diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fbcfb8496a310cfb95a7603c056076e619fb1b27..a82cbdf70bf4105f919e8d04166b3ede1951965b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -231,6 +231,8 @@ default: variables: _TARGET_PHP: "8.3-ubuntu" _TARGET_DB: "mysql-8" + _TARGET_DB_DRIVER: "mysql" + _TARGET_DB_DRIVER_MODULE: "mysql" PERFORMANCE_TEST: $PERFORMANCE_TEST # Run on MR, schedule, push, parent pipeline and performance test. rules: @@ -265,6 +267,8 @@ default: variables: _TARGET_PHP: "8.3-ubuntu" _TARGET_DB: "mysql-8" + _TARGET_DB_DRIVER: "mysql" + _TARGET_DB_DRIVER_MODULE: "mysql" rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" trigger: @@ -281,18 +285,40 @@ default: variables: _TARGET_PHP: "8.3-ubuntu" _TARGET_DB: "mariadb-10.6" + _TARGET_DB_DRIVER: "mysql" + _TARGET_DB_DRIVER_MODULE: "mysql" + +'mysqli - PHP 8.3 MySQL 8.4': + <<: [ *default-stage, *run-on-mr ] + variables: + _TARGET_PHP: "8.3-ubuntu" + _TARGET_DB: "mysql-8.4" + _TARGET_DB_DRIVER: "mysqli" + _TARGET_DB_DRIVER_MODULE: "mysqli" + +'mysqli - PHP 8.4 MySQL 9.3': + <<: [ *default-stage, *run-on-mr ] + variables: + _TARGET_PHP: "8.4-ubuntu" + _TARGET_DB: "mysql-9" + _TARGET_DB_DRIVER: "mysqli" + _TARGET_DB_DRIVER_MODULE: "mysqli" 'PHP 8.3 MySQL 8.4': <<: [ *default-stage, *run-on-mr ] variables: _TARGET_PHP: "8.3-ubuntu" _TARGET_DB: "mysql-8.4" + _TARGET_DB_DRIVER: "mysql" + _TARGET_DB_DRIVER_MODULE: "mysql" 'PHP 8.4 MySQL 9.3': <<: [ *default-stage, *run-on-mr ] variables: _TARGET_PHP: "8.4-ubuntu" _TARGET_DB: "mysql-9" + _TARGET_DB_DRIVER: "mysql" + _TARGET_DB_DRIVER_MODULE: "mysql" 'PHP 8.3 PostgreSQL 16': <<: [ *default-stage, *run-on-mr ] diff --git a/.gitlab-ci/pipeline.yml b/.gitlab-ci/pipeline.yml index c311b1a03a634714ef411ea640801b97f3b4f377..d5b8f31541a021d63a71875b2641aef223a724b4 100644 --- a/.gitlab-ci/pipeline.yml +++ b/.gitlab-ci/pipeline.yml @@ -22,8 +22,8 @@ default: before_script: - | [[ $_TARGET_DB == sqlite* ]] && export SIMPLETEST_DB=sqlite://localhost/$CI_PROJECT_DIR/sites/default/files/db.sqlite?module=sqlite - [[ $_TARGET_DB == mysql* ]] && export SIMPLETEST_DB=mysql://$MYSQL_USER:$MYSQL_PASSWORD@database/$MYSQL_DATABASE?module=mysql - [[ $_TARGET_DB == mariadb* ]] && export SIMPLETEST_DB=mysql://$MYSQL_USER:$MYSQL_PASSWORD@database/$MYSQL_DATABASE?module=mysql + [[ $_TARGET_DB == mysql* ]] && export SIMPLETEST_DB=$_TARGET_DB_DRIVER://$MYSQL_USER:$MYSQL_PASSWORD@database/$MYSQL_DATABASE?module=$_TARGET_DB_DRIVER_MODULE + [[ $_TARGET_DB == mariadb* ]] && export SIMPLETEST_DB=$_TARGET_DB_DRIVER://$MYSQL_USER:$MYSQL_PASSWORD@database/$MYSQL_DATABASE?module=$_TARGET_DB_DRIVER_MODULE [[ $_TARGET_DB == pgsql* ]] && export SIMPLETEST_DB=pgsql://$POSTGRES_USER:$POSTGRES_PASSWORD@database/$POSTGRES_DB?module=pgsql - echo "SIMPLETEST_DB = $SIMPLETEST_DB" - $CI_PROJECT_DIR/.gitlab-ci/scripts/server-setup.sh @@ -269,8 +269,8 @@ variables: # Determine DB driver. - | [[ $_TARGET_DB == sqlite* ]] && export SIMPLETEST_DB=sqlite://localhost/subdirectory/sites/default/files/db.sqlite?module=sqlite - [[ $_TARGET_DB == mysql* ]] && export SIMPLETEST_DB=mysql://$MYSQL_USER:$MYSQL_PASSWORD@database/$MYSQL_DATABASE?module=mysql - [[ $_TARGET_DB == mariadb* ]] && export SIMPLETEST_DB=mysql://$MYSQL_USER:$MYSQL_PASSWORD@database/$MYSQL_DATABASE?module=mysql + [[ $_TARGET_DB == mysql* ]] && export SIMPLETEST_DB=$_TARGET_DB_DRIVER://$MYSQL_USER:$MYSQL_PASSWORD@database/$MYSQL_DATABASE?module=$_TARGET_DB_DRIVER_MODULE + [[ $_TARGET_DB == mariadb* ]] && export SIMPLETEST_DB=$_TARGET_DB_DRIVER://$MYSQL_USER:$MYSQL_PASSWORD@database/$MYSQL_DATABASE?module=$_TARGET_DB_DRIVER_MODULE [[ $_TARGET_DB == pgsql* ]] && export SIMPLETEST_DB=pgsql://$POSTGRES_USER:$POSTGRES_PASSWORD@database/$POSTGRES_DB?module=pgsql - composer install --optimize-autoloader - export OTEL_COLLECTOR="$OTEL_COLLECTOR" diff --git a/core/lib/Drupal/Core/Database/Database.php b/core/lib/Drupal/Core/Database/Database.php index e76bc2d6991fc7c6e69f737def67505683c3cb4a..ba4195252f652a5cfe301eb787223f9ab250f0ad 100644 --- a/core/lib/Drupal/Core/Database/Database.php +++ b/core/lib/Drupal/Core/Database/Database.php @@ -202,19 +202,12 @@ final public static function parseConnectionInfo(array $info) { // arrays. Those have the wrong 'namespace' key set, or not set at all // for core supported database drivers. if (empty($info['namespace']) || str_starts_with($info['namespace'], 'Drupal\\Core\\Database\\Driver\\')) { - switch (strtolower($info['driver'])) { - case 'mysql': - $info['namespace'] = 'Drupal\\mysql\\Driver\\Database\\mysql'; - break; - - case 'pgsql': - $info['namespace'] = 'Drupal\\pgsql\\Driver\\Database\\pgsql'; - break; - - case 'sqlite': - $info['namespace'] = 'Drupal\\sqlite\\Driver\\Database\\sqlite'; - break; - } + $info['namespace'] = match (strtolower($info['driver'])) { + 'mysql' => 'Drupal\\mysql\\Driver\\Database\\mysql', + 'mysqli' => 'Drupal\\mysqli\\Driver\\Database\\mysqli', + 'pgsql' => 'Drupal\\pgsql\\Driver\\Database\\pgsql', + 'sqlite' => 'Drupal\\sqlite\\Driver\\Database\\sqlite', + }; } // Backwards compatibility layer for Drupal 8 style database connection // arrays. Those do not have the 'autoload' key set for core database @@ -225,6 +218,14 @@ final public static function parseConnectionInfo(array $info) { $info['autoload'] = "core/modules/mysql/src/Driver/Database/mysql/"; break; + case "Drupal\\mysqli\\Driver\\Database\\mysqli": + $info['autoload'] = "core/modules/mysqli/src/Driver/Database/mysqli/"; + $info['dependencies']['mysql'] = [ + 'namespace' => 'Drupal\\mysql', + 'autoload' => 'core/modules/mysql/src/', + ]; + break; + case "Drupal\\pgsql\\Driver\\Database\\pgsql": $info['autoload'] = "core/modules/pgsql/src/Driver/Database/pgsql/"; break; @@ -556,7 +557,6 @@ public static function convertDbUrlToConnectionInfo($url, $root, ?bool $include_ $additional_class_loader->addPsr4($dependency['namespace'] . '\\', $dependency['autoload']); } } - $additional_class_loader->register(TRUE); $options = $connection_class::createConnectionOptionsFromUrl($url, NULL); diff --git a/core/lib/Drupal/Core/Database/Exception/SchemaPrimaryKeyMustBeDroppedException.php b/core/lib/Drupal/Core/Database/Exception/SchemaPrimaryKeyMustBeDroppedException.php new file mode 100644 index 0000000000000000000000000000000000000000..799e9ca9560fbfa751eb37cea102e79b5ed00b77 --- /dev/null +++ b/core/lib/Drupal/Core/Database/Exception/SchemaPrimaryKeyMustBeDroppedException.php @@ -0,0 +1,12 @@ +<?php + +namespace Drupal\Core\Database\Exception; + +use Drupal\Core\Database\DatabaseException; +use Drupal\Core\Database\SchemaException; + +/** + * Exception thrown if the Primary Key must be dropped before an operation. + */ +class SchemaPrimaryKeyMustBeDroppedException extends SchemaException implements DatabaseException { +} diff --git a/core/lib/Drupal/Core/Database/Statement/PdoResult.php b/core/lib/Drupal/Core/Database/Statement/PdoResult.php index 1353ea8e8ad7c6f51eef60a96ba46cc13ade685b..f046001076af34cab8868f4277a44b7f0efa2895 100644 --- a/core/lib/Drupal/Core/Database/Statement/PdoResult.php +++ b/core/lib/Drupal/Core/Database/Statement/PdoResult.php @@ -30,6 +30,18 @@ public function __construct( parent::__construct($fetchMode, $fetchOptions); } + /** + * Returns the client-level database PDO statement object. + * + * This method should normally be used only within database driver code. + * + * @return \PDOStatement + * The client-level database PDO statement. + */ + public function getClientStatement(): \PDOStatement { + return $this->clientStatement; + } + /** * {@inheritdoc} */ diff --git a/core/lib/Drupal/Core/Database/Statement/PdoTrait.php b/core/lib/Drupal/Core/Database/Statement/PdoTrait.php index 3e1f104c9f4f02f478b7edf7fe01a6e8eaceb607..fd92d505d1dd7e23b9bdaeea45bb2909634c7a0d 100644 --- a/core/lib/Drupal/Core/Database/Statement/PdoTrait.php +++ b/core/lib/Drupal/Core/Database/Statement/PdoTrait.php @@ -49,23 +49,17 @@ protected function pdoToFetchAs(int $mode): FetchAs { } /** - * Returns the client-level database PDO statement object. + * Returns the client-level database statement object. * * This method should normally be used only within database driver code. * - * @return \PDOStatement - * The client-level database PDO statement. + * @return object + * The client-level database statement. * * @throws \RuntimeException * If the client-level statement is not set. */ - public function getClientStatement(): \PDOStatement { - if (isset($this->clientStatement)) { - assert($this->clientStatement instanceof \PDOStatement); - return $this->clientStatement; - } - throw new \LogicException('\\PDOStatement not initialized'); - } + abstract public function getClientStatement(): object; /** * Sets the default fetch mode for the PDO statement. diff --git a/core/lib/Drupal/Core/Database/Statement/StatementBase.php b/core/lib/Drupal/Core/Database/Statement/StatementBase.php index c193c5d350207dcc6f34bbde14f77b1996a20893..a52e7434c858d7698ed2da16a06dbdcc23b809d6 100644 --- a/core/lib/Drupal/Core/Database/Statement/StatementBase.php +++ b/core/lib/Drupal/Core/Database/Statement/StatementBase.php @@ -85,6 +85,36 @@ public function __construct( ) { } + /** + * Determines if the client-level database statement object exists. + * + * This method should normally be used only within database driver code. + * + * @return bool + * TRUE if the client statement exists, FALSE otherwise. + */ + public function hasClientStatement(): bool { + return isset($this->clientStatement); + } + + /** + * Returns the client-level database statement object. + * + * This method should normally be used only within database driver code. + * + * @return object + * The client-level database statement. + * + * @throws \RuntimeException + * If the client-level statement is not set. + */ + public function getClientStatement(): object { + if ($this->hasClientStatement()) { + return $this->clientStatement; + } + throw new \LogicException('Client statement not initialized'); + } + /** * {@inheritdoc} */ diff --git a/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php b/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php index 96bc07e7f89edd5e5b0d962d094e8bc489d7e288..b01eebd8d15fa84e0191643584f98cef3da4ccb8 100644 --- a/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php +++ b/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php @@ -96,6 +96,25 @@ public function __construct( parent::__construct($connection, $clientConnection, $queryString, $rowCountEnabled); } + /** + * Returns the client-level database PDO statement object. + * + * This method should normally be used only within database driver code. + * + * @return \PDOStatement + * The client-level database PDO statement. + * + * @throws \RuntimeException + * If the client-level statement is not set. + */ + public function getClientStatement(): \PDOStatement { + if (isset($this->clientStatement)) { + assert($this->clientStatement instanceof \PDOStatement); + return $this->clientStatement; + } + throw new \LogicException('\\PDOStatement not initialized'); + } + /** * {@inheritdoc} */ diff --git a/core/lib/Drupal/Core/Database/StatementWrapperIterator.php b/core/lib/Drupal/Core/Database/StatementWrapperIterator.php index 88dc007f54031825af4d63cbdfc0c1b8a7aa9734..d3cf4e9a40dda73cfdf1819a05a388881e3c5db4 100644 --- a/core/lib/Drupal/Core/Database/StatementWrapperIterator.php +++ b/core/lib/Drupal/Core/Database/StatementWrapperIterator.php @@ -47,6 +47,25 @@ public function __construct( $this->setFetchMode(FetchAs::Object); } + /** + * Returns the client-level database PDO statement object. + * + * This method should normally be used only within database driver code. + * + * @return \PDOStatement + * The client-level database PDO statement. + * + * @throws \RuntimeException + * If the client-level statement is not set. + */ + public function getClientStatement(): \PDOStatement { + if (isset($this->clientStatement)) { + assert($this->clientStatement instanceof \PDOStatement); + return $this->clientStatement; + } + throw new \LogicException('\\PDOStatement not initialized'); + } + /** * {@inheritdoc} */ @@ -71,7 +90,7 @@ public function execute($args = [], $options = []) { $this->result = new PdoResult( $this->fetchMode, $this->fetchOptions, - $this->clientStatement, + $this->getClientStatement(), ); $this->markResultsetIterable($return); } diff --git a/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php index a00d087e8347c3af704ac0b551831742e660736e..d4185e82669bd20649cedee02f089804d7b797af 100644 --- a/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php @@ -7,6 +7,7 @@ use Drupal\Core\Config\ConfigImporterEvent; use Drupal\Core\Config\ConfigImportValidateEventSubscriberBase; use Drupal\Core\Config\ConfigNameException; +use Drupal\Core\Database\Connection; use Drupal\Core\Extension\ConfigImportModuleUninstallValidatorInterface; use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Core\Extension\ThemeExtensionList; @@ -48,12 +49,15 @@ class ConfigImportSubscriber extends ConfigImportValidateEventSubscriberBase { * The module extension list. * @param \Traversable $uninstallValidators * The uninstall validator services. + * @param \Drupal\Core\Database\Connection $connection + * The database connection. */ public function __construct( ThemeExtensionList $theme_extension_list, ModuleExtensionList $extension_list_module, #[AutowireIterator(tag: 'module_install.uninstall_validator')] protected \Traversable $uninstallValidators, + protected readonly Connection $connection, ) { $this->themeList = $theme_extension_list; $this->moduleExtensionList = $extension_list_module; @@ -103,6 +107,7 @@ protected function validateModules(ConfigImporter $config_importer) { $current_core_extension = $config_importer->getStorageComparer()->getTargetStorage()->read('core.extension'); $install_profile = $current_core_extension['profile'] ?? NULL; $new_install_profile = $core_extension['profile'] ?? NULL; + $database_driver_module = $this->connection->getProvider(); // Ensure the profile is not changing. if ($install_profile !== $new_install_profile) { @@ -159,7 +164,10 @@ protected function validateModules(ConfigImporter $config_importer) { $uninstalls = $config_importer->getExtensionChangelist('module', 'uninstall'); foreach ($uninstalls as $module) { foreach (array_keys($module_data[$module]->required_by) as $dependent_module) { - if ($module_data[$dependent_module]->status && !in_array($dependent_module, $uninstalls, TRUE) && $dependent_module !== $install_profile) { + if ($module_data[$dependent_module]->status && + !in_array($dependent_module, $uninstalls, TRUE) && + !in_array($dependent_module, [$install_profile, $database_driver_module], TRUE) + ) { $module_name = $module_data[$module]->info['name']; $dependent_module_name = $module_data[$dependent_module]->info['name']; $config_importer->logError($this->t('Unable to uninstall the %module module since the %dependent_module module is installed.', [ diff --git a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php index 5019535470126efca305add89877a0eeb2caf3be..2a40a48f47ee073df8d54d83358fb5ab9ca68899 100644 --- a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php +++ b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php @@ -7,6 +7,7 @@ use Drupal\Core\Config\StorageComparer; use Drupal\Core\Entity\ContentEntityTypeInterface; use Drupal\Core\Extension\ExtensionLifecycle; +use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Tests\SchemaCheckTestTrait; use Drupal\Tests\system\Functional\Module\ModuleTestBase; @@ -109,6 +110,9 @@ public function testInstallUninstall(): void { $all_modules = \Drupal::service('extension.list.module')->getList(); $database_module = \Drupal::service('database')->getProvider(); $expected_modules = ['path_alias', 'system', 'user', $database_module]; + // If the database module has dependencies, they are expected too. + $database_module_extension = \Drupal::service(ModuleExtensionList::class)->get($database_module); + $database_module_dependencies = $database_module_extension->requires ? array_keys($database_module_extension->requires) : []; // Ensure that only core required modules and the install profile can not be // uninstalled. @@ -127,8 +131,11 @@ public function testInstallUninstall(): void { // Can not uninstall config and use admin/config/development/configuration! unset($modules_to_uninstall['config']); - // Can not uninstall the database module. + // Can not uninstall the database module and its dependencies. unset($modules_to_uninstall[$database_module]); + foreach ($database_module_dependencies as $dependency) { + unset($modules_to_uninstall[$dependency]); + } $this->assertTrue(isset($modules_to_uninstall['comment']), 'The comment module will be disabled'); $this->assertTrue(isset($modules_to_uninstall['file']), 'The File module will be disabled'); diff --git a/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php b/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php index 7926175e0dcf6f090eabc2cc95df9f872fd5bd0e..9f2a89ca6ea97e8f2be58b65c814c16afec6dcd0 100644 --- a/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php +++ b/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php @@ -5,6 +5,7 @@ use Drupal\Component\Utility\Unicode; use Drupal\Core\Database\DatabaseExceptionWrapper; use Drupal\Core\Database\ExceptionHandler as BaseExceptionHandler; +use Drupal\Core\Database\Exception\SchemaPrimaryKeyMustBeDroppedException; use Drupal\Core\Database\Exception\SchemaTableColumnSizeTooLargeException; use Drupal\Core\Database\Exception\SchemaTableKeyTooLargeException; use Drupal\Core\Database\IntegrityConstraintViolationException; @@ -19,44 +20,81 @@ 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); + if (!$exception instanceof \PDOException) { + throw $exception; + } + $this->rethrowNormalizedException($exception, $exception->getCode(), $exception->errorInfo[1] ?? NULL, $statement->getQueryString(), $arguments); + } + + /** + * Rethrows exceptions thrown during execution of statement objects. + * + * Wrap the exception in another exception, because PHP does not allow + * overriding Exception::getMessage(). Its message is the extra database + * debug information. + * + * @param \Exception $exception + * The exception to be handled. + * @param int|string $sqlState + * MySql SQLState error condition. + * @param int|null $errorCode + * MySql error code. + * @param string $queryString + * The SQL statement string. + * @param array $arguments + * An array of arguments for the prepared statement. + * + * @throws \Drupal\Core\Database\DatabaseExceptionWrapper + * @throws \Drupal\Core\Database\IntegrityConstraintViolationException + * @throws \Drupal\Core\Database\Exception\SchemaPrimaryKeyMustBeDroppedException + * @throws \Drupal\Core\Database\Exception\SchemaTableColumnSizeTooLargeException + * @throws \Drupal\Core\Database\Exception\SchemaTableKeyTooLargeException + */ + protected function rethrowNormalizedException( + \Exception $exception, + int|string $sqlState, + ?int $errorCode, + string $queryString, + array $arguments, + ): void { + + // SQLState could be 'HY000' which cannot be used as a $code argument for + // exceptions. PDOException is contravariant in this case, but since we are + // re-throwing an exception that inherits from \Exception, we need to + // convert the code to an integer. + // @see https://www.php.net/manual/en/class.exception.php + // @see https://www.php.net/manual/en/class.pdoexception.php + $code = (int) $sqlState; + + // 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 ($errorCode === 1153) { + $message = Unicode::truncateBytes($exception->getMessage(), Connection::MIN_MAX_ALLOWED_PACKET); + throw new DatabaseExceptionWrapper($message, $code, $exception); + } + + $message = $exception->getMessage() . ": " . $queryString . "; " . 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($sqlState, -6, -3) == '23' || $errorCode === 1364) { + throw new IntegrityConstraintViolationException($message, $code, $exception); } - throw $exception; + match ($sqlState) { + 'HY000' => match ($errorCode) { + 4111 => throw new SchemaPrimaryKeyMustBeDroppedException($message, 0, $exception), + default => throw new DatabaseExceptionWrapper($message, 0, $exception), + }, + '42000' => match ($errorCode) { + 1071 => throw new SchemaTableKeyTooLargeException($message, $code, $exception), + 1074 => throw new SchemaTableColumnSizeTooLargeException($message, $code, $exception), + default => throw new DatabaseExceptionWrapper($message, 0, $exception), + }, + default => throw new DatabaseExceptionWrapper($message, 0, $exception), + }; } } diff --git a/core/modules/mysql/src/Driver/Database/mysql/Schema.php b/core/modules/mysql/src/Driver/Database/mysql/Schema.php index a8b9c07564e8197c53d95d2fc35cfe94e006e1cf..c3eb28584334375e59defcb2760567dd775b498f 100644 --- a/core/modules/mysql/src/Driver/Database/mysql/Schema.php +++ b/core/modules/mysql/src/Driver/Database/mysql/Schema.php @@ -2,7 +2,7 @@ namespace Drupal\mysql\Driver\Database\mysql; -use Drupal\Core\Database\DatabaseExceptionWrapper; +use Drupal\Core\Database\Exception\SchemaPrimaryKeyMustBeDroppedException; use Drupal\Core\Database\SchemaException; use Drupal\Core\Database\SchemaObjectExistsException; use Drupal\Core\Database\SchemaObjectDoesNotExistException; @@ -438,11 +438,11 @@ public function addField($table, $field, $spec, $keys_new = []) { try { $this->executeDdlStatement($query); } - catch (DatabaseExceptionWrapper $e) { + catch (SchemaPrimaryKeyMustBeDroppedException $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']) { + if (isset($keys_new['primary key']) && $this->indexExists($table, 'PRIMARY') && $this->findPrimaryKeyColumns($table) === ['my_row_id']) { $this->executeDdlStatement($query . ', DROP COLUMN [my_row_id]'); } else { diff --git a/core/modules/mysql/tests/src/Functional/RequirementsTest.php b/core/modules/mysql/tests/src/Functional/RequirementsTest.php index 5d054334b6962732f448a34e9cca80a9dc302829..38617714bc8c117f453239dc68c2bd6a985bcc49 100644 --- a/core/modules/mysql/tests/src/Functional/RequirementsTest.php +++ b/core/modules/mysql/tests/src/Functional/RequirementsTest.php @@ -32,7 +32,7 @@ protected function setUp(): void { // The isolation_level option is only available for MySQL. $connection = Database::getConnection(); - if ($connection->driver() !== 'mysql') { + if (!in_array($connection->driver(), ['mysql', 'mysqli'])) { $this->markTestSkipped("This test does not support the {$connection->driver()} database driver."); } } diff --git a/core/modules/mysqli/mysqli.info.yml b/core/modules/mysqli/mysqli.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..38a9239f3e95c1a89e4e26f39d67d4fbf9e4b934 --- /dev/null +++ b/core/modules/mysqli/mysqli.info.yml @@ -0,0 +1,9 @@ +name: MySQLi +type: module +description: 'Database driver for MySQLi.' +version: VERSION +package: Core (Experimental) +lifecycle: experimental +hidden: true +dependencies: + - drupal:mysql diff --git a/core/modules/mysqli/mysqli.install b/core/modules/mysqli/mysqli.install new file mode 100644 index 0000000000000000000000000000000000000000..8d40e88d909073a185b6660853513514e5c3b5e2 --- /dev/null +++ b/core/modules/mysqli/mysqli.install @@ -0,0 +1,77 @@ +<?php + +/** + * @file + * Install, update and uninstall functions for the mysqli module. + */ + +use Drupal\Core\Database\Database; +use Drupal\Core\Render\Markup; + +/** + * Implements hook_requirements(). + */ +function mysqli_requirements($phase): array { + $requirements = []; + + if ($phase === 'runtime') { + // Test with MySql databases. + if (Database::isActiveConnection()) { + $connection = Database::getConnection(); + // Only show requirements when MySQLi is the default database connection. + if (!($connection->driver() === 'mysqli' && $connection->getProvider() === 'mysqli')) { + return []; + } + + $query = $connection->isMariaDb() ? 'SELECT @@SESSION.tx_isolation' : '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; +} diff --git a/core/modules/mysqli/mysqli.services.yml b/core/modules/mysqli/mysqli.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..82a476ceb9e8cb4b91e3619d4973ac8b2b90f9af --- /dev/null +++ b/core/modules/mysqli/mysqli.services.yml @@ -0,0 +1,4 @@ +services: + mysqli.views.cast_sql: + class: Drupal\mysqli\Plugin\views\query\MysqliCastSql + public: false diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/Connection.php b/core/modules/mysqli/src/Driver/Database/mysqli/Connection.php new file mode 100644 index 0000000000000000000000000000000000000000..e41df23075a3c66ec5350cd5779b76a696a2a763 --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/Connection.php @@ -0,0 +1,191 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\Connection as BaseConnection; +use Drupal\Core\Database\ConnectionNotDefinedException; +use Drupal\Core\Database\Database; +use Drupal\Core\Database\DatabaseAccessDeniedException; +use Drupal\Core\Database\DatabaseNotFoundException; +use Drupal\Core\Database\Transaction\TransactionManagerInterface; +use Drupal\mysql\Driver\Database\mysql\Connection as BaseMySqlConnection; + +/** + * MySQLi implementation of \Drupal\Core\Database\Connection. + */ +class Connection extends BaseMySqlConnection { + + /** + * {@inheritdoc} + */ + protected $statementWrapperClass = Statement::class; + + public function __construct( + \mysqli $connection, + array $connectionOptions = [], + ) { + // 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. + // + // @see https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_ansi_quotes + $ansiQuotesModes = ['ANSI_QUOTES', 'ANSI']; + $isAnsiQuotesMode = FALSE; + if (isset($connectionOptions['init_commands']['sql_mode'])) { + foreach ($ansiQuotesModes as $mode) { + // None of the modes in $ansiQuotesModes are substrings of other modes + // that are not in $ansiQuotesModes, so a simple stripos() does not + // return false positives. + if (stripos($connectionOptions['init_commands']['sql_mode'], $mode) !== FALSE) { + $isAnsiQuotesMode = TRUE; + break; + } + } + } + + if ($this->identifierQuotes === ['"', '"'] && !$isAnsiQuotesMode) { + $this->identifierQuotes = ['`', '`']; + } + + BaseConnection::__construct($connection, $connectionOptions); + } + + /** + * {@inheritdoc} + */ + public static function open(array &$connection_options = []) { + // Sets mysqli error reporting mode to report errors from mysqli function + // calls and to throw mysqli_sql_exception for errors. + // @see https://www.php.net/manual/en/mysqli-driver.report-mode.php + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + // Allow PDO options to be overridden. + $connection_options += [ + 'pdo' => [], + ]; + + try { + $mysqli = @new \mysqli( + $connection_options['host'], + $connection_options['username'], + $connection_options['password'], + $connection_options['database'] ?? '', + !empty($connection_options['port']) ? (int) $connection_options['port'] : 3306, + $connection_options['unix_socket'] ?? '' + ); + if (!$mysqli->set_charset('utf8mb4')) { + throw new InvalidCharsetException('Invalid charset utf8mb4'); + } + } + catch (\mysqli_sql_exception $e) { + if ($e->getCode() === static::DATABASE_NOT_FOUND) { + throw new DatabaseNotFoundException($e->getMessage(), $e->getCode(), $e); + } + elseif ($e->getCode() === static::ACCESS_DENIED) { + throw new DatabaseAccessDeniedException($e->getMessage(), $e->getCode(), $e); + } + + throw new ConnectionNotDefinedException('Invalid database connection: ' . $e->getMessage(), $e->getCode(), $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_0900_ai_ci' for the 'utf8mb4' character set. + if (!empty($connection_options['collation'])) { + $mysqli->query('SET NAMES utf8mb4 COLLATE ' . $connection_options['collation']); + } + else { + $mysqli->query('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 regard 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) { + $mysqli->query($sql); + } + + return $mysqli; + } + + /** + * {@inheritdoc} + */ + public function driver() { + return 'mysqli'; + } + + /** + * {@inheritdoc} + */ + public function clientVersion() { + return \mysqli_get_client_info(); + } + + /** + * {@inheritdoc} + */ + public function createDatabase($database): void { + // Escape the database name. + $database = Database::getConnection()->escapeDatabase($database); + + try { + // Create the database and set it as active. + $this->connection->query("CREATE DATABASE $database"); + $this->connection->query("USE $database"); + } + catch (\Exception $e) { + throw new DatabaseNotFoundException($e->getMessage()); + } + } + + /** + * {@inheritdoc} + */ + public function quote($string, $parameter_type = \PDO::PARAM_STR) { + return "'" . $this->connection->escape_string((string) $string) . "'"; + } + + /** + * {@inheritdoc} + */ + public function lastInsertId(?string $name = NULL): string { + return (string) $this->connection->insert_id; + } + + /** + * {@inheritdoc} + */ + public function exceptionHandler() { + return new ExceptionHandler(); + } + + /** + * {@inheritdoc} + */ + protected function driverTransactionManager(): TransactionManagerInterface { + return new TransactionManager($this); + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/ExceptionHandler.php b/core/modules/mysqli/src/Driver/Database/mysqli/ExceptionHandler.php new file mode 100644 index 0000000000000000000000000000000000000000..78e7a331f1211267dd20d5bb93b804135e0a4d2f --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/ExceptionHandler.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\StatementInterface; +use Drupal\mysql\Driver\Database\mysql\ExceptionHandler as BaseMySqlExceptionHandler; + +/** + * MySQLi database exception handler class. + */ +class ExceptionHandler extends BaseMySqlExceptionHandler { + + /** + * {@inheritdoc} + */ + public function handleExecutionException(\Exception $exception, StatementInterface $statement, array $arguments = [], array $options = []): void { + // Close the client statement to release handles. + if ($statement->hasClientStatement()) { + $statement->getClientStatement()->close(); + } + + if (!($exception instanceof \mysqli_sql_exception)) { + throw $exception; + } + $this->rethrowNormalizedException($exception, $exception->getSqlState(), $exception->getCode(), $statement->getQueryString(), $arguments); + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/Install/Tasks.php b/core/modules/mysqli/src/Driver/Database/mysqli/Install/Tasks.php new file mode 100644 index 0000000000000000000000000000000000000000..f27a083541e6c2add7e974add7d8a12802fabd72 --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/Install/Tasks.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli\Install; + +use Drupal\mysql\Driver\Database\mysql\Install\Tasks as BaseInstallTasks; + +/** + * Specifies installation tasks for MySQLi. + */ +class Tasks extends BaseInstallTasks { + + /** + * {@inheritdoc} + */ + public function installable() { + return extension_loaded('mysqli'); + } + + /** + * {@inheritdoc} + */ + public function name() { + return $this->t('@parent via mysqli (Experimental)', ['@parent' => parent::name()]); + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/InvalidCharsetException.php b/core/modules/mysqli/src/Driver/Database/mysqli/InvalidCharsetException.php new file mode 100644 index 0000000000000000000000000000000000000000..e6f2c86148c507adae0aa0a9a3ac4ead93c069d8 --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/InvalidCharsetException.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\DatabaseExceptionWrapper; + +/** + * This exception class signals an invalid charset is being used. + */ +class InvalidCharsetException extends DatabaseExceptionWrapper { +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/NamedPlaceholderConverter.php b/core/modules/mysqli/src/Driver/Database/mysqli/NamedPlaceholderConverter.php new file mode 100644 index 0000000000000000000000000000000000000000..31386bc907bcd21156e105f0b959eaf70d440c2f --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/NamedPlaceholderConverter.php @@ -0,0 +1,250 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +// cspell:ignore DBAL MULTICHAR + +/** + * A class to convert a SQL statement with named placeholders to positional. + * + * The parsing logic and the implementation is inspired by the PHP PDO parser, + * and a simplified copy of the parser implementation done by the Doctrine DBAL + * project. + * + * This class is a near-copy of Doctrine\DBAL\SQL\Parser, which is part of the + * Doctrine project: <http://www.doctrine-project.org>. It was copied from + * version 4.0.0. + * + * Original copyright: + * + * Copyright (c) 2006-2018 Doctrine Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * @see https://github.com/doctrine/dbal/blob/4.0.0/src/SQL/Parser.php + * + * @internal + */ +final class NamedPlaceholderConverter { + /** + * A list of regex patterns for parsing. + */ + private const string SPECIAL_CHARS = ':\?\'"`\\[\\-\\/'; + private const string BACKTICK_IDENTIFIER = '`[^`]*`'; + private const string BRACKET_IDENTIFIER = '(?<!\b(?i:ARRAY))\[(?:[^\]])*\]'; + private const string MULTICHAR = ':{2,}'; + private const string NAMED_PARAMETER = ':[a-zA-Z0-9_]+'; + private const string POSITIONAL_PARAMETER = '(?<!\\?)\\?(?!\\?)'; + private const string ONE_LINE_COMMENT = '--[^\r\n]*'; + private const string MULTI_LINE_COMMENT = '/\*([^*]+|\*+[^/*])*\**\*/'; + private const string SPECIAL = '[' . self::SPECIAL_CHARS . ']'; + private const string OTHER = '[^' . self::SPECIAL_CHARS . ']+'; + + /** + * The combined regex pattern for parsing. + */ + private string $sqlPattern; + + /** + * The list of original named arguments. + * + * The initial placeholder colon is removed. + * + * @var array<string|int, mixed> + */ + private array $originalParameters = []; + + /** + * The maximum positional placeholder parsed. + * + * Normally Drupal does not produce SQL with positional placeholders, but + * this is to manage the edge case. + */ + private int $originalParameterIndex = 0; + + /** + * The converted SQL statement in its parts. + * + * @var list<string> + */ + private array $convertedSQL = []; + + /** + * The list of converted arguments. + * + * @var list<mixed> + */ + private array $convertedParameters = []; + + public function __construct() { + // Builds the combined regex pattern for parsing. + $this->sqlPattern = sprintf('(%s)', implode('|', [ + $this->getAnsiSQLStringLiteralPattern("'"), + $this->getAnsiSQLStringLiteralPattern('"'), + self::BACKTICK_IDENTIFIER, + self::BRACKET_IDENTIFIER, + self::MULTICHAR, + self::ONE_LINE_COMMENT, + self::MULTI_LINE_COMMENT, + self::OTHER, + ])); + } + + /** + * Parses an SQL statement with named placeholders. + * + * This method explodes the SQL statement in parts that can be reassembled + * into a string with positional placeholders. + * + * @param string $sql + * The SQL statement with named placeholders. + * @param array<string|int, mixed> $args + * The statement arguments. + */ + public function parse(string $sql, array $args): void { + // Reset the object state. + $this->originalParameters = []; + $this->originalParameterIndex = 0; + $this->convertedSQL = []; + $this->convertedParameters = []; + + foreach ($args as $key => $value) { + if (is_int($key)) { + // Positional placeholder; edge case. + $this->originalParameters[$key] = $value; + } + else { + // Named placeholder like ':placeholder'; remove the initial colon. + $parameter = $key[0] === ':' ? substr($key, 1) : $key; + $this->originalParameters[$parameter] = $value; + } + } + + /** @var array<string,callable> $patterns */ + $patterns = [ + self::NAMED_PARAMETER => function (string $sql): void { + $this->addNamedParameter($sql); + }, + self::POSITIONAL_PARAMETER => function (string $sql): void { + $this->addPositionalParameter($sql); + }, + $this->sqlPattern => function (string $sql): void { + $this->addOther($sql); + }, + self::SPECIAL => function (string $sql): void { + $this->addOther($sql); + }, + ]; + + $offset = 0; + + while (($handler = current($patterns)) !== FALSE) { + if (preg_match('~\G' . key($patterns) . '~s', $sql, $matches, 0, $offset) === 1) { + $handler($matches[0]); + reset($patterns); + $offset += strlen($matches[0]); + } + elseif (preg_last_error() !== PREG_NO_ERROR) { + throw new \RuntimeException('Regular expression error'); + } + else { + next($patterns); + } + } + + assert($offset === strlen($sql)); + } + + /** + * Helper to return a regex pattern from a delimiter character. + * + * @param string $delimiter + * A delimiter character. + * + * @return string + * The regex pattern. + */ + private function getAnsiSQLStringLiteralPattern(string $delimiter): string { + return $delimiter . '[^' . $delimiter . ']*' . $delimiter; + } + + /** + * Adds a positional placeholder to the converted parts. + * + * Normally Drupal does not produce SQL with positional placeholders, but + * this is to manage the edge case. + * + * @param string $sql + * The SQL part. + */ + private function addPositionalParameter(string $sql): void { + $index = $this->originalParameterIndex; + + if (!array_key_exists($index, $this->originalParameters)) { + throw new \RuntimeException('Missing Positional Parameter ' . $index); + } + + $this->convertedSQL[] = '?'; + $this->convertedParameters[] = $this->originalParameters[$index]; + + $this->originalParameterIndex++; + } + + /** + * Adds a named placeholder to the converted parts. + * + * @param string $sql + * The SQL part. + */ + private function addNamedParameter(string $sql): void { + $name = substr($sql, 1); + + if (!array_key_exists($name, $this->originalParameters)) { + throw new \RuntimeException('Missing Named Parameter ' . $name); + } + + $this->convertedSQL[] = '?'; + $this->convertedParameters[] = $this->originalParameters[$name]; + } + + /** + * Adds a generic SQL string fragment to the converted parts. + * + * @param string $sql + * The SQL part. + */ + private function addOther(string $sql): void { + $this->convertedSQL[] = $sql; + } + + /** + * Returns the converted SQL statement with positional placeholders. + * + * @return string + * The converted SQL statement with positional placeholders. + */ + public function getConvertedSQL(): string { + return implode('', $this->convertedSQL); + } + + /** + * Returns the array of arguments for use with positional placeholders. + * + * @return list<mixed> + * The array of arguments for use with positional placeholders. + */ + public function getConvertedParameters(): array { + return $this->convertedParameters; + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/Result.php b/core/modules/mysqli/src/Driver/Database/mysqli/Result.php new file mode 100644 index 0000000000000000000000000000000000000000..2c5e57c3aa82cc001dfe9a09ebb393bf04fd41e3 --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/Result.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\DatabaseExceptionWrapper; +use Drupal\Core\Database\FetchModeTrait; +use Drupal\Core\Database\Statement\FetchAs; +use Drupal\Core\Database\Statement\ResultBase; + +/** + * Class for mysqli-provided results of a data query language (DQL) statement. + */ +class Result extends ResultBase { + + use FetchModeTrait; + + /** + * Constructor. + * + * @param \Drupal\Core\Database\Statement\FetchAs $fetchMode + * The fetch mode. + * @param array{class: class-string, constructor_args: list<mixed>, column: int, cursor_orientation?: int, cursor_offset?: int} $fetchOptions + * The fetch options. + * @param \mysqli_result|false $mysqliResult + * The MySQLi result object. + * @param \mysqli $mysqliConnection + * Client database connection object. + */ + public function __construct( + FetchAs $fetchMode, + array $fetchOptions, + protected readonly \mysqli_result|false $mysqliResult, + protected readonly \mysqli $mysqliConnection, + ) { + parent::__construct($fetchMode, $fetchOptions); + } + + /** + * {@inheritdoc} + */ + public function rowCount(): ?int { + // The most accurate value to return for Drupal here is the first + // occurrence of an integer in the string stored by the connection's + // $info property. + // This is something like 'Rows matched: 1 Changed: 1 Warnings: 0' for + // UPDATE or DELETE operations, 'Records: 2 Duplicates: 1 Warnings: 0' + // for INSERT ones. + // This however requires a regex parsing of the string which is expensive; + // $affected_rows would be less accurate but much faster. We would need + // Drupal to be less strict in testing, and never rely on this value in + // runtime (which would be healthy anyway). + if ($this->mysqliConnection->info !== NULL) { + $matches = []; + if (preg_match('/\s(\d+)\s/', $this->mysqliConnection->info, $matches) === 1) { + return (int) $matches[0]; + } + else { + throw new DatabaseExceptionWrapper('Invalid data in the $info property of the mysqli connection - ' . $this->mysqliConnection->info); + } + } + elseif ($this->mysqliConnection->affected_rows !== NULL) { + return $this->mysqliConnection->affected_rows; + } + throw new DatabaseExceptionWrapper('Unable to retrieve affected rows data'); + } + + /** + * {@inheritdoc} + */ + public function setFetchMode(FetchAs $mode, array $fetchOptions): bool { + // There are no methods to set fetch mode in \mysqli_result. + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function fetch(FetchAs $mode, array $fetchOptions): array|object|int|float|string|bool|NULL { + assert($this->mysqliResult instanceof \mysqli_result); + + $mysqli_row = $this->mysqliResult->fetch_assoc(); + + if (!$mysqli_row) { + return FALSE; + } + + // Stringify all non-NULL column values. + $row = array_map(fn ($value) => $value === NULL ? NULL : (string) $value, $mysqli_row); + + return $this->assocToFetchMode($row, $mode, $fetchOptions); + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/Statement.php b/core/modules/mysqli/src/Driver/Database/mysqli/Statement.php new file mode 100644 index 0000000000000000000000000000000000000000..24e8c83501c2b91972607e3364f6380502bd3567 --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/Statement.php @@ -0,0 +1,123 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\Connection; +use Drupal\Core\Database\Statement\FetchAs; +use Drupal\Core\Database\Statement\StatementBase; + +/** + * MySQLi implementation of \Drupal\Core\Database\Query\StatementInterface. + */ +class Statement extends StatementBase { + + /** + * Holds the index position of named parameters. + * + * The mysqli driver only allows positional placeholders '?', whereas in + * Drupal the SQL is generated with named placeholders ':name'. In order to + * execute the SQL, the string containing the named placeholders is converted + * to using positional ones, and the position (index) of each named + * placeholder in the string is stored here. + */ + protected array $paramsPositions; + + /** + * Constructs a Statement object. + * + * @param \Drupal\Core\Database\Connection $connection + * Drupal database connection object. + * @param \mysqli $clientConnection + * Client database connection object. + * @param string $queryString + * The SQL query string. + * @param array $driverOpts + * (optional) Array of query options. + * @param bool $rowCountEnabled + * (optional) Enables counting the rows affected. Defaults to FALSE. + */ + public function __construct( + Connection $connection, + \mysqli $clientConnection, + string $queryString, + protected array $driverOpts = [], + bool $rowCountEnabled = FALSE, + ) { + parent::__construct($connection, $clientConnection, $queryString, $rowCountEnabled); + $this->setFetchMode(FetchAs::Object); + } + + /** + * Returns the client-level database statement object. + * + * This method should normally be used only within database driver code. + * + * @return \mysqli_stmt + * The client-level database statement. + */ + public function getClientStatement(): \mysqli_stmt { + if ($this->hasClientStatement()) { + assert($this->clientStatement instanceof \mysqli_stmt); + return $this->clientStatement; + } + throw new \LogicException('\\mysqli_stmt not initialized'); + } + + /** + * {@inheritdoc} + */ + public function execute($args = [], $options = []) { + if (isset($options['fetch'])) { + if (is_string($options['fetch'])) { + $this->setFetchMode(FetchAs::ClassObject, $options['fetch']); + } + else { + $this->setFetchMode($options['fetch']); + } + } + + $startEvent = $this->dispatchStatementExecutionStartEvent($args ?? []); + + try { + // Prepare the lower-level statement if it's not been prepared already. + if (!$this->hasClientStatement()) { + // Replace named placeholders with positional ones if needed. + $this->paramsPositions = array_flip(array_keys($args)); + $converter = new NamedPlaceholderConverter(); + $converter->parse($this->queryString, $args); + [$convertedQueryString, $args] = [$converter->getConvertedSQL(), $converter->getConvertedParameters()]; + $this->clientStatement = $this->clientConnection->prepare($convertedQueryString); + } + else { + // Transform the $args to positional. + $tmp = []; + foreach ($this->paramsPositions as $param => $pos) { + $tmp[$pos] = $args[$param]; + } + $args = $tmp; + } + + // In mysqli, the results of the statement execution are returned in a + // different object than the statement itself. + $return = $this->getClientStatement()->execute($args); + $this->result = new Result( + $this->fetchMode, + $this->fetchOptions, + $this->getClientStatement()->get_result(), + $this->clientConnection, + ); + $this->markResultsetIterable($return); + } + catch (\Exception $e) { + $this->dispatchStatementExecutionFailureEvent($startEvent, $e); + throw $e; + } + + $this->dispatchStatementExecutionEndEvent($startEvent); + + return $return; + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/TransactionManager.php b/core/modules/mysqli/src/Driver/Database/mysqli/TransactionManager.php new file mode 100644 index 0000000000000000000000000000000000000000..90237fd6a43cc22a0de5b89ee9198b78c41ab05c --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/TransactionManager.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\Transaction\ClientConnectionTransactionState; +use Drupal\Core\Database\Transaction\TransactionManagerBase; + +/** + * MySqli implementation of TransactionManagerInterface. + */ +class TransactionManager extends TransactionManagerBase { + + /** + * {@inheritdoc} + */ + protected function beginClientTransaction(): bool { + return $this->connection->getClientConnection()->begin_transaction(); + } + + /** + * {@inheritdoc} + */ + protected function addClientSavepoint(string $name): bool { + return $this->connection->getClientConnection()->savepoint($name); + } + + /** + * {@inheritdoc} + */ + protected function rollbackClientSavepoint(string $name): bool { + // Mysqli does not have a rollback_to_savepoint method, and it does not + // allow a prepared statement for 'ROLLBACK TO SAVEPOINT', so we need to + // fallback to querying on the client connection directly. + try { + return (bool) $this->connection->getClientConnection()->query('ROLLBACK TO SAVEPOINT ' . $name); + } + catch (\mysqli_sql_exception) { + // If the rollback failed, most likely the savepoint was not there + // because the transaction is no longer active. In this case we void the + // transaction stack. + $this->voidClientTransaction(); + return TRUE; + } + } + + /** + * {@inheritdoc} + */ + protected function releaseClientSavepoint(string $name): bool { + return $this->connection->getClientConnection()->release_savepoint($name); + } + + /** + * {@inheritdoc} + */ + protected function rollbackClientTransaction(): bool { + // Note: mysqli::rollback() returns TRUE if there's no active transaction. + // This is diverging from PDO MySql. A PHP bug report exists. + // @see https://bugs.php.net/bug.php?id=81533. + $clientRollback = $this->connection->getClientConnection()->rollBack(); + $this->setConnectionTransactionState($clientRollback ? + ClientConnectionTransactionState::RolledBack : + ClientConnectionTransactionState::RollbackFailed + ); + return $clientRollback; + } + + /** + * {@inheritdoc} + */ + protected function commitClientTransaction(): bool { + $clientCommit = $this->connection->getClientConnection()->commit(); + $this->setConnectionTransactionState($clientCommit ? + ClientConnectionTransactionState::Committed : + ClientConnectionTransactionState::CommitFailed + ); + return $clientCommit; + } + +} diff --git a/core/modules/mysqli/src/Hook/MysqliHooks.php b/core/modules/mysqli/src/Hook/MysqliHooks.php new file mode 100644 index 0000000000000000000000000000000000000000..5fae187d16c76a3d996aecc0c669e21702e87aca --- /dev/null +++ b/core/modules/mysqli/src/Hook/MysqliHooks.php @@ -0,0 +1,32 @@ +<?php + +namespace Drupal\mysqli\Hook; + +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * Hook implementations for mysqli. + */ +class MysqliHooks { + + use StringTranslationTrait; + + /** + * Implements hook_help(). + */ + #[Hook('help')] + public function help($route_name, RouteMatchInterface $route_match): ?string { + switch ($route_name) { + case 'help.page.mysqli': + $output = ''; + $output .= '<h3>' . $this->t('About') . '</h3>'; + $output .= '<p>' . $this->t('The MySQLi module provides the connection between Drupal and a MySQL, MariaDB or equivalent database using the mysqli PHP extension. For more information, see the <a href=":mysqli">online documentation for the MySQLi module</a>.', [':mysqli' => 'https://www.drupal.org/documentation/modules/mysqli']) . '</p>'; + return $output; + + } + return NULL; + } + +} diff --git a/core/modules/mysqli/src/Plugin/views/query/MysqliCastSql.php b/core/modules/mysqli/src/Plugin/views/query/MysqliCastSql.php new file mode 100644 index 0000000000000000000000000000000000000000..d1f1ca55f8f5e633523f00204d5cf2abaa96570e --- /dev/null +++ b/core/modules/mysqli/src/Plugin/views/query/MysqliCastSql.php @@ -0,0 +1,11 @@ +<?php + +namespace Drupal\mysqli\Plugin\views\query; + +use Drupal\mysql\Plugin\views\query\MysqlCastSql; + +/** + * MySQLi specific cast handling. + */ +class MysqliCastSql extends MysqlCastSql { +} diff --git a/core/modules/mysqli/tests/src/Functional/GenericTest.php b/core/modules/mysqli/tests/src/Functional/GenericTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e3cf88e97462834adcb2f5f6bf39da7749023065 --- /dev/null +++ b/core/modules/mysqli/tests/src/Functional/GenericTest.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Functional; + +use Drupal\Core\Extension\ExtensionLifecycle; +use Drupal\Tests\system\Functional\Module\GenericModuleTestBase; + +/** + * Generic module test for mysqli. + * + * @group mysqli + */ +class GenericTest extends GenericModuleTestBase { + + /** + * Checks visibility of the module. + */ + public function testMysqliModule(): void { + $module = $this->getModule(); + \Drupal::service('module_installer')->install([$module]); + $info = \Drupal::service('extension.list.module')->getExtensionInfo($module); + $this->assertTrue($info['hidden']); + $this->assertSame(ExtensionLifecycle::EXPERIMENTAL, $info['lifecycle']); + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c9d54e09b0ff8cac86db5618e30655a883706cda --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionTest.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\ConnectionTest as BaseMySqlTest; + +/** + * MySQL-specific connection tests. + * + * @group Database + */ +class ConnectionTest extends BaseMySqlTest { +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionUnitTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionUnitTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3036135f1845312c1ebe9fd9a6763e6297f56f7e --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionUnitTest.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\ConnectionUnitTest as BaseMySqlTest; + +/** + * MySQL-specific connection unit tests. + * + * @group Database + */ +class ConnectionUnitTest extends BaseMySqlTest { + + /** + * Tests pdo options override. + */ + public function testConnectionOpen(): void { + $this->markTestSkipped('mysqli is not a pdo driver.'); + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/DatabaseExceptionWrapperTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/DatabaseExceptionWrapperTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8a9e33abd7ce179e709a18f38a22948b22b7e564 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/DatabaseExceptionWrapperTest.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\DatabaseExceptionWrapperTest as BaseMySqlTest; + +/** + * Tests exceptions thrown by queries. + * + * @group Database + */ +class DatabaseExceptionWrapperTest extends BaseMySqlTest { + + /** + * 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 { + $this->markTestSkipped('mysqli is not a pdo driver.'); + } + + /** + * Tests Connection::prepareStatement exception on execution. + */ + public function testPrepareStatementFailOnExecution(): void { + $this->expectException(\mysqli_sql_exception::class); + $stmt = $this->connection->prepareStatement('bananas', []); + $stmt->execute(); + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/LargeQueryTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/LargeQueryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9c15e03a2b8294641cc5c47eaceec98a95294c18 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/LargeQueryTest.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Component\Utility\Environment; +use Drupal\Core\Database\Database; +use Drupal\Core\Database\DatabaseExceptionWrapper; +use Drupal\Tests\mysql\Kernel\mysql\LargeQueryTest as BaseMySqlTest; + +/** + * Tests handling of large queries. + * + * @group Database + */ +class LargeQueryTest extends BaseMySqlTest { + + /** + * Tests truncation of messages when max_allowed_packet exception occurs. + */ + public function testMaxAllowedPacketQueryTruncating(): void { + $connectionInfo = Database::getConnectionInfo(); + Database::addConnectionInfo('default', 'testMaxAllowedPacketQueryTruncating', $connectionInfo['default']); + $testConnection = Database::getConnection('testMaxAllowedPacketQueryTruncating'); + + // 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 = $testConnection->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 { + $testConnection->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 (\Throwable $e) { + Database::closeConnection('testMaxAllowedPacketQueryTruncating'); + // Got a packet bigger than 'max_allowed_packet' bytes exception thrown. + $this->assertInstanceOf(DatabaseExceptionWrapper::class, $e); + $this->assertEquals(1153, $e->getPrevious()->getCode()); + // '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())); + } + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/PrefixInfoTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/PrefixInfoTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e6ded59389c87f618ef3f0a1e82b17756141dfd8 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/PrefixInfoTest.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\PrefixInfoTest as BaseMySqlTest; + +/** + * Tests that the prefix info for a database schema is correct. + * + * @group Database + */ +class PrefixInfoTest extends BaseMySqlTest { +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/SchemaTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/SchemaTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9a9da146025282fb0098f1c41a2fd313aee4673e --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/SchemaTest.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\SchemaTest as BaseMySqlTest; + +/** + * Tests schema API for the MySQL driver. + * + * @group Database + */ +class SchemaTest extends BaseMySqlTest { +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/SqlModeTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/SqlModeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c117073394351711f62d8c11233a42a08f0775d6 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/SqlModeTest.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +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'] === 'mysqli') { + $info['default']['init_commands']['sql_mode'] = "SET sql_mode = ''"; + } + + return $info; + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/SyntaxTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/SyntaxTest.php new file mode 100644 index 0000000000000000000000000000000000000000..aca3b60f0147721d185a698d875698302575ff48 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/SyntaxTest.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\KernelTests\Core\Database\DriverSpecificSyntaxTestBase; + +/** + * Tests MySql syntax interpretation. + * + * @group Database + */ +class SyntaxTest extends DriverSpecificSyntaxTestBase { + + /** + * Tests string concatenation with separator, with field values. + */ + public function testConcatWsFields(): void { + $result = $this->connection->query("SELECT CONCAT_WS('-', CONVERT(:a1 USING utf8mb4), [name], CONVERT(:a2 USING utf8mb4), [age]) FROM {test} WHERE [age] = :age", [ + ':a1' => 'name', + ':a2' => 'age', + ':age' => 25, + ]); + $this->assertSame('name-John-age-25', $result->fetchField()); + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/TemporaryQueryTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/TemporaryQueryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a25b833eddfa6980a3ff9a2f811bfeb281bc7383 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/TemporaryQueryTest.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\TemporaryQueryTest as BaseMySqlTest; + +/** + * Tests the temporary query functionality. + * + * @group Database + */ +class TemporaryQueryTest extends BaseMySqlTest { +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/TransactionTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/TransactionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..d83cd63292b8d46fc39a611869097d6f08f81695 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/TransactionTest.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\KernelTests\Core\Database\DriverSpecificTransactionTestBase; + +/** + * Tests transaction for the MySQLi driver. + * + * @group Database + */ +class TransactionTest extends DriverSpecificTransactionTestBase { + + /** + * Tests starting a transaction when there's one active on the client. + * + * MySQLi does not fail if multiple commits are made on the client, so this + * test is failing. Let's change this if/when MySQLi will provide a way to + * check if a client transaction is active. + * + * This is mitigated by the fact that transaction should not be initiated from + * code outside the TransactionManager, that keeps track of the stack of + * transaction-related operations in its stack. + */ + public function testStartTransactionWhenActive(): void { + $this->markTestSkipped('Skipping this while MySQLi cannot detect if a client transaction is active.'); + $this->connection->getClientConnection()->begin_transaction(); + $this->connection->startTransaction(); + $this->assertFalse($this->connection->inTransaction()); + } + + /** + * Tests committing a transaction when there's none active on the client. + * + * MySQLi does not fail if multiple commits are made on the client, so this + * test is failing. Let's change this if/when MySQLi will provide a way to + * check if a client transaction is active. + * + * This is mitigated by the fact that transaction should not be initiated from + * code outside the TransactionManager, that keeps track of the stack of + * transaction-related operations in its stack. + */ + public function testCommitTransactionWhenInactive(): void { + $this->markTestSkipped('Skipping this while MySQLi cannot detect if a client transaction is active.'); + $transaction = $this->connection->startTransaction(); + $this->assertTrue($this->connection->inTransaction()); + $this->connection->getClientConnection()->commit(); + $this->assertFalse($this->connection->inTransaction()); + unset($transaction); + } + +} diff --git a/core/modules/mysqli/tests/src/Unit/NamedPlaceholderConverterTest.php b/core/modules/mysqli/tests/src/Unit/NamedPlaceholderConverterTest.php new file mode 100644 index 0000000000000000000000000000000000000000..997b0f8491f8df6b94246f44b4dbafbd08197041 --- /dev/null +++ b/core/modules/mysqli/tests/src/Unit/NamedPlaceholderConverterTest.php @@ -0,0 +1,392 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Unit; + +use Drupal\mysqli\Driver\Database\mysqli\NamedPlaceholderConverter; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\mysqli\Driver\Database\mysqli\NamedPlaceholderConverter + * @group Database + */ +class NamedPlaceholderConverterTest extends UnitTestCase { + + /** + * @covers ::parse + * @covers ::getConvertedSQL + * @covers ::getConvertedParameters + * @dataProvider statementsWithParametersProvider + */ + public function testParse(string $sql, array $parameters, string $expectedSql, array $expectedParameters): void { + $converter = new NamedPlaceholderConverter(); + $converter->parse($sql, $parameters); + $this->assertSame($expectedSql, $converter->getConvertedSQL()); + $this->assertSame($expectedParameters, $converter->getConvertedParameters()); + } + + /** + * Data for testParse. + */ + public static function statementsWithParametersProvider(): iterable { + yield [ + 'SELECT ?', + ['foo'], + 'SELECT ?', + ['foo'], + ]; + + yield [ + 'SELECT * FROM Foo WHERE bar IN (?, ?, ?)', + ['baz', 'qux', 'fred'], + 'SELECT * FROM Foo WHERE bar IN (?, ?, ?)', + ['baz', 'qux', 'fred'], + ]; + + yield [ + 'SELECT ? FROM ?', + ['baz', 'qux'], + 'SELECT ? FROM ?', + ['baz', 'qux'], + ]; + + yield [ + 'SELECT "?" FROM foo WHERE bar = ?', + ['baz'], + 'SELECT "?" FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + "SELECT '?' FROM foo WHERE bar = ?", + ['baz'], + "SELECT '?' FROM foo WHERE bar = ?", + ['baz'], + ]; + + yield [ + 'SELECT `?` FROM foo WHERE bar = ?', + ['baz'], + 'SELECT `?` FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT [?] FROM foo WHERE bar = ?', + ['baz'], + 'SELECT [?] FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[?])', + ['baz'], + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[?])', + ['baz'], + ]; + + yield [ + "SELECT 'foo-bar?' FROM foo WHERE bar = ?", + ['baz'], + "SELECT 'foo-bar?' FROM foo WHERE bar = ?", + ['baz'], + ]; + + yield [ + 'SELECT "foo-bar?" FROM foo WHERE bar = ?', + ['baz'], + 'SELECT "foo-bar?" FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT `foo-bar?` FROM foo WHERE bar = ?', + ['baz'], + 'SELECT `foo-bar?` FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT [foo-bar?] FROM foo WHERE bar = ?', + ['baz'], + 'SELECT [foo-bar?] FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT :foo FROM :bar', + [':foo' => 'baz', ':bar' => 'qux'], + 'SELECT ? FROM ?', + ['baz', 'qux'], + ]; + + yield [ + 'SELECT * FROM Foo WHERE bar IN (:name1, :name2)', + [':name1' => 'baz', ':name2' => 'qux'], + 'SELECT * FROM Foo WHERE bar IN (?, ?)', + ['baz', 'qux'], + ]; + + yield [ + 'SELECT ":foo" FROM Foo WHERE bar IN (:name1, :name2)', + [':name1' => 'baz', ':name2' => 'qux'], + 'SELECT ":foo" FROM Foo WHERE bar IN (?, ?)', + ['baz', 'qux'], + ]; + + yield [ + "SELECT ':foo' FROM Foo WHERE bar IN (:name1, :name2)", + [':name1' => 'baz', ':name2' => 'qux'], + "SELECT ':foo' FROM Foo WHERE bar IN (?, ?)", + ['baz', 'qux'], + ]; + + yield [ + 'SELECT :foo_id', + [':foo_id' => 'bar'], + 'SELECT ?', + ['bar'], + ]; + + yield [ + 'SELECT @rank := 1 AS rank, :foo AS foo FROM :bar', + [':foo' => 'baz', ':bar' => 'qux'], + 'SELECT @rank := 1 AS rank, ? AS foo FROM ?', + ['baz', 'qux'], + ]; + + yield [ + 'SELECT * FROM Foo WHERE bar > :start_date AND baz > :start_date', + [':start_date' => 'qux'], + 'SELECT * FROM Foo WHERE bar > ? AND baz > ?', + ['qux', 'qux'], + ]; + + yield [ + 'SELECT foo::date as date FROM Foo WHERE bar > :start_date AND baz > :start_date', + [':start_date' => 'qux'], + 'SELECT foo::date as date FROM Foo WHERE bar > ? AND baz > ?', + ['qux', 'qux'], + ]; + + yield [ + 'SELECT `d.ns:col_name` FROM my_table d WHERE `d.date` >= :param1', + [':param1' => 'qux'], + 'SELECT `d.ns:col_name` FROM my_table d WHERE `d.date` >= ?', + ['qux'], + ]; + + yield [ + 'SELECT [d.ns:col_name] FROM my_table d WHERE [d.date] >= :param1', + [':param1' => 'qux'], + 'SELECT [d.ns:col_name] FROM my_table d WHERE [d.date] >= ?', + ['qux'], + ]; + + yield [ + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[:foo])', + [':foo' => 'qux'], + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[?])', + ['qux'], + ]; + + yield [ + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, array[:foo])', + [':foo' => 'qux'], + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, array[?])', + ['qux'], + ]; + + yield [ + "SELECT table.column1, ARRAY['3'] FROM schema.table table WHERE table.f1 = :foo AND ARRAY['3']", + [':foo' => 'qux'], + "SELECT table.column1, ARRAY['3'] FROM schema.table table WHERE table.f1 = ? AND ARRAY['3']", + ['qux'], + ]; + + yield [ + "SELECT table.column1, ARRAY['3']::integer[] FROM schema.table table" . " WHERE table.f1 = :foo AND ARRAY['3']::integer[]", + [':foo' => 'qux'], + "SELECT table.column1, ARRAY['3']::integer[] FROM schema.table table" . " WHERE table.f1 = ? AND ARRAY['3']::integer[]", + ['qux'], + ]; + + yield [ + "SELECT table.column1, ARRAY[:foo] FROM schema.table table WHERE table.f1 = :bar AND ARRAY['3']", + [':foo' => 'qux', ':bar' => 'git'], + "SELECT table.column1, ARRAY[?] FROM schema.table table WHERE table.f1 = ? AND ARRAY['3']", + ['qux', 'git'], + ]; + + yield [ + 'SELECT table.column1, ARRAY[:foo]::integer[] FROM schema.table table' . " WHERE table.f1 = :bar AND ARRAY['3']::integer[]", + [':foo' => 'qux', ':bar' => 'git'], + 'SELECT table.column1, ARRAY[?]::integer[] FROM schema.table table' . " WHERE table.f1 = ? AND ARRAY['3']::integer[]", + ['qux', 'git'], + ]; + + yield 'Parameter array with placeholder keys missing starting colon' => [ + 'SELECT table.column1, ARRAY[:foo]::integer[] FROM schema.table table' . " WHERE table.f1 = :bar AND ARRAY['3']::integer[]", + ['foo' => 'qux', 'bar' => 'git'], + 'SELECT table.column1, ARRAY[?]::integer[] FROM schema.table table' . " WHERE table.f1 = ? AND ARRAY['3']::integer[]", + ['qux', 'git'], + ]; + + yield 'Quotes inside literals escaped by doubling' => [ + <<<'SQL' +SELECT * FROM foo +WHERE bar = ':not_a_param1 ''":not_a_param2"''' + OR bar=:a_param1 + OR bar=:a_param2||':not_a_param3' + OR bar=':not_a_param4 '':not_a_param5'' :not_a_param6' + OR bar='' + OR bar=:a_param3 +SQL, + [':a_param1' => 'qux', ':a_param2' => 'git', ':a_param3' => 'foo'], + <<<'SQL' +SELECT * FROM foo +WHERE bar = ':not_a_param1 ''":not_a_param2"''' + OR bar=? + OR bar=?||':not_a_param3' + OR bar=':not_a_param4 '':not_a_param5'' :not_a_param6' + OR bar='' + OR bar=? +SQL, + ['qux', 'git', 'foo'], + ]; + + yield [ + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' . " WHERE (data.description LIKE :condition_0 ESCAPE '\\\\')" . " AND (data.description LIKE :condition_1 ESCAPE '\\\\') ORDER BY id ASC", + [':condition_0' => 'qux', ':condition_1' => 'git'], + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' . " WHERE (data.description LIKE ? ESCAPE '\\\\')" . " AND (data.description LIKE ? ESCAPE '\\\\') ORDER BY id ASC", + ['qux', 'git'], + ]; + + yield [ + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' . ' WHERE (data.description LIKE :condition_0 ESCAPE "\\\\")' . ' AND (data.description LIKE :condition_1 ESCAPE "\\\\") ORDER BY id ASC', + [':condition_0' => 'qux', ':condition_1' => 'git'], + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' . ' WHERE (data.description LIKE ? ESCAPE "\\\\")' . ' AND (data.description LIKE ? ESCAPE "\\\\") ORDER BY id ASC', + ['qux', 'git'], + ]; + + yield 'Combined single and double quotes' => [ + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE :condition_0 ESCAPE "\\") + AND (data.description LIKE :condition_1 ESCAPE '\\') ORDER BY id ASC +SQL, + [':condition_0' => 'qux', ':condition_1' => 'git'], + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE ? ESCAPE "\\") + AND (data.description LIKE ? ESCAPE '\\') ORDER BY id ASC +SQL, + ['qux', 'git'], + ]; + + yield [ + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' . ' WHERE (data.description LIKE :condition_0 ESCAPE `\\\\`)' . ' AND (data.description LIKE :condition_1 ESCAPE `\\\\`) ORDER BY id ASC', + [':condition_0' => 'qux', ':condition_1' => 'git'], + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' . ' WHERE (data.description LIKE ? ESCAPE `\\\\`)' . ' AND (data.description LIKE ? ESCAPE `\\\\`) ORDER BY id ASC', + ['qux', 'git'], + ]; + + yield 'Combined single quotes and backticks' => [ + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE :condition_0 ESCAPE '\\') + AND (data.description LIKE :condition_1 ESCAPE `\\`) ORDER BY id ASC +SQL, + [':condition_0' => 'qux', ':condition_1' => 'git'], + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE ? ESCAPE '\\') + AND (data.description LIKE ? ESCAPE `\\`) ORDER BY id ASC +SQL, + ['qux', 'git'], + ]; + + yield '? placeholders inside comments' => [ + <<<'SQL' +/* + * test placeholder ? + */ +SELECT dummy as "dummy?" + FROM DUAL + WHERE '?' = '?' +-- AND dummy <> ? + AND dummy = ? +SQL, + ['baz'], + <<<'SQL' +/* + * test placeholder ? + */ +SELECT dummy as "dummy?" + FROM DUAL + WHERE '?' = '?' +-- AND dummy <> ? + AND dummy = ? +SQL, + ['baz'], + ]; + + yield 'Named placeholders inside comments' => [ + <<<'SQL' +/* + * test :placeholder + */ +SELECT dummy as "dummy?" + FROM DUAL + WHERE '?' = '?' +-- AND dummy <> :dummy + AND dummy = :key +SQL, + [':key' => 'baz'], + <<<'SQL' +/* + * test :placeholder + */ +SELECT dummy as "dummy?" + FROM DUAL + WHERE '?' = '?' +-- AND dummy <> :dummy + AND dummy = ? +SQL, + ['baz'], + ]; + + yield 'Escaped question' => [ + <<<'SQL' +SELECT '{"a":null}'::jsonb ?? :key +SQL, + [':key' => 'qux'], + <<<'SQL' +SELECT '{"a":null}'::jsonb ?? ? +SQL, + ['qux'], + ]; + } + + /** + * @covers ::parse + * @covers ::getConvertedSQL + * @covers ::getConvertedParameters + */ + public function testParseReuseObject(): void { + $converter = new NamedPlaceholderConverter(); + $converter->parse('SELECT ?', ['foo']); + $this->assertSame('SELECT ?', $converter->getConvertedSQL()); + $this->assertSame(['foo'], $converter->getConvertedParameters()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing Positional Parameter 0'); + $converter->parse('SELECT ?', []); + } + +} diff --git a/core/modules/system/tests/src/Functional/Module/GenericModuleTestBase.php b/core/modules/system/tests/src/Functional/Module/GenericModuleTestBase.php index 7b3754bc34cbe8e91bd1ffd579f5f4274b0e6a0d..d22f433a4147b472a425ae1ed593fa5a5e3c2437 100644 --- a/core/modules/system/tests/src/Functional/Module/GenericModuleTestBase.php +++ b/core/modules/system/tests/src/Functional/Module/GenericModuleTestBase.php @@ -5,6 +5,7 @@ namespace Drupal\Tests\system\Functional\Module; use Drupal\Core\Database\Database; +use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Tests\BrowserTestBase; /** @@ -50,9 +51,12 @@ public function testModuleGenericIssues(): void { if (empty($info['required'])) { $connection = Database::getConnection(); - // When the database driver is provided by a module, then that module - // cannot be uninstalled. - if ($module !== $connection->getProvider()) { + // The module that provides the database driver, or is a dependency of + // the database driver, cannot be uninstalled. + $database_module_extension = \Drupal::service(ModuleExtensionList::class)->get($connection->getProvider()); + $database_modules_required = $database_module_extension->requires ? array_keys($database_module_extension->requires) : []; + $database_modules_required[] = $connection->getProvider(); + if (!in_array($module, $database_modules_required)) { // Check that the module can be uninstalled and then re-installed again. $this->preUnInstallSteps(); $this->assertTrue(\Drupal::service('module_installer')->uninstall([$module]), "Failed to uninstall '$module' module"); diff --git a/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php b/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php index 80fd287751c57ba2fa2828da424be73869d477cd..aac21dcff875e853a3f9dbb7f63ec94447209b8c 100644 --- a/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php +++ b/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php @@ -203,6 +203,7 @@ public function testLostDatabaseConnection(): void { switch ($this->container->get('database')->driver()) { case 'pgsql': case 'mysql': + case 'mysqli': $this->expectedExceptionMessage = $incorrect_username; break; diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php index 4bf46e8d4911fb8c71da4c2ed9c6ee2ecfd6f747..68a793f8771f355b160a254c68aac1e71e5ef73d 100644 --- a/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php +++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php @@ -4,6 +4,7 @@ namespace Drupal\FunctionalTests\Core\Recipe; +use Drupal\Core\Extension\ModuleExtensionList; use Drupal\shortcut\Entity\Shortcut; use Drupal\Tests\standard\Functional\StandardTest; use Drupal\user\RoleInterface; @@ -35,7 +36,12 @@ public function testStandard(): void { $theme_installer->uninstall(['claro', 'olivero']); // Determine which modules to uninstall. - $uninstall = array_diff(array_keys(\Drupal::moduleHandler()->getModuleList()), ['user', 'system', 'path_alias', \Drupal::database()->getProvider()]); + // If the database module has dependencies, they are expected too. + $database_module_extension = \Drupal::service(ModuleExtensionList::class)->get(\Drupal::database()->getProvider()); + $database_modules = $database_module_extension->requires ? array_keys($database_module_extension->requires) : []; + $database_modules[] = \Drupal::database()->getProvider(); + $keep = array_merge(['user', 'system', 'path_alias'], $database_modules); + $uninstall = array_diff(array_keys(\Drupal::moduleHandler()->getModuleList()), $keep); foreach (['shortcut', 'field_config', 'filter_format', 'field_storage_config'] as $entity_type) { $storage = \Drupal::entityTypeManager()->getStorage($entity_type); $storage->delete($storage->loadMultiple()); diff --git a/core/tests/Drupal/FunctionalTests/ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest.php b/core/tests/Drupal/FunctionalTests/ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest.php index 01c694b4ef4b47375676287b0bc8762e30cbe0aa..883e983966e3ca13bbe39e5c445944477264e5a9 100644 --- a/core/tests/Drupal/FunctionalTests/ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest.php +++ b/core/tests/Drupal/FunctionalTests/ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest.php @@ -25,7 +25,7 @@ protected function setUp(): void { parent::setUp(); $driver = Database::getConnection()->driver(); - if (!in_array($driver, ['mysql', 'pgsql', 'sqlite'])) { + if (!in_array($driver, ['mysql', 'mysqli', 'pgsql', 'sqlite'])) { $this->markTestSkipped("This test does not support the {$driver} database driver."); } diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php index 2ac3ae778a4b4a5c90786eafc55a83f2bb90dd73..f7c949637547b15a1a2d74dd2fec201d0bbd04b4 100644 --- a/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php @@ -7,6 +7,7 @@ use Drupal\Component\Serialization\Yaml; use Drupal\Core\Archiver\ArchiveTar; use Drupal\Core\Database\Database; +use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Core\Installer\Form\SelectProfileForm; /** @@ -99,8 +100,11 @@ protected function prepareEnvironment() { // modules that can not be uninstalled in the core.extension configuration. if (file_exists($config_sync_directory . '/core.extension.yml')) { $core_extension = Yaml::decode(file_get_contents($config_sync_directory . '/core.extension.yml')); - $module = Database::getConnection()->getProvider(); - if ($module !== 'core') { + // If the database module has dependencies, they are expected too. + $database_module_extension = \Drupal::service(ModuleExtensionList::class)->get(Database::getConnection()->getProvider()); + $database_modules = $database_module_extension->requires ? array_keys($database_module_extension->requires) : []; + $database_modules[] = Database::getConnection()->getProvider(); + foreach ($database_modules as $module) { $core_extension['module'][$module] = 0; $core_extension['module'] = module_config_sort($core_extension['module']); } diff --git a/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php b/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php index 8b2c4b493e4b5a5bd52ae9db5bc2b5e922d61113..3ee78e553654544e3e48a3b9d989651b1feb9bd8 100644 --- a/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php +++ b/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php @@ -137,6 +137,16 @@ protected function doInstall() { // Load the database(s). foreach ($this->databaseDumpFiles as $file) { + // Determine the version of the database dump if specified. + $matches = []; + $dumpVersion = preg_match('/drupal-(\d+\.\d+\.\d+)\./', $file, $matches) === 1 ? $matches[1] : NULL; + + // If the db driver is mysqli, we do not need to run the update tests for + // db dumps prior to 11.2 when the module was introduced. + if (Database::getConnection()->getProvider() === 'mysqli' && $dumpVersion && version_compare($dumpVersion, '11.2.0', '<')) { + $this->markTestSkipped("The mysqli driver was introduced in Drupal 11.2, skip update tests from database at version {$dumpVersion}"); + } + if (str_ends_with($file, '.gz')) { $file = "compress.zlib://$file"; } diff --git a/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php b/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php index 079009dc7a56276a8dfc9dfcee7f611d0d2f1ec8..8725f647e8d1cff5a2bf0d6e30e6b421e80975d3 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php +++ b/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php @@ -62,18 +62,6 @@ public function testConcatWsLiterals(): void { $this->assertSame('Hello, , world.', $result->fetchField()); } - /** - * Tests string concatenation with separator, with field values. - */ - public function testConcatWsFields(): void { - $result = $this->connection->query("SELECT CONCAT_WS('-', :a1, [name], :a2, [age]) FROM {test} WHERE [age] = :age", [ - ':a1' => 'name', - ':a2' => 'age', - ':age' => 25, - ]); - $this->assertSame('name-John-age-25', $result->fetchField()); - } - /** * Tests escaping of LIKE wildcards. */ diff --git a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSyntaxTestBase.php b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSyntaxTestBase.php index 7723d872cc122e8e8d89907b54350d60bdbbb17a..e183362053982a2393efa4e7ef6333a0556905f3 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSyntaxTestBase.php +++ b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSyntaxTestBase.php @@ -43,4 +43,16 @@ public function testAllowSquareBrackets(): void { $this->assertSame('[square]', $result->fetchField()); } + /** + * Tests string concatenation with separator, with field values. + */ + public function testConcatWsFields(): void { + $result = $this->connection->query("SELECT CONCAT_WS('-', :a1, [name], :a2, [age]) FROM {test} WHERE [age] = :age", [ + ':a1' => 'name', + ':a2' => 'age', + ':age' => 25, + ]); + $this->assertSame('name-John-age-25', $result->fetchField()); + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php b/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php index 6e28787f764c2f3179ed1bd92fd80743704fa0c3..d06d10c82c918d7c8142883af494ec7bbb4e7404 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php +++ b/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php @@ -20,6 +20,9 @@ class FetchLegacyTest extends DatabaseTestBase { */ #[IgnoreDeprecations] public function testQueryFetchObject(): void { + if ($this->connection->driver() === 'mysqli') { + $this->markTestSkipped("This test is not relevant for the mysqli database driver."); + } $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); @@ -39,6 +42,9 @@ public function testQueryFetchObject(): void { */ #[IgnoreDeprecations] public function testQueryFetchArray(): void { + if ($this->connection->driver() === 'mysqli') { + $this->markTestSkipped("This test is not relevant for the mysqli database driver."); + } $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); @@ -59,6 +65,9 @@ public function testQueryFetchArray(): void { */ #[IgnoreDeprecations] public function testQueryFetchNum(): void { + if ($this->connection->driver() === 'mysqli') { + $this->markTestSkipped("This test is not relevant for the mysqli database driver."); + } $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338"); diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php index 5e708fbbc2ffd49d71ab53b7e1a46540a158fbe4..a914752e9f17826525611f21cf059c7f55d1c31f 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php @@ -774,7 +774,7 @@ public function testCreateFieldAndIndexOnSharedTable(): void { $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table."); $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), "New index 'entity_test_update_field__new_base_field' has been created on the 'entity_test_update' table."); // Check index size in for MySQL. - if (Database::getConnection()->driver() == 'mysql') { + if (in_array(Database::getConnection()->driver(), ['mysql', 'mysqli'])) { $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update_field__new_base_field\' and column_name = \'new_base_field\'')->fetchObject(); $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.'); } @@ -803,7 +803,7 @@ public function testCreateIndexUsingEntityStorageSchemaWithData(): void { $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__type_index'), "New index 'entity_test_update__type_index' has been created on the 'entity_test_update' table."); // Check index size in for MySQL. - if (Database::getConnection()->driver() == 'mysql') { + if (in_array(Database::getConnection()->driver(), ['mysql', 'mysqli'])) { $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update__type_index\' and column_name = \'type\'')->fetchObject(); $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.'); }