RevisionStore.php 100 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272
  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.
  23. *
  24. * @file
  25. */
  26. namespace MediaWiki\Revision;
  27. use ActorMigration;
  28. use CommentStore;
  29. use CommentStoreComment;
  30. use Content;
  31. use ContentHandler;
  32. use DBAccessObjectUtils;
  33. use Hooks;
  34. use IDBAccessObject;
  35. use InvalidArgumentException;
  36. use IP;
  37. use LogicException;
  38. use MediaWiki\Linker\LinkTarget;
  39. use MediaWiki\Storage\BlobAccessException;
  40. use MediaWiki\Storage\BlobStore;
  41. use MediaWiki\Storage\NameTableAccessException;
  42. use MediaWiki\Storage\NameTableStore;
  43. use MediaWiki\Storage\SqlBlobStore;
  44. use MediaWiki\User\UserIdentity;
  45. use MediaWiki\User\UserIdentityValue;
  46. use Message;
  47. use MWException;
  48. use MWUnknownContentModelException;
  49. use Psr\Log\LoggerAwareInterface;
  50. use Psr\Log\LoggerInterface;
  51. use Psr\Log\NullLogger;
  52. use RecentChange;
  53. use Revision;
  54. use RuntimeException;
  55. use StatusValue;
  56. use stdClass;
  57. use Title;
  58. use Traversable;
  59. use User;
  60. use WANObjectCache;
  61. use Wikimedia\Assert\Assert;
  62. use Wikimedia\Rdbms\Database;
  63. use Wikimedia\Rdbms\DBConnRef;
  64. use Wikimedia\Rdbms\IDatabase;
  65. use Wikimedia\Rdbms\ILoadBalancer;
  66. use Wikimedia\Rdbms\IResultWrapper;
  67. /**
  68. * Service for looking up page revisions.
  69. *
  70. * @since 1.31
  71. * @since 1.32 Renamed from MediaWiki\Storage\RevisionStore
  72. *
  73. * @note This was written to act as a drop-in replacement for the corresponding
  74. * static methods in Revision.
  75. */
  76. class RevisionStore
  77. implements IDBAccessObject, RevisionFactory, RevisionLookup, LoggerAwareInterface {
  78. const ROW_CACHE_KEY = 'revision-row-1.29';
  79. /**
  80. * @var SqlBlobStore
  81. */
  82. private $blobStore;
  83. /**
  84. * @var bool|string
  85. */
  86. private $dbDomain;
  87. /**
  88. * @var boolean
  89. * @see $wgContentHandlerUseDB
  90. */
  91. private $contentHandlerUseDB = true;
  92. /**
  93. * @var ILoadBalancer
  94. */
  95. private $loadBalancer;
  96. /**
  97. * @var WANObjectCache
  98. */
  99. private $cache;
  100. /**
  101. * @var CommentStore
  102. */
  103. private $commentStore;
  104. /**
  105. * @var ActorMigration
  106. */
  107. private $actorMigration;
  108. /**
  109. * @var LoggerInterface
  110. */
  111. private $logger;
  112. /**
  113. * @var NameTableStore
  114. */
  115. private $contentModelStore;
  116. /**
  117. * @var NameTableStore
  118. */
  119. private $slotRoleStore;
  120. /** @var int An appropriate combination of SCHEMA_COMPAT_XXX flags. */
  121. private $mcrMigrationStage;
  122. /** @var SlotRoleRegistry */
  123. private $slotRoleRegistry;
  124. /**
  125. * @todo $blobStore should be allowed to be any BlobStore!
  126. *
  127. * @param ILoadBalancer $loadBalancer
  128. * @param SqlBlobStore $blobStore
  129. * @param WANObjectCache $cache A cache for caching revision rows. This can be the local
  130. * wiki's default instance even if $dbDomain refers to a different wiki, since
  131. * makeGlobalKey() is used to constructed a key that allows cached revision rows from
  132. * the same database to be re-used between wikis. For example, enwiki and frwiki will
  133. * use the same cache keys for revision rows from the wikidatawiki database, regardless
  134. * of the cache's default key space.
  135. * @param CommentStore $commentStore
  136. * @param NameTableStore $contentModelStore
  137. * @param NameTableStore $slotRoleStore
  138. * @param SlotRoleRegistry $slotRoleRegistry
  139. * @param int $mcrMigrationStage An appropriate combination of SCHEMA_COMPAT_XXX flags
  140. * @param ActorMigration $actorMigration
  141. * @param bool|string $dbDomain DB domain of the relevant wiki or false for the current one
  142. */
  143. public function __construct(
  144. ILoadBalancer $loadBalancer,
  145. SqlBlobStore $blobStore,
  146. WANObjectCache $cache,
  147. CommentStore $commentStore,
  148. NameTableStore $contentModelStore,
  149. NameTableStore $slotRoleStore,
  150. SlotRoleRegistry $slotRoleRegistry,
  151. $mcrMigrationStage,
  152. ActorMigration $actorMigration,
  153. $dbDomain = false
  154. ) {
  155. Assert::parameterType( 'string|boolean', $dbDomain, '$dbDomain' );
  156. Assert::parameterType( 'integer', $mcrMigrationStage, '$mcrMigrationStage' );
  157. Assert::parameter(
  158. ( $mcrMigrationStage & SCHEMA_COMPAT_READ_BOTH ) !== SCHEMA_COMPAT_READ_BOTH,
  159. '$mcrMigrationStage',
  160. 'Reading from the old and the new schema at the same time is not supported.'
  161. );
  162. Assert::parameter(
  163. ( $mcrMigrationStage & SCHEMA_COMPAT_READ_BOTH ) !== 0,
  164. '$mcrMigrationStage',
  165. 'Reading needs to be enabled for the old or the new schema.'
  166. );
  167. Assert::parameter(
  168. ( $mcrMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) !== 0,
  169. '$mcrMigrationStage',
  170. 'Writing needs to be enabled for the new schema.'
  171. );
  172. Assert::parameter(
  173. ( $mcrMigrationStage & SCHEMA_COMPAT_READ_OLD ) === 0
  174. || ( $mcrMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) !== 0,
  175. '$mcrMigrationStage',
  176. 'Cannot read the old schema when not also writing it.'
  177. );
  178. $this->loadBalancer = $loadBalancer;
  179. $this->blobStore = $blobStore;
  180. $this->cache = $cache;
  181. $this->commentStore = $commentStore;
  182. $this->contentModelStore = $contentModelStore;
  183. $this->slotRoleStore = $slotRoleStore;
  184. $this->slotRoleRegistry = $slotRoleRegistry;
  185. $this->mcrMigrationStage = $mcrMigrationStage;
  186. $this->actorMigration = $actorMigration;
  187. $this->dbDomain = $dbDomain;
  188. $this->logger = new NullLogger();
  189. }
  190. /**
  191. * @param int $flags A combination of SCHEMA_COMPAT_XXX flags.
  192. * @return bool True if all the given flags were set in the $mcrMigrationStage
  193. * parameter passed to the constructor.
  194. */
  195. private function hasMcrSchemaFlags( $flags ) {
  196. return ( $this->mcrMigrationStage & $flags ) === $flags;
  197. }
  198. /**
  199. * Throws a RevisionAccessException if this RevisionStore is configured for cross-wiki loading
  200. * and still reading from the old DB schema.
  201. *
  202. * @throws RevisionAccessException
  203. */
  204. private function assertCrossWikiContentLoadingIsSafe() {
  205. if ( $this->dbDomain !== false && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
  206. throw new RevisionAccessException(
  207. "Cross-wiki content loading is not supported by the pre-MCR schema"
  208. );
  209. }
  210. }
  211. public function setLogger( LoggerInterface $logger ) {
  212. $this->logger = $logger;
  213. }
  214. /**
  215. * @return bool Whether the store is read-only
  216. */
  217. public function isReadOnly() {
  218. return $this->blobStore->isReadOnly();
  219. }
  220. /**
  221. * @return bool
  222. */
  223. public function getContentHandlerUseDB() {
  224. return $this->contentHandlerUseDB;
  225. }
  226. /**
  227. * @see $wgContentHandlerUseDB
  228. * @param bool $contentHandlerUseDB
  229. * @throws MWException
  230. */
  231. public function setContentHandlerUseDB( $contentHandlerUseDB ) {
  232. if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW )
  233. || $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW )
  234. ) {
  235. if ( !$contentHandlerUseDB ) {
  236. throw new MWException(
  237. 'Content model must be stored in the database for multi content revision migration.'
  238. );
  239. }
  240. }
  241. $this->contentHandlerUseDB = $contentHandlerUseDB;
  242. }
  243. /**
  244. * @return ILoadBalancer
  245. */
  246. private function getDBLoadBalancer() {
  247. return $this->loadBalancer;
  248. }
  249. /**
  250. * @param int $queryFlags a bit field composed of READ_XXX flags
  251. *
  252. * @return DBConnRef
  253. */
  254. private function getDBConnectionRefForQueryFlags( $queryFlags ) {
  255. list( $mode, ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
  256. return $this->getDBConnectionRef( $mode );
  257. }
  258. /**
  259. * @param int $mode DB_MASTER or DB_REPLICA
  260. *
  261. * @param array $groups
  262. * @return DBConnRef
  263. */
  264. private function getDBConnectionRef( $mode, $groups = [] ) {
  265. $lb = $this->getDBLoadBalancer();
  266. return $lb->getConnectionRef( $mode, $groups, $this->dbDomain );
  267. }
  268. /**
  269. * Determines the page Title based on the available information.
  270. *
  271. * MCR migration note: this corresponds to Revision::getTitle
  272. *
  273. * @note this method should be private, external use should be avoided!
  274. *
  275. * @param int|null $pageId
  276. * @param int|null $revId
  277. * @param int $queryFlags
  278. *
  279. * @return Title
  280. * @throws RevisionAccessException
  281. */
  282. public function getTitle( $pageId, $revId, $queryFlags = self::READ_NORMAL ) {
  283. if ( !$pageId && !$revId ) {
  284. throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' );
  285. }
  286. // This method recalls itself with READ_LATEST if READ_NORMAL doesn't get us a Title
  287. // So ignore READ_LATEST_IMMUTABLE flags and handle the fallback logic in this method
  288. if ( DBAccessObjectUtils::hasFlags( $queryFlags, self::READ_LATEST_IMMUTABLE ) ) {
  289. $queryFlags = self::READ_NORMAL;
  290. }
  291. $canUseTitleNewFromId = ( $pageId !== null && $pageId > 0 && $this->dbDomain === false );
  292. list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
  293. // Loading by ID is best, but Title::newFromID does not support that for foreign IDs.
  294. if ( $canUseTitleNewFromId ) {
  295. $titleFlags = ( $dbMode == DB_MASTER ? Title::READ_LATEST : 0 );
  296. // TODO: better foreign title handling (introduce TitleFactory)
  297. $title = Title::newFromID( $pageId, $titleFlags );
  298. if ( $title ) {
  299. return $title;
  300. }
  301. }
  302. // rev_id is defined as NOT NULL, but this revision may not yet have been inserted.
  303. $canUseRevId = ( $revId !== null && $revId > 0 );
  304. if ( $canUseRevId ) {
  305. $dbr = $this->getDBConnectionRef( $dbMode );
  306. // @todo: Title::getSelectFields(), or Title::getQueryInfo(), or something like that
  307. $row = $dbr->selectRow(
  308. [ 'revision', 'page' ],
  309. [
  310. 'page_namespace',
  311. 'page_title',
  312. 'page_id',
  313. 'page_latest',
  314. 'page_is_redirect',
  315. 'page_len',
  316. ],
  317. [ 'rev_id' => $revId ],
  318. __METHOD__,
  319. $dbOptions,
  320. [ 'page' => [ 'JOIN', 'page_id=rev_page' ] ]
  321. );
  322. if ( $row ) {
  323. // TODO: better foreign title handling (introduce TitleFactory)
  324. return Title::newFromRow( $row );
  325. }
  326. }
  327. // If we still don't have a title, fallback to master if that wasn't already happening.
  328. if ( $dbMode !== DB_MASTER ) {
  329. $title = $this->getTitle( $pageId, $revId, self::READ_LATEST );
  330. if ( $title ) {
  331. $this->logger->info(
  332. __METHOD__ . ' fell back to READ_LATEST and got a Title.',
  333. [ 'trace' => wfBacktrace() ]
  334. );
  335. return $title;
  336. }
  337. }
  338. throw new RevisionAccessException(
  339. "Could not determine title for page ID $pageId and revision ID $revId"
  340. );
  341. }
  342. /**
  343. * @param mixed $value
  344. * @param string $name
  345. *
  346. * @throws IncompleteRevisionException if $value is null
  347. * @return mixed $value, if $value is not null
  348. */
  349. private function failOnNull( $value, $name ) {
  350. if ( $value === null ) {
  351. throw new IncompleteRevisionException(
  352. "$name must not be " . var_export( $value, true ) . "!"
  353. );
  354. }
  355. return $value;
  356. }
  357. /**
  358. * @param mixed $value
  359. * @param string $name
  360. *
  361. * @throws IncompleteRevisionException if $value is empty
  362. * @return mixed $value, if $value is not null
  363. */
  364. private function failOnEmpty( $value, $name ) {
  365. if ( $value === null || $value === 0 || $value === '' ) {
  366. throw new IncompleteRevisionException(
  367. "$name must not be " . var_export( $value, true ) . "!"
  368. );
  369. }
  370. return $value;
  371. }
  372. /**
  373. * Insert a new revision into the database, returning the new revision record
  374. * on success and dies horribly on failure.
  375. *
  376. * MCR migration note: this replaces Revision::insertOn
  377. *
  378. * @param RevisionRecord $rev
  379. * @param IDatabase $dbw (master connection)
  380. *
  381. * @throws InvalidArgumentException
  382. * @return RevisionRecord the new revision record.
  383. */
  384. public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) {
  385. // TODO: pass in a DBTransactionContext instead of a database connection.
  386. $this->checkDatabaseDomain( $dbw );
  387. $slotRoles = $rev->getSlotRoles();
  388. // Make sure the main slot is always provided throughout migration
  389. if ( !in_array( SlotRecord::MAIN, $slotRoles ) ) {
  390. throw new InvalidArgumentException(
  391. 'main slot must be provided'
  392. );
  393. }
  394. // If we are not writing into the new schema, we can't support extra slots.
  395. if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW )
  396. && $slotRoles !== [ SlotRecord::MAIN ]
  397. ) {
  398. throw new InvalidArgumentException(
  399. 'Only the main slot is supported when not writing to the MCR enabled schema!'
  400. );
  401. }
  402. // As long as we are not reading from the new schema, we don't want to write extra slots.
  403. if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW )
  404. && $slotRoles !== [ SlotRecord::MAIN ]
  405. ) {
  406. throw new InvalidArgumentException(
  407. 'Only the main slot is supported when not reading from the MCR enabled schema!'
  408. );
  409. }
  410. // Checks
  411. $this->failOnNull( $rev->getSize(), 'size field' );
  412. $this->failOnEmpty( $rev->getSha1(), 'sha1 field' );
  413. $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' );
  414. $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' );
  415. $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' );
  416. $this->failOnNull( $user->getId(), 'user field' );
  417. $this->failOnEmpty( $user->getName(), 'user_text field' );
  418. if ( !$rev->isReadyForInsertion() ) {
  419. // This is here for future-proofing. At the time this check being added, it
  420. // was redundant to the individual checks above.
  421. throw new IncompleteRevisionException( 'Revision is incomplete' );
  422. }
  423. // TODO: we shouldn't need an actual Title here.
  424. $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
  425. $pageId = $this->failOnEmpty( $rev->getPageId(), 'rev_page field' ); // check this early
  426. $parentId = $rev->getParentId() === null
  427. ? $this->getPreviousRevisionId( $dbw, $rev )
  428. : $rev->getParentId();
  429. /** @var RevisionRecord $rev */
  430. $rev = $dbw->doAtomicSection(
  431. __METHOD__,
  432. function ( IDatabase $dbw, $fname ) use (
  433. $rev,
  434. $user,
  435. $comment,
  436. $title,
  437. $pageId,
  438. $parentId
  439. ) {
  440. return $this->insertRevisionInternal(
  441. $rev,
  442. $dbw,
  443. $user,
  444. $comment,
  445. $title,
  446. $pageId,
  447. $parentId
  448. );
  449. }
  450. );
  451. // sanity checks
  452. Assert::postcondition( $rev->getId() > 0, 'revision must have an ID' );
  453. Assert::postcondition( $rev->getPageId() > 0, 'revision must have a page ID' );
  454. Assert::postcondition(
  455. $rev->getComment( RevisionRecord::RAW ) !== null,
  456. 'revision must have a comment'
  457. );
  458. Assert::postcondition(
  459. $rev->getUser( RevisionRecord::RAW ) !== null,
  460. 'revision must have a user'
  461. );
  462. // Trigger exception if the main slot is missing.
  463. // Technically, this could go away after MCR migration: while
  464. // calling code may require a main slot to exist, RevisionStore
  465. // really should not know or care about that requirement.
  466. $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW );
  467. foreach ( $slotRoles as $role ) {
  468. $slot = $rev->getSlot( $role, RevisionRecord::RAW );
  469. Assert::postcondition(
  470. $slot->getContent() !== null,
  471. $role . ' slot must have content'
  472. );
  473. Assert::postcondition(
  474. $slot->hasRevision(),
  475. $role . ' slot must have a revision associated'
  476. );
  477. }
  478. Hooks::run( 'RevisionRecordInserted', [ $rev ] );
  479. // TODO: deprecate in 1.32!
  480. $legacyRevision = new Revision( $rev );
  481. Hooks::run( 'RevisionInsertComplete', [ &$legacyRevision, null, null ] );
  482. return $rev;
  483. }
  484. private function insertRevisionInternal(
  485. RevisionRecord $rev,
  486. IDatabase $dbw,
  487. User $user,
  488. CommentStoreComment $comment,
  489. Title $title,
  490. $pageId,
  491. $parentId
  492. ) {
  493. $slotRoles = $rev->getSlotRoles();
  494. $revisionRow = $this->insertRevisionRowOn(
  495. $dbw,
  496. $rev,
  497. $title,
  498. $parentId
  499. );
  500. $revisionId = $revisionRow['rev_id'];
  501. $blobHints = [
  502. BlobStore::PAGE_HINT => $pageId,
  503. BlobStore::REVISION_HINT => $revisionId,
  504. BlobStore::PARENT_HINT => $parentId,
  505. ];
  506. $newSlots = [];
  507. foreach ( $slotRoles as $role ) {
  508. $slot = $rev->getSlot( $role, RevisionRecord::RAW );
  509. // If the SlotRecord already has a revision ID set, this means it already exists
  510. // in the database, and should already belong to the current revision.
  511. // However, a slot may already have a revision, but no content ID, if the slot
  512. // is emulated based on the archive table, because we are in SCHEMA_COMPAT_READ_OLD
  513. // mode, and the respective archive row was not yet migrated to the new schema.
  514. // In that case, a new slot row (and content row) must be inserted even during
  515. // undeletion.
  516. if ( $slot->hasRevision() && $slot->hasContentId() ) {
  517. // TODO: properly abort transaction if the assertion fails!
  518. Assert::parameter(
  519. $slot->getRevision() === $revisionId,
  520. 'slot role ' . $slot->getRole(),
  521. 'Existing slot should belong to revision '
  522. . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!'
  523. );
  524. // Slot exists, nothing to do, move along.
  525. // This happens when restoring archived revisions.
  526. $newSlots[$role] = $slot;
  527. // Write the main slot's text ID to the revision table for backwards compatibility
  528. if ( $slot->getRole() === SlotRecord::MAIN
  529. && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD )
  530. ) {
  531. $blobAddress = $slot->getAddress();
  532. $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress );
  533. }
  534. } else {
  535. $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $title, $blobHints );
  536. }
  537. }
  538. $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId );
  539. $rev = new RevisionStoreRecord(
  540. $title,
  541. $user,
  542. $comment,
  543. (object)$revisionRow,
  544. new RevisionSlots( $newSlots ),
  545. $this->dbDomain
  546. );
  547. return $rev;
  548. }
  549. /**
  550. * @param IDatabase $dbw
  551. * @param int $revisionId
  552. * @param string &$blobAddress (may change!)
  553. *
  554. * @return int the text row id
  555. */
  556. private function updateRevisionTextId( IDatabase $dbw, $revisionId, &$blobAddress ) {
  557. $textId = $this->blobStore->getTextIdFromAddress( $blobAddress );
  558. if ( !$textId ) {
  559. throw new LogicException(
  560. 'Blob address not supported in 1.29 database schema: ' . $blobAddress
  561. );
  562. }
  563. // getTextIdFromAddress() is free to insert something into the text table, so $textId
  564. // may be a new value, not anything already contained in $blobAddress.
  565. $blobAddress = SqlBlobStore::makeAddressFromTextId( $textId );
  566. $dbw->update(
  567. 'revision',
  568. [ 'rev_text_id' => $textId ],
  569. [ 'rev_id' => $revisionId ],
  570. __METHOD__
  571. );
  572. return $textId;
  573. }
  574. /**
  575. * @param IDatabase $dbw
  576. * @param int $revisionId
  577. * @param SlotRecord $protoSlot
  578. * @param Title $title
  579. * @param array $blobHints See the BlobStore::XXX_HINT constants
  580. * @return SlotRecord
  581. */
  582. private function insertSlotOn(
  583. IDatabase $dbw,
  584. $revisionId,
  585. SlotRecord $protoSlot,
  586. Title $title,
  587. array $blobHints = []
  588. ) {
  589. if ( $protoSlot->hasAddress() ) {
  590. $blobAddress = $protoSlot->getAddress();
  591. } else {
  592. $blobAddress = $this->storeContentBlob( $protoSlot, $title, $blobHints );
  593. }
  594. $contentId = null;
  595. // Write the main slot's text ID to the revision table for backwards compatibility
  596. if ( $protoSlot->getRole() === SlotRecord::MAIN
  597. && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD )
  598. ) {
  599. // If SCHEMA_COMPAT_WRITE_NEW is also set, the fake content ID is overwritten
  600. // with the real content ID below.
  601. $textId = $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress );
  602. $contentId = $this->emulateContentId( $textId );
  603. }
  604. if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
  605. if ( $protoSlot->hasContentId() ) {
  606. $contentId = $protoSlot->getContentId();
  607. } else {
  608. $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress );
  609. }
  610. $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId );
  611. }
  612. $savedSlot = SlotRecord::newSaved(
  613. $revisionId,
  614. $contentId,
  615. $blobAddress,
  616. $protoSlot
  617. );
  618. return $savedSlot;
  619. }
  620. /**
  621. * Insert IP revision into ip_changes for use when querying for a range.
  622. * @param IDatabase $dbw
  623. * @param User $user
  624. * @param RevisionRecord $rev
  625. * @param int $revisionId
  626. */
  627. private function insertIpChangesRow(
  628. IDatabase $dbw,
  629. User $user,
  630. RevisionRecord $rev,
  631. $revisionId
  632. ) {
  633. if ( $user->getId() === 0 && IP::isValid( $user->getName() ) ) {
  634. $ipcRow = [
  635. 'ipc_rev_id' => $revisionId,
  636. 'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
  637. 'ipc_hex' => IP::toHex( $user->getName() ),
  638. ];
  639. $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ );
  640. }
  641. }
  642. /**
  643. * @param IDatabase $dbw
  644. * @param RevisionRecord $rev
  645. * @param Title $title
  646. * @param int $parentId
  647. *
  648. * @return array a revision table row
  649. *
  650. * @throws MWException
  651. * @throws MWUnknownContentModelException
  652. */
  653. private function insertRevisionRowOn(
  654. IDatabase $dbw,
  655. RevisionRecord $rev,
  656. Title $title,
  657. $parentId
  658. ) {
  659. $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $title, $parentId );
  660. list( $commentFields, $commentCallback ) =
  661. $this->commentStore->insertWithTempTable(
  662. $dbw,
  663. 'rev_comment',
  664. $rev->getComment( RevisionRecord::RAW )
  665. );
  666. $revisionRow += $commentFields;
  667. list( $actorFields, $actorCallback ) =
  668. $this->actorMigration->getInsertValuesWithTempTable(
  669. $dbw,
  670. 'rev_user',
  671. $rev->getUser( RevisionRecord::RAW )
  672. );
  673. $revisionRow += $actorFields;
  674. $dbw->insert( 'revision', $revisionRow, __METHOD__ );
  675. if ( !isset( $revisionRow['rev_id'] ) ) {
  676. // only if auto-increment was used
  677. $revisionRow['rev_id'] = intval( $dbw->insertId() );
  678. if ( $dbw->getType() === 'mysql' ) {
  679. // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the
  680. // auto-increment value to disk, so on server restart it might reuse IDs from deleted
  681. // revisions. We can fix that with an insert with an explicit rev_id value, if necessary.
  682. $maxRevId = intval( $dbw->selectField( 'archive', 'MAX(ar_rev_id)', '', __METHOD__ ) );
  683. $table = 'archive';
  684. if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
  685. $maxRevId2 = intval( $dbw->selectField( 'slots', 'MAX(slot_revision_id)', '', __METHOD__ ) );
  686. if ( $maxRevId2 >= $maxRevId ) {
  687. $maxRevId = $maxRevId2;
  688. $table = 'slots';
  689. }
  690. }
  691. if ( $maxRevId >= $revisionRow['rev_id'] ) {
  692. $this->logger->debug(
  693. '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.'
  694. . ' Trying to fix it.',
  695. [
  696. 'revid' => $revisionRow['rev_id'],
  697. 'table' => $table,
  698. 'maxrevid' => $maxRevId,
  699. ]
  700. );
  701. if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) {
  702. throw new MWException( 'Failed to get database lock for T202032' );
  703. }
  704. $fname = __METHOD__;
  705. $dbw->onTransactionResolution(
  706. function ( $trigger, IDatabase $dbw ) use ( $fname ) {
  707. $dbw->unlock( 'fix-for-T202032', $fname );
  708. }
  709. );
  710. $dbw->delete( 'revision', [ 'rev_id' => $revisionRow['rev_id'] ], __METHOD__ );
  711. // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction
  712. // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing
  713. // inserts too, though, at least on MariaDB 10.1.29.
  714. //
  715. // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent
  716. // transactions in this code path thanks to the row lock from the original ->insert() above.
  717. //
  718. // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning
  719. // that's for non-MySQL DBs.
  720. $row1 = $dbw->query(
  721. $dbw->selectSQLText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE'
  722. )->fetchObject();
  723. if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
  724. $row2 = $dbw->query(
  725. $dbw->selectSQLText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ )
  726. . ' FOR UPDATE'
  727. )->fetchObject();
  728. } else {
  729. $row2 = null;
  730. }
  731. $maxRevId = max(
  732. $maxRevId,
  733. $row1 ? intval( $row1->v ) : 0,
  734. $row2 ? intval( $row2->v ) : 0
  735. );
  736. // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent
  737. // transactions will throw a duplicate key error here. It doesn't seem worth trying
  738. // to avoid that.
  739. $revisionRow['rev_id'] = $maxRevId + 1;
  740. $dbw->insert( 'revision', $revisionRow, __METHOD__ );
  741. }
  742. }
  743. }
  744. $commentCallback( $revisionRow['rev_id'] );
  745. $actorCallback( $revisionRow['rev_id'], $revisionRow );
  746. return $revisionRow;
  747. }
  748. /**
  749. * @param IDatabase $dbw
  750. * @param RevisionRecord $rev
  751. * @param Title $title
  752. * @param int $parentId
  753. *
  754. * @return array [ 0 => array $revisionRow, 1 => callable ]
  755. * @throws MWException
  756. * @throws MWUnknownContentModelException
  757. */
  758. private function getBaseRevisionRow(
  759. IDatabase $dbw,
  760. RevisionRecord $rev,
  761. Title $title,
  762. $parentId
  763. ) {
  764. // Record the edit in revisions
  765. $revisionRow = [
  766. 'rev_page' => $rev->getPageId(),
  767. 'rev_parent_id' => $parentId,
  768. 'rev_minor_edit' => $rev->isMinor() ? 1 : 0,
  769. 'rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
  770. 'rev_deleted' => $rev->getVisibility(),
  771. 'rev_len' => $rev->getSize(),
  772. 'rev_sha1' => $rev->getSha1(),
  773. ];
  774. if ( $rev->getId() !== null ) {
  775. // Needed to restore revisions with their original ID
  776. $revisionRow['rev_id'] = $rev->getId();
  777. }
  778. if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) ) {
  779. // In non MCR mode this IF section will relate to the main slot
  780. $mainSlot = $rev->getSlot( SlotRecord::MAIN );
  781. $model = $mainSlot->getModel();
  782. $format = $mainSlot->getFormat();
  783. // MCR migration note: rev_content_model and rev_content_format will go away
  784. if ( $this->contentHandlerUseDB ) {
  785. $this->assertCrossWikiContentLoadingIsSafe();
  786. $defaultModel = ContentHandler::getDefaultModelFor( $title );
  787. $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat();
  788. $revisionRow['rev_content_model'] = ( $model === $defaultModel ) ? null : $model;
  789. $revisionRow['rev_content_format'] = ( $format === $defaultFormat ) ? null : $format;
  790. }
  791. }
  792. return $revisionRow;
  793. }
  794. /**
  795. * @param SlotRecord $slot
  796. * @param Title $title
  797. * @param array $blobHints See the BlobStore::XXX_HINT constants
  798. *
  799. * @throws MWException
  800. * @return string the blob address
  801. */
  802. private function storeContentBlob(
  803. SlotRecord $slot,
  804. Title $title,
  805. array $blobHints = []
  806. ) {
  807. $content = $slot->getContent();
  808. $format = $content->getDefaultFormat();
  809. $model = $content->getModel();
  810. $this->checkContent( $content, $title, $slot->getRole() );
  811. return $this->blobStore->storeBlob(
  812. $content->serialize( $format ),
  813. // These hints "leak" some information from the higher abstraction layer to
  814. // low level storage to allow for optimization.
  815. array_merge(
  816. $blobHints,
  817. [
  818. BlobStore::DESIGNATION_HINT => 'page-content',
  819. BlobStore::ROLE_HINT => $slot->getRole(),
  820. BlobStore::SHA1_HINT => $slot->getSha1(),
  821. BlobStore::MODEL_HINT => $model,
  822. BlobStore::FORMAT_HINT => $format,
  823. ]
  824. )
  825. );
  826. }
  827. /**
  828. * @param SlotRecord $slot
  829. * @param IDatabase $dbw
  830. * @param int $revisionId
  831. * @param int $contentId
  832. */
  833. private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) {
  834. $slotRow = [
  835. 'slot_revision_id' => $revisionId,
  836. 'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ),
  837. 'slot_content_id' => $contentId,
  838. // If the slot has a specific origin use that ID, otherwise use the ID of the revision
  839. // that we just inserted.
  840. 'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId,
  841. ];
  842. $dbw->insert( 'slots', $slotRow, __METHOD__ );
  843. }
  844. /**
  845. * @param SlotRecord $slot
  846. * @param IDatabase $dbw
  847. * @param string $blobAddress
  848. * @return int content row ID
  849. */
  850. private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) {
  851. $contentRow = [
  852. 'content_size' => $slot->getSize(),
  853. 'content_sha1' => $slot->getSha1(),
  854. 'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ),
  855. 'content_address' => $blobAddress,
  856. ];
  857. $dbw->insert( 'content', $contentRow, __METHOD__ );
  858. return intval( $dbw->insertId() );
  859. }
  860. /**
  861. * MCR migration note: this corresponds to Revision::checkContentModel
  862. *
  863. * @param Content $content
  864. * @param Title $title
  865. * @param string $role
  866. *
  867. * @throws MWException
  868. * @throws MWUnknownContentModelException
  869. */
  870. private function checkContent( Content $content, Title $title, $role ) {
  871. // Note: may return null for revisions that have not yet been inserted
  872. $model = $content->getModel();
  873. $format = $content->getDefaultFormat();
  874. $handler = $content->getContentHandler();
  875. $name = "$title";
  876. if ( !$handler->isSupportedFormat( $format ) ) {
  877. throw new MWException( "Can't use format $format with content model $model on $name" );
  878. }
  879. if ( !$this->contentHandlerUseDB ) {
  880. // if $wgContentHandlerUseDB is not set,
  881. // all revisions must use the default content model and format.
  882. $this->assertCrossWikiContentLoadingIsSafe();
  883. $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
  884. $defaultModel = $roleHandler->getDefaultModel( $title );
  885. $defaultHandler = ContentHandler::getForModelID( $defaultModel );
  886. $defaultFormat = $defaultHandler->getDefaultFormat();
  887. if ( $model != $defaultModel ) {
  888. throw new MWException( "Can't save non-default content model with "
  889. . "\$wgContentHandlerUseDB disabled: model is $model, "
  890. . "default for $name is $defaultModel"
  891. );
  892. }
  893. if ( $format != $defaultFormat ) {
  894. throw new MWException( "Can't use non-default content format with "
  895. . "\$wgContentHandlerUseDB disabled: format is $format, "
  896. . "default for $name is $defaultFormat"
  897. );
  898. }
  899. }
  900. if ( !$content->isValid() ) {
  901. throw new MWException(
  902. "New content for $name is not valid! Content model is $model"
  903. );
  904. }
  905. }
  906. /**
  907. * Create a new null-revision for insertion into a page's
  908. * history. This will not re-save the text, but simply refer
  909. * to the text from the previous version.
  910. *
  911. * Such revisions can for instance identify page rename
  912. * operations and other such meta-modifications.
  913. *
  914. * @note This method grabs a FOR UPDATE lock on the relevant row of the page table,
  915. * to prevent a new revision from being inserted before the null revision has been written
  916. * to the database.
  917. *
  918. * MCR migration note: this replaces Revision::newNullRevision
  919. *
  920. * @todo Introduce newFromParentRevision(). newNullRevision can then be based on that
  921. * (or go away).
  922. *
  923. * @param IDatabase $dbw used for obtaining the lock on the page table row
  924. * @param Title $title Title of the page to read from
  925. * @param CommentStoreComment $comment RevisionRecord's summary
  926. * @param bool $minor Whether the revision should be considered as minor
  927. * @param User $user The user to attribute the revision to
  928. *
  929. * @return RevisionRecord|null RevisionRecord or null on error
  930. */
  931. public function newNullRevision(
  932. IDatabase $dbw,
  933. Title $title,
  934. CommentStoreComment $comment,
  935. $minor,
  936. User $user
  937. ) {
  938. $this->checkDatabaseDomain( $dbw );
  939. $pageId = $title->getArticleID();
  940. // T51581: Lock the page table row to ensure no other process
  941. // is adding a revision to the page at the same time.
  942. // Avoid locking extra tables, compare T191892.
  943. $pageLatest = $dbw->selectField(
  944. 'page',
  945. 'page_latest',
  946. [ 'page_id' => $pageId ],
  947. __METHOD__,
  948. [ 'FOR UPDATE' ]
  949. );
  950. if ( !$pageLatest ) {
  951. return null;
  952. }
  953. // Fetch the actual revision row from master, without locking all extra tables.
  954. $oldRevision = $this->loadRevisionFromConds(
  955. $dbw,
  956. [ 'rev_id' => intval( $pageLatest ) ],
  957. self::READ_LATEST,
  958. $title
  959. );
  960. if ( !$oldRevision ) {
  961. $msg = "Failed to load latest revision ID $pageLatest of page ID $pageId.";
  962. $this->logger->error(
  963. $msg,
  964. [ 'exception' => new RuntimeException( $msg ) ]
  965. );
  966. return null;
  967. }
  968. // Construct the new revision
  969. $timestamp = wfTimestampNow(); // TODO: use a callback, so we can override it for testing.
  970. $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision );
  971. $newRevision->setComment( $comment );
  972. $newRevision->setUser( $user );
  973. $newRevision->setTimestamp( $timestamp );
  974. $newRevision->setMinorEdit( $minor );
  975. return $newRevision;
  976. }
  977. /**
  978. * MCR migration note: this replaces Revision::isUnpatrolled
  979. *
  980. * @todo This is overly specific, so move or kill this method.
  981. *
  982. * @param RevisionRecord $rev
  983. *
  984. * @return int Rcid of the unpatrolled row, zero if there isn't one
  985. */
  986. public function getRcIdIfUnpatrolled( RevisionRecord $rev ) {
  987. $rc = $this->getRecentChange( $rev );
  988. if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) {
  989. return $rc->getAttribute( 'rc_id' );
  990. } else {
  991. return 0;
  992. }
  993. }
  994. /**
  995. * Get the RC object belonging to the current revision, if there's one
  996. *
  997. * MCR migration note: this replaces Revision::getRecentChange
  998. *
  999. * @todo move this somewhere else?
  1000. *
  1001. * @param RevisionRecord $rev
  1002. * @param int $flags (optional) $flags include:
  1003. * IDBAccessObject::READ_LATEST: Select the data from the master
  1004. *
  1005. * @return null|RecentChange
  1006. */
  1007. public function getRecentChange( RevisionRecord $rev, $flags = 0 ) {
  1008. list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags );
  1009. $db = $this->getDBConnectionRef( $dbType );
  1010. $userIdentity = $rev->getUser( RevisionRecord::RAW );
  1011. if ( !$userIdentity ) {
  1012. // If the revision has no user identity, chances are it never went
  1013. // into the database, and doesn't have an RC entry.
  1014. return null;
  1015. }
  1016. // TODO: Select by rc_this_oldid alone - but as of Nov 2017, there is no index on that!
  1017. $actorWhere = $this->actorMigration->getWhere( $db, 'rc_user', $rev->getUser(), false );
  1018. $rc = RecentChange::newFromConds(
  1019. [
  1020. $actorWhere['conds'],
  1021. 'rc_timestamp' => $db->timestamp( $rev->getTimestamp() ),
  1022. 'rc_this_oldid' => $rev->getId()
  1023. ],
  1024. __METHOD__,
  1025. $dbType
  1026. );
  1027. // XXX: cache this locally? Glue it to the RevisionRecord?
  1028. return $rc;
  1029. }
  1030. /**
  1031. * Maps fields of the archive row to corresponding revision rows.
  1032. *
  1033. * @param object $archiveRow
  1034. *
  1035. * @return object a revision row object, corresponding to $archiveRow.
  1036. */
  1037. private static function mapArchiveFields( $archiveRow ) {
  1038. $fieldMap = [
  1039. // keep with ar prefix:
  1040. 'ar_id' => 'ar_id',
  1041. // not the same suffix:
  1042. 'ar_page_id' => 'rev_page',
  1043. 'ar_rev_id' => 'rev_id',
  1044. // same suffix:
  1045. 'ar_text_id' => 'rev_text_id',
  1046. 'ar_timestamp' => 'rev_timestamp',
  1047. 'ar_user_text' => 'rev_user_text',
  1048. 'ar_user' => 'rev_user',
  1049. 'ar_actor' => 'rev_actor',
  1050. 'ar_minor_edit' => 'rev_minor_edit',
  1051. 'ar_deleted' => 'rev_deleted',
  1052. 'ar_len' => 'rev_len',
  1053. 'ar_parent_id' => 'rev_parent_id',
  1054. 'ar_sha1' => 'rev_sha1',
  1055. 'ar_comment' => 'rev_comment',
  1056. 'ar_comment_cid' => 'rev_comment_cid',
  1057. 'ar_comment_id' => 'rev_comment_id',
  1058. 'ar_comment_text' => 'rev_comment_text',
  1059. 'ar_comment_data' => 'rev_comment_data',
  1060. 'ar_comment_old' => 'rev_comment_old',
  1061. 'ar_content_format' => 'rev_content_format',
  1062. 'ar_content_model' => 'rev_content_model',
  1063. ];
  1064. $revRow = new stdClass();
  1065. foreach ( $fieldMap as $arKey => $revKey ) {
  1066. if ( property_exists( $archiveRow, $arKey ) ) {
  1067. $revRow->$revKey = $archiveRow->$arKey;
  1068. }
  1069. }
  1070. return $revRow;
  1071. }
  1072. /**
  1073. * Constructs a RevisionRecord for the revisions main slot, based on the MW1.29 schema.
  1074. *
  1075. * @param object|array $row Either a database row or an array
  1076. * @param int $queryFlags for callbacks
  1077. * @param Title $title
  1078. *
  1079. * @return SlotRecord The main slot, extracted from the MW 1.29 style row.
  1080. * @throws MWException
  1081. */
  1082. private function emulateMainSlot_1_29( $row, $queryFlags, Title $title ) {
  1083. $mainSlotRow = new stdClass();
  1084. $mainSlotRow->role_name = SlotRecord::MAIN;
  1085. $mainSlotRow->model_name = null;
  1086. $mainSlotRow->slot_revision_id = null;
  1087. $mainSlotRow->slot_content_id = null;
  1088. $mainSlotRow->content_address = null;
  1089. $content = null;
  1090. $blobData = null;
  1091. $blobFlags = null;
  1092. if ( is_object( $row ) ) {
  1093. if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) {
  1094. // Don't emulate from a row when using the new schema.
  1095. // Emulating from an array is still OK.
  1096. throw new LogicException( 'Can\'t emulate the main slot when using MCR schema.' );
  1097. }
  1098. // archive row
  1099. if ( !isset( $row->rev_id ) && ( isset( $row->ar_user ) || isset( $row->ar_actor ) ) ) {
  1100. $row = $this->mapArchiveFields( $row );
  1101. }
  1102. if ( isset( $row->rev_text_id ) && $row->rev_text_id > 0 ) {
  1103. $mainSlotRow->content_address = SqlBlobStore::makeAddressFromTextId(
  1104. $row->rev_text_id
  1105. );
  1106. }
  1107. // This is used by null-revisions
  1108. $mainSlotRow->slot_origin = isset( $row->slot_origin )
  1109. ? intval( $row->slot_origin )
  1110. : null;
  1111. if ( isset( $row->old_text ) ) {
  1112. // this happens when the text-table gets joined directly, in the pre-1.30 schema
  1113. $blobData = isset( $row->old_text ) ? strval( $row->old_text ) : null;
  1114. // Check against selects that might have not included old_flags
  1115. if ( !property_exists( $row, 'old_flags' ) ) {
  1116. throw new InvalidArgumentException( 'old_flags was not set in $row' );
  1117. }
  1118. $blobFlags = $row->old_flags ?? '';
  1119. }
  1120. $mainSlotRow->slot_revision_id = intval( $row->rev_id );
  1121. $mainSlotRow->content_size = isset( $row->rev_len ) ? intval( $row->rev_len ) : null;
  1122. $mainSlotRow->content_sha1 = isset( $row->rev_sha1 ) ? strval( $row->rev_sha1 ) : null;
  1123. $mainSlotRow->model_name = isset( $row->rev_content_model )
  1124. ? strval( $row->rev_content_model )
  1125. : null;
  1126. // XXX: in the future, we'll probably always use the default format, and drop content_format
  1127. $mainSlotRow->format_name = isset( $row->rev_content_format )
  1128. ? strval( $row->rev_content_format )
  1129. : null;
  1130. if ( isset( $row->rev_text_id ) && intval( $row->rev_text_id ) > 0 ) {
  1131. // Overwritten below for SCHEMA_COMPAT_WRITE_NEW
  1132. $mainSlotRow->slot_content_id
  1133. = $this->emulateContentId( intval( $row->rev_text_id ) );
  1134. }
  1135. } elseif ( is_array( $row ) ) {
  1136. $mainSlotRow->slot_revision_id = isset( $row['id'] ) ? intval( $row['id'] ) : null;
  1137. $mainSlotRow->slot_origin = isset( $row['slot_origin'] )
  1138. ? intval( $row['slot_origin'] )
  1139. : null;
  1140. $mainSlotRow->content_address = isset( $row['text_id'] )
  1141. ? SqlBlobStore::makeAddressFromTextId( intval( $row['text_id'] ) )
  1142. : null;
  1143. $mainSlotRow->content_size = isset( $row['len'] ) ? intval( $row['len'] ) : null;
  1144. $mainSlotRow->content_sha1 = isset( $row['sha1'] ) ? strval( $row['sha1'] ) : null;
  1145. $mainSlotRow->model_name = isset( $row['content_model'] )
  1146. ? strval( $row['content_model'] ) : null; // XXX: must be a string!
  1147. // XXX: in the future, we'll probably always use the default format, and drop content_format
  1148. $mainSlotRow->format_name = isset( $row['content_format'] )
  1149. ? strval( $row['content_format'] ) : null;
  1150. $blobData = isset( $row['text'] ) ? rtrim( strval( $row['text'] ) ) : null;
  1151. // XXX: If the flags field is not set then $blobFlags should be null so that no
  1152. // decoding will happen. An empty string will result in default decodings.
  1153. $blobFlags = isset( $row['flags'] ) ? trim( strval( $row['flags'] ) ) : null;
  1154. // if we have a Content object, override mText and mContentModel
  1155. if ( !empty( $row['content'] ) ) {
  1156. if ( !( $row['content'] instanceof Content ) ) {
  1157. throw new MWException( 'content field must contain a Content object.' );
  1158. }
  1159. /** @var Content $content */
  1160. $content = $row['content'];
  1161. $handler = $content->getContentHandler();
  1162. $mainSlotRow->model_name = $content->getModel();
  1163. // XXX: in the future, we'll probably always use the default format.
  1164. if ( $mainSlotRow->format_name === null ) {
  1165. $mainSlotRow->format_name = $handler->getDefaultFormat();
  1166. }
  1167. }
  1168. if ( isset( $row['text_id'] ) && intval( $row['text_id'] ) > 0 ) {
  1169. // Overwritten below for SCHEMA_COMPAT_WRITE_NEW
  1170. $mainSlotRow->slot_content_id
  1171. = $this->emulateContentId( intval( $row['text_id'] ) );
  1172. }
  1173. } else {
  1174. throw new MWException( 'Revision constructor passed invalid row format.' );
  1175. }
  1176. // With the old schema, the content changes with every revision,
  1177. // except for null-revisions.
  1178. if ( !isset( $mainSlotRow->slot_origin ) ) {
  1179. $mainSlotRow->slot_origin = $mainSlotRow->slot_revision_id;
  1180. }
  1181. if ( $mainSlotRow->model_name === null ) {
  1182. $mainSlotRow->model_name = function ( SlotRecord $slot ) use ( $title ) {
  1183. $this->assertCrossWikiContentLoadingIsSafe();
  1184. return $this->slotRoleRegistry->getRoleHandler( $slot->getRole() )
  1185. ->getDefaultModel( $title );
  1186. };
  1187. }
  1188. if ( !$content ) {
  1189. // XXX: We should perhaps fail if $blobData is null and $mainSlotRow->content_address
  1190. // is missing, but "empty revisions" with no content are used in some edge cases.
  1191. $content = function ( SlotRecord $slot )
  1192. use ( $blobData, $blobFlags, $queryFlags, $mainSlotRow )
  1193. {
  1194. return $this->loadSlotContent(
  1195. $slot,
  1196. $blobData,
  1197. $blobFlags,
  1198. $mainSlotRow->format_name,
  1199. $queryFlags
  1200. );
  1201. };
  1202. }
  1203. if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
  1204. // NOTE: this callback will be looped through RevisionSlot::newInherited(), allowing
  1205. // the inherited slot to have the same content_id as the original slot. In that case,
  1206. // $slot will be the inherited slot, while $mainSlotRow still refers to the original slot.
  1207. $mainSlotRow->slot_content_id =
  1208. function ( SlotRecord $slot ) use ( $queryFlags, $mainSlotRow ) {
  1209. $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
  1210. return $this->findSlotContentId( $db, $mainSlotRow->slot_revision_id, SlotRecord::MAIN );
  1211. };
  1212. }
  1213. return new SlotRecord( $mainSlotRow, $content );
  1214. }
  1215. /**
  1216. * Provides a content ID to use with emulated SlotRecords in SCHEMA_COMPAT_OLD mode,
  1217. * based on the revision's text ID (rev_text_id or ar_text_id, respectively).
  1218. * Note that in SCHEMA_COMPAT_WRITE_BOTH, a callback to findSlotContentId() should be used
  1219. * instead, since in that mode, some revision rows may already have a real content ID,
  1220. * while other's don't - and for the ones that don't, we should indicate that it
  1221. * is missing and cause SlotRecords::hasContentId() to return false.
  1222. *
  1223. * @param int $textId
  1224. * @return int The emulated content ID
  1225. */
  1226. private function emulateContentId( $textId ) {
  1227. // Return a negative number to ensure the ID is distinct from any real content IDs
  1228. // that will be assigned in SCHEMA_COMPAT_WRITE_NEW mode and read in SCHEMA_COMPAT_READ_NEW
  1229. // mode.
  1230. return -$textId;
  1231. }
  1232. /**
  1233. * Loads a Content object based on a slot row.
  1234. *
  1235. * This method does not call $slot->getContent(), and may be used as a callback
  1236. * called by $slot->getContent().
  1237. *
  1238. * MCR migration note: this roughly corresponds to Revision::getContentInternal
  1239. *
  1240. * @param SlotRecord $slot The SlotRecord to load content for
  1241. * @param string|null $blobData The content blob, in the form indicated by $blobFlags
  1242. * @param string|null $blobFlags Flags indicating how $blobData needs to be processed.
  1243. * Use null if no processing should happen. That is in constrast to the empty string,
  1244. * which causes the blob to be decoded according to the configured legacy encoding.
  1245. * @param string|null $blobFormat MIME type indicating how $dataBlob is encoded
  1246. * @param int $queryFlags
  1247. *
  1248. * @throws RevisionAccessException
  1249. * @return Content
  1250. */
  1251. private function loadSlotContent(
  1252. SlotRecord $slot,
  1253. $blobData = null,
  1254. $blobFlags = null,
  1255. $blobFormat = null,
  1256. $queryFlags = 0
  1257. ) {
  1258. if ( $blobData !== null ) {
  1259. Assert::parameterType( 'string', $blobData, '$blobData' );
  1260. Assert::parameterType( 'string|null', $blobFlags, '$blobFlags' );
  1261. $cacheKey = $slot->hasAddress() ? $slot->getAddress() : null;
  1262. if ( $blobFlags === null ) {
  1263. // No blob flags, so use the blob verbatim.
  1264. $data = $blobData;
  1265. } else {
  1266. $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $cacheKey );
  1267. if ( $data === false ) {
  1268. throw new RevisionAccessException(
  1269. "Failed to expand blob data using flags $blobFlags (key: $cacheKey)"
  1270. );
  1271. }
  1272. }
  1273. } else {
  1274. $address = $slot->getAddress();
  1275. try {
  1276. $data = $this->blobStore->getBlob( $address, $queryFlags );
  1277. } catch ( BlobAccessException $e ) {
  1278. throw new RevisionAccessException(
  1279. "Failed to load data blob from $address: " . $e->getMessage(), 0, $e
  1280. );
  1281. }
  1282. }
  1283. // Unserialize content
  1284. $handler = ContentHandler::getForModelID( $slot->getModel() );
  1285. $content = $handler->unserializeContent( $data, $blobFormat );
  1286. return $content;
  1287. }
  1288. /**
  1289. * Load a page revision from a given revision ID number.
  1290. * Returns null if no such revision can be found.
  1291. *
  1292. * MCR migration note: this replaces Revision::newFromId
  1293. *
  1294. * $flags include:
  1295. * IDBAccessObject::READ_LATEST: Select the data from the master
  1296. * IDBAccessObject::READ_LOCKING : Select & lock the data from the master
  1297. *
  1298. * @param int $id
  1299. * @param int $flags (optional)
  1300. * @return RevisionRecord|null
  1301. */
  1302. public function getRevisionById( $id, $flags = 0 ) {
  1303. return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags );
  1304. }
  1305. /**
  1306. * Load either the current, or a specified, revision
  1307. * that's attached to a given link target. If not attached
  1308. * to that link target, will return null.
  1309. *
  1310. * MCR migration note: this replaces Revision::newFromTitle
  1311. *
  1312. * $flags include:
  1313. * IDBAccessObject::READ_LATEST: Select the data from the master
  1314. * IDBAccessObject::READ_LOCKING : Select & lock the data from the master
  1315. *
  1316. * @param LinkTarget $linkTarget
  1317. * @param int $revId (optional)
  1318. * @param int $flags Bitfield (optional)
  1319. * @return RevisionRecord|null
  1320. */
  1321. public function getRevisionByTitle( LinkTarget $linkTarget, $revId = 0, $flags = 0 ) {
  1322. // TODO should not require Title in future (T206498)
  1323. $title = Title::newFromLinkTarget( $linkTarget );
  1324. $conds = [
  1325. 'page_namespace' => $title->getNamespace(),
  1326. 'page_title' => $title->getDBkey()
  1327. ];
  1328. if ( $revId ) {
  1329. // Use the specified revision ID.
  1330. // Note that we use newRevisionFromConds here because we want to retry
  1331. // and fall back to master if the page is not found on a replica.
  1332. // Since the caller supplied a revision ID, we are pretty sure the revision is
  1333. // supposed to exist, so we should try hard to find it.
  1334. $conds['rev_id'] = $revId;
  1335. return $this->newRevisionFromConds( $conds, $flags, $title );
  1336. } else {
  1337. // Use a join to get the latest revision.
  1338. // Note that we don't use newRevisionFromConds here because we don't want to retry
  1339. // and fall back to master. The assumption is that we only want to force the fallback
  1340. // if we are quite sure the revision exists because the caller supplied a revision ID.
  1341. // If the page isn't found at all on a replica, it probably simply does not exist.
  1342. $db = $this->getDBConnectionRefForQueryFlags( $flags );
  1343. $conds[] = 'rev_id=page_latest';
  1344. $rev = $this->loadRevisionFromConds( $db, $conds, $flags, $title );
  1345. return $rev;
  1346. }
  1347. }
  1348. /**
  1349. * Load either the current, or a specified, revision
  1350. * that's attached to a given page ID.
  1351. * Returns null if no such revision can be found.
  1352. *
  1353. * MCR migration note: this replaces Revision::newFromPageId
  1354. *
  1355. * $flags include:
  1356. * IDBAccessObject::READ_LATEST: Select the data from the master (since 1.20)
  1357. * IDBAccessObject::READ_LOCKING : Select & lock the data from the master
  1358. *
  1359. * @param int $pageId
  1360. * @param int $revId (optional)
  1361. * @param int $flags Bitfield (optional)
  1362. * @return RevisionRecord|null
  1363. */
  1364. public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) {
  1365. $conds = [ 'page_id' => $pageId ];
  1366. if ( $revId ) {
  1367. // Use the specified revision ID.
  1368. // Note that we use newRevisionFromConds here because we want to retry
  1369. // and fall back to master if the page is not found on a replica.
  1370. // Since the caller supplied a revision ID, we are pretty sure the revision is
  1371. // supposed to exist, so we should try hard to find it.
  1372. $conds['rev_id'] = $revId;
  1373. return $this->newRevisionFromConds( $conds, $flags );
  1374. } else {
  1375. // Use a join to get the latest revision.
  1376. // Note that we don't use newRevisionFromConds here because we don't want to retry
  1377. // and fall back to master. The assumption is that we only want to force the fallback
  1378. // if we are quite sure the revision exists because the caller supplied a revision ID.
  1379. // If the page isn't found at all on a replica, it probably simply does not exist.
  1380. $db = $this->getDBConnectionRefForQueryFlags( $flags );
  1381. $conds[] = 'rev_id=page_latest';
  1382. $rev = $this->loadRevisionFromConds( $db, $conds, $flags );
  1383. return $rev;
  1384. }
  1385. }
  1386. /**
  1387. * Load the revision for the given title with the given timestamp.
  1388. * WARNING: Timestamps may in some circumstances not be unique,
  1389. * so this isn't the best key to use.
  1390. *
  1391. * MCR migration note: this replaces Revision::loadFromTimestamp
  1392. *
  1393. * @param Title $title
  1394. * @param string $timestamp
  1395. * @return RevisionRecord|null
  1396. */
  1397. public function getRevisionByTimestamp( $title, $timestamp ) {
  1398. $db = $this->getDBConnectionRef( DB_REPLICA );
  1399. return $this->newRevisionFromConds(
  1400. [
  1401. 'rev_timestamp' => $db->timestamp( $timestamp ),
  1402. 'page_namespace' => $title->getNamespace(),
  1403. 'page_title' => $title->getDBkey()
  1404. ],
  1405. 0,
  1406. $title
  1407. );
  1408. }
  1409. /**
  1410. * @param int $revId The revision to load slots for.
  1411. * @param int $queryFlags
  1412. * @param Title $title
  1413. *
  1414. * @return SlotRecord[]
  1415. */
  1416. private function loadSlotRecords( $revId, $queryFlags, Title $title ) {
  1417. $revQuery = self::getSlotsQueryInfo( [ 'content' ] );
  1418. list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
  1419. $db = $this->getDBConnectionRef( $dbMode );
  1420. $res = $db->select(
  1421. $revQuery['tables'],
  1422. $revQuery['fields'],
  1423. [
  1424. 'slot_revision_id' => $revId,
  1425. ],
  1426. __METHOD__,
  1427. $dbOptions,
  1428. $revQuery['joins']
  1429. );
  1430. $slots = $this->constructSlotRecords( $revId, $res, $queryFlags, $title );
  1431. return $slots;
  1432. }
  1433. /**
  1434. * Factory method for SlotRecords based on known slot rows.
  1435. *
  1436. * @param int $revId The revision to load slots for.
  1437. * @param object[]|IResultWrapper $slotRows
  1438. * @param int $queryFlags
  1439. * @param Title $title
  1440. * @param array|null $slotContents a map from blobAddress to slot
  1441. * content blob or Content object.
  1442. *
  1443. * @return SlotRecord[]
  1444. */
  1445. private function constructSlotRecords(
  1446. $revId,
  1447. $slotRows,
  1448. $queryFlags,
  1449. Title $title,
  1450. $slotContents = null
  1451. ) {
  1452. $slots = [];
  1453. foreach ( $slotRows as $row ) {
  1454. // Resolve role names and model names from in-memory cache, if they were not joined in.
  1455. if ( !isset( $row->role_name ) ) {
  1456. $row->role_name = $this->slotRoleStore->getName( (int)$row->slot_role_id );
  1457. }
  1458. if ( !isset( $row->model_name ) ) {
  1459. if ( isset( $row->content_model ) ) {
  1460. $row->model_name = $this->contentModelStore->getName( (int)$row->content_model );
  1461. } else {
  1462. // We may get here if $row->model_name is set but null, perhaps because it
  1463. // came from rev_content_model, which is NULL for the default model.
  1464. $slotRoleHandler = $this->slotRoleRegistry->getRoleHandler( $row->role_name );
  1465. $row->model_name = $slotRoleHandler->getDefaultModel( $title );
  1466. }
  1467. }
  1468. if ( !isset( $row->content_id ) && isset( $row->rev_text_id ) ) {
  1469. $row->slot_content_id
  1470. = $this->emulateContentId( intval( $row->rev_text_id ) );
  1471. }
  1472. // We may have a fake blob_data field from getSlotRowsForBatch(), use it!
  1473. if ( isset( $row->blob_data ) ) {
  1474. $slotContents[$row->content_address] = $row->blob_data;
  1475. }
  1476. $contentCallback = function ( SlotRecord $slot ) use ( $slotContents, $queryFlags ) {
  1477. $blob = null;
  1478. if ( isset( $slotContents[$slot->getAddress()] ) ) {
  1479. $blob = $slotContents[$slot->getAddress()];
  1480. if ( $blob instanceof Content ) {
  1481. return $blob;
  1482. }
  1483. }
  1484. return $this->loadSlotContent( $slot, $blob, null, null, $queryFlags );
  1485. };
  1486. $slots[$row->role_name] = new SlotRecord( $row, $contentCallback );
  1487. }
  1488. if ( !isset( $slots[SlotRecord::MAIN] ) ) {
  1489. throw new RevisionAccessException(
  1490. 'Main slot of revision ' . $revId . ' not found in database!'
  1491. );
  1492. }
  1493. return $slots;
  1494. }
  1495. /**
  1496. * Factory method for RevisionSlots based on a revision ID.
  1497. *
  1498. * @note If other code has a need to construct RevisionSlots objects, this should be made
  1499. * public, since RevisionSlots instances should not be constructed directly.
  1500. *
  1501. * @param int $revId
  1502. * @param object $revisionRow
  1503. * @param object[]|null $slotRows
  1504. * @param int $queryFlags
  1505. * @param Title $title
  1506. *
  1507. * @return RevisionSlots
  1508. * @throws MWException
  1509. */
  1510. private function newRevisionSlots(
  1511. $revId,
  1512. $revisionRow,
  1513. $slotRows,
  1514. $queryFlags,
  1515. Title $title
  1516. ) {
  1517. if ( $slotRows ) {
  1518. $slots = new RevisionSlots(
  1519. $this->constructSlotRecords( $revId, $slotRows, $queryFlags, $title )
  1520. );
  1521. } elseif ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) {
  1522. $mainSlot = $this->emulateMainSlot_1_29( $revisionRow, $queryFlags, $title );
  1523. // @phan-suppress-next-line PhanTypeInvalidCallableArraySize false positive
  1524. $slots = new RevisionSlots( [ SlotRecord::MAIN => $mainSlot ] );
  1525. } else {
  1526. // XXX: do we need the same kind of caching here
  1527. // that getKnownCurrentRevision uses (if $revId == page_latest?)
  1528. $slots = new RevisionSlots( function () use( $revId, $queryFlags, $title ) {
  1529. return $this->loadSlotRecords( $revId, $queryFlags, $title );
  1530. } );
  1531. }
  1532. return $slots;
  1533. }
  1534. /**
  1535. * Make a fake revision object from an archive table row. This is queried
  1536. * for permissions or even inserted (as in Special:Undelete)
  1537. *
  1538. * MCR migration note: this replaces Revision::newFromArchiveRow
  1539. *
  1540. * @param object $row
  1541. * @param int $queryFlags
  1542. * @param Title|null $title
  1543. * @param array $overrides associative array with fields of $row to override. This may be
  1544. * used e.g. to force the parent revision ID or page ID. Keys in the array are fields
  1545. * names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to
  1546. * override ar_parent_id.
  1547. *
  1548. * @return RevisionRecord
  1549. * @throws MWException
  1550. */
  1551. public function newRevisionFromArchiveRow(
  1552. $row,
  1553. $queryFlags = 0,
  1554. Title $title = null,
  1555. array $overrides = []
  1556. ) {
  1557. Assert::parameterType( 'object', $row, '$row' );
  1558. // check second argument, since Revision::newFromArchiveRow had $overrides in that spot.
  1559. Assert::parameterType( 'integer', $queryFlags, '$queryFlags' );
  1560. if ( !$title && isset( $overrides['title'] ) ) {
  1561. if ( !( $overrides['title'] instanceof Title ) ) {
  1562. throw new MWException( 'title field override must contain a Title object.' );
  1563. }
  1564. $title = $overrides['title'];
  1565. }
  1566. if ( !isset( $title ) ) {
  1567. if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) {
  1568. $title = Title::makeTitle( $row->ar_namespace, $row->ar_title );
  1569. } else {
  1570. throw new InvalidArgumentException(
  1571. 'A Title or ar_namespace and ar_title must be given'
  1572. );
  1573. }
  1574. }
  1575. foreach ( $overrides as $key => $value ) {
  1576. $field = "ar_$key";
  1577. $row->$field = $value;
  1578. }
  1579. try {
  1580. $user = User::newFromAnyId(
  1581. $row->ar_user ?? null,
  1582. $row->ar_user_text ?? null,
  1583. $row->ar_actor ?? null,
  1584. $this->dbDomain
  1585. );
  1586. } catch ( InvalidArgumentException $ex ) {
  1587. wfWarn( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ': ' . $ex->getMessage() );
  1588. $user = new UserIdentityValue( 0, 'Unknown user', 0 );
  1589. }
  1590. $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
  1591. // Legacy because $row may have come from self::selectFields()
  1592. $comment = $this->commentStore->getCommentLegacy( $db, 'ar_comment', $row, true );
  1593. $slots = $this->newRevisionSlots( $row->ar_rev_id, $row, null, $queryFlags, $title );
  1594. return new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $this->dbDomain );
  1595. }
  1596. /**
  1597. * @see RevisionFactory::newRevisionFromRow
  1598. *
  1599. * MCR migration note: this replaces Revision::newFromRow
  1600. *
  1601. * @param object $row A database row generated from a query based on getQueryInfo()
  1602. * @param int $queryFlags
  1603. * @param Title|null $title
  1604. * @param bool $fromCache if true, the returned RevisionRecord will ensure that no stale
  1605. * data is returned from getters, by querying the database as needed
  1606. * @return RevisionRecord
  1607. */
  1608. public function newRevisionFromRow(
  1609. $row,
  1610. $queryFlags = 0,
  1611. Title $title = null,
  1612. $fromCache = false
  1613. ) {
  1614. return $this->newRevisionFromRowAndSlots( $row, null, $queryFlags, $title, $fromCache );
  1615. }
  1616. /**
  1617. * @param object $row A database row generated from a query based on getQueryInfo()
  1618. * @param null|object[]|RevisionSlots $slots
  1619. * - Database rows generated from a query based on getSlotsQueryInfo
  1620. * with the 'content' flag set. Or
  1621. * - RevisionSlots instance
  1622. * @param int $queryFlags
  1623. * @param Title|null $title
  1624. * @param bool $fromCache if true, the returned RevisionRecord will ensure that no stale
  1625. * data is returned from getters, by querying the database as needed
  1626. *
  1627. * @return RevisionRecord
  1628. * @throws MWException
  1629. * @see RevisionFactory::newRevisionFromRow
  1630. *
  1631. * MCR migration note: this replaces Revision::newFromRow
  1632. */
  1633. public function newRevisionFromRowAndSlots(
  1634. $row,
  1635. $slots,
  1636. $queryFlags = 0,
  1637. Title $title = null,
  1638. $fromCache = false
  1639. ) {
  1640. Assert::parameterType( 'object', $row, '$row' );
  1641. if ( !$title ) {
  1642. $pageId = $row->rev_page ?? 0; // XXX: also check page_id?
  1643. $revId = $row->rev_id ?? 0;
  1644. $title = $this->getTitle( $pageId, $revId, $queryFlags );
  1645. }
  1646. if ( !isset( $row->page_latest ) ) {
  1647. $row->page_latest = $title->getLatestRevID();
  1648. if ( $row->page_latest === 0 && $title->exists() ) {
  1649. wfWarn( 'Encountered title object in limbo: ID ' . $title->getArticleID() );
  1650. }
  1651. }
  1652. try {
  1653. $user = User::newFromAnyId(
  1654. $row->rev_user ?? null,
  1655. $row->rev_user_text ?? null,
  1656. $row->rev_actor ?? null,
  1657. $this->dbDomain
  1658. );
  1659. } catch ( InvalidArgumentException $ex ) {
  1660. wfWarn( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ': ' . $ex->getMessage() );
  1661. $user = new UserIdentityValue( 0, 'Unknown user', 0 );
  1662. }
  1663. $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
  1664. // Legacy because $row may have come from self::selectFields()
  1665. $comment = $this->commentStore->getCommentLegacy( $db, 'rev_comment', $row, true );
  1666. if ( !( $slots instanceof RevisionSlots ) ) {
  1667. $slots = $this->newRevisionSlots( $row->rev_id, $row, $slots, $queryFlags, $title );
  1668. }
  1669. // If this is a cached row, instantiate a cache-aware revision class to avoid stale data.
  1670. if ( $fromCache ) {
  1671. $rev = new RevisionStoreCacheRecord(
  1672. function ( $revId ) use ( $queryFlags ) {
  1673. $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
  1674. return $this->fetchRevisionRowFromConds(
  1675. $db,
  1676. [ 'rev_id' => intval( $revId ) ]
  1677. );
  1678. },
  1679. $title, $user, $comment, $row, $slots, $this->dbDomain
  1680. );
  1681. } else {
  1682. $rev = new RevisionStoreRecord(
  1683. $title, $user, $comment, $row, $slots, $this->dbDomain );
  1684. }
  1685. return $rev;
  1686. }
  1687. /**
  1688. * Construct a RevisionRecord instance for each row in $rows,
  1689. * and return them as an associative array indexed by revision ID.
  1690. * @param Traversable|array $rows the rows to construct revision records from
  1691. * @param array $options Supports the following options:
  1692. * 'slots' - whether metadata about revision slots should be
  1693. * loaded immediately. Supports falsy or truthy value as well
  1694. * as an explicit list of slot role names. The main slot will
  1695. * always be loaded.
  1696. * 'content'- whether the actual content of the slots should be
  1697. * preloaded.
  1698. * @param int $queryFlags
  1699. * @param Title|null $title The title to which all the revision rows belong, if there
  1700. * is such a title and the caller has it handy, so we don't have to look it up again.
  1701. * If this parameter is given and any of the rows has a rev_page_id that is different
  1702. * from $title->getArticleID(), an InvalidArgumentException is thrown.
  1703. *
  1704. * @return StatusValue a status with a RevisionRecord[] of successfully fetched revisions
  1705. * and an array of errors for the revisions failed to fetch.
  1706. */
  1707. public function newRevisionsFromBatch(
  1708. $rows,
  1709. array $options = [],
  1710. $queryFlags = 0,
  1711. Title $title = null
  1712. ) {
  1713. $result = new StatusValue();
  1714. $rowsByRevId = [];
  1715. $pageIdsToFetchTitles = [];
  1716. $titlesByPageId = [];
  1717. foreach ( $rows as $row ) {
  1718. if ( isset( $rowsByRevId[$row->rev_id] ) ) {
  1719. $result->warning(
  1720. 'internalerror',
  1721. "Duplicate rows in newRevisionsFromBatch, rev_id {$row->rev_id}"
  1722. );
  1723. }
  1724. if ( $title && $row->rev_page != $title->getArticleID() ) {
  1725. throw new InvalidArgumentException(
  1726. "Revision {$row->rev_id} doesn't belong to page {$title->getArticleID()}"
  1727. );
  1728. } elseif ( !$title && !isset( $titlesByPageId[ $row->rev_page ] ) ) {
  1729. if ( isset( $row->page_namespace ) && isset( $row->page_title ) &&
  1730. // This should not happen, but just in case we don't have a page_id
  1731. // set or it doesn't match rev_page, let's fetch the title again.
  1732. isset( $row->page_id ) && $row->rev_page === $row->page_id
  1733. ) {
  1734. $titlesByPageId[ $row->rev_page ] = Title::newFromRow( $row );
  1735. } else {
  1736. $pageIdsToFetchTitles[] = $row->rev_page;
  1737. }
  1738. }
  1739. $rowsByRevId[$row->rev_id] = $row;
  1740. }
  1741. if ( empty( $rowsByRevId ) ) {
  1742. $result->setResult( true, [] );
  1743. return $result;
  1744. }
  1745. // If the title is not supplied, batch-fetch Title objects.
  1746. if ( $title ) {
  1747. $titlesByPageId[$title->getArticleID()] = $title;
  1748. } elseif ( !empty( $pageIdsToFetchTitles ) ) {
  1749. $pageIdsToFetchTitles = array_unique( $pageIdsToFetchTitles );
  1750. foreach ( Title::newFromIDs( $pageIdsToFetchTitles ) as $t ) {
  1751. $titlesByPageId[$t->getArticleID()] = $t;
  1752. }
  1753. }
  1754. if ( !isset( $options['slots'] ) || $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
  1755. $result->setResult( true,
  1756. array_map( function ( $row ) use ( $queryFlags, $titlesByPageId, $result ) {
  1757. try {
  1758. return $this->newRevisionFromRow(
  1759. $row,
  1760. $queryFlags,
  1761. $titlesByPageId[$row->rev_page]
  1762. );
  1763. } catch ( MWException $e ) {
  1764. $result->warning( 'internalerror', $e->getMessage() );
  1765. return null;
  1766. }
  1767. }, $rowsByRevId )
  1768. );
  1769. return $result;
  1770. }
  1771. $slotRowOptions = [
  1772. 'slots' => $options['slots'] ?? true,
  1773. 'blobs' => $options['content'] ?? false,
  1774. ];
  1775. if ( is_array( $slotRowOptions['slots'] )
  1776. && !in_array( SlotRecord::MAIN, $slotRowOptions['slots'] )
  1777. ) {
  1778. // Make sure the main slot is always loaded, RevisionRecord requires this.
  1779. $slotRowOptions['slots'][] = SlotRecord::MAIN;
  1780. }
  1781. $slotRowsStatus = $this->getSlotRowsForBatch( $rowsByRevId, $slotRowOptions, $queryFlags );
  1782. $result->merge( $slotRowsStatus );
  1783. $slotRowsByRevId = $slotRowsStatus->getValue();
  1784. $result->setResult( true, array_map( function ( $row ) use
  1785. ( $slotRowsByRevId, $queryFlags, $titlesByPageId, $result ) {
  1786. if ( !isset( $slotRowsByRevId[$row->rev_id] ) ) {
  1787. $result->warning(
  1788. 'internalerror',
  1789. "Couldn't find slots for rev {$row->rev_id}"
  1790. );
  1791. return null;
  1792. }
  1793. try {
  1794. return $this->newRevisionFromRowAndSlots(
  1795. $row,
  1796. new RevisionSlots(
  1797. $this->constructSlotRecords(
  1798. $row->rev_id,
  1799. $slotRowsByRevId[$row->rev_id],
  1800. $queryFlags,
  1801. $titlesByPageId[$row->rev_page]
  1802. )
  1803. ),
  1804. $queryFlags,
  1805. $titlesByPageId[$row->rev_page]
  1806. );
  1807. } catch ( MWException $e ) {
  1808. $result->warning( 'internalerror', $e->getMessage() );
  1809. return null;
  1810. }
  1811. }, $rowsByRevId ) );
  1812. return $result;
  1813. }
  1814. /**
  1815. * Gets the slot rows associated with a batch of revisions.
  1816. * The serialized content of each slot can be included by setting the 'blobs' option.
  1817. * Callers are responsible for unserializing and interpreting the content blobs
  1818. * based on the model_name and role_name fields.
  1819. *
  1820. * @param Traversable|array $rowsOrIds list of revision ids, or revision rows from a db query.
  1821. * @param array $options Supports the following options:
  1822. * 'slots' - a list of slot role names to fetch. If omitted or true or null,
  1823. * all slots are fetched
  1824. * 'blobs'- whether the serialized content of each slot should be loaded.
  1825. * If true, the serialiezd content will be present in the slot row
  1826. * in the blob_data field.
  1827. * @param int $queryFlags
  1828. *
  1829. * @return StatusValue a status containing, if isOK() returns true, a two-level nested
  1830. * associative array, mapping from revision ID to an associative array that maps from
  1831. * role name to a database row object. The database row object will contain the fields
  1832. * defined by getSlotQueryInfo() with the 'content' flag set, plus the blob_data field
  1833. * if the 'blobs' is set in $options. The model_name and role_name fields will also be
  1834. * set.
  1835. */
  1836. private function getSlotRowsForBatch(
  1837. $rowsOrIds,
  1838. array $options = [],
  1839. $queryFlags = 0
  1840. ) {
  1841. $readNew = $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW );
  1842. $result = new StatusValue();
  1843. $revIds = [];
  1844. foreach ( $rowsOrIds as $row ) {
  1845. $revIds[] = is_object( $row ) ? (int)$row->rev_id : (int)$row;
  1846. }
  1847. // Nothing to do.
  1848. // Note that $rowsOrIds may not be "empty" even if $revIds is, e.g. if it's a ResultWrapper.
  1849. if ( empty( $revIds ) ) {
  1850. $result->setResult( true, [] );
  1851. return $result;
  1852. }
  1853. // We need to set the `content` flag to join in content meta-data
  1854. $slotQueryInfo = self::getSlotsQueryInfo( [ 'content' ] );
  1855. $revIdField = $slotQueryInfo['keys']['rev_id'];
  1856. $slotQueryConds = [ $revIdField => $revIds ];
  1857. if ( $readNew && isset( $options['slots'] ) && is_array( $options['slots'] ) ) {
  1858. if ( empty( $options['slots'] ) ) {
  1859. // Degenerate case: return no slots for each revision.
  1860. $result->setResult( true, array_fill_keys( $revIds, [] ) );
  1861. return $result;
  1862. }
  1863. $roleIdField = $slotQueryInfo['keys']['role_id'];
  1864. $slotQueryConds[$roleIdField] = array_map( function ( $slot_name ) {
  1865. return $this->slotRoleStore->getId( $slot_name );
  1866. }, $options['slots'] );
  1867. }
  1868. $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
  1869. $slotRows = $db->select(
  1870. $slotQueryInfo['tables'],
  1871. $slotQueryInfo['fields'],
  1872. $slotQueryConds,
  1873. __METHOD__,
  1874. [],
  1875. $slotQueryInfo['joins']
  1876. );
  1877. $slotContents = null;
  1878. if ( $options['blobs'] ?? false ) {
  1879. $blobAddresses = [];
  1880. foreach ( $slotRows as $slotRow ) {
  1881. $blobAddresses[] = $slotRow->content_address;
  1882. }
  1883. $slotContentFetchStatus = $this->blobStore
  1884. ->getBlobBatch( $blobAddresses, $queryFlags );
  1885. foreach ( $slotContentFetchStatus->getErrors() as $error ) {
  1886. $result->warning( $error['message'], ...$error['params'] );
  1887. }
  1888. $slotContents = $slotContentFetchStatus->getValue();
  1889. }
  1890. $slotRowsByRevId = [];
  1891. foreach ( $slotRows as $slotRow ) {
  1892. if ( $slotContents === null ) {
  1893. // nothing to do
  1894. } elseif ( isset( $slotContents[$slotRow->content_address] ) ) {
  1895. $slotRow->blob_data = $slotContents[$slotRow->content_address];
  1896. } else {
  1897. $result->warning(
  1898. 'internalerror',
  1899. "Couldn't find blob data for rev {$slotRow->slot_revision_id}"
  1900. );
  1901. $slotRow->blob_data = null;
  1902. }
  1903. // conditional needed for SCHEMA_COMPAT_READ_OLD
  1904. if ( !isset( $slotRow->role_name ) && isset( $slotRow->slot_role_id ) ) {
  1905. $slotRow->role_name = $this->slotRoleStore->getName( (int)$slotRow->slot_role_id );
  1906. }
  1907. // conditional needed for SCHEMA_COMPAT_READ_OLD
  1908. if ( !isset( $slotRow->model_name ) && isset( $slotRow->content_model ) ) {
  1909. $slotRow->model_name = $this->contentModelStore->getName( (int)$slotRow->content_model );
  1910. }
  1911. $slotRowsByRevId[$slotRow->slot_revision_id][$slotRow->role_name] = $slotRow;
  1912. }
  1913. $result->setResult( true, $slotRowsByRevId );
  1914. return $result;
  1915. }
  1916. /**
  1917. * Gets raw (serialized) content blobs for the given set of revisions.
  1918. * Callers are responsible for unserializing and interpreting the content blobs
  1919. * based on the model_name field and the slot role.
  1920. *
  1921. * This method is intended for bulk operations in maintenance scripts.
  1922. * It may be chosen over newRevisionsFromBatch by code that are only interested
  1923. * in raw content, as opposed to meta data. Code that needs to access meta data of revisions,
  1924. * slots, or content objects should use newRevisionsFromBatch() instead.
  1925. *
  1926. * @param Traversable|array $rowsOrIds list of revision ids, or revision rows from a db query.
  1927. * @param array|null $slots the role names for which to get slots.
  1928. * @param int $queryFlags
  1929. *
  1930. * @return StatusValue a status containing, if isOK() returns true, a two-level nested
  1931. * associative array, mapping from revision ID to an associative array that maps from
  1932. * role name to an anonymous object object containing two fields:
  1933. * - model_name: the name of the content's model
  1934. * - blob_data: serialized content data
  1935. */
  1936. public function getContentBlobsForBatch(
  1937. $rowsOrIds,
  1938. $slots = null,
  1939. $queryFlags = 0
  1940. ) {
  1941. $result = $this->getSlotRowsForBatch(
  1942. $rowsOrIds,
  1943. [ 'slots' => $slots, 'blobs' => true ],
  1944. $queryFlags
  1945. );
  1946. if ( $result->isOK() ) {
  1947. // strip out all internal meta data that we don't want to expose
  1948. foreach ( $result->value as $revId => $rowsByRole ) {
  1949. foreach ( $rowsByRole as $role => $slotRow ) {
  1950. if ( is_array( $slots ) && !in_array( $role, $slots ) ) {
  1951. // In SCHEMA_COMPAT_READ_OLD mode we may get the main slot even
  1952. // if we didn't ask for it.
  1953. unset( $result->value[$revId][$role] );
  1954. continue;
  1955. }
  1956. $result->value[$revId][$role] = (object)[
  1957. 'blob_data' => $slotRow->blob_data,
  1958. 'model_name' => $slotRow->model_name,
  1959. ];
  1960. }
  1961. }
  1962. }
  1963. return $result;
  1964. }
  1965. /**
  1966. * Constructs a new MutableRevisionRecord based on the given associative array following
  1967. * the MW1.29 convention for the Revision constructor.
  1968. *
  1969. * MCR migration note: this replaces Revision::newFromRow
  1970. *
  1971. * @param array $fields
  1972. * @param int $queryFlags
  1973. * @param Title|null $title
  1974. *
  1975. * @return MutableRevisionRecord
  1976. * @throws MWException
  1977. * @throws RevisionAccessException
  1978. */
  1979. public function newMutableRevisionFromArray(
  1980. array $fields,
  1981. $queryFlags = 0,
  1982. Title $title = null
  1983. ) {
  1984. if ( !$title && isset( $fields['title'] ) ) {
  1985. if ( !( $fields['title'] instanceof Title ) ) {
  1986. throw new MWException( 'title field must contain a Title object.' );
  1987. }
  1988. $title = $fields['title'];
  1989. }
  1990. if ( !$title ) {
  1991. $pageId = $fields['page'] ?? 0;
  1992. $revId = $fields['id'] ?? 0;
  1993. $title = $this->getTitle( $pageId, $revId, $queryFlags );
  1994. }
  1995. if ( !isset( $fields['page'] ) ) {
  1996. $fields['page'] = $title->getArticleID( $queryFlags );
  1997. }
  1998. // if we have a content object, use it to set the model and type
  1999. if ( !empty( $fields['content'] ) && !( $fields['content'] instanceof Content )
  2000. && !is_array( $fields['content'] )
  2001. ) {
  2002. throw new MWException(
  2003. 'content field must contain a Content object or an array of Content objects.'
  2004. );
  2005. }
  2006. if ( !empty( $fields['text_id'] ) ) {
  2007. if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
  2008. throw new MWException( "The text_id field is only available in the pre-MCR schema" );
  2009. }
  2010. if ( !empty( $fields['content'] ) ) {
  2011. throw new MWException(
  2012. "Text already stored in external store (id {$fields['text_id']}), " .
  2013. "can't specify content object"
  2014. );
  2015. }
  2016. }
  2017. if (
  2018. isset( $fields['comment'] )
  2019. && !( $fields['comment'] instanceof CommentStoreComment )
  2020. ) {
  2021. $commentData = $fields['comment_data'] ?? null;
  2022. if ( $fields['comment'] instanceof Message ) {
  2023. $fields['comment'] = CommentStoreComment::newUnsavedComment(
  2024. $fields['comment'],
  2025. $commentData
  2026. );
  2027. } else {
  2028. $commentText = trim( strval( $fields['comment'] ) );
  2029. $fields['comment'] = CommentStoreComment::newUnsavedComment(
  2030. $commentText,
  2031. $commentData
  2032. );
  2033. }
  2034. }
  2035. $revision = new MutableRevisionRecord( $title, $this->dbDomain );
  2036. $this->initializeMutableRevisionFromArray( $revision, $fields );
  2037. if ( isset( $fields['content'] ) && is_array( $fields['content'] ) ) {
  2038. // @phan-suppress-next-line PhanTypeNoPropertiesForeach
  2039. foreach ( $fields['content'] as $role => $content ) {
  2040. $revision->setContent( $role, $content );
  2041. }
  2042. } else {
  2043. $mainSlot = $this->emulateMainSlot_1_29( $fields, $queryFlags, $title );
  2044. $revision->setSlot( $mainSlot );
  2045. }
  2046. return $revision;
  2047. }
  2048. /**
  2049. * @param MutableRevisionRecord $record
  2050. * @param array $fields
  2051. */
  2052. private function initializeMutableRevisionFromArray(
  2053. MutableRevisionRecord $record,
  2054. array $fields
  2055. ) {
  2056. /** @var UserIdentity $user */
  2057. $user = null;
  2058. // If a user is passed in, use it if possible. We cannot use a user from a
  2059. // remote wiki with unsuppressed ids, due to issues described in T222212.
  2060. if ( isset( $fields['user'] ) &&
  2061. ( $fields['user'] instanceof UserIdentity ) &&
  2062. ( $this->dbDomain === false ||
  2063. ( !$fields['user']->getId() && !$fields['user']->getActorId() ) )
  2064. ) {
  2065. $user = $fields['user'];
  2066. } else {
  2067. try {
  2068. $user = User::newFromAnyId(
  2069. $fields['user'] ?? null,
  2070. $fields['user_text'] ?? null,
  2071. $fields['actor'] ?? null,
  2072. $this->dbDomain
  2073. );
  2074. } catch ( InvalidArgumentException $ex ) {
  2075. $user = null;
  2076. }
  2077. }
  2078. if ( $user ) {
  2079. $record->setUser( $user );
  2080. }
  2081. $timestamp = isset( $fields['timestamp'] )
  2082. ? strval( $fields['timestamp'] )
  2083. : wfTimestampNow(); // TODO: use a callback, so we can override it for testing.
  2084. $record->setTimestamp( $timestamp );
  2085. if ( isset( $fields['page'] ) ) {
  2086. $record->setPageId( intval( $fields['page'] ) );
  2087. }
  2088. if ( isset( $fields['id'] ) ) {
  2089. $record->setId( intval( $fields['id'] ) );
  2090. }
  2091. if ( isset( $fields['parent_id'] ) ) {
  2092. $record->setParentId( intval( $fields['parent_id'] ) );
  2093. }
  2094. if ( isset( $fields['sha1'] ) ) {
  2095. $record->setSha1( $fields['sha1'] );
  2096. }
  2097. if ( isset( $fields['size'] ) ) {
  2098. $record->setSize( intval( $fields['size'] ) );
  2099. }
  2100. if ( isset( $fields['minor_edit'] ) ) {
  2101. $record->setMinorEdit( intval( $fields['minor_edit'] ) !== 0 );
  2102. }
  2103. if ( isset( $fields['deleted'] ) ) {
  2104. $record->setVisibility( intval( $fields['deleted'] ) );
  2105. }
  2106. if ( isset( $fields['comment'] ) ) {
  2107. Assert::parameterType(
  2108. CommentStoreComment::class,
  2109. $fields['comment'],
  2110. '$row[\'comment\']'
  2111. );
  2112. $record->setComment( $fields['comment'] );
  2113. }
  2114. }
  2115. /**
  2116. * Load a page revision from a given revision ID number.
  2117. * Returns null if no such revision can be found.
  2118. *
  2119. * MCR migration note: this corresponds to Revision::loadFromId
  2120. *
  2121. * @note direct use is deprecated!
  2122. * @todo remove when unused! there seem to be no callers of Revision::loadFromId
  2123. *
  2124. * @param IDatabase $db
  2125. * @param int $id
  2126. *
  2127. * @return RevisionRecord|null
  2128. */
  2129. public function loadRevisionFromId( IDatabase $db, $id ) {
  2130. return $this->loadRevisionFromConds( $db, [ 'rev_id' => intval( $id ) ] );
  2131. }
  2132. /**
  2133. * Load either the current, or a specified, revision
  2134. * that's attached to a given page. If not attached
  2135. * to that page, will return null.
  2136. *
  2137. * MCR migration note: this replaces Revision::loadFromPageId
  2138. *
  2139. * @note direct use is deprecated!
  2140. * @todo remove when unused!
  2141. *
  2142. * @param IDatabase $db
  2143. * @param int $pageid
  2144. * @param int $id
  2145. * @return RevisionRecord|null
  2146. */
  2147. public function loadRevisionFromPageId( IDatabase $db, $pageid, $id = 0 ) {
  2148. $conds = [ 'rev_page' => intval( $pageid ), 'page_id' => intval( $pageid ) ];
  2149. if ( $id ) {
  2150. $conds['rev_id'] = intval( $id );
  2151. } else {
  2152. $conds[] = 'rev_id=page_latest';
  2153. }
  2154. return $this->loadRevisionFromConds( $db, $conds );
  2155. }
  2156. /**
  2157. * Load either the current, or a specified, revision
  2158. * that's attached to a given page. If not attached
  2159. * to that page, will return null.
  2160. *
  2161. * MCR migration note: this replaces Revision::loadFromTitle
  2162. *
  2163. * @note direct use is deprecated!
  2164. * @todo remove when unused!
  2165. *
  2166. * @param IDatabase $db
  2167. * @param Title $title
  2168. * @param int $id
  2169. *
  2170. * @return RevisionRecord|null
  2171. */
  2172. public function loadRevisionFromTitle( IDatabase $db, $title, $id = 0 ) {
  2173. if ( $id ) {
  2174. $matchId = intval( $id );
  2175. } else {
  2176. $matchId = 'page_latest';
  2177. }
  2178. return $this->loadRevisionFromConds(
  2179. $db,
  2180. [
  2181. "rev_id=$matchId",
  2182. 'page_namespace' => $title->getNamespace(),
  2183. 'page_title' => $title->getDBkey()
  2184. ],
  2185. 0,
  2186. $title
  2187. );
  2188. }
  2189. /**
  2190. * Load the revision for the given title with the given timestamp.
  2191. * WARNING: Timestamps may in some circumstances not be unique,
  2192. * so this isn't the best key to use.
  2193. *
  2194. * MCR migration note: this replaces Revision::loadFromTimestamp
  2195. *
  2196. * @note direct use is deprecated! Use getRevisionFromTimestamp instead!
  2197. * @todo remove when unused!
  2198. *
  2199. * @param IDatabase $db
  2200. * @param Title $title
  2201. * @param string $timestamp
  2202. * @return RevisionRecord|null
  2203. */
  2204. public function loadRevisionFromTimestamp( IDatabase $db, $title, $timestamp ) {
  2205. return $this->loadRevisionFromConds( $db,
  2206. [
  2207. 'rev_timestamp' => $db->timestamp( $timestamp ),
  2208. 'page_namespace' => $title->getNamespace(),
  2209. 'page_title' => $title->getDBkey()
  2210. ],
  2211. 0,
  2212. $title
  2213. );
  2214. }
  2215. /**
  2216. * Given a set of conditions, fetch a revision
  2217. *
  2218. * This method should be used if we are pretty sure the revision exists.
  2219. * Unless $flags has READ_LATEST set, this method will first try to find the revision
  2220. * on a replica before hitting the master database.
  2221. *
  2222. * MCR migration note: this corresponds to Revision::newFromConds
  2223. *
  2224. * @param array $conditions
  2225. * @param int $flags (optional)
  2226. * @param Title|null $title
  2227. *
  2228. * @return RevisionRecord|null
  2229. */
  2230. private function newRevisionFromConds( $conditions, $flags = 0, Title $title = null ) {
  2231. $db = $this->getDBConnectionRefForQueryFlags( $flags );
  2232. $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $title );
  2233. $lb = $this->getDBLoadBalancer();
  2234. // Make sure new pending/committed revision are visibile later on
  2235. // within web requests to certain avoid bugs like T93866 and T94407.
  2236. if ( !$rev
  2237. && !( $flags & self::READ_LATEST )
  2238. && $lb->hasStreamingReplicaServers()
  2239. && $lb->hasOrMadeRecentMasterChanges()
  2240. ) {
  2241. $flags = self::READ_LATEST;
  2242. $dbw = $this->getDBConnectionRef( DB_MASTER );
  2243. $rev = $this->loadRevisionFromConds( $dbw, $conditions, $flags, $title );
  2244. }
  2245. return $rev;
  2246. }
  2247. /**
  2248. * Given a set of conditions, fetch a revision from
  2249. * the given database connection.
  2250. *
  2251. * MCR migration note: this corresponds to Revision::loadFromConds
  2252. *
  2253. * @param IDatabase $db
  2254. * @param array $conditions
  2255. * @param int $flags (optional)
  2256. * @param Title|null $title
  2257. *
  2258. * @return RevisionRecord|null
  2259. */
  2260. private function loadRevisionFromConds(
  2261. IDatabase $db,
  2262. $conditions,
  2263. $flags = 0,
  2264. Title $title = null
  2265. ) {
  2266. $row = $this->fetchRevisionRowFromConds( $db, $conditions, $flags );
  2267. if ( $row ) {
  2268. $rev = $this->newRevisionFromRow( $row, $flags, $title );
  2269. return $rev;
  2270. }
  2271. return null;
  2272. }
  2273. /**
  2274. * Throws an exception if the given database connection does not belong to the wiki this
  2275. * RevisionStore is bound to.
  2276. *
  2277. * @param IDatabase $db
  2278. * @throws MWException
  2279. */
  2280. private function checkDatabaseDomain( IDatabase $db ) {
  2281. $dbDomain = $db->getDomainID();
  2282. $storeDomain = $this->loadBalancer->resolveDomainID( $this->dbDomain );
  2283. if ( $dbDomain === $storeDomain ) {
  2284. return;
  2285. }
  2286. throw new MWException( "DB connection domain '$dbDomain' does not match '$storeDomain'" );
  2287. }
  2288. /**
  2289. * Given a set of conditions, return a row with the
  2290. * fields necessary to build RevisionRecord objects.
  2291. *
  2292. * MCR migration note: this corresponds to Revision::fetchFromConds
  2293. *
  2294. * @param IDatabase $db
  2295. * @param array $conditions
  2296. * @param int $flags (optional)
  2297. *
  2298. * @return object|false data row as a raw object
  2299. */
  2300. private function fetchRevisionRowFromConds( IDatabase $db, $conditions, $flags = 0 ) {
  2301. $this->checkDatabaseDomain( $db );
  2302. $revQuery = $this->getQueryInfo( [ 'page', 'user' ] );
  2303. $options = [];
  2304. if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) {
  2305. $options[] = 'FOR UPDATE';
  2306. }
  2307. return $db->selectRow(
  2308. $revQuery['tables'],
  2309. $revQuery['fields'],
  2310. $conditions,
  2311. __METHOD__,
  2312. $options,
  2313. $revQuery['joins']
  2314. );
  2315. }
  2316. /**
  2317. * Finds the ID of a content row for a given revision and slot role.
  2318. * This can be used to re-use content rows even while the content ID
  2319. * is still missing from SlotRecords, when writing to both the old and
  2320. * the new schema during MCR schema migration.
  2321. *
  2322. * @todo remove after MCR schema migration is complete.
  2323. *
  2324. * @param IDatabase $db
  2325. * @param int $revId
  2326. * @param string $role
  2327. *
  2328. * @return int|null
  2329. */
  2330. private function findSlotContentId( IDatabase $db, $revId, $role ) {
  2331. if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
  2332. return null;
  2333. }
  2334. try {
  2335. $roleId = $this->slotRoleStore->getId( $role );
  2336. $conditions = [
  2337. 'slot_revision_id' => $revId,
  2338. 'slot_role_id' => $roleId,
  2339. ];
  2340. $contentId = $db->selectField( 'slots', 'slot_content_id', $conditions, __METHOD__ );
  2341. return $contentId ?: null;
  2342. } catch ( NameTableAccessException $ex ) {
  2343. // If the role is missing from the slot_roles table,
  2344. // the corresponding row in slots cannot exist.
  2345. return null;
  2346. }
  2347. }
  2348. /**
  2349. * Return the tables, fields, and join conditions to be selected to create
  2350. * a new RevisionStoreRecord object.
  2351. *
  2352. * MCR migration note: this replaces Revision::getQueryInfo
  2353. *
  2354. * If the format of fields returned changes in any way then the cache key provided by
  2355. * self::getRevisionRowCacheKey should be updated.
  2356. *
  2357. * @since 1.31
  2358. *
  2359. * @param array $options Any combination of the following strings
  2360. * - 'page': Join with the page table, and select fields to identify the page
  2361. * - 'user': Join with the user table, and select the user name
  2362. * - 'text': Join with the text table, and select fields to load page text. This
  2363. * option is deprecated in MW 1.32 when the MCR migration flag SCHEMA_COMPAT_WRITE_NEW
  2364. * is set, and disallowed when SCHEMA_COMPAT_READ_OLD is not set.
  2365. *
  2366. * @return array With three keys:
  2367. * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
  2368. * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
  2369. * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
  2370. * @phan-return array{tables:string[],fields:string[],joins:array}
  2371. */
  2372. public function getQueryInfo( $options = [] ) {
  2373. $ret = [
  2374. 'tables' => [],
  2375. 'fields' => [],
  2376. 'joins' => [],
  2377. ];
  2378. $ret['tables'][] = 'revision';
  2379. $ret['fields'] = array_merge( $ret['fields'], [
  2380. 'rev_id',
  2381. 'rev_page',
  2382. 'rev_timestamp',
  2383. 'rev_minor_edit',
  2384. 'rev_deleted',
  2385. 'rev_len',
  2386. 'rev_parent_id',
  2387. 'rev_sha1',
  2388. ] );
  2389. $commentQuery = $this->commentStore->getJoin( 'rev_comment' );
  2390. $ret['tables'] = array_merge( $ret['tables'], $commentQuery['tables'] );
  2391. $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] );
  2392. $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] );
  2393. $actorQuery = $this->actorMigration->getJoin( 'rev_user' );
  2394. $ret['tables'] = array_merge( $ret['tables'], $actorQuery['tables'] );
  2395. $ret['fields'] = array_merge( $ret['fields'], $actorQuery['fields'] );
  2396. $ret['joins'] = array_merge( $ret['joins'], $actorQuery['joins'] );
  2397. if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
  2398. $ret['fields'][] = 'rev_text_id';
  2399. if ( $this->contentHandlerUseDB ) {
  2400. $ret['fields'][] = 'rev_content_format';
  2401. $ret['fields'][] = 'rev_content_model';
  2402. }
  2403. }
  2404. if ( in_array( 'page', $options, true ) ) {
  2405. $ret['tables'][] = 'page';
  2406. $ret['fields'] = array_merge( $ret['fields'], [
  2407. 'page_namespace',
  2408. 'page_title',
  2409. 'page_id',
  2410. 'page_latest',
  2411. 'page_is_redirect',
  2412. 'page_len',
  2413. ] );
  2414. $ret['joins']['page'] = [ 'JOIN', [ 'page_id = rev_page' ] ];
  2415. }
  2416. if ( in_array( 'user', $options, true ) ) {
  2417. $ret['tables'][] = 'user';
  2418. $ret['fields'] = array_merge( $ret['fields'], [
  2419. 'user_name',
  2420. ] );
  2421. $u = $actorQuery['fields']['rev_user'];
  2422. $ret['joins']['user'] = [ 'LEFT JOIN', [ "$u != 0", "user_id = $u" ] ];
  2423. }
  2424. if ( in_array( 'text', $options, true ) ) {
  2425. if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) ) {
  2426. throw new InvalidArgumentException( 'text table can no longer be joined directly' );
  2427. } elseif ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
  2428. // NOTE: even when this class is set to not read from the old schema, callers
  2429. // should still be able to join against the text table, as long as we are still
  2430. // writing the old schema for compatibility.
  2431. // TODO: This should trigger a deprecation warning eventually (T200918), but not
  2432. // before all known usages are removed (see T198341 and T201164).
  2433. // wfDeprecated( __METHOD__ . ' with `text` option', '1.32' );
  2434. }
  2435. $ret['tables'][] = 'text';
  2436. $ret['fields'] = array_merge( $ret['fields'], [
  2437. 'old_text',
  2438. 'old_flags'
  2439. ] );
  2440. $ret['joins']['text'] = [ 'JOIN', [ 'rev_text_id=old_id' ] ];
  2441. }
  2442. return $ret;
  2443. }
  2444. /**
  2445. * Return the tables, fields, and join conditions to be selected to create
  2446. * a new SlotRecord.
  2447. *
  2448. * @since 1.32
  2449. *
  2450. * @param array $options Any combination of the following strings
  2451. * - 'content': Join with the content table, and select content meta-data fields
  2452. * - 'model': Join with the content_models table, and select the model_name field.
  2453. * Only applicable if 'content' is also set.
  2454. * - 'role': Join with the slot_roles table, and select the role_name field
  2455. *
  2456. * @return array With three keys:
  2457. * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
  2458. * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
  2459. * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
  2460. * - keys: (associative array) to look up fields to match against.
  2461. * In particular, the field that can be used to find slots by rev_id
  2462. * can be found in ['keys']['rev_id'].
  2463. */
  2464. public function getSlotsQueryInfo( $options = [] ) {
  2465. $ret = [
  2466. 'tables' => [],
  2467. 'fields' => [],
  2468. 'joins' => [],
  2469. 'keys' => [],
  2470. ];
  2471. if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
  2472. $db = $this->getDBConnectionRef( DB_REPLICA );
  2473. $ret['keys']['rev_id'] = 'rev_id';
  2474. $ret['tables'][] = 'revision';
  2475. $ret['fields']['slot_revision_id'] = 'rev_id';
  2476. $ret['fields']['slot_content_id'] = 'NULL';
  2477. $ret['fields']['slot_origin'] = 'rev_id';
  2478. $ret['fields']['role_name'] = $db->addQuotes( SlotRecord::MAIN );
  2479. if ( in_array( 'content', $options, true ) ) {
  2480. $ret['fields']['content_size'] = 'rev_len';
  2481. $ret['fields']['content_sha1'] = 'rev_sha1';
  2482. $ret['fields']['content_address']
  2483. = $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'rev_text_id' ] );
  2484. // Allow the content_id field to be emulated later
  2485. $ret['fields']['rev_text_id'] = 'rev_text_id';
  2486. if ( $this->contentHandlerUseDB ) {
  2487. $ret['fields']['model_name'] = 'rev_content_model';
  2488. } else {
  2489. $ret['fields']['model_name'] = 'NULL';
  2490. }
  2491. }
  2492. } else {
  2493. $ret['keys']['rev_id'] = 'slot_revision_id';
  2494. $ret['keys']['role_id'] = 'slot_role_id';
  2495. $ret['tables'][] = 'slots';
  2496. $ret['fields'] = array_merge( $ret['fields'], [
  2497. 'slot_revision_id',
  2498. 'slot_content_id',
  2499. 'slot_origin',
  2500. 'slot_role_id',
  2501. ] );
  2502. if ( in_array( 'role', $options, true ) ) {
  2503. // Use left join to attach role name, so we still find the revision row even
  2504. // if the role name is missing. This triggers a more obvious failure mode.
  2505. $ret['tables'][] = 'slot_roles';
  2506. $ret['joins']['slot_roles'] = [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ];
  2507. $ret['fields'][] = 'role_name';
  2508. }
  2509. if ( in_array( 'content', $options, true ) ) {
  2510. $ret['keys']['model_id'] = 'content_model';
  2511. $ret['tables'][] = 'content';
  2512. $ret['fields'] = array_merge( $ret['fields'], [
  2513. 'content_size',
  2514. 'content_sha1',
  2515. 'content_address',
  2516. 'content_model',
  2517. ] );
  2518. $ret['joins']['content'] = [ 'JOIN', [ 'slot_content_id = content_id' ] ];
  2519. if ( in_array( 'model', $options, true ) ) {
  2520. // Use left join to attach model name, so we still find the revision row even
  2521. // if the model name is missing. This triggers a more obvious failure mode.
  2522. $ret['tables'][] = 'content_models';
  2523. $ret['joins']['content_models'] = [ 'LEFT JOIN', [ 'content_model = model_id' ] ];
  2524. $ret['fields'][] = 'model_name';
  2525. }
  2526. }
  2527. }
  2528. return $ret;
  2529. }
  2530. /**
  2531. * Return the tables, fields, and join conditions to be selected to create
  2532. * a new RevisionArchiveRecord object.
  2533. *
  2534. * MCR migration note: this replaces Revision::getArchiveQueryInfo
  2535. *
  2536. * @since 1.31
  2537. *
  2538. * @return array With three keys:
  2539. * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
  2540. * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
  2541. * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
  2542. */
  2543. public function getArchiveQueryInfo() {
  2544. $commentQuery = $this->commentStore->getJoin( 'ar_comment' );
  2545. $actorQuery = $this->actorMigration->getJoin( 'ar_user' );
  2546. $ret = [
  2547. 'tables' => [ 'archive' ] + $commentQuery['tables'] + $actorQuery['tables'],
  2548. 'fields' => [
  2549. 'ar_id',
  2550. 'ar_page_id',
  2551. 'ar_namespace',
  2552. 'ar_title',
  2553. 'ar_rev_id',
  2554. 'ar_timestamp',
  2555. 'ar_minor_edit',
  2556. 'ar_deleted',
  2557. 'ar_len',
  2558. 'ar_parent_id',
  2559. 'ar_sha1',
  2560. ] + $commentQuery['fields'] + $actorQuery['fields'],
  2561. 'joins' => $commentQuery['joins'] + $actorQuery['joins'],
  2562. ];
  2563. if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
  2564. $ret['fields'][] = 'ar_text_id';
  2565. if ( $this->contentHandlerUseDB ) {
  2566. $ret['fields'][] = 'ar_content_format';
  2567. $ret['fields'][] = 'ar_content_model';
  2568. }
  2569. }
  2570. return $ret;
  2571. }
  2572. /**
  2573. * Do a batched query for the sizes of a set of revisions.
  2574. *
  2575. * MCR migration note: this replaces Revision::getParentLengths
  2576. *
  2577. * @param int[] $revIds
  2578. * @return int[] associative array mapping revision IDs from $revIds to the nominal size
  2579. * of the corresponding revision.
  2580. */
  2581. public function getRevisionSizes( array $revIds ) {
  2582. return $this->listRevisionSizes( $this->getDBConnectionRef( DB_REPLICA ), $revIds );
  2583. }
  2584. /**
  2585. * Do a batched query for the sizes of a set of revisions.
  2586. *
  2587. * MCR migration note: this replaces Revision::getParentLengths
  2588. *
  2589. * @deprecated use RevisionStore::getRevisionSizes instead.
  2590. *
  2591. * @param IDatabase $db
  2592. * @param int[] $revIds
  2593. * @return int[] associative array mapping revision IDs from $revIds to the nominal size
  2594. * of the corresponding revision.
  2595. */
  2596. public function listRevisionSizes( IDatabase $db, array $revIds ) {
  2597. $this->checkDatabaseDomain( $db );
  2598. $revLens = [];
  2599. if ( !$revIds ) {
  2600. return $revLens; // empty
  2601. }
  2602. $res = $db->select(
  2603. 'revision',
  2604. [ 'rev_id', 'rev_len' ],
  2605. [ 'rev_id' => $revIds ],
  2606. __METHOD__
  2607. );
  2608. foreach ( $res as $row ) {
  2609. $revLens[$row->rev_id] = intval( $row->rev_len );
  2610. }
  2611. return $revLens;
  2612. }
  2613. /**
  2614. * Implementation of getPreviousRevision and getNextRevision.
  2615. *
  2616. * @param RevisionRecord $rev
  2617. * @param int $flags
  2618. * @param string $dir 'next' or 'prev'
  2619. * @return RevisionRecord|null
  2620. */
  2621. private function getRelativeRevision( RevisionRecord $rev, $flags, $dir ) {
  2622. $op = $dir === 'next' ? '>' : '<';
  2623. $sort = $dir === 'next' ? 'ASC' : 'DESC';
  2624. if ( !$rev->getId() || !$rev->getPageId() ) {
  2625. // revision is unsaved or otherwise incomplete
  2626. return null;
  2627. }
  2628. if ( $rev instanceof RevisionArchiveRecord ) {
  2629. // revision is deleted, so it's not part of the page history
  2630. return null;
  2631. }
  2632. list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags );
  2633. $db = $this->getDBConnectionRef( $dbType, [ 'contributions' ] );
  2634. $ts = $this->getTimestampFromId( $rev->getId(), $flags );
  2635. if ( $ts === false ) {
  2636. // XXX Should this be moved into getTimestampFromId?
  2637. $ts = $db->selectField( 'archive', 'ar_timestamp',
  2638. [ 'ar_rev_id' => $rev->getId() ], __METHOD__ );
  2639. if ( $ts === false ) {
  2640. // XXX Is this reachable? How can we have a page id but no timestamp?
  2641. return null;
  2642. }
  2643. }
  2644. $ts = $db->addQuotes( $db->timestamp( $ts ) );
  2645. $revId = $db->selectField( 'revision', 'rev_id',
  2646. [
  2647. 'rev_page' => $rev->getPageId(),
  2648. "rev_timestamp $op $ts OR (rev_timestamp = $ts AND rev_id $op {$rev->getId()})"
  2649. ],
  2650. __METHOD__,
  2651. [
  2652. 'ORDER BY' => "rev_timestamp $sort, rev_id $sort",
  2653. 'IGNORE INDEX' => 'rev_timestamp', // Probably needed for T159319
  2654. ]
  2655. );
  2656. if ( $revId === false ) {
  2657. return null;
  2658. }
  2659. return $this->getRevisionById( intval( $revId ) );
  2660. }
  2661. /**
  2662. * Get the revision before $rev in the page's history, if any.
  2663. * Will return null for the first revision but also for deleted or unsaved revisions.
  2664. *
  2665. * MCR migration note: this replaces Revision::getPrevious
  2666. *
  2667. * @see Title::getPreviousRevisionID
  2668. * @see PageArchive::getPreviousRevision
  2669. *
  2670. * @param RevisionRecord $rev
  2671. * @param int $flags (optional) $flags include:
  2672. * IDBAccessObject::READ_LATEST: Select the data from the master
  2673. *
  2674. * @return RevisionRecord|null
  2675. */
  2676. public function getPreviousRevision( RevisionRecord $rev, $flags = 0 ) {
  2677. if ( $flags instanceof Title ) {
  2678. // Old calling convention, we don't use Title here anymore
  2679. wfDeprecated( __METHOD__ . ' with Title', '1.34' );
  2680. $flags = 0;
  2681. }
  2682. return $this->getRelativeRevision( $rev, $flags, 'prev' );
  2683. }
  2684. /**
  2685. * Get the revision after $rev in the page's history, if any.
  2686. * Will return null for the latest revision but also for deleted or unsaved revisions.
  2687. *
  2688. * MCR migration note: this replaces Revision::getNext
  2689. *
  2690. * @see Title::getNextRevisionID
  2691. *
  2692. * @param RevisionRecord $rev
  2693. * @param int $flags (optional) $flags include:
  2694. * IDBAccessObject::READ_LATEST: Select the data from the master
  2695. * @return RevisionRecord|null
  2696. */
  2697. public function getNextRevision( RevisionRecord $rev, $flags = 0 ) {
  2698. if ( $flags instanceof Title ) {
  2699. // Old calling convention, we don't use Title here anymore
  2700. wfDeprecated( __METHOD__ . ' with Title', '1.34' );
  2701. $flags = 0;
  2702. }
  2703. return $this->getRelativeRevision( $rev, $flags, 'next' );
  2704. }
  2705. /**
  2706. * Get previous revision Id for this page_id
  2707. * This is used to populate rev_parent_id on save
  2708. *
  2709. * MCR migration note: this corresponds to Revision::getPreviousRevisionId
  2710. *
  2711. * @param IDatabase $db
  2712. * @param RevisionRecord $rev
  2713. *
  2714. * @return int
  2715. */
  2716. private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) {
  2717. $this->checkDatabaseDomain( $db );
  2718. if ( $rev->getPageId() === null ) {
  2719. return 0;
  2720. }
  2721. # Use page_latest if ID is not given
  2722. if ( !$rev->getId() ) {
  2723. $prevId = $db->selectField(
  2724. 'page', 'page_latest',
  2725. [ 'page_id' => $rev->getPageId() ],
  2726. __METHOD__
  2727. );
  2728. } else {
  2729. $prevId = $db->selectField(
  2730. 'revision', 'rev_id',
  2731. [ 'rev_page' => $rev->getPageId(), 'rev_id < ' . $rev->getId() ],
  2732. __METHOD__,
  2733. [ 'ORDER BY' => 'rev_id DESC' ]
  2734. );
  2735. }
  2736. return intval( $prevId );
  2737. }
  2738. /**
  2739. * Get rev_timestamp from rev_id, without loading the rest of the row.
  2740. *
  2741. * Historically, there was an extra Title parameter that was passed before $id. This is no
  2742. * longer needed and is deprecated in 1.34.
  2743. *
  2744. * MCR migration note: this replaces Revision::getTimestampFromId
  2745. *
  2746. * @param int $id
  2747. * @param int $flags
  2748. * @return string|bool False if not found
  2749. */
  2750. public function getTimestampFromId( $id, $flags = 0 ) {
  2751. if ( $id instanceof Title ) {
  2752. // Old deprecated calling convention supported for backwards compatibility
  2753. $id = $flags;
  2754. $flags = func_num_args() > 2 ? func_get_arg( 2 ) : 0;
  2755. }
  2756. $db = $this->getDBConnectionRefForQueryFlags( $flags );
  2757. $timestamp =
  2758. $db->selectField( 'revision', 'rev_timestamp', [ 'rev_id' => $id ], __METHOD__ );
  2759. return ( $timestamp !== false ) ? wfTimestamp( TS_MW, $timestamp ) : false;
  2760. }
  2761. /**
  2762. * Get count of revisions per page...not very efficient
  2763. *
  2764. * MCR migration note: this replaces Revision::countByPageId
  2765. *
  2766. * @param IDatabase $db
  2767. * @param int $id Page id
  2768. * @return int
  2769. */
  2770. public function countRevisionsByPageId( IDatabase $db, $id ) {
  2771. $this->checkDatabaseDomain( $db );
  2772. $row = $db->selectRow( 'revision',
  2773. [ 'revCount' => 'COUNT(*)' ],
  2774. [ 'rev_page' => $id ],
  2775. __METHOD__
  2776. );
  2777. if ( $row ) {
  2778. return intval( $row->revCount );
  2779. }
  2780. return 0;
  2781. }
  2782. /**
  2783. * Get count of revisions per page...not very efficient
  2784. *
  2785. * MCR migration note: this replaces Revision::countByTitle
  2786. *
  2787. * @param IDatabase $db
  2788. * @param Title $title
  2789. * @return int
  2790. */
  2791. public function countRevisionsByTitle( IDatabase $db, $title ) {
  2792. $id = $title->getArticleID();
  2793. if ( $id ) {
  2794. return $this->countRevisionsByPageId( $db, $id );
  2795. }
  2796. return 0;
  2797. }
  2798. /**
  2799. * Check if no edits were made by other users since
  2800. * the time a user started editing the page. Limit to
  2801. * 50 revisions for the sake of performance.
  2802. *
  2803. * MCR migration note: this replaces Revision::userWasLastToEdit
  2804. *
  2805. * @deprecated since 1.31; Can possibly be removed, since the self-conflict suppression
  2806. * logic in EditPage that uses this seems conceptually dubious. Revision::userWasLastToEdit
  2807. * has been deprecated since 1.24.
  2808. *
  2809. * @param IDatabase $db The Database to perform the check on.
  2810. * @param int $pageId The ID of the page in question
  2811. * @param int $userId The ID of the user in question
  2812. * @param string $since Look at edits since this time
  2813. *
  2814. * @return bool True if the given user was the only one to edit since the given timestamp
  2815. */
  2816. public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) {
  2817. $this->checkDatabaseDomain( $db );
  2818. if ( !$userId ) {
  2819. return false;
  2820. }
  2821. $revQuery = $this->getQueryInfo();
  2822. $res = $db->select(
  2823. $revQuery['tables'],
  2824. [
  2825. 'rev_user' => $revQuery['fields']['rev_user'],
  2826. ],
  2827. [
  2828. 'rev_page' => $pageId,
  2829. 'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) )
  2830. ],
  2831. __METHOD__,
  2832. [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ],
  2833. $revQuery['joins']
  2834. );
  2835. foreach ( $res as $row ) {
  2836. if ( $row->rev_user != $userId ) {
  2837. return false;
  2838. }
  2839. }
  2840. return true;
  2841. }
  2842. /**
  2843. * Load a revision based on a known page ID and current revision ID from the DB
  2844. *
  2845. * This method allows for the use of caching, though accessing anything that normally
  2846. * requires permission checks (aside from the text) will trigger a small DB lookup.
  2847. *
  2848. * MCR migration note: this replaces Revision::newKnownCurrent
  2849. *
  2850. * @param Title $title the associated page title
  2851. * @param int $revId current revision of this page. Defaults to $title->getLatestRevID().
  2852. *
  2853. * @return RevisionRecord|bool Returns false if missing
  2854. */
  2855. public function getKnownCurrentRevision( Title $title, $revId ) {
  2856. $db = $this->getDBConnectionRef( DB_REPLICA );
  2857. $pageId = $title->getArticleID();
  2858. if ( !$pageId ) {
  2859. return false;
  2860. }
  2861. if ( !$revId ) {
  2862. $revId = $title->getLatestRevID();
  2863. }
  2864. if ( !$revId ) {
  2865. wfWarn(
  2866. 'No latest revision known for page ' . $title->getPrefixedDBkey()
  2867. . ' even though it exists with page ID ' . $pageId
  2868. );
  2869. return false;
  2870. }
  2871. // Load the row from cache if possible. If not possible, populate the cache.
  2872. // As a minor optimization, remember if this was a cache hit or miss.
  2873. // We can sometimes avoid a database query later if this is a cache miss.
  2874. $fromCache = true;
  2875. $row = $this->cache->getWithSetCallback(
  2876. // Page/rev IDs passed in from DB to reflect history merges
  2877. $this->getRevisionRowCacheKey( $db, $pageId, $revId ),
  2878. WANObjectCache::TTL_WEEK,
  2879. function ( $curValue, &$ttl, array &$setOpts ) use (
  2880. $db, $pageId, $revId, &$fromCache
  2881. ) {
  2882. $setOpts += Database::getCacheSetOptions( $db );
  2883. $row = $this->fetchRevisionRowFromConds( $db, [ 'rev_id' => intval( $revId ) ] );
  2884. if ( $row ) {
  2885. $fromCache = false;
  2886. }
  2887. return $row; // don't cache negatives
  2888. }
  2889. );
  2890. // Reflect revision deletion and user renames.
  2891. if ( $row ) {
  2892. return $this->newRevisionFromRow( $row, 0, $title, $fromCache );
  2893. } else {
  2894. return false;
  2895. }
  2896. }
  2897. /**
  2898. * Get a cache key for use with a row as selected with getQueryInfo( [ 'page', 'user' ] )
  2899. * Caching rows without 'page' or 'user' could lead to issues.
  2900. * If the format of the rows returned by the query provided by getQueryInfo changes the
  2901. * cache key should be updated to avoid conflicts.
  2902. *
  2903. * @param IDatabase $db
  2904. * @param int $pageId
  2905. * @param int $revId
  2906. * @return string
  2907. */
  2908. private function getRevisionRowCacheKey( IDatabase $db, $pageId, $revId ) {
  2909. return $this->cache->makeGlobalKey(
  2910. self::ROW_CACHE_KEY,
  2911. $db->getDomainID(),
  2912. $pageId,
  2913. $revId
  2914. );
  2915. }
  2916. // TODO: move relevant methods from Title here, e.g. getFirstRevision, isBigDeletion, etc.
  2917. }
  2918. /**
  2919. * Retain the old class name for backwards compatibility.
  2920. * @deprecated since 1.32
  2921. */
  2922. class_alias( RevisionStore::class, 'MediaWiki\Storage\RevisionStore' );