diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 8c40ba882a..b59a95258d 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -36,6 +36,40 @@ return new PostgresConfig( ); ``` +### 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 `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::fromConnectionString(env('DATABASE_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::fromConnectionString(env('DATABASE_URL'), persistent: true); +``` + +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 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/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/MysqlConfig.php b/packages/database/src/Config/MysqlConfig.php index 56a6b6724e..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,5 +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 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 $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 fromConnectionString(#[SensitiveParameter] string $connectionString, mixed ...$options): self + { + $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 7112e80061..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,5 +82,41 @@ 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 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 $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 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(...[ + ...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 f85d9ed115..b2a074965e 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\DatabaseConnectionStringWasInvalid; use Tempest\Database\Migrations\DatePrefixStrategy; use Tempest\Database\Migrations\MigrationNamingStrategy; use Tempest\Database\Tables\NamingStrategy; @@ -68,4 +69,28 @@ public function __construct( public MigrationNamingStrategy $migrationNaming = new DatePrefixStrategy(), public string|UnitEnum|null $tag = null, ) {} + + /** + * Creates a SQLite configuration from a connection string, such as `sqlite:///path/to/database.sqlite` or `sqlite::memory:`. + * + * @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 fromConnectionString(#[SensitiveParameter] string $connectionString, mixed ...$options): self + { + $parsed = DatabaseConnectionString::from($connectionString); + + if ($parsed->scheme !== 'sqlite') { + throw DatabaseConnectionStringWasInvalid::schemeWasNotSupported($parsed->scheme, ['sqlite']); + } + + if ($parsed->path === null) { + throw DatabaseConnectionStringWasInvalid::couldNotBeParsed(); + } + + return new self(...[ + '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_connection_strings(): Generator + { + yield 'mysql' => [ + MysqlConfig::fromConnectionString('mysql://user:secret@db.example.com:3307/tempest'), + 'mysql:host=db.example.com:3307;dbname=tempest', + 'user', + 'secret', + ]; + + yield 'mariadb' => [ + 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::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::fromConnectionString('mysql://db.example.com'), + 'mysql:host=db.example.com:3306;dbname=app', + 'root', + '', + ]; + + yield 'mysql with overrides' => [ + 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::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::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::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::fromConnectionString('sqlite:///usr/local/db.sqlite'), + 'sqlite:/usr/local/db.sqlite', + null, + null, + ]; + + yield 'sqlite in memory' => [ + SQLiteConfig::fromConnectionString('sqlite::memory:'), + 'sqlite::memory:', + null, + null, + ]; + } + + #[Test] + public function connection_string_options_are_passed_to_the_driver(): void + { + $config = MysqlConfig::fromConnectionString( + '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_connection_strings')] + #[Test] + public function invalid_connection_string_throws(callable $factory): void + { + $this->expectException(DatabaseConnectionStringWasInvalid::class); + + $factory(); + } + + public static function provide_invalid_connection_strings(): Generator + { + 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'); + } +}