diff --git a/config/database.php b/config/database.php index 466ccc5e18ea..dcfe3f033179 100644 --- a/config/database.php +++ b/config/database.php @@ -99,6 +99,14 @@ 'prefix_indexes' => true, 'search_path' => 'public', 'sslmode' => 'prefer', + 'pooled' => env('DB_POOLED', false), + 'direct' => array_filter([ + 'host' => env('DB_DIRECT_HOST'), + 'port' => env('DB_DIRECT_PORT'), + 'username' => env('DB_DIRECT_USERNAME'), + 'password' => env('DB_DIRECT_PASSWORD'), + 'sslmode' => env('DB_DIRECT_SSLMODE'), + ]), ], 'sqlsrv' => [ diff --git a/src/Illuminate/Database/Connection.php b/src/Illuminate/Database/Connection.php index 7b62c355946a..0aa269914264 100755 --- a/src/Illuminate/Database/Connection.php +++ b/src/Illuminate/Database/Connection.php @@ -49,6 +49,13 @@ class Connection implements ConnectionInterface */ protected $readPdo; + /** + * The active PDO connection used for direct connections. + * + * @var \PDO|(\Closure(): \PDO) + */ + protected $directPdo; + /** * The database connection configuration options for reading. * @@ -56,6 +63,13 @@ class Connection implements ConnectionInterface */ protected $readPdoConfig = []; + /** + * The database connection configuration options for direct connections. + * + * @var array + */ + protected $directPdoConfig = []; + /** * The name of the connected database. * @@ -213,7 +227,7 @@ class Connection implements ConnectionInterface /** * The last retrieved PDO read / write type. * - * @var null|'read'|'write' + * @var null|'read'|'write'|'direct' */ protected $latestPdoTypeRetrieved = null; @@ -1059,7 +1073,7 @@ public function reconnectIfMissingConnection() */ public function disconnect() { - $this->setPdo(null)->setReadPdo(null); + $this->setPdo(null)->setReadPdo(null)->setDirectPdo(null); } /** @@ -1327,6 +1341,32 @@ public function getRawReadPdo() return $this->readPdo; } + /** + * Get the current PDO connection used for direct connections. + * + * @return \PDO + */ + public function getDirectPdo() + { + $this->latestPdoTypeRetrieved = 'direct'; + + if ($this->directPdo instanceof Closure) { + return $this->directPdo = call_user_func($this->directPdo); + } + + return $this->directPdo ?: $this->getPdo(); + } + + /** + * Get the current direct PDO connection parameter without executing any reconnect logic. + * + * @return \PDO|\Closure|null + */ + public function getRawDirectPdo() + { + return $this->directPdo; + } + /** * Set the PDO connection. * @@ -1355,6 +1395,19 @@ public function setReadPdo($pdo) return $this; } + /** + * Set the PDO connection used for direct connections. + * + * @param \PDO|\Closure|null $pdo + * @return $this + */ + public function setDirectPdo($pdo) + { + $this->directPdo = $pdo; + + return $this; + } + /** * Set the read PDO connection configuration. * @@ -1368,6 +1421,39 @@ public function setReadPdoConfig(array $config) return $this; } + /** + * Set the direct PDO connection configuration. + * + * @param array $config + * @return $this + */ + public function setDirectPdoConfig(array $config) + { + $this->directPdoConfig = $config; + + return $this; + } + + /** + * Get the direct PDO connection configuration. + * + * @return array + */ + public function getDirectConfig() + { + return $this->directPdoConfig; + } + + /** + * Determine if this connection has a direct PDO connection configured. + * + * @return bool + */ + public function usesDirectConnection() + { + return ! empty($this->directPdoConfig); + } + /** * Set the reconnect instance on the connection. * @@ -1421,9 +1507,11 @@ public function getConfig($option = null) */ protected function getConnectionDetails() { - $config = $this->latestReadWriteTypeUsed() === 'read' - ? $this->readPdoConfig - : $this->config; + $config = match ($this->latestReadWriteTypeUsed()) { + 'read' => $this->readPdoConfig, + 'direct' => $this->directPdoConfig, + default => $this->config, + }; return [ 'driver' => $this->getDriverName(), @@ -1705,7 +1793,7 @@ public function setReadWriteType($readWriteType) /** * Retrieve the latest read / write type used. * - * @return 'read'|'write'|null + * @return 'read'|'write'|'direct'|null */ protected function latestReadWriteTypeUsed() { diff --git a/src/Illuminate/Database/Connectors/ConnectionFactory.php b/src/Illuminate/Database/Connectors/ConnectionFactory.php index 7b21bd0f27a9..12c3e8cf88bc 100755 --- a/src/Illuminate/Database/Connectors/ConnectionFactory.php +++ b/src/Illuminate/Database/Connectors/ConnectionFactory.php @@ -11,6 +11,7 @@ use Illuminate\Database\SqlServerConnection; use Illuminate\Support\Arr; use InvalidArgumentException; +use PDO; use PDOException; class ConnectionFactory @@ -42,6 +43,7 @@ public function __construct(Container $container) public function make(array $config, $name = null) { $config = $this->parseConfig($config, $name); + $config = $this->applyPooledPostgresOptions($config); if (isset($config['read'])) { return $this->createReadWriteConnection($config); @@ -72,9 +74,16 @@ protected function createSingleConnection(array $config) { $pdo = $this->createPdoResolver($config); - return $this->createConnection( + $connection = $this->createConnection( $config['driver'], $pdo, $config['database'], $config['prefix'], $config ); + + if ($this->hasDirectConnection($config)) { + $connection->setDirectPdo($this->createDirectPdo($config)) + ->setDirectPdoConfig($this->getDirectConfig($config)); + } + + return $connection; } /** @@ -87,9 +96,16 @@ protected function createReadWriteConnection(array $config) { $connection = $this->createSingleConnection($this->getWriteConfig($config)); - return $connection + $connection ->setReadPdo($this->createReadPdo($config)) ->setReadPdoConfig($this->getReadConfig($config)); + + if ($this->hasDirectConnection($config)) { + $connection->setDirectPdo($this->createDirectPdo($config)) + ->setDirectPdoConfig($this->getDirectConfig($config)); + } + + return $connection; } /** @@ -129,6 +145,30 @@ protected function getWriteConfig(array $config) ); } + /** + * Create a new PDO instance for direct connections. + * + * @param array $config + * @return \Closure + */ + protected function createDirectPdo(array $config) + { + return $this->createPdoResolver($this->getDirectConfig($config)); + } + + /** + * Get the direct configuration for a connection. + * + * @param array $config + * @return array + */ + protected function getDirectConfig(array $config) + { + return $this->mergeDirectConfig( + $config, $this->getReadWriteConfig($config, 'direct') + ); + } + /** * Get a read / write level configuration. * @@ -155,6 +195,114 @@ protected function mergeReadWriteConfig(array $config, array $merge) return Arr::except(array_merge($config, $merge), ['read', 'write']); } + /** + * Merge a configuration for a direct connection. + * + * @param array $config + * @param array $merge + * @return array + */ + protected function mergeDirectConfig(array $config, array $merge) + { + $direct = Arr::except(array_merge($config, $merge), [ + 'read', 'write', 'direct', 'pooled', 'connect_via_database', 'connect_via_port', + ]); + + if (! isset($direct['options']) || ! is_array($direct['options'])) { + $direct['options'] = []; + } + + $directEmulatePreparesConfigured = isset($merge['options']) && + is_array($merge['options']) && + array_key_exists(PDO::ATTR_EMULATE_PREPARES, $merge['options']); + + if (! $directEmulatePreparesConfigured) { + $direct['options'][PDO::ATTR_EMULATE_PREPARES] = false; + } + + return $direct; + } + + /** + * Apply transaction-pooler options to PostgreSQL connections. + * + * @param array $config + * @return array + */ + protected function applyPooledPostgresOptions(array $config) + { + if (($config['driver'] ?? null) !== 'pgsql') { + return $config; + } + + $hasDirectConnection = ! empty($config['direct']); + + if (! $hasDirectConnection && ($config['pooled'] ?? false) !== true) { + return $config; + } + + if ($hasDirectConnection) { + $config['pooled'] = true; + } + + if (! $hasDirectConnection && ($config['pooled'] ?? false) === true) { + trigger_error( + "Database connection [{$config['name']}] sets 'pooled' => true without a 'direct' endpoint; migrations and DDL will still traverse the transaction pooler.", + E_USER_WARNING + ); + } + + $config = $this->withEmulatedPrepares($config); + + foreach (['read', 'write'] as $type) { + if (! isset($config[$type])) { + continue; + } + + if (isset($config[$type][0])) { + foreach ($config[$type] as $index => $connection) { + if (isset($connection['options'])) { + $config[$type][$index] = $this->withEmulatedPrepares($connection); + } + } + } elseif (isset($config[$type]['options'])) { + $config[$type] = $this->withEmulatedPrepares($config[$type]); + } + } + + return $config; + } + + /** + * Stamp emulated prepares onto a connection configuration when not explicit. + * + * @param array $config + * @return array + */ + protected function withEmulatedPrepares(array $config) + { + if (! isset($config['options']) || ! is_array($config['options'])) { + $config['options'] = []; + } + + if (! array_key_exists(PDO::ATTR_EMULATE_PREPARES, $config['options'] ?? [])) { + $config['options'][PDO::ATTR_EMULATE_PREPARES] = true; + } + + return $config; + } + + /** + * Determine if the configuration has a direct PostgreSQL connection. + * + * @param array $config + * @return bool + */ + protected function hasDirectConnection(array $config) + { + return ($config['driver'] ?? null) === 'pgsql' && ! empty($config['direct']); + } + /** * Create a new Closure that resolves to a PDO instance. * diff --git a/src/Illuminate/Database/Console/Concerns/ResolvesDirectConnection.php b/src/Illuminate/Database/Console/Concerns/ResolvesDirectConnection.php new file mode 100644 index 000000000000..9f93b5081fa0 --- /dev/null +++ b/src/Illuminate/Database/Console/Concerns/ResolvesDirectConnection.php @@ -0,0 +1,25 @@ +getDefaultConnection(); + $connection = $connections->connection($name); + + return $connection->usesDirectConnection() && ! Str::endsWith($name, ['::read', '::write', '::direct']) + ? $connections->connection($name.'::direct') + : $connection; + } +} diff --git a/src/Illuminate/Database/Console/DbCommand.php b/src/Illuminate/Database/Console/DbCommand.php index 30176073558d..92218054fa31 100644 --- a/src/Illuminate/Database/Console/DbCommand.php +++ b/src/Illuminate/Database/Console/DbCommand.php @@ -3,6 +3,7 @@ namespace Illuminate\Database\Console; use Illuminate\Console\Command; +use Illuminate\Support\Arr; use Illuminate\Support\ConfigurationUrlParser; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Process\Exception\ProcessFailedException; @@ -19,7 +20,8 @@ class DbCommand extends Command */ protected $signature = 'db {connection? : The database connection that should be used} {--read : Connect to the read connection} - {--write : Connect to the write connection}'; + {--write : Connect to the write connection} + {--pooled : Connect to the pooled connection}'; /** * The console command description. @@ -86,22 +88,48 @@ public function getConnection() } if ($this->option('read')) { - if (is_array($connection['read']['host'])) { - $connection['read']['host'] = $connection['read']['host'][0]; - } - - $connection = array_merge($connection, $connection['read']); + $connection = $this->mergeConnectionConfiguration($connection, 'read'); } elseif ($this->option('write')) { - if (is_array($connection['write']['host'])) { - $connection['write']['host'] = $connection['write']['host'][0]; - } - - $connection = array_merge($connection, $connection['write']); + $connection = $this->mergeConnectionConfiguration($connection, 'write'); + } elseif (! $this->option('pooled') && ($connection['driver'] ?? null) === 'pgsql' && ($connection['pooled'] ?? false) === true && ! empty($connection['direct'])) { + $connection = $this->mergeConnectionConfiguration($connection, 'direct'); } return $connection; } + /** + * Merge a nested connection configuration onto the base connection. + * + * @param array $connection + * @param string $type + * @return array + */ + protected function mergeConnectionConfiguration(array $connection, $type) + { + if (empty($connection[$type])) { + return $connection; + } + + $merge = $connection[$type]; + + if (isset($merge[0]) && is_array($merge[0])) { + $merge = $merge[0]; + } + + if (is_array($merge['host'] ?? null)) { + $merge['host'] = $merge['host'][0]; + } + + $connection = array_merge($connection, $merge); + + if (is_array($connection['host'] ?? null)) { + $connection['host'] = $connection['host'][0]; + } + + return Arr::except($connection, ['read', 'write', 'direct', 'pooled']); + } + /** * Get the arguments for the database client command. * diff --git a/src/Illuminate/Database/Console/ShowCommand.php b/src/Illuminate/Database/Console/ShowCommand.php index 64c80572b927..fa27087d0187 100644 --- a/src/Illuminate/Database/Console/ShowCommand.php +++ b/src/Illuminate/Database/Console/ShowCommand.php @@ -4,6 +4,7 @@ use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionResolverInterface; +use Illuminate\Database\Console\Concerns\ResolvesDirectConnection; use Illuminate\Database\Schema\Builder; use Illuminate\Support\Arr; use Illuminate\Support\Collection; @@ -13,6 +14,8 @@ #[AsCommand(name: 'db:show')] class ShowCommand extends DatabaseInspectionCommand { + use ResolvesDirectConnection; + /** * The name and signature of the console command. * @@ -39,7 +42,7 @@ class ShowCommand extends DatabaseInspectionCommand */ public function handle(ConnectionResolverInterface $connections) { - $connection = $connections->connection($database = $this->input->getOption('database')); + $connection = $this->resolveConnection($connections, $database = $this->input->getOption('database')); $schema = $connection->getSchemaBuilder(); diff --git a/src/Illuminate/Database/Console/TableCommand.php b/src/Illuminate/Database/Console/TableCommand.php index ecfa00a9e164..4c0102bad5bc 100644 --- a/src/Illuminate/Database/Console/TableCommand.php +++ b/src/Illuminate/Database/Console/TableCommand.php @@ -3,6 +3,7 @@ namespace Illuminate\Database\Console; use Illuminate\Database\ConnectionResolverInterface; +use Illuminate\Database\Console\Concerns\ResolvesDirectConnection; use Illuminate\Database\Schema\Builder; use Illuminate\Support\Arr; use Illuminate\Support\Collection; @@ -14,6 +15,8 @@ #[AsCommand(name: 'db:table')] class TableCommand extends DatabaseInspectionCommand { + use ResolvesDirectConnection; + /** * The name and signature of the console command. * @@ -38,7 +41,7 @@ class TableCommand extends DatabaseInspectionCommand */ public function handle(ConnectionResolverInterface $connections) { - $connection = $connections->connection($this->input->getOption('database')); + $connection = $this->resolveConnection($connections, $this->input->getOption('database')); $tables = (new Collection($connection->getSchemaBuilder()->getTables())) ->keyBy('schema_qualified_name')->all(); diff --git a/src/Illuminate/Database/Console/WipeCommand.php b/src/Illuminate/Database/Console/WipeCommand.php index d638db41d0a4..aa29d238627c 100644 --- a/src/Illuminate/Database/Console/WipeCommand.php +++ b/src/Illuminate/Database/Console/WipeCommand.php @@ -5,13 +5,14 @@ use Illuminate\Console\Command; use Illuminate\Console\ConfirmableTrait; use Illuminate\Console\Prohibitable; +use Illuminate\Database\Console\Concerns\ResolvesDirectConnection; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'db:wipe')] class WipeCommand extends Command { - use ConfirmableTrait, Prohibitable; + use ConfirmableTrait, Prohibitable, ResolvesDirectConnection; /** * The console command name. @@ -70,7 +71,7 @@ public function handle() */ protected function dropAllTables($database) { - $this->laravel['db']->connection($database) + $this->resolveConnection($this->laravel['db'], $database) ->getSchemaBuilder() ->dropAllTables(); } @@ -83,7 +84,7 @@ protected function dropAllTables($database) */ protected function dropAllViews($database) { - $this->laravel['db']->connection($database) + $this->resolveConnection($this->laravel['db'], $database) ->getSchemaBuilder() ->dropAllViews(); } @@ -96,7 +97,7 @@ protected function dropAllViews($database) */ protected function dropAllTypes($database) { - $this->laravel['db']->connection($database) + $this->resolveConnection($this->laravel['db'], $database) ->getSchemaBuilder() ->dropAllTypes(); } @@ -109,7 +110,7 @@ protected function dropAllTypes($database) */ protected function flushDatabaseConnection($database) { - $this->laravel['db']->connection($database)->disconnect(); + $this->resolveConnection($this->laravel['db'], $database)->disconnect(); } /** diff --git a/src/Illuminate/Database/DatabaseManager.php b/src/Illuminate/Database/DatabaseManager.php index 0afeb7d41fd8..0bc577a361cc 100755 --- a/src/Illuminate/Database/DatabaseManager.php +++ b/src/Illuminate/Database/DatabaseManager.php @@ -176,7 +176,7 @@ public function connectUsing(UnitEnum|string $name, array $config, bool $force = */ protected function parseConnectionName($name) { - return Str::endsWith($name, ['::read', '::write']) + return Str::endsWith($name, ['::read', '::write', '::direct']) ? explode('::', $name, 2) : [$name, null]; } @@ -290,6 +290,9 @@ protected function setPdoForType(Connection $connection, $type = null) $connection->setPdo($connection->getReadPdo()); } elseif ($type === 'write') { $connection->setReadPdo($connection->getPdo()); + } elseif ($type === 'direct') { + $connection->setPdo($connection->getDirectPdo()) + ->setReadPdo($connection->getDirectPdo()); } return $connection; @@ -376,7 +379,8 @@ protected function refreshPdoConnections($name) return $this->connections[$name] ->setPdo($fresh->getRawPdo()) - ->setReadPdo($fresh->getRawReadPdo()); + ->setReadPdo($fresh->getRawReadPdo()) + ->setDirectPdo($fresh->getRawDirectPdo()); } /** diff --git a/src/Illuminate/Database/Events/QueryExecuted.php b/src/Illuminate/Database/Events/QueryExecuted.php index d9209dfd591a..49b620734046 100644 --- a/src/Illuminate/Database/Events/QueryExecuted.php +++ b/src/Illuminate/Database/Events/QueryExecuted.php @@ -42,7 +42,7 @@ class QueryExecuted /** * The PDO read / write type for the executed query. * - * @var null|'read'|'write' + * @var null|'read'|'write'|'direct' */ public $readWriteType; @@ -53,7 +53,7 @@ class QueryExecuted * @param array $bindings * @param float|null $time * @param \Illuminate\Database\Connection $connection - * @param null|'read'|'write' $readWriteType + * @param null|'read'|'write'|'direct' $readWriteType */ public function __construct($sql, $bindings, $time, $connection, $readWriteType = null) { diff --git a/src/Illuminate/Database/Migrations/Migrator.php b/src/Illuminate/Database/Migrations/Migrator.php index 2f89c0ca10cf..8670ab1d038e 100755 --- a/src/Illuminate/Database/Migrations/Migrator.php +++ b/src/Illuminate/Database/Migrations/Migrator.php @@ -62,7 +62,7 @@ class Migrator /** * The name of the default connection. * - * @var string + * @var string|null */ protected $connection; @@ -512,7 +512,7 @@ protected function runMethod($connection, $migration, $method) $previousConnection = $this->resolver->getDefaultConnection(); try { - $this->resolver->setDefaultConnection($connection->getName()); + $this->resolver->setDefaultConnection($connection->getNameWithReadWriteType()); $migration->{$method}(); } finally { @@ -645,7 +645,7 @@ public static function withoutMigrations(array $migrations) /** * Get the default connection name. * - * @return string + * @return string|null */ public function getConnection() { @@ -677,16 +677,30 @@ public function usingConnection($name, callable $callback) /** * Set the default connection name. * - * @param string $name + * @param string|null $name * @return void */ public function setConnection($name) { - if (! is_null($name)) { - $this->resolver->setDefaultConnection($name); + if (is_null($name)) { + $defaultName = $this->resolver->getDefaultConnection(); + $directName = $this->directConnectionName($defaultName); + + if ($directName === $defaultName) { + $this->repository->setSource(null); + + $this->connection = null; + + return; + } + + $name = $directName; + } else { + $name = $this->directConnectionName($name); } $this->repository->setSource($name); + $this->resolver->setDefaultConnection($name); $this->connection = $name; } @@ -694,7 +708,10 @@ public function setConnection($name) /** * Resolve the database connection instance. * - * @param string $connection + * The default resolver path routes configured direct PostgreSQL connections + * to their direct variant; custom resolver callbacks keep full priority. + * + * @param string|null $connection * @return \Illuminate\Database\Connection */ public function resolveConnection($connection) @@ -706,10 +723,31 @@ public function resolveConnection($connection) $connection ?: $this->connection ); } else { - return $this->resolver->connection($connection ?: $this->connection); + return $this->resolver->connection( + $this->directConnectionName($connection ?: $this->connection) + ); } } + /** + * Resolve the direct connection variant when one is configured. + * + * @param string|null $name + * @return string + */ + protected function directConnectionName($name) + { + $name ??= $this->resolver->getDefaultConnection(); + + if (Str::endsWith($name, ['::read', '::write', '::direct'])) { + return $name; + } + + return $this->resolver->connection($name)->usesDirectConnection() + ? $name.'::direct' + : $name; + } + /** * Set a connection resolver callback. * diff --git a/src/Illuminate/Database/PostgresConnection.php b/src/Illuminate/Database/PostgresConnection.php index 1c2b372c622a..b996b0ac5af7 100755 --- a/src/Illuminate/Database/PostgresConnection.php +++ b/src/Illuminate/Database/PostgresConnection.php @@ -2,6 +2,7 @@ namespace Illuminate\Database; +use DateTimeInterface; use Exception; use Illuminate\Database\Query\Grammars\PostgresGrammar as QueryGrammar; use Illuminate\Database\Query\Processors\PostgresProcessor; @@ -9,9 +10,50 @@ use Illuminate\Database\Schema\PostgresBuilder; use Illuminate\Database\Schema\PostgresSchemaState; use Illuminate\Filesystem\Filesystem; +use PDO; class PostgresConnection extends Connection { + /** + * Prepare the query bindings for execution. + * + * @param array $bindings + * @return array + */ + public function prepareBindings(array $bindings) + { + $grammar = $this->getQueryGrammar(); + + foreach ($bindings as $key => $value) { + if ($value instanceof DateTimeInterface) { + $bindings[$key] = $value->format($grammar->getDateFormat()); + } elseif (is_bool($value)) { + $bindings[$key] = $this->usesEmulatedPrepares() + ? ($value ? 'true' : 'false') + : (int) $value; + } + } + + return $bindings; + } + + /** + * Determine if the active PDO configuration uses emulated prepares. + * + * @return bool + */ + protected function usesEmulatedPrepares() + { + // Binding preparation runs after query routing has selected the PDO variant. + $config = match ($this->latestReadWriteTypeUsed()) { + 'read' => $this->readPdoConfig, + 'direct' => $this->directPdoConfig, + default => $this->config, + }; + + return (bool) ($config['options'][PDO::ATTR_EMULATE_PREPARES] ?? false); + } + /** * {@inheritdoc} */ diff --git a/src/Illuminate/Database/QueryException.php b/src/Illuminate/Database/QueryException.php index ba46baa97bc0..90877435c5b9 100644 --- a/src/Illuminate/Database/QueryException.php +++ b/src/Illuminate/Database/QueryException.php @@ -33,7 +33,7 @@ class QueryException extends PDOException /** * The PDO read / write type for the executed query. * - * @var null|'read'|'write' + * @var null|'read'|'write'|'direct' */ public $readWriteType; @@ -52,7 +52,7 @@ class QueryException extends PDOException * @param array $bindings * @param \Throwable $previous * @param array $connectionDetails - * @param null|'read'|'write' $readWriteType + * @param null|'read'|'write'|'direct' $readWriteType */ public function __construct($connectionName, $sql, array $bindings, Throwable $previous, array $connectionDetails = [], $readWriteType = null) { diff --git a/src/Illuminate/Database/Schema/PostgresSchemaState.php b/src/Illuminate/Database/Schema/PostgresSchemaState.php index 25da812e61c5..60b8a057cfc7 100644 --- a/src/Illuminate/Database/Schema/PostgresSchemaState.php +++ b/src/Illuminate/Database/Schema/PostgresSchemaState.php @@ -82,14 +82,25 @@ protected function baseDumpCommand() */ protected function baseVariables(array $config) { + if ($this->connection->usesDirectConnection()) { + $config = $this->connection->getDirectConfig(); + } + $config['host'] ??= ''; - return [ + $variables = [ 'LARAVEL_LOAD_HOST' => is_array($config['host']) ? $config['host'][0] : $config['host'], 'LARAVEL_LOAD_PORT' => $config['port'] ?? '', 'LARAVEL_LOAD_USER' => $config['username'], 'PGPASSWORD' => $config['password'], - 'LARAVEL_LOAD_DATABASE' => $config['database'], ]; + + if (! empty($config['sslmode'])) { + $variables['PGSSLMODE'] = $config['sslmode']; + } + + $variables['LARAVEL_LOAD_DATABASE'] = $config['database']; + + return $variables; } } diff --git a/src/Illuminate/Support/Facades/DB.php b/src/Illuminate/Support/Facades/DB.php index 3da739b441ba..c18738456134 100644 --- a/src/Illuminate/Support/Facades/DB.php +++ b/src/Illuminate/Support/Facades/DB.php @@ -74,9 +74,15 @@ * @method static \PDO|\Closure|null getRawPdo() * @method static \PDO getReadPdo() * @method static \PDO|\Closure|null getRawReadPdo() + * @method static \PDO getDirectPdo() + * @method static \PDO|\Closure|null getRawDirectPdo() * @method static \Illuminate\Database\Connection setPdo(\PDO|\Closure|null $pdo) * @method static \Illuminate\Database\Connection setReadPdo(\PDO|\Closure|null $pdo) + * @method static \Illuminate\Database\Connection setDirectPdo(\PDO|\Closure|null $pdo) * @method static \Illuminate\Database\Connection setReadPdoConfig(array $config) + * @method static \Illuminate\Database\Connection setDirectPdoConfig(array $config) + * @method static array getDirectConfig() + * @method static bool usesDirectConnection() * @method static string|null getName() * @method static string|null getNameWithReadWriteType() * @method static mixed getConfig(string|null $option = null) diff --git a/tests/Database/DatabaseConnectionFactoryTest.php b/tests/Database/DatabaseConnectionFactoryTest.php index c69cd9daacf7..6091f4b55c49 100755 --- a/tests/Database/DatabaseConnectionFactoryTest.php +++ b/tests/Database/DatabaseConnectionFactoryTest.php @@ -8,7 +8,9 @@ use InvalidArgumentException; use Mockery as m; use PDO; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionMethod; use ReflectionProperty; class DatabaseConnectionFactoryTest extends TestCase @@ -114,6 +116,229 @@ public function testReadWriteConnectionSetsReadPdoConfig() $this->assertSame(':memory:', $config['database']); } + public function testPostgresDirectConnectionConfigurationIsAttached() + { + $this->db->addConnection([ + 'driver' => 'pgsql', + 'host' => 'pooler-host', + 'port' => '6432', + 'database' => 'laravel', + 'username' => 'pooler-user', + 'password' => 'pooler-password', + 'prefix' => '', + 'connect_via_database' => 'pooler_database', + 'connect_via_port' => '6432', + 'direct' => [ + 'host' => 'direct-host', + 'port' => '5432', + 'username' => 'direct-user', + 'password' => 'direct-password', + 'sslmode' => 'require', + ], + ], 'pooled_pgsql'); + + $connection = $this->db->getConnection('pooled_pgsql'); + $directPdo = new ReflectionProperty(get_class($connection), 'directPdo'); + + $this->assertTrue($connection->usesDirectConnection()); + $this->assertNotInstanceOf(PDO::class, $directPdo->getValue($connection)); + $this->assertTrue($connection->getConfig('pooled')); + $this->assertTrue($connection->getConfig('options')[PDO::ATTR_EMULATE_PREPARES]); + + $directConfig = $connection->getDirectConfig(); + + $this->assertSame('direct-host', $directConfig['host']); + $this->assertSame('5432', $directConfig['port']); + $this->assertSame('direct-user', $directConfig['username']); + $this->assertSame('direct-password', $directConfig['password']); + $this->assertSame('require', $directConfig['sslmode']); + $this->assertSame('laravel', $directConfig['database']); + $this->assertFalse($directConfig['options'][PDO::ATTR_EMULATE_PREPARES]); + $this->assertArrayNotHasKey('connect_via_database', $directConfig); + $this->assertArrayNotHasKey('connect_via_port', $directConfig); + } + + public function testPostgresDirectConnectionConfigurationInheritsBaseCredentialsWhenNotConfigured() + { + $this->db->addConnection([ + 'driver' => 'pgsql', + 'host' => 'pooler-host', + 'port' => '6432', + 'database' => 'laravel', + 'username' => 'pooler-user', + 'password' => 'pooler-password', + 'prefix' => '', + 'direct' => [ + 'host' => 'direct-host', + 'port' => '5432', + ], + ], 'pooled_pgsql_inherited_credentials'); + + $directConfig = $this->db->getConnection('pooled_pgsql_inherited_credentials')->getDirectConfig(); + + $this->assertSame('pooler-user', $directConfig['username']); + $this->assertSame('pooler-password', $directConfig['password']); + } + + public function testPostgresDirectConnectionConfigurationCanOverridePortAndUsernameWithoutHost() + { + $this->db->addConnection([ + 'driver' => 'pgsql', + 'host' => 'same-host', + 'port' => '6432', + 'database' => 'laravel', + 'username' => 'pooler-user|pooler', + 'password' => 'shared-password', + 'prefix' => '', + 'direct' => [ + 'port' => '5432', + 'username' => 'direct-user', + ], + ], 'pooled_pgsql_same_host'); + + $directConfig = $this->db->getConnection('pooled_pgsql_same_host')->getDirectConfig(); + + $this->assertSame('same-host', $directConfig['host']); + $this->assertSame('5432', $directConfig['port']); + $this->assertSame('direct-user', $directConfig['username']); + $this->assertSame('shared-password', $directConfig['password']); + } + + public function testNonPostgresDirectConfigurationIsIgnored() + { + $this->db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'direct' => [ + 'database' => ':memory:', + ], + ], 'sqlite_direct'); + + $this->assertFalse($this->db->getConnection('sqlite_direct')->usesDirectConnection()); + } + + #[DataProvider('pooledPostgresEmulatePreparesProvider')] + public function testPooledPostgresEmulatePreparesPrecedence($baseOption, $directOption, $expectedPooledOption, $expectedDirectOption) + { + $config = [ + 'driver' => 'pgsql', + 'name' => 'pgsql', + 'host' => 'pooler-host', + 'port' => '6432', + 'database' => 'laravel', + 'username' => 'root', + 'password' => '', + 'prefix' => '', + 'direct' => [ + 'host' => 'direct-host', + ], + ]; + + if (! is_null($baseOption)) { + $config['options'][PDO::ATTR_EMULATE_PREPARES] = $baseOption; + } + + if (! is_null($directOption)) { + $config['direct']['options'][PDO::ATTR_EMULATE_PREPARES] = $directOption; + } + + $config = $this->callConnectionFactoryMethod('applyPooledPostgresOptions', $config); + $directConfig = $this->callConnectionFactoryMethod('getDirectConfig', $config); + + $this->assertSame($expectedPooledOption, $config['options'][PDO::ATTR_EMULATE_PREPARES]); + $this->assertSame($expectedDirectOption, $directConfig['options'][PDO::ATTR_EMULATE_PREPARES]); + } + + public static function pooledPostgresEmulatePreparesProvider() + { + return [ + 'base missing, direct missing' => [null, null, true, false], + 'base missing, direct true' => [null, true, true, true], + 'base missing, direct false' => [null, false, true, false], + 'base true, direct missing' => [true, null, true, false], + 'base true, direct true' => [true, true, true, true], + 'base true, direct false' => [true, false, true, false], + 'base false, direct missing' => [false, null, false, false], + 'base false, direct true' => [false, true, false, true], + 'base false, direct false' => [false, false, false, false], + ]; + } + + public function testPooledPostgresOptionsAreAppliedToReadAndWriteConfigurations() + { + $config = $this->callConnectionFactoryMethod('applyPooledPostgresOptions', [ + 'driver' => 'pgsql', + 'name' => 'pgsql', + 'host' => 'pooler-host', + 'database' => 'laravel', + 'username' => 'root', + 'password' => '', + 'prefix' => '', + 'read' => [ + 'host' => 'read-pooler-host', + 'options' => [ + PDO::ATTR_TIMEOUT => 5, + ], + ], + 'write' => [[ + 'host' => 'write-pooler-host', + 'options' => [ + PDO::ATTR_TIMEOUT => 10, + ], + ]], + 'direct' => [ + 'host' => 'direct-host', + ], + ]); + + $readConfig = $this->callConnectionFactoryMethod('getReadConfig', $config); + $writeConfig = $this->callConnectionFactoryMethod('getWriteConfig', $config); + $directConfig = $this->callConnectionFactoryMethod('getDirectConfig', $config); + + $this->assertSame('read-pooler-host', $readConfig['host']); + $this->assertSame(5, $readConfig['options'][PDO::ATTR_TIMEOUT]); + $this->assertTrue($readConfig['options'][PDO::ATTR_EMULATE_PREPARES]); + + $this->assertSame('write-pooler-host', $writeConfig['host']); + $this->assertSame(10, $writeConfig['options'][PDO::ATTR_TIMEOUT]); + $this->assertTrue($writeConfig['options'][PDO::ATTR_EMULATE_PREPARES]); + + $this->assertSame('direct-host', $directConfig['host']); + $this->assertFalse($directConfig['options'][PDO::ATTR_EMULATE_PREPARES]); + $this->assertArrayNotHasKey('read', $directConfig); + $this->assertArrayNotHasKey('write', $directConfig); + } + + public function testPooledPostgresWithoutDirectEndpointEmitsWarning() + { + $warning = null; + + set_error_handler(function ($level, $message) use (&$warning) { + $warning = [$level, $message]; + + return true; + }, E_USER_WARNING); + + try { + $config = $this->callConnectionFactoryMethod('applyPooledPostgresOptions', [ + 'driver' => 'pgsql', + 'name' => 'pgsql', + 'host' => 'pooler-host', + 'database' => 'laravel', + 'username' => 'root', + 'password' => '', + 'prefix' => '', + 'pooled' => true, + ]); + } finally { + restore_error_handler(); + } + + $this->assertSame(E_USER_WARNING, $warning[0]); + $this->assertStringContainsString("sets 'pooled' => true without a 'direct' endpoint", $warning[1]); + $this->assertTrue($config['options'][PDO::ATTR_EMULATE_PREPARES]); + } + public function testIfDriverIsntSetExceptionIsThrown() { $this->expectException(InvalidArgumentException::class); @@ -175,4 +400,12 @@ public function testSqliteSynchronous() $this->assertSame(1, $this->db->getConnection('synchronous_set')->select('PRAGMA synchronous')[0]->synchronous); } + + protected function callConnectionFactoryMethod($method, ...$arguments) + { + return (new ReflectionMethod(ConnectionFactory::class, $method))->invoke( + new ConnectionFactory(m::mock(Container::class)), + ...$arguments + ); + } } diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index f91806bc6a65..6a1e5f9bb7fc 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -756,6 +756,93 @@ public function testQueryExceptionContainsWriteConnectionDetailsWhenWritePdoConn } } + public function testDirectPdoCanBeSetAndResolved() + { + $connection = new Connection(new DatabaseConnectionTestMockPDO); + $directPdo = new DatabaseConnectionTestMockPDO; + + $connection->setDirectPdo(function () use ($directPdo) { + return $directPdo; + }); + + $this->assertSame($directPdo, $connection->getDirectPdo()); + $this->assertSame($directPdo, $connection->getRawDirectPdo()); + } + + public function testDirectConnectionConfigurationCanBeSet() + { + $connection = new Connection(new DatabaseConnectionTestMockPDO); + + $this->assertFalse($connection->usesDirectConnection()); + + $connection->setDirectPdoConfig($config = [ + 'host' => 'direct-host', + 'database' => 'direct_db', + ]); + + $this->assertTrue($connection->usesDirectConnection()); + $this->assertSame($config, $connection->getDirectConfig()); + } + + public function testDisconnectClearsDirectPdo() + { + $connection = new Connection(new DatabaseConnectionTestMockPDO); + + $connection->setDirectPdo(new DatabaseConnectionTestMockPDO); + $connection->disconnect(); + + $this->assertNull($connection->getRawDirectPdo()); + } + + public function testNameWithReadWriteTypeIncludesDirectType() + { + $connection = new Connection(new DatabaseConnectionTestMockPDO, 'database', '', [ + 'name' => 'pgsql', + ]); + + $connection->setReadWriteType('direct'); + + $this->assertSame('pgsql::direct', $connection->getNameWithReadWriteType()); + } + + public function testQueryExceptionContainsDirectConnectionDetailsWhenUsingDirectConnection() + { + $directPdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class) + ->onlyMethods(['prepare']) + ->getMock(); + $directPdo->expects($this->once()) + ->method('prepare') + ->willThrowException(new PDOException('Connection refused')); + + $connection = new Connection($directPdo, 'write_db', '', [ + 'driver' => 'pgsql', + 'name' => 'pgsql', + 'host' => 'pooler-host', + 'port' => '6432', + 'database' => 'write_db', + ]); + $connection->useDefaultQueryGrammar(); + $connection->useDefaultPostProcessor(); + $connection->setReadWriteType('direct'); + $connection->setDirectPdoConfig([ + 'host' => 'direct-host', + 'port' => '5432', + 'database' => 'direct_db', + ]); + + try { + $connection->select('SELECT * FROM users', useReadPdo: false); + $this->fail('Expected QueryException was not thrown'); + } catch (QueryException $e) { + $this->assertSame('direct', $e->readWriteType); + + $connectionDetails = $e->getConnectionDetails(); + $this->assertSame('direct-host', $connectionDetails['host']); + $this->assertSame('5432', $connectionDetails['port']); + $this->assertSame('direct_db', $connectionDetails['database']); + } + } + protected function getMockConnection($methods = [], $pdo = null) { $pdo = $pdo ?: new DatabaseConnectionTestMockPDO; diff --git a/tests/Database/DatabaseConsoleDirectConnectionTest.php b/tests/Database/DatabaseConsoleDirectConnectionTest.php new file mode 100644 index 000000000000..6c0348035b4a --- /dev/null +++ b/tests/Database/DatabaseConsoleDirectConnectionTest.php @@ -0,0 +1,153 @@ +shouldReceive('getDefaultConnection')->once()->andReturn('pgsql'); + $resolver->shouldReceive('connection')->once()->with('pgsql')->andReturn($baseConnection); + $baseConnection->shouldReceive('usesDirectConnection')->once()->andReturn(true); + $resolver->shouldReceive('connection')->once()->with('pgsql::direct')->andReturn($directConnection); + + $this->assertSame($directConnection, $command->resolve($resolver, null)); + } + + public function testResolvesDirectConnectionConcernPassesThroughWhenNoDirectVariantIsConfigured() + { + $resolver = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(Connection::class); + $command = new DatabaseConsoleDirectConnectionTestCommand; + + $resolver->shouldReceive('connection')->once()->with('sqlite')->andReturn($connection); + $connection->shouldReceive('usesDirectConnection')->once()->andReturn(false); + + $this->assertSame($connection, $command->resolve($resolver, 'sqlite')); + } + + public function testResolvesDirectConnectionConcernPassesThroughExplicitSuffixes() + { + $resolver = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(Connection::class); + $command = new DatabaseConsoleDirectConnectionTestCommand; + + $resolver->shouldReceive('connection')->once()->with('pgsql::write')->andReturn($connection); + $connection->shouldReceive('usesDirectConnection')->once()->andReturn(true); + + $this->assertSame($connection, $command->resolve($resolver, 'pgsql::write')); + } + + public function testDbCommandUsesBasePostgresConnectionWhenDirectEndpointExistsWithoutPooledMode() + { + $connection = $this->dbCommand()->getConnection(); + + $this->assertSame('pooler-host', $connection['host']); + $this->assertSame('6432', $connection['port']); + $this->assertArrayHasKey('direct', $connection); + } + + public function testDbCommandDefaultsToDirectPostgresConnectionWhenPooledModeIsEnabled() + { + $connection = $this->dbCommand(pooled: true)->getConnection(); + + $this->assertSame('direct-host', $connection['host']); + $this->assertSame('5432', $connection['port']); + $this->assertSame('direct-user', $connection['username']); + $this->assertSame('direct-password', $connection['password']); + $this->assertSame('require', $connection['sslmode']); + $this->assertSame('laravel', $connection['database']); + $this->assertArrayNotHasKey('direct', $connection); + } + + public function testDbCommandPooledOptionUsesBasePooledConnection() + { + $connection = $this->dbCommand(['--pooled' => true])->getConnection(); + + $this->assertSame('pooler-host', $connection['host']); + $this->assertSame('6432', $connection['port']); + } + + public function testDbCommandReadAndWriteOptionsUsePooledConnectionBranches() + { + $readConnection = $this->dbCommand(['--read' => true], pooled: true)->getConnection(); + $writeConnection = $this->dbCommand(['--write' => true], pooled: true)->getConnection(); + + $this->assertSame('read-pooler-host', $readConnection['host']); + $this->assertSame('6433', $readConnection['port']); + $this->assertSame('write-pooler-host', $writeConnection['host']); + $this->assertSame('6434', $writeConnection['port']); + } + + protected function dbCommand(array $input = [], bool $pooled = false) + { + $command = new DbCommand; + $command->setLaravel($this->application($pooled)); + $command->setInput(new ArrayInput($input, $command->getDefinition())); + + return $command; + } + + protected function application(bool $pooled = false) + { + $app = new Application; + $app->instance('config', new Config([ + 'database' => [ + 'default' => 'pgsql', + 'connections' => [ + 'pgsql' => [ + 'driver' => 'pgsql', + 'host' => 'pooler-host', + 'port' => '6432', + 'database' => 'laravel', + 'username' => 'root', + 'password' => '', + 'pooled' => $pooled, + 'read' => [ + 'host' => ['read-pooler-host', 'read-pooler-host-2'], + 'port' => '6433', + ], + 'write' => [ + 'host' => 'write-pooler-host', + 'port' => '6434', + ], + 'direct' => [ + 'host' => ['direct-host', 'direct-host-2'], + 'port' => '5432', + 'username' => 'direct-user', + 'password' => 'direct-password', + 'sslmode' => 'require', + ], + ], + ], + ], + ])); + + return $app; + } +} + +class DatabaseConsoleDirectConnectionTestCommand +{ + use ResolvesDirectConnection; + + public function resolve($connections, $database) + { + return $this->resolveConnection($connections, $database); + } +} diff --git a/tests/Database/DatabaseManagerTest.php b/tests/Database/DatabaseManagerTest.php new file mode 100644 index 000000000000..fd0e42d8448d --- /dev/null +++ b/tests/Database/DatabaseManagerTest.php @@ -0,0 +1,95 @@ +assertSame(['pgsql', 'direct'], $manager->parseConnectionNamePublic('pgsql::direct')); + $this->assertSame(['pgsql', 'read'], $manager->parseConnectionNamePublic('pgsql::read')); + $this->assertSame(['pgsql', null], $manager->parseConnectionNamePublic('pgsql')); + } + + public function testSetPdoForDirectTypeSetsReadAndWritePdosToDirectPdo() + { + $manager = new DatabaseManagerTestManager(new Container, m::mock(ConnectionFactory::class)); + $connection = new Connection(new DatabaseManagerTestMockPDO); + $directPdo = new DatabaseManagerTestMockPDO; + + $connection->setDirectPdo($directPdo); + + $manager->setPdoForTypePublic($connection, 'direct'); + + $this->assertSame($directPdo, $connection->getPdo()); + $this->assertSame($directPdo, $connection->getReadPdo()); + } + + public function testRefreshPdoConnectionsRefreshesDirectPdo() + { + $manager = new DatabaseManagerTestManager(new Container, m::mock(ConnectionFactory::class)); + $connection = new Connection(new DatabaseManagerTestMockPDO, 'database', '', ['name' => 'pgsql']); + $freshDirectPdo = new DatabaseManagerTestMockPDO; + $freshConnection = new Connection(new DatabaseManagerTestMockPDO, 'database', '', ['name' => 'pgsql']); + $freshConnection->setReadPdo(new DatabaseManagerTestMockPDO); + $freshConnection->setDirectPdo($freshDirectPdo); + $freshConnection->setDirectPdoConfig(['host' => 'direct-host']); + + $manager->freshConnection = $freshConnection; + $manager->setCachedConnection('pgsql::direct', $connection); + + $manager->refreshPdoConnectionsPublic('pgsql::direct'); + + $this->assertSame($freshDirectPdo, $connection->getPdo()); + $this->assertSame($freshDirectPdo, $connection->getReadPdo()); + $this->assertSame($freshDirectPdo, $connection->getDirectPdo()); + } +} + +class DatabaseManagerTestManager extends DatabaseManager +{ + public $freshConnection; + + public function parseConnectionNamePublic($name) + { + return $this->parseConnectionName($name); + } + + public function setPdoForTypePublic(Connection $connection, $type = null) + { + return $this->setPdoForType($connection, $type); + } + + public function refreshPdoConnectionsPublic($name) + { + return $this->refreshPdoConnections($name); + } + + public function setCachedConnection($name, Connection $connection) + { + $this->connections[$name] = $connection; + } + + protected function makeConnection($name) + { + return $this->freshConnection; + } +} + +class DatabaseManagerTestMockPDO extends PDO +{ + public function __construct() + { + // + } +} diff --git a/tests/Database/DatabaseMigratorTest.php b/tests/Database/DatabaseMigratorTest.php new file mode 100644 index 000000000000..0d887e96d4ba --- /dev/null +++ b/tests/Database/DatabaseMigratorTest.php @@ -0,0 +1,187 @@ +setValue(null, null); + + m::close(); + + parent::tearDown(); + } + + public function testResolveConnectionUsesDirectVariantWhenConfigured() + { + $resolver = m::mock(ConnectionResolverInterface::class); + $baseConnection = m::mock(Connection::class); + $directConnection = m::mock(Connection::class); + + $resolver->shouldReceive('connection')->once()->with('pgsql')->andReturn($baseConnection); + $baseConnection->shouldReceive('usesDirectConnection')->once()->andReturn(true); + $resolver->shouldReceive('connection')->once()->with('pgsql::direct')->andReturn($directConnection); + + $this->assertSame($directConnection, $this->migrator($resolver)->resolveConnection('pgsql')); + } + + public function testResolveConnectionLeavesExplicitSuffixesUntouched() + { + $resolver = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(Connection::class); + + $resolver->shouldReceive('connection')->once()->with('pgsql::write')->andReturn($connection); + + $this->assertSame($connection, $this->migrator($resolver)->resolveConnection('pgsql::write')); + } + + public function testResolveConnectionPassesThroughWhenDirectConnectionIsNotConfigured() + { + $resolver = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(Connection::class); + + $resolver->shouldReceive('connection')->twice()->with('sqlite')->andReturn($connection); + $connection->shouldReceive('usesDirectConnection')->once()->andReturn(false); + + $this->assertSame($connection, $this->migrator($resolver)->resolveConnection('sqlite')); + } + + public function testCustomConnectionResolverCallbackKeepsPriority() + { + $resolver = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(Connection::class); + + Migrator::resolveConnectionsUsing(function ($resolver, $name) use ($connection) { + $this->assertSame('pgsql', $name); + + return $connection; + }); + + $this->assertSame($connection, $this->migrator($resolver)->resolveConnection('pgsql')); + } + + public function testSetConnectionUsesDirectVariantForRepositoryAndDefaultConnection() + { + $resolver = m::mock(ConnectionResolverInterface::class); + $repository = m::mock(MigrationRepositoryInterface::class); + $baseConnection = m::mock(Connection::class); + + $resolver->shouldReceive('connection')->once()->with('pgsql')->andReturn($baseConnection); + $baseConnection->shouldReceive('usesDirectConnection')->once()->andReturn(true); + $resolver->shouldReceive('setDefaultConnection')->once()->with('pgsql::direct'); + $repository->shouldReceive('setSource')->once()->with('pgsql::direct'); + + $migrator = $this->migrator($resolver, $repository); + $migrator->setConnection('pgsql'); + + $this->assertSame('pgsql::direct', $migrator->getConnection()); + } + + public function testSetConnectionNullPreservesDefaultConnectionBehaviorWithoutDirectConnection() + { + $resolver = m::mock(ConnectionResolverInterface::class); + $repository = m::mock(MigrationRepositoryInterface::class); + $connection = m::mock(Connection::class); + + $resolver->shouldReceive('getDefaultConnection')->once()->andReturn('sqlite'); + $resolver->shouldReceive('connection')->once()->with('sqlite')->andReturn($connection); + $connection->shouldReceive('usesDirectConnection')->once()->andReturn(false); + $repository->shouldReceive('setSource')->once()->with(null); + $resolver->shouldNotReceive('setDefaultConnection'); + + $migrator = $this->migrator($resolver, $repository); + $migrator->setConnection(null); + + $this->assertNull($migrator->getConnection()); + } + + public function testSetConnectionNullUsesDirectVariantWhenDefaultConnectionHasDirectConnection() + { + $resolver = m::mock(ConnectionResolverInterface::class); + $repository = m::mock(MigrationRepositoryInterface::class); + $connection = m::mock(Connection::class); + + $resolver->shouldReceive('getDefaultConnection')->once()->andReturn('pgsql'); + $resolver->shouldReceive('connection')->once()->with('pgsql')->andReturn($connection); + $connection->shouldReceive('usesDirectConnection')->once()->andReturn(true); + $repository->shouldReceive('setSource')->once()->with('pgsql::direct'); + $resolver->shouldReceive('setDefaultConnection')->once()->with('pgsql::direct'); + + $migrator = $this->migrator($resolver, $repository); + $migrator->setConnection(null); + + $this->assertSame('pgsql::direct', $migrator->getConnection()); + } + + public function testRunMethodPreservesDirectConnectionName() + { + $resolver = new DatabaseMigratorTestResolver; + $migrator = $this->migrator($resolver); + $connection = m::mock(Connection::class); + $connection->shouldReceive('getNameWithReadWriteType')->once()->andReturn('pgsql::direct'); + + $migration = new class($resolver, $this) + { + public function __construct(public $resolver, public $test) + { + // + } + + public function up() + { + $this->test->assertSame('pgsql::direct', $this->resolver->getDefaultConnection()); + } + }; + + $migrator->runMethodPublic($connection, $migration, 'up'); + + $this->assertSame('pgsql', $resolver->getDefaultConnection()); + } + + protected function migrator($resolver, $repository = null) + { + return new DatabaseMigratorTestMigrator( + $repository ?: m::mock(MigrationRepositoryInterface::class), + $resolver, + new Filesystem + ); + } +} + +class DatabaseMigratorTestMigrator extends Migrator +{ + public function runMethodPublic($connection, $migration, $method) + { + return $this->runMethod($connection, $migration, $method); + } +} + +class DatabaseMigratorTestResolver implements ConnectionResolverInterface +{ + public $default = 'pgsql'; + + public function connection($name = null) + { + // + } + + public function getDefaultConnection() + { + return $this->default; + } + + public function setDefaultConnection($name) + { + $this->default = $name; + } +} diff --git a/tests/Database/DatabasePostgresConnectionTest.php b/tests/Database/DatabasePostgresConnectionTest.php new file mode 100644 index 000000000000..b2533da5240c --- /dev/null +++ b/tests/Database/DatabasePostgresConnectionTest.php @@ -0,0 +1,85 @@ + [ + PDO::ATTR_EMULATE_PREPARES => true, + ], + ]); + + $this->assertSame(['true', 'false'], $connection->prepareBindings([true, false])); + } + + public function testBooleanBindingsAreStringifiedWhenUsingTruthyEmulatedPreparesOption() + { + $connection = new PostgresConnection(new DatabasePostgresConnectionTestMockPDO, 'database', '', [ + 'options' => [ + PDO::ATTR_EMULATE_PREPARES => 1, + ], + ]); + + $this->assertSame(['true', 'false'], $connection->prepareBindings([true, false])); + } + + public function testBooleanBindingsUseDefaultIntegerConversionWhenNotUsingEmulatedPrepares() + { + $connection = new PostgresConnection(new DatabasePostgresConnectionTestMockPDO, 'database', '', [ + 'options' => [ + PDO::ATTR_EMULATE_PREPARES => false, + ], + ]); + + $this->assertSame([1, 0], $connection->prepareBindings([true, false])); + } + + public function testBooleanBindingsUseReadPdoConfigWhenReadConnectionIsActive() + { + $connection = new PostgresConnection(new DatabasePostgresConnectionTestMockPDO, 'database', '', [ + 'options' => [ + PDO::ATTR_EMULATE_PREPARES => false, + ], + ]); + $connection->setReadPdoConfig([ + 'options' => [ + PDO::ATTR_EMULATE_PREPARES => true, + ], + ]); + $connection->setReadWriteType('read'); + + $this->assertSame(['true', 'false'], $connection->prepareBindings([true, false])); + } + + public function testBooleanBindingsUseDirectPdoConfigWhenDirectConnectionIsActive() + { + $connection = new PostgresConnection(new DatabasePostgresConnectionTestMockPDO, 'database', '', [ + 'options' => [ + PDO::ATTR_EMULATE_PREPARES => true, + ], + ]); + $connection->setDirectPdoConfig([ + 'options' => [ + PDO::ATTR_EMULATE_PREPARES => false, + ], + ]); + $connection->setReadWriteType('direct'); + + $this->assertSame([1, 0], $connection->prepareBindings([true, false])); + } +} + +class DatabasePostgresConnectionTestMockPDO extends PDO +{ + public function __construct() + { + // + } +} diff --git a/tests/Database/DatabasePostgresSchemaStateTest.php b/tests/Database/DatabasePostgresSchemaStateTest.php new file mode 100644 index 000000000000..918df738199c --- /dev/null +++ b/tests/Database/DatabasePostgresSchemaStateTest.php @@ -0,0 +1,97 @@ +invoke($schemaState, [ + 'host' => 'pooler-host', + 'port' => '6432', + 'username' => 'root', + 'password' => 'secret', + 'database' => 'laravel', + 'sslmode' => 'prefer', + ]); + + $this->assertSame([ + 'LARAVEL_LOAD_HOST' => 'pooler-host', + 'LARAVEL_LOAD_PORT' => '6432', + 'LARAVEL_LOAD_USER' => 'root', + 'PGPASSWORD' => 'secret', + 'PGSSLMODE' => 'prefer', + 'LARAVEL_LOAD_DATABASE' => 'laravel', + ], $variables); + } + + public function testBaseVariablesUseDirectConnectionConfigurationWhenAvailable() + { + $connection = new Connection(new DatabasePostgresSchemaStateTestMockPDO); + $connection->setDirectPdoConfig([ + 'host' => ['direct-host', 'direct-host-2'], + 'port' => '5432', + 'username' => 'direct_user', + 'password' => 'direct_secret', + 'database' => 'direct_database', + 'sslmode' => 'require', + ]); + + $schemaState = new PostgresSchemaState($connection); + + $variables = (new ReflectionMethod(PostgresSchemaState::class, 'baseVariables'))->invoke($schemaState, [ + 'host' => 'pooler-host', + 'port' => '6432', + 'username' => 'root', + 'password' => 'secret', + 'database' => 'laravel', + 'sslmode' => 'prefer', + ]); + + $this->assertSame([ + 'LARAVEL_LOAD_HOST' => 'direct-host', + 'LARAVEL_LOAD_PORT' => '5432', + 'LARAVEL_LOAD_USER' => 'direct_user', + 'PGPASSWORD' => 'direct_secret', + 'PGSSLMODE' => 'require', + 'LARAVEL_LOAD_DATABASE' => 'direct_database', + ], $variables); + } + + public function testBaseVariablesDoNotExportEmptySslMode() + { + $schemaState = new PostgresSchemaState(new Connection(new DatabasePostgresSchemaStateTestMockPDO)); + + $variables = (new ReflectionMethod(PostgresSchemaState::class, 'baseVariables'))->invoke($schemaState, [ + 'host' => 'pooler-host', + 'port' => '6432', + 'username' => 'root', + 'password' => 'secret', + 'database' => 'laravel', + ]); + + $this->assertSame([ + 'LARAVEL_LOAD_HOST' => 'pooler-host', + 'LARAVEL_LOAD_PORT' => '6432', + 'LARAVEL_LOAD_USER' => 'root', + 'PGPASSWORD' => 'secret', + 'LARAVEL_LOAD_DATABASE' => 'laravel', + ], $variables); + } +} + +class DatabasePostgresSchemaStateTestMockPDO extends PDO +{ + public function __construct() + { + // + } +} diff --git a/tests/Integration/Database/Postgres/DatabasePgsqlPooledConnectionTest.php b/tests/Integration/Database/Postgres/DatabasePgsqlPooledConnectionTest.php new file mode 100644 index 000000000000..246afa55f490 --- /dev/null +++ b/tests/Integration/Database/Postgres/DatabasePgsqlPooledConnectionTest.php @@ -0,0 +1,66 @@ +get('database.connections.pgsql'); + + $app['config']->set('database.connections.pgsql.direct', array_filter([ + 'host' => $config['host'] ?? null, + 'port' => $config['port'] ?? null, + 'database' => $config['database'] ?? null, + 'username' => $config['username'] ?? null, + 'password' => $config['password'] ?? null, + 'sslmode' => $config['sslmode'] ?? null, + ])); + } + + public function testPooledAndDirectConnectionsUseExpectedPrepareModes() + { + $this->assertTrue( + DB::connection('pgsql')->getPdo()->getAttribute(PDO::ATTR_EMULATE_PREPARES) + ); + + $this->assertFalse( + DB::connection('pgsql::direct')->getPdo()->getAttribute(PDO::ATTR_EMULATE_PREPARES) + ); + } + + public function testRuntimeSchemaInspectionWorksThroughPooledConnection() + { + $this->assertIsBool(DB::connection('pgsql')->getSchemaBuilder()->hasTable('migrations')); + } + + public function testPooledConnectionCanBindBooleansWithEmulatedPrepares() + { + $schema = DB::connection('pgsql::direct')->getSchemaBuilder(); + + $schema->dropIfExists('pooled_boolean_bindings'); + $schema->create('pooled_boolean_bindings', function ($table) { + $table->boolean('active'); + }); + + try { + DB::connection('pgsql')->table('pooled_boolean_bindings')->insert([ + 'active' => true, + ]); + + $this->assertSame( + 1, + DB::connection('pgsql')->table('pooled_boolean_bindings')->where('active', true)->count() + ); + } finally { + $schema->dropIfExists('pooled_boolean_bindings'); + } + } +}