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;
59 use MWException;
60 use MWTimestamp;
62 use Psr\Log\LoggerAwareInterface;
63 use Psr\Log\LoggerInterface;
64 use Psr\Log\NullLogger;
65 use RecentChange;
66 use RuntimeException;
67 use StatusValue;
68 use stdClass;
69 use Traversable;
70 use WANObjectCache;
71 use Wikimedia\Assert\Assert;
72 use Wikimedia\IPUtils;
79 
90  implements IDBAccessObject, RevisionFactory, RevisionLookup, LoggerAwareInterface {
91 
93 
94  public const ROW_CACHE_KEY = 'revision-row-1.29';
95 
96  public const ORDER_OLDEST_TO_NEWEST = 'ASC';
97  public const ORDER_NEWEST_TO_OLDEST = 'DESC';
98 
99  // Constants for get(...)Between methods
100  public const INCLUDE_OLD = 'include_old';
101  public const INCLUDE_NEW = 'include_new';
102  public const INCLUDE_BOTH = 'include_both';
103 
107  private $blobStore;
108 
112  private $wikiId;
113 
117  private $loadBalancer;
118 
122  private $cache;
123 
127  private $localCache;
128 
132  private $commentStore;
133 
137  private $actorMigration;
138 
140  private $actorStore;
141 
145  private $logger;
146 
150  private $contentModelStore;
151 
155  private $slotRoleStore;
156 
158  private $slotRoleRegistry;
159 
161  private $contentHandlerFactory;
162 
164  private $hookRunner;
165 
167  private $pageStore;
168 
170  private $titleFactory;
171 
197  public function __construct(
198  ILoadBalancer $loadBalancer,
199  SqlBlobStore $blobStore,
200  WANObjectCache $cache,
201  BagOStuff $localCache,
202  CommentStore $commentStore,
203  NameTableStore $contentModelStore,
204  NameTableStore $slotRoleStore,
205  SlotRoleRegistry $slotRoleRegistry,
206  ActorMigration $actorMigration,
207  ActorStore $actorStore,
208  IContentHandlerFactory $contentHandlerFactory,
209  PageStore $pageStore,
210  TitleFactory $titleFactory,
211  HookContainer $hookContainer,
212  $wikiId = WikiAwareEntity::LOCAL
213  ) {
214  Assert::parameterType( [ 'string', 'false' ], $wikiId, '$wikiId' );
215 
216  $this->loadBalancer = $loadBalancer;
217  $this->blobStore = $blobStore;
218  $this->cache = $cache;
219  $this->localCache = $localCache;
220  $this->commentStore = $commentStore;
221  $this->contentModelStore = $contentModelStore;
222  $this->slotRoleStore = $slotRoleStore;
223  $this->slotRoleRegistry = $slotRoleRegistry;
224  $this->actorMigration = $actorMigration;
225  $this->actorStore = $actorStore;
226  $this->wikiId = $wikiId;
227  $this->logger = new NullLogger();
228  $this->contentHandlerFactory = $contentHandlerFactory;
229  $this->pageStore = $pageStore;
230  $this->titleFactory = $titleFactory;
231  $this->hookRunner = new HookRunner( $hookContainer );
232  }
233 
234  public function setLogger( LoggerInterface $logger ) {
235  $this->logger = $logger;
236  }
237 
241  public function isReadOnly() {
242  return $this->blobStore->isReadOnly();
243  }
244 
250  public function getWikiId() {
251  return $this->wikiId;
252  }
253 
259  private function getDBConnectionRefForQueryFlags( $queryFlags ) {
260  [ $mode, ] = DBAccessObjectUtils::getDBOptions( $queryFlags );
261  return $this->getDBConnectionRef( $mode );
262  }
263 
269  private function getDBConnectionRef( $mode, $groups = [] ) {
270  return $this->loadBalancer->getConnectionRef( $mode, $groups, $this->wikiId );
271  }
272 
289  public function getTitle( $pageId, $revId, $queryFlags = self::READ_NORMAL ) {
290  // TODO: Hard-deprecate this once getPage() returns a PageRecord. T195069
291  if ( $this->wikiId !== WikiAwareEntity::LOCAL ) {
292  wfDeprecatedMsg( 'Using a Title object to refer to a page on another site.', '1.36' );
293  }
294 
295  $page = $this->getPage( $pageId, $revId, $queryFlags );
296  return $this->titleFactory->newFromPageIdentity( $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  return $this->titleFactory->newFromPageIdentity( $page );
376  } else {
377  return $page;
378  }
379  }
380 
388  private function failOnNull( $value, $name ) {
389  if ( $value === null ) {
390  throw new IncompleteRevisionException(
391  "$name must not be " . var_export( $value, true ) . "!"
392  );
393  }
394 
395  return $value;
396  }
397 
405  private function failOnEmpty( $value, $name ) {
406  if ( $value === null || $value === 0 || $value === '' ) {
407  throw new IncompleteRevisionException(
408  "$name must not be " . var_export( $value, true ) . "!"
409  );
410  }
411 
412  return $value;
413  }
414 
426  public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) {
427  // TODO: pass in a DBTransactionContext instead of a database connection.
428  $this->checkDatabaseDomain( $dbw );
429 
430  $slotRoles = $rev->getSlotRoles();
431 
432  // Make sure the main slot is always provided throughout migration
433  if ( !in_array( SlotRecord::MAIN, $slotRoles ) ) {
434  throw new IncompleteRevisionException(
435  'main slot must be provided'
436  );
437  }
438 
439  // Checks
440  $this->failOnNull( $rev->getSize(), 'size field' );
441  $this->failOnEmpty( $rev->getSha1(), 'sha1 field' );
442  $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' );
443  $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' );
444  $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' );
445  $this->failOnNull( $user->getId(), 'user field' );
446  $this->failOnEmpty( $user->getName(), 'user_text field' );
447 
448  if ( !$rev->isReadyForInsertion() ) {
449  // This is here for future-proofing. At the time this check being added, it
450  // was redundant to the individual checks above.
451  throw new IncompleteRevisionException( 'Revision is incomplete' );
452  }
453 
454  if ( $slotRoles == [ SlotRecord::MAIN ] ) {
455  // T239717: If the main slot is the only slot, make sure the revision's nominal size
456  // and hash match the main slot's nominal size and hash.
457  $mainSlot = $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW );
458  Assert::precondition(
459  $mainSlot->getSize() === $rev->getSize(),
460  'The revisions\'s size must match the main slot\'s size (see T239717)'
461  );
462  Assert::precondition(
463  $mainSlot->getSha1() === $rev->getSha1(),
464  'The revisions\'s SHA1 hash must match the main slot\'s SHA1 hash (see T239717)'
465  );
466  }
467 
468  $pageId = $this->failOnEmpty( $rev->getPageId( $this->wikiId ), 'rev_page field' ); // check this early
469 
470  $parentId = $rev->getParentId() ?? $this->getPreviousRevisionId( $dbw, $rev );
471 
473  $rev = $dbw->doAtomicSection(
474  __METHOD__,
475  function ( IDatabase $dbw, $fname ) use (
476  $rev,
477  $user,
478  $comment,
479  $pageId,
480  $parentId
481  ) {
482  return $this->insertRevisionInternal(
483  $rev,
484  $dbw,
485  $user,
486  $comment,
487  $rev->getPage(),
488  $pageId,
489  $parentId
490  );
491  }
492  );
493 
494  Assert::postcondition( $rev->getId( $this->wikiId ) > 0, 'revision must have an ID' );
495  Assert::postcondition( $rev->getPageId( $this->wikiId ) > 0, 'revision must have a page ID' );
496  Assert::postcondition(
497  $rev->getComment( RevisionRecord::RAW ) !== null,
498  'revision must have a comment'
499  );
500  Assert::postcondition(
501  $rev->getUser( RevisionRecord::RAW ) !== null,
502  'revision must have a user'
503  );
504 
505  // Trigger exception if the main slot is missing.
506  // Technically, this could go away after MCR migration: while
507  // calling code may require a main slot to exist, RevisionStore
508  // really should not know or care about that requirement.
510 
511  foreach ( $slotRoles as $role ) {
512  $slot = $rev->getSlot( $role, RevisionRecord::RAW );
513  Assert::postcondition(
514  $slot->getContent() !== null,
515  $role . ' slot must have content'
516  );
517  Assert::postcondition(
518  $slot->hasRevision(),
519  $role . ' slot must have a revision associated'
520  );
521  }
522 
523  $this->hookRunner->onRevisionRecordInserted( $rev );
524 
525  return $rev;
526  }
527 
540  public function updateSlotsOn(
541  RevisionRecord $revision,
542  RevisionSlotsUpdate $revisionSlotsUpdate,
543  IDatabase $dbw
544  ): array {
545  $this->checkDatabaseDomain( $dbw );
546 
547  // Make sure all modified and removed slots are derived slots
548  foreach ( $revisionSlotsUpdate->getModifiedRoles() as $role ) {
549  Assert::precondition(
550  $this->slotRoleRegistry->getRoleHandler( $role )->isDerived(),
551  'Trying to modify a slot that is not derived'
552  );
553  }
554  foreach ( $revisionSlotsUpdate->getRemovedRoles() as $role ) {
555  $isDerived = $this->slotRoleRegistry->getRoleHandler( $role )->isDerived();
556  Assert::precondition(
557  $isDerived,
558  'Trying to remove a slot that is not derived'
559  );
560  throw new LogicException( 'Removing derived slots is not yet implemented. See T277394.' );
561  }
562 
564  $slotRecords = $dbw->doAtomicSection(
565  __METHOD__,
566  function ( IDatabase $dbw, $fname ) use (
567  $revision,
568  $revisionSlotsUpdate
569  ) {
570  return $this->updateSlotsInternal(
571  $revision,
572  $revisionSlotsUpdate,
573  $dbw
574  );
575  }
576  );
577 
578  foreach ( $slotRecords as $role => $slot ) {
579  Assert::postcondition(
580  $slot->getContent() !== null,
581  $role . ' slot must have content'
582  );
583  Assert::postcondition(
584  $slot->hasRevision(),
585  $role . ' slot must have a revision associated'
586  );
587  }
588 
589  return $slotRecords;
590  }
591 
598  private function updateSlotsInternal(
599  RevisionRecord $revision,
600  RevisionSlotsUpdate $revisionSlotsUpdate,
601  IDatabase $dbw
602  ): array {
603  $page = $revision->getPage();
604  $revId = $revision->getId( $this->wikiId );
605  $blobHints = [
606  BlobStore::PAGE_HINT => $page->getId( $this->wikiId ),
607  BlobStore::REVISION_HINT => $revId,
608  BlobStore::PARENT_HINT => $revision->getParentId( $this->wikiId ),
609  ];
610 
611  $newSlots = [];
612  foreach ( $revisionSlotsUpdate->getModifiedRoles() as $role ) {
613  $slot = $revisionSlotsUpdate->getModifiedSlot( $role );
614  $newSlots[$role] = $this->insertSlotOn( $dbw, $revId, $slot, $page, $blobHints );
615  }
616 
617  return $newSlots;
618  }
619 
620  private function insertRevisionInternal(
621  RevisionRecord $rev,
622  IDatabase $dbw,
623  UserIdentity $user,
624  CommentStoreComment $comment,
625  PageIdentity $page,
626  $pageId,
627  $parentId
628  ) {
629  $slotRoles = $rev->getSlotRoles();
630 
631  $revisionRow = $this->insertRevisionRowOn(
632  $dbw,
633  $rev,
634  $parentId
635  );
636 
637  $revisionId = $revisionRow['rev_id'];
638 
639  $blobHints = [
640  BlobStore::PAGE_HINT => $pageId,
641  BlobStore::REVISION_HINT => $revisionId,
642  BlobStore::PARENT_HINT => $parentId,
643  ];
644 
645  $newSlots = [];
646  foreach ( $slotRoles as $role ) {
647  $slot = $rev->getSlot( $role, RevisionRecord::RAW );
648 
649  // If the SlotRecord already has a revision ID set, this means it already exists
650  // in the database, and should already belong to the current revision.
651  // However, a slot may already have a revision, but no content ID, if the slot
652  // is emulated based on the archive table, because we are in SCHEMA_COMPAT_READ_OLD
653  // mode, and the respective archive row was not yet migrated to the new schema.
654  // In that case, a new slot row (and content row) must be inserted even during
655  // undeletion.
656  if ( $slot->hasRevision() && $slot->hasContentId() ) {
657  // TODO: properly abort transaction if the assertion fails!
658  Assert::parameter(
659  $slot->getRevision() === $revisionId,
660  'slot role ' . $slot->getRole(),
661  'Existing slot should belong to revision '
662  . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!'
663  );
664 
665  // Slot exists, nothing to do, move along.
666  // This happens when restoring archived revisions.
667 
668  $newSlots[$role] = $slot;
669  } else {
670  $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $page, $blobHints );
671  }
672  }
673 
674  $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId );
675 
676  $rev = new RevisionStoreRecord(
677  $page,
678  $user,
679  $comment,
680  (object)$revisionRow,
681  new RevisionSlots( $newSlots ),
682  $this->wikiId
683  );
684 
685  return $rev;
686  }
687 
696  private function insertSlotOn(
697  IDatabase $dbw,
698  $revisionId,
699  SlotRecord $protoSlot,
700  PageIdentity $page,
701  array $blobHints = []
702  ) {
703  if ( $protoSlot->hasAddress() ) {
704  $blobAddress = $protoSlot->getAddress();
705  } else {
706  $blobAddress = $this->storeContentBlob( $protoSlot, $page, $blobHints );
707  }
708 
709  if ( $protoSlot->hasContentId() ) {
710  $contentId = $protoSlot->getContentId();
711  } else {
712  $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress );
713  }
714 
715  $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId );
716 
717  return SlotRecord::newSaved(
718  $revisionId,
719  $contentId,
720  $blobAddress,
721  $protoSlot
722  );
723  }
724 
732  private function insertIpChangesRow(
733  IDatabase $dbw,
734  UserIdentity $user,
735  RevisionRecord $rev,
736  $revisionId
737  ) {
738  if ( !$user->isRegistered() && IPUtils::isValid( $user->getName() ) ) {
739  $ipcRow = [
740  'ipc_rev_id' => $revisionId,
741  'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
742  'ipc_hex' => IPUtils::toHex( $user->getName() ),
743  ];
744  $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ );
745  }
746  }
747 
758  private function insertRevisionRowOn(
759  IDatabase $dbw,
760  RevisionRecord $rev,
761  $parentId
762  ) {
763  $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $parentId );
764 
765  [ $commentFields, $commentCallback ] =
766  $this->commentStore->insertWithTempTable(
767  $dbw,
768  'rev_comment',
769  $rev->getComment( RevisionRecord::RAW )
770  );
771  $revisionRow += $commentFields;
772 
773  [ $actorFields, $actorCallback ] =
774  $this->actorMigration->getInsertValuesWithTempTable(
775  $dbw,
776  'rev_user',
777  $rev->getUser( RevisionRecord::RAW )
778  );
779  $revisionRow += $actorFields;
780 
781  $dbw->insert( 'revision', $revisionRow, __METHOD__ );
782 
783  if ( !isset( $revisionRow['rev_id'] ) ) {
784  // only if auto-increment was used
785  $revisionRow['rev_id'] = intval( $dbw->insertId() );
786 
787  if ( $dbw->getType() === 'mysql' ) {
788  // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the
789  // auto-increment value to disk, so on server restart it might reuse IDs from deleted
790  // revisions. We can fix that with an insert with an explicit rev_id value, if necessary.
791 
792  $maxRevId = intval( $dbw->selectField( 'archive', 'MAX(ar_rev_id)', '', __METHOD__ ) );
793  $table = 'archive';
794  $maxRevId2 = intval( $dbw->selectField( 'slots', 'MAX(slot_revision_id)', '', __METHOD__ ) );
795  if ( $maxRevId2 >= $maxRevId ) {
796  $maxRevId = $maxRevId2;
797  $table = 'slots';
798  }
799 
800  if ( $maxRevId >= $revisionRow['rev_id'] ) {
801  $this->logger->debug(
802  '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.'
803  . ' Trying to fix it.',
804  [
805  'revid' => $revisionRow['rev_id'],
806  'table' => $table,
807  'maxrevid' => $maxRevId,
808  ]
809  );
810 
811  if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) {
812  throw new MWException( 'Failed to get database lock for T202032' );
813  }
814  $fname = __METHOD__;
815  $dbw->onTransactionResolution(
816  static function ( $trigger, IDatabase $dbw ) use ( $fname ) {
817  $dbw->unlock( 'fix-for-T202032', $fname );
818  },
819  __METHOD__
820  );
821 
822  $dbw->delete( 'revision', [ 'rev_id' => $revisionRow['rev_id'] ], __METHOD__ );
823 
824  // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction
825  // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing
826  // inserts too, though, at least on MariaDB 10.1.29.
827  //
828  // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent
829  // transactions in this code path thanks to the row lock from the original ->insert() above.
830  //
831  // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning
832  // that's for non-MySQL DBs.
833  $row1 = $dbw->query(
834  $dbw->selectSQLText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE',
835  __METHOD__
836  )->fetchObject();
837 
838  $row2 = $dbw->query(
839  $dbw->selectSQLText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ )
840  . ' FOR UPDATE',
841  __METHOD__
842  )->fetchObject();
843 
844  $maxRevId = max(
845  $maxRevId,
846  $row1 ? intval( $row1->v ) : 0,
847  $row2 ? intval( $row2->v ) : 0
848  );
849 
850  // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent
851  // transactions will throw a duplicate key error here. It doesn't seem worth trying
852  // to avoid that.
853  $revisionRow['rev_id'] = $maxRevId + 1;
854  $dbw->insert( 'revision', $revisionRow, __METHOD__ );
855  }
856  }
857  }
858 
859  $commentCallback( $revisionRow['rev_id'] );
860  $actorCallback( $revisionRow['rev_id'], $revisionRow );
861 
862  return $revisionRow;
863  }
864 
872  private function getBaseRevisionRow(
873  IDatabase $dbw,
874  RevisionRecord $rev,
875  $parentId
876  ) {
877  // Record the edit in revisions
878  $revisionRow = [
879  'rev_page' => $rev->getPageId( $this->wikiId ),
880  'rev_parent_id' => $parentId,
881  'rev_minor_edit' => $rev->isMinor() ? 1 : 0,
882  'rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
883  'rev_deleted' => $rev->getVisibility(),
884  'rev_len' => $rev->getSize(),
885  'rev_sha1' => $rev->getSha1(),
886  ];
887 
888  if ( $rev->getId( $this->wikiId ) !== null ) {
889  // Needed to restore revisions with their original ID
890  $revisionRow['rev_id'] = $rev->getId( $this->wikiId );
891  }
892 
893  return $revisionRow;
894  }
895 
904  private function storeContentBlob(
905  SlotRecord $slot,
906  PageIdentity $page,
907  array $blobHints = []
908  ) {
909  $content = $slot->getContent();
910  $format = $content->getDefaultFormat();
911  $model = $content->getModel();
912 
913  $this->checkContent( $content, $page, $slot->getRole() );
914 
915  return $this->blobStore->storeBlob(
916  $content->serialize( $format ),
917  // These hints "leak" some information from the higher abstraction layer to
918  // low level storage to allow for optimization.
919  array_merge(
920  $blobHints,
921  [
922  BlobStore::DESIGNATION_HINT => 'page-content',
923  BlobStore::ROLE_HINT => $slot->getRole(),
924  BlobStore::SHA1_HINT => $slot->getSha1(),
925  BlobStore::MODEL_HINT => $model,
926  BlobStore::FORMAT_HINT => $format,
927  ]
928  )
929  );
930  }
931 
938  private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) {
939  $slotRow = [
940  'slot_revision_id' => $revisionId,
941  'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ),
942  'slot_content_id' => $contentId,
943  // If the slot has a specific origin use that ID, otherwise use the ID of the revision
944  // that we just inserted.
945  'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId,
946  ];
947  $dbw->insert( 'slots', $slotRow, __METHOD__ );
948  }
949 
956  private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) {
957  $contentRow = [
958  'content_size' => $slot->getSize(),
959  'content_sha1' => $slot->getSha1(),
960  'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ),
961  'content_address' => $blobAddress,
962  ];
963  $dbw->insert( 'content', $contentRow, __METHOD__ );
964  return intval( $dbw->insertId() );
965  }
966 
977  private function checkContent( Content $content, PageIdentity $page, string $role ) {
978  // Note: may return null for revisions that have not yet been inserted
979 
980  $model = $content->getModel();
981  $format = $content->getDefaultFormat();
982  $handler = $content->getContentHandler();
983 
984  if ( !$handler->isSupportedFormat( $format ) ) {
985  throw new MWException(
986  "Can't use format $format with content model $model on $page role $role"
987  );
988  }
989 
990  if ( !$content->isValid() ) {
991  throw new MWException(
992  "New content for $page role $role is not valid! Content model is $model"
993  );
994  }
995  }
996 
1022  public function newNullRevision(
1023  IDatabase $dbw,
1024  PageIdentity $page,
1025  CommentStoreComment $comment,
1026  $minor,
1027  UserIdentity $user
1028  ) {
1029  $this->checkDatabaseDomain( $dbw );
1030 
1031  $pageId = $this->getArticleId( $page );
1032 
1033  // T51581: Lock the page table row to ensure no other process
1034  // is adding a revision to the page at the same time.
1035  // Avoid locking extra tables, compare T191892.
1036  $pageLatest = $dbw->selectField(
1037  'page',
1038  'page_latest',
1039  [ 'page_id' => $pageId ],
1040  __METHOD__,
1041  [ 'FOR UPDATE' ]
1042  );
1043 
1044  if ( !$pageLatest ) {
1045  $msg = 'T235589: Failed to select table row during null revision creation' .
1046  " Page id '$pageId' does not exist.";
1047  $this->logger->error(
1048  $msg,
1049  [ 'exception' => new RuntimeException( $msg ) ]
1050  );
1051 
1052  return null;
1053  }
1054 
1055  // Fetch the actual revision row from primary DB, without locking all extra tables.
1056  $oldRevision = $this->loadRevisionFromConds(
1057  $dbw,
1058  [ 'rev_id' => intval( $pageLatest ) ],
1059  self::READ_LATEST,
1060  $page
1061  );
1062 
1063  if ( !$oldRevision ) {
1064  $msg = "Failed to load latest revision ID $pageLatest of page ID $pageId.";
1065  $this->logger->error(
1066  $msg,
1067  [ 'exception' => new RuntimeException( $msg ) ]
1068  );
1069  return null;
1070  }
1071 
1072  // Construct the new revision
1073  $timestamp = MWTimestamp::now( TS_MW );
1074  $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision );
1075 
1076  $newRevision->setComment( $comment );
1077  $newRevision->setUser( $user );
1078  $newRevision->setTimestamp( $timestamp );
1079  $newRevision->setMinorEdit( $minor );
1080 
1081  return $newRevision;
1082  }
1083 
1093  public function getRcIdIfUnpatrolled( RevisionRecord $rev ) {
1094  $rc = $this->getRecentChange( $rev );
1095  if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) {
1096  return $rc->getAttribute( 'rc_id' );
1097  } else {
1098  return 0;
1099  }
1100  }
1101 
1115  public function getRecentChange( RevisionRecord $rev, $flags = 0 ) {
1116  [ $dbType, ] = DBAccessObjectUtils::getDBOptions( $flags );
1117 
1119  [
1120  'rc_this_oldid' => $rev->getId( $this->wikiId ),
1121  // rc_this_oldid does not have to be unique,
1122  // in particular, it is shared with categorization
1123  // changes. Prefer the original change because callers
1124  // often expect a change for patrolling.
1125  'rc_type' => [ RC_EDIT, RC_NEW, RC_LOG ],
1126  ],
1127  __METHOD__,
1128  $dbType
1129  );
1130 
1131  // XXX: cache this locally? Glue it to the RevisionRecord?
1132  return $rc;
1133  }
1134 
1154  private function loadSlotContent(
1155  SlotRecord $slot,
1156  ?string $blobData = null,
1157  ?string $blobFlags = null,
1158  ?string $blobFormat = null,
1159  int $queryFlags = 0
1160  ) {
1161  if ( $blobData !== null ) {
1162  $blobAddress = $slot->hasAddress() ? $slot->getAddress() : null;
1163 
1164  if ( $blobFlags === null ) {
1165  // No blob flags, so use the blob verbatim.
1166  $data = $blobData;
1167  } else {
1168  try {
1169  $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $blobAddress );
1170  } catch ( BadBlobException $e ) {
1171  throw new BadRevisionException( $e->getMessage(), [], 0, $e );
1172  }
1173 
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' => $blobAddress,
1180  ]
1181  );
1182  }
1183  }
1184 
1185  } else {
1186  $address = $slot->getAddress();
1187  try {
1188  $data = $this->blobStore->getBlob( $address, $queryFlags );
1189  } catch ( BadBlobException $e ) {
1190  throw new BadRevisionException( $e->getMessage(), [], 0, $e );
1191  } catch ( BlobAccessException $e ) {
1192  throw new RevisionAccessException(
1193  'Failed to load data blob from {address} for revision {revision}. '
1194  . 'If this problem persist, use the findBadBlobs maintenance script '
1195  . 'to investigate the issue and mark bad blobs.',
1196  [ 'address' => $e->getMessage(), 'revision' => $slot->getRevision() ],
1197  0,
1198  $e
1199  );
1200  }
1201  }
1202 
1203  $model = $slot->getModel();
1204 
1205  // If the content model is not known, don't fail here (T220594, T220793, T228921)
1206  if ( !$this->contentHandlerFactory->isDefinedModel( $model ) ) {
1207  $this->logger->warning(
1208  "Undefined content model '$model', falling back to FallbackContent",
1209  [
1210  'content_address' => $slot->getAddress(),
1211  'rev_id' => $slot->getRevision(),
1212  'role_name' => $slot->getRole(),
1213  'model_name' => $model,
1214  'exception' => new RuntimeException()
1215  ]
1216  );
1217 
1218  return new FallbackContent( $data, $model );
1219  }
1220 
1221  return $this->contentHandlerFactory
1222  ->getContentHandler( $model )
1223  ->unserializeContent( $data, $blobFormat );
1224  }
1225 
1243  public function getRevisionById( $id, $flags = 0, PageIdentity $page = null ) {
1244  return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags, $page );
1245  }
1246 
1263  public function getRevisionByTitle( $page, $revId = 0, $flags = 0 ) {
1264  $conds = [
1265  'page_namespace' => $page->getNamespace(),
1266  'page_title' => $page->getDBkey()
1267  ];
1268 
1269  if ( $page instanceof LinkTarget ) {
1270  // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756)
1271  $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null;
1272  }
1273 
1274  if ( $revId ) {
1275  // Use the specified revision ID.
1276  // Note that we use newRevisionFromConds here because we want to retry
1277  // and fall back to primary DB if the page is not found on a replica.
1278  // Since the caller supplied a revision ID, we are pretty sure the revision is
1279  // supposed to exist, so we should try hard to find it.
1280  $conds['rev_id'] = $revId;
1281  return $this->newRevisionFromConds( $conds, $flags, $page );
1282  } else {
1283  // Use a join to get the latest revision.
1284  // Note that we don't use newRevisionFromConds here because we don't want to retry
1285  // and fall back to primary DB. The assumption is that we only want to force the fallback
1286  // if we are quite sure the revision exists because the caller supplied a revision ID.
1287  // If the page isn't found at all on a replica, it probably simply does not exist.
1288  $db = $this->getDBConnectionRefForQueryFlags( $flags );
1289  $conds[] = 'rev_id=page_latest';
1290  return $this->loadRevisionFromConds( $db, $conds, $flags, $page );
1291  }
1292  }
1293 
1310  public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) {
1311  $conds = [ 'page_id' => $pageId ];
1312  if ( $revId ) {
1313  // Use the specified revision ID.
1314  // Note that we use newRevisionFromConds here because we want to retry
1315  // and fall back to primary DB if the page is not found on a replica.
1316  // Since the caller supplied a revision ID, we are pretty sure the revision is
1317  // supposed to exist, so we should try hard to find it.
1318  $conds['rev_id'] = $revId;
1319  return $this->newRevisionFromConds( $conds, $flags );
1320  } else {
1321  // Use a join to get the latest revision.
1322  // Note that we don't use newRevisionFromConds here because we don't want to retry
1323  // and fall back to primary DB. The assumption is that we only want to force the fallback
1324  // if we are quite sure the revision exists because the caller supplied a revision ID.
1325  // If the page isn't found at all on a replica, it probably simply does not exist.
1326  $db = $this->getDBConnectionRefForQueryFlags( $flags );
1327 
1328  $conds[] = 'rev_id=page_latest';
1329 
1330  return $this->loadRevisionFromConds( $db, $conds, $flags );
1331  }
1332  }
1333 
1349  public function getRevisionByTimestamp(
1350  $page,
1351  string $timestamp,
1352  int $flags = IDBAccessObject::READ_NORMAL
1353  ): ?RevisionRecord {
1354  if ( $page instanceof LinkTarget ) {
1355  // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756)
1356  $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null;
1357  }
1358  $db = $this->getDBConnectionRefForQueryFlags( $flags );
1359  return $this->newRevisionFromConds(
1360  [
1361  'rev_timestamp' => $db->timestamp( $timestamp ),
1362  'page_namespace' => $page->getNamespace(),
1363  'page_title' => $page->getDBkey()
1364  ],
1365  $flags,
1366  $page
1367  );
1368  }
1369 
1377  private function loadSlotRecords( $revId, $queryFlags, PageIdentity $page ) {
1378  // TODO: Find a way to add NS_MODULE from Scribunto here
1379  if ( $page->getNamespace() !== NS_TEMPLATE ) {
1380  $res = $this->loadSlotRecordsFromDb( $revId, $queryFlags, $page );
1381  return $this->constructSlotRecords( $revId, $res, $queryFlags, $page );
1382  }
1383 
1384  // TODO: These caches should not be needed. See T297147#7563670
1385  $res = $this->localCache->getWithSetCallback(
1386  $this->localCache->makeKey(
1387  'revision-slots',
1388  $page->getWikiId(),
1389  $page->getId( $page->getWikiId() ),
1390  $revId
1391  ),
1392  $this->localCache::TTL_HOUR,
1393  function () use ( $revId, $queryFlags, $page ) {
1394  return $this->cache->getWithSetCallback(
1395  $this->cache->makeKey(
1396  'revision-slots',
1397  $page->getWikiId(),
1398  $page->getId( $page->getWikiId() ),
1399  $revId
1400  ),
1401  WANObjectCache::TTL_DAY,
1402  function () use ( $revId, $queryFlags, $page ) {
1403  $res = $this->loadSlotRecordsFromDb( $revId, $queryFlags, $page );
1404  if ( !$res ) {
1405  // Avoid caching
1406  return false;
1407  }
1408  return $res;
1409  }
1410  );
1411  }
1412  );
1413  if ( !$res ) {
1414  $res = [];
1415  }
1416 
1417  return $this->constructSlotRecords( $revId, $res, $queryFlags, $page );
1418  }
1419 
1420  private function loadSlotRecordsFromDb( $revId, $queryFlags, PageIdentity $page ): array {
1421  $revQuery = $this->getSlotsQueryInfo( [ 'content' ] );
1422 
1423  [ $dbMode, $dbOptions ] = DBAccessObjectUtils::getDBOptions( $queryFlags );
1424  $db = $this->getDBConnectionRef( $dbMode );
1425 
1426  $res = $db->select(
1427  $revQuery['tables'],
1428  $revQuery['fields'],
1429  [
1430  'slot_revision_id' => $revId,
1431  ],
1432  __METHOD__,
1433  $dbOptions,
1434  $revQuery['joins']
1435  );
1436 
1437  if ( !$res->numRows() && !( $queryFlags & self::READ_LATEST ) ) {
1438  // If we found no slots, try looking on the primary database (T212428, T252156)
1439  $this->logger->info(
1440  __METHOD__ . ' falling back to READ_LATEST.',
1441  [
1442  'revid' => $revId,
1443  'exception' => new RuntimeException(),
1444  ]
1445  );
1446  return $this->loadSlotRecordsFromDb(
1447  $revId,
1448  $queryFlags | self::READ_LATEST,
1449  $page
1450  );
1451  }
1452  return iterator_to_array( $res );
1453  }
1454 
1467  private function constructSlotRecords(
1468  $revId,
1469  $slotRows,
1470  $queryFlags,
1471  PageIdentity $page,
1472  $slotContents = null
1473  ) {
1474  $slots = [];
1475 
1476  foreach ( $slotRows as $row ) {
1477  // Resolve role names and model names from in-memory cache, if they were not joined in.
1478  if ( !isset( $row->role_name ) ) {
1479  $row->role_name = $this->slotRoleStore->getName( (int)$row->slot_role_id );
1480  }
1481 
1482  if ( !isset( $row->model_name ) ) {
1483  if ( isset( $row->content_model ) ) {
1484  $row->model_name = $this->contentModelStore->getName( (int)$row->content_model );
1485  } else {
1486  // We may get here if $row->model_name is set but null, perhaps because it
1487  // came from rev_content_model, which is NULL for the default model.
1488  $slotRoleHandler = $this->slotRoleRegistry->getRoleHandler( $row->role_name );
1489  $row->model_name = $slotRoleHandler->getDefaultModel( $page );
1490  }
1491  }
1492 
1493  // We may have a fake blob_data field from getSlotRowsForBatch(), use it!
1494  if ( isset( $row->blob_data ) ) {
1495  $slotContents[$row->content_address] = $row->blob_data;
1496  }
1497 
1498  $contentCallback = function ( SlotRecord $slot ) use ( $slotContents, $queryFlags ) {
1499  $blob = null;
1500  if ( isset( $slotContents[$slot->getAddress()] ) ) {
1501  $blob = $slotContents[$slot->getAddress()];
1502  if ( $blob instanceof Content ) {
1503  return $blob;
1504  }
1505  }
1506  return $this->loadSlotContent( $slot, $blob, null, null, $queryFlags );
1507  };
1508 
1509  $slots[$row->role_name] = new SlotRecord( $row, $contentCallback );
1510  }
1511 
1512  if ( !isset( $slots[SlotRecord::MAIN] ) ) {
1513  $this->logger->error(
1514  __METHOD__ . ': Main slot of revision not found in database. See T212428.',
1515  [
1516  'revid' => $revId,
1517  'queryFlags' => $queryFlags,
1518  'exception' => new RuntimeException(),
1519  ]
1520  );
1521 
1522  throw new RevisionAccessException(
1523  'Main slot of revision not found in database. See T212428.'
1524  );
1525  }
1526 
1527  return $slots;
1528  }
1529 
1544  private function newRevisionSlots(
1545  $revId,
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( (int)$row->ar_rev_id, $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( (int)$row->rev_id, $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  $slotIds = [];
2165  foreach ( $options['slots'] as $slot ) {
2166  try {
2167  $slotIds[] = $this->slotRoleStore->getId( $slot );
2168  } catch ( NameTableAccessException $exception ) {
2169  // Do not fail when slot has no id (unused slot)
2170  // This also means for this slot are never data in the database
2171  }
2172  }
2173  if ( $slotIds === [] ) {
2174  // Degenerate case: return no slots for each revision.
2175  $result->setResult( true, array_fill_keys( $revIds, [] ) );
2176  return $result;
2177  }
2178 
2179  $roleIdField = $slotQueryInfo['keys']['role_id'];
2180  $slotQueryConds[$roleIdField] = $slotIds;
2181  }
2182 
2183  $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
2184  $slotRows = $db->select(
2185  $slotQueryInfo['tables'],
2186  $slotQueryInfo['fields'],
2187  $slotQueryConds,
2188  __METHOD__,
2189  [],
2190  $slotQueryInfo['joins']
2191  );
2192 
2193  $slotContents = null;
2194  if ( $options['blobs'] ?? false ) {
2195  $blobAddresses = [];
2196  foreach ( $slotRows as $slotRow ) {
2197  $blobAddresses[] = $slotRow->content_address;
2198  }
2199  $slotContentFetchStatus = $this->blobStore
2200  ->getBlobBatch( $blobAddresses, $queryFlags );
2201  foreach ( $slotContentFetchStatus->getErrors() as $error ) {
2202  $result->warning( $error['message'], ...$error['params'] );
2203  }
2204  $slotContents = $slotContentFetchStatus->getValue();
2205  }
2206 
2207  $slotRowsByRevId = [];
2208  foreach ( $slotRows as $slotRow ) {
2209  if ( $slotContents === null ) {
2210  // nothing to do
2211  } elseif ( isset( $slotContents[$slotRow->content_address] ) ) {
2212  $slotRow->blob_data = $slotContents[$slotRow->content_address];
2213  } else {
2214  $result->warning(
2215  'internalerror_info',
2216  "Couldn't find blob data for rev {$slotRow->slot_revision_id}"
2217  );
2218  $slotRow->blob_data = null;
2219  }
2220 
2221  // conditional needed for SCHEMA_COMPAT_READ_OLD
2222  if ( !isset( $slotRow->role_name ) && isset( $slotRow->slot_role_id ) ) {
2223  $slotRow->role_name = $this->slotRoleStore->getName( (int)$slotRow->slot_role_id );
2224  }
2225 
2226  // conditional needed for SCHEMA_COMPAT_READ_OLD
2227  if ( !isset( $slotRow->model_name ) && isset( $slotRow->content_model ) ) {
2228  $slotRow->model_name = $this->contentModelStore->getName( (int)$slotRow->content_model );
2229  }
2230 
2231  $slotRowsByRevId[$slotRow->slot_revision_id][$slotRow->role_name] = $slotRow;
2232  }
2233 
2234  $result->setResult( true, $slotRowsByRevId );
2235  return $result;
2236  }
2237 
2258  public function getContentBlobsForBatch(
2259  $rowsOrIds,
2260  $slots = null,
2261  $queryFlags = 0
2262  ) {
2263  $result = $this->getSlotRowsForBatch(
2264  $rowsOrIds,
2265  [ 'slots' => $slots, 'blobs' => true ],
2266  $queryFlags
2267  );
2268 
2269  if ( $result->isOK() ) {
2270  // strip out all internal meta data that we don't want to expose
2271  foreach ( $result->value as $revId => $rowsByRole ) {
2272  foreach ( $rowsByRole as $role => $slotRow ) {
2273  if ( is_array( $slots ) && !in_array( $role, $slots ) ) {
2274  // In SCHEMA_COMPAT_READ_OLD mode we may get the main slot even
2275  // if we didn't ask for it.
2276  unset( $result->value[$revId][$role] );
2277  continue;
2278  }
2279 
2280  $result->value[$revId][$role] = (object)[
2281  'blob_data' => $slotRow->blob_data,
2282  'model_name' => $slotRow->model_name,
2283  ];
2284  }
2285  }
2286  }
2287 
2288  return $result;
2289  }
2290 
2307  private function newRevisionFromConds(
2308  array $conditions,
2309  int $flags = IDBAccessObject::READ_NORMAL,
2310  PageIdentity $page = null,
2311  array $options = []
2312  ) {
2313  $db = $this->getDBConnectionRefForQueryFlags( $flags );
2314  $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $page, $options );
2315 
2316  // Make sure new pending/committed revision are visible later on
2317  // within web requests to certain avoid bugs like T93866 and T94407.
2318  if ( !$rev
2319  && !( $flags & self::READ_LATEST )
2320  && $this->loadBalancer->hasStreamingReplicaServers()
2321  && $this->loadBalancer->hasOrMadeRecentPrimaryChanges()
2322  ) {
2323  $flags = self::READ_LATEST;
2324  $dbw = $this->getDBConnectionRef( DB_PRIMARY );
2325  $rev = $this->loadRevisionFromConds( $dbw, $conditions, $flags, $page, $options );
2326  }
2327 
2328  return $rev;
2329  }
2330 
2345  private function loadRevisionFromConds(
2346  IDatabase $db,
2347  array $conditions,
2348  int $flags = IDBAccessObject::READ_NORMAL,
2349  PageIdentity $page = null,
2350  array $options = []
2351  ) {
2352  $row = $this->fetchRevisionRowFromConds( $db, $conditions, $flags, $options );
2353  if ( $row ) {
2354  return $this->newRevisionFromRow( $row, $flags, $page );
2355  }
2356 
2357  return null;
2358  }
2359 
2367  private function checkDatabaseDomain( IReadableDatabase $db ) {
2368  $dbDomain = $db->getDomainID();
2369  $storeDomain = $this->loadBalancer->resolveDomainID( $this->wikiId );
2370  if ( $dbDomain === $storeDomain ) {
2371  return;
2372  }
2373 
2374  throw new MWException( "DB connection domain '$dbDomain' does not match '$storeDomain'" );
2375  }
2376 
2390  private function fetchRevisionRowFromConds(
2391  IDatabase $db,
2392  array $conditions,
2393  int $flags = IDBAccessObject::READ_NORMAL,
2394  array $options = []
2395  ) {
2396  $this->checkDatabaseDomain( $db );
2397 
2398  $revQuery = $this->getQueryInfo( [ 'page', 'user' ] );
2399  if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) {
2400  $options[] = 'FOR UPDATE';
2401  }
2402  return $db->selectRow(
2403  $revQuery['tables'],
2404  $revQuery['fields'],
2405  $conditions,
2406  __METHOD__,
2407  $options,
2408  $revQuery['joins']
2409  );
2410  }
2411 
2433  public function getQueryInfo( $options = [] ) {
2434  $ret = [
2435  'tables' => [],
2436  'fields' => [],
2437  'joins' => [],
2438  ];
2439 
2440  $ret['tables'][] = 'revision';
2441  $ret['fields'] = array_merge( $ret['fields'], [
2442  'rev_id',
2443  'rev_page',
2444  'rev_timestamp',
2445  'rev_minor_edit',
2446  'rev_deleted',
2447  'rev_len',
2448  'rev_parent_id',
2449  'rev_sha1',
2450  ] );
2451 
2452  $commentQuery = $this->commentStore->getJoin( 'rev_comment' );
2453  $ret['tables'] = array_merge( $ret['tables'], $commentQuery['tables'] );
2454  $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] );
2455  $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] );
2456 
2457  $actorQuery = $this->actorMigration->getJoin( 'rev_user' );
2458  $ret['tables'] = array_merge( $ret['tables'], $actorQuery['tables'] );
2459  $ret['fields'] = array_merge( $ret['fields'], $actorQuery['fields'] );
2460  $ret['joins'] = array_merge( $ret['joins'], $actorQuery['joins'] );
2461 
2462  if ( in_array( 'page', $options, true ) ) {
2463  $ret['tables'][] = 'page';
2464  $ret['fields'] = array_merge( $ret['fields'], [
2465  'page_namespace',
2466  'page_title',
2467  'page_id',
2468  'page_latest',
2469  'page_is_redirect',
2470  'page_len',
2471  ] );
2472  $ret['joins']['page'] = [ 'JOIN', [ 'page_id = rev_page' ] ];
2473  }
2474 
2475  if ( in_array( 'user', $options, true ) ) {
2476  $ret['tables'][] = 'user';
2477  $ret['fields'] = array_merge( $ret['fields'], [
2478  'user_name',
2479  ] );
2480  $u = $actorQuery['fields']['rev_user'];
2481  $ret['joins']['user'] = [ 'LEFT JOIN', [ "$u != 0", "user_id = $u" ] ];
2482  }
2483 
2484  if ( in_array( 'text', $options, true ) ) {
2485  throw new InvalidArgumentException(
2486  'The `text` option is no longer supported in MediaWiki 1.35 and later.'
2487  );
2488  }
2489 
2490  return $ret;
2491  }
2492 
2514  public function getSlotsQueryInfo( $options = [] ) {
2515  $ret = [
2516  'tables' => [],
2517  'fields' => [],
2518  'joins' => [],
2519  'keys' => [],
2520  ];
2521 
2522  $ret['keys']['rev_id'] = 'slot_revision_id';
2523  $ret['keys']['role_id'] = 'slot_role_id';
2524 
2525  $ret['tables'][] = 'slots';
2526  $ret['fields'] = array_merge( $ret['fields'], [
2527  'slot_revision_id',
2528  'slot_content_id',
2529  'slot_origin',
2530  'slot_role_id',
2531  ] );
2532 
2533  if ( in_array( 'role', $options, true ) ) {
2534  // Use left join to attach role name, so we still find the revision row even
2535  // if the role name is missing. This triggers a more obvious failure mode.
2536  $ret['tables'][] = 'slot_roles';
2537  $ret['joins']['slot_roles'] = [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ];
2538  $ret['fields'][] = 'role_name';
2539  }
2540 
2541  if ( in_array( 'content', $options, true ) ) {
2542  $ret['keys']['model_id'] = 'content_model';
2543 
2544  $ret['tables'][] = 'content';
2545  $ret['fields'] = array_merge( $ret['fields'], [
2546  'content_size',
2547  'content_sha1',
2548  'content_address',
2549  'content_model',
2550  ] );
2551  $ret['joins']['content'] = [ 'JOIN', [ 'slot_content_id = content_id' ] ];
2552 
2553  if ( in_array( 'model', $options, true ) ) {
2554  // Use left join to attach model name, so we still find the revision row even
2555  // if the model name is missing. This triggers a more obvious failure mode.
2556  $ret['tables'][] = 'content_models';
2557  $ret['joins']['content_models'] = [ 'LEFT JOIN', [ 'content_model = model_id' ] ];
2558  $ret['fields'][] = 'model_name';
2559  }
2560 
2561  }
2562 
2563  return $ret;
2564  }
2565 
2574  public function isRevisionRow( $row, string $table = '' ) {
2575  if ( !( $row instanceof stdClass ) ) {
2576  return false;
2577  }
2578  $queryInfo = $table === 'archive' ? $this->getArchiveQueryInfo() : $this->getQueryInfo();
2579  foreach ( $queryInfo['fields'] as $alias => $field ) {
2580  $name = is_numeric( $alias ) ? $field : $alias;
2581  if ( !property_exists( $row, $name ) ) {
2582  return false;
2583  }
2584  }
2585  return true;
2586  }
2587 
2606  public function getArchiveQueryInfo() {
2607  $commentQuery = $this->commentStore->getJoin( 'ar_comment' );
2608  $ret = [
2609  'tables' => [
2610  'archive',
2611  'archive_actor' => 'actor'
2612  ] + $commentQuery['tables'],
2613  'fields' => [
2614  'ar_id',
2615  'ar_page_id',
2616  'ar_namespace',
2617  'ar_title',
2618  'ar_rev_id',
2619  'ar_timestamp',
2620  'ar_minor_edit',
2621  'ar_deleted',
2622  'ar_len',
2623  'ar_parent_id',
2624  'ar_sha1',
2625  'ar_actor',
2626  'ar_user' => 'archive_actor.actor_user',
2627  'ar_user_text' => 'archive_actor.actor_name',
2628  ] + $commentQuery['fields'],
2629  'joins' => [
2630  'archive_actor' => [ 'JOIN', 'actor_id=ar_actor' ]
2631  ] + $commentQuery['joins'],
2632  ];
2633 
2634  return $ret;
2635  }
2636 
2646  public function getRevisionSizes( array $revIds ) {
2647  $dbr = $this->getDBConnectionRef( DB_REPLICA );
2648  $revLens = [];
2649  if ( !$revIds ) {
2650  return $revLens; // empty
2651  }
2652 
2653  $res = $dbr->select(
2654  'revision',
2655  [ 'rev_id', 'rev_len' ],
2656  [ 'rev_id' => $revIds ],
2657  __METHOD__
2658  );
2659 
2660  foreach ( $res as $row ) {
2661  $revLens[$row->rev_id] = intval( $row->rev_len );
2662  }
2663 
2664  return $revLens;
2665  }
2666 
2675  private function getRelativeRevision( RevisionRecord $rev, $flags, $dir ) {
2676  $op = $dir === 'next' ? '>' : '<';
2677  $sort = $dir === 'next' ? 'ASC' : 'DESC';
2678 
2679  $revisionIdValue = $rev->getId( $this->wikiId );
2680 
2681  if ( !$revisionIdValue || !$rev->getPageId( $this->wikiId ) ) {
2682  // revision is unsaved or otherwise incomplete
2683  return null;
2684  }
2685 
2686  if ( $rev instanceof RevisionArchiveRecord ) {
2687  // revision is deleted, so it's not part of the page history
2688  return null;
2689  }
2690 
2691  [ $dbType, ] = DBAccessObjectUtils::getDBOptions( $flags );
2692  $db = $this->getDBConnectionRef( $dbType );
2693 
2694  $ts = $rev->getTimestamp() ?? $this->getTimestampFromId( $revisionIdValue, $flags );
2695  if ( $ts === false ) {
2696  // XXX Should this be moved into getTimestampFromId?
2697  $ts = $db->selectField( 'archive', 'ar_timestamp',
2698  [ 'ar_rev_id' => $revisionIdValue ], __METHOD__ );
2699  if ( $ts === false ) {
2700  // XXX Is this reachable? How can we have a page id but no timestamp?
2701  return null;
2702  }
2703  }
2704 
2705  $revId = $db->selectField( 'revision', 'rev_id',
2706  [
2707  'rev_page' => $rev->getPageId( $this->wikiId ),
2708  $db->buildComparison( $op, [
2709  'rev_timestamp' => $db->timestamp( $ts ),
2710  'rev_id' => $revisionIdValue,
2711  ] ),
2712  ],
2713  __METHOD__,
2714  [
2715  'ORDER BY' => [ "rev_timestamp $sort", "rev_id $sort" ],
2716  'IGNORE INDEX' => 'rev_timestamp', // Probably needed for T159319
2717  ]
2718  );
2719 
2720  if ( $revId === false ) {
2721  return null;
2722  }
2723 
2724  return $this->getRevisionById( intval( $revId ), $flags );
2725  }
2726 
2741  public function getPreviousRevision( RevisionRecord $rev, $flags = self::READ_NORMAL ) {
2742  return $this->getRelativeRevision( $rev, $flags, 'prev' );
2743  }
2744 
2756  public function getNextRevision( RevisionRecord $rev, $flags = self::READ_NORMAL ) {
2757  return $this->getRelativeRevision( $rev, $flags, 'next' );
2758  }
2759 
2771  private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) {
2772  $this->checkDatabaseDomain( $db );
2773 
2774  if ( $rev->getPageId( $this->wikiId ) === null ) {
2775  return 0;
2776  }
2777  # Use page_latest if ID is not given
2778  if ( !$rev->getId( $this->wikiId ) ) {
2779  $prevId = $db->selectField(
2780  'page', 'page_latest',
2781  [ 'page_id' => $rev->getPageId( $this->wikiId ) ],
2782  __METHOD__
2783  );
2784  } else {
2785  $prevId = $db->selectField(
2786  'revision', 'rev_id',
2787  [ 'rev_page' => $rev->getPageId( $this->wikiId ), 'rev_id < ' . $rev->getId( $this->wikiId ) ],
2788  __METHOD__,
2789  [ 'ORDER BY' => 'rev_id DESC' ]
2790  );
2791  }
2792  return intval( $prevId );
2793  }
2794 
2807  public function getTimestampFromId( $id, $flags = 0 ) {
2808  if ( $id instanceof Title ) {
2809  // Old deprecated calling convention supported for backwards compatibility
2810  $id = $flags;
2811  $flags = func_num_args() > 2 ? func_get_arg( 2 ) : 0;
2812  }
2813 
2814  // T270149: Bail out if we know the query will definitely return false. Some callers are
2815  // passing RevisionRecord::getId() call directly as $id which can possibly return null.
2816  // Null $id or $id <= 0 will lead to useless query with WHERE clause of 'rev_id IS NULL'
2817  // or 'rev_id = 0', but 'rev_id' is always greater than zero and cannot be null.
2818  // @todo typehint $id and remove the null check
2819  if ( $id === null || $id <= 0 ) {
2820  return false;
2821  }
2822 
2823  $db = $this->getDBConnectionRefForQueryFlags( $flags );
2824 
2825  $timestamp =
2826  $db->selectField( 'revision', 'rev_timestamp', [ 'rev_id' => $id ], __METHOD__ );
2827 
2828  return ( $timestamp !== false ) ? MWTimestamp::convert( TS_MW, $timestamp ) : false;
2829  }
2830 
2840  public function countRevisionsByPageId( IReadableDatabase $db, $id ) {
2841  $this->checkDatabaseDomain( $db );
2842 
2843  $row = $db->selectRow( 'revision',
2844  [ 'revCount' => 'COUNT(*)' ],
2845  [ 'rev_page' => $id ],
2846  __METHOD__
2847  );
2848  if ( $row ) {
2849  return intval( $row->revCount );
2850  }
2851  return 0;
2852  }
2853 
2863  public function countRevisionsByTitle( IDatabase $db, PageIdentity $page ) {
2864  $id = $this->getArticleId( $page );
2865  if ( $id ) {
2866  return $this->countRevisionsByPageId( $db, $id );
2867  }
2868  return 0;
2869  }
2870 
2889  public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) {
2890  $this->checkDatabaseDomain( $db );
2891 
2892  if ( !$userId ) {
2893  return false;
2894  }
2895 
2896  $revQuery = $this->getQueryInfo();
2897  $res = $db->select(
2898  $revQuery['tables'],
2899  [
2900  'rev_user' => $revQuery['fields']['rev_user'],
2901  ],
2902  [
2903  'rev_page' => $pageId,
2904  'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) )
2905  ],
2906  __METHOD__,
2907  [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ],
2908  $revQuery['joins']
2909  );
2910  foreach ( $res as $row ) {
2911  if ( $row->rev_user != $userId ) {
2912  return false;
2913  }
2914  }
2915  return true;
2916  }
2917 
2931  public function getKnownCurrentRevision( PageIdentity $page, $revId = 0 ) {
2932  $db = $this->getDBConnectionRef( DB_REPLICA );
2933  $revIdPassed = $revId;
2934  $pageId = $this->getArticleId( $page );
2935  if ( !$pageId ) {
2936  return false;
2937  }
2938 
2939  if ( !$revId ) {
2940  if ( $page instanceof Title ) {
2941  $revId = $page->getLatestRevID();
2942  } else {
2943  $pageRecord = $this->pageStore->getPageByReference( $page );
2944  if ( $pageRecord ) {
2945  $revId = $pageRecord->getLatest( $this->getWikiId() );
2946  }
2947  }
2948  }
2949 
2950  if ( !$revId ) {
2951  $this->logger->warning(
2952  'No latest revision known for page {page} even though it exists with page ID {page_id}', [
2953  'page' => $page->__toString(),
2954  'page_id' => $pageId,
2955  'wiki_id' => $this->getWikiId() ?: 'local',
2956  ] );
2957  return false;
2958  }
2959 
2960  // Load the row from cache if possible. If not possible, populate the cache.
2961  // As a minor optimization, remember if this was a cache hit or miss.
2962  // We can sometimes avoid a database query later if this is a cache miss.
2963  $fromCache = true;
2964  $row = $this->cache->getWithSetCallback(
2965  // Page/rev IDs passed in from DB to reflect history merges
2966  $this->getRevisionRowCacheKey( $db, $pageId, $revId ),
2967  WANObjectCache::TTL_WEEK,
2968  function ( $curValue, &$ttl, array &$setOpts ) use (
2969  $db, $revId, &$fromCache
2970  ) {
2971  $setOpts += Database::getCacheSetOptions( $db );
2972  $row = $this->fetchRevisionRowFromConds( $db, [ 'rev_id' => intval( $revId ) ] );
2973  if ( $row ) {
2974  $fromCache = false;
2975  }
2976  return $row; // don't cache negatives
2977  }
2978  );
2979 
2980  // Reflect revision deletion and user renames.
2981  if ( $row ) {
2982  $title = $this->ensureRevisionRowMatchesPage( $row, $page, [
2983  'from_cache_flag' => $fromCache,
2984  'page_id_initial' => $pageId,
2985  'rev_id_used' => $revId,
2986  'rev_id_requested' => $revIdPassed,
2987  ] );
2988 
2989  return $this->newRevisionFromRow( $row, 0, $title, $fromCache );
2990  } else {
2991  return false;
2992  }
2993  }
2994 
3003  public function getFirstRevision(
3004  $page,
3005  int $flags = IDBAccessObject::READ_NORMAL
3006  ): ?RevisionRecord {
3007  if ( $page instanceof LinkTarget ) {
3008  // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756)
3009  $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null;
3010  }
3011  return $this->newRevisionFromConds(
3012  [
3013  'page_namespace' => $page->getNamespace(),
3014  'page_title' => $page->getDBkey()
3015  ],
3016  $flags,
3017  $page,
3018  [
3019  'ORDER BY' => [ 'rev_timestamp ASC', 'rev_id ASC' ],
3020  'IGNORE INDEX' => [ 'revision' => 'rev_timestamp' ], // See T159319
3021  ]
3022  );
3023  }
3024 
3036  private function getRevisionRowCacheKey( IDatabase $db, $pageId, $revId ) {
3037  return $this->cache->makeGlobalKey(
3038  self::ROW_CACHE_KEY,
3039  $db->getDomainID(),
3040  $pageId,
3041  $revId
3042  );
3043  }
3044 
3052  private function assertRevisionParameter( $paramName, $pageId, RevisionRecord $rev = null ) {
3053  if ( $rev ) {
3054  if ( $rev->getId( $this->wikiId ) === null ) {
3055  throw new InvalidArgumentException( "Unsaved {$paramName} revision passed" );
3056  }
3057  if ( $rev->getPageId( $this->wikiId ) !== $pageId ) {
3058  throw new InvalidArgumentException(
3059  "Revision {$rev->getId( $this->wikiId )} doesn't belong to page {$pageId}"
3060  );
3061  }
3062  }
3063  }
3064 
3079  private function getRevisionLimitConditions(
3080  IDatabase $dbr,
3081  RevisionRecord $old = null,
3082  RevisionRecord $new = null,
3083  $options = []
3084  ) {
3085  $options = (array)$options;
3086  if ( in_array( self::INCLUDE_OLD, $options ) || in_array( self::INCLUDE_BOTH, $options ) ) {
3087  $oldCmp = '>=';
3088  } else {
3089  $oldCmp = '>';
3090  }
3091  if ( in_array( self::INCLUDE_NEW, $options ) || in_array( self::INCLUDE_BOTH, $options ) ) {
3092  $newCmp = '<=';
3093  } else {
3094  $newCmp = '<';
3095  }
3096 
3097  $conds = [];
3098  if ( $old ) {
3099  $conds[] = $dbr->buildComparison( $oldCmp, [
3100  'rev_timestamp' => $dbr->timestamp( $old->getTimestamp() ),
3101  'rev_id' => $old->getId( $this->wikiId ),
3102  ] );
3103  }
3104  if ( $new ) {
3105  $conds[] = $dbr->buildComparison( $newCmp, [
3106  'rev_timestamp' => $dbr->timestamp( $new->getTimestamp() ),
3107  'rev_id' => $new->getId( $this->wikiId ),
3108  ] );
3109  }
3110  return $conds;
3111  }
3112 
3139  public function getRevisionIdsBetween(
3140  int $pageId,
3141  RevisionRecord $old = null,
3142  RevisionRecord $new = null,
3143  ?int $max = null,
3144  $options = [],
3145  ?string $order = null,
3146  int $flags = IDBAccessObject::READ_NORMAL
3147  ): array {
3148  $this->assertRevisionParameter( 'old', $pageId, $old );
3149  $this->assertRevisionParameter( 'new', $pageId, $new );
3150 
3151  $options = (array)$options;
3152  $includeOld = in_array( self::INCLUDE_OLD, $options ) ||
3153  in_array( self::INCLUDE_BOTH, $options );
3154  $includeNew = in_array( self::INCLUDE_NEW, $options ) ||
3155  in_array( self::INCLUDE_BOTH, $options );
3156 
3157  // No DB query needed if old and new are the same revision.
3158  // Can't check for consecutive revisions with 'getParentId' for a similar
3159  // optimization as edge cases exist when there are revisions between
3160  // a revision and it's parent. See T185167 for more details.
3161  if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) {
3162  return $includeOld || $includeNew ? [ $new->getId( $this->wikiId ) ] : [];
3163  }
3164 
3165  $db = $this->getDBConnectionRefForQueryFlags( $flags );
3166  $conds = array_merge(
3167  [
3168  'rev_page' => $pageId,
3169  $db->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . ' = 0'
3170  ],
3171  $this->getRevisionLimitConditions( $db, $old, $new, $options )
3172  );
3173 
3174  $queryOptions = [];
3175  if ( $order !== null ) {
3176  $queryOptions['ORDER BY'] = [ "rev_timestamp $order", "rev_id $order" ];
3177  }
3178  if ( $max !== null ) {
3179  $queryOptions['LIMIT'] = $max + 1; // extra to detect truncation
3180  }
3181 
3182  $values = $db->selectFieldValues(
3183  'revision',
3184  'rev_id',
3185  $conds,
3186  __METHOD__,
3187  $queryOptions
3188  );
3189  return array_map( 'intval', $values );
3190  }
3191 
3213  public function getAuthorsBetween(
3214  $pageId,
3215  RevisionRecord $old = null,
3216  RevisionRecord $new = null,
3217  Authority $performer = null,
3218  $max = null,
3219  $options = []
3220  ) {
3221  $this->assertRevisionParameter( 'old', $pageId, $old );
3222  $this->assertRevisionParameter( 'new', $pageId, $new );
3223  $options = (array)$options;
3224 
3225  // No DB query needed if old and new are the same revision.
3226  // Can't check for consecutive revisions with 'getParentId' for a similar
3227  // optimization as edge cases exist when there are revisions between
3228  //a revision and it's parent. See T185167 for more details.
3229  if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) {
3230  if ( empty( $options ) ) {
3231  return [];
3232  } elseif ( $performer ) {
3233  return [ $new->getUser( RevisionRecord::FOR_THIS_USER, $performer ) ];
3234  } else {
3235  return [ $new->getUser() ];
3236  }
3237  }
3238 
3239  $dbr = $this->getDBConnectionRef( DB_REPLICA );
3240  $conds = array_merge(
3241  [
3242  'rev_page' => $pageId,
3243  $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER ) . " = 0"
3244  ],
3245  $this->getRevisionLimitConditions( $dbr, $old, $new, $options )
3246  );
3247 
3248  $queryOpts = [ 'DISTINCT' ];
3249  if ( $max !== null ) {
3250  $queryOpts['LIMIT'] = $max + 1;
3251  }
3252 
3253  $actorQuery = $this->actorMigration->getJoin( 'rev_user' );
3254  return array_map( function ( $row ) {
3255  return $this->actorStore->newActorFromRowFields(
3256  $row->rev_user,
3257  $row->rev_user_text,
3258  $row->rev_actor
3259  );
3260  }, iterator_to_array( $dbr->select(
3261  array_merge( [ 'revision' ], $actorQuery['tables'] ),
3262  $actorQuery['fields'],
3263  $conds, __METHOD__,
3264  $queryOpts,
3265  $actorQuery['joins']
3266  ) ) );
3267  }
3268 
3290  public function countAuthorsBetween(
3291  $pageId,
3292  RevisionRecord $old = null,
3293  RevisionRecord $new = null,
3294  Authority $performer = null,
3295  $max = null,
3296  $options = []
3297  ) {
3298  // TODO: Implement with a separate query to avoid cost of selecting unneeded fields
3299  // and creation of UserIdentity stuff.
3300  return count( $this->getAuthorsBetween( $pageId, $old, $new, $performer, $max, $options ) );
3301  }
3302 
3323  public function countRevisionsBetween(
3324  $pageId,
3325  RevisionRecord $old = null,
3326  RevisionRecord $new = null,
3327  $max = null,
3328  $options = []
3329  ) {
3330  $this->assertRevisionParameter( 'old', $pageId, $old );
3331  $this->assertRevisionParameter( 'new', $pageId, $new );
3332 
3333  // No DB query needed if old and new are the same revision.
3334  // Can't check for consecutive revisions with 'getParentId' for a similar
3335  // optimization as edge cases exist when there are revisions between
3336  //a revision and it's parent. See T185167 for more details.
3337  if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) {
3338  return 0;
3339  }
3340 
3341  $dbr = $this->getDBConnectionRef( DB_REPLICA );
3342  $conds = array_merge(
3343  [
3344  'rev_page' => $pageId,
3345  $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
3346  ],
3347  $this->getRevisionLimitConditions( $dbr, $old, $new, $options )
3348  );
3349  if ( $max !== null ) {
3350  return $dbr->selectRowCount( 'revision', '1',
3351  $conds,
3352  __METHOD__,
3353  [ 'LIMIT' => $max + 1 ] // extra to detect truncation
3354  );
3355  } else {
3356  return (int)$dbr->selectField( 'revision', 'count(*)', $conds, __METHOD__ );
3357  }
3358  }
3359 
3371  public function findIdenticalRevision(
3372  RevisionRecord $revision,
3373  int $searchLimit
3374  ): ?RevisionRecord {
3375  $revision->assertWiki( $this->wikiId );
3376  $db = $this->getDBConnectionRef( DB_REPLICA );
3377  $revQuery = $this->getQueryInfo();
3378  $subquery = $db->buildSelectSubquery(
3379  $revQuery['tables'],
3380  $revQuery['fields'],
3381  [ 'rev_page' => $revision->getPageId( $this->wikiId ) ],
3382  __METHOD__,
3383  [
3384  'ORDER BY' => [
3385  'rev_timestamp DESC',
3386  // for cases where there are multiple revs with same timestamp
3387  'rev_id DESC'
3388  ],
3389  'LIMIT' => $searchLimit,
3390  // skip the most recent edit, we can't revert to it anyway
3391  'OFFSET' => 1
3392  ],
3393  $revQuery['joins']
3394  );
3395 
3396  // selectRow effectively uses LIMIT 1 clause, returning only the first result
3397  $revisionRow = $db->selectRow(
3398  [ 'recent_revs' => $subquery ],
3399  '*',
3400  [ 'rev_sha1' => $revision->getSha1() ],
3401  __METHOD__
3402  );
3403 
3404  return $revisionRow ? $this->newRevisionFromRow( $revisionRow ) : null;
3405  }
3406 
3407  // TODO: move relevant methods from Title here, e.g. isBigDeletion, etc.
3408 }
const RC_NEW
Definition: Defines.php:117
const NS_TEMPLATE
Definition: Defines.php:74
const RC_LOG
Definition: Defines.php:118
const RC_EDIT
Definition: Defines.php:116
wfDeprecatedMsg( $msg, $version=false, $component=false, $callerOffset=2)
Log a deprecation warning with arbitrary message text.
if(!defined('MW_SETUP_CALLBACK'))
Definition: WebStart.php:88
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:32
Library for creating and parsing MW-style timestamps.
Definition: MWTimestamp.php:41
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:565
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.
countRevisionsByPageId(IReadableDatabase $db, $id)
Get count of revisions per page...not very efficient.
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.
__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
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
getModel()
Returns the content model.
Definition: SlotRecord.php:586
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.
Exception representing a failure to look up a row from a name table.
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.
Represents a title within MediaWiki.
Definition: Title.php:82
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
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:37
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:36
doAtomicSection( $fname, callable $callback, $cancelable=self::ATOMIC_NOT_CANCELABLE)
Perform an atomic section of reversible SQL statements from a callback.
This class is a delegate to ILBFactory for a given database cluster.
A database connection without write operations.
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.
selectRow( $table, $vars, $conds, $fname=__METHOD__, $options=[], $join_conds=[])
Wrapper to IDatabase::select() that only fetches one row (via LIMIT)
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