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