Merge "Add cleanup for handlers cache."
[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
42 */
43 private $row;
44
45 /**
46 * @var Content|callable
47 */
48 private $content;
49
50 /**
51 * Returns a new SlotRecord just like the given $slot, except that calling getContent()
52 * will fail with an exception.
53 *
54 * @param SlotRecord $slot
55 *
56 * @return SlotRecord
57 */
58 public static function newWithSuppressedContent( SlotRecord $slot ) {
59 $row = $slot->row;
60
61 return new SlotRecord( $row, function () {
62 throw new SuppressedDataException( 'Content suppressed!' );
63 } );
64 }
65
66 /**
67 * Constructs a new SlotRecord from an existing SlotRecord, overriding some fields.
68 * The slot's content cannot be overwritten.
69 *
70 * @param SlotRecord $slot
71 * @param array $overrides
72 *
73 * @return SlotRecord
74 */
75 private static function newDerived( SlotRecord $slot, array $overrides = [] ) {
76 $row = clone $slot->row;
77 $row->slot_id = null; // never copy the row ID!
78
79 foreach ( $overrides as $key => $value ) {
80 $row->$key = $value;
81 }
82
83 return new SlotRecord( $row, $slot->content );
84 }
85
86 /**
87 * Constructs a new SlotRecord for a new revision, inheriting the content of the given SlotRecord
88 * of a previous revision.
89 *
90 * Note that a SlotRecord constructed this way are intended as prototypes,
91 * to be used wit newSaved(). They are incomplete, so some getters such as
92 * getRevision() will fail.
93 *
94 * @param SlotRecord $slot
95 *
96 * @return SlotRecord
97 */
98 public static function newInherited( SlotRecord $slot ) {
99 return self::newDerived( $slot, [
100 'slot_inherited' => true,
101 'slot_revision_id' => null,
102 ] );
103 }
104
105 /**
106 * Constructs a new Slot from a Content object for a new revision.
107 * This is the preferred way to construct a slot for storing Content that
108 * resulted from a user edit. The slot is assumed to be not inherited.
109 *
110 * Note that a SlotRecord constructed this way are intended as prototypes,
111 * to be used wit newSaved(). They are incomplete, so some getters such as
112 * getAddress() will fail.
113 *
114 * @param string $role
115 * @param Content $content
116 *
117 * @return SlotRecord An incomplete proto-slot object, to be used with newSaved() later.
118 */
119 public static function newUnsaved( $role, Content $content ) {
120 Assert::parameterType( 'string', $role, '$role' );
121
122 $row = [
123 'slot_id' => null, // not yet known
124 'slot_revision_id' => null, // not yet known
125 'slot_inherited' => 0, // not inherited
126 'content_size' => null, // compute later
127 'content_sha1' => null, // compute later
128 'slot_content_id' => null, // not yet known, will be set in newSaved()
129 'content_address' => null, // not yet known, will be set in newSaved()
130 'role_name' => $role,
131 'model_name' => $content->getModel(),
132 ];
133
134 return new SlotRecord( (object)$row, $content );
135 }
136
137 /**
138 * Constructs a complete SlotRecord for a newly saved revision, based on the incomplete
139 * proto-slot. This adds information that has only become available during saving,
140 * particularly the revision ID and content address.
141 *
142 * @param int $revisionId the revision the slot is to be associated with (field slot_revision_id).
143 * If $protoSlot already has a revision, it must be the same.
144 * @param int $contentId the ID of the row in the content table describing the content
145 * referenced by $contentAddress (field slot_content_id).
146 * If $protoSlot already has a content ID, it must be the same.
147 * @param string $contentAddress the slot's content address (field content_address).
148 * If $protoSlot already has an address, it must be the same.
149 * @param SlotRecord $protoSlot The proto-slot that was provided as input for creating a new
150 * revision. $protoSlot must have a content address if inherited.
151 *
152 * @return SlotRecord If the state of $protoSlot is inappropriate for saving a new revision.
153 */
154 public static function newSaved(
155 $revisionId,
156 $contentId,
157 $contentAddress,
158 SlotRecord $protoSlot
159 ) {
160 Assert::parameterType( 'integer', $revisionId, '$revisionId' );
161 Assert::parameterType( 'integer', $contentId, '$contentId' );
162 Assert::parameterType( 'string', $contentAddress, '$contentAddress' );
163
164 if ( $protoSlot->hasRevision() && $protoSlot->getRevision() !== $revisionId ) {
165 throw new LogicException(
166 "Mismatching revision ID $revisionId: "
167 . "The slot already belongs to revision {$protoSlot->getRevision()}. "
168 . "Use SlotRecord::newInherited() to re-use content between revisions."
169 );
170 }
171
172 if ( $protoSlot->hasAddress() && $protoSlot->getAddress() !== $contentAddress ) {
173 throw new LogicException(
174 "Mismatching blob address $contentAddress: "
175 . "The slot already has content at {$protoSlot->getAddress()}."
176 );
177 }
178
179 if ( $protoSlot->hasAddress() && $protoSlot->getContentId() !== $contentId ) {
180 throw new LogicException(
181 "Mismatching content ID $contentId: "
182 . "The slot already has content row {$protoSlot->getContentId()} associated."
183 );
184 }
185
186 if ( $protoSlot->isInherited() && !$protoSlot->hasAddress() ) {
187 throw new InvalidArgumentException(
188 "An inherited blob should have a content address!"
189 );
190 }
191
192 return self::newDerived( $protoSlot, [
193 'slot_revision_id' => $revisionId,
194 'slot_content_id' => $contentId,
195 'content_address' => $contentAddress,
196 ] );
197 }
198
199 /**
200 * SlotRecord constructor.
201 *
202 * The following fields are supported by the $row parameter:
203 *
204 * $row->blob_data
205 * $row->blob_address
206 *
207 * @param object $row A database row composed of fields of the slot and content tables,
208 * as a raw object. Any field value can be a callback that produces the field value
209 * given this SlotRecord as a parameter. However, plain strings cannot be used as
210 * callbacks here, for security reasons.
211 * @param Content|callable $content The content object associated with the slot, or a
212 * callback that will return that Content object, given this SlotRecord as a parameter.
213 */
214 public function __construct( $row, $content ) {
215 Assert::parameterType( 'object', $row, '$row' );
216 Assert::parameterType( 'Content|callable', $content, '$content' );
217
218 Assert::parameter(
219 property_exists( $row, 'slot_id' ),
220 '$row->slot_id',
221 'must exist'
222 );
223 Assert::parameter(
224 property_exists( $row, 'slot_revision_id' ),
225 '$row->slot_revision_id',
226 'must exist'
227 );
228 Assert::parameter(
229 property_exists( $row, 'slot_inherited' ),
230 '$row->slot_inherited',
231 'must exist'
232 );
233 Assert::parameter(
234 property_exists( $row, 'slot_content_id' ),
235 '$row->slot_content_id',
236 'must exist'
237 );
238 Assert::parameter(
239 property_exists( $row, 'content_address' ),
240 '$row->content_address',
241 'must exist'
242 );
243 Assert::parameter(
244 property_exists( $row, 'model_name' ),
245 '$row->model_name',
246 'must exist'
247 );
248
249 $this->row = $row;
250 $this->content = $content;
251 }
252
253 /**
254 * Implemented to defy serialization.
255 *
256 * @throws LogicException always
257 */
258 public function __sleep() {
259 throw new LogicException( __CLASS__ . ' is not serializable.' );
260 }
261
262 /**
263 * Returns the Content of the given slot.
264 *
265 * @note This is free to load Content from whatever subsystem is necessary,
266 * performing potentially expensive operations and triggering I/O-related
267 * failure modes.
268 *
269 * @note This method does not apply audience filtering.
270 *
271 * @throws SuppressedDataException if access to the content is not allowed according
272 * to the audience check performed by RevisionRecord::getSlot().
273 *
274 * @return Content The slot's content. This is a direct reference to the internal instance,
275 * copy before exposing to application logic!
276 */
277 public function getContent() {
278 if ( $this->content instanceof Content ) {
279 return $this->content;
280 }
281
282 $obj = call_user_func( $this->content, $this );
283
284 Assert::postcondition(
285 $obj instanceof Content,
286 'Slot content callback should return a Content object'
287 );
288
289 $this->content = $obj;
290
291 return $this->content;
292 }
293
294 /**
295 * Returns the string value of a data field from the database row supplied to the constructor.
296 * If the field was set to a callback, that callback is invoked and the result returned.
297 *
298 * @param string $name
299 *
300 * @throws OutOfBoundsException
301 * @throws IncompleteRevisionException
302 * @return mixed Returns the field's value, never null.
303 */
304 private function getField( $name ) {
305 if ( !isset( $this->row->$name ) ) {
306 // distinguish between unknown and uninitialized fields
307 if ( property_exists( $this->row, $name ) ) {
308 throw new IncompleteRevisionException( 'Uninitialized field: ' . $name );
309 } else {
310 throw new OutOfBoundsException( 'No such field: ' . $name );
311 }
312 }
313
314 $value = $this->row->$name;
315
316 // NOTE: allow callbacks, but don't trust plain string callables from the database!
317 if ( !is_string( $value ) && is_callable( $value ) ) {
318 $value = call_user_func( $value, $this );
319 $this->setField( $name, $value );
320 }
321
322 return $value;
323 }
324
325 /**
326 * Returns the string value of a data field from the database row supplied to the constructor.
327 *
328 * @param string $name
329 *
330 * @throws OutOfBoundsException
331 * @throws IncompleteRevisionException
332 * @return string Returns the string value
333 */
334 private function getStringField( $name ) {
335 return strval( $this->getField( $name ) );
336 }
337
338 /**
339 * Returns the int value of a data field from the database row supplied to the constructor.
340 *
341 * @param string $name
342 *
343 * @throws OutOfBoundsException
344 * @throws IncompleteRevisionException
345 * @return int Returns the int value
346 */
347 private function getIntField( $name ) {
348 return intval( $this->getField( $name ) );
349 }
350
351 /**
352 * @param string $name
353 * @return bool whether this record contains the given field
354 */
355 private function hasField( $name ) {
356 return isset( $this->row->$name );
357 }
358
359 /**
360 * Returns the ID of the revision this slot is associated with.
361 *
362 * @return int
363 */
364 public function getRevision() {
365 return $this->getIntField( 'slot_revision_id' );
366 }
367
368 /**
369 * Whether this slot was inherited from an older revision.
370 *
371 * @return bool
372 */
373 public function isInherited() {
374 return $this->getIntField( 'slot_inherited' ) !== 0;
375 }
376
377 /**
378 * Whether this slot has an address. Slots will have an address if their
379 * content has been stored. While building a new revision,
380 * SlotRecords will not have an address associated.
381 *
382 * @return bool
383 */
384 public function hasAddress() {
385 return $this->hasField( 'content_address' );
386 }
387
388 /**
389 * Whether this slot has revision ID associated. Slots will have a revision ID associated
390 * only if they were loaded as part of an existing revision. While building a new revision,
391 * Slotrecords will not have a revision ID associated.
392 *
393 * @return bool
394 */
395 public function hasRevision() {
396 return $this->hasField( 'slot_revision_id' );
397 }
398
399 /**
400 * Returns the role of the slot.
401 *
402 * @return string
403 */
404 public function getRole() {
405 return $this->getStringField( 'role_name' );
406 }
407
408 /**
409 * Returns the address of this slot's content.
410 * This address can be used with BlobStore to load the Content object.
411 *
412 * @return string
413 */
414 public function getAddress() {
415 return $this->getStringField( 'content_address' );
416 }
417
418 /**
419 * Returns the ID of the content meta data row associated with the slot.
420 * This information should be irrelevant to application logic, it is here to allow
421 * the construction of a full row for the revision table.
422 *
423 * @return int
424 */
425 public function getContentId() {
426 return $this->getIntField( 'slot_content_id' );
427 }
428
429 /**
430 * Returns the content size
431 *
432 * @return int size of the content, in bogo-bytes, as reported by Content::getSize.
433 */
434 public function getSize() {
435 try {
436 $size = $this->getIntField( 'content_size' );
437 } catch ( IncompleteRevisionException $ex ) {
438 $size = $this->getContent()->getSize();
439 $this->setField( 'content_size', $size );
440 }
441
442 return $size;
443 }
444
445 /**
446 * Returns the content size
447 *
448 * @return string hash of the content.
449 */
450 public function getSha1() {
451 try {
452 $sha1 = $this->getStringField( 'content_sha1' );
453 } catch ( IncompleteRevisionException $ex ) {
454 $format = $this->hasField( 'format_name' )
455 ? $this->getStringField( 'format_name' )
456 : null;
457
458 $data = $this->getContent()->serialize( $format );
459 $sha1 = self::base36Sha1( $data );
460 $this->setField( 'content_sha1', $sha1 );
461 }
462
463 return $sha1;
464 }
465
466 /**
467 * Returns the content model. This is the model name that decides
468 * which ContentHandler is appropriate for interpreting the
469 * data of the blob referenced by the address returned by getAddress().
470 *
471 * @return string the content model of the content
472 */
473 public function getModel() {
474 try {
475 $model = $this->getStringField( 'model_name' );
476 } catch ( IncompleteRevisionException $ex ) {
477 $model = $this->getContent()->getModel();
478 $this->setField( 'model_name', $model );
479 }
480
481 return $model;
482 }
483
484 /**
485 * Returns the blob serialization format as a MIME type.
486 *
487 * @note When this method returns null, the caller is expected
488 * to auto-detect the serialization format, or to rely on
489 * the default format associated with the content model.
490 *
491 * @return string|null
492 */
493 public function getFormat() {
494 // XXX: we currently do not plan to store the format for each slot!
495
496 if ( $this->hasField( 'format_name' ) ) {
497 return $this->getStringField( 'format_name' );
498 }
499
500 return null;
501 }
502
503 /**
504 * @param string $name
505 * @param string|int|null $value
506 */
507 private function setField( $name, $value ) {
508 $this->row->$name = $value;
509 }
510
511 /**
512 * Get the base 36 SHA-1 value for a string of text
513 *
514 * MCR migration note: this replaces Revision::base36Sha1
515 *
516 * @param string $blob
517 * @return string
518 */
519 public static function base36Sha1( $blob ) {
520 return \Wikimedia\base_convert( sha1( $blob ), 16, 36, 31 );
521 }
522
523 }