1<?php 2/** 3 * Service for looking up page revisions. 4 * 5 * This program is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation; either version 2 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License along 16 * with this program; if not, write to the Free Software Foundation, Inc., 17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 * http://www.gnu.org/copyleft/gpl.html 19 * 20 * Attribution notice: when this file was created, much of its content was taken 21 * from the Revision.php file as present in release 1.30. Refer to the history 22 * of that file for original authorship (that file was removed entirely in 1.37, 23 * but its history can still be found in prior versions of MediaWiki). 24 * 25 * @file 26 */ 27 28namespace MediaWiki\Revision; 29 30use ActorMigration; 31use CommentStore; 32use CommentStoreComment; 33use Content; 34use DBAccessObjectUtils; 35use FallbackContent; 36use IDBAccessObject; 37use InvalidArgumentException; 38use LogicException; 39use MediaWiki\Content\IContentHandlerFactory; 40use MediaWiki\DAO\WikiAwareEntity; 41use MediaWiki\HookContainer\HookContainer; 42use MediaWiki\HookContainer\HookRunner; 43use MediaWiki\Linker\LinkTarget; 44use MediaWiki\Page\LegacyArticleIdAccess; 45use MediaWiki\Page\PageIdentity; 46use MediaWiki\Page\PageIdentityValue; 47use MediaWiki\Page\PageStore; 48use MediaWiki\Permissions\Authority; 49use MediaWiki\Storage\BlobAccessException; 50use MediaWiki\Storage\BlobStore; 51use MediaWiki\Storage\NameTableStore; 52use MediaWiki\Storage\RevisionSlotsUpdate; 53use MediaWiki\Storage\SqlBlobStore; 54use MediaWiki\User\ActorStore; 55use MediaWiki\User\UserIdentity; 56use MWException; 57use MWTimestamp; 58use MWUnknownContentModelException; 59use Psr\Log\LoggerAwareInterface; 60use Psr\Log\LoggerInterface; 61use Psr\Log\NullLogger; 62use RecentChange; 63use RuntimeException; 64use StatusValue; 65use stdClass; 66use Title; 67use TitleFactory; 68use Traversable; 69use WANObjectCache; 70use Wikimedia\Assert\Assert; 71use Wikimedia\IPUtils; 72use Wikimedia\Rdbms\Database; 73use Wikimedia\Rdbms\DBConnRef; 74use Wikimedia\Rdbms\IDatabase; 75use Wikimedia\Rdbms\ILoadBalancer; 76use Wikimedia\Rdbms\IResultWrapper; 77 78/** 79 * Service for looking up page revisions. 80 * 81 * @since 1.31 82 * @since 1.32 Renamed from MediaWiki\Storage\RevisionStore 83 * 84 * @note This was written to act as a drop-in replacement for the corresponding 85 * static methods in the old Revision class (which was later removed in 1.37). 86 */ 87class RevisionStore 88 implements IDBAccessObject, RevisionFactory, RevisionLookup, LoggerAwareInterface { 89 90 use LegacyArticleIdAccess; 91 92 public const ROW_CACHE_KEY = 'revision-row-1.29'; 93 94 public const ORDER_OLDEST_TO_NEWEST = 'ASC'; 95 public const ORDER_NEWEST_TO_OLDEST = 'DESC'; 96 97 // Constants for get(...)Between methods 98 public const INCLUDE_OLD = 'include_old'; 99 public const INCLUDE_NEW = 'include_new'; 100 public const INCLUDE_BOTH = 'include_both'; 101 102 /** 103 * @var SqlBlobStore 104 */ 105 private $blobStore; 106 107 /** 108 * @var bool|string 109 */ 110 private $wikiId; 111 112 /** 113 * @var ILoadBalancer 114 */ 115 private $loadBalancer; 116 117 /** 118 * @var WANObjectCache 119 */ 120 private $cache; 121 122 /** 123 * @var CommentStore 124 */ 125 private $commentStore; 126 127 /** 128 * @var ActorMigration 129 */ 130 private $actorMigration; 131 132 /** @var ActorStore */ 133 private $actorStore; 134 135 /** 136 * @var LoggerInterface 137 */ 138 private $logger; 139 140 /** 141 * @var NameTableStore 142 */ 143 private $contentModelStore; 144 145 /** 146 * @var NameTableStore 147 */ 148 private $slotRoleStore; 149 150 /** @var SlotRoleRegistry */ 151 private $slotRoleRegistry; 152 153 /** @var IContentHandlerFactory */ 154 private $contentHandlerFactory; 155 156 /** @var HookRunner */ 157 private $hookRunner; 158 159 /** @var PageStore */ 160 private $pageStore; 161 162 /** @var TitleFactory */ 163 private $titleFactory; 164 165 /** 166 * @param ILoadBalancer $loadBalancer 167 * @param SqlBlobStore $blobStore 168 * @param WANObjectCache $cache A cache for caching revision rows. This can be the local 169 * wiki's default instance even if $wikiId refers to a different wiki, since 170 * makeGlobalKey() is used to constructed a key that allows cached revision rows from 171 * the same database to be re-used between wikis. For example, enwiki and frwiki will 172 * use the same cache keys for revision rows from the wikidatawiki database, regardless 173 * of the cache's default key space. 174 * @param CommentStore $commentStore 175 * @param NameTableStore $contentModelStore 176 * @param NameTableStore $slotRoleStore 177 * @param SlotRoleRegistry $slotRoleRegistry 178 * @param ActorMigration $actorMigration 179 * @param ActorStore $actorStore 180 * @param IContentHandlerFactory $contentHandlerFactory 181 * @param PageStore $pageStore 182 * @param TitleFactory $titleFactory 183 * @param HookContainer $hookContainer 184 * @param false|string $wikiId Relevant wiki id or WikiAwareEntity::LOCAL for the current one 185 * 186 * @todo $blobStore should be allowed to be any BlobStore! 187 * 188 */ 189 public function __construct( 190 ILoadBalancer $loadBalancer, 191 SqlBlobStore $blobStore, 192 WANObjectCache $cache, 193 CommentStore $commentStore, 194 NameTableStore $contentModelStore, 195 NameTableStore $slotRoleStore, 196 SlotRoleRegistry $slotRoleRegistry, 197 ActorMigration $actorMigration, 198 ActorStore $actorStore, 199 IContentHandlerFactory $contentHandlerFactory, 200 PageStore $pageStore, 201 TitleFactory $titleFactory, 202 HookContainer $hookContainer, 203 $wikiId = WikiAwareEntity::LOCAL 204 ) { 205 Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' ); 206 207 $this->loadBalancer = $loadBalancer; 208 $this->blobStore = $blobStore; 209 $this->cache = $cache; 210 $this->commentStore = $commentStore; 211 $this->contentModelStore = $contentModelStore; 212 $this->slotRoleStore = $slotRoleStore; 213 $this->slotRoleRegistry = $slotRoleRegistry; 214 $this->actorMigration = $actorMigration; 215 $this->actorStore = $actorStore; 216 $this->wikiId = $wikiId; 217 $this->logger = new NullLogger(); 218 $this->contentHandlerFactory = $contentHandlerFactory; 219 $this->pageStore = $pageStore; 220 $this->titleFactory = $titleFactory; 221 $this->hookRunner = new HookRunner( $hookContainer ); 222 } 223 224 public function setLogger( LoggerInterface $logger ) { 225 $this->logger = $logger; 226 } 227 228 /** 229 * @return bool Whether the store is read-only 230 */ 231 public function isReadOnly() { 232 return $this->blobStore->isReadOnly(); 233 } 234 235 /** 236 * @return ILoadBalancer 237 */ 238 private function getDBLoadBalancer() { 239 return $this->loadBalancer; 240 } 241 242 /** 243 * Get the ID of the wiki this revision belongs to. 244 * 245 * @return string|false The wiki's logical name, of false to indicate the local wiki. 246 */ 247 public function getWikiId() { 248 return $this->wikiId; 249 } 250 251 /** 252 * @param int $queryFlags a bit field composed of READ_XXX flags 253 * 254 * @return DBConnRef 255 */ 256 private function getDBConnectionRefForQueryFlags( $queryFlags ) { 257 list( $mode, ) = DBAccessObjectUtils::getDBOptions( $queryFlags ); 258 return $this->getDBConnectionRef( $mode ); 259 } 260 261 /** 262 * @param int $mode DB_PRIMARY or DB_REPLICA 263 * 264 * @param array $groups 265 * @return DBConnRef 266 */ 267 private function getDBConnectionRef( $mode, $groups = [] ) { 268 $lb = $this->getDBLoadBalancer(); 269 return $lb->getConnectionRef( $mode, $groups, $this->wikiId ); 270 } 271 272 /** 273 * Determines the page Title based on the available information. 274 * 275 * MCR migration note: this corresponded to Revision::getTitle 276 * 277 * @deprecated since 1.36, Use RevisionRecord::getPage() instead. 278 * @note The resulting Title object will be misleading if the RevisionStore is not 279 * for the local wiki. 280 * 281 * @param int|null $pageId 282 * @param int|null $revId 283 * @param int $queryFlags 284 * 285 * @return Title 286 * @throws RevisionAccessException 287 */ 288 public function getTitle( $pageId, $revId, $queryFlags = self::READ_NORMAL ) { 289 // TODO: Hard-deprecate this once getPage() returns a PageRecord. T195069 290 if ( $this->wikiId !== WikiAwareEntity::LOCAL ) { 291 wfDeprecatedMsg( 'Using a Title object to refer to a page on another site.', '1.36' ); 292 } 293 294 $page = $this->getPage( $pageId, $revId, $queryFlags ); 295 return $this->titleFactory->castFromPageIdentity( $page ); 296 } 297 298 /** 299 * Determines the page based on the available information. 300 * 301 * @param int|null $pageId 302 * @param int|null $revId 303 * @param int $queryFlags 304 * 305 * @return PageIdentity 306 * @throws RevisionAccessException 307 */ 308 private function getPage( ?int $pageId, ?int $revId, int $queryFlags = self::READ_NORMAL ) { 309 if ( !$pageId && !$revId ) { 310 throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' ); 311 } 312 313 // This method recalls itself with READ_LATEST if READ_NORMAL doesn't get us a Title 314 // So ignore READ_LATEST_IMMUTABLE flags and handle the fallback logic in this method 315 if ( DBAccessObjectUtils::hasFlags( $queryFlags, self::READ_LATEST_IMMUTABLE ) ) { 316 $queryFlags = self::READ_NORMAL; 317 } 318 319 $canUsePageId = ( $pageId !== null && $pageId > 0 ); 320 321 // Loading by ID is best 322 if ( $canUsePageId ) { 323 $page = $this->pageStore->getPageById( $pageId, $queryFlags ); 324 if ( $page ) { 325 return $this->wrapPage( $page ); 326 } 327 } 328 329 // rev_id is defined as NOT NULL, but this revision may not yet have been inserted. 330 $canUseRevId = ( $revId !== null && $revId > 0 ); 331 332 if ( $canUseRevId ) { 333 $pageQuery = $this->pageStore->newSelectQueryBuilder( $queryFlags ) 334 ->join( 'revision', null, 'page_id=rev_page' ) 335 ->conds( [ 'rev_id' => $revId ] ) 336 ->caller( __METHOD__ ); 337 338 $page = $pageQuery->fetchPageRecord(); 339 if ( $page ) { 340 return $this->wrapPage( $page ); 341 } 342 } 343 344 // If we still don't have a title, fallback to primary DB if that wasn't already happening. 345 if ( $queryFlags === self::READ_NORMAL ) { 346 $title = $this->getPage( $pageId, $revId, self::READ_LATEST ); 347 if ( $title ) { 348 $this->logger->info( 349 __METHOD__ . ' fell back to READ_LATEST and got a Title.', 350 [ 'trace' => wfBacktrace() ] 351 ); 352 return $title; 353 } 354 } 355 356 throw new RevisionAccessException( 357 'Could not determine title for page ID {page_id} and revision ID {rev_id}', 358 [ 359 'page_id' => $pageId, 360 'rev_id' => $revId, 361 ] 362 ); 363 } 364 365 /** 366 * @param PageIdentity $page 367 * 368 * @return PageIdentity 369 */ 370 private function wrapPage( PageIdentity $page ): PageIdentity { 371 if ( $this->wikiId === WikiAwareEntity::LOCAL ) { 372 // NOTE: since there is still a lot of code that needs a full Title, 373 // and uses Title::castFromPageIdentity() to get one, it's beneficial 374 // to create a Title right away if we can, so we don't have to convert 375 // over and over later on. 376 // When there is less need to convert to Title, this special case can 377 // be removed. 378 return $this->titleFactory->castFromPageIdentity( $page ); 379 } else { 380 return $page; 381 } 382 } 383 384 /** 385 * @param mixed $value 386 * @param string $name 387 * 388 * @throws IncompleteRevisionException if $value is null 389 * @return mixed $value, if $value is not null 390 */ 391 private function failOnNull( $value, $name ) { 392 if ( $value === null ) { 393 throw new IncompleteRevisionException( 394 "$name must not be " . var_export( $value, true ) . "!" 395 ); 396 } 397 398 return $value; 399 } 400 401 /** 402 * @param mixed $value 403 * @param string $name 404 * 405 * @throws IncompleteRevisionException if $value is empty 406 * @return mixed $value, if $value is not null 407 */ 408 private function failOnEmpty( $value, $name ) { 409 if ( $value === null || $value === 0 || $value === '' ) { 410 throw new IncompleteRevisionException( 411 "$name must not be " . var_export( $value, true ) . "!" 412 ); 413 } 414 415 return $value; 416 } 417 418 /** 419 * Insert a new revision into the database, returning the new revision record 420 * on success and dies horribly on failure. 421 * 422 * MCR migration note: this replaced Revision::insertOn 423 * 424 * @param RevisionRecord $rev 425 * @param IDatabase $dbw (primary connection) 426 * 427 * @return RevisionRecord the new revision record. 428 */ 429 public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) { 430 // TODO: pass in a DBTransactionContext instead of a database connection. 431 $this->checkDatabaseDomain( $dbw ); 432 433 $slotRoles = $rev->getSlotRoles(); 434 435 // Make sure the main slot is always provided throughout migration 436 if ( !in_array( SlotRecord::MAIN, $slotRoles ) ) { 437 throw new IncompleteRevisionException( 438 'main slot must be provided' 439 ); 440 } 441 442 // Checks 443 $this->failOnNull( $rev->getSize(), 'size field' ); 444 $this->failOnEmpty( $rev->getSha1(), 'sha1 field' ); 445 $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' ); 446 $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' ); 447 $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' ); 448 $this->failOnNull( $user->getId(), 'user field' ); 449 $this->failOnEmpty( $user->getName(), 'user_text field' ); 450 451 if ( !$rev->isReadyForInsertion() ) { 452 // This is here for future-proofing. At the time this check being added, it 453 // was redundant to the individual checks above. 454 throw new IncompleteRevisionException( 'Revision is incomplete' ); 455 } 456 457 if ( $slotRoles == [ SlotRecord::MAIN ] ) { 458 // T239717: If the main slot is the only slot, make sure the revision's nominal size 459 // and hash match the main slot's nominal size and hash. 460 $mainSlot = $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ); 461 Assert::precondition( 462 $mainSlot->getSize() === $rev->getSize(), 463 'The revisions\'s size must match the main slot\'s size (see T239717)' 464 ); 465 Assert::precondition( 466 $mainSlot->getSha1() === $rev->getSha1(), 467 'The revisions\'s SHA1 hash must match the main slot\'s SHA1 hash (see T239717)' 468 ); 469 } 470 471 $pageId = $this->failOnEmpty( $rev->getPageId( $this->wikiId ), 'rev_page field' ); // check this early 472 473 $parentId = $rev->getParentId() ?? $this->getPreviousRevisionId( $dbw, $rev ); 474 475 /** @var RevisionRecord $rev */ 476 $rev = $dbw->doAtomicSection( 477 __METHOD__, 478 function ( IDatabase $dbw, $fname ) use ( 479 $rev, 480 $user, 481 $comment, 482 $pageId, 483 $parentId 484 ) { 485 return $this->insertRevisionInternal( 486 $rev, 487 $dbw, 488 $user, 489 $comment, 490 $rev->getPage(), 491 $pageId, 492 $parentId 493 ); 494 } 495 ); 496 497 // sanity checks 498 Assert::postcondition( $rev->getId( $this->wikiId ) > 0, 'revision must have an ID' ); 499 Assert::postcondition( $rev->getPageId( $this->wikiId ) > 0, 'revision must have a page ID' ); 500 Assert::postcondition( 501 $rev->getComment( RevisionRecord::RAW ) !== null, 502 'revision must have a comment' 503 ); 504 Assert::postcondition( 505 $rev->getUser( RevisionRecord::RAW ) !== null, 506 'revision must have a user' 507 ); 508 509 // Trigger exception if the main slot is missing. 510 // Technically, this could go away after MCR migration: while 511 // calling code may require a main slot to exist, RevisionStore 512 // really should not know or care about that requirement. 513 $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ); 514 515 foreach ( $slotRoles as $role ) { 516 $slot = $rev->getSlot( $role, RevisionRecord::RAW ); 517 Assert::postcondition( 518 $slot->getContent() !== null, 519 $role . ' slot must have content' 520 ); 521 Assert::postcondition( 522 $slot->hasRevision(), 523 $role . ' slot must have a revision associated' 524 ); 525 } 526 527 $this->hookRunner->onRevisionRecordInserted( $rev ); 528 529 return $rev; 530 } 531 532 /** 533 * Update derived slots in an existing revision into the database, returning the modified 534 * slots on success. 535 * 536 * @param RevisionRecord $revision After this method returns, the $revision object will be 537 * obsolete in that it does not have the new slots. 538 * @param RevisionSlotsUpdate $revisionSlotsUpdate 539 * @param IDatabase $dbw (primary connection) 540 * 541 * @return SlotRecord[] the new slot records. 542 * @internal 543 */ 544 public function updateSlotsOn( 545 RevisionRecord $revision, 546 RevisionSlotsUpdate $revisionSlotsUpdate, 547 IDatabase $dbw 548 ): array { 549 $this->checkDatabaseDomain( $dbw ); 550 551 // Make sure all modified and removed slots are derived slots 552 foreach ( $revisionSlotsUpdate->getModifiedRoles() as $role ) { 553 Assert::precondition( 554 $this->slotRoleRegistry->getRoleHandler( $role )->isDerived(), 555 'Trying to modify a slot that is not derived' 556 ); 557 } 558 foreach ( $revisionSlotsUpdate->getRemovedRoles() as $role ) { 559 $isDerived = $this->slotRoleRegistry->getRoleHandler( $role )->isDerived(); 560 Assert::precondition( 561 $isDerived, 562 'Trying to remove a slot that is not derived' 563 ); 564 throw new LogicException( 'Removing derived slots is not yet implemented. See T277394.' ); 565 } 566 567 /** @var SlotRecord[] $slotRecords */ 568 $slotRecords = $dbw->doAtomicSection( 569 __METHOD__, 570 function ( IDatabase $dbw, $fname ) use ( 571 $revision, 572 $revisionSlotsUpdate 573 ) { 574 return $this->updateSlotsInternal( 575 $revision, 576 $revisionSlotsUpdate, 577 $dbw 578 ); 579 } 580 ); 581 582 foreach ( $slotRecords as $role => $slot ) { 583 Assert::postcondition( 584 $slot->getContent() !== null, 585 $role . ' slot must have content' 586 ); 587 Assert::postcondition( 588 $slot->hasRevision(), 589 $role . ' slot must have a revision associated' 590 ); 591 } 592 593 return $slotRecords; 594 } 595 596 /** 597 * @param RevisionRecord $revision 598 * @param RevisionSlotsUpdate $revisionSlotsUpdate 599 * @param IDatabase $dbw 600 * @return SlotRecord[] 601 */ 602 private function updateSlotsInternal( 603 RevisionRecord $revision, 604 RevisionSlotsUpdate $revisionSlotsUpdate, 605 IDatabase $dbw 606 ): array { 607 $page = $revision->getPage(); 608 $revId = $revision->getId( $this->wikiId ); 609 $blobHints = [ 610 BlobStore::PAGE_HINT => $page->getId( $this->wikiId ), 611 BlobStore::REVISION_HINT => $revId, 612 BlobStore::PARENT_HINT => $revision->getParentId( $this->wikiId ), 613 ]; 614 615 $newSlots = []; 616 foreach ( $revisionSlotsUpdate->getModifiedRoles() as $role ) { 617 $slot = $revisionSlotsUpdate->getModifiedSlot( $role ); 618 $newSlots[$role] = $this->insertSlotOn( $dbw, $revId, $slot, $page, $blobHints ); 619 } 620 621 return $newSlots; 622 } 623 624 private function insertRevisionInternal( 625 RevisionRecord $rev, 626 IDatabase $dbw, 627 UserIdentity $user, 628 CommentStoreComment $comment, 629 PageIdentity $page, 630 $pageId, 631 $parentId 632 ) { 633 $slotRoles = $rev->getSlotRoles(); 634 635 $revisionRow = $this->insertRevisionRowOn( 636 $dbw, 637 $rev, 638 $parentId 639 ); 640 641 $revisionId = $revisionRow['rev_id']; 642 643 $blobHints = [ 644 BlobStore::PAGE_HINT => $pageId, 645 BlobStore::REVISION_HINT => $revisionId, 646 BlobStore::PARENT_HINT => $parentId, 647 ]; 648 649 $newSlots = []; 650 foreach ( $slotRoles as $role ) { 651 $slot = $rev->getSlot( $role, RevisionRecord::RAW ); 652 653 // If the SlotRecord already has a revision ID set, this means it already exists 654 // in the database, and should already belong to the current revision. 655 // However, a slot may already have a revision, but no content ID, if the slot 656 // is emulated based on the archive table, because we are in SCHEMA_COMPAT_READ_OLD 657 // mode, and the respective archive row was not yet migrated to the new schema. 658 // In that case, a new slot row (and content row) must be inserted even during 659 // undeletion. 660 if ( $slot->hasRevision() && $slot->hasContentId() ) { 661 // TODO: properly abort transaction if the assertion fails! 662 Assert::parameter( 663 $slot->getRevision() === $revisionId, 664 'slot role ' . $slot->getRole(), 665 'Existing slot should belong to revision ' 666 . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!' 667 ); 668 669 // Slot exists, nothing to do, move along. 670 // This happens when restoring archived revisions. 671 672 $newSlots[$role] = $slot; 673 } else { 674 $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $page, $blobHints ); 675 } 676 } 677 678 $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId ); 679 680 $rev = new RevisionStoreRecord( 681 $page, 682 $user, 683 $comment, 684 (object)$revisionRow, 685 new RevisionSlots( $newSlots ), 686 $this->wikiId 687 ); 688 689 return $rev; 690 } 691 692 /** 693 * @param IDatabase $dbw 694 * @param int $revisionId 695 * @param SlotRecord $protoSlot 696 * @param PageIdentity $page 697 * @param array $blobHints See the BlobStore::XXX_HINT constants 698 * @return SlotRecord 699 */ 700 private function insertSlotOn( 701 IDatabase $dbw, 702 $revisionId, 703 SlotRecord $protoSlot, 704 PageIdentity $page, 705 array $blobHints = [] 706 ) { 707 if ( $protoSlot->hasAddress() ) { 708 $blobAddress = $protoSlot->getAddress(); 709 } else { 710 $blobAddress = $this->storeContentBlob( $protoSlot, $page, $blobHints ); 711 } 712 713 $contentId = null; 714 715 if ( $protoSlot->hasContentId() ) { 716 $contentId = $protoSlot->getContentId(); 717 } else { 718 $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress ); 719 } 720 721 $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId ); 722 723 return SlotRecord::newSaved( 724 $revisionId, 725 $contentId, 726 $blobAddress, 727 $protoSlot 728 ); 729 } 730 731 /** 732 * Insert IP revision into ip_changes for use when querying for a range. 733 * @param IDatabase $dbw 734 * @param UserIdentity $user 735 * @param RevisionRecord $rev 736 * @param int $revisionId 737 */ 738 private function insertIpChangesRow( 739 IDatabase $dbw, 740 UserIdentity $user, 741 RevisionRecord $rev, 742 $revisionId 743 ) { 744 if ( $user->getId() === 0 && IPUtils::isValid( $user->getName() ) ) { 745 $ipcRow = [ 746 'ipc_rev_id' => $revisionId, 747 'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ), 748 'ipc_hex' => IPUtils::toHex( $user->getName() ), 749 ]; 750 $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ ); 751 } 752 } 753 754 /** 755 * @param IDatabase $dbw 756 * @param RevisionRecord $rev 757 * @param int $parentId 758 * 759 * @return array a revision table row 760 * 761 * @throws MWException 762 * @throws MWUnknownContentModelException 763 */ 764 private function insertRevisionRowOn( 765 IDatabase $dbw, 766 RevisionRecord $rev, 767 $parentId 768 ) { 769 $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $parentId ); 770 771 list( $commentFields, $commentCallback ) = 772 $this->commentStore->insertWithTempTable( 773 $dbw, 774 'rev_comment', 775 $rev->getComment( RevisionRecord::RAW ) 776 ); 777 $revisionRow += $commentFields; 778 779 list( $actorFields, $actorCallback ) = 780 $this->actorMigration->getInsertValuesWithTempTable( 781 $dbw, 782 'rev_user', 783 $rev->getUser( RevisionRecord::RAW ) 784 ); 785 $revisionRow += $actorFields; 786 787 $dbw->insert( 'revision', $revisionRow, __METHOD__ ); 788 789 if ( !isset( $revisionRow['rev_id'] ) ) { 790 // only if auto-increment was used 791 $revisionRow['rev_id'] = intval( $dbw->insertId() ); 792 793 if ( $dbw->getType() === 'mysql' ) { 794 // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the 795 // auto-increment value to disk, so on server restart it might reuse IDs from deleted 796 // revisions. We can fix that with an insert with an explicit rev_id value, if necessary. 797 798 $maxRevId = intval( $dbw->selectField( 'archive', 'MAX(ar_rev_id)', '', __METHOD__ ) ); 799 $table = 'archive'; 800 $maxRevId2 = intval( $dbw->selectField( 'slots', 'MAX(slot_revision_id)', '', __METHOD__ ) ); 801 if ( $maxRevId2 >= $maxRevId ) { 802 $maxRevId = $maxRevId2; 803 $table = 'slots'; 804 } 805 806 if ( $maxRevId >= $revisionRow['rev_id'] ) { 807 $this->logger->debug( 808 '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.' 809 . ' Trying to fix it.', 810 [ 811 'revid' => $revisionRow['rev_id'], 812 'table' => $table, 813 'maxrevid' => $maxRevId, 814 ] 815 ); 816 817 if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) { 818 throw new MWException( 'Failed to get database lock for T202032' ); 819 } 820 $fname = __METHOD__; 821 $dbw->onTransactionResolution( 822 static function ( $trigger, IDatabase $dbw ) use ( $fname ) { 823 $dbw->unlock( 'fix-for-T202032', $fname ); 824 }, 825 __METHOD__ 826 ); 827 828 $dbw->delete( 'revision', [ 'rev_id' => $revisionRow['rev_id'] ], __METHOD__ ); 829 830 // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction 831 // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing 832 // inserts too, though, at least on MariaDB 10.1.29. 833 // 834 // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent 835 // transactions in this code path thanks to the row lock from the original ->insert() above. 836 // 837 // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning 838 // that's for non-MySQL DBs. 839 $row1 = $dbw->query( 840 $dbw->selectSQLText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE', 841 __METHOD__ 842 )->fetchObject(); 843 844 $row2 = $dbw->query( 845 $dbw->selectSQLText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ ) 846 . ' FOR UPDATE', 847 __METHOD__ 848 )->fetchObject(); 849 850 $maxRevId = max( 851 $maxRevId, 852 $row1 ? intval( $row1->v ) : 0, 853 $row2 ? intval( $row2->v ) : 0 854 ); 855 856 // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent 857 // transactions will throw a duplicate key error here. It doesn't seem worth trying 858 // to avoid that. 859 $revisionRow['rev_id'] = $maxRevId + 1; 860 $dbw->insert( 'revision', $revisionRow, __METHOD__ ); 861 } 862 } 863 } 864 865 $commentCallback( $revisionRow['rev_id'] ); 866 $actorCallback( $revisionRow['rev_id'], $revisionRow ); 867 868 return $revisionRow; 869 } 870 871 /** 872 * @param IDatabase $dbw 873 * @param RevisionRecord $rev 874 * @param int $parentId 875 * 876 * @return array a revision table row 877 */ 878 private function getBaseRevisionRow( 879 IDatabase $dbw, 880 RevisionRecord $rev, 881 $parentId 882 ) { 883 // Record the edit in revisions 884 $revisionRow = [ 885 'rev_page' => $rev->getPageId( $this->wikiId ), 886 'rev_parent_id' => $parentId, 887 'rev_minor_edit' => $rev->isMinor() ? 1 : 0, 888 'rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ), 889 'rev_deleted' => $rev->getVisibility(), 890 'rev_len' => $rev->getSize(), 891 'rev_sha1' => $rev->getSha1(), 892 ]; 893 894 if ( $rev->getId( $this->wikiId ) !== null ) { 895 // Needed to restore revisions with their original ID 896 $revisionRow['rev_id'] = $rev->getId( $this->wikiId ); 897 } 898 899 return $revisionRow; 900 } 901 902 /** 903 * @param SlotRecord $slot 904 * @param PageIdentity $page 905 * @param array $blobHints See the BlobStore::XXX_HINT constants 906 * 907 * @throws MWException 908 * @return string the blob address 909 */ 910 private function storeContentBlob( 911 SlotRecord $slot, 912 PageIdentity $page, 913 array $blobHints = [] 914 ) { 915 $content = $slot->getContent(); 916 $format = $content->getDefaultFormat(); 917 $model = $content->getModel(); 918 919 $this->checkContent( $content, $page, $slot->getRole() ); 920 921 return $this->blobStore->storeBlob( 922 $content->serialize( $format ), 923 // These hints "leak" some information from the higher abstraction layer to 924 // low level storage to allow for optimization. 925 array_merge( 926 $blobHints, 927 [ 928 BlobStore::DESIGNATION_HINT => 'page-content', 929 BlobStore::ROLE_HINT => $slot->getRole(), 930 BlobStore::SHA1_HINT => $slot->getSha1(), 931 BlobStore::MODEL_HINT => $model, 932 BlobStore::FORMAT_HINT => $format, 933 ] 934 ) 935 ); 936 } 937 938 /** 939 * @param SlotRecord $slot 940 * @param IDatabase $dbw 941 * @param int $revisionId 942 * @param int $contentId 943 */ 944 private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) { 945 $slotRow = [ 946 'slot_revision_id' => $revisionId, 947 'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ), 948 'slot_content_id' => $contentId, 949 // If the slot has a specific origin use that ID, otherwise use the ID of the revision 950 // that we just inserted. 951 'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId, 952 ]; 953 $dbw->insert( 'slots', $slotRow, __METHOD__ ); 954 } 955 956 /** 957 * @param SlotRecord $slot 958 * @param IDatabase $dbw 959 * @param string $blobAddress 960 * @return int content row ID 961 */ 962 private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) { 963 $contentRow = [ 964 'content_size' => $slot->getSize(), 965 'content_sha1' => $slot->getSha1(), 966 'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ), 967 'content_address' => $blobAddress, 968 ]; 969 $dbw->insert( 'content', $contentRow, __METHOD__ ); 970 return intval( $dbw->insertId() ); 971 } 972 973 /** 974 * MCR migration note: this corresponded to Revision::checkContentModel 975 * 976 * @param Content $content 977 * @param PageIdentity $page 978 * @param string $role 979 * 980 * @throws MWException 981 * @throws MWUnknownContentModelException 982 */ 983 private function checkContent( Content $content, PageIdentity $page, string $role ) { 984 // Note: may return null for revisions that have not yet been inserted 985 986 $model = $content->getModel(); 987 $format = $content->getDefaultFormat(); 988 $handler = $content->getContentHandler(); 989 990 if ( !$handler->isSupportedFormat( $format ) ) { 991 throw new MWException( 992 "Can't use format $format with content model $model on $page role $role" 993 ); 994 } 995 996 if ( !$content->isValid() ) { 997 throw new MWException( 998 "New content for $page role $role is not valid! Content model is $model" 999 ); 1000 } 1001 } 1002 1003 /** 1004 * Create a new null-revision for insertion into a page's 1005 * history. This will not re-save the text, but simply refer 1006 * to the text from the previous version. 1007 * 1008 * Such revisions can for instance identify page rename 1009 * operations and other such meta-modifications. 1010 * 1011 * @note This method grabs a FOR UPDATE lock on the relevant row of the page table, 1012 * to prevent a new revision from being inserted before the null revision has been written 1013 * to the database. 1014 * 1015 * MCR migration note: this replaced Revision::newNullRevision 1016 * 1017 * @todo Introduce newFromParentRevision(). newNullRevision can then be based on that 1018 * (or go away). 1019 * 1020 * @param IDatabase $dbw used for obtaining the lock on the page table row 1021 * @param PageIdentity $page the page to read from 1022 * @param CommentStoreComment $comment RevisionRecord's summary 1023 * @param bool $minor Whether the revision should be considered as minor 1024 * @param UserIdentity $user The user to attribute the revision to 1025 * 1026 * @return RevisionRecord|null RevisionRecord or null on error 1027 */ 1028 public function newNullRevision( 1029 IDatabase $dbw, 1030 PageIdentity $page, 1031 CommentStoreComment $comment, 1032 $minor, 1033 UserIdentity $user 1034 ) { 1035 $this->checkDatabaseDomain( $dbw ); 1036 1037 $pageId = $this->getArticleId( $page ); 1038 1039 // T51581: Lock the page table row to ensure no other process 1040 // is adding a revision to the page at the same time. 1041 // Avoid locking extra tables, compare T191892. 1042 $pageLatest = $dbw->selectField( 1043 'page', 1044 'page_latest', 1045 [ 'page_id' => $pageId ], 1046 __METHOD__, 1047 [ 'FOR UPDATE' ] 1048 ); 1049 1050 if ( !$pageLatest ) { 1051 $msg = 'T235589: Failed to select table row during null revision creation' . 1052 " Page id '$pageId' does not exist."; 1053 $this->logger->error( 1054 $msg, 1055 [ 'exception' => new RuntimeException( $msg ) ] 1056 ); 1057 1058 return null; 1059 } 1060 1061 // Fetch the actual revision row from primary DB, without locking all extra tables. 1062 $oldRevision = $this->loadRevisionFromConds( 1063 $dbw, 1064 [ 'rev_id' => intval( $pageLatest ) ], 1065 self::READ_LATEST, 1066 $page 1067 ); 1068 1069 if ( !$oldRevision ) { 1070 $msg = "Failed to load latest revision ID $pageLatest of page ID $pageId."; 1071 $this->logger->error( 1072 $msg, 1073 [ 'exception' => new RuntimeException( $msg ) ] 1074 ); 1075 return null; 1076 } 1077 1078 // Construct the new revision 1079 $timestamp = MWTimestamp::now( TS_MW ); 1080 $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision ); 1081 1082 $newRevision->setComment( $comment ); 1083 $newRevision->setUser( $user ); 1084 $newRevision->setTimestamp( $timestamp ); 1085 $newRevision->setMinorEdit( $minor ); 1086 1087 return $newRevision; 1088 } 1089 1090 /** 1091 * MCR migration note: this replaced Revision::isUnpatrolled 1092 * 1093 * @todo This is overly specific, so move or kill this method. 1094 * 1095 * @param RevisionRecord $rev 1096 * 1097 * @return int Rcid of the unpatrolled row, zero if there isn't one 1098 */ 1099 public function getRcIdIfUnpatrolled( RevisionRecord $rev ) { 1100 $rc = $this->getRecentChange( $rev ); 1101 if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) { 1102 return $rc->getAttribute( 'rc_id' ); 1103 } else { 1104 return 0; 1105 } 1106 } 1107 1108 /** 1109 * Get the RC object belonging to the current revision, if there's one 1110 * 1111 * MCR migration note: this replaced Revision::getRecentChange 1112 * 1113 * @todo move this somewhere else? 1114 * 1115 * @param RevisionRecord $rev 1116 * @param int $flags (optional) $flags include: 1117 * IDBAccessObject::READ_LATEST: Select the data from the primary DB 1118 * 1119 * @return null|RecentChange 1120 */ 1121 public function getRecentChange( RevisionRecord $rev, $flags = 0 ) { 1122 list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags ); 1123 1124 $rc = RecentChange::newFromConds( 1125 [ 'rc_this_oldid' => $rev->getId( $this->wikiId ) ], 1126 __METHOD__, 1127 $dbType 1128 ); 1129 1130 // XXX: cache this locally? Glue it to the RevisionRecord? 1131 return $rc; 1132 } 1133 1134 /** 1135 * Loads a Content object based on a slot row. 1136 * 1137 * This method does not call $slot->getContent(), and may be used as a callback 1138 * called by $slot->getContent(). 1139 * 1140 * MCR migration note: this roughly corresponded to Revision::getContentInternal 1141 * 1142 * @param SlotRecord $slot The SlotRecord to load content for 1143 * @param string|null $blobData The content blob, in the form indicated by $blobFlags 1144 * @param string|null $blobFlags Flags indicating how $blobData needs to be processed. 1145 * Use null if no processing should happen. That is in constrast to the empty string, 1146 * which causes the blob to be decoded according to the configured legacy encoding. 1147 * @param string|null $blobFormat MIME type indicating how $dataBlob is encoded 1148 * @param int $queryFlags 1149 * 1150 * @throws RevisionAccessException 1151 * @return Content 1152 */ 1153 private function loadSlotContent( 1154 SlotRecord $slot, 1155 ?string $blobData = null, 1156 ?string $blobFlags = null, 1157 ?string $blobFormat = null, 1158 int $queryFlags = 0 1159 ) { 1160 if ( $blobData !== null ) { 1161 $cacheKey = $slot->hasAddress() ? $slot->getAddress() : null; 1162 1163 if ( $blobFlags === null ) { 1164 // No blob flags, so use the blob verbatim. 1165 $data = $blobData; 1166 } else { 1167 $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $cacheKey ); 1168 if ( $data === false ) { 1169 throw new RevisionAccessException( 1170 'Failed to expand blob data using flags {flags} (key: {cache_key})', 1171 [ 1172 'flags' => $blobFlags, 1173 'cache_key' => $cacheKey, 1174 ] 1175 ); 1176 } 1177 } 1178 1179 } else { 1180 $address = $slot->getAddress(); 1181 try { 1182 $data = $this->blobStore->getBlob( $address, $queryFlags ); 1183 } catch ( BlobAccessException $e ) { 1184 throw new RevisionAccessException( 1185 'Failed to load data blob from {address}' 1186 . 'If this problem persist, use the findBadBlobs maintenance script ' 1187 . 'to investigate the issue and mark bad blobs.', 1188 [ 'address' => $e->getMessage() ], 1189 0, 1190 $e 1191 ); 1192 } 1193 } 1194 1195 $model = $slot->getModel(); 1196 1197 // If the content model is not known, don't fail here (T220594, T220793, T228921) 1198 if ( !$this->contentHandlerFactory->isDefinedModel( $model ) ) { 1199 $this->logger->warning( 1200 "Undefined content model '$model', falling back to UnknownContent", 1201 [ 1202 'content_address' => $slot->getAddress(), 1203 'rev_id' => $slot->getRevision(), 1204 'role_name' => $slot->getRole(), 1205 'model_name' => $model, 1206 'trace' => wfBacktrace() 1207 ] 1208 ); 1209 1210 return new FallbackContent( $data, $model ); 1211 } 1212 1213 return $this->contentHandlerFactory 1214 ->getContentHandler( $model ) 1215 ->unserializeContent( $data, $blobFormat ); 1216 } 1217 1218 /** 1219 * Load a page revision from a given revision ID number. 1220 * Returns null if no such revision can be found. 1221 * 1222 * MCR migration note: this replaced Revision::newFromId 1223 * 1224 * $flags include: 1225 * IDBAccessObject::READ_LATEST: Select the data from the primary DB 1226 * IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB 1227 * 1228 * @param int $id 1229 * @param int $flags (optional) 1230 * @param PageIdentity|null $page The page the revision belongs to. 1231 * Providing the page may improve performance. 1232 * 1233 * @return RevisionRecord|null 1234 */ 1235 public function getRevisionById( $id, $flags = 0, PageIdentity $page = null ) { 1236 return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags, $page ); 1237 } 1238 1239 /** 1240 * Load either the current, or a specified, revision 1241 * that's attached to a given link target. If not attached 1242 * to that link target, will return null. 1243 * 1244 * MCR migration note: this replaced Revision::newFromTitle 1245 * 1246 * $flags include: 1247 * IDBAccessObject::READ_LATEST: Select the data from the primary DB 1248 * IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB 1249 * 1250 * @param LinkTarget|PageIdentity $page Calling with LinkTarget is deprecated since 1.36 1251 * @param int $revId (optional) 1252 * @param int $flags Bitfield (optional) 1253 * @return RevisionRecord|null 1254 */ 1255 public function getRevisionByTitle( $page, $revId = 0, $flags = 0 ) { 1256 $conds = [ 1257 'page_namespace' => $page->getNamespace(), 1258 'page_title' => $page->getDBkey() 1259 ]; 1260 1261 if ( $page instanceof LinkTarget ) { 1262 // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756) 1263 $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null; 1264 } 1265 1266 if ( $revId ) { 1267 // Use the specified revision ID. 1268 // Note that we use newRevisionFromConds here because we want to retry 1269 // and fall back to primary DB if the page is not found on a replica. 1270 // Since the caller supplied a revision ID, we are pretty sure the revision is 1271 // supposed to exist, so we should try hard to find it. 1272 $conds['rev_id'] = $revId; 1273 return $this->newRevisionFromConds( $conds, $flags, $page ); 1274 } else { 1275 // Use a join to get the latest revision. 1276 // Note that we don't use newRevisionFromConds here because we don't want to retry 1277 // and fall back to primary DB. The assumption is that we only want to force the fallback 1278 // if we are quite sure the revision exists because the caller supplied a revision ID. 1279 // If the page isn't found at all on a replica, it probably simply does not exist. 1280 $db = $this->getDBConnectionRefForQueryFlags( $flags ); 1281 $conds[] = 'rev_id=page_latest'; 1282 return $this->loadRevisionFromConds( $db, $conds, $flags, $page ); 1283 } 1284 } 1285 1286 /** 1287 * Load either the current, or a specified, revision 1288 * that's attached to a given page ID. 1289 * Returns null if no such revision can be found. 1290 * 1291 * MCR migration note: this replaced Revision::newFromPageId 1292 * 1293 * $flags include: 1294 * IDBAccessObject::READ_LATEST: Select the data from the primary DB (since 1.20) 1295 * IDBAccessObject::READ_LOCKING : Select & lock the data from the primary DB 1296 * 1297 * @param int $pageId 1298 * @param int $revId (optional) 1299 * @param int $flags Bitfield (optional) 1300 * @return RevisionRecord|null 1301 */ 1302 public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) { 1303 $conds = [ 'page_id' => $pageId ]; 1304 if ( $revId ) { 1305 // Use the specified revision ID. 1306 // Note that we use newRevisionFromConds here because we want to retry 1307 // and fall back to primary DB if the page is not found on a replica. 1308 // Since the caller supplied a revision ID, we are pretty sure the revision is 1309 // supposed to exist, so we should try hard to find it. 1310 $conds['rev_id'] = $revId; 1311 return $this->newRevisionFromConds( $conds, $flags ); 1312 } else { 1313 // Use a join to get the latest revision. 1314 // Note that we don't use newRevisionFromConds here because we don't want to retry 1315 // and fall back to primary DB. The assumption is that we only want to force the fallback 1316 // if we are quite sure the revision exists because the caller supplied a revision ID. 1317 // If the page isn't found at all on a replica, it probably simply does not exist. 1318 $db = $this->getDBConnectionRefForQueryFlags( $flags ); 1319 1320 $conds[] = 'rev_id=page_latest'; 1321 1322 return $this->loadRevisionFromConds( $db, $conds, $flags ); 1323 } 1324 } 1325 1326 /** 1327 * Load the revision for the given title with the given timestamp. 1328 * WARNING: Timestamps may in some circumstances not be unique, 1329 * so this isn't the best key to use. 1330 * 1331 * MCR migration note: this replaced Revision::loadFromTimestamp 1332 * 1333 * @param LinkTarget|PageIdentity $page Calling with LinkTarget is deprecated since 1.36 1334 * @param string $timestamp 1335 * @param int $flags Bitfield (optional) include: 1336 * IDBAccessObject::READ_LATEST: Select the data from the primary DB 1337 * IDBAccessObject::READ_LOCKING: Select & lock the data from the primary DB 1338 * Default: IDBAccessObject::READ_NORMAL 1339 * @return RevisionRecord|null 1340 */ 1341 public function getRevisionByTimestamp( 1342 $page, 1343 string $timestamp, 1344 int $flags = IDBAccessObject::READ_NORMAL 1345 ): ?RevisionRecord { 1346 if ( $page instanceof LinkTarget ) { 1347 // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756) 1348 $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null; 1349 } 1350 $db = $this->getDBConnectionRefForQueryFlags( $flags ); 1351 return $this->newRevisionFromConds( 1352 [ 1353 'rev_timestamp' => $db->timestamp( $timestamp ), 1354 'page_namespace' => $page->getNamespace(), 1355 'page_title' => $page->getDBkey() 1356 ], 1357 $flags, 1358 $page 1359 ); 1360 } 1361 1362 /** 1363 * @param int $revId The revision to load slots for. 1364 * @param int $queryFlags 1365 * @param PageIdentity $page 1366 * 1367 * @return SlotRecord[] 1368 */ 1369 private function loadSlotRecords( $revId, $queryFlags, PageIdentity $page ) { 1370 $revQuery = $this->getSlotsQueryInfo( [ 'content' ] ); 1371 1372 list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags ); 1373 $db = $this->getDBConnectionRef( $dbMode ); 1374 1375 $res = $db->select( 1376 $revQuery['tables'], 1377 $revQuery['fields'], 1378 [ 1379 'slot_revision_id' => $revId, 1380 ], 1381 __METHOD__, 1382 $dbOptions, 1383 $revQuery['joins'] 1384 ); 1385 1386 if ( !$res->numRows() && !( $queryFlags & self::READ_LATEST ) ) { 1387 // If we found no slots, try looking on the primary database (T212428, T252156) 1388 $this->logger->info( 1389 __METHOD__ . ' falling back to READ_LATEST.', 1390 [ 1391 'revid' => $revId, 1392 'trace' => wfBacktrace( true ) 1393 ] 1394 ); 1395 return $this->loadSlotRecords( 1396 $revId, 1397 $queryFlags | self::READ_LATEST, 1398 $page 1399 ); 1400 } 1401 1402 return $this->constructSlotRecords( $revId, $res, $queryFlags, $page ); 1403 } 1404 1405 /** 1406 * Factory method for SlotRecords based on known slot rows. 1407 * 1408 * @param int $revId The revision to load slots for. 1409 * @param \stdClass[]|IResultWrapper $slotRows 1410 * @param int $queryFlags 1411 * @param PageIdentity $page 1412 * @param array|null $slotContents a map from blobAddress to slot 1413 * content blob or Content object. 1414 * 1415 * @return SlotRecord[] 1416 */ 1417 private function constructSlotRecords( 1418 $revId, 1419 $slotRows, 1420 $queryFlags, 1421 PageIdentity $page, 1422 $slotContents = null 1423 ) { 1424 $slots = []; 1425 1426 foreach ( $slotRows as $row ) { 1427 // Resolve role names and model names from in-memory cache, if they were not joined in. 1428 if ( !isset( $row->role_name ) ) { 1429 $row->role_name = $this->slotRoleStore->getName( (int)$row->slot_role_id ); 1430 } 1431 1432 if ( !isset( $row->model_name ) ) { 1433 if ( isset( $row->content_model ) ) { 1434 $row->model_name = $this->contentModelStore->getName( (int)$row->content_model ); 1435 } else { 1436 // We may get here if $row->model_name is set but null, perhaps because it 1437 // came from rev_content_model, which is NULL for the default model. 1438 $slotRoleHandler = $this->slotRoleRegistry->getRoleHandler( $row->role_name ); 1439 $row->model_name = $slotRoleHandler->getDefaultModel( $page ); 1440 } 1441 } 1442 1443 // We may have a fake blob_data field from getSlotRowsForBatch(), use it! 1444 if ( isset( $row->blob_data ) ) { 1445 $slotContents[$row->content_address] = $row->blob_data; 1446 } 1447 1448 $contentCallback = function ( SlotRecord $slot ) use ( $slotContents, $queryFlags ) { 1449 $blob = null; 1450 if ( isset( $slotContents[$slot->getAddress()] ) ) { 1451 $blob = $slotContents[$slot->getAddress()]; 1452 if ( $blob instanceof Content ) { 1453 return $blob; 1454 } 1455 } 1456 return $this->loadSlotContent( $slot, $blob, null, null, $queryFlags ); 1457 }; 1458 1459 $slots[$row->role_name] = new SlotRecord( $row, $contentCallback ); 1460 } 1461 1462 if ( !isset( $slots[SlotRecord::MAIN] ) ) { 1463 $this->logger->error( 1464 __METHOD__ . ': Main slot of revision not found in database. See T212428.', 1465 [ 1466 'revid' => $revId, 1467 'queryFlags' => $queryFlags, 1468 'trace' => wfBacktrace( true ) 1469 ] 1470 ); 1471 1472 throw new RevisionAccessException( 1473 'Main slot of revision not found in database. See T212428.' 1474 ); 1475 } 1476 1477 return $slots; 1478 } 1479 1480 /** 1481 * Factory method for RevisionSlots based on a revision ID. 1482 * 1483 * @note If other code has a need to construct RevisionSlots objects, this should be made 1484 * public, since RevisionSlots instances should not be constructed directly. 1485 * 1486 * @param int $revId 1487 * @param \stdClass $revisionRow 1488 * @param \stdClass[]|null $slotRows 1489 * @param int $queryFlags 1490 * @param PageIdentity $page 1491 * 1492 * @return RevisionSlots 1493 * @throws MWException 1494 */ 1495 private function newRevisionSlots( 1496 $revId, 1497 $revisionRow, 1498 $slotRows, 1499 $queryFlags, 1500 PageIdentity $page 1501 ) { 1502 if ( $slotRows ) { 1503 $slots = new RevisionSlots( 1504 $this->constructSlotRecords( $revId, $slotRows, $queryFlags, $page ) 1505 ); 1506 } else { 1507 // XXX: do we need the same kind of caching here 1508 // that getKnownCurrentRevision uses (if $revId == page_latest?) 1509 1510 $slots = new RevisionSlots( function () use( $revId, $queryFlags, $page ) { 1511 return $this->loadSlotRecords( $revId, $queryFlags, $page ); 1512 } ); 1513 } 1514 1515 return $slots; 1516 } 1517 1518 /** 1519 * Make a fake RevisionRecord object from an archive table row. This is queried 1520 * for permissions or even inserted (as in Special:Undelete) 1521 * 1522 * The user ID and user name may optionally be supplied using the aliases 1523 * ar_user and ar_user_text (the names of fields which existed before 1524 * MW 1.34). 1525 * 1526 * MCR migration note: this replaced Revision::newFromArchiveRow 1527 * 1528 * @param \stdClass $row 1529 * @param int $queryFlags 1530 * @param PageIdentity|null $page 1531 * @param array $overrides associative array with fields of $row to override. This may be 1532 * used e.g. to force the parent revision ID or page ID. Keys in the array are fields 1533 * names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to 1534 * override ar_parent_id. 1535 * 1536 * @return RevisionRecord 1537 * @throws MWException 1538 */ 1539 public function newRevisionFromArchiveRow( 1540 $row, 1541 $queryFlags = 0, 1542 PageIdentity $page = null, 1543 array $overrides = [] 1544 ) { 1545 return $this->newRevisionFromArchiveRowAndSlots( $row, null, $queryFlags, $page, $overrides ); 1546 } 1547 1548 /** 1549 * @see RevisionFactory::newRevisionFromRow 1550 * 1551 * MCR migration note: this replaced Revision::newFromRow 1552 * 1553 * @param \stdClass $row A database row generated from a query based on getQueryInfo() 1554 * @param int $queryFlags 1555 * @param PageIdentity|null $page Preloaded page object 1556 * @param bool $fromCache if true, the returned RevisionRecord will ensure that no stale 1557 * data is returned from getters, by querying the database as needed 1558 * @return RevisionRecord 1559 */ 1560 public function newRevisionFromRow( 1561 $row, 1562 $queryFlags = 0, 1563 PageIdentity $page = null, 1564 $fromCache = false 1565 ) { 1566 return $this->newRevisionFromRowAndSlots( $row, null, $queryFlags, $page, $fromCache ); 1567 } 1568 1569 /** 1570 * @see newRevisionFromArchiveRow() 1571 * @since 1.35 1572 * 1573 * @param stdClass $row 1574 * @param null|stdClass[]|RevisionSlots $slots 1575 * - Database rows generated from a query based on getSlotsQueryInfo 1576 * with the 'content' flag set. Or 1577 * - RevisionSlots instance 1578 * @param int $queryFlags 1579 * @param PageIdentity|null $page 1580 * @param array $overrides associative array with fields of $row to override. This may be 1581 * used e.g. to force the parent revision ID or page ID. Keys in the array are fields 1582 * names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to 1583 * override ar_parent_id. 1584 * 1585 * @return RevisionRecord 1586 * @throws MWException 1587 */ 1588 public function newRevisionFromArchiveRowAndSlots( 1589 stdClass $row, 1590 $slots, 1591 int $queryFlags = 0, 1592 ?PageIdentity $page = null, 1593 array $overrides = [] 1594 ) { 1595 if ( !$page && isset( $overrides['title'] ) ) { 1596 if ( !( $overrides['title'] instanceof PageIdentity ) ) { 1597 throw new MWException( 'title field override must contain a PageIdentity object.' ); 1598 } 1599 1600 $page = $overrides['title']; 1601 } 1602 1603 if ( !isset( $page ) ) { 1604 if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) { 1605 $page = Title::makeTitle( $row->ar_namespace, $row->ar_title ); 1606 } else { 1607 throw new InvalidArgumentException( 1608 'A Title or ar_namespace and ar_title must be given' 1609 ); 1610 } 1611 } 1612 1613 foreach ( $overrides as $key => $value ) { 1614 $field = "ar_$key"; 1615 $row->$field = $value; 1616 } 1617 1618 try { 1619 $user = $this->actorStore->newActorFromRowFields( 1620 $row->ar_user ?? null, 1621 $row->ar_user_text ?? null, 1622 $row->ar_actor ?? null 1623 ); 1624 } catch ( InvalidArgumentException $ex ) { 1625 $this->logger->warning( 'Could not load user for archive revision {rev_id}', [ 1626 'ar_rev_id' => $row->ar_rev_id, 1627 'ar_actor' => $row->ar_actor ?? 'null', 1628 'ar_user_text' => $row->ar_user_text ?? 'null', 1629 'ar_user' => $row->ar_user ?? 'null', 1630 'exception' => $ex 1631 ] ); 1632 $user = $this->actorStore->getUnknownActor(); 1633 } 1634 1635 $db = $this->getDBConnectionRefForQueryFlags( $queryFlags ); 1636 // Legacy because $row may have come from self::selectFields() 1637 $comment = $this->commentStore->getCommentLegacy( $db, 'ar_comment', $row, true ); 1638 1639 if ( !( $slots instanceof RevisionSlots ) ) { 1640 $slots = $this->newRevisionSlots( $row->ar_rev_id, $row, $slots, $queryFlags, $page ); 1641 } 1642 return new RevisionArchiveRecord( $page, $user, $comment, $row, $slots, $this->wikiId ); 1643 } 1644 1645 /** 1646 * @see newFromRevisionRow() 1647 * 1648 * @param stdClass $row A database row generated from a query based on getQueryInfo() 1649 * @param null|stdClass[]|RevisionSlots $slots 1650 * - Database rows generated from a query based on getSlotsQueryInfo 1651 * with the 'content' flag set. Or 1652 * - RevisionSlots instance 1653 * @param int $queryFlags 1654 * @param PageIdentity|null $page 1655 * @param bool $fromCache if true, the returned RevisionRecord will ensure that no stale 1656 * data is returned from getters, by querying the database as needed 1657 * 1658 * @return RevisionRecord 1659 * @throws MWException 1660 * @throws RevisionAccessException 1661 * @see RevisionFactory::newRevisionFromRow 1662 */ 1663 public function newRevisionFromRowAndSlots( 1664 stdClass $row, 1665 $slots, 1666 int $queryFlags = 0, 1667 ?PageIdentity $page = null, 1668 bool $fromCache = false 1669 ) { 1670 if ( !$page ) { 1671 if ( isset( $row->page_id ) 1672 && isset( $row->page_namespace ) 1673 && isset( $row->page_title ) 1674 ) { 1675 $page = new PageIdentityValue( 1676 (int)$row->page_id, 1677 (int)$row->page_namespace, 1678 $row->page_title, 1679 $this->wikiId 1680 ); 1681 1682 $page = $this->wrapPage( $page ); 1683 } else { 1684 $pageId = (int)( $row->rev_page ?? 0 ); 1685 $revId = (int)( $row->rev_id ?? 0 ); 1686 1687 $page = $this->getPage( $pageId, $revId, $queryFlags ); 1688 } 1689 } else { 1690 $page = $this->ensureRevisionRowMatchesPage( $row, $page ); 1691 } 1692 1693 if ( !$page ) { 1694 // This should already have been caught about, but apparently 1695 // it not always is, see T286877. 1696 throw new RevisionAccessException( 1697 "Failed to determine page associated with revision {$row->rev_id}" 1698 ); 1699 } 1700 1701 try { 1702 $user = $this->actorStore->newActorFromRowFields( 1703 $row->rev_user ?? null, 1704 $row->rev_user_text ?? null, 1705 $row->rev_actor ?? null 1706 ); 1707 } catch ( InvalidArgumentException $ex ) { 1708 $this->logger->warning( 'Could not load user for revision {rev_id}', [ 1709 'rev_id' => $row->rev_id, 1710 'rev_actor' => $row->rev_actor ?? 'null', 1711 'rev_user_text' => $row->rev_user_text ?? 'null', 1712 'rev_user' => $row->rev_user ?? 'null', 1713 'exception' => $ex 1714 ] ); 1715 $user = $this->actorStore->getUnknownActor(); 1716 } 1717 1718 $db = $this->getDBConnectionRefForQueryFlags( $queryFlags ); 1719 // Legacy because $row may have come from self::selectFields() 1720 $comment = $this->commentStore->getCommentLegacy( $db, 'rev_comment', $row, true ); 1721 1722 if ( !( $slots instanceof RevisionSlots ) ) { 1723 $slots = $this->newRevisionSlots( $row->rev_id, $row, $slots, $queryFlags, $page ); 1724 } 1725 1726 // If this is a cached row, instantiate a cache-aware RevisionRecord to avoid stale data. 1727 if ( $fromCache ) { 1728 $rev = new RevisionStoreCacheRecord( 1729 function ( $revId ) use ( $queryFlags ) { 1730 $db = $this->getDBConnectionRefForQueryFlags( $queryFlags ); 1731 $row = $this->fetchRevisionRowFromConds( 1732 $db, 1733 [ 'rev_id' => intval( $revId ) ] 1734 ); 1735 if ( !$row && !( $queryFlags & self::READ_LATEST ) ) { 1736 // If we found no slots, try looking on the primary database (T259738) 1737 $this->logger->info( 1738 'RevisionStoreCacheRecord refresh callback falling back to READ_LATEST.', 1739 [ 1740 'revid' => $revId, 1741 'trace' => wfBacktrace( true ) 1742 ] 1743 ); 1744 $dbw = $this->getDBConnectionRefForQueryFlags( self::READ_LATEST ); 1745 $row = $this->fetchRevisionRowFromConds( 1746 $dbw, 1747 [ 'rev_id' => intval( $revId ) ] 1748 ); 1749 } 1750 if ( !$row ) { 1751 return [ null, null ]; 1752 } 1753 return [ 1754 $row->rev_deleted, 1755 $this->actorStore->newActorFromRowFields( 1756 $row->rev_user ?? null, 1757 $row->rev_user_text ?? null, 1758 $row->rev_actor ?? null 1759 ) 1760 ]; 1761 }, 1762 $page, $user, $comment, $row, $slots, $this->wikiId 1763 ); 1764 } else { 1765 $rev = new RevisionStoreRecord( 1766 $page, $user, $comment, $row, $slots, $this->wikiId ); 1767 } 1768 return $rev; 1769 } 1770 1771 /** 1772 * Check that the given row matches the given Title object. 1773 * When a mismatch is detected, this tries to re-load the title from primary DB, 1774 * to avoid spurious errors during page moves. 1775 * 1776 * @param \stdClass $row 1777 * @param PageIdentity $page 1778 * @param array $context 1779 * 1780 * @return Pageidentity 1781 */ 1782 private function ensureRevisionRowMatchesPage( $row, PageIdentity $page, $context = [] ) { 1783 $revId = (int)( $row->rev_id ?? 0 ); 1784 $revPageId = (int)( $row->rev_page ?? 0 ); // XXX: also check $row->page_id? 1785 $expectedPageId = $page->getId( $this->wikiId ); 1786 // Avoid fatal error when the Title's ID changed, T246720 1787 if ( $revPageId && $expectedPageId && $revPageId !== $expectedPageId ) { 1788 // NOTE: PageStore::getPageByReference may use the page ID, which we don't want here. 1789 $pageRec = $this->pageStore->getPageByName( 1790 $page->getNamespace(), 1791 $page->getDBkey(), 1792 PageStore::READ_LATEST 1793 ); 1794 $masterPageId = $pageRec->getId( $this->wikiId ); 1795 $masterLatest = $pageRec->getLatest( $this->wikiId ); 1796 if ( $revPageId === $masterPageId ) { 1797 if ( $page instanceof Title ) { 1798 // If we were using a Title object, keep using it, but update the page ID. 1799 // This way, we don't unexpectedly mix Titles with immutable value objects. 1800 $page->resetArticleID( $masterPageId ); 1801 1802 } else { 1803 $page = $pageRec; 1804 } 1805 1806 $this->logger->info( 1807 "Encountered stale Title object", 1808 [ 1809 'page_id_stale' => $expectedPageId, 1810 'page_id_reloaded' => $masterPageId, 1811 'page_latest' => $masterLatest, 1812 'rev_id' => $revId, 1813 'trace' => wfBacktrace() 1814 ] + $context 1815 ); 1816 } else { 1817 $expectedTitle = (string)$page; 1818 if ( $page instanceof Title ) { 1819 // If we started with a Title, keep using a Title. 1820 $page = $this->titleFactory->newFromID( $revPageId ); 1821 } else { 1822 $page = $pageRec; 1823 } 1824 1825 // This could happen if a caller to e.g. getRevisionById supplied a Title that is 1826 // plain wrong. In this case, we should ideally throw an IllegalArgumentException. 1827 // However, it is more likely that we encountered a race condition during a page 1828 // move (T268910, T279832) or database corruption (T263340). That situation 1829 // should not be ignored, but we can allow the request to continue in a reasonable 1830 // manner without breaking things for the user. 1831 $this->logger->error( 1832 "Encountered mismatching Title object (see T259022, T268910, T279832, T263340)", 1833 [ 1834 'expected_page_id' => $masterPageId, 1835 'expected_page_title' => $expectedTitle, 1836 'rev_page' => $revPageId, 1837 'rev_page_title' => (string)$page, 1838 'page_latest' => $masterLatest, 1839 'rev_id' => $revId, 1840 'trace' => wfBacktrace() 1841 ] + $context 1842 ); 1843 } 1844 } 1845 1846 return $page; 1847 } 1848 1849 /** 1850 * Construct a RevisionRecord instance for each row in $rows, 1851 * and return them as an associative array indexed by revision ID. 1852 * Use getQueryInfo() or getArchiveQueryInfo() to construct the 1853 * query that produces the rows. 1854 * 1855 * @param IResultWrapper|\stdClass[] $rows the rows to construct revision records from 1856 * @param array $options Supports the following options: 1857 * 'slots' - whether metadata about revision slots should be 1858 * loaded immediately. Supports falsy or truthy value as well 1859 * as an explicit list of slot role names. The main slot will 1860 * always be loaded. 1861 * 'content' - whether the actual content of the slots should be 1862 * preloaded. 1863 * 'archive' - whether the rows where generated using getArchiveQueryInfo(), 1864 * rather than getQueryInfo. 1865 * @param int $queryFlags 1866 * @param PageIdentity|null $page The page to which all the revision rows belong, if there 1867 * is such a page and the caller has it handy, so we don't have to look it up again. 1868 * If this parameter is given and any of the rows has a rev_page_id that is different 1869 * from Article Id associated with the page, an InvalidArgumentException is thrown. 1870 * 1871 * @return StatusValue a status with a RevisionRecord[] of successfully fetched revisions 1872 * and an array of errors for the revisions failed to fetch. 1873 */ 1874 public function newRevisionsFromBatch( 1875 $rows, 1876 array $options = [], 1877 $queryFlags = 0, 1878 PageIdentity $page = null 1879 ) { 1880 $result = new StatusValue(); 1881 $archiveMode = $options['archive'] ?? false; 1882 1883 if ( $archiveMode ) { 1884 $revIdField = 'ar_rev_id'; 1885 } else { 1886 $revIdField = 'rev_id'; 1887 } 1888 1889 $rowsByRevId = []; 1890 $pageIdsToFetchTitles = []; 1891 $titlesByPageKey = []; 1892 foreach ( $rows as $row ) { 1893 if ( isset( $rowsByRevId[$row->$revIdField] ) ) { 1894 $result->warning( 1895 'internalerror_info', 1896 "Duplicate rows in newRevisionsFromBatch, $revIdField {$row->$revIdField}" 1897 ); 1898 } 1899 1900 // Attach a page key to the row, so we can find and reuse Title objects easily. 1901 $row->_page_key = 1902 $archiveMode ? $row->ar_namespace . ':' . $row->ar_title : $row->rev_page; 1903 1904 if ( $page ) { 1905 if ( !$archiveMode && $row->rev_page != $this->getArticleId( $page ) ) { 1906 throw new InvalidArgumentException( 1907 "Revision {$row->$revIdField} doesn't belong to page " 1908 . $this->getArticleId( $page ) 1909 ); 1910 } 1911 1912 if ( $archiveMode 1913 && ( $row->ar_namespace != $page->getNamespace() 1914 || $row->ar_title !== $page->getDBkey() ) 1915 ) { 1916 throw new InvalidArgumentException( 1917 "Revision {$row->$revIdField} doesn't belong to page " 1918 . $page 1919 ); 1920 } 1921 } elseif ( !isset( $titlesByPageKey[ $row->_page_key ] ) ) { 1922 if ( isset( $row->page_namespace ) && isset( $row->page_title ) 1923 // This should always be true, but just in case we don't have a page_id 1924 // set or it doesn't match rev_page, let's fetch the title again. 1925 && isset( $row->page_id ) && isset( $row->rev_page ) 1926 && $row->rev_page === $row->page_id 1927 ) { 1928 $titlesByPageKey[ $row->_page_key ] = Title::newFromRow( $row ); 1929 } elseif ( $archiveMode ) { 1930 // Can't look up deleted pages by ID, but we have namespace and title 1931 $titlesByPageKey[ $row->_page_key ] = 1932 Title::makeTitle( $row->ar_namespace, $row->ar_title ); 1933 } else { 1934 $pageIdsToFetchTitles[] = $row->rev_page; 1935 } 1936 } 1937 $rowsByRevId[$row->$revIdField] = $row; 1938 } 1939 1940 if ( empty( $rowsByRevId ) ) { 1941 $result->setResult( true, [] ); 1942 return $result; 1943 } 1944 1945 // If the page is not supplied, batch-fetch Title objects. 1946 if ( $page ) { 1947 // same logic as for $row->_page_key above 1948 $pageKey = $archiveMode 1949 ? $page->getNamespace() . ':' . $page->getDBkey() 1950 : $this->getArticleId( $page ); 1951 1952 $titlesByPageKey[$pageKey] = $page; 1953 } elseif ( !empty( $pageIdsToFetchTitles ) ) { 1954 // Note: when we fetch titles by ID, the page key is also the ID. 1955 // We should never get here if $archiveMode is true. 1956 Assert::invariant( !$archiveMode, 'Titles are not loaded by ID in archive mode.' ); 1957 1958 $pageIdsToFetchTitles = array_unique( $pageIdsToFetchTitles ); 1959 foreach ( Title::newFromIDs( $pageIdsToFetchTitles ) as $t ) { 1960 $titlesByPageKey[$t->getArticleID()] = $t; 1961 } 1962 } 1963 1964 // which method to use for creating RevisionRecords 1965 $newRevisionRecord = [ 1966 $this, 1967 $archiveMode ? 'newRevisionFromArchiveRowAndSlots' : 'newRevisionFromRowAndSlots' 1968 ]; 1969 1970 if ( !isset( $options['slots'] ) ) { 1971 $result->setResult( 1972 true, 1973 array_map( 1974 static function ( $row ) 1975 use ( $queryFlags, $titlesByPageKey, $result, $newRevisionRecord, $revIdField ) { 1976 try { 1977 if ( !isset( $titlesByPageKey[$row->_page_key] ) ) { 1978 $result->warning( 1979 'internalerror_info', 1980 "Couldn't find title for rev {$row->$revIdField} " 1981 . "(page key {$row->_page_key})" 1982 ); 1983 return null; 1984 } 1985 return $newRevisionRecord( $row, null, $queryFlags, 1986 $titlesByPageKey[ $row->_page_key ] ); 1987 } catch ( MWException $e ) { 1988 $result->warning( 'internalerror_info', $e->getMessage() ); 1989 return null; 1990 } 1991 }, 1992 $rowsByRevId 1993 ) 1994 ); 1995 return $result; 1996 } 1997 1998 $slotRowOptions = [ 1999 'slots' => $options['slots'] ?? true, 2000 'blobs' => $options['content'] ?? false, 2001 ]; 2002 2003 if ( is_array( $slotRowOptions['slots'] ) 2004 && !in_array( SlotRecord::MAIN, $slotRowOptions['slots'] ) 2005 ) { 2006 // Make sure the main slot is always loaded, RevisionRecord requires this. 2007 $slotRowOptions['slots'][] = SlotRecord::MAIN; 2008 } 2009 2010 $slotRowsStatus = $this->getSlotRowsForBatch( $rowsByRevId, $slotRowOptions, $queryFlags ); 2011 2012 $result->merge( $slotRowsStatus ); 2013 $slotRowsByRevId = $slotRowsStatus->getValue(); 2014 2015 $result->setResult( 2016 true, 2017 array_map( 2018 function ( $row ) 2019 use ( $slotRowsByRevId, $queryFlags, $titlesByPageKey, $result, 2020 $revIdField, $newRevisionRecord 2021 ) { 2022 if ( !isset( $slotRowsByRevId[$row->$revIdField] ) ) { 2023 $result->warning( 2024 'internalerror_info', 2025 "Couldn't find slots for rev {$row->$revIdField}" 2026 ); 2027 return null; 2028 } 2029 if ( !isset( $titlesByPageKey[$row->_page_key] ) ) { 2030 $result->warning( 2031 'internalerror_info', 2032 "Couldn't find title for rev {$row->$revIdField} " 2033 . "(page key {$row->_page_key})" 2034 ); 2035 return null; 2036 } 2037 try { 2038 return $newRevisionRecord( 2039 $row, 2040 new RevisionSlots( 2041 $this->constructSlotRecords( 2042 $row->$revIdField, 2043 $slotRowsByRevId[$row->$revIdField], 2044 $queryFlags, 2045 $titlesByPageKey[$row->_page_key] 2046 ) 2047 ), 2048 $queryFlags, 2049 $titlesByPageKey[$row->_page_key] 2050 ); 2051 } catch ( MWException $e ) { 2052 $result->warning( 'internalerror_info', $e->getMessage() ); 2053 return null; 2054 } 2055 }, 2056 $rowsByRevId 2057 ) 2058 ); 2059 return $result; 2060 } 2061 2062 /** 2063 * Gets the slot rows associated with a batch of revisions. 2064 * The serialized content of each slot can be included by setting the 'blobs' option. 2065 * Callers are responsible for unserializing and interpreting the content blobs 2066 * based on the model_name and role_name fields. 2067 * 2068 * @param Traversable|array $rowsOrIds list of revision ids, or revision or archive rows 2069 * from a db query. 2070 * @param array $options Supports the following options: 2071 * 'slots' - a list of slot role names to fetch. If omitted or true or null, 2072 * all slots are fetched 2073 * 'blobs' - whether the serialized content of each slot should be loaded. 2074 * If true, the serialiezd content will be present in the slot row 2075 * in the blob_data field. 2076 * @param int $queryFlags 2077 * 2078 * @return StatusValue a status containing, if isOK() returns true, a two-level nested 2079 * associative array, mapping from revision ID to an associative array that maps from 2080 * role name to a database row object. The database row object will contain the fields 2081 * defined by getSlotQueryInfo() with the 'content' flag set, plus the blob_data field 2082 * if the 'blobs' is set in $options. The model_name and role_name fields will also be 2083 * set. 2084 */ 2085 private function getSlotRowsForBatch( 2086 $rowsOrIds, 2087 array $options = [], 2088 $queryFlags = 0 2089 ) { 2090 $result = new StatusValue(); 2091 2092 $revIds = []; 2093 foreach ( $rowsOrIds as $row ) { 2094 if ( is_object( $row ) ) { 2095 $revIds[] = isset( $row->ar_rev_id ) ? (int)$row->ar_rev_id : (int)$row->rev_id; 2096 } else { 2097 $revIds[] = (int)$row; 2098 } 2099 } 2100 2101 // Nothing to do. 2102 // Note that $rowsOrIds may not be "empty" even if $revIds is, e.g. if it's a ResultWrapper. 2103 if ( empty( $revIds ) ) { 2104 $result->setResult( true, [] ); 2105 return $result; 2106 } 2107 2108 // We need to set the `content` flag to join in content meta-data 2109 $slotQueryInfo = $this->getSlotsQueryInfo( [ 'content' ] ); 2110 $revIdField = $slotQueryInfo['keys']['rev_id']; 2111 $slotQueryConds = [ $revIdField => $revIds ]; 2112 2113 if ( isset( $options['slots'] ) && is_array( $options['slots'] ) ) { 2114 if ( empty( $options['slots'] ) ) { 2115 // Degenerate case: return no slots for each revision. 2116 $result->setResult( true, array_fill_keys( $revIds, [] ) ); 2117 return $result; 2118 } 2119 2120 $roleIdField = $slotQueryInfo['keys']['role_id']; 2121 $slotQueryConds[$roleIdField] = array_map( function ( $slot_name ) { 2122 return $this->slotRoleStore->getId( $slot_name ); 2123 }, $options['slots'] ); 2124 } 2125 2126 $db = $this->getDBConnectionRefForQueryFlags( $queryFlags ); 2127 $slotRows = $db->select( 2128 $slotQueryInfo['tables'], 2129 $slotQueryInfo['fields'], 2130 $slotQueryConds, 2131 __METHOD__, 2132 [], 2133 $slotQueryInfo['joins'] 2134 ); 2135 2136 $slotContents = null; 2137 if ( $options['blobs'] ?? false ) { 2138 $blobAddresses = []; 2139 foreach ( $slotRows as $slotRow ) { 2140 $blobAddresses[] = $slotRow->content_address; 2141 } 2142 $slotContentFetchStatus = $this->blobStore 2143 ->getBlobBatch( $blobAddresses, $queryFlags ); 2144 foreach ( $slotContentFetchStatus->getErrors() as $error ) { 2145 $result->warning( $error['message'], ...$error['params'] ); 2146 } 2147 $slotContents = $slotContentFetchStatus->getValue(); 2148 } 2149 2150 $slotRowsByRevId = []; 2151 foreach ( $slotRows as $slotRow ) { 2152 if ( $slotContents === null ) { 2153 // nothing to do 2154 } elseif ( isset( $slotContents[$slotRow->content_address] ) ) { 2155 $slotRow->blob_data = $slotContents[$slotRow->content_address]; 2156 } else { 2157 $result->warning( 2158 'internalerror_info', 2159 "Couldn't find blob data for rev {$slotRow->slot_revision_id}" 2160 ); 2161 $slotRow->blob_data = null; 2162 } 2163 2164 // conditional needed for SCHEMA_COMPAT_READ_OLD 2165 if ( !isset( $slotRow->role_name ) && isset( $slotRow->slot_role_id ) ) { 2166 $slotRow->role_name = $this->slotRoleStore->getName( (int)$slotRow->slot_role_id ); 2167 } 2168 2169 // conditional needed for SCHEMA_COMPAT_READ_OLD 2170 if ( !isset( $slotRow->model_name ) && isset( $slotRow->content_model ) ) { 2171 $slotRow->model_name = $this->contentModelStore->getName( (int)$slotRow->content_model ); 2172 } 2173 2174 $slotRowsByRevId[$slotRow->slot_revision_id][$slotRow->role_name] = $slotRow; 2175 } 2176 2177 $result->setResult( true, $slotRowsByRevId ); 2178 return $result; 2179 } 2180 2181 /** 2182 * Gets raw (serialized) content blobs for the given set of revisions. 2183 * Callers are responsible for unserializing and interpreting the content blobs 2184 * based on the model_name field and the slot role. 2185 * 2186 * This method is intended for bulk operations in maintenance scripts. 2187 * It may be chosen over newRevisionsFromBatch by code that are only interested 2188 * in raw content, as opposed to meta data. Code that needs to access meta data of revisions, 2189 * slots, or content objects should use newRevisionsFromBatch() instead. 2190 * 2191 * @param Traversable|array $rowsOrIds list of revision ids, or revision rows from a db query. 2192 * @param array|null $slots the role names for which to get slots. 2193 * @param int $queryFlags 2194 * 2195 * @return StatusValue a status containing, if isOK() returns true, a two-level nested 2196 * associative array, mapping from revision ID to an associative array that maps from 2197 * role name to an anonymous object containing two fields: 2198 * - model_name: the name of the content's model 2199 * - blob_data: serialized content data 2200 */ 2201 public function getContentBlobsForBatch( 2202 $rowsOrIds, 2203 $slots = null, 2204 $queryFlags = 0 2205 ) { 2206 $result = $this->getSlotRowsForBatch( 2207 $rowsOrIds, 2208 [ 'slots' => $slots, 'blobs' => true ], 2209 $queryFlags 2210 ); 2211 2212 if ( $result->isOK() ) { 2213 // strip out all internal meta data that we don't want to expose 2214 foreach ( $result->value as $revId => $rowsByRole ) { 2215 foreach ( $rowsByRole as $role => $slotRow ) { 2216 if ( is_array( $slots ) && !in_array( $role, $slots ) ) { 2217 // In SCHEMA_COMPAT_READ_OLD mode we may get the main slot even 2218 // if we didn't ask for it. 2219 unset( $result->value[$revId][$role] ); 2220 continue; 2221 } 2222 2223 $result->value[$revId][$role] = (object)[ 2224 'blob_data' => $slotRow->blob_data, 2225 'model_name' => $slotRow->model_name, 2226 ]; 2227 } 2228 } 2229 } 2230 2231 return $result; 2232 } 2233 2234 /** 2235 * Given a set of conditions, fetch a revision 2236 * 2237 * This method should be used if we are pretty sure the revision exists. 2238 * Unless $flags has READ_LATEST set, this method will first try to find the revision 2239 * on a replica before hitting the primary database. 2240 * 2241 * MCR migration note: this corresponded to Revision::newFromConds 2242 * 2243 * @param array $conditions 2244 * @param int $flags (optional) 2245 * @param PageIdentity|null $page (optional) 2246 * @param array $options (optional) additional query options 2247 * 2248 * @return RevisionRecord|null 2249 */ 2250 private function newRevisionFromConds( 2251 array $conditions, 2252 int $flags = IDBAccessObject::READ_NORMAL, 2253 PageIdentity $page = null, 2254 array $options = [] 2255 ) { 2256 $db = $this->getDBConnectionRefForQueryFlags( $flags ); 2257 $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $page, $options ); 2258 2259 $lb = $this->getDBLoadBalancer(); 2260 2261 // Make sure new pending/committed revision are visibile later on 2262 // within web requests to certain avoid bugs like T93866 and T94407. 2263 if ( !$rev 2264 && !( $flags & self::READ_LATEST ) 2265 && $lb->hasStreamingReplicaServers() 2266 && $lb->hasOrMadeRecentPrimaryChanges() 2267 ) { 2268 $flags = self::READ_LATEST; 2269 $dbw = $this->getDBConnectionRef( DB_PRIMARY ); 2270 $rev = $this->loadRevisionFromConds( $dbw, $conditions, $flags, $page, $options ); 2271 } 2272 2273 return $rev; 2274 } 2275 2276 /** 2277 * Given a set of conditions, fetch a revision from 2278 * the given database connection. 2279 * 2280 * MCR migration note: this corresponded to Revision::loadFromConds 2281 * 2282 * @param IDatabase $db 2283 * @param array $conditions 2284 * @param int $flags (optional) 2285 * @param PageIdentity|null $page (optional) additional query options 2286 * @param array $options (optional) additional query options 2287 * 2288 * @return RevisionRecord|null 2289 */ 2290 private function loadRevisionFromConds( 2291 IDatabase $db, 2292 array $conditions, 2293 int $flags = IDBAccessObject::READ_NORMAL, 2294 PageIdentity $page = null, 2295 array $options = [] 2296 ) { 2297 $row = $this->fetchRevisionRowFromConds( $db, $conditions, $flags, $options ); 2298 if ( $row ) { 2299 return $this->newRevisionFromRow( $row, $flags, $page ); 2300 } 2301 2302 return null; 2303 } 2304 2305 /** 2306 * Throws an exception if the given database connection does not belong to the wiki this 2307 * RevisionStore is bound to. 2308 * 2309 * @param IDatabase $db 2310 * @throws MWException 2311 */ 2312 private function checkDatabaseDomain( IDatabase $db ) { 2313 $dbDomain = $db->getDomainID(); 2314 $storeDomain = $this->loadBalancer->resolveDomainID( $this->wikiId ); 2315 if ( $dbDomain === $storeDomain ) { 2316 return; 2317 } 2318 2319 throw new MWException( "DB connection domain '$dbDomain' does not match '$storeDomain'" ); 2320 } 2321 2322 /** 2323 * Given a set of conditions, return a row with the 2324 * fields necessary to build RevisionRecord objects. 2325 * 2326 * MCR migration note: this corresponded to Revision::fetchFromConds 2327 * 2328 * @param IDatabase $db 2329 * @param array $conditions 2330 * @param int $flags (optional) 2331 * @param array $options (optional) additional query options 2332 * 2333 * @return \stdClass|false data row as a raw object 2334 */ 2335 private function fetchRevisionRowFromConds( 2336 IDatabase $db, 2337 array $conditions, 2338 int $flags = IDBAccessObject::READ_NORMAL, 2339 array $options = [] 2340 ) { 2341 $this->checkDatabaseDomain( $db ); 2342 2343 $revQuery = $this->getQueryInfo( [ 'page', 'user' ] ); 2344 if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) { 2345 $options[] = 'FOR UPDATE'; 2346 } 2347 return $db->selectRow( 2348 $revQuery['tables'], 2349 $revQuery['fields'], 2350 $conditions, 2351 __METHOD__, 2352 $options, 2353 $revQuery['joins'] 2354 ); 2355 } 2356 2357 /** 2358 * Return the tables, fields, and join conditions to be selected to create 2359 * a new RevisionStoreRecord object. 2360 * 2361 * MCR migration note: this replaced Revision::getQueryInfo 2362 * 2363 * If the format of fields returned changes in any way then the cache key provided by 2364 * self::getRevisionRowCacheKey should be updated. 2365 * 2366 * @since 1.31 2367 * 2368 * @param array $options Any combination of the following strings 2369 * - 'page': Join with the page table, and select fields to identify the page 2370 * - 'user': Join with the user table, and select the user name 2371 * 2372 * @return array[] With three keys: 2373 * - tables: (string[]) to include in the `$table` to `IDatabase->select()` 2374 * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` 2375 * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` 2376 * @phan-return array{tables:string[],fields:string[],joins:array} 2377 */ 2378 public function getQueryInfo( $options = [] ) { 2379 $ret = [ 2380 'tables' => [], 2381 'fields' => [], 2382 'joins' => [], 2383 ]; 2384 2385 $ret['tables'][] = 'revision'; 2386 $ret['fields'] = array_merge( $ret['fields'], [ 2387 'rev_id', 2388 'rev_page', 2389 'rev_timestamp', 2390 'rev_minor_edit', 2391 'rev_deleted', 2392 'rev_len', 2393 'rev_parent_id', 2394 'rev_sha1', 2395 ] ); 2396 2397 $commentQuery = $this->commentStore->getJoin( 'rev_comment' ); 2398 $ret['tables'] = array_merge( $ret['tables'], $commentQuery['tables'] ); 2399 $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] ); 2400 $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] ); 2401 2402 $actorQuery = $this->actorMigration->getJoin( 'rev_user' ); 2403 $ret['tables'] = array_merge( $ret['tables'], $actorQuery['tables'] ); 2404 $ret['fields'] = array_merge( $ret['fields'], $actorQuery['fields'] ); 2405 $ret['joins'] = array_merge( $ret['joins'], $actorQuery['joins'] ); 2406 2407 if ( in_array( 'page', $options, true ) ) { 2408 $ret['tables'][] = 'page'; 2409 $ret['fields'] = array_merge( $ret['fields'], [ 2410 'page_namespace', 2411 'page_title', 2412 'page_id', 2413 'page_latest', 2414 'page_is_redirect', 2415 'page_len', 2416 ] ); 2417 $ret['joins']['page'] = [ 'JOIN', [ 'page_id = rev_page' ] ]; 2418 } 2419 2420 if ( in_array( 'user', $options, true ) ) { 2421 $ret['tables'][] = 'user'; 2422 $ret['fields'] = array_merge( $ret['fields'], [ 2423 'user_name', 2424 ] ); 2425 $u = $actorQuery['fields']['rev_user']; 2426 $ret['joins']['user'] = [ 'LEFT JOIN', [ "$u != 0", "user_id = $u" ] ]; 2427 } 2428 2429 if ( in_array( 'text', $options, true ) ) { 2430 throw new InvalidArgumentException( 2431 'The `text` option is no longer supported in MediaWiki 1.35 and later.' 2432 ); 2433 } 2434 2435 return $ret; 2436 } 2437 2438 /** 2439 * Return the tables, fields, and join conditions to be selected to create 2440 * a new SlotRecord. 2441 * 2442 * @since 1.32 2443 * 2444 * @param array $options Any combination of the following strings 2445 * - 'content': Join with the content table, and select content meta-data fields 2446 * - 'model': Join with the content_models table, and select the model_name field. 2447 * Only applicable if 'content' is also set. 2448 * - 'role': Join with the slot_roles table, and select the role_name field 2449 * 2450 * @return array With three keys: 2451 * - tables: (string[]) to include in the `$table` to `IDatabase->select()` 2452 * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` 2453 * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` 2454 * - keys: (associative array) to look up fields to match against. 2455 * In particular, the field that can be used to find slots by rev_id 2456 * can be found in ['keys']['rev_id']. 2457 */ 2458 public function getSlotsQueryInfo( $options = [] ) { 2459 $ret = [ 2460 'tables' => [], 2461 'fields' => [], 2462 'joins' => [], 2463 'keys' => [], 2464 ]; 2465 2466 $ret['keys']['rev_id'] = 'slot_revision_id'; 2467 $ret['keys']['role_id'] = 'slot_role_id'; 2468 2469 $ret['tables'][] = 'slots'; 2470 $ret['fields'] = array_merge( $ret['fields'], [ 2471 'slot_revision_id', 2472 'slot_content_id', 2473 'slot_origin', 2474 'slot_role_id', 2475 ] ); 2476 2477 if ( in_array( 'role', $options, true ) ) { 2478 // Use left join to attach role name, so we still find the revision row even 2479 // if the role name is missing. This triggers a more obvious failure mode. 2480 $ret['tables'][] = 'slot_roles'; 2481 $ret['joins']['slot_roles'] = [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ]; 2482 $ret['fields'][] = 'role_name'; 2483 } 2484 2485 if ( in_array( 'content', $options, true ) ) { 2486 $ret['keys']['model_id'] = 'content_model'; 2487 2488 $ret['tables'][] = 'content'; 2489 $ret['fields'] = array_merge( $ret['fields'], [ 2490 'content_size', 2491 'content_sha1', 2492 'content_address', 2493 'content_model', 2494 ] ); 2495 $ret['joins']['content'] = [ 'JOIN', [ 'slot_content_id = content_id' ] ]; 2496 2497 if ( in_array( 'model', $options, true ) ) { 2498 // Use left join to attach model name, so we still find the revision row even 2499 // if the model name is missing. This triggers a more obvious failure mode. 2500 $ret['tables'][] = 'content_models'; 2501 $ret['joins']['content_models'] = [ 'LEFT JOIN', [ 'content_model = model_id' ] ]; 2502 $ret['fields'][] = 'model_name'; 2503 } 2504 2505 } 2506 2507 return $ret; 2508 } 2509 2510 /** 2511 * Determine whether the parameter is a row containing all the fields 2512 * that RevisionStore needs to create a RevisionRecord from the row. 2513 * 2514 * @param mixed $row 2515 * @param string $table 'archive' or empty 2516 * @return bool 2517 */ 2518 public function isRevisionRow( $row, string $table = '' ) { 2519 if ( !( $row instanceof stdClass ) ) { 2520 return false; 2521 } 2522 $queryInfo = $table === 'archive' ? $this->getArchiveQueryInfo() : $this->getQueryInfo(); 2523 foreach ( $queryInfo['fields'] as $alias => $field ) { 2524 $name = is_numeric( $alias ) ? $field : $alias; 2525 if ( !property_exists( $row, $name ) ) { 2526 return false; 2527 } 2528 } 2529 return true; 2530 } 2531 2532 /** 2533 * Return the tables, fields, and join conditions to be selected to create 2534 * a new RevisionArchiveRecord object. 2535 * 2536 * Since 1.34, ar_user and ar_user_text have not been present in the 2537 * database, but they continue to be available in query results as 2538 * aliases. 2539 * 2540 * MCR migration note: this replaced Revision::getArchiveQueryInfo 2541 * 2542 * @since 1.31 2543 * 2544 * @return array With three keys: 2545 * - tables: (string[]) to include in the `$table` to `IDatabase->select()` 2546 * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` 2547 * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` 2548 */ 2549 public function getArchiveQueryInfo() { 2550 $commentQuery = $this->commentStore->getJoin( 'ar_comment' ); 2551 $ret = [ 2552 'tables' => [ 2553 'archive', 2554 'archive_actor' => 'actor' 2555 ] + $commentQuery['tables'], 2556 'fields' => [ 2557 'ar_id', 2558 'ar_page_id', 2559 'ar_namespace', 2560 'ar_title', 2561 'ar_rev_id', 2562 'ar_timestamp', 2563 'ar_minor_edit', 2564 'ar_deleted', 2565 'ar_len', 2566 'ar_parent_id', 2567 'ar_sha1', 2568 'ar_actor', 2569 'ar_user' => 'archive_actor.actor_user', 2570 'ar_user_text' => 'archive_actor.actor_name', 2571 ] + $commentQuery['fields'], 2572 'joins' => [ 2573 'archive_actor' => [ 'JOIN', 'actor_id=ar_actor' ] 2574 ] + $commentQuery['joins'], 2575 ]; 2576 2577 return $ret; 2578 } 2579 2580 /** 2581 * Do a batched query for the sizes of a set of revisions. 2582 * 2583 * MCR migration note: this replaced Revision::getParentLengths 2584 * 2585 * @param int[] $revIds 2586 * @return int[] associative array mapping revision IDs from $revIds to the nominal size 2587 * of the corresponding revision. 2588 */ 2589 public function getRevisionSizes( array $revIds ) { 2590 $dbr = $this->getDBConnectionRef( DB_REPLICA ); 2591 $revLens = []; 2592 if ( !$revIds ) { 2593 return $revLens; // empty 2594 } 2595 2596 $res = $dbr->select( 2597 'revision', 2598 [ 'rev_id', 'rev_len' ], 2599 [ 'rev_id' => $revIds ], 2600 __METHOD__ 2601 ); 2602 2603 foreach ( $res as $row ) { 2604 $revLens[$row->rev_id] = intval( $row->rev_len ); 2605 } 2606 2607 return $revLens; 2608 } 2609 2610 /** 2611 * Implementation of getPreviousRevision and getNextRevision. 2612 * 2613 * @param RevisionRecord $rev 2614 * @param int $flags 2615 * @param string $dir 'next' or 'prev' 2616 * @return RevisionRecord|null 2617 */ 2618 private function getRelativeRevision( RevisionRecord $rev, $flags, $dir ) { 2619 $op = $dir === 'next' ? '>' : '<'; 2620 $sort = $dir === 'next' ? 'ASC' : 'DESC'; 2621 2622 $revisionIdValue = $rev->getId( $this->wikiId ); 2623 2624 if ( !$revisionIdValue || !$rev->getPageId( $this->wikiId ) ) { 2625 // revision is unsaved or otherwise incomplete 2626 return null; 2627 } 2628 2629 if ( $rev instanceof RevisionArchiveRecord ) { 2630 // revision is deleted, so it's not part of the page history 2631 return null; 2632 } 2633 2634 list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags ); 2635 $db = $this->getDBConnectionRef( $dbType, [ 'contributions' ] ); 2636 2637 $ts = $this->getTimestampFromId( $revisionIdValue, $flags ); 2638 if ( $ts === false ) { 2639 // XXX Should this be moved into getTimestampFromId? 2640 $ts = $db->selectField( 'archive', 'ar_timestamp', 2641 [ 'ar_rev_id' => $revisionIdValue ], __METHOD__ ); 2642 if ( $ts === false ) { 2643 // XXX Is this reachable? How can we have a page id but no timestamp? 2644 return null; 2645 } 2646 } 2647 $dbts = $db->addQuotes( $db->timestamp( $ts ) ); 2648 2649 $revId = $db->selectField( 'revision', 'rev_id', 2650 [ 2651 'rev_page' => $rev->getPageId( $this->wikiId ), 2652 "rev_timestamp $op $dbts OR (rev_timestamp = $dbts AND rev_id $op $revisionIdValue )" 2653 ], 2654 __METHOD__, 2655 [ 2656 'ORDER BY' => [ "rev_timestamp $sort", "rev_id $sort" ], 2657 'IGNORE INDEX' => 'rev_timestamp', // Probably needed for T159319 2658 ] 2659 ); 2660 2661 if ( $revId === false ) { 2662 return null; 2663 } 2664 2665 return $this->getRevisionById( intval( $revId ) ); 2666 } 2667 2668 /** 2669 * Get the revision before $rev in the page's history, if any. 2670 * Will return null for the first revision but also for deleted or unsaved revisions. 2671 * 2672 * MCR migration note: this replaced Revision::getPrevious 2673 * 2674 * @see PageArchive::getPreviousRevisionRecord 2675 * 2676 * @param RevisionRecord $rev 2677 * @param int $flags (optional) $flags include: 2678 * IDBAccessObject::READ_LATEST: Select the data from the primary DB 2679 * 2680 * @return RevisionRecord|null 2681 */ 2682 public function getPreviousRevision( RevisionRecord $rev, $flags = self::READ_NORMAL ) { 2683 return $this->getRelativeRevision( $rev, $flags, 'prev' ); 2684 } 2685 2686 /** 2687 * Get the revision after $rev in the page's history, if any. 2688 * Will return null for the latest revision but also for deleted or unsaved revisions. 2689 * 2690 * MCR migration note: this replaced Revision::getNext 2691 * 2692 * @param RevisionRecord $rev 2693 * @param int $flags (optional) $flags include: 2694 * IDBAccessObject::READ_LATEST: Select the data from the primary DB 2695 * @return RevisionRecord|null 2696 */ 2697 public function getNextRevision( RevisionRecord $rev, $flags = self::READ_NORMAL ) { 2698 return $this->getRelativeRevision( $rev, $flags, 'next' ); 2699 } 2700 2701 /** 2702 * Get previous revision Id for this page_id 2703 * This is used to populate rev_parent_id on save 2704 * 2705 * MCR migration note: this corresponded to Revision::getPreviousRevisionId 2706 * 2707 * @param IDatabase $db 2708 * @param RevisionRecord $rev 2709 * 2710 * @return int 2711 */ 2712 private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) { 2713 $this->checkDatabaseDomain( $db ); 2714 2715 if ( $rev->getPageId( $this->wikiId ) === null ) { 2716 return 0; 2717 } 2718 # Use page_latest if ID is not given 2719 if ( !$rev->getId( $this->wikiId ) ) { 2720 $prevId = $db->selectField( 2721 'page', 'page_latest', 2722 [ 'page_id' => $rev->getPageId( $this->wikiId ) ], 2723 __METHOD__ 2724 ); 2725 } else { 2726 $prevId = $db->selectField( 2727 'revision', 'rev_id', 2728 [ 'rev_page' => $rev->getPageId( $this->wikiId ), 'rev_id < ' . $rev->getId( $this->wikiId ) ], 2729 __METHOD__, 2730 [ 'ORDER BY' => 'rev_id DESC' ] 2731 ); 2732 } 2733 return intval( $prevId ); 2734 } 2735 2736 /** 2737 * Get rev_timestamp from rev_id, without loading the rest of the row. 2738 * 2739 * Historically, there was an extra Title parameter that was passed before $id. This is no 2740 * longer needed and is deprecated in 1.34. 2741 * 2742 * MCR migration note: this replaced Revision::getTimestampFromId 2743 * 2744 * @param int $id 2745 * @param int $flags 2746 * @return string|bool False if not found 2747 */ 2748 public function getTimestampFromId( $id, $flags = 0 ) { 2749 if ( $id instanceof Title ) { 2750 // Old deprecated calling convention supported for backwards compatibility 2751 $id = $flags; 2752 $flags = func_num_args() > 2 ? func_get_arg( 2 ) : 0; 2753 } 2754 2755 // T270149: Bail out if we know the query will definitely return false. Some callers are 2756 // passing RevisionRecord::getId() call directly as $id which can possibly return null. 2757 // Null $id or $id <= 0 will lead to useless query with WHERE clause of 'rev_id IS NULL' 2758 // or 'rev_id = 0', but 'rev_id' is always greater than zero and cannot be null. 2759 // @todo typehint $id and remove the null check 2760 if ( $id === null || $id <= 0 ) { 2761 return false; 2762 } 2763 2764 $db = $this->getDBConnectionRefForQueryFlags( $flags ); 2765 2766 $timestamp = 2767 $db->selectField( 'revision', 'rev_timestamp', [ 'rev_id' => $id ], __METHOD__ ); 2768 2769 return ( $timestamp !== false ) ? MWTimestamp::convert( TS_MW, $timestamp ) : false; 2770 } 2771 2772 /** 2773 * Get count of revisions per page...not very efficient 2774 * 2775 * MCR migration note: this replaced Revision::countByPageId 2776 * 2777 * @param IDatabase $db 2778 * @param int $id Page id 2779 * @return int 2780 */ 2781 public function countRevisionsByPageId( IDatabase $db, $id ) { 2782 $this->checkDatabaseDomain( $db ); 2783 2784 $row = $db->selectRow( 'revision', 2785 [ 'revCount' => 'COUNT(*)' ], 2786 [ 'rev_page' => $id ], 2787 __METHOD__ 2788 ); 2789 if ( $row ) { 2790 return intval( $row->revCount ); 2791 } 2792 return 0; 2793 } 2794 2795 /** 2796 * Get count of revisions per page...not very efficient 2797 * 2798 * MCR migration note: this replaced Revision::countByTitle 2799 * 2800 * @param IDatabase $db 2801 * @param PageIdentity $page 2802 * @return int 2803 */ 2804 public function countRevisionsByTitle( IDatabase $db, PageIdentity $page ) { 2805 $id = $this->getArticleId( $page ); 2806 if ( $id ) { 2807 return $this->countRevisionsByPageId( $db, $id ); 2808 } 2809 return 0; 2810 } 2811 2812 /** 2813 * Check if no edits were made by other users since 2814 * the time a user started editing the page. Limit to 2815 * 50 revisions for the sake of performance. 2816 * 2817 * MCR migration note: this replaced Revision::userWasLastToEdit 2818 * 2819 * @deprecated since 1.31; Can possibly be removed, since the self-conflict suppression 2820 * logic in EditPage that uses this seems conceptually dubious. Revision::userWasLastToEdit 2821 * had been deprecated since 1.24 (the Revision class was removed entirely in 1.37). 2822 * 2823 * @param IDatabase $db The Database to perform the check on. 2824 * @param int $pageId The ID of the page in question 2825 * @param int $userId The ID of the user in question 2826 * @param string $since Look at edits since this time 2827 * 2828 * @return bool True if the given user was the only one to edit since the given timestamp 2829 */ 2830 public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) { 2831 $this->checkDatabaseDomain( $db ); 2832 2833 if ( !$userId ) { 2834 return false; 2835 } 2836 2837 $revQuery = $this->getQueryInfo(); 2838 $res = $db->select( 2839 $revQuery['tables'], 2840 [ 2841 'rev_user' => $revQuery['fields']['rev_user'], 2842 ], 2843 [ 2844 'rev_page' => $pageId, 2845 'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) ) 2846 ], 2847 __METHOD__, 2848 [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ], 2849 $revQuery['joins'] 2850 ); 2851 foreach ( $res as $row ) { 2852 if ( $row->rev_user != $userId ) { 2853 return false; 2854 } 2855 } 2856 return true; 2857 } 2858 2859 /** 2860 * Load a revision based on a known page ID and current revision ID from the DB 2861 * 2862 * This method allows for the use of caching, though accessing anything that normally 2863 * requires permission checks (aside from the text) will trigger a small DB lookup. 2864 * 2865 * MCR migration note: this replaced Revision::newKnownCurrent 2866 * 2867 * @param PageIdentity $page the associated page 2868 * @param int $revId current revision of this page. Defaults to $title->getLatestRevID(). 2869 * 2870 * @return RevisionRecord|bool Returns false if missing 2871 */ 2872 public function getKnownCurrentRevision( PageIdentity $page, $revId = 0 ) { 2873 $db = $this->getDBConnectionRef( DB_REPLICA ); 2874 $revIdPassed = $revId; 2875 $pageId = $this->getArticleId( $page ); 2876 if ( !$pageId ) { 2877 return false; 2878 } 2879 2880 if ( !$revId ) { 2881 if ( $page instanceof Title ) { 2882 $revId = $page->getLatestRevID(); 2883 } else { 2884 $pageRecord = $this->pageStore->getPageByReference( $page ); 2885 if ( $pageRecord ) { 2886 $revId = $pageRecord->getLatest( $this->getWikiId() ); 2887 } 2888 } 2889 } 2890 2891 if ( !$revId ) { 2892 $this->logger->warning( 2893 'No latest revision known for page {page} even though it exists with page ID {page_id}', [ 2894 'page' => $page->__toString(), 2895 'page_id' => $pageId, 2896 'wiki_id' => $this->getWikiId() ?: 'local', 2897 ] ); 2898 return false; 2899 } 2900 2901 // Load the row from cache if possible. If not possible, populate the cache. 2902 // As a minor optimization, remember if this was a cache hit or miss. 2903 // We can sometimes avoid a database query later if this is a cache miss. 2904 $fromCache = true; 2905 $row = $this->cache->getWithSetCallback( 2906 // Page/rev IDs passed in from DB to reflect history merges 2907 $this->getRevisionRowCacheKey( $db, $pageId, $revId ), 2908 WANObjectCache::TTL_WEEK, 2909 function ( $curValue, &$ttl, array &$setOpts ) use ( 2910 $db, $revId, &$fromCache 2911 ) { 2912 $setOpts += Database::getCacheSetOptions( $db ); 2913 $row = $this->fetchRevisionRowFromConds( $db, [ 'rev_id' => intval( $revId ) ] ); 2914 if ( $row ) { 2915 $fromCache = false; 2916 } 2917 return $row; // don't cache negatives 2918 } 2919 ); 2920 2921 // Reflect revision deletion and user renames. 2922 if ( $row ) { 2923 $title = $this->ensureRevisionRowMatchesPage( $row, $page, [ 2924 'from_cache_flag' => $fromCache, 2925 'page_id_initial' => $pageId, 2926 'rev_id_used' => $revId, 2927 'rev_id_requested' => $revIdPassed, 2928 ] ); 2929 2930 return $this->newRevisionFromRow( $row, 0, $title, $fromCache ); 2931 } else { 2932 return false; 2933 } 2934 } 2935 2936 /** 2937 * Get the first revision of a given page. 2938 * 2939 * @since 1.35 2940 * @param LinkTarget|PageIdentity $page Calling with LinkTarget is deprecated since 1.36 2941 * @param int $flags 2942 * @return RevisionRecord|null 2943 */ 2944 public function getFirstRevision( 2945 $page, 2946 int $flags = IDBAccessObject::READ_NORMAL 2947 ): ?RevisionRecord { 2948 if ( $page instanceof LinkTarget ) { 2949 // Only resolve LinkTarget to a Title when operating in the context of the local wiki (T248756) 2950 $page = $this->wikiId === WikiAwareEntity::LOCAL ? Title::castFromLinkTarget( $page ) : null; 2951 } 2952 return $this->newRevisionFromConds( 2953 [ 2954 'page_namespace' => $page->getNamespace(), 2955 'page_title' => $page->getDBkey() 2956 ], 2957 $flags, 2958 $page, 2959 [ 2960 'ORDER BY' => [ 'rev_timestamp ASC', 'rev_id ASC' ], 2961 'IGNORE INDEX' => [ 'revision' => 'rev_timestamp' ], // See T159319 2962 ] 2963 ); 2964 } 2965 2966 /** 2967 * Get a cache key for use with a row as selected with getQueryInfo( [ 'page', 'user' ] ) 2968 * Caching rows without 'page' or 'user' could lead to issues. 2969 * If the format of the rows returned by the query provided by getQueryInfo changes the 2970 * cache key should be updated to avoid conflicts. 2971 * 2972 * @param IDatabase $db 2973 * @param int $pageId 2974 * @param int $revId 2975 * @return string 2976 */ 2977 private function getRevisionRowCacheKey( IDatabase $db, $pageId, $revId ) { 2978 return $this->cache->makeGlobalKey( 2979 self::ROW_CACHE_KEY, 2980 $db->getDomainID(), 2981 $pageId, 2982 $revId 2983 ); 2984 } 2985 2986 /** 2987 * Asserts that if revision is provided, it's saved and belongs to the page with provided pageId. 2988 * @param string $paramName 2989 * @param int $pageId 2990 * @param RevisionRecord|null $rev 2991 * @throws InvalidArgumentException 2992 */ 2993 private function assertRevisionParameter( $paramName, $pageId, RevisionRecord $rev = null ) { 2994 if ( $rev ) { 2995 if ( $rev->getId( $this->wikiId ) === null ) { 2996 throw new InvalidArgumentException( "Unsaved {$paramName} revision passed" ); 2997 } 2998 if ( $rev->getPageId( $this->wikiId ) !== $pageId ) { 2999 throw new InvalidArgumentException( 3000 "Revision {$rev->getId( $this->wikiId )} doesn't belong to page {$pageId}" 3001 ); 3002 } 3003 } 3004 } 3005 3006 /** 3007 * Converts revision limits to query conditions. 3008 * 3009 * @param IDatabase $dbr 3010 * @param RevisionRecord|null $old Old revision. 3011 * If null is provided, count starting from the first revision (inclusive). 3012 * @param RevisionRecord|null $new New revision. 3013 * If null is provided, count until the last revision (inclusive). 3014 * @param string|array $options Single option, or an array of options: 3015 * RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded. 3016 * RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded. 3017 * RevisionStore::INCLUDE_BOTH Include both $old and $new in the range. 3018 * @return array 3019 */ 3020 private function getRevisionLimitConditions( 3021 IDatabase $dbr, 3022 RevisionRecord $old = null, 3023 RevisionRecord $new = null, 3024 $options = [] 3025 ) { 3026 $options = (array)$options; 3027 $oldCmp = '>'; 3028 $newCmp = '<'; 3029 if ( in_array( self::INCLUDE_OLD, $options ) ) { 3030 $oldCmp = '>='; 3031 } 3032 if ( in_array( self::INCLUDE_NEW, $options ) ) { 3033 $newCmp = '<='; 3034 } 3035 if ( in_array( self::INCLUDE_BOTH, $options ) ) { 3036 $oldCmp = '>='; 3037 $newCmp = '<='; 3038 } 3039 3040 $conds = []; 3041 if ( $old ) { 3042 $oldTs = $dbr->addQuotes( $dbr->timestamp( $old->getTimestamp() ) ); 3043 $conds[] = "(rev_timestamp = {$oldTs} AND rev_id {$oldCmp} {$old->getId( $this->wikiId )}) " . 3044 "OR rev_timestamp > {$oldTs}"; 3045 } 3046 if ( $new ) { 3047 $newTs = $dbr->addQuotes( $dbr->timestamp( $new->getTimestamp() ) ); 3048 $conds[] = "(rev_timestamp = {$newTs} AND rev_id {$newCmp} {$new->getId( $this->wikiId )}) " . 3049 "OR rev_timestamp < {$newTs}"; 3050 } 3051 return $conds; 3052 } 3053 3054 /** 3055 * Get IDs of revisions between the given revisions. 3056 * 3057 * @since 1.36 3058 * 3059 * @param int $pageId The id of the page 3060 * @param RevisionRecord|null $old Old revision. 3061 * If null is provided, count starting from the first revision (inclusive). 3062 * @param RevisionRecord|null $new New revision. 3063 * If null is provided, count until the last revision (inclusive). 3064 * @param int|null $max Limit of Revisions to count, will be incremented by 3065 * one to detect truncations. 3066 * @param string|array $options Single option, or an array of options: 3067 * RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded. 3068 * RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded. 3069 * RevisionStore::INCLUDE_BOTH Include both $old and $new in the range. 3070 * @param string|null $order The direction in which the revisions should be sorted. 3071 * Possible values: 3072 * - RevisionStore::ORDER_OLDEST_TO_NEWEST 3073 * - RevisionStore::ORDER_NEWEST_TO_OLDEST 3074 * - null for no specific ordering (default value) 3075 * @param int $flags 3076 * @throws InvalidArgumentException in case either revision is unsaved or 3077 * the revisions do not belong to the same page or unknown option is passed. 3078 * @return int[] 3079 */ 3080 public function getRevisionIdsBetween( 3081 int $pageId, 3082 RevisionRecord $old = null, 3083 RevisionRecord $new = null, 3084 ?int $max = null, 3085 $options = [], 3086 ?string $order = null, 3087 int $flags = IDBAccessObject::READ_NORMAL 3088 ): array { 3089 $this->assertRevisionParameter( 'old', $pageId, $old ); 3090 $this->assertRevisionParameter( 'new', $pageId, $new ); 3091 3092 $options = (array)$options; 3093 $includeOld = in_array( self::INCLUDE_OLD, $options ) || 3094 in_array( self::INCLUDE_BOTH, $options ); 3095 $includeNew = in_array( self::INCLUDE_NEW, $options ) || 3096 in_array( self::INCLUDE_BOTH, $options ); 3097 3098 // No DB query needed if old and new are the same revision. 3099 // Can't check for consecutive revisions with 'getParentId' for a similar 3100 // optimization as edge cases exist when there are revisions between 3101 // a revision and it's parent. See T185167 for more details. 3102 if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) { 3103 return $includeOld || $includeNew ? [ $new->getId( $this->wikiId ) ] : []; 3104 } 3105 3106 $db = $this->getDBConnectionRefForQueryFlags( $flags ); 3107 $conds = array_merge( 3108 [ 3109 'rev_page' => $pageId, 3110 $db->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . ' = 0' 3111 ], 3112 $this->getRevisionLimitConditions( $db, $old, $new, $options ) 3113 ); 3114 3115 $queryOptions = []; 3116 if ( $order !== null ) { 3117 $queryOptions['ORDER BY'] = [ "rev_timestamp $order", "rev_id $order" ]; 3118 } 3119 if ( $max !== null ) { 3120 $queryOptions['LIMIT'] = $max + 1; // extra to detect truncation 3121 } 3122 3123 $values = $db->selectFieldValues( 3124 'revision', 3125 'rev_id', 3126 $conds, 3127 __METHOD__, 3128 $queryOptions 3129 ); 3130 return array_map( 'intval', $values ); 3131 } 3132 3133 /** 3134 * Get the authors between the given revisions or revisions. 3135 * Used for diffs and other things that really need it. 3136 * 3137 * @since 1.35 3138 * 3139 * @param int $pageId The id of the page 3140 * @param RevisionRecord|null $old Old revision. 3141 * If null is provided, count starting from the first revision (inclusive). 3142 * @param RevisionRecord|null $new New revision. 3143 * If null is provided, count until the last revision (inclusive). 3144 * @param Authority|null $performer the user who's access rights to apply 3145 * @param int|null $max Limit of Revisions to count, will be incremented to detect truncations. 3146 * @param string|array $options Single option, or an array of options: 3147 * RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded. 3148 * RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded. 3149 * RevisionStore::INCLUDE_BOTH Include both $old and $new in the range. 3150 * @throws InvalidArgumentException in case either revision is unsaved or 3151 * the revisions do not belong to the same page or unknown option is passed. 3152 * @return UserIdentity[] Names of revision authors in the range 3153 */ 3154 public function getAuthorsBetween( 3155 $pageId, 3156 RevisionRecord $old = null, 3157 RevisionRecord $new = null, 3158 Authority $performer = null, 3159 $max = null, 3160 $options = [] 3161 ) { 3162 $this->assertRevisionParameter( 'old', $pageId, $old ); 3163 $this->assertRevisionParameter( 'new', $pageId, $new ); 3164 $options = (array)$options; 3165 3166 // No DB query needed if old and new are the same revision. 3167 // Can't check for consecutive revisions with 'getParentId' for a similar 3168 // optimization as edge cases exist when there are revisions between 3169 //a revision and it's parent. See T185167 for more details. 3170 if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) { 3171 if ( empty( $options ) ) { 3172 return []; 3173 } elseif ( $performer ) { 3174 return [ $new->getUser( RevisionRecord::FOR_THIS_USER, $performer ) ]; 3175 } else { 3176 return [ $new->getUser() ]; 3177 } 3178 } 3179 3180 $dbr = $this->getDBConnectionRef( DB_REPLICA ); 3181 $conds = array_merge( 3182 [ 3183 'rev_page' => $pageId, 3184 $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER ) . " = 0" 3185 ], 3186 $this->getRevisionLimitConditions( $dbr, $old, $new, $options ) 3187 ); 3188 3189 $queryOpts = [ 'DISTINCT' ]; 3190 if ( $max !== null ) { 3191 $queryOpts['LIMIT'] = $max + 1; 3192 } 3193 3194 $actorQuery = $this->actorMigration->getJoin( 'rev_user' ); 3195 return array_map( function ( $row ) { 3196 return $this->actorStore->newActorFromRowFields( 3197 $row->rev_user, 3198 $row->rev_user_text, 3199 $row->rev_actor 3200 ); 3201 }, iterator_to_array( $dbr->select( 3202 array_merge( [ 'revision' ], $actorQuery['tables'] ), 3203 $actorQuery['fields'], 3204 $conds, __METHOD__, 3205 $queryOpts, 3206 $actorQuery['joins'] 3207 ) ) ); 3208 } 3209 3210 /** 3211 * Get the number of authors between the given revisions. 3212 * Used for diffs and other things that really need it. 3213 * 3214 * @since 1.35 3215 * 3216 * @param int $pageId The id of the page 3217 * @param RevisionRecord|null $old Old revision . 3218 * If null is provided, count starting from the first revision (inclusive). 3219 * @param RevisionRecord|null $new New revision. 3220 * If null is provided, count until the last revision (inclusive). 3221 * @param Authority|null $performer the user who's access rights to apply 3222 * @param int|null $max Limit of Revisions to count, will be incremented to detect truncations. 3223 * @param string|array $options Single option, or an array of options: 3224 * RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded. 3225 * RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded. 3226 * RevisionStore::INCLUDE_BOTH Include both $old and $new in the range. 3227 * @throws InvalidArgumentException in case either revision is unsaved or 3228 * the revisions do not belong to the same page or unknown option is passed. 3229 * @return int Number of revisions authors in the range. 3230 */ 3231 public function countAuthorsBetween( 3232 $pageId, 3233 RevisionRecord $old = null, 3234 RevisionRecord $new = null, 3235 Authority $performer = null, 3236 $max = null, 3237 $options = [] 3238 ) { 3239 // TODO: Implement with a separate query to avoid cost of selecting unneeded fields 3240 // and creation of UserIdentity stuff. 3241 return count( $this->getAuthorsBetween( $pageId, $old, $new, $performer, $max, $options ) ); 3242 } 3243 3244 /** 3245 * Get the number of revisions between the given revisions. 3246 * Used for diffs and other things that really need it. 3247 * 3248 * @since 1.35 3249 * 3250 * @param int $pageId The id of the page 3251 * @param RevisionRecord|null $old Old revision. 3252 * If null is provided, count starting from the first revision (inclusive). 3253 * @param RevisionRecord|null $new New revision. 3254 * If null is provided, count until the last revision (inclusive). 3255 * @param int|null $max Limit of Revisions to count, will be incremented to detect truncations. 3256 * @param string|array $options Single option, or an array of options: 3257 * RevisionStore::INCLUDE_OLD Include $old in the range; $new is excluded. 3258 * RevisionStore::INCLUDE_NEW Include $new in the range; $old is excluded. 3259 * RevisionStore::INCLUDE_BOTH Include both $old and $new in the range. 3260 * @throws InvalidArgumentException in case either revision is unsaved or 3261 * the revisions do not belong to the same page. 3262 * @return int Number of revisions between these revisions. 3263 */ 3264 public function countRevisionsBetween( 3265 $pageId, 3266 RevisionRecord $old = null, 3267 RevisionRecord $new = null, 3268 $max = null, 3269 $options = [] 3270 ) { 3271 $this->assertRevisionParameter( 'old', $pageId, $old ); 3272 $this->assertRevisionParameter( 'new', $pageId, $new ); 3273 3274 // No DB query needed if old and new are the same revision. 3275 // Can't check for consecutive revisions with 'getParentId' for a similar 3276 // optimization as edge cases exist when there are revisions between 3277 //a revision and it's parent. See T185167 for more details. 3278 if ( $old && $new && $new->getId( $this->wikiId ) === $old->getId( $this->wikiId ) ) { 3279 return 0; 3280 } 3281 3282 $dbr = $this->getDBConnectionRef( DB_REPLICA ); 3283 $conds = array_merge( 3284 [ 3285 'rev_page' => $pageId, 3286 $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0" 3287 ], 3288 $this->getRevisionLimitConditions( $dbr, $old, $new, $options ) 3289 ); 3290 if ( $max !== null ) { 3291 return $dbr->selectRowCount( 'revision', '1', 3292 $conds, 3293 __METHOD__, 3294 [ 'LIMIT' => $max + 1 ] // extra to detect truncation 3295 ); 3296 } else { 3297 return (int)$dbr->selectField( 'revision', 'count(*)', $conds, __METHOD__ ); 3298 } 3299 } 3300 3301 /** 3302 * Tries to find a revision identical to $revision in $searchLimit most recent revisions 3303 * of this page. The comparison is based on SHA1s of these revisions. 3304 * 3305 * @since 1.37 3306 * 3307 * @param RevisionRecord $revision which revision to compare to 3308 * @param int $searchLimit How many recent revisions should be checked 3309 * 3310 * @return RevisionRecord|null 3311 */ 3312 public function findIdenticalRevision( 3313 RevisionRecord $revision, 3314 int $searchLimit 3315 ): ?RevisionRecord { 3316 $revision->assertWiki( $this->wikiId ); 3317 $db = $this->getDBConnectionRef( DB_REPLICA ); 3318 $revQuery = $this->getQueryInfo(); 3319 $subquery = $db->buildSelectSubquery( 3320 $revQuery['tables'], 3321 $revQuery['fields'], 3322 [ 'rev_page' => $revision->getPageId( $this->wikiId ) ], 3323 __METHOD__, 3324 [ 3325 'ORDER BY' => [ 3326 'rev_timestamp DESC', 3327 // for cases where there are multiple revs with same timestamp 3328 'rev_id DESC' 3329 ], 3330 'LIMIT' => $searchLimit, 3331 // skip the most recent edit, we can't revert to it anyway 3332 'OFFSET' => 1 3333 ], 3334 $revQuery['joins'] 3335 ); 3336 3337 // selectRow effectively uses LIMIT 1 clause, returning only the first result 3338 $revisionRow = $db->selectRow( 3339 [ 'recent_revs' => $subquery ], 3340 '*', 3341 [ 'rev_sha1' => $revision->getSha1() ], 3342 __METHOD__ 3343 ); 3344 3345 return $revisionRow ? $this->newRevisionFromRow( $revisionRow ) : null; 3346 } 3347 3348 // TODO: move relevant methods from Title here, e.g. isBigDeletion, etc. 3349} 3350 3351/** 3352 * Retain the old class name for backwards compatibility. 3353 * @deprecated since 1.32 3354 */ 3355class_alias( RevisionStore::class, 'MediaWiki\Storage\RevisionStore' ); 3356