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