Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
4.99% |
19 / 381 |
|
2.33% |
1 / 43 |
CRAP | |
0.00% |
0 / 1 |
DatabaseMySQL | |
4.99% |
19 / 381 |
|
2.33% |
1 / 43 |
15305.39 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
12 | |||
getType | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
open | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
210 | |||
doSelectDomain | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
30 | |||
lastError | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
isInsertSelectSafe | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
checkInsertWarnings | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
30 | |||
estimateRowCount | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
56 | |||
tableExists | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
20 | |||
fieldInfo | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
indexInfo | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
30 | |||
strencode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
serverIsReadOnly | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
getSoftwareLink | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getMySqlServerVariant | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
3.02 | |||
getServerVersion | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
20 | |||
setSessionOptions | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
20 | |||
streamStatementEnd | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
doLockIsFree | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
doLock | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
doUnlock | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
namedLocksEnqueue | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
doFlushSession | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
upsert | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
6 | |||
replace | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
wasDeadlock | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
wasReadOnlyError | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
12 | |||
isConnectionError | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isQueryTimeoutError | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isKnownStatementRollbackError | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
duplicateTableStructure | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
listTables | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
listViews | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
selectSQLText | |
57.14% |
8 / 14 |
|
0.00% |
0 / 1 |
8.83 | |||
doSingleStatementQuery | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
mysqlConnect | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
156 | |||
closeConnection | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
lastInsertId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
doHandleSessionLossPreconnect | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
insertId | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
lastErrno | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
mysqlError | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
mysqlRealEscapeString | |
0.00% |
0 / 2 |
|
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 | */ |
20 | namespace Wikimedia\Rdbms; |
21 | |
22 | use mysqli; |
23 | use mysqli_result; |
24 | use RuntimeException; |
25 | use Wikimedia\AtEase\AtEase; |
26 | use Wikimedia\IPUtils; |
27 | use Wikimedia\Rdbms\Platform\MySQLPlatform; |
28 | use Wikimedia\Rdbms\Platform\SQLPlatform; |
29 | use 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 | */ |
42 | class 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 | } |