Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 204
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
TranslationStore
0.00% covered (danger)
0.00%
0 / 204
0.00% covered (danger)
0.00%
0 / 14
1056
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
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 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 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 / 19
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 / 23
0.00% covered (danger)
0.00%
0 / 1
42
 insertTranslation
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 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 / 13
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace ContentTranslation\Store;
4
5use ContentTranslation\LoadBalancer;
6use ContentTranslation\Service\UserService;
7use ContentTranslation\Translation;
8use DateTime;
9use MediaWiki\User\UserIdentity;
10use Wikimedia\Rdbms\Platform\ISQLPlatform;
11use Wikimedia\Rdbms\SelectQueryBuilder;
12
13class TranslationStore {
14    public const TRANSLATION_TABLE_NAME = 'cx_translations';
15    public const TRANSLATOR_TABLE_NAME = 'cx_translators';
16
17    public const TRANSLATION_STATUS_DRAFT = 'draft';
18    public const TRANSLATION_STATUS_PUBLISHED = 'published';
19    public const TRANSLATION_STATUS_DELETED = 'deleted';
20
21    private LoadBalancer $lb;
22    private UserService $userService;
23
24    public function __construct( LoadBalancer $lb, UserService $userService ) {
25        $this->lb = $lb;
26        $this->userService = $userService;
27    }
28
29    public function unlinkTranslationFromTranslator( int $translationId ) {
30        $dbw = $this->lb->getConnection( DB_PRIMARY );
31
32        $dbw->newDeleteQueryBuilder()
33            ->deleteFrom( self::TRANSLATOR_TABLE_NAME )
34            ->where( [ 'translator_translation_id' => $translationId ] )
35            ->caller( __METHOD__ )
36            ->execute();
37    }
38
39    public function deleteTranslation( int $translationId ) {
40        $dbw = $this->lb->getConnection( DB_PRIMARY );
41
42        $dbw->newUpdateQueryBuilder()
43            ->update( self::TRANSLATION_TABLE_NAME )
44            ->set( [ 'translation_status' => self::TRANSLATION_STATUS_DELETED ] )
45            ->where( [ 'translation_id' => $translationId ] )
46            ->caller( __METHOD__ )
47            ->execute();
48    }
49
50    /**
51     * This method finds a translation inside "cx_translations" table, that corresponds to the
52     * given source/target languages, source title and the translator of the published
53     * translation, and returns it. If no such translation exists, the method returns null.
54     *
55     * There can only ever be one translation, returned by this method.
56     *
57     * @param UserIdentity $user
58     * @param string $sourceTitle
59     * @param string $sourceLanguage
60     * @param string $targetLanguage
61     * @param string|null $status possible status values: "published"|"draft"|"deleted"
62     * @return Translation|null
63     */
64    public function findTranslationByUser(
65        UserIdentity $user,
66        string $sourceTitle,
67        string $sourceLanguage,
68        string $targetLanguage,
69        string $status = null
70    ): ?Translation {
71        $dbr = $this->lb->getConnection( DB_REPLICA );
72        $globalUserId = $this->userService->getGlobalUserId( $user );
73
74        $conditions = [
75            'translation_source_language' => $sourceLanguage,
76            'translation_target_language' => $targetLanguage,
77            'translation_source_title' => $sourceTitle,
78            'translation_started_by' => $globalUserId,
79            'translation_last_update_by' => $globalUserId,
80        ];
81
82        if ( $status ) {
83            $conditions['translation_status'] = $status;
84        }
85        $row = $dbr->newSelectQueryBuilder()
86            ->select( ISQLPlatform::ALL_ROWS )
87            ->from( self::TRANSLATION_TABLE_NAME )
88            ->where( $conditions )
89            ->caller( __METHOD__ )
90            ->fetchRow();
91
92        return $row ? Translation::newFromRow( $row ) : null;
93    }
94
95    /**
96     * Given a user id, this method returns the last published translation for that translator,
97     * which have been started within the last 10 minutes. If no published translation within
98     * the last 10 minutes, null is returned.
99     */
100    public function findRecentTranslationByUser( int $userId ): ?Translation {
101        $dbr = $this->lb->getConnection( DB_REPLICA );
102
103        $conditions = [
104            'translation_started_by' => $userId,
105            // Only fetch translations within 10 last minutes
106            // Translations older than 10 minutes, are not considered recent here
107            $dbr->expr( 'translation_start_timestamp', '>=', $dbr->timestamp( time() - ( 10 * 60 ) ) ),
108            // target URL is always not null for articles that have been published at some point
109            $dbr->expr( 'translation_target_url', '!=', null ),
110        ];
111
112        $row = $dbr->newSelectQueryBuilder()
113            ->select( ISQLPlatform::ALL_ROWS )
114            ->from( self::TRANSLATION_TABLE_NAME )
115            ->where( $conditions )
116            ->orderBy( 'translation_start_timestamp', SelectQueryBuilder::SORT_DESC )
117            ->limit( 1 )
118            ->caller( __METHOD__ )
119            ->fetchRow();
120
121        return $row ? Translation::newFromRow( $row ) : null;
122    }
123
124    /**
125     * This method finds a translation inside "cx_translations" table, that corresponds to the
126     * given id and the translator (user) of the translation, and returns it. If no such translation
127     * exists, the method returns null.
128     *
129     * @param UserIdentity $user
130     * @param int $id
131     * @return Translation|null
132     * @throws \Exception
133     */
134    public function findByUserAndId( UserIdentity $user, int $id ): ?Translation {
135        $dbr = $this->lb->getConnection( DB_REPLICA );
136        $globalUserId = $this->userService->getGlobalUserId( $user );
137
138        $row = $dbr->newSelectQueryBuilder()
139            ->select( ISQLPlatform::ALL_ROWS )
140            ->from( self::TRANSLATION_TABLE_NAME )
141            ->where( [
142                'translation_id' => $id,
143                'translation_started_by' => $globalUserId,
144                'translation_last_update_by' => $globalUserId,
145            ] )
146            ->caller( __METHOD__ )
147            ->fetchRow();
148
149        return $row ? Translation::newFromRow( $row ) : null;
150    }
151
152    /**
153     * Find a published translation for a given target title and language
154     *
155     * @param string $publishedTitle
156     * @param string $targetLanguage
157     * @return Translation|null
158     */
159    public function findByPublishedTitle( string $publishedTitle, string $targetLanguage ): ?Translation {
160        $dbr = $this->lb->getConnection( DB_REPLICA );
161
162        $isPublishedCondition = $dbr->makeList(
163            [
164                'translation_status' => self::TRANSLATION_STATUS_PUBLISHED,
165                'translation_target_url IS NOT NULL',
166            ],
167            LIST_OR
168        );
169
170        $row = $dbr->newSelectQueryBuilder()
171            ->select( ISQLPlatform::ALL_ROWS )
172            ->from( self::TRANSLATION_TABLE_NAME )
173            ->where( [
174                'translation_target_language' => $targetLanguage,
175                'translation_target_title' => $publishedTitle,
176                $isPublishedCondition
177            ] )
178            ->caller( __METHOD__ )
179            ->fetchRow();
180
181        return $row ? Translation::newFromRow( $row ) : null;
182    }
183
184    /**
185     * Given a source title, a source language and a target language,
186     * find the oldest matching translation.
187     *
188     * @param string $sourceTitle
189     * @param string $sourceLanguage
190     * @param string $targetLanguage
191     * @return Translation|null
192     */
193    public function findTranslationByTitle(
194        string $sourceTitle,
195        string $sourceLanguage,
196        string $targetLanguage
197    ): ?Translation {
198        $dbr = $this->lb->getConnection( DB_REPLICA );
199
200        $row = $dbr->newSelectQueryBuilder()
201            ->select( ISQLPlatform::ALL_ROWS )
202            ->from( self::TRANSLATION_TABLE_NAME )
203            ->where( [
204                'translation_source_language' => $sourceLanguage,
205                'translation_target_language' => $targetLanguage,
206                'translation_source_title' => $sourceTitle
207            ] )
208            ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_ASC )
209            ->limit( 1 )
210            ->caller( __METHOD__ )
211            ->fetchRow();
212
213        return $row ? Translation::newFromRow( $row ) : null;
214    }
215
216    /**
217     * Given an array of source titles, a source language and a target language,
218     * find all matching translations.
219     *
220     * @param string[] $titles
221     * @param string $sourceLanguage
222     * @param string $targetLanguage
223     * @return Translation[]
224     */
225    public function findTranslationsByTitles( array $titles, string $sourceLanguage, string $targetLanguage ): array {
226        $dbr = $this->lb->getConnection( DB_REPLICA );
227
228        $resultSet = $dbr->newSelectQueryBuilder()
229            ->select( ISQLPlatform::ALL_ROWS )
230            ->from( self::TRANSLATION_TABLE_NAME )
231            ->where( [
232                'translation_source_language' => $sourceLanguage,
233                'translation_target_language' => $targetLanguage,
234                'translation_source_title' => $titles
235            ] )
236            ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_ASC )
237            ->caller( __METHOD__ )
238            ->fetchResultSet();
239
240        $result = [];
241        foreach ( $resultSet as $row ) {
242            $result[] = Translation::newFromRow( $row );
243        }
244
245        return $result;
246    }
247
248    /**
249     * Given a source title, a source language and a target language, find all conflicting translations.
250     * Conflicting translations are translations in progress ("draft") for same language pair and source
251     * page in last 24 hours.
252     *
253     * Here we assume that the caller already checked that no draft for the user already exists.
254     *
255     * @param string $title
256     * @param string $sourceLang
257     * @param string $targetLang
258     * @return Translation[]
259     * @throws \Exception
260     */
261    public function findConflictingDraftTranslations( string $title, string $sourceLang, string $targetLang ): array {
262        $translations = $this->findTranslationsByTitles( [ $title ], $sourceLang, $targetLang );
263
264        $conflicts = array_filter( $translations, static function ( Translation $translation ) {
265            $isDraft = $translation->getData()['status'] === self::TRANSLATION_STATUS_DRAFT;
266
267            // filter out non-draft translations
268            if ( !$isDraft ) {
269                return false;
270            }
271
272            $lastUpdateTime = new DateTime( $translation->getData()['lastUpdateTimestamp'] );
273
274            // Only keep translations that have been updated in the last 24 hours
275            return (bool)$lastUpdateTime->diff( new DateTime( '-24 hours' ) )->invert;
276        } );
277
278        return array_values( $conflicts );
279    }
280
281    /**
282     * @param int $userId
283     * @param int $limit How many results to return
284     * @param string|null $offset Offset condition (timestamp)
285     * @param string|null $type
286     * @param string|null $from
287     * @param string|null $to
288     * @return Translation[]
289     */
290    public function getAllTranslationsByUserId(
291        int $userId,
292        int $limit,
293        ?string $offset = null,
294        ?string $type = null,
295        ?string $from = null,
296        ?string $to = null
297    ): array {
298        // Note: there is no index on translation_last_updated_timestamp
299        $dbr = $this->lb->getConnection( DB_REPLICA );
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            $ts = $dbr->addQuotes( $dbr->timestamp( $offset ) );
314            $whereConditions[] = "translation_last_updated_timestamp < $ts";
315        }
316
317        $resultSet = $dbr->newSelectQueryBuilder()
318            ->select( ISQLPlatform::ALL_ROWS )
319            ->from( self::TRANSLATION_TABLE_NAME )
320            ->where( $whereConditions )
321            ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_DESC )
322            ->limit( $limit )
323            ->caller( __METHOD__ )
324            ->fetchResultSet();
325
326        $result = [];
327        foreach ( $resultSet as $row ) {
328            $result[] = Translation::newFromRow( $row );
329        }
330
331        return $result;
332    }
333
334    public function insertTranslation( Translation $translation, UserIdentity $user ): void {
335        $dbw = $this->lb->getConnection( DB_PRIMARY );
336
337        $row = [
338            'translation_source_title' => $translation->translation['sourceTitle'],
339            'translation_target_title' => $translation->translation['targetTitle'],
340            'translation_source_language' => $translation->translation['sourceLanguage'],
341            'translation_target_language' => $translation->translation['targetLanguage'],
342            'translation_source_revision_id' => $translation->translation['sourceRevisionId'],
343            'translation_source_url' => $translation->translation['sourceURL'],
344            'translation_status' => $translation->translation['status'],
345            'translation_progress' => $translation->translation['progress'],
346            'translation_last_updated_timestamp' => $dbw->timestamp(),
347            'translation_last_update_by' => $this->userService->getGlobalUserId( $user ),
348            'translation_start_timestamp' => $dbw->timestamp(),
349            'translation_started_by' => $this->userService->getGlobalUserId( $user ),
350            'translation_cx_version' => $translation->translation['cxVersion'],
351        ];
352
353        if ( $translation->translation['status'] === self::TRANSLATION_STATUS_PUBLISHED ) {
354            $row['translation_target_url'] = $translation->translation['targetURL'];
355            $row['translation_target_revision_id'] = $translation->translation['targetRevisionId'];
356        }
357
358        $dbw->newInsertQueryBuilder()
359            ->insertInto( self::TRANSLATION_TABLE_NAME )
360            ->row( $row )
361            ->caller( __METHOD__ )
362            ->execute();
363
364        $translation->translation['id'] = (int)$dbw->insertId();
365        $translation->setIsNew( true );
366    }
367
368    public function updateTranslation( Translation $translation, array $options = [] ): void {
369        $dbw = $this->lb->getConnection( DB_PRIMARY );
370
371        $set = [
372            'translation_target_title' => $translation->translation['targetTitle'],
373            'translation_source_revision_id' => $translation->translation['sourceRevisionId'],
374            'translation_source_url' => $translation->translation['sourceURL'],
375            'translation_status' => $translation->translation['status'],
376            'translation_last_updated_timestamp' => $dbw->timestamp(),
377            'translation_progress' => $translation->translation['progress'],
378            'translation_cx_version' => $translation->translation['cxVersion'],
379        ];
380
381        if ( $translation->translation['status'] === self::TRANSLATION_STATUS_PUBLISHED ) {
382            $set['translation_target_url'] = $translation->translation['targetURL'];
383            $set['translation_target_revision_id'] = $translation->translation['targetRevisionId'];
384        }
385
386        $isFreshTranslation = $options['freshTranslation'] ?? false;
387        if ( $isFreshTranslation ) {
388            $set['translation_start_timestamp'] = $dbw->timestamp();
389        }
390
391        $dbw->newUpdateQueryBuilder()
392            ->update( self::TRANSLATION_TABLE_NAME )
393            ->set( $set )
394            ->where( [ 'translation_id' => $translation->getTranslationId() ] )
395            ->caller( __METHOD__ )
396            ->execute();
397
398        $translation->setIsNew( false );
399    }
400
401    /**
402     * A convenient abstraction of create and update methods. Checks if
403     * translation exists and chooses either of create or update actions.
404     *
405     * @param Translation $translation
406     * @param UserIdentity $user
407     */
408    public function saveTranslation( Translation $translation, UserIdentity $user ): void {
409        $existingTranslation = $this->findTranslationByUser(
410            $user,
411            $translation->getSourceTitle(),
412            $translation->getSourceLanguage(),
413            $translation->getTargetLanguage()
414        );
415
416        if ( $existingTranslation === null ) {
417            $this->insertTranslation( $translation, $user );
418        } else {
419            $options = [];
420            if ( $existingTranslation->translation['status'] === self::TRANSLATION_STATUS_DELETED ) {
421                // Existing translation is deleted, so this is a fresh start of same
422                // language pair and source title.
423                $options['freshTranslation'] = true;
424            }
425            $translation->translation['id'] = $existingTranslation->getTranslationId();
426            $this->updateTranslation( $translation, $options );
427        }
428    }
429}