Skip to content

Commit

Permalink
add (unused yet) Dsn class for databases
Browse files Browse the repository at this point in the history
  • Loading branch information
pounard committed Apr 22, 2024
1 parent 626f157 commit 8443138
Show file tree
Hide file tree
Showing 4 changed files with 248 additions and 2 deletions.
174 changes: 174 additions & 0 deletions src/Dsn.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php

declare(strict_types=1);

namespace MakinaCorpus\QueryBuilder;

/**
* Various use case this DSN implements:
* - driver://user:pass@host:port/database?arg=value&arg=value...
* - driver:///path/to/file?arg=value&... (socket connection, or database file name).
*/
class Dsn
{
public const DRIVER_ANY = 'any';
public const DRIVER_DOCTRINE = 'doctrine';
public const DRIVER_EXT = 'ext';
public const DRIVER_PDO = 'pdo';

private string $scheme;
private readonly bool $isFile;
private readonly string $driver;
private readonly string $vendor;

public function __construct(
/** Database vendor, eg. "mysql", "postgresql", ... */
string $vendor,
/** Host or local filename (unix socket, database file) */
private readonly string $host,
bool $isFile = false,
/** Driver, eg. "pdo", "ext", ... */
?string $driver = null,
private readonly ?string $database = null,
private readonly ?string $user = null,
#[\SensitiveParameter]
private readonly ?string $password = null,
private readonly ?int $port = null,
private readonly array $query = [],
) {
// Deal with some exceptions.
$matches = [];
if (\preg_match('@^([^-_+]+)[-_+](.+)$@i', $vendor, $matches)) {
$this->driver = $matches[1];
$this->vendor = Vendor::vendorNameNormalize($matches[2]);
$this->scheme = $vendor;
} else {
$this->driver = $driver ?? self::DRIVER_ANY;
$this->vendor = $vendor;
$this->scheme = $driver . '-' . $vendor;
}
$this->isFile = $isFile || '/' === $host[0];
}

public static function fromString(#[\SensitiveParameter] string $dsn): self
{
$scheme = null;
$isFile = false;

// parse_url() disallow some schemes with special characters we need
// such as using an underscore in it. Let's split the scheme first then
// parse_url() the rest.
// It also disallows triple slash for other than file:// scheme, but we
// do allow them, for example sqlite:///foo.db, or for connections via
// a UNIX socket.
$matches = [];
if (\preg_match('@^([^:/]+)://(.*)$@i', $dsn, $matches)) {
$scheme = $matches[1];
if ($isFile = ('/' === $matches[2][0])) {
$dsn = 'file://' . $matches[2];
} else {
$dsn = 'mock://' . $matches[2];
}
} else {
throw new \InvalidArgumentException('The database DSN must contain a scheme.');
}

if (false === ($params = \parse_url($dsn))) {
throw new \InvalidArgumentException('The database DSN is invalid.');
}

if ($isFile) {
if (empty($params['path'])) {
throw new \InvalidArgumentException('The database DSN must contain a path when a targetting a local filename.');
}
$host = $params['path'];
$database = $params['path'];
} else {
if (empty($params['host'])) {
throw new \InvalidArgumentException('The database DSN must contain a host (use "default" by default).');
}
if (empty($params['path'])) {
throw new \InvalidArgumentException('The database DSN must contain a database name.');
}
$host = $params['host'];
$database = \trim($params['path'], '/');
}

$port = $params['port'] ?? null;
$user = '' !== ($params['user'] ?? '') ? \rawurldecode($params['user']) : null;
$password = '' !== ($params['pass'] ?? '') ? \rawurldecode($params['pass']) : null;

$query = [];
\parse_str($params['query'] ?? '', $query);

return new self(
database: $database,
host: $host,
isFile: $isFile,
password: $password,
port: $port,
query: $query,
user: $user,
vendor: $scheme,
);
}

public function getDriver(): string
{
return $this->driver;
}

public function getVendor(): string
{
return $this->vendor;
}

public function getHost(): string
{
return $this->host;
}

public function isFile(): bool
{
return $this->isFile;
}

public function getDatabase(): ?string
{
return $this->database;
}

public function getUser(): ?string
{
return $this->user;
}

public function getPassword(): ?string
{
return $this->password;
}

public function getPort(?int $default = null): ?int
{
return $this->port ?? $default;
}

public function getOption(string $key, mixed $default = null): mixed
{
return $this->query[$key] ?? $default;
}

public function toUrl(array $excludeParams = []): string
{
$database = $this->database ?? '';

$queryString = '';
if ($this->query && $values = \array_diff_key($this->query, \array_flip($excludeParams))) {
$queryString = (\str_contains($database, '?') ? '&' : '?') . \http_build_query($values);
}

$authString = $this->user ? (\rawurlencode($this->user) . ($this->password ? ':' . \rawurlencode($this->password) : '') . '@') : '';

return $this->scheme . '://' . $authString . $this->host . ($this->port ? ':' . $this->port : '') . '/' . \ltrim($database, '/') . $queryString;
}
}
2 changes: 1 addition & 1 deletion src/Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ final class Platform extends Vendor
public const MARIADB = 'mariadb';
public const MYSQL = 'mysql';
public const ORACLE = 'oracle';
public const POSTGRESQL = 'postgresql';
public const POSTGRESQL = 'pgsql';
public const SQLITE = 'sqlite';
public const SQLSERVER = 'sqlsrv';
public const UNKNOWN = 'unknown';
Expand Down
2 changes: 1 addition & 1 deletion src/Vendor.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
public const MARIADB = 'mariadb';
public const MYSQL = 'mysql';
public const ORACLE = 'oracle';
public const POSTGRESQL = 'postgresql';
public const POSTGRESQL = 'pgsql';
public const SQLITE = 'sqlite';
public const SQLSERVER = 'sqlsrv';
public const UNKNOWN = 'unknown';
Expand Down
72 changes: 72 additions & 0 deletions tests/DsnTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare (strict_types=1);

namespace MakinaCorpus\QueryBuilder\Tests;

use MakinaCorpus\QueryBuilder\Dsn;
use PHPUnit\Framework\TestCase;

class DsnTest extends TestCase
{
public function testBasics(): void
{
$dsn = Dsn::fromString('pdo_mysql://foo:[email protected]:1234/some_database?server=mysql-10.0.0');

self::assertFalse($dsn->isFile());
self::assertSame('pdo', $dsn->getDriver());
self::assertSame('mysql', $dsn->getVendor());
self::assertSame('foo', $dsn->getUser());
self::assertSame('bar', $dsn->getPassword());
self::assertSame(1234, $dsn->getPort());
self::assertSame('somehost.com', $dsn->getHost());
self::assertSame('some_database', $dsn->getDatabase());
self::assertSame('mysql-10.0.0', $dsn->getOption('server'));
self::assertNull($dsn->getOption('non_existing_option'));
}

public function testWithFilename(): void
{
$dsn = Dsn::fromString('sqlite:///some/path.db?server=sqlite-3');

self::assertTrue($dsn->isFile());
self::assertSame('sqlite', $dsn->getVendor());
self::assertSame('any', $dsn->getDriver());
self::assertSame('sqlite-3', $dsn->getOption('server'));
self::assertSame('/some/path.db', $dsn->getHost());
self::assertSame('/some/path.db', $dsn->getDatabase());
}

public function testUserPassAreDecoded(): void
{
$dsn = Dsn::fromString('pdo_mysql://some%20user:some%[email protected]:1234/some_database');

self::assertSame('some user', $dsn->getUser());
self::assertSame('some password', $dsn->getPassword());
}

public function testNoDatabaseRaiseException(): void
{
self::expectException(\InvalidArgumentException::class);
Dsn::fromString('pdo_mysql://some%20user:some%[email protected]');
}

public function testNoSchemeRaiseException(): void
{
self::expectException(\InvalidArgumentException::class);
Dsn::fromString('some%20user:some%[email protected]/bla');
}

public function testNoHostRaiseException(): void
{
self::expectException(\InvalidArgumentException::class);
Dsn::fromString('oauth://some%20user:some%20password@?foo=bar');
}

public function testToUrl(): void
{
$dsn = Dsn::fromString('pdo_mysql://foo:[email protected]:1234/some_database?server=mysql-10.0.0&bla=bla');

self::assertSame('pdo_mysql://foo:[email protected]:1234/some_database?server=mysql-10.0.0', $dsn->toUrl(['bla']));
}
}

0 comments on commit 8443138

Please sign in to comment.