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