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