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