1<?php 2 3namespace MediaWiki\Page; 4 5use BadMethodCallException; 6use BagOStuff; 7use ChangeTags; 8use CommentStore; 9use Content; 10use DeferrableUpdate; 11use DeferredUpdates; 12use DeletePageJob; 13use Exception; 14use JobQueueGroup; 15use LinksDeletionUpdate; 16use LinksUpdate; 17use LogicException; 18use ManualLogEntry; 19use MediaWiki\Cache\BacklinkCacheFactory; 20use MediaWiki\Config\ServiceOptions; 21use MediaWiki\HookContainer\HookContainer; 22use MediaWiki\HookContainer\HookRunner; 23use MediaWiki\Logger\LoggerFactory; 24use MediaWiki\Permissions\Authority; 25use MediaWiki\Permissions\PermissionStatus; 26use MediaWiki\Revision\RevisionRecord; 27use MediaWiki\Revision\RevisionStore; 28use MediaWiki\Revision\SlotRecord; 29use MediaWiki\User\UserFactory; 30use Message; 31use RawMessage; 32use ResourceLoaderWikiModule; 33use SearchUpdate; 34use SiteStatsUpdate; 35use Status; 36use StatusValue; 37use Wikimedia\IPUtils; 38use Wikimedia\Rdbms\ILoadBalancer; 39use Wikimedia\Rdbms\LBFactory; 40use WikiPage; 41 42/** 43 * @since 1.37 44 * @package MediaWiki\Page 45 */ 46class DeletePage { 47 /** 48 * @internal For use by PageCommandFactory 49 */ 50 public const CONSTRUCTOR_OPTIONS = [ 51 'DeleteRevisionsBatchSize', 52 'ActorTableSchemaMigrationStage', 53 'DeleteRevisionsLimit', 54 ]; 55 56 /** @var HookRunner */ 57 private $hookRunner; 58 /** @var RevisionStore */ 59 private $revisionStore; 60 /** @var LBFactory */ 61 private $lbFactory; 62 /** @var ILoadBalancer */ 63 private $loadBalancer; 64 /** @var JobQueueGroup */ 65 private $jobQueueGroup; 66 /** @var CommentStore */ 67 private $commentStore; 68 /** @var ServiceOptions */ 69 private $options; 70 /** @var BagOStuff */ 71 private $recentDeletesCache; 72 /** @var string */ 73 private $localWikiID; 74 /** @var string */ 75 private $webRequestID; 76 /** @var UserFactory */ 77 private $userFactory; 78 /** @var BacklinkCacheFactory */ 79 private $backlinkCacheFactory; 80 81 /** @var bool */ 82 private $isDeletePageUnitTest = false; 83 84 /** @var WikiPage */ 85 private $page; 86 /** @var Authority */ 87 private $deleter; 88 89 /** @var bool */ 90 private $suppress = false; 91 /** @var string[] */ 92 private $tags = []; 93 /** @var string */ 94 private $logSubtype = 'delete'; 95 /** @var bool */ 96 private $forceImmediate = false; 97 98 /** @var string|array */ 99 private $legacyHookErrors = ''; 100 /** @var bool */ 101 private $mergeLegacyHookErrors = true; 102 103 /** @var int[]|null */ 104 private $successfulDeletionsIDs; 105 /** @var bool|null */ 106 private $wasScheduled; 107 /** @var bool Whether a deletion was attempted */ 108 private $attemptedDeletion = false; 109 110 /** 111 * @param HookContainer $hookContainer 112 * @param RevisionStore $revisionStore 113 * @param LBFactory $lbFactory 114 * @param JobQueueGroup $jobQueueGroup 115 * @param CommentStore $commentStore 116 * @param ServiceOptions $serviceOptions 117 * @param BagOStuff $recentDeletesCache 118 * @param string $localWikiID 119 * @param string $webRequestID 120 * @param WikiPageFactory $wikiPageFactory 121 * @param UserFactory $userFactory 122 * @param ProperPageIdentity $page 123 * @param Authority $deleter 124 * @param BacklinkCacheFactory $backlinkCacheFactory 125 */ 126 public function __construct( 127 HookContainer $hookContainer, 128 RevisionStore $revisionStore, 129 LBFactory $lbFactory, 130 JobQueueGroup $jobQueueGroup, 131 CommentStore $commentStore, 132 ServiceOptions $serviceOptions, 133 BagOStuff $recentDeletesCache, 134 string $localWikiID, 135 string $webRequestID, 136 WikiPageFactory $wikiPageFactory, 137 UserFactory $userFactory, 138 ProperPageIdentity $page, 139 Authority $deleter, 140 BacklinkCacheFactory $backlinkCacheFactory 141 ) { 142 $this->hookRunner = new HookRunner( $hookContainer ); 143 $this->revisionStore = $revisionStore; 144 $this->lbFactory = $lbFactory; 145 $this->loadBalancer = $this->lbFactory->getMainLB(); 146 $this->jobQueueGroup = $jobQueueGroup; 147 $this->commentStore = $commentStore; 148 $serviceOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); 149 $this->options = $serviceOptions; 150 $this->recentDeletesCache = $recentDeletesCache; 151 $this->localWikiID = $localWikiID; 152 $this->webRequestID = $webRequestID; 153 $this->userFactory = $userFactory; 154 155 $this->page = $wikiPageFactory->newFromTitle( $page ); 156 $this->deleter = $deleter; 157 $this->backlinkCacheFactory = $backlinkCacheFactory; 158 } 159 160 /** 161 * @internal BC method for use by WikiPage::doDeleteArticleReal only. 162 * @return array|string 163 */ 164 public function getLegacyHookErrors() { 165 return $this->legacyHookErrors; 166 } 167 168 /** 169 * @internal BC method for use by WikiPage::doDeleteArticleReal only. 170 * @return self 171 */ 172 public function keepLegacyHookErrorsSeparate(): self { 173 $this->mergeLegacyHookErrors = false; 174 return $this; 175 } 176 177 /** 178 * If true, suppress all revisions and log the deletion in the suppression log instead of 179 * the deletion log. 180 * 181 * @param bool $suppress 182 * @return self For chaining 183 */ 184 public function setSuppress( bool $suppress ): self { 185 $this->suppress = $suppress; 186 return $this; 187 } 188 189 /** 190 * Change tags to apply to the deletion action 191 * 192 * @param string[] $tags 193 * @return self For chaining 194 */ 195 public function setTags( array $tags ): self { 196 $this->tags = $tags; 197 return $this; 198 } 199 200 /** 201 * Set a specific log subtype for the deletion log entry. 202 * 203 * @param string $logSubtype 204 * @return self For chaining 205 */ 206 public function setLogSubtype( string $logSubtype ): self { 207 $this->logSubtype = $logSubtype; 208 return $this; 209 } 210 211 /** 212 * If false, allows deleting over time via the job queue 213 * 214 * @param bool $forceImmediate 215 * @return self For chaining 216 */ 217 public function forceImmediate( bool $forceImmediate ): self { 218 $this->forceImmediate = $forceImmediate; 219 return $this; 220 } 221 222 /** 223 * @internal FIXME: Hack used when running the DeletePage unit test to disable some legacy code. 224 * @codeCoverageIgnore 225 * @param bool $test 226 */ 227 public function setIsDeletePageUnitTest( bool $test ): void { 228 if ( !defined( 'MW_PHPUNIT_TEST' ) ) { 229 throw new BadMethodCallException( __METHOD__ . ' can only be used in tests!' ); 230 } 231 $this->isDeletePageUnitTest = $test; 232 } 233 234 /** 235 * Called before attempting a deletion, allows the result getters to be used 236 */ 237 private function setDeletionAttempted(): void { 238 $this->attemptedDeletion = true; 239 $this->successfulDeletionsIDs = []; 240 $this->wasScheduled = false; 241 } 242 243 /** 244 * Asserts that a deletion operation was attempted 245 * @throws BadMethodCallException 246 */ 247 private function assertDeletionAttempted(): void { 248 if ( !$this->attemptedDeletion ) { 249 throw new BadMethodCallException( 'No deletion was attempted' ); 250 } 251 } 252 253 /** 254 * @return int[] Array of log IDs of successful deletions 255 * @throws BadMethodCallException If no deletions were attempted 256 */ 257 public function getSuccessfulDeletionsIDs(): array { 258 $this->assertDeletionAttempted(); 259 return $this->successfulDeletionsIDs; 260 } 261 262 /** 263 * @return bool Whether (part of) the deletion was scheduled 264 * @throws BadMethodCallException If no deletions were attempted 265 */ 266 public function deletionWasScheduled(): bool { 267 $this->assertDeletionAttempted(); 268 return $this->wasScheduled; 269 } 270 271 /** 272 * Same as deleteUnsafe, but checks permissions. 273 * 274 * @param string $reason 275 * @return StatusValue 276 */ 277 public function deleteIfAllowed( string $reason ): StatusValue { 278 $this->setDeletionAttempted(); 279 $status = $this->authorizeDeletion(); 280 if ( !$status->isGood() ) { 281 return $status; 282 } 283 284 return $this->deleteUnsafe( $reason ); 285 } 286 287 /** 288 * @return PermissionStatus 289 */ 290 private function authorizeDeletion(): PermissionStatus { 291 $status = PermissionStatus::newEmpty(); 292 $this->deleter->authorizeWrite( 'delete', $this->page, $status ); 293 if ( 294 !$this->deleter->authorizeWrite( 'bigdelete', $this->page ) && 295 $this->isBigDeletion() 296 ) { 297 $status->fatal( 'delete-toobig', Message::numParam( $this->options->get( 'DeleteRevisionsLimit' ) ) ); 298 } 299 if ( $this->tags ) { 300 $status->merge( ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->deleter ) ); 301 } 302 return $status; 303 } 304 305 /** 306 * @return bool 307 */ 308 private function isBigDeletion(): bool { 309 $revLimit = $this->options->get( 'DeleteRevisionsLimit' ); 310 if ( !$revLimit ) { 311 return false; 312 } 313 314 $revCount = $this->revisionStore->countRevisionsByPageId( 315 $this->loadBalancer->getConnectionRef( DB_REPLICA ), 316 $this->page->getId() 317 ); 318 319 return $revCount > $revLimit; 320 } 321 322 /** 323 * Determines if this deletion would be batched (executed over time by the job queue) 324 * or not (completed in the same request as the delete call). 325 * 326 * It is unlikely but possible that an edit from another request could push the page over the 327 * batching threshold after this function is called, but before the caller acts upon the 328 * return value. Callers must decide for themselves how to deal with this. $safetyMargin 329 * is provided as an unreliable but situationally useful help for some common cases. 330 * 331 * @param int $safetyMargin Added to the revision count when checking for batching 332 * @return bool True if deletion would be batched, false otherwise 333 */ 334 public function isBatchedDelete( int $safetyMargin = 0 ): bool { 335 $revCount = $this->revisionStore->countRevisionsByPageId( 336 $this->loadBalancer->getConnectionRef( DB_REPLICA ), 337 $this->page->getId() 338 ); 339 $revCount += $safetyMargin; 340 341 return $revCount >= $this->options->get( 'DeleteRevisionsBatchSize' ); 342 } 343 344 /** 345 * Back-end article deletion: deletes the article with database consistency, writes logs, purges caches. 346 * @note This method doesn't check user permissions. Use deleteIfAllowed for that. 347 * 348 * @param string $reason Delete reason for deletion log 349 * @return Status Status object: 350 * - If successful (or scheduled), a good Status 351 * - If the page couldn't be deleted because it wasn't found, a Status with a non-fatal 'cannotdelete' error. 352 * - A fatal Status otherwise. 353 */ 354 public function deleteUnsafe( string $reason ): Status { 355 $this->setDeletionAttempted(); 356 $status = Status::newGood(); 357 358 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter ); 359 if ( !$this->hookRunner->onArticleDelete( 360 $this->page, $legacyDeleter, $reason, $this->legacyHookErrors, $status, $this->suppress ) 361 ) { 362 if ( $this->mergeLegacyHookErrors && $this->legacyHookErrors !== '' ) { 363 if ( is_string( $this->legacyHookErrors ) ) { 364 $this->legacyHookErrors = [ $this->legacyHookErrors ]; 365 } 366 foreach ( $this->legacyHookErrors as $legacyError ) { 367 $status->fatal( new RawMessage( $legacyError ) ); 368 } 369 } 370 if ( $status->isOK() ) { 371 // Hook aborted but didn't set a fatal status 372 $status->fatal( 'delete-hook-aborted' ); 373 } 374 return $status; 375 } 376 377 // Use a new Status in case a hook handler put something here without aborting. 378 $status = Status::newGood(); 379 $hookRes = $this->hookRunner->onPageDelete( $this->page, $this->deleter, $reason, $status, $this->suppress ); 380 if ( !$hookRes && !$status->isGood() ) { 381 // Note: as per the PageDeleteHook documentation, `return false` is ignored if $status is good. 382 return $status; 383 } 384 385 return $this->deleteInternal( $reason ); 386 } 387 388 /** 389 * @internal The only external caller allowed is DeletePageJob. 390 * Back-end article deletion 391 * 392 * Only invokes batching via the job queue if necessary per DeleteRevisionsBatchSize. 393 * Deletions can often be completed inline without involving the job queue. 394 * 395 * Potentially called many times per deletion operation for pages with many revisions. 396 * @param string $reason 397 * @param string|null $webRequestId 398 * @return Status 399 */ 400 public function deleteInternal( string $reason, ?string $webRequestId = null ): Status { 401 // The following is necessary for direct calls from the outside 402 $this->setDeletionAttempted(); 403 404 $title = $this->page->getTitle(); 405 $status = Status::newGood(); 406 407 $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY ); 408 $dbw->startAtomic( __METHOD__ ); 409 410 $this->page->loadPageData( WikiPage::READ_LATEST ); 411 $id = $this->page->getId(); 412 // T98706: lock the page from various other updates but avoid using 413 // WikiPage::READ_LOCKING as that will carry over the FOR UPDATE to 414 // the revisions queries (which also JOIN on user). Only lock the page 415 // row and CAS check on page_latest to see if the trx snapshot matches. 416 $lockedLatest = $this->page->lockAndGetLatest(); 417 if ( $id === 0 || $this->page->getLatest() !== $lockedLatest ) { 418 $dbw->endAtomic( __METHOD__ ); 419 // Page not there or trx snapshot is stale 420 $status->error( 'cannotdelete', wfEscapeWikiText( $title->getPrefixedText() ) ); 421 return $status; 422 } 423 424 // At this point we are now committed to returning an OK 425 // status unless some DB query error or other exception comes up. 426 // This way callers don't have to call rollback() if $status is bad 427 // unless they actually try to catch exceptions (which is rare). 428 429 // we need to remember the old content so we can use it to generate all deletion updates. 430 $revisionRecord = $this->page->getRevisionRecord(); 431 if ( !$revisionRecord ) { 432 throw new LogicException( "No revisions for $this->page?" ); 433 } 434 try { 435 $content = $this->page->getContent( RevisionRecord::RAW ); 436 } catch ( Exception $ex ) { 437 wfLogWarning( __METHOD__ . ': failed to load content during deletion! ' 438 . $ex->getMessage() ); 439 440 $content = null; 441 } 442 443 // Archive revisions. In immediate mode, archive all revisions. Otherwise, archive 444 // one batch of revisions and defer archival of any others to the job queue. 445 $explictTrxLogged = false; 446 while ( true ) { 447 $done = $this->archiveRevisions( $id ); 448 if ( $done || !$this->forceImmediate ) { 449 break; 450 } 451 $dbw->endAtomic( __METHOD__ ); 452 if ( $dbw->explicitTrxActive() ) { 453 // Explict transactions may never happen here in practice. Log to be sure. 454 if ( !$explictTrxLogged ) { 455 $explictTrxLogged = true; 456 LoggerFactory::getInstance( 'wfDebug' )->debug( 457 'explicit transaction active in ' . __METHOD__ . ' while deleting {title}', [ 458 'title' => $title->getText(), 459 ] ); 460 } 461 continue; 462 } 463 if ( $dbw->trxLevel() ) { 464 $dbw->commit( __METHOD__ ); 465 } 466 $this->lbFactory->waitForReplication(); 467 $dbw->startAtomic( __METHOD__ ); 468 } 469 470 if ( !$done ) { 471 $dbw->endAtomic( __METHOD__ ); 472 473 $jobParams = [ 474 'namespace' => $title->getNamespace(), 475 'title' => $title->getDBkey(), 476 'wikiPageId' => $id, 477 'requestId' => $webRequestId ?? $this->webRequestID, 478 'reason' => $reason, 479 'suppress' => $this->suppress, 480 'userId' => $this->deleter->getUser()->getId(), 481 'tags' => json_encode( $this->tags ), 482 'logsubtype' => $this->logSubtype, 483 ]; 484 485 $job = new DeletePageJob( $jobParams ); 486 $this->jobQueueGroup->push( $job ); 487 $this->wasScheduled = true; 488 return $status; 489 } 490 491 // Get archivedRevisionCount by db query, because there's no better alternative. 492 // Jobs cannot pass a count of archived revisions to the next job, because additional 493 // deletion operations can be started while the first is running. Jobs from each 494 // gracefully interleave, but would not know about each other's count. Deduplication 495 // in the job queue to avoid simultaneous deletion operations would add overhead. 496 // Number of archived revisions cannot be known beforehand, because edits can be made 497 // while deletion operations are being processed, changing the number of archivals. 498 $archivedRevisionCount = $dbw->selectRowCount( 499 'archive', 500 '*', 501 [ 502 'ar_namespace' => $title->getNamespace(), 503 'ar_title' => $title->getDBkey(), 504 'ar_page_id' => $id 505 ], __METHOD__ 506 ); 507 508 // Clone the title and wikiPage, so we have the information we need when 509 // we log and run the ArticleDeleteComplete hook. 510 $logTitle = clone $title; 511 $wikiPageBeforeDelete = clone $this->page; 512 513 // Now that it's safely backed up, delete it 514 $dbw->delete( 'page', [ 'page_id' => $id ], __METHOD__ ); 515 516 // Log the deletion, if the page was suppressed, put it in the suppression log instead 517 $logtype = $this->suppress ? 'suppress' : 'delete'; 518 519 $logEntry = new ManualLogEntry( $logtype, $this->logSubtype ); 520 $logEntry->setPerformer( $this->deleter->getUser() ); 521 $logEntry->setTarget( $logTitle ); 522 $logEntry->setComment( $reason ); 523 $logEntry->addTags( $this->tags ); 524 if ( !$this->isDeletePageUnitTest ) { 525 // TODO: Remove conditional once ManualLogEntry is servicified (T253717) 526 $logid = $logEntry->insert(); 527 528 $dbw->onTransactionPreCommitOrIdle( 529 static function () use ( $logEntry, $logid ) { 530 // T58776: avoid deadlocks (especially from FileDeleteForm) 531 $logEntry->publish( $logid ); 532 }, 533 __METHOD__ 534 ); 535 } else { 536 $logid = 42; 537 } 538 539 $dbw->endAtomic( __METHOD__ ); 540 541 $this->doDeleteUpdates( $revisionRecord ); 542 543 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter ); 544 $this->hookRunner->onArticleDeleteComplete( 545 $wikiPageBeforeDelete, 546 $legacyDeleter, 547 $reason, 548 $id, 549 $content, 550 $logEntry, 551 $archivedRevisionCount 552 ); 553 $this->hookRunner->onPageDeleteComplete( 554 $wikiPageBeforeDelete, 555 $this->deleter, 556 $reason, 557 $id, 558 $revisionRecord, 559 $logEntry, 560 $archivedRevisionCount 561 ); 562 $this->successfulDeletionsIDs[] = $logid; 563 564 // Show log excerpt on 404 pages rather than just a link 565 $key = $this->recentDeletesCache->makeKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) ); 566 $this->recentDeletesCache->set( $key, 1, BagOStuff::TTL_DAY ); 567 568 return $status; 569 } 570 571 /** 572 * Archives revisions as part of page deletion. 573 * 574 * @param int $id 575 * @return bool 576 */ 577 private function archiveRevisions( int $id ): bool { 578 // Given the lock above, we can be confident in the title and page ID values 579 $namespace = $this->page->getTitle()->getNamespace(); 580 $dbKey = $this->page->getTitle()->getDBkey(); 581 582 $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY ); 583 584 $revQuery = $this->revisionStore->getQueryInfo(); 585 $bitfield = false; 586 587 // Bitfields to further suppress the content 588 if ( $this->suppress ) { 589 $bitfield = RevisionRecord::SUPPRESSED_ALL; 590 $revQuery['fields'] = array_diff( $revQuery['fields'], [ 'rev_deleted' ] ); 591 } 592 593 // For now, shunt the revision data into the archive table. 594 // Text is *not* removed from the text table; bulk storage 595 // is left intact to avoid breaking block-compression or 596 // immutable storage schemes. 597 // In the future, we may keep revisions and mark them with 598 // the rev_deleted field, which is reserved for this purpose. 599 600 // Lock rows in `revision` and its temp tables, but not any others. 601 // Note array_intersect() preserves keys from the first arg, and we're 602 // assuming $revQuery has `revision` primary and isn't using subtables 603 // for anything we care about. 604 $dbw->lockForUpdate( 605 array_intersect( 606 $revQuery['tables'], 607 [ 'revision', 'revision_comment_temp', 'revision_actor_temp' ] 608 ), 609 [ 'rev_page' => $id ], 610 __METHOD__, 611 [], 612 $revQuery['joins'] 613 ); 614 615 $deleteBatchSize = $this->options->get( 'DeleteRevisionsBatchSize' ); 616 // Get as many of the page revisions as we are allowed to. The +1 lets us recognize the 617 // unusual case where there were exactly $deleteBatchSize revisions remaining. 618 $res = $dbw->select( 619 $revQuery['tables'], 620 $revQuery['fields'], 621 [ 'rev_page' => $id ], 622 __METHOD__, 623 [ 'ORDER BY' => 'rev_timestamp ASC, rev_id ASC', 'LIMIT' => $deleteBatchSize + 1 ], 624 $revQuery['joins'] 625 ); 626 627 // Build their equivalent archive rows 628 $rowsInsert = []; 629 $revids = []; 630 631 /** @var int[] Revision IDs of edits that were made by IPs */ 632 $ipRevIds = []; 633 634 $done = true; 635 foreach ( $res as $row ) { 636 if ( count( $revids ) >= $deleteBatchSize ) { 637 $done = false; 638 break; 639 } 640 641 $comment = $this->commentStore->getComment( 'rev_comment', $row ); 642 $rowInsert = [ 643 'ar_namespace' => $namespace, 644 'ar_title' => $dbKey, 645 'ar_actor' => $row->rev_actor, 646 'ar_timestamp' => $row->rev_timestamp, 647 'ar_minor_edit' => $row->rev_minor_edit, 648 'ar_rev_id' => $row->rev_id, 649 'ar_parent_id' => $row->rev_parent_id, 650 'ar_len' => $row->rev_len, 651 'ar_page_id' => $id, 652 'ar_deleted' => $this->suppress ? $bitfield : $row->rev_deleted, 653 'ar_sha1' => $row->rev_sha1, 654 ] + $this->commentStore->insert( $dbw, 'ar_comment', $comment ); 655 656 $rowsInsert[] = $rowInsert; 657 $revids[] = $row->rev_id; 658 659 // Keep track of IP edits, so that the corresponding rows can 660 // be deleted in the ip_changes table. 661 if ( (int)$row->rev_user === 0 && IPUtils::isValid( $row->rev_user_text ) ) { 662 $ipRevIds[] = $row->rev_id; 663 } 664 } 665 666 // This conditional is just a sanity check 667 if ( count( $revids ) > 0 ) { 668 // Copy them into the archive table 669 $dbw->insert( 'archive', $rowsInsert, __METHOD__ ); 670 671 $dbw->delete( 'revision', [ 'rev_id' => $revids ], __METHOD__ ); 672 $dbw->delete( 'revision_comment_temp', [ 'revcomment_rev' => $revids ], __METHOD__ ); 673 if ( $this->options->get( 'ActorTableSchemaMigrationStage' ) & SCHEMA_COMPAT_WRITE_TEMP ) { 674 $dbw->delete( 'revision_actor_temp', [ 'revactor_rev' => $revids ], __METHOD__ ); 675 } 676 677 // Also delete records from ip_changes as applicable. 678 if ( count( $ipRevIds ) > 0 ) { 679 $dbw->delete( 'ip_changes', [ 'ipc_rev_id' => $ipRevIds ], __METHOD__ ); 680 } 681 } 682 683 return $done; 684 } 685 686 /** 687 * @private Public for BC only 688 * Do some database updates after deletion 689 * 690 * @param RevisionRecord $revRecord The current page revision at the time of 691 * deletion, used when determining the required updates. This may be needed because 692 * $this->page->getRevisionRecord() may already return null when the page proper was deleted. 693 */ 694 public function doDeleteUpdates( RevisionRecord $revRecord ): void { 695 try { 696 $countable = $this->page->isCountable(); 697 } catch ( Exception $ex ) { 698 // fallback for deleting broken pages for which we cannot load the content for 699 // some reason. Note that doDeleteArticleReal() already logged this problem. 700 $countable = false; 701 } 702 703 // Update site status 704 if ( !$this->isDeletePageUnitTest ) { 705 // TODO Remove conditional once DeferredUpdates is servicified (T265749) 706 DeferredUpdates::addUpdate( SiteStatsUpdate::factory( 707 [ 'edits' => 1, 'articles' => -$countable, 'pages' => -1 ] 708 ) ); 709 710 // Delete pagelinks, update secondary indexes, etc 711 $updates = $this->getDeletionUpdates( $revRecord ); 712 foreach ( $updates as $update ) { 713 DeferredUpdates::addUpdate( $update ); 714 } 715 } 716 717 // Reparse any pages transcluding this page 718 LinksUpdate::queueRecursiveJobsForTable( 719 $this->page->getTitle(), 720 'templatelinks', 721 'delete-page', 722 $this->deleter->getUser()->getName(), 723 $this->backlinkCacheFactory->getBacklinkCache( $this->page->getTitle() ) 724 ); 725 // Reparse any pages including this image 726 if ( $this->page->getTitle()->getNamespace() === NS_FILE ) { 727 LinksUpdate::queueRecursiveJobsForTable( 728 $this->page->getTitle(), 729 'imagelinks', 730 'delete-page', 731 $this->deleter->getUser()->getName(), 732 $this->backlinkCacheFactory->getBacklinkCache( $this->page->getTitle() ) 733 ); 734 } 735 736 if ( !$this->isDeletePageUnitTest ) { 737 // TODO Remove conditional once WikiPage::onArticleDelete is moved to a proper service 738 // Clear caches 739 WikiPage::onArticleDelete( $this->page->getTitle() ); 740 } 741 742 ResourceLoaderWikiModule::invalidateModuleCache( 743 $this->page->getTitle(), 744 $revRecord, 745 null, 746 $this->localWikiID 747 ); 748 749 // Reset the page object and the Title object 750 $this->page->loadFromRow( false, WikiPage::READ_LATEST ); 751 752 if ( !$this->isDeletePageUnitTest ) { 753 // TODO Remove conditional once DeferredUpdates is servicified (T265749) 754 // Search engine 755 DeferredUpdates::addUpdate( new SearchUpdate( $this->page->getId(), $this->page->getTitle() ) ); 756 } 757 } 758 759 /** 760 * @private Public for BC only 761 * Returns a list of updates to be performed when the page is deleted. The 762 * updates should remove any information about this page from secondary data 763 * stores such as links tables. 764 * 765 * @param RevisionRecord $rev The revision being deleted. 766 * @return DeferrableUpdate[] 767 */ 768 public function getDeletionUpdates( RevisionRecord $rev ): array { 769 $slotContent = array_map( static function ( SlotRecord $slot ) { 770 return $slot->getContent(); 771 }, $rev->getSlots()->getSlots() ); 772 773 $allUpdates = [ new LinksDeletionUpdate( $this->page ) ]; 774 775 // NOTE: once Content::getDeletionUpdates() is removed, we only need the content 776 // model here, not the content object! 777 // TODO: consolidate with similar logic in DerivedPageDataUpdater::getSecondaryDataUpdates() 778 /** @var ?Content $content */ 779 $content = null; // in case $slotContent is zero-length 780 foreach ( $slotContent as $role => $content ) { 781 $handler = $content->getContentHandler(); 782 783 $updates = $handler->getDeletionUpdates( 784 $this->page->getTitle(), 785 $role 786 ); 787 788 $allUpdates = array_merge( $allUpdates, $updates ); 789 } 790 791 $this->hookRunner->onPageDeletionDataUpdates( 792 $this->page->getTitle(), $rev, $allUpdates ); 793 794 // TODO: hard deprecate old hook in 1.33 795 $this->hookRunner->onWikiPageDeletionUpdates( $this->page, $content, $allUpdates ); 796 return $allUpdates; 797 } 798} 799