70 private $revisionStore;
74 private $jobQueueGroup;
76 private $commentStore;
80 private $recentDeletesCache;
84 private $webRequestID;
88 private $backlinkCacheFactory;
90 private $wikiPageFactory;
92 private $namespaceInfo;
94 private $contLangMsgTextFormatter;
97 private $isDeletePageUnitTest =
false;
105 private $suppress =
false;
109 private $logSubtype =
'delete';
111 private $forceImmediate =
false;
113 private $associatedTalk;
116 private $legacyHookErrors =
'';
118 private $mergeLegacyHookErrors =
true;
124 private $successfulDeletionsIDs;
129 private $wasScheduled;
131 private $attemptedDeletion =
false;
161 string $webRequestID,
170 $this->hookRunner =
new HookRunner( $hookContainer );
171 $this->revisionStore = $revisionStore;
172 $this->lbFactory = $lbFactory;
173 $this->jobQueueGroup = $jobQueueGroup;
174 $this->commentStore = $commentStore;
176 $this->options = $serviceOptions;
177 $this->recentDeletesCache = $recentDeletesCache;
178 $this->localWikiID = $localWikiID;
179 $this->webRequestID = $webRequestID;
180 $this->wikiPageFactory = $wikiPageFactory;
181 $this->userFactory = $userFactory;
182 $this->backlinkCacheFactory = $backlinkCacheFactory;
183 $this->namespaceInfo = $namespaceInfo;
184 $this->contLangMsgTextFormatter = $contLangMsgTextFormatter;
187 $this->deleter = $deleter;
195 return $this->legacyHookErrors;
203 $this->mergeLegacyHookErrors = false;
215 $this->suppress = $suppress;
225 public function setTags( array $tags ): self {
237 $this->logSubtype = $logSubtype;
248 $this->forceImmediate = $forceImmediate;
259 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
260 return StatusValue::newFatal(
'delete-error-associated-alreadytalk' );
263 $talkPage = $this->wikiPageFactory->newFromLinkTarget(
264 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
266 if ( !$talkPage->exists() ) {
267 return StatusValue::newFatal(
'delete-error-associated-doesnotexist' );
269 return StatusValue::newGood();
285 $this->associatedTalk =
null;
289 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
290 throw new BadMethodCallException(
"Cannot delete associated talk page of a talk page! ($this->page)" );
293 $this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget(
294 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
305 if ( !defined(
'MW_PHPUNIT_TEST' ) ) {
306 throw new LogicException( __METHOD__ .
' can only be used in tests!' );
308 $this->isDeletePageUnitTest = $test;
317 $this->attemptedDeletion = true;
318 $this->successfulDeletionsIDs = [ self::PAGE_BASE => null ];
319 $this->wasScheduled = [ self::PAGE_BASE => null ];
320 if ( $this->associatedTalk ) {
321 $this->successfulDeletionsIDs[self::PAGE_TALK] =
null;
322 $this->wasScheduled[self::PAGE_TALK] =
null;
331 private function assertDeletionAttempted(): void {
332 if ( !$this->attemptedDeletion ) {
333 throw new BadMethodCallException(
'No deletion was attempted' );
342 $this->assertDeletionAttempted();
343 return $this->successfulDeletionsIDs;
351 $this->assertDeletionAttempted();
352 return $this->wasScheduled;
362 $this->setDeletionAttempted();
363 $status = $this->authorizeDeletion();
364 if ( !$status->isGood() ) {
368 return $this->deleteUnsafe( $reason );
374 private function authorizeDeletion(): PermissionStatus {
375 $status = PermissionStatus::newEmpty();
376 $this->deleter->authorizeWrite(
'delete', $this->page, $status );
377 if ( $this->associatedTalk ) {
378 $this->deleter->authorizeWrite(
'delete', $this->associatedTalk, $status );
380 if ( !$this->deleter->isAllowed(
'bigdelete' ) && $this->isBigDeletion() ) {
382 'delete-toomanyrevisions',
383 Message::numParam( $this->options->get( MainConfigNames::DeleteRevisionsLimit ) )
395 private function isBigDeletion(): bool {
396 $revLimit = $this->options->get( MainConfigNames::DeleteRevisionsLimit );
401 $dbr = $this->lbFactory->getReplicaDatabase();
402 $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
403 if ( $this->associatedTalk ) {
404 $revCount += $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
407 return $revCount > $revLimit;
423 $dbr = $this->lbFactory->getReplicaDatabase();
424 $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
425 $revCount += $safetyMargin;
427 if ( $revCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize ) ) {
429 } elseif ( !$this->associatedTalk ) {
433 $talkRevCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
434 $talkRevCount += $safetyMargin;
436 return $talkRevCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
450 $this->setDeletionAttempted();
451 $origReason = $reason;
452 $hookStatus = $this->runPreDeleteHooks( $this->page, $reason );
453 if ( !$hookStatus->isGood() ) {
456 if ( $this->associatedTalk ) {
457 $talkReason = $this->contLangMsgTextFormatter->format(
458 MessageValue::new(
'delete-talk-summary-prefix' )->plaintextParams( $origReason )
460 $talkHookStatus = $this->runPreDeleteHooks( $this->associatedTalk, $talkReason );
461 if ( !$talkHookStatus->isGood() ) {
462 return $talkHookStatus;
466 $status = $this->deleteInternal( $this->page, self::PAGE_BASE, $reason );
467 if ( !$this->associatedTalk || !$status->isGood() ) {
474 $status->merge( $this->deleteInternal( $this->associatedTalk, self::PAGE_TALK, $talkReason ) );
483 private function runPreDeleteHooks(
WikiPage $page,
string &$reason ): Status {
484 $status = Status::newGood();
486 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
487 if ( !$this->hookRunner->onArticleDelete(
488 $page, $legacyDeleter, $reason, $this->legacyHookErrors, $status, $this->suppress )
490 if ( $this->mergeLegacyHookErrors && $this->legacyHookErrors !==
'' ) {
491 if ( is_string( $this->legacyHookErrors ) ) {
492 $this->legacyHookErrors = [ $this->legacyHookErrors ];
494 foreach ( $this->legacyHookErrors as $legacyError ) {
495 $status->fatal(
new RawMessage( $legacyError ) );
498 if ( $status->isOK() ) {
500 $status->fatal(
'delete-hook-aborted' );
506 $status = Status::newGood();
507 $hookRes = $this->hookRunner->onPageDelete( $page, $this->deleter, $reason, $status, $this->suppress );
508 if ( !$hookRes && !$status->isGood() ) {
512 return Status::newGood();
533 ?
string $webRequestId =
null
536 $status = Status::newGood();
538 $dbw = $this->lbFactory->getPrimaryDatabase();
539 $dbw->startAtomic( __METHOD__ );
542 $id = $page->
getId();
548 if ( $id === 0 || $page->
getLatest() !== $lockedLatest ) {
549 $dbw->endAtomic( __METHOD__ );
551 $status->error(
'cannotdelete',
wfEscapeWikiText( $title->getPrefixedText() ) );
562 if ( !$revisionRecord ) {
563 throw new LogicException(
"No revisions for $page?" );
566 $content = $page->
getContent( RevisionRecord::RAW );
567 }
catch ( TimeoutException $e ) {
569 }
catch ( Exception $ex ) {
570 wfLogWarning( __METHOD__ .
': failed to load content during deletion! '
571 . $ex->getMessage() );
578 $explictTrxLogged =
false;
580 $done = $this->archiveRevisions( $page, $id );
581 if ( $done || !$this->forceImmediate ) {
584 $dbw->endAtomic( __METHOD__ );
585 if ( $dbw->explicitTrxActive() ) {
587 if ( !$explictTrxLogged ) {
588 $explictTrxLogged =
true;
589 LoggerFactory::getInstance(
'wfDebug' )->debug(
590 'explicit transaction active in ' . __METHOD__ .
' while deleting {title}', [
591 'title' => $title->getText(),
596 if ( $dbw->trxLevel() ) {
597 $dbw->commit( __METHOD__ );
599 $this->lbFactory->waitForReplication();
600 $dbw->startAtomic( __METHOD__ );
604 $dbw->endAtomic( __METHOD__ );
607 'namespace' => $title->getNamespace(),
608 'title' => $title->getDBkey(),
610 'requestId' => $webRequestId ?? $this->webRequestID,
612 'suppress' => $this->suppress,
613 'userId' => $this->deleter->getUser()->getId(),
614 'tags' => json_encode( $this->tags ),
615 'logsubtype' => $this->logSubtype,
616 'pageRole' => $pageRole,
620 $this->jobQueueGroup->push(
$job );
621 $this->wasScheduled[$pageRole] =
true;
624 $this->wasScheduled[$pageRole] =
false;
633 $archivedRevisionCount = $dbw->newSelectQueryBuilder()
637 'ar_namespace' => $title->getNamespace(),
638 'ar_title' => $title->getDBkey(),
641 ->caller( __METHOD__ )->fetchRowCount();
649 $logTitle = clone $title;
650 $wikiPageBeforeDelete = clone $page;
653 $dbw->newDeleteQueryBuilder()
654 ->deleteFrom(
'page' )
655 ->where( [
'page_id' => $id ] )
656 ->caller( __METHOD__ )->execute();
659 $logtype = $this->suppress ?
'suppress' :
'delete';
662 $logEntry->setPerformer( $this->deleter->getUser() );
663 $logEntry->setTarget( $logTitle );
664 $logEntry->setComment( $reason );
665 $logEntry->addTags( $this->tags );
666 if ( !$this->isDeletePageUnitTest ) {
668 $logid = $logEntry->insert();
670 $dbw->onTransactionPreCommitOrIdle(
671 static function () use ( $logEntry, $logid ) {
673 $logEntry->publish( $logid );
681 $dbw->endAtomic( __METHOD__ );
683 $this->doDeleteUpdates( $page, $revisionRecord );
685 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
686 $this->hookRunner->onArticleDeleteComplete(
687 $wikiPageBeforeDelete,
693 $archivedRevisionCount
695 $this->hookRunner->onPageDeleteComplete(
696 $wikiPageBeforeDelete,
702 $archivedRevisionCount
704 $this->successfulDeletionsIDs[$pageRole] = $logid;
707 $key = $this->recentDeletesCache->makeKey(
'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
708 $this->recentDeletesCache->set( $key, 1, BagOStuff::TTL_DAY );
720 private function archiveRevisions(
WikiPage $page,
int $id ): bool {
722 $namespace = $page->
getTitle()->getNamespace();
723 $dbKey = $page->
getTitle()->getDBkey();
725 $dbw = $this->lbFactory->getPrimaryDatabase();
727 $revQuery = $this->revisionStore->getQueryInfo();
731 if ( $this->suppress ) {
732 $bitfield = RevisionRecord::SUPPRESSED_ALL;
733 $revQuery[
'fields'] = array_diff( $revQuery[
'fields'], [
'rev_deleted' ] );
747 $lockQuery = $revQuery;
748 $lockQuery[
'tables'] = array_intersect(
750 [
'revision',
'revision_comment_temp' ]
752 unset( $lockQuery[
'fields'] );
753 $dbw->newSelectQueryBuilder()
754 ->queryInfo( $lockQuery )
755 ->where( [
'rev_page' => $id ] )
757 ->caller( __METHOD__ )
760 $deleteBatchSize = $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
766 [
'rev_page' => $id ],
768 [
'ORDER BY' =>
'rev_timestamp ASC, rev_id ASC',
'LIMIT' => $deleteBatchSize + 1 ],
780 foreach ( $res as $row ) {
781 if ( count( $revids ) >= $deleteBatchSize ) {
786 $comment = $this->commentStore->getComment(
'rev_comment', $row );
788 'ar_namespace' => $namespace,
789 'ar_title' => $dbKey,
790 'ar_actor' => $row->rev_actor,
791 'ar_timestamp' => $row->rev_timestamp,
792 'ar_minor_edit' => $row->rev_minor_edit,
793 'ar_rev_id' => $row->rev_id,
794 'ar_parent_id' => $row->rev_parent_id,
795 'ar_len' => $row->rev_len,
797 'ar_deleted' => $this->suppress ? $bitfield : $row->rev_deleted,
798 'ar_sha1' => $row->rev_sha1,
799 ] + $this->commentStore->insert( $dbw,
'ar_comment', $comment );
801 $rowsInsert[] = $rowInsert;
802 $revids[] = $row->rev_id;
806 if ( (
int)$row->rev_user === 0 && IPUtils::isValid( $row->rev_user_text ) ) {
807 $ipRevIds[] = $row->rev_id;
811 if ( count( $revids ) > 0 ) {
813 $dbw->newInsertQueryBuilder()
814 ->insertInto(
'archive' )
815 ->rows( $rowsInsert )
816 ->caller( __METHOD__ )->execute();
818 $dbw->newDeleteQueryBuilder()
819 ->deleteFrom(
'revision' )
820 ->where( [
'rev_id' => $revids ] )
821 ->caller( __METHOD__ )->execute();
823 if ( count( $ipRevIds ) > 0 ) {
824 $dbw->newDeleteQueryBuilder()
825 ->deleteFrom(
'ip_changes' )
826 ->where( [
'ipc_rev_id' => $ipRevIds ] )
827 ->caller( __METHOD__ )->execute();
842 private function doDeleteUpdates(
WikiPage $page, RevisionRecord $revRecord ): void {
844 $countable = $page->isCountable();
845 }
catch ( TimeoutException $e ) {
847 }
catch ( Exception $ex ) {
854 DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
855 [
'edits' => 1,
'articles' => $countable ? -1 : 0,
'pages' => -1 ]
859 $updates = $this->getDeletionUpdates( $page, $revRecord );
860 foreach ( $updates as $update ) {
861 DeferredUpdates::addUpdate( $update );
865 LinksUpdate::queueRecursiveJobsForTable(
869 $this->deleter->getUser()->getName(),
870 $this->backlinkCacheFactory->getBacklinkCache( $page->
getTitle() )
874 LinksUpdate::queueRecursiveJobsForTable(
878 $this->deleter->getUser()->getName(),
879 $this->backlinkCacheFactory->getBacklinkCache( $page->
getTitle() )
883 if ( !$this->isDeletePageUnitTest ) {
889 WikiModule::invalidateModuleCache(
897 $page->
loadFromRow(
false, IDBAccessObject::READ_LATEST );
900 DeferredUpdates::addUpdate(
new SearchUpdate( $page->
getId(), $page->
getTitle() ) );
912 private function getDeletionUpdates(
WikiPage $page, RevisionRecord $rev ): array {
913 if ( $this->isDeletePageUnitTest ) {
917 $slotContent = array_map(
static function ( SlotRecord $slot ) {
918 return $slot->getContent();
919 }, $rev->getSlots()->getSlots() );
921 $allUpdates = [
new LinksDeletionUpdate( $page ) ];
928 foreach ( $slotContent as $role => $content ) {
929 $handler = $content->getContentHandler();
931 $updates = $handler->getDeletionUpdates(
936 $allUpdates = array_merge( $allUpdates, $updates );
939 $this->hookRunner->onPageDeletionDataUpdates(
940 $page->
getTitle(), $rev, $allUpdates );
943 $this->hookRunner->onWikiPageDeletionUpdates( $page, $content, $allUpdates );
Class for creating new log entries and inserting them into the database.
Base representation for an editable wiki page.
getContent( $audience=RevisionRecord::FOR_PUBLIC, Authority $performer=null)
Get the content of the current revision.
loadFromRow( $data, $from)
Load the object from a database row.
lockAndGetLatest()
Lock the page row for this title+id and return page_latest (or 0)
getLatest( $wikiId=self::LOCAL)
Get the page_latest field.
static onArticleDelete(Title $title)
Clears caches when article is deleted.
loadPageData( $from='fromdb')
Load the object from a given source by title.
Base interface for representing page content.