SlotRecord.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. <?php
  2. /**
  3. * Value object representing a content slot associated with a page revision.
  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. * @file
  21. */
  22. namespace MediaWiki\Revision;
  23. use Content;
  24. use InvalidArgumentException;
  25. use LogicException;
  26. use OutOfBoundsException;
  27. use Wikimedia\Assert\Assert;
  28. /**
  29. * Value object representing a content slot associated with a page revision.
  30. * SlotRecord provides direct access to a Content object.
  31. * That access may be implemented through a callback.
  32. *
  33. * @since 1.31
  34. * @since 1.32 Renamed from MediaWiki\Storage\SlotRecord
  35. */
  36. class SlotRecord {
  37. const MAIN = 'main';
  38. /**
  39. * @var object database result row, as a raw object. Callbacks are supported for field values,
  40. * to enable on-demand emulation of these values. This is primarily intended for use
  41. * during schema migration.
  42. */
  43. private $row;
  44. /**
  45. * @var Content|callable
  46. */
  47. private $content;
  48. /**
  49. * Returns a new SlotRecord just like the given $slot, except that calling getContent()
  50. * will fail with an exception.
  51. *
  52. * @param SlotRecord $slot
  53. *
  54. * @return SlotRecord
  55. */
  56. public static function newWithSuppressedContent( SlotRecord $slot ) {
  57. $row = $slot->row;
  58. return new SlotRecord( $row, function () {
  59. throw new SuppressedDataException( 'Content suppressed!' );
  60. } );
  61. }
  62. /**
  63. * Constructs a new SlotRecord from an existing SlotRecord, overriding some fields.
  64. * The slot's content cannot be overwritten.
  65. *
  66. * @param SlotRecord $slot
  67. * @param array $overrides
  68. *
  69. * @return SlotRecord
  70. */
  71. private static function newDerived( SlotRecord $slot, array $overrides = [] ) {
  72. $row = clone $slot->row;
  73. $row->slot_id = null; // never copy the row ID!
  74. foreach ( $overrides as $key => $value ) {
  75. $row->$key = $value;
  76. }
  77. return new SlotRecord( $row, $slot->content );
  78. }
  79. /**
  80. * Constructs a new SlotRecord for a new revision, inheriting the content of the given SlotRecord
  81. * of a previous revision.
  82. *
  83. * Note that a SlotRecord constructed this way are intended as prototypes,
  84. * to be used wit newSaved(). They are incomplete, so some getters such as
  85. * getRevision() will fail.
  86. *
  87. * @param SlotRecord $slot
  88. *
  89. * @return SlotRecord
  90. */
  91. public static function newInherited( SlotRecord $slot ) {
  92. // Sanity check - we can't inherit from a Slot that's not attached to a revision.
  93. $slot->getRevision();
  94. $slot->getOrigin();
  95. $slot->getAddress();
  96. // NOTE: slot_origin and content_address are copied from $slot.
  97. return self::newDerived( $slot, [
  98. 'slot_revision_id' => null,
  99. ] );
  100. }
  101. /**
  102. * Constructs a new Slot from a Content object for a new revision.
  103. * This is the preferred way to construct a slot for storing Content that
  104. * resulted from a user edit. The slot is assumed to be not inherited.
  105. *
  106. * Note that a SlotRecord constructed this way are intended as prototypes,
  107. * to be used wit newSaved(). They are incomplete, so some getters such as
  108. * getAddress() will fail.
  109. *
  110. * @param string $role
  111. * @param Content $content
  112. *
  113. * @return SlotRecord An incomplete proto-slot object, to be used with newSaved() later.
  114. */
  115. public static function newUnsaved( $role, Content $content ) {
  116. Assert::parameterType( 'string', $role, '$role' );
  117. $row = [
  118. 'slot_id' => null, // not yet known
  119. 'slot_revision_id' => null, // not yet known
  120. 'slot_origin' => null, // not yet known, will be set in newSaved()
  121. 'content_size' => null, // compute later
  122. 'content_sha1' => null, // compute later
  123. 'slot_content_id' => null, // not yet known, will be set in newSaved()
  124. 'content_address' => null, // not yet known, will be set in newSaved()
  125. 'role_name' => $role,
  126. 'model_name' => $content->getModel(),
  127. ];
  128. return new SlotRecord( (object)$row, $content );
  129. }
  130. /**
  131. * Constructs a complete SlotRecord for a newly saved revision, based on the incomplete
  132. * proto-slot. This adds information that has only become available during saving,
  133. * particularly the revision ID, content ID and content address.
  134. *
  135. * @param int $revisionId the revision the slot is to be associated with (field slot_revision_id).
  136. * If $protoSlot already has a revision, it must be the same.
  137. * @param int|null $contentId the ID of the row in the content table describing the content
  138. * referenced by $contentAddress (field slot_content_id).
  139. * If $protoSlot already has a content ID, it must be the same.
  140. * @param string $contentAddress the slot's content address (field content_address).
  141. * If $protoSlot already has an address, it must be the same.
  142. * @param SlotRecord $protoSlot The proto-slot that was provided as input for creating a new
  143. * revision. $protoSlot must have a content address if inherited.
  144. *
  145. * @return SlotRecord If the state of $protoSlot is inappropriate for saving a new revision.
  146. */
  147. public static function newSaved(
  148. $revisionId,
  149. $contentId,
  150. $contentAddress,
  151. SlotRecord $protoSlot
  152. ) {
  153. Assert::parameterType( 'integer', $revisionId, '$revisionId' );
  154. // TODO once migration is over $contentId must be an integer
  155. Assert::parameterType( 'integer|null', $contentId, '$contentId' );
  156. Assert::parameterType( 'string', $contentAddress, '$contentAddress' );
  157. if ( $protoSlot->hasRevision() && $protoSlot->getRevision() !== $revisionId ) {
  158. throw new LogicException(
  159. "Mismatching revision ID $revisionId: "
  160. . "The slot already belongs to revision {$protoSlot->getRevision()}. "
  161. . "Use SlotRecord::newInherited() to re-use content between revisions."
  162. );
  163. }
  164. if ( $protoSlot->hasAddress() && $protoSlot->getAddress() !== $contentAddress ) {
  165. throw new LogicException(
  166. "Mismatching blob address $contentAddress: "
  167. . "The slot already has content at {$protoSlot->getAddress()}."
  168. );
  169. }
  170. if ( $protoSlot->hasContentId() && $protoSlot->getContentId() !== $contentId ) {
  171. throw new LogicException(
  172. "Mismatching content ID $contentId: "
  173. . "The slot already has content row {$protoSlot->getContentId()} associated."
  174. );
  175. }
  176. if ( $protoSlot->isInherited() ) {
  177. if ( !$protoSlot->hasAddress() ) {
  178. throw new InvalidArgumentException(
  179. "An inherited blob should have a content address!"
  180. );
  181. }
  182. if ( !$protoSlot->hasField( 'slot_origin' ) ) {
  183. throw new InvalidArgumentException(
  184. "A saved inherited slot should have an origin set!"
  185. );
  186. }
  187. $origin = $protoSlot->getOrigin();
  188. } else {
  189. $origin = $revisionId;
  190. }
  191. return self::newDerived( $protoSlot, [
  192. 'slot_revision_id' => $revisionId,
  193. 'slot_content_id' => $contentId,
  194. 'slot_origin' => $origin,
  195. 'content_address' => $contentAddress,
  196. ] );
  197. }
  198. /**
  199. * The following fields are supported by the $row parameter:
  200. *
  201. * $row->blob_data
  202. * $row->blob_address
  203. *
  204. * @param object $row A database row composed of fields of the slot and content tables,
  205. * as a raw object. Any field value can be a callback that produces the field value
  206. * given this SlotRecord as a parameter. However, plain strings cannot be used as
  207. * callbacks here, for security reasons.
  208. * @param Content|callable $content The content object associated with the slot, or a
  209. * callback that will return that Content object, given this SlotRecord as a parameter.
  210. */
  211. public function __construct( $row, $content ) {
  212. Assert::parameterType( 'object', $row, '$row' );
  213. Assert::parameterType( 'Content|callable', $content, '$content' );
  214. Assert::parameter(
  215. property_exists( $row, 'slot_revision_id' ),
  216. '$row->slot_revision_id',
  217. 'must exist'
  218. );
  219. Assert::parameter(
  220. property_exists( $row, 'slot_content_id' ),
  221. '$row->slot_content_id',
  222. 'must exist'
  223. );
  224. Assert::parameter(
  225. property_exists( $row, 'content_address' ),
  226. '$row->content_address',
  227. 'must exist'
  228. );
  229. Assert::parameter(
  230. property_exists( $row, 'model_name' ),
  231. '$row->model_name',
  232. 'must exist'
  233. );
  234. Assert::parameter(
  235. property_exists( $row, 'slot_origin' ),
  236. '$row->slot_origin',
  237. 'must exist'
  238. );
  239. Assert::parameter(
  240. !property_exists( $row, 'slot_inherited' ),
  241. '$row->slot_inherited',
  242. 'must not exist'
  243. );
  244. Assert::parameter(
  245. !property_exists( $row, 'slot_revision' ),
  246. '$row->slot_revision',
  247. 'must not exist'
  248. );
  249. $this->row = $row;
  250. $this->content = $content;
  251. }
  252. /**
  253. * Implemented to defy serialization.
  254. *
  255. * @throws LogicException always
  256. */
  257. public function __sleep() {
  258. throw new LogicException( __CLASS__ . ' is not serializable.' );
  259. }
  260. /**
  261. * Returns the Content of the given slot.
  262. *
  263. * @note This is free to load Content from whatever subsystem is necessary,
  264. * performing potentially expensive operations and triggering I/O-related
  265. * failure modes.
  266. *
  267. * @note This method does not apply audience filtering.
  268. *
  269. * @throws SuppressedDataException if access to the content is not allowed according
  270. * to the audience check performed by RevisionRecord::getSlot().
  271. *
  272. * @return Content The slot's content. This is a direct reference to the internal instance,
  273. * copy before exposing to application logic!
  274. */
  275. public function getContent() {
  276. if ( $this->content instanceof Content ) {
  277. return $this->content;
  278. }
  279. $obj = call_user_func( $this->content, $this );
  280. Assert::postcondition(
  281. $obj instanceof Content,
  282. 'Slot content callback should return a Content object'
  283. );
  284. $this->content = $obj;
  285. return $this->content;
  286. }
  287. /**
  288. * Returns the string value of a data field from the database row supplied to the constructor.
  289. * If the field was set to a callback, that callback is invoked and the result returned.
  290. *
  291. * @param string $name
  292. *
  293. * @throws OutOfBoundsException
  294. * @throws IncompleteRevisionException
  295. * @return mixed Returns the field's value, never null.
  296. */
  297. private function getField( $name ) {
  298. if ( !isset( $this->row->$name ) ) {
  299. // distinguish between unknown and uninitialized fields
  300. if ( property_exists( $this->row, $name ) ) {
  301. throw new IncompleteRevisionException( 'Uninitialized field: ' . $name );
  302. } else {
  303. throw new OutOfBoundsException( 'No such field: ' . $name );
  304. }
  305. }
  306. $value = $this->row->$name;
  307. // NOTE: allow callbacks, but don't trust plain string callables from the database!
  308. if ( !is_string( $value ) && is_callable( $value ) ) {
  309. $value = call_user_func( $value, $this );
  310. $this->setField( $name, $value );
  311. }
  312. return $value;
  313. }
  314. /**
  315. * Returns the string value of a data field from the database row supplied to the constructor.
  316. *
  317. * @param string $name
  318. *
  319. * @throws OutOfBoundsException
  320. * @throws IncompleteRevisionException
  321. * @return string Returns the string value
  322. */
  323. private function getStringField( $name ) {
  324. return strval( $this->getField( $name ) );
  325. }
  326. /**
  327. * Returns the int value of a data field from the database row supplied to the constructor.
  328. *
  329. * @param string $name
  330. *
  331. * @throws OutOfBoundsException
  332. * @throws IncompleteRevisionException
  333. * @return int Returns the int value
  334. */
  335. private function getIntField( $name ) {
  336. return intval( $this->getField( $name ) );
  337. }
  338. /**
  339. * @param string $name
  340. * @return bool whether this record contains the given field
  341. */
  342. private function hasField( $name ) {
  343. if ( isset( $this->row->$name ) ) {
  344. // if the field is a callback, resolve first, then re-check
  345. if ( !is_string( $this->row->$name ) && is_callable( $this->row->$name ) ) {
  346. $this->getField( $name );
  347. }
  348. }
  349. return isset( $this->row->$name );
  350. }
  351. /**
  352. * Returns the ID of the revision this slot is associated with.
  353. *
  354. * @return int
  355. */
  356. public function getRevision() {
  357. return $this->getIntField( 'slot_revision_id' );
  358. }
  359. /**
  360. * Returns the revision ID of the revision that originated the slot's content.
  361. *
  362. * @return int
  363. */
  364. public function getOrigin() {
  365. return $this->getIntField( 'slot_origin' );
  366. }
  367. /**
  368. * Whether this slot was inherited from an older revision.
  369. *
  370. * If this SlotRecord is already attached to a revision, this returns true
  371. * if the slot's revision of origin is the same as the revision it belongs to.
  372. *
  373. * If this SlotRecord is not yet attached to a revision, this returns true
  374. * if the slot already has an address.
  375. *
  376. * @return bool
  377. */
  378. public function isInherited() {
  379. if ( $this->hasRevision() ) {
  380. return $this->getRevision() !== $this->getOrigin();
  381. } else {
  382. return $this->hasAddress();
  383. }
  384. }
  385. /**
  386. * Whether this slot has an address. Slots will have an address if their
  387. * content has been stored. While building a new revision,
  388. * SlotRecords will not have an address associated.
  389. *
  390. * @return bool
  391. */
  392. public function hasAddress() {
  393. return $this->hasField( 'content_address' );
  394. }
  395. /**
  396. * Whether this slot has an origin (revision ID that originated the slot's content.
  397. *
  398. * @since 1.32
  399. *
  400. * @return bool
  401. */
  402. public function hasOrigin() {
  403. return $this->hasField( 'slot_origin' );
  404. }
  405. /**
  406. * Whether this slot has a content ID. Slots will have a content ID if their
  407. * content has been stored in the content table. While building a new revision,
  408. * SlotRecords will not have an ID associated.
  409. *
  410. * Also, during schema migration, hasContentId() may return false when encountering an
  411. * un-migrated database entry in SCHEMA_COMPAT_WRITE_BOTH mode.
  412. * It will however always return true for saved revisions on SCHEMA_COMPAT_READ_NEW mode,
  413. * or without SCHEMA_COMPAT_WRITE_NEW mode. In the latter case, an emulated content ID
  414. * is used, derived from the revision's text ID.
  415. *
  416. * Note that hasContentId() returning false while hasRevision() returns true always
  417. * indicates an unmigrated row in SCHEMA_COMPAT_WRITE_BOTH mode, as described above.
  418. * For an unsaved slot, both these methods would return false.
  419. *
  420. * @since 1.32
  421. *
  422. * @return bool
  423. */
  424. public function hasContentId() {
  425. return $this->hasField( 'slot_content_id' );
  426. }
  427. /**
  428. * Whether this slot has revision ID associated. Slots will have a revision ID associated
  429. * only if they were loaded as part of an existing revision. While building a new revision,
  430. * Slotrecords will not have a revision ID associated.
  431. *
  432. * @return bool
  433. */
  434. public function hasRevision() {
  435. return $this->hasField( 'slot_revision_id' );
  436. }
  437. /**
  438. * Returns the role of the slot.
  439. *
  440. * @return string
  441. */
  442. public function getRole() {
  443. return $this->getStringField( 'role_name' );
  444. }
  445. /**
  446. * Returns the address of this slot's content.
  447. * This address can be used with BlobStore to load the Content object.
  448. *
  449. * @return string
  450. */
  451. public function getAddress() {
  452. return $this->getStringField( 'content_address' );
  453. }
  454. /**
  455. * Returns the ID of the content meta data row associated with the slot.
  456. * This information should be irrelevant to application logic, it is here to allow
  457. * the construction of a full row for the revision table.
  458. *
  459. * Note that this method may return an emulated value during schema migration in
  460. * SCHEMA_COMPAT_WRITE_OLD mode. See RevisionStore::emulateContentId for more information.
  461. *
  462. * @return int
  463. */
  464. public function getContentId() {
  465. return $this->getIntField( 'slot_content_id' );
  466. }
  467. /**
  468. * Returns the content size
  469. *
  470. * @return int size of the content, in bogo-bytes, as reported by Content::getSize.
  471. */
  472. public function getSize() {
  473. try {
  474. $size = $this->getIntField( 'content_size' );
  475. } catch ( IncompleteRevisionException $ex ) {
  476. $size = $this->getContent()->getSize();
  477. $this->setField( 'content_size', $size );
  478. }
  479. return $size;
  480. }
  481. /**
  482. * Returns the content size
  483. *
  484. * @return string hash of the content.
  485. */
  486. public function getSha1() {
  487. try {
  488. $sha1 = $this->getStringField( 'content_sha1' );
  489. } catch ( IncompleteRevisionException $ex ) {
  490. $sha1 = null;
  491. }
  492. // Compute if missing. Missing could mean null or empty.
  493. if ( $sha1 === null || $sha1 === '' ) {
  494. $format = $this->hasField( 'format_name' )
  495. ? $this->getStringField( 'format_name' )
  496. : null;
  497. $data = $this->getContent()->serialize( $format );
  498. $sha1 = self::base36Sha1( $data );
  499. $this->setField( 'content_sha1', $sha1 );
  500. }
  501. return $sha1;
  502. }
  503. /**
  504. * Returns the content model. This is the model name that decides
  505. * which ContentHandler is appropriate for interpreting the
  506. * data of the blob referenced by the address returned by getAddress().
  507. *
  508. * @return string the content model of the content
  509. */
  510. public function getModel() {
  511. try {
  512. $model = $this->getStringField( 'model_name' );
  513. } catch ( IncompleteRevisionException $ex ) {
  514. $model = $this->getContent()->getModel();
  515. $this->setField( 'model_name', $model );
  516. }
  517. return $model;
  518. }
  519. /**
  520. * Returns the blob serialization format as a MIME type.
  521. *
  522. * @note When this method returns null, the caller is expected
  523. * to auto-detect the serialization format, or to rely on
  524. * the default format associated with the content model.
  525. *
  526. * @return string|null
  527. */
  528. public function getFormat() {
  529. // XXX: we currently do not plan to store the format for each slot!
  530. if ( $this->hasField( 'format_name' ) ) {
  531. return $this->getStringField( 'format_name' );
  532. }
  533. return null;
  534. }
  535. /**
  536. * @param string $name
  537. * @param string|int|null $value
  538. */
  539. private function setField( $name, $value ) {
  540. $this->row->$name = $value;
  541. }
  542. /**
  543. * Get the base 36 SHA-1 value for a string of text
  544. *
  545. * MCR migration note: this replaces Revision::base36Sha1
  546. *
  547. * @param string $blob
  548. * @return string
  549. */
  550. public static function base36Sha1( $blob ) {
  551. return \Wikimedia\base_convert( sha1( $blob ), 16, 36, 31 );
  552. }
  553. /**
  554. * Returns true if $other has the same content as this slot.
  555. * The check is performed based on the model, address size, and hash.
  556. * Two slots can have the same content if they use different content addresses,
  557. * but if they have the same address and the same model, they have the same content.
  558. * Two slots can have the same content if they belong to different
  559. * revisions or pages.
  560. *
  561. * Note that hasSameContent() may return false even if Content::equals returns true for
  562. * the content of two slots. This may happen if the two slots have different serializations
  563. * representing equivalent Content. Such false negatives are considered acceptable. Code
  564. * that has to be absolutely sure the Content is really not the same if hasSameContent()
  565. * returns false should call getContent() and compare the Content objects directly.
  566. *
  567. * @since 1.32
  568. *
  569. * @param SlotRecord $other
  570. * @return bool
  571. */
  572. public function hasSameContent( SlotRecord $other ) {
  573. if ( $other === $this ) {
  574. return true;
  575. }
  576. if ( $this->getModel() !== $other->getModel() ) {
  577. return false;
  578. }
  579. if ( $this->hasAddress()
  580. && $other->hasAddress()
  581. && $this->getAddress() == $other->getAddress()
  582. ) {
  583. return true;
  584. }
  585. if ( $this->getSize() !== $other->getSize() ) {
  586. return false;
  587. }
  588. if ( $this->getSha1() !== $other->getSha1() ) {
  589. return false;
  590. }
  591. return true;
  592. }
  593. }
  594. /**
  595. * Retain the old class name for backwards compatibility.
  596. * @deprecated since 1.32
  597. */
  598. class_alias( SlotRecord::class, 'MediaWiki\Storage\SlotRecord' );