diff --git a/composer.json b/composer.json index e0d84c48..79e6bc96 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ ], "require": { "php": ">=8.4", + "doctrine/dbal": "^4", "psr/log": "^1|^2|^3", "symfony/process": "^5.0|^6.0|^7.0", "symfony/polyfill-mbstring": "^1.15" diff --git a/examples/query_builder_sqlite.php b/examples/query_builder_sqlite.php new file mode 100644 index 00000000..6bdf445c --- /dev/null +++ b/examples/query_builder_sqlite.php @@ -0,0 +1,195 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +/** + * Runnable demo of Connection::createQueryBuilder() backed by an in-memory SQLite database. + * + * Usage (from the repo root): + * + * php examples/query_builder_sqlite.php + * + * Requires `doctrine/dbal:^4` to be installed (already in require-dev). + */ + +use Artemeon\Database\Connection; +use Artemeon\Database\ConnectionParameters; +use Artemeon\Database\DriverFactory; +use Artemeon\Database\Schema\DataType; + +require __DIR__ . '/../vendor/autoload.php'; + +$params = new ConnectionParameters( + host: 'localhost', + username: '', + password: '', + database: ':memory:', + port: null, + driver: 'sqlite3', +); + +$connection = new Connection($params, new DriverFactory()); + +// --- Schema + seed ----------------------------------------------------------- + +$table = 'demo_books'; +$tableReviews = 'demo_reviews'; + +$connection->dropTable($table); +$connection->dropTable($tableReviews); + +$connection->createTable( + $table, + [ + 'id' => [DataType::CHAR20, false], + 'title' => [DataType::CHAR100, false], + 'author' => [DataType::CHAR100, false], + 'year' => [DataType::INT, true], + ], + ['id'], +); + +$connection->createTable( + $tableReviews, + [ + 'id' => [DataType::CHAR20, false], + 'book_id' => [DataType::CHAR20, false], + 'stars' => [DataType::INT, true], + 'comment' => [DataType::CHAR100, true], + ], + ['id'], +); + +$books = [ + ['id' => 'b-1', 'title' => 'The Pragmatic Programmer', 'author' => 'Andy Hunt', 'year' => 1999], + ['id' => 'b-2', 'title' => 'Refactoring', 'author' => 'Martin Fowler', 'year' => 1999], + ['id' => 'b-3', 'title' => 'Domain-Driven Design', 'author' => 'Eric Evans', 'year' => 2003], + ['id' => 'b-4', 'title' => 'Clean Code', 'author' => 'Robert Martin', 'year' => 2008], + ['id' => 'b-5', 'title' => 'Designing Data-Intensive Applications', 'author' => 'Martin Kleppmann', 'year' => 2017], +]; +foreach ($books as $book) { + $connection->insert($table, $book); +} + +$reviews = [ + ['id' => 'r-1', 'book_id' => 'b-1', 'stars' => 5, 'comment' => 'Essential read'], + ['id' => 'r-2', 'book_id' => 'b-1', 'stars' => 4, 'comment' => 'Very practical'], + ['id' => 'r-3', 'book_id' => 'b-3', 'stars' => 5, 'comment' => 'Changed how I think'], + ['id' => 'r-4', 'book_id' => 'b-4', 'stars' => 3, 'comment' => 'Good but dated'], + ['id' => 'r-5', 'book_id' => 'b-5', 'stars' => 5, 'comment' => 'Comprehensive'], +]; +foreach ($reviews as $review) { + $connection->insert($tableReviews, $review); +} + +// --- Build queries via the Doctrine DBAL QueryBuilder ------------------------ + +echo "1) SELECT with WHERE + ORDER BY (positional parameter)\n"; +echo "------------------------------------------------------\n"; +$rows = $connection->createQueryBuilder() + ->select('title', 'author', 'year') + ->from($table) + ->where('year >= ?') + ->orderBy('year', 'ASC') + ->setParameter(0, 2000) + ->executeQuery() + ->fetchAllAssociative(); + +foreach ($rows as $row) { + printf(" %d %-45s %s\n", $row['year'], $row['title'], $row['author']); +} + +echo "\n2) SELECT with named parameter (DBAL rewrites to positional)\n"; +echo "------------------------------------------------------------\n"; +$row = $connection->createQueryBuilder() + ->select('title', 'author') + ->from($table) + ->where('id = :id') + ->setParameter('id', 'b-3') + ->executeQuery() + ->fetchAssociative(); + +printf(" Book b-3 → %s by %s\n", $row['title'], $row['author']); + +echo "\n3) UPDATE returning the affected row count\n"; +echo "-----------------------------------------\n"; +$affected = $connection->createQueryBuilder() + ->update($table) + ->set('author', '?') + ->where('id = ?') + ->setParameter(0, 'Andy Hunt and Dave Thomas') + ->setParameter(1, 'b-1') + ->executeStatement(); + +printf(" Updated %d row(s).\n", $affected); + +$confirmed = $connection->fetchOne( + 'SELECT author FROM ' . $table . ' WHERE id = ?', + ['b-1'], +); +printf(" New author for b-1: %s\n", $confirmed); + +echo "\n4) Aggregate via QueryBuilder\n"; +echo "-----------------------------\n"; +$count = $connection->createQueryBuilder() + ->select('COUNT(*)') + ->from($table) + ->executeQuery() + ->fetchOne(); + +printf(" Total books in table: %d\n", $count); + +echo "\n5) Complex WHERE with AND, OR, and IN\n"; +echo "---------------------------------------\n"; +// Books published before 2000 OR after 2010, but only from a specific id set +$qb = $connection->createQueryBuilder(); +$rows = $qb + ->select('title', 'author', 'year') + ->from($table) + ->where( + $qb->expr()->and( + $qb->expr()->or( + $qb->expr()->lt('year', ':cutoff_low'), + $qb->expr()->gt('year', ':cutoff_high'), + ), + $qb->expr()->in('id', [':b1', ':b2', ':b3']), + ), + ) + ->setParameter('cutoff_low', 2000) + ->setParameter('cutoff_high', 2010) + ->setParameter('b1', 'b-1') + ->setParameter('b2', 'b-3') + ->setParameter('b3', 'b-5') + ->orderBy('year', 'ASC') + ->executeQuery() + ->fetchAllAssociative(); + +foreach ($rows as $row) { + printf(" %d %-45s %s\n", $row['year'], $row['title'], $row['author']); +} + +echo "\n6) JOIN books with reviews, filter by minimum rating\n"; +echo "------------------------------------------------------\n"; +$qb = $connection->createQueryBuilder(); +$rows = $qb + ->select('b.title', 'b.author', 'r.stars', 'r.comment') + ->from($table, 'b') + ->innerJoin('b', $tableReviews, 'r', 'r.book_id = b.id') + ->where($qb->expr()->gte('r.stars', ':min_stars')) + ->setParameter('min_stars', 5) + ->orderBy('b.year', 'ASC') + ->executeQuery() + ->fetchAllAssociative(); + +foreach ($rows as $row) { + printf(" %-45s %d★ %s\n", $row['title'], $row['stars'], $row['comment']); +} diff --git a/src/Connection.php b/src/Connection.php index be3472e6..fde78b73 100755 --- a/src/Connection.php +++ b/src/Connection.php @@ -13,6 +13,9 @@ namespace Artemeon\Database; +use Artemeon\Database\Doctrine\Driver\MysqlDriver as DoctrineMysqlDriver; +use Artemeon\Database\Doctrine\Driver\PostgresDriver as DoctrinePostgresDriver; +use Artemeon\Database\Doctrine\Driver\SqliteDriver as DoctrineSqliteDriver; use Artemeon\Database\Exception\AddColumnException; use Artemeon\Database\Exception\ChangeColumnException; use Artemeon\Database\Exception\CommitException; @@ -26,6 +29,9 @@ use Artemeon\Database\Schema\TableIndex; use BackedEnum; use Closure; +use Doctrine\DBAL\Configuration as DbalConfiguration; +use Doctrine\DBAL\Connection as DbalConnection; +use Doctrine\DBAL\Query\QueryBuilder; use Generator; use InvalidArgumentException; use Override; @@ -73,6 +79,8 @@ class Connection implements ConnectionInterface */ private int $numberCache = 0; + private ?DbalConnection $dbalConnection = null; + /** * Instance of the db-driver defined in the configs. */ @@ -350,6 +358,21 @@ public function executeStatement(string $query, array $params = []): int return $this->dbDriver->getAffectedRowsCount(); } + public function createQueryBuilder(): QueryBuilder + { + if ($this->dbalConnection === null) { + $driver = match ($this->connectionParams->getDriver()) { + 'mysqli' => new DoctrineMysqlDriver($this), + 'postgres' => new DoctrinePostgresDriver($this), + default => new DoctrineSqliteDriver($this), + }; + + $this->dbalConnection = new DbalConnection([], $driver, new DbalConfiguration()); + } + + return $this->dbalConnection->createQueryBuilder(); + } + /** * @inheritDoc */ diff --git a/src/Doctrine/Connection.php b/src/Doctrine/Connection.php new file mode 100644 index 00000000..c963e3ae --- /dev/null +++ b/src/Doctrine/Connection.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Artemeon\Database\Doctrine; + +use Artemeon\Database\Connection as ArtemeonConnection; +use Doctrine\DBAL\Driver\Connection as DriverConnection; +use Doctrine\DBAL\Driver\Exception\NoIdentityValue; +use Doctrine\DBAL\Driver\Result as DriverResult; +use Doctrine\DBAL\Driver\Statement as DriverStatement; + +/** + * DBAL driver connection that delegates to an Artemeon Connection. + * + * Platform selection happens in the {@see Driver} subclasses; this class only + * wires DBAL's per-query primitives to the matching Artemeon Connection calls. + * The server-version string is supplied by the Driver and used by DBAL solely + * to pick a platform variant (MySQL 8.4 vs 8.0, MariaDB 11.7 vs 10.6, etc.). + */ +final class Connection implements DriverConnection +{ + public function __construct( + private readonly ArtemeonConnection $connection, + private readonly string $serverVersion, + ) { + } + + public function prepare(string $sql): DriverStatement + { + return new Statement($this->connection, $sql); + } + + public function query(string $sql): DriverResult + { + return $this->prepare($sql)->execute(); + } + + public function quote(string $value): string + { + return "'" . str_replace(['\\', "'"], ['\\\\', "''"], $value) . "'"; + } + + public function exec(string $sql): int + { + return $this->connection->executeStatement($sql); + } + + public function lastInsertId(): int | string + { + throw NoIdentityValue::new(); + } + + public function beginTransaction(): void + { + $this->connection->beginTransaction(); + } + + public function commit(): void + { + $this->connection->commit(); + } + + public function rollBack(): void + { + $this->connection->rollBack(); + } + + public function getNativeConnection(): object + { + return $this->connection; + } + + public function getServerVersion(): string + { + return $this->serverVersion; + } +} diff --git a/src/Doctrine/Driver/MysqlDriver.php b/src/Doctrine/Driver/MysqlDriver.php new file mode 100644 index 00000000..c6c3064a --- /dev/null +++ b/src/Doctrine/Driver/MysqlDriver.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Artemeon\Database\Doctrine\Driver; + +use Artemeon\Database\Connection as ArtemeonConnection; +use Artemeon\Database\Doctrine\Connection as DriverConnectionAdapter; +use Doctrine\DBAL\Driver\AbstractMySQLDriver; +use Doctrine\DBAL\Driver\Connection as DriverConnection; +use SensitiveParameter; + +/** + * DBAL driver façade for an Artemeon Mysqli driver. + */ +final class MysqlDriver extends AbstractMySQLDriver +{ + public function __construct(private readonly ArtemeonConnection $connection) + { + } + + public function connect(#[SensitiveParameter] array $params): DriverConnection + { + $version = $this->connection->fetchOne('SELECT VERSION()'); + + return new DriverConnectionAdapter($this->connection, is_string($version) ? $version : '8.0.0'); + } +} diff --git a/src/Doctrine/Driver/PostgresDriver.php b/src/Doctrine/Driver/PostgresDriver.php new file mode 100644 index 00000000..8f0e17e0 --- /dev/null +++ b/src/Doctrine/Driver/PostgresDriver.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Artemeon\Database\Doctrine\Driver; + +use Artemeon\Database\Connection as ArtemeonConnection; +use Artemeon\Database\Doctrine\Connection as DriverConnectionAdapter; +use Doctrine\DBAL\Driver\AbstractPostgreSQLDriver; +use Doctrine\DBAL\Driver\Connection as DriverConnection; +use SensitiveParameter; + +/** + * DBAL driver façade for an Artemeon Postgres driver. + */ +final class PostgresDriver extends AbstractPostgreSQLDriver +{ + public function __construct(private readonly ArtemeonConnection $connection) + { + } + + public function connect(#[SensitiveParameter] array $params): DriverConnection + { + return new DriverConnectionAdapter($this->connection, '16.0'); + } +} diff --git a/src/Doctrine/Driver/SqliteDriver.php b/src/Doctrine/Driver/SqliteDriver.php new file mode 100644 index 00000000..0b50adf3 --- /dev/null +++ b/src/Doctrine/Driver/SqliteDriver.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Artemeon\Database\Doctrine\Driver; + +use Artemeon\Database\Connection as ArtemeonConnection; +use Artemeon\Database\Doctrine\Connection as DriverConnectionAdapter; +use Doctrine\DBAL\Driver\AbstractSQLiteDriver; +use Doctrine\DBAL\Driver\Connection as DriverConnection; +use SensitiveParameter; + +/** + * DBAL driver façade for an Artemeon Sqlite3 driver. + */ +final class SqliteDriver extends AbstractSQLiteDriver +{ + public function __construct(private readonly ArtemeonConnection $connection) + { + } + + public function connect(#[SensitiveParameter] array $params): DriverConnection + { + return new DriverConnectionAdapter($this->connection, '3.0.0'); + } +} diff --git a/src/Doctrine/Result.php b/src/Doctrine/Result.php new file mode 100644 index 00000000..20107b26 --- /dev/null +++ b/src/Doctrine/Result.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Artemeon\Database\Doctrine; + +use Doctrine\DBAL\Driver\Result as DriverResult; + +/** + * Buffered DBAL driver result backed by a pre-fetched row array. + * + * The Artemeon connection materialises full result sets, so the adapter + * owns the rows up-front and replays them through the DBAL fetch API. + */ +final class Result implements DriverResult +{ + private int $position = 0; + + /** + * @param list> $rows + */ + public function __construct( + private array $rows, + private readonly int $affectedRows, + ) { + } + + public function fetchNumeric(): array | false + { + $row = $this->rows[$this->position] ?? null; + if ($row === null) { + return false; + } + + $this->position++; + + return array_values($row); + } + + public function fetchAssociative(): array | false + { + $row = $this->rows[$this->position] ?? null; + if ($row === null) { + return false; + } + + $this->position++; + + return $row; + } + + public function fetchOne(): mixed + { + $row = $this->fetchNumeric(); + if ($row === false) { + return false; + } + + return $row[0] ?? null; + } + + public function fetchAllNumeric(): array + { + $out = []; + while (($row = $this->fetchNumeric()) !== false) { + $out[] = $row; + } + + return $out; + } + + public function fetchAllAssociative(): array + { + $out = []; + while (($row = $this->fetchAssociative()) !== false) { + $out[] = $row; + } + + return $out; + } + + public function fetchFirstColumn(): array + { + $out = []; + while (($row = $this->fetchNumeric()) !== false) { + $out[] = $row[0]; + } + + return $out; + } + + public function rowCount(): int + { + return $this->affectedRows; + } + + public function columnCount(): int + { + $first = $this->rows[0] ?? null; + + return $first === null ? 0 : count($first); + } + + public function free(): void + { + $this->rows = []; + $this->position = 0; + } +} diff --git a/src/Doctrine/Statement.php b/src/Doctrine/Statement.php new file mode 100644 index 00000000..e81fd1c1 --- /dev/null +++ b/src/Doctrine/Statement.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Artemeon\Database\Doctrine; + +use Artemeon\Database\Connection as ArtemeonConnection; +use Doctrine\DBAL\Driver\Statement as DriverStatement; +use Doctrine\DBAL\ParameterType; +use LogicException; +use Stringable; + +/** + * DBAL driver statement that delegates to an Artemeon Connection. + * + * Values are buffered via {@see bindValue()} and replayed in positional order at + * {@see execute()} time. The Artemeon driver layer only accepts positional `?` + * placeholders, so named parameters are rejected (the DBAL Connection rewrites + * them to positional before reaching us). + */ +final class Statement implements DriverStatement +{ + /** @var array */ + private array $values = []; + + public function __construct( + private readonly ArtemeonConnection $connection, + private readonly string $sql, + ) { + } + + public function bindValue(int | string $param, mixed $value, ParameterType $type): void + { + if (is_string($param)) { + throw new LogicException( + 'Named parameters are not supported by the Artemeon DBAL adapter; use positional placeholders.', + ); + } + + if ($type === ParameterType::BOOLEAN && is_bool($value)) { + $value = (int) $value; + } + + if ($value instanceof Stringable) { + $value = (string) $value; + } + + if ($value !== null && !is_scalar($value)) { + throw new LogicException( + 'Unsupported parameter type: ' . get_debug_type($value), + ); + } + + $this->values[$param] = $value; + } + + public function execute(): Result + { + ksort($this->values); + $params = array_values($this->values); + + if (self::returnsResultSet($this->sql)) { + $rows = $this->connection->fetchAllAssociative($this->sql, $params); + + return new Result($rows, count($rows)); + } + + $this->connection->_pQuery($this->sql, $params, array_fill(0, count($params), false)); + $affected = $this->connection->getAffectedRowsCount(); + + return new Result([], $affected); + } + + /** + * Heuristic to decide whether a SQL string returns a result set. + * + * This exists because Artemeon splits execution into two separate methods — fetchAllAssociative() + * for queries and _pQuery() for statements — so the driver adapter must route before DBAL decides + * which result interface to use. A real PDO-backed driver never needs this because PDO's execute() + * returns a unified handle that supports both fetch() and rowCount() regardless of SQL type. + * + * Known limitation: writable CTEs (WITH … INSERT/UPDATE/DELETE) start with WITH and are therefore + * misclassified as queries, causing them to be routed through fetchAllAssociative() instead of + * _pQuery(). The proper fix is to add a unified execute-and-return-result method to the Artemeon + * Connection that returns both rows and an affected-row count in one call, eliminating the need + * for this heuristic entirely. + */ + private static function returnsResultSet(string $sql): bool + { + $head = strtoupper(ltrim($sql, " \t\r\n(")); + foreach (['SELECT', 'WITH', 'SHOW', 'PRAGMA', 'EXPLAIN', 'DESCRIBE', 'VALUES', 'TABLE'] as $keyword) { + if (str_starts_with($head, $keyword)) { + return true; + } + } + + return false; + } +} diff --git a/tests/Doctrine/QueryBuilderTest.php b/tests/Doctrine/QueryBuilderTest.php new file mode 100644 index 00000000..dc896966 --- /dev/null +++ b/tests/Doctrine/QueryBuilderTest.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Artemeon\Database\Tests\Doctrine; + +use Artemeon\Database\Tests\ConnectionTestCase; + +/** + * @internal + */ +class QueryBuilderTest extends ConnectionTestCase +{ + public function testSelectAllReturnsSeededRows(): void + { + $qb = $this->getConnection()->createQueryBuilder() + ->select('temp_char10') + ->from(self::TEST_TABLE_NAME) + ->orderBy('temp_bigint', 'ASC'); + + $rows = $qb->executeQuery()->fetchAllAssociative(); + + $this->assertCount(50, $rows); + $this->assertSame('char10-1', $rows[0]['temp_char10']); + $this->assertSame('char10-10', $rows[9]['temp_char10']); + } + + public function testSelectWithPositionalParameter(): void + { + $qb = $this->getConnection()->createQueryBuilder() + ->select('temp_int', 'temp_char10') + ->from(self::TEST_TABLE_NAME) + ->where('temp_char10 = ?') + ->setParameter(0, 'char10-3'); + + $row = $qb->executeQuery()->fetchAssociative(); + + $this->assertIsArray($row); + $this->assertSame('char10-3', $row['temp_char10']); + $this->assertEquals(123459, $row['temp_int']); + } + + public function testSelectWithNamedParameterGetsRewrittenByDoctrine(): void + { + $qb = $this->getConnection()->createQueryBuilder() + ->select('temp_char20') + ->from(self::TEST_TABLE_NAME) + ->where('temp_char10 = :needle') + ->setParameter('needle', 'char10-7'); + + $value = $qb->executeQuery()->fetchOne(); + + $this->assertSame('char20-7', $value); + } + + public function testUpdateThroughQueryBuilderReturnsAffectedRows(): void + { + $qb = $this->getConnection()->createQueryBuilder() + ->update(self::TEST_TABLE_NAME) + ->set('temp_char100', '?') + ->where('temp_char10 = ?') + ->setParameter(0, 'updated-via-qb') + ->setParameter(1, 'char10-5'); + + $affected = $qb->executeStatement(); + + $this->assertSame(1, $affected); + + $check = $this->getConnection()->fetchOne( + 'SELECT temp_char100 FROM ' . self::TEST_TABLE_NAME . ' WHERE temp_char10 = ?', + ['char10-5'], + ); + $this->assertSame('updated-via-qb', $check); + } +}