Merge "Add maintenance to populate change_tag_def table and ct_tag_id field"
[lhc/web/wiklou.git] / includes / Storage / PageUpdater.php
1 <?php
2 /**
3 * Controller-like object for creating and updating pages by creating new revisions.
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 * @author Daniel Kinzler
23 */
24
25 namespace MediaWiki\Storage;
26
27 use AtomicSectionUpdate;
28 use ChangeTags;
29 use CommentStoreComment;
30 use Content;
31 use ContentHandler;
32 use DeferredUpdates;
33 use Hooks;
34 use InvalidArgumentException;
35 use LogicException;
36 use ManualLogEntry;
37 use MediaWiki\Linker\LinkTarget;
38 use MWException;
39 use RecentChange;
40 use Revision;
41 use RuntimeException;
42 use Status;
43 use Title;
44 use User;
45 use Wikimedia\Assert\Assert;
46 use Wikimedia\Rdbms\DBConnRef;
47 use Wikimedia\Rdbms\DBUnexpectedError;
48 use Wikimedia\Rdbms\LoadBalancer;
49 use WikiPage;
50
51 /**
52 * Controller-like object for creating and updating pages by creating new revisions.
53 *
54 * PageUpdater instances provide compare-and-swap (CAS) protection against concurrent updates
55 * between the time grabParentRevision() is called and saveRevision() inserts a new revision.
56 * This allows application logic to safely perform edit conflict resolution using the parent
57 * revision's content.
58 *
59 * @see docs/pageupdater.txt for more information.
60 *
61 * MCR migration note: this replaces the relevant methods in WikiPage.
62 *
63 * @since 1.32
64 * @ingroup Page
65 */
66 class PageUpdater {
67
68 /**
69 * @var User
70 */
71 private $user;
72
73 /**
74 * @var WikiPage
75 */
76 private $wikiPage;
77
78 /**
79 * @var DerivedPageDataUpdater
80 */
81 private $derivedDataUpdater;
82
83 /**
84 * @var LoadBalancer
85 */
86 private $loadBalancer;
87
88 /**
89 * @var RevisionStore
90 */
91 private $revisionStore;
92
93 /**
94 * @var boolean see $wgUseAutomaticEditSummaries
95 * @see $wgUseAutomaticEditSummaries
96 */
97 private $useAutomaticEditSummaries = true;
98
99 /**
100 * @var int the RC patrol status the new revision should be marked with.
101 */
102 private $rcPatrolStatus = RecentChange::PRC_UNPATROLLED;
103
104 /**
105 * @var bool whether to create a log entry for new page creations.
106 */
107 private $usePageCreationLog = true;
108
109 /**
110 * @var boolean see $wgAjaxEditStash
111 */
112 private $ajaxEditStash = true;
113
114 /**
115 * The ID of the logical base revision the content of the new revision is based on.
116 * Not to be confused with the immediate parent revision (the current revision before the
117 * new revision is created).
118 * The base revision is the last revision known to the client, while the parent revision
119 * is determined on the server by grabParentRevision().
120 *
121 * @var bool|int
122 */
123 private $baseRevId = false;
124
125 /**
126 * @var array
127 */
128 private $tags = [];
129
130 /**
131 * @var int
132 */
133 private $undidRevId = 0;
134
135 /**
136 * @var RevisionSlotsUpdate
137 */
138 private $slotsUpdate;
139
140 /**
141 * @var Status|null
142 */
143 private $status = null;
144
145 /**
146 * @param User $user
147 * @param WikiPage $wikiPage
148 * @param DerivedPageDataUpdater $derivedDataUpdater
149 * @param LoadBalancer $loadBalancer
150 * @param RevisionStore $revisionStore
151 */
152 public function __construct(
153 User $user,
154 WikiPage $wikiPage,
155 DerivedPageDataUpdater $derivedDataUpdater,
156 LoadBalancer $loadBalancer,
157 RevisionStore $revisionStore
158 ) {
159 $this->user = $user;
160 $this->wikiPage = $wikiPage;
161 $this->derivedDataUpdater = $derivedDataUpdater;
162
163 $this->loadBalancer = $loadBalancer;
164 $this->revisionStore = $revisionStore;
165
166 $this->slotsUpdate = new RevisionSlotsUpdate();
167 }
168
169 /**
170 * Can be used to enable or disable automatic summaries that are applied to certain kinds of
171 * changes, like completely blanking a page.
172 *
173 * @param bool $useAutomaticEditSummaries
174 * @see $wgUseAutomaticEditSummaries
175 */
176 public function setUseAutomaticEditSummaries( $useAutomaticEditSummaries ) {
177 $this->useAutomaticEditSummaries = $useAutomaticEditSummaries;
178 }
179
180 /**
181 * Sets the "patrolled" status of the edit.
182 * Callers should check the "patrol" and "autopatrol" permissions as appropriate.
183 *
184 * @see $wgUseRCPatrol
185 * @see $wgUseNPPatrol
186 *
187 * @param int $status RC patrol status, e.g. RecentChange::PRC_AUTOPATROLLED.
188 */
189 public function setRcPatrolStatus( $status ) {
190 $this->rcPatrolStatus = $status;
191 }
192
193 /**
194 * Whether to create a log entry for new page creations.
195 *
196 * @see $wgPageCreationLog
197 *
198 * @param bool $use
199 */
200 public function setUsePageCreationLog( $use ) {
201 $this->usePageCreationLog = $use;
202 }
203
204 /**
205 * @param bool $ajaxEditStash
206 * @see $wgAjaxEditStash
207 */
208 public function setAjaxEditStash( $ajaxEditStash ) {
209 $this->ajaxEditStash = $ajaxEditStash;
210 }
211
212 private function getWikiId() {
213 return false; // TODO: get from RevisionStore!
214 }
215
216 /**
217 * @param int $mode DB_MASTER or DB_REPLICA
218 *
219 * @return DBConnRef
220 */
221 private function getDBConnectionRef( $mode ) {
222 return $this->loadBalancer->getConnectionRef( $mode, [], $this->getWikiId() );
223 }
224
225 /**
226 * @return LinkTarget
227 */
228 private function getLinkTarget() {
229 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
230 return $this->wikiPage->getTitle();
231 }
232
233 /**
234 * @return Title
235 */
236 private function getTitle() {
237 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
238 return $this->wikiPage->getTitle();
239 }
240
241 /**
242 * @return WikiPage
243 */
244 private function getWikiPage() {
245 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
246 return $this->wikiPage;
247 }
248
249 /**
250 * Checks whether this update conflicts with another update performed since the specified base
251 * revision. A user level "edit conflict" is detected when the base revision known to the client
252 * and specified via setBaseRevisionId() is not the ID of the current revision before the
253 * update. If setBaseRevisionId() was not called, this method always returns false.
254 *
255 * Note that an update expected to be based on a non-existing page will have base revision ID 0,
256 * and is considered to have a conflict if a current revision exists (that is, the page was
257 * created since the base revision was determined by the client).
258 *
259 * This method returning true indicates to calling code that edit conflict resolution should
260 * be applied before saving any data. It does not prevent the update from being performed, and
261 * it should not be confused with a "late" conflict indicated by the "edit-conflict" status.
262 * A "late" conflict is a CAS failure caused by an update being performed concurrently, between
263 * the time grabParentRevision() was called and the time saveRevision() trying to insert the
264 * new revision.
265 *
266 * @note A user level edit conflict is not the same as the "edit-conflict" status triggered by
267 * a CAS failure. Calling this method establishes the CAS token, it does not check against it:
268 * This method calls grabParentRevision(), and thus causes the expected parent revision
269 * for the update to be fixed to the page's current revision at this point in time.
270 * It acts as a compare-and-swap (CAS) token in that it is guaranteed that saveRevision()
271 * will fail with the "edit-conflict" status if the current revision of the page changes after
272 * hasEditConflict() was called and before saveRevision() could insert a new revision.
273 *
274 * @see grabParentRevision()
275 *
276 * @return bool
277 */
278 public function hasEditConflict() {
279 $baseId = $this->getBaseRevisionId();
280 if ( $baseId === false ) {
281 return false;
282 }
283
284 $parent = $this->grabParentRevision();
285 $parentId = $parent ? $parent->getId() : 0;
286
287 return $parentId !== $baseId;
288 }
289
290 /**
291 * Returns the revision that was the page's current revision when grabParentRevision()
292 * was first called. This revision is the expected parent revision of the update, and will be
293 * recorded as the new revision's parent revision (unless no new revision is created because
294 * the content was not changed).
295 *
296 * This method MUST not be called after saveRevision() was called!
297 *
298 * The current revision determined by the first call to this methods effectively acts a
299 * compare-and-swap (CAS) token which is checked by saveRevision(), which fails if any
300 * concurrent updates created a new revision.
301 *
302 * Application code should call this method before applying transformations to the new
303 * content that depend on the parent revision, e.g. adding/replacing sections, or resolving
304 * conflicts via a 3-way merge. This protects against race conditions triggered by concurrent
305 * updates.
306 *
307 * @see DerivedPageDataUpdater::grabCurrentRevision()
308 *
309 * @note The expected parent revision is not to be confused with the logical base revision.
310 * The base revision is specified by the client, the parent revision is determined from the
311 * database. If base revision and parent revision are not the same, the updates is considered
312 * to require edit conflict resolution.
313 *
314 * @throws LogicException if called after saveRevision().
315 * @return RevisionRecord|null the parent revision, or null of the page does not yet exist.
316 */
317 public function grabParentRevision() {
318 return $this->derivedDataUpdater->grabCurrentRevision();
319 }
320
321 /**
322 * @return string
323 */
324 private function getTimestampNow() {
325 // TODO: allow an override to be injected for testing
326 return wfTimestampNow();
327 }
328
329 /**
330 * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
331 * This also performs sanity checks against the base revision specified via setBaseRevisionId().
332 *
333 * @param int $flags
334 * @return int Updated $flags
335 */
336 private function checkFlags( $flags ) {
337 if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) {
338 if ( $this->baseRevId === false ) {
339 $flags |= ( $this->derivedDataUpdater->pageExisted() ) ? EDIT_UPDATE : EDIT_NEW;
340 } else {
341 $flags |= ( $this->baseRevId > 0 ) ? EDIT_UPDATE : EDIT_NEW;
342 }
343 }
344
345 return $flags;
346 }
347
348 /**
349 * Set the new content for the given slot role
350 *
351 * @param string $role A slot role name (such as "main")
352 * @param Content $content
353 */
354 public function setContent( $role, Content $content ) {
355 // TODO: MCR: check the role and the content's model against the list of supported
356 // roles, see T194046.
357
358 $this->slotsUpdate->modifyContent( $role, $content );
359 }
360
361 /**
362 * Explicitly inherit a slot from some earlier revision.
363 *
364 * The primary use case for this is rollbacks, when slots are to be inherited from
365 * the rollback target, overriding the content from the parent revision (which is the
366 * revision being rolled back).
367 *
368 * This should typically not be used to inherit slots from the parent revision, which
369 * happens implicitly. Using this method causes the given slot to be treated as "modified"
370 * during revision creation, even if it has the same content as in the parent revision.
371 *
372 * @param SlotRecord $originalSlot A slot already existing in the database, to be inherited
373 * by the new revision.
374 */
375 public function inheritSlot( SlotRecord $originalSlot ) {
376 // NOTE: this slot is inherited from some other revision, but it's
377 // a "modified" slot for the RevisionSlotsUpdate and DerivedPageDataUpdater,
378 // since it's not implicitly inherited from the parent revision.
379 $inheritedSlot = SlotRecord::newInherited( $originalSlot );
380 $this->slotsUpdate->modifySlot( $inheritedSlot );
381 }
382
383 /**
384 * Removes the slot with the given role.
385 *
386 * This discontinues the "stream" of slots with this role on the page,
387 * preventing the new revision, and any subsequent revisions, from
388 * inheriting the slot with this role.
389 *
390 * @param string $role A slot role name (but not "main")
391 */
392 public function removeSlot( $role ) {
393 if ( $role === 'main' ) {
394 throw new InvalidArgumentException( 'Cannot remove the main slot!' );
395 }
396
397 $this->slotsUpdate->removeSlot( $role );
398 }
399
400 /**
401 * Returns the ID of the logical base revision of the update. Not to be confused with the
402 * immediate parent revision. The base revision is set via setBaseRevisionId(),
403 * the parent revision is determined by grabParentRevision().
404 *
405 * Application may use this information to detect user level edit conflicts. Edit conflicts
406 * can be resolved by performing a 3-way merge, using the revision returned by this method as
407 * the common base of the conflicting revisions, namely the new revision being saved,
408 * and the revision returned by grabParentRevision().
409 *
410 * @return bool|int The ID of the base revision, 0 if the base is a non-existing page, false
411 * if no base revision was specified.
412 */
413 public function getBaseRevisionId() {
414 return $this->baseRevId;
415 }
416
417 /**
418 * Sets the ID of the revision the content of this update is based on, if any.
419 * The base revision ID is not to be confused with the new revision's parent revision:
420 * the parent revision is the page's current revision immediately before the new revision
421 * is created; the base revision indicates what revision the client based the content of
422 * the new revision on. If base revision and parent revision are not the same, the update is
423 * considered to require edit conflict resolution.
424 *
425 * @param int|bool $baseRevId The ID of the base revision, or 0 if the update is expected to be
426 * performed on a non-existing page. false can be used to indicate that the caller
427 * doesn't care about the base revision.
428 */
429 public function setBaseRevisionId( $baseRevId ) {
430 Assert::parameterType( 'integer|boolean', $baseRevId, '$baseRevId' );
431 $this->baseRevId = $baseRevId;
432 }
433
434 /**
435 * Returns the revision ID set by setUndidRevisionId(), indicating what revision is being
436 * undone by this edit.
437 *
438 * @return int
439 */
440 public function getUndidRevisionId() {
441 return $this->undidRevId;
442 }
443
444 /**
445 * Sets the ID of revision that was undone by the present update.
446 * This is used with the "undo" action, and is expected to hold the oldest revision ID
447 * in case more then one revision is being undone.
448 *
449 * @param int $undidRevId
450 */
451 public function setUndidRevisionId( $undidRevId ) {
452 Assert::parameterType( 'integer', $undidRevId, '$undidRevId' );
453 $this->undidRevId = $undidRevId;
454 }
455
456 /**
457 * Sets a tag to apply to this update.
458 * Callers are responsible for permission checks,
459 * using ChangeTags::canAddTagsAccompanyingChange.
460 * @param string $tag
461 */
462 public function addTag( $tag ) {
463 Assert::parameterType( 'string', $tag, '$tag' );
464 $this->tags[] = trim( $tag );
465 }
466
467 /**
468 * Sets tags to apply to this update.
469 * Callers are responsible for permission checks,
470 * using ChangeTags::canAddTagsAccompanyingChange.
471 * @param string[] $tags
472 */
473 public function addTags( array $tags ) {
474 Assert::parameterElementType( 'string', $tags, '$tags' );
475 foreach ( $tags as $tag ) {
476 $this->addTag( $tag );
477 }
478 }
479
480 /**
481 * Returns the list of tags set using the addTag() method.
482 *
483 * @return string[]
484 */
485 public function getExplicitTags() {
486 return $this->tags;
487 }
488
489 /**
490 * @param int $flags Bit mask: a bit mask of EDIT_XXX flags.
491 * @return string[]
492 */
493 private function computeEffectiveTags( $flags ) {
494 $tags = $this->tags;
495
496 foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) {
497 $old_content = $this->getParentContent( $role );
498
499 $handler = $this->getContentHandler( $role );
500 $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent();
501
502 // TODO: MCR: Do this for all slots. Also add tags for removing roles!
503 $tag = $handler->getChangeTag( $old_content, $content, $flags );
504 // If there is no applicable tag, null is returned, so we need to check
505 if ( $tag ) {
506 $tags[] = $tag;
507 }
508 }
509
510 // Check for undo tag
511 if ( $this->undidRevId !== 0 && in_array( 'mw-undo', ChangeTags::getSoftwareTags() ) ) {
512 $tags[] = 'mw-undo';
513 }
514
515 return array_unique( $tags );
516 }
517
518 /**
519 * Returns the content of the given slot of the parent revision, with no audience checks applied.
520 * If there is no parent revision or the slot is not defined, this returns null.
521 *
522 * @param string $role slot role name
523 * @return Content|null
524 */
525 private function getParentContent( $role ) {
526 $parent = $this->grabParentRevision();
527
528 if ( $parent && $parent->hasSlot( $role ) ) {
529 return $parent->getContent( $role, RevisionRecord::RAW );
530 }
531
532 return null;
533 }
534
535 /**
536 * @param string $role slot role name
537 * @return ContentHandler
538 */
539 private function getContentHandler( $role ) {
540 // TODO: inject something like a ContentHandlerRegistry
541 if ( $this->slotsUpdate->isModifiedSlot( $role ) ) {
542 $slot = $this->slotsUpdate->getModifiedSlot( $role );
543 } else {
544 $parent = $this->grabParentRevision();
545
546 if ( $parent ) {
547 $slot = $parent->getSlot( $role, RevisionRecord::RAW );
548 } else {
549 throw new RevisionAccessException( 'No such slot: ' . $role );
550 }
551 }
552
553 return ContentHandler::getForModelID( $slot->getModel() );
554 }
555
556 /**
557 * @param int $flags Bit mask: a bit mask of EDIT_XXX flags.
558 *
559 * @return CommentStoreComment
560 */
561 private function makeAutoSummary( $flags ) {
562 if ( !$this->useAutomaticEditSummaries || ( $flags & EDIT_AUTOSUMMARY ) === 0 ) {
563 return CommentStoreComment::newUnsavedComment( '' );
564 }
565
566 // NOTE: this generates an auto-summary for SOME RANDOM changed slot!
567 // TODO: combine auto-summaries for multiple slots!
568 // XXX: this logic should not be in the storage layer!
569 $roles = $this->slotsUpdate->getModifiedRoles();
570 $role = reset( $roles );
571
572 if ( $role === false ) {
573 return CommentStoreComment::newUnsavedComment( '' );
574 }
575
576 $handler = $this->getContentHandler( $role );
577 $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent();
578 $old_content = $this->getParentContent( $role );
579 $summary = $handler->getAutosummary( $old_content, $content, $flags );
580
581 return CommentStoreComment::newUnsavedComment( $summary );
582 }
583
584 /**
585 * Change an existing article or create a new article. Updates RC and all necessary caches,
586 * optionally via the deferred update array. This does not check user permissions.
587 *
588 * It is guaranteed that saveRevision() will fail if the current revision of the page
589 * changes after grabParentRevision() was called and before saveRevision() can insert
590 * a new revision, as per the CAS mechanism described above.
591 *
592 * However, the actual parent revision is allowed to be different from the revision set
593 * with setBaseRevisionId(). The caller is responsible for checking this via
594 * hasEditConflict() and adjusting the content of the new revision accordingly,
595 * using a 3-way-merge if desired.
596 *
597 * MCR migration note: this replaces WikiPage::doEditContent. Callers that change to using
598 * saveRevision() now need to check the "minoredit" themselves before using EDIT_MINOR.
599 *
600 * @param CommentStoreComment $summary Edit summary
601 * @param int $flags Bitfield:
602 * EDIT_NEW
603 * Create a new page, or fail with "edit-already-exists" if the page exists.
604 * EDIT_UPDATE
605 * Create a new revision, or fail with "edit-gone-missing" if the page does not exist.
606 * EDIT_MINOR
607 * Mark this revision as minor
608 * EDIT_SUPPRESS_RC
609 * Do not log the change in recentchanges
610 * EDIT_FORCE_BOT
611 * Mark the revision as automated ("bot edit")
612 * EDIT_AUTOSUMMARY
613 * Fill in blank summaries with generated text where possible
614 * EDIT_INTERNAL
615 * Signal that the page retrieve/save cycle happened entirely in this request.
616 *
617 * If neither EDIT_NEW nor EDIT_UPDATE is specified, the expected state is detected
618 * automatically via grabParentRevision(). In this case, the "edit-already-exists" or
619 * "edit-gone-missing" errors may still be triggered due to race conditions, if the page
620 * was unexpectedly created or deleted while revision creation is in progress. This can be
621 * viewed as part of the CAS mechanism described above.
622 *
623 * @return RevisionRecord|null The new revision, or null if no new revision was created due
624 * to a failure or a null-edit. Use isUnchanged(), wasSuccessful() and getStatus()
625 * to determine the outcome of the revision creation.
626 *
627 * @throws MWException
628 * @throws RuntimeException
629 */
630 public function saveRevision( CommentStoreComment $summary, $flags = 0 ) {
631 // Defend against mistakes caused by differences with the
632 // signature of WikiPage::doEditContent.
633 Assert::parameterType( 'integer', $flags, '$flags' );
634 Assert::parameterType( 'CommentStoreComment', $summary, '$summary' );
635
636 if ( $this->wasCommitted() ) {
637 throw new RuntimeException( 'saveRevision() has already been called on this PageUpdater!' );
638 }
639
640 // Low-level sanity check
641 if ( $this->getLinkTarget()->getText() === '' ) {
642 throw new RuntimeException( 'Something is trying to edit an article with an empty title' );
643 }
644
645 // TODO: MCR: check the role and the content's model against the list of supported
646 // and required roles, see T194046.
647
648 // Make sure the given content type is allowed for this page
649 // TODO: decide: Extend check to other slots? Consider the role in check? [PageType]
650 $mainContentHandler = $this->getContentHandler( 'main' );
651 if ( !$mainContentHandler->canBeUsedOn( $this->getTitle() ) ) {
652 $this->status = Status::newFatal( 'content-not-allowed-here',
653 ContentHandler::getLocalizedName( $mainContentHandler->getModelID() ),
654 $this->getTitle()->getPrefixedText()
655 );
656 return null;
657 }
658
659 // Load the data from the master database if needed. Needed to check flags.
660 // NOTE: This grabs the parent revision as the CAS token, if grabParentRevision
661 // wasn't called yet. If the page is modified by another process before we are done with
662 // it, this method must fail (with status 'edit-conflict')!
663 // NOTE: The actual parent revision may be different from $this->baseRevisionId.
664 // The caller is responsible for checking this via hasEditConflict and adjusting the
665 // content of the new revision accordingly, using a 3-way-merge.
666 $this->grabParentRevision();
667 $flags = $this->checkFlags( $flags );
668
669 // Avoid statsd noise and wasted cycles check the edit stash (T136678)
670 if ( ( $flags & EDIT_INTERNAL ) || ( $flags & EDIT_FORCE_BOT ) ) {
671 $useStashed = false;
672 } else {
673 $useStashed = $this->ajaxEditStash;
674 }
675
676 // TODO: use this only for the legacy hook, and only if something uses the legacy hook
677 $wikiPage = $this->getWikiPage();
678
679 $user = $this->user;
680
681 // Prepare the update. This performs PST and generates the canonical ParserOutput.
682 $this->derivedDataUpdater->prepareContent(
683 $this->user,
684 $this->slotsUpdate,
685 $useStashed
686 );
687
688 // TODO: don't force initialization here!
689 // This is a hack to work around the fact that late initialization of the ParserOutput
690 // causes ApiFlowEditHeaderTest::testCache to fail. Whether that failure indicates an
691 // actual problem, or is just an issue with the test setup, remains to be determined
692 // [dk, 2018-03].
693 // Anomie said in 2018-03:
694 /*
695 I suspect that what's breaking is this:
696
697 The old version of WikiPage::doEditContent() called prepareContentForEdit() which
698 generated the ParserOutput right then, so when doEditUpdates() gets called from the
699 DeferredUpdate scheduled by WikiPage::doCreate() there's no need to parse. I note
700 there's a comment there that says "Get the pre-save transform content and final
701 parser output".
702 The new version of WikiPage::doEditContent() makes a PageUpdater and calls its
703 saveRevision(), which calls DerivedPageDataUpdater::prepareContent() and
704 PageUpdater::doCreate() without ever having to actually generate a ParserOutput.
705 Thus, when DerivedPageDataUpdater::doUpdates() is called from the DeferredUpdate
706 scheduled by PageUpdater::doCreate(), it does find that it needs to parse at that point.
707
708 And the order of operations in that Flow test is presumably:
709
710 - Create a page with a call to WikiPage::doEditContent(), in a way that somehow avoids
711 processing the DeferredUpdate.
712 - Set up the "no set!" mock cache in Flow\Tests\Api\ApiTestCase::expectCacheInvalidate()
713 - Then, during the course of doing that test, a $db->commit() results in the
714 DeferredUpdates being run.
715 */
716 $this->derivedDataUpdater->getCanonicalParserOutput();
717
718 $mainContent = $this->derivedDataUpdater->getSlots()->getContent( 'main' );
719
720 // Trigger pre-save hook (using provided edit summary)
721 $hookStatus = Status::newGood( [] );
722 // TODO: replace legacy hook!
723 // TODO: avoid pass-by-reference, see T193950
724 $hook_args = [ &$wikiPage, &$user, &$mainContent, &$summary,
725 $flags & EDIT_MINOR, null, null, &$flags, &$hookStatus ];
726 // Check if the hook rejected the attempted save
727 if ( !Hooks::run( 'PageContentSave', $hook_args ) ) {
728 if ( $hookStatus->isOK() ) {
729 // Hook returned false but didn't call fatal(); use generic message
730 $hookStatus->fatal( 'edit-hook-aborted' );
731 }
732
733 $this->status = $hookStatus;
734 return null;
735 }
736
737 // Provide autosummaries if one is not provided and autosummaries are enabled
738 // XXX: $summary == null seems logical, but the empty string may actually come from the user
739 // XXX: Move this logic out of the storage layer! It does not belong here! Use a callback?
740 if ( $summary->text === '' && $summary->data === null ) {
741 $summary = $this->makeAutoSummary( $flags );
742 }
743
744 // Actually create the revision and create/update the page.
745 // Do NOT yet set $this->status!
746 if ( $flags & EDIT_UPDATE ) {
747 $status = $this->doModify( $summary, $this->user, $flags );
748 } else {
749 $status = $this->doCreate( $summary, $this->user, $flags );
750 }
751
752 // Promote user to any groups they meet the criteria for
753 DeferredUpdates::addCallableUpdate( function () use ( $user ) {
754 $user->addAutopromoteOnceGroups( 'onEdit' );
755 $user->addAutopromoteOnceGroups( 'onView' ); // b/c
756 } );
757
758 // NOTE: set $this->status only after all hooks have been called,
759 // so wasCommitted doesn't return true wehn called indirectly from a hook handler!
760 $this->status = $status;
761
762 // TODO: replace bad status with Exceptions!
763 return ( $this->status && $this->status->isOK() )
764 ? $this->status->value['revision-record']
765 : null;
766 }
767
768 /**
769 * Whether saveRevision() has been called on this instance
770 *
771 * @return bool
772 */
773 public function wasCommitted() {
774 return $this->status !== null;
775 }
776
777 /**
778 * The Status object indicating whether saveRevision() was successful, or null if
779 * saveRevision() was not yet called on this instance.
780 *
781 * @note This is here for compatibility with WikiPage::doEditContent. It may be deprecated
782 * soon.
783 *
784 * Possible status errors:
785 * edit-hook-aborted: The ArticleSave hook aborted the update but didn't
786 * set the fatal flag of $status.
787 * edit-gone-missing: In update mode, but the article didn't exist.
788 * edit-conflict: In update mode, the article changed unexpectedly.
789 * edit-no-change: Warning that the text was the same as before.
790 * edit-already-exists: In creation mode, but the article already exists.
791 *
792 * Extensions may define additional errors.
793 *
794 * $return->value will contain an associative array with members as follows:
795 * new: Boolean indicating if the function attempted to create a new article.
796 * revision: The revision object for the inserted revision, or null.
797 *
798 * @return null|Status
799 */
800 public function getStatus() {
801 return $this->status;
802 }
803
804 /**
805 * Whether saveRevision() completed successfully
806 *
807 * @return bool
808 */
809 public function wasSuccessful() {
810 return $this->status && $this->status->isOK();
811 }
812
813 /**
814 * Whether saveRevision() was called and created a new page.
815 *
816 * @return bool
817 */
818 public function isNew() {
819 return $this->status && $this->status->isOK() && $this->status->value['new'];
820 }
821
822 /**
823 * Whether saveRevision() did not create a revision because the content didn't change
824 * (null-edit). Whether the content changed or not is determined by
825 * DerivedPageDataUpdater::isChange().
826 *
827 * @return bool
828 */
829 public function isUnchanged() {
830 return $this->status
831 && $this->status->isOK()
832 && $this->status->value['revision-record'] === null;
833 }
834
835 /**
836 * The new revision created by saveRevision(), or null if saveRevision() has not yet been
837 * called, failed, or did not create a new revision because the content did not change.
838 *
839 * @return RevisionRecord|null
840 */
841 public function getNewRevision() {
842 return ( $this->status && $this->status->isOK() )
843 ? $this->status->value['revision-record']
844 : null;
845 }
846
847 /**
848 * Constructs a MutableRevisionRecord based on the Content prepared by the
849 * DerivedPageDataUpdater. This takes care of inheriting slots, updating slots
850 * with PST applied, and removing discontinued slots.
851 *
852 * This calls Content::prepareSave() to verify that the slot content can be saved.
853 * The $status parameter is updated with any errors or warnings found by Content::prepareSave().
854 *
855 * @param CommentStoreComment $comment
856 * @param User $user
857 * @param string $timestamp
858 * @param int $flags
859 * @param Status $status
860 *
861 * @return MutableRevisionRecord
862 */
863 private function makeNewRevision(
864 CommentStoreComment $comment,
865 User $user,
866 $timestamp,
867 $flags,
868 Status $status
869 ) {
870 $wikiPage = $this->getWikiPage();
871 $title = $this->getTitle();
872 $parent = $this->grabParentRevision();
873
874 $rev = new MutableRevisionRecord( $title, $this->getWikiId() );
875 $rev->setPageId( $title->getArticleID() );
876
877 if ( $parent ) {
878 $oldid = $parent->getId();
879 $rev->setParentId( $oldid );
880 } else {
881 $oldid = 0;
882 }
883
884 $rev->setComment( $comment );
885 $rev->setUser( $user );
886 $rev->setTimestamp( $timestamp );
887 $rev->setMinorEdit( ( $flags & EDIT_MINOR ) > 0 );
888
889 foreach ( $this->derivedDataUpdater->getSlots()->getSlots() as $slot ) {
890 $content = $slot->getContent();
891
892 // XXX: We may push this up to the "edit controller" level, see T192777.
893 // TODO: change the signature of PrepareSave to not take a WikiPage!
894 $prepStatus = $content->prepareSave( $wikiPage, $flags, $oldid, $user );
895
896 if ( $prepStatus->isOK() ) {
897 $rev->setSlot( $slot );
898 }
899
900 // TODO: MCR: record which problem arose in which slot.
901 $status->merge( $prepStatus );
902 }
903
904 return $rev;
905 }
906
907 /**
908 * @param CommentStoreComment $summary The edit summary
909 * @param User $user The revision's author
910 * @param int $flags EXIT_XXX constants
911 *
912 * @throws MWException
913 * @return Status
914 */
915 private function doModify( CommentStoreComment $summary, User $user, $flags ) {
916 $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
917
918 // Update article, but only if changed.
919 $status = Status::newGood( [ 'new' => false, 'revision' => null, 'revision-record' => null ] );
920
921 // Convenience variables
922 $now = $this->getTimestampNow();
923
924 $oldRev = $this->grabParentRevision();
925 $oldid = $oldRev ? $oldRev->getId() : 0;
926
927 if ( !$oldRev ) {
928 // Article gone missing
929 $status->fatal( 'edit-gone-missing' );
930
931 return $status;
932 }
933
934 $newRevisionRecord = $this->makeNewRevision(
935 $summary,
936 $user,
937 $now,
938 $flags,
939 $status
940 );
941
942 if ( !$status->isOK() ) {
943 return $status;
944 }
945
946 // XXX: we may want a flag that allows a null revision to be forced!
947 $changed = $this->derivedDataUpdater->isChange();
948 $mainContent = $newRevisionRecord->getContent( 'main' );
949
950 $dbw = $this->getDBConnectionRef( DB_MASTER );
951
952 if ( $changed ) {
953 $dbw->startAtomic( __METHOD__ );
954
955 // Get the latest page_latest value while locking it.
956 // Do a CAS style check to see if it's the same as when this method
957 // started. If it changed then bail out before touching the DB.
958 $latestNow = $wikiPage->lockAndGetLatest(); // TODO: move to storage service, pass DB
959 if ( $latestNow != $oldid ) {
960 // We don't need to roll back, since we did not modify the database yet.
961 // XXX: Or do we want to rollback, any transaction started by calling
962 // code will fail? If we want that, we should probably throw an exception.
963 $dbw->endAtomic( __METHOD__ );
964 // Page updated or deleted in the mean time
965 $status->fatal( 'edit-conflict' );
966
967 return $status;
968 }
969
970 // At this point we are now comitted to returning an OK
971 // status unless some DB query error or other exception comes up.
972 // This way callers don't have to call rollback() if $status is bad
973 // unless they actually try to catch exceptions (which is rare).
974
975 // Save revision content and meta-data
976 $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw );
977 $newLegacyRevision = new Revision( $newRevisionRecord );
978
979 // Update page_latest and friends to reflect the new revision
980 // TODO: move to storage service
981 $wasRedirect = $this->derivedDataUpdater->wasRedirect();
982 if ( !$wikiPage->updateRevisionOn( $dbw, $newLegacyRevision, null, $wasRedirect ) ) {
983 throw new PageUpdateException( "Failed to update page row to use new revision." );
984 }
985
986 // TODO: replace legacy hook!
987 $tags = $this->computeEffectiveTags( $flags );
988 Hooks::run(
989 'NewRevisionFromEditComplete',
990 [ $wikiPage, $newLegacyRevision, $this->baseRevId, $user, &$tags ]
991 );
992
993 // Update recentchanges
994 if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
995 // Add RC row to the DB
996 RecentChange::notifyEdit(
997 $now,
998 $this->getTitle(),
999 $newRevisionRecord->isMinor(),
1000 $user,
1001 $summary->text, // TODO: pass object when that becomes possible
1002 $oldid,
1003 $newRevisionRecord->getTimestamp(),
1004 ( $flags & EDIT_FORCE_BOT ) > 0,
1005 '',
1006 $oldRev->getSize(),
1007 $newRevisionRecord->getSize(),
1008 $newRevisionRecord->getId(),
1009 $this->rcPatrolStatus,
1010 $tags
1011 );
1012 }
1013
1014 $user->incEditCount();
1015
1016 $dbw->endAtomic( __METHOD__ );
1017 } else {
1018 // T34948: revision ID must be set to page {{REVISIONID}} and
1019 // related variables correctly. Likewise for {{REVISIONUSER}} (T135261).
1020 // Since we don't insert a new revision into the database, the least
1021 // error-prone way is to reuse given old revision.
1022 $newRevisionRecord = $oldRev;
1023 $newLegacyRevision = new Revision( $newRevisionRecord );
1024 }
1025
1026 if ( $changed ) {
1027 // Return the new revision to the caller
1028 $status->value['revision-record'] = $newRevisionRecord;
1029
1030 // TODO: globally replace usages of 'revision' with getNewRevision()
1031 $status->value['revision'] = $newLegacyRevision;
1032 } else {
1033 $status->warning( 'edit-no-change' );
1034 // Update page_touched as updateRevisionOn() was not called.
1035 // Other cache updates are managed in WikiPage::onArticleEdit()
1036 // via WikiPage::doEditUpdates().
1037 $this->getTitle()->invalidateCache( $now );
1038 }
1039
1040 // Do secondary updates once the main changes have been committed...
1041 // NOTE: the updates have to be processed before sending the response to the client
1042 // (DeferredUpdates::PRESEND), otherwise the client may already be following the
1043 // HTTP redirect to the standard view before dervide data has been created - most
1044 // importantly, before the parser cache has been updated. This would cause the
1045 // content to be parsed a second time, or may cause stale content to be shown.
1046 DeferredUpdates::addUpdate(
1047 new AtomicSectionUpdate(
1048 $dbw,
1049 __METHOD__,
1050 function () use (
1051 $wikiPage, $newRevisionRecord, $newLegacyRevision, $user, $mainContent,
1052 $summary, $flags, $changed, $status
1053 ) {
1054 // Update links tables, site stats, etc.
1055 $this->derivedDataUpdater->prepareUpdate(
1056 $newRevisionRecord,
1057 [
1058 'changed' => $changed,
1059 ]
1060 );
1061 $this->derivedDataUpdater->doUpdates();
1062
1063 // Trigger post-save hook
1064 // TODO: replace legacy hook!
1065 // TODO: avoid pass-by-reference, see T193950
1066 $params = [ &$wikiPage, &$user, $mainContent, $summary->text, $flags & EDIT_MINOR,
1067 null, null, &$flags, $newLegacyRevision, &$status, $this->baseRevId,
1068 $this->undidRevId ];
1069 Hooks::run( 'PageContentSaveComplete', $params );
1070 }
1071 ),
1072 DeferredUpdates::PRESEND
1073 );
1074
1075 return $status;
1076 }
1077
1078 /**
1079 * @param CommentStoreComment $summary The edit summary
1080 * @param User $user The revision's author
1081 * @param int $flags EXIT_XXX constants
1082 *
1083 * @throws DBUnexpectedError
1084 * @throws MWException
1085 * @return Status
1086 */
1087 private function doCreate( CommentStoreComment $summary, User $user, $flags ) {
1088 $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
1089
1090 if ( !$this->derivedDataUpdater->getSlots()->hasSlot( 'main' ) ) {
1091 throw new PageUpdateException( 'Must provide a main slot when creating a page!' );
1092 }
1093
1094 $status = Status::newGood( [ 'new' => true, 'revision' => null, 'revision-record' => null ] );
1095
1096 $now = $this->getTimestampNow();
1097
1098 $newRevisionRecord = $this->makeNewRevision(
1099 $summary,
1100 $user,
1101 $now,
1102 $flags,
1103 $status
1104 );
1105
1106 if ( !$status->isOK() ) {
1107 return $status;
1108 }
1109
1110 $dbw = $this->getDBConnectionRef( DB_MASTER );
1111 $dbw->startAtomic( __METHOD__ );
1112
1113 // Add the page record unless one already exists for the title
1114 // TODO: move to storage service
1115 $newid = $wikiPage->insertOn( $dbw );
1116 if ( $newid === false ) {
1117 $dbw->endAtomic( __METHOD__ ); // nothing inserted
1118 $status->fatal( 'edit-already-exists' );
1119
1120 return $status; // nothing done
1121 }
1122
1123 // At this point we are now comitted to returning an OK
1124 // status unless some DB query error or other exception comes up.
1125 // This way callers don't have to call rollback() if $status is bad
1126 // unless they actually try to catch exceptions (which is rare).
1127 $newRevisionRecord->setPageId( $newid );
1128
1129 // Save the revision text...
1130 $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw );
1131 $newLegacyRevision = new Revision( $newRevisionRecord );
1132
1133 // Update the page record with revision data
1134 // TODO: move to storage service
1135 if ( !$wikiPage->updateRevisionOn( $dbw, $newLegacyRevision, 0 ) ) {
1136 throw new PageUpdateException( "Failed to update page row to use new revision." );
1137 }
1138
1139 // TODO: replace legacy hook!
1140 $tags = $this->computeEffectiveTags( $flags );
1141 Hooks::run(
1142 'NewRevisionFromEditComplete',
1143 [ $wikiPage, $newLegacyRevision, false, $user, &$tags ]
1144 );
1145
1146 // Update recentchanges
1147 if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
1148 // Add RC row to the DB
1149 RecentChange::notifyNew(
1150 $now,
1151 $this->getTitle(),
1152 $newRevisionRecord->isMinor(),
1153 $user,
1154 $summary->text, // TODO: pass object when that becomes possible
1155 ( $flags & EDIT_FORCE_BOT ) > 0,
1156 '',
1157 $newRevisionRecord->getSize(),
1158 $newRevisionRecord->getId(),
1159 $this->rcPatrolStatus,
1160 $tags
1161 );
1162 }
1163
1164 $user->incEditCount();
1165
1166 if ( $this->usePageCreationLog ) {
1167 // Log the page creation
1168 // @TODO: Do we want a 'recreate' action?
1169 $logEntry = new ManualLogEntry( 'create', 'create' );
1170 $logEntry->setPerformer( $user );
1171 $logEntry->setTarget( $this->getTitle() );
1172 $logEntry->setComment( $summary->text );
1173 $logEntry->setTimestamp( $now );
1174 $logEntry->setAssociatedRevId( $newRevisionRecord->getId() );
1175 $logEntry->insert();
1176 // Note that we don't publish page creation events to recentchanges
1177 // (i.e. $logEntry->publish()) since this would create duplicate entries,
1178 // one for the edit and one for the page creation.
1179 }
1180
1181 $dbw->endAtomic( __METHOD__ );
1182
1183 // Return the new revision to the caller
1184 // TODO: globally replace usages of 'revision' with getNewRevision()
1185 $status->value['revision'] = $newLegacyRevision;
1186 $status->value['revision-record'] = $newRevisionRecord;
1187
1188 // XXX: make sure we are not loading the Content from the DB
1189 $mainContent = $newRevisionRecord->getContent( 'main' );
1190
1191 // Do secondary updates once the main changes have been committed...
1192 DeferredUpdates::addUpdate(
1193 new AtomicSectionUpdate(
1194 $dbw,
1195 __METHOD__,
1196 function () use (
1197 $wikiPage,
1198 $newRevisionRecord,
1199 $newLegacyRevision,
1200 $user,
1201 $mainContent,
1202 $summary,
1203 $flags,
1204 $status
1205 ) {
1206 // Update links, etc.
1207 $this->derivedDataUpdater->prepareUpdate(
1208 $newRevisionRecord,
1209 [ 'created' => true ]
1210 );
1211 $this->derivedDataUpdater->doUpdates();
1212
1213 // Trigger post-create hook
1214 // TODO: replace legacy hook!
1215 // TODO: avoid pass-by-reference, see T193950
1216 $params = [ &$wikiPage, &$user, $mainContent, $summary->text,
1217 $flags & EDIT_MINOR, null, null, &$flags, $newLegacyRevision ];
1218 Hooks::run( 'PageContentInsertComplete', $params );
1219 // Trigger post-save hook
1220 // TODO: replace legacy hook!
1221 $params = array_merge( $params, [ &$status, $this->baseRevId, 0 ] );
1222 Hooks::run( 'PageContentSaveComplete', $params );
1223 }
1224 ),
1225 DeferredUpdates::PRESEND
1226 );
1227
1228 return $status;
1229 }
1230
1231 }