72 private $isDeletePageUnitTest =
false;
74 private $suppress =
false;
78 private $logSubtype =
'delete';
80 private $forceImmediate =
false;
82 private $associatedTalk;
85 private $legacyHookErrors =
'';
87 private $mergeLegacyHookErrors =
true;
93 private $successfulDeletionsIDs;
98 private $wasScheduled;
100 private $attemptedDeletion =
false;
116 private readonly
BagOStuff $recentDeletesCache,
117 private readonly
string $webRequestID,
129 $this->hookRunner =
new HookRunner( $hookContainer );
130 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
131 $this->page = $wikiPageFactory->newFromTitle( $page );
139 return $this->legacyHookErrors;
147 $this->mergeLegacyHookErrors = false;
159 $this->suppress = $suppress;
169 public function setTags( array $tags ): self {
181 $this->logSubtype = $logSubtype;
192 $this->forceImmediate = $forceImmediate;
201 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
202 return StatusValue::newFatal(
'delete-error-associated-alreadytalk' );
205 $talkPage = $this->wikiPageFactory->newFromLinkTarget(
206 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
208 if ( !$talkPage->exists() ) {
209 return StatusValue::newFatal(
'delete-error-associated-doesnotexist' );
211 return StatusValue::newGood();
227 $this->associatedTalk =
null;
231 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
232 throw new BadMethodCallException(
"Cannot delete associated talk page of a talk page! ($this->page)" );
235 $this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget(
236 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
247 if ( !defined(
'MW_PHPUNIT_TEST' ) ) {
248 throw new LogicException( __METHOD__ .
' can only be used in tests!' );
250 $this->isDeletePageUnitTest = $test;
259 $this->attemptedDeletion = true;
260 $this->successfulDeletionsIDs = [ self::PAGE_BASE => null ];
261 $this->wasScheduled = [ self::PAGE_BASE => null ];
262 if ( $this->associatedTalk ) {
263 $this->successfulDeletionsIDs[self::PAGE_TALK] =
null;
264 $this->wasScheduled[self::PAGE_TALK] =
null;
273 private function assertDeletionAttempted(): void {
274 if ( !$this->attemptedDeletion ) {
275 throw new BadMethodCallException(
'No deletion was attempted' );
284 $this->assertDeletionAttempted();
285 return $this->successfulDeletionsIDs;
293 $this->assertDeletionAttempted();
294 return $this->wasScheduled;
304 $this->setDeletionAttempted();
305 $status = $this->authorizeDeletion();
306 if ( !$status->isGood() ) {
310 return $this->deleteUnsafe( $reason );
313 private function authorizeDeletion(): PermissionStatus {
314 $status = PermissionStatus::newEmpty();
315 $this->deleter->authorizeWrite(
'delete', $this->page, $status );
316 if ( $this->associatedTalk ) {
317 $this->deleter->authorizeWrite(
'delete', $this->associatedTalk, $status );
319 if ( !$this->deleter->isAllowed(
'bigdelete' ) && $this->isBigDeletion() ) {
321 'delete-toomanyrevisions',
322 Message::numParam( $this->options->get( MainConfigNames::DeleteRevisionsLimit ) )
326 $status->merge( ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->deleter ) );
331 private function isBigDeletion(): bool {
332 $revLimit = $this->options->get( MainConfigNames::DeleteRevisionsLimit );
337 $dbr = $this->lbFactory->getReplicaDatabase();
338 $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
339 if ( $this->associatedTalk ) {
340 $revCount += $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
343 return $revCount > $revLimit;
359 $dbr = $this->lbFactory->getReplicaDatabase();
360 $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
361 $revCount += $safetyMargin;
363 if ( $revCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize ) ) {
365 } elseif ( !$this->associatedTalk ) {
369 $talkRevCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
370 $talkRevCount += $safetyMargin;
372 return $talkRevCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
386 $this->setDeletionAttempted();
387 $origReason = $reason;
388 $hookStatus = $this->runPreDeleteHooks( $this->page, $reason );
389 if ( !$hookStatus->isGood() ) {
392 if ( $this->associatedTalk ) {
393 $talkReason = $this->contLangMsgTextFormatter->format(
394 MessageValue::new(
'delete-talk-summary-prefix' )->plaintextParams( $origReason )
396 $talkHookStatus = $this->runPreDeleteHooks( $this->associatedTalk, $talkReason );
397 if ( !$talkHookStatus->isGood() ) {
398 return $talkHookStatus;
402 $status = $this->deleteInternal( $this->page, self::PAGE_BASE, $reason );
403 if ( !$this->associatedTalk || !$status->isGood() ) {
409 $status->merge( $this->deleteInternal( $this->associatedTalk, self::PAGE_TALK, $talkReason ) );
418 private function runPreDeleteHooks( WikiPage $page,
string &$reason ): Status {
419 $status = Status::newGood();
421 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
422 if ( !$this->hookRunner->onArticleDelete(
423 $page, $legacyDeleter, $reason, $this->legacyHookErrors, $status, $this->suppress )
425 if ( $this->mergeLegacyHookErrors && $this->legacyHookErrors !==
'' ) {
426 if ( is_string( $this->legacyHookErrors ) ) {
427 $this->legacyHookErrors = [ $this->legacyHookErrors ];
429 foreach ( $this->legacyHookErrors as $legacyError ) {
430 $status->fatal(
new RawMessage( $legacyError ) );
433 if ( $status->isOK() ) {
435 $status->fatal(
'delete-hook-aborted' );
441 $status = Status::newGood();
442 $hookRes = $this->hookRunner->onPageDelete( $page, $this->deleter, $reason, $status, $this->suppress );
443 if ( !$hookRes && !$status->isGood() ) {
447 return Status::newGood();
469 ?
string $webRequestId =
null,
472 $title = $page->getTitle();
473 $status = Status::newGood();
475 $dbw = $this->lbFactory->getPrimaryDatabase();
476 $dbw->startAtomic( __METHOD__ );
479 $id = $page->
getId();
485 if ( $id === 0 || $page->
getLatest() !== $lockedLatest ) {
486 $dbw->endAtomic( __METHOD__ );
488 $status->error(
'cannotdelete',
wfEscapeWikiText( $title->getPrefixedText() ) );
499 if ( !$revisionRecord ) {
500 throw new LogicException(
"No revisions for $page?" );
503 $content = $page->
getContent( RevisionRecord::RAW );
504 }
catch ( TimeoutException $e ) {
506 }
catch ( Exception $ex ) {
507 wfLogWarning( __METHOD__ .
': failed to load content during deletion! '
508 . $ex->getMessage() );
516 $done = $this->archiveRevisions( $page, $id );
517 if ( $done || !$this->forceImmediate ) {
520 $dbw->endAtomic( __METHOD__ );
521 $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
522 $dbw->startAtomic( __METHOD__ );
526 $dbw->endAtomic( __METHOD__ );
529 'namespace' => $title->getNamespace(),
530 'title' => $title->getDBkey(),
532 'requestId' => $webRequestId ?? $this->webRequestID,
534 'suppress' => $this->suppress,
535 'userId' => $this->deleter->getUser()->getId(),
536 'tags' => json_encode( $this->tags ),
537 'logsubtype' => $this->logSubtype,
538 'pageRole' => $pageRole,
541 $job =
new DeletePageJob( $jobParams );
542 $this->jobQueueGroup->push(
$job );
543 $this->wasScheduled[$pageRole] =
true;
546 $this->wasScheduled[$pageRole] =
false;
555 $archivedRevisionCount = $dbw->newSelectQueryBuilder()
559 'ar_namespace' => $title->getNamespace(),
560 'ar_title' => $title->getDBkey(),
563 ->caller( __METHOD__ )->fetchRowCount();
571 $logTitle = clone $title;
572 $wikiPageBeforeDelete = clone $page;
576 $delete = $dbw->newDeleteQueryBuilder()
577 ->deleteFrom(
'page' )
578 ->where( [
'page_id' => $id ] )
579 ->caller( __METHOD__ );
581 $this->linkWriteDuplicator->duplicate( $delete );
584 $logtype = $this->suppress ?
'suppress' :
'delete';
586 $logEntry =
new ManualLogEntry( $logtype, $this->logSubtype );
587 $logEntry->setPerformer( $this->deleter->getUser() );
588 $logEntry->setTarget( $logTitle );
589 $logEntry->setComment( $reason );
590 $logEntry->addTags( $this->tags );
591 if ( !$this->isDeletePageUnitTest ) {
593 $logid = $logEntry->insert();
595 $dbw->onTransactionPreCommitOrIdle(
596 static function () use ( $logEntry, $logid ) {
598 $logEntry->publish( $logid );
606 $this->eventDispatcher->dispatch(
new PageDeletedEvent(
609 $this->deleter->getUser(),
611 [ PageDeletedEvent::FLAG_SUPPRESSED => $this->suppress ],
612 $logEntry->getTimestamp(),
614 $archivedRevisionCount,
615 $pageBeforeDelete->isRedirect() ? $this->redirectStore->getRedirectTarget( $page ) : null
616 ), $this->lbFactory );
618 $dbw->endAtomic( __METHOD__ );
620 $this->doDeleteUpdates( $wikiPageBeforeDelete, $revisionRecord );
623 $page->loadFromRow(
false, IDBAccessObject::READ_LATEST );
626 Title::clearCaches();
628 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
629 $this->hookRunner->onArticleDeleteComplete(
630 $wikiPageBeforeDelete,
636 $archivedRevisionCount
638 $this->hookRunner->onPageDeleteComplete(
645 $archivedRevisionCount
647 $this->successfulDeletionsIDs[$pageRole] = $logid;
650 $this->redirectStore->clearCache( $page );
653 $key = $this->recentDeletesCache->makeKey(
'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
654 $this->recentDeletesCache->set( $key, 1, BagOStuff::TTL_DAY );
666 private function archiveRevisions( WikiPage $page,
int $id ): bool {
668 $namespace = $page->
getTitle()->getNamespace();
669 $dbKey = $page->getTitle()->getDBkey();
671 $dbw = $this->lbFactory->getPrimaryDatabase();
673 $revQuery = $this->revisionStore->getQueryInfo();
677 if ( $this->suppress ) {
678 $bitfield = RevisionRecord::SUPPRESSED_ALL;
679 $revQuery[
'fields'] = array_diff( $revQuery[
'fields'], [
'rev_deleted' ] );
693 $lockQuery = $revQuery;
694 $lockQuery[
'tables'] = array_intersect(
696 [
'revision',
'revision_comment_temp' ]
698 unset( $lockQuery[
'fields'] );
699 $dbw->newSelectQueryBuilder()
700 ->queryInfo( $lockQuery )
701 ->where( [
'rev_page' => $id ] )
703 ->caller( __METHOD__ )
706 $deleteBatchSize = $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
709 $res = $dbw->newSelectQueryBuilder()
710 ->queryInfo( $revQuery )
711 ->where( [
'rev_page' => $id ] )
712 ->orderBy( [
'rev_timestamp',
'rev_id' ] )
713 ->limit( $deleteBatchSize + 1 )
714 ->caller( __METHOD__ )
726 foreach ( $res as $row ) {
727 if ( count( $revids ) >= $deleteBatchSize ) {
732 $comment = $this->commentStore->getComment(
'rev_comment', $row );
734 'ar_namespace' => $namespace,
735 'ar_title' => $dbKey,
736 'ar_actor' => $row->rev_actor,
737 'ar_timestamp' => $row->rev_timestamp,
738 'ar_minor_edit' => $row->rev_minor_edit,
739 'ar_rev_id' => $row->rev_id,
740 'ar_parent_id' => $row->rev_parent_id,
741 'ar_len' => $row->rev_len,
743 'ar_deleted' => $this->suppress ? $bitfield : $row->rev_deleted,
744 ] + $this->commentStore->insert( $dbw,
'ar_comment', $comment );
746 $rowsInsert[] = $rowInsert;
747 $revids[] = $row->rev_id;
751 if ( (
int)$row->rev_user === 0 && IPUtils::isValid( $row->rev_user_text ) ) {
752 $ipRevIds[] = $row->rev_id;
757 if ( !array_key_exists( (
int)$row->rev_user, $revAuthors ) ) {
758 $userIdentity =
new UserIdentityValue( (
int)$row->rev_user, $row->rev_user_text );
759 $timestamp = ConvertibleTimestamp::convert( TS::MW, $row->rev_timestamp );
760 $revAuthors[$userIdentity->getId()] = [ $userIdentity, $timestamp ];
764 $this->userEditTracker->invalidateCachedFirstEditTimestamps( array_values( $revAuthors ) );
766 if ( count( $revids ) > 0 ) {
768 $dbw->newInsertQueryBuilder()
769 ->insertInto(
'archive' )
770 ->rows( $rowsInsert )
771 ->caller( __METHOD__ )->execute();
773 $dbw->newDeleteQueryBuilder()
774 ->deleteFrom(
'revision' )
775 ->where( [
'rev_id' => $revids ] )
776 ->caller( __METHOD__ )->execute();
778 if ( count( $ipRevIds ) > 0 ) {
779 $dbw->newDeleteQueryBuilder()
780 ->deleteFrom(
'ip_changes' )
781 ->where( [
'ipc_rev_id' => $ipRevIds ] )
782 ->caller( __METHOD__ )->execute();
797 private function doDeleteUpdates( WikiPage $pageBeforeDelete, RevisionRecord $revRecord ): void {
799 $countable = $pageBeforeDelete->isCountable();
800 }
catch ( TimeoutException $e ) {
802 }
catch ( Exception ) {
811 DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
812 [
'edits' => 1,
'articles' => $countable ? -1 : 0,
'pages' => -1 ]
816 $updates = $this->getDeletionUpdates( $pageBeforeDelete, $revRecord );
817 foreach ( $updates as $update ) {
818 DeferredUpdates::addUpdate( $update );
822 LinksUpdate::queueRecursiveJobsForTable(
823 $pageBeforeDelete->getTitle(),
826 $this->deleter->getUser()->getName(),
827 $this->backlinkCacheFactory->getBacklinkCache( $pageBeforeDelete->getTitle() )
830 if ( $pageBeforeDelete->getTitle()->getNamespace() ===
NS_FILE ) {
831 LinksUpdate::queueRecursiveJobsForTable(
832 $pageBeforeDelete->getTitle(),
835 $this->deleter->getUser()->getName(),
836 $this->backlinkCacheFactory->getBacklinkCache( $pageBeforeDelete->getTitle() )
840 if ( !$this->isDeletePageUnitTest ) {
843 WikiPage::onArticleDelete( $pageBeforeDelete->getTitle() );
856 private function getDeletionUpdates( WikiPage $page, RevisionRecord $rev ): array {
857 if ( $this->isDeletePageUnitTest ) {
861 $slotContent = array_map(
static function ( SlotRecord $slot ) {
862 return $slot->getContent();
863 }, $rev->getSlots()->getSlots() );
865 $allUpdates = [
new LinksDeletionUpdate( $page ) ];
872 foreach ( $slotContent as $role => $content ) {
873 $handler = $content->getContentHandler();
875 $updates = $handler->getDeletionUpdates(
880 $allUpdates = array_merge( $allUpdates, $updates );
883 $this->hookRunner->onPageDeletionDataUpdates(
884 $page->getTitle(), $rev, $allUpdates );
887 $this->hookRunner->onWikiPageDeletionUpdates( $page, $content, $allUpdates );
__construct(HookContainer $hookContainer, private readonly DomainEventDispatcher $eventDispatcher, private readonly RevisionStore $revisionStore, private readonly LBFactory $lbFactory, private readonly JobQueueGroup $jobQueueGroup, private readonly CommentStore $commentStore, private readonly ServiceOptions $options, private readonly BagOStuff $recentDeletesCache, private readonly string $webRequestID, private readonly WikiPageFactory $wikiPageFactory, private readonly UserFactory $userFactory, private readonly BacklinkCacheFactory $backlinkCacheFactory, private readonly NamespaceInfo $namespaceInfo, private readonly ITextFormatter $contLangMsgTextFormatter, private readonly RedirectStore $redirectStore, ProperPageIdentity $page, private readonly Authority $deleter, private readonly WriteDuplicator $linkWriteDuplicator, private readonly UserEditTracker $userEditTracker)