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