MediaWiki  master
RevisionStore.php
Go to the documentation of this file.
1 <?php
27 namespace MediaWiki\Revision;
28 
32 use Content;
35 use Hooks;
38 use IP;
48 use Message;
58 use Title;
59 use User;
66 
77  implements IDBAccessObject, RevisionFactory, RevisionLookup, LoggerAwareInterface {
78 
79  const ROW_CACHE_KEY = 'revision-row-1.29';
80 
84  private $blobStore;
85 
89  private $wikiId;
90 
95  private $contentHandlerUseDB = true;
96 
100  private $loadBalancer;
101 
105  private $cache;
106 
110  private $commentStore;
111 
116 
120  private $logger;
121 
126 
130  private $slotRoleStore;
131 
134 
137 
158  public function __construct(
159  ILoadBalancer $loadBalancer,
160  SqlBlobStore $blobStore,
163  NameTableStore $contentModelStore,
164  NameTableStore $slotRoleStore,
168  $wikiId = false
169  ) {
170  Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' );
171  Assert::parameterType( 'integer', $mcrMigrationStage, '$mcrMigrationStage' );
172  Assert::parameter(
173  ( $mcrMigrationStage & SCHEMA_COMPAT_READ_BOTH ) !== SCHEMA_COMPAT_READ_BOTH,
174  '$mcrMigrationStage',
175  'Reading from the old and the new schema at the same time is not supported.'
176  );
177  Assert::parameter(
178  ( $mcrMigrationStage & SCHEMA_COMPAT_READ_BOTH ) !== 0,
179  '$mcrMigrationStage',
180  'Reading needs to be enabled for the old or the new schema.'
181  );
182  Assert::parameter(
184  '$mcrMigrationStage',
185  'Writing needs to be enabled for the old or the new schema.'
186  );
187  Assert::parameter(
190  '$mcrMigrationStage',
191  'Cannot read the old schema when not also writing it.'
192  );
193  Assert::parameter(
196  '$mcrMigrationStage',
197  'Cannot read the new schema when not also writing it.'
198  );
199 
200  $this->loadBalancer = $loadBalancer;
201  $this->blobStore = $blobStore;
202  $this->cache = $cache;
203  $this->commentStore = $commentStore;
204  $this->contentModelStore = $contentModelStore;
205  $this->slotRoleStore = $slotRoleStore;
206  $this->slotRoleRegistry = $slotRoleRegistry;
207  $this->mcrMigrationStage = $mcrMigrationStage;
208  $this->actorMigration = $actorMigration;
209  $this->wikiId = $wikiId;
210  $this->logger = new NullLogger();
211  }
212 
218  private function hasMcrSchemaFlags( $flags ) {
219  return ( $this->mcrMigrationStage & $flags ) === $flags;
220  }
221 
229  if ( $this->wikiId !== false && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
230  throw new RevisionAccessException(
231  "Cross-wiki content loading is not supported by the pre-MCR schema"
232  );
233  }
234  }
235 
236  public function setLogger( LoggerInterface $logger ) {
237  $this->logger = $logger;
238  }
239 
243  public function isReadOnly() {
244  return $this->blobStore->isReadOnly();
245  }
246 
250  public function getContentHandlerUseDB() {
252  }
253 
262  ) {
263  if ( !$contentHandlerUseDB ) {
264  throw new MWException(
265  'Content model must be stored in the database for multi content revision migration.'
266  );
267  }
268  }
269  $this->contentHandlerUseDB = $contentHandlerUseDB;
270  }
271 
275  private function getDBLoadBalancer() {
276  return $this->loadBalancer;
277  }
278 
285  private function getDBConnection( $mode, $groups = [] ) {
286  $lb = $this->getDBLoadBalancer();
287  return $lb->getConnection( $mode, $groups, $this->wikiId );
288  }
289 
295  private function getDBConnectionRefForQueryFlags( $queryFlags ) {
296  list( $mode, ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
297  return $this->getDBConnectionRef( $mode );
298  }
299 
303  private function releaseDBConnection( IDatabase $connection ) {
304  $lb = $this->getDBLoadBalancer();
305  $lb->reuseConnection( $connection );
306  }
307 
313  private function getDBConnectionRef( $mode ) {
314  $lb = $this->getDBLoadBalancer();
315  return $lb->getConnectionRef( $mode, [], $this->wikiId );
316  }
317 
332  public function getTitle( $pageId, $revId, $queryFlags = self::READ_NORMAL ) {
333  if ( !$pageId && !$revId ) {
334  throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' );
335  }
336 
337  // This method recalls itself with READ_LATEST if READ_NORMAL doesn't get us a Title
338  // So ignore READ_LATEST_IMMUTABLE flags and handle the fallback logic in this method
339  if ( DBAccessObjectUtils::hasFlags( $queryFlags, self::READ_LATEST_IMMUTABLE ) ) {
340  $queryFlags = self::READ_NORMAL;
341  }
342 
343  $canUseTitleNewFromId = ( $pageId !== null && $pageId > 0 && $this->wikiId === false );
344  list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
345  $titleFlags = ( $dbMode == DB_MASTER ? Title::GAID_FOR_UPDATE : 0 );
346 
347  // Loading by ID is best, but Title::newFromID does not support that for foreign IDs.
348  if ( $canUseTitleNewFromId ) {
349  // TODO: better foreign title handling (introduce TitleFactory)
350  $title = Title::newFromID( $pageId, $titleFlags );
351  if ( $title ) {
352  return $title;
353  }
354  }
355 
356  // rev_id is defined as NOT NULL, but this revision may not yet have been inserted.
357  $canUseRevId = ( $revId !== null && $revId > 0 );
358 
359  if ( $canUseRevId ) {
360  $dbr = $this->getDBConnectionRef( $dbMode );
361  // @todo: Title::getSelectFields(), or Title::getQueryInfo(), or something like that
362  $row = $dbr->selectRow(
363  [ 'revision', 'page' ],
364  [
365  'page_namespace',
366  'page_title',
367  'page_id',
368  'page_latest',
369  'page_is_redirect',
370  'page_len',
371  ],
372  [ 'rev_id' => $revId ],
373  __METHOD__,
374  $dbOptions,
375  [ 'page' => [ 'JOIN', 'page_id=rev_page' ] ]
376  );
377  if ( $row ) {
378  // TODO: better foreign title handling (introduce TitleFactory)
379  return Title::newFromRow( $row );
380  }
381  }
382 
383  // If we still don't have a title, fallback to master if that wasn't already happening.
384  if ( $dbMode !== DB_MASTER ) {
385  $title = $this->getTitle( $pageId, $revId, self::READ_LATEST );
386  if ( $title ) {
387  $this->logger->info(
388  __METHOD__ . ' fell back to READ_LATEST and got a Title.',
389  [ 'trace' => wfBacktrace() ]
390  );
391  return $title;
392  }
393  }
394 
395  throw new RevisionAccessException(
396  "Could not determine title for page ID $pageId and revision ID $revId"
397  );
398  }
399 
407  private function failOnNull( $value, $name ) {
408  if ( $value === null ) {
409  throw new IncompleteRevisionException(
410  "$name must not be " . var_export( $value, true ) . "!"
411  );
412  }
413 
414  return $value;
415  }
416 
424  private function failOnEmpty( $value, $name ) {
425  if ( $value === null || $value === 0 || $value === '' ) {
426  throw new IncompleteRevisionException(
427  "$name must not be " . var_export( $value, true ) . "!"
428  );
429  }
430 
431  return $value;
432  }
433 
446  public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) {
447  // TODO: pass in a DBTransactionContext instead of a database connection.
448  $this->checkDatabaseWikiId( $dbw );
449 
450  $slotRoles = $rev->getSlotRoles();
451 
452  // Make sure the main slot is always provided throughout migration
453  if ( !in_array( SlotRecord::MAIN, $slotRoles ) ) {
454  throw new InvalidArgumentException(
455  'main slot must be provided'
456  );
457  }
458 
459  // If we are not writing into the new schema, we can't support extra slots.
461  && $slotRoles !== [ SlotRecord::MAIN ]
462  ) {
463  throw new InvalidArgumentException(
464  'Only the main slot is supported when not writing to the MCR enabled schema!'
465  );
466  }
467 
468  // As long as we are not reading from the new schema, we don't want to write extra slots.
470  && $slotRoles !== [ SlotRecord::MAIN ]
471  ) {
472  throw new InvalidArgumentException(
473  'Only the main slot is supported when not reading from the MCR enabled schema!'
474  );
475  }
476 
477  // Checks
478  $this->failOnNull( $rev->getSize(), 'size field' );
479  $this->failOnEmpty( $rev->getSha1(), 'sha1 field' );
480  $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' );
481  $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' );
482  $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' );
483  $this->failOnNull( $user->getId(), 'user field' );
484  $this->failOnEmpty( $user->getName(), 'user_text field' );
485 
486  if ( !$rev->isReadyForInsertion() ) {
487  // This is here for future-proofing. At the time this check being added, it
488  // was redundant to the individual checks above.
489  throw new IncompleteRevisionException( 'Revision is incomplete' );
490  }
491 
492  // TODO: we shouldn't need an actual Title here.
494  $pageId = $this->failOnEmpty( $rev->getPageId(), 'rev_page field' ); // check this early
495 
496  $parentId = $rev->getParentId() === null
497  ? $this->getPreviousRevisionId( $dbw, $rev )
498  : $rev->getParentId();
499 
501  $rev = $dbw->doAtomicSection(
502  __METHOD__,
503  function ( IDatabase $dbw, $fname ) use (
504  $rev,
505  $user,
506  $comment,
507  $title,
508  $pageId,
509  $parentId
510  ) {
511  return $this->insertRevisionInternal(
512  $rev,
513  $dbw,
514  $user,
515  $comment,
516  $title,
517  $pageId,
518  $parentId
519  );
520  }
521  );
522 
523  // sanity checks
524  Assert::postcondition( $rev->getId() > 0, 'revision must have an ID' );
525  Assert::postcondition( $rev->getPageId() > 0, 'revision must have a page ID' );
526  Assert::postcondition(
527  $rev->getComment( RevisionRecord::RAW ) !== null,
528  'revision must have a comment'
529  );
530  Assert::postcondition(
531  $rev->getUser( RevisionRecord::RAW ) !== null,
532  'revision must have a user'
533  );
534 
535  // Trigger exception if the main slot is missing.
536  // Technically, this could go away after MCR migration: while
537  // calling code may require a main slot to exist, RevisionStore
538  // really should not know or care about that requirement.
540 
541  foreach ( $slotRoles as $role ) {
542  $slot = $rev->getSlot( $role, RevisionRecord::RAW );
543  Assert::postcondition(
544  $slot->getContent() !== null,
545  $role . ' slot must have content'
546  );
547  Assert::postcondition(
548  $slot->hasRevision(),
549  $role . ' slot must have a revision associated'
550  );
551  }
552 
553  Hooks::run( 'RevisionRecordInserted', [ $rev ] );
554 
555  // TODO: deprecate in 1.32!
556  $legacyRevision = new Revision( $rev );
557  Hooks::run( 'RevisionInsertComplete', [ &$legacyRevision, null, null ] );
558 
559  return $rev;
560  }
561 
562  private function insertRevisionInternal(
564  IDatabase $dbw,
565  User $user,
566  CommentStoreComment $comment,
567  Title $title,
568  $pageId,
569  $parentId
570  ) {
571  $slotRoles = $rev->getSlotRoles();
572 
573  $revisionRow = $this->insertRevisionRowOn(
574  $dbw,
575  $rev,
576  $title,
577  $parentId
578  );
579 
580  $revisionId = $revisionRow['rev_id'];
581 
582  $blobHints = [
583  BlobStore::PAGE_HINT => $pageId,
584  BlobStore::REVISION_HINT => $revisionId,
585  BlobStore::PARENT_HINT => $parentId,
586  ];
587 
588  $newSlots = [];
589  foreach ( $slotRoles as $role ) {
590  $slot = $rev->getSlot( $role, RevisionRecord::RAW );
591 
592  // If the SlotRecord already has a revision ID set, this means it already exists
593  // in the database, and should already belong to the current revision.
594  // However, a slot may already have a revision, but no content ID, if the slot
595  // is emulated based on the archive table, because we are in SCHEMA_COMPAT_READ_OLD
596  // mode, and the respective archive row was not yet migrated to the new schema.
597  // In that case, a new slot row (and content row) must be inserted even during
598  // undeletion.
599  if ( $slot->hasRevision() && $slot->hasContentId() ) {
600  // TODO: properly abort transaction if the assertion fails!
601  Assert::parameter(
602  $slot->getRevision() === $revisionId,
603  'slot role ' . $slot->getRole(),
604  'Existing slot should belong to revision '
605  . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!'
606  );
607 
608  // Slot exists, nothing to do, move along.
609  // This happens when restoring archived revisions.
610 
611  $newSlots[$role] = $slot;
612 
613  // Write the main slot's text ID to the revision table for backwards compatibility
614  if ( $slot->getRole() === SlotRecord::MAIN
616  ) {
617  $blobAddress = $slot->getAddress();
618  $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress );
619  }
620  } else {
621  $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $title, $blobHints );
622  }
623  }
624 
625  $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId );
626 
627  $rev = new RevisionStoreRecord(
628  $title,
629  $user,
630  $comment,
631  (object)$revisionRow,
632  new RevisionSlots( $newSlots ),
633  $this->wikiId
634  );
635 
636  return $rev;
637  }
638 
646  private function updateRevisionTextId( IDatabase $dbw, $revisionId, &$blobAddress ) {
647  $textId = $this->blobStore->getTextIdFromAddress( $blobAddress );
648  if ( !$textId ) {
649  throw new LogicException(
650  'Blob address not supported in 1.29 database schema: ' . $blobAddress
651  );
652  }
653 
654  // getTextIdFromAddress() is free to insert something into the text table, so $textId
655  // may be a new value, not anything already contained in $blobAddress.
656  $blobAddress = SqlBlobStore::makeAddressFromTextId( $textId );
657 
658  $dbw->update(
659  'revision',
660  [ 'rev_text_id' => $textId ],
661  [ 'rev_id' => $revisionId ],
662  __METHOD__
663  );
664 
665  return $textId;
666  }
667 
676  private function insertSlotOn(
677  IDatabase $dbw,
678  $revisionId,
679  SlotRecord $protoSlot,
680  Title $title,
681  array $blobHints = []
682  ) {
683  if ( $protoSlot->hasAddress() ) {
684  $blobAddress = $protoSlot->getAddress();
685  } else {
686  $blobAddress = $this->storeContentBlob( $protoSlot, $title, $blobHints );
687  }
688 
689  $contentId = null;
690 
691  // Write the main slot's text ID to the revision table for backwards compatibility
692  if ( $protoSlot->getRole() === SlotRecord::MAIN
694  ) {
695  // If SCHEMA_COMPAT_WRITE_NEW is also set, the fake content ID is overwritten
696  // with the real content ID below.
697  $textId = $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress );
698  $contentId = $this->emulateContentId( $textId );
699  }
700 
701  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
702  if ( $protoSlot->hasContentId() ) {
703  $contentId = $protoSlot->getContentId();
704  } else {
705  $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress );
706  }
707 
708  $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId );
709  }
710 
711  $savedSlot = SlotRecord::newSaved(
712  $revisionId,
713  $contentId,
714  $blobAddress,
715  $protoSlot
716  );
717 
718  return $savedSlot;
719  }
720 
728  private function insertIpChangesRow(
729  IDatabase $dbw,
730  User $user,
732  $revisionId
733  ) {
734  if ( $user->getId() === 0 && IP::isValid( $user->getName() ) ) {
735  $ipcRow = [
736  'ipc_rev_id' => $revisionId,
737  'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
738  'ipc_hex' => IP::toHex( $user->getName() ),
739  ];
740  $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ );
741  }
742  }
743 
755  private function insertRevisionRowOn(
756  IDatabase $dbw,
758  Title $title,
759  $parentId
760  ) {
761  $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $title, $parentId );
762 
763  list( $commentFields, $commentCallback ) =
764  $this->commentStore->insertWithTempTable(
765  $dbw,
766  'rev_comment',
768  );
769  $revisionRow += $commentFields;
770 
771  list( $actorFields, $actorCallback ) =
772  $this->actorMigration->getInsertValuesWithTempTable(
773  $dbw,
774  'rev_user',
776  );
777  $revisionRow += $actorFields;
778 
779  $dbw->insert( 'revision', $revisionRow, __METHOD__ );
780 
781  if ( !isset( $revisionRow['rev_id'] ) ) {
782  // only if auto-increment was used
783  $revisionRow['rev_id'] = intval( $dbw->insertId() );
784 
785  if ( $dbw->getType() === 'mysql' ) {
786  // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the
787  // auto-increment value to disk, so on server restart it might reuse IDs from deleted
788  // revisions. We can fix that with an insert with an explicit rev_id value, if necessary.
789 
790  $maxRevId = intval( $dbw->selectField( 'archive', 'MAX(ar_rev_id)', '', __METHOD__ ) );
791  $table = 'archive';
792  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
793  $maxRevId2 = intval( $dbw->selectField( 'slots', 'MAX(slot_revision_id)', '', __METHOD__ ) );
794  if ( $maxRevId2 >= $maxRevId ) {
795  $maxRevId = $maxRevId2;
796  $table = 'slots';
797  }
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( function ( $trigger, $dbw ) use ( $fname ) {
816  $dbw->unlock( 'fix-for-T202032', $fname );
817  } );
818 
819  $dbw->delete( 'revision', [ 'rev_id' => $revisionRow['rev_id'] ], __METHOD__ );
820 
821  // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction
822  // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing
823  // inserts too, though, at least on MariaDB 10.1.29.
824  //
825  // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent
826  // transactions in this code path thanks to the row lock from the original ->insert() above.
827  //
828  // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning
829  // that's for non-MySQL DBs.
830  $row1 = $dbw->query(
831  $dbw->selectSQLText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE'
832  )->fetchObject();
833  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
834  $row2 = $dbw->query(
835  $dbw->selectSQLText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ )
836  . ' FOR UPDATE'
837  )->fetchObject();
838  } else {
839  $row2 = null;
840  }
841  $maxRevId = max(
842  $maxRevId,
843  $row1 ? intval( $row1->v ) : 0,
844  $row2 ? intval( $row2->v ) : 0
845  );
846 
847  // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent
848  // transactions will throw a duplicate key error here. It doesn't seem worth trying
849  // to avoid that.
850  $revisionRow['rev_id'] = $maxRevId + 1;
851  $dbw->insert( 'revision', $revisionRow, __METHOD__ );
852  }
853  }
854  }
855 
856  $commentCallback( $revisionRow['rev_id'] );
857  $actorCallback( $revisionRow['rev_id'], $revisionRow );
858 
859  return $revisionRow;
860  }
861 
872  private function getBaseRevisionRow(
873  IDatabase $dbw,
875  Title $title,
876  $parentId
877  ) {
878  // Record the edit in revisions
879  $revisionRow = [
880  'rev_page' => $rev->getPageId(),
881  'rev_parent_id' => $parentId,
882  'rev_minor_edit' => $rev->isMinor() ? 1 : 0,
883  'rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
884  'rev_deleted' => $rev->getVisibility(),
885  'rev_len' => $rev->getSize(),
886  'rev_sha1' => $rev->getSha1(),
887  ];
888 
889  if ( $rev->getId() !== null ) {
890  // Needed to restore revisions with their original ID
891  $revisionRow['rev_id'] = $rev->getId();
892  }
893 
894  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) ) {
895  // In non MCR mode this IF section will relate to the main slot
896  $mainSlot = $rev->getSlot( SlotRecord::MAIN );
897  $model = $mainSlot->getModel();
898  $format = $mainSlot->getFormat();
899 
900  // MCR migration note: rev_content_model and rev_content_format will go away
901  if ( $this->contentHandlerUseDB ) {
903 
904  $defaultModel = ContentHandler::getDefaultModelFor( $title );
905  $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat();
906 
907  $revisionRow['rev_content_model'] = ( $model === $defaultModel ) ? null : $model;
908  $revisionRow['rev_content_format'] = ( $format === $defaultFormat ) ? null : $format;
909  }
910  }
911 
912  return $revisionRow;
913  }
914 
923  private function storeContentBlob(
924  SlotRecord $slot,
925  Title $title,
926  array $blobHints = []
927  ) {
928  $content = $slot->getContent();
929  $format = $content->getDefaultFormat();
930  $model = $content->getModel();
931 
932  $this->checkContent( $content, $title, $slot->getRole() );
933 
934  return $this->blobStore->storeBlob(
935  $content->serialize( $format ),
936  // These hints "leak" some information from the higher abstraction layer to
937  // low level storage to allow for optimization.
938  array_merge(
939  $blobHints,
940  [
941  BlobStore::DESIGNATION_HINT => 'page-content',
942  BlobStore::ROLE_HINT => $slot->getRole(),
943  BlobStore::SHA1_HINT => $slot->getSha1(),
944  BlobStore::MODEL_HINT => $model,
945  BlobStore::FORMAT_HINT => $format,
946  ]
947  )
948  );
949  }
950 
957  private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) {
958  $slotRow = [
959  'slot_revision_id' => $revisionId,
960  'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ),
961  'slot_content_id' => $contentId,
962  // If the slot has a specific origin use that ID, otherwise use the ID of the revision
963  // that we just inserted.
964  'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId,
965  ];
966  $dbw->insert( 'slots', $slotRow, __METHOD__ );
967  }
968 
975  private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) {
976  $contentRow = [
977  'content_size' => $slot->getSize(),
978  'content_sha1' => $slot->getSha1(),
979  'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ),
980  'content_address' => $blobAddress,
981  ];
982  $dbw->insert( 'content', $contentRow, __METHOD__ );
983  return intval( $dbw->insertId() );
984  }
985 
996  private function checkContent( Content $content, Title $title, $role ) {
997  // Note: may return null for revisions that have not yet been inserted
998 
999  $model = $content->getModel();
1000  $format = $content->getDefaultFormat();
1001  $handler = $content->getContentHandler();
1002 
1003  $name = "$title";
1004 
1005  if ( !$handler->isSupportedFormat( $format ) ) {
1006  throw new MWException( "Can't use format $format with content model $model on $name" );
1007  }
1008 
1009  if ( !$this->contentHandlerUseDB ) {
1010  // if $wgContentHandlerUseDB is not set,
1011  // all revisions must use the default content model and format.
1012 
1014 
1015  $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
1016  $defaultModel = $roleHandler->getDefaultModel( $title );
1017  $defaultHandler = ContentHandler::getForModelID( $defaultModel );
1018  $defaultFormat = $defaultHandler->getDefaultFormat();
1019 
1020  if ( $model != $defaultModel ) {
1021  throw new MWException( "Can't save non-default content model with "
1022  . "\$wgContentHandlerUseDB disabled: model is $model, "
1023  . "default for $name is $defaultModel"
1024  );
1025  }
1026 
1027  if ( $format != $defaultFormat ) {
1028  throw new MWException( "Can't use non-default content format with "
1029  . "\$wgContentHandlerUseDB disabled: format is $format, "
1030  . "default for $name is $defaultFormat"
1031  );
1032  }
1033  }
1034 
1035  if ( !$content->isValid() ) {
1036  throw new MWException(
1037  "New content for $name is not valid! Content model is $model"
1038  );
1039  }
1040  }
1041 
1067  public function newNullRevision(
1068  IDatabase $dbw,
1069  Title $title,
1070  CommentStoreComment $comment,
1071  $minor,
1072  User $user
1073  ) {
1074  $this->checkDatabaseWikiId( $dbw );
1075 
1076  $pageId = $title->getArticleID();
1077 
1078  // T51581: Lock the page table row to ensure no other process
1079  // is adding a revision to the page at the same time.
1080  // Avoid locking extra tables, compare T191892.
1081  $pageLatest = $dbw->selectField(
1082  'page',
1083  'page_latest',
1084  [ 'page_id' => $pageId ],
1085  __METHOD__,
1086  [ 'FOR UPDATE' ]
1087  );
1088 
1089  if ( !$pageLatest ) {
1090  return null;
1091  }
1092 
1093  // Fetch the actual revision row from master, without locking all extra tables.
1094  $oldRevision = $this->loadRevisionFromConds(
1095  $dbw,
1096  [ 'rev_id' => intval( $pageLatest ) ],
1097  self::READ_LATEST,
1098  $title
1099  );
1100 
1101  if ( !$oldRevision ) {
1102  $msg = "Failed to load latest revision ID $pageLatest of page ID $pageId.";
1103  $this->logger->error(
1104  $msg,
1105  [ 'exception' => new RuntimeException( $msg ) ]
1106  );
1107  return null;
1108  }
1109 
1110  // Construct the new revision
1111  $timestamp = wfTimestampNow(); // TODO: use a callback, so we can override it for testing.
1112  $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision );
1113 
1114  $newRevision->setComment( $comment );
1115  $newRevision->setUser( $user );
1116  $newRevision->setTimestamp( $timestamp );
1117  $newRevision->setMinorEdit( $minor );
1118 
1119  return $newRevision;
1120  }
1121 
1132  $rc = $this->getRecentChange( $rev );
1133  if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) {
1134  return $rc->getAttribute( 'rc_id' );
1135  } else {
1136  return 0;
1137  }
1138  }
1139 
1153  public function getRecentChange( RevisionRecord $rev, $flags = 0 ) {
1154  list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags );
1155  $db = $this->getDBConnection( $dbType );
1156 
1157  $userIdentity = $rev->getUser( RevisionRecord::RAW );
1158 
1159  if ( !$userIdentity ) {
1160  // If the revision has no user identity, chances are it never went
1161  // into the database, and doesn't have an RC entry.
1162  return null;
1163  }
1164 
1165  // TODO: Select by rc_this_oldid alone - but as of Nov 2017, there is no index on that!
1166  $actorWhere = $this->actorMigration->getWhere( $db, 'rc_user', $rev->getUser(), false );
1168  [
1169  $actorWhere['conds'],
1170  'rc_timestamp' => $db->timestamp( $rev->getTimestamp() ),
1171  'rc_this_oldid' => $rev->getId()
1172  ],
1173  __METHOD__,
1174  $dbType
1175  );
1176 
1177  $this->releaseDBConnection( $db );
1178 
1179  // XXX: cache this locally? Glue it to the RevisionRecord?
1180  return $rc;
1181  }
1182 
1190  private static function mapArchiveFields( $archiveRow ) {
1191  $fieldMap = [
1192  // keep with ar prefix:
1193  'ar_id' => 'ar_id',
1194 
1195  // not the same suffix:
1196  'ar_page_id' => 'rev_page',
1197  'ar_rev_id' => 'rev_id',
1198 
1199  // same suffix:
1200  'ar_text_id' => 'rev_text_id',
1201  'ar_timestamp' => 'rev_timestamp',
1202  'ar_user_text' => 'rev_user_text',
1203  'ar_user' => 'rev_user',
1204  'ar_actor' => 'rev_actor',
1205  'ar_minor_edit' => 'rev_minor_edit',
1206  'ar_deleted' => 'rev_deleted',
1207  'ar_len' => 'rev_len',
1208  'ar_parent_id' => 'rev_parent_id',
1209  'ar_sha1' => 'rev_sha1',
1210  'ar_comment' => 'rev_comment',
1211  'ar_comment_cid' => 'rev_comment_cid',
1212  'ar_comment_id' => 'rev_comment_id',
1213  'ar_comment_text' => 'rev_comment_text',
1214  'ar_comment_data' => 'rev_comment_data',
1215  'ar_comment_old' => 'rev_comment_old',
1216  'ar_content_format' => 'rev_content_format',
1217  'ar_content_model' => 'rev_content_model',
1218  ];
1219 
1220  $revRow = new stdClass();
1221  foreach ( $fieldMap as $arKey => $revKey ) {
1222  if ( property_exists( $archiveRow, $arKey ) ) {
1223  $revRow->$revKey = $archiveRow->$arKey;
1224  }
1225  }
1226 
1227  return $revRow;
1228  }
1229 
1240  private function emulateMainSlot_1_29( $row, $queryFlags, Title $title ) {
1241  $mainSlotRow = new stdClass();
1242  $mainSlotRow->role_name = SlotRecord::MAIN;
1243  $mainSlotRow->model_name = null;
1244  $mainSlotRow->slot_revision_id = null;
1245  $mainSlotRow->slot_content_id = null;
1246  $mainSlotRow->content_address = null;
1247 
1248  $content = null;
1249  $blobData = null;
1250  $blobFlags = null;
1251 
1252  if ( is_object( $row ) ) {
1253  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) {
1254  // Don't emulate from a row when using the new schema.
1255  // Emulating from an array is still OK.
1256  throw new LogicException( 'Can\'t emulate the main slot when using MCR schema.' );
1257  }
1258 
1259  // archive row
1260  if ( !isset( $row->rev_id ) && ( isset( $row->ar_user ) || isset( $row->ar_actor ) ) ) {
1261  $row = $this->mapArchiveFields( $row );
1262  }
1263 
1264  if ( isset( $row->rev_text_id ) && $row->rev_text_id > 0 ) {
1265  $mainSlotRow->content_address = SqlBlobStore::makeAddressFromTextId(
1266  $row->rev_text_id
1267  );
1268  }
1269 
1270  // This is used by null-revisions
1271  $mainSlotRow->slot_origin = isset( $row->slot_origin )
1272  ? intval( $row->slot_origin )
1273  : null;
1274 
1275  if ( isset( $row->old_text ) ) {
1276  // this happens when the text-table gets joined directly, in the pre-1.30 schema
1277  $blobData = isset( $row->old_text ) ? strval( $row->old_text ) : null;
1278  // Check against selects that might have not included old_flags
1279  if ( !property_exists( $row, 'old_flags' ) ) {
1280  throw new InvalidArgumentException( 'old_flags was not set in $row' );
1281  }
1282  $blobFlags = $row->old_flags ?? '';
1283  }
1284 
1285  $mainSlotRow->slot_revision_id = intval( $row->rev_id );
1286 
1287  $mainSlotRow->content_size = isset( $row->rev_len ) ? intval( $row->rev_len ) : null;
1288  $mainSlotRow->content_sha1 = isset( $row->rev_sha1 ) ? strval( $row->rev_sha1 ) : null;
1289  $mainSlotRow->model_name = isset( $row->rev_content_model )
1290  ? strval( $row->rev_content_model )
1291  : null;
1292  // XXX: in the future, we'll probably always use the default format, and drop content_format
1293  $mainSlotRow->format_name = isset( $row->rev_content_format )
1294  ? strval( $row->rev_content_format )
1295  : null;
1296 
1297  if ( isset( $row->rev_text_id ) && intval( $row->rev_text_id ) > 0 ) {
1298  // Overwritten below for SCHEMA_COMPAT_WRITE_NEW
1299  $mainSlotRow->slot_content_id
1300  = $this->emulateContentId( intval( $row->rev_text_id ) );
1301  }
1302  } elseif ( is_array( $row ) ) {
1303  $mainSlotRow->slot_revision_id = isset( $row['id'] ) ? intval( $row['id'] ) : null;
1304 
1305  $mainSlotRow->slot_origin = isset( $row['slot_origin'] )
1306  ? intval( $row['slot_origin'] )
1307  : null;
1308  $mainSlotRow->content_address = isset( $row['text_id'] )
1309  ? SqlBlobStore::makeAddressFromTextId( intval( $row['text_id'] ) )
1310  : null;
1311  $mainSlotRow->content_size = isset( $row['len'] ) ? intval( $row['len'] ) : null;
1312  $mainSlotRow->content_sha1 = isset( $row['sha1'] ) ? strval( $row['sha1'] ) : null;
1313 
1314  $mainSlotRow->model_name = isset( $row['content_model'] )
1315  ? strval( $row['content_model'] ) : null; // XXX: must be a string!
1316  // XXX: in the future, we'll probably always use the default format, and drop content_format
1317  $mainSlotRow->format_name = isset( $row['content_format'] )
1318  ? strval( $row['content_format'] ) : null;
1319  $blobData = isset( $row['text'] ) ? rtrim( strval( $row['text'] ) ) : null;
1320  // XXX: If the flags field is not set then $blobFlags should be null so that no
1321  // decoding will happen. An empty string will result in default decodings.
1322  $blobFlags = isset( $row['flags'] ) ? trim( strval( $row['flags'] ) ) : null;
1323 
1324  // if we have a Content object, override mText and mContentModel
1325  if ( !empty( $row['content'] ) ) {
1326  if ( !( $row['content'] instanceof Content ) ) {
1327  throw new MWException( 'content field must contain a Content object.' );
1328  }
1329 
1331  $content = $row['content'];
1332  $handler = $content->getContentHandler();
1333 
1334  $mainSlotRow->model_name = $content->getModel();
1335 
1336  // XXX: in the future, we'll probably always use the default format.
1337  if ( $mainSlotRow->format_name === null ) {
1338  $mainSlotRow->format_name = $handler->getDefaultFormat();
1339  }
1340  }
1341 
1342  if ( isset( $row['text_id'] ) && intval( $row['text_id'] ) > 0 ) {
1343  // Overwritten below for SCHEMA_COMPAT_WRITE_NEW
1344  $mainSlotRow->slot_content_id
1345  = $this->emulateContentId( intval( $row['text_id'] ) );
1346  }
1347  } else {
1348  throw new MWException( 'Revision constructor passed invalid row format.' );
1349  }
1350 
1351  // With the old schema, the content changes with every revision,
1352  // except for null-revisions.
1353  if ( !isset( $mainSlotRow->slot_origin ) ) {
1354  $mainSlotRow->slot_origin = $mainSlotRow->slot_revision_id;
1355  }
1356 
1357  if ( $mainSlotRow->model_name === null ) {
1358  $mainSlotRow->model_name = function ( SlotRecord $slot ) use ( $title ) {
1360 
1361  return $this->slotRoleRegistry->getRoleHandler( $slot->getRole() )
1362  ->getDefaultModel( $title );
1363  };
1364  }
1365 
1366  if ( !$content ) {
1367  // XXX: We should perhaps fail if $blobData is null and $mainSlotRow->content_address
1368  // is missing, but "empty revisions" with no content are used in some edge cases.
1369 
1370  $content = function ( SlotRecord $slot )
1371  use ( $blobData, $blobFlags, $queryFlags, $mainSlotRow )
1372  {
1373  return $this->loadSlotContent(
1374  $slot,
1375  $blobData,
1376  $blobFlags,
1377  $mainSlotRow->format_name,
1378  $queryFlags
1379  );
1380  };
1381  }
1382 
1383  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
1384  // NOTE: this callback will be looped through RevisionSlot::newInherited(), allowing
1385  // the inherited slot to have the same content_id as the original slot. In that case,
1386  // $slot will be the inherited slot, while $mainSlotRow still refers to the original slot.
1387  $mainSlotRow->slot_content_id =
1388  function ( SlotRecord $slot ) use ( $queryFlags, $mainSlotRow ) {
1389  $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1390  return $this->findSlotContentId( $db, $mainSlotRow->slot_revision_id, SlotRecord::MAIN );
1391  };
1392  }
1393 
1394  return new SlotRecord( $mainSlotRow, $content );
1395  }
1396 
1408  private function emulateContentId( $textId ) {
1409  // Return a negative number to ensure the ID is distinct from any real content IDs
1410  // that will be assigned in SCHEMA_COMPAT_WRITE_NEW mode and read in SCHEMA_COMPAT_READ_NEW
1411  // mode.
1412  return -$textId;
1413  }
1414 
1434  private function loadSlotContent(
1435  SlotRecord $slot,
1436  $blobData = null,
1437  $blobFlags = null,
1438  $blobFormat = null,
1439  $queryFlags = 0
1440  ) {
1441  if ( $blobData !== null ) {
1442  Assert::parameterType( 'string', $blobData, '$blobData' );
1443  Assert::parameterType( 'string|null', $blobFlags, '$blobFlags' );
1444 
1445  $cacheKey = $slot->hasAddress() ? $slot->getAddress() : null;
1446 
1447  if ( $blobFlags === null ) {
1448  // No blob flags, so use the blob verbatim.
1449  $data = $blobData;
1450  } else {
1451  $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $cacheKey );
1452  if ( $data === false ) {
1453  throw new RevisionAccessException(
1454  "Failed to expand blob data using flags $blobFlags (key: $cacheKey)"
1455  );
1456  }
1457  }
1458 
1459  } else {
1460  $address = $slot->getAddress();
1461  try {
1462  $data = $this->blobStore->getBlob( $address, $queryFlags );
1463  } catch ( BlobAccessException $e ) {
1464  throw new RevisionAccessException(
1465  "Failed to load data blob from $address: " . $e->getMessage(), 0, $e
1466  );
1467  }
1468  }
1469 
1470  // Unserialize content
1472 
1473  $content = $handler->unserializeContent( $data, $blobFormat );
1474  return $content;
1475  }
1476 
1491  public function getRevisionById( $id, $flags = 0 ) {
1492  return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags );
1493  }
1494 
1511  public function getRevisionByTitle( LinkTarget $linkTarget, $revId = 0, $flags = 0 ) {
1512  // TODO should not require Title in future (T206498)
1513  $title = Title::newFromLinkTarget( $linkTarget );
1514  $conds = [
1515  'page_namespace' => $title->getNamespace(),
1516  'page_title' => $title->getDBkey()
1517  ];
1518  if ( $revId ) {
1519  // Use the specified revision ID.
1520  // Note that we use newRevisionFromConds here because we want to retry
1521  // and fall back to master if the page is not found on a replica.
1522  // Since the caller supplied a revision ID, we are pretty sure the revision is
1523  // supposed to exist, so we should try hard to find it.
1524  $conds['rev_id'] = $revId;
1525  return $this->newRevisionFromConds( $conds, $flags, $title );
1526  } else {
1527  // Use a join to get the latest revision.
1528  // Note that we don't use newRevisionFromConds here because we don't want to retry
1529  // and fall back to master. The assumption is that we only want to force the fallback
1530  // if we are quite sure the revision exists because the caller supplied a revision ID.
1531  // If the page isn't found at all on a replica, it probably simply does not exist.
1532  $db = $this->getDBConnectionRefForQueryFlags( $flags );
1533 
1534  $conds[] = 'rev_id=page_latest';
1535  $rev = $this->loadRevisionFromConds( $db, $conds, $flags, $title );
1536 
1537  return $rev;
1538  }
1539  }
1540 
1557  public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) {
1558  $conds = [ 'page_id' => $pageId ];
1559  if ( $revId ) {
1560  // Use the specified revision ID.
1561  // Note that we use newRevisionFromConds here because we want to retry
1562  // and fall back to master if the page is not found on a replica.
1563  // Since the caller supplied a revision ID, we are pretty sure the revision is
1564  // supposed to exist, so we should try hard to find it.
1565  $conds['rev_id'] = $revId;
1566  return $this->newRevisionFromConds( $conds, $flags );
1567  } else {
1568  // Use a join to get the latest revision.
1569  // Note that we don't use newRevisionFromConds here because we don't want to retry
1570  // and fall back to master. The assumption is that we only want to force the fallback
1571  // if we are quite sure the revision exists because the caller supplied a revision ID.
1572  // If the page isn't found at all on a replica, it probably simply does not exist.
1573  $db = $this->getDBConnectionRefForQueryFlags( $flags );
1574 
1575  $conds[] = 'rev_id=page_latest';
1576  $rev = $this->loadRevisionFromConds( $db, $conds, $flags );
1577 
1578  return $rev;
1579  }
1580  }
1581 
1593  public function getRevisionByTimestamp( $title, $timestamp ) {
1594  $db = $this->getDBConnection( DB_REPLICA );
1595  return $this->newRevisionFromConds(
1596  [
1597  'rev_timestamp' => $db->timestamp( $timestamp ),
1598  'page_namespace' => $title->getNamespace(),
1599  'page_title' => $title->getDBkey()
1600  ],
1601  0,
1602  $title
1603  );
1604  }
1605 
1612  private function loadSlotRecords( $revId, $queryFlags ) {
1613  $revQuery = self::getSlotsQueryInfo( [ 'content' ] );
1614 
1615  list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
1616  $db = $this->getDBConnectionRef( $dbMode );
1617 
1618  $res = $db->select(
1619  $revQuery['tables'],
1620  $revQuery['fields'],
1621  [
1622  'slot_revision_id' => $revId,
1623  ],
1624  __METHOD__,
1625  $dbOptions,
1626  $revQuery['joins']
1627  );
1628 
1629  $slots = [];
1630 
1631  foreach ( $res as $row ) {
1632  // resolve role names and model names from in-memory cache, instead of joining.
1633  $row->role_name = $this->slotRoleStore->getName( (int)$row->slot_role_id );
1634  $row->model_name = $this->contentModelStore->getName( (int)$row->content_model );
1635 
1636  $contentCallback = function ( SlotRecord $slot ) use ( $queryFlags ) {
1637  return $this->loadSlotContent( $slot, null, null, null, $queryFlags );
1638  };
1639 
1640  $slots[$row->role_name] = new SlotRecord( $row, $contentCallback );
1641  }
1642 
1643  if ( !isset( $slots[SlotRecord::MAIN] ) ) {
1644  throw new RevisionAccessException(
1645  'Main slot of revision ' . $revId . ' not found in database!'
1646  );
1647  }
1648 
1649  return $slots;
1650  }
1651 
1666  private function newRevisionSlots(
1667  $revId,
1668  $revisionRow,
1669  $queryFlags,
1670  Title $title
1671  ) {
1672  if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) {
1673  $mainSlot = $this->emulateMainSlot_1_29( $revisionRow, $queryFlags, $title );
1674  // @phan-suppress-next-line PhanTypeInvalidCallableArraySize false positive
1675  $slots = new RevisionSlots( [ SlotRecord::MAIN => $mainSlot ] );
1676  } else {
1677  // XXX: do we need the same kind of caching here
1678  // that getKnownCurrentRevision uses (if $revId == page_latest?)
1679 
1680  $slots = new RevisionSlots( function () use( $revId, $queryFlags ) {
1681  return $this->loadSlotRecords( $revId, $queryFlags );
1682  } );
1683  }
1684 
1685  return $slots;
1686  }
1687 
1705  public function newRevisionFromArchiveRow(
1706  $row,
1707  $queryFlags = 0,
1708  Title $title = null,
1709  array $overrides = []
1710  ) {
1711  Assert::parameterType( 'object', $row, '$row' );
1712 
1713  // check second argument, since Revision::newFromArchiveRow had $overrides in that spot.
1714  Assert::parameterType( 'integer', $queryFlags, '$queryFlags' );
1715 
1716  if ( !$title && isset( $overrides['title'] ) ) {
1717  if ( !( $overrides['title'] instanceof Title ) ) {
1718  throw new MWException( 'title field override must contain a Title object.' );
1719  }
1720 
1721  $title = $overrides['title'];
1722  }
1723 
1724  if ( !isset( $title ) ) {
1725  if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) {
1726  $title = Title::makeTitle( $row->ar_namespace, $row->ar_title );
1727  } else {
1728  throw new InvalidArgumentException(
1729  'A Title or ar_namespace and ar_title must be given'
1730  );
1731  }
1732  }
1733 
1734  foreach ( $overrides as $key => $value ) {
1735  $field = "ar_$key";
1736  $row->$field = $value;
1737  }
1738 
1739  try {
1741  $row->ar_user ?? null,
1742  $row->ar_user_text ?? null,
1743  $row->ar_actor ?? null,
1744  $this->wikiId
1745  );
1746  } catch ( InvalidArgumentException $ex ) {
1747  wfWarn( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ': ' . $ex->getMessage() );
1748  $user = new UserIdentityValue( 0, 'Unknown user', 0 );
1749  }
1750 
1751  $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1752  // Legacy because $row may have come from self::selectFields()
1753  $comment = $this->commentStore->getCommentLegacy( $db, 'ar_comment', $row, true );
1754 
1755  $slots = $this->newRevisionSlots( $row->ar_rev_id, $row, $queryFlags, $title );
1756 
1757  return new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $this->wikiId );
1758  }
1759 
1772  public function newRevisionFromRow(
1773  $row,
1774  $queryFlags = 0,
1775  Title $title = null,
1776  $fromCache = false
1777  ) {
1778  Assert::parameterType( 'object', $row, '$row' );
1779 
1780  if ( !$title ) {
1781  $pageId = $row->rev_page ?? 0; // XXX: also check page_id?
1782  $revId = $row->rev_id ?? 0;
1783 
1784  $title = $this->getTitle( $pageId, $revId, $queryFlags );
1785  }
1786 
1787  if ( !isset( $row->page_latest ) ) {
1788  $row->page_latest = $title->getLatestRevID();
1789  if ( $row->page_latest === 0 && $title->exists() ) {
1790  wfWarn( 'Encountered title object in limbo: ID ' . $title->getArticleID() );
1791  }
1792  }
1793 
1794  try {
1796  $row->rev_user ?? null,
1797  $row->rev_user_text ?? null,
1798  $row->rev_actor ?? null,
1799  $this->wikiId
1800  );
1801  } catch ( InvalidArgumentException $ex ) {
1802  wfWarn( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ': ' . $ex->getMessage() );
1803  $user = new UserIdentityValue( 0, 'Unknown user', 0 );
1804  }
1805 
1806  $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1807  // Legacy because $row may have come from self::selectFields()
1808  $comment = $this->commentStore->getCommentLegacy( $db, 'rev_comment', $row, true );
1809 
1810  $slots = $this->newRevisionSlots( $row->rev_id, $row, $queryFlags, $title );
1811 
1812  // If this is a cached row, instantiate a cache-aware revision class to avoid stale data.
1813  if ( $fromCache ) {
1815  function ( $revId ) use ( $queryFlags ) {
1816  $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1817  return $this->fetchRevisionRowFromConds(
1818  $db,
1819  [ 'rev_id' => intval( $revId ) ]
1820  );
1821  },
1822  $title, $user, $comment, $row, $slots, $this->wikiId
1823  );
1824  } else {
1825  $rev = new RevisionStoreRecord(
1826  $title, $user, $comment, $row, $slots, $this->wikiId );
1827  }
1828  return $rev;
1829  }
1830 
1846  array $fields,
1847  $queryFlags = 0,
1848  Title $title = null
1849  ) {
1850  if ( !$title && isset( $fields['title'] ) ) {
1851  if ( !( $fields['title'] instanceof Title ) ) {
1852  throw new MWException( 'title field must contain a Title object.' );
1853  }
1854 
1855  $title = $fields['title'];
1856  }
1857 
1858  if ( !$title ) {
1859  $pageId = $fields['page'] ?? 0;
1860  $revId = $fields['id'] ?? 0;
1861 
1862  $title = $this->getTitle( $pageId, $revId, $queryFlags );
1863  }
1864 
1865  if ( !isset( $fields['page'] ) ) {
1866  $fields['page'] = $title->getArticleID( $queryFlags );
1867  }
1868 
1869  // if we have a content object, use it to set the model and type
1870  if ( !empty( $fields['content'] ) && !( $fields['content'] instanceof Content )
1871  && !is_array( $fields['content'] )
1872  ) {
1873  throw new MWException(
1874  'content field must contain a Content object or an array of Content objects.'
1875  );
1876  }
1877 
1878  if ( !empty( $fields['text_id'] ) ) {
1879  if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
1880  throw new MWException( "The text_id field is only available in the pre-MCR schema" );
1881  }
1882 
1883  if ( !empty( $fields['content'] ) ) {
1884  throw new MWException(
1885  "Text already stored in external store (id {$fields['text_id']}), " .
1886  "can't specify content object"
1887  );
1888  }
1889  }
1890 
1891  if (
1892  isset( $fields['comment'] )
1893  && !( $fields['comment'] instanceof CommentStoreComment )
1894  ) {
1895  $commentData = $fields['comment_data'] ?? null;
1896 
1897  if ( $fields['comment'] instanceof Message ) {
1898  $fields['comment'] = CommentStoreComment::newUnsavedComment(
1899  $fields['comment'],
1900  $commentData
1901  );
1902  } else {
1903  $commentText = trim( strval( $fields['comment'] ) );
1904  $fields['comment'] = CommentStoreComment::newUnsavedComment(
1905  $commentText,
1906  $commentData
1907  );
1908  }
1909  }
1910 
1911  $revision = new MutableRevisionRecord( $title, $this->wikiId );
1912  $this->initializeMutableRevisionFromArray( $revision, $fields );
1913 
1914  if ( isset( $fields['content'] ) && is_array( $fields['content'] ) ) {
1915  // @phan-suppress-next-line PhanTypeNoPropertiesForeach
1916  foreach ( $fields['content'] as $role => $content ) {
1917  $revision->setContent( $role, $content );
1918  }
1919  } else {
1920  $mainSlot = $this->emulateMainSlot_1_29( $fields, $queryFlags, $title );
1921  $revision->setSlot( $mainSlot );
1922  }
1923 
1924  return $revision;
1925  }
1926 
1932  MutableRevisionRecord $record,
1933  array $fields
1934  ) {
1936  $user = null;
1937 
1938  // If a user is passed in, use it if possible. We cannot use a user from a
1939  // remote wiki with unsuppressed ids, due to issues described in T222212.
1940  if ( isset( $fields['user'] ) &&
1941  ( $fields['user'] instanceof UserIdentity ) &&
1942  ( $this->wikiId === false ||
1943  ( !$fields['user']->getId() && !$fields['user']->getActorId() ) )
1944  ) {
1945  $user = $fields['user'];
1946  } else {
1947  try {
1949  $fields['user'] ?? null,
1950  $fields['user_text'] ?? null,
1951  $fields['actor'] ?? null,
1952  $this->wikiId
1953  );
1954  } catch ( InvalidArgumentException $ex ) {
1955  $user = null;
1956  }
1957  }
1958 
1959  if ( $user ) {
1960  $record->setUser( $user );
1961  }
1962 
1963  $timestamp = isset( $fields['timestamp'] )
1964  ? strval( $fields['timestamp'] )
1965  : wfTimestampNow(); // TODO: use a callback, so we can override it for testing.
1966 
1967  $record->setTimestamp( $timestamp );
1968 
1969  if ( isset( $fields['page'] ) ) {
1970  $record->setPageId( intval( $fields['page'] ) );
1971  }
1972 
1973  if ( isset( $fields['id'] ) ) {
1974  $record->setId( intval( $fields['id'] ) );
1975  }
1976  if ( isset( $fields['parent_id'] ) ) {
1977  $record->setParentId( intval( $fields['parent_id'] ) );
1978  }
1979 
1980  if ( isset( $fields['sha1'] ) ) {
1981  $record->setSha1( $fields['sha1'] );
1982  }
1983  if ( isset( $fields['size'] ) ) {
1984  $record->setSize( intval( $fields['size'] ) );
1985  }
1986 
1987  if ( isset( $fields['minor_edit'] ) ) {
1988  $record->setMinorEdit( intval( $fields['minor_edit'] ) !== 0 );
1989  }
1990  if ( isset( $fields['deleted'] ) ) {
1991  $record->setVisibility( intval( $fields['deleted'] ) );
1992  }
1993 
1994  if ( isset( $fields['comment'] ) ) {
1995  Assert::parameterType(
1997  $fields['comment'],
1998  '$row[\'comment\']'
1999  );
2000  $record->setComment( $fields['comment'] );
2001  }
2002  }
2003 
2018  public function loadRevisionFromId( IDatabase $db, $id ) {
2019  return $this->loadRevisionFromConds( $db, [ 'rev_id' => intval( $id ) ] );
2020  }
2021 
2037  public function loadRevisionFromPageId( IDatabase $db, $pageid, $id = 0 ) {
2038  $conds = [ 'rev_page' => intval( $pageid ), 'page_id' => intval( $pageid ) ];
2039  if ( $id ) {
2040  $conds['rev_id'] = intval( $id );
2041  } else {
2042  $conds[] = 'rev_id=page_latest';
2043  }
2044  return $this->loadRevisionFromConds( $db, $conds );
2045  }
2046 
2063  public function loadRevisionFromTitle( IDatabase $db, $title, $id = 0 ) {
2064  if ( $id ) {
2065  $matchId = intval( $id );
2066  } else {
2067  $matchId = 'page_latest';
2068  }
2069 
2070  return $this->loadRevisionFromConds(
2071  $db,
2072  [
2073  "rev_id=$matchId",
2074  'page_namespace' => $title->getNamespace(),
2075  'page_title' => $title->getDBkey()
2076  ],
2077  0,
2078  $title
2079  );
2080  }
2081 
2097  public function loadRevisionFromTimestamp( IDatabase $db, $title, $timestamp ) {
2098  return $this->loadRevisionFromConds( $db,
2099  [
2100  'rev_timestamp' => $db->timestamp( $timestamp ),
2101  'page_namespace' => $title->getNamespace(),
2102  'page_title' => $title->getDBkey()
2103  ],
2104  0,
2105  $title
2106  );
2107  }
2108 
2124  private function newRevisionFromConds( $conditions, $flags = 0, Title $title = null ) {
2125  $db = $this->getDBConnectionRefForQueryFlags( $flags );
2126  $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $title );
2127 
2128  $lb = $this->getDBLoadBalancer();
2129 
2130  // Make sure new pending/committed revision are visibile later on
2131  // within web requests to certain avoid bugs like T93866 and T94407.
2132  if ( !$rev
2133  && !( $flags & self::READ_LATEST )
2134  && $lb->getServerCount() > 1
2135  && $lb->hasOrMadeRecentMasterChanges()
2136  ) {
2137  $flags = self::READ_LATEST;
2138  $dbw = $this->getDBConnection( DB_MASTER );
2139  $rev = $this->loadRevisionFromConds( $dbw, $conditions, $flags, $title );
2140  $this->releaseDBConnection( $dbw );
2141  }
2142 
2143  return $rev;
2144  }
2145 
2159  private function loadRevisionFromConds(
2160  IDatabase $db,
2161  $conditions,
2162  $flags = 0,
2163  Title $title = null
2164  ) {
2165  $row = $this->fetchRevisionRowFromConds( $db, $conditions, $flags );
2166  if ( $row ) {
2167  $rev = $this->newRevisionFromRow( $row, $flags, $title );
2168 
2169  return $rev;
2170  }
2171 
2172  return null;
2173  }
2174 
2182  private function checkDatabaseWikiId( IDatabase $db ) {
2183  $storeWiki = $this->wikiId;
2184  $dbWiki = $db->getDomainID();
2185 
2186  if ( $dbWiki === $storeWiki ) {
2187  return;
2188  }
2189 
2190  $storeWiki = $storeWiki ?: $this->loadBalancer->getLocalDomainID();
2191  // @FIXME: when would getDomainID() be false here?
2192  $dbWiki = $dbWiki ?: wfWikiID();
2193 
2194  if ( $dbWiki === $storeWiki ) {
2195  return;
2196  }
2197 
2198  // HACK: counteract encoding imposed by DatabaseDomain
2199  $storeWiki = str_replace( '?h', '-', $storeWiki );
2200  $dbWiki = str_replace( '?h', '-', $dbWiki );
2201 
2202  if ( $dbWiki === $storeWiki ) {
2203  return;
2204  }
2205 
2206  throw new MWException( "RevisionStore for $storeWiki "
2207  . "cannot be used with a DB connection for $dbWiki" );
2208  }
2209 
2222  private function fetchRevisionRowFromConds( IDatabase $db, $conditions, $flags = 0 ) {
2223  $this->checkDatabaseWikiId( $db );
2224 
2225  $revQuery = $this->getQueryInfo( [ 'page', 'user' ] );
2226  $options = [];
2227  if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) {
2228  $options[] = 'FOR UPDATE';
2229  }
2230  return $db->selectRow(
2231  $revQuery['tables'],
2232  $revQuery['fields'],
2233  $conditions,
2234  __METHOD__,
2235  $options,
2236  $revQuery['joins']
2237  );
2238  }
2239 
2254  private function findSlotContentId( IDatabase $db, $revId, $role ) {
2255  if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
2256  return null;
2257  }
2258 
2259  try {
2260  $roleId = $this->slotRoleStore->getId( $role );
2261  $conditions = [
2262  'slot_revision_id' => $revId,
2263  'slot_role_id' => $roleId,
2264  ];
2265 
2266  $contentId = $db->selectField( 'slots', 'slot_content_id', $conditions, __METHOD__ );
2267 
2268  return $contentId ?: null;
2269  } catch ( NameTableAccessException $ex ) {
2270  // If the role is missing from the slot_roles table,
2271  // the corresponding row in slots cannot exist.
2272  return null;
2273  }
2274  }
2275 
2299  public function getQueryInfo( $options = [] ) {
2300  $ret = [
2301  'tables' => [],
2302  'fields' => [],
2303  'joins' => [],
2304  ];
2305 
2306  $ret['tables'][] = 'revision';
2307  $ret['fields'] = array_merge( $ret['fields'], [
2308  'rev_id',
2309  'rev_page',
2310  'rev_timestamp',
2311  'rev_minor_edit',
2312  'rev_deleted',
2313  'rev_len',
2314  'rev_parent_id',
2315  'rev_sha1',
2316  ] );
2317 
2318  $commentQuery = $this->commentStore->getJoin( 'rev_comment' );
2319  $ret['tables'] = array_merge( $ret['tables'], $commentQuery['tables'] );
2320  $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] );
2321  $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] );
2322 
2323  $actorQuery = $this->actorMigration->getJoin( 'rev_user' );
2324  $ret['tables'] = array_merge( $ret['tables'], $actorQuery['tables'] );
2325  $ret['fields'] = array_merge( $ret['fields'], $actorQuery['fields'] );
2326  $ret['joins'] = array_merge( $ret['joins'], $actorQuery['joins'] );
2327 
2328  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
2329  $ret['fields'][] = 'rev_text_id';
2330 
2331  if ( $this->contentHandlerUseDB ) {
2332  $ret['fields'][] = 'rev_content_format';
2333  $ret['fields'][] = 'rev_content_model';
2334  }
2335  }
2336 
2337  if ( in_array( 'page', $options, true ) ) {
2338  $ret['tables'][] = 'page';
2339  $ret['fields'] = array_merge( $ret['fields'], [
2340  'page_namespace',
2341  'page_title',
2342  'page_id',
2343  'page_latest',
2344  'page_is_redirect',
2345  'page_len',
2346  ] );
2347  $ret['joins']['page'] = [ 'JOIN', [ 'page_id = rev_page' ] ];
2348  }
2349 
2350  if ( in_array( 'user', $options, true ) ) {
2351  $ret['tables'][] = 'user';
2352  $ret['fields'] = array_merge( $ret['fields'], [
2353  'user_name',
2354  ] );
2355  $u = $actorQuery['fields']['rev_user'];
2356  $ret['joins']['user'] = [ 'LEFT JOIN', [ "$u != 0", "user_id = $u" ] ];
2357  }
2358 
2359  if ( in_array( 'text', $options, true ) ) {
2360  if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) ) {
2361  throw new InvalidArgumentException( 'text table can no longer be joined directly' );
2362  } elseif ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
2363  // NOTE: even when this class is set to not read from the old schema, callers
2364  // should still be able to join against the text table, as long as we are still
2365  // writing the old schema for compatibility.
2366  // TODO: This should trigger a deprecation warning eventually (T200918), but not
2367  // before all known usages are removed (see T198341 and T201164).
2368  // wfDeprecated( __METHOD__ . ' with `text` option', '1.32' );
2369  }
2370 
2371  $ret['tables'][] = 'text';
2372  $ret['fields'] = array_merge( $ret['fields'], [
2373  'old_text',
2374  'old_flags'
2375  ] );
2376  $ret['joins']['text'] = [ 'JOIN', [ 'rev_text_id=old_id' ] ];
2377  }
2378 
2379  return $ret;
2380  }
2381 
2399  public function getSlotsQueryInfo( $options = [] ) {
2400  $ret = [
2401  'tables' => [],
2402  'fields' => [],
2403  'joins' => [],
2404  ];
2405 
2406  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
2407  $db = $this->getDBConnectionRef( DB_REPLICA );
2408  $ret['tables']['slots'] = 'revision';
2409 
2410  $ret['fields']['slot_revision_id'] = 'slots.rev_id';
2411  $ret['fields']['slot_content_id'] = 'NULL';
2412  $ret['fields']['slot_origin'] = 'slots.rev_id';
2413  $ret['fields']['role_name'] = $db->addQuotes( SlotRecord::MAIN );
2414 
2415  if ( in_array( 'content', $options, true ) ) {
2416  $ret['fields']['content_size'] = 'slots.rev_len';
2417  $ret['fields']['content_sha1'] = 'slots.rev_sha1';
2418  $ret['fields']['content_address']
2419  = $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] );
2420 
2421  if ( $this->contentHandlerUseDB ) {
2422  $ret['fields']['model_name'] = 'slots.rev_content_model';
2423  } else {
2424  $ret['fields']['model_name'] = 'NULL';
2425  }
2426  }
2427  } else {
2428  $ret['tables'][] = 'slots';
2429  $ret['fields'] = array_merge( $ret['fields'], [
2430  'slot_revision_id',
2431  'slot_content_id',
2432  'slot_origin',
2433  'slot_role_id',
2434  ] );
2435 
2436  if ( in_array( 'role', $options, true ) ) {
2437  // Use left join to attach role name, so we still find the revision row even
2438  // if the role name is missing. This triggers a more obvious failure mode.
2439  $ret['tables'][] = 'slot_roles';
2440  $ret['joins']['slot_roles'] = [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ];
2441  $ret['fields'][] = 'role_name';
2442  }
2443 
2444  if ( in_array( 'content', $options, true ) ) {
2445  $ret['tables'][] = 'content';
2446  $ret['fields'] = array_merge( $ret['fields'], [
2447  'content_size',
2448  'content_sha1',
2449  'content_address',
2450  'content_model',
2451  ] );
2452  $ret['joins']['content'] = [ 'JOIN', [ 'slot_content_id = content_id' ] ];
2453 
2454  if ( in_array( 'model', $options, true ) ) {
2455  // Use left join to attach model name, so we still find the revision row even
2456  // if the model name is missing. This triggers a more obvious failure mode.
2457  $ret['tables'][] = 'content_models';
2458  $ret['joins']['content_models'] = [ 'LEFT JOIN', [ 'content_model = model_id' ] ];
2459  $ret['fields'][] = 'model_name';
2460  }
2461 
2462  }
2463  }
2464 
2465  return $ret;
2466  }
2467 
2481  public function getArchiveQueryInfo() {
2482  $commentQuery = $this->commentStore->getJoin( 'ar_comment' );
2483  $actorQuery = $this->actorMigration->getJoin( 'ar_user' );
2484  $ret = [
2485  'tables' => [ 'archive' ] + $commentQuery['tables'] + $actorQuery['tables'],
2486  'fields' => [
2487  'ar_id',
2488  'ar_page_id',
2489  'ar_namespace',
2490  'ar_title',
2491  'ar_rev_id',
2492  'ar_timestamp',
2493  'ar_minor_edit',
2494  'ar_deleted',
2495  'ar_len',
2496  'ar_parent_id',
2497  'ar_sha1',
2498  ] + $commentQuery['fields'] + $actorQuery['fields'],
2499  'joins' => $commentQuery['joins'] + $actorQuery['joins'],
2500  ];
2501 
2502  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
2503  $ret['fields'][] = 'ar_text_id';
2504 
2505  if ( $this->contentHandlerUseDB ) {
2506  $ret['fields'][] = 'ar_content_format';
2507  $ret['fields'][] = 'ar_content_model';
2508  }
2509  }
2510 
2511  return $ret;
2512  }
2513 
2523  public function getRevisionSizes( array $revIds ) {
2524  return $this->listRevisionSizes( $this->getDBConnection( DB_REPLICA ), $revIds );
2525  }
2526 
2539  public function listRevisionSizes( IDatabase $db, array $revIds ) {
2540  $this->checkDatabaseWikiId( $db );
2541 
2542  $revLens = [];
2543  if ( !$revIds ) {
2544  return $revLens; // empty
2545  }
2546 
2547  $res = $db->select(
2548  'revision',
2549  [ 'rev_id', 'rev_len' ],
2550  [ 'rev_id' => $revIds ],
2551  __METHOD__
2552  );
2553 
2554  foreach ( $res as $row ) {
2555  $revLens[$row->rev_id] = intval( $row->rev_len );
2556  }
2557 
2558  return $revLens;
2559  }
2560 
2569  private function getRelativeRevision( RevisionRecord $rev, $flags, $dir ) {
2570  $op = $dir === 'next' ? '>' : '<';
2571  $sort = $dir === 'next' ? 'ASC' : 'DESC';
2572 
2573  if ( !$rev->getId() || !$rev->getPageId() ) {
2574  // revision is unsaved or otherwise incomplete
2575  return null;
2576  }
2577 
2578  if ( $rev instanceof RevisionArchiveRecord ) {
2579  // revision is deleted, so it's not part of the page history
2580  return null;
2581  }
2582 
2583  list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags );
2584  $db = $this->getDBConnection( $dbType, [ 'contributions' ] );
2585 
2586  $ts = $this->getTimestampFromId( $rev->getId(), $flags );
2587  if ( $ts === false ) {
2588  // XXX Should this be moved into getTimestampFromId?
2589  $ts = $db->selectField( 'archive', 'ar_timestamp',
2590  [ 'ar_rev_id' => $rev->getId() ], __METHOD__ );
2591  if ( $ts === false ) {
2592  // XXX Is this reachable? How can we have a page id but no timestamp?
2593  return null;
2594  }
2595  }
2596  $ts = $db->addQuotes( $db->timestamp( $ts ) );
2597 
2598  $revId = $db->selectField( 'revision', 'rev_id',
2599  [
2600  'rev_page' => $rev->getPageId(),
2601  "rev_timestamp $op $ts OR (rev_timestamp = $ts AND rev_id $op {$rev->getId()})"
2602  ],
2603  __METHOD__,
2604  [
2605  'ORDER BY' => "rev_timestamp $sort, rev_id $sort",
2606  'IGNORE INDEX' => 'rev_timestamp', // Probably needed for T159319
2607  ]
2608  );
2609 
2610  if ( $revId === false ) {
2611  return null;
2612  }
2613 
2614  return $this->getRevisionById( intval( $revId ) );
2615  }
2616 
2632  public function getPreviousRevision( RevisionRecord $rev, $flags = 0 ) {
2633  if ( $flags instanceof Title ) {
2634  // Old calling convention, we don't use Title here anymore
2635  wfDeprecated( __METHOD__ . ' with Title', '1.34' );
2636  $flags = 0;
2637  }
2638 
2639  return $this->getRelativeRevision( $rev, $flags, 'prev' );
2640  }
2641 
2655  public function getNextRevision( RevisionRecord $rev, $flags = 0 ) {
2656  if ( $flags instanceof Title ) {
2657  // Old calling convention, we don't use Title here anymore
2658  wfDeprecated( __METHOD__ . ' with Title', '1.34' );
2659  $flags = 0;
2660  }
2661 
2662  return $this->getRelativeRevision( $rev, $flags, 'next' );
2663  }
2664 
2676  private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) {
2677  $this->checkDatabaseWikiId( $db );
2678 
2679  if ( $rev->getPageId() === null ) {
2680  return 0;
2681  }
2682  # Use page_latest if ID is not given
2683  if ( !$rev->getId() ) {
2684  $prevId = $db->selectField(
2685  'page', 'page_latest',
2686  [ 'page_id' => $rev->getPageId() ],
2687  __METHOD__
2688  );
2689  } else {
2690  $prevId = $db->selectField(
2691  'revision', 'rev_id',
2692  [ 'rev_page' => $rev->getPageId(), 'rev_id < ' . $rev->getId() ],
2693  __METHOD__,
2694  [ 'ORDER BY' => 'rev_id DESC' ]
2695  );
2696  }
2697  return intval( $prevId );
2698  }
2699 
2712  public function getTimestampFromId( $id, $flags = 0 ) {
2713  if ( $id instanceof Title ) {
2714  // Old deprecated calling convention supported for backwards compatibility
2715  $id = $flags;
2716  $flags = func_num_args() > 2 ? func_get_arg( 2 ) : 0;
2717  }
2718  $db = $this->getDBConnectionRefForQueryFlags( $flags );
2719 
2720  $timestamp =
2721  $db->selectField( 'revision', 'rev_timestamp', [ 'rev_id' => $id ], __METHOD__ );
2722 
2723  return ( $timestamp !== false ) ? wfTimestamp( TS_MW, $timestamp ) : false;
2724  }
2725 
2735  public function countRevisionsByPageId( IDatabase $db, $id ) {
2736  $this->checkDatabaseWikiId( $db );
2737 
2738  $row = $db->selectRow( 'revision',
2739  [ 'revCount' => 'COUNT(*)' ],
2740  [ 'rev_page' => $id ],
2741  __METHOD__
2742  );
2743  if ( $row ) {
2744  return intval( $row->revCount );
2745  }
2746  return 0;
2747  }
2748 
2758  public function countRevisionsByTitle( IDatabase $db, $title ) {
2759  $id = $title->getArticleID();
2760  if ( $id ) {
2761  return $this->countRevisionsByPageId( $db, $id );
2762  }
2763  return 0;
2764  }
2765 
2784  public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) {
2785  $this->checkDatabaseWikiId( $db );
2786 
2787  if ( !$userId ) {
2788  return false;
2789  }
2790 
2791  $revQuery = $this->getQueryInfo();
2792  $res = $db->select(
2793  $revQuery['tables'],
2794  [
2795  'rev_user' => $revQuery['fields']['rev_user'],
2796  ],
2797  [
2798  'rev_page' => $pageId,
2799  'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) )
2800  ],
2801  __METHOD__,
2802  [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ],
2803  $revQuery['joins']
2804  );
2805  foreach ( $res as $row ) {
2806  if ( $row->rev_user != $userId ) {
2807  return false;
2808  }
2809  }
2810  return true;
2811  }
2812 
2826  public function getKnownCurrentRevision( Title $title, $revId ) {
2827  $db = $this->getDBConnectionRef( DB_REPLICA );
2828 
2829  $pageId = $title->getArticleID();
2830 
2831  if ( !$pageId ) {
2832  return false;
2833  }
2834 
2835  if ( !$revId ) {
2836  $revId = $title->getLatestRevID();
2837  }
2838 
2839  if ( !$revId ) {
2840  wfWarn(
2841  'No latest revision known for page ' . $title->getPrefixedDBkey()
2842  . ' even though it exists with page ID ' . $pageId
2843  );
2844  return false;
2845  }
2846 
2847  // Load the row from cache if possible. If not possible, populate the cache.
2848  // As a minor optimization, remember if this was a cache hit or miss.
2849  // We can sometimes avoid a database query later if this is a cache miss.
2850  $fromCache = true;
2851  $row = $this->cache->getWithSetCallback(
2852  // Page/rev IDs passed in from DB to reflect history merges
2853  $this->getRevisionRowCacheKey( $db, $pageId, $revId ),
2855  function ( $curValue, &$ttl, array &$setOpts ) use (
2856  $db, $pageId, $revId, &$fromCache
2857  ) {
2858  $setOpts += Database::getCacheSetOptions( $db );
2859  $row = $this->fetchRevisionRowFromConds( $db, [ 'rev_id' => intval( $revId ) ] );
2860  if ( $row ) {
2861  $fromCache = false;
2862  }
2863  return $row; // don't cache negatives
2864  }
2865  );
2866 
2867  // Reflect revision deletion and user renames.
2868  if ( $row ) {
2869  return $this->newRevisionFromRow( $row, 0, $title, $fromCache );
2870  } else {
2871  return false;
2872  }
2873  }
2874 
2886  private function getRevisionRowCacheKey( IDatabase $db, $pageId, $revId ) {
2887  return $this->cache->makeGlobalKey(
2888  self::ROW_CACHE_KEY,
2889  $db->getDomainID(),
2890  $pageId,
2891  $revId
2892  );
2893  }
2894 
2895  // TODO: move relevant methods from Title here, e.g. getFirstRevision, isBigDeletion, etc.
2896 
2897 }
2898 
2903 class_alias( RevisionStore::class, 'MediaWiki\Storage\RevisionStore' );
const SCHEMA_COMPAT_WRITE_OLD
Definition: Defines.php:280
The wiki should then use memcached to cache various data To use multiple just add more items to the array To increase the weight of a make its entry a array("192.168.0.1:11211", 2))
getTitle( $pageId, $revId, $queryFlags=self::READ_NORMAL)
Determines the page Title based on the available information.
ActorMigration $actorMigration
newRevisionSlots( $revId, $revisionRow, $queryFlags, Title $title)
Factory method for RevisionSlots.
deferred txt A few of the database updates required by various functions here can be deferred until after the result page is displayed to the user For updating the view updating the linked to tables after a etc PHP does not yet have any way to tell the server to actually return and disconnect while still running these but it might have such a feature in the future We handle these by creating a deferred update object and putting those objects on a global list
Definition: deferred.txt:11
wfWarn( $msg, $callerOffset=1, $level=E_USER_NOTICE)
Send a warning either to the debug log or in a PHP error depending on $wgDevelopmentWarnings.
failOnEmpty( $value, $name)
const SCHEMA_COMPAT_READ_BOTH
Definition: Defines.php:285
static newSaved( $revisionId, $contentId, $contentAddress, SlotRecord $protoSlot)
Constructs a complete SlotRecord for a newly saved revision, based on the incomplete proto-slot...
Definition: SlotRecord.php:164
getPreviousRevisionId(IDatabase $db, RevisionRecord $rev)
Get previous revision Id for this page_id This is used to populate rev_parent_id on save...
isValid()
Returns whether the content is valid.
isMinor()
MCR migration note: this replaces Revision::isMinor.
getArticleID( $flags=0)
Get the article ID for this Title from the link cache, adding it if necessary.
Definition: Title.php:3005
static toHex( $ip)
Return a zero-padded upper case hexadecimal representation of an IP address.
Definition: IP.php:404
const MODEL_HINT
Hint key for use with storeBlob, indicating the model of the content encoded in the given blob...
Definition: BlobStore.php:76
A RevisionRecord representing an existing revision persisted in the revision table.
loadSlotRecords( $revId, $queryFlags)
static newFromID( $id, $flags=0)
Create a new Title from an article ID.
Definition: Title.php:470
getRecentChange(RevisionRecord $rev, $flags=0)
Get the RC object belonging to the current revision, if there&#39;s one.
getContentHandler()
Convenience method that returns the ContentHandler singleton for handling the content model that this...
NameTableStore $slotRoleStore
processing should stop and the error should be shown to the user * false
Definition: hooks.txt:187
storeContentBlob(SlotRecord $slot, Title $title, array $blobHints=[])
NameTableStore $contentModelStore
null means default in associative array with keys and values unescaped Should be merged with default with a value of false meaning to suppress the attribute in associative array with keys and values unescaped noclasses & $ret
Definition: hooks.txt:1982
getRevisionRowCacheKey(IDatabase $db, $pageId, $revId)
Get a cache key for use with a row as selected with getQueryInfo( [ &#39;page&#39;, &#39;user&#39; ] ) Caching rows w...
Apache License January AND DISTRIBUTION Definitions License shall mean the terms and conditions for use
userWasLastToEdit(IDatabase $db, $pageId, $userId, $since)
Check if no edits were made by other users since the time a user started editing the page...
div flags Integer display flags(NO_ACTION_LINK, NO_EXTRA_USER_LINKS) 'LogException' returning false will NOT prevent logging $e
Definition: hooks.txt:2159
getKnownCurrentRevision(Title $title, $revId)
Load a revision based on a known page ID and current revision ID from the DB.
getBaseRevisionRow(IDatabase $dbw, RevisionRecord $rev, Title $title, $parentId)
failOnNull( $value, $name)
getRevisionByTitle(LinkTarget $linkTarget, $revId=0, $flags=0)
Load either the current, or a specified, revision that&#39;s attached to a given link target...
static makeAddressFromTextId( $id)
Returns an address referring to content stored in the text table row with the given ID...
setLogger(LoggerInterface $logger)
static mapArchiveFields( $archiveRow)
Maps fields of the archive row to corresponding revision rows.
$sort
static getDefaultModelFor(Title $title)
Returns the name of the default content model to be used for the page with the given title...
A registry service for SlotRoleHandlers, used to define which slot roles are available on which page...
$value
assertCrossWikiContentLoadingIsSafe()
Throws a RevisionAccessException if this RevisionStore is configured for cross-wiki loading and still...
const SHA1_HINT
Hint key for use with storeBlob, providing the SHA1 hash of the blob as passed to the method...
Definition: BlobStore.php:70
const SCHEMA_COMPAT_READ_NEW
Definition: Defines.php:283
const PAGE_HINT
Hint key for use with storeBlob, indicating the page the blob is associated with. ...
Definition: BlobStore.php:46
getQueryInfo( $options=[])
Return the tables, fields, and join conditions to be selected to create a new RevisionStoreRecord obj...
newRevisionFromRow( $row, $queryFlags=0, Title $title=null, $fromCache=false)
Exception throw when trying to access undefined fields on an incomplete RevisionRecord.
Value object representing a content slot associated with a page revision.
Definition: SlotRecord.php:39
getDBConnectionRefForQueryFlags( $queryFlags)
static newFromRow( $row)
Make a Title object from a DB row.
Definition: Title.php:519
CommentStore $commentStore
static newUnsavedComment( $comment, array $data=null)
Create a new, unsaved CommentStoreComment.
const DB_MASTER
Definition: defines.php:26
static getDBOptions( $bitfield)
Get an appropriate DB index, options, and fallback DB index for a query.
getName()
Get the user name, or the IP of an anonymous user.
Definition: User.php:2311
getDBConnection( $mode, $groups=[])
getTimestampFromId( $id, $flags=0)
Get rev_timestamp from rev_id, without loading the rest of the row.
getContent()
Returns the Content of the given slot.
Definition: SlotRecord.php:302
static newFromAnyId( $userId, $userName, $actorId, $wikiId=false)
Static factory method for creation from an ID, name, and/or actor ID.
Definition: User.php:686
getModel()
Returns the content model.
Definition: SlotRecord.php:561
Mutable RevisionRecord implementation, for building new revision entries programmatically.
countRevisionsByPageId(IDatabase $db, $id)
Get count of revisions per page...not very efficient.
The User object encapsulates all of the user-specific settings (user_id, name, rights, email address, options, last login time).
Definition: User.php:51
this hook is for auditing only or null if authentication failed before getting that far or null if we can t even determine that When $user is not it can be in the form of< username >< more info > e g for bot passwords intended to be added to log contexts Fields it might only if the login was with a bot password it is not rendered in wiki pages or galleries in category pages allow injecting custom HTML after the section Any uses of the hook need to handle escaping see BaseTemplate::getToolbox and BaseTemplate::makeListItem for details on the format of individual items inside of this array or by returning and letting standard HTTP rendering take place modifiable or by returning false and taking over the output modifiable modifiable after all normalizations have been except for the $wgMaxImageArea check set to true or false to override the $wgMaxImageArea check result gives extension the possibility to transform it themselves $handler
Definition: hooks.txt:780
Created by PhpStorm.
you have access to all of the normal MediaWiki so you can get a DB use the cache
Definition: maintenance.txt:52
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
newMutableRevisionFromArray(array $fields, $queryFlags=0, Title $title=null)
Constructs a new MutableRevisionRecord based on the given associative array following the MW1...
getRevisionById( $id, $flags=0)
Load a page revision from a given revision ID number.
setSize( $size)
Set nominal revision size, for optimization.
emulateMainSlot_1_29( $row, $queryFlags, Title $title)
Constructs a RevisionRecord for the revisions main slot, based on the MW1.29 schema.
getPageAsLinkTarget()
Returns the title of the page this revision is associated with as a LinkTarget object.
newNullRevision(IDatabase $dbw, Title $title, CommentStoreComment $comment, $minor, User $user)
Create a new null-revision for insertion into a page&#39;s history.
static getForModelID( $modelId)
Returns the ContentHandler singleton for the given model ID.
getVisibility()
Get the deletion bitfield of the revision.
const PARENT_HINT
Hint key for use with storeBlob, indicating the parent revision of the revision the blob is associate...
Definition: BlobStore.php:64
getSlotRoles()
Returns the slot names (roles) of all slots present in this revision.
hasOrigin()
Whether this slot has an origin (revision ID that originated the slot&#39;s content.
Definition: SlotRecord.php:446
setComment(CommentStoreComment $comment)
loadRevisionFromId(IDatabase $db, $id)
Load a page revision from a given revision ID number.
insertSlotRowOn(SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId)
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&#39;s attached to a given page ID...
Service for looking up page revisions.
const SCHEMA_COMPAT_WRITE_NEW
Definition: Defines.php:282
$res
Definition: database.txt:21
insertRevisionOn(RevisionRecord $rev, IDatabase $dbw)
Insert a new revision into the database, returning the new revision record on success and dies horrib...
fetchRevisionRowFromConds(IDatabase $db, $conditions, $flags=0)
Given a set of conditions, return a row with the fields necessary to build RevisionRecord objects...
const ROLE_HINT
Hint key for use with storeBlob, indicating the slot the blob is associated with. ...
Definition: BlobStore.php:52
newRevisionFromArchiveRow( $row, $queryFlags=0, Title $title=null, array $overrides=[])
Make a fake revision object from an archive table row.
static isValid( $ip)
Validate an IP address.
Definition: IP.php:111
const GAID_FOR_UPDATE
Used to be GAID_FOR_UPDATE define.
Definition: Title.php:55
getRelativeRevision(RevisionRecord $rev, $flags, $dir)
Implementation of getPreviousRevision and getNextRevision.
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
SlotRoleRegistry $slotRoleRegistry
null means default in associative array with keys and values unescaped Should be merged with default with a value of false meaning to suppress the attribute in associative array with keys and values unescaped & $options
Definition: hooks.txt:1982
getRcIdIfUnpatrolled(RevisionRecord $rev)
MCR migration note: this replaces Revision::isUnpatrolled.
insertRevisionRowOn(IDatabase $dbw, RevisionRecord $rev, Title $title, $parentId)
this hook is for auditing only or null if authentication failed before getting that far or null if we can t even determine that When $user is not null
Definition: hooks.txt:780
namespace and then decline to actually register it file or subcat img or subcat $title
Definition: hooks.txt:925
Service for looking up page revisions.
insertIpChangesRow(IDatabase $dbw, User $user, RevisionRecord $rev, $revisionId)
Insert IP revision into ip_changes for use when querying for a range.
checkDatabaseWikiId(IDatabase $db)
Throws an exception if the given database connection does not belong to the wiki this RevisionStore i...
static hasFlags( $bitfield, $flags)
wfWikiID()
Get an ASCII string identifying this wiki This is used as a prefix in memcached keys.
isReadyForInsertion()
Returns whether this RevisionRecord is ready for insertion, that is, whether it contains all informat...
presenting them properly to the user as errors is done by the caller return true use this to change the list i e etc $rev
Definition: hooks.txt:1766
getNextRevision(RevisionRecord $rev, $flags=0)
Get the revision after $rev in the page&#39;s history, if any.
A RevisionRecord representing a revision of a deleted page persisted in the archive table...
static newFromConds( $conds, $fname=__METHOD__, $dbType=DB_REPLICA)
Find the first recent change matching some specific conditions.
newRevisionFromConds( $conditions, $flags=0, Title $title=null)
Given a set of conditions, fetch a revision.
hasContentId()
Whether this slot has a content ID.
Definition: SlotRecord.php:469
This document is intended to provide useful advice for parties seeking to redistribute MediaWiki to end users It s targeted particularly at maintainers for Linux since it s been observed that distribution packages of MediaWiki often break We ve consistently had to recommend that users seeking support use official tarballs instead of their distribution s and this often solves whatever problem the user is having It would be nice if this could such as
Definition: distributors.txt:9
static newFromParentRevision(RevisionRecord $parent)
Returns an incomplete MutableRevisionRecord which uses $parent as its parent revision, and inherits all slots form it.
const SCHEMA_COMPAT_WRITE_BOTH
Definition: Defines.php:284
if(defined( 'MW_SETUP_CALLBACK')) $fname
Customization point after all loading (constants, functions, classes, DefaultSettings, LocalSettings).
Definition: Setup.php:123
loadRevisionFromPageId(IDatabase $db, $pageid, $id=0)
Load either the current, or a specified, revision that&#39;s attached to a given page.
setSha1( $sha1)
Set revision hash, for optimization.
getOrigin()
Returns the revision ID of the revision that originated the slot&#39;s content.
Definition: SlotRecord.php:405
updateRevisionTextId(IDatabase $dbw, $revisionId, &$blobAddress)
countRevisionsByTitle(IDatabase $db, $title)
Get count of revisions per page...not very efficient.
__construct(ILoadBalancer $loadBalancer, SqlBlobStore $blobStore, WANObjectCache $cache, CommentStore $commentStore, NameTableStore $contentModelStore, NameTableStore $slotRoleStore, SlotRoleRegistry $slotRoleRegistry, $mcrMigrationStage, ActorMigration $actorMigration, $wikiId=false)
const PRC_UNPATROLLED
static newFromLinkTarget(LinkTarget $linkTarget, $forceClone='')
Returns a Title given a LinkTarget.
Definition: Title.php:271
insertRevisionInternal(RevisionRecord $rev, IDatabase $dbw, User $user, CommentStoreComment $comment, Title $title, $pageId, $parentId)
static makeTitle( $ns, $title, $fragment='', $interwiki='')
Create a new Title from a namespace index and a DB key.
Definition: Title.php:589
Service for constructing revision objects.
getRevisionByTimestamp( $title, $timestamp)
Load the revision for the given title with the given timestamp.
getSlot( $role, $audience=self::FOR_PUBLIC, User $user=null)
Returns meta-data for the given slot.
getId()
Get revision ID.
injection txt This is an overview of how MediaWiki makes use of dependency injection The design described here grew from the discussion of RFC T384 The term dependency this means that anything an object needs to operate should be injected from the the object itself should only know narrow no concrete implementation of the logic it relies on The requirement to inject everything typically results in an architecture that based on two main types of and essentially stateless service objects that use other service objects to operate on the value objects As of the beginning MediaWiki is only starting to use the DI approach Much of the code still relies on global state or direct resulting in a highly cyclical dependency which acts as the top level factory for services in MediaWiki which can be used to gain access to default instances of various services MediaWikiServices however also allows new services to be defined and default services to be redefined Services are defined or redefined by providing a callback the instantiator that will return a new instance of the service When it will create an instance of MediaWikiServices and populate it with the services defined in the files listed by thereby bootstrapping the DI framework Per $wgServiceWiringFiles lists includes ServiceWiring php
Definition: injection.txt:35
getRole()
Returns the role of the slot.
Definition: SlotRecord.php:489
getSha1()
Returns the content size.
Definition: SlotRecord.php:538
getId()
Get the user&#39;s ID.
Definition: User.php:2284
setId( $id)
Set the revision ID.
you have access to all of the normal MediaWiki so you can get a DB use the etc For full docs on the Maintenance class
Definition: maintenance.txt:52
getSha1()
Returns the base36 sha1 of this revision.
getContentId()
Returns the ID of the content meta data row associated with the slot.
Definition: SlotRecord.php:513
setContentHandlerUseDB( $contentHandlerUseDB)
getSize()
Returns the content size.
Definition: SlotRecord.php:522
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Throws a warning that $function is deprecated.
getTimestamp()
MCR migration note: this replaces Revision::getTimestamp.
$revQuery
getAddress()
Returns the address of this slot&#39;s content.
Definition: SlotRecord.php:499
listRevisionSizes(IDatabase $db, array $revIds)
Do a batched query for the sizes of a set of revisions.
Exception representing a failure to look up a revision.
ILoadBalancer $loadBalancer
initializeMutableRevisionFromArray(MutableRevisionRecord $record, array $fields)
getLatestRevID( $flags=0)
What is the page_latest field for this page?
Definition: Title.php:3092
loadRevisionFromTitle(IDatabase $db, $title, $id=0)
Load either the current, or a specified, revision that&#39;s attached to a given page.
findSlotContentId(IDatabase $db, $revId, $role)
Finds the ID of a content row for a given revision and slot role.
loadRevisionFromTimestamp(IDatabase $db, $title, $timestamp)
Load the revision for the given title with the given timestamp.
insertContentRowOn(SlotRecord $slot, IDatabase $dbw, $blobAddress)
getPreviousRevision(RevisionRecord $rev, $flags=0)
Get the revision before $rev in the page&#39;s history, if any.
static getCacheSetOptions(IDatabase $db1, IDatabase $db2=null)
Merge the result of getSessionLagStatus() for several DBs using the most pessimistic values to estima...
Definition: Database.php:4230
setUser(UserIdentity $user)
Sets the user identity associated with the revision.
Allows to change the fields on the form that will be generated $name
Definition: hooks.txt:271
const DESIGNATION_HINT
Hint key for use with storeBlob, indicating the general role the block takes in the application...
Definition: BlobStore.php:40
getSize()
Returns the nominal size of this revision, in bogo-bytes.
Page revision base class.
hasAddress()
Whether this slot has an address.
Definition: SlotRecord.php:435
const DB_REPLICA
Definition: defines.php:25
getUser( $audience=self::FOR_PUBLIC, User $user=null)
Fetch revision&#39;s author&#39;s user identity, if it&#39;s available to the specified audience.
wfBacktrace( $raw=null)
Get a debug backtrace as a string.
const SCHEMA_COMPAT_READ_OLD
Definition: Defines.php:281
Value object representing the set of slots belonging to a revision.
releaseDBConnection(IDatabase $connection)
getModel()
Returns the ID of the content model used by this Content object.
loadRevisionFromConds(IDatabase $db, $conditions, $flags=0, Title $title=null)
Given a set of conditions, fetch a revision from the given database connection.
loadSlotContent(SlotRecord $slot, $blobData=null, $blobFlags=null, $blobFormat=null, $queryFlags=0)
Loads a Content object based on a slot row.
getSlotsQueryInfo( $options=[])
Return the tables, fields, and join conditions to be selected to create a new SlotRecord.
getComment( $audience=self::FOR_PUBLIC, User $user=null)
Fetch revision comment, if it&#39;s available to the specified audience.
$content
Definition: pageupdater.txt:72
emulateContentId( $textId)
Provides a content ID to use with emulated SlotRecords in SCHEMA_COMPAT_OLD mode, based on the revisi...
int $mcrMigrationStage
An appropriate combination of SCHEMA_COMPAT_XXX flags.
checkContent(Content $content, Title $title, $role)
MCR migration note: this corresponds to Revision::checkContentModel.
const REVISION_HINT
Hint key for use with storeBlob, indicating the revision the blob is associated with.
Definition: BlobStore.php:58
getParentId()
Get parent revision ID (the original previous page revision).
getArchiveQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new RevisionArchiveRecord o...
LoggerInterface $logger
getDefaultFormat()
Convenience method that returns the default serialization format for the content model that this Cont...
return true to allow those checks to and false if checking is done & $user
Definition: hooks.txt:1473
insertSlotOn(IDatabase $dbw, $revisionId, SlotRecord $protoSlot, Title $title, array $blobHints=[])
getPageId()
Get the page ID.
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200
getPrefixedDBkey()
Get the prefixed database key form.
Definition: Title.php:1684
const FORMAT_HINT
Hint key for use with storeBlob, indicating the serialization format used to create the blob...
Definition: BlobStore.php:82