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