Merge "rdbms: Add multi-statement query support to Database"
This commit is contained in:
commit
dadf6b8edd
12 changed files with 803 additions and 274 deletions
|
|
@ -1851,6 +1851,7 @@ $wgAutoloadLocalClasses = [
|
|||
'Wikimedia\\Rdbms\\PostgresBlob' => __DIR__ . '/includes/libs/rdbms/encasing/PostgresBlob.php',
|
||||
'Wikimedia\\Rdbms\\PostgresField' => __DIR__ . '/includes/libs/rdbms/field/PostgresField.php',
|
||||
'Wikimedia\\Rdbms\\PostgresResultWrapper' => __DIR__ . '/includes/libs/rdbms/database/resultwrapper/PostgresResultWrapper.php',
|
||||
'Wikimedia\\Rdbms\\QueryStatus' => __DIR__ . '/includes/libs/rdbms/database/utils/QueryStatus.php',
|
||||
'Wikimedia\\Rdbms\\ResultWrapper' => __DIR__ . '/includes/libs/rdbms/database/resultwrapper/ResultWrapper.php',
|
||||
'Wikimedia\\Rdbms\\SQLiteField' => __DIR__ . '/includes/libs/rdbms/field/SQLiteField.php',
|
||||
'Wikimedia\\Rdbms\\SchemaBuilder' => __DIR__ . '/includes/libs/rdbms/dbal/SchemaBuilder.php',
|
||||
|
|
|
|||
|
|
@ -964,7 +964,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
|
|||
abstract protected function closeConnection();
|
||||
|
||||
/**
|
||||
* Run a query and return a DBMS-dependent wrapper or boolean
|
||||
* Run a query and return a QueryStatus instance with the query result information
|
||||
*
|
||||
* This is meant to handle the basic command of actually sending a query to the
|
||||
* server via the driver. No implicit transaction, reconnection, nor retry logic
|
||||
|
|
@ -977,19 +977,45 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
|
|||
* to doQuery() such that an immediately subsequent call to lastError()/lastErrno()
|
||||
* meaningfully reflects any error that occurred during that public query method call.
|
||||
*
|
||||
* For SELECT queries, this returns either:
|
||||
* - a) An IResultWrapper describing the query results
|
||||
* For SELECT queries, the result field contains either:
|
||||
* - a) A driver-specific IResultWrapper describing the query results
|
||||
* - b) False, on any query failure
|
||||
*
|
||||
* For non-SELECT queries, this returns either:
|
||||
* - a) A driver-specific value/resource, only on success
|
||||
* For non-SELECT queries, the result field contains either:
|
||||
* - a) A driver-specific IResultWrapper, only on success
|
||||
* - b) True, only on success (e.g. no meaningful result other than "OK")
|
||||
* - c) False, on any query failure
|
||||
*
|
||||
* @param string $sql SQL query
|
||||
* @return IResultWrapper|bool An IResultWrapper, or true on success; false on failure
|
||||
* @param string $sql Single-statement SQL query
|
||||
* @return QueryStatus
|
||||
* @since 1.39
|
||||
*/
|
||||
abstract protected function doQuery( $sql );
|
||||
abstract protected function doSingleStatementQuery( string $sql ): QueryStatus;
|
||||
|
||||
/**
|
||||
* Execute a batch of query statements, aborting remaining statements if one fails
|
||||
*
|
||||
* @see Database::doQuery()
|
||||
*
|
||||
* @stable to override
|
||||
* @param string[] $sqls Non-empty map of (statement ID => SQL statement)
|
||||
* @return array<string,QueryStatus> Map of (statement ID => QueryStatus)
|
||||
* @since 1.39
|
||||
*/
|
||||
protected function doMultiStatementQuery( array $sqls ): array {
|
||||
$qsByStatementId = [];
|
||||
|
||||
$aborted = false;
|
||||
foreach ( $sqls as $statementId => $sql ) {
|
||||
$qs = $aborted
|
||||
? new QueryStatus( false, 0, 'Query aborted', 0 )
|
||||
: $this->doSingleStatementQuery( $sql );
|
||||
$qsByStatementId[$statementId] = $qs;
|
||||
$aborted = ( $qs->res === false );
|
||||
}
|
||||
|
||||
return $qsByStatementId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a query writes to the DB. When in doubt, this returns true.
|
||||
|
|
@ -1207,147 +1233,245 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
|
|||
: false;
|
||||
}
|
||||
|
||||
public function query( $sql, $fname = __METHOD__, $flags = self::QUERY_NORMAL ) {
|
||||
public function query( $sql, $fname = __METHOD__, $flags = 0 ) {
|
||||
$flags = (int)$flags; // b/c; this field used to be a bool
|
||||
// Double check that the SQL query is appropriate in the current context and is
|
||||
// allowed for an outside caller (e.g. does not break session/transaction tracking).
|
||||
|
||||
// Make sure that this caller is allowed to issue this query statement
|
||||
$this->assertQueryIsCurrentlyAllowed( $sql, $fname );
|
||||
|
||||
// Send the query to the server and fetch any corresponding errors
|
||||
list( $ret, $err, $errno, $errFlags ) = $this->executeQuery( $sql, $fname, $flags );
|
||||
if ( $ret === false ) {
|
||||
/** @var QueryStatus $qs */
|
||||
$qs = $this->executeQuery( $sql, $fname, $flags, $sql );
|
||||
|
||||
// Handle any errors that occurred
|
||||
if ( $qs->res === false ) {
|
||||
// An error occurred; log and report it as needed. Errors that corrupt the state of
|
||||
// the transaction/session cannot be silenced from the client.
|
||||
$ignore = (
|
||||
$this->fieldHasBit( $flags, self::QUERY_SILENCE_ERRORS ) &&
|
||||
!$this->fieldHasBit( $errFlags, self::ERR_ABORT_SESSION ) &&
|
||||
!$this->fieldHasBit( $errFlags, self::ERR_ABORT_TRX )
|
||||
!$this->fieldHasBit( $qs->flags, self::ERR_ABORT_SESSION ) &&
|
||||
!$this->fieldHasBit( $qs->flags, self::ERR_ABORT_TRX )
|
||||
);
|
||||
$this->reportQueryError( $err, $errno, $sql, $fname, $ignore );
|
||||
// Throw an error unless both the ignore flag was set and a rollback is not needed
|
||||
$this->reportQueryError( $qs->message, $qs->code, $sql, $fname, $ignore );
|
||||
}
|
||||
|
||||
return $ret;
|
||||
return $qs->res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query, retrying it if there is a recoverable connection loss
|
||||
* Run a batch of SQL query statements and return the results
|
||||
*
|
||||
* This is similar to query() except:
|
||||
* - It does not prevent all non-ROLLBACK queries if there is a corrupted transaction
|
||||
* - It does not disallow raw queries that are supposed to use dedicated IDatabase methods
|
||||
* - It does not throw exceptions for common error cases
|
||||
* @see Database::query()
|
||||
*
|
||||
* This is meant for internal use with Database subclasses.
|
||||
*
|
||||
* @param string $sql Original SQL statement query
|
||||
* @param string[] $sqls Map of (statement ID => SQL statement)
|
||||
* @param string $fname Name of the calling function
|
||||
* @param int $flags Bit field of class QUERY_* constants
|
||||
* @return array An n-tuple of:
|
||||
* - mixed|bool: An object, resource, or true on success; false on failure
|
||||
* - string: The result of calling lastError()
|
||||
* - int: The result of calling lastErrno()
|
||||
* - int: Bit field of ERR_* class constants
|
||||
* @throws DBUnexpectedError
|
||||
* @param int $flags Bit field of IDatabase::QUERY_* constants
|
||||
* @param string $summarySql Virtual SQL for profiling (e.g. "UPSERT INTO TABLE 'x'")
|
||||
* @return array<string,QueryStatus> Ordered map of (statement ID => QueryStatus)
|
||||
* @since 1.39
|
||||
*/
|
||||
final protected function executeQuery( $sql, $fname, $flags ) {
|
||||
$this->assertHasConnectionHandle();
|
||||
|
||||
if ( $this->isWriteQuery( $sql, $flags ) ) {
|
||||
// Do not treat temporary table writes as "meaningful writes" since they are only
|
||||
// visible to one session and are not permanent. Profile them as reads. Integration
|
||||
// tests can override this behavior via $flags.
|
||||
$pseudoPermanent = $this->fieldHasBit( $flags, self::QUERY_PSEUDO_PERMANENT );
|
||||
$tempTableChanges = $this->getTempTableWrites( $sql, $pseudoPermanent );
|
||||
$isPermWrite = !$tempTableChanges;
|
||||
foreach ( $tempTableChanges as list( $tmpType ) ) {
|
||||
$isPermWrite = $isPermWrite || ( $tmpType !== self::TEMP_NORMAL );
|
||||
}
|
||||
// Permit temporary table writes on replica DB connections
|
||||
// but require a writable primary DB connection for any persistent writes.
|
||||
if ( $isPermWrite ) {
|
||||
$this->assertIsWritablePrimary();
|
||||
// DBConnRef uses QUERY_REPLICA_ROLE to enforce replica roles for raw SQL queries
|
||||
if ( $this->fieldHasBit( $flags, self::QUERY_REPLICA_ROLE ) ) {
|
||||
throw new DBReadOnlyRoleError(
|
||||
$this,
|
||||
"Cannot write; target role is DB_REPLICA"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No permanent writes in this query
|
||||
$isPermWrite = false;
|
||||
// No temporary tables written to either
|
||||
$tempTableChanges = [];
|
||||
public function queryMulti( array $sqls, string $fname, int $flags, string $summarySql ) {
|
||||
// Make sure that this caller is allowed to issue these query statements
|
||||
foreach ( $sqls as $sql ) {
|
||||
$this->assertQueryIsCurrentlyAllowed( $sql, $fname );
|
||||
}
|
||||
|
||||
// Send the query statements to the server and fetch the results
|
||||
$statusByStatementId = $this->executeQuery( $sqls, $fname, $flags, $summarySql );
|
||||
// @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach
|
||||
foreach ( $statusByStatementId as $statementId => $qs ) {
|
||||
if ( $qs->res === false ) {
|
||||
// An error occurred; log and report it as needed. Errors that corrupt the state of
|
||||
// the transaction/session cannot be silenced from the client.
|
||||
$ignore = (
|
||||
$this->fieldHasBit( $flags, self::QUERY_SILENCE_ERRORS ) &&
|
||||
!$this->fieldHasBit( $qs->flags, self::ERR_ABORT_SESSION ) &&
|
||||
!$this->fieldHasBit( $qs->flags, self::ERR_ABORT_TRX )
|
||||
);
|
||||
$this->reportQueryError(
|
||||
$qs->message,
|
||||
$qs->code,
|
||||
$sqls[$statementId],
|
||||
$fname,
|
||||
$ignore
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $statusByStatementId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a set of queries without enforcing public (non-Database) caller restrictions
|
||||
*
|
||||
* Retry it if there is a recoverable connection loss (e.g. no important state lost)
|
||||
*
|
||||
* This does not precheck for transaction/session state errors or critical section errors
|
||||
*
|
||||
* @see Database::query()
|
||||
* @see Database::querMulti()
|
||||
*
|
||||
* @param string|string[] $sqls SQL statment or (statement ID => SQL statement) map
|
||||
* @param string $fname Name of the calling function
|
||||
* @param int $flags Bit field of class QUERY_* constants
|
||||
* @param string $summarySql Actual/simplified SQL for profiling
|
||||
* @return QueryStatus|array<string,QueryStatus> QueryStatus (when given a string statement)
|
||||
* or ordered map of (statement ID => QueryStatus) (when given an array of statements)
|
||||
* @throws DBUnexpectedError
|
||||
* @since 1.34
|
||||
*/
|
||||
final protected function executeQuery( $sqls, $fname, $flags, $summarySql ) {
|
||||
if ( is_array( $sqls ) ) {
|
||||
// Query consists of an atomic match of statements
|
||||
$multiMode = true;
|
||||
$statementsById = $sqls;
|
||||
} else {
|
||||
// Query consists of a single statement
|
||||
$multiMode = false;
|
||||
$statementsById = [ '*' => $sqls ];
|
||||
}
|
||||
|
||||
$this->assertHasConnectionHandle();
|
||||
|
||||
$hasPermWrite = false;
|
||||
$cStatementsById = [];
|
||||
$tempTableChangesByStatementId = [];
|
||||
foreach ( $statementsById as $statementId => $sql ) {
|
||||
if ( $this->isWriteQuery( $sql, $flags ) ) {
|
||||
$verb = $this->getQueryVerb( $sql );
|
||||
// Temporary table writes are not "meaningful" writes, since they are only
|
||||
// visible to one (ephermal) session, so treat them as reads instead. This
|
||||
// can be overriden during integration testing via $flags. For simplicity,
|
||||
// disallow CREATE/DROP statements during multi queries, avoiding the need
|
||||
// to speculatively track whether a table will be temporary at query time.
|
||||
if ( $multiMode && in_array( $verb, [ 'CREATE', 'DROP' ] ) ) {
|
||||
throw new DBUnexpectedError(
|
||||
$this,
|
||||
"Cannot issue CREATE/DROP as part of multi-queries"
|
||||
);
|
||||
}
|
||||
$pseudoPermanent = $this->fieldHasBit( $flags, self::QUERY_PSEUDO_PERMANENT );
|
||||
$tempTableChanges = $this->getTempTableWrites( $sql, $pseudoPermanent );
|
||||
$isPermWrite = !$tempTableChanges;
|
||||
foreach ( $tempTableChanges as list( $tmpType ) ) {
|
||||
$isPermWrite = $isPermWrite || ( $tmpType !== self::TEMP_NORMAL );
|
||||
}
|
||||
// Permit temporary table writes on replica connections, but require a writable
|
||||
// master connection for writes to persistent tables. Note that this
|
||||
if ( $isPermWrite ) {
|
||||
$this->assertIsWritablePrimary();
|
||||
// DBConnRef uses QUERY_REPLICA_ROLE to enforce replica roles during query()
|
||||
if ( $this->fieldHasBit( $flags, self::QUERY_REPLICA_ROLE ) ) {
|
||||
throw new DBReadOnlyRoleError(
|
||||
$this,
|
||||
"Cannot write; target role is DB_REPLICA"
|
||||
);
|
||||
}
|
||||
}
|
||||
$hasPermWrite = $hasPermWrite || $isPermWrite;
|
||||
} else {
|
||||
// No temporary tables written to either
|
||||
$tempTableChanges = [];
|
||||
}
|
||||
$tempTableChangesByStatementId[$statementId] = $tempTableChanges;
|
||||
// Add agent and calling method comments to the SQL
|
||||
$cStatementsById[$statementId] = $this->makeCommentedSql( $sql, $fname );
|
||||
}
|
||||
|
||||
// Add agent and calling method comments to the SQL
|
||||
$commentedSql = $this->makeCommentedSql( $sql, $fname );
|
||||
// Whether a silent retry attempt is left for recoverable connection loss errors
|
||||
$retryLeft = !$this->fieldHasBit( $flags, self::QUERY_NO_RETRY );
|
||||
$firstStatement = reset( $statementsById );
|
||||
|
||||
$cs = $this->commenceCriticalSection( __METHOD__ );
|
||||
|
||||
do {
|
||||
// Start a DBO_TRX wrapper transaction as needed (throw an error on failure)
|
||||
if ( $this->beginIfImplied( $sql, $fname, $flags ) ) {
|
||||
// Since begin() was called, any connection loss already handled
|
||||
if ( $this->beginIfImplied( $firstStatement, $fname, $flags ) ) {
|
||||
// Since begin() was called, any connection loss was already handled
|
||||
$retryLeft = false;
|
||||
}
|
||||
// Send the query to the server, fetching any results and corresponding errors
|
||||
// Send the query to the server, fetching any results and corresponding errors.
|
||||
// Silently retry the query if the error was just a recoverable connection loss.
|
||||
list( $ret, $err, $errno, $errflags ) = $this->executeQueryAttempt(
|
||||
$sql,
|
||||
$commentedSql,
|
||||
$isPermWrite,
|
||||
// Send the query statements to the server and fetch any results. Retry all the
|
||||
// statements if the error was a recoverable connection loss on the first statement.
|
||||
// To reduce the risk of running queries twice, do not retry the statements if there
|
||||
// is a connection error during any of the subsequent statements.
|
||||
$statusByStatementId = $this->attemptQuery(
|
||||
$statementsById,
|
||||
$cStatementsById,
|
||||
$fname,
|
||||
$flags
|
||||
$summarySql,
|
||||
$hasPermWrite,
|
||||
$multiMode
|
||||
);
|
||||
// Silently retry the query if the error was just a recoverable connection loss
|
||||
if ( !$retryLeft ) {
|
||||
break;
|
||||
}
|
||||
$retryLeft = false;
|
||||
} while ( $this->fieldHasBit( $errflags, self::ERR_RETRY_QUERY ) );
|
||||
} while (
|
||||
// Query had at least one statement
|
||||
( $firstQs = reset( $statusByStatementId ) ) &&
|
||||
// An error occurred that can be recovered from via query retry
|
||||
$this->fieldHasBit( $firstQs->flags, self::ERR_RETRY_QUERY ) &&
|
||||
// The retry has not been exhausted (consume it now)
|
||||
$retryLeft && !( $retryLeft = false )
|
||||
);
|
||||
|
||||
// Register creation and dropping of temporary tables
|
||||
$this->registerTempWrites( $ret, $tempTableChanges );
|
||||
foreach ( $statusByStatementId as $statementId => $qs ) {
|
||||
// Register creation and dropping of temporary tables
|
||||
$this->registerTempWrites( $qs->res, $tempTableChangesByStatementId[$statementId] );
|
||||
}
|
||||
|
||||
$this->completeCriticalSection( __METHOD__, $cs );
|
||||
|
||||
return [ $ret, $err, $errno, $errflags ];
|
||||
return $multiMode ? $statusByStatementId : $statusByStatementId['*'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for doQuery() that handles a single SQL statement query attempt
|
||||
* Query method wrapper handling profiling, logging, affected row count tracking, and
|
||||
* automatic reconnections (without retry) on query failure due to connection loss
|
||||
*
|
||||
* Note that this does not handle DBO_TRX logic.
|
||||
*
|
||||
* This method handles profiling, debug logging, reconnection and the tracking of:
|
||||
* - write callers
|
||||
* - last write time
|
||||
* - affected row count of the last write
|
||||
* - whether writes occurred in a transaction
|
||||
* - last successful query time (confirming that the connection was not dropped)
|
||||
*
|
||||
* This method does *not* handle DBO_TRX transaction logic *nor* query retries.
|
||||
* @see doSingleStatementQuery()
|
||||
* @see doMultiStatementQuery()
|
||||
*
|
||||
* @param string $sql Original SQL statement query
|
||||
* @param string $commentedSql SQL statement query with debugging/trace comment
|
||||
* @param bool $isPermWrite Whether the query is a (non-temporary table) write
|
||||
* @param string[] $statementsById Map of (statement ID => SQL statement)
|
||||
* @param string[] $cStatementsById Map of (statement ID => commented SQL statement)
|
||||
* @param string $fname Name of the calling function
|
||||
* @param int $flags Bit field of class QUERY_* constants
|
||||
* @return array An n-tuple of:
|
||||
* - mixed|bool: An object, resource, or true on success; false on failure
|
||||
* - string: The result of calling lastError()
|
||||
* - int: The result of calling lastErrno()
|
||||
* - int: Bit field of ERR_* class constants
|
||||
* @param string $summarySql Actual/simplified SQL for profiling
|
||||
* @param bool $hasPermWrite Whether any of the queries write to permanent tables
|
||||
* @param bool $multiMode Whether this is for an anomic statement batch
|
||||
* @return array<string,QueryStatus> Map of (statement ID => statement result)
|
||||
* @throws DBUnexpectedError
|
||||
*/
|
||||
private function executeQueryAttempt( $sql, $commentedSql, $isPermWrite, $fname, $flags ) {
|
||||
private function attemptQuery(
|
||||
array $statementsById,
|
||||
array $cStatementsById,
|
||||
string $fname,
|
||||
string $summarySql,
|
||||
bool $hasPermWrite,
|
||||
bool $multiMode
|
||||
) {
|
||||
// Treat empty multi-statement query lists as no query at all
|
||||
if ( !$statementsById ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Transaction attributes before issuing this query
|
||||
$priorSessInfo = $this->getCriticalSessionInfo();
|
||||
|
||||
// Keep track of whether the transaction has write queries pending
|
||||
if ( $isPermWrite ) {
|
||||
// Get the transaction-aware SQL string used for profiling
|
||||
$prefix = ( $this->topologyRole === self::ROLE_STREAMING_MASTER ) ? 'query-m: ' : 'query: ';
|
||||
$generalizedSql = new GeneralizedSql( $summarySql, $prefix );
|
||||
|
||||
$startTime = microtime( true );
|
||||
$ps = $this->profiler ? ( $this->profiler )( $generalizedSql->stringify() ) : null;
|
||||
$this->lastQuery = $summarySql;
|
||||
$this->affectedRowCount = null;
|
||||
if ( $hasPermWrite ) {
|
||||
$this->lastWriteTime = microtime( true );
|
||||
$this->transactionManager->transactionWritingIn(
|
||||
$this->getServerName(),
|
||||
|
|
@ -1355,95 +1479,109 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
|
|||
);
|
||||
}
|
||||
|
||||
$prefix = ( $this->topologyRole === self::ROLE_STREAMING_MASTER ) ? 'query-m: ' : 'query: ';
|
||||
$generalizedSql = new GeneralizedSql( $commentedSql, $prefix );
|
||||
if ( $multiMode ) {
|
||||
$qsByStatementId = $this->doMultiStatementQuery( $cStatementsById );
|
||||
} else {
|
||||
$qsByStatementId = [ '*' => $this->doSingleStatementQuery( $cStatementsById['*'] ) ];
|
||||
}
|
||||
|
||||
$startTime = microtime( true );
|
||||
$ps = $this->profiler ? ( $this->profiler )( $generalizedSql->stringify() ) : null;
|
||||
$this->affectedRowCount = null;
|
||||
$this->lastQuery = $sql;
|
||||
$ret = $this->doQuery( $commentedSql );
|
||||
$lastError = $this->lastError();
|
||||
$lastErrno = $this->lastErrno();
|
||||
$this->affectedRowCount = $this->affectedRows();
|
||||
unset( $ps ); // profile out (if set)
|
||||
$queryRuntime = max( microtime( true ) - $startTime, 0.0 );
|
||||
|
||||
$errflags = self::ERR_NONE;
|
||||
$lastStatus = end( $qsByStatementId );
|
||||
// Use the last affected row count for consistency with lastErrno()/lastError()
|
||||
$this->affectedRowCount = $lastStatus->rowsAffected;
|
||||
// Compute the total number of rows affected by all statements in the query
|
||||
$totalAffectedRowCount = 0;
|
||||
foreach ( $qsByStatementId as $qs ) {
|
||||
$totalAffectedRowCount += $qs->rowsAffected;
|
||||
}
|
||||
|
||||
if ( $ret !== false ) {
|
||||
if ( $lastStatus->res !== false ) {
|
||||
$this->lastPing = $startTime;
|
||||
// Keep track of whether the transaction has write queries pending
|
||||
if ( $isPermWrite && $this->trxLevel() ) {
|
||||
if ( $hasPermWrite && $this->trxLevel() ) {
|
||||
$this->transactionManager->updateTrxWriteQueryReport(
|
||||
$this->getQueryVerb( $sql ),
|
||||
$summarySql,
|
||||
$queryRuntime,
|
||||
$this->affectedRows(),
|
||||
$totalAffectedRowCount,
|
||||
$fname
|
||||
);
|
||||
}
|
||||
} elseif ( $this->isConnectionError( $lastErrno ) ) {
|
||||
// Connection lost before or during the query...
|
||||
// Determine how to proceed given the lost session state
|
||||
$connLossFlag = $this->assessConnectionLoss( $sql, $queryRuntime, $priorSessInfo );
|
||||
// Update session state tracking and try to reestablish a connection
|
||||
$reconnected = $this->replaceLostConnection( $lastErrno, __METHOD__ );
|
||||
// Check if important server-side session-level state was lost
|
||||
if ( $connLossFlag >= self::ERR_ABORT_SESSION ) {
|
||||
$sessionError = $this->getQueryException( $lastError, $lastErrno, $sql, $fname );
|
||||
$this->transactionManager->setSessionError( $sessionError );
|
||||
}
|
||||
// Check if important server-side transaction-level state was lost
|
||||
if ( $connLossFlag >= self::ERR_ABORT_TRX ) {
|
||||
$trxError = $this->getQueryException( $lastError, $lastErrno, $sql, $fname );
|
||||
$this->transactionManager->setTransactionError( $trxError );
|
||||
}
|
||||
// Check if the query should be retried (having made the reconnection attempt)
|
||||
if ( $connLossFlag === self::ERR_RETRY_QUERY ) {
|
||||
$errflags |= ( $reconnected ? self::ERR_RETRY_QUERY : self::ERR_ABORT_QUERY );
|
||||
} else {
|
||||
$errflags |= $connLossFlag;
|
||||
}
|
||||
} elseif ( $this->isKnownStatementRollbackError( $lastErrno ) ) {
|
||||
// Query error triggered a server-side statement-only rollback...
|
||||
$errflags |= self::ERR_ABORT_QUERY;
|
||||
if ( $this->trxLevel() ) {
|
||||
// Allow legacy callers to ignore such errors via QUERY_IGNORE_DBO_TRX and
|
||||
// try/catch. However, a deprecation notice will be logged on the next query.
|
||||
$cause = [ $lastError, $lastErrno, $fname ];
|
||||
$this->transactionManager->setTrxStatusIgnoredCause( $cause );
|
||||
}
|
||||
} else {
|
||||
// Some other error occurred during the query...
|
||||
if ( $this->trxLevel() ) {
|
||||
}
|
||||
|
||||
$errflags = 0;
|
||||
$numRowsReturned = 0;
|
||||
$numRowsAffected = 0;
|
||||
if ( !$multiMode && $lastStatus->res === false ) {
|
||||
$lastSql = end( $statementsById );
|
||||
$lastError = $lastStatus->message;
|
||||
$lastErrno = $lastStatus->code;
|
||||
if ( $this->isConnectionError( $lastErrno ) ) {
|
||||
// Connection lost before or during the query...
|
||||
// Determine how to proceed given the lost session state
|
||||
$connLossFlag = $this->assessConnectionLoss(
|
||||
$lastSql,
|
||||
$queryRuntime,
|
||||
$priorSessInfo
|
||||
);
|
||||
// Update session state tracking and try to reestablish a connection
|
||||
$reconnected = $this->replaceLostConnection( $lastErrno, __METHOD__ );
|
||||
// Check if important server-side session-level state was lost
|
||||
if ( $connLossFlag >= self::ERR_ABORT_SESSION ) {
|
||||
$ex = $this->getQueryException( $lastError, $lastErrno, $lastSql, $fname );
|
||||
$this->transactionManager->setSessionError( $ex );
|
||||
}
|
||||
// Check if important server-side transaction-level state was lost
|
||||
if ( $connLossFlag >= self::ERR_ABORT_TRX ) {
|
||||
$ex = $this->getQueryException( $lastError, $lastErrno, $lastSql, $fname );
|
||||
$this->transactionManager->setTransactionError( $ex );
|
||||
}
|
||||
// Check if the query should be retried (having made the reconnection attempt)
|
||||
if ( $connLossFlag === self::ERR_RETRY_QUERY ) {
|
||||
$errflags |= ( $reconnected ? self::ERR_RETRY_QUERY : self::ERR_ABORT_QUERY );
|
||||
} else {
|
||||
$errflags |= $connLossFlag;
|
||||
}
|
||||
} elseif ( $this->isKnownStatementRollbackError( $lastErrno ) ) {
|
||||
// Query error triggered a server-side statement-only rollback...
|
||||
$errflags |= self::ERR_ABORT_QUERY;
|
||||
if ( $this->trxLevel() ) {
|
||||
// Allow legacy callers to ignore such errors via QUERY_IGNORE_DBO_TRX and
|
||||
// try/catch. However, a deprecation notice will be logged on the next query.
|
||||
$cause = [ $lastError, $lastErrno, $fname ];
|
||||
$this->transactionManager->setTrxStatusIgnoredCause( $cause );
|
||||
}
|
||||
} elseif ( $this->trxLevel() ) {
|
||||
// Some other error occurred during the query, within a transaction...
|
||||
// Server-side handling of errors during transactions varies widely depending on
|
||||
// the RDBMS type and configuration. There are several possible results: (a) the
|
||||
// whole transaction is rolled back, (b) only the queries after BEGIN are rolled
|
||||
// back, (c) the transaction is marked as "aborted" and a ROLLBACK is required
|
||||
// before other queries are permitted. For compatibility reasons, pessimistically
|
||||
// require a ROLLBACK query (not using SAVEPOINT) before allowing other queries.
|
||||
$trxError = $this->getQueryException( $lastError, $lastErrno, $sql, $fname );
|
||||
$this->transactionManager->setTransactionError( $trxError );
|
||||
$ex = $this->getQueryException( $lastError, $lastErrno, $lastSql, $fname );
|
||||
$this->transactionManager->setTransactionError( $ex );
|
||||
$errflags |= self::ERR_ABORT_TRX;
|
||||
} else {
|
||||
// Some other error occurred during the query, without a transaction...
|
||||
$errflags |= self::ERR_ABORT_QUERY;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $sql === self::PING_QUERY ) {
|
||||
$this->lastRoundTripEstimate = $queryRuntime;
|
||||
foreach ( $qsByStatementId as $qs ) {
|
||||
$qs->flags = $errflags;
|
||||
$numRowsReturned += $qs->rowsReturned;
|
||||
$numRowsAffected += $qs->rowsAffected;
|
||||
}
|
||||
|
||||
$numRows = 0;
|
||||
if ( $ret instanceof IResultWrapper ) {
|
||||
$numRows = $ret->numRows();
|
||||
if ( !$multiMode && $statementsById['*'] === self::PING_QUERY ) {
|
||||
$this->lastRoundTripEstimate = $queryRuntime;
|
||||
}
|
||||
|
||||
$this->transactionManager->recordQueryCompletion(
|
||||
$generalizedSql,
|
||||
$startTime,
|
||||
$isPermWrite,
|
||||
$isPermWrite ? $this->affectedRows() : $numRows,
|
||||
$hasPermWrite,
|
||||
$hasPermWrite ? $numRowsAffected : $numRowsReturned,
|
||||
$this->getServerName()
|
||||
);
|
||||
|
||||
|
|
@ -1453,7 +1591,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
|
|||
"{method} [{runtime}s] {db_server}: {sql}",
|
||||
$this->getLogContext( [
|
||||
'method' => $fname,
|
||||
'sql' => $sql,
|
||||
'sql' => $summarySql,
|
||||
'domain' => $this->getDomainID(),
|
||||
'runtime' => round( $queryRuntime, 3 ),
|
||||
'db_log_category' => 'query'
|
||||
|
|
@ -1461,12 +1599,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
|
|||
);
|
||||
}
|
||||
|
||||
if ( !is_bool( $ret ) && $ret !== null && !( $ret instanceof IResultWrapper ) ) {
|
||||
throw new DBUnexpectedError( $this,
|
||||
static::class . '::doQuery() should return an IResultWrapper' );
|
||||
}
|
||||
|
||||
return [ $ret, $lastError, $lastErrno, $errflags ];
|
||||
return $qsByStatementId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1510,14 +1643,22 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
|
|||
}
|
||||
|
||||
/**
|
||||
* Error out if the DB is not in a valid state for a query via query()
|
||||
* Check if the given query is appropriate to run in a public context
|
||||
*
|
||||
* The caller is assumed to come from outside Database.
|
||||
* In order to keep the DB handle's session state tracking in sync, certain queries
|
||||
* like "USE", "BEGIN", "COMMIT", and "ROLLBACK" must not be issued directly from
|
||||
* outside callers. Such commands should only be issued through dedicated methods
|
||||
* like selectDomain(), begin(), commit(), and rollback(), respectively.
|
||||
*
|
||||
* This also checks if the session state tracking was corrupted by a prior exception.
|
||||
*
|
||||
* @param string $sql
|
||||
* @param string $fname
|
||||
* @throws DBUnexpectedError
|
||||
* @throws DBTransactionStateError
|
||||
*/
|
||||
private function assertQueryIsCurrentlyAllowed( $sql, $fname ) {
|
||||
private function assertQueryIsCurrentlyAllowed( string $sql, string $fname ) {
|
||||
$verb = $this->getQueryVerb( $sql );
|
||||
|
||||
if ( $verb === 'USE' ) {
|
||||
|
|
@ -1526,6 +1667,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
|
|||
|
||||
if ( $verb === 'ROLLBACK' ) {
|
||||
// Whole transaction rollback is used for recovery
|
||||
// @TODO: T269161; prevent "BEGIN"/"COMMIT"/"ROLLBACK" from outside callers
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1708,19 +1850,24 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
|
|||
}
|
||||
|
||||
/**
|
||||
* Report a query error. Log the error, and if neither the object ignore
|
||||
* flag nor the $ignoreErrors flag is set, throw a DBQueryError.
|
||||
* Report a query error
|
||||
*
|
||||
* If $ignore is set, emit a DEBUG level log entry and continue,
|
||||
* otherwise, emit an ERROR level log entry and throw an exception.
|
||||
*
|
||||
* @param string $error
|
||||
* @param int|string $errno
|
||||
* @param string $sql
|
||||
* @param string $fname
|
||||
* @param bool $ignore
|
||||
* @param bool $ignore Whether to just log an error rather than throw an exception
|
||||
* @throws DBQueryError
|
||||
*/
|
||||
public function reportQueryError( $error, $errno, $sql, $fname, $ignore = false ) {
|
||||
if ( $ignore ) {
|
||||
$this->queryLogger->debug( "SQL ERROR (ignored): $error", [ 'db_log_category' => 'query' ] );
|
||||
$this->queryLogger->debug(
|
||||
"SQL ERROR (ignored): $error",
|
||||
[ 'db_log_category' => 'query' ]
|
||||
);
|
||||
} else {
|
||||
throw $this->getQueryExceptionAndLog( $error, $errno, $sql, $fname );
|
||||
}
|
||||
|
|
@ -2709,7 +2856,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
|
|||
$sqlCondition = $this->makeKeyCollisionCondition( [ $row ], $identityKey );
|
||||
$this->delete( $table, [ $sqlCondition ], $fname );
|
||||
$affectedRowCount += $this->affectedRows();
|
||||
// Now insert the row
|
||||
// Insert the new row
|
||||
$this->insert( $table, $row, $fname );
|
||||
$affectedRowCount += $this->affectedRows();
|
||||
}
|
||||
|
|
@ -2727,8 +2874,9 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
|
|||
* @param array[] $rows Non-empty list of rows
|
||||
* @param string[] $uniqueKey List of columns that define a single unique index
|
||||
* @return string
|
||||
* @since 1.38
|
||||
*/
|
||||
private function makeKeyCollisionCondition( array $rows, array $uniqueKey ) {
|
||||
protected function makeKeyCollisionCondition( array $rows, array $uniqueKey ) {
|
||||
if ( !$rows ) {
|
||||
throw new DBUnexpectedError( $this, "Empty row array" );
|
||||
} elseif ( !$uniqueKey ) {
|
||||
|
|
@ -2812,11 +2960,12 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
|
|||
$affectedRowCount += $this->affectedRows();
|
||||
}
|
||||
}
|
||||
$this->endAtomic( $fname );
|
||||
} catch ( DBError $e ) {
|
||||
$this->cancelAtomic( $fname );
|
||||
throw $e;
|
||||
}
|
||||
$this->endAtomic( $fname );
|
||||
// Set the affected row count for the whole operation
|
||||
$this->affectedRowCount = $affectedRowCount;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -178,9 +178,9 @@ abstract class DatabaseMysqlBase extends Database {
|
|||
$flags = self::QUERY_NO_RETRY | self::QUERY_CHANGE_TRX;
|
||||
// Avoid using query() so that replaceLostConnection() does not throw
|
||||
// errors if the transaction status is STATUS_TRX_ERROR
|
||||
list( $ret, $err, $errno ) = $this->executeQuery( $sql, __METHOD__, $flags );
|
||||
if ( $ret === false ) {
|
||||
$this->reportQueryError( $err, $errno, $sql, __METHOD__ );
|
||||
$qs = $this->executeQuery( $sql, __METHOD__, $flags, $sql );
|
||||
if ( $qs->res === false ) {
|
||||
$this->reportQueryError( $qs->message, $qs->code, $sql, __METHOD__ );
|
||||
}
|
||||
}
|
||||
} catch ( RuntimeException $e ) {
|
||||
|
|
@ -210,12 +210,10 @@ abstract class DatabaseMysqlBase extends Database {
|
|||
}
|
||||
|
||||
if ( $database !== $this->getDBname() ) {
|
||||
$sql = 'USE ' . $this->platform->addIdentifierQuotes( $database );
|
||||
list( $res, $err, $errno ) =
|
||||
$this->executeQuery( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX );
|
||||
|
||||
if ( $res === false ) {
|
||||
$this->reportQueryError( $err, $errno, $sql, __METHOD__ );
|
||||
$sql = 'USE ' . $this->addIdentifierQuotes( $database );
|
||||
$qs = $this->executeQuery( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX, $sql );
|
||||
if ( $qs->res === false ) {
|
||||
$this->reportQueryError( $qs->message, $qs->code, $sql, __METHOD__ );
|
||||
return false; // unreachable
|
||||
}
|
||||
}
|
||||
|
|
@ -446,6 +444,8 @@ abstract class DatabaseMysqlBase extends Database {
|
|||
}
|
||||
|
||||
/**
|
||||
* Escape special characters in a string for use in an SQL statement
|
||||
*
|
||||
* @param string $s
|
||||
* @return mixed
|
||||
*/
|
||||
|
|
@ -1043,7 +1043,6 @@ abstract class DatabaseMysqlBase extends Database {
|
|||
|
||||
protected function doFlushSession( $fname ) {
|
||||
$flags = self::QUERY_CHANGE_LOCKS | self::QUERY_NO_RETRY;
|
||||
|
||||
// Note that RELEASE_ALL_LOCKS() is not supported well enough to use here.
|
||||
// https://mariadb.com/kb/en/release_all_locks/
|
||||
$releaseLockFields = [];
|
||||
|
|
@ -1053,9 +1052,9 @@ abstract class DatabaseMysqlBase extends Database {
|
|||
}
|
||||
if ( $releaseLockFields ) {
|
||||
$sql = 'SELECT ' . implode( ',', $releaseLockFields );
|
||||
list( $res, $err, $errno ) = $this->executeQuery( $sql, __METHOD__, $flags );
|
||||
if ( $res === false ) {
|
||||
$this->reportQueryError( $err, $errno, $sql, $fname, true );
|
||||
$qs = $this->executeQuery( $sql, __METHOD__, $flags, $sql );
|
||||
if ( $qs->res === false ) {
|
||||
$this->reportQueryError( $qs->message, $qs->code, $sql, $fname, true );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,16 +40,62 @@ use Wikimedia\IPUtils;
|
|||
* @see Database
|
||||
*/
|
||||
class DatabaseMysqli extends DatabaseMysqlBase {
|
||||
/**
|
||||
* @param string $sql
|
||||
* @return MysqliResultWrapper|bool
|
||||
*/
|
||||
protected function doQuery( $sql ) {
|
||||
protected function doSingleStatementQuery( string $sql ): QueryStatus {
|
||||
AtEase::suppressWarnings();
|
||||
$res = $this->getBindingHandle()->query( $sql );
|
||||
AtEase::restoreWarnings();
|
||||
|
||||
return $res instanceof mysqli_result ? new MysqliResultWrapper( $this, $res ) : $res;
|
||||
return new QueryStatus(
|
||||
$res instanceof mysqli_result ? new MysqliResultWrapper( $this, $res ) : $res,
|
||||
$this->affectedRows(),
|
||||
$this->lastError(),
|
||||
$this->lastErrno()
|
||||
);
|
||||
}
|
||||
|
||||
protected function doMultiStatementQuery( array $sqls ): array {
|
||||
$qsByStatementId = [];
|
||||
|
||||
$conn = $this->getBindingHandle();
|
||||
// Clear any previously left over result
|
||||
while ( $conn->more_results() && $conn->next_result() ) {
|
||||
$mysqliResult = $conn->store_result();
|
||||
$mysqliResult->free();
|
||||
}
|
||||
|
||||
$combinedSql = implode( ";\n", $sqls );
|
||||
$conn->multi_query( $combinedSql );
|
||||
|
||||
reset( $sqls );
|
||||
do {
|
||||
$mysqliResult = $conn->store_result();
|
||||
$statementId = key( $sqls );
|
||||
if ( $statementId !== null ) {
|
||||
// Database uses "true" for succesfull queries without result sets
|
||||
if ( $mysqliResult === false ) {
|
||||
$res = ( $conn->errno === 0 );
|
||||
} elseif ( $mysqliResult instanceof mysqli_result ) {
|
||||
$res = new MysqliResultWrapper( $this, $mysqliResult );
|
||||
} else {
|
||||
$res = $mysqliResult;
|
||||
}
|
||||
$qsByStatementId[$statementId] = new QueryStatus(
|
||||
$res,
|
||||
$conn->affected_rows,
|
||||
$conn->error,
|
||||
$conn->errno
|
||||
);
|
||||
next( $sqls );
|
||||
}
|
||||
// For error handling, continue regardless of next_result() returning false
|
||||
} while ( $conn->more_results() && ( $conn->next_result() || true ) );
|
||||
// Fill in status for statements aborted due to prior statement failure
|
||||
while ( ( $statementId = key( $sqls ) ) !== null ) {
|
||||
$qsByStatementId[$statementId] = new QueryStatus( false, 0, 'Query aborted', 0 );
|
||||
next( $sqls );
|
||||
}
|
||||
|
||||
return $qsByStatementId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -131,18 +177,12 @@ class DatabaseMysqli extends DatabaseMysqlBase {
|
|||
return $ok ? $mysqli : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
protected function closeConnection() {
|
||||
$conn = $this->getBindingHandle();
|
||||
|
||||
return $conn->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function insertId() {
|
||||
$conn = $this->getBindingHandle();
|
||||
|
||||
|
|
@ -154,15 +194,12 @@ class DatabaseMysqli extends DatabaseMysqlBase {
|
|||
*/
|
||||
public function lastErrno() {
|
||||
if ( $this->conn instanceof mysqli ) {
|
||||
return $this->conn->errno;
|
||||
return (int)$this->conn->errno;
|
||||
} else {
|
||||
return mysqli_connect_errno();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
protected function fetchAffectedRowCount() {
|
||||
$conn = $this->getBindingHandle();
|
||||
|
||||
|
|
@ -175,17 +212,12 @@ class DatabaseMysqli extends DatabaseMysqlBase {
|
|||
*/
|
||||
protected function mysqlError( $conn = null ) {
|
||||
if ( $conn === null ) {
|
||||
return mysqli_connect_error();
|
||||
return (string)mysqli_connect_error();
|
||||
} else {
|
||||
return $conn->error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes special characters in a string for use in an SQL statement
|
||||
* @param string $s
|
||||
* @return string
|
||||
*/
|
||||
protected function mysqlRealEscapeString( $s ) {
|
||||
$conn = $this->getBindingHandle();
|
||||
|
||||
|
|
|
|||
|
|
@ -193,28 +193,72 @@ class DatabasePostgres extends Database {
|
|||
!preg_match( '/^SELECT\s+pg_(try_|)advisory_\w+\(/', $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $sql
|
||||
* @return bool|IResultWrapper
|
||||
*/
|
||||
public function doQuery( $sql ) {
|
||||
public function doSingleStatementQuery( string $sql ): QueryStatus {
|
||||
$conn = $this->getBindingHandle();
|
||||
|
||||
$sql = mb_convert_encoding( $sql, 'UTF-8' );
|
||||
// Clear previously left over PQresult
|
||||
while ( $res = pg_get_result( $conn ) ) {
|
||||
pg_free_result( $res );
|
||||
// Clear any previously left over result
|
||||
while ( $priorRes = pg_get_result( $conn ) ) {
|
||||
pg_free_result( $priorRes );
|
||||
}
|
||||
|
||||
if ( pg_send_query( $conn, $sql ) === false ) {
|
||||
throw new DBUnexpectedError( $this, "Unable to post new query to PostgreSQL\n" );
|
||||
}
|
||||
$this->lastResultHandle = pg_get_result( $conn );
|
||||
if ( pg_result_error( $this->lastResultHandle ) ) {
|
||||
return false;
|
||||
|
||||
// Newer PHP versions use PgSql\Result instead of resource variables
|
||||
// https://www.php.net/manual/en/function.pg-get-result.php
|
||||
$pgRes = pg_get_result( $conn );
|
||||
$this->lastResultHandle = $pgRes;
|
||||
$res = pg_result_error( $pgRes ) ? false : $pgRes;
|
||||
|
||||
return new QueryStatus(
|
||||
is_bool( $res ) ? $res : new PostgresResultWrapper( $this, $conn, $res ),
|
||||
$this->affectedRows(),
|
||||
$this->lastError(),
|
||||
$this->lastErrno()
|
||||
);
|
||||
}
|
||||
|
||||
protected function doMultiStatementQuery( array $sqls ): array {
|
||||
$qsByStatementId = [];
|
||||
|
||||
$conn = $this->getBindingHandle();
|
||||
// Clear any previously left over result
|
||||
while ( $pgResultSet = pg_get_result( $conn ) ) {
|
||||
pg_free_result( $pgResultSet );
|
||||
}
|
||||
|
||||
return $this->lastResultHandle ?
|
||||
new PostgresResultWrapper( $this, $this->getBindingHandle(), $this->lastResultHandle ) : false;
|
||||
$combinedSql = mb_convert_encoding( implode( ";\n", $sqls ), 'UTF-8' );
|
||||
pg_send_query( $conn, $combinedSql );
|
||||
|
||||
reset( $sqls );
|
||||
while ( ( $pgResultSet = pg_get_result( $conn ) ) !== false ) {
|
||||
$this->lastResultHandle = $pgResultSet;
|
||||
|
||||
$statementId = key( $sqls );
|
||||
if ( $statementId !== null ) {
|
||||
if ( pg_result_error( $pgResultSet ) ) {
|
||||
$res = false;
|
||||
} else {
|
||||
$res = new PostgresResultWrapper( $this, $conn, $pgResultSet );
|
||||
}
|
||||
$qsByStatementId[$statementId] = new QueryStatus(
|
||||
$res,
|
||||
pg_affected_rows( $pgResultSet ),
|
||||
(string)pg_result_error( $pgResultSet ),
|
||||
pg_result_error_field( $pgResultSet, PGSQL_DIAG_SQLSTATE )
|
||||
);
|
||||
}
|
||||
next( $sqls );
|
||||
}
|
||||
// Fill in status for statements aborted due to prior statement failure
|
||||
while ( ( $statementId = key( $sqls ) ) !== null ) {
|
||||
$qsByStatementId[$statementId] = new QueryStatus( false, 0, 'Query aborted', 0 );
|
||||
next( $sqls );
|
||||
}
|
||||
|
||||
return $qsByStatementId;
|
||||
}
|
||||
|
||||
protected function dumpError() {
|
||||
|
|
@ -510,7 +554,6 @@ __INDEXATTR__;
|
|||
throw $e;
|
||||
}
|
||||
$this->endAtomic( "$fname (outer)" );
|
||||
// Set the affected row count for the whole operation
|
||||
$this->affectedRowCount = $affectedRowCount;
|
||||
}
|
||||
|
||||
|
|
@ -1264,9 +1307,9 @@ SQL;
|
|||
|
||||
// https://www.postgresql.org/docs/9.1/functions-admin.html
|
||||
$sql = "pg_advisory_unlock_all()";
|
||||
list( $res, $err, $errno ) = $this->executeQuery( $sql, __METHOD__, $flags );
|
||||
if ( $res === false ) {
|
||||
$this->reportQueryError( $err, $errno, $sql, $fname, true );
|
||||
$qs = $this->executeQuery( $sql, __METHOD__, $flags, $sql );
|
||||
if ( $qs->res === false ) {
|
||||
$this->reportQueryError( $qs->message, $qs->code, $sql, $fname, true );
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ use LockManager;
|
|||
use NullLockManager;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use PDOStatement;
|
||||
use RuntimeException;
|
||||
use Wikimedia\Rdbms\Platform\ISQLPlatform;
|
||||
use Wikimedia\Rdbms\Platform\SqlitePlatform;
|
||||
|
|
@ -379,18 +380,18 @@ class DatabaseSqlite extends Database {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $sql
|
||||
* @return IResultWrapper|bool
|
||||
*/
|
||||
protected function doQuery( $sql ) {
|
||||
$res = $this->getBindingHandle()->query( $sql );
|
||||
if ( $res === false ) {
|
||||
return false;
|
||||
}
|
||||
protected function doSingleStatementQuery( string $sql ): QueryStatus {
|
||||
$conn = $this->getBindingHandle();
|
||||
|
||||
$this->lastAffectedRowCount = $res->rowCount();
|
||||
return new SqliteResultWrapper( $res );
|
||||
$res = $conn->query( $sql );
|
||||
$this->lastAffectedRowCount = $res ? $res->rowCount() : 0;
|
||||
|
||||
return new QueryStatus(
|
||||
$res instanceof PDOStatement ? new SqliteResultWrapper( $res ) : $res,
|
||||
$res ? $res->rowCount() : 0,
|
||||
$this->lastError(),
|
||||
$this->lastErrno()
|
||||
);
|
||||
}
|
||||
|
||||
protected function doSelectDomain( DatabaseDomain $domain ) {
|
||||
|
|
|
|||
66
includes/libs/rdbms/database/utils/QueryStatus.php
Normal file
66
includes/libs/rdbms/database/utils/QueryStatus.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
/**
|
||||
* @defgroup Database Database
|
||||
*
|
||||
* This file deals with database interface functions
|
||||
* and query specifics/optimisations.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
* http://www.gnu.org/copyleft/gpl.html
|
||||
*
|
||||
* @file
|
||||
* @ingroup Database
|
||||
*/
|
||||
namespace Wikimedia\Rdbms;
|
||||
|
||||
/**
|
||||
* @internal This class should only be used by Database
|
||||
* @since 1.39
|
||||
*/
|
||||
class QueryStatus {
|
||||
/** @var ResultWrapper|bool|null Result set */
|
||||
public $res;
|
||||
/** @var int Returned row count */
|
||||
public $rowsReturned;
|
||||
/** @var int Affected row count */
|
||||
public $rowsAffected;
|
||||
/** @var string Error message or empty string */
|
||||
public $message;
|
||||
/** @var int|string Error code or zero */
|
||||
public $code;
|
||||
/** @var int Error flag bit field of Database::ERR_* constants */
|
||||
public $flags;
|
||||
|
||||
/**
|
||||
* @param ResultWrapper|bool|null $res
|
||||
* @param int $affected
|
||||
* @param string $error
|
||||
* @param int|string $errno
|
||||
*/
|
||||
public function __construct( $res, int $affected, string $error, $errno ) {
|
||||
if ( !( $res instanceof IResultWrapper ) && !is_bool( $res ) && $res !== null ) {
|
||||
throw new DBUnexpectedError(
|
||||
null,
|
||||
'Got ' . gettype( $res ) . ' instead of IResultWrapper|bool'
|
||||
);
|
||||
}
|
||||
$this->res = $res;
|
||||
$this->rowsReturned = ( $res instanceof IResultWrapper ) ? $res->numRows() : 0;
|
||||
$this->rowsAffected = $affected;
|
||||
$this->message = $error;
|
||||
$this->code = $errno;
|
||||
$this->flags = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -152,8 +152,13 @@ class RevisionStoreDbTest extends MediaWikiIntegrationTestCase {
|
|||
*/
|
||||
private function getDatabaseMock( array $params ) {
|
||||
$db = $this->getMockBuilder( DatabaseSqlite::class )
|
||||
->onlyMethods( [ 'select', 'doQuery', 'open', 'closeConnection', 'isOpen' ] )
|
||||
->setConstructorArgs( [ $params ] )
|
||||
->onlyMethods( [
|
||||
'select',
|
||||
'doSingleStatementQuery',
|
||||
'open',
|
||||
'closeConnection',
|
||||
'isOpen'
|
||||
] )->setConstructorArgs( [ $params ] )
|
||||
->getMock();
|
||||
|
||||
$db->method( 'select' )->willReturn( new FakeResultWrapper( [] ) );
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use Psr\Log\NullLogger;
|
|||
use Wikimedia\Rdbms\Database;
|
||||
use Wikimedia\Rdbms\DatabaseDomain;
|
||||
use Wikimedia\Rdbms\FakeResultWrapper;
|
||||
use Wikimedia\Rdbms\QueryStatus;
|
||||
use Wikimedia\Rdbms\TransactionProfiler;
|
||||
use Wikimedia\RequestTimeout\RequestTimeout;
|
||||
|
||||
|
|
@ -28,13 +29,11 @@ class DatabaseTestHelper extends Database {
|
|||
*/
|
||||
protected $lastSqls = [];
|
||||
|
||||
/** @var array List of row arrays */
|
||||
protected $nextResult = [];
|
||||
/** @var array Stack of result maps */
|
||||
protected $nextResMapQueue = [];
|
||||
|
||||
/** @var array|null */
|
||||
protected $nextError = null;
|
||||
/** @var array|null */
|
||||
protected $lastError = null;
|
||||
protected $lastResMap = null;
|
||||
|
||||
/**
|
||||
* @var string[] Array of tables to be considered as existing by tableExist()
|
||||
|
|
@ -100,19 +99,17 @@ class DatabaseTestHelper extends Database {
|
|||
|
||||
/**
|
||||
* @param mixed $res Use an array of row arrays to set row result
|
||||
*/
|
||||
public function forceNextResult( $res ) {
|
||||
$this->nextResult = $res;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $errno Error number
|
||||
* @param string $error Error text
|
||||
* @param array $options
|
||||
* - isKnownStatementRollbackError: Return value for isKnownStatementRollbackError()
|
||||
*/
|
||||
public function forceNextQueryError( $errno, $error, $options = [] ) {
|
||||
$this->nextError = [ 'errno' => $errno, 'error' => $error ] + $options;
|
||||
public function forceNextResult( $res, $errno = 0, $error = '', $options = [] ) {
|
||||
$this->nextResMapQueue[] = [
|
||||
'res' => $res,
|
||||
'errno' => $errno,
|
||||
'error' => $error
|
||||
] + $options;
|
||||
}
|
||||
|
||||
protected function addSql( $sql ) {
|
||||
|
|
@ -182,16 +179,16 @@ class DatabaseTestHelper extends Database {
|
|||
}
|
||||
|
||||
public function lastErrno() {
|
||||
return $this->lastError ? $this->lastError['errno'] : -1;
|
||||
return $this->lastResMap ? $this->lastResMap['errno'] : -1;
|
||||
}
|
||||
|
||||
public function lastError() {
|
||||
return $this->lastError ? $this->lastError['error'] : 'test';
|
||||
return $this->lastResMap ? $this->lastResMap['error'] : 'test';
|
||||
}
|
||||
|
||||
protected function isKnownStatementRollbackError( $errno ) {
|
||||
return ( $this->lastError['errno'] ?? 0 ) === $errno
|
||||
? ( $this->lastError['isKnownStatementRollbackError'] ?? false )
|
||||
return ( $this->lastResMap['errno'] ?? 0 ) === $errno
|
||||
? ( $this->lastResMap['isKnownStatementRollbackError'] ?? false )
|
||||
: false;
|
||||
}
|
||||
|
||||
|
|
@ -232,24 +229,25 @@ class DatabaseTestHelper extends Database {
|
|||
$this->forcedAffectedCountQueue = $counts;
|
||||
}
|
||||
|
||||
protected function doQuery( $sql ) {
|
||||
protected function doSingleStatementQuery( string $sql ): QueryStatus {
|
||||
$sql = preg_replace( '< /\* .+? \*/>', '', $sql );
|
||||
$this->addSql( $sql );
|
||||
|
||||
if ( $this->nextError ) {
|
||||
$this->lastError = $this->nextError;
|
||||
$this->nextError = null;
|
||||
return false;
|
||||
if ( $this->nextResMapQueue ) {
|
||||
$this->lastResMap = array_shift( $this->nextResMapQueue );
|
||||
if ( !$this->lastResMap['errno'] && $this->forcedAffectedCountQueue ) {
|
||||
$this->affectedRowCount = array_shift( $this->forcedAffectedCountQueue );
|
||||
}
|
||||
} else {
|
||||
$this->lastResMap = [ 'res' => [], 'errno' => 0, 'error' => '' ];
|
||||
}
|
||||
$res = $this->lastResMap['res'];
|
||||
|
||||
$res = $this->nextResult;
|
||||
$this->nextResult = [];
|
||||
$this->lastError = null;
|
||||
|
||||
if ( $this->forcedAffectedCountQueue ) {
|
||||
$this->affectedRowCount = array_shift( $this->forcedAffectedCountQueue );
|
||||
}
|
||||
|
||||
return new FakeResultWrapper( $res );
|
||||
return new QueryStatus(
|
||||
is_bool( $res ) ? $res : new FakeResultWrapper( $res ),
|
||||
$this->affectedRows(),
|
||||
$this->lastError(),
|
||||
$this->lastErrno()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -240,4 +240,187 @@ class DatabaseMysqlTest extends \MediaWikiIntegrationTestCase {
|
|||
|
||||
return $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideQueryMulti
|
||||
* @covers Wikimedia\Rdbms\Database::queryMulti
|
||||
*/
|
||||
public function testQueryMulti(
|
||||
array $sqls,
|
||||
int $flags,
|
||||
string $summarySql,
|
||||
?array $resMap,
|
||||
?array $exception
|
||||
) {
|
||||
$row = $this->conn->query( "SELECT 3 as test", __METHOD__ )->fetchObject();
|
||||
$this->assertNotFalse( $row );
|
||||
$this->assertSame( '3', $row->test );
|
||||
|
||||
if ( is_array( $resMap ) ) {
|
||||
$qsMap = $this->conn->queryMulti( $sqls, __METHOD__, $flags, $summarySql );
|
||||
|
||||
reset( $resMap );
|
||||
foreach ( $qsMap as $qs ) {
|
||||
if ( is_iterable( $qs->res ) ) {
|
||||
$this->assertArrayEquals( current( $resMap ), iterator_to_array( $qs->res ) );
|
||||
} else {
|
||||
$this->assertSame( current( $resMap ), $qs->res );
|
||||
}
|
||||
next( $resMap );
|
||||
}
|
||||
} else {
|
||||
[ $class, $message, $code ] = $exception;
|
||||
|
||||
try {
|
||||
$this->conn->queryMulti( $sqls, __METHOD__, $flags, $summarySql );
|
||||
$this->fail( "No DBError thrown" );
|
||||
} catch ( DBError $e ) {
|
||||
$this->assertInstanceOf( $class, $e );
|
||||
$this->assertStringContainsString( $message, $e->getMessage() );
|
||||
$this->assertSame( $code, $e->errno );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function provideQueryMulti() {
|
||||
return [
|
||||
[
|
||||
[
|
||||
'SELECT 1 AS v, 2 AS x',
|
||||
'(SELECT 1 AS v) UNION ALL (SELECT 2 AS v) UNION ALL (SELECT 3 AS v)',
|
||||
'SELECT IS_FREE_LOCK("unused_lock") AS free',
|
||||
'SELECT RELEASE_LOCK("unused_lock") AS released'
|
||||
],
|
||||
Database::QUERY_IGNORE_DBO_TRX,
|
||||
'COMPOSITE QUERY 1',
|
||||
[
|
||||
[
|
||||
(object)[ 'v' => '1', 'x' => '2' ]
|
||||
],
|
||||
[
|
||||
(object)[ 'v' => '1' ],
|
||||
(object)[ 'v' => '2' ],
|
||||
(object)[ 'v' => '3' ]
|
||||
],
|
||||
[
|
||||
(object)[ 'free' => '1' ]
|
||||
],
|
||||
[
|
||||
(object)[ 'released' => null ]
|
||||
]
|
||||
],
|
||||
null
|
||||
],
|
||||
[
|
||||
[
|
||||
'SELECT 1 AS v, 2 AS x',
|
||||
'SELECT UNION PARSE_ERROR ()',
|
||||
'SELECT IS_FREE_LOCK("unused_lock") AS free',
|
||||
'SELECT RELEASE_LOCK("unused_lock") AS released'
|
||||
],
|
||||
0,
|
||||
'COMPOSITE QUERY 2',
|
||||
null,
|
||||
[ DBQueryError::class, 'You have an error in your SQL syntax', 1064 ]
|
||||
],
|
||||
[
|
||||
[
|
||||
'SELECT UNION PARSE_ERROR ()',
|
||||
'SELECT 1 AS v, 2 AS x',
|
||||
'SELECT IS_FREE_LOCK("unused_lock") AS free',
|
||||
'SELECT RELEASE_LOCK("unused_lock") AS released'
|
||||
],
|
||||
0,
|
||||
'COMPOSITE QUERY 3',
|
||||
null,
|
||||
[ DBQueryError::class, 'You have an error in your SQL syntax', 1064 ]
|
||||
],
|
||||
[
|
||||
[
|
||||
'SELECT 1 AS v, 2 AS x',
|
||||
'SELECT IS_FREE_LOCK("unused_lock") AS free',
|
||||
'SELECT RELEASE_LOCK("unused_lock") AS released',
|
||||
'SELECT UNION PARSE_ERROR ()',
|
||||
],
|
||||
0,
|
||||
'COMPOSITE QUERY 4',
|
||||
null,
|
||||
[ DBQueryError::class, 'You have an error in your SQL syntax', 1064 ]
|
||||
],
|
||||
[
|
||||
[],
|
||||
0,
|
||||
'COMPOSITE QUERY 5',
|
||||
[],
|
||||
null
|
||||
],
|
||||
[
|
||||
[
|
||||
'SELECT 1 AS v, 2 AS x',
|
||||
'SELECT UNION PARSE_ERROR ()',
|
||||
'SELECT IS_FREE_LOCK("unused_lock") AS free',
|
||||
'SELECT RELEASE_LOCK("unused_lock") AS released'
|
||||
],
|
||||
Database::QUERY_SILENCE_ERRORS,
|
||||
'COMPOSITE QUERY 2I',
|
||||
[
|
||||
[
|
||||
(object)[ 'v' => '1', 'x' => '2' ]
|
||||
],
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
null
|
||||
],
|
||||
[
|
||||
[
|
||||
'SELECT UNION PARSE_ERROR IGNORE ()',
|
||||
'SELECT 1 AS v, 2 AS x',
|
||||
'SELECT IS_FREE_LOCK("unused_lock") AS free',
|
||||
'SELECT RELEASE_LOCK("unused_lock") AS released'
|
||||
],
|
||||
Database::QUERY_SILENCE_ERRORS,
|
||||
'COMPOSITE QUERY 3I',
|
||||
[
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
null
|
||||
],
|
||||
[
|
||||
[
|
||||
'SELECT 1 AS v, 2 AS x',
|
||||
'SELECT IS_FREE_LOCK("unused_lock") AS free',
|
||||
'SELECT RELEASE_LOCK("unused_lock") AS released',
|
||||
'SELECT UNION PARSE_ERROR ()',
|
||||
],
|
||||
Database::QUERY_SILENCE_ERRORS,
|
||||
'COMPOSITE QUERY 4I',
|
||||
[
|
||||
[
|
||||
(object)[ 'v' => '1', 'x' => '2' ]
|
||||
],
|
||||
[
|
||||
(object)[ 'free' => '1' ]
|
||||
],
|
||||
[
|
||||
(object)[ 'released' => null ]
|
||||
],
|
||||
false
|
||||
],
|
||||
null
|
||||
],
|
||||
[
|
||||
[],
|
||||
Database::QUERY_SILENCE_ERRORS,
|
||||
'COMPOSITE QUERY 5I',
|
||||
[],
|
||||
null
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,45 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
|
|||
$this->assertEquals( $sqlText, $db->getLastSqls() );
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideQueryMulti
|
||||
* @covers Wikimedia\Rdbms\Database::queryMulti
|
||||
*/
|
||||
public function testQueryMulti( array $sqls, string $summarySql, array $resTriples ) {
|
||||
$lastSql = null;
|
||||
reset( $sqls );
|
||||
foreach ( $resTriples as [ $res, $errno, $error ] ) {
|
||||
$this->database->forceNextResult( $res, $errno, $error );
|
||||
if ( $lastSql !== null && $errno ) {
|
||||
$lastSql = current( $sqls );
|
||||
}
|
||||
next( $sqls );
|
||||
}
|
||||
$lastSql = $lastSql ?? end( $sqls );
|
||||
$this->database->queryMulti( $sqls, __METHOD__, 0, $summarySql );
|
||||
$this->assertLastSql( implode( '; ', $sqls ) );
|
||||
}
|
||||
|
||||
public static function provideQueryMulti() {
|
||||
return [
|
||||
[
|
||||
[
|
||||
'SELECT 1 AS v',
|
||||
'UPDATE page SET page_size=0 WHERE page_id=42',
|
||||
'DELETE FROM page WHERE page_id=999',
|
||||
'SELECT page_id FROM page LIMIT 3'
|
||||
],
|
||||
'COMPOSITE page QUERY',
|
||||
[
|
||||
[ [ [ 'v' => 1 ] ], 0, '' ],
|
||||
[ true, 0, '' ],
|
||||
[ true, 0, '' ],
|
||||
[ [ [ 'page_id' => 42 ], [ 'page_id' => 1 ], [ 'page_id' => 11 ] ], 0, '' ]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideSelect
|
||||
* @covers Wikimedia\Rdbms\Database::select
|
||||
|
|
@ -2321,7 +2360,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
|
|||
*/
|
||||
public function testImplicitTransactionRollback() {
|
||||
$doError = function () {
|
||||
$this->database->forceNextQueryError( 666, 'Evilness' );
|
||||
$this->database->forceNextResult( false, 666, 'Evilness' );
|
||||
try {
|
||||
$this->database->delete( 'error', '1', __CLASS__ . '::SomeCaller' );
|
||||
$this->fail( 'Expected exception not thrown' );
|
||||
|
|
@ -2381,9 +2420,12 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
|
|||
};
|
||||
|
||||
$doError = function () {
|
||||
$this->database->forceNextQueryError( 666, 'Evilness', [
|
||||
'isKnownStatementRollbackError' => true,
|
||||
] );
|
||||
$this->database->forceNextResult(
|
||||
false,
|
||||
666,
|
||||
'Evilness',
|
||||
[ 'isKnownStatementRollbackError' => true ]
|
||||
);
|
||||
try {
|
||||
$this->database->delete( 'error', '1', __CLASS__ . '::SomeCaller' );
|
||||
$this->fail( 'Expected exception not thrown' );
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use Wikimedia\Rdbms\IDatabase;
|
|||
use Wikimedia\Rdbms\IResultWrapper;
|
||||
use Wikimedia\Rdbms\LBFactorySingle;
|
||||
use Wikimedia\Rdbms\Platform\SQLPlatform;
|
||||
use Wikimedia\Rdbms\QueryStatus;
|
||||
use Wikimedia\Rdbms\TransactionManager;
|
||||
use Wikimedia\RequestTimeout\CriticalSectionScope;
|
||||
use Wikimedia\TestingAccessWrapper;
|
||||
|
|
@ -452,7 +453,7 @@ class DatabaseTest extends PHPUnit\Framework\TestCase {
|
|||
static $abstractMethods = [
|
||||
'fetchAffectedRowCount',
|
||||
'closeConnection',
|
||||
'doQuery',
|
||||
'doSingleStatementQuery',
|
||||
'fieldInfo',
|
||||
'getSoftwareLink',
|
||||
'getServerVersion',
|
||||
|
|
@ -483,10 +484,19 @@ class DatabaseTest extends PHPUnit\Framework\TestCase {
|
|||
};
|
||||
$wdb->currentDomain = DatabaseDomain::newUnspecified();
|
||||
$wdb->platform = new SQLPlatform( new AddQuoterMock() );
|
||||
// Info used for logging/errors
|
||||
$wdb->connectionParams = [
|
||||
'host' => 'localhost',
|
||||
'user' => 'testuser'
|
||||
];
|
||||
|
||||
$db->method( 'getServer' )->willReturn( '*dummy*' );
|
||||
$db->setTransactionManager( new TransactionManager() );
|
||||
|
||||
$qs = new QueryStatus( false, 0, '', 0 );
|
||||
$qs->res = true;
|
||||
$db->method( 'doSingleStatementQuery' )->willReturn( $qs );
|
||||
|
||||
return $db;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue