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