Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.19% covered (danger)
5.19%
19 / 366
2.50% covered (danger)
2.50%
1 / 40
CRAP
0.00% covered (danger)
0.00%
0 / 1
DatabaseMySQL
5.19% covered (danger)
5.19%
19 / 366
2.50% covered (danger)
2.50%
1 / 40
13227.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 getType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 open
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
182
 doSelectDomain
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
30
 lastError
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 isInsertSelectSafe
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 checkInsertWarnings
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 estimateRowCount
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 tableExists
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 fieldInfo
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 indexInfo
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getPrimaryKeyColumns
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 strencode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 serverIsReadOnly
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getSoftwareLink
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getMySqlServerVariant
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 getServerVersion
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 setSessionOptions
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 streamStatementEnd
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 doLockIsFree
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 doLock
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 doUnlock
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 doFlushSession
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 upsert
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 replace
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 isConnectionError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isQueryTimeoutError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isKnownStatementRollbackError
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 duplicateTableStructure
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 listTables
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 selectSQLText
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
8.83
 doSingleStatementQuery
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 mysqlConnect
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
156
 closeConnection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 lastInsertId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doHandleSessionLossPreconnect
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 insertId
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 lastErrno
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 mysqlError
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 mysqlRealEscapeString
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6namespace Wikimedia\Rdbms;
7
8use mysqli;
9use mysqli_result;
10use RuntimeException;
11use Wikimedia\IPUtils;
12use Wikimedia\Rdbms\Platform\MySQLPlatform;
13use Wikimedia\Rdbms\Platform\SQLPlatform;
14use Wikimedia\Rdbms\Replication\MysqlReplicationReporter;
15
16/**
17 * MySQL database abstraction layer.
18 *
19 * Defines methods independent of the used MySQL extension.
20 *
21 * @property mysqli|null $conn
22 *
23 * @ingroup Database
24 * @since 1.22
25 * @see Database
26 */
27class DatabaseMySQL extends Database {
28    /** @var string|null */
29    private $sslKeyPath;
30    /** @var string|null */
31    private $sslCertPath;
32    /** @var string|null */
33    private $sslCAFile;
34    /** @var string|null */
35    private $sslCAPath;
36    /**
37     * Open SSL cipher list string
38     * @see https://docs.openssl.org/3.3/man1/openssl-ciphers/
39     * @var string|null
40     */
41    private $sslCiphers;
42    /** @var bool Use experimental UTF-8 transmission encoding */
43    private $utf8Mode;
44
45    /** @var SQLPlatform */
46    protected $platform;
47
48    /** @var MysqlReplicationReporter */
49    protected $replicationReporter;
50    /** @var int Last implicit row ID for the session (0 if none) */
51    private $sessionLastAutoRowId;
52
53    /**
54     * Additional $params include:
55     *   - lagDetectionMethod : set to one of (Seconds_Behind_Master,pt-heartbeat).
56     *       pt-heartbeat assumes the table is at heartbeat.heartbeat
57     *       and uses UTC timestamps in the heartbeat.ts column.
58     *       (https://www.percona.com/doc/percona-toolkit/2.2/pt-heartbeat.html)
59     *   - lagDetectionOptions : if using pt-heartbeat, this can be set to an array map.
60     *       The "conds" key overrides the WHERE clause used to find the relevant row in the
61     *       `heartbeat` table, e.g. ['shard' => 's1']. By default, the row used is the newest
62     *       row having a server_id matching that of the immediate replication source server
63     *       for the given replica.
64     *   - useGTIDs : use GTID methods like MASTER_GTID_WAIT() when possible.
65     *   - sslKeyPath : path to key file [default: null]
66     *   - sslCertPath : path to certificate file [default: null]
67     *   - sslCAFile: path to a single certificate authority PEM file [default: null]
68     *   - sslCAPath : parth to certificate authority PEM directory [default: null]
69     *   - sslCiphers : array list of allowable ciphers [default: null]
70     * @param array $params
71     */
72    public function __construct( array $params ) {
73        foreach ( [ 'KeyPath', 'CertPath', 'CAFile', 'CAPath', 'Ciphers' ] as $name ) {
74            $var = "ssl{$name}";
75            if ( isset( $params[$var] ) ) {
76                $this->$var = $params[$var];
77            }
78        }
79        $this->utf8Mode = !empty( $params['utf8Mode'] );
80        parent::__construct( $params );
81        $this->platform = new MySQLPlatform(
82            $this,
83            $this->logger,
84            $this->currentDomain,
85            $this->errorLogger
86        );
87        $this->replicationReporter = new MysqlReplicationReporter(
88            $params['topologyRole'],
89            $this->logger,
90            $params['srvCache'],
91            $params['lagDetectionMethod'] ?? 'Seconds_Behind_Master',
92            $params['lagDetectionOptions'] ?? [],
93            !empty( $params['useGTIDs' ] )
94        );
95    }
96
97    /**
98     * @return string
99     */
100    public function getType() {
101        return 'mysql';
102    }
103
104    /** @inheritDoc */
105    protected function open( $server, $user, $password, $db, $schema, $tablePrefix ) {
106        $this->close( __METHOD__ );
107
108        if ( $schema !== null ) {
109            throw $this->newExceptionAfterConnectError( "Got schema '$schema'; not supported." );
110        }
111
112        $this->installErrorHandler();
113        try {
114            $this->conn = $this->mysqlConnect( $server, $user, $password, $db );
115        } catch ( RuntimeException $e ) {
116            $this->restoreErrorHandler();
117            throw $this->newExceptionAfterConnectError( $e->getMessage() );
118        }
119        $error = $this->restoreErrorHandler();
120
121        if ( !$this->conn ) {
122            throw $this->newExceptionAfterConnectError( $error ?: $this->lastError() );
123        }
124
125        try {
126            $this->currentDomain = new DatabaseDomain(
127                ( $db !== '' ) ? $db : null,
128                null,
129                $tablePrefix
130            );
131            $this->platform->setCurrentDomain( $this->currentDomain );
132
133            $set = [];
134            if ( !$this->flagsHolder->getFlag( self::DBO_GAUGE ) ) {
135                // Abstract over any excessive MySQL defaults
136                $set[] = 'group_concat_max_len = 262144';
137                // Set any custom settings defined by site config
138                // https://dev.mysql.com/doc/refman/5.7/en/innodb-parameters.html
139                foreach ( $this->connectionVariables as $var => $val ) {
140                    // Escape strings but not numbers to avoid MySQL complaining
141                    if ( !is_int( $val ) && !is_float( $val ) ) {
142                        $val = $this->addQuotes( $val );
143                    }
144                    $set[] = $this->platform->addIdentifierQuotes( $var ) . ' = ' . $val;
145                }
146            }
147
148            if ( $set ) {
149                $sql = 'SET ' . implode( ', ', $set );
150                $flags = self::QUERY_NO_RETRY | self::QUERY_CHANGE_TRX;
151                $query = new Query( $sql, $flags, 'SET' );
152                // Avoid using query() so that replaceLostConnection() does not throw
153                // errors if the transaction status is STATUS_TRX_ERROR
154                $qs = $this->executeQuery( $query, __METHOD__, $flags );
155                if ( $qs->res === false ) {
156                    $this->reportQueryError( $qs->message, $qs->code, $sql, __METHOD__ );
157                }
158            }
159        } catch ( RuntimeException $e ) {
160            throw $this->newExceptionAfterConnectError( $e->getMessage() );
161        }
162    }
163
164    /** @inheritDoc */
165    protected function doSelectDomain( DatabaseDomain $domain ) {
166        if ( $domain->getSchema() !== null ) {
167            throw new DBExpectedError(
168                $this,
169                __CLASS__ . ": domain '{$domain->getId()}' has a schema component"
170            );
171        }
172
173        $database = $domain->getDatabase();
174        // A null database means "don't care" so leave it as is and update the table prefix
175        if ( $database === null ) {
176            $this->currentDomain = new DatabaseDomain(
177                $this->currentDomain->getDatabase(),
178                null,
179                $domain->getTablePrefix()
180            );
181            $this->platform->setCurrentDomain( $this->currentDomain );
182
183            return true;
184        }
185
186        if ( $database !== $this->getDBname() ) {
187            $sql = 'USE ' . $this->addIdentifierQuotes( $database );
188            $query = new Query( $sql, self::QUERY_CHANGE_TRX, 'USE' );
189            $qs = $this->executeQuery( $query, __METHOD__, self::QUERY_CHANGE_TRX );
190            if ( $qs->res === false ) {
191                $this->reportQueryError( $qs->message, $qs->code, $sql, __METHOD__ );
192                return false; // unreachable
193            }
194        }
195
196        // Update that domain fields on success (no exception thrown)
197        $this->currentDomain = $domain;
198        $this->platform->setCurrentDomain( $domain );
199
200        return true;
201    }
202
203    /**
204     * @return string
205     */
206    public function lastError() {
207        if ( $this->conn ) {
208            // Even if it's non-zero, it can still be invalid
209            $error = $this->mysqlError( $this->conn );
210            if ( !$error ) {
211                $error = $this->mysqlError();
212            }
213        } else {
214            $error = $this->mysqlError() ?: $this->lastConnectError;
215        }
216
217        return $error;
218    }
219
220    /** @inheritDoc */
221    protected function isInsertSelectSafe( array $insertOptions, array $selectOptions, $fname ) {
222        $row = $this->replicationReporter->getReplicationSafetyInfo( $this, $fname );
223        // For row-based-replication, the resulting changes will be relayed, not the query
224        if ( $row->binlog_format === 'ROW' ) {
225            return true;
226        }
227        // LIMIT requires ORDER BY on a unique key or it is non-deterministic
228        if ( isset( $selectOptions['LIMIT'] ) ) {
229            return false;
230        }
231        // In MySQL, an INSERT SELECT is only replication safe with row-based
232        // replication or if innodb_autoinc_lock_mode is 0. When those
233        // conditions aren't met, use non-native mode.
234        // While we could try to determine if the insert is safe anyway by
235        // checking if the target table has an auto-increment column that
236        // isn't set in $varMap, that seems unlikely to be worth the extra
237        // complexity.
238        return (
239            in_array( 'NO_AUTO_COLUMNS', $insertOptions ) ||
240            (int)$row->innodb_autoinc_lock_mode === 0
241        );
242    }
243
244    /** @inheritDoc */
245    protected function checkInsertWarnings( Query $query, $fname ) {
246        if ( $this->conn && $this->conn->warning_count ) {
247            // Yeah it's weird. It's not iterable.
248            $warnings = $this->conn->get_warnings();
249            $done = $warnings === false;
250            while ( !$done ) {
251                if ( in_array( $warnings->errno, [
252                    // List based on https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#ignore-effect-on-execution
253                    1048, /* ER_BAD_NULL_ERROR */
254                    1526, /* ER_NO_PARTITION_FOR_GIVEN_VALUE */
255                    1748, /* ER_ROW_DOES_NOT_MATCH_GIVEN_PARTITION_SET */
256                    1242, /* ER_SUBQUERY_NO_1_ROW */
257                    1369, /* ER_VIEW_CHECK_FAILED */
258                    // Truncation and overflow per T108255
259                    1264, /* ER_WARN_DATA_OUT_OF_RANGE */
260                    1265, /* WARN_DATA_TRUNCATED */
261                ] ) ) {
262                    $this->reportQueryError(
263                        'Insert returned unacceptable warning: ' . $warnings->message,
264                        $warnings->errno,
265                        $query->getSQL(),
266                        $fname
267                    );
268                }
269                $done = !$warnings->next();
270            }
271        }
272    }
273
274    /** @inheritDoc */
275    public function estimateRowCount(
276        $tables,
277        $var = '*',
278        $conds = '',
279        $fname = __METHOD__,
280        $options = [],
281        $join_conds = []
282    ): int {
283        $conds = $this->platform->normalizeConditions( $conds, $fname );
284        $column = $this->platform->extractSingleFieldFromList( $var );
285        if ( is_string( $column ) && !in_array( $column, [ '*', '1' ] ) ) {
286            $conds[] = "$column IS NOT NULL";
287        }
288
289        $options['EXPLAIN'] = true;
290        $res = $this->select( $tables, $var, $conds, $fname, $options, $join_conds );
291        if ( $res === false ) {
292            return -1;
293        }
294        if ( !$res->numRows() ) {
295            return 0;
296        }
297
298        $rows = 1;
299        foreach ( $res as $plan ) {
300            $rows *= $plan->rows > 0 ? $plan->rows : 1; // avoid resetting to zero
301        }
302
303        return (int)$rows;
304    }
305
306    /** @inheritDoc */
307    public function tableExists( $table, $fname = __METHOD__ ) {
308        [ $db, $pt ] = $this->platform->getDatabaseAndTableIdentifier( $table );
309        if ( isset( $this->sessionTempTables[$db][$pt] ) ) {
310            return true; // already known to exist and won't be found in the query anyway
311        }
312
313        return (bool)$this->newSelectQueryBuilder()
314            ->select( '1' )
315            ->from( 'information_schema.tables' )
316            ->where( [
317                'table_schema' => $db,
318                'table_name' => $pt,
319            ] )
320            ->caller( $fname )
321            ->fetchField();
322    }
323
324    /**
325     * @param string $table
326     * @param string $field
327     * @return MySQLField|false
328     */
329    public function fieldInfo( $table, $field ) {
330        $query = new Query(
331            "SELECT * FROM " . $this->tableName( $table ) . " LIMIT 1",
332            self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
333            'SELECT'
334        );
335        $res = $this->query( $query, __METHOD__ );
336        if ( !$res ) {
337            return false;
338        }
339        /** @var MysqliResultWrapper $res */
340        '@phan-var MysqliResultWrapper $res';
341        return $res->getInternalFieldInfo( $field );
342    }
343
344    /** @inheritDoc */
345    public function indexInfo( $table, $index, $fname = __METHOD__ ) {
346        # https://dev.mysql.com/doc/mysql/en/SHOW_INDEX.html
347        $query = new Query(
348            'SHOW INDEX FROM ' . $this->tableName( $table ),
349            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
350            'SHOW'
351        );
352        $res = $this->query( $query, $fname );
353
354        foreach ( $res as $row ) {
355            if ( $row->Key_name === $index ) {
356                return [ 'unique' => !$row->Non_unique ];
357            }
358        }
359
360        return false;
361    }
362
363    /** @inheritDoc */
364    public function getPrimaryKeyColumns( $table, $fname = __METHOD__ ) {
365        $query = new Query(
366            'SHOW INDEX FROM ' . $this->tableName( $table ),
367            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
368            'SHOW'
369        );
370        $res = $this->query( $query, $fname );
371
372        $bySeq = [];
373        foreach ( $res as $row ) {
374            if ( $row->Key_name === 'PRIMARY' ) {
375                $bySeq[(int)$row->Seq_in_index] = (string)$row->Column_name;
376            }
377        }
378
379        ksort( $bySeq );
380
381        return array_values( $bySeq );
382    }
383
384    /**
385     * @param string $s
386     * @return string
387     */
388    public function strencode( $s ) {
389        return $this->mysqlRealEscapeString( $s );
390    }
391
392    /** @inheritDoc */
393    public function serverIsReadOnly() {
394        // Avoid SHOW to avoid internal temporary tables
395        $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE;
396        $query = new Query( "SELECT @@GLOBAL.read_only AS Value", $flags, 'SELECT' );
397        $res = $this->query( $query, __METHOD__ );
398        $row = $res->fetchObject();
399
400        return $row && $row->Value && $row->Value !== 'OFF';
401    }
402
403    /**
404     * @return string
405     */
406    public function getSoftwareLink() {
407        [ $variant ] = $this->getMySqlServerVariant();
408        if ( $variant === 'MariaDB' ) {
409            return '[{{int:version-db-mariadb-url}} MariaDB]';
410        }
411
412        return '[{{int:version-db-mysql-url}} MySQL]';
413    }
414
415    /**
416     * @return string[] (one of ("MariaDB","MySQL"), x.y.z version string)
417     */
418    private function getMySqlServerVariant() {
419        $version = $this->getServerVersion();
420
421        // MariaDB includes its name in its version string; this is how MariaDB's version of
422        // the mysql command-line client identifies MariaDB servers.
423        // https://dev.mysql.com/doc/refman/8.0/en/information-functions.html#function_version
424        // https://mariadb.com/kb/en/version/
425        $parts = explode( '-', $version, 2 );
426        $number = $parts[0];
427        $suffix = $parts[1] ?? '';
428        if ( str_contains( $suffix, 'MariaDB' ) || str_contains( $suffix, '-maria-' ) ) {
429            $vendor = 'MariaDB';
430        } else {
431            $vendor = 'MySQL';
432        }
433
434        return [ $vendor, $number ];
435    }
436
437    /**
438     * @return string
439     */
440    public function getServerVersion() {
441        // MariaDB 10 adds the prefix "5.5.5-", and only some newer client libraries strip
442        // it off (see RPL_VERSION_HACK in include/mysql_com.h).
443        $version = $this->conn->server_info;
444        if (
445            str_starts_with( $version, '5.5.5-' ) &&
446            ( str_contains( $version, 'MariaDB' ) || str_contains( $version, '-maria-' ) )
447        ) {
448            $version = substr( $version, strlen( '5.5.5-' ) );
449        }
450        return $version;
451    }
452
453    public function setSessionOptions( array $options ) {
454        $sqlAssignments = [];
455
456        if ( isset( $options['connTimeout'] ) ) {
457            $encTimeout = (int)$options['connTimeout'];
458            $sqlAssignments[] = "net_read_timeout=$encTimeout";
459            $sqlAssignments[] = "net_write_timeout=$encTimeout";
460        }
461        if ( isset( $options['groupConcatMaxLen'] ) ) {
462            $maxLength = (int)$options['groupConcatMaxLen'];
463            $sqlAssignments[] = "group_concat_max_len=$maxLength";
464        }
465
466        if ( $sqlAssignments ) {
467            $query = new Query(
468                'SET ' . implode( ', ', $sqlAssignments ),
469                self::QUERY_CHANGE_TRX | self::QUERY_CHANGE_NONE,
470                'SET'
471            );
472            $this->query( $query, __METHOD__ );
473        }
474    }
475
476    /**
477     * @param string &$sql
478     * @param string &$newLine
479     * @return bool
480     */
481    public function streamStatementEnd( &$sql, &$newLine ) {
482        if ( preg_match( '/^DELIMITER\s+(\S+)/i', $newLine, $m ) ) {
483            $this->delimiter = $m[1];
484            $newLine = '';
485        }
486
487        return parent::streamStatementEnd( $sql, $newLine );
488    }
489
490    /** @inheritDoc */
491    public function doLockIsFree( string $lockName, string $method ) {
492        $query = new Query( $this->platform->lockIsFreeSQLText( $lockName ), self::QUERY_CHANGE_LOCKS, 'SELECT' );
493        $res = $this->query( $query, $method );
494        $row = $res->fetchObject();
495
496        return ( $row->unlocked == 1 );
497    }
498
499    /** @inheritDoc */
500    public function doLock( string $lockName, string $method, int $timeout ) {
501        $query = new Query( $this->platform->lockSQLText( $lockName, $timeout ), self::QUERY_CHANGE_LOCKS, 'SELECT' );
502        $res = $this->query( $query, $method );
503        $row = $res->fetchObject();
504
505        return ( $row->acquired !== null ) ? (float)$row->acquired : null;
506    }
507
508    /** @inheritDoc */
509    public function doUnlock( string $lockName, string $method ) {
510        $query = new Query( $this->platform->unlockSQLText( $lockName ), self::QUERY_CHANGE_LOCKS, 'SELECT' );
511        $res = $this->query( $query, $method );
512        $row = $res->fetchObject();
513
514        return ( $row->released == 1 );
515    }
516
517    /** @inheritDoc */
518    protected function doFlushSession( $fname ) {
519        // Note that RELEASE_ALL_LOCKS() is not supported well enough to use here.
520        // https://mariadb.com/kb/en/release_all_locks/
521        $releaseLockFields = [];
522        foreach ( $this->sessionNamedLocks as $name => $info ) {
523            $encName = $this->addQuotes( $this->platform->makeLockName( $name ) );
524            $releaseLockFields[] = "RELEASE_LOCK($encName)";
525        }
526        if ( $releaseLockFields ) {
527            $sql = 'SELECT ' . implode( ',', $releaseLockFields );
528            $flags = self::QUERY_CHANGE_LOCKS | self::QUERY_NO_RETRY;
529            $query = new Query( $sql, $flags, 'SELECT' );
530            $qs = $this->executeQuery( $query, __METHOD__, $flags );
531            if ( $qs->res === false ) {
532                $this->reportQueryError( $qs->message, $qs->code, $sql, $fname, true );
533            }
534        }
535    }
536
537    /** @inheritDoc */
538    public function upsert( $table, array $rows, $uniqueKeys, array $set, $fname = __METHOD__ ) {
539        $identityKey = $this->platform->normalizeUpsertParams( $uniqueKeys, $rows );
540        if ( !$rows ) {
541            return;
542        }
543        $this->platform->assertValidUpsertSetArray( $set, $identityKey, $rows );
544
545        $encTable = $this->tableName( $table );
546        [ $sqlColumns, $sqlTuples ] = $this->platform->makeInsertLists( $rows );
547        $sqlColumnAssignments = $this->makeList( $set, self::LIST_SET );
548        // No need to expose __NEW.* since buildExcludedValue() uses VALUES(column)
549
550        // https://mariadb.com/kb/en/insert-on-duplicate-key-update/
551        // https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html
552        $sql =
553            "INSERT INTO $encTable " .
554            "($sqlColumns) VALUES $sqlTuples " .
555            "ON DUPLICATE KEY UPDATE $sqlColumnAssignments";
556        $query = new Query( $sql, self::QUERY_CHANGE_ROWS, 'INSERT', $table );
557        $this->query( $query, $fname );
558        // Count updates of conflicting rows and row inserts equally toward the change count
559        $this->lastQueryAffectedRows = min( $this->lastQueryAffectedRows, count( $rows ) );
560    }
561
562    /** @inheritDoc */
563    public function replace( $table, $uniqueKeys, $rows, $fname = __METHOD__ ) {
564        $this->platform->normalizeUpsertParams( $uniqueKeys, $rows );
565        if ( !$rows ) {
566            return;
567        }
568        $encTable = $this->tableName( $table );
569        [ $sqlColumns, $sqlTuples ] = $this->platform->makeInsertLists( $rows );
570        // https://dev.mysql.com/doc/refman/8.0/en/replace.html
571        $sql = "REPLACE INTO $encTable ($sqlColumns) VALUES $sqlTuples";
572        // Note that any auto-increment columns on conflicting rows will be reassigned
573        // due to combined DELETE+INSERT semantics. This will be reflected in insertId().
574        $query = new Query( $sql, self::QUERY_CHANGE_ROWS, 'REPLACE', $table );
575        $this->query( $query, $fname );
576        // Do not count deletions of conflicting rows toward the change count
577        $this->lastQueryAffectedRows = min( $this->lastQueryAffectedRows, count( $rows ) );
578    }
579
580    /** @inheritDoc */
581    protected function isConnectionError( $errno ) {
582        // https://mariadb.com/kb/en/mariadb-error-codes/
583        // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html
584        // https://dev.mysql.com/doc/mysql-errors/8.0/en/client-error-reference.html
585        return in_array( $errno, [ 2013, 2006, 2003, 1927, 1053 ], true );
586    }
587
588    /** @inheritDoc */
589    protected function isQueryTimeoutError( $errno ) {
590        // https://mariadb.com/kb/en/mariadb-error-codes/
591        // https://dev.mysql.com/doc/refman/8.0/en/client-error-reference.html
592        // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html
593        // Note that 1969 is MariaDB specific and unused in MySQL.
594        return in_array( $errno, [ 3024, 1969, 1028 ], true );
595    }
596
597    /** @inheritDoc */
598    protected function isKnownStatementRollbackError( $errno ) {
599        // https://mariadb.com/kb/en/mariadb-error-codes/
600        // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html
601        return in_array(
602            $errno,
603            [ 3024, 1969, 1022, 1062, 1216, 1217, 1137, 1146, 1051, 1054 ],
604            true
605        );
606    }
607
608    /**
609     * @param string $oldName
610     * @param string $newName
611     * @param bool $temporary
612     * @param string $fname
613     * @return bool
614     */
615    public function duplicateTableStructure(
616        $oldName, $newName, $temporary = false, $fname = __METHOD__
617    ) {
618        $tmp = $temporary ? 'TEMPORARY ' : '';
619        $newNameQuoted = $this->addIdentifierQuotes( $newName );
620        $oldNameQuoted = $this->addIdentifierQuotes( $oldName );
621
622        $query = new Query(
623            "CREATE $tmp TABLE $newNameQuoted (LIKE $oldNameQuoted)",
624            self::QUERY_PSEUDO_PERMANENT | self::QUERY_CHANGE_SCHEMA,
625            $temporary ? 'CREATE TEMPORARY' : 'CREATE',
626            // Use a dot to avoid double-prefixing in Database::getTempTableWrites()
627            '.' . $newName
628        );
629        return $this->query( $query, $fname );
630    }
631
632    /**
633     * List all tables on the database
634     *
635     * @param string|null $prefix Only show tables with this prefix, e.g. mw_
636     * @param string $fname Calling function name
637     * @return array
638     */
639    public function listTables( $prefix = null, $fname = __METHOD__ ) {
640        $qb = $this->newSelectQueryBuilder()
641            ->select( 'table_name' )
642            ->from( 'information_schema.tables' )
643            ->where( [
644                'table_schema' => $this->currentDomain->getDatabase(),
645                'table_type' => 'BASE TABLE'
646            ] )
647            ->caller( $fname );
648        if ( $prefix !== null && $prefix !== '' ) {
649            $qb->andWhere( $this->expr(
650                'table_name', IExpression::LIKE, new LikeValue( $prefix, $this->anyString() )
651            ) );
652        }
653        return $qb->fetchFieldValues();
654    }
655
656    /** @inheritDoc */
657    public function selectSQLText(
658        $tables,
659        $vars,
660        $conds = '',
661        $fname = __METHOD__,
662        $options = [],
663        $join_conds = []
664    ) {
665        $sql = parent::selectSQLText( $tables, $vars, $conds, $fname, $options, $join_conds );
666        // https://dev.mysql.com/doc/refman/5.7/en/optimizer-hints.html
667        // https://mariadb.com/kb/en/library/aborting-statements/
668        $timeoutMsec = intval( $options['MAX_EXECUTION_TIME'] ?? 0 );
669        if ( $timeoutMsec > 0 ) {
670            [ $vendor, $number ] = $this->getMySqlServerVariant();
671            if ( $vendor === 'MariaDB' && version_compare( $number, '10.1.2', '>=' ) ) {
672                $timeoutSec = $timeoutMsec / 1000;
673                $sql = "SET STATEMENT max_statement_time=$timeoutSec FOR $sql";
674            } elseif ( $vendor === 'MySQL' && version_compare( $number, '5.7.0', '>=' ) ) {
675                $sql = preg_replace(
676                    '/^SELECT(?=\s)/',
677                    "SELECT /*+ MAX_EXECUTION_TIME($timeoutMsec)*/",
678                    $sql
679                );
680            }
681        }
682
683        return $sql;
684    }
685
686    protected function doSingleStatementQuery( string $sql ): QueryStatus {
687        $conn = $this->getBindingHandle();
688
689        // Hide packet warnings caused by things like dropped connections
690        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
691        $res = @$conn->query( $sql );
692        // Note that mysqli::insert_id only reflects the last query statement
693        $insertId = (int)$conn->insert_id;
694        $this->lastQueryInsertId = $insertId;
695        $this->sessionLastAutoRowId = $insertId ?: $this->sessionLastAutoRowId;
696
697        return new QueryStatus(
698            $res instanceof mysqli_result ? new MysqliResultWrapper( $this, $res ) : $res,
699            $conn->affected_rows,
700            $conn->error,
701            $conn->errno
702        );
703    }
704
705    /**
706     * @param string|null $server
707     * @param string|null $user
708     * @param string|null $password
709     * @param string|null $db
710     * @return mysqli|null
711     * @throws DBConnectionError
712     */
713    private function mysqlConnect( $server, $user, $password, $db ) {
714        if ( !function_exists( 'mysqli_init' ) ) {
715            throw $this->newExceptionAfterConnectError(
716                "MySQLi functions missing, have you compiled PHP with the --with-mysqli option?"
717            );
718        }
719
720        // PHP 8.1.0+ throws exceptions by default. Turn that off for consistency.
721        mysqli_report( MYSQLI_REPORT_OFF );
722
723        // Other than mysql_connect, mysqli_real_connect expects an explicit port number
724        // e.g. "localhost:1234" or "127.0.0.1:1234"
725        // or Unix domain socket path
726        // e.g. "localhost:/socket_path" or "localhost:/foo/bar:bar:bar"
727        // colons are known to be used by Google AppEngine,
728        // see <https://cloud.google.com/sql/docs/mysql/connect-app-engine>
729        //
730        // We need to parse the port or socket path out of $realServer
731        $port = null;
732        $socket = null;
733        $hostAndPort = IPUtils::splitHostAndPort( $server );
734        if ( $hostAndPort ) {
735            $realServer = $hostAndPort[0];
736            if ( $hostAndPort[1] ) {
737                $port = $hostAndPort[1];
738            }
739        } elseif ( substr_count( $server, ':/' ) == 1 ) {
740            // If we have a colon slash instead of a colon and a port number
741            // after the ip or hostname, assume it's the Unix domain socket path
742            [ $realServer, $socket ] = explode( ':', $server, 2 );
743        } else {
744            $realServer = $server;
745        }
746
747        $mysqli = mysqli_init();
748        // Make affectedRows() for UPDATE reflect the number of matching rows, regardless
749        // of whether any column values changed. This is what callers want to know and is
750        // consistent with what Postgres and SQLite return.
751        $flags = MYSQLI_CLIENT_FOUND_ROWS;
752        if ( $this->ssl ) {
753            $flags |= MYSQLI_CLIENT_SSL;
754            $mysqli->ssl_set(
755                $this->sslKeyPath,
756                $this->sslCertPath,
757                $this->sslCAFile,
758                $this->sslCAPath,
759                $this->sslCiphers
760            );
761        }
762        if ( $this->getFlag( self::DBO_COMPRESS ) ) {
763            $flags |= MYSQLI_CLIENT_COMPRESS;
764        }
765        if ( $this->getFlag( self::DBO_PERSISTENT ) ) {
766            $realServer = 'p:' . $realServer;
767        }
768
769        if ( $this->utf8Mode ) {
770            // Tell the server we're communicating with it in UTF-8.
771            // This may engage various charset conversions.
772            $mysqli->options( MYSQLI_SET_CHARSET_NAME, 'utf8' );
773        } else {
774            $mysqli->options( MYSQLI_SET_CHARSET_NAME, 'binary' );
775        }
776
777        $mysqli->options( MYSQLI_OPT_CONNECT_TIMEOUT, $this->connectTimeout ?: 3 );
778        if ( $this->receiveTimeout ) {
779            $mysqli->options( MYSQLI_OPT_READ_TIMEOUT, $this->receiveTimeout );
780        }
781
782        // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal socket seems set when used
783        $ok = $mysqli->real_connect( $realServer, $user, $password, $db, $port, $socket, $flags );
784
785        return $ok ? $mysqli : null;
786    }
787
788    /** @inheritDoc */
789    protected function closeConnection() {
790        return ( $this->conn instanceof mysqli ) ? mysqli_close( $this->conn ) : true;
791    }
792
793    /** @inheritDoc */
794    protected function lastInsertId() {
795        return $this->sessionLastAutoRowId;
796    }
797
798    protected function doHandleSessionLossPreconnect() {
799        // https://mariadb.com/kb/en/last_insert_id/
800        $this->sessionLastAutoRowId = 0;
801    }
802
803    /** @inheritDoc */
804    public function insertId() {
805        if ( $this->lastEmulatedInsertId === null ) {
806            $conn = $this->getBindingHandle();
807            // Note that mysqli::insert_id only reflects the last query statement
808            $this->lastEmulatedInsertId = (int)$conn->insert_id;
809        }
810
811        return $this->lastEmulatedInsertId;
812    }
813
814    /**
815     * @return int
816     */
817    public function lastErrno() {
818        if ( $this->conn instanceof mysqli ) {
819            return $this->conn->errno;
820        } else {
821            return mysqli_connect_errno();
822        }
823    }
824
825    /**
826     * @param mysqli|null $conn Optional connection object
827     * @return string
828     */
829    private function mysqlError( $conn = null ) {
830        if ( $conn === null ) {
831            return (string)mysqli_connect_error();
832        } else {
833            return $conn->error;
834        }
835    }
836
837    /**
838     * @param mixed $s
839     */
840    private function mysqlRealEscapeString( $s ): string {
841        $conn = $this->getBindingHandle();
842
843        return $conn->real_escape_string( (string)$s );
844    }
845}