From b24e918d7034d9268b24b3ca2252f4f0bdf9337a Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 19 Dec 2025 23:46:50 +0000 Subject: [PATCH 01/11] chore: add dev container support --- .devcontainer/devcontainer.json | 31 +++++++++++++++++++++++++++++++ .github/dependabot.yml | 12 ++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/dependabot.yml diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..59dd1418 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,31 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/php +{ + "name": "PHP", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/php:1-8.2-bullseye", + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Configure tool-specific properties. + // "customizations": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [ + 8080 + ], + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/php:1": { + "version": "8.2", + "installComposer": true + } + } + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "sudo chmod a+x \"$(pwd)\" && sudo rm -rf /var/www/html && sudo ln -s \"$(pwd)\" /var/www/html" + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..f33a02cd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly From 354f80e9eec31749ee0f54abf030bcabdbea9bbb Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 20 Dec 2025 08:07:47 -0500 Subject: [PATCH 02/11] feat(SQLite): add SQLite support with connection handling and database truncation --- composer.json | 1 + .../Connections/ConnectionFactory.php | 8 +++ src/Testing/Concerns/RefreshDatabase.php | 58 ++++++++++++++++++- tests/Unit/RefreshDatabaseTest.php | 27 +++++++++ .../fixtures/application/config/database.php | 4 ++ .../fixtures/application/database/.gitignore | 1 + 6 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/application/database/.gitignore diff --git a/composer.json b/composer.json index 836b55eb..58f6400e 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "require": { "php": "^8.2", "ext-pcntl": "*", + "ext-sockets": "*", "adbario/php-dot-notation": "^3.1", "amphp/cache": "^2.0", "amphp/cluster": "^2.0", diff --git a/src/Database/Connections/ConnectionFactory.php b/src/Database/Connections/ConnectionFactory.php index 611f20e0..4aac0f63 100644 --- a/src/Database/Connections/ConnectionFactory.php +++ b/src/Database/Connections/ConnectionFactory.php @@ -8,6 +8,7 @@ use Amp\Mysql\MysqlConnectionPool; use Amp\Postgres\PostgresConfig; use Amp\Postgres\PostgresConnectionPool; +use Amp\SQLite3\SQLite3WorkerConnection; use Closure; use InvalidArgumentException; use Phenix\Database\Constants\Driver; @@ -15,6 +16,7 @@ use SensitiveParameter; use function Amp\Redis\createRedisClient; +use function Amp\SQLite3\connect; use function sprintf; class ConnectionFactory @@ -25,12 +27,18 @@ public static function make(Driver $driver, #[SensitiveParameter] array $setting Driver::MYSQL => self::createMySqlConnection($settings), Driver::POSTGRESQL => self::createPostgreSqlConnection($settings), Driver::REDIS => self::createRedisConnection($settings), + Driver::SQLITE => self::createSqliteConnection($settings), default => throw new InvalidArgumentException( sprintf('Unsupported driver: %s', $driver->name) ), }; } + private static function createSqliteConnection(#[SensitiveParameter] array $settings): Closure + { + return static fn (): SQLite3WorkerConnection => connect($settings['database']); + } + private static function createMySqlConnection(#[SensitiveParameter] array $settings): Closure { return static function () use ($settings): MysqlConnectionPool { diff --git a/src/Testing/Concerns/RefreshDatabase.php b/src/Testing/Concerns/RefreshDatabase.php index 1cc9674a..c7f1dc65 100644 --- a/src/Testing/Concerns/RefreshDatabase.php +++ b/src/Testing/Concerns/RefreshDatabase.php @@ -67,11 +67,21 @@ protected function runMigrations(): void protected function truncateDatabase(): void { - /** @var SqlCommonConnectionPool $connection */ + /** @var SqlCommonConnectionPool|object $connection */ $connection = App::make(Connection::default()); $driver = $this->resolveDriver(); + if ($driver === Driver::SQLITE) { + try { + $this->truncateSqliteDatabase($connection); + } catch (Throwable $e) { + report($e); + } + + return; + } + try { $tables = $this->getDatabaseTables($connection, $driver); } catch (Throwable) { @@ -123,7 +133,6 @@ protected function getDatabaseTables(SqlCommonConnectionPool $connection, Driver } } } else { - // Unsupported driver (sqlite, etc.) – return empty so caller exits gracefully. return []; } @@ -165,4 +174,49 @@ protected function truncateTables(SqlCommonConnectionPool $connection, Driver $d report($e); } } + + protected function truncateSqliteDatabase(object $connection): void + { + $stmt = $connection->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"); + $result = $stmt->execute(); + + $tables = []; + + foreach ($result as $row) { + $table = $row['name'] ?? null; + + if ($table) { + $tables[] = $table; + } + } + + $tables = $this->filterTruncatableTables($tables); + + if (empty($tables)) { + return; + } + + try { + $connection->prepare('BEGIN IMMEDIATE')->execute(); + } catch (Throwable) { + // If BEGIN fails, continue best-effort without explicit transaction + } + + try { + foreach ($tables as $table) { + $connection->prepare('DELETE FROM ' . '"' . str_replace('"', '""', $table) . '"')->execute(); + } + + try { + $connection->prepare('DELETE FROM sqlite_sequence')->execute(); + } catch (Throwable) { + } + } finally { + try { + $connection->prepare('COMMIT')->execute(); + } catch (Throwable) { + // Best-effort commit; ignore errors + } + } + } } diff --git a/tests/Unit/RefreshDatabaseTest.php b/tests/Unit/RefreshDatabaseTest.php index 5f48887d..f4396e4d 100644 --- a/tests/Unit/RefreshDatabaseTest.php +++ b/tests/Unit/RefreshDatabaseTest.php @@ -65,3 +65,30 @@ $this->assertTrue(true); }); + +it('truncates tables for sqlite driver', function (): void { + Config::set('database.default', 'sqlite'); + + expect(Config::get('database.default'))->toBe('sqlite'); + + $connection = new class () { + public function prepare(string $sql): Statement + { + if (str_starts_with($sql, 'SELECT name FROM sqlite_master')) { + return new Statement(new Result([ + ['name' => 'users'], + ['name' => 'posts'], + ['name' => 'migrations'], + ])); + } + + return new Statement(new Result()); + } + }; + + $this->app->swap(Connection::default(), $connection); + + $this->refreshDatabase(); + + $this->assertTrue(true); +}); diff --git a/tests/fixtures/application/config/database.php b/tests/fixtures/application/config/database.php index 2bb5ceb4..10d3db79 100644 --- a/tests/fixtures/application/config/database.php +++ b/tests/fixtures/application/config/database.php @@ -6,6 +6,10 @@ 'default' => env('DB_CONNECTION', static fn () => 'mysql'), 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => env('DB_DATABASE', static fn () => base_path('database/database')), + ], 'mysql' => [ 'driver' => 'mysql', 'host' => env('DB_HOST', static fn () => '127.0.0.1'), diff --git a/tests/fixtures/application/database/.gitignore b/tests/fixtures/application/database/.gitignore new file mode 100644 index 00000000..885029a5 --- /dev/null +++ b/tests/fixtures/application/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* \ No newline at end of file From 20b8f0a87e5a4b669433d602338e20b26935bb90 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 22 Dec 2025 07:29:43 -0500 Subject: [PATCH 03/11] feat(ParallelQueue): disable processing when no running tasks and queue is empty --- src/Queue/ParallelQueue.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Queue/ParallelQueue.php b/src/Queue/ParallelQueue.php index 752ec3b6..31683444 100644 --- a/src/Queue/ParallelQueue.php +++ b/src/Queue/ParallelQueue.php @@ -153,6 +153,12 @@ private function handleIntervalTick(): void { $this->cleanupCompletedTasks(); + if (empty($this->runningTasks) && parent::size() === 0) { + $this->disableProcessing(); + + return; + } + if (! empty($this->runningTasks)) { return; } From 94b908f4d9f022f7bbc318ffda4659e891441e58 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 22 Dec 2025 23:26:07 +0000 Subject: [PATCH 04/11] feat(devcontainer): add Dockerfile for PHP environment setup --- .devcontainer/Dockerfile | 19 +++++++++++++++ .devcontainer/devcontainer.json | 42 +++++++++++++-------------------- 2 files changed, 36 insertions(+), 25 deletions(-) create mode 100644 .devcontainer/Dockerfile diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..bc52bcf7 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,19 @@ +FROM php:8.2-cli + +RUN apt-get update && apt-get install -y \ + git \ + curl \ + wget \ + unzip \ + zip \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN docker-php-ext-install \ + pcntl \ + sockets + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +ENV COMPOSER_ALLOW_SUPERUSER=1 +ENV COMPOSER_HOME=/composer diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 59dd1418..75d65d8a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,31 +1,23 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/php { "name": "PHP", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/php:1-8.2-bullseye", - - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - - // Configure tool-specific properties. - // "customizations": {}, - - // Use 'forwardPorts' to make a list of ports inside the container available locally. + "build": { + "dockerfile": "./Dockerfile", + "context": ".." + }, + "customizations": { + "vscode": { + "extensions": [ + "bmewburn.vscode-intelephense-client", + "xdebug.php-pack", + "devsense.phptools-vscode", + "mehedidracula.php-namespace-resolver", + "devsense.composer-php-vscode", + "phiter.phpstorm-snippets" + ] + } + }, "forwardPorts": [ 8080 ], - "features": { - "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/devcontainers/features/php:1": { - "version": "8.2", - "installComposer": true - } - } - - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "sudo chmod a+x \"$(pwd)\" && sudo rm -rf /var/www/html && sudo ln -s \"$(pwd)\" /var/www/html" - - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" + "remoteUser": "root" } From 6c33132fcf2396363b159cbd6e0f9e1015daf524 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 22 Dec 2025 23:28:28 +0000 Subject: [PATCH 05/11] refactor(Database): move common methods to super class and overload methods using union types --- src/Database/Concerns/Query/BuildsQuery.php | 123 +------------ src/Database/Concerns/Query/HasSentences.php | 100 ----------- .../QueryBuilders/DatabaseQueryBuilder.php | 25 +-- src/Database/QueryBase.php | 116 +++++++++++++ src/Database/QueryBuilder.php | 164 ++++++++++++++++-- src/Database/QueryGenerator.php | 40 ++--- 6 files changed, 281 insertions(+), 287 deletions(-) diff --git a/src/Database/Concerns/Query/BuildsQuery.php b/src/Database/Concerns/Query/BuildsQuery.php index 2df6f7fe..31e08f92 100644 --- a/src/Database/Concerns/Query/BuildsQuery.php +++ b/src/Database/Concerns/Query/BuildsQuery.php @@ -16,12 +16,6 @@ use Phenix\Database\Value; use Phenix\Util\Arr; -use function array_is_list; -use function array_keys; -use function array_unique; -use function array_values; -use function ksort; - trait BuildsQuery { use HasLock; @@ -70,74 +64,7 @@ public function selectAllColumns(): static return $this; } - public function insert(array $data): static - { - $this->action = Action::INSERT; - - $this->prepareDataToInsert($data); - - return $this; - } - - public function insertOrIgnore(array $values): static - { - $this->ignore = true; - - $this->insert($values); - - return $this; - } - - public function upsert(array $values, array $columns): static - { - $this->action = Action::INSERT; - - $this->uniqueColumns = $columns; - - $this->prepareDataToInsert($values); - - return $this; - } - - public function insertFrom(Closure $subquery, array $columns, bool $ignore = false): static - { - $builder = new Subquery($this->driver); - $builder->selectAllColumns(); - - $subquery($builder); - - [$dml, $arguments] = $builder->toSql(); - - $this->rawStatement = trim($dml, '()'); - - $this->arguments = array_merge($this->arguments, $arguments); - - $this->action = Action::INSERT; - - $this->ignore = $ignore; - - $this->columns = $columns; - - return $this; - } - - public function update(array $values): static - { - $this->action = Action::UPDATE; - - $this->values = $values; - - return $this; - } - - public function delete(): static - { - $this->action = Action::DELETE; - - return $this; - } - - public function groupBy(Functions|array|string $column) + public function groupBy(Functions|array|string $column): static { $column = match (true) { $column instanceof Functions => (string) $column, @@ -164,7 +91,7 @@ public function having(Closure $clause): static return $this; } - public function orderBy(SelectCase|array|string $column, Order $order = Order::DESC) + public function orderBy(SelectCase|array|string $column, Order $order = Order::DESC): static { $column = match (true) { $column instanceof SelectCase => '(' . $column . ')', @@ -196,33 +123,6 @@ public function page(int $page = 1, int $perPage = 15): static return $this; } - public function count(string $column = '*'): static - { - $this->action = Action::SELECT; - - $this->columns = [Functions::count($column)]; - - return $this; - } - - public function exists(): static - { - $this->action = Action::EXISTS; - - $this->columns = [Operator::EXISTS->value]; - - return $this; - } - - public function doesntExist(): static - { - $this->action = Action::EXISTS; - - $this->columns = [Operator::NOT_EXISTS->value]; - - return $this; - } - /** * @return array */ @@ -304,25 +204,6 @@ protected function buildExistsQuery(): string return Arr::implodeDeeply($query); } - private function prepareDataToInsert(array $data): void - { - if (array_is_list($data)) { - foreach ($data as $record) { - $this->prepareDataToInsert($record); - } - - return; - } - - ksort($data); - - $this->columns = array_unique([...$this->columns, ...array_keys($data)]); - - $this->arguments = \array_merge($this->arguments, array_values($data)); - - $this->values[] = array_fill(0, count($data), SQL::PLACEHOLDER->value); - } - private function buildInsertSentence(): string { $dml = [ diff --git a/src/Database/Concerns/Query/HasSentences.php b/src/Database/Concerns/Query/HasSentences.php index f667e701..767a9750 100644 --- a/src/Database/Concerns/Query/HasSentences.php +++ b/src/Database/Concerns/Query/HasSentences.php @@ -4,10 +4,7 @@ namespace Phenix\Database\Concerns\Query; -use Amp\Mysql\Internal\MysqlPooledResult; -use Amp\Sql\SqlQueryError; use Amp\Sql\SqlTransaction; -use Amp\Sql\SqlTransactionError; use Closure; use League\Uri\Components\Query; use League\Uri\Http; @@ -40,103 +37,6 @@ public function paginate(Http $uri, int $defaultPage = 1, int $defaultPerPage = return new Paginator($uri, $data, (int) $total, (int) $currentPage, (int) $perPage); } - public function count(string $column = '*'): int - { - $this->action = Action::SELECT; - - $this->countRows($column); - - [$dml, $params] = $this->toSql(); - - /** @var array $count */ - $count = $this->exec($dml, $params)->fetchRow(); - - return array_values($count)[0]; - } - - public function insert(array $data): bool - { - [$dml, $params] = $this->insertRows($data)->toSql(); - - try { - $this->exec($dml, $params); - - return true; - } catch (SqlQueryError|SqlTransactionError $e) { - report($e); - - return false; - } - } - - public function insertRow(array $data): int|string|bool - { - [$dml, $params] = $this->insertRows($data)->toSql(); - - try { - /** @var MysqlPooledResult $result */ - $result = $this->exec($dml, $params); - - return $result->getLastInsertId(); - } catch (SqlQueryError|SqlTransactionError $e) { - report($e); - - return false; - } - } - - public function exists(): bool - { - $this->action = Action::EXISTS; - - $this->existsRows(); - - [$dml, $params] = $this->toSql(); - - $results = $this->exec($dml, $params)->fetchRow(); - - return (bool) array_values($results)[0]; - } - - public function doesntExist(): bool - { - return ! $this->exists(); - } - - public function update(array $values): bool - { - $this->updateRow($values); - - [$dml, $params] = $this->toSql(); - - try { - $this->exec($dml, $params); - - return true; - } catch (SqlQueryError|SqlTransactionError $e) { - report($e); - - return false; - } - } - - public function delete(): bool - { - $this->deleteRows(); - - [$dml, $params] = $this->toSql(); - - try { - $this->exec($dml, $params); - - return true; - } catch (SqlQueryError|SqlTransactionError $e) { - report($e); - - return false; - } - } - public function transaction(Closure $callback): mixed { /** @var SqlTransaction $transaction */ diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index cd534151..719cf868 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -22,34 +22,17 @@ use Phenix\Database\Models\Relationships\HasMany; use Phenix\Database\Models\Relationships\Relationship; use Phenix\Database\Models\Relationships\RelationshipParser; -use Phenix\Database\QueryBase; +use Phenix\Database\QueryBuilder; use Phenix\Util\Arr; use function array_key_exists; use function is_array; use function is_string; -class DatabaseQueryBuilder extends QueryBase +class DatabaseQueryBuilder extends QueryBuilder { - use BuildsQuery, HasSentences { - HasSentences::count insteadof BuildsQuery; - HasSentences::insert insteadof BuildsQuery; - HasSentences::exists insteadof BuildsQuery; - HasSentences::doesntExist insteadof BuildsQuery; - HasSentences::update insteadof BuildsQuery; - HasSentences::delete insteadof BuildsQuery; - BuildsQuery::table as protected; - BuildsQuery::from as protected; - BuildsQuery::insert as protected insertRows; - BuildsQuery::insertOrIgnore as protected insertOrIgnoreRows; - BuildsQuery::upsert as protected upsertRows; - BuildsQuery::insertFrom as protected insertFromRows; - BuildsQuery::update as protected updateRow; - BuildsQuery::delete as protected deleteRows; - BuildsQuery::count as protected countRows; - BuildsQuery::exists as protected existsRows; - BuildsQuery::doesntExist as protected doesntExistRows; - } + use BuildsQuery; + use HasSentences; use HasJoinClause; protected DatabaseModel $model; diff --git a/src/Database/QueryBase.php b/src/Database/QueryBase.php index 7e7099cb..7e2f04aa 100644 --- a/src/Database/QueryBase.php +++ b/src/Database/QueryBase.php @@ -4,8 +4,11 @@ namespace Phenix\Database; +use Closure; use Phenix\Database\Concerns\Query\HasDriver; use Phenix\Database\Constants\Action; +use Phenix\Database\Constants\Operator; +use Phenix\Database\Constants\SQL; use Phenix\Database\Contracts\Builder; use Phenix\Database\Contracts\QueryBuilder; @@ -60,4 +63,117 @@ protected function resetBaseProperties(): void $this->arguments = []; $this->uniqueColumns = []; } + + public function count(string $column = '*'): array|int + { + $this->action = Action::SELECT; + + $this->columns = [Functions::count($column)]; + + return $this->toSql(); + } + + public function exists(): array|bool + { + $this->action = Action::EXISTS; + + $this->columns = [Operator::EXISTS->value]; + + return $this->toSql(); + } + + public function doesntExist(): array|bool + { + $this->action = Action::EXISTS; + + $this->columns = [Operator::NOT_EXISTS->value]; + + return $this->toSql(); + } + + public function insert(array $data): array|bool + { + $this->action = Action::INSERT; + + $this->prepareDataToInsert($data); + + return $this->toSql(); + } + + public function insertOrIgnore(array $values): array|bool + { + $this->ignore = true; + + $this->insert($values); + + return $this->toSql(); + } + + public function insertFrom(Closure $subquery, array $columns, bool $ignore = false): array|bool + { + $builder = new Subquery($this->driver); + $builder->selectAllColumns(); + + $subquery($builder); + + [$dml, $arguments] = $builder->toSql(); + + $this->rawStatement = trim($dml, '()'); + + $this->arguments = array_merge($this->arguments, $arguments); + + $this->action = Action::INSERT; + + $this->ignore = $ignore; + + $this->columns = $columns; + + return $this->toSql(); + } + + public function update(array $values): array|bool + { + $this->action = Action::UPDATE; + + $this->values = $values; + + return $this->toSql(); + } + + public function upsert(array $values, array $columns): array|bool + { + $this->action = Action::INSERT; + + $this->uniqueColumns = $columns; + + $this->prepareDataToInsert($values); + + return $this->toSql(); + } + + public function delete(): array|bool + { + $this->action = Action::DELETE; + + return $this->toSql(); + } + + protected function prepareDataToInsert(array $data): void + { + if (array_is_list($data)) { + foreach ($data as $record) { + $this->prepareDataToInsert($record); + } + + return; + } + + ksort($data); + + $this->columns = array_unique([...$this->columns, ...array_keys($data)]); + + $this->arguments = \array_merge($this->arguments, array_values($data)); + + $this->values[] = array_fill(0, count($data), SQL::PLACEHOLDER->value); + } } diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index 54fdc4ae..47cd0eb2 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -4,7 +4,11 @@ namespace Phenix\Database; +use Amp\Mysql\Internal\MysqlPooledResult; use Amp\Sql\Common\SqlCommonConnectionPool; +use Amp\Sql\SqlQueryError; +use Amp\Sql\SqlTransactionError; +use Closure; use Phenix\App; use Phenix\Data\Collection; use Phenix\Database\Concerns\Query\BuildsQuery; @@ -17,23 +21,8 @@ class QueryBuilder extends QueryBase { - use BuildsQuery, HasSentences { - HasSentences::count insteadof BuildsQuery; - HasSentences::insert insteadof BuildsQuery; - HasSentences::exists insteadof BuildsQuery; - HasSentences::doesntExist insteadof BuildsQuery; - HasSentences::update insteadof BuildsQuery; - HasSentences::delete insteadof BuildsQuery; - BuildsQuery::insert as protected insertRows; - BuildsQuery::insertOrIgnore as protected insertOrIgnoreRows; - BuildsQuery::upsert as protected upsertRows; - BuildsQuery::insertFrom as protected insertFromRows; - BuildsQuery::update as protected updateRow; - BuildsQuery::delete as protected deleteRows; - BuildsQuery::count as protected countRows; - BuildsQuery::exists as protected existsRows; - BuildsQuery::doesntExist as protected doesntExistRows; - } + use BuildsQuery; + use HasSentences; use HasJoinClause; protected SqlCommonConnectionPool $connection; @@ -67,6 +56,143 @@ public function connection(SqlCommonConnectionPool|string $connection): self return $this; } + public function count(string $column = '*'): int + { + $this->action = Action::SELECT; + + [$dml, $params] = parent::count($column); + + /** @var array $count */ + $count = $this->exec($dml, $params)->fetchRow(); + + return array_values($count)[0]; + } + + public function exists(): bool + { + $this->action = Action::EXISTS; + + [$dml, $params] = parent::exists(); + + $results = $this->exec($dml, $params)->fetchRow(); + + return (bool) array_values($results)[0]; + } + + public function doesntExist(): bool + { + return ! $this->exists(); + } + + public function insert(array $data): bool + { + [$dml, $params] = parent::insert($data); + + try { + $this->exec($dml, $params); + + return true; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + + public function insertOrIgnore(array $values): bool + { + $this->ignore = true; + + return $this->insert($values); + } + + public function insertFrom(Closure $subquery, array $columns, bool $ignore = false): bool + { + $builder = new Subquery($this->driver); + $builder->selectAllColumns(); + + $subquery($builder); + + [$dml, $arguments] = $builder->toSql(); + + $this->rawStatement = trim($dml, '()'); + + $this->arguments = array_merge($this->arguments, $arguments); + + $this->action = Action::INSERT; + + $this->ignore = $ignore; + + $this->columns = $columns; + + try { + [$dml, $params] = $this->toSql(); + + $this->exec($dml, $params); + + return true; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + + public function insertRow(array $data): int|string|bool + { + [$dml, $params] = parent::insert($data); + + try { + /** @var MysqlPooledResult $result */ + $result = $this->exec($dml, $params); + + return $result->getLastInsertId(); + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + + public function update(array $values): bool + { + [$dml, $params] = parent::update($values); + + try { + $this->exec($dml, $params); + + return true; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + + public function upsert(array $values, array $columns): bool + { + $this->action = Action::INSERT; + + $this->uniqueColumns = $columns; + + return $this->insert($values); + } + + public function delete(): bool + { + [$dml, $params] = parent::delete(); + + try { + $this->exec($dml, $params); + + return true; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + /** * @return Collection> */ @@ -88,9 +214,9 @@ public function get(): Collection } /** - * @return array|null + * @return object|array|null */ - public function first(): array|null + public function first(): object|array|null { $this->action = Action::SELECT; diff --git a/src/Database/QueryGenerator.php b/src/Database/QueryGenerator.php index 853df2f7..45a0ed25 100644 --- a/src/Database/QueryGenerator.php +++ b/src/Database/QueryGenerator.php @@ -12,17 +12,7 @@ class QueryGenerator extends QueryBase { - use BuildsQuery { - insert as protected insertRows; - insertOrIgnore as protected insertOrIgnoreRows; - upsert as protected upsertRows; - insertFrom as protected insertFromRows; - update as protected updateRow; - delete as protected deleteRows; - count as protected countRows; - exists as protected existsRows; - doesntExist as protected doesntExistRows; - } + use BuildsQuery; use HasJoinClause; public function __construct(Driver $driver = Driver::MYSQL) @@ -41,53 +31,51 @@ public function __clone(): void public function insert(array $data): array { - return $this->insertRows($data)->toSql(); + return parent::insert($data); } public function insertOrIgnore(array $values): array { - return $this->insertOrIgnoreRows($values)->toSql(); + $this->ignore = true; + + $this->insert($values); + + return $this->toSql(); } public function upsert(array $values, array $columns): array { - return $this->upsertRows($values, $columns)->toSql(); + return parent::upsert($values, $columns); } public function insertFrom(Closure $subquery, array $columns, bool $ignore = false): array { - return $this->insertFromRows($subquery, $columns, $ignore)->toSql(); + return parent::insertFrom($subquery, $columns, $ignore); } public function update(array $values): array { - return $this->updateRow($values)->toSql(); + return parent::update($values); } public function delete(): array { - return $this->deleteRows()->toSql(); + return parent::delete(); } public function count(string $column = '*'): array { - $this->action = Action::SELECT; - - return $this->countRows($column)->toSql(); + return parent::count($column); } public function exists(): array { - $this->action = Action::EXISTS; - - return $this->existsRows()->toSql(); + return parent::exists(); } public function doesntExist(): array { - $this->action = Action::EXISTS; - - return $this->doesntExistRows()->toSql(); + return parent::doesntExist(); } public function get(): array From 3cb5e8586769c0c6c633cb8e66de1a3a3d7b9b44 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 23 Dec 2025 22:47:52 +0000 Subject: [PATCH 06/11] refactor(Database): implement transaction handling and remove unused traits --- src/Database/Concerns/Query/BuildsQuery.php | 2 -- .../{HasSentences.php => HasTransaction.php} | 27 +--------------- .../QueryBuilders/DatabaseQueryBuilder.php | 5 --- src/Database/QueryBase.php | 6 ++++ src/Database/QueryBuilder.php | 31 +++++++++++++++---- src/Database/QueryGenerator.php | 5 --- 6 files changed, 32 insertions(+), 44 deletions(-) rename src/Database/Concerns/Query/{HasSentences.php => HasTransaction.php} (63%) diff --git a/src/Database/Concerns/Query/BuildsQuery.php b/src/Database/Concerns/Query/BuildsQuery.php index 31e08f92..948f5c06 100644 --- a/src/Database/Concerns/Query/BuildsQuery.php +++ b/src/Database/Concerns/Query/BuildsQuery.php @@ -18,8 +18,6 @@ trait BuildsQuery { - use HasLock; - public function table(string $table): static { $this->table = $table; diff --git a/src/Database/Concerns/Query/HasSentences.php b/src/Database/Concerns/Query/HasTransaction.php similarity index 63% rename from src/Database/Concerns/Query/HasSentences.php rename to src/Database/Concerns/Query/HasTransaction.php index 767a9750..359b0690 100644 --- a/src/Database/Concerns/Query/HasSentences.php +++ b/src/Database/Concerns/Query/HasTransaction.php @@ -6,37 +6,12 @@ use Amp\Sql\SqlTransaction; use Closure; -use League\Uri\Components\Query; -use League\Uri\Http; -use Phenix\Database\Constants\Action; -use Phenix\Database\Paginator; use Throwable; -trait HasSentences +trait HasTransaction { protected SqlTransaction|null $transaction = null; - public function paginate(Http $uri, int $defaultPage = 1, int $defaultPerPage = 15): Paginator - { - $this->action = Action::SELECT; - - $query = Query::fromUri($uri); - - $currentPage = filter_var($query->get('page') ?? $defaultPage, FILTER_SANITIZE_NUMBER_INT); - $currentPage = $currentPage === false ? $defaultPage : $currentPage; - - $perPage = filter_var($query->get('per_page') ?? $defaultPerPage, FILTER_SANITIZE_NUMBER_INT); - $perPage = $perPage === false ? $defaultPerPage : $perPage; - - $countQuery = clone $this; - - $total = $countQuery->count(); - - $data = $this->page((int) $currentPage, (int) $perPage)->get(); - - return new Paginator($uri, $data, (int) $total, (int) $currentPage, (int) $perPage); - } - public function transaction(Closure $callback): mixed { /** @var SqlTransaction $transaction */ diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index 719cf868..474d6c9d 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -7,9 +7,6 @@ use Amp\Sql\Common\SqlCommonConnectionPool; use Closure; use Phenix\App; -use Phenix\Database\Concerns\Query\BuildsQuery; -use Phenix\Database\Concerns\Query\HasJoinClause; -use Phenix\Database\Concerns\Query\HasSentences; use Phenix\Database\Constants\Action; use Phenix\Database\Constants\Connection; use Phenix\Database\Exceptions\ModelException; @@ -31,8 +28,6 @@ class DatabaseQueryBuilder extends QueryBuilder { - use BuildsQuery; - use HasSentences; use HasJoinClause; protected DatabaseModel $model; diff --git a/src/Database/QueryBase.php b/src/Database/QueryBase.php index 7e2f04aa..dd4457f8 100644 --- a/src/Database/QueryBase.php +++ b/src/Database/QueryBase.php @@ -5,7 +5,10 @@ namespace Phenix\Database; use Closure; +use Phenix\Database\Concerns\Query\BuildsQuery; use Phenix\Database\Concerns\Query\HasDriver; +use Phenix\Database\Concerns\Query\HasJoinClause; +use Phenix\Database\Concerns\Query\HasLock; use Phenix\Database\Constants\Action; use Phenix\Database\Constants\Operator; use Phenix\Database\Constants\SQL; @@ -15,6 +18,9 @@ abstract class QueryBase extends Clause implements QueryBuilder, Builder { use HasDriver; + use BuildsQuery; + use HasLock; + use HasJoinClause; protected string $table; diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index 47cd0eb2..6b1abd91 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -9,11 +9,11 @@ use Amp\Sql\SqlQueryError; use Amp\Sql\SqlTransactionError; use Closure; +use League\Uri\Components\Query; +use League\Uri\Http; use Phenix\App; use Phenix\Data\Collection; -use Phenix\Database\Concerns\Query\BuildsQuery; -use Phenix\Database\Concerns\Query\HasJoinClause; -use Phenix\Database\Concerns\Query\HasSentences; +use Phenix\Database\Concerns\Query\HasTransaction; use Phenix\Database\Constants\Action; use Phenix\Database\Constants\Connection; @@ -21,9 +21,7 @@ class QueryBuilder extends QueryBase { - use BuildsQuery; - use HasSentences; - use HasJoinClause; + use HasTransaction; protected SqlCommonConnectionPool $connection; @@ -84,6 +82,27 @@ public function doesntExist(): bool return ! $this->exists(); } + public function paginate(Http $uri, int $defaultPage = 1, int $defaultPerPage = 15): Paginator + { + $this->action = Action::SELECT; + + $query = Query::fromUri($uri); + + $currentPage = filter_var($query->get('page') ?? $defaultPage, FILTER_SANITIZE_NUMBER_INT); + $currentPage = $currentPage === false ? $defaultPage : $currentPage; + + $perPage = filter_var($query->get('per_page') ?? $defaultPerPage, FILTER_SANITIZE_NUMBER_INT); + $perPage = $perPage === false ? $defaultPerPage : $perPage; + + $countQuery = clone $this; + + $total = $countQuery->count(); + + $data = $this->page((int) $currentPage, (int) $perPage)->get(); + + return new Paginator($uri, $data, (int) $total, (int) $currentPage, (int) $perPage); + } + public function insert(array $data): bool { [$dml, $params] = parent::insert($data); diff --git a/src/Database/QueryGenerator.php b/src/Database/QueryGenerator.php index 45a0ed25..e2514f92 100644 --- a/src/Database/QueryGenerator.php +++ b/src/Database/QueryGenerator.php @@ -5,16 +5,11 @@ namespace Phenix\Database; use Closure; -use Phenix\Database\Concerns\Query\BuildsQuery; -use Phenix\Database\Concerns\Query\HasJoinClause; use Phenix\Database\Constants\Action; use Phenix\Database\Constants\Driver; class QueryGenerator extends QueryBase { - use BuildsQuery; - use HasJoinClause; - public function __construct(Driver $driver = Driver::MYSQL) { parent::__construct(); From ae1d84c5f3fd86262788b65402507ed6b77a0bf8 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 24 Dec 2025 14:34:04 -0500 Subject: [PATCH 07/11] feat: introduce AST for each supported SQL driver --- src/Database/Clause.php | 1 + src/Database/Concerns/Query/BuildsQuery.php | 57 +++++--- .../Dialects/Compilers/DeleteCompiler.php | 39 ++++++ .../Dialects/Compilers/ExistsCompiler.php | 49 +++++++ .../Dialects/Compilers/InsertCompiler.php | 77 +++++++++++ .../Dialects/Compilers/SelectCompiler.php | 129 ++++++++++++++++++ .../Dialects/Compilers/UpdateCompiler.php | 55 ++++++++ .../Dialects/Compilers/WhereCompiler.php | 48 +++++++ .../Dialects/Contracts/ClauseCompiler.php | 12 ++ .../Dialects/Contracts/CompiledClause.php | 17 +++ src/Database/Dialects/Contracts/Dialect.php | 18 +++ .../Contracts/DialectCapabilities.php | 49 +++++++ src/Database/Dialects/DialectFactory.php | 35 +++++ .../MySQL/Compilers/MysqlDeleteCompiler.php | 12 ++ .../MySQL/Compilers/MysqlExistsCompiler.php | 12 ++ .../MySQL/Compilers/MysqlInsertCompiler.php | 27 ++++ .../MySQL/Compilers/MysqlSelectCompiler.php | 26 ++++ .../MySQL/Compilers/MysqlUpdateCompiler.php | 12 ++ src/Database/Dialects/MySQL/MysqlDialect.php | 116 ++++++++++++++++ .../Compilers/PostgresDeleteCompiler.php | 12 ++ .../Compilers/PostgresExistsCompiler.php | 12 ++ .../Compilers/PostgresInsertCompiler.php | 65 +++++++++ .../Compilers/PostgresSelectCompiler.php | 33 +++++ .../Compilers/PostgresUpdateCompiler.php | 12 ++ .../Dialects/PostgreSQL/PostgresDialect.php | 111 +++++++++++++++ .../SQLite/Compilers/SqliteDeleteCompiler.php | 12 ++ .../SQLite/Compilers/SqliteExistsCompiler.php | 12 ++ .../SQLite/Compilers/SqliteInsertCompiler.php | 43 ++++++ .../SQLite/Compilers/SqliteSelectCompiler.php | 17 +++ .../SQLite/Compilers/SqliteUpdateCompiler.php | 12 ++ .../Dialects/SQLite/SqliteDialect.php | 111 +++++++++++++++ .../QueryBuilders/DatabaseQueryBuilder.php | 2 - src/Database/QueryAst.php | 89 ++++++++++++ .../Database/Dialects/DialectFactoryTest.php | 49 +++++++ 34 files changed, 1361 insertions(+), 22 deletions(-) create mode 100644 src/Database/Dialects/Compilers/DeleteCompiler.php create mode 100644 src/Database/Dialects/Compilers/ExistsCompiler.php create mode 100644 src/Database/Dialects/Compilers/InsertCompiler.php create mode 100644 src/Database/Dialects/Compilers/SelectCompiler.php create mode 100644 src/Database/Dialects/Compilers/UpdateCompiler.php create mode 100644 src/Database/Dialects/Compilers/WhereCompiler.php create mode 100644 src/Database/Dialects/Contracts/ClauseCompiler.php create mode 100644 src/Database/Dialects/Contracts/CompiledClause.php create mode 100644 src/Database/Dialects/Contracts/Dialect.php create mode 100644 src/Database/Dialects/Contracts/DialectCapabilities.php create mode 100644 src/Database/Dialects/DialectFactory.php create mode 100644 src/Database/Dialects/MySQL/Compilers/MysqlDeleteCompiler.php create mode 100644 src/Database/Dialects/MySQL/Compilers/MysqlExistsCompiler.php create mode 100644 src/Database/Dialects/MySQL/Compilers/MysqlInsertCompiler.php create mode 100644 src/Database/Dialects/MySQL/Compilers/MysqlSelectCompiler.php create mode 100644 src/Database/Dialects/MySQL/Compilers/MysqlUpdateCompiler.php create mode 100644 src/Database/Dialects/MySQL/MysqlDialect.php create mode 100644 src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php create mode 100644 src/Database/Dialects/PostgreSQL/Compilers/PostgresExistsCompiler.php create mode 100644 src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php create mode 100644 src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php create mode 100644 src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php create mode 100644 src/Database/Dialects/PostgreSQL/PostgresDialect.php create mode 100644 src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php create mode 100644 src/Database/Dialects/SQLite/Compilers/SqliteExistsCompiler.php create mode 100644 src/Database/Dialects/SQLite/Compilers/SqliteInsertCompiler.php create mode 100644 src/Database/Dialects/SQLite/Compilers/SqliteSelectCompiler.php create mode 100644 src/Database/Dialects/SQLite/Compilers/SqliteUpdateCompiler.php create mode 100644 src/Database/Dialects/SQLite/SqliteDialect.php create mode 100644 src/Database/QueryAst.php create mode 100644 tests/Unit/Database/Dialects/DialectFactoryTest.php diff --git a/src/Database/Clause.php b/src/Database/Clause.php index 70953836..6e8e8551 100644 --- a/src/Database/Clause.php +++ b/src/Database/Clause.php @@ -21,6 +21,7 @@ abstract class Clause extends Grammar implements Builder use PrepareColumns; protected array $clauses; + protected array $arguments; protected function resolveWhereMethod( diff --git a/src/Database/Concerns/Query/BuildsQuery.php b/src/Database/Concerns/Query/BuildsQuery.php index 948f5c06..c681b838 100644 --- a/src/Database/Concerns/Query/BuildsQuery.php +++ b/src/Database/Concerns/Query/BuildsQuery.php @@ -5,16 +5,18 @@ namespace Phenix\Database\Concerns\Query; use Closure; -use Phenix\Database\Constants\Action; -use Phenix\Database\Constants\Operator; -use Phenix\Database\Constants\Order; -use Phenix\Database\Constants\SQL; -use Phenix\Database\Functions; +use Phenix\Util\Arr; +use Phenix\Database\Value; use Phenix\Database\Having; -use Phenix\Database\SelectCase; +use Phenix\Database\QueryAst; use Phenix\Database\Subquery; -use Phenix\Database\Value; -use Phenix\Util\Arr; +use Phenix\Database\Functions; +use Phenix\Database\SelectCase; +use Phenix\Database\Constants\SQL; +use Phenix\Database\Constants\Order; +use Phenix\Database\Constants\Action; +use Phenix\Database\Constants\Operator; +use Phenix\Database\Dialects\DialectFactory; trait BuildsQuery { @@ -122,22 +124,37 @@ public function page(int $page = 1, int $perPage = 15): static } /** - * @return array + * @return array{0: string, 1: array} */ public function toSql(): array { - $sql = match ($this->action) { - Action::SELECT => $this->buildSelectQuery(), - Action::EXISTS => $this->buildExistsQuery(), - Action::INSERT => $this->buildInsertSentence(), - Action::UPDATE => $this->buildUpdateSentence(), - Action::DELETE => $this->buildDeleteSentence(), - }; + $ast = $this->buildAst(); + $dialect = DialectFactory::fromDriver($this->driver); - return [ - $sql, - $this->arguments, - ]; + return $dialect->compile($ast); + } + + protected function buildAst(): QueryAst + { + $ast = new QueryAst(); + $ast->action = $this->action; + $ast->table = $this->table; + $ast->columns = $this->columns; + $ast->values = $this->values ?? []; + $ast->wheres = $this->clauses ?? []; + $ast->joins = $this->joins ?? []; + $ast->groups = $this->groupBy ?? []; + $ast->orders = $this->orderBy ?? []; + $ast->limit = isset($this->limit) ? $this->limit[1] : null; + $ast->offset = isset($this->offset) ? $this->offset[1] : null; + $ast->lock = $this->lockType ?? null; + $ast->having = $this->having ?? null; + $ast->rawStatement = $this->rawStatement ?? null; + $ast->ignore = $this->ignore ?? false; + $ast->uniqueColumns = $this->uniqueColumns ?? []; + $ast->params = $this->arguments; + + return $ast; } protected function buildSelectQuery(): string diff --git a/src/Database/Dialects/Compilers/DeleteCompiler.php b/src/Database/Dialects/Compilers/DeleteCompiler.php new file mode 100644 index 00000000..6e30316a --- /dev/null +++ b/src/Database/Dialects/Compilers/DeleteCompiler.php @@ -0,0 +1,39 @@ +whereCompiler = new WhereCompiler(); + } + + public function compile(QueryAst $ast): CompiledClause + { + $parts = []; + + $parts[] = 'DELETE FROM'; + $parts[] = $ast->table; + + if (!empty($ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($ast->wheres); + + $parts[] = 'WHERE'; + $parts[] = $whereCompiled->sql; + } + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $ast->params); + } +} diff --git a/src/Database/Dialects/Compilers/ExistsCompiler.php b/src/Database/Dialects/Compilers/ExistsCompiler.php new file mode 100644 index 00000000..9227e2b4 --- /dev/null +++ b/src/Database/Dialects/Compilers/ExistsCompiler.php @@ -0,0 +1,49 @@ +whereCompiler = new WhereCompiler(); + } + + public function compile(QueryAst $ast): CompiledClause + { + $parts = []; + $parts[] = 'SELECT'; + + $column = !empty($ast->columns) ? $ast->columns[0] : 'EXISTS'; + $parts[] = $column; + + $subquery = []; + $subquery[] = 'SELECT 1 FROM'; + $subquery[] = $ast->table; + + if (!empty($ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($ast->wheres); + + $subquery[] = 'WHERE'; + $subquery[] = $whereCompiled->sql; + } + + $parts[] = '(' . Arr::implodeDeeply($subquery) . ')'; + $parts[] = 'AS'; + $parts[] = Value::from('exists'); + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $ast->params); + } +} diff --git a/src/Database/Dialects/Compilers/InsertCompiler.php b/src/Database/Dialects/Compilers/InsertCompiler.php new file mode 100644 index 00000000..5e657440 --- /dev/null +++ b/src/Database/Dialects/Compilers/InsertCompiler.php @@ -0,0 +1,77 @@ +params; + + // INSERT [IGNORE] INTO + $parts[] = $this->compileInsertClause($ast); + + $parts[] = $ast->table; + + // (column1, column2, ...) + $parts[] = '(' . Arr::implodeDeeply($ast->columns, ', ') . ')'; + + // VALUES (...), (...) or raw statement + if ($ast->rawStatement !== null) { + $parts[] = $ast->rawStatement; + } else { + $parts[] = 'VALUES'; + + $placeholders = array_map(function (array $value): string { + return '(' . Arr::implodeDeeply($value, ', ') . ')'; + }, $ast->values); + + $parts[] = Arr::implodeDeeply($placeholders, ', '); + } + + // Dialect-specific UPSERT/ON CONFLICT handling + if (!empty($ast->uniqueColumns)) { + $parts[] = $this->compileUpsert($ast); + } + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $params); + } + + protected function compileInsertClause(QueryAst $ast): string + { + if ($ast->ignore) { + return $this->compileInsertIgnore(); + } + + return 'INSERT INTO'; + } + + /** + * MySQL: INSERT IGNORE INTO + * PostgreSQL: INSERT INTO ... ON CONFLICT DO NOTHING (handled in compileUpsert) + * SQLite: INSERT OR IGNORE INTO + * + * @return string INSERT IGNORE clause + */ + abstract protected function compileInsertIgnore(): string; + + /** + * MySQL: ON DUPLICATE KEY UPDATE + * PostgreSQL: ON CONFLICT (...) DO UPDATE SET + * SQLite: ON CONFLICT (...) DO UPDATE SET + * + * @param QueryAst $ast Query AST with uniqueColumns + * @return string UPSERT clause + */ + abstract protected function compileUpsert(QueryAst $ast): string; +} diff --git a/src/Database/Dialects/Compilers/SelectCompiler.php b/src/Database/Dialects/Compilers/SelectCompiler.php new file mode 100644 index 00000000..b1003a68 --- /dev/null +++ b/src/Database/Dialects/Compilers/SelectCompiler.php @@ -0,0 +1,129 @@ +whereCompiler = new WhereCompiler(); + } + + public function compile(QueryAst $ast): CompiledClause + { + $columns = empty($ast->columns) ? ['*'] : $ast->columns; + + $sql = [ + 'SELECT', + $this->compileColumns($columns, $ast->params), + 'FROM', + $ast->table, + ]; + + if (!empty($ast->joins)) { + $sql[] = $ast->joins; + } + + if (!empty($ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($ast->wheres); + + if ($whereCompiled->sql !== '') { + $sql[] = 'WHERE'; + $sql[] = $whereCompiled->sql; + } + } + + if ($ast->having !== null) { + $sql[] = $ast->having; + } + + if (!empty($ast->groups)) { + $sql[] = Arr::implodeDeeply($ast->groups); + } + + if (!empty($ast->orders)) { + $sql[] = Arr::implodeDeeply($ast->orders); + } + + if ($ast->limit !== null) { + $sql[] = "LIMIT {$ast->limit}"; + } + + if ($ast->offset !== null) { + $sql[] = "OFFSET {$ast->offset}"; + } + + if ($ast->lock !== null) { + $lockSql = $this->compileLock($ast); + + if ($lockSql !== '') { + $sql[] = $lockSql; + } + } + + return new CompiledClause( + Arr::implodeDeeply($sql), + $ast->params + ); + } + + /** + * @param QueryAst $ast + * @return string + */ + abstract protected function compileLock(QueryAst $ast): string; + + /** + * @param array $columns + * @param array $params Reference to params array for subqueries + * @return string + */ + protected function compileColumns(array $columns, array &$params): string + { + $compiled = Arr::map($columns, function (string|Functions|SelectCase|Subquery $value, int|string $key) use (&$params): string { + return match (true) { + is_string($key) => (string) Alias::of($key)->as($value), + $value instanceof Functions => (string) $value, + $value instanceof SelectCase => (string) $value, + $value instanceof Subquery => $this->compileSubquery($value, $params), + default => $value, + }; + }); + + return Arr::implodeDeeply($compiled, ', '); + } + + /** + * @param Subquery $subquery + * @param array $params Reference to params array + * @return string + */ + private function compileSubquery(Subquery $subquery, array &$params): string + { + [$dml, $arguments] = $subquery->toSql(); + + if (!str_contains($dml, 'LIMIT 1')) { + throw new QueryErrorException('The subquery must be limited to one record'); + } + + $params = array_merge($params, $arguments); + + return $dml; + } +} diff --git a/src/Database/Dialects/Compilers/UpdateCompiler.php b/src/Database/Dialects/Compilers/UpdateCompiler.php new file mode 100644 index 00000000..945ad98b --- /dev/null +++ b/src/Database/Dialects/Compilers/UpdateCompiler.php @@ -0,0 +1,55 @@ +whereCompiler = new WhereCompiler(); + } + + public function compile(QueryAst $ast): CompiledClause + { + $parts = []; + $params = []; + + $parts[] = 'UPDATE'; + $parts[] = $ast->table; + + // SET col1 = ?, col2 = ? + // Extract params from values (these are actual values, not placeholders) + $columns = []; + + foreach ($ast->values as $column => $value) { + $params[] = $value; + $columns[] = "{$column} = " . SQL::PLACEHOLDER->value; + } + + $parts[] = 'SET'; + $parts[] = Arr::implodeDeeply($columns, ', '); + + if (!empty($ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($ast->wheres); + + $parts[] = 'WHERE'; + $parts[] = $whereCompiled->sql; + + $params = array_merge($params, $ast->params); + } + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $params); + } +} diff --git a/src/Database/Dialects/Compilers/WhereCompiler.php b/src/Database/Dialects/Compilers/WhereCompiler.php new file mode 100644 index 00000000..280a6f9d --- /dev/null +++ b/src/Database/Dialects/Compilers/WhereCompiler.php @@ -0,0 +1,48 @@ +> $wheres + * @return CompiledClause + */ + public function compile(array $wheres): CompiledClause + { + if (empty($wheres)) { + return new CompiledClause('', []); + } + + $prepared = $this->prepareClauses($wheres); + $sql = Arr::implodeDeeply($prepared); + + // WHERE clauses don't add new params - they're already in QueryAst params + return new CompiledClause($sql, []); + } + + /** + * @param array> $clauses + * @return array> + */ + private function prepareClauses(array $clauses): array + { + return array_map(function (array $clause): array { + return array_map(function ($value): mixed { + return match (true) { + $value instanceof Operator => $value->value, + $value instanceof LogicalOperator => $value->value, + is_array($value) => '(' . Arr::implodeDeeply($value, ', ') . ')', + default => $value, + }; + }, $clause); + }, $clauses); + } +} diff --git a/src/Database/Dialects/Contracts/ClauseCompiler.php b/src/Database/Dialects/Contracts/ClauseCompiler.php new file mode 100644 index 00000000..ca8598e5 --- /dev/null +++ b/src/Database/Dialects/Contracts/ClauseCompiler.php @@ -0,0 +1,12 @@ + $params The parameters for prepared statements + */ + public function __construct( + public string $sql, + public array $params = [] + ) {} +} diff --git a/src/Database/Dialects/Contracts/Dialect.php b/src/Database/Dialects/Contracts/Dialect.php new file mode 100644 index 00000000..48193bff --- /dev/null +++ b/src/Database/Dialects/Contracts/Dialect.php @@ -0,0 +1,18 @@ +} A tuple of SQL string and parameters + */ + public function compile(QueryAst $ast): array; + + public function capabilities(): DialectCapabilities; +} diff --git a/src/Database/Dialects/Contracts/DialectCapabilities.php b/src/Database/Dialects/Contracts/DialectCapabilities.php new file mode 100644 index 00000000..cdfbb139 --- /dev/null +++ b/src/Database/Dialects/Contracts/DialectCapabilities.php @@ -0,0 +1,49 @@ +>, ->, etc.) + * @param bool $supportsAdvancedLocks Whether the dialect supports advanced locks (FOR NO KEY UPDATE, etc.) + * @param bool $supportsInsertIgnore Whether the dialect supports INSERT IGNORE syntax + * @param bool $supportsFulltextSearch Whether the dialect supports full-text search + * @param bool $supportsGeneratedColumns Whether the dialect supports generated/computed columns + */ + public function __construct( + public bool $supportsLocks = false, + public bool $supportsUpsert = false, + public bool $supportsReturning = false, + public bool $supportsJsonOperators = false, + public bool $supportsAdvancedLocks = false, + public bool $supportsInsertIgnore = false, + public bool $supportsFulltextSearch = false, + public bool $supportsGeneratedColumns = false, + ) {} + + /** + * Check if a specific capability is supported. + * + * @param string $capability The capability name (e.g., 'locks', 'upsert') + * @return bool + */ + public function supports(string $capability): bool + { + $property = 'supports' . ucfirst($capability); + + return property_exists($this, $property) && $this->$property; + } +} diff --git a/src/Database/Dialects/DialectFactory.php b/src/Database/Dialects/DialectFactory.php new file mode 100644 index 00000000..40fc82f2 --- /dev/null +++ b/src/Database/Dialects/DialectFactory.php @@ -0,0 +1,35 @@ + + */ + private static array $instances = []; + + private function __construct() {} + + public static function fromDriver(Driver $driver): Dialect + { + return self::$instances[$driver->value] ??= match ($driver) { + Driver::MYSQL => new MysqlDialect(), + Driver::POSTGRESQL => new PostgresDialect(), + Driver::SQLITE => new SqliteDialect(), + }; + } + + public static function clearCache(): void + { + self::$instances = []; + } +} diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlDeleteCompiler.php b/src/Database/Dialects/MySQL/Compilers/MysqlDeleteCompiler.php new file mode 100644 index 00000000..1940319d --- /dev/null +++ b/src/Database/Dialects/MySQL/Compilers/MysqlDeleteCompiler.php @@ -0,0 +1,12 @@ + "{$column} = VALUES({$column})", + $ast->uniqueColumns + ); + + return 'ON DUPLICATE KEY UPDATE ' . Arr::implodeDeeply($columns, ', '); + } +} diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlSelectCompiler.php b/src/Database/Dialects/MySQL/Compilers/MysqlSelectCompiler.php new file mode 100644 index 00000000..d54f37d9 --- /dev/null +++ b/src/Database/Dialects/MySQL/Compilers/MysqlSelectCompiler.php @@ -0,0 +1,26 @@ +lock === null) { + return ''; + } + + return match ($ast->lock) { + Lock::FOR_UPDATE => 'FOR UPDATE', + Lock::FOR_SHARE => 'FOR SHARE', + Lock::FOR_UPDATE_SKIP_LOCKED => 'FOR UPDATE SKIP LOCKED', + default => '', + }; + } +} diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlUpdateCompiler.php b/src/Database/Dialects/MySQL/Compilers/MysqlUpdateCompiler.php new file mode 100644 index 00000000..20c665f1 --- /dev/null +++ b/src/Database/Dialects/MySQL/Compilers/MysqlUpdateCompiler.php @@ -0,0 +1,12 @@ +capabilities = new DialectCapabilities( + supportsLocks: true, + supportsUpsert: true, + supportsReturning: false, + supportsJsonOperators: true, + supportsAdvancedLocks: false, + supportsInsertIgnore: true, + supportsFulltextSearch: true, + supportsGeneratedColumns: true, + ); + + $this->selectCompiler = new MysqlSelectCompiler(); + $this->insertCompiler = new MysqlInsertCompiler(); + $this->updateCompiler = new MysqlUpdateCompiler(); + $this->deleteCompiler = new MysqlDeleteCompiler(); + $this->existsCompiler = new MysqlExistsCompiler(); + } + + public function capabilities(): DialectCapabilities + { + return $this->capabilities; + } + + public function compile(QueryAst $ast): array + { + return match ($ast->action) { + Action::SELECT => $this->compileSelect($ast), + Action::INSERT => $this->compileInsert($ast), + Action::UPDATE => $this->compileUpdate($ast), + Action::DELETE => $this->compileDelete($ast), + Action::EXISTS => $this->compileExists($ast), + }; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileSelect(QueryAst $ast): array + { + $compiled = $this->selectCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileInsert(QueryAst $ast): array + { + $compiled = $this->insertCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileUpdate(QueryAst $ast): array + { + $compiled = $this->updateCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileDelete(QueryAst $ast): array + { + $compiled = $this->deleteCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileExists(QueryAst $ast): array + { + $compiled = $this->existsCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } +} diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php new file mode 100644 index 00000000..14161130 --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php @@ -0,0 +1,12 @@ +uniqueColumns, ', '); + + $updateColumns = array_map(function (string $column): string { + return "{$column} = EXCLUDED.{$column}"; + }, $ast->uniqueColumns); + + return sprintf( + 'ON CONFLICT (%s) DO UPDATE SET %s', + $conflictColumns, + Arr::implodeDeeply($updateColumns, ', ') + ); + } + + public function compile(QueryAst $ast): CompiledClause + { + if ($ast->ignore && empty($ast->uniqueColumns)) { + $parts = []; + $parts[] = 'INSERT INTO'; + $parts[] = $ast->table; + $parts[] = '(' . Arr::implodeDeeply($ast->columns, ', ') . ')'; + + if ($ast->rawStatement !== null) { + $parts[] = $ast->rawStatement; + } else { + $parts[] = 'VALUES'; + $placeholders = array_map(function (array $value): string { + return '(' . Arr::implodeDeeply($value, ', ') . ')'; + }, $ast->values); + $parts[] = Arr::implodeDeeply($placeholders, ', '); + } + + $parts[] = 'ON CONFLICT DO NOTHING'; + + $sql = Arr::implodeDeeply($parts); + return new CompiledClause($sql, $ast->params); + } + + return parent::compile($ast); + } +} diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php new file mode 100644 index 00000000..3e97c013 --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php @@ -0,0 +1,33 @@ +lock === null) { + return ''; + } + + return match ($ast->lock) { + Lock::FOR_UPDATE => 'FOR UPDATE', + Lock::FOR_SHARE => 'FOR SHARE', + Lock::FOR_NO_KEY_UPDATE => 'FOR NO KEY UPDATE', + Lock::FOR_KEY_SHARE => 'FOR KEY SHARE', + Lock::FOR_UPDATE_SKIP_LOCKED => 'FOR UPDATE SKIP LOCKED', + Lock::FOR_SHARE_SKIP_LOCKED => 'FOR SHARE SKIP LOCKED', + Lock::FOR_NO_KEY_UPDATE_SKIP_LOCKED => 'FOR NO KEY UPDATE SKIP LOCKED', + Lock::FOR_UPDATE_NOWAIT => 'FOR UPDATE NOWAIT', + Lock::FOR_SHARE_NOWAIT => 'FOR SHARE NOWAIT', + Lock::FOR_NO_KEY_UPDATE_NOWAIT => 'FOR NO KEY UPDATE NOWAIT', + default => '', + }; + } +} diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php new file mode 100644 index 00000000..df52eb67 --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php @@ -0,0 +1,12 @@ +capabilities = new DialectCapabilities( + supportsLocks: true, + supportsUpsert: true, + supportsReturning: true, + supportsJsonOperators: true, + supportsAdvancedLocks: true, // FOR NO KEY UPDATE, FOR KEY SHARE, etc. + supportsInsertIgnore: false, // Uses ON CONFLICT instead + supportsFulltextSearch: true, + supportsGeneratedColumns: true, + ); + + $this->selectCompiler = new PostgresSelectCompiler(); + $this->insertCompiler = new PostgresInsertCompiler(); + $this->updateCompiler = new PostgresUpdateCompiler(); + $this->deleteCompiler = new PostgresDeleteCompiler(); + $this->existsCompiler = new PostgresExistsCompiler(); + } + + public function capabilities(): DialectCapabilities + { + return $this->capabilities; + } + + public function compile(QueryAst $ast): array + { + return match ($ast->action) { + Action::SELECT => $this->compileSelect($ast), + Action::INSERT => $this->compileInsert($ast), + Action::UPDATE => $this->compileUpdate($ast), + Action::DELETE => $this->compileDelete($ast), + Action::EXISTS => $this->compileExists($ast), + }; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileSelect(QueryAst $ast): array + { + $compiled = $this->selectCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileInsert(QueryAst $ast): array + { + $compiled = $this->insertCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileUpdate(QueryAst $ast): array + { + $compiled = $this->updateCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileDelete(QueryAst $ast): array + { + $compiled = $this->deleteCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileExists(QueryAst $ast): array + { + $compiled = $this->existsCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } +} diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php b/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php new file mode 100644 index 00000000..5dc363db --- /dev/null +++ b/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php @@ -0,0 +1,12 @@ +uniqueColumns, ', '); + + $updateColumns = array_map(function (string $column): string { + return "{$column} = excluded.{$column}"; + }, $ast->uniqueColumns); + + return sprintf( + 'ON CONFLICT (%s) DO UPDATE SET %s', + $conflictColumns, + Arr::implodeDeeply($updateColumns, ', ') + ); + } +} diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteSelectCompiler.php b/src/Database/Dialects/SQLite/Compilers/SqliteSelectCompiler.php new file mode 100644 index 00000000..f5bc1729 --- /dev/null +++ b/src/Database/Dialects/SQLite/Compilers/SqliteSelectCompiler.php @@ -0,0 +1,17 @@ +capabilities = new DialectCapabilities( + supportsLocks: false, // SQLite doesn't support row-level locks + supportsUpsert: true, // SQLite 3.24.0+ supports ON CONFLICT + supportsReturning: true, // SQLite 3.35.0+ supports RETURNING + supportsJsonOperators: true, // SQLite 3.38.0+ supports JSON functions + supportsAdvancedLocks: false, + supportsInsertIgnore: true, // INSERT OR IGNORE + supportsFulltextSearch: true, // FTS5 + supportsGeneratedColumns: true, // SQLite 3.31.0+ + ); + + $this->selectCompiler = new SqliteSelectCompiler(); + $this->insertCompiler = new SqliteInsertCompiler(); + $this->updateCompiler = new SqliteUpdateCompiler(); + $this->deleteCompiler = new SqliteDeleteCompiler(); + $this->existsCompiler = new SqliteExistsCompiler(); + } + + public function capabilities(): DialectCapabilities + { + return $this->capabilities; + } + + public function compile(QueryAst $ast): array + { + return match ($ast->action) { + Action::SELECT => $this->compileSelect($ast), + Action::INSERT => $this->compileInsert($ast), + Action::UPDATE => $this->compileUpdate($ast), + Action::DELETE => $this->compileDelete($ast), + Action::EXISTS => $this->compileExists($ast), + }; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileSelect(QueryAst $ast): array + { + $compiled = $this->selectCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileInsert(QueryAst $ast): array + { + $compiled = $this->insertCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileUpdate(QueryAst $ast): array + { + $compiled = $this->updateCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileDelete(QueryAst $ast): array + { + $compiled = $this->deleteCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileExists(QueryAst $ast): array + { + $compiled = $this->existsCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } +} diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index 474d6c9d..33287321 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -28,8 +28,6 @@ class DatabaseQueryBuilder extends QueryBuilder { - use HasJoinClause; - protected DatabaseModel $model; /** diff --git a/src/Database/QueryAst.php b/src/Database/QueryAst.php new file mode 100644 index 00000000..7079cd0c --- /dev/null +++ b/src/Database/QueryAst.php @@ -0,0 +1,89 @@ + + */ + public array $columns = ['*']; + + /** + * Values for INSERT/UPDATE operations + * + * @var array + */ + public array $values = []; + + /** + * @var array + */ + public array $joins = []; + + /** + * @var array> + */ + public array $wheres = []; + + /** + * @var string|null + */ + public string|null $having = null; + + /** + * @var array + */ + public array $groups = []; + + /** + * @var array + */ + public array $orders = []; + + public int|null $limit = null; + + public int|null $offset = null; + + public Lock|null $lock = null; + + /** + * RETURNING clause columns (PostgreSQL, SQLite 3.35+) + * + * @var array + */ + public array $returning = []; + + /** + * Prepared statement parameters + * + * @var array + */ + public array $params = []; + + /** + * @var string|null + */ + public string|null $rawStatement = null; + + /** + * Whether to use INSERT IGNORE (MySQL) + * */ + public bool $ignore = false; + + /** + * Columns for UPSERT operations (ON DUPLICATE KEY / ON CONFLICT) + * + * @var array + */ + public array $uniqueColumns = []; +} diff --git a/tests/Unit/Database/Dialects/DialectFactoryTest.php b/tests/Unit/Database/Dialects/DialectFactoryTest.php new file mode 100644 index 00000000..ebbe399c --- /dev/null +++ b/tests/Unit/Database/Dialects/DialectFactoryTest.php @@ -0,0 +1,49 @@ +toBeInstanceOf(MysqlDialect::class); + }); + +test('DialectFactory creates PostgreSQL dialect for PostgreSQL driver', function () { + $dialect = DialectFactory::fromDriver(Driver::POSTGRESQL); + + expect($dialect)->toBeInstanceOf(PostgresDialect::class); + }); + +test('DialectFactory creates SQLite dialect for SQLite driver', function () { + $dialect = DialectFactory::fromDriver(Driver::SQLITE); + + expect($dialect)->toBeInstanceOf(SqliteDialect::class); + }); + +test('DialectFactory returns same instance for repeated calls (singleton)', function () { + $dialect1 = DialectFactory::fromDriver(Driver::MYSQL); + $dialect2 = DialectFactory::fromDriver(Driver::MYSQL); + + expect($dialect1)->toBe($dialect2); + }); + +test('DialectFactory clearCache clears cached instances', function () { + $dialect1 = DialectFactory::fromDriver(Driver::MYSQL); + + DialectFactory::clearCache(); + + $dialect2 = DialectFactory::fromDriver(Driver::MYSQL); + + expect($dialect1)->not->toBe($dialect2); +}); From 1306eaeb34762095954379dd80d67a2219b841e1 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 24 Dec 2025 14:36:57 -0500 Subject: [PATCH 08/11] style: php cs --- src/Database/Concerns/Query/BuildsQuery.php | 18 +++++------ .../Dialects/Compilers/DeleteCompiler.php | 4 +-- .../Dialects/Compilers/ExistsCompiler.php | 6 ++-- .../Dialects/Compilers/InsertCompiler.php | 2 +- .../Dialects/Compilers/SelectCompiler.php | 10 +++--- .../Dialects/Compilers/UpdateCompiler.php | 4 +-- .../Dialects/Contracts/CompiledClause.php | 3 +- .../Contracts/DialectCapabilities.php | 7 +++-- src/Database/Dialects/DialectFactory.php | 4 ++- .../MySQL/Compilers/MysqlInsertCompiler.php | 4 +-- src/Database/Dialects/MySQL/MysqlDialect.php | 16 +++++----- .../Compilers/PostgresInsertCompiler.php | 5 +-- .../Dialects/PostgreSQL/PostgresDialect.php | 16 +++++----- .../Dialects/SQLite/SqliteDialect.php | 16 +++++----- .../Database/Dialects/DialectFactoryTest.php | 31 +++++++++---------- 15 files changed, 75 insertions(+), 71 deletions(-) diff --git a/src/Database/Concerns/Query/BuildsQuery.php b/src/Database/Concerns/Query/BuildsQuery.php index c681b838..bfa4ff56 100644 --- a/src/Database/Concerns/Query/BuildsQuery.php +++ b/src/Database/Concerns/Query/BuildsQuery.php @@ -5,18 +5,18 @@ namespace Phenix\Database\Concerns\Query; use Closure; -use Phenix\Util\Arr; -use Phenix\Database\Value; -use Phenix\Database\Having; -use Phenix\Database\QueryAst; -use Phenix\Database\Subquery; -use Phenix\Database\Functions; -use Phenix\Database\SelectCase; -use Phenix\Database\Constants\SQL; -use Phenix\Database\Constants\Order; use Phenix\Database\Constants\Action; use Phenix\Database\Constants\Operator; +use Phenix\Database\Constants\Order; +use Phenix\Database\Constants\SQL; use Phenix\Database\Dialects\DialectFactory; +use Phenix\Database\Functions; +use Phenix\Database\Having; +use Phenix\Database\QueryAst; +use Phenix\Database\SelectCase; +use Phenix\Database\Subquery; +use Phenix\Database\Value; +use Phenix\Util\Arr; trait BuildsQuery { diff --git a/src/Database/Dialects/Compilers/DeleteCompiler.php b/src/Database/Dialects/Compilers/DeleteCompiler.php index 6e30316a..22076dbf 100644 --- a/src/Database/Dialects/Compilers/DeleteCompiler.php +++ b/src/Database/Dialects/Compilers/DeleteCompiler.php @@ -25,9 +25,9 @@ public function compile(QueryAst $ast): CompiledClause $parts[] = 'DELETE FROM'; $parts[] = $ast->table; - if (!empty($ast->wheres)) { + if (! empty($ast->wheres)) { $whereCompiled = $this->whereCompiler->compile($ast->wheres); - + $parts[] = 'WHERE'; $parts[] = $whereCompiled->sql; } diff --git a/src/Database/Dialects/Compilers/ExistsCompiler.php b/src/Database/Dialects/Compilers/ExistsCompiler.php index 9227e2b4..4360d540 100644 --- a/src/Database/Dialects/Compilers/ExistsCompiler.php +++ b/src/Database/Dialects/Compilers/ExistsCompiler.php @@ -23,15 +23,15 @@ public function compile(QueryAst $ast): CompiledClause { $parts = []; $parts[] = 'SELECT'; - - $column = !empty($ast->columns) ? $ast->columns[0] : 'EXISTS'; + + $column = ! empty($ast->columns) ? $ast->columns[0] : 'EXISTS'; $parts[] = $column; $subquery = []; $subquery[] = 'SELECT 1 FROM'; $subquery[] = $ast->table; - if (!empty($ast->wheres)) { + if (! empty($ast->wheres)) { $whereCompiled = $this->whereCompiler->compile($ast->wheres); $subquery[] = 'WHERE'; diff --git a/src/Database/Dialects/Compilers/InsertCompiler.php b/src/Database/Dialects/Compilers/InsertCompiler.php index 5e657440..98eca4ad 100644 --- a/src/Database/Dialects/Compilers/InsertCompiler.php +++ b/src/Database/Dialects/Compilers/InsertCompiler.php @@ -38,7 +38,7 @@ public function compile(QueryAst $ast): CompiledClause } // Dialect-specific UPSERT/ON CONFLICT handling - if (!empty($ast->uniqueColumns)) { + if (! empty($ast->uniqueColumns)) { $parts[] = $this->compileUpsert($ast); } diff --git a/src/Database/Dialects/Compilers/SelectCompiler.php b/src/Database/Dialects/Compilers/SelectCompiler.php index b1003a68..be623ac6 100644 --- a/src/Database/Dialects/Compilers/SelectCompiler.php +++ b/src/Database/Dialects/Compilers/SelectCompiler.php @@ -36,11 +36,11 @@ public function compile(QueryAst $ast): CompiledClause $ast->table, ]; - if (!empty($ast->joins)) { + if (! empty($ast->joins)) { $sql[] = $ast->joins; } - if (!empty($ast->wheres)) { + if (! empty($ast->wheres)) { $whereCompiled = $this->whereCompiler->compile($ast->wheres); if ($whereCompiled->sql !== '') { @@ -53,11 +53,11 @@ public function compile(QueryAst $ast): CompiledClause $sql[] = $ast->having; } - if (!empty($ast->groups)) { + if (! empty($ast->groups)) { $sql[] = Arr::implodeDeeply($ast->groups); } - if (!empty($ast->orders)) { + if (! empty($ast->orders)) { $sql[] = Arr::implodeDeeply($ast->orders); } @@ -118,7 +118,7 @@ private function compileSubquery(Subquery $subquery, array &$params): string { [$dml, $arguments] = $subquery->toSql(); - if (!str_contains($dml, 'LIMIT 1')) { + if (! str_contains($dml, 'LIMIT 1')) { throw new QueryErrorException('The subquery must be limited to one record'); } diff --git a/src/Database/Dialects/Compilers/UpdateCompiler.php b/src/Database/Dialects/Compilers/UpdateCompiler.php index 945ad98b..1a40bcd2 100644 --- a/src/Database/Dialects/Compilers/UpdateCompiler.php +++ b/src/Database/Dialects/Compilers/UpdateCompiler.php @@ -35,11 +35,11 @@ public function compile(QueryAst $ast): CompiledClause $params[] = $value; $columns[] = "{$column} = " . SQL::PLACEHOLDER->value; } - + $parts[] = 'SET'; $parts[] = Arr::implodeDeeply($columns, ', '); - if (!empty($ast->wheres)) { + if (! empty($ast->wheres)) { $whereCompiled = $this->whereCompiler->compile($ast->wheres); $parts[] = 'WHERE'; diff --git a/src/Database/Dialects/Contracts/CompiledClause.php b/src/Database/Dialects/Contracts/CompiledClause.php index 6ca82567..020bc46b 100644 --- a/src/Database/Dialects/Contracts/CompiledClause.php +++ b/src/Database/Dialects/Contracts/CompiledClause.php @@ -13,5 +13,6 @@ public function __construct( public string $sql, public array $params = [] - ) {} + ) { + } } diff --git a/src/Database/Dialects/Contracts/DialectCapabilities.php b/src/Database/Dialects/Contracts/DialectCapabilities.php index cdfbb139..95795156 100644 --- a/src/Database/Dialects/Contracts/DialectCapabilities.php +++ b/src/Database/Dialects/Contracts/DialectCapabilities.php @@ -6,7 +6,7 @@ /** * Defines the capabilities supported by a SQL dialect. - * + * * This immutable value object declares which features are supported * by a specific database driver, allowing graceful degradation or * error handling for unsupported features. @@ -32,7 +32,8 @@ public function __construct( public bool $supportsInsertIgnore = false, public bool $supportsFulltextSearch = false, public bool $supportsGeneratedColumns = false, - ) {} + ) { + } /** * Check if a specific capability is supported. @@ -43,7 +44,7 @@ public function __construct( public function supports(string $capability): bool { $property = 'supports' . ucfirst($capability); - + return property_exists($this, $property) && $this->$property; } } diff --git a/src/Database/Dialects/DialectFactory.php b/src/Database/Dialects/DialectFactory.php index 40fc82f2..d674eee2 100644 --- a/src/Database/Dialects/DialectFactory.php +++ b/src/Database/Dialects/DialectFactory.php @@ -17,7 +17,9 @@ final class DialectFactory */ private static array $instances = []; - private function __construct() {} + private function __construct() + { + } public static function fromDriver(Driver $driver): Dialect { diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlInsertCompiler.php b/src/Database/Dialects/MySQL/Compilers/MysqlInsertCompiler.php index e8b92e7a..183ca854 100644 --- a/src/Database/Dialects/MySQL/Compilers/MysqlInsertCompiler.php +++ b/src/Database/Dialects/MySQL/Compilers/MysqlInsertCompiler.php @@ -18,8 +18,8 @@ protected function compileInsertIgnore(): string protected function compileUpsert(QueryAst $ast): string { $columns = array_map( - fn (string $column): string => "{$column} = VALUES({$column})", - $ast->uniqueColumns + fn (string $column): string => "{$column} = VALUES({$column})", + $ast->uniqueColumns ); return 'ON DUPLICATE KEY UPDATE ' . Arr::implodeDeeply($columns, ', '); diff --git a/src/Database/Dialects/MySQL/MysqlDialect.php b/src/Database/Dialects/MySQL/MysqlDialect.php index 299da485..63d5491e 100644 --- a/src/Database/Dialects/MySQL/MysqlDialect.php +++ b/src/Database/Dialects/MySQL/MysqlDialect.php @@ -7,11 +7,11 @@ use Phenix\Database\Constants\Action; use Phenix\Database\Dialects\Contracts\Dialect; use Phenix\Database\Dialects\Contracts\DialectCapabilities; -use Phenix\Database\Dialects\MySQL\Compilers\MysqlSelectCompiler; -use Phenix\Database\Dialects\MySQL\Compilers\MysqlInsertCompiler; -use Phenix\Database\Dialects\MySQL\Compilers\MysqlUpdateCompiler; use Phenix\Database\Dialects\MySQL\Compilers\MysqlDeleteCompiler; use Phenix\Database\Dialects\MySQL\Compilers\MysqlExistsCompiler; +use Phenix\Database\Dialects\MySQL\Compilers\MysqlInsertCompiler; +use Phenix\Database\Dialects\MySQL\Compilers\MysqlSelectCompiler; +use Phenix\Database\Dialects\MySQL\Compilers\MysqlUpdateCompiler; use Phenix\Database\QueryAst; final class MysqlDialect implements Dialect @@ -70,7 +70,7 @@ public function compile(QueryAst $ast): array private function compileSelect(QueryAst $ast): array { $compiled = $this->selectCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } @@ -80,7 +80,7 @@ private function compileSelect(QueryAst $ast): array private function compileInsert(QueryAst $ast): array { $compiled = $this->insertCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } @@ -90,7 +90,7 @@ private function compileInsert(QueryAst $ast): array private function compileUpdate(QueryAst $ast): array { $compiled = $this->updateCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } @@ -100,7 +100,7 @@ private function compileUpdate(QueryAst $ast): array private function compileDelete(QueryAst $ast): array { $compiled = $this->deleteCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } @@ -110,7 +110,7 @@ private function compileDelete(QueryAst $ast): array private function compileExists(QueryAst $ast): array { $compiled = $this->existsCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } } diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php index 1cf8154f..c7a839fd 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php @@ -4,10 +4,10 @@ namespace Phenix\Database\Dialects\PostgreSQL\Compilers; -use Phenix\Util\Arr; -use Phenix\Database\QueryAst; use Phenix\Database\Dialects\Compilers\InsertCompiler; use Phenix\Database\Dialects\Contracts\CompiledClause; +use Phenix\Database\QueryAst; +use Phenix\Util\Arr; /** * Supports: @@ -57,6 +57,7 @@ public function compile(QueryAst $ast): CompiledClause $parts[] = 'ON CONFLICT DO NOTHING'; $sql = Arr::implodeDeeply($parts); + return new CompiledClause($sql, $ast->params); } diff --git a/src/Database/Dialects/PostgreSQL/PostgresDialect.php b/src/Database/Dialects/PostgreSQL/PostgresDialect.php index 5961d248..a0bb1af9 100644 --- a/src/Database/Dialects/PostgreSQL/PostgresDialect.php +++ b/src/Database/Dialects/PostgreSQL/PostgresDialect.php @@ -7,11 +7,11 @@ use Phenix\Database\Constants\Action; use Phenix\Database\Dialects\Contracts\Dialect; use Phenix\Database\Dialects\Contracts\DialectCapabilities; -use Phenix\Database\Dialects\PostgreSQL\Compilers\PostgresSelectCompiler; -use Phenix\Database\Dialects\PostgreSQL\Compilers\PostgresInsertCompiler; -use Phenix\Database\Dialects\PostgreSQL\Compilers\PostgresUpdateCompiler; use Phenix\Database\Dialects\PostgreSQL\Compilers\PostgresDeleteCompiler; use Phenix\Database\Dialects\PostgreSQL\Compilers\PostgresExistsCompiler; +use Phenix\Database\Dialects\PostgreSQL\Compilers\PostgresInsertCompiler; +use Phenix\Database\Dialects\PostgreSQL\Compilers\PostgresSelectCompiler; +use Phenix\Database\Dialects\PostgreSQL\Compilers\PostgresUpdateCompiler; use Phenix\Database\QueryAst; final class PostgresDialect implements Dialect @@ -65,7 +65,7 @@ public function compile(QueryAst $ast): array private function compileSelect(QueryAst $ast): array { $compiled = $this->selectCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } @@ -75,7 +75,7 @@ private function compileSelect(QueryAst $ast): array private function compileInsert(QueryAst $ast): array { $compiled = $this->insertCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } @@ -85,7 +85,7 @@ private function compileInsert(QueryAst $ast): array private function compileUpdate(QueryAst $ast): array { $compiled = $this->updateCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } @@ -95,7 +95,7 @@ private function compileUpdate(QueryAst $ast): array private function compileDelete(QueryAst $ast): array { $compiled = $this->deleteCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } @@ -105,7 +105,7 @@ private function compileDelete(QueryAst $ast): array private function compileExists(QueryAst $ast): array { $compiled = $this->existsCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } } diff --git a/src/Database/Dialects/SQLite/SqliteDialect.php b/src/Database/Dialects/SQLite/SqliteDialect.php index c10ac0e2..bfa7985f 100644 --- a/src/Database/Dialects/SQLite/SqliteDialect.php +++ b/src/Database/Dialects/SQLite/SqliteDialect.php @@ -7,11 +7,11 @@ use Phenix\Database\Constants\Action; use Phenix\Database\Dialects\Contracts\Dialect; use Phenix\Database\Dialects\Contracts\DialectCapabilities; -use Phenix\Database\Dialects\SQLite\Compilers\SqliteSelectCompiler; -use Phenix\Database\Dialects\SQLite\Compilers\SqliteInsertCompiler; -use Phenix\Database\Dialects\SQLite\Compilers\SqliteUpdateCompiler; use Phenix\Database\Dialects\SQLite\Compilers\SqliteDeleteCompiler; use Phenix\Database\Dialects\SQLite\Compilers\SqliteExistsCompiler; +use Phenix\Database\Dialects\SQLite\Compilers\SqliteInsertCompiler; +use Phenix\Database\Dialects\SQLite\Compilers\SqliteSelectCompiler; +use Phenix\Database\Dialects\SQLite\Compilers\SqliteUpdateCompiler; use Phenix\Database\QueryAst; final class SqliteDialect implements Dialect @@ -65,7 +65,7 @@ public function compile(QueryAst $ast): array private function compileSelect(QueryAst $ast): array { $compiled = $this->selectCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } @@ -75,7 +75,7 @@ private function compileSelect(QueryAst $ast): array private function compileInsert(QueryAst $ast): array { $compiled = $this->insertCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } @@ -85,7 +85,7 @@ private function compileInsert(QueryAst $ast): array private function compileUpdate(QueryAst $ast): array { $compiled = $this->updateCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } @@ -95,7 +95,7 @@ private function compileUpdate(QueryAst $ast): array private function compileDelete(QueryAst $ast): array { $compiled = $this->deleteCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } @@ -105,7 +105,7 @@ private function compileDelete(QueryAst $ast): array private function compileExists(QueryAst $ast): array { $compiled = $this->existsCompiler->compile($ast); - + return [$compiled->sql, $compiled->params]; } } diff --git a/tests/Unit/Database/Dialects/DialectFactoryTest.php b/tests/Unit/Database/Dialects/DialectFactoryTest.php index ebbe399c..e542bb7b 100644 --- a/tests/Unit/Database/Dialects/DialectFactoryTest.php +++ b/tests/Unit/Database/Dialects/DialectFactoryTest.php @@ -8,41 +8,40 @@ use Phenix\Database\Dialects\PostgreSQL\PostgresDialect; use Phenix\Database\Dialects\SQLite\SqliteDialect; - afterEach(function (): void { DialectFactory::clearCache(); }); test('DialectFactory creates MySQL dialect for MySQL driver', function () { - $dialect = DialectFactory::fromDriver(Driver::MYSQL); + $dialect = DialectFactory::fromDriver(Driver::MYSQL); - expect($dialect)->toBeInstanceOf(MysqlDialect::class); - }); + expect($dialect)->toBeInstanceOf(MysqlDialect::class); +}); test('DialectFactory creates PostgreSQL dialect for PostgreSQL driver', function () { - $dialect = DialectFactory::fromDriver(Driver::POSTGRESQL); + $dialect = DialectFactory::fromDriver(Driver::POSTGRESQL); - expect($dialect)->toBeInstanceOf(PostgresDialect::class); - }); + expect($dialect)->toBeInstanceOf(PostgresDialect::class); +}); test('DialectFactory creates SQLite dialect for SQLite driver', function () { - $dialect = DialectFactory::fromDriver(Driver::SQLITE); + $dialect = DialectFactory::fromDriver(Driver::SQLITE); - expect($dialect)->toBeInstanceOf(SqliteDialect::class); - }); + expect($dialect)->toBeInstanceOf(SqliteDialect::class); +}); test('DialectFactory returns same instance for repeated calls (singleton)', function () { - $dialect1 = DialectFactory::fromDriver(Driver::MYSQL); - $dialect2 = DialectFactory::fromDriver(Driver::MYSQL); + $dialect1 = DialectFactory::fromDriver(Driver::MYSQL); + $dialect2 = DialectFactory::fromDriver(Driver::MYSQL); - expect($dialect1)->toBe($dialect2); - }); + expect($dialect1)->toBe($dialect2); +}); test('DialectFactory clearCache clears cached instances', function () { $dialect1 = DialectFactory::fromDriver(Driver::MYSQL); - + DialectFactory::clearCache(); - + $dialect2 = DialectFactory::fromDriver(Driver::MYSQL); expect($dialect1)->not->toBe($dialect2); From 379a1a84aec96bd66668620a5cf76022b25f787b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 24 Dec 2025 14:47:08 -0500 Subject: [PATCH 09/11] refactor(Database): improve connection handling and default dialect behavior --- src/Database/Connections/ConnectionFactory.php | 3 --- src/Database/Dialects/DialectFactory.php | 2 ++ .../Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Database/Connections/ConnectionFactory.php b/src/Database/Connections/ConnectionFactory.php index 4aac0f63..77fa2cc3 100644 --- a/src/Database/Connections/ConnectionFactory.php +++ b/src/Database/Connections/ConnectionFactory.php @@ -28,9 +28,6 @@ public static function make(Driver $driver, #[SensitiveParameter] array $setting Driver::POSTGRESQL => self::createPostgreSqlConnection($settings), Driver::REDIS => self::createRedisConnection($settings), Driver::SQLITE => self::createSqliteConnection($settings), - default => throw new InvalidArgumentException( - sprintf('Unsupported driver: %s', $driver->name) - ), }; } diff --git a/src/Database/Dialects/DialectFactory.php b/src/Database/Dialects/DialectFactory.php index d674eee2..193a442c 100644 --- a/src/Database/Dialects/DialectFactory.php +++ b/src/Database/Dialects/DialectFactory.php @@ -19,6 +19,7 @@ final class DialectFactory private function __construct() { + // Prevent instantiation } public static function fromDriver(Driver $driver): Dialect @@ -27,6 +28,7 @@ public static function fromDriver(Driver $driver): Dialect Driver::MYSQL => new MysqlDialect(), Driver::POSTGRESQL => new PostgresDialect(), Driver::SQLITE => new SqliteDialect(), + default => new MysqlDialect(), }; } diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php index 3e97c013..f9495347 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php @@ -27,7 +27,6 @@ protected function compileLock(QueryAst $ast): string Lock::FOR_UPDATE_NOWAIT => 'FOR UPDATE NOWAIT', Lock::FOR_SHARE_NOWAIT => 'FOR SHARE NOWAIT', Lock::FOR_NO_KEY_UPDATE_NOWAIT => 'FOR NO KEY UPDATE NOWAIT', - default => '', }; } } From b3d5aa10dc3e82d02ae2d8fba477c26d99a8f531 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 24 Dec 2025 14:48:12 -0500 Subject: [PATCH 10/11] fix(PostgresInsertCompiler): ensure placeholders are correctly indexed in VALUES clause --- src/Database/Connections/ConnectionFactory.php | 1 - src/Database/Dialects/Compilers/InsertCompiler.php | 2 +- .../Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php | 4 +++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Database/Connections/ConnectionFactory.php b/src/Database/Connections/ConnectionFactory.php index 77fa2cc3..94e7ce4f 100644 --- a/src/Database/Connections/ConnectionFactory.php +++ b/src/Database/Connections/ConnectionFactory.php @@ -10,7 +10,6 @@ use Amp\Postgres\PostgresConnectionPool; use Amp\SQLite3\SQLite3WorkerConnection; use Closure; -use InvalidArgumentException; use Phenix\Database\Constants\Driver; use Phenix\Redis\ClientWrapper; use SensitiveParameter; diff --git a/src/Database/Dialects/Compilers/InsertCompiler.php b/src/Database/Dialects/Compilers/InsertCompiler.php index 98eca4ad..a22bdc95 100644 --- a/src/Database/Dialects/Compilers/InsertCompiler.php +++ b/src/Database/Dialects/Compilers/InsertCompiler.php @@ -34,7 +34,7 @@ public function compile(QueryAst $ast): CompiledClause return '(' . Arr::implodeDeeply($value, ', ') . ')'; }, $ast->values); - $parts[] = Arr::implodeDeeply($placeholders, ', '); + $parts[] = Arr::implodeDeeply(array_values($placeholders), ', '); } // Dialect-specific UPSERT/ON CONFLICT handling diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php index c7a839fd..48713dfd 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php @@ -48,10 +48,12 @@ public function compile(QueryAst $ast): CompiledClause $parts[] = $ast->rawStatement; } else { $parts[] = 'VALUES'; + $placeholders = array_map(function (array $value): string { return '(' . Arr::implodeDeeply($value, ', ') . ')'; }, $ast->values); - $parts[] = Arr::implodeDeeply($placeholders, ', '); + + $parts[] = Arr::implodeDeeply(array_values($placeholders), ', '); } $parts[] = 'ON CONFLICT DO NOTHING'; From 0b424035b69820e671dd61e60faf6a952cf7d32c Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 24 Dec 2025 14:56:48 -0500 Subject: [PATCH 11/11] feat: add SQLite3 support to composer dependencies --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 58f6400e..8eb5399d 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "ext-pcntl": "*", "ext-sockets": "*", "adbario/php-dot-notation": "^3.1", + "ahjdev/amphp-sqlite3": "dev-main", "amphp/cache": "^2.0", "amphp/cluster": "^2.0", "amphp/file": "^v3.0.0",