MediaWiki  master
RevisionStore.php
Go to the documentation of this file.
1 <?php
27 namespace MediaWiki\Revision;
28 
29 use ActorMigration;
30 use CommentStore;
32 use Content;
33 use ContentHandler;
35 use Hooks;
36 use IDBAccessObject;
38 use IP;
39 use LogicException;
48 use Message;
49 use MWException;
54 use RecentChange;
55 use Revision;
57 use StatusValue;
58 use stdClass;
59 use Title;
60 use Traversable;
61 use User;
62 use WANObjectCache;
69 
80  implements IDBAccessObject, RevisionFactory, RevisionLookup, LoggerAwareInterface {
81 
82  const ROW_CACHE_KEY = 'revision-row-1.29';
83 
87  private $blobStore;
88 
92  private $dbDomain;
93 
98  private $contentHandlerUseDB = true;
99 
103  private $loadBalancer;
104 
108  private $cache;
109 
113  private $commentStore;
114 
119 
123  private $logger;
124 
129 
133  private $slotRoleStore;
134 
137 
140 
160  public function __construct(
161  ILoadBalancer $loadBalancer,
162  SqlBlobStore $blobStore,
165  NameTableStore $contentModelStore,
166  NameTableStore $slotRoleStore,
170  $dbDomain = false
171  ) {
172  Assert::parameterType( 'string|boolean', $dbDomain, '$dbDomain' );
173  Assert::parameterType( 'integer', $mcrMigrationStage, '$mcrMigrationStage' );
174  Assert::parameter(
175  ( $mcrMigrationStage & SCHEMA_COMPAT_READ_BOTH ) !== SCHEMA_COMPAT_READ_BOTH,
176  '$mcrMigrationStage',
177  'Reading from the old and the new schema at the same time is not supported.'
178  );
179  Assert::parameter(
180  ( $mcrMigrationStage & SCHEMA_COMPAT_READ_BOTH ) !== 0,
181  '$mcrMigrationStage',
182  'Reading needs to be enabled for the old or the new schema.'
183  );
184  Assert::parameter(
186  '$mcrMigrationStage',
187  'Writing needs to be enabled for the new schema.'
188  );
189  Assert::parameter(
192  '$mcrMigrationStage',
193  'Cannot read the old schema when not also writing it.'
194  );
195 
196  $this->loadBalancer = $loadBalancer;
197  $this->blobStore = $blobStore;
198  $this->cache = $cache;
199  $this->commentStore = $commentStore;
200  $this->contentModelStore = $contentModelStore;
201  $this->slotRoleStore = $slotRoleStore;
202  $this->slotRoleRegistry = $slotRoleRegistry;
203  $this->mcrMigrationStage = $mcrMigrationStage;
204  $this->actorMigration = $actorMigration;
205  $this->dbDomain = $dbDomain;
206  $this->logger = new NullLogger();
207  }
208 
214  private function hasMcrSchemaFlags( $flags ) {
215  return ( $this->mcrMigrationStage & $flags ) === $flags;
216  }
217 
225  if ( $this->dbDomain !== false && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
226  throw new RevisionAccessException(
227  "Cross-wiki content loading is not supported by the pre-MCR schema"
228  );
229  }
230  }
231 
232  public function setLogger( LoggerInterface $logger ) {
233  $this->logger = $logger;
234  }
235 
239  public function isReadOnly() {
240  return $this->blobStore->isReadOnly();
241  }
242 
246  public function getContentHandlerUseDB() {
248  }
249 
258  ) {
259  if ( !$contentHandlerUseDB ) {
260  throw new MWException(
261  'Content model must be stored in the database for multi content revision migration.'
262  );
263  }
264  }
265  $this->contentHandlerUseDB = $contentHandlerUseDB;
266  }
267 
271  private function getDBLoadBalancer() {
272  return $this->loadBalancer;
273  }
274 
280  private function getDBConnectionRefForQueryFlags( $queryFlags ) {
281  list( $mode, ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
282  return $this->getDBConnectionRef( $mode );
283  }
284 
291  private function getDBConnectionRef( $mode, $groups = [] ) {
292  $lb = $this->getDBLoadBalancer();
293  return $lb->getConnectionRef( $mode, $groups, $this->dbDomain );
294  }
295 
310  public function getTitle( $pageId, $revId, $queryFlags = self::READ_NORMAL ) {
311  if ( !$pageId && !$revId ) {
312  throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' );
313  }
314 
315  // This method recalls itself with READ_LATEST if READ_NORMAL doesn't get us a Title
316  // So ignore READ_LATEST_IMMUTABLE flags and handle the fallback logic in this method
317  if ( DBAccessObjectUtils::hasFlags( $queryFlags, self::READ_LATEST_IMMUTABLE ) ) {
318  $queryFlags = self::READ_NORMAL;
319  }
320 
321  $canUseTitleNewFromId = ( $pageId !== null && $pageId > 0 && $this->dbDomain === false );
322  list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
323 
324  // Loading by ID is best, but Title::newFromID does not support that for foreign IDs.
325  if ( $canUseTitleNewFromId ) {
326  $titleFlags = ( $dbMode == DB_MASTER ? Title::READ_LATEST : 0 );
327  // TODO: better foreign title handling (introduce TitleFactory)
328  $title = Title::newFromID( $pageId, $titleFlags );
329  if ( $title ) {
330  return $title;
331  }
332  }
333 
334  // rev_id is defined as NOT NULL, but this revision may not yet have been inserted.
335  $canUseRevId = ( $revId !== null && $revId > 0 );
336 
337  if ( $canUseRevId ) {
338  $dbr = $this->getDBConnectionRef( $dbMode );
339  // @todo: Title::getSelectFields(), or Title::getQueryInfo(), or something like that
340  $row = $dbr->selectRow(
341  [ 'revision', 'page' ],
342  [
343  'page_namespace',
344  'page_title',
345  'page_id',
346  'page_latest',
347  'page_is_redirect',
348  'page_len',
349  ],
350  [ 'rev_id' => $revId ],
351  __METHOD__,
352  $dbOptions,
353  [ 'page' => [ 'JOIN', 'page_id=rev_page' ] ]
354  );
355  if ( $row ) {
356  // TODO: better foreign title handling (introduce TitleFactory)
357  return Title::newFromRow( $row );
358  }
359  }
360 
361  // If we still don't have a title, fallback to master if that wasn't already happening.
362  if ( $dbMode !== DB_MASTER ) {
363  $title = $this->getTitle( $pageId, $revId, self::READ_LATEST );
364  if ( $title ) {
365  $this->logger->info(
366  __METHOD__ . ' fell back to READ_LATEST and got a Title.',
367  [ 'trace' => wfBacktrace() ]
368  );
369  return $title;
370  }
371  }
372 
373  throw new RevisionAccessException(
374  "Could not determine title for page ID $pageId and revision ID $revId"
375  );
376  }
377 
385  private function failOnNull( $value, $name ) {
386  if ( $value === null ) {
387  throw new IncompleteRevisionException(
388  "$name must not be " . var_export( $value, true ) . "!"
389  );
390  }
391 
392  return $value;
393  }
394 
402  private function failOnEmpty( $value, $name ) {
403  if ( $value === null || $value === 0 || $value === '' ) {
404  throw new IncompleteRevisionException(
405  "$name must not be " . var_export( $value, true ) . "!"
406  );
407  }
408 
409  return $value;
410  }
411 
424  public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) {
425  // TODO: pass in a DBTransactionContext instead of a database connection.
426  $this->checkDatabaseDomain( $dbw );
427 
428  $slotRoles = $rev->getSlotRoles();
429 
430  // Make sure the main slot is always provided throughout migration
431  if ( !in_array( SlotRecord::MAIN, $slotRoles ) ) {
432  throw new InvalidArgumentException(
433  'main slot must be provided'
434  );
435  }
436 
437  // If we are not writing into the new schema, we can't support extra slots.
439  && $slotRoles !== [ SlotRecord::MAIN ]
440  ) {
441  throw new InvalidArgumentException(
442  'Only the main slot is supported when not writing to the MCR enabled schema!'
443  );
444  }
445 
446  // As long as we are not reading from the new schema, we don't want to write extra slots.
448  && $slotRoles !== [ SlotRecord::MAIN ]
449  ) {
450  throw new InvalidArgumentException(
451  'Only the main slot is supported when not reading from the MCR enabled schema!'
452  );
453  }
454 
455  // Checks
456  $this->failOnNull( $rev->getSize(), 'size field' );
457  $this->failOnEmpty( $rev->getSha1(), 'sha1 field' );
458  $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' );
459  $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' );
460  $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' );
461  $this->failOnNull( $user->getId(), 'user field' );
462  $this->failOnEmpty( $user->getName(), 'user_text field' );
463 
464  if ( !$rev->isReadyForInsertion() ) {
465  // This is here for future-proofing. At the time this check being added, it
466  // was redundant to the individual checks above.
467  throw new IncompleteRevisionException( 'Revision is incomplete' );
468  }
469 
470  // TODO: we shouldn't need an actual Title here.
472  $pageId = $this->failOnEmpty( $rev->getPageId(), 'rev_page field' ); // check this early
473 
474  $parentId = $rev->getParentId() === null
475  ? $this->getPreviousRevisionId( $dbw, $rev )
476  : $rev->getParentId();
477 
479  $rev = $dbw->doAtomicSection(
480  __METHOD__,
481  function ( IDatabase $dbw, $fname ) use (
482  $rev,
483  $user,
484  $comment,
485  $title,
486  $pageId,
487  $parentId
488  ) {
489  return $this->insertRevisionInternal(
490  $rev,
491  $dbw,
492  $user,
493  $comment,
494  $title,
495  $pageId,
496  $parentId
497  );
498  }
499  );
500 
501  // sanity checks
502  Assert::postcondition( $rev->getId() > 0, 'revision must have an ID' );
503  Assert::postcondition( $rev->getPageId() > 0, 'revision must have a page ID' );
504  Assert::postcondition(
505  $rev->getComment( RevisionRecord::RAW ) !== null,
506  'revision must have a comment'
507  );
508  Assert::postcondition(
509  $rev->getUser( RevisionRecord::RAW ) !== null,
510  'revision must have a user'
511  );
512 
513  // Trigger exception if the main slot is missing.
514  // Technically, this could go away after MCR migration: while
515  // calling code may require a main slot to exist, RevisionStore
516  // really should not know or care about that requirement.
518 
519  foreach ( $slotRoles as $role ) {
520  $slot = $rev->getSlot( $role, RevisionRecord::RAW );
521  Assert::postcondition(
522  $slot->getContent() !== null,
523  $role . ' slot must have content'
524  );
525  Assert::postcondition(
526  $slot->hasRevision(),
527  $role . ' slot must have a revision associated'
528  );
529  }
530 
531  Hooks::run( 'RevisionRecordInserted', [ $rev ] );
532 
533  // TODO: deprecate in 1.32!
534  $legacyRevision = new Revision( $rev );
535  Hooks::run( 'RevisionInsertComplete', [ &$legacyRevision, null, null ] );
536 
537  return $rev;
538  }
539 
540  private function insertRevisionInternal(
541  RevisionRecord $rev,
542  IDatabase $dbw,
543  User $user,
544  CommentStoreComment $comment,
545  Title $title,
546  $pageId,
547  $parentId
548  ) {
549  $slotRoles = $rev->getSlotRoles();
550 
551  $revisionRow = $this->insertRevisionRowOn(
552  $dbw,
553  $rev,
554  $title,
555  $parentId
556  );
557 
558  $revisionId = $revisionRow['rev_id'];
559 
560  $blobHints = [
561  BlobStore::PAGE_HINT => $pageId,
562  BlobStore::REVISION_HINT => $revisionId,
563  BlobStore::PARENT_HINT => $parentId,
564  ];
565 
566  $newSlots = [];
567  foreach ( $slotRoles as $role ) {
568  $slot = $rev->getSlot( $role, RevisionRecord::RAW );
569 
570  // If the SlotRecord already has a revision ID set, this means it already exists
571  // in the database, and should already belong to the current revision.
572  // However, a slot may already have a revision, but no content ID, if the slot
573  // is emulated based on the archive table, because we are in SCHEMA_COMPAT_READ_OLD
574  // mode, and the respective archive row was not yet migrated to the new schema.
575  // In that case, a new slot row (and content row) must be inserted even during
576  // undeletion.
577  if ( $slot->hasRevision() && $slot->hasContentId() ) {
578  // TODO: properly abort transaction if the assertion fails!
579  Assert::parameter(
580  $slot->getRevision() === $revisionId,
581  'slot role ' . $slot->getRole(),
582  'Existing slot should belong to revision '
583  . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!'
584  );
585 
586  // Slot exists, nothing to do, move along.
587  // This happens when restoring archived revisions.
588 
589  $newSlots[$role] = $slot;
590 
591  // Write the main slot's text ID to the revision table for backwards compatibility
592  if ( $slot->getRole() === SlotRecord::MAIN
594  ) {
595  $blobAddress = $slot->getAddress();
596  $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress );
597  }
598  } else {
599  $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $title, $blobHints );
600  }
601  }
602 
603  $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId );
604 
605  $rev = new RevisionStoreRecord(
606  $title,
607  $user,
608  $comment,
609  (object)$revisionRow,
610  new RevisionSlots( $newSlots ),
611  $this->dbDomain
612  );
613 
614  return $rev;
615  }
616 
624  private function updateRevisionTextId( IDatabase $dbw, $revisionId, &$blobAddress ) {
625  $textId = $this->blobStore->getTextIdFromAddress( $blobAddress );
626  if ( !$textId ) {
627  throw new LogicException(
628  'Blob address not supported in 1.29 database schema: ' . $blobAddress
629  );
630  }
631 
632  // getTextIdFromAddress() is free to insert something into the text table, so $textId
633  // may be a new value, not anything already contained in $blobAddress.
634  $blobAddress = SqlBlobStore::makeAddressFromTextId( $textId );
635 
636  $dbw->update(
637  'revision',
638  [ 'rev_text_id' => $textId ],
639  [ 'rev_id' => $revisionId ],
640  __METHOD__
641  );
642 
643  return $textId;
644  }
645 
654  private function insertSlotOn(
655  IDatabase $dbw,
656  $revisionId,
657  SlotRecord $protoSlot,
658  Title $title,
659  array $blobHints = []
660  ) {
661  if ( $protoSlot->hasAddress() ) {
662  $blobAddress = $protoSlot->getAddress();
663  } else {
664  $blobAddress = $this->storeContentBlob( $protoSlot, $title, $blobHints );
665  }
666 
667  $contentId = null;
668 
669  // Write the main slot's text ID to the revision table for backwards compatibility
670  if ( $protoSlot->getRole() === SlotRecord::MAIN
672  ) {
673  // If SCHEMA_COMPAT_WRITE_NEW is also set, the fake content ID is overwritten
674  // with the real content ID below.
675  $textId = $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress );
676  $contentId = $this->emulateContentId( $textId );
677  }
678 
679  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
680  if ( $protoSlot->hasContentId() ) {
681  $contentId = $protoSlot->getContentId();
682  } else {
683  $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress );
684  }
685 
686  $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId );
687  }
688 
689  $savedSlot = SlotRecord::newSaved(
690  $revisionId,
691  $contentId,
692  $blobAddress,
693  $protoSlot
694  );
695 
696  return $savedSlot;
697  }
698 
706  private function insertIpChangesRow(
707  IDatabase $dbw,
708  User $user,
709  RevisionRecord $rev,
710  $revisionId
711  ) {
712  if ( $user->getId() === 0 && IP::isValid( $user->getName() ) ) {
713  $ipcRow = [
714  'ipc_rev_id' => $revisionId,
715  'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
716  'ipc_hex' => IP::toHex( $user->getName() ),
717  ];
718  $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ );
719  }
720  }
721 
733  private function insertRevisionRowOn(
734  IDatabase $dbw,
735  RevisionRecord $rev,
736  Title $title,
737  $parentId
738  ) {
739  $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $title, $parentId );
740 
741  list( $commentFields, $commentCallback ) =
742  $this->commentStore->insertWithTempTable(
743  $dbw,
744  'rev_comment',
746  );
747  $revisionRow += $commentFields;
748 
749  list( $actorFields, $actorCallback ) =
750  $this->actorMigration->getInsertValuesWithTempTable(
751  $dbw,
752  'rev_user',
754  );
755  $revisionRow += $actorFields;
756 
757  $dbw->insert( 'revision', $revisionRow, __METHOD__ );
758 
759  if ( !isset( $revisionRow['rev_id'] ) ) {
760  // only if auto-increment was used
761  $revisionRow['rev_id'] = intval( $dbw->insertId() );
762 
763  if ( $dbw->getType() === 'mysql' ) {
764  // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the
765  // auto-increment value to disk, so on server restart it might reuse IDs from deleted
766  // revisions. We can fix that with an insert with an explicit rev_id value, if necessary.
767 
768  $maxRevId = intval( $dbw->selectField( 'archive', 'MAX(ar_rev_id)', '', __METHOD__ ) );
769  $table = 'archive';
770  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
771  $maxRevId2 = intval( $dbw->selectField( 'slots', 'MAX(slot_revision_id)', '', __METHOD__ ) );
772  if ( $maxRevId2 >= $maxRevId ) {
773  $maxRevId = $maxRevId2;
774  $table = 'slots';
775  }
776  }
777 
778  if ( $maxRevId >= $revisionRow['rev_id'] ) {
779  $this->logger->debug(
780  '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.'
781  . ' Trying to fix it.',
782  [
783  'revid' => $revisionRow['rev_id'],
784  'table' => $table,
785  'maxrevid' => $maxRevId,
786  ]
787  );
788 
789  if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) {
790  throw new MWException( 'Failed to get database lock for T202032' );
791  }
792  $fname = __METHOD__;
793  $dbw->onTransactionResolution(
794  function ( $trigger, IDatabase $dbw ) use ( $fname ) {
795  $dbw->unlock( 'fix-for-T202032', $fname );
796  }
797  );
798 
799  $dbw->delete( 'revision', [ 'rev_id' => $revisionRow['rev_id'] ], __METHOD__ );
800 
801  // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction
802  // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing
803  // inserts too, though, at least on MariaDB 10.1.29.
804  //
805  // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent
806  // transactions in this code path thanks to the row lock from the original ->insert() above.
807  //
808  // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning
809  // that's for non-MySQL DBs.
810  $row1 = $dbw->query(
811  $dbw->selectSQLText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE'
812  )->fetchObject();
813  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
814  $row2 = $dbw->query(
815  $dbw->selectSQLText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ )
816  . ' FOR UPDATE'
817  )->fetchObject();
818  } else {
819  $row2 = null;
820  }
821  $maxRevId = max(
822  $maxRevId,
823  $row1 ? intval( $row1->v ) : 0,
824  $row2 ? intval( $row2->v ) : 0
825  );
826 
827  // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent
828  // transactions will throw a duplicate key error here. It doesn't seem worth trying
829  // to avoid that.
830  $revisionRow['rev_id'] = $maxRevId + 1;
831  $dbw->insert( 'revision', $revisionRow, __METHOD__ );
832  }
833  }
834  }
835 
836  $commentCallback( $revisionRow['rev_id'] );
837  $actorCallback( $revisionRow['rev_id'], $revisionRow );
838 
839  return $revisionRow;
840  }
841 
852  private function getBaseRevisionRow(
853  IDatabase $dbw,
854  RevisionRecord $rev,
855  Title $title,
856  $parentId
857  ) {
858  // Record the edit in revisions
859  $revisionRow = [
860  'rev_page' => $rev->getPageId(),
861  'rev_parent_id' => $parentId,
862  'rev_minor_edit' => $rev->isMinor() ? 1 : 0,
863  'rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
864  'rev_deleted' => $rev->getVisibility(),
865  'rev_len' => $rev->getSize(),
866  'rev_sha1' => $rev->getSha1(),
867  ];
868 
869  if ( $rev->getId() !== null ) {
870  // Needed to restore revisions with their original ID
871  $revisionRow['rev_id'] = $rev->getId();
872  }
873 
874  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) ) {
875  // In non MCR mode this IF section will relate to the main slot
876  $mainSlot = $rev->getSlot( SlotRecord::MAIN );
877  $model = $mainSlot->getModel();
878  $format = $mainSlot->getFormat();
879 
880  // MCR migration note: rev_content_model and rev_content_format will go away
881  if ( $this->contentHandlerUseDB ) {
883 
884  $defaultModel = ContentHandler::getDefaultModelFor( $title );
885  $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat();
886 
887  $revisionRow['rev_content_model'] = ( $model === $defaultModel ) ? null : $model;
888  $revisionRow['rev_content_format'] = ( $format === $defaultFormat ) ? null : $format;
889  }
890  }
891 
892  return $revisionRow;
893  }
894 
903  private function storeContentBlob(
904  SlotRecord $slot,
905  Title $title,
906  array $blobHints = []
907  ) {
908  $content = $slot->getContent();
909  $format = $content->getDefaultFormat();
910  $model = $content->getModel();
911 
912  $this->checkContent( $content, $title, $slot->getRole() );
913 
914  return $this->blobStore->storeBlob(
915  $content->serialize( $format ),
916  // These hints "leak" some information from the higher abstraction layer to
917  // low level storage to allow for optimization.
918  array_merge(
919  $blobHints,
920  [
921  BlobStore::DESIGNATION_HINT => 'page-content',
922  BlobStore::ROLE_HINT => $slot->getRole(),
923  BlobStore::SHA1_HINT => $slot->getSha1(),
924  BlobStore::MODEL_HINT => $model,
925  BlobStore::FORMAT_HINT => $format,
926  ]
927  )
928  );
929  }
930 
937  private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) {
938  $slotRow = [
939  'slot_revision_id' => $revisionId,
940  'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ),
941  'slot_content_id' => $contentId,
942  // If the slot has a specific origin use that ID, otherwise use the ID of the revision
943  // that we just inserted.
944  'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId,
945  ];
946  $dbw->insert( 'slots', $slotRow, __METHOD__ );
947  }
948 
955  private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) {
956  $contentRow = [
957  'content_size' => $slot->getSize(),
958  'content_sha1' => $slot->getSha1(),
959  'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ),
960  'content_address' => $blobAddress,
961  ];
962  $dbw->insert( 'content', $contentRow, __METHOD__ );
963  return intval( $dbw->insertId() );
964  }
965 
976  private function checkContent( Content $content, Title $title, $role ) {
977  // Note: may return null for revisions that have not yet been inserted
978 
979  $model = $content->getModel();
980  $format = $content->getDefaultFormat();
981  $handler = $content->getContentHandler();
982 
983  $name = "$title";
984 
985  if ( !$handler->isSupportedFormat( $format ) ) {
986  throw new MWException( "Can't use format $format with content model $model on $name" );
987  }
988 
989  if ( !$this->contentHandlerUseDB ) {
990  // if $wgContentHandlerUseDB is not set,
991  // all revisions must use the default content model and format.
992 
994 
995  $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
996  $defaultModel = $roleHandler->getDefaultModel( $title );
997  $defaultHandler = ContentHandler::getForModelID( $defaultModel );
998  $defaultFormat = $defaultHandler->getDefaultFormat();
999 
1000  if ( $model != $defaultModel ) {
1001  throw new MWException( "Can't save non-default content model with "
1002  . "\$wgContentHandlerUseDB disabled: model is $model, "
1003  . "default for $name is $defaultModel"
1004  );
1005  }
1006 
1007  if ( $format != $defaultFormat ) {
1008  throw new MWException( "Can't use non-default content format with "
1009  . "\$wgContentHandlerUseDB disabled: format is $format, "
1010  . "default for $name is $defaultFormat"
1011  );
1012  }
1013  }
1014 
1015  if ( !$content->isValid() ) {
1016  throw new MWException(
1017  "New content for $name is not valid! Content model is $model"
1018  );
1019  }
1020  }
1021 
1047  public function newNullRevision(
1048  IDatabase $dbw,
1049  Title $title,
1050  CommentStoreComment $comment,
1051  $minor,
1052  User $user
1053  ) {
1054  $this->checkDatabaseDomain( $dbw );
1055 
1056  $pageId = $title->getArticleID();
1057 
1058  // T51581: Lock the page table row to ensure no other process
1059  // is adding a revision to the page at the same time.
1060  // Avoid locking extra tables, compare T191892.
1061  $pageLatest = $dbw->selectField(
1062  'page',
1063  'page_latest',
1064  [ 'page_id' => $pageId ],
1065  __METHOD__,
1066  [ 'FOR UPDATE' ]
1067  );
1068 
1069  if ( !$pageLatest ) {
1070  return null;
1071  }
1072 
1073  // Fetch the actual revision row from master, without locking all extra tables.
1074  $oldRevision = $this->loadRevisionFromConds(
1075  $dbw,
1076  [ 'rev_id' => intval( $pageLatest ) ],
1077  self::READ_LATEST,
1078  $title
1079  );
1080 
1081  if ( !$oldRevision ) {
1082  $msg = "Failed to load latest revision ID $pageLatest of page ID $pageId.";
1083  $this->logger->error(
1084  $msg,
1085  [ 'exception' => new RuntimeException( $msg ) ]
1086  );
1087  return null;
1088  }
1089 
1090  // Construct the new revision
1091  $timestamp = wfTimestampNow(); // TODO: use a callback, so we can override it for testing.
1092  $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision );
1093 
1094  $newRevision->setComment( $comment );
1095  $newRevision->setUser( $user );
1096  $newRevision->setTimestamp( $timestamp );
1097  $newRevision->setMinorEdit( $minor );
1098 
1099  return $newRevision;
1100  }
1101 
1111  public function getRcIdIfUnpatrolled( RevisionRecord $rev ) {
1112  $rc = $this->getRecentChange( $rev );
1113  if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) {
1114  return $rc->getAttribute( 'rc_id' );
1115  } else {
1116  return 0;
1117  }
1118  }
1119 
1133  public function getRecentChange( RevisionRecord $rev, $flags = 0 ) {
1134  list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags );
1135 
1137  [ 'rc_this_oldid' => $rev->getId() ],
1138  __METHOD__,
1139  $dbType
1140  );
1141 
1142  // XXX: cache this locally? Glue it to the RevisionRecord?
1143  return $rc;
1144  }
1145 
1153  private static function mapArchiveFields( $archiveRow ) {
1154  $fieldMap = [
1155  // keep with ar prefix:
1156  'ar_id' => 'ar_id',
1157 
1158  // not the same suffix:
1159  'ar_page_id' => 'rev_page',
1160  'ar_rev_id' => 'rev_id',
1161 
1162  // same suffix:
1163  'ar_text_id' => 'rev_text_id',
1164  'ar_timestamp' => 'rev_timestamp',
1165  'ar_user_text' => 'rev_user_text',
1166  'ar_user' => 'rev_user',
1167  'ar_actor' => 'rev_actor',
1168  'ar_minor_edit' => 'rev_minor_edit',
1169  'ar_deleted' => 'rev_deleted',
1170  'ar_len' => 'rev_len',
1171  'ar_parent_id' => 'rev_parent_id',
1172  'ar_sha1' => 'rev_sha1',
1173  'ar_comment' => 'rev_comment',
1174  'ar_comment_cid' => 'rev_comment_cid',
1175  'ar_comment_id' => 'rev_comment_id',
1176  'ar_comment_text' => 'rev_comment_text',
1177  'ar_comment_data' => 'rev_comment_data',
1178  'ar_comment_old' => 'rev_comment_old',
1179  'ar_content_format' => 'rev_content_format',
1180  'ar_content_model' => 'rev_content_model',
1181  ];
1182 
1183  $revRow = new stdClass();
1184  foreach ( $fieldMap as $arKey => $revKey ) {
1185  if ( property_exists( $archiveRow, $arKey ) ) {
1186  $revRow->$revKey = $archiveRow->$arKey;
1187  }
1188  }
1189 
1190  return $revRow;
1191  }
1192 
1203  private function emulateMainSlot_1_29( $row, $queryFlags, Title $title ) {
1204  $mainSlotRow = new stdClass();
1205  $mainSlotRow->role_name = SlotRecord::MAIN;
1206  $mainSlotRow->model_name = null;
1207  $mainSlotRow->slot_revision_id = null;
1208  $mainSlotRow->slot_content_id = null;
1209  $mainSlotRow->content_address = null;
1210 
1211  $content = null;
1212  $blobData = null;
1213  $blobFlags = null;
1214 
1215  if ( is_object( $row ) ) {
1216  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) {
1217  // Don't emulate from a row when using the new schema.
1218  // Emulating from an array is still OK.
1219  throw new LogicException( 'Can\'t emulate the main slot when using MCR schema.' );
1220  }
1221 
1222  // archive row
1223  if ( !isset( $row->rev_id ) && ( isset( $row->ar_user ) || isset( $row->ar_actor ) ) ) {
1224  $row = $this->mapArchiveFields( $row );
1225  }
1226 
1227  if ( isset( $row->rev_text_id ) && $row->rev_text_id > 0 ) {
1228  $mainSlotRow->content_address = SqlBlobStore::makeAddressFromTextId(
1229  $row->rev_text_id
1230  );
1231  }
1232 
1233  // This is used by null-revisions
1234  $mainSlotRow->slot_origin = isset( $row->slot_origin )
1235  ? intval( $row->slot_origin )
1236  : null;
1237 
1238  if ( isset( $row->old_text ) ) {
1239  // this happens when the text-table gets joined directly, in the pre-1.30 schema
1240  $blobData = isset( $row->old_text ) ? strval( $row->old_text ) : null;
1241  // Check against selects that might have not included old_flags
1242  if ( !property_exists( $row, 'old_flags' ) ) {
1243  throw new InvalidArgumentException( 'old_flags was not set in $row' );
1244  }
1245  $blobFlags = $row->old_flags ?? '';
1246  }
1247 
1248  $mainSlotRow->slot_revision_id = intval( $row->rev_id );
1249 
1250  $mainSlotRow->content_size = isset( $row->rev_len ) ? intval( $row->rev_len ) : null;
1251  $mainSlotRow->content_sha1 = isset( $row->rev_sha1 ) ? strval( $row->rev_sha1 ) : null;
1252  $mainSlotRow->model_name = isset( $row->rev_content_model )
1253  ? strval( $row->rev_content_model )
1254  : null;
1255  // XXX: in the future, we'll probably always use the default format, and drop content_format
1256  $mainSlotRow->format_name = isset( $row->rev_content_format )
1257  ? strval( $row->rev_content_format )
1258  : null;
1259 
1260  if ( isset( $row->rev_text_id ) && intval( $row->rev_text_id ) > 0 ) {
1261  // Overwritten below for SCHEMA_COMPAT_WRITE_NEW
1262  $mainSlotRow->slot_content_id
1263  = $this->emulateContentId( intval( $row->rev_text_id ) );
1264  }
1265  } elseif ( is_array( $row ) ) {
1266  $mainSlotRow->slot_revision_id = isset( $row['id'] ) ? intval( $row['id'] ) : null;
1267 
1268  $mainSlotRow->slot_origin = isset( $row['slot_origin'] )
1269  ? intval( $row['slot_origin'] )
1270  : null;
1271  $mainSlotRow->content_address = isset( $row['text_id'] )
1272  ? SqlBlobStore::makeAddressFromTextId( intval( $row['text_id'] ) )
1273  : null;
1274  $mainSlotRow->content_size = isset( $row['len'] ) ? intval( $row['len'] ) : null;
1275  $mainSlotRow->content_sha1 = isset( $row['sha1'] ) ? strval( $row['sha1'] ) : null;
1276 
1277  $mainSlotRow->model_name = isset( $row['content_model'] )
1278  ? strval( $row['content_model'] ) : null; // XXX: must be a string!
1279  // XXX: in the future, we'll probably always use the default format, and drop content_format
1280  $mainSlotRow->format_name = isset( $row['content_format'] )
1281  ? strval( $row['content_format'] ) : null;
1282  $blobData = isset( $row['text'] ) ? rtrim( strval( $row['text'] ) ) : null;
1283  // XXX: If the flags field is not set then $blobFlags should be null so that no
1284  // decoding will happen. An empty string will result in default decodings.
1285  $blobFlags = isset( $row['flags'] ) ? trim( strval( $row['flags'] ) ) : null;
1286 
1287  // if we have a Content object, override mText and mContentModel
1288  if ( !empty( $row['content'] ) ) {
1289  if ( !( $row['content'] instanceof Content ) ) {
1290  throw new MWException( 'content field must contain a Content object.' );
1291  }
1292 
1294  $content = $row['content'];
1295  $handler = $content->getContentHandler();
1296 
1297  $mainSlotRow->model_name = $content->getModel();
1298 
1299  // XXX: in the future, we'll probably always use the default format.
1300  if ( $mainSlotRow->format_name === null ) {
1301  $mainSlotRow->format_name = $handler->getDefaultFormat();
1302  }
1303  }
1304 
1305  if ( isset( $row['text_id'] ) && intval( $row['text_id'] ) > 0 ) {
1306  // Overwritten below for SCHEMA_COMPAT_WRITE_NEW
1307  $mainSlotRow->slot_content_id
1308  = $this->emulateContentId( intval( $row['text_id'] ) );
1309  }
1310  } else {
1311  throw new MWException( 'Revision constructor passed invalid row format.' );
1312  }
1313 
1314  // With the old schema, the content changes with every revision,
1315  // except for null-revisions.
1316  if ( !isset( $mainSlotRow->slot_origin ) ) {
1317  $mainSlotRow->slot_origin = $mainSlotRow->slot_revision_id;
1318  }
1319 
1320  if ( $mainSlotRow->model_name === null ) {
1321  $mainSlotRow->model_name = function ( SlotRecord $slot ) use ( $title ) {
1323 
1324  return $this->slotRoleRegistry->getRoleHandler( $slot->getRole() )
1325  ->getDefaultModel( $title );
1326  };
1327  }
1328 
1329  if ( !$content ) {
1330  // XXX: We should perhaps fail if $blobData is null and $mainSlotRow->content_address
1331  // is missing, but "empty revisions" with no content are used in some edge cases.
1332 
1333  $content = function ( SlotRecord $slot )
1334  use ( $blobData, $blobFlags, $queryFlags, $mainSlotRow )
1335  {
1336  return $this->loadSlotContent(
1337  $slot,
1338  $blobData,
1339  $blobFlags,
1340  $mainSlotRow->format_name,
1341  $queryFlags
1342  );
1343  };
1344  }
1345 
1346  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
1347  // NOTE: this callback will be looped through RevisionSlot::newInherited(), allowing
1348  // the inherited slot to have the same content_id as the original slot. In that case,
1349  // $slot will be the inherited slot, while $mainSlotRow still refers to the original slot.
1350  $mainSlotRow->slot_content_id =
1351  function ( SlotRecord $slot ) use ( $queryFlags, $mainSlotRow ) {
1352  $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1353  return $this->findSlotContentId( $db, $mainSlotRow->slot_revision_id, SlotRecord::MAIN );
1354  };
1355  }
1356 
1357  return new SlotRecord( $mainSlotRow, $content );
1358  }
1359 
1371  private function emulateContentId( $textId ) {
1372  // Return a negative number to ensure the ID is distinct from any real content IDs
1373  // that will be assigned in SCHEMA_COMPAT_WRITE_NEW mode and read in SCHEMA_COMPAT_READ_NEW
1374  // mode.
1375  return -$textId;
1376  }
1377 
1397  private function loadSlotContent(
1398  SlotRecord $slot,
1399  $blobData = null,
1400  $blobFlags = null,
1401  $blobFormat = null,
1402  $queryFlags = 0
1403  ) {
1404  if ( $blobData !== null ) {
1405  Assert::parameterType( 'string', $blobData, '$blobData' );
1406  Assert::parameterType( 'string|null', $blobFlags, '$blobFlags' );
1407 
1408  $cacheKey = $slot->hasAddress() ? $slot->getAddress() : null;
1409 
1410  if ( $blobFlags === null ) {
1411  // No blob flags, so use the blob verbatim.
1412  $data = $blobData;
1413  } else {
1414  $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $cacheKey );
1415  if ( $data === false ) {
1416  throw new RevisionAccessException(
1417  "Failed to expand blob data using flags $blobFlags (key: $cacheKey)"
1418  );
1419  }
1420  }
1421 
1422  } else {
1423  $address = $slot->getAddress();
1424  try {
1425  $data = $this->blobStore->getBlob( $address, $queryFlags );
1426  } catch ( BlobAccessException $e ) {
1427  throw new RevisionAccessException(
1428  "Failed to load data blob from $address: " . $e->getMessage(), 0, $e
1429  );
1430  }
1431  }
1432 
1433  // Unserialize content
1434  $handler = ContentHandler::getForModelID( $slot->getModel() );
1435 
1436  $content = $handler->unserializeContent( $data, $blobFormat );
1437  return $content;
1438  }
1439 
1454  public function getRevisionById( $id, $flags = 0 ) {
1455  return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags );
1456  }
1457 
1474  public function getRevisionByTitle( LinkTarget $linkTarget, $revId = 0, $flags = 0 ) {
1475  // TODO should not require Title in future (T206498)
1476  $title = Title::newFromLinkTarget( $linkTarget );
1477  $conds = [
1478  'page_namespace' => $title->getNamespace(),
1479  'page_title' => $title->getDBkey()
1480  ];
1481  if ( $revId ) {
1482  // Use the specified revision ID.
1483  // Note that we use newRevisionFromConds here because we want to retry
1484  // and fall back to master if the page is not found on a replica.
1485  // Since the caller supplied a revision ID, we are pretty sure the revision is
1486  // supposed to exist, so we should try hard to find it.
1487  $conds['rev_id'] = $revId;
1488  return $this->newRevisionFromConds( $conds, $flags, $title );
1489  } else {
1490  // Use a join to get the latest revision.
1491  // Note that we don't use newRevisionFromConds here because we don't want to retry
1492  // and fall back to master. The assumption is that we only want to force the fallback
1493  // if we are quite sure the revision exists because the caller supplied a revision ID.
1494  // If the page isn't found at all on a replica, it probably simply does not exist.
1495  $db = $this->getDBConnectionRefForQueryFlags( $flags );
1496 
1497  $conds[] = 'rev_id=page_latest';
1498  $rev = $this->loadRevisionFromConds( $db, $conds, $flags, $title );
1499 
1500  return $rev;
1501  }
1502  }
1503 
1520  public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) {
1521  $conds = [ 'page_id' => $pageId ];
1522  if ( $revId ) {
1523  // Use the specified revision ID.
1524  // Note that we use newRevisionFromConds here because we want to retry
1525  // and fall back to master if the page is not found on a replica.
1526  // Since the caller supplied a revision ID, we are pretty sure the revision is
1527  // supposed to exist, so we should try hard to find it.
1528  $conds['rev_id'] = $revId;
1529  return $this->newRevisionFromConds( $conds, $flags );
1530  } else {
1531  // Use a join to get the latest revision.
1532  // Note that we don't use newRevisionFromConds here because we don't want to retry
1533  // and fall back to master. The assumption is that we only want to force the fallback
1534  // if we are quite sure the revision exists because the caller supplied a revision ID.
1535  // If the page isn't found at all on a replica, it probably simply does not exist.
1536  $db = $this->getDBConnectionRefForQueryFlags( $flags );
1537 
1538  $conds[] = 'rev_id=page_latest';
1539  $rev = $this->loadRevisionFromConds( $db, $conds, $flags );
1540 
1541  return $rev;
1542  }
1543  }
1544 
1556  public function getRevisionByTimestamp( $title, $timestamp ) {
1557  $db = $this->getDBConnectionRef( DB_REPLICA );
1558  return $this->newRevisionFromConds(
1559  [
1560  'rev_timestamp' => $db->timestamp( $timestamp ),
1561  'page_namespace' => $title->getNamespace(),
1562  'page_title' => $title->getDBkey()
1563  ],
1564  0,
1565  $title
1566  );
1567  }
1568 
1576  private function loadSlotRecords( $revId, $queryFlags, Title $title ) {
1577  $revQuery = self::getSlotsQueryInfo( [ 'content' ] );
1578 
1579  list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
1580  $db = $this->getDBConnectionRef( $dbMode );
1581 
1582  $res = $db->select(
1583  $revQuery['tables'],
1584  $revQuery['fields'],
1585  [
1586  'slot_revision_id' => $revId,
1587  ],
1588  __METHOD__,
1589  $dbOptions,
1590  $revQuery['joins']
1591  );
1592 
1593  $slots = $this->constructSlotRecords( $revId, $res, $queryFlags, $title );
1594 
1595  return $slots;
1596  }
1597 
1610  private function constructSlotRecords(
1611  $revId,
1612  $slotRows,
1613  $queryFlags,
1614  Title $title,
1615  $slotContents = null
1616  ) {
1617  $slots = [];
1618 
1619  foreach ( $slotRows as $row ) {
1620  // Resolve role names and model names from in-memory cache, if they were not joined in.
1621  if ( !isset( $row->role_name ) ) {
1622  $row->role_name = $this->slotRoleStore->getName( (int)$row->slot_role_id );
1623  }
1624 
1625  if ( !isset( $row->model_name ) ) {
1626  if ( isset( $row->content_model ) ) {
1627  $row->model_name = $this->contentModelStore->getName( (int)$row->content_model );
1628  } else {
1629  // We may get here if $row->model_name is set but null, perhaps because it
1630  // came from rev_content_model, which is NULL for the default model.
1631  $slotRoleHandler = $this->slotRoleRegistry->getRoleHandler( $row->role_name );
1632  $row->model_name = $slotRoleHandler->getDefaultModel( $title );
1633  }
1634  }
1635 
1636  if ( !isset( $row->content_id ) && isset( $row->rev_text_id ) ) {
1637  $row->slot_content_id
1638  = $this->emulateContentId( intval( $row->rev_text_id ) );
1639  }
1640 
1641  // We may have a fake blob_data field from getSlotRowsForBatch(), use it!
1642  if ( isset( $row->blob_data ) ) {
1643  $slotContents[$row->content_address] = $row->blob_data;
1644  }
1645 
1646  $contentCallback = function ( SlotRecord $slot ) use ( $slotContents, $queryFlags ) {
1647  $blob = null;
1648  if ( isset( $slotContents[$slot->getAddress()] ) ) {
1649  $blob = $slotContents[$slot->getAddress()];
1650  if ( $blob instanceof Content ) {
1651  return $blob;
1652  }
1653  }
1654  return $this->loadSlotContent( $slot, $blob, null, null, $queryFlags );
1655  };
1656 
1657  $slots[$row->role_name] = new SlotRecord( $row, $contentCallback );
1658  }
1659 
1660  if ( !isset( $slots[SlotRecord::MAIN] ) ) {
1661  throw new RevisionAccessException(
1662  'Main slot of revision ' . $revId . ' not found in database!'
1663  );
1664  }
1665 
1666  return $slots;
1667  }
1668 
1684  private function newRevisionSlots(
1685  $revId,
1686  $revisionRow,
1687  $slotRows,
1688  $queryFlags,
1689  Title $title
1690  ) {
1691  if ( $slotRows ) {
1692  $slots = new RevisionSlots(
1693  $this->constructSlotRecords( $revId, $slotRows, $queryFlags, $title )
1694  );
1695  } elseif ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) {
1696  $mainSlot = $this->emulateMainSlot_1_29( $revisionRow, $queryFlags, $title );
1697  // @phan-suppress-next-line PhanTypeInvalidCallableArraySize false positive
1698  $slots = new RevisionSlots( [ SlotRecord::MAIN => $mainSlot ] );
1699  } else {
1700  // XXX: do we need the same kind of caching here
1701  // that getKnownCurrentRevision uses (if $revId == page_latest?)
1702 
1703  $slots = new RevisionSlots( function () use( $revId, $queryFlags, $title ) {
1704  return $this->loadSlotRecords( $revId, $queryFlags, $title );
1705  } );
1706  }
1707 
1708  return $slots;
1709  }
1710 
1728  public function newRevisionFromArchiveRow(
1729  $row,
1730  $queryFlags = 0,
1731  Title $title = null,
1732  array $overrides = []
1733  ) {
1734  Assert::parameterType( 'object', $row, '$row' );
1735 
1736  // check second argument, since Revision::newFromArchiveRow had $overrides in that spot.
1737  Assert::parameterType( 'integer', $queryFlags, '$queryFlags' );
1738 
1739  if ( !$title && isset( $overrides['title'] ) ) {
1740  if ( !( $overrides['title'] instanceof Title ) ) {
1741  throw new MWException( 'title field override must contain a Title object.' );
1742  }
1743 
1744  $title = $overrides['title'];
1745  }
1746 
1747  if ( !isset( $title ) ) {
1748  if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) {
1749  $title = Title::makeTitle( $row->ar_namespace, $row->ar_title );
1750  } else {
1751  throw new InvalidArgumentException(
1752  'A Title or ar_namespace and ar_title must be given'
1753  );
1754  }
1755  }
1756 
1757  foreach ( $overrides as $key => $value ) {
1758  $field = "ar_$key";
1759  $row->$field = $value;
1760  }
1761 
1762  try {
1763  $user = User::newFromAnyId(
1764  $row->ar_user ?? null,
1765  $row->ar_user_text ?? null,
1766  $row->ar_actor ?? null,
1767  $this->dbDomain
1768  );
1769  } catch ( InvalidArgumentException $ex ) {
1770  wfWarn( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ': ' . $ex->getMessage() );
1771  $user = new UserIdentityValue( 0, 'Unknown user', 0 );
1772  }
1773 
1774  if ( $user->getName() === '' ) {
1775  // T236624: If the user name is empty, force 'Unknown user',
1776  // even if the actor table has an entry for the empty user name.
1777  $user = new UserIdentityValue( 0, 'Unknown user', 0 );
1778  }
1779 
1780  $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1781  // Legacy because $row may have come from self::selectFields()
1782  $comment = $this->commentStore->getCommentLegacy( $db, 'ar_comment', $row, true );
1783 
1784  $slots = $this->newRevisionSlots( $row->ar_rev_id, $row, null, $queryFlags, $title );
1785 
1786  return new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $this->dbDomain );
1787  }
1788 
1801  public function newRevisionFromRow(
1802  $row,
1803  $queryFlags = 0,
1804  Title $title = null,
1805  $fromCache = false
1806  ) {
1807  return $this->newRevisionFromRowAndSlots( $row, null, $queryFlags, $title, $fromCache );
1808  }
1809 
1828  $row,
1829  $slots,
1830  $queryFlags = 0,
1831  Title $title = null,
1832  $fromCache = false
1833  ) {
1834  Assert::parameterType( 'object', $row, '$row' );
1835 
1836  if ( !$title ) {
1837  $pageId = $row->rev_page ?? 0; // XXX: also check page_id?
1838  $revId = $row->rev_id ?? 0;
1839 
1840  $title = $this->getTitle( $pageId, $revId, $queryFlags );
1841  }
1842 
1843  if ( !isset( $row->page_latest ) ) {
1844  $row->page_latest = $title->getLatestRevID();
1845  if ( $row->page_latest === 0 && $title->exists() ) {
1846  wfWarn( 'Encountered title object in limbo: ID ' . $title->getArticleID() );
1847  }
1848  }
1849 
1850  try {
1851  $user = User::newFromAnyId(
1852  $row->rev_user ?? null,
1853  $row->rev_user_text ?? null,
1854  $row->rev_actor ?? null,
1855  $this->dbDomain
1856  );
1857  } catch ( InvalidArgumentException $ex ) {
1858  wfWarn( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ': ' . $ex->getMessage() );
1859  $user = new UserIdentityValue( 0, 'Unknown user', 0 );
1860  }
1861 
1862  $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1863  // Legacy because $row may have come from self::selectFields()
1864  $comment = $this->commentStore->getCommentLegacy( $db, 'rev_comment', $row, true );
1865 
1866  if ( !( $slots instanceof RevisionSlots ) ) {
1867  $slots = $this->newRevisionSlots( $row->rev_id, $row, $slots, $queryFlags, $title );
1868  }
1869 
1870  // If this is a cached row, instantiate a cache-aware revision class to avoid stale data.
1871  if ( $fromCache ) {
1872  $rev = new RevisionStoreCacheRecord(
1873  function ( $revId ) use ( $queryFlags ) {
1874  $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
1875  return $this->fetchRevisionRowFromConds(
1876  $db,
1877  [ 'rev_id' => intval( $revId ) ]
1878  );
1879  },
1880  $title, $user, $comment, $row, $slots, $this->dbDomain
1881  );
1882  } else {
1883  $rev = new RevisionStoreRecord(
1884  $title, $user, $comment, $row, $slots, $this->dbDomain );
1885  }
1886  return $rev;
1887  }
1888 
1909  public function newRevisionsFromBatch(
1910  $rows,
1911  array $options = [],
1912  $queryFlags = 0,
1913  Title $title = null
1914  ) {
1915  $result = new StatusValue();
1916 
1917  $rowsByRevId = [];
1918  $pageIdsToFetchTitles = [];
1919  $titlesByPageId = [];
1920  foreach ( $rows as $row ) {
1921  if ( isset( $rowsByRevId[$row->rev_id] ) ) {
1922  $result->warning(
1923  'internalerror',
1924  "Duplicate rows in newRevisionsFromBatch, rev_id {$row->rev_id}"
1925  );
1926  }
1927  if ( $title && $row->rev_page != $title->getArticleID() ) {
1928  throw new InvalidArgumentException(
1929  "Revision {$row->rev_id} doesn't belong to page {$title->getArticleID()}"
1930  );
1931  } elseif ( !$title && !isset( $titlesByPageId[ $row->rev_page ] ) ) {
1932  if ( isset( $row->page_namespace ) && isset( $row->page_title ) &&
1933  // This should not happen, but just in case we don't have a page_id
1934  // set or it doesn't match rev_page, let's fetch the title again.
1935  isset( $row->page_id ) && $row->rev_page === $row->page_id
1936  ) {
1937  $titlesByPageId[ $row->rev_page ] = Title::newFromRow( $row );
1938  } else {
1939  $pageIdsToFetchTitles[] = $row->rev_page;
1940  }
1941  }
1942  $rowsByRevId[$row->rev_id] = $row;
1943  }
1944 
1945  if ( empty( $rowsByRevId ) ) {
1946  $result->setResult( true, [] );
1947  return $result;
1948  }
1949 
1950  // If the title is not supplied, batch-fetch Title objects.
1951  if ( $title ) {
1952  $titlesByPageId[$title->getArticleID()] = $title;
1953  } elseif ( !empty( $pageIdsToFetchTitles ) ) {
1954  $pageIdsToFetchTitles = array_unique( $pageIdsToFetchTitles );
1955  foreach ( Title::newFromIDs( $pageIdsToFetchTitles ) as $t ) {
1956  $titlesByPageId[$t->getArticleID()] = $t;
1957  }
1958  }
1959 
1960  if ( !isset( $options['slots'] ) || $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
1961  $result->setResult( true,
1962  array_map( function ( $row ) use ( $queryFlags, $titlesByPageId, $result ) {
1963  try {
1964  return $this->newRevisionFromRow(
1965  $row,
1966  $queryFlags,
1967  $titlesByPageId[$row->rev_page]
1968  );
1969  } catch ( MWException $e ) {
1970  $result->warning( 'internalerror', $e->getMessage() );
1971  return null;
1972  }
1973  }, $rowsByRevId )
1974  );
1975  return $result;
1976  }
1977 
1978  $slotRowOptions = [
1979  'slots' => $options['slots'] ?? true,
1980  'blobs' => $options['content'] ?? false,
1981  ];
1982 
1983  if ( is_array( $slotRowOptions['slots'] )
1984  && !in_array( SlotRecord::MAIN, $slotRowOptions['slots'] )
1985  ) {
1986  // Make sure the main slot is always loaded, RevisionRecord requires this.
1987  $slotRowOptions['slots'][] = SlotRecord::MAIN;
1988  }
1989 
1990  $slotRowsStatus = $this->getSlotRowsForBatch( $rowsByRevId, $slotRowOptions, $queryFlags );
1991 
1992  $result->merge( $slotRowsStatus );
1993  $slotRowsByRevId = $slotRowsStatus->getValue();
1994 
1995  $result->setResult( true, array_map( function ( $row ) use
1996  ( $slotRowsByRevId, $queryFlags, $titlesByPageId, $result ) {
1997  if ( !isset( $slotRowsByRevId[$row->rev_id] ) ) {
1998  $result->warning(
1999  'internalerror',
2000  "Couldn't find slots for rev {$row->rev_id}"
2001  );
2002  return null;
2003  }
2004  try {
2005  return $this->newRevisionFromRowAndSlots(
2006  $row,
2007  new RevisionSlots(
2008  $this->constructSlotRecords(
2009  $row->rev_id,
2010  $slotRowsByRevId[$row->rev_id],
2011  $queryFlags,
2012  $titlesByPageId[$row->rev_page]
2013  )
2014  ),
2015  $queryFlags,
2016  $titlesByPageId[$row->rev_page]
2017  );
2018  } catch ( MWException $e ) {
2019  $result->warning( 'internalerror', $e->getMessage() );
2020  return null;
2021  }
2022  }, $rowsByRevId ) );
2023  return $result;
2024  }
2025 
2048  private function getSlotRowsForBatch(
2049  $rowsOrIds,
2050  array $options = [],
2051  $queryFlags = 0
2052  ) {
2053  $readNew = $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW );
2054  $result = new StatusValue();
2055 
2056  $revIds = [];
2057  foreach ( $rowsOrIds as $row ) {
2058  $revIds[] = is_object( $row ) ? (int)$row->rev_id : (int)$row;
2059  }
2060 
2061  // Nothing to do.
2062  // Note that $rowsOrIds may not be "empty" even if $revIds is, e.g. if it's a ResultWrapper.
2063  if ( empty( $revIds ) ) {
2064  $result->setResult( true, [] );
2065  return $result;
2066  }
2067 
2068  // We need to set the `content` flag to join in content meta-data
2069  $slotQueryInfo = self::getSlotsQueryInfo( [ 'content' ] );
2070  $revIdField = $slotQueryInfo['keys']['rev_id'];
2071  $slotQueryConds = [ $revIdField => $revIds ];
2072 
2073  if ( $readNew && isset( $options['slots'] ) && is_array( $options['slots'] ) ) {
2074  if ( empty( $options['slots'] ) ) {
2075  // Degenerate case: return no slots for each revision.
2076  $result->setResult( true, array_fill_keys( $revIds, [] ) );
2077  return $result;
2078  }
2079 
2080  $roleIdField = $slotQueryInfo['keys']['role_id'];
2081  $slotQueryConds[$roleIdField] = array_map( function ( $slot_name ) {
2082  return $this->slotRoleStore->getId( $slot_name );
2083  }, $options['slots'] );
2084  }
2085 
2086  $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
2087  $slotRows = $db->select(
2088  $slotQueryInfo['tables'],
2089  $slotQueryInfo['fields'],
2090  $slotQueryConds,
2091  __METHOD__,
2092  [],
2093  $slotQueryInfo['joins']
2094  );
2095 
2096  $slotContents = null;
2097  if ( $options['blobs'] ?? false ) {
2098  $blobAddresses = [];
2099  foreach ( $slotRows as $slotRow ) {
2100  $blobAddresses[] = $slotRow->content_address;
2101  }
2102  $slotContentFetchStatus = $this->blobStore
2103  ->getBlobBatch( $blobAddresses, $queryFlags );
2104  foreach ( $slotContentFetchStatus->getErrors() as $error ) {
2105  $result->warning( $error['message'], ...$error['params'] );
2106  }
2107  $slotContents = $slotContentFetchStatus->getValue();
2108  }
2109 
2110  $slotRowsByRevId = [];
2111  foreach ( $slotRows as $slotRow ) {
2112  if ( $slotContents === null ) {
2113  // nothing to do
2114  } elseif ( isset( $slotContents[$slotRow->content_address] ) ) {
2115  $slotRow->blob_data = $slotContents[$slotRow->content_address];
2116  } else {
2117  $result->warning(
2118  'internalerror',
2119  "Couldn't find blob data for rev {$slotRow->slot_revision_id}"
2120  );
2121  $slotRow->blob_data = null;
2122  }
2123 
2124  // conditional needed for SCHEMA_COMPAT_READ_OLD
2125  if ( !isset( $slotRow->role_name ) && isset( $slotRow->slot_role_id ) ) {
2126  $slotRow->role_name = $this->slotRoleStore->getName( (int)$slotRow->slot_role_id );
2127  }
2128 
2129  // conditional needed for SCHEMA_COMPAT_READ_OLD
2130  if ( !isset( $slotRow->model_name ) && isset( $slotRow->content_model ) ) {
2131  $slotRow->model_name = $this->contentModelStore->getName( (int)$slotRow->content_model );
2132  }
2133 
2134  $slotRowsByRevId[$slotRow->slot_revision_id][$slotRow->role_name] = $slotRow;
2135  }
2136 
2137  $result->setResult( true, $slotRowsByRevId );
2138  return $result;
2139  }
2140 
2161  public function getContentBlobsForBatch(
2162  $rowsOrIds,
2163  $slots = null,
2164  $queryFlags = 0
2165  ) {
2166  $result = $this->getSlotRowsForBatch(
2167  $rowsOrIds,
2168  [ 'slots' => $slots, 'blobs' => true ],
2169  $queryFlags
2170  );
2171 
2172  if ( $result->isOK() ) {
2173  // strip out all internal meta data that we don't want to expose
2174  foreach ( $result->value as $revId => $rowsByRole ) {
2175  foreach ( $rowsByRole as $role => $slotRow ) {
2176  if ( is_array( $slots ) && !in_array( $role, $slots ) ) {
2177  // In SCHEMA_COMPAT_READ_OLD mode we may get the main slot even
2178  // if we didn't ask for it.
2179  unset( $result->value[$revId][$role] );
2180  continue;
2181  }
2182 
2183  $result->value[$revId][$role] = (object)[
2184  'blob_data' => $slotRow->blob_data,
2185  'model_name' => $slotRow->model_name,
2186  ];
2187  }
2188  }
2189  }
2190 
2191  return $result;
2192  }
2193 
2209  array $fields,
2210  $queryFlags = 0,
2211  Title $title = null
2212  ) {
2213  if ( !$title && isset( $fields['title'] ) ) {
2214  if ( !( $fields['title'] instanceof Title ) ) {
2215  throw new MWException( 'title field must contain a Title object.' );
2216  }
2217 
2218  $title = $fields['title'];
2219  }
2220 
2221  if ( !$title ) {
2222  $pageId = $fields['page'] ?? 0;
2223  $revId = $fields['id'] ?? 0;
2224 
2225  $title = $this->getTitle( $pageId, $revId, $queryFlags );
2226  }
2227 
2228  if ( !isset( $fields['page'] ) ) {
2229  $fields['page'] = $title->getArticleID( $queryFlags );
2230  }
2231 
2232  // if we have a content object, use it to set the model and type
2233  if ( !empty( $fields['content'] ) && !( $fields['content'] instanceof Content )
2234  && !is_array( $fields['content'] )
2235  ) {
2236  throw new MWException(
2237  'content field must contain a Content object or an array of Content objects.'
2238  );
2239  }
2240 
2241  if ( !empty( $fields['text_id'] ) ) {
2242  if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
2243  throw new MWException( "The text_id field is only available in the pre-MCR schema" );
2244  }
2245 
2246  if ( !empty( $fields['content'] ) ) {
2247  throw new MWException(
2248  "Text already stored in external store (id {$fields['text_id']}), " .
2249  "can't specify content object"
2250  );
2251  }
2252  }
2253 
2254  if (
2255  isset( $fields['comment'] )
2256  && !( $fields['comment'] instanceof CommentStoreComment )
2257  ) {
2258  $commentData = $fields['comment_data'] ?? null;
2259 
2260  if ( $fields['comment'] instanceof Message ) {
2261  $fields['comment'] = CommentStoreComment::newUnsavedComment(
2262  $fields['comment'],
2263  $commentData
2264  );
2265  } else {
2266  $commentText = trim( strval( $fields['comment'] ) );
2267  $fields['comment'] = CommentStoreComment::newUnsavedComment(
2268  $commentText,
2269  $commentData
2270  );
2271  }
2272  }
2273 
2274  $revision = new MutableRevisionRecord( $title, $this->dbDomain );
2275  $this->initializeMutableRevisionFromArray( $revision, $fields );
2276 
2277  if ( isset( $fields['content'] ) && is_array( $fields['content'] ) ) {
2278  // @phan-suppress-next-line PhanTypeNoPropertiesForeach
2279  foreach ( $fields['content'] as $role => $content ) {
2280  $revision->setContent( $role, $content );
2281  }
2282  } else {
2283  $mainSlot = $this->emulateMainSlot_1_29( $fields, $queryFlags, $title );
2284  $revision->setSlot( $mainSlot );
2285  }
2286 
2287  return $revision;
2288  }
2289 
2295  MutableRevisionRecord $record,
2296  array $fields
2297  ) {
2299  $user = null;
2300 
2301  // If a user is passed in, use it if possible. We cannot use a user from a
2302  // remote wiki with unsuppressed ids, due to issues described in T222212.
2303  if ( isset( $fields['user'] ) &&
2304  ( $fields['user'] instanceof UserIdentity ) &&
2305  ( $this->dbDomain === false ||
2306  ( !$fields['user']->getId() && !$fields['user']->getActorId() ) )
2307  ) {
2308  $user = $fields['user'];
2309  } else {
2310  try {
2311  $user = User::newFromAnyId(
2312  $fields['user'] ?? null,
2313  $fields['user_text'] ?? null,
2314  $fields['actor'] ?? null,
2315  $this->dbDomain
2316  );
2317  } catch ( InvalidArgumentException $ex ) {
2318  $user = null;
2319  }
2320  }
2321 
2322  if ( $user ) {
2323  $record->setUser( $user );
2324  }
2325 
2326  $timestamp = isset( $fields['timestamp'] )
2327  ? strval( $fields['timestamp'] )
2328  : wfTimestampNow(); // TODO: use a callback, so we can override it for testing.
2329 
2330  $record->setTimestamp( $timestamp );
2331 
2332  if ( isset( $fields['page'] ) ) {
2333  $record->setPageId( intval( $fields['page'] ) );
2334  }
2335 
2336  if ( isset( $fields['id'] ) ) {
2337  $record->setId( intval( $fields['id'] ) );
2338  }
2339  if ( isset( $fields['parent_id'] ) ) {
2340  $record->setParentId( intval( $fields['parent_id'] ) );
2341  }
2342 
2343  if ( isset( $fields['sha1'] ) ) {
2344  $record->setSha1( $fields['sha1'] );
2345  }
2346  if ( isset( $fields['size'] ) ) {
2347  $record->setSize( intval( $fields['size'] ) );
2348  }
2349 
2350  if ( isset( $fields['minor_edit'] ) ) {
2351  $record->setMinorEdit( intval( $fields['minor_edit'] ) !== 0 );
2352  }
2353  if ( isset( $fields['deleted'] ) ) {
2354  $record->setVisibility( intval( $fields['deleted'] ) );
2355  }
2356 
2357  if ( isset( $fields['comment'] ) ) {
2358  Assert::parameterType(
2359  CommentStoreComment::class,
2360  $fields['comment'],
2361  '$row[\'comment\']'
2362  );
2363  $record->setComment( $fields['comment'] );
2364  }
2365  }
2366 
2381  public function loadRevisionFromId( IDatabase $db, $id ) {
2382  return $this->loadRevisionFromConds( $db, [ 'rev_id' => intval( $id ) ] );
2383  }
2384 
2400  public function loadRevisionFromPageId( IDatabase $db, $pageid, $id = 0 ) {
2401  $conds = [ 'rev_page' => intval( $pageid ), 'page_id' => intval( $pageid ) ];
2402  if ( $id ) {
2403  $conds['rev_id'] = intval( $id );
2404  } else {
2405  $conds[] = 'rev_id=page_latest';
2406  }
2407  return $this->loadRevisionFromConds( $db, $conds );
2408  }
2409 
2426  public function loadRevisionFromTitle( IDatabase $db, $title, $id = 0 ) {
2427  if ( $id ) {
2428  $matchId = intval( $id );
2429  } else {
2430  $matchId = 'page_latest';
2431  }
2432 
2433  return $this->loadRevisionFromConds(
2434  $db,
2435  [
2436  "rev_id=$matchId",
2437  'page_namespace' => $title->getNamespace(),
2438  'page_title' => $title->getDBkey()
2439  ],
2440  0,
2441  $title
2442  );
2443  }
2444 
2460  public function loadRevisionFromTimestamp( IDatabase $db, $title, $timestamp ) {
2461  return $this->loadRevisionFromConds( $db,
2462  [
2463  'rev_timestamp' => $db->timestamp( $timestamp ),
2464  'page_namespace' => $title->getNamespace(),
2465  'page_title' => $title->getDBkey()
2466  ],
2467  0,
2468  $title
2469  );
2470  }
2471 
2487  private function newRevisionFromConds( $conditions, $flags = 0, Title $title = null ) {
2488  $db = $this->getDBConnectionRefForQueryFlags( $flags );
2489  $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $title );
2490 
2491  $lb = $this->getDBLoadBalancer();
2492 
2493  // Make sure new pending/committed revision are visibile later on
2494  // within web requests to certain avoid bugs like T93866 and T94407.
2495  if ( !$rev
2496  && !( $flags & self::READ_LATEST )
2497  && $lb->hasStreamingReplicaServers()
2498  && $lb->hasOrMadeRecentMasterChanges()
2499  ) {
2500  $flags = self::READ_LATEST;
2501  $dbw = $this->getDBConnectionRef( DB_MASTER );
2502  $rev = $this->loadRevisionFromConds( $dbw, $conditions, $flags, $title );
2503  }
2504 
2505  return $rev;
2506  }
2507 
2521  private function loadRevisionFromConds(
2522  IDatabase $db,
2523  $conditions,
2524  $flags = 0,
2525  Title $title = null
2526  ) {
2527  $row = $this->fetchRevisionRowFromConds( $db, $conditions, $flags );
2528  if ( $row ) {
2529  $rev = $this->newRevisionFromRow( $row, $flags, $title );
2530 
2531  return $rev;
2532  }
2533 
2534  return null;
2535  }
2536 
2544  private function checkDatabaseDomain( IDatabase $db ) {
2545  $dbDomain = $db->getDomainID();
2546  $storeDomain = $this->loadBalancer->resolveDomainID( $this->dbDomain );
2547  if ( $dbDomain === $storeDomain ) {
2548  return;
2549  }
2550 
2551  throw new MWException( "DB connection domain '$dbDomain' does not match '$storeDomain'" );
2552  }
2553 
2566  private function fetchRevisionRowFromConds( IDatabase $db, $conditions, $flags = 0 ) {
2567  $this->checkDatabaseDomain( $db );
2568 
2569  $revQuery = $this->getQueryInfo( [ 'page', 'user' ] );
2570  $options = [];
2571  if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) {
2572  $options[] = 'FOR UPDATE';
2573  }
2574  return $db->selectRow(
2575  $revQuery['tables'],
2576  $revQuery['fields'],
2577  $conditions,
2578  __METHOD__,
2579  $options,
2580  $revQuery['joins']
2581  );
2582  }
2583 
2598  private function findSlotContentId( IDatabase $db, $revId, $role ) {
2599  if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
2600  return null;
2601  }
2602 
2603  try {
2604  $roleId = $this->slotRoleStore->getId( $role );
2605  $conditions = [
2606  'slot_revision_id' => $revId,
2607  'slot_role_id' => $roleId,
2608  ];
2609 
2610  $contentId = $db->selectField( 'slots', 'slot_content_id', $conditions, __METHOD__ );
2611 
2612  return $contentId ?: null;
2613  } catch ( NameTableAccessException $ex ) {
2614  // If the role is missing from the slot_roles table,
2615  // the corresponding row in slots cannot exist.
2616  return null;
2617  }
2618  }
2619 
2644  public function getQueryInfo( $options = [] ) {
2645  $ret = [
2646  'tables' => [],
2647  'fields' => [],
2648  'joins' => [],
2649  ];
2650 
2651  $ret['tables'][] = 'revision';
2652  $ret['fields'] = array_merge( $ret['fields'], [
2653  'rev_id',
2654  'rev_page',
2655  'rev_timestamp',
2656  'rev_minor_edit',
2657  'rev_deleted',
2658  'rev_len',
2659  'rev_parent_id',
2660  'rev_sha1',
2661  ] );
2662 
2663  $commentQuery = $this->commentStore->getJoin( 'rev_comment' );
2664  $ret['tables'] = array_merge( $ret['tables'], $commentQuery['tables'] );
2665  $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] );
2666  $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] );
2667 
2668  $actorQuery = $this->actorMigration->getJoin( 'rev_user' );
2669  $ret['tables'] = array_merge( $ret['tables'], $actorQuery['tables'] );
2670  $ret['fields'] = array_merge( $ret['fields'], $actorQuery['fields'] );
2671  $ret['joins'] = array_merge( $ret['joins'], $actorQuery['joins'] );
2672 
2673  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
2674  $ret['fields'][] = 'rev_text_id';
2675 
2676  if ( $this->contentHandlerUseDB ) {
2677  $ret['fields'][] = 'rev_content_format';
2678  $ret['fields'][] = 'rev_content_model';
2679  }
2680  }
2681 
2682  if ( in_array( 'page', $options, true ) ) {
2683  $ret['tables'][] = 'page';
2684  $ret['fields'] = array_merge( $ret['fields'], [
2685  'page_namespace',
2686  'page_title',
2687  'page_id',
2688  'page_latest',
2689  'page_is_redirect',
2690  'page_len',
2691  ] );
2692  $ret['joins']['page'] = [ 'JOIN', [ 'page_id = rev_page' ] ];
2693  }
2694 
2695  if ( in_array( 'user', $options, true ) ) {
2696  $ret['tables'][] = 'user';
2697  $ret['fields'] = array_merge( $ret['fields'], [
2698  'user_name',
2699  ] );
2700  $u = $actorQuery['fields']['rev_user'];
2701  $ret['joins']['user'] = [ 'LEFT JOIN', [ "$u != 0", "user_id = $u" ] ];
2702  }
2703 
2704  if ( in_array( 'text', $options, true ) ) {
2705  if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) ) {
2706  throw new InvalidArgumentException( 'text table can no longer be joined directly' );
2707  } elseif ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
2708  // NOTE: even when this class is set to not read from the old schema, callers
2709  // should still be able to join against the text table, as long as we are still
2710  // writing the old schema for compatibility.
2711  wfDeprecated( __METHOD__ . ' with `text` option', '1.32' );
2712  }
2713 
2714  $ret['tables'][] = 'text';
2715  $ret['fields'] = array_merge( $ret['fields'], [
2716  'old_text',
2717  'old_flags'
2718  ] );
2719  $ret['joins']['text'] = [ 'JOIN', [ 'rev_text_id=old_id' ] ];
2720  }
2721 
2722  return $ret;
2723  }
2724 
2745  public function getSlotsQueryInfo( $options = [] ) {
2746  $ret = [
2747  'tables' => [],
2748  'fields' => [],
2749  'joins' => [],
2750  'keys' => [],
2751  ];
2752 
2753  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
2754  $db = $this->getDBConnectionRef( DB_REPLICA );
2755  $ret['keys']['rev_id'] = 'rev_id';
2756 
2757  $ret['tables'][] = 'revision';
2758 
2759  $ret['fields']['slot_revision_id'] = 'rev_id';
2760  $ret['fields']['slot_content_id'] = 'NULL';
2761  $ret['fields']['slot_origin'] = 'rev_id';
2762  $ret['fields']['role_name'] = $db->addQuotes( SlotRecord::MAIN );
2763 
2764  if ( in_array( 'content', $options, true ) ) {
2765  $ret['fields']['content_size'] = 'rev_len';
2766  $ret['fields']['content_sha1'] = 'rev_sha1';
2767  $ret['fields']['content_address']
2768  = $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'rev_text_id' ] );
2769 
2770  // Allow the content_id field to be emulated later
2771  $ret['fields']['rev_text_id'] = 'rev_text_id';
2772 
2773  if ( $this->contentHandlerUseDB ) {
2774  $ret['fields']['model_name'] = 'rev_content_model';
2775  } else {
2776  $ret['fields']['model_name'] = 'NULL';
2777  }
2778  }
2779  } else {
2780  $ret['keys']['rev_id'] = 'slot_revision_id';
2781  $ret['keys']['role_id'] = 'slot_role_id';
2782 
2783  $ret['tables'][] = 'slots';
2784  $ret['fields'] = array_merge( $ret['fields'], [
2785  'slot_revision_id',
2786  'slot_content_id',
2787  'slot_origin',
2788  'slot_role_id',
2789  ] );
2790 
2791  if ( in_array( 'role', $options, true ) ) {
2792  // Use left join to attach role name, so we still find the revision row even
2793  // if the role name is missing. This triggers a more obvious failure mode.
2794  $ret['tables'][] = 'slot_roles';
2795  $ret['joins']['slot_roles'] = [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ];
2796  $ret['fields'][] = 'role_name';
2797  }
2798 
2799  if ( in_array( 'content', $options, true ) ) {
2800  $ret['keys']['model_id'] = 'content_model';
2801 
2802  $ret['tables'][] = 'content';
2803  $ret['fields'] = array_merge( $ret['fields'], [
2804  'content_size',
2805  'content_sha1',
2806  'content_address',
2807  'content_model',
2808  ] );
2809  $ret['joins']['content'] = [ 'JOIN', [ 'slot_content_id = content_id' ] ];
2810 
2811  if ( in_array( 'model', $options, true ) ) {
2812  // Use left join to attach model name, so we still find the revision row even
2813  // if the model name is missing. This triggers a more obvious failure mode.
2814  $ret['tables'][] = 'content_models';
2815  $ret['joins']['content_models'] = [ 'LEFT JOIN', [ 'content_model = model_id' ] ];
2816  $ret['fields'][] = 'model_name';
2817  }
2818 
2819  }
2820  }
2821 
2822  return $ret;
2823  }
2824 
2838  public function getArchiveQueryInfo() {
2839  $commentQuery = $this->commentStore->getJoin( 'ar_comment' );
2840  $actorQuery = $this->actorMigration->getJoin( 'ar_user' );
2841  $ret = [
2842  'tables' => [ 'archive' ] + $commentQuery['tables'] + $actorQuery['tables'],
2843  'fields' => [
2844  'ar_id',
2845  'ar_page_id',
2846  'ar_namespace',
2847  'ar_title',
2848  'ar_rev_id',
2849  'ar_timestamp',
2850  'ar_minor_edit',
2851  'ar_deleted',
2852  'ar_len',
2853  'ar_parent_id',
2854  'ar_sha1',
2855  ] + $commentQuery['fields'] + $actorQuery['fields'],
2856  'joins' => $commentQuery['joins'] + $actorQuery['joins'],
2857  ];
2858 
2859  if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
2860  $ret['fields'][] = 'ar_text_id';
2861 
2862  if ( $this->contentHandlerUseDB ) {
2863  $ret['fields'][] = 'ar_content_format';
2864  $ret['fields'][] = 'ar_content_model';
2865  }
2866  }
2867 
2868  return $ret;
2869  }
2870 
2880  public function getRevisionSizes( array $revIds ) {
2881  return $this->listRevisionSizes( $this->getDBConnectionRef( DB_REPLICA ), $revIds );
2882  }
2883 
2896  public function listRevisionSizes( IDatabase $db, array $revIds ) {
2897  $this->checkDatabaseDomain( $db );
2898 
2899  $revLens = [];
2900  if ( !$revIds ) {
2901  return $revLens; // empty
2902  }
2903 
2904  $res = $db->select(
2905  'revision',
2906  [ 'rev_id', 'rev_len' ],
2907  [ 'rev_id' => $revIds ],
2908  __METHOD__
2909  );
2910 
2911  foreach ( $res as $row ) {
2912  $revLens[$row->rev_id] = intval( $row->rev_len );
2913  }
2914 
2915  return $revLens;
2916  }
2917 
2926  private function getRelativeRevision( RevisionRecord $rev, $flags, $dir ) {
2927  $op = $dir === 'next' ? '>' : '<';
2928  $sort = $dir === 'next' ? 'ASC' : 'DESC';
2929 
2930  if ( !$rev->getId() || !$rev->getPageId() ) {
2931  // revision is unsaved or otherwise incomplete
2932  return null;
2933  }
2934 
2935  if ( $rev instanceof RevisionArchiveRecord ) {
2936  // revision is deleted, so it's not part of the page history
2937  return null;
2938  }
2939 
2940  list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags );
2941  $db = $this->getDBConnectionRef( $dbType, [ 'contributions' ] );
2942 
2943  $ts = $this->getTimestampFromId( $rev->getId(), $flags );
2944  if ( $ts === false ) {
2945  // XXX Should this be moved into getTimestampFromId?
2946  $ts = $db->selectField( 'archive', 'ar_timestamp',
2947  [ 'ar_rev_id' => $rev->getId() ], __METHOD__ );
2948  if ( $ts === false ) {
2949  // XXX Is this reachable? How can we have a page id but no timestamp?
2950  return null;
2951  }
2952  }
2953  $ts = $db->addQuotes( $db->timestamp( $ts ) );
2954 
2955  $revId = $db->selectField( 'revision', 'rev_id',
2956  [
2957  'rev_page' => $rev->getPageId(),
2958  "rev_timestamp $op $ts OR (rev_timestamp = $ts AND rev_id $op {$rev->getId()})"
2959  ],
2960  __METHOD__,
2961  [
2962  'ORDER BY' => [ "rev_timestamp $sort", "rev_id $sort" ],
2963  'IGNORE INDEX' => 'rev_timestamp', // Probably needed for T159319
2964  ]
2965  );
2966 
2967  if ( $revId === false ) {
2968  return null;
2969  }
2970 
2971  return $this->getRevisionById( intval( $revId ) );
2972  }
2973 
2989  public function getPreviousRevision( RevisionRecord $rev, $flags = 0 ) {
2990  if ( $flags instanceof Title ) {
2991  // Old calling convention, we don't use Title here anymore
2992  wfDeprecated( __METHOD__ . ' with Title', '1.34' );
2993  $flags = 0;
2994  }
2995 
2996  return $this->getRelativeRevision( $rev, $flags, 'prev' );
2997  }
2998 
3012  public function getNextRevision( RevisionRecord $rev, $flags = 0 ) {
3013  if ( $flags instanceof Title ) {
3014  // Old calling convention, we don't use Title here anymore
3015  wfDeprecated( __METHOD__ . ' with Title', '1.34' );
3016  $flags = 0;
3017  }
3018 
3019  return $this->getRelativeRevision( $rev, $flags, 'next' );
3020  }
3021 
3033  private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) {
3034  $this->checkDatabaseDomain( $db );
3035 
3036  if ( $rev->getPageId() === null ) {
3037  return 0;
3038  }
3039  # Use page_latest if ID is not given
3040  if ( !$rev->getId() ) {
3041  $prevId = $db->selectField(
3042  'page', 'page_latest',
3043  [ 'page_id' => $rev->getPageId() ],
3044  __METHOD__
3045  );
3046  } else {
3047  $prevId = $db->selectField(
3048  'revision', 'rev_id',
3049  [ 'rev_page' => $rev->getPageId(), 'rev_id < ' . $rev->getId() ],
3050  __METHOD__,
3051  [ 'ORDER BY' => 'rev_id DESC' ]
3052  );
3053  }
3054  return intval( $prevId );
3055  }
3056 
3069  public function getTimestampFromId( $id, $flags = 0 ) {
3070  if ( $id instanceof Title ) {
3071  // Old deprecated calling convention supported for backwards compatibility
3072  $id = $flags;
3073  $flags = func_num_args() > 2 ? func_get_arg( 2 ) : 0;
3074  }
3075  $db = $this->getDBConnectionRefForQueryFlags( $flags );
3076 
3077  $timestamp =
3078  $db->selectField( 'revision', 'rev_timestamp', [ 'rev_id' => $id ], __METHOD__ );
3079 
3080  return ( $timestamp !== false ) ? wfTimestamp( TS_MW, $timestamp ) : false;
3081  }
3082 
3092  public function countRevisionsByPageId( IDatabase $db, $id ) {
3093  $this->checkDatabaseDomain( $db );
3094 
3095  $row = $db->selectRow( 'revision',
3096  [ 'revCount' => 'COUNT(*)' ],
3097  [ 'rev_page' => $id ],
3098  __METHOD__
3099  );
3100  if ( $row ) {
3101  return intval( $row->revCount );
3102  }
3103  return 0;
3104  }
3105 
3115  public function countRevisionsByTitle( IDatabase $db, $title ) {
3116  $id = $title->getArticleID();
3117  if ( $id ) {
3118  return $this->countRevisionsByPageId( $db, $id );
3119  }
3120  return 0;
3121  }
3122 
3141  public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) {
3142  $this->checkDatabaseDomain( $db );
3143 
3144  if ( !$userId ) {
3145  return false;
3146  }
3147 
3148  $revQuery = $this->getQueryInfo();
3149  $res = $db->select(
3150  $revQuery['tables'],
3151  [
3152  'rev_user' => $revQuery['fields']['rev_user'],
3153  ],
3154  [
3155  'rev_page' => $pageId,
3156  'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) )
3157  ],
3158  __METHOD__,
3159  [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ],
3160  $revQuery['joins']
3161  );
3162  foreach ( $res as $row ) {
3163  if ( $row->rev_user != $userId ) {
3164  return false;
3165  }
3166  }
3167  return true;
3168  }
3169 
3183  public function getKnownCurrentRevision( Title $title, $revId = 0 ) {
3184  $db = $this->getDBConnectionRef( DB_REPLICA );
3185 
3186  $pageId = $title->getArticleID();
3187 
3188  if ( !$pageId ) {
3189  return false;
3190  }
3191 
3192  if ( !$revId ) {
3193  $revId = $title->getLatestRevID();
3194  }
3195 
3196  if ( !$revId ) {
3197  wfWarn(
3198  'No latest revision known for page ' . $title->getPrefixedDBkey()
3199  . ' even though it exists with page ID ' . $pageId
3200  );
3201  return false;
3202  }
3203 
3204  // Load the row from cache if possible. If not possible, populate the cache.
3205  // As a minor optimization, remember if this was a cache hit or miss.
3206  // We can sometimes avoid a database query later if this is a cache miss.
3207  $fromCache = true;
3208  $row = $this->cache->getWithSetCallback(
3209  // Page/rev IDs passed in from DB to reflect history merges
3210  $this->getRevisionRowCacheKey( $db, $pageId, $revId ),
3212  function ( $curValue, &$ttl, array &$setOpts ) use (
3213  $db, $pageId, $revId, &$fromCache
3214  ) {
3215  $setOpts += Database::getCacheSetOptions( $db );
3216  $row = $this->fetchRevisionRowFromConds( $db, [ 'rev_id' => intval( $revId ) ] );
3217  if ( $row ) {
3218  $fromCache = false;
3219  }
3220  return $row; // don't cache negatives
3221  }
3222  );
3223 
3224  // Reflect revision deletion and user renames.
3225  if ( $row ) {
3226  return $this->newRevisionFromRow( $row, 0, $title, $fromCache );
3227  } else {
3228  return false;
3229  }
3230  }
3231 
3243  private function getRevisionRowCacheKey( IDatabase $db, $pageId, $revId ) {
3244  return $this->cache->makeGlobalKey(
3245  self::ROW_CACHE_KEY,
3246  $db->getDomainID(),
3247  $pageId,
3248  $revId
3249  );
3250  }
3251 
3259  private function assertRevisionParameter( $paramName, $pageId, RevisionRecord $rev = null ) {
3260  if ( $rev ) {
3261  if ( $rev->getId() === null ) {
3262  throw new InvalidArgumentException( "Unsaved {$paramName} revision passed" );
3263  }
3264  if ( $rev->getPageId() !== $pageId ) {
3265  throw new InvalidArgumentException(
3266  "Revision {$rev->getId()} doesn't belong to page {$pageId}"
3267  );
3268  }
3269  }
3270  }
3271 
3284  private function getRevisionLimitConditions(
3285  IDatabase $dbr,
3286  RevisionRecord $old = null,
3287  RevisionRecord $new = null,
3288  $options = []
3289  ) {
3290  $options = (array)$options;
3291  $oldCmp = '>';
3292  $newCmp = '<';
3293  if ( in_array( 'include_old', $options ) ) {
3294  $oldCmp = '>=';
3295  }
3296  if ( in_array( 'include_new', $options ) ) {
3297  $newCmp = '<=';
3298  }
3299  if ( in_array( 'include_both', $options ) ) {
3300  $oldCmp = '>=';
3301  $newCmp = '<=';
3302  }
3303 
3304  $conds = [];
3305  if ( $old ) {
3306  $oldTs = $dbr->addQuotes( $dbr->timestamp( $old->getTimestamp() ) );
3307  $conds[] = "(rev_timestamp = {$oldTs} AND rev_id {$oldCmp} {$old->getId()}) " .
3308  "OR rev_timestamp > {$oldTs}";
3309  }
3310  if ( $new ) {
3311  $newTs = $dbr->addQuotes( $dbr->timestamp( $new->getTimestamp() ) );
3312  $conds[] = "(rev_timestamp = {$newTs} AND rev_id {$newCmp} {$new->getId()}) " .
3313  "OR rev_timestamp < {$newTs}";
3314  }
3315  return $conds;
3316  }
3317 
3339  public function getAuthorsBetween(
3340  $pageId,
3341  RevisionRecord $old = null,
3342  RevisionRecord $new = null,
3343  User $user = null,
3344  $max = null,
3345  $options = []
3346  ) {
3347  $this->assertRevisionParameter( 'old', $pageId, $old );
3348  $this->assertRevisionParameter( 'new', $pageId, $new );
3349  $options = (array)$options;
3350 
3351  // No DB query needed if old and new are the same revision.
3352  // Can't check for consecutive revisions with 'getParentId' for a similar
3353  // optimization as edge cases exist when there are revisions between
3354  //a revision and it's parent. See T185167 for more details.
3355  if ( $old && $new && $new->getId() === $old->getId() ) {
3356  if ( empty( $options ) ) {
3357  return [];
3358  } else {
3359  return $user ? [ $new->getUser( RevisionRecord::FOR_PUBLIC, $user ) ] : [ $new->getUser() ];
3360  }
3361  }
3362 
3363  $dbr = $this->getDBConnectionRef( DB_REPLICA );
3364  $conds = array_merge(
3365  [
3366  'rev_page' => $pageId,
3367  $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER ) . " = 0"
3368  ],
3369  $this->getRevisionLimitConditions( $dbr, $old, $new, $options )
3370  );
3371 
3372  $queryOpts = [ 'DISTINCT' ];
3373  if ( $max !== null ) {
3374  $queryOpts['LIMIT'] = $max + 1;
3375  }
3376 
3377  $actorQuery = $this->actorMigration->getJoin( 'rev_user' );
3378  return array_map( function ( $row ) {
3379  return new UserIdentityValue( (int)$row->rev_user, $row->rev_user_text, (int)$row->rev_actor );
3380  }, iterator_to_array( $dbr->select(
3381  array_merge( [ 'revision' ], $actorQuery['tables'] ),
3382  $actorQuery['fields'],
3383  $conds, __METHOD__,
3384  $queryOpts,
3385  $actorQuery['joins']
3386  ) ) );
3387  }
3388 
3410  public function countAuthorsBetween(
3411  $pageId,
3412  RevisionRecord $old = null,
3413  RevisionRecord $new = null,
3414  User $user = null,
3415  $max = null,
3416  $options = []
3417  ) {
3418  // TODO: Implement with a separate query to avoid cost of selecting unneeded fields
3419  // and creation of UserIdentity stuff.
3420  return count( $this->getAuthorsBetween( $pageId, $old, $new, $user, $max, $options ) );
3421  }
3422 
3443  public function countRevisionsBetween(
3444  $pageId,
3445  RevisionRecord $old = null,
3446  RevisionRecord $new = null,
3447  $max = null,
3448  $options = []
3449  ) {
3450  $this->assertRevisionParameter( 'old', $pageId, $old );
3451  $this->assertRevisionParameter( 'new', $pageId, $new );
3452 
3453  // No DB query needed if old and new are the same revision.
3454  // Can't check for consecutive revisions with 'getParentId' for a similar
3455  // optimization as edge cases exist when there are revisions between
3456  //a revision and it's parent. See T185167 for more details.
3457  if ( $old && $new && $new->getId() === $old->getId() ) {
3458  return 0;
3459  }
3460 
3461  $dbr = $this->getDBConnectionRef( DB_REPLICA );
3462  $conds = array_merge(
3463  [
3464  'rev_page' => $pageId,
3465  $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
3466  ],
3467  $this->getRevisionLimitConditions( $dbr, $old, $new, $options )
3468  );
3469  if ( $max !== null ) {
3470  return $dbr->selectRowCount( 'revision', '1',
3471  $conds,
3472  __METHOD__,
3473  [ 'LIMIT' => $max + 1 ] // extra to detect truncation
3474  );
3475  } else {
3476  return (int)$dbr->selectField( 'revision', 'count(*)', $conds, __METHOD__ );
3477  }
3478  }
3479 
3480  // TODO: move relevant methods from Title here, e.g. getFirstRevision, isBigDeletion, etc.
3481 }
3482 
3487 class_alias( RevisionStore::class, 'MediaWiki\Storage\RevisionStore' );
const SCHEMA_COMPAT_WRITE_OLD
Definition: Defines.php:264
getTitle( $pageId, $revId, $queryFlags=self::READ_NORMAL)
Determines the page Title based on the available information.
ActorMigration $actorMigration
getRevisionLimitConditions(IDatabase $dbr, RevisionRecord $old=null, RevisionRecord $new=null, $options=[])
Converts revision limits to query conditions.
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:269
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:3161
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:78
A RevisionRecord representing an existing revision persisted in the revision table.
static newFromID( $id, $flags=0)
Create a new Title from an article ID.
Definition: Title.php:465
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
storeContentBlob(SlotRecord $slot, Title $title, array $blobHints=[])
NameTableStore $contentModelStore
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...
userWasLastToEdit(IDatabase $db, $pageId, $userId, $since)
Check if no edits were made by other users since the time a user started editing the page...
The Message class provides methods which fulfil two basic services:
Definition: Message.php:162
getBaseRevisionRow(IDatabase $dbw, RevisionRecord $rev, Title $title, $parentId)
failOnNull( $value, $name)
getAuthorsBetween( $pageId, RevisionRecord $old=null, RevisionRecord $new=null, User $user=null, $max=null, $options=[])
Get the authors between the given revisions or revisions.
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 newFromAnyId( $userId, $userName, $actorId, $dbDomain=false)
Static factory method for creation from an ID, name, and/or actor ID.
Definition: User.php:617
static mapArchiveFields( $archiveRow)
Maps fields of the archive row to corresponding revision rows.
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...
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:72
const SCHEMA_COMPAT_READ_NEW
Definition: Defines.php:267
const PAGE_HINT
Hint key for use with storeBlob, indicating the page the blob is associated with. ...
Definition: BlobStore.php:48
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)
newRevisionFromRowAndSlots( $row, $slots, $queryFlags=0, Title $title=null, $fromCache=false)
static newFromRow( $row)
Make a Title object from a DB row.
Definition: Title.php:516
CommentStore $commentStore
static newUnsavedComment( $comment, array $data=null)
Create a new, unsaved CommentStoreComment.
getDBConnectionRef( $mode, $groups=[])
getKnownCurrentRevision(Title $title, $revId=0)
Load a revision based on a known page ID and current revision ID from the DB.
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:2283
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
getModel()
Returns the content model.
Definition: SlotRecord.php:566
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
Created by PhpStorm.
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...
newRevisionSlots( $revId, $revisionRow, $slotRows, $queryFlags, Title $title)
Factory method for RevisionSlots based on a revision ID.
assertRevisionParameter( $paramName, $pageId, RevisionRecord $rev=null)
Asserts that if revision is provided, it&#39;s saved and belongs to the page with provided pageId...
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:66
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...
loadSlotRecords( $revId, $queryFlags, Title $title)
Service for looking up page revisions.
const SCHEMA_COMPAT_WRITE_NEW
Definition: Defines.php:266
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:54
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
getRelativeRevision(RevisionRecord $rev, $flags, $dir)
Implementation of getPreviousRevision and getNextRevision.
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
SlotRoleRegistry $slotRoleRegistry
getRcIdIfUnpatrolled(RevisionRecord $rev)
MCR migration note: this replaces Revision::isUnpatrolled.
static newFromIDs( $ids)
Make an array of titles from an array of IDs.
Definition: Title.php:490
insertRevisionRowOn(IDatabase $dbw, RevisionRecord $rev, Title $title, $parentId)
countRevisionsBetween( $pageId, RevisionRecord $old=null, RevisionRecord $new=null, $max=null, $options=[])
Get the number of revisions between the given revisions.
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.
constructSlotRecords( $revId, $slotRows, $queryFlags, Title $title, $slotContents=null)
Factory method for SlotRecords based on known slot rows.
static hasFlags( $bitfield, $flags)
getSlotRowsForBatch( $rowsOrIds, array $options=[], $queryFlags=0)
Gets the slot rows associated with a batch of revisions.
checkDatabaseDomain(IDatabase $db)
Throws an exception if the given database connection does not belong to the wiki this RevisionStore i...
isReadyForInsertion()
Returns whether this RevisionRecord is ready for insertion, that is, whether it contains all informat...
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
static newFromParentRevision(RevisionRecord $parent)
Returns an incomplete MutableRevisionRecord which uses $parent as its parent revision, and inherits all slots form it.
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.
const PRC_UNPATROLLED
static newFromLinkTarget(LinkTarget $linkTarget, $forceClone='')
Returns a Title given a LinkTarget.
Definition: Title.php:269
__construct(ILoadBalancer $loadBalancer, SqlBlobStore $blobStore, WANObjectCache $cache, CommentStore $commentStore, NameTableStore $contentModelStore, NameTableStore $slotRoleStore, SlotRoleRegistry $slotRoleRegistry, $mcrMigrationStage, ActorMigration $actorMigration, $dbDomain=false)
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:584
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.
getRole()
Returns the role of the slot.
Definition: SlotRecord.php:489
countAuthorsBetween( $pageId, RevisionRecord $old=null, RevisionRecord $new=null, User $user=null, $max=null, $options=[])
Get the number of authors between the given revisions.
getSha1()
Returns the content size.
Definition: SlotRecord.php:538
getId()
Get the user&#39;s ID.
Definition: User.php:2254
setId( $id)
Set the revision ID.
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.
getContentBlobsForBatch( $rowsOrIds, $slots=null, $queryFlags=0)
Gets raw (serialized) content blobs for the given set of revisions.
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:3242
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:4428
setUser(UserIdentity $user)
Sets the user identity associated with the revision.
const DESIGNATION_HINT
Hint key for use with storeBlob, indicating the general role the block takes in the application...
Definition: BlobStore.php:42
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.
$content
Definition: router.php:78
const SCHEMA_COMPAT_READ_OLD
Definition: Defines.php:265
Value object representing the set of slots belonging to a revision.
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.
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:60
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...
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
newRevisionsFromBatch( $rows, array $options=[], $queryFlags=0, Title $title=null)
Construct a RevisionRecord instance for each row in $rows, and return them as an associative array in...
getPrefixedDBkey()
Get the prefixed database key form.
Definition: Title.php:1841
const FORMAT_HINT
Hint key for use with storeBlob, indicating the serialization format used to create the blob...
Definition: BlobStore.php:84