]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blob - includes/deferred/LinksUpdate.php
MediaWiki 1.30.2
[autoinstallsdev/mediawiki.git] / includes / deferred / LinksUpdate.php
1 <?php
2 /**
3  * Updater for link tracking tables after a page edit.
4  *
5  * This program is free software; you can redistribute it and/or modify
6  * it under the terms of the GNU General Public License as published by
7  * the Free Software Foundation; either version 2 of the License, or
8  * (at your option) any later version.
9  *
10  * This program is distributed in the hope that it will be useful,
11  * but WITHOUT ANY WARRANTY; without even the implied warranty of
12  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13  * GNU General Public License for more details.
14  *
15  * You should have received a copy of the GNU General Public License along
16  * with this program; if not, write to the Free Software Foundation, Inc.,
17  * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18  * http://www.gnu.org/copyleft/gpl.html
19  *
20  * @file
21  */
22
23 use Wikimedia\Rdbms\IDatabase;
24 use MediaWiki\MediaWikiServices;
25 use Wikimedia\ScopedCallback;
26
27 /**
28  * Class the manages updates of *_link tables as well as similar extension-managed tables
29  *
30  * @note: LinksUpdate is managed by DeferredUpdates::execute(). Do not run this in a transaction.
31  *
32  * See docs/deferred.txt
33  */
34 class LinksUpdate extends DataUpdate implements EnqueueableDataUpdate {
35         // @todo make members protected, but make sure extensions don't break
36
37         /** @var int Page ID of the article linked from */
38         public $mId;
39
40         /** @var Title Title object of the article linked from */
41         public $mTitle;
42
43         /** @var ParserOutput */
44         public $mParserOutput;
45
46         /** @var array Map of title strings to IDs for the links in the document */
47         public $mLinks;
48
49         /** @var array DB keys of the images used, in the array key only */
50         public $mImages;
51
52         /** @var array Map of title strings to IDs for the template references, including broken ones */
53         public $mTemplates;
54
55         /** @var array URLs of external links, array key only */
56         public $mExternals;
57
58         /** @var array Map of category names to sort keys */
59         public $mCategories;
60
61         /** @var array Map of language codes to titles */
62         public $mInterlangs;
63
64         /** @var array 2-D map of (prefix => DBK => 1) */
65         public $mInterwikis;
66
67         /** @var array Map of arbitrary name to value */
68         public $mProperties;
69
70         /** @var bool Whether to queue jobs for recursive updates */
71         public $mRecursive;
72
73         /** @var Revision Revision for which this update has been triggered */
74         private $mRevision;
75
76         /**
77          * @var null|array Added links if calculated.
78          */
79         private $linkInsertions = null;
80
81         /**
82          * @var null|array Deleted links if calculated.
83          */
84         private $linkDeletions = null;
85
86         /**
87          * @var null|array Added properties if calculated.
88          */
89         private $propertyInsertions = null;
90
91         /**
92          * @var null|array Deleted properties if calculated.
93          */
94         private $propertyDeletions = null;
95
96         /**
97          * @var User|null
98          */
99         private $user;
100
101         /** @var IDatabase */
102         private $db;
103
104         /**
105          * @param Title $title Title of the page we're updating
106          * @param ParserOutput $parserOutput Output from a full parse of this page
107          * @param bool $recursive Queue jobs for recursive updates?
108          * @throws MWException
109          */
110         function __construct( Title $title, ParserOutput $parserOutput, $recursive = true ) {
111                 parent::__construct();
112
113                 $this->mTitle = $title;
114                 $this->mId = $title->getArticleID( Title::GAID_FOR_UPDATE );
115
116                 if ( !$this->mId ) {
117                         throw new InvalidArgumentException(
118                                 "The Title object yields no ID. Perhaps the page doesn't exist?"
119                         );
120                 }
121
122                 $this->mParserOutput = $parserOutput;
123
124                 $this->mLinks = $parserOutput->getLinks();
125                 $this->mImages = $parserOutput->getImages();
126                 $this->mTemplates = $parserOutput->getTemplates();
127                 $this->mExternals = $parserOutput->getExternalLinks();
128                 $this->mCategories = $parserOutput->getCategories();
129                 $this->mProperties = $parserOutput->getProperties();
130                 $this->mInterwikis = $parserOutput->getInterwikiLinks();
131
132                 # Convert the format of the interlanguage links
133                 # I didn't want to change it in the ParserOutput, because that array is passed all
134                 # the way back to the skin, so either a skin API break would be required, or an
135                 # inefficient back-conversion.
136                 $ill = $parserOutput->getLanguageLinks();
137                 $this->mInterlangs = [];
138                 foreach ( $ill as $link ) {
139                         list( $key, $title ) = explode( ':', $link, 2 );
140                         $this->mInterlangs[$key] = $title;
141                 }
142
143                 foreach ( $this->mCategories as &$sortkey ) {
144                         # If the sortkey is longer then 255 bytes,
145                         # it truncated by DB, and then doesn't get
146                         # matched when comparing existing vs current
147                         # categories, causing T27254.
148                         # Also. substr behaves weird when given "".
149                         if ( $sortkey !== '' ) {
150                                 $sortkey = substr( $sortkey, 0, 255 );
151                         }
152                 }
153
154                 $this->mRecursive = $recursive;
155
156                 // Avoid PHP 7.1 warning from passing $this by reference
157                 $linksUpdate = $this;
158                 Hooks::run( 'LinksUpdateConstructed', [ &$linksUpdate ] );
159         }
160
161         /**
162          * Update link tables with outgoing links from an updated article
163          *
164          * @note: this is managed by DeferredUpdates::execute(). Do not run this in a transaction.
165          */
166         public function doUpdate() {
167                 if ( $this->ticket ) {
168                         // Make sure all links update threads see the changes of each other.
169                         // This handles the case when updates have to batched into several COMMITs.
170                         $scopedLock = self::acquirePageLock( $this->getDB(), $this->mId );
171                 }
172
173                 // Avoid PHP 7.1 warning from passing $this by reference
174                 $linksUpdate = $this;
175                 Hooks::run( 'LinksUpdate', [ &$linksUpdate ] );
176                 $this->doIncrementalUpdate();
177
178                 // Commit and release the lock (if set)
179                 ScopedCallback::consume( $scopedLock );
180                 // Run post-commit hooks without DBO_TRX
181                 $this->getDB()->onTransactionIdle(
182                         function () {
183                                 // Avoid PHP 7.1 warning from passing $this by reference
184                                 $linksUpdate = $this;
185                                 Hooks::run( 'LinksUpdateComplete', [ &$linksUpdate, $this->ticket ] );
186                         },
187                         __METHOD__
188                 );
189         }
190
191         /**
192          * Acquire a lock for performing link table updates for a page on a DB
193          *
194          * @param IDatabase $dbw
195          * @param int $pageId
196          * @param string $why One of (job, atomicity)
197          * @return ScopedCallback
198          * @throws RuntimeException
199          * @since 1.27
200          */
201         public static function acquirePageLock( IDatabase $dbw, $pageId, $why = 'atomicity' ) {
202                 $key = "LinksUpdate:$why:pageid:$pageId";
203                 $scopedLock = $dbw->getScopedLockAndFlush( $key, __METHOD__, 15 );
204                 if ( !$scopedLock ) {
205                         throw new RuntimeException( "Could not acquire lock '$key'." );
206                 }
207
208                 return $scopedLock;
209         }
210
211         protected function doIncrementalUpdate() {
212                 # Page links
213                 $existingPL = $this->getExistingLinks();
214                 $this->linkDeletions = $this->getLinkDeletions( $existingPL );
215                 $this->linkInsertions = $this->getLinkInsertions( $existingPL );
216                 $this->incrTableUpdate( 'pagelinks', 'pl', $this->linkDeletions, $this->linkInsertions );
217
218                 # Image links
219                 $existingIL = $this->getExistingImages();
220                 $imageDeletes = $this->getImageDeletions( $existingIL );
221                 $this->incrTableUpdate(
222                         'imagelinks',
223                         'il',
224                         $imageDeletes,
225                         $this->getImageInsertions( $existingIL ) );
226
227                 # Invalidate all image description pages which had links added or removed
228                 $imageUpdates = $imageDeletes + array_diff_key( $this->mImages, $existingIL );
229                 $this->invalidateImageDescriptions( $imageUpdates );
230
231                 # External links
232                 $existingEL = $this->getExistingExternals();
233                 $this->incrTableUpdate(
234                         'externallinks',
235                         'el',
236                         $this->getExternalDeletions( $existingEL ),
237                         $this->getExternalInsertions( $existingEL ) );
238
239                 # Language links
240                 $existingLL = $this->getExistingInterlangs();
241                 $this->incrTableUpdate(
242                         'langlinks',
243                         'll',
244                         $this->getInterlangDeletions( $existingLL ),
245                         $this->getInterlangInsertions( $existingLL ) );
246
247                 # Inline interwiki links
248                 $existingIW = $this->getExistingInterwikis();
249                 $this->incrTableUpdate(
250                         'iwlinks',
251                         'iwl',
252                         $this->getInterwikiDeletions( $existingIW ),
253                         $this->getInterwikiInsertions( $existingIW ) );
254
255                 # Template links
256                 $existingTL = $this->getExistingTemplates();
257                 $this->incrTableUpdate(
258                         'templatelinks',
259                         'tl',
260                         $this->getTemplateDeletions( $existingTL ),
261                         $this->getTemplateInsertions( $existingTL ) );
262
263                 # Category links
264                 $existingCL = $this->getExistingCategories();
265                 $categoryDeletes = $this->getCategoryDeletions( $existingCL );
266                 $this->incrTableUpdate(
267                         'categorylinks',
268                         'cl',
269                         $categoryDeletes,
270                         $this->getCategoryInsertions( $existingCL ) );
271                 $categoryInserts = array_diff_assoc( $this->mCategories, $existingCL );
272                 $categoryUpdates = $categoryInserts + $categoryDeletes;
273
274                 # Page properties
275                 $existingPP = $this->getExistingProperties();
276                 $this->propertyDeletions = $this->getPropertyDeletions( $existingPP );
277                 $this->incrTableUpdate(
278                         'page_props',
279                         'pp',
280                         $this->propertyDeletions,
281                         $this->getPropertyInsertions( $existingPP ) );
282
283                 # Invalidate the necessary pages
284                 $this->propertyInsertions = array_diff_assoc( $this->mProperties, $existingPP );
285                 $changed = $this->propertyDeletions + $this->propertyInsertions;
286                 $this->invalidateProperties( $changed );
287
288                 # Invalidate all categories which were added, deleted or changed (set symmetric difference)
289                 $this->invalidateCategories( $categoryUpdates );
290                 $this->updateCategoryCounts( $categoryInserts, $categoryDeletes );
291
292                 # Refresh links of all pages including this page
293                 # This will be in a separate transaction
294                 if ( $this->mRecursive ) {
295                         $this->queueRecursiveJobs();
296                 }
297
298                 # Update the links table freshness for this title
299                 $this->updateLinksTimestamp();
300         }
301
302         /**
303          * Queue recursive jobs for this page
304          *
305          * Which means do LinksUpdate on all pages that include the current page,
306          * using the job queue.
307          */
308         protected function queueRecursiveJobs() {
309                 self::queueRecursiveJobsForTable( $this->mTitle, 'templatelinks' );
310                 if ( $this->mTitle->getNamespace() == NS_FILE ) {
311                         // Process imagelinks in case the title is or was a redirect
312                         self::queueRecursiveJobsForTable( $this->mTitle, 'imagelinks' );
313                 }
314
315                 $bc = $this->mTitle->getBacklinkCache();
316                 // Get jobs for cascade-protected backlinks for a high priority queue.
317                 // If meta-templates change to using a new template, the new template
318                 // should be implicitly protected as soon as possible, if applicable.
319                 // These jobs duplicate a subset of the above ones, but can run sooner.
320                 // Which ever runs first generally no-ops the other one.
321                 $jobs = [];
322                 foreach ( $bc->getCascadeProtectedLinks() as $title ) {
323                         $jobs[] = RefreshLinksJob::newPrioritized( $title, [] );
324                 }
325                 JobQueueGroup::singleton()->push( $jobs );
326         }
327
328         /**
329          * Queue a RefreshLinks job for any table.
330          *
331          * @param Title $title Title to do job for
332          * @param string $table Table to use (e.g. 'templatelinks')
333          */
334         public static function queueRecursiveJobsForTable( Title $title, $table ) {
335                 if ( $title->getBacklinkCache()->hasLinks( $table ) ) {
336                         $job = new RefreshLinksJob(
337                                 $title,
338                                 [
339                                         'table' => $table,
340                                         'recursive' => true,
341                                 ] + Job::newRootJobParams( // "overall" refresh links job info
342                                         "refreshlinks:{$table}:{$title->getPrefixedText()}"
343                                 )
344                         );
345
346                         JobQueueGroup::singleton()->push( $job );
347                 }
348         }
349
350         /**
351          * @param array $cats
352          */
353         private function invalidateCategories( $cats ) {
354                 PurgeJobUtils::invalidatePages( $this->getDB(), NS_CATEGORY, array_keys( $cats ) );
355         }
356
357         /**
358          * Update all the appropriate counts in the category table.
359          * @param array $added Associative array of category name => sort key
360          * @param array $deleted Associative array of category name => sort key
361          */
362         private function updateCategoryCounts( array $added, array $deleted ) {
363                 global $wgUpdateRowsPerQuery;
364
365                 if ( !$added && !$deleted ) {
366                         return;
367                 }
368
369                 $domainId = $this->getDB()->getDomainID();
370                 $wp = WikiPage::factory( $this->mTitle );
371                 $lbf = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
372                 // T163801: try to release any row locks to reduce contention
373                 $lbf->commitAndWaitForReplication( __METHOD__, $this->ticket, [ 'domain' => $domainId ] );
374
375                 foreach ( array_chunk( array_keys( $added ), $wgUpdateRowsPerQuery ) as $addBatch ) {
376                         $wp->updateCategoryCounts( $addBatch, [], $this->mId );
377                         $lbf->commitAndWaitForReplication(
378                                 __METHOD__, $this->ticket, [ 'domain' => $domainId ] );
379                 }
380
381                 foreach ( array_chunk( array_keys( $deleted ), $wgUpdateRowsPerQuery ) as $deleteBatch ) {
382                         $wp->updateCategoryCounts( [], $deleteBatch, $this->mId );
383                         $lbf->commitAndWaitForReplication(
384                                 __METHOD__, $this->ticket, [ 'domain' => $domainId ] );
385                 }
386         }
387
388         /**
389          * @param array $images
390          */
391         private function invalidateImageDescriptions( $images ) {
392                 PurgeJobUtils::invalidatePages( $this->getDB(), NS_FILE, array_keys( $images ) );
393         }
394
395         /**
396          * Update a table by doing a delete query then an insert query
397          * @param string $table Table name
398          * @param string $prefix Field name prefix
399          * @param array $deletions
400          * @param array $insertions Rows to insert
401          */
402         private function incrTableUpdate( $table, $prefix, $deletions, $insertions ) {
403                 $services = MediaWikiServices::getInstance();
404                 $bSize = $services->getMainConfig()->get( 'UpdateRowsPerQuery' );
405                 $lbf = $services->getDBLoadBalancerFactory();
406
407                 if ( $table === 'page_props' ) {
408                         $fromField = 'pp_page';
409                 } else {
410                         $fromField = "{$prefix}_from";
411                 }
412
413                 $deleteWheres = []; // list of WHERE clause arrays for each DB delete() call
414                 if ( $table === 'pagelinks' || $table === 'templatelinks' || $table === 'iwlinks' ) {
415                         $baseKey = ( $table === 'iwlinks' ) ? 'iwl_prefix' : "{$prefix}_namespace";
416
417                         $curBatchSize = 0;
418                         $curDeletionBatch = [];
419                         $deletionBatches = [];
420                         foreach ( $deletions as $ns => $dbKeys ) {
421                                 foreach ( $dbKeys as $dbKey => $unused ) {
422                                         $curDeletionBatch[$ns][$dbKey] = 1;
423                                         if ( ++$curBatchSize >= $bSize ) {
424                                                 $deletionBatches[] = $curDeletionBatch;
425                                                 $curDeletionBatch = [];
426                                                 $curBatchSize = 0;
427                                         }
428                                 }
429                         }
430                         if ( $curDeletionBatch ) {
431                                 $deletionBatches[] = $curDeletionBatch;
432                         }
433
434                         foreach ( $deletionBatches as $deletionBatch ) {
435                                 $deleteWheres[] = [
436                                         $fromField => $this->mId,
437                                         $this->getDB()->makeWhereFrom2d( $deletionBatch, $baseKey, "{$prefix}_title" )
438                                 ];
439                         }
440                 } else {
441                         if ( $table === 'langlinks' ) {
442                                 $toField = 'll_lang';
443                         } elseif ( $table === 'page_props' ) {
444                                 $toField = 'pp_propname';
445                         } else {
446                                 $toField = $prefix . '_to';
447                         }
448
449                         $deletionBatches = array_chunk( array_keys( $deletions ), $bSize );
450                         foreach ( $deletionBatches as $deletionBatch ) {
451                                 $deleteWheres[] = [ $fromField => $this->mId, $toField => $deletionBatch ];
452                         }
453                 }
454
455                 $domainId = $this->getDB()->getDomainID();
456
457                 foreach ( $deleteWheres as $deleteWhere ) {
458                         $this->getDB()->delete( $table, $deleteWhere, __METHOD__ );
459                         $lbf->commitAndWaitForReplication(
460                                 __METHOD__, $this->ticket, [ 'domain' => $domainId ]
461                         );
462                 }
463
464                 $insertBatches = array_chunk( $insertions, $bSize );
465                 foreach ( $insertBatches as $insertBatch ) {
466                         $this->getDB()->insert( $table, $insertBatch, __METHOD__, 'IGNORE' );
467                         $lbf->commitAndWaitForReplication(
468                                 __METHOD__, $this->ticket, [ 'domain' => $domainId ]
469                         );
470                 }
471
472                 if ( count( $insertions ) ) {
473                         Hooks::run( 'LinksUpdateAfterInsert', [ $this, $table, $insertions ] );
474                 }
475         }
476
477         /**
478          * Get an array of pagelinks insertions for passing to the DB
479          * Skips the titles specified by the 2-D array $existing
480          * @param array $existing
481          * @return array
482          */
483         private function getLinkInsertions( $existing = [] ) {
484                 $arr = [];
485                 foreach ( $this->mLinks as $ns => $dbkeys ) {
486                         $diffs = isset( $existing[$ns] )
487                                 ? array_diff_key( $dbkeys, $existing[$ns] )
488                                 : $dbkeys;
489                         foreach ( $diffs as $dbk => $id ) {
490                                 $arr[] = [
491                                         'pl_from' => $this->mId,
492                                         'pl_from_namespace' => $this->mTitle->getNamespace(),
493                                         'pl_namespace' => $ns,
494                                         'pl_title' => $dbk
495                                 ];
496                         }
497                 }
498
499                 return $arr;
500         }
501
502         /**
503          * Get an array of template insertions. Like getLinkInsertions()
504          * @param array $existing
505          * @return array
506          */
507         private function getTemplateInsertions( $existing = [] ) {
508                 $arr = [];
509                 foreach ( $this->mTemplates as $ns => $dbkeys ) {
510                         $diffs = isset( $existing[$ns] ) ? array_diff_key( $dbkeys, $existing[$ns] ) : $dbkeys;
511                         foreach ( $diffs as $dbk => $id ) {
512                                 $arr[] = [
513                                         'tl_from' => $this->mId,
514                                         'tl_from_namespace' => $this->mTitle->getNamespace(),
515                                         'tl_namespace' => $ns,
516                                         'tl_title' => $dbk
517                                 ];
518                         }
519                 }
520
521                 return $arr;
522         }
523
524         /**
525          * Get an array of image insertions
526          * Skips the names specified in $existing
527          * @param array $existing
528          * @return array
529          */
530         private function getImageInsertions( $existing = [] ) {
531                 $arr = [];
532                 $diffs = array_diff_key( $this->mImages, $existing );
533                 foreach ( $diffs as $iname => $dummy ) {
534                         $arr[] = [
535                                 'il_from' => $this->mId,
536                                 'il_from_namespace' => $this->mTitle->getNamespace(),
537                                 'il_to' => $iname
538                         ];
539                 }
540
541                 return $arr;
542         }
543
544         /**
545          * Get an array of externallinks insertions. Skips the names specified in $existing
546          * @param array $existing
547          * @return array
548          */
549         private function getExternalInsertions( $existing = [] ) {
550                 $arr = [];
551                 $diffs = array_diff_key( $this->mExternals, $existing );
552                 foreach ( $diffs as $url => $dummy ) {
553                         foreach ( wfMakeUrlIndexes( $url ) as $index ) {
554                                 $arr[] = [
555                                         'el_from' => $this->mId,
556                                         'el_to' => $url,
557                                         'el_index' => $index,
558                                 ];
559                         }
560                 }
561
562                 return $arr;
563         }
564
565         /**
566          * Get an array of category insertions
567          *
568          * @param array $existing Mapping existing category names to sort keys. If both
569          * match a link in $this, the link will be omitted from the output
570          *
571          * @return array
572          */
573         private function getCategoryInsertions( $existing = [] ) {
574                 global $wgContLang, $wgCategoryCollation;
575                 $diffs = array_diff_assoc( $this->mCategories, $existing );
576                 $arr = [];
577                 foreach ( $diffs as $name => $prefix ) {
578                         $nt = Title::makeTitleSafe( NS_CATEGORY, $name );
579                         $wgContLang->findVariantLink( $name, $nt, true );
580
581                         if ( $this->mTitle->getNamespace() == NS_CATEGORY ) {
582                                 $type = 'subcat';
583                         } elseif ( $this->mTitle->getNamespace() == NS_FILE ) {
584                                 $type = 'file';
585                         } else {
586                                 $type = 'page';
587                         }
588
589                         # Treat custom sortkeys as a prefix, so that if multiple
590                         # things are forced to sort as '*' or something, they'll
591                         # sort properly in the category rather than in page_id
592                         # order or such.
593                         $sortkey = Collation::singleton()->getSortKey(
594                                 $this->mTitle->getCategorySortkey( $prefix ) );
595
596                         $arr[] = [
597                                 'cl_from' => $this->mId,
598                                 'cl_to' => $name,
599                                 'cl_sortkey' => $sortkey,
600                                 'cl_timestamp' => $this->getDB()->timestamp(),
601                                 'cl_sortkey_prefix' => $prefix,
602                                 'cl_collation' => $wgCategoryCollation,
603                                 'cl_type' => $type,
604                         ];
605                 }
606
607                 return $arr;
608         }
609
610         /**
611          * Get an array of interlanguage link insertions
612          *
613          * @param array $existing Mapping existing language codes to titles
614          *
615          * @return array
616          */
617         private function getInterlangInsertions( $existing = [] ) {
618                 $diffs = array_diff_assoc( $this->mInterlangs, $existing );
619                 $arr = [];
620                 foreach ( $diffs as $lang => $title ) {
621                         $arr[] = [
622                                 'll_from' => $this->mId,
623                                 'll_lang' => $lang,
624                                 'll_title' => $title
625                         ];
626                 }
627
628                 return $arr;
629         }
630
631         /**
632          * Get an array of page property insertions
633          * @param array $existing
634          * @return array
635          */
636         function getPropertyInsertions( $existing = [] ) {
637                 $diffs = array_diff_assoc( $this->mProperties, $existing );
638
639                 $arr = [];
640                 foreach ( array_keys( $diffs ) as $name ) {
641                         $arr[] = $this->getPagePropRowData( $name );
642                 }
643
644                 return $arr;
645         }
646
647         /**
648          * Returns an associative array to be used for inserting a row into
649          * the page_props table. Besides the given property name, this will
650          * include the page id from $this->mId and any property value from
651          * $this->mProperties.
652          *
653          * The array returned will include the pp_sortkey field if this
654          * is present in the database (as indicated by $wgPagePropsHaveSortkey).
655          * The sortkey value is currently determined by getPropertySortKeyValue().
656          *
657          * @note this assumes that $this->mProperties[$prop] is defined.
658          *
659          * @param string $prop The name of the property.
660          *
661          * @return array
662          */
663         private function getPagePropRowData( $prop ) {
664                 global $wgPagePropsHaveSortkey;
665
666                 $value = $this->mProperties[$prop];
667
668                 $row = [
669                         'pp_page' => $this->mId,
670                         'pp_propname' => $prop,
671                         'pp_value' => $value,
672                 ];
673
674                 if ( $wgPagePropsHaveSortkey ) {
675                         $row['pp_sortkey'] = $this->getPropertySortKeyValue( $value );
676                 }
677
678                 return $row;
679         }
680
681         /**
682          * Determines the sort key for the given property value.
683          * This will return $value if it is a float or int,
684          * 1 or resp. 0 if it is a bool, and null otherwise.
685          *
686          * @note In the future, we may allow the sortkey to be specified explicitly
687          *       in ParserOutput::setProperty.
688          *
689          * @param mixed $value
690          *
691          * @return float|null
692          */
693         private function getPropertySortKeyValue( $value ) {
694                 if ( is_int( $value ) || is_float( $value ) || is_bool( $value ) ) {
695                         return floatval( $value );
696                 }
697
698                 return null;
699         }
700
701         /**
702          * Get an array of interwiki insertions for passing to the DB
703          * Skips the titles specified by the 2-D array $existing
704          * @param array $existing
705          * @return array
706          */
707         private function getInterwikiInsertions( $existing = [] ) {
708                 $arr = [];
709                 foreach ( $this->mInterwikis as $prefix => $dbkeys ) {
710                         $diffs = isset( $existing[$prefix] )
711                                 ? array_diff_key( $dbkeys, $existing[$prefix] )
712                                 : $dbkeys;
713
714                         foreach ( $diffs as $dbk => $id ) {
715                                 $arr[] = [
716                                         'iwl_from' => $this->mId,
717                                         'iwl_prefix' => $prefix,
718                                         'iwl_title' => $dbk
719                                 ];
720                         }
721                 }
722
723                 return $arr;
724         }
725
726         /**
727          * Given an array of existing links, returns those links which are not in $this
728          * and thus should be deleted.
729          * @param array $existing
730          * @return array
731          */
732         private function getLinkDeletions( $existing ) {
733                 $del = [];
734                 foreach ( $existing as $ns => $dbkeys ) {
735                         if ( isset( $this->mLinks[$ns] ) ) {
736                                 $del[$ns] = array_diff_key( $existing[$ns], $this->mLinks[$ns] );
737                         } else {
738                                 $del[$ns] = $existing[$ns];
739                         }
740                 }
741
742                 return $del;
743         }
744
745         /**
746          * Given an array of existing templates, returns those templates which are not in $this
747          * and thus should be deleted.
748          * @param array $existing
749          * @return array
750          */
751         private function getTemplateDeletions( $existing ) {
752                 $del = [];
753                 foreach ( $existing as $ns => $dbkeys ) {
754                         if ( isset( $this->mTemplates[$ns] ) ) {
755                                 $del[$ns] = array_diff_key( $existing[$ns], $this->mTemplates[$ns] );
756                         } else {
757                                 $del[$ns] = $existing[$ns];
758                         }
759                 }
760
761                 return $del;
762         }
763
764         /**
765          * Given an array of existing images, returns those images which are not in $this
766          * and thus should be deleted.
767          * @param array $existing
768          * @return array
769          */
770         private function getImageDeletions( $existing ) {
771                 return array_diff_key( $existing, $this->mImages );
772         }
773
774         /**
775          * Given an array of existing external links, returns those links which are not
776          * in $this and thus should be deleted.
777          * @param array $existing
778          * @return array
779          */
780         private function getExternalDeletions( $existing ) {
781                 return array_diff_key( $existing, $this->mExternals );
782         }
783
784         /**
785          * Given an array of existing categories, returns those categories which are not in $this
786          * and thus should be deleted.
787          * @param array $existing
788          * @return array
789          */
790         private function getCategoryDeletions( $existing ) {
791                 return array_diff_assoc( $existing, $this->mCategories );
792         }
793
794         /**
795          * Given an array of existing interlanguage links, returns those links which are not
796          * in $this and thus should be deleted.
797          * @param array $existing
798          * @return array
799          */
800         private function getInterlangDeletions( $existing ) {
801                 return array_diff_assoc( $existing, $this->mInterlangs );
802         }
803
804         /**
805          * Get array of properties which should be deleted.
806          * @param array $existing
807          * @return array
808          */
809         function getPropertyDeletions( $existing ) {
810                 return array_diff_assoc( $existing, $this->mProperties );
811         }
812
813         /**
814          * Given an array of existing interwiki links, returns those links which are not in $this
815          * and thus should be deleted.
816          * @param array $existing
817          * @return array
818          */
819         private function getInterwikiDeletions( $existing ) {
820                 $del = [];
821                 foreach ( $existing as $prefix => $dbkeys ) {
822                         if ( isset( $this->mInterwikis[$prefix] ) ) {
823                                 $del[$prefix] = array_diff_key( $existing[$prefix], $this->mInterwikis[$prefix] );
824                         } else {
825                                 $del[$prefix] = $existing[$prefix];
826                         }
827                 }
828
829                 return $del;
830         }
831
832         /**
833          * Get an array of existing links, as a 2-D array
834          *
835          * @return array
836          */
837         private function getExistingLinks() {
838                 $res = $this->getDB()->select( 'pagelinks', [ 'pl_namespace', 'pl_title' ],
839                         [ 'pl_from' => $this->mId ], __METHOD__ );
840                 $arr = [];
841                 foreach ( $res as $row ) {
842                         if ( !isset( $arr[$row->pl_namespace] ) ) {
843                                 $arr[$row->pl_namespace] = [];
844                         }
845                         $arr[$row->pl_namespace][$row->pl_title] = 1;
846                 }
847
848                 return $arr;
849         }
850
851         /**
852          * Get an array of existing templates, as a 2-D array
853          *
854          * @return array
855          */
856         private function getExistingTemplates() {
857                 $res = $this->getDB()->select( 'templatelinks', [ 'tl_namespace', 'tl_title' ],
858                         [ 'tl_from' => $this->mId ], __METHOD__ );
859                 $arr = [];
860                 foreach ( $res as $row ) {
861                         if ( !isset( $arr[$row->tl_namespace] ) ) {
862                                 $arr[$row->tl_namespace] = [];
863                         }
864                         $arr[$row->tl_namespace][$row->tl_title] = 1;
865                 }
866
867                 return $arr;
868         }
869
870         /**
871          * Get an array of existing images, image names in the keys
872          *
873          * @return array
874          */
875         private function getExistingImages() {
876                 $res = $this->getDB()->select( 'imagelinks', [ 'il_to' ],
877                         [ 'il_from' => $this->mId ], __METHOD__ );
878                 $arr = [];
879                 foreach ( $res as $row ) {
880                         $arr[$row->il_to] = 1;
881                 }
882
883                 return $arr;
884         }
885
886         /**
887          * Get an array of existing external links, URLs in the keys
888          *
889          * @return array
890          */
891         private function getExistingExternals() {
892                 $res = $this->getDB()->select( 'externallinks', [ 'el_to' ],
893                         [ 'el_from' => $this->mId ], __METHOD__ );
894                 $arr = [];
895                 foreach ( $res as $row ) {
896                         $arr[$row->el_to] = 1;
897                 }
898
899                 return $arr;
900         }
901
902         /**
903          * Get an array of existing categories, with the name in the key and sort key in the value.
904          *
905          * @return array
906          */
907         private function getExistingCategories() {
908                 $res = $this->getDB()->select( 'categorylinks', [ 'cl_to', 'cl_sortkey_prefix' ],
909                         [ 'cl_from' => $this->mId ], __METHOD__ );
910                 $arr = [];
911                 foreach ( $res as $row ) {
912                         $arr[$row->cl_to] = $row->cl_sortkey_prefix;
913                 }
914
915                 return $arr;
916         }
917
918         /**
919          * Get an array of existing interlanguage links, with the language code in the key and the
920          * title in the value.
921          *
922          * @return array
923          */
924         private function getExistingInterlangs() {
925                 $res = $this->getDB()->select( 'langlinks', [ 'll_lang', 'll_title' ],
926                         [ 'll_from' => $this->mId ], __METHOD__ );
927                 $arr = [];
928                 foreach ( $res as $row ) {
929                         $arr[$row->ll_lang] = $row->ll_title;
930                 }
931
932                 return $arr;
933         }
934
935         /**
936          * Get an array of existing inline interwiki links, as a 2-D array
937          * @return array (prefix => array(dbkey => 1))
938          */
939         private function getExistingInterwikis() {
940                 $res = $this->getDB()->select( 'iwlinks', [ 'iwl_prefix', 'iwl_title' ],
941                         [ 'iwl_from' => $this->mId ], __METHOD__ );
942                 $arr = [];
943                 foreach ( $res as $row ) {
944                         if ( !isset( $arr[$row->iwl_prefix] ) ) {
945                                 $arr[$row->iwl_prefix] = [];
946                         }
947                         $arr[$row->iwl_prefix][$row->iwl_title] = 1;
948                 }
949
950                 return $arr;
951         }
952
953         /**
954          * Get an array of existing categories, with the name in the key and sort key in the value.
955          *
956          * @return array Array of property names and values
957          */
958         private function getExistingProperties() {
959                 $res = $this->getDB()->select( 'page_props', [ 'pp_propname', 'pp_value' ],
960                         [ 'pp_page' => $this->mId ], __METHOD__ );
961                 $arr = [];
962                 foreach ( $res as $row ) {
963                         $arr[$row->pp_propname] = $row->pp_value;
964                 }
965
966                 return $arr;
967         }
968
969         /**
970          * Return the title object of the page being updated
971          * @return Title
972          */
973         public function getTitle() {
974                 return $this->mTitle;
975         }
976
977         /**
978          * Returns parser output
979          * @since 1.19
980          * @return ParserOutput
981          */
982         public function getParserOutput() {
983                 return $this->mParserOutput;
984         }
985
986         /**
987          * Return the list of images used as generated by the parser
988          * @return array
989          */
990         public function getImages() {
991                 return $this->mImages;
992         }
993
994         /**
995          * Set the revision corresponding to this LinksUpdate
996          *
997          * @since 1.27
998          *
999          * @param Revision $revision
1000          */
1001         public function setRevision( Revision $revision ) {
1002                 $this->mRevision = $revision;
1003         }
1004
1005         /**
1006          * @since 1.28
1007          * @return null|Revision
1008          */
1009         public function getRevision() {
1010                 return $this->mRevision;
1011         }
1012
1013         /**
1014          * Set the User who triggered this LinksUpdate
1015          *
1016          * @since 1.27
1017          * @param User $user
1018          */
1019         public function setTriggeringUser( User $user ) {
1020                 $this->user = $user;
1021         }
1022
1023         /**
1024          * @since 1.27
1025          * @return null|User
1026          */
1027         public function getTriggeringUser() {
1028                 return $this->user;
1029         }
1030
1031         /**
1032          * Invalidate any necessary link lists related to page property changes
1033          * @param array $changed
1034          */
1035         private function invalidateProperties( $changed ) {
1036                 global $wgPagePropLinkInvalidations;
1037
1038                 foreach ( $changed as $name => $value ) {
1039                         if ( isset( $wgPagePropLinkInvalidations[$name] ) ) {
1040                                 $inv = $wgPagePropLinkInvalidations[$name];
1041                                 if ( !is_array( $inv ) ) {
1042                                         $inv = [ $inv ];
1043                                 }
1044                                 foreach ( $inv as $table ) {
1045                                         DeferredUpdates::addUpdate( new HTMLCacheUpdate( $this->mTitle, $table ) );
1046                                 }
1047                         }
1048                 }
1049         }
1050
1051         /**
1052          * Fetch page links added by this LinksUpdate.  Only available after the update is complete.
1053          * @since 1.22
1054          * @return null|array Array of Titles
1055          */
1056         public function getAddedLinks() {
1057                 if ( $this->linkInsertions === null ) {
1058                         return null;
1059                 }
1060                 $result = [];
1061                 foreach ( $this->linkInsertions as $insertion ) {
1062                         $result[] = Title::makeTitle( $insertion['pl_namespace'], $insertion['pl_title'] );
1063                 }
1064
1065                 return $result;
1066         }
1067
1068         /**
1069          * Fetch page links removed by this LinksUpdate.  Only available after the update is complete.
1070          * @since 1.22
1071          * @return null|array Array of Titles
1072          */
1073         public function getRemovedLinks() {
1074                 if ( $this->linkDeletions === null ) {
1075                         return null;
1076                 }
1077                 $result = [];
1078                 foreach ( $this->linkDeletions as $ns => $titles ) {
1079                         foreach ( $titles as $title => $unused ) {
1080                                 $result[] = Title::makeTitle( $ns, $title );
1081                         }
1082                 }
1083
1084                 return $result;
1085         }
1086
1087         /**
1088          * Fetch page properties added by this LinksUpdate.
1089          * Only available after the update is complete.
1090          * @since 1.28
1091          * @return null|array
1092          */
1093         public function getAddedProperties() {
1094                 return $this->propertyInsertions;
1095         }
1096
1097         /**
1098          * Fetch page properties removed by this LinksUpdate.
1099          * Only available after the update is complete.
1100          * @since 1.28
1101          * @return null|array
1102          */
1103         public function getRemovedProperties() {
1104                 return $this->propertyDeletions;
1105         }
1106
1107         /**
1108          * Update links table freshness
1109          */
1110         private function updateLinksTimestamp() {
1111                 if ( $this->mId ) {
1112                         // The link updates made here only reflect the freshness of the parser output
1113                         $timestamp = $this->mParserOutput->getCacheTime();
1114                         $this->getDB()->update( 'page',
1115                                 [ 'page_links_updated' => $this->getDB()->timestamp( $timestamp ) ],
1116                                 [ 'page_id' => $this->mId ],
1117                                 __METHOD__
1118                         );
1119                 }
1120         }
1121
1122         /**
1123          * @return IDatabase
1124          */
1125         private function getDB() {
1126                 if ( !$this->db ) {
1127                         $this->db = wfGetDB( DB_MASTER );
1128                 }
1129
1130                 return $this->db;
1131         }
1132
1133         public function getAsJobSpecification() {
1134                 if ( $this->user ) {
1135                         $userInfo = [
1136                                 'userId' => $this->user->getId(),
1137                                 'userName' => $this->user->getName(),
1138                         ];
1139                 } else {
1140                         $userInfo = false;
1141                 }
1142
1143                 if ( $this->mRevision ) {
1144                         $triggeringRevisionId = $this->mRevision->getId();
1145                 } else {
1146                         $triggeringRevisionId = false;
1147                 }
1148
1149                 return [
1150                         'wiki' => WikiMap::getWikiIdFromDomain( $this->getDB()->getDomainID() ),
1151                         'job'  => new JobSpecification(
1152                                 'refreshLinksPrioritized',
1153                                 [
1154                                         // Reuse the parser cache if it was saved
1155                                         'rootJobTimestamp' => $this->mParserOutput->getCacheTime(),
1156                                         'useRecursiveLinksUpdate' => $this->mRecursive,
1157                                         'triggeringUser' => $userInfo,
1158                                         'triggeringRevisionId' => $triggeringRevisionId,
1159                                 ],
1160                                 [ 'removeDuplicates' => true ],
1161                                 $this->getTitle()
1162                         )
1163                 ];
1164         }
1165 }