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.');
     }