From 0ad0507c5d6e83f74636e16feb634518e2e046b6 Mon Sep 17 00:00:00 2001 From: Boyd Bloemsma Date: Thu, 22 Oct 2026 21:27:53 +0200 Subject: [PATCH 1/2] feat(database): support creating database configs from a URL --- docs/1-essentials/03-database.md | 19 +++ packages/database/src/Config/DatabaseUrl.php | 48 +++++++ packages/database/src/Config/MysqlConfig.php | 14 ++ .../database/src/Config/PostgresConfig.php | 14 ++ packages/database/src/Config/SQLiteConfig.php | 29 +++++ .../src/Exceptions/DatabaseUrlWasInvalid.php | 24 ++++ .../tests/Config/DatabaseConfigTest.php | 123 ++++++++++++++++++ 7 files changed, 271 insertions(+) create mode 100644 packages/database/src/Config/DatabaseUrl.php create mode 100644 packages/database/src/Exceptions/DatabaseUrlWasInvalid.php diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 8c40ba882a..ef2d080173 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -36,6 +36,25 @@ return new PostgresConfig( ); ``` +### Connecting with a database URL + +Some hosting providers, such as Laravel Cloud, inject the connection details as a single `DATABASE_URL` environment variable. Each configuration class provides a `fromUrl()` method that builds the configuration from such a URL: + +```php app/Config/database.config.php +use Tempest\Database\Config\MysqlConfig; +use function Tempest\env; + +return MysqlConfig::fromUrl(env('DATABASE_URL')); +``` + +Components missing from the URL fall back to their defaults. Any other constructor argument may be passed by name, and takes precedence over the values parsed from the URL: + +```php app/Config/database.config.php +return PostgresConfig::fromUrl(env('DATABASE_URL'), persistent: true); +``` + +{b`Tempest\Database\Config\MysqlConfig`} accepts the `mysql` and `mariadb` schemes, {b`Tempest\Database\Config\PostgresConfig`} accepts `postgres`, `postgresql` and `pgsql`, and {b`Tempest\Database\Config\SQLiteConfig`} accepts URLs such as `sqlite:///path/to/database.sqlite` and `sqlite::memory:`. Credentials containing special characters must be percent-encoded. + ## Querying the database Multiple approaches exist for querying the database, all of which execute a {b`Tempest\Database\Query`} on the {b`Tempest\Database\Database`} class. The most straightforward approach is to inject {b`Tempest\Database\Database`}: diff --git a/packages/database/src/Config/DatabaseUrl.php b/packages/database/src/Config/DatabaseUrl.php new file mode 100644 index 0000000000..e51794c82f --- /dev/null +++ b/packages/database/src/Config/DatabaseUrl.php @@ -0,0 +1,48 @@ + + */ + public static function parse(#[SensitiveParameter] string $url, array $schemes): array + { + $parts = parse_url($url); + + if ($parts === false || ! isset($parts['host'])) { + throw DatabaseUrlWasInvalid::couldNotBeParsed(); + } + + $scheme = $parts['scheme'] ?? null; + + if (! in_array($scheme, $schemes, strict: true)) { + throw DatabaseUrlWasInvalid::schemeWasNotSupported($scheme, $schemes); + } + + $database = ltrim($parts['path'] ?? '', '/'); + + return array_filter( + [ + 'host' => $parts['host'], + 'port' => isset($parts['port']) ? (string) $parts['port'] : null, + 'username' => isset($parts['user']) ? rawurldecode($parts['user']) : null, + 'password' => isset($parts['pass']) ? rawurldecode($parts['pass']) : null, + 'database' => $database !== '' ? rawurldecode($database) : null, + ], + fn (?string $value) => $value !== null, + ); + } +} diff --git a/packages/database/src/Config/MysqlConfig.php b/packages/database/src/Config/MysqlConfig.php index 56a6b6724e..bc1cf45dd0 100644 --- a/packages/database/src/Config/MysqlConfig.php +++ b/packages/database/src/Config/MysqlConfig.php @@ -99,4 +99,18 @@ public function __construct( public MigrationNamingStrategy $migrationNaming = new DatePrefixStrategy(), public string|UnitEnum|null $tag = null, ) {} + + /** + * Creates a MySQL configuration from a database URL, such as `mysql://user:password@host:3306/database`. Components missing from the URL fall back to their defaults. + * + * @param string $url The database URL. Supported schemes are `mysql` and `mariadb`. + * @param mixed ...$options Named constructor arguments, which take precedence over the values parsed from the URL. + */ + public static function fromUrl(#[SensitiveParameter] string $url, mixed ...$options): self + { + return new self(...[ + ...DatabaseUrl::parse($url, schemes: ['mysql', 'mariadb']), + ...$options, + ]); + } } diff --git a/packages/database/src/Config/PostgresConfig.php b/packages/database/src/Config/PostgresConfig.php index 7112e80061..a17bb7c8e9 100644 --- a/packages/database/src/Config/PostgresConfig.php +++ b/packages/database/src/Config/PostgresConfig.php @@ -76,4 +76,18 @@ public function __construct( public MigrationNamingStrategy $migrationNaming = new DatePrefixStrategy(), public string|UnitEnum|null $tag = null, ) {} + + /** + * Creates a PostgreSQL configuration from a database URL, such as `postgres://user:password@host:5432/database`. Components missing from the URL fall back to their defaults. + * + * @param string $url The database URL. Supported schemes are `postgres`, `postgresql` and `pgsql`. + * @param mixed ...$options Named constructor arguments, which take precedence over the values parsed from the URL. + */ + public static function fromUrl(#[SensitiveParameter] string $url, mixed ...$options): self + { + return new self(...[ + ...DatabaseUrl::parse($url, schemes: ['postgres', 'postgresql', 'pgsql']), + ...$options, + ]); + } } diff --git a/packages/database/src/Config/SQLiteConfig.php b/packages/database/src/Config/SQLiteConfig.php index f85d9ed115..77366232c1 100644 --- a/packages/database/src/Config/SQLiteConfig.php +++ b/packages/database/src/Config/SQLiteConfig.php @@ -6,6 +6,7 @@ use PDO; use SensitiveParameter; +use Tempest\Database\Exceptions\DatabaseUrlWasInvalid; use Tempest\Database\Migrations\DatePrefixStrategy; use Tempest\Database\Migrations\MigrationNamingStrategy; use Tempest\Database\Tables\NamingStrategy; @@ -68,4 +69,32 @@ public function __construct( public MigrationNamingStrategy $migrationNaming = new DatePrefixStrategy(), public string|UnitEnum|null $tag = null, ) {} + + /** + * Creates a SQLite configuration from a database URL, such as `sqlite:///path/to/database.sqlite` or `sqlite::memory:`. + * + * @param string $url The database URL, using the `sqlite` scheme. + * @param mixed ...$options Named constructor arguments, which take precedence over the values parsed from the URL. + */ + public static function fromUrl(#[SensitiveParameter] string $url, mixed ...$options): self + { + if (! str_starts_with($url, 'sqlite:')) { + throw DatabaseUrlWasInvalid::schemeWasNotSupported(parse_url($url, PHP_URL_SCHEME) ?: null, ['sqlite']); + } + + $path = substr($url, strlen('sqlite:')); + + if (str_starts_with($path, '//')) { + $path = substr($path, 2); + } + + if ($path === '') { + throw DatabaseUrlWasInvalid::couldNotBeParsed(); + } + + return new self(...[ + 'path' => rawurldecode($path), + ...$options, + ]); + } } diff --git a/packages/database/src/Exceptions/DatabaseUrlWasInvalid.php b/packages/database/src/Exceptions/DatabaseUrlWasInvalid.php new file mode 100644 index 0000000000..60ae6e9d6f --- /dev/null +++ b/packages/database/src/Exceptions/DatabaseUrlWasInvalid.php @@ -0,0 +1,24 @@ +assertSame($dsn, $driver->dsn); + $this->assertSame($username, $driver->username); + $this->assertSame($password, $driver->password); + } + + public static function provide_database_urls(): Generator + { + yield 'mysql' => [ + MysqlConfig::fromUrl('mysql://user:secret@db.example.com:3307/tempest'), + 'mysql:host=db.example.com:3307;dbname=tempest', + 'user', + 'secret', + ]; + + yield 'mariadb' => [ + MysqlConfig::fromUrl('mariadb://user:secret@db.example.com:3307/tempest'), + 'mysql:host=db.example.com:3307;dbname=tempest', + 'user', + 'secret', + ]; + + yield 'mysql with encoded credentials' => [ + MysqlConfig::fromUrl('mysql://us%3Aer:p%40ss%2Fw%3Ard@db.example.com/tempest'), + 'mysql:host=db.example.com:3306;dbname=tempest', + 'us:er', + 'p@ss/w:rd', + ]; + + yield 'mysql with defaults' => [ + MysqlConfig::fromUrl('mysql://db.example.com'), + 'mysql:host=db.example.com:3306;dbname=app', + 'root', + '', + ]; + + yield 'mysql with overrides' => [ + MysqlConfig::fromUrl('mysql://user:secret@db.example.com:3307/tempest', database: 'other', username: 'admin'), + 'mysql:host=db.example.com:3307;dbname=other', + 'admin', + 'secret', + ]; + + yield 'postgres' => [ + PostgresConfig::fromUrl('postgres://user:secret@db.example.com:5433/tempest'), + 'pgsql:host=db.example.com;port=5433;dbname=tempest;user=user;password=secret', + 'user', + 'secret', + ]; + + yield 'postgresql' => [ + PostgresConfig::fromUrl('postgresql://user:secret@db.example.com/tempest'), + 'pgsql:host=db.example.com;port=5432;dbname=tempest;user=user;password=secret', + 'user', + 'secret', + ]; + + yield 'pgsql' => [ + PostgresConfig::fromUrl('pgsql://db.example.com'), + 'pgsql:host=db.example.com;port=5432;dbname=app;user=postgres;password=', + 'postgres', + '', + ]; + + yield 'sqlite with absolute path' => [ + SQLiteConfig::fromUrl('sqlite:///usr/local/db.sqlite'), + 'sqlite:/usr/local/db.sqlite', + null, + null, + ]; + + yield 'sqlite in memory' => [ + SQLiteConfig::fromUrl('sqlite::memory:'), + 'sqlite::memory:', + null, + null, + ]; + } + + #[Test] + public function url_options_are_passed_to_the_driver(): void + { + $config = MysqlConfig::fromUrl( + 'mysql://user:secret@db.example.com/tempest', + persistent: true, + certificateAuthority: '/etc/ssl/certs/ca-certificates.crt', + tag: 'main', + ); + + $this->assertSame('main', $config->tag); + $this->assertSame( + [ + PDO::ATTR_PERSISTENT => true, + Mysql::ATTR_SSL_CA => '/etc/ssl/certs/ca-certificates.crt', + ], + $config->options, + ); + } + + #[DataProvider('provide_invalid_database_urls')] + #[Test] + public function invalid_url_throws(callable $factory): void + { + $this->expectException(DatabaseUrlWasInvalid::class); + + $factory(); + } + + public static function provide_invalid_database_urls(): Generator + { + yield 'mysql with postgres scheme' => [fn () => MysqlConfig::fromUrl('postgres://user:secret@db.example.com/tempest')]; + yield 'postgres with mysql scheme' => [fn () => PostgresConfig::fromUrl('mysql://user:secret@db.example.com/tempest')]; + yield 'mysql without host' => [fn () => MysqlConfig::fromUrl('mysql:///tempest')]; + yield 'mysql without scheme' => [fn () => MysqlConfig::fromUrl('db.example.com')]; + yield 'empty url' => [fn () => MysqlConfig::fromUrl('')]; + yield 'sqlite with mysql scheme' => [fn () => SQLiteConfig::fromUrl('mysql://db.example.com/tempest')]; + yield 'sqlite without path' => [fn () => SQLiteConfig::fromUrl('sqlite://')]; + } } From 7c9fd7e051f3fa3166e53824a2ee940db78ce60c Mon Sep 17 00:00:00 2001 From: Boyd Bloemsma Date: Fri, 23 Oct 2026 09:18:58 +0200 Subject: [PATCH 2/2] refactor(database): rename database URL to connection string --- docs/1-essentials/03-database.md | 27 ++++- .../src/Config/DatabaseConnectionString.php | 72 +++++++++++ packages/database/src/Config/DatabaseUrl.php | 48 -------- packages/database/src/Config/MysqlConfig.php | 53 +++++++-- .../database/src/Config/PostgresConfig.php | 43 +++++-- packages/database/src/Config/SQLiteConfig.php | 26 ++-- .../DatabaseConnectionStringWasInvalid.php | 29 +++++ .../src/Exceptions/DatabaseUrlWasInvalid.php | 24 ---- .../tests/Config/DatabaseConfigTest.php | 112 +++++++++++++----- .../Config/DatabaseConnectionStringTest.php | 101 ++++++++++++++++ 10 files changed, 399 insertions(+), 136 deletions(-) create mode 100644 packages/database/src/Config/DatabaseConnectionString.php delete mode 100644 packages/database/src/Config/DatabaseUrl.php create mode 100644 packages/database/src/Exceptions/DatabaseConnectionStringWasInvalid.php delete mode 100644 packages/database/src/Exceptions/DatabaseUrlWasInvalid.php create mode 100644 packages/database/tests/Config/DatabaseConnectionStringTest.php diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index ef2d080173..b59a95258d 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -36,24 +36,39 @@ return new PostgresConfig( ); ``` -### Connecting with a database URL +### Connecting with a connection string -Some hosting providers, such as Laravel Cloud, inject the connection details as a single `DATABASE_URL` environment variable. Each configuration class provides a `fromUrl()` method that builds the configuration from such a URL: +Some hosting providers, such as Laravel Cloud, inject the connection details as a single `DATABASE_URL` environment variable. Each configuration class provides a `fromConnectionString()` method that builds the configuration from such a connection string: ```php app/Config/database.config.php use Tempest\Database\Config\MysqlConfig; use function Tempest\env; -return MysqlConfig::fromUrl(env('DATABASE_URL')); +return MysqlConfig::fromConnectionString(env('DATABASE_URL')); ``` -Components missing from the URL fall back to their defaults. Any other constructor argument may be passed by name, and takes precedence over the values parsed from the URL: +Components missing from the connection string fall back to their defaults. Any other constructor argument may be passed by name, and takes precedence over the values parsed from the connection string: ```php app/Config/database.config.php -return PostgresConfig::fromUrl(env('DATABASE_URL'), persistent: true); +return PostgresConfig::fromConnectionString(env('DATABASE_URL'), persistent: true); ``` -{b`Tempest\Database\Config\MysqlConfig`} accepts the `mysql` and `mariadb` schemes, {b`Tempest\Database\Config\PostgresConfig`} accepts `postgres`, `postgresql` and `pgsql`, and {b`Tempest\Database\Config\SQLiteConfig`} accepts URLs such as `sqlite:///path/to/database.sqlite` and `sqlite::memory:`. Credentials containing special characters must be percent-encoded. +Query parameters are appended to the DSN, which allows passing driver-specific options such as `sslmode` or `connect_timeout`: + +``` +postgres://user:secret@db.example.com:5432/app?sslmode=require&connect_timeout=10 +``` + +To connect through a Unix socket, pass its path as a percent-encoded host, or as the `host` query parameter for PostgreSQL and the `unix_socket` query parameter for MySQL: + +``` +postgres://user:secret@%2Fvar%2Frun%2Fpostgresql/app +mysql://user:secret@localhost/app?unix_socket=/var/run/mysqld/mysqld.sock +``` + +{b`Tempest\Database\Config\MysqlConfig`} accepts the `mysql` and `mariadb` schemes, {b`Tempest\Database\Config\PostgresConfig`} accepts `postgres`, `postgresql` and `pgsql`, and {b`Tempest\Database\Config\SQLiteConfig`} accepts connection strings such as `sqlite:///path/to/database.sqlite` and `sqlite::memory:`. Credentials containing special characters must be percent-encoded. + +The parsed components are also available through the {b`Tempest\Database\Config\DatabaseConnectionString`} value object, using `DatabaseConnectionString::from()`. ## Querying the database diff --git a/packages/database/src/Config/DatabaseConnectionString.php b/packages/database/src/Config/DatabaseConnectionString.php new file mode 100644 index 0000000000..4dc2ee48fb --- /dev/null +++ b/packages/database/src/Config/DatabaseConnectionString.php @@ -0,0 +1,72 @@ + ltrim($this->path ?? '', '/') ?: null; + } + + /** + * @param string $scheme The scheme, such as `mysql` or `postgres`. + * @param null|string $host The host, or the path to a Unix socket. + * @param null|int $port The port. + * @param null|string $username The username. + * @param null|string $password The password. + * @param null|string $path The path following the authority, such as `/database` or `/path/to/database.sqlite`. + * @param array $parameters The query parameters, such as `sslmode` or `connect_timeout`. + */ + public function __construct( + public readonly string $scheme, + public readonly ?string $host = null, + public readonly ?int $port = null, + public readonly ?string $username = null, + #[SensitiveParameter] + public readonly ?string $password = null, + public readonly ?string $path = null, + public readonly array $parameters = [], + ) {} + + /** + * Creates a DatabaseConnectionString instance from a connection string, such as `postgres://user:password@host:5432/database?sslmode=require`. Components are percent-decoded, so a Unix socket may be given as a percent-encoded host. + */ + public static function from(#[SensitiveParameter] string $connectionString): self + { + $uri = Uri::parse($connectionString); + + if ($uri?->getScheme() === null) { + throw DatabaseConnectionStringWasInvalid::couldNotBeParsed(); + } + + parse_str($uri->getQuery() ?? '', $parameters); + + return new self( + scheme: $uri->getScheme(), + host: self::decode($uri->getHost()), + port: $uri->getPort(), + username: self::decode($uri->getUsername()), + password: self::decode($uri->getPassword()), + path: self::decode($uri->getPath()), + parameters: array_filter($parameters, is_string(...)), + ); + } + + private static function decode(?string $component): ?string + { + if ($component === null || $component === '') { + return null; + } + + return rawurldecode($component); + } +} diff --git a/packages/database/src/Config/DatabaseUrl.php b/packages/database/src/Config/DatabaseUrl.php deleted file mode 100644 index e51794c82f..0000000000 --- a/packages/database/src/Config/DatabaseUrl.php +++ /dev/null @@ -1,48 +0,0 @@ - - */ - public static function parse(#[SensitiveParameter] string $url, array $schemes): array - { - $parts = parse_url($url); - - if ($parts === false || ! isset($parts['host'])) { - throw DatabaseUrlWasInvalid::couldNotBeParsed(); - } - - $scheme = $parts['scheme'] ?? null; - - if (! in_array($scheme, $schemes, strict: true)) { - throw DatabaseUrlWasInvalid::schemeWasNotSupported($scheme, $schemes); - } - - $database = ltrim($parts['path'] ?? '', '/'); - - return array_filter( - [ - 'host' => $parts['host'], - 'port' => isset($parts['port']) ? (string) $parts['port'] : null, - 'username' => isset($parts['user']) ? rawurldecode($parts['user']) : null, - 'password' => isset($parts['pass']) ? rawurldecode($parts['pass']) : null, - 'database' => $database !== '' ? rawurldecode($database) : null, - ], - fn (?string $value) => $value !== null, - ); - } -} diff --git a/packages/database/src/Config/MysqlConfig.php b/packages/database/src/Config/MysqlConfig.php index bc1cf45dd0..e9136a937c 100644 --- a/packages/database/src/Config/MysqlConfig.php +++ b/packages/database/src/Config/MysqlConfig.php @@ -7,6 +7,7 @@ use PDO; use Pdo\Mysql; use SensitiveParameter; +use Tempest\Database\Exceptions\DatabaseConnectionStringWasInvalid; use Tempest\Database\Migrations\DatePrefixStrategy; use Tempest\Database\Migrations\MigrationNamingStrategy; use Tempest\Database\Tables\NamingStrategy; @@ -17,10 +18,15 @@ final class MysqlConfig implements DatabaseConfig { public string $dsn { get => sprintf( - 'mysql:host=%s:%s;dbname=%s', + 'mysql:host=%s:%s;dbname=%s%s', $this->host, $this->port, $this->database, + implode('', array_map( + fn (string $key, string $value) => ";{$key}={$value}", + array_keys($this->parameters), + $this->parameters, + )), ); } @@ -78,6 +84,7 @@ final class MysqlConfig implements DatabaseConfig * @param NamingStrategy $namingStrategy The naming strategy for database tables and columns. * @param MigrationNamingStrategy $migrationNaming The naming strategy for migration file prefixes. * @param string|UnitEnum|null $tag An optional tag to identify this database configuration. + * @param array $parameters Additional parameters appended to the DSN, such as `unix_socket` or `charset`. */ public function __construct( #[SensitiveParameter] @@ -98,19 +105,47 @@ public function __construct( public NamingStrategy $namingStrategy = new PluralizedSnakeCaseStrategy(), public MigrationNamingStrategy $migrationNaming = new DatePrefixStrategy(), public string|UnitEnum|null $tag = null, + public array $parameters = [], ) {} /** - * Creates a MySQL configuration from a database URL, such as `mysql://user:password@host:3306/database`. Components missing from the URL fall back to their defaults. + * Creates a MySQL configuration from a connection string, such as `mysql://user:password@host:3306/database?charset=utf8mb4`. Components missing from the connection string fall back to their defaults, and query parameters are appended to the DSN. A Unix socket may be given as a percent-encoded host or as the `unix_socket` query parameter. * - * @param string $url The database URL. Supported schemes are `mysql` and `mariadb`. - * @param mixed ...$options Named constructor arguments, which take precedence over the values parsed from the URL. + * @param string $connectionString The connection string. Supported schemes are `mysql` and `mariadb`. + * @param mixed ...$options Named constructor arguments, which take precedence over the values parsed from the connection string. */ - public static function fromUrl(#[SensitiveParameter] string $url, mixed ...$options): self + public static function fromConnectionString(#[SensitiveParameter] string $connectionString, mixed ...$options): self { - return new self(...[ - ...DatabaseUrl::parse($url, schemes: ['mysql', 'mariadb']), - ...$options, - ]); + $parsed = DatabaseConnectionString::from($connectionString); + + if (! in_array($parsed->scheme, ['mysql', 'mariadb'], strict: true)) { + throw DatabaseConnectionStringWasInvalid::schemeWasNotSupported($parsed->scheme, ['mysql', 'mariadb']); + } + + $host = $parsed->host; + $parameters = $parsed->parameters; + + if ($host !== null && str_starts_with($host, '/')) { + $parameters['unix_socket'] ??= $host; + $host = null; + } + + if ($host === null && ! isset($parameters['unix_socket'])) { + throw DatabaseConnectionStringWasInvalid::hostWasMissing(); + } + + $arguments = array_filter( + [ + 'host' => $host, + 'port' => $parsed->port !== null ? (string) $parsed->port : null, + 'username' => $parsed->username, + 'password' => $parsed->password, + 'database' => $parsed->database, + 'parameters' => $parameters, + ], + fn (mixed $value) => $value !== null, + ); + + return new self(...[...$arguments, ...$options]); } } diff --git a/packages/database/src/Config/PostgresConfig.php b/packages/database/src/Config/PostgresConfig.php index a17bb7c8e9..7012600683 100644 --- a/packages/database/src/Config/PostgresConfig.php +++ b/packages/database/src/Config/PostgresConfig.php @@ -6,6 +6,7 @@ use PDO; use SensitiveParameter; +use Tempest\Database\Exceptions\DatabaseConnectionStringWasInvalid; use Tempest\Database\Migrations\DatePrefixStrategy; use Tempest\Database\Migrations\MigrationNamingStrategy; use Tempest\Database\Tables\NamingStrategy; @@ -16,12 +17,17 @@ final class PostgresConfig implements DatabaseConfig { public string $dsn { get => sprintf( - 'pgsql:host=%s;port=%s;dbname=%s;user=%s;password=%s', + 'pgsql:host=%s;port=%s;dbname=%s;user=%s;password=%s%s', $this->host, $this->port, $this->database, $this->username, $this->password, + implode('', array_map( + fn (string $key, string $value) => ";{$key}={$value}", + array_keys($this->parameters), + $this->parameters, + )), ); } @@ -50,7 +56,7 @@ final class PostgresConfig implements DatabaseConfig } /** - * @param string $host The PostgreSQL server hostname or IP address. + * @param string $host The PostgreSQL server hostname or IP address, or the directory containing its Unix socket. * @param string $port The PostgreSQL server port number. * @param string $username The PostgreSQL username for authentication. * @param string $password The PostgreSQL password for authentication. @@ -59,6 +65,7 @@ final class PostgresConfig implements DatabaseConfig * @param NamingStrategy $namingStrategy The naming strategy for database tables and columns. * @param MigrationNamingStrategy $migrationNaming The naming strategy for migration file prefixes. * @param string|UnitEnum|null $tag An optional tag to identify this database configuration. + * @param array $parameters Additional parameters appended to the DSN, such as `sslmode`, `connect_timeout` or `keepalives`. */ public function __construct( #[SensitiveParameter] @@ -75,18 +82,40 @@ public function __construct( public NamingStrategy $namingStrategy = new PluralizedSnakeCaseStrategy(), public MigrationNamingStrategy $migrationNaming = new DatePrefixStrategy(), public string|UnitEnum|null $tag = null, + public array $parameters = [], ) {} /** - * Creates a PostgreSQL configuration from a database URL, such as `postgres://user:password@host:5432/database`. Components missing from the URL fall back to their defaults. + * Creates a PostgreSQL configuration from a connection string, such as `postgres://user:password@host:5432/database?sslmode=require`. Components missing from the connection string fall back to their defaults, and query parameters are appended to the DSN. As with libpq, the `host`, `port`, `user`, `password` and `dbname` query parameters take precedence over the other components, so a Unix socket may be given as `postgres:///database?host=/var/run/postgresql` or as a percent-encoded host. * - * @param string $url The database URL. Supported schemes are `postgres`, `postgresql` and `pgsql`. - * @param mixed ...$options Named constructor arguments, which take precedence over the values parsed from the URL. + * @param string $connectionString The connection string. Supported schemes are `postgres`, `postgresql` and `pgsql`. + * @param mixed ...$options Named constructor arguments, which take precedence over the values parsed from the connection string. */ - public static function fromUrl(#[SensitiveParameter] string $url, mixed ...$options): self + public static function fromConnectionString(#[SensitiveParameter] string $connectionString, mixed ...$options): self { + $parsed = DatabaseConnectionString::from($connectionString); + + if (! in_array($parsed->scheme, ['postgres', 'postgresql', 'pgsql'], strict: true)) { + throw DatabaseConnectionStringWasInvalid::schemeWasNotSupported($parsed->scheme, ['postgres', 'postgresql', 'pgsql']); + } + + $parameters = $parsed->parameters; + + $arguments = [ + 'host' => $parameters['host'] ?? $parsed->host, + 'port' => $parameters['port'] ?? ($parsed->port !== null ? (string) $parsed->port : null), + 'username' => $parameters['user'] ?? $parsed->username, + 'password' => $parameters['password'] ?? $parsed->password, + 'database' => $parameters['dbname'] ?? $parsed->database, + 'parameters' => array_diff_key($parameters, array_flip(['host', 'port', 'user', 'password', 'dbname'])), + ]; + + if ($arguments['host'] === null) { + throw DatabaseConnectionStringWasInvalid::hostWasMissing(); + } + return new self(...[ - ...DatabaseUrl::parse($url, schemes: ['postgres', 'postgresql', 'pgsql']), + ...array_filter($arguments, fn (mixed $value) => $value !== null), ...$options, ]); } diff --git a/packages/database/src/Config/SQLiteConfig.php b/packages/database/src/Config/SQLiteConfig.php index 77366232c1..b2a074965e 100644 --- a/packages/database/src/Config/SQLiteConfig.php +++ b/packages/database/src/Config/SQLiteConfig.php @@ -6,7 +6,7 @@ use PDO; use SensitiveParameter; -use Tempest\Database\Exceptions\DatabaseUrlWasInvalid; +use Tempest\Database\Exceptions\DatabaseConnectionStringWasInvalid; use Tempest\Database\Migrations\DatePrefixStrategy; use Tempest\Database\Migrations\MigrationNamingStrategy; use Tempest\Database\Tables\NamingStrategy; @@ -71,29 +71,25 @@ public function __construct( ) {} /** - * Creates a SQLite configuration from a database URL, such as `sqlite:///path/to/database.sqlite` or `sqlite::memory:`. + * Creates a SQLite configuration from a connection string, such as `sqlite:///path/to/database.sqlite` or `sqlite::memory:`. * - * @param string $url The database URL, using the `sqlite` scheme. - * @param mixed ...$options Named constructor arguments, which take precedence over the values parsed from the URL. + * @param string $connectionString The connection string, using the `sqlite` scheme. + * @param mixed ...$options Named constructor arguments, which take precedence over the values parsed from the connection string. */ - public static function fromUrl(#[SensitiveParameter] string $url, mixed ...$options): self + public static function fromConnectionString(#[SensitiveParameter] string $connectionString, mixed ...$options): self { - if (! str_starts_with($url, 'sqlite:')) { - throw DatabaseUrlWasInvalid::schemeWasNotSupported(parse_url($url, PHP_URL_SCHEME) ?: null, ['sqlite']); - } - - $path = substr($url, strlen('sqlite:')); + $parsed = DatabaseConnectionString::from($connectionString); - if (str_starts_with($path, '//')) { - $path = substr($path, 2); + if ($parsed->scheme !== 'sqlite') { + throw DatabaseConnectionStringWasInvalid::schemeWasNotSupported($parsed->scheme, ['sqlite']); } - if ($path === '') { - throw DatabaseUrlWasInvalid::couldNotBeParsed(); + if ($parsed->path === null) { + throw DatabaseConnectionStringWasInvalid::couldNotBeParsed(); } return new self(...[ - 'path' => rawurldecode($path), + 'path' => $parsed->path, ...$options, ]); } diff --git a/packages/database/src/Exceptions/DatabaseConnectionStringWasInvalid.php b/packages/database/src/Exceptions/DatabaseConnectionStringWasInvalid.php new file mode 100644 index 0000000000..8bbee17d28 --- /dev/null +++ b/packages/database/src/Exceptions/DatabaseConnectionStringWasInvalid.php @@ -0,0 +1,29 @@ +assertSame($dsn, $driver->dsn); $this->assertSame($username, $driver->username); $this->assertSame($password, $driver->password); } - public static function provide_database_urls(): Generator + public static function provide_connection_strings(): Generator { yield 'mysql' => [ - MysqlConfig::fromUrl('mysql://user:secret@db.example.com:3307/tempest'), + MysqlConfig::fromConnectionString('mysql://user:secret@db.example.com:3307/tempest'), 'mysql:host=db.example.com:3307;dbname=tempest', 'user', 'secret', ]; yield 'mariadb' => [ - MysqlConfig::fromUrl('mariadb://user:secret@db.example.com:3307/tempest'), + MysqlConfig::fromConnectionString('mariadb://user:secret@db.example.com:3307/tempest'), 'mysql:host=db.example.com:3307;dbname=tempest', 'user', 'secret', ]; yield 'mysql with encoded credentials' => [ - MysqlConfig::fromUrl('mysql://us%3Aer:p%40ss%2Fw%3Ard@db.example.com/tempest'), + MysqlConfig::fromConnectionString('mysql://us%3Aer:p%40ss%2Fw%3Ard@db.example.com/tempest'), 'mysql:host=db.example.com:3306;dbname=tempest', 'us:er', 'p@ss/w:rd', ]; yield 'mysql with defaults' => [ - MysqlConfig::fromUrl('mysql://db.example.com'), + MysqlConfig::fromConnectionString('mysql://db.example.com'), 'mysql:host=db.example.com:3306;dbname=app', 'root', '', ]; yield 'mysql with overrides' => [ - MysqlConfig::fromUrl('mysql://user:secret@db.example.com:3307/tempest', database: 'other', username: 'admin'), + MysqlConfig::fromConnectionString('mysql://user:secret@db.example.com:3307/tempest', database: 'other', username: 'admin'), 'mysql:host=db.example.com:3307;dbname=other', 'admin', 'secret', ]; + yield 'mysql with query parameters' => [ + MysqlConfig::fromConnectionString('mysql://user:secret@db.example.com/tempest?charset=utf8mb4'), + 'mysql:host=db.example.com:3306;dbname=tempest;charset=utf8mb4', + 'user', + 'secret', + ]; + + yield 'mysql with unix socket parameter' => [ + MysqlConfig::fromConnectionString('mysql://user:secret@localhost/tempest?unix_socket=/var/run/mysqld/mysqld.sock'), + 'mysql:host=localhost:3306;dbname=tempest;unix_socket=/var/run/mysqld/mysqld.sock', + 'user', + 'secret', + ]; + + yield 'mysql with unix socket host' => [ + MysqlConfig::fromConnectionString('mysql://user:secret@%2Fvar%2Frun%2Fmysqld%2Fmysqld.sock/tempest'), + 'mysql:host=localhost:3306;dbname=tempest;unix_socket=/var/run/mysqld/mysqld.sock', + 'user', + 'secret', + ]; + + yield 'mysql with unix socket and without host' => [ + MysqlConfig::fromConnectionString('mysql://user:secret@/tempest?unix_socket=/var/run/mysqld/mysqld.sock'), + 'mysql:host=localhost:3306;dbname=tempest;unix_socket=/var/run/mysqld/mysqld.sock', + 'user', + 'secret', + ]; + yield 'postgres' => [ - PostgresConfig::fromUrl('postgres://user:secret@db.example.com:5433/tempest'), + PostgresConfig::fromConnectionString('postgres://user:secret@db.example.com:5433/tempest'), 'pgsql:host=db.example.com;port=5433;dbname=tempest;user=user;password=secret', 'user', 'secret', ]; yield 'postgresql' => [ - PostgresConfig::fromUrl('postgresql://user:secret@db.example.com/tempest'), + PostgresConfig::fromConnectionString('postgresql://user:secret@db.example.com/tempest'), 'pgsql:host=db.example.com;port=5432;dbname=tempest;user=user;password=secret', 'user', 'secret', ]; yield 'pgsql' => [ - PostgresConfig::fromUrl('pgsql://db.example.com'), + PostgresConfig::fromConnectionString('pgsql://db.example.com'), 'pgsql:host=db.example.com;port=5432;dbname=app;user=postgres;password=', 'postgres', '', ]; + yield 'postgres with query parameters' => [ + PostgresConfig::fromConnectionString('postgres://user:secret@db.example.com/tempest?sslmode=require&connect_timeout=10&keepalives=1'), + 'pgsql:host=db.example.com;port=5432;dbname=tempest;user=user;password=secret;sslmode=require;connect_timeout=10;keepalives=1', + 'user', + 'secret', + ]; + + yield 'postgres with unix socket parameter' => [ + PostgresConfig::fromConnectionString('postgresql://user:secret@/tempest?host=/var/run/postgresql'), + 'pgsql:host=/var/run/postgresql;port=5432;dbname=tempest;user=user;password=secret', + 'user', + 'secret', + ]; + + yield 'postgres with unix socket host' => [ + PostgresConfig::fromConnectionString('postgresql://user:secret@%2Fvar%2Frun%2Fpostgresql/tempest'), + 'pgsql:host=/var/run/postgresql;port=5432;dbname=tempest;user=user;password=secret', + 'user', + 'secret', + ]; + + yield 'postgres with connection parameters' => [ + PostgresConfig::fromConnectionString('postgresql:///?host=db.example.com&port=5433&user=user&password=secret&dbname=tempest&sslmode=verify-full'), + 'pgsql:host=db.example.com;port=5433;dbname=tempest;user=user;password=secret;sslmode=verify-full', + 'user', + 'secret', + ]; + yield 'sqlite with absolute path' => [ - SQLiteConfig::fromUrl('sqlite:///usr/local/db.sqlite'), + SQLiteConfig::fromConnectionString('sqlite:///usr/local/db.sqlite'), 'sqlite:/usr/local/db.sqlite', null, null, ]; yield 'sqlite in memory' => [ - SQLiteConfig::fromUrl('sqlite::memory:'), + SQLiteConfig::fromConnectionString('sqlite::memory:'), 'sqlite::memory:', null, null, @@ -203,9 +259,9 @@ public static function provide_database_urls(): Generator } #[Test] - public function url_options_are_passed_to_the_driver(): void + public function connection_string_options_are_passed_to_the_driver(): void { - $config = MysqlConfig::fromUrl( + $config = MysqlConfig::fromConnectionString( 'mysql://user:secret@db.example.com/tempest', persistent: true, certificateAuthority: '/etc/ssl/certs/ca-certificates.crt', @@ -222,23 +278,25 @@ public function url_options_are_passed_to_the_driver(): void ); } - #[DataProvider('provide_invalid_database_urls')] + #[DataProvider('provide_invalid_connection_strings')] #[Test] - public function invalid_url_throws(callable $factory): void + public function invalid_connection_string_throws(callable $factory): void { - $this->expectException(DatabaseUrlWasInvalid::class); + $this->expectException(DatabaseConnectionStringWasInvalid::class); $factory(); } - public static function provide_invalid_database_urls(): Generator + public static function provide_invalid_connection_strings(): Generator { - yield 'mysql with postgres scheme' => [fn () => MysqlConfig::fromUrl('postgres://user:secret@db.example.com/tempest')]; - yield 'postgres with mysql scheme' => [fn () => PostgresConfig::fromUrl('mysql://user:secret@db.example.com/tempest')]; - yield 'mysql without host' => [fn () => MysqlConfig::fromUrl('mysql:///tempest')]; - yield 'mysql without scheme' => [fn () => MysqlConfig::fromUrl('db.example.com')]; - yield 'empty url' => [fn () => MysqlConfig::fromUrl('')]; - yield 'sqlite with mysql scheme' => [fn () => SQLiteConfig::fromUrl('mysql://db.example.com/tempest')]; - yield 'sqlite without path' => [fn () => SQLiteConfig::fromUrl('sqlite://')]; + yield 'mysql with postgres scheme' => [fn () => MysqlConfig::fromConnectionString('postgres://user:secret@db.example.com/tempest')]; + yield 'postgres with mysql scheme' => [fn () => PostgresConfig::fromConnectionString('mysql://user:secret@db.example.com/tempest')]; + yield 'mysql without host' => [fn () => MysqlConfig::fromConnectionString('mysql:///tempest')]; + yield 'postgres without host' => [fn () => PostgresConfig::fromConnectionString('postgres:///tempest')]; + yield 'mysql without scheme' => [fn () => MysqlConfig::fromConnectionString('db.example.com')]; + yield 'mysql with invalid port' => [fn () => MysqlConfig::fromConnectionString('mysql://db.example.com:port/tempest')]; + yield 'empty connection string' => [fn () => MysqlConfig::fromConnectionString('')]; + yield 'sqlite with mysql scheme' => [fn () => SQLiteConfig::fromConnectionString('mysql://db.example.com/tempest')]; + yield 'sqlite without path' => [fn () => SQLiteConfig::fromConnectionString('sqlite://')]; } } diff --git a/packages/database/tests/Config/DatabaseConnectionStringTest.php b/packages/database/tests/Config/DatabaseConnectionStringTest.php new file mode 100644 index 0000000000..5b9c3ff92f --- /dev/null +++ b/packages/database/tests/Config/DatabaseConnectionStringTest.php @@ -0,0 +1,101 @@ +assertSame('postgres', $connectionString->scheme); + $this->assertSame('db.example.com', $connectionString->host); + $this->assertSame(5433, $connectionString->port); + $this->assertSame('us:er', $connectionString->username); + $this->assertSame('p@ss', $connectionString->password); + $this->assertSame('/tempest', $connectionString->path); + $this->assertSame('tempest', $connectionString->database); + $this->assertSame(['sslmode' => 'require', 'connect_timeout' => '10'], $connectionString->parameters); + } + + #[Test] + public function missing_components_are_null(): void + { + $connectionString = DatabaseConnectionString::from('MySQL://db.example.com'); + + $this->assertSame('mysql', $connectionString->scheme); + $this->assertSame('db.example.com', $connectionString->host); + $this->assertNull($connectionString->port); + $this->assertNull($connectionString->username); + $this->assertNull($connectionString->password); + $this->assertNull($connectionString->path); + $this->assertNull($connectionString->database); + $this->assertSame([], $connectionString->parameters); + } + + #[Test] + public function unix_socket_host_is_decoded(): void + { + $connectionString = DatabaseConnectionString::from('postgresql://user@%2Fvar%2Frun%2Fpostgresql/tempest'); + + $this->assertSame('/var/run/postgresql', $connectionString->host); + $this->assertNull($connectionString->port); + $this->assertSame('tempest', $connectionString->database); + } + + #[Test] + public function host_may_be_omitted(): void + { + $connectionString = DatabaseConnectionString::from('postgresql:///tempest?host=/var/run/postgresql'); + + $this->assertNull($connectionString->host); + $this->assertSame('tempest', $connectionString->database); + $this->assertSame(['host' => '/var/run/postgresql'], $connectionString->parameters); + } + + #[Test] + public function ipv6_host_is_parsed(): void + { + $connectionString = DatabaseConnectionString::from('mysql://[::1]:3307/tempest'); + + $this->assertSame('[::1]', $connectionString->host); + $this->assertSame(3307, $connectionString->port); + } + + #[Test] + public function connection_string_without_authority_is_parsed(): void + { + $connectionString = DatabaseConnectionString::from('sqlite::memory:'); + + $this->assertSame('sqlite', $connectionString->scheme); + $this->assertNull($connectionString->host); + $this->assertSame(':memory:', $connectionString->path); + } + + #[Test] + public function connection_string_without_scheme_throws(): void + { + $this->expectException(DatabaseConnectionStringWasInvalid::class); + + DatabaseConnectionString::from('db.example.com'); + } + + #[Test] + public function invalid_port_throws(): void + { + $this->expectException(DatabaseConnectionStringWasInvalid::class); + + DatabaseConnectionString::from('mysql://db.example.com:port/tempest'); + } +}