Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 187
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
SectionTranslationStore
0.00% covered (danger)
0.00%
0 / 187
0.00% covered (danger)
0.00%
0 / 14
992
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
 insertTranslation
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 insertMultipleTranslations
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 updateTranslation
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 updateTranslationStatusById
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 findTranslation
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 findTranslationBySectionTitle
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 createTranslationFromRow
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getStatusIndexByStatus
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 doFindTranslationsByUser
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
30
 findDraftSectionTranslationsByUser
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
12
 findPublishedSectionTranslationsByUser
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
42
 deleteTranslationById
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 translationToDBRow
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare( strict_types = 1 );
4
5namespace ContentTranslation\Store;
6
7use ContentTranslation\DTO\DraftTranslationDTO;
8use ContentTranslation\DTO\PublishedSectionTranslationDTO;
9use ContentTranslation\DTO\PublishedTranslationDTO;
10use ContentTranslation\Entity\SectionTranslation;
11use ContentTranslation\LoadBalancer;
12use InvalidArgumentException;
13use Wikimedia\Rdbms\IDatabase;
14use Wikimedia\Rdbms\IResultWrapper;
15use Wikimedia\Rdbms\Platform\ISQLPlatform;
16use Wikimedia\Rdbms\SelectQueryBuilder;
17
18class SectionTranslationStore {
19    public const TABLE_NAME = 'cx_section_translations';
20    public const TRANSLATION_STATUS_DRAFT = 'draft';
21    public const TRANSLATION_STATUS_PUBLISHED = 'published';
22    public const TRANSLATION_STATUS_DELETED = 'deleted';
23
24    /**
25     * This constant contains the mappings of translation statuses to integers,
26     * that are used as values for "cxsx_translation_status" field inside the database.
27     * This constant should NOT be changed or reordered.
28     */
29    public const TRANSLATION_STATUSES = [
30        0 => self::TRANSLATION_STATUS_DRAFT,
31        1 => self::TRANSLATION_STATUS_PUBLISHED,
32        2 => self::TRANSLATION_STATUS_DELETED,
33    ];
34
35    /** @var LoadBalancer */
36    private $lb;
37
38    public function __construct( LoadBalancer $loadBalancer ) {
39        $this->lb = $loadBalancer;
40    }
41
42    public function insertTranslation( SectionTranslation $translation ) {
43        $dbw = $this->lb->getConnection( DB_PRIMARY );
44        $values = $this->translationToDBRow( $translation );
45        // set start/last_updated timestamps to current timestamp
46        $values['cxsx_translation_start_timestamp'] = $dbw->timestamp();
47        $values['cxsx_translation_last_updated_timestamp'] = $dbw->timestamp();
48
49        $dbw->newInsertQueryBuilder()
50            ->insertInto( self::TABLE_NAME )
51            ->row( $values )
52            ->caller( __METHOD__ )
53            ->execute();
54        $translation->setId( $dbw->insertId() );
55    }
56
57    /**
58     * @param SectionTranslation[] $translations
59     * @return void
60     */
61    public function insertMultipleTranslations( array $translations ): void {
62        if ( !$translations ) {
63            return;
64        }
65
66        $dbw = $this->lb->getConnection( DB_PRIMARY );
67        $rows = [];
68        foreach ( $translations as $translation ) {
69            $values = $this->translationToDBRow( $translation );
70            // set start/last_updated timestamps to current timestamp
71            $values['cxsx_translation_start_timestamp'] = $dbw->timestamp();
72            $values['cxsx_translation_last_updated_timestamp'] = $dbw->timestamp();
73            $rows[] = $values;
74        }
75
76        $dbw->newInsertQueryBuilder()
77            ->insertInto( self::TABLE_NAME )
78            ->rows( $rows )
79            ->caller( __METHOD__ )
80            ->execute();
81    }
82
83    public function updateTranslation( SectionTranslation $translation ) {
84        $dbw = $this->lb->getConnection( DB_PRIMARY );
85        $values = $this->translationToDBRow( $translation );
86        $values['cxsx_translation_last_updated_timestamp'] = $dbw->timestamp();
87
88        $dbw->newUpdateQueryBuilder()
89            ->update( self::TABLE_NAME )
90            ->set( $values )
91            ->where( [ 'cxsx_id' => $translation->getId() ] )
92            ->caller( __METHOD__ )
93            ->execute();
94    }
95
96    /**
97     * @param int|null $id The "cxsx_id" of the section translation row
98     * @param int|null $translationStatus Either "deleted", "draft" or "published"
99     */
100    public function updateTranslationStatusById( ?int $id, ?int $translationStatus ) {
101        $dbw = $this->lb->getConnection( DB_PRIMARY );
102        $dbw->newUpdateQueryBuilder()
103            ->update( self::TABLE_NAME )
104            ->set( [ 'cxsx_translation_status' => $translationStatus ] )
105            ->where( [ 'cxsx_id' => $id ] )
106            ->caller( __METHOD__ )
107            ->execute();
108    }
109
110    public function findTranslation( int $translationId, string $sectionId ): ?SectionTranslation {
111        $dbr = $this->lb->getConnection( DB_REPLICA );
112
113        $row = $dbr->newSelectQueryBuilder()
114            ->select( IDatabase::ALL_ROWS )
115            ->from( self::TABLE_NAME )
116            ->where( [
117                'cxsx_translation_id' => $translationId,
118                'cxsx_section_id' => $sectionId
119            ] )
120            ->caller( __METHOD__ )
121            ->fetchRow();
122        return $row ? $this->createTranslationFromRow( $row ) : null;
123    }
124
125    /**
126     * @param int $translationId The id of the parent translation (from "cx_translations" table)
127     * @param string $sectionTitle The source section title
128     * @return SectionTranslation|null
129     */
130    public function findTranslationBySectionTitle( int $translationId, string $sectionTitle ): ?SectionTranslation {
131        $dbr = $this->lb->getConnection( DB_REPLICA );
132
133        $row = $dbr->newSelectQueryBuilder()
134            ->select( ISQLPlatform::ALL_ROWS )
135            ->from( self::TABLE_NAME )
136            ->where( [
137                'cxsx_translation_id' => $translationId,
138                'cxsx_source_section_title' => $sectionTitle
139            ] )
140            ->limit( 1 )
141            ->caller( __METHOD__ )
142            ->fetchRow();
143
144        return $row ? $this->createTranslationFromRow( $row ) : null;
145    }
146
147    public function createTranslationFromRow( \stdClass $row ): SectionTranslation {
148        return new SectionTranslation(
149            (int)$row->cxsx_id,
150            (int)$row->cxsx_translation_id,
151            $row->cxsx_section_id,
152            $row->cxsx_source_section_title,
153            $row->cxsx_target_section_title,
154            $row->cxsx_translation_status ? (int)$row->cxsx_translation_status : null,
155            $row->cxsx_translation_progress,
156        );
157    }
158
159    public static function getStatusIndexByStatus( string $status ) {
160        $index = array_search( $status, self::TRANSLATION_STATUSES );
161
162        if ( $index === false ) {
163            throw new InvalidArgumentException( '[CX] Invalid status provided' );
164        }
165
166        return $index;
167    }
168
169    /**
170     * @param int $userId User ID
171     * @param string|null $from
172     * @param string|null $to
173     * @param string|null $status The status of the translation. Either "draft" or "published"
174     * @param int $limit How many results to return. Defaults to 100, same as for the "contenttranslation" list API
175     * @param string|null $offset Offset condition (timestamp)
176     * @return IResultWrapper
177     */
178    private function doFindTranslationsByUser(
179        int $userId,
180        string $from = null,
181        string $to = null,
182        string $status = null,
183        int $limit = 100,
184        string $offset = null
185    ): IResultWrapper {
186        // Note: there is no index on translation_last_updated_timestamp
187        $dbr = $this->lb->getConnection( DB_REPLICA );
188
189        $whereConditions = [ 'translation_started_by' => $userId ];
190
191        if ( $status !== null ) {
192            $statusIndex = self::getStatusIndexByStatus( $status );
193            $isPublishedCondition = $dbr->makeList(
194                [
195                    'translation_status' => $status,
196                    'cxsx_translation_status' => $statusIndex
197                ],
198                LIST_OR
199            );
200            $whereConditions[] = $isPublishedCondition;
201        }
202        if ( $from !== null ) {
203            $whereConditions['translation_source_language'] = $from;
204        }
205        if ( $to !== null ) {
206            $whereConditions['translation_target_language'] = $to;
207        }
208        if ( $offset !== null ) {
209            $ts = $dbr->addQuotes( $dbr->timestamp( $offset ) );
210            $whereConditions[] = "translation_last_updated_timestamp < $ts";
211        }
212
213        return $dbr->newSelectQueryBuilder()
214            ->select( ISQLPlatform::ALL_ROWS )
215            ->from( TranslationStore::TRANSLATION_TABLE_NAME )
216            ->leftJoin( self::TABLE_NAME, null, 'translation_id = cxsx_translation_id' )
217            ->where( $whereConditions )
218            ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_DESC )
219            ->limit( $limit )
220            ->caller( __METHOD__ )
221            ->fetchResultSet();
222    }
223
224    /**
225     * @param int $userId User ID
226     * @param string|null $from
227     * @param string|null $to
228     * @param int $limit How many results to return. Defaults to 100, same as for the "contenttranslation" list API
229     * @param string|null $offset Offset condition (timestamp)
230     * @return DraftTranslationDTO[]
231     */
232    public function findDraftSectionTranslationsByUser(
233        int $userId,
234        string $from = null,
235        string $to = null,
236        int $limit = 100,
237        string $offset = null
238    ): array {
239        $resultSet = $this->doFindTranslationsByUser(
240            $userId,
241            $from,
242            $to,
243            self::TRANSLATION_STATUS_DRAFT,
244            $limit,
245            $offset
246        );
247
248        $result = [];
249        foreach ( $resultSet as $row ) {
250            $result[] = new DraftTranslationDTO(
251                $row->cxsx_id ? (int)$row->cxsx_id : null,
252                (int)$row->translation_id,
253                $row->cxsx_section_id ?? null,
254                $row->translation_source_title,
255                $row->translation_source_language,
256                $row->translation_target_language,
257                $row->translation_start_timestamp,
258                $row->translation_last_updated_timestamp,
259                self::TRANSLATION_STATUSES[ $row->cxsx_translation_status ] ?? $row->translation_status,
260                $row->translation_source_revision_id,
261                $row->cxsx_translation_progress ?? $row->translation_progress,
262                $row->translation_target_title,
263                $row->cxsx_source_section_title,
264                $row->cxsx_target_section_title,
265            );
266        }
267
268        return $result;
269    }
270
271    /**
272     * @param int $userId User ID
273     * @param string|null $from
274     * @param string|null $to
275     * @param int $limit How many results to return. Defaults to 100, same as for the "contenttranslation" list API
276     * @param string|null $offset Offset condition (timestamp)
277     * @return PublishedTranslationDTO[]
278     */
279    public function findPublishedSectionTranslationsByUser(
280        int $userId,
281        string $from = null,
282        string $to = null,
283        int $limit = 100,
284        string $offset = null
285    ): array {
286        $resultSet = $this->doFindTranslationsByUser(
287            $userId,
288            $from,
289            $to,
290            self::TRANSLATION_STATUS_PUBLISHED,
291            $limit,
292            $offset
293        );
294
295        /**
296         * An array of PublishedSectionTranslationDTO objects grouped by translation id
297         * @var PublishedSectionTranslationDTO[] $publishedSectionTranslations
298         */
299        $publishedSectionTranslations = [];
300        $publishedStatusIndex = self::getStatusIndexByStatus( self::TRANSLATION_STATUS_PUBLISHED );
301
302        foreach ( $resultSet as $row ) {
303            $translationId = (int)$row->translation_id;
304            if ( $row->cxsx_id && $row->cxsx_translation_status === $publishedStatusIndex ) {
305                $publishedSectionTranslations[$translationId] = new PublishedSectionTranslationDTO(
306                    (int)$row->cxsx_id,
307                    $row->cxsx_section_id,
308                    $row->cxsx_translation_start_timestamp,
309                    $row->cxsx_translation_last_updated_timestamp,
310                    $row->cxsx_source_section_title,
311                    $row->cxsx_target_section_title,
312                );
313            }
314        }
315
316        $result = [];
317        foreach ( $resultSet as $row ) {
318            $translationId = (int)$row->translation_id;
319            // multiple rows can exist for the same translation id
320            // we only need one DTO per translation
321            if ( isset( $result[$translationId] ) ) {
322                continue;
323            }
324            $result[$translationId] = new PublishedTranslationDTO(
325                $translationId,
326                $row->translation_source_title,
327                $row->translation_source_language,
328                $row->translation_target_language,
329                $row->translation_start_timestamp,
330                $row->translation_last_updated_timestamp,
331                $row->translation_source_revision_id,
332                $row->translation_target_title,
333                $row->translation_target_url,
334                $publishedSectionTranslations[$translationId] ?? []
335            );
336        }
337
338        return array_values( $result );
339    }
340
341    /**
342     * Given the "parent" translation id and the section id
343     * (in the "${revision}_${sectionNumber} form), this method
344     * deletes the corresponding section translation from the
345     * "cx_section_translations" table.
346     *
347     * @param int $sectionTranslationId
348     * @return void
349     */
350    public function deleteTranslationById( int $sectionTranslationId ): void {
351        $dbw = $this->lb->getConnection( DB_PRIMARY );
352
353        $deletedStatusIndex = self::getStatusIndexByStatus( self::TRANSLATION_STATUS_DELETED );
354        $dbw->newUpdateQueryBuilder()
355            ->update( self::TABLE_NAME )
356            ->set( [ 'cxsx_translation_status' => $deletedStatusIndex ] )
357            ->where( [ 'cxsx_id' => $sectionTranslationId ] )
358            ->caller( __METHOD__ )
359            ->execute();
360    }
361
362    private function translationToDBRow( SectionTranslation $translation ): array {
363        return [
364            'cxsx_translation_id' => $translation->getTranslationId(),
365            'cxsx_section_id' => $translation->getSectionId(),
366            'cxsx_source_section_title' => $translation->getSourceSectionTitle(),
367            'cxsx_target_section_title' => $translation->getTargetSectionTitle(),
368            'cxsx_translation_status' => $translation->getTranslationStatus(),
369            'cxsx_translation_progress' => $translation->getProgress(),
370        ];
371    }
372}