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 new file mode 100644 index 00000000..f4f09d8d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +{ + "name": "PHP", + "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 + ], + "mounts": [ + "source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/root/.ssh,readonly,type=bind" + ], + "remoteEnv": { + "SSH_AUTH_SOCK": "${localEnv:SSH_AUTH_SOCK}" + } +} 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 diff --git a/composer.json b/composer.json index 836b55eb..8eb5399d 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,9 @@ "require": { "php": "^8.2", "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", diff --git a/src/Database/Clause.php b/src/Database/Clause.php index 70953836..d2597cef 100644 --- a/src/Database/Clause.php +++ b/src/Database/Clause.php @@ -5,29 +5,34 @@ 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 is_array; +use function count; abstract class Clause extends Grammar implements Builder { use HasWhereClause; use PrepareColumns; + /** + * @var array + */ protected array $clauses; + protected array $arguments; 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( @@ -46,7 +51,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(['*']); @@ -55,9 +60,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); } @@ -66,37 +78,19 @@ 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; } - - 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 LogicalOperator => $value->value, - is_array($value) => '(' . Arr::implodeDeeply($value, ', ') . ')', - default => $value, - }; - }, $clause); - }, $clauses); - } } diff --git a/src/Database/Clauses/BasicWhereClause.php b/src/Database/Clauses/BasicWhereClause.php new file mode 100644 index 00000000..7746a37b --- /dev/null +++ b/src/Database/Clauses/BasicWhereClause.php @@ -0,0 +1,81 @@ +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), SQL::PLACEHOLDER->value)) . ')'; + } + + return SQL::PLACEHOLDER->value; + } + + // 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..6dbf0852 --- /dev/null +++ b/src/Database/Clauses/BetweenWhereClause.php @@ -0,0 +1,45 @@ +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 renderValue(): string + { + return SQL::PLACEHOLDER->value . ' AND ' . SQL::PLACEHOLDER->value; + } +} 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/SubqueryWhereClause.php b/src/Database/Clauses/SubqueryWhereClause.php new file mode 100644 index 00000000..9bac5bf2 --- /dev/null +++ b/src/Database/Clauses/SubqueryWhereClause.php @@ -0,0 +1,73 @@ + 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 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..1187d22b --- /dev/null +++ b/src/Database/Clauses/WhereClause.php @@ -0,0 +1,33 @@ +connector = $connector; + } + + public function getConnector(): LogicalConnector|null + { + return $this->connector; + } +} diff --git a/src/Database/Concerns/Query/BuildsQuery.php b/src/Database/Concerns/Query/BuildsQuery.php index 2df6f7fe..24ac37e9 100644 --- a/src/Database/Concerns/Query/BuildsQuery.php +++ b/src/Database/Concerns/Query/BuildsQuery.php @@ -8,24 +8,16 @@ 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; -use function array_is_list; -use function array_keys; -use function array_unique; -use function array_values; -use function ksort; - trait BuildsQuery { - use HasLock; - public function table(string $table): static { $this->table = $table; @@ -70,74 +62,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 +89,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,207 +121,38 @@ 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 + * @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(), - }; - - return [ - $sql, - $this->arguments, - ]; - } - - 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 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 = [ - $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); + $ast = $this->buildAst(); + $dialect = DialectFactory::fromDriver($this->driver); + + 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->returning = $this->returning ?? []; + $ast->params = $this->arguments; + + return $ast; } } diff --git a/src/Database/Concerns/Query/HasSentences.php b/src/Database/Concerns/Query/HasSentences.php deleted file mode 100644 index f667e701..00000000 --- a/src/Database/Concerns/Query/HasSentences.php +++ /dev/null @@ -1,200 +0,0 @@ -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 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 */ - $transaction = $this->connection->beginTransaction(); - - $this->transaction = $transaction; - - try { - $result = $callback($this); - - $transaction->commit(); - - unset($this->transaction); - - return $result; - } catch (Throwable $e) { - report($e); - - $transaction->rollBack(); - - unset($this->transaction); - - throw $e; - } - } - - public function beginTransaction(): SqlTransaction - { - $this->transaction = $this->connection->beginTransaction(); - - return $this->transaction; - } - - public function commit(): void - { - if ($this->transaction) { - $this->transaction->commit(); - $this->transaction = null; - } - } - - public function rollBack(): void - { - if ($this->transaction) { - $this->transaction->rollBack(); - $this->transaction = null; - } - } - - public function hasActiveTransaction(): bool - { - return isset($this->transaction) && $this->transaction !== null; - } - - protected function exec(string $dml, array $params = []): mixed - { - $executor = $this->hasActiveTransaction() ? $this->transaction : $this->connection; - - return $executor->prepare($dml)->execute($params); - } -} diff --git a/src/Database/Concerns/Query/HasTransaction.php b/src/Database/Concerns/Query/HasTransaction.php new file mode 100644 index 00000000..359b0690 --- /dev/null +++ b/src/Database/Concerns/Query/HasTransaction.php @@ -0,0 +1,75 @@ +connection->beginTransaction(); + + $this->transaction = $transaction; + + try { + $result = $callback($this); + + $transaction->commit(); + + unset($this->transaction); + + return $result; + } catch (Throwable $e) { + report($e); + + $transaction->rollBack(); + + unset($this->transaction); + + throw $e; + } + } + + public function beginTransaction(): SqlTransaction + { + $this->transaction = $this->connection->beginTransaction(); + + return $this->transaction; + } + + public function commit(): void + { + if ($this->transaction) { + $this->transaction->commit(); + $this->transaction = null; + } + } + + public function rollBack(): void + { + if ($this->transaction) { + $this->transaction->rollBack(); + $this->transaction = null; + } + } + + public function hasActiveTransaction(): bool + { + return isset($this->transaction) && $this->transaction !== null; + } + + protected function exec(string $dml, array $params = []): mixed + { + $executor = $this->hasActiveTransaction() ? $this->transaction : $this->connection; + + return $executor->prepare($dml)->execute($params); + } +} 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 33428058..a0e0fc8d 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,21 +29,21 @@ 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; } - 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, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::NOT_EQUAL, $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/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/Connections/ConnectionFactory.php b/src/Database/Connections/ConnectionFactory.php index 611f20e0..94e7ce4f 100644 --- a/src/Database/Connections/ConnectionFactory.php +++ b/src/Database/Connections/ConnectionFactory.php @@ -8,13 +8,14 @@ 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; use Phenix\Redis\ClientWrapper; use SensitiveParameter; use function Amp\Redis\createRedisClient; +use function Amp\SQLite3\connect; use function sprintf; class ConnectionFactory @@ -25,12 +26,15 @@ public static function make(Driver $driver, #[SensitiveParameter] array $setting Driver::MYSQL => self::createMySqlConnection($settings), Driver::POSTGRESQL => self::createPostgreSqlConnection($settings), Driver::REDIS => self::createRedisConnection($settings), - default => throw new InvalidArgumentException( - sprintf('Unsupported driver: %s', $driver->name) - ), + Driver::SQLITE => self::createSqliteConnection($settings), }; } + 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/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 @@ +'; case GREATER_THAN_OR_EQUAL = '>='; case LESS_THAN = '<'; diff --git a/src/Database/Contracts/ClauseCompiler.php b/src/Database/Contracts/ClauseCompiler.php new file mode 100644 index 00000000..00493450 --- /dev/null +++ b/src/Database/Contracts/ClauseCompiler.php @@ -0,0 +1,13 @@ +} A tuple of SQL string and parameters + */ + public function compile(QueryAst $ast): array; +} diff --git a/src/Database/Dialects/CompiledClause.php b/src/Database/Dialects/CompiledClause.php new file mode 100644 index 00000000..89b0b556 --- /dev/null +++ b/src/Database/Dialects/CompiledClause.php @@ -0,0 +1,18 @@ + $params The parameters for prepared statements + */ + public function __construct( + public string $sql, + public array $params = [] + ) { + } +} diff --git a/src/Database/Dialects/Compilers/DeleteCompiler.php b/src/Database/Dialects/Compilers/DeleteCompiler.php new file mode 100644 index 00000000..86a3fb5d --- /dev/null +++ b/src/Database/Dialects/Compilers/DeleteCompiler.php @@ -0,0 +1,34 @@ +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..312c2bbb --- /dev/null +++ b/src/Database/Dialects/Compilers/ExistsCompiler.php @@ -0,0 +1,44 @@ +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..45a3f57c --- /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(array_values($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..ff18f24a --- /dev/null +++ b/src/Database/Dialects/Compilers/SelectCompiler.php @@ -0,0 +1,124 @@ +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..dcd6861f --- /dev/null +++ b/src/Database/Dialects/Compilers/UpdateCompiler.php @@ -0,0 +1,60 @@ +table; + + // SET col1 = ?, col2 = ? + // Extract params from values (these are actual values, not placeholders) + $columns = []; + + foreach ($ast->values as $column => $value) { + $params[] = $value; + $columns[] = $this->compileSetClause($column, count($params)); + } + + $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); + } + + if (! empty($ast->returning)) { + $parts[] = 'RETURNING'; + $parts[] = Arr::implodeDeeply($ast->returning, ', '); + } + + $sql = Arr::implodeDeeply($parts); + + 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 new file mode 100644 index 00000000..1b2c2626 --- /dev/null +++ b/src/Database/Dialects/Compilers/WhereCompiler.php @@ -0,0 +1,71 @@ + $wheres + * @return CompiledClause + */ + public function compile(array $wheres): 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), + 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()}"; + } +} 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/DialectFactory.php b/src/Database/Dialects/DialectFactory.php new file mode 100644 index 00000000..252ea7f0 --- /dev/null +++ b/src/Database/Dialects/DialectFactory.php @@ -0,0 +1,39 @@ + + */ + private static array $instances = []; + + private function __construct() + { + // Prevent instantiation + } + + 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(), + default => new MysqlDialect(), + }; + } + + public static function clearCache(): void + { + self::$instances = []; + } +} diff --git a/src/Database/Dialects/MySQL/Compilers/Delete.php b/src/Database/Dialects/MySQL/Compilers/Delete.php new file mode 100644 index 00000000..7d2623dd --- /dev/null +++ b/src/Database/Dialects/MySQL/Compilers/Delete.php @@ -0,0 +1,15 @@ +whereCompiler = new Where(); + } +} diff --git a/src/Database/Dialects/MySQL/Compilers/Exists.php b/src/Database/Dialects/MySQL/Compilers/Exists.php new file mode 100644 index 00000000..aa038b96 --- /dev/null +++ b/src/Database/Dialects/MySQL/Compilers/Exists.php @@ -0,0 +1,15 @@ +whereCompiler = new Where(); + } +} diff --git a/src/Database/Dialects/MySQL/Compilers/Insert.php b/src/Database/Dialects/MySQL/Compilers/Insert.php new file mode 100644 index 00000000..3dd95778 --- /dev/null +++ b/src/Database/Dialects/MySQL/Compilers/Insert.php @@ -0,0 +1,27 @@ + "{$column} = VALUES({$column})", + $ast->uniqueColumns + ); + + return 'ON DUPLICATE KEY UPDATE ' . Arr::implodeDeeply($columns, ', '); + } +} diff --git a/src/Database/Dialects/MySQL/Compilers/Select.php b/src/Database/Dialects/MySQL/Compilers/Select.php new file mode 100644 index 00000000..13918e68 --- /dev/null +++ b/src/Database/Dialects/MySQL/Compilers/Select.php @@ -0,0 +1,27 @@ +whereCompiler = new Where(); + } + + protected function compileLock(QueryAst $ast): string + { + 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/Update.php b/src/Database/Dialects/MySQL/Compilers/Update.php new file mode 100644 index 00000000..7cbb2c04 --- /dev/null +++ b/src/Database/Dialects/MySQL/Compilers/Update.php @@ -0,0 +1,20 @@ +whereCompiler = new Where(); + } + + protected function compileSetClause(string $column, int $paramIndex): string + { + return "{$column} = ?"; + } +} diff --git a/src/Database/Dialects/MySQL/Compilers/Where.php b/src/Database/Dialects/MySQL/Compilers/Where.php new file mode 100644 index 00000000..390e7a3b --- /dev/null +++ b/src/Database/Dialects/MySQL/Compilers/Where.php @@ -0,0 +1,46 @@ +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; + } + + protected function compileBetweenClause(BetweenWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->renderValue()}"; + } + + protected 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); + } +} diff --git a/src/Database/Dialects/MySQL/MysqlDialect.php b/src/Database/Dialects/MySQL/MysqlDialect.php new file mode 100644 index 00000000..e9cd5f5d --- /dev/null +++ b/src/Database/Dialects/MySQL/MysqlDialect.php @@ -0,0 +1,29 @@ +initializeCompilers(); + } + + protected function initializeCompilers(): void + { + $this->selectCompiler = new Select(); + $this->insertCompiler = new Insert(); + $this->updateCompiler = new Update(); + $this->deleteCompiler = new Delete(); + $this->existsCompiler = new Exists(); + } +} diff --git a/src/Database/Dialects/PostgreSQL/Compilers/Delete.php b/src/Database/Dialects/PostgreSQL/Compilers/Delete.php new file mode 100644 index 00000000..16d40cdd --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Compilers/Delete.php @@ -0,0 +1,28 @@ +whereCompiler = new Where(); + } + + public function compile(QueryAst $ast): CompiledClause + { + $clause = parent::compile($ast); + $sql = $this->convertPlaceholders($clause->sql); + + return new CompiledClause($sql, $clause->params); + } +} diff --git a/src/Database/Dialects/PostgreSQL/Compilers/Exists.php b/src/Database/Dialects/PostgreSQL/Compilers/Exists.php new file mode 100644 index 00000000..9c5ea568 --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Compilers/Exists.php @@ -0,0 +1,30 @@ +whereCompiler = new Where(); + } + + 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/Insert.php b/src/Database/Dialects/PostgreSQL/Compilers/Insert.php new file mode 100644 index 00000000..34a5bfda --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Compilers/Insert.php @@ -0,0 +1,77 @@ +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(array_values($placeholders), ', '); + } + + $parts[] = 'ON CONFLICT DO NOTHING'; + + $sql = Arr::implodeDeeply($parts); + $sql = $this->convertPlaceholders($sql); + + return new CompiledClause($sql, $ast->params); + } + + $result = parent::compile($ast); + + return new CompiledClause( + $this->convertPlaceholders($result->sql), + $result->params + ); + } +} diff --git a/src/Database/Dialects/PostgreSQL/Compilers/Select.php b/src/Database/Dialects/PostgreSQL/Compilers/Select.php new file mode 100644 index 00000000..c5ac89ea --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Compilers/Select.php @@ -0,0 +1,48 @@ +whereCompiler = new Where(); + } + + 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 + { + 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/Update.php b/src/Database/Dialects/PostgreSQL/Compilers/Update.php new file mode 100644 index 00000000..178f0e41 --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Compilers/Update.php @@ -0,0 +1,39 @@ +whereCompiler = new Where(); + } + + protected function compileSetClause(string $column, int $paramIndex): string + { + return "{$column} = $" . $paramIndex; + } + + public function compile(QueryAst $ast): CompiledClause + { + $result = parent::compile($ast); + + $paramsCount = count($ast->values); + + return new CompiledClause( + $this->convertPlaceholders($result->sql, $paramsCount), + $result->params + ); + } +} diff --git a/src/Database/Dialects/PostgreSQL/Compilers/Where.php b/src/Database/Dialects/PostgreSQL/Compilers/Where.php new file mode 100644 index 00000000..50f35e7d --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Compilers/Where.php @@ -0,0 +1,56 @@ +getColumn(); + $operator = $clause->getOperator(); + + if ($clause->isInOperator()) { + $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} {$clause->renderValue()}"; + } + + 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); + } +} diff --git a/src/Database/Dialects/PostgreSQL/Concerns/HasPlaceholders.php b/src/Database/Dialects/PostgreSQL/Concerns/HasPlaceholders.php new file mode 100644 index 00000000..23bfd18e --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Concerns/HasPlaceholders.php @@ -0,0 +1,21 @@ +initializeCompilers(); + } + + protected function initializeCompilers(): void + { + $this->selectCompiler = new Select(); + $this->insertCompiler = new Insert(); + $this->updateCompiler = new Update(); + $this->deleteCompiler = new Delete(); + $this->existsCompiler = new Exists(); + } +} diff --git a/src/Database/Dialects/SQLite/Compilers/Delete.php b/src/Database/Dialects/SQLite/Compilers/Delete.php new file mode 100644 index 00000000..faf689c3 --- /dev/null +++ b/src/Database/Dialects/SQLite/Compilers/Delete.php @@ -0,0 +1,42 @@ +whereCompiler = new Where(); + } + + 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/Dialects/SQLite/Compilers/Exists.php b/src/Database/Dialects/SQLite/Compilers/Exists.php new file mode 100644 index 00000000..30eeb27c --- /dev/null +++ b/src/Database/Dialects/SQLite/Compilers/Exists.php @@ -0,0 +1,15 @@ +whereCompiler = new Where(); + } +} diff --git a/src/Database/Dialects/SQLite/Compilers/Insert.php b/src/Database/Dialects/SQLite/Compilers/Insert.php new file mode 100644 index 00000000..c374f426 --- /dev/null +++ b/src/Database/Dialects/SQLite/Compilers/Insert.php @@ -0,0 +1,43 @@ +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/Select.php b/src/Database/Dialects/SQLite/Compilers/Select.php new file mode 100644 index 00000000..945311f5 --- /dev/null +++ b/src/Database/Dialects/SQLite/Compilers/Select.php @@ -0,0 +1,22 @@ +whereCompiler = new Where(); + } + + protected function compileLock(QueryAst $ast): string + { + // SQLite doesn't support row-level locks + return ''; + } +} diff --git a/src/Database/Dialects/SQLite/Compilers/Update.php b/src/Database/Dialects/SQLite/Compilers/Update.php new file mode 100644 index 00000000..6de0afa1 --- /dev/null +++ b/src/Database/Dialects/SQLite/Compilers/Update.php @@ -0,0 +1,20 @@ +whereCompiler = new Where(); + } + + protected function compileSetClause(string $column, int $paramIndex): string + { + return "{$column} = ?"; + } +} diff --git a/src/Database/Dialects/SQLite/Compilers/Where.php b/src/Database/Dialects/SQLite/Compilers/Where.php new file mode 100644 index 00000000..2531551c --- /dev/null +++ b/src/Database/Dialects/SQLite/Compilers/Where.php @@ -0,0 +1,12 @@ +initializeCompilers(); + } + + protected function initializeCompilers(): void + { + $this->selectCompiler = new Select(); + $this->insertCompiler = new Insert(); + $this->updateCompiler = new Update(); + $this->deleteCompiler = new Delete(); + $this->existsCompiler = new Exists(); + } +} diff --git a/src/Database/Having.php b/src/Database/Having.php index ca47d7fa..be4b309e 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,18 @@ public function __construct() public function toSql(): array { - $clauses = Arr::implodeDeeply($this->prepareClauses($this->clauses)); + $sql = []; - return ["HAVING {$clauses}", $this->arguments]; + foreach ($this->clauses as $clause) { + $clauseSql = "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::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..1648b30b 100644 --- a/src/Database/Join.php +++ b/src/Database/Join.php @@ -4,11 +4,11 @@ namespace Phenix\Database; +use Phenix\Database\Clauses\BasicWhereClause; 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 { @@ -22,38 +22,54 @@ public function __construct( 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 + public function onNotEqual(string $column, string $value): self { - $this->pushClause([$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([$column, Operator::DISTINCT, $value], LogicalOperator::OR); + $this->pushClause(new BasicWhereClause($column, Operator::NOT_EQUAL, $value), LogicalConnector::OR); return $this; } public function toSql(): array { - $clauses = Arr::implodeDeeply($this->prepareClauses($this->clauses)); + $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/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index cd534151..33287321 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; @@ -22,36 +19,15 @@ 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 HasJoinClause; - protected DatabaseModel $model; /** diff --git a/src/Database/QueryAst.php b/src/Database/QueryAst.php new file mode 100644 index 00000000..16d40cb1 --- /dev/null +++ b/src/Database/QueryAst.php @@ -0,0 +1,90 @@ + + */ + 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/src/Database/QueryBase.php b/src/Database/QueryBase.php index 7e7099cb..4b69b661 100644 --- a/src/Database/QueryBase.php +++ b/src/Database/QueryBase.php @@ -4,14 +4,23 @@ 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; use Phenix\Database\Contracts\Builder; use Phenix\Database\Contracts\QueryBuilder; abstract class QueryBase extends Clause implements QueryBuilder, Builder { use HasDriver; + use BuildsQuery; + use HasLock; + use HasJoinClause; protected string $table; @@ -39,6 +48,8 @@ abstract class QueryBase extends Clause implements QueryBuilder, Builder protected array $uniqueColumns; + protected array $returning = []; + public function __construct() { $this->ignore = false; @@ -59,5 +70,131 @@ protected function resetBaseProperties(): void $this->clauses = []; $this->arguments = []; $this->uniqueColumns = []; + $this->returning = []; + } + + 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(); + } + + /** + * 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)) { + 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..65b56449 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -4,12 +4,16 @@ namespace Phenix\Database; +use Amp\Mysql\Internal\MysqlPooledResult; use Amp\Sql\Common\SqlCommonConnectionPool; +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; @@ -17,24 +21,7 @@ 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 HasJoinClause; + use HasTransaction; protected SqlCommonConnectionPool $connection; @@ -67,6 +54,223 @@ 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 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); + + 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; + } + } + + /** + * 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; + + $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; + } + } + + /** + * 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> */ @@ -88,9 +292,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..e2514f92 100644 --- a/src/Database/QueryGenerator.php +++ b/src/Database/QueryGenerator.php @@ -5,26 +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 { - 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 HasJoinClause; - public function __construct(Driver $driver = Driver::MYSQL) { parent::__construct(); @@ -41,53 +26,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 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/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; } diff --git a/src/Testing/Concerns/RefreshDatabase.php b/src/Testing/Concerns/RefreshDatabase.php index 1cc9674a..add86d5e 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,50 @@ 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) { + // Best-effort reset of AUTOINCREMENT sequences; ignore errors + } + } finally { + try { + $connection->prepare('COMMIT')->execute(); + } catch (Throwable) { + // Best-effort commit; ignore errors + } + } + } } diff --git a/tests/Unit/Database/Dialects/DialectFactoryTest.php b/tests/Unit/Database/Dialects/DialectFactoryTest.php new file mode 100644 index 00000000..e542bb7b --- /dev/null +++ b/tests/Unit/Database/Dialects/DialectFactoryTest.php @@ -0,0 +1,48 @@ +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); +}); diff --git a/tests/Unit/Database/QueryBuilderTest.php b/tests/Unit/Database/QueryBuilderTest.php index f30c1dad..32732f72 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,337 @@ $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); +}); + +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); +}); + +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(); +}); 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/DeleteStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php new file mode 100644 index 00000000..0e9f6f56 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php @@ -0,0 +1,205 @@ +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(); +}); + +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/Postgres/GroupByStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/GroupByStatementTest.php new file mode 100644 index 00000000..f4223bb5 --- /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 > $1 " + . "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/Postgres/HavingClauseTest.php b/tests/Unit/Database/QueryGenerator/Postgres/HavingClauseTest.php new file mode 100644 index 00000000..3a1ec837 --- /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 > $1 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 > $1 AND products.category_id > $2 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 > $2 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 < $1 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 = $1 GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([10]); +}); 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/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/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/Postgres/SelectColumnsTest.php b/tests/Unit/Database/QueryGenerator/Postgres/SelectColumnsTest.php new file mode 100644 index 00000000..1bb05d0d --- /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], + ['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], + ['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/Postgres/UpdateStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php new file mode 100644 index 00000000..c1a9ca0b --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php @@ -0,0 +1,231 @@ +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]); +}); + +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/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/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], +]); 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/DeleteStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php new file mode 100644 index 00000000..7eedbb1c --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php @@ -0,0 +1,205 @@ +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(); +}); + +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]); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php new file mode 100644 index 00000000..784a5bb5 --- /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(); +}); 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]); +}); 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(); +}); 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']); +}); 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(); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php new file mode 100644 index 00000000..9f1cd124 --- /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], + ['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], + ['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([]); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php new file mode 100644 index 00000000..c8f8f85c --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php @@ -0,0 +1,231 @@ +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]); +}); + +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]); +}); 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(); +}); 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], +]); 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/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/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) { 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