207 lines
6.6 KiB
PHP
207 lines
6.6 KiB
PHP
<?php
|
|
/**
|
|
* This program is free software; you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation; either version 2 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License along
|
|
* with this program; if not, write to the Free Software Foundation, Inc.,
|
|
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
* http://www.gnu.org/copyleft/gpl.html
|
|
*
|
|
* @file
|
|
*/
|
|
namespace Wikimedia\Rdbms;
|
|
|
|
use Wikimedia\Rdbms\Platform\SQLPlatform;
|
|
|
|
/**
|
|
* This is to contain any regex on SQL work and get rid of them eventually
|
|
*
|
|
* This is a radioactive swamp and an extremely flawed and buggy last resort
|
|
* for when the information has not been provided via Query object.
|
|
* Bugs are to be expected in the regexes here.
|
|
*
|
|
* @ingroup Database
|
|
* @internal
|
|
* @since 1.41
|
|
*/
|
|
class QueryBuilderFromRawSql {
|
|
/** All the bits of QUERY_WRITE_* flags */
|
|
private const QUERY_CHANGE_MASK = (
|
|
SQLPlatform::QUERY_CHANGE_NONE |
|
|
SQLPlatform::QUERY_CHANGE_TRX |
|
|
SQLPlatform::QUERY_CHANGE_ROWS |
|
|
SQLPlatform::QUERY_CHANGE_SCHEMA |
|
|
SQLPlatform::QUERY_CHANGE_LOCKS
|
|
);
|
|
|
|
private const SCHEMA_CHANGE_VERBS = [
|
|
'CREATE',
|
|
'CREATE TEMPORARY',
|
|
'CREATE INDEX',
|
|
'CREATE DATABASE',
|
|
'ALTER',
|
|
'ALTER DATABASE',
|
|
'DROP',
|
|
'DROP INDEX',
|
|
'DROP DATABASE',
|
|
];
|
|
|
|
private const TRX_VERBS = [
|
|
'BEGIN',
|
|
'COMMIT',
|
|
'ROLLBACK',
|
|
'SAVEPOINT',
|
|
'RELEASE SAVEPOINT',
|
|
'ROLLBACK TO SAVEPOINT',
|
|
];
|
|
|
|
private static string $queryVerbRegex;
|
|
|
|
/**
|
|
* @param string $sql
|
|
* @param int $flags
|
|
* @param string $tablePrefix
|
|
* @return Query
|
|
*/
|
|
public static function buildQuery( string $sql, $flags, string $tablePrefix = '' ) {
|
|
$verb = self::getQueryVerb( $sql );
|
|
|
|
if ( ( $flags & self::QUERY_CHANGE_MASK ) == 0 ) {
|
|
$isWriteQuery = self::isWriteQuery( $sql );
|
|
if ( $isWriteQuery ) {
|
|
if ( in_array( $verb, self::SCHEMA_CHANGE_VERBS, true ) ) {
|
|
$flags |= SQLPlatform::QUERY_CHANGE_SCHEMA;
|
|
} else {
|
|
$flags |= SQLPlatform::QUERY_CHANGE_ROWS;
|
|
}
|
|
} else {
|
|
if ( in_array( $verb, self::TRX_VERBS, true ) ) {
|
|
$flags |= SQLPlatform::QUERY_CHANGE_TRX;
|
|
} else {
|
|
$flags |= SQLPlatform::QUERY_CHANGE_NONE;
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Query(
|
|
$sql,
|
|
$flags,
|
|
$verb,
|
|
self::getWriteTable( $sql, $tablePrefix )
|
|
);
|
|
}
|
|
|
|
private static function isWriteQuery( $rawSql ) {
|
|
// Treat SELECT queries without FOR UPDATE queries as non-writes. This matches
|
|
// how MySQL enforces read_only (FOR SHARE and LOCK IN SHADE MODE are allowed).
|
|
// Handle (SELECT ...) UNION (SELECT ...) queries in a similar fashion.
|
|
if ( preg_match( '/^\s*\(?SELECT\b/i', $rawSql ) ) {
|
|
return (bool)preg_match( '/\bFOR\s+UPDATE\)?\s*$/i', $rawSql );
|
|
}
|
|
// BEGIN and COMMIT queries are considered non-write queries here.
|
|
// Database backends and drivers (MySQL, MariaDB, php-mysqli) generally
|
|
// treat these as write queries, in that their results have "affected rows"
|
|
// as meta data as from writes, instead of "num rows" as from reads.
|
|
// But, we treat them as non-write queries because when reading data (from
|
|
// either replica or primary DB) we use transactions to enable repeatable-read
|
|
// snapshots, which ensures we get consistent results from the same snapshot
|
|
// for all queries within a request. Use cases:
|
|
// - Treating these as writes would trigger ChronologyProtector (see method doc).
|
|
// - We use this method to reject writes to replicas, but we need to allow
|
|
// use of transactions on replicas for read snapshots. This is fine given
|
|
// that transactions by themselves don't make changes, only actual writes
|
|
// within the transaction matter, which we still detect.
|
|
return !preg_match(
|
|
'/^\s*(BEGIN|ROLLBACK|COMMIT|SAVEPOINT|RELEASE|SET|SHOW|EXPLAIN|USE)\b/i',
|
|
$rawSql
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param string $sql SQL query
|
|
* @return string
|
|
*/
|
|
private static function getQueryVerb( $sql ) {
|
|
// @phan-suppress-next-line PhanRedundantCondition https://github.com/phan/phan/issues/4720
|
|
if ( !isset( self::$queryVerbRegex ) ) {
|
|
$multiwordVerbsRegex = implode( '|', array_map(
|
|
fn ( $words ) => str_replace( ' ', '\s+', $words ),
|
|
Query::MULTIWORD_VERBS
|
|
) );
|
|
self::$queryVerbRegex = "/^\s*($multiwordVerbsRegex|[a-z]+)/i";
|
|
}
|
|
return preg_match( self::$queryVerbRegex, $sql, $m ) ? strtoupper( $m[1] ) : '';
|
|
}
|
|
|
|
/**
|
|
* @param string $sql
|
|
* @param string $tablePrefix
|
|
* @return string|null
|
|
*/
|
|
private static function getWriteTable( $sql, $tablePrefix ) {
|
|
// Regex for basic queries that can create/change/drop temporary tables.
|
|
// For simplicity, this only looks for tables with sensible alphanumeric names.
|
|
// Temporary tables only need simple programming names anyway.
|
|
$regex = <<<REGEX
|
|
/^
|
|
(?:
|
|
(?:INSERT|REPLACE)\s+(?:\w+\s+)*?INTO
|
|
| UPDATE(?:\s+OR\s+\w+|\s+IGNORE|\s+ONLY)?
|
|
| DELETE\s+(?:\w+\s+)*?FROM(?:\s+ONLY)?
|
|
| CREATE\s+(?:TEMPORARY\s+)?TABLE(?:\s+IF\s+NOT\s+EXISTS)?
|
|
| DROP\s+(?:TEMPORARY\s+)?TABLE(?:\s+IF\s+EXISTS)?
|
|
| TRUNCATE\s+(?:TEMPORARY\s+)?TABLE
|
|
| ALTER\s+TABLE
|
|
) \s+
|
|
(\w+|`\w+`|'\w+'|"\w+")
|
|
/ix
|
|
REGEX;
|
|
if ( preg_match( $regex, $sql, $m ) ) {
|
|
$tableName = trim( $m[1], "\"'`" );
|
|
if ( str_starts_with( $tableName, $tablePrefix ) ) {
|
|
$tableName = substr( $tableName, strlen( $tablePrefix ) );
|
|
}
|
|
return $tableName;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Removes most variables from an SQL query and replaces them with X or N for numbers.
|
|
* It's only slightly flawed. Don't use for anything important.
|
|
*
|
|
* @param string $sql A SQL Query
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function generalizeSQL( $sql ) {
|
|
# This does the same as the regexp below would do, but in such a way
|
|
# as to avoid crashing php on some large strings.
|
|
# $sql = preg_replace( "/'([^\\\\']|\\\\.)*'|\"([^\\\\\"]|\\\\.)*\"/", "'X'", $sql );
|
|
|
|
$sql = str_replace( "\\\\", '', $sql );
|
|
$sql = str_replace( "\\'", '', $sql );
|
|
$sql = str_replace( "\\\"", '', $sql );
|
|
$sql = preg_replace( "/'.*'/s", "'X'", $sql );
|
|
$sql = preg_replace( '/".*"/s', "'X'", $sql );
|
|
|
|
# All newlines, tabs, etc replaced by single space
|
|
$sql = preg_replace( '/\s+/', ' ', $sql );
|
|
|
|
# All numbers => N,
|
|
# except the ones surrounded by characters, e.g. l10n
|
|
$sql = preg_replace( '/-?\d++(,-?\d++)+/', 'N,...,N', $sql );
|
|
$sql = preg_replace( '/(?<![a-zA-Z])-?\d+(?![a-zA-Z])/', 'N', $sql );
|
|
|
|
return $sql;
|
|
}
|
|
}
|