Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 296
0.00% covered (danger)
0.00%
0 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
TranslationStore
0.00% covered (danger)
0.00%
0 / 296
0.00% covered (danger)
0.00%
0 / 17
2352
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unlinkTranslationFromTranslator
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 deleteTranslation
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 findTranslationByUser
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 findRecentTranslationByUser
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 findByUserAndId
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 findByPublishedTitle
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 findTranslationByTitle
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 findTranslationsByTitles
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 findConflictingDraftTranslations
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getAllTranslationsByUserId
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 insertTranslation
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 updateTranslation
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 saveTranslation
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 getAllPublishedTranslations
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
12
 getPublishedCondition
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getTrendByStatus
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2
3namespace ContentTranslation\Store;
4
5use ContentTranslation\DateManipulator;
6use ContentTranslation\Exception\TranslationSaveException;
7use ContentTranslation\Service\UserService;
8use ContentTranslation\Translation;
9use DateTime;
10use MediaWiki\User\UserIdentity;
11use Wikimedia\Rdbms\IConnectionProvider;
12use Wikimedia\Rdbms\IReadableDatabase;
13use Wikimedia\Rdbms\OrExpressionGroup;
14use Wikimedia\Rdbms\Platform\ISQLPlatform;
15use Wikimedia\Rdbms\SelectQueryBuilder;
16
17class TranslationStore {
18    public const TRANSLATION_TABLE_NAME = 'cx_translations';
19    public const TRANSLATOR_TABLE_NAME = 'cx_translators';
20
21    public const TRANSLATION_STATUS_DRAFT = 'draft';
22    public const TRANSLATION_STATUS_PUBLISHED = 'published';
23    public const TRANSLATION_STATUS_DELETED = 'deleted';
24
25    public function __construct(
26        private readonly IConnectionProvider $connectionProvider,
27        private readonly UserService $userService
28    ) {
29    }
30
31    public function unlinkTranslationFromTranslator( int $translationId ) {
32        $dbw = $this->connectionProvider->getPrimaryDatabase();
33
34        $dbw->newDeleteQueryBuilder()
35            ->deleteFrom( self::TRANSLATOR_TABLE_NAME )
36            ->where( [ 'translator_translation_id' => $translationId ] )
37            ->caller( __METHOD__ )
38            ->execute();
39    }
40
41    public function deleteTranslation( int $translationId ) {
42        $dbw = $this->connectionProvider->getPrimaryDatabase();
43
44        $dbw->newUpdateQueryBuilder()
45            ->update( self::TRANSLATION_TABLE_NAME )
46            ->set( [ 'translation_status' => self::TRANSLATION_STATUS_DELETED ] )
47            ->where( [ 'translation_id' => $translationId ] )
48            ->caller( __METHOD__ )
49            ->execute();
50    }
51
52    /**
53     * This method finds a translation inside "cx_translations" table, that corresponds to the
54     * given source/target languages, source title and the translator of the published
55     * translation, and returns it. If no such translation exists, the method returns null.
56     *
57     * There can only ever be one translation, returned by this method.
58     *
59     * @param UserIdentity $user
60     * @param string $sourceTitle
61     * @param string $sourceLanguage
62     * @param string $targetLanguage
63     * @param string|null $status possible status values: "published"|"draft"|"deleted"
64     * @param int $dbType
65     * @return Translation|null
66     */
67    public function findTranslationByUser(
68        UserIdentity $user,
69        string $sourceTitle,
70        string $sourceLanguage,
71        string $targetLanguage,
72        ?string $status = null,
73        int $dbType = DB_REPLICA
74    ): ?Translation {
75        if ( $dbType === DB_REPLICA ) {
76            $dbr = $this->connectionProvider->getReplicaDatabase();
77        } else {
78            $dbr = $this->connectionProvider->getPrimaryDatabase();
79        }
80
81        $globalUserId = $this->userService->getGlobalUserId( $user );
82
83        $conditions = [
84            'translation_source_language' => $sourceLanguage,
85            'translation_target_language' => $targetLanguage,
86            'translation_source_title' => $sourceTitle,
87            'translation_started_by' => $globalUserId,
88            'translation_last_update_by' => $globalUserId,
89        ];
90
91        if ( $status ) {
92            $conditions['translation_status'] = $status;
93        }
94        $row = $dbr->newSelectQueryBuilder()
95            ->select( ISQLPlatform::ALL_ROWS )
96            ->from( self::TRANSLATION_TABLE_NAME )
97            ->where( $conditions )
98            ->caller( __METHOD__ )
99            ->fetchRow();
100
101        return $row ? Translation::newFromRow( $row ) : null;
102    }
103
104    /**
105     * Given a user id, this method returns the last published translation for that translator,
106     * which have been started within the last 10 minutes. If no published translation within
107     * the last 10 minutes, null is returned.
108     */
109    public function findRecentTranslationByUser( int $userId ): ?Translation {
110        $dbr = $this->connectionProvider->getReplicaDatabase();
111
112        $conditions = [
113            'translation_started_by' => $userId,
114            // Only fetch translations within 10 last minutes
115            // Translations older than 10 minutes, are not considered recent here
116            $dbr->expr( 'translation_start_timestamp', '>=', $dbr->timestamp( time() - ( 10 * 60 ) ) ),
117            // target URL is always not null for articles that have been published at some point
118            $dbr->expr( 'translation_target_url', '!=', null ),
119        ];
120
121        $row = $dbr->newSelectQueryBuilder()
122            ->select( ISQLPlatform::ALL_ROWS )
123            ->from( self::TRANSLATION_TABLE_NAME )
124            ->where( $conditions )
125            ->orderBy( 'translation_start_timestamp', SelectQueryBuilder::SORT_DESC )
126            ->limit( 1 )
127            ->caller( __METHOD__ )
128            ->fetchRow();
129
130        return $row ? Translation::newFromRow( $row ) : null;
131    }
132
133    /**
134     * This method finds a translation inside "cx_translations" table, that corresponds to the
135     * given id and the translator (user) of the translation, and returns it. If no such translation
136     * exists, the method returns null.
137     *
138     * @param UserIdentity $user
139     * @param int $id
140     * @return Translation|null
141     * @throws \Exception
142     */
143    public function findByUserAndId( UserIdentity $user, int $id ): ?Translation {
144        $dbr = $this->connectionProvider->getReplicaDatabase();
145        $globalUserId = $this->userService->getGlobalUserId( $user );
146
147        $row = $dbr->newSelectQueryBuilder()
148            ->select( ISQLPlatform::ALL_ROWS )
149            ->from( self::TRANSLATION_TABLE_NAME )
150            ->where( [
151                'translation_id' => $id,
152                'translation_started_by' => $globalUserId,
153                'translation_last_update_by' => $globalUserId,
154            ] )
155            ->caller( __METHOD__ )
156            ->fetchRow();
157
158        return $row ? Translation::newFromRow( $row ) : null;
159    }
160
161    /**
162     * Find a published translation for a given target title and language
163     *
164     * @param string $publishedTitle
165     * @param string $targetLanguage
166     * @return Translation|null
167     */
168    public function findByPublishedTitle( string $publishedTitle, string $targetLanguage ): ?Translation {
169        $dbr = $this->connectionProvider->getReplicaDatabase();
170
171        $row = $dbr->newSelectQueryBuilder()
172            ->select( ISQLPlatform::ALL_ROWS )
173            ->from( self::TRANSLATION_TABLE_NAME )
174            ->where( [
175                'translation_target_language' => $targetLanguage,
176                'translation_target_title' => $publishedTitle,
177                self::getPublishedCondition( $dbr ),
178            ] )
179            ->caller( __METHOD__ )
180            ->fetchRow();
181
182        return $row ? Translation::newFromRow( $row ) : null;
183    }
184
185    /**
186     * Given a source title, a source language and a target language,
187     * find the oldest matching translation.
188     *
189     * @param string $sourceTitle
190     * @param string $sourceLanguage
191     * @param string $targetLanguage
192     * @return Translation|null
193     */
194    public function findTranslationByTitle(
195        string $sourceTitle,
196        string $sourceLanguage,
197        string $targetLanguage
198    ): ?Translation {
199        $dbr = $this->connectionProvider->getReplicaDatabase();
200
201        $row = $dbr->newSelectQueryBuilder()
202            ->select( ISQLPlatform::ALL_ROWS )
203            ->from( self::TRANSLATION_TABLE_NAME )
204            ->where( [
205                'translation_source_language' => $sourceLanguage,
206                'translation_target_language' => $targetLanguage,
207                'translation_source_title' => $sourceTitle
208            ] )
209            ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_ASC )
210            ->limit( 1 )
211            ->caller( __METHOD__ )
212            ->fetchRow();
213
214        return $row ? Translation::newFromRow( $row ) : null;
215    }
216
217    /**
218     * Given an array of source titles, a source language and a target language,
219     * find all matching translations.
220     *
221     * @param string[] $titles
222     * @param string $sourceLanguage
223     * @param string $targetLanguage
224     * @return Translation[]
225     */
226    public function findTranslationsByTitles( array $titles, string $sourceLanguage, string $targetLanguage ): array {
227        $dbr = $this->connectionProvider->getReplicaDatabase();
228
229        $resultSet = $dbr->newSelectQueryBuilder()
230            ->select( ISQLPlatform::ALL_ROWS )
231            ->from( self::TRANSLATION_TABLE_NAME )
232            ->where( [
233                'translation_source_language' => $sourceLanguage,
234                'translation_target_language' => $targetLanguage,
235                'translation_source_title' => $titles
236            ] )
237            ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_ASC )
238            ->caller( __METHOD__ )
239            ->fetchResultSet();
240
241        $result = [];
242        foreach ( $resultSet as $row ) {
243            $result[] = Translation::newFromRow( $row );
244        }
245
246        return $result;
247    }
248
249    /**
250     * Given a source title, a source language and a target language, find all conflicting translations.
251     * Conflicting translations are translations in progress ("draft") for same language pair and source
252     * page in last 24 hours.
253     *
254     * Here we assume that the caller already checked that no draft for the user already exists.
255     *
256     * @param string $title
257     * @param string $sourceLang
258     * @param string $targetLang
259     * @return Translation[]
260     * @throws \Exception
261     */
262    public function findConflictingDraftTranslations( string $title, string $sourceLang, string $targetLang ): array {
263        $translations = $this->findTranslationsByTitles( [ $title ], $sourceLang, $targetLang );
264
265        $conflicts = array_filter( $translations, static function ( Translation $translation ) {
266            $isDraft = $translation->getData()['status'] === self::TRANSLATION_STATUS_DRAFT;
267
268            // filter out non-draft translations
269            if ( !$isDraft ) {
270                return false;
271            }
272
273            $lastUpdateTime = new DateTime( $translation->getData()['lastUpdateTimestamp'] );
274
275            // Only keep translations that have been updated in the last 24 hours
276            return (bool)$lastUpdateTime->diff( new DateTime( '-24 hours' ) )->invert;
277        } );
278
279        return array_values( $conflicts );
280    }
281
282    /**
283     * @param int $userId
284     * @param int $limit How many results to return
285     * @param string|null $offset Offset condition (timestamp)
286     * @param string|null $type
287     * @param string|null $from
288     * @param string|null $to
289     * @return Translation[]
290     */
291    public function getAllTranslationsByUserId(
292        int $userId,
293        int $limit,
294        ?string $offset = null,
295        ?string $type = null,
296        ?string $from = null,
297        ?string $to = null
298    ): array {
299        $dbr = $this->connectionProvider->getReplicaDatabase();
300
301        $whereConditions = [ 'translation_started_by' => $userId ];
302
303        if ( $type !== null ) {
304            $whereConditions['translation_status'] = $type;
305        }
306        if ( $from !== null ) {
307            $whereConditions['translation_source_language'] = $from;
308        }
309        if ( $to !== null ) {
310            $whereConditions['translation_target_language'] = $to;
311        }
312        if ( $offset !== null ) {
313            $whereConditions[] = $dbr->expr( 'translation_last_updated_timestamp', '<', $dbr->timestamp( $offset ) );
314        }
315
316        $resultSet = $dbr->newSelectQueryBuilder()
317            ->select( ISQLPlatform::ALL_ROWS )
318            ->from( self::TRANSLATION_TABLE_NAME )
319            ->where( $whereConditions )
320            ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_DESC )
321            ->limit( $limit )
322            ->caller( __METHOD__ )
323            ->fetchResultSet();
324
325        $result = [];
326        foreach ( $resultSet as $row ) {
327            $result[] = Translation::newFromRow( $row );
328        }
329
330        return $result;
331    }
332
333    public function insertTranslation( Translation $translation, UserIdentity $user ): bool {
334        $dbw = $this->connectionProvider->getPrimaryDatabase();
335
336        $row = [
337            'translation_source_title' => $translation->translation['sourceTitle'],
338            'translation_target_title' => $translation->translation['targetTitle'],
339            'translation_source_language' => $translation->translation['sourceLanguage'],
340            'translation_target_language' => $translation->translation['targetLanguage'],
341            'translation_source_revision_id' => $translation->translation['sourceRevisionId'],
342            'translation_source_url' => $translation->translation['sourceURL'],
343            'translation_status' => $translation->translation['status'],
344            'translation_progress' => $translation->translation['progress'],
345            'translation_last_updated_timestamp' => $dbw->timestamp(),
346            'translation_last_update_by' => $this->userService->getGlobalUserId( $user ),
347            'translation_start_timestamp' => $dbw->timestamp(),
348            'translation_started_by' => $this->userService->getGlobalUserId( $user ),
349            'translation_cx_version' => $translation->translation['cxVersion'],
350        ];
351
352        if ( $translation->translation['status'] === self::TRANSLATION_STATUS_PUBLISHED ) {
353            $row['translation_target_url'] = $translation->translation['targetURL'];
354            $row['translation_target_revision_id'] = $translation->translation['targetRevisionId'];
355        }
356
357        $dbw->newInsertQueryBuilder()
358            ->insertInto( self::TRANSLATION_TABLE_NAME )
359            ->row( $row )
360            ->ignore()
361            ->caller( __METHOD__ )
362            ->execute();
363
364        $affectedRows = $dbw->affectedRows();
365        if ( $affectedRows > 0 ) {
366            $translation->translation['id'] = $dbw->insertId();
367            $translation->setIsNew( true );
368        }
369
370        return $affectedRows > 0;
371    }
372
373    public function updateTranslation( Translation $translation, array $options = [] ): void {
374        $dbw = $this->connectionProvider->getPrimaryDatabase();
375
376        $set = [
377            'translation_target_title' => $translation->translation['targetTitle'],
378            'translation_source_revision_id' => $translation->translation['sourceRevisionId'],
379            'translation_source_url' => $translation->translation['sourceURL'],
380            'translation_status' => $translation->translation['status'],
381            'translation_last_updated_timestamp' => $dbw->timestamp(),
382            'translation_progress' => $translation->translation['progress'],
383            'translation_cx_version' => $translation->translation['cxVersion'],
384        ];
385
386        if ( $translation->translation['status'] === self::TRANSLATION_STATUS_PUBLISHED ) {
387            $set['translation_target_url'] = $translation->translation['targetURL'];
388            $set['translation_target_revision_id'] = $translation->translation['targetRevisionId'];
389        }
390
391        $isFreshTranslation = $options['freshTranslation'] ?? false;
392        if ( $isFreshTranslation ) {
393            $set['translation_start_timestamp'] = $dbw->timestamp();
394        }
395
396        $dbw->newUpdateQueryBuilder()
397            ->update( self::TRANSLATION_TABLE_NAME )
398            ->set( $set )
399            ->where( [ 'translation_id' => $translation->getTranslationId() ] )
400            ->caller( __METHOD__ )
401            ->execute();
402
403        $translation->setIsNew( false );
404    }
405
406    /**
407     * A convenient abstraction of create and update methods. Checks if
408     * translation exists and chooses either of create or update actions.
409     *
410     * @param Translation $translation
411     * @param UserIdentity $user
412     * @throws TranslationSaveException
413     */
414    public function saveTranslation( Translation $translation, UserIdentity $user ): void {
415        $existingTranslation = $this->findTranslationByUser(
416            $user,
417            $translation->getSourceTitle(),
418            $translation->getSourceLanguage(),
419            $translation->getTargetLanguage()
420        );
421
422        if ( $existingTranslation === null ) {
423            $rowInserted = $this->insertTranslation( $translation, $user );
424            if ( $rowInserted ) {
425                return;
426            }
427
428            // Nothing was inserted, the row probably exists. Let's try fetching it with DB_PRIMARY
429            $existingTranslation = $this->findTranslationByUser(
430                $user,
431                $translation->getSourceTitle(),
432                $translation->getSourceLanguage(),
433                $translation->getTargetLanguage(),
434                null,
435                DB_PRIMARY
436            );
437        }
438
439        if ( !$existingTranslation ) {
440            // We've tried enough, lets give up.
441            throw new TranslationSaveException(
442                'Translation save failed: could not insert a new translation or locate an existing one.'
443            );
444        }
445
446        /**
447         * TODO: The existing translation that we fetched might or might not be the newer one.
448         * To fix this CX could send some kind of "revision numbers" that lets us know which version
449         * should be saved.
450         */
451
452        $options = [];
453        if ( $existingTranslation->translation['status'] === self::TRANSLATION_STATUS_DELETED ) {
454            // Existing translation is deleted, so this is a fresh start of same
455            // language pair and source title.
456            $options['freshTranslation'] = true;
457        }
458        $translation->translation['id'] = $existingTranslation->getTranslationId();
459        $this->updateTranslation( $translation, $options );
460    }
461
462    /**
463     * Get all published translation records.
464     *
465     * @param string $from Source language code
466     * @param string $to Target language code
467     * @param int $limit Number of records to fetch at most
468     * @param int $offset Offset from which at most $limit records to fetch
469     * @return array
470     */
471    public function getAllPublishedTranslations( string $from, string $to, int $limit, int $offset ): array {
472        $dbr = $this->connectionProvider->getReplicaDatabase();
473        $conditions = [
474            'translation_source_language' => $from,
475            'translation_target_language' => $to,
476        ];
477        $conditions[] = self::getPublishedCondition( $dbr );
478
479        $queryBuilder = $dbr->newSelectQueryBuilder()
480            ->select( [
481                'translationId' => 'translation_id',
482                'sourceTitle' => 'translation_source_title',
483                'targetTitle' => 'translation_target_title',
484                'sourceLanguage' => 'translation_source_language',
485                'sourceRevisionId' => 'translation_source_revision_id',
486                'targetRevisionId' => 'translation_target_revision_id',
487                'targetLanguage' => 'translation_target_language',
488                'sourceURL' => 'translation_source_url',
489                'targetURL' => 'translation_target_url',
490                'publishedDate' => 'translation_last_updated_timestamp',
491                'stats' => 'translation_progress',
492            ] )
493            ->from( 'cx_translations' )
494            ->where( $conditions )
495            ->limit( $limit )
496            ->caller( __METHOD__ );
497
498        if ( $offset ) {
499            $queryBuilder->offset( $offset );
500        }
501
502        $rows = $queryBuilder->fetchResultSet();
503
504        $result = [];
505
506        foreach ( $rows as $row ) {
507            $translation = (array)$row;
508            $translation['stats'] = json_decode( $translation['stats'] );
509            $result[] = $translation;
510        }
511
512        return $result;
513    }
514
515    public static function getPublishedCondition( IReadableDatabase $db ): OrExpressionGroup {
516        return $db
517            ->expr( 'translation_status', '=', 'published' )
518            ->or( 'translation_target_url', '!=', null );
519    }
520
521    /**
522     * Get time-wise cumulative number of translations for given
523     * language pairs, with given interval.
524     *
525     * @param string|null $source Source language code
526     * @param string|null $target Target language code
527     * @param string $status Status of translation. Either 'published' or 'draft'
528     * @param string $interval 'weekly' or 'monthly' trend
529     * @param int|null $translatorId
530     * @return array
531     */
532    public function getTrendByStatus(
533        ?string $source,
534        ?string $target,
535        string $status,
536        string $interval,
537        ?int $translatorId
538    ): array {
539        $dbr = $this->connectionProvider->getReplicaDatabase();
540
541        $conditions = [];
542        if ( $status === 'published' ) {
543            $conditions[] = self::getPublishedCondition( $dbr );
544        } else {
545            $conditions[] = $dbr->andExpr( [
546                'translation_status' => 'draft',
547                'translation_target_url' => null,
548            ] );
549        }
550
551        if ( $source !== null ) {
552            $conditions['translation_source_language'] = $source;
553        }
554        if ( $target !== null ) {
555            $conditions['translation_target_language'] = $target;
556        }
557        if ( $translatorId !== null ) {
558            $conditions['translation_last_update_by'] = $translatorId;
559        }
560        $groupBy = [];
561        if ( $interval === 'week' ) {
562            $groupBy = [
563                'YEARWEEK(translation_last_updated_timestamp, 3)',
564            ];
565        } elseif ( $interval === 'month' ) {
566            $groupBy = [
567                'YEAR(translation_last_updated_timestamp)',
568                'MONTH(translation_last_updated_timestamp)',
569            ];
570        }
571
572        $rows = $dbr->newSelectQueryBuilder()
573            ->select( [
574                'date' => 'MAX(translation_last_updated_timestamp)',
575                'count' => 'COUNT(translation_id)'
576            ] )
577            ->from( 'cx_translations' )
578            ->where( $conditions )
579            ->groupBy( $groupBy )
580            ->caller( __METHOD__ )
581            ->fetchResultSet();
582
583        $count = 0;
584        $result = [];
585        $dm = new DateManipulator( $interval );
586        foreach ( $rows as $row ) {
587            $count += (int)$row->count;
588            $time = $dm->getIntervalIdentifier( $row->date )->format( 'U' );
589            $result[$time] = [
590                'count' => $count,
591                'delta' => (int)$row->count,
592            ];
593        }
594
595        return $result;
596    }
597}