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