Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/1-essentials/03-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`}:
Expand Down
72 changes: 72 additions & 0 deletions packages/database/src/Config/DatabaseConnectionString.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Config;

use SensitiveParameter;
use Tempest\Database\Exceptions\DatabaseConnectionStringWasInvalid;
use Uri\Rfc3986\Uri;

final class DatabaseConnectionString
{
/**
* The path without its leading slash.
*/
public ?string $database {
get => 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<string,string> $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);
}
}
51 changes: 50 additions & 1 deletion packages/database/src/Config/MysqlConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
)),
);
}

Expand Down Expand Up @@ -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<string,string> $parameters Additional parameters appended to the DSN, such as `unix_socket` or `charset`.
*/
public function __construct(
#[SensitiveParameter]
Expand All @@ -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]);
}
}
47 changes: 45 additions & 2 deletions packages/database/src/Config/PostgresConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
)),
);
}

Expand Down Expand Up @@ -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.
Expand All @@ -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<string,string> $parameters Additional parameters appended to the DSN, such as `sslmode`, `connect_timeout` or `keepalives`.
*/
public function __construct(
#[SensitiveParameter]
Expand All @@ -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,
]);
}
}
25 changes: 25 additions & 0 deletions packages/database/src/Config/SQLiteConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Exceptions;

use Exception;

final class DatabaseConnectionStringWasInvalid extends Exception implements DatabaseException
{
public static function couldNotBeParsed(): self
{
return new self('The database connection string could not be parsed. Make sure it is a valid URI with a scheme, e.g. `mysql://user:password@host:3306/database`.');
}

public static function hostWasMissing(): self
{
return new self('The database connection string must contain a host, or a Unix socket in its query parameters.');
}

public static function schemeWasNotSupported(string $scheme, array $supported): self
{
return new self(sprintf(
'The database connection string scheme `%s` is not supported by this configuration. Supported schemes are: `%s`.',
$scheme,
implode('`, `', $supported),
));
}
}
Loading
Loading