Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 196
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 / 196
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 / 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 / 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        $row = $dbr->newSelectQueryBuilder()
163            ->select( ISQLPlatform::ALL_ROWS )
164            ->from( self::TRANSLATION_TABLE_NAME )
165            ->where( [
166                'translation_target_language' => $targetLanguage,
167                'translation_target_title' => $publishedTitle,
168                Translation::getPublishedCondition( $dbr ),
169            ] )
170            ->caller( __METHOD__ )
171            ->fetchRow();
172
173        return $row ? Translation::newFromRow( $row ) : null;
174    }
175
176    /**
177     * Given a source title, a source language and a target language,
178     * find the oldest matching translation.
179     *
180     * @param string $sourceTitle
181     * @param string $sourceLanguage
182     * @param string $targetLanguage
183     * @return Translation|null
184     */
185    public function findTranslationByTitle(
186        string $sourceTitle,
187        string $sourceLanguage,
188        string $targetLanguage
189    ): ?Translation {
190        $dbr = $this->lb->getConnection( DB_REPLICA );
191
192        $row = $dbr->newSelectQueryBuilder()
193            ->select( ISQLPlatform::ALL_ROWS )
194            ->from( self::TRANSLATION_TABLE_NAME )
195            ->where( [
196                'translation_source_language' => $sourceLanguage,
197                'translation_target_language' => $targetLanguage,
198                'translation_source_title' => $sourceTitle
199            ] )
200            ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_ASC )
201            ->limit( 1 )
202            ->caller( __METHOD__ )
203            ->fetchRow();
204
205        return $row ? Translation::newFromRow( $row ) : null;
206    }
207
208    /**
209     * Given an array of source titles, a source language and a target language,
210     * find all matching translations.
211     *
212     * @param string[] $titles
213     * @param string $sourceLanguage
214     * @param string $targetLanguage
215     * @return Translation[]
216     */
217    public function findTranslationsByTitles( array $titles, string $sourceLanguage, string $targetLanguage ): array {
218        $dbr = $this->lb->getConnection( DB_REPLICA );
219
220        $resultSet = $dbr->newSelectQueryBuilder()
221            ->select( ISQLPlatform::ALL_ROWS )
222            ->from( self::TRANSLATION_TABLE_NAME )
223            ->where( [
224                'translation_source_language' => $sourceLanguage,
225                'translation_target_language' => $targetLanguage,
226                'translation_source_title' => $titles
227            ] )
228            ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_ASC )
229            ->caller( __METHOD__ )
230            ->fetchResultSet();
231
232        $result = [];
233        foreach ( $resultSet as $row ) {
234            $result[] = Translation::newFromRow( $row );
235        }
236
237        return $result;
238    }
239
240    /**
241     * Given a source title, a source language and a target language, find all conflicting translations.
242     * Conflicting translations are translations in progress ("draft") for same language pair and source
243     * page in last 24 hours.
244     *
245     * Here we assume that the caller already checked that no draft for the user already exists.
246     *
247     * @param string $title
248     * @param string $sourceLang
249     * @param string $targetLang
250     * @return Translation[]
251     * @throws \Exception
252     */
253    public function findConflictingDraftTranslations( string $title, string $sourceLang, string $targetLang ): array {
254        $translations = $this->findTranslationsByTitles( [ $title ], $sourceLang, $targetLang );
255
256        $conflicts = array_filter( $translations, static function ( Translation $translation ) {
257            $isDraft = $translation->getData()['status'] === self::TRANSLATION_STATUS_DRAFT;
258
259            // filter out non-draft translations
260            if ( !$isDraft ) {
261                return false;
262            }
263
264            $lastUpdateTime = new DateTime( $translation->getData()['lastUpdateTimestamp'] );
265
266            // Only keep translations that have been updated in the last 24 hours
267            return (bool)$lastUpdateTime->diff( new DateTime( '-24 hours' ) )->invert;
268        } );
269
270        return array_values( $conflicts );
271    }
272
273    /**
274     * @param int $userId
275     * @param int $limit How many results to return
276     * @param string|null $offset Offset condition (timestamp)
277     * @param string|null $type
278     * @param string|null $from
279     * @param string|null $to
280     * @return Translation[]
281     */
282    public function getAllTranslationsByUserId(
283        int $userId,
284        int $limit,
285        ?string $offset = null,
286        ?string $type = null,
287        ?string $from = null,
288        ?string $to = null
289    ): array {
290        // Note: there is no index on translation_last_updated_timestamp
291        $dbr = $this->lb->getConnection( DB_REPLICA );
292
293        $whereConditions = [ 'translation_started_by' => $userId ];
294
295        if ( $type !== null ) {
296            $whereConditions['translation_status'] = $type;
297        }
298        if ( $from !== null ) {
299            $whereConditions['translation_source_language'] = $from;
300        }
301        if ( $to !== null ) {
302            $whereConditions['translation_target_language'] = $to;
303        }
304        if ( $offset !== null ) {
305            $whereConditions[] = $dbr->expr( 'translation_last_updated_timestamp', '<', $dbr->timestamp( $offset ) );
306        }
307
308        $resultSet = $dbr->newSelectQueryBuilder()
309            ->select( ISQLPlatform::ALL_ROWS )
310            ->from( self::TRANSLATION_TABLE_NAME )
311            ->where( $whereConditions )
312            ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_DESC )
313            ->limit( $limit )
314            ->caller( __METHOD__ )
315            ->fetchResultSet();
316
317        $result = [];
318        foreach ( $resultSet as $row ) {
319            $result[] = Translation::newFromRow( $row );
320        }
321
322        return $result;
323    }
324
325    public function insertTranslation( Translation $translation, UserIdentity $user ): void {
326        $dbw = $this->lb->getConnection( DB_PRIMARY );
327
328        $row = [
329            'translation_source_title' => $translation->translation['sourceTitle'],
330            'translation_target_title' => $translation->translation['targetTitle'],
331            'translation_source_language' => $translation->translation['sourceLanguage'],
332            'translation_target_language' => $translation->translation['targetLanguage'],
333            'translation_source_revision_id' => $translation->translation['sourceRevisionId'],
334            'translation_source_url' => $translation->translation['sourceURL'],
335            'translation_status' => $translation->translation['status'],
336            'translation_progress' => $translation->translation['progress'],
337            'translation_last_updated_timestamp' => $dbw->timestamp(),
338            'translation_last_update_by' => $this->userService->getGlobalUserId( $user ),
339            'translation_start_timestamp' => $dbw->timestamp(),
340            'translation_started_by' => $this->userService->getGlobalUserId( $user ),
341            'translation_cx_version' => $translation->translation['cxVersion'],
342        ];
343
344        if ( $translation->translation['status'] === self::TRANSLATION_STATUS_PUBLISHED ) {
345            $row['translation_target_url'] = $translation->translation['targetURL'];
346            $row['translation_target_revision_id'] = $translation->translation['targetRevisionId'];
347        }
348
349        $dbw->newInsertQueryBuilder()
350            ->insertInto( self::TRANSLATION_TABLE_NAME )
351            ->row( $row )
352            ->caller( __METHOD__ )
353            ->execute();
354
355        $translation->translation['id'] = (int)$dbw->insertId();
356        $translation->setIsNew( true );
357    }
358
359    public function updateTranslation( Translation $translation, array $options = [] ): void {
360        $dbw = $this->lb->getConnection( DB_PRIMARY );
361
362        $set = [
363            'translation_target_title' => $translation->translation['targetTitle'],
364            'translation_source_revision_id' => $translation->translation['sourceRevisionId'],
365            'translation_source_url' => $translation->translation['sourceURL'],
366            'translation_status' => $translation->translation['status'],
367            'translation_last_updated_timestamp' => $dbw->timestamp(),
368            'translation_progress' => $translation->translation['progress'],
369            'translation_cx_version' => $translation->translation['cxVersion'],
370        ];
371
372        if ( $translation->translation['status'] === self::TRANSLATION_STATUS_PUBLISHED ) {
373            $set['translation_target_url'] = $translation->translation['targetURL'];
374            $set['translation_target_revision_id'] = $translation->translation['targetRevisionId'];
375        }
376
377        $isFreshTranslation = $options['freshTranslation'] ?? false;
378        if ( $isFreshTranslation ) {
379            $set['translation_start_timestamp'] = $dbw->timestamp();
380        }
381
382        $dbw->newUpdateQueryBuilder()
383            ->update( self::TRANSLATION_TABLE_NAME )
384            ->set( $set )
385            ->where( [ 'translation_id' => $translation->getTranslationId() ] )
386            ->caller( __METHOD__ )
387            ->execute();
388
389        $translation->setIsNew( false );
390    }
391
392    /**
393     * A convenient abstraction of create and update methods. Checks if
394     * translation exists and chooses either of create or update actions.
395     *
396     * @param Translation $translation
397     * @param UserIdentity $user
398     */
399    public function saveTranslation( Translation $translation, UserIdentity $user ): void {
400        $existingTranslation = $this->findTranslationByUser(
401            $user,
402            $translation->getSourceTitle(),
403            $translation->getSourceLanguage(),
404            $translation->getTargetLanguage()
405        );
406
407        if ( $existingTranslation === null ) {
408            $this->insertTranslation( $translation, $user );
409        } else {
410            $options = [];
411            if ( $existingTranslation->translation['status'] === self::TRANSLATION_STATUS_DELETED ) {
412                // Existing translation is deleted, so this is a fresh start of same
413                // language pair and source title.
414                $options['freshTranslation'] = true;
415            }
416            $translation->translation['id'] = $existingTranslation->getTranslationId();
417            $this->updateTranslation( $translation, $options );
418        }
419    }
420}