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