Add checkDependencies.php
[lhc/web/wiklou.git] / includes / Storage / DerivedPageDataUpdater.php
1 <?php
2 /**
3 * A handle for managing updates for derived page data on edit, import, purge, etc.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23 namespace MediaWiki\Storage;
24
25 use CategoryMembershipChangeJob;
26 use Content;
27 use ContentHandler;
28 use DataUpdate;
29 use DeferrableUpdate;
30 use DeferredUpdates;
31 use Hooks;
32 use IDBAccessObject;
33 use InvalidArgumentException;
34 use JobQueueGroup;
35 use Language;
36 use LinksDeletionUpdate;
37 use LinksUpdate;
38 use LogicException;
39 use MediaWiki\Edit\PreparedEdit;
40 use MediaWiki\MediaWikiServices;
41 use MediaWiki\Revision\MutableRevisionRecord;
42 use MediaWiki\Revision\RenderedRevision;
43 use MediaWiki\Revision\RevisionRecord;
44 use MediaWiki\Revision\RevisionRenderer;
45 use MediaWiki\Revision\RevisionSlots;
46 use MediaWiki\Revision\RevisionStore;
47 use MediaWiki\Revision\SlotRoleRegistry;
48 use MediaWiki\Revision\SlotRecord;
49 use MediaWiki\User\UserIdentity;
50 use MessageCache;
51 use ParserCache;
52 use ParserOptions;
53 use ParserOutput;
54 use RecentChangesUpdateJob;
55 use ResourceLoaderWikiModule;
56 use Revision;
57 use SearchUpdate;
58 use SiteStatsUpdate;
59 use Title;
60 use User;
61 use Wikimedia\Assert\Assert;
62 use Wikimedia\Rdbms\LBFactory;
63 use WikiPage;
64
65 /**
66 * A handle for managing updates for derived page data on edit, import, purge, etc.
67 *
68 * @note Avoid direct usage of DerivedPageDataUpdater.
69 *
70 * @todo Define interfaces for the different use cases of DerivedPageDataUpdater, particularly
71 * providing access to post-PST content and ParserOutput to callbacks during revision creation,
72 * which currently use WikiPage::prepareContentForEdit, and allowing updates to be triggered on
73 * purge, import, and undeletion, which currently use WikiPage::doEditUpdates() and
74 * Content::getSecondaryDataUpdates().
75 *
76 * DerivedPageDataUpdater instances are designed to be cached inside a WikiPage instance,
77 * and re-used by callback code over the course of an update operation. It's a stepping stone
78 * one the way to a more complete refactoring of WikiPage.
79 *
80 * When using a DerivedPageDataUpdater, the following life cycle must be observed:
81 * grabCurrentRevision (optional), prepareContent (optional), prepareUpdate (required
82 * for doUpdates). getCanonicalParserOutput, getSlots, and getSecondaryDataUpdates
83 * require prepareContent or prepareUpdate to have been called first, to initialize the
84 * DerivedPageDataUpdater.
85 *
86 * @see docs/pageupdater.txt for more information.
87 *
88 * MCR migration note: this replaces the relevant methods in WikiPage, and covers the use cases
89 * of PreparedEdit.
90 *
91 * @internal
92 *
93 * @since 1.32
94 * @ingroup Page
95 */
96 class DerivedPageDataUpdater implements IDBAccessObject {
97
98 /**
99 * @var UserIdentity|null
100 */
101 private $user = null;
102
103 /**
104 * @var WikiPage
105 */
106 private $wikiPage;
107
108 /**
109 * @var ParserCache
110 */
111 private $parserCache;
112
113 /**
114 * @var RevisionStore
115 */
116 private $revisionStore;
117
118 /**
119 * @var Language
120 */
121 private $contLang;
122
123 /**
124 * @var JobQueueGroup
125 */
126 private $jobQueueGroup;
127
128 /**
129 * @var MessageCache
130 */
131 private $messageCache;
132
133 /**
134 * @var LBFactory
135 */
136 private $loadbalancerFactory;
137
138 /**
139 * @var string see $wgArticleCountMethod
140 */
141 private $articleCountMethod;
142
143 /**
144 * @var boolean see $wgRCWatchCategoryMembership
145 */
146 private $rcWatchCategoryMembership = false;
147
148 /**
149 * Stores (most of) the $options parameter of prepareUpdate().
150 * @see prepareUpdate()
151 */
152 private $options = [
153 'changed' => true,
154 // newrev is true if prepareUpdate is handling the creation of a new revision,
155 // as opposed to a null edit or a forced update.
156 'newrev' => false,
157 'created' => false,
158 'moved' => false,
159 'restored' => false,
160 'oldrevision' => null,
161 'oldcountable' => null,
162 'oldredirect' => null,
163 'triggeringUser' => null,
164 // causeAction/causeAgent default to 'unknown' but that's handled where it's read,
165 // to make the life of prepareUpdate() callers easier.
166 'causeAction' => null,
167 'causeAgent' => null,
168 ];
169
170 /**
171 * The state of the relevant row in page table before the edit.
172 * This is determined by the first call to grabCurrentRevision, prepareContent,
173 * or prepareUpdate (so it is only accessible in 'knows-current' or a later stage).
174 * If pageState was not initialized when prepareUpdate() is called, prepareUpdate() will
175 * attempt to emulate the state of the page table before the edit.
176 *
177 * Contains the following fields:
178 * - oldRevision (RevisionRecord|null): the revision that was current before the change
179 * associated with this update. Might not be set, use getParentRevision().
180 * - oldId (int|null): the id of the above revision. 0 if there is no such revision (the change
181 * was about creating a new page); null if not known (that should not happen).
182 * - oldIsRedirect (bool|null): whether the page was a redirect before the change. Lazy-loaded,
183 * can be null; use wasRedirect() instead of direct access.
184 * - oldCountable (bool|null): whether the page was countable before the change (or null
185 * if we don't have that information)
186 *
187 * @var array
188 */
189 private $pageState = null;
190
191 /**
192 * @var RevisionSlotsUpdate|null
193 */
194 private $slotsUpdate = null;
195
196 /**
197 * @var RevisionRecord|null
198 */
199 private $parentRevision = null;
200
201 /**
202 * @var RevisionRecord|null
203 */
204 private $revision = null;
205
206 /**
207 * @var RenderedRevision
208 */
209 private $renderedRevision = null;
210
211 /**
212 * @var RevisionRenderer
213 */
214 private $revisionRenderer;
215
216 /** @var SlotRoleRegistry */
217 private $slotRoleRegistry;
218
219 /**
220 * A stage identifier for managing the life cycle of this instance.
221 * Possible stages are 'new', 'knows-current', 'has-content', 'has-revision', and 'done'.
222 *
223 * @see docs/pageupdater.txt for documentation of the life cycle.
224 *
225 * @var string
226 */
227 private $stage = 'new';
228
229 /**
230 * Transition table for managing the life cycle of DerivedPageDateUpdater instances.
231 *
232 * XXX: Overkill. This is a linear order, we could just count. Names are nice though,
233 * and constants are also overkill...
234 *
235 * @see docs/pageupdater.txt for documentation of the life cycle.
236 *
237 * @var array[]
238 */
239 private static $transitions = [
240 'new' => [
241 'new' => true,
242 'knows-current' => true,
243 'has-content' => true,
244 'has-revision' => true,
245 ],
246 'knows-current' => [
247 'knows-current' => true,
248 'has-content' => true,
249 'has-revision' => true,
250 ],
251 'has-content' => [
252 'has-content' => true,
253 'has-revision' => true,
254 ],
255 'has-revision' => [
256 'has-revision' => true,
257 'done' => true,
258 ],
259 ];
260
261 /**
262 * @param WikiPage $wikiPage ,
263 * @param RevisionStore $revisionStore
264 * @param RevisionRenderer $revisionRenderer
265 * @param SlotRoleRegistry $slotRoleRegistry
266 * @param ParserCache $parserCache
267 * @param JobQueueGroup $jobQueueGroup
268 * @param MessageCache $messageCache
269 * @param Language $contLang
270 * @param LBFactory $loadbalancerFactory
271 */
272 public function __construct(
273 WikiPage $wikiPage,
274 RevisionStore $revisionStore,
275 RevisionRenderer $revisionRenderer,
276 SlotRoleRegistry $slotRoleRegistry,
277 ParserCache $parserCache,
278 JobQueueGroup $jobQueueGroup,
279 MessageCache $messageCache,
280 Language $contLang,
281 LBFactory $loadbalancerFactory
282 ) {
283 $this->wikiPage = $wikiPage;
284
285 $this->parserCache = $parserCache;
286 $this->revisionStore = $revisionStore;
287 $this->revisionRenderer = $revisionRenderer;
288 $this->slotRoleRegistry = $slotRoleRegistry;
289 $this->jobQueueGroup = $jobQueueGroup;
290 $this->messageCache = $messageCache;
291 $this->contLang = $contLang;
292 // XXX only needed for waiting for replicas to catch up; there should be a narrower
293 // interface for that.
294 $this->loadbalancerFactory = $loadbalancerFactory;
295 }
296
297 /**
298 * Transition function for managing the life cycle of this instances.
299 *
300 * @see docs/pageupdater.txt for documentation of the life cycle.
301 *
302 * @param string $newStage the new stage
303 * @return string the previous stage
304 *
305 * @throws LogicException If a transition to the given stage is not possible in the current
306 * stage.
307 */
308 private function doTransition( $newStage ) {
309 $this->assertTransition( $newStage );
310
311 $oldStage = $this->stage;
312 $this->stage = $newStage;
313
314 return $oldStage;
315 }
316
317 /**
318 * Asserts that a transition to the given stage is possible, without performing it.
319 *
320 * @see docs/pageupdater.txt for documentation of the life cycle.
321 *
322 * @param string $newStage the new stage
323 *
324 * @throws LogicException If this instance is not in the expected stage
325 */
326 private function assertTransition( $newStage ) {
327 if ( empty( self::$transitions[$this->stage][$newStage] ) ) {
328 throw new LogicException( "Cannot transition from {$this->stage} to $newStage" );
329 }
330 }
331
332 /**
333 * @return bool|string
334 */
335 private function getWikiId() {
336 // TODO: get from RevisionStore
337 return false;
338 }
339
340 /**
341 * Checks whether this DerivedPageDataUpdater can be re-used for running updates targeting
342 * the given revision.
343 *
344 * @param UserIdentity|null $user The user creating the revision in question
345 * @param RevisionRecord|null $revision New revision (after save, if already saved)
346 * @param RevisionSlotsUpdate|null $slotsUpdate New content (before PST)
347 * @param null|int $parentId Parent revision of the edit (use 0 for page creation)
348 *
349 * @return bool
350 */
351 public function isReusableFor(
352 UserIdentity $user = null,
353 RevisionRecord $revision = null,
354 RevisionSlotsUpdate $slotsUpdate = null,
355 $parentId = null
356 ) {
357 if ( $revision
358 && $parentId
359 && $revision->getParentId() !== $parentId
360 ) {
361 throw new InvalidArgumentException( '$parentId should match the parent of $revision' );
362 }
363
364 // NOTE: For null revisions, $user may be different from $this->revision->getUser
365 // and also from $revision->getUser.
366 // But $user should always match $this->user.
367 if ( $user && $this->user && $user->getName() !== $this->user->getName() ) {
368 return false;
369 }
370
371 if ( $revision && $this->revision && $this->revision->getId()
372 && $this->revision->getId() !== $revision->getId()
373 ) {
374 return false;
375 }
376
377 if ( $this->pageState
378 && $revision
379 && $revision->getParentId() !== null
380 && $this->pageState['oldId'] !== $revision->getParentId()
381 ) {
382 return false;
383 }
384
385 if ( $this->pageState
386 && $parentId !== null
387 && $this->pageState['oldId'] !== $parentId
388 ) {
389 return false;
390 }
391
392 // NOTE: this check is the primary reason for having the $this->slotsUpdate field!
393 if ( $this->slotsUpdate
394 && $slotsUpdate
395 && !$this->slotsUpdate->hasSameUpdates( $slotsUpdate )
396 ) {
397 return false;
398 }
399
400 if ( $revision
401 && $this->revision
402 && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
403 ) {
404 return false;
405 }
406
407 return true;
408 }
409
410 /**
411 * @param string $articleCountMethod "any" or "link".
412 * @see $wgArticleCountMethod
413 */
414 public function setArticleCountMethod( $articleCountMethod ) {
415 $this->articleCountMethod = $articleCountMethod;
416 }
417
418 /**
419 * @param bool $rcWatchCategoryMembership
420 * @see $wgRCWatchCategoryMembership
421 */
422 public function setRcWatchCategoryMembership( $rcWatchCategoryMembership ) {
423 $this->rcWatchCategoryMembership = $rcWatchCategoryMembership;
424 }
425
426 /**
427 * @return Title
428 */
429 private function getTitle() {
430 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
431 return $this->wikiPage->getTitle();
432 }
433
434 /**
435 * @return WikiPage
436 */
437 private function getWikiPage() {
438 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
439 return $this->wikiPage;
440 }
441
442 /**
443 * Determines whether the page being edited already existed.
444 * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()!
445 *
446 * @return bool
447 * @throws LogicException if called before grabCurrentRevision
448 */
449 public function pageExisted() {
450 $this->assertHasPageState( __METHOD__ );
451
452 return $this->pageState['oldId'] > 0;
453 }
454
455 /**
456 * Returns the parent revision of the new revision wrapped by this update.
457 * If the update is a null-edit, this will return the parent of the current (and new) revision.
458 * This will return null if the revision wrapped by this update created the page.
459 * Only defined after calling prepareContent() or prepareUpdate()!
460 *
461 * @return RevisionRecord|null the parent revision of the new revision, or null if
462 * the update created the page.
463 */
464 private function getParentRevision() {
465 $this->assertPrepared( __METHOD__ );
466
467 if ( $this->parentRevision ) {
468 return $this->parentRevision;
469 }
470
471 if ( !$this->pageState['oldId'] ) {
472 // If there was no current revision, there is no parent revision,
473 // since the page didn't exist.
474 return null;
475 }
476
477 $oldId = $this->revision->getParentId();
478 $flags = $this->useMaster() ? RevisionStore::READ_LATEST : 0;
479 $this->parentRevision = $oldId
480 ? $this->revisionStore->getRevisionById( $oldId, $flags )
481 : null;
482
483 return $this->parentRevision;
484 }
485
486 /**
487 * Returns the revision that was the page's current revision when grabCurrentRevision()
488 * was first called.
489 *
490 * During an edit, that revision will act as the logical parent of the new revision.
491 *
492 * Some updates are performed based on the difference between the database state at the
493 * moment this method is first called, and the state after the edit.
494 *
495 * @see docs/pageupdater.txt for more information on when thie method can and should be called.
496 *
497 * @note After prepareUpdate() was called, grabCurrentRevision() will throw an exception
498 * to avoid confusion, since the page's current revision is then the new revision after
499 * the edit, which was presumably passed to prepareUpdate() as the $revision parameter.
500 * Use getParentRevision() instead to access the revision that is the parent of the
501 * new revision.
502 *
503 * @return RevisionRecord|null the page's current revision, or null if the page does not
504 * yet exist.
505 */
506 public function grabCurrentRevision() {
507 if ( $this->pageState ) {
508 return $this->pageState['oldRevision'];
509 }
510
511 $this->assertTransition( 'knows-current' );
512
513 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
514 $wikiPage = $this->getWikiPage();
515
516 // Do not call WikiPage::clear(), since the caller may already have caused page data
517 // to be loaded with SELECT FOR UPDATE. Just assert it's loaded now.
518 $wikiPage->loadPageData( self::READ_LATEST );
519 $rev = $wikiPage->getRevision();
520 $current = $rev ? $rev->getRevisionRecord() : null;
521
522 $this->pageState = [
523 'oldRevision' => $current,
524 'oldId' => $rev ? $rev->getId() : 0,
525 'oldIsRedirect' => $wikiPage->isRedirect(), // NOTE: uses page table
526 'oldCountable' => $wikiPage->isCountable(), // NOTE: uses pagelinks table
527 ];
528
529 $this->doTransition( 'knows-current' );
530
531 return $this->pageState['oldRevision'];
532 }
533
534 /**
535 * Whether prepareUpdate() or prepareContent() have been called on this instance.
536 *
537 * @return bool
538 */
539 public function isContentPrepared() {
540 return $this->revision !== null;
541 }
542
543 /**
544 * Whether prepareUpdate() has been called on this instance.
545 *
546 * @note will also return null in case of a null-edit!
547 *
548 * @return bool
549 */
550 public function isUpdatePrepared() {
551 return $this->revision !== null && $this->revision->getId() !== null;
552 }
553
554 /**
555 * @return int
556 */
557 private function getPageId() {
558 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
559 return $this->wikiPage->getId();
560 }
561
562 /**
563 * Whether the content is deleted and thus not visible to the public.
564 *
565 * @return bool
566 */
567 public function isContentDeleted() {
568 if ( $this->revision ) {
569 // XXX: if that revision is the current revision, this should be skipped
570 return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
571 } else {
572 // If the content has not been saved yet, it cannot have been deleted yet.
573 return false;
574 }
575 }
576
577 /**
578 * Returns the slot, modified or inherited, after PST, with no audience checks applied.
579 *
580 * @param string $role slot role name
581 *
582 * @throws PageUpdateException If the slot is neither set for update nor inherited from the
583 * parent revision.
584 * @return SlotRecord
585 */
586 public function getRawSlot( $role ) {
587 return $this->getSlots()->getSlot( $role );
588 }
589
590 /**
591 * Returns the content of the given slot, with no audience checks.
592 *
593 * @throws PageUpdateException If the slot is neither set for update nor inherited from the
594 * parent revision.
595 * @param string $role slot role name
596 * @return Content
597 */
598 public function getRawContent( $role ) {
599 return $this->getRawSlot( $role )->getContent();
600 }
601
602 /**
603 * Returns the content model of the given slot
604 *
605 * @param string $role slot role name
606 * @return string
607 */
608 private function getContentModel( $role ) {
609 return $this->getRawSlot( $role )->getModel();
610 }
611
612 /**
613 * @param string $role slot role name
614 * @return ContentHandler
615 */
616 private function getContentHandler( $role ) {
617 // TODO: inject something like a ContentHandlerRegistry
618 return ContentHandler::getForModelID( $this->getContentModel( $role ) );
619 }
620
621 private function useMaster() {
622 // TODO: can we just set a flag to true in prepareContent()?
623 return $this->wikiPage->wasLoadedFrom( self::READ_LATEST );
624 }
625
626 /**
627 * @return bool
628 */
629 public function isCountable() {
630 // NOTE: Keep in sync with WikiPage::isCountable.
631
632 if ( !$this->getTitle()->isContentPage() ) {
633 return false;
634 }
635
636 if ( $this->isContentDeleted() ) {
637 // This should be irrelevant: countability only applies to the current revision,
638 // and the current revision is never suppressed.
639 return false;
640 }
641
642 if ( $this->isRedirect() ) {
643 return false;
644 }
645
646 $hasLinks = null;
647
648 if ( $this->articleCountMethod === 'link' ) {
649 // NOTE: it would be more appropriate to determine for each slot separately
650 // whether it has links, and use that information with that slot's
651 // isCountable() method. However, that would break parity with
652 // WikiPage::isCountable, which uses the pagelinks table to determine
653 // whether the current revision has links.
654 $hasLinks = (bool)count( $this->getCanonicalParserOutput()->getLinks() );
655 }
656
657 foreach ( $this->getModifiedSlotRoles() as $role ) {
658 $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
659 if ( $roleHandler->supportsArticleCount() ) {
660 $content = $this->getRawContent( $role );
661
662 if ( $content->isCountable( $hasLinks ) ) {
663 return true;
664 }
665 }
666 }
667
668 return false;
669 }
670
671 /**
672 * @return bool
673 */
674 public function isRedirect() {
675 // NOTE: main slot determines redirect status
676 // TODO: MCR: this should be controlled by a PageTypeHandler
677 $mainContent = $this->getRawContent( SlotRecord::MAIN );
678
679 return $mainContent->isRedirect();
680 }
681
682 /**
683 * @param RevisionRecord $rev
684 *
685 * @return bool
686 */
687 private function revisionIsRedirect( RevisionRecord $rev ) {
688 // NOTE: main slot determines redirect status
689 $mainContent = $rev->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
690
691 return $mainContent->isRedirect();
692 }
693
694 /**
695 * Prepare updates based on an update which has not yet been saved.
696 *
697 * This may be used to create derived data that is needed when creating a new revision;
698 * particularly, this makes available the slots of the new revision via the getSlots()
699 * method, after applying PST and slot inheritance.
700 *
701 * The derived data prepared for revision creation may then later be re-used by doUpdates(),
702 * without the need to re-calculate.
703 *
704 * @see docs/pageupdater.txt for more information on when thie method can and should be called.
705 *
706 * @note Calling this method more than once with the same $slotsUpdate
707 * has no effect. Calling this method multiple times with different content will cause
708 * an exception.
709 *
710 * @note Calling this method after prepareUpdate() has been called will cause an exception.
711 *
712 * @param User $user The user to act as context for pre-save transformation (PST).
713 * Type hint should be reduced to UserIdentity at some point.
714 * @param RevisionSlotsUpdate $slotsUpdate The new content of the slots to be updated
715 * by this edit, before PST.
716 * @param bool $useStash Whether to use stashed ParserOutput
717 */
718 public function prepareContent(
719 User $user,
720 RevisionSlotsUpdate $slotsUpdate,
721 $useStash = true
722 ) {
723 if ( $this->slotsUpdate ) {
724 if ( !$this->user ) {
725 throw new LogicException(
726 'Unexpected state: $this->slotsUpdate was initialized, '
727 . 'but $this->user was not.'
728 );
729 }
730
731 if ( $this->user->getName() !== $user->getName() ) {
732 throw new LogicException( 'Can\'t call prepareContent() again for different user! '
733 . 'Expected ' . $this->user->getName() . ', got ' . $user->getName()
734 );
735 }
736
737 if ( !$this->slotsUpdate->hasSameUpdates( $slotsUpdate ) ) {
738 throw new LogicException(
739 'Can\'t call prepareContent() again with different slot content!'
740 );
741 }
742
743 return; // prepareContent() already done, nothing to do
744 }
745
746 $this->assertTransition( 'has-content' );
747
748 $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
749 $title = $this->getTitle();
750
751 $parentRevision = $this->grabCurrentRevision();
752
753 // The edit may have already been prepared via api.php?action=stashedit
754 $stashedEdit = false;
755
756 // TODO: MCR: allow output for all slots to be stashed.
757 if ( $useStash && $slotsUpdate->isModifiedSlot( SlotRecord::MAIN ) ) {
758 $editStash = MediaWikiServices::getInstance()->getPageEditStash();
759 $stashedEdit = $editStash->checkCache(
760 $title,
761 $slotsUpdate->getModifiedSlot( SlotRecord::MAIN )->getContent(),
762 User::newFromIdentity( $user )
763 );
764 }
765
766 $userPopts = ParserOptions::newFromUserAndLang( $user, $this->contLang );
767 Hooks::run( 'ArticlePrepareTextForEdit', [ $wikiPage, $userPopts ] );
768
769 $this->user = $user;
770 $this->slotsUpdate = $slotsUpdate;
771
772 if ( $parentRevision ) {
773 $this->revision = MutableRevisionRecord::newFromParentRevision( $parentRevision );
774 } else {
775 $this->revision = new MutableRevisionRecord( $title );
776 }
777
778 // NOTE: user and timestamp must be set, so they can be used for
779 // {{subst:REVISIONUSER}} and {{subst:REVISIONTIMESTAMP}} in PST!
780 $this->revision->setTimestamp( wfTimestampNow() );
781 $this->revision->setUser( $user );
782
783 // Set up ParserOptions to operate on the new revision
784 $oldCallback = $userPopts->getCurrentRevisionCallback();
785 $userPopts->setCurrentRevisionCallback(
786 function ( Title $parserTitle, $parser = false ) use ( $title, $oldCallback ) {
787 if ( $parserTitle->equals( $title ) ) {
788 $legacyRevision = new Revision( $this->revision );
789 return $legacyRevision;
790 } else {
791 return call_user_func( $oldCallback, $parserTitle, $parser );
792 }
793 }
794 );
795
796 $pstContentSlots = $this->revision->getSlots();
797
798 foreach ( $slotsUpdate->getModifiedRoles() as $role ) {
799 $slot = $slotsUpdate->getModifiedSlot( $role );
800
801 if ( $slot->isInherited() ) {
802 // No PST for inherited slots! Note that "modified" slots may still be inherited
803 // from an earlier version, e.g. for rollbacks.
804 $pstSlot = $slot;
805 } elseif ( $role === SlotRecord::MAIN && $stashedEdit ) {
806 // TODO: MCR: allow PST content for all slots to be stashed.
807 $pstSlot = SlotRecord::newUnsaved( $role, $stashedEdit->pstContent );
808 } else {
809 $content = $slot->getContent();
810 $pstContent = $content->preSaveTransform( $title, $this->user, $userPopts );
811 $pstSlot = SlotRecord::newUnsaved( $role, $pstContent );
812 }
813
814 $pstContentSlots->setSlot( $pstSlot );
815 }
816
817 foreach ( $slotsUpdate->getRemovedRoles() as $role ) {
818 $pstContentSlots->removeSlot( $role );
819 }
820
821 $this->options['created'] = ( $parentRevision === null );
822 $this->options['changed'] = ( $parentRevision === null
823 || !$pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
824
825 $this->doTransition( 'has-content' );
826
827 if ( !$this->options['changed'] ) {
828 // null-edit!
829
830 // TODO: move this into MutableRevisionRecord
831 // TODO: This needs to behave differently for a forced dummy edit!
832 $this->revision->setId( $parentRevision->getId() );
833 $this->revision->setTimestamp( $parentRevision->getTimestamp() );
834 $this->revision->setPageId( $parentRevision->getPageId() );
835 $this->revision->setParentId( $parentRevision->getParentId() );
836 $this->revision->setUser( $parentRevision->getUser( RevisionRecord::RAW ) );
837 $this->revision->setComment( $parentRevision->getComment( RevisionRecord::RAW ) );
838 $this->revision->setMinorEdit( $parentRevision->isMinor() );
839 $this->revision->setVisibility( $parentRevision->getVisibility() );
840
841 // prepareUpdate() is redundant for null-edits
842 $this->doTransition( 'has-revision' );
843 } else {
844 $this->parentRevision = $parentRevision;
845 }
846
847 $renderHints = [ 'use-master' => $this->useMaster(), 'audience' => RevisionRecord::RAW ];
848
849 if ( $stashedEdit ) {
850 /** @var ParserOutput $output */
851 $output = $stashedEdit->output;
852
853 // TODO: this should happen when stashing the ParserOutput, not now!
854 $output->setCacheTime( $stashedEdit->timestamp );
855
856 $renderHints['known-revision-output'] = $output;
857 }
858
859 // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
860 // NOTE: the revision is either new or current, so we can bypass audience checks.
861 $this->renderedRevision = $this->revisionRenderer->getRenderedRevision(
862 $this->revision,
863 null,
864 null,
865 $renderHints
866 );
867 }
868
869 /**
870 * Returns the update's target revision - that is, the revision that will be the current
871 * revision after the update.
872 *
873 * @note Callers must treat the returned RevisionRecord's content as immutable, even
874 * if it is a MutableRevisionRecord instance. Other aspects of a MutableRevisionRecord
875 * returned from here, such as the user or the comment, may be changed, but may not
876 * be reflected in ParserOutput until after prepareUpdate() has been called.
877 *
878 * @todo This is currently used by PageUpdater::makeNewRevision() to construct an unsaved
879 * MutableRevisionRecord instance. Introduce something like an UnsavedRevisionFactory service
880 * for that purpose instead!
881 *
882 * @return RevisionRecord
883 */
884 public function getRevision() {
885 $this->assertPrepared( __METHOD__ );
886 return $this->revision;
887 }
888
889 /**
890 * @return RenderedRevision
891 */
892 public function getRenderedRevision() {
893 $this->assertPrepared( __METHOD__ );
894
895 return $this->renderedRevision;
896 }
897
898 private function assertHasPageState( $method ) {
899 if ( !$this->pageState ) {
900 throw new LogicException(
901 'Must call grabCurrentRevision() or prepareContent() '
902 . 'or prepareUpdate() before calling ' . $method
903 );
904 }
905 }
906
907 private function assertPrepared( $method ) {
908 if ( !$this->revision ) {
909 throw new LogicException(
910 'Must call prepareContent() or prepareUpdate() before calling ' . $method
911 );
912 }
913 }
914
915 private function assertHasRevision( $method ) {
916 if ( !$this->revision->getId() ) {
917 throw new LogicException(
918 'Must call prepareUpdate() before calling ' . $method
919 );
920 }
921 }
922
923 /**
924 * Whether the edit creates the page.
925 *
926 * @return bool
927 */
928 public function isCreation() {
929 $this->assertPrepared( __METHOD__ );
930 return $this->options['created'];
931 }
932
933 /**
934 * Whether the edit created, or should create, a new revision (that is, it's not a null-edit).
935 *
936 * @warning at present, "null-revisions" that do not change content but do have a revision
937 * record would return false after prepareContent(), but true after prepareUpdate()!
938 * This should probably be fixed.
939 *
940 * @return bool
941 */
942 public function isChange() {
943 $this->assertPrepared( __METHOD__ );
944 return $this->options['changed'];
945 }
946
947 /**
948 * Whether the page was a redirect before the edit.
949 *
950 * @return bool
951 */
952 public function wasRedirect() {
953 $this->assertHasPageState( __METHOD__ );
954
955 if ( $this->pageState['oldIsRedirect'] === null ) {
956 /** @var RevisionRecord $rev */
957 $rev = $this->pageState['oldRevision'];
958 if ( $rev ) {
959 $this->pageState['oldIsRedirect'] = $this->revisionIsRedirect( $rev );
960 } else {
961 $this->pageState['oldIsRedirect'] = false;
962 }
963 }
964
965 return $this->pageState['oldIsRedirect'];
966 }
967
968 /**
969 * Returns the slots of the target revision, after PST.
970 *
971 * @note Callers must treat the returned RevisionSlots instance as immutable, even
972 * if it is a MutableRevisionSlots instance.
973 *
974 * @return RevisionSlots
975 */
976 public function getSlots() {
977 $this->assertPrepared( __METHOD__ );
978 return $this->revision->getSlots();
979 }
980
981 /**
982 * Returns the RevisionSlotsUpdate for this updater.
983 *
984 * @return RevisionSlotsUpdate
985 */
986 private function getRevisionSlotsUpdate() {
987 $this->assertPrepared( __METHOD__ );
988
989 if ( !$this->slotsUpdate ) {
990 $old = $this->getParentRevision();
991 $this->slotsUpdate = RevisionSlotsUpdate::newFromRevisionSlots(
992 $this->revision->getSlots(),
993 $old ? $old->getSlots() : null
994 );
995 }
996 return $this->slotsUpdate;
997 }
998
999 /**
1000 * Returns the role names of the slots touched by the new revision,
1001 * including removed roles.
1002 *
1003 * @return string[]
1004 */
1005 public function getTouchedSlotRoles() {
1006 return $this->getRevisionSlotsUpdate()->getTouchedRoles();
1007 }
1008
1009 /**
1010 * Returns the role names of the slots modified by the new revision,
1011 * not including removed roles.
1012 *
1013 * @return string[]
1014 */
1015 public function getModifiedSlotRoles() {
1016 return $this->getRevisionSlotsUpdate()->getModifiedRoles();
1017 }
1018
1019 /**
1020 * Returns the role names of the slots removed by the new revision.
1021 *
1022 * @return string[]
1023 */
1024 public function getRemovedSlotRoles() {
1025 return $this->getRevisionSlotsUpdate()->getRemovedRoles();
1026 }
1027
1028 /**
1029 * Prepare derived data updates targeting the given Revision.
1030 *
1031 * Calling this method requires the given revision to be present in the database.
1032 * This may be right after a new revision has been created, or when re-generating
1033 * derived data e.g. in ApiPurge, RefreshLinksJob, and the refreshLinks
1034 * script.
1035 *
1036 * @see docs/pageupdater.txt for more information on when thie method can and should be called.
1037 *
1038 * @note Calling this method more than once with the same revision has no effect.
1039 * $options are only used for the first call. Calling this method multiple times with
1040 * different revisions will cause an exception.
1041 *
1042 * @note If grabCurrentRevision() (or prepareContent()) has been called before
1043 * calling this method, $revision->getParentRevision() has to refer to the revision that
1044 * was the current revision at the time grabCurrentRevision() was called.
1045 *
1046 * @param RevisionRecord $revision
1047 * @param array $options Array of options, following indexes are used:
1048 * - changed: bool, whether the revision changed the content (default true)
1049 * - created: bool, whether the revision created the page (default false)
1050 * - moved: bool, whether the page was moved (default false)
1051 * - restored: bool, whether the page was undeleted (default false)
1052 * - oldrevision: Revision object for the pre-update revision (default null)
1053 * - triggeringUser: The user triggering the update (UserIdentity, defaults to the
1054 * user who created the revision)
1055 * - oldredirect: bool, null, or string 'no-change' (default null):
1056 * - bool: whether the page was counted as a redirect before that
1057 * revision, only used in changed is true and created is false
1058 * - null or 'no-change': don't update the redirect status.
1059 * - oldcountable: bool, null, or string 'no-change' (default null):
1060 * - bool: whether the page was counted as an article before that
1061 * revision, only used in changed is true and created is false
1062 * - null: if created is false, don't update the article count; if created
1063 * is true, do update the article count
1064 * - 'no-change': don't update the article count, ever
1065 * When set to null, pageState['oldCountable'] will be used instead if available.
1066 * - causeAction: an arbitrary string identifying the reason for the update.
1067 * See DataUpdate::getCauseAction(). (default 'unknown')
1068 * - causeAgent: name of the user who caused the update. See DataUpdate::getCauseAgent().
1069 * (string, default 'unknown')
1070 */
1071 public function prepareUpdate( RevisionRecord $revision, array $options = [] ) {
1072 Assert::parameter(
1073 !isset( $options['oldrevision'] )
1074 || $options['oldrevision'] instanceof Revision
1075 || $options['oldrevision'] instanceof RevisionRecord,
1076 '$options["oldrevision"]',
1077 'must be a RevisionRecord (or Revision)'
1078 );
1079 Assert::parameter(
1080 !isset( $options['triggeringUser'] )
1081 || $options['triggeringUser'] instanceof UserIdentity,
1082 '$options["triggeringUser"]',
1083 'must be a UserIdentity'
1084 );
1085
1086 if ( !$revision->getId() ) {
1087 throw new InvalidArgumentException(
1088 'Revision must have an ID set for it to be used with prepareUpdate()!'
1089 );
1090 }
1091
1092 if ( $this->revision && $this->revision->getId() ) {
1093 if ( $this->revision->getId() === $revision->getId() ) {
1094 return; // nothing to do!
1095 } else {
1096 throw new LogicException(
1097 'Trying to re-use DerivedPageDataUpdater with revision '
1098 . $revision->getId()
1099 . ', but it\'s already bound to revision '
1100 . $this->revision->getId()
1101 );
1102 }
1103 }
1104
1105 if ( $this->revision
1106 && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
1107 ) {
1108 throw new LogicException(
1109 'The Revision provided has mismatching content!'
1110 );
1111 }
1112
1113 // Override fields defined in $this->options with values from $options.
1114 $this->options = array_intersect_key( $options, $this->options ) + $this->options;
1115
1116 if ( $this->revision ) {
1117 $oldId = $this->pageState['oldId'] ?? 0;
1118 $this->options['newrev'] = ( $revision->getId() !== $oldId );
1119 } elseif ( isset( $this->options['oldrevision'] ) ) {
1120 /** @var Revision|RevisionRecord $oldRev */
1121 $oldRev = $this->options['oldrevision'];
1122 $oldId = $oldRev->getId();
1123 $this->options['newrev'] = ( $revision->getId() !== $oldId );
1124 } else {
1125 $oldId = $revision->getParentId();
1126 }
1127
1128 if ( $oldId !== null ) {
1129 // XXX: what if $options['changed'] disagrees?
1130 // MovePage creates a dummy revision with changed = false!
1131 // We may want to explicitly distinguish between "no new revision" (null-edit)
1132 // and "new revision without new content" (dummy revision).
1133
1134 if ( $oldId === $revision->getParentId() ) {
1135 // NOTE: this may still be a NullRevision!
1136 // New revision!
1137 $this->options['changed'] = true;
1138 } elseif ( $oldId === $revision->getId() ) {
1139 // Null-edit!
1140 $this->options['changed'] = false;
1141 } else {
1142 // This indicates that calling code has given us the wrong Revision object
1143 throw new LogicException(
1144 'The Revision mismatches old revision ID: '
1145 . 'Old ID is ' . $oldId
1146 . ', parent ID is ' . $revision->getParentId()
1147 . ', revision ID is ' . $revision->getId()
1148 );
1149 }
1150 }
1151
1152 // If prepareContent() was used to generate the PST content (which is indicated by
1153 // $this->slotsUpdate being set), and this is not a null-edit, then the given
1154 // revision must have the acting user as the revision author. Otherwise, user
1155 // signatures generated by PST would mismatch the user in the revision record.
1156 if ( $this->user !== null && $this->options['changed'] && $this->slotsUpdate ) {
1157 $user = $revision->getUser();
1158 if ( !$this->user->equals( $user ) ) {
1159 throw new LogicException(
1160 'The Revision provided has a mismatching actor: expected '
1161 . $this->user->getName()
1162 . ', got '
1163 . $user->getName()
1164 );
1165 }
1166 }
1167
1168 // If $this->pageState was not yet initialized by grabCurrentRevision or prepareContent,
1169 // emulate the state of the page table before the edit, as good as we can.
1170 if ( !$this->pageState ) {
1171 $this->pageState = [
1172 'oldIsRedirect' => isset( $this->options['oldredirect'] )
1173 && is_bool( $this->options['oldredirect'] )
1174 ? $this->options['oldredirect']
1175 : null,
1176 'oldCountable' => isset( $this->options['oldcountable'] )
1177 && is_bool( $this->options['oldcountable'] )
1178 ? $this->options['oldcountable']
1179 : null,
1180 ];
1181
1182 if ( $this->options['changed'] ) {
1183 // The edit created a new revision
1184 $this->pageState['oldId'] = $revision->getParentId();
1185
1186 if ( isset( $this->options['oldrevision'] ) ) {
1187 $rev = $this->options['oldrevision'];
1188 $this->pageState['oldRevision'] = $rev instanceof Revision
1189 ? $rev->getRevisionRecord()
1190 : $rev;
1191 }
1192 } else {
1193 // This is a null-edit, so the old revision IS the new revision!
1194 $this->pageState['oldId'] = $revision->getId();
1195 $this->pageState['oldRevision'] = $revision;
1196 }
1197 }
1198
1199 // "created" is forced here
1200 $this->options['created'] = ( $this->pageState['oldId'] === 0 );
1201
1202 $this->revision = $revision;
1203
1204 $this->doTransition( 'has-revision' );
1205
1206 // NOTE: in case we have a User object, don't override with a UserIdentity.
1207 // We already checked that $revision->getUser() mathces $this->user;
1208 if ( !$this->user ) {
1209 $this->user = $revision->getUser( RevisionRecord::RAW );
1210 }
1211
1212 // Prune any output that depends on the revision ID.
1213 if ( $this->renderedRevision ) {
1214 $this->renderedRevision->updateRevision( $revision );
1215 } else {
1216
1217 // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
1218 // NOTE: the revision is either new or current, so we can bypass audience checks.
1219 $this->renderedRevision = $this->revisionRenderer->getRenderedRevision(
1220 $this->revision,
1221 null,
1222 null,
1223 [ 'use-master' => $this->useMaster(), 'audience' => RevisionRecord::RAW ]
1224 );
1225
1226 // XXX: Since we presumably are dealing with the current revision,
1227 // we could try to get the ParserOutput from the parser cache.
1228 }
1229
1230 // TODO: optionally get ParserOutput from the ParserCache here.
1231 // Move the logic used by RefreshLinksJob here!
1232 }
1233
1234 /**
1235 * @deprecated This only exists for B/C, use the getters on DerivedPageDataUpdater directly!
1236 * @return PreparedEdit
1237 */
1238 public function getPreparedEdit() {
1239 $this->assertPrepared( __METHOD__ );
1240
1241 $slotsUpdate = $this->getRevisionSlotsUpdate();
1242 $preparedEdit = new PreparedEdit();
1243
1244 $preparedEdit->popts = $this->getCanonicalParserOptions();
1245 $preparedEdit->parserOutputCallback = [ $this, 'getCanonicalParserOutput' ];
1246 $preparedEdit->pstContent = $this->revision->getContent( SlotRecord::MAIN );
1247 $preparedEdit->newContent =
1248 $slotsUpdate->isModifiedSlot( SlotRecord::MAIN )
1249 ? $slotsUpdate->getModifiedSlot( SlotRecord::MAIN )->getContent()
1250 : $this->revision->getContent( SlotRecord::MAIN ); // XXX: can we just remove this?
1251 $preparedEdit->oldContent = null; // unused. // XXX: could get this from the parent revision
1252 $preparedEdit->revid = $this->revision ? $this->revision->getId() : null;
1253 $preparedEdit->timestamp = $preparedEdit->output->getCacheTime();
1254 $preparedEdit->format = $preparedEdit->pstContent->getDefaultFormat();
1255
1256 return $preparedEdit;
1257 }
1258
1259 /**
1260 * @param string $role
1261 * @param bool $generateHtml
1262 * @return ParserOutput
1263 */
1264 public function getSlotParserOutput( $role, $generateHtml = true ) {
1265 return $this->getRenderedRevision()->getSlotParserOutput(
1266 $role,
1267 [ 'generate-html' => $generateHtml ]
1268 );
1269 }
1270
1271 /**
1272 * @return ParserOutput
1273 */
1274 public function getCanonicalParserOutput() {
1275 return $this->getRenderedRevision()->getRevisionParserOutput();
1276 }
1277
1278 /**
1279 * @return ParserOptions
1280 */
1281 public function getCanonicalParserOptions() {
1282 return $this->getRenderedRevision()->getOptions();
1283 }
1284
1285 /**
1286 * @param bool $recursive
1287 *
1288 * @return DeferrableUpdate[]
1289 */
1290 public function getSecondaryDataUpdates( $recursive = false ) {
1291 if ( $this->isContentDeleted() ) {
1292 // This shouldn't happen, since the current content is always public,
1293 // and DataUpates are only needed for current content.
1294 return [];
1295 }
1296
1297 $output = $this->getCanonicalParserOutput();
1298
1299 // Construct a LinksUpdate for the combined canonical output.
1300 $linksUpdate = new LinksUpdate(
1301 $this->getTitle(),
1302 $output,
1303 $recursive
1304 );
1305
1306 $allUpdates = [ $linksUpdate ];
1307
1308 // NOTE: Run updates for all slots, not just the modified slots! Otherwise,
1309 // info for an inherited slot may end up being removed. This is also needed
1310 // to ensure that purges are effective.
1311 $renderedRevision = $this->getRenderedRevision();
1312 foreach ( $this->getSlots()->getSlotRoles() as $role ) {
1313 $slot = $this->getRawSlot( $role );
1314 $content = $slot->getContent();
1315 $handler = $content->getContentHandler();
1316
1317 $updates = $handler->getSecondaryDataUpdates(
1318 $this->getTitle(),
1319 $content,
1320 $role,
1321 $renderedRevision
1322 );
1323 $allUpdates = array_merge( $allUpdates, $updates );
1324
1325 // TODO: remove B/C hack in 1.32!
1326 // NOTE: we assume that the combined output contains all relevant meta-data for
1327 // all slots!
1328 $legacyUpdates = $content->getSecondaryDataUpdates(
1329 $this->getTitle(),
1330 null,
1331 $recursive,
1332 $output
1333 );
1334
1335 // HACK: filter out redundant and incomplete LinksUpdates
1336 $legacyUpdates = array_filter( $legacyUpdates, function ( $update ) {
1337 return !( $update instanceof LinksUpdate );
1338 } );
1339
1340 $allUpdates = array_merge( $allUpdates, $legacyUpdates );
1341 }
1342
1343 // XXX: if a slot was removed by an earlier edit, but deletion updates failed to run at
1344 // that time, we don't know for which slots to run deletion updates when purging a page.
1345 // We'd have to examine the entire history of the page to determine that. Perhaps there
1346 // could be a "try extra hard" mode for that case that would run a DB query to find all
1347 // roles/models ever used on the page. On the other hand, removing slots should be quite
1348 // rare, so perhaps this isn't worth the trouble.
1349
1350 // TODO: consolidate with similar logic in WikiPage::getDeletionUpdates()
1351 $wikiPage = $this->getWikiPage();
1352 $parentRevision = $this->getParentRevision();
1353 foreach ( $this->getRemovedSlotRoles() as $role ) {
1354 // HACK: we should get the content model of the removed slot from a SlotRoleHandler!
1355 // For now, find the slot in the parent revision - if the slot was removed, it should
1356 // always exist in the parent revision.
1357 $parentSlot = $parentRevision->getSlot( $role, RevisionRecord::RAW );
1358 $content = $parentSlot->getContent();
1359 $handler = $content->getContentHandler();
1360
1361 $updates = $handler->getDeletionUpdates(
1362 $this->getTitle(),
1363 $role
1364 );
1365 $allUpdates = array_merge( $allUpdates, $updates );
1366
1367 // TODO: remove B/C hack in 1.32!
1368 $legacyUpdates = $content->getDeletionUpdates( $wikiPage );
1369
1370 // HACK: filter out redundant and incomplete LinksDeletionUpdate
1371 $legacyUpdates = array_filter( $legacyUpdates, function ( $update ) {
1372 return !( $update instanceof LinksDeletionUpdate );
1373 } );
1374
1375 $allUpdates = array_merge( $allUpdates, $legacyUpdates );
1376 }
1377
1378 // TODO: hard deprecate SecondaryDataUpdates in favor of RevisionDataUpdates in 1.33!
1379 Hooks::run(
1380 'RevisionDataUpdates',
1381 [ $this->getTitle(), $renderedRevision, &$allUpdates ]
1382 );
1383
1384 return $allUpdates;
1385 }
1386
1387 /**
1388 * Do standard updates after page edit, purge, or import.
1389 * Update links tables, site stats, search index, title cache, message cache, etc.
1390 * Purges pages that depend on this page when appropriate.
1391 * With a 10% chance, triggers pruning the recent changes table.
1392 *
1393 * @note prepareUpdate() must be called before calling this method!
1394 *
1395 * MCR migration note: this replaces WikiPage::doEditUpdates.
1396 */
1397 public function doUpdates() {
1398 $this->assertTransition( 'done' );
1399
1400 // TODO: move logic into a PageEventEmitter service
1401
1402 $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
1403
1404 $legacyUser = User::newFromIdentity( $this->user );
1405 $legacyRevision = new Revision( $this->revision );
1406
1407 $userParserOptions = ParserOptions::newFromUser( $legacyUser );
1408 // Decide whether to save the final canonical parser ouput based on the fact that
1409 // users are typically redirected to viewing pages right after they edit those pages.
1410 // Due to vary-revision-id, getting/saving that output here might require a reparse.
1411 if ( $userParserOptions->matchesForCacheKey( $this->getCanonicalParserOptions() ) ) {
1412 // Whether getting the final output requires a reparse or not, the user will
1413 // need canonical output anyway, since that is what their parser options use.
1414 // A reparse now at least has the benefit of various warm process caches.
1415 $this->doParserCacheUpdate();
1416 } else {
1417 // If the user does not have canonical parse options, then don't risk another parse
1418 // to make output they cannot use on the page refresh that typically occurs after
1419 // editing. Doing the parser output save post-send will still benefit *other* users.
1420 DeferredUpdates::addCallableUpdate( function () {
1421 $this->doParserCacheUpdate();
1422 } );
1423 }
1424
1425 // Defer the getCannonicalParserOutput() call triggered by getSecondaryDataUpdates()
1426 DeferredUpdates::addCallableUpdate( function () {
1427 $this->doSecondaryDataUpdates( [
1428 // T52785 do not update any other pages on a null edit
1429 'recursive' => $this->options['changed']
1430 ] );
1431 } );
1432
1433 // TODO: MCR: check if *any* changed slot supports categories!
1434 if ( $this->rcWatchCategoryMembership
1435 && $this->getContentHandler( SlotRecord::MAIN )->supportsCategories() === true
1436 && ( $this->options['changed'] || $this->options['created'] )
1437 && !$this->options['restored']
1438 ) {
1439 // Note: jobs are pushed after deferred updates, so the job should be able to see
1440 // the recent change entry (also done via deferred updates) and carry over any
1441 // bot/deletion/IP flags, ect.
1442 $this->jobQueueGroup->lazyPush(
1443 CategoryMembershipChangeJob::newSpec(
1444 $this->getTitle(),
1445 $this->revision->getTimestamp()
1446 )
1447 );
1448 }
1449
1450 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1451 // @note: Extensions should *avoid* calling getCannonicalParserOutput() when using
1452 // this hook whenever possible in order to avoid unnecessary additional parses.
1453 $editInfo = $this->getPreparedEdit();
1454 Hooks::run( 'ArticleEditUpdates',
1455 [ &$wikiPage, &$editInfo, $this->options['changed'] ] );
1456
1457 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1458 if ( Hooks::run( 'ArticleEditUpdatesDeleteFromRecentchanges', [ &$wikiPage ] ) ) {
1459 // Flush old entries from the `recentchanges` table
1460 if ( mt_rand( 0, 9 ) == 0 ) {
1461 $this->jobQueueGroup->lazyPush( RecentChangesUpdateJob::newPurgeJob() );
1462 }
1463 }
1464
1465 $id = $this->getPageId();
1466 $title = $this->getTitle();
1467 $dbKey = $title->getPrefixedDBkey();
1468 $shortTitle = $title->getDBkey();
1469
1470 if ( !$title->exists() ) {
1471 wfDebug( __METHOD__ . ": Page doesn't exist any more, bailing out\n" );
1472
1473 $this->doTransition( 'done' );
1474 return;
1475 }
1476
1477 DeferredUpdates::addCallableUpdate( function () {
1478 if (
1479 $this->options['oldcountable'] === 'no-change' ||
1480 ( !$this->options['changed'] && !$this->options['moved'] )
1481 ) {
1482 $good = 0;
1483 } elseif ( $this->options['created'] ) {
1484 $good = (int)$this->isCountable();
1485 } elseif ( $this->options['oldcountable'] !== null ) {
1486 $good = (int)$this->isCountable()
1487 - (int)$this->options['oldcountable'];
1488 } elseif ( isset( $this->pageState['oldCountable'] ) ) {
1489 $good = (int)$this->isCountable()
1490 - (int)$this->pageState['oldCountable'];
1491 } else {
1492 $good = 0;
1493 }
1494 $edits = $this->options['changed'] ? 1 : 0;
1495 $pages = $this->options['created'] ? 1 : 0;
1496
1497 DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
1498 [ 'edits' => $edits, 'articles' => $good, 'pages' => $pages ]
1499 ) );
1500 } );
1501
1502 // TODO: make search infrastructure aware of slots!
1503 $mainSlot = $this->revision->getSlot( SlotRecord::MAIN );
1504 if ( !$mainSlot->isInherited() && !$this->isContentDeleted() ) {
1505 DeferredUpdates::addUpdate( new SearchUpdate( $id, $dbKey, $mainSlot->getContent() ) );
1506 }
1507
1508 // If this is another user's talk page, update newtalk.
1509 // Don't do this if $options['changed'] = false (null-edits) nor if
1510 // it's a minor edit and the user making the edit doesn't generate notifications for those.
1511 if ( $this->options['changed']
1512 && $title->getNamespace() == NS_USER_TALK
1513 && $shortTitle != $legacyUser->getTitleKey()
1514 && !( $this->revision->isMinor() && $legacyUser->isAllowed( 'nominornewtalk' ) )
1515 ) {
1516 $recipient = User::newFromName( $shortTitle, false );
1517 if ( !$recipient ) {
1518 wfDebug( __METHOD__ . ": invalid username\n" );
1519 } else {
1520 // Allow extensions to prevent user notification
1521 // when a new message is added to their talk page
1522 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1523 if ( Hooks::run( 'ArticleEditUpdateNewTalk', [ &$wikiPage, $recipient ] ) ) {
1524 if ( User::isIP( $shortTitle ) ) {
1525 // An anonymous user
1526 $recipient->setNewtalk( true, $legacyRevision );
1527 } elseif ( $recipient->isLoggedIn() ) {
1528 $recipient->setNewtalk( true, $legacyRevision );
1529 } else {
1530 wfDebug( __METHOD__ . ": don't need to notify a nonexistent user\n" );
1531 }
1532 }
1533 }
1534 }
1535
1536 if ( $title->getNamespace() == NS_MEDIAWIKI
1537 && $this->getRevisionSlotsUpdate()->isModifiedSlot( SlotRecord::MAIN )
1538 ) {
1539 $mainContent = $this->isContentDeleted() ? null : $this->getRawContent( SlotRecord::MAIN );
1540
1541 $this->messageCache->updateMessageOverride( $title, $mainContent );
1542 }
1543
1544 // TODO: move onArticleCreate and onArticle into a PageEventEmitter service
1545 if ( $this->options['created'] ) {
1546 WikiPage::onArticleCreate( $title );
1547 } elseif ( $this->options['changed'] ) { // T52785
1548 WikiPage::onArticleEdit( $title, $legacyRevision, $this->getTouchedSlotRoles() );
1549 }
1550
1551 $oldRevision = $this->getParentRevision();
1552 $oldLegacyRevision = $oldRevision ? new Revision( $oldRevision ) : null;
1553
1554 // TODO: In the wiring, register a listener for this on the new PageEventEmitter
1555 ResourceLoaderWikiModule::invalidateModuleCache(
1556 $title, $oldLegacyRevision, $legacyRevision, $this->getWikiId() ?: wfWikiID()
1557 );
1558
1559 $this->doTransition( 'done' );
1560 }
1561
1562 /**
1563 * Do secondary data updates (such as updating link tables).
1564 *
1565 * MCR note: this method is temporarily exposed via WikiPage::doSecondaryDataUpdates.
1566 *
1567 * @param array $options
1568 * - recursive: make the update recursive, i.e. also update pages which transclude the
1569 * current page or otherwise depend on it (default: false)
1570 * - defer: one of the DeferredUpdates constants, or false to run immediately after waiting
1571 * for replication of the changes from the SecondaryDataUpdates hooks (default: false)
1572 * - transactionTicket: a transaction ticket from LBFactory::getEmptyTransactionTicket(),
1573 * only when defer is false (default: null)
1574 * @since 1.32
1575 */
1576 public function doSecondaryDataUpdates( array $options = [] ) {
1577 $this->assertHasRevision( __METHOD__ );
1578 $options += [
1579 'recursive' => false,
1580 'defer' => false,
1581 'transactionTicket' => null,
1582 ];
1583 $deferValues = [ false, DeferredUpdates::PRESEND, DeferredUpdates::POSTSEND ];
1584 if ( !in_array( $options['defer'], $deferValues, true ) ) {
1585 throw new InvalidArgumentException( 'invalid value for defer: ' . $options['defer'] );
1586 }
1587 Assert::parameterType( 'integer|null', $options['transactionTicket'],
1588 '$options[\'transactionTicket\']' );
1589
1590 $updates = $this->getSecondaryDataUpdates( $options['recursive'] );
1591
1592 $triggeringUser = $this->options['triggeringUser'] ?? $this->user;
1593 if ( !$triggeringUser instanceof User ) {
1594 $triggeringUser = User::newFromIdentity( $triggeringUser );
1595 }
1596 $causeAction = $this->options['causeAction'] ?? 'unknown';
1597 $causeAgent = $this->options['causeAgent'] ?? 'unknown';
1598 $legacyRevision = new Revision( $this->revision );
1599
1600 if ( $options['defer'] === false && $options['transactionTicket'] !== null ) {
1601 // For legacy hook handlers doing updates via LinksUpdateConstructed, make sure
1602 // any pending writes they made get flushed before the doUpdate() calls below.
1603 // This avoids snapshot-clearing errors in LinksUpdate::acquirePageLock().
1604 $this->loadbalancerFactory->commitAndWaitForReplication(
1605 __METHOD__, $options['transactionTicket']
1606 );
1607 }
1608
1609 foreach ( $updates as $update ) {
1610 if ( $update instanceof DataUpdate ) {
1611 $update->setCause( $causeAction, $causeAgent );
1612 }
1613 if ( $update instanceof LinksUpdate ) {
1614 $update->setRevision( $legacyRevision );
1615 $update->setTriggeringUser( $triggeringUser );
1616 }
1617
1618 if ( $options['defer'] === false ) {
1619 if ( $update instanceof DataUpdate && $options['transactionTicket'] !== null ) {
1620 $update->setTransactionTicket( $options['transactionTicket'] );
1621 }
1622 $update->doUpdate();
1623 } else {
1624 DeferredUpdates::addUpdate( $update, $options['defer'] );
1625 }
1626 }
1627 }
1628
1629 public function doParserCacheUpdate() {
1630 $this->assertHasRevision( __METHOD__ );
1631
1632 $wikiPage = $this->getWikiPage(); // TODO: ParserCache should accept a RevisionRecord instead
1633
1634 // NOTE: this may trigger the first parsing of the new content after an edit (when not
1635 // using pre-generated stashed output).
1636 // XXX: we may want to use the PoolCounter here. This would perhaps allow the initial parse
1637 // to be performed post-send. The client could already follow a HTTP redirect to the
1638 // page view, but would then have to wait for a response until rendering is complete.
1639 $output = $this->getCanonicalParserOutput();
1640
1641 // Save it to the parser cache. Use the revision timestamp in the case of a
1642 // freshly saved edit, as that matches page_touched and a mismatch would trigger an
1643 // unnecessary reparse.
1644 $timestamp = $this->options['newrev'] ? $this->revision->getTimestamp()
1645 : $output->getCacheTime();
1646 $this->parserCache->save(
1647 $output, $wikiPage, $this->getCanonicalParserOptions(),
1648 $timestamp, $this->revision->getId()
1649 );
1650 }
1651
1652 }