Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 116 |
|
0.00% |
0 / 10 |
CRAP | |
0.00% |
0 / 1 |
RecentSignificantEditStore | |
0.00% |
0 / 116 |
|
0.00% |
0 / 10 |
240 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
isCurrentWikiFamilySupported | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
insert | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
2 | |||
update | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
deleteOldEditsByUser | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
findEditsByUser | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
6 | |||
findExistingEdit | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
6 | |||
findEditsForPotentialSuggestions | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
6 | |||
createEditFromRow | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
normalizeEdit | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | declare( strict_types=1 ); |
4 | |
5 | namespace ContentTranslation\Store; |
6 | |
7 | use ContentTranslation\Entity\RecentSignificantEdit; |
8 | use ContentTranslation\LoadBalancer; |
9 | use Wikimedia\Rdbms\IDatabase; |
10 | use Wikimedia\Rdbms\SelectQueryBuilder; |
11 | |
12 | class 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 | $replicaDb = $this->lb->getConnection( DB_REPLICA ); |
196 | $conditions = [ |
197 | 'cxse_global_user_id' => $userId, |
198 | 'cxse_page_wikidata_id' => $wikidataId, |
199 | $replicaDb->expr( 'cxse_language', '!=', $language ), |
200 | "cxse_wiki_family" => $this->currentWikiFamilyKey |
201 | ]; |
202 | |
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 | } |