diff --git a/composer.json b/composer.json index 0317fdd..020e212 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,11 @@ "autoload": { "psr-4": { "Index\\": "src" - } + }, + "files": [ + "src/Db/MariaDb/_ndx.php", + "src/Db/NullDb/_ndx.php", + "src/Db/Sqlite/_ndx.php" + ] } } diff --git a/src/Db/DbBackends.php b/src/Db/DbBackends.php new file mode 100644 index 0000000..3e93906 --- /dev/null +++ b/src/Db/DbBackends.php @@ -0,0 +1,104 @@ + */ + private static array $schemes = []; + + /** @var array */ + private static array $backends = []; + + /** + * @throws RuntimeException Will always throw because this is a static class exclusively. + */ + public function __construct() { + throw new RuntimeException('This is a static class, you cannot create an instance of it.'); + } + + /** + * Registers a database backend with a protocol scheme. + * + * @param string $scheme URL scheme. + * @param string $className Fully qualified class name of backend. + * @throws InvalidArgumentException If $scheme is already registered. + */ + public static function register(string $scheme, string $className): void { + $scheme = strtolower($scheme); + if(array_key_exists($scheme, self::$schemes)) + throw new InvalidArgumentException('$scheme has already been registered'); + + self::$schemes[$scheme] = $className; + } + + /** + * Creates a database connection from a DSN URI. + * + * @param string $uri DSN URI. + * @throws RuntimeException If the requested database backend is not available. + * @return DbConnection Connection instance. + */ + public static function create(string $uri): DbConnection { + $parsed = self::parseUri($uri); + + $backend = self::backend($parsed['scheme']); + if(!$backend->isAvailable()) + throw new RuntimeException('requested database backend is not available'); + + return $backend->createConnection($backend->parseDsn($parsed)); + } + + /** + * Parses a DSN URI into a connection info class. + * + * @param string $uri DSN URI. + * @return DbConnectionInfo Connection info instance. + */ + public static function parse(string $uri): DbConnectionInfo { + $parsed = self::parseUri($uri); + return self::backend($parsed['scheme'])->parseDsn($parsed); + } + + /** + * Creates a database backend information class from a scheme. + * + * @param string $scheme Database protocol scheme. + * @throws InvalidArgumentException If $scheme is not a registered protocol scheme. + * @throws RuntimeException If the implementation class is not available or doesn't implement DbBackend. + * @return DbBackend Database backend information instance. + */ + public static function backend(string $scheme): DbBackend { + $scheme = strtolower($scheme); + if(!array_key_exists($scheme, self::$schemes)) + throw new InvalidArgumentException('$scheme is not a registered scheme'); + + $className = self::$schemes[$scheme]; + if(array_key_exists($className, self::$backends)) + return self::$backends[$className]; + + if(!class_exists($className)) + throw new RuntimeException('implementation class for the given $scheme does not exist'); + + $backend = new $className; + if(!($backend instanceof DbBackend)) + throw new RuntimeException('implementation class for the given $scheme does not implement DbBackend'); + + self::$backends[$className] = $backend; + return $backend; + } + + /** @return array{scheme: string, host?: string, port?: int, user?: string, pass?: string, path?: string, query?: string, fragment?: string} */ + private static function parseUri(string $uri): array { + $uri = parse_url($uri); + if($uri === false) + throw new InvalidArgumentException('$uri is not a valid uri'); + if(empty($uri['scheme'])) + throw new InvalidArgumentException('$uri must contain a non-empty scheme component'); + return $uri; + } +} diff --git a/src/Db/DbTools.php b/src/Db/DbTools.php index 8c51064..9cd81fd 100644 --- a/src/Db/DbTools.php +++ b/src/Db/DbTools.php @@ -6,109 +6,11 @@ namespace Index\Db; use Countable; -use InvalidArgumentException; -use RuntimeException; /** * Common database actions. - * - * DSN documentation: - * - * DbTools only handles the scheme part of the URL, - * the rest of the URL is described in the documentation of the parseDsn implementation of the respective backend. - * - * The scheme can be a PHP classpath to an implementation of DbBackend, or any of these aliases: - * - `null`: maps to `NullDb\NullDbBackend`, provides a fallback blackhole database backend. - * - `mariadb`, `mysql`: maps to `MariaDb\MariaDbBackend`, provides a backend based on the mysqli extension. - * - `sqlite`, `sqlite3`: maps to `Sqlite\SqliteBackend`, provides a backend based on the sqlite3 extension. - * - * Short names are currently hardcoded and cannot be expanded. */ final class DbTools { - private const DB_PROTOS = [ - 'null' => NullDb\NullDbBackend::class, - 'mariadb' => MariaDb\MariaDbBackend::class, - 'mysql' => MariaDb\MariaDbBackend::class, - 'sqlite' => Sqlite\SqliteBackend::class, - 'sqlite3' => Sqlite\SqliteBackend::class, - ]; - - /** @return array */ - private static function parseDsnUri(string $dsn): array { - $uri = parse_url($dsn); - if($uri === false) - throw new InvalidArgumentException('$dsn is not a valid uri.'); - return $uri; - } - - /** @param array $uri */ - private static function resolveBackend(array $uri): DbBackend { - static $backends = null; - if(!is_array($backends)) - $backends = []; - - $scheme = $uri['scheme']; - $backend = $backends[$scheme] ?? null; - - if(!($backend instanceof DbBackend)) { - if(!array_key_exists($scheme, self::DB_PROTOS)) - throw new RuntimeException('No implementation is available for the specified scheme.'); - - $backend = new (self::DB_PROTOS[$scheme]); - if(!$backend->isAvailable()) - throw new RuntimeException('Requested database backend is not available, likely due to missing dependencies.'); - - $backends[$scheme] = $backend; - } - - return $backend; - } - - /** - * Retrieves database backend based on DSN URL. - * - * Format of the DSN URLs are described in the documentation of the DbTools class as a whole. - * - * @param string $dsn URL to create database connection from. - * @throws InvalidArgumentException if $dsn is not a valid URL. - * @throws RuntimeException if no database connection can be made using the URL. - * @return DbBackend Database backend instance. - */ - public static function backend(string $dsn): DbBackend { - return self::resolveBackend(self::parseDsnUri($dsn)); - } - - /** - * Parses a DSN URL. - * - * Format of the DSN URLs are described in the documentation of the DbTools class as a whole. - * - * @param string $dsn URL to create database connection from. - * @throws InvalidArgumentException if $dsn is not a valid URL. - * @throws RuntimeException if no database connection can be made using the URL. - * @return DbConnectionInfo Database connection info. - */ - public static function parse(string $dsn): DbConnectionInfo { - $uri = self::parseDsnUri($dsn); - return self::resolveBackend($uri)->parseDsn($uri); - } - - /** - * Uses a DSN URL to create a database instance. - * - * Format of the DSN URLs are described in the documentation of the DbTools class as a whole. - * - * @param string $dsn URL to create database connection from. - * @throws InvalidArgumentException if $dsn is not a valid URL. - * @throws RuntimeException if no database connection can be made using the URL. - * @return DbConnection An active database connection. - */ - public static function create(string $dsn): DbConnection { - $uri = self::parseDsnUri($dsn); - $backend = self::resolveBackend($uri); - return $backend->createConnection($backend->parseDsn($uri)); - } - /** * Transaction wrapper. * diff --git a/src/Db/MariaDb/_ndx.php b/src/Db/MariaDb/_ndx.php new file mode 100644 index 0000000..4219cd8 --- /dev/null +++ b/src/Db/MariaDb/_ndx.php @@ -0,0 +1,7 @@ +assertInstanceOf(NullDbConnection::class, $nullDbConn1); + + $maria = new MariaDbBackend; + $sqlite = new SqliteBackend; + + // flashii style misuzu connections details + $mci1 = $maria->parseDsn('mariadb://flashii:TiFGzCTxzx0n2HYzmNZpa98j255X7W4B@:unix:/flashii/misuzu?socket=/var/run/mysqld/mysqld.sock&charset=utf8mb4&init=SET SESSION time_zone = \'+00:00\', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\''); + + $this->assertTrue($mci1->isUnixSocket()); + $this->assertEquals('/var/run/mysqld/mysqld.sock', (string)$mci1->getEndPoint()); + $this->assertEquals('', $mci1->getHost()); + $this->assertEquals(-1, $mci1->getPort()); + $this->assertEquals('/var/run/mysqld/mysqld.sock', $mci1->getSocketPath()); + $this->assertEquals('flashii', $mci1->getUserName()); + $this->assertEquals('TiFGzCTxzx0n2HYzmNZpa98j255X7W4B', $mci1->getPassword()); + $this->assertEquals('flashii_misuzu', $mci1->getDatabaseName()); + $this->assertTrue($mci1->hasCharacterSet()); + $this->assertEquals('utf8mb4', $mci1->getCharacterSet()); + $this->assertTrue($mci1->hasInitCommand()); + $this->assertEquals('SET SESSION time_zone = \'+00:00\', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\'', $mci1->getInitCommand()); + $this->assertFalse($mci1->isSecure()); + $this->assertNull($mci1->getKeyPath()); + $this->assertNull($mci1->getCertificatePath()); + $this->assertNull($mci1->getCertificateAuthorityPath()); + $this->assertNull($mci1->getTrustedCertificatesPath()); + $this->assertNull($mci1->getCipherAlgorithms()); + $this->assertTrue($mci1->shouldVerifyCertificate()); + $this->assertFalse($mci1->shouldUseCompression()); + + // generic ipv4 + $mci2 = $maria->parseDsn('mariadb://username:password@127.0.0.1/database'); + $this->assertEquals('127.0.0.1', (string)$mci2->getEndPoint()); + $this->assertEquals('127.0.0.1', $mci2->getHost()); + $this->assertEquals(3306, $mci2->getPort()); + $this->assertEquals('', $mci2->getSocketPath()); + $this->assertEquals('username', $mci2->getUserName()); + $this->assertEquals('password', $mci2->getPassword()); + $this->assertEquals('database', $mci2->getDatabaseName()); + $this->assertFalse($mci2->hasCharacterSet()); + $this->assertNull($mci2->getCharacterSet()); + $this->assertFalse($mci2->hasInitCommand()); + $this->assertNull($mci2->getInitCommand()); + $this->assertFalse($mci2->isSecure()); + $this->assertNull($mci2->getKeyPath()); + $this->assertNull($mci2->getCertificatePath()); + $this->assertNull($mci2->getCertificateAuthorityPath()); + $this->assertNull($mci2->getTrustedCertificatesPath()); + $this->assertNull($mci2->getCipherAlgorithms()); + $this->assertTrue($mci2->shouldVerifyCertificate()); + $this->assertFalse($mci2->shouldUseCompression()); + + // generic ipv6 with port + $mci3 = $maria->parseDsn('mysql://username:password@[::1]:9001/database'); + $this->assertEquals('[::1]:9001', (string)$mci3->getEndPoint()); + $this->assertEquals('::1', $mci3->getHost()); + $this->assertEquals(9001, $mci3->getPort()); + $this->assertEquals('', $mci3->getSocketPath()); + $this->assertEquals('username', $mci3->getUserName()); + $this->assertEquals('password', $mci3->getPassword()); + $this->assertEquals('database', $mci3->getDatabaseName()); + $this->assertFalse($mci3->hasCharacterSet()); + $this->assertNull($mci3->getCharacterSet()); + $this->assertFalse($mci3->hasInitCommand()); + $this->assertNull($mci3->getInitCommand()); + $this->assertFalse($mci3->isSecure()); + $this->assertNull($mci3->getKeyPath()); + $this->assertNull($mci3->getCertificatePath()); + $this->assertNull($mci3->getCertificateAuthorityPath()); + $this->assertNull($mci3->getTrustedCertificatesPath()); + $this->assertNull($mci3->getCipherAlgorithms()); + $this->assertTrue($mci3->shouldVerifyCertificate()); + $this->assertFalse($mci3->shouldUseCompression()); + + // sqlite normal + $sql1 = $sqlite->parseDsn('sqlite:/path/to/database.db?key=ebwOKzGkLYIxDGXk&openOnly'); + $this->assertEquals('/path/to/database.db', $sql1->getFileName()); + $this->assertEquals('ebwOKzGkLYIxDGXk', $sql1->getEncryptionKey()); + $this->assertFalse($sql1->shouldReadOnly()); + $this->assertFalse($sql1->shouldCreate()); + + // sqlite temp file + $sql1 = $sqlite->parseDsn('sqlite:?key=Y63vrCttK8bEdPUIXVCOiLQKIgdbUn8V&readOnly'); + $this->assertEquals('', $sql1->getFileName()); + $this->assertEquals('Y63vrCttK8bEdPUIXVCOiLQKIgdbUn8V', $sql1->getEncryptionKey()); + $this->assertTrue($sql1->shouldReadOnly()); + $this->assertTrue($sql1->shouldCreate()); + + // sqlite memory + $sql1 = $sqlite->parseDsn('sqlite::memory:'); + $this->assertEquals(':memory:', $sql1->getFileName()); + $this->assertEquals('', $sql1->getEncryptionKey()); + $this->assertFalse($sql1->shouldReadOnly()); + $this->assertTrue($sql1->shouldCreate()); + } +} diff --git a/tests/DbConfigTest.php b/tests/DbConfigTest.php index 5dc8778..45d27d9 100644 --- a/tests/DbConfigTest.php +++ b/tests/DbConfigTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; use Index\Config\{GetValueInfoTrait,GetValuesTrait,MutableConfigTrait}; use Index\Config\Db\{DbConfig,DbConfigValueInfo}; -use Index\Db\{DbConnection,DbTools}; +use Index\Db\{DbBackends,DbConnection}; #[CoversClass(DbConfig::class)] #[CoversClass(DbConfigValueInfo::class)] @@ -43,7 +43,7 @@ final class DbConfigTest extends TestCase { ]; protected function setUp(): void { - $this->dbConn = DbTools::create('sqlite::memory:'); + $this->dbConn = DbBackends::create('sqlite::memory:'); $this->dbConn->execute('CREATE TABLE skh_config (config_name TEXT NOT NULL COLLATE NOCASE, config_value BLOB NOT NULL, PRIMARY KEY (config_name))'); $this->dbConn->execute('CREATE TABLE skh_user_settings (user_id INTEGER NOT NULL, setting_name TEXT NOT NULL COLLATE NOCASE, setting_value BLOB NOT NULL, PRIMARY KEY (user_id, setting_name))'); diff --git a/tests/DbToolsTest.php b/tests/DbToolsTest.php index 3b1839d..644e97e 100644 --- a/tests/DbToolsTest.php +++ b/tests/DbToolsTest.php @@ -6,17 +6,11 @@ declare(strict_types=1); use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\Attributes\{CoversClass,UsesClass}; +use PHPUnit\Framework\Attributes\CoversClass; use Index\Db\{DbTools,DbType}; -use Index\Db\MariaDb\MariaDbBackend; -use Index\Db\NullDb\NullDbConnection; -use Index\Db\Sqlite\SqliteBackend; #[CoversClass(DbTools::class)] #[CoversClass(DbType::class)] -#[CoversClass(NullDbConnection::class)] -#[CoversClass(MariaDbBackend::class)] -#[CoversClass(SqliteBackend::class)] final class DbToolsTest extends TestCase { public function testDetectType(): void { $this->assertEquals(DbType::NULL, DbTools::detectType(null)); @@ -33,101 +27,4 @@ final class DbToolsTest extends TestCase { $this->assertEquals(DbType::BLOB, DbTools::detectType($blob)); } - - public function testDSN(): void { - $nullDbConn1 = DbTools::create('null:'); - $this->assertInstanceOf(NullDbConnection::class, $nullDbConn1); - - $maria = new MariaDbBackend; - $sqlite = new SqliteBackend; - - // flashii style misuzu connections details - $mci1 = $maria->parseDsn('mariadb://flashii:TiFGzCTxzx0n2HYzmNZpa98j255X7W4B@:unix:/flashii/misuzu?socket=/var/run/mysqld/mysqld.sock&charset=utf8mb4&init=SET SESSION time_zone = \'+00:00\', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\''); - - $this->assertTrue($mci1->isUnixSocket()); - $this->assertEquals('/var/run/mysqld/mysqld.sock', (string)$mci1->getEndPoint()); - $this->assertEquals('', $mci1->getHost()); - $this->assertEquals(-1, $mci1->getPort()); - $this->assertEquals('/var/run/mysqld/mysqld.sock', $mci1->getSocketPath()); - $this->assertEquals('flashii', $mci1->getUserName()); - $this->assertEquals('TiFGzCTxzx0n2HYzmNZpa98j255X7W4B', $mci1->getPassword()); - $this->assertEquals('flashii_misuzu', $mci1->getDatabaseName()); - $this->assertTrue($mci1->hasCharacterSet()); - $this->assertEquals('utf8mb4', $mci1->getCharacterSet()); - $this->assertTrue($mci1->hasInitCommand()); - $this->assertEquals('SET SESSION time_zone = \'+00:00\', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\'', $mci1->getInitCommand()); - $this->assertFalse($mci1->isSecure()); - $this->assertNull($mci1->getKeyPath()); - $this->assertNull($mci1->getCertificatePath()); - $this->assertNull($mci1->getCertificateAuthorityPath()); - $this->assertNull($mci1->getTrustedCertificatesPath()); - $this->assertNull($mci1->getCipherAlgorithms()); - $this->assertTrue($mci1->shouldVerifyCertificate()); - $this->assertFalse($mci1->shouldUseCompression()); - - // generic ipv4 - $mci2 = $maria->parseDsn('mariadb://username:password@127.0.0.1/database'); - $this->assertEquals('127.0.0.1', (string)$mci2->getEndPoint()); - $this->assertEquals('127.0.0.1', $mci2->getHost()); - $this->assertEquals(3306, $mci2->getPort()); - $this->assertEquals('', $mci2->getSocketPath()); - $this->assertEquals('username', $mci2->getUserName()); - $this->assertEquals('password', $mci2->getPassword()); - $this->assertEquals('database', $mci2->getDatabaseName()); - $this->assertFalse($mci2->hasCharacterSet()); - $this->assertNull($mci2->getCharacterSet()); - $this->assertFalse($mci2->hasInitCommand()); - $this->assertNull($mci2->getInitCommand()); - $this->assertFalse($mci2->isSecure()); - $this->assertNull($mci2->getKeyPath()); - $this->assertNull($mci2->getCertificatePath()); - $this->assertNull($mci2->getCertificateAuthorityPath()); - $this->assertNull($mci2->getTrustedCertificatesPath()); - $this->assertNull($mci2->getCipherAlgorithms()); - $this->assertTrue($mci2->shouldVerifyCertificate()); - $this->assertFalse($mci2->shouldUseCompression()); - - // generic ipv6 with port - $mci3 = $maria->parseDsn('mysql://username:password@[::1]:9001/database'); - $this->assertEquals('[::1]:9001', (string)$mci3->getEndPoint()); - $this->assertEquals('::1', $mci3->getHost()); - $this->assertEquals(9001, $mci3->getPort()); - $this->assertEquals('', $mci3->getSocketPath()); - $this->assertEquals('username', $mci3->getUserName()); - $this->assertEquals('password', $mci3->getPassword()); - $this->assertEquals('database', $mci3->getDatabaseName()); - $this->assertFalse($mci3->hasCharacterSet()); - $this->assertNull($mci3->getCharacterSet()); - $this->assertFalse($mci3->hasInitCommand()); - $this->assertNull($mci3->getInitCommand()); - $this->assertFalse($mci3->isSecure()); - $this->assertNull($mci3->getKeyPath()); - $this->assertNull($mci3->getCertificatePath()); - $this->assertNull($mci3->getCertificateAuthorityPath()); - $this->assertNull($mci3->getTrustedCertificatesPath()); - $this->assertNull($mci3->getCipherAlgorithms()); - $this->assertTrue($mci3->shouldVerifyCertificate()); - $this->assertFalse($mci3->shouldUseCompression()); - - // sqlite normal - $sql1 = $sqlite->parseDsn('sqlite:/path/to/database.db?key=ebwOKzGkLYIxDGXk&openOnly'); - $this->assertEquals('/path/to/database.db', $sql1->getFileName()); - $this->assertEquals('ebwOKzGkLYIxDGXk', $sql1->getEncryptionKey()); - $this->assertFalse($sql1->shouldReadOnly()); - $this->assertFalse($sql1->shouldCreate()); - - // sqlite temp file - $sql1 = $sqlite->parseDsn('sqlite:?key=Y63vrCttK8bEdPUIXVCOiLQKIgdbUn8V&readOnly'); - $this->assertEquals('', $sql1->getFileName()); - $this->assertEquals('Y63vrCttK8bEdPUIXVCOiLQKIgdbUn8V', $sql1->getEncryptionKey()); - $this->assertTrue($sql1->shouldReadOnly()); - $this->assertTrue($sql1->shouldCreate()); - - // sqlite memory - $sql1 = $sqlite->parseDsn('sqlite::memory:'); - $this->assertEquals(':memory:', $sql1->getFileName()); - $this->assertEquals('', $sql1->getEncryptionKey()); - $this->assertFalse($sql1->shouldReadOnly()); - $this->assertTrue($sql1->shouldCreate()); - } }