68 private $isDeletePageUnitTest =
false;
70 private $suppress =
false;
74 private $logSubtype =
'delete';
76 private $forceImmediate =
false;
78 private $associatedTalk;
81 private $legacyHookErrors =
'';
83 private $mergeLegacyHookErrors =
true;
89 private $successfulDeletionsIDs;
94 private $wasScheduled;
96 private $attemptedDeletion =
false;
105 private string $localWikiID;
106 private string $webRequestID;
128 string $webRequestID,
138 $this->hookRunner =
new HookRunner( $hookContainer );
139 $this->revisionStore = $revisionStore;
140 $this->lbFactory = $lbFactory;
141 $this->jobQueueGroup = $jobQueueGroup;
142 $this->commentStore = $commentStore;
144 $this->options = $serviceOptions;
145 $this->recentDeletesCache = $recentDeletesCache;
146 $this->localWikiID = $localWikiID;
147 $this->webRequestID = $webRequestID;
148 $this->wikiPageFactory = $wikiPageFactory;
149 $this->userFactory = $userFactory;
150 $this->backlinkCacheFactory = $backlinkCacheFactory;
151 $this->namespaceInfo = $namespaceInfo;
152 $this->contLangMsgTextFormatter = $contLangMsgTextFormatter;
155 $this->deleter = $deleter;
156 $this->redirectStore = $redirectStore;
164 return $this->legacyHookErrors;
172 $this->mergeLegacyHookErrors = false;
184 $this->suppress = $suppress;
194 public function setTags( array $tags ): self {
206 $this->logSubtype = $logSubtype;
217 $this->forceImmediate = $forceImmediate;
228 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
229 return StatusValue::newFatal(
'delete-error-associated-alreadytalk' );
232 $talkPage = $this->wikiPageFactory->newFromLinkTarget(
233 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
235 if ( !$talkPage->exists() ) {
236 return StatusValue::newFatal(
'delete-error-associated-doesnotexist' );
238 return StatusValue::newGood();
254 $this->associatedTalk =
null;
258 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
259 throw new BadMethodCallException(
"Cannot delete associated talk page of a talk page! ($this->page)" );
262 $this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget(
263 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
274 if ( !defined(
'MW_PHPUNIT_TEST' ) ) {
275 throw new LogicException( __METHOD__ .
' can only be used in tests!' );
277 $this->isDeletePageUnitTest = $test;
286 $this->attemptedDeletion = true;
287 $this->successfulDeletionsIDs = [ self::PAGE_BASE => null ];
288 $this->wasScheduled = [ self::PAGE_BASE => null ];
289 if ( $this->associatedTalk ) {
290 $this->successfulDeletionsIDs[self::PAGE_TALK] =
null;
291 $this->wasScheduled[self::PAGE_TALK] =
null;
300 private function assertDeletionAttempted(): void {
301 if ( !$this->attemptedDeletion ) {
302 throw new BadMethodCallException(
'No deletion was attempted' );
311 $this->assertDeletionAttempted();
312 return $this->successfulDeletionsIDs;
320 $this->assertDeletionAttempted();
321 return $this->wasScheduled;
331 $this->setDeletionAttempted();
332 $status = $this->authorizeDeletion();
333 if ( !$status->isGood() ) {
337 return $this->deleteUnsafe( $reason );
343 private function authorizeDeletion(): PermissionStatus {
344 $status = PermissionStatus::newEmpty();
345 $this->deleter->authorizeWrite(
'delete', $this->page, $status );
346 if ( $this->associatedTalk ) {
347 $this->deleter->authorizeWrite(
'delete', $this->associatedTalk, $status );
349 if ( !$this->deleter->isAllowed(
'bigdelete' ) && $this->isBigDeletion() ) {
351 'delete-toomanyrevisions',
352 Message::numParam( $this->options->get( MainConfigNames::DeleteRevisionsLimit ) )
364 private function isBigDeletion(): bool {
365 $revLimit = $this->options->get( MainConfigNames::DeleteRevisionsLimit );
370 $dbr = $this->lbFactory->getReplicaDatabase();
371 $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
372 if ( $this->associatedTalk ) {
373 $revCount += $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
376 return $revCount > $revLimit;
392 $dbr = $this->lbFactory->getReplicaDatabase();
393 $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
394 $revCount += $safetyMargin;
396 if ( $revCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize ) ) {
398 } elseif ( !$this->associatedTalk ) {
402 $talkRevCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
403 $talkRevCount += $safetyMargin;
405 return $talkRevCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
419 $this->setDeletionAttempted();
420 $origReason = $reason;
421 $hookStatus = $this->runPreDeleteHooks( $this->page, $reason );
422 if ( !$hookStatus->isGood() ) {
425 if ( $this->associatedTalk ) {
426 $talkReason = $this->contLangMsgTextFormatter->format(
427 MessageValue::new(
'delete-talk-summary-prefix' )->plaintextParams( $origReason )
429 $talkHookStatus = $this->runPreDeleteHooks( $this->associatedTalk, $talkReason );
430 if ( !$talkHookStatus->isGood() ) {
431 return $talkHookStatus;
435 $status = $this->deleteInternal( $this->page, self::PAGE_BASE, $reason );
436 if ( !$this->associatedTalk || !$status->isGood() ) {
443 $status->merge( $this->deleteInternal( $this->associatedTalk, self::PAGE_TALK, $talkReason ) );
452 private function runPreDeleteHooks(
WikiPage $page,
string &$reason ): Status {
453 $status = Status::newGood();
455 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
456 if ( !$this->hookRunner->onArticleDelete(
457 $page, $legacyDeleter, $reason, $this->legacyHookErrors, $status, $this->suppress )
459 if ( $this->mergeLegacyHookErrors && $this->legacyHookErrors !==
'' ) {
460 if ( is_string( $this->legacyHookErrors ) ) {
461 $this->legacyHookErrors = [ $this->legacyHookErrors ];
463 foreach ( $this->legacyHookErrors as $legacyError ) {
464 $status->fatal(
new RawMessage( $legacyError ) );
467 if ( $status->isOK() ) {
469 $status->fatal(
'delete-hook-aborted' );
475 $status = Status::newGood();
476 $hookRes = $this->hookRunner->onPageDelete( $page, $this->deleter, $reason, $status, $this->suppress );
477 if ( !$hookRes && !$status->isGood() ) {
481 return Status::newGood();
502 ?
string $webRequestId =
null
505 $status = Status::newGood();
507 $dbw = $this->lbFactory->getPrimaryDatabase();
508 $dbw->startAtomic( __METHOD__ );
511 $id = $page->
getId();
517 if ( $id === 0 || $page->
getLatest() !== $lockedLatest ) {
518 $dbw->endAtomic( __METHOD__ );
520 $status->error(
'cannotdelete',
wfEscapeWikiText( $title->getPrefixedText() ) );
531 if ( !$revisionRecord ) {
532 throw new LogicException(
"No revisions for $page?" );
535 $content = $page->
getContent( RevisionRecord::RAW );
536 }
catch ( TimeoutException $e ) {
538 }
catch ( Exception $ex ) {
539 wfLogWarning( __METHOD__ .
': failed to load content during deletion! '
540 . $ex->getMessage() );
547 $explictTrxLogged =
false;
549 $done = $this->archiveRevisions( $page, $id );
550 if ( $done || !$this->forceImmediate ) {
553 $dbw->endAtomic( __METHOD__ );
554 if ( $dbw->explicitTrxActive() ) {
556 if ( !$explictTrxLogged ) {
557 $explictTrxLogged =
true;
558 LoggerFactory::getInstance(
'wfDebug' )->debug(
559 'explicit transaction active in ' . __METHOD__ .
' while deleting {title}', [
560 'title' => $title->getText(),
565 if ( $dbw->trxLevel() ) {
566 $dbw->commit( __METHOD__ );
568 $this->lbFactory->waitForReplication();
569 $dbw->startAtomic( __METHOD__ );
573 $dbw->endAtomic( __METHOD__ );
576 'namespace' => $title->getNamespace(),
577 'title' => $title->getDBkey(),
579 'requestId' => $webRequestId ?? $this->webRequestID,
581 'suppress' => $this->suppress,
582 'userId' => $this->deleter->getUser()->getId(),
583 'tags' => json_encode( $this->tags ),
584 'logsubtype' => $this->logSubtype,
585 'pageRole' => $pageRole,
589 $this->jobQueueGroup->push(
$job );
590 $this->wasScheduled[$pageRole] =
true;
593 $this->wasScheduled[$pageRole] =
false;
602 $archivedRevisionCount = $dbw->newSelectQueryBuilder()
606 'ar_namespace' => $title->getNamespace(),
607 'ar_title' => $title->getDBkey(),
610 ->caller( __METHOD__ )->fetchRowCount();
618 $logTitle = clone $title;
619 $wikiPageBeforeDelete = clone $page;
622 $dbw->newDeleteQueryBuilder()
623 ->deleteFrom(
'page' )
624 ->where( [
'page_id' => $id ] )
625 ->caller( __METHOD__ )->execute();
628 $logtype = $this->suppress ?
'suppress' :
'delete';
631 $logEntry->setPerformer( $this->deleter->getUser() );
632 $logEntry->setTarget( $logTitle );
633 $logEntry->setComment( $reason );
634 $logEntry->addTags( $this->tags );
635 if ( !$this->isDeletePageUnitTest ) {
637 $logid = $logEntry->insert();
639 $dbw->onTransactionPreCommitOrIdle(
640 static function () use ( $logEntry, $logid ) {
642 $logEntry->publish( $logid );
650 $dbw->endAtomic( __METHOD__ );
652 $this->doDeleteUpdates( $page, $revisionRecord );
654 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
655 $this->hookRunner->onArticleDeleteComplete(
656 $wikiPageBeforeDelete,
662 $archivedRevisionCount
664 $this->hookRunner->onPageDeleteComplete(
665 $wikiPageBeforeDelete,
671 $archivedRevisionCount
673 $this->successfulDeletionsIDs[$pageRole] = $logid;
676 $this->redirectStore->clearCache( $page );
679 $key = $this->recentDeletesCache->makeKey(
'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
680 $this->recentDeletesCache->set( $key, 1, BagOStuff::TTL_DAY );
692 private function archiveRevisions(
WikiPage $page,
int $id ): bool {
694 $namespace = $page->
getTitle()->getNamespace();
695 $dbKey = $page->
getTitle()->getDBkey();
697 $dbw = $this->lbFactory->getPrimaryDatabase();
699 $revQuery = $this->revisionStore->getQueryInfo();
703 if ( $this->suppress ) {
704 $bitfield = RevisionRecord::SUPPRESSED_ALL;
705 $revQuery[
'fields'] = array_diff( $revQuery[
'fields'], [
'rev_deleted' ] );
719 $lockQuery = $revQuery;
720 $lockQuery[
'tables'] = array_intersect(
722 [
'revision',
'revision_comment_temp' ]
724 unset( $lockQuery[
'fields'] );
725 $dbw->newSelectQueryBuilder()
726 ->queryInfo( $lockQuery )
727 ->where( [
'rev_page' => $id ] )
729 ->caller( __METHOD__ )
732 $deleteBatchSize = $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
735 $res = $dbw->newSelectQueryBuilder()
736 ->queryInfo( $revQuery )
737 ->where( [
'rev_page' => $id ] )
738 ->orderBy( [
'rev_timestamp',
'rev_id' ] )
739 ->limit( $deleteBatchSize + 1 )
740 ->caller( __METHOD__ )
751 foreach ( $res as $row ) {
752 if ( count( $revids ) >= $deleteBatchSize ) {
757 $comment = $this->commentStore->getComment(
'rev_comment', $row );
759 'ar_namespace' => $namespace,
760 'ar_title' => $dbKey,
761 'ar_actor' => $row->rev_actor,
762 'ar_timestamp' => $row->rev_timestamp,
763 'ar_minor_edit' => $row->rev_minor_edit,
764 'ar_rev_id' => $row->rev_id,
765 'ar_parent_id' => $row->rev_parent_id,
766 'ar_len' => $row->rev_len,
768 'ar_deleted' => $this->suppress ? $bitfield : $row->rev_deleted,
769 'ar_sha1' => $row->rev_sha1,
770 ] + $this->commentStore->insert( $dbw,
'ar_comment', $comment );
772 $rowsInsert[] = $rowInsert;
773 $revids[] = $row->rev_id;
777 if ( (
int)$row->rev_user === 0 && IPUtils::isValid( $row->rev_user_text ) ) {
778 $ipRevIds[] = $row->rev_id;
782 if ( count( $revids ) > 0 ) {
784 $dbw->newInsertQueryBuilder()
785 ->insertInto(
'archive' )
786 ->rows( $rowsInsert )
787 ->caller( __METHOD__ )->execute();
789 $dbw->newDeleteQueryBuilder()
790 ->deleteFrom(
'revision' )
791 ->where( [
'rev_id' => $revids ] )
792 ->caller( __METHOD__ )->execute();
794 if ( count( $ipRevIds ) > 0 ) {
795 $dbw->newDeleteQueryBuilder()
796 ->deleteFrom(
'ip_changes' )
797 ->where( [
'ipc_rev_id' => $ipRevIds ] )
798 ->caller( __METHOD__ )->execute();
813 private function doDeleteUpdates(
WikiPage $page, RevisionRecord $revRecord ): void {
815 $countable = $page->isCountable();
816 }
catch ( TimeoutException $e ) {
818 }
catch ( Exception $ex ) {
825 DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
826 [
'edits' => 1,
'articles' => $countable ? -1 : 0,
'pages' => -1 ]
830 $updates = $this->getDeletionUpdates( $page, $revRecord );
831 foreach ( $updates as $update ) {
832 DeferredUpdates::addUpdate( $update );
836 LinksUpdate::queueRecursiveJobsForTable(
840 $this->deleter->getUser()->getName(),
841 $this->backlinkCacheFactory->getBacklinkCache( $page->
getTitle() )
845 LinksUpdate::queueRecursiveJobsForTable(
849 $this->deleter->getUser()->getName(),
850 $this->backlinkCacheFactory->getBacklinkCache( $page->
getTitle() )
854 if ( !$this->isDeletePageUnitTest ) {
860 WikiModule::invalidateModuleCache(
868 $page->
loadFromRow(
false, IDBAccessObject::READ_LATEST );
871 DeferredUpdates::addUpdate(
new SearchUpdate( $page->
getId(), $page->
getTitle() ) );
883 private function getDeletionUpdates(
WikiPage $page, RevisionRecord $rev ): array {
884 if ( $this->isDeletePageUnitTest ) {
888 $slotContent = array_map(
static function ( SlotRecord $slot ) {
889 return $slot->getContent();
890 }, $rev->getSlots()->getSlots() );
892 $allUpdates = [
new LinksDeletionUpdate( $page ) ];
899 foreach ( $slotContent as $role => $content ) {
900 $handler = $content->getContentHandler();
902 $updates = $handler->getDeletionUpdates(
907 $allUpdates = array_merge( $allUpdates, $updates );
910 $this->hookRunner->onPageDeletionDataUpdates(
911 $page->
getTitle(), $rev, $allUpdates );
914 $this->hookRunner->onWikiPageDeletionUpdates( $page, $content, $allUpdates );
Class for creating new log entries and inserting them into the database.
__construct(HookContainer $hookContainer, RevisionStore $revisionStore, LBFactory $lbFactory, JobQueueGroup $jobQueueGroup, CommentStore $commentStore, ServiceOptions $serviceOptions, BagOStuff $recentDeletesCache, string $localWikiID, string $webRequestID, WikiPageFactory $wikiPageFactory, UserFactory $userFactory, BacklinkCacheFactory $backlinkCacheFactory, NamespaceInfo $namespaceInfo, ITextFormatter $contLangMsgTextFormatter, RedirectStore $redirectStore, ProperPageIdentity $page, Authority $deleter)
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.