Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
34.94% covered (danger)
34.94%
58 / 166
35.71% covered (danger)
35.71%
5 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageAssessmentsDAO
34.94% covered (danger)
34.94%
58 / 166
35.71% covered (danger)
35.71%
5 / 14
552.19
0.00% covered (danger)
0.00%
0 / 1
 getReplicaDBConnection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPrimaryDBConnection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doUpdates
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
380
 getProjectName
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 extractParentProjectId
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getProjectId
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 insertProject
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 cleanProjectTitle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 updateRecord
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
4
 insertRecord
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getAllProjects
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 deleteRecord
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 deleteRecordsForPage
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 cacheAssessment
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
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
25namespace MediaWiki\Extension\PageAssessments;
26
27use IDBAccessObject;
28use MediaWiki\MediaWikiServices;
29use MediaWiki\Title\Title;
30use Parser;
31use Wikimedia\Rdbms\IDatabase;
32use Wikimedia\Rdbms\IReadableDatabase;
33
34class 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}