Skip to content
Draft
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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
195 changes: 195 additions & 0 deletions examples/query_builder_sqlite.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<?php

/*
* This file is part of the Artemeon Core - Web Application Framework.
*
* (c) Artemeon <www.artemeon.de>
*
* 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']);
}
23 changes: 23 additions & 0 deletions src/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
*/
Expand Down
87 changes: 87 additions & 0 deletions src/Doctrine/Connection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

/*
* This file is part of the Artemeon Core - Web Application Framework.
*
* (c) Artemeon <www.artemeon.de>
*
* 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;
}
}
37 changes: 37 additions & 0 deletions src/Doctrine/Driver/MysqlDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/*
* This file is part of the Artemeon Core - Web Application Framework.
*
* (c) Artemeon <www.artemeon.de>
*
* 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');

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(?)

}
}
Loading
Loading