Commit 3e9a5a53 authored by catch's avatar catch
Browse files

Issue #2986452 by alexpott, andypost, kostyashupenko, daffie, kriboogh,...

Issue #2986452 by alexpott, andypost, kostyashupenko, daffie, kriboogh, larowlan, kristiaanvandeneynde, chrisrockwell, bleen, nevergone: Database reserved keywords need to be quoted as per the ANSI standard. Also resolves https://www.drupal.org/project/drupal/issues/371
parent 8a23417a
......@@ -264,7 +264,10 @@ protected function getTableIndexes(Connection $connection, $table, &$definition)
* The schema definition to modify.
*/
protected function getTableCollation(Connection $connection, $table, &$definition) {
$query = $connection->query("SHOW TABLE STATUS LIKE '{" . $table . "}'");
// Remove identifier quotes from the table name. See
// \Drupal\Core\Database\Driver\mysql\Connection::identifierQuote().
$table = trim($connection->prefixTables('{' . $table . '}'), '"');
$query = $connection->query("SHOW TABLE STATUS WHERE NAME = :table_name", [':table_name' => $table]);
$data = $query->fetchAssoc();
// Map the collation to a character set. For example, 'utf8mb4_general_ci'
......
......@@ -144,9 +144,32 @@ abstract class Connection {
* List of escaped database, table, and field names, keyed by unescaped names.
*
* @var array
*
* @deprecated in drupal:9.0.0 and is removed from drupal:10.0.0. This is no
* longer used. Use \Drupal\Core\Database\Connection::$escapedTables or
* \Drupal\Core\Database\Connection::$escapedFields instead.
*
* @see https://www.drupal.org/node/2986894
*/
protected $escapedNames = [];
/**
* List of escaped table names, keyed by unescaped names.
*
* @var array
*/
protected $escapedTables = [];
/**
* List of escaped field names, keyed by unescaped names.
*
* There are cases in which escapeField() is called on an empty string. In
* this case it should always return an empty string.
*
* @var array
*/
protected $escapedFields = ["" => ""];
/**
* List of escaped aliases names, keyed by unescaped aliases.
*
......@@ -255,6 +278,11 @@ public function destroy() {
* additional queries (such as inserting new user accounts). In rare cases,
* such as creating an SQL function, a ; is needed and can be allowed by
* changing this option to TRUE.
* - allow_square_brackets: By default, queries which contain square brackets
* will have them replaced with the identifier quote character for the
* database type. In rare cases, such as creating an SQL function, []
* characters might be needed and can be allowed by changing this option to
* TRUE.
*
* @return array
* An array of default query options.
......@@ -265,6 +293,7 @@ protected function defaultOptions() {
'return' => Database::RETURN_STATEMENT,
'throw_exception' => TRUE,
'allow_delimiter_in_query' => FALSE,
'allow_square_brackets' => FALSE,
];
}
......@@ -299,6 +328,7 @@ protected function setPrefix($prefix) {
$this->prefixes = ['default' => $prefix];
}
$identifier_quote = $this->identifierQuote();
// Set up variables for use in prefixTables(). Replace table-specific
// prefixes first.
$this->prefixSearch = [];
......@@ -306,14 +336,20 @@ protected function setPrefix($prefix) {
foreach ($this->prefixes as $key => $val) {
if ($key != 'default') {
$this->prefixSearch[] = '{' . $key . '}';
$this->prefixReplace[] = $val . $key;
// $val can point to another database like 'database.users'. In this
// instance we need to quote the identifiers correctly.
$val = str_replace('.', $identifier_quote . '.' . $identifier_quote, $val);
$this->prefixReplace[] = $identifier_quote . $val . $key . $identifier_quote;
}
}
// Then replace remaining tables with the default prefix.
$this->prefixSearch[] = '{';
$this->prefixReplace[] = $this->prefixes['default'];
// $this->prefixes['default'] can point to another database like
// 'other_db.'. In this instance we need to quote the identifiers correctly.
// For example, "other_db"."PREFIX_table_name".
$this->prefixReplace[] = $identifier_quote . str_replace('.', $identifier_quote . '.' . $identifier_quote, $this->prefixes['default']);
$this->prefixSearch[] = '}';
$this->prefixReplace[] = '';
$this->prefixReplace[] = $identifier_quote;
// Set up a map of prefixed => un-prefixed tables.
foreach ($this->prefixes as $table_name => $prefix) {
......@@ -323,6 +359,20 @@ protected function setPrefix($prefix) {
}
}
/**
* Returns the identifier quote character for the database type.
*
* The ANSI SQL standard identifier quote character is a double quotation
* mark.
*
* @return string
* The identifier quote character for the database type.
*/
protected function identifierQuote() {
@trigger_error('In drupal:10.0.0 this method will be abstract and contrib and custom drivers will have to implement it. See https://www.drupal.org/node/2986894', E_USER_DEPRECATED);
return '';
}
/**
* Appends a database prefix to all tables in a query.
*
......@@ -341,6 +391,30 @@ public function prefixTables($sql) {
return str_replace($this->prefixSearch, $this->prefixReplace, $sql);
}
/**
* Quotes all identifiers in a query.
*
* Queries sent to Drupal should wrap all unquoted identifiers in square
* brackets. This function searches for this syntax and replaces them with the
* database specific identifier. In ANSI SQL this a double quote.
*
* Note that :variable[] is used to denote array arguments but
* Connection::expandArguments() is always called first.
*
* @param string $sql
* A string containing a partial or entire SQL query.
*
* @return string
* The string containing a partial or entire SQL query with all identifiers
* quoted.
*
* @internal
* This method should only be called by database API code.
*/
public function quoteIdentifiers($sql) {
return str_replace(['[', ']'], $this->identifierQuote(), $sql);
}
/**
* Find the prefix for a table.
*
......@@ -387,18 +461,25 @@ public function getFullQualifiedTableName($table) {
/**
* Prepares a query string and returns the prepared statement.
*
* This method caches prepared statements, reusing them when
* possible. It also prefixes tables names enclosed in curly-braces.
* This method caches prepared statements, reusing them when possible. It also
* prefixes tables names enclosed in curly-braces and, optionally, quotes
* identifiers enclosed in square brackets.
*
* @param $query
* The query string as SQL, with curly-braces surrounding the
* table names.
* @param bool $quote_identifiers
* (optional) Quote any identifiers enclosed in square brackets. Defaults to
* TRUE.
*
* @return \Drupal\Core\Database\StatementInterface
* A PDO prepared statement ready for its execute() method.
*/
public function prepareQuery($query) {
public function prepareQuery($query, $quote_identifiers = TRUE) {
$query = $this->prefixTables($query);
if ($quote_identifiers) {
$query = $this->quoteIdentifiers($query);
}
return $this->connection->prepare($query);
}
......@@ -494,7 +575,10 @@ public function getLogger() {
* A table prefix-parsed string for the sequence name.
*/
public function makeSequenceName($table, $field) {
return $this->prefixTables('{' . $table . '}_' . $field . '_seq');
$sequence_name = $this->prefixTables('{' . $table . '}_' . $field . '_seq');
// Remove identifier quotes as we are constructing a new name from a
// prefixed and quoted table name.
return str_replace($this->identifierQuote(), '', $sequence_name);
}
/**
......@@ -628,7 +712,7 @@ public function query($query, array $args = [], $options = []) {
if (strpos($query, ';') !== FALSE && empty($options['allow_delimiter_in_query'])) {
throw new \InvalidArgumentException('; is not supported in SQL strings. Use only one statement at a time.');
}
$stmt = $this->prepareQuery($query);
$stmt = $this->prepareQuery($query, !$options['allow_square_brackets']);
$stmt->execute($args, $options);
}
......@@ -956,30 +1040,32 @@ public function schema() {
* The sanitized database name.
*/
public function escapeDatabase($database) {
if (!isset($this->escapedNames[$database])) {
$this->escapedNames[$database] = preg_replace('/[^A-Za-z0-9_.]+/', '', $database);
}
return $this->escapedNames[$database];
$database = preg_replace('/[^A-Za-z0-9_]+/', '', $database);
return $this->identifierQuote() . $database . $this->identifierQuote();
}
/**
* Escapes a table name string.
*
* Force all table names to be strictly alphanumeric-plus-underscore.
* For some database drivers, it may also wrap the table name in
* database-specific escape characters.
* Database drivers should never wrap the table name in database-specific
* escape characters. This is done in Connection::prefixTables(). The
* database-specific escape characters are added in Connection::setPrefix().
*
* @param string $table
* An unsanitized table name.
*
* @return string
* The sanitized table name.
*
* @see \Drupal\Core\Database\Connection::prefixTables()
* @see \Drupal\Core\Database\Connection::setPrefix()
*/
public function escapeTable($table) {
if (!isset($this->escapedNames[$table])) {
$this->escapedNames[$table] = preg_replace('/[^A-Za-z0-9_.]+/', '', $table);
if (!isset($this->escapedTables[$table])) {
$this->escapedTables[$table] = preg_replace('/[^A-Za-z0-9_.]+/', '', $table);
}
return $this->escapedNames[$table];
return $this->escapedTables[$table];
}
/**
......@@ -996,10 +1082,14 @@ public function escapeTable($table) {
* The sanitized field name.
*/
public function escapeField($field) {
if (!isset($this->escapedNames[$field])) {
$this->escapedNames[$field] = preg_replace('/[^A-Za-z0-9_.]+/', '', $field);
if (!isset($this->escapedFields[$field])) {
$escaped = preg_replace('/[^A-Za-z0-9_.]+/', '', $field);
$identifier_quote = $this->identifierQuote();
// Sometimes fields have the format table_alias.field. In such cases
// both identifiers should be quoted, for example, "table_alias"."field".
$this->escapedFields[$field] = $identifier_quote . str_replace('.', $identifier_quote . '.' . $identifier_quote, $escaped) . $identifier_quote;
}
return $this->escapedNames[$field];
return $this->escapedFields[$field];
}
/**
......@@ -1018,7 +1108,7 @@ public function escapeField($field) {
*/
public function escapeAlias($field) {
if (!isset($this->escapedAliases[$field])) {
$this->escapedAliases[$field] = preg_replace('/[^A-Za-z0-9_]+/', '', $field);
$this->escapedAliases[$field] = $this->identifierQuote() . preg_replace('/[^A-Za-z0-9_]+/', '', $field) . $this->identifierQuote();
}
return $this->escapedAliases[$field];
}
......
......@@ -63,277 +63,6 @@ class Connection extends DatabaseConnection {
*/
const MIN_MAX_ALLOWED_PACKET = 1024;
/**
* The list of MySQL reserved key words.
*
* @link https://dev.mysql.com/doc/refman/8.0/en/keywords.html
*/
private $reservedKeyWords = [
'accessible',
'add',
'admin',
'all',
'alter',
'analyze',
'and',
'as',
'asc',
'asensitive',
'before',
'between',
'bigint',
'binary',
'blob',
'both',
'by',
'call',
'cascade',
'case',
'change',
'char',
'character',
'check',
'collate',
'column',
'condition',
'constraint',
'continue',
'convert',
'create',
'cross',
'cube',
'cume_dist',
'current_date',
'current_time',
'current_timestamp',
'current_user',
'cursor',
'database',
'databases',
'day_hour',
'day_microsecond',
'day_minute',
'day_second',
'dec',
'decimal',
'declare',
'default',
'delayed',
'delete',
'dense_rank',
'desc',
'describe',
'deterministic',
'distinct',
'distinctrow',
'div',
'double',
'drop',
'dual',
'each',
'else',
'elseif',
'empty',
'enclosed',
'escaped',
'except',
'exists',
'exit',
'explain',
'false',
'fetch',
'first_value',
'float',
'float4',
'float8',
'for',
'force',
'foreign',
'from',
'fulltext',
'function',
'generated',
'get',
'grant',
'group',
'grouping',
'groups',
'having',
'high_priority',
'hour_microsecond',
'hour_minute',
'hour_second',
'if',
'ignore',
'in',
'index',
'infile',
'inner',
'inout',
'insensitive',
'insert',
'int',
'int1',
'int2',
'int3',
'int4',
'int8',
'integer',
'interval',
'into',
'io_after_gtids',
'io_before_gtids',
'is',
'iterate',
'join',
'json_table',
'key',
'keys',
'kill',
'lag',
'last_value',
'lead',
'leading',
'leave',
'left',
'like',
'limit',
'linear',
'lines',
'load',
'localtime',
'localtimestamp',
'lock',
'long',
'longblob',
'longtext',
'loop',
'low_priority',
'master_bind',
'master_ssl_verify_server_cert',
'match',
'maxvalue',
'mediumblob',
'mediumint',
'mediumtext',
'middleint',
'minute_microsecond',
'minute_second',
'mod',
'modifies',
'natural',
'not',
'no_write_to_binlog',
'nth_value',
'ntile',
'null',
'numeric',
'of',
'on',
'optimize',
'optimizer_costs',
'option',
'optionally',
'or',
'order',
'out',
'outer',
'outfile',
'over',
'partition',
'percent_rank',
'persist',
'persist_only',
'precision',
'primary',
'procedure',
'purge',
'range',
'rank',
'read',
'reads',
'read_write',
'real',
'recursive',
'references',
'regexp',
'release',
'rename',
'repeat',
'replace',
'require',
'resignal',
'restrict',
'return',
'revoke',
'right',
'rlike',
'row',
'rows',
'row_number',
'schema',
'schemas',
'second_microsecond',
'select',
'sensitive',
'separator',
'set',
'show',
'signal',
'smallint',
'spatial',
'specific',
'sql',
'sqlexception',
'sqlstate',
'sqlwarning',
'sql_big_result',
'sql_calc_found_rows',
'sql_small_result',
'ssl',
'starting',
'stored',
'straight_join',
'system',
'table',
'terminated',
'then',
'tinyblob',
'tinyint',
'tinytext',
'to',
'trailing',
'trigger',
'true',
'undo',
'union',
'unique',
'unlock',
'unsigned',
'update',
'usage',
'use',
'using',
'utc_date',
'utc_time',
'utc_timestamp',
'values',
'varbinary',
'varchar',
'varcharacter',
'varying',
'virtual',
'when',
'where',
'while',
'window',
'with',
'write',
'xor',
'year_month',
'zerofill',
];
/**
* Constructs a Connection object.
*/
......@@ -467,49 +196,6 @@ public static function open(array &$connection_options = []) {
return $pdo;
}
/**
* {@inheritdoc}
*/
public function escapeField($field) {
$field = parent::escapeField($field);
return $this->quoteIdentifier($field);
}
/**
* {@inheritdoc}
*/
public function escapeAlias($field) {
// Quote fields so that MySQL reserved words like 'function' can be used
// as aliases.
$field = parent::escapeAlias($field);
return $this->quoteIdentifier($field);
}
/**
* Quotes an identifier if it matches a MySQL reserved keyword.
*
* @param string $identifier
* The field to check.
*
* @return string
* The identifier, quoted if it matches a MySQL reserved keyword.
*/
private function quoteIdentifier($identifier) {
// Quote identifiers so that MySQL reserved words like 'function' can be
// used as column names. Sometimes the 'table.column_name' format is passed
// in. For example,
// \Drupal\Core\Entity\Sql\SqlContentEntityStorage::buildQuery() adds a
// condition on "base.uid" while loading user entities.
if (strpos($identifier, '.') !== FALSE) {
list($table, $identifier) = explode('.', $identifier, 2);
}
if (in_array(strtolower($identifier), $this->reservedKeyWords, TRUE)) {
// Quote the string for MySQL reserved keywords.
$identifier = '"' . $identifier . '"';
}
return isset($table) ? $table . '.' . $identifier : $identifier;
}
/**
* {@inheritdoc}
*/
......@@ -542,6 +228,15 @@ public function queryTemporary($query, array $args = [], array $options = []) {
return $tablename;
}
/**
* {@inheritdoc}
*/
protected function identifierQuote() {
// The database is using the ANSI option on set up so use ANSI quotes and
// not MySQL's custom backtick quote.
return '"';
}