From 0f22b77b5c544649f77ae950dffdc899920cc747 Mon Sep 17 00:00:00 2001 From: Roman Konz Date: Tue, 23 Jun 2026 22:50:15 +0200 Subject: [PATCH 1/5] feat: Add Doctrine DBAL QueryBuilder support via Connection::createQueryBuilder() Introduces a thin adapter layer (src/Doctrine/) that bridges the Artemeon Connection to Doctrine DBAL's Driver interface, enabling use of the DBAL QueryBuilder against all three supported databases (MySQL, PostgreSQL, SQLite). - Connection::createQueryBuilder() returns a DBAL QueryBuilder backed by the existing connection (no new physical connection is opened; DBAL instance is cached per Connection object) - DML parameters are passed without HTML-escaping to match the behaviour of the existing fetch methods - MysqlDriver queries SELECT VERSION() once to let DBAL pick the correct platform (MySQL vs MariaDB) instead of hard-coding a version string - Connection::quote() escapes backslashes before single quotes to prevent SQL injection on MySQL with default sql_mode - exec() return type widened to int|string to match the DriverConnection interface contract - fetchFirstColumn() sentinel fix: loops on fetchNumeric() to avoid false-cell truncation Co-Authored-By: Claude Sonnet 4.6 --- examples/query_builder_sqlite.php | 123 +++++++++++++++++++++++++ src/Connection.php | 23 +++++ src/Doctrine/Connection.php | 87 +++++++++++++++++ src/Doctrine/Driver/MysqlDriver.php | 37 ++++++++ src/Doctrine/Driver/PostgresDriver.php | 35 +++++++ src/Doctrine/Driver/SqliteDriver.php | 35 +++++++ src/Doctrine/Result.php | 118 ++++++++++++++++++++++++ src/Doctrine/Statement.php | 108 ++++++++++++++++++++++ src/MockConnection.php | 2 +- tests/Doctrine/QueryBuilderTest.php | 84 +++++++++++++++++ 10 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 examples/query_builder_sqlite.php create mode 100644 src/Doctrine/Connection.php create mode 100644 src/Doctrine/Driver/MysqlDriver.php create mode 100644 src/Doctrine/Driver/PostgresDriver.php create mode 100644 src/Doctrine/Driver/SqliteDriver.php create mode 100644 src/Doctrine/Result.php create mode 100644 src/Doctrine/Statement.php create mode 100644 tests/Doctrine/QueryBuilderTest.php diff --git a/examples/query_builder_sqlite.php b/examples/query_builder_sqlite.php new file mode 100644 index 00000000..a7802249 --- /dev/null +++ b/examples/query_builder_sqlite.php @@ -0,0 +1,123 @@ + + * + * 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'; +$connection->dropTable($table); +$connection->createTable( + $table, + [ + 'id' => [DataType::CHAR20, false], + 'title' => [DataType::CHAR100, false], + 'author' => [DataType::CHAR100, false], + 'year' => [DataType::INT, 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); +} + +// --- 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); diff --git a/src/Connection.php b/src/Connection.php index be3472e6..3af66e21 100755 --- a/src/Connection.php +++ b/src/Connection.php @@ -13,7 +13,13 @@ 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 Doctrine\DBAL\Configuration as DbalConfiguration; +use Doctrine\DBAL\Connection as DbalConnection; +use Doctrine\DBAL\Query\QueryBuilder; use Artemeon\Database\Exception\ChangeColumnException; use Artemeon\Database\Exception\CommitException; use Artemeon\Database\Exception\ConnectionException; @@ -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..6482e692 --- /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|string + { + 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..6859cdbb --- /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 = (string) $this->connection->fetchOne('SELECT VERSION()'); + + return new DriverConnectionAdapter($this->connection, $version); + } +} 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..88ec3abd --- /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/src/MockConnection.php b/src/MockConnection.php index 565579ee..90098860 100644 --- a/src/MockConnection.php +++ b/src/MockConnection.php @@ -150,7 +150,7 @@ public function executeStatement(string $query, array $params = []): int return 1; } - #[Override] +#[Override] public function getAffectedRowsCount(): int { return 1; 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); + } +} From 889386e0af6a5b05abc22ef56fee06ab265bade2 Mon Sep 17 00:00:00 2001 From: Roman Konz Date: Tue, 23 Jun 2026 22:52:18 +0200 Subject: [PATCH 2/5] docs: Add complex AND/OR/IN example to query_builder_sqlite demo Co-Authored-By: Claude Sonnet 4.6 --- examples/query_builder_sqlite.php | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/examples/query_builder_sqlite.php b/examples/query_builder_sqlite.php index a7802249..3369f5fb 100644 --- a/examples/query_builder_sqlite.php +++ b/examples/query_builder_sqlite.php @@ -121,3 +121,32 @@ ->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']); +} From c23ae868ee254754767d970a08d76d84db9c1b54 Mon Sep 17 00:00:00 2001 From: Roman Konz Date: Tue, 23 Jun 2026 22:53:30 +0200 Subject: [PATCH 3/5] docs: Add JOIN example and reviews table to query_builder_sqlite demo Co-Authored-By: Claude Sonnet 4.6 --- examples/query_builder_sqlite.php | 57 +++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/examples/query_builder_sqlite.php b/examples/query_builder_sqlite.php index 3369f5fb..c67662d5 100644 --- a/examples/query_builder_sqlite.php +++ b/examples/query_builder_sqlite.php @@ -42,29 +42,55 @@ // --- 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], + 'id' => [DataType::CHAR20, false], + 'title' => [DataType::CHAR100, false], 'author' => [DataType::CHAR100, false], - 'year' => [DataType::INT, true], + '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-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"; @@ -150,3 +176,20 @@ 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']); +} From 5b271f6c01342cc6d208ff859c283e7d7a262619 Mon Sep 17 00:00:00 2001 From: Roman Konz Date: Tue, 23 Jun 2026 22:55:16 +0200 Subject: [PATCH 4/5] fix: Resolve PHPStan errors in Doctrine adapter - exec(): narrow return type back to int (covariant narrowing of the interface's int|string is valid; string is never returned in practice) - MysqlDriver: guard fetchOne() result with is_string() before passing as server version string (fetchOne returns mixed) Co-Authored-By: Claude Sonnet 4.6 --- src/Doctrine/Connection.php | 2 +- src/Doctrine/Driver/MysqlDriver.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Doctrine/Connection.php b/src/Doctrine/Connection.php index 6482e692..e30e07ba 100644 --- a/src/Doctrine/Connection.php +++ b/src/Doctrine/Connection.php @@ -50,7 +50,7 @@ public function quote(string $value): string return "'" . str_replace(["\\", "'"], ["\\\\", "''"], $value) . "'"; } - public function exec(string $sql): int|string + public function exec(string $sql): int { return $this->connection->executeStatement($sql); } diff --git a/src/Doctrine/Driver/MysqlDriver.php b/src/Doctrine/Driver/MysqlDriver.php index 6859cdbb..c6c3064a 100644 --- a/src/Doctrine/Driver/MysqlDriver.php +++ b/src/Doctrine/Driver/MysqlDriver.php @@ -30,8 +30,8 @@ public function __construct(private readonly ArtemeonConnection $connection) public function connect(#[SensitiveParameter] array $params): DriverConnection { - $version = (string) $this->connection->fetchOne('SELECT VERSION()'); + $version = $this->connection->fetchOne('SELECT VERSION()'); - return new DriverConnectionAdapter($this->connection, $version); + return new DriverConnectionAdapter($this->connection, is_string($version) ? $version : '8.0.0'); } } From 4b99ecd3080e2cda49215ab4dc2a4593aa108ec5 Mon Sep 17 00:00:00 2001 From: Roman Konz Date: Tue, 23 Jun 2026 22:58:07 +0200 Subject: [PATCH 5/5] fix: Add doctrine/dbal as a runtime dependency and fix CI failures - Add doctrine/dbal:^4 to require (was missing; only came in transitively via dev dependencies, causing AbstractSQLiteDriver not found in CI) - Apply Pint style fixes (binary_operator_spaces, whitespace_after_comma, ordered_imports, single_quote) Co-Authored-By: Claude Sonnet 4.6 --- composer.json | 1 + examples/query_builder_sqlite.php | 18 +++++++++--------- src/Connection.php | 6 +++--- src/Doctrine/Connection.php | 4 ++-- src/Doctrine/Statement.php | 2 +- src/MockConnection.php | 2 +- 6 files changed, 17 insertions(+), 16 deletions(-) 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 index c67662d5..6bdf445c 100644 --- a/examples/query_builder_sqlite.php +++ b/examples/query_builder_sqlite.php @@ -50,10 +50,10 @@ $connection->createTable( $table, [ - 'id' => [DataType::CHAR20, false], - 'title' => [DataType::CHAR100, false], + 'id' => [DataType::CHAR20, false], + 'title' => [DataType::CHAR100, false], 'author' => [DataType::CHAR100, false], - 'year' => [DataType::INT, true], + 'year' => [DataType::INT, true], ], ['id'], ); @@ -61,19 +61,19 @@ $connection->createTable( $tableReviews, [ - 'id' => [DataType::CHAR20, false], + 'id' => [DataType::CHAR20, false], 'book_id' => [DataType::CHAR20, false], - 'stars' => [DataType::INT, true], + '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-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) { diff --git a/src/Connection.php b/src/Connection.php index 3af66e21..fde78b73 100755 --- a/src/Connection.php +++ b/src/Connection.php @@ -17,9 +17,6 @@ use Artemeon\Database\Doctrine\Driver\PostgresDriver as DoctrinePostgresDriver; use Artemeon\Database\Doctrine\Driver\SqliteDriver as DoctrineSqliteDriver; use Artemeon\Database\Exception\AddColumnException; -use Doctrine\DBAL\Configuration as DbalConfiguration; -use Doctrine\DBAL\Connection as DbalConnection; -use Doctrine\DBAL\Query\QueryBuilder; use Artemeon\Database\Exception\ChangeColumnException; use Artemeon\Database\Exception\CommitException; use Artemeon\Database\Exception\ConnectionException; @@ -32,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; diff --git a/src/Doctrine/Connection.php b/src/Doctrine/Connection.php index e30e07ba..c963e3ae 100644 --- a/src/Doctrine/Connection.php +++ b/src/Doctrine/Connection.php @@ -47,7 +47,7 @@ public function query(string $sql): DriverResult public function quote(string $value): string { - return "'" . str_replace(["\\", "'"], ["\\\\", "''"], $value) . "'"; + return "'" . str_replace(['\\', "'"], ['\\\\', "''"], $value) . "'"; } public function exec(string $sql): int @@ -55,7 +55,7 @@ public function exec(string $sql): int return $this->connection->executeStatement($sql); } - public function lastInsertId(): int|string + public function lastInsertId(): int | string { throw NoIdentityValue::new(); } diff --git a/src/Doctrine/Statement.php b/src/Doctrine/Statement.php index 88ec3abd..e81fd1c1 100644 --- a/src/Doctrine/Statement.php +++ b/src/Doctrine/Statement.php @@ -38,7 +38,7 @@ public function __construct( ) { } - public function bindValue(int|string $param, mixed $value, ParameterType $type): void + public function bindValue(int | string $param, mixed $value, ParameterType $type): void { if (is_string($param)) { throw new LogicException( diff --git a/src/MockConnection.php b/src/MockConnection.php index 90098860..565579ee 100644 --- a/src/MockConnection.php +++ b/src/MockConnection.php @@ -150,7 +150,7 @@ public function executeStatement(string $query, array $params = []): int return 1; } -#[Override] + #[Override] public function getAffectedRowsCount(): int { return 1;