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