Merge ".mailmap: Correct two contributor names"
[mediawiki.git] / includes / libs / rdbms / database / Database.php
blobeffe306700b8e910440e5d7557ab00b502488d59
1 <?php
2 /**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
18 * @file
20 namespace Wikimedia\Rdbms;
22 use InvalidArgumentException;
23 use LogicException;
24 use Psr\Log\LoggerAwareInterface;
25 use Psr\Log\LoggerInterface;
26 use Psr\Log\NullLogger;
27 use RuntimeException;
28 use Stringable;
29 use Throwable;
30 use Wikimedia\AtEase\AtEase;
31 use Wikimedia\Rdbms\Database\DatabaseFlags;
32 use Wikimedia\Rdbms\Platform\SQLPlatform;
33 use Wikimedia\Rdbms\Replication\ReplicationReporter;
34 use Wikimedia\RequestTimeout\CriticalSectionProvider;
35 use Wikimedia\RequestTimeout\CriticalSectionScope;
36 use Wikimedia\ScopedCallback;
37 use Wikimedia\Telemetry\NoopTracer;
38 use Wikimedia\Telemetry\SpanInterface;
39 use Wikimedia\Telemetry\TracerInterface;
41 /**
42 * A single concrete connection to a relational database.
44 * This is the base class for all connection-specific relational database handles.
45 * No two instances of this class should share the same underlying network connection.
47 * @see IDatabase
48 * @ingroup Database
49 * @since 1.28
51 abstract class Database implements Stringable, IDatabaseForOwner, IMaintainableDatabase, LoggerAwareInterface {
52 /** @var CriticalSectionProvider|null */
53 protected $csProvider;
54 /** @var LoggerInterface */
55 protected $logger;
56 /** @var callable Error logging callback */
57 protected $errorLogger;
58 /** @var callable Deprecation logging callback */
59 protected $deprecationLogger;
60 /** @var callable|null */
61 protected $profiler;
62 /** @var TracerInterface */
63 private $tracer;
64 /** @var TransactionManager */
65 private $transactionManager;
67 /** @var DatabaseDomain */
68 protected $currentDomain;
69 /** @var DatabaseFlags */
70 protected $flagsHolder;
72 // phpcs:ignore MediaWiki.Commenting.PropertyDocumentation.ObjectTypeHintVar
73 /** @var object|resource|null Database connection */
74 protected $conn;
76 /** @var string|null Readable name or host/IP of the database server */
77 protected $serverName;
78 /** @var bool Whether this PHP instance is for a CLI script */
79 protected $cliMode;
80 /** @var int|null Maximum seconds to wait on connection attempts */
81 protected $connectTimeout;
82 /** @var int|null Maximum seconds to wait on receiving query results */
83 protected $receiveTimeout;
84 /** @var string Agent name for query profiling */
85 protected $agent;
86 /** @var array<string,mixed> Connection parameters used by initConnection() and open() */
87 protected $connectionParams;
88 /** @var string[]|int[]|float[] SQL variables values to use for all new connections */
89 protected $connectionVariables;
90 /** @var int Row batch size to use for emulated INSERT SELECT queries */
91 protected $nonNativeInsertSelectBatchSize;
93 /** @var bool Whether to use SSL connections */
94 protected $ssl;
95 /** @var bool Whether to check for warnings */
96 protected $strictWarnings;
97 /** @var array Current LoadBalancer tracking information */
98 protected $lbInfo = [];
99 /** @var string|false Current SQL query delimiter */
100 protected $delimiter = ';';
102 /** @var string|bool|null Stashed value of html_errors INI setting */
103 private $htmlErrors;
105 /** @var array<string,array> Map of (lock name => (UNIX time,trx ID)) */
106 protected $sessionNamedLocks = [];
107 /** @var array<string,array<string, TempTableInfo>> Map of (DB name => table name => info) */
108 protected $sessionTempTables = [];
110 /** @var int Affected row count for the last statement to query() */
111 protected $lastQueryAffectedRows = 0;
112 /** @var int|null Insert (row) ID for the last statement to query() (null if not supported) */
113 protected $lastQueryInsertId;
115 /** @var int|null Affected row count for the last query method call; null if unspecified */
116 protected $lastEmulatedAffectedRows;
117 /** @var int|null Insert (row) ID for the last query method call; null if unspecified */
118 protected $lastEmulatedInsertId;
120 /** @var string Last error during connection; empty string if none */
121 protected $lastConnectError = '';
123 /** @var float UNIX timestamp of the last server response */
124 private $lastPing = 0.0;
125 /** @var float|null UNIX timestamp of the last committed write */
126 private $lastWriteTime;
127 /** @var string|false The last PHP error from a query or connection attempt */
128 private $lastPhpError = false;
130 /** @var int|null Current critical section numeric ID */
131 private $csmId;
132 /** @var string|null Last critical section caller name */
133 private $csmFname;
134 /** @var DBUnexpectedError|null Last unresolved critical section error */
135 private $csmError;
137 /** Whether the database is a file on disk */
138 public const ATTR_DB_IS_FILE = 'db-is-file';
139 /** Lock granularity is on the level of the entire database */
140 public const ATTR_DB_LEVEL_LOCKING = 'db-level-locking';
141 /** The SCHEMA keyword refers to a grouping of tables in a database */
142 public const ATTR_SCHEMAS_AS_TABLE_GROUPS = 'supports-schemas';
144 /** New Database instance will not be connected yet when returned */
145 public const NEW_UNCONNECTED = 0;
146 /** New Database instance will already be connected when returned */
147 public const NEW_CONNECTED = 1;
149 /** No errors occurred during the query */
150 protected const ERR_NONE = 0;
151 /** Retry query due to a connection loss detected while sending the query (session intact) */
152 protected const ERR_RETRY_QUERY = 1;
153 /** Abort query (no retries) due to a statement rollback (session/transaction intact) */
154 protected const ERR_ABORT_QUERY = 2;
155 /** Abort any current transaction, by rolling it back, due to an error during the query */
156 protected const ERR_ABORT_TRX = 4;
157 /** Abort and reset session due to server-side session-level state loss (locks, temp tables) */
158 protected const ERR_ABORT_SESSION = 8;
160 /** Assume that queries taking this long to yield connection loss errors are at fault */
161 protected const DROPPED_CONN_BLAME_THRESHOLD_SEC = 3.0;
163 /** @var string Idiom used when a cancelable atomic section started the transaction */
164 private const NOT_APPLICABLE = 'n/a';
166 /** How long before it is worth doing a dummy query to test the connection */
167 private const PING_TTL = 1.0;
168 /** Dummy SQL query */
169 private const PING_QUERY = 'SELECT 1 AS ping';
171 /** Hostname or IP address to use on all connections */
172 protected const CONN_HOST = 'host';
173 /** Database server username to use on all connections */
174 protected const CONN_USER = 'user';
175 /** Database server password to use on all connections */
176 protected const CONN_PASSWORD = 'password';
177 /** Database name to use on initial connection */
178 protected const CONN_INITIAL_DB = 'dbname';
179 /** Schema name to use on initial connection */
180 protected const CONN_INITIAL_SCHEMA = 'schema';
181 /** Table prefix to use on initial connection */
182 protected const CONN_INITIAL_TABLE_PREFIX = 'tablePrefix';
184 /** @var SQLPlatform */
185 protected $platform;
187 /** @var ReplicationReporter */
188 protected $replicationReporter;
191 * @note exceptions for missing libraries/drivers should be thrown in initConnection()
192 * @param array $params Parameters passed from Database::factory()
194 public function __construct( array $params ) {
195 $this->logger = $params['logger'] ?? new NullLogger();
196 $this->transactionManager = new TransactionManager(
197 $this->logger,
198 $params['trxProfiler']
200 $this->connectionParams = [
201 self::CONN_HOST => ( isset( $params['host'] ) && $params['host'] !== '' )
202 ? $params['host']
203 : null,
204 self::CONN_USER => ( isset( $params['user'] ) && $params['user'] !== '' )
205 ? $params['user']
206 : null,
207 self::CONN_INITIAL_DB => ( isset( $params['dbname'] ) && $params['dbname'] !== '' )
208 ? $params['dbname']
209 : null,
210 self::CONN_INITIAL_SCHEMA => ( isset( $params['schema'] ) && $params['schema'] !== '' )
211 ? $params['schema']
212 : null,
213 self::CONN_PASSWORD => is_string( $params['password'] ) ? $params['password'] : null,
214 self::CONN_INITIAL_TABLE_PREFIX => (string)$params['tablePrefix']
217 $this->lbInfo = $params['lbInfo'] ?? [];
218 $this->connectionVariables = $params['variables'] ?? [];
219 // Set SQL mode, default is turning them all off, can be overridden or skipped with null
220 if ( is_string( $params['sqlMode'] ?? null ) ) {
221 $this->connectionVariables['sql_mode'] = $params['sqlMode'];
223 $flags = (int)$params['flags'];
224 $this->flagsHolder = new DatabaseFlags( $flags );
225 $this->ssl = $params['ssl'] ?? (bool)( $flags & self::DBO_SSL );
226 $this->connectTimeout = $params['connectTimeout'] ?? null;
227 $this->receiveTimeout = $params['receiveTimeout'] ?? null;
228 $this->cliMode = (bool)$params['cliMode'];
229 $this->agent = (string)$params['agent'];
230 $this->serverName = $params['serverName'];
231 $this->nonNativeInsertSelectBatchSize = $params['nonNativeInsertSelectBatchSize'] ?? 10000;
232 $this->strictWarnings = !empty( $params['strictWarnings'] );
234 $this->profiler = is_callable( $params['profiler'] ) ? $params['profiler'] : null;
235 $this->errorLogger = $params['errorLogger'];
236 $this->deprecationLogger = $params['deprecationLogger'];
238 $this->csProvider = $params['criticalSectionProvider'] ?? null;
240 // Set initial dummy domain until open() sets the final DB/prefix
241 $this->currentDomain = new DatabaseDomain(
242 $params['dbname'] != '' ? $params['dbname'] : null,
243 $params['schema'] != '' ? $params['schema'] : null,
244 $params['tablePrefix']
246 $this->platform = new SQLPlatform(
247 $this,
248 $this->logger,
249 $this->currentDomain,
250 $this->errorLogger
252 $this->tracer = $params['tracer'] ?? new NoopTracer();
253 // Children classes must set $this->replicationReporter.
257 * Initialize the connection to the database over the wire (or to local files)
259 * @throws LogicException
260 * @throws InvalidArgumentException
261 * @throws DBConnectionError
262 * @since 1.31
264 final public function initConnection() {
265 if ( $this->isOpen() ) {
266 throw new LogicException( __METHOD__ . ': already connected' );
268 // Establish the connection
269 $this->open(
270 $this->connectionParams[self::CONN_HOST],
271 $this->connectionParams[self::CONN_USER],
272 $this->connectionParams[self::CONN_PASSWORD],
273 $this->connectionParams[self::CONN_INITIAL_DB],
274 $this->connectionParams[self::CONN_INITIAL_SCHEMA],
275 $this->connectionParams[self::CONN_INITIAL_TABLE_PREFIX]
277 $this->lastPing = microtime( true );
281 * Open a new connection to the database (closing any existing one)
283 * @param string|null $server Server host/address and optional port {@see connectionParams}
284 * @param string|null $user User name {@see connectionParams}
285 * @param string|null $password User password {@see connectionParams}
286 * @param string|null $db Database name
287 * @param string|null $schema Database schema name
288 * @param string $tablePrefix
289 * @throws DBConnectionError
291 abstract protected function open( $server, $user, $password, $db, $schema, $tablePrefix );
294 * @return array Map of (Database::ATTR_* constant => value)
295 * @since 1.31
297 public static function getAttributes() {
298 return [];
302 * Set the PSR-3 logger interface to use.
304 * @param LoggerInterface $logger
306 public function setLogger( LoggerInterface $logger ) {
307 $this->logger = $logger;
310 public function getServerInfo() {
311 return $this->getServerVersion();
314 public function tablePrefix( $prefix = null ) {
315 $old = $this->currentDomain->getTablePrefix();
317 if ( $prefix !== null ) {
318 $this->currentDomain = new DatabaseDomain(
319 $this->currentDomain->getDatabase(),
320 $this->currentDomain->getSchema(),
321 $prefix
323 $this->platform->setCurrentDomain( $this->currentDomain );
326 return $old;
329 public function dbSchema( $schema = null ) {
330 $old = $this->currentDomain->getSchema();
332 if ( $schema !== null ) {
333 if ( $schema !== '' && $this->getDBname() === null ) {
334 throw new DBUnexpectedError(
335 $this,
336 "Cannot set schema to '$schema'; no database set"
340 $this->currentDomain = new DatabaseDomain(
341 $this->currentDomain->getDatabase(),
342 // DatabaseDomain uses null for unspecified schemas
343 ( $schema !== '' ) ? $schema : null,
344 $this->currentDomain->getTablePrefix()
346 $this->platform->setCurrentDomain( $this->currentDomain );
349 return (string)$old;
352 public function getLBInfo( $name = null ) {
353 if ( $name === null ) {
354 return $this->lbInfo;
357 if ( array_key_exists( $name, $this->lbInfo ) ) {
358 return $this->lbInfo[$name];
361 return null;
364 public function setLBInfo( $nameOrArray, $value = null ) {
365 if ( is_array( $nameOrArray ) ) {
366 $this->lbInfo = $nameOrArray;
367 } elseif ( is_string( $nameOrArray ) ) {
368 if ( $value !== null ) {
369 $this->lbInfo[$nameOrArray] = $value;
370 } else {
371 unset( $this->lbInfo[$nameOrArray] );
373 } else {
374 throw new InvalidArgumentException( "Got non-string key" );
378 public function lastDoneWrites() {
379 return $this->lastWriteTime;
383 * @return bool
384 * @since 1.39
385 * @internal For use by Database/LoadBalancer only
387 public function sessionLocksPending() {
388 return (bool)$this->sessionNamedLocks;
392 * @return ?string Owner name of explicit transaction round being participating in; null if none
394 final protected function getTransactionRoundFname() {
395 if ( $this->flagsHolder->hasImplicitTrxFlag() ) {
396 // LoadBalancer transaction round participation is enabled for this DB handle;
397 // get the owner of the active explicit transaction round (if any)
398 return $this->getLBInfo( self::LB_TRX_ROUND_FNAME );
401 return null;
404 public function isOpen() {
405 return (bool)$this->conn;
408 public function getDomainID() {
409 return $this->currentDomain->getId();
413 * Wrapper for addslashes()
415 * @param string $s String to be slashed.
416 * @return string Slashed string.
418 abstract public function strencode( $s );
421 * Set a custom error handler for logging errors during database connection
423 protected function installErrorHandler() {
424 $this->lastPhpError = false;
425 $this->htmlErrors = ini_set( 'html_errors', '0' );
426 set_error_handler( [ $this, 'connectionErrorLogger' ] );
430 * Restore the previous error handler and return the last PHP error for this DB
432 * @return string|false
434 protected function restoreErrorHandler() {
435 restore_error_handler();
436 if ( $this->htmlErrors !== false ) {
437 ini_set( 'html_errors', $this->htmlErrors );
440 return $this->getLastPHPError();
444 * @return string|false Last PHP error for this DB (typically connection errors)
446 protected function getLastPHPError() {
447 if ( $this->lastPhpError ) {
448 $error = preg_replace( '!\[<a.*</a>\]!', '', $this->lastPhpError );
449 $error = preg_replace( '!^.*?:\s?(.*)$!', '$1', $error );
451 return $error;
454 return false;
458 * Error handler for logging errors during database connection
460 * @internal This method should not be used outside of Database classes
462 * @param int|string $errno
463 * @param string $errstr
465 public function connectionErrorLogger( $errno, $errstr ) {
466 $this->lastPhpError = $errstr;
470 * Create a log context to pass to PSR-3 logger functions.
472 * @param array $extras Additional data to add to context
473 * @return array
475 protected function getLogContext( array $extras = [] ) {
476 return array_merge(
478 'db_server' => $this->getServerName(),
479 'db_name' => $this->getDBname(),
480 'db_user' => $this->connectionParams[self::CONN_USER] ?? null,
482 $extras
486 final public function close( $fname = __METHOD__ ) {
487 $error = null; // error to throw after disconnecting
489 $wasOpen = (bool)$this->conn;
490 // This should mostly do nothing if the connection is already closed
491 if ( $this->conn ) {
492 // Roll back any dangling transaction first
493 if ( $this->trxLevel() ) {
494 $error = $this->transactionManager->trxCheckBeforeClose( $this, $fname );
495 // Rollback the changes and run any callbacks as needed
496 $this->rollback( __METHOD__, self::FLUSHING_INTERNAL );
497 $this->runTransactionPostRollbackCallbacks();
500 // Close the actual connection in the binding handle
501 $closed = $this->closeConnection();
502 } else {
503 $closed = true; // already closed; nothing to do
506 $this->conn = null;
508 // Log any unexpected errors after having disconnected
509 if ( $error !== null ) {
510 // T217819, T231443: this is probably just LoadBalancer trying to recover from
511 // errors and shutdown. Log any problems and move on since the request has to
512 // end one way or another. Throwing errors is not very useful at some point.
513 $this->logger->error( $error, [ 'db_log_category' => 'query' ] );
516 // Note that various subclasses call close() at the start of open(), which itself is
517 // called by replaceLostConnection(). In that case, just because onTransactionResolution()
518 // callbacks are pending does not mean that an exception should be thrown. Rather, they
519 // will be executed after the reconnection step.
520 if ( $wasOpen ) {
521 // Double check that no callbacks are dangling
522 $fnames = $this->pendingWriteAndCallbackCallers();
523 if ( $fnames ) {
524 throw new RuntimeException(
525 "Transaction callbacks are still pending: " . implode( ', ', $fnames )
530 return $closed;
534 * Make sure there is an open connection handle (alive or not)
536 * This guards against fatal errors to the binding handle not being defined in cases
537 * where open() was never called or close() was already called.
539 * @throws DBUnexpectedError
541 final protected function assertHasConnectionHandle() {
542 if ( !$this->isOpen() ) {
543 throw new DBUnexpectedError( $this, "DB connection was already closed" );
548 * Closes underlying database connection
549 * @return bool Whether connection was closed successfully
550 * @since 1.20
552 abstract protected function closeConnection();
555 * Run a query and return a QueryStatus instance with the query result information
557 * This is meant to handle the basic command of actually sending a query to the
558 * server via the driver. No implicit transaction, reconnection, nor retry logic
559 * should happen here. The higher level query() method is designed to handle those
560 * sorts of concerns. This method should not trigger such higher level methods.
562 * The lastError() and lastErrno() methods should meaningfully reflect what error,
563 * if any, occurred during the last call to this method. Methods like executeQuery(),
564 * query(), select(), insert(), update(), delete(), and upsert() implement their calls
565 * to doQuery() such that an immediately subsequent call to lastError()/lastErrno()
566 * meaningfully reflects any error that occurred during that public query method call.
568 * For SELECT queries, the result field contains either:
569 * - a) A driver-specific IResultWrapper describing the query results
570 * - b) False, on any query failure
572 * For non-SELECT queries, the result field contains either:
573 * - a) A driver-specific IResultWrapper, only on success
574 * - b) True, only on success (e.g. no meaningful result other than "OK")
575 * - c) False, on any query failure
577 * @param string $sql Single-statement SQL query
578 * @return QueryStatus
579 * @since 1.39
581 abstract protected function doSingleStatementQuery( string $sql ): QueryStatus;
584 * Determine whether a write query affects a permanent table.
585 * This includes pseudo-permanent tables.
587 * @param Query $query
588 * @return bool
590 private function hasPermanentTable( Query $query ) {
591 if ( $query->getVerb() === 'CREATE TEMPORARY' ) {
592 // Temporary table creation is allowed
593 return false;
595 $table = $query->getWriteTable();
596 if ( $table === null ) {
597 // Parse error? Assume permanent.
598 return true;
600 [ $db, $pt ] = $this->platform->getDatabaseAndTableIdentifier( $table );
601 $tempInfo = $this->sessionTempTables[$db][$pt] ?? null;
602 return !$tempInfo || $tempInfo->pseudoPermanent;
606 * Register creation and dropping of temporary tables
608 * @param Query $query
610 protected function registerTempTables( Query $query ) {
611 $table = $query->getWriteTable();
612 if ( $table === null ) {
613 return;
615 switch ( $query->getVerb() ) {
616 case 'CREATE TEMPORARY':
617 [ $db, $pt ] = $this->platform->getDatabaseAndTableIdentifier( $table );
618 $this->sessionTempTables[$db][$pt] = new TempTableInfo(
619 $this->transactionManager->getTrxId(),
620 (bool)( $query->getFlags() & self::QUERY_PSEUDO_PERMANENT )
622 break;
624 case 'DROP':
625 [ $db, $pt ] = $this->platform->getDatabaseAndTableIdentifier( $table );
626 unset( $this->sessionTempTables[$db][$pt] );
630 public function query( $sql, $fname = __METHOD__, $flags = 0 ) {
631 if ( !( $sql instanceof Query ) ) {
632 $flags = (int)$flags; // b/c; this field used to be a bool
633 $sql = QueryBuilderFromRawSql::buildQuery( $sql, $flags, $this->currentDomain->getTablePrefix() );
634 } else {
635 $flags = $sql->getFlags();
638 // Make sure that this caller is allowed to issue this query statement
639 $this->assertQueryIsCurrentlyAllowed( $sql->getVerb(), $fname );
641 // Send the query to the server and fetch any corresponding errors
642 $status = $this->executeQuery( $sql, $fname, $flags );
643 if ( $status->res === false ) {
644 // An error occurred; log, and, if needed, report an exception.
645 // Errors that corrupt the transaction/session state cannot be silenced.
646 $ignore = (
647 $this->flagsHolder::contains( $flags, self::QUERY_SILENCE_ERRORS ) &&
648 !$this->flagsHolder::contains( $status->flags, self::ERR_ABORT_SESSION ) &&
649 !$this->flagsHolder::contains( $status->flags, self::ERR_ABORT_TRX )
651 $this->reportQueryError( $status->message, $status->code, $sql->getSQL(), $fname, $ignore );
654 return $status->res;
658 * Execute a query without enforcing public (non-Database) caller restrictions.
660 * Retry it if there is a recoverable connection loss (e.g. no important state lost).
662 * This does not precheck for transaction/session state errors or critical section errors.
664 * @see Database::query()
666 * @param Query $sql SQL statement
667 * @param string $fname Name of the calling function
668 * @param int $flags Bit field of ISQLPlatform::QUERY_* constants
669 * @return QueryStatus
670 * @throws DBUnexpectedError
671 * @since 1.34
673 final protected function executeQuery( $sql, $fname, $flags ) {
674 $this->assertHasConnectionHandle();
676 $isPermWrite = false;
677 $isWrite = $sql->isWriteQuery();
678 if ( $isWrite ) {
679 ChangedTablesTracker::recordQuery( $this->currentDomain, $sql );
680 // Permit temporary table writes on replica connections, but require a writable
681 // master connection for writes to persistent tables.
682 if ( $this->hasPermanentTable( $sql ) ) {
683 $isPermWrite = true;
684 $info = $this->getReadOnlyReason();
685 if ( $info ) {
686 [ $reason, $source ] = $info;
687 if ( $source === 'role' ) {
688 throw new DBReadOnlyRoleError( $this, "Database is read-only: $reason" );
689 } else {
690 throw new DBReadOnlyError( $this, "Database is read-only: $reason" );
693 // DBConnRef uses QUERY_REPLICA_ROLE to enforce replica roles during query()
694 if ( $this->flagsHolder::contains( $sql->getFlags(), self::QUERY_REPLICA_ROLE ) ) {
695 throw new DBReadOnlyRoleError(
696 $this,
697 "Cannot write; target role is DB_REPLICA"
703 // Whether a silent retry attempt is left for recoverable connection loss errors
704 $retryLeft = !$this->flagsHolder::contains( $flags, self::QUERY_NO_RETRY );
706 $cs = $this->commenceCriticalSection( __METHOD__ );
708 do {
709 // Start a DBO_TRX wrapper transaction as needed (throw an error on failure)
710 if ( $this->beginIfImplied( $sql, $fname, $flags ) ) {
711 // Since begin() was called, any connection loss was already handled
712 $retryLeft = false;
714 // Send the query statement to the server and fetch any results.
715 $status = $this->attemptQuery( $sql, $fname, $isPermWrite );
716 } while (
717 // An error occurred that can be recovered from via query retry
718 $this->flagsHolder::contains( $status->flags, self::ERR_RETRY_QUERY ) &&
719 // The retry has not been exhausted (consume it now)
720 // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
721 $retryLeft && !( $retryLeft = false )
724 // Register creation and dropping of temporary tables
725 if ( $status->res ) {
726 $this->registerTempTables( $sql );
728 $this->completeCriticalSection( __METHOD__, $cs );
730 return $status;
734 * Query method wrapper handling profiling, logging, affected row count tracking, and
735 * automatic reconnections (without retry) on query failure due to connection loss
737 * Note that this does not handle DBO_TRX logic.
739 * This method handles profiling, debug logging, reconnection and the tracking of:
740 * - write callers
741 * - last write time
742 * - affected row count of the last write
743 * - whether writes occurred in a transaction
744 * - last successful query time (confirming that the connection was not dropped)
746 * @see doSingleStatementQuery()
748 * @param Query $sql SQL statement
749 * @param string $fname Name of the calling function
750 * @param bool $isPermWrite Whether it's a query writing to permanent tables
751 * @return QueryStatus statement result
752 * @throws DBUnexpectedError
754 private function attemptQuery(
755 $sql,
756 string $fname,
757 bool $isPermWrite
759 // Transaction attributes before issuing this query
760 $priorSessInfo = new CriticalSessionInfo(
761 $this->transactionManager->getTrxId(),
762 $this->transactionManager->explicitTrxActive(),
763 $this->transactionManager->pendingWriteCallers(),
764 $this->transactionManager->pendingPreCommitCallbackCallers(),
765 $this->sessionNamedLocks,
766 $this->sessionTempTables
768 // Get the transaction-aware SQL string used for profiling
769 $generalizedSql = GeneralizedSql::newFromQuery(
770 $sql,
771 ( $this->replicationReporter->getTopologyRole() === self::ROLE_STREAMING_MASTER )
772 ? 'role-primary: '
773 : ''
775 // Add agent and calling method comments to the SQL
776 $cStatement = $this->makeCommentedSql( $sql->getSQL(), $fname );
777 // Start profile section
778 $ps = $this->profiler ? ( $this->profiler )( $generalizedSql->stringify() ) : null;
779 $startTime = microtime( true );
781 // Clear any overrides from a prior "query method". Note that this does not affect
782 // any such methods that are currently invoking query() itself since those query
783 // methods set these fields before returning.
784 $this->lastEmulatedAffectedRows = null;
785 $this->lastEmulatedInsertId = null;
787 // Record an OTEL span for this query.
788 $writeTableName = $sql->getWriteTable();
789 $spanName = $writeTableName ?
790 "Database {$sql->getVerb()} {$this->getDBname()}.{$writeTableName}" :
791 "Database {$sql->getVerb()} {$this->getDBname()}";
792 $span = $this->tracer->createSpan( $spanName )
793 ->setSpanKind( SpanInterface::SPAN_KIND_CLIENT )
794 ->start();
795 if ( $span->getContext()->isSampled() ) {
796 $span->setAttributes( [
797 'code.function' => $fname,
798 'db.namespace' => $this->getDBname(),
799 'db.operation.name' => $sql->getVerb(),
800 'db.query.text' => $generalizedSql->stringify(),
801 'db.system' => $this->getType(),
802 'server.address' => $this->getServerName(),
803 'db.collection.name' => $writeTableName, # nulls filtered out
804 ] );
807 $status = $this->doSingleStatementQuery( $cStatement );
809 // End profile section
810 $endTime = microtime( true );
811 $queryRuntime = max( $endTime - $startTime, 0.0 );
812 unset( $ps );
813 $span->end();
815 if ( $status->res !== false ) {
816 $this->lastPing = $endTime;
817 $span->setSpanStatus( SpanInterface::SPAN_STATUS_OK );
818 } else {
819 $span->setSpanStatus( SpanInterface::SPAN_STATUS_ERROR )
820 ->setAttributes( [
821 'db.response.status_code' => $status->code,
822 'exception.message' => $status->message,
823 ] );
826 $affectedRowCount = $status->rowsAffected;
827 $returnedRowCount = $status->rowsReturned;
828 $this->lastQueryAffectedRows = $affectedRowCount;
830 if ( $span->getContext()->isSampled() ) {
831 $span->setAttributes( [
832 'db.response.affected_rows' => $affectedRowCount,
833 'db.response.returned_rows' => $returnedRowCount,
834 ] );
837 if ( $status->res !== false ) {
838 if ( $isPermWrite ) {
839 if ( $this->trxLevel() ) {
840 $this->transactionManager->transactionWritingIn(
841 $this->getServerName(),
842 $this->getDomainID(),
843 $startTime
845 $this->transactionManager->updateTrxWriteQueryReport(
846 $sql->getSQL(),
847 $queryRuntime,
848 $affectedRowCount,
849 $fname
851 } else {
852 $this->lastWriteTime = $endTime;
857 $this->transactionManager->recordQueryCompletion(
858 $generalizedSql,
859 $startTime,
860 $isPermWrite,
861 $isPermWrite ? $affectedRowCount : $returnedRowCount,
862 $this->getServerName()
865 // Check if the query failed...
866 $status->flags = $this->handleErroredQuery( $status, $sql, $fname, $queryRuntime, $priorSessInfo );
867 // Avoid the overhead of logging calls unless debug mode is enabled
868 if ( $this->flagsHolder->getFlag( self::DBO_DEBUG ) ) {
869 $this->logger->debug(
870 "{method} [{runtime_ms}ms] {db_server}: {sql}",
871 $this->getLogContext( [
872 'method' => $fname,
873 'sql' => $sql->getSQL(),
874 'domain' => $this->getDomainID(),
875 'runtime_ms' => round( $queryRuntime * 1000, 3 ),
876 'db_log_category' => 'query'
881 return $status;
884 private function handleErroredQuery( QueryStatus $status, $sql, $fname, $queryRuntime, $priorSessInfo ) {
885 $errflags = self::ERR_NONE;
886 $error = $status->message;
887 $errno = $status->code;
888 if ( $status->res !== false ) {
889 // Statement succeeded
890 return $errflags;
892 if ( $this->isConnectionError( $errno ) ) {
893 // Connection lost before or during the query...
894 // Determine how to proceed given the lost session state
895 $connLossFlag = $this->assessConnectionLoss(
896 $sql->getVerb(),
897 $queryRuntime,
898 $priorSessInfo
900 // Update session state tracking and try to reestablish a connection
901 $reconnected = $this->replaceLostConnection( $errno, __METHOD__ );
902 // Check if important server-side session-level state was lost
903 if ( $connLossFlag >= self::ERR_ABORT_SESSION ) {
904 $ex = $this->getQueryException( $error, $errno, $sql->getSQL(), $fname );
905 $this->transactionManager->setSessionError( $ex );
907 // Check if important server-side transaction-level state was lost
908 if ( $connLossFlag >= self::ERR_ABORT_TRX ) {
909 $ex = $this->getQueryException( $error, $errno, $sql->getSQL(), $fname );
910 $this->transactionManager->setTransactionError( $ex );
912 // Check if the query should be retried (having made the reconnection attempt)
913 if ( $connLossFlag === self::ERR_RETRY_QUERY ) {
914 $errflags |= ( $reconnected ? self::ERR_RETRY_QUERY : self::ERR_ABORT_QUERY );
915 } else {
916 $errflags |= $connLossFlag;
918 } elseif ( $this->isKnownStatementRollbackError( $errno ) ) {
919 // Query error triggered a server-side statement-only rollback...
920 $errflags |= self::ERR_ABORT_QUERY;
921 if ( $this->trxLevel() ) {
922 // Allow legacy callers to ignore such errors via QUERY_IGNORE_DBO_TRX and
923 // try/catch. However, a deprecation notice will be logged on the next query.
924 $cause = [ $error, $errno, $fname ];
925 $this->transactionManager->setTrxStatusIgnoredCause( $cause );
927 } elseif ( $this->trxLevel() ) {
928 // Some other error occurred during the query, within a transaction...
929 // Server-side handling of errors during transactions varies widely depending on
930 // the RDBMS type and configuration. There are several possible results: (a) the
931 // whole transaction is rolled back, (b) only the queries after BEGIN are rolled
932 // back, (c) the transaction is marked as "aborted" and a ROLLBACK is required
933 // before other queries are permitted. For compatibility reasons, pessimistically
934 // require a ROLLBACK query (not using SAVEPOINT) before allowing other queries.
935 $ex = $this->getQueryException( $error, $errno, $sql->getSQL(), $fname );
936 $this->transactionManager->setTransactionError( $ex );
937 $errflags |= self::ERR_ABORT_TRX;
938 } else {
939 // Some other error occurred during the query, without a transaction...
940 $errflags |= self::ERR_ABORT_QUERY;
943 return $errflags;
947 * @param string $sql
948 * @param string $fname
949 * @return string
951 private function makeCommentedSql( $sql, $fname ): string {
952 // Add trace comment to the begin of the sql string, right after the operator.
953 // Or, for one-word queries (like "BEGIN" or COMMIT") add it to the end (T44598).
954 // NOTE: Don't add varying ids such as request id or session id to the comment.
955 // It would break aggregation of similar queries in analysis tools (see T193050#7512149)
956 $encName = preg_replace( '/[\x00-\x1F\/]/', '-', "$fname {$this->agent}" );
957 return preg_replace( '/\s|$/', " /* $encName */ ", $sql, 1 );
961 * Start an implicit transaction if DBO_TRX is enabled and no transaction is active
963 * @param Query $sql SQL statement
964 * @param string $fname
965 * @param int $flags Bit field of ISQLPlatform::QUERY_* constants
966 * @return bool Whether an implicit transaction was started
967 * @throws DBError
969 private function beginIfImplied( $sql, $fname, $flags ) {
970 if ( !$this->trxLevel() && $this->flagsHolder->hasApplicableImplicitTrxFlag( $flags ) ) {
971 if ( $this->platform->isTransactableQuery( $sql ) ) {
972 $this->begin( __METHOD__ . " ($fname)", self::TRANSACTION_INTERNAL );
973 $this->transactionManager->turnOnAutomatic();
975 return true;
979 return false;
983 * Check if callers outside of Database can run the given query given the session state
985 * In order to keep the DB handle's session state tracking in sync, certain queries
986 * like "USE", "BEGIN", "COMMIT", and "ROLLBACK" must not be issued directly from
987 * outside callers. Such commands should only be issued through dedicated methods
988 * like selectDomain(), begin(), commit(), and rollback(), respectively.
990 * This also checks if the session state tracking was corrupted by a prior exception.
992 * @param string $verb
993 * @param string $fname
994 * @throws DBUnexpectedError
995 * @throws DBTransactionStateError
997 private function assertQueryIsCurrentlyAllowed( string $verb, string $fname ) {
998 if ( $verb === 'USE' ) {
999 throw new DBUnexpectedError( $this, "Got USE query; use selectDomain() instead" );
1002 if ( $verb === 'ROLLBACK' ) {
1003 // Whole transaction rollback is used for recovery
1004 // @TODO: T269161; prevent "BEGIN"/"COMMIT"/"ROLLBACK" from outside callers
1005 return;
1008 if ( $this->csmError ) {
1009 throw new DBTransactionStateError(
1010 $this,
1011 "Cannot execute query from $fname while session state is out of sync",
1013 $this->csmError
1017 $this->transactionManager->assertSessionStatus( $this, $fname );
1019 if ( $verb !== 'ROLLBACK TO SAVEPOINT' ) {
1020 $this->transactionManager->assertTransactionStatus(
1021 $this,
1022 $this->deprecationLogger,
1023 $fname
1029 * Determine how to handle a connection lost discovered during a query attempt
1031 * This checks if explicit transactions, pending transaction writes, and important
1032 * session-level state (locks, temp tables) was lost. Point-in-time read snapshot loss
1033 * is considered acceptable for DBO_TRX logic.
1035 * If state was lost, but that loss was discovered during a ROLLBACK that would have
1036 * destroyed that state anyway, treat the error as recoverable.
1038 * @param string $verb SQL query verb
1039 * @param float $walltime How many seconds passes while attempting the query
1040 * @param CriticalSessionInfo $priorSessInfo Session state just before the query
1041 * @return int Recovery approach. One of the following ERR_* class constants:
1042 * - Database::ERR_RETRY_QUERY: reconnect silently, retry query
1043 * - Database::ERR_ABORT_QUERY: reconnect silently, do not retry query
1044 * - Database::ERR_ABORT_TRX: reconnect, throw error, enforce transaction rollback
1045 * - Database::ERR_ABORT_SESSION: reconnect, throw error, enforce session rollback
1047 private function assessConnectionLoss(
1048 string $verb,
1049 float $walltime,
1050 CriticalSessionInfo $priorSessInfo
1052 if ( $walltime < self::DROPPED_CONN_BLAME_THRESHOLD_SEC ) {
1053 // Query failed quickly; the connection was probably lost before the query was sent
1054 $res = self::ERR_RETRY_QUERY;
1055 } else {
1056 // Query took a long time; the connection was probably lost during query execution
1057 $res = self::ERR_ABORT_QUERY;
1060 // List of problems causing session/transaction state corruption
1061 $blockers = [];
1062 // Loss of named locks breaks future callers relying on those locks for critical sections
1063 foreach ( $priorSessInfo->namedLocks as $lockName => $lockInfo ) {
1064 if ( $lockInfo['trxId'] && $lockInfo['trxId'] === $priorSessInfo->trxId ) {
1065 // Treat lost locks acquired during the lost transaction as a transaction state
1066 // problem. Connection loss on ROLLBACK (non-SAVEPOINT) is tolerable since
1067 // rollback automatically triggered server-side.
1068 if ( $verb !== 'ROLLBACK' ) {
1069 $res = max( $res, self::ERR_ABORT_TRX );
1070 $blockers[] = "named lock '$lockName'";
1072 } else {
1073 // Treat lost locks acquired either during prior transactions or during no
1074 // transaction as a session state problem.
1075 $res = max( $res, self::ERR_ABORT_SESSION );
1076 $blockers[] = "named lock '$lockName'";
1079 // Loss of temp tables breaks future callers relying on those tables for queries
1080 foreach ( $priorSessInfo->tempTables as $domainTempTables ) {
1081 foreach ( $domainTempTables as $tableName => $tableInfo ) {
1082 if ( $tableInfo->trxId && $tableInfo->trxId === $priorSessInfo->trxId ) {
1083 // Treat lost temp tables created during the lost transaction as a
1084 // transaction state problem. Connection loss on ROLLBACK (non-SAVEPOINT)
1085 // is tolerable since rollback automatically triggered server-side.
1086 if ( $verb !== 'ROLLBACK' ) {
1087 $res = max( $res, self::ERR_ABORT_TRX );
1088 $blockers[] = "temp table '$tableName'";
1090 } else {
1091 // Treat lost temp tables created either during prior transactions or during
1092 // no transaction as a session state problem.
1093 $res = max( $res, self::ERR_ABORT_SESSION );
1094 $blockers[] = "temp table '$tableName'";
1098 // Loss of transaction writes breaks future callers and DBO_TRX logic relying on those
1099 // writes to be atomic and still pending. Connection loss on ROLLBACK (non-SAVEPOINT) is
1100 // tolerable since rollback automatically triggered server-side.
1101 if ( $priorSessInfo->trxWriteCallers && $verb !== 'ROLLBACK' ) {
1102 $res = max( $res, self::ERR_ABORT_TRX );
1103 $blockers[] = 'uncommitted writes';
1105 if ( $priorSessInfo->trxPreCommitCbCallers && $verb !== 'ROLLBACK' ) {
1106 $res = max( $res, self::ERR_ABORT_TRX );
1107 $blockers[] = 'pre-commit callbacks';
1109 if ( $priorSessInfo->trxExplicit && $verb !== 'ROLLBACK' && $verb !== 'COMMIT' ) {
1110 // Transaction automatically rolled back, breaking the expectations of callers
1111 // relying on the continued existence of that transaction for things like atomic
1112 // writes, serializability, or reads from the same point-in-time snapshot. If the
1113 // connection loss occured on ROLLBACK (non-SAVEPOINT) or COMMIT, then we do not
1114 // need to mark the transaction state as corrupt, since no transaction would still
1115 // be open even if the query did succeed (T127428).
1116 $res = max( $res, self::ERR_ABORT_TRX );
1117 $blockers[] = 'explicit transaction';
1120 if ( $blockers ) {
1121 $this->logger->warning(
1122 "cannot reconnect to {db_server} silently: {error}",
1123 $this->getLogContext( [
1124 'error' => 'session state loss (' . implode( ', ', $blockers ) . ')',
1125 'exception' => new RuntimeException(),
1126 'db_log_category' => 'connection'
1131 return $res;
1135 * Clean things up after session (and thus transaction) loss before reconnect
1137 private function handleSessionLossPreconnect() {
1138 // Clean up tracking of session-level things...
1139 // https://mariadb.com/kb/en/create-table/#create-temporary-table
1140 // https://www.postgresql.org/docs/9.2/static/sql-createtable.html (ignoring ON COMMIT)
1141 $this->sessionTempTables = [];
1142 // https://mariadb.com/kb/en/get_lock/
1143 // https://www.postgresql.org/docs/9.4/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
1144 $this->sessionNamedLocks = [];
1145 // Session loss implies transaction loss (T67263)
1146 $this->transactionManager->onSessionLoss( $this );
1147 // Clear additional subclass fields
1148 $this->doHandleSessionLossPreconnect();
1152 * Reset any additional subclass trx* and session* fields
1154 protected function doHandleSessionLossPreconnect() {
1155 // no-op
1159 * Checks whether the cause of the error is detected to be a timeout.
1161 * It returns false by default, and not all engines support detecting this yet.
1162 * If this returns false, it will be treated as a generic query error.
1164 * @param int|string $errno Error number
1165 * @return bool
1166 * @since 1.39
1168 protected function isQueryTimeoutError( $errno ) {
1169 return false;
1173 * Report a query error
1175 * If $ignore is set, emit a DEBUG level log entry and continue,
1176 * otherwise, emit an ERROR level log entry and throw an exception.
1178 * @param string $error
1179 * @param int|string $errno
1180 * @param string $sql
1181 * @param string $fname
1182 * @param bool $ignore Whether to just log an error rather than throw an exception
1183 * @throws DBQueryError
1185 public function reportQueryError( $error, $errno, $sql, $fname, $ignore = false ) {
1186 if ( $ignore ) {
1187 $this->logger->debug(
1188 "SQL ERROR (ignored): $error",
1189 [ 'db_log_category' => 'query' ]
1191 } else {
1192 throw $this->getQueryExceptionAndLog( $error, $errno, $sql, $fname );
1197 * @param string $error
1198 * @param string|int $errno
1199 * @param string $sql
1200 * @param string $fname
1201 * @return DBError
1203 private function getQueryExceptionAndLog( $error, $errno, $sql, $fname ) {
1204 // Information that instances of the same problem have in common should
1205 // not be normalized (T255202).
1206 $this->logger->error(
1207 "Error $errno from $fname, {error} {sql1line} {db_server}",
1208 $this->getLogContext( [
1209 'method' => __METHOD__,
1210 'errno' => $errno,
1211 'error' => $error,
1212 'sql1line' => mb_substr( str_replace( "\n", "\\n", $sql ), 0, 5 * 1024 ),
1213 'fname' => $fname,
1214 'db_log_category' => 'query',
1215 'exception' => new RuntimeException()
1218 return $this->getQueryException( $error, $errno, $sql, $fname );
1222 * @param string $error
1223 * @param string|int $errno
1224 * @param string $sql
1225 * @param string $fname
1226 * @return DBError
1228 private function getQueryException( $error, $errno, $sql, $fname ) {
1229 if ( $this->isQueryTimeoutError( $errno ) ) {
1230 return new DBQueryTimeoutError( $this, $error, $errno, $sql, $fname );
1231 } elseif ( $this->isConnectionError( $errno ) ) {
1232 return new DBQueryDisconnectedError( $this, $error, $errno, $sql, $fname );
1233 } else {
1234 return new DBQueryError( $this, $error, $errno, $sql, $fname );
1239 * @param string $error
1240 * @return DBConnectionError
1242 final protected function newExceptionAfterConnectError( $error ) {
1243 // Connection was not fully initialized and is not safe for use.
1244 // Stash any error associated with the handle before destroying it.
1245 $this->lastConnectError = $error;
1246 $this->conn = null;
1248 $this->logger->error(
1249 "Error connecting to {db_server} as user {db_user}: {error}",
1250 $this->getLogContext( [
1251 'error' => $error,
1252 'exception' => new RuntimeException(),
1253 'db_log_category' => 'connection',
1257 return new DBConnectionError( $this, $error );
1261 * Get a SelectQueryBuilder bound to this connection. This is overridden by
1262 * DBConnRef.
1264 * @return SelectQueryBuilder
1266 public function newSelectQueryBuilder(): SelectQueryBuilder {
1267 return new SelectQueryBuilder( $this );
1271 * Get a UnionQueryBuilder bound to this connection. This is overridden by
1272 * DBConnRef.
1274 * @return UnionQueryBuilder
1276 public function newUnionQueryBuilder(): UnionQueryBuilder {
1277 return new UnionQueryBuilder( $this );
1281 * Get an UpdateQueryBuilder bound to this connection. This is overridden by
1282 * DBConnRef.
1284 * @return UpdateQueryBuilder
1286 public function newUpdateQueryBuilder(): UpdateQueryBuilder {
1287 return new UpdateQueryBuilder( $this );
1291 * Get a DeleteQueryBuilder bound to this connection. This is overridden by
1292 * DBConnRef.
1294 * @return DeleteQueryBuilder
1296 public function newDeleteQueryBuilder(): DeleteQueryBuilder {
1297 return new DeleteQueryBuilder( $this );
1301 * Get a InsertQueryBuilder bound to this connection. This is overridden by
1302 * DBConnRef.
1304 * @return InsertQueryBuilder
1306 public function newInsertQueryBuilder(): InsertQueryBuilder {
1307 return new InsertQueryBuilder( $this );
1311 * Get a ReplaceQueryBuilder bound to this connection. This is overridden by
1312 * DBConnRef.
1314 * @return ReplaceQueryBuilder
1316 public function newReplaceQueryBuilder(): ReplaceQueryBuilder {
1317 return new ReplaceQueryBuilder( $this );
1320 public function selectField(
1321 $tables, $var, $cond = '', $fname = __METHOD__, $options = [], $join_conds = []
1323 if ( $var === '*' ) {
1324 throw new DBUnexpectedError( $this, "Cannot use a * field" );
1325 } elseif ( is_array( $var ) && count( $var ) !== 1 ) {
1326 throw new DBUnexpectedError( $this, 'Cannot use more than one field' );
1329 $options = $this->platform->normalizeOptions( $options );
1330 $options['LIMIT'] = 1;
1332 $res = $this->select( $tables, $var, $cond, $fname, $options, $join_conds );
1333 if ( $res === false ) {
1334 throw new DBUnexpectedError( $this, "Got false from select()" );
1337 $row = $res->fetchRow();
1338 if ( $row === false ) {
1339 return false;
1342 return reset( $row );
1345 public function selectFieldValues(
1346 $tables, $var, $cond = '', $fname = __METHOD__, $options = [], $join_conds = []
1347 ): array {
1348 if ( $var === '*' ) {
1349 throw new DBUnexpectedError( $this, "Cannot use a * field" );
1350 } elseif ( !is_string( $var ) ) {
1351 throw new DBUnexpectedError( $this, "Cannot use an array of fields" );
1354 $options = $this->platform->normalizeOptions( $options );
1355 $res = $this->select( $tables, [ 'value' => $var ], $cond, $fname, $options, $join_conds );
1356 if ( $res === false ) {
1357 throw new DBUnexpectedError( $this, "Got false from select()" );
1360 $values = [];
1361 foreach ( $res as $row ) {
1362 $values[] = $row->value;
1365 return $values;
1368 public function select(
1369 $tables, $vars, $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
1371 $options = (array)$options;
1372 // Don't turn this into using platform directly, DatabaseMySQL overrides this.
1373 $sql = $this->selectSQLText( $tables, $vars, $conds, $fname, $options, $join_conds );
1374 // Treat SELECT queries with FOR UPDATE as writes. This matches
1375 // how MySQL enforces read_only (FOR SHARE and LOCK IN SHADE MODE are allowed).
1376 $flags = in_array( 'FOR UPDATE', $options, true )
1377 ? self::QUERY_CHANGE_ROWS
1378 : self::QUERY_CHANGE_NONE;
1380 $query = new Query( $sql, $flags, 'SELECT' );
1381 return $this->query( $query, $fname );
1384 public function selectRow( $tables, $vars, $conds, $fname = __METHOD__,
1385 $options = [], $join_conds = []
1387 $options = (array)$options;
1388 $options['LIMIT'] = 1;
1390 $res = $this->select( $tables, $vars, $conds, $fname, $options, $join_conds );
1391 if ( $res === false ) {
1392 throw new DBUnexpectedError( $this, "Got false from select()" );
1395 if ( !$res->numRows() ) {
1396 return false;
1399 return $res->fetchObject();
1403 * @inheritDoc
1405 public function estimateRowCount(
1406 $tables, $var = '*', $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
1407 ): int {
1408 $conds = $this->platform->normalizeConditions( $conds, $fname );
1409 $column = $this->platform->extractSingleFieldFromList( $var );
1410 if ( is_string( $column ) && !in_array( $column, [ '*', '1' ] ) ) {
1411 $conds[] = "$column IS NOT NULL";
1414 $res = $this->select(
1415 $tables, [ 'rowcount' => 'COUNT(*)' ], $conds, $fname, $options, $join_conds
1417 $row = $res ? $res->fetchRow() : [];
1419 return isset( $row['rowcount'] ) ? (int)$row['rowcount'] : 0;
1422 public function selectRowCount(
1423 $tables, $var = '*', $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
1424 ): int {
1425 $conds = $this->platform->normalizeConditions( $conds, $fname );
1426 $column = $this->platform->extractSingleFieldFromList( $var );
1427 if ( is_string( $column ) && !in_array( $column, [ '*', '1' ] ) ) {
1428 $conds[] = "$column IS NOT NULL";
1430 if ( in_array( 'DISTINCT', (array)$options ) ) {
1431 if ( $column === null ) {
1432 throw new DBUnexpectedError( $this,
1433 '$var cannot be empty when the DISTINCT option is given' );
1435 $innerVar = $column;
1436 } else {
1437 $innerVar = '1';
1440 $res = $this->select(
1442 'tmp_count' => $this->platform->buildSelectSubquery(
1443 $tables,
1444 $innerVar,
1445 $conds,
1446 $fname,
1447 $options,
1448 $join_conds
1451 [ 'rowcount' => 'COUNT(*)' ],
1453 $fname
1455 $row = $res ? $res->fetchRow() : [];
1457 return isset( $row['rowcount'] ) ? (int)$row['rowcount'] : 0;
1460 public function lockForUpdate(
1461 $table, $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
1463 if ( !$this->trxLevel() && !$this->flagsHolder->hasImplicitTrxFlag() ) {
1464 throw new DBUnexpectedError(
1465 $this,
1466 __METHOD__ . ': no transaction is active nor is DBO_TRX set'
1470 $options = (array)$options;
1471 $options[] = 'FOR UPDATE';
1473 return $this->selectRowCount( $table, '*', $conds, $fname, $options, $join_conds );
1476 public function fieldExists( $table, $field, $fname = __METHOD__ ) {
1477 $info = $this->fieldInfo( $table, $field );
1479 return (bool)$info;
1482 abstract public function tableExists( $table, $fname = __METHOD__ );
1484 public function indexExists( $table, $index, $fname = __METHOD__ ) {
1485 $info = $this->indexInfo( $table, $index, $fname );
1487 return (bool)$info;
1490 public function indexUnique( $table, $index, $fname = __METHOD__ ) {
1491 $info = $this->indexInfo( $table, $index, $fname );
1493 return $info ? $info['unique'] : null;
1497 * Get information about an index into an object
1499 * @param string $table The unqualified name of a table
1500 * @param string $index Index name
1501 * @param string $fname Calling function name
1502 * @return array<string,mixed>|false Index info map; false if it does not exist
1503 * @phan-return array{unique:bool}|false
1505 abstract public function indexInfo( $table, $index, $fname = __METHOD__ );
1507 public function insert( $table, $rows, $fname = __METHOD__, $options = [] ) {
1508 $query = $this->platform->dispatchingInsertSqlText( $table, $rows, $options );
1509 if ( !$query ) {
1510 return true;
1512 $this->query( $query, $fname );
1513 if ( $this->strictWarnings ) {
1514 $this->checkInsertWarnings( $query, $fname );
1516 return true;
1520 * Check for warnings after performing an INSERT query, and throw exceptions
1521 * if necessary.
1523 * @param Query $query
1524 * @param string $fname
1525 * @return void
1527 protected function checkInsertWarnings( Query $query, $fname ) {
1530 public function update( $table, $set, $conds, $fname = __METHOD__, $options = [] ) {
1531 $query = $this->platform->updateSqlText( $table, $set, $conds, $options );
1532 $this->query( $query, $fname );
1534 return true;
1537 public function databasesAreIndependent() {
1538 return false;
1541 final public function selectDomain( $domain ) {
1542 $cs = $this->commenceCriticalSection( __METHOD__ );
1544 try {
1545 $this->doSelectDomain( DatabaseDomain::newFromId( $domain ) );
1546 } catch ( DBError $e ) {
1547 $this->completeCriticalSection( __METHOD__, $cs );
1548 throw $e;
1551 $this->completeCriticalSection( __METHOD__, $cs );
1555 * @param DatabaseDomain $domain
1556 * @throws DBConnectionError
1557 * @throws DBError
1558 * @since 1.32
1560 protected function doSelectDomain( DatabaseDomain $domain ) {
1561 $this->currentDomain = $domain;
1562 $this->platform->setCurrentDomain( $this->currentDomain );
1565 public function getDBname() {
1566 return $this->currentDomain->getDatabase();
1569 public function getServer() {
1570 return $this->connectionParams[self::CONN_HOST] ?? null;
1573 public function getServerName() {
1574 return $this->serverName ?? $this->getServer() ?? 'unknown';
1577 public function addQuotes( $s ) {
1578 if ( $s instanceof RawSQLValue ) {
1579 return $s->toSql();
1581 if ( $s instanceof Blob ) {
1582 $s = $s->fetch();
1584 if ( $s === null ) {
1585 return 'NULL';
1586 } elseif ( is_bool( $s ) ) {
1587 return (string)(int)$s;
1588 } elseif ( is_int( $s ) ) {
1589 return (string)$s;
1590 } else {
1591 return "'" . $this->strencode( $s ) . "'";
1595 public function expr( string $field, string $op, $value ): Expression {
1596 return new Expression( $field, $op, $value );
1599 public function andExpr( array $conds ): AndExpressionGroup {
1600 return AndExpressionGroup::newFromArray( $conds );
1603 public function orExpr( array $conds ): OrExpressionGroup {
1604 return OrExpressionGroup::newFromArray( $conds );
1607 public function replace( $table, $uniqueKeys, $rows, $fname = __METHOD__ ) {
1608 $uniqueKey = $this->platform->normalizeUpsertParams( $uniqueKeys, $rows );
1609 if ( !$rows ) {
1610 return;
1612 $affectedRowCount = 0;
1613 $insertId = null;
1614 $this->startAtomic( $fname, self::ATOMIC_CANCELABLE );
1615 try {
1616 foreach ( $rows as $row ) {
1617 // Delete any conflicting rows (including ones inserted from $rows)
1618 $query = $this->platform->deleteSqlText(
1619 $table,
1620 [ $this->platform->makeKeyCollisionCondition( [ $row ], $uniqueKey ) ]
1622 $this->query( $query, $fname );
1623 // Insert the new row
1624 $query = $this->platform->dispatchingInsertSqlText( $table, $row, [] );
1625 $this->query( $query, $fname );
1626 $affectedRowCount += $this->lastQueryAffectedRows;
1627 $insertId = $insertId ?: $this->lastQueryInsertId;
1629 $this->endAtomic( $fname );
1630 } catch ( DBError $e ) {
1631 $this->cancelAtomic( $fname );
1632 throw $e;
1634 $this->lastEmulatedAffectedRows = $affectedRowCount;
1635 $this->lastEmulatedInsertId = $insertId;
1638 public function upsert( $table, array $rows, $uniqueKeys, array $set, $fname = __METHOD__ ) {
1639 $uniqueKey = $this->platform->normalizeUpsertParams( $uniqueKeys, $rows );
1640 if ( !$rows ) {
1641 return true;
1643 $this->platform->assertValidUpsertSetArray( $set, $uniqueKey, $rows );
1645 $encTable = $this->tableName( $table );
1646 $sqlColumnAssignments = $this->makeList( $set, self::LIST_SET );
1647 // Get any AUTO_INCREMENT/SERIAL column for this table so we can set insertId()
1648 $autoIncrementColumn = $this->getInsertIdColumnForUpsert( $table );
1649 // Check if there is a SQL assignment expression in $set (as generated by SQLPlatform::buildExcludedValue)
1650 $useWith = (bool)array_filter(
1651 $set,
1652 static function ( $v, $k ) {
1653 return $v instanceof RawSQLValue || is_int( $k );
1655 ARRAY_FILTER_USE_BOTH
1657 // Subclasses might need explicit type casting within "WITH...AS (VALUES ...)"
1658 // so that these CTE rows can be referenced within the SET clause assigments.
1659 $typeByColumn = $useWith ? $this->getValueTypesForWithClause( $table ) : [];
1661 $first = true;
1662 $affectedRowCount = 0;
1663 $insertId = null;
1664 $this->startAtomic( $fname, self::ATOMIC_CANCELABLE );
1665 try {
1666 foreach ( $rows as $row ) {
1667 // Update any existing conflicting row (including ones inserted from $rows)
1668 [ $sqlColumns, $sqlTuples, $sqlVals ] = $this->platform->makeInsertLists(
1669 [ $row ],
1670 '__',
1671 $typeByColumn
1673 $sqlConditions = $this->platform->makeKeyCollisionCondition(
1674 [ $row ],
1675 $uniqueKey
1677 $query = new Query(
1678 ( $useWith ? "WITH __VALS ($sqlVals) AS (VALUES $sqlTuples) " : "" ) .
1679 "UPDATE $encTable SET $sqlColumnAssignments " .
1680 "WHERE ($sqlConditions)",
1681 self::QUERY_CHANGE_ROWS,
1682 'UPDATE',
1683 $table
1685 $this->query( $query, $fname );
1686 $rowsUpdated = $this->lastQueryAffectedRows;
1687 $affectedRowCount += $rowsUpdated;
1688 if ( $rowsUpdated > 0 ) {
1689 // Conflicting row found and updated
1690 if ( $first && $autoIncrementColumn !== null ) {
1691 // @TODO: use "RETURNING" instead (when supported by SQLite)
1692 $query = new Query(
1693 "SELECT $autoIncrementColumn AS id FROM $encTable " .
1694 "WHERE ($sqlConditions)",
1695 self::QUERY_CHANGE_NONE,
1696 'SELECT'
1698 $sRes = $this->query( $query, $fname, self::QUERY_CHANGE_ROWS );
1699 $insertId = (int)$sRes->fetchRow()['id'];
1701 } else {
1702 // No conflicting row found
1703 $query = new Query(
1704 "INSERT INTO $encTable ($sqlColumns) VALUES $sqlTuples",
1705 self::QUERY_CHANGE_ROWS,
1706 'INSERT',
1707 $table
1709 $this->query( $query, $fname );
1710 $affectedRowCount += $this->lastQueryAffectedRows;
1712 $first = false;
1714 $this->endAtomic( $fname );
1715 } catch ( DBError $e ) {
1716 $this->cancelAtomic( $fname );
1717 throw $e;
1719 $this->lastEmulatedAffectedRows = $affectedRowCount;
1720 $this->lastEmulatedInsertId = $insertId;
1721 return true;
1725 * @param string $table The unqualified name of a table
1726 * @return string|null The AUTO_INCREMENT/SERIAL column; null if not needed
1728 protected function getInsertIdColumnForUpsert( $table ) {
1729 return null;
1733 * @param string $table The unqualified name of a table
1734 * @return array<string,string> Map of (column => type); [] if not needed
1736 protected function getValueTypesForWithClause( $table ) {
1737 return [];
1740 public function deleteJoin(
1741 $delTable,
1742 $joinTable,
1743 $delVar,
1744 $joinVar,
1745 $conds,
1746 $fname = __METHOD__
1748 $sql = $this->platform->deleteJoinSqlText( $delTable, $joinTable, $delVar, $joinVar, $conds );
1749 $query = new Query( $sql, self::QUERY_CHANGE_ROWS, 'DELETE', $delTable );
1750 $this->query( $query, $fname );
1753 public function delete( $table, $conds, $fname = __METHOD__ ) {
1754 $this->query( $this->platform->deleteSqlText( $table, $conds ), $fname );
1756 return true;
1759 final public function insertSelect(
1760 $destTable,
1761 $srcTable,
1762 $varMap,
1763 $conds,
1764 $fname = __METHOD__,
1765 $insertOptions = [],
1766 $selectOptions = [],
1767 $selectJoinConds = []
1769 static $hints = [ 'NO_AUTO_COLUMNS' ];
1771 $insertOptions = $this->platform->normalizeOptions( $insertOptions );
1772 $selectOptions = $this->platform->normalizeOptions( $selectOptions );
1774 if ( $this->cliMode && $this->isInsertSelectSafe( $insertOptions, $selectOptions, $fname ) ) {
1775 // For massive migrations with downtime, we don't want to select everything
1776 // into memory and OOM, so do all this native on the server side if possible.
1777 $this->doInsertSelectNative(
1778 $destTable,
1779 $srcTable,
1780 $varMap,
1781 $conds,
1782 $fname,
1783 array_diff( $insertOptions, $hints ),
1784 $selectOptions,
1785 $selectJoinConds
1787 } else {
1788 $this->doInsertSelectGeneric(
1789 $destTable,
1790 $srcTable,
1791 $varMap,
1792 $conds,
1793 $fname,
1794 array_diff( $insertOptions, $hints ),
1795 $selectOptions,
1796 $selectJoinConds
1800 return true;
1804 * @param array $insertOptions
1805 * @param array $selectOptions
1806 * @param string $fname
1807 * @return bool Whether an INSERT SELECT with these options will be replication safe
1808 * @since 1.31
1810 protected function isInsertSelectSafe( array $insertOptions, array $selectOptions, $fname ) {
1811 return true;
1815 * Implementation of insertSelect() based on select() and insert()
1817 * @see IDatabase::insertSelect()
1818 * @param string $destTable Unqualified name of destination table
1819 * @param string|array $srcTable Unqualified name of source table
1820 * @param array $varMap
1821 * @param array $conds
1822 * @param string $fname
1823 * @param array $insertOptions
1824 * @param array $selectOptions
1825 * @param array $selectJoinConds
1826 * @since 1.35
1828 private function doInsertSelectGeneric(
1829 $destTable,
1830 $srcTable,
1831 array $varMap,
1832 $conds,
1833 $fname,
1834 array $insertOptions,
1835 array $selectOptions,
1836 $selectJoinConds
1838 // For web requests, do a locking SELECT and then INSERT. This puts the SELECT burden
1839 // on only the primary DB (without needing row-based-replication). It also makes it easy to
1840 // know how big the INSERT is going to be.
1841 $fields = [];
1842 foreach ( $varMap as $dstColumn => $sourceColumnOrSql ) {
1843 $fields[] = $this->platform->fieldNameWithAlias( $sourceColumnOrSql, $dstColumn );
1845 $res = $this->select(
1846 $srcTable,
1847 implode( ',', $fields ),
1848 $conds,
1849 $fname,
1850 array_merge( $selectOptions, [ 'FOR UPDATE' ] ),
1851 $selectJoinConds
1854 $affectedRowCount = 0;
1855 $insertId = null;
1856 if ( $res ) {
1857 $this->startAtomic( $fname, self::ATOMIC_CANCELABLE );
1858 try {
1859 $rows = [];
1860 foreach ( $res as $row ) {
1861 $rows[] = (array)$row;
1863 // Avoid inserts that are too huge
1864 $rowBatches = array_chunk( $rows, $this->nonNativeInsertSelectBatchSize );
1865 foreach ( $rowBatches as $rows ) {
1866 $query = $this->platform->dispatchingInsertSqlText( $destTable, $rows, $insertOptions );
1867 $this->query( $query, $fname );
1868 $affectedRowCount += $this->lastQueryAffectedRows;
1869 $insertId = $insertId ?: $this->lastQueryInsertId;
1871 $this->endAtomic( $fname );
1872 } catch ( DBError $e ) {
1873 $this->cancelAtomic( $fname );
1874 throw $e;
1877 $this->lastEmulatedAffectedRows = $affectedRowCount;
1878 $this->lastEmulatedInsertId = $insertId;
1882 * Native server-side implementation of insertSelect() for situations where
1883 * we don't want to select everything into memory
1885 * @see IDatabase::insertSelect()
1886 * @param string $destTable The unqualified name of destination table
1887 * @param string|array $srcTable The unqualified name of source table
1888 * @param array $varMap
1889 * @param array $conds
1890 * @param string $fname
1891 * @param array $insertOptions
1892 * @param array $selectOptions
1893 * @param array $selectJoinConds
1894 * @since 1.35
1896 protected function doInsertSelectNative(
1897 $destTable,
1898 $srcTable,
1899 array $varMap,
1900 $conds,
1901 $fname,
1902 array $insertOptions,
1903 array $selectOptions,
1904 $selectJoinConds
1906 $sql = $this->platform->insertSelectNativeSqlText(
1907 $destTable,
1908 $srcTable,
1909 $varMap,
1910 $conds,
1911 $fname,
1912 $insertOptions,
1913 $selectOptions,
1914 $selectJoinConds
1916 $query = new Query(
1917 $sql,
1918 self::QUERY_CHANGE_ROWS,
1919 'INSERT',
1920 $destTable
1922 $this->query( $query, $fname );
1926 * Do not use this method outside of Database/DBError classes
1928 * @param int|string $errno
1929 * @return bool Whether the given query error was a connection drop
1930 * @since 1.38
1932 protected function isConnectionError( $errno ) {
1933 return false;
1937 * @param int|string $errno
1938 * @return bool Whether it is known that the last query error only caused statement rollback
1939 * @note This is for backwards compatibility for callers catching DBError exceptions in
1940 * order to ignore problems like duplicate key errors or foreign key violations
1941 * @since 1.39
1943 protected function isKnownStatementRollbackError( $errno ) {
1944 return false; // don't know; it could have caused a transaction rollback
1948 * @inheritDoc
1950 public function serverIsReadOnly() {
1951 return false;
1954 final public function onTransactionResolution( callable $callback, $fname = __METHOD__ ) {
1955 $this->transactionManager->onTransactionResolution( $this, $callback, $fname );
1958 final public function onTransactionCommitOrIdle( callable $callback, $fname = __METHOD__ ) {
1959 if ( !$this->trxLevel() && $this->getTransactionRoundFname() !== null ) {
1960 // This DB handle is set to participate in LoadBalancer transaction rounds and
1961 // an explicit transaction round is active. Start an implicit transaction on this
1962 // DB handle (setting trxAutomatic) similar to how query() does in such situations.
1963 $this->begin( __METHOD__, self::TRANSACTION_INTERNAL );
1966 $this->transactionManager->addPostCommitOrIdleCallback( $callback, $fname );
1967 if ( !$this->trxLevel() ) {
1968 $dbErrors = [];
1969 $this->runOnTransactionIdleCallbacks( self::TRIGGER_IDLE, $dbErrors );
1970 if ( $dbErrors ) {
1971 throw $dbErrors[0];
1976 final public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ ) {
1977 if ( !$this->trxLevel() && $this->getTransactionRoundFname() !== null ) {
1978 // This DB handle is set to participate in LoadBalancer transaction rounds and
1979 // an explicit transaction round is active. Start an implicit transaction on this
1980 // DB handle (setting trxAutomatic) similar to how query() does in such situations.
1981 $this->begin( __METHOD__, self::TRANSACTION_INTERNAL );
1984 if ( $this->trxLevel() ) {
1985 $this->transactionManager->addPreCommitOrIdleCallback(
1986 $callback,
1987 $fname
1989 } else {
1990 // No transaction is active nor will start implicitly, so make one for this callback
1991 $this->startAtomic( __METHOD__, self::ATOMIC_CANCELABLE );
1992 try {
1993 $callback( $this );
1994 } catch ( Throwable $e ) {
1995 // Avoid confusing error reporting during critical section errors
1996 if ( !$this->csmError ) {
1997 $this->cancelAtomic( __METHOD__ );
1999 throw $e;
2001 $this->endAtomic( __METHOD__ );
2005 final public function setTransactionListener( $name, ?callable $callback = null ) {
2006 $this->transactionManager->setTransactionListener( $name, $callback );
2010 * Whether to disable running of post-COMMIT/ROLLBACK callbacks
2012 * @internal This method should not be used outside of Database/LoadBalancer
2014 * @since 1.28
2015 * @param bool $suppress
2017 final public function setTrxEndCallbackSuppression( $suppress ) {
2018 $this->transactionManager->setTrxEndCallbackSuppression( $suppress );
2022 * Consume and run any "on transaction idle/resolution" callbacks
2024 * @internal This method should not be used outside of Database/LoadBalancer
2026 * @since 1.20
2027 * @param int $trigger IDatabase::TRIGGER_* constant
2028 * @param DBError[] &$errors DB exceptions caught [returned]
2029 * @return int Number of callbacks attempted
2030 * @throws DBUnexpectedError
2031 * @throws Throwable Any non-DBError exception thrown by a callback
2033 public function runOnTransactionIdleCallbacks( $trigger, array &$errors = [] ) {
2034 if ( $this->trxLevel() ) {
2035 throw new DBUnexpectedError( $this, __METHOD__ . ': a transaction is still open' );
2038 if ( $this->transactionManager->isEndCallbacksSuppressed() ) {
2039 // Execution deferred by LoadBalancer for explicit execution later
2040 return 0;
2043 $cs = $this->commenceCriticalSection( __METHOD__ );
2045 $count = 0;
2046 $autoTrx = $this->flagsHolder->hasImplicitTrxFlag(); // automatic begin() enabled?
2047 // Drain the queues of transaction "idle" and "end" callbacks until they are empty
2048 do {
2049 $callbackEntries = $this->transactionManager->consumeEndCallbacks();
2050 $count += count( $callbackEntries );
2051 foreach ( $callbackEntries as $entry ) {
2052 $this->flagsHolder->clearFlag( self::DBO_TRX ); // make each query its own transaction
2053 try {
2054 $entry[0]( $trigger, $this );
2055 } catch ( DBError $ex ) {
2056 call_user_func( $this->errorLogger, $ex );
2057 $errors[] = $ex;
2058 // Some callbacks may use startAtomic/endAtomic, so make sure
2059 // their transactions are ended so other callbacks don't fail
2060 if ( $this->trxLevel() ) {
2061 $this->rollback( __METHOD__, self::FLUSHING_INTERNAL );
2063 } finally {
2064 if ( $autoTrx ) {
2065 $this->flagsHolder->setFlag( self::DBO_TRX ); // restore automatic begin()
2066 } else {
2067 $this->flagsHolder->clearFlag( self::DBO_TRX ); // restore auto-commit
2071 } while ( $this->transactionManager->countPostCommitOrIdleCallbacks() );
2073 $this->completeCriticalSection( __METHOD__, $cs );
2075 return $count;
2079 * Actually run any "transaction listener" callbacks
2081 * @internal This method should not be used outside of Database/LoadBalancer
2083 * @since 1.20
2084 * @param int $trigger IDatabase::TRIGGER_* constant
2085 * @param DBError[] &$errors DB exceptions caught [returned]
2086 * @throws Throwable Any non-DBError exception thrown by a callback
2088 public function runTransactionListenerCallbacks( $trigger, array &$errors = [] ) {
2089 if ( $this->transactionManager->isEndCallbacksSuppressed() ) {
2090 // Execution deferred by LoadBalancer for explicit execution later
2091 return;
2094 // These callbacks should only be registered in setup, thus no iteration is needed
2095 foreach ( $this->transactionManager->getRecurringCallbacks() as $callback ) {
2096 try {
2097 $callback( $trigger, $this );
2098 } catch ( DBError $ex ) {
2099 ( $this->errorLogger )( $ex );
2100 $errors[] = $ex;
2106 * Handle "on transaction idle/resolution" and "transaction listener" callbacks post-COMMIT
2108 * @throws DBError The first DBError exception thrown by a callback
2109 * @throws Throwable Any non-DBError exception thrown by a callback
2111 private function runTransactionPostCommitCallbacks() {
2112 $dbErrors = [];
2113 $this->runOnTransactionIdleCallbacks( self::TRIGGER_COMMIT, $dbErrors );
2114 $this->runTransactionListenerCallbacks( self::TRIGGER_COMMIT, $dbErrors );
2115 $this->lastEmulatedAffectedRows = 0; // for the sake of consistency
2116 if ( $dbErrors ) {
2117 throw $dbErrors[0];
2122 * Handle "on transaction idle/resolution" and "transaction listener" callbacks post-ROLLBACK
2124 * This will suppress and log any DBError exceptions
2126 * @throws Throwable Any non-DBError exception thrown by a callback
2128 private function runTransactionPostRollbackCallbacks() {
2129 $this->runOnTransactionIdleCallbacks( self::TRIGGER_ROLLBACK );
2130 $this->runTransactionListenerCallbacks( self::TRIGGER_ROLLBACK );
2131 $this->lastEmulatedAffectedRows = 0; // for the sake of consistency
2134 final public function startAtomic(
2135 $fname = __METHOD__,
2136 $cancelable = self::ATOMIC_NOT_CANCELABLE
2138 $cs = $this->commenceCriticalSection( __METHOD__ );
2140 if ( $this->trxLevel() ) {
2141 // This atomic section is only one part of a larger transaction
2142 $sectionOwnsTrx = false;
2143 } else {
2144 // Start an implicit transaction (sets trxAutomatic)
2145 try {
2146 $this->begin( $fname, self::TRANSACTION_INTERNAL );
2147 } catch ( DBError $e ) {
2148 $this->completeCriticalSection( __METHOD__, $cs );
2149 throw $e;
2151 if ( $this->flagsHolder->hasImplicitTrxFlag() ) {
2152 // This DB handle participates in LoadBalancer transaction rounds; all atomic
2153 // sections should be buffered into one transaction (e.g. to keep web requests
2154 // transactional). Note that an implicit transaction round is considered to be
2155 // active when no there is no explicit transaction round.
2156 $sectionOwnsTrx = false;
2157 } else {
2158 // This DB handle does not participate in LoadBalancer transaction rounds;
2159 // each topmost atomic section will use its own transaction.
2160 $sectionOwnsTrx = true;
2162 $this->transactionManager->setAutomaticAtomic( $sectionOwnsTrx );
2165 if ( $cancelable === self::ATOMIC_CANCELABLE ) {
2166 if ( $sectionOwnsTrx ) {
2167 // This atomic section is synonymous with the whole transaction; just
2168 // use full COMMIT/ROLLBACK in endAtomic()/cancelAtomic(), respectively
2169 $savepointId = self::NOT_APPLICABLE;
2170 } else {
2171 // This atomic section is only part of the whole transaction; use a SAVEPOINT
2172 // query so that its changes can be cancelled without losing the rest of the
2173 // transaction (e.g. changes from other sections or from outside of sections)
2174 try {
2175 $savepointId = $this->transactionManager->nextSavePointId( $this, $fname );
2176 $sql = $this->platform->savepointSqlText( $savepointId );
2177 $query = new Query( $sql, self::QUERY_CHANGE_TRX, 'SAVEPOINT' );
2178 $this->query( $query, $fname );
2179 } catch ( DBError $e ) {
2180 $this->completeCriticalSection( __METHOD__, $cs, $e );
2181 throw $e;
2184 } else {
2185 $savepointId = null;
2188 $sectionId = new AtomicSectionIdentifier;
2189 $this->transactionManager->addToAtomicLevels( $fname, $sectionId, $savepointId );
2191 $this->completeCriticalSection( __METHOD__, $cs );
2193 return $sectionId;
2196 final public function endAtomic( $fname = __METHOD__ ) {
2197 [ $savepointId, $sectionId ] = $this->transactionManager->onEndAtomic( $this, $fname );
2199 $runPostCommitCallbacks = false;
2201 $cs = $this->commenceCriticalSection( __METHOD__ );
2203 // Remove the last section (no need to re-index the array)
2204 $finalLevelOfImplicitTrxPopped = $this->transactionManager->popAtomicLevel();
2206 try {
2207 if ( $finalLevelOfImplicitTrxPopped ) {
2208 $this->commit( $fname, self::FLUSHING_INTERNAL );
2209 $runPostCommitCallbacks = true;
2210 } elseif ( $savepointId !== null && $savepointId !== self::NOT_APPLICABLE ) {
2211 $sql = $this->platform->releaseSavepointSqlText( $savepointId );
2212 $query = new Query( $sql, self::QUERY_CHANGE_TRX, 'RELEASE SAVEPOINT' );
2213 $this->query( $query, $fname );
2215 } catch ( DBError $e ) {
2216 $this->completeCriticalSection( __METHOD__, $cs, $e );
2217 throw $e;
2220 $this->transactionManager->onEndAtomicInCriticalSection( $sectionId );
2222 $this->completeCriticalSection( __METHOD__, $cs );
2224 if ( $runPostCommitCallbacks ) {
2225 $this->runTransactionPostCommitCallbacks();
2229 final public function cancelAtomic(
2230 $fname = __METHOD__,
2231 ?AtomicSectionIdentifier $sectionId = null
2233 $this->transactionManager->onCancelAtomicBeforeCriticalSection( $this, $fname );
2234 $pos = $this->transactionManager->getPositionFromSectionId( $sectionId );
2235 if ( $pos < 0 ) {
2236 throw new DBUnexpectedError( $this, "Atomic section not found (for $fname)" );
2239 $cs = $this->commenceCriticalSection( __METHOD__ );
2240 $runPostRollbackCallbacks = false;
2241 [ $savedFname, $excisedSectionIds, $newTopSectionId, $savedSectionId, $savepointId ] =
2242 $this->transactionManager->cancelAtomic( $pos );
2244 try {
2245 if ( $savedFname !== $fname ) {
2246 $e = new DBUnexpectedError(
2247 $this,
2248 "Invalid atomic section ended (got $fname but expected $savedFname)"
2250 $this->completeCriticalSection( __METHOD__, $cs, $e );
2251 throw $e;
2254 // Remove the last section (no need to re-index the array)
2255 $this->transactionManager->popAtomicLevel();
2256 $excisedSectionIds[] = $savedSectionId;
2257 $newTopSectionId = $this->transactionManager->currentAtomicSectionId();
2259 if ( $savepointId !== null ) {
2260 // Rollback the transaction changes proposed within this atomic section
2261 if ( $savepointId === self::NOT_APPLICABLE ) {
2262 // Atomic section started the transaction; rollback the whole transaction
2263 // and trigger cancellation callbacks for all active atomic sections
2264 $this->rollback( $fname, self::FLUSHING_INTERNAL );
2265 $runPostRollbackCallbacks = true;
2266 } else {
2267 // Atomic section nested within the transaction; rollback the transaction
2268 // to the state prior to this section and trigger its cancellation callbacks
2269 $sql = $this->platform->rollbackToSavepointSqlText( $savepointId );
2270 $query = new Query( $sql, self::QUERY_CHANGE_TRX, 'ROLLBACK TO SAVEPOINT' );
2271 $this->query( $query, $fname );
2272 $this->transactionManager->setTrxStatusToOk(); // no exception; recovered
2274 } else {
2275 // Put the transaction into an error state if it's not already in one
2276 $trxError = new DBUnexpectedError(
2277 $this,
2278 "Uncancelable atomic section canceled (got $fname)"
2280 $this->transactionManager->setTransactionError( $trxError );
2282 } finally {
2283 // Fix up callbacks owned by the sections that were just cancelled.
2284 // All callbacks should have an owner that is present in trxAtomicLevels.
2285 $this->transactionManager->modifyCallbacksForCancel(
2286 $excisedSectionIds,
2287 $newTopSectionId
2291 $this->lastEmulatedAffectedRows = 0; // for the sake of consistency
2293 $this->completeCriticalSection( __METHOD__, $cs );
2295 if ( $runPostRollbackCallbacks ) {
2296 $this->runTransactionPostRollbackCallbacks();
2300 final public function doAtomicSection(
2301 $fname,
2302 callable $callback,
2303 $cancelable = self::ATOMIC_NOT_CANCELABLE
2305 $sectionId = $this->startAtomic( $fname, $cancelable );
2306 try {
2307 $res = $callback( $this, $fname );
2308 } catch ( Throwable $e ) {
2309 // Avoid confusing error reporting during critical section errors
2310 if ( !$this->csmError ) {
2311 $this->cancelAtomic( $fname, $sectionId );
2314 throw $e;
2316 $this->endAtomic( $fname );
2318 return $res;
2321 final public function begin( $fname = __METHOD__, $mode = self::TRANSACTION_EXPLICIT ) {
2322 static $modes = [ self::TRANSACTION_EXPLICIT, self::TRANSACTION_INTERNAL ];
2323 if ( !in_array( $mode, $modes, true ) ) {
2324 throw new DBUnexpectedError( $this, "$fname: invalid mode parameter '$mode'" );
2327 $this->transactionManager->onBegin( $this, $fname );
2329 if ( $this->flagsHolder->hasImplicitTrxFlag() && $mode !== self::TRANSACTION_INTERNAL ) {
2330 $msg = "$fname: implicit transaction expected (DBO_TRX set)";
2331 throw new DBUnexpectedError( $this, $msg );
2334 $this->assertHasConnectionHandle();
2336 $cs = $this->commenceCriticalSection( __METHOD__ );
2337 $timeStart = microtime( true );
2338 try {
2339 $this->doBegin( $fname );
2340 } catch ( DBError $e ) {
2341 $this->completeCriticalSection( __METHOD__, $cs );
2342 throw $e;
2344 $timeEnd = microtime( true );
2345 // Treat "BEGIN" as a trivial query to gauge the RTT delay
2346 $rtt = max( $timeEnd - $timeStart, 0.0 );
2347 $this->transactionManager->onBeginInCriticalSection( $mode, $fname, $rtt );
2348 $this->replicationReporter->resetReplicationLagStatus( $this );
2349 $this->completeCriticalSection( __METHOD__, $cs );
2353 * Issues the BEGIN command to the database server.
2355 * @see Database::begin()
2356 * @param string $fname
2357 * @throws DBError
2359 protected function doBegin( $fname ) {
2360 $query = new Query( 'BEGIN', self::QUERY_CHANGE_TRX, 'BEGIN' );
2361 $this->query( $query, $fname );
2364 final public function commit( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) {
2365 static $modes = [ self::FLUSHING_ONE, self::FLUSHING_ALL_PEERS, self::FLUSHING_INTERNAL ];
2366 if ( !in_array( $flush, $modes, true ) ) {
2367 throw new DBUnexpectedError( $this, "$fname: invalid flush parameter '$flush'" );
2370 if ( !$this->transactionManager->onCommit( $this, $fname, $flush ) ) {
2371 return;
2374 $this->assertHasConnectionHandle();
2376 $this->runOnTransactionPreCommitCallbacks();
2378 $cs = $this->commenceCriticalSection( __METHOD__ );
2379 try {
2380 if ( $this->trxLevel() ) {
2381 $query = new Query( 'COMMIT', self::QUERY_CHANGE_TRX, 'COMMIT' );
2382 $this->query( $query, $fname );
2384 } catch ( DBError $e ) {
2385 $this->completeCriticalSection( __METHOD__, $cs );
2386 throw $e;
2388 $lastWriteTime = $this->transactionManager->onCommitInCriticalSection( $this );
2389 if ( $lastWriteTime ) {
2390 $this->lastWriteTime = $lastWriteTime;
2392 // With FLUSHING_ALL_PEERS, callbacks will run when requested by a dedicated phase
2393 // within LoadBalancer. With FLUSHING_INTERNAL, callbacks will run when requested by
2394 // the Database caller during a safe point. This avoids isolation and recursion issues.
2395 if ( $flush === self::FLUSHING_ONE ) {
2396 $this->runTransactionPostCommitCallbacks();
2398 $this->completeCriticalSection( __METHOD__, $cs );
2401 final public function rollback( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) {
2402 if (
2403 $flush !== self::FLUSHING_INTERNAL &&
2404 $flush !== self::FLUSHING_ALL_PEERS &&
2405 $this->flagsHolder->hasImplicitTrxFlag()
2407 throw new DBUnexpectedError(
2408 $this,
2409 "$fname: Expected mass rollback of all peer transactions (DBO_TRX set)"
2413 if ( !$this->trxLevel() ) {
2414 $this->transactionManager->setTrxStatusToNone();
2415 $this->transactionManager->clearPreEndCallbacks();
2416 if ( $this->transactionManager->trxLevel() === TransactionManager::STATUS_TRX_ERROR ) {
2417 $this->logger->info(
2418 "$fname: acknowledged server-side transaction loss on {db_server}",
2419 $this->getLogContext()
2423 return;
2426 $this->assertHasConnectionHandle();
2428 if ( $this->csmError ) {
2429 // Since the session state is corrupt, we cannot just rollback the transaction
2430 // while preserving the non-transaction session state. The handle will remain
2431 // marked as corrupt until flushSession() is called to reset the connection
2432 // and deal with any remaining callbacks.
2433 $this->logger->info(
2434 "$fname: acknowledged client-side transaction loss on {db_server}",
2435 $this->getLogContext()
2438 return;
2441 $cs = $this->commenceCriticalSection( __METHOD__ );
2442 if ( $this->trxLevel() ) {
2443 // Disconnects cause rollback anyway, so ignore those errors
2444 $query = new Query(
2445 $this->platform->rollbackSqlText(),
2446 self::QUERY_SILENCE_ERRORS | self::QUERY_CHANGE_TRX,
2447 'ROLLBACK'
2449 $this->query( $query, $fname );
2451 $this->transactionManager->onRollbackInCriticalSection( $this );
2452 // With FLUSHING_ALL_PEERS, callbacks will run when requested by a dedicated phase
2453 // within LoadBalancer. With FLUSHING_INTERNAL, callbacks will run when requested by
2454 // the Database caller during a safe point. This avoids isolation and recursion issues.
2455 if ( $flush === self::FLUSHING_ONE ) {
2456 $this->runTransactionPostRollbackCallbacks();
2458 $this->completeCriticalSection( __METHOD__, $cs );
2462 * @internal Only for tests and highly discouraged
2463 * @param TransactionManager $transactionManager
2465 public function setTransactionManager( TransactionManager $transactionManager ) {
2466 $this->transactionManager = $transactionManager;
2469 public function flushSession( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) {
2470 if (
2471 $flush !== self::FLUSHING_INTERNAL &&
2472 $flush !== self::FLUSHING_ALL_PEERS &&
2473 $this->flagsHolder->hasImplicitTrxFlag()
2475 throw new DBUnexpectedError(
2476 $this,
2477 "$fname: Expected mass flush of all peer connections (DBO_TRX set)"
2481 if ( $this->csmError ) {
2482 // If a critical section error occurred, such as Excimer timeout exceptions raised
2483 // before a query response was marshalled, destroy the connection handle and reset
2484 // the session state tracking variables. The value of trxLevel() is irrelevant here,
2485 // and, in fact, might be 1 due to rollback() deferring critical section recovery.
2486 $this->logger->info(
2487 "$fname: acknowledged client-side session loss on {db_server}",
2488 $this->getLogContext()
2490 $this->csmError = null;
2491 $this->csmFname = null;
2492 $this->replaceLostConnection( 2048, __METHOD__ );
2494 return;
2497 if ( $this->trxLevel() ) {
2498 // Any existing transaction should have been rolled back already
2499 throw new DBUnexpectedError(
2500 $this,
2501 "$fname: transaction still in progress (not yet rolled back)"
2505 if ( $this->transactionManager->sessionStatus() === TransactionManager::STATUS_SESS_ERROR ) {
2506 // If the session state was already lost due to either an unacknowledged session
2507 // state loss error (e.g. dropped connection) or an explicit connection close call,
2508 // then there is nothing to do here. Note that in such cases, even temporary tables
2509 // and server-side config variables are lost (invocation of this method is assumed
2510 // to imply that such losses are tolerable).
2511 $this->logger->info(
2512 "$fname: acknowledged server-side session loss on {db_server}",
2513 $this->getLogContext()
2515 } elseif ( $this->isOpen() ) {
2516 // Connection handle exists; server-side session state must be flushed
2517 $this->doFlushSession( $fname );
2518 $this->sessionNamedLocks = [];
2521 $this->transactionManager->clearSessionError();
2525 * Reset the server-side session state for named locks and table locks
2527 * Connection and query errors will be suppressed and logged
2529 * @param string $fname
2530 * @since 1.38
2532 protected function doFlushSession( $fname ) {
2533 // no-op
2536 public function flushSnapshot( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) {
2537 $this->transactionManager->onFlushSnapshot(
2538 $this,
2539 $fname,
2540 $flush,
2541 $this->getTransactionRoundFname()
2543 if (
2544 $this->transactionManager->sessionStatus() === TransactionManager::STATUS_SESS_ERROR ||
2545 $this->transactionManager->trxStatus() === TransactionManager::STATUS_TRX_ERROR
2547 $this->rollback( $fname, self::FLUSHING_INTERNAL );
2548 } else {
2549 $this->commit( $fname, self::FLUSHING_INTERNAL );
2553 public function duplicateTableStructure(
2554 $oldName,
2555 $newName,
2556 $temporary = false,
2557 $fname = __METHOD__
2559 throw new RuntimeException( __METHOD__ . ' is not implemented in descendant class' );
2562 public function listTables( $prefix = null, $fname = __METHOD__ ) {
2563 throw new RuntimeException( __METHOD__ . ' is not implemented in descendant class' );
2566 public function affectedRows() {
2567 $this->lastEmulatedAffectedRows ??= $this->lastQueryAffectedRows;
2569 return $this->lastEmulatedAffectedRows;
2572 public function insertId() {
2573 if ( $this->lastEmulatedInsertId === null ) {
2574 // Guard against misuse of this method by checking affectedRows(). Note that calls
2575 // to insert() with "IGNORE" and calls to insertSelect() might not add any rows.
2576 if ( $this->affectedRows() ) {
2577 $this->lastEmulatedInsertId = $this->lastInsertId();
2578 } else {
2579 $this->lastEmulatedInsertId = 0;
2583 return $this->lastEmulatedInsertId;
2587 * Get a row ID from the last insert statement to implicitly assign one within the session
2589 * If the statement involved assigning sequence IDs to multiple rows, then the return value
2590 * will be any one of those values (database-specific). If the statement was an "UPSERT" and
2591 * some existing rows were updated, then the result will either reflect only IDs of created
2592 * rows or it will reflect IDs of both created and updated rows (this is database-specific).
2594 * The result is unspecified if the statement gave an error.
2596 * @return int Sequence ID, 0 (if none)
2597 * @throws DBError
2599 abstract protected function lastInsertId();
2601 public function ping() {
2602 if ( $this->isOpen() ) {
2603 // If the connection was recently used, assume that it is still good
2604 if ( ( microtime( true ) - $this->lastPing ) < self::PING_TTL ) {
2605 return true;
2607 // Send a trivial query to test the connection, triggering an automatic
2608 // reconnection attempt if the connection was lost
2609 $query = new Query(
2610 self::PING_QUERY,
2611 self::QUERY_IGNORE_DBO_TRX | self::QUERY_SILENCE_ERRORS | self::QUERY_CHANGE_NONE,
2612 'SELECT'
2614 $res = $this->query( $query, __METHOD__ );
2615 $ok = ( $res !== false );
2616 } else {
2617 // Try to re-establish a connection
2618 $ok = $this->replaceLostConnection( null, __METHOD__ );
2621 return $ok;
2625 * Close any existing (dead) database connection and open a new connection
2627 * @param int|null $lastErrno
2628 * @param string $fname
2629 * @return bool True if new connection is opened successfully, false if error
2631 protected function replaceLostConnection( $lastErrno, $fname ) {
2632 if ( $this->conn ) {
2633 $this->closeConnection();
2634 $this->conn = null;
2635 $this->handleSessionLossPreconnect();
2638 try {
2639 $this->open(
2640 $this->connectionParams[self::CONN_HOST],
2641 $this->connectionParams[self::CONN_USER],
2642 $this->connectionParams[self::CONN_PASSWORD],
2643 $this->currentDomain->getDatabase(),
2644 $this->currentDomain->getSchema(),
2645 $this->tablePrefix()
2647 $this->lastPing = microtime( true );
2648 $ok = true;
2650 $this->logger->warning(
2651 $fname . ': lost connection to {db_server} with error {errno}; reconnected',
2652 $this->getLogContext( [
2653 'exception' => new RuntimeException(),
2654 'db_log_category' => 'connection',
2655 'errno' => $lastErrno
2658 } catch ( DBConnectionError $e ) {
2659 $ok = false;
2661 $this->logger->error(
2662 $fname . ': lost connection to {db_server} with error {errno}; reconnection failed: {connect_msg}',
2663 $this->getLogContext( [
2664 'exception' => new RuntimeException(),
2665 'db_log_category' => 'connection',
2666 'errno' => $lastErrno,
2667 'connect_msg' => $e->getMessage()
2672 // Handle callbacks in trxEndCallbacks, e.g. onTransactionResolution().
2673 // If callback suppression is set then the array will remain unhandled.
2674 $this->runOnTransactionIdleCallbacks( self::TRIGGER_ROLLBACK );
2675 // Handle callbacks in trxRecurringCallbacks, e.g. setTransactionListener().
2676 // If callback suppression is set then the array will remain unhandled.
2677 $this->runTransactionListenerCallbacks( self::TRIGGER_ROLLBACK );
2679 return $ok;
2683 * Merge the result of getSessionLagStatus() for several DBs
2684 * using the most pessimistic values to estimate the lag of
2685 * any data derived from them in combination
2687 * This is information is useful for caching modules
2689 * @see WANObjectCache::set()
2690 * @see WANObjectCache::getWithSetCallback()
2692 * @param IReadableDatabase|null ...$dbs
2693 * Note: For backward compatibility, it is allowed for null values
2694 * to be passed among the parameters. This is deprecated since 1.36,
2695 * only IReadableDatabase objects should be passed.
2697 * @return array Map of values:
2698 * - lag: highest lag of any of the DBs or false on error (e.g. replication stopped)
2699 * - since: oldest UNIX timestamp of any of the DB lag estimates
2700 * - pending: whether any of the DBs have uncommitted changes
2701 * @throws DBError
2702 * @since 1.27
2704 public static function getCacheSetOptions( ?IReadableDatabase ...$dbs ) {
2705 $res = [ 'lag' => 0, 'since' => INF, 'pending' => false ];
2707 foreach ( func_get_args() as $db ) {
2708 if ( $db instanceof IReadableDatabase ) {
2709 $status = $db->getSessionLagStatus();
2711 if ( $status['lag'] === false ) {
2712 $res['lag'] = false;
2713 } elseif ( $res['lag'] !== false ) {
2714 $res['lag'] = max( $res['lag'], $status['lag'] );
2716 $res['since'] = min( $res['since'], $status['since'] );
2719 if ( $db instanceof IDatabaseForOwner ) {
2720 $res['pending'] = $res['pending'] ?: $db->writesPending();
2724 return $res;
2727 public function encodeBlob( $b ) {
2728 return $b;
2731 public function decodeBlob( $b ) {
2732 if ( $b instanceof Blob ) {
2733 $b = $b->fetch();
2735 return $b;
2738 public function setSessionOptions( array $options ) {
2741 public function sourceFile(
2742 $filename,
2743 ?callable $lineCallback = null,
2744 ?callable $resultCallback = null,
2745 $fname = false,
2746 ?callable $inputCallback = null
2748 AtEase::suppressWarnings();
2749 $fp = fopen( $filename, 'r' );
2750 AtEase::restoreWarnings();
2752 if ( $fp === false ) {
2753 throw new RuntimeException( "Could not open \"{$filename}\"" );
2756 if ( !$fname ) {
2757 $fname = __METHOD__ . "( $filename )";
2760 try {
2761 return $this->sourceStream(
2762 $fp,
2763 $lineCallback,
2764 $resultCallback,
2765 $fname,
2766 $inputCallback
2768 } finally {
2769 fclose( $fp );
2773 public function sourceStream(
2774 $fp,
2775 ?callable $lineCallback = null,
2776 ?callable $resultCallback = null,
2777 $fname = __METHOD__,
2778 ?callable $inputCallback = null
2780 $delimiterReset = new ScopedCallback(
2781 function ( $delimiter ) {
2782 $this->delimiter = $delimiter;
2784 [ $this->delimiter ]
2786 $cmd = '';
2788 while ( !feof( $fp ) ) {
2789 if ( $lineCallback ) {
2790 call_user_func( $lineCallback );
2793 $line = trim( fgets( $fp ) );
2795 if ( $line == '' ) {
2796 continue;
2799 if ( $line[0] == '-' && $line[1] == '-' ) {
2800 continue;
2803 if ( $cmd != '' ) {
2804 $cmd .= ' ';
2807 $done = $this->streamStatementEnd( $cmd, $line );
2809 $cmd .= "$line\n";
2811 if ( $done || feof( $fp ) ) {
2812 $cmd = $this->platform->replaceVars( $cmd );
2814 if ( $inputCallback ) {
2815 $callbackResult = $inputCallback( $cmd );
2817 if ( is_string( $callbackResult ) || !$callbackResult ) {
2818 $cmd = $callbackResult;
2822 if ( $cmd ) {
2823 $res = $this->query( $cmd, $fname );
2825 if ( $resultCallback ) {
2826 $resultCallback( $res, $this );
2829 if ( $res === false ) {
2830 $err = $this->lastError();
2832 return "Query \"{$cmd}\" failed with error code \"$err\".\n";
2835 $cmd = '';
2839 ScopedCallback::consume( $delimiterReset );
2840 return true;
2844 * Called by sourceStream() to check if we've reached a statement end
2846 * @param string &$sql SQL assembled so far
2847 * @param string &$newLine New line about to be added to $sql
2848 * @return bool Whether $newLine contains end of the statement
2850 public function streamStatementEnd( &$sql, &$newLine ) {
2851 if ( $this->delimiter ) {
2852 $prev = $newLine;
2853 $newLine = preg_replace(
2854 '/' . preg_quote( $this->delimiter, '/' ) . '$/',
2856 $newLine
2858 if ( $newLine != $prev ) {
2859 return true;
2863 return false;
2867 * @inheritDoc
2869 public function lockIsFree( $lockName, $method ) {
2870 // RDBMs methods for checking named locks may or may not count this thread itself.
2871 // In MySQL, IS_FREE_LOCK() returns 0 if the thread already has the lock. This is
2872 // the behavior chosen by the interface for this method.
2873 if ( isset( $this->sessionNamedLocks[$lockName] ) ) {
2874 $lockIsFree = false;
2875 } else {
2876 $lockIsFree = $this->doLockIsFree( $lockName, $method );
2879 return $lockIsFree;
2883 * @see lockIsFree()
2885 * @param string $lockName
2886 * @param string $method
2887 * @return bool Success
2888 * @throws DBError
2890 protected function doLockIsFree( string $lockName, string $method ) {
2891 return true; // not implemented
2895 * @inheritDoc
2897 public function lock( $lockName, $method, $timeout = 5, $flags = 0 ) {
2898 $lockTsUnix = $this->doLock( $lockName, $method, $timeout );
2899 if ( $lockTsUnix !== null ) {
2900 $locked = true;
2901 $this->sessionNamedLocks[$lockName] = [
2902 'ts' => $lockTsUnix,
2903 'trxId' => $this->transactionManager->getTrxId()
2905 } else {
2906 $locked = false;
2907 $this->logger->info(
2908 __METHOD__ . ": failed to acquire lock '{lockname}'",
2910 'lockname' => $lockName,
2911 'db_log_category' => 'locking'
2916 return $this->flagsHolder::contains( $flags, self::LOCK_TIMESTAMP ) ? $lockTsUnix : $locked;
2920 * @see lock()
2922 * @param string $lockName
2923 * @param string $method
2924 * @param int $timeout
2925 * @return float|null UNIX timestamp of lock acquisition; null on failure
2926 * @throws DBError
2928 protected function doLock( string $lockName, string $method, int $timeout ) {
2929 return microtime( true ); // not implemented
2933 * @inheritDoc
2935 public function unlock( $lockName, $method ) {
2936 if ( !isset( $this->sessionNamedLocks[$lockName] ) ) {
2937 $released = false;
2938 $this->logger->warning(
2939 __METHOD__ . ": trying to release unheld lock '$lockName'\n",
2940 [ 'db_log_category' => 'locking' ]
2942 } else {
2943 $released = $this->doUnlock( $lockName, $method );
2944 if ( $released ) {
2945 unset( $this->sessionNamedLocks[$lockName] );
2946 } else {
2947 $this->logger->warning(
2948 __METHOD__ . ": failed to release lock '$lockName'\n",
2949 [ 'db_log_category' => 'locking' ]
2954 return $released;
2958 * @see unlock()
2960 * @param string $lockName
2961 * @param string $method
2962 * @return bool Success
2963 * @throws DBError
2965 protected function doUnlock( string $lockName, string $method ) {
2966 return true; // not implemented
2969 public function getScopedLockAndFlush( $lockKey, $fname, $timeout ) {
2970 $this->transactionManager->onGetScopedLockAndFlush( $this, $fname );
2972 if ( !$this->lock( $lockKey, $fname, $timeout ) ) {
2973 return null;
2976 $unlocker = new ScopedCallback( function () use ( $lockKey, $fname ) {
2977 // Note that the callback can be reached due to an exception making the calling
2978 // function end early. If the transaction/session is in an error state, avoid log
2979 // spam and confusing replacement of an original DBError with one about unlock().
2980 // Unlock query will fail anyway; avoid possibly triggering errors in rollback()
2981 if (
2982 $this->transactionManager->sessionStatus() === TransactionManager::STATUS_SESS_ERROR ||
2983 $this->transactionManager->trxStatus() === TransactionManager::STATUS_TRX_ERROR
2985 return;
2987 if ( $this->trxLevel() ) {
2988 $this->onTransactionResolution(
2989 function () use ( $lockKey, $fname ) {
2990 $this->unlock( $lockKey, $fname );
2992 $fname
2994 } else {
2995 $this->unlock( $lockKey, $fname );
2997 } );
2999 $this->commit( $fname, self::FLUSHING_INTERNAL );
3001 return $unlocker;
3004 public function dropTable( $table, $fname = __METHOD__ ) {
3005 if ( !$this->tableExists( $table, $fname ) ) {
3006 return false;
3009 $query = new Query(
3010 $this->platform->dropTableSqlText( $table ),
3011 self::QUERY_CHANGE_SCHEMA,
3012 'DROP',
3013 $table
3015 $this->query( $query, $fname );
3017 return true;
3020 public function truncateTable( $table, $fname = __METHOD__ ) {
3021 $sql = "TRUNCATE TABLE " . $this->tableName( $table );
3022 $query = new Query( $sql, self::QUERY_CHANGE_SCHEMA, 'TRUNCATE', $table );
3023 $this->query( $query, $fname );
3026 public function isReadOnly() {
3027 return ( $this->getReadOnlyReason() !== null );
3031 * @return array|null Tuple of (reason string, "role" or "lb") if read-only; null otherwise
3033 protected function getReadOnlyReason() {
3034 $reason = $this->replicationReporter->getTopologyBasedReadOnlyReason();
3035 if ( $reason ) {
3036 return $reason;
3039 $reason = $this->getLBInfo( self::LB_READ_ONLY_REASON );
3040 if ( is_string( $reason ) ) {
3041 return [ $reason, 'lb' ];
3044 return null;
3048 * Get the underlying binding connection handle
3050 * Makes sure the connection resource is set (disconnects and ping() failure can unset it).
3051 * This catches broken callers than catch and ignore disconnection exceptions.
3052 * Unlike checking isOpen(), this is safe to call inside of open().
3054 * @return mixed
3055 * @throws DBUnexpectedError
3056 * @since 1.26
3058 protected function getBindingHandle() {
3059 if ( !$this->conn ) {
3060 throw new DBUnexpectedError(
3061 $this,
3062 'DB connection was already closed or the connection dropped'
3066 return $this->conn;
3070 * Demark the start of a critical section of session/transaction state changes
3072 * Use this to disable potentially DB handles due to corruption from highly unexpected
3073 * exceptions (e.g. from zend timers or coding errors) preempting execution of methods.
3075 * Callers must demark completion of the critical section with completeCriticalSection().
3076 * Callers should handle DBError exceptions that do not cause object state corruption by
3077 * catching them, calling completeCriticalSection(), and then rethrowing them.
3079 * @code
3080 * $cs = $this->commenceCriticalSection( __METHOD__ );
3081 * try {
3082 * //...send a query that changes the session/transaction state...
3083 * } catch ( DBError $e ) {
3084 * $this->completeCriticalSection( __METHOD__, $cs );
3085 * throw $expectedException;
3087 * try {
3088 * //...send another query that changes the session/transaction state...
3089 * } catch ( DBError $trxError ) {
3090 * // Require ROLLBACK before allowing any other queries from outside callers
3091 * $this->completeCriticalSection( __METHOD__, $cs, $trxError );
3092 * throw $expectedException;
3094 * // ...update session state fields of $this...
3095 * $this->completeCriticalSection( __METHOD__, $cs );
3096 * @endcode
3098 * @see Database::completeCriticalSection()
3100 * @since 1.36
3101 * @param string $fname Caller name
3102 * @return CriticalSectionScope|null RAII-style monitor (topmost sections only)
3103 * @throws DBUnexpectedError If an unresolved critical section error already exists
3105 protected function commenceCriticalSection( string $fname ) {
3106 if ( $this->csmError ) {
3107 throw new DBUnexpectedError(
3108 $this,
3109 "Cannot execute $fname critical section while session state is out of sync.\n\n" .
3110 $this->csmError->getMessage() . "\n" .
3111 $this->csmError->getTraceAsString()
3115 if ( $this->csmId ) {
3116 $csm = null; // fold into the outer critical section
3117 } elseif ( $this->csProvider ) {
3118 $csm = $this->csProvider->scopedEnter(
3119 $fname,
3120 null, // emergency limit (default)
3121 null, // emergency callback (default)
3122 function () use ( $fname ) {
3123 // Mark a critical section as having been aborted by an error
3124 $e = new RuntimeException( "A critical section from {$fname} has failed" );
3125 $this->csmError = $e;
3126 $this->csmId = null;
3129 $this->csmId = $csm->getId();
3130 $this->csmFname = $fname;
3131 } else {
3132 $csm = null; // not supported
3135 return $csm;
3139 * Demark the completion of a critical section of session/transaction state changes
3141 * @see Database::commenceCriticalSection()
3143 * @since 1.36
3144 * @param string $fname Caller name
3145 * @param CriticalSectionScope|null $csm RAII-style monitor (topmost sections only)
3146 * @param Throwable|null $trxError Error that requires setting STATUS_TRX_ERROR (if any)
3148 protected function completeCriticalSection(
3149 string $fname,
3150 ?CriticalSectionScope $csm,
3151 ?Throwable $trxError = null
3153 if ( $csm !== null ) {
3154 if ( $this->csmId === null ) {
3155 throw new LogicException( "$fname critical section is not active" );
3156 } elseif ( $csm->getId() !== $this->csmId ) {
3157 throw new LogicException(
3158 "$fname critical section is not the active ({$this->csmFname}) one"
3162 $csm->exit();
3163 $this->csmId = null;
3166 if ( $trxError ) {
3167 $this->transactionManager->setTransactionError( $trxError );
3171 public function __toString() {
3172 $id = spl_object_id( $this );
3174 $description = $this->getType() . ' object #' . $id;
3175 // phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.is_resource
3176 if ( is_resource( $this->conn ) ) {
3177 $description .= ' (' . (string)$this->conn . ')'; // "resource id #<ID>"
3178 } elseif ( is_object( $this->conn ) ) {
3179 $handleId = spl_object_id( $this->conn );
3180 $description .= " (handle id #$handleId)";
3183 return $description;
3187 * Make sure that copies do not share the same client binding handle
3188 * @throws DBConnectionError
3190 public function __clone() {
3191 $this->logger->warning(
3192 "Cloning " . static::class . " is not recommended; forking connection",
3194 'exception' => new RuntimeException(),
3195 'db_log_category' => 'connection'
3199 if ( $this->isOpen() ) {
3200 // Open a new connection resource without messing with the old one
3201 $this->conn = null;
3202 $this->transactionManager->clearEndCallbacks();
3203 $this->handleSessionLossPreconnect(); // no trx or locks anymore
3204 $this->open(
3205 $this->connectionParams[self::CONN_HOST],
3206 $this->connectionParams[self::CONN_USER],
3207 $this->connectionParams[self::CONN_PASSWORD],
3208 $this->currentDomain->getDatabase(),
3209 $this->currentDomain->getSchema(),
3210 $this->tablePrefix()
3212 $this->lastPing = microtime( true );
3217 * Called by serialize. Throw an exception when DB connection is serialized.
3218 * This causes problems on some database engines because the connection is
3219 * not restored on unserialize.
3220 * @return never
3222 public function __sleep() {
3223 throw new RuntimeException( 'Database serialization may cause problems, since ' .
3224 'the connection is not restored on wakeup' );
3228 * Run a few simple checks and close dangling connections
3230 public function __destruct() {
3231 if ( $this->transactionManager ) {
3232 // Tests mock this class and disable constructor.
3233 $this->transactionManager->onDestruct();
3236 $danglingWriters = $this->pendingWriteAndCallbackCallers();
3237 if ( $danglingWriters ) {
3238 $fnames = implode( ', ', $danglingWriters );
3239 trigger_error( "DB transaction writes or callbacks still pending ($fnames)" );
3242 if ( $this->conn ) {
3243 // Avoid connection leaks. Normally, resources close at script completion.
3244 // The connection might already be closed in PHP by now, so suppress warnings.
3245 AtEase::suppressWarnings();
3246 $this->closeConnection();
3247 AtEase::restoreWarnings();
3248 $this->conn = null;
3252 /* Start of methods delegated to DatabaseFlags. Avoid using them outside of rdbms library */
3254 public function setFlag( $flag, $remember = self::REMEMBER_NOTHING ) {
3255 $this->flagsHolder->setFlag( $flag, $remember );
3258 public function clearFlag( $flag, $remember = self::REMEMBER_NOTHING ) {
3259 $this->flagsHolder->clearFlag( $flag, $remember );
3262 public function restoreFlags( $state = self::RESTORE_PRIOR ) {
3263 $this->flagsHolder->restoreFlags( $state );
3266 public function getFlag( $flag ) {
3267 return $this->flagsHolder->getFlag( $flag );
3270 /* End of methods delegated to DatabaseFlags. */
3272 /* Start of methods delegated to TransactionManager. Avoid using them outside of rdbms library */
3274 final public function trxLevel() {
3275 // FIXME: A lot of tests disable constructor leading to trx manager being
3276 // null and breaking, this is unacceptable but hopefully this should
3277 // happen less by moving these functions to the transaction manager class.
3278 if ( !$this->transactionManager ) {
3279 $this->transactionManager = new TransactionManager( new NullLogger() );
3281 return $this->transactionManager->trxLevel();
3284 public function trxTimestamp() {
3285 return $this->transactionManager->trxTimestamp();
3288 public function trxStatus() {
3289 return $this->transactionManager->trxStatus();
3292 public function writesPending() {
3293 return $this->transactionManager->writesPending();
3296 public function writesOrCallbacksPending() {
3297 return $this->transactionManager->writesOrCallbacksPending();
3300 public function pendingWriteQueryDuration( $type = self::ESTIMATE_TOTAL ) {
3301 return $this->transactionManager->pendingWriteQueryDuration( $type );
3304 public function pendingWriteCallers() {
3305 if ( !$this->transactionManager ) {
3306 return [];
3308 return $this->transactionManager->pendingWriteCallers();
3311 public function pendingWriteAndCallbackCallers() {
3312 if ( !$this->transactionManager ) {
3313 return [];
3315 return $this->transactionManager->pendingWriteAndCallbackCallers();
3318 public function runOnTransactionPreCommitCallbacks() {
3319 return $this->transactionManager->runOnTransactionPreCommitCallbacks( $this );
3322 public function explicitTrxActive() {
3323 return $this->transactionManager->explicitTrxActive();
3326 /* End of methods delegated to TransactionManager. */
3328 /* Start of methods delegated to SQLPlatform. Avoid using them outside of rdbms library */
3330 public function implicitOrderby() {
3331 return $this->platform->implicitOrderby();
3334 public function selectSQLText(
3335 $tables, $vars, $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
3337 return $this->platform->selectSQLText( $tables, $vars, $conds, $fname, $options, $join_conds );
3340 public function buildComparison( string $op, array $conds ): string {
3341 return $this->platform->buildComparison( $op, $conds );
3344 public function makeList( array $a, $mode = self::LIST_COMMA ) {
3345 return $this->platform->makeList( $a, $mode );
3348 public function makeWhereFrom2d( $data, $baseKey, $subKey ) {
3349 return $this->platform->makeWhereFrom2d( $data, $baseKey, $subKey );
3352 public function factorConds( $condsArray ) {
3353 return $this->platform->factorConds( $condsArray );
3356 public function bitNot( $field ) {
3357 return $this->platform->bitNot( $field );
3360 public function bitAnd( $fieldLeft, $fieldRight ) {
3361 return $this->platform->bitAnd( $fieldLeft, $fieldRight );
3364 public function bitOr( $fieldLeft, $fieldRight ) {
3365 return $this->platform->bitOr( $fieldLeft, $fieldRight );
3368 public function buildConcat( $stringList ) {
3369 return $this->platform->buildConcat( $stringList );
3372 public function buildGreatest( $fields, $values ) {
3373 return $this->platform->buildGreatest( $fields, $values );
3376 public function buildLeast( $fields, $values ) {
3377 return $this->platform->buildLeast( $fields, $values );
3380 public function buildSubstring( $input, $startPosition, $length = null ) {
3381 return $this->platform->buildSubstring( $input, $startPosition, $length );
3384 public function buildStringCast( $field ) {
3385 return $this->platform->buildStringCast( $field );
3388 public function buildIntegerCast( $field ) {
3389 return $this->platform->buildIntegerCast( $field );
3392 public function tableName( string $name, $format = 'quoted' ) {
3393 return $this->platform->tableName( $name, $format );
3396 public function tableNamesN( ...$tables ) {
3397 return $this->platform->tableNamesN( ...$tables );
3400 public function addIdentifierQuotes( $s ) {
3401 return $this->platform->addIdentifierQuotes( $s );
3404 public function isQuotedIdentifier( $name ) {
3405 return $this->platform->isQuotedIdentifier( $name );
3408 public function buildLike( $param, ...$params ) {
3409 return $this->platform->buildLike( $param, ...$params );
3412 public function anyChar() {
3413 return $this->platform->anyChar();
3416 public function anyString() {
3417 return $this->platform->anyString();
3420 public function limitResult( $sql, $limit, $offset = false ) {
3421 return $this->platform->limitResult( $sql, $limit, $offset );
3424 public function unionSupportsOrderAndLimit() {
3425 return $this->platform->unionSupportsOrderAndLimit();
3428 public function unionQueries( $sqls, $all, $options = [] ) {
3429 return $this->platform->unionQueries( $sqls, $all, $options );
3432 public function conditional( $cond, $caseTrueExpression, $caseFalseExpression ) {
3433 return $this->platform->conditional( $cond, $caseTrueExpression, $caseFalseExpression );
3436 public function strreplace( $orig, $old, $new ) {
3437 return $this->platform->strreplace( $orig, $old, $new );
3440 public function timestamp( $ts = 0 ) {
3441 return $this->platform->timestamp( $ts );
3444 public function timestampOrNull( $ts = null ) {
3445 return $this->platform->timestampOrNull( $ts );
3448 public function getInfinity() {
3449 return $this->platform->getInfinity();
3452 public function encodeExpiry( $expiry ) {
3453 return $this->platform->encodeExpiry( $expiry );
3456 public function decodeExpiry( $expiry, $format = TS_MW ) {
3457 return $this->platform->decodeExpiry( $expiry, $format );
3460 public function setTableAliases( array $aliases ) {
3461 $this->platform->setTableAliases( $aliases );
3464 public function getTableAliases() {
3465 return $this->platform->getTableAliases();
3468 public function setIndexAliases( array $aliases ) {
3469 $this->platform->setIndexAliases( $aliases );
3472 public function buildGroupConcatField(
3473 $delim, $tables, $field, $conds = '', $join_conds = []
3475 return $this->platform->buildGroupConcatField( $delim, $tables, $field, $conds, $join_conds );
3478 public function buildSelectSubquery(
3479 $tables, $vars, $conds = '', $fname = __METHOD__,
3480 $options = [], $join_conds = []
3482 return $this->platform->buildSelectSubquery( $tables, $vars, $conds, $fname, $options, $join_conds );
3485 public function buildExcludedValue( $column ) {
3486 return $this->platform->buildExcludedValue( $column );
3489 public function setSchemaVars( $vars ) {
3490 $this->platform->setSchemaVars( $vars );
3493 /* End of methods delegated to SQLPlatform. */
3495 /* Start of methods delegated to ReplicationReporter. */
3496 public function primaryPosWait( DBPrimaryPos $pos, $timeout ) {
3497 return $this->replicationReporter->primaryPosWait( $this, $pos, $timeout );
3500 public function getPrimaryPos() {
3501 return $this->replicationReporter->getPrimaryPos( $this );
3504 public function getLag() {
3505 return $this->replicationReporter->getLag( $this );
3508 public function getSessionLagStatus() {
3509 return $this->replicationReporter->getSessionLagStatus( $this );
3512 /* End of methods delegated to ReplicationReporter. */