Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 116
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
RecentSignificantEditStore
0.00% covered (danger)
0.00%
0 / 116
0.00% covered (danger)
0.00%
0 / 10
240
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 isCurrentWikiFamilySupported
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 insert
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 update
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 deleteOldEditsByUser
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 findEditsByUser
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 findExistingEdit
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 findEditsForPotentialSuggestions
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
6
 createEditFromRow
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 normalizeEdit
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare( strict_types=1 );
4
5namespace ContentTranslation\Store;
6
7use ContentTranslation\Entity\RecentSignificantEdit;
8use ContentTranslation\LoadBalancer;
9use Wikimedia\Rdbms\IDatabase;
10use Wikimedia\Rdbms\SelectQueryBuilder;
11
12class RecentSignificantEditStore {
13    // Array of supported wiki families for the 'recent significant edit' entrypoint
14    // The order of the entries should never change in production, as the indexes of this array
15    // are used to populate the "cxse_wiki_family" field inside "cx_significant_edits" table.
16    private const SUPPORTED_WIKI_FAMILIES = [
17        'wikipedia',
18    ];
19    private const TABLE_NAME = 'cx_significant_edits';
20    private const DEFAULT_WIKI_FAMILY = 'wikipedia';
21
22    /**
23     * The index of the wiki family of the current wiki inside SUPPORTED_WIKI_FAMILIES array.
24     * Used to populate "cxse_wiki_family" field inside "cx_significant_edits" table.
25     *
26     * @var int|false
27     */
28    private $currentWikiFamilyKey;
29
30    /** @var LoadBalancer */
31    private $lb;
32
33    public function __construct( LoadBalancer $lb, ?string $currentWikiFamily ) {
34        $this->lb = $lb;
35        $currentWikiFamily ??= self::DEFAULT_WIKI_FAMILY;
36        $this->currentWikiFamilyKey = array_search( $currentWikiFamily, self::SUPPORTED_WIKI_FAMILIES );
37    }
38
39    public function isCurrentWikiFamilySupported(): bool {
40        return $this->currentWikiFamilyKey !== false;
41    }
42
43    /**
44     * Given a RecentSignificantEdit instance, this method
45     * stores its fields, as a row inside the table.
46     * To make sure that at most 10 edits per user will exist,
47     * this method keeps only the 10 newest edits and deletes the rest.
48     *
49     * @param RecentSignificantEdit $edit
50     * @return int
51     */
52    public function insert( RecentSignificantEdit $edit ): int {
53        $values = $this->normalizeEdit( $edit );
54        $primaryDb = $this->lb->getConnection( DB_PRIMARY );
55        $values['cxse_timestamp'] = $primaryDb->timestamp();
56
57        $primaryDb->newInsertQueryBuilder()
58            ->insertInto( self::TABLE_NAME )
59            ->row( $values )
60            ->caller( __METHOD__ )
61            ->execute();
62        $newId = $primaryDb->insertId();
63        $edit->setId( $newId );
64        // Keep only the 10 newest edits and delete the rest
65        $this->deleteOldEditsByUser( $edit->getUserId() );
66        return $newId;
67    }
68
69    /**
70     * Given a RecentSignificantEdit instance with a valid id,
71     * this method updates the corresponding row inside the database,
72     * with the current values of the model and an updated edit
73     * timestamp.
74     *
75     * @param RecentSignificantEdit $edit
76     */
77    public function update( RecentSignificantEdit $edit ): void {
78        if ( $edit->getId() === null ) {
79            return;
80        }
81        $primaryDb = $this->lb->getConnection( DB_PRIMARY );
82
83        $values = $this->normalizeEdit( $edit );
84        $values['cxse_timestamp'] = $primaryDb->timestamp();
85
86        $primaryDb->newUpdateQueryBuilder()
87            ->update( self::TABLE_NAME )
88            ->set( $values )
89            ->where( [ 'cxse_id' => $edit->getId() ] )
90            ->caller( __METHOD__ )
91            ->execute();
92    }
93
94    /**
95     * Given a user id, this method deletes all the edits done by
96     * this user, that are older than the 10 first edits. This way
97     * we ensure that the "cx_significant_edits" table will not
98     * store more than 10 edits per user. If less than 10 edits exist,
99     * it returns without doing anything.
100     *
101     * @param int $userId
102     */
103    private function deleteOldEditsByUser( int $userId ) {
104        $edits = $this->findEditsByUser( $userId );
105        if ( count( $edits ) <= 10 ) {
106            return;
107        }
108
109        $editIdsToDelete = array_map( static function ( RecentSignificantEdit $edit ) {
110            return $edit->getId();
111        }, array_slice( $edits, 10 ) );
112
113        $primaryDb = $this->lb->getConnection( DB_PRIMARY );
114        $primaryDb->newDeleteQueryBuilder()
115            ->deleteFrom( self::TABLE_NAME )
116            ->where( [ 'cxse_id' => $editIdsToDelete ] )
117            ->caller( __METHOD__ )
118            ->execute();
119    }
120
121    /**
122     * Returns an array of RecentSignificantEdit instances, containing all
123     * the recent significant edits done by the given user and the current
124     * wiki family, ordered by their edit timestamp, in ascending order (i.e. oldest first).
125     *
126     * @param int $userId
127     * @return RecentSignificantEdit[]
128     */
129    public function findEditsByUser( int $userId ): array {
130        $replicaDb = $this->lb->getConnection( DB_REPLICA );
131        $result = $replicaDb->newSelectQueryBuilder()
132            ->select( IDatabase::ALL_ROWS )
133            ->from( self::TABLE_NAME )
134            ->where( [
135                'cxse_global_user_id' => $userId,
136                'cxse_wiki_family' => $this->currentWikiFamilyKey
137            ] )
138            ->orderBy( 'cxse_timestamp' )
139            ->caller( __METHOD__ )
140            ->fetchResultSet();
141
142        $edits = [];
143        foreach ( $result as $row ) {
144            $edits[] = $this->createEditFromRow( $row );
145        }
146        return $edits;
147    }
148
149    /**
150     * Given a user id, a page wikidata id and a language, this method
151     * searches for a recent significant edit, matching these arguments,
152     * an instance of RecentSignificantEdit class if exists.
153     *
154     * @param int $userId
155     * @param int $pageWikidataId
156     * @param string $language
157     * @return RecentSignificantEdit|null
158     */
159    public function findExistingEdit( int $userId, int $pageWikidataId, string $language ): ?RecentSignificantEdit {
160        $replicaDb = $this->lb->getConnection( DB_REPLICA );
161        $row = $replicaDb->newSelectQueryBuilder()
162            ->select( IDatabase::ALL_ROWS )
163            ->from( self::TABLE_NAME )
164            ->where( [
165                'cxse_global_user_id' => $userId,
166                'cxse_page_wikidata_id' => $pageWikidataId,
167                'cxse_language' => $language,
168                'cxse_wiki_family' => $this->currentWikiFamilyKey
169            ] )
170            ->caller( __METHOD__ )
171            ->fetchRow();
172
173        if ( !$row ) {
174            return null;
175        }
176
177        return $this->createEditFromRow( $row );
178    }
179
180    /**
181     * Given a user id, a page wikidata id and a language code,
182     * this static method returns an array containing at most
183     * 10 instances of this class. These objects represent
184     * edits stored inside the table that:
185     * 1. were done by the given user,
186     * 2. were done to an article with the given wikidata page id
187     * 3. were done in a language different that the given one
188     *
189     * @param int $userId
190     * @param int $wikidataId
191     * @param string $language
192     * @return RecentSignificantEdit[]
193     */
194    public function findEditsForPotentialSuggestions( int $userId, int $wikidataId, string $language ): array {
195        $conditions = [
196            'cxse_global_user_id' => $userId,
197            'cxse_page_wikidata_id' => $wikidataId,
198            "cxse_language != '$language'",
199            "cxse_wiki_family" => $this->currentWikiFamilyKey
200        ];
201
202        $replicaDb = $this->lb->getConnection( DB_REPLICA );
203        $result = $replicaDb->newSelectQueryBuilder()
204            ->select( [
205                'cxse_id',
206                'cxse_global_user_id',
207                'cxse_page_wikidata_id',
208                'cxse_language',
209                'cxse_wiki_family',
210                'cxse_page_title',
211                'cxse_section_titles',
212                'cxse_timestamp'
213            ] )
214            ->from( self::TABLE_NAME )
215            ->where( $conditions )
216            ->orderBy( 'cxse_timestamp', SelectQueryBuilder::SORT_DESC )
217            ->limit( 10 )
218            ->caller( __METHOD__ )
219            ->fetchResultSet();
220
221        $edits = [];
222        foreach ( $result as $row ) {
223            $edits[] = $this->createEditFromRow( $row );
224        }
225        return $edits;
226    }
227
228    /**
229     * Given an stdClass instance, that represents
230     * a row from "cx_significant_edits" table, this
231     * method creates a RecentSignificantEdit object
232     * and returns it.
233     *
234     * @param \stdClass $row
235     * @return RecentSignificantEdit
236     */
237    private function createEditFromRow( \stdClass $row ): RecentSignificantEdit {
238        // json_decode should always return an array and not an \stdClass instance. Although we do not
239        // store associative arrays, we somehow get errors for this field. These errors can also occur
240        // because of empty arrays being encoded as "{}", instead of [] by "json_encode" function. However,
241        // we do not store any significant edit in the database when section titles are an empty array.
242        // In any case, here we are making sure that section titles are always an indexed array, to avoid
243        // such errors being thrown.
244        // TODO: Investigate what value causes the error (https://phabricator.wikimedia.org/T319799) and
245        // how end up with this unexpected value
246        $sectionTitles = array_values( json_decode( $row->cxse_section_titles, true ) );
247
248        return new RecentSignificantEdit(
249            (int)$row->cxse_id,
250            (int)$row->cxse_global_user_id,
251            (int)$row->cxse_page_wikidata_id,
252            $row->cxse_language,
253            $row->cxse_page_title,
254            $sectionTitles,
255            $row->cxse_timestamp
256        );
257    }
258
259    /**
260     * Given a RecentSignificantEdit model, this method
261     * returns an array containing the corresponding
262     * fields and values, to be stored in the database.
263     *
264     * @param RecentSignificantEdit $edit
265     * @return array
266     */
267    private function normalizeEdit( RecentSignificantEdit $edit ): array {
268        return [
269            'cxse_global_user_id' => $edit->getUserId(),
270            'cxse_page_wikidata_id' => $edit->getPageWikidataId(),
271            'cxse_language' => $edit->getLanguage(),
272            'cxse_wiki_family' => $this->currentWikiFamilyKey,
273            'cxse_page_title' => $edit->getPageTitle(),
274            'cxse_section_titles' => json_encode( $edit->getSectionTitles() ),
275            'cxse_timestamp' => $edit->getTimestamp(),
276        ];
277    }
278
279}