MediaWiki REL1_38
RevisionStore.php
Go to the documentation of this file.
1<?php
28namespace MediaWiki\Revision;
29
31use BagOStuff;
32use CommentStore;
34use Content;
38use InvalidArgumentException;
39use LogicException;
45use MediaWiki\Page\LegacyArticleIdAccess;
57use MWException;
58use MWTimestamp;
60use Psr\Log\LoggerAwareInterface;
61use Psr\Log\LoggerInterface;
62use Psr\Log\NullLogger;
63use RecentChange;
64use RuntimeException;
65use StatusValue;
66use stdClass;
67use Title;
68use TitleFactory;
69use Traversable;
71use Wikimedia\Assert\Assert;
72use Wikimedia\IPUtils;
78
89 implements IDBAccessObject, RevisionFactory, RevisionLookup, LoggerAwareInterface {
90
91 use LegacyArticleIdAccess;
92
93 public const ROW_CACHE_KEY = 'revision-row-1.29';
94
95 public const ORDER_OLDEST_TO_NEWEST = 'ASC';
96 public const ORDER_NEWEST_TO_OLDEST = 'DESC';
97
98 // Constants for get(...)Between methods
99 public const INCLUDE_OLD = 'include_old';
100 public const INCLUDE_NEW = 'include_new';
101 public const INCLUDE_BOTH = 'include_both';
102
106 private $blobStore;
107
111 private $wikiId;
112
117
121 private $cache;
122
126 private $localCache;
127
132
137
139 private $actorStore;
140
144 private $logger;
145
150
155
158
161
163 private $hookRunner;
164
166 private $pageStore;
167
170
196 public function __construct(
210 HookContainer $hookContainer,
211 $wikiId = WikiAwareEntity::LOCAL
212 ) {
213 Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' );
214
215 $this->loadBalancer = $loadBalancer;
216 $this->blobStore = $blobStore;
217 $this->cache = $cache;
218 $this->localCache = $localCache;
219 $this->commentStore = $commentStore;
220 $this->contentModelStore = $contentModelStore;
221 $this->slotRoleStore = $slotRoleStore;
222 $this->slotRoleRegistry = $slotRoleRegistry;
223 $this->actorMigration = $actorMigration;
224 $this->actorStore = $actorStore;
225 $this->wikiId = $wikiId;
226 $this->logger = new NullLogger();
227 $this->contentHandlerFactory = $contentHandlerFactory;
228 $this->pageStore = $pageStore;
229 $this->titleFactory = $titleFactory;
230 $this->hookRunner = new HookRunner( $hookContainer );
231 }
232
233 public function setLogger( LoggerInterface $logger ) {
234 $this->logger = $logger;
235 }
236
240 public function isReadOnly() {
241 return $this->blobStore->isReadOnly();
242 }
243
247 private function getDBLoadBalancer() {
248 return $this->loadBalancer;
249 }
250
256 public function getWikiId() {
257 return $this->wikiId;
258 }
259
265 private function getDBConnectionRefForQueryFlags( $queryFlags ) {
266 list( $mode, ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
267 return $this->getDBConnectionRef( $mode );
268 }
269
276 private function getDBConnectionRef( $mode, $groups = [] ) {
277 $lb = $this->getDBLoadBalancer();
278 return $lb->getConnectionRef( $mode, $groups, $this->wikiId );
279 }
280
297 public function getTitle( $pageId, $revId, $queryFlags = self::READ_NORMAL ) {
298 // TODO: Hard-deprecate this once getPage() returns a PageRecord. T195069
299 if ( $this->wikiId !== WikiAwareEntity::LOCAL ) {
300 wfDeprecatedMsg( 'Using a Title object to refer to a page on another site.', '1.36' );
301 }
302
303 $page = $this->getPage( $pageId, $revId, $queryFlags );
304 return $this->titleFactory->castFromPageIdentity( $page );
305 }
306
317 private function getPage( ?int $pageId, ?int $revId, int $queryFlags = self::READ_NORMAL ) {
318 if ( !$pageId && !$revId ) {
319 throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' );
320 }
321
322 // This method recalls itself with READ_LATEST if READ_NORMAL doesn't get us a Title
323 // So ignore READ_LATEST_IMMUTABLE flags and handle the fallback logic in this method
324 if ( DBAccessObjectUtils::hasFlags( $queryFlags, self::READ_LATEST_IMMUTABLE ) ) {
325 $queryFlags = self::READ_NORMAL;
326 }
327
328 // Loading by ID is best
329 if ( $pageId !== null && $pageId > 0 ) {
330 $page = $this->pageStore->getPageById( $pageId, $queryFlags );
331 if ( $page ) {
332 return $this->wrapPage( $page );
333 }
334 }
335
336 // rev_id is defined as NOT NULL, but this revision may not yet have been inserted.
337 if ( $revId !== null && $revId > 0 ) {
338 $pageQuery = $this->pageStore->newSelectQueryBuilder( $queryFlags )
339 ->join( 'revision', null, 'page_id=rev_page' )
340 ->conds( [ 'rev_id' => $revId ] )
341 ->caller( __METHOD__ );
342
343 $page = $pageQuery->fetchPageRecord();
344 if ( $page ) {
345 return $this->wrapPage( $page );
346 }
347 }
348
349 // If we still don't have a title, fallback to primary DB if that wasn't already happening.
350 if ( $queryFlags === self::READ_NORMAL ) {
351 $title = $this->getPage( $pageId, $revId, self::READ_LATEST );
352 if ( $title ) {
353 $this->logger->info(
354 __METHOD__ . ' fell back to READ_LATEST and got a Title.',
355 [ 'trace' => wfBacktrace() ]
356 );
357 return $title;
358 }
359 }
360
361 throw new RevisionAccessException(
362 'Could not determine title for page ID {page_id} and revision ID {rev_id}',
363 [
364 'page_id' => $pageId,
365 'rev_id' => $revId,
366 ]
367 );
368 }
369
375 private function wrapPage( PageIdentity $page ): PageIdentity {
376 if ( $this->wikiId === WikiAwareEntity::LOCAL ) {
377 // NOTE: since there is still a lot of code that needs a full Title,
378 // and uses Title::castFromPageIdentity() to get one, it's beneficial
379 // to create a Title right away if we can, so we don't have to convert
380 // over and over later on.
381 // When there is less need to convert to Title, this special case can
382 // be removed.
383 return $this->titleFactory->castFromPageIdentity( $page );
384 } else {
385 return $page;
386 }
387 }
388
396 private function failOnNull( $value, $name ) {
397 if ( $value === null ) {
399 "$name must not be " . var_export( $value, true ) . "!"
400 );
401 }
402
403 return $value;
404 }
405
413 private function failOnEmpty( $value, $name ) {
414 if ( $value === null || $value === 0 || $value === '' ) {
416 "$name must not be " . var_export( $value, true ) . "!"
417 );
418 }
419
420 return $value;
421 }
422
434 public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) {
435 // TODO: pass in a DBTransactionContext instead of a database connection.
436 $this->checkDatabaseDomain( $dbw );
437
438 $slotRoles = $rev->getSlotRoles();
439
440 // Make sure the main slot is always provided throughout migration
441 if ( !in_array( SlotRecord::MAIN, $slotRoles ) ) {
443 'main slot must be provided'
444 );
445 }
446
447 // Checks
448 $this->failOnNull( $rev->getSize(), 'size field' );
449 $this->failOnEmpty( $rev->getSha1(), 'sha1 field' );
450 $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' );
451 $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' );
452 $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' );
453 $this->failOnNull( $user->getId(), 'user field' );
454 $this->failOnEmpty( $user->getName(), 'user_text field' );
455
456 if ( !$rev->isReadyForInsertion() ) {
457 // This is here for future-proofing. At the time this check being added, it
458 // was redundant to the individual checks above.
459 throw new IncompleteRevisionException( 'Revision is incomplete' );
460 }
461
462 if ( $slotRoles == [ SlotRecord::MAIN ] ) {
463 // T239717: If the main slot is the only slot, make sure the revision's nominal size
464 // and hash match the main slot's nominal size and hash.
465 $mainSlot = $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW );
466 Assert::precondition(
467 $mainSlot->getSize() === $rev->getSize(),
468 'The revisions\'s size must match the main slot\'s size (see T239717)'
469 );
470 Assert::precondition(
471 $mainSlot->getSha1() === $rev->getSha1(),
472 'The revisions\'s SHA1 hash must match the main slot\'s SHA1 hash (see T239717)'
473 );
474 }
475
476 $pageId = $this->failOnEmpty( $rev->getPageId( $this->wikiId ), 'rev_page field' ); // check this early
477
478 $parentId = $rev->getParentId() ?? $this->getPreviousRevisionId( $dbw, $rev );
479
481 $rev = $dbw->doAtomicSection(
482 __METHOD__,
483 function ( IDatabase $dbw, $fname ) use (
484 $rev,
485 $user,
486 $comment,
487 $pageId,
488 $parentId
489 ) {
490 return $this->insertRevisionInternal(
491 $rev,
492 $dbw,
493 $user,
494 $comment,
495 $rev->getPage(),
496 $pageId,
497 $parentId
498 );
499 }
500 );
501
502 Assert::postcondition( $rev->getId( $this->wikiId ) > 0, 'revision must have an ID' );
503 Assert::postcondition( $rev->getPageId( $this->wikiId ) > 0, 'revision must have a page ID' );
504 Assert::postcondition(
505 $rev->getComment( RevisionRecord::RAW ) !== null,
506 'revision must have a comment'
507 );
508 Assert::postcondition(
509 $rev->getUser( RevisionRecord::RAW ) !== null,
510 'revision must have a user'
511 );
512
513 // Trigger exception if the main slot is missing.
514 // Technically, this could go away after MCR migration: while
515 // calling code may require a main slot to exist, RevisionStore
516 // really should not know or care about that requirement.
518
519 foreach ( $slotRoles as $role ) {
520 $slot = $rev->getSlot( $role, RevisionRecord::RAW );
521 Assert::postcondition(
522 $slot->getContent() !== null,
523 $role . ' slot must have content'
524 );
525 Assert::postcondition(
526 $slot->hasRevision(),
527 $role . ' slot must have a revision associated'
528 );
529 }
530
531 $this->hookRunner->onRevisionRecordInserted( $rev );
532
533 return $rev;
534 }
535
548 public function updateSlotsOn(
549 RevisionRecord $revision,
550 RevisionSlotsUpdate $revisionSlotsUpdate,
551 IDatabase $dbw
552 ): array {
553 $this->checkDatabaseDomain( $dbw );
554
555 // Make sure all modified and removed slots are derived slots
556 foreach ( $revisionSlotsUpdate->getModifiedRoles() as $role ) {
557 Assert::precondition(
558 $this->slotRoleRegistry->getRoleHandler( $role )->isDerived(),
559 'Trying to modify a slot that is not derived'
560 );
561 }
562 foreach ( $revisionSlotsUpdate->getRemovedRoles() as $role ) {
563 $isDerived = $this->slotRoleRegistry->getRoleHandler( $role )->isDerived();
564 Assert::precondition(
565 $isDerived,
566 'Trying to remove a slot that is not derived'
567 );
568 throw new LogicException( 'Removing derived slots is not yet implemented. See T277394.' );
569 }
570
572 $slotRecords = $dbw->doAtomicSection(
573 __METHOD__,
574 function ( IDatabase $dbw, $fname ) use (
575 $revision,
576 $revisionSlotsUpdate
577 ) {
578 return $this->updateSlotsInternal(
579 $revision,
580 $revisionSlotsUpdate,
581 $dbw
582 );
583 }
584 );
585
586 foreach ( $slotRecords as $role => $slot ) {
587 Assert::postcondition(
588 $slot->getContent() !== null,
589 $role . ' slot must have content'
590 );
591 Assert::postcondition(
592 $slot->hasRevision(),
593 $role . ' slot must have a revision associated'
594 );
595 }
596
597 return $slotRecords;
598 }
599
606 private function updateSlotsInternal(
607 RevisionRecord $revision,
608 RevisionSlotsUpdate $revisionSlotsUpdate,
609 IDatabase $dbw
610 ): array {
611 $page = $revision->getPage();
612 $revId = $revision->getId( $this->wikiId );
613 $blobHints = [
614 BlobStore::PAGE_HINT => $page->getId( $this->wikiId ),
615 BlobStore::REVISION_HINT => $revId,
616 BlobStore::PARENT_HINT => $revision->getParentId( $this->wikiId ),
617 ];
618
619 $newSlots = [];
620 foreach ( $revisionSlotsUpdate->getModifiedRoles() as $role ) {
621 $slot = $revisionSlotsUpdate->getModifiedSlot( $role );
622 $newSlots[$role] = $this->insertSlotOn( $dbw, $revId, $slot, $page, $blobHints );
623 }
624
625 return $newSlots;
626 }
627
628 private function insertRevisionInternal(
629 RevisionRecord $rev,
630 IDatabase $dbw,
631 UserIdentity $user,
632 CommentStoreComment $comment,
633 PageIdentity $page,
634 $pageId,
635 $parentId
636 ) {
637 $slotRoles = $rev->getSlotRoles();
638
639 $revisionRow = $this->insertRevisionRowOn(
640 $dbw,
641 $rev,
642 $parentId
643 );
644
645 $revisionId = $revisionRow['rev_id'];
646
647 $blobHints = [
648 BlobStore::PAGE_HINT => $pageId,
649 BlobStore::REVISION_HINT => $revisionId,
650 BlobStore::PARENT_HINT => $parentId,
651 ];
652
653 $newSlots = [];
654 foreach ( $slotRoles as $role ) {
655 $slot = $rev->getSlot( $role, RevisionRecord::RAW );
656
657 // If the SlotRecord already has a revision ID set, this means it already exists
658 // in the database, and should already belong to the current revision.
659 // However, a slot may already have a revision, but no content ID, if the slot
660 // is emulated based on the archive table, because we are in SCHEMA_COMPAT_READ_OLD
661 // mode, and the respective archive row was not yet migrated to the new schema.
662 // In that case, a new slot row (and content row) must be inserted even during
663 // undeletion.
664 if ( $slot->hasRevision() && $slot->hasContentId() ) {
665 // TODO: properly abort transaction if the assertion fails!
666 Assert::parameter(
667 $slot->getRevision() === $revisionId,
668 'slot role ' . $slot->getRole(),
669 'Existing slot should belong to revision '
670 . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!'
671 );
672
673 // Slot exists, nothing to do, move along.
674 // This happens when restoring archived revisions.
675
676 $newSlots[$role] = $slot;
677 } else {
678 $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $page, $blobHints );
679 }
680 }
681
682 $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId );
683
684 $rev = new RevisionStoreRecord(
685 $page,
686 $user,
687 $comment,
688 (object)$revisionRow,
689 new RevisionSlots( $newSlots ),
690 $this->wikiId
691 );
692
693 return $rev;
694 }
695
704 private function insertSlotOn(
705 IDatabase $dbw,
706 $revisionId,
707 SlotRecord $protoSlot,
708 PageIdentity $page,
709 array $blobHints = []
710 ) {
711 if ( $protoSlot->hasAddress() ) {
712 $blobAddress = $protoSlot->getAddress();
713 } else {
714 $blobAddress = $this->storeContentBlob( $protoSlot, $page, $blobHints );
715 }
716
717 $contentId = null;
718
719 if ( $protoSlot->hasContentId() ) {
720 $contentId = $protoSlot->getContentId();
721 } else {
722 $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress );
723 }
724
725 $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId );
726
727 return SlotRecord::newSaved(
728 $revisionId,
729 $contentId,
730 $blobAddress,
731 $protoSlot
732 );
733 }
734
742 private function insertIpChangesRow(
743 IDatabase $dbw,
744 UserIdentity $user,
745 RevisionRecord $rev,
746 $revisionId
747 ) {
748 if ( $user->getId() === 0 && IPUtils::isValid( $user->getName() ) ) {
749 $ipcRow = [
750 'ipc_rev_id' => $revisionId,
751 'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
752 'ipc_hex' => IPUtils::toHex( $user->getName() ),
753 ];
754 $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ );
755 }
756 }
757
768 private function insertRevisionRowOn(
769 IDatabase $dbw,
770 RevisionRecord $rev,
771 $parentId
772 ) {
773 $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $parentId );
774
775 list( $commentFields, $commentCallback ) =
776 $this->commentStore->insertWithTempTable(
777 $dbw,
778 'rev_comment',
779 $rev->getComment( RevisionRecord::RAW )
780 );
781 $revisionRow += $commentFields;
782
783 list( $actorFields, $actorCallback ) =
784 $this->actorMigration->getInsertValuesWithTempTable(
785 $dbw,
786 'rev_user',
787 $rev->getUser( RevisionRecord::RAW )
788 );
789 $revisionRow += $actorFields;
790
791 $dbw->insert( 'revision', $revisionRow, __METHOD__ );
792
793 if ( !isset( $revisionRow['rev_id'] ) ) {
794 // only if auto-increment was used
795 $revisionRow['rev_id'] = intval( $dbw->insertId() );
796
797 if ( $dbw->getType() === 'mysql' ) {
798 // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the
799 // auto-increment value to disk, so on server restart it might reuse IDs from deleted
800 // revisions. We can fix that with an insert with an explicit rev_id value, if necessary.
801
802 $maxRevId = intval( $dbw->selectField( 'archive', 'MAX(ar_rev_id)', '', __METHOD__ ) );
803 $table = 'archive';
804 $maxRevId2 = intval( $dbw->selectField( 'slots', 'MAX(slot_revision_id)', '', __METHOD__ ) );
805 if ( $maxRevId2 >= $maxRevId ) {
806 $maxRevId = $maxRevId2;
807 $table = 'slots';
808 }
809
810 if ( $maxRevId >= $revisionRow['rev_id'] ) {
811 $this->logger->debug(
812 '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.'
813 . ' Trying to fix it.',
814 [
815 'revid' => $revisionRow['rev_id'],
816 'table' => $table,
817 'maxrevid' => $maxRevId,
818 ]
819 );
820
821 if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) {
822 throw new MWException( 'Failed to get database lock for T202032' );
823 }
824 $fname = __METHOD__;
826 static function ( $trigger, IDatabase $dbw ) use ( $fname ) {
827 $dbw->unlock( 'fix-for-T202032', $fname );
828 },
829 __METHOD__
830 );
831
832 $dbw->delete( 'revision', [ 'rev_id' => $revisionRow['rev_id'] ], __METHOD__ );
833
834 // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction
835 // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing
836 // inserts too, though, at least on MariaDB 10.1.29.
837 //
838 // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent
839 // transactions in this code path thanks to the row lock from the original ->insert() above.
840 //
841 // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning
842 // that's for non-MySQL DBs.
843 $row1 = $dbw->query(
844 $dbw->selectSQLText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE',
845 __METHOD__
846 )->fetchObject();
847
848 $row2 = $dbw->query(
849 $dbw->selectSQLText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ )
850 . ' FOR UPDATE',
851 __METHOD__
852 )->fetchObject();
853
854 $maxRevId = max(
855 $maxRevId,
856 $row1 ? intval( $row1->v ) : 0,
857 $row2 ? intval( $row2->v ) : 0
858 );
859
860 // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent
861 // transactions will throw a duplicate key error here. It doesn't seem worth trying
862 // to avoid that.
863 $revisionRow['rev_id'] = $maxRevId + 1;
864 $dbw->insert( 'revision', $revisionRow, __METHOD__ );
865 }
866 }
867 }
868
869 $commentCallback( $revisionRow['rev_id'] );
870 $actorCallback( $revisionRow['rev_id'], $revisionRow );
871
872 return $revisionRow;
873 }
874
882 private function getBaseRevisionRow(
883 IDatabase $dbw,
884 RevisionRecord $rev,
885 $parentId
886 ) {
887 // Record the edit in revisions
888 $revisionRow = [
889 'rev_page' => $rev->getPageId( $this->wikiId ),
890 'rev_parent_id' => $parentId,
891 'rev_minor_edit' => $rev->isMinor() ? 1 : 0,
892 'rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
893 'rev_deleted' => $rev->getVisibility(),
894 'rev_len' => $rev->getSize(),
895 'rev_sha1' => $rev->getSha1(),
896 ];
897
898 if ( $rev->getId( $this->wikiId ) !== null ) {
899 // Needed to restore revisions with their original ID
900 $revisionRow['rev_id'] = $rev->getId( $this->wikiId );
901 }
902
903 return $revisionRow;
904 }
905
914 private function storeContentBlob(
915 SlotRecord $slot,
916 PageIdentity $page,
917 array $blobHints = []
918 ) {
919 $content = $slot->getContent();
920 $format = $content->getDefaultFormat();
921 $model = $content->getModel();
922
923 $this->checkContent( $content, $page, $slot->getRole() );
924
925 return $this->blobStore->storeBlob(
926 $content->serialize( $format ),
927 // These hints "leak" some information from the higher abstraction layer to
928 // low level storage to allow for optimization.
929 array_merge(
930 $blobHints,
931 [
932 BlobStore::DESIGNATION_HINT => 'page-content',
933 BlobStore::ROLE_HINT => $slot->getRole(),
934 BlobStore::SHA1_HINT => $slot->getSha1(),
935 BlobStore::MODEL_HINT => $model,
936 BlobStore::FORMAT_HINT => $format,
937 ]
938 )
939 );
940 }
941
948 private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) {
949 $slotRow = [
950 'slot_revision_id' => $revisionId,
951 'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ),
952 'slot_content_id' => $contentId,
953 // If the slot has a specific origin use that ID, otherwise use the ID of the revision
954 // that we just inserted.
955 'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId,
956 ];
957 $dbw->insert( 'slots', $slotRow, __METHOD__ );
958 }
959
966 private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) {
967 $contentRow = [
968 'content_size' => $slot->getSize(),
969 'content_sha1' => $slot->getSha1(),
970 'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ),
971 'content_address' => $blobAddress,
972 ];
973 $dbw->insert( 'content', $contentRow, __METHOD__ );
974 return intval( $dbw->insertId() );
975 }
976
987 private function checkContent( Content $content, PageIdentity $page, string $role ) {
988 // Note: may return null for revisions that have not yet been inserted
989
990 $model = $content->getModel();
991 $format = $content->getDefaultFormat();
992 $handler = $content->getContentHandler();
993
994 if ( !$handler->isSupportedFormat( $format ) ) {
995 throw new MWException(
996 "Can't use format $format with content model $model on $page role $role"
997 );
998 }
999
1000 if ( !$content->isValid() ) {
1001 throw new MWException(
1002 "New content for $page role $role is not valid! Content model is $model"
1003 );
1004 }
1005 }
1006
1032 public function newNullRevision(
1033 IDatabase $dbw,
1034 PageIdentity $page,
1035 CommentStoreComment $comment,
1036 $minor,
1037 UserIdentity $user
1038 ) {
1039 $this->checkDatabaseDomain( $dbw );
1040
1041 $pageId = $this->getArticleId( $page );
1042
1043 // T51581: Lock the page table row to ensure no other process
1044 // is adding a revision to the page at the same time.
1045 // Avoid locking extra tables, compare T191892.
1046 $pageLatest = $dbw->selectField(
1047 'page',
1048 'page_latest',
1049 [ 'page_id' => $pageId ],
1050 __METHOD__,
1051 [ 'FOR UPDATE' ]
1052 );
1053
1054 if ( !$pageLatest ) {
1055 $msg = 'T235589: Failed to select table row during null revision creation' .
1056 " Page id '$pageId' does not exist.";
1057 $this->logger->error(
1058 $msg,
1059 [ 'exception' => new RuntimeException( $msg ) ]
1060 );
1061
1062 return null;
1063 }
1064
1065 // Fetch the actual revision row from primary DB, without locking all extra tables.
1066 $oldRevision = $this->loadRevisionFromConds(
1067 $dbw,
1068 [ 'rev_id' => intval( $pageLatest ) ],
1069 self::READ_LATEST,
1070 $page
1071 );
1072
1073 if ( !$oldRevision ) {
1074 $msg = "Failed to load latest revision ID $pageLatest of page ID $pageId.";
1075 $this->logger->error(
1076 $msg,
1077 [ 'exception' => new RuntimeException( $msg ) ]
1078 );
1079 return null;
1080 }
1081
1082 // Construct the new revision
1083 $timestamp = MWTimestamp::now( TS_MW );
1084 $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision );
1085
1086 $newRevision->setComment( $comment );
1087 $newRevision->setUser( $user );
1088 $newRevision->setTimestamp( $timestamp );
1089 $newRevision->setMinorEdit( $minor );
1090
1091 return $newRevision;
1092 }
1093
1103 public function getRcIdIfUnpatrolled( RevisionRecord $rev ) {
1104 $rc = $this->getRecentChange( $rev );
1105 if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) {
1106 return $rc->getAttribute( 'rc_id' );
1107 } else {
1108 return 0;
1109 }
1110 }
1111
1125 public function getRecentChange( RevisionRecord $rev, $flags = 0 ) {
1126 list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags );
1127
1128 $rc = RecentChange::newFromConds(
1129 [ 'rc_this_oldid' => $rev->getId( $this->wikiId ) ],
1130 __METHOD__,
1131 $dbType
1132 );
1133
1134 // XXX: cache this locally? Glue it to the RevisionRecord?
1135 return $rc;
1136 }
1137
1157 private function loadSlotContent(
1158 SlotRecord $slot,
1159 ?string $blobData = null,
1160 ?string $blobFlags = null,
1161 ?string $blobFormat = null,
1162 int $queryFlags = 0
1163 ) {
1164 if ( $blobData !== null ) {
1165 $cacheKey = $slot->hasAddress() ? $slot->getAddress() : null;
1166
1167 if ( $blobFlags === null ) {
1168 // No blob flags, so use the blob verbatim.
1169 $data = $blobData;
1170 } else {
1171 $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $cacheKey );
1172 if ( $data === false ) {
1173 throw new RevisionAccessException(
1174 'Failed to expand blob data using flags {flags} (key: {cache_key})',
1175 [
1176 'flags' => $blobFlags,
1177 'cache_key' => $cacheKey,
1178 ]
1179 );
1180 }
1181 }
1182
1183 } else {
1184 $address = $slot->getAddress();
1185 try {
1186 $data = $this->blobStore->getBlob( $address, $queryFlags );
1187 } catch ( BlobAccessException $e ) {
1188 throw new RevisionAccessException(
1189 'Failed to load data blob from {address}'
1190 . 'If this problem persist, use the findBadBlobs maintenance script '
1191 . 'to investigate the issue and mark bad blobs.',
1192 [ 'address' => $e->getMessage() ],
1193 0,
1194 $e
1195 );
1196 }
1197 }
1198
1199 $model = $slot->getModel();
1200
1201 // If the content model is not known, don't fail here (T220594, T220793, T228921)
1202 if ( !$this->contentHandlerFactory->isDefinedModel( $model ) ) {
1203 $this->logger->warning(
1204 "Undefined content model '$model', falling back to UnknownContent",
1205 [
1206 'content_address' => $slot->getAddress(),
1207 'rev_id' => $slot->getRevision(),
1208 'role_name' => $slot->getRole(),
1209 'model_name' => $model,
1210 'trace' => wfBacktrace()
1211 ]
1212 );
1213
1214 return new FallbackContent( $data, $model );
1215 }
1216
1217 return $this->contentHandlerFactory
1218 ->getContentHandler( $model )
1219 ->unserializeContent( $data, $blobFormat );
1220 }
1221
1239 public function getRevisionById( $id, $flags = 0, PageIdentity $page = null ) {
1240 return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags, $page );
1241 }
1242
1259 public function getRevisionByTitle( $page, $revId = 0, $flags = 0 ) {
1260 $conds = [
1261 'page_namespace' => $page->getNamespace(),
1262 'page_title' => $page->getDBkey()
1263 ];
1264
1265 if ( $page instanceof LinkTarget ) {
1266 // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756)
1267 $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null;
1268 }
1269
1270 if ( $revId ) {
1271 // Use the specified revision ID.
1272 // Note that we use newRevisionFromConds here because we want to retry
1273 // and fall back to primary DB if the page is not found on a replica.
1274 // Since the caller supplied a revision ID, we are pretty sure the revision is
1275 // supposed to exist, so we should try hard to find it.
1276 $conds['rev_id'] = $revId;
1277 return $this->newRevisionFromConds( $conds, $flags, $page );
1278 } else {
1279 // Use a join to get the latest revision.
1280 // Note that we don't use newRevisionFromConds here because we don't want to retry
1281 // and fall back to primary DB. The assumption is that we only want to force the fallback
1282 // if we are quite sure the revision exists because the caller supplied a revision ID.
1283 // If the page isn't found at all on a replica, it probably simply does not exist.
1284 $db = $this->getDBConnectionRefForQueryFlags( $flags );
1285 $conds[] = 'rev_id=page_latest';
1286 return $this->loadRevisionFromConds( $db, $conds, $flags, $page );
1287 }
1288 }
1289
1306 public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) {
1307 $conds = [ 'page_id' => $pageId ];
1308 if ( $revId ) {
1309 // Use the specified revision ID.
1310 // Note that we use newRevisionFromConds here because we want to retry
1311 // and fall back to primary DB if the page is not found on a replica.
1312 // Since the caller supplied a revision ID, we are pretty sure the revision is
1313 // supposed to exist, so we should try hard to find it.
1314 $conds['rev_id'] = $revId;
1315 return $this->newRevisionFromConds( $conds, $flags );
1316 } else {
1317 // Use a join to get the latest revision.
1318 // Note that we don't use newRevisionFromConds here because we don't want to retry
1319 // and fall back to primary DB. The assumption is that we only want to force the fallback
1320 // if we are quite sure the revision exists because the caller supplied a revision ID.
1321 // If the page isn't found at all on a replica, it probably simply does not exist.
1322 $db = $this->getDBConnectionRefForQueryFlags( $flags );
1323
1324 $conds[] = 'rev_id=page_latest';
1325
1326 return $this->loadRevisionFromConds( $db, $conds, $flags );
1327 }
1328 }
1329
1345 public function getRevisionByTimestamp(
1346 $page,
1347 string $timestamp,
1348 int $flags = IDBAccessObject::READ_NORMAL
1349 ): ?RevisionRecord {
1350 if ( $page instanceof LinkTarget ) {
1351 // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756)
1352 $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null;
1353 }
1354 $db = $this->getDBConnectionRefForQueryFlags( $flags );
1355 return $this->newRevisionFromConds(
1356 [
1357 'rev_timestamp' => $db->timestamp( $timestamp ),
1358 'page_namespace' => $page->getNamespace(),
1359 'page_title' => $page->getDBkey()
1360 ],
1361 $flags,
1362 $page
1363 );
1364 }
1365
1373 private function loadSlotRecords( $revId, $queryFlags, PageIdentity $page ) {
1374 // TODO: Find a way to add NS_MODULE from Scribunto here
1375 if ( $page->getNamespace() !== NS_TEMPLATE ) {
1376 $res = $this->loadSlotRecordsFromDb( $revId, $queryFlags, $page );
1377 return $this->constructSlotRecords( $revId, $res, $queryFlags, $page );
1378 }
1379
1380 // TODO: These caches should not be needed. See T297147#7563670
1381 $res = $this->localCache->getWithSetCallback(
1382 $this->localCache->makeKey(
1383 'revision-slots',
1384 $page->getWikiId(),
1385 $page->getId( $page->getWikiId() ),
1386 $revId
1387 ),
1388 $this->localCache::TTL_HOUR,
1389 function () use ( $revId, $queryFlags, $page ) {
1390 return $this->cache->getWithSetCallback(
1391 $this->cache->makeKey(
1392 'revision-slots',
1393 $page->getWikiId(),
1394 $page->getId( $page->getWikiId() ),
1395 $revId
1396 ),
1397 WANObjectCache::TTL_DAY,
1398 function () use ( $revId, $queryFlags, $page ) {
1399 $res = $this->loadSlotRecordsFromDb( $revId, $queryFlags, $page );
1400 if ( !$res ) {
1401 // Avoid caching
1402 return false;
1403 }
1404 return $res;
1405 }
1406 );
1407 }
1408 );
1409 if ( !$res ) {
1410 $res = [];
1411 }
1412
1413 return $this->constructSlotRecords( $revId, $res, $queryFlags, $page );
1414 }
1415
1416 private function loadSlotRecordsFromDb( $revId, $queryFlags, PageIdentity $page ): array {
1417 $revQuery = $this->getSlotsQueryInfo( [ 'content' ] );
1418
1419 list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
1420 $db = $this->getDBConnectionRef( $dbMode );
1421
1422 $res = $db->select(
1423 $revQuery['tables'],
1424 $revQuery['fields'],
1425 [
1426 'slot_revision_id' => $revId,
1427 ],
1428 __METHOD__,
1429 $dbOptions,
1430 $revQuery['joins']
1431 );
1432
1433 if ( !$res->numRows() && !( $queryFlags & self::READ_LATEST ) ) {
1434 // If we found no slots, try looking on the primary database (T212428, T252156)
1435 $this->logger->info(
1436 __METHOD__ . ' falling back to READ_LATEST.',
1437 [
1438 'revid' => $revId,
1439 'trace' => wfBacktrace( true )
1440 ]
1441 );
1442 return $this->loadSlotRecordsFromDb(
1443 $revId,
1444 $queryFlags | self::READ_LATEST,
1445 $page
1446 );
1447 }
1448 return iterator_to_array( $res );
1449 }
1450
1463 private function constructSlotRecords(
1464 $revId,
1465 $slotRows,
1466 $queryFlags,
1467 PageIdentity $page,
1468 $slotContents = null
1469 ) {
1470 $slots = [];
1471
1472 foreach ( $slotRows as $row ) {
1473 // Resolve role names and model names from in-memory cache, if they were not joined in.
1474 if ( !isset( $row->role_name ) ) {
1475 $row->role_name = $this->slotRoleStore->getName( (int)$row->slot_role_id );
1476 }
1477
1478 if ( !isset( $row->model_name ) ) {
1479 if ( isset( $row->content_model ) ) {
1480 $row->model_name = $this->contentModelStore->getName( (int)$row->content_model );
1481 } else {
1482 // We may get here if $row->model_name is set but null, perhaps because it
1483 // came from rev_content_model, which is NULL for the default model.
1484 $slotRoleHandler = $this->slotRoleRegistry->getRoleHandler( $row->role_name );
1485 $row->model_name = $slotRoleHandler->getDefaultModel( $page );
1486 }
1487 }
1488
1489 // We may have a fake blob_data field from getSlotRowsForBatch(), use it!
1490 if ( isset( $row->blob_data ) ) {
1491 $slotContents[$row->content_address] = $row->blob_data;
1492 }
1493
1494 $contentCallback = function ( SlotRecord $slot ) use ( $slotContents, $queryFlags ) {
1495 $blob = null;
1496 if ( isset( $slotContents[$slot->getAddress()] ) ) {
1497 $blob = $slotContents[$slot->getAddress()];
1498 if ( $blob instanceof Content ) {
1499 return $blob;
1500 }
1501 }
1502 return $this->loadSlotContent( $slot, $blob, null, null, $queryFlags );
1503 };
1504
1505 $slots[$row->role_name] = new SlotRecord( $row, $contentCallback );
1506 }
1507
1508 if ( !isset( $slots[SlotRecord::MAIN] ) ) {
1509 $this->logger->error(
1510 __METHOD__ . ': Main slot of revision not found in database. See T212428.',
1511 [
1512 'revid' => $revId,
1513 'queryFlags' => $queryFlags,
1514 'trace' => wfBacktrace( true )
1515 ]
1516 );
1517
1518 throw new RevisionAccessException(
1519 'Main slot of revision not found in database. See T212428.'
1520 );
1521 }
1522
1523 return $slots;
1524 }
1525
1541 private function newRevisionSlots(
1542 $revId,
1543 $revisionRow,
1544 $slotRows,
1545 $queryFlags,
1546 PageIdentity $page
1547 ) {
1548 if ( $slotRows ) {
1549 $slots = new RevisionSlots(
1550 $this->constructSlotRecords( $revId, $slotRows, $queryFlags, $page )
1551 );
1552 } else {
1553 $slots = new RevisionSlots( function () use( $revId, $queryFlags, $page ) {
1554 return $this->loadSlotRecords( $revId, $queryFlags, $page );
1555 } );
1556 }
1557
1558 return $slots;
1559 }
1560
1583 $row,
1584 $queryFlags = 0,
1585 PageIdentity $page = null,
1586 array $overrides = []
1587 ) {
1588 return $this->newRevisionFromArchiveRowAndSlots( $row, null, $queryFlags, $page, $overrides );
1589 }
1590
1603 public function newRevisionFromRow(
1604 $row,
1605 $queryFlags = 0,
1606 PageIdentity $page = null,
1607 $fromCache = false
1608 ) {
1609 return $this->newRevisionFromRowAndSlots( $row, null, $queryFlags, $page, $fromCache );
1610 }
1611
1632 stdClass $row,
1633 $slots,
1634 int $queryFlags = 0,
1635 ?PageIdentity $page = null,
1636 array $overrides = []
1637 ) {
1638 if ( !$page && isset( $overrides['title'] ) ) {
1639 if ( !( $overrides['title'] instanceof PageIdentity ) ) {
1640 throw new MWException( 'title field override must contain a PageIdentity object.' );
1641 }
1642
1643 $page = $overrides['title'];
1644 }
1645
1646 if ( !isset( $page ) ) {
1647 if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) {
1648 $page = Title::makeTitle( $row->ar_namespace, $row->ar_title );
1649 } else {
1650 throw new InvalidArgumentException(
1651 'A Title or ar_namespace and ar_title must be given'
1652 );
1653 }
1654 }
1655
1656 foreach ( $overrides as $key => $value ) {
1657 $field = "ar_$key";
1658 $row->$field = $value;
1659 }
1660
1661 try {
1662 $user = $this->actorStore->newActorFromRowFields(
1663 $row->ar_user ?? null,
1664 $row->ar_user_text ?? null,
1665 $row->ar_actor ?? null
1666 );
1667 } catch ( InvalidArgumentException $ex ) {
1668 $this->logger->warning( 'Could not load user for archive revision {rev_id}', [
1669 'ar_rev_id' => $row->ar_rev_id,
1670 'ar_actor' => $row->ar_actor ?? 'null',
1671 'ar_user_text' => $row->ar_user_text ?? 'null',
1672 'ar_user' => $row->ar_user ?? 'null',
1673 'exception' => $ex
1674 ] );
1675 $user = $this->actorStore->getUnknownActor();
1676 }
1677
1678 $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1679 // Legacy because $row may have come from self::selectFields()
1680 $comment = $this->commentStore->getCommentLegacy( $db, 'ar_comment', $row, true );
1681
1682 if ( !( $slots instanceof RevisionSlots ) ) {
1683 $slots = $this->newRevisionSlots( $row->ar_rev_id, $row, $slots, $queryFlags, $page );
1684 }
1685 return new RevisionArchiveRecord( $page, $user, $comment, $row, $slots, $this->wikiId );
1686 }
1687
1707 stdClass $row,
1708 $slots,
1709 int $queryFlags = 0,
1710 ?PageIdentity $page = null,
1711 bool $fromCache = false
1712 ) {
1713 if ( !$page ) {
1714 if ( isset( $row->page_id )
1715 && isset( $row->page_namespace )
1716 && isset( $row->page_title )
1717 ) {
1718 $page = new PageIdentityValue(
1719 (int)$row->page_id,
1720 (int)$row->page_namespace,
1721 $row->page_title,
1722 $this->wikiId
1723 );
1724
1725 $page = $this->wrapPage( $page );
1726 } else {
1727 $pageId = (int)( $row->rev_page ?? 0 );
1728 $revId = (int)( $row->rev_id ?? 0 );
1729
1730 $page = $this->getPage( $pageId, $revId, $queryFlags );
1731 }
1732 } else {
1733 $page = $this->ensureRevisionRowMatchesPage( $row, $page );
1734 }
1735
1736 if ( !$page ) {
1737 // This should already have been caught about, but apparently
1738 // it not always is, see T286877.
1739 throw new RevisionAccessException(
1740 "Failed to determine page associated with revision {$row->rev_id}"
1741 );
1742 }
1743
1744 try {
1745 $user = $this->actorStore->newActorFromRowFields(
1746 $row->rev_user ?? null,
1747 $row->rev_user_text ?? null,
1748 $row->rev_actor ?? null
1749 );
1750 } catch ( InvalidArgumentException $ex ) {
1751 $this->logger->warning( 'Could not load user for revision {rev_id}', [
1752 'rev_id' => $row->rev_id,
1753 'rev_actor' => $row->rev_actor ?? 'null',
1754 'rev_user_text' => $row->rev_user_text ?? 'null',
1755 'rev_user' => $row->rev_user ?? 'null',
1756 'exception' => $ex
1757 ] );
1758 $user = $this->actorStore->getUnknownActor();
1759 }
1760
1761 $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1762 // Legacy because $row may have come from self::selectFields()
1763 $comment = $this->commentStore->getCommentLegacy( $db, 'rev_comment', $row, true );
1764
1765 if ( !( $slots instanceof RevisionSlots ) ) {
1766 $slots = $this->newRevisionSlots( $row->rev_id, $row, $slots, $queryFlags, $page );
1767 }
1768
1769 // If this is a cached row, instantiate a cache-aware RevisionRecord to avoid stale data.
1770 if ( $fromCache ) {
1771 $rev = new RevisionStoreCacheRecord(
1772 function ( $revId ) use ( $queryFlags ) {
1773 $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1774 $row = $this->fetchRevisionRowFromConds(
1775 $db,
1776 [ 'rev_id' => intval( $revId ) ]
1777 );
1778 if ( !$row && !( $queryFlags & self::READ_LATEST ) ) {
1779 // If we found no slots, try looking on the primary database (T259738)
1780 $this->logger->info(
1781 'RevisionStoreCacheRecord refresh callback falling back to READ_LATEST.',
1782 [
1783 'revid' => $revId,
1784 'trace' => wfBacktrace( true )
1785 ]
1786 );
1787 $dbw = $this->getDBConnectionRefForQueryFlags( self::READ_LATEST );
1788 $row = $this->fetchRevisionRowFromConds(
1789 $dbw,
1790 [ 'rev_id' => intval( $revId ) ]
1791 );
1792 }
1793 if ( !$row ) {
1794 return [ null, null ];
1795 }
1796 return [
1797 $row->rev_deleted,
1798 $this->actorStore->newActorFromRowFields(
1799 $row->rev_user ?? null,
1800 $row->rev_user_text ?? null,
1801 $row->rev_actor ?? null
1802 )
1803 ];
1804 },
1805 $page, $user, $comment, $row, $slots, $this->wikiId
1806 );
1807 } else {
1808 $rev = new RevisionStoreRecord(
1809 $page, $user, $comment, $row, $slots, $this->wikiId );
1810 }
1811 return $rev;
1812 }
1813
1825 private function ensureRevisionRowMatchesPage( $row, PageIdentity $page, $context = [] ) {
1826 $revId = (int)( $row->rev_id ?? 0 );
1827 $revPageId = (int)( $row->rev_page ?? 0 ); // XXX: also check $row->page_id?
1828 $expectedPageId = $page->getId( $this->wikiId );
1829 // Avoid fatal error when the Title's ID changed, T246720
1830 if ( $revPageId && $expectedPageId && $revPageId !== $expectedPageId ) {
1831 // NOTE: PageStore::getPageByReference may use the page ID, which we don't want here.
1832 $pageRec = $this->pageStore->getPageByName(
1833 $page->getNamespace(),
1834 $page->getDBkey(),
1835 PageStore::READ_LATEST
1836 );
1837 $masterPageId = $pageRec->getId( $this->wikiId );
1838 $masterLatest = $pageRec->getLatest( $this->wikiId );
1839 if ( $revPageId === $masterPageId ) {
1840 if ( $page instanceof Title ) {
1841 // If we were using a Title object, keep using it, but update the page ID.
1842 // This way, we don't unexpectedly mix Titles with immutable value objects.
1843 $page->resetArticleID( $masterPageId );
1844
1845 } else {
1846 $page = $pageRec;
1847 }
1848
1849 $this->logger->info(
1850 "Encountered stale Title object",
1851 [
1852 'page_id_stale' => $expectedPageId,
1853 'page_id_reloaded' => $masterPageId,
1854 'page_latest' => $masterLatest,
1855 'rev_id' => $revId,
1856 'trace' => wfBacktrace()
1857 ] + $context
1858 );
1859 } else {
1860 $expectedTitle = (string)$page;
1861 if ( $page instanceof Title ) {
1862 // If we started with a Title, keep using a Title.
1863 $page = $this->titleFactory->newFromID( $revPageId );
1864 } else {
1865 $page = $pageRec;
1866 }
1867
1868 // This could happen if a caller to e.g. getRevisionById supplied a Title that is
1869 // plain wrong. In this case, we should ideally throw an IllegalArgumentException.
1870 // However, it is more likely that we encountered a race condition during a page
1871 // move (T268910, T279832) or database corruption (T263340). That situation
1872 // should not be ignored, but we can allow the request to continue in a reasonable
1873 // manner without breaking things for the user.
1874 $this->logger->error(
1875 "Encountered mismatching Title object (see T259022, T268910, T279832, T263340)",
1876 [
1877 'expected_page_id' => $masterPageId,
1878 'expected_page_title' => $expectedTitle,
1879 'rev_page' => $revPageId,
1880 'rev_page_title' => (string)$page,
1881 'page_latest' => $masterLatest,
1882 'rev_id' => $revId,
1883 'trace' => wfBacktrace()
1884 ] + $context
1885 );
1886 }
1887 }
1888
1889 return $page;
1890 }
1891
1917 public function newRevisionsFromBatch(
1918 $rows,
1919 array $options = [],
1920 $queryFlags = 0,
1921 PageIdentity $page = null
1922 ) {
1923 $result = new StatusValue();
1924 $archiveMode = $options['archive'] ?? false;
1925
1926 if ( $archiveMode ) {
1927 $revIdField = 'ar_rev_id';
1928 } else {
1929 $revIdField = 'rev_id';
1930 }
1931
1932 $rowsByRevId = [];
1933 $pageIdsToFetchTitles = [];
1934 $titlesByPageKey = [];
1935 foreach ( $rows as $row ) {
1936 if ( isset( $rowsByRevId[$row->$revIdField] ) ) {
1937 $result->warning(
1938 'internalerror_info',
1939 "Duplicate rows in newRevisionsFromBatch, $revIdField {$row->$revIdField}"
1940 );
1941 }
1942
1943 // Attach a page key to the row, so we can find and reuse Title objects easily.
1944 $row->_page_key =
1945 $archiveMode ? $row->ar_namespace . ':' . $row->ar_title : $row->rev_page;
1946
1947 if ( $page ) {
1948 if ( !$archiveMode && $row->rev_page != $this->getArticleId( $page ) ) {
1949 throw new InvalidArgumentException(
1950 "Revision {$row->$revIdField} doesn't belong to page "
1951 . $this->getArticleId( $page )
1952 );
1953 }
1954
1955 if ( $archiveMode
1956 && ( $row->ar_namespace != $page->getNamespace()
1957 || $row->ar_title !== $page->getDBkey() )
1958 ) {
1959 throw new InvalidArgumentException(
1960 "Revision {$row->$revIdField} doesn't belong to page "
1961 . $page
1962 );
1963 }
1964 } elseif ( !isset( $titlesByPageKey[ $row->_page_key ] ) ) {
1965 if ( isset( $row->page_namespace ) && isset( $row->page_title )
1966 // This should always be true, but just in case we don't have a page_id
1967 // set or it doesn't match rev_page, let's fetch the title again.
1968 && isset( $row->page_id ) && isset( $row->rev_page )
1969 && $row->rev_page === $row->page_id
1970 ) {
1971 $titlesByPageKey[ $row->_page_key ] = Title::newFromRow( $row );
1972 } elseif ( $archiveMode ) {
1973 // Can't look up deleted pages by ID, but we have namespace and title
1974 $titlesByPageKey[ $row->_page_key ] =
1975 Title::makeTitle( $row->ar_namespace, $row->ar_title );
1976 } else {
1977 $pageIdsToFetchTitles[] = $row->rev_page;
1978 }
1979 }
1980 $rowsByRevId[$row->$revIdField] = $row;
1981 }
1982
1983 if ( empty( $rowsByRevId ) ) {
1984 $result->setResult( true, [] );
1985 return $result;
1986 }
1987
1988 // If the page is not supplied, batch-fetch Title objects.
1989 if ( $page ) {
1990 // same logic as for $row->_page_key above
1991 $pageKey = $archiveMode
1992 ? $page->getNamespace() . ':' . $page->getDBkey()
1993 : $this->getArticleId( $page );
1994
1995 $titlesByPageKey[$pageKey] = $page;
1996 } elseif ( !empty( $pageIdsToFetchTitles ) ) {
1997 // Note: when we fetch titles by ID, the page key is also the ID.
1998 // We should never get here if $archiveMode is true.
1999 Assert::invariant( !$archiveMode, 'Titles are not loaded by ID in archive mode.' );
2000
2001 $pageIdsToFetchTitles = array_unique( $pageIdsToFetchTitles );
2002 $pageRecords = $this->pageStore
2003 ->newSelectQueryBuilder()
2004 ->wherePageIds( $pageIdsToFetchTitles )
2005 ->caller( __METHOD__ )
2006 ->fetchPageRecordArray();
2007 // Cannot array_merge because it re-indexes entries
2008 $titlesByPageKey = $pageRecords + $titlesByPageKey;
2009 }
2010
2011 // which method to use for creating RevisionRecords
2012 $newRevisionRecord = [
2013 $this,
2014 $archiveMode ? 'newRevisionFromArchiveRowAndSlots' : 'newRevisionFromRowAndSlots'
2015 ];
2016
2017 if ( !isset( $options['slots'] ) ) {
2018 $result->setResult(
2019 true,
2020 array_map(
2021 static function ( $row )
2022 use ( $queryFlags, $titlesByPageKey, $result, $newRevisionRecord, $revIdField ) {
2023 try {
2024 if ( !isset( $titlesByPageKey[$row->_page_key] ) ) {
2025 $result->warning(
2026 'internalerror_info',
2027 "Couldn't find title for rev {$row->$revIdField} "
2028 . "(page key {$row->_page_key})"
2029 );
2030 return null;
2031 }
2032 return $newRevisionRecord( $row, null, $queryFlags,
2033 $titlesByPageKey[ $row->_page_key ] );
2034 } catch ( MWException $e ) {
2035 $result->warning( 'internalerror_info', $e->getMessage() );
2036 return null;
2037 }
2038 },
2039 $rowsByRevId
2040 )
2041 );
2042 return $result;
2043 }
2044
2045 $slotRowOptions = [
2046 'slots' => $options['slots'] ?? true,
2047 'blobs' => $options['content'] ?? false,
2048 ];
2049
2050 if ( is_array( $slotRowOptions['slots'] )
2051 && !in_array( SlotRecord::MAIN, $slotRowOptions['slots'] )
2052 ) {
2053 // Make sure the main slot is always loaded, RevisionRecord requires this.
2054 $slotRowOptions['slots'][] = SlotRecord::MAIN;
2055 }
2056
2057 $slotRowsStatus = $this->getSlotRowsForBatch( $rowsByRevId, $slotRowOptions, $queryFlags );
2058
2059 $result->merge( $slotRowsStatus );
2060 $slotRowsByRevId = $slotRowsStatus->getValue();
2061
2062 $result->setResult(
2063 true,
2064 array_map(
2065 function ( $row )
2066 use ( $slotRowsByRevId, $queryFlags, $titlesByPageKey, $result,
2067 $revIdField, $newRevisionRecord
2068 ) {
2069 if ( !isset( $slotRowsByRevId[$row->$revIdField] ) ) {
2070 $result->warning(
2071 'internalerror_info',
2072 "Couldn't find slots for rev {$row->$revIdField}"
2073 );
2074 return null;
2075 }
2076 if ( !isset( $titlesByPageKey[$row->_page_key] ) ) {
2077 $result->warning(
2078 'internalerror_info',
2079 "Couldn't find title for rev {$row->$revIdField} "
2080 . "(page key {$row->_page_key})"
2081 );
2082 return null;
2083 }
2084 try {
2085 return $newRevisionRecord(
2086 $row,
2087 new RevisionSlots(
2088 $this->constructSlotRecords(
2089 $row->$revIdField,
2090 $slotRowsByRevId[$row->$revIdField],
2091 $queryFlags,
2092 $titlesByPageKey[$row->_page_key]
2093 )
2094 ),
2095 $queryFlags,
2096 $titlesByPageKey[$row->_page_key]
2097 );
2098 } catch ( MWException $e ) {
2099 $result->warning( 'internalerror_info', $e->getMessage() );
2100 return null;
2101 }
2102 },
2103 $rowsByRevId
2104 )
2105 );
2106 return $result;
2107 }
2108
2132 private function getSlotRowsForBatch(
2133 $rowsOrIds,
2134 array $options = [],
2135 $queryFlags = 0
2136 ) {
2137 $result = new StatusValue();
2138
2139 $revIds = [];
2140 foreach ( $rowsOrIds as $row ) {
2141 if ( is_object( $row ) ) {
2142 $revIds[] = isset( $row->ar_rev_id ) ? (int)$row->ar_rev_id : (int)$row->rev_id;
2143 } else {
2144 $revIds[] = (int)$row;
2145 }
2146 }
2147
2148 // Nothing to do.
2149 // Note that $rowsOrIds may not be "empty" even if $revIds is, e.g. if it's a ResultWrapper.
2150 if ( empty( $revIds ) ) {
2151 $result->setResult( true, [] );
2152 return $result;
2153 }
2154
2155 // We need to set the `content` flag to join in content meta-data
2156 $slotQueryInfo = $this->getSlotsQueryInfo( [ 'content' ] );
2157 $revIdField = $slotQueryInfo['keys']['rev_id'];
2158 $slotQueryConds = [ $revIdField => $revIds ];
2159
2160 if ( isset( $options['slots'] ) && is_array( $options['slots'] ) ) {
2161 if ( empty( $options['slots'] ) ) {
2162 // Degenerate case: return no slots for each revision.
2163 $result->setResult( true, array_fill_keys( $revIds, [] ) );
2164 return $result;
2165 }
2166
2167 $roleIdField = $slotQueryInfo['keys']['role_id'];
2168 $slotQueryConds[$roleIdField] = array_map(
2169 [ $this->slotRoleStore, 'getId' ],
2170 $options['slots']
2171 );
2172 }
2173
2174 $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
2175 $slotRows = $db->select(
2176 $slotQueryInfo['tables'],
2177 $slotQueryInfo['fields'],
2178 $slotQueryConds,
2179 __METHOD__,
2180 [],
2181 $slotQueryInfo['joins']
2182 );
2183
2184 $slotContents = null;
2185 if ( $options['blobs'] ?? false ) {
2186 $blobAddresses = [];
2187 foreach ( $slotRows as $slotRow ) {
2188 $blobAddresses[] = $slotRow->content_address;
2189 }
2190 $slotContentFetchStatus = $this->blobStore
2191 ->getBlobBatch( $blobAddresses, $queryFlags );
2192 foreach ( $slotContentFetchStatus->getErrors() as $error ) {
2193 $result->warning( $error['message'], ...$error['params'] );
2194 }
2195 $slotContents = $slotContentFetchStatus->getValue();
2196 }
2197
2198 $slotRowsByRevId = [];
2199 foreach ( $slotRows as $slotRow ) {
2200 if ( $slotContents === null ) {
2201 // nothing to do
2202 } elseif ( isset( $slotContents[$slotRow->content_address] ) ) {
2203 $slotRow->blob_data = $slotContents[$slotRow->content_address];
2204 } else {
2205 $result->warning(
2206 'internalerror_info',
2207 "Couldn't find blob data for rev {$slotRow->slot_revision_id}"
2208 );
2209 $slotRow->blob_data = null;
2210 }
2211
2212 // conditional needed for SCHEMA_COMPAT_READ_OLD
2213 if ( !isset( $slotRow->role_name ) && isset( $slotRow->slot_role_id ) ) {
2214 $slotRow->role_name = $this->slotRoleStore->getName( (int)$slotRow->slot_role_id );
2215 }
2216
2217 // conditional needed for SCHEMA_COMPAT_READ_OLD
2218 if ( !isset( $slotRow->model_name ) && isset( $slotRow->content_model ) ) {
2219 $slotRow->model_name = $this->contentModelStore->getName( (int)$slotRow->content_model );
2220 }
2221
2222 $slotRowsByRevId[$slotRow->slot_revision_id][$slotRow->role_name] = $slotRow;
2223 }
2224
2225 $result->setResult( true, $slotRowsByRevId );
2226 return $result;
2227 }
2228
2250 $rowsOrIds,
2251 $slots = null,
2252 $queryFlags = 0
2253 ) {
2254 $result = $this->getSlotRowsForBatch(
2255 $rowsOrIds,
2256 [ 'slots' => $slots, 'blobs' => true ],
2257 $queryFlags
2258 );
2259
2260 if ( $result->isOK() ) {
2261 // strip out all internal meta data that we don't want to expose
2262 foreach ( $result->value as $revId => $rowsByRole ) {
2263 foreach ( $rowsByRole as $role => $slotRow ) {
2264 if ( is_array( $slots ) && !in_array( $role, $slots ) ) {
2265 // In SCHEMA_COMPAT_READ_OLD mode we may get the main slot even
2266 // if we didn't ask for it.
2267 unset( $result->value[$revId][$role] );
2268 continue;
2269 }
2270
2271 $result->value[$revId][$role] = (object)[
2272 'blob_data' => $slotRow->blob_data,
2273 'model_name' => $slotRow->model_name,
2274 ];
2275 }
2276 }
2277 }
2278
2279 return $result;
2280 }
2281
2298 private function newRevisionFromConds(
2299 array $conditions,
2300 int $flags = IDBAccessObject::READ_NORMAL,
2301 PageIdentity $page = null,
2302 array $options = []
2303 ) {
2304 $db = $this->getDBConnectionRefForQueryFlags( $flags );
2305 $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $page, $options );
2306
2307 $lb = $this->getDBLoadBalancer();
2308
2309 // Make sure new pending/committed revision are visibile later on
2310 // within web requests to certain avoid bugs like T93866 and T94407.
2311 if ( !$rev
2312 && !( $flags & self::READ_LATEST )
2313 && $lb->hasStreamingReplicaServers()
2314 && $lb->hasOrMadeRecentPrimaryChanges()
2315 ) {
2316 $flags = self::READ_LATEST;
2317 $dbw = $this->getDBConnectionRef( DB_PRIMARY );
2318 $rev = $this->loadRevisionFromConds( $dbw, $conditions, $flags, $page, $options );
2319 }
2320
2321 return $rev;
2322 }
2323
2338 private function loadRevisionFromConds(
2339 IDatabase $db,
2340 array $conditions,
2341 int $flags = IDBAccessObject::READ_NORMAL,
2342 PageIdentity $page = null,
2343 array $options = []
2344 ) {
2345 $row = $this->fetchRevisionRowFromConds( $db, $conditions, $flags, $options );
2346 if ( $row ) {
2347 return $this->newRevisionFromRow( $row, $flags, $page );
2348 }
2349
2350 return null;
2351 }
2352
2360 private function checkDatabaseDomain( IDatabase $db ) {
2361 $dbDomain = $db->getDomainID();
2362 $storeDomain = $this->loadBalancer->resolveDomainID( $this->wikiId );
2363 if ( $dbDomain === $storeDomain ) {
2364 return;
2365 }
2366
2367 throw new MWException( "DB connection domain '$dbDomain' does not match '$storeDomain'" );
2368 }
2369
2384 IDatabase $db,
2385 array $conditions,
2386 int $flags = IDBAccessObject::READ_NORMAL,
2387 array $options = []
2388 ) {
2389 $this->checkDatabaseDomain( $db );
2390
2391 $revQuery = $this->getQueryInfo( [ 'page', 'user' ] );
2392 if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) {
2393 $options[] = 'FOR UPDATE';
2394 }
2395 return $db->selectRow(
2396 $revQuery['tables'],
2397 $revQuery['fields'],
2398 $conditions,
2399 __METHOD__,
2400 $options,
2401 $revQuery['joins']
2402 );
2403 }
2404
2426 public function getQueryInfo( $options = [] ) {
2427 $ret = [
2428 'tables' => [],
2429 'fields' => [],
2430 'joins' => [],
2431 ];
2432
2433 $ret['tables'][] = 'revision';
2434 $ret['fields'] = array_merge( $ret['fields'], [
2435 'rev_id',
2436 'rev_page',
2437 'rev_timestamp',
2438 'rev_minor_edit',
2439 'rev_deleted',
2440 'rev_len',
2441 'rev_parent_id',
2442 'rev_sha1',
2443 ] );
2444
2445 $commentQuery = $this->commentStore->getJoin( 'rev_comment' );
2446 $ret['tables'] = array_merge( $ret['tables'], $commentQuery['tables'] );
2447 $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] );
2448 $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] );
2449
2450 $actorQuery = $this->actorMigration->getJoin( 'rev_user' );
2451 $ret['tables'] = array_merge( $ret['tables'], $actorQuery['tables'] );
2452 $ret['fields'] = array_merge( $ret['fields'], $actorQuery['fields'] );
2453 $ret['joins'] = array_merge( $ret['joins'], $actorQuery['joins'] );
2454
2455 if ( in_array( 'page', $options, true ) ) {
2456 $ret['tables'][] = 'page';
2457 $ret['fields'] = array_merge( $ret['fields'], [
2458 'page_namespace',
2459 'page_title',
2460 'page_id',
2461 'page_latest',
2462 'page_is_redirect',
2463 'page_len',
2464 ] );
2465 $ret['joins']['page'] = [ 'JOIN', [ 'page_id = rev_page' ] ];
2466 }
2467
2468 if ( in_array( 'user', $options, true ) ) {
2469 $ret['tables'][] = 'user';
2470 $ret['fields'] = array_merge( $ret['fields'], [
2471 'user_name',
2472 ] );
2473 $u = $actorQuery['fields']['rev_user'];
2474 $ret['joins']['user'] = [ 'LEFT JOIN', [ "$u != 0", "user_id = $u" ] ];
2475 }
2476
2477 if ( in_array( 'text', $options, true ) ) {
2478 throw new InvalidArgumentException(
2479 'The `text` option is no longer supported in MediaWiki 1.35 and later.'
2480 );
2481 }
2482
2483 return $ret;
2484 }
2485
2506 public function getSlotsQueryInfo( $options = [] ) {
2507 $ret = [
2508 'tables' => [],
2509 'fields' => [],
2510 'joins' => [],
2511 'keys' => [],
2512 ];
2513
2514 $ret['keys']['rev_id'] = 'slot_revision_id';
2515 $ret['keys']['role_id'] = 'slot_role_id';
2516
2517 $ret['tables'][] = 'slots';
2518 $ret['fields'] = array_merge( $ret['fields'], [
2519 'slot_revision_id',
2520 'slot_content_id',
2521 'slot_origin',
2522 'slot_role_id',
2523 ] );
2524
2525 if ( in_array( 'role', $options, true ) ) {
2526 // Use left join to attach role name, so we still find the revision row even
2527 // if the role name is missing. This triggers a more obvious failure mode.
2528 $ret['tables'][] = 'slot_roles';
2529 $ret['joins']['slot_roles'] = [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ];
2530 $ret['fields'][] = 'role_name';
2531 }
2532
2533 if ( in_array( 'content', $options, true ) ) {
2534 $ret['keys']['model_id'] = 'content_model';
2535
2536 $ret['tables'][] = 'content';
2537 $ret['fields'] = array_merge( $ret['fields'], [
2538 'content_size',
2539 'content_sha1',
2540 'content_address',
2541 'content_model',
2542 ] );
2543 $ret['joins']['content'] = [ 'JOIN', [ 'slot_content_id = content_id' ] ];
2544
2545 if ( in_array( 'model', $options, true ) ) {
2546 // Use left join to attach model name, so we still find the revision row even
2547 // if the model name is missing. This triggers a more obvious failure mode.
2548 $ret['tables'][] = 'content_models';
2549 $ret['joins']['content_models'] = [ 'LEFT JOIN', [ 'content_model = model_id' ] ];
2550 $ret['fields'][] = 'model_name';
2551 }
2552
2553 }
2554
2555 return $ret;
2556 }
2557
2566 public function isRevisionRow( $row, string $table = '' ) {
2567 if ( !( $row instanceof stdClass ) ) {
2568 return false;
2569 }
2570 $queryInfo = $table === 'archive' ? $this->getArchiveQueryInfo() : $this->getQueryInfo();
2571 foreach ( $queryInfo['fields'] as $alias => $field ) {
2572 $name = is_numeric( $alias ) ? $field : $alias;
2573 if ( !property_exists( $row, $name ) ) {
2574 return false;
2575 }
2576 }
2577 return true;
2578 }
2579
2597 public function getArchiveQueryInfo() {
2598 $commentQuery = $this->commentStore->getJoin( 'ar_comment' );
2599 $ret = [
2600 'tables' => [
2601 'archive',
2602 'archive_actor' => 'actor'
2603 ] + $commentQuery['tables'],
2604 'fields' => [
2605 'ar_id',
2606 'ar_page_id',
2607 'ar_namespace',
2608 'ar_title',
2609 'ar_rev_id',
2610 'ar_timestamp',
2611 'ar_minor_edit',
2612 'ar_deleted',
2613 'ar_len',
2614 'ar_parent_id',
2615 'ar_sha1',
2616 'ar_actor',
2617 'ar_user' => 'archive_actor.actor_user',
2618 'ar_user_text' => 'archive_actor.actor_name',
2619 ] + $commentQuery['fields'],
2620 'joins' => [
2621 'archive_actor' => [ 'JOIN', 'actor_id=ar_actor' ]
2622 ] + $commentQuery['joins'],
2623 ];
2624
2625 return $ret;
2626 }
2627
2637 public function getRevisionSizes( array $revIds ) {
2638 $dbr = $this->getDBConnectionRef( DB_REPLICA );
2639 $revLens = [];
2640 if ( !$revIds ) {
2641 return $revLens; // empty
2642 }
2643
2644 $res = $dbr->select(
2645 'revision',
2646 [ 'rev_id', 'rev_len' ],
2647 [ 'rev_id' => $revIds ],
2648 __METHOD__
2649 );
2650
2651 foreach ( $res as $row ) {
2652 $revLens[$row->rev_id] = intval( $row->rev_len );
2653 }
2654
2655 return $revLens;
2656 }
2657
2666 private function getRelativeRevision( RevisionRecord $rev, $flags, $dir ) {
2667 $op = $dir === 'next' ? '>' : '<';
2668 $sort = $dir === 'next' ? 'ASC' : 'DESC';
2669
2670 $revisionIdValue = $rev->getId( $this->wikiId );
2671
2672 if ( !$revisionIdValue || !$rev->getPageId( $this->wikiId ) ) {
2673 // revision is unsaved or otherwise incomplete
2674 return null;
2675 }
2676
2677 if ( $rev instanceof RevisionArchiveRecord ) {
2678 // revision is deleted, so it's not part of the page history
2679 return null;
2680 }
2681
2682 list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags );
2683 $db = $this->getDBConnectionRef( $dbType, [ 'contributions' ] );
2684
2685 $ts = $this->getTimestampFromId( $revisionIdValue, $flags );
2686 if ( $ts === false ) {
2687 // XXX Should this be moved into getTimestampFromId?
2688 $ts = $db->selectField( 'archive', 'ar_timestamp',
2689 [ 'ar_rev_id' => $revisionIdValue ], __METHOD__ );
2690 if ( $ts === false ) {
2691 // XXX Is this reachable? How can we have a page id but no timestamp?
2692 return null;
2693 }
2694 }
2695 $dbts = $db->addQuotes( $db->timestamp( $ts ) );
2696
2697 $revId = $db->selectField( 'revision', 'rev_id',
2698 [
2699 'rev_page' => $rev->getPageId( $this->wikiId ),
2700 "rev_timestamp $op $dbts OR (rev_timestamp = $dbts AND rev_id $op $revisionIdValue )"
2701 ],
2702 __METHOD__,
2703 [
2704 'ORDER BY' => [ "rev_timestamp $sort", "rev_id $sort" ],
2705 'IGNORE INDEX' => 'rev_timestamp', // Probably needed for T159319
2706 ]
2707 );
2708
2709 if ( $revId === false ) {
2710 return null;
2711 }
2712
2713 return $this->getRevisionById( intval( $revId ), $flags );
2714 }
2715
2730 public function getPreviousRevision( RevisionRecord $rev, $flags = self::READ_NORMAL ) {
2731 return $this->getRelativeRevision( $rev, $flags, 'prev' );
2732 }
2733
2745 public function getNextRevision( RevisionRecord $rev, $flags = self::READ_NORMAL ) {
2746 return $this->getRelativeRevision( $rev, $flags, 'next' );
2747 }
2748
2760 private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) {
2761 $this->checkDatabaseDomain( $db );
2762
2763 if ( $rev->getPageId( $this->wikiId ) === null ) {
2764 return 0;
2765 }
2766 # Use page_latest if ID is not given
2767 if ( !$rev->getId( $this->wikiId ) ) {
2768 $prevId = $db->selectField(
2769 'page', 'page_latest',
2770 [ 'page_id' => $rev->getPageId( $this->wikiId ) ],
2771 __METHOD__
2772 );
2773 } else {
2774 $prevId = $db->selectField(
2775 'revision', 'rev_id',
2776 [ 'rev_page' => $rev->getPageId( $this->wikiId ), 'rev_id < ' . $rev->getId( $this->wikiId ) ],
2777 __METHOD__,
2778 [ 'ORDER BY' => 'rev_id DESC' ]
2779 );
2780 }
2781 return intval( $prevId );
2782 }
2783
2796 public function getTimestampFromId( $id, $flags = 0 ) {
2797 if ( $id instanceof Title ) {
2798 // Old deprecated calling convention supported for backwards compatibility
2799 $id = $flags;
2800 $flags = func_num_args() > 2 ? func_get_arg( 2 ) : 0;
2801 }
2802
2803 // T270149: Bail out if we know the query will definitely return false. Some callers are
2804 // passing RevisionRecord::getId() call directly as $id which can possibly return null.
2805 // Null $id or $id <= 0 will lead to useless query with WHERE clause of 'rev_id IS NULL'
2806 // or 'rev_id = 0', but 'rev_id' is always greater than zero and cannot be null.
2807 // @todo typehint $id and remove the null check
2808 if ( $id === null || $id <= 0 ) {
2809 return false;
2810 }
2811
2812 $db = $this->getDBConnectionRefForQueryFlags( $flags );
2813
2814 $timestamp =
2815 $db->selectField( 'revision', 'rev_timestamp', [ 'rev_id' => $id ], __METHOD__ );
2816
2817 return ( $timestamp !== false ) ? MWTimestamp::convert( TS_MW, $timestamp ) : false;
2818 }
2819
2829 public function countRevisionsByPageId( IDatabase $db, $id ) {
2830 $this->checkDatabaseDomain( $db );
2831
2832 $row = $db->selectRow( 'revision',
2833 [ 'revCount' => 'COUNT(*)' ],
2834 [ 'rev_page' => $id ],
2835 __METHOD__
2836 );
2837 if ( $row ) {
2838 return intval( $row->revCount );
2839 }
2840 return 0;
2841 }
2842
2852 public function countRevisionsByTitle( IDatabase $db, PageIdentity $page ) {
2853 $id = $this->getArticleId( $page );
2854 if ( $id ) {
2855 return $this->countRevisionsByPageId( $db, $id );
2856 }
2857 return 0;
2858 }
2859
2878 public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) {
2879 $this->checkDatabaseDomain( $db );
2880
2881 if ( !$userId ) {
2882 return false;
2883 }
2884
2885 $revQuery = $this->getQueryInfo();
2886 $res = $db->select(
2887 $revQuery['tables'],
2888 [
2889 'rev_user' => $revQuery['fields']['rev_user'],
2890 ],
2891 [
2892 'rev_page' => $pageId,
2893 'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) )
2894 ],
2895 __METHOD__,
2896 [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ],
2897 $revQuery['joins']
2898 );
2899 foreach ( $res as $row ) {
2900 if ( $row->rev_user != $userId ) {
2901 return false;
2902 }
2903 }
2904 return true;
2905 }
2906
2920 public function getKnownCurrentRevision( PageIdentity $page, $revId = 0 ) {
2921 $db = $this->getDBConnectionRef( DB_REPLICA );
2922 $revIdPassed = $revId;
2923 $pageId = $this->getArticleId( $page );
2924 if ( !$pageId ) {
2925 return false;
2926 }
2927
2928 if ( !$revId ) {
2929 if ( $page instanceof Title ) {
2930 $revId = $page->getLatestRevID();
2931 } else {
2932 $pageRecord = $this->pageStore->getPageByReference( $page );
2933 if ( $pageRecord ) {
2934 $revId = $pageRecord->getLatest( $this->getWikiId() );
2935 }
2936 }
2937 }
2938
2939 if ( !$revId ) {
2940 $this->logger->warning(
2941 'No latest revision known for page {page} even though it exists with page ID {page_id}', [
2942 'page' => $page->__toString(),
2943 'page_id' => $pageId,
2944 'wiki_id' => $this->getWikiId() ?: 'local',
2945 ] );
2946 return false;
2947 }
2948
2949 // Load the row from cache if possible. If not possible, populate the cache.
2950 // As a minor optimization, remember if this was a cache hit or miss.
2951 // We can sometimes avoid a database query later if this is a cache miss.
2952 $fromCache = true;
2953 $row = $this->cache->getWithSetCallback(
2954 // Page/rev IDs passed in from DB to reflect history merges
2955 $this->getRevisionRowCacheKey( $db, $pageId, $revId ),
2956 WANObjectCache::TTL_WEEK,
2957 function ( $curValue, &$ttl, array &$setOpts ) use (
2958 $db, $revId, &$fromCache
2959 ) {
2960 $setOpts += Database::getCacheSetOptions( $db );
2961 $row = $this->fetchRevisionRowFromConds( $db, [ 'rev_id' => intval( $revId ) ] );
2962 if ( $row ) {
2963 $fromCache = false;
2964 }
2965 return $row; // don't cache negatives
2966 }
2967 );
2968
2969 // Reflect revision deletion and user renames.
2970 if ( $row ) {
2971 $title = $this->ensureRevisionRowMatchesPage( $row, $page, [
2972 'from_cache_flag' => $fromCache,
2973 'page_id_initial' => $pageId,
2974 'rev_id_used' => $revId,
2975 'rev_id_requested' => $revIdPassed,
2976 ] );
2977
2978 return $this->newRevisionFromRow( $row, 0, $title, $fromCache );
2979 } else {
2980 return false;
2981 }
2982 }
2983
2992 public function getFirstRevision(
2993 $page,
2994 int $flags = IDBAccessObject::READ_NORMAL
2995 ): ?RevisionRecord {
2996 if ( $page instanceof LinkTarget ) {
2997 // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756)
2998 $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null;
2999 }
3000 return $this->newRevisionFromConds(
3001 [
3002 'page_namespace' => $page->getNamespace(),
3003 'page_title' => $page->getDBkey()
3004 ],
3005 $flags,
3006 $page,
3007 [
3008 'ORDER BY' => [ 'rev_timestamp ASC', 'rev_id ASC' ],
3009 'IGNORE INDEX' => [ 'revision' => 'rev_timestamp' ], // See T159319
3010 ]
3011 );
3012 }
3013
3025 private function getRevisionRowCacheKey( IDatabase $db, $pageId, $revId ) {
3026 return $this->cache->makeGlobalKey(
3027 self::ROW_CACHE_KEY,
3028 $db->getDomainID(),
3029 $pageId,
3030 $revId
3031 );
3032 }
3033
3041 private function assertRevisionParameter( $paramName, $pageId, RevisionRecord $rev = null ) {
3042 if ( $rev ) {
3043 if ( $rev->getId( $this->wikiId ) === null ) {
3044 throw new InvalidArgumentException( "Unsaved {$paramName} revision passed" );
3045 }
3046 if ( $rev->getPageId( $this->wikiId ) !== $pageId ) {
3047 throw new InvalidArgumentException(
3048 "Revision {$rev->getId( $this->wikiId )} doesn't belong to page {$pageId}"
3049 );
3050 }
3051 }
3052 }
3053
3070 RevisionRecord $old = null,
3071 RevisionRecord $new = null,
3072 $options = []
3073 ) {
3074 $options = (array)$options;
3075 $oldCmp = '>';
3076 $newCmp = '<';
3077 if ( in_array( self::INCLUDE_OLD, $options ) ) {
3078 $oldCmp = '>=';
3079 }
3080 if ( in_array( self::INCLUDE_NEW, $options ) ) {
3081 $newCmp = '<=';
3082 }
3083 if ( in_array( self::INCLUDE_BOTH, $options ) ) {
3084 $oldCmp = '>=';
3085 $newCmp = '<=';
3086 }
3087
3088 $conds = [];
3089 if ( $old ) {
3090 $oldTs = $dbr->addQuotes( $dbr->timestamp( $old->getTimestamp() ) );
3091 $conds[] = "(rev_timestamp = {$oldTs} AND rev_id {$oldCmp} {$old->getId( $this->wikiId )}) " .
3092 "OR rev_timestamp > {$oldTs}";
3093 }
3094 if ( $new ) {
3095 $newTs = $dbr->addQuotes( $dbr->timestamp( $new->getTimestamp() ) );
3096 $conds[] = "(rev_timestamp = {$newTs} AND rev_id {$newCmp} {$new->getId( $this->wikiId )}) " .
3097 "OR rev_timestamp < {$newTs}";
3098 }
3099 return $conds;
3100 }
3101
3128 public function getRevisionIdsBetween(
3129 int $pageId,
3130 RevisionRecord $old = null,
3131 RevisionRecord $new = null,
3132 ?int $max = null,
3133 $options = [],
3134 ?string $order = null,
3135 int $flags = IDBAccessObject::READ_NORMAL
3136 ): array {
3137 $this->assertRevisionParameter( 'old', $pageId, $old );
3138 $this->assertRevisionParameter( 'new', $pageId, $new );
3139
3140 $options = (array)$options;
3141 $includeOld = in_array( self::INCLUDE_OLD, $options ) ||
3142 in_array( self::INCLUDE_BOTH, $options );
3143 $includeNew = in_array( self::INCLUDE_NEW, $options ) ||
3144 in_array( self::INCLUDE_BOTH, $options );
3145
3146 // No DB query needed if old and new are the same revision.
3147 // Can't check for consecutive revisions with 'getParentId' for a similar
3148 // optimization as edge cases exist when there are revisions between
3149 // a revision and it's parent. See T185167 for more details.
3150 if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) {
3151 return $includeOld || $includeNew ? [ $new->getId( $this->wikiId ) ] : [];
3152 }
3153
3154 $db = $this->getDBConnectionRefForQueryFlags( $flags );
3155 $conds = array_merge(
3156 [
3157 'rev_page' => $pageId,
3158 $db->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . ' = 0'
3159 ],
3160 $this->getRevisionLimitConditions( $db, $old, $new, $options )
3161 );
3162
3163 $queryOptions = [];
3164 if ( $order !== null ) {
3165 $queryOptions['ORDER BY'] = [ "rev_timestamp $order", "rev_id $order" ];
3166 }
3167 if ( $max !== null ) {
3168 $queryOptions['LIMIT'] = $max + 1; // extra to detect truncation
3169 }
3170
3171 $values = $db->selectFieldValues(
3172 'revision',
3173 'rev_id',
3174 $conds,
3175 __METHOD__,
3176 $queryOptions
3177 );
3178 return array_map( 'intval', $values );
3179 }
3180
3202 public function getAuthorsBetween(
3203 $pageId,
3204 RevisionRecord $old = null,
3205 RevisionRecord $new = null,
3206 Authority $performer = null,
3207 $max = null,
3208 $options = []
3209 ) {
3210 $this->assertRevisionParameter( 'old', $pageId, $old );
3211 $this->assertRevisionParameter( 'new', $pageId, $new );
3212 $options = (array)$options;
3213
3214 // No DB query needed if old and new are the same revision.
3215 // Can't check for consecutive revisions with 'getParentId' for a similar
3216 // optimization as edge cases exist when there are revisions between
3217 //a revision and it's parent. See T185167 for more details.
3218 if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) {
3219 if ( empty( $options ) ) {
3220 return [];
3221 } elseif ( $performer ) {
3222 return [ $new->getUser( RevisionRecord::FOR_THIS_USER, $performer ) ];
3223 } else {
3224 return [ $new->getUser() ];
3225 }
3226 }
3227
3228 $dbr = $this->getDBConnectionRef( DB_REPLICA );
3229 $conds = array_merge(
3230 [
3231 'rev_page' => $pageId,
3232 $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER ) . " = 0"
3233 ],
3234 $this->getRevisionLimitConditions( $dbr, $old, $new, $options )
3235 );
3236
3237 $queryOpts = [ 'DISTINCT' ];
3238 if ( $max !== null ) {
3239 $queryOpts['LIMIT'] = $max + 1;
3240 }
3241
3242 $actorQuery = $this->actorMigration->getJoin( 'rev_user' );
3243 return array_map( function ( $row ) {
3244 return $this->actorStore->newActorFromRowFields(
3245 $row->rev_user,
3246 $row->rev_user_text,
3247 $row->rev_actor
3248 );
3249 }, iterator_to_array( $dbr->select(
3250 array_merge( [ 'revision' ], $actorQuery['tables'] ),
3251 $actorQuery['fields'],
3252 $conds, __METHOD__,
3253 $queryOpts,
3254 $actorQuery['joins']
3255 ) ) );
3256 }
3257
3279 public function countAuthorsBetween(
3280 $pageId,
3281 RevisionRecord $old = null,
3282 RevisionRecord $new = null,
3283 Authority $performer = null,
3284 $max = null,
3285 $options = []
3286 ) {
3287 // TODO: Implement with a separate query to avoid cost of selecting unneeded fields
3288 // and creation of UserIdentity stuff.
3289 return count( $this->getAuthorsBetween( $pageId, $old, $new, $performer, $max, $options ) );
3290 }
3291
3312 public function countRevisionsBetween(
3313 $pageId,
3314 RevisionRecord $old = null,
3315 RevisionRecord $new = null,
3316 $max = null,
3317 $options = []
3318 ) {
3319 $this->assertRevisionParameter( 'old', $pageId, $old );
3320 $this->assertRevisionParameter( 'new', $pageId, $new );
3321
3322 // No DB query needed if old and new are the same revision.
3323 // Can't check for consecutive revisions with 'getParentId' for a similar
3324 // optimization as edge cases exist when there are revisions between
3325 //a revision and it's parent. See T185167 for more details.
3326 if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) {
3327 return 0;
3328 }
3329
3330 $dbr = $this->getDBConnectionRef( DB_REPLICA );
3331 $conds = array_merge(
3332 [
3333 'rev_page' => $pageId,
3334 $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
3335 ],
3336 $this->getRevisionLimitConditions( $dbr, $old, $new, $options )
3337 );
3338 if ( $max !== null ) {
3339 return $dbr->selectRowCount( 'revision', '1',
3340 $conds,
3341 __METHOD__,
3342 [ 'LIMIT' => $max + 1 ] // extra to detect truncation
3343 );
3344 } else {
3345 return (int)$dbr->selectField( 'revision', 'count(*)', $conds, __METHOD__ );
3346 }
3347 }
3348
3360 public function findIdenticalRevision(
3361 RevisionRecord $revision,
3362 int $searchLimit
3363 ): ?RevisionRecord {
3364 $revision->assertWiki( $this->wikiId );
3365 $db = $this->getDBConnectionRef( DB_REPLICA );
3366 $revQuery = $this->getQueryInfo();
3367 $subquery = $db->buildSelectSubquery(
3368 $revQuery['tables'],
3369 $revQuery['fields'],
3370 [ 'rev_page' => $revision->getPageId( $this->wikiId ) ],
3371 __METHOD__,
3372 [
3373 'ORDER BY' => [
3374 'rev_timestamp DESC',
3375 // for cases where there are multiple revs with same timestamp
3376 'rev_id DESC'
3377 ],
3378 'LIMIT' => $searchLimit,
3379 // skip the most recent edit, we can't revert to it anyway
3380 'OFFSET' => 1
3381 ],
3382 $revQuery['joins']
3383 );
3384
3385 // selectRow effectively uses LIMIT 1 clause, returning only the first result
3386 $revisionRow = $db->selectRow(
3387 [ 'recent_revs' => $subquery ],
3388 '*',
3389 [ 'rev_sha1' => $revision->getSha1() ],
3390 __METHOD__
3391 );
3392
3393 return $revisionRow ? $this->newRevisionFromRow( $revisionRow ) : null;
3394 }
3395
3396 // TODO: move relevant methods from Title here, e.g. isBigDeletion, etc.
3397}
3398
3403class_alias( RevisionStore::class, 'MediaWiki\Storage\RevisionStore' );
const NS_TEMPLATE
Definition Defines.php:74
wfBacktrace( $raw=null)
Get a debug backtrace as a string.
wfDeprecatedMsg( $msg, $version=false, $component=false, $callerOffset=2)
Log a deprecation warning with arbitrary message text.
if(!defined('MW_SETUP_CALLBACK'))
Expand dynamic defaults and shortcuts.
Definition WebStart.php:89
This is not intended to be a long-term part of MediaWiki; it will be deprecated and removed once acto...
Class representing a cache/ephemeral data store.
Definition BagOStuff.php:86
Value object for a comment stored by CommentStore.
Handle database storage of comments such as edit summaries and log reasons.
Helper class for DAO classes.
Content object implementation representing unknown content.
MediaWiki exception.
Library for creating and parsing MW-style timestamps.
Exception thrown when an unregistered content model is requested.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Immutable value object representing a page identity.
Exception throw when trying to access undefined fields on an incomplete RevisionRecord.
Exception representing a failure to look up a revision.
A RevisionRecord representing a revision of a deleted page persisted in the archive table.
Page revision base class.
getUser( $audience=self::FOR_PUBLIC, Authority $performer=null)
Fetch revision's author's user identity, if it's available to the specified audience.
getSize()
Returns the nominal size of this revision, in bogo-bytes.
isReadyForInsertion()
Returns whether this RevisionRecord is ready for insertion, that is, whether it contains all informat...
getSlotRoles()
Returns the slot names (roles) of all slots present in this revision.
getPage()
Returns the page this revision belongs to.
getParentId( $wikiId=self::LOCAL)
Get parent revision ID (the original previous page revision).
getVisibility()
Get the deletion bitfield of the revision.
getPageId( $wikiId=self::LOCAL)
Get the page ID.
getTimestamp()
MCR migration note: this replaced Revision::getTimestamp.
getComment( $audience=self::FOR_PUBLIC, Authority $performer=null)
Fetch revision comment, if it's available to the specified audience.
getSlot( $role, $audience=self::FOR_PUBLIC, Authority $performer=null)
Returns meta-data for the given slot.
getSha1()
Returns the base36 sha1 of this revision.
isMinor()
MCR migration note: this replaced Revision::isMinor.
getId( $wikiId=self::LOCAL)
Get revision ID.
Value object representing the set of slots belonging to a revision.
A RevisionRecord representing an existing revision persisted in the revision table.
Service for looking up page revisions.
countRevisionsBetween( $pageId, RevisionRecord $old=null, RevisionRecord $new=null, $max=null, $options=[])
Get the number of revisions between the given revisions.
getDBConnectionRef( $mode, $groups=[])
getTimestampFromId( $id, $flags=0)
Get rev_timestamp from rev_id, without loading the rest of the row.
ensureRevisionRowMatchesPage( $row, PageIdentity $page, $context=[])
Check that the given row matches the given Title object.
constructSlotRecords( $revId, $slotRows, $queryFlags, PageIdentity $page, $slotContents=null)
Factory method for SlotRecords based on known slot rows.
IContentHandlerFactory $contentHandlerFactory
getRevisionByTimestamp( $page, string $timestamp, int $flags=IDBAccessObject::READ_NORMAL)
Load the revision for the given title with the given timestamp.
countAuthorsBetween( $pageId, RevisionRecord $old=null, RevisionRecord $new=null, Authority $performer=null, $max=null, $options=[])
Get the number of authors between the given revisions.
isRevisionRow( $row, string $table='')
Determine whether the parameter is a row containing all the fields that RevisionStore needs to create...
newNullRevision(IDatabase $dbw, PageIdentity $page, CommentStoreComment $comment, $minor, UserIdentity $user)
Create a new null-revision for insertion into a page's history.
newRevisionsFromBatch( $rows, array $options=[], $queryFlags=0, PageIdentity $page=null)
Construct a RevisionRecord instance for each row in $rows, and return them as an associative array in...
getRevisionSizes(array $revIds)
Do a batched query for the sizes of a set of revisions.
newRevisionSlots( $revId, $revisionRow, $slotRows, $queryFlags, PageIdentity $page)
Factory method for RevisionSlots based on a revision ID.
getRevisionByPageId( $pageId, $revId=0, $flags=0)
Load either the current, or a specified, revision that's attached to a given page ID.
insertSlotOn(IDatabase $dbw, $revisionId, SlotRecord $protoSlot, PageIdentity $page, array $blobHints=[])
newRevisionFromArchiveRowAndSlots(stdClass $row, $slots, int $queryFlags=0, ?PageIdentity $page=null, array $overrides=[])
getWikiId()
Get the ID of the wiki this revision belongs to.
insertSlotRowOn(SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId)
newRevisionFromConds(array $conditions, int $flags=IDBAccessObject::READ_NORMAL, PageIdentity $page=null, array $options=[])
Given a set of conditions, fetch a revision.
storeContentBlob(SlotRecord $slot, PageIdentity $page, array $blobHints=[])
loadRevisionFromConds(IDatabase $db, array $conditions, int $flags=IDBAccessObject::READ_NORMAL, PageIdentity $page=null, array $options=[])
Given a set of conditions, fetch a revision from the given database connection.
findIdenticalRevision(RevisionRecord $revision, int $searchLimit)
Tries to find a revision identical to $revision in $searchLimit most recent revisions of this page.
newRevisionFromRow( $row, $queryFlags=0, PageIdentity $page=null, $fromCache=false)
insertRevisionOn(RevisionRecord $rev, IDatabase $dbw)
Insert a new revision into the database, returning the new revision record on success and dies horrib...
insertContentRowOn(SlotRecord $slot, IDatabase $dbw, $blobAddress)
getQueryInfo( $options=[])
Return the tables, fields, and join conditions to be selected to create a new RevisionStoreRecord obj...
fetchRevisionRowFromConds(IDatabase $db, array $conditions, int $flags=IDBAccessObject::READ_NORMAL, array $options=[])
Given a set of conditions, return a row with the fields necessary to build RevisionRecord objects.
updateSlotsOn(RevisionRecord $revision, RevisionSlotsUpdate $revisionSlotsUpdate, IDatabase $dbw)
Update derived slots in an existing revision into the database, returning the modified slots on succe...
getSlotsQueryInfo( $options=[])
Return the tables, fields, and join conditions to be selected to create a new SlotRecord.
getContentBlobsForBatch( $rowsOrIds, $slots=null, $queryFlags=0)
Gets raw (serialized) content blobs for the given set of revisions.
getRecentChange(RevisionRecord $rev, $flags=0)
Get the RC object belonging to the current revision, if there's one.
getTitle( $pageId, $revId, $queryFlags=self::READ_NORMAL)
Determines the page Title based on the available information.
getAuthorsBetween( $pageId, RevisionRecord $old=null, RevisionRecord $new=null, Authority $performer=null, $max=null, $options=[])
Get the authors between the given revisions or revisions.
loadSlotRecords( $revId, $queryFlags, PageIdentity $page)
assertRevisionParameter( $paramName, $pageId, RevisionRecord $rev=null)
Asserts that if revision is provided, it's saved and belongs to the page with provided pageId.
checkContent(Content $content, PageIdentity $page, string $role)
MCR migration note: this corresponded to Revision::checkContentModel.
getKnownCurrentRevision(PageIdentity $page, $revId=0)
Load a revision based on a known page ID and current revision ID from the DB.
getRcIdIfUnpatrolled(RevisionRecord $rev)
MCR migration note: this replaced Revision::isUnpatrolled.
checkDatabaseDomain(IDatabase $db)
Throws an exception if the given database connection does not belong to the wiki this RevisionStore i...
insertIpChangesRow(IDatabase $dbw, UserIdentity $user, RevisionRecord $rev, $revisionId)
Insert IP revision into ip_changes for use when querying for a range.
insertRevisionRowOn(IDatabase $dbw, RevisionRecord $rev, $parentId)
loadSlotRecordsFromDb( $revId, $queryFlags, PageIdentity $page)
getNextRevision(RevisionRecord $rev, $flags=self::READ_NORMAL)
Get the revision after $rev in the page's history, if any.
getRelativeRevision(RevisionRecord $rev, $flags, $dir)
Implementation of getPreviousRevision and getNextRevision.
newRevisionFromArchiveRow( $row, $queryFlags=0, PageIdentity $page=null, array $overrides=[])
Make a fake RevisionRecord object from an archive table row.
getSlotRowsForBatch( $rowsOrIds, array $options=[], $queryFlags=0)
Gets the slot rows associated with a batch of revisions.
getRevisionById( $id, $flags=0, PageIdentity $page=null)
Load a page revision from a given revision ID number.
setLogger(LoggerInterface $logger)
countRevisionsByTitle(IDatabase $db, PageIdentity $page)
Get count of revisions per page...not very efficient.
getRevisionRowCacheKey(IDatabase $db, $pageId, $revId)
Get a cache key for use with a row as selected with getQueryInfo( [ 'page', 'user' ] ) Caching rows w...
getPreviousRevision(RevisionRecord $rev, $flags=self::READ_NORMAL)
Get the revision before $rev in the page's history, if any.
newRevisionFromRowAndSlots(stdClass $row, $slots, int $queryFlags=0, ?PageIdentity $page=null, bool $fromCache=false)
getFirstRevision( $page, int $flags=IDBAccessObject::READ_NORMAL)
Get the first revision of a given page.
getRevisionLimitConditions(IDatabase $dbr, RevisionRecord $old=null, RevisionRecord $new=null, $options=[])
Converts revision limits to query conditions.
userWasLastToEdit(IDatabase $db, $pageId, $userId, $since)
Check if no edits were made by other users since the time a user started editing the page.
countRevisionsByPageId(IDatabase $db, $id)
Get count of revisions per page...not very efficient.
updateSlotsInternal(RevisionRecord $revision, RevisionSlotsUpdate $revisionSlotsUpdate, IDatabase $dbw)
insertRevisionInternal(RevisionRecord $rev, IDatabase $dbw, UserIdentity $user, CommentStoreComment $comment, PageIdentity $page, $pageId, $parentId)
__construct(ILoadBalancer $loadBalancer, SqlBlobStore $blobStore, WANObjectCache $cache, BagOStuff $localCache, CommentStore $commentStore, NameTableStore $contentModelStore, NameTableStore $slotRoleStore, SlotRoleRegistry $slotRoleRegistry, ActorMigration $actorMigration, ActorStore $actorStore, IContentHandlerFactory $contentHandlerFactory, PageStore $pageStore, TitleFactory $titleFactory, HookContainer $hookContainer, $wikiId=WikiAwareEntity::LOCAL)
getRevisionIdsBetween(int $pageId, RevisionRecord $old=null, RevisionRecord $new=null, ?int $max=null, $options=[], ?string $order=null, int $flags=IDBAccessObject::READ_NORMAL)
Get IDs of revisions between the given revisions.
getPreviousRevisionId(IDatabase $db, RevisionRecord $rev)
Get previous revision Id for this page_id This is used to populate rev_parent_id on save.
getRevisionByTitle( $page, $revId=0, $flags=0)
Load either the current, or a specified, revision that's attached to a given link target.
getBaseRevisionRow(IDatabase $dbw, RevisionRecord $rev, $parentId)
loadSlotContent(SlotRecord $slot, ?string $blobData=null, ?string $blobFlags=null, ?string $blobFormat=null, int $queryFlags=0)
Loads a Content object based on a slot row.
getPage(?int $pageId, ?int $revId, int $queryFlags=self::READ_NORMAL)
Determines the page based on the available information.
getArchiveQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new RevisionArchiveRecord o...
Value object representing a content slot associated with a page revision.
getContent()
Returns the Content of the given slot.
getRole()
Returns the role of the slot.
hasAddress()
Whether this slot has an address.
getSha1()
Returns the content size.
getSize()
Returns the content size.
getAddress()
Returns the address of this slot's content.
hasOrigin()
Whether this slot has an origin (revision ID that originated the slot's content.
getModel()
Returns the content model.
getOrigin()
Returns the revision ID of the revision that originated the slot's content.
hasContentId()
Whether this slot has a content ID.
getContentId()
Returns the ID of the content meta data row associated with the slot.
getRevision()
Returns the ID of the revision this slot is associated with.
A registry service for SlotRoleHandlers, used to define which slot roles are available on which page.
Exception representing a failure to access a data blob.
Value object representing a modification of revision slots.
getRemovedRoles()
Returns a list of removed slot roles, that is, roles removed by calling removeSlot(),...
getModifiedRoles()
Returns a list of modified slot roles, that is, roles modified by calling modifySlot(),...
getModifiedSlot( $role)
Returns the SlotRecord associated with the given role, if the slot with that role was modified (and n...
Service for storing and loading Content objects.
Utility class for creating new RC entries.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Creates Title objects.
Represents a title within MediaWiki.
Definition Title.php:47
Multi-datacenter aware caching interface.
Helper class used for automatically marking an IDatabase connection as reusable (once it no longer ma...
Definition DBConnRef.php:30
Relational database abstraction object.
Definition Database.php:51
Base interface for content objects.
Definition Content.php:35
Interface for database access objects.
Marker interface for entities aware of the wiki they belong to.
Interface for objects (potentially) representing an editable wiki page.
getId( $wikiId=self::LOCAL)
Returns the page ID.
getNamespace()
Returns the page's namespace number.
getDBkey()
Get the page title in DB key form.
getWikiId()
Get the ID of the wiki this page belongs to.
__toString()
Returns an informative human readable unique representation of the page identity, for use as a cache ...
This interface represents the authority associated the current execution context, such as a web reque...
Definition Authority.php:37
Service for constructing RevisionRecord objects.
Service for looking up page revisions.
Service for loading and storing data blobs.
Definition BlobStore.php:35
Interface for objects representing user identity.
getId( $wikiId=self::LOCAL)
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:38
onTransactionResolution(callable $callback, $fname=__METHOD__)
Run a callback when the current transaction commits or rolls back.
unlock( $lockName, $method)
Release a lock.
selectRow( $table, $vars, $conds, $fname=__METHOD__, $options=[], $join_conds=[])
Wrapper to IDatabase::select() that only fetches one row (via LIMIT)
doAtomicSection( $fname, callable $callback, $cancelable=self::ATOMIC_NOT_CANCELABLE)
Perform an atomic section of reversible SQL statements from a callback.
selectSQLText( $table, $vars, $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Take the same arguments as IDatabase::select() and return the SQL it would use.
getDomainID()
Return the currently selected domain ID.
lock( $lockName, $method, $timeout=5, $flags=0)
Acquire a named lock.
select( $table, $vars, $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Execute a SELECT query constructed using the various parameters provided.
delete( $table, $conds, $fname=__METHOD__)
Delete all rows in a table that match a condition.
addQuotes( $s)
Escape and quote a raw value string for use in a SQL query.
timestamp( $ts=0)
Convert a timestamp in one of the formats accepted by ConvertibleTimestamp to the format used for ins...
selectField( $table, $var, $cond='', $fname=__METHOD__, $options=[], $join_conds=[])
A SELECT wrapper which returns a single field from a single result row.
getType()
Get the RDBMS type of the server (e.g.
query( $sql, $fname=__METHOD__, $flags=0)
Run an SQL query and return the result.
insert( $table, $rows, $fname=__METHOD__, $options=[])
Insert the given row(s) into a table.
insertId()
Get the inserted value of an auto-increment row.
Database cluster connection, tracking, load balancing, and transaction manager interface.
Result wrapper for grabbing data queried from an IDatabase object.
const DB_REPLICA
Definition defines.php:25
const DB_PRIMARY
Definition defines.php:27
$content
Definition router.php:76