StringUtils: Add a utility for checking if a string is a valid regex
[lhc/web/wiklou.git] / includes / Storage / SqlBlobStore.php
1 <?php
2 /**
3 * Service for storing and loading data blobs representing revision content.
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 * Attribution notice: when this file was created, much of its content was taken
21 * from the Revision.php file as present in release 1.30. Refer to the history
22 * of that file for original authorship.
23 *
24 * @file
25 */
26
27 namespace MediaWiki\Storage;
28
29 use AppendIterator;
30 use DBAccessObjectUtils;
31 use IDBAccessObject;
32 use IExpiringStore;
33 use InvalidArgumentException;
34 use Language;
35 use MWException;
36 use StatusValue;
37 use WANObjectCache;
38 use ExternalStoreAccess;
39 use Wikimedia\Assert\Assert;
40 use Wikimedia\Rdbms\IDatabase;
41 use Wikimedia\Rdbms\ILoadBalancer;
42
43 /**
44 * Service for storing and loading Content objects.
45 *
46 * @since 1.31
47 *
48 * @note This was written to act as a drop-in replacement for the corresponding
49 * static methods in Revision.
50 */
51 class SqlBlobStore implements IDBAccessObject, BlobStore {
52
53 // Note: the name has been taken unchanged from the Revision class.
54 const TEXT_CACHE_GROUP = 'revisiontext:10';
55
56 /**
57 * @var ILoadBalancer
58 */
59 private $dbLoadBalancer;
60
61 /**
62 * @var ExternalStoreAccess
63 */
64 private $extStoreAccess;
65
66 /**
67 * @var WANObjectCache
68 */
69 private $cache;
70
71 /**
72 * @var string|bool DB domain ID of a wiki or false for the local one
73 */
74 private $dbDomain;
75
76 /**
77 * @var int
78 */
79 private $cacheExpiry = 604800; // 7 days
80
81 /**
82 * @var bool
83 */
84 private $compressBlobs = false;
85
86 /**
87 * @var bool|string
88 */
89 private $legacyEncoding = false;
90
91 /**
92 * @var Language|null
93 */
94 private $legacyEncodingConversionLang = null;
95
96 /**
97 * @var boolean
98 */
99 private $useExternalStore = false;
100
101 /**
102 * @param ILoadBalancer $dbLoadBalancer A load balancer for acquiring database connections
103 * @param ExternalStoreAccess $extStoreAccess Access layer for external storage
104 * @param WANObjectCache $cache A cache manager for caching blobs. This can be the local
105 * wiki's default instance even if $dbDomain refers to a different wiki, since
106 * makeGlobalKey() is used to constructed a key that allows cached blobs from the
107 * same database to be re-used between wikis. For example, enwiki and frwiki will
108 * use the same cache keys for blobs from the wikidatawiki database, regardless of
109 * the cache's default key space.
110 * @param bool|string $dbDomain The ID of the target wiki database. Use false for the local wiki.
111 */
112 public function __construct(
113 ILoadBalancer $dbLoadBalancer,
114 ExternalStoreAccess $extStoreAccess,
115 WANObjectCache $cache,
116 $dbDomain = false
117 ) {
118 $this->dbLoadBalancer = $dbLoadBalancer;
119 $this->extStoreAccess = $extStoreAccess;
120 $this->cache = $cache;
121 $this->dbDomain = $dbDomain;
122 }
123
124 /**
125 * @return int time for which blobs can be cached, in seconds
126 */
127 public function getCacheExpiry() {
128 return $this->cacheExpiry;
129 }
130
131 /**
132 * @param int $cacheExpiry time for which blobs can be cached, in seconds
133 */
134 public function setCacheExpiry( $cacheExpiry ) {
135 Assert::parameterType( 'integer', $cacheExpiry, '$cacheExpiry' );
136
137 $this->cacheExpiry = $cacheExpiry;
138 }
139
140 /**
141 * @return bool whether blobs should be compressed for storage
142 */
143 public function getCompressBlobs() {
144 return $this->compressBlobs;
145 }
146
147 /**
148 * @param bool $compressBlobs whether blobs should be compressed for storage
149 */
150 public function setCompressBlobs( $compressBlobs ) {
151 $this->compressBlobs = $compressBlobs;
152 }
153
154 /**
155 * @return false|string The legacy encoding to assume for blobs that are not marked as utf8.
156 * False means handling of legacy encoding is disabled, and utf8 assumed.
157 */
158 public function getLegacyEncoding() {
159 return $this->legacyEncoding;
160 }
161
162 /**
163 * @return Language|null The locale to use when decoding from a legacy encoding, or null
164 * if handling of legacy encoding is disabled.
165 */
166 public function getLegacyEncodingConversionLang() {
167 return $this->legacyEncodingConversionLang;
168 }
169
170 /**
171 * @param string $legacyEncoding The legacy encoding to assume for blobs that are
172 * not marked as utf8.
173 * @param Language $language The locale to use when decoding from a legacy encoding.
174 */
175 public function setLegacyEncoding( $legacyEncoding, Language $language ) {
176 Assert::parameterType( 'string', $legacyEncoding, '$legacyEncoding' );
177
178 $this->legacyEncoding = $legacyEncoding;
179 $this->legacyEncodingConversionLang = $language;
180 }
181
182 /**
183 * @return bool Whether to use the ExternalStore mechanism for storing blobs.
184 */
185 public function getUseExternalStore() {
186 return $this->useExternalStore;
187 }
188
189 /**
190 * @param bool $useExternalStore Whether to use the ExternalStore mechanism for storing blobs.
191 */
192 public function setUseExternalStore( $useExternalStore ) {
193 Assert::parameterType( 'boolean', $useExternalStore, '$useExternalStore' );
194
195 $this->useExternalStore = $useExternalStore;
196 }
197
198 /**
199 * @return ILoadBalancer
200 */
201 private function getDBLoadBalancer() {
202 return $this->dbLoadBalancer;
203 }
204
205 /**
206 * @param int $index A database index, like DB_MASTER or DB_REPLICA
207 *
208 * @return IDatabase
209 */
210 private function getDBConnection( $index ) {
211 $lb = $this->getDBLoadBalancer();
212 return $lb->getConnectionRef( $index, [], $this->dbDomain );
213 }
214
215 /**
216 * Stores an arbitrary blob of data and returns an address that can be used with
217 * getBlob() to retrieve the same blob of data,
218 *
219 * @param string $data
220 * @param array $hints An array of hints.
221 *
222 * @throws BlobAccessException
223 * @return string an address that can be used with getBlob() to retrieve the data.
224 */
225 public function storeBlob( $data, $hints = [] ) {
226 try {
227 $flags = $this->compressData( $data );
228
229 # Write to external storage if required
230 if ( $this->useExternalStore ) {
231 // Store and get the URL
232 $data = $this->extStoreAccess->insert( $data, [ 'domain' => $this->dbDomain ] );
233 if ( !$data ) {
234 throw new BlobAccessException( "Failed to store text to external storage" );
235 }
236 if ( $flags ) {
237 $flags .= ',';
238 }
239 $flags .= 'external';
240
241 // TODO: we could also return an address for the external store directly here.
242 // That would mean bypassing the text table entirely when the external store is
243 // used. We'll need to assess expected fallout before doing that.
244 }
245
246 $dbw = $this->getDBConnection( DB_MASTER );
247
248 $old_id = $dbw->nextSequenceValue( 'text_old_id_seq' );
249 $dbw->insert(
250 'text',
251 [
252 'old_id' => $old_id,
253 'old_text' => $data,
254 'old_flags' => $flags,
255 ],
256 __METHOD__
257 );
258
259 $textId = $dbw->insertId();
260
261 return self::makeAddressFromTextId( $textId );
262 } catch ( MWException $e ) {
263 throw new BlobAccessException( $e->getMessage(), 0, $e );
264 }
265 }
266
267 /**
268 * Retrieve a blob, given an address.
269 * Currently hardcoded to the 'text' table storage engine.
270 *
271 * MCR migration note: this replaces Revision::loadText
272 *
273 * @param string $blobAddress
274 * @param int $queryFlags
275 *
276 * @throws BlobAccessException
277 * @return string
278 */
279 public function getBlob( $blobAddress, $queryFlags = 0 ) {
280 Assert::parameterType( 'string', $blobAddress, '$blobAddress' );
281
282 $error = null;
283 $blob = $this->cache->getWithSetCallback(
284 $this->getCacheKey( $blobAddress ),
285 $this->getCacheTTL(),
286 function ( $unused, &$ttl, &$setOpts ) use ( $blobAddress, $queryFlags, &$error ) {
287 // Ignore $setOpts; blobs are immutable and negatives are not cached
288 list( $result, $errors ) = $this->fetchBlobs( [ $blobAddress ], $queryFlags );
289 // No negative caching; negative hits on text rows may be due to corrupted replica DBs
290 $error = $errors[$blobAddress] ?? null;
291 return $result[$blobAddress];
292 },
293 [ 'pcGroup' => self::TEXT_CACHE_GROUP, 'pcTTL' => IExpiringStore::TTL_PROC_LONG ]
294 );
295
296 if ( $error ) {
297 throw new BlobAccessException( $error );
298 }
299
300 Assert::postcondition( is_string( $blob ), 'Blob must not be null' );
301 return $blob;
302 }
303
304 /**
305 * A batched version of BlobStore::getBlob.
306 *
307 * @param string[] $blobAddresses An array of blob addresses.
308 * @param int $queryFlags See IDBAccessObject.
309 * @throws BlobAccessException
310 * @return StatusValue A status with a map of blobAddress => binary blob data or null
311 * if fetching the blob has failed. Fetch failures errors are the
312 * warnings in the status object.
313 * @since 1.34
314 */
315 public function getBlobBatch( $blobAddresses, $queryFlags = 0 ) {
316 $errors = null;
317 $addressByCacheKey = $this->cache->makeMultiKeys(
318 $blobAddresses,
319 function ( $blobAddress ) {
320 return $this->getCacheKey( $blobAddress );
321 }
322 );
323 $blobsByCacheKey = $this->cache->getMultiWithUnionSetCallback(
324 $addressByCacheKey,
325 $this->getCacheTTL(),
326 function ( array $blobAddresses, array &$ttls, array &$setOpts ) use ( $queryFlags, &$errors ) {
327 // Ignore $setOpts; blobs are immutable and negatives are not cached
328 list( $result, $errors ) = $this->fetchBlobs( $blobAddresses, $queryFlags );
329 return $result;
330 },
331 [ 'pcGroup' => self::TEXT_CACHE_GROUP, 'pcTTL' => IExpiringStore::TTL_PROC_LONG ]
332 );
333
334 // Remap back to incoming blob addresses. The return value of the
335 // WANObjectCache::getMultiWithUnionSetCallback is keyed on the internal
336 // keys from WANObjectCache::makeMultiKeys, so we need to remap them
337 // before returning to the client.
338 $blobsByAddress = [];
339 foreach ( $blobsByCacheKey as $cacheKey => $blob ) {
340 $blobsByAddress[ $addressByCacheKey[ $cacheKey ] ] = $blob !== false ? $blob : null;
341 }
342
343 $result = StatusValue::newGood( $blobsByAddress );
344 if ( $errors ) {
345 foreach ( $errors as $error ) {
346 $result->warning( 'internalerror', $error );
347 }
348 }
349 return $result;
350 }
351
352 /**
353 * MCR migration note: this corresponds to Revision::fetchText
354 *
355 * @param string[] $blobAddresses
356 * @param int $queryFlags
357 *
358 * @throws BlobAccessException
359 * @return array [ $result, $errors ] A map of blob addresses to successfully fetched blobs
360 * or false if fetch failed, plus and array of errors
361 */
362 private function fetchBlobs( $blobAddresses, $queryFlags ) {
363 $textIdToBlobAddress = [];
364 $result = [];
365 $errors = [];
366 foreach ( $blobAddresses as $blobAddress ) {
367 list( $schema, $id ) = self::splitBlobAddress( $blobAddress );
368 //TODO: MCR: also support 'ex' schema with ExternalStore URLs, plus flags encoded in the URL!
369 if ( $schema === 'tt' ) {
370 $textId = intval( $id );
371 $textIdToBlobAddress[$textId] = $blobAddress;
372 } else {
373 $errors[$blobAddress] = "Unknown blob address schema: $schema";
374 $result[$blobAddress] = false;
375 continue;
376 }
377
378 if ( !$textId || $id !== (string)$textId ) {
379 $errors[$blobAddress] = "Bad blob address: $blobAddress";
380 $result[$blobAddress] = false;
381 }
382 }
383
384 $textIds = array_keys( $textIdToBlobAddress );
385 if ( !$textIds ) {
386 return [ $result, $errors ];
387 }
388 // Callers doing updates will pass in READ_LATEST as usual. Since the text/blob tables
389 // do not normally get rows changed around, set READ_LATEST_IMMUTABLE in those cases.
390 $queryFlags |= DBAccessObjectUtils::hasFlags( $queryFlags, self::READ_LATEST )
391 ? self::READ_LATEST_IMMUTABLE
392 : 0;
393 list( $index, $options, $fallbackIndex, $fallbackOptions ) =
394 DBAccessObjectUtils::getDBOptions( $queryFlags );
395 // Text data is immutable; check replica DBs first.
396 $dbConnection = $this->getDBConnection( $index );
397 $rows = $dbConnection->select(
398 'text',
399 [ 'old_id', 'old_text', 'old_flags' ],
400 [ 'old_id' => $textIds ],
401 __METHOD__,
402 $options
403 );
404
405 // Fallback to DB_MASTER in some cases if not all the rows were found, using the appropriate
406 // options, such as FOR UPDATE to avoid missing rows due to REPEATABLE-READ.
407 if ( $dbConnection->numRows( $rows ) !== count( $textIds ) && $fallbackIndex !== null ) {
408 $fetchedTextIds = [];
409 foreach ( $rows as $row ) {
410 $fetchedTextIds[] = $row->old_id;
411 }
412 $missingTextIds = array_diff( $textIds, $fetchedTextIds );
413 $dbConnection = $this->getDBConnection( $fallbackIndex );
414 $rowsFromFallback = $dbConnection->select(
415 'text',
416 [ 'old_id', 'old_text', 'old_flags' ],
417 [ 'old_id' => $missingTextIds ],
418 __METHOD__,
419 $fallbackOptions
420 );
421 $appendIterator = new AppendIterator();
422 $appendIterator->append( $rows );
423 $appendIterator->append( $rowsFromFallback );
424 $rows = $appendIterator;
425 }
426
427 foreach ( $rows as $row ) {
428 $blobAddress = $textIdToBlobAddress[$row->old_id];
429 $blob = $this->expandBlob( $row->old_text, $row->old_flags, $blobAddress );
430 if ( $blob === false ) {
431 $errors[$blobAddress] = "Bad data in text row {$row->old_id}.";
432 }
433 $result[$blobAddress] = $blob;
434 }
435
436 // If we're still missing some of the rows, set errors for missing blobs.
437 if ( count( $result ) !== count( $blobAddresses ) ) {
438 foreach ( $blobAddresses as $blobAddress ) {
439 if ( !isset( $result[$blobAddress ] ) ) {
440 $errors[$blobAddress] = "Unable to fetch blob at $blobAddress";
441 $result[$blobAddress] = false;
442 }
443 }
444 }
445 return [ $result, $errors ];
446 }
447
448 /**
449 * Get a cache key for a given Blob address.
450 *
451 * The cache key is constructed in a way that allows cached blobs from the same database
452 * to be re-used between wikis. For example, enwiki and frwiki will use the same cache keys
453 * for blobs from the wikidatawiki database.
454 *
455 * @param string $blobAddress
456 * @return string
457 */
458 private function getCacheKey( $blobAddress ) {
459 return $this->cache->makeGlobalKey(
460 'BlobStore',
461 'address',
462 $this->dbLoadBalancer->resolveDomainID( $this->dbDomain ),
463 $blobAddress
464 );
465 }
466
467 /**
468 * Expand a raw data blob according to the flags given.
469 *
470 * MCR migration note: this replaces Revision::getRevisionText
471 *
472 * @note direct use is deprecated, use getBlob() or SlotRecord::getContent() instead.
473 * @todo make this private, there should be no need to use this method outside this class.
474 *
475 * @param string $raw The raw blob data, to be processed according to $flags.
476 * May be the blob itself, or the blob compressed, or just the address
477 * of the actual blob, depending on $flags.
478 * @param string|string[] $flags Blob flags, such as 'external' or 'gzip'.
479 * Note that not including 'utf-8' in $flags will cause the data to be decoded
480 * according to the legacy encoding specified via setLegacyEncoding.
481 * @param string|null $cacheKey A blob address for use in the cache key. If not given,
482 * caching is disabled.
483 *
484 * @return false|string The expanded blob or false on failure
485 */
486 public function expandBlob( $raw, $flags, $cacheKey = null ) {
487 if ( is_string( $flags ) ) {
488 $flags = explode( ',', $flags );
489 }
490
491 // Use external methods for external objects, text in table is URL-only then
492 if ( in_array( 'external', $flags ) ) {
493 $url = $raw;
494 $parts = explode( '://', $url, 2 );
495 if ( count( $parts ) == 1 || $parts[1] == '' ) {
496 return false;
497 }
498
499 if ( $cacheKey ) {
500 // The cached value should be decompressed, so handle that and return here.
501 return $this->cache->getWithSetCallback(
502 $this->getCacheKey( $cacheKey ),
503 $this->getCacheTTL(),
504 function () use ( $url, $flags ) {
505 // Ignore $setOpts; blobs are immutable and negatives are not cached
506 $blob = $this->extStoreAccess
507 ->fetchFromURL( $url, [ 'domain' => $this->dbDomain ] );
508
509 return $blob === false ? false : $this->decompressData( $blob, $flags );
510 },
511 [ 'pcGroup' => self::TEXT_CACHE_GROUP, 'pcTTL' => WANObjectCache::TTL_PROC_LONG ]
512 );
513 } else {
514 $blob = $this->extStoreAccess->fetchFromURL( $url, [ 'domain' => $this->dbDomain ] );
515 return $blob === false ? false : $this->decompressData( $blob, $flags );
516 }
517 } else {
518 return $this->decompressData( $raw, $flags );
519 }
520 }
521
522 /**
523 * If $wgCompressRevisions is enabled, we will compress data.
524 * The input string is modified in place.
525 * Return value is the flags field: contains 'gzip' if the
526 * data is compressed, and 'utf-8' if we're saving in UTF-8
527 * mode.
528 *
529 * MCR migration note: this replaces Revision::compressRevisionText
530 *
531 * @note direct use is deprecated!
532 * @todo make this private, there should be no need to use this method outside this class.
533 *
534 * @param mixed &$blob Reference to a text
535 *
536 * @return string
537 */
538 public function compressData( &$blob ) {
539 $blobFlags = [];
540
541 // Revisions not marked as UTF-8 will have legacy decoding applied by decompressData().
542 // XXX: if $this->legacyEncoding is not set, we could skip this. That would however be
543 // risky, since $this->legacyEncoding being set in the future would lead to data corruption.
544 $blobFlags[] = 'utf-8';
545
546 if ( $this->compressBlobs ) {
547 if ( function_exists( 'gzdeflate' ) ) {
548 $deflated = gzdeflate( $blob );
549
550 if ( $deflated === false ) {
551 wfLogWarning( __METHOD__ . ': gzdeflate() failed' );
552 } else {
553 $blob = $deflated;
554 $blobFlags[] = 'gzip';
555 }
556 } else {
557 wfDebug( __METHOD__ . " -- no zlib support, not compressing\n" );
558 }
559 }
560 return implode( ',', $blobFlags );
561 }
562
563 /**
564 * Re-converts revision text according to its flags.
565 *
566 * MCR migration note: this replaces Revision::decompressRevisionText
567 *
568 * @note direct use is deprecated, use getBlob() or SlotRecord::getContent() instead.
569 * @todo make this private, there should be no need to use this method outside this class.
570 *
571 * @param string $blob Blob in compressed/encoded form.
572 * @param array $blobFlags Compression flags, such as 'gzip'.
573 * Note that not including 'utf-8' in $blobFlags will cause the data to be decoded
574 * according to the legacy encoding specified via setLegacyEncoding.
575 *
576 * @return string|bool Decompressed text, or false on failure
577 */
578 public function decompressData( $blob, array $blobFlags ) {
579 // Revision::decompressRevisionText accepted false here, so defend against that
580 Assert::parameterType( 'string', $blob, '$blob' );
581
582 if ( in_array( 'error', $blobFlags ) ) {
583 // Error row, return false
584 return false;
585 }
586
587 if ( in_array( 'gzip', $blobFlags ) ) {
588 # Deal with optional compression of archived pages.
589 # This can be done periodically via maintenance/compressOld.php, and
590 # as pages are saved if $wgCompressRevisions is set.
591 $blob = gzinflate( $blob );
592
593 if ( $blob === false ) {
594 wfWarn( __METHOD__ . ': gzinflate() failed' );
595 return false;
596 }
597 }
598
599 if ( in_array( 'object', $blobFlags ) ) {
600 # Generic compressed storage
601 $obj = unserialize( $blob );
602 if ( !is_object( $obj ) ) {
603 // Invalid object
604 return false;
605 }
606 $blob = $obj->getText();
607 }
608
609 // Needed to support old revisions left over from from the 1.4 / 1.5 migration.
610 if ( $blob !== false && $this->legacyEncoding && $this->legacyEncodingConversionLang
611 && !in_array( 'utf-8', $blobFlags ) && !in_array( 'utf8', $blobFlags )
612 ) {
613 # Old revisions kept around in a legacy encoding?
614 # Upconvert on demand.
615 # ("utf8" checked for compatibility with some broken
616 # conversion scripts 2008-12-30)
617 $blob = $this->legacyEncodingConversionLang->iconv( $this->legacyEncoding, 'UTF-8', $blob );
618 }
619
620 return $blob;
621 }
622
623 /**
624 * Get the text cache TTL
625 *
626 * MCR migration note: this replaces Revision::getCacheTTL
627 *
628 * @return int
629 */
630 private function getCacheTTL() {
631 if ( $this->cache->getQoS( WANObjectCache::ATTR_EMULATION )
632 <= WANObjectCache::QOS_EMULATION_SQL
633 ) {
634 // Do not cache RDBMs blobs in...the RDBMs store
635 $ttl = WANObjectCache::TTL_UNCACHEABLE;
636 } else {
637 $ttl = $this->cacheExpiry ?: WANObjectCache::TTL_UNCACHEABLE;
638 }
639
640 return $ttl;
641 }
642
643 /**
644 * Returns an ID corresponding to the old_id field in the text table, corresponding
645 * to the given $address.
646 *
647 * Currently, $address must start with 'tt:' followed by a decimal integer representing
648 * the old_id; if $address does not start with 'tt:', null is returned. However,
649 * the implementation may change to insert rows into the text table on the fly.
650 * This implies that this method cannot be static.
651 *
652 * @note This method exists for use with the text table based storage schema.
653 * It should not be assumed that is will function with all future kinds of content addresses.
654 *
655 * @deprecated since 1.31, so don't assume that all blob addresses refer to a row in the text
656 * table. This method should become private once the relevant refactoring in WikiPage is
657 * complete.
658 *
659 * @param string $address
660 *
661 * @return int|null
662 */
663 public function getTextIdFromAddress( $address ) {
664 list( $schema, $id, ) = self::splitBlobAddress( $address );
665
666 if ( $schema !== 'tt' ) {
667 return null;
668 }
669
670 $textId = intval( $id );
671
672 if ( !$textId || $id !== (string)$textId ) {
673 throw new InvalidArgumentException( "Malformed text_id: $id" );
674 }
675
676 return $textId;
677 }
678
679 /**
680 * Returns an address referring to content stored in the text table row with the given ID.
681 * The address schema for blobs stored in the text table is "tt:" followed by an integer
682 * that corresponds to a value of the old_id field.
683 *
684 * @deprecated since 1.31. This method should become private once the relevant refactoring
685 * in WikiPage is complete.
686 *
687 * @param int $id
688 *
689 * @return string
690 */
691 public static function makeAddressFromTextId( $id ) {
692 return 'tt:' . $id;
693 }
694
695 /**
696 * Splits a blob address into three parts: the schema, the ID, and parameters/flags.
697 *
698 * @since 1.33
699 *
700 * @param string $address
701 *
702 * @throws InvalidArgumentException
703 * @return array [ $schema, $id, $parameters ], with $parameters being an assoc array.
704 */
705 public static function splitBlobAddress( $address ) {
706 if ( !preg_match( '/^(\w+):(\w+)(\?(.*))?$/', $address, $m ) ) {
707 throw new InvalidArgumentException( "Bad blob address: $address" );
708 }
709
710 $schema = strtolower( $m[1] );
711 $id = $m[2];
712 $parameters = isset( $m[4] ) ? wfCgiToArray( $m[4] ) : [];
713
714 return [ $schema, $id, $parameters ];
715 }
716
717 public function isReadOnly() {
718 if ( $this->useExternalStore && $this->extStoreAccess->isReadOnly() ) {
719 return true;
720 }
721
722 return ( $this->getDBLoadBalancer()->getReadOnlyReason() !== false );
723 }
724 }