Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
5.34% |
19 / 356 |
|
2.56% |
1 / 39 |
CRAP | |
0.00% |
0 / 1 |
DatabaseMySQL | |
5.34% |
19 / 356 |
|
2.56% |
1 / 39 |
12335.25 | |
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 |
182 | |||
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 / 12 |
|
0.00% |
0 / 1 |
6 | |||
fieldInfo | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
indexInfo | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
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 | |||
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 | |||
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 | |||
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://docs.openssl.org/3.3/man1/openssl-ciphers/ |
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 !== '' ) ? $db : null, |
142 | null, |
143 | $tablePrefix |
144 | ); |
145 | $this->platform->setCurrentDomain( $this->currentDomain ); |
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->setCurrentDomain( $this->currentDomain ); |
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->setCurrentDomain( $domain ); |
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, $fname ) { |
234 | $row = $this->replicationReporter->getReplicationSafetyInfo( $this, $fname ); |
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 | public function estimateRowCount( |
286 | $tables, |
287 | $var = '*', |
288 | $conds = '', |
289 | $fname = __METHOD__, |
290 | $options = [], |
291 | $join_conds = [] |
292 | ): int { |
293 | $conds = $this->platform->normalizeConditions( $conds, $fname ); |
294 | $column = $this->platform->extractSingleFieldFromList( $var ); |
295 | if ( is_string( $column ) && !in_array( $column, [ '*', '1' ] ) ) { |
296 | $conds[] = "$column IS NOT NULL"; |
297 | } |
298 | |
299 | $options['EXPLAIN'] = true; |
300 | $res = $this->select( $tables, $var, $conds, $fname, $options, $join_conds ); |
301 | if ( $res === false ) { |
302 | return -1; |
303 | } |
304 | if ( !$res->numRows() ) { |
305 | return 0; |
306 | } |
307 | |
308 | $rows = 1; |
309 | foreach ( $res as $plan ) { |
310 | $rows *= $plan->rows > 0 ? $plan->rows : 1; // avoid resetting to zero |
311 | } |
312 | |
313 | return (int)$rows; |
314 | } |
315 | |
316 | public function tableExists( $table, $fname = __METHOD__ ) { |
317 | [ $db, $pt ] = $this->platform->getDatabaseAndTableIdentifier( $table ); |
318 | if ( isset( $this->sessionTempTables[$db][$pt] ) ) { |
319 | return true; // already known to exist and won't be found in the query anyway |
320 | } |
321 | |
322 | return (bool)$this->newSelectQueryBuilder() |
323 | ->select( '1' ) |
324 | ->from( 'information_schema.tables' ) |
325 | ->where( [ |
326 | 'table_schema' => $db, |
327 | 'table_name' => $pt, |
328 | ] ) |
329 | ->caller( $fname ) |
330 | ->fetchField(); |
331 | } |
332 | |
333 | /** |
334 | * @param string $table |
335 | * @param string $field |
336 | * @return MySQLField|false |
337 | */ |
338 | public function fieldInfo( $table, $field ) { |
339 | $query = new Query( |
340 | "SELECT * FROM " . $this->tableName( $table ) . " LIMIT 1", |
341 | self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE, |
342 | 'SELECT' |
343 | ); |
344 | $res = $this->query( $query, __METHOD__ ); |
345 | if ( !$res ) { |
346 | return false; |
347 | } |
348 | /** @var MysqliResultWrapper $res */ |
349 | '@phan-var MysqliResultWrapper $res'; |
350 | return $res->getInternalFieldInfo( $field ); |
351 | } |
352 | |
353 | public function indexInfo( $table, $index, $fname = __METHOD__ ) { |
354 | # https://dev.mysql.com/doc/mysql/en/SHOW_INDEX.html |
355 | $query = new Query( |
356 | 'SHOW INDEX FROM ' . $this->tableName( $table ), |
357 | self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE, |
358 | 'SHOW' |
359 | ); |
360 | $res = $this->query( $query, $fname ); |
361 | |
362 | foreach ( $res as $row ) { |
363 | if ( $row->Key_name === $index ) { |
364 | return [ 'unique' => !$row->Non_unique ]; |
365 | } |
366 | } |
367 | |
368 | return false; |
369 | } |
370 | |
371 | /** |
372 | * @param string $s |
373 | * @return string |
374 | */ |
375 | public function strencode( $s ) { |
376 | return $this->mysqlRealEscapeString( $s ); |
377 | } |
378 | |
379 | public function serverIsReadOnly() { |
380 | // Avoid SHOW to avoid internal temporary tables |
381 | $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE; |
382 | $query = new Query( "SELECT @@GLOBAL.read_only AS Value", $flags, 'SELECT' ); |
383 | $res = $this->query( $query, __METHOD__ ); |
384 | $row = $res->fetchObject(); |
385 | |
386 | return $row && (bool)$row->Value; |
387 | } |
388 | |
389 | /** |
390 | * @return string |
391 | */ |
392 | public function getSoftwareLink() { |
393 | [ $variant ] = $this->getMySqlServerVariant(); |
394 | if ( $variant === 'MariaDB' ) { |
395 | return '[{{int:version-db-mariadb-url}} MariaDB]'; |
396 | } |
397 | |
398 | return '[{{int:version-db-mysql-url}} MySQL]'; |
399 | } |
400 | |
401 | /** |
402 | * @return string[] (one of ("MariaDB","MySQL"), x.y.z version string) |
403 | */ |
404 | private function getMySqlServerVariant() { |
405 | $version = $this->getServerVersion(); |
406 | |
407 | // MariaDB includes its name in its version string; this is how MariaDB's version of |
408 | // the mysql command-line client identifies MariaDB servers. |
409 | // https://dev.mysql.com/doc/refman/8.0/en/information-functions.html#function_version |
410 | // https://mariadb.com/kb/en/version/ |
411 | $parts = explode( '-', $version, 2 ); |
412 | $number = $parts[0]; |
413 | $suffix = $parts[1] ?? ''; |
414 | if ( strpos( $suffix, 'MariaDB' ) !== false || strpos( $suffix, '-maria-' ) !== false ) { |
415 | $vendor = 'MariaDB'; |
416 | } else { |
417 | $vendor = 'MySQL'; |
418 | } |
419 | |
420 | return [ $vendor, $number ]; |
421 | } |
422 | |
423 | /** |
424 | * @return string |
425 | */ |
426 | public function getServerVersion() { |
427 | // MariaDB 10 adds the prefix "5.5.5-", and only some newer client libraries strip |
428 | // it off (see RPL_VERSION_HACK in include/mysql_com.h). |
429 | $version = $this->conn->server_info; |
430 | if ( |
431 | str_starts_with( $version, '5.5.5-' ) && |
432 | ( str_contains( $version, 'MariaDB' ) || str_contains( $version, '-maria-' ) ) |
433 | ) { |
434 | $version = substr( $version, strlen( '5.5.5-' ) ); |
435 | } |
436 | return $version; |
437 | } |
438 | |
439 | public function setSessionOptions( array $options ) { |
440 | $sqlAssignments = []; |
441 | |
442 | if ( isset( $options['connTimeout'] ) ) { |
443 | $encTimeout = (int)$options['connTimeout']; |
444 | $sqlAssignments[] = "net_read_timeout=$encTimeout"; |
445 | $sqlAssignments[] = "net_write_timeout=$encTimeout"; |
446 | } |
447 | if ( isset( $options['groupConcatMaxLen'] ) ) { |
448 | $maxLength = (int)$options['groupConcatMaxLen']; |
449 | $sqlAssignments[] = "group_concat_max_len=$maxLength"; |
450 | } |
451 | |
452 | if ( $sqlAssignments ) { |
453 | $query = new Query( |
454 | 'SET ' . implode( ', ', $sqlAssignments ), |
455 | self::QUERY_CHANGE_TRX | self::QUERY_CHANGE_NONE, |
456 | 'SET' |
457 | ); |
458 | $this->query( $query, __METHOD__ ); |
459 | } |
460 | } |
461 | |
462 | /** |
463 | * @param string &$sql |
464 | * @param string &$newLine |
465 | * @return bool |
466 | */ |
467 | public function streamStatementEnd( &$sql, &$newLine ) { |
468 | if ( preg_match( '/^DELIMITER\s+(\S+)/i', $newLine, $m ) ) { |
469 | $this->delimiter = $m[1]; |
470 | $newLine = ''; |
471 | } |
472 | |
473 | return parent::streamStatementEnd( $sql, $newLine ); |
474 | } |
475 | |
476 | public function doLockIsFree( string $lockName, string $method ) { |
477 | $query = new Query( $this->platform->lockIsFreeSQLText( $lockName ), self::QUERY_CHANGE_LOCKS, 'SELECT' ); |
478 | $res = $this->query( $query, $method ); |
479 | $row = $res->fetchObject(); |
480 | |
481 | return ( $row->unlocked == 1 ); |
482 | } |
483 | |
484 | public function doLock( string $lockName, string $method, int $timeout ) { |
485 | $query = new Query( $this->platform->lockSQLText( $lockName, $timeout ), self::QUERY_CHANGE_LOCKS, 'SELECT' ); |
486 | $res = $this->query( $query, $method ); |
487 | $row = $res->fetchObject(); |
488 | |
489 | return ( $row->acquired !== null ) ? (float)$row->acquired : null; |
490 | } |
491 | |
492 | public function doUnlock( string $lockName, string $method ) { |
493 | $query = new Query( $this->platform->unlockSQLText( $lockName ), self::QUERY_CHANGE_LOCKS, 'SELECT' ); |
494 | $res = $this->query( $query, $method ); |
495 | $row = $res->fetchObject(); |
496 | |
497 | return ( $row->released == 1 ); |
498 | } |
499 | |
500 | protected function doFlushSession( $fname ) { |
501 | // Note that RELEASE_ALL_LOCKS() is not supported well enough to use here. |
502 | // https://mariadb.com/kb/en/release_all_locks/ |
503 | $releaseLockFields = []; |
504 | foreach ( $this->sessionNamedLocks as $name => $info ) { |
505 | $encName = $this->addQuotes( $this->platform->makeLockName( $name ) ); |
506 | $releaseLockFields[] = "RELEASE_LOCK($encName)"; |
507 | } |
508 | if ( $releaseLockFields ) { |
509 | $sql = 'SELECT ' . implode( ',', $releaseLockFields ); |
510 | $flags = self::QUERY_CHANGE_LOCKS | self::QUERY_NO_RETRY; |
511 | $query = new Query( $sql, $flags, 'SELECT' ); |
512 | $qs = $this->executeQuery( $query, __METHOD__, $flags ); |
513 | if ( $qs->res === false ) { |
514 | $this->reportQueryError( $qs->message, $qs->code, $sql, $fname, true ); |
515 | } |
516 | } |
517 | } |
518 | |
519 | public function upsert( $table, array $rows, $uniqueKeys, array $set, $fname = __METHOD__ ) { |
520 | $identityKey = $this->platform->normalizeUpsertParams( $uniqueKeys, $rows ); |
521 | if ( !$rows ) { |
522 | return; |
523 | } |
524 | $this->platform->assertValidUpsertSetArray( $set, $identityKey, $rows ); |
525 | |
526 | $encTable = $this->tableName( $table ); |
527 | [ $sqlColumns, $sqlTuples ] = $this->platform->makeInsertLists( $rows ); |
528 | $sqlColumnAssignments = $this->makeList( $set, self::LIST_SET ); |
529 | // No need to expose __NEW.* since buildExcludedValue() uses VALUES(column) |
530 | |
531 | // https://mariadb.com/kb/en/insert-on-duplicate-key-update/ |
532 | // https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html |
533 | $sql = |
534 | "INSERT INTO $encTable " . |
535 | "($sqlColumns) VALUES $sqlTuples " . |
536 | "ON DUPLICATE KEY UPDATE $sqlColumnAssignments"; |
537 | $query = new Query( $sql, self::QUERY_CHANGE_ROWS, 'INSERT', $table ); |
538 | $this->query( $query, $fname ); |
539 | // Count updates of conflicting rows and row inserts equally toward the change count |
540 | $this->lastQueryAffectedRows = min( $this->lastQueryAffectedRows, count( $rows ) ); |
541 | } |
542 | |
543 | public function replace( $table, $uniqueKeys, $rows, $fname = __METHOD__ ) { |
544 | $this->platform->normalizeUpsertParams( $uniqueKeys, $rows ); |
545 | if ( !$rows ) { |
546 | return; |
547 | } |
548 | $encTable = $this->tableName( $table ); |
549 | [ $sqlColumns, $sqlTuples ] = $this->platform->makeInsertLists( $rows ); |
550 | // https://dev.mysql.com/doc/refman/8.0/en/replace.html |
551 | $sql = "REPLACE INTO $encTable ($sqlColumns) VALUES $sqlTuples"; |
552 | // Note that any auto-increment columns on conflicting rows will be reassigned |
553 | // due to combined DELETE+INSERT semantics. This will be reflected in insertId(). |
554 | $query = new Query( $sql, self::QUERY_CHANGE_ROWS, 'REPLACE', $table ); |
555 | $this->query( $query, $fname ); |
556 | // Do not count deletions of conflicting rows toward the change count |
557 | $this->lastQueryAffectedRows = min( $this->lastQueryAffectedRows, count( $rows ) ); |
558 | } |
559 | |
560 | protected function isConnectionError( $errno ) { |
561 | // https://mariadb.com/kb/en/mariadb-error-codes/ |
562 | // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html |
563 | // https://dev.mysql.com/doc/mysql-errors/8.0/en/client-error-reference.html |
564 | return in_array( $errno, [ 2013, 2006, 2003, 1927, 1053 ], true ); |
565 | } |
566 | |
567 | protected function isQueryTimeoutError( $errno ) { |
568 | // https://mariadb.com/kb/en/mariadb-error-codes/ |
569 | // https://dev.mysql.com/doc/refman/8.0/en/client-error-reference.html |
570 | // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html |
571 | return in_array( $errno, [ 3024, 2062, 1969, 1028 ], true ); |
572 | } |
573 | |
574 | protected function isKnownStatementRollbackError( $errno ) { |
575 | // https://mariadb.com/kb/en/mariadb-error-codes/ |
576 | // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html |
577 | return in_array( |
578 | $errno, |
579 | [ 3024, 1969, 1022, 1062, 1216, 1217, 1137, 1146, 1051, 1054 ], |
580 | true |
581 | ); |
582 | } |
583 | |
584 | /** |
585 | * @param string $oldName |
586 | * @param string $newName |
587 | * @param bool $temporary |
588 | * @param string $fname |
589 | * @return bool |
590 | */ |
591 | public function duplicateTableStructure( |
592 | $oldName, $newName, $temporary = false, $fname = __METHOD__ |
593 | ) { |
594 | $tmp = $temporary ? 'TEMPORARY ' : ''; |
595 | $newNameQuoted = $this->addIdentifierQuotes( $newName ); |
596 | $oldNameQuoted = $this->addIdentifierQuotes( $oldName ); |
597 | |
598 | $query = new Query( |
599 | "CREATE $tmp TABLE $newNameQuoted (LIKE $oldNameQuoted)", |
600 | self::QUERY_PSEUDO_PERMANENT | self::QUERY_CHANGE_SCHEMA, |
601 | $temporary ? 'CREATE TEMPORARY' : 'CREATE', |
602 | // Use a dot to avoid double-prefixing in Database::getTempTableWrites() |
603 | '.' . $newName |
604 | ); |
605 | return $this->query( $query, $fname ); |
606 | } |
607 | |
608 | /** |
609 | * List all tables on the database |
610 | * |
611 | * @param string|null $prefix Only show tables with this prefix, e.g. mw_ |
612 | * @param string $fname Calling function name |
613 | * @return array |
614 | */ |
615 | public function listTables( $prefix = null, $fname = __METHOD__ ) { |
616 | $qb = $this->newSelectQueryBuilder() |
617 | ->select( 'table_name' ) |
618 | ->from( 'information_schema.tables' ) |
619 | ->where( [ |
620 | 'table_schema' => $this->currentDomain->getDatabase(), |
621 | 'table_type' => 'BASE TABLE' |
622 | ] ) |
623 | ->caller( $fname ); |
624 | if ( $prefix !== null && $prefix !== '' ) { |
625 | $qb->andWhere( $this->expr( |
626 | 'table_name', IExpression::LIKE, new LikeValue( $prefix, $this->anyString() ) |
627 | ) ); |
628 | } |
629 | return $qb->fetchFieldValues(); |
630 | } |
631 | |
632 | public function selectSQLText( |
633 | $tables, |
634 | $vars, |
635 | $conds = '', |
636 | $fname = __METHOD__, |
637 | $options = [], |
638 | $join_conds = [] |
639 | ) { |
640 | $sql = parent::selectSQLText( $tables, $vars, $conds, $fname, $options, $join_conds ); |
641 | // https://dev.mysql.com/doc/refman/5.7/en/optimizer-hints.html |
642 | // https://mariadb.com/kb/en/library/aborting-statements/ |
643 | $timeoutMsec = intval( $options['MAX_EXECUTION_TIME'] ?? 0 ); |
644 | if ( $timeoutMsec > 0 ) { |
645 | [ $vendor, $number ] = $this->getMySqlServerVariant(); |
646 | if ( $vendor === 'MariaDB' && version_compare( $number, '10.1.2', '>=' ) ) { |
647 | $timeoutSec = $timeoutMsec / 1000; |
648 | $sql = "SET STATEMENT max_statement_time=$timeoutSec FOR $sql"; |
649 | } elseif ( $vendor === 'MySQL' && version_compare( $number, '5.7.0', '>=' ) ) { |
650 | $sql = preg_replace( |
651 | '/^SELECT(?=\s)/', |
652 | "SELECT /*+ MAX_EXECUTION_TIME($timeoutMsec)*/", |
653 | $sql |
654 | ); |
655 | } |
656 | } |
657 | |
658 | return $sql; |
659 | } |
660 | |
661 | protected function doSingleStatementQuery( string $sql ): QueryStatus { |
662 | $conn = $this->getBindingHandle(); |
663 | |
664 | // Hide packet warnings caused by things like dropped connections |
665 | AtEase::suppressWarnings(); |
666 | $res = $conn->query( $sql ); |
667 | AtEase::restoreWarnings(); |
668 | // Note that mysqli::insert_id only reflects the last query statement |
669 | $insertId = (int)$conn->insert_id; |
670 | $this->lastQueryInsertId = $insertId; |
671 | $this->sessionLastAutoRowId = $insertId ?: $this->sessionLastAutoRowId; |
672 | |
673 | return new QueryStatus( |
674 | $res instanceof mysqli_result ? new MysqliResultWrapper( $this, $res ) : $res, |
675 | $conn->affected_rows, |
676 | $conn->error, |
677 | $conn->errno |
678 | ); |
679 | } |
680 | |
681 | /** |
682 | * @param string|null $server |
683 | * @param string|null $user |
684 | * @param string|null $password |
685 | * @param string|null $db |
686 | * @return mysqli|null |
687 | * @throws DBConnectionError |
688 | */ |
689 | private function mysqlConnect( $server, $user, $password, $db ) { |
690 | if ( !function_exists( 'mysqli_init' ) ) { |
691 | throw $this->newExceptionAfterConnectError( |
692 | "MySQLi functions missing, have you compiled PHP with the --with-mysqli option?" |
693 | ); |
694 | } |
695 | |
696 | // PHP 8.1.0+ throws exceptions by default. Turn that off for consistency. |
697 | mysqli_report( MYSQLI_REPORT_OFF ); |
698 | |
699 | // Other than mysql_connect, mysqli_real_connect expects an explicit port number |
700 | // e.g. "localhost:1234" or "127.0.0.1:1234" |
701 | // or Unix domain socket path |
702 | // e.g. "localhost:/socket_path" or "localhost:/foo/bar:bar:bar" |
703 | // colons are known to be used by Google AppEngine, |
704 | // see <https://cloud.google.com/sql/docs/mysql/connect-app-engine> |
705 | // |
706 | // We need to parse the port or socket path out of $realServer |
707 | $port = null; |
708 | $socket = null; |
709 | $hostAndPort = IPUtils::splitHostAndPort( $server ); |
710 | if ( $hostAndPort ) { |
711 | $realServer = $hostAndPort[0]; |
712 | if ( $hostAndPort[1] ) { |
713 | $port = $hostAndPort[1]; |
714 | } |
715 | } elseif ( substr_count( $server, ':/' ) == 1 ) { |
716 | // If we have a colon slash instead of a colon and a port number |
717 | // after the ip or hostname, assume it's the Unix domain socket path |
718 | [ $realServer, $socket ] = explode( ':', $server, 2 ); |
719 | } else { |
720 | $realServer = $server; |
721 | } |
722 | |
723 | $mysqli = mysqli_init(); |
724 | // Make affectedRows() for UPDATE reflect the number of matching rows, regardless |
725 | // of whether any column values changed. This is what callers want to know and is |
726 | // consistent with what Postgres and SQLite return. |
727 | $flags = MYSQLI_CLIENT_FOUND_ROWS; |
728 | if ( $this->ssl ) { |
729 | $flags |= MYSQLI_CLIENT_SSL; |
730 | $mysqli->ssl_set( |
731 | $this->sslKeyPath, |
732 | $this->sslCertPath, |
733 | $this->sslCAFile, |
734 | $this->sslCAPath, |
735 | $this->sslCiphers |
736 | ); |
737 | } |
738 | if ( $this->getFlag( self::DBO_COMPRESS ) ) { |
739 | $flags |= MYSQLI_CLIENT_COMPRESS; |
740 | } |
741 | if ( $this->getFlag( self::DBO_PERSISTENT ) ) { |
742 | $realServer = 'p:' . $realServer; |
743 | } |
744 | |
745 | if ( $this->utf8Mode ) { |
746 | // Tell the server we're communicating with it in UTF-8. |
747 | // This may engage various charset conversions. |
748 | $mysqli->options( MYSQLI_SET_CHARSET_NAME, 'utf8' ); |
749 | } else { |
750 | $mysqli->options( MYSQLI_SET_CHARSET_NAME, 'binary' ); |
751 | } |
752 | |
753 | $mysqli->options( MYSQLI_OPT_CONNECT_TIMEOUT, $this->connectTimeout ?: 3 ); |
754 | if ( $this->receiveTimeout ) { |
755 | $mysqli->options( MYSQLI_OPT_READ_TIMEOUT, $this->receiveTimeout ); |
756 | } |
757 | |
758 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal socket seems set when used |
759 | $ok = $mysqli->real_connect( $realServer, $user, $password, $db, $port, $socket, $flags ); |
760 | |
761 | return $ok ? $mysqli : null; |
762 | } |
763 | |
764 | protected function closeConnection() { |
765 | return ( $this->conn instanceof mysqli ) ? mysqli_close( $this->conn ) : true; |
766 | } |
767 | |
768 | protected function lastInsertId() { |
769 | return $this->sessionLastAutoRowId; |
770 | } |
771 | |
772 | protected function doHandleSessionLossPreconnect() { |
773 | // https://mariadb.com/kb/en/last_insert_id/ |
774 | $this->sessionLastAutoRowId = 0; |
775 | } |
776 | |
777 | public function insertId() { |
778 | if ( $this->lastEmulatedInsertId === null ) { |
779 | $conn = $this->getBindingHandle(); |
780 | // Note that mysqli::insert_id only reflects the last query statement |
781 | $this->lastEmulatedInsertId = (int)$conn->insert_id; |
782 | } |
783 | |
784 | return $this->lastEmulatedInsertId; |
785 | } |
786 | |
787 | /** |
788 | * @return int |
789 | */ |
790 | public function lastErrno() { |
791 | if ( $this->conn instanceof mysqli ) { |
792 | return $this->conn->errno; |
793 | } else { |
794 | return mysqli_connect_errno(); |
795 | } |
796 | } |
797 | |
798 | /** |
799 | * @param mysqli|null $conn Optional connection object |
800 | * @return string |
801 | */ |
802 | private function mysqlError( $conn = null ) { |
803 | if ( $conn === null ) { |
804 | return (string)mysqli_connect_error(); |
805 | } else { |
806 | return $conn->error; |
807 | } |
808 | } |
809 | |
810 | /** |
811 | * @param mixed $s |
812 | */ |
813 | private function mysqlRealEscapeString( $s ): string { |
814 | $conn = $this->getBindingHandle(); |
815 | |
816 | return $conn->real_escape_string( (string)$s ); |
817 | } |
818 | } |