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