Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
34.94% |
58 / 166 |
|
35.71% |
5 / 14 |
CRAP | |
0.00% |
0 / 1 |
PageAssessmentsDAO | |
34.94% |
58 / 166 |
|
35.71% |
5 / 14 |
552.19 | |
0.00% |
0 / 1 |
getReplicaDBConnection | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getPrimaryDBConnection | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
doUpdates | |
0.00% |
0 / 51 |
|
0.00% |
0 / 1 |
380 | |||
getProjectName | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
extractParentProjectId | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
getProjectId | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
insertProject | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
6 | |||
cleanProjectTitle | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
updateRecord | |
95.65% |
22 / 23 |
|
0.00% |
0 / 1 |
4 | |||
insertRecord | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
getAllProjects | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
3.00 | |||
deleteRecord | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
deleteRecordsForPage | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
cacheAssessment | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | /** |
4 | * This program is free software; you can redistribute it and/or modify |
5 | * it under the terms of the GNU General Public License as published by |
6 | * the Free Software Foundation; either version 2 of the License, or |
7 | * (at your option) any later version. |
8 | * |
9 | * This program is distributed in the hope that it will be useful, |
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
12 | * GNU General Public License for more details. |
13 | * |
14 | * You should have received a copy of the GNU General Public License along |
15 | * with this program; if not, write to the Free Software Foundation, Inc., |
16 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
17 | * http://www.gnu.org/copyleft/gpl.html |
18 | * |
19 | * PageAssessments extension body |
20 | * |
21 | * @file |
22 | * @ingroup Extensions |
23 | */ |
24 | |
25 | namespace MediaWiki\Extension\PageAssessments; |
26 | |
27 | use IDBAccessObject; |
28 | use MediaWiki\MediaWikiServices; |
29 | use MediaWiki\Title\Title; |
30 | use Parser; |
31 | use Wikimedia\Rdbms\IDatabase; |
32 | use Wikimedia\Rdbms\IReadableDatabase; |
33 | |
34 | class PageAssessmentsDAO { |
35 | |
36 | /** @var array Instance cache associating project IDs with project names */ |
37 | protected static $projectNames = []; |
38 | |
39 | private static function getReplicaDBConnection(): IReadableDatabase { |
40 | return MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
41 | } |
42 | |
43 | private static function getPrimaryDBConnection(): IDatabase { |
44 | return MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase(); |
45 | } |
46 | |
47 | /** |
48 | * Driver function that handles updating assessment data in database |
49 | * @param Title $titleObj Title object of the subject page |
50 | * @param array $assessmentData Data for all assessments compiled |
51 | * @param mixed|null $ticket Transaction ticket |
52 | */ |
53 | public static function doUpdates( $titleObj, $assessmentData, $ticket = null ) { |
54 | global $wgUpdateRowsPerQuery, $wgPageAssessmentsSubprojects; |
55 | |
56 | $dbProvider = MediaWikiServices::getInstance()->getConnectionProvider(); |
57 | $ticket = $ticket ?: $dbProvider->getEmptyTransactionTicket( __METHOD__ ); |
58 | |
59 | $pageId = $titleObj->getArticleID(); |
60 | $revisionId = $titleObj->getLatestRevID(); |
61 | // Compile a list of projects found in the parserData to find out which |
62 | // assessment records need to be inserted, deleted, or updated. |
63 | $projects = []; |
64 | foreach ( $assessmentData as $key => $parserData ) { |
65 | // If the name of the project is set... |
66 | if ( isset( $parserData[0] ) && $parserData[0] !== '' ) { |
67 | // Clean the project name. |
68 | $projectName = self::cleanProjectTitle( $parserData[0] ); |
69 | // Replace the original project name with the cleaned project |
70 | // name in the assessment data, since we'll need it to match later. |
71 | $assessmentData[$key][0] = $projectName; |
72 | // Get the corresponding ID from page_assessments_projects table. |
73 | $projectId = self::getProjectId( $projectName ); |
74 | // If there is no existing project by that name, add it to the table. |
75 | if ( $projectId === false ) { |
76 | if ( $wgPageAssessmentsSubprojects ) { |
77 | // Extract possible parent from the project name. |
78 | $parentId = self::extractParentProjectId( $projectName ); |
79 | // Insert project data into the database table. |
80 | $projectId = self::insertProject( $projectName, $parentId ); |
81 | } else { |
82 | $projectId = self::insertProject( $projectName ); |
83 | } |
84 | } |
85 | // Add the project's ID to the array. |
86 | $projects[$projectName] = $projectId; |
87 | } |
88 | } |
89 | // Get a list of all the projects previously assigned to the page. |
90 | $projectsInDb = self::getAllProjects( $pageId, IDBAccessObject::READ_LATEST ); |
91 | |
92 | $toInsert = array_diff( $projects, $projectsInDb ); |
93 | $toDelete = array_diff( $projectsInDb, $projects ); |
94 | $toUpdate = array_intersect( $projects, $projectsInDb ); |
95 | |
96 | $i = 0; |
97 | |
98 | // Add and update assessment records to the database |
99 | foreach ( $assessmentData as $parserData ) { |
100 | // Make sure the name of the project is set. |
101 | if ( !isset( $parserData[0] ) || $parserData[0] == '' ) { |
102 | continue; |
103 | } |
104 | $projectId = $projects[$parserData[0]]; |
105 | if ( $projectId && $pageId ) { |
106 | $class = $parserData[1]; |
107 | $importance = $parserData[2]; |
108 | $values = [ |
109 | 'pa_page_id' => $pageId, |
110 | 'pa_project_id' => $projectId, |
111 | 'pa_class' => $class, |
112 | 'pa_importance' => $importance, |
113 | 'pa_page_revision' => $revisionId |
114 | ]; |
115 | if ( in_array( $projectId, $toInsert ) ) { |
116 | self::insertRecord( $values ); |
117 | } elseif ( in_array( $projectId, $toUpdate ) ) { |
118 | self::updateRecord( $values ); |
119 | } |
120 | // Check for database lag if there's a huge number of assessments |
121 | if ( $i > 0 && $i % $wgUpdateRowsPerQuery == 0 ) { |
122 | $dbProvider->commitAndWaitForReplication( __METHOD__, $ticket ); |
123 | } |
124 | $i++; |
125 | } |
126 | } |
127 | |
128 | // Delete records from the database |
129 | foreach ( $toDelete as $project ) { |
130 | $values = [ |
131 | 'pa_page_id' => $pageId, |
132 | 'pa_project_id' => $project |
133 | ]; |
134 | self::deleteRecord( $values ); |
135 | // Check for database lag if there's a huge number of deleted assessments |
136 | if ( $i > 0 && $i % $wgUpdateRowsPerQuery == 0 ) { |
137 | $dbProvider->commitAndWaitForReplication( __METHOD__, $ticket ); |
138 | } |
139 | $i++; |
140 | } |
141 | } |
142 | |
143 | /** |
144 | * Get name for the given wikiproject |
145 | * @param int $projectId The ID of the project |
146 | * @return string|false The name of the project or false if not found |
147 | */ |
148 | public static function getProjectName( $projectId ) { |
149 | // Check for a valid project ID |
150 | if ( $projectId > 0 ) { |
151 | // See if the project name is already in the instance cache |
152 | if ( isset( self::$projectNames[$projectId] ) ) { |
153 | return self::$projectNames[$projectId]; |
154 | } else { |
155 | $dbr = self::getReplicaDBConnection(); |
156 | $projectName = $dbr->newSelectQueryBuilder() |
157 | ->select( 'pap_project_title' ) |
158 | ->from( 'page_assessments_projects' ) |
159 | ->where( [ 'pap_project_id' => $projectId ] ) |
160 | ->caller( __METHOD__ ) |
161 | ->fetchField(); |
162 | // Store the project name in instance cache |
163 | self::$projectNames[$projectId] = $projectName; |
164 | return $projectName; |
165 | } |
166 | } |
167 | return false; |
168 | } |
169 | |
170 | /** |
171 | * Extract parent from a project name and return the ID. For example, if the |
172 | * project name is "Novels/Crime task force", the parent will be "Novels", |
173 | * i.e. WikiProject Novels. |
174 | * |
175 | * @param string $projectName Project title |
176 | * @return int|false project ID or false if not found |
177 | */ |
178 | protected static function extractParentProjectId( $projectName ) { |
179 | $projectNameParts = explode( '/', $projectName ); |
180 | if ( count( $projectNameParts ) > 1 && $projectNameParts[0] !== '' ) { |
181 | return self::getProjectId( $projectNameParts[0] ); |
182 | } |
183 | return false; |
184 | } |
185 | |
186 | /** |
187 | * Get project ID for a given wikiproject title |
188 | * @param string $project Project title |
189 | * @return int|false project ID or false if not found |
190 | */ |
191 | public static function getProjectId( $project ) { |
192 | $dbr = self::getReplicaDBConnection(); |
193 | return $dbr->newSelectQueryBuilder() |
194 | ->select( 'pap_project_id' ) |
195 | ->from( 'page_assessments_projects' ) |
196 | ->where( [ 'pap_project_title' => $project ] ) |
197 | ->caller( __METHOD__ ) |
198 | ->fetchField(); |
199 | } |
200 | |
201 | /** |
202 | * Insert a new wikiproject into the projects table |
203 | * @param string $project Wikiproject title |
204 | * @param int|null $parentId ID of the parent project (for subprojects) (optional) |
205 | * @return int Insert Id for new project |
206 | */ |
207 | public static function insertProject( $project, $parentId = null ) { |
208 | $dbw = self::getPrimaryDBConnection(); |
209 | $values = [ 'pap_project_title' => $project ]; |
210 | if ( $parentId ) { |
211 | $values[ 'pap_parent_id' ] = (int)$parentId; |
212 | } |
213 | $dbw->newInsertQueryBuilder() |
214 | ->insertInto( 'page_assessments_projects' ) |
215 | // Use ignore() in case two projects with the same name are added at once. |
216 | // This normally shouldn't happen, but is possible perhaps from clicking |
217 | // 'Publish changes' twice in very quick succession. (See T286671) |
218 | ->ignore() |
219 | ->row( $values ) |
220 | ->caller( __METHOD__ ) |
221 | ->execute(); |
222 | $id = $dbw->insertId(); |
223 | return $id; |
224 | } |
225 | |
226 | /** |
227 | * Clean up the title of the project (or subproject) |
228 | * |
229 | * Since the project title comes from a template parameter, it can basically |
230 | * be anything. This function accounts for common cases where editors put |
231 | * extra stuff into the parameter besides just the name of the project. |
232 | * @param string $project WikiProject title |
233 | * @return string Cleaned-up WikiProject title |
234 | */ |
235 | public static function cleanProjectTitle( $project ) { |
236 | // Remove any bold formatting. |
237 | $project = str_replace( "'''", "", $project ); |
238 | // Remove "the" prefix for subprojects (common on English Wikipedia). |
239 | // This is case-sensitive on purpose, as there are some legitimate |
240 | // subproject titles starting with "The", e.g. "The Canterbury Tales". |
241 | $project = str_replace( "/the ", "/", $project ); |
242 | // Truncate to 255 characters to avoid DB warnings. |
243 | return substr( $project, 0, 255 ); |
244 | } |
245 | |
246 | /** |
247 | * Update record in DB if there are new values |
248 | * @param array $values New values to be entered into the DB |
249 | * @return bool true |
250 | */ |
251 | public static function updateRecord( $values ) { |
252 | $dbr = self::getReplicaDBConnection(); |
253 | $conds = [ |
254 | 'pa_page_id' => $values['pa_page_id'], |
255 | 'pa_project_id' => $values['pa_project_id'] |
256 | ]; |
257 | // Check if there are no updates to be done |
258 | $record = $dbr->newSelectQueryBuilder() |
259 | ->select( [ 'pa_class', 'pa_importance', 'pa_project_id', 'pa_page_id' ] ) |
260 | ->from( 'page_assessments' ) |
261 | ->where( $conds ) |
262 | ->caller( __METHOD__ ) |
263 | ->fetchResultSet(); |
264 | foreach ( $record as $row ) { |
265 | if ( $row->pa_importance == $values['pa_importance'] && |
266 | $row->pa_class == $values['pa_class'] |
267 | ) { |
268 | // Return if no update is needed |
269 | return true; |
270 | } |
271 | } |
272 | // Make updates if there are changes |
273 | $dbw = self::getPrimaryDBConnection(); |
274 | $dbw->newUpdateQueryBuilder() |
275 | ->update( 'page_assessments' ) |
276 | ->set( $values ) |
277 | ->where( $conds ) |
278 | ->caller( __METHOD__ ) |
279 | ->execute(); |
280 | return true; |
281 | } |
282 | |
283 | /** |
284 | * Insert a new record in DB |
285 | * @param array $values New values to be entered into the DB |
286 | * @return bool true |
287 | */ |
288 | public static function insertRecord( $values ) { |
289 | $dbw = self::getPrimaryDBConnection(); |
290 | // Use IGNORE in case 2 records for the same project are added at once. |
291 | // This normally shouldn't happen, but is possible. (See T152080) |
292 | $dbw->newInsertQueryBuilder() |
293 | ->insertInto( 'page_assessments' ) |
294 | ->ignore() |
295 | ->row( $values ) |
296 | ->caller( __METHOD__ ) |
297 | ->execute(); |
298 | return true; |
299 | } |
300 | |
301 | /** |
302 | * Get all projects associated with a given page (as project IDs) |
303 | * @param int $pageId Page ID |
304 | * @param int $flags IDBAccessObject::READ_* constant. This can be used to |
305 | * force reading from the primary database. See docs at IDBAccessObject.php. |
306 | * @return array $results All projects associated with given page |
307 | */ |
308 | public static function getAllProjects( $pageId, $flags = IDBAccessObject::READ_NORMAL ) { |
309 | if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
310 | $db = self::getPrimaryDBConnection(); |
311 | } else { |
312 | $db = self::getReplicaDBConnection(); |
313 | } |
314 | $res = $db->newSelectQueryBuilder() |
315 | ->select( 'pa_project_id' ) |
316 | ->from( 'page_assessments' ) |
317 | ->where( [ 'pa_page_id' => $pageId ] ) |
318 | ->recency( $flags ) |
319 | ->caller( __METHOD__ )->fetchResultSet(); |
320 | $results = []; |
321 | foreach ( $res as $row ) { |
322 | $results[] = $row->pa_project_id; |
323 | } |
324 | return $results; |
325 | } |
326 | |
327 | /** |
328 | * Delete a record from DB |
329 | * @param array $values Conditions for looking up records to delete |
330 | * @return bool true |
331 | */ |
332 | public static function deleteRecord( $values ) { |
333 | $dbw = self::getPrimaryDBConnection(); |
334 | $conds = [ |
335 | 'pa_page_id' => $values['pa_page_id'], |
336 | 'pa_project_id' => $values['pa_project_id'] |
337 | ]; |
338 | $dbw->newDeleteQueryBuilder() |
339 | ->deleteFrom( 'page_assessments' ) |
340 | ->where( $conds ) |
341 | ->caller( __METHOD__ ) |
342 | ->execute(); |
343 | return true; |
344 | } |
345 | |
346 | /** |
347 | * Delete all records for a given page when page is deleted |
348 | * Note: We don't take care of undeletions explicitly, the records are restored |
349 | * when the page is parsed again. |
350 | * @param int $id Page ID of deleted page |
351 | * @return bool true |
352 | */ |
353 | public static function deleteRecordsForPage( $id ) { |
354 | $dbw = self::getPrimaryDBConnection(); |
355 | $conds = [ |
356 | 'pa_page_id' => $id, |
357 | ]; |
358 | $dbw->newDeleteQueryBuilder() |
359 | ->deleteFrom( 'page_assessments' ) |
360 | ->where( $conds ) |
361 | ->caller( __METHOD__ ) |
362 | ->execute(); |
363 | return true; |
364 | } |
365 | |
366 | /** |
367 | * Function called on parser init |
368 | * @param Parser $parser Parser object |
369 | * @param string $project Wikiproject name |
370 | * @param string $class Class of article |
371 | * @param string $importance Importance of article |
372 | */ |
373 | public static function cacheAssessment( |
374 | Parser $parser, |
375 | $project = '', |
376 | $class = '', |
377 | $importance = '' |
378 | ) { |
379 | $parserData = $parser->getOutput()->getExtensionData( 'ext-pageassessment-assessmentdata' ); |
380 | $values = [ $project, $class, $importance ]; |
381 | if ( $parserData == null ) { |
382 | $parserData = []; |
383 | } |
384 | $parserData[] = $values; |
385 | $parser->getOutput()->setExtensionData( 'ext-pageassessment-assessmentdata', $parserData ); |
386 | } |
387 | |
388 | } |