ソースを参照

Decoupled connection roles from ConnectionManager

Corey Taylor 3 年 前
コミット
de77869e73

+ 111 - 46
src/Database/Connection.php

@@ -57,12 +57,14 @@ class Connection implements ConnectionInterface
     protected $_config;
 
     /**
-     * Driver object, responsible for creating the real connection
-     * and provide specific SQL dialect.
-     *
      * @var \Cake\Database\DriverInterface
      */
-    protected $_driver;
+    protected DriverInterface $readDriver;
+
+    /**
+     * @var \Cake\Database\DriverInterface
+     */
+    protected DriverInterface $writeDriver;
 
     /**
      * Contains how many nested transactions have been started.
@@ -139,19 +141,61 @@ class Connection implements ConnectionInterface
     public function __construct(array $config)
     {
         $this->_config = $config;
+        [self::ROLE_READ => $this->readDriver, self::ROLE_WRITE => $this->writeDriver] = $this->createDrivers($config);
+
+        if (!empty($config['log'])) {
+            $this->enableQueryLogging((bool)$config['log']);
+        }
+    }
+
+    /**
+     * Creates read and write drivers.
+     *
+     * @param array $config Connection config
+     * @return array<string, \Cake\Database\DriverInterface>
+     * @psalm-return array{read: \Cake\Database\DriverInterface, write: \Cake\Database\DriverInterface}
+     */
+    protected function createDrivers(array $config): array
+    {
+        $driver = $config['driver'] ?? '';
+        if (!is_string($driver)) {
+            /** @var \Cake\Database\DriverInterface $driver */
+            if (!$driver->enabled()) {
+                throw new MissingExtensionException(['driver' => get_class($driver), 'name' => $this->configName()]);
+            }
+
+            // Legacy support for setting instance instead of driver class
+            return [self::ROLE_READ => $driver, self::ROLE_WRITE => $driver];
+        }
+
+        /** @var class-string<\Cake\Database\DriverInterface>|null $driverClass */
+        $driverClass = App::className($driver, 'Database/Driver');
+        if ($driverClass === null) {
+            throw new MissingDriverException(['driver' => $driver, 'connection' => $this->configName()]);
+        }
 
-        $driverConfig = array_diff_key($config, array_flip([
+        $sharedConfig = array_diff_key($config, array_flip([
             'name',
             'driver',
             'log',
             'cacheMetaData',
             'cacheKeyPrefix',
         ]));
-        $this->_driver = $this->createDriver($config['driver'] ?? '', $driverConfig);
 
-        if (!empty($config['log'])) {
-            $this->enableQueryLogging((bool)$config['log']);
+        $writeConfig = $config['write'] ?? [] + $sharedConfig;
+        $readConfig = $config['read'] ?? [] + $sharedConfig;
+        if ($readConfig == $writeConfig) {
+            $readDriver = $writeDriver = new $driverClass(['_role' => self::ROLE_WRITE] + $writeConfig);
+        } else {
+            $readDriver = new $driverClass(['_role' => self::ROLE_READ] + $readConfig);
+            $writeDriver = new $driverClass(['_role' => self::ROLE_WRITE] + $writeConfig);
+        }
+
+        if (!$writeDriver->enabled()) {
+            throw new MissingExtensionException(['driver' => get_class($writeDriver), 'name' => $this->configName()]);
         }
+
+        return [self::ROLE_READ => $readDriver, self::ROLE_WRITE => $writeDriver];
     }
 
     /**
@@ -207,7 +251,8 @@ class Connection implements ConnectionInterface
     {
         deprecationWarning('Setting the driver is deprecated. Use the connection config instead.');
 
-        $this->_driver = $this->createDriver($driver, $config);
+        $driver = $this->createDriver($driver, $config);
+        $this->readDriver = $this->writeDriver = $driver;
 
         return $this;
     }
@@ -230,7 +275,7 @@ class Connection implements ConnectionInterface
             if ($className === null) {
                 throw new MissingDriverException(['driver' => $driver, 'connection' => $this->configName()]);
             }
-            $driver = new $className($config);
+            $driver = new $className(['_role' => self::ROLE_WRITE] + $config);
         }
 
         if (!$driver->enabled()) {
@@ -254,11 +299,14 @@ class Connection implements ConnectionInterface
     /**
      * Gets the driver instance.
      *
+     * @param string $role Connection role ('read' or 'write')
      * @return \Cake\Database\DriverInterface
      */
-    public function getDriver(): DriverInterface
+    public function getDriver(string $role = self::ROLE_WRITE): DriverInterface
     {
-        return $this->_driver;
+        assert($role === self::ROLE_READ || $role === self::ROLE_WRITE);
+
+        return $role === self::ROLE_READ ? $this->readDriver : $this->writeDriver;
     }
 
     /**
@@ -269,20 +317,25 @@ class Connection implements ConnectionInterface
      */
     public function connect(): bool
     {
-        try {
-            return $this->_driver->connect();
-        } catch (MissingConnectionException $e) {
-            throw $e;
-        } catch (Throwable $e) {
-            throw new MissingConnectionException(
-                [
-                    'driver' => App::shortName(get_class($this->_driver), 'Database/Driver'),
-                    'reason' => $e->getMessage(),
-                ],
-                null,
-                $e
-            );
+        $connected = true;
+        foreach ([self::ROLE_READ, self::ROLE_WRITE] as $role) {
+            try {
+                $connected = $connected && $this->getDriver($role)->connect();
+            } catch (MissingConnectionException $e) {
+                throw $e;
+            } catch (Throwable $e) {
+                throw new MissingConnectionException(
+                    [
+                        'driver' => App::shortName(get_class($this->getDriver($role)), 'Database/Driver'),
+                        'reason' => $e->getMessage(),
+                    ],
+                    null,
+                    $e
+                );
+            }
         }
+
+        return $connected;
     }
 
     /**
@@ -292,7 +345,8 @@ class Connection implements ConnectionInterface
      */
     public function disconnect(): void
     {
-        $this->_driver->disconnect();
+        $this->getDriver(self::ROLE_READ)->disconnect();
+        $this->getDriver(self::ROLE_WRITE)->disconnect();
     }
 
     /**
@@ -302,7 +356,7 @@ class Connection implements ConnectionInterface
      */
     public function isConnected(): bool
     {
-        return $this->_driver->isConnected();
+        return $this->getDriver(self::ROLE_READ)->isConnected() && $this->getDriver(self::ROLE_WRITE)->isConnected();
     }
 
     /**
@@ -313,8 +367,10 @@ class Connection implements ConnectionInterface
      */
     public function prepare($query): StatementInterface
     {
-        return $this->getDisconnectRetry()->run(function () use ($query) {
-            $statement = $this->_driver->prepare($query);
+        $role = $query instanceof Query ? $query->getConnectionRole() : self::ROLE_WRITE;
+
+        return $this->getDisconnectRetry()->run(function () use ($query, $role) {
+            $statement = $this->getDriver($role)->prepare($query);
 
             if ($this->_logQueries) {
                 $statement = $this->_newLogger($statement);
@@ -356,7 +412,7 @@ class Connection implements ConnectionInterface
      */
     public function compileQuery(Query $query, ValueBinder $binder): string
     {
-        return $this->getDriver()->compileQuery($query, $binder)[1];
+        return $this->getDriver($query->getConnectionRole())->compileQuery($query, $binder)[1];
     }
 
     /**
@@ -602,7 +658,7 @@ class Connection implements ConnectionInterface
             }
 
             $this->getDisconnectRetry()->run(function (): void {
-                $this->_driver->beginTransaction();
+                $this->getDriver()->beginTransaction();
             });
 
             $this->_transactionLevel = 0;
@@ -643,7 +699,7 @@ class Connection implements ConnectionInterface
                 $this->log('COMMIT');
             }
 
-            return $this->_driver->commitTransaction();
+            return $this->getDriver()->commitTransaction();
         }
         if ($this->isSavePointsEnabled()) {
             $this->releaseSavePoint((string)$this->_transactionLevel);
@@ -678,7 +734,7 @@ class Connection implements ConnectionInterface
             if ($this->_logQueries) {
                 $this->log('ROLLBACK');
             }
-            $this->_driver->rollbackTransaction();
+            $this->getDriver()->rollbackTransaction();
 
             return true;
         }
@@ -707,7 +763,7 @@ class Connection implements ConnectionInterface
         if ($enable === false) {
             $this->_useSavePoints = false;
         } else {
-            $this->_useSavePoints = $this->_driver->supports(DriverInterface::FEATURE_SAVEPOINT);
+            $this->_useSavePoints = $this->getDriver()->supports(DriverInterface::FEATURE_SAVEPOINT);
         }
 
         return $this;
@@ -743,7 +799,7 @@ class Connection implements ConnectionInterface
      */
     public function createSavePoint($name): void
     {
-        $this->execute($this->_driver->savePointSQL($name))->closeCursor();
+        $this->execute($this->getDriver()->savePointSQL($name))->closeCursor();
     }
 
     /**
@@ -754,7 +810,7 @@ class Connection implements ConnectionInterface
      */
     public function releaseSavePoint($name): void
     {
-        $sql = $this->_driver->releaseSavePointSQL($name);
+        $sql = $this->getDriver()->releaseSavePointSQL($name);
         if ($sql) {
             $this->execute($sql)->closeCursor();
         }
@@ -768,7 +824,7 @@ class Connection implements ConnectionInterface
      */
     public function rollbackSavepoint($name): void
     {
-        $this->execute($this->_driver->rollbackSavePointSQL($name))->closeCursor();
+        $this->execute($this->getDriver()->rollbackSavePointSQL($name))->closeCursor();
     }
 
     /**
@@ -779,7 +835,7 @@ class Connection implements ConnectionInterface
     public function disableForeignKeys(): void
     {
         $this->getDisconnectRetry()->run(function (): void {
-            $this->execute($this->_driver->disableForeignKeySQL())->closeCursor();
+            $this->execute($this->getDriver()->disableForeignKeySQL())->closeCursor();
         });
     }
 
@@ -791,7 +847,7 @@ class Connection implements ConnectionInterface
     public function enableForeignKeys(): void
     {
         $this->getDisconnectRetry()->run(function (): void {
-            $this->execute($this->_driver->enableForeignKeySQL())->closeCursor();
+            $this->execute($this->getDriver()->enableForeignKeySQL())->closeCursor();
         });
     }
 
@@ -804,7 +860,7 @@ class Connection implements ConnectionInterface
      */
     public function supportsDynamicConstraints(): bool
     {
-        return $this->_driver->supportsDynamicConstraints();
+        return $this->getDriver()->supportsDynamicConstraints();
     }
 
     /**
@@ -888,7 +944,7 @@ class Connection implements ConnectionInterface
     {
         [$value, $type] = $this->cast($value, $type);
 
-        return $this->_driver->quote($value, $type);
+        return $this->getDriver()->quote($value, $type);
     }
 
     /**
@@ -900,7 +956,7 @@ class Connection implements ConnectionInterface
      */
     public function supportsQuoting(): bool
     {
-        return $this->_driver->supports(DriverInterface::FEATURE_QUOTE);
+        return $this->getDriver()->supports(DriverInterface::FEATURE_QUOTE);
     }
 
     /**
@@ -914,7 +970,7 @@ class Connection implements ConnectionInterface
      */
     public function quoteIdentifier(string $identifier): string
     {
-        return $this->_driver->quoteIdentifier($identifier);
+        return $this->getDriver()->quoteIdentifier($identifier);
     }
 
     /**
@@ -1061,7 +1117,7 @@ class Connection implements ConnectionInterface
      */
     protected function _newLogger(StatementInterface $statement): LoggingStatement
     {
-        $log = new LoggingStatement($statement, $this->_driver);
+        $log = new LoggingStatement($statement, $this->getDriver());
         $log->setLogger($this->getLogger());
 
         return $log;
@@ -1085,10 +1141,19 @@ class Connection implements ConnectionInterface
         $replace = array_intersect_key($secrets, $this->_config);
         $config = $replace + $this->_config;
 
+        if (isset($config['read'])) {
+            /** @psalm-suppress PossiblyInvalidArgument */
+            $config['read'] = array_intersect_key($secrets, $config['read']) + $config['read'];
+        }
+        if (isset($config['write'])) {
+            /** @psalm-suppress PossiblyInvalidArgument */
+            $config['write'] = array_intersect_key($secrets, $config['write']) + $config['write'];
+        }
+
         return [
             'config' => $config,
-            'driver' => $this->_driver,
-            'role' => $this->role(),
+            'readDriver' => $this->readDriver,
+            'writeDriver' => $this->writeDriver,
             'transactionLevel' => $this->_transactionLevel,
             'transactionStarted' => $this->_transactionStarted,
             'useSavePoints' => $this->_useSavePoints,

+ 11 - 0
src/Database/Driver.php

@@ -524,6 +524,16 @@ abstract class Driver implements DriverInterface
     }
 
     /**
+     * Returns the connection role this driver performs.
+     *
+     * @return string
+     */
+    public function getRole(): string
+    {
+        return $this->_config['_role'] ?? Connection::ROLE_WRITE;
+    }
+
+    /**
      * Destructor
      */
     public function __destruct()
@@ -542,6 +552,7 @@ abstract class Driver implements DriverInterface
     {
         return [
             'connected' => $this->_connection !== null,
+            'role' => $this->getRole(),
         ];
     }
 }

+ 1 - 0
src/Database/DriverInterface.php

@@ -28,6 +28,7 @@ use Closure;
  * @method bool supports(string $feature) Checks whether a feature is supported by the driver.
  * @method bool inTransaction() Returns whether a transaction is active.
  * @method array config() Get the configuration data used to create the driver.
+ * @method string getRole() Returns the connection role this driver prforms.
  */
 interface DriverInterface
 {

+ 1 - 0
src/Database/Log/LoggedQuery.php

@@ -130,6 +130,7 @@ class LoggedQuery implements JsonSerializable
         return [
             'numRows' => $this->numRows,
             'took' => $this->took,
+            'role' => $this->driver ? $this->driver->getRole() : '',
         ];
     }
 

+ 1 - 1
src/Database/Log/QueryLogger.php

@@ -50,7 +50,7 @@ class QueryLogger extends BaseLog
 
         if ($context['query'] instanceof LoggedQuery) {
             $context = $context['query']->getContext() + $context;
-            $message = 'connection={connection} duration={took} rows={numRows} ' . $message;
+            $message = 'connection={connection} role={role} duration={took} rows={numRows} ' . $message;
         }
         Log::write('debug', $message, $context);
     }

+ 12 - 41
src/Database/Query.php

@@ -25,7 +25,6 @@ use Cake\Database\Expression\QueryExpression;
 use Cake\Database\Expression\ValuesExpression;
 use Cake\Database\Expression\WindowExpression;
 use Cake\Database\Statement\CallbackStatement;
-use Cake\Datasource\ConnectionManager;
 use Closure;
 use InvalidArgumentException;
 use IteratorAggregate;
@@ -64,6 +63,13 @@ class Query implements ExpressionInterface, IteratorAggregate
     protected $_connection;
 
     /**
+     * Connection role ('read' or 'write')
+     *
+     * @var string
+     */
+    protected $connectionRole = Connection::ROLE_WRITE;
+
+    /**
      * Type of this query (select, insert, update, delete).
      *
      * @var string
@@ -231,48 +237,13 @@ class Query implements ExpressionInterface, IteratorAggregate
     }
 
     /**
-     * Sets the connection for this query to the read-only role for the current connection.
-     *
-     * @return $this
-     */
-    public function useReadRole()
-    {
-        return $this->useRole(Connection::ROLE_READ);
-    }
-
-    /**
-     * Sets the connection for this query to the write role for the current connection.
-     *
-     * @return $this
-     */
-    public function useWriteRole()
-    {
-        return $this->useRole(Connection::ROLE_WRITE);
-    }
-
-    /**
-     * Sets the connection for this query to the specified role for the current connection.
+     * Returns the connection role ('read' or 'write')
      *
-     * @param string $role Connection role - read or write
-     * @return $this
+     * @return string
      */
-    public function useRole(string $role)
+    public function getConnectionRole(): string
     {
-        assert(in_array($role, [Connection::ROLE_READ, Connection::ROLE_WRITE], true));
-        if ($this->_connection->role() === $role) {
-            return $this;
-        }
-
-        $name = $this->_connection->configName();
-        $roleName = ConnectionManager::getName($role, $name);
-        if ($roleName === $name) {
-            return $this;
-        }
-
-        /** @var \Cake\Database\Connection $connection */
-        $connection = ConnectionManager::get($roleName);
-
-        return $this->setConnection($connection);
+        return $this->connectionRole;
     }
 
     /**
@@ -2353,7 +2324,7 @@ class Query implements ExpressionInterface, IteratorAggregate
     protected function _decorateStatement(StatementInterface $statement)
     {
         $typeMap = $this->getSelectTypeMap();
-        $driver = $this->getConnection()->getDriver();
+        $driver = $this->getConnection()->getDriver($this->connectionRole);
 
         if ($this->typeCastEnabled && $typeMap->toArray()) {
             $statement = new CallbackStatement($statement, $driver, new FieldTypeConverter($typeMap, $driver));

+ 35 - 0
src/Database/Query/SelectQuery.php

@@ -16,6 +16,7 @@ declare(strict_types=1);
  */
 namespace Cake\Database\Query;
 
+use Cake\Database\Connection;
 use Cake\Database\Query;
 
 /**
@@ -89,4 +90,38 @@ class SelectQuery extends Query
 
         return parent::set($key, $value, $types);
     }
+
+    /**
+     * Sets the connection role.
+     *
+     * @param string $role Connection role ('read' or 'write')
+     * @return $this
+     */
+    public function setConnectionRole(string $role)
+    {
+        assert($role === Connection::ROLE_READ || $role === Connection::ROLE_WRITE);
+        $this->connectionRole = $role;
+
+        return $this;
+    }
+
+    /**
+     * Sets the connection role to read.
+     *
+     * @return $this
+     */
+    public function useReadRole()
+    {
+        return $this->setConnectionRole(Connection::ROLE_READ);
+    }
+
+    /**
+     * Sets the connection role to write.
+     *
+     * @return $this
+     */
+    public function useWriteRole()
+    {
+        return $this->setConnectionRole(Connection::ROLE_WRITE);
+    }
 }

+ 1 - 1
src/Database/QueryCompiler.php

@@ -201,7 +201,7 @@ class QueryCompiler
         $distinct = $query->clause('distinct');
         $modifiers = $this->_buildModifierPart($query->clause('modifier'), $query, $binder);
 
-        $driver = $query->getConnection()->getDriver();
+        $driver = $query->getConnection()->getDriver($query->getConnectionRole());
         $quoteIdentifiers = $driver->isAutoQuotingEnabled() || $this->_quotedSelectAliases;
         $normalized = [];
         $parts = $this->_stringifyExpressions($parts, $binder);

+ 10 - 0
src/Database/Statement/BufferedStatement.php

@@ -86,6 +86,16 @@ class BufferedStatement implements Iterator, StatementInterface
     }
 
     /**
+     * Returns the connection driver.
+     *
+     * @return \Cake\Database\DriverInterface
+     */
+    protected function getDriver(): DriverInterface
+    {
+        return $this->_driver;
+    }
+
+    /**
      * Magic getter to return $queryString as read-only.
      *
      * @param string $property internal property to get

+ 10 - 0
src/Database/Statement/StatementDecorator.php

@@ -73,6 +73,16 @@ class StatementDecorator implements StatementInterface, Countable, IteratorAggre
     }
 
     /**
+     * Returns the connection driver.
+     *
+     * @return \Cake\Database\DriverInterface
+     */
+    protected function getDriver(): DriverInterface
+    {
+        return $this->_driver;
+    }
+
+    /**
      * Magic getter to return $queryString as read-only.
      *
      * @param string $property internal property to get

+ 2 - 2
src/Database/TypeConverterTrait.php

@@ -36,8 +36,8 @@ trait TypeConverterTrait
             $type = TypeFactory::build($type);
         }
         if ($type instanceof TypeInterface) {
-            $value = $type->toDatabase($value, $this->_driver);
-            $type = $type->toStatement($value, $this->_driver);
+            $value = $type->toDatabase($value, $this->getDriver());
+            $type = $type->toStatement($value, $this->getDriver());
         }
 
         return [$value, $type];

+ 1 - 78
src/Datasource/ConnectionManager.php

@@ -85,19 +85,6 @@ class ConnectionManager
         }
 
         static::_setConfig($key, $config);
-
-        foreach (static::$_config as $name => $config) {
-            if (preg_match('/(.*):read$/', $name, $matches) === 1) {
-                $writeName = $matches[1];
-                if (empty(static::$_config[$writeName])) {
-                    throw new MissingDatasourceConfigException(sprintf(
-                        'Missing write datasource %s for read-only datasource %s',
-                        $writeName,
-                        $name
-                    ));
-                }
-            }
-        }
     }
 
     /**
@@ -206,17 +193,12 @@ class ConnectionManager
      *
      * @param string $name The connection name.
      * @param bool $useAliases Whether connection aliases are used
-     * @param string|null $role Which connection role to get. Defaults to whatever role `$name` resolves to.
      * @return \Cake\Datasource\ConnectionInterface
      * @throws \Cake\Datasource\Exception\MissingDatasourceConfigException When config
      * data is missing.
      */
-    public static function get(string $name, bool $useAliases = true, ?string $role = null)
+    public static function get(string $name, bool $useAliases = true)
     {
-        if ($role) {
-            $roleName = static::getName($role, $name, $useAliases);
-        }
-
         if ($useAliases && isset(static::$_aliasMap[$name])) {
             $name = static::$_aliasMap[$name];
         }
@@ -231,63 +213,4 @@ class ConnectionManager
 
         return static::$_registry->{$name} ?? static::$_registry->load($name, static::$_config[$name]);
     }
-
-    /**
-     * Gets the connection name (or alias if `$useAliases` is true) for a role.
-     *
-     * @param string $role Connection role - read or write
-     * @param string $name Connection name
-     * @param bool $useAliases Whether connection aliases are used
-     * @return string
-     */
-    public static function getName(string $role, string $name, $useAliases = true): string
-    {
-        assert(in_array($role, [ConnectionInterface::ROLE_READ, ConnectionInterface::ROLE_WRITE], true));
-        if ($role === ConnectionInterface::ROLE_READ) {
-            return static::getReadName($name, $useAliases);
-        }
-
-        return static::getWriteName($name, $useAliases);
-    }
-
-    /**
-     * Gets the read connection name (or alias if `$useAliases` is true).
-     *
-     * @param string $name Connection name
-     * @param bool $useAliases Whether connection aliases are used
-     * @return string
-     */
-    protected static function getReadName(string $name, bool $useAliases): string
-    {
-        $readName = $name;
-        $writeName = $name;
-        if (preg_match('/(.*):read$/', $name, $matches) === 1) {
-            $writeName = $matches[1];
-        } else {
-            $readName = $name . ':read';
-        }
-
-        if (($useAliases && isset(static::$_aliasMap[$readName])) || isset(static::$_config[$readName])) {
-            return $readName;
-        }
-
-        return $writeName;
-    }
-
-    /**
-     * Gets the write connection name (or alias if `$useAliases` is true).
-     *
-     * @param string $name Connection name
-     * @param bool $useAliases Whether connection aliases are used
-     * @return string
-     */
-    protected static function getWriteName(string $name, bool $useAliases): string
-    {
-        $writeName = $name;
-        if (preg_match('/(.*):read$/', $name, $matches) === 1) {
-            $writeName = $matches[1];
-        }
-
-        return $writeName;
-    }
 }

+ 1 - 1
src/ORM/EagerLoader.php

@@ -630,7 +630,7 @@ class EagerLoader
             return $statement;
         }
 
-        $driver = $query->getConnection()->getDriver();
+        $driver = $query->getConnection()->getDriver($query->getConnectionRole());
         [$collected, $statement] = $this->_collectKeys($external, $query, $statement);
 
         // No records found, skip trying to attach associations.

+ 1 - 1
src/ORM/ResultSet.php

@@ -161,7 +161,7 @@ class ResultSet implements ResultSetInterface
     {
         $repository = $query->getRepository();
         $this->_statement = $statement;
-        $this->_driver = $query->getConnection()->getDriver();
+        $this->_driver = $query->getConnection()->getDriver($query->getConnectionRole());
         $this->_defaultTable = $repository;
         $this->_calculateAssociationMap($query);
         $this->_hydrate = $query->isHydrationEnabled();

+ 76 - 29
tests/TestCase/Database/ConnectionTest.php

@@ -43,6 +43,7 @@ use InvalidArgumentException;
 use PDO;
 use ReflectionMethod;
 use ReflectionProperty;
+use TestApp\Database\Driver\DisabledDriver;
 use TestApp\Log\Engine\TestBaseLog;
 
 /**
@@ -102,8 +103,6 @@ class ConnectionTest extends TestCase
         $this->connection->disableSavePoints();
         $this->connection->setLogger($this->defaultLogger);
         $this->connection->enableQueryLogging($this->logState);
-        ConnectionManager::dropAlias('test:read');
-        ConnectionManager::drop('test:read');
 
         Log::reset();
         unset($this->connection);
@@ -197,6 +196,54 @@ class ConnectionTest extends TestCase
     }
 
     /**
+     * Test providing a unique read config only creates separate drivers.
+     */
+    public function testDifferentReadDriver(): void
+    {
+        $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing');
+        $config = ConnectionManager::getConfig('test') + ['read' => ['database' => 'read_test.db']];
+        $connection = new Connection($config);
+        $this->assertNotSame($connection->getDriver(Connection::ROLE_READ), $connection->getDriver(Connection::ROLE_WRITE));
+        $this->assertSame(Connection::ROLE_READ, $connection->getDriver(Connection::ROLE_READ)->getRole());
+        $this->assertSame(Connection::ROLE_WRITE, $connection->getDriver(Connection::ROLE_WRITE)->getRole());
+    }
+
+    /**
+     * Test providing a unique write config only creates separate drivers.
+     */
+    public function testDifferentWriteDriver(): void
+    {
+        $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing');
+        $config = ConnectionManager::getConfig('test') + ['write' => ['database' => 'read_test.db']];
+        $connection = new Connection($config);
+        $this->assertNotSame($connection->getDriver(Connection::ROLE_READ), $connection->getDriver(Connection::ROLE_WRITE));
+        $this->assertSame(Connection::ROLE_READ, $connection->getDriver(Connection::ROLE_READ)->getRole());
+        $this->assertSame(Connection::ROLE_WRITE, $connection->getDriver(Connection::ROLE_WRITE)->getRole());
+    }
+
+    /**
+     * Test providing the same read and write config uses a shared driver.
+     */
+    public function testSameReadWriteDriver(): void
+    {
+        $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing');
+        $config = ConnectionManager::getConfig('test') + ['read' => ['database' => 'read_test.db'], 'write' => ['database' => 'read_test.db']];
+        $connection = new Connection($config);
+        $this->assertSame($connection->getDriver(Connection::ROLE_READ), $connection->getDriver(Connection::ROLE_WRITE));
+        $this->assertSame(Connection::ROLE_WRITE, $connection->getDriver(Connection::ROLE_READ)->getRole());
+        $this->assertSame(Connection::ROLE_WRITE, $connection->getDriver(Connection::ROLE_WRITE)->getRole());
+    }
+
+    public function testDisabledReadWriteDriver(): void
+    {
+        $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing');
+        $config = ['driver' => DisabledDriver::class] + ConnectionManager::getConfig('test');
+
+        $this->expectException(MissingExtensionException::class);
+        $connection = new Connection($config);
+    }
+
+    /**
      * Tests that connecting with invalid credentials or database name throws an exception
      */
     public function testWrongCredentials(): void
@@ -1016,7 +1063,7 @@ class ConnectionTest extends TestCase
 
         $messages = Log::engine('queries')->read();
         $this->assertCount(1, $messages);
-        $this->assertSame('debug: connection=test duration=0 rows=0 SELECT 1', $messages[0]);
+        $this->assertSame('debug: connection=test role= duration=0 rows=0 SELECT 1', $messages[0]);
     }
 
     /**
@@ -1076,8 +1123,8 @@ class ConnectionTest extends TestCase
 
         $messages = Log::engine('queries')->read();
         $this->assertCount(2, $messages);
-        $this->assertSame('debug: connection= duration=0 rows=0 BEGIN', $messages[0]);
-        $this->assertSame('debug: connection= duration=0 rows=0 ROLLBACK', $messages[1]);
+        $this->assertSame('debug: connection= role= duration=0 rows=0 BEGIN', $messages[0]);
+        $this->assertSame('debug: connection= role= duration=0 rows=0 ROLLBACK', $messages[1]);
     }
 
     /**
@@ -1098,8 +1145,8 @@ class ConnectionTest extends TestCase
 
         $messages = Log::engine('queries')->read();
         $this->assertCount(2, $messages);
-        $this->assertSame('debug: connection= duration=0 rows=0 BEGIN', $messages[0]);
-        $this->assertSame('debug: connection= duration=0 rows=0 COMMIT', $messages[1]);
+        $this->assertSame('debug: connection= role= duration=0 rows=0 BEGIN', $messages[0]);
+        $this->assertSame('debug: connection= role= duration=0 rows=0 COMMIT', $messages[1]);
     }
 
     /**
@@ -1370,17 +1417,19 @@ class ConnectionTest extends TestCase
      * Tests that the connection is restablished whenever it is interrupted
      * after having used the connection at least once.
      */
-    public function testAutomaticReconnect(): void
+    public function testAutomaticReconnect2(): void
     {
         $conn = clone $this->connection;
         $statement = $conn->query('SELECT 1');
         $statement->execute();
         $statement->closeCursor();
 
-        $prop = new ReflectionProperty($conn, '_driver');
-        $prop->setAccessible(true);
-        $oldDriver = $prop->getValue($conn);
         $newDriver = $this->getMockBuilder(Driver::class)->getMock();
+        $prop = new ReflectionProperty($conn, 'readDriver');
+        $prop->setAccessible(true);
+        $prop->setValue($conn, $newDriver);
+        $prop = new ReflectionProperty($conn, 'writeDriver');
+        $prop->setAccessible(true);
         $prop->setValue($conn, $newDriver);
 
         $newDriver->expects($this->exactly(2))
@@ -1407,10 +1456,13 @@ class ConnectionTest extends TestCase
 
         $conn->begin();
 
-        $prop = new ReflectionProperty($conn, '_driver');
+        $newDriver = $this->getMockBuilder(Driver::class)->getMock();
+        $prop = new ReflectionProperty($conn, 'readDriver');
+        $prop->setAccessible(true);
+        $prop->setValue($conn, $newDriver);
+        $prop = new ReflectionProperty($conn, 'writeDriver');
         $prop->setAccessible(true);
         $oldDriver = $prop->getValue($conn);
-        $newDriver = $this->getMockBuilder(Driver::class)->getMock();
         $prop->setValue($conn, $newDriver);
 
         $newDriver->expects($this->once())
@@ -1427,18 +1479,6 @@ class ConnectionTest extends TestCase
         $conn->rollback();
     }
 
-    public function testRoles(): void
-    {
-        $this->assertSame(Connection::ROLE_WRITE, $this->connection->role());
-
-        ConnectionManager::setConfig('test:read', ['url' => getenv('DB_URL')]);
-        $this->assertSame(Connection::ROLE_READ, ConnectionManager::get(ConnectionManager::getName(Connection::ROLE_READ, 'test'))->role());
-
-        // when read connection is only an alias, it should resolve as the write connection
-        ConnectionManager::alias('test', 'test:read');
-        $this->assertSame(Connection::ROLE_WRITE, ConnectionManager::get(ConnectionManager::getName(Connection::ROLE_READ, 'test'))->role());
-    }
-
     public function testAutomaticReconnectWithoutQueryLogging(): void
     {
         $conn = clone $this->connection;
@@ -1451,9 +1491,12 @@ class ConnectionTest extends TestCase
         $statement->execute();
         $statement->closeCursor();
 
-        $prop = new ReflectionProperty($conn, '_driver');
-        $prop->setAccessible(true);
         $newDriver = $this->getMockBuilder(Driver::class)->getMock();
+        $prop = new ReflectionProperty($conn, 'readDriver');
+        $prop->setAccessible(true);
+        $prop->setValue($conn, $newDriver);
+        $prop = new ReflectionProperty($conn, 'writeDriver');
+        $prop->setAccessible(true);
         $prop->setValue($conn, $newDriver);
 
         $newDriver->expects($this->exactly(2))
@@ -1480,9 +1523,13 @@ class ConnectionTest extends TestCase
         $statement->execute();
         $statement->closeCursor();
 
-        $prop = new ReflectionProperty($conn, '_driver');
-        $prop->setAccessible(true);
         $newDriver = $this->getMockBuilder(Driver::class)->getMock();
+        $prop = new ReflectionProperty($conn, 'readDriver');
+        $prop->setAccessible(true);
+        $prop->setValue($conn, $newDriver);
+        $prop = new ReflectionProperty($conn, 'writeDriver');
+        $prop->setAccessible(true);
+        $oldDriver = $prop->getValue($conn);
         $prop->setValue($conn, $newDriver);
 
         $newDriver->expects($this->exactly(2))

+ 1 - 0
tests/TestCase/Database/Log/LoggedQueryTest.php

@@ -158,6 +158,7 @@ class LoggedQueryTest extends TestCase
         $expected = [
             'numRows' => 10,
             'took' => 15,
+            'role' => '',
         ];
         $this->assertSame($expected, $query->getContext());
     }

+ 20 - 8
tests/TestCase/Database/Log/LoggingStatementTest.php

@@ -16,6 +16,8 @@ declare(strict_types=1);
  */
 namespace Cake\Test\TestCase\Database\Log;
 
+use Cake\Database\Connection;
+use Cake\Database\Driver;
 use Cake\Database\DriverInterface;
 use Cake\Database\Log\LoggingStatement;
 use Cake\Database\Log\QueryLogger;
@@ -54,7 +56,10 @@ class LoggingStatementTest extends TestCase
         $inner->method('rowCount')->will($this->returnValue(3));
         $inner->method('execute')->will($this->returnValue(true));
 
-        $driver = $this->getMockBuilder(DriverInterface::class)->getMock();
+        $driver = $this->getMockBuilder(Driver::class)->getMock();
+        $driver->expects($this->any())
+            ->method('getRole')
+            ->will($this->returnValue(Connection::ROLE_WRITE));
         $st = $this->getMockBuilder(LoggingStatement::class)
             ->onlyMethods(['__get'])
             ->setConstructorArgs([$inner, $driver])
@@ -68,7 +73,7 @@ class LoggingStatementTest extends TestCase
 
         $messages = Log::engine('queries')->read();
         $this->assertCount(1, $messages);
-        $this->assertMatchesRegularExpression('/^debug: connection=test duration=\d+ rows=3 SELECT bar FROM foo$/', $messages[0]);
+        $this->assertMatchesRegularExpression('/^debug: connection=test role=write duration=\d+ rows=3 SELECT bar FROM foo$/', $messages[0]);
     }
 
     /**
@@ -80,7 +85,11 @@ class LoggingStatementTest extends TestCase
         $inner->method('rowCount')->will($this->returnValue(4));
         $inner->method('execute')->will($this->returnValue(true));
 
-        $driver = $this->getMockBuilder(DriverInterface::class)->getMock();
+        $driver = $this->getMockBuilder(Driver::class)->getMock();
+        $driver->expects($this->any())
+            ->method('getRole')
+            ->will($this->returnValue(Connection::ROLE_WRITE));
+
         $st = $this->getMockBuilder(LoggingStatement::class)
             ->onlyMethods(['__get'])
             ->setConstructorArgs([$inner, $driver])
@@ -94,7 +103,7 @@ class LoggingStatementTest extends TestCase
 
         $messages = Log::engine('queries')->read();
         $this->assertCount(1, $messages);
-        $this->assertMatchesRegularExpression('/^debug: connection=test duration=\d+ rows=4 SELECT bar FROM foo WHERE x=1 AND y=2$/', $messages[0]);
+        $this->assertMatchesRegularExpression('/^debug: connection=test role=write duration=\d+ rows=4 SELECT bar FROM foo WHERE x=1 AND y=2$/', $messages[0]);
     }
 
     /**
@@ -131,8 +140,8 @@ class LoggingStatementTest extends TestCase
 
         $messages = Log::engine('queries')->read();
         $this->assertCount(2, $messages);
-        $this->assertMatchesRegularExpression("/^debug: connection=test duration=\d+ rows=4 SELECT bar FROM foo WHERE a='1' AND b='2013-01-01'$/", $messages[0]);
-        $this->assertMatchesRegularExpression("/^debug: connection=test duration=\d+ rows=4 SELECT bar FROM foo WHERE a='1' AND b='2014-01-01'$/", $messages[1]);
+        $this->assertMatchesRegularExpression("/^debug: connection=test role= duration=\d+ rows=4 SELECT bar FROM foo WHERE a='1' AND b='2013-01-01'$/", $messages[0]);
+        $this->assertMatchesRegularExpression("/^debug: connection=test role= duration=\d+ rows=4 SELECT bar FROM foo WHERE a='1' AND b='2014-01-01'$/", $messages[1]);
     }
 
     /**
@@ -146,7 +155,10 @@ class LoggingStatementTest extends TestCase
             ->method('execute')
             ->will($this->throwException($exception));
 
-        $driver = $this->getMockBuilder(DriverInterface::class)->getMock();
+        $driver = $this->getMockBuilder(Driver::class)->getMock();
+        $driver->expects($this->any())
+            ->method('getRole')
+            ->will($this->returnValue(Connection::ROLE_WRITE));
         $st = $this->getMockBuilder(LoggingStatement::class)
             ->onlyMethods(['__get'])
             ->setConstructorArgs([$inner, $driver])
@@ -164,7 +176,7 @@ class LoggingStatementTest extends TestCase
 
         $messages = Log::engine('queries')->read();
         $this->assertCount(1, $messages);
-        $this->assertMatchesRegularExpression("/^debug: connection=test duration=\d+ rows=0 SELECT bar FROM foo$/", $messages[0]);
+        $this->assertMatchesRegularExpression("/^debug: connection=test role=write duration=\d+ rows=0 SELECT bar FROM foo$/", $messages[0]);
     }
 
     /**

+ 1 - 1
tests/TestCase/Database/Log/QueryLoggerTest.php

@@ -77,6 +77,6 @@ class QueryLoggerTest extends TestCase
         ]);
         $logger->log(LogLevel::DEBUG, '', compact('query'));
 
-        $this->assertStringContainsString('connection=test duration=', current(Log::engine('queryLoggerTest')->read()));
+        $this->assertStringContainsString('connection=test role= duration=', current(Log::engine('queryLoggerTest')->read()));
     }
 }

+ 10 - 44
tests/TestCase/Database/QueryTest.php

@@ -36,7 +36,6 @@ use Cake\Database\TypeFactory;
 use Cake\Database\TypeMap;
 use Cake\Database\ValueBinder;
 use Cake\Datasource\ConnectionManager;
-use Cake\Datasource\Exception\MissingDatasourceConfigException;
 use Cake\TestSuite\TestCase;
 use DateTime;
 use DateTimeImmutable;
@@ -94,57 +93,24 @@ class QueryTest extends TestCase
         parent::tearDown();
         $this->connection->getDriver()->enableAutoQuoting($this->autoQuote);
         unset($this->connection);
-
-        ConnectionManager::drop('test:read');
     }
 
     public function testConnectionRoles(): void
     {
-        $query = new Query($this->connection);
-
-        // Defaults to write "test" connection
-        $query->useReadRole();
-        $this->assertSame('test', $query->getConnection()->configName());
-        $this->assertSame(Connection::ROLE_WRITE, $query->getConnection()->role());
-
-        ConnectionManager::setConfig('test:read', ['url' => getenv('DB_URL')]);
+        // Defaults to write role
+        $this->assertSame(Connection::ROLE_WRITE, (new Query($this->connection))->getConnectionRole());
 
-        $query->useReadRole();
-        $this->assertSame('test:read', $query->getConnection()->configName());
-        $this->assertSame(Connection::ROLE_READ, $query->getConnection()->role());
+        $selectQuery = $this->connection->selectQuery();
+        $this->assertSame(Connection::ROLE_WRITE, $selectQuery->getConnectionRole());
 
-        $query->useWriteRole();
-        $this->assertSame('test', $query->getConnection()->configName());
-        $this->assertSame(Connection::ROLE_WRITE, $query->getConnection()->role());
-    }
-
-    public function testConnectionRolesManualWriteConnection(): void
-    {
-        $config = $this->connection->config();
-        $config['name'] = 'not-in-manager';
-
-        $query = new Query(new Connection($config));
-        $this->assertSame(Connection::ROLE_WRITE, $query->getConnection()->role());
-
-        $query->useWriteRole();
-        $this->assertSame('not-in-manager', $query->getConnection()->configName());
-        $this->assertSame(Connection::ROLE_WRITE, $query->getConnection()->role());
-
-        $query->useReadRole();
-        $this->assertSame('not-in-manager', $query->getConnection()->configName());
-        $this->assertSame(Connection::ROLE_WRITE, $query->getConnection()->role());
-    }
-
-    public function testConnectionRolesManualReadConnection(): void
-    {
-        $config = $this->connection->config();
-        $config['name'] = 'not-in-manager:read';
+        // Can set read role for select queries
+        $this->assertSame(Connection::ROLE_READ, $selectQuery->setConnectionRole(Connection::ROLE_READ)->getConnectionRole());
 
-        $query = new Query(new Connection($config));
-        $this->assertSame(Connection::ROLE_READ, $query->getConnection()->role());
+        // Can set read role for select queries
+        $this->assertSame(Connection::ROLE_READ, $selectQuery->useReadRole()->getConnectionRole());
 
-        $this->expectException(MissingDatasourceConfigException::class);
-        $query->useWriteRole();
+        // Can set write role for select queries
+        $this->assertSame(Connection::ROLE_WRITE, $selectQuery->useWriteRole()->getConnectionRole());
     }
 
     /**

+ 0 - 67
tests/TestCase/Datasource/ConnectionManagerTest.php

@@ -15,9 +15,7 @@ namespace Cake\Test\TestCase\Datasource;
 
 use BadMethodCallException;
 use Cake\Core\Exception\CakeException;
-use Cake\Datasource\ConnectionInterface;
 use Cake\Datasource\ConnectionManager;
-use Cake\Datasource\Exception\MissingDatasourceConfigException;
 use Cake\Datasource\Exception\MissingDatasourceException;
 use Cake\TestSuite\TestCase;
 use InvalidArgumentException;
@@ -36,9 +34,7 @@ class ConnectionManagerTest extends TestCase
         parent::tearDown();
         $this->clearPlugins();
         ConnectionManager::drop('test_variant');
-        ConnectionManager::drop('missing_write:read');
         ConnectionManager::dropAlias('other_name');
-        ConnectionManager::dropAlias('test:read');
         ConnectionManager::dropAlias('test2');
     }
 
@@ -211,69 +207,6 @@ class ConnectionManagerTest extends TestCase
         $this->assertSame($result, ConnectionManager::get('other_name'));
     }
 
-    public function testWriteRoleName(): void
-    {
-        $writeRole = ConnectionInterface::ROLE_WRITE;
-        $this->assertSame('test', ConnectionManager::getName($writeRole, 'test'));
-        $this->assertSame('test', ConnectionManager::getName($writeRole, 'test:read'));
-        $this->assertSame('default', ConnectionManager::getName($writeRole, 'default'));
-        $this->assertSame('default', ConnectionManager::getName($writeRole, 'default:read'));
-
-        $this->assertSame('test', ConnectionManager::getName($writeRole, 'test', false));
-        $this->assertSame('test', ConnectionManager::getName($writeRole, 'test:read', false));
-        $this->assertSame('default', ConnectionManager::getName($writeRole, 'default', false));
-        $this->assertSame('default', ConnectionManager::getName($writeRole, 'default:read', false));
-
-        ConnectionManager::alias('test', 'test:read');
-        $this->assertSame('test', ConnectionManager::getName($writeRole, 'test:read'));
-        $this->assertSame('default', ConnectionManager::getName($writeRole, 'default:read'));
-
-        $this->assertSame('test', ConnectionManager::getName($writeRole, 'test:read', false));
-        $this->assertSame('default', ConnectionManager::getName($writeRole, 'default:read', false));
-    }
-
-    public function testReadRoleName(): void
-    {
-        $readRole = ConnectionInterface::ROLE_READ;
-        $this->assertSame('test', ConnectionManager::getName($readRole, 'test'));
-        $this->assertSame('test', ConnectionManager::getName($readRole, 'test:read'));
-        $this->assertSame('default', ConnectionManager::getName($readRole, 'default'));
-        $this->assertSame('default', ConnectionManager::getName($readRole, 'default:read'));
-
-        $this->assertSame('test', ConnectionManager::getName($readRole, 'test', false));
-        $this->assertSame('test', ConnectionManager::getName($readRole, 'test:read', false));
-        $this->assertSame('default', ConnectionManager::getName($readRole, 'default', false));
-        $this->assertSame('default', ConnectionManager::getName($readRole, 'default:read', false));
-
-        ConnectionManager::alias('test', 'test:read');
-        $this->assertSame('test:read', ConnectionManager::getName($readRole, 'test'));
-        $this->assertSame('test:read', ConnectionManager::getName($readRole, 'test:read'));
-        // The default alias does not know about test:read
-        $this->assertSame('default', ConnectionManager::getName($readRole, 'default'));
-        $this->assertSame('default', ConnectionManager::getName($readRole, 'default:read'));
-
-        $this->assertSame('test', ConnectionManager::getName($readRole, 'test', false));
-        // With no alias, defaults to the physical test connection
-        $this->assertSame('test', ConnectionManager::getName($readRole, 'test:read', false));
-        $this->assertSame('default', ConnectionManager::getName($readRole, 'default', false));
-        $this->assertSame('default', ConnectionManager::getName($readRole, 'default:read', false));
-
-        ConnectionManager::alias('test', 'default:read');
-        $this->assertSame('default:read', ConnectionManager::getName($readRole, 'default'));
-        $this->assertSame('default', ConnectionManager::getName($readRole, 'default', false));
-        $this->assertSame('default:read', ConnectionManager::getName($readRole, 'default:read'));
-        $this->assertSame('default', ConnectionManager::getName($readRole, 'default:read', false));
-    }
-
-    public function testMissingWriteConnection(): void
-    {
-        $this->expectException(MissingDatasourceConfigException::class);
-        ConnectionManager::setConfig('missing_write:read', [
-            'className' => FakeConnection::class,
-            'database' => ':memory:',
-        ]);
-    }
-
     /**
      * provider for DSN strings.
      *

+ 7 - 2
tests/TestCase/ORM/Association/BelongsToManyTest.php

@@ -17,6 +17,7 @@ declare(strict_types=1);
 namespace Cake\Test\TestCase\ORM\Association;
 
 use Cake\Database\Connection;
+use Cake\Database\Driver;
 use Cake\Database\Expression\QueryExpression;
 use Cake\Datasource\ConnectionManager;
 use Cake\Datasource\EntityInterface;
@@ -206,9 +207,13 @@ class BelongsToManyTest extends TestCase
      */
     public function testJunctionConnection(): void
     {
+        $driver = $this->getMockBuilder(Driver::class)->getMock();
+        $driver->expects($this->once())
+            ->method('enabled')
+            ->will($this->returnValue(true));
+
         $mock = $this->getMockBuilder(Connection::class)
-            ->onlyMethods(['createDriver'])
-            ->setConstructorArgs([['name' => 'other_source']])
+            ->setConstructorArgs([['name' => 'other_source', 'driver' => $driver]])
             ->getMock();
         ConnectionManager::setConfig('other_source', $mock);
         $this->article->setConnection(ConnectionManager::get('other_source'));

+ 29 - 0
tests/test_app/TestApp/Database/Driver/DisabledDriver.php

@@ -0,0 +1,29 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright 2005-2011, Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright 2005-2011, Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.5.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace TestApp\Database\Driver;
+
+use Cake\Database\Driver\Sqlite;
+
+class DisabledDriver extends Sqlite
+{
+    /**
+     * @inheritDoc
+     */
+    public function enabled(): bool
+    {
+        return false;
+    }
+}