first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
name: MySQL
type: module
description: 'Provides the MySQL database driver.'
package: Core
# version: VERSION
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,84 @@
<?php
/**
* @file
* Install, update and uninstall functions for the mysql module.
*/
use Drupal\Core\Database\Database;
use Drupal\Core\Render\Markup;
/**
* Implements hook_requirements().
*/
function mysql_requirements($phase) {
$requirements = [];
if ($phase === 'runtime') {
// Test with MySql databases.
if (Database::isActiveConnection()) {
$connection = Database::getConnection();
// Only show requirements when MySQL is the default database connection.
if (!($connection->driver() === 'mysql' && $connection->getProvider() === 'mysql')) {
return [];
}
$query = 'SELECT @@SESSION.tx_isolation';
// The database variable "tx_isolation" has been removed in MySQL v8.0.3 and
// has been replaced by "transaction_isolation".
// @see https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_tx_isolation
// @see https://dev.mysql.com/doc/refman/8.0/en/added-deprecated-removed.html
if (!$connection->isMariaDb() && version_compare($connection->version(), '8.0.2-AnyName', '>')) {
$query = 'SELECT @@SESSION.transaction_isolation';
}
$isolation_level = $connection->query($query)->fetchField();
$tables_missing_primary_key = [];
$tables = $connection->schema()->findTables('%');
foreach ($tables as $table) {
$primary_key_column = Database::getConnection()->query("SHOW KEYS FROM {" . $table . "} WHERE Key_name = 'PRIMARY'")->fetchAllAssoc('Column_name');
if (empty($primary_key_column)) {
$tables_missing_primary_key[] = $table;
}
}
$description = [];
if ($isolation_level == 'READ-COMMITTED') {
if (empty($tables_missing_primary_key)) {
$severity_level = REQUIREMENT_OK;
}
else {
$severity_level = REQUIREMENT_ERROR;
}
}
else {
if ($isolation_level == 'REPEATABLE-READ') {
$severity_level = REQUIREMENT_WARNING;
}
else {
$severity_level = REQUIREMENT_ERROR;
$description[] = t('This is not supported by Drupal.');
}
$description[] = t('The recommended level for Drupal is "READ COMMITTED".');
}
if (!empty($tables_missing_primary_key)) {
$description[] = t('For this to work correctly, all tables must have a primary key. The following table(s) do not have a primary key: @tables.', ['@tables' => implode(', ', $tables_missing_primary_key)]);
}
$description[] = t('See the <a href=":performance_doc">setting MySQL transaction isolation level</a> page for more information.', [
':performance_doc' => 'https://www.drupal.org/docs/system-requirements/setting-the-mysql-transaction-isolation-level',
]);
$requirements['mysql_transaction_level'] = [
'title' => t('Transaction isolation level'),
'severity' => $severity_level,
'value' => $isolation_level,
'description' => Markup::create(implode(' ', $description)),
];
}
}
return $requirements;
}

22
core/modules/mysql/mysql.module Executable file
View File

@@ -0,0 +1,22 @@
<?php
/**
* @file
* The MySQL module provides the connection between Drupal and a MySQL, MariaDB or equivalent database.
*/
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Implements hook_help().
*/
function mysql_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.mysql':
$output = '';
$output .= '<h2>' . t('About') . '</h2>';
$output .= '<p>' . t('The MySQL module provides the connection between Drupal and a MySQL, MariaDB or equivalent database. For more information, see the <a href=":mysql">online documentation for the MySQL module</a>.', [':mysql' => 'https://www.drupal.org/docs/core-modules-and-themes/core-modules/mysql-module']) . '</p>';
return $output;
}
}

View File

@@ -0,0 +1,4 @@
services:
mysql.views.cast_sql:
class: Drupal\mysql\Plugin\views\query\MysqlCastSql
public: false

View File

@@ -0,0 +1,481 @@
<?php
namespace Drupal\mysql\Driver\Database\mysql;
use Drupal\Core\Database\Connection as DatabaseConnection;
use Drupal\Core\Database\Database;
use Drupal\Core\Database\DatabaseAccessDeniedException;
use Drupal\Core\Database\DatabaseConnectionRefusedException;
use Drupal\Core\Database\DatabaseException;
use Drupal\Core\Database\DatabaseNotFoundException;
use Drupal\Core\Database\Query\Condition;
use Drupal\Core\Database\StatementWrapperIterator;
use Drupal\Core\Database\SupportsTemporaryTablesInterface;
use Drupal\Core\Database\Transaction\TransactionManagerInterface;
/**
* @addtogroup database
* @{
*/
/**
* MySQL implementation of \Drupal\Core\Database\Connection.
*/
class Connection extends DatabaseConnection implements SupportsTemporaryTablesInterface {
/**
* Error code for "Unknown database" error.
*/
const DATABASE_NOT_FOUND = 1049;
/**
* Error code for "Access denied" error.
*/
const ACCESS_DENIED = 1045;
/**
* Error code for "Connection refused".
*/
const CONNECTION_REFUSED = 2002;
/**
* {@inheritdoc}
*/
protected $statementWrapperClass = StatementWrapperIterator::class;
/**
* Flag to indicate if the cleanup function in __destruct() should run.
*
* @var bool
*
* @deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. There's no
* replacement.
*
* @see https://www.drupal.org/node/3349345
*/
protected $needsCleanup = FALSE;
/**
* Stores the server version after it has been retrieved from the database.
*
* @var string
*
* @see \Drupal\mysql\Driver\Database\mysql\Connection::version
*/
private $serverVersion;
/**
* The minimal possible value for the max_allowed_packet setting of MySQL.
*
* @link https://mariadb.com/kb/en/mariadb/server-system-variables/#max_allowed_packet
* @link https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_max_allowed_packet
*
* @var int
*/
const MIN_MAX_ALLOWED_PACKET = 1024;
/**
* {@inheritdoc}
*/
protected $identifierQuotes = ['"', '"'];
/**
* {@inheritdoc}
*/
public function __construct(\PDO $connection, array $connection_options) {
// If the SQL mode doesn't include 'ANSI_QUOTES' (explicitly or via a
// combination mode), then MySQL doesn't interpret a double quote as an
// identifier quote, in which case use the non-ANSI-standard backtick.
//
// Because we still support MySQL 5.7, check for the deprecated combination
// modes as well.
//
// @see https://dev.mysql.com/doc/refman/5.7/en/sql-mode.html#sqlmode_ansi_quotes
$ansi_quotes_modes = ['ANSI_QUOTES', 'ANSI', 'DB2', 'MAXDB', 'MSSQL', 'ORACLE', 'POSTGRESQL'];
$is_ansi_quotes_mode = FALSE;
if (isset($connection_options['init_commands']['sql_mode'])) {
foreach ($ansi_quotes_modes as $mode) {
// None of the modes in $ansi_quotes_modes are substrings of other modes
// that are not in $ansi_quotes_modes, so a simple stripos() does not
// return false positives.
if (stripos($connection_options['init_commands']['sql_mode'], $mode) !== FALSE) {
$is_ansi_quotes_mode = TRUE;
break;
}
}
}
if ($this->identifierQuotes === ['"', '"'] && !$is_ansi_quotes_mode) {
$this->identifierQuotes = ['`', '`'];
}
parent::__construct($connection, $connection_options);
}
/**
* {@inheritdoc}
*/
public static function open(array &$connection_options = []) {
// The DSN should use either a socket or a host/port.
if (isset($connection_options['unix_socket'])) {
$dsn = 'mysql:unix_socket=' . $connection_options['unix_socket'];
}
else {
// Default to TCP connection on port 3306.
$dsn = 'mysql:host=' . $connection_options['host'] . ';port=' . (empty($connection_options['port']) ? 3306 : $connection_options['port']);
}
// Character set is added to dsn to ensure PDO uses the proper character
// set when escaping. This has security implications. See
// https://www.drupal.org/node/1201452 for further discussion.
$dsn .= ';charset=utf8mb4';
if (!empty($connection_options['database'])) {
$dsn .= ';dbname=' . $connection_options['database'];
}
// Allow PDO options to be overridden.
$connection_options += [
'pdo' => [],
];
$connection_options['pdo'] += [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
// So we don't have to mess around with cursors and unbuffered queries by default.
\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => TRUE,
// Make sure MySQL returns all matched rows on update queries including
// rows that actually didn't have to be updated because the values didn't
// change. This matches common behavior among other database systems.
\PDO::MYSQL_ATTR_FOUND_ROWS => TRUE,
// Because MySQL's prepared statements skip the query cache, because it's dumb.
\PDO::ATTR_EMULATE_PREPARES => TRUE,
// Limit SQL to a single statement like mysqli.
\PDO::MYSQL_ATTR_MULTI_STATEMENTS => FALSE,
// Convert numeric values to strings when fetching. In PHP 8.1,
// \PDO::ATTR_EMULATE_PREPARES now behaves the same way as non emulated
// prepares and returns integers. See https://externals.io/message/113294
// for further discussion.
\PDO::ATTR_STRINGIFY_FETCHES => TRUE,
];
try {
$pdo = new \PDO($dsn, $connection_options['username'], $connection_options['password'], $connection_options['pdo']);
}
catch (\PDOException $e) {
switch ($e->getCode()) {
case static::CONNECTION_REFUSED:
if (isset($connection_options['unix_socket'])) {
// Show message for socket connection via 'unix_socket' option.
$message = 'Drupal is configured to connect to the database server via a socket, but the socket file could not be found.';
$message .= ' This message normally means that there is no MySQL server running on the system or that you are using an incorrect Unix socket file name when trying to connect to the server.';
throw new DatabaseConnectionRefusedException($e->getMessage() . ' [Tip: ' . $message . '] ', $e->getCode(), $e);
}
if (isset($connection_options['host']) && in_array(strtolower($connection_options['host']), ['', 'localhost'], TRUE)) {
// Show message for socket connection via 'host' option.
$message = 'Drupal was attempting to connect to the database server via a socket, but the socket file could not be found.';
$message .= ' A Unix socket file is used if you do not specify a host name or if you specify the special host name localhost.';
$message .= ' To connect via TPC/IP use an IP address (127.0.0.1 for IPv4) instead of "localhost".';
$message .= ' This message normally means that there is no MySQL server running on the system or that you are using an incorrect Unix socket file name when trying to connect to the server.';
throw new DatabaseConnectionRefusedException($e->getMessage() . ' [Tip: ' . $message . '] ', $e->getCode(), $e);
}
// Show message for TCP/IP connection.
$message = 'This message normally means that there is no MySQL server running on the system or that you are using an incorrect host name or port number when trying to connect to the server.';
$message .= ' You should also check that the TCP/IP port you are using has not been blocked by a firewall or port blocking service.';
throw new DatabaseConnectionRefusedException($e->getMessage() . ' [Tip: ' . $message . '] ', $e->getCode(), $e);
case static::DATABASE_NOT_FOUND:
throw new DatabaseNotFoundException($e->getMessage(), $e->getCode(), $e);
case static::ACCESS_DENIED:
throw new DatabaseAccessDeniedException($e->getMessage(), $e->getCode(), $e);
default:
throw $e;
}
}
// Force MySQL to use the UTF-8 character set. Also set the collation, if a
// certain one has been set; otherwise, MySQL defaults to
// 'utf8mb4_general_ci' (MySQL 5) or 'utf8mb4_0900_ai_ci' (MySQL 8) for
// utf8mb4.
if (!empty($connection_options['collation'])) {
$pdo->exec('SET NAMES utf8mb4 COLLATE ' . $connection_options['collation']);
}
else {
$pdo->exec('SET NAMES utf8mb4');
}
// Set MySQL init_commands if not already defined. Default Drupal's MySQL
// behavior to conform more closely to SQL standards. This allows Drupal
// to run almost seamlessly on many different kinds of database systems.
// These settings force MySQL to behave the same as postgresql, or sqlite
// in regards to syntax interpretation and invalid data handling. See
// https://www.drupal.org/node/344575 for further discussion. Also, as MySQL
// 5.5 changed the meaning of TRADITIONAL we need to spell out the modes one
// by one.
$connection_options += [
'init_commands' => [],
];
$connection_options['init_commands'] += [
'sql_mode' => "SET sql_mode = 'ANSI,TRADITIONAL'",
];
if (!empty($connection_options['isolation_level'])) {
$connection_options['init_commands'] += [
'isolation_level' => 'SET SESSION TRANSACTION ISOLATION LEVEL ' . strtoupper($connection_options['isolation_level']),
];
}
// Execute initial commands.
foreach ($connection_options['init_commands'] as $sql) {
$pdo->exec($sql);
}
return $pdo;
}
/**
* {@inheritdoc}
*/
public function __destruct() {
if ($this->needsCleanup) {
$this->nextIdDelete();
}
parent::__destruct();
}
public function queryRange($query, $from, $count, array $args = [], array $options = []) {
return $this->query($query . ' LIMIT ' . (int) $from . ', ' . (int) $count, $args, $options);
}
/**
* {@inheritdoc}
*/
public function queryTemporary($query, array $args = [], array $options = []) {
$tablename = 'db_temporary_' . uniqid();
$this->query('CREATE TEMPORARY TABLE {' . $tablename . '} Engine=MEMORY ' . $query, $args, $options);
return $tablename;
}
public function driver() {
return 'mysql';
}
/**
* {@inheritdoc}
*/
public function version() {
if ($this->isMariaDb()) {
return $this->getMariaDbVersionMatch();
}
return $this->getServerVersion();
}
/**
* Determines whether the MySQL distribution is MariaDB or not.
*
* @return bool
* Returns TRUE if the distribution is MariaDB, or FALSE if not.
*/
public function isMariaDb(): bool {
return (bool) $this->getMariaDbVersionMatch();
}
/**
* Gets the MariaDB portion of the server version.
*
* @return string
* The MariaDB portion of the server version if present, or NULL if not.
*/
protected function getMariaDbVersionMatch(): ?string {
// MariaDB may prefix its version string with '5.5.5-', which should be
// ignored.
// @see https://github.com/MariaDB/server/blob/f6633bf058802ad7da8196d01fd19d75c53f7274/include/mysql_com.h#L42.
$regex = '/^(?:5\.5\.5-)?(\d+\.\d+\.\d+.*-mariadb.*)/i';
preg_match($regex, $this->getServerVersion(), $matches);
return (empty($matches[1])) ? NULL : $matches[1];
}
/**
* Gets the server version.
*
* @return string
* The PDO server version.
*/
protected function getServerVersion(): string {
if (!$this->serverVersion) {
$this->serverVersion = $this->query('SELECT VERSION()')->fetchField();
}
return $this->serverVersion;
}
public function databaseType() {
return 'mysql';
}
/**
* Overrides \Drupal\Core\Database\Connection::createDatabase().
*
* @param string $database
* The name of the database to create.
*
* @throws \Drupal\Core\Database\DatabaseNotFoundException
*/
public function createDatabase($database) {
// Escape the database name.
$database = Database::getConnection()->escapeDatabase($database);
try {
// Create the database and set it as active.
$this->connection->exec("CREATE DATABASE $database");
$this->connection->exec("USE $database");
}
catch (\Exception $e) {
throw new DatabaseNotFoundException($e->getMessage());
}
}
public function mapConditionOperator($operator) {
// We don't want to override any of the defaults.
return NULL;
}
/**
* {@inheritdoc}
*/
public function nextId($existing_id = 0) {
@trigger_error('Drupal\Core\Database\Connection::nextId() is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Modules should use instead the keyvalue storage for the last used id. See https://www.drupal.org/node/3349345', E_USER_DEPRECATED);
$this->query('INSERT INTO {sequences} () VALUES ()');
$new_id = $this->lastInsertId();
// This should only happen after an import or similar event.
if ($existing_id >= $new_id) {
// If we INSERT a value manually into the sequences table, on the next
// INSERT, MySQL will generate a larger value. However, there is no way
// of knowing whether this value already exists in the table. MySQL
// provides an INSERT IGNORE which would work, but that can mask problems
// other than duplicate keys. Instead, we use INSERT ... ON DUPLICATE KEY
// UPDATE in such a way that the UPDATE does not do anything. This way,
// duplicate keys do not generate errors but everything else does.
$this->query('INSERT INTO {sequences} (value) VALUES (:value) ON DUPLICATE KEY UPDATE value = value', [':value' => $existing_id]);
$this->query('INSERT INTO {sequences} () VALUES ()');
$new_id = $this->lastInsertId();
}
$this->needsCleanup = TRUE;
return $new_id;
}
public function nextIdDelete() {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Modules should use instead the keyvalue storage for the last used id. See https://www.drupal.org/node/3349345', E_USER_DEPRECATED);
// While we want to clean up the table to keep it up from occupying too
// much storage and memory, we must keep the highest value in the table
// because InnoDB uses an in-memory auto-increment counter as long as the
// server runs. When the server is stopped and restarted, InnoDB
// re-initializes the counter for each table for the first INSERT to the
// table based solely on values from the table so deleting all values would
// be a problem in this case. Also, TRUNCATE resets the auto increment
// counter.
try {
$max_id = $this->query('SELECT MAX(value) FROM {sequences}')->fetchField();
// We know we are using MySQL here, no need for the slower ::delete().
$this->query('DELETE FROM {sequences} WHERE value < :value', [':value' => $max_id]);
}
// During testing, this function is called from shutdown with the
// simpletest prefix stored in $this->connection, and those tables are gone
// by the time shutdown is called so we need to ignore the database
// errors. There is no problem with completely ignoring errors here: if
// these queries fail, the sequence will work just fine, just use a bit
// more database storage and memory.
catch (DatabaseException $e) {
}
}
/**
* {@inheritdoc}
*/
public function exceptionHandler() {
return new ExceptionHandler();
}
/**
* {@inheritdoc}
*/
public function select($table, $alias = NULL, array $options = []) {
return new Select($this, $table, $alias, $options);
}
/**
* {@inheritdoc}
*/
public function insert($table, array $options = []) {
return new Insert($this, $table, $options);
}
/**
* {@inheritdoc}
*/
public function merge($table, array $options = []) {
return new Merge($this, $table, $options);
}
/**
* {@inheritdoc}
*/
public function upsert($table, array $options = []) {
return new Upsert($this, $table, $options);
}
/**
* {@inheritdoc}
*/
public function update($table, array $options = []) {
return new Update($this, $table, $options);
}
/**
* {@inheritdoc}
*/
public function delete($table, array $options = []) {
return new Delete($this, $table, $options);
}
/**
* {@inheritdoc}
*/
public function truncate($table, array $options = []) {
return new Truncate($this, $table, $options);
}
/**
* {@inheritdoc}
*/
public function schema() {
if (empty($this->schema)) {
$this->schema = new Schema($this);
}
return $this->schema;
}
/**
* {@inheritdoc}
*/
public function condition($conjunction) {
return new Condition($conjunction);
}
/**
* {@inheritdoc}
*/
protected function driverTransactionManager(): TransactionManagerInterface {
return new TransactionManager($this);
}
/**
* {@inheritdoc}
*/
public function startTransaction($name = '') {
return $this->transactionManager()->push($name);
}
}
/**
* @} End of "addtogroup database".
*/

View File

@@ -0,0 +1,22 @@
<?php
namespace Drupal\mysql\Driver\Database\mysql;
use Drupal\Core\Database\Query\Delete as QueryDelete;
/**
* MySQL implementation of \Drupal\Core\Database\Query\Delete.
*/
class Delete extends QueryDelete {
/**
* {@inheritdoc}
*/
public function __construct(Connection $connection, string $table, array $options = []) {
// @todo Remove the __construct in Drupal 11.
// @see https://www.drupal.org/project/drupal/issues/3256524
parent::__construct($connection, $table, $options);
unset($this->queryOptions['return']);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Drupal\mysql\Driver\Database\mysql;
use Drupal\Component\Utility\Unicode;
use Drupal\Core\Database\DatabaseExceptionWrapper;
use Drupal\Core\Database\ExceptionHandler as BaseExceptionHandler;
use Drupal\Core\Database\Exception\SchemaTableColumnSizeTooLargeException;
use Drupal\Core\Database\Exception\SchemaTableKeyTooLargeException;
use Drupal\Core\Database\IntegrityConstraintViolationException;
use Drupal\Core\Database\StatementInterface;
/**
* MySql database exception handler class.
*/
class ExceptionHandler extends BaseExceptionHandler {
/**
* {@inheritdoc}
*/
public function handleExecutionException(\Exception $exception, StatementInterface $statement, array $arguments = [], array $options = []): void {
if ($exception instanceof \PDOException) {
// Wrap the exception in another exception, because PHP does not allow
// overriding Exception::getMessage(). Its message is the extra database
// debug information.
$code = is_int($exception->getCode()) ? $exception->getCode() : 0;
// If a max_allowed_packet error occurs the message length is truncated.
// This should prevent the error from recurring if the exception is logged
// to the database using dblog or the like.
if (($exception->errorInfo[1] ?? NULL) === 1153) {
$message = Unicode::truncateBytes($exception->getMessage(), Connection::MIN_MAX_ALLOWED_PACKET);
throw new DatabaseExceptionWrapper($message, $code, $exception);
}
$message = $exception->getMessage() . ": " . $statement->getQueryString() . "; " . print_r($arguments, TRUE);
// SQLSTATE 23xxx errors indicate an integrity constraint violation. Also,
// in case of attempted INSERT of a record with an undefined column and no
// default value indicated in schema, MySql returns a 1364 error code.
if (
substr($exception->getCode(), -6, -3) == '23' ||
($exception->errorInfo[1] ?? NULL) === 1364
) {
throw new IntegrityConstraintViolationException($message, $code, $exception);
}
if ($exception->getCode() === '42000') {
match ($exception->errorInfo[1]) {
1071 => throw new SchemaTableKeyTooLargeException($message, $code, $exception),
1074 => throw new SchemaTableColumnSizeTooLargeException($message, $code, $exception),
default => throw new DatabaseExceptionWrapper($message, 0, $exception),
};
}
throw new DatabaseExceptionWrapper($message, 0, $exception);
}
throw $exception;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Drupal\mysql\Driver\Database\mysql;
use Drupal\Core\Database\Query\Insert as QueryInsert;
/**
* MySQL implementation of \Drupal\Core\Database\Query\Insert.
*/
class Insert extends QueryInsert {
/**
* {@inheritdoc}
*/
public function __construct(Connection $connection, string $table, array $options = []) {
// @todo Remove the __construct in Drupal 11.
// @see https://www.drupal.org/project/drupal/issues/3256524
parent::__construct($connection, $table, $options);
unset($this->queryOptions['return']);
}
public function execute() {
if (!$this->preExecute()) {
return NULL;
}
// If we're selecting from a SelectQuery, finish building the query and
// pass it back, as any remaining options are irrelevant.
if (empty($this->fromQuery)) {
$max_placeholder = 0;
$values = [];
foreach ($this->insertValues as $insert_values) {
foreach ($insert_values as $value) {
$values[':db_insert_placeholder_' . $max_placeholder++] = $value;
}
}
}
else {
$values = $this->fromQuery->getArguments();
}
$stmt = $this->connection->prepareStatement((string) $this, $this->queryOptions);
try {
$stmt->execute($values, $this->queryOptions);
$last_insert_id = $this->connection->lastInsertId();
}
catch (\Exception $e) {
$this->connection->exceptionHandler()->handleExecutionException($e, $stmt, $values, $this->queryOptions);
}
// Re-initialize the values array so that we can re-use this query.
$this->insertValues = [];
return $last_insert_id;
}
public function __toString() {
// Create a sanitized comment string to prepend to the query.
$comments = $this->connection->makeComment($this->comments);
// Default fields are always placed first for consistency.
$insert_fields = array_merge($this->defaultFields, $this->insertFields);
$insert_fields = array_map(function ($field) {
return $this->connection->escapeField($field);
}, $insert_fields);
// If we're selecting from a SelectQuery, finish building the query and
// pass it back, as any remaining options are irrelevant.
if (!empty($this->fromQuery)) {
$insert_fields_string = $insert_fields ? ' (' . implode(', ', $insert_fields) . ') ' : ' ';
return $comments . 'INSERT INTO {' . $this->table . '}' . $insert_fields_string . $this->fromQuery;
}
$query = $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') VALUES ';
$values = $this->getInsertPlaceholderFragment($this->insertValues, $this->defaultFields);
$query .= implode(', ', $values);
return $query;
}
}

View File

@@ -0,0 +1,167 @@
<?php
namespace Drupal\mysql\Driver\Database\mysql\Install;
use Drupal\Core\Database\ConnectionNotDefinedException;
use Drupal\Core\Database\Database;
use Drupal\Core\Database\Install\Tasks as InstallTasks;
use Drupal\mysql\Driver\Database\mysql\Connection;
use Drupal\Core\Database\DatabaseNotFoundException;
/**
* Specifies installation tasks for MySQL and equivalent databases.
*/
class Tasks extends InstallTasks {
/**
* Minimum required MySQL version.
*
* 5.7.8 is the minimum version that supports the JSON datatype.
* @see https://dev.mysql.com/doc/refman/5.7/en/json.html
*/
const MYSQL_MINIMUM_VERSION = '5.7.8';
/**
* Minimum required MariaDB version.
*
* 10.3.7 is the first stable (GA) release in the 10.3 series.
* @see https://mariadb.com/kb/en/changes-improvements-in-mariadb-103/#list-of-all-mariadb-103-releases
*/
const MARIADB_MINIMUM_VERSION = '10.3.7';
/**
* The PDO driver name for MySQL and equivalent databases.
*
* @var string
*/
protected $pdoDriver = 'mysql';
/**
* Constructs a \Drupal\mysql\Driver\Database\mysql\Install\Tasks object.
*/
public function __construct() {
$this->tasks[] = [
'arguments' => [],
'function' => 'ensureInnoDbAvailable',
];
}
/**
* {@inheritdoc}
*/
public function name() {
try {
if (!$this->isConnectionActive() || !$this->getConnection() instanceof Connection) {
throw new ConnectionNotDefinedException('The database connection is not active or not a MySql connection');
}
if ($this->getConnection()->isMariaDb()) {
return $this->t('MariaDB');
}
return $this->t('MySQL, Percona Server, or equivalent');
}
catch (ConnectionNotDefinedException $e) {
return $this->t('MySQL, MariaDB, Percona Server, or equivalent');
}
}
/**
* {@inheritdoc}
*/
public function minimumVersion() {
if ($this->getConnection()->isMariaDb()) {
return static::MARIADB_MINIMUM_VERSION;
}
return static::MYSQL_MINIMUM_VERSION;
}
/**
* {@inheritdoc}
*/
protected function connect() {
try {
// This doesn't actually test the connection.
Database::setActiveConnection();
// Now actually do a check.
Database::getConnection();
$this->pass('Drupal can CONNECT to the database ok.');
}
catch (\Exception $e) {
// Attempt to create the database if it is not found.
if ($e->getCode() == Connection::DATABASE_NOT_FOUND) {
// Remove the database string from connection info.
$connection_info = Database::getConnectionInfo();
$database = $connection_info['default']['database'];
unset($connection_info['default']['database']);
// In order to change the Database::$databaseInfo array, need to remove
// the active connection, then re-add it with the new info.
Database::removeConnection('default');
Database::addConnectionInfo('default', 'default', $connection_info['default']);
try {
// Now, attempt the connection again; if it's successful, attempt to
// create the database.
Database::getConnection()->createDatabase($database);
Database::closeConnection();
// Now, restore the database config.
Database::removeConnection('default');
$connection_info['default']['database'] = $database;
Database::addConnectionInfo('default', 'default', $connection_info['default']);
// Check the database connection.
Database::getConnection();
$this->pass('Drupal can CONNECT to the database ok.');
}
catch (DatabaseNotFoundException $e) {
// Still no dice; probably a permission issue. Raise the error to the
// installer.
$this->fail(t('Database %database not found. The server reports the following message when attempting to create the database: %error.', ['%database' => $database, '%error' => $e->getMessage()]));
}
}
else {
// Database connection failed for some other reason than a non-existent
// database.
$this->fail(t('Failed to connect to your database server. The server reports the following message: %error.<ul><li>Is the database server running?</li><li>Does the database exist or does the database user have sufficient privileges to create the database?</li><li>Have you entered the correct database name?</li><li>Have you entered the correct username and password?</li><li>Have you entered the correct database hostname and port number?</li></ul>', ['%error' => $e->getMessage()]));
return FALSE;
}
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getFormOptions(array $database) {
$form = parent::getFormOptions($database);
if (empty($form['advanced_options']['port']['#default_value'])) {
$form['advanced_options']['port']['#default_value'] = '3306';
}
$form['advanced_options']['isolation_level'] = [
'#type' => 'select',
'#title' => $this->t('Transaction isolation level'),
'#options' => [
'READ COMMITTED' => $this->t('READ COMMITTED'),
'REPEATABLE READ' => $this->t('REPEATABLE READ'),
'' => $this->t('Use database default'),
],
'#default_value' => $database['isolation_level'] ?? 'READ COMMITTED',
'#description' => $this->t('The recommended database transaction level for Drupal is "READ COMMITTED". For more information, see the <a href=":performance_doc">setting MySQL transaction isolation level</a> page.', [
':performance_doc' => 'https://www.drupal.org/docs/system-requirements/setting-the-mysql-transaction-isolation-level',
]),
];
return $form;
}
/**
* Ensure that InnoDB is available.
*/
public function ensureInnoDbAvailable() {
$engines = Database::getConnection()->query('SHOW ENGINES')->fetchAllKeyed();
if (isset($engines['MyISAM']) && $engines['MyISAM'] == 'DEFAULT' && !isset($engines['InnoDB'])) {
$this->fail(t('The MyISAM storage engine is not supported.'));
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Drupal\mysql\Driver\Database\mysql;
use Drupal\Core\Database\Query\Merge as QueryMerge;
/**
* MySQL implementation of \Drupal\Core\Database\Query\Merge.
*/
class Merge extends QueryMerge {
/**
* {@inheritdoc}
*/
public function __construct(Connection $connection, string $table, array $options = []) {
// @todo Remove the __construct in Drupal 11.
// @see https://www.drupal.org/project/drupal/issues/3256524
parent::__construct($connection, $table, $options);
unset($this->queryOptions['return']);
}
}

View File

@@ -0,0 +1,689 @@
<?php
namespace Drupal\mysql\Driver\Database\mysql;
use Drupal\Core\Database\DatabaseExceptionWrapper;
use Drupal\Core\Database\SchemaException;
use Drupal\Core\Database\SchemaObjectExistsException;
use Drupal\Core\Database\SchemaObjectDoesNotExistException;
use Drupal\Core\Database\Schema as DatabaseSchema;
use Drupal\Component\Utility\Unicode;
// cspell:ignore gipk
/**
* @addtogroup schemaapi
* @{
*/
/**
* MySQL implementation of \Drupal\Core\Database\Schema.
*/
class Schema extends DatabaseSchema {
/**
* Maximum length of a table comment in MySQL.
*/
const COMMENT_MAX_TABLE = 60;
/**
* Maximum length of a column comment in MySQL.
*/
const COMMENT_MAX_COLUMN = 255;
/**
* @var array
* List of MySQL string types.
*/
protected $mysqlStringTypes = [
'VARCHAR',
'CHAR',
'TINYTEXT',
'MEDIUMTEXT',
'LONGTEXT',
'TEXT',
];
/**
* Get information about the table and database name from the prefix.
*
* @return array
* A keyed array with information about the database, table name and prefix.
*/
protected function getPrefixInfo($table = 'default', $add_prefix = TRUE) {
$info = ['prefix' => $this->connection->getPrefix()];
if ($add_prefix) {
$table = $info['prefix'] . $table;
}
if (($pos = strpos($table, '.')) !== FALSE) {
$info['database'] = substr($table, 0, $pos);
$info['table'] = substr($table, ++$pos);
}
else {
$info['database'] = $this->connection->getConnectionOptions()['database'];
$info['table'] = $table;
}
return $info;
}
/**
* Build a condition to match a table name against a standard information_schema.
*
* MySQL uses databases like schemas rather than catalogs so when we build
* a condition to query the information_schema.tables, we set the default
* database as the schema unless specified otherwise, and exclude table_catalog
* from the condition criteria.
*/
protected function buildTableNameCondition($table_name, $operator = '=', $add_prefix = TRUE) {
$table_info = $this->getPrefixInfo($table_name, $add_prefix);
$condition = $this->connection->condition('AND');
$condition->condition('table_schema', $table_info['database']);
$condition->condition('table_name', $table_info['table'], $operator);
return $condition;
}
/**
* {@inheritdoc}
*/
protected function createTableSql($name, $table) {
$info = $this->connection->getConnectionOptions();
// Provide defaults if needed.
$table += [
'mysql_engine' => 'InnoDB',
'mysql_character_set' => 'utf8mb4',
];
$sql = "CREATE TABLE {" . $name . "} (\n";
// Add the SQL statement for each field.
foreach ($table['fields'] as $field_name => $field) {
$sql .= $this->createFieldSql($field_name, $this->processField($field)) . ", \n";
}
// Process keys & indexes.
if (!empty($table['primary key']) && is_array($table['primary key'])) {
$this->ensureNotNullPrimaryKey($table['primary key'], $table['fields']);
}
$keys = $this->createKeysSql($table);
if (count($keys)) {
$sql .= implode(", \n", $keys) . ", \n";
}
// Remove the last comma and space.
$sql = substr($sql, 0, -3) . "\n) ";
$sql .= 'ENGINE = ' . $table['mysql_engine'] . ' DEFAULT CHARACTER SET ' . $table['mysql_character_set'];
// By default, MySQL uses the default collation for new tables, which is
// 'utf8mb4_general_ci' (MySQL 5) or 'utf8mb4_0900_ai_ci' (MySQL 8) for
// utf8mb4. If an alternate collation has been set, it needs to be
// explicitly specified.
// @see \Drupal\mysql\Driver\Database\mysql\Schema
if (!empty($info['collation'])) {
$sql .= ' COLLATE ' . $info['collation'];
}
// Add table comment.
if (!empty($table['description'])) {
$sql .= ' COMMENT ' . $this->prepareComment($table['description'], self::COMMENT_MAX_TABLE);
}
return [$sql];
}
/**
* Create an SQL string for a field to be used in table creation or alteration.
*
* @param string $name
* Name of the field.
* @param array $spec
* The field specification, as per the schema data structure format.
*/
protected function createFieldSql($name, $spec) {
$sql = "[" . $name . "] " . $spec['mysql_type'];
if (in_array($spec['mysql_type'], $this->mysqlStringTypes)) {
if (isset($spec['length'])) {
$sql .= '(' . $spec['length'] . ')';
}
if (isset($spec['type']) && $spec['type'] == 'varchar_ascii') {
$sql .= ' CHARACTER SET ascii';
}
if (!empty($spec['binary'])) {
$sql .= ' BINARY';
}
// Note we check for the "type" key here. "mysql_type" is VARCHAR:
elseif (isset($spec['type']) && $spec['type'] == 'varchar_ascii') {
$sql .= ' COLLATE ascii_general_ci';
}
}
elseif (isset($spec['precision']) && isset($spec['scale'])) {
$sql .= '(' . $spec['precision'] . ', ' . $spec['scale'] . ')';
}
if (!empty($spec['unsigned'])) {
$sql .= ' unsigned';
}
if (isset($spec['not null'])) {
if ($spec['not null']) {
$sql .= ' NOT NULL';
}
else {
$sql .= ' NULL';
}
}
if (!empty($spec['auto_increment'])) {
$sql .= ' auto_increment';
}
// $spec['default'] can be NULL, so we explicitly check for the key here.
if (array_key_exists('default', $spec)) {
$sql .= ' DEFAULT ' . $this->escapeDefaultValue($spec['default']);
}
if (empty($spec['not null']) && !isset($spec['default'])) {
$sql .= ' DEFAULT NULL';
}
// Add column comment.
if (!empty($spec['description'])) {
$sql .= ' COMMENT ' . $this->prepareComment($spec['description'], self::COMMENT_MAX_COLUMN);
}
return $sql;
}
/**
* Set database-engine specific properties for a field.
*
* @param $field
* A field description array, as specified in the schema documentation.
*/
protected function processField($field) {
if (!isset($field['size'])) {
$field['size'] = 'normal';
}
// Set the correct database-engine specific datatype.
// In case one is already provided, force it to uppercase.
if (isset($field['mysql_type'])) {
$field['mysql_type'] = mb_strtoupper($field['mysql_type']);
}
else {
$map = $this->getFieldTypeMap();
$field['mysql_type'] = $map[$field['type'] . ':' . $field['size']];
}
if (isset($field['type']) && $field['type'] == 'serial') {
$field['auto_increment'] = TRUE;
}
return $field;
}
/**
* {@inheritdoc}
*/
public function getFieldTypeMap() {
// Put :normal last so it gets preserved by array_flip. This makes
// it much easier for modules (such as schema.module) to map
// database types back into schema types.
// $map does not use drupal_static as its value never changes.
static $map = [
'varchar_ascii:normal' => 'VARCHAR',
'varchar:normal' => 'VARCHAR',
'char:normal' => 'CHAR',
'text:tiny' => 'TINYTEXT',
'text:small' => 'TINYTEXT',
'text:medium' => 'MEDIUMTEXT',
'text:big' => 'LONGTEXT',
'text:normal' => 'TEXT',
'serial:tiny' => 'TINYINT',
'serial:small' => 'SMALLINT',
'serial:medium' => 'MEDIUMINT',
'serial:big' => 'BIGINT',
'serial:normal' => 'INT',
'int:tiny' => 'TINYINT',
'int:small' => 'SMALLINT',
'int:medium' => 'MEDIUMINT',
'int:big' => 'BIGINT',
'int:normal' => 'INT',
'float:tiny' => 'FLOAT',
'float:small' => 'FLOAT',
'float:medium' => 'FLOAT',
'float:big' => 'DOUBLE',
'float:normal' => 'FLOAT',
'numeric:normal' => 'DECIMAL',
'blob:big' => 'LONGBLOB',
'blob:normal' => 'BLOB',
];
return $map;
}
protected function createKeysSql($spec) {
$keys = [];
if (!empty($spec['primary key'])) {
$keys[] = 'PRIMARY KEY (' . $this->createKeySql($spec['primary key']) . ')';
}
if (!empty($spec['unique keys'])) {
foreach ($spec['unique keys'] as $key => $fields) {
$keys[] = 'UNIQUE KEY [' . $key . '] (' . $this->createKeySql($fields) . ')';
}
}
if (!empty($spec['indexes'])) {
$indexes = $this->getNormalizedIndexes($spec);
foreach ($indexes as $index => $fields) {
$keys[] = 'INDEX [' . $index . '] (' . $this->createKeySql($fields) . ')';
}
}
return $keys;
}
/**
* Gets normalized indexes from a table specification.
*
* Shortens indexes to 191 characters if they apply to utf8mb4-encoded
* fields, in order to comply with the InnoDB index limitation of 756 bytes.
*
* @param array $spec
* The table specification.
*
* @return array
* List of shortened indexes.
*
* @throws \Drupal\Core\Database\SchemaException
* Thrown if field specification is missing.
*/
protected function getNormalizedIndexes(array $spec) {
$indexes = $spec['indexes'] ?? [];
foreach ($indexes as $index_name => $index_fields) {
foreach ($index_fields as $index_key => $index_field) {
// Get the name of the field from the index specification.
$field_name = is_array($index_field) ? $index_field[0] : $index_field;
// Check whether the field is defined in the table specification.
if (isset($spec['fields'][$field_name])) {
// Get the MySQL type from the processed field.
$mysql_field = $this->processField($spec['fields'][$field_name]);
if (in_array($mysql_field['mysql_type'], $this->mysqlStringTypes)) {
// Check whether we need to shorten the index.
if ((!isset($mysql_field['type']) || $mysql_field['type'] != 'varchar_ascii') && (!isset($mysql_field['length']) || $mysql_field['length'] > 191)) {
// Limit the index length to 191 characters.
$this->shortenIndex($indexes[$index_name][$index_key]);
}
}
}
else {
throw new SchemaException("MySQL needs the '$field_name' field specification in order to normalize the '$index_name' index");
}
}
}
return $indexes;
}
/**
* Helper function for normalizeIndexes().
*
* Shortens an index to 191 characters.
*
* @param array $index
* The index array to be used in createKeySql.
*
* @see Drupal\mysql\Driver\Database\mysql\Schema::createKeySql()
* @see Drupal\mysql\Driver\Database\mysql\Schema::normalizeIndexes()
*/
protected function shortenIndex(&$index) {
if (is_array($index)) {
if ($index[1] > 191) {
$index[1] = 191;
}
}
else {
$index = [$index, 191];
}
}
protected function createKeySql($fields) {
$return = [];
foreach ($fields as $field) {
if (is_array($field)) {
$return[] = '[' . $field[0] . '] (' . $field[1] . ')';
}
else {
$return[] = '[' . $field . ']';
}
}
return implode(', ', $return);
}
/**
* {@inheritdoc}
*/
public function renameTable($table, $new_name) {
if (!$this->tableExists($table)) {
throw new SchemaObjectDoesNotExistException("Cannot rename '$table' to '$new_name': table '$table' doesn't exist.");
}
if ($this->tableExists($new_name)) {
throw new SchemaObjectExistsException("Cannot rename '$table' to '$new_name': table '$new_name' already exists.");
}
$info = $this->getPrefixInfo($new_name);
$this->connection->query('ALTER TABLE {' . $table . '} RENAME TO [' . $info['table'] . ']');
}
/**
* {@inheritdoc}
*/
public function dropTable($table) {
if (!$this->tableExists($table)) {
return FALSE;
}
$this->connection->query('DROP TABLE {' . $table . '}');
return TRUE;
}
/**
* {@inheritdoc}
*/
public function addField($table, $field, $spec, $keys_new = []) {
if (!$this->tableExists($table)) {
throw new SchemaObjectDoesNotExistException("Cannot add field '$table.$field': table doesn't exist.");
}
if ($this->fieldExists($table, $field)) {
throw new SchemaObjectExistsException("Cannot add field '$table.$field': field already exists.");
}
// Fields that are part of a PRIMARY KEY must be added as NOT NULL.
$is_primary_key = isset($keys_new['primary key']) && in_array($field, $keys_new['primary key'], TRUE);
if ($is_primary_key) {
$this->ensureNotNullPrimaryKey($keys_new['primary key'], [$field => $spec]);
}
$fix_null = FALSE;
if (!empty($spec['not null']) && !isset($spec['default']) && !$is_primary_key) {
$fix_null = TRUE;
$spec['not null'] = FALSE;
}
$query = 'ALTER TABLE {' . $table . '} ADD ';
$query .= $this->createFieldSql($field, $this->processField($spec));
if ($keys_sql = $this->createKeysSql($keys_new)) {
// Make sure to drop the existing primary key before adding a new one.
// This is only needed when adding a field because this method, unlike
// changeField(), is supposed to handle primary keys automatically.
if (isset($keys_new['primary key']) && $this->indexExists($table, 'PRIMARY')) {
$query .= ', DROP PRIMARY KEY';
}
$query .= ', ADD ' . implode(', ADD ', $keys_sql);
}
try {
$this->connection->query($query);
}
catch (DatabaseExceptionWrapper $e) {
// MySQL error number 4111 (ER_DROP_PK_COLUMN_TO_DROP_GIPK) indicates that
// when dropping and adding a primary key, the generated invisible primary
// key (GIPK) column must also be dropped.
if (isset($e->getPrevious()->errorInfo[1]) && $e->getPrevious()->errorInfo[1] === 4111 && isset($keys_new['primary key']) && $this->indexExists($table, 'PRIMARY') && $this->findPrimaryKeyColumns($table) === ['my_row_id']) {
$this->connection->query($query . ', DROP COLUMN [my_row_id]');
}
else {
throw $e;
}
}
if (isset($spec['initial_from_field'])) {
if (isset($spec['initial'])) {
$expression = 'COALESCE(' . $spec['initial_from_field'] . ', :default_initial_value)';
$arguments = [':default_initial_value' => $spec['initial']];
}
else {
$expression = $spec['initial_from_field'];
$arguments = [];
}
$this->connection->update($table)
->expression($field, $expression, $arguments)
->execute();
}
elseif (isset($spec['initial'])) {
$this->connection->update($table)
->fields([$field => $spec['initial']])
->execute();
}
if ($fix_null) {
$spec['not null'] = TRUE;
$this->changeField($table, $field, $field, $spec);
}
}
/**
* {@inheritdoc}
*/
public function dropField($table, $field) {
if (!$this->fieldExists($table, $field)) {
return FALSE;
}
// When dropping a field that is part of a composite primary key MySQL
// automatically removes the field from the primary key, which can leave the
// table in an invalid state. MariaDB 10.2.8 requires explicitly dropping
// the primary key first for this reason. We perform this deletion
// explicitly which also makes the behavior on both MySQL and MariaDB
// consistent with PostgreSQL.
// @see https://mariadb.com/kb/en/library/alter-table
$primary_key = $this->findPrimaryKeyColumns($table);
if ((count($primary_key) > 1) && in_array($field, $primary_key, TRUE)) {
$this->dropPrimaryKey($table);
}
$this->connection->query('ALTER TABLE {' . $table . '} DROP [' . $field . ']');
return TRUE;
}
/**
* {@inheritdoc}
*/
public function indexExists($table, $name) {
// Returns one row for each column in the index. Result is string or FALSE.
// Details at http://dev.mysql.com/doc/refman/5.0/en/show-index.html
$row = $this->connection->query('SHOW INDEX FROM {' . $table . '} WHERE key_name = ' . $this->connection->quote($name))->fetchAssoc();
return isset($row['Key_name']);
}
/**
* {@inheritdoc}
*/
public function addPrimaryKey($table, $fields) {
if (!$this->tableExists($table)) {
throw new SchemaObjectDoesNotExistException("Cannot add primary key to table '$table': table doesn't exist.");
}
if ($this->indexExists($table, 'PRIMARY')) {
throw new SchemaObjectExistsException("Cannot add primary key to table '$table': primary key already exists.");
}
$this->connection->query('ALTER TABLE {' . $table . '} ADD PRIMARY KEY (' . $this->createKeySql($fields) . ')');
}
/**
* {@inheritdoc}
*/
public function dropPrimaryKey($table) {
if (!$this->indexExists($table, 'PRIMARY')) {
return FALSE;
}
$this->connection->query('ALTER TABLE {' . $table . '} DROP PRIMARY KEY');
return TRUE;
}
/**
* {@inheritdoc}
*/
protected function findPrimaryKeyColumns($table) {
if (!$this->tableExists($table)) {
return FALSE;
}
$result = $this->connection->query("SHOW KEYS FROM {" . $table . "} WHERE Key_name = 'PRIMARY'")->fetchAllAssoc('Column_name');
return array_keys($result);
}
/**
* {@inheritdoc}
*/
public function addUniqueKey($table, $name, $fields) {
if (!$this->tableExists($table)) {
throw new SchemaObjectDoesNotExistException("Cannot add unique key '$name' to table '$table': table doesn't exist.");
}
if ($this->indexExists($table, $name)) {
throw new SchemaObjectExistsException("Cannot add unique key '$name' to table '$table': unique key already exists.");
}
$this->connection->query('ALTER TABLE {' . $table . '} ADD UNIQUE KEY [' . $name . '] (' . $this->createKeySql($fields) . ')');
}
/**
* {@inheritdoc}
*/
public function dropUniqueKey($table, $name) {
if (!$this->indexExists($table, $name)) {
return FALSE;
}
$this->connection->query('ALTER TABLE {' . $table . '} DROP KEY [' . $name . ']');
return TRUE;
}
/**
* {@inheritdoc}
*/
public function addIndex($table, $name, $fields, array $spec) {
if (!$this->tableExists($table)) {
throw new SchemaObjectDoesNotExistException("Cannot add index '$name' to table '$table': table doesn't exist.");
}
if ($this->indexExists($table, $name)) {
throw new SchemaObjectExistsException("Cannot add index '$name' to table '$table': index already exists.");
}
$spec['indexes'][$name] = $fields;
$indexes = $this->getNormalizedIndexes($spec);
$this->connection->query('ALTER TABLE {' . $table . '} ADD INDEX [' . $name . '] (' . $this->createKeySql($indexes[$name]) . ')');
}
/**
* {@inheritdoc}
*/
public function dropIndex($table, $name) {
if (!$this->indexExists($table, $name)) {
return FALSE;
}
$this->connection->query('ALTER TABLE {' . $table . '} DROP INDEX [' . $name . ']');
return TRUE;
}
/**
* {@inheritdoc}
*/
protected function introspectIndexSchema($table) {
if (!$this->tableExists($table)) {
throw new SchemaObjectDoesNotExistException("The table $table doesn't exist.");
}
$index_schema = [
'primary key' => [],
'unique keys' => [],
'indexes' => [],
];
$result = $this->connection->query('SHOW INDEX FROM {' . $table . '}')->fetchAll();
foreach ($result as $row) {
if ($row->Key_name === 'PRIMARY') {
$index_schema['primary key'][] = $row->Column_name;
}
elseif ($row->Non_unique == 0) {
$index_schema['unique keys'][$row->Key_name][] = $row->Column_name;
}
else {
$index_schema['indexes'][$row->Key_name][] = $row->Column_name;
}
}
return $index_schema;
}
/**
* {@inheritdoc}
*/
public function changeField($table, $field, $field_new, $spec, $keys_new = []) {
if (!$this->fieldExists($table, $field)) {
throw new SchemaObjectDoesNotExistException("Cannot change the definition of field '$table.$field': field doesn't exist.");
}
if (($field != $field_new) && $this->fieldExists($table, $field_new)) {
throw new SchemaObjectExistsException("Cannot rename field '$table.$field' to '$field_new': target field already exists.");
}
if (isset($keys_new['primary key']) && in_array($field_new, $keys_new['primary key'], TRUE)) {
$this->ensureNotNullPrimaryKey($keys_new['primary key'], [$field_new => $spec]);
}
$sql = 'ALTER TABLE {' . $table . '} CHANGE [' . $field . '] ' . $this->createFieldSql($field_new, $this->processField($spec));
if ($keys_sql = $this->createKeysSql($keys_new)) {
$sql .= ', ADD ' . implode(', ADD ', $keys_sql);
}
$this->connection->query($sql);
if ($spec['type'] === 'serial') {
$max = $this->connection->query('SELECT MAX(`' . $field_new . '`) FROM {' . $table . '}')->fetchField();
$this->connection->query("ALTER TABLE {" . $table . "} AUTO_INCREMENT = " . ($max + 1));
}
}
/**
* {@inheritdoc}
*/
public function prepareComment($comment, $length = NULL) {
// Truncate comment to maximum comment length.
if (isset($length)) {
// Add table prefix before truncating.
$comment = Unicode::truncate($this->connection->prefixTables($comment), $length, TRUE, TRUE);
}
// Remove semicolons to avoid triggering multi-statement check.
$comment = strtr($comment, [';' => '.']);
return $this->connection->quote($comment);
}
/**
* Retrieve a table or column comment.
*/
public function getComment($table, $column = NULL) {
$condition = $this->buildTableNameCondition($table);
if (isset($column)) {
$condition->condition('column_name', $column);
$condition->compile($this->connection, $this);
// Don't use {} around information_schema.columns table.
return $this->connection->query("SELECT column_comment AS column_comment FROM information_schema.columns WHERE " . (string) $condition, $condition->arguments())->fetchField();
}
$condition->compile($this->connection, $this);
// Don't use {} around information_schema.tables table.
$comment = $this->connection->query("SELECT table_comment AS table_comment FROM information_schema.tables WHERE " . (string) $condition, $condition->arguments())->fetchField();
// Work-around for MySQL 5.0 bug http://bugs.mysql.com/bug.php?id=11379
return preg_replace('/; InnoDB free:.*$/', '', $comment);
}
}
/**
* @} End of "addtogroup schemaapi".
*/

View File

@@ -0,0 +1,22 @@
<?php
namespace Drupal\mysql\Driver\Database\mysql;
use Drupal\Core\Database\Query\Select as QuerySelect;
/**
* MySQL implementation of \Drupal\Core\Database\Query\Select.
*/
class Select extends QuerySelect {
/**
* {@inheritdoc}
*/
public function __construct(Connection $connection, $table, $alias = NULL, array $options = []) {
// @todo Remove the __construct in Drupal 11.
// @see https://www.drupal.org/project/drupal/issues/3256524
parent::__construct($connection, $table, $alias, $options);
unset($this->queryOptions['return']);
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Drupal\mysql\Driver\Database\mysql;
use Drupal\Core\Database\Transaction\ClientConnectionTransactionState;
use Drupal\Core\Database\Transaction\TransactionManagerBase;
/**
* MySql implementation of TransactionManagerInterface.
*
* MySQL will automatically commit transactions when tables are altered or
* created (DDL transactions are not supported). However, pdo_mysql tracks
* whether a client connection is still active and we can prevent triggering
* exceptions.
*/
class TransactionManager extends TransactionManagerBase {
/**
* {@inheritdoc}
*/
protected function beginClientTransaction(): bool {
return $this->connection->getClientConnection()->beginTransaction();
}
/**
* {@inheritdoc}
*/
protected function processRootCommit(): void {
if (!$this->connection->getClientConnection()->inTransaction()) {
$this->voidClientTransaction();
return;
}
parent::processRootCommit();
}
/**
* {@inheritdoc}
*/
protected function rollbackClientSavepoint(string $name): bool {
if (!$this->connection->getClientConnection()->inTransaction()) {
$this->voidClientTransaction();
return TRUE;
}
return parent::rollbackClientSavepoint($name);
}
/**
* {@inheritdoc}
*/
protected function releaseClientSavepoint(string $name): bool {
if (!$this->connection->getClientConnection()->inTransaction()) {
$this->voidClientTransaction();
return TRUE;
}
return parent::releaseClientSavepoint($name);
}
/**
* {@inheritdoc}
*/
protected function commitClientTransaction(): bool {
$clientCommit = $this->connection->getClientConnection()->commit();
$this->setConnectionTransactionState($clientCommit ?
ClientConnectionTransactionState::Committed :
ClientConnectionTransactionState::CommitFailed
);
return $clientCommit;
}
/**
* {@inheritdoc}
*/
protected function rollbackClientTransaction(): bool {
if (!$this->connection->getClientConnection()->inTransaction()) {
$this->voidClientTransaction();
return FALSE;
}
$clientRollback = $this->connection->getClientConnection()->rollBack();
$this->setConnectionTransactionState($clientRollback ?
ClientConnectionTransactionState::RolledBack :
ClientConnectionTransactionState::RollbackFailed
);
return $clientRollback;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Drupal\mysql\Driver\Database\mysql;
use Drupal\Core\Database\Query\Truncate as QueryTruncate;
/**
* MySQL implementation of \Drupal\Core\Database\Query\Truncate.
*/
class Truncate extends QueryTruncate {
/**
* {@inheritdoc}
*/
public function __construct(Connection $connection, string $table, array $options = []) {
// @todo Remove the __construct in Drupal 11.
// @see https://www.drupal.org/project/drupal/issues/3256524
parent::__construct($connection, $table, $options);
unset($this->queryOptions['return']);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Drupal\mysql\Driver\Database\mysql;
use Drupal\Core\Database\Query\Update as QueryUpdate;
/**
* MySQL implementation of \Drupal\Core\Database\Query\Update.
*/
class Update extends QueryUpdate {
/**
* {@inheritdoc}
*/
public function __construct(Connection $connection, string $table, array $options = []) {
// @todo Remove the __construct in Drupal 11.
// @see https://www.drupal.org/project/drupal/issues/3256524
parent::__construct($connection, $table, $options);
unset($this->queryOptions['return']);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Drupal\mysql\Driver\Database\mysql;
use Drupal\Core\Database\Query\Upsert as QueryUpsert;
/**
* MySQL implementation of \Drupal\Core\Database\Query\Upsert.
*/
class Upsert extends QueryUpsert {
/**
* {@inheritdoc}
*/
public function __construct(Connection $connection, string $table, array $options = []) {
// @todo Remove the __construct in Drupal 11.
// @see https://www.drupal.org/project/drupal/issues/3256524
parent::__construct($connection, $table, $options);
unset($this->queryOptions['return']);
}
/**
* {@inheritdoc}
*/
public function __toString() {
// Create a sanitized comment string to prepend to the query.
$comments = $this->connection->makeComment($this->comments);
// Default fields are always placed first for consistency.
$insert_fields = array_merge($this->defaultFields, $this->insertFields);
$insert_fields = array_map(function ($field) {
return $this->connection->escapeField($field);
}, $insert_fields);
$query = $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') VALUES ';
$values = $this->getInsertPlaceholderFragment($this->insertValues, $this->defaultFields);
$query .= implode(', ', $values);
// Updating the unique / primary key is not necessary.
unset($insert_fields[$this->key]);
$update = [];
foreach ($insert_fields as $field) {
$update[] = "$field = VALUES($field)";
}
$query .= ' ON DUPLICATE KEY UPDATE ' . implode(', ', $update);
return $query;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Drupal\mysql\Plugin\views\query;
use Drupal\views\Plugin\views\query\CastSqlInterface;
/**
* MySQL specific cast handling.
*/
class MysqlCastSql implements CastSqlInterface {
/**
* {@inheritdoc}
*/
public function getFieldAsInt(string $field): string {
return "CAST($field AS UNSIGNED)";
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for mysql.
*
* @group mysql
*/
class GenericTest extends GenericModuleTestBase {}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Functional;
use Drupal\Core\Database\Database;
use Drupal\FunctionalTests\Installer\InstallerExistingSettingsTest;
/**
* Tests the isolation_level setting with existing database settings.
*
* @group Installer
*/
class InstallerIsolationLevelExistingSettingsTest extends InstallerExistingSettingsTest {
/**
* {@inheritdoc}
*/
protected function prepareEnvironment() {
parent::prepareEnvironment();
$connection_info = Database::getConnectionInfo();
// The isolation_level option is only available for MySQL.
if ($connection_info['default']['driver'] !== 'mysql') {
$this->markTestSkipped("This test does not support the {$connection_info['default']['driver']} database driver.");
}
}
/**
* Verifies that isolation_level is not set in the database settings.
*/
public function testInstaller(): void {
$contents = file_get_contents($this->container->getParameter('app.root') . '/' . $this->siteDirectory . '/settings.php');
// Test that isolation_level was not set.
$this->assertStringNotContainsString("'isolation_level' => 'READ COMMITTED'", $contents);
$this->assertStringNotContainsString("'isolation_level' => 'REPEATABLE READ'", $contents);
// Change the default database connection to use the isolation level from
// the test.
$connection_info = Database::getConnectionInfo();
$driver_test_connection = $connection_info['default'];
// We have asserted that the isolation level was not set.
unset($driver_test_connection['isolation_level']);
unset($driver_test_connection['init_commands']);
Database::renameConnection('default', 'original_database_connection');
Database::addConnectionInfo('default', 'default', $driver_test_connection);
// Close and reopen the database connection, so the database init commands
// get executed.
Database::closeConnection('default', 'default');
$connection = Database::getConnection('default', 'default');
$query = 'SELECT @@SESSION.tx_isolation';
// The database variable "tx_isolation" has been removed in MySQL v8.0 and
// has been replaced by "transaction_isolation".
// @see https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_tx_isolation
if (!$connection->isMariaDb() && version_compare($connection->version(), '8.0.0-AnyName', '>')) {
$query = 'SELECT @@SESSION.transaction_isolation';
}
// Test that transaction level is REPEATABLE READ.
$this->assertEquals('REPEATABLE-READ', $connection->query($query)->fetchField());
// Restore the old database connection.
Database::addConnectionInfo('default', 'default', $connection_info['default']);
Database::closeConnection('default', 'default');
Database::getConnection('default', 'default');
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Functional;
use Drupal\Core\Database\Database;
use Drupal\FunctionalTests\Installer\InstallerTestBase;
/**
* Tests the isolation_level setting with no database settings.
*
* @group Installer
*/
class InstallerIsolationLevelNoDatabaseSettingsTest extends InstallerTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function prepareEnvironment() {
parent::prepareEnvironment();
// The isolation_level option is only available for MySQL.
$connection_info = Database::getConnectionInfo();
if ($connection_info['default']['driver'] !== 'mysql') {
$this->markTestSkipped("This test does not support the {$connection_info['default']['driver']} database driver.");
}
}
/**
* Verifies that the isolation_level was added to the database settings.
*/
public function testInstaller(): void {
$contents = file_get_contents($this->container->getParameter('app.root') . '/' . $this->siteDirectory . '/settings.php');
// Test that isolation_level was set to "READ COMMITTED".
$this->assertStringContainsString("'isolation_level' => 'READ COMMITTED',", $contents);
// Change the default database connection to use the isolation level from
// the test.
$connection_info = Database::getConnectionInfo();
$driver_test_connection = $connection_info['default'];
// We have asserted that the isolation level was set to 'READ COMMITTED'.
$driver_test_connection['isolation_level'] = 'READ COMMITTED';
unset($driver_test_connection['init_commands']);
Database::renameConnection('default', 'original_database_connection');
Database::addConnectionInfo('default', 'default', $driver_test_connection);
// Close and reopen the database connection, so the database init commands
// get executed.
Database::closeConnection('default', 'default');
$connection = Database::getConnection('default', 'default');
$query = 'SELECT @@SESSION.tx_isolation';
// The database variable "tx_isolation" has been removed in MySQL v8.0 and
// has been replaced by "transaction_isolation".
// @see https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_tx_isolation
if (!$connection->isMariaDb() && version_compare($connection->version(), '8.0.0-AnyName', '>')) {
$query = 'SELECT @@SESSION.transaction_isolation';
}
// Test that transaction level is READ-COMMITTED.
$this->assertEquals('READ-COMMITTED', $connection->query($query)->fetchField());
// Restore the old database connection.
Database::addConnectionInfo('default', 'default', $connection_info['default']);
Database::closeConnection('default', 'default');
Database::getConnection('default', 'default');
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Functional;
use Drupal\Core\Database\Database;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
/**
* Tests updates MySQL 8 when sql_require_primary_key is on.
*
* This acts as a generic test the Drupal supports this setting and does not
* break during updates.
*
* @group mysql
*/
class Mysql8RequirePrimaryKeyUpdateTest extends UpdatePathTestBase {
/**
* {@inheritdoc}
*/
protected function runDbTasks() {
parent::runDbTasks();
$database = Database::getConnection();
$is_maria = method_exists($database, 'isMariaDb') && $database->isMariaDb();
if ($database->databaseType() !== 'mysql' || $is_maria || version_compare($database->version(), '8.0.13', '<')) {
$this->markTestSkipped('This test only runs on MySQL 8.0.13 and above');
}
$database->query("SET sql_require_primary_key = 1;")->execute();
}
/**
* {@inheritdoc}
*/
protected function prepareSettings() {
parent::prepareSettings();
// Set sql_require_primary_key for any future connections.
$settings['databases']['default']['default']['init_commands'] = (object) [
'value' => ['sql_require_primary_key' => 'SET sql_require_primary_key = 1;'],
'required' => TRUE,
];
$this->writeSettings($settings);
}
/**
* {@inheritdoc}
*/
protected function setDatabaseDumpFiles() {
$this->databaseDumpFiles[] = __DIR__ . '/../../../../system/tests/fixtures/update/drupal-9.4.0.bare.standard.php.gz';
}
/**
* Tests updates.
*/
public function testDatabaseLoaded(): void {
$this->runUpdates();
// Ensure that after updating a user can be created and do a basic test that
// the site is available by logging in.
$this->drupalLogin($this->createUser(admin: TRUE));
$this->assertSession()->statusCodeEquals(200);
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Functional;
use Drupal\Core\Database\Database;
use Drupal\Tests\BrowserTestBase;
/**
* Tests isolation level warning when the config is set in settings.php.
*
* @group mysql
*/
class RequirementsTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['mysql'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// The isolation_level option is only available for MySQL.
$connection = Database::getConnection();
if ($connection->driver() !== 'mysql') {
$this->markTestSkipped("This test does not support the {$connection->driver()} database driver.");
}
}
/**
* Test the isolation level warning message on status page.
*/
public function testIsolationLevelWarningNotDisplaying(): void {
$admin_user = $this->drupalCreateUser([
'administer site configuration',
'access site reports',
]);
$this->drupalLogin($admin_user);
$connection = Database::getConnection();
// Set the isolation level to a level that produces a warning.
$this->writeIsolationLevelSettings('REPEATABLE READ');
// Check the message is not a warning.
$this->drupalGet('admin/reports/status');
$elements = $this->xpath('//details[@class="system-status-report__entry"]//div[contains(text(), "REPEATABLE-READ")]');
$this->assertCount(1, $elements);
// Ensure it is a warning.
$this->assertStringContainsString('warning', $elements[0]->getParent()->getParent()->find('css', 'summary')->getAttribute('class'));
// Rollback the isolation level to read committed.
$this->writeIsolationLevelSettings('READ COMMITTED');
// Check the message is not a warning.
$this->drupalGet('admin/reports/status');
$elements = $this->xpath('//details[@class="system-status-report__entry"]//div[contains(text(), "READ-COMMITTED")]');
$this->assertCount(1, $elements);
// Ensure it is a not a warning.
$this->assertStringNotContainsString('warning', $elements[0]->getParent()->getParent()->find('css', 'summary')->getAttribute('class'));
$specification = [
'fields' => [
'text' => [
'type' => 'text',
'description' => 'A text field',
],
],
];
$connection->schema()->createTable('test_table_without_primary_key', $specification);
// Set the isolation level to a level that produces a warning.
$this->writeIsolationLevelSettings('REPEATABLE READ');
// Check the message is not a warning.
$this->drupalGet('admin/reports/status');
$elements = $this->xpath('//details[@class="system-status-report__entry"]//div[contains(text(), :text)]', [
':text' => 'The recommended level for Drupal is "READ COMMITTED". For this to work correctly, all tables must have a primary key. The following table(s) do not have a primary key: test_table_without_primary_key.',
]);
$this->assertCount(1, $elements);
$this->assertStringStartsWith('REPEATABLE-READ', $elements[0]->getParent()->getText());
// Ensure it is a warning.
$this->assertStringContainsString('warning', $elements[0]->getParent()->getParent()->find('css', 'summary')->getAttribute('class'));
// Rollback the isolation level to read committed.
$this->writeIsolationLevelSettings('READ COMMITTED');
// Check the message is not a warning.
$this->drupalGet('admin/reports/status');
$elements = $this->xpath('//details[@class="system-status-report__entry"]//div[contains(text(), :text)]', [
':text' => 'For this to work correctly, all tables must have a primary key. The following table(s) do not have a primary key: test_table_without_primary_key.',
]);
$this->assertCount(1, $elements);
$this->assertStringStartsWith('READ-COMMITTED', $elements[0]->getParent()->getText());
// Ensure it is an error.
$this->assertStringContainsString('error', $elements[0]->getParent()->getParent()->find('css', 'summary')->getAttribute('class'));
}
/**
* Writes the isolation level in settings.php.
*
* @param string $isolation_level
* The isolation level.
*/
private function writeIsolationLevelSettings(string $isolation_level) {
$settings['databases']['default']['default']['init_commands'] = (object) [
'value' => [
'isolation_level' => "SET SESSION TRANSACTION ISOLATION LEVEL {$isolation_level}",
],
'required' => TRUE,
];
$this->writeSettings($settings);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\Core\Database\Database;
use Drupal\Core\Database\DatabaseExceptionWrapper;
use Drupal\KernelTests\Core\Database\DriverSpecificDatabaseTestBase;
/**
* MySQL-specific connection tests.
*
* @group Database
*/
class ConnectionTest extends DriverSpecificDatabaseTestBase {
/**
* Ensure that you cannot execute multiple statements on MySQL.
*/
public function testMultipleStatementsForNewPhp(): void {
$this->expectException(DatabaseExceptionWrapper::class);
Database::getConnection('default', 'default')->query('SELECT * FROM {test}; SELECT * FROM {test_people}', [], ['allow_delimiter_in_query' => TRUE]);
}
/**
* Tests deprecation of ::makeSequenceName().
*
* @group legacy
*/
public function testMakeSequenceNameDeprecation(): void {
$this->expectDeprecation("Drupal\\Core\\Database\\Connection::makeSequenceName() is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. There is no replacement. See https://www.drupal.org/node/3377046");
$this->assertIsString($this->connection->makeSequenceName('foo', 'bar'));
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\KernelTests\Core\Database\DriverSpecificConnectionUnitTestBase;
/**
* MySQL-specific connection unit tests.
*
* @group Database
*/
class ConnectionUnitTest extends DriverSpecificConnectionUnitTestBase {
/**
* Returns a set of queries specific for MySQL.
*/
protected function getQuery(): array {
return [
'connection_id' => 'SELECT CONNECTION_ID()',
'processlist' => 'SHOW PROCESSLIST',
'show_tables' => 'SHOW TABLES',
];
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql\Console;
use Drupal\Core\Command\DbDumpCommand;
use Drupal\KernelTests\Core\Database\DriverSpecificKernelTestBase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* Test that the DbDumpCommand works correctly.
*
* @group console
*/
class DbDumpCommandTest extends DriverSpecificKernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['system'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Rebuild the router to ensure a routing table.
\Drupal::service('router.builder')->rebuild();
/** @var \Drupal\Core\Database\Connection $connection */
$connection = $this->container->get('database');
$connection->insert('router')->fields(['name', 'path', 'pattern_outline'])->values(['test', 'test', 'test'])->execute();
// Create a table with a field type not defined in
// \Drupal\Core\Database\Schema::getFieldTypeMap.
$table_name = $connection->getPrefix() . 'foo';
$sql = "create table if not exists `$table_name` (`test` datetime NOT NULL);";
$connection->query($sql)->execute();
}
/**
* Tests the command directly.
*/
public function testDbDumpCommand(): void {
$command = new DbDumpCommand();
$command_tester = new CommandTester($command);
$command_tester->execute([]);
// Assert that insert exists and that some expected fields exist.
$output = $command_tester->getDisplay();
$this->assertStringContainsString("createTable('router", $output, 'Table router found');
$this->assertStringContainsString("insert('router", $output, 'Insert found');
$this->assertStringContainsString("'name' => 'test", $output, 'Insert name field found');
$this->assertStringContainsString("'path' => 'test", $output, 'Insert path field found');
$this->assertStringContainsString("'pattern_outline' => 'test", $output, 'Insert pattern_outline field found');
$this->assertStringContainsString("// phpcs:ignoreFile", $output);
$version = \Drupal::VERSION;
$this->assertStringContainsString("This file was generated by the Drupal {$version} db-tools.php script.", $output);
}
/**
* Tests schema only option.
*/
public function testSchemaOnly(): void {
$command = new DbDumpCommand();
$command_tester = new CommandTester($command);
$command_tester->execute(['--schema-only' => 'router']);
// Assert that insert statement doesn't exist for schema only table.
$output = $command_tester->getDisplay();
$this->assertStringContainsString("createTable('router", $output, 'Table router found');
$this->assertStringNotContainsString("insert('router", $output, 'Insert not found');
$this->assertStringNotContainsString("'name' => 'test", $output, 'Insert name field not found');
$this->assertStringNotContainsString("'path' => 'test", $output, 'Insert path field not found');
$this->assertStringNotContainsString("'pattern_outline' => 'test", $output, 'Insert pattern_outline field not found');
// Assert that insert statement doesn't exist for wildcard schema only match.
$command_tester->execute(['--schema-only' => 'route.*']);
$output = $command_tester->getDisplay();
$this->assertStringContainsString("createTable('router", $output, 'Table router found');
$this->assertStringNotContainsString("insert('router", $output, 'Insert not found');
$this->assertStringNotContainsString("'name' => 'test", $output, 'Insert name field not found');
$this->assertStringNotContainsString("'path' => 'test", $output, 'Insert path field not found');
$this->assertStringNotContainsString("'pattern_outline' => 'test", $output, 'Insert pattern_outline field not found');
}
/**
* Tests insert count option.
*/
public function testInsertCount(): void {
$command = new DbDumpCommand();
$command_tester = new CommandTester($command);
$command_tester->execute(['--insert-count' => '1']);
$router_row_count = (int) $this->container->get('database')->select('router')->countQuery()->execute()->fetchField();
$output = $command_tester->getDisplay();
$this->assertSame($router_row_count, substr_count($output, "insert('router"));
$this->assertGreaterThan(1, $router_row_count);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\Core\Database\DatabaseExceptionWrapper;
use Drupal\Core\Database\Database;
use Drupal\KernelTests\Core\Database\DriverSpecificKernelTestBase;
/**
* Tests exceptions thrown by queries.
*
* @group Database
*/
class DatabaseExceptionWrapperTest extends DriverSpecificKernelTestBase {
/**
* Tests Connection::prepareStatement exceptions on preparation.
*
* Core database drivers use PDO emulated statements or the StatementPrefetch
* class, which defer the statement check to the moment of the execution. In
* order to test a failure at preparation time, we have to force the
* connection not to emulate statement preparation. Still, this is only valid
* for the MySql driver.
*/
public function testPrepareStatementFailOnPreparation(): void {
$connection_info = Database::getConnectionInfo('default');
$connection_info['default']['pdo'][\PDO::ATTR_EMULATE_PREPARES] = FALSE;
Database::addConnectionInfo('default', 'foo', $connection_info['default']);
$foo_connection = Database::getConnection('foo', 'default');
$this->expectException(DatabaseExceptionWrapper::class);
$stmt = $foo_connection->prepareStatement('bananas', []);
}
/**
* Tests Connection::prepareStatement exception on execution.
*/
public function testPrepareStatementFailOnExecution(): void {
$this->expectException(\PDOException::class);
$stmt = $this->connection->prepareStatement('bananas', []);
$stmt->execute();
}
}

View File

@@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Command\DbDumpApplication;
use Drupal\Core\Config\DatabaseStorage;
use Drupal\Core\Database\Database;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\KernelTests\Core\Database\DriverSpecificKernelTestBase;
use Drupal\Tests\Traits\Core\PathAliasTestTrait;
use Drupal\user\Entity\User;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\DependencyInjection\Reference;
/**
* Tests for the database dump commands.
*
* @group Update
*/
class DbDumpTest extends DriverSpecificKernelTestBase {
use PathAliasTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
// @todo system can be removed from this test once
// https://www.drupal.org/project/drupal/issues/2851705 is committed.
'system',
'config',
'dblog',
'menu_link_content',
'link',
'block_content',
'file',
'path_alias',
'user',
];
/**
* Test data to write into config.
*
* @var array
*/
protected $data;
/**
* An array of original table schemas.
*
* @var array
*/
protected $originalTableSchemas = [];
/**
* An array of original table indexes (including primary and unique keys).
*
* @var array
*/
protected $originalTableIndexes = [];
/**
* Tables that should be part of the exported script.
*
* @var array
*/
protected $tables;
/**
* {@inheritdoc}
*
* Register a database cache backend rather than memory-based.
*/
public function register(ContainerBuilder $container) {
parent::register($container);
$container->register('cache_factory', 'Drupal\Core\Cache\DatabaseBackendFactory')
->addArgument(new Reference('database'))
->addArgument(new Reference('cache_tags.invalidator.checksum'))
->addArgument(new Reference('settings'))
->addArgument(new Reference('serialization.phpserialize'))
->addArgument(new Reference(TimeInterface::class));
}
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create some schemas so our export contains tables.
$this->installSchema('dblog', ['watchdog']);
$this->installEntitySchema('block_content');
$this->installEntitySchema('user');
$this->installEntitySchema('file');
$this->installEntitySchema('menu_link_content');
$this->installEntitySchema('path_alias');
// Place some sample config to test for in the export.
$this->data = [
'foo' => $this->randomMachineName(),
'bar' => $this->randomMachineName(),
];
$storage = new DatabaseStorage(Database::getConnection(), 'config');
$storage->write('test_config', $this->data);
// Create user account with some potential syntax issues.
// cspell:disable-next-line
$account = User::create(['mail' => 'q\'uote$dollar@example.com', 'name' => '$dollar']);
$account->save();
// Create a path alias.
$this->createPathAlias('/user/' . $account->id(), '/user/example');
// Create a cache table (this will create 'cache_discovery').
\Drupal::cache('discovery')->set('test', $this->data);
// These are all the tables that should now be in place.
$this->tables = [
'block_content',
'block_content_field_data',
'block_content_field_revision',
'block_content_revision',
'cachetags',
'config',
'cache_bootstrap',
'cache_config',
'cache_discovery',
'cache_entity',
'file_managed',
'menu_link_content',
'menu_link_content_data',
'menu_link_content_revision',
'menu_link_content_field_revision',
'path_alias',
'path_alias_revision',
'user__roles',
'users',
'users_field_data',
'watchdog',
];
}
/**
* Tests the command directly.
*/
public function testDbDumpCommand(): void {
$application = new DbDumpApplication();
$command = $application->find('dump-database-d8-mysql');
$command_tester = new CommandTester($command);
$command_tester->execute([]);
// Tables that are schema-only should not have data exported.
$pattern = preg_quote("\$connection->insert('sessions')");
$this->assertDoesNotMatchRegularExpression('/' . $pattern . '/', $command_tester->getDisplay(), 'Tables defined as schema-only do not have data exported to the script.');
// Table data is exported.
$pattern = preg_quote("\$connection->insert('config')");
$this->assertMatchesRegularExpression('/' . $pattern . '/', $command_tester->getDisplay(), 'Table data is properly exported to the script.');
// The test data are in the dump (serialized).
$pattern = preg_quote(serialize($this->data));
$this->assertMatchesRegularExpression('/' . $pattern . '/', $command_tester->getDisplay(), 'Generated data is found in the exported script.');
// Check that the user account name and email address was properly escaped.
// cspell:disable-next-line
$pattern = preg_quote('"q\'uote\$dollar@example.com"');
$this->assertMatchesRegularExpression('/' . $pattern . '/', $command_tester->getDisplay(), 'The user account email address was properly escaped in the exported script.');
$pattern = preg_quote('\'$dollar\'');
$this->assertMatchesRegularExpression('/' . $pattern . '/', $command_tester->getDisplay(), 'The user account name was properly escaped in the exported script.');
}
/**
* Tests loading the script back into the database.
*/
public function testScriptLoad(): void {
// Generate the script.
$application = new DbDumpApplication();
$command = $application->find('dump-database-d8-mysql');
$command_tester = new CommandTester($command);
$command_tester->execute([]);
$script = $command_tester->getDisplay();
// Store original schemas and drop tables to avoid errors.
$connection = Database::getConnection();
$schema = $connection->schema();
foreach ($this->tables as $table) {
$this->originalTableSchemas[$table] = $this->getTableSchema($table);
$this->originalTableIndexes[$table] = $this->getTableIndexes($table);
$schema->dropTable($table);
}
// This will load the data.
$file = sys_get_temp_dir() . '/' . $this->randomMachineName();
file_put_contents($file, $script);
require_once $file;
// The tables should now exist and the schemas should match the originals.
foreach ($this->tables as $table) {
$this->assertTrue($schema
->tableExists($table), "Table $table created by the database script.");
$this->assertSame($this->originalTableSchemas[$table], $this->getTableSchema($table), "The schema for $table was properly restored.");
$this->assertSame($this->originalTableIndexes[$table], $this->getTableIndexes($table), "The indexes for $table were properly restored.");
}
// Ensure the test config has been replaced.
$config = unserialize($connection->select('config', 'c')->fields('c', ['data'])->condition('name', 'test_config')->execute()->fetchField());
$this->assertSame($this->data, $config, 'Script has properly restored the config table data.');
// Ensure the cache data was not exported.
$this->assertFalse(\Drupal::cache('discovery')
->get('test'), 'Cache data was not exported to the script.');
}
/**
* Helper function to get a simplified schema for a given table.
*
* @param string $table
* The table name.
*
* @return array
* Array keyed by field name, with the values being the field type.
*/
protected function getTableSchema($table) {
// Verify the field type on the data column in the cache table.
// @todo this is MySQL specific.
$query = Database::getConnection()->query("SHOW COLUMNS FROM {" . $table . "}");
$definition = [];
while ($row = $query->fetchAssoc()) {
$definition[$row['Field']] = $row['Type'];
}
return $definition;
}
/**
* Returns indexes for a given table.
*
* @param string $table
* The table to find indexes for.
*
* @return array
* The 'primary key', 'unique keys', and 'indexes' portion of the Drupal
* table schema.
*/
protected function getTableIndexes($table) {
$query = Database::getConnection()->query("SHOW INDEX FROM {" . $table . "}");
$definition = [];
while ($row = $query->fetchAssoc()) {
$index_name = $row['Key_name'];
$column = $row['Column_name'];
// Key the arrays by the index sequence for proper ordering (start at 0).
$order = $row['Seq_in_index'] - 1;
// If specified, add length to the index.
if ($row['Sub_part']) {
$column = [$column, $row['Sub_part']];
}
if ($index_name === 'PRIMARY') {
$definition['primary key'][$order] = $column;
}
elseif ($row['Non_unique'] == 0) {
$definition['unique keys'][$index_name][$order] = $column;
}
else {
$definition['indexes'][$index_name][$order] = $column;
}
}
return $definition;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\Component\Utility\Environment;
use Drupal\Core\Database\Database;
use Drupal\Core\Database\DatabaseException;
use Drupal\KernelTests\Core\Database\DriverSpecificDatabaseTestBase;
/**
* Tests handling of large queries.
*
* @group Database
*/
class LargeQueryTest extends DriverSpecificDatabaseTestBase {
/**
* Tests truncation of messages when max_allowed_packet exception occurs.
*/
public function testMaxAllowedPacketQueryTruncating(): void {
// The max_allowed_packet value is configured per database instance.
// Retrieve the max_allowed_packet value from the current instance and
// check if PHP is configured with sufficient allowed memory to be able
// to generate a query larger than max_allowed_packet.
$max_allowed_packet = $this->connection->query('SELECT @@global.max_allowed_packet')->fetchField();
if (!Environment::checkMemoryLimit($max_allowed_packet + (16 * 1024 * 1024))) {
$this->markTestSkipped('The configured max_allowed_packet exceeds the php memory limit. Therefore the test is skipped.');
}
$long_name = str_repeat('a', $max_allowed_packet + 1);
try {
$this->connection->query('SELECT [name] FROM {test} WHERE [name] = :name', [':name' => $long_name]);
$this->fail("An exception should be thrown for queries larger than 'max_allowed_packet'");
}
catch (DatabaseException $e) {
// Close and re-open the connection. Otherwise we will run into error
// 2006 "MySQL server had gone away" afterwards.
Database::closeConnection();
Database::getConnection();
// Got a packet bigger than 'max_allowed_packet' bytes exception thrown.
$this->assertEquals(1153, $e->getPrevious()->errorInfo[1]);
// 'max_allowed_packet' exception message truncated.
// Use strlen() to count the bytes exactly, not the unicode chars.
$this->assertLessThanOrEqual($max_allowed_packet, strlen($e->getMessage()));
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\Core\Database\Driver\mysql\Connection;
use Drupal\Core\Database\Driver\mysql\ExceptionHandler;
use Drupal\Core\Database\Driver\mysql\Install\Tasks;
use Drupal\Core\Database\Driver\mysql\Insert;
use Drupal\Core\Database\Driver\mysql\Schema;
use Drupal\Core\Database\Driver\mysql\Upsert;
use Drupal\KernelTests\Core\Database\DriverSpecificDatabaseTestBase;
use Drupal\Tests\Core\Database\Stub\StubPDO;
/**
* Tests the deprecations of the MySQL database driver classes in Core.
*
* @group legacy
* @group Database
*/
class MysqlDriverLegacyTest extends DriverSpecificDatabaseTestBase {
/**
* @covers Drupal\Core\Database\Driver\mysql\Install\Tasks
*/
public function testDeprecationInstallTasks(): void {
$this->expectDeprecation('\Drupal\Core\Database\Driver\mysql\Install\Tasks is deprecated in drupal:9.4.0 and is removed from drupal:11.0.0. The MySQL database driver has been moved to the mysql module. See https://www.drupal.org/node/3129492');
$tasks = new Tasks();
$this->assertInstanceOf(Tasks::class, $tasks);
}
/**
* @covers Drupal\Core\Database\Driver\mysql\Connection
*/
public function testDeprecationConnection(): void {
$this->expectDeprecation('\Drupal\Core\Database\Driver\mysql\Connection is deprecated in drupal:9.4.0 and is removed from drupal:11.0.0. The MySQL database driver has been moved to the mysql module. See https://www.drupal.org/node/3129492');
// @todo https://www.drupal.org/project/drupal/issues/3251084 Remove setting
// the $options parameter.
$options['init_commands']['sql_mode'] = '';
$connection = new Connection($this->createMock(StubPDO::class), $options);
$this->assertInstanceOf(Connection::class, $connection);
}
/**
* @covers Drupal\Core\Database\Driver\mysql\ExceptionHandler
*/
public function testDeprecationExceptionHandler(): void {
$this->expectDeprecation('\Drupal\Core\Database\Driver\mysql\ExceptionHandler is deprecated in drupal:9.4.0 and is removed from drupal:11.0.0. The MySQL database driver has been moved to the mysql module. See https://www.drupal.org/node/3129492');
$handler = new ExceptionHandler();
$this->assertInstanceOf(ExceptionHandler::class, $handler);
}
/**
* @covers Drupal\Core\Database\Driver\mysql\Insert
*/
public function testDeprecationInsert(): void {
$this->expectDeprecation('\Drupal\Core\Database\Driver\mysql\Insert is deprecated in drupal:9.4.0 and is removed from drupal:11.0.0. The MySQL database driver has been moved to the mysql module. See https://www.drupal.org/node/3129492');
$insert = new Insert($this->connection, 'test');
$this->assertInstanceOf(Insert::class, $insert);
}
/**
* @covers Drupal\Core\Database\Driver\mysql\Schema
*/
public function testDeprecationSchema(): void {
$this->expectDeprecation('\Drupal\Core\Database\Driver\mysql\Schema is deprecated in drupal:9.4.0 and is removed from drupal:11.0.0. The MySQL database driver has been moved to the mysql module. See https://www.drupal.org/node/3129492');
$schema = new Schema($this->connection);
$this->assertInstanceOf(Schema::class, $schema);
}
/**
* @covers Drupal\Core\Database\Driver\mysql\Upsert
*/
public function testDeprecationUpsert(): void {
$this->expectDeprecation('\Drupal\Core\Database\Driver\mysql\Upsert is deprecated in drupal:9.4.0 and is removed from drupal:11.0.0. The MySQL database driver has been moved to the mysql module. See https://www.drupal.org/node/3129492');
$upsert = new Upsert($this->connection, 'test');
$this->assertInstanceOf(Upsert::class, $upsert);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\mysql\Driver\Database\mysql\Connection;
use Drupal\KernelTests\Core\Database\DriverSpecificKernelTestBase;
use Drupal\Tests\Core\Database\Stub\StubPDO;
/**
* Tests the deprecations of the MySQL database driver classes in Core.
*
* @group Database
*/
class MysqlDriverTest extends DriverSpecificKernelTestBase {
/**
* @covers \Drupal\mysql\Driver\Database\mysql\Connection
*/
public function testConnection(): void {
$connection = new Connection($this->createMock(StubPDO::class), []);
$this->assertInstanceOf(Connection::class, $connection);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\Core\Database\Database;
use Drupal\KernelTests\Core\Database\DriverSpecificDatabaseTestBase;
/**
* Tests the sequences API.
*
* @group Database
* @group legacy
*/
class NextIdTest extends DriverSpecificDatabaseTestBase {
/**
* The modules to enable.
*
* @var array
*/
protected static $modules = ['database_test', 'system'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$table_specification = [
'description' => 'Stores IDs.',
'fields' => [
'value' => [
'description' => 'The value of the sequence.',
'type' => 'serial',
'unsigned' => TRUE,
'not null' => TRUE,
],
],
'primary key' => ['value'],
];
$this->connection->schema()->createTable('sequences', $table_specification);
}
/**
* Tests that sequences table clear up works when a connection is closed.
*
* @see \Drupal\mysql\Driver\Database\mysql\Connection::__destruct()
*/
public function testDbNextIdClosedConnection(): void {
$this->expectDeprecation('Drupal\Core\Database\Connection::nextId() is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Modules should use instead the keyvalue storage for the last used id. See https://www.drupal.org/node/3349345');
$this->expectDeprecation('Drupal\mysql\Driver\Database\mysql\Connection::nextIdDelete() is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Modules should use instead the keyvalue storage for the last used id. See https://www.drupal.org/node/3349345');
// Create an additional connection to test closing the connection.
$connection_info = Database::getConnectionInfo();
Database::addConnectionInfo('default', 'next_id', $connection_info['default']);
// Get a few IDs to ensure there the clean up needs to run and there is more
// than one row.
Database::getConnection('next_id')->nextId();
Database::getConnection('next_id')->nextId();
// At this point the sequences table should contain unnecessary rows.
$count = $this->connection->select('sequences')->countQuery()->execute()->fetchField();
$this->assertGreaterThan(1, $count);
// Close the connection.
Database::closeConnection('next_id');
// Test that \Drupal\mysql\Driver\Database\mysql\Connection::__destruct()
// successfully trims the sequences table if the connection is closed.
$count = $this->connection->select('sequences')->countQuery()->execute()->fetchField();
$this->assertEquals(1, $count);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql\Plugin\views;
use Drupal\Tests\views\Kernel\Plugin\CastedIntFieldJoinTestBase;
/**
* Tests MySQL specific cast handling.
*
* @group Database
*/
class MySqlCastedIntFieldJoinTest extends CastedIntFieldJoinTestBase {
/**
* The db type that should be used for casting fields as integers.
*/
protected string $castingType = 'UNSIGNED';
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\Core\Database\Database;
use Drupal\KernelTests\Core\Database\DriverSpecificKernelTestBase;
/**
* Tests that the prefix info for a database schema is correct.
*
* @group Database
*/
class PrefixInfoTest extends DriverSpecificKernelTestBase {
/**
* Tests that DatabaseSchema::getPrefixInfo() returns the right database.
*
* We are testing if the return array of the method
* \Drupal\mysql\Driver\Database\mysql\Schema::getPrefixInfo(). This return
* array is a keyed array with info about amongst other things the database.
* The other two by Drupal core supported databases do not have this variable
* set in the return array.
*/
public function testGetPrefixInfo(): void {
$connection_info = Database::getConnectionInfo('default');
// Copy the default connection info to the 'extra' key.
Database::addConnectionInfo('extra', 'default', $connection_info['default']);
$db1_connection = Database::getConnection('default', 'default');
$db1_schema = $db1_connection->schema();
$db2_connection = Database::getConnection('default', 'extra');
// Get the prefix info for the first database.
$method = new \ReflectionMethod($db1_schema, 'getPrefixInfo');
$db1_info = $method->invoke($db1_schema);
// We change the database after opening the connection, so as to prevent
// connecting to a non-existent database.
$reflection = new \ReflectionObject($db2_connection);
$property = $reflection->getProperty('connectionOptions');
$connection_info['default']['database'] = 'foobar';
$property->setValue($db2_connection, $connection_info['default']);
// For testing purposes, we also change the database info.
$reflection_class = new \ReflectionClass(Database::class);
$property = $reflection_class->getProperty('databaseInfo');
$info = $property->getValue();
$info['extra']['default']['database'] = 'foobar';
$property->setValue(NULL, $info);
$extra_info = Database::getConnectionInfo('extra');
$this->assertSame('foobar', $extra_info['default']['database']);
$db2_schema = $db2_connection->schema();
$db2_info = $method->invoke($db2_schema);
// Each target connection has a different database.
$this->assertNotSame($db2_info['database'], $db1_info['database']);
// The new profile has a different database.
$this->assertSame('foobar', $db2_info['database']);
Database::removeConnection('extra');
}
}

View File

@@ -0,0 +1,345 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\Component\Utility\Unicode;
use Drupal\Core\Database\DatabaseExceptionWrapper;
use Drupal\Core\Database\Exception\SchemaTableColumnSizeTooLargeException;
use Drupal\Core\Database\Exception\SchemaTableKeyTooLargeException;
use Drupal\Core\Database\SchemaException;
use Drupal\Core\Database\SchemaObjectDoesNotExistException;
use Drupal\Core\Database\SchemaObjectExistsException;
use Drupal\KernelTests\Core\Database\DriverSpecificSchemaTestBase;
/**
* Tests schema API for the MySQL driver.
*
* @group Database
*/
class SchemaTest extends DriverSpecificSchemaTestBase {
/**
* {@inheritdoc}
*/
public function checkSchemaComment(string $description, string $table, ?string $column = NULL): void {
$comment = $this->schema->getComment($table, $column);
$max_length = $column ? 255 : 60;
$description = Unicode::truncate($description, $max_length, TRUE, TRUE);
$this->assertSame($description, $comment, 'The comment matches the schema description.');
}
/**
* {@inheritdoc}
*/
protected function assertCollation(): void {
// Make sure that varchar fields have the correct collations.
$columns = $this->connection->query('SHOW FULL COLUMNS FROM {test_table}');
foreach ($columns as $column) {
if ($column->Field == 'test_field_string') {
$string_check = $column->Collation;
}
if ($column->Field == 'test_field_string_ascii') {
$string_ascii_check = $column->Collation;
}
}
$this->assertMatchesRegularExpression('#^(utf8mb4_general_ci|utf8mb4_0900_ai_ci)$#', $string_check, 'test_field_string should have a utf8mb4_general_ci or a utf8mb4_0900_ai_ci collation, but it has not.');
$this->assertSame('ascii_general_ci', $string_ascii_check, 'test_field_string_ascii should have a ascii_general_ci collation, but it has not.');
}
/**
* {@inheritdoc}
*/
public function testTableWithSpecificDataType(): void {
$table_specification = [
'description' => 'Schema table description.',
'fields' => [
'timestamp' => [
'mysql_type' => 'timestamp',
'not null' => FALSE,
'default' => NULL,
],
],
];
$this->schema->createTable('test_timestamp', $table_specification);
$this->assertTrue($this->schema->tableExists('test_timestamp'));
}
/**
* Tests that indexes on string fields are limited to 191 characters on MySQL.
*
* @see \Drupal\mysql\Driver\Database\mysql\Schema::getNormalizedIndexes()
*/
public function testIndexLength(): void {
$table_specification = [
'fields' => [
'id' => [
'type' => 'int',
'default' => NULL,
],
'test_field_text' => [
'type' => 'text',
'not null' => TRUE,
],
'test_field_string_long' => [
'type' => 'varchar',
'length' => 255,
'not null' => TRUE,
],
'test_field_string_ascii_long' => [
'type' => 'varchar_ascii',
'length' => 255,
],
'test_field_string_short' => [
'type' => 'varchar',
'length' => 128,
'not null' => TRUE,
],
],
'indexes' => [
'test_regular' => [
'test_field_text',
'test_field_string_long',
'test_field_string_ascii_long',
'test_field_string_short',
],
'test_length' => [
['test_field_text', 128],
['test_field_string_long', 128],
['test_field_string_ascii_long', 128],
['test_field_string_short', 128],
],
'test_mixed' => [
['test_field_text', 200],
'test_field_string_long',
['test_field_string_ascii_long', 200],
'test_field_string_short',
],
],
];
$this->schema->createTable('test_table_index_length', $table_specification);
// Ensure expected exception thrown when adding index with missing info.
$expected_exception_message = "MySQL needs the 'test_field_text' field specification in order to normalize the 'test_regular' index";
$missing_field_spec = $table_specification;
unset($missing_field_spec['fields']['test_field_text']);
try {
$this->schema->addIndex('test_table_index_length', 'test_separate', [['test_field_text', 200]], $missing_field_spec);
$this->fail('SchemaException not thrown when adding index with missing information.');
}
catch (SchemaException $e) {
$this->assertEquals($expected_exception_message, $e->getMessage());
}
// Add a separate index.
$this->schema->addIndex('test_table_index_length', 'test_separate', [['test_field_text', 200]], $table_specification);
$table_specification_with_new_index = $table_specification;
$table_specification_with_new_index['indexes']['test_separate'] = [['test_field_text', 200]];
// Ensure that the exceptions of addIndex are thrown as expected.
try {
$this->schema->addIndex('test_table_index_length', 'test_separate', [['test_field_text', 200]], $table_specification);
$this->fail('\Drupal\Core\Database\SchemaObjectExistsException exception missed.');
}
catch (SchemaObjectExistsException $e) {
// Expected exception; just continue testing.
}
try {
$this->schema->addIndex('test_table_non_existing', 'test_separate', [['test_field_text', 200]], $table_specification);
$this->fail('\Drupal\Core\Database\SchemaObjectDoesNotExistException exception missed.');
}
catch (SchemaObjectDoesNotExistException $e) {
// Expected exception; just continue testing.
}
// Get index information.
$results = $this->connection->query('SHOW INDEX FROM {test_table_index_length}');
$expected_lengths = [
'test_regular' => [
'test_field_text' => 191,
'test_field_string_long' => 191,
'test_field_string_ascii_long' => NULL,
'test_field_string_short' => NULL,
],
'test_length' => [
'test_field_text' => 128,
'test_field_string_long' => 128,
'test_field_string_ascii_long' => 128,
'test_field_string_short' => NULL,
],
'test_mixed' => [
'test_field_text' => 191,
'test_field_string_long' => 191,
'test_field_string_ascii_long' => 200,
'test_field_string_short' => NULL,
],
'test_separate' => [
'test_field_text' => 191,
],
];
// Count the number of columns defined in the indexes.
$column_count = 0;
foreach ($table_specification_with_new_index['indexes'] as $index) {
foreach ($index as $field) {
$column_count++;
}
}
$test_count = 0;
foreach ($results as $result) {
$this->assertEquals($expected_lengths[$result->Key_name][$result->Column_name], $result->Sub_part, 'Index length matches expected value.');
$test_count++;
}
$this->assertEquals($column_count, $test_count, 'Number of tests matches expected value.');
}
/**
* @covers \Drupal\mysql\Driver\Database\mysql\Schema::introspectIndexSchema
*/
public function testIntrospectIndexSchema(): void {
$table_specification = [
'fields' => [
'id' => [
'type' => 'int',
'not null' => TRUE,
'default' => 0,
],
'test_field_1' => [
'type' => 'int',
'not null' => TRUE,
'default' => 0,
],
'test_field_2' => [
'type' => 'int',
'default' => 0,
],
'test_field_3' => [
'type' => 'int',
'default' => 0,
],
'test_field_4' => [
'type' => 'int',
'default' => 0,
],
'test_field_5' => [
'type' => 'int',
'default' => 0,
],
],
'primary key' => ['id', 'test_field_1'],
'unique keys' => [
'test_field_2' => ['test_field_2'],
'test_field_3_test_field_4' => ['test_field_3', 'test_field_4'],
],
'indexes' => [
'test_field_4' => ['test_field_4'],
'test_field_4_test_field_5' => ['test_field_4', 'test_field_5'],
],
];
$table_name = strtolower($this->getRandomGenerator()->name());
$this->schema->createTable($table_name, $table_specification);
unset($table_specification['fields']);
$introspect_index_schema = new \ReflectionMethod(get_class($this->schema), 'introspectIndexSchema');
$index_schema = $introspect_index_schema->invoke($this->schema, $table_name);
$this->assertEquals($table_specification, $index_schema);
}
/**
* Tests SchemaTableKeyTooLargeException.
*/
public function testSchemaTableKeyTooLargeException(): void {
$this->expectException(SchemaTableKeyTooLargeException::class);
$this->schema->createTable('test_schema', [
'description' => 'Tests SchemaTableKeyTooLargeException.',
'fields' => [
'id' => [
'type' => 'varchar',
'length' => 64,
'not null' => TRUE,
],
'id1' => [
'type' => 'varchar',
'length' => 255,
'not null' => TRUE,
],
'id2' => [
'type' => 'varchar',
'length' => 255,
'not null' => TRUE,
],
'id3' => [
'type' => 'varchar',
'length' => 255,
'not null' => TRUE,
],
'id4' => [
'type' => 'varchar',
'length' => 255,
'not null' => TRUE,
],
'id5' => [
'type' => 'varchar',
'length' => 255,
'not null' => TRUE,
],
],
'primary key' => ['id'],
'indexes' => [
'key1' => ['id1', 'id2', 'id3', 'id4', 'id5'],
],
]);
}
/**
* Tests SchemaTableColumnSizeTooLargeException.
*/
public function testSchemaTableColumnSizeTooLargeException(): void {
$this->expectException(SchemaTableColumnSizeTooLargeException::class);
$this->expectExceptionMessage("Column length too big for column 'too_large' (max = 16383); use BLOB or TEXT instead");
$this->schema->createTable('test_schema', [
'description' => 'Tests SchemaTableColumnSizeTooLargeException.',
'fields' => [
'too_large' => [
'type' => 'varchar',
'length' => 65536,
'not null' => TRUE,
],
],
]);
}
/**
* Tests adding a primary key when sql_generate_invisible_primary_key is on.
*/
public function testGeneratedInvisiblePrimaryKey(): void {
$is_maria = method_exists($this->connection, 'isMariaDb') && $this->connection->isMariaDb();
if ($this->connection->databaseType() !== 'mysql' || $is_maria || version_compare($this->connection->version(), '8.0.30', '<')) {
$this->markTestSkipped('This test only runs on MySQL 8.0.30 and above');
}
try {
$this->connection->query("SET sql_generate_invisible_primary_key = 1;")->execute();
}
catch (DatabaseExceptionWrapper $e) {
$this->markTestSkipped('This test requires the SESSION_VARIABLES_ADMIN privilege.');
}
$this->schema->createTable('test_primary_key', [
'fields' => [
'foo' => [
'type' => 'varchar',
'length' => 1,
],
],
]);
$this->schema->addField('test_primary_key', 'id', [
'type' => 'serial',
'not null' => TRUE,
], ['primary key' => ['id']]);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\KernelTests\Core\Database\SchemaUniquePrefixedKeysIndexTestBase;
/**
* Tests adding UNIQUE keys to tables.
*
* @group Database
*/
class SchemaUniquePrefixedKeysIndexTest extends SchemaUniquePrefixedKeysIndexTestBase {
/**
* {@inheritdoc}
*/
protected string $columnValue = '1234567890 bar';
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\KernelTests\Core\Database\DriverSpecificDatabaseTestBase;
/**
* Tests compatibility of the MySQL driver with various sql_mode options.
*
* @group Database
*/
class SqlModeTest extends DriverSpecificDatabaseTestBase {
/**
* Tests quoting identifiers in queries.
*/
public function testQuotingIdentifiers(): void {
// Use SQL-reserved words for both the table and column names.
$query = $this->connection->query('SELECT [update] FROM {select}');
$this->assertEquals('Update value 1', $query->fetchObject()->update);
$this->assertStringContainsString('SELECT `update` FROM `', $query->getQueryString());
}
/**
* {@inheritdoc}
*/
protected function getDatabaseConnectionInfo() {
$info = parent::getDatabaseConnectionInfo();
// This runs during setUp(), so is not yet skipped for non MySQL databases.
// We defer skipping the test to later in setUp(), so that that can be
// based on databaseType() rather than 'driver', but here all we have to go
// on is 'driver'.
if ($info['default']['driver'] === 'mysql') {
$info['default']['init_commands']['sql_mode'] = "SET sql_mode = ''";
}
return $info;
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\KernelTests\Core\Database\DriverSpecificSyntaxTestBase;
/**
* Tests MySql syntax interpretation.
*
* @group Database
*/
class SyntaxTest extends DriverSpecificSyntaxTestBase {
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\KernelTests\Core\Database\TemporaryQueryTestBase;
/**
* Tests the temporary query functionality.
*
* @group Database
*/
class TemporaryQueryTest extends TemporaryQueryTestBase {
/**
* Confirms that temporary tables work.
*/
public function testTemporaryQuery(): void {
parent::testTemporaryQuery();
$connection = $this->getConnection();
$table_name_test = $connection->queryTemporary('SELECT [name] FROM {test}', []);
// Assert that the table is indeed a temporary one.
$temporary_table_info = $connection->query("SHOW CREATE TABLE {" . $table_name_test . "}")->fetchAssoc();
$this->assertStringContainsString('CREATE TEMPORARY TABLE', $temporary_table_info['Create Table']);
// Assert that both have the same field names.
$normal_table_fields = $connection->query("SELECT * FROM {test}")->fetch();
$temp_table_name = $connection->queryTemporary('SELECT * FROM {test}');
$temp_table_fields = $connection->query("SELECT * FROM {" . $temp_table_name . "}")->fetch();
$normal_table_fields = array_keys(get_object_vars($normal_table_fields));
$temp_table_fields = array_keys(get_object_vars($temp_table_fields));
$this->assertEmpty(array_diff($normal_table_fields, $temp_table_fields));
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Kernel\mysql;
use Drupal\KernelTests\Core\Database\DriverSpecificTransactionTestBase;
/**
* Tests transaction for the MySQL driver.
*
* @group Database
*/
class TransactionTest extends DriverSpecificTransactionTestBase {
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Unit;
use Drupal\mysql\Driver\Database\mysql\Connection;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
/**
* Tests MySQL database connections.
*
* @coversDefaultClass \Drupal\mysql\Driver\Database\mysql\Connection
* @group Database
*/
class ConnectionTest extends UnitTestCase {
/**
* A PDO statement prophecy.
*
* @var \PDOStatement|\Prophecy\Prophecy\ObjectProphecy
*/
private $pdoStatement;
/**
* A PDO object prophecy.
*
* @var \PDO|\Prophecy\Prophecy\ObjectProphecy
*/
private $pdoConnection;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->pdoStatement = $this->prophesize(\PDOStatement::class);
$this->pdoConnection = $this->prophesize(\PDO::class);
}
/**
* Creates a Connection object for testing.
*
* @return \Drupal\mysql\Driver\Database\mysql\Connection
*/
private function createConnection(): Connection {
$this->pdoStatement
->setFetchMode(Argument::any())
->shouldBeCalled()
->willReturn(TRUE);
$this->pdoStatement
->execute(Argument::any())
->shouldBeCalled()
->willReturn(TRUE);
$this->pdoConnection
->prepare('SELECT VERSION()', Argument::any())
->shouldBeCalled()
->willReturn($this->pdoStatement->reveal());
/** @var \PDO $pdo_connection */
$pdo_connection = $this->pdoConnection->reveal();
return new class($pdo_connection) extends Connection {
public function __construct(\PDO $connection) {
$this->connection = $connection;
$this->setPrefix('');
}
};
}
/**
* @covers ::version
* @covers ::isMariaDb
* @dataProvider providerVersionAndIsMariaDb
*/
public function testVersionAndIsMariaDb(bool $expected_is_mariadb, string $server_version, string $expected_version): void {
$this->pdoStatement
->fetchColumn(Argument::any())
->shouldBeCalled()
->willReturn($server_version);
$connection = $this->createConnection();
$is_mariadb = $connection->isMariaDb();
$version = $connection->version();
$this->assertSame($expected_is_mariadb, $is_mariadb);
$this->assertSame($expected_version, $version);
}
/**
* Provides test data.
*
* @return array
*/
public static function providerVersionAndIsMariaDb(): array {
return [
// MariaDB.
[
TRUE,
'10.2.0-MariaDB',
'10.2.0-MariaDB',
],
[
TRUE,
'10.2.1-MARIADB',
'10.2.1-MARIADB',
],
[
TRUE,
'10.2.2-alphaX-MARIADB',
'10.2.2-alphaX-MARIADB',
],
[
TRUE,
'5.5.5-10.2.20-MariaDB-1:10.2.20+maria~bionic',
'10.2.20-MariaDB-1:10.2.20+maria~bionic',
],
[
TRUE,
'5.5.5-10.3.22-MariaDB-0+deb10u1',
'10.3.22-MariaDB-0+deb10u1',
],
[
TRUE,
'5.5.5-10.3.22-buzz+-MariaDB-0+deb10u1',
'10.3.22-buzz+-MariaDB-0+deb10u1',
],
// MySQL.
[
FALSE,
'5.5.5-10.2.20-notMariaDB',
'5.5.5-10.2.20-notMariaDB',
],
[
FALSE,
'5.5.5',
'5.5.5',
],
[
FALSE,
'5.5.5-',
'5.5.5-',
],
[
FALSE,
'5.7.28',
'5.7.28',
],
[
FALSE,
'5.7.28-31',
'5.7.28-31',
],
];
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\mysql\Unit;
use Drupal\mysql\Driver\Database\mysql\Connection;
use Drupal\mysql\Driver\Database\mysql\Install\Tasks;
use Drupal\Tests\UnitTestCase;
/**
* Tests the MySQL install tasks.
*
* @coversDefaultClass \Drupal\mysql\Driver\Database\mysql\Install\Tasks
* @group Database
*/
class InstallTasksTest extends UnitTestCase {
/**
* A connection object prophecy.
*
* @var \Drupal\mysql\Driver\Database\mysql\Connection|\Prophecy\Prophecy\ObjectProphecy
*/
private $connection;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->connection = $this->prophesize(Connection::class);
}
/**
* Creates a Tasks object for testing.
*
* @return \Drupal\mysql\Driver\Database\mysql\Install\Tasks
*/
private function createTasks(): Tasks {
/** @var \Drupal\mysql\Driver\Database\mysql\Connection $connection */
$connection = $this->connection->reveal();
return new class($connection) extends Tasks {
private $connection;
public function __construct(Connection $connection) {
$this->connection = $connection;
}
protected function isConnectionActive() {
return TRUE;
}
protected function getConnection() {
return $this->connection;
}
protected function t($string, array $args = [], array $options = []) {
return $string;
}
};
}
/**
* Creates a Tasks object for testing, without connection.
*
* @return \Drupal\mysql\Driver\Database\mysql\Install\Tasks
*/
private function createTasksNoConnection(): Tasks {
return new class() extends Tasks {
protected function isConnectionActive() {
return FALSE;
}
protected function getConnection() {
return NULL;
}
protected function t($string, array $args = [], array $options = []) {
return $string;
}
};
}
/**
* @covers ::minimumVersion
* @covers ::name
* @dataProvider providerNameAndMinimumVersion
*/
public function testNameAndMinimumVersion(bool $is_mariadb, string $expected_name, string $expected_minimum_version): void {
$this->connection
->isMariaDb()
->shouldBeCalledTimes(2)
->willReturn($is_mariadb);
$tasks = $this->createTasks();
$minimum_version = $tasks->minimumVersion();
$name = $tasks->name();
$this->assertSame($expected_minimum_version, $minimum_version);
$this->assertSame($expected_name, $name);
}
/**
* Provides test data.
*
* @return array
*/
public static function providerNameAndMinimumVersion(): array {
return [
[
TRUE,
'MariaDB',
Tasks::MARIADB_MINIMUM_VERSION,
],
[
FALSE,
'MySQL, Percona Server, or equivalent',
Tasks::MYSQL_MINIMUM_VERSION,
],
];
}
/**
* @covers ::name
*/
public function testNameWithNoConnection(): void {
$tasks = $this->createTasksNoConnection();
$this->assertSame('MySQL, MariaDB, Percona Server, or equivalent', $tasks->name());
}
}