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