's1']. By default, the row used is the newest * row having a server_id matching that of the immediate replication source server * for the given replica. * - useGTIDs : use GTID methods like MASTER_GTID_WAIT() when possible. * - sslKeyPath : path to key file [default: null] * - sslCertPath : path to certificate file [default: null] * - sslCAFile: path to a single certificate authority PEM file [default: null] * - sslCAPath : parth to certificate authority PEM directory [default: null] * - sslCiphers : array list of allowable ciphers [default: null] * @param array $params */ public function __construct( array $params ) { foreach ( [ 'KeyPath', 'CertPath', 'CAFile', 'CAPath', 'Ciphers' ] as $name ) { $var = "ssl{$name}"; if ( isset( $params[$var] ) ) { $this->$var = $params[$var]; } } $this->utf8Mode = !empty( $params['utf8Mode'] ); parent::__construct( $params ); $this->platform = new MySQLPlatform( $this, $this->logger, $this->currentDomain, $this->errorLogger ); $this->replicationReporter = new MysqlReplicationReporter( $params['topologyRole'], $this->logger, $params['srvCache'], $params['lagDetectionMethod'] ?? 'Seconds_Behind_Master', $params['lagDetectionOptions'] ?? [], !empty( $params['useGTIDs' ] ) ); } /** * @return string */ public function getType() { return 'mysql'; } protected function open( $server, $user, $password, $db, $schema, $tablePrefix ) { $this->close( __METHOD__ ); if ( $schema !== null ) { throw $this->newExceptionAfterConnectError( "Got schema '$schema'; not supported." ); } $this->installErrorHandler(); try { $this->conn = $this->mysqlConnect( $server, $user, $password, $db ); } catch ( RuntimeException $e ) { $this->restoreErrorHandler(); throw $this->newExceptionAfterConnectError( $e->getMessage() ); } $error = $this->restoreErrorHandler(); if ( !$this->conn ) { throw $this->newExceptionAfterConnectError( $error ?: $this->lastError() ); } try { $this->currentDomain = new DatabaseDomain( ( $db !== '' ) ? $db : null, null, $tablePrefix ); $this->platform->setCurrentDomain( $this->currentDomain ); $set = []; if ( !$this->flagsHolder->getFlag( self::DBO_GAUGE ) ) { // Abstract over any excessive MySQL defaults $set[] = 'group_concat_max_len = 262144'; // Set any custom settings defined by site config // https://dev.mysql.com/doc/refman/5.7/en/innodb-parameters.html foreach ( $this->connectionVariables as $var => $val ) { // Escape strings but not numbers to avoid MySQL complaining if ( !is_int( $val ) && !is_float( $val ) ) { $val = $this->addQuotes( $val ); } $set[] = $this->platform->addIdentifierQuotes( $var ) . ' = ' . $val; } } if ( $set ) { $sql = 'SET ' . implode( ', ', $set ); $flags = self::QUERY_NO_RETRY | self::QUERY_CHANGE_TRX; $query = new Query( $sql, $flags, 'SET' ); // Avoid using query() so that replaceLostConnection() does not throw // errors if the transaction status is STATUS_TRX_ERROR $qs = $this->executeQuery( $query, __METHOD__, $flags ); if ( $qs->res === false ) { $this->reportQueryError( $qs->message, $qs->code, $sql, __METHOD__ ); } } } catch ( RuntimeException $e ) { throw $this->newExceptionAfterConnectError( $e->getMessage() ); } } protected function doSelectDomain( DatabaseDomain $domain ) { if ( $domain->getSchema() !== null ) { throw new DBExpectedError( $this, __CLASS__ . ": domain '{$domain->getId()}' has a schema component" ); } $database = $domain->getDatabase(); // A null database means "don't care" so leave it as is and update the table prefix if ( $database === null ) { $this->currentDomain = new DatabaseDomain( $this->currentDomain->getDatabase(), null, $domain->getTablePrefix() ); $this->platform->setCurrentDomain( $this->currentDomain ); return true; } if ( $database !== $this->getDBname() ) { $sql = 'USE ' . $this->addIdentifierQuotes( $database ); $query = new Query( $sql, self::QUERY_CHANGE_TRX, 'USE' ); $qs = $this->executeQuery( $query, __METHOD__, self::QUERY_CHANGE_TRX ); if ( $qs->res === false ) { $this->reportQueryError( $qs->message, $qs->code, $sql, __METHOD__ ); return false; // unreachable } } // Update that domain fields on success (no exception thrown) $this->currentDomain = $domain; $this->platform->setCurrentDomain( $domain ); return true; } /** * @return string */ public function lastError() { if ( $this->conn ) { // Even if it's non-zero, it can still be invalid $error = $this->mysqlError( $this->conn ); if ( !$error ) { $error = $this->mysqlError(); } } else { $error = $this->mysqlError() ?: $this->lastConnectError; } return $error; } protected function isInsertSelectSafe( array $insertOptions, array $selectOptions, $fname ) { $row = $this->replicationReporter->getReplicationSafetyInfo( $this, $fname ); // For row-based-replication, the resulting changes will be relayed, not the query if ( $row->binlog_format === 'ROW' ) { return true; } // LIMIT requires ORDER BY on a unique key or it is non-deterministic if ( isset( $selectOptions['LIMIT'] ) ) { return false; } // In MySQL, an INSERT SELECT is only replication safe with row-based // replication or if innodb_autoinc_lock_mode is 0. When those // conditions aren't met, use non-native mode. // While we could try to determine if the insert is safe anyway by // checking if the target table has an auto-increment column that // isn't set in $varMap, that seems unlikely to be worth the extra // complexity. return ( in_array( 'NO_AUTO_COLUMNS', $insertOptions ) || (int)$row->innodb_autoinc_lock_mode === 0 ); } protected function checkInsertWarnings( Query $query, $fname ) { if ( $this->conn && $this->conn->warning_count ) { // Yeah it's weird. It's not iterable. $warnings = $this->conn->get_warnings(); $done = $warnings === false; while ( !$done ) { if ( in_array( $warnings->errno, [ // List based on https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#ignore-effect-on-execution 1048, /* ER_BAD_NULL_ERROR */ 1526, /* ER_NO_PARTITION_FOR_GIVEN_VALUE */ 1748, /* ER_ROW_DOES_NOT_MATCH_GIVEN_PARTITION_SET */ 1242, /* ER_SUBQUERY_NO_1_ROW */ 1369, /* ER_VIEW_CHECK_FAILED */ // Truncation and overflow per T108255 1264, /* ER_WARN_DATA_OUT_OF_RANGE */ 1265, /* WARN_DATA_TRUNCATED */ ] ) ) { $this->reportQueryError( 'Insert returned unacceptable warning: ' . $warnings->message, $warnings->errno, $query->getSQL(), $fname ); } $done = !$warnings->next(); } } } public function estimateRowCount( $tables, $var = '*', $conds = '', $fname = __METHOD__, $options = [], $join_conds = [] ): int { $conds = $this->platform->normalizeConditions( $conds, $fname ); $column = $this->platform->extractSingleFieldFromList( $var ); if ( is_string( $column ) && !in_array( $column, [ '*', '1' ] ) ) { $conds[] = "$column IS NOT NULL"; } $options['EXPLAIN'] = true; $res = $this->select( $tables, $var, $conds, $fname, $options, $join_conds ); if ( $res === false ) { return -1; } if ( !$res->numRows() ) { return 0; } $rows = 1; foreach ( $res as $plan ) { $rows *= $plan->rows > 0 ? $plan->rows : 1; // avoid resetting to zero } return (int)$rows; } public function tableExists( $table, $fname = __METHOD__ ) { [ $db, $pt ] = $this->platform->getDatabaseAndTableIdentifier( $table ); if ( isset( $this->sessionTempTables[$db][$pt] ) ) { return true; // already known to exist and won't be found in the query anyway } return (bool)$this->newSelectQueryBuilder() ->select( '1' ) ->from( 'information_schema.tables' ) ->where( [ 'table_schema' => $db, 'table_name' => $pt, ] ) ->caller( $fname ) ->fetchField(); } /** * @param string $table * @param string $field * @return MySQLField|false */ public function fieldInfo( $table, $field ) { $query = new Query( "SELECT * FROM " . $this->tableName( $table ) . " LIMIT 1", self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE, 'SELECT' ); $res = $this->query( $query, __METHOD__ ); if ( !$res ) { return false; } /** @var MysqliResultWrapper $res */ '@phan-var MysqliResultWrapper $res'; return $res->getInternalFieldInfo( $field ); } public function indexInfo( $table, $index, $fname = __METHOD__ ) { # https://dev.mysql.com/doc/mysql/en/SHOW_INDEX.html $index = $this->platform->indexName( $index ); $query = new Query( 'SHOW INDEX FROM ' . $this->tableName( $table ), self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE, 'SHOW' ); $res = $this->query( $query, $fname ); foreach ( $res as $row ) { if ( $row->Key_name === $index ) { return [ 'unique' => !$row->Non_unique ]; } } return false; } /** * @param string $s * @return string */ public function strencode( $s ) { return $this->mysqlRealEscapeString( $s ); } public function serverIsReadOnly() { // Avoid SHOW to avoid internal temporary tables $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE; $query = new Query( "SELECT @@GLOBAL.read_only AS Value", $flags, 'SELECT' ); $res = $this->query( $query, __METHOD__ ); $row = $res->fetchObject(); return $row && (bool)$row->Value; } /** * @return string */ public function getSoftwareLink() { [ $variant ] = $this->getMySqlServerVariant(); if ( $variant === 'MariaDB' ) { return '[{{int:version-db-mariadb-url}} MariaDB]'; } return '[{{int:version-db-mysql-url}} MySQL]'; } /** * @return string[] (one of ("MariaDB","MySQL"), x.y.z version string) */ private function getMySqlServerVariant() { $version = $this->getServerVersion(); // MariaDB includes its name in its version string; this is how MariaDB's version of // the mysql command-line client identifies MariaDB servers. // https://dev.mysql.com/doc/refman/8.0/en/information-functions.html#function_version // https://mariadb.com/kb/en/version/ $parts = explode( '-', $version, 2 ); $number = $parts[0]; $suffix = $parts[1] ?? ''; if ( strpos( $suffix, 'MariaDB' ) !== false || strpos( $suffix, '-maria-' ) !== false ) { $vendor = 'MariaDB'; } else { $vendor = 'MySQL'; } return [ $vendor, $number ]; } /** * @return string */ public function getServerVersion() { // MariaDB 10 adds the prefix "5.5.5-", and only some newer client libraries strip // it off (see RPL_VERSION_HACK in include/mysql_com.h). $version = $this->conn->server_info; if ( str_starts_with( $version, '5.5.5-' ) && ( str_contains( $version, 'MariaDB' ) || str_contains( $version, '-maria-' ) ) ) { $version = substr( $version, strlen( '5.5.5-' ) ); } return $version; } public function setSessionOptions( array $options ) { $sqlAssignments = []; if ( isset( $options['connTimeout'] ) ) { $encTimeout = (int)$options['connTimeout']; $sqlAssignments[] = "net_read_timeout=$encTimeout"; $sqlAssignments[] = "net_write_timeout=$encTimeout"; } if ( isset( $options['groupConcatMaxLen'] ) ) { $maxLength = (int)$options['groupConcatMaxLen']; $sqlAssignments[] = "group_concat_max_len=$maxLength"; } if ( $sqlAssignments ) { $query = new Query( 'SET ' . implode( ', ', $sqlAssignments ), self::QUERY_CHANGE_TRX | self::QUERY_CHANGE_NONE, 'SET' ); $this->query( $query, __METHOD__ ); } } /** * @param string &$sql * @param string &$newLine * @return bool */ public function streamStatementEnd( &$sql, &$newLine ) { if ( preg_match( '/^DELIMITER\s+(\S+)/i', $newLine, $m ) ) { $this->delimiter = $m[1]; $newLine = ''; } return parent::streamStatementEnd( $sql, $newLine ); } public function doLockIsFree( string $lockName, string $method ) { $query = new Query( $this->platform->lockIsFreeSQLText( $lockName ), self::QUERY_CHANGE_LOCKS, 'SELECT' ); $res = $this->query( $query, $method ); $row = $res->fetchObject(); return ( $row->unlocked == 1 ); } public function doLock( string $lockName, string $method, int $timeout ) { $query = new Query( $this->platform->lockSQLText( $lockName, $timeout ), self::QUERY_CHANGE_LOCKS, 'SELECT' ); $res = $this->query( $query, $method ); $row = $res->fetchObject(); return ( $row->acquired !== null ) ? (float)$row->acquired : null; } public function doUnlock( string $lockName, string $method ) { $query = new Query( $this->platform->unlockSQLText( $lockName ), self::QUERY_CHANGE_LOCKS, 'SELECT' ); $res = $this->query( $query, $method ); $row = $res->fetchObject(); return ( $row->released == 1 ); } protected function doFlushSession( $fname ) { // Note that RELEASE_ALL_LOCKS() is not supported well enough to use here. // https://mariadb.com/kb/en/release_all_locks/ $releaseLockFields = []; foreach ( $this->sessionNamedLocks as $name => $info ) { $encName = $this->addQuotes( $this->platform->makeLockName( $name ) ); $releaseLockFields[] = "RELEASE_LOCK($encName)"; } if ( $releaseLockFields ) { $sql = 'SELECT ' . implode( ',', $releaseLockFields ); $flags = self::QUERY_CHANGE_LOCKS | self::QUERY_NO_RETRY; $query = new Query( $sql, $flags, 'SELECT' ); $qs = $this->executeQuery( $query, __METHOD__, $flags ); if ( $qs->res === false ) { $this->reportQueryError( $qs->message, $qs->code, $sql, $fname, true ); } } } public function upsert( $table, array $rows, $uniqueKeys, array $set, $fname = __METHOD__ ) { $identityKey = $this->platform->normalizeUpsertParams( $uniqueKeys, $rows ); if ( !$rows ) { return; } $this->platform->assertValidUpsertSetArray( $set, $identityKey, $rows ); $encTable = $this->tableName( $table ); [ $sqlColumns, $sqlTuples ] = $this->platform->makeInsertLists( $rows ); $sqlColumnAssignments = $this->makeList( $set, self::LIST_SET ); // No need to expose __NEW.* since buildExcludedValue() uses VALUES(column) // https://mariadb.com/kb/en/insert-on-duplicate-key-update/ // https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html $sql = "INSERT INTO $encTable " . "($sqlColumns) VALUES $sqlTuples " . "ON DUPLICATE KEY UPDATE $sqlColumnAssignments"; $query = new Query( $sql, self::QUERY_CHANGE_ROWS, 'INSERT', $table ); $this->query( $query, $fname ); // Count updates of conflicting rows and row inserts equally toward the change count $this->lastQueryAffectedRows = min( $this->lastQueryAffectedRows, count( $rows ) ); } public function replace( $table, $uniqueKeys, $rows, $fname = __METHOD__ ) { $this->platform->normalizeUpsertParams( $uniqueKeys, $rows ); if ( !$rows ) { return; } $encTable = $this->tableName( $table ); [ $sqlColumns, $sqlTuples ] = $this->platform->makeInsertLists( $rows ); // https://dev.mysql.com/doc/refman/8.0/en/replace.html $sql = "REPLACE INTO $encTable ($sqlColumns) VALUES $sqlTuples"; // Note that any auto-increment columns on conflicting rows will be reassigned // due to combined DELETE+INSERT semantics. This will be reflected in insertId(). $query = new Query( $sql, self::QUERY_CHANGE_ROWS, 'REPLACE', $table ); $this->query( $query, $fname ); // Do not count deletions of conflicting rows toward the change count $this->lastQueryAffectedRows = min( $this->lastQueryAffectedRows, count( $rows ) ); } protected function isConnectionError( $errno ) { // https://mariadb.com/kb/en/mariadb-error-codes/ // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html // https://dev.mysql.com/doc/mysql-errors/8.0/en/client-error-reference.html return in_array( $errno, [ 2013, 2006, 2003, 1927, 1053 ], true ); } protected function isQueryTimeoutError( $errno ) { // https://mariadb.com/kb/en/mariadb-error-codes/ // https://dev.mysql.com/doc/refman/8.0/en/client-error-reference.html // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html return in_array( $errno, [ 3024, 2062, 1969, 1028 ], true ); } protected function isKnownStatementRollbackError( $errno ) { // https://mariadb.com/kb/en/mariadb-error-codes/ // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html return in_array( $errno, [ 3024, 1969, 1022, 1062, 1216, 1217, 1137, 1146, 1051, 1054 ], true ); } /** * @param string $oldName * @param string $newName * @param bool $temporary * @param string $fname * @return bool */ public function duplicateTableStructure( $oldName, $newName, $temporary = false, $fname = __METHOD__ ) { $tmp = $temporary ? 'TEMPORARY ' : ''; $newNameQuoted = $this->addIdentifierQuotes( $newName ); $oldNameQuoted = $this->addIdentifierQuotes( $oldName ); $query = new Query( "CREATE $tmp TABLE $newNameQuoted (LIKE $oldNameQuoted)", self::QUERY_PSEUDO_PERMANENT | self::QUERY_CHANGE_SCHEMA, $temporary ? 'CREATE TEMPORARY' : 'CREATE', // Use a dot to avoid double-prefixing in Database::getTempTableWrites() '.' . $newName ); return $this->query( $query, $fname ); } /** * List all tables on the database * * @param string|null $prefix Only show tables with this prefix, e.g. mw_ * @param string $fname Calling function name * @return array */ public function listTables( $prefix = null, $fname = __METHOD__ ) { $qb = $this->newSelectQueryBuilder() ->select( 'table_name' ) ->from( 'information_schema.tables' ) ->where( [ 'table_schema' => $this->currentDomain->getDatabase(), 'table_type' => 'BASE TABLE' ] ) ->caller( $fname ); if ( $prefix !== null && $prefix !== '' ) { $qb->andWhere( $this->expr( 'table_name', IExpression::LIKE, new LikeValue( $prefix, $this->anyString() ) ) ); } return $qb->fetchFieldValues(); } public function selectSQLText( $tables, $vars, $conds = '', $fname = __METHOD__, $options = [], $join_conds = [] ) { $sql = parent::selectSQLText( $tables, $vars, $conds, $fname, $options, $join_conds ); // https://dev.mysql.com/doc/refman/5.7/en/optimizer-hints.html // https://mariadb.com/kb/en/library/aborting-statements/ $timeoutMsec = intval( $options['MAX_EXECUTION_TIME'] ?? 0 ); if ( $timeoutMsec > 0 ) { [ $vendor, $number ] = $this->getMySqlServerVariant(); if ( $vendor === 'MariaDB' && version_compare( $number, '10.1.2', '>=' ) ) { $timeoutSec = $timeoutMsec / 1000; $sql = "SET STATEMENT max_statement_time=$timeoutSec FOR $sql"; } elseif ( $vendor === 'MySQL' && version_compare( $number, '5.7.0', '>=' ) ) { $sql = preg_replace( '/^SELECT(?=\s)/', "SELECT /*+ MAX_EXECUTION_TIME($timeoutMsec)*/", $sql ); } } return $sql; } protected function doSingleStatementQuery( string $sql ): QueryStatus { $conn = $this->getBindingHandle(); // Hide packet warnings caused by things like dropped connections AtEase::suppressWarnings(); $res = $conn->query( $sql ); AtEase::restoreWarnings(); // Note that mysqli::insert_id only reflects the last query statement $insertId = (int)$conn->insert_id; $this->lastQueryInsertId = $insertId; $this->sessionLastAutoRowId = $insertId ?: $this->sessionLastAutoRowId; return new QueryStatus( $res instanceof mysqli_result ? new MysqliResultWrapper( $this, $res ) : $res, $conn->affected_rows, $conn->error, $conn->errno ); } /** * @param string|null $server * @param string|null $user * @param string|null $password * @param string|null $db * @return mysqli|null * @throws DBConnectionError */ private function mysqlConnect( $server, $user, $password, $db ) { if ( !function_exists( 'mysqli_init' ) ) { throw $this->newExceptionAfterConnectError( "MySQLi functions missing, have you compiled PHP with the --with-mysqli option?" ); } // PHP 8.1.0+ throws exceptions by default. Turn that off for consistency. mysqli_report( MYSQLI_REPORT_OFF ); // Other than mysql_connect, mysqli_real_connect expects an explicit port number // e.g. "localhost:1234" or "127.0.0.1:1234" // or Unix domain socket path // e.g. "localhost:/socket_path" or "localhost:/foo/bar:bar:bar" // colons are known to be used by Google AppEngine, // see // // We need to parse the port or socket path out of $realServer $port = null; $socket = null; $hostAndPort = IPUtils::splitHostAndPort( $server ); if ( $hostAndPort ) { $realServer = $hostAndPort[0]; if ( $hostAndPort[1] ) { $port = $hostAndPort[1]; } } elseif ( substr_count( $server, ':/' ) == 1 ) { // If we have a colon slash instead of a colon and a port number // after the ip or hostname, assume it's the Unix domain socket path [ $realServer, $socket ] = explode( ':', $server, 2 ); } else { $realServer = $server; } $mysqli = mysqli_init(); // Make affectedRows() for UPDATE reflect the number of matching rows, regardless // of whether any column values changed. This is what callers want to know and is // consistent with what Postgres and SQLite return. $flags = MYSQLI_CLIENT_FOUND_ROWS; if ( $this->ssl ) { $flags |= MYSQLI_CLIENT_SSL; $mysqli->ssl_set( $this->sslKeyPath, $this->sslCertPath, $this->sslCAFile, $this->sslCAPath, $this->sslCiphers ); } if ( $this->getFlag( self::DBO_COMPRESS ) ) { $flags |= MYSQLI_CLIENT_COMPRESS; } if ( $this->getFlag( self::DBO_PERSISTENT ) ) { $realServer = 'p:' . $realServer; } if ( $this->utf8Mode ) { // Tell the server we're communicating with it in UTF-8. // This may engage various charset conversions. $mysqli->options( MYSQLI_SET_CHARSET_NAME, 'utf8' ); } else { $mysqli->options( MYSQLI_SET_CHARSET_NAME, 'binary' ); } $mysqli->options( MYSQLI_OPT_CONNECT_TIMEOUT, $this->connectTimeout ?: 3 ); if ( $this->receiveTimeout ) { $mysqli->options( MYSQLI_OPT_READ_TIMEOUT, $this->receiveTimeout ); } // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal socket seems set when used $ok = $mysqli->real_connect( $realServer, $user, $password, $db, $port, $socket, $flags ); return $ok ? $mysqli : null; } protected function closeConnection() { return ( $this->conn instanceof mysqli ) ? mysqli_close( $this->conn ) : true; } protected function lastInsertId() { return $this->sessionLastAutoRowId; } protected function doHandleSessionLossPreconnect() { // https://mariadb.com/kb/en/last_insert_id/ $this->sessionLastAutoRowId = 0; } public function insertId() { if ( $this->lastEmulatedInsertId === null ) { $conn = $this->getBindingHandle(); // Note that mysqli::insert_id only reflects the last query statement $this->lastEmulatedInsertId = (int)$conn->insert_id; } return $this->lastEmulatedInsertId; } /** * @return int */ public function lastErrno() { if ( $this->conn instanceof mysqli ) { return $this->conn->errno; } else { return mysqli_connect_errno(); } } /** * @param mysqli|null $conn Optional connection object * @return string */ private function mysqlError( $conn = null ) { if ( $conn === null ) { return (string)mysqli_connect_error(); } else { return $conn->error; } } private function mysqlRealEscapeString( $s ): string { $conn = $this->getBindingHandle(); return $conn->real_escape_string( (string)$s ); } }