diff --git a/core/.phpstan-baseline.php b/core/.phpstan-baseline.php
index 5937ccef90f83ae11ea53e6cbb611c4bf8e73886..cc39d334b5b7ee901aec05a975a91bf1b4981426 100644
--- a/core/.phpstan-baseline.php
+++ b/core/.phpstan-baseline.php
@@ -29817,12 +29817,6 @@
 	'count' => 1,
 	'path' => __DIR__ . '/modules/pgsql/src/Driver/Database/pgsql/Connection.php',
 ];
-$ignoreErrors[] = [
-	'message' => '#^Method Drupal\\\\pgsql\\\\Driver\\\\Database\\\\pgsql\\\\Connection\\:\\:setPrefix\\(\\) has no return type specified\\.$#',
-	'identifier' => 'missingType.return',
-	'count' => 1,
-	'path' => __DIR__ . '/modules/pgsql/src/Driver/Database/pgsql/Connection.php',
-];
 $ignoreErrors[] = [
 	'message' => '#^Method Drupal\\\\pgsql\\\\Driver\\\\Database\\\\pgsql\\\\Install\\\\Tasks\\:\\:checkBinaryOutput\\(\\) has no return type specified\\.$#',
 	'identifier' => 'missingType.return',
@@ -55676,6 +55670,12 @@
 	'count' => 1,
 	'path' => __DIR__ . '/tests/Drupal/Tests/Core/Database/Stub/StubConnection.php',
 ];
+$ignoreErrors[] = [
+	'message' => '#^Method Drupal\\\\Tests\\\\Core\\\\Database\\\\Stub\\\\StubLegacyConnection\\:\\:createDatabase\\(\\) has no return type specified\\.$#',
+	'identifier' => 'missingType.return',
+	'count' => 1,
+	'path' => __DIR__ . '/tests/Drupal/Tests/Core/Database/Stub/StubLegacyConnection.php',
+];
 $ignoreErrors[] = [
 	'message' => '#^Method Drupal\\\\Tests\\\\Core\\\\Database\\\\Stub\\\\StubSchema\\:\\:addField\\(\\) has no return type specified\\.$#',
 	'identifier' => 'missingType.return',
diff --git a/core/lib/Drupal/Core/Cache/DatabaseBackend.php b/core/lib/Drupal/Core/Cache/DatabaseBackend.php
index efae811b7679a66c32a046fcd63058ce13913c7c..e41ffee9069773c568810e7f7b7363f09a05840d 100644
--- a/core/lib/Drupal/Core/Cache/DatabaseBackend.php
+++ b/core/lib/Drupal/Core/Cache/DatabaseBackend.php
@@ -128,7 +128,7 @@ public function getMultiple(&$cids, $allow_invalid = FALSE) {
     // ::select() is a much smaller proportion of the request.
     $result = [];
     try {
-      $result = $this->connection->query('SELECT [cid], [data], [created], [expire], [serialized], [tags], [checksum] FROM {' . $this->connection->escapeTable($this->bin) . '} WHERE [cid] IN ( :cids[] ) ORDER BY [cid]', [':cids[]' => array_keys($cid_mapping)]);
+      $result = $this->connection->query('SELECT [cid], [data], [created], [expire], [serialized], [tags], [checksum] FROM {' . $this->bin . '} WHERE [cid] IN ( :cids[] ) ORDER BY [cid]', [':cids[]' => array_keys($cid_mapping)]);
     }
     catch (\Exception) {
       // Nothing to do.
diff --git a/core/lib/Drupal/Core/Config/DatabaseStorage.php b/core/lib/Drupal/Core/Config/DatabaseStorage.php
index 60da9de12470e0950a429aa086ddb81c5f075dfe..53bb9584f34aa53c4da23e3ec1c6139a0e9976b6 100644
--- a/core/lib/Drupal/Core/Config/DatabaseStorage.php
+++ b/core/lib/Drupal/Core/Config/DatabaseStorage.php
@@ -65,7 +65,7 @@ public function __construct(Connection $connection, $table, array $options = [],
    */
   public function exists($name) {
     try {
-      return (bool) $this->connection->queryRange('SELECT 1 FROM {' . $this->connection->escapeTable($this->table) . '} WHERE [collection] = :collection AND [name] = :name', 0, 1, [
+      return (bool) $this->connection->queryRange('SELECT 1 FROM {' . $this->table . '} WHERE [collection] = :collection AND [name] = :name', 0, 1, [
         ':collection' => $this->collection,
         ':name' => $name,
       ], $this->options)->fetchField();
@@ -86,7 +86,7 @@ public function exists($name) {
   public function read($name) {
     $data = FALSE;
     try {
-      $raw = $this->connection->query('SELECT [data] FROM {' . $this->connection->escapeTable($this->table) . '} WHERE [collection] = :collection AND [name] = :name', [
+      $raw = $this->connection->query('SELECT [data] FROM {' . $this->table . '} WHERE [collection] = :collection AND [name] = :name', [
         ':collection' => $this->collection,
         ':name' => $name,
       ], $this->options)->fetchField();
@@ -115,7 +115,7 @@ public function readMultiple(array $names) {
     $list = [];
     try {
       $list = $this->connection
-        ->query('SELECT [name], [data] FROM {' . $this->connection->escapeTable($this->table) . '} WHERE [collection] = :collection AND [name] IN ( :names[] )', [
+        ->query('SELECT [name], [data] FROM {' . $this->table . '} WHERE [collection] = :collection AND [name] IN ( :names[] )', [
           ':collection' => $this->collection,
           ':names[]' => $names,
         ], $this->options)
@@ -342,7 +342,7 @@ public function getCollectionName() {
    */
   public function getAllCollectionNames() {
     try {
-      return $this->connection->query('SELECT DISTINCT [collection] FROM {' . $this->connection->escapeTable($this->table) . '} WHERE [collection] <> :collection ORDER by [collection]', [
+      return $this->connection->query('SELECT DISTINCT [collection] FROM {' . $this->table . '} WHERE [collection] <> :collection ORDER by [collection]', [
         ':collection' => StorageInterface::DEFAULT_COLLECTION,
       ])->fetchCol();
     }
diff --git a/core/lib/Drupal/Core/Database/Connection.php b/core/lib/Drupal/Core/Database/Connection.php
index 41bd043c5c8f45846e4e5ac8fc78c89621b6b60f..7380f733d552a812f51e219e60a5a7426633721c 100644
--- a/core/lib/Drupal/Core/Database/Connection.php
+++ b/core/lib/Drupal/Core/Database/Connection.php
@@ -2,9 +2,10 @@
 
 namespace Drupal\Core\Database;
 
-use Drupal\Component\Assertion\Inspector;
 use Drupal\Core\Database\Event\DatabaseEvent;
 use Drupal\Core\Database\Exception\EventException;
+use Drupal\Core\Database\Identifier\IdentifierHandlerBase;
+use Drupal\Core\Database\Identifier\IdentifierType;
 use Drupal\Core\Database\Query\Condition;
 use Drupal\Core\Database\Query\Delete;
 use Drupal\Core\Database\Query\Insert;
@@ -101,30 +102,6 @@ abstract class Connection {
    */
   protected $schema = NULL;
 
-  /**
-   * The prefix used by this database connection.
-   *
-   * @var string
-   */
-  protected string $prefix;
-
-  /**
-   * Replacements to fully qualify {table} placeholders in SQL strings.
-   *
-   * An array of two strings, the first being the replacement for opening curly
-   * brace '{', the second for closing curly brace '}'.
-   *
-   * @var string[]
-   */
-  protected array $tablePlaceholderReplacements;
-
-  /**
-   * List of escaped table names, keyed by unescaped names.
-   *
-   * @var array
-   */
-  protected $escapedTables = [];
-
   /**
    * List of escaped field names, keyed by unescaped names.
    *
@@ -142,17 +119,6 @@ abstract class Connection {
    */
   protected $escapedAliases = [];
 
-  /**
-   * The identifier quote characters for the database type.
-   *
-   * An array containing the start and end identifier quote characters for the
-   * database type. The ANSI SQL standard identifier quote character is a double
-   * quotation mark.
-   *
-   * @var string[]
-   */
-  protected $identifierQuotes;
-
   /**
    * Tracks the database API events to be dispatched.
    *
@@ -166,6 +132,11 @@ abstract class Connection {
    */
   protected TransactionManagerInterface $transactionManager;
 
+  /**
+   * The identifiers handler.
+   */
+  public readonly IdentifierHandlerBase $identifiers;
+
   /**
    * Constructs a Connection object.
    *
@@ -176,13 +147,17 @@ abstract class Connection {
    *   - prefix
    *   - namespace
    *   - Other driver-specific options.
-   */
-  public function __construct(object $connection, array $connection_options) {
-    assert(count($this->identifierQuotes) === 2 && Inspector::assertAllStrings($this->identifierQuotes), '\Drupal\Core\Database\Connection::$identifierQuotes must contain 2 string values');
-
+   * @param \Drupal\Core\Database\Identifier\IdentifierHandlerBase|null $identifierHandler
+   *   The identifiers handler.
+   */
+  public function __construct(
+    object $connection,
+    array $connection_options,
+    ?IdentifierHandlerBase $identifierHandler = NULL,
+  ) {
     // Manage the table prefix.
     $connection_options['prefix'] = $connection_options['prefix'] ?? '';
-    $this->setPrefix($connection_options['prefix']);
+    assert(is_string($connection_options['prefix']), 'The \'prefix\' connection option to ' . __METHOD__ . '() must be a string.');
 
     // Work out the database driver namespace if none is provided. This normally
     // written to setting.php by installer or set by
@@ -191,10 +166,109 @@ public function __construct(object $connection, array $connection_options) {
       $connection_options['namespace'] = (new \ReflectionObject($this))->getNamespaceName();
     }
 
+    if ($identifierHandler) {
+      $this->identifiers = $identifierHandler;
+    }
+    else {
+      @trigger_error("Not passing an IdentifierHandler object to " . __METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. See https://www.drupal.org/node/3513282", E_USER_DEPRECATED);
+      $this->identifiers = new class($connection_options['prefix'] ?? '') extends IdentifierHandlerBase {
+
+        /**
+         * {@inheritdoc}
+         */
+        public function getMaxLength(IdentifierType $type): int {
+          return 128;
+        }
+
+      };
+    }
+
     $this->connection = $connection;
     $this->connectionOptions = $connection_options;
   }
 
+  /**
+   * Implements the magic __get() method.
+   */
+  public function __get(string $name): mixed {
+    switch ($name) {
+      case 'prefix':
+        @trigger_error("Accessing Connection::\${$name} is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282", E_USER_DEPRECATED);
+        return $this->identifiers->tablePrefix ?? '';
+
+      case 'escapedTables':
+        @trigger_error("Accessing Connection::\${$name} is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282", E_USER_DEPRECATED);
+        return [];
+
+      case 'identifierQuotes':
+        @trigger_error("Accessing Connection::\${$name} is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282", E_USER_DEPRECATED);
+        return $this->identifiers->identifierQuotes ?? '';
+
+      case 'tablePlaceholderReplacements':
+        @trigger_error("Accessing Connection::\${$name} is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282", E_USER_DEPRECATED);
+        $identifierQuotes = $this->identifiers->identifierQuotes ?? ['"', '"'];
+        return [
+          $identifierQuotes[0] . str_replace('.', $identifierQuotes[1] . '.' . $identifierQuotes[0], $this->identifiers->tablePrefix ?? ''),
+          $identifierQuotes[1],
+        ];
+
+      default:
+        throw new \LogicException("The \${$name} property is undefined in " . __CLASS__);
+
+    }
+  }
+
+  /**
+   * Implements the magic __set() method.
+   */
+  public function __set(string $name, mixed $value): void {
+    switch ($name) {
+      case 'prefix':
+      case 'escapedTables':
+      case 'identifierQuotes':
+      case 'tablePlaceholderReplacements':
+        @trigger_error("Accessing Connection::\${$name} is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282", E_USER_DEPRECATED);
+        return;
+
+      default:
+        throw new \LogicException("The \${$name} property is undefined in " . __CLASS__);
+    }
+  }
+
+  /**
+   * Implements the magic __isset() method.
+   */
+  public function __isset(string $name): bool {
+    switch ($name) {
+      case 'prefix':
+      case 'escapedTables':
+      case 'identifierQuotes':
+      case 'tablePlaceholderReplacements':
+        @trigger_error("Accessing Connection::\${$name} is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282", E_USER_DEPRECATED);
+        return TRUE;
+
+      default:
+        throw new \LogicException("The \${$name} property is undefined in " . __CLASS__);
+    }
+  }
+
+  /**
+   * Implements the magic __unset() method.
+   */
+  public function __unset(string $name): void {
+    switch ($name) {
+      case 'prefix':
+      case 'escapedTables':
+      case 'identifierQuotes':
+      case 'tablePlaceholderReplacements':
+        @trigger_error("Accessing Connection::\${$name} is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282", E_USER_DEPRECATED);
+        return;
+
+      default:
+        throw new \LogicException("The \${$name} property is undefined in " . __CLASS__);
+    }
+  }
+
   /**
    * Opens a client connection.
    *
@@ -327,9 +401,15 @@ public function attachDatabase(string $database): void {
    *
    * @return string
    *   The table prefix.
+   *
+   * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use
+   *   IdentifierHandler methods instead.
+   *
+   * @see https://www.drupal.org/node/3513282
    */
   public function getPrefix(): string {
-    return $this->prefix;
+    @trigger_error(__METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282", E_USER_DEPRECATED);
+    return $this->identifiers->tablePrefix;
   }
 
   /**
@@ -337,14 +417,14 @@ public function getPrefix(): string {
    *
    * @param string $prefix
    *   A single prefix.
+   *
+   * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Pass the
+   *   table prefix to the IdentifierHandler constructor instead.
+   *
+   * @see https://www.drupal.org/node/3513282
    */
   protected function setPrefix($prefix) {
-    assert(is_string($prefix), 'The \'$prefix\' argument to ' . __METHOD__ . '() must be a string');
-    $this->prefix = $prefix;
-    $this->tablePlaceholderReplacements = [
-      $this->identifierQuotes[0] . str_replace('.', $this->identifierQuotes[1] . '.' . $this->identifierQuotes[0], $prefix),
-      $this->identifierQuotes[1],
-    ];
+    @trigger_error(__METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Pass the table prefix to the IdentifierHandler constructor instead. See https://www.drupal.org/node/3513282", E_USER_DEPRECATED);
   }
 
   /**
@@ -362,7 +442,12 @@ protected function setPrefix($prefix) {
    *   The properly-prefixed string.
    */
   public function prefixTables($sql) {
-    return str_replace(['{', '}'], $this->tablePlaceholderReplacements, $sql);
+    $replacements = $tables = [];
+    preg_match_all('/(\{(\S*)\})/', $sql, $tables, PREG_SET_ORDER, 0);
+    foreach ($tables as $table) {
+      $replacements[$table[1]] = $this->identifiers->table($table[2])->forMachine();
+    }
+    return str_replace(array_keys($replacements), array_values($replacements), $sql);
   }
 
   /**
@@ -386,7 +471,7 @@ public function prefixTables($sql) {
    *   This method should only be called by database API code.
    */
   public function quoteIdentifiers($sql) {
-    return str_replace(['[', ']'], $this->identifierQuotes, $sql);
+    return str_replace(['[', ']'], $this->identifiers->identifierQuotes, $sql);
   }
 
   /**
@@ -399,9 +484,13 @@ public function quoteIdentifiers($sql) {
    *   The fully qualified table name.
    */
   public function getFullQualifiedTableName($table) {
-    $options = $this->getConnectionOptions();
-    $prefix = $this->getPrefix();
-    return $options['database'] . '.' . $prefix . $table;
+    $tableIdentifier = $this->identifiers->table($table);
+    // If already fully qualified, just pass it on.
+    if ($tableIdentifier->database || $tableIdentifier->schema) {
+      return $tableIdentifier->forMachine();
+    }
+    // Return as "<schema>"."<table>".
+    return $this->identifiers->schema($this->getConnectionOptions()['database'])->quotedMachineName . '.' . $tableIdentifier->quotedMachineName;
   }
 
   /**
@@ -983,11 +1072,15 @@ public function condition($conjunction) {
    *
    * @return string
    *   The sanitized database name.
+   *
+   * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use
+   *   IdentifierHandler methods instead.
+   *
+   * @see https://www.drupal.org/node/3513282
    */
   public function escapeDatabase($database) {
-    $database = preg_replace('/[^A-Za-z0-9_]+/', '', $database);
-    [$start_quote, $end_quote] = $this->identifierQuotes;
-    return $start_quote . $database . $end_quote;
+    @trigger_error(__METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282", E_USER_DEPRECATED);
+    return $this->identifiers->schema($database)->forMachine();
   }
 
   /**
@@ -1004,14 +1097,16 @@ public function escapeDatabase($database) {
    * @return string
    *   The sanitized table name.
    *
+   * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use
+   *   IdentifierHandler methods instead.
+   *
+   * @see https://www.drupal.org/node/3513282
    * @see \Drupal\Core\Database\Connection::prefixTables()
    * @see \Drupal\Core\Database\Connection::setPrefix()
    */
   public function escapeTable($table) {
-    if (!isset($this->escapedTables[$table])) {
-      $this->escapedTables[$table] = preg_replace('/[^A-Za-z0-9_.]+/', '', $table);
-    }
-    return $this->escapedTables[$table];
+    @trigger_error(__METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282", E_USER_DEPRECATED);
+    return $this->identifiers->table($table)->canonical();
   }
 
   /**
@@ -1030,7 +1125,7 @@ public function escapeTable($table) {
   public function escapeField($field) {
     if (!isset($this->escapedFields[$field])) {
       $escaped = preg_replace('/[^A-Za-z0-9_.]+/', '', $field);
-      [$start_quote, $end_quote] = $this->identifierQuotes;
+      [$start_quote, $end_quote] = $this->identifiers->identifierQuotes;
       // Sometimes fields have the format table_alias.field. In such cases
       // both identifiers should be quoted, for example, "table_alias"."field".
       $this->escapedFields[$field] = $start_quote . str_replace('.', $end_quote . '.' . $start_quote, $escaped) . $end_quote;
@@ -1054,7 +1149,7 @@ public function escapeField($field) {
    */
   public function escapeAlias($field) {
     if (!isset($this->escapedAliases[$field])) {
-      [$start_quote, $end_quote] = $this->identifierQuotes;
+      [$start_quote, $end_quote] = $this->identifiers->identifierQuotes;
       $this->escapedAliases[$field] = $start_quote . preg_replace('/[^A-Za-z0-9_]+/', '', $field) . $end_quote;
     }
     return $this->escapedAliases[$field];
@@ -1242,6 +1337,8 @@ abstract public function databaseType();
    *
    * @param string $database
    *   The name of the database to create.
+   *
+   * @throws \Drupal\Core\Database\DatabaseNotFoundException
    */
   abstract public function createDatabase($database);
 
diff --git a/core/lib/Drupal/Core/Database/Exception/IdentifierException.php b/core/lib/Drupal/Core/Database/Exception/IdentifierException.php
new file mode 100644
index 0000000000000000000000000000000000000000..a069749f918d8d56dddea7609ffeff616e1421e4
--- /dev/null
+++ b/core/lib/Drupal/Core/Database/Exception/IdentifierException.php
@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Database\Exception;
+
+use Drupal\Core\Database\DatabaseException;
+
+/**
+ * Exception thrown by the identifier handling API.
+ */
+class IdentifierException extends \RuntimeException implements DatabaseException {
+}
diff --git a/core/lib/Drupal/Core/Database/Identifier/Database.php b/core/lib/Drupal/Core/Database/Identifier/Database.php
new file mode 100644
index 0000000000000000000000000000000000000000..6f5173303e5f351deae277a79450cb1fd3e8fb94
--- /dev/null
+++ b/core/lib/Drupal/Core/Database/Identifier/Database.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Database\Identifier;
+
+/**
+ * Handles a database identifier.
+ *
+ * When using full notation, a table can be identified as
+ * [database.][schema.]table.
+ */
+final class Database extends IdentifierBase {
+
+  public function __construct(
+    IdentifierHandlerBase $identifierHandler,
+    string $identifier,
+  ) {
+    $canonicalName = $identifierHandler->canonicalize($identifier, IdentifierType::Database);
+    $machineName = $identifierHandler->resolveForMachine($canonicalName, [], IdentifierType::Database);
+    parent::__construct($identifier, $canonicalName, $machineName, $identifierHandler->quote($machineName));
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Database/Identifier/IdentifierBase.php b/core/lib/Drupal/Core/Database/Identifier/IdentifierBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..68d9266a183b0b4d105d09437f2809ad3fe6fdf2
--- /dev/null
+++ b/core/lib/Drupal/Core/Database/Identifier/IdentifierBase.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Database\Identifier;
+
+/**
+ * The base class for database identifier value objects.
+ */
+abstract class IdentifierBase implements \Stringable {
+
+  public function __construct(
+    public readonly string $identifier,
+    public readonly string $canonicalName,
+    public readonly string $machineName,
+    public readonly string $quotedMachineName,
+  ) {
+  }
+
+  /**
+   * Returns the canonical name of the identifier.
+   *
+   * @return string
+   *   The canonical name of the identifier.
+   */
+  public function canonical(): string {
+    return $this->canonicalName;
+  }
+
+  /**
+   * Returns the identifier in a format suitable for including in SQL queries.
+   *
+   * @return string
+   *   The identifier in a format suitable for including in SQL queries.
+   */
+  public function forMachine(): string {
+    return $this->quotedMachineName;
+  }
+
+  /**
+   * Returns the identifier in a format suitable for including in SQL queries.
+   *
+   * @return string
+   *   The identifier in a format suitable for including in SQL queries.
+   */
+  public function __toString(): string {
+    return $this->forMachine();
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Database/Identifier/IdentifierHandlerBase.php b/core/lib/Drupal/Core/Database/Identifier/IdentifierHandlerBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..f67092dcabdf100b4d40350dbd08bb9cfe074f22
--- /dev/null
+++ b/core/lib/Drupal/Core/Database/Identifier/IdentifierHandlerBase.php
@@ -0,0 +1,367 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Database\Identifier;
+
+use Drupal\Component\Assertion\Inspector;
+use Drupal\Core\Database\Exception\IdentifierException;
+
+/**
+ * Base class to handle identifier value objects.
+ *
+ * Database drivers should extend this class to implement db-specific
+ * limitations and behaviors.
+ */
+abstract class IdentifierHandlerBase {
+
+  /**
+   * A cache of all identifiers handled.
+   */
+  protected array $identifiers;
+
+  /**
+   * Constructor.
+   *
+   * @param string $tablePrefix
+   *   The table prefix to be used by the database connection.
+   * @param array{0:string, 1:string} $identifierQuotes
+   *   The identifier quote characters for the database type. An array
+   *   containing the start and end identifier quote characters for the
+   *   database type. The ANSI SQL standard identifier quote character is a
+   *   double quotation mark.
+   */
+  public function __construct(
+    public readonly string $tablePrefix,
+    public readonly array $identifierQuotes = ['"', '"'],
+  ) {
+    assert(count($this->identifierQuotes) === 2 && Inspector::assertAllStrings($this->identifierQuotes), __CLASS__ . '::$identifierQuotes must contain 2 string values');
+  }
+
+  /**
+   * Returns a database identifier value object.
+   *
+   * @param string|\Drupal\Core\Database\Identifier\Database $identifier
+   *   A database name as a string, or a Database value object.
+   *
+   * @return \Drupal\Core\Database\Identifier\Database
+   *   A database identifier value object.
+   */
+  public function database(string|Database $identifier): Database {
+    return $this->getIdentifierValueObject(IdentifierType::Database, $identifier);
+  }
+
+  /**
+   * Returns a schema identifier value object.
+   *
+   * @param string|\Drupal\Core\Database\Identifier\Schema $identifier
+   *   A schema name as a string, or a Schema value object.
+   *
+   * @return \Drupal\Core\Database\Identifier\Schema
+   *   A schema identifier value object.
+   */
+  public function schema(string|Schema $identifier): Schema {
+    return $this->getIdentifierValueObject(IdentifierType::Schema, $identifier);
+  }
+
+  /**
+   * Returns a table identifier value object.
+   *
+   * @param string|\Drupal\Core\Database\Identifier\Table $identifier
+   *   A table name as a string, or a Table value object.
+   *
+   * @return \Drupal\Core\Database\Identifier\Table
+   *   A table identifier value object.
+   */
+  public function table(string|Table $identifier): Table {
+    return $this->getIdentifierValueObject(IdentifierType::Table, $identifier);
+  }
+
+  /**
+   * Returns the value object of an identifier.
+   *
+   * @param \Drupal\Core\Database\Identifier\IdentifierType $type
+   *   The type of identifier.
+   * @param string|\Drupal\Core\Database\Identifier\IdentifierBase $identifier
+   *   An identifier as a string, or an identifier value object.
+   *
+   * @return \Drupal\Core\Database\Identifier\IdentifierBase
+   *   An identifier value object.
+   */
+  protected function getIdentifierValueObject(IdentifierType $type, string|IdentifierBase $identifier): IdentifierBase {
+    $valueObjectClass = IdentifierType::valueObjectClass($type);
+    if ($identifier instanceof $valueObjectClass) {
+      $identifier = $identifier->identifier;
+    }
+    if ($this->isCached($identifier, $type)) {
+      $valueObject = $this->fromCache($identifier, $type);
+    }
+    else {
+      $valueObject = new $valueObjectClass($this, $identifier);
+      $this->toCache($identifier, $type, $valueObject);
+    }
+    return $valueObject;
+  }
+
+  /**
+   * Adds an identifier value object to the local cache.
+   *
+   * @param string $identifier
+   *   The raw identifier string.
+   * @param \Drupal\Core\Database\Identifier\IdentifierType $type
+   *   The type of identifier.
+   * @param \Drupal\Core\Database\Identifier\IdentifierBase $identifierValueObject
+   *   The identifier value object.
+   */
+  protected function toCache(string $identifier, IdentifierType $type, IdentifierBase $identifierValueObject): void {
+    $this->identifiers['identifier'][$identifier][$type->value] = $identifierValueObject;
+  }
+
+  /**
+   * Checks if an identifier value object is present in the local cache.
+   *
+   * @param string $identifier
+   *   The raw identifier string.
+   * @param \Drupal\Core\Database\Identifier\IdentifierType $type
+   *   The type of identifier.
+   *
+   * @return bool
+   *   TRUE if the identifier value object is available in tha local cache,
+   *   FALSE otherwise.
+   */
+  protected function isCached(string $identifier, IdentifierType $type): bool {
+    return isset($this->identifiers['identifier'][$identifier][$type->value]);
+  }
+
+  /**
+   * Gets an identifier value object from the local cache.
+   *
+   * @param string $identifier
+   *   The raw identifier string.
+   * @param \Drupal\Core\Database\Identifier\IdentifierType $type
+   *   The type of identifier.
+   *
+   * @return \Drupal\Core\Database\Identifier\IdentifierBase
+   *   The identifier value object.
+   */
+  protected function fromCache(string $identifier, IdentifierType $type): IdentifierBase {
+    return $this->identifiers['identifier'][$identifier][$type->value];
+  }
+
+  /**
+   * Returns the maximum length, in bytes, of an identifier type.
+   *
+   * @return positive-int
+   *   The maximum length, in bytes, of an identifier type.
+   */
+  abstract public function getMaxLength(IdentifierType $type): int;
+
+  /**
+   * Returns a string with initial and final quote characters.
+   *
+   * @param string $value
+   *   The input string.
+   *
+   * @return string
+   *   The quoted string.
+   */
+  public function quote(string $value): string {
+    return $this->identifierQuotes[0] . $value . $this->identifierQuotes[1];
+  }
+
+  /**
+   * Returns a canonicalized and validated identifier string.
+   *
+   * Standard SQL identifiers designate basic Latin letters, digits 0-9,
+   * dollar and underscore as valid characters. Drupal is stricter in the
+   * sense that the dollar character is not allowed.
+   *
+   * @param string $identifier
+   *   A raw identifier string. Can include quote characters and any character
+   *   in general.
+   * @param \Drupal\Core\Database\Identifier\IdentifierType $type
+   *   The type of identifier.
+   *
+   * @return string
+   *   A canonicalized and validated identifier string.
+   *
+   * @throws \Drupal\Core\Database\Exception\IdentifierException
+   *   If the identifier is invalid.
+   */
+  public function canonicalize(string $identifier, IdentifierType $type): string {
+    $canonicalName = preg_replace('/[^A-Za-z0-9_]+/', '', $identifier);
+    $this->validateCanonicalName($identifier, $canonicalName, $type);
+    return $canonicalName;
+  }
+
+  /**
+   * Validates a canonicalized identifier string.
+   *
+   * @param string $identifier
+   *   A raw identifier string. Can include quote characters and any character
+   *   in general.
+   * @param string $canonicalName
+   *   A canonical identifier string.
+   * @param \Drupal\Core\Database\Identifier\IdentifierType $type
+   *   The type of identifier.
+   *
+   * @return true
+   *   Upon successful validation.
+   *
+   * @throws \Drupal\Core\Database\Exception\IdentifierException
+   *   If the identifier is invalid.
+   */
+  protected function validateCanonicalName(string $identifier, string $canonicalName, IdentifierType $type): TRUE {
+    $canonicalNameLength = strlen($canonicalName);
+    if ($canonicalNameLength > $this->getMaxLength($type) || $canonicalNameLength === 0) {
+      throw new IdentifierException(sprintf(
+        'The length of the %s identifier \'%s\' once canonicalized to \'%s\' is invalid (maximum allowed: %d)',
+        $type->value,
+        $identifier,
+        $canonicalName,
+        $this->getMaxLength($type),
+      ));
+    }
+    return TRUE;
+  }
+
+  /**
+   * Shortens an identifier's canonical name by adding an hash.
+   *
+   * This method calculates an hash of the canonical name and then returns a
+   * string suitable for machine use. The hash is inserted in the middle of
+   * the remaining part of the canonical name once a prefix has been added.
+   *
+   * @param string $canonicalName
+   *   A canonical identifier string.
+   * @param array<string,mixed> $info
+   *   An associative array of context information.
+   * @param \Drupal\Core\Database\Identifier\IdentifierType $type
+   *   The type of identifier.
+   * @param string $prefix
+   *   A prefix that cannot be part of the shortening.
+   * @param positive-int $length
+   *   The maximum length of the returned string.
+   * @param positive-int $hashLength
+   *   The length of the hashed part in the returned string.
+   *
+   * @return string
+   *   The shortened string.
+   *
+   * @throws \Drupal\Core\Database\Exception\IdentifierException
+   *   If a shortened string could not be calculated.
+   */
+  protected function cropByHashing(string $canonicalName, array $info, IdentifierType $type, string $prefix, int $length, int $hashLength): string {
+    $allowedLength = $length - strlen($prefix) - $hashLength;
+    if ($allowedLength < 4) {
+      throw new IdentifierException(sprintf(
+        '%s canonical identifier \'%s\' cannot be converted into a machine identifier%s',
+        ucfirst($type->value),
+        $canonicalName,
+        $prefix !== '' ? "; prefix '{$prefix}'" : '',
+      ));
+    }
+    $hash = substr(hash('sha256', $canonicalName), 0, $hashLength);
+    $lSize = (int) ($allowedLength / 2);
+    $rSize = $allowedLength - $lSize;
+    return $prefix . substr($canonicalName, 0, $lSize) . $hash . substr($canonicalName, -$rSize);
+  }
+
+  /**
+   * Returns the machine accepted string for an identifier.
+   *
+   * This method converts a canonical identifier in the machine readable
+   * version. It could shorten the canonical name or perform other
+   * transformation as necessary. The returned value is stored in the
+   * identifier's $machineName property.
+   *
+   * @param string $canonicalName
+   *   A canonical identifier string.
+   * @param array<string,mixed> $info
+   *   An associative array of context information.
+   * @param \Drupal\Core\Database\Identifier\IdentifierType $type
+   *   The type of identifier.
+   *
+   * @return string
+   *   The machine accepted string for an identifier.
+   *
+   * @throws \Drupal\Core\Database\Exception\IdentifierException
+   *   If a machine string could not be determined.
+   */
+  public function resolveForMachine(string $canonicalName, array $info, IdentifierType $type): string {
+    return match ($type) {
+      IdentifierType::Table => $this->resolveTableForMachine($canonicalName, $info),
+      default => $canonicalName,
+    };
+  }
+
+  /**
+   * Returns the machine accepted string for a table.
+   *
+   * @param string $canonicalName
+   *   A canonical table name string.
+   * @param array<string,mixed> $info
+   *   An associative array of context information.
+   *
+   * @return string
+   *   The machine accepted string for a table.
+   *
+   * @throws \Drupal\Core\Database\Exception\IdentifierException
+   *   If a machine string could not be determined.
+   */
+  protected function resolveTableForMachine(string $canonicalName, array $info): string {
+    if (strlen($info['needs_prefix'] ? $this->tablePrefix : '' . $canonicalName) > $this->getMaxLength(IdentifierType::Table)) {
+      throw new IdentifierException(sprintf(
+        'The machine length of the %s canonicalized identifier \'%s\' once table prefix \'%s\' is added is invalid (maximum allowed: %d)',
+        IdentifierType::Table->value,
+        $canonicalName,
+        $this->tablePrefix,
+        $this->getMaxLength(IdentifierType::Table),
+      ));
+    }
+    return $info['needs_prefix'] ? $this->tablePrefix . $canonicalName : $canonicalName;
+  }
+
+  /**
+   * Parses a raw table identifier string into its components.
+   *
+   * A raw table identifier may include database and/or schema information in
+   * the format [database.][schema.]table and may include quote characters.
+   * This method returns the parts that can be used to get a Table identifier
+   * value object.
+   *
+   * @param string $identifier
+   *   A raw table identifier string.
+   *
+   * @return array{database: \Drupal\Core\Database\Identifier\Database|null,schema: \Drupal\Core\Database\Identifier\Schema|null, table: string, needs_prefix: bool}
+   *   The parts that can be used to get a Table identifier value object.
+   *
+   * @throws \Drupal\Core\Database\Exception\IdentifierException
+   *   If an error occurred.
+   */
+  public function parseTableIdentifier(string $identifier): array {
+    $parts = explode(".", $identifier);
+    [$database, $schema, $table] = match (count($parts)) {
+      1 => [NULL, NULL, $parts[0]],
+      2 => [NULL, $this->schema($parts[0]), $parts[1]],
+      3 => [$this->database($parts[0]), $this->schema($parts[1]), $parts[2]],
+      default => throw new IdentifierException(sprintf(
+        'The table identifier \'%s\' does not comply with the syntax [database.][schema.]table',
+        $identifier,
+      )),
+    };
+    if ($this->tablePrefix !== '') {
+      $needsPrefix = count($parts) === 1;
+    }
+    else {
+      $needsPrefix = FALSE;
+    }
+    return [
+      'database' => $database,
+      'schema' => $schema,
+      'table' => $table,
+      'needs_prefix' => $needsPrefix,
+    ];
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Database/Identifier/IdentifierType.php b/core/lib/Drupal/Core/Database/Identifier/IdentifierType.php
new file mode 100644
index 0000000000000000000000000000000000000000..67515d8e179dca17c6c91c26bf7d7369a936eb49
--- /dev/null
+++ b/core/lib/Drupal/Core/Database/Identifier/IdentifierType.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Database\Identifier;
+
+/**
+ * Enumeration of database identifier types.
+ */
+enum IdentifierType: string {
+  case Database = 'database';
+  case Schema = 'schema';
+  case Sequence = 'sequence';
+  case Table = 'table';
+  case Column = 'column';
+  case Index = 'index';
+
+  case Alias = 'alias';
+
+  case Unknown = 'unknown';
+
+  /**
+   * Returns the value object class for an identifier type.
+   *
+   * @param Drupal\Core\Database\Identifier\IdentifierType $case
+   *   The identifier type.
+   *
+   * @return class-string<\Drupal\Core\Database\Identifier\IdentifierBase>
+   *   The class of the identifier type value object.
+   */
+  public static function valueObjectClass(self $case): string {
+    return match ($case) {
+      IdentifierType::Database => Database::class,
+      IdentifierType::Schema => Schema::class,
+      IdentifierType::Table => Table::class,
+    };
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Database/Identifier/Schema.php b/core/lib/Drupal/Core/Database/Identifier/Schema.php
new file mode 100644
index 0000000000000000000000000000000000000000..e9df7f0d56e4c70b2901749d67ab29cec5046c26
--- /dev/null
+++ b/core/lib/Drupal/Core/Database/Identifier/Schema.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Database\Identifier;
+
+/**
+ * Handles a schema identifier.
+ *
+ * When using full notation, a table can be identified as
+ * [database.][schema.]table.
+ */
+final class Schema extends IdentifierBase {
+
+  public function __construct(
+    IdentifierHandlerBase $identifierHandler,
+    string $identifier,
+  ) {
+    $canonicalName = $identifierHandler->canonicalize($identifier, IdentifierType::Schema);
+    $machineName = $identifierHandler->resolveForMachine($canonicalName, [], IdentifierType::Schema);
+    parent::__construct($identifier, $canonicalName, $machineName, $identifierHandler->quote($machineName));
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Database/Identifier/Table.php b/core/lib/Drupal/Core/Database/Identifier/Table.php
new file mode 100644
index 0000000000000000000000000000000000000000..45a7386291e3b9d73e8526206b3ef8ef9214d6bd
--- /dev/null
+++ b/core/lib/Drupal/Core/Database/Identifier/Table.php
@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Database\Identifier;
+
+/**
+ * Handles a table identifier.
+ *
+ * When using full notation, a table can be identified as
+ * [database.][schema.]table.
+ */
+final class Table extends IdentifierBase {
+
+  /**
+   * The database identifier, if specified.
+   */
+  public readonly ?Database $database;
+
+  /**
+   * The schema identifier, if specified.
+   */
+  public readonly ?Schema $schema;
+
+  /**
+   * Whether the table requires to be prefixed.
+   */
+  public readonly bool $needsPrefix;
+
+  public function __construct(
+    IdentifierHandlerBase $identifierHandler,
+    string $identifier,
+  ) {
+    $parts = $identifierHandler->parseTableIdentifier($identifier);
+
+    $canonicalName = $identifierHandler->canonicalize($parts['table'], IdentifierType::Table);
+    $machineName = $identifierHandler->resolveForMachine($canonicalName, $parts, IdentifierType::Table);
+    parent::__construct($identifier, $canonicalName, $machineName, $identifierHandler->quote($machineName));
+
+    $this->database = $parts['database'];
+    $this->schema = $parts['schema'];
+    $this->needsPrefix = $parts['needs_prefix'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function canonical(): string {
+    $ret = isset($this->database) ? $this->database->canonicalName . '.' : '';
+    $ret .= isset($this->schema) ? $this->schema->canonicalName . '.' : '';
+    $ret .= $this->canonicalName;
+    return $ret;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function forMachine(): string {
+    $ret = isset($this->database) ? $this->database->quotedMachineName . '.' : '';
+    $ret .= isset($this->schema) ? $this->schema->quotedMachineName . '.' : '';
+    $ret .= $this->quotedMachineName;
+    return $ret;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Database/Query/Delete.php b/core/lib/Drupal/Core/Database/Query/Delete.php
index 946f91240e1f4b3a2cdf099214dc9ce0cce3c3fc..da1e4748adf1bc26566ca6334b20e38134ba2ccc 100644
--- a/core/lib/Drupal/Core/Database/Query/Delete.php
+++ b/core/lib/Drupal/Core/Database/Query/Delete.php
@@ -70,7 +70,7 @@ public function __toString() {
     // Create a sanitized comment string to prepend to the query.
     $comments = $this->connection->makeComment($this->comments);
 
-    $query = $comments . 'DELETE FROM {' . $this->connection->escapeTable($this->table) . '} ';
+    $query = $comments . 'DELETE FROM ' . $this->connection->identifiers->table($this->table)->forMachine();
 
     if (count($this->condition)) {
 
diff --git a/core/lib/Drupal/Core/Database/Query/Insert.php b/core/lib/Drupal/Core/Database/Query/Insert.php
index 4657e5c3e5e82330a1401c8f6b7bbbaf1b45229d..60bccaea343c6a90a96d2d3b2873907204053eb8 100644
--- a/core/lib/Drupal/Core/Database/Query/Insert.php
+++ b/core/lib/Drupal/Core/Database/Query/Insert.php
@@ -116,7 +116,7 @@ public function __toString() {
     $insert_fields = array_merge($this->defaultFields, $this->insertFields);
 
     if (!empty($this->fromQuery)) {
-      return $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') ' . $this->fromQuery;
+      return $comments . 'INSERT INTO ' . $this->connection->identifiers->table($this->table)->forMachine() . ' (' . implode(', ', $insert_fields) . ') ' . $this->fromQuery;
     }
 
     // For simplicity, we will use the $placeholders array to inject
@@ -126,7 +126,7 @@ public function __toString() {
     $placeholders = array_pad($placeholders, count($this->defaultFields), 'default');
     $placeholders = array_pad($placeholders, count($this->insertFields), '?');
 
-    return $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') VALUES (' . implode(', ', $placeholders) . ')';
+    return $comments . 'INSERT INTO ' . $this->connection->identifiers->table($this->table)->forMachine() . ' (' . implode(', ', $insert_fields) . ') VALUES (' . implode(', ', $placeholders) . ')';
   }
 
   /**
diff --git a/core/lib/Drupal/Core/Database/Query/Select.php b/core/lib/Drupal/Core/Database/Query/Select.php
index d284ea285a5b0fc749041b977dd635b0febfd6f4..2e3f0f49d5fd4de069909e815d1009cf95d6eb75 100644
--- a/core/lib/Drupal/Core/Database/Query/Select.php
+++ b/core/lib/Drupal/Core/Database/Query/Select.php
@@ -854,11 +854,7 @@ public function __toString() {
         $table_string = '(' . (string) $subquery . ')';
       }
       else {
-        $table_string = $this->connection->escapeTable($table['table']);
-        // Do not attempt prefixing cross database / schema queries.
-        if (!str_contains($table_string, '.')) {
-          $table_string = '{' . $table_string . '}';
-        }
+        $table_string = $this->connection->identifiers->table($table['table'])->forMachine();
       }
 
       // Don't use the AS keyword for table aliases, as some
diff --git a/core/lib/Drupal/Core/Database/Query/Truncate.php b/core/lib/Drupal/Core/Database/Query/Truncate.php
index 33676e7534afdfd7a2c1b0fda03d87cbce3b7699..044340cc33d8da37a3fff6cb295dc53500daf98d 100644
--- a/core/lib/Drupal/Core/Database/Query/Truncate.php
+++ b/core/lib/Drupal/Core/Database/Query/Truncate.php
@@ -75,10 +75,10 @@ public function __toString() {
     // The statement actually built depends on whether a transaction is active.
     // @see ::execute()
     if ($this->connection->inTransaction()) {
-      return $comments . 'DELETE FROM {' . $this->connection->escapeTable($this->table) . '}';
+      return $comments . 'DELETE FROM ' . $this->connection->identifiers->table($this->table)->forMachine();
     }
     else {
-      return $comments . 'TRUNCATE {' . $this->connection->escapeTable($this->table) . '} ';
+      return $comments . 'TRUNCATE ' . $this->connection->identifiers->table($this->table)->forMachine();
     }
   }
 
diff --git a/core/lib/Drupal/Core/Database/Query/Update.php b/core/lib/Drupal/Core/Database/Query/Update.php
index 651bb674e755a23773a5799e251a17d813f427eb..82e33988b70231c1e0d04c67f4a087bb01267289 100644
--- a/core/lib/Drupal/Core/Database/Query/Update.php
+++ b/core/lib/Drupal/Core/Database/Query/Update.php
@@ -166,7 +166,7 @@ public function __toString() {
       $update_fields[] = $this->connection->escapeField($field) . '=' . $placeholders[$max_placeholder++];
     }
 
-    $query = $comments . 'UPDATE {' . $this->connection->escapeTable($this->table) . '} SET ' . implode(', ', $update_fields);
+    $query = $comments . 'UPDATE ' . $this->connection->identifiers->table($this->table)->forMachine() . ' SET ' . implode(', ', $update_fields);
 
     if (count($this->condition)) {
       $this->condition->compile($this->connection, $this);
diff --git a/core/lib/Drupal/Core/Database/Schema.php b/core/lib/Drupal/Core/Database/Schema.php
index 97e73b1bf8cbd1fd8aa6640c9c9a56c751919bdb..9073ca3fbaed76f38727abb3f16780032258223a 100644
--- a/core/lib/Drupal/Core/Database/Schema.php
+++ b/core/lib/Drupal/Core/Database/Schema.php
@@ -82,9 +82,10 @@ public function nextPlaceholder() {
    *   A keyed array with information about the schema, table name and prefix.
    */
   protected function getPrefixInfo($table = 'default', $add_prefix = TRUE) {
+    @trigger_error(__METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/7654132", E_USER_DEPRECATED);
     $info = [
       'schema' => $this->defaultSchema,
-      'prefix' => $this->connection->getPrefix(),
+      'prefix' => isset($this->connection->identifiers) ? $this->connection->identifiers->tablePrefix : '',
     ];
     if ($add_prefix) {
       $table = $info['prefix'] . $table;
@@ -111,6 +112,7 @@ protected function getPrefixInfo($table = 'default', $add_prefix = TRUE) {
    * This prevents using {} around non-table names like indexes and keys.
    */
   public function prefixNonTable($table) {
+    @trigger_error(__METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/7654132", E_USER_DEPRECATED);
     $args = func_get_args();
     $info = $this->getPrefixInfo($table);
     $args[0] = $info['table'];
@@ -223,7 +225,7 @@ public function findTables($table_expression) {
     $condition = $this->buildTableNameCondition('%', 'LIKE');
     $condition->compile($this->connection, $this);
 
-    $prefix = $this->connection->getPrefix();
+    $prefix = $this->connection->identifiers->tablePrefix;
     $prefix_length = strlen($prefix);
     $tables = [];
     // Normally, we would heartily discourage the use of string
diff --git a/core/lib/Drupal/Core/KeyValueStore/DatabaseStorage.php b/core/lib/Drupal/Core/KeyValueStore/DatabaseStorage.php
index ea5247bc750b603a64a9b198727442c02c36e12a..97c84d8f704e1b2395f97c65eaf3f84a3e9e9d22 100644
--- a/core/lib/Drupal/Core/KeyValueStore/DatabaseStorage.php
+++ b/core/lib/Drupal/Core/KeyValueStore/DatabaseStorage.php
@@ -63,7 +63,7 @@ public function __construct($collection, SerializationInterface $serializer, Con
    */
   public function has($key) {
     try {
-      return (bool) $this->connection->query('SELECT 1 FROM {' . $this->connection->escapeTable($this->table) . '} WHERE [collection] = :collection AND [name] = :key', [
+      return (bool) $this->connection->query('SELECT 1 FROM {' . $this->table . '} WHERE [collection] = :collection AND [name] = :key', [
         ':collection' => $this->collection,
         ':key' => $key,
       ])->fetchField();
@@ -81,7 +81,7 @@ public function getMultiple(array $keys) {
     $values = [];
     try {
       $result = $this->connection
-        ->query('SELECT [name], [value] FROM {' . $this->connection->escapeTable($this->table) . '} WHERE [name] IN ( :keys[] ) AND [collection] = :collection', [
+        ->query('SELECT [name], [value] FROM {' . $this->table . '} WHERE [name] IN ( :keys[] ) AND [collection] = :collection', [
           ':keys[]' => $keys,
           ':collection' => $this->collection,
         ])
@@ -105,7 +105,7 @@ public function getMultiple(array $keys) {
    */
   public function getAll() {
     try {
-      $result = $this->connection->query('SELECT [name], [value] FROM {' . $this->connection->escapeTable($this->table) . '} WHERE [collection] = :collection', [':collection' => $this->collection]);
+      $result = $this->connection->query('SELECT [name], [value] FROM {' . $this->table . '} WHERE [collection] = :collection', [':collection' => $this->collection]);
     }
     catch (\Exception $e) {
       $this->catchException($e);
diff --git a/core/lib/Drupal/Core/KeyValueStore/DatabaseStorageExpirable.php b/core/lib/Drupal/Core/KeyValueStore/DatabaseStorageExpirable.php
index 8813d9e2abe60b5780e794931cec780089fe33d8..aa0c2b3ccc6c41aaca14ad8bfccd193bfb8439a0 100644
--- a/core/lib/Drupal/Core/KeyValueStore/DatabaseStorageExpirable.php
+++ b/core/lib/Drupal/Core/KeyValueStore/DatabaseStorageExpirable.php
@@ -43,7 +43,7 @@ public function __construct(
    */
   public function has($key) {
     try {
-      return (bool) $this->connection->query('SELECT 1 FROM {' . $this->connection->escapeTable($this->table) . '} WHERE [collection] = :collection AND [name] = :key AND [expire] > :now', [
+      return (bool) $this->connection->query('SELECT 1 FROM {' . $this->table . '} WHERE [collection] = :collection AND [name] = :key AND [expire] > :now', [
         ':collection' => $this->collection,
         ':key' => $key,
         ':now' => $this->time->getRequestTime(),
@@ -61,7 +61,7 @@ public function has($key) {
   public function getMultiple(array $keys) {
     try {
       $values = $this->connection->query(
-        'SELECT [name], [value] FROM {' . $this->connection->escapeTable($this->table) . '} WHERE [expire] > :now AND [name] IN ( :keys[] ) AND [collection] = :collection',
+        'SELECT [name], [value] FROM {' . $this->table . '} WHERE [expire] > :now AND [name] IN ( :keys[] ) AND [collection] = :collection',
         [
           ':now' => $this->time->getRequestTime(),
           ':keys[]' => $keys,
@@ -85,7 +85,7 @@ public function getMultiple(array $keys) {
   public function getAll() {
     try {
       $values = $this->connection->query(
-        'SELECT [name], [value] FROM {' . $this->connection->escapeTable($this->table) . '} WHERE [collection] = :collection AND [expire] > :now',
+        'SELECT [name], [value] FROM {' . $this->table . '} WHERE [collection] = :collection AND [expire] > :now',
         [
           ':collection' => $this->collection,
           ':now' => $this->time->getRequestTime(),
diff --git a/core/lib/Drupal/Core/Routing/RouteProvider.php b/core/lib/Drupal/Core/Routing/RouteProvider.php
index ab06f6ea274de085ab4b5e386bb4bfaaed5d3bdd..aeb047c79c704bfac8ff557c187215b4b49cbdff 100644
--- a/core/lib/Drupal/Core/Routing/RouteProvider.php
+++ b/core/lib/Drupal/Core/Routing/RouteProvider.php
@@ -243,7 +243,7 @@ public function preLoadRoutes($names) {
       }
       else {
         try {
-          $result = $this->connection->query('SELECT [name], [route] FROM {' . $this->connection->escapeTable($this->tableName) . '} WHERE [name] IN ( :names[] )', [':names[]' => $routes_to_load]);
+          $result = $this->connection->query('SELECT [name], [route] FROM {' . $this->tableName . '} WHERE [name] IN ( :names[] )', [':names[]' => $routes_to_load]);
           $routes = $result->fetchAllKeyed();
 
           $this->cache->set($cid, $routes, Cache::PERMANENT, ['routes']);
@@ -380,7 +380,7 @@ protected function getRoutesByPath($path) {
     // trailing wildcard parts as long as the pattern matches, since we
     // dump the route pattern without those optional parts.
     try {
-      $routes = $this->connection->query("SELECT [name], [route], [fit] FROM {" . $this->connection->escapeTable($this->tableName) . "} WHERE [pattern_outline] IN ( :patterns[] ) AND [number_parts] >= :count_parts", [
+      $routes = $this->connection->query("SELECT [name], [route], [fit] FROM {" . $this->tableName . "} WHERE [pattern_outline] IN ( :patterns[] ) AND [number_parts] >= :count_parts", [
         ':patterns[]' => $ancestors,
         ':count_parts' => count($parts),
       ])
diff --git a/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageContentCommentSettingsTest.php b/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageContentCommentSettingsTest.php
index 16923949a2e12dd6d09bdf27f4d04eafa0239a61..2a2ab96cd6825220b575fe11c7d9a7169fa3f820 100644
--- a/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageContentCommentSettingsTest.php
+++ b/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageContentCommentSettingsTest.php
@@ -43,7 +43,7 @@ protected function setUp(): void {
    */
   public function testLanguageCommentSettings(): void {
     // Confirm there is no message about a missing bundle.
-    $this->assertEmpty($this->migrateMessages, $this->migrateMessages['error'][0] ?? '');
+    $this->assertEmpty($this->migrateMessages, isset($this->migrateMessages['error'][0]) ? (string) $this->migrateMessages['error'][0] : '');
 
     // Article and Blog content type have multilingual settings of 'Enabled,
     // with Translation'. Assert that comments are translatable and the default
diff --git a/core/modules/migrate/src/Plugin/migrate/id_map/Sql.php b/core/modules/migrate/src/Plugin/migrate/id_map/Sql.php
index faec03c72cc45d585b3bf603c4ffd4ac1580a09a..53b598bb14db68863b9db5848372bb43885b5d1c 100644
--- a/core/modules/migrate/src/Plugin/migrate/id_map/Sql.php
+++ b/core/modules/migrate/src/Plugin/migrate/id_map/Sql.php
@@ -183,7 +183,7 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
 
     // Default generated table names, limited to 63 characters.
     $machine_name = str_replace(':', '__', $this->migration->id());
-    $prefix_length = strlen($this->database->getPrefix());
+    $prefix_length = isset($this->database->identifiers) ? strlen($this->database->identifiers->tablePrefix) : 0;
     $this->mapTableName = 'migrate_map_' . mb_strtolower($machine_name);
     $this->mapTableName = mb_substr($this->mapTableName, 0, 63 - $prefix_length);
     $this->messageTableName = 'migrate_message_' . mb_strtolower($machine_name);
diff --git a/core/modules/migrate/tests/src/Kernel/MigrateTestBase.php b/core/modules/migrate/tests/src/Kernel/MigrateTestBase.php
index 4f4089b3ac370a253174ab7094c20d272ce574bb..6cc3c5634d330287c3a30b4e8279d0172e09e68a 100644
--- a/core/modules/migrate/tests/src/Kernel/MigrateTestBase.php
+++ b/core/modules/migrate/tests/src/Kernel/MigrateTestBase.php
@@ -203,7 +203,7 @@ public function display($message, $type = 'status') {
       $this->migrateMessages[$type][] = $message;
     }
     else {
-      $this->assertEquals('status', $type, $message);
+      $this->assertEquals('status', $type, (string) $message);
     }
   }
 
diff --git a/core/modules/migrate/tests/src/Unit/MigrateSqlIdMapTest.php b/core/modules/migrate/tests/src/Unit/MigrateSqlIdMapTest.php
index b162742a2e35ff486e2a01b6cd8a7ccab49090e7..25f6edff4571616baa089a486d46839c3cad0642 100644
--- a/core/modules/migrate/tests/src/Unit/MigrateSqlIdMapTest.php
+++ b/core/modules/migrate/tests/src/Unit/MigrateSqlIdMapTest.php
@@ -1027,7 +1027,7 @@ public function testGetQualifiedMapTablePrefix(): void {
     // The SQLite driver is a special flower. It will prefix tables with
     // PREFIX.TABLE, instead of the standard PREFIXTABLE.
     // @see \Drupal\sqlite\Driver\Database\sqlite\Connection::__construct()
-    $this->assertEquals('prefix.migrate_map_sql_idmap_test', $qualified_map_table);
+    $this->assertEquals('"prefix"."migrate_map_sql_idmap_test"', $qualified_map_table);
   }
 
   /**
diff --git a/core/modules/mysql/src/Driver/Database/mysql/Connection.php b/core/modules/mysql/src/Driver/Database/mysql/Connection.php
index bc4a360d4ae1d07450aa70df97ffdd85bf10409d..8748db1203ef39fffa5da3d80ec87661ba2bc2a0 100644
--- a/core/modules/mysql/src/Driver/Database/mysql/Connection.php
+++ b/core/modules/mysql/src/Driver/Database/mysql/Connection.php
@@ -60,11 +60,6 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn
    */
   const MIN_MAX_ALLOWED_PACKET = 1024;
 
-  /**
-   * {@inheritdoc}
-   */
-  protected $identifierQuotes = ['"', '"'];
-
   /**
    * {@inheritdoc}
    */
@@ -88,10 +83,15 @@ public function __construct(\PDO $connection, array $connection_options) {
       }
     }
 
-    if ($this->identifierQuotes === ['"', '"'] && !$is_ansi_quotes_mode) {
-      $this->identifierQuotes = ['`', '`'];
-    }
-    parent::__construct($connection, $connection_options);
+    // Manage the table prefix.
+    $connection_options['prefix'] = $connection_options['prefix'] ?? '';
+    assert(is_string($connection_options['prefix']), 'The \'prefix\' connection option to ' . __METHOD__ . '() must be a string.');
+
+    parent::__construct(
+      $connection,
+      $connection_options,
+      new IdentifierHandler($connection_options['prefix'], $is_ansi_quotes_mode ? ['"', '"'] : ['`', '`']),
+    );
   }
 
   /**
@@ -295,16 +295,11 @@ public function databaseType() {
   }
 
   /**
-   * Overrides \Drupal\Core\Database\Connection::createDatabase().
-   *
-   * @param string $database
-   *   The name of the database to create.
-   *
-   * @throws \Drupal\Core\Database\DatabaseNotFoundException
+   * {@inheritdoc}
    */
   public function createDatabase($database) {
     // Escape the database name.
-    $database = Database::getConnection()->escapeDatabase($database);
+    $database = Database::getConnection()->identifiers->schema($database)->forMachine();
 
     try {
       // Create the database and set it as active.
diff --git a/core/modules/mysql/src/Driver/Database/mysql/IdentifierHandler.php b/core/modules/mysql/src/Driver/Database/mysql/IdentifierHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..6bd320862c1122cdd5d85ab48dc855d761c36903
--- /dev/null
+++ b/core/modules/mysql/src/Driver/Database/mysql/IdentifierHandler.php
@@ -0,0 +1,76 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\mysql\Driver\Database\mysql;
+
+use Drupal\Core\Database\Exception\IdentifierException;
+use Drupal\Core\Database\Identifier\IdentifierHandlerBase;
+use Drupal\Core\Database\Identifier\IdentifierType;
+
+/**
+ * MySQL implementation of the identifier handler.
+ */
+class IdentifierHandler extends IdentifierHandlerBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getMaxLength(IdentifierType $type): int {
+    // @see https://dev.mysql.com/doc/refman/8.4/en/identifier-length.html
+    return match ($type) {
+      IdentifierType::Alias => 256,
+      default => 64,
+    };
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function validateCanonicalName(string $identifier, string $canonicalName, IdentifierType $type): true {
+    return match ($type) {
+      IdentifierType::Table => TRUE,
+      default => parent::validateCanonicalName($identifier, $canonicalName, $type),
+    };
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function parseTableIdentifier(string $identifier): array {
+    $parts = parent::parseTableIdentifier($identifier);
+    if ($parts['database']) {
+      throw new IdentifierException(sprintf(
+        'MySql does not support the syntax [database.][schema.]table for the table identifier \'%s\'. Avoid specifying the \'database\' part',
+        $identifier,
+      ));
+    }
+    return $parts;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function resolveTableForMachine(string $canonicalName, array $info): string {
+    if ($info['schema']) {
+      // We are processing a fully qualified table identifier, canonical name
+      // should not be processed, just checked it does not exceed max length.
+      if (strlen($canonicalName) > $this->getMaxLength(IdentifierType::Table)) {
+        throw new IdentifierException(sprintf(
+          'Table identifier \'%s\' exceeds maximum allowed length (%d)',
+          $canonicalName,
+          $this->getMaxLength(IdentifierType::Table),
+        ));
+      }
+      return $canonicalName;
+    }
+
+    $prefix = $info['needs_prefix'] ? $this->tablePrefix : '';
+    if (strlen($prefix . $canonicalName) > $this->getMaxLength(IdentifierType::Table)) {
+      // We shorten too long table names.
+      return $this->cropByHashing($canonicalName, $info, IdentifierType::Table, $prefix, $this->getMaxLength(IdentifierType::Table), 10);
+    }
+    return $prefix . $canonicalName;
+  }
+
+}
diff --git a/core/modules/mysql/src/Driver/Database/mysql/Schema.php b/core/modules/mysql/src/Driver/Database/mysql/Schema.php
index 85e71af18d1214521a0e91f4a2be32a3bcba9622..2cc75a078ab9074e82e2592a9446928e04ad32e4 100644
--- a/core/modules/mysql/src/Driver/Database/mysql/Schema.php
+++ b/core/modules/mysql/src/Driver/Database/mysql/Schema.php
@@ -51,7 +51,7 @@ class Schema extends DatabaseSchema {
    *   A keyed array with information about the database, table name and prefix.
    */
   protected function getPrefixInfo($table = 'default', $add_prefix = TRUE) {
-    $info = ['prefix' => $this->connection->getPrefix()];
+    $info = ['prefix' => $this->connection->identifiers->tablePrefix];
     if ($add_prefix) {
       $table = $info['prefix'] . $table;
     }
diff --git a/core/modules/mysql/tests/src/Kernel/mysql/Console/DbDumpCommandTest.php b/core/modules/mysql/tests/src/Kernel/mysql/Console/DbDumpCommandTest.php
index 66d781b5eb953ed8a224a60ea838f6b3145c9685..d6ab61ec886c6f1343b8f7d5808f2e3187e84f77 100644
--- a/core/modules/mysql/tests/src/Kernel/mysql/Console/DbDumpCommandTest.php
+++ b/core/modules/mysql/tests/src/Kernel/mysql/Console/DbDumpCommandTest.php
@@ -35,7 +35,7 @@ protected function setUp(): void {
 
     // Create a table with a field type not defined in
     // \Drupal\Core\Database\Schema::getFieldTypeMap.
-    $table_name = $connection->getPrefix() . 'foo';
+    $table_name = $connection->identifiers->tablePrefix . 'foo';
     $sql = "create table if not exists `$table_name` (`test` datetime NOT NULL);";
     $connection->query($sql)->execute();
   }
diff --git a/core/modules/mysql/tests/src/Kernel/mysql/MysqlDriverTest.php b/core/modules/mysql/tests/src/Kernel/mysql/MysqlDriverTest.php
index 4fcb75b60aa018bd4272ee39a11d82fa9a1342dc..48905dc0e6d6c4228df9748448bec73db67b1754 100644
--- a/core/modules/mysql/tests/src/Kernel/mysql/MysqlDriverTest.php
+++ b/core/modules/mysql/tests/src/Kernel/mysql/MysqlDriverTest.php
@@ -19,7 +19,7 @@ class MysqlDriverTest extends DriverSpecificKernelTestBase {
    * @covers \Drupal\mysql\Driver\Database\mysql\Connection
    */
   public function testConnection(): void {
-    $connection = new Connection($this->createMock(StubPDO::class), []);
+    $connection = new Connection($this->createMock(StubPDO::class), ['prefix' => '']);
     $this->assertInstanceOf(Connection::class, $connection);
   }
 
diff --git a/core/modules/mysql/tests/src/Unit/ConnectionTest.php b/core/modules/mysql/tests/src/Unit/ConnectionTest.php
index 8865c764e193b0b300c859e92cd6e838ec1f700f..a839c4c1f6e82f3ed55e83fccd7e0363da4839a4 100644
--- a/core/modules/mysql/tests/src/Unit/ConnectionTest.php
+++ b/core/modules/mysql/tests/src/Unit/ConnectionTest.php
@@ -68,8 +68,7 @@ private function createConnection(): Connection {
     return new class($pdo_connection) extends Connection {
 
       public function __construct(\PDO $connection) {
-        $this->connection = $connection;
-        $this->setPrefix('');
+        parent::__construct($connection, ['prefix' => '']);
       }
 
     };
diff --git a/core/modules/mysql/tests/src/Unit/IdentifierTest.php b/core/modules/mysql/tests/src/Unit/IdentifierTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..7efff85160245bcdfc1ff36b327333aea3c96cd8
--- /dev/null
+++ b/core/modules/mysql/tests/src/Unit/IdentifierTest.php
@@ -0,0 +1,199 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\mysql\Unit;
+
+use Drupal\Core\Database\Exception\IdentifierException;
+use Drupal\Core\Database\Identifier\IdentifierType;
+use Drupal\mysql\Driver\Database\mysql\IdentifierHandler;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * Tests MySQL database identifiers.
+ *
+ * @coversDefaultClass \Drupal\mysql\Driver\Database\mysql\IdentifierHandler
+ * @group Database
+ */
+class IdentifierTest extends UnitTestCase {
+
+  // cSpell:disable
+
+  /**
+   * Data provider for testTable.
+   *
+   * @return array
+   *   An associative array of test case data.
+   */
+  public static function providerTable(): array {
+    return [
+      'No prefix' => [
+        'identifier' => 'nocase',
+        'prefix' => '',
+        'expectedCanonical' => 'nocase',
+        'expectedMachine' => '"nocase"',
+      ],
+      'Prefix' => [
+        'identifier' => 'nocase',
+        'prefix' => 'foobar',
+        'expectedCanonical' => 'nocase',
+        'expectedMachine' => '"foobarnocase"',
+      ],
+      'No prefix, camelCase' => [
+        'identifier' => 'camelCase',
+        'prefix' => '',
+        'expectedCanonical' => 'camelCase',
+        'expectedMachine' => '"camelCase"',
+      ],
+      'Prefix, camelCase' => [
+        'identifier' => 'camelCase',
+        'prefix' => 'foobar',
+        'expectedCanonical' => 'camelCase',
+        'expectedMachine' => '"foobarcamelCase"',
+      ],
+      'No prefix, backtick' => [
+        'identifier' => '`backtick`',
+        'prefix' => '',
+        'expectedCanonical' => 'backtick',
+        'expectedMachine' => '"backtick"',
+      ],
+      'No prefix, brackets' => [
+        'identifier' => '[brackets]',
+        'prefix' => '',
+        'expectedCanonical' => 'brackets',
+        'expectedMachine' => '"brackets"',
+      ],
+      'No prefix, remove slash' => [
+        'identifier' => 'no/case',
+        'prefix' => '',
+        'expectedCanonical' => 'nocase',
+        'expectedMachine' => '"nocase"',
+      ],
+      'No prefix, remove quote' => [
+        'identifier' => 'no"case',
+        'prefix' => '',
+        'expectedCanonical' => 'nocase',
+        'expectedMachine' => '"nocase"',
+      ],
+      'No prefix, shortened machine name' => [
+        'identifier' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => '',
+        'expectedCanonical' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'expectedMachine' => '"VeryVeryVeryVeryHungryHungr25fa0c3753ngryHungryHungryCaterpillar"',
+      ],
+      'Prefix, shortened machine name' => [
+        'identifier' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => 'foobar',
+        'expectedCanonical' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'expectedMachine' => '"foobarVeryVeryVeryVeryHungryHu25fa0c3753yHungryHungryCaterpillar"',
+      ],
+      'Prefix one less than overflow, shortened machine name' => [
+        'identifier' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => 'prefix____123456789012345678901234567890123456789',
+        'expectedCanonical' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'expectedMachine' => '"prefix____123456789012345678901234567890123456789Ve25fa0c3753lar"',
+      ],
+      'Prefix just right, shortened machine name' => [
+        'identifier' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => 'prefix____1234567890123456789012345678901234567890',
+        'expectedCanonical' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'expectedMachine' => '"prefix____1234567890123456789012345678901234567890Ve25fa0c3753ar"',
+      ],
+      'Prefix one more than overflow, shortened machine name' => [
+        'identifier' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => 'prefix____12345678901234567890123456789012345678901',
+        'expectedCanonical' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'expectedMachine' => '',
+        'expectedException' => 'Table canonical identifier \'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' cannot be converted into a machine identifier; prefix \'prefix____12345678901234567890123456789012345678901\'',
+      ],
+      'Prefix too long to fit, table does not require shortening' => [
+        'identifier' => 'nocase',
+        'prefix' => 'VeryVeryVeryVeryVeryVeryVeryVeryLongLongLongLongLongLongLongPrefix',
+        'expectedCanonical' => 'nocase',
+        'expectedMachine' => '',
+        'expectedException' => 'Table canonical identifier \'nocase\' cannot be converted into a machine identifier; prefix \'VeryVeryVeryVeryVeryVeryVeryVeryLongLongLongLongLongLongLongPrefix\'',
+      ],
+      'Prefix too long to fit, table requires shortening' => [
+        'identifier' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => 'VeryVeryVeryVeryVeryVeryVeryVeryLongLongLongLongLongLongLongPrefix',
+        'expectedCanonical' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'expectedMachine' => '',
+        'expectedException' => 'Table canonical identifier \'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' cannot be converted into a machine identifier; prefix \'VeryVeryVeryVeryVeryVeryVeryVeryLongLongLongLongLongLongLongPrefix\'',
+      ],
+      // Sometimes, table names are following the pattern database.schema.table.
+      'Fully qualified - too many parts: 4' => [
+        'identifier' => 'Chowra.Teressa.Bompuka.Katchal',
+        'prefix' => '',
+        'expectedCanonical' => '',
+        'expectedMachine' => '',
+        'expectedException' => 'The table identifier \'Chowra.Teressa.Bompuka.Katchal\' does not comply with the syntax [database.][schema.]table',
+      ],
+      'Fully qualified - too many parts: MySql not supporting \'database\'' => [
+        'identifier' => 'Chowra.Teressa.Bompuka',
+        'prefix' => '',
+        'expectedCanonical' => '',
+        'expectedMachine' => '',
+        'expectedException' => 'MySql does not support the syntax [database.][schema.]table for the table identifier \'Chowra.Teressa.Bompuka\'. Avoid specifying the \'database\' part',
+      ],
+      'Fully qualified - no prefix' => [
+        'identifier' => '"Nancowry"."Tillangchong"',
+        'prefix' => '',
+        'expectedCanonical' => 'Nancowry.Tillangchong',
+        'expectedMachine' => '"Nancowry"."Tillangchong"',
+      ],
+      'Fully qualified - prefix' => [
+        'identifier' => '"Nancowry"."Tillangchong"',
+        'prefix' => 'foobar',
+        'expectedCanonical' => 'Nancowry.Tillangchong',
+        'expectedMachine' => '"Nancowry"."Tillangchong"',
+      ],
+      'Fully qualified - prefix not duplicated' => [
+        'identifier' => 'Nancowry.foobarTillangchong',
+        'prefix' => 'foobar',
+        'expectedCanonical' => 'Nancowry.foobarTillangchong',
+        'expectedMachine' => '"Nancowry"."foobarTillangchong"',
+      ],
+      'Fully qualified - remove not canonical characters' => [
+        'identifier' => '!Nancowry?.$Tillangchong%%%',
+        'prefix' => '',
+        'expectedCanonical' => 'Nancowry.Tillangchong',
+        'expectedMachine' => '"Nancowry"."Tillangchong"',
+      ],
+      'Fully qualified - invalid schema name length' => [
+        'identifier' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar.Trinket',
+        'prefix' => '',
+        'expectedCanonical' => '',
+        'expectedMachine' => '',
+        'expectedException' => 'The length of the schema identifier \'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' once canonicalized to \'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' is invalid (maximum allowed: 64)',
+      ],
+      'Fully qualified - invalid table name length' => [
+        'identifier' => 'Camorta.VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => '',
+        'expectedCanonical' => 'Camorta.VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'expectedMachine' => '',
+        'expectedException' => 'Table identifier \'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' exceeds maximum allowed length (64)',
+      ],
+    ];
+  }
+
+  // cSpell:enable
+
+  /**
+   * Tests table identifiers.
+   *
+   * @dataProvider providerTable
+   */
+  public function testTable(string $identifier, string $prefix = '', ?string $expectedCanonical = '', ?string $expectedMachine = '', ?string $expectedException = NULL): void {
+    $handler = new IdentifierHandler($prefix);
+    if ($expectedException) {
+      $this->expectException(IdentifierException::class);
+      $this->expectExceptionMessage($expectedException);
+    }
+    $this->assertSame($expectedCanonical, $handler->table($identifier)->canonical());
+    $this->assertSame($expectedMachine, $handler->table($identifier)->forMachine());
+    // The machine name includes the quote characters so we need to subtract
+    // those from the length.
+    $this->assertLessThanOrEqual($handler->getMaxLength(IdentifierType::Table), strlen($handler->table($identifier)->machineName), 'Invalid machine table length.');
+  }
+
+}
diff --git a/core/modules/pgsql/src/Driver/Database/pgsql/Connection.php b/core/modules/pgsql/src/Driver/Database/pgsql/Connection.php
index 40b2de75cf051b28830fbe92559f39ba2a93349f..a9d5d4e1e155455fbf2fc00a9f2f0f35a113b1ea 100644
--- a/core/modules/pgsql/src/Driver/Database/pgsql/Connection.php
+++ b/core/modules/pgsql/src/Driver/Database/pgsql/Connection.php
@@ -68,11 +68,6 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn
    */
   protected $transactionalDDLSupport = TRUE;
 
-  /**
-   * {@inheritdoc}
-   */
-  protected $identifierQuotes = ['"', '"'];
-
   /**
    * An array of transaction savepoints.
    *
@@ -92,17 +87,14 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn
    * Constructs a connection object.
    */
   public function __construct(\PDO $connection, array $connection_options) {
-    // Sanitize the schema name here, so we do not have to do it in other
-    // functions.
-    if (isset($connection_options['schema']) && ($connection_options['schema'] !== 'public')) {
-      $connection_options['schema'] = preg_replace('/[^A-Za-z0-9_]+/', '', $connection_options['schema']);
-    }
+    // Manage the table prefix.
+    $prefix = $connection_options['prefix'] ?? '';
+    assert(is_string($prefix), 'The \'prefix\' connection option to ' . __METHOD__ . '() must be a string.');
 
-    // We need to set the connectionOptions before the parent, because setPrefix
-    // needs this.
-    $this->connectionOptions = $connection_options;
+    // Manage the schema name.
+    $defaultSchema = $connection_options['schema'] ?? '';
 
-    parent::__construct($connection, $connection_options);
+    parent::__construct($connection, $connection_options, new IdentifierHandler($prefix, $defaultSchema));
 
     // Force PostgreSQL to use the UTF-8 character set by default.
     $this->connection->exec("SET NAMES 'UTF8'");
@@ -113,26 +105,6 @@ public function __construct(\PDO $connection, array $connection_options) {
     }
   }
 
-  /**
-   * {@inheritdoc}
-   */
-  protected function setPrefix($prefix) {
-    assert(is_string($prefix), 'The \'$prefix\' argument to ' . __METHOD__ . '() must be a string');
-    $this->prefix = $prefix;
-
-    // Add the schema name if it is not set to public, otherwise it will use the
-    // default schema name.
-    $quoted_schema = '';
-    if (isset($this->connectionOptions['schema']) && ($this->connectionOptions['schema'] !== 'public')) {
-      $quoted_schema = $this->identifierQuotes[0] . $this->connectionOptions['schema'] . $this->identifierQuotes[1] . '.';
-    }
-
-    $this->tablePlaceholderReplacements = [
-      $quoted_schema . $this->identifierQuotes[0] . str_replace('.', $this->identifierQuotes[1] . '.' . $this->identifierQuotes[0], $prefix),
-      $this->identifierQuotes[1],
-    ];
-  }
-
   /**
    * {@inheritdoc}
    */
@@ -287,16 +259,11 @@ public function databaseType() {
   }
 
   /**
-   * Overrides \Drupal\Core\Database\Connection::createDatabase().
-   *
-   * @param string $database
-   *   The name of the database to create.
-   *
-   * @throws \Drupal\Core\Database\DatabaseNotFoundException
+   * {@inheritdoc}
    */
   public function createDatabase($database) {
     // Escape the database name.
-    $database = Database::getConnection()->escapeDatabase($database);
+    $database = Database::getConnection()->identifiers->database($database)->forMachine();
     $db_created = FALSE;
 
     // Try to determine the proper locales for character classification and
@@ -354,19 +321,25 @@ public function makeSequenceName($table, $field) {
     $sequence_name = $this->prefixTables('{' . $table . '}_' . $field . '_seq');
     // Remove identifier quotes as we are constructing a new name from a
     // prefixed and quoted table name.
-    return str_replace($this->identifierQuotes, '', $sequence_name);
+    return str_replace($this->identifiers->identifierQuotes, '', $sequence_name);
   }
 
   /**
    * {@inheritdoc}
    */
   public function getFullQualifiedTableName($table) {
+    $tableIdentifier = $this->identifiers->table($table);
+    // If already fully qualified, just pass it on.
+    if ($tableIdentifier->database || $tableIdentifier->schema) {
+      return $tableIdentifier->forMachine();
+    }
+    // The fully qualified table name in PostgreSQL is in the form of
+    // "<database>"."<schema>"."<table>".
     $options = $this->getConnectionOptions();
     $schema = $options['schema'] ?? 'public';
-
-    // The fully qualified table name in PostgreSQL is in the form of
-    // <database>.<schema>.<table>.
-    return $options['database'] . '.' . $schema . '.' . $this->getPrefix() . $table;
+    return $this->identifiers->database($options['database'])->quotedMachineName . '.' .
+      $this->identifiers->schema($schema)->quotedMachineName . '.' .
+      $tableIdentifier->quotedMachineName;
   }
 
   /**
diff --git a/core/modules/pgsql/src/Driver/Database/pgsql/IdentifierHandler.php b/core/modules/pgsql/src/Driver/Database/pgsql/IdentifierHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..441087406833fc8578a6011ab2c4b0c33fcde091
--- /dev/null
+++ b/core/modules/pgsql/src/Driver/Database/pgsql/IdentifierHandler.php
@@ -0,0 +1,104 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\pgsql\Driver\Database\pgsql;
+
+use Drupal\Core\Database\Exception\IdentifierException;
+use Drupal\Core\Database\Identifier\IdentifierHandlerBase;
+use Drupal\Core\Database\Identifier\IdentifierType;
+use Drupal\Core\Database\Identifier\Schema;
+
+/**
+ * PostgreSql implementation of the identifier handler.
+ */
+class IdentifierHandler extends IdentifierHandlerBase {
+
+  /**
+   * The default schema identifier, if specified.
+   */
+  public readonly ?Schema $defaultSchema;
+
+  /**
+   * Constructor.
+   *
+   * @param string $tablePrefix
+   *   The table prefix to be used by the database connection.
+   * @param string $defaultSchema
+   *   The default schema to use for database operations. If not specified,
+   *   it's assumed to use the 'public' schema.
+   * @param array{0:string, 1:string} $identifierQuotes
+   *   The identifier quote characters.
+   */
+  public function __construct(
+    string $tablePrefix,
+    string $defaultSchema = '',
+    array $identifierQuotes = ['"', '"'],
+  ) {
+    parent::__construct($tablePrefix, $identifierQuotes);
+    if ($defaultSchema !== '' && $defaultSchema !== 'public') {
+      $this->defaultSchema = $this->schema($defaultSchema);
+    }
+    else {
+      $this->defaultSchema = NULL;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getMaxLength(IdentifierType $type): int {
+    // @see https://www.postgresql.org/docs/current/limits.html
+    return 63;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function validateCanonicalName(string $identifier, string $canonicalName, IdentifierType $type): true {
+    return match ($type) {
+      IdentifierType::Table => TRUE,
+      default => parent::validateCanonicalName($identifier, $canonicalName, $type),
+    };
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function resolveTableForMachine(string $canonicalName, array $info): string {
+    if ($info['schema'] && ($info['schema_default_added'] ?? FALSE) === FALSE) {
+      // We are processing a fully qualified table identifier, canonical name
+      // should not be processed, just checked it does not exceed max length.
+      if (strlen($canonicalName) > $this->getMaxLength(IdentifierType::Table)) {
+        throw new IdentifierException(sprintf(
+          'Table identifier \'%s\' exceeds maximum allowed length (%d)',
+          $canonicalName,
+          $this->getMaxLength(IdentifierType::Table),
+        ));
+      }
+      return $canonicalName;
+    }
+
+    $prefix = $info['needs_prefix'] ? $this->tablePrefix : '';
+    if (strlen($prefix . $canonicalName) > $this->getMaxLength(IdentifierType::Table)) {
+      // We shorten too long table names.
+      return $this->cropByHashing($canonicalName, $info, IdentifierType::Table, $prefix, $this->getMaxLength(IdentifierType::Table), 10);
+    }
+    return $prefix . $canonicalName;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function parseTableIdentifier(string $identifier): array {
+    $parts = parent::parseTableIdentifier($identifier);
+    if (isset($this->defaultSchema) && $parts['schema'] === NULL) {
+      // When a non-public schema is defined for the connection, machine names
+      // for table identifiers should be in the "schema"."table" format.
+      $parts['schema'] = $this->defaultSchema;
+      $parts['schema_default_added'] = TRUE;
+    }
+    return $parts;
+  }
+
+}
diff --git a/core/modules/pgsql/src/Driver/Database/pgsql/Schema.php b/core/modules/pgsql/src/Driver/Database/pgsql/Schema.php
index a4585e15da7abcb1e1bc5c3f251c2c223fd86905..3b992977cce0198ce260b0a8f5de24c4158b326a 100644
--- a/core/modules/pgsql/src/Driver/Database/pgsql/Schema.php
+++ b/core/modules/pgsql/src/Driver/Database/pgsql/Schema.php
@@ -128,7 +128,7 @@ protected function ensureIdentifiersLength($table_identifier_part, $column_ident
    */
   public function queryTableInformation($table) {
     // Generate a key to reference this table's information on.
-    $prefixed_table = $this->connection->getPrefix() . $table;
+    $prefixed_table = $this->connection->identifiers->tablePrefix . $table;
     $key = $this->connection->prefixTables('{' . $table . '}');
 
     // Take into account that temporary tables are stored in a different schema.
@@ -220,7 +220,7 @@ protected function getTempNamespaceName() {
    *   The non-prefixed name of the table.
    */
   protected function resetTableInformation($table) {
-    $key = $this->defaultSchema . '.' . $this->connection->getPrefix() . $table;
+    $key = $this->defaultSchema . '.' . $this->connection->identifiers->tablePrefix . $table;
     unset($this->tableInformation[$key]);
   }
 
@@ -521,7 +521,7 @@ public function tableExists($table, $add_prefix = TRUE) {
    * {@inheritdoc}
    */
   public function findTables($table_expression) {
-    $prefix = $this->connection->getPrefix();
+    $prefix = $this->connection->identifiers->tablePrefix;
     $prefix_length = strlen($prefix);
     $tables = [];
 
@@ -567,7 +567,7 @@ public function renameTable($table, $new_name) {
     }
 
     // Get the schema and tablename for the old table.
-    $table_name = $this->connection->getPrefix() . $table;
+    $table_name = $this->connection->identifiers->tablePrefix . $table;
     // Index names and constraint names are global in PostgreSQL, so we need to
     // rename them when renaming the table.
     $indexes = $this->connection->query('SELECT indexname FROM pg_indexes WHERE schemaname = :schema AND tablename = :table', [':schema' => $this->defaultSchema, ':table' => $table_name]);
@@ -734,7 +734,7 @@ public function indexExists($table, $name) {
 
     $sql_params = [
       ':schema' => $this->defaultSchema,
-      ':table' => $this->connection->getPrefix() . $table,
+      ':table' => $this->connection->identifiers->tablePrefix . $table,
       ':index' => $index_name,
     ];
     return (bool) $this->connection->query("SELECT 1 FROM pg_indexes WHERE schemaname = :schema AND tablename = :table AND indexname = :index", $sql_params)->fetchField();
@@ -1118,7 +1118,7 @@ public function extensionExists($name): bool {
   protected function getSequenceName(string $table, string $column): ?string {
     return $this->connection
       ->query("SELECT pg_get_serial_sequence(:table, :column)", [
-        ':table' => $this->defaultSchema . '.' . $this->connection->getPrefix() . $table,
+        ':table' => $this->defaultSchema . '.' . $this->connection->identifiers->tablePrefix . $table,
         ':column' => $column,
       ])
       ->fetchField();
diff --git a/core/modules/pgsql/tests/src/Kernel/pgsql/NonPublicSchemaTest.php b/core/modules/pgsql/tests/src/Kernel/pgsql/NonPublicSchemaTest.php
index 2f7386b4306be165d688324456d3519e188a24fb..dbb8babe612fc75cc0e1eccf0dc63faf8061cd67 100644
--- a/core/modules/pgsql/tests/src/Kernel/pgsql/NonPublicSchemaTest.php
+++ b/core/modules/pgsql/tests/src/Kernel/pgsql/NonPublicSchemaTest.php
@@ -118,7 +118,7 @@ public function testExtensionExists(): void {
     $this->assertTrue($this->testingFakeConnection->schema()->tableExists('faking_table'));
 
     // Hardcoded assertion that we created the table in the non-public schema.
-    $this->assertCount(1, $this->testingFakeConnection->query("SELECT * FROM pg_tables WHERE schemaname = 'testing_fake' AND tablename = :prefixedTable", [':prefixedTable' => $this->testingFakeConnection->getPrefix() . "faking_table"])->fetchAll());
+    $this->assertCount(1, $this->testingFakeConnection->query("SELECT * FROM pg_tables WHERE schemaname = 'testing_fake' AND tablename = :prefixedTable", [':prefixedTable' => $this->testingFakeConnection->identifiers->tablePrefix . "faking_table"])->fetchAll());
   }
 
   /**
@@ -288,11 +288,11 @@ public function testIndex(): void {
 
     $this->assertTrue($this->testingFakeConnection->schema()->indexExists('faking_table', 'test_field'));
 
-    $results = $this->testingFakeConnection->query("SELECT * FROM pg_indexes WHERE indexname = :indexname", [':indexname' => $this->testingFakeConnection->getPrefix() . 'faking_table__test_field__idx'])->fetchAll();
+    $results = $this->testingFakeConnection->query("SELECT * FROM pg_indexes WHERE indexname = :indexname", [':indexname' => $this->testingFakeConnection->identifiers->tablePrefix . 'faking_table__test_field__idx'])->fetchAll();
 
     $this->assertCount(1, $results);
     $this->assertSame('testing_fake', $results[0]->schemaname);
-    $this->assertSame($this->testingFakeConnection->getPrefix() . 'faking_table', $results[0]->tablename);
+    $this->assertSame($this->testingFakeConnection->identifiers->tablePrefix . 'faking_table', $results[0]->tablename);
     $this->assertStringContainsString('USING btree (test_field)', $results[0]->indexdef);
 
     $this->testingFakeConnection->schema()->dropIndex('faking_table', 'test_field');
@@ -315,12 +315,12 @@ public function testUniqueKey(): void {
     // phpcs:ignore
     // $this->assertTrue($this->testingFakeConnection->schema()->indexExists('faking_table', 'test_field'));
 
-    $results = $this->testingFakeConnection->query("SELECT * FROM pg_indexes WHERE indexname = :indexname", [':indexname' => $this->testingFakeConnection->getPrefix() . 'faking_table__test_field__key'])->fetchAll();
+    $results = $this->testingFakeConnection->query("SELECT * FROM pg_indexes WHERE indexname = :indexname", [':indexname' => $this->testingFakeConnection->identifiers->tablePrefix . 'faking_table__test_field__key'])->fetchAll();
 
     // Check the unique key columns.
     $this->assertCount(1, $results);
     $this->assertSame('testing_fake', $results[0]->schemaname);
-    $this->assertSame($this->testingFakeConnection->getPrefix() . 'faking_table', $results[0]->tablename);
+    $this->assertSame($this->testingFakeConnection->identifiers->tablePrefix . 'faking_table', $results[0]->tablename);
     $this->assertStringContainsString('USING btree (test_field)', $results[0]->indexdef);
 
     $this->testingFakeConnection->schema()->dropUniqueKey('faking_table', 'test_field');
@@ -348,7 +348,7 @@ public function testPrimaryKey(): void {
 
     $this->assertCount(1, $results);
     $this->assertSame('testing_fake', $results[0]->schemaname);
-    $this->assertSame($this->testingFakeConnection->getPrefix() . 'faking_table', $results[0]->tablename);
+    $this->assertSame($this->testingFakeConnection->identifiers->tablePrefix . 'faking_table', $results[0]->tablename);
     $this->assertStringContainsString('USING btree (id)', $results[0]->indexdef);
 
     $find_primary_keys_columns = new \ReflectionMethod(get_class($this->testingFakeConnection->schema()), 'findPrimaryKeyColumns');
@@ -371,7 +371,7 @@ public function testTable(): void {
     $result = $this->testingFakeConnection->query("SELECT * FROM information_schema.tables WHERE table_schema = 'testing_fake'")->fetchAll();
     $this->assertFalse($this->testingFakeConnection->schema()->tableExists('faking_table'));
     $this->assertTrue($this->testingFakeConnection->schema()->tableExists('new_faking_table'));
-    $this->assertEquals($this->testingFakeConnection->getPrefix() . 'new_faking_table', $result[0]->table_name);
+    $this->assertEquals($this->testingFakeConnection->identifiers->tablePrefix . 'new_faking_table', $result[0]->table_name);
     $this->assertEquals('testing_fake', $result[0]->table_schema);
     sort($tables);
     $this->assertEquals(['new_faking_table'], $tables);
diff --git a/core/modules/pgsql/tests/src/Kernel/pgsql/SchemaTest.php b/core/modules/pgsql/tests/src/Kernel/pgsql/SchemaTest.php
index e10ed33d1903613076e3631c591fce991313af80..e26e298bd74015f072a1fab40e649be9da3052b7 100644
--- a/core/modules/pgsql/tests/src/Kernel/pgsql/SchemaTest.php
+++ b/core/modules/pgsql/tests/src/Kernel/pgsql/SchemaTest.php
@@ -281,18 +281,18 @@ public function testPgsqlSequences(): void {
     // Retrieves a sequence name that is owned by the table and column.
     $sequence_name = $this->connection
       ->query("SELECT pg_get_serial_sequence(:table, :column)", [
-        ':table' => $this->connection->getPrefix() . 'sequence_test',
+        ':table' => $this->connection->identifiers->tablePrefix . 'sequence_test',
         ':column' => 'uid',
       ])
       ->fetchField();
 
     $schema = $this->connection->getConnectionOptions()['schema'] ?? 'public';
-    $this->assertEquals($schema . '.' . $this->connection->getPrefix() . 'sequence_test_uid_seq', $sequence_name);
+    $this->assertEquals($schema . '.' . $this->connection->identifiers->tablePrefix . 'sequence_test_uid_seq', $sequence_name);
 
     // Checks if the sequence exists.
     $this->assertTrue((bool) \Drupal::database()
       ->query("SELECT c.relname FROM pg_class as c WHERE c.relkind = 'S' AND c.relname = :name", [
-        ':name' => $this->connection->getPrefix() . 'sequence_test_uid_seq',
+        ':name' => $this->connection->identifiers->tablePrefix . 'sequence_test_uid_seq',
       ])
       ->fetchField());
 
@@ -304,7 +304,7 @@ public function testPgsqlSequences(): void {
       AND d.refobjsubid > 0
       AND d.classid = 'pg_class'::regclass", [':seq_name' => $sequence_name])->fetchObject();
 
-    $this->assertEquals($this->connection->getPrefix() . 'sequence_test', $sequence_owner->table_name);
+    $this->assertEquals($this->connection->identifiers->tablePrefix . 'sequence_test', $sequence_owner->table_name);
     $this->assertEquals('uid', $sequence_owner->field_name, 'New sequence is owned by its table.');
 
   }
@@ -323,7 +323,7 @@ public function testTableExists(): void {
       ],
     ];
     $this->schema->createTable($table_name, $table_specification);
-    $prefixed_table_name = $this->connection->getPrefix($table_name) . $table_name;
+    $prefixed_table_name = $this->connection->identifiers->tablePrefix . $table_name;
 
     // Three different calls to the method Schema::tableExists() with an
     // unprefixed table name.
diff --git a/core/modules/pgsql/tests/src/Unit/IdentifierTest.php b/core/modules/pgsql/tests/src/Unit/IdentifierTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9f6e48aeb907dce6b7354762b7f467ba98666aab
--- /dev/null
+++ b/core/modules/pgsql/tests/src/Unit/IdentifierTest.php
@@ -0,0 +1,197 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\pgsql\Unit;
+
+use Drupal\Core\Database\Exception\IdentifierException;
+use Drupal\Core\Database\Identifier\IdentifierType;
+use Drupal\pgsql\Driver\Database\pgsql\IdentifierHandler;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * Tests PostgreSql database identifiers.
+ *
+ * @coversDefaultClass \Drupal\pgsql\Driver\Database\pgsql\IdentifierHandler
+ * @group Database
+ */
+class IdentifierTest extends UnitTestCase {
+
+  // cSpell:disable
+
+  /**
+   * Data provider for testTable.
+   *
+   * @return array
+   *   An associative array of test case data.
+   */
+  public static function providerTable(): array {
+    return [
+      'No prefix' => [
+        'identifier' => 'nocase',
+        'prefix' => '',
+        'expectedCanonical' => 'nocase',
+        'expectedMachine' => '"nocase"',
+      ],
+      'Prefix' => [
+        'identifier' => 'nocase',
+        'prefix' => 'foobar',
+        'expectedCanonical' => 'nocase',
+        'expectedMachine' => '"foobarnocase"',
+      ],
+      'No prefix, camelCase' => [
+        'identifier' => 'camelCase',
+        'prefix' => '',
+        'expectedCanonical' => 'camelCase',
+        'expectedMachine' => '"camelCase"',
+      ],
+      'Prefix, camelCase' => [
+        'identifier' => 'camelCase',
+        'prefix' => 'foobar',
+        'expectedCanonical' => 'camelCase',
+        'expectedMachine' => '"foobarcamelCase"',
+      ],
+      'No prefix, backtick' => [
+        'identifier' => '`backtick`',
+        'prefix' => '',
+        'expectedCanonical' => 'backtick',
+        'expectedMachine' => '"backtick"',
+      ],
+      'No prefix, brackets' => [
+        'identifier' => '[brackets]',
+        'prefix' => '',
+        'expectedCanonical' => 'brackets',
+        'expectedMachine' => '"brackets"',
+      ],
+      'No prefix, remove slash' => [
+        'identifier' => 'no/case',
+        'prefix' => '',
+        'expectedCanonical' => 'nocase',
+        'expectedMachine' => '"nocase"',
+      ],
+      'No prefix, remove quote' => [
+        'identifier' => 'no"case',
+        'prefix' => '',
+        'expectedCanonical' => 'nocase',
+        'expectedMachine' => '"nocase"',
+      ],
+      'No prefix, shortened machine name' => [
+        'identifier' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => '',
+        'expectedCanonical' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'expectedMachine' => '"VeryVeryVeryVeryHungryHung25fa0c3753ngryHungryHungryCaterpillar"',
+      ],
+      'Prefix, shortened machine name' => [
+        'identifier' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => 'foobar',
+        'expectedCanonical' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'expectedMachine' => '"foobarVeryVeryVeryVeryHungryH25fa0c3753yHungryHungryCaterpillar"',
+      ],
+      'Prefix one less than overflow, shortened machine name' => [
+        'identifier' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => 'prefix____12345678901234567890123456789012345678',
+        'expectedCanonical' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'expectedMachine' => '"prefix____12345678901234567890123456789012345678Ve25fa0c3753lar"',
+      ],
+      'Prefix just right, shortened machine name' => [
+        'identifier' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => 'prefix____123456789012345678901234567890123456789',
+        'expectedCanonical' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'expectedMachine' => '"prefix____123456789012345678901234567890123456789Ve25fa0c3753ar"',
+      ],
+      'Prefix one more than overflow, shortened machine name' => [
+        'identifier' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => 'prefix____1234567890123456789012345678901234567890',
+        'expectedCanonical' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'expectedMachine' => '',
+        'expectedException' => 'Table canonical identifier \'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' cannot be converted into a machine identifier; prefix \'prefix____1234567890123456789012345678901234567890\'',
+      ],
+      'Prefix too long to fit, table does not require shortening' => [
+        'identifier' => 'nocase',
+        'prefix' => 'VeryVeryVeryVeryVeryVeryVeryVeryLongLongLongLongLongLongLongPrefix',
+        'expectedCanonical' => 'nocase',
+        'expectedMachine' => '',
+        'expectedException' => 'Table canonical identifier \'nocase\' cannot be converted into a machine identifier; prefix \'VeryVeryVeryVeryVeryVeryVeryVeryLongLongLongLongLongLongLongPrefix\'',
+      ],
+      'Prefix too long to fit, table requires shortening' => [
+        'identifier' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => 'VeryVeryVeryVeryVeryVeryVeryVeryLongLongLongLongLongLongLongPrefix',
+        'expectedCanonical' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'expectedMachine' => '',
+        'expectedException' => 'Table canonical identifier \'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' cannot be converted into a machine identifier; prefix \'VeryVeryVeryVeryVeryVeryVeryVeryLongLongLongLongLongLongLongPrefix\'',
+      ],
+      // Sometimes, table names are following the pattern database.schema.table.
+      'Fully qualified - too many parts: 4' => [
+        'identifier' => 'Chowra.Teressa.Bompuka.Katchal',
+        'prefix' => '',
+        'expectedCanonical' => '',
+        'expectedMachine' => '',
+        'expectedException' => 'The table identifier \'Chowra.Teressa.Bompuka.Katchal\' does not comply with the syntax [database.][schema.]table',
+      ],
+      'Fully qualified - no prefix' => [
+        'identifier' => '"Laouk"."Nancowry"."Tillangchong"',
+        'prefix' => '',
+        'expectedCanonical' => 'Laouk.Nancowry.Tillangchong',
+        'expectedMachine' => '"Laouk"."Nancowry"."Tillangchong"',
+      ],
+      'Fully qualified - prefix' => [
+        'identifier' => '"Laouk"."Nancowry"."Tillangchong"',
+        'prefix' => 'foobar',
+        'expectedCanonical' => 'Laouk.Nancowry.Tillangchong',
+        'expectedMachine' => '"Laouk"."Nancowry"."Tillangchong"',
+      ],
+      'Fully qualified - prefix not duplicated' => [
+        'identifier' => 'Laouk.Nancowry.foobarTillangchong',
+        'prefix' => 'foobar',
+        'expectedCanonical' => 'Laouk.Nancowry.foobarTillangchong',
+        'expectedMachine' => '"Laouk"."Nancowry"."foobarTillangchong"',
+      ],
+      'Fully qualified - remove not canonical characters' => [
+        'identifier' => '&Laouk£.!Nancowry?.$Tillangchong%%%',
+        'prefix' => '',
+        'expectedCanonical' => 'Laouk.Nancowry.Tillangchong',
+        'expectedMachine' => '"Laouk"."Nancowry"."Tillangchong"',
+      ],
+      'Fully qualified - invalid database name length' => [
+        'identifier' => 'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar.Laouk.Trinket',
+        'prefix' => '',
+        'expectedCanonical' => '',
+        'expectedMachine' => '',
+        'expectedException' => 'The length of the database identifier \'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' once canonicalized to \'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' is invalid (maximum allowed: 63)',
+      ],
+      'Fully qualified - invalid schema name length' => [
+        'identifier' => 'Laouk.VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar.Trinket',
+        'prefix' => '',
+        'expectedCanonical' => '',
+        'expectedMachine' => '',
+        'expectedException' => 'The length of the schema identifier \'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' once canonicalized to \'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' is invalid (maximum allowed: 63)',
+      ],
+      'Fully qualified - invalid table name length' => [
+        'identifier' => 'Laouk.Camorta.VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => '',
+        'expectedCanonical' => 'Laouk.Camorta.VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'expectedMachine' => '',
+        'expectedException' => 'Table identifier \'VeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' exceeds maximum allowed length (63)',
+      ],
+    ];
+  }
+
+  // cSpell:enable
+
+  /**
+   * @dataProvider providerTable
+   */
+  public function testTable(string $identifier, string $prefix = '', ?string $expectedCanonical = '', ?string $expectedMachine = '', ?string $expectedException = NULL): void {
+    $handler = new IdentifierHandler($prefix);
+    if ($expectedException) {
+      $this->expectException(IdentifierException::class);
+      $this->expectExceptionMessage($expectedException);
+    }
+    $this->assertSame($expectedCanonical, $handler->table($identifier)->canonical());
+    $this->assertSame($expectedMachine, $handler->table($identifier)->forMachine());
+    // The machine name includes the quote characters so we need to subtract
+    // those from the length.
+    $this->assertLessThanOrEqual($handler->getMaxLength(IdentifierType::Table), strlen($handler->table($identifier)->machineName), 'Invalid machine table length.');
+  }
+
+}
diff --git a/core/modules/pgsql/tests/src/Unit/SchemaTest.php b/core/modules/pgsql/tests/src/Unit/SchemaTest.php
index 4a554a014de3c990c69c755076b108d1ea0adfcd..d78688b6b482cc712f848a66eaee6e0c72a513a0 100644
--- a/core/modules/pgsql/tests/src/Unit/SchemaTest.php
+++ b/core/modules/pgsql/tests/src/Unit/SchemaTest.php
@@ -32,7 +32,6 @@ public function testComputedConstraintName($table_name, $name, $expected): void
 
     $connection = $this->prophesize('\Drupal\pgsql\Driver\Database\pgsql\Connection');
     $connection->getConnectionOptions()->willReturn([]);
-    $connection->getPrefix()->willReturn('');
 
     $statement = $this->prophesize('\Drupal\Core\Database\StatementInterface');
     $statement->fetchField()->willReturn($max_identifier_length);
diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php b/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php
index 0a353dceed05ce51d151de60f99ed24ac0e7f1ac..85c7473c04637d683056789a7b84e3060c47c10f 100644
--- a/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php
+++ b/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php
@@ -67,28 +67,23 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn
    */
   protected $transactionalDDLSupport = TRUE;
 
-  /**
-   * {@inheritdoc}
-   */
-  protected $identifierQuotes = ['"', '"'];
-
   /**
    * Constructs a \Drupal\sqlite\Driver\Database\sqlite\Connection object.
    */
   public function __construct(\PDO $connection, array $connection_options) {
-    parent::__construct($connection, $connection_options);
-
     // Empty prefix means query the main database -- no need to attach anything.
-    $prefix = $this->connectionOptions['prefix'] ?? '';
+    $prefix = $connection_options['prefix'] ?? '';
+    assert(is_string($prefix), 'The \'prefix\' connection option to ' . __METHOD__ . '() must be a string.');
     if ($prefix !== '') {
-      $this->attachDatabase($prefix);
+      $attachedDatabaseName = $prefix;
       // Add a ., so queries become prefix.table, which is proper syntax for
       // querying an attached database.
       $prefix .= '.';
     }
-
-    // Regenerate the prefix.
-    $this->setPrefix($prefix);
+    parent::__construct($connection, $connection_options, new IdentifierHandler($prefix));
+    if (isset($attachedDatabaseName)) {
+      $this->attachDatabase($attachedDatabaseName);
+    }
   }
 
   /**
@@ -387,12 +382,7 @@ public function databaseType() {
   }
 
   /**
-   * Overrides \Drupal\Core\Database\Connection::createDatabase().
-   *
-   * @param string $database
-   *   The name of the database to create.
-   *
-   * @throws \Drupal\Core\Database\DatabaseNotFoundException
+   * {@inheritdoc}
    */
   public function createDatabase($database) {
     // Verify the database is writable.
@@ -432,10 +422,8 @@ public function prepareStatement(string $query, array $options, bool $allow_row_
    * {@inheritdoc}
    */
   public function getFullQualifiedTableName($table) {
-    $prefix = $this->getPrefix();
-
     // Don't include the SQLite database file name as part of the table name.
-    return $prefix . $table;
+    return $this->identifiers->table($table)->forMachine();
   }
 
   /**
diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/IdentifierHandler.php b/core/modules/sqlite/src/Driver/Database/sqlite/IdentifierHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..dca9b770b91d9f7254ae46563d3b7c1e2ba420ff
--- /dev/null
+++ b/core/modules/sqlite/src/Driver/Database/sqlite/IdentifierHandler.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\sqlite\Driver\Database\sqlite;
+
+use Drupal\Core\Database\Exception\IdentifierException;
+use Drupal\Core\Database\Identifier\IdentifierHandlerBase;
+use Drupal\Core\Database\Identifier\IdentifierType;
+
+/**
+ * SQLite implementation of the identifier handler.
+ */
+class IdentifierHandler extends IdentifierHandlerBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getMaxLength(IdentifierType $type): int {
+    // There is no hard limit on identifier length in SQLite, so we just use
+    // common sense: identifiers longer than 128 characters are hardly
+    // readable.
+    // @see https://www.sqlite.org/limits.html
+    // @see https://stackoverflow.com/questions/8135013/table-name-limit-in-sqlite-android
+    return 128;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function parseTableIdentifier(string $identifier): array {
+    $parts = parent::parseTableIdentifier($identifier);
+    if ($parts['database']) {
+      throw new IdentifierException(sprintf(
+        'SQLite does not support the syntax [database.][schema.]table for the table identifier \'%s\'. Avoid specifying the \'database\' part',
+        $identifier,
+      ));
+    }
+    if ($this->tablePrefix !== '' && $parts['schema'] === NULL) {
+      $parts['schema'] = $this->schema(rtrim($this->tablePrefix, '.'));
+      $parts['needs_prefix'] = FALSE;
+    }
+    return $parts;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function resolveTableForMachine(string $canonicalName, array $info): string {
+    return $canonicalName;
+  }
+
+}
diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/Schema.php b/core/modules/sqlite/src/Driver/Database/sqlite/Schema.php
index 3779e7625195c342548095a4aa305be5a73292fc..2f091e316a8c8be85adc1b514f5e04ee42ec3102 100644
--- a/core/modules/sqlite/src/Driver/Database/sqlite/Schema.php
+++ b/core/modules/sqlite/src/Driver/Database/sqlite/Schema.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\sqlite\Driver\Database\sqlite;
 
+use Drupal\Core\Database\Identifier\Table as TableIdentifier;
 use Drupal\Core\Database\SchemaObjectExistsException;
 use Drupal\Core\Database\SchemaObjectDoesNotExistException;
 use Drupal\Core\Database\Schema as DatabaseSchema;
@@ -29,16 +30,30 @@ class Schema extends DatabaseSchema {
    * {@inheritdoc}
    */
   public function tableExists($table, $add_prefix = TRUE) {
-    $info = $this->getPrefixInfo($table, $add_prefix);
+    if (!$table instanceof TableIdentifier) {
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
+    $schema = $table->schema ?? $this->defaultSchema;
 
-    // Don't use {} around sqlite_master table.
-    return (bool) $this->connection->query('SELECT 1 FROM [' . $info['schema'] . '].sqlite_master WHERE type = :type AND name = :name', [':type' => 'table', ':name' => $info['table']])->fetchField();
+    $sql = sprintf(
+      'SELECT 1 FROM %s WHERE type = :type AND name = :name',
+      $this->connection->identifiers->table($schema . '.sqlite_master'),
+    );
+
+    return (bool) $this->connection->query($sql, [':type' => 'table', ':name' => $table->machineName])->fetchField();
   }
 
   /**
    * {@inheritdoc}
    */
   public function fieldExists($table, $column) {
+    if (!$table instanceof TableIdentifier) {
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     $schema = $this->introspectSchema($table);
     return !empty($schema['fields'][$column]);
   }
@@ -47,12 +62,17 @@ public function fieldExists($table, $column) {
    * {@inheritdoc}
    */
   public function createTableSql($name, $table) {
+    if (!$name instanceof TableIdentifier) {
+      $name = $this->connection->identifiers->table($name);
+    }
+    assert($name instanceof TableIdentifier);
+
     if (!empty($table['primary key']) && is_array($table['primary key'])) {
       $this->ensureNotNullPrimaryKey($table['primary key'], $table['fields']);
     }
 
     $sql = [];
-    $sql[] = "CREATE TABLE {" . $name . "} (\n" . $this->createColumnsSql($name, $table) . "\n)\n";
+    $sql[] = "CREATE TABLE " . $name->forMachine() . " (\n" . $this->createColumnsSql($name, $table) . "\n)\n";
     return array_merge($sql, $this->createIndexSql($name, $table));
   }
 
@@ -60,16 +80,41 @@ public function createTableSql($name, $table) {
    * Build the SQL expression for indexes.
    */
   protected function createIndexSql($tablename, $schema) {
+    if (!$tablename instanceof TableIdentifier) {
+      @trigger_error("Passing a table identifier as a string to " . __METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Pass a Table identifier value object instead. See https://www.drupal.org/node/7654132", E_USER_DEPRECATED);
+      $table = $this->connection->identifiers->table($tablename);
+    }
+    else {
+      $table = $tablename;
+    }
+    assert($table instanceof TableIdentifier);
+
+    // In SQLite, the 'CREATE [UNIQUE] INDEX' DDL statements requires that the
+    // table name be NOT prefixed by the schema name. We cannot use the
+    // Table::forMachine() method but should rather pick
+    // Table->quotedMachineName directly.
+    // @see https://www.sqlite.org/syntax/create-index-stmt.html
     $sql = [];
-    $info = $this->getPrefixInfo($tablename);
     if (!empty($schema['unique keys'])) {
       foreach ($schema['unique keys'] as $key => $fields) {
-        $sql[] = 'CREATE UNIQUE INDEX [' . $info['schema'] . '].[' . $info['table'] . '_' . $key . '] ON [' . $info['table'] . '] (' . $this->createKeySql($fields) . ")\n";
+        $sql[] = sprintf(
+          "CREATE UNIQUE INDEX %s.%s ON %s (%s)\n",
+          $table->schema,
+          '[' . $table->machineName . '_' . $key . ']',
+          $table->quotedMachineName,
+          $this->createKeySql($fields),
+        );
       }
     }
     if (!empty($schema['indexes'])) {
       foreach ($schema['indexes'] as $key => $fields) {
-        $sql[] = 'CREATE INDEX [' . $info['schema'] . '].[' . $info['table'] . '_' . $key . '] ON [' . $info['table'] . '] (' . $this->createKeySql($fields) . ")\n";
+        $sql[] = sprintf(
+          "CREATE INDEX %s.%s ON %s (%s)\n",
+          $table->schema,
+          '[' . $table->machineName . '_' . $key . ']',
+          $table->quotedMachineName,
+          $this->createKeySql($fields),
+        );
       }
     }
     return $sql;
@@ -79,6 +124,15 @@ protected function createIndexSql($tablename, $schema) {
    * Build the SQL expression for creating columns.
    */
   protected function createColumnsSql($tablename, $schema) {
+    if (!$tablename instanceof TableIdentifier) {
+      @trigger_error("Passing a table identifier as a string to " . __METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Pass a Table identifier value object instead. See https://www.drupal.org/node/7654132", E_USER_DEPRECATED);
+      $table = $this->connection->identifiers->table($tablename);
+    }
+    else {
+      $table = $tablename;
+    }
+    assert($table instanceof TableIdentifier);
+
     $sql_array = [];
 
     // Add the SQL statement for each field.
@@ -257,6 +311,15 @@ public function getFieldTypeMap() {
    * {@inheritdoc}
    */
   public function renameTable($table, $new_name) {
+    if (!$table instanceof TableIdentifier) {
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+    if (!$new_name instanceof TableIdentifier) {
+      $new_name = $this->connection->identifiers->table($new_name);
+    }
+    assert($new_name instanceof TableIdentifier);
+
     if (!$this->tableExists($table)) {
       throw new SchemaObjectDoesNotExistException("Cannot rename '$table' to '$new_name': table '$table' doesn't exist.");
     }
@@ -266,13 +329,12 @@ public function renameTable($table, $new_name) {
 
     $schema = $this->introspectSchema($table);
 
-    // SQLite doesn't allow you to rename tables outside of the current
-    // database. So the syntax '... RENAME TO database.table' would fail.
-    // So we must determine the full table name here rather than surrounding
-    // the table with curly braces in case the db_prefix contains a reference
-    // to a database outside of our existing database.
-    $info = $this->getPrefixInfo($new_name);
-    $this->executeDdlStatement('ALTER TABLE {' . $table . '} RENAME TO [' . $info['table'] . ']');
+    // SQLite doesn't allow you to rename tables outside of the current schema,
+    // so the syntax '... RENAME TO schema.table' would fail. We cannot use the
+    // Table::forMachine() method but should rather pick
+    // Table->quotedMachineName directly.
+    // @see https://www.sqlite.org/syntax/alter-table-stmt.html
+    $this->executeDdlStatement(sprintf('ALTER TABLE %s RENAME TO %s', $table->forMachine(), $new_name->quotedMachineName));
 
     // Drop the indexes, there is no RENAME INDEX command in SQLite.
     if (!empty($schema['unique keys'])) {
@@ -297,11 +359,16 @@ public function renameTable($table, $new_name) {
    * {@inheritdoc}
    */
   public function dropTable($table) {
+    if (!$table instanceof TableIdentifier) {
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     if (!$this->tableExists($table)) {
       return FALSE;
     }
     $this->connection->tableDropped = TRUE;
-    $this->executeDdlStatement('DROP TABLE {' . $table . '}');
+    $this->executeDdlStatement('DROP TABLE ' . $table->forMachine());
     return TRUE;
   }
 
@@ -309,6 +376,11 @@ public function dropTable($table) {
    * {@inheritdoc}
    */
   public function addField($table, $field, $specification, $keys_new = []) {
+    if (!$table instanceof TableIdentifier) {
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     if (!$this->tableExists($table)) {
       throw new SchemaObjectDoesNotExistException("Cannot add field '$table.$field': table doesn't exist.");
     }
@@ -325,7 +397,7 @@ public function addField($table, $field, $specification, $keys_new = []) {
     if (empty($keys_new) && (empty($specification['not null']) || isset($specification['default']))) {
       // When we don't have to create new keys and we are not creating a NOT
       // NULL column without a default value, we can use the quicker version.
-      $query = 'ALTER TABLE {' . $table . '} ADD ' . $this->createFieldSql($field, $this->processField($specification));
+      $query = 'ALTER TABLE ' . $table->forMachine() . ' ADD ' . $this->createFieldSql($field, $this->processField($specification));
       $this->executeDdlStatement($query);
 
       // Apply the initial value if set.
@@ -338,12 +410,12 @@ public function addField($table, $field, $specification, $keys_new = []) {
           $expression = $specification['initial_from_field'];
           $arguments = [];
         }
-        $this->connection->update($table)
+        $this->connection->update($table->identifier)
           ->expression($field, $expression, $arguments)
           ->execute();
       }
       elseif (isset($specification['initial'])) {
-        $this->connection->update($table)
+        $this->connection->update($table->identifier)
           ->fields([$field => $specification['initial']])
           ->execute();
       }
@@ -415,15 +487,21 @@ public function addField($table, $field, $specification, $keys_new = []) {
    *       that will be used as an expression field.
    */
   protected function alterTable($table, $old_schema, $new_schema, array $mapping = []) {
+    if (!$table instanceof TableIdentifier) {
+      @trigger_error("Passing a table identifier as a string to " . __METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Pass a Table identifier value object instead. See https://www.drupal.org/node/7654132", E_USER_DEPRECATED);
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     $i = 0;
     do {
-      $new_table = $table . '_' . $i++;
+      $new_table = $this->connection->identifiers->table($table->canonicalName . '_' . $i++);
     } while ($this->tableExists($new_table));
 
     $this->createTable($new_table, $new_schema);
 
     // Build a SQL query to migrate the data from the old table to the new.
-    $select = $this->connection->select($table);
+    $select = $this->connection->select($table->identifier);
 
     // Complete the mapping.
     $possible_keys = array_keys($new_schema['fields']);
@@ -445,12 +523,12 @@ protected function alterTable($table, $old_schema, $new_schema, array $mapping =
     }
 
     // Execute the data migration query.
-    $this->connection->insert($new_table)
+    $this->connection->insert($new_table->identifier)
       ->from($select)
       ->execute();
 
-    $old_count = $this->connection->query('SELECT COUNT(*) FROM {' . $table . '}')->fetchField();
-    $new_count = $this->connection->query('SELECT COUNT(*) FROM {' . $new_table . '}')->fetchField();
+    $old_count = $this->connection->query('SELECT COUNT(*) FROM ' . $table->forMachine())->fetchField();
+    $new_count = $this->connection->query('SELECT COUNT(*) FROM ' . $new_table->forMachine())->fetchField();
     if ($old_count == $new_count) {
       $this->dropTable($table);
       $this->renameTable($new_table, $table);
@@ -474,6 +552,12 @@ protected function alterTable($table, $old_schema, $new_schema, array $mapping =
    *   If a column of the table could not be parsed.
    */
   protected function introspectSchema($table) {
+    if (!$table instanceof TableIdentifier) {
+      @trigger_error("Passing a table identifier as a string to " . __METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Pass a Table identifier value object instead. See https://www.drupal.org/node/7654132", E_USER_DEPRECATED);
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     $mapped_fields = array_flip($this->getFieldTypeMap());
     $schema = [
       'fields' => [],
@@ -482,8 +566,7 @@ protected function introspectSchema($table) {
       'indexes' => [],
     ];
 
-    $info = $this->getPrefixInfo($table);
-    $result = $this->connection->query('PRAGMA [' . $info['schema'] . '].table_info([' . $info['table'] . '])');
+    $result = $this->connection->query('PRAGMA ' . $table->schema . '.table_info(' . $table->quotedMachineName . ')');
     foreach ($result as $row) {
       if (preg_match('/^([^(]+)\((.*)\)$/', $row->type, $matches)) {
         $type = $matches[1];
@@ -539,7 +622,7 @@ protected function introspectSchema($table) {
     $schema['primary key'] = array_values($schema['primary key']);
 
     $indexes = [];
-    $result = $this->connection->query('PRAGMA [' . $info['schema'] . '].index_list([' . $info['table'] . '])');
+    $result = $this->connection->query('PRAGMA ' . $table->schema . '.index_list(' . $table->quotedMachineName . ')');
     foreach ($result as $row) {
       if (!str_starts_with($row->name, 'sqlite_autoindex_')) {
         $indexes[] = [
@@ -551,8 +634,8 @@ protected function introspectSchema($table) {
     foreach ($indexes as $index) {
       $name = $index['name'];
       // Get index name without prefix.
-      $index_name = substr($name, strlen($info['table']) + 1);
-      $result = $this->connection->query('PRAGMA [' . $info['schema'] . '].index_info([' . $name . '])');
+      $index_name = substr($name, strlen($table->machineName) + 1);
+      $result = $this->connection->query('PRAGMA ' . $table->schema . '.index_info([' . $name . '])');
       foreach ($result as $row) {
         $schema[$index['schema_key']][$index_name][] = $row->name;
       }
@@ -564,6 +647,11 @@ protected function introspectSchema($table) {
    * {@inheritdoc}
    */
   public function dropField($table, $field) {
+    if (!$table instanceof TableIdentifier) {
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     if (!$this->fieldExists($table, $field)) {
       return FALSE;
     }
@@ -600,6 +688,11 @@ public function dropField($table, $field) {
    * {@inheritdoc}
    */
   public function changeField($table, $field, $field_new, $spec, $keys_new = []) {
+    if (!$table instanceof TableIdentifier) {
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     if (!$this->fieldExists($table, $field)) {
       throw new SchemaObjectDoesNotExistException("Cannot change the definition of field '$table.$field': field doesn't exist.");
     }
@@ -673,6 +766,11 @@ protected function mapKeyDefinition(array $key_definition, array $mapping) {
    * {@inheritdoc}
    */
   public function addIndex($table, $name, $fields, array $spec) {
+    if (!$table instanceof TableIdentifier) {
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     if (!$this->tableExists($table)) {
       throw new SchemaObjectDoesNotExistException("Cannot add index '$name' to table '$table': table doesn't exist.");
     }
@@ -691,29 +789,40 @@ public function addIndex($table, $name, $fields, array $spec) {
    * {@inheritdoc}
    */
   public function indexExists($table, $name) {
-    $info = $this->getPrefixInfo($table);
+    if (!$table instanceof TableIdentifier) {
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
 
-    return $this->connection->query('PRAGMA [' . $info['schema'] . '].index_info([' . $info['table'] . '_' . $name . '])')->fetchField() != '';
+    return $this->connection->query('PRAGMA ' . $table->schema . '.index_info([' . $table->machineName . '_' . $name . '])')->fetchField() != '';
   }
 
   /**
    * {@inheritdoc}
    */
   public function dropIndex($table, $name) {
+    if (!$table instanceof TableIdentifier) {
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     if (!$this->indexExists($table, $name)) {
       return FALSE;
     }
 
-    $info = $this->getPrefixInfo($table);
-
-    $this->executeDdlStatement('DROP INDEX [' . $info['schema'] . '].[' . $info['table'] . '_' . $name . ']');
-    return TRUE;
+    $this->executeDdlStatement('DROP INDEX ' . $table->schema . '.[' . $table->machineName . '_' . $name . ']');
+      return TRUE;
   }
 
   /**
    * {@inheritdoc}
    */
   public function addUniqueKey($table, $name, $fields) {
+    if (!$table instanceof TableIdentifier) {
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     if (!$this->tableExists($table)) {
       throw new SchemaObjectDoesNotExistException("Cannot add unique key '$name' to table '$table': table doesn't exist.");
     }
@@ -732,13 +841,16 @@ public function addUniqueKey($table, $name, $fields) {
    * {@inheritdoc}
    */
   public function dropUniqueKey($table, $name) {
+    if (!$table instanceof TableIdentifier) {
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     if (!$this->indexExists($table, $name)) {
       return FALSE;
     }
 
-    $info = $this->getPrefixInfo($table);
-
-    $this->executeDdlStatement('DROP INDEX [' . $info['schema'] . '].[' . $info['table'] . '_' . $name . ']');
+    $this->executeDdlStatement('DROP INDEX ' . $table->schema . '.[' . $table->machineName . '_' . $name . ']');
     return TRUE;
   }
 
@@ -746,6 +858,11 @@ public function dropUniqueKey($table, $name) {
    * {@inheritdoc}
    */
   public function addPrimaryKey($table, $fields) {
+    if (!$table instanceof TableIdentifier) {
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     if (!$this->tableExists($table)) {
       throw new SchemaObjectDoesNotExistException("Cannot add primary key to table '$table': table doesn't exist.");
     }
@@ -766,6 +883,11 @@ public function addPrimaryKey($table, $fields) {
    * {@inheritdoc}
    */
   public function dropPrimaryKey($table) {
+    if (!$table instanceof TableIdentifier) {
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     $old_schema = $this->introspectSchema($table);
     $new_schema = $old_schema;
 
@@ -782,6 +904,12 @@ public function dropPrimaryKey($table) {
    * {@inheritdoc}
    */
   protected function findPrimaryKeyColumns($table) {
+    if (!$table instanceof TableIdentifier) {
+      @trigger_error("Passing a table identifier as a string to " . __METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Pass a Table identifier value object instead. See https://www.drupal.org/node/7654132", E_USER_DEPRECATED);
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     if (!$this->tableExists($table)) {
       return FALSE;
     }
@@ -793,6 +921,13 @@ protected function findPrimaryKeyColumns($table) {
    * {@inheritdoc}
    */
   protected function introspectIndexSchema($table) {
+    if (!$table instanceof TableIdentifier) {
+      @trigger_error("Passing a table identifier as a string to " . __METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Pass a Table identifier value object instead. See https://www.drupal.org/node/7654132", E_USER_DEPRECATED);
+#      throw new \Exception("Passing a table identifier as a string to " . __METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Pass a Table identifier value object instead. See https://www.drupal.org/node/7654132");
+      $table = $this->connection->identifiers->table($table);
+    }
+    assert($table instanceof TableIdentifier);
+
     if (!$this->tableExists($table)) {
       throw new SchemaObjectDoesNotExistException("The table $table doesn't exist.");
     }
diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/Truncate.php b/core/modules/sqlite/src/Driver/Database/sqlite/Truncate.php
index 29d82f47792139470f21041a3df64485d645464f..192b6bcbfdcb1539e66fc485a363f2665e7b0907 100644
--- a/core/modules/sqlite/src/Driver/Database/sqlite/Truncate.php
+++ b/core/modules/sqlite/src/Driver/Database/sqlite/Truncate.php
@@ -19,7 +19,7 @@ public function __toString() {
     // Create a sanitized comment string to prepend to the query.
     $comments = $this->connection->makeComment($this->comments);
 
-    return $comments . 'DELETE FROM {' . $this->connection->escapeTable($this->table) . '} ';
+    return $comments . 'DELETE FROM ' . $this->connection->identifiers->table($this->table)->forMachine();
   }
 
 }
diff --git a/core/modules/sqlite/tests/src/Kernel/sqlite/SchemaTest.php b/core/modules/sqlite/tests/src/Kernel/sqlite/SchemaTest.php
index 8848c8586604f2fba0b9156c61dd869b9e5647af..9a9ea16c537f2dfe18a3f94d170e5ec20802bf7d 100644
--- a/core/modules/sqlite/tests/src/Kernel/sqlite/SchemaTest.php
+++ b/core/modules/sqlite/tests/src/Kernel/sqlite/SchemaTest.php
@@ -96,7 +96,7 @@ public function testIntrospectIndexSchema(): void {
     unset($table_specification['fields']);
 
     $introspect_index_schema = new \ReflectionMethod(get_class($this->schema), 'introspectIndexSchema');
-    $index_schema = $introspect_index_schema->invoke($this->schema, $table_name);
+    $index_schema = $introspect_index_schema->invoke($this->schema, $this->connection->identifiers->table($table_name));
 
     $this->assertEquals($table_specification, $index_schema);
   }
diff --git a/core/modules/sqlite/tests/src/Unit/IdentifierTest.php b/core/modules/sqlite/tests/src/Unit/IdentifierTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f27928b8253b402f3471dc1029a9925a8068ddb6
--- /dev/null
+++ b/core/modules/sqlite/tests/src/Unit/IdentifierTest.php
@@ -0,0 +1,168 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\sqlite\Unit;
+
+use Drupal\Core\Database\Exception\IdentifierException;
+use Drupal\Core\Database\Identifier\IdentifierType;
+use Drupal\sqlite\Driver\Database\sqlite\IdentifierHandler;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * Tests SQLite database identifiers.
+ *
+ * @coversDefaultClass \Drupal\sqlite\Driver\Database\sqlite\IdentifierHandler
+ * @group Database
+ */
+class IdentifierTest extends UnitTestCase {
+
+  // cSpell:disable
+
+  /**
+   * Data provider for testTable.
+   *
+   * @return array
+   *   An associative array of test case data.
+   */
+  public static function providerTable(): array {
+    return [
+      'No prefix' => [
+        'identifier' => 'nocase',
+        'prefix' => '',
+        'expectedCanonical' => 'nocase',
+        'expectedMachine' => '"nocase"',
+      ],
+      'Prefix' => [
+        'identifier' => 'nocase',
+        'prefix' => 'foobar',
+        'expectedCanonical' => 'foobar.nocase',
+        'expectedMachine' => '"foobar"."nocase"',
+      ],
+      'No prefix, camelCase' => [
+        'identifier' => 'camelCase',
+        'prefix' => '',
+        'expectedCanonical' => 'camelCase',
+        'expectedMachine' => '"camelCase"',
+      ],
+      'Prefix, camelCase' => [
+        'identifier' => 'camelCase',
+        'prefix' => 'foobar',
+        'expectedCanonical' => 'foobar.camelCase',
+        'expectedMachine' => '"foobar"."camelCase"',
+      ],
+      'No prefix, backtick' => [
+        'identifier' => '`backtick`',
+        'prefix' => '',
+        'expectedCanonical' => 'backtick',
+        'expectedMachine' => '"backtick"',
+      ],
+      'No prefix, brackets' => [
+        'identifier' => '[brackets]',
+        'prefix' => '',
+        'expectedCanonical' => 'brackets',
+        'expectedMachine' => '"brackets"',
+      ],
+      'No prefix, remove slash' => [
+        'identifier' => 'no/case',
+        'prefix' => '',
+        'expectedCanonical' => 'nocase',
+        'expectedMachine' => '"nocase"',
+      ],
+      'No prefix, remove quote' => [
+        'identifier' => 'no"case',
+        'prefix' => '',
+        'expectedCanonical' => 'nocase',
+        'expectedMachine' => '"nocase"',
+      ],
+      'No prefix, too long table name' => [
+        'identifier' => 'VeryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => '',
+        'expectedCanonical' => '',
+        'expectedMachine' => '',
+        'expectedException' => 'The length of the table identifier \'VeryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' once canonicalized to \'VeryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' is invalid (maximum allowed: 128)',
+      ],
+      'Prefix too long to fit' => [
+        'identifier' => 'nocase',
+        'prefix' => 'VeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLongLongLongLongLongLongLongVeryVeryVeryVeryVeryVeryVeryVeryLongLongLongLongLongLongLongPrefix',
+        'expectedCanonical' => '',
+        'expectedMachine' => '',
+        'expectedException' => 'The length of the schema identifier \'VeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLongLongLongLongLongLongLongVeryVeryVeryVeryVeryVeryVeryVeryLongLongLongLongLongLongLongPrefix\' once canonicalized to \'VeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLongLongLongLongLongLongLongVeryVeryVeryVeryVeryVeryVeryVeryLongLongLongLongLongLongLongPrefix\' is invalid (maximum allowed: 128)',
+      ],
+      // Sometimes, table names are following the pattern database.schema.table.
+      'Fully qualified - too many parts: 4' => [
+        'identifier' => 'Chowra.Teressa.Bompuka.Katchal',
+        'prefix' => '',
+        'expectedCanonical' => '',
+        'expectedMachine' => '',
+        'expectedException' => 'The table identifier \'Chowra.Teressa.Bompuka.Katchal\' does not comply with the syntax [database.][schema.]table',
+      ],
+      'Fully qualified - too many parts: SQLite not supporting \'database\'' => [
+        'identifier' => 'Chowra.Teressa.Bompuka',
+        'prefix' => '',
+        'expectedCanonical' => '',
+        'expectedMachine' => '',
+        'expectedException' => 'SQLite does not support the syntax [database.][schema.]table for the table identifier \'Chowra.Teressa.Bompuka\'. Avoid specifying the \'database\' part',
+      ],
+      'Fully qualified - no prefix' => [
+        'identifier' => '"Nancowry"."Tillangchong"',
+        'prefix' => '',
+        'expectedCanonical' => 'Nancowry.Tillangchong',
+        'expectedMachine' => '"Nancowry"."Tillangchong"',
+      ],
+      'Fully qualified - prefix' => [
+        'identifier' => '"Nancowry"."Tillangchong"',
+        'prefix' => 'foobar',
+        'expectedCanonical' => 'Nancowry.Tillangchong',
+        'expectedMachine' => '"Nancowry"."Tillangchong"',
+      ],
+      'Fully qualified - prefix not duplicated' => [
+        'identifier' => 'Nancowry.foobarTillangchong',
+        'prefix' => 'foobar',
+        'expectedCanonical' => 'Nancowry.foobarTillangchong',
+        'expectedMachine' => '"Nancowry"."foobarTillangchong"',
+      ],
+      'Fully qualified - remove not canonical characters' => [
+        'identifier' => '!Nancowry?.$Tillangchong%%%',
+        'prefix' => '',
+        'expectedCanonical' => 'Nancowry.Tillangchong',
+        'expectedMachine' => '"Nancowry"."Tillangchong"',
+      ],
+      'Fully qualified - invalid schema name length' => [
+        'identifier' => 'VeryVeryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar.Trinket',
+        'prefix' => '',
+        'expectedCanonical' => '',
+        'expectedMachine' => '',
+        'expectedException' => 'The length of the schema identifier \'VeryVeryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' once canonicalized to \'VeryVeryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' is invalid (maximum allowed: 128)',
+      ],
+      'Fully qualified - invalid table name length' => [
+        'identifier' => 'Camorta.VeryVeryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar',
+        'prefix' => '',
+        'expectedCanonical' => '',
+        'expectedMachine' => '',
+        'expectedException' => 'The length of the table identifier \'VeryVeryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' once canonicalized to \'VeryVeryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryVeryVeryVeryVeryHungryHungryHungryHungryHungryHungryHungryCaterpillar\' is invalid (maximum allowed: 128)',
+      ],
+    ];
+  }
+
+  // cSpell:enable
+
+  /**
+   * Tests table identifiers.
+   *
+   * @dataProvider providerTable
+   */
+  public function testTable(string $identifier, string $prefix = '', ?string $expectedCanonical = '', ?string $expectedMachine = '', ?string $expectedException = NULL): void {
+    $handler = new IdentifierHandler($prefix);
+    if ($expectedException) {
+      $this->expectException(IdentifierException::class);
+      $this->expectExceptionMessage($expectedException);
+    }
+    $this->assertSame($expectedCanonical, $handler->table($identifier)->canonical());
+    $this->assertSame($expectedMachine, $handler->table($identifier)->forMachine());
+    // The machine name includes the quote characters so we need to subtract
+    // those from the length.
+    $this->assertLessThanOrEqual($handler->getMaxLength(IdentifierType::Table), strlen($handler->table($identifier)->machineName), 'Invalid machine table length.');
+  }
+
+}
diff --git a/core/modules/system/tests/src/Functional/System/ErrorHandlerTest.php b/core/modules/system/tests/src/Functional/System/ErrorHandlerTest.php
index 82ba0c8896a18e6df18125464f9e5695b8c3a3be..44488c32a9c9f3c94477ecf3e094a9eeeec363fd 100644
--- a/core/modules/system/tests/src/Functional/System/ErrorHandlerTest.php
+++ b/core/modules/system/tests/src/Functional/System/ErrorHandlerTest.php
@@ -132,9 +132,7 @@ public function testExceptionHandler(): void {
     $message = str_replace(["\r", "\n"], ' ', $message);
     $error_pdo_exception = [
       '%type' => 'DatabaseExceptionWrapper',
-      '@message' => PHP_VERSION_ID >= 80400 ?
-      $message :
-      'SELECT "b".* FROM {bananas_are_awesome} "b"',
+      '@message' => PHP_VERSION_ID >= 80400 ? $message : '/SELECT "b"\.\* FROM .*bananas_are_awesome. "b"/',
       '%function' => 'Drupal\error_test\Controller\ErrorTestController->triggerPDOException()',
       '%line' => 64,
       '%file' => $this->getModulePath('error_test') . '/error_test.module',
@@ -160,7 +158,12 @@ public function testExceptionHandler(): void {
     $this->assertSession()->pageTextContains($error_pdo_exception['%type']);
     // Assert statement improved since static queries adds table alias in the
     // error message.
-    $this->assertSession()->pageTextContains($error_pdo_exception['@message']);
+    if (PHP_VERSION_ID >= 80400) {
+      $this->assertSession()->pageTextContains($error_pdo_exception['@message']);
+    }
+    else {
+      $this->assertSession()->pageTextMatches($error_pdo_exception['@message']);
+    }
     $error_details = new FormattableMarkup('in %function (line ', $error_pdo_exception);
     $this->assertSession()->responseContains($error_details);
     $this->drupalGet('error-test/trigger-renderer-exception');
diff --git a/core/modules/system/tests/src/Kernel/Scripts/DbCommandBaseTest.php b/core/modules/system/tests/src/Kernel/Scripts/DbCommandBaseTest.php
index 8856055b8ce55ff9fe455a1967db56838e47994d..07a841f9f4d1331002e067de15712ef560dde637 100644
--- a/core/modules/system/tests/src/Kernel/Scripts/DbCommandBaseTest.php
+++ b/core/modules/system/tests/src/Kernel/Scripts/DbCommandBaseTest.php
@@ -84,13 +84,13 @@ public function testPrefix(): void {
       '--database' => 'magic_db',
       '--prefix' => 'extra',
     ]);
-    $this->assertEquals('extra', $command->getDatabaseConnection($command_tester->getInput())->getPrefix());
+    $this->assertEquals('extra', $command->getDatabaseConnection($command_tester->getInput())->identifiers->tablePrefix);
 
     $command_tester->execute([
       '-db-url' => Database::getConnectionInfoAsUrl(),
       '--prefix' => 'extra2',
     ]);
-    $this->assertEquals('extra2', $command->getDatabaseConnection($command_tester->getInput())->getPrefix());
+    $this->assertEquals('extra2', $command->getDatabaseConnection($command_tester->getInput())->identifiers->tablePrefix);
 
     // This breaks test cleanup.
     // @code
diff --git a/core/modules/views_ui/tests/src/Functional/PreviewTest.php b/core/modules/views_ui/tests/src/Functional/PreviewTest.php
index e0baf9193c053916d737f7d6d287d38646c65c49..ae2e8cdbe14db55673b67f10a3cd3d65618a5707 100644
--- a/core/modules/views_ui/tests/src/Functional/PreviewTest.php
+++ b/core/modules/views_ui/tests/src/Functional/PreviewTest.php
@@ -123,13 +123,8 @@ public function testPreviewUI(): void {
     $this->assertSession()->pageTextContains('Query execute time');
     $this->assertSession()->pageTextContains('View render time');
     $this->assertSession()->responseContains('<strong>Query</strong>');
-    $query_string = <<<SQL
-SELECT "views_test_data"."name" AS "views_test_data_name"
-FROM
-{views_test_data} "views_test_data"
-WHERE (views_test_data.id = '100')
-SQL;
-    $this->assertSession()->assertEscaped($query_string);
+    $query_string = '/SELECT "views_test_data"\."name" AS "views_test_data_name" FROM .*views_test_data" "views_test_data" WHERE \(views_test_data\.id = \'100\'\)/';
+    $this->assertSession()->pageTextMatches($query_string);
 
     // Test that the statistics and query are rendered above the preview.
     $this->assertLessThan(strpos($this->getSession()->getPage()->getContent(), 'js-view-dom-id'), strpos($this->getSession()->getPage()->getContent(), 'views-query-info'));
diff --git a/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryNodePagePerformanceTest.php b/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryNodePagePerformanceTest.php
index 8718a00e7d4b66ba1472b95023acd3a181a5440e..aa2cddd606cca0a7f5e508a9a925948c82b3a3a3 100644
--- a/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryNodePagePerformanceTest.php
+++ b/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryNodePagePerformanceTest.php
@@ -279,9 +279,9 @@ protected function testNodePageWarmCache(): void {
       'SELECT "t".* FROM "node_revision__field_tags" "t" WHERE ("revision_id" IN ("75")) AND ("deleted" = 0) AND ("langcode" IN ("en", "es", "und", "zxx")) ORDER BY "delta" ASC',
       'SELECT "t".* FROM "node_revision__layout_builder__layout" "t" WHERE ("revision_id" IN ("75")) AND ("deleted" = 0) AND ("langcode" IN ("en", "es", "und", "zxx")) ORDER BY "delta" ASC',
       'INSERT INTO "semaphore" ("name", "value", "expire") VALUES ("theme_registry:runtime:umami:Drupal\Core\Utility\ThemeRegistry", "LOCK_ID", "EXPIRE")',
-      'DELETE FROM "semaphore"  WHERE ("name" = "theme_registry:runtime:umami:Drupal\Core\Utility\ThemeRegistry") AND ("value" = "LOCK_ID")',
+      'DELETE FROM "semaphore" WHERE ("name" = "theme_registry:runtime:umami:Drupal\Core\Utility\ThemeRegistry") AND ("value" = "LOCK_ID")',
       'INSERT INTO "semaphore" ("name", "value", "expire") VALUES ("active-trail:route:entity.node.canonical:route_parameters:a:1:{s:4:"node";s:1:"1";}:Drupal\Core\Cache\CacheCollector", "LOCK_ID", "EXPIRE")',
-      'DELETE FROM "semaphore"  WHERE ("name" = "active-trail:route:entity.node.canonical:route_parameters:a:1:{s:4:"node";s:1:"1";}:Drupal\Core\Cache\CacheCollector") AND ("value" = "LOCK_ID")',
+      'DELETE FROM "semaphore" WHERE ("name" = "active-trail:route:entity.node.canonical:route_parameters:a:1:{s:4:"node";s:1:"1";}:Drupal\Core\Cache\CacheCollector") AND ("value" = "LOCK_ID")',
     ];
     $recorded_queries = $performance_data->getQueries();
     $this->assertSame($expected_queries, $recorded_queries);
diff --git a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php
index 036447895e2730644369dbc3e20ae659f0e2053a..dacaad0474dcfca4637b190b28acfd6149d41b75 100644
--- a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php
+++ b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php
@@ -115,13 +115,13 @@ protected function testAnonymous(): void {
       'SELECT "menu_tree"."menu_name" AS "menu_name", "menu_tree"."route_name" AS "route_name", "menu_tree"."route_parameters" AS "route_parameters", "menu_tree"."url" AS "url", "menu_tree"."title" AS "title", "menu_tree"."description" AS "description", "menu_tree"."parent" AS "parent", "menu_tree"."weight" AS "weight", "menu_tree"."options" AS "options", "menu_tree"."expanded" AS "expanded", "menu_tree"."enabled" AS "enabled", "menu_tree"."provider" AS "provider", "menu_tree"."metadata" AS "metadata", "menu_tree"."class" AS "class", "menu_tree"."form_class" AS "form_class", "menu_tree"."id" AS "id" FROM "menu_tree" "menu_tree" WHERE ("route_name" = "view.frontpage.page_1") AND ("route_param_key" = "view_id=frontpage&display_id=page_1") AND ("menu_name" = "main") ORDER BY "depth" ASC, "weight" ASC, "id" ASC',
       'SELECT "menu_tree"."menu_name" AS "menu_name", "menu_tree"."route_name" AS "route_name", "menu_tree"."route_parameters" AS "route_parameters", "menu_tree"."url" AS "url", "menu_tree"."title" AS "title", "menu_tree"."description" AS "description", "menu_tree"."parent" AS "parent", "menu_tree"."weight" AS "weight", "menu_tree"."options" AS "options", "menu_tree"."expanded" AS "expanded", "menu_tree"."enabled" AS "enabled", "menu_tree"."provider" AS "provider", "menu_tree"."metadata" AS "metadata", "menu_tree"."class" AS "class", "menu_tree"."form_class" AS "form_class", "menu_tree"."id" AS "id" FROM "menu_tree" "menu_tree" WHERE ("route_name" = "view.frontpage.page_1") AND ("route_param_key" = "view_id=frontpage&display_id=page_1") AND ("menu_name" = "account") ORDER BY "depth" ASC, "weight" ASC, "id" ASC',
       'INSERT INTO "semaphore" ("name", "value", "expire") VALUES ("theme_registry:runtime:stark:Drupal\Core\Utility\ThemeRegistry", "LOCK_ID", "EXPIRE")',
-      'DELETE FROM "semaphore"  WHERE ("name" = "theme_registry:runtime:stark:Drupal\Core\Utility\ThemeRegistry") AND ("value" = "LOCK_ID")',
+      'DELETE FROM "semaphore" WHERE ("name" = "theme_registry:runtime:stark:Drupal\Core\Utility\ThemeRegistry") AND ("value" = "LOCK_ID")',
       'INSERT INTO "semaphore" ("name", "value", "expire") VALUES ("library_info:stark:Drupal\Core\Cache\CacheCollector", "LOCK_ID", "EXPIRE")',
-      'DELETE FROM "semaphore"  WHERE ("name" = "library_info:stark:Drupal\Core\Cache\CacheCollector") AND ("value" = "LOCK_ID")',
+      'DELETE FROM "semaphore" WHERE ("name" = "library_info:stark:Drupal\Core\Cache\CacheCollector") AND ("value" = "LOCK_ID")',
       'INSERT INTO "semaphore" ("name", "value", "expire") VALUES ("path_alias_prefix_list:Drupal\Core\Cache\CacheCollector", "LOCK_ID", "EXPIRE")',
-      'DELETE FROM "semaphore"  WHERE ("name" = "path_alias_prefix_list:Drupal\Core\Cache\CacheCollector") AND ("value" = "LOCK_ID")',
+      'DELETE FROM "semaphore" WHERE ("name" = "path_alias_prefix_list:Drupal\Core\Cache\CacheCollector") AND ("value" = "LOCK_ID")',
       'INSERT INTO "semaphore" ("name", "value", "expire") VALUES ("active-trail:route:view.frontpage.page_1:route_parameters:a:2:{s:10:"display_id";s:6:"page_1";s:7:"view_id";s:9:"frontpage";}:Drupal\Core\Cache\CacheCollector", "LOCK_ID", "EXPIRE")',
-      'DELETE FROM "semaphore"  WHERE ("name" = "active-trail:route:view.frontpage.page_1:route_parameters:a:2:{s:10:"display_id";s:6:"page_1";s:7:"view_id";s:9:"frontpage";}:Drupal\Core\Cache\CacheCollector") AND ("value" = "LOCK_ID")',
+      'DELETE FROM "semaphore" WHERE ("name" = "active-trail:route:view.frontpage.page_1:route_parameters:a:2:{s:10:"display_id";s:6:"page_1";s:7:"view_id";s:9:"frontpage";}:Drupal\Core\Cache\CacheCollector") AND ("value" = "LOCK_ID")',
     ];
     $recorded_queries = $performance_data->getQueries();
     $this->assertSame($expected_queries, $recorded_queries);
@@ -217,9 +217,9 @@ protected function testAnonymous(): void {
       'SELECT "menu_tree"."menu_name" AS "menu_name", "menu_tree"."route_name" AS "route_name", "menu_tree"."route_parameters" AS "route_parameters", "menu_tree"."url" AS "url", "menu_tree"."title" AS "title", "menu_tree"."description" AS "description", "menu_tree"."parent" AS "parent", "menu_tree"."weight" AS "weight", "menu_tree"."options" AS "options", "menu_tree"."expanded" AS "expanded", "menu_tree"."enabled" AS "enabled", "menu_tree"."provider" AS "provider", "menu_tree"."metadata" AS "metadata", "menu_tree"."class" AS "class", "menu_tree"."form_class" AS "form_class", "menu_tree"."id" AS "id" FROM "menu_tree" "menu_tree" WHERE ("route_name" = "entity.node.canonical") AND ("route_param_key" = "node=1") AND ("menu_name" = "main") ORDER BY "depth" ASC, "weight" ASC, "id" ASC',
       'SELECT "menu_tree"."menu_name" AS "menu_name", "menu_tree"."route_name" AS "route_name", "menu_tree"."route_parameters" AS "route_parameters", "menu_tree"."url" AS "url", "menu_tree"."title" AS "title", "menu_tree"."description" AS "description", "menu_tree"."parent" AS "parent", "menu_tree"."weight" AS "weight", "menu_tree"."options" AS "options", "menu_tree"."expanded" AS "expanded", "menu_tree"."enabled" AS "enabled", "menu_tree"."provider" AS "provider", "menu_tree"."metadata" AS "metadata", "menu_tree"."class" AS "class", "menu_tree"."form_class" AS "form_class", "menu_tree"."id" AS "id" FROM "menu_tree" "menu_tree" WHERE ("route_name" = "entity.node.canonical") AND ("route_param_key" = "node=1") AND ("menu_name" = "account") ORDER BY "depth" ASC, "weight" ASC, "id" ASC',
       'INSERT INTO "semaphore" ("name", "value", "expire") VALUES ("theme_registry:runtime:stark:Drupal\Core\Utility\ThemeRegistry", "LOCK_ID", "EXPIRE")',
-      'DELETE FROM "semaphore"  WHERE ("name" = "theme_registry:runtime:stark:Drupal\Core\Utility\ThemeRegistry") AND ("value" = "LOCK_ID")',
+      'DELETE FROM "semaphore" WHERE ("name" = "theme_registry:runtime:stark:Drupal\Core\Utility\ThemeRegistry") AND ("value" = "LOCK_ID")',
       'INSERT INTO "semaphore" ("name", "value", "expire") VALUES ("active-trail:route:entity.node.canonical:route_parameters:a:1:{s:4:"node";s:1:"1";}:Drupal\Core\Cache\CacheCollector", "LOCK_ID", "EXPIRE")',
-      'DELETE FROM "semaphore"  WHERE ("name" = "active-trail:route:entity.node.canonical:route_parameters:a:1:{s:4:"node";s:1:"1";}:Drupal\Core\Cache\CacheCollector") AND ("value" = "LOCK_ID")',
+      'DELETE FROM "semaphore" WHERE ("name" = "active-trail:route:entity.node.canonical:route_parameters:a:1:{s:4:"node";s:1:"1";}:Drupal\Core\Cache\CacheCollector") AND ("value" = "LOCK_ID")',
     ];
     $recorded_queries = $performance_data->getQueries();
     $this->assertSame($expected_queries, $recorded_queries);
@@ -300,7 +300,7 @@ protected function testAnonymous(): void {
       'SELECT "ud".* FROM "users_data" "ud" WHERE ("module" = "contact") AND ("uid" = "2") AND ("name" = "enabled")',
       'SELECT "name", "data" FROM "config" WHERE "collection" = "" AND "name" IN ( "contact.settings" )',
       'INSERT INTO "semaphore" ("name", "value", "expire") VALUES ("active-trail:route:entity.user.canonical:route_parameters:a:1:{s:4:"user";s:1:"2";}:Drupal\Core\Cache\CacheCollector", "LOCK_ID", "EXPIRE")',
-      'DELETE FROM "semaphore"  WHERE ("name" = "active-trail:route:entity.user.canonical:route_parameters:a:1:{s:4:"user";s:1:"2";}:Drupal\Core\Cache\CacheCollector") AND ("value" = "LOCK_ID")',
+      'DELETE FROM "semaphore" WHERE ("name" = "active-trail:route:entity.user.canonical:route_parameters:a:1:{s:4:"user";s:1:"2";}:Drupal\Core\Cache\CacheCollector") AND ("value" = "LOCK_ID")',
     ];
     $recorded_queries = $performance_data->getQueries();
     $this->assertSame($expected_queries, $recorded_queries);
diff --git a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSchemaTestBase.php b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSchemaTestBase.php
index ca5fb32936b55bc2ec02c054ea0eb8fc02187227..f2efcd9cecf94d92e2710ed7ba3b4e760972bc0d 100644
--- a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSchemaTestBase.php
+++ b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSchemaTestBase.php
@@ -255,7 +255,7 @@ public function testSchema(): void {
 
     // Test the primary key columns.
     $method = new \ReflectionMethod(get_class($this->schema), 'findPrimaryKeyColumns');
-    $this->assertSame(['test_serial'], $method->invoke($this->schema, 'test_table'));
+    $this->assertSame(['test_serial'], $method->invoke($this->schema, $this->connection->identifiers->table('test_table')));
 
     $this->assertTrue($this->tryInsert(), 'Insert with a serial succeeded.');
     $max1 = $this->connection->query('SELECT MAX([test_serial]) FROM {test_table}')->fetchField();
@@ -270,7 +270,7 @@ public function testSchema(): void {
     $this->schema->addField('test_table', 'test_composite_primary_key', ['type' => 'int', 'not null' => TRUE, 'default' => 0], ['primary key' => ['test_serial', 'test_composite_primary_key']]);
 
     // Test the primary key columns.
-    $this->assertSame(['test_serial', 'test_composite_primary_key'], $method->invoke($this->schema, 'test_table'));
+    $this->assertSame(['test_serial', 'test_composite_primary_key'], $method->invoke($this->schema, $this->connection->identifiers->table('test_table')));
 
     // Test renaming of keys and constraints.
     $this->schema->dropTable('test_table');
@@ -295,21 +295,21 @@ public function testSchema(): void {
     // SQLite does not have any limit. Use the lowest common value and create a
     // table name as long as possible in order to cover edge cases around
     // identifier names for the table's primary or unique key constraints.
-    $table_name = strtolower($this->getRandomGenerator()->name(63 - strlen($this->getDatabasePrefix())));
-    $this->schema->createTable($table_name, $table_specification);
+    $table = $this->connection->identifiers->table(strtolower($this->getRandomGenerator()->name(63 - strlen($this->getDatabasePrefix()))));
+    $this->schema->createTable($table, $table_specification);
 
-    $this->assertIndexOnColumns($table_name, ['id'], 'primary');
-    $this->assertIndexOnColumns($table_name, ['test_field'], 'unique');
+    $this->assertIndexOnColumns($table, ['id'], 'primary');
+    $this->assertIndexOnColumns($table, ['test_field'], 'unique');
 
-    $new_table_name = strtolower($this->getRandomGenerator()->name(63 - strlen($this->getDatabasePrefix())));
-    $this->assertNull($this->schema->renameTable($table_name, $new_table_name));
+    $new_table = $this->connection->identifiers->table(strtolower($this->getRandomGenerator()->name(63 - strlen($this->getDatabasePrefix()))));
+    $this->assertNull($this->schema->renameTable($table, $new_table));
 
     // Test for renamed primary and unique keys.
-    $this->assertIndexOnColumns($new_table_name, ['id'], 'primary');
-    $this->assertIndexOnColumns($new_table_name, ['test_field'], 'unique');
+    $this->assertIndexOnColumns($new_table, ['id'], 'primary');
+    $this->assertIndexOnColumns($new_table, ['test_field'], 'unique');
 
     // Check that the ID sequence gets renamed when the table is renamed.
-    $this->checkSequenceRenaming($new_table_name);
+    $this->checkSequenceRenaming($new_table->identifier);
   }
 
   /**
@@ -582,7 +582,7 @@ public function testSchemaChangePrimaryKey(array $initial_primary_key, array $re
     $find_primary_key_columns = new \ReflectionMethod(get_class($this->schema), 'findPrimaryKeyColumns');
 
     // Test making the field the primary key of the table upon creation.
-    $table_name = 'test_table';
+    $table_name = $this->connection->identifiers->table('test_table');
     $table_spec = [
       'fields' => [
         'test_field' => ['type' => 'int', 'not null' => TRUE],
@@ -908,7 +908,7 @@ public function testFindPrimaryKeyColumns(): void {
       ],
       'primary key' => ['id'],
     ]);
-    $this->assertSame(['id'], $method->invoke($this->schema, 'table_with_pk_0'));
+    $this->assertSame(['id'], $method->invoke($this->schema, $this->connection->identifiers->table('table_with_pk_0')));
 
     // Test with multiple column primary key.
     $this->schema->createTable('table_with_pk_1', [
@@ -929,7 +929,7 @@ public function testFindPrimaryKeyColumns(): void {
       ],
       'primary key' => ['id0', 'id1'],
     ]);
-    $this->assertSame(['id0', 'id1'], $method->invoke($this->schema, 'table_with_pk_1'));
+    $this->assertSame(['id0', 'id1'], $method->invoke($this->schema, $this->connection->identifiers->table('table_with_pk_1')));
 
     // Test with multiple column primary key and not being the first column of
     // the table definition.
@@ -955,7 +955,7 @@ public function testFindPrimaryKeyColumns(): void {
       ],
       'primary key' => ['id4', 'id3'],
     ]);
-    $this->assertSame(['id4', 'id3'], $method->invoke($this->schema, 'table_with_pk_2'));
+    $this->assertSame(['id4', 'id3'], $method->invoke($this->schema, $this->connection->identifiers->table('table_with_pk_2')));
 
     // Test with multiple column primary key in a different order. For the
     // PostgreSQL and the SQLite drivers is sorting used to get the primary key
@@ -982,7 +982,7 @@ public function testFindPrimaryKeyColumns(): void {
       ],
       'primary key' => ['id3', 'test_field_2', 'id4'],
     ]);
-    $this->assertSame(['id3', 'test_field_2', 'id4'], $method->invoke($this->schema, 'table_with_pk_3'));
+    $this->assertSame(['id3', 'test_field_2', 'id4'], $method->invoke($this->schema, $this->connection->identifiers->table('table_with_pk_3')));
 
     // Test with table without a primary key.
     $this->schema->createTable('table_without_pk_1', [
@@ -998,7 +998,7 @@ public function testFindPrimaryKeyColumns(): void {
         ],
       ],
     ]);
-    $this->assertSame([], $method->invoke($this->schema, 'table_without_pk_1'));
+    $this->assertSame([], $method->invoke($this->schema, $this->connection->identifiers->table('table_without_pk_1')));
 
     // Test with table with an empty primary key.
     $this->schema->createTable('table_without_pk_2', [
@@ -1015,10 +1015,10 @@ public function testFindPrimaryKeyColumns(): void {
       ],
       'primary key' => [],
     ]);
-    $this->assertSame([], $method->invoke($this->schema, 'table_without_pk_2'));
+    $this->assertSame([], $method->invoke($this->schema, $this->connection->identifiers->table('table_without_pk_2')));
 
     // Test with non existing table.
-    $this->assertFalse($method->invoke($this->schema, 'non_existing_table'));
+    $this->assertFalse($method->invoke($this->schema, $this->connection->identifiers->table('non_existing_table')));
   }
 
   /**
@@ -1271,7 +1271,7 @@ public function testReservedKeywordsForNaming(): void {
 
     // Check the primary key columns.
     $find_primary_key_columns = new \ReflectionMethod(get_class($this->schema), 'findPrimaryKeyColumns');
-    $this->assertEquals([$field_name], $find_primary_key_columns->invoke($this->schema, $table_name_new));
+    $this->assertEquals([$field_name], $find_primary_key_columns->invoke($this->schema, $this->connection->identifiers->table($table_name_new)));
 
     // Dropping a primary key.
     $this->schema->dropPrimaryKey($table_name_new);
@@ -1288,7 +1288,7 @@ public function testReservedKeywordsForNaming(): void {
 
     // Check the unique key columns.
     $introspect_index_schema = new \ReflectionMethod(get_class($this->schema), 'introspectIndexSchema');
-    $this->assertEquals([$field_name_new], $introspect_index_schema->invoke($this->schema, $table_name_new)['unique keys'][$unique_key_introspect_name]);
+    $this->assertEquals([$field_name_new], $introspect_index_schema->invoke($this->schema, $this->connection->identifiers->table($table_name_new))['unique keys'][$unique_key_introspect_name]);
 
     // Dropping an unique key
     $this->schema->dropUniqueKey($table_name_new, $unique_key_name);
@@ -1303,7 +1303,7 @@ public function testReservedKeywordsForNaming(): void {
     $this->assertTrue($this->schema->indexExists($table_name_new, $index_name));
 
     // Check the index columns.
-    $this->assertEquals(['update'], $introspect_index_schema->invoke($this->schema, $table_name_new)['indexes'][$index_introspect_name]);
+    $this->assertEquals(['update'], $introspect_index_schema->invoke($this->schema, $this->connection->identifiers->table($table_name_new))['indexes'][$index_introspect_name]);
 
     // Dropping an index.
     $this->schema->dropIndex($table_name_new, $index_name);
diff --git a/core/tests/Drupal/Tests/Core/Database/ConnectionLegacyTest.php b/core/tests/Drupal/Tests/Core/Database/ConnectionLegacyTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f298be160dc6cfa44cf63ee67b5f17b732ec0ab4
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Database/ConnectionLegacyTest.php
@@ -0,0 +1,81 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Database;
+
+use Drupal\Tests\Core\Database\Stub\StubLegacyConnection;
+use Drupal\Tests\Core\Database\Stub\StubPDO;
+use Drupal\Tests\UnitTestCase;
+use PHPUnit\Framework\Attributes\IgnoreDeprecations;
+
+/**
+ * Tests deprecations of the Connection class.
+ *
+ * @group Database
+ */
+class ConnectionLegacyTest extends UnitTestCase {
+
+  /**
+   * Tests that missing IdentifierHandler throws a deprecation.
+   */
+  #[IgnoreDeprecations]
+  public function testMissingIdentifierHandler(): void {
+    $this->expectDeprecation('Not passing an IdentifierHandler object to Drupal\\Core\\Database\\Connection::__construct() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. See https://www.drupal.org/node/3513282');
+    $connection = new StubLegacyConnection($this->createMock(StubPDO::class), ['prefix' => 'foo']);
+    $this->assertInstanceOf(StubLegacyConnection::class, $connection);
+  }
+
+  /**
+   * Deprecation of Connection::$prefix.
+   */
+  #[IgnoreDeprecations]
+  public function testPrefix(): void {
+    $this->expectDeprecation('Accessing Connection::$prefix is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282');
+    $connection = new StubLegacyConnection($this->createMock(StubPDO::class), ['prefix' => 'foo']);
+    $this->assertSame('foo', $connection->prefix);
+    $this->expectDeprecation('Accessing Connection::$prefix is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282');
+    $connection->prefix = 'bar';
+    $this->assertSame('foo', $connection->prefix);
+  }
+
+  /**
+   * Deprecation of Connection::$escapedTables.
+   */
+  #[IgnoreDeprecations]
+  public function testEscapedTables(): void {
+    $this->expectDeprecation('Accessing Connection::$escapedTables is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282');
+    $connection = new StubLegacyConnection($this->createMock(StubPDO::class), ['prefix' => 'foo']);
+    $this->assertSame([], $connection->escapedTables);
+    $this->expectDeprecation('Accessing Connection::$escapedTables is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282');
+    $connection->escapedTables = 'bar';
+    $this->assertSame([], $connection->escapedTables);
+  }
+
+  /**
+   * Deprecation of Connection::$identifierQuotes.
+   */
+  #[IgnoreDeprecations]
+  public function testIdentifierQuotes(): void {
+    $this->expectDeprecation('Accessing Connection::$identifierQuotes is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282');
+    $connection = new StubLegacyConnection($this->createMock(StubPDO::class), ['prefix' => 'foo']);
+    $this->assertSame(['"', '"'], $connection->identifierQuotes);
+    $this->expectDeprecation('Accessing Connection::$identifierQuotes is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282');
+    $connection->identifierQuotes = 'bar';
+    $this->assertSame(['"', '"'], $connection->identifierQuotes);
+  }
+
+  /**
+   * Deprecation of Connection::$tablePlaceholderReplacements.
+   */
+  #[IgnoreDeprecations]
+  public function testTablePlaceholderReplacements(): void {
+    $this->expectDeprecation('Accessing Connection::$tablePlaceholderReplacements is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282');
+    $connection = new StubLegacyConnection($this->createMock(StubPDO::class), ['prefix' => 'foo']);
+    $this->assertSame(['"foo', '"'], $connection->tablePlaceholderReplacements);
+    $this->expectDeprecation('Accessing Connection::$tablePlaceholderReplacements is deprecated in drupal:11.2.0 and the property is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282');
+    $connection->tablePlaceholderReplacements = 'bar';
+    $this->assertSame(['"foo', '"'], $connection->tablePlaceholderReplacements);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php b/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php
index f9b25e8e70723b7dac051c6fd68526fbc23231b0..0b307223bf34803a9ad009e91a4c9605d85862fb 100644
--- a/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php
+++ b/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php
@@ -30,7 +30,7 @@ class ConnectionTest extends UnitTestCase {
    *   - Arguments to pass to Connection::setPrefix().
    *   - Expected result from Connection::getPrefix().
    */
-  public static function providerPrefixRoundTrip() {
+  public static function providerPrefixRoundTrip(): array {
     return [
       [
         [
@@ -49,16 +49,19 @@ public static function providerPrefixRoundTrip() {
   }
 
   /**
-   * Exercise setPrefix() and getPrefix().
-   *
-   * @dataProvider providerPrefixRoundTrip
+   * Exercise legacy setPrefix() and getPrefix().
    */
+  #[IgnoreDeprecations]
+  #[DataProvider('providerPrefixRoundTrip')]
   public function testPrefixRoundTrip($expected, $prefix_info): void {
-    $mock_pdo = $this->createMock('Drupal\Tests\Core\Database\Stub\StubPDO');
-    $connection = new StubConnection($mock_pdo, []);
+    $this->expectDeprecation('Drupal\\Core\\Database\\Connection::setPrefix() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Pass the table prefix to the IdentifierHandler constructor instead. See https://www.drupal.org/node/3513282');
+    $this->expectDeprecation('Drupal\\Core\\Database\\Connection::getPrefix() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282');
+
+    $mock_pdo = $this->createMock(StubPDO::class);
+    $connection = new StubConnection($mock_pdo, ['prefix' => $prefix_info]);
 
     // setPrefix() is protected, so we make it accessible with reflection.
-    $reflection = new \ReflectionClass('Drupal\Tests\Core\Database\Stub\StubConnection');
+    $reflection = new \ReflectionClass(StubConnection::class);
     $set_prefix = $reflection->getMethod('setPrefix');
 
     // Set the prefix data.
@@ -458,7 +461,7 @@ public function testFilterComments($expected, $comment): void {
    *   testEscapeField. The first value is the expected value, and the second
    *   value is the value to test.
    */
-  public static function providerEscapeTables() {
+  public static function providerEscapeTables(): array {
     return [
       ['nocase', 'nocase'],
       ['camelCase', 'camelCase'],
@@ -475,10 +478,12 @@ public static function providerEscapeTables() {
   }
 
   /**
-   * @covers ::escapeTable
-   * @dataProvider providerEscapeTables
+   * Tests legacy ::escapeTable.
    */
+  #[IgnoreDeprecations]
+  #[DataProvider('providerEscapeTables')]
   public function testEscapeTable($expected, $name, array $identifier_quote = ['"', '"']): void {
+    $this->expectDeprecation('Drupal\\Core\\Database\\Connection::escapeTable() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282');
     $mock_pdo = $this->createMock(StubPDO::class);
     $connection = new StubConnection($mock_pdo, [], $identifier_quote);
 
@@ -558,7 +563,7 @@ public function testEscapeField($expected, $name, array $identifier_quote = ['"'
    *   testEscapeField. The first value is the expected value, and the second
    *   value is the value to test.
    */
-  public static function providerEscapeDatabase() {
+  public static function providerEscapeDatabase(): array {
     return [
       ['/name/', 'name', ['/', '/']],
       ['`backtick`', 'backtick', ['`', '`']],
@@ -569,10 +574,12 @@ public static function providerEscapeDatabase() {
   }
 
   /**
-   * @covers ::escapeDatabase
-   * @dataProvider providerEscapeDatabase
+   * Tests legacy ::escapeDatabase.
    */
+  #[IgnoreDeprecations]
+  #[DataProvider('providerEscapeDatabase')]
   public function testEscapeDatabase($expected, $name, array $identifier_quote = ['"', '"']): void {
+    $this->expectDeprecation('Drupal\\Core\\Database\\Connection::escapeDatabase() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use IdentifierHandler methods instead. See https://www.drupal.org/node/3513282');
     $mock_pdo = $this->createMock(StubPDO::class);
     $connection = new StubConnection($mock_pdo, [], $identifier_quote);
 
@@ -580,21 +587,21 @@ public function testEscapeDatabase($expected, $name, array $identifier_quote = [
   }
 
   /**
-   * @covers ::__construct
+   * Tests identifier quotes.
    */
   public function testIdentifierQuotesAssertCount(): void {
     $this->expectException(\AssertionError::class);
-    $this->expectExceptionMessage('\Drupal\Core\Database\Connection::$identifierQuotes must contain 2 string values');
+    $this->expectExceptionMessage('Drupal\\Core\\Database\\Identifier\\IdentifierHandlerBase::$identifierQuotes must contain 2 string values');
     $mock_pdo = $this->createMock(StubPDO::class);
     new StubConnection($mock_pdo, [], ['"']);
   }
 
   /**
-   * @covers ::__construct
+   * Tests identifier quotes.
    */
   public function testIdentifierQuotesAssertString(): void {
     $this->expectException(\AssertionError::class);
-    $this->expectExceptionMessage('\Drupal\Core\Database\Connection::$identifierQuotes must contain 2 string values');
+    $this->expectExceptionMessage('Drupal\\Core\\Database\\Identifier\\IdentifierHandlerBase::$identifierQuotes must contain 2 string values');
     $mock_pdo = $this->createMock(StubPDO::class);
     new StubConnection($mock_pdo, [], [0, '1']);
   }
diff --git a/core/tests/Drupal/Tests/Core/Database/SchemaIntrospectionTestTrait.php b/core/tests/Drupal/Tests/Core/Database/SchemaIntrospectionTestTrait.php
index 2168fdb2a112bef977d57c772d3cc3f15c0776b0..9cb02730939c42bf6fe2e0fbdd58456cfe7fd134 100644
--- a/core/tests/Drupal/Tests/Core/Database/SchemaIntrospectionTestTrait.php
+++ b/core/tests/Drupal/Tests/Core/Database/SchemaIntrospectionTestTrait.php
@@ -4,6 +4,8 @@
 
 namespace Drupal\Tests\Core\Database;
 
+use Drupal\Core\Database\Identifier\Table as TableIdentifier;
+
 /**
  * Provides methods for testing database schema characteristics.
  */
@@ -21,6 +23,15 @@ trait SchemaIntrospectionTestTrait {
    *   'primary'. Defaults to 'index'.
    */
   protected function assertIndexOnColumns($table_name, array $column_names, $index_type = 'index') {
+    if (!$table_name instanceof TableIdentifier) {
+      @trigger_error("Passing a table identifier as a string to " . __METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Pass a Table identifier value object instead. See https://www.drupal.org/node/7654132", E_USER_DEPRECATED);
+      $table = $this->connection->identifiers->table($table_name);
+    }
+    else {
+      $table = $table_name;
+    }
+    assert($table instanceof TableIdentifier);
+
     foreach ($this->getIndexColumnNames($table_name, $index_type) as $index_columns) {
       if ($column_names == $index_columns) {
         $this->assertTrue(TRUE);
@@ -42,7 +53,16 @@ protected function assertIndexOnColumns($table_name, array $column_names, $index
    *   'primary'. Defaults to 'index'.
    */
   protected function assertNoIndexOnColumns($table_name, array $column_names, $index_type = 'index') {
-    foreach ($this->getIndexColumnNames($table_name, $index_type) as $index_columns) {
+    if (!$table_name instanceof TableIdentifier) {
+      @trigger_error("Passing a table identifier as a string to " . __METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Pass a Table identifier value object instead. See https://www.drupal.org/node/7654132", E_USER_DEPRECATED);
+      $table = $this->connection->identifiers->table($table_name);
+    }
+    else {
+      $table = $table_name;
+    }
+    assert($table instanceof TableIdentifier);
+
+    foreach ($this->getIndexColumnNames($table, $index_type) as $index_columns) {
       if ($column_names == $index_columns) {
         $this->assertTrue(FALSE);
       }
@@ -63,11 +83,20 @@ protected function assertNoIndexOnColumns($table_name, array $column_names, $ind
    *   the given type.
    */
   protected function getIndexColumnNames($table_name, $index_type) {
+    if (!$table_name instanceof TableIdentifier) {
+      @trigger_error("Passing a table identifier as a string to " . __METHOD__ . "() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Pass a Table identifier value object instead. See https://www.drupal.org/node/7654132", E_USER_DEPRECATED);
+      $table = $this->connection->identifiers->table($table_name);
+    }
+    else {
+      $table = $table_name;
+    }
+    assert($table instanceof TableIdentifier);
+
     assert(in_array($index_type, ['index', 'unique', 'primary'], TRUE));
 
     $schema = \Drupal::database()->schema();
     $introspect_index_schema = new \ReflectionMethod(get_class($schema), 'introspectIndexSchema');
-    $index_schema = $introspect_index_schema->invoke($schema, $table_name);
+    $index_schema = $introspect_index_schema->invoke($schema, $table);
 
     // Filter the indexes by type.
     if ($index_type === 'primary') {
diff --git a/core/tests/Drupal/Tests/Core/Database/Stub/StubConnection.php b/core/tests/Drupal/Tests/Core/Database/Stub/StubConnection.php
index 4b31c06c2e6711c2d92eea1932feb03eea283400..3b57f4a5f6f84e94a6f4e205a9f9a8950b932483 100644
--- a/core/tests/Drupal/Tests/Core/Database/Stub/StubConnection.php
+++ b/core/tests/Drupal/Tests/Core/Database/Stub/StubConnection.php
@@ -41,8 +41,11 @@ class StubConnection extends Connection {
    *   The identifier quote characters. Defaults to an empty strings.
    */
   public function __construct(\PDO $connection, array $connection_options, $identifier_quotes = ['', '']) {
-    $this->identifierQuotes = $identifier_quotes;
-    parent::__construct($connection, $connection_options);
+    parent::__construct(
+      $connection,
+      $connection_options,
+      new StubIdentifierHandler($connection_options['prefix'] ?? '', $identifier_quotes),
+    );
   }
 
   /**
diff --git a/core/tests/Drupal/Tests/Core/Database/Stub/StubIdentifierHandler.php b/core/tests/Drupal/Tests/Core/Database/Stub/StubIdentifierHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..b3d4e0b66fb87fd6440f1814e4b526c6aa24c1be
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Database/Stub/StubIdentifierHandler.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Database\Stub;
+
+use Drupal\Core\Database\Identifier\IdentifierHandlerBase;
+use Drupal\Core\Database\Identifier\IdentifierType;
+
+/**
+ * A stub of the abstract IdentifierHandlerBase class for testing purposes.
+ */
+class StubIdentifierHandler extends IdentifierHandlerBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getMaxLength(IdentifierType $type): int {
+    return 4000;
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Database/Stub/StubLegacyConnection.php b/core/tests/Drupal/Tests/Core/Database/Stub/StubLegacyConnection.php
new file mode 100644
index 0000000000000000000000000000000000000000..cef6b9befebd1ce94a511d06707882d3845ab43a
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Database/Stub/StubLegacyConnection.php
@@ -0,0 +1,129 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Database\Stub;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Database\ExceptionHandler;
+use Drupal\Core\Database\Log;
+use Drupal\Core\Database\StatementWrapperIterator;
+use Drupal\Tests\Core\Database\Stub\Driver\Schema;
+
+/**
+ * A stub of the abstract Connection class for testing purposes.
+ *
+ * Includes minimal implementations of Connection's abstract methods.
+ */
+class StubLegacyConnection extends Connection {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $statementWrapperClass = StatementWrapperIterator::class;
+
+  /**
+   * Public property so we can test driver loading mechanism.
+   *
+   * @var string
+   * @see driver().
+   */
+  public $driver = 'stub';
+
+  /**
+   * Constructs a Connection object.
+   *
+   * @param \PDO $connection
+   *   An object of the PDO class representing a database connection.
+   * @param array $connection_options
+   *   An array of options for the connection.
+   */
+  public function __construct(\PDO $connection, array $connection_options) {
+    parent::__construct(
+      $connection,
+      $connection_options,
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function open(array &$connection_options = []) {
+    return new \stdClass();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function queryRange($query, $from, $count, array $args = [], array $options = []) {
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function driver() {
+    return $this->driver;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function databaseType() {
+    return 'stub';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createDatabase($database) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function mapConditionOperator($operator) {
+    return NULL;
+  }
+
+  /**
+   * Helper method to test database classes are not included in backtraces.
+   *
+   * @return array
+   *   The caller stack entry.
+   */
+  public function testLogCaller() {
+    return (new Log())->findCaller();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function exceptionHandler() {
+    return new ExceptionHandler();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function upsert($table, array $options = []) {
+    return new StubUpsert($this, $table, $options);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function schema() {
+    if (empty($this->schema)) {
+      $this->schema = new Schema();
+    }
+    return $this->schema;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function condition($conjunction) {
+    return new StubCondition($conjunction);
+  }
+
+}