diff --git a/.gitignore b/.gitignore index 837c7528..3c2bc1f6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ Thumbs.db build .env knowledge +.agents +AGENTS.md +.devcontainer diff --git a/composer.json b/composer.json index 8d355f61..f96b3b36 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,8 @@ "Phenix\\": "src/" }, "files": [ - "src/functions.php" + "src/functions.php", + "src/Database/functions.php" ] }, "autoload-dev": { diff --git a/src/Database/Alias.php b/src/Database/Alias.php index c3a903a3..aa0a372c 100644 --- a/src/Database/Alias.php +++ b/src/Database/Alias.php @@ -4,10 +4,13 @@ namespace Phenix\Database; +use Phenix\Database\Concerns\HasDriver; use Stringable; class Alias implements Stringable { + use HasDriver; + protected string $alias; public function __construct(protected readonly string $name) @@ -24,7 +27,14 @@ public function as(string $alias): self public function __toString(): string { - return "{$this->name} AS {$this->alias}"; + $parts = array_map( + fn (string $part): string => (string) Wrapper::of($this->getDriver(), $part), + explode('.', $this->name) + ); + + $alias = Wrapper::of($this->getDriver(), $this->alias); + + return implode('.', $parts) . " AS {$alias}"; } public static function of(string $name): self diff --git a/src/Database/Clause.php b/src/Database/Clause.php index d2597cef..656201cf 100644 --- a/src/Database/Clause.php +++ b/src/Database/Clause.php @@ -4,93 +4,9 @@ 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\LogicalConnector; -use Phenix\Database\Constants\Operator; use Phenix\Database\Contracts\Builder; -use function count; - -abstract class Clause extends Grammar implements Builder +abstract class Clause extends ClauseBuilder 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, - LogicalConnector $logicalConnector = LogicalConnector::AND - ): void { - if ($value instanceof Closure) { - $this->whereSubquery( - subquery: $value, - comparisonOperator: $operator, - column: $column, - logicalConnector: $logicalConnector - ); - } else { - $this->pushWhereWithArgs($column, $operator, $value, $logicalConnector); - } - } - - protected function whereSubquery( - Closure $subquery, - Operator $comparisonOperator, - string|null $column = null, - Operator|null $operator = null, - LogicalConnector $logicalConnector = LogicalConnector::AND - ): void { - $builder = new Subquery($this->driver); - $builder->select(['*']); - - $subquery($builder); - - [$dml, $arguments] = $builder->toSql(); - - $connector = count($this->clauses) === 0 ? null : $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); - } - - protected function pushWhereWithArgs( - string $column, - Operator $operator, - array|string|int $value, - LogicalConnector $logicalConnector = LogicalConnector::AND - ): void { - $this->pushClause(new BasicWhereClause($column, $operator, $value, null, true), $logicalConnector); - - $this->arguments = array_merge($this->arguments, (array) $value); - } - - protected function pushClause(WhereClause $where, LogicalConnector $logicalConnector = LogicalConnector::AND): void - { - if (count($this->clauses) > 0) { - $where->setConnector($logicalConnector); - } - - $this->clauses[] = $where; - } + // } diff --git a/src/Database/ClauseBuilder.php b/src/Database/ClauseBuilder.php new file mode 100644 index 00000000..f2469614 --- /dev/null +++ b/src/Database/ClauseBuilder.php @@ -0,0 +1,169 @@ + + */ + protected array $clauses; + + /** + * @var array + */ + protected array $arguments; + + /** + * @return array + */ + protected function getClauses(): array + { + return $this->clauses; + } + + /** + * @return array + */ + protected function getArguments(): array + { + return $this->getClauseArguments(); + } + + protected function hasWhereClauses(): bool + { + return count($this->getClauses()) > 0; + } + + protected function addArguments(array $arguments): void + { + $this->arguments = [...$this->arguments, ...$arguments]; + } + + /** + * @return array + */ + protected function getClauseArguments(): array + { + $arguments = []; + + foreach ($this->getClauses() as $clause) { + $arguments = [...$arguments, ...$clause->getParams()]; + } + + return $arguments; + } + + protected function pushWhereClause( + WhereClause $where, + LogicalConnector $logicalConnector = LogicalConnector::AND + ): void { + if ($this->hasWhereClauses()) { + $where->setConnector($logicalConnector); + } + + $this->clauses[] = $where; + } + + protected function resolveWhereMethod( + string $column, + Operator $operator, + Closure|array|string|int $value, + LogicalConnector $logicalConnector = LogicalConnector::AND + ): void { + if ($value instanceof Closure) { + $this->whereSubquery( + subquery: $value, + comparisonOperator: $operator, + column: $column, + logicalConnector: $logicalConnector + ); + } else { + $this->pushWhereWithArgs($column, $operator, $value, $logicalConnector); + } + } + + protected function whereSubquery( + Closure $subquery, + Operator $comparisonOperator, + string|null $column = null, + Operator|null $operator = null, + LogicalConnector $logicalConnector = LogicalConnector::AND + ): void { + $builder = new Subquery($this->driver); + $builder->setDriver($this->driver); + $builder->selectAllColumns(); + + $subquery($builder); + + [$dml, $arguments] = $builder->toSql(); + + $connector = $this->hasWhereClauses() ? $logicalConnector : null; + + $this->pushWhereClause(new SubqueryWhereClause( + comparisonOperator: $comparisonOperator, + sql: trim($dml, '()'), + params: $arguments, + column: $column, + operator: $operator, + connector: $connector + ), $logicalConnector); + } + + /** + * @param array $columns + */ + protected function whereRowSubquery( + Closure $subquery, + Operator $comparisonOperator, + array $columns, + LogicalConnector $logicalConnector = LogicalConnector::AND + ): void { + $builder = new Subquery($this->driver); + $builder->setDriver($this->driver); + $builder->selectAllColumns(); + + $subquery($builder); + + [$dml, $arguments] = $builder->toSql(); + + $connector = $this->hasWhereClauses() ? $logicalConnector : null; + + $this->pushWhereClause(new RowWhereClause( + columns: $columns, + comparisonOperator: $comparisonOperator, + sql: trim($dml, '()'), + params: $arguments, + connector: $connector + ), $logicalConnector); + } + + protected function pushWhereWithArgs( + string $column, + Operator $operator, + array|string|int $value, + LogicalConnector $logicalConnector = LogicalConnector::AND + ): void { + $this->pushClause(new BasicWhereClause($column, $operator, $value, null, true), $logicalConnector); + } + + protected function pushClause(WhereClause $where, LogicalConnector $logicalConnector = LogicalConnector::AND): void + { + $this->pushWhereClause($where, $logicalConnector); + } +} diff --git a/src/Database/Clauses/BasicWhereClause.php b/src/Database/Clauses/BasicWhereClause.php index 7746a37b..2df1e008 100644 --- a/src/Database/Clauses/BasicWhereClause.php +++ b/src/Database/Clauses/BasicWhereClause.php @@ -6,7 +6,7 @@ use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; -use Phenix\Database\Constants\SQL; +use Phenix\Database\Constants\SqlMark; use function count; use function is_array; @@ -55,10 +55,10 @@ public function renderValue(): string if ($this->usePlaceholder) { // In WHERE context with parameterized queries, use placeholder if (is_array($this->value)) { - return '(' . implode(', ', array_fill(0, count($this->value), SQL::PLACEHOLDER->value)) . ')'; + return '(' . implode(', ', array_fill(0, count($this->value), SqlMark::Placeholder->value)) . ')'; } - return SQL::PLACEHOLDER->value; + return SqlMark::Placeholder->value; } // In JOIN ON context, render the value directly (typically a column name) @@ -78,4 +78,21 @@ public function isInOperator(): bool { return $this->operator === Operator::IN || $this->operator === Operator::NOT_IN; } + + public function usesPlaceholder(): bool + { + return $this->usePlaceholder; + } + + /** + * @return array + */ + public function getParams(): array + { + if (! $this->usePlaceholder) { + return []; + } + + return (array) $this->value; + } } diff --git a/src/Database/Clauses/BetweenWhereClause.php b/src/Database/Clauses/BetweenWhereClause.php index 6dbf0852..4d926693 100644 --- a/src/Database/Clauses/BetweenWhereClause.php +++ b/src/Database/Clauses/BetweenWhereClause.php @@ -6,7 +6,7 @@ use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; -use Phenix\Database\Constants\SQL; +use Phenix\Database\Constants\SqlMark; class BetweenWhereClause extends WhereClause { @@ -40,6 +40,14 @@ public function getOperator(): Operator public function renderValue(): string { - return SQL::PLACEHOLDER->value . ' AND ' . SQL::PLACEHOLDER->value; + return SqlMark::Placeholder->value . ' AND ' . SqlMark::Placeholder->value; + } + + /** + * @return array + */ + public function getParams(): array + { + return $this->values; } } diff --git a/src/Database/Clauses/DateWhereClause.php b/src/Database/Clauses/DateWhereClause.php new file mode 100644 index 00000000..b4bed394 --- /dev/null +++ b/src/Database/Clauses/DateWhereClause.php @@ -0,0 +1,68 @@ +column = $column; + $this->operator = $operator; + $this->function = $function; + $this->value = $value; + $this->connector = $connector; + } + + public function getColumn(): string + { + return $this->column; + } + + public function getOperator(): Operator + { + return $this->operator; + } + + public function getFunction(): DatabaseFunction + { + return $this->function; + } + + public function getValue(): string|int + { + return $this->value; + } + + public function renderValue(): string + { + return SqlMark::Placeholder->value; + } + + /** + * @return array + */ + public function getParams(): array + { + return [$this->value]; + } +} diff --git a/src/Database/Clauses/RowWhereClause.php b/src/Database/Clauses/RowWhereClause.php new file mode 100644 index 00000000..e9613c5f --- /dev/null +++ b/src/Database/Clauses/RowWhereClause.php @@ -0,0 +1,61 @@ + $columns + * @param array $params + */ + public function __construct( + protected array $columns, + protected Operator $comparisonOperator, + protected string $sql, + protected array $params, + LogicalConnector|null $connector = null + ) { + $this->connector = $connector; + } + + /** + * @return array + */ + public function getColumns(): array + { + return $this->columns; + } + + public function getColumn(): null + { + return null; + } + + public function getOperator(): Operator + { + return $this->comparisonOperator; + } + + public function getSql(): string + { + return $this->sql; + } + + /** + * @return array + */ + public function getParams(): array + { + return $this->params; + } + + public function renderValue(): string + { + return "({$this->sql})"; + } +} diff --git a/src/Database/Clauses/SubqueryWhereClause.php b/src/Database/Clauses/SubqueryWhereClause.php index 9bac5bf2..5bb0ab93 100644 --- a/src/Database/Clauses/SubqueryWhereClause.php +++ b/src/Database/Clauses/SubqueryWhereClause.php @@ -65,6 +65,14 @@ public function getSql(): string return $this->sql; } + /** + * @return array + */ + public function getParams(): array + { + return $this->params; + } + public function renderValue(): string { // Render subquery with optional operator (ANY, ALL, SOME) diff --git a/src/Database/Clauses/WhereClause.php b/src/Database/Clauses/WhereClause.php index 1187d22b..0125d387 100644 --- a/src/Database/Clauses/WhereClause.php +++ b/src/Database/Clauses/WhereClause.php @@ -21,6 +21,14 @@ abstract public function getOperator(): Operator|null; */ abstract public function renderValue(): string; + /** + * @return array + */ + public function getParams(): array + { + return []; + } + public function setConnector(LogicalConnector $connector): void { $this->connector = $connector; diff --git a/src/Database/Concerns/HasDriver.php b/src/Database/Concerns/HasDriver.php new file mode 100644 index 00000000..ec98b03d --- /dev/null +++ b/src/Database/Concerns/HasDriver.php @@ -0,0 +1,24 @@ +driver = $driver; + + return $this; + } + + public function getDriver(): Driver + { + return $this->driver; + } +} diff --git a/src/Database/Concerns/Query/BuildsQuery.php b/src/Database/Concerns/Query/BuildsQuery.php index 24ac37e9..52491036 100644 --- a/src/Database/Concerns/Query/BuildsQuery.php +++ b/src/Database/Concerns/Query/BuildsQuery.php @@ -6,39 +6,39 @@ use Closure; use Phenix\Database\Constants\Action; -use Phenix\Database\Constants\Operator; use Phenix\Database\Constants\Order; use Phenix\Database\Dialects\DialectFactory; -use Phenix\Database\Functions; +use Phenix\Database\Funct; use Phenix\Database\Having; use Phenix\Database\QueryAst; use Phenix\Database\SelectCase; use Phenix\Database\Subquery; -use Phenix\Util\Arr; + +use function is_string; trait BuildsQuery { public function table(string $table): static { - $this->table = $table; + $this->ast->table = $table; return $this; } - public function from(Closure|string $table): static + public function from(Closure|Subquery|string $table): static { if ($table instanceof Closure) { $builder = new Subquery($this->driver); + $builder->setDriver($this->driver); $builder->selectAllColumns(); $table($builder); - [$dml, $arguments] = $builder->toSql(); - - $this->table($dml); - - $this->arguments = array_merge($this->arguments, $arguments); + $this->ast->table = $builder; + } elseif ($table instanceof Subquery) { + $table->setDriver($this->driver); + $this->ast->table = $table; } else { $this->table($table); } @@ -48,9 +48,9 @@ public function from(Closure|string $table): static public function select(array $columns): static { - $this->action = Action::SELECT; + $this->ast->action = Action::SELECT; - $this->columns = $columns; + $this->ast->columns = $columns; return $this; } @@ -62,14 +62,13 @@ public function selectAllColumns(): static return $this; } - public function groupBy(Functions|array|string $column): static + public function groupBy(Funct|array|string $column): static { - $column = match (true) { - $column instanceof Functions => (string) $column, - default => $column, - }; + if ($column instanceof Funct || is_string($column)) { + $column = [$column]; + } - $this->groupBy = [Operator::GROUP_BY->value, Arr::implodeDeeply((array) $column, ', ')]; + $this->ast->groups = $column; return $this; } @@ -77,33 +76,29 @@ public function groupBy(Functions|array|string $column): static public function having(Closure $clause): static { $having = new Having(); + $having->setDriver($this->driver); $clause($having); - [$dml, $arguments] = $having->toSql(); - - $this->having = $dml; - - $this->arguments = array_merge($this->arguments, $arguments); + $this->ast->having = $having; return $this; } public function orderBy(SelectCase|array|string $column, Order $order = Order::DESC): static { - $column = match (true) { - $column instanceof SelectCase => '(' . $column . ')', - default => $column, - }; + if ($column instanceof SelectCase || is_string($column)) { + $column = [$column]; + } - $this->orderBy = [Operator::ORDER_BY->value, Arr::implodeDeeply((array) $column, ', '), $order->value]; + $this->ast->orders = [$column, $order->value]; return $this; } public function limit(int $number): static { - $this->limit = [Operator::LIMIT->value, abs($number)]; + $this->ast->limit = abs($number); return $this; } @@ -116,7 +111,7 @@ public function page(int $page = 1, int $perPage = 15): static $offset = $page === 1 ? 0 : (($page - 1) * abs($perPage)); - $this->offset = [Operator::OFFSET->value, $offset]; + $this->ast->offset = $offset; return $this; } @@ -126,33 +121,13 @@ public function page(int $page = 1, int $perPage = 15): static */ public function toSql(): array { - $ast = $this->buildAst(); $dialect = DialectFactory::fromDriver($this->driver); - return $dialect->compile($ast); + return $dialect->compile($this->getAst()); } - protected function buildAst(): QueryAst + protected function getAst(): 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; + return $this->ast; } } diff --git a/src/Database/Concerns/Query/HasJoinClause.php b/src/Database/Concerns/Query/HasJoinClause.php index 12c807eb..1edc9182 100644 --- a/src/Database/Concerns/Query/HasJoinClause.php +++ b/src/Database/Concerns/Query/HasJoinClause.php @@ -5,75 +5,76 @@ namespace Phenix\Database\Concerns\Query; use Closure; +use Phenix\Database\Alias; use Phenix\Database\Constants\JoinType; use Phenix\Database\Join; trait HasJoinClause { - public function innerJoin(string $relationship, Closure $callback): static + public function innerJoin(Alias|string $relationship, Closure $callback): static { $this->jointIt($relationship, $callback, JoinType::INNER); return $this; } - public function innerJoinOnEqual(string $relationship, string $column, string $value): static + public function innerJoinOnEqual(Alias|string $relationship, string $column, string $value): static { $this->jointFrom($relationship, $column, $value, JoinType::INNER); return $this; } - public function leftJoin(string $relationship, Closure $callback): static + public function leftJoin(Alias|string $relationship, Closure $callback): static { $this->jointIt($relationship, $callback, JoinType::LEFT); return $this; } - public function leftJoinOnEqual(string $relationship, string $column, string $value): static + public function leftJoinOnEqual(Alias|string $relationship, string $column, string $value): static { $this->jointFrom($relationship, $column, $value, JoinType::LEFT); return $this; } - public function leftOuterJoin(string $relationship, Closure $callback): static + public function leftOuterJoin(Alias|string $relationship, Closure $callback): static { $this->jointIt($relationship, $callback, JoinType::LEFT_OUTER); return $this; } - public function rightJoin(string $relationship, Closure $callback): static + public function rightJoin(Alias|string $relationship, Closure $callback): static { $this->jointIt($relationship, $callback, JoinType::RIGHT); return $this; } - public function rightJoinOnEqual(string $relationship, string $column, string $value): static + public function rightJoinOnEqual(Alias|string $relationship, string $column, string $value): static { $this->jointFrom($relationship, $column, $value, JoinType::RIGHT); return $this; } - public function rightOuterJoin(string $relationship, Closure $callback): static + public function rightOuterJoin(Alias|string $relationship, Closure $callback): static { $this->jointIt($relationship, $callback, JoinType::RIGHT_OUTER); return $this; } - public function crossJoin(string $relationship, Closure $callback): static + public function crossJoin(Alias|string $relationship, Closure $callback): static { $this->jointIt($relationship, $callback, JoinType::CROSS); return $this; } - protected function jointIt(string $relationship, Closure $callback, JoinType $joinType): void + protected function jointIt(Alias|string $relationship, Closure $callback, JoinType $joinType): void { $join = new Join($relationship, $joinType); @@ -82,7 +83,7 @@ protected function jointIt(string $relationship, Closure $callback, JoinType $jo $this->pushJoin($join); } - protected function jointFrom(string $relationship, string $column, string $value, JoinType $joinType): void + protected function jointFrom(Alias|string $relationship, string $column, string $value, JoinType $joinType): void { $join = new Join($relationship, $joinType); $join->onEqual($column, $value); @@ -92,10 +93,8 @@ protected function jointFrom(string $relationship, string $column, string $value protected function pushJoin(Join $join): void { - [$dml, $arguments] = $join->toSql(); + $join->setDriver($this->driver); - $this->joins[] = $dml; - - $this->arguments = array_merge($this->arguments, $arguments); + $this->ast->joins[] = $join; } } diff --git a/src/Database/Concerns/Query/HasLock.php b/src/Database/Concerns/Query/HasLock.php index 011731ab..df7e63b4 100644 --- a/src/Database/Concerns/Query/HasLock.php +++ b/src/Database/Concerns/Query/HasLock.php @@ -13,19 +13,17 @@ trait HasLock { protected bool $isLocked = false; - protected Lock|null $lockType = null; - public function lock(Lock $lockType): static { if (! $this->supportsLock($lockType)) { $this->isLocked = false; - $this->lockType = null; + $this->ast->lock = null; return $this; } $this->isLocked = true; - $this->lockType = $lockType; + $this->ast->lock = $lockType; return $this; } @@ -83,7 +81,7 @@ public function lockForNoKeyUpdateSkipLocked(): static public function unlock(): static { $this->isLocked = false; - $this->lockType = null; + $this->ast->lock = null; return $this; } @@ -96,7 +94,7 @@ public function isLocked(): bool protected function buildLock(): string { if ($this->driver === Driver::POSTGRESQL) { - return match ($this->lockType) { + return match ($this->ast->lock) { Lock::FOR_UPDATE => 'FOR UPDATE', Lock::FOR_SHARE => 'FOR SHARE', Lock::FOR_NO_KEY_UPDATE => 'FOR NO KEY UPDATE', @@ -111,7 +109,7 @@ protected function buildLock(): string }; } - return match ($this->lockType) { + return match ($this->ast->lock) { Lock::FOR_UPDATE => 'FOR UPDATE', Lock::FOR_SHARE => 'FOR SHARE', Lock::FOR_UPDATE_SKIP_LOCKED => 'FOR UPDATE SKIP LOCKED', diff --git a/src/Database/Concerns/Query/HasWhereClause.php b/src/Database/Concerns/Query/HasWhereClause.php index a0e0fc8d..23f74509 100644 --- a/src/Database/Concerns/Query/HasWhereClause.php +++ b/src/Database/Concerns/Query/HasWhereClause.php @@ -134,15 +134,12 @@ public function orWhereNotIn(string $column, Closure|array $value): static public function whereNull(string $column): static { - $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; - $clause = new NullWhereClause( column: $column, operator: Operator::IS_NULL, - connector: $connector ); - $this->clauses[] = $clause; + $this->pushWhereClause($clause); return $this; } @@ -152,25 +149,21 @@ public function orWhereNull(string $column): static $clause = new NullWhereClause( column: $column, operator: Operator::IS_NULL, - connector: LogicalConnector::OR ); - $this->clauses[] = $clause; + $this->pushWhereClause($clause, LogicalConnector::OR); return $this; } public function whereNotNull(string $column): static { - $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; - $clause = new NullWhereClause( column: $column, operator: Operator::IS_NOT_NULL, - connector: $connector ); - $this->clauses[] = $clause; + $this->pushWhereClause($clause); return $this; } @@ -180,25 +173,21 @@ public function orWhereNotNull(string $column): static $clause = new NullWhereClause( column: $column, operator: Operator::IS_NOT_NULL, - connector: LogicalConnector::OR ); - $this->clauses[] = $clause; + $this->pushWhereClause($clause, LogicalConnector::OR); return $this; } public function whereTrue(string $column): static { - $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; - $clause = new BooleanWhereClause( column: $column, operator: Operator::IS_TRUE, - connector: $connector ); - $this->clauses[] = $clause; + $this->pushWhereClause($clause); return $this; } @@ -208,25 +197,21 @@ public function orWhereTrue(string $column): static $clause = new BooleanWhereClause( column: $column, operator: Operator::IS_TRUE, - connector: LogicalConnector::OR ); - $this->clauses[] = $clause; + $this->pushWhereClause($clause, LogicalConnector::OR); return $this; } public function whereFalse(string $column): static { - $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; - $clause = new BooleanWhereClause( column: $column, operator: Operator::IS_FALSE, - connector: $connector ); - $this->clauses[] = $clause; + $this->pushWhereClause($clause); return $this; } @@ -236,28 +221,22 @@ public function orWhereFalse(string $column): static $clause = new BooleanWhereClause( column: $column, operator: Operator::IS_FALSE, - connector: LogicalConnector::OR ); - $this->clauses[] = $clause; + $this->pushWhereClause($clause, LogicalConnector::OR); return $this; } public function whereBetween(string $column, array $values): static { - $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); + $this->pushWhereClause($clause); return $this; } @@ -268,30 +247,22 @@ public function orWhereBetween(string $column, array $values): static column: $column, operator: Operator::BETWEEN, values: $values, - connector: LogicalConnector::OR ); - $this->clauses[] = $clause; - - $this->arguments = array_merge($this->arguments, (array) $values); + $this->pushWhereClause($clause, LogicalConnector::OR); return $this; } public function whereNotBetween(string $column, array $values): static { - $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); + $this->pushWhereClause($clause); return $this; } @@ -302,12 +273,9 @@ public function orWhereNotBetween(string $column, array $values): static column: $column, operator: Operator::NOT_BETWEEN, values: $values, - connector: LogicalConnector::OR ); - $this->clauses[] = $clause; - - $this->arguments = array_merge($this->arguments, (array) $values); + $this->pushWhereClause($clause, LogicalConnector::OR); return $this; } @@ -350,16 +318,13 @@ public function orWhereNotExists(Closure $subquery): static public function whereColumn(string $localColumn, string $foreignColumn): static { - $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; - $clause = new ColumnWhereClause( column: $localColumn, operator: Operator::EQUAL, compareColumn: $foreignColumn, - connector: $connector ); - $this->clauses[] = $clause; + $this->pushWhereClause($clause); return $this; } diff --git a/src/Database/Concerns/Query/HasWhereDateClause.php b/src/Database/Concerns/Query/HasWhereDateClause.php index 60ecf2cf..23332a05 100644 --- a/src/Database/Concerns/Query/HasWhereDateClause.php +++ b/src/Database/Concerns/Query/HasWhereDateClause.php @@ -5,9 +5,10 @@ namespace Phenix\Database\Concerns\Query; use Carbon\CarbonInterface; +use Phenix\Database\Clauses\DateWhereClause; +use Phenix\Database\Constants\DatabaseFunction; use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; -use Phenix\Database\Functions; trait HasWhereDateClause { @@ -231,12 +232,7 @@ protected function pushDateClause( $value = $value->format('Y-m-d'); } - $this->pushTimeClause( - Functions::date($column), - $operator, - $value, - $logicalConnector - ); + $this->pushTimeClause($column, $operator, DatabaseFunction::DATE, $value, $logicalConnector); } protected function pushMonthClause( @@ -249,12 +245,7 @@ protected function pushMonthClause( $value = (int) $value->format('m'); } - $this->pushTimeClause( - Functions::month($column), - $operator, - $value, - $logicalConnector - ); + $this->pushTimeClause($column, $operator, DatabaseFunction::MONTH, $value, $logicalConnector); } protected function pushYearClause( @@ -267,20 +258,16 @@ protected function pushYearClause( $value = (int) $value->format('Y'); } - $this->pushTimeClause( - Functions::year($column), - $operator, - $value, - $logicalConnector - ); + $this->pushTimeClause($column, $operator, DatabaseFunction::YEAR, $value, $logicalConnector); } protected function pushTimeClause( - Functions $function, + string $column, Operator $operator, - CarbonInterface|string|int $value, + DatabaseFunction $function, + string|int $value, LogicalConnector $logicalConnector = LogicalConnector::AND ): void { - $this->pushWhereWithArgs((string) $function, $operator, $value, $logicalConnector); + $this->pushClause(new DateWhereClause($column, $operator, $function, $value), $logicalConnector); } } diff --git a/src/Database/Concerns/Query/HasWhereRowClause.php b/src/Database/Concerns/Query/HasWhereRowClause.php index 23d30116..1cf2b8cb 100644 --- a/src/Database/Concerns/Query/HasWhereRowClause.php +++ b/src/Database/Concerns/Query/HasWhereRowClause.php @@ -5,80 +5,76 @@ namespace Phenix\Database\Concerns\Query; use Closure; +use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; trait HasWhereRowClause { public function whereRowEqual(array $columns, Closure $subquery): static { - $this->whereSubquery($subquery, Operator::EQUAL, $this->prepareRowFields($columns)); + $this->pushRowClause($columns, $subquery, Operator::EQUAL); return $this; } public function whereRowNotEqual(array $columns, Closure $subquery): static { - $this->whereSubquery($subquery, Operator::NOT_EQUAL, $this->prepareRowFields($columns)); + $this->pushRowClause($columns, $subquery, Operator::NOT_EQUAL); return $this; } public function whereRowGreaterThan(array $columns, Closure $subquery): static { - $this->whereSubquery( - $subquery, - Operator::GREATER_THAN, - $this->prepareRowFields($columns) - ); + $this->pushRowClause($columns, $subquery, Operator::GREATER_THAN); return $this; } public function whereRowGreaterThanOrEqual(array $columns, Closure $subquery): static { - $this->whereSubquery( - $subquery, - Operator::GREATER_THAN_OR_EQUAL, - $this->prepareRowFields($columns) - ); + $this->pushRowClause($columns, $subquery, Operator::GREATER_THAN_OR_EQUAL); return $this; } public function whereRowLessThan(array $columns, Closure $subquery): static { - $this->whereSubquery($subquery, Operator::LESS_THAN, $this->prepareRowFields($columns)); + $this->pushRowClause($columns, $subquery, Operator::LESS_THAN); return $this; } public function whereRowLessThanOrEqual(array $columns, Closure $subquery): static { - $this->whereSubquery( - $subquery, - Operator::LESS_THAN_OR_EQUAL, - $this->prepareRowFields($columns) - ); + $this->pushRowClause($columns, $subquery, Operator::LESS_THAN_OR_EQUAL); return $this; } public function whereRowIn(array $columns, Closure $subquery): static { - $this->whereSubquery($subquery, Operator::IN, $this->prepareRowFields($columns)); + $this->pushRowClause($columns, $subquery, Operator::IN); return $this; } public function whereRowNotIn(array $columns, Closure $subquery): static { - $this->whereSubquery($subquery, Operator::NOT_IN, $this->prepareRowFields($columns)); + $this->pushRowClause($columns, $subquery, Operator::NOT_IN); return $this; } - private function prepareRowFields(array $fields) - { - return 'ROW(' . $this->prepareColumns($fields) . ')'; + /** + * @param array $columns + */ + private function pushRowClause( + array $columns, + Closure $subquery, + Operator $operator, + LogicalConnector $logicalConnector = LogicalConnector::AND + ): void { + $this->whereRowSubquery($subquery, $operator, $columns, $logicalConnector); } } diff --git a/src/Database/Concerns/Query/PrepareColumns.php b/src/Database/Concerns/Query/PrepareColumns.php deleted file mode 100644 index c3f6f7e7..00000000 --- a/src/Database/Concerns/Query/PrepareColumns.php +++ /dev/null @@ -1,45 +0,0 @@ - (string) Alias::of($key)->as($value), - $value instanceof Functions => (string) $value, - $value instanceof SelectCase => (string) $value, - $value instanceof Subquery => $this->resolveSubquery($value), - default => $value, - }; - }); - - return Arr::implodeDeeply($columns, ', '); - } - - private function resolveSubquery(Subquery $subquery): string - { - [$dml, $arguments] = $subquery->toSql(); - - if (! str_contains($dml, 'LIMIT 1')) { - throw new QueryErrorException('The subquery must be limited to one record'); - } - - $this->arguments = array_merge($this->arguments, $arguments); - - return $dml; - } -} diff --git a/src/Database/Constants/SQL.php b/src/Database/Constants/SqlMark.php similarity index 59% rename from src/Database/Constants/SQL.php rename to src/Database/Constants/SqlMark.php index fca2fffd..0ea09949 100644 --- a/src/Database/Constants/SQL.php +++ b/src/Database/Constants/SqlMark.php @@ -4,7 +4,7 @@ namespace Phenix\Database\Constants; -enum SQL: string +enum SqlMark: string { - case PLACEHOLDER = '?'; + case Placeholder = '{?}'; } diff --git a/src/Database/Contracts/ClauseCompiler.php b/src/Database/Contracts/ClauseCompiler.php deleted file mode 100644 index 00493450..00000000 --- a/src/Database/Contracts/ClauseCompiler.php +++ /dev/null @@ -1,13 +0,0 @@ -table; - if (! empty($ast->wheres)) { - $whereCompiled = $this->whereCompiler->compile($ast->wheres); + if ($this->ast->table instanceof Subquery) { + $table = $this->compileTable(); + $parts[] = $table->sql; + $params = [...$params, ...$table->params]; + } else { + $parts[] = $this->wrap($this->ast->table); + } + + if (! empty($this->ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($this->ast->wheres); $parts[] = 'WHERE'; $parts[] = $whereCompiled->sql; + $params = [...$params, ...$whereCompiled->params]; } $sql = Arr::implodeDeeply($parts); - return new CompiledClause($sql, $ast->params); + return new SqlData($this->replacePlaceholders($sql), $params); } } diff --git a/src/Database/Dialects/Compilers/ExistsCompiler.php b/src/Database/Dialects/Compilers/ExistsCompiler.php index 312c2bbb..fefd6de8 100644 --- a/src/Database/Dialects/Compilers/ExistsCompiler.php +++ b/src/Database/Dialects/Compilers/ExistsCompiler.php @@ -4,41 +4,41 @@ namespace Phenix\Database\Dialects\Compilers; -use Phenix\Database\Contracts\ClauseCompiler; -use Phenix\Database\Dialects\CompiledClause; -use Phenix\Database\QueryAst; -use Phenix\Database\Value; +use Phenix\Database\Dialects\SqlData; use Phenix\Util\Arr; -abstract class ExistsCompiler implements ClauseCompiler +abstract class ExistsCompiler extends SqlCompiler { - protected $whereCompiler; - - public function compile(QueryAst $ast): CompiledClause + public function compile(): SqlData { $parts = []; + $params = []; + $table = $this->compileTable(); + $parts[] = 'SELECT'; - $column = ! empty($ast->columns) ? $ast->columns[0] : 'EXISTS'; + $column = ! empty($this->ast->columns) ? $this->ast->columns[0] : 'EXISTS'; $parts[] = $column; $subquery = []; $subquery[] = 'SELECT 1 FROM'; - $subquery[] = $ast->table; + $subquery[] = $table->sql; + $params = [...$params, ...$table->params]; - if (! empty($ast->wheres)) { - $whereCompiled = $this->whereCompiler->compile($ast->wheres); + if (! empty($this->ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($this->ast->wheres); $subquery[] = 'WHERE'; $subquery[] = $whereCompiled->sql; + $params = [...$params, ...$whereCompiled->params]; } $parts[] = '(' . Arr::implodeDeeply($subquery) . ')'; $parts[] = 'AS'; - $parts[] = Value::from('exists'); + $parts[] = $this->wrap('exists'); $sql = Arr::implodeDeeply($parts); - return new CompiledClause($sql, $ast->params); + return new SqlData($this->replacePlaceholders($sql), $params); } } diff --git a/src/Database/Dialects/Compilers/HavingCompiler.php b/src/Database/Dialects/Compilers/HavingCompiler.php new file mode 100644 index 00000000..9d0b69ae --- /dev/null +++ b/src/Database/Dialects/Compilers/HavingCompiler.php @@ -0,0 +1,30 @@ +whereCompiler->compile($having->getClauses()); + + if ($compiled->sql === '') { + return new SqlData(''); + } + + return new SqlData( + "HAVING {$compiled->sql}", + $compiled->params + ); + } +} diff --git a/src/Database/Dialects/Compilers/InsertCompiler.php b/src/Database/Dialects/Compilers/InsertCompiler.php index 45a3f57c..023b045a 100644 --- a/src/Database/Dialects/Compilers/InsertCompiler.php +++ b/src/Database/Dialects/Compilers/InsertCompiler.php @@ -4,52 +4,51 @@ namespace Phenix\Database\Dialects\Compilers; -use Phenix\Database\Contracts\ClauseCompiler; -use Phenix\Database\Dialects\CompiledClause; -use Phenix\Database\QueryAst; +use Phenix\Database\Dialects\SqlData; use Phenix\Util\Arr; -abstract class InsertCompiler implements ClauseCompiler +abstract class InsertCompiler extends SqlCompiler { - public function compile(QueryAst $ast): CompiledClause + public function compile(): SqlData { $parts = []; - $params = $ast->params; + $table = $this->compileTable(); + $params = [...$table->params, ...$this->ast->params]; // INSERT [IGNORE] INTO - $parts[] = $this->compileInsertClause($ast); + $parts[] = $this->compileInsertClause(); - $parts[] = $ast->table; + $parts[] = $table->sql; // (column1, column2, ...) - $parts[] = '(' . Arr::implodeDeeply($ast->columns, ', ') . ')'; + $parts[] = '(' . Arr::implodeDeeply($this->wrapList($this->ast->columns), ', ') . ')'; // VALUES (...), (...) or raw statement - if ($ast->rawStatement !== null) { - $parts[] = $ast->rawStatement; + if ($this->ast->rawStatement !== null) { + $parts[] = $this->ast->rawStatement; } else { $parts[] = 'VALUES'; $placeholders = array_map(function (array $value): string { return '(' . Arr::implodeDeeply($value, ', ') . ')'; - }, $ast->values); + }, $this->ast->values); $parts[] = Arr::implodeDeeply(array_values($placeholders), ', '); } // Dialect-specific UPSERT/ON CONFLICT handling - if (! empty($ast->uniqueColumns)) { - $parts[] = $this->compileUpsert($ast); + if (! empty($this->ast->uniqueColumns)) { + $parts[] = $this->compileUpsert(); } $sql = Arr::implodeDeeply($parts); - return new CompiledClause($sql, $params); + return new SqlData($this->replacePlaceholders($sql), $params); } - protected function compileInsertClause(QueryAst $ast): string + protected function compileInsertClause(): string { - if ($ast->ignore) { + if ($this->ast->ignore) { return $this->compileInsertIgnore(); } @@ -70,8 +69,7 @@ abstract protected function compileInsertIgnore(): string; * 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; + abstract protected function compileUpsert(): string; } diff --git a/src/Database/Dialects/Compilers/JoinCompiler.php b/src/Database/Dialects/Compilers/JoinCompiler.php new file mode 100644 index 00000000..ae300f65 --- /dev/null +++ b/src/Database/Dialects/Compilers/JoinCompiler.php @@ -0,0 +1,75 @@ +getClauses() as $clause) { + $clauses[] = $this->compileClause($clause); + $params = [...$params, ...$clause->getParams()]; + } + + return new SqlData( + "{$join->getType()->value} {$this->compileRelationship($join)} ON " . implode(' ', $clauses), + $params + ); + } + + protected function compileClause(WhereClause $clause): string + { + $column = $clause->getColumn(); + $column = $column ? Wrapper::column($this->driver, $column) : null; + + if ($clause instanceof DateWhereClause) { + $function = $clause->getFunction()->name; + $column = "{$function}({$column})"; + $value = $clause->renderValue(); + } else { + $value = $clause->renderValue(); + + if (! $clause instanceof BasicWhereClause || ! $clause->usesPlaceholder()) { + $value = Wrapper::column($this->driver, $value); + } + } + + $sql = "{$column} {$clause->getOperator()->value} {$value}"; + + if ($connector = $clause->getConnector()) { + $sql = "{$connector->value} {$sql}"; + } + + return $sql; + } + + protected function compileRelationship(Join $join): string + { + $relationship = $join->getRelationship(); + + if ($relationship instanceof Alias) { + return (string) $relationship->setDriver($this->driver); + } + + return (string) Wrapper::of($this->driver, $relationship); + } +} diff --git a/src/Database/Dialects/Compilers/SelectCompiler.php b/src/Database/Dialects/Compilers/SelectCompiler.php index ff18f24a..47b14840 100644 --- a/src/Database/Dialects/Compilers/SelectCompiler.php +++ b/src/Database/Dialects/Compilers/SelectCompiler.php @@ -5,120 +5,185 @@ namespace Phenix\Database\Dialects\Compilers; use Phenix\Database\Alias; -use Phenix\Database\Contracts\ClauseCompiler; -use Phenix\Database\Dialects\CompiledClause; +use Phenix\Database\Constants\Operator; +use Phenix\Database\Dialects\SqlData; use Phenix\Database\Exceptions\QueryErrorException; -use Phenix\Database\Functions; -use Phenix\Database\QueryAst; +use Phenix\Database\Funct; use Phenix\Database\SelectCase; use Phenix\Database\Subquery; use Phenix\Util\Arr; use function is_string; -abstract class SelectCompiler implements ClauseCompiler +abstract class SelectCompiler extends SqlCompiler { - protected $whereCompiler; + abstract protected function compileLock(): string; - public function compile(QueryAst $ast): CompiledClause + public function compile(): SqlData { - $columns = empty($ast->columns) ? ['*'] : $ast->columns; + $columns = empty($this->ast->columns) ? ['*'] : $this->ast->columns; + $columnsCompiled = $this->compileColumns($columns); + $tableCompiled = $this->compileTable(); + $params = [...$columnsCompiled->params, ...$tableCompiled->params]; $sql = [ 'SELECT', - $this->compileColumns($columns, $ast->params), + $columnsCompiled->sql, 'FROM', - $ast->table, + $tableCompiled->sql, ]; - if (! empty($ast->joins)) { - $sql[] = $ast->joins; + if (! empty($this->ast->joins)) { + $joins = $this->compileJoins(); + + if ($joins->sql !== '') { + $sql[] = $joins->sql; + $params = [...$params, ...$joins->params]; + } } - if (! empty($ast->wheres)) { - $whereCompiled = $this->whereCompiler->compile($ast->wheres); + if (! empty($this->ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($this->ast->wheres); if ($whereCompiled->sql !== '') { $sql[] = 'WHERE'; $sql[] = $whereCompiled->sql; + $params = [...$params, ...$whereCompiled->params]; } } - if ($ast->having !== null) { - $sql[] = $ast->having; + if (! empty($this->ast->groups)) { + $sql[] = Operator::GROUP_BY->value; + $sql[] = $this->compileGroups($this->ast->groups); } - if (! empty($ast->groups)) { - $sql[] = Arr::implodeDeeply($ast->groups); + if ($this->ast->having !== null && $havingCompiled = $this->compileHaving()) { + $sql[] = $havingCompiled->sql; + $params = [...$params, ...$havingCompiled->params]; } - if (! empty($ast->orders)) { - $sql[] = Arr::implodeDeeply($ast->orders); + if (! empty($this->ast->orders)) { + $sql[] = Operator::ORDER_BY->value; + $sql[] = $this->compileOrders($this->ast->orders); } - if ($ast->limit !== null) { - $sql[] = "LIMIT {$ast->limit}"; + if ($this->ast->limit !== null) { + $sql[] = "LIMIT {$this->ast->limit}"; } - if ($ast->offset !== null) { - $sql[] = "OFFSET {$ast->offset}"; + if ($this->ast->offset !== null) { + $sql[] = "OFFSET {$this->ast->offset}"; } - if ($ast->lock !== null) { - $lockSql = $this->compileLock($ast); - - if ($lockSql !== '') { - $sql[] = $lockSql; - } + if ($this->ast->lock !== null && $lockSql = $this->compileLock()) { + $sql[] = $lockSql; } - return new CompiledClause( - Arr::implodeDeeply($sql), - $ast->params + $sql = Arr::implodeDeeply($sql); + + return new SqlData( + $this->replacePlaceholders($sql), + $params ); } /** - * @param QueryAst $ast - * @return string + * @param array $columns */ - abstract protected function compileLock(QueryAst $ast): string; + protected function compileColumns(array $columns): SqlData + { + $compiled = []; + $params = []; + + foreach ($columns as $key => $value) { + if ($value instanceof Subquery) { + $subquery = $this->compileSubquery($value, true); + + $compiled[] = $subquery->sql; + $params = [...$params, ...$subquery->params]; + + continue; + } + + $compiled[] = match (true) { + is_string($key) => (string) Alias::of($key)->as($value)->setDriver($this->ast->driver), + $value instanceof Alias => (string) $value->setDriver($this->ast->driver), + $value instanceof Funct => (string) $value->setDriver($this->ast->driver), + $value instanceof SelectCase => (string) $value->setDriver($this->ast->driver), + default => $this->wrap((string) $value), + }; + } + + return new SqlData(Arr::implodeDeeply($compiled, ', '), $params); + } + + protected function compileHaving(): SqlData|null + { + $having = $this->havingCompiler->compile($this->ast->having); + + if ($having->sql === '') { + return null; + } + + return $having; + } /** - * @param array $columns - * @param array $params Reference to params array for subqueries + * @param array $groups * @return string */ - protected function compileColumns(array $columns, array &$params): string + protected function compileGroups(array $groups): string { - $compiled = Arr::map($columns, function (string|Functions|SelectCase|Subquery $value, int|string $key) use (&$params): string { + $compiled = Arr::map( + $groups, + fn (string|Funct $value): string => match (true) { + $value instanceof Funct => (string) $value->setDriver($this->ast->driver), + default => $this->wrap((string) $value), + } + ); + + return Arr::implodeDeeply($compiled, ', '); + } + + protected function compileOrders(array $orders): string + { + [$columns, $order] = $orders; + + $compiled = Arr::map($columns, function (string|Funct|SelectCase $value): 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, + $value instanceof Funct => (string) $value->setDriver($this->ast->driver), + $value instanceof SelectCase => '(' . (string) $value->setDriver($this->ast->driver) . ')', + default => $this->wrap((string) $value), }; }); - return Arr::implodeDeeply($compiled, ', '); + return Arr::implodeDeeply([Arr::implodeDeeply($compiled, ', '), $order]); } - /** - * @param Subquery $subquery - * @param array $params Reference to params array - * @return string - */ - private function compileSubquery(Subquery $subquery, array &$params): string + protected function compileJoins(): SqlData { + $sql = []; + $params = []; + + foreach ($this->ast->joins as $join) { + $compiled = $this->joinCompiler->compile($join); + $sql[] = $compiled->sql; + $params = [...$params, ...$compiled->params]; + } + + return new SqlData(Arr::implodeDeeply($sql), $params); + } + + protected function compileSubquery(Subquery $subquery, bool $requiresLimit = false): SqlData + { + $subquery->setDriver($this->ast->driver); + [$dml, $arguments] = $subquery->toSql(); - if (! str_contains($dml, 'LIMIT 1')) { + if ($requiresLimit && ! str_contains($dml, 'LIMIT 1')) { throw new QueryErrorException('The subquery must be limited to one record'); } - $params = array_merge($params, $arguments); - - return $dml; + return new SqlData($dml, $arguments); } } diff --git a/src/Database/Dialects/Compilers/SqlCompiler.php b/src/Database/Dialects/Compilers/SqlCompiler.php new file mode 100644 index 00000000..25dc6cc5 --- /dev/null +++ b/src/Database/Dialects/Compilers/SqlCompiler.php @@ -0,0 +1,63 @@ +ast = $ast; + + return $this; + } + + protected function replacePlaceholders(string $sql): string + { + return str_replace(SqlMark::Placeholder->value, '?', $sql); + } + + protected function wrap(string $value): string + { + return Wrapper::column($this->ast->driver, $value); + } + + protected function wrapOf(string $value): string + { + return (string) Wrapper::of($this->ast->driver, $value); + } + + protected function compileTable(): SqlData + { + if ($this->ast->table instanceof Subquery) { + $this->ast->table->setDriver($this->ast->driver); + + [$sql, $params] = $this->ast->table->toSql(); + + return new SqlData($sql, $params); + } + + return new SqlData($this->wrapOf($this->ast->table)); + } + + protected function wrapList(array $values): array + { + return Wrapper::columnList($this->ast->driver, $values); + } +} diff --git a/src/Database/Dialects/Compilers/UpdateCompiler.php b/src/Database/Dialects/Compilers/UpdateCompiler.php index dcd6861f..9e357329 100644 --- a/src/Database/Dialects/Compilers/UpdateCompiler.php +++ b/src/Database/Dialects/Compilers/UpdateCompiler.php @@ -4,57 +4,57 @@ namespace Phenix\Database\Dialects\Compilers; -use Phenix\Database\Contracts\ClauseCompiler; -use Phenix\Database\Dialects\CompiledClause; -use Phenix\Database\QueryAst; +use Phenix\Database\Constants\SqlMark; +use Phenix\Database\Dialects\SqlData; use Phenix\Util\Arr; -abstract class UpdateCompiler implements ClauseCompiler +abstract class UpdateCompiler extends SqlCompiler { - protected $whereCompiler; - - public function compile(QueryAst $ast): CompiledClause + public function compile(): SqlData { $parts = []; $params = []; + $table = $this->compileTable(); $parts[] = 'UPDATE'; - $parts[] = $ast->table; + $parts[] = $table->sql; + $params = [...$params, ...$table->params]; // SET col1 = ?, col2 = ? // Extract params from values (these are actual values, not placeholders) $columns = []; - foreach ($ast->values as $column => $value) { + foreach ($this->ast->values as $column => $value) { $params[] = $value; - $columns[] = $this->compileSetClause($column, count($params)); + $columns[] = $this->compileSetClause($column); } $parts[] = 'SET'; $parts[] = Arr::implodeDeeply($columns, ', '); - if (! empty($ast->wheres)) { - $whereCompiled = $this->whereCompiler->compile($ast->wheres); + if (! empty($this->ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($this->ast->wheres); $parts[] = 'WHERE'; $parts[] = $whereCompiled->sql; - $params = array_merge($params, $ast->params); + $params = [...$params, ...$whereCompiled->params]; } - if (! empty($ast->returning)) { + if (! empty($this->ast->returning)) { $parts[] = 'RETURNING'; - $parts[] = Arr::implodeDeeply($ast->returning, ', '); + $parts[] = Arr::implodeDeeply($this->wrapList($this->ast->returning), ', '); } $sql = Arr::implodeDeeply($parts); - return new CompiledClause($sql, $params); + return new SqlData($this->replacePlaceholders($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; + protected function compileSetClause(string $column): string + { + $column = $this->wrap($column); + + return "{$column} = " . SqlMark::Placeholder->value; + } } diff --git a/src/Database/Dialects/Compilers/WhereCompiler.php b/src/Database/Dialects/Compilers/WhereCompiler.php index 1b2c2626..6efe4797 100644 --- a/src/Database/Dialects/Compilers/WhereCompiler.php +++ b/src/Database/Dialects/Compilers/WhereCompiler.php @@ -8,20 +8,23 @@ use Phenix\Database\Clauses\BetweenWhereClause; use Phenix\Database\Clauses\BooleanWhereClause; use Phenix\Database\Clauses\ColumnWhereClause; +use Phenix\Database\Clauses\DateWhereClause; use Phenix\Database\Clauses\NullWhereClause; +use Phenix\Database\Clauses\RowWhereClause; use Phenix\Database\Clauses\SubqueryWhereClause; use Phenix\Database\Clauses\WhereClause; -use Phenix\Database\Dialects\CompiledClause; +use Phenix\Database\Dialects\SqlData; abstract class WhereCompiler { /** * @param array $wheres - * @return CompiledClause + * @return SqlData */ - public function compile(array $wheres): CompiledClause + public function compile(array $wheres): SqlData { $sql = []; + $params = []; foreach ($wheres as $index => $where) { // Add logical connector if not the first clause @@ -30,18 +33,21 @@ public function compile(array $wheres): CompiledClause } $sql[] = $this->compileClause($where); + $params = [...$params, ...$where->getParams()]; } - return new CompiledClause(implode(' ', $sql), []); + return new SqlData(implode(' ', $sql), $params); } protected function compileClause(WhereClause $clause): string { return match (true) { $clause instanceof BasicWhereClause => $this->compileBasicClause($clause), + $clause instanceof DateWhereClause => $this->compileDateClause($clause), $clause instanceof NullWhereClause => $this->compileNullClause($clause), $clause instanceof BooleanWhereClause => $this->compileBooleanClause($clause), $clause instanceof BetweenWhereClause => $this->compileBetweenClause($clause), + $clause instanceof RowWhereClause => $this->compileRowClause($clause), $clause instanceof SubqueryWhereClause => $this->compileSubqueryClause($clause), $clause instanceof ColumnWhereClause => $this->compileColumnClause($clause), default => '', @@ -50,22 +56,17 @@ protected function compileClause(WhereClause $clause): string abstract protected function compileBasicClause(BasicWhereClause $clause): string; - protected function compileNullClause(NullWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value}"; - } + abstract protected function compileDateClause(DateWhereClause $clause): string; - protected function compileBooleanClause(BooleanWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value}"; - } + abstract protected function compileNullClause(NullWhereClause $clause): string; + + abstract protected function compileBooleanClause(BooleanWhereClause $clause): string; abstract protected function compileBetweenClause(BetweenWhereClause $clause): string; + abstract protected function compileRowClause(RowWhereClause $clause): string; + abstract protected function compileSubqueryClause(SubqueryWhereClause $clause): string; - protected function compileColumnClause(ColumnWhereClause $clause): string - { - return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->getCompareColumn()}"; - } + abstract protected function compileColumnClause(ColumnWhereClause $clause): string; } diff --git a/src/Database/Dialects/Dialect.php b/src/Database/Dialects/Dialect.php index d1aae500..90fed3d2 100644 --- a/src/Database/Dialects/Dialect.php +++ b/src/Database/Dialects/Dialect.php @@ -43,7 +43,7 @@ public function compile(QueryAst $ast): array */ private function compileSelect(QueryAst $ast): array { - $compiled = $this->selectCompiler->compile($ast); + $compiled = $this->selectCompiler->setAst($ast)->compile(); return [$compiled->sql, $compiled->params]; } @@ -53,7 +53,7 @@ private function compileSelect(QueryAst $ast): array */ private function compileInsert(QueryAst $ast): array { - $compiled = $this->insertCompiler->compile($ast); + $compiled = $this->insertCompiler->setAst($ast)->compile(); return [$compiled->sql, $compiled->params]; } @@ -63,7 +63,7 @@ private function compileInsert(QueryAst $ast): array */ private function compileUpdate(QueryAst $ast): array { - $compiled = $this->updateCompiler->compile($ast); + $compiled = $this->updateCompiler->setAst($ast)->compile(); return [$compiled->sql, $compiled->params]; } @@ -73,7 +73,7 @@ private function compileUpdate(QueryAst $ast): array */ private function compileDelete(QueryAst $ast): array { - $compiled = $this->deleteCompiler->compile($ast); + $compiled = $this->deleteCompiler->setAst($ast)->compile(); return [$compiled->sql, $compiled->params]; } @@ -83,7 +83,7 @@ private function compileDelete(QueryAst $ast): array */ private function compileExists(QueryAst $ast): array { - $compiled = $this->existsCompiler->compile($ast); + $compiled = $this->existsCompiler->setAst($ast)->compile(); return [$compiled->sql, $compiled->params]; } diff --git a/src/Database/Dialects/DialectFactory.php b/src/Database/Dialects/DialectFactory.php index 8e023278..3c4731a6 100644 --- a/src/Database/Dialects/DialectFactory.php +++ b/src/Database/Dialects/DialectFactory.php @@ -12,11 +12,6 @@ class DialectFactory { - /** - * @var array - */ - private static array $instances = []; - private function __construct() { // Prevent instantiation @@ -24,16 +19,11 @@ private function __construct() public static function fromDriver(Driver $driver): Dialect { - return self::$instances[$driver->value] ??= match ($driver) { + return 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/Insert.php b/src/Database/Dialects/Mysql/Compilers/Insert.php index 0ce6c441..cc81ae8f 100644 --- a/src/Database/Dialects/Mysql/Compilers/Insert.php +++ b/src/Database/Dialects/Mysql/Compilers/Insert.php @@ -5,7 +5,6 @@ namespace Phenix\Database\Dialects\Mysql\Compilers; use Phenix\Database\Dialects\Compilers\InsertCompiler; -use Phenix\Database\QueryAst; use Phenix\Util\Arr; class Insert extends InsertCompiler @@ -15,11 +14,15 @@ protected function compileInsertIgnore(): string return 'INSERT IGNORE INTO'; } - protected function compileUpsert(QueryAst $ast): string + protected function compileUpsert(): string { $columns = array_map( - fn (string $column): string => "{$column} = VALUES({$column})", - $ast->uniqueColumns + function (string $column): string { + $column = $this->wrap($column); + + return "{$column} = VALUES({$column})"; + }, + $this->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 index 01ea4095..a4991aa6 100644 --- a/src/Database/Dialects/Mysql/Compilers/Select.php +++ b/src/Database/Dialects/Mysql/Compilers/Select.php @@ -4,20 +4,24 @@ namespace Phenix\Database\Dialects\Mysql\Compilers; +use Phenix\Database\Constants\Driver; use Phenix\Database\Constants\Lock; +use Phenix\Database\Dialects\Compilers\HavingCompiler; +use Phenix\Database\Dialects\Compilers\JoinCompiler; use Phenix\Database\Dialects\Compilers\SelectCompiler; -use Phenix\Database\QueryAst; class Select extends SelectCompiler { public function __construct() { $this->whereCompiler = new Where(); + $this->joinCompiler = new JoinCompiler(Driver::MYSQL); + $this->havingCompiler = new HavingCompiler($this->whereCompiler); } - protected function compileLock(QueryAst $ast): string + protected function compileLock(): string { - return match ($ast->lock) { + return match ($this->ast->lock) { Lock::FOR_UPDATE => 'FOR UPDATE', Lock::FOR_SHARE => 'FOR SHARE', Lock::FOR_UPDATE_SKIP_LOCKED => 'FOR UPDATE SKIP LOCKED', diff --git a/src/Database/Dialects/Mysql/Compilers/Update.php b/src/Database/Dialects/Mysql/Compilers/Update.php index 8a99ffed..cc5cf54b 100644 --- a/src/Database/Dialects/Mysql/Compilers/Update.php +++ b/src/Database/Dialects/Mysql/Compilers/Update.php @@ -12,9 +12,4 @@ public function __construct() { $this->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 index 72e94248..9bebe73c 100644 --- a/src/Database/Dialects/Mysql/Compilers/Where.php +++ b/src/Database/Dialects/Mysql/Compilers/Where.php @@ -6,26 +6,53 @@ use Phenix\Database\Clauses\BasicWhereClause; use Phenix\Database\Clauses\BetweenWhereClause; +use Phenix\Database\Clauses\BooleanWhereClause; +use Phenix\Database\Clauses\ColumnWhereClause; +use Phenix\Database\Clauses\DateWhereClause; +use Phenix\Database\Clauses\NullWhereClause; +use Phenix\Database\Clauses\RowWhereClause; use Phenix\Database\Clauses\SubqueryWhereClause; -use Phenix\Database\Constants\SQL; +use Phenix\Database\Clauses\WhereClause; +use Phenix\Database\Constants\Driver; +use Phenix\Database\Constants\SqlMark; use Phenix\Database\Dialects\Compilers\WhereCompiler; +use Phenix\Database\Wrapper; class Where extends WhereCompiler { protected function compileBasicClause(BasicWhereClause $clause): string { + $column = Wrapper::column(Driver::MYSQL, $clause->getColumn()); + if ($clause->isInOperator()) { - $placeholders = str_repeat(SQL::PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::PLACEHOLDER->value; + $placeholders = str_repeat(SqlMark::Placeholder->value . ', ', $clause->getValueCount() - 1) . SqlMark::Placeholder->value; - return "{$clause->getColumn()} {$clause->getOperator()->value} ({$placeholders})"; + return "{$column} {$clause->getOperator()->value} ({$placeholders})"; } - return "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::PLACEHOLDER->value; + return "{$column} {$clause->getOperator()->value} " . SqlMark::Placeholder->value; + } + + protected function compileDateClause(DateWhereClause $clause): string + { + $column = Wrapper::column(Driver::MYSQL, $clause->getColumn()); + $function = $clause->getFunction()->name; + + return "{$function}({$column}) {$clause->getOperator()->value} {$clause->renderValue()}"; } protected function compileBetweenClause(BetweenWhereClause $clause): string { - return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->renderValue()}"; + $column = Wrapper::column(Driver::MYSQL, $clause->getColumn()); + + return "{$column} {$clause->getOperator()->value} {$clause->renderValue()}"; + } + + protected function compileRowClause(RowWhereClause $clause): string + { + $columns = implode(', ', Wrapper::columnList(Driver::MYSQL, $clause->getColumns())); + + return "ROW({$columns}) {$clause->getOperator()->value} {$clause->renderValue()}"; } protected function compileSubqueryClause(SubqueryWhereClause $clause): string @@ -33,7 +60,7 @@ protected function compileSubqueryClause(SubqueryWhereClause $clause): string $parts = []; if ($clause->getColumn() !== null) { - $parts[] = $clause->getColumn(); + $parts[] = Wrapper::column(Driver::MYSQL, $clause->getColumn()); } $parts[] = $clause->getOperator()->value; @@ -43,4 +70,29 @@ protected function compileSubqueryClause(SubqueryWhereClause $clause): string return implode(' ', $parts); } + + protected function compileColumnClause(ColumnWhereClause $clause): string + { + $column = Wrapper::column(Driver::MYSQL, $clause->getColumn()); + $compareColumn = Wrapper::column(Driver::MYSQL, $clause->getCompareColumn()); + + return "{$column} {$clause->getOperator()->value} {$compareColumn}"; + } + + protected function compileNullClause(NullWhereClause $clause): string + { + return $this->compileCommonClause($clause); + } + + protected function compileBooleanClause(BooleanWhereClause $clause): string + { + return $this->compileCommonClause($clause); + } + + private function compileCommonClause(WhereClause $clause): string + { + $column = Wrapper::column(Driver::MYSQL, $clause->getColumn()); + + return "{$column} {$clause->getOperator()->value}"; + } } diff --git a/src/Database/Dialects/Postgres/Compilers/Delete.php b/src/Database/Dialects/Postgres/Compilers/Delete.php index d10ab846..61af21a1 100644 --- a/src/Database/Dialects/Postgres/Compilers/Delete.php +++ b/src/Database/Dialects/Postgres/Compilers/Delete.php @@ -4,10 +4,8 @@ namespace Phenix\Database\Dialects\Postgres\Compilers; -use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Postgres\Concerns\HasPlaceholders; use Phenix\Database\Dialects\Sqlite\Compilers\Delete as SQLiteDelete; -use Phenix\Database\QueryAst; class Delete extends SQLiteDelete { @@ -17,12 +15,4 @@ public function __construct() { $this->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/Postgres/Compilers/Exists.php b/src/Database/Dialects/Postgres/Compilers/Exists.php index 5d422adf..4da6ba82 100644 --- a/src/Database/Dialects/Postgres/Compilers/Exists.php +++ b/src/Database/Dialects/Postgres/Compilers/Exists.php @@ -4,10 +4,8 @@ namespace Phenix\Database\Dialects\Postgres\Compilers; -use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\ExistsCompiler; use Phenix\Database\Dialects\Postgres\Concerns\HasPlaceholders; -use Phenix\Database\QueryAst; class Exists extends ExistsCompiler { @@ -17,14 +15,4 @@ public function __construct() { $this->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/Postgres/Compilers/Insert.php b/src/Database/Dialects/Postgres/Compilers/Insert.php index 23d44c01..674d116c 100644 --- a/src/Database/Dialects/Postgres/Compilers/Insert.php +++ b/src/Database/Dialects/Postgres/Compilers/Insert.php @@ -4,12 +4,13 @@ namespace Phenix\Database\Dialects\Postgres\Compilers; -use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\InsertCompiler; use Phenix\Database\Dialects\Postgres\Concerns\HasPlaceholders; -use Phenix\Database\QueryAst; +use Phenix\Database\Dialects\SqlData; use Phenix\Util\Arr; +use function sprintf; + /** * Supports: * - INSERT ... ON CONFLICT DO NOTHING (ignore conflicts) @@ -24,13 +25,15 @@ protected function compileInsertIgnore(): string return 'INSERT INTO'; } - protected function compileUpsert(QueryAst $ast): string + protected function compileUpsert(): string { - $conflictColumns = Arr::implodeDeeply($ast->uniqueColumns, ', '); + $conflictColumns = Arr::implodeDeeply($this->wrapList($this->ast->uniqueColumns), ', '); $updateColumns = array_map(function (string $column): string { + $column = $this->wrap($column); + return "{$column} = EXCLUDED.{$column}"; - }, $ast->uniqueColumns); + }, $this->ast->uniqueColumns); return sprintf( 'ON CONFLICT (%s) DO UPDATE SET %s', @@ -39,49 +42,51 @@ protected function compileUpsert(QueryAst $ast): string ); } - public function compile(QueryAst $ast): CompiledClause + public function compile(): SqlData { - if ($ast->ignore && empty($ast->uniqueColumns)) { + if ($this->ast->ignore && empty($this->ast->uniqueColumns)) { $parts = []; + $table = $this->compileTable(); + $parts[] = 'INSERT INTO'; - $parts[] = $ast->table; - $parts[] = '(' . Arr::implodeDeeply($ast->columns, ', ') . ')'; + $parts[] = $table->sql; + $parts[] = '(' . Arr::implodeDeeply($this->wrapList($this->ast->columns), ', ') . ')'; - if ($ast->rawStatement !== null) { - $parts[] = $ast->rawStatement; + if ($this->ast->rawStatement !== null) { + $parts[] = $this->ast->rawStatement; } else { $parts[] = 'VALUES'; $placeholders = array_map(function (array $value): string { return '(' . Arr::implodeDeeply($value, ', ') . ')'; - }, $ast->values); + }, $this->ast->values); $parts[] = Arr::implodeDeeply(array_values($placeholders), ', '); } $parts[] = 'ON CONFLICT DO NOTHING'; - if (! empty($ast->returning)) { + if (! empty($this->ast->returning)) { $parts[] = 'RETURNING'; - $parts[] = Arr::implodeDeeply($ast->returning, ', '); + $parts[] = Arr::implodeDeeply($this->wrapList($this->ast->returning), ', '); } $sql = Arr::implodeDeeply($parts); - $sql = $this->convertPlaceholders($sql); + $sql = $this->replacePlaceholders($sql); - return new CompiledClause($sql, $ast->params); + return new SqlData($sql, [...$table->params, ...$this->ast->params]); } - $result = parent::compile($ast); + $result = parent::compile(); $parts = [$result->sql]; - if (! empty($ast->returning)) { + if (! empty($this->ast->returning)) { $parts[] = 'RETURNING'; - $parts[] = Arr::implodeDeeply($ast->returning, ', '); + $parts[] = Arr::implodeDeeply($this->wrapList($this->ast->returning), ', '); } - return new CompiledClause( - $this->convertPlaceholders(Arr::implodeDeeply($parts)), + return new SqlData( + $this->replacePlaceholders(Arr::implodeDeeply($parts)), $result->params ); } diff --git a/src/Database/Dialects/Postgres/Compilers/Select.php b/src/Database/Dialects/Postgres/Compilers/Select.php index 59eb6a0f..0caaa0c9 100644 --- a/src/Database/Dialects/Postgres/Compilers/Select.php +++ b/src/Database/Dialects/Postgres/Compilers/Select.php @@ -4,11 +4,13 @@ namespace Phenix\Database\Dialects\Postgres\Compilers; +use Phenix\Database\Constants\Driver; use Phenix\Database\Constants\Lock; -use Phenix\Database\Dialects\CompiledClause; +use Phenix\Database\Dialects\Compilers\HavingCompiler; +use Phenix\Database\Dialects\Compilers\JoinCompiler; use Phenix\Database\Dialects\Compilers\SelectCompiler; use Phenix\Database\Dialects\Postgres\Concerns\HasPlaceholders; -use Phenix\Database\QueryAst; +use Phenix\Database\Dialects\SqlData; class Select extends SelectCompiler { @@ -17,21 +19,23 @@ class Select extends SelectCompiler public function __construct() { $this->whereCompiler = new Where(); + $this->joinCompiler = new JoinCompiler(Driver::POSTGRESQL); + $this->havingCompiler = new HavingCompiler($this->whereCompiler); } - public function compile(QueryAst $ast): CompiledClause + protected function compileTable(): SqlData { - $result = parent::compile($ast); + $result = parent::compileTable(); - return new CompiledClause( - $this->convertPlaceholders($result->sql), + return new SqlData( + $this->resetPlaceholders($result->sql), $result->params ); } - protected function compileLock(QueryAst $ast): string + protected function compileLock(): string { - return match ($ast->lock) { + return match ($this->ast->lock) { Lock::FOR_UPDATE => 'FOR UPDATE', Lock::FOR_SHARE => 'FOR SHARE', Lock::FOR_NO_KEY_UPDATE => 'FOR NO KEY UPDATE', diff --git a/src/Database/Dialects/Postgres/Compilers/Update.php b/src/Database/Dialects/Postgres/Compilers/Update.php index 8da8aafb..bf194bac 100644 --- a/src/Database/Dialects/Postgres/Compilers/Update.php +++ b/src/Database/Dialects/Postgres/Compilers/Update.php @@ -4,12 +4,8 @@ namespace Phenix\Database\Dialects\Postgres\Compilers; -use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\UpdateCompiler; use Phenix\Database\Dialects\Postgres\Concerns\HasPlaceholders; -use Phenix\Database\QueryAst; - -use function count; class Update extends UpdateCompiler { @@ -19,21 +15,4 @@ public function __construct() { $this->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/Postgres/Compilers/Where.php b/src/Database/Dialects/Postgres/Compilers/Where.php index 59fb5cb7..378ef3f7 100644 --- a/src/Database/Dialects/Postgres/Compilers/Where.php +++ b/src/Database/Dialects/Postgres/Compilers/Where.php @@ -6,51 +6,100 @@ use Phenix\Database\Clauses\BasicWhereClause; use Phenix\Database\Clauses\BetweenWhereClause; +use Phenix\Database\Clauses\BooleanWhereClause; +use Phenix\Database\Clauses\ColumnWhereClause; +use Phenix\Database\Clauses\DateWhereClause; +use Phenix\Database\Clauses\NullWhereClause; +use Phenix\Database\Clauses\RowWhereClause; use Phenix\Database\Clauses\SubqueryWhereClause; -use Phenix\Database\Constants\SQL; +use Phenix\Database\Clauses\WhereClause; +use Phenix\Database\Constants\Driver; +use Phenix\Database\Constants\SqlMark; use Phenix\Database\Dialects\Compilers\WhereCompiler; +use Phenix\Database\Dialects\Postgres\Concerns\HasPlaceholders; +use Phenix\Database\Wrapper; class Where extends WhereCompiler { + use HasPlaceholders; + protected function compileBasicClause(BasicWhereClause $clause): string { - $column = $clause->getColumn(); + $column = Wrapper::column(Driver::POSTGRESQL, $clause->getColumn()); $operator = $clause->getOperator(); if ($clause->isInOperator()) { - $placeholders = str_repeat(SQL::PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::PLACEHOLDER->value; + $placeholders = str_repeat(SqlMark::Placeholder->value . ', ', $clause->getValueCount() - 1) . SqlMark::Placeholder->value; return "{$column} {$operator->value} ({$placeholders})"; } - return "{$column} {$operator->value} " . SQL::PLACEHOLDER->value; + return "{$column} {$operator->value} " . SqlMark::Placeholder->value; + } + + protected function compileDateClause(DateWhereClause $clause): string + { + $column = Wrapper::column(Driver::POSTGRESQL, $clause->getColumn()); + $function = $clause->getFunction()->name; + + return "{$function}({$column}) {$clause->getOperator()->value} {$clause->renderValue()}"; } protected function compileBetweenClause(BetweenWhereClause $clause): string { - $column = $clause->getColumn(); + $column = Wrapper::column(Driver::POSTGRESQL, $clause->getColumn()); $operator = $clause->getOperator(); return "{$column} {$operator->value} {$clause->renderValue()}"; } + protected function compileRowClause(RowWhereClause $clause): string + { + $columns = implode(', ', Wrapper::columnList(Driver::POSTGRESQL, $clause->getColumns())); + + return "ROW({$columns}) {$clause->getOperator()->value} {$clause->renderValue()}"; + } + protected function compileSubqueryClause(SubqueryWhereClause $clause): string { $parts = []; if ($clause->getColumn() !== null) { - $parts[] = $clause->getColumn(); + $parts[] = Wrapper::column(Driver::POSTGRESQL, $clause->getColumn()); } $parts[] = $clause->getOperator()->value; + $sql = $this->resetPlaceholders($clause->getSql()); - if ($clause->getSubqueryOperator() !== null) { - // For ANY/ALL/SOME, no space between operator and subquery - $parts[] = $clause->getSubqueryOperator()->value . '(' . $clause->getSql() . ')'; - } else { - $parts[] = '(' . $clause->getSql() . ')'; - } + $parts[] = $clause->getSubqueryOperator() !== null + ? "{$clause->getSubqueryOperator()->value}({$sql})" // For ANY/ALL/SOME, no space between operator and subquery + : "({$sql})"; return implode(' ', $parts); } + + protected function compileColumnClause(ColumnWhereClause $clause): string + { + $column = Wrapper::column(Driver::POSTGRESQL, $clause->getColumn()); + $compareColumn = Wrapper::column(Driver::POSTGRESQL, $clause->getCompareColumn()); + + return "{$column} {$clause->getOperator()->value} {$compareColumn}"; + } + + protected function compileNullClause(NullWhereClause $clause): string + { + return $this->compileCommonClause($clause); + } + + protected function compileBooleanClause(BooleanWhereClause $clause): string + { + return $this->compileCommonClause($clause); + } + + private function compileCommonClause(WhereClause $clause): string + { + $column = Wrapper::column(Driver::POSTGRESQL, $clause->getColumn()); + + return "{$column} {$clause->getOperator()->value}"; + } } diff --git a/src/Database/Dialects/Postgres/Concerns/HasPlaceholders.php b/src/Database/Dialects/Postgres/Concerns/HasPlaceholders.php index ee3e7665..a2b8c7ed 100644 --- a/src/Database/Dialects/Postgres/Concerns/HasPlaceholders.php +++ b/src/Database/Dialects/Postgres/Concerns/HasPlaceholders.php @@ -4,18 +4,29 @@ namespace Phenix\Database\Dialects\Postgres\Concerns; +use Phenix\Database\Constants\SqlMark; + trait HasPlaceholders { - protected function convertPlaceholders(string $sql, int $startIndex = 0): string + protected function replacePlaceholders(string $sql, int $startIndex = 0): string { $index = $startIndex + 1; return preg_replace_callback( - '/\?/', + '/\{\?\}/', function () use (&$index): string { - return '$' . ($index++); + $placeholder = "\${$index}"; + + $index++; + + return $placeholder; }, $sql ); } + + protected function resetPlaceholders(string $sql): string + { + return preg_replace('/\$\d+/', SqlMark::Placeholder->value, $sql); + } } diff --git a/src/Database/Dialects/CompiledClause.php b/src/Database/Dialects/SqlData.php similarity index 91% rename from src/Database/Dialects/CompiledClause.php rename to src/Database/Dialects/SqlData.php index 89b0b556..5459f4bc 100644 --- a/src/Database/Dialects/CompiledClause.php +++ b/src/Database/Dialects/SqlData.php @@ -4,7 +4,7 @@ namespace Phenix\Database\Dialects; -readonly class CompiledClause +readonly class SqlData { /** * @param string $sql The compiled SQL string diff --git a/src/Database/Dialects/Sqlite/Compilers/Delete.php b/src/Database/Dialects/Sqlite/Compilers/Delete.php index 0fe24bdf..a7484370 100644 --- a/src/Database/Dialects/Sqlite/Compilers/Delete.php +++ b/src/Database/Dialects/Sqlite/Compilers/Delete.php @@ -4,9 +4,8 @@ namespace Phenix\Database\Dialects\Sqlite\Compilers; -use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\DeleteCompiler; -use Phenix\Database\QueryAst; +use Phenix\Database\Dialects\SqlData; use Phenix\Util\Arr; class Delete extends DeleteCompiler @@ -16,27 +15,31 @@ public function __construct() $this->whereCompiler = new Where(); } - public function compile(QueryAst $ast): CompiledClause + public function compile(): SqlData { $parts = []; + $params = []; + $table = $this->compileTable(); $parts[] = 'DELETE FROM'; - $parts[] = $ast->table; + $parts[] = $table->sql; + $params = [...$params, ...$table->params]; - if (! empty($ast->wheres)) { - $whereCompiled = $this->whereCompiler->compile($ast->wheres); + if (! empty($this->ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($this->ast->wheres); $parts[] = 'WHERE'; $parts[] = $whereCompiled->sql; + $params = [...$params, ...$whereCompiled->params]; } - if (! empty($ast->returning)) { + if (! empty($this->ast->returning)) { $parts[] = 'RETURNING'; - $parts[] = Arr::implodeDeeply($ast->returning, ', '); + $parts[] = Arr::implodeDeeply($this->wrapList($this->ast->returning), ', '); } $sql = Arr::implodeDeeply($parts); - return new CompiledClause($sql, $ast->params); + return new SqlData($this->replacePlaceholders($sql), $params); } } diff --git a/src/Database/Dialects/Sqlite/Compilers/Insert.php b/src/Database/Dialects/Sqlite/Compilers/Insert.php index cdad0799..ed8a9b29 100644 --- a/src/Database/Dialects/Sqlite/Compilers/Insert.php +++ b/src/Database/Dialects/Sqlite/Compilers/Insert.php @@ -4,9 +4,8 @@ namespace Phenix\Database\Dialects\Sqlite\Compilers; -use Phenix\Database\Dialects\CompiledClause; use Phenix\Database\Dialects\Compilers\InsertCompiler; -use Phenix\Database\QueryAst; +use Phenix\Database\Dialects\SqlData; use Phenix\Util\Arr; /** @@ -24,16 +23,17 @@ protected function compileInsertIgnore(): string /** * Syntax: ON CONFLICT (col1, col2) DO UPDATE SET col1 = excluded.col1 * - * @param QueryAst $ast Query AST with uniqueColumns * @return string ON CONFLICT clause */ - protected function compileUpsert(QueryAst $ast): string + protected function compileUpsert(): string { - $conflictColumns = Arr::implodeDeeply($ast->uniqueColumns, ', '); + $conflictColumns = Arr::implodeDeeply($this->wrapList($this->ast->uniqueColumns), ', '); + + $updateColumns = array_map(function (string $column) { + $column = $this->wrap($column); - $updateColumns = array_map(function (string $column): string { return "{$column} = excluded.{$column}"; - }, $ast->uniqueColumns); + }, $this->ast->uniqueColumns); return sprintf( 'ON CONFLICT (%s) DO UPDATE SET %s', @@ -42,18 +42,20 @@ protected function compileUpsert(QueryAst $ast): string ); } - public function compile(QueryAst $ast): CompiledClause + public function compile(): SqlData { - $result = parent::compile($ast); - $parts = [$result->sql]; + $result = parent::compile(); + $sql = [$result->sql]; - if (! empty($ast->returning)) { - $parts[] = 'RETURNING'; - $parts[] = Arr::implodeDeeply($ast->returning, ', '); + if (! empty($this->ast->returning)) { + $sql[] = 'RETURNING'; + $sql[] = Arr::implodeDeeply($this->wrapList($this->ast->returning), ', '); } - return new CompiledClause( - Arr::implodeDeeply($parts), + $sql = Arr::implodeDeeply($sql); + + return new SqlData( + $this->replacePlaceholders($sql), $result->params ); } diff --git a/src/Database/Dialects/Sqlite/Compilers/Select.php b/src/Database/Dialects/Sqlite/Compilers/Select.php index 66cd505b..a8584d49 100644 --- a/src/Database/Dialects/Sqlite/Compilers/Select.php +++ b/src/Database/Dialects/Sqlite/Compilers/Select.php @@ -4,17 +4,21 @@ namespace Phenix\Database\Dialects\Sqlite\Compilers; +use Phenix\Database\Constants\Driver; +use Phenix\Database\Dialects\Compilers\HavingCompiler; +use Phenix\Database\Dialects\Compilers\JoinCompiler; use Phenix\Database\Dialects\Compilers\SelectCompiler; -use Phenix\Database\QueryAst; class Select extends SelectCompiler { public function __construct() { $this->whereCompiler = new Where(); + $this->joinCompiler = new JoinCompiler(Driver::SQLITE); + $this->havingCompiler = new HavingCompiler($this->whereCompiler); } - protected function compileLock(QueryAst $ast): string + protected function compileLock(): 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 index 67d05255..abc4ae07 100644 --- a/src/Database/Dialects/Sqlite/Compilers/Update.php +++ b/src/Database/Dialects/Sqlite/Compilers/Update.php @@ -12,9 +12,4 @@ public function __construct() { $this->whereCompiler = new Where(); } - - protected function compileSetClause(string $column, int $paramIndex): string - { - return "{$column} = ?"; - } } diff --git a/src/Database/Functions.php b/src/Database/Funct.php similarity index 84% rename from src/Database/Functions.php rename to src/Database/Funct.php index d12139a6..69eccb96 100644 --- a/src/Database/Functions.php +++ b/src/Database/Funct.php @@ -4,11 +4,14 @@ namespace Phenix\Database; +use Phenix\Database\Concerns\HasDriver; use Phenix\Database\Constants\DatabaseFunction; use Stringable; -class Functions implements Stringable +class Funct implements Stringable { + use HasDriver; + protected string $alias; public function __construct( @@ -18,24 +21,6 @@ public function __construct( // .. } - public function as(string $alias): self - { - $this->alias = $alias; - - return $this; - } - - public function __toString(): string - { - $function = $this->function->name . '(' . $this->column . ')'; - - if (isset($this->alias)) { - $function .= ' AS ' . $this->alias; - } - - return $function; - } - public static function avg(string $column): self { return new self(DatabaseFunction::AVG, $column); @@ -80,4 +65,24 @@ public static function case(): SelectCase { return new SelectCase(); } + + public function as(string $alias): self + { + $this->alias = $alias; + + return $this; + } + + public function __toString(): string + { + $column = Wrapper::column($this->driver, $this->column); + + $function = $this->function->name . '(' . $column . ')'; + + if (isset($this->alias)) { + $function .= ' AS ' . Wrapper::column($this->driver, $this->alias); + } + + return $function; + } } diff --git a/src/Database/Grammar.php b/src/Database/Grammar.php index 1857db3b..8cca4224 100644 --- a/src/Database/Grammar.php +++ b/src/Database/Grammar.php @@ -7,32 +7,21 @@ use Amp\Mysql\MysqlConnectionPool; use Amp\Postgres\PostgresConnectionPool; use Amp\Sql\SqlConnection; +use Phenix\Database\Concerns\HasDriver; use Phenix\Database\Constants\Driver; use Phenix\Facades\Config; use Phenix\Sqlite\SqliteConnection; abstract class Grammar { - protected Driver $driver; - - public function setDriver(Driver $driver): static - { - $this->driver = $driver; - - return $this; - } - - public function getDriver(): Driver - { - return $this->driver; - } + use HasDriver; protected function resolveDriver(SqlConnection $connection): void { $driver = $this->resolveDriverFromConnection($connection); $driver ??= $this->resolveDriverFromConfig(); - $this->driver = $driver; + $this->setDriver($driver); } protected function resolveDriverFromConfig(): Driver diff --git a/src/Database/Having.php b/src/Database/Having.php index be4b309e..843a36ea 100644 --- a/src/Database/Having.php +++ b/src/Database/Having.php @@ -4,9 +4,9 @@ namespace Phenix\Database; -use Phenix\Database\Constants\SQL; +use Phenix\Database\Clauses\WhereClause; -class Having extends Clause +class Having extends ClauseBuilder { public function __construct() { @@ -14,20 +14,19 @@ public function __construct() $this->arguments = []; } - public function toSql(): array + /** + * @return array + */ + public function getClauses(): array { - $sql = []; - - 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 $this->clauses; + } - return ['HAVING ' . implode(' ', $sql), $this->arguments]; + /** + * @return array + */ + public function getArguments(): array + { + return $this->getClauseArguments(); } } diff --git a/src/Database/Join.php b/src/Database/Join.php index 1648b30b..4b98d7b2 100644 --- a/src/Database/Join.php +++ b/src/Database/Join.php @@ -5,12 +5,12 @@ namespace Phenix\Database; use Phenix\Database\Clauses\BasicWhereClause; +use Phenix\Database\Clauses\WhereClause; use Phenix\Database\Constants\JoinType; use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; -use Phenix\Database\Contracts\Builder; -class Join extends Clause implements Builder +class Join extends ClauseBuilder { public function __construct( protected Alias|string $relationship, @@ -48,29 +48,29 @@ public function orOnNotEqual(string $column, string $value): self return $this; } - public function toSql(): array + public function getRelationship(): Alias|string { - $sql = []; - - foreach ($this->clauses as $clause) { - $connector = $clause->getConnector(); - - $column = $clause->getColumn(); - $operator = $clause->getOperator(); - $value = $clause->renderValue(); - - $clauseSql = "{$column} {$operator->value} {$value}"; + return $this->relationship; + } - if ($connector !== null) { - $clauseSql = "{$connector->value} {$clauseSql}"; - } + public function getType(): JoinType + { + return $this->type; + } - $sql[] = $clauseSql; - } + /** + * @return array + */ + public function getClauses(): array + { + return $this->clauses; + } - return [ - "{$this->type->value} {$this->relationship} ON " . implode(' ', $sql), - $this->arguments, - ]; + /** + * @return array + */ + public function getArguments(): array + { + return $this->getClauseArguments(); } } diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index e3f25e50..88ffb555 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -54,14 +54,13 @@ public function __clone(): void parent::__clone(); $this->relationships = []; $this->isLocked = false; - $this->lockType = null; } public function addSelect(array $columns): static { - $this->action = Action::SELECT; + $this->ast->action = Action::SELECT; - $this->columns = array_merge($this->columns, $columns); + $this->ast->columns = array_merge($this->ast->columns, $columns); return $this; } @@ -76,7 +75,7 @@ public function setModel(DatabaseModel $model): self $this->model = $model; } - $this->table = $this->model->getTable(); + $this->ast->table = $this->model->getTable(); return $this; } @@ -132,8 +131,8 @@ public function with(array|string $relationships): self */ public function get(): Collection { - $this->action = Action::SELECT; - $this->columns = empty($this->columns) ? ['*'] : $this->columns; + $this->ast->action = Action::SELECT; + $this->ast->columns = empty($this->ast->columns) ? ['*'] : $this->ast->columns; [$dml, $params] = $this->toSql(); @@ -157,7 +156,7 @@ public function get(): Collection */ public function first(): DatabaseModel|null { - $this->action = Action::SELECT; + $this->ast->action = Action::SELECT; $this->limit(1); diff --git a/src/Database/QueryAst.php b/src/Database/QueryAst.php index 16d40cb1..cf1fb5de 100644 --- a/src/Database/QueryAst.php +++ b/src/Database/QueryAst.php @@ -6,13 +6,16 @@ use Phenix\Database\Clauses\WhereClause; use Phenix\Database\Constants\Action; +use Phenix\Database\Constants\Driver; use Phenix\Database\Constants\Lock; class QueryAst { + public Driver $driver; + public Action $action; - public string $table; + public string|Subquery $table; /** * @var array @@ -27,7 +30,7 @@ class QueryAst public array $values = []; /** - * @var array + * @var array */ public array $joins = []; @@ -36,10 +39,7 @@ class QueryAst */ public array $wheres = []; - /** - * @var string|null - */ - public string|null $having = null; + public Having|null $having = null; /** * @var array diff --git a/src/Database/QueryBase.php b/src/Database/QueryBase.php index 253ca55b..e58f9a6b 100644 --- a/src/Database/QueryBase.php +++ b/src/Database/QueryBase.php @@ -5,102 +5,135 @@ namespace Phenix\Database; use Closure; +use Phenix\Database\Clauses\WhereClause; use Phenix\Database\Concerns\Query\BuildsQuery; use Phenix\Database\Concerns\Query\HasJoinClause; use Phenix\Database\Concerns\Query\HasLock; use Phenix\Database\Constants\Action; +use Phenix\Database\Constants\Driver; +use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; -use Phenix\Database\Constants\SQL; +use Phenix\Database\Constants\SqlMark; use Phenix\Database\Contracts\Builder; use Phenix\Database\Contracts\QueryBuilder; +use function count; + abstract class QueryBase extends Clause implements QueryBuilder, Builder { use BuildsQuery; use HasLock; use HasJoinClause; - protected string $table; - - protected Action $action; + protected QueryAst $ast; - protected array $columns; + public function __construct() + { + $this->resetBaseProperties(); + } - protected array $values; + public function __clone(): void + { + $this->ast = clone $this->ast; + $this->ast->lock = null; + } - protected array $joins; + protected function resetBaseProperties(): void + { + $ast = new QueryAst(); + $ast->columns = []; - protected string $having; + if (isset($this->driver)) { + $ast->driver = $this->driver; + } - protected array $groupBy; + $this->ast = $ast; + } - protected array $orderBy; + public function setDriver(Driver $driver): static + { + $this->driver = $driver; - protected array $limit; + if (isset($this->ast)) { + $this->ast->driver = $driver; + } - protected array $offset; + return $this; + } - protected string $rawStatement; + /** + * @return array + */ + protected function getClauses(): array + { + return $this->ast->wheres; + } - protected bool $ignore = false; + /** + * @return array + */ + protected function getArguments(): array + { + $arguments = $this->ast->params; - protected array $uniqueColumns; + foreach ($this->ast->wheres as $where) { + $arguments = [...$arguments, ...$where->getParams()]; + } - protected array $returning = []; + return $arguments; + } - public function __construct() + protected function hasWhereClauses(): bool { - $this->ignore = false; - - $this->resetBaseProperties(); + return count($this->ast->wheres) > 0; } - public function __clone(): void + protected function addArguments(array $arguments): void { - $this->resetBaseProperties(); + $this->ast->params = [...$this->ast->params, ...$arguments]; } - protected function resetBaseProperties(): void - { - $this->joins = []; - $this->columns = []; - $this->values = []; - $this->clauses = []; - $this->arguments = []; - $this->uniqueColumns = []; - $this->returning = []; + protected function pushWhereClause( + WhereClause $where, + LogicalConnector $logicalConnector = LogicalConnector::AND + ): void { + if ($this->hasWhereClauses()) { + $where->setConnector($logicalConnector); + } + + $this->ast->wheres[] = $where; } public function count(string $column = '*'): array|int { - $this->action = Action::SELECT; + $this->ast->action = Action::SELECT; - $this->columns = [Functions::count($column)]; + $this->ast->columns = [Funct::count($column)]; return $this->toSql(); } public function exists(): array|bool { - $this->action = Action::EXISTS; + $this->ast->action = Action::EXISTS; - $this->columns = [Operator::EXISTS->value]; + $this->ast->columns = [Operator::EXISTS->value]; return $this->toSql(); } public function doesntExist(): array|bool { - $this->action = Action::EXISTS; + $this->ast->action = Action::EXISTS; - $this->columns = [Operator::NOT_EXISTS->value]; + $this->ast->columns = [Operator::NOT_EXISTS->value]; return $this->toSql(); } public function insert(array $data): array|bool { - $this->action = Action::INSERT; + $this->ast->action = Action::INSERT; $this->prepareDataToInsert($data); @@ -109,7 +142,7 @@ public function insert(array $data): array|bool public function insertOrIgnore(array $values): array|bool { - $this->ignore = true; + $this->ast->ignore = true; $this->insert($values); @@ -125,33 +158,33 @@ public function insertFrom(Closure $subquery, array $columns, bool $ignore = fal [$dml, $arguments] = $builder->toSql(); - $this->rawStatement = trim($dml, '()'); + $this->ast->rawStatement = trim($dml, '()'); - $this->arguments = array_merge($this->arguments, $arguments); + $this->addArguments($arguments); - $this->action = Action::INSERT; + $this->ast->action = Action::INSERT; - $this->ignore = $ignore; + $this->ast->ignore = $ignore; - $this->columns = $columns; + $this->ast->columns = $columns; return $this->toSql(); } public function update(array $values): array|bool { - $this->action = Action::UPDATE; + $this->ast->action = Action::UPDATE; - $this->values = $values; + $this->ast->values = $values; return $this->toSql(); } public function upsert(array $values, array $columns): array|bool { - $this->action = Action::INSERT; + $this->ast->action = Action::INSERT; - $this->uniqueColumns = $columns; + $this->ast->uniqueColumns = $columns; $this->prepareDataToInsert($values); @@ -160,7 +193,7 @@ public function upsert(array $values, array $columns): array|bool public function delete(): array|bool { - $this->action = Action::DELETE; + $this->ast->action = Action::DELETE; return $this->toSql(); } @@ -172,7 +205,7 @@ public function delete(): array|bool */ public function returning(array $columns = ['*']): static { - $this->returning = array_unique($columns); + $this->ast->returning = array_unique($columns); return $this; } @@ -189,10 +222,10 @@ protected function prepareDataToInsert(array $data): void ksort($data); - $this->columns = array_unique([...$this->columns, ...array_keys($data)]); + $this->ast->columns = array_unique([...$this->ast->columns, ...array_keys($data)]); - $this->arguments = \array_merge($this->arguments, array_values($data)); + $this->addArguments(array_values($data)); - $this->values[] = array_fill(0, count($data), SQL::PLACEHOLDER->value); + $this->ast->values[] = array_fill(0, count($data), SqlMark::Placeholder->value); } } diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index 283c69fb..463fa284 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -45,7 +45,6 @@ public function __clone(): void $this->connection = $connection; $this->transaction = $transaction; $this->isLocked = false; - $this->lockType = null; } public function connection(SqlConnection|string $connection): self @@ -68,7 +67,7 @@ public function getConnection(): SqlConnection public function count(string $column = '*'): int { - $this->action = Action::SELECT; + $this->ast->action = Action::SELECT; [$dml, $params] = parent::count($column); @@ -80,7 +79,7 @@ public function count(string $column = '*'): int public function exists(): bool { - $this->action = Action::EXISTS; + $this->ast->action = Action::EXISTS; [$dml, $params] = parent::exists(); @@ -96,7 +95,7 @@ public function doesntExist(): bool public function paginate(Http $uri, int $defaultPage = 1, int $defaultPerPage = 15): Paginator { - $this->action = Action::SELECT; + $this->ast->action = Action::SELECT; $query = Query::fromUri($uri); @@ -132,7 +131,7 @@ public function insert(array $data): bool public function insertOrIgnore(array $values): bool { - $this->ignore = true; + $this->ast->ignore = true; return $this->insert($values); } @@ -146,15 +145,15 @@ public function insertFrom(Closure $subquery, array $columns, bool $ignore = fal [$dml, $arguments] = $builder->toSql(); - $this->rawStatement = trim($dml, '()'); + $this->ast->rawStatement = trim($dml, '()'); - $this->arguments = array_merge($this->arguments, $arguments); + $this->addArguments($arguments); - $this->action = Action::INSERT; + $this->ast->action = Action::INSERT; - $this->ignore = $ignore; + $this->ast->ignore = $ignore; - $this->columns = $columns; + $this->ast->columns = $columns; try { [$dml, $params] = $this->toSql(); @@ -216,7 +215,7 @@ public function update(array $values): bool */ public function updateReturning(array $values, array $columns = ['*']): Collection { - $this->returning = array_unique($columns); + $this->ast->returning = array_unique($columns); [$dml, $params] = parent::update($values); @@ -239,9 +238,9 @@ public function updateReturning(array $values, array $columns = ['*']): Collecti public function upsert(array $values, array $columns): bool { - $this->action = Action::INSERT; + $this->ast->action = Action::INSERT; - $this->uniqueColumns = $columns; + $this->ast->uniqueColumns = $columns; return $this->insert($values); } @@ -269,7 +268,7 @@ public function delete(): bool */ public function deleteReturning(array $columns = ['*']): Collection { - $this->returning = array_unique($columns); + $this->ast->returning = array_unique($columns); [$dml, $params] = parent::delete(); @@ -295,7 +294,7 @@ public function deleteReturning(array $columns = ['*']): Collection */ public function get(): Collection { - $this->action = Action::SELECT; + $this->ast->action = Action::SELECT; [$dml, $params] = $this->toSql(); @@ -315,7 +314,7 @@ public function get(): Collection */ public function first(): object|array|null { - $this->action = Action::SELECT; + $this->ast->action = Action::SELECT; $this->limit(1); diff --git a/src/Database/QueryGenerator.php b/src/Database/QueryGenerator.php index e2514f92..ac406faf 100644 --- a/src/Database/QueryGenerator.php +++ b/src/Database/QueryGenerator.php @@ -14,14 +14,13 @@ public function __construct(Driver $driver = Driver::MYSQL) { parent::__construct(); - $this->driver = $driver; + $this->setDriver($driver); } public function __clone(): void { parent::__clone(); $this->isLocked = false; - $this->lockType = null; } public function insert(array $data): array @@ -31,7 +30,7 @@ public function insert(array $data): array public function insertOrIgnore(array $values): array { - $this->ignore = true; + $this->ast->ignore = true; $this->insert($values); @@ -75,14 +74,14 @@ public function doesntExist(): array public function get(): array { - $this->action = Action::SELECT; + $this->ast->action = Action::SELECT; return $this->toSql(); } public function first(): array { - $this->action = Action::SELECT; + $this->ast->action = Action::SELECT; return $this->limit(1)->toSql(); } diff --git a/src/Database/SelectCase.php b/src/Database/SelectCase.php index d60735c2..dd837676 100644 --- a/src/Database/SelectCase.php +++ b/src/Database/SelectCase.php @@ -4,14 +4,22 @@ namespace Phenix\Database; +use Phenix\Database\Concerns\HasDriver; use Phenix\Database\Constants\Operator; +use Phenix\Database\Contracts\RawValue; use Phenix\Util\Arr; use Stringable; +use function is_int; + class SelectCase implements Stringable { + use HasDriver; + protected array $cases; - protected Value|string $default; + + protected RawValue|string|int $default; + protected string $alias; public function __construct() @@ -19,7 +27,7 @@ public function __construct() $this->cases = []; } - public function whenEqual(Functions|string $column, Value|string|int $value, Value|string $result): self + public function whenEqual(Funct|string $column, RawValue|string|int $value, RawValue|string|int $result): self { $this->pushCase( $column, @@ -31,7 +39,7 @@ public function whenEqual(Functions|string $column, Value|string|int $value, Val return $this; } - public function whenNotEqual(Functions|string $column, Value|string|int $value, Value|string $result): self + public function whenNotEqual(Funct|string $column, RawValue|string|int $value, RawValue|string|int $result): self { $this->pushCase( $column, @@ -43,7 +51,7 @@ public function whenNotEqual(Functions|string $column, Value|string|int $value, return $this; } - public function whenGreaterThan(Functions|string $column, Value|string|int $value, Value|string $result): self + public function whenGreaterThan(Funct|string $column, RawValue|string|int $value, RawValue|string|int $result): self { $this->pushCase( $column, @@ -56,9 +64,9 @@ public function whenGreaterThan(Functions|string $column, Value|string|int $valu } public function whenGreaterThanOrEqual( - Functions|string $column, - Value|string|int $value, - Value|string $result + Funct|string $column, + RawValue|string|int $value, + RawValue|string|int $result ): self { $this->pushCase( $column, @@ -70,7 +78,7 @@ public function whenGreaterThanOrEqual( return $this; } - public function whenLessThan(Functions|string $column, Value|string|int $value, Value|string $result): self + public function whenLessThan(Funct|string $column, RawValue|string|int $value, RawValue|string|int $result): self { $this->pushCase( $column, @@ -82,7 +90,7 @@ public function whenLessThan(Functions|string $column, Value|string|int $value, return $this; } - public function whenLessThanOrEqual(Functions|string $column, Value|string|int $value, Value|string $result): self + public function whenLessThanOrEqual(Funct|string $column, RawValue|string|int $value, RawValue|string|int $result): self { $this->pushCase( $column, @@ -94,7 +102,7 @@ public function whenLessThanOrEqual(Functions|string $column, Value|string|int $ return $this; } - public function whenNull(string $column, Value|string $result): self + public function whenNull(string $column, RawValue|string|int $result): self { $this->pushCase( $column, @@ -105,7 +113,7 @@ public function whenNull(string $column, Value|string $result): self return $this; } - public function whenNotNull(string $column, Value|string $result): self + public function whenNotNull(string $column, RawValue|string|int $result): self { $this->pushCase( $column, @@ -116,7 +124,7 @@ public function whenNotNull(string $column, Value|string $result): self return $this; } - public function whenTrue(string $column, Value|string $result): self + public function whenTrue(string $column, RawValue|string|int $result): self { $this->pushCase( $column, @@ -127,7 +135,7 @@ public function whenTrue(string $column, Value|string $result): self return $this; } - public function whenFalse(string $column, Value|string $result): self + public function whenFalse(string $column, RawValue|string|int $result): self { $this->pushCase( $column, @@ -138,7 +146,7 @@ public function whenFalse(string $column, Value|string $result): self return $this; } - public function defaultResult(Value|string|int $value): self + public function defaultResult(RawValue|string|int $value): self { $this->default = $value; @@ -154,38 +162,63 @@ public function as(string $alias): self public function __toString(): string { - $cases = array_map(function (array $case): array { - return array_map(function (Operator|string $item): string { - return match (true) { - $item instanceof Operator => $item->value, - default => (string) $item, - }; - }, $case); - }, $this->cases); + $cases = array_map($this->compileCase(...), $this->cases); if (isset($this->default)) { - $cases[] = ['ELSE ' . strval($this->default)]; + $cases[] = 'ELSE ' . $this->renderOperand($this->default); } $cases[] = 'END'; - $dml = 'CASE ' . Arr::implodeDeeply($cases); + $dml = 'CASE ' . Arr::implodeDeeply($cases, ' '); if (isset($this->alias)) { - $dml = '(' . $dml . ') AS ' . $this->alias; + $alias = Wrapper::of($this->getDriver(), $this->alias); + + $dml = "({$dml}) AS {$alias}"; } return $dml; } protected function pushCase( - Functions|string $column, + Funct|string $column, Operator $operators, - Value|string $result, - Value|string|int|null $value = null + RawValue|string|int $result, + RawValue|string|int|null $value = null ): void { - $condition = array_filter([$column, $operators, $value]); + $condition = array_filter([$column, $operators, $value], static fn (mixed $item): bool => $item !== null); $this->cases[] = ['WHEN', ...$condition, 'THEN', $result]; } + + protected function compileCase(array $case): string + { + $column = $this->compileColumn($case[1]); + $operator = $case[2] instanceof Operator ? $case[2]->value : (string) $case[2]; + + if (($case[3] ?? null) === 'THEN') { + return "WHEN {$column} {$operator} THEN " . $this->renderOperand($case[4]); + } + + return "WHEN {$column} {$operator} " . $this->renderOperand($case[3]) . " THEN " . $this->renderOperand($case[5]); + } + + protected function compileColumn(Funct|string $column): string + { + if ($column instanceof Funct) { + return (string) $column->setDriver($this->getDriver()); + } + + return Wrapper::column($this->getDriver(), $column); + } + + protected function renderOperand(RawValue|string|int $value): string + { + if ($value instanceof RawValue || is_int($value)) { + return (string) $value; + } + + return (string) Value::from($value); + } } diff --git a/src/Database/Subquery.php b/src/Database/Subquery.php index 9ad78e88..1d0e1fbb 100644 --- a/src/Database/Subquery.php +++ b/src/Database/Subquery.php @@ -20,7 +20,9 @@ public function toSql(): array [$dml, $arguments] = parent::toSql(); if (isset($this->alias)) { - return ["({$dml}) AS {$this->alias}", $arguments]; + $alias = Wrapper::column($this->driver, $this->alias); + + return ["({$dml}) AS {$alias}", $arguments]; } return ["({$dml})", $arguments]; diff --git a/src/Database/Value.php b/src/Database/Value.php index eee01eaf..d58a5a80 100644 --- a/src/Database/Value.php +++ b/src/Database/Value.php @@ -6,6 +6,8 @@ use Phenix\Database\Contracts\RawValue; +use function is_int; + class Value implements RawValue { public function __construct( @@ -16,7 +18,11 @@ public function __construct( public function __toString(): string { - return "'" . $this->value . "'"; + if (is_int($this->value)) { + return (string) $this->value; + } + + return "'" . str_replace("'", "''", $this->value) . "'"; } public static function from(string|int $value): self diff --git a/src/Database/Wrapper.php b/src/Database/Wrapper.php new file mode 100644 index 00000000..755f4387 --- /dev/null +++ b/src/Database/Wrapper.php @@ -0,0 +1,64 @@ +value) || $this->value === '*' || $this->value === SqlMark::Placeholder->value) { + return $this->value; + } + + return $this->encloser + . str_replace($this->encloser, $this->encloser . $this->encloser, $this->value) + . $this->encloser; + } + + public static function doubleQuote(string $value): self + { + return new self($value, '"'); + } + + public static function backtick(string $value): self + { + return new self($value, '`'); + } + + public static function of(Driver $driver, string $value): self + { + return match ($driver) { + Driver::MYSQL => self::backtick($value), + Driver::POSTGRESQL, Driver::SQLITE => self::doubleQuote($value), + default => self::backtick($value), + }; + } + + public static function column(Driver $driver, string $column): string + { + $parts = array_map( + fn (string $part): string => (string) self::of($driver, $part), + explode('.', $column) + ); + + return implode('.', $parts); + } + + public static function columnList(Driver $driver, array $columns): array + { + return array_map(fn (string $column): string => self::column($driver, $column), $columns); + } +} diff --git a/src/Database/functions.php b/src/Database/functions.php new file mode 100644 index 00000000..066811d0 --- /dev/null +++ b/src/Database/functions.php @@ -0,0 +1,110 @@ + $columns + */ +function subquery(array $columns = ['*']): Subquery +{ + return Subquery::make()->select($columns); +} + +function when_equal(Funct|string $column, RawValue|string|int $value, RawValue|string|int $result): SelectCase +{ + return Funct::case()->whenEqual($column, $value, $result); +} + +function when_not_equal(Funct|string $column, RawValue|string|int $value, RawValue|string|int $result): SelectCase +{ + return Funct::case()->whenNotEqual($column, $value, $result); +} + +function when_gt(Funct|string $column, RawValue|string|int $value, RawValue|string|int $result): SelectCase +{ + return Funct::case()->whenGreaterThan($column, $value, $result); +} + +function when_gte(Funct|string $column, RawValue|string|int $value, RawValue|string|int $result): SelectCase +{ + return Funct::case()->whenGreaterThanOrEqual($column, $value, $result); +} + +function when_lt(Funct|string $column, RawValue|string|int $value, RawValue|string|int $result): SelectCase +{ + return Funct::case()->whenLessThan($column, $value, $result); +} + +function when_lte(Funct|string $column, RawValue|string|int $value, RawValue|string|int $result): SelectCase +{ + return Funct::case()->whenLessThanOrEqual($column, $value, $result); +} + +function when_null(string $column, RawValue|string|int $result): SelectCase +{ + return Funct::case()->whenNull($column, $result); +} + +function when_not_null(string $column, RawValue|string|int $result): SelectCase +{ + return Funct::case()->whenNotNull($column, $result); +} + +function when_true(string $column, RawValue|string|int $result): SelectCase +{ + return Funct::case()->whenTrue($column, $result); +} + +function when_false(string $column, RawValue|string|int $result): SelectCase +{ + return Funct::case()->whenFalse($column, $result); +} + +function alias_of(string $name, string $alias): Alias +{ + return Alias::of($name)->as($alias); +} diff --git a/tests/Feature/Database/Models/Postgres/DatabaseModelTest.php b/tests/Feature/Database/Models/Postgres/DatabaseModelTest.php index 542182a2..2498ac65 100644 --- a/tests/Feature/Database/Models/Postgres/DatabaseModelTest.php +++ b/tests/Feature/Database/Models/Postgres/DatabaseModelTest.php @@ -40,7 +40,7 @@ expect($model->save())->toBeTrue(); expect($model->id)->toBe(77); expect($model->isExisting())->toBeTrue(); - expect($capturedSql)->toContain('RETURNING id'); + expect($capturedSql)->toContain('RETURNING "id"'); }); it('creates a new model on postgresql using its mapped key column', function (): void { @@ -68,5 +68,5 @@ expect($model->id)->toBe(88); expect($model->isExisting())->toBeTrue(); - expect($capturedSql)->toContain('RETURNING id'); + expect($capturedSql)->toContain('RETURNING "id"'); }); diff --git a/tests/Unit/Database/Dialects/DialectFactoryTest.php b/tests/Unit/Database/Dialects/DialectFactoryTest.php index 4d0db28b..c007a51e 100644 --- a/tests/Unit/Database/Dialects/DialectFactoryTest.php +++ b/tests/Unit/Database/Dialects/DialectFactoryTest.php @@ -8,10 +8,6 @@ use Phenix\Database\Dialects\Postgres\PostgresDialect; use Phenix\Database\Dialects\Sqlite\SqliteDialect; -afterEach(function (): void { - DialectFactory::clearCache(); -}); - test('DialectFactory creates MySQL dialect for MySQL driver', function () { $dialect = DialectFactory::fromDriver(Driver::MYSQL); @@ -30,19 +26,11 @@ expect($dialect)->toBeInstanceOf(SqliteDialect::class); }); -test('DialectFactory returns same instance for repeated calls (singleton)', function () { +test('DialectFactory returns new instance for each call', 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); + expect($dialect1)->toBeInstanceOf(MysqlDialect::class); + expect($dialect2)->toBeInstanceOf(MysqlDialect::class); }); diff --git a/tests/Unit/Database/QueryBuilderTest.php b/tests/Unit/Database/QueryBuilderTest.php index 7f28c86a..b24605c5 100644 --- a/tests/Unit/Database/QueryBuilderTest.php +++ b/tests/Unit/Database/QueryBuilderTest.php @@ -119,7 +119,7 @@ $insertedId = $query->table('users')->insertGetId(['name' => 'Tony']); expect($insertedId)->toBe(123); - expect($capturedSql)->toContain('RETURNING id'); + expect($capturedSql)->toContain('RETURNING "id"'); }); it('inserts row and returns generated id on mysql', function () { @@ -160,7 +160,7 @@ ->insertGetId(['name' => 'Tony'], 'user_id'); expect($insertedId)->toBe(456); - expect($capturedSql)->toContain('RETURNING user_id'); + expect($capturedSql)->toContain('RETURNING "user_id"'); }); it('fails on insert records', function () { diff --git a/tests/Unit/Database/QueryGenerator/DeleteStatementTest.php b/tests/Unit/Database/QueryGenerator/DeleteStatementTest.php index c7716339..99bc1009 100644 --- a/tests/Unit/Database/QueryGenerator/DeleteStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/DeleteStatementTest.php @@ -13,7 +13,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE id = ?"; + $expected = "DELETE FROM `users` WHERE `id` = ?"; expect($dml)->toBe($expected); expect($params)->toBe([1]); @@ -27,7 +27,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users"; + $expected = "DELETE FROM `users`"; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); diff --git a/tests/Unit/Database/QueryGenerator/GroupByStatementTest.php b/tests/Unit/Database/QueryGenerator/GroupByStatementTest.php index e9a2eded..0fd4f2bf 100644 --- a/tests/Unit/Database/QueryGenerator/GroupByStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/GroupByStatementTest.php @@ -2,11 +2,13 @@ declare(strict_types=1); -use Phenix\Database\Functions; +use Phenix\Database\Funct; use Phenix\Database\Join; use Phenix\Database\QueryGenerator; -it('generates a grouped query', function (Functions|string $column, Functions|array|string $groupBy, string $rawGroup) { +use function Phenix\Database\count_of; + +it('generates a grouped query', function (Funct|string $column, Funct|array|string $groupBy, string $rawGroup, string $rawColumn) { $query = new QueryGenerator(); $sql = $query->select([ @@ -23,23 +25,24 @@ [$dml, $params] = $sql; - $expected = "SELECT {$column}, products.category_id, categories.description " - . "FROM products " - . "LEFT JOIN categories ON products.category_id = categories.id " + $expected = "SELECT {$rawColumn}, `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)'], + [count_of('products.id'), 'category_id', '`category_id`', 'COUNT(`products`.`id`)'], + ['location_id', ['category_id', 'location_id'], '`category_id`, `location_id`', '`location_id`'], + [count_of('products.id'), count_of('products.id'), 'COUNT(`products`.`id`)', 'COUNT(`products`.`id`)'], ]); it('generates a grouped and ordered query', function ( - Functions|string $column, - Functions|array|string $groupBy, - string $rawGroup + Funct|string $column, + Funct|array|string $groupBy, + string $rawGroup, + string $rawColumn ) { $query = new QueryGenerator(); @@ -58,16 +61,16 @@ [$dml, $params] = $sql; - $expected = "SELECT {$column}, products.category_id, categories.description " - . "FROM products " - . "LEFT JOIN categories ON products.category_id = categories.id " + $expected = "SELECT {$rawColumn}, `products`.`category_id`, `categories`.`description` " + . "FROM `products` " + . "LEFT JOIN `categories` ON `products`.`category_id` = `categories`.`id` " . "GROUP BY {$rawGroup} " - . "ORDER BY products.id DESC"; + . "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)'], + [count_of('products.id'), 'category_id', '`category_id`', 'COUNT(`products`.`id`)'], + ['location_id', ['category_id', 'location_id'], '`category_id`, `location_id`', '`location_id`'], + [count_of('products.id'), count_of('products.id'), 'COUNT(`products`.`id`)', 'COUNT(`products`.`id`)'], ]); diff --git a/tests/Unit/Database/QueryGenerator/HavingClauseTest.php b/tests/Unit/Database/QueryGenerator/HavingClauseTest.php index c2f6d75e..4ed6198b 100644 --- a/tests/Unit/Database/QueryGenerator/HavingClauseTest.php +++ b/tests/Unit/Database/QueryGenerator/HavingClauseTest.php @@ -2,16 +2,17 @@ declare(strict_types=1); -use Phenix\Database\Functions; use Phenix\Database\Having; use Phenix\Database\Join; use Phenix\Database\QueryGenerator; +use function Phenix\Database\count_of; + it('generates a query using having clause', function () { $query = new QueryGenerator(); $sql = $query->select([ - Functions::count('products.id')->as('identifiers'), + count_of('products.id')->as('identifiers'), 'products.category_id', 'categories.description', ]) @@ -27,10 +28,10 @@ [$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"; + $expected = "SELECT COUNT(`products`.`id`) AS `identifiers`, `products`.`category_id`, `categories`.`description` " + . "FROM `products` " + . "LEFT JOIN `categories` ON `products`.`category_id` = `categories`.`id` " + . "GROUP BY `products`.`category_id` HAVING `identifiers` > ?"; expect($dml)->toBe($expected); expect($params)->toBe([5]); @@ -40,7 +41,7 @@ $query = new QueryGenerator(); $sql = $query->select([ - Functions::count('products.id')->as('identifiers'), + count_of('products.id')->as('identifiers'), 'products.category_id', 'categories.description', ]) @@ -57,11 +58,35 @@ [$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"; + $expected = "SELECT COUNT(`products`.`id`) AS `identifiers`, `products`.`category_id`, `categories`.`description` " + . "FROM `products` " + . "LEFT JOIN `categories` ON `products`.`category_id` = `categories`.`id` " + . "GROUP BY `products`.`category_id` HAVING `identifiers` > ? AND `products`.`category_id` > ?"; expect($dml)->toBe($expected); expect($params)->toBe([5, 10]); }); + +it('generates a query using having with date clause', function () { + $query = new QueryGenerator(); + + $sql = $query->select([ + count_of('products.id')->as('product_count'), + 'products.created_at', + ]) + ->from('products') + ->groupBy('products.created_at') + ->having(function (Having $having): void { + $having->whereDateEqual('products.created_at', '2026-01-15'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(`products`.`id`) AS `product_count`, `products`.`created_at` " + . "FROM `products` " + . "GROUP BY `products`.`created_at` HAVING DATE(`products`.`created_at`) = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2026-01-15']); +}); diff --git a/tests/Unit/Database/QueryGenerator/InsertIntoStatementTest.php b/tests/Unit/Database/QueryGenerator/InsertIntoStatementTest.php index 4af01758..b1629c80 100644 --- a/tests/Unit/Database/QueryGenerator/InsertIntoStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/InsertIntoStatementTest.php @@ -21,7 +21,7 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name) VALUES (?, ?)"; + $expected = "INSERT INTO `users` (`email`, `name`) VALUES (?, ?)"; expect($dml)->toBe($expected); expect($params)->toBe([$email, $name]); @@ -47,7 +47,7 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name) VALUES (?, ?), (?, ?)"; + $expected = "INSERT INTO `users` (`email`, `name`) VALUES (?, ?), (?, ?)"; expect($dml)->toBe($expected); expect($params)->toBe([$email, $name, $email, $name]); @@ -67,7 +67,7 @@ [$dml, $params] = $sql; - $expected = "INSERT IGNORE INTO users (email, name) VALUES (?, ?)"; + $expected = "INSERT IGNORE INTO `users` (`email`, `name`) VALUES (?, ?)"; expect($dml)->toBe($expected); expect($params)->toBe([$email, $name]); @@ -87,8 +87,8 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name) VALUES (?, ?) " - . "ON DUPLICATE KEY UPDATE name = VALUES(name)"; + $expected = "INSERT INTO `users` (`email`, `name`) VALUES (?, ?) " + . "ON DUPLICATE KEY UPDATE `name` = VALUES(`name`)"; expect($dml)->toBe($expected); expect($params)->toBe([$email, $name]); @@ -108,8 +108,8 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name, username) VALUES (?, ?, ?) " - . "ON DUPLICATE KEY UPDATE name = VALUES(name), username = VALUES(username)"; + $expected = "INSERT INTO `users` (`email`, `name`, `username`) VALUES (?, ?, ?) " + . "ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `username` = VALUES(`username`)"; \ksort($data); @@ -130,12 +130,37 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (name, email) SELECT name, email FROM customers WHERE verified_at IS NOT NULL"; + $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('stores insert from subquery params directly in query ast', function () { + $query = new class () extends QueryGenerator { + public function params(): array + { + return $this->getAst()->params; + } + }; + + $sql = $query->table('users') + ->insertFrom(function (Subquery $subquery) { + $subquery->table('customers') + ->select(['name', 'email']) + ->whereEqual('status', 'active'); + }, ['name', 'email']); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO `users` (`name`, `email`) " + . "SELECT `name`, `email` FROM `customers` WHERE `status` = ?"; + + expect($query->params())->toBe(['active']); + expect($dml)->toBe($expected); + expect($params)->toBe(['active']); +}); + it('generates insert ignore statement from subquery', function () { $query = new QueryGenerator(); @@ -148,8 +173,8 @@ [$dml, $params] = $sql; - $expected = "INSERT IGNORE INTO users (name, email) " - . "SELECT name, email FROM customers WHERE verified_at IS NOT NULL"; + $expected = "INSERT 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/JoinClausesTest.php b/tests/Unit/Database/QueryGenerator/JoinClausesTest.php index 961669a2..d4e14c87 100644 --- a/tests/Unit/Database/QueryGenerator/JoinClausesTest.php +++ b/tests/Unit/Database/QueryGenerator/JoinClausesTest.php @@ -5,6 +5,7 @@ use Phenix\Database\Constants\JoinType; use Phenix\Database\Join; use Phenix\Database\QueryGenerator; +use Phenix\Database\Subquery; it('generates query for all join types', function (string $method, string $joinType) { $query = new QueryGenerator(); @@ -22,10 +23,10 @@ [$dml, $params] = $sql; - $expected = "SELECT products.id, products.description, categories.description " - . "FROM products " - . "{$joinType} categories " - . "ON products.category_id = categories.id"; + $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(); @@ -54,10 +55,10 @@ [$dml, $params] = $sql; - $expected = "SELECT products.id, products.description, categories.description " - . "FROM products " - . "INNER JOIN categories " - . "ON products.category_id != categories.id"; + $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(); @@ -85,10 +86,10 @@ [$dml, $params] = $sql; - $expected = "SELECT products.id, products.description, categories.description " - . "FROM products " - . "INNER JOIN categories " - . "ON products.category_id = categories.id {$clause}"; + $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); @@ -96,25 +97,25 @@ [ 'orOnEqual', ['products.location_id', 'categories.location_id'], - 'OR products.location_id = categories.location_id', + 'OR `products`.`location_id` = `categories`.`location_id`', [], ], [ 'whereEqual', ['categories.name', 'php'], - 'AND categories.name = ?', + 'AND `categories`.`name` = ?', ['php'], ], [ 'orOnNotEqual', ['products.location_id', 'categories.location_id'], - 'OR products.location_id != categories.location_id', + 'OR `products`.`location_id` != `categories`.`location_id`', [], ], [ 'orWhereEqual', ['categories.name', 'php'], - 'OR categories.name = ?', + 'OR `categories`.`name` = ?', ['php'], ], ]); @@ -133,10 +134,10 @@ [$dml, $params] = $sql; - $expected = "SELECT products.id, products.description, categories.description " - . "FROM products " - . "{$joinType} categories " - . "ON products.category_id = categories.id"; + $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(); @@ -145,3 +146,85 @@ ['leftJoinOnEqual', JoinType::LEFT->value], ['rightJoinOnEqual', JoinType::RIGHT->value], ]); + +it('generates query with join date clause', function () { + $query = new QueryGenerator(); + + $sql = $query->select([ + 'products.id', + 'categories.description', + ]) + ->from('products') + ->innerJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id') + ->whereDateEqual('categories.created_at', '2026-01-15'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT `products`.`id`, `categories`.`description` " + . "FROM `products` " + . "INNER JOIN `categories` " + . "ON `products`.`category_id` = `categories`.`id` AND DATE(`categories`.`created_at`) = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2026-01-15']); +}); + +it('generates query with multiple joins using params', function () { + $query = new QueryGenerator(); + + $sql = $query->select([ + 'products.id', + 'categories.name', + 'suppliers.name', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id') + ->whereEqual('categories.status', 'active'); + }) + ->leftJoin('suppliers', function (Join $join) { + $join->onEqual('products.supplier_id', 'suppliers.id') + ->whereEqual('suppliers.region', 'latam'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT `products`.`id`, `categories`.`name`, `suppliers`.`name` " + . "FROM `products` " + . "LEFT JOIN `categories` ON `products`.`category_id` = `categories`.`id` AND `categories`.`status` = ? " + . "LEFT JOIN `suppliers` ON `products`.`supplier_id` = `suppliers`.`id` AND `suppliers`.`region` = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', 'latam']); +}); + +it('keeps params ordered when from subquery and join both use placeholders', function (): void { + $query = new QueryGenerator(); + + $sql = $query->select(['products.id']) + ->from(function (Subquery $subquery): void { + $subquery->from('products') + ->whereEqual('tenant_id', 7); + }) + ->leftJoin('categories', function (Join $join): void { + $join->onEqual('products.category_id', 'categories.id') + ->whereEqual('categories.status', 'active'); + }) + ->whereEqual('products.status', 'published') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT `products`.`id` " + . "FROM (SELECT * FROM `products` WHERE `tenant_id` = ?) " + . "LEFT JOIN `categories` " + . "ON `products`.`category_id` = `categories`.`id` AND `categories`.`status` = ? " + . "WHERE `products`.`status` = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([7, 'active', 'published']); +}); diff --git a/tests/Unit/Database/QueryGenerator/PaginateTest.php b/tests/Unit/Database/QueryGenerator/PaginateTest.php index c09c314f..00d3a8ee 100644 --- a/tests/Unit/Database/QueryGenerator/PaginateTest.php +++ b/tests/Unit/Database/QueryGenerator/PaginateTest.php @@ -13,7 +13,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 0'); + expect($dml)->toBe('SELECT * FROM `users` LIMIT 15 OFFSET 0'); expect($params)->toBeEmpty(); }); @@ -26,7 +26,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 30'); + expect($dml)->toBe('SELECT * FROM `users` LIMIT 15 OFFSET 30'); expect($params)->toBeEmpty(); }); @@ -40,6 +40,6 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 15'); + expect($dml)->toBe('SELECT * FROM `users` LIMIT 15 OFFSET 15'); expect($params)->toBeEmpty(); }); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php index 0e9f6f56..2bf239f0 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php @@ -14,7 +14,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE id = $1"; + $expected = "DELETE FROM \"users\" WHERE \"id\" = $1"; expect($dml)->toBe($expected); expect($params)->toBe([1]); @@ -28,7 +28,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users"; + $expected = "DELETE FROM \"users\""; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -44,7 +44,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE status = $1 AND role = $2"; + $expected = "DELETE FROM \"users\" WHERE \"status\" = $1 AND \"role\" = $2"; expect($dml)->toBe($expected); expect($params)->toBe(['inactive', 'user']); @@ -59,7 +59,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE id IN ($1, $2, $3)"; + $expected = "DELETE FROM \"users\" WHERE \"id\" IN ($1, $2, $3)"; expect($dml)->toBe($expected); expect($params)->toBe([1, 2, 3]); @@ -74,7 +74,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE status != $1"; + $expected = "DELETE FROM \"users\" WHERE \"status\" != $1"; expect($dml)->toBe($expected); expect($params)->toBe(['active']); @@ -89,7 +89,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE age > $1"; + $expected = "DELETE FROM \"users\" WHERE \"age\" > $1"; expect($dml)->toBe($expected); expect($params)->toBe([18]); @@ -104,7 +104,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE age < $1"; + $expected = "DELETE FROM \"users\" WHERE \"age\" < $1"; expect($dml)->toBe($expected); expect($params)->toBe([65]); @@ -119,7 +119,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE deleted_at IS NULL"; + $expected = "DELETE FROM \"users\" WHERE \"deleted_at\" IS NULL"; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -134,7 +134,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE email IS NOT NULL"; + $expected = "DELETE FROM \"users\" WHERE \"email\" IS NOT NULL"; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -150,7 +150,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE id = $1 RETURNING id, name, email"; + $expected = "DELETE FROM \"users\" WHERE \"id\" = $1 RETURNING \"id\", \"name\", \"email\""; expect($dml)->toBe($expected); expect($params)->toBe([1]); @@ -166,7 +166,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE status IN ($1, $2) RETURNING *"; + $expected = "DELETE FROM \"users\" WHERE \"status\" IN ($1, $2) RETURNING *"; expect($dml)->toBe($expected); expect($params)->toBe(['inactive', 'deleted']); @@ -181,7 +181,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users RETURNING id, email"; + $expected = "DELETE FROM \"users\" RETURNING \"id\", \"email\""; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -198,7 +198,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE status = $1 AND created_at > $2 RETURNING id, name, status, created_at"; + $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 index f4223bb5..c9e6ebfd 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/GroupByStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/GroupByStatementTest.php @@ -3,12 +3,16 @@ declare(strict_types=1); use Phenix\Database\Constants\Driver; -use Phenix\Database\Functions; +use Phenix\Database\Funct; use Phenix\Database\Having; use Phenix\Database\Join; use Phenix\Database\QueryGenerator; -it('generates a grouped query', function (Functions|string $column, Functions|array|string $groupBy, string $rawGroup): void { +use function Phenix\Database\avg; +use function Phenix\Database\count_of; +use function Phenix\Database\sum; + +it('generates a grouped query', function (Funct|string $column, Funct|array|string $groupBy, string $rawGroup, string $rawColumn): void { $query = new QueryGenerator(Driver::POSTGRESQL); $sql = $query->select([ @@ -25,23 +29,24 @@ [$dml, $params] = $sql; - $expected = "SELECT {$column}, products.category_id, categories.description " - . "FROM products " - . "LEFT JOIN categories ON products.category_id = categories.id " + $expected = "SELECT {$rawColumn}, \"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)'], + [count_of('products.id'), 'category_id', '"category_id"', 'COUNT("products"."id")'], + ['location_id', ['category_id', 'location_id'], '"category_id", "location_id"', '"location_id"'], + [count_of('products.id'), count_of('products.id'), 'COUNT("products"."id")', 'COUNT("products"."id")'], ]); it('generates a grouped and ordered query', function ( - Functions|string $column, - Functions|array|string $groupBy, - string $rawGroup + Funct|string $column, + Funct|array|string $groupBy, + string $rawGroup, + string $rawColumn ) { $query = new QueryGenerator(Driver::POSTGRESQL); @@ -60,25 +65,25 @@ [$dml, $params] = $sql; - $expected = "SELECT {$column}, products.category_id, categories.description " - . "FROM products " - . "LEFT JOIN categories ON products.category_id = categories.id " + $expected = "SELECT {$rawColumn}, \"products\".\"category_id\", \"categories\".\"description\" " + . "FROM \"products\" " + . "LEFT JOIN \"categories\" ON \"products\".\"category_id\" = \"categories\".\"id\" " . "GROUP BY {$rawGroup} " - . "ORDER BY products.id DESC"; + . "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)'], + [count_of('products.id'), 'category_id', '"category_id"', 'COUNT("products"."id")'], + ['location_id', ['category_id', 'location_id'], '"category_id", "location_id"', '"location_id"'], + [count_of('products.id'), count_of('products.id'), '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'), + count_of('products.id'), 'products.category_id', ]) ->from('products') @@ -88,10 +93,10 @@ [$dml, $params] = $sql; - $expected = "SELECT COUNT(products.id), products.category_id " - . "FROM products " - . "WHERE products.status = $1 " - . "GROUP BY category_id"; + $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']); @@ -101,7 +106,7 @@ $query = new QueryGenerator(Driver::POSTGRESQL); $sql = $query->select([ - Functions::count('products.id')->as('product_count'), + count_of('products.id')->as('product_count'), 'products.category_id', ]) ->from('products') @@ -113,10 +118,10 @@ [$dml, $params] = $sql; - $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " - . "FROM products " - . "HAVING product_count > $1 " - . "GROUP BY category_id"; + $expected = "SELECT COUNT(\"products\".\"id\") AS \"product_count\", \"products\".\"category_id\" " + . "FROM \"products\" " + . "GROUP BY \"category_id\" " + . "HAVING \"product_count\" > $1"; expect($dml)->toBe($expected); expect($params)->toBe([5]); @@ -126,9 +131,9 @@ $query = new QueryGenerator(Driver::POSTGRESQL); $sql = $query->select([ - Functions::count('products.id'), - Functions::sum('products.price'), - Functions::avg('products.price'), + count_of('products.id'), + sum('products.price'), + avg('products.price'), 'products.category_id', ]) ->from('products') @@ -137,9 +142,9 @@ [$dml, $params] = $sql; - $expected = "SELECT COUNT(products.id), SUM(products.price), AVG(products.price), products.category_id " - . "FROM products " - . "GROUP BY category_id"; + $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 index 3a1ec837..887772a3 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/HavingClauseTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/HavingClauseTest.php @@ -3,16 +3,18 @@ declare(strict_types=1); use Phenix\Database\Constants\Driver; -use Phenix\Database\Functions; use Phenix\Database\Having; use Phenix\Database\Join; use Phenix\Database\QueryGenerator; +use function Phenix\Database\count_of; +use function Phenix\Database\sum; + it('generates a query using having clause', function () { $query = new QueryGenerator(Driver::POSTGRESQL); $sql = $query->select([ - Functions::count('products.id')->as('identifiers'), + count_of('products.id')->as('identifiers'), 'products.category_id', 'categories.description', ]) @@ -28,10 +30,10 @@ [$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"; + $expected = "SELECT COUNT(\"products\".\"id\") AS \"identifiers\", \"products\".\"category_id\", \"categories\".\"description\" " + . "FROM \"products\" " + . "LEFT JOIN \"categories\" ON \"products\".\"category_id\" = \"categories\".\"id\" " + . "GROUP BY \"products\".\"category_id\" HAVING \"identifiers\" > $1"; expect($dml)->toBe($expected); expect($params)->toBe([5]); @@ -41,7 +43,7 @@ $query = new QueryGenerator(Driver::POSTGRESQL); $sql = $query->select([ - Functions::count('products.id')->as('identifiers'), + count_of('products.id')->as('identifiers'), 'products.category_id', 'categories.description', ]) @@ -58,10 +60,10 @@ [$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"; + $expected = "SELECT COUNT(\"products\".\"id\") AS \"identifiers\", \"products\".\"category_id\", \"categories\".\"description\" " + . "FROM \"products\" " + . "LEFT JOIN \"categories\" ON \"products\".\"category_id\" = \"categories\".\"id\" " + . "GROUP BY \"products\".\"category_id\" HAVING \"identifiers\" > $1 AND \"products\".\"category_id\" > $2"; expect($dml)->toBe($expected); expect($params)->toBe([5, 10]); @@ -71,7 +73,7 @@ $query = new QueryGenerator(Driver::POSTGRESQL); $sql = $query->select([ - Functions::count('products.id')->as('product_count'), + count_of('products.id')->as('product_count'), 'products.category_id', ]) ->from('products') @@ -84,10 +86,10 @@ [$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"; + $expected = "SELECT COUNT(\"products\".\"id\") AS \"product_count\", \"products\".\"category_id\" " + . "FROM \"products\" " + . "WHERE \"products\".\"status\" = $1 " + . "GROUP BY \"products\".\"category_id\" HAVING \"product_count\" > $2"; expect($dml)->toBe($expected); expect($params)->toBe(['active', 3]); @@ -97,7 +99,7 @@ $query = new QueryGenerator(Driver::POSTGRESQL); $sql = $query->select([ - Functions::sum('orders.total')->as('total_sales'), + sum('orders.total')->as('total_sales'), 'orders.customer_id', ]) ->from('orders') @@ -109,9 +111,9 @@ [$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"; + $expected = "SELECT SUM(\"orders\".\"total\") AS \"total_sales\", \"orders\".\"customer_id\" " + . "FROM \"orders\" " + . "GROUP BY \"orders\".\"customer_id\" HAVING \"total_sales\" < $1"; expect($dml)->toBe($expected); expect($params)->toBe([1000]); @@ -121,7 +123,7 @@ $query = new QueryGenerator(Driver::POSTGRESQL); $sql = $query->select([ - Functions::count('products.id')->as('product_count'), + count_of('products.id')->as('product_count'), 'products.category_id', ]) ->from('products') @@ -133,10 +135,91 @@ [$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"; + $expected = "SELECT COUNT(\"products\".\"id\") AS \"product_count\", \"products\".\"category_id\" " + . "FROM \"products\" " + . "GROUP BY \"products\".\"category_id\" HAVING \"product_count\" = $1"; expect($dml)->toBe($expected); expect($params)->toBe([10]); }); + +it('generates a query using having with date clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + count_of('products.id')->as('product_count'), + 'products.created_at', + ]) + ->from('products') + ->groupBy('products.created_at') + ->having(function (Having $having): void { + $having->whereDateEqual('products.created_at', '2026-01-15'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(\"products\".\"id\") AS \"product_count\", \"products\".\"created_at\" " + . "FROM \"products\" " + . "GROUP BY \"products\".\"created_at\" HAVING DATE(\"products\".\"created_at\") = $1"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2026-01-15']); +}); + +it('orders join where and having params by final sql position', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + count_of('products.id')->as('product_count'), + 'products.category_id', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id') + ->whereEqual('categories.status', 'enabled'); + }) + ->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\" " + . "LEFT JOIN \"categories\" ON \"products\".\"category_id\" = \"categories\".\"id\" AND \"categories\".\"status\" = $1 " + . "WHERE \"products\".\"status\" = $2 " + . "GROUP BY \"products\".\"category_id\" HAVING \"product_count\" > $3"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['enabled', 'active', 3]); +}); + +it('orders where params before having params regardless of call order', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + count_of('products.id')->as('product_count'), + 'products.category_id', + ]) + ->from('products') + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('product_count', 3); + }) + ->whereEqual('products.status', 'active') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(\"products\".\"id\") AS \"product_count\", \"products\".\"category_id\" " + . "FROM \"products\" " + . "WHERE \"products\".\"status\" = $1 " + . "GROUP BY \"products\".\"category_id\" HAVING \"product_count\" > $2"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', 3]); +}); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/InsertIntoStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/InsertIntoStatementTest.php index 4204a7fc..d536408c 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/InsertIntoStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/InsertIntoStatementTest.php @@ -22,7 +22,7 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name) VALUES ($1, $2)"; + $expected = "INSERT INTO \"users\" (\"email\", \"name\") VALUES ($1, $2)"; expect($dml)->toBe($expected); expect($params)->toBe([$email, $name]); @@ -48,7 +48,7 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name) VALUES ($1, $2), ($3, $4)"; + $expected = "INSERT INTO \"users\" (\"email\", \"name\") VALUES ($1, $2), ($3, $4)"; expect($dml)->toBe($expected); expect($params)->toBe([$email, $name, $email, $name]); @@ -68,7 +68,7 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name) VALUES ($1, $2) ON CONFLICT DO NOTHING"; + $expected = "INSERT INTO \"users\" (\"email\", \"name\") VALUES ($1, $2) ON CONFLICT DO NOTHING"; expect($dml)->toBe($expected); expect($params)->toBe([$email, $name]); @@ -89,7 +89,7 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id"; + $expected = "INSERT INTO \"users\" (\"email\", \"name\") VALUES ($1, $2) RETURNING \"id\""; expect($dml)->toBe($expected); expect($params)->toBe([$email, $name]); @@ -110,7 +110,7 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING id, email"; + $expected = "INSERT INTO \"users\" (\"email\", \"name\") VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING \"id\", \"email\""; expect($dml)->toBe($expected); expect($params)->toBe([$email, $name]); @@ -130,8 +130,8 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name) VALUES ($1, $2) " - . "ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name"; + $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]); @@ -151,8 +151,8 @@ [$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"; + $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); @@ -172,7 +172,7 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (name, email) SELECT name, email FROM customers WHERE verified_at IS NOT NULL"; + $expected = "INSERT INTO \"users\" (\"name\", \"email\") SELECT \"name\", \"email\" FROM \"customers\" WHERE \"verified_at\" IS NOT NULL"; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -190,8 +190,8 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (name, email) " - . "SELECT name, email FROM customers WHERE verified_at IS NOT NULL " + $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); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/JoinClausesTest.php b/tests/Unit/Database/QueryGenerator/Postgres/JoinClausesTest.php index ecfdc834..26520cf5 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/JoinClausesTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/JoinClausesTest.php @@ -6,6 +6,7 @@ use Phenix\Database\Constants\JoinType; use Phenix\Database\Join; use Phenix\Database\QueryGenerator; +use Phenix\Database\Subquery; it('generates query for all join types', function (string $method, string $joinType) { $query = new QueryGenerator(Driver::POSTGRESQL); @@ -23,10 +24,10 @@ [$dml, $params] = $sql; - $expected = "SELECT products.id, products.description, categories.description " - . "FROM products " - . "{$joinType} categories " - . "ON products.category_id = categories.id"; + $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(); @@ -55,10 +56,10 @@ [$dml, $params] = $sql; - $expected = "SELECT products.id, products.description, categories.description " - . "FROM products " - . "INNER JOIN categories " - . "ON products.category_id != categories.id"; + $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(); @@ -86,10 +87,10 @@ [$dml, $params] = $sql; - $expected = "SELECT products.id, products.description, categories.description " - . "FROM products " - . "INNER JOIN categories " - . "ON products.category_id = categories.id {$clause}"; + $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); @@ -97,25 +98,25 @@ [ 'orOnEqual', ['products.location_id', 'categories.location_id'], - 'OR products.location_id = categories.location_id', + 'OR "products"."location_id" = "categories"."location_id"', [], ], [ 'whereEqual', ['categories.name', 'php'], - 'AND categories.name = $1', + 'AND "categories"."name" = $1', ['php'], ], [ 'orOnNotEqual', ['products.location_id', 'categories.location_id'], - 'OR products.location_id != categories.location_id', + 'OR "products"."location_id" != "categories"."location_id"', [], ], [ 'orWhereEqual', ['categories.name', 'php'], - 'OR categories.name = $1', + 'OR "categories"."name" = $1', ['php'], ], ]); @@ -134,10 +135,10 @@ [$dml, $params] = $sql; - $expected = "SELECT products.id, products.description, categories.description " - . "FROM products " - . "{$joinType} categories " - . "ON products.category_id = categories.id"; + $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(); @@ -147,6 +148,31 @@ ['rightJoinOnEqual', JoinType::RIGHT->value], ]); +it('generates query with join date clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'products.id', + 'categories.description', + ]) + ->from('products') + ->innerJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id') + ->whereDateEqual('categories.created_at', '2026-01-15'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT \"products\".\"id\", \"categories\".\"description\" " + . "FROM \"products\" " + . "INNER JOIN \"categories\" " + . "ON \"products\".\"category_id\" = \"categories\".\"id\" AND DATE(\"categories\".\"created_at\") = $1"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2026-01-15']); +}); + it('generates query with multiple joins', function () { $query = new QueryGenerator(Driver::POSTGRESQL); @@ -166,10 +192,10 @@ [$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"; + $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(); @@ -191,11 +217,64 @@ [$dml, $params] = $sql; - $expected = "SELECT products.id, categories.name " - . "FROM products " - . "LEFT JOIN categories ON products.category_id = categories.id " - . "WHERE products.status = $1"; + $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']); }); + +it('orders join params before where params for postgresql placeholders', 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('categories.status', 'active'); + }) + ->whereEqual('products.status', 'published') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT \"products\".\"id\", \"categories\".\"name\" " + . "FROM \"products\" " + . "LEFT JOIN \"categories\" ON \"products\".\"category_id\" = \"categories\".\"id\" AND \"categories\".\"status\" = $1 " + . "WHERE \"products\".\"status\" = $2"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', 'published']); +}); + +it('keeps params ordered when from subquery and join both use postgresql placeholders', function (): void { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select(['products.id']) + ->from(function (Subquery $subquery): void { + $subquery->from('products') + ->whereEqual('tenant_id', 7); + }) + ->leftJoin('categories', function (Join $join): void { + $join->onEqual('products.category_id', 'categories.id') + ->whereEqual('categories.status', 'active'); + }) + ->whereEqual('products.status', 'published') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT \"products\".\"id\" " + . "FROM (SELECT * FROM \"products\" WHERE \"tenant_id\" = $1) " + . "LEFT JOIN \"categories\" " + . "ON \"products\".\"category_id\" = \"categories\".\"id\" AND \"categories\".\"status\" = $2 " + . "WHERE \"products\".\"status\" = $3"; + + expect($dml)->toBe($expected); + expect($params)->toBe([7, 'active', 'published']); +}); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/PaginateTest.php b/tests/Unit/Database/QueryGenerator/Postgres/PaginateTest.php index 6f782397..6de35d34 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/PaginateTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/PaginateTest.php @@ -15,7 +15,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 0'); + expect($dml)->toBe('SELECT * FROM "users" LIMIT 15 OFFSET 0'); expect($params)->toBeEmpty(); }); @@ -28,7 +28,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 30'); + expect($dml)->toBe('SELECT * FROM "users" LIMIT 15 OFFSET 30'); expect($params)->toBeEmpty(); }); @@ -42,7 +42,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 15'); + expect($dml)->toBe('SELECT * FROM "users" LIMIT 15 OFFSET 15'); expect($params)->toBeEmpty(); }); @@ -56,7 +56,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users WHERE status = $1 LIMIT 15 OFFSET 15'); + expect($dml)->toBe('SELECT * FROM "users" WHERE "status" = $1 LIMIT 15 OFFSET 15'); expect($params)->toBe(['active']); }); @@ -70,7 +70,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users ORDER BY created_at ASC LIMIT 15 OFFSET 0'); + expect($dml)->toBe('SELECT * FROM "users" ORDER BY "created_at" ASC LIMIT 15 OFFSET 0'); expect($params)->toBeEmpty(); }); @@ -83,6 +83,6 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users LIMIT 25 OFFSET 25'); + 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 index 1bb05d0d..d2cb52f2 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/SelectColumnsTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/SelectColumnsTest.php @@ -2,15 +2,19 @@ declare(strict_types=1); -use Phenix\Database\Alias; use Phenix\Database\Constants\Driver; use Phenix\Database\Constants\Lock; use Phenix\Database\Constants\Operator; use Phenix\Database\Exceptions\QueryErrorException; -use Phenix\Database\Functions; use Phenix\Database\QueryGenerator; use Phenix\Database\Subquery; -use Phenix\Database\Value; + +use function Phenix\Database\alias_of; +use function Phenix\Database\avg; +use function Phenix\Database\subquery; +use function Phenix\Database\when_equal; +use function Phenix\Database\when_gte; +use function Phenix\Database\when_null; it('generates query to select all columns of table', function () { $query = new QueryGenerator(Driver::POSTGRESQL); @@ -22,7 +26,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users'); + expect($dml)->toBe('SELECT * FROM "users"'); expect($params)->toBeEmpty(); }); @@ -36,27 +40,28 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users'); + 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); + $factory = "Phenix\\Database\\{$function}"; $sql = $query->table('products') - ->select([Functions::{$function}($column)]) + ->select([$factory($column)]) ->get(); [$dml, $params] = $sql; - expect($dml)->toBe("SELECT {$rawFunction} FROM products"); + 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)'], + ['avg', 'price', 'AVG("price")'], + ['sum', 'price', 'SUM("price")'], + ['min_of', 'price', 'MIN("price")'], + ['max_of', 'price', 'MAX("price")'], + ['count_of', 'id', 'COUNT("id")'], ]); it('generates a query using sql functions with alias', function ( @@ -66,21 +71,22 @@ string $rawFunction ) { $query = new QueryGenerator(Driver::POSTGRESQL); + $factory = "Phenix\\Database\\{$function}"; $sql = $query->table('products') - ->select([Functions::{$function}($column)->as($alias)]) + ->select([$factory($column)->as($alias)]) ->get(); [$dml, $params] = $sql; - expect($dml)->toBe("SELECT {$rawFunction} FROM products"); + 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'], + ['avg', 'price', 'value', 'AVG("price") AS "value"'], + ['sum', 'price', 'value', 'SUM("price") AS "value"'], + ['min_of', 'price', 'value', 'MIN("price") AS "value"'], + ['max_of', 'price', 'value', 'MAX("price") AS "value"'], + ['count_of', 'id', 'value', 'COUNT("id") AS "value"'], ]); it('selects field from subquery', function () { @@ -96,7 +102,7 @@ [$dml, $params] = $sql; - $expected = "SELECT id, name, email FROM (SELECT * FROM users WHERE verified_at = $1)"; + $expected = "SELECT \"id\", \"name\", \"email\" FROM (SELECT * FROM \"users\" WHERE \"verified_at\" = $1)"; expect($dml)->toBe($expected); expect($params)->toBe([$date]); @@ -109,7 +115,7 @@ $sql = $query->select([ 'id', 'name', - Subquery::make(Driver::POSTGRESQL)->select(['name']) + subquery(['name']) ->from('countries') ->whereColumn('users.country_id', 'countries.id') ->as('country_name') @@ -120,8 +126,8 @@ [$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"; + $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(); @@ -134,7 +140,7 @@ $query->select([ 'id', 'name', - Subquery::make(Driver::POSTGRESQL)->select(['name']) + subquery(['name']) ->from('countries') ->whereColumn('users.country_id', 'countries.id') ->as('country_name'), @@ -149,14 +155,14 @@ $sql = $query->select([ 'id', - Alias::of('name')->as('full_name'), + alias_of('name', 'full_name'), ]) ->from('users') ->get(); [$dml, $params] = $sql; - $expected = "SELECT id, name AS full_name FROM users"; + $expected = "SELECT \"id\", \"name\" AS \"full_name\" FROM \"users\""; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -174,26 +180,25 @@ [$dml, $params] = $sql; - $expected = "SELECT id AS model_id, name AS full_name FROM users"; + $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, + string $function, 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) + $factory = "Phenix\\Database\\{$function}"; + + $case = $factory($column, $value, $result) ->defaultResult($defaultResult) ->as('type'); @@ -207,22 +212,22 @@ [$dml, $params] = $sql; - $expected = "SELECT id, description, (CASE WHEN {$column} {$operator} {$value} " - . "THEN {$result} ELSE $defaultResult END) AS type FROM products"; + $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], + ['when_equal', ['price', 100, 'expensive'], 'cheap', Operator::EQUAL->value], + ['when_not_equal', ['price', 100, 'expensive'], 'cheap', Operator::NOT_EQUAL->value], + ['when_gt', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN->value], + ['when_gte', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN_OR_EQUAL->value], + ['when_lt', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN->value], + ['when_lte', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN_OR_EQUAL->value], ]); it('generates query with select-cases using logical comparisons', function ( - string $method, + string $function, array $data, string $defaultResult, string $operator @@ -231,8 +236,9 @@ $query = new QueryGenerator(Driver::POSTGRESQL); - $case = Functions::case() - ->{$method}(...$data) + $factory = "Phenix\\Database\\{$function}"; + + $case = $factory(...$data) ->defaultResult($defaultResult) ->as('status'); @@ -246,27 +252,46 @@ [$dml, $params] = $sql; - $expected = "SELECT id, name, (CASE WHEN {$column} {$operator} " - . "THEN {$result} ELSE $defaultResult END) AS status FROM users"; + $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], + ['when_null', ['created_at', 'inactive'], 'active', Operator::IS_NULL->value], + ['when_not_null', ['created_at', 'active'], 'inactive', Operator::IS_NOT_NULL->value], + ['when_true', ['is_verified', 'active'], 'inactive', Operator::IS_TRUE->value], + ['when_false', ['is_verified', 'inactive'], 'active', Operator::IS_FALSE->value], ]); +it('does not rewrite placeholders inside select-case string literals', function (): void { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $case = when_equal('status', 'draft', 'needs?') + ->defaultResult("it's ok?") + ->as('label'); + + $sql = $query->select([$case]) + ->from('tasks') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT (CASE WHEN \"status\" = 'draft' " + . "THEN 'needs?' ELSE 'it''s ok?' END) AS \"label\" FROM \"tasks\""; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + 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')) + $case = when_null('created_at', 'inactive') + ->whenGreaterThan('created_at', $date, 'new user') + ->defaultResult('old user') ->as('status'); $sql = $query->select([ @@ -279,8 +304,8 @@ [$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"; + $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(); @@ -291,9 +316,8 @@ $query = new QueryGenerator(Driver::POSTGRESQL); - $case = Functions::case() - ->whenNull('created_at', Value::from('inactive')) - ->whenGreaterThan('created_at', Value::from($date), Value::from('new user')) + $case = when_null('created_at', 'inactive') + ->whenGreaterThan('created_at', $date, 'new user') ->as('status'); $sql = $query->select([ @@ -306,8 +330,8 @@ [$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"; + $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(); @@ -316,9 +340,8 @@ 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')) + $case = when_gte(avg('price'), 4, 'expensive') + ->defaultResult('cheap') ->as('message'); $sql = $query->select([ @@ -332,8 +355,8 @@ [$dml, $params] = $sql; - $expected = "SELECT id, description, price, (CASE WHEN AVG(price) >= 4 THEN 'expensive' ELSE 'cheap' END) " - . "AS message FROM products"; + $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(); @@ -346,7 +369,7 @@ [$dml, $params] = $sql; - $expected = "SELECT COUNT(*) FROM products"; + $expected = "SELECT COUNT(*) FROM \"products\""; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -362,7 +385,7 @@ [$dml, $params] = $sql; $expected = "SELECT EXISTS" - . " (SELECT 1 FROM products WHERE id = $1) AS 'exists'"; + . " (SELECT 1 FROM \"products\" WHERE \"id\" = $1) AS \"exists\""; expect($dml)->toBe($expected); expect($params)->toBe([1]); @@ -378,7 +401,7 @@ [$dml, $params] = $sql; $expected = "SELECT NOT EXISTS" - . " (SELECT 1 FROM products WHERE id = $1) AS 'exists'"; + . " (SELECT 1 FROM \"products\" WHERE \"id\" = $1) AS \"exists\""; expect($dml)->toBe($expected); expect($params)->toBe([1]); @@ -393,7 +416,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM products WHERE id = $1 LIMIT 1"; + $expected = "SELECT * FROM \"products\" WHERE \"id\" = $1 LIMIT 1"; expect($dml)->toBe($expected); expect($params)->toBe([1]); @@ -408,7 +431,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users'); + expect($dml)->toBe('SELECT * FROM "users"'); expect($params)->toBeEmpty(); }); @@ -422,7 +445,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR UPDATE"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL FOR UPDATE"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -438,7 +461,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR SHARE"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL FOR SHARE"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -454,7 +477,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR UPDATE SKIP LOCKED"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL FOR UPDATE SKIP LOCKED"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -470,7 +493,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR UPDATE NOWAIT"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL FOR UPDATE NOWAIT"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -486,7 +509,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR UPDATE SKIP LOCKED"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL FOR UPDATE SKIP LOCKED"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -504,7 +527,7 @@ [$dml, $params] = $builder->get(); - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -520,7 +543,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR NO KEY UPDATE"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL FOR NO KEY UPDATE"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -536,7 +559,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR KEY SHARE"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL FOR KEY SHARE"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -552,7 +575,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR SHARE SKIP LOCKED"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL FOR SHARE SKIP LOCKED"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -568,7 +591,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR SHARE NOWAIT"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL FOR SHARE NOWAIT"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -584,7 +607,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR NO KEY UPDATE SKIP LOCKED"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL FOR NO KEY UPDATE SKIP LOCKED"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -600,7 +623,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR NO KEY UPDATE NOWAIT"; + $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 index c1a9ca0b..cb5d02fe 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php @@ -18,7 +18,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET name = $1 WHERE id = $2"; + $expected = "UPDATE \"users\" SET \"name\" = $1 WHERE \"id\" = $2"; expect($dml)->toBe($expected); expect($params)->toBe([$name, 1]); @@ -36,7 +36,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET name = $1, active = $2 WHERE verified_at IS NOT NULL AND role_id = $3"; + $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]); @@ -51,7 +51,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET status = $1 WHERE id = $2"; + $expected = "UPDATE \"users\" SET \"status\" = $1 WHERE \"id\" = $2"; expect($dml)->toBe($expected); expect($params)->toBe(['inactive', 5]); @@ -66,7 +66,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET status = $1 WHERE id IN ($2, $3, $4)"; + $expected = "UPDATE \"users\" SET \"status\" = $1 WHERE \"id\" IN ($2, $3, $4)"; expect($dml)->toBe($expected); expect($params)->toBe(['active', 1, 2, 3]); @@ -84,7 +84,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET email = $1, verified = $2 WHERE status = $3 AND created_at > $4"; + $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']); @@ -99,7 +99,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET access_level = $1 WHERE role != $2"; + $expected = "UPDATE \"users\" SET \"access_level\" = $1 WHERE \"role\" != $2"; expect($dml)->toBe($expected); expect($params)->toBe([1, 'admin']); @@ -114,7 +114,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET last_login = $1 WHERE deleted_at IS NULL"; + $expected = "UPDATE \"users\" SET \"last_login\" = $1 WHERE \"deleted_at\" IS NULL"; expect($dml)->toBe($expected); expect($params)->toBe(['2024-12-30']); @@ -138,8 +138,8 @@ [$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"; + $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]); @@ -155,7 +155,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET name = $1, email = $2 WHERE id = $3 RETURNING id, name, email, updated_at"; + $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]); @@ -171,7 +171,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET status = $1, activated_at = $2 WHERE status IN ($3, $4) RETURNING *"; + $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']); @@ -186,7 +186,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE settings SET updated_at = $1 RETURNING id, key, value"; + $expected = "UPDATE \"settings\" SET \"updated_at\" = $1 RETURNING \"id\", \"key\", \"value\""; expect($dml)->toBe($expected); expect($params)->toBe(['2024-12-31']); @@ -206,9 +206,9 @@ [$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"; + $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']); @@ -224,7 +224,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE posts SET published_at = $1 WHERE id = $2 RETURNING id, title, published_at"; + $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 index 4087629f..b692fc1f 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/WhereClausesTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/WhereClausesTest.php @@ -5,10 +5,12 @@ use Phenix\Database\Constants\Driver; use Phenix\Database\Constants\Operator; use Phenix\Database\Constants\Order; -use Phenix\Database\Functions; use Phenix\Database\QueryGenerator; use Phenix\Database\Subquery; +use function Phenix\Database\max_of; +use function Phenix\Database\when_null; + it('generates query to select a record by column', function () { $query = new QueryGenerator(Driver::POSTGRESQL); @@ -20,7 +22,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users WHERE id = $1'); + expect($dml)->toBe('SELECT * FROM "users" WHERE "id" = $1'); expect($params)->toBe([1]); }); @@ -37,7 +39,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users WHERE username = $1 AND email = $2 AND document = $3'); + expect($dml)->toBe('SELECT * FROM "users" WHERE "username" = $1 AND "email" = $2 AND "document" = $3'); expect($params)->toBe(['john', 'john@mail.com', 123456]); }); @@ -55,7 +57,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE {$column} {$operator} $1"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"{$column}\" {$operator} $1"); expect($params)->toBe([$value]); })->with([ ['whereNotEqual', 'id', Operator::NOT_EQUAL->value, 1], @@ -75,7 +77,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT id, name, email FROM users WHERE id = $1'); + expect($dml)->toBe('SELECT "id", "name", "email" FROM "users" WHERE "id" = $1'); expect($params)->toBe([1]); }); @@ -89,7 +91,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE id {$operator} ($1, $2, $3)"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"id\" {$operator} ($1, $2, $3)"); expect($params)->toBe([1, 2, 3]); })->with([ ['whereIn', Operator::IN->value], @@ -111,8 +113,8 @@ $date = date('Y-m-d'); - $expected = "SELECT * FROM users WHERE id {$operator} " - . "(SELECT id FROM users WHERE created_at >= $1)"; + $expected = "SELECT * FROM \"users\" WHERE \"id\" {$operator} " + . "(SELECT \"id\" FROM \"users\" WHERE \"created_at\" >= $1)"; expect($dml)->toBe($expected); expect($params)->toBe([$date]); @@ -121,6 +123,27 @@ ['whereNotIn', Operator::NOT_IN->value], ]); +it('keeps postgres placeholder order across where and subquery params', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('status', 'active') + ->whereIn('id', function (Subquery $query) { + $query->select(['user_id']) + ->from('orders') + ->whereGreaterThan('total', 100); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = 'SELECT * FROM "users" WHERE "status" = $1 AND "id" IN ' + . '(SELECT "user_id" FROM "orders" WHERE "total" > $2)'; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', 100]); +}); + it('generates query to select null or not null columns', function (string $method, string $operator) { $query = new QueryGenerator(Driver::POSTGRESQL); @@ -130,7 +153,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE verified_at {$operator}"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"verified_at\" {$operator}"); expect($params)->toBe([]); })->with([ ['whereNull', Operator::IS_NULL->value], @@ -149,7 +172,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE created_at > $1 OR verified_at {$operator}"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"created_at\" > $1 OR \"verified_at\" {$operator}"); expect($params)->toBe([$date]); })->with([ ['orWhereNull', Operator::IS_NULL->value], @@ -165,7 +188,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE enabled {$operator}"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"enabled\" {$operator}"); expect($params)->toBe([]); })->with([ ['whereTrue', Operator::IS_TRUE->value], @@ -184,7 +207,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE created_at > $1 OR enabled {$operator}"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"created_at\" > $1 OR \"enabled\" {$operator}"); expect($params)->toBe([$date]); })->with([ ['orWhereTrue', Operator::IS_TRUE->value], @@ -206,7 +229,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE verified_at IS NOT NULL AND created_at > $1 OR updated_at < $2"); + 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]); }); @@ -225,7 +248,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE created_at > $1 OR updated_at < $2 AND verified_at IS NOT NULL"); + 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]); }); @@ -257,7 +280,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE verified_at IS NOT NULL OR {$column} {$operator} {$placeholders}"); + 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], @@ -280,7 +303,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE age {$operator} $1 AND $2"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"age\" {$operator} $1 AND $2"); expect($params)->toBe([20, 30]); })->with([ ['whereBetween', Operator::BETWEEN->value], @@ -301,7 +324,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE created_at > $1 OR updated_at {$operator} $2 AND $3"); + 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], @@ -319,9 +342,12 @@ $operator = Operator::ORDER_BY->value; - $column = implode(', ', (array) $column); + $column = implode(', ', array_map( + fn (string $column): string => '"' . str_replace('.', '"."', $column) . '"', + (array) $column + )); - expect($dml)->toBe("SELECT * FROM users {$operator} {$column} {$order}"); + expect($dml)->toBe("SELECT * FROM \"users\" {$operator} {$column} {$order}"); expect($params)->toBe($params); })->with([ ['id', Order::ASC->value], @@ -331,8 +357,7 @@ ]); it('generates a column-ordered query using select-case', function () { - $case = Functions::case() - ->whenNull('city', 'country') + $case = when_null('city', 'country') ->defaultResult('city'); $query = new QueryGenerator(Driver::POSTGRESQL); @@ -343,7 +368,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users ORDER BY (CASE WHEN city IS NULL THEN country ELSE city END) ASC"); + expect($dml)->toBe("SELECT * FROM \"users\" ORDER BY (CASE WHEN \"city\" IS NULL THEN 'country' ELSE 'city' END) ASC"); expect($params)->toBe($params); }); @@ -360,9 +385,12 @@ $operator = Operator::ORDER_BY->value; - $column = implode(', ', (array) $column); + $column = implode(', ', array_map( + fn (string $column): string => '"' . str_replace('.', '"."', $column) . '"', + (array) $column + )); - expect($dml)->toBe("SELECT * FROM users WHERE id = $1 {$operator} {$column} {$order} LIMIT 1"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"id\" = $1 {$operator} {$column} {$order} LIMIT 1"); expect($params)->toBe([1]); })->with([ ['id', Order::ASC->value], @@ -385,8 +413,8 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM users WHERE {$operator} " - . "(SELECT * FROM user_role WHERE user_id = $1 AND role_id = $2 LIMIT 1)"; + $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]); @@ -412,8 +440,8 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM users WHERE is_admin IS TRUE OR {$operator} " - . "(SELECT * FROM user_role WHERE user_id = $1 LIMIT 1)"; + $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]); @@ -431,14 +459,14 @@ $sql = $query->table('products') ->{$method}($column, function (Subquery $subquery) { - $subquery->select([Functions::max('price')])->from('products'); + $subquery->select([max_of('price')])->from('products'); }) ->get(); [$dml, $params] = $sql; - $expected = "SELECT * FROM products WHERE {$column} {$operator} " - . '(SELECT ' . Functions::max('price') . ' FROM products)'; + $expected = "SELECT * FROM \"products\" WHERE \"{$column}\" {$operator} " + . '(SELECT MAX("price") FROM "products")'; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -469,8 +497,8 @@ [$dml, $params] = $sql; - $expected = "SELECT description FROM products WHERE id {$comparisonOperator} {$operator}" - . "(SELECT product_id FROM orders WHERE quantity > $1)"; + $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]); @@ -502,7 +530,7 @@ $sql = $query->table('employees') ->{$method}(['manager_id', 'department_id'], function (Subquery $subquery) { - $subquery->select(['id, department_id']) + $subquery->select(['id', 'department_id']) ->from('managers') ->whereEqual('location_id', 1); }) @@ -511,10 +539,10 @@ [$dml, $params] = $sql; - $subquery = 'SELECT id, department_id FROM managers WHERE location_id = $1'; + $subquery = 'SELECT "id", "department_id" FROM "managers" WHERE "location_id" = $1'; - $expected = "SELECT name FROM employees " - . "WHERE ROW(manager_id, department_id) {$operator} ({$subquery})"; + $expected = "SELECT \"name\" FROM \"employees\" " + . "WHERE ROW(\"manager_id\", \"department_id\") {$operator} ({$subquery})"; expect($dml)->toBe($expected); expect($params)->toBe([1]); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/WhereDateClausesTest.php b/tests/Unit/Database/QueryGenerator/Postgres/WhereDateClausesTest.php index 077fd006..42028af7 100644 --- a/tests/Unit/Database/QueryGenerator/Postgres/WhereDateClausesTest.php +++ b/tests/Unit/Database/QueryGenerator/Postgres/WhereDateClausesTest.php @@ -24,7 +24,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE DATE(created_at) {$operator} $1"); + 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], @@ -52,7 +52,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR DATE(created_at) {$operator} $1"); + 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], @@ -78,7 +78,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE MONTH(created_at) {$operator} $1"); + 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], @@ -106,7 +106,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR MONTH(created_at) {$operator} $1"); + 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], @@ -133,7 +133,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE YEAR(created_at) {$operator} $1"); + 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], @@ -161,7 +161,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR YEAR(created_at) {$operator} $1"); + 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], diff --git a/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php b/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php index 541b5047..a42c9ea4 100644 --- a/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php +++ b/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php @@ -4,15 +4,18 @@ use Phenix\Database\Alias; use Phenix\Database\Constants\Driver; -use Phenix\Database\Constants\Lock; use Phenix\Database\Constants\Operator; use Phenix\Database\Exceptions\QueryErrorException; -use Phenix\Database\Functions; +use Phenix\Database\QueryAst; use Phenix\Database\QueryGenerator; use Phenix\Database\Subquery; -use Phenix\Database\Value; -it('generates query to select all columns of table', function () { +use function Phenix\Database\avg; +use function Phenix\Database\subquery; +use function Phenix\Database\when_gte; +use function Phenix\Database\when_null; + +it('generates query to select all columns of table', function (): void { $query = new QueryGenerator(); $sql = $query->table('users') @@ -22,11 +25,11 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users'); + expect($dml)->toBe('SELECT * FROM `users`'); expect($params)->toBeEmpty(); }); -it('generates query to select all columns from table', function () { +it('generates query to select all columns from table', function (): void { $query = new QueryGenerator(); $sql = $query->from('users') @@ -36,27 +39,97 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users'); + expect($dml)->toBe('SELECT * FROM `users`'); expect($params)->toBeEmpty(); }); +it('keeps query ast synchronized as primary query state', function (): void { + $query = new class () extends QueryGenerator { + public function ast(): QueryAst + { + return $this->getAst(); + } + }; + + $query->setDriver(Driver::POSTGRESQL) + ->table('users') + ->select(['id']) + ->whereEqual('id', 1); + + $ast = $query->ast(); + [$dml, $params] = $query->get(); + + expect($ast->driver)->toBe(Driver::POSTGRESQL); + expect($ast->table)->toBe('users'); + expect($ast->columns)->toBe(['id']); + expect($ast->params)->toBeEmpty(); + expect($dml)->toBe('SELECT "id" FROM "users" WHERE "id" = $1'); + expect($params)->toBe([1]); +}); + +it('does not leak cached dialect compiler state across compilations', function (): void { + $first = (new QueryGenerator()) + ->table('users') + ->whereEqual('id', 1) + ->get(); + + $second = (new QueryGenerator()) + ->table('posts') + ->whereEqual('slug', 'hello') + ->get(); + + expect($first)->toBe(['SELECT * FROM `users` WHERE `id` = ?', [1]]); + expect($second)->toBe(['SELECT * FROM `posts` WHERE `slug` = ?', ['hello']]); +}); + +it('keeps where and from subquery params on clauses until compile', function (): void { + $query = new class () extends QueryGenerator { + public function ast(): QueryAst + { + return $this->getAst(); + } + }; + + $query->select(['id']) + ->from(function (Subquery $subquery): void { + $subquery->from('users') + ->whereEqual('verified_at', '2026-01-15'); + }) + ->whereEqual('status', 'active') + ->whereBetween('age', [18, 65]) + ->whereDateEqual('created_at', '2026-01-30'); + + $ast = $query->ast(); + [$dml, $params] = $query->get(); + + $expected = "SELECT `id` FROM (SELECT * FROM `users` WHERE `verified_at` = ?) " + . "WHERE `status` = ? AND `age` BETWEEN ? AND ? AND DATE(`created_at`) = ?"; + + expect($ast->wheres)->toHaveCount(3); + expect($ast->table)->toBeInstanceOf(Subquery::class); + expect($ast->params)->toBeEmpty(); + expect($dml)->toBe($expected); + expect($params)->toBe(['2026-01-15', 'active', 18, 65, '2026-01-30']); +}); + it('generates a query using sql functions', function (string $function, string $column, string $rawFunction) { $query = new QueryGenerator(); + $factory = "Phenix\\Database\\{$function}"; $sql = $query->table('products') - ->select([Functions::{$function}($column)]) + ->select([$factory($column)]) ->get(); [$dml, $params] = $sql; - expect($dml)->toBe("SELECT {$rawFunction} FROM products"); + 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)'], + ['avg', 'price', 'AVG(`price`)'], + ['sum', 'price', 'SUM(`price`)'], + ['min_of', 'price', 'MIN(`price`)'], + ['max_of', 'price', 'MAX(`price`)'], + ['count_of', 'id', 'COUNT(`id`)'], ]); it('generates a query using sql functions with alias', function ( @@ -66,21 +139,22 @@ string $rawFunction ) { $query = new QueryGenerator(); + $factory = "Phenix\\Database\\{$function}"; $sql = $query->table('products') - ->select([Functions::{$function}($column)->as($alias)]) + ->select([$factory($column)->as($alias)]) ->get(); [$dml, $params] = $sql; - expect($dml)->toBe("SELECT {$rawFunction} FROM products"); + 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'], + ['avg', 'price', 'value', 'AVG(`price`) AS `value`'], + ['sum', 'price', 'value', 'SUM(`price`) AS `value`'], + ['min_of', 'price', 'value', 'MIN(`price`) AS `value`'], + ['max_of', 'price', 'value', 'MAX(`price`) AS `value`'], + ['count_of', 'id', 'value', 'COUNT(`id`) AS `value`'], ]); it('selects field from subquery', function () { @@ -96,12 +170,22 @@ [$dml, $params] = $sql; - $expected = "SELECT id, name, email FROM (SELECT * FROM users WHERE verified_at = ?)"; + $expected = "SELECT `id`, `name`, `email` FROM (SELECT * FROM `users` WHERE `verified_at` = ?)"; expect($dml)->toBe($expected); expect($params)->toBe([$date]); }); +it('initializes subquery helper selecting all columns by default', function () { + $sql = subquery() + ->from('countries') + ->toSql(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('(SELECT * FROM `countries`)'); + expect($params)->toBeEmpty(); +}); it('generates query using subqueries in column selection', function () { $query = new QueryGenerator(); @@ -109,7 +193,7 @@ $sql = $query->select([ 'id', 'name', - Subquery::make()->select(['name']) + subquery(['name']) ->from('countries') ->whereColumn('users.country_id', 'countries.id') ->as('country_name') @@ -120,8 +204,8 @@ [$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"; + $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(); @@ -134,7 +218,7 @@ $query->select([ 'id', 'name', - Subquery::make()->select(['name']) + subquery(['name']) ->from('countries') ->whereColumn('users.country_id', 'countries.id') ->as('country_name'), @@ -156,7 +240,7 @@ [$dml, $params] = $sql; - $expected = "SELECT id, name AS full_name FROM users"; + $expected = "SELECT `id`, `name` AS `full_name` FROM `users`"; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -174,26 +258,25 @@ [$dml, $params] = $sql; - $expected = "SELECT id AS model_id, name AS full_name FROM users"; + $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, + string $function, array $data, string $defaultResult, string $operator ) { [$column, $value, $result] = $data; - $value = Value::from($value); - $query = new QueryGenerator(); - $case = Functions::case() - ->{$method}($column, $value, $result) + $factory = "Phenix\\Database\\{$function}"; + + $case = $factory($column, $value, $result) ->defaultResult($defaultResult) ->as('type'); @@ -207,22 +290,22 @@ [$dml, $params] = $sql; - $expected = "SELECT id, description, (CASE WHEN {$column} {$operator} {$value} " - . "THEN {$result} ELSE $defaultResult END) AS type FROM products"; + $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], + ['when_equal', ['price', 100, 'expensive'], 'cheap', Operator::EQUAL->value], + ['when_not_equal', ['price', 100, 'expensive'], 'cheap', Operator::NOT_EQUAL->value], + ['when_gt', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN->value], + ['when_gte', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN_OR_EQUAL->value], + ['when_lt', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN->value], + ['when_lte', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN_OR_EQUAL->value], ]); it('generates query with select-cases using logical comparisons', function ( - string $method, + string $function, array $data, string $defaultResult, string $operator @@ -231,8 +314,9 @@ $query = new QueryGenerator(); - $case = Functions::case() - ->{$method}(...$data) + $factory = "Phenix\\Database\\{$function}"; + + $case = $factory(...$data) ->defaultResult($defaultResult) ->as('status'); @@ -246,16 +330,16 @@ [$dml, $params] = $sql; - $expected = "SELECT id, name, (CASE WHEN {$column} {$operator} " - . "THEN {$result} ELSE $defaultResult END) AS status FROM users"; + $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], + ['when_null', ['created_at', 'inactive'], 'active', Operator::IS_NULL->value], + ['when_not_null', ['created_at', 'active'], 'inactive', Operator::IS_NOT_NULL->value], + ['when_true', ['is_verified', 'active'], 'inactive', Operator::IS_TRUE->value], + ['when_false', ['is_verified', 'inactive'], 'active', Operator::IS_FALSE->value], ]); it('generates query with select-cases with multiple conditions and string values', function () { @@ -263,10 +347,9 @@ $query = new QueryGenerator(); - $case = Functions::case() - ->whenNull('created_at', Value::from('inactive')) - ->whenGreaterThan('created_at', Value::from($date), Value::from('new user')) - ->defaultResult(Value::from('old user')) + $case = when_null('created_at', 'inactive') + ->whenGreaterThan('created_at', $date, 'new user') + ->defaultResult('old user') ->as('status'); $sql = $query->select([ @@ -279,8 +362,8 @@ [$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"; + $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(); @@ -291,9 +374,8 @@ $query = new QueryGenerator(); - $case = Functions::case() - ->whenNull('created_at', Value::from('inactive')) - ->whenGreaterThan('created_at', Value::from($date), Value::from('new user')) + $case = when_null('created_at', 'inactive') + ->whenGreaterThan('created_at', $date, 'new user') ->as('status'); $sql = $query->select([ @@ -306,8 +388,8 @@ [$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"; + $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(); @@ -316,9 +398,8 @@ it('generates query with select-case using functions', function () { $query = new QueryGenerator(); - $case = Functions::case() - ->whenGreaterThanOrEqual(Functions::avg('price'), 4, Value::from('expensive')) - ->defaultResult(Value::from('cheap')) + $case = when_gte(avg('price'), 4, 'expensive') + ->defaultResult('cheap') ->as('message'); $sql = $query->select([ @@ -332,8 +413,8 @@ [$dml, $params] = $sql; - $expected = "SELECT id, description, price, (CASE WHEN AVG(price) >= 4 THEN 'expensive' ELSE 'cheap' END) " - . "AS message FROM products"; + $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(); @@ -346,7 +427,7 @@ [$dml, $params] = $sql; - $expected = "SELECT COUNT(*) FROM products"; + $expected = "SELECT COUNT(*) FROM `products`"; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -362,7 +443,7 @@ [$dml, $params] = $sql; $expected = "SELECT EXISTS" - . " (SELECT 1 FROM products WHERE id = ?) AS 'exists'"; + . " (SELECT 1 FROM `products` WHERE `id` = ?) AS `exists`"; expect($dml)->toBe($expected); expect($params)->toBe([1]); @@ -378,7 +459,7 @@ [$dml, $params] = $sql; $expected = "SELECT NOT EXISTS" - . " (SELECT 1 FROM products WHERE id = ?) AS 'exists'"; + . " (SELECT 1 FROM `products` WHERE `id` = ?) AS `exists`"; expect($dml)->toBe($expected); expect($params)->toBe([1]); @@ -393,7 +474,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM products WHERE id = ? LIMIT 1"; + $expected = "SELECT * FROM `products` WHERE `id` = ? LIMIT 1"; expect($dml)->toBe($expected); expect($params)->toBe([1]); @@ -408,7 +489,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users'); + expect($dml)->toBe('SELECT * FROM `users`'); expect($params)->toBeEmpty(); }); @@ -422,7 +503,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR UPDATE"; + $expected = "SELECT * FROM `tasks` WHERE `reserved_at` IS NULL FOR UPDATE"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -438,7 +519,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR SHARE"; + $expected = "SELECT * FROM `tasks` WHERE `reserved_at` IS NULL FOR SHARE"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -454,237 +535,7 @@ [$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 for postgresql', 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 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 for postgresql', 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 skip locked for postgresql 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 share for postgresql', 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 no key update for postgresql', 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 for postgresql', 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 for postgresql', 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 for postgresql', 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 for postgresql', 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 for postgresql', 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([]); -}); - -it('tries to generate lock using sqlite', function () { - $query = new QueryGenerator(Driver::SQLITE); - - expect($query->getDriver())->toBe(Driver::SQLITE); - - $sql = $query->from('tasks') - ->whereNull('reserved_at') - ->lockForNoKeyUpdateNoWait() - ->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', function () { - $query = new QueryGenerator(Driver::SQLITE); - - expect($query->getDriver())->toBe(Driver::SQLITE); - - $sql = $query->from('tasks') - ->whereNull('reserved_at') - ->lock(Lock::FOR_NO_KEY_UPDATE) - ->get(); - - [$dml, $params] = $sql; - - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + $expected = "SELECT * FROM `tasks` WHERE `reserved_at` IS NULL FOR UPDATE SKIP LOCKED"; expect($dml)->toBe($expected); expect($params)->toBe([]); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php index 7eedbb1c..79e7b113 100644 --- a/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php @@ -14,7 +14,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE id = ?"; + $expected = "DELETE FROM \"users\" WHERE \"id\" = ?"; expect($dml)->toBe($expected); expect($params)->toBe([1]); @@ -28,7 +28,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users"; + $expected = "DELETE FROM \"users\""; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -44,7 +44,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE status = ? AND role = ?"; + $expected = "DELETE FROM \"users\" WHERE \"status\" = ? AND \"role\" = ?"; expect($dml)->toBe($expected); expect($params)->toBe(['inactive', 'user']); @@ -59,7 +59,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE id IN (?, ?, ?)"; + $expected = "DELETE FROM \"users\" WHERE \"id\" IN (?, ?, ?)"; expect($dml)->toBe($expected); expect($params)->toBe([1, 2, 3]); @@ -74,7 +74,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE status != ?"; + $expected = "DELETE FROM \"users\" WHERE \"status\" != ?"; expect($dml)->toBe($expected); expect($params)->toBe(['active']); @@ -89,7 +89,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE age > ?"; + $expected = "DELETE FROM \"users\" WHERE \"age\" > ?"; expect($dml)->toBe($expected); expect($params)->toBe([18]); @@ -104,7 +104,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE age < ?"; + $expected = "DELETE FROM \"users\" WHERE \"age\" < ?"; expect($dml)->toBe($expected); expect($params)->toBe([65]); @@ -119,7 +119,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE deleted_at IS NULL"; + $expected = "DELETE FROM \"users\" WHERE \"deleted_at\" IS NULL"; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -134,7 +134,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE email IS NOT NULL"; + $expected = "DELETE FROM \"users\" WHERE \"email\" IS NOT NULL"; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -150,7 +150,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE id = ? RETURNING id, name, email"; + $expected = "DELETE FROM \"users\" WHERE \"id\" = ? RETURNING \"id\", \"name\", \"email\""; expect($dml)->toBe($expected); expect($params)->toBe([1]); @@ -166,7 +166,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE status IN (?, ?) RETURNING *"; + $expected = "DELETE FROM \"users\" WHERE \"status\" IN (?, ?) RETURNING *"; expect($dml)->toBe($expected); expect($params)->toBe(['inactive', 'deleted']); @@ -181,7 +181,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users RETURNING id, email"; + $expected = "DELETE FROM \"users\" RETURNING \"id\", \"email\""; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -198,7 +198,7 @@ [$dml, $params] = $sql; - $expected = "DELETE FROM users WHERE status = ? AND age > ? RETURNING id, name, status, age"; + $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 index 784a5bb5..e13ee4d0 100644 --- a/tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php @@ -3,12 +3,16 @@ declare(strict_types=1); use Phenix\Database\Constants\Driver; -use Phenix\Database\Functions; +use Phenix\Database\Funct; use Phenix\Database\Having; use Phenix\Database\Join; use Phenix\Database\QueryGenerator; -it('generates a grouped query', function (Functions|string $column, Functions|array|string $groupBy, string $rawGroup): void { +use function Phenix\Database\avg; +use function Phenix\Database\count_of; +use function Phenix\Database\sum; + +it('generates a grouped query', function (Funct|string $column, Funct|array|string $groupBy, string $rawGroup, string $rawColumn): void { $query = new QueryGenerator(Driver::SQLITE); $sql = $query->select([ @@ -25,23 +29,24 @@ [$dml, $params] = $sql; - $expected = "SELECT {$column}, products.category_id, categories.description " - . "FROM products " - . "LEFT JOIN categories ON products.category_id = categories.id " + $expected = "SELECT {$rawColumn}, \"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)'], + [count_of('products.id'), 'category_id', '"category_id"', 'COUNT("products"."id")'], + ['location_id', ['category_id', 'location_id'], '"category_id", "location_id"', '"location_id"'], + [count_of('products.id'), count_of('products.id'), 'COUNT("products"."id")', 'COUNT("products"."id")'], ]); it('generates a grouped and ordered query', function ( - Functions|string $column, - Functions|array|string $groupBy, - string $rawGroup + Funct|string $column, + Funct|array|string $groupBy, + string $rawGroup, + string $rawColumn ): void { $query = new QueryGenerator(Driver::SQLITE); @@ -60,25 +65,25 @@ [$dml, $params] = $sql; - $expected = "SELECT {$column}, products.category_id, categories.description " - . "FROM products " - . "LEFT JOIN categories ON products.category_id = categories.id " + $expected = "SELECT {$rawColumn}, \"products\".\"category_id\", \"categories\".\"description\" " + . "FROM \"products\" " + . "LEFT JOIN \"categories\" ON \"products\".\"category_id\" = \"categories\".\"id\" " . "GROUP BY {$rawGroup} " - . "ORDER BY products.id DESC"; + . "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)'], + [count_of('products.id'), 'category_id', '"category_id"', 'COUNT("products"."id")'], + ['location_id', ['category_id', 'location_id'], '"category_id", "location_id"', '"location_id"'], + [count_of('products.id'), count_of('products.id'), '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'), + count_of('products.id'), 'products.category_id', ]) ->from('products') @@ -88,10 +93,10 @@ [$dml, $params] = $sql; - $expected = "SELECT COUNT(products.id), products.category_id " - . "FROM products " - . "WHERE products.status = ? " - . "GROUP BY category_id"; + $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']); @@ -101,7 +106,7 @@ $query = new QueryGenerator(Driver::SQLITE); $sql = $query->select([ - Functions::count('products.id')->as('product_count'), + count_of('products.id')->as('product_count'), 'products.category_id', ]) ->from('products') @@ -113,10 +118,10 @@ [$dml, $params] = $sql; - $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " - . "FROM products " - . "HAVING product_count > ? " - . "GROUP BY category_id"; + $expected = "SELECT COUNT(\"products\".\"id\") AS \"product_count\", \"products\".\"category_id\" " + . "FROM \"products\" " + . "GROUP BY \"category_id\" " + . "HAVING \"product_count\" > ?"; expect($dml)->toBe($expected); expect($params)->toBe([5]); @@ -126,9 +131,9 @@ $query = new QueryGenerator(Driver::SQLITE); $sql = $query->select([ - Functions::count('products.id'), - Functions::sum('products.price'), - Functions::avg('products.price'), + count_of('products.id'), + sum('products.price'), + avg('products.price'), 'products.category_id', ]) ->from('products') @@ -137,9 +142,9 @@ [$dml, $params] = $sql; - $expected = "SELECT COUNT(products.id), SUM(products.price), AVG(products.price), products.category_id " - . "FROM products " - . "GROUP BY category_id"; + $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 index d026ea28..12e2aad4 100644 --- a/tests/Unit/Database/QueryGenerator/Sqlite/HavingClauseTest.php +++ b/tests/Unit/Database/QueryGenerator/Sqlite/HavingClauseTest.php @@ -3,16 +3,18 @@ declare(strict_types=1); use Phenix\Database\Constants\Driver; -use Phenix\Database\Functions; use Phenix\Database\Having; use Phenix\Database\Join; use Phenix\Database\QueryGenerator; +use function Phenix\Database\count_of; +use function Phenix\Database\sum; + it('generates a query using having clause', function () { $query = new QueryGenerator(Driver::SQLITE); $sql = $query->select([ - Functions::count('products.id')->as('identifiers'), + count_of('products.id')->as('identifiers'), 'products.category_id', 'categories.description', ]) @@ -28,10 +30,10 @@ [$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"; + $expected = "SELECT COUNT(\"products\".\"id\") AS \"identifiers\", \"products\".\"category_id\", \"categories\".\"description\" " + . "FROM \"products\" " + . "LEFT JOIN \"categories\" ON \"products\".\"category_id\" = \"categories\".\"id\" " + . "GROUP BY \"products\".\"category_id\" HAVING \"identifiers\" > ?"; expect($dml)->toBe($expected); expect($params)->toBe([5]); @@ -41,7 +43,7 @@ $query = new QueryGenerator(Driver::SQLITE); $sql = $query->select([ - Functions::count('products.id')->as('identifiers'), + count_of('products.id')->as('identifiers'), 'products.category_id', 'categories.description', ]) @@ -58,10 +60,10 @@ [$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"; + $expected = "SELECT COUNT(\"products\".\"id\") AS \"identifiers\", \"products\".\"category_id\", \"categories\".\"description\" " + . "FROM \"products\" " + . "LEFT JOIN \"categories\" ON \"products\".\"category_id\" = \"categories\".\"id\" " + . "GROUP BY \"products\".\"category_id\" HAVING \"identifiers\" > ? AND \"products\".\"category_id\" > ?"; expect($dml)->toBe($expected); expect($params)->toBe([5, 10]); @@ -71,7 +73,7 @@ $query = new QueryGenerator(Driver::SQLITE); $sql = $query->select([ - Functions::count('products.id')->as('product_count'), + count_of('products.id')->as('product_count'), 'products.category_id', ]) ->from('products') @@ -84,10 +86,10 @@ [$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"; + $expected = "SELECT COUNT(\"products\".\"id\") AS \"product_count\", \"products\".\"category_id\" " + . "FROM \"products\" " + . "WHERE \"products\".\"status\" = ? " + . "GROUP BY \"products\".\"category_id\" HAVING \"product_count\" > ?"; expect($dml)->toBe($expected); expect($params)->toBe(['active', 3]); @@ -97,7 +99,7 @@ $query = new QueryGenerator(Driver::SQLITE); $sql = $query->select([ - Functions::sum('orders.total')->as('total_sales'), + sum('orders.total')->as('total_sales'), 'orders.customer_id', ]) ->from('orders') @@ -109,9 +111,9 @@ [$dml, $params] = $sql; - $expected = "SELECT SUM(orders.total) AS total_sales, orders.customer_id " - . "FROM orders " - . "HAVING total_sales < ? GROUP BY orders.customer_id"; + $expected = "SELECT SUM(\"orders\".\"total\") AS \"total_sales\", \"orders\".\"customer_id\" " + . "FROM \"orders\" " + . "GROUP BY \"orders\".\"customer_id\" HAVING \"total_sales\" < ?"; expect($dml)->toBe($expected); expect($params)->toBe([1000]); @@ -121,7 +123,7 @@ $query = new QueryGenerator(Driver::SQLITE); $sql = $query->select([ - Functions::count('products.id')->as('product_count'), + count_of('products.id')->as('product_count'), 'products.category_id', ]) ->from('products') @@ -133,10 +135,34 @@ [$dml, $params] = $sql; - $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " - . "FROM products " - . "HAVING product_count = ? GROUP BY products.category_id"; + $expected = "SELECT COUNT(\"products\".\"id\") AS \"product_count\", \"products\".\"category_id\" " + . "FROM \"products\" " + . "GROUP BY \"products\".\"category_id\" HAVING \"product_count\" = ?"; expect($dml)->toBe($expected); expect($params)->toBe([10]); }); + +it('generates a query using having with date clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + count_of('products.id')->as('product_count'), + 'products.created_at', + ]) + ->from('products') + ->groupBy('products.created_at') + ->having(function (Having $having): void { + $having->whereDateEqual('products.created_at', '2026-01-15'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(\"products\".\"id\") AS \"product_count\", \"products\".\"created_at\" " + . "FROM \"products\" " + . "GROUP BY \"products\".\"created_at\" HAVING DATE(\"products\".\"created_at\") = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2026-01-15']); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/InsertIntoStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/InsertIntoStatementTest.php index e319b049..4d2ef0a4 100644 --- a/tests/Unit/Database/QueryGenerator/Sqlite/InsertIntoStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/Sqlite/InsertIntoStatementTest.php @@ -22,7 +22,7 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name) VALUES (?, ?)"; + $expected = "INSERT INTO \"users\" (\"email\", \"name\") VALUES (?, ?)"; expect($dml)->toBe($expected); expect($params)->toBe([$email, $name]); @@ -48,7 +48,7 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name) VALUES (?, ?), (?, ?)"; + $expected = "INSERT INTO \"users\" (\"email\", \"name\") VALUES (?, ?), (?, ?)"; expect($dml)->toBe($expected); expect($params)->toBe([$email, $name, $email, $name]); @@ -68,7 +68,7 @@ [$dml, $params] = $sql; - $expected = "INSERT OR IGNORE INTO users (email, name) VALUES (?, ?)"; + $expected = "INSERT OR IGNORE INTO \"users\" (\"email\", \"name\") VALUES (?, ?)"; expect($dml)->toBe($expected); expect($params)->toBe([$email, $name]); @@ -89,7 +89,7 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name) VALUES (?, ?) RETURNING id"; + $expected = "INSERT INTO \"users\" (\"email\", \"name\") VALUES (?, ?) RETURNING \"id\""; expect($dml)->toBe($expected); expect($params)->toBe([$email, $name]); @@ -110,7 +110,7 @@ [$dml, $params] = $sql; - $expected = "INSERT OR IGNORE INTO users (email, name) VALUES (?, ?) RETURNING id, email"; + $expected = "INSERT OR IGNORE INTO \"users\" (\"email\", \"name\") VALUES (?, ?) RETURNING \"id\", \"email\""; expect($dml)->toBe($expected); expect($params)->toBe([$email, $name]); @@ -130,8 +130,8 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name) VALUES (?, ?) " - . "ON CONFLICT (name) DO UPDATE SET name = excluded.name"; + $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]); @@ -151,8 +151,8 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (email, name, username) VALUES (?, ?, ?) " - . "ON CONFLICT (name, username) DO UPDATE SET name = excluded.name, username = excluded.username"; + $expected = "INSERT INTO \"users\" (\"email\", \"name\", \"username\") VALUES (?, ?, ?) " + . "ON CONFLICT (\"name\", \"username\") DO UPDATE SET \"name\" = excluded.\"name\", \"username\" = excluded.\"username\""; \ksort($data); @@ -172,7 +172,7 @@ [$dml, $params] = $sql; - $expected = "INSERT INTO users (name, email) SELECT name, email FROM customers WHERE verified_at IS NOT NULL"; + $expected = "INSERT INTO \"users\" (\"name\", \"email\") SELECT \"name\", \"email\" FROM \"customers\" WHERE \"verified_at\" IS NOT NULL"; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -190,8 +190,8 @@ [$dml, $params] = $sql; - $expected = "INSERT OR IGNORE INTO users (name, email) " - . "SELECT name, email FROM customers WHERE verified_at IS NOT NULL"; + $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 index eab97eb1..85e5d27e 100644 --- a/tests/Unit/Database/QueryGenerator/Sqlite/JoinClausesTest.php +++ b/tests/Unit/Database/QueryGenerator/Sqlite/JoinClausesTest.php @@ -23,10 +23,10 @@ [$dml, $params] = $sql; - $expected = "SELECT products.id, products.description, categories.description " - . "FROM products " - . "{$joinType} categories " - . "ON products.category_id = categories.id"; + $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(); @@ -55,10 +55,10 @@ [$dml, $params] = $sql; - $expected = "SELECT products.id, products.description, categories.description " - . "FROM products " - . "INNER JOIN categories " - . "ON products.category_id != categories.id"; + $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(); @@ -86,10 +86,10 @@ [$dml, $params] = $sql; - $expected = "SELECT products.id, products.description, categories.description " - . "FROM products " - . "INNER JOIN categories " - . "ON products.category_id = categories.id {$clause}"; + $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); @@ -97,25 +97,25 @@ [ 'orOnEqual', ['products.location_id', 'categories.location_id'], - 'OR products.location_id = categories.location_id', + 'OR "products"."location_id" = "categories"."location_id"', [], ], [ 'whereEqual', ['categories.name', 'php'], - 'AND categories.name = ?', + 'AND "categories"."name" = ?', ['php'], ], [ 'orOnNotEqual', ['products.location_id', 'categories.location_id'], - 'OR products.location_id != categories.location_id', + 'OR "products"."location_id" != "categories"."location_id"', [], ], [ 'orWhereEqual', ['categories.name', 'php'], - 'OR categories.name = ?', + 'OR "categories"."name" = ?', ['php'], ], ]); @@ -134,10 +134,10 @@ [$dml, $params] = $sql; - $expected = "SELECT products.id, products.description, categories.description " - . "FROM products " - . "{$joinType} categories " - . "ON products.category_id = categories.id"; + $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(); @@ -147,6 +147,31 @@ ['rightJoinOnEqual', JoinType::RIGHT->value], ]); +it('generates query with join date clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'products.id', + 'categories.description', + ]) + ->from('products') + ->innerJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id') + ->whereDateEqual('categories.created_at', '2026-01-15'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT \"products\".\"id\", \"categories\".\"description\" " + . "FROM \"products\" " + . "INNER JOIN \"categories\" " + . "ON \"products\".\"category_id\" = \"categories\".\"id\" AND DATE(\"categories\".\"created_at\") = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2026-01-15']); +}); + it('generates query with multiple joins', function () { $query = new QueryGenerator(Driver::SQLITE); @@ -166,10 +191,10 @@ [$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"; + $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(); @@ -191,10 +216,10 @@ [$dml, $params] = $sql; - $expected = "SELECT products.id, categories.name " - . "FROM products " - . "LEFT JOIN categories ON products.category_id = categories.id " - . "WHERE products.status = ?"; + $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 index 0b67a705..d7c2b1c6 100644 --- a/tests/Unit/Database/QueryGenerator/Sqlite/PaginateTest.php +++ b/tests/Unit/Database/QueryGenerator/Sqlite/PaginateTest.php @@ -15,7 +15,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 0'); + expect($dml)->toBe('SELECT * FROM "users" LIMIT 15 OFFSET 0'); expect($params)->toBeEmpty(); }); @@ -28,7 +28,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 30'); + expect($dml)->toBe('SELECT * FROM "users" LIMIT 15 OFFSET 30'); expect($params)->toBeEmpty(); }); @@ -42,7 +42,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 15'); + expect($dml)->toBe('SELECT * FROM "users" LIMIT 15 OFFSET 15'); expect($params)->toBeEmpty(); }); @@ -56,7 +56,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users WHERE status = ? LIMIT 15 OFFSET 15'); + expect($dml)->toBe('SELECT * FROM "users" WHERE "status" = ? LIMIT 15 OFFSET 15'); expect($params)->toBe(['active']); }); @@ -70,7 +70,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users ORDER BY created_at ASC LIMIT 15 OFFSET 0'); + expect($dml)->toBe('SELECT * FROM "users" ORDER BY "created_at" ASC LIMIT 15 OFFSET 0'); expect($params)->toBeEmpty(); }); @@ -83,6 +83,6 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users LIMIT 25 OFFSET 25'); + 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 index 9f1cd124..9351d7e3 100644 --- a/tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php +++ b/tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php @@ -7,10 +7,13 @@ use Phenix\Database\Constants\Lock; use Phenix\Database\Constants\Operator; use Phenix\Database\Exceptions\QueryErrorException; -use Phenix\Database\Functions; use Phenix\Database\QueryGenerator; use Phenix\Database\Subquery; -use Phenix\Database\Value; + +use function Phenix\Database\avg; +use function Phenix\Database\subquery; +use function Phenix\Database\when_gte; +use function Phenix\Database\when_null; it('generates query to select all columns of table', function () { $query = new QueryGenerator(Driver::SQLITE); @@ -22,7 +25,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users'); + expect($dml)->toBe('SELECT * FROM "users"'); expect($params)->toBeEmpty(); }); @@ -36,27 +39,28 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users'); + 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); + $factory = "Phenix\\Database\\{$function}"; $sql = $query->table('products') - ->select([Functions::{$function}($column)]) + ->select([$factory($column)]) ->get(); [$dml, $params] = $sql; - expect($dml)->toBe("SELECT {$rawFunction} FROM products"); + 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)'], + ['avg', 'price', 'AVG("price")'], + ['sum', 'price', 'SUM("price")'], + ['min_of', 'price', 'MIN("price")'], + ['max_of', 'price', 'MAX("price")'], + ['count_of', 'id', 'COUNT("id")'], ]); it('generates a query using sql functions with alias', function ( @@ -66,21 +70,22 @@ string $rawFunction ) { $query = new QueryGenerator(Driver::SQLITE); + $factory = "Phenix\\Database\\{$function}"; $sql = $query->table('products') - ->select([Functions::{$function}($column)->as($alias)]) + ->select([$factory($column)->as($alias)]) ->get(); [$dml, $params] = $sql; - expect($dml)->toBe("SELECT {$rawFunction} FROM products"); + 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'], + ['avg', 'price', 'value', 'AVG("price") AS "value"'], + ['sum', 'price', 'value', 'SUM("price") AS "value"'], + ['min_of', 'price', 'value', 'MIN("price") AS "value"'], + ['max_of', 'price', 'value', 'MAX("price") AS "value"'], + ['count_of', 'id', 'value', 'COUNT("id") AS "value"'], ]); it('selects field from subquery', function () { @@ -96,7 +101,7 @@ [$dml, $params] = $sql; - $expected = "SELECT id, name, email FROM (SELECT * FROM users WHERE verified_at = ?)"; + $expected = "SELECT \"id\", \"name\", \"email\" FROM (SELECT * FROM \"users\" WHERE \"verified_at\" = ?)"; expect($dml)->toBe($expected); expect($params)->toBe([$date]); @@ -109,7 +114,7 @@ $sql = $query->select([ 'id', 'name', - Subquery::make(Driver::SQLITE)->select(['name']) + subquery(['name']) ->from('countries') ->whereColumn('users.country_id', 'countries.id') ->as('country_name') @@ -120,8 +125,8 @@ [$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"; + $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(); @@ -134,7 +139,7 @@ $query->select([ 'id', 'name', - Subquery::make(Driver::SQLITE)->select(['name']) + subquery(['name']) ->from('countries') ->whereColumn('users.country_id', 'countries.id') ->as('country_name'), @@ -156,7 +161,7 @@ [$dml, $params] = $sql; - $expected = "SELECT id, name AS full_name FROM users"; + $expected = "SELECT \"id\", \"name\" AS \"full_name\" FROM \"users\""; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -174,26 +179,25 @@ [$dml, $params] = $sql; - $expected = "SELECT id AS model_id, name AS full_name FROM users"; + $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, + string $function, 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) + $factory = "Phenix\\Database\\{$function}"; + + $case = $factory($column, $value, $result) ->defaultResult($defaultResult) ->as('type'); @@ -207,22 +211,22 @@ [$dml, $params] = $sql; - $expected = "SELECT id, description, (CASE WHEN {$column} {$operator} {$value} " - . "THEN {$result} ELSE $defaultResult END) AS type FROM products"; + $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], + ['when_equal', ['price', 100, 'expensive'], 'cheap', Operator::EQUAL->value], + ['when_not_equal', ['price', 100, 'expensive'], 'cheap', Operator::NOT_EQUAL->value], + ['when_gt', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN->value], + ['when_gte', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN_OR_EQUAL->value], + ['when_lt', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN->value], + ['when_lte', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN_OR_EQUAL->value], ]); it('generates query with select-cases using logical comparisons', function ( - string $method, + string $function, array $data, string $defaultResult, string $operator @@ -231,8 +235,9 @@ $query = new QueryGenerator(Driver::SQLITE); - $case = Functions::case() - ->{$method}(...$data) + $factory = "Phenix\\Database\\{$function}"; + + $case = $factory(...$data) ->defaultResult($defaultResult) ->as('status'); @@ -246,16 +251,16 @@ [$dml, $params] = $sql; - $expected = "SELECT id, name, (CASE WHEN {$column} {$operator} " - . "THEN {$result} ELSE $defaultResult END) AS status FROM users"; + $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], + ['when_null', ['created_at', 'inactive'], 'active', Operator::IS_NULL->value], + ['when_not_null', ['created_at', 'active'], 'inactive', Operator::IS_NOT_NULL->value], + ['when_true', ['is_verified', 'active'], 'inactive', Operator::IS_TRUE->value], + ['when_false', ['is_verified', 'inactive'], 'active', Operator::IS_FALSE->value], ]); it('generates query with select-cases with multiple conditions and string values', function () { @@ -263,10 +268,9 @@ $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')) + $case = when_null('created_at', 'inactive') + ->whenGreaterThan('created_at', $date, 'new user') + ->defaultResult('old user') ->as('status'); $sql = $query->select([ @@ -279,8 +283,8 @@ [$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"; + $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(); @@ -291,9 +295,8 @@ $query = new QueryGenerator(Driver::SQLITE); - $case = Functions::case() - ->whenNull('created_at', Value::from('inactive')) - ->whenGreaterThan('created_at', Value::from($date), Value::from('new user')) + $case = when_null('created_at', 'inactive') + ->whenGreaterThan('created_at', $date, 'new user') ->as('status'); $sql = $query->select([ @@ -306,8 +309,8 @@ [$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"; + $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(); @@ -316,9 +319,8 @@ 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')) + $case = when_gte(avg('price'), 4, 'expensive') + ->defaultResult('cheap') ->as('message'); $sql = $query->select([ @@ -332,8 +334,8 @@ [$dml, $params] = $sql; - $expected = "SELECT id, description, price, (CASE WHEN AVG(price) >= 4 THEN 'expensive' ELSE 'cheap' END) " - . "AS message FROM products"; + $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(); @@ -346,7 +348,7 @@ [$dml, $params] = $sql; - $expected = "SELECT COUNT(*) FROM products"; + $expected = "SELECT COUNT(*) FROM \"products\""; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -362,7 +364,7 @@ [$dml, $params] = $sql; $expected = "SELECT EXISTS" - . " (SELECT 1 FROM products WHERE id = ?) AS 'exists'"; + . " (SELECT 1 FROM \"products\" WHERE \"id\" = ?) AS \"exists\""; expect($dml)->toBe($expected); expect($params)->toBe([1]); @@ -378,7 +380,7 @@ [$dml, $params] = $sql; $expected = "SELECT NOT EXISTS" - . " (SELECT 1 FROM products WHERE id = ?) AS 'exists'"; + . " (SELECT 1 FROM \"products\" WHERE \"id\" = ?) AS \"exists\""; expect($dml)->toBe($expected); expect($params)->toBe([1]); @@ -393,7 +395,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM products WHERE id = ? LIMIT 1"; + $expected = "SELECT * FROM \"products\" WHERE \"id\" = ? LIMIT 1"; expect($dml)->toBe($expected); expect($params)->toBe([1]); @@ -408,7 +410,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users'); + expect($dml)->toBe('SELECT * FROM "users"'); expect($params)->toBeEmpty(); }); @@ -424,7 +426,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -440,7 +442,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -458,7 +460,7 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL"; expect($dml)->toBe($expected); expect($params)->toBe([]); @@ -476,7 +478,43 @@ [$dml, $params] = $builder->get(); - $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + $expected = "SELECT * FROM \"tasks\" WHERE \"reserved_at\" IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('tries to generate lock using sqlite', function () { + $query = new QueryGenerator(Driver::SQLITE); + + expect($query->getDriver())->toBe(Driver::SQLITE); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForNoKeyUpdateNoWait() + ->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', function () { + $query = new QueryGenerator(Driver::SQLITE); + + expect($query->getDriver())->toBe(Driver::SQLITE); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lock(Lock::FOR_NO_KEY_UPDATE) + ->get(); + + [$dml, $params] = $sql; + + $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 index c8f8f85c..0f345d82 100644 --- a/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php @@ -18,7 +18,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET name = ? WHERE id = ?"; + $expected = "UPDATE \"users\" SET \"name\" = ? WHERE \"id\" = ?"; expect($dml)->toBe($expected); expect($params)->toBe([$name, 1]); @@ -36,7 +36,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET name = ?, active = ? WHERE verified_at IS NOT NULL AND role_id = ?"; + $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]); @@ -51,7 +51,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET status = ? WHERE id = ?"; + $expected = "UPDATE \"users\" SET \"status\" = ? WHERE \"id\" = ?"; expect($dml)->toBe($expected); expect($params)->toBe(['inactive', 5]); @@ -66,7 +66,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET status = ? WHERE id IN (?, ?, ?)"; + $expected = "UPDATE \"users\" SET \"status\" = ? WHERE \"id\" IN (?, ?, ?)"; expect($dml)->toBe($expected); expect($params)->toBe(['active', 1, 2, 3]); @@ -84,7 +84,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET email = ?, verified = ? WHERE status = ? AND created_at > ?"; + $expected = "UPDATE \"users\" SET \"email\" = ?, \"verified\" = ? WHERE \"status\" = ? AND \"created_at\" > ?"; expect($dml)->toBe($expected); expect($params)->toBe([$email, true, 'pending', '2024-01-01']); @@ -99,7 +99,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET access_level = ? WHERE role != ?"; + $expected = "UPDATE \"users\" SET \"access_level\" = ? WHERE \"role\" != ?"; expect($dml)->toBe($expected); expect($params)->toBe([1, 'admin']); @@ -114,7 +114,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET last_login = ? WHERE deleted_at IS NULL"; + $expected = "UPDATE \"users\" SET \"last_login\" = ? WHERE \"deleted_at\" IS NULL"; expect($dml)->toBe($expected); expect($params)->toBe(['2024-12-30']); @@ -138,8 +138,8 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET name = ?, email = ?, updated_at = ? " - . "WHERE status = ? AND email_verified_at IS NOT NULL AND login_count < ?"; + $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]); @@ -155,7 +155,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET name = ?, email = ? WHERE id = ? RETURNING id, name, email, updated_at"; + $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]); @@ -171,7 +171,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET status = ?, activated_at = ? WHERE status IN (?, ?) RETURNING *"; + $expected = "UPDATE \"users\" SET \"status\" = ?, \"activated_at\" = ? WHERE \"status\" IN (?, ?) RETURNING *"; expect($dml)->toBe($expected); expect($params)->toBe(['active', '2024-12-31', 'pending', 'inactive']); @@ -186,7 +186,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE settings SET updated_at = ? RETURNING id, key, value"; + $expected = "UPDATE \"settings\" SET \"updated_at\" = ? RETURNING \"id\", \"key\", \"value\""; expect($dml)->toBe($expected); expect($params)->toBe(['2024-12-31']); @@ -206,9 +206,9 @@ [$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"; + $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']); @@ -224,7 +224,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE posts SET published_at = ? WHERE id = ? RETURNING id, title, published_at"; + $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 index b75fff0c..838a52f1 100644 --- a/tests/Unit/Database/QueryGenerator/Sqlite/WhereClausesTest.php +++ b/tests/Unit/Database/QueryGenerator/Sqlite/WhereClausesTest.php @@ -5,10 +5,12 @@ use Phenix\Database\Constants\Driver; use Phenix\Database\Constants\Operator; use Phenix\Database\Constants\Order; -use Phenix\Database\Functions; use Phenix\Database\QueryGenerator; use Phenix\Database\Subquery; +use function Phenix\Database\max_of; +use function Phenix\Database\when_null; + it('generates query to select a record by column', function () { $query = new QueryGenerator(Driver::SQLITE); @@ -20,7 +22,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users WHERE id = ?'); + expect($dml)->toBe('SELECT * FROM "users" WHERE "id" = ?'); expect($params)->toBe([1]); }); @@ -37,7 +39,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users WHERE username = ? AND email = ? AND document = ?'); + expect($dml)->toBe('SELECT * FROM "users" WHERE "username" = ? AND "email" = ? AND "document" = ?'); expect($params)->toBe(['john', 'john@mail.com', 123456]); }); @@ -55,7 +57,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE {$column} {$operator} ?"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"{$column}\" {$operator} ?"); expect($params)->toBe([$value]); })->with([ ['whereNotEqual', 'id', Operator::NOT_EQUAL->value, 1], @@ -75,7 +77,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT id, name, email FROM users WHERE id = ?'); + expect($dml)->toBe('SELECT "id", "name", "email" FROM "users" WHERE "id" = ?'); expect($params)->toBe([1]); }); @@ -89,7 +91,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE id {$operator} (?, ?, ?)"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"id\" {$operator} (?, ?, ?)"); expect($params)->toBe([1, 2, 3]); })->with([ ['whereIn', Operator::IN->value], @@ -111,8 +113,8 @@ $date = date('Y-m-d'); - $expected = "SELECT * FROM users WHERE id {$operator} " - . "(SELECT id FROM users WHERE created_at >= ?)"; + $expected = "SELECT * FROM \"users\" WHERE \"id\" {$operator} " + . "(SELECT \"id\" FROM \"users\" WHERE \"created_at\" >= ?)"; expect($dml)->toBe($expected); expect($params)->toBe([$date]); @@ -130,7 +132,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE verified_at {$operator}"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"verified_at\" {$operator}"); expect($params)->toBe([]); })->with([ ['whereNull', Operator::IS_NULL->value], @@ -149,7 +151,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR verified_at {$operator}"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"created_at\" > ? OR \"verified_at\" {$operator}"); expect($params)->toBe([$date]); })->with([ ['orWhereNull', Operator::IS_NULL->value], @@ -165,7 +167,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE enabled {$operator}"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"enabled\" {$operator}"); expect($params)->toBe([]); })->with([ ['whereTrue', Operator::IS_TRUE->value], @@ -184,7 +186,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR enabled {$operator}"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"created_at\" > ? OR \"enabled\" {$operator}"); expect($params)->toBe([$date]); })->with([ ['orWhereTrue', Operator::IS_TRUE->value], @@ -206,7 +208,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE verified_at IS NOT NULL AND created_at > ? OR updated_at < ?"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"verified_at\" IS NOT NULL AND \"created_at\" > ? OR \"updated_at\" < ?"); expect($params)->toBe([$date, $date]); }); @@ -225,7 +227,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR updated_at < ? AND verified_at IS NOT NULL"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"created_at\" > ? OR \"updated_at\" < ? AND \"verified_at\" IS NOT NULL"); expect($params)->toBe([$date, $date]); }); @@ -254,7 +256,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE verified_at IS NOT NULL OR {$column} {$operator} {$placeholders}"); + 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], @@ -277,7 +279,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE age {$operator} ? AND ?"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"age\" {$operator} ? AND ?"); expect($params)->toBe([20, 30]); })->with([ ['whereBetween', Operator::BETWEEN->value], @@ -298,7 +300,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR updated_at {$operator} ? AND ?"); + 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], @@ -316,9 +318,12 @@ $operator = Operator::ORDER_BY->value; - $column = implode(', ', (array) $column); + $column = implode(', ', array_map( + fn (string $column): string => '"' . str_replace('.', '"."', $column) . '"', + (array) $column + )); - expect($dml)->toBe("SELECT * FROM users {$operator} {$column} {$order}"); + expect($dml)->toBe("SELECT * FROM \"users\" {$operator} {$column} {$order}"); expect($params)->toBe($params); })->with([ ['id', Order::ASC->value], @@ -328,8 +333,7 @@ ]); it('generates a column-ordered query using select-case', function () { - $case = Functions::case() - ->whenNull('city', 'country') + $case = when_null('city', 'country') ->defaultResult('city'); $query = new QueryGenerator(Driver::SQLITE); @@ -340,7 +344,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users ORDER BY (CASE WHEN city IS NULL THEN country ELSE city END) ASC"); + expect($dml)->toBe("SELECT * FROM \"users\" ORDER BY (CASE WHEN \"city\" IS NULL THEN 'country' ELSE 'city' END) ASC"); expect($params)->toBe($params); }); @@ -357,9 +361,12 @@ $operator = Operator::ORDER_BY->value; - $column = implode(', ', (array) $column); + $column = implode(', ', array_map( + fn (string $column): string => '"' . str_replace('.', '"."', $column) . '"', + (array) $column + )); - expect($dml)->toBe("SELECT * FROM users WHERE id = ? {$operator} {$column} {$order} LIMIT 1"); + expect($dml)->toBe("SELECT * FROM \"users\" WHERE \"id\" = ? {$operator} {$column} {$order} LIMIT 1"); expect($params)->toBe([1]); })->with([ ['id', Order::ASC->value], @@ -382,8 +389,8 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM users WHERE {$operator} " - . "(SELECT * FROM user_role WHERE user_id = ? AND role_id = ? LIMIT 1)"; + $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]); @@ -409,8 +416,8 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM users WHERE is_admin IS TRUE OR {$operator} " - . "(SELECT * FROM user_role WHERE user_id = ? LIMIT 1)"; + $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]); @@ -428,14 +435,14 @@ $sql = $query->table('products') ->{$method}($column, function (Subquery $subquery) { - $subquery->select([Functions::max('price')])->from('products'); + $subquery->select([max_of('price')])->from('products'); }) ->get(); [$dml, $params] = $sql; - $expected = "SELECT * FROM products WHERE {$column} {$operator} " - . '(SELECT ' . Functions::max('price') . ' FROM products)'; + $expected = "SELECT * FROM \"products\" WHERE \"{$column}\" {$operator} " + . '(SELECT MAX("price") FROM "products")'; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -466,8 +473,8 @@ [$dml, $params] = $sql; - $expected = "SELECT description FROM products WHERE id {$comparisonOperator} {$operator}" - . "(SELECT product_id FROM orders WHERE quantity > ?)"; + $expected = "SELECT \"description\" FROM \"products\" WHERE \"id\" {$comparisonOperator} {$operator}" + . "(SELECT \"product_id\" FROM \"orders\" WHERE \"quantity\" > ?)"; expect($dml)->toBe($expected); expect($params)->toBe([10]); @@ -499,7 +506,7 @@ $sql = $query->table('employees') ->{$method}(['manager_id', 'department_id'], function (Subquery $subquery) { - $subquery->select(['id, department_id']) + $subquery->select(['id', 'department_id']) ->from('managers') ->whereEqual('location_id', 1); }) @@ -508,10 +515,10 @@ [$dml, $params] = $sql; - $subquery = 'SELECT id, department_id FROM managers WHERE location_id = ?'; + $subquery = 'SELECT "id", "department_id" FROM "managers" WHERE "location_id" = ?'; - $expected = "SELECT name FROM employees " - . "WHERE ROW(manager_id, department_id) {$operator} ({$subquery})"; + $expected = "SELECT \"name\" FROM \"employees\" " + . "WHERE ROW(\"manager_id\", \"department_id\") {$operator} ({$subquery})"; expect($dml)->toBe($expected); expect($params)->toBe([1]); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/WhereDateClausesTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/WhereDateClausesTest.php index 6c0abc9b..3dc44450 100644 --- a/tests/Unit/Database/QueryGenerator/Sqlite/WhereDateClausesTest.php +++ b/tests/Unit/Database/QueryGenerator/Sqlite/WhereDateClausesTest.php @@ -24,7 +24,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE DATE(created_at) {$operator} ?"); + 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], @@ -52,7 +52,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR DATE(created_at) {$operator} ?"); + 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], @@ -78,7 +78,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE MONTH(created_at) {$operator} ?"); + 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], @@ -106,7 +106,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR MONTH(created_at) {$operator} ?"); + 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], @@ -133,7 +133,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE YEAR(created_at) {$operator} ?"); + 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], @@ -161,7 +161,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR YEAR(created_at) {$operator} ?"); + 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], diff --git a/tests/Unit/Database/QueryGenerator/UpdateStatementTest.php b/tests/Unit/Database/QueryGenerator/UpdateStatementTest.php index 7f22f06c..e1ed9b5b 100644 --- a/tests/Unit/Database/QueryGenerator/UpdateStatementTest.php +++ b/tests/Unit/Database/QueryGenerator/UpdateStatementTest.php @@ -17,7 +17,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET name = ? WHERE id = ?"; + $expected = "UPDATE `users` SET `name` = ? WHERE `id` = ?"; expect($dml)->toBe($expected); expect($params)->toBe([$name, 1]); @@ -35,7 +35,7 @@ [$dml, $params] = $sql; - $expected = "UPDATE users SET name = ?, active = ? WHERE verified_at IS NOT NULL AND role_id = ?"; + $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]); diff --git a/tests/Unit/Database/QueryGenerator/WhereClausesTest.php b/tests/Unit/Database/QueryGenerator/WhereClausesTest.php index 3b1d8d01..4b906686 100644 --- a/tests/Unit/Database/QueryGenerator/WhereClausesTest.php +++ b/tests/Unit/Database/QueryGenerator/WhereClausesTest.php @@ -4,10 +4,12 @@ use Phenix\Database\Constants\Operator; use Phenix\Database\Constants\Order; -use Phenix\Database\Functions; use Phenix\Database\QueryGenerator; use Phenix\Database\Subquery; +use function Phenix\Database\max_of; +use function Phenix\Database\when_null; + it('generates query to select a record by column', function () { $query = new QueryGenerator(); @@ -19,7 +21,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users WHERE id = ?'); + expect($dml)->toBe('SELECT * FROM `users` WHERE `id` = ?'); expect($params)->toBe([1]); }); @@ -36,7 +38,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT * FROM users WHERE username = ? AND email = ? AND document = ?'); + expect($dml)->toBe('SELECT * FROM `users` WHERE `username` = ? AND `email` = ? AND `document` = ?'); expect($params)->toBe(['john', 'john@mail.com', 123456]); }); @@ -54,7 +56,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE {$column} {$operator} ?"); + expect($dml)->toBe("SELECT * FROM `users` WHERE `{$column}` {$operator} ?"); expect($params)->toBe([$value]); })->with([ ['whereNotEqual', 'id', Operator::NOT_EQUAL->value, 1], @@ -74,7 +76,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe('SELECT id, name, email FROM users WHERE id = ?'); + expect($dml)->toBe('SELECT `id`, `name`, `email` FROM `users` WHERE `id` = ?'); expect($params)->toBe([1]); }); @@ -88,7 +90,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE id {$operator} (?, ?, ?)"); + expect($dml)->toBe("SELECT * FROM `users` WHERE `id` {$operator} (?, ?, ?)"); expect($params)->toBe([1, 2, 3]); })->with([ ['whereIn', Operator::IN->value], @@ -110,8 +112,8 @@ $date = date('Y-m-d'); - $expected = "SELECT * FROM users WHERE id {$operator} " - . "(SELECT id FROM users WHERE created_at >= ?)"; + $expected = "SELECT * FROM `users` WHERE `id` {$operator} " + . "(SELECT `id` FROM `users` WHERE `created_at` >= ?)"; expect($dml)->toBe($expected); expect($params)->toBe([$date]); @@ -129,7 +131,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE verified_at {$operator}"); + expect($dml)->toBe("SELECT * FROM `users` WHERE `verified_at` {$operator}"); expect($params)->toBe([]); })->with([ ['whereNull', Operator::IS_NULL->value], @@ -148,7 +150,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR verified_at {$operator}"); + expect($dml)->toBe("SELECT * FROM `users` WHERE `created_at` > ? OR `verified_at` {$operator}"); expect($params)->toBe([$date]); })->with([ ['orWhereNull', Operator::IS_NULL->value], @@ -164,7 +166,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE enabled {$operator}"); + expect($dml)->toBe("SELECT * FROM `users` WHERE `enabled` {$operator}"); expect($params)->toBe([]); })->with([ ['whereTrue', Operator::IS_TRUE->value], @@ -183,7 +185,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR enabled {$operator}"); + expect($dml)->toBe("SELECT * FROM `users` WHERE `created_at` > ? OR `enabled` {$operator}"); expect($params)->toBe([$date]); })->with([ ['orWhereTrue', Operator::IS_TRUE->value], @@ -205,7 +207,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE verified_at IS NOT NULL AND created_at > ? OR updated_at < ?"); + expect($dml)->toBe("SELECT * FROM `users` WHERE `verified_at` IS NOT NULL AND `created_at` > ? OR `updated_at` < ?"); expect($params)->toBe([$date, $date]); }); @@ -224,7 +226,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR updated_at < ? AND verified_at IS NOT NULL"); + expect($dml)->toBe("SELECT * FROM `users` WHERE `created_at` > ? OR `updated_at` < ? AND `verified_at` IS NOT NULL"); expect($params)->toBe([$date, $date]); }); @@ -253,7 +255,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE verified_at IS NOT NULL OR {$column} {$operator} {$placeholders}"); + 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], @@ -276,7 +278,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE age {$operator} ? AND ?"); + expect($dml)->toBe("SELECT * FROM `users` WHERE `age` {$operator} ? AND ?"); expect($params)->toBe([20, 30]); })->with([ ['whereBetween', Operator::BETWEEN->value], @@ -297,7 +299,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR updated_at {$operator} ? AND ?"); + 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], @@ -315,9 +317,12 @@ $operator = Operator::ORDER_BY->value; - $column = implode(', ', (array) $column); + $column = implode(', ', array_map( + fn (string $column): string => '`' . str_replace('.', '`.`', $column) . '`', + (array) $column + )); - expect($dml)->toBe("SELECT * FROM users {$operator} {$column} {$order}"); + expect($dml)->toBe("SELECT * FROM `users` {$operator} {$column} {$order}"); expect($params)->toBe($params); })->with([ ['id', Order::ASC->value], @@ -327,8 +332,7 @@ ]); it('generates a column-ordered query using select-case', function () { - $case = Functions::case() - ->whenNull('city', 'country') + $case = when_null('city', 'country') ->defaultResult('city'); $query = new QueryGenerator(); @@ -339,7 +343,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users ORDER BY (CASE WHEN city IS NULL THEN country ELSE city END) ASC"); + expect($dml)->toBe("SELECT * FROM `users` ORDER BY (CASE WHEN `city` IS NULL THEN 'country' ELSE 'city' END) ASC"); expect($params)->toBe($params); }); @@ -356,9 +360,12 @@ $operator = Operator::ORDER_BY->value; - $column = implode(', ', (array) $column); + $column = implode(', ', array_map( + fn (string $column): string => '`' . str_replace('.', '`.`', $column) . '`', + (array) $column + )); - expect($dml)->toBe("SELECT * FROM users WHERE id = ? {$operator} {$column} {$order} LIMIT 1"); + expect($dml)->toBe("SELECT * FROM `users` WHERE `id` = ? {$operator} {$column} {$order} LIMIT 1"); expect($params)->toBe([1]); })->with([ ['id', Order::ASC->value], @@ -381,8 +388,8 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM users WHERE {$operator} " - . "(SELECT * FROM user_role WHERE user_id = ? AND role_id = ? LIMIT 1)"; + $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]); @@ -408,8 +415,8 @@ [$dml, $params] = $sql; - $expected = "SELECT * FROM users WHERE is_admin IS TRUE OR {$operator} " - . "(SELECT * FROM user_role WHERE user_id = ? LIMIT 1)"; + $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]); @@ -427,14 +434,14 @@ $sql = $query->table('products') ->{$method}($column, function (Subquery $subquery) { - $subquery->select([Functions::max('price')])->from('products'); + $subquery->select([max_of('price')])->from('products'); }) ->get(); [$dml, $params] = $sql; - $expected = "SELECT * FROM products WHERE {$column} {$operator} " - . '(SELECT ' . Functions::max('price') . ' FROM products)'; + $expected = "SELECT * FROM `products` WHERE `{$column}` {$operator} " + . '(SELECT MAX(`price`) FROM `products`)'; expect($dml)->toBe($expected); expect($params)->toBeEmpty(); @@ -465,8 +472,8 @@ [$dml, $params] = $sql; - $expected = "SELECT description FROM products WHERE id {$comparisonOperator} {$operator}" - . "(SELECT product_id FROM orders WHERE quantity > ?)"; + $expected = "SELECT `description` FROM `products` WHERE `id` {$comparisonOperator} {$operator}" + . "(SELECT `product_id` FROM `orders` WHERE `quantity` > ?)"; expect($dml)->toBe($expected); expect($params)->toBe([10]); @@ -498,7 +505,7 @@ $sql = $query->table('employees') ->{$method}(['manager_id', 'department_id'], function (Subquery $subquery) { - $subquery->select(['id, department_id']) + $subquery->select(['id', 'department_id']) ->from('managers') ->whereEqual('location_id', 1); }) @@ -507,10 +514,10 @@ [$dml, $params] = $sql; - $subquery = 'SELECT id, department_id FROM managers WHERE location_id = ?'; + $subquery = 'SELECT `id`, `department_id` FROM `managers` WHERE `location_id` = ?'; - $expected = "SELECT name FROM employees " - . "WHERE ROW(manager_id, department_id) {$operator} ({$subquery})"; + $expected = "SELECT `name` FROM `employees` " + . "WHERE ROW(`manager_id`, `department_id`) {$operator} ({$subquery})"; expect($dml)->toBe($expected); expect($params)->toBe([1]); diff --git a/tests/Unit/Database/QueryGenerator/WhereDateClausesTest.php b/tests/Unit/Database/QueryGenerator/WhereDateClausesTest.php index d8944ac8..549b3745 100644 --- a/tests/Unit/Database/QueryGenerator/WhereDateClausesTest.php +++ b/tests/Unit/Database/QueryGenerator/WhereDateClausesTest.php @@ -23,7 +23,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE DATE(created_at) {$operator} ?"); + 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], @@ -51,7 +51,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR DATE(created_at) {$operator} ?"); + 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], @@ -77,7 +77,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE MONTH(created_at) {$operator} ?"); + 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], @@ -105,7 +105,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR MONTH(created_at) {$operator} ?"); + 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], @@ -132,7 +132,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE YEAR(created_at) {$operator} ?"); + 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], @@ -160,7 +160,7 @@ [$dml, $params] = $sql; - expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR YEAR(created_at) {$operator} ?"); + 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],