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