Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 182
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 / 182
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 / 23
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            $whereConditions[] = $dbr->orExpr( [
194                'translation_status' => $status,
195                'cxsx_translation_status' => $statusIndex
196            ] );
197        }
198        if ( $from !== null ) {
199            $whereConditions['translation_source_language'] = $from;
200        }
201        if ( $to !== null ) {
202            $whereConditions['translation_target_language'] = $to;
203        }
204        if ( $offset !== null ) {
205            $whereConditions[] = $dbr->expr( 'translation_last_updated_timestamp', '<', $dbr->timestamp( $offset ) );
206        }
207
208        return $dbr->newSelectQueryBuilder()
209            ->select( ISQLPlatform::ALL_ROWS )
210            ->from( TranslationStore::TRANSLATION_TABLE_NAME )
211            ->leftJoin( self::TABLE_NAME, null, 'translation_id = cxsx_translation_id' )
212            ->where( $whereConditions )
213            ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_DESC )
214            ->limit( $limit )
215            ->caller( __METHOD__ )
216            ->fetchResultSet();
217    }
218
219    /**
220     * @param int $userId User ID
221     * @param string|null $from
222     * @param string|null $to
223     * @param int $limit How many results to return. Defaults to 100, same as for the "contenttranslation" list API
224     * @param string|null $offset Offset condition (timestamp)
225     * @return DraftTranslationDTO[]
226     */
227    public function findDraftSectionTranslationsByUser(
228        int $userId,
229        ?string $from = null,
230        ?string $to = null,
231        int $limit = 100,
232        ?string $offset = null
233    ): array {
234        $resultSet = $this->doFindTranslationsByUser(
235            $userId,
236            $from,
237            $to,
238            self::TRANSLATION_STATUS_DRAFT,
239            $limit,
240            $offset
241        );
242
243        $result = [];
244        foreach ( $resultSet as $row ) {
245            $result[] = new DraftTranslationDTO(
246                $row->cxsx_id ? (int)$row->cxsx_id : null,
247                (int)$row->translation_id,
248                $row->cxsx_section_id ?? null,
249                $row->translation_source_title,
250                $row->translation_source_language,
251                $row->translation_target_language,
252                $row->translation_start_timestamp,
253                $row->translation_last_updated_timestamp,
254                self::TRANSLATION_STATUSES[ $row->cxsx_translation_status ] ?? $row->translation_status,
255                $row->translation_source_revision_id,
256                $row->cxsx_translation_progress ?? $row->translation_progress,
257                $row->translation_target_title,
258                $row->cxsx_source_section_title,
259                $row->cxsx_target_section_title,
260            );
261        }
262
263        return $result;
264    }
265
266    /**
267     * @param int $userId User ID
268     * @param string|null $from
269     * @param string|null $to
270     * @param int $limit How many results to return. Defaults to 100, same as for the "contenttranslation" list API
271     * @param string|null $offset Offset condition (timestamp)
272     * @return PublishedTranslationDTO[]
273     */
274    public function findPublishedSectionTranslationsByUser(
275        int $userId,
276        ?string $from = null,
277        ?string $to = null,
278        int $limit = 100,
279        ?string $offset = null
280    ): array {
281        $resultSet = $this->doFindTranslationsByUser(
282            $userId,
283            $from,
284            $to,
285            self::TRANSLATION_STATUS_PUBLISHED,
286            $limit,
287            $offset
288        );
289
290        /**
291         * An array of PublishedSectionTranslationDTO objects grouped by translation id
292         * @var PublishedSectionTranslationDTO[] $publishedSectionTranslations
293         */
294        $publishedSectionTranslations = [];
295        $publishedStatusIndex = self::getStatusIndexByStatus( self::TRANSLATION_STATUS_PUBLISHED );
296
297        foreach ( $resultSet as $row ) {
298            $translationId = (int)$row->translation_id;
299            if ( $row->cxsx_id && $row->cxsx_translation_status === $publishedStatusIndex ) {
300                $publishedSectionTranslations[$translationId] = new PublishedSectionTranslationDTO(
301                    (int)$row->cxsx_id,
302                    $row->cxsx_section_id,
303                    $row->cxsx_translation_start_timestamp,
304                    $row->cxsx_translation_last_updated_timestamp,
305                    $row->cxsx_source_section_title,
306                    $row->cxsx_target_section_title,
307                );
308            }
309        }
310
311        $result = [];
312        foreach ( $resultSet as $row ) {
313            $translationId = (int)$row->translation_id;
314            // multiple rows can exist for the same translation id
315            // we only need one DTO per translation
316            if ( isset( $result[$translationId] ) ) {
317                continue;
318            }
319            $result[$translationId] = new PublishedTranslationDTO(
320                $translationId,
321                $row->translation_source_title,
322                $row->translation_source_language,
323                $row->translation_target_language,
324                $row->translation_start_timestamp,
325                $row->translation_last_updated_timestamp,
326                $row->translation_source_revision_id,
327                $row->translation_target_title,
328                $row->translation_target_url,
329                $publishedSectionTranslations[$translationId] ?? []
330            );
331        }
332
333        return array_values( $result );
334    }
335
336    /**
337     * Given the "parent" translation id and the section id
338     * (in the "${revision}_${sectionNumber} form), this method
339     * deletes the corresponding section translation from the
340     * "cx_section_translations" table.
341     *
342     * @param int $sectionTranslationId
343     * @return void
344     */
345    public function deleteTranslationById( int $sectionTranslationId ): void {
346        $dbw = $this->lb->getConnection( DB_PRIMARY );
347
348        $deletedStatusIndex = self::getStatusIndexByStatus( self::TRANSLATION_STATUS_DELETED );
349        $dbw->newUpdateQueryBuilder()
350            ->update( self::TABLE_NAME )
351            ->set( [ 'cxsx_translation_status' => $deletedStatusIndex ] )
352            ->where( [ 'cxsx_id' => $sectionTranslationId ] )
353            ->caller( __METHOD__ )
354            ->execute();
355    }
356
357    private function translationToDBRow( SectionTranslation $translation ): array {
358        return [
359            'cxsx_translation_id' => $translation->getTranslationId(),
360            'cxsx_section_id' => $translation->getSectionId(),
361            'cxsx_source_section_title' => $translation->getSourceSectionTitle(),
362            'cxsx_target_section_title' => $translation->getTargetSectionTitle(),
363            'cxsx_translation_status' => $translation->getTranslationStatus(),
364            'cxsx_translation_progress' => $translation->getProgress(),
365        ];
366    }
367}