From b24e918d7034d9268b24b3ca2252f4f0bdf9337a Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 19 Dec 2025 23:46:50 +0000 Subject: [PATCH 01/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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", From 8885b303291bc9324edf87b0fdcf770c732d3089 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Dec 2025 12:09:31 -0500 Subject: [PATCH 12/49] feat: remove DialectCapabilities and add unit tests for MySQL, PostgreSQL, and SQLite dialects --- src/Database/Dialects/Contracts/Dialect.php | 2 - .../Contracts/DialectCapabilities.php | 50 ------------------- src/Database/Dialects/MySQL/MysqlDialect.php | 19 ------- .../Dialects/PostgreSQL/PostgresDialect.php | 18 ------- .../Dialects/SQLite/SqliteDialect.php | 18 ------- .../Database/Dialects/MysqlDialectTest.php | 29 +++++++++++ .../Database/Dialects/PostgresDialectTest.php | 29 +++++++++++ .../Database/Dialects/SqliteDialectTest.php | 29 +++++++++++ 8 files changed, 87 insertions(+), 107 deletions(-) delete mode 100644 src/Database/Dialects/Contracts/DialectCapabilities.php create mode 100644 tests/Unit/Database/Dialects/MysqlDialectTest.php create mode 100644 tests/Unit/Database/Dialects/PostgresDialectTest.php create mode 100644 tests/Unit/Database/Dialects/SqliteDialectTest.php diff --git a/src/Database/Dialects/Contracts/Dialect.php b/src/Database/Dialects/Contracts/Dialect.php index 48193bff..b5ccdd07 100644 --- a/src/Database/Dialects/Contracts/Dialect.php +++ b/src/Database/Dialects/Contracts/Dialect.php @@ -13,6 +13,4 @@ interface Dialect * @return array{0: string, 1: array} 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 deleted file mode 100644 index 95795156..00000000 --- a/src/Database/Dialects/Contracts/DialectCapabilities.php +++ /dev/null @@ -1,50 +0,0 @@ ->, ->, 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/MySQL/MysqlDialect.php b/src/Database/Dialects/MySQL/MysqlDialect.php index 63d5491e..45e296cd 100644 --- a/src/Database/Dialects/MySQL/MysqlDialect.php +++ b/src/Database/Dialects/MySQL/MysqlDialect.php @@ -6,7 +6,6 @@ use Phenix\Database\Constants\Action; use Phenix\Database\Dialects\Contracts\Dialect; -use Phenix\Database\Dialects\Contracts\DialectCapabilities; use Phenix\Database\Dialects\MySQL\Compilers\MysqlDeleteCompiler; use Phenix\Database\Dialects\MySQL\Compilers\MysqlExistsCompiler; use Phenix\Database\Dialects\MySQL\Compilers\MysqlInsertCompiler; @@ -16,8 +15,6 @@ final class MysqlDialect implements Dialect { - private DialectCapabilities $capabilities; - private MysqlSelectCompiler $selectCompiler; private MysqlInsertCompiler $insertCompiler; @@ -30,17 +27,6 @@ final class MysqlDialect implements Dialect public function __construct() { - $this->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(); @@ -48,11 +34,6 @@ public function __construct() $this->existsCompiler = new MysqlExistsCompiler(); } - public function capabilities(): DialectCapabilities - { - return $this->capabilities; - } - public function compile(QueryAst $ast): array { return match ($ast->action) { diff --git a/src/Database/Dialects/PostgreSQL/PostgresDialect.php b/src/Database/Dialects/PostgreSQL/PostgresDialect.php index a0bb1af9..bab2f2a2 100644 --- a/src/Database/Dialects/PostgreSQL/PostgresDialect.php +++ b/src/Database/Dialects/PostgreSQL/PostgresDialect.php @@ -6,7 +6,6 @@ use Phenix\Database\Constants\Action; use Phenix\Database\Dialects\Contracts\Dialect; -use Phenix\Database\Dialects\Contracts\DialectCapabilities; use Phenix\Database\Dialects\PostgreSQL\Compilers\PostgresDeleteCompiler; use Phenix\Database\Dialects\PostgreSQL\Compilers\PostgresExistsCompiler; use Phenix\Database\Dialects\PostgreSQL\Compilers\PostgresInsertCompiler; @@ -16,7 +15,6 @@ final class PostgresDialect implements Dialect { - private DialectCapabilities $capabilities; private PostgresSelectCompiler $selectCompiler; private PostgresInsertCompiler $insertCompiler; private PostgresUpdateCompiler $updateCompiler; @@ -25,17 +23,6 @@ final class PostgresDialect implements Dialect public function __construct() { - $this->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(); @@ -43,11 +30,6 @@ public function __construct() $this->existsCompiler = new PostgresExistsCompiler(); } - public function capabilities(): DialectCapabilities - { - return $this->capabilities; - } - public function compile(QueryAst $ast): array { return match ($ast->action) { diff --git a/src/Database/Dialects/SQLite/SqliteDialect.php b/src/Database/Dialects/SQLite/SqliteDialect.php index bfa7985f..dfb1507d 100644 --- a/src/Database/Dialects/SQLite/SqliteDialect.php +++ b/src/Database/Dialects/SQLite/SqliteDialect.php @@ -6,7 +6,6 @@ use Phenix\Database\Constants\Action; use Phenix\Database\Dialects\Contracts\Dialect; -use Phenix\Database\Dialects\Contracts\DialectCapabilities; use Phenix\Database\Dialects\SQLite\Compilers\SqliteDeleteCompiler; use Phenix\Database\Dialects\SQLite\Compilers\SqliteExistsCompiler; use Phenix\Database\Dialects\SQLite\Compilers\SqliteInsertCompiler; @@ -16,7 +15,6 @@ final class SqliteDialect implements Dialect { - private DialectCapabilities $capabilities; private SqliteSelectCompiler $selectCompiler; private SqliteInsertCompiler $insertCompiler; private SqliteUpdateCompiler $updateCompiler; @@ -25,17 +23,6 @@ final class SqliteDialect implements Dialect public function __construct() { - $this->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(); @@ -43,11 +30,6 @@ public function __construct() $this->existsCompiler = new SqliteExistsCompiler(); } - public function capabilities(): DialectCapabilities - { - return $this->capabilities; - } - public function compile(QueryAst $ast): array { return match ($ast->action) { diff --git a/tests/Unit/Database/Dialects/MysqlDialectTest.php b/tests/Unit/Database/Dialects/MysqlDialectTest.php new file mode 100644 index 00000000..6ec5b9d2 --- /dev/null +++ b/tests/Unit/Database/Dialects/MysqlDialectTest.php @@ -0,0 +1,29 @@ +capabilities(); + + expect($capabilities->supportsLocks)->toBeTrue(); + expect($capabilities->supportsUpsert)->toBeTrue(); + expect($capabilities->supportsReturning)->toBeFalse(); + expect($capabilities->supportsJsonOperators)->toBeTrue(); + expect($capabilities->supportsAdvancedLocks)->toBeFalse(); + expect($capabilities->supportsInsertIgnore)->toBeTrue(); + expect($capabilities->supportsFulltextSearch)->toBeTrue(); + expect($capabilities->supportsGeneratedColumns)->toBeTrue(); +}); + +test('MysqlDialect supports method works correctly', function () { + $dialect = new MysqlDialect(); + $capabilities = $dialect->capabilities(); + + expect($capabilities->supports('locks'))->toBeTrue(); + expect($capabilities->supports('upsert'))->toBeTrue(); + expect($capabilities->supports('returning'))->toBeFalse(); + expect($capabilities->supports('advancedLocks'))->toBeFalse(); +}); diff --git a/tests/Unit/Database/Dialects/PostgresDialectTest.php b/tests/Unit/Database/Dialects/PostgresDialectTest.php new file mode 100644 index 00000000..0275d562 --- /dev/null +++ b/tests/Unit/Database/Dialects/PostgresDialectTest.php @@ -0,0 +1,29 @@ +capabilities(); + + expect($capabilities->supportsLocks)->toBeTrue(); + expect($capabilities->supportsUpsert)->toBeTrue(); + expect($capabilities->supportsReturning)->toBeTrue(); + expect($capabilities->supportsJsonOperators)->toBeTrue(); + expect($capabilities->supportsAdvancedLocks)->toBeTrue(); + expect($capabilities->supportsInsertIgnore)->toBeFalse(); + expect($capabilities->supportsFulltextSearch)->toBeTrue(); + expect($capabilities->supportsGeneratedColumns)->toBeTrue(); +}); + +test('PostgresDialect supports method works correctly', function () { + $dialect = new PostgresDialect(); + $capabilities = $dialect->capabilities(); + + expect($capabilities->supports('locks'))->toBeTrue(); + expect($capabilities->supports('returning'))->toBeTrue(); + expect($capabilities->supports('advancedLocks'))->toBeTrue(); + expect($capabilities->supports('insertIgnore'))->toBeFalse(); +}); diff --git a/tests/Unit/Database/Dialects/SqliteDialectTest.php b/tests/Unit/Database/Dialects/SqliteDialectTest.php new file mode 100644 index 00000000..600fa7aa --- /dev/null +++ b/tests/Unit/Database/Dialects/SqliteDialectTest.php @@ -0,0 +1,29 @@ +capabilities(); + + expect($capabilities->supportsLocks)->toBeFalse(); + expect($capabilities->supportsUpsert)->toBeTrue(); + expect($capabilities->supportsReturning)->toBeTrue(); + expect($capabilities->supportsJsonOperators)->toBeTrue(); + expect($capabilities->supportsAdvancedLocks)->toBeFalse(); + expect($capabilities->supportsInsertIgnore)->toBeTrue(); + expect($capabilities->supportsFulltextSearch)->toBeTrue(); + expect($capabilities->supportsGeneratedColumns)->toBeTrue(); +}); + +test('SqliteDialect supports method works correctly', function () { + $dialect = new SqliteDialect(); + $capabilities = $dialect->capabilities(); + + expect($capabilities->supports('locks'))->toBeFalse(); + expect($capabilities->supports('upsert'))->toBeTrue(); + expect($capabilities->supports('returning'))->toBeTrue(); + expect($capabilities->supports('advancedLocks'))->toBeFalse(); +}); From 9fbfeadf95fcff1f76c35e92501c476ceac75cfe Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Dec 2025 12:17:29 -0500 Subject: [PATCH 13/49] refactor(BuildsQuery): remove unused query building methods and constants --- src/Database/Concerns/Query/BuildsQuery.php | 141 -------------------- 1 file changed, 141 deletions(-) diff --git a/src/Database/Concerns/Query/BuildsQuery.php b/src/Database/Concerns/Query/BuildsQuery.php index bfa4ff56..6cc451e5 100644 --- a/src/Database/Concerns/Query/BuildsQuery.php +++ b/src/Database/Concerns/Query/BuildsQuery.php @@ -8,14 +8,12 @@ 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 @@ -156,143 +154,4 @@ protected function buildAst(): QueryAst return $ast; } - - protected function buildSelectQuery(): string - { - $this->columns = empty($this->columns) ? ['*'] : $this->columns; - - $query = [ - 'SELECT', - $this->prepareColumns($this->columns), - 'FROM', - $this->table, - $this->joins, - ]; - - if (! empty($this->clauses)) { - $query[] = 'WHERE'; - $query[] = $this->prepareClauses($this->clauses); - } - - if (isset($this->having)) { - $query[] = $this->having; - } - - if (isset($this->groupBy)) { - $query[] = Arr::implodeDeeply($this->groupBy); - } - - if (isset($this->orderBy)) { - $query[] = Arr::implodeDeeply($this->orderBy); - } - - if (isset($this->limit)) { - $query[] = Arr::implodeDeeply($this->limit); - } - - if (isset($this->offset)) { - $query[] = Arr::implodeDeeply($this->offset); - - } - - if (isset($this->lockType)) { - $query[] = $this->buildLock(); - } - - return Arr::implodeDeeply($query); - } - - protected function buildExistsQuery(): string - { - $query = ['SELECT']; - $query[] = $this->columns[0]; - - $subquery[] = "SELECT 1 FROM {$this->table}"; - - if (! empty($this->clauses)) { - $subquery[] = 'WHERE'; - $subquery[] = $this->prepareClauses($this->clauses); - } - - $query[] = '(' . Arr::implodeDeeply($subquery) . ') AS ' . Value::from('exists'); - - return Arr::implodeDeeply($query); - } - - private function buildInsertSentence(): string - { - $dml = [ - $this->ignore ? 'INSERT IGNORE INTO' : 'INSERT INTO', - $this->table, - '(' . Arr::implodeDeeply($this->columns, ', ') . ')', - ]; - - if (isset($this->rawStatement)) { - $dml[] = $this->rawStatement; - } else { - $dml[] = 'VALUES'; - - $placeholders = array_map(function (array $value): string { - return '(' . Arr::implodeDeeply($value, ', ') . ')'; - }, $this->values); - - $dml[] = Arr::implodeDeeply($placeholders, ', '); - - if (! empty($this->uniqueColumns)) { - $dml[] = 'ON DUPLICATE KEY UPDATE'; - - $columns = array_map(function (string $column): string { - return "{$column} = VALUES({$column})"; - }, $this->uniqueColumns); - - $dml[] = Arr::implodeDeeply($columns, ', '); - } - } - - return Arr::implodeDeeply($dml); - } - - private function buildUpdateSentence(): string - { - $dml = [ - 'UPDATE', - $this->table, - 'SET', - ]; - - $columns = []; - $arguments = []; - - foreach ($this->values as $column => $value) { - $arguments[] = $value; - - $columns[] = "{$column} = " . SQL::PLACEHOLDER->value; - } - - $this->arguments = [...$arguments, ...$this->arguments]; - - $dml[] = Arr::implodeDeeply($columns, ', '); - - if (! empty($this->clauses)) { - $dml[] = 'WHERE'; - $dml[] = $this->prepareClauses($this->clauses); - } - - return Arr::implodeDeeply($dml); - } - - private function buildDeleteSentence(): string - { - $dml = [ - 'DELETE FROM', - $this->table, - ]; - - if (! empty($this->clauses)) { - $dml[] = 'WHERE'; - $dml[] = $this->prepareClauses($this->clauses); - } - - return Arr::implodeDeeply($dml); - } } From f8f25e0d894373ee803dd0fd3ea37343542d36a2 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 29 Dec 2025 21:27:36 -0500 Subject: [PATCH 14/49] Refactor database dialect compilers to initialize where compilers - Added constructors to Mysql, Postgres, and Sqlite compilers to instantiate their respective WhereCompiler classes. - Implemented placeholder conversion for PostgreSQL insert statements. - Introduced new PostgresWhereCompiler and SqliteWhereCompiler classes to handle WHERE clause compilation. - Updated the QueryAst class to specify the type of where clauses. - Modified the Join class to utilize WhereClause objects for better type safety. - Enhanced the Having class to improve SQL generation. - Removed outdated unit tests for MySQL, PostgreSQL, and SQLite dialects. - Added new unit tests for PostgreSQL and SQLite insert statements to ensure correct SQL generation. --- src/Database/Clause.php | 39 ++-- src/Database/Clauses/BasicWhereClause.php | 80 ++++++++ src/Database/Clauses/BetweenWhereClause.php | 50 +++++ src/Database/Clauses/BooleanWhereClause.php | 41 +++++ src/Database/Clauses/ColumnWhereClause.php | 50 +++++ src/Database/Clauses/NullWhereClause.php | 41 +++++ src/Database/Clauses/RawWhereClause.php | 41 +++++ src/Database/Clauses/SubqueryWhereClause.php | 78 ++++++++ src/Database/Clauses/WhereClause.php | 38 ++++ .../Concerns/Query/HasWhereClause.php | 174 +++++++++++++----- .../Concerns/Query/HasWhereDateClause.php | 40 ++-- src/Database/Constants/ClauseType.php | 18 ++ ...gicalOperator.php => LogicalConnector.php} | 2 +- src/Database/Constants/SQL.php | 2 +- .../Contracts/ClauseCompiler.php | 3 +- .../{Dialects => }/Contracts/Dialect.php | 2 +- .../{Contracts => }/CompiledClause.php | 4 +- .../Dialects/Compilers/DeleteCompiler.php | 13 +- .../Dialects/Compilers/ExistsCompiler.php | 13 +- .../Dialects/Compilers/InsertCompiler.php | 4 +- .../Dialects/Compilers/SelectCompiler.php | 11 +- .../Dialects/Compilers/UpdateCompiler.php | 22 +-- .../Dialects/Compilers/WhereCompiler.php | 8 +- src/Database/Dialects/DialectFactory.php | 4 +- .../MySQL/Compilers/MySQLWhereCompiler.php | 123 +++++++++++++ .../MySQL/Compilers/MysqlDeleteCompiler.php | 5 +- .../MySQL/Compilers/MysqlExistsCompiler.php | 5 +- .../MySQL/Compilers/MysqlSelectCompiler.php | 5 + .../MySQL/Compilers/MysqlUpdateCompiler.php | 10 +- src/Database/Dialects/MySQL/MysqlDialect.php | 12 +- .../Compilers/PostgresDeleteCompiler.php | 5 + .../Compilers/PostgresExistsCompiler.php | 5 +- .../Compilers/PostgresInsertCompiler.php | 22 ++- .../Compilers/PostgresSelectCompiler.php | 5 + .../Compilers/PostgresUpdateCompiler.php | 10 + .../Compilers/PostgresWhereCompiler.php | 167 +++++++++++++++++ .../Dialects/PostgreSQL/PostgresDialect.php | 2 +- .../SQLite/Compilers/SqliteDeleteCompiler.php | 4 + .../SQLite/Compilers/SqliteExistsCompiler.php | 5 +- .../SQLite/Compilers/SqliteSelectCompiler.php | 5 + .../SQLite/Compilers/SqliteUpdateCompiler.php | 9 + .../SQLite/Compilers/SqliteWhereCompiler.php | 132 +++++++++++++ .../Dialects/SQLite/SqliteDialect.php | 2 +- src/Database/Having.php | 20 +- src/Database/Join.php | 52 +++++- src/Database/QueryAst.php | 3 +- src/Database/QueryBase.php | 2 +- .../Database/Dialects/MysqlDialectTest.php | 29 --- .../Database/Dialects/PostgresDialectTest.php | 29 --- .../Database/Dialects/SqliteDialectTest.php | 29 --- .../Postgres/InsertIntoStatementTest.php | 157 ++++++++++++++++ .../Sqlite/InsertIntoStatementTest.php | 156 ++++++++++++++++ 52 files changed, 1538 insertions(+), 250 deletions(-) create mode 100644 src/Database/Clauses/BasicWhereClause.php create mode 100644 src/Database/Clauses/BetweenWhereClause.php create mode 100644 src/Database/Clauses/BooleanWhereClause.php create mode 100644 src/Database/Clauses/ColumnWhereClause.php create mode 100644 src/Database/Clauses/NullWhereClause.php create mode 100644 src/Database/Clauses/RawWhereClause.php create mode 100644 src/Database/Clauses/SubqueryWhereClause.php create mode 100644 src/Database/Clauses/WhereClause.php create mode 100644 src/Database/Constants/ClauseType.php rename src/Database/Constants/{LogicalOperator.php => LogicalConnector.php} (82%) rename src/Database/{Dialects => }/Contracts/ClauseCompiler.php (65%) rename src/Database/{Dialects => }/Contracts/Dialect.php (85%) rename src/Database/Dialects/{Contracts => }/CompiledClause.php (78%) create mode 100644 src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php create mode 100644 src/Database/Dialects/PostgreSQL/Compilers/PostgresWhereCompiler.php create mode 100644 src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php delete mode 100644 tests/Unit/Database/Dialects/MysqlDialectTest.php delete mode 100644 tests/Unit/Database/Dialects/PostgresDialectTest.php delete mode 100644 tests/Unit/Database/Dialects/SqliteDialectTest.php create mode 100644 tests/Unit/Database/QueryGenerator/Postgres/InsertIntoStatementTest.php create mode 100644 tests/Unit/Database/QueryGenerator/Sqlite/InsertIntoStatementTest.php diff --git a/src/Database/Clause.php b/src/Database/Clause.php index 6e8e8551..1c8afee2 100644 --- a/src/Database/Clause.php +++ b/src/Database/Clause.php @@ -5,14 +5,17 @@ namespace Phenix\Database; use Closure; +use Phenix\Database\Clauses\BasicWhereClause; +use Phenix\Database\Clauses\SubqueryWhereClause; +use Phenix\Database\Clauses\WhereClause; use Phenix\Database\Concerns\Query\HasWhereClause; use Phenix\Database\Concerns\Query\PrepareColumns; -use Phenix\Database\Constants\LogicalOperator; +use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; -use Phenix\Database\Constants\SQL; use Phenix\Database\Contracts\Builder; use Phenix\Util\Arr; +use function count; use function is_array; abstract class Clause extends Grammar implements Builder @@ -20,6 +23,9 @@ abstract class Clause extends Grammar implements Builder use HasWhereClause; use PrepareColumns; + /** + * @var array + */ protected array $clauses; protected array $arguments; @@ -28,7 +34,7 @@ protected function resolveWhereMethod( string $column, Operator $operator, Closure|array|string|int $value, - LogicalOperator $logicalConnector = LogicalOperator::AND + LogicalConnector $logicalConnector = LogicalConnector::AND ): void { if ($value instanceof Closure) { $this->whereSubquery( @@ -47,7 +53,7 @@ protected function whereSubquery( Operator $comparisonOperator, string|null $column = null, Operator|null $operator = null, - LogicalOperator $logicalConnector = LogicalOperator::AND + LogicalConnector $logicalConnector = LogicalConnector::AND ): void { $builder = new Subquery($this->driver); $builder->select(['*']); @@ -56,9 +62,16 @@ protected function whereSubquery( [$dml, $arguments] = $builder->toSql(); - $value = $operator?->value . $dml; + $connector = count($this->clauses) === 0 ? null : $logicalConnector; - $this->pushClause(array_filter([$column, $comparisonOperator, $value]), $logicalConnector); + $this->clauses[] = new SubqueryWhereClause( + comparisonOperator: $comparisonOperator, + sql: trim($dml, '()'), + params: $arguments, + column: $column, + operator: $operator, + connector: $connector + ); $this->arguments = array_merge($this->arguments, $arguments); } @@ -67,21 +80,17 @@ protected function pushWhereWithArgs( string $column, Operator $operator, array|string|int $value, - LogicalOperator $logicalConnector = LogicalOperator::AND + LogicalConnector $logicalConnector = LogicalConnector::AND ): void { - $placeholders = is_array($value) - ? array_fill(0, count($value), SQL::PLACEHOLDER->value) - : SQL::PLACEHOLDER->value; - - $this->pushClause([$column, $operator, $placeholders], $logicalConnector); + $this->pushClause(new BasicWhereClause($column, $operator, $value, null, true), $logicalConnector); $this->arguments = array_merge($this->arguments, (array) $value); } - protected function pushClause(array $where, LogicalOperator $logicalConnector = LogicalOperator::AND): void + protected function pushClause(WhereClause $where, LogicalConnector $logicalConnector = LogicalConnector::AND): void { if (count($this->clauses) > 0) { - array_unshift($where, $logicalConnector); + $where->setConnector($logicalConnector); } $this->clauses[] = $where; @@ -93,7 +102,7 @@ protected function prepareClauses(array $clauses): array return array_map(function ($value) { return match (true) { $value instanceof Operator => $value->value, - $value instanceof LogicalOperator => $value->value, + $value instanceof LogicalConnector => $value->value, is_array($value) => '(' . Arr::implodeDeeply($value, ', ') . ')', default => $value, }; diff --git a/src/Database/Clauses/BasicWhereClause.php b/src/Database/Clauses/BasicWhereClause.php new file mode 100644 index 00000000..7f5a5da7 --- /dev/null +++ b/src/Database/Clauses/BasicWhereClause.php @@ -0,0 +1,80 @@ +column = $column; + $this->operator = $operator; + $this->value = $value; + $this->connector = $connector; + $this->usePlaceholder = $usePlaceholder; + } + + public function getColumn(): string + { + return $this->column; + } + + public function getOperator(): Operator + { + return $this->operator; + } + + public function getValue(): array|string|int + { + return $this->value; + } + + public function renderValue(): string + { + if ($this->usePlaceholder) { + // In WHERE context with parameterized queries, use placeholder + if (is_array($this->value)) { + return '(' . implode(', ', array_fill(0, count($this->value), '?')) . ')'; + } + + return '?'; + } + + // In JOIN ON context, render the value directly (typically a column name) + return (string) $this->value; + } + + public function getValueCount(): int + { + if (is_array($this->value)) { + return count($this->value); + } + + return 1; + } + + public function isInOperator(): bool + { + return $this->operator === Operator::IN || $this->operator === Operator::NOT_IN; + } +} diff --git a/src/Database/Clauses/BetweenWhereClause.php b/src/Database/Clauses/BetweenWhereClause.php new file mode 100644 index 00000000..e97b4647 --- /dev/null +++ b/src/Database/Clauses/BetweenWhereClause.php @@ -0,0 +1,50 @@ +column = $column; + $this->operator = $operator; + $this->values = $values; + $this->connector = $connector; + } + + public function getColumn(): string + { + return $this->column; + } + + public function getOperator(): Operator + { + return $this->operator; + } + + public function getValues(): array + { + return $this->values; + } + + public function renderValue(): string + { + // BETWEEN uses placeholders for both values + return '? AND ?'; + } +} diff --git a/src/Database/Clauses/BooleanWhereClause.php b/src/Database/Clauses/BooleanWhereClause.php new file mode 100644 index 00000000..d59c528c --- /dev/null +++ b/src/Database/Clauses/BooleanWhereClause.php @@ -0,0 +1,41 @@ +column = $column; + $this->operator = $operator; + $this->connector = $connector; + } + + public function getColumn(): string + { + return $this->column; + } + + public function getOperator(): Operator + { + return $this->operator; + } + + public function renderValue(): string + { + // Boolean clauses (IS TRUE/IS FALSE) have no value part + return ''; + } +} diff --git a/src/Database/Clauses/ColumnWhereClause.php b/src/Database/Clauses/ColumnWhereClause.php new file mode 100644 index 00000000..63eedfdb --- /dev/null +++ b/src/Database/Clauses/ColumnWhereClause.php @@ -0,0 +1,50 @@ +column = $column; + $this->operator = $operator; + $this->compareColumn = $compareColumn; + $this->connector = $connector; + } + + public function getColumn(): string + { + return $this->column; + } + + public function getOperator(): Operator + { + return $this->operator; + } + + public function getCompareColumn(): string + { + return $this->compareColumn; + } + + public function renderValue(): string + { + // Column comparisons use the column name directly, not a placeholder + return $this->compareColumn; + } +} diff --git a/src/Database/Clauses/NullWhereClause.php b/src/Database/Clauses/NullWhereClause.php new file mode 100644 index 00000000..76a182c5 --- /dev/null +++ b/src/Database/Clauses/NullWhereClause.php @@ -0,0 +1,41 @@ +column = $column; + $this->operator = $operator; + $this->connector = $connector; + } + + public function getColumn(): string + { + return $this->column; + } + + public function getOperator(): Operator + { + return $this->operator; + } + + public function renderValue(): string + { + // NULL clauses (IS NULL/IS NOT NULL) have no value part + return ''; + } +} diff --git a/src/Database/Clauses/RawWhereClause.php b/src/Database/Clauses/RawWhereClause.php new file mode 100644 index 00000000..95f46ab6 --- /dev/null +++ b/src/Database/Clauses/RawWhereClause.php @@ -0,0 +1,41 @@ +parts = $parts; + $this->connector = $connector; + } + + public function getColumn(): null + { + return null; + } + + public function getOperator(): null + { + return null; + } + + public function getParts(): array + { + return $this->parts; + } + + public function renderValue(): string + { + // Raw clauses handle their own rendering through getParts() + return ''; + } +} diff --git a/src/Database/Clauses/SubqueryWhereClause.php b/src/Database/Clauses/SubqueryWhereClause.php new file mode 100644 index 00000000..f66883bd --- /dev/null +++ b/src/Database/Clauses/SubqueryWhereClause.php @@ -0,0 +1,78 @@ + ANY (SELECT ...) + * - WHERE status IN (SELECT ...) + */ +class SubqueryWhereClause extends WhereClause +{ + protected Operator $comparisonOperator; + + protected string $sql; + + protected array $params; + + protected string|null $column; + + protected Operator|null $operator; + + public function __construct( + Operator $comparisonOperator, + string $sql, + array $params, + string|null $column = null, + Operator|null $operator = null, // ANY, ALL, SOME + LogicalConnector|null $connector = null + ) { + $this->comparisonOperator = $comparisonOperator; + $this->sql = $sql; + $this->params = $params; + $this->column = $column; + $this->operator = $operator; + $this->connector = $connector; + } + + public function getColumn(): string|null + { + return $this->column; + } + + public function getOperator(): Operator + { + return $this->comparisonOperator; + } + + public function getSubqueryOperator(): Operator|null + { + return $this->operator; + } + + public function getSql(): string + { + return $this->sql; + } + + public function getParams(): array + { + return $this->params; + } + + public function renderValue(): string + { + // Render subquery with optional operator (ANY, ALL, SOME) + return $this->operator?->value . $this->sql; + } +} diff --git a/src/Database/Clauses/WhereClause.php b/src/Database/Clauses/WhereClause.php new file mode 100644 index 00000000..16a19e9c --- /dev/null +++ b/src/Database/Clauses/WhereClause.php @@ -0,0 +1,38 @@ +connector = $connector; + } + + public function getConnector(): LogicalConnector|null + { + return $this->connector; + } + + public function isFirstClause(): bool + { + return $this->getConnector() === null; + } +} diff --git a/src/Database/Concerns/Query/HasWhereClause.php b/src/Database/Concerns/Query/HasWhereClause.php index 33428058..74df2dd3 100644 --- a/src/Database/Concerns/Query/HasWhereClause.php +++ b/src/Database/Concerns/Query/HasWhereClause.php @@ -5,9 +5,12 @@ namespace Phenix\Database\Concerns\Query; use Closure; -use Phenix\Database\Constants\LogicalOperator; +use Phenix\Database\Clauses\BetweenWhereClause; +use Phenix\Database\Clauses\BooleanWhereClause; +use Phenix\Database\Clauses\ColumnWhereClause; +use Phenix\Database\Clauses\NullWhereClause; +use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; -use Phenix\Database\Constants\SQL; trait HasWhereClause { @@ -26,7 +29,7 @@ public function whereEqual(string $column, Closure|string|int $value): static public function orWhereEqual(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::EQUAL, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::EQUAL, $value, LogicalConnector::OR); return $this; } @@ -40,7 +43,7 @@ public function whereDistinct(string $column, Closure|string|int $value): static public function orWhereDistinct(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::DISTINCT, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::DISTINCT, $value, LogicalConnector::OR); return $this; } @@ -54,7 +57,7 @@ public function whereGreaterThan(string $column, Closure|string|int $value): sta public function orWhereGreaterThan(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::GREATER_THAN, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::GREATER_THAN, $value, LogicalConnector::OR); return $this; } @@ -68,7 +71,7 @@ public function whereGreaterThanOrEqual(string $column, Closure|string|int $valu public function orWhereGreaterThanOrEqual(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -82,7 +85,7 @@ public function whereLessThan(string $column, Closure|string|int $value): static public function orWhereLessThan(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::LESS_THAN, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::LESS_THAN, $value, LogicalConnector::OR); return $this; } @@ -96,7 +99,7 @@ public function whereLessThanOrEqual(string $column, Closure|string|int $value): public function orWhereLessThanOrEqual(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -110,7 +113,7 @@ public function whereIn(string $column, Closure|array $value): static public function orWhereIn(string $column, Closure|array $value): static { - $this->resolveWhereMethod($column, Operator::IN, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::IN, $value, LogicalConnector::OR); return $this; } @@ -124,76 +127,135 @@ public function whereNotIn(string $column, Closure|array $value): static public function orWhereNotIn(string $column, Closure|array $value): static { - $this->resolveWhereMethod($column, Operator::NOT_IN, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::NOT_IN, $value, LogicalConnector::OR); return $this; } public function whereNull(string $column): static { - $this->pushClause([$column, Operator::IS_NULL]); + $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; + + $clause = new NullWhereClause( + column: $column, + operator: Operator::IS_NULL, + connector: $connector + ); + + $this->clauses[] = $clause; return $this; } public function orWhereNull(string $column): static { - $this->pushClause([$column, Operator::IS_NULL], LogicalOperator::OR); + $clause = new NullWhereClause( + column: $column, + operator: Operator::IS_NULL, + connector: LogicalConnector::OR + ); + + $this->clauses[] = $clause; return $this; } public function whereNotNull(string $column): static { - $this->pushClause([$column, Operator::IS_NOT_NULL]); + $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; + + $clause = new NullWhereClause( + column: $column, + operator: Operator::IS_NOT_NULL, + connector: $connector + ); + + $this->clauses[] = $clause; return $this; } public function orWhereNotNull(string $column): static { - $this->pushClause([$column, Operator::IS_NOT_NULL], LogicalOperator::OR); + $clause = new NullWhereClause( + column: $column, + operator: Operator::IS_NOT_NULL, + connector: LogicalConnector::OR + ); + + $this->clauses[] = $clause; return $this; } public function whereTrue(string $column): static { - $this->pushClause([$column, Operator::IS_TRUE]); + $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; + + $clause = new BooleanWhereClause( + column: $column, + operator: Operator::IS_TRUE, + connector: $connector + ); + + $this->clauses[] = $clause; return $this; } public function orWhereTrue(string $column): static { - $this->pushClause([$column, Operator::IS_TRUE], LogicalOperator::OR); + $clause = new BooleanWhereClause( + column: $column, + operator: Operator::IS_TRUE, + connector: LogicalConnector::OR + ); + + $this->clauses[] = $clause; return $this; } public function whereFalse(string $column): static { - $this->pushClause([$column, Operator::IS_FALSE]); + $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; + + $clause = new BooleanWhereClause( + column: $column, + operator: Operator::IS_FALSE, + connector: $connector + ); + + $this->clauses[] = $clause; return $this; } public function orWhereFalse(string $column): static { - $this->pushClause([$column, Operator::IS_FALSE], LogicalOperator::OR); + $clause = new BooleanWhereClause( + column: $column, + operator: Operator::IS_FALSE, + connector: LogicalConnector::OR + ); + + $this->clauses[] = $clause; return $this; } public function whereBetween(string $column, array $values): static { - $this->pushClause([ - $column, - Operator::BETWEEN, - SQL::PLACEHOLDER->value, - LogicalOperator::AND, - SQL::PLACEHOLDER->value, - ]); + $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; + + $clause = new BetweenWhereClause( + column: $column, + operator: Operator::BETWEEN, + values: $values, + connector: $connector + ); + + $this->clauses[] = $clause; $this->arguments = array_merge($this->arguments, (array) $values); @@ -202,13 +264,14 @@ public function whereBetween(string $column, array $values): static public function orWhereBetween(string $column, array $values): static { - $this->pushClause([ - $column, - Operator::BETWEEN, - SQL::PLACEHOLDER->value, - LogicalOperator::AND, - SQL::PLACEHOLDER->value, - ], LogicalOperator::OR); + $clause = new BetweenWhereClause( + column: $column, + operator: Operator::BETWEEN, + values: $values, + connector: LogicalConnector::OR + ); + + $this->clauses[] = $clause; $this->arguments = array_merge($this->arguments, (array) $values); @@ -217,13 +280,16 @@ public function orWhereBetween(string $column, array $values): static public function whereNotBetween(string $column, array $values): static { - $this->pushClause([ - $column, - Operator::NOT_BETWEEN, - SQL::PLACEHOLDER->value, - LogicalOperator::AND, - SQL::PLACEHOLDER->value, - ]); + $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; + + $clause = new BetweenWhereClause( + column: $column, + operator: Operator::NOT_BETWEEN, + values: $values, + connector: $connector + ); + + $this->clauses[] = $clause; $this->arguments = array_merge($this->arguments, (array) $values); @@ -232,13 +298,14 @@ public function whereNotBetween(string $column, array $values): static public function orWhereNotBetween(string $column, array $values): static { - $this->pushClause([ - $column, - Operator::NOT_BETWEEN, - SQL::PLACEHOLDER->value, - LogicalOperator::AND, - SQL::PLACEHOLDER->value, - ], LogicalOperator::OR); + $clause = new BetweenWhereClause( + column: $column, + operator: Operator::NOT_BETWEEN, + values: $values, + connector: LogicalConnector::OR + ); + + $this->clauses[] = $clause; $this->arguments = array_merge($this->arguments, (array) $values); @@ -257,7 +324,7 @@ public function orWhereExists(Closure $subquery): static $this->whereSubquery( subquery: $subquery, comparisonOperator: Operator::EXISTS, - logicalConnector: LogicalOperator::OR + logicalConnector: LogicalConnector::OR ); return $this; @@ -275,7 +342,7 @@ public function orWhereNotExists(Closure $subquery): static $this->whereSubquery( subquery: $subquery, comparisonOperator: Operator::NOT_EXISTS, - logicalConnector: LogicalOperator::OR + logicalConnector: LogicalConnector::OR ); return $this; @@ -283,7 +350,16 @@ public function orWhereNotExists(Closure $subquery): static public function whereColumn(string $localColumn, string $foreignColumn): static { - $this->pushClause([$localColumn, Operator::EQUAL, $foreignColumn]); + $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; + + $clause = new ColumnWhereClause( + column: $localColumn, + operator: Operator::EQUAL, + compareColumn: $foreignColumn, + connector: $connector + ); + + $this->clauses[] = $clause; return $this; } diff --git a/src/Database/Concerns/Query/HasWhereDateClause.php b/src/Database/Concerns/Query/HasWhereDateClause.php index c6d12eb3..60ecf2cf 100644 --- a/src/Database/Concerns/Query/HasWhereDateClause.php +++ b/src/Database/Concerns/Query/HasWhereDateClause.php @@ -5,7 +5,7 @@ namespace Phenix\Database\Concerns\Query; use Carbon\CarbonInterface; -use Phenix\Database\Constants\LogicalOperator; +use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; use Phenix\Database\Functions; @@ -20,7 +20,7 @@ public function whereDateEqual(string $column, CarbonInterface|string $value): s public function orWhereDateEqual(string $column, CarbonInterface|string $value): static { - $this->pushDateClause($column, Operator::EQUAL, $value, LogicalOperator::OR); + $this->pushDateClause($column, Operator::EQUAL, $value, LogicalConnector::OR); return $this; } @@ -34,7 +34,7 @@ public function whereDateGreaterThan(string $column, CarbonInterface|string $val public function orWhereDateGreaterThan(string $column, CarbonInterface|string $value): static { - $this->pushDateClause($column, Operator::GREATER_THAN, $value, LogicalOperator::OR); + $this->pushDateClause($column, Operator::GREATER_THAN, $value, LogicalConnector::OR); return $this; } @@ -48,7 +48,7 @@ public function whereDateGreaterThanOrEqual(string $column, CarbonInterface|stri public function orWhereDateGreaterThanOrEqual(string $column, CarbonInterface|string $value): static { - $this->pushDateClause($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->pushDateClause($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -62,7 +62,7 @@ public function whereDateLessThan(string $column, CarbonInterface|string $value) public function orWhereDateLessThan(string $column, CarbonInterface|string $value): static { - $this->pushDateClause($column, Operator::LESS_THAN, $value, LogicalOperator::OR); + $this->pushDateClause($column, Operator::LESS_THAN, $value, LogicalConnector::OR); return $this; } @@ -76,7 +76,7 @@ public function whereDateLessThanOrEqual(string $column, CarbonInterface|string public function orWhereDateLessThanOrEqual(string $column, CarbonInterface|string $value): static { - $this->pushDateClause($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->pushDateClause($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -90,7 +90,7 @@ public function whereMonthEqual(string $column, CarbonInterface|int $value): sta public function orWhereMonthEqual(string $column, CarbonInterface|int $value): static { - $this->pushMonthClause($column, Operator::EQUAL, $value, LogicalOperator::OR); + $this->pushMonthClause($column, Operator::EQUAL, $value, LogicalConnector::OR); return $this; } @@ -104,7 +104,7 @@ public function whereMonthGreaterThan(string $column, CarbonInterface|int $value public function orWhereMonthGreaterThan(string $column, CarbonInterface|int $value): static { - $this->pushMonthClause($column, Operator::GREATER_THAN, $value, LogicalOperator::OR); + $this->pushMonthClause($column, Operator::GREATER_THAN, $value, LogicalConnector::OR); return $this; } @@ -118,7 +118,7 @@ public function whereMonthGreaterThanOrEqual(string $column, CarbonInterface|int public function orWhereMonthGreaterThanOrEqual(string $column, CarbonInterface|int $value): static { - $this->pushMonthClause($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->pushMonthClause($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -132,7 +132,7 @@ public function whereMonthLessThan(string $column, CarbonInterface|int $value): public function orWhereMonthLessThan(string $column, CarbonInterface|int $value): static { - $this->pushMonthClause($column, Operator::LESS_THAN, $value, LogicalOperator::OR); + $this->pushMonthClause($column, Operator::LESS_THAN, $value, LogicalConnector::OR); return $this; } @@ -146,7 +146,7 @@ public function whereMonthLessThanOrEqual(string $column, CarbonInterface|int $v public function orWhereMonthLessThanOrEqual(string $column, CarbonInterface|int $value): static { - $this->pushMonthClause($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->pushMonthClause($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -160,7 +160,7 @@ public function whereYearEqual(string $column, CarbonInterface|int $value): stat public function orWhereYearEqual(string $column, CarbonInterface|int $value): static { - $this->pushYearClause($column, Operator::EQUAL, $value, LogicalOperator::OR); + $this->pushYearClause($column, Operator::EQUAL, $value, LogicalConnector::OR); return $this; } @@ -174,7 +174,7 @@ public function whereYearGreaterThan(string $column, CarbonInterface|int $value) public function orWhereYearGreaterThan(string $column, CarbonInterface|int $value): static { - $this->pushYearClause($column, Operator::GREATER_THAN, $value, LogicalOperator::OR); + $this->pushYearClause($column, Operator::GREATER_THAN, $value, LogicalConnector::OR); return $this; } @@ -188,7 +188,7 @@ public function whereYearGreaterThanOrEqual(string $column, CarbonInterface|int public function orWhereYearGreaterThanOrEqual(string $column, CarbonInterface|int $value): static { - $this->pushYearClause($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->pushYearClause($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -202,7 +202,7 @@ public function whereYearLessThan(string $column, CarbonInterface|int $value): s public function orWhereYearLessThan(string $column, CarbonInterface|int $value): static { - $this->pushYearClause($column, Operator::LESS_THAN, $value, LogicalOperator::OR); + $this->pushYearClause($column, Operator::LESS_THAN, $value, LogicalConnector::OR); return $this; } @@ -216,7 +216,7 @@ public function whereYearLessThanOrEqual(string $column, CarbonInterface|int $va public function orWhereYearLessThanOrEqual(string $column, CarbonInterface|int $value): static { - $this->pushYearClause($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->pushYearClause($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -225,7 +225,7 @@ protected function pushDateClause( string $column, Operator $operator, CarbonInterface|string $value, - LogicalOperator $logicalConnector = LogicalOperator::AND + LogicalConnector $logicalConnector = LogicalConnector::AND ): void { if ($value instanceof CarbonInterface) { $value = $value->format('Y-m-d'); @@ -243,7 +243,7 @@ protected function pushMonthClause( string $column, Operator $operator, CarbonInterface|int $value, - LogicalOperator $logicalConnector = LogicalOperator::AND + LogicalConnector $logicalConnector = LogicalConnector::AND ): void { if ($value instanceof CarbonInterface) { $value = (int) $value->format('m'); @@ -261,7 +261,7 @@ protected function pushYearClause( string $column, Operator $operator, CarbonInterface|int $value, - LogicalOperator $logicalConnector = LogicalOperator::AND + LogicalConnector $logicalConnector = LogicalConnector::AND ): void { if ($value instanceof CarbonInterface) { $value = (int) $value->format('Y'); @@ -279,7 +279,7 @@ protected function pushTimeClause( Functions $function, Operator $operator, CarbonInterface|string|int $value, - LogicalOperator $logicalConnector = LogicalOperator::AND + LogicalConnector $logicalConnector = LogicalConnector::AND ): void { $this->pushWhereWithArgs((string) $function, $operator, $value, $logicalConnector); } diff --git a/src/Database/Constants/ClauseType.php b/src/Database/Constants/ClauseType.php new file mode 100644 index 00000000..5901dba8 --- /dev/null +++ b/src/Database/Constants/ClauseType.php @@ -0,0 +1,18 @@ +whereCompiler = new WhereCompiler(); - } + protected $whereCompiler; public function compile(QueryAst $ast): CompiledClause { diff --git a/src/Database/Dialects/Compilers/ExistsCompiler.php b/src/Database/Dialects/Compilers/ExistsCompiler.php index 4360d540..312c2bbb 100644 --- a/src/Database/Dialects/Compilers/ExistsCompiler.php +++ b/src/Database/Dialects/Compilers/ExistsCompiler.php @@ -4,20 +4,15 @@ namespace Phenix\Database\Dialects\Compilers; -use Phenix\Database\Dialects\Contracts\ClauseCompiler; -use Phenix\Database\Dialects\Contracts\CompiledClause; +use Phenix\Database\Contracts\ClauseCompiler; +use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\QueryAst; use Phenix\Database\Value; use Phenix\Util\Arr; -class ExistsCompiler implements ClauseCompiler +abstract class ExistsCompiler implements ClauseCompiler { - private WhereCompiler $whereCompiler; - - public function __construct() - { - $this->whereCompiler = new WhereCompiler(); - } + protected $whereCompiler; public function compile(QueryAst $ast): CompiledClause { diff --git a/src/Database/Dialects/Compilers/InsertCompiler.php b/src/Database/Dialects/Compilers/InsertCompiler.php index a22bdc95..45a3f57c 100644 --- a/src/Database/Dialects/Compilers/InsertCompiler.php +++ b/src/Database/Dialects/Compilers/InsertCompiler.php @@ -4,8 +4,8 @@ namespace Phenix\Database\Dialects\Compilers; -use Phenix\Database\Dialects\Contracts\ClauseCompiler; -use Phenix\Database\Dialects\Contracts\CompiledClause; +use Phenix\Database\Contracts\ClauseCompiler; +use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\QueryAst; use Phenix\Util\Arr; diff --git a/src/Database/Dialects/Compilers/SelectCompiler.php b/src/Database/Dialects/Compilers/SelectCompiler.php index be623ac6..ff18f24a 100644 --- a/src/Database/Dialects/Compilers/SelectCompiler.php +++ b/src/Database/Dialects/Compilers/SelectCompiler.php @@ -5,8 +5,8 @@ namespace Phenix\Database\Dialects\Compilers; use Phenix\Database\Alias; -use Phenix\Database\Dialects\Contracts\ClauseCompiler; -use Phenix\Database\Dialects\Contracts\CompiledClause; +use Phenix\Database\Contracts\ClauseCompiler; +use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Exceptions\QueryErrorException; use Phenix\Database\Functions; use Phenix\Database\QueryAst; @@ -18,12 +18,7 @@ abstract class SelectCompiler implements ClauseCompiler { - protected WhereCompiler $whereCompiler; - - public function __construct() - { - $this->whereCompiler = new WhereCompiler(); - } + protected $whereCompiler; public function compile(QueryAst $ast): CompiledClause { diff --git a/src/Database/Dialects/Compilers/UpdateCompiler.php b/src/Database/Dialects/Compilers/UpdateCompiler.php index 1a40bcd2..75570a3e 100644 --- a/src/Database/Dialects/Compilers/UpdateCompiler.php +++ b/src/Database/Dialects/Compilers/UpdateCompiler.php @@ -4,20 +4,14 @@ namespace Phenix\Database\Dialects\Compilers; -use Phenix\Database\Constants\SQL; -use Phenix\Database\Dialects\Contracts\ClauseCompiler; -use Phenix\Database\Dialects\Contracts\CompiledClause; +use Phenix\Database\Contracts\ClauseCompiler; +use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\QueryAst; use Phenix\Util\Arr; -class UpdateCompiler implements ClauseCompiler +abstract class UpdateCompiler implements ClauseCompiler { - private WhereCompiler $whereCompiler; - - public function __construct() - { - $this->whereCompiler = new WhereCompiler(); - } + protected $whereCompiler; public function compile(QueryAst $ast): CompiledClause { @@ -33,7 +27,7 @@ public function compile(QueryAst $ast): CompiledClause foreach ($ast->values as $column => $value) { $params[] = $value; - $columns[] = "{$column} = " . SQL::PLACEHOLDER->value; + $columns[] = $this->compileSetClause($column, count($params)); } $parts[] = 'SET'; @@ -52,4 +46,10 @@ public function compile(QueryAst $ast): CompiledClause return new CompiledClause($sql, $params); } + + /** + * Compile the SET clause for a column assignment + * This is dialect-specific for placeholder syntax + */ + abstract protected function compileSetClause(string $column, int $paramIndex): string; } diff --git a/src/Database/Dialects/Compilers/WhereCompiler.php b/src/Database/Dialects/Compilers/WhereCompiler.php index 280a6f9d..b05722cd 100644 --- a/src/Database/Dialects/Compilers/WhereCompiler.php +++ b/src/Database/Dialects/Compilers/WhereCompiler.php @@ -4,12 +4,12 @@ namespace Phenix\Database\Dialects\Compilers; -use Phenix\Database\Constants\LogicalOperator; +use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; -use Phenix\Database\Dialects\Contracts\CompiledClause; +use Phenix\Database\Dialects\CompiledClause; use Phenix\Util\Arr; -final class WhereCompiler +class WhereCompiler { /** * @param array> $wheres @@ -38,7 +38,7 @@ private function prepareClauses(array $clauses): array return array_map(function ($value): mixed { return match (true) { $value instanceof Operator => $value->value, - $value instanceof LogicalOperator => $value->value, + $value instanceof LogicalConnector => $value->value, is_array($value) => '(' . Arr::implodeDeeply($value, ', ') . ')', default => $value, }; diff --git a/src/Database/Dialects/DialectFactory.php b/src/Database/Dialects/DialectFactory.php index 193a442c..252ea7f0 100644 --- a/src/Database/Dialects/DialectFactory.php +++ b/src/Database/Dialects/DialectFactory.php @@ -5,12 +5,12 @@ namespace Phenix\Database\Dialects; use Phenix\Database\Constants\Driver; -use Phenix\Database\Dialects\Contracts\Dialect; +use Phenix\Database\Contracts\Dialect; use Phenix\Database\Dialects\MySQL\MysqlDialect; use Phenix\Database\Dialects\PostgreSQL\PostgresDialect; use Phenix\Database\Dialects\SQLite\SqliteDialect; -final class DialectFactory +class DialectFactory { /** * @var array diff --git a/src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php b/src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php new file mode 100644 index 00000000..7d362aec --- /dev/null +++ b/src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php @@ -0,0 +1,123 @@ + $wheres + * @return CompiledClause + */ + public function compile(array $wheres): CompiledClause + { + if (empty($wheres)) { + return new CompiledClause('', []); + } + + $sql = []; + + foreach ($wheres as $index => $where) { + // Add logical connector if not the first clause + if ($index > 0 && $where->getConnector() !== null) { + $sql[] = $where->getConnector()->value; + } + + $sql[] = $this->compileClause($where); + } + + return new CompiledClause(implode(' ', $sql), []); + } + + private function compileClause(WhereClause $clause): string + { + return match (true) { + $clause instanceof BasicWhereClause => $this->compileBasicClause($clause), + $clause instanceof NullWhereClause => $this->compileNullClause($clause), + $clause instanceof BooleanWhereClause => $this->compileBooleanClause($clause), + $clause instanceof BetweenWhereClause => $this->compileBetweenClause($clause), + $clause instanceof SubqueryWhereClause => $this->compileSubqueryClause($clause), + $clause instanceof ColumnWhereClause => $this->compileColumnClause($clause), + $clause instanceof RawWhereClause => $this->compileRawClause($clause), + default => '', + }; + } + + private function compileBasicClause(BasicWhereClause $clause): string + { + if ($clause->isInOperator()) { + $placeholders = str_repeat(SQL::STD_PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::STD_PLACEHOLDER->value; + + return "{$clause->getColumn()} {$clause->getOperator()->value} ({$placeholders})"; + } + + return "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::STD_PLACEHOLDER->value; + } + + private function compileNullClause(NullWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value}"; + } + + private function compileBooleanClause(BooleanWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value}"; + } + + private function compileBetweenClause(BetweenWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value} ? AND ?"; + } + + private function compileSubqueryClause(SubqueryWhereClause $clause): string + { + $parts = []; + + if ($clause->getColumn() !== null) { + $parts[] = $clause->getColumn(); + } + + $parts[] = $clause->getOperator()->value; + $parts[] = $clause->getSubqueryOperator() !== null + ? "{$clause->getSubqueryOperator()->value}({$clause->getSql()})" + : "({$clause->getSql()})"; + + return implode(' ', $parts); + } + + private function compileColumnClause(ColumnWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->getCompareColumn()}"; + } + + private function compileRawClause(RawWhereClause $clause): string + { + // For backwards compatibility with any remaining raw clauses + $parts = array_map(function ($value) { + return match (true) { + $value instanceof Operator => $value->value, + $value instanceof LogicalConnector => $value->value, + is_array($value) => '(' . implode(', ', $value) . ')', + default => $value, + }; + }, $clause->getParts()); + + return implode(' ', $parts); + } +} diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlDeleteCompiler.php b/src/Database/Dialects/MySQL/Compilers/MysqlDeleteCompiler.php index 1940319d..ffda0afb 100644 --- a/src/Database/Dialects/MySQL/Compilers/MysqlDeleteCompiler.php +++ b/src/Database/Dialects/MySQL/Compilers/MysqlDeleteCompiler.php @@ -8,5 +8,8 @@ class MysqlDeleteCompiler extends DeleteCompiler { - // + public function __construct() + { + $this->whereCompiler = new MysqlWhereCompiler(); + } } diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlExistsCompiler.php b/src/Database/Dialects/MySQL/Compilers/MysqlExistsCompiler.php index 11717fdf..871decee 100644 --- a/src/Database/Dialects/MySQL/Compilers/MysqlExistsCompiler.php +++ b/src/Database/Dialects/MySQL/Compilers/MysqlExistsCompiler.php @@ -8,5 +8,8 @@ final class MysqlExistsCompiler extends ExistsCompiler { - // + public function __construct() + { + $this->whereCompiler = new MysqlWhereCompiler(); + } } diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlSelectCompiler.php b/src/Database/Dialects/MySQL/Compilers/MysqlSelectCompiler.php index d54f37d9..4bf4a254 100644 --- a/src/Database/Dialects/MySQL/Compilers/MysqlSelectCompiler.php +++ b/src/Database/Dialects/MySQL/Compilers/MysqlSelectCompiler.php @@ -10,6 +10,11 @@ final class MysqlSelectCompiler extends SelectCompiler { + public function __construct() + { + $this->whereCompiler = new MysqlWhereCompiler(); + } + protected function compileLock(QueryAst $ast): string { if ($ast->lock === null) { diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlUpdateCompiler.php b/src/Database/Dialects/MySQL/Compilers/MysqlUpdateCompiler.php index 20c665f1..19f62841 100644 --- a/src/Database/Dialects/MySQL/Compilers/MysqlUpdateCompiler.php +++ b/src/Database/Dialects/MySQL/Compilers/MysqlUpdateCompiler.php @@ -8,5 +8,13 @@ class MysqlUpdateCompiler extends UpdateCompiler { - // + public function __construct() + { + $this->whereCompiler = new MysqlWhereCompiler(); + } + + protected function compileSetClause(string $column, int $paramIndex): string + { + return "{$column} = ?"; + } } diff --git a/src/Database/Dialects/MySQL/MysqlDialect.php b/src/Database/Dialects/MySQL/MysqlDialect.php index 45e296cd..c00c9542 100644 --- a/src/Database/Dialects/MySQL/MysqlDialect.php +++ b/src/Database/Dialects/MySQL/MysqlDialect.php @@ -5,7 +5,7 @@ namespace Phenix\Database\Dialects\MySQL; use Phenix\Database\Constants\Action; -use Phenix\Database\Dialects\Contracts\Dialect; +use Phenix\Database\Contracts\Dialect; use Phenix\Database\Dialects\MySQL\Compilers\MysqlDeleteCompiler; use Phenix\Database\Dialects\MySQL\Compilers\MysqlExistsCompiler; use Phenix\Database\Dialects\MySQL\Compilers\MysqlInsertCompiler; @@ -15,15 +15,15 @@ final class MysqlDialect implements Dialect { - private MysqlSelectCompiler $selectCompiler; + protected MysqlSelectCompiler $selectCompiler; - private MysqlInsertCompiler $insertCompiler; + protected MysqlInsertCompiler $insertCompiler; - private MysqlUpdateCompiler $updateCompiler; + protected MysqlUpdateCompiler $updateCompiler; - private MysqlDeleteCompiler $deleteCompiler; + protected MysqlDeleteCompiler $deleteCompiler; - private MysqlExistsCompiler $existsCompiler; + protected MysqlExistsCompiler $existsCompiler; public function __construct() { diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php index 14161130..0a8f9410 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php @@ -8,5 +8,10 @@ class PostgresDeleteCompiler extends DeleteCompiler { + public function __construct() + { + $this->whereCompiler = new PostgresWhereCompiler(); + } + // TODO: Support RETURNING clause } diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresExistsCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresExistsCompiler.php index 03c527c9..7a343174 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresExistsCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresExistsCompiler.php @@ -8,5 +8,8 @@ final class PostgresExistsCompiler extends ExistsCompiler { - // + public function __construct() + { + $this->whereCompiler = new PostgresWhereCompiler(); + } } diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php index 48713dfd..ab9cf8a8 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php @@ -4,8 +4,8 @@ namespace Phenix\Database\Dialects\PostgreSQL\Compilers; +use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\InsertCompiler; -use Phenix\Database\Dialects\Contracts\CompiledClause; use Phenix\Database\QueryAst; use Phenix\Util\Arr; @@ -16,6 +16,18 @@ */ class PostgresInsertCompiler extends InsertCompiler { + /** + * Convert ? placeholders to $n format for PostgreSQL + */ + protected function convertPlaceholders(string $sql): string + { + $index = 1; + + return preg_replace_callback('/\?/', function () use (&$index) { + return '$' . ($index++); + }, $sql); + } + protected function compileInsertIgnore(): string { return 'INSERT INTO'; @@ -59,10 +71,16 @@ public function compile(QueryAst $ast): CompiledClause $parts[] = 'ON CONFLICT DO NOTHING'; $sql = Arr::implodeDeeply($parts); + $sql = $this->convertPlaceholders($sql); return new CompiledClause($sql, $ast->params); } - return parent::compile($ast); + $result = parent::compile($ast); + + return new CompiledClause( + $this->convertPlaceholders($result->sql), + $result->params + ); } } diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php index f9495347..2bc00a25 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php @@ -10,6 +10,11 @@ final class PostgresSelectCompiler extends SelectCompiler { + public function __construct() + { + $this->whereCompiler = new PostgresWhereCompiler(); + } + protected function compileLock(QueryAst $ast): string { if ($ast->lock === null) { diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php index df52eb67..e7633bfd 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php @@ -8,5 +8,15 @@ class PostgresUpdateCompiler extends UpdateCompiler { + public function __construct() + { + $this->whereCompiler = new PostgresWhereCompiler(); + } + + protected function compileSetClause(string $column, int $paramIndex): string + { + return "{$column} = $" . $paramIndex; + } + // TODO: Support RETURNING clause } diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresWhereCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresWhereCompiler.php new file mode 100644 index 00000000..705138a6 --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresWhereCompiler.php @@ -0,0 +1,167 @@ + $wheres + * @return CompiledClause + */ + public function compile(array $wheres): CompiledClause + { + if (empty($wheres)) { + return new CompiledClause('', []); + } + + $this->paramIndex = 0; + $sql = []; + + foreach ($wheres as $index => $where) { + // Add logical connector if not the first clause + if ($index > 0 && $where->getConnector() !== null) { + $sql[] = $where->getConnector()->value; + } + + $sql[] = $this->compileClause($where); + } + + return new CompiledClause(implode(' ', $sql), []); + } + + private function compileClause(WhereClause $clause): string + { + return match (true) { + $clause instanceof BasicWhereClause => $this->compileBasicClause($clause), + $clause instanceof NullWhereClause => $this->compileNullClause($clause), + $clause instanceof BooleanWhereClause => $this->compileBooleanClause($clause), + $clause instanceof BetweenWhereClause => $this->compileBetweenClause($clause), + $clause instanceof SubqueryWhereClause => $this->compileSubqueryClause($clause), + $clause instanceof ColumnWhereClause => $this->compileColumnClause($clause), + $clause instanceof RawWhereClause => $this->compileRawClause($clause), + default => '', + }; + } + + private function compileBasicClause(BasicWhereClause $clause): string + { + $column = $clause->getColumn(); + $operator = $clause->getOperator(); + + if ($clause->isInOperator()) { + $placeholders = $this->generatePlaceholders($clause->getValueCount()); + + return "{$column} {$operator->value} ({$placeholders})"; + } + + $placeholder = $this->nextPlaceholder(); + + return "{$column} {$operator->value} {$placeholder}"; + } + + private function compileNullClause(NullWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value}"; + } + + private function compileBooleanClause(BooleanWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value}"; + } + + private function compileBetweenClause(BetweenWhereClause $clause): string + { + $column = $clause->getColumn(); + $operator = $clause->getOperator(); + $p1 = $this->nextPlaceholder(); + $p2 = $this->nextPlaceholder(); + + return "{$column} {$operator->value} {$p1} AND {$p2}"; + } + + private function compileSubqueryClause(SubqueryWhereClause $clause): string + { + $parts = []; + + if ($clause->getColumn() !== null) { + $parts[] = $clause->getColumn(); + } + + $parts[] = $clause->getOperator()->value; + + if ($clause->getSubqueryOperator() !== null) { + // For ANY/ALL/SOME, no space between operator and subquery + $parts[] = $clause->getSubqueryOperator()->value . '(' . $clause->getSql() . ')'; + } else { + // For regular subqueries, add space + $parts[] = '(' . $clause->getSql() . ')'; + } + + // Update param index based on subquery params + $this->paramIndex += count($clause->getParams()); + + return implode(' ', $parts); + } + + private function compileColumnClause(ColumnWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->getCompareColumn()}"; + } + + private function compileRawClause(RawWhereClause $clause): string + { + // For backwards compatibility with any remaining raw clauses + $parts = array_map(function ($value) { + return match (true) { + $value instanceof Operator => $value->value, + $value instanceof LogicalConnector => $value->value, + is_array($value) => '(' . implode(', ', $value) . ')', + default => $value, + }; + }, $clause->getParts()); + + return implode(' ', $parts); + } + + private function nextPlaceholder(): string + { + return '$' . (++$this->paramIndex); + } + + private function generatePlaceholders(int $count): string + { + $placeholders = []; + for ($i = 0; $i < $count; $i++) { + $placeholders[] = $this->nextPlaceholder(); + } + + return implode(', ', $placeholders); + } + + /** + * Set the starting parameter index (used when WHERE is not the first clause with params) + */ + public function setStartingParamIndex(int $index): void + { + $this->paramIndex = $index; + } +} diff --git a/src/Database/Dialects/PostgreSQL/PostgresDialect.php b/src/Database/Dialects/PostgreSQL/PostgresDialect.php index bab2f2a2..b6bc4b20 100644 --- a/src/Database/Dialects/PostgreSQL/PostgresDialect.php +++ b/src/Database/Dialects/PostgreSQL/PostgresDialect.php @@ -5,7 +5,7 @@ namespace Phenix\Database\Dialects\PostgreSQL; use Phenix\Database\Constants\Action; -use Phenix\Database\Dialects\Contracts\Dialect; +use Phenix\Database\Contracts\Dialect; use Phenix\Database\Dialects\PostgreSQL\Compilers\PostgresDeleteCompiler; use Phenix\Database\Dialects\PostgreSQL\Compilers\PostgresExistsCompiler; use Phenix\Database\Dialects\PostgreSQL\Compilers\PostgresInsertCompiler; diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php b/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php index 5dc363db..1441cb30 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php @@ -8,5 +8,9 @@ class SqliteDeleteCompiler extends DeleteCompiler { + public function __construct() + { + $this->whereCompiler = new SqliteWhereCompiler(); + } // TODO: Support RETURNING clause (SQLite 3.35.0+) } diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteExistsCompiler.php b/src/Database/Dialects/SQLite/Compilers/SqliteExistsCompiler.php index 230462a9..ba43c691 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteExistsCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/SqliteExistsCompiler.php @@ -8,5 +8,8 @@ final class SqliteExistsCompiler extends ExistsCompiler { - // + public function __construct() + { + $this->whereCompiler = new SqliteWhereCompiler(); + } } diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteSelectCompiler.php b/src/Database/Dialects/SQLite/Compilers/SqliteSelectCompiler.php index f5bc1729..004183ec 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteSelectCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/SqliteSelectCompiler.php @@ -9,6 +9,11 @@ final class SqliteSelectCompiler extends SelectCompiler { + public function __construct() + { + $this->whereCompiler = new SqliteWhereCompiler(); + } + protected function compileLock(QueryAst $ast): string { // SQLite doesn't support row-level locks diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteUpdateCompiler.php b/src/Database/Dialects/SQLite/Compilers/SqliteUpdateCompiler.php index a0bbf9a0..bb1512a2 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteUpdateCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/SqliteUpdateCompiler.php @@ -8,5 +8,14 @@ class SqliteUpdateCompiler extends UpdateCompiler { + public function __construct() + { + $this->whereCompiler = new SqliteWhereCompiler(); + } + + protected function compileSetClause(string $column, int $paramIndex): string + { + return "{$column} = ?"; + } // TODO: Support RETURNING clause (SQLite 3.35.0+) } diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php b/src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php new file mode 100644 index 00000000..0da5251b --- /dev/null +++ b/src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php @@ -0,0 +1,132 @@ + $wheres + * @return CompiledClause + */ + public function compile(array $wheres): CompiledClause + { + if (empty($wheres)) { + return new CompiledClause('', []); + } + + $sql = []; + + foreach ($wheres as $index => $where) { + // Add logical connector if not the first clause + if ($index > 0 && $where->getConnector() !== null) { + $sql[] = $where->getConnector()->value; + } + + $sql[] = $this->compileClause($where); + } + + return new CompiledClause(implode(' ', $sql), []); + } + + private function compileClause(WhereClause $clause): string + { + return match (true) { + $clause instanceof BasicWhereClause => $this->compileBasicClause($clause), + $clause instanceof NullWhereClause => $this->compileNullClause($clause), + $clause instanceof BooleanWhereClause => $this->compileBooleanClause($clause), + $clause instanceof BetweenWhereClause => $this->compileBetweenClause($clause), + $clause instanceof SubqueryWhereClause => $this->compileSubqueryClause($clause), + $clause instanceof ColumnWhereClause => $this->compileColumnClause($clause), + $clause instanceof RawWhereClause => $this->compileRawClause($clause), + default => '', + }; + } + + private function compileBasicClause(BasicWhereClause $clause): string + { + $column = $clause->getColumn(); + $operator = $clause->getOperator(); + + // SQLite uses '?' as placeholder + if ($operator === Operator::IN || $operator === Operator::NOT_IN) { + $placeholders = str_repeat('?, ', $clause->getValueCount() - 1) . '?'; + + return "{$column} {$operator->value} ({$placeholders})"; + } + + return "{$column} {$operator->value} ?"; + } + + private function compileNullClause(NullWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value}"; + } + + private function compileBooleanClause(BooleanWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value}"; + } + + private function compileBetweenClause(BetweenWhereClause $clause): string + { + $column = $clause->getColumn(); + $operator = $clause->getOperator(); + + return "{$column} {$operator->value} ? AND ?"; + } + + private function compileSubqueryClause(SubqueryWhereClause $clause): string + { + $parts = []; + + if ($clause->getColumn() !== null) { + $parts[] = $clause->getColumn(); + } + + $parts[] = $clause->getOperator()->value; + + if ($clause->getSubqueryOperator() !== null) { + // For ANY/ALL/SOME, no space between operator and subquery + $parts[] = $clause->getSubqueryOperator()->value . '(' . $clause->getSubquerySql() . ')'; + } else { + // For regular subqueries, add space + $parts[] = '(' . $clause->getSubquerySql() . ')'; + } + + return implode(' ', $parts); + } + + private function compileColumnClause(ColumnWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->getCompareColumn()}"; + } + + private function compileRawClause(RawWhereClause $clause): string + { + // For backwards compatibility with any remaining raw clauses + $parts = array_map(function ($value) { + return match (true) { + $value instanceof Operator => $value->value, + $value instanceof LogicalConnector => $value->value, + is_array($value) => '(' . implode(', ', $value) . ')', + default => $value, + }; + }, $clause->getParts()); + + return implode(' ', $parts); + } +} diff --git a/src/Database/Dialects/SQLite/SqliteDialect.php b/src/Database/Dialects/SQLite/SqliteDialect.php index dfb1507d..4a9de4d1 100644 --- a/src/Database/Dialects/SQLite/SqliteDialect.php +++ b/src/Database/Dialects/SQLite/SqliteDialect.php @@ -5,7 +5,7 @@ namespace Phenix\Database\Dialects\SQLite; use Phenix\Database\Constants\Action; -use Phenix\Database\Dialects\Contracts\Dialect; +use Phenix\Database\Contracts\Dialect; use Phenix\Database\Dialects\SQLite\Compilers\SqliteDeleteCompiler; use Phenix\Database\Dialects\SQLite\Compilers\SqliteExistsCompiler; use Phenix\Database\Dialects\SQLite\Compilers\SqliteInsertCompiler; diff --git a/src/Database/Having.php b/src/Database/Having.php index ca47d7fa..32407438 100644 --- a/src/Database/Having.php +++ b/src/Database/Having.php @@ -4,7 +4,7 @@ namespace Phenix\Database; -use Phenix\Util\Arr; +use Phenix\Database\Constants\SQL; class Having extends Clause { @@ -16,8 +16,22 @@ public function __construct() public function toSql(): array { - $clauses = Arr::implodeDeeply($this->prepareClauses($this->clauses)); + if (empty($this->clauses)) { + return ['', []]; + } - return ["HAVING {$clauses}", $this->arguments]; + $sql = []; + + foreach ($this->clauses as $clause) { + $clauseSql = "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::STD_PLACEHOLDER->value; + + if ($connector = $clause->getConnector()) { + $clauseSql = "{$connector->value} {$clauseSql}"; + } + + $sql[] = $clauseSql; + } + + return ['HAVING ' . implode(' ', $sql), $this->arguments]; } } diff --git a/src/Database/Join.php b/src/Database/Join.php index ca1de718..c996ea0b 100644 --- a/src/Database/Join.php +++ b/src/Database/Join.php @@ -4,11 +4,12 @@ namespace Phenix\Database; +use Phenix\Database\Clauses\BasicWhereClause; +use Phenix\Database\Clauses\WhereClause; use Phenix\Database\Constants\JoinType; -use Phenix\Database\Constants\LogicalOperator; +use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; use Phenix\Database\Contracts\Builder; -use Phenix\Util\Arr; class Join extends Clause implements Builder { @@ -20,40 +21,75 @@ public function __construct( $this->arguments = []; } + // protected function pushClause(WhereClause $clause, LogicalConnector $logicalConnector = LogicalConnector::AND): void + // { + // // For Join clauses, remove connector from first clause + // if (empty($this->clauses)) { + // $clause->setConnector(null); + // } else { + // $clause->setConnector($logicalConnector); + // } + + // $this->clauses[] = $clause; + // } + public function onEqual(string $column, string $value): self { - $this->pushClause([$column, Operator::EQUAL, $value]); + $this->pushClause(new BasicWhereClause($column, Operator::EQUAL, $value)); return $this; } public function orOnEqual(string $column, string $value): self { - $this->pushClause([$column, Operator::EQUAL, $value], LogicalOperator::OR); + $this->pushClause(new BasicWhereClause($column, Operator::EQUAL, $value), LogicalConnector::OR); return $this; } public function onDistinct(string $column, string $value): self { - $this->pushClause([$column, Operator::DISTINCT, $value]); + $this->pushClause(new BasicWhereClause($column, Operator::DISTINCT, $value)); return $this; } public function orOnDistinct(string $column, string $value): self { - $this->pushClause([$column, Operator::DISTINCT, $value], LogicalOperator::OR); + $this->pushClause(new BasicWhereClause($column, Operator::DISTINCT, $value), LogicalConnector::OR); return $this; } public function toSql(): array { - $clauses = Arr::implodeDeeply($this->prepareClauses($this->clauses)); + if (empty($this->clauses)) { + return [ + "{$this->type->value} {$this->relationship}", + [], + ]; + } + + $sql = []; + + foreach ($this->clauses as $clause) { + $connector = $clause->getConnector(); + + $column = $clause->getColumn(); + $operator = $clause->getOperator(); + $value = $clause->renderValue(); + + $clauseSql = "{$column} {$operator->value} {$value}"; + + if ($connector !== null) { + $clauseSql = "{$connector->value} {$clauseSql}"; + } + + $sql[] = $clauseSql; + } return [ - "{$this->type->value} {$this->relationship} ON {$clauses}", + "{$this->type->value} {$this->relationship} ON " . implode(' ', $sql), $this->arguments, ]; } diff --git a/src/Database/QueryAst.php b/src/Database/QueryAst.php index 7079cd0c..16d40cb1 100644 --- a/src/Database/QueryAst.php +++ b/src/Database/QueryAst.php @@ -4,6 +4,7 @@ namespace Phenix\Database; +use Phenix\Database\Clauses\WhereClause; use Phenix\Database\Constants\Action; use Phenix\Database\Constants\Lock; @@ -31,7 +32,7 @@ class QueryAst public array $joins = []; /** - * @var array> + * @var array */ public array $wheres = []; diff --git a/src/Database/QueryBase.php b/src/Database/QueryBase.php index dd4457f8..4d3efbe9 100644 --- a/src/Database/QueryBase.php +++ b/src/Database/QueryBase.php @@ -180,6 +180,6 @@ protected function prepareDataToInsert(array $data): void $this->arguments = \array_merge($this->arguments, array_values($data)); - $this->values[] = array_fill(0, count($data), SQL::PLACEHOLDER->value); + $this->values[] = array_fill(0, count($data), SQL::STD_PLACEHOLDER->value); } } diff --git a/tests/Unit/Database/Dialects/MysqlDialectTest.php b/tests/Unit/Database/Dialects/MysqlDialectTest.php deleted file mode 100644 index 6ec5b9d2..00000000 --- a/tests/Unit/Database/Dialects/MysqlDialectTest.php +++ /dev/null @@ -1,29 +0,0 @@ -capabilities(); - - expect($capabilities->supportsLocks)->toBeTrue(); - expect($capabilities->supportsUpsert)->toBeTrue(); - expect($capabilities->supportsReturning)->toBeFalse(); - expect($capabilities->supportsJsonOperators)->toBeTrue(); - expect($capabilities->supportsAdvancedLocks)->toBeFalse(); - expect($capabilities->supportsInsertIgnore)->toBeTrue(); - expect($capabilities->supportsFulltextSearch)->toBeTrue(); - expect($capabilities->supportsGeneratedColumns)->toBeTrue(); -}); - -test('MysqlDialect supports method works correctly', function () { - $dialect = new MysqlDialect(); - $capabilities = $dialect->capabilities(); - - expect($capabilities->supports('locks'))->toBeTrue(); - expect($capabilities->supports('upsert'))->toBeTrue(); - expect($capabilities->supports('returning'))->toBeFalse(); - expect($capabilities->supports('advancedLocks'))->toBeFalse(); -}); diff --git a/tests/Unit/Database/Dialects/PostgresDialectTest.php b/tests/Unit/Database/Dialects/PostgresDialectTest.php deleted file mode 100644 index 0275d562..00000000 --- a/tests/Unit/Database/Dialects/PostgresDialectTest.php +++ /dev/null @@ -1,29 +0,0 @@ -capabilities(); - - expect($capabilities->supportsLocks)->toBeTrue(); - expect($capabilities->supportsUpsert)->toBeTrue(); - expect($capabilities->supportsReturning)->toBeTrue(); - expect($capabilities->supportsJsonOperators)->toBeTrue(); - expect($capabilities->supportsAdvancedLocks)->toBeTrue(); - expect($capabilities->supportsInsertIgnore)->toBeFalse(); - expect($capabilities->supportsFulltextSearch)->toBeTrue(); - expect($capabilities->supportsGeneratedColumns)->toBeTrue(); -}); - -test('PostgresDialect supports method works correctly', function () { - $dialect = new PostgresDialect(); - $capabilities = $dialect->capabilities(); - - expect($capabilities->supports('locks'))->toBeTrue(); - expect($capabilities->supports('returning'))->toBeTrue(); - expect($capabilities->supports('advancedLocks'))->toBeTrue(); - expect($capabilities->supports('insertIgnore'))->toBeFalse(); -}); diff --git a/tests/Unit/Database/Dialects/SqliteDialectTest.php b/tests/Unit/Database/Dialects/SqliteDialectTest.php deleted file mode 100644 index 600fa7aa..00000000 --- a/tests/Unit/Database/Dialects/SqliteDialectTest.php +++ /dev/null @@ -1,29 +0,0 @@ -capabilities(); - - expect($capabilities->supportsLocks)->toBeFalse(); - expect($capabilities->supportsUpsert)->toBeTrue(); - expect($capabilities->supportsReturning)->toBeTrue(); - expect($capabilities->supportsJsonOperators)->toBeTrue(); - expect($capabilities->supportsAdvancedLocks)->toBeFalse(); - expect($capabilities->supportsInsertIgnore)->toBeTrue(); - expect($capabilities->supportsFulltextSearch)->toBeTrue(); - expect($capabilities->supportsGeneratedColumns)->toBeTrue(); -}); - -test('SqliteDialect supports method works correctly', function () { - $dialect = new SqliteDialect(); - $capabilities = $dialect->capabilities(); - - expect($capabilities->supports('locks'))->toBeFalse(); - expect($capabilities->supports('upsert'))->toBeTrue(); - expect($capabilities->supports('returning'))->toBeTrue(); - expect($capabilities->supports('advancedLocks'))->toBeFalse(); -}); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/InsertIntoStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/InsertIntoStatementTest.php new file mode 100644 index 00000000..e491633d --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/InsertIntoStatementTest.php @@ -0,0 +1,157 @@ +name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->insert([ + 'name' => $name, + 'email' => $email, + ]); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name) VALUES ($1, $2)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name]); +}); + +it('generates insert into statement with data collection', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->insert([ + [ + 'name' => $name, + 'email' => $email, + ], + [ + 'name' => $name, + 'email' => $email, + ], + ]); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name) VALUES ($1, $2), ($3, $4)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name, $email, $name]); +}); + +it('generates insert ignore into statement', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->insertOrIgnore([ + 'name' => $name, + 'email' => $email, + ]); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name) VALUES ($1, $2) ON CONFLICT DO NOTHING"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name]); +}); + +it('generates upsert statement to handle duplicate keys', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->upsert([ + 'name' => $name, + 'email' => $email, + ], ['name']); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name) VALUES ($1, $2) " + . "ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name]); +}); + +it('generates upsert statement to handle duplicate keys with many unique columns', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $data = [ + 'name' => faker()->name, + 'username' => faker()->userName, + 'email' => faker()->freeEmail, + ]; + + $sql = $query->table('users') + ->upsert($data, ['name', 'username']); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name, username) VALUES ($1, $2, $3) " + . "ON CONFLICT (name, username) DO UPDATE SET name = EXCLUDED.name, username = EXCLUDED.username"; + + \ksort($data); + + expect($dml)->toBe($expected); + expect($params)->toBe(\array_values($data)); +}); + +it('generates insert statement from subquery', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->insertFrom(function (Subquery $subquery) { + $subquery->table('customers') + ->select(['name', 'email']) + ->whereNotNull('verified_at'); + }, ['name', 'email']); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (name, email) SELECT name, email FROM customers WHERE verified_at IS NOT NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates insert ignore statement from subquery', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->insertFrom(function (Subquery $subquery) { + $subquery->table('customers') + ->select(['name', 'email']) + ->whereNotNull('verified_at'); + }, ['name', 'email'], true); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (name, email) " + . "SELECT name, email FROM customers WHERE verified_at IS NOT NULL " + . "ON CONFLICT DO NOTHING"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/InsertIntoStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/InsertIntoStatementTest.php new file mode 100644 index 00000000..5de88752 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/InsertIntoStatementTest.php @@ -0,0 +1,156 @@ +name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->insert([ + 'name' => $name, + 'email' => $email, + ]); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name) VALUES (?, ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name]); +}); + +it('generates insert into statement with data collection', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->insert([ + [ + 'name' => $name, + 'email' => $email, + ], + [ + 'name' => $name, + 'email' => $email, + ], + ]); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name) VALUES (?, ?), (?, ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name, $email, $name]); +}); + +it('generates insert ignore into statement', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->insertOrIgnore([ + 'name' => $name, + 'email' => $email, + ]); + + [$dml, $params] = $sql; + + $expected = "INSERT OR IGNORE INTO users (email, name) VALUES (?, ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name]); +}); + +it('generates upsert statement to handle duplicate keys', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->upsert([ + 'name' => $name, + 'email' => $email, + ], ['name']); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name) VALUES (?, ?) " + . "ON CONFLICT (name) DO UPDATE SET name = excluded.name"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name]); +}); + +it('generates upsert statement to handle duplicate keys with many unique columns', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $data = [ + 'name' => faker()->name, + 'username' => faker()->userName, + 'email' => faker()->freeEmail, + ]; + + $sql = $query->table('users') + ->upsert($data, ['name', 'username']); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name, username) VALUES (?, ?, ?) " + . "ON CONFLICT (name, username) DO UPDATE SET name = excluded.name, username = excluded.username"; + + \ksort($data); + + expect($dml)->toBe($expected); + expect($params)->toBe(\array_values($data)); +}); + +it('generates insert statement from subquery', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->insertFrom(function (Subquery $subquery) { + $subquery->table('customers') + ->select(['name', 'email']) + ->whereNotNull('verified_at'); + }, ['name', 'email']); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (name, email) SELECT name, email FROM customers WHERE verified_at IS NOT NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates insert ignore statement from subquery', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->insertFrom(function (Subquery $subquery) { + $subquery->table('customers') + ->select(['name', 'email']) + ->whereNotNull('verified_at'); + }, ['name', 'email'], true); + + [$dml, $params] = $sql; + + $expected = "INSERT OR IGNORE INTO users (name, email) " + . "SELECT name, email FROM customers WHERE verified_at IS NOT NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); From f90c70414e9ccf2a779a2e0d7266527d781e42e6 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 30 Dec 2025 08:24:07 -0500 Subject: [PATCH 15/49] feat: add PostgreSQL/SQLite support for select column query generation tests --- .../Postgres/SelectColumnsTest.php | 607 ++++++++++++++++++ .../Sqlite/SelectColumnsTest.php | 483 ++++++++++++++ 2 files changed, 1090 insertions(+) create mode 100644 tests/Unit/Database/QueryGenerator/Postgres/SelectColumnsTest.php create mode 100644 tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php diff --git a/tests/Unit/Database/QueryGenerator/Postgres/SelectColumnsTest.php b/tests/Unit/Database/QueryGenerator/Postgres/SelectColumnsTest.php new file mode 100644 index 00000000..8c609fd3 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/SelectColumnsTest.php @@ -0,0 +1,607 @@ +table('users') + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users'); + expect($params)->toBeEmpty(); +}); + +it('generates query to select all columns from table', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('users') + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users'); + expect($params)->toBeEmpty(); +}); + +it('generates a query using sql functions', function (string $function, string $column, string $rawFunction) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('products') + ->select([Functions::{$function}($column)]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT {$rawFunction} FROM products"); + expect($params)->toBeEmpty(); +})->with([ + ['avg', 'price', 'AVG(price)'], + ['sum', 'price', 'SUM(price)'], + ['min', 'price', 'MIN(price)'], + ['max', 'price', 'MAX(price)'], + ['count', 'id', 'COUNT(id)'], +]); + +it('generates a query using sql functions with alias', function ( + string $function, + string $column, + string $alias, + string $rawFunction +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('products') + ->select([Functions::{$function}($column)->as($alias)]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT {$rawFunction} FROM products"); + expect($params)->toBeEmpty(); +})->with([ + ['avg', 'price', 'value', 'AVG(price) AS value'], + ['sum', 'price', 'value', 'SUM(price) AS value'], + ['min', 'price', 'value', 'MIN(price) AS value'], + ['max', 'price', 'value', 'MAX(price) AS value'], + ['count', 'id', 'value', 'COUNT(id) AS value'], +]); + +it('selects field from subquery', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $date = date('Y-m-d'); + $sql = $query->select(['id', 'name', 'email']) + ->from(function (Subquery $subquery) use ($date) { + $subquery->from('users') + ->whereEqual('verified_at', $date); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, email FROM (SELECT * FROM users WHERE verified_at = $1)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$date]); +}); + + +it('generates query using subqueries in column selection', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'id', + 'name', + Subquery::make(Driver::POSTGRESQL)->select(['name']) + ->from('countries') + ->whereColumn('users.country_id', 'countries.id') + ->as('country_name') + ->limit(1), + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $subquery = "SELECT name FROM countries WHERE users.country_id = countries.id LIMIT 1"; + $expected = "SELECT id, name, ({$subquery}) AS country_name FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('throws exception on generate query using subqueries in column selection with limit missing', function () { + expect(function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $query->select([ + 'id', + 'name', + Subquery::make(Driver::POSTGRESQL)->select(['name']) + ->from('countries') + ->whereColumn('users.country_id', 'countries.id') + ->as('country_name'), + ]) + ->from('users') + ->get(); + })->toThrow(QueryErrorException::class); +}); + +it('generates query with column alias', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'id', + Alias::of('name')->as('full_name'), + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name AS full_name FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with many column alias', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'id' => 'model_id', + 'name' => 'full_name', + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id AS model_id, name AS full_name FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with select-cases using comparisons', function ( + string $method, + array $data, + string $defaultResult, + string $operator +) { + [$column, $value, $result] = $data; + + $value = Value::from($value); + + $query = new QueryGenerator(Driver::POSTGRESQL); + + $case = Functions::case() + ->{$method}($column, $value, $result) + ->defaultResult($defaultResult) + ->as('type'); + + $sql = $query->select([ + 'id', + 'description', + $case, + ]) + ->from('products') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, description, (CASE WHEN {$column} {$operator} {$value} " + . "THEN {$result} ELSE $defaultResult END) AS type FROM products"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['whenEqual', ['price', 100, 'expensive'], 'cheap', Operator::EQUAL->value], + ['whenDistinct', ['price', 100, 'expensive'], 'cheap', Operator::DISTINCT->value], + ['whenGreaterThan', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN->value], + ['whenGreaterThanOrEqual', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN_OR_EQUAL->value], + ['whenLessThan', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN->value], + ['whenLessThanOrEqual', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query with select-cases using logical comparisons', function ( + string $method, + array $data, + string $defaultResult, + string $operator +) { + [$column, $result] = $data; + + $query = new QueryGenerator(Driver::POSTGRESQL); + + $case = Functions::case() + ->{$method}(...$data) + ->defaultResult($defaultResult) + ->as('status'); + + $sql = $query->select([ + 'id', + 'name', + $case, + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, (CASE WHEN {$column} {$operator} " + . "THEN {$result} ELSE $defaultResult END) AS status FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['whenNull', ['created_at', 'inactive'], 'active', Operator::IS_NULL->value], + ['whenNotNull', ['created_at', 'active'], 'inactive', Operator::IS_NOT_NULL->value], + ['whenTrue', ['is_verified', 'active'], 'inactive', Operator::IS_TRUE->value], + ['whenFalse', ['is_verified', 'inactive'], 'active', Operator::IS_FALSE->value], +]); + +it('generates query with select-cases with multiple conditions and string values', function () { + $date = date('Y-m-d H:i:s'); + + $query = new QueryGenerator(Driver::POSTGRESQL); + + $case = Functions::case() + ->whenNull('created_at', Value::from('inactive')) + ->whenGreaterThan('created_at', Value::from($date), Value::from('new user')) + ->defaultResult(Value::from('old user')) + ->as('status'); + + $sql = $query->select([ + 'id', + 'name', + $case, + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, (CASE WHEN created_at IS NULL THEN 'inactive' " + . "WHEN created_at > '{$date}' THEN 'new user' ELSE 'old user' END) AS status FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with select-cases without default value', function () { + $date = date('Y-m-d H:i:s'); + + $query = new QueryGenerator(Driver::POSTGRESQL); + + $case = Functions::case() + ->whenNull('created_at', Value::from('inactive')) + ->whenGreaterThan('created_at', Value::from($date), Value::from('new user')) + ->as('status'); + + $sql = $query->select([ + 'id', + 'name', + $case, + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, (CASE WHEN created_at IS NULL THEN 'inactive' " + . "WHEN created_at > '{$date}' THEN 'new user' END) AS status FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with select-case using functions', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $case = Functions::case() + ->whenGreaterThanOrEqual(Functions::avg('price'), 4, Value::from('expensive')) + ->defaultResult(Value::from('cheap')) + ->as('message'); + + $sql = $query->select([ + 'id', + 'description', + 'price', + $case, + ]) + ->from('products') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, description, price, (CASE WHEN AVG(price) >= 4 THEN 'expensive' ELSE 'cheap' END) " + . "AS message FROM products"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('counts all records', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('products')->count(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(*) FROM products"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query to check if record exists', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('products') + ->whereEqual('id', 1) + ->exists(); + + [$dml, $params] = $sql; + + $expected = "SELECT EXISTS" + . " (SELECT 1 FROM products WHERE id = $1) AS 'exists'"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates query to check if record does not exist', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('products') + ->whereEqual('id', 1) + ->doesntExist(); + + [$dml, $params] = $sql; + + $expected = "SELECT NOT EXISTS" + . " (SELECT 1 FROM products WHERE id = $1) AS 'exists'"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates query to select first row', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('products') + ->whereEqual('id', 1) + ->first(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM products WHERE id = $1 LIMIT 1"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates query to select all columns of table without column selection', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users')->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users'); + expect($params)->toBeEmpty(); +}); + +it('generate query with lock for update', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForUpdate() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR UPDATE"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for share', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForShare() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR SHARE"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for update skip locked', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForUpdateSkipLocked() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR UPDATE SKIP LOCKED"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for update no wait', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForUpdateNoWait() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR UPDATE NOWAIT"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for update skip locked using constants', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lock(Lock::FOR_UPDATE_SKIP_LOCKED) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR UPDATE SKIP LOCKED"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('remove locks from query', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $builder = $query->from('tasks') + ->whereNull('reserved_at') + ->lock(Lock::FOR_UPDATE_SKIP_LOCKED) + ->unlock(); + + expect($builder->isLocked())->toBeFalse(); + + [$dml, $params] = $builder->get(); + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for no key update', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForNoKeyUpdate() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR NO KEY UPDATE"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for key share', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForKeyShare() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR KEY SHARE"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for share skip locked', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForShareSkipLocked() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR SHARE SKIP LOCKED"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for share no wait', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForShareNoWait() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR SHARE NOWAIT"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for no key update skip locked', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForNoKeyUpdateSkipLocked() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR NO KEY UPDATE SKIP LOCKED"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for no key update no wait', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForNoKeyUpdateNoWait() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR NO KEY UPDATE NOWAIT"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php new file mode 100644 index 00000000..7c439e8c --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php @@ -0,0 +1,483 @@ +table('users') + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users'); + expect($params)->toBeEmpty(); +}); + +it('generates query to select all columns from table', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->from('users') + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users'); + expect($params)->toBeEmpty(); +}); + +it('generates a query using sql functions', function (string $function, string $column, string $rawFunction) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('products') + ->select([Functions::{$function}($column)]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT {$rawFunction} FROM products"); + expect($params)->toBeEmpty(); +})->with([ + ['avg', 'price', 'AVG(price)'], + ['sum', 'price', 'SUM(price)'], + ['min', 'price', 'MIN(price)'], + ['max', 'price', 'MAX(price)'], + ['count', 'id', 'COUNT(id)'], +]); + +it('generates a query using sql functions with alias', function ( + string $function, + string $column, + string $alias, + string $rawFunction +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('products') + ->select([Functions::{$function}($column)->as($alias)]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT {$rawFunction} FROM products"); + expect($params)->toBeEmpty(); +})->with([ + ['avg', 'price', 'value', 'AVG(price) AS value'], + ['sum', 'price', 'value', 'SUM(price) AS value'], + ['min', 'price', 'value', 'MIN(price) AS value'], + ['max', 'price', 'value', 'MAX(price) AS value'], + ['count', 'id', 'value', 'COUNT(id) AS value'], +]); + +it('selects field from subquery', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $date = date('Y-m-d'); + $sql = $query->select(['id', 'name', 'email']) + ->from(function (Subquery $subquery) use ($date) { + $subquery->from('users') + ->whereEqual('verified_at', $date); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, email FROM (SELECT * FROM users WHERE verified_at = ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$date]); +}); + + +it('generates query using subqueries in column selection', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'id', + 'name', + Subquery::make(Driver::SQLITE)->select(['name']) + ->from('countries') + ->whereColumn('users.country_id', 'countries.id') + ->as('country_name') + ->limit(1), + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $subquery = "SELECT name FROM countries WHERE users.country_id = countries.id LIMIT 1"; + $expected = "SELECT id, name, ({$subquery}) AS country_name FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('throws exception on generate query using subqueries in column selection with limit missing', function () { + expect(function () { + $query = new QueryGenerator(Driver::SQLITE); + + $query->select([ + 'id', + 'name', + Subquery::make(Driver::SQLITE)->select(['name']) + ->from('countries') + ->whereColumn('users.country_id', 'countries.id') + ->as('country_name'), + ]) + ->from('users') + ->get(); + })->toThrow(QueryErrorException::class); +}); + +it('generates query with column alias', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'id', + Alias::of('name')->as('full_name'), + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name AS full_name FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with many column alias', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'id' => 'model_id', + 'name' => 'full_name', + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id AS model_id, name AS full_name FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with select-cases using comparisons', function ( + string $method, + array $data, + string $defaultResult, + string $operator +) { + [$column, $value, $result] = $data; + + $value = Value::from($value); + + $query = new QueryGenerator(Driver::SQLITE); + + $case = Functions::case() + ->{$method}($column, $value, $result) + ->defaultResult($defaultResult) + ->as('type'); + + $sql = $query->select([ + 'id', + 'description', + $case, + ]) + ->from('products') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, description, (CASE WHEN {$column} {$operator} {$value} " + . "THEN {$result} ELSE $defaultResult END) AS type FROM products"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['whenEqual', ['price', 100, 'expensive'], 'cheap', Operator::EQUAL->value], + ['whenDistinct', ['price', 100, 'expensive'], 'cheap', Operator::DISTINCT->value], + ['whenGreaterThan', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN->value], + ['whenGreaterThanOrEqual', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN_OR_EQUAL->value], + ['whenLessThan', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN->value], + ['whenLessThanOrEqual', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query with select-cases using logical comparisons', function ( + string $method, + array $data, + string $defaultResult, + string $operator +) { + [$column, $result] = $data; + + $query = new QueryGenerator(Driver::SQLITE); + + $case = Functions::case() + ->{$method}(...$data) + ->defaultResult($defaultResult) + ->as('status'); + + $sql = $query->select([ + 'id', + 'name', + $case, + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, (CASE WHEN {$column} {$operator} " + . "THEN {$result} ELSE $defaultResult END) AS status FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['whenNull', ['created_at', 'inactive'], 'active', Operator::IS_NULL->value], + ['whenNotNull', ['created_at', 'active'], 'inactive', Operator::IS_NOT_NULL->value], + ['whenTrue', ['is_verified', 'active'], 'inactive', Operator::IS_TRUE->value], + ['whenFalse', ['is_verified', 'inactive'], 'active', Operator::IS_FALSE->value], +]); + +it('generates query with select-cases with multiple conditions and string values', function () { + $date = date('Y-m-d H:i:s'); + + $query = new QueryGenerator(Driver::SQLITE); + + $case = Functions::case() + ->whenNull('created_at', Value::from('inactive')) + ->whenGreaterThan('created_at', Value::from($date), Value::from('new user')) + ->defaultResult(Value::from('old user')) + ->as('status'); + + $sql = $query->select([ + 'id', + 'name', + $case, + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, (CASE WHEN created_at IS NULL THEN 'inactive' " + . "WHEN created_at > '{$date}' THEN 'new user' ELSE 'old user' END) AS status FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with select-cases without default value', function () { + $date = date('Y-m-d H:i:s'); + + $query = new QueryGenerator(Driver::SQLITE); + + $case = Functions::case() + ->whenNull('created_at', Value::from('inactive')) + ->whenGreaterThan('created_at', Value::from($date), Value::from('new user')) + ->as('status'); + + $sql = $query->select([ + 'id', + 'name', + $case, + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, (CASE WHEN created_at IS NULL THEN 'inactive' " + . "WHEN created_at > '{$date}' THEN 'new user' END) AS status FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with select-case using functions', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $case = Functions::case() + ->whenGreaterThanOrEqual(Functions::avg('price'), 4, Value::from('expensive')) + ->defaultResult(Value::from('cheap')) + ->as('message'); + + $sql = $query->select([ + 'id', + 'description', + 'price', + $case, + ]) + ->from('products') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, description, price, (CASE WHEN AVG(price) >= 4 THEN 'expensive' ELSE 'cheap' END) " + . "AS message FROM products"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('counts all records', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->from('products')->count(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(*) FROM products"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query to check if record exists', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->from('products') + ->whereEqual('id', 1) + ->exists(); + + [$dml, $params] = $sql; + + $expected = "SELECT EXISTS" + . " (SELECT 1 FROM products WHERE id = ?) AS 'exists'"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates query to check if record does not exist', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->from('products') + ->whereEqual('id', 1) + ->doesntExist(); + + [$dml, $params] = $sql; + + $expected = "SELECT NOT EXISTS" + . " (SELECT 1 FROM products WHERE id = ?) AS 'exists'"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates query to select first row', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->from('products') + ->whereEqual('id', 1) + ->first(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM products WHERE id = ? LIMIT 1"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates query to select all columns of table without column selection', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users')->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users'); + expect($params)->toBeEmpty(); +}); + +it('tries to generate lock using sqlite - locks are ignored', function () { + $query = new QueryGenerator(Driver::SQLITE); + + expect($query->getDriver())->toBe(Driver::SQLITE); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForUpdate() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('tries to generate lock for share using sqlite - locks are ignored', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForShare() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('tries to generate lock using sqlite with constants - locks are ignored', function () { + $query = new QueryGenerator(Driver::SQLITE); + + expect($query->getDriver())->toBe(Driver::SQLITE); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lock(Lock::FOR_UPDATE) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('remove locks from query on sqlite', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $builder = $query->from('tasks') + ->whereNull('reserved_at') + ->lock(Lock::FOR_UPDATE) + ->unlock(); + + expect($builder->isLocked())->toBeFalse(); + + [$dml, $params] = $builder->get(); + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); From ad47e4451e4e60cff71dc2efb59beb06a5a1fe88 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 30 Dec 2025 16:40:50 +0000 Subject: [PATCH 16/49] feat: rename distinct operators to not equal across query clauses and tests --- src/Database/Concerns/Query/HasWhereAllClause.php | 4 ++-- src/Database/Concerns/Query/HasWhereAnyClause.php | 4 ++-- src/Database/Concerns/Query/HasWhereClause.php | 8 ++++---- src/Database/Concerns/Query/HasWhereRowClause.php | 4 ++-- src/Database/Concerns/Query/HasWhereSomeClause.php | 4 ++-- src/Database/Constants/Operator.php | 2 +- src/Database/Join.php | 8 ++++---- src/Database/SelectCase.php | 4 ++-- .../Database/QueryGenerator/JoinClausesTest.php | 4 ++-- .../QueryGenerator/Postgres/SelectColumnsTest.php | 2 +- .../Database/QueryGenerator/SelectColumnsTest.php | 2 +- .../QueryGenerator/Sqlite/SelectColumnsTest.php | 2 +- .../Database/QueryGenerator/WhereClausesTest.php | 14 +++++++------- tests/Unit/Validation/Types/EmailTest.php | 2 +- 14 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/Database/Concerns/Query/HasWhereAllClause.php b/src/Database/Concerns/Query/HasWhereAllClause.php index fdcea5ef..9c540f23 100644 --- a/src/Database/Concerns/Query/HasWhereAllClause.php +++ b/src/Database/Concerns/Query/HasWhereAllClause.php @@ -16,9 +16,9 @@ public function whereAllEqual(string $column, Closure $subquery): static return $this; } - public function whereAllDistinct(string $column, Closure $subquery): static + public function whereAllNotEqual(string $column, Closure $subquery): static { - $this->whereSubquery($subquery, Operator::DISTINCT, $column, Operator::ALL); + $this->whereSubquery($subquery, Operator::NOT_EQUAL, $column, Operator::ALL); return $this; } diff --git a/src/Database/Concerns/Query/HasWhereAnyClause.php b/src/Database/Concerns/Query/HasWhereAnyClause.php index ef6b22ee..d8c75147 100644 --- a/src/Database/Concerns/Query/HasWhereAnyClause.php +++ b/src/Database/Concerns/Query/HasWhereAnyClause.php @@ -16,9 +16,9 @@ public function whereAnyEqual(string $column, Closure $subquery): static return $this; } - public function whereAnyDistinct(string $column, Closure $subquery): static + public function whereAnyNotEqual(string $column, Closure $subquery): static { - $this->whereSubquery($subquery, Operator::DISTINCT, $column, Operator::ANY); + $this->whereSubquery($subquery, Operator::NOT_EQUAL, $column, Operator::ANY); return $this; } diff --git a/src/Database/Concerns/Query/HasWhereClause.php b/src/Database/Concerns/Query/HasWhereClause.php index 74df2dd3..a0e0fc8d 100644 --- a/src/Database/Concerns/Query/HasWhereClause.php +++ b/src/Database/Concerns/Query/HasWhereClause.php @@ -34,16 +34,16 @@ public function orWhereEqual(string $column, Closure|string|int $value): static return $this; } - public function whereDistinct(string $column, Closure|string|int $value): static + public function whereNotEqual(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::DISTINCT, $value); + $this->resolveWhereMethod($column, Operator::NOT_EQUAL, $value); return $this; } - public function orWhereDistinct(string $column, Closure|string|int $value): static + public function orWhereNotEqual(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::DISTINCT, $value, LogicalConnector::OR); + $this->resolveWhereMethod($column, Operator::NOT_EQUAL, $value, LogicalConnector::OR); return $this; } diff --git a/src/Database/Concerns/Query/HasWhereRowClause.php b/src/Database/Concerns/Query/HasWhereRowClause.php index 1b14d9f6..23d30116 100644 --- a/src/Database/Concerns/Query/HasWhereRowClause.php +++ b/src/Database/Concerns/Query/HasWhereRowClause.php @@ -16,9 +16,9 @@ public function whereRowEqual(array $columns, Closure $subquery): static return $this; } - public function whereRowDistinct(array $columns, Closure $subquery): static + public function whereRowNotEqual(array $columns, Closure $subquery): static { - $this->whereSubquery($subquery, Operator::DISTINCT, $this->prepareRowFields($columns)); + $this->whereSubquery($subquery, Operator::NOT_EQUAL, $this->prepareRowFields($columns)); return $this; } diff --git a/src/Database/Concerns/Query/HasWhereSomeClause.php b/src/Database/Concerns/Query/HasWhereSomeClause.php index 817910ac..f9349f56 100644 --- a/src/Database/Concerns/Query/HasWhereSomeClause.php +++ b/src/Database/Concerns/Query/HasWhereSomeClause.php @@ -16,9 +16,9 @@ public function whereSomeEqual(string $column, Closure $subquery): static return $this; } - public function whereSomeDistinct(string $column, Closure $subquery): static + public function whereSomeNotEqual(string $column, Closure $subquery): static { - $this->whereSubquery($subquery, Operator::DISTINCT, $column, Operator::SOME); + $this->whereSubquery($subquery, Operator::NOT_EQUAL, $column, Operator::SOME); return $this; } diff --git a/src/Database/Constants/Operator.php b/src/Database/Constants/Operator.php index 1c22560a..d96f5340 100644 --- a/src/Database/Constants/Operator.php +++ b/src/Database/Constants/Operator.php @@ -7,7 +7,7 @@ enum Operator: string { case EQUAL = '='; - case DISTINCT = '!='; + case NOT_EQUAL = '!='; case GREATER_THAN = '>'; case GREATER_THAN_OR_EQUAL = '>='; case LESS_THAN = '<'; diff --git a/src/Database/Join.php b/src/Database/Join.php index c996ea0b..632194b3 100644 --- a/src/Database/Join.php +++ b/src/Database/Join.php @@ -47,16 +47,16 @@ public function orOnEqual(string $column, string $value): self return $this; } - public function onDistinct(string $column, string $value): self + public function onNotEqual(string $column, string $value): self { - $this->pushClause(new BasicWhereClause($column, Operator::DISTINCT, $value)); + $this->pushClause(new BasicWhereClause($column, Operator::NOT_EQUAL, $value)); return $this; } - public function orOnDistinct(string $column, string $value): self + public function orOnNotEqual(string $column, string $value): self { - $this->pushClause(new BasicWhereClause($column, Operator::DISTINCT, $value), LogicalConnector::OR); + $this->pushClause(new BasicWhereClause($column, Operator::NOT_EQUAL, $value), LogicalConnector::OR); return $this; } diff --git a/src/Database/SelectCase.php b/src/Database/SelectCase.php index c5204365..d60735c2 100644 --- a/src/Database/SelectCase.php +++ b/src/Database/SelectCase.php @@ -31,11 +31,11 @@ public function whenEqual(Functions|string $column, Value|string|int $value, Val return $this; } - public function whenDistinct(Functions|string $column, Value|string|int $value, Value|string $result): self + public function whenNotEqual(Functions|string $column, Value|string|int $value, Value|string $result): self { $this->pushCase( $column, - Operator::DISTINCT, + Operator::NOT_EQUAL, $result, $value ); diff --git a/tests/Unit/Database/QueryGenerator/JoinClausesTest.php b/tests/Unit/Database/QueryGenerator/JoinClausesTest.php index 505ddb7d..961669a2 100644 --- a/tests/Unit/Database/QueryGenerator/JoinClausesTest.php +++ b/tests/Unit/Database/QueryGenerator/JoinClausesTest.php @@ -48,7 +48,7 @@ ]) ->from('products') ->innerJoin('categories', function (Join $join) { - $join->onDistinct('products.category_id', 'categories.id'); + $join->onNotEqual('products.category_id', 'categories.id'); }) ->get(); @@ -106,7 +106,7 @@ ['php'], ], [ - 'orOnDistinct', + 'orOnNotEqual', ['products.location_id', 'categories.location_id'], 'OR products.location_id != categories.location_id', [], diff --git a/tests/Unit/Database/QueryGenerator/Postgres/SelectColumnsTest.php b/tests/Unit/Database/QueryGenerator/Postgres/SelectColumnsTest.php index 8c609fd3..1bb05d0d 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/SelectColumnsTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/SelectColumnsTest.php @@ -214,7 +214,7 @@ expect($params)->toBeEmpty(); })->with([ ['whenEqual', ['price', 100, 'expensive'], 'cheap', Operator::EQUAL->value], - ['whenDistinct', ['price', 100, 'expensive'], 'cheap', Operator::DISTINCT->value], + ['whenNotEqual', ['price', 100, 'expensive'], 'cheap', Operator::NOT_EQUAL->value], ['whenGreaterThan', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN->value], ['whenGreaterThanOrEqual', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN_OR_EQUAL->value], ['whenLessThan', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN->value], diff --git a/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php b/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php index cf51e55c..541b5047 100644 --- a/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php +++ b/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php @@ -214,7 +214,7 @@ expect($params)->toBeEmpty(); })->with([ ['whenEqual', ['price', 100, 'expensive'], 'cheap', Operator::EQUAL->value], - ['whenDistinct', ['price', 100, 'expensive'], 'cheap', Operator::DISTINCT->value], + ['whenNotEqual', ['price', 100, 'expensive'], 'cheap', Operator::NOT_EQUAL->value], ['whenGreaterThan', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN->value], ['whenGreaterThanOrEqual', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN_OR_EQUAL->value], ['whenLessThan', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN->value], diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php index 7c439e8c..9f1cd124 100644 --- a/tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php +++ b/tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php @@ -214,7 +214,7 @@ expect($params)->toBeEmpty(); })->with([ ['whenEqual', ['price', 100, 'expensive'], 'cheap', Operator::EQUAL->value], - ['whenDistinct', ['price', 100, 'expensive'], 'cheap', Operator::DISTINCT->value], + ['whenNotEqual', ['price', 100, 'expensive'], 'cheap', Operator::NOT_EQUAL->value], ['whenGreaterThan', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN->value], ['whenGreaterThanOrEqual', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN_OR_EQUAL->value], ['whenLessThan', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN->value], diff --git a/tests/Unit/Database/QueryGenerator/WhereClausesTest.php b/tests/Unit/Database/QueryGenerator/WhereClausesTest.php index c39c60be..3b1d8d01 100644 --- a/tests/Unit/Database/QueryGenerator/WhereClausesTest.php +++ b/tests/Unit/Database/QueryGenerator/WhereClausesTest.php @@ -57,7 +57,7 @@ expect($dml)->toBe("SELECT * FROM users WHERE {$column} {$operator} ?"); expect($params)->toBe([$value]); })->with([ - ['whereDistinct', 'id', Operator::DISTINCT->value, 1], + ['whereNotEqual', 'id', Operator::NOT_EQUAL->value, 1], ['whereGreaterThan', 'id', Operator::GREATER_THAN->value, 1], ['whereGreaterThanOrEqual', 'id', Operator::GREATER_THAN_OR_EQUAL->value, 1], ['whereLessThan', 'id', Operator::LESS_THAN->value, 1], @@ -258,7 +258,7 @@ })->with([ ['orWhereLessThan', 'updated_at', date('Y-m-d'), Operator::LESS_THAN->value], ['orWhereEqual', 'updated_at', date('Y-m-d'), Operator::EQUAL->value], - ['orWhereDistinct', 'updated_at', date('Y-m-d'), Operator::DISTINCT->value], + ['orWhereNotEqual', 'updated_at', date('Y-m-d'), Operator::NOT_EQUAL->value], ['orWhereGreaterThan', 'updated_at', date('Y-m-d'), Operator::GREATER_THAN->value], ['orWhereGreaterThanOrEqual', 'updated_at', date('Y-m-d'), Operator::GREATER_THAN_OR_EQUAL->value], ['orWhereLessThan', 'updated_at', date('Y-m-d'), Operator::LESS_THAN->value], @@ -440,7 +440,7 @@ expect($params)->toBeEmpty(); })->with([ ['whereEqual', 'price', Operator::EQUAL->value], - ['whereDistinct', 'price', Operator::DISTINCT->value], + ['whereNotEqual', 'price', Operator::NOT_EQUAL->value], ['whereGreaterThan', 'price', Operator::GREATER_THAN->value], ['whereGreaterThanOrEqual', 'price', Operator::GREATER_THAN_OR_EQUAL->value], ['whereLessThan', 'price', Operator::LESS_THAN->value], @@ -472,21 +472,21 @@ expect($params)->toBe([10]); })->with([ ['whereAnyEqual', Operator::EQUAL->value, Operator::ANY->value], - ['whereAnyDistinct', Operator::DISTINCT->value, Operator::ANY->value], + ['whereAnyNotEqual', Operator::NOT_EQUAL->value, Operator::ANY->value], ['whereAnyGreaterThan', Operator::GREATER_THAN->value, Operator::ANY->value], ['whereAnyGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::ANY->value], ['whereAnyLessThan', Operator::LESS_THAN->value, Operator::ANY->value], ['whereAnyLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::ANY->value], ['whereAllEqual', Operator::EQUAL->value, Operator::ALL->value], - ['whereAllDistinct', Operator::DISTINCT->value, Operator::ALL->value], + ['whereAllNotEqual', Operator::NOT_EQUAL->value, Operator::ALL->value], ['whereAllGreaterThan', Operator::GREATER_THAN->value, Operator::ALL->value], ['whereAllGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::ALL->value], ['whereAllLessThan', Operator::LESS_THAN->value, Operator::ALL->value], ['whereAllLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::ALL->value], ['whereSomeEqual', Operator::EQUAL->value, Operator::SOME->value], - ['whereSomeDistinct', Operator::DISTINCT->value, Operator::SOME->value], + ['whereSomeNotEqual', Operator::NOT_EQUAL->value, Operator::SOME->value], ['whereSomeGreaterThan', Operator::GREATER_THAN->value, Operator::SOME->value], ['whereSomeGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::SOME->value], ['whereSomeLessThan', Operator::LESS_THAN->value, Operator::SOME->value], @@ -516,7 +516,7 @@ expect($params)->toBe([1]); })->with([ ['whereRowEqual', Operator::EQUAL->value], - ['whereRowDistinct', Operator::DISTINCT->value], + ['whereRowNotEqual', Operator::NOT_EQUAL->value], ['whereRowGreaterThan', Operator::GREATER_THAN->value], ['whereRowGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value], ['whereRowLessThan', Operator::LESS_THAN->value], diff --git a/tests/Unit/Validation/Types/EmailTest.php b/tests/Unit/Validation/Types/EmailTest.php index 4134c9dd..7d49a792 100644 --- a/tests/Unit/Validation/Types/EmailTest.php +++ b/tests/Unit/Validation/Types/EmailTest.php @@ -115,7 +115,7 @@ $this->app->swap(Connection::default(), $connection); $rules = Email::required()->unique(table: 'users', query: function (QueryBuilder $queryBuilder): void { - $queryBuilder->whereDistinct('email', 'john.doe@mail.com'); + $queryBuilder->whereNotEqual('email', 'john.doe@mail.com'); })->toArray(); foreach ($rules['type'] as $rule) { From 0e49e09e1cef7709bcdfb38989ae6942793bde4e Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 30 Dec 2025 16:40:58 +0000 Subject: [PATCH 17/49] tests: delete statement for sqlite and postgres --- .../Postgres/DeleteStatementTest.php | 141 ++++++++++++++++++ .../Sqlite/DeleteStatementTest.php | 141 ++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php create mode 100644 tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php diff --git a/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php new file mode 100644 index 00000000..29dc2a35 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php @@ -0,0 +1,141 @@ +table('users') + ->whereEqual('id', 1) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE id = $1"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates delete statement without clauses', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates delete statement with multiple where clauses', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('status', 'inactive') + ->whereEqual('role', 'user') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status = $1 AND role = $2"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', 'user']); +}); + +it('generates delete statement with where in clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereIn('id', [1, 2, 3]) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE id IN ($1, $2, $3)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1, 2, 3]); +}); + +it('generates delete statement with where not equal', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereNotEqual('status', 'active') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status != $1"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active']); +}); + +it('generates delete statement with where greater than', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereGreaterThan('age', 18) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE age > $1"; + + expect($dml)->toBe($expected); + expect($params)->toBe([18]); +}); + +it('generates delete statement with where less than', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereLessThan('age', 65) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE age < $1"; + + expect($dml)->toBe($expected); + expect($params)->toBe([65]); +}); + +it('generates delete statement with where null', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereNull('deleted_at') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE deleted_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates delete statement with where not null', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereNotNull('email') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE email IS NOT NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php new file mode 100644 index 00000000..697da439 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php @@ -0,0 +1,141 @@ +table('users') + ->whereEqual('id', 1) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE id = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates delete statement without clauses', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates delete statement with multiple where clauses', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('status', 'inactive') + ->whereEqual('role', 'user') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status = ? AND role = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', 'user']); +}); + +it('generates delete statement with where in clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereIn('id', [1, 2, 3]) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE id IN (?, ?, ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1, 2, 3]); +}); + +it('generates delete statement with where not equal', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereNotEqual('status', 'active') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status != ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active']); +}); + +it('generates delete statement with where greater than', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereGreaterThan('age', 18) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE age > ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([18]); +}); + +it('generates delete statement with where less than', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereLessThan('age', 65) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE age < ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([65]); +}); + +it('generates delete statement with where null', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereNull('deleted_at') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE deleted_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates delete statement with where not null', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereNotNull('email') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE email IS NOT NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); From 3456b2a472dec404580ad740a9c806aab4a2fac2 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 30 Dec 2025 17:06:34 +0000 Subject: [PATCH 18/49] feat: add SQLite support for grouped query generation tests --- .../Postgres/GroupByStatementTest.php | 146 ++++++++++++++++++ .../Sqlite/GroupByStatementTest.php | 146 ++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 tests/Unit/Database/QueryGenerator/Postgres/GroupByStatementTest.php create mode 100644 tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php diff --git a/tests/Unit/Database/QueryGenerator/Postgres/GroupByStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/GroupByStatementTest.php new file mode 100644 index 00000000..9a9b6b33 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/GroupByStatementTest.php @@ -0,0 +1,146 @@ +select([ + $column, + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join): void { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy($groupBy) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT {$column}, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "GROUP BY {$rawGroup}"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + [Functions::count('products.id'), 'category_id', 'category_id'], + ['location_id', ['category_id', 'location_id'], 'category_id, location_id'], + [Functions::count('products.id'), Functions::count('products.id'), 'COUNT(products.id)'], +]); + +it('generates a grouped and ordered query', function ( + Functions|string $column, + Functions|array|string $groupBy, + string $rawGroup +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + $column, + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join): void { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy($groupBy) + ->orderBy('products.id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT {$column}, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "GROUP BY {$rawGroup} " + . "ORDER BY products.id DESC"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + [Functions::count('products.id'), 'category_id', 'category_id'], + ['location_id', ['category_id', 'location_id'], 'category_id, location_id'], + [Functions::count('products.id'), Functions::count('products.id'), 'COUNT(products.id)'], +]); + +it('generates a grouped query with where clause', function (): void { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + Functions::count('products.id'), + 'products.category_id', + ]) + ->from('products') + ->whereEqual('products.status', 'active') + ->groupBy('category_id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id), products.category_id " + . "FROM products " + . "WHERE products.status = $1 " + . "GROUP BY category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active']); +}); + +it('generates a grouped query with having clause', function (): void { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + Functions::count('products.id')->as('product_count'), + 'products.category_id', + ]) + ->from('products') + ->groupBy('category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('product_count', 5); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " + . "FROM products " + . "HAVING product_count > ? " + . "GROUP BY category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([5]); +}); + +it('generates a grouped query with multiple aggregations', function (): void { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + Functions::count('products.id'), + Functions::sum('products.price'), + Functions::avg('products.price'), + 'products.category_id', + ]) + ->from('products') + ->groupBy('category_id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id), SUM(products.price), AVG(products.price), products.category_id " + . "FROM products " + . "GROUP BY category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php new file mode 100644 index 00000000..051bbb69 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php @@ -0,0 +1,146 @@ +select([ + $column, + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join): void { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy($groupBy) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT {$column}, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "GROUP BY {$rawGroup}"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + [Functions::count('products.id'), 'category_id', 'category_id'], + ['location_id', ['category_id', 'location_id'], 'category_id, location_id'], + [Functions::count('products.id'), Functions::count('products.id'), 'COUNT(products.id)'], +]); + +it('generates a grouped and ordered query', function ( + Functions|string $column, + Functions|array|string $groupBy, + string $rawGroup +): void { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + $column, + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join): void { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy($groupBy) + ->orderBy('products.id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT {$column}, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "GROUP BY {$rawGroup} " + . "ORDER BY products.id DESC"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + [Functions::count('products.id'), 'category_id', 'category_id'], + ['location_id', ['category_id', 'location_id'], 'category_id, location_id'], + [Functions::count('products.id'), Functions::count('products.id'), 'COUNT(products.id)'], +]); + +it('generates a grouped query with where clause', function (): void { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + Functions::count('products.id'), + 'products.category_id', + ]) + ->from('products') + ->whereEqual('products.status', 'active') + ->groupBy('category_id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id), products.category_id " + . "FROM products " + . "WHERE products.status = ? " + . "GROUP BY category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active']); +}); + +it('generates a grouped query with having clause', function (): void { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + Functions::count('products.id')->as('product_count'), + 'products.category_id', + ]) + ->from('products') + ->groupBy('category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('product_count', 5); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " + . "FROM products " + . "HAVING product_count > ? " + . "GROUP BY category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([5]); +}); + +it('generates a grouped query with multiple aggregations', function (): void { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + Functions::count('products.id'), + Functions::sum('products.price'), + Functions::avg('products.price'), + 'products.category_id', + ]) + ->from('products') + ->groupBy('category_id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id), SUM(products.price), AVG(products.price), products.category_id " + . "FROM products " + . "GROUP BY category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); From b239f48513c8a6ab16ec320acefb3504e88904a7 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 30 Dec 2025 17:17:08 +0000 Subject: [PATCH 19/49] tests: add SQLite and Posgres having clause query generation tests --- .../Postgres/HavingClauseTest.php | 142 ++++++++++++++++++ .../Sqlite/HavingClauseTest.php | 142 ++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 tests/Unit/Database/QueryGenerator/Postgres/HavingClauseTest.php create mode 100644 tests/Unit/Database/QueryGenerator/Sqlite/HavingClauseTest.php diff --git a/tests/Unit/Database/QueryGenerator/Postgres/HavingClauseTest.php b/tests/Unit/Database/QueryGenerator/Postgres/HavingClauseTest.php new file mode 100644 index 00000000..af9469fc --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/HavingClauseTest.php @@ -0,0 +1,142 @@ +select([ + Functions::count('products.id')->as('identifiers'), + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('identifiers', 5); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS identifiers, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "HAVING identifiers > ? GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([5]); +}); + +it('generates a query using having with many clauses', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + Functions::count('products.id')->as('identifiers'), + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('identifiers', 5) + ->whereGreaterThan('products.category_id', 10); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS identifiers, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "HAVING identifiers > ? AND products.category_id > ? GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([5, 10]); +}); + +it('generates a query using having with where clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + Functions::count('products.id')->as('product_count'), + 'products.category_id', + ]) + ->from('products') + ->whereEqual('products.status', 'active') + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('product_count', 3); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " + . "FROM products " + . "WHERE products.status = $1 " + . "HAVING product_count > ? GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', 3]); +}); + +it('generates a query using having with less than', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + Functions::sum('orders.total')->as('total_sales'), + 'orders.customer_id', + ]) + ->from('orders') + ->groupBy('orders.customer_id') + ->having(function (Having $having): void { + $having->whereLessThan('total_sales', 1000); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT SUM(orders.total) AS total_sales, orders.customer_id " + . "FROM orders " + . "HAVING total_sales < ? GROUP BY orders.customer_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1000]); +}); + +it('generates a query using having with equal', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + Functions::count('products.id')->as('product_count'), + 'products.category_id', + ]) + ->from('products') + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereEqual('product_count', 10); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " + . "FROM products " + . "HAVING product_count = ? GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([10]); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/HavingClauseTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/HavingClauseTest.php new file mode 100644 index 00000000..d026ea28 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/HavingClauseTest.php @@ -0,0 +1,142 @@ +select([ + Functions::count('products.id')->as('identifiers'), + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('identifiers', 5); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS identifiers, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "HAVING identifiers > ? GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([5]); +}); + +it('generates a query using having with many clauses', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + Functions::count('products.id')->as('identifiers'), + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('identifiers', 5) + ->whereGreaterThan('products.category_id', 10); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS identifiers, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "HAVING identifiers > ? AND products.category_id > ? GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([5, 10]); +}); + +it('generates a query using having with where clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + Functions::count('products.id')->as('product_count'), + 'products.category_id', + ]) + ->from('products') + ->whereEqual('products.status', 'active') + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('product_count', 3); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " + . "FROM products " + . "WHERE products.status = ? " + . "HAVING product_count > ? GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', 3]); +}); + +it('generates a query using having with less than', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + Functions::sum('orders.total')->as('total_sales'), + 'orders.customer_id', + ]) + ->from('orders') + ->groupBy('orders.customer_id') + ->having(function (Having $having): void { + $having->whereLessThan('total_sales', 1000); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT SUM(orders.total) AS total_sales, orders.customer_id " + . "FROM orders " + . "HAVING total_sales < ? GROUP BY orders.customer_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1000]); +}); + +it('generates a query using having with equal', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + Functions::count('products.id')->as('product_count'), + 'products.category_id', + ]) + ->from('products') + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereEqual('product_count', 10); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " + . "FROM products " + . "HAVING product_count = ? GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([10]); +}); From 1817ddc3d2eff8c4edef472130e65201c7829813 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 30 Dec 2025 19:34:18 +0000 Subject: [PATCH 20/49] feat: implement HasPlaceholders trait and update PostgreSQL compilers to use it for placeholder conversion --- .../Compilers/PostgresDeleteCompiler.php | 15 +++++++ .../Compilers/PostgresExistsCompiler.php | 17 +++++++- .../Compilers/PostgresInsertCompiler.php | 13 +----- .../Compilers/PostgresSelectCompiler.php | 14 ++++++ .../Compilers/PostgresUpdateCompiler.php | 12 ++++++ .../Compilers/PostgresWhereCompiler.php | 43 +++---------------- .../PostgreSQL/Concerns/HasPlaceholders.php | 21 +++++++++ 7 files changed, 85 insertions(+), 50 deletions(-) create mode 100644 src/Database/Dialects/PostgreSQL/Concerns/HasPlaceholders.php diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php index 0a8f9410..2381d738 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php @@ -4,14 +4,29 @@ namespace Phenix\Database\Dialects\PostgreSQL\Compilers; +use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\DeleteCompiler; +use Phenix\Database\Dialects\PostgreSQL\Concerns\HasPlaceholders; +use Phenix\Database\QueryAst; class PostgresDeleteCompiler extends DeleteCompiler { + use HasPlaceholders; + public function __construct() { $this->whereCompiler = new PostgresWhereCompiler(); } + public function compile(QueryAst $ast): CompiledClause + { + $result = parent::compile($ast); + + return new CompiledClause( + $this->convertPlaceholders($result->sql), + $result->params + ); + } + // TODO: Support RETURNING clause } diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresExistsCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresExistsCompiler.php index 7a343174..929df722 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresExistsCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresExistsCompiler.php @@ -4,12 +4,27 @@ namespace Phenix\Database\Dialects\PostgreSQL\Compilers; +use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\ExistsCompiler; +use Phenix\Database\Dialects\PostgreSQL\Concerns\HasPlaceholders; +use Phenix\Database\QueryAst; -final class PostgresExistsCompiler extends ExistsCompiler +class PostgresExistsCompiler extends ExistsCompiler { + use HasPlaceholders; + public function __construct() { $this->whereCompiler = new PostgresWhereCompiler(); } + + public function compile(QueryAst $ast): CompiledClause + { + $result = parent::compile($ast); + + return new CompiledClause( + $this->convertPlaceholders($result->sql), + $result->params + ); + } } diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php index ab9cf8a8..cec8e07a 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php @@ -6,6 +6,7 @@ use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\InsertCompiler; +use Phenix\Database\Dialects\PostgreSQL\Concerns\HasPlaceholders; use Phenix\Database\QueryAst; use Phenix\Util\Arr; @@ -16,17 +17,7 @@ */ class PostgresInsertCompiler extends InsertCompiler { - /** - * Convert ? placeholders to $n format for PostgreSQL - */ - protected function convertPlaceholders(string $sql): string - { - $index = 1; - - return preg_replace_callback('/\?/', function () use (&$index) { - return '$' . ($index++); - }, $sql); - } + use HasPlaceholders; protected function compileInsertIgnore(): string { diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php index 2bc00a25..190be795 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php @@ -5,16 +5,30 @@ namespace Phenix\Database\Dialects\PostgreSQL\Compilers; use Phenix\Database\Constants\Lock; +use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\SelectCompiler; +use Phenix\Database\Dialects\PostgreSQL\Concerns\HasPlaceholders; use Phenix\Database\QueryAst; final class PostgresSelectCompiler extends SelectCompiler { + use HasPlaceholders; + public function __construct() { $this->whereCompiler = new PostgresWhereCompiler(); } + public function compile(QueryAst $ast): CompiledClause + { + $result = parent::compile($ast); + + return new CompiledClause( + $this->convertPlaceholders($result->sql), + $result->params + ); + } + protected function compileLock(QueryAst $ast): string { if ($ast->lock === null) { diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php index e7633bfd..e1b4b412 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php @@ -4,10 +4,15 @@ namespace Phenix\Database\Dialects\PostgreSQL\Compilers; +use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\UpdateCompiler; +use Phenix\Database\Dialects\PostgreSQL\Concerns\HasPlaceholders; +use Phenix\Database\QueryAst; class PostgresUpdateCompiler extends UpdateCompiler { + use HasPlaceholders; + public function __construct() { $this->whereCompiler = new PostgresWhereCompiler(); @@ -18,5 +23,12 @@ protected function compileSetClause(string $column, int $paramIndex): string return "{$column} = $" . $paramIndex; } + public function compile(QueryAst $ast): CompiledClause + { + $result = parent::compile($ast); + + return new CompiledClause($this->convertPlaceholders($result->sql), $result->params); + } + // TODO: Support RETURNING clause } diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresWhereCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresWhereCompiler.php index 705138a6..d4adab4f 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresWhereCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresWhereCompiler.php @@ -14,15 +14,13 @@ use Phenix\Database\Clauses\WhereClause; use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; +use Phenix\Database\Constants\SQL; use Phenix\Database\Dialects\CompiledClause; -use function count; use function is_array; -final class PostgresWhereCompiler +class PostgresWhereCompiler { - private int $paramIndex = 0; - /** * @param array $wheres * @return CompiledClause @@ -33,7 +31,6 @@ public function compile(array $wheres): CompiledClause return new CompiledClause('', []); } - $this->paramIndex = 0; $sql = []; foreach ($wheres as $index => $where) { @@ -68,14 +65,12 @@ private function compileBasicClause(BasicWhereClause $clause): string $operator = $clause->getOperator(); if ($clause->isInOperator()) { - $placeholders = $this->generatePlaceholders($clause->getValueCount()); + $placeholders = str_repeat('?, ', $clause->getValueCount() - 1) . '?'; return "{$column} {$operator->value} ({$placeholders})"; } - $placeholder = $this->nextPlaceholder(); - - return "{$column} {$operator->value} {$placeholder}"; + return "{$column} {$operator->value} " . SQL::STD_PLACEHOLDER->value; } private function compileNullClause(NullWhereClause $clause): string @@ -92,10 +87,8 @@ private function compileBetweenClause(BetweenWhereClause $clause): string { $column = $clause->getColumn(); $operator = $clause->getOperator(); - $p1 = $this->nextPlaceholder(); - $p2 = $this->nextPlaceholder(); - return "{$column} {$operator->value} {$p1} AND {$p2}"; + return "{$column} {$operator->value} {$clause->renderValue()}"; } private function compileSubqueryClause(SubqueryWhereClause $clause): string @@ -116,9 +109,6 @@ private function compileSubqueryClause(SubqueryWhereClause $clause): string $parts[] = '(' . $clause->getSql() . ')'; } - // Update param index based on subquery params - $this->paramIndex += count($clause->getParams()); - return implode(' ', $parts); } @@ -141,27 +131,4 @@ private function compileRawClause(RawWhereClause $clause): string return implode(' ', $parts); } - - private function nextPlaceholder(): string - { - return '$' . (++$this->paramIndex); - } - - private function generatePlaceholders(int $count): string - { - $placeholders = []; - for ($i = 0; $i < $count; $i++) { - $placeholders[] = $this->nextPlaceholder(); - } - - return implode(', ', $placeholders); - } - - /** - * Set the starting parameter index (used when WHERE is not the first clause with params) - */ - public function setStartingParamIndex(int $index): void - { - $this->paramIndex = $index; - } } diff --git a/src/Database/Dialects/PostgreSQL/Concerns/HasPlaceholders.php b/src/Database/Dialects/PostgreSQL/Concerns/HasPlaceholders.php new file mode 100644 index 00000000..73cb1c26 --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Concerns/HasPlaceholders.php @@ -0,0 +1,21 @@ + Date: Tue, 30 Dec 2025 19:35:07 +0000 Subject: [PATCH 21/49] style: php cs --- .../QueryGenerator/Postgres/GroupByStatementTest.php | 8 ++++---- .../QueryGenerator/Sqlite/GroupByStatementTest.php | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/Unit/Database/QueryGenerator/Postgres/GroupByStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/GroupByStatementTest.php index 9a9b6b33..f4223bb5 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/GroupByStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/GroupByStatementTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -use Phenix\Database\Join; -use Phenix\Database\Having; +use Phenix\Database\Constants\Driver; use Phenix\Database\Functions; +use Phenix\Database\Having; +use Phenix\Database\Join; use Phenix\Database\QueryGenerator; -use Phenix\Database\Constants\Driver; it('generates a grouped query', function (Functions|string $column, Functions|array|string $groupBy, string $rawGroup): void { $query = new QueryGenerator(Driver::POSTGRESQL); @@ -115,7 +115,7 @@ $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " . "FROM products " - . "HAVING product_count > ? " + . "HAVING product_count > $1 " . "GROUP BY category_id"; expect($dml)->toBe($expected); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php index 051bbb69..784a5bb5 100644 --- a/tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -use Phenix\Database\Join; -use Phenix\Database\Having; +use Phenix\Database\Constants\Driver; use Phenix\Database\Functions; +use Phenix\Database\Having; +use Phenix\Database\Join; use Phenix\Database\QueryGenerator; -use Phenix\Database\Constants\Driver; it('generates a grouped query', function (Functions|string $column, Functions|array|string $groupBy, string $rawGroup): void { $query = new QueryGenerator(Driver::SQLITE); From bc8f5d0c4af0969e9750200b49b59946428f053c Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 30 Dec 2025 19:35:14 +0000 Subject: [PATCH 22/49] fix: update having clause parameter placeholders to use positional syntax --- .../QueryGenerator/Postgres/HavingClauseTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Unit/Database/QueryGenerator/Postgres/HavingClauseTest.php b/tests/Unit/Database/QueryGenerator/Postgres/HavingClauseTest.php index af9469fc..3a1ec837 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/HavingClauseTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/HavingClauseTest.php @@ -31,7 +31,7 @@ $expected = "SELECT COUNT(products.id) AS identifiers, products.category_id, categories.description " . "FROM products " . "LEFT JOIN categories ON products.category_id = categories.id " - . "HAVING identifiers > ? GROUP BY products.category_id"; + . "HAVING identifiers > $1 GROUP BY products.category_id"; expect($dml)->toBe($expected); expect($params)->toBe([5]); @@ -61,7 +61,7 @@ $expected = "SELECT COUNT(products.id) AS identifiers, products.category_id, categories.description " . "FROM products " . "LEFT JOIN categories ON products.category_id = categories.id " - . "HAVING identifiers > ? AND products.category_id > ? GROUP BY products.category_id"; + . "HAVING identifiers > $1 AND products.category_id > $2 GROUP BY products.category_id"; expect($dml)->toBe($expected); expect($params)->toBe([5, 10]); @@ -87,7 +87,7 @@ $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " . "FROM products " . "WHERE products.status = $1 " - . "HAVING product_count > ? GROUP BY products.category_id"; + . "HAVING product_count > $2 GROUP BY products.category_id"; expect($dml)->toBe($expected); expect($params)->toBe(['active', 3]); @@ -111,7 +111,7 @@ $expected = "SELECT SUM(orders.total) AS total_sales, orders.customer_id " . "FROM orders " - . "HAVING total_sales < ? GROUP BY orders.customer_id"; + . "HAVING total_sales < $1 GROUP BY orders.customer_id"; expect($dml)->toBe($expected); expect($params)->toBe([1000]); @@ -135,7 +135,7 @@ $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " . "FROM products " - . "HAVING product_count = ? GROUP BY products.category_id"; + . "HAVING product_count = $1 GROUP BY products.category_id"; expect($dml)->toBe($expected); expect($params)->toBe([10]); From 1adb2443663016224dc712cea08c16573ce63062 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 30 Dec 2025 19:36:12 +0000 Subject: [PATCH 23/49] tests: SQLite and Postgre support for join clause query generation --- .../Postgres/JoinClausesTest.php | 201 ++++++++++++++++++ .../QueryGenerator/Sqlite/JoinClausesTest.php | 201 ++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 tests/Unit/Database/QueryGenerator/Postgres/JoinClausesTest.php create mode 100644 tests/Unit/Database/QueryGenerator/Sqlite/JoinClausesTest.php diff --git a/tests/Unit/Database/QueryGenerator/Postgres/JoinClausesTest.php b/tests/Unit/Database/QueryGenerator/Postgres/JoinClausesTest.php new file mode 100644 index 00000000..ecfdc834 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/JoinClausesTest.php @@ -0,0 +1,201 @@ +select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->{$method}('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "{$joinType} categories " + . "ON products.category_id = categories.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['innerJoin', JoinType::INNER->value], + ['leftJoin', JoinType::LEFT->value], + ['leftOuterJoin', JoinType::LEFT_OUTER->value], + ['rightJoin', JoinType::RIGHT->value], + ['rightOuterJoin', JoinType::RIGHT_OUTER->value], + ['crossJoin', JoinType::CROSS->value], +]); + +it('generates query using join with distinct clasue', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->innerJoin('categories', function (Join $join) { + $join->onNotEqual('products.category_id', 'categories.id'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "INNER JOIN categories " + . "ON products.category_id != categories.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with join and multi clauses', function ( + string $chainingMethod, + array $arguments, + string $clause, + array|null $joinParams +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->innerJoin('categories', function (Join $join) use ($chainingMethod, $arguments) { + $join->onEqual('products.category_id', 'categories.id') + ->$chainingMethod(...$arguments); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "INNER JOIN categories " + . "ON products.category_id = categories.id {$clause}"; + + expect($dml)->toBe($expected); + expect($params)->toBe($joinParams); +})->with([ + [ + 'orOnEqual', + ['products.location_id', 'categories.location_id'], + 'OR products.location_id = categories.location_id', + [], + ], + [ + 'whereEqual', + ['categories.name', 'php'], + 'AND categories.name = $1', + ['php'], + ], + [ + 'orOnNotEqual', + ['products.location_id', 'categories.location_id'], + 'OR products.location_id != categories.location_id', + [], + ], + [ + 'orWhereEqual', + ['categories.name', 'php'], + 'OR categories.name = $1', + ['php'], + ], +]); + +it('generates query with shortcut methods for all join types', function (string $method, string $joinType) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->{$method}('categories', 'products.category_id', 'categories.id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "{$joinType} categories " + . "ON products.category_id = categories.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['innerJoinOnEqual', JoinType::INNER->value], + ['leftJoinOnEqual', JoinType::LEFT->value], + ['rightJoinOnEqual', JoinType::RIGHT->value], +]); + +it('generates query with multiple joins', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'products.id', + 'categories.name', + 'suppliers.name', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->leftJoin('suppliers', function (Join $join) { + $join->onEqual('products.supplier_id', 'suppliers.id'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, categories.name, suppliers.name " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "LEFT JOIN suppliers ON products.supplier_id = suppliers.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with join and where clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'products.id', + 'categories.name', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->whereEqual('products.status', 'active') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, categories.name " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "WHERE products.status = $1"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active']); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/JoinClausesTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/JoinClausesTest.php new file mode 100644 index 00000000..eab97eb1 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/JoinClausesTest.php @@ -0,0 +1,201 @@ +select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->{$method}('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "{$joinType} categories " + . "ON products.category_id = categories.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['innerJoin', JoinType::INNER->value], + ['leftJoin', JoinType::LEFT->value], + ['leftOuterJoin', JoinType::LEFT_OUTER->value], + ['rightJoin', JoinType::RIGHT->value], + ['rightOuterJoin', JoinType::RIGHT_OUTER->value], + ['crossJoin', JoinType::CROSS->value], +]); + +it('generates query using join with distinct clasue', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->innerJoin('categories', function (Join $join) { + $join->onNotEqual('products.category_id', 'categories.id'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "INNER JOIN categories " + . "ON products.category_id != categories.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with join and multi clauses', function ( + string $chainingMethod, + array $arguments, + string $clause, + array|null $joinParams +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->innerJoin('categories', function (Join $join) use ($chainingMethod, $arguments) { + $join->onEqual('products.category_id', 'categories.id') + ->$chainingMethod(...$arguments); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "INNER JOIN categories " + . "ON products.category_id = categories.id {$clause}"; + + expect($dml)->toBe($expected); + expect($params)->toBe($joinParams); +})->with([ + [ + 'orOnEqual', + ['products.location_id', 'categories.location_id'], + 'OR products.location_id = categories.location_id', + [], + ], + [ + 'whereEqual', + ['categories.name', 'php'], + 'AND categories.name = ?', + ['php'], + ], + [ + 'orOnNotEqual', + ['products.location_id', 'categories.location_id'], + 'OR products.location_id != categories.location_id', + [], + ], + [ + 'orWhereEqual', + ['categories.name', 'php'], + 'OR categories.name = ?', + ['php'], + ], +]); + +it('generates query with shortcut methods for all join types', function (string $method, string $joinType) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->{$method}('categories', 'products.category_id', 'categories.id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "{$joinType} categories " + . "ON products.category_id = categories.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['innerJoinOnEqual', JoinType::INNER->value], + ['leftJoinOnEqual', JoinType::LEFT->value], + ['rightJoinOnEqual', JoinType::RIGHT->value], +]); + +it('generates query with multiple joins', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'products.id', + 'categories.name', + 'suppliers.name', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->leftJoin('suppliers', function (Join $join) { + $join->onEqual('products.supplier_id', 'suppliers.id'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, categories.name, suppliers.name " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "LEFT JOIN suppliers ON products.supplier_id = suppliers.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with join and where clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'products.id', + 'categories.name', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->whereEqual('products.status', 'active') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, categories.name " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "WHERE products.status = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active']); +}); From db67a0e29b410b2072f11d3f7a966cde3af8390a Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 30 Dec 2025 23:21:53 +0000 Subject: [PATCH 24/49] tests: add SQLite pagination query generation tests --- .../QueryGenerator/Postgres/PaginateTest.php | 88 +++++++++++++++++++ .../QueryGenerator/Sqlite/PaginateTest.php | 88 +++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 tests/Unit/Database/QueryGenerator/Postgres/PaginateTest.php create mode 100644 tests/Unit/Database/QueryGenerator/Sqlite/PaginateTest.php diff --git a/tests/Unit/Database/QueryGenerator/Postgres/PaginateTest.php b/tests/Unit/Database/QueryGenerator/Postgres/PaginateTest.php new file mode 100644 index 00000000..6f782397 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/PaginateTest.php @@ -0,0 +1,88 @@ +table('users') + ->page() + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 0'); + expect($params)->toBeEmpty(); +}); + +it('generates offset pagination query with indicate page', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->page(3) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 30'); + expect($params)->toBeEmpty(); +}); + +it('overwrites limit when pagination is called', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->limit(5) + ->page(2) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 15'); + expect($params)->toBeEmpty(); +}); + +it('generates pagination query with where clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('status', 'active') + ->page(2) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users WHERE status = $1 LIMIT 15 OFFSET 15'); + expect($params)->toBe(['active']); +}); + +it('generates pagination query with order by', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->orderBy('created_at', Order::ASC) + ->page(1) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users ORDER BY created_at ASC LIMIT 15 OFFSET 0'); + expect($params)->toBeEmpty(); +}); + +it('generates pagination with custom per page', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->page(2, 25) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 25 OFFSET 25'); + expect($params)->toBeEmpty(); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/PaginateTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/PaginateTest.php new file mode 100644 index 00000000..0b67a705 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/PaginateTest.php @@ -0,0 +1,88 @@ +table('users') + ->page() + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 0'); + expect($params)->toBeEmpty(); +}); + +it('generates offset pagination query with indicate page', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->page(3) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 30'); + expect($params)->toBeEmpty(); +}); + +it('overwrites limit when pagination is called', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->limit(5) + ->page(2) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 15'); + expect($params)->toBeEmpty(); +}); + +it('generates pagination query with where clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('status', 'active') + ->page(2) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users WHERE status = ? LIMIT 15 OFFSET 15'); + expect($params)->toBe(['active']); +}); + +it('generates pagination query with order by', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->orderBy('created_at', Order::ASC) + ->page(1) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users ORDER BY created_at ASC LIMIT 15 OFFSET 0'); + expect($params)->toBeEmpty(); +}); + +it('generates pagination with custom per page', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->page(2, 25) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 25 OFFSET 25'); + expect($params)->toBeEmpty(); +}); From 9f7ba6cb6d6353b40d1c2f464b34286b609019b2 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 00:22:38 +0000 Subject: [PATCH 25/49] fix: update placeholder conversion to include parameter count in PostgreSQL compiler --- .../PostgreSQL/Compilers/PostgresUpdateCompiler.php | 9 ++++++++- .../Dialects/PostgreSQL/Concerns/HasPlaceholders.php | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php index e1b4b412..ad117617 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php @@ -9,6 +9,8 @@ use Phenix\Database\Dialects\PostgreSQL\Concerns\HasPlaceholders; use Phenix\Database\QueryAst; +use function count; + class PostgresUpdateCompiler extends UpdateCompiler { use HasPlaceholders; @@ -27,7 +29,12 @@ public function compile(QueryAst $ast): CompiledClause { $result = parent::compile($ast); - return new CompiledClause($this->convertPlaceholders($result->sql), $result->params); + $paramsCount = count($ast->values); + + return new CompiledClause( + $this->convertPlaceholders($result->sql, $paramsCount), + $result->params + ); } // TODO: Support RETURNING clause diff --git a/src/Database/Dialects/PostgreSQL/Concerns/HasPlaceholders.php b/src/Database/Dialects/PostgreSQL/Concerns/HasPlaceholders.php index 73cb1c26..23bfd18e 100644 --- a/src/Database/Dialects/PostgreSQL/Concerns/HasPlaceholders.php +++ b/src/Database/Dialects/PostgreSQL/Concerns/HasPlaceholders.php @@ -6,9 +6,9 @@ trait HasPlaceholders { - protected function convertPlaceholders(string $sql): string + protected function convertPlaceholders(string $sql, int $startIndex = 0): string { - $index = 1; + $index = $startIndex + 1; return preg_replace_callback( '/\?/', From 27648934d582e932f711979fb1b65ba0823a2868 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 00:22:51 +0000 Subject: [PATCH 26/49] tests: add SQLite support for update statement generation --- .../Postgres/UpdateStatementTest.php | 146 ++++++++++++++++++ .../Sqlite/UpdateStatementTest.php | 146 ++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php create mode 100644 tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php diff --git a/tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php new file mode 100644 index 00000000..1d6804da --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php @@ -0,0 +1,146 @@ +name; + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->update(['name' => $name]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = $1 WHERE id = $2"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, 1]); +}); + +it('generates update statement with many conditions and columns', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $name = faker()->name; + + $sql = $query->table('users') + ->whereNotNull('verified_at') + ->whereEqual('role_id', 2) + ->update(['name' => $name, 'active' => true]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = $1, active = $2 WHERE verified_at IS NOT NULL AND role_id = $3"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, true, 2]); +}); + +it('generates update statement with single column', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('id', 5) + ->update(['status' => 'inactive']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET status = $1 WHERE id = $2"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', 5]); +}); + +it('generates update statement with where in clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereIn('id', [1, 2, 3]) + ->update(['status' => 'active']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET status = $1 WHERE id IN ($2, $3, $4)"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', 1, 2, 3]); +}); + +it('generates update statement with multiple where clauses', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->whereEqual('status', 'pending') + ->whereGreaterThan('created_at', '2024-01-01') + ->update(['email' => $email, 'verified' => true]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET email = $1, verified = $2 WHERE status = $3 AND created_at > $4"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, true, 'pending', '2024-01-01']); +}); + +it('generates update statement with where not equal', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereNotEqual('role', 'admin') + ->update(['access_level' => 1]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET access_level = $1 WHERE role != $2"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1, 'admin']); +}); + +it('generates update statement with where null', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereNull('deleted_at') + ->update(['last_login' => '2024-12-30']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET last_login = $1 WHERE deleted_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2024-12-30']); +}); + +it('generates update statement with multiple columns and complex where', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->whereEqual('status', 'active') + ->whereNotNull('email_verified_at') + ->whereLessThan('login_count', 5) + ->update([ + 'name' => $name, + 'email' => $email, + 'updated_at' => '2024-12-30', + ]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = $1, email = $2, updated_at = $3 " + . "WHERE status = $4 AND email_verified_at IS NOT NULL AND login_count < $5"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, $email, '2024-12-30', 'active', 5]); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php new file mode 100644 index 00000000..860040fe --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php @@ -0,0 +1,146 @@ +name; + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->update(['name' => $name]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = ? WHERE id = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, 1]); +}); + +it('generates update statement with many conditions and columns', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $name = faker()->name; + + $sql = $query->table('users') + ->whereNotNull('verified_at') + ->whereEqual('role_id', 2) + ->update(['name' => $name, 'active' => true]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = ?, active = ? WHERE verified_at IS NOT NULL AND role_id = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, true, 2]); +}); + +it('generates update statement with single column', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('id', 5) + ->update(['status' => 'inactive']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET status = ? WHERE id = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', 5]); +}); + +it('generates update statement with where in clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereIn('id', [1, 2, 3]) + ->update(['status' => 'active']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET status = ? WHERE id IN (?, ?, ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', 1, 2, 3]); +}); + +it('generates update statement with multiple where clauses', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->whereEqual('status', 'pending') + ->whereGreaterThan('created_at', '2024-01-01') + ->update(['email' => $email, 'verified' => true]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET email = ?, verified = ? WHERE status = ? AND created_at > ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, true, 'pending', '2024-01-01']); +}); + +it('generates update statement with where not equal', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereNotEqual('role', 'admin') + ->update(['access_level' => 1]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET access_level = ? WHERE role != ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1, 'admin']); +}); + +it('generates update statement with where null', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereNull('deleted_at') + ->update(['last_login' => '2024-12-30']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET last_login = ? WHERE deleted_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2024-12-30']); +}); + +it('generates update statement with multiple columns and complex where', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->whereEqual('status', 'active') + ->whereNotNull('email_verified_at') + ->whereLessThan('login_count', 5) + ->update([ + 'name' => $name, + 'email' => $email, + 'updated_at' => '2024-12-30', + ]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = ?, email = ?, updated_at = ? " + . "WHERE status = ? AND email_verified_at IS NOT NULL AND login_count < ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, $email, '2024-12-30', 'active', 5]); +}); From 35219db1a8a1770e5f37bd625074cc86ed70b135 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 03:51:08 +0000 Subject: [PATCH 27/49] fix: update subquery clause compilation to use correct SQL method --- .../Dialects/SQLite/Compilers/SqliteWhereCompiler.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php b/src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php index 0da5251b..0fd11098 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php @@ -98,13 +98,12 @@ private function compileSubqueryClause(SubqueryWhereClause $clause): string } $parts[] = $clause->getOperator()->value; - + if ($clause->getSubqueryOperator() !== null) { // For ANY/ALL/SOME, no space between operator and subquery - $parts[] = $clause->getSubqueryOperator()->value . '(' . $clause->getSubquerySql() . ')'; + $parts[] = $clause->getSubqueryOperator()->value . '(' . $clause->getSql() . ')'; } else { - // For regular subqueries, add space - $parts[] = '(' . $clause->getSubquerySql() . ')'; + $parts[] = '(' . $clause->getSql() . ')'; } return implode(' ', $parts); From 5e2c8a1c150c1efacc671008c8df827028074a78 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 03:51:28 +0000 Subject: [PATCH 28/49] feat: add SQLite support for where clause query generation tests --- .../Postgres/WhereClausesTest.php | 543 ++++++++++++++++++ .../Sqlite/WhereClausesTest.php | 540 +++++++++++++++++ 2 files changed, 1083 insertions(+) create mode 100644 tests/Unit/Database/QueryGenerator/Postgres/WhereClausesTest.php create mode 100644 tests/Unit/Database/QueryGenerator/Sqlite/WhereClausesTest.php diff --git a/tests/Unit/Database/QueryGenerator/Postgres/WhereClausesTest.php b/tests/Unit/Database/QueryGenerator/Postgres/WhereClausesTest.php new file mode 100644 index 00000000..4087629f --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/WhereClausesTest.php @@ -0,0 +1,543 @@ +table('users') + ->whereEqual('id', 1) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users WHERE id = $1'); + expect($params)->toBe([1]); +}); + +it('generates query to select a record using many clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('username', 'john') + ->whereEqual('email', 'john@mail.com') + ->whereEqual('document', 123456) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users WHERE username = $1 AND email = $2 AND document = $3'); + expect($params)->toBe(['john', 'john@mail.com', 123456]); +}); + +it('generates query to select using comparison clause', function ( + string $method, + string $column, + string $operator, + string|int $value +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}($column, $value) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE {$column} {$operator} $1"); + expect($params)->toBe([$value]); +})->with([ + ['whereNotEqual', 'id', Operator::NOT_EQUAL->value, 1], + ['whereGreaterThan', 'id', Operator::GREATER_THAN->value, 1], + ['whereGreaterThanOrEqual', 'id', Operator::GREATER_THAN_OR_EQUAL->value, 1], + ['whereLessThan', 'id', Operator::LESS_THAN->value, 1], + ['whereLessThanOrEqual', 'id', Operator::LESS_THAN_OR_EQUAL->value, 1], +]); + +it('generates query selecting specific columns', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->select(['id', 'name', 'email']) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT id, name, email FROM users WHERE id = $1'); + expect($params)->toBe([1]); +}); + + +it('generates query using in and not in operators', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}('id', [1, 2, 3]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE id {$operator} ($1, $2, $3)"); + expect($params)->toBe([1, 2, 3]); +})->with([ + ['whereIn', Operator::IN->value], + ['whereNotIn', Operator::NOT_IN->value], +]); + +it('generates query using in and not in operators with subquery', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}('id', function (Subquery $query) { + $query->select(['id']) + ->from('users') + ->whereGreaterThanOrEqual('created_at', date('Y-m-d')); + }) + ->get(); + + [$dml, $params] = $sql; + + $date = date('Y-m-d'); + + $expected = "SELECT * FROM users WHERE id {$operator} " + . "(SELECT id FROM users WHERE created_at >= $1)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$date]); +})->with([ + ['whereIn', Operator::IN->value], + ['whereNotIn', Operator::NOT_IN->value], +]); + +it('generates query to select null or not null columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}('verified_at') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE verified_at {$operator}"); + expect($params)->toBe([]); +})->with([ + ['whereNull', Operator::IS_NULL->value], + ['whereNotNull', Operator::IS_NOT_NULL->value], +]); + +it('generates query to select by column or null or not null columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->{$method}('verified_at') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > $1 OR verified_at {$operator}"); + expect($params)->toBe([$date]); +})->with([ + ['orWhereNull', Operator::IS_NULL->value], + ['orWhereNotNull', Operator::IS_NOT_NULL->value], +]); + +it('generates query to select boolean columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}('enabled') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE enabled {$operator}"); + expect($params)->toBe([]); +})->with([ + ['whereTrue', Operator::IS_TRUE->value], + ['whereFalse', Operator::IS_FALSE->value], +]); + +it('generates query to select by column or boolean column', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->{$method}('enabled') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > $1 OR enabled {$operator}"); + expect($params)->toBe([$date]); +})->with([ + ['orWhereTrue', Operator::IS_TRUE->value], + ['orWhereFalse', Operator::IS_FALSE->value], +]); + +it('generates query using logical connectors', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereNotNull('verified_at') + ->whereGreaterThan('created_at', $date) + ->orWhereLessThan('updated_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE verified_at IS NOT NULL AND created_at > $1 OR updated_at < $2"); + expect($params)->toBe([$date, $date]); +}); + +it('generates query using the or operator between the and operators', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->orWhereLessThan('updated_at', $date) + ->whereNotNull('verified_at') + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > $1 OR updated_at < $2 AND verified_at IS NOT NULL"); + expect($params)->toBe([$date, $date]); +}); + +it('generates queries using logical connectors', function ( + string $method, + string $column, + array|string $value, + string $operator +) { + $placeholders = '$1'; + + if (\is_array($value)) { + $params = []; + for ($i = 1; $i <= count($value); $i++) { + $params[] = '$' . $i; + } + + $placeholders = '(' . implode(', ', $params) . ')'; + } + + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereNotNull('verified_at') + ->{$method}($column, $value) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE verified_at IS NOT NULL OR {$column} {$operator} {$placeholders}"); + expect($params)->toBe([...(array)$value]); +})->with([ + ['orWhereLessThan', 'updated_at', date('Y-m-d'), Operator::LESS_THAN->value], + ['orWhereEqual', 'updated_at', date('Y-m-d'), Operator::EQUAL->value], + ['orWhereNotEqual', 'updated_at', date('Y-m-d'), Operator::NOT_EQUAL->value], + ['orWhereGreaterThan', 'updated_at', date('Y-m-d'), Operator::GREATER_THAN->value], + ['orWhereGreaterThanOrEqual', 'updated_at', date('Y-m-d'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereLessThan', 'updated_at', date('Y-m-d'), Operator::LESS_THAN->value], + ['orWhereLessThanOrEqual', 'updated_at', date('Y-m-d'), Operator::LESS_THAN_OR_EQUAL->value], + ['orWhereIn', 'status', ['enabled', 'verified'], Operator::IN->value], + ['orWhereNotIn', 'status', ['disabled', 'banned'], Operator::NOT_IN->value], +]); + +it('generates query to select between columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}('age', [20, 30]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE age {$operator} $1 AND $2"); + expect($params)->toBe([20, 30]); +})->with([ + ['whereBetween', Operator::BETWEEN->value], + ['whereNotBetween', Operator::NOT_BETWEEN->value], +]); + +it('generates query to select by column or between columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $date = date('Y-m-d'); + $startDate = date('Y-m-d'); + $endDate = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->{$method}('updated_at', [$startDate, $endDate]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > $1 OR updated_at {$operator} $2 AND $3"); + expect($params)->toBe([$date, $startDate, $endDate]); +})->with([ + ['orWhereBetween', Operator::BETWEEN->value], + ['orWhereNotBetween', Operator::NOT_BETWEEN->value], +]); + +it('generates a column-ordered query', function (array|string $column, string $order) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->orderBy($column, Order::from($order)) + ->get(); + + [$dml, $params] = $sql; + + $operator = Operator::ORDER_BY->value; + + $column = implode(', ', (array) $column); + + expect($dml)->toBe("SELECT * FROM users {$operator} {$column} {$order}"); + expect($params)->toBe($params); +})->with([ + ['id', Order::ASC->value], + [['id', 'created_at'], Order::ASC->value], + ['id', Order::DESC->value], + [['id', 'created_at'], Order::DESC->value], +]); + +it('generates a column-ordered query using select-case', function () { + $case = Functions::case() + ->whenNull('city', 'country') + ->defaultResult('city'); + + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->orderBy($case, Order::ASC) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users ORDER BY (CASE WHEN city IS NULL THEN country ELSE city END) ASC"); + expect($params)->toBe($params); +}); + +it('generates a limited query', function (array|string $column, string $order) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->orderBy($column, Order::from($order)) + ->limit(1) + ->get(); + + [$dml, $params] = $sql; + + $operator = Operator::ORDER_BY->value; + + $column = implode(', ', (array) $column); + + expect($dml)->toBe("SELECT * FROM users WHERE id = $1 {$operator} {$column} {$order} LIMIT 1"); + expect($params)->toBe([1]); +})->with([ + ['id', Order::ASC->value], + [['id', 'created_at'], Order::ASC->value], + ['id', Order::DESC->value], + [['id', 'created_at'], Order::DESC->value], +]); + +it('generates a query with a exists subquery in where clause', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}(function (Subquery $query) { + $query->table('user_role') + ->whereEqual('user_id', 1) + ->whereEqual('role_id', 9) + ->limit(1); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM users WHERE {$operator} " + . "(SELECT * FROM user_role WHERE user_id = $1 AND role_id = $2 LIMIT 1)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1, 9]); +})->with([ + ['whereExists', Operator::EXISTS->value], + ['whereNotExists', Operator::NOT_EXISTS->value], +]); + +it('generates a query to select by column or when exists or not exists subquery', function ( + string $method, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereTrue('is_admin') + ->{$method}(function (Subquery $query) { + $query->table('user_role') + ->whereEqual('user_id', 1) + ->limit(1); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM users WHERE is_admin IS TRUE OR {$operator} " + . "(SELECT * FROM user_role WHERE user_id = $1 LIMIT 1)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +})->with([ + ['orWhereExists', Operator::EXISTS->value], + ['orWhereNotExists', Operator::NOT_EXISTS->value], +]); + +it('generates query to select using comparison clause with subqueries and functions', function ( + string $method, + string $column, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('products') + ->{$method}($column, function (Subquery $subquery) { + $subquery->select([Functions::max('price')])->from('products'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM products WHERE {$column} {$operator} " + . '(SELECT ' . Functions::max('price') . ' FROM products)'; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['whereEqual', 'price', Operator::EQUAL->value], + ['whereNotEqual', 'price', Operator::NOT_EQUAL->value], + ['whereGreaterThan', 'price', Operator::GREATER_THAN->value], + ['whereGreaterThanOrEqual', 'price', Operator::GREATER_THAN_OR_EQUAL->value], + ['whereLessThan', 'price', Operator::LESS_THAN->value], + ['whereLessThanOrEqual', 'price', Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query using comparison clause with subqueries and any, all, some operators', function ( + string $method, + string $comparisonOperator, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('products') + ->{$method}('id', function (Subquery $subquery) { + $subquery->select(['product_id']) + ->from('orders') + ->whereGreaterThan('quantity', 10); + }) + ->select(['description']) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT description FROM products WHERE id {$comparisonOperator} {$operator}" + . "(SELECT product_id FROM orders WHERE quantity > $1)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([10]); +})->with([ + ['whereAnyEqual', Operator::EQUAL->value, Operator::ANY->value], + ['whereAnyNotEqual', Operator::NOT_EQUAL->value, Operator::ANY->value], + ['whereAnyGreaterThan', Operator::GREATER_THAN->value, Operator::ANY->value], + ['whereAnyGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::ANY->value], + ['whereAnyLessThan', Operator::LESS_THAN->value, Operator::ANY->value], + ['whereAnyLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::ANY->value], + + ['whereAllEqual', Operator::EQUAL->value, Operator::ALL->value], + ['whereAllNotEqual', Operator::NOT_EQUAL->value, Operator::ALL->value], + ['whereAllGreaterThan', Operator::GREATER_THAN->value, Operator::ALL->value], + ['whereAllGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::ALL->value], + ['whereAllLessThan', Operator::LESS_THAN->value, Operator::ALL->value], + ['whereAllLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::ALL->value], + + ['whereSomeEqual', Operator::EQUAL->value, Operator::SOME->value], + ['whereSomeNotEqual', Operator::NOT_EQUAL->value, Operator::SOME->value], + ['whereSomeGreaterThan', Operator::GREATER_THAN->value, Operator::SOME->value], + ['whereSomeGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::SOME->value], + ['whereSomeLessThan', Operator::LESS_THAN->value, Operator::SOME->value], + ['whereSomeLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::SOME->value], +]); + +it('generates query with row subquery', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('employees') + ->{$method}(['manager_id', 'department_id'], function (Subquery $subquery) { + $subquery->select(['id, department_id']) + ->from('managers') + ->whereEqual('location_id', 1); + }) + ->select(['name']) + ->get(); + + [$dml, $params] = $sql; + + $subquery = 'SELECT id, department_id FROM managers WHERE location_id = $1'; + + $expected = "SELECT name FROM employees " + . "WHERE ROW(manager_id, department_id) {$operator} ({$subquery})"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +})->with([ + ['whereRowEqual', Operator::EQUAL->value], + ['whereRowNotEqual', Operator::NOT_EQUAL->value], + ['whereRowGreaterThan', Operator::GREATER_THAN->value], + ['whereRowGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value], + ['whereRowLessThan', Operator::LESS_THAN->value], + ['whereRowLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value], + ['whereRowIn', Operator::IN->value], + ['whereRowNotIn', Operator::NOT_IN->value], +]); + +it('clone query generator successfully', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $queryBuilder = $query->table('users') + ->whereEqual('id', 1) + ->lockForUpdate(); + + $cloned = clone $queryBuilder; + + expect($cloned)->toBeInstanceOf(QueryGenerator::class); + expect($cloned->isLocked())->toBeFalse(); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/WhereClausesTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/WhereClausesTest.php new file mode 100644 index 00000000..b75fff0c --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/WhereClausesTest.php @@ -0,0 +1,540 @@ +table('users') + ->whereEqual('id', 1) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users WHERE id = ?'); + expect($params)->toBe([1]); +}); + +it('generates query to select a record using many clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('username', 'john') + ->whereEqual('email', 'john@mail.com') + ->whereEqual('document', 123456) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users WHERE username = ? AND email = ? AND document = ?'); + expect($params)->toBe(['john', 'john@mail.com', 123456]); +}); + +it('generates query to select using comparison clause', function ( + string $method, + string $column, + string $operator, + string|int $value +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}($column, $value) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE {$column} {$operator} ?"); + expect($params)->toBe([$value]); +})->with([ + ['whereNotEqual', 'id', Operator::NOT_EQUAL->value, 1], + ['whereGreaterThan', 'id', Operator::GREATER_THAN->value, 1], + ['whereGreaterThanOrEqual', 'id', Operator::GREATER_THAN_OR_EQUAL->value, 1], + ['whereLessThan', 'id', Operator::LESS_THAN->value, 1], + ['whereLessThanOrEqual', 'id', Operator::LESS_THAN_OR_EQUAL->value, 1], +]); + +it('generates query selecting specific columns', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->select(['id', 'name', 'email']) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT id, name, email FROM users WHERE id = ?'); + expect($params)->toBe([1]); +}); + + +it('generates query using in and not in operators', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}('id', [1, 2, 3]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE id {$operator} (?, ?, ?)"); + expect($params)->toBe([1, 2, 3]); +})->with([ + ['whereIn', Operator::IN->value], + ['whereNotIn', Operator::NOT_IN->value], +]); + +it('generates query using in and not in operators with subquery', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}('id', function (Subquery $query) { + $query->select(['id']) + ->from('users') + ->whereGreaterThanOrEqual('created_at', date('Y-m-d')); + }) + ->get(); + + [$dml, $params] = $sql; + + $date = date('Y-m-d'); + + $expected = "SELECT * FROM users WHERE id {$operator} " + . "(SELECT id FROM users WHERE created_at >= ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$date]); +})->with([ + ['whereIn', Operator::IN->value], + ['whereNotIn', Operator::NOT_IN->value], +]); + +it('generates query to select null or not null columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}('verified_at') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE verified_at {$operator}"); + expect($params)->toBe([]); +})->with([ + ['whereNull', Operator::IS_NULL->value], + ['whereNotNull', Operator::IS_NOT_NULL->value], +]); + +it('generates query to select by column or null or not null columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->{$method}('verified_at') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR verified_at {$operator}"); + expect($params)->toBe([$date]); +})->with([ + ['orWhereNull', Operator::IS_NULL->value], + ['orWhereNotNull', Operator::IS_NOT_NULL->value], +]); + +it('generates query to select boolean columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}('enabled') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE enabled {$operator}"); + expect($params)->toBe([]); +})->with([ + ['whereTrue', Operator::IS_TRUE->value], + ['whereFalse', Operator::IS_FALSE->value], +]); + +it('generates query to select by column or boolean column', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->{$method}('enabled') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR enabled {$operator}"); + expect($params)->toBe([$date]); +})->with([ + ['orWhereTrue', Operator::IS_TRUE->value], + ['orWhereFalse', Operator::IS_FALSE->value], +]); + +it('generates query using logical connectors', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereNotNull('verified_at') + ->whereGreaterThan('created_at', $date) + ->orWhereLessThan('updated_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE verified_at IS NOT NULL AND created_at > ? OR updated_at < ?"); + expect($params)->toBe([$date, $date]); +}); + +it('generates query using the or operator between the and operators', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->orWhereLessThan('updated_at', $date) + ->whereNotNull('verified_at') + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR updated_at < ? AND verified_at IS NOT NULL"); + expect($params)->toBe([$date, $date]); +}); + +it('generates queries using logical connectors', function ( + string $method, + string $column, + array|string $value, + string $operator +) { + $placeholders = '?'; + + if (\is_array($value)) { + $params = array_pad([], count($value), '?'); + + $placeholders = '(' . implode(', ', $params) . ')'; + } + + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereNotNull('verified_at') + ->{$method}($column, $value) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE verified_at IS NOT NULL OR {$column} {$operator} {$placeholders}"); + expect($params)->toBe([...(array)$value]); +})->with([ + ['orWhereLessThan', 'updated_at', date('Y-m-d'), Operator::LESS_THAN->value], + ['orWhereEqual', 'updated_at', date('Y-m-d'), Operator::EQUAL->value], + ['orWhereNotEqual', 'updated_at', date('Y-m-d'), Operator::NOT_EQUAL->value], + ['orWhereGreaterThan', 'updated_at', date('Y-m-d'), Operator::GREATER_THAN->value], + ['orWhereGreaterThanOrEqual', 'updated_at', date('Y-m-d'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereLessThan', 'updated_at', date('Y-m-d'), Operator::LESS_THAN->value], + ['orWhereLessThanOrEqual', 'updated_at', date('Y-m-d'), Operator::LESS_THAN_OR_EQUAL->value], + ['orWhereIn', 'status', ['enabled', 'verified'], Operator::IN->value], + ['orWhereNotIn', 'status', ['disabled', 'banned'], Operator::NOT_IN->value], +]); + +it('generates query to select between columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}('age', [20, 30]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE age {$operator} ? AND ?"); + expect($params)->toBe([20, 30]); +})->with([ + ['whereBetween', Operator::BETWEEN->value], + ['whereNotBetween', Operator::NOT_BETWEEN->value], +]); + +it('generates query to select by column or between columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $date = date('Y-m-d'); + $startDate = date('Y-m-d'); + $endDate = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->{$method}('updated_at', [$startDate, $endDate]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR updated_at {$operator} ? AND ?"); + expect($params)->toBe([$date, $startDate, $endDate]); +})->with([ + ['orWhereBetween', Operator::BETWEEN->value], + ['orWhereNotBetween', Operator::NOT_BETWEEN->value], +]); + +it('generates a column-ordered query', function (array|string $column, string $order) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->orderBy($column, Order::from($order)) + ->get(); + + [$dml, $params] = $sql; + + $operator = Operator::ORDER_BY->value; + + $column = implode(', ', (array) $column); + + expect($dml)->toBe("SELECT * FROM users {$operator} {$column} {$order}"); + expect($params)->toBe($params); +})->with([ + ['id', Order::ASC->value], + [['id', 'created_at'], Order::ASC->value], + ['id', Order::DESC->value], + [['id', 'created_at'], Order::DESC->value], +]); + +it('generates a column-ordered query using select-case', function () { + $case = Functions::case() + ->whenNull('city', 'country') + ->defaultResult('city'); + + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->orderBy($case, Order::ASC) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users ORDER BY (CASE WHEN city IS NULL THEN country ELSE city END) ASC"); + expect($params)->toBe($params); +}); + +it('generates a limited query', function (array|string $column, string $order) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->orderBy($column, Order::from($order)) + ->limit(1) + ->get(); + + [$dml, $params] = $sql; + + $operator = Operator::ORDER_BY->value; + + $column = implode(', ', (array) $column); + + expect($dml)->toBe("SELECT * FROM users WHERE id = ? {$operator} {$column} {$order} LIMIT 1"); + expect($params)->toBe([1]); +})->with([ + ['id', Order::ASC->value], + [['id', 'created_at'], Order::ASC->value], + ['id', Order::DESC->value], + [['id', 'created_at'], Order::DESC->value], +]); + +it('generates a query with a exists subquery in where clause', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}(function (Subquery $query) { + $query->table('user_role') + ->whereEqual('user_id', 1) + ->whereEqual('role_id', 9) + ->limit(1); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM users WHERE {$operator} " + . "(SELECT * FROM user_role WHERE user_id = ? AND role_id = ? LIMIT 1)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1, 9]); +})->with([ + ['whereExists', Operator::EXISTS->value], + ['whereNotExists', Operator::NOT_EXISTS->value], +]); + +it('generates a query to select by column or when exists or not exists subquery', function ( + string $method, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereTrue('is_admin') + ->{$method}(function (Subquery $query) { + $query->table('user_role') + ->whereEqual('user_id', 1) + ->limit(1); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM users WHERE is_admin IS TRUE OR {$operator} " + . "(SELECT * FROM user_role WHERE user_id = ? LIMIT 1)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +})->with([ + ['orWhereExists', Operator::EXISTS->value], + ['orWhereNotExists', Operator::NOT_EXISTS->value], +]); + +it('generates query to select using comparison clause with subqueries and functions', function ( + string $method, + string $column, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('products') + ->{$method}($column, function (Subquery $subquery) { + $subquery->select([Functions::max('price')])->from('products'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM products WHERE {$column} {$operator} " + . '(SELECT ' . Functions::max('price') . ' FROM products)'; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['whereEqual', 'price', Operator::EQUAL->value], + ['whereNotEqual', 'price', Operator::NOT_EQUAL->value], + ['whereGreaterThan', 'price', Operator::GREATER_THAN->value], + ['whereGreaterThanOrEqual', 'price', Operator::GREATER_THAN_OR_EQUAL->value], + ['whereLessThan', 'price', Operator::LESS_THAN->value], + ['whereLessThanOrEqual', 'price', Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query using comparison clause with subqueries and any, all, some operators', function ( + string $method, + string $comparisonOperator, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('products') + ->{$method}('id', function (Subquery $subquery) { + $subquery->select(['product_id']) + ->from('orders') + ->whereGreaterThan('quantity', 10); + }) + ->select(['description']) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT description FROM products WHERE id {$comparisonOperator} {$operator}" + . "(SELECT product_id FROM orders WHERE quantity > ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([10]); +})->with([ + ['whereAnyEqual', Operator::EQUAL->value, Operator::ANY->value], + ['whereAnyNotEqual', Operator::NOT_EQUAL->value, Operator::ANY->value], + ['whereAnyGreaterThan', Operator::GREATER_THAN->value, Operator::ANY->value], + ['whereAnyGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::ANY->value], + ['whereAnyLessThan', Operator::LESS_THAN->value, Operator::ANY->value], + ['whereAnyLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::ANY->value], + + ['whereAllEqual', Operator::EQUAL->value, Operator::ALL->value], + ['whereAllNotEqual', Operator::NOT_EQUAL->value, Operator::ALL->value], + ['whereAllGreaterThan', Operator::GREATER_THAN->value, Operator::ALL->value], + ['whereAllGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::ALL->value], + ['whereAllLessThan', Operator::LESS_THAN->value, Operator::ALL->value], + ['whereAllLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::ALL->value], + + ['whereSomeEqual', Operator::EQUAL->value, Operator::SOME->value], + ['whereSomeNotEqual', Operator::NOT_EQUAL->value, Operator::SOME->value], + ['whereSomeGreaterThan', Operator::GREATER_THAN->value, Operator::SOME->value], + ['whereSomeGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::SOME->value], + ['whereSomeLessThan', Operator::LESS_THAN->value, Operator::SOME->value], + ['whereSomeLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::SOME->value], +]); + +it('generates query with row subquery', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('employees') + ->{$method}(['manager_id', 'department_id'], function (Subquery $subquery) { + $subquery->select(['id, department_id']) + ->from('managers') + ->whereEqual('location_id', 1); + }) + ->select(['name']) + ->get(); + + [$dml, $params] = $sql; + + $subquery = 'SELECT id, department_id FROM managers WHERE location_id = ?'; + + $expected = "SELECT name FROM employees " + . "WHERE ROW(manager_id, department_id) {$operator} ({$subquery})"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +})->with([ + ['whereRowEqual', Operator::EQUAL->value], + ['whereRowNotEqual', Operator::NOT_EQUAL->value], + ['whereRowGreaterThan', Operator::GREATER_THAN->value], + ['whereRowGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value], + ['whereRowLessThan', Operator::LESS_THAN->value], + ['whereRowLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value], + ['whereRowIn', Operator::IN->value], + ['whereRowNotIn', Operator::NOT_IN->value], +]); + +it('clone query generator successfully', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $queryBuilder = $query->table('users') + ->whereEqual('id', 1) + ->lockForUpdate(); + + $cloned = clone $queryBuilder; + + expect($cloned)->toBeInstanceOf(QueryGenerator::class); + expect($cloned->isLocked())->toBeFalse(); +}); From 988c91b9986d91fbb127a009cd1d15d76ee5504a Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 12:15:29 +0000 Subject: [PATCH 29/49] feat: add tests for date, month, and year query generation in PostgreSQL --- .../Postgres/WhereDateClausesTest.php | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tests/Unit/Database/QueryGenerator/Postgres/WhereDateClausesTest.php diff --git a/tests/Unit/Database/QueryGenerator/Postgres/WhereDateClausesTest.php b/tests/Unit/Database/QueryGenerator/Postgres/WhereDateClausesTest.php new file mode 100644 index 00000000..077fd006 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/WhereDateClausesTest.php @@ -0,0 +1,173 @@ +table('users') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE DATE(created_at) {$operator} $1"); + expect($params)->toBe([$value]); +})->with([ + ['whereDateEqual', Carbon::now(), Carbon::now()->format('Y-m-d'), Operator::EQUAL->value], + ['whereDateEqual', date('Y-m-d'), date('Y-m-d'), Operator::EQUAL->value], + ['whereDateGreaterThan', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN->value], + ['whereDateGreaterThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN_OR_EQUAL->value], + ['whereDateLessThan', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN->value], + ['whereDateLessThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by condition or by date', function ( + string $method, + CarbonInterface|string $date, + string $value, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereFalse('active') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR DATE(created_at) {$operator} $1"); + expect($params)->toBe([$value]); +})->with([ + ['orWhereDateEqual', date('Y-m-d'), date('Y-m-d'), Operator::EQUAL->value], + ['orWhereDateGreaterThan', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN->value], + ['orWhereDateGreaterThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereDateLessThan', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN->value], + ['orWhereDateLessThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by month', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE MONTH(created_at) {$operator} $1"); + expect($params)->toBe([$value]); +})->with([ + ['whereMonthEqual', Carbon::now(), Carbon::now()->format('m'), Operator::EQUAL->value], + ['whereMonthEqual', date('m'), date('m'), Operator::EQUAL->value], + ['whereMonthGreaterThan', date('m'), date('m'), Operator::GREATER_THAN->value], + ['whereMonthGreaterThanOrEqual', date('m'), date('m'), Operator::GREATER_THAN_OR_EQUAL->value], + ['whereMonthLessThan', date('m'), date('m'), Operator::LESS_THAN->value], + ['whereMonthLessThanOrEqual', date('m'), date('m'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by condition or by month', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereFalse('active') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR MONTH(created_at) {$operator} $1"); + expect($params)->toBe([$value]); +})->with([ + ['orWhereMonthEqual', Carbon::now(), Carbon::now()->format('m'), Operator::EQUAL->value], + ['orWhereMonthEqual', date('m'), date('m'), Operator::EQUAL->value], + ['orWhereMonthGreaterThan', date('m'), date('m'), Operator::GREATER_THAN->value], + ['orWhereMonthGreaterThanOrEqual', date('m'), date('m'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereMonthLessThan', date('m'), date('m'), Operator::LESS_THAN->value], + ['orWhereMonthLessThanOrEqual', date('m'), date('m'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by year', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE YEAR(created_at) {$operator} $1"); + expect($params)->toBe([$value]); +})->with([ + ['whereYearEqual', Carbon::now(), Carbon::now()->format('Y'), Operator::EQUAL->value], + ['whereYearEqual', date('Y'), date('Y'), Operator::EQUAL->value], + ['whereYearGreaterThan', date('Y'), date('Y'), Operator::GREATER_THAN->value], + ['whereYearGreaterThanOrEqual', date('Y'), date('Y'), Operator::GREATER_THAN_OR_EQUAL->value], + ['whereYearLessThan', date('Y'), date('Y'), Operator::LESS_THAN->value], + ['whereYearLessThanOrEqual', date('Y'), date('Y'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by condition or by year', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereFalse('active') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR YEAR(created_at) {$operator} $1"); + expect($params)->toBe([$value]); +})->with([ + ['orWhereYearEqual', Carbon::now(), Carbon::now()->format('Y'), Operator::EQUAL->value], + ['orWhereYearEqual', date('Y'), date('Y'), Operator::EQUAL->value], + ['orWhereYearGreaterThan', date('Y'), date('Y'), Operator::GREATER_THAN->value], + ['orWhereYearGreaterThanOrEqual', date('Y'), date('Y'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereYearLessThan', date('Y'), date('Y'), Operator::LESS_THAN->value], + ['orWhereYearLessThanOrEqual', date('Y'), date('Y'), Operator::LESS_THAN_OR_EQUAL->value], +]); From 6d6983d45375dc174aa78b9f682635afc1b55734 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 12:15:35 +0000 Subject: [PATCH 30/49] feat: add SQLite support for date, month, and year query generation tests --- .../Sqlite/WhereDateClausesTest.php | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tests/Unit/Database/QueryGenerator/Sqlite/WhereDateClausesTest.php diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/WhereDateClausesTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/WhereDateClausesTest.php new file mode 100644 index 00000000..6c0abc9b --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/WhereDateClausesTest.php @@ -0,0 +1,173 @@ +table('users') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE DATE(created_at) {$operator} ?"); + expect($params)->toBe([$value]); +})->with([ + ['whereDateEqual', Carbon::now(), Carbon::now()->format('Y-m-d'), Operator::EQUAL->value], + ['whereDateEqual', date('Y-m-d'), date('Y-m-d'), Operator::EQUAL->value], + ['whereDateGreaterThan', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN->value], + ['whereDateGreaterThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN_OR_EQUAL->value], + ['whereDateLessThan', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN->value], + ['whereDateLessThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by condition or by date', function ( + string $method, + CarbonInterface|string $date, + string $value, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereFalse('active') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR DATE(created_at) {$operator} ?"); + expect($params)->toBe([$value]); +})->with([ + ['orWhereDateEqual', date('Y-m-d'), date('Y-m-d'), Operator::EQUAL->value], + ['orWhereDateGreaterThan', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN->value], + ['orWhereDateGreaterThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereDateLessThan', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN->value], + ['orWhereDateLessThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by month', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE MONTH(created_at) {$operator} ?"); + expect($params)->toBe([$value]); +})->with([ + ['whereMonthEqual', Carbon::now(), Carbon::now()->format('m'), Operator::EQUAL->value], + ['whereMonthEqual', date('m'), date('m'), Operator::EQUAL->value], + ['whereMonthGreaterThan', date('m'), date('m'), Operator::GREATER_THAN->value], + ['whereMonthGreaterThanOrEqual', date('m'), date('m'), Operator::GREATER_THAN_OR_EQUAL->value], + ['whereMonthLessThan', date('m'), date('m'), Operator::LESS_THAN->value], + ['whereMonthLessThanOrEqual', date('m'), date('m'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by condition or by month', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereFalse('active') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR MONTH(created_at) {$operator} ?"); + expect($params)->toBe([$value]); +})->with([ + ['orWhereMonthEqual', Carbon::now(), Carbon::now()->format('m'), Operator::EQUAL->value], + ['orWhereMonthEqual', date('m'), date('m'), Operator::EQUAL->value], + ['orWhereMonthGreaterThan', date('m'), date('m'), Operator::GREATER_THAN->value], + ['orWhereMonthGreaterThanOrEqual', date('m'), date('m'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereMonthLessThan', date('m'), date('m'), Operator::LESS_THAN->value], + ['orWhereMonthLessThanOrEqual', date('m'), date('m'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by year', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE YEAR(created_at) {$operator} ?"); + expect($params)->toBe([$value]); +})->with([ + ['whereYearEqual', Carbon::now(), Carbon::now()->format('Y'), Operator::EQUAL->value], + ['whereYearEqual', date('Y'), date('Y'), Operator::EQUAL->value], + ['whereYearGreaterThan', date('Y'), date('Y'), Operator::GREATER_THAN->value], + ['whereYearGreaterThanOrEqual', date('Y'), date('Y'), Operator::GREATER_THAN_OR_EQUAL->value], + ['whereYearLessThan', date('Y'), date('Y'), Operator::LESS_THAN->value], + ['whereYearLessThanOrEqual', date('Y'), date('Y'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by condition or by year', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereFalse('active') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR YEAR(created_at) {$operator} ?"); + expect($params)->toBe([$value]); +})->with([ + ['orWhereYearEqual', Carbon::now(), Carbon::now()->format('Y'), Operator::EQUAL->value], + ['orWhereYearEqual', date('Y'), date('Y'), Operator::EQUAL->value], + ['orWhereYearGreaterThan', date('Y'), date('Y'), Operator::GREATER_THAN->value], + ['orWhereYearGreaterThanOrEqual', date('Y'), date('Y'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereYearLessThan', date('Y'), date('Y'), Operator::LESS_THAN->value], + ['orWhereYearLessThanOrEqual', date('Y'), date('Y'), Operator::LESS_THAN_OR_EQUAL->value], +]); From 3f29f31ae22af0fdfcad06cdff1a7452aa7c7c27 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 15:19:28 +0000 Subject: [PATCH 31/49] feat: implement RETURNING clause support for DELETE statements in PostgreSQL and SQLite --- src/Database/Concerns/Query/BuildsQuery.php | 1 + .../Compilers/PostgresDeleteCompiler.php | 30 ++++-- .../SQLite/Compilers/SqliteDeleteCompiler.php | 28 +++++- src/Database/QueryBase.php | 15 +++ src/Database/QueryBuilder.php | 29 ++++++ tests/Unit/Database/QueryBuilderTest.php | 91 +++++++++++++++++++ .../Postgres/DeleteStatementTest.php | 64 +++++++++++++ .../Sqlite/DeleteStatementTest.php | 64 +++++++++++++ 8 files changed, 313 insertions(+), 9 deletions(-) diff --git a/src/Database/Concerns/Query/BuildsQuery.php b/src/Database/Concerns/Query/BuildsQuery.php index 6cc451e5..24ac37e9 100644 --- a/src/Database/Concerns/Query/BuildsQuery.php +++ b/src/Database/Concerns/Query/BuildsQuery.php @@ -150,6 +150,7 @@ protected function buildAst(): QueryAst $ast->rawStatement = $this->rawStatement ?? null; $ast->ignore = $this->ignore ?? false; $ast->uniqueColumns = $this->uniqueColumns ?? []; + $ast->returning = $this->returning ?? []; $ast->params = $this->arguments; return $ast; diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php index 2381d738..e81bef40 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php @@ -4,10 +4,11 @@ namespace Phenix\Database\Dialects\PostgreSQL\Compilers; +use Phenix\Util\Arr; +use Phenix\Database\QueryAst; use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\DeleteCompiler; use Phenix\Database\Dialects\PostgreSQL\Concerns\HasPlaceholders; -use Phenix\Database\QueryAst; class PostgresDeleteCompiler extends DeleteCompiler { @@ -20,13 +21,26 @@ public function __construct() public function compile(QueryAst $ast): CompiledClause { - $result = parent::compile($ast); + $parts = []; - return new CompiledClause( - $this->convertPlaceholders($result->sql), - $result->params - ); - } + $parts[] = 'DELETE FROM'; + $parts[] = $ast->table; + + if (! empty($ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($ast->wheres); - // TODO: Support RETURNING clause + $parts[] = 'WHERE'; + $parts[] = $whereCompiled->sql; + } + + if (! empty($ast->returning)) { + $parts[] = 'RETURNING'; + $parts[] = Arr::implodeDeeply($ast->returning, ', '); + } + + $sql = Arr::implodeDeeply($parts); + $sql = $this->convertPlaceholders($sql); + + return new CompiledClause($sql, $ast->params); + } } diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php b/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php index 1441cb30..5fb16175 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php @@ -4,6 +4,9 @@ namespace Phenix\Database\Dialects\SQLite\Compilers; +use Phenix\Util\Arr; +use Phenix\Database\QueryAst; +use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\DeleteCompiler; class SqliteDeleteCompiler extends DeleteCompiler @@ -12,5 +15,28 @@ public function __construct() { $this->whereCompiler = new SqliteWhereCompiler(); } - // TODO: Support RETURNING clause (SQLite 3.35.0+) + + 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; + } + + if (! empty($ast->returning)) { + $parts[] = 'RETURNING'; + $parts[] = Arr::implodeDeeply($ast->returning, ', '); + } + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $ast->params); + } } diff --git a/src/Database/QueryBase.php b/src/Database/QueryBase.php index 4d3efbe9..144d6b76 100644 --- a/src/Database/QueryBase.php +++ b/src/Database/QueryBase.php @@ -48,6 +48,8 @@ abstract class QueryBase extends Clause implements QueryBuilder, Builder protected array $uniqueColumns; + protected array $returning = []; + public function __construct() { $this->ignore = false; @@ -68,6 +70,7 @@ protected function resetBaseProperties(): void $this->clauses = []; $this->arguments = []; $this->uniqueColumns = []; + $this->returning = []; } public function count(string $column = '*'): array|int @@ -164,6 +167,18 @@ public function delete(): array|bool return $this->toSql(); } + /** + * Specify columns to return after DELETE/UPDATE (PostgreSQL, SQLite 3.35+) + * + * @param array $columns + */ + public function returning(array $columns = ['*']): static + { + $this->returning = array_unique($columns); + + return $this; + } + protected function prepareDataToInsert(array $data): void { if (array_is_list($data)) { diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index 6b1abd91..f204c1cd 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -212,6 +212,35 @@ public function delete(): bool } } + /** + * Delete records and return deleted data (PostgreSQL, SQLite 3.35+) + * + * @param array $columns + * @return Collection> + */ + public function deleteReturning(array $columns = ['*']): Collection + { + $this->returning = array_unique($columns); + + [$dml, $params] = parent::delete(); + + try { + $result = $this->exec($dml, $params); + + $collection = new Collection('array'); + + foreach ($result as $row) { + $collection->add($row); + } + + return $collection; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return new Collection('array'); + } + } + /** * @return Collection> */ diff --git a/tests/Unit/Database/QueryBuilderTest.php b/tests/Unit/Database/QueryBuilderTest.php index f30c1dad..961fe12c 100644 --- a/tests/Unit/Database/QueryBuilderTest.php +++ b/tests/Unit/Database/QueryBuilderTest.php @@ -12,6 +12,7 @@ use Phenix\Facades\DB; use Phenix\Util\URL; use Tests\Mocks\Database\MysqlConnectionPool; +use Tests\Mocks\Database\PostgresqlConnectionPool; use Tests\Mocks\Database\Result; use Tests\Mocks\Database\Statement; @@ -408,3 +409,93 @@ $query->rollBack(); } }); + +it('deletes records and returns deleted data', function () { + $deletedData = [ + ['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com'], + ['id' => 2, 'name' => 'Jane Doe', 'email' => 'jane@example.com'], + ]; + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturn(new Statement(new Result($deletedData))); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereEqual('status', 'inactive') + ->deleteReturning(['id', 'name', 'email']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->toArray())->toBe($deletedData); + expect($result->count())->toBe(2); +}); + +it('returns empty collection on delete returning error', function () { + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willThrowException(new SqlQueryError('Foreign key violation')); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereEqual('id', 1) + ->deleteReturning(['*']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->isEmpty())->toBeTrue(); +}); + +it('deletes single record and returns its data', function () { + $deletedData = [ + ['id' => 5, 'name' => 'Old User', 'email' => 'old@example.com', 'status' => 'deleted'], + ]; + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturn(new Statement(new Result($deletedData))); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereEqual('id', 5) + ->deleteReturning(['id', 'name', 'email', 'status']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->count())->toBe(1); + expect($result->first())->toBe($deletedData[0]); +}); + +it('deletes records with returning all columns', function () { + $deletedData = [ + ['id' => 1, 'name' => 'User 1', 'email' => 'user1@test.com', 'created_at' => '2024-01-01'], + ['id' => 2, 'name' => 'User 2', 'email' => 'user2@test.com', 'created_at' => '2024-01-02'], + ['id' => 3, 'name' => 'User 3', 'email' => 'user3@test.com', 'created_at' => '2024-01-03'], + ]; + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturn(new Statement(new Result($deletedData))); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereIn('id', [1, 2, 3]) + ->deleteReturning(['*']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->count())->toBe(3); + expect($result->toArray())->toBe($deletedData); +}); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php index 29dc2a35..0e9f6f56 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php @@ -139,3 +139,67 @@ expect($dml)->toBe($expected); expect($params)->toBeEmpty(); }); + +it('generates delete statement with returning clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->returning(['id', 'name', 'email']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE id = $1 RETURNING id, name, email"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates delete statement with returning all columns', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereIn('status', ['inactive', 'deleted']) + ->returning(['*']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status IN ($1, $2) RETURNING *"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', 'deleted']); +}); + +it('generates delete statement with returning without where clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->returning(['id', 'email']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users RETURNING id, email"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates delete statement with multiple where clauses and returning', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('status', 'inactive') + ->whereGreaterThan('created_at', '2024-01-01') + ->returning(['id', 'name', 'status', 'created_at']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status = $1 AND created_at > $2 RETURNING id, name, status, created_at"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', '2024-01-01']); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php index 697da439..7eedbb1c 100644 --- a/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php @@ -139,3 +139,67 @@ expect($dml)->toBe($expected); expect($params)->toBeEmpty(); }); + +it('generates delete statement with returning clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->returning(['id', 'name', 'email']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE id = ? RETURNING id, name, email"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates delete statement with returning all columns', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereIn('status', ['inactive', 'deleted']) + ->returning(['*']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status IN (?, ?) RETURNING *"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', 'deleted']); +}); + +it('generates delete statement with returning without where clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->returning(['id', 'email']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users RETURNING id, email"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates delete statement with multiple where clauses and returning', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('status', 'inactive') + ->whereGreaterThan('age', 65) + ->returning(['id', 'name', 'status', 'age']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status = ? AND age > ? RETURNING id, name, status, age"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', 65]); +}); From 5e6affc8aca8eb87e10189f0a3351d90e7e4dfea Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 15:27:21 +0000 Subject: [PATCH 32/49] style: php cs --- .../Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php | 4 ++-- .../Dialects/SQLite/Compilers/SqliteDeleteCompiler.php | 4 ++-- .../Dialects/SQLite/Compilers/SqliteWhereCompiler.php | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php index e81bef40..29e803c1 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php @@ -4,11 +4,11 @@ namespace Phenix\Database\Dialects\PostgreSQL\Compilers; -use Phenix\Util\Arr; -use Phenix\Database\QueryAst; use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\DeleteCompiler; use Phenix\Database\Dialects\PostgreSQL\Concerns\HasPlaceholders; +use Phenix\Database\QueryAst; +use Phenix\Util\Arr; class PostgresDeleteCompiler extends DeleteCompiler { diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php b/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php index 5fb16175..19ef0794 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php @@ -4,10 +4,10 @@ namespace Phenix\Database\Dialects\SQLite\Compilers; -use Phenix\Util\Arr; -use Phenix\Database\QueryAst; use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\DeleteCompiler; +use Phenix\Database\QueryAst; +use Phenix\Util\Arr; class SqliteDeleteCompiler extends DeleteCompiler { diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php b/src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php index 0fd11098..f37ad7dc 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php @@ -98,7 +98,7 @@ private function compileSubqueryClause(SubqueryWhereClause $clause): string } $parts[] = $clause->getOperator()->value; - + if ($clause->getSubqueryOperator() !== null) { // For ANY/ALL/SOME, no space between operator and subquery $parts[] = $clause->getSubqueryOperator()->value . '(' . $clause->getSql() . ')'; From eb3a3c588cfc76a4b71a90a55032a3b6eb7e65e2 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 17:18:36 +0000 Subject: [PATCH 33/49] feat: add RETURNING clause support for UPDATE statements in PostgreSQL and SQLite --- .../Dialects/Compilers/UpdateCompiler.php | 5 + .../Compilers/PostgresUpdateCompiler.php | 2 - .../SQLite/Compilers/SqliteUpdateCompiler.php | 1 - src/Database/QueryBuilder.php | 30 ++++++ tests/Unit/Database/QueryBuilderTest.php | 96 +++++++++++++++++++ .../Postgres/UpdateStatementTest.php | 85 ++++++++++++++++ .../Sqlite/UpdateStatementTest.php | 85 ++++++++++++++++ 7 files changed, 301 insertions(+), 3 deletions(-) diff --git a/src/Database/Dialects/Compilers/UpdateCompiler.php b/src/Database/Dialects/Compilers/UpdateCompiler.php index 75570a3e..dcd6861f 100644 --- a/src/Database/Dialects/Compilers/UpdateCompiler.php +++ b/src/Database/Dialects/Compilers/UpdateCompiler.php @@ -42,6 +42,11 @@ public function compile(QueryAst $ast): CompiledClause $params = array_merge($params, $ast->params); } + if (! empty($ast->returning)) { + $parts[] = 'RETURNING'; + $parts[] = Arr::implodeDeeply($ast->returning, ', '); + } + $sql = Arr::implodeDeeply($parts); return new CompiledClause($sql, $params); diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php index ad117617..8a4ab2c4 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php @@ -36,6 +36,4 @@ public function compile(QueryAst $ast): CompiledClause $result->params ); } - - // TODO: Support RETURNING clause } diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteUpdateCompiler.php b/src/Database/Dialects/SQLite/Compilers/SqliteUpdateCompiler.php index bb1512a2..82680df4 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteUpdateCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/SqliteUpdateCompiler.php @@ -17,5 +17,4 @@ protected function compileSetClause(string $column, int $paramIndex): string { return "{$column} = ?"; } - // TODO: Support RETURNING clause (SQLite 3.35.0+) } diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index f204c1cd..65b56449 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -188,6 +188,36 @@ public function update(array $values): bool } } + /** + * Update records and return updated data (PostgreSQL, SQLite 3.35+) + * + * @param array $values + * @param array $columns + * @return Collection> + */ + public function updateReturning(array $values, array $columns = ['*']): Collection + { + $this->returning = array_unique($columns); + + [$dml, $params] = parent::update($values); + + try { + $result = $this->exec($dml, $params); + + $collection = new Collection('array'); + + foreach ($result as $row) { + $collection->add($row); + } + + return $collection; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return new Collection('array'); + } + } + public function upsert(array $values, array $columns): bool { $this->action = Action::INSERT; diff --git a/tests/Unit/Database/QueryBuilderTest.php b/tests/Unit/Database/QueryBuilderTest.php index 961fe12c..b9162e90 100644 --- a/tests/Unit/Database/QueryBuilderTest.php +++ b/tests/Unit/Database/QueryBuilderTest.php @@ -499,3 +499,99 @@ expect($result->count())->toBe(3); expect($result->toArray())->toBe($deletedData); }); + +it('updates records and returns updated data', function () { + $updatedData = [ + ['id' => 1, 'name' => 'John Updated', 'email' => 'john@new.com', 'status' => 'active'], + ['id' => 2, 'name' => 'Jane Updated', 'email' => 'jane@new.com', 'status' => 'active'], + ]; + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturn(new Statement(new Result($updatedData))); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereEqual('status', 'pending') + ->updateReturning( + ['status' => 'active'], + ['id', 'name', 'email', 'status'] + ); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->toArray())->toBe($updatedData); + expect($result->count())->toBe(2); +}); + +it('returns empty collection on update returning error', function () { + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willThrowException(new SqlQueryError('Constraint violation')); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereEqual('id', 1) + ->updateReturning(['email' => 'duplicate@test.com'], ['*']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->isEmpty())->toBeTrue(); +}); + +it('updates single record and returns its data', function () { + $updatedData = [ + ['id' => 5, 'name' => 'Updated User', 'email' => 'updated@example.com', 'updated_at' => '2024-12-31'], + ]; + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturn(new Statement(new Result($updatedData))); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereEqual('id', 5) + ->updateReturning( + ['name' => 'Updated User', 'updated_at' => '2024-12-31'], + ['id', 'name', 'email', 'updated_at'] + ); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->count())->toBe(1); + expect($result->first())->toBe($updatedData[0]); +}); + +it('updates records with returning all columns', function () { + $updatedData = [ + ['id' => 1, 'name' => 'User 1', 'status' => 'active', 'updated_at' => '2024-12-31'], + ['id' => 2, 'name' => 'User 2', 'status' => 'active', 'updated_at' => '2024-12-31'], + ['id' => 3, 'name' => 'User 3', 'status' => 'active', 'updated_at' => '2024-12-31'], + ]; + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturn(new Statement(new Result($updatedData))); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereIn('id', [1, 2, 3]) + ->updateReturning(['status' => 'active', 'updated_at' => '2024-12-31'], ['*']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->count())->toBe(3); + expect($result->toArray())->toBe($updatedData); +}); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php index 1d6804da..c1a9ca0b 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php @@ -144,3 +144,88 @@ expect($dml)->toBe($expected); expect($params)->toBe([$name, $email, '2024-12-30', 'active', 5]); }); + +it('generates update statement with returning clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->returning(['id', 'name', 'email', 'updated_at']) + ->update(['name' => 'John Updated', 'email' => 'john@new.com']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = $1, email = $2 WHERE id = $3 RETURNING id, name, email, updated_at"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['John Updated', 'john@new.com', 1]); +}); + +it('generates update statement with returning all columns', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereIn('status', ['pending', 'inactive']) + ->returning(['*']) + ->update(['status' => 'active', 'activated_at' => '2024-12-31']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET status = $1, activated_at = $2 WHERE status IN ($3, $4) RETURNING *"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', '2024-12-31', 'pending', 'inactive']); +}); + +it('generates update statement with returning without where clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('settings') + ->returning(['id', 'key', 'value']) + ->update(['updated_at' => '2024-12-31']); + + [$dml, $params] = $sql; + + $expected = "UPDATE settings SET updated_at = $1 RETURNING id, key, value"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2024-12-31']); +}); + +it('generates update statement with multiple where clauses and returning', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $name = faker()->name; + + $sql = $query->table('users') + ->whereEqual('status', 'pending') + ->whereGreaterThan('created_at', '2024-01-01') + ->whereNotNull('email') + ->returning(['id', 'name', 'status', 'created_at']) + ->update(['name' => $name, 'status' => 'active']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = $1, status = $2 " + . "WHERE status = $3 AND created_at > $4 AND email IS NOT NULL " + . "RETURNING id, name, status, created_at"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, 'active', 'pending', '2024-01-01']); +}); + +it('generates update statement with single column and returning', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('posts') + ->whereEqual('id', 42) + ->returning(['id', 'title', 'published_at']) + ->update(['published_at' => '2024-12-31 10:00:00']); + + [$dml, $params] = $sql; + + $expected = "UPDATE posts SET published_at = $1 WHERE id = $2 RETURNING id, title, published_at"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2024-12-31 10:00:00', 42]); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php index 860040fe..c8f8f85c 100644 --- a/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php @@ -144,3 +144,88 @@ expect($dml)->toBe($expected); expect($params)->toBe([$name, $email, '2024-12-30', 'active', 5]); }); + +it('generates update statement with returning clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->returning(['id', 'name', 'email', 'updated_at']) + ->update(['name' => 'John Updated', 'email' => 'john@new.com']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = ?, email = ? WHERE id = ? RETURNING id, name, email, updated_at"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['John Updated', 'john@new.com', 1]); +}); + +it('generates update statement with returning all columns', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereIn('status', ['pending', 'inactive']) + ->returning(['*']) + ->update(['status' => 'active', 'activated_at' => '2024-12-31']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET status = ?, activated_at = ? WHERE status IN (?, ?) RETURNING *"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', '2024-12-31', 'pending', 'inactive']); +}); + +it('generates update statement with returning without where clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('settings') + ->returning(['id', 'key', 'value']) + ->update(['updated_at' => '2024-12-31']); + + [$dml, $params] = $sql; + + $expected = "UPDATE settings SET updated_at = ? RETURNING id, key, value"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2024-12-31']); +}); + +it('generates update statement with multiple where clauses and returning', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $name = faker()->name; + + $sql = $query->table('users') + ->whereEqual('status', 'pending') + ->whereGreaterThan('created_at', '2024-01-01') + ->whereNotNull('email') + ->returning(['id', 'name', 'status', 'created_at']) + ->update(['name' => $name, 'status' => 'active']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = ?, status = ? " + . "WHERE status = ? AND created_at > ? AND email IS NOT NULL " + . "RETURNING id, name, status, created_at"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, 'active', 'pending', '2024-01-01']); +}); + +it('generates update statement with single column and returning', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('posts') + ->whereEqual('id', 42) + ->returning(['id', 'title', 'published_at']) + ->update(['published_at' => '2024-12-31 10:00:00']); + + [$dml, $params] = $sql; + + $expected = "UPDATE posts SET published_at = ? WHERE id = ? RETURNING id, title, published_at"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2024-12-31 10:00:00', 42]); +}); From fed5c8f42486fd7dbd8e9e83ddf4745c682cc291 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 18:19:38 +0000 Subject: [PATCH 34/49] feat: add error handling for AUTOINCREMENT sequence reset in SQLite truncation --- src/Testing/Concerns/RefreshDatabase.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Testing/Concerns/RefreshDatabase.php b/src/Testing/Concerns/RefreshDatabase.php index c7f1dc65..add86d5e 100644 --- a/src/Testing/Concerns/RefreshDatabase.php +++ b/src/Testing/Concerns/RefreshDatabase.php @@ -210,6 +210,7 @@ protected function truncateSqliteDatabase(object $connection): void try { $connection->prepare('DELETE FROM sqlite_sequence')->execute(); } catch (Throwable) { + // Best-effort reset of AUTOINCREMENT sequences; ignore errors } } finally { try { From fa1ce878956502f6bb6e990f3d09d042bf500f68 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 18:31:42 +0000 Subject: [PATCH 35/49] refactor: rename compilers for each driver --- .../{MysqlDeleteCompiler.php => Delete.php} | 4 +- .../{MysqlExistsCompiler.php => Exists.php} | 4 +- .../{MysqlInsertCompiler.php => Insert.php} | 2 +- .../MySQL/Compilers/MySQLWhereCompiler.php | 2 +- .../{MysqlSelectCompiler.php => Select.php} | 4 +- .../{MysqlUpdateCompiler.php => Update.php} | 4 +- .../Dialects/MySQL/Compilers/Where.php | 123 ++++++++++++++++++ src/Database/Dialects/MySQL/MysqlDialect.php | 32 ++--- ...{PostgresDeleteCompiler.php => Delete.php} | 4 +- ...{PostgresExistsCompiler.php => Exists.php} | 4 +- ...{PostgresInsertCompiler.php => Insert.php} | 2 +- ...{PostgresSelectCompiler.php => Select.php} | 4 +- ...{PostgresUpdateCompiler.php => Update.php} | 4 +- .../{PostgresWhereCompiler.php => Where.php} | 2 +- .../Dialects/PostgreSQL/PostgresDialect.php | 32 ++--- .../{SqliteDeleteCompiler.php => Delete.php} | 4 +- .../{SqliteExistsCompiler.php => Exists.php} | 4 +- .../{SqliteInsertCompiler.php => Insert.php} | 2 +- .../{SqliteSelectCompiler.php => Select.php} | 4 +- .../{SqliteUpdateCompiler.php => Update.php} | 4 +- .../{SqliteWhereCompiler.php => Where.php} | 3 +- .../Dialects/SQLite/SqliteDialect.php | 32 ++--- 22 files changed, 201 insertions(+), 79 deletions(-) rename src/Database/Dialects/MySQL/Compilers/{MysqlDeleteCompiler.php => Delete.php} (64%) rename src/Database/Dialects/MySQL/Compilers/{MysqlExistsCompiler.php => Exists.php} (63%) rename src/Database/Dialects/MySQL/Compilers/{MysqlInsertCompiler.php => Insert.php} (92%) rename src/Database/Dialects/MySQL/Compilers/{MysqlSelectCompiler.php => Select.php} (84%) rename src/Database/Dialects/MySQL/Compilers/{MysqlUpdateCompiler.php => Update.php} (75%) create mode 100644 src/Database/Dialects/MySQL/Compilers/Where.php rename src/Database/Dialects/PostgreSQL/Compilers/{PostgresDeleteCompiler.php => Delete.php} (90%) rename src/Database/Dialects/PostgreSQL/Compilers/{PostgresExistsCompiler.php => Exists.php} (84%) rename src/Database/Dialects/PostgreSQL/Compilers/{PostgresInsertCompiler.php => Insert.php} (97%) rename src/Database/Dialects/PostgreSQL/Compilers/{PostgresSelectCompiler.php => Select.php} (92%) rename src/Database/Dialects/PostgreSQL/Compilers/{PostgresUpdateCompiler.php => Update.php} (88%) rename src/Database/Dialects/PostgreSQL/Compilers/{PostgresWhereCompiler.php => Where.php} (99%) rename src/Database/Dialects/SQLite/Compilers/{SqliteDeleteCompiler.php => Delete.php} (89%) rename src/Database/Dialects/SQLite/Compilers/{SqliteExistsCompiler.php => Exists.php} (62%) rename src/Database/Dialects/SQLite/Compilers/{SqliteInsertCompiler.php => Insert.php} (95%) rename src/Database/Dialects/SQLite/Compilers/{SqliteSelectCompiler.php => Select.php} (76%) rename src/Database/Dialects/SQLite/Compilers/{SqliteUpdateCompiler.php => Update.php} (74%) rename src/Database/Dialects/SQLite/Compilers/{SqliteWhereCompiler.php => Where.php} (98%) diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlDeleteCompiler.php b/src/Database/Dialects/MySQL/Compilers/Delete.php similarity index 64% rename from src/Database/Dialects/MySQL/Compilers/MysqlDeleteCompiler.php rename to src/Database/Dialects/MySQL/Compilers/Delete.php index ffda0afb..7d2623dd 100644 --- a/src/Database/Dialects/MySQL/Compilers/MysqlDeleteCompiler.php +++ b/src/Database/Dialects/MySQL/Compilers/Delete.php @@ -6,10 +6,10 @@ use Phenix\Database\Dialects\Compilers\DeleteCompiler; -class MysqlDeleteCompiler extends DeleteCompiler +class Delete extends DeleteCompiler { public function __construct() { - $this->whereCompiler = new MysqlWhereCompiler(); + $this->whereCompiler = new Where(); } } diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlExistsCompiler.php b/src/Database/Dialects/MySQL/Compilers/Exists.php similarity index 63% rename from src/Database/Dialects/MySQL/Compilers/MysqlExistsCompiler.php rename to src/Database/Dialects/MySQL/Compilers/Exists.php index 871decee..aa038b96 100644 --- a/src/Database/Dialects/MySQL/Compilers/MysqlExistsCompiler.php +++ b/src/Database/Dialects/MySQL/Compilers/Exists.php @@ -6,10 +6,10 @@ use Phenix\Database\Dialects\Compilers\ExistsCompiler; -final class MysqlExistsCompiler extends ExistsCompiler +class Exists extends ExistsCompiler { public function __construct() { - $this->whereCompiler = new MysqlWhereCompiler(); + $this->whereCompiler = new Where(); } } diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlInsertCompiler.php b/src/Database/Dialects/MySQL/Compilers/Insert.php similarity index 92% rename from src/Database/Dialects/MySQL/Compilers/MysqlInsertCompiler.php rename to src/Database/Dialects/MySQL/Compilers/Insert.php index 183ca854..3dd95778 100644 --- a/src/Database/Dialects/MySQL/Compilers/MysqlInsertCompiler.php +++ b/src/Database/Dialects/MySQL/Compilers/Insert.php @@ -8,7 +8,7 @@ use Phenix\Database\QueryAst; use Phenix\Util\Arr; -class MysqlInsertCompiler extends InsertCompiler +class Insert extends InsertCompiler { protected function compileInsertIgnore(): string { diff --git a/src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php b/src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php index 7d362aec..89aefd5e 100644 --- a/src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php +++ b/src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php @@ -19,7 +19,7 @@ use function is_array; -class MysqlWhereCompiler +class Where { /** * @param array $wheres diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlSelectCompiler.php b/src/Database/Dialects/MySQL/Compilers/Select.php similarity index 84% rename from src/Database/Dialects/MySQL/Compilers/MysqlSelectCompiler.php rename to src/Database/Dialects/MySQL/Compilers/Select.php index 4bf4a254..ff90c274 100644 --- a/src/Database/Dialects/MySQL/Compilers/MysqlSelectCompiler.php +++ b/src/Database/Dialects/MySQL/Compilers/Select.php @@ -8,11 +8,11 @@ use Phenix\Database\Dialects\Compilers\SelectCompiler; use Phenix\Database\QueryAst; -final class MysqlSelectCompiler extends SelectCompiler +class Select extends SelectCompiler { public function __construct() { - $this->whereCompiler = new MysqlWhereCompiler(); + $this->whereCompiler = new Where(); } protected function compileLock(QueryAst $ast): string diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlUpdateCompiler.php b/src/Database/Dialects/MySQL/Compilers/Update.php similarity index 75% rename from src/Database/Dialects/MySQL/Compilers/MysqlUpdateCompiler.php rename to src/Database/Dialects/MySQL/Compilers/Update.php index 19f62841..7cbb2c04 100644 --- a/src/Database/Dialects/MySQL/Compilers/MysqlUpdateCompiler.php +++ b/src/Database/Dialects/MySQL/Compilers/Update.php @@ -6,11 +6,11 @@ use Phenix\Database\Dialects\Compilers\UpdateCompiler; -class MysqlUpdateCompiler extends UpdateCompiler +class Update extends UpdateCompiler { public function __construct() { - $this->whereCompiler = new MysqlWhereCompiler(); + $this->whereCompiler = new Where(); } protected function compileSetClause(string $column, int $paramIndex): string diff --git a/src/Database/Dialects/MySQL/Compilers/Where.php b/src/Database/Dialects/MySQL/Compilers/Where.php new file mode 100644 index 00000000..89aefd5e --- /dev/null +++ b/src/Database/Dialects/MySQL/Compilers/Where.php @@ -0,0 +1,123 @@ + $wheres + * @return CompiledClause + */ + public function compile(array $wheres): CompiledClause + { + if (empty($wheres)) { + return new CompiledClause('', []); + } + + $sql = []; + + foreach ($wheres as $index => $where) { + // Add logical connector if not the first clause + if ($index > 0 && $where->getConnector() !== null) { + $sql[] = $where->getConnector()->value; + } + + $sql[] = $this->compileClause($where); + } + + return new CompiledClause(implode(' ', $sql), []); + } + + private function compileClause(WhereClause $clause): string + { + return match (true) { + $clause instanceof BasicWhereClause => $this->compileBasicClause($clause), + $clause instanceof NullWhereClause => $this->compileNullClause($clause), + $clause instanceof BooleanWhereClause => $this->compileBooleanClause($clause), + $clause instanceof BetweenWhereClause => $this->compileBetweenClause($clause), + $clause instanceof SubqueryWhereClause => $this->compileSubqueryClause($clause), + $clause instanceof ColumnWhereClause => $this->compileColumnClause($clause), + $clause instanceof RawWhereClause => $this->compileRawClause($clause), + default => '', + }; + } + + private function compileBasicClause(BasicWhereClause $clause): string + { + if ($clause->isInOperator()) { + $placeholders = str_repeat(SQL::STD_PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::STD_PLACEHOLDER->value; + + return "{$clause->getColumn()} {$clause->getOperator()->value} ({$placeholders})"; + } + + return "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::STD_PLACEHOLDER->value; + } + + private function compileNullClause(NullWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value}"; + } + + private function compileBooleanClause(BooleanWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value}"; + } + + private function compileBetweenClause(BetweenWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value} ? AND ?"; + } + + private function compileSubqueryClause(SubqueryWhereClause $clause): string + { + $parts = []; + + if ($clause->getColumn() !== null) { + $parts[] = $clause->getColumn(); + } + + $parts[] = $clause->getOperator()->value; + $parts[] = $clause->getSubqueryOperator() !== null + ? "{$clause->getSubqueryOperator()->value}({$clause->getSql()})" + : "({$clause->getSql()})"; + + return implode(' ', $parts); + } + + private function compileColumnClause(ColumnWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->getCompareColumn()}"; + } + + private function compileRawClause(RawWhereClause $clause): string + { + // For backwards compatibility with any remaining raw clauses + $parts = array_map(function ($value) { + return match (true) { + $value instanceof Operator => $value->value, + $value instanceof LogicalConnector => $value->value, + is_array($value) => '(' . implode(', ', $value) . ')', + default => $value, + }; + }, $clause->getParts()); + + return implode(' ', $parts); + } +} diff --git a/src/Database/Dialects/MySQL/MysqlDialect.php b/src/Database/Dialects/MySQL/MysqlDialect.php index c00c9542..834f027f 100644 --- a/src/Database/Dialects/MySQL/MysqlDialect.php +++ b/src/Database/Dialects/MySQL/MysqlDialect.php @@ -6,32 +6,32 @@ use Phenix\Database\Constants\Action; use Phenix\Database\Contracts\Dialect; -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\Dialects\MySQL\Compilers\Delete; +use Phenix\Database\Dialects\MySQL\Compilers\Exists; +use Phenix\Database\Dialects\MySQL\Compilers\Insert; +use Phenix\Database\Dialects\MySQL\Compilers\Select; +use Phenix\Database\Dialects\MySQL\Compilers\Update; use Phenix\Database\QueryAst; -final class MysqlDialect implements Dialect +class MysqlDialect implements Dialect { - protected MysqlSelectCompiler $selectCompiler; + protected Select $selectCompiler; - protected MysqlInsertCompiler $insertCompiler; + protected Insert $insertCompiler; - protected MysqlUpdateCompiler $updateCompiler; + protected Update $updateCompiler; - protected MysqlDeleteCompiler $deleteCompiler; + protected Delete $deleteCompiler; - protected MysqlExistsCompiler $existsCompiler; + protected Exists $existsCompiler; public function __construct() { - $this->selectCompiler = new MysqlSelectCompiler(); - $this->insertCompiler = new MysqlInsertCompiler(); - $this->updateCompiler = new MysqlUpdateCompiler(); - $this->deleteCompiler = new MysqlDeleteCompiler(); - $this->existsCompiler = new MysqlExistsCompiler(); + $this->selectCompiler = new Select(); + $this->insertCompiler = new Insert(); + $this->updateCompiler = new Update(); + $this->deleteCompiler = new Delete(); + $this->existsCompiler = new Exists(); } public function compile(QueryAst $ast): array diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/Delete.php similarity index 90% rename from src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php rename to src/Database/Dialects/PostgreSQL/Compilers/Delete.php index 29e803c1..4f480b31 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/Delete.php @@ -10,13 +10,13 @@ use Phenix\Database\QueryAst; use Phenix\Util\Arr; -class PostgresDeleteCompiler extends DeleteCompiler +class Delete extends DeleteCompiler { use HasPlaceholders; public function __construct() { - $this->whereCompiler = new PostgresWhereCompiler(); + $this->whereCompiler = new Where(); } public function compile(QueryAst $ast): CompiledClause diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresExistsCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/Exists.php similarity index 84% rename from src/Database/Dialects/PostgreSQL/Compilers/PostgresExistsCompiler.php rename to src/Database/Dialects/PostgreSQL/Compilers/Exists.php index 929df722..9c5ea568 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresExistsCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/Exists.php @@ -9,13 +9,13 @@ use Phenix\Database\Dialects\PostgreSQL\Concerns\HasPlaceholders; use Phenix\Database\QueryAst; -class PostgresExistsCompiler extends ExistsCompiler +class Exists extends ExistsCompiler { use HasPlaceholders; public function __construct() { - $this->whereCompiler = new PostgresWhereCompiler(); + $this->whereCompiler = new Where(); } public function compile(QueryAst $ast): CompiledClause diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/Insert.php similarity index 97% rename from src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php rename to src/Database/Dialects/PostgreSQL/Compilers/Insert.php index cec8e07a..34a5bfda 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresInsertCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/Insert.php @@ -15,7 +15,7 @@ * - INSERT ... ON CONFLICT DO NOTHING (ignore conflicts) * - INSERT ... ON CONFLICT (...) DO UPDATE SET (upsert functionality) */ -class PostgresInsertCompiler extends InsertCompiler +class Insert extends InsertCompiler { use HasPlaceholders; diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/Select.php similarity index 92% rename from src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php rename to src/Database/Dialects/PostgreSQL/Compilers/Select.php index 190be795..1be2ac50 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/Select.php @@ -10,13 +10,13 @@ use Phenix\Database\Dialects\PostgreSQL\Concerns\HasPlaceholders; use Phenix\Database\QueryAst; -final class PostgresSelectCompiler extends SelectCompiler +class Select extends SelectCompiler { use HasPlaceholders; public function __construct() { - $this->whereCompiler = new PostgresWhereCompiler(); + $this->whereCompiler = new Where(); } public function compile(QueryAst $ast): CompiledClause diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/Update.php similarity index 88% rename from src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php rename to src/Database/Dialects/PostgreSQL/Compilers/Update.php index 8a4ab2c4..178f0e41 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/Update.php @@ -11,13 +11,13 @@ use function count; -class PostgresUpdateCompiler extends UpdateCompiler +class Update extends UpdateCompiler { use HasPlaceholders; public function __construct() { - $this->whereCompiler = new PostgresWhereCompiler(); + $this->whereCompiler = new Where(); } protected function compileSetClause(string $column, int $paramIndex): string diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresWhereCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/Where.php similarity index 99% rename from src/Database/Dialects/PostgreSQL/Compilers/PostgresWhereCompiler.php rename to src/Database/Dialects/PostgreSQL/Compilers/Where.php index d4adab4f..00c9dea8 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/PostgresWhereCompiler.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/Where.php @@ -19,7 +19,7 @@ use function is_array; -class PostgresWhereCompiler +class Where { /** * @param array $wheres diff --git a/src/Database/Dialects/PostgreSQL/PostgresDialect.php b/src/Database/Dialects/PostgreSQL/PostgresDialect.php index b6bc4b20..7de51f6e 100644 --- a/src/Database/Dialects/PostgreSQL/PostgresDialect.php +++ b/src/Database/Dialects/PostgreSQL/PostgresDialect.php @@ -6,28 +6,28 @@ use Phenix\Database\Constants\Action; use Phenix\Database\Contracts\Dialect; -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\Dialects\PostgreSQL\Compilers\Delete; +use Phenix\Database\Dialects\PostgreSQL\Compilers\Exists; +use Phenix\Database\Dialects\PostgreSQL\Compilers\Insert; +use Phenix\Database\Dialects\PostgreSQL\Compilers\Select; +use Phenix\Database\Dialects\PostgreSQL\Compilers\Update; use Phenix\Database\QueryAst; -final class PostgresDialect implements Dialect +class PostgresDialect implements Dialect { - private PostgresSelectCompiler $selectCompiler; - private PostgresInsertCompiler $insertCompiler; - private PostgresUpdateCompiler $updateCompiler; - private PostgresDeleteCompiler $deleteCompiler; - private PostgresExistsCompiler $existsCompiler; + private Select $selectCompiler; + private Insert $insertCompiler; + private Update $updateCompiler; + private Delete $deleteCompiler; + private Exists $existsCompiler; public function __construct() { - $this->selectCompiler = new PostgresSelectCompiler(); - $this->insertCompiler = new PostgresInsertCompiler(); - $this->updateCompiler = new PostgresUpdateCompiler(); - $this->deleteCompiler = new PostgresDeleteCompiler(); - $this->existsCompiler = new PostgresExistsCompiler(); + $this->selectCompiler = new Select(); + $this->insertCompiler = new Insert(); + $this->updateCompiler = new Update(); + $this->deleteCompiler = new Delete(); + $this->existsCompiler = new Exists(); } public function compile(QueryAst $ast): array diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php b/src/Database/Dialects/SQLite/Compilers/Delete.php similarity index 89% rename from src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php rename to src/Database/Dialects/SQLite/Compilers/Delete.php index 19ef0794..faf689c3 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/Delete.php @@ -9,11 +9,11 @@ use Phenix\Database\QueryAst; use Phenix\Util\Arr; -class SqliteDeleteCompiler extends DeleteCompiler +class Delete extends DeleteCompiler { public function __construct() { - $this->whereCompiler = new SqliteWhereCompiler(); + $this->whereCompiler = new Where(); } public function compile(QueryAst $ast): CompiledClause diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteExistsCompiler.php b/src/Database/Dialects/SQLite/Compilers/Exists.php similarity index 62% rename from src/Database/Dialects/SQLite/Compilers/SqliteExistsCompiler.php rename to src/Database/Dialects/SQLite/Compilers/Exists.php index ba43c691..30eeb27c 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteExistsCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/Exists.php @@ -6,10 +6,10 @@ use Phenix\Database\Dialects\Compilers\ExistsCompiler; -final class SqliteExistsCompiler extends ExistsCompiler +class Exists extends ExistsCompiler { public function __construct() { - $this->whereCompiler = new SqliteWhereCompiler(); + $this->whereCompiler = new Where(); } } diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteInsertCompiler.php b/src/Database/Dialects/SQLite/Compilers/Insert.php similarity index 95% rename from src/Database/Dialects/SQLite/Compilers/SqliteInsertCompiler.php rename to src/Database/Dialects/SQLite/Compilers/Insert.php index 41aae887..c374f426 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteInsertCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/Insert.php @@ -13,7 +13,7 @@ * - INSERT OR IGNORE INTO (silently skip conflicts) * - INSERT ... ON CONFLICT (...) DO UPDATE SET (upsert functionality) */ -class SqliteInsertCompiler extends InsertCompiler +class Insert extends InsertCompiler { protected function compileInsertIgnore(): string { diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteSelectCompiler.php b/src/Database/Dialects/SQLite/Compilers/Select.php similarity index 76% rename from src/Database/Dialects/SQLite/Compilers/SqliteSelectCompiler.php rename to src/Database/Dialects/SQLite/Compilers/Select.php index 004183ec..945311f5 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteSelectCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/Select.php @@ -7,11 +7,11 @@ use Phenix\Database\Dialects\Compilers\SelectCompiler; use Phenix\Database\QueryAst; -final class SqliteSelectCompiler extends SelectCompiler +class Select extends SelectCompiler { public function __construct() { - $this->whereCompiler = new SqliteWhereCompiler(); + $this->whereCompiler = new Where(); } protected function compileLock(QueryAst $ast): string diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteUpdateCompiler.php b/src/Database/Dialects/SQLite/Compilers/Update.php similarity index 74% rename from src/Database/Dialects/SQLite/Compilers/SqliteUpdateCompiler.php rename to src/Database/Dialects/SQLite/Compilers/Update.php index 82680df4..6de0afa1 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteUpdateCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/Update.php @@ -6,11 +6,11 @@ use Phenix\Database\Dialects\Compilers\UpdateCompiler; -class SqliteUpdateCompiler extends UpdateCompiler +class Update extends UpdateCompiler { public function __construct() { - $this->whereCompiler = new SqliteWhereCompiler(); + $this->whereCompiler = new Where(); } protected function compileSetClause(string $column, int $paramIndex): string diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php b/src/Database/Dialects/SQLite/Compilers/Where.php similarity index 98% rename from src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php rename to src/Database/Dialects/SQLite/Compilers/Where.php index f37ad7dc..9b0816f7 100644 --- a/src/Database/Dialects/SQLite/Compilers/SqliteWhereCompiler.php +++ b/src/Database/Dialects/SQLite/Compilers/Where.php @@ -16,7 +16,7 @@ use Phenix\Database\Constants\Operator; use Phenix\Database\Dialects\CompiledClause; -final class SqliteWhereCompiler +class Where { /** * @param array $wheres @@ -61,7 +61,6 @@ private function compileBasicClause(BasicWhereClause $clause): string $column = $clause->getColumn(); $operator = $clause->getOperator(); - // SQLite uses '?' as placeholder if ($operator === Operator::IN || $operator === Operator::NOT_IN) { $placeholders = str_repeat('?, ', $clause->getValueCount() - 1) . '?'; diff --git a/src/Database/Dialects/SQLite/SqliteDialect.php b/src/Database/Dialects/SQLite/SqliteDialect.php index 4a9de4d1..ddfe1699 100644 --- a/src/Database/Dialects/SQLite/SqliteDialect.php +++ b/src/Database/Dialects/SQLite/SqliteDialect.php @@ -6,28 +6,28 @@ use Phenix\Database\Constants\Action; use Phenix\Database\Contracts\Dialect; -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\Dialects\SQLite\Compilers\Delete; +use Phenix\Database\Dialects\SQLite\Compilers\Exists; +use Phenix\Database\Dialects\SQLite\Compilers\Insert; +use Phenix\Database\Dialects\SQLite\Compilers\Select; +use Phenix\Database\Dialects\SQLite\Compilers\Update; use Phenix\Database\QueryAst; -final class SqliteDialect implements Dialect +class SqliteDialect implements Dialect { - private SqliteSelectCompiler $selectCompiler; - private SqliteInsertCompiler $insertCompiler; - private SqliteUpdateCompiler $updateCompiler; - private SqliteDeleteCompiler $deleteCompiler; - private SqliteExistsCompiler $existsCompiler; + private Select $selectCompiler; + private Insert $insertCompiler; + private Update $updateCompiler; + private Delete $deleteCompiler; + private Exists $existsCompiler; public function __construct() { - $this->selectCompiler = new SqliteSelectCompiler(); - $this->insertCompiler = new SqliteInsertCompiler(); - $this->updateCompiler = new SqliteUpdateCompiler(); - $this->deleteCompiler = new SqliteDeleteCompiler(); - $this->existsCompiler = new SqliteExistsCompiler(); + $this->selectCompiler = new Select(); + $this->insertCompiler = new Insert(); + $this->updateCompiler = new Update(); + $this->deleteCompiler = new Delete(); + $this->existsCompiler = new Exists(); } public function compile(QueryAst $ast): array From 37c1e29fcf5a79a97020c0e935c02b42e450c1c5 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 18:33:24 +0000 Subject: [PATCH 36/49] refactor: rename placeholder constant --- src/Database/Constants/SQL.php | 2 +- src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php | 4 ++-- src/Database/Dialects/MySQL/Compilers/Where.php | 4 ++-- src/Database/Dialects/PostgreSQL/Compilers/Where.php | 2 +- src/Database/Having.php | 2 +- src/Database/QueryBase.php | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Database/Constants/SQL.php b/src/Database/Constants/SQL.php index d71d09b6..fca2fffd 100644 --- a/src/Database/Constants/SQL.php +++ b/src/Database/Constants/SQL.php @@ -6,5 +6,5 @@ enum SQL: string { - case STD_PLACEHOLDER = '?'; + case PLACEHOLDER = '?'; } diff --git a/src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php b/src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php index 89aefd5e..378e7422 100644 --- a/src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php +++ b/src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php @@ -62,12 +62,12 @@ private function compileClause(WhereClause $clause): string private function compileBasicClause(BasicWhereClause $clause): string { if ($clause->isInOperator()) { - $placeholders = str_repeat(SQL::STD_PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::STD_PLACEHOLDER->value; + $placeholders = str_repeat(SQL::PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::PLACEHOLDER->value; return "{$clause->getColumn()} {$clause->getOperator()->value} ({$placeholders})"; } - return "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::STD_PLACEHOLDER->value; + return "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::PLACEHOLDER->value; } private function compileNullClause(NullWhereClause $clause): string diff --git a/src/Database/Dialects/MySQL/Compilers/Where.php b/src/Database/Dialects/MySQL/Compilers/Where.php index 89aefd5e..378e7422 100644 --- a/src/Database/Dialects/MySQL/Compilers/Where.php +++ b/src/Database/Dialects/MySQL/Compilers/Where.php @@ -62,12 +62,12 @@ private function compileClause(WhereClause $clause): string private function compileBasicClause(BasicWhereClause $clause): string { if ($clause->isInOperator()) { - $placeholders = str_repeat(SQL::STD_PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::STD_PLACEHOLDER->value; + $placeholders = str_repeat(SQL::PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::PLACEHOLDER->value; return "{$clause->getColumn()} {$clause->getOperator()->value} ({$placeholders})"; } - return "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::STD_PLACEHOLDER->value; + return "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::PLACEHOLDER->value; } private function compileNullClause(NullWhereClause $clause): string diff --git a/src/Database/Dialects/PostgreSQL/Compilers/Where.php b/src/Database/Dialects/PostgreSQL/Compilers/Where.php index 00c9dea8..e140008a 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/Where.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/Where.php @@ -70,7 +70,7 @@ private function compileBasicClause(BasicWhereClause $clause): string return "{$column} {$operator->value} ({$placeholders})"; } - return "{$column} {$operator->value} " . SQL::STD_PLACEHOLDER->value; + return "{$column} {$operator->value} " . SQL::PLACEHOLDER->value; } private function compileNullClause(NullWhereClause $clause): string diff --git a/src/Database/Having.php b/src/Database/Having.php index 32407438..2ffded9b 100644 --- a/src/Database/Having.php +++ b/src/Database/Having.php @@ -23,7 +23,7 @@ public function toSql(): array $sql = []; foreach ($this->clauses as $clause) { - $clauseSql = "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::STD_PLACEHOLDER->value; + $clauseSql = "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::PLACEHOLDER->value; if ($connector = $clause->getConnector()) { $clauseSql = "{$connector->value} {$clauseSql}"; diff --git a/src/Database/QueryBase.php b/src/Database/QueryBase.php index 144d6b76..4b69b661 100644 --- a/src/Database/QueryBase.php +++ b/src/Database/QueryBase.php @@ -195,6 +195,6 @@ protected function prepareDataToInsert(array $data): void $this->arguments = \array_merge($this->arguments, array_values($data)); - $this->values[] = array_fill(0, count($data), SQL::STD_PLACEHOLDER->value); + $this->values[] = array_fill(0, count($data), SQL::PLACEHOLDER->value); } } From 6a790db556f80671a643ae9d6ac2101ca50653b5 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 18:39:09 +0000 Subject: [PATCH 37/49] feat: replace string placeholders with SQL constant in WHERE clause rendering --- src/Database/Clauses/BasicWhereClause.php | 5 +++-- src/Database/Clauses/BetweenWhereClause.php | 4 ++-- src/Database/Dialects/PostgreSQL/Compilers/Where.php | 2 +- src/Database/Dialects/SQLite/Compilers/Where.php | 5 +++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Database/Clauses/BasicWhereClause.php b/src/Database/Clauses/BasicWhereClause.php index 7f5a5da7..7746a37b 100644 --- a/src/Database/Clauses/BasicWhereClause.php +++ b/src/Database/Clauses/BasicWhereClause.php @@ -6,6 +6,7 @@ use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; +use Phenix\Database\Constants\SQL; use function count; use function is_array; @@ -54,10 +55,10 @@ public function renderValue(): string if ($this->usePlaceholder) { // In WHERE context with parameterized queries, use placeholder if (is_array($this->value)) { - return '(' . implode(', ', array_fill(0, count($this->value), '?')) . ')'; + return '(' . implode(', ', array_fill(0, count($this->value), SQL::PLACEHOLDER->value)) . ')'; } - return '?'; + return SQL::PLACEHOLDER->value; } // In JOIN ON context, render the value directly (typically a column name) diff --git a/src/Database/Clauses/BetweenWhereClause.php b/src/Database/Clauses/BetweenWhereClause.php index e97b4647..9c3dbdd0 100644 --- a/src/Database/Clauses/BetweenWhereClause.php +++ b/src/Database/Clauses/BetweenWhereClause.php @@ -6,6 +6,7 @@ use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; +use Phenix\Database\Constants\SQL; class BetweenWhereClause extends WhereClause { @@ -44,7 +45,6 @@ public function getValues(): array public function renderValue(): string { - // BETWEEN uses placeholders for both values - return '? AND ?'; + return SQL::PLACEHOLDER->value . ' AND ' . SQL::PLACEHOLDER->value; } } diff --git a/src/Database/Dialects/PostgreSQL/Compilers/Where.php b/src/Database/Dialects/PostgreSQL/Compilers/Where.php index e140008a..88f8d1a6 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/Where.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/Where.php @@ -65,7 +65,7 @@ private function compileBasicClause(BasicWhereClause $clause): string $operator = $clause->getOperator(); if ($clause->isInOperator()) { - $placeholders = str_repeat('?, ', $clause->getValueCount() - 1) . '?'; + $placeholders = str_repeat(SQL::PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::PLACEHOLDER->value; return "{$column} {$operator->value} ({$placeholders})"; } diff --git a/src/Database/Dialects/SQLite/Compilers/Where.php b/src/Database/Dialects/SQLite/Compilers/Where.php index 9b0816f7..23f2864c 100644 --- a/src/Database/Dialects/SQLite/Compilers/Where.php +++ b/src/Database/Dialects/SQLite/Compilers/Where.php @@ -14,6 +14,7 @@ use Phenix\Database\Clauses\WhereClause; use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; +use Phenix\Database\Constants\SQL; use Phenix\Database\Dialects\CompiledClause; class Where @@ -62,12 +63,12 @@ private function compileBasicClause(BasicWhereClause $clause): string $operator = $clause->getOperator(); if ($operator === Operator::IN || $operator === Operator::NOT_IN) { - $placeholders = str_repeat('?, ', $clause->getValueCount() - 1) . '?'; + $placeholders = str_repeat(SQL::PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::PLACEHOLDER->value; return "{$column} {$operator->value} ({$placeholders})"; } - return "{$column} {$operator->value} ?"; + return "{$column} {$operator->value} " . SQL::PLACEHOLDER->value; } private function compileNullClause(NullWhereClause $clause): string From ac761846e4eb5e1e19679e89e1801f879b34eee0 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 19:05:02 +0000 Subject: [PATCH 38/49] feat: implement base Dialect class and extend for MySQL, PostgreSQL, and SQLite --- src/Database/Dialects/Dialect.php | 90 +++++++++++++++++++ src/Database/Dialects/MySQL/MysqlDialect.php | 82 ++--------------- .../Dialects/PostgreSQL/PostgresDialect.php | 78 ++-------------- .../Dialects/SQLite/SqliteDialect.php | 78 ++-------------- 4 files changed, 111 insertions(+), 217 deletions(-) create mode 100644 src/Database/Dialects/Dialect.php diff --git a/src/Database/Dialects/Dialect.php b/src/Database/Dialects/Dialect.php new file mode 100644 index 00000000..d1aae500 --- /dev/null +++ b/src/Database/Dialects/Dialect.php @@ -0,0 +1,90 @@ +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/MySQL/MysqlDialect.php b/src/Database/Dialects/MySQL/MysqlDialect.php index 834f027f..e9cd5f5d 100644 --- a/src/Database/Dialects/MySQL/MysqlDialect.php +++ b/src/Database/Dialects/MySQL/MysqlDialect.php @@ -4,28 +4,21 @@ namespace Phenix\Database\Dialects\MySQL; -use Phenix\Database\Constants\Action; -use Phenix\Database\Contracts\Dialect; +use Phenix\Database\Dialects\Dialect; use Phenix\Database\Dialects\MySQL\Compilers\Delete; use Phenix\Database\Dialects\MySQL\Compilers\Exists; use Phenix\Database\Dialects\MySQL\Compilers\Insert; use Phenix\Database\Dialects\MySQL\Compilers\Select; use Phenix\Database\Dialects\MySQL\Compilers\Update; -use Phenix\Database\QueryAst; -class MysqlDialect implements Dialect +class MysqlDialect extends Dialect { - protected Select $selectCompiler; - - protected Insert $insertCompiler; - - protected Update $updateCompiler; - - protected Delete $deleteCompiler; - - protected Exists $existsCompiler; - public function __construct() + { + $this->initializeCompilers(); + } + + protected function initializeCompilers(): void { $this->selectCompiler = new Select(); $this->insertCompiler = new Insert(); @@ -33,65 +26,4 @@ public function __construct() $this->deleteCompiler = new Delete(); $this->existsCompiler = new Exists(); } - - 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/PostgresDialect.php b/src/Database/Dialects/PostgreSQL/PostgresDialect.php index 7de51f6e..2593946c 100644 --- a/src/Database/Dialects/PostgreSQL/PostgresDialect.php +++ b/src/Database/Dialects/PostgreSQL/PostgresDialect.php @@ -4,24 +4,21 @@ namespace Phenix\Database\Dialects\PostgreSQL; -use Phenix\Database\Constants\Action; -use Phenix\Database\Contracts\Dialect; +use Phenix\Database\Dialects\Dialect; use Phenix\Database\Dialects\PostgreSQL\Compilers\Delete; use Phenix\Database\Dialects\PostgreSQL\Compilers\Exists; use Phenix\Database\Dialects\PostgreSQL\Compilers\Insert; use Phenix\Database\Dialects\PostgreSQL\Compilers\Select; use Phenix\Database\Dialects\PostgreSQL\Compilers\Update; -use Phenix\Database\QueryAst; -class PostgresDialect implements Dialect +class PostgresDialect extends Dialect { - private Select $selectCompiler; - private Insert $insertCompiler; - private Update $updateCompiler; - private Delete $deleteCompiler; - private Exists $existsCompiler; - public function __construct() + { + $this->initializeCompilers(); + } + + protected function initializeCompilers(): void { $this->selectCompiler = new Select(); $this->insertCompiler = new Insert(); @@ -29,65 +26,4 @@ public function __construct() $this->deleteCompiler = new Delete(); $this->existsCompiler = new Exists(); } - - 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/SqliteDialect.php b/src/Database/Dialects/SQLite/SqliteDialect.php index ddfe1699..29cecba5 100644 --- a/src/Database/Dialects/SQLite/SqliteDialect.php +++ b/src/Database/Dialects/SQLite/SqliteDialect.php @@ -4,24 +4,21 @@ namespace Phenix\Database\Dialects\SQLite; -use Phenix\Database\Constants\Action; -use Phenix\Database\Contracts\Dialect; +use Phenix\Database\Dialects\Dialect; use Phenix\Database\Dialects\SQLite\Compilers\Delete; use Phenix\Database\Dialects\SQLite\Compilers\Exists; use Phenix\Database\Dialects\SQLite\Compilers\Insert; use Phenix\Database\Dialects\SQLite\Compilers\Select; use Phenix\Database\Dialects\SQLite\Compilers\Update; -use Phenix\Database\QueryAst; -class SqliteDialect implements Dialect +class SqliteDialect extends Dialect { - private Select $selectCompiler; - private Insert $insertCompiler; - private Update $updateCompiler; - private Delete $deleteCompiler; - private Exists $existsCompiler; - public function __construct() + { + $this->initializeCompilers(); + } + + protected function initializeCompilers(): void { $this->selectCompiler = new Select(); $this->insertCompiler = new Insert(); @@ -29,65 +26,4 @@ public function __construct() $this->deleteCompiler = new Delete(); $this->existsCompiler = new Exists(); } - - 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]; - } } From 82709dbae5ed047ba24ce6fcd60f60b26f814167 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 31 Dec 2025 14:12:59 -0500 Subject: [PATCH 39/49] refactor: remove MySQLWhereCompiler class as part of dialect consolidation --- .../MySQL/Compilers/MySQLWhereCompiler.php | 123 ------------------ 1 file changed, 123 deletions(-) delete mode 100644 src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php diff --git a/src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php b/src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php deleted file mode 100644 index 378e7422..00000000 --- a/src/Database/Dialects/MySQL/Compilers/MySQLWhereCompiler.php +++ /dev/null @@ -1,123 +0,0 @@ - $wheres - * @return CompiledClause - */ - public function compile(array $wheres): CompiledClause - { - if (empty($wheres)) { - return new CompiledClause('', []); - } - - $sql = []; - - foreach ($wheres as $index => $where) { - // Add logical connector if not the first clause - if ($index > 0 && $where->getConnector() !== null) { - $sql[] = $where->getConnector()->value; - } - - $sql[] = $this->compileClause($where); - } - - return new CompiledClause(implode(' ', $sql), []); - } - - private function compileClause(WhereClause $clause): string - { - return match (true) { - $clause instanceof BasicWhereClause => $this->compileBasicClause($clause), - $clause instanceof NullWhereClause => $this->compileNullClause($clause), - $clause instanceof BooleanWhereClause => $this->compileBooleanClause($clause), - $clause instanceof BetweenWhereClause => $this->compileBetweenClause($clause), - $clause instanceof SubqueryWhereClause => $this->compileSubqueryClause($clause), - $clause instanceof ColumnWhereClause => $this->compileColumnClause($clause), - $clause instanceof RawWhereClause => $this->compileRawClause($clause), - default => '', - }; - } - - private function compileBasicClause(BasicWhereClause $clause): string - { - if ($clause->isInOperator()) { - $placeholders = str_repeat(SQL::PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::PLACEHOLDER->value; - - return "{$clause->getColumn()} {$clause->getOperator()->value} ({$placeholders})"; - } - - return "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::PLACEHOLDER->value; - } - - private function compileNullClause(NullWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value}"; - } - - private function compileBooleanClause(BooleanWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value}"; - } - - private function compileBetweenClause(BetweenWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value} ? AND ?"; - } - - private function compileSubqueryClause(SubqueryWhereClause $clause): string - { - $parts = []; - - if ($clause->getColumn() !== null) { - $parts[] = $clause->getColumn(); - } - - $parts[] = $clause->getOperator()->value; - $parts[] = $clause->getSubqueryOperator() !== null - ? "{$clause->getSubqueryOperator()->value}({$clause->getSql()})" - : "({$clause->getSql()})"; - - return implode(' ', $parts); - } - - private function compileColumnClause(ColumnWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->getCompareColumn()}"; - } - - private function compileRawClause(RawWhereClause $clause): string - { - // For backwards compatibility with any remaining raw clauses - $parts = array_map(function ($value) { - return match (true) { - $value instanceof Operator => $value->value, - $value instanceof LogicalConnector => $value->value, - is_array($value) => '(' . implode(', ', $value) . ')', - default => $value, - }; - }, $clause->getParts()); - - return implode(' ', $parts); - } -} From 0d9b1f9164174ec5f89280088d3881bcd16c3b25 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 2 Jan 2026 17:17:14 +0000 Subject: [PATCH 40/49] feat: implement WhereCompiler abstraction and extend for MySQL, PostgreSQL, and SQLite --- .../Dialects/Compilers/DeleteCompiler.php | 2 +- .../Dialects/Compilers/WhereCompiler.php | 39 ++----------------- .../Dialects/MySQL/Compilers/Where.php | 3 +- .../Dialects/PostgreSQL/Compilers/Delete.php | 28 +++---------- .../Dialects/PostgreSQL/Compilers/Where.php | 3 +- .../Dialects/SQLite/Compilers/Where.php | 3 +- 6 files changed, 16 insertions(+), 62 deletions(-) diff --git a/src/Database/Dialects/Compilers/DeleteCompiler.php b/src/Database/Dialects/Compilers/DeleteCompiler.php index 8f079257..86a3fb5d 100644 --- a/src/Database/Dialects/Compilers/DeleteCompiler.php +++ b/src/Database/Dialects/Compilers/DeleteCompiler.php @@ -11,7 +11,7 @@ abstract class DeleteCompiler implements ClauseCompiler { - protected $whereCompiler; + protected WhereCompiler $whereCompiler; public function compile(QueryAst $ast): CompiledClause { diff --git a/src/Database/Dialects/Compilers/WhereCompiler.php b/src/Database/Dialects/Compilers/WhereCompiler.php index b05722cd..591493d0 100644 --- a/src/Database/Dialects/Compilers/WhereCompiler.php +++ b/src/Database/Dialects/Compilers/WhereCompiler.php @@ -4,45 +4,14 @@ namespace Phenix\Database\Dialects\Compilers; -use Phenix\Database\Constants\LogicalConnector; -use Phenix\Database\Constants\Operator; +use Phenix\Database\Clauses\WhereClause; use Phenix\Database\Dialects\CompiledClause; -use Phenix\Util\Arr; -class WhereCompiler +abstract class WhereCompiler { /** - * @param array> $wheres + * @param array $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 LogicalConnector => $value->value, - is_array($value) => '(' . Arr::implodeDeeply($value, ', ') . ')', - default => $value, - }; - }, $clause); - }, $clauses); - } + abstract public function compile(array $wheres): CompiledClause; } diff --git a/src/Database/Dialects/MySQL/Compilers/Where.php b/src/Database/Dialects/MySQL/Compilers/Where.php index 378e7422..e6fd9355 100644 --- a/src/Database/Dialects/MySQL/Compilers/Where.php +++ b/src/Database/Dialects/MySQL/Compilers/Where.php @@ -16,10 +16,11 @@ use Phenix\Database\Constants\Operator; use Phenix\Database\Constants\SQL; use Phenix\Database\Dialects\CompiledClause; +use Phenix\Database\Dialects\Compilers\WhereCompiler; use function is_array; -class Where +class Where extends WhereCompiler { /** * @param array $wheres diff --git a/src/Database/Dialects/PostgreSQL/Compilers/Delete.php b/src/Database/Dialects/PostgreSQL/Compilers/Delete.php index 4f480b31..16d40cdd 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/Delete.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/Delete.php @@ -5,12 +5,11 @@ namespace Phenix\Database\Dialects\PostgreSQL\Compilers; use Phenix\Database\Dialects\CompiledClause; -use Phenix\Database\Dialects\Compilers\DeleteCompiler; use Phenix\Database\Dialects\PostgreSQL\Concerns\HasPlaceholders; +use Phenix\Database\Dialects\SQLite\Compilers\Delete as SQLiteDelete; use Phenix\Database\QueryAst; -use Phenix\Util\Arr; -class Delete extends DeleteCompiler +class Delete extends SQLiteDelete { use HasPlaceholders; @@ -21,26 +20,9 @@ public function __construct() public function compile(QueryAst $ast): CompiledClause { - $parts = []; + $clause = parent::compile($ast); + $sql = $this->convertPlaceholders($clause->sql); - $parts[] = 'DELETE FROM'; - $parts[] = $ast->table; - - if (! empty($ast->wheres)) { - $whereCompiled = $this->whereCompiler->compile($ast->wheres); - - $parts[] = 'WHERE'; - $parts[] = $whereCompiled->sql; - } - - if (! empty($ast->returning)) { - $parts[] = 'RETURNING'; - $parts[] = Arr::implodeDeeply($ast->returning, ', '); - } - - $sql = Arr::implodeDeeply($parts); - $sql = $this->convertPlaceholders($sql); - - return new CompiledClause($sql, $ast->params); + return new CompiledClause($sql, $clause->params); } } diff --git a/src/Database/Dialects/PostgreSQL/Compilers/Where.php b/src/Database/Dialects/PostgreSQL/Compilers/Where.php index 88f8d1a6..f6737eff 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/Where.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/Where.php @@ -16,10 +16,11 @@ use Phenix\Database\Constants\Operator; use Phenix\Database\Constants\SQL; use Phenix\Database\Dialects\CompiledClause; +use Phenix\Database\Dialects\Compilers\WhereCompiler; use function is_array; -class Where +class Where extends WhereCompiler { /** * @param array $wheres diff --git a/src/Database/Dialects/SQLite/Compilers/Where.php b/src/Database/Dialects/SQLite/Compilers/Where.php index 23f2864c..3ecb5707 100644 --- a/src/Database/Dialects/SQLite/Compilers/Where.php +++ b/src/Database/Dialects/SQLite/Compilers/Where.php @@ -16,8 +16,9 @@ use Phenix\Database\Constants\Operator; use Phenix\Database\Constants\SQL; use Phenix\Database\Dialects\CompiledClause; +use Phenix\Database\Dialects\Compilers\WhereCompiler; -class Where +class Where extends WhereCompiler { /** * @param array $wheres From edfc4e660bfda912a18fc1aff237e085b2804909 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 2 Jan 2026 17:27:15 +0000 Subject: [PATCH 41/49] feat: enhance WhereCompiler with compile method and clause handling for SQLite, MySQL, and PostgreSQL --- .../Dialects/Compilers/WhereCompiler.php | 81 +++++++++++++++++- .../Dialects/MySQL/Compilers/Where.php | 84 +------------------ .../Dialects/PostgreSQL/Compilers/Where.php | 84 +------------------ .../Dialects/SQLite/Compilers/Where.php | 81 +----------------- 4 files changed, 89 insertions(+), 241 deletions(-) diff --git a/src/Database/Dialects/Compilers/WhereCompiler.php b/src/Database/Dialects/Compilers/WhereCompiler.php index 591493d0..d668b312 100644 --- a/src/Database/Dialects/Compilers/WhereCompiler.php +++ b/src/Database/Dialects/Compilers/WhereCompiler.php @@ -4,14 +4,93 @@ namespace Phenix\Database\Dialects\Compilers; +use Phenix\Database\Clauses\BasicWhereClause; +use Phenix\Database\Clauses\BetweenWhereClause; +use Phenix\Database\Clauses\BooleanWhereClause; +use Phenix\Database\Clauses\ColumnWhereClause; +use Phenix\Database\Clauses\NullWhereClause; +use Phenix\Database\Clauses\RawWhereClause; +use Phenix\Database\Clauses\SubqueryWhereClause; use Phenix\Database\Clauses\WhereClause; +use Phenix\Database\Constants\LogicalConnector; +use Phenix\Database\Constants\Operator; use Phenix\Database\Dialects\CompiledClause; +use function is_array; + abstract class WhereCompiler { /** * @param array $wheres * @return CompiledClause */ - abstract public function compile(array $wheres): CompiledClause; + public function compile(array $wheres): CompiledClause + { + if (empty($wheres)) { + return new CompiledClause('', []); + } + + $sql = []; + + foreach ($wheres as $index => $where) { + // Add logical connector if not the first clause + if ($index > 0 && $where->getConnector() !== null) { + $sql[] = $where->getConnector()->value; + } + + $sql[] = $this->compileClause($where); + } + + return new CompiledClause(implode(' ', $sql), []); + } + + protected function compileClause(WhereClause $clause): string + { + return match (true) { + $clause instanceof BasicWhereClause => $this->compileBasicClause($clause), + $clause instanceof NullWhereClause => $this->compileNullClause($clause), + $clause instanceof BooleanWhereClause => $this->compileBooleanClause($clause), + $clause instanceof BetweenWhereClause => $this->compileBetweenClause($clause), + $clause instanceof SubqueryWhereClause => $this->compileSubqueryClause($clause), + $clause instanceof ColumnWhereClause => $this->compileColumnClause($clause), + $clause instanceof RawWhereClause => $this->compileRawClause($clause), + default => '', + }; + } + + abstract protected function compileBasicClause(BasicWhereClause $clause): string; + + protected function compileNullClause(NullWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value}"; + } + + protected function compileBooleanClause(BooleanWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value}"; + } + + abstract protected function compileBetweenClause(BetweenWhereClause $clause): string; + + abstract protected function compileSubqueryClause(SubqueryWhereClause $clause): string; + + protected function compileColumnClause(ColumnWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->getCompareColumn()}"; + } + + protected function compileRawClause(RawWhereClause $clause): string + { + // For backwards compatibility with any remaining raw clauses + $parts = array_map(function ($value) { + return match (true) { + $value instanceof Operator => $value->value, + $value instanceof LogicalConnector => $value->value, + is_array($value) => '(' . implode(', ', $value) . ')', + default => $value, + }; + }, $clause->getParts()); + + return implode(' ', $parts); + } } diff --git a/src/Database/Dialects/MySQL/Compilers/Where.php b/src/Database/Dialects/MySQL/Compilers/Where.php index e6fd9355..eda4382a 100644 --- a/src/Database/Dialects/MySQL/Compilers/Where.php +++ b/src/Database/Dialects/MySQL/Compilers/Where.php @@ -6,61 +6,13 @@ use Phenix\Database\Clauses\BasicWhereClause; use Phenix\Database\Clauses\BetweenWhereClause; -use Phenix\Database\Clauses\BooleanWhereClause; -use Phenix\Database\Clauses\ColumnWhereClause; -use Phenix\Database\Clauses\NullWhereClause; -use Phenix\Database\Clauses\RawWhereClause; use Phenix\Database\Clauses\SubqueryWhereClause; -use Phenix\Database\Clauses\WhereClause; -use Phenix\Database\Constants\LogicalConnector; -use Phenix\Database\Constants\Operator; use Phenix\Database\Constants\SQL; -use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\WhereCompiler; -use function is_array; - class Where extends WhereCompiler { - /** - * @param array $wheres - * @return CompiledClause - */ - public function compile(array $wheres): CompiledClause - { - if (empty($wheres)) { - return new CompiledClause('', []); - } - - $sql = []; - - foreach ($wheres as $index => $where) { - // Add logical connector if not the first clause - if ($index > 0 && $where->getConnector() !== null) { - $sql[] = $where->getConnector()->value; - } - - $sql[] = $this->compileClause($where); - } - - return new CompiledClause(implode(' ', $sql), []); - } - - private function compileClause(WhereClause $clause): string - { - return match (true) { - $clause instanceof BasicWhereClause => $this->compileBasicClause($clause), - $clause instanceof NullWhereClause => $this->compileNullClause($clause), - $clause instanceof BooleanWhereClause => $this->compileBooleanClause($clause), - $clause instanceof BetweenWhereClause => $this->compileBetweenClause($clause), - $clause instanceof SubqueryWhereClause => $this->compileSubqueryClause($clause), - $clause instanceof ColumnWhereClause => $this->compileColumnClause($clause), - $clause instanceof RawWhereClause => $this->compileRawClause($clause), - default => '', - }; - } - - private function compileBasicClause(BasicWhereClause $clause): string + protected function compileBasicClause(BasicWhereClause $clause): string { if ($clause->isInOperator()) { $placeholders = str_repeat(SQL::PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::PLACEHOLDER->value; @@ -71,22 +23,12 @@ private function compileBasicClause(BasicWhereClause $clause): string return "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::PLACEHOLDER->value; } - private function compileNullClause(NullWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value}"; - } - - private function compileBooleanClause(BooleanWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value}"; - } - - private function compileBetweenClause(BetweenWhereClause $clause): string + protected function compileBetweenClause(BetweenWhereClause $clause): string { return "{$clause->getColumn()} {$clause->getOperator()->value} ? AND ?"; } - private function compileSubqueryClause(SubqueryWhereClause $clause): string + protected function compileSubqueryClause(SubqueryWhereClause $clause): string { $parts = []; @@ -101,24 +43,4 @@ private function compileSubqueryClause(SubqueryWhereClause $clause): string return implode(' ', $parts); } - - private function compileColumnClause(ColumnWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->getCompareColumn()}"; - } - - private function compileRawClause(RawWhereClause $clause): string - { - // For backwards compatibility with any remaining raw clauses - $parts = array_map(function ($value) { - return match (true) { - $value instanceof Operator => $value->value, - $value instanceof LogicalConnector => $value->value, - is_array($value) => '(' . implode(', ', $value) . ')', - default => $value, - }; - }, $clause->getParts()); - - return implode(' ', $parts); - } } diff --git a/src/Database/Dialects/PostgreSQL/Compilers/Where.php b/src/Database/Dialects/PostgreSQL/Compilers/Where.php index f6737eff..ebab885b 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/Where.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/Where.php @@ -6,61 +6,13 @@ use Phenix\Database\Clauses\BasicWhereClause; use Phenix\Database\Clauses\BetweenWhereClause; -use Phenix\Database\Clauses\BooleanWhereClause; -use Phenix\Database\Clauses\ColumnWhereClause; -use Phenix\Database\Clauses\NullWhereClause; -use Phenix\Database\Clauses\RawWhereClause; use Phenix\Database\Clauses\SubqueryWhereClause; -use Phenix\Database\Clauses\WhereClause; -use Phenix\Database\Constants\LogicalConnector; -use Phenix\Database\Constants\Operator; use Phenix\Database\Constants\SQL; -use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\WhereCompiler; -use function is_array; - class Where extends WhereCompiler { - /** - * @param array $wheres - * @return CompiledClause - */ - public function compile(array $wheres): CompiledClause - { - if (empty($wheres)) { - return new CompiledClause('', []); - } - - $sql = []; - - foreach ($wheres as $index => $where) { - // Add logical connector if not the first clause - if ($index > 0 && $where->getConnector() !== null) { - $sql[] = $where->getConnector()->value; - } - - $sql[] = $this->compileClause($where); - } - - return new CompiledClause(implode(' ', $sql), []); - } - - private function compileClause(WhereClause $clause): string - { - return match (true) { - $clause instanceof BasicWhereClause => $this->compileBasicClause($clause), - $clause instanceof NullWhereClause => $this->compileNullClause($clause), - $clause instanceof BooleanWhereClause => $this->compileBooleanClause($clause), - $clause instanceof BetweenWhereClause => $this->compileBetweenClause($clause), - $clause instanceof SubqueryWhereClause => $this->compileSubqueryClause($clause), - $clause instanceof ColumnWhereClause => $this->compileColumnClause($clause), - $clause instanceof RawWhereClause => $this->compileRawClause($clause), - default => '', - }; - } - - private function compileBasicClause(BasicWhereClause $clause): string + protected function compileBasicClause(BasicWhereClause $clause): string { $column = $clause->getColumn(); $operator = $clause->getOperator(); @@ -74,17 +26,7 @@ private function compileBasicClause(BasicWhereClause $clause): string return "{$column} {$operator->value} " . SQL::PLACEHOLDER->value; } - private function compileNullClause(NullWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value}"; - } - - private function compileBooleanClause(BooleanWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value}"; - } - - private function compileBetweenClause(BetweenWhereClause $clause): string + protected function compileBetweenClause(BetweenWhereClause $clause): string { $column = $clause->getColumn(); $operator = $clause->getOperator(); @@ -92,7 +34,7 @@ private function compileBetweenClause(BetweenWhereClause $clause): string return "{$column} {$operator->value} {$clause->renderValue()}"; } - private function compileSubqueryClause(SubqueryWhereClause $clause): string + protected function compileSubqueryClause(SubqueryWhereClause $clause): string { $parts = []; @@ -112,24 +54,4 @@ private function compileSubqueryClause(SubqueryWhereClause $clause): string return implode(' ', $parts); } - - private function compileColumnClause(ColumnWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->getCompareColumn()}"; - } - - private function compileRawClause(RawWhereClause $clause): string - { - // For backwards compatibility with any remaining raw clauses - $parts = array_map(function ($value) { - return match (true) { - $value instanceof Operator => $value->value, - $value instanceof LogicalConnector => $value->value, - is_array($value) => '(' . implode(', ', $value) . ')', - default => $value, - }; - }, $clause->getParts()); - - return implode(' ', $parts); - } } diff --git a/src/Database/Dialects/SQLite/Compilers/Where.php b/src/Database/Dialects/SQLite/Compilers/Where.php index 3ecb5707..6cd11652 100644 --- a/src/Database/Dialects/SQLite/Compilers/Where.php +++ b/src/Database/Dialects/SQLite/Compilers/Where.php @@ -6,59 +6,14 @@ use Phenix\Database\Clauses\BasicWhereClause; use Phenix\Database\Clauses\BetweenWhereClause; -use Phenix\Database\Clauses\BooleanWhereClause; -use Phenix\Database\Clauses\ColumnWhereClause; -use Phenix\Database\Clauses\NullWhereClause; -use Phenix\Database\Clauses\RawWhereClause; use Phenix\Database\Clauses\SubqueryWhereClause; -use Phenix\Database\Clauses\WhereClause; -use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; use Phenix\Database\Constants\SQL; -use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\WhereCompiler; class Where extends WhereCompiler { - /** - * @param array $wheres - * @return CompiledClause - */ - public function compile(array $wheres): CompiledClause - { - if (empty($wheres)) { - return new CompiledClause('', []); - } - - $sql = []; - - foreach ($wheres as $index => $where) { - // Add logical connector if not the first clause - if ($index > 0 && $where->getConnector() !== null) { - $sql[] = $where->getConnector()->value; - } - - $sql[] = $this->compileClause($where); - } - - return new CompiledClause(implode(' ', $sql), []); - } - - private function compileClause(WhereClause $clause): string - { - return match (true) { - $clause instanceof BasicWhereClause => $this->compileBasicClause($clause), - $clause instanceof NullWhereClause => $this->compileNullClause($clause), - $clause instanceof BooleanWhereClause => $this->compileBooleanClause($clause), - $clause instanceof BetweenWhereClause => $this->compileBetweenClause($clause), - $clause instanceof SubqueryWhereClause => $this->compileSubqueryClause($clause), - $clause instanceof ColumnWhereClause => $this->compileColumnClause($clause), - $clause instanceof RawWhereClause => $this->compileRawClause($clause), - default => '', - }; - } - - private function compileBasicClause(BasicWhereClause $clause): string + protected function compileBasicClause(BasicWhereClause $clause): string { $column = $clause->getColumn(); $operator = $clause->getOperator(); @@ -72,17 +27,7 @@ private function compileBasicClause(BasicWhereClause $clause): string return "{$column} {$operator->value} " . SQL::PLACEHOLDER->value; } - private function compileNullClause(NullWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value}"; - } - - private function compileBooleanClause(BooleanWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value}"; - } - - private function compileBetweenClause(BetweenWhereClause $clause): string + protected function compileBetweenClause(BetweenWhereClause $clause): string { $column = $clause->getColumn(); $operator = $clause->getOperator(); @@ -90,7 +35,7 @@ private function compileBetweenClause(BetweenWhereClause $clause): string return "{$column} {$operator->value} ? AND ?"; } - private function compileSubqueryClause(SubqueryWhereClause $clause): string + protected function compileSubqueryClause(SubqueryWhereClause $clause): string { $parts = []; @@ -109,24 +54,4 @@ private function compileSubqueryClause(SubqueryWhereClause $clause): string return implode(' ', $parts); } - - private function compileColumnClause(ColumnWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->getCompareColumn()}"; - } - - private function compileRawClause(RawWhereClause $clause): string - { - // For backwards compatibility with any remaining raw clauses - $parts = array_map(function ($value) { - return match (true) { - $value instanceof Operator => $value->value, - $value instanceof LogicalConnector => $value->value, - is_array($value) => '(' . implode(', ', $value) . ')', - default => $value, - }; - }, $clause->getParts()); - - return implode(' ', $parts); - } } From 00d4178958ebc0e7d9e85e6d46522e9f8d82a0fc Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 2 Jan 2026 19:36:10 +0000 Subject: [PATCH 42/49] refactor: simplify SQLite Where compiler by extending PostgreSQL implementation --- .../Dialects/PostgreSQL/Compilers/Where.php | 1 - .../Dialects/SQLite/Compilers/Where.php | 51 ++----------------- 2 files changed, 3 insertions(+), 49 deletions(-) diff --git a/src/Database/Dialects/PostgreSQL/Compilers/Where.php b/src/Database/Dialects/PostgreSQL/Compilers/Where.php index ebab885b..50f35e7d 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/Where.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/Where.php @@ -48,7 +48,6 @@ protected function compileSubqueryClause(SubqueryWhereClause $clause): string // For ANY/ALL/SOME, no space between operator and subquery $parts[] = $clause->getSubqueryOperator()->value . '(' . $clause->getSql() . ')'; } else { - // For regular subqueries, add space $parts[] = '(' . $clause->getSql() . ')'; } diff --git a/src/Database/Dialects/SQLite/Compilers/Where.php b/src/Database/Dialects/SQLite/Compilers/Where.php index 6cd11652..2531551c 100644 --- a/src/Database/Dialects/SQLite/Compilers/Where.php +++ b/src/Database/Dialects/SQLite/Compilers/Where.php @@ -4,54 +4,9 @@ namespace Phenix\Database\Dialects\SQLite\Compilers; -use Phenix\Database\Clauses\BasicWhereClause; -use Phenix\Database\Clauses\BetweenWhereClause; -use Phenix\Database\Clauses\SubqueryWhereClause; -use Phenix\Database\Constants\Operator; -use Phenix\Database\Constants\SQL; -use Phenix\Database\Dialects\Compilers\WhereCompiler; +use Phenix\Database\Dialects\PostgreSQL\Compilers\Where as PostgresWhereCompiler; -class Where extends WhereCompiler +class Where extends PostgresWhereCompiler { - protected function compileBasicClause(BasicWhereClause $clause): string - { - $column = $clause->getColumn(); - $operator = $clause->getOperator(); - - if ($operator === Operator::IN || $operator === Operator::NOT_IN) { - $placeholders = str_repeat(SQL::PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::PLACEHOLDER->value; - - return "{$column} {$operator->value} ({$placeholders})"; - } - - return "{$column} {$operator->value} " . SQL::PLACEHOLDER->value; - } - - protected function compileBetweenClause(BetweenWhereClause $clause): string - { - $column = $clause->getColumn(); - $operator = $clause->getOperator(); - - return "{$column} {$operator->value} ? AND ?"; - } - - protected function compileSubqueryClause(SubqueryWhereClause $clause): string - { - $parts = []; - - if ($clause->getColumn() !== null) { - $parts[] = $clause->getColumn(); - } - - $parts[] = $clause->getOperator()->value; - - if ($clause->getSubqueryOperator() !== null) { - // For ANY/ALL/SOME, no space between operator and subquery - $parts[] = $clause->getSubqueryOperator()->value . '(' . $clause->getSql() . ')'; - } else { - $parts[] = '(' . $clause->getSql() . ')'; - } - - return implode(' ', $parts); - } + // } From e3cec826b68bebc2af804c9ae4078020278681d0 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 2 Jan 2026 19:36:15 +0000 Subject: [PATCH 43/49] refactor: remove unused WhereClause import and commented pushClause method in Join class --- src/Database/Join.php | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/Database/Join.php b/src/Database/Join.php index 632194b3..f8be4fa3 100644 --- a/src/Database/Join.php +++ b/src/Database/Join.php @@ -5,7 +5,6 @@ namespace Phenix\Database; use Phenix\Database\Clauses\BasicWhereClause; -use Phenix\Database\Clauses\WhereClause; use Phenix\Database\Constants\JoinType; use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; @@ -21,18 +20,6 @@ public function __construct( $this->arguments = []; } - // protected function pushClause(WhereClause $clause, LogicalConnector $logicalConnector = LogicalConnector::AND): void - // { - // // For Join clauses, remove connector from first clause - // if (empty($this->clauses)) { - // $clause->setConnector(null); - // } else { - // $clause->setConnector($logicalConnector); - // } - - // $this->clauses[] = $clause; - // } - public function onEqual(string $column, string $value): self { $this->pushClause(new BasicWhereClause($column, Operator::EQUAL, $value)); From 63866068e6bd569f7a9f7570f9f0d31097a9576c Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 2 Jan 2026 20:16:49 +0000 Subject: [PATCH 44/49] feat: add insertOrIgnore, insertFrom, and upsert methods to QueryBuilder tests --- tests/Unit/Database/QueryBuilderTest.php | 148 +++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/tests/Unit/Database/QueryBuilderTest.php b/tests/Unit/Database/QueryBuilderTest.php index b9162e90..32732f72 100644 --- a/tests/Unit/Database/QueryBuilderTest.php +++ b/tests/Unit/Database/QueryBuilderTest.php @@ -595,3 +595,151 @@ expect($result->count())->toBe(3); expect($result->toArray())->toBe($updatedData); }); + +it('inserts records using insert or ignore successfully', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturnCallback(fn () => new Statement(new Result())); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users')->insertOrIgnore(['name' => 'Tony', 'email' => 'tony@example.com']); + + expect($result)->toBeTrue(); +}); + +it('fails on insert or ignore records', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->any()) + ->method('prepare') + ->willThrowException(new SqlQueryError('Query error')); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users')->insertOrIgnore(['name' => 'Tony', 'email' => 'tony@example.com']); + + expect($result)->toBeFalse(); +}); + +it('inserts records from subquery successfully', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturnCallback(fn () => new Statement(new Result())); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users_backup')->insertFrom( + function ($subquery) { + $subquery->from('users')->whereEqual('status', 'active'); + }, + ['id', 'name', 'email'] + ); + + expect($result)->toBeTrue(); +}); + +it('inserts records from subquery with ignore flag', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturnCallback(fn () => new Statement(new Result())); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users_backup')->insertFrom( + function ($subquery) { + $subquery->from('users')->whereEqual('status', 'active'); + }, + ['id', 'name', 'email'], + true + ); + + expect($result)->toBeTrue(); +}); + +it('fails on insert from records', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->any()) + ->method('prepare') + ->willThrowException(new SqlQueryError('Insert from subquery failed')); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users_backup')->insertFrom( + function ($subquery) { + $subquery->from('users')->whereEqual('status', 'active'); + }, + ['id', 'name', 'email'] + ); + + expect($result)->toBeFalse(); +}); + +it('upserts records successfully', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturnCallback(fn () => new Statement(new Result())); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users')->upsert( + ['name' => 'Tony', 'email' => 'tony@example.com', 'status' => 'active'], + ['email'] + ); + + expect($result)->toBeTrue(); +}); + +it('upserts multiple records successfully', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturnCallback(fn () => new Statement(new Result())); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users')->upsert( + [ + ['name' => 'Tony', 'email' => 'tony@example.com'], + ['name' => 'John', 'email' => 'john@example.com'], + ], + ['email'] + ); + + expect($result)->toBeTrue(); +}); + +it('fails on upsert records', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->any()) + ->method('prepare') + ->willThrowException(new SqlQueryError('Upsert failed')); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users')->upsert( + ['name' => 'Tony', 'email' => 'tony@example.com'], + ['email'] + ); + + expect($result)->toBeFalse(); +}); From 937f7de3c7ba8a5f8451a9d0610b488524072017 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 2 Jan 2026 23:01:26 +0000 Subject: [PATCH 45/49] refactor: remove RawWhereClause and its related compilation logic from WhereCompiler --- src/Database/Clauses/RawWhereClause.php | 41 ------------------- .../Dialects/Compilers/WhereCompiler.php | 21 ---------- 2 files changed, 62 deletions(-) delete mode 100644 src/Database/Clauses/RawWhereClause.php diff --git a/src/Database/Clauses/RawWhereClause.php b/src/Database/Clauses/RawWhereClause.php deleted file mode 100644 index 95f46ab6..00000000 --- a/src/Database/Clauses/RawWhereClause.php +++ /dev/null @@ -1,41 +0,0 @@ -parts = $parts; - $this->connector = $connector; - } - - public function getColumn(): null - { - return null; - } - - public function getOperator(): null - { - return null; - } - - public function getParts(): array - { - return $this->parts; - } - - public function renderValue(): string - { - // Raw clauses handle their own rendering through getParts() - return ''; - } -} diff --git a/src/Database/Dialects/Compilers/WhereCompiler.php b/src/Database/Dialects/Compilers/WhereCompiler.php index d668b312..4b33f93a 100644 --- a/src/Database/Dialects/Compilers/WhereCompiler.php +++ b/src/Database/Dialects/Compilers/WhereCompiler.php @@ -9,15 +9,10 @@ use Phenix\Database\Clauses\BooleanWhereClause; use Phenix\Database\Clauses\ColumnWhereClause; use Phenix\Database\Clauses\NullWhereClause; -use Phenix\Database\Clauses\RawWhereClause; use Phenix\Database\Clauses\SubqueryWhereClause; use Phenix\Database\Clauses\WhereClause; -use Phenix\Database\Constants\LogicalConnector; -use Phenix\Database\Constants\Operator; use Phenix\Database\Dialects\CompiledClause; -use function is_array; - abstract class WhereCompiler { /** @@ -53,7 +48,6 @@ protected function compileClause(WhereClause $clause): string $clause instanceof BetweenWhereClause => $this->compileBetweenClause($clause), $clause instanceof SubqueryWhereClause => $this->compileSubqueryClause($clause), $clause instanceof ColumnWhereClause => $this->compileColumnClause($clause), - $clause instanceof RawWhereClause => $this->compileRawClause($clause), default => '', }; } @@ -78,19 +72,4 @@ protected function compileColumnClause(ColumnWhereClause $clause): string { return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->getCompareColumn()}"; } - - protected function compileRawClause(RawWhereClause $clause): string - { - // For backwards compatibility with any remaining raw clauses - $parts = array_map(function ($value) { - return match (true) { - $value instanceof Operator => $value->value, - $value instanceof LogicalConnector => $value->value, - is_array($value) => '(' . implode(', ', $value) . ')', - default => $value, - }; - }, $clause->getParts()); - - return implode(' ', $parts); - } } From 7c847221bb753d3a0ae823ccf7d19677662901a4 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 5 Jan 2026 11:43:57 -0500 Subject: [PATCH 46/49] refactor: remove unused methods and simplify clause handling in various classes --- src/Database/Clause.php | 16 ---------------- src/Database/Clauses/BetweenWhereClause.php | 5 ----- src/Database/Clauses/SubqueryWhereClause.php | 5 ----- src/Database/Clauses/WhereClause.php | 5 ----- .../Dialects/Compilers/WhereCompiler.php | 4 ---- src/Database/Dialects/MySQL/Compilers/Select.php | 4 ---- .../Dialects/PostgreSQL/Compilers/Select.php | 5 +---- src/Database/Having.php | 4 ---- src/Database/Join.php | 7 ------- 9 files changed, 1 insertion(+), 54 deletions(-) diff --git a/src/Database/Clause.php b/src/Database/Clause.php index 1c8afee2..d2597cef 100644 --- a/src/Database/Clause.php +++ b/src/Database/Clause.php @@ -13,10 +13,8 @@ use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; use Phenix\Database\Contracts\Builder; -use Phenix\Util\Arr; use function count; -use function is_array; abstract class Clause extends Grammar implements Builder { @@ -95,18 +93,4 @@ protected function pushClause(WhereClause $where, LogicalConnector $logicalConne $this->clauses[] = $where; } - - protected function prepareClauses(array $clauses): array - { - return array_map(function (array $clause): array { - return array_map(function ($value) { - return match (true) { - $value instanceof Operator => $value->value, - $value instanceof LogicalConnector => $value->value, - is_array($value) => '(' . Arr::implodeDeeply($value, ', ') . ')', - default => $value, - }; - }, $clause); - }, $clauses); - } } diff --git a/src/Database/Clauses/BetweenWhereClause.php b/src/Database/Clauses/BetweenWhereClause.php index 9c3dbdd0..6dbf0852 100644 --- a/src/Database/Clauses/BetweenWhereClause.php +++ b/src/Database/Clauses/BetweenWhereClause.php @@ -38,11 +38,6 @@ public function getOperator(): Operator return $this->operator; } - public function getValues(): array - { - return $this->values; - } - public function renderValue(): string { return SQL::PLACEHOLDER->value . ' AND ' . SQL::PLACEHOLDER->value; diff --git a/src/Database/Clauses/SubqueryWhereClause.php b/src/Database/Clauses/SubqueryWhereClause.php index f66883bd..9bac5bf2 100644 --- a/src/Database/Clauses/SubqueryWhereClause.php +++ b/src/Database/Clauses/SubqueryWhereClause.php @@ -65,11 +65,6 @@ public function getSql(): string return $this->sql; } - public function getParams(): array - { - return $this->params; - } - public function renderValue(): string { // Render subquery with optional operator (ANY, ALL, SOME) diff --git a/src/Database/Clauses/WhereClause.php b/src/Database/Clauses/WhereClause.php index 16a19e9c..1187d22b 100644 --- a/src/Database/Clauses/WhereClause.php +++ b/src/Database/Clauses/WhereClause.php @@ -30,9 +30,4 @@ public function getConnector(): LogicalConnector|null { return $this->connector; } - - public function isFirstClause(): bool - { - return $this->getConnector() === null; - } } diff --git a/src/Database/Dialects/Compilers/WhereCompiler.php b/src/Database/Dialects/Compilers/WhereCompiler.php index 4b33f93a..1b2c2626 100644 --- a/src/Database/Dialects/Compilers/WhereCompiler.php +++ b/src/Database/Dialects/Compilers/WhereCompiler.php @@ -21,10 +21,6 @@ abstract class WhereCompiler */ public function compile(array $wheres): CompiledClause { - if (empty($wheres)) { - return new CompiledClause('', []); - } - $sql = []; foreach ($wheres as $index => $where) { diff --git a/src/Database/Dialects/MySQL/Compilers/Select.php b/src/Database/Dialects/MySQL/Compilers/Select.php index ff90c274..13918e68 100644 --- a/src/Database/Dialects/MySQL/Compilers/Select.php +++ b/src/Database/Dialects/MySQL/Compilers/Select.php @@ -17,10 +17,6 @@ public function __construct() protected function compileLock(QueryAst $ast): string { - if ($ast->lock === null) { - return ''; - } - return match ($ast->lock) { Lock::FOR_UPDATE => 'FOR UPDATE', Lock::FOR_SHARE => 'FOR SHARE', diff --git a/src/Database/Dialects/PostgreSQL/Compilers/Select.php b/src/Database/Dialects/PostgreSQL/Compilers/Select.php index 1be2ac50..c5ac89ea 100644 --- a/src/Database/Dialects/PostgreSQL/Compilers/Select.php +++ b/src/Database/Dialects/PostgreSQL/Compilers/Select.php @@ -31,10 +31,6 @@ public function compile(QueryAst $ast): CompiledClause protected function compileLock(QueryAst $ast): string { - if ($ast->lock === null) { - return ''; - } - return match ($ast->lock) { Lock::FOR_UPDATE => 'FOR UPDATE', Lock::FOR_SHARE => 'FOR SHARE', @@ -46,6 +42,7 @@ 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 => '', }; } } diff --git a/src/Database/Having.php b/src/Database/Having.php index 2ffded9b..be4b309e 100644 --- a/src/Database/Having.php +++ b/src/Database/Having.php @@ -16,10 +16,6 @@ public function __construct() public function toSql(): array { - if (empty($this->clauses)) { - return ['', []]; - } - $sql = []; foreach ($this->clauses as $clause) { diff --git a/src/Database/Join.php b/src/Database/Join.php index f8be4fa3..1648b30b 100644 --- a/src/Database/Join.php +++ b/src/Database/Join.php @@ -50,13 +50,6 @@ public function orOnNotEqual(string $column, string $value): self public function toSql(): array { - if (empty($this->clauses)) { - return [ - "{$this->type->value} {$this->relationship}", - [], - ]; - } - $sql = []; foreach ($this->clauses as $clause) { From 89628698fc3aaf3749bef322c70752a059336c73 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 5 Jan 2026 11:44:02 -0500 Subject: [PATCH 47/49] fix: correct return value in compileBetweenClause method to use renderValue --- src/Database/Dialects/MySQL/Compilers/Where.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Dialects/MySQL/Compilers/Where.php b/src/Database/Dialects/MySQL/Compilers/Where.php index eda4382a..390e7a3b 100644 --- a/src/Database/Dialects/MySQL/Compilers/Where.php +++ b/src/Database/Dialects/MySQL/Compilers/Where.php @@ -25,7 +25,7 @@ protected function compileBasicClause(BasicWhereClause $clause): string protected function compileBetweenClause(BetweenWhereClause $clause): string { - return "{$clause->getColumn()} {$clause->getOperator()->value} ? AND ?"; + return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->renderValue()}"; } protected function compileSubqueryClause(SubqueryWhereClause $clause): string From d6b87d2358ba6491952769a13a86debf7670fb73 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 5 Jan 2026 17:17:16 +0000 Subject: [PATCH 48/49] fix: update devcontainer configuration to include SSH mounts [skip ci] --- .devcontainer/devcontainer.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 75d65d8a..45ceb1f4 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,5 +19,7 @@ "forwardPorts": [ 8080 ], - "remoteUser": "root" + "mounts": [ + "source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/vscode/.ssh,readonly,type=bind" + ] } From 59ba8dea3dc8270d5a4952c1c94f8657357e8fe4 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 5 Jan 2026 17:24:21 +0000 Subject: [PATCH 49/49] fix: update SSH mount path in devcontainer configuration --- .devcontainer/devcontainer.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 45ceb1f4..f4f09d8d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,6 +20,9 @@ 8080 ], "mounts": [ - "source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/vscode/.ssh,readonly,type=bind" - ] + "source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/root/.ssh,readonly,type=bind" + ], + "remoteEnv": { + "SSH_AUTH_SOCK": "${localEnv:SSH_AUTH_SOCK}" + } }