From e43e1a9da6494f4ac88e815d0562d6c3cfc519b1 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 4 Mar 2026 23:01:09 +1300 Subject: [PATCH 01/29] (feat): Add Compiler interface and Query::compile() visitor method --- src/Query/Compiler.php | 36 ++++++++++++++++++++++++++++++++++++ src/Query/Query.php | 27 +++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 src/Query/Compiler.php diff --git a/src/Query/Compiler.php b/src/Query/Compiler.php new file mode 100644 index 0000000..e7e38c7 --- /dev/null +++ b/src/Query/Compiler.php @@ -0,0 +1,36 @@ +method) { + self::TYPE_ORDER_ASC, + self::TYPE_ORDER_DESC, + self::TYPE_ORDER_RANDOM => $compiler->compileOrder($this), + + self::TYPE_LIMIT => $compiler->compileLimit($this), + + self::TYPE_OFFSET => $compiler->compileOffset($this), + + self::TYPE_CURSOR_AFTER, + self::TYPE_CURSOR_BEFORE => $compiler->compileCursor($this), + + self::TYPE_SELECT => $compiler->compileSelect($this), + + default => $compiler->compileFilter($this), + }; + } + /** * @throws QueryException */ @@ -824,8 +847,8 @@ public static function getCursorQueries(array $queries, bool $clone = true): arr * * @param array $queries * @return array{ - * filters: array, - * selections: array, + * filters: list, + * selections: list, * limit: int|null, * offset: int|null, * orderAttributes: array, From 57bb071c8de456503020cb9d44dd985fce31acc3 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 4 Mar 2026 23:01:16 +1300 Subject: [PATCH 02/29] (feat): Add SQL Builder with fluent API and parameterized queries --- src/Query/Builder.php | 590 +++++++++++++++++++++++++++++++ tests/Query/BuilderTest.php | 672 ++++++++++++++++++++++++++++++++++++ 2 files changed, 1262 insertions(+) create mode 100644 src/Query/Builder.php create mode 100644 tests/Query/BuilderTest.php diff --git a/src/Query/Builder.php b/src/Query/Builder.php new file mode 100644 index 0000000..018abb6 --- /dev/null +++ b/src/Query/Builder.php @@ -0,0 +1,590 @@ + + */ + protected array $pendingQueries = []; + + /** + * @var list + */ + protected array $bindings = []; + + private string $wrapChar = '`'; + + private ?Closure $attributeResolver = null; + + /** + * @var array + */ + private array $conditionProviders = []; + + /** + * Set the collection/table name + */ + public function from(string $table): static + { + $this->table = $table; + + return $this; + } + + /** + * Add a SELECT clause + * + * @param array $columns + */ + public function select(array $columns): static + { + $this->pendingQueries[] = Query::select($columns); + + return $this; + } + + /** + * Add filter queries + * + * @param array $queries + */ + public function filter(array $queries): static + { + foreach ($queries as $query) { + $this->pendingQueries[] = $query; + } + + return $this; + } + + /** + * Add sort ascending + */ + public function sortAsc(string $attribute): static + { + $this->pendingQueries[] = Query::orderAsc($attribute); + + return $this; + } + + /** + * Add sort descending + */ + public function sortDesc(string $attribute): static + { + $this->pendingQueries[] = Query::orderDesc($attribute); + + return $this; + } + + /** + * Add sort random + */ + public function sortRandom(): static + { + $this->pendingQueries[] = Query::orderRandom(); + + return $this; + } + + /** + * Set LIMIT + */ + public function limit(int $value): static + { + $this->pendingQueries[] = Query::limit($value); + + return $this; + } + + /** + * Set OFFSET + */ + public function offset(int $value): static + { + $this->pendingQueries[] = Query::offset($value); + + return $this; + } + + /** + * Set cursor after + */ + public function cursorAfter(mixed $value): static + { + $this->pendingQueries[] = Query::cursorAfter($value); + + return $this; + } + + /** + * Set cursor before + */ + public function cursorBefore(mixed $value): static + { + $this->pendingQueries[] = Query::cursorBefore($value); + + return $this; + } + + /** + * Add multiple queries at once (batch mode) + * + * @param array $queries + */ + public function queries(array $queries): static + { + foreach ($queries as $query) { + $this->pendingQueries[] = $query; + } + + return $this; + } + + /** + * Set the wrap character for identifiers + */ + public function setWrapChar(string $char): static + { + $this->wrapChar = $char; + + return $this; + } + + /** + * Set an attribute resolver closure + */ + public function setAttributeResolver(Closure $resolver): static + { + $this->attributeResolver = $resolver; + + return $this; + } + + /** + * Add a condition provider closure + * + * @param Closure(string): array{0: string, 1: list} $provider + */ + public function addConditionProvider(Closure $provider): static + { + $this->conditionProviders[] = $provider; + + return $this; + } + + /** + * Build the query and bindings from accumulated state + * + * @return array{query: string, bindings: list} + */ + public function build(): array + { + $this->bindings = []; + + $grouped = Query::groupByType($this->pendingQueries); + + $parts = []; + + // SELECT + $selectSQL = '*'; + if (! empty($grouped['selections'])) { + $selectSQL = $this->compileSelect($grouped['selections'][0]); + } + $parts[] = 'SELECT ' . $selectSQL; + + // FROM + $parts[] = 'FROM ' . $this->wrapIdentifier($this->table); + + // WHERE + $whereClauses = []; + + // Compile filters + foreach ($grouped['filters'] as $filter) { + $whereClauses[] = $this->compileFilter($filter); + } + + // Condition providers + $providerBindings = []; + foreach ($this->conditionProviders as $provider) { + /** @var array{0: string, 1: list} $result */ + $result = $provider($this->table); + $whereClauses[] = $result[0]; + foreach ($result[1] as $binding) { + $providerBindings[] = $binding; + } + } + foreach ($providerBindings as $binding) { + $this->addBinding($binding); + } + + // Cursor + $cursorSQL = ''; + if ($grouped['cursor'] !== null && $grouped['cursorDirection'] !== null) { + $cursorQueries = Query::getCursorQueries($this->pendingQueries, false); + if (! empty($cursorQueries)) { + $cursorSQL = $this->compileCursor($cursorQueries[0]); + } + } + if ($cursorSQL !== '') { + $whereClauses[] = $cursorSQL; + } + + if (! empty($whereClauses)) { + $parts[] = 'WHERE ' . \implode(' AND ', $whereClauses); + } + + // ORDER BY + $orderClauses = []; + $orderQueries = Query::getByType($this->pendingQueries, [ + Query::TYPE_ORDER_ASC, + Query::TYPE_ORDER_DESC, + Query::TYPE_ORDER_RANDOM, + ], false); + foreach ($orderQueries as $orderQuery) { + $orderClauses[] = $this->compileOrder($orderQuery); + } + if (! empty($orderClauses)) { + $parts[] = 'ORDER BY ' . \implode(', ', $orderClauses); + } + + // LIMIT + if ($grouped['limit'] !== null) { + $parts[] = 'LIMIT ?'; + $this->addBinding($grouped['limit']); + } + + // OFFSET + if ($grouped['offset'] !== null) { + $parts[] = 'OFFSET ?'; + $this->addBinding($grouped['offset']); + } + + return [ + 'query' => \implode(' ', $parts), + 'bindings' => $this->bindings, + ]; + } + + /** + * Get bindings from last build/compile + * + * @return list + */ + public function getBindings(): array + { + return $this->bindings; + } + + /** + * Clear all accumulated state for reuse + */ + public function reset(): static + { + $this->pendingQueries = []; + $this->bindings = []; + $this->table = ''; + + return $this; + } + + // ── Compiler interface ── + + public function compileFilter(Query $query): string + { + $method = $query->getMethod(); + $attribute = $this->resolveAndWrap($query->getAttribute()); + $values = $query->getValues(); + + return match ($method) { + Query::TYPE_EQUAL => $this->compileIn($attribute, $values), + Query::TYPE_NOT_EQUAL => $this->compileNotIn($attribute, $values), + Query::TYPE_LESSER => $this->compileComparison($attribute, '<', $values), + Query::TYPE_LESSER_EQUAL => $this->compileComparison($attribute, '<=', $values), + Query::TYPE_GREATER => $this->compileComparison($attribute, '>', $values), + Query::TYPE_GREATER_EQUAL => $this->compileComparison($attribute, '>=', $values), + Query::TYPE_BETWEEN => $this->compileBetween($attribute, $values, false), + Query::TYPE_NOT_BETWEEN => $this->compileBetween($attribute, $values, true), + Query::TYPE_STARTS_WITH => $this->compileLike($attribute, $values, '', '%', false), + Query::TYPE_NOT_STARTS_WITH => $this->compileLike($attribute, $values, '', '%', true), + Query::TYPE_ENDS_WITH => $this->compileLike($attribute, $values, '%', '', false), + Query::TYPE_NOT_ENDS_WITH => $this->compileLike($attribute, $values, '%', '', true), + Query::TYPE_CONTAINS => $this->compileContains($attribute, $values), + Query::TYPE_CONTAINS_ANY => $this->compileIn($attribute, $values), + Query::TYPE_CONTAINS_ALL => $this->compileContainsAll($attribute, $values), + Query::TYPE_NOT_CONTAINS => $this->compileNotContains($attribute, $values), + Query::TYPE_SEARCH => $this->compileSearch($attribute, $values, false), + Query::TYPE_NOT_SEARCH => $this->compileSearch($attribute, $values, true), + Query::TYPE_REGEX => $this->compileRegex($attribute, $values), + Query::TYPE_IS_NULL => $attribute . ' IS NULL', + Query::TYPE_IS_NOT_NULL => $attribute . ' IS NOT NULL', + Query::TYPE_AND => $this->compileLogical($query, 'AND'), + Query::TYPE_OR => $this->compileLogical($query, 'OR'), + Query::TYPE_EXISTS => $this->compileExists($query), + Query::TYPE_NOT_EXISTS => $this->compileNotExists($query), + default => throw new Exception('Unsupported filter type: ' . $method), + }; + } + + public function compileOrder(Query $query): string + { + return match ($query->getMethod()) { + Query::TYPE_ORDER_ASC => $this->resolveAndWrap($query->getAttribute()) . ' ASC', + Query::TYPE_ORDER_DESC => $this->resolveAndWrap($query->getAttribute()) . ' DESC', + Query::TYPE_ORDER_RANDOM => 'RAND()', + default => throw new Exception('Unsupported order type: ' . $query->getMethod()), + }; + } + + public function compileLimit(Query $query): string + { + $this->addBinding($query->getValue()); + + return 'LIMIT ?'; + } + + public function compileOffset(Query $query): string + { + $this->addBinding($query->getValue()); + + return 'OFFSET ?'; + } + + public function compileSelect(Query $query): string + { + /** @var array $values */ + $values = $query->getValues(); + $columns = \array_map( + fn (string $col): string => $this->resolveAndWrap($col), + $values + ); + + return \implode(', ', $columns); + } + + public function compileCursor(Query $query): string + { + $value = $query->getValue(); + $this->addBinding($value); + + $operator = $query->getMethod() === Query::TYPE_CURSOR_AFTER ? '>' : '<'; + + return '_cursor ' . $operator . ' ?'; + } + + // ── Protected (overridable) ── + + protected function resolveAttribute(string $attribute): string + { + if ($this->attributeResolver !== null) { + /** @var string */ + return ($this->attributeResolver)($attribute); + } + + return $attribute; + } + + protected function wrapIdentifier(string $identifier): string + { + return $this->wrapChar . $identifier . $this->wrapChar; + } + + protected function resolveAndWrap(string $attribute): string + { + return $this->wrapIdentifier($this->resolveAttribute($attribute)); + } + + // ── Private helpers ── + + private function addBinding(mixed $value): void + { + $this->bindings[] = $value; + } + + /** + * @param array $values + */ + private function compileIn(string $attribute, array $values): string + { + $placeholders = \array_fill(0, \count($values), '?'); + foreach ($values as $value) { + $this->addBinding($value); + } + + return $attribute . ' IN (' . \implode(', ', $placeholders) . ')'; + } + + /** + * @param array $values + */ + private function compileNotIn(string $attribute, array $values): string + { + if (\count($values) === 1) { + $this->addBinding($values[0]); + + return $attribute . ' != ?'; + } + + $placeholders = \array_fill(0, \count($values), '?'); + foreach ($values as $value) { + $this->addBinding($value); + } + + return $attribute . ' NOT IN (' . \implode(', ', $placeholders) . ')'; + } + + /** + * @param array $values + */ + private function compileComparison(string $attribute, string $operator, array $values): string + { + $this->addBinding($values[0]); + + return $attribute . ' ' . $operator . ' ?'; + } + + /** + * @param array $values + */ + private function compileBetween(string $attribute, array $values, bool $not): string + { + $this->addBinding($values[0]); + $this->addBinding($values[1]); + $keyword = $not ? 'NOT BETWEEN' : 'BETWEEN'; + + return $attribute . ' ' . $keyword . ' ? AND ?'; + } + + /** + * @param array $values + */ + private function compileLike(string $attribute, array $values, string $prefix, string $suffix, bool $not): string + { + /** @var string $val */ + $val = $values[0]; + $this->addBinding($prefix . $val . $suffix); + $keyword = $not ? 'NOT LIKE' : 'LIKE'; + + return $attribute . ' ' . $keyword . ' ?'; + } + + /** + * @param array $values + */ + private function compileContains(string $attribute, array $values): string + { + /** @var array $values */ + if (\count($values) === 1) { + $this->addBinding('%' . $values[0] . '%'); + + return $attribute . ' LIKE ?'; + } + + $parts = []; + foreach ($values as $value) { + $this->addBinding('%' . $value . '%'); + $parts[] = $attribute . ' LIKE ?'; + } + + return '(' . \implode(' OR ', $parts) . ')'; + } + + /** + * @param array $values + */ + private function compileContainsAll(string $attribute, array $values): string + { + /** @var array $values */ + $parts = []; + foreach ($values as $value) { + $this->addBinding('%' . $value . '%'); + $parts[] = $attribute . ' LIKE ?'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } + + /** + * @param array $values + */ + private function compileNotContains(string $attribute, array $values): string + { + /** @var array $values */ + if (\count($values) === 1) { + $this->addBinding('%' . $values[0] . '%'); + + return $attribute . ' NOT LIKE ?'; + } + + $parts = []; + foreach ($values as $value) { + $this->addBinding('%' . $value . '%'); + $parts[] = $attribute . ' NOT LIKE ?'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } + + /** + * @param array $values + */ + private function compileSearch(string $attribute, array $values, bool $not): string + { + $this->addBinding($values[0]); + + if ($not) { + return 'NOT MATCH(' . $attribute . ') AGAINST(?)'; + } + + return 'MATCH(' . $attribute . ') AGAINST(?)'; + } + + /** + * @param array $values + */ + private function compileRegex(string $attribute, array $values): string + { + $this->addBinding($values[0]); + + return $attribute . ' REGEXP ?'; + } + + private function compileLogical(Query $query, string $operator): string + { + $parts = []; + foreach ($query->getValues() as $subQuery) { + /** @var Query $subQuery */ + $parts[] = $this->compileFilter($subQuery); + } + + return '(' . \implode(' ' . $operator . ' ', $parts) . ')'; + } + + private function compileExists(Query $query): string + { + $parts = []; + foreach ($query->getValues() as $attr) { + /** @var string $attr */ + $parts[] = $this->resolveAndWrap($attr) . ' IS NOT NULL'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } + + private function compileNotExists(Query $query): string + { + $parts = []; + foreach ($query->getValues() as $attr) { + /** @var string $attr */ + $parts[] = $this->resolveAndWrap($attr) . ' IS NULL'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } +} diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php new file mode 100644 index 0000000..d2033b0 --- /dev/null +++ b/tests/Query/BuilderTest.php @@ -0,0 +1,672 @@ +assertInstanceOf(Compiler::class, $builder); + } + + public function testStandaloneCompile(): void + { + $builder = new Builder(); + + $filter = Query::greaterThan('age', 18); + $sql = $filter->compile($builder); + $this->assertEquals('`age` > ?', $sql); + $this->assertEquals([18], $builder->getBindings()); + } + + // ── Fluent API ── + + public function testFluentSelectFromFilterSortLimitOffset(): void + { + $result = (new Builder()) + ->select(['name', 'email']) + ->from('users') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->sortAsc('name') + ->limit(25) + ->offset(0) + ->build(); + + $this->assertEquals( + 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', + $result['query'] + ); + $this->assertEquals(['active', 18, 25, 0], $result['bindings']); + } + + // ── Batch mode ── + + public function testBatchModeProducesSameOutput(): void + { + $result = (new Builder()) + ->from('users') + ->queries([ + Query::select(['name', 'email']), + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + Query::orderAsc('name'), + Query::limit(25), + Query::offset(0), + ]) + ->build(); + + $this->assertEquals( + 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', + $result['query'] + ); + $this->assertEquals(['active', 18, 25, 0], $result['bindings']); + } + + // ── Filter types ── + + public function testEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active', 'pending'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?, ?)', $result['query']); + $this->assertEquals(['active', 'pending'], $result['bindings']); + } + + public function testNotEqualSingle(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('role', 'guest')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `role` != ?', $result['query']); + $this->assertEquals(['guest'], $result['bindings']); + } + + public function testNotEqualMultiple(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('role', ['guest', 'banned'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result['query']); + $this->assertEquals(['guest', 'banned'], $result['bindings']); + } + + public function testLessThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('price', 100)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `price` < ?', $result['query']); + $this->assertEquals([100], $result['bindings']); + } + + public function testLessThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThanEqual('price', 100)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `price` <= ?', $result['query']); + $this->assertEquals([100], $result['bindings']); + } + + public function testGreaterThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('age', 18)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `age` > ?', $result['query']); + $this->assertEquals([18], $result['bindings']); + } + + public function testGreaterThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('score', 90)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `score` >= ?', $result['query']); + $this->assertEquals([90], $result['bindings']); + } + + public function testBetween(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 18, 65)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result['query']); + $this->assertEquals([18, 65], $result['bindings']); + } + + public function testNotBetween(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notBetween('age', 18, 65)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `age` NOT BETWEEN ? AND ?', $result['query']); + $this->assertEquals([18, 65], $result['bindings']); + } + + public function testStartsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', 'Jo')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result['query']); + $this->assertEquals(['Jo%'], $result['bindings']); + } + + public function testNotStartsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notStartsWith('name', 'Jo')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` NOT LIKE ?', $result['query']); + $this->assertEquals(['Jo%'], $result['bindings']); + } + + public function testEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('email', '.com')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `email` LIKE ?', $result['query']); + $this->assertEquals(['%.com'], $result['bindings']); + } + + public function testNotEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEndsWith('email', '.com')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `email` NOT LIKE ?', $result['query']); + $this->assertEquals(['%.com'], $result['bindings']); + } + + public function testContainsSingle(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['php'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result['query']); + $this->assertEquals(['%php%'], $result['bindings']); + } + + public function testContainsMultiple(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['php', 'js'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`bio` LIKE ? OR `bio` LIKE ?)', $result['query']); + $this->assertEquals(['%php%', '%js%'], $result['bindings']); + } + + public function testContainsAny(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAny('tags', ['a', 'b'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `tags` IN (?, ?)', $result['query']); + $this->assertEquals(['a', 'b'], $result['bindings']); + } + + public function testContainsAll(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('perms', ['read', 'write'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`perms` LIKE ? AND `perms` LIKE ?)', $result['query']); + $this->assertEquals(['%read%', '%write%'], $result['bindings']); + } + + public function testNotContainsSingle(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', ['php'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result['query']); + $this->assertEquals(['%php%'], $result['bindings']); + } + + public function testNotContainsMultiple(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', ['php', 'js'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`bio` NOT LIKE ? AND `bio` NOT LIKE ?)', $result['query']); + $this->assertEquals(['%php%', '%js%'], $result['bindings']); + } + + public function testSearch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('content', 'hello')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?)', $result['query']); + $this->assertEquals(['hello'], $result['bindings']); + } + + public function testNotSearch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notSearch('content', 'hello')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE NOT MATCH(`content`) AGAINST(?)', $result['query']); + $this->assertEquals(['hello'], $result['bindings']); + } + + public function testRegex(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('slug', '^[a-z]+$')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result['query']); + $this->assertEquals(['^[a-z]+$'], $result['bindings']); + } + + public function testIsNull(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNull('deleted')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `deleted` IS NULL', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testIsNotNull(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNotNull('verified')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `verified` IS NOT NULL', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testExists(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name', 'email'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`name` IS NOT NULL AND `email` IS NOT NULL)', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testNotExists(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists(['legacy'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`legacy` IS NULL)', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + // ── Logical / nested ── + + public function testAndLogical(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::greaterThan('age', 18), + Query::equal('status', ['active']), + ]), + ]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`age` > ? AND `status` IN (?))', $result['query']); + $this->assertEquals([18, 'active'], $result['bindings']); + } + + public function testOrLogical(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('role', ['admin']), + Query::equal('role', ['mod']), + ]), + ]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`role` IN (?) OR `role` IN (?))', $result['query']); + $this->assertEquals(['admin', 'mod'], $result['bindings']); + } + + public function testDeeplyNested(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::greaterThan('age', 18), + Query::or([ + Query::equal('role', ['admin']), + Query::equal('role', ['mod']), + ]), + ]), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`age` > ? AND (`role` IN (?) OR `role` IN (?)))', + $result['query'] + ); + $this->assertEquals([18, 'admin', 'mod'], $result['bindings']); + } + + // ── Sort ── + + public function testSortAsc(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->build(); + + $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC', $result['query']); + } + + public function testSortDesc(): void + { + $result = (new Builder()) + ->from('t') + ->sortDesc('score') + ->build(); + + $this->assertEquals('SELECT * FROM `t` ORDER BY `score` DESC', $result['query']); + } + + public function testSortRandom(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->build(); + + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND()', $result['query']); + } + + public function testMultipleSorts(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + + $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC, `age` DESC', $result['query']); + } + + // ── Pagination ── + + public function testLimitOnly(): void + { + $result = (new Builder()) + ->from('t') + ->limit(10) + ->build(); + + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result['query']); + $this->assertEquals([10], $result['bindings']); + } + + public function testOffsetOnly(): void + { + $result = (new Builder()) + ->from('t') + ->offset(50) + ->build(); + + $this->assertEquals('SELECT * FROM `t` OFFSET ?', $result['query']); + $this->assertEquals([50], $result['bindings']); + } + + public function testCursorAfter(): void + { + $result = (new Builder()) + ->from('t') + ->cursorAfter('abc123') + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE _cursor > ?', $result['query']); + $this->assertEquals(['abc123'], $result['bindings']); + } + + public function testCursorBefore(): void + { + $result = (new Builder()) + ->from('t') + ->cursorBefore('xyz789') + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE _cursor < ?', $result['query']); + $this->assertEquals(['xyz789'], $result['bindings']); + } + + // ── Combined full query ── + + public function testFullCombinedQuery(): void + { + $result = (new Builder()) + ->select(['id', 'name']) + ->from('users') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->sortAsc('name') + ->sortDesc('age') + ->limit(25) + ->offset(10) + ->build(); + + $this->assertEquals( + 'SELECT `id`, `name` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC, `age` DESC LIMIT ? OFFSET ?', + $result['query'] + ); + $this->assertEquals(['active', 18, 25, 10], $result['bindings']); + } + + // ── Multiple filter() calls (additive) ── + + public function testMultipleFilterCalls(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]) + ->filter([Query::equal('b', [2])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?)', $result['query']); + $this->assertEquals([1, 2], $result['bindings']); + } + + // ── Reset ── + + public function testResetClearsState(): void + { + $builder = (new Builder()) + ->select(['name']) + ->from('users') + ->filter([Query::equal('x', [1])]) + ->limit(10); + + $builder->build(); + + $builder->reset(); + + $result = $builder + ->from('orders') + ->filter([Query::greaterThan('total', 100)]) + ->build(); + + $this->assertEquals('SELECT * FROM `orders` WHERE `total` > ?', $result['query']); + $this->assertEquals([100], $result['bindings']); + } + + // ── Extension points ── + + public function testAttributeResolver(): void + { + $result = (new Builder()) + ->from('users') + ->setAttributeResolver(fn (string $a): string => match ($a) { + '$id' => '_uid', + '$createdAt' => '_createdAt', + default => $a, + }) + ->filter([Query::equal('$id', ['abc'])]) + ->sortAsc('$createdAt') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` WHERE `_uid` IN (?) ORDER BY `_createdAt` ASC', + $result['query'] + ); + $this->assertEquals(['abc'], $result['bindings']); + } + + public function testWrapChar(): void + { + $result = (new Builder()) + ->from('users') + ->setWrapChar('"') + ->select(['name']) + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertEquals( + 'SELECT "name" FROM "users" WHERE "status" IN (?)', + $result['query'] + ); + } + + public function testConditionProvider(): void + { + $result = (new Builder()) + ->from('users') + ->addConditionProvider(fn (string $table): array => [ + "_uid IN (SELECT _document FROM {$table}_perms WHERE _type = 'read')", + [], + ]) + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertEquals( + "SELECT * FROM `users` WHERE `status` IN (?) AND _uid IN (SELECT _document FROM users_perms WHERE _type = 'read')", + $result['query'] + ); + $this->assertEquals(['active'], $result['bindings']); + } + + public function testConditionProviderWithBindings(): void + { + $result = (new Builder()) + ->from('docs') + ->addConditionProvider(fn (string $table): array => [ + '_tenant = ?', + ['tenant_abc'], + ]) + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `docs` WHERE `status` IN (?) AND _tenant = ?', + $result['query'] + ); + // filter bindings first, then provider bindings + $this->assertEquals(['active', 'tenant_abc'], $result['bindings']); + } + + public function testBindingOrderingWithProviderAndCursor(): void + { + $result = (new Builder()) + ->from('docs') + ->addConditionProvider(fn (string $table): array => [ + '_tenant = ?', + ['t1'], + ]) + ->filter([Query::equal('status', ['active'])]) + ->cursorAfter('cursor_val') + ->limit(10) + ->offset(5) + ->build(); + + // binding order: filter, provider, cursor, limit, offset + $this->assertEquals(['active', 't1', 'cursor_val', 10, 5], $result['bindings']); + } + + // ── Select with no columns defaults to * ── + + public function testDefaultSelectStar(): void + { + $result = (new Builder()) + ->from('t') + ->build(); + + $this->assertEquals('SELECT * FROM `t`', $result['query']); + } +} From 2a388f995eca4df224c7c21b5f7321af1d54c136 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 4 Mar 2026 23:01:22 +1300 Subject: [PATCH 03/29] (docs): Update README with Compiler and Builder examples --- README.md | 219 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 142 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index b0452ca..39b1bf2 100644 --- a/README.md +++ b/README.md @@ -169,102 +169,167 @@ $grouped = Query::groupByType($queries); $cursors = Query::getByType($queries, [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); ``` -### Building an Adapter +### Building a Compiler -The `Query` object is backend-agnostic — your library decides how to translate it. Use `groupByType` to break queries apart, then map each piece to your target syntax: +This library ships with a `Compiler` interface so you can translate queries into any backend syntax. Each query delegates to the correct compiler method via `$query->compile($compiler)`: ```php +use Utopia\Query\Compiler; use Utopia\Query\Query; -class SQLAdapter +class SQLCompiler implements Compiler { - /** - * @param array $queries - */ - public function find(string $table, array $queries): array + public function compileFilter(Query $query): string { - $grouped = Query::groupByType($queries); - - // SELECT - $columns = '*'; - if (!empty($grouped['selections'])) { - $columns = implode(', ', $grouped['selections'][0]->getValues()); - } - - $sql = "SELECT {$columns} FROM {$table}"; - - // WHERE - $conditions = []; - foreach ($grouped['filters'] as $filter) { - $conditions[] = match ($filter->getMethod()) { - Query::TYPE_EQUAL => $filter->getAttribute() . ' IN (' . $this->placeholders($filter->getValues()) . ')', - Query::TYPE_NOT_EQUAL => $filter->getAttribute() . ' != ?', - Query::TYPE_GREATER => $filter->getAttribute() . ' > ?', - Query::TYPE_LESSER => $filter->getAttribute() . ' < ?', - Query::TYPE_BETWEEN => $filter->getAttribute() . ' BETWEEN ? AND ?', - Query::TYPE_IS_NULL => $filter->getAttribute() . ' IS NULL', - Query::TYPE_IS_NOT_NULL => $filter->getAttribute() . ' IS NOT NULL', - Query::TYPE_STARTS_WITH => $filter->getAttribute() . " LIKE CONCAT(?, '%')", - // ... handle other types - }; - } - - if (!empty($conditions)) { - $sql .= ' WHERE ' . implode(' AND ', $conditions); - } - - // ORDER BY - foreach ($grouped['orderAttributes'] as $i => $attr) { - $sql .= ($i === 0 ? ' ORDER BY ' : ', ') . $attr . ' ' . $grouped['orderTypes'][$i]; - } - - // LIMIT / OFFSET - if ($grouped['limit'] !== null) { - $sql .= ' LIMIT ' . $grouped['limit']; - } - if ($grouped['offset'] !== null) { - $sql .= ' OFFSET ' . $grouped['offset']; - } - - // Execute $sql with bound parameters ... + return match ($query->getMethod()) { + Query::TYPE_EQUAL => $query->getAttribute() . ' IN (' . $this->placeholders($query->getValues()) . ')', + Query::TYPE_NOT_EQUAL => $query->getAttribute() . ' != ?', + Query::TYPE_GREATER => $query->getAttribute() . ' > ?', + Query::TYPE_LESSER => $query->getAttribute() . ' < ?', + Query::TYPE_BETWEEN => $query->getAttribute() . ' BETWEEN ? AND ?', + Query::TYPE_IS_NULL => $query->getAttribute() . ' IS NULL', + Query::TYPE_IS_NOT_NULL => $query->getAttribute() . ' IS NOT NULL', + Query::TYPE_STARTS_WITH => $query->getAttribute() . " LIKE CONCAT(?, '%')", + // ... handle remaining types + }; + } + + public function compileOrder(Query $query): string + { + return match ($query->getMethod()) { + Query::TYPE_ORDER_ASC => $query->getAttribute() . ' ASC', + Query::TYPE_ORDER_DESC => $query->getAttribute() . ' DESC', + Query::TYPE_ORDER_RANDOM => 'RAND()', + }; + } + + public function compileLimit(Query $query): string + { + return 'LIMIT ' . $query->getValue(); + } + + public function compileOffset(Query $query): string + { + return 'OFFSET ' . $query->getValue(); + } + + public function compileSelect(Query $query): string + { + return implode(', ', $query->getValues()); + } + + public function compileCursor(Query $query): string + { + // Cursor-based pagination is adapter-specific + return ''; } } ``` -The same pattern works for any backend. A Redis adapter might map filters to sorted-set range commands, an Elasticsearch adapter might build a `bool` query, or a MongoDB adapter might produce a `find()` filter document — the Query objects stay the same regardless: +Then calling `compile()` on any query routes to the right method automatically: + +```php +$compiler = new SQLCompiler(); + +$filter = Query::greaterThan('age', 18); +echo $filter->compile($compiler); // "age > ?" + +$order = Query::orderAsc('name'); +echo $order->compile($compiler); // "name ASC" + +$limit = Query::limit(25); +echo $limit->compile($compiler); // "LIMIT 25" +``` + +The same interface works for any backend — implement `Compiler` for Redis, MongoDB, Elasticsearch, etc. and every query compiles without changes: ```php -class RedisAdapter +class RedisCompiler implements Compiler { - /** - * @param array $queries - */ - public function find(string $key, array $queries): array + public function compileFilter(Query $query): string { - $grouped = Query::groupByType($queries); - - foreach ($grouped['filters'] as $filter) { - match ($filter->getMethod()) { - Query::TYPE_BETWEEN => $this->redis->zRangeByScore( - $key, - $filter->getValues()[0], - $filter->getValues()[1], - ), - Query::TYPE_GREATER => $this->redis->zRangeByScore( - $key, - '(' . $filter->getValue(), - '+inf', - ), - // ... handle other types - }; - } - - // ... + return match ($query->getMethod()) { + Query::TYPE_BETWEEN => $query->getValues()[0] . ' ' . $query->getValues()[1], + Query::TYPE_GREATER => '(' . $query->getValue() . ' +inf', + // ... handle remaining types + }; } + + // ... implement remaining methods } ``` -This keeps your application code decoupled from any particular storage engine — swap adapters without changing a single query. +This is the pattern used by [utopia-php/database](https://github.com/utopia-php/database) — it implements `Compiler` for each supported database engine, keeping application code fully decoupled from any particular storage backend. + +### SQL Builder + +The library includes a built-in `Builder` class that implements `Compiler` and provides a fluent API for building parameterized SQL queries: + +```php +use Utopia\Query\Builder; +use Utopia\Query\Query; + +// Fluent API +$result = (new Builder()) + ->select(['name', 'email']) + ->from('users') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->sortAsc('name') + ->limit(25) + ->offset(0) + ->build(); + +$result['query']; // SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ? +$result['bindings']; // ['active', 18, 25, 0] +``` + +**Batch mode** — pass all queries at once: + +```php +$result = (new Builder()) + ->from('users') + ->queries([ + Query::select(['name', 'email']), + Query::equal('status', ['active']), + Query::orderAsc('name'), + Query::limit(25), + ]) + ->build(); +``` + +**Using with PDO:** + +```php +$result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->build(); + +$stmt = $pdo->prepare($result['query']); +$stmt->execute($result['bindings']); +$rows = $stmt->fetchAll(); +``` + +**Pluggable extensions** — customize attribute mapping, identifier wrapping, and inject extra conditions: + +```php +$result = (new Builder()) + ->from('users') + ->setAttributeResolver(fn(string $a) => match($a) { + '$id' => '_uid', '$createdAt' => '_createdAt', default => $a + }) + ->setWrapChar('"') // PostgreSQL + ->addConditionProvider(fn(string $table) => [ + "_uid IN (SELECT _document FROM {$table}_perms WHERE _type = 'read')", + [], + ]) + ->filter([Query::equal('status', ['active'])]) + ->build(); +``` ## Contributing From d8c3af913ed2531dc6aa148546d1ff68c6c2f7db Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 4 Mar 2026 23:37:49 +1300 Subject: [PATCH 04/29] (feat): Add aggregation, join, distinct, union, and raw query types with static helpers --- src/Query/Compiler.php | 15 ++ src/Query/Query.php | 399 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 413 insertions(+), 1 deletion(-) diff --git a/src/Query/Compiler.php b/src/Query/Compiler.php index e7e38c7..f7c0a24 100644 --- a/src/Query/Compiler.php +++ b/src/Query/Compiler.php @@ -33,4 +33,19 @@ public function compileSelect(Query $query): string; * Compile a cursor query (cursorAfter, cursorBefore) */ public function compileCursor(Query $query): string; + + /** + * Compile an aggregate query (count, sum, avg, min, max) + */ + public function compileAggregate(Query $query): string; + + /** + * Compile a group by query + */ + public function compileGroupBy(Query $query): string; + + /** + * Compile a join query (join, leftJoin, rightJoin, crossJoin) + */ + public function compileJoin(Query $query): string; } diff --git a/src/Query/Query.php b/src/Query/Query.php index a1c94ba..11c831d 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -112,6 +112,41 @@ class Query public const TYPE_ELEM_MATCH = 'elemMatch'; + // Aggregation methods + public const TYPE_COUNT = 'count'; + + public const TYPE_SUM = 'sum'; + + public const TYPE_AVG = 'avg'; + + public const TYPE_MIN = 'min'; + + public const TYPE_MAX = 'max'; + + public const TYPE_GROUP_BY = 'groupBy'; + + public const TYPE_HAVING = 'having'; + + // Distinct + public const TYPE_DISTINCT = 'distinct'; + + // Join methods + public const TYPE_JOIN = 'join'; + + public const TYPE_LEFT_JOIN = 'leftJoin'; + + public const TYPE_RIGHT_JOIN = 'rightJoin'; + + public const TYPE_CROSS_JOIN = 'crossJoin'; + + // Union + public const TYPE_UNION = 'union'; + + public const TYPE_UNION_ALL = 'unionAll'; + + // Raw + public const TYPE_RAW = 'raw'; + public const DEFAULT_ALIAS = 'main'; // Order direction constants (inlined from Database) @@ -176,6 +211,36 @@ class Query self::TYPE_CONTAINS_ALL, self::TYPE_ELEM_MATCH, self::TYPE_REGEX, + self::TYPE_COUNT, + self::TYPE_SUM, + self::TYPE_AVG, + self::TYPE_MIN, + self::TYPE_MAX, + self::TYPE_GROUP_BY, + self::TYPE_HAVING, + self::TYPE_DISTINCT, + self::TYPE_JOIN, + self::TYPE_LEFT_JOIN, + self::TYPE_RIGHT_JOIN, + self::TYPE_CROSS_JOIN, + self::TYPE_UNION, + self::TYPE_UNION_ALL, + self::TYPE_RAW, + ]; + + public const AGGREGATE_TYPES = [ + self::TYPE_COUNT, + self::TYPE_SUM, + self::TYPE_AVG, + self::TYPE_MIN, + self::TYPE_MAX, + ]; + + public const JOIN_TYPES = [ + self::TYPE_JOIN, + self::TYPE_LEFT_JOIN, + self::TYPE_RIGHT_JOIN, + self::TYPE_CROSS_JOIN, ]; public const VECTOR_TYPES = [ @@ -188,6 +253,9 @@ class Query self::TYPE_AND, self::TYPE_OR, self::TYPE_ELEM_MATCH, + self::TYPE_HAVING, + self::TYPE_UNION, + self::TYPE_UNION_ALL, ]; protected string $method = ''; @@ -343,7 +411,22 @@ public static function isMethod(string $value): bool self::TYPE_VECTOR_EUCLIDEAN, self::TYPE_EXISTS, self::TYPE_NOT_EXISTS, - self::TYPE_REGEX => true, + self::TYPE_REGEX, + self::TYPE_COUNT, + self::TYPE_SUM, + self::TYPE_AVG, + self::TYPE_MIN, + self::TYPE_MAX, + self::TYPE_GROUP_BY, + self::TYPE_HAVING, + self::TYPE_DISTINCT, + self::TYPE_JOIN, + self::TYPE_LEFT_JOIN, + self::TYPE_RIGHT_JOIN, + self::TYPE_CROSS_JOIN, + self::TYPE_UNION, + self::TYPE_UNION_ALL, + self::TYPE_RAW => true, default => false, }; } @@ -494,6 +577,21 @@ public function compile(Compiler $compiler): string self::TYPE_SELECT => $compiler->compileSelect($this), + self::TYPE_COUNT, + self::TYPE_SUM, + self::TYPE_AVG, + self::TYPE_MIN, + self::TYPE_MAX => $compiler->compileAggregate($this), + + self::TYPE_GROUP_BY => $compiler->compileGroupBy($this), + + self::TYPE_JOIN, + self::TYPE_LEFT_JOIN, + self::TYPE_RIGHT_JOIN, + self::TYPE_CROSS_JOIN => $compiler->compileJoin($this), + + self::TYPE_HAVING => $compiler->compileFilter($this), + default => $compiler->compileFilter($this), }; } @@ -849,6 +947,12 @@ public static function getCursorQueries(array $queries, bool $clone = true): arr * @return array{ * filters: list, * selections: list, + * aggregations: list, + * groupBy: list, + * having: list, + * distinct: bool, + * joins: list, + * unions: list, * limit: int|null, * offset: int|null, * orderAttributes: array, @@ -861,6 +965,12 @@ public static function groupByType(array $queries): array { $filters = []; $selections = []; + $aggregations = []; + $groupBy = []; + $having = []; + $distinct = false; + $joins = []; + $unions = []; $limit = null; $offset = null; $orderAttributes = []; @@ -923,6 +1033,41 @@ public static function groupByType(array $queries): array $selections[] = clone $query; break; + case Query::TYPE_COUNT: + case Query::TYPE_SUM: + case Query::TYPE_AVG: + case Query::TYPE_MIN: + case Query::TYPE_MAX: + $aggregations[] = clone $query; + break; + + case Query::TYPE_GROUP_BY: + /** @var array $values */ + foreach ($values as $col) { + $groupBy[] = $col; + } + break; + + case Query::TYPE_HAVING: + $having[] = clone $query; + break; + + case Query::TYPE_DISTINCT: + $distinct = true; + break; + + case Query::TYPE_JOIN: + case Query::TYPE_LEFT_JOIN: + case Query::TYPE_RIGHT_JOIN: + case Query::TYPE_CROSS_JOIN: + $joins[] = clone $query; + break; + + case Query::TYPE_UNION: + case Query::TYPE_UNION_ALL: + $unions[] = clone $query; + break; + default: $filters[] = clone $query; break; @@ -932,6 +1077,12 @@ public static function groupByType(array $queries): array return [ 'filters' => $filters, 'selections' => $selections, + 'aggregations' => $aggregations, + 'groupBy' => $groupBy, + 'having' => $having, + 'distinct' => $distinct, + 'joins' => $joins, + 'unions' => $unions, 'limit' => $limit, 'offset' => $offset, 'orderAttributes' => $orderAttributes, @@ -1160,4 +1311,250 @@ public static function elemMatch(string $attribute, array $queries): static { return new static(self::TYPE_ELEM_MATCH, $attribute, $queries); } + + // Aggregation factory methods + + public static function count(string $attribute = '*', string $alias = ''): static + { + return new static(self::TYPE_COUNT, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function sum(string $attribute, string $alias = ''): static + { + return new static(self::TYPE_SUM, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function avg(string $attribute, string $alias = ''): static + { + return new static(self::TYPE_AVG, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function min(string $attribute, string $alias = ''): static + { + return new static(self::TYPE_MIN, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function max(string $attribute, string $alias = ''): static + { + return new static(self::TYPE_MAX, $attribute, $alias !== '' ? [$alias] : []); + } + + /** + * @param array $attributes + */ + public static function groupBy(array $attributes): static + { + return new static(self::TYPE_GROUP_BY, '', $attributes); + } + + /** + * @param array $queries + */ + public static function having(array $queries): static + { + return new static(self::TYPE_HAVING, '', $queries); + } + + public static function distinct(): static + { + return new static(self::TYPE_DISTINCT); + } + + // Join factory methods + + public static function join(string $table, string $left, string $right, string $operator = '='): static + { + return new static(self::TYPE_JOIN, $table, [$left, $operator, $right]); + } + + public static function leftJoin(string $table, string $left, string $right, string $operator = '='): static + { + return new static(self::TYPE_LEFT_JOIN, $table, [$left, $operator, $right]); + } + + public static function rightJoin(string $table, string $left, string $right, string $operator = '='): static + { + return new static(self::TYPE_RIGHT_JOIN, $table, [$left, $operator, $right]); + } + + public static function crossJoin(string $table): static + { + return new static(self::TYPE_CROSS_JOIN, $table); + } + + // Union factory methods + + /** + * @param array $queries + */ + public static function union(array $queries): static + { + return new static(self::TYPE_UNION, '', $queries); + } + + /** + * @param array $queries + */ + public static function unionAll(array $queries): static + { + return new static(self::TYPE_UNION_ALL, '', $queries); + } + + // Raw factory method + + /** + * @param array $bindings + */ + public static function raw(string $sql, array $bindings = []): static + { + return new static(self::TYPE_RAW, $sql, $bindings); + } + + // Convenience: page + + /** + * Returns an array of limit and offset queries for page-based pagination + * + * @return array{0: static, 1: static} + */ + public static function page(int $page, int $perPage = 25): array + { + return [ + static::limit($perPage), + static::offset(($page - 1) * $perPage), + ]; + } + + // Static helpers + + /** + * Merge two query arrays. For limit/offset/cursor, values from $queriesB override $queriesA. + * + * @param array $queriesA + * @param array $queriesB + * @return array + */ + public static function merge(array $queriesA, array $queriesB): array + { + $singularTypes = [ + self::TYPE_LIMIT, + self::TYPE_OFFSET, + self::TYPE_CURSOR_AFTER, + self::TYPE_CURSOR_BEFORE, + ]; + + $result = $queriesA; + + foreach ($queriesB as $queryB) { + $method = $queryB->getMethod(); + + if (\in_array($method, $singularTypes, true)) { + // Remove existing queries of the same type from result + $result = \array_values(\array_filter( + $result, + fn (Query $q): bool => $q->getMethod() !== $method + )); + } + + $result[] = $queryB; + } + + return $result; + } + + /** + * Returns queries in A that are not in B (compared by toArray()) + * + * @param array $queriesA + * @param array $queriesB + * @return array + */ + public static function diff(array $queriesA, array $queriesB): array + { + $bArrays = \array_map(fn (Query $q): array => $q->toArray(), $queriesB); + + $result = []; + foreach ($queriesA as $queryA) { + $aArray = $queryA->toArray(); + $found = false; + + foreach ($bArrays as $bArray) { + if ($aArray === $bArray) { + $found = true; + break; + } + } + + if (! $found) { + $result[] = $queryA; + } + } + + return $result; + } + + /** + * Validate queries against allowed attributes + * + * @param array $queries + * @param array $allowedAttributes + * @return array Error messages + */ + public static function validate(array $queries, array $allowedAttributes): array + { + $errors = []; + $skipTypes = [ + self::TYPE_LIMIT, + self::TYPE_OFFSET, + self::TYPE_CURSOR_AFTER, + self::TYPE_CURSOR_BEFORE, + self::TYPE_ORDER_RANDOM, + self::TYPE_DISTINCT, + self::TYPE_SELECT, + self::TYPE_EXISTS, + self::TYPE_NOT_EXISTS, + ]; + + foreach ($queries as $query) { + $method = $query->getMethod(); + + // Recursively validate nested queries + if (\in_array($method, self::LOGICAL_TYPES, true)) { + /** @var array $nested */ + $nested = $query->getValues(); + $errors = \array_merge($errors, static::validate($nested, $allowedAttributes)); + + continue; + } + + if (\in_array($method, $skipTypes, true)) { + continue; + } + + // GROUP_BY stores attributes in values + if ($method === self::TYPE_GROUP_BY) { + /** @var array $columns */ + $columns = $query->getValues(); + foreach ($columns as $col) { + if (! \in_array($col, $allowedAttributes, true)) { + $errors[] = "Invalid attribute \"{$col}\" used in {$method}"; + } + } + + continue; + } + + $attribute = $query->getAttribute(); + + if ($attribute === '' || $attribute === '*') { + continue; + } + + if (! \in_array($attribute, $allowedAttributes, true)) { + $errors[] = "Invalid attribute \"{$attribute}\" used in {$method}"; + } + } + + return $errors; + } } From d0094ba0bf68ab718308a0724322c3edb6970d6d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 4 Mar 2026 23:37:56 +1300 Subject: [PATCH 05/29] (feat): Add Builder support for aggregations, joins, distinct, union, raw, and convenience methods --- src/Query/Builder.php | 294 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 290 insertions(+), 4 deletions(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 018abb6..78be942 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -18,6 +18,11 @@ class Builder implements Compiler */ protected array $bindings = []; + /** + * @var array}> + */ + protected array $unions = []; + private string $wrapChar = '`'; private ?Closure $attributeResolver = null; @@ -179,6 +184,166 @@ public function addConditionProvider(Closure $provider): static return $this; } + // ── Aggregation fluent API ── + + public function count(string $attribute = '*', string $alias = ''): static + { + $this->pendingQueries[] = Query::count($attribute, $alias); + + return $this; + } + + public function sum(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::sum($attribute, $alias); + + return $this; + } + + public function avg(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::avg($attribute, $alias); + + return $this; + } + + public function min(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::min($attribute, $alias); + + return $this; + } + + public function max(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::max($attribute, $alias); + + return $this; + } + + /** + * @param array $columns + */ + public function groupBy(array $columns): static + { + $this->pendingQueries[] = Query::groupBy($columns); + + return $this; + } + + /** + * @param array $queries + */ + public function having(array $queries): static + { + $this->pendingQueries[] = Query::having($queries); + + return $this; + } + + public function distinct(): static + { + $this->pendingQueries[] = Query::distinct(); + + return $this; + } + + // ── Join fluent API ── + + public function join(string $table, string $left, string $right, string $operator = '='): static + { + $this->pendingQueries[] = Query::join($table, $left, $right, $operator); + + return $this; + } + + public function leftJoin(string $table, string $left, string $right, string $operator = '='): static + { + $this->pendingQueries[] = Query::leftJoin($table, $left, $right, $operator); + + return $this; + } + + public function rightJoin(string $table, string $left, string $right, string $operator = '='): static + { + $this->pendingQueries[] = Query::rightJoin($table, $left, $right, $operator); + + return $this; + } + + public function crossJoin(string $table): static + { + $this->pendingQueries[] = Query::crossJoin($table); + + return $this; + } + + // ── Union fluent API ── + + public function union(Builder $other): static + { + $result = $other->build(); + $this->unions[] = [ + 'type' => 'UNION', + 'query' => $result['query'], + 'bindings' => $result['bindings'], + ]; + + return $this; + } + + public function unionAll(Builder $other): static + { + $result = $other->build(); + $this->unions[] = [ + 'type' => 'UNION ALL', + 'query' => $result['query'], + 'bindings' => $result['bindings'], + ]; + + return $this; + } + + // ── Convenience methods ── + + public function when(bool $condition, Closure $callback): static + { + if ($condition) { + $callback($this); + } + + return $this; + } + + public function page(int $page, int $perPage = 25): static + { + $this->pendingQueries[] = Query::limit($perPage); + $this->pendingQueries[] = Query::offset(($page - 1) * $perPage); + + return $this; + } + + public function toRawSql(): string + { + $result = $this->build(); + $sql = $result['query']; + + foreach ($result['bindings'] as $binding) { + if (\is_string($binding)) { + $value = "'" . $binding . "'"; + } elseif (\is_int($binding) || \is_float($binding)) { + $value = (string) $binding; + } elseif (\is_bool($binding)) { + $value = $binding ? '1' : '0'; + } else { + $value = 'NULL'; + } + $sql = \preg_replace('/\?/', $value, $sql, 1) ?? $sql; + } + + return $sql; + } + /** * Build the query and bindings from accumulated state * @@ -193,15 +358,33 @@ public function build(): array $parts = []; // SELECT - $selectSQL = '*'; + $selectParts = []; + + if (! empty($grouped['aggregations'])) { + foreach ($grouped['aggregations'] as $agg) { + $selectParts[] = $this->compileAggregate($agg); + } + } + if (! empty($grouped['selections'])) { - $selectSQL = $this->compileSelect($grouped['selections'][0]); + $selectParts[] = $this->compileSelect($grouped['selections'][0]); } - $parts[] = 'SELECT ' . $selectSQL; + + $selectSQL = ! empty($selectParts) ? \implode(', ', $selectParts) : '*'; + + $selectKeyword = $grouped['distinct'] ? 'SELECT DISTINCT' : 'SELECT'; + $parts[] = $selectKeyword . ' ' . $selectSQL; // FROM $parts[] = 'FROM ' . $this->wrapIdentifier($this->table); + // JOINS + if (! empty($grouped['joins'])) { + foreach ($grouped['joins'] as $joinQuery) { + $parts[] = $this->compileJoin($joinQuery); + } + } + // WHERE $whereClauses = []; @@ -240,6 +423,29 @@ public function build(): array $parts[] = 'WHERE ' . \implode(' AND ', $whereClauses); } + // GROUP BY + if (! empty($grouped['groupBy'])) { + $groupByCols = \array_map( + fn (string $col): string => $this->resolveAndWrap($col), + $grouped['groupBy'] + ); + $parts[] = 'GROUP BY ' . \implode(', ', $groupByCols); + } + + // HAVING + if (! empty($grouped['having'])) { + $havingClauses = []; + foreach ($grouped['having'] as $havingQuery) { + foreach ($havingQuery->getValues() as $subQuery) { + /** @var Query $subQuery */ + $havingClauses[] = $this->compileFilter($subQuery); + } + } + if (! empty($havingClauses)) { + $parts[] = 'HAVING ' . \implode(' AND ', $havingClauses); + } + } + // ORDER BY $orderClauses = []; $orderQueries = Query::getByType($this->pendingQueries, [ @@ -266,8 +472,18 @@ public function build(): array $this->addBinding($grouped['offset']); } + $sql = \implode(' ', $parts); + + // UNION + foreach ($this->unions as $union) { + $sql .= ' ' . $union['type'] . ' ' . $union['query']; + foreach ($union['bindings'] as $binding) { + $this->addBinding($binding); + } + } + return [ - 'query' => \implode(' ', $parts), + 'query' => $sql, 'bindings' => $this->bindings, ]; } @@ -290,6 +506,7 @@ public function reset(): static $this->pendingQueries = []; $this->bindings = []; $this->table = ''; + $this->unions = []; return $this; } @@ -326,8 +543,10 @@ public function compileFilter(Query $query): string Query::TYPE_IS_NOT_NULL => $attribute . ' IS NOT NULL', Query::TYPE_AND => $this->compileLogical($query, 'AND'), Query::TYPE_OR => $this->compileLogical($query, 'OR'), + Query::TYPE_HAVING => $this->compileLogical($query, 'AND'), Query::TYPE_EXISTS => $this->compileExists($query), Query::TYPE_NOT_EXISTS => $this->compileNotExists($query), + Query::TYPE_RAW => $this->compileRaw($query), default => throw new Exception('Unsupported filter type: ' . $method), }; } @@ -378,6 +597,64 @@ public function compileCursor(Query $query): string return '_cursor ' . $operator . ' ?'; } + public function compileAggregate(Query $query): string + { + $func = \strtoupper($query->getMethod()); + $attr = $query->getAttribute(); + $col = $attr === '*' ? '*' : $this->resolveAndWrap($attr); + /** @var string $alias */ + $alias = $query->getValue(''); + $sql = $func . '(' . $col . ')'; + + if ($alias !== '') { + $sql .= ' AS ' . $this->wrapIdentifier($alias); + } + + return $sql; + } + + public function compileGroupBy(Query $query): string + { + /** @var array $values */ + $values = $query->getValues(); + $columns = \array_map( + fn (string $col): string => $this->resolveAndWrap($col), + $values + ); + + return \implode(', ', $columns); + } + + public function compileJoin(Query $query): string + { + $type = match ($query->getMethod()) { + Query::TYPE_JOIN => 'JOIN', + Query::TYPE_LEFT_JOIN => 'LEFT JOIN', + Query::TYPE_RIGHT_JOIN => 'RIGHT JOIN', + Query::TYPE_CROSS_JOIN => 'CROSS JOIN', + default => throw new Exception('Unsupported join type: ' . $query->getMethod()), + }; + + $table = $this->wrapIdentifier($query->getAttribute()); + $values = $query->getValues(); + + if (empty($values)) { + return $type . ' ' . $table; + } + + /** @var string $leftCol */ + $leftCol = $values[0]; + /** @var string $operator */ + $operator = $values[1]; + /** @var string $rightCol */ + $rightCol = $values[2]; + + $left = $this->resolveAndWrap($leftCol); + $right = $this->resolveAndWrap($rightCol); + + return $type . ' ' . $table . ' ON ' . $left . ' ' . $operator . ' ' . $right; + } + // ── Protected (overridable) ── protected function resolveAttribute(string $attribute): string @@ -587,4 +864,13 @@ private function compileNotExists(Query $query): string return '(' . \implode(' AND ', $parts) . ')'; } + + private function compileRaw(Query $query): string + { + foreach ($query->getValues() as $binding) { + $this->addBinding($binding); + } + + return $query->getAttribute(); + } } From 8ecb2b831958d87c30362f9a0cfa8a6fbd5a4f2e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 4 Mar 2026 23:38:03 +1300 Subject: [PATCH 06/29] (test): Add tests for aggregations, joins, distinct, union, raw, and query helpers --- tests/Query/AggregationQueryTest.php | 97 +++++++ tests/Query/BuilderTest.php | 388 +++++++++++++++++++++++++++ tests/Query/JoinQueryTest.php | 55 ++++ tests/Query/QueryHelperTest.php | 239 +++++++++++++++++ tests/Query/QueryParseTest.php | 87 ++++++ tests/Query/QueryTest.php | 69 +++++ 6 files changed, 935 insertions(+) create mode 100644 tests/Query/AggregationQueryTest.php create mode 100644 tests/Query/JoinQueryTest.php diff --git a/tests/Query/AggregationQueryTest.php b/tests/Query/AggregationQueryTest.php new file mode 100644 index 0000000..7962589 --- /dev/null +++ b/tests/Query/AggregationQueryTest.php @@ -0,0 +1,97 @@ +assertEquals(Query::TYPE_COUNT, $query->getMethod()); + $this->assertEquals('*', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testCountWithAttribute(): void + { + $query = Query::count('id'); + $this->assertEquals(Query::TYPE_COUNT, $query->getMethod()); + $this->assertEquals('id', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testCountWithAlias(): void + { + $query = Query::count('*', 'total'); + $this->assertEquals('*', $query->getAttribute()); + $this->assertEquals(['total'], $query->getValues()); + $this->assertEquals('total', $query->getValue()); + } + + public function testSum(): void + { + $query = Query::sum('price'); + $this->assertEquals(Query::TYPE_SUM, $query->getMethod()); + $this->assertEquals('price', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testSumWithAlias(): void + { + $query = Query::sum('price', 'total_price'); + $this->assertEquals(['total_price'], $query->getValues()); + } + + public function testAvg(): void + { + $query = Query::avg('score'); + $this->assertEquals(Query::TYPE_AVG, $query->getMethod()); + $this->assertEquals('score', $query->getAttribute()); + } + + public function testMin(): void + { + $query = Query::min('price'); + $this->assertEquals(Query::TYPE_MIN, $query->getMethod()); + $this->assertEquals('price', $query->getAttribute()); + } + + public function testMax(): void + { + $query = Query::max('price'); + $this->assertEquals(Query::TYPE_MAX, $query->getMethod()); + $this->assertEquals('price', $query->getAttribute()); + } + + public function testGroupBy(): void + { + $query = Query::groupBy(['status', 'country']); + $this->assertEquals(Query::TYPE_GROUP_BY, $query->getMethod()); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals(['status', 'country'], $query->getValues()); + } + + public function testHaving(): void + { + $inner = [ + Query::greaterThan('count', 5), + ]; + $query = Query::having($inner); + $this->assertEquals(Query::TYPE_HAVING, $query->getMethod()); + $this->assertCount(1, $query->getValues()); + $this->assertInstanceOf(Query::class, $query->getValues()[0]); + } + + public function testAggregateTypesConstant(): void + { + $this->assertContains(Query::TYPE_COUNT, Query::AGGREGATE_TYPES); + $this->assertContains(Query::TYPE_SUM, Query::AGGREGATE_TYPES); + $this->assertContains(Query::TYPE_AVG, Query::AGGREGATE_TYPES); + $this->assertContains(Query::TYPE_MIN, Query::AGGREGATE_TYPES); + $this->assertContains(Query::TYPE_MAX, Query::AGGREGATE_TYPES); + $this->assertCount(5, Query::AGGREGATE_TYPES); + } +} diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index d2033b0..e286909 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -669,4 +669,392 @@ public function testDefaultSelectStar(): void $this->assertEquals('SELECT * FROM `t`', $result['query']); } + + // ── Aggregations ── + + public function testCountStar(): void + { + $result = (new Builder()) + ->from('t') + ->count() + ->build(); + + $this->assertEquals('SELECT COUNT(*) FROM `t`', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testCountWithAlias(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->build(); + + $this->assertEquals('SELECT COUNT(*) AS `total` FROM `t`', $result['query']); + } + + public function testSumColumn(): void + { + $result = (new Builder()) + ->from('orders') + ->sum('price', 'total_price') + ->build(); + + $this->assertEquals('SELECT SUM(`price`) AS `total_price` FROM `orders`', $result['query']); + } + + public function testAvgColumn(): void + { + $result = (new Builder()) + ->from('t') + ->avg('score') + ->build(); + + $this->assertEquals('SELECT AVG(`score`) FROM `t`', $result['query']); + } + + public function testMinColumn(): void + { + $result = (new Builder()) + ->from('t') + ->min('price') + ->build(); + + $this->assertEquals('SELECT MIN(`price`) FROM `t`', $result['query']); + } + + public function testMaxColumn(): void + { + $result = (new Builder()) + ->from('t') + ->max('price') + ->build(); + + $this->assertEquals('SELECT MAX(`price`) FROM `t`', $result['query']); + } + + public function testAggregationWithSelection(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->select(['status']) + ->groupBy(['status']) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total`, `status` FROM `orders` GROUP BY `status`', + $result['query'] + ); + } + + // ── Group By ── + + public function testGroupBy(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['status']) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`', + $result['query'] + ); + } + + public function testGroupByMultiple(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['status', 'country']) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`, `country`', + $result['query'] + ); + } + + // ── Having ── + + public function testHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['status']) + ->having([Query::greaterThan('total', 5)]) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status` HAVING `total` > ?', + $result['query'] + ); + $this->assertEquals([5], $result['bindings']); + } + + // ── Distinct ── + + public function testDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['status']) + ->build(); + + $this->assertEquals('SELECT DISTINCT `status` FROM `t`', $result['query']); + } + + public function testDistinctStar(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->build(); + + $this->assertEquals('SELECT DISTINCT * FROM `t`', $result['query']); + } + + // ── Joins ── + + public function testJoin(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` JOIN `orders` ON `users.id` = `orders.user_id`', + $result['query'] + ); + } + + public function testLeftJoin(): void + { + $result = (new Builder()) + ->from('users') + ->leftJoin('profiles', 'users.id', 'profiles.user_id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` LEFT JOIN `profiles` ON `users.id` = `profiles.user_id`', + $result['query'] + ); + } + + public function testRightJoin(): void + { + $result = (new Builder()) + ->from('users') + ->rightJoin('orders', 'users.id', 'orders.user_id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` RIGHT JOIN `orders` ON `users.id` = `orders.user_id`', + $result['query'] + ); + } + + public function testCrossJoin(): void + { + $result = (new Builder()) + ->from('sizes') + ->crossJoin('colors') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `sizes` CROSS JOIN `colors`', + $result['query'] + ); + } + + public function testJoinWithFilter(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->filter([Query::greaterThan('orders.total', 100)]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` JOIN `orders` ON `users.id` = `orders.user_id` WHERE `orders.total` > ?', + $result['query'] + ); + $this->assertEquals([100], $result['bindings']); + } + + // ── Raw ── + + public function testRawFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('score > ? AND score < ?', [10, 100])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE score > ? AND score < ?', $result['query']); + $this->assertEquals([10, 100], $result['bindings']); + } + + public function testRawFilterNoBindings(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('1 = 1')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + // ── Union ── + + public function testUnion(): void + { + $admins = (new Builder())->from('admins')->filter([Query::equal('role', ['admin'])]); + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->union($admins) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` WHERE `status` IN (?) UNION SELECT * FROM `admins` WHERE `role` IN (?)', + $result['query'] + ); + $this->assertEquals(['active', 'admin'], $result['bindings']); + } + + public function testUnionAll(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('current') + ->unionAll($other) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `current` UNION ALL SELECT * FROM `archive`', + $result['query'] + ); + } + + // ── when() ── + + public function testWhenTrue(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?)', $result['query']); + $this->assertEquals(['active'], $result['bindings']); + } + + public function testWhenFalse(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->build(); + + $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + // ── page() ── + + public function testPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(3, 10) + ->build(); + + $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result['query']); + $this->assertEquals([10, 20], $result['bindings']); + } + + public function testPageDefaultPerPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(1) + ->build(); + + $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result['query']); + $this->assertEquals([25, 0], $result['bindings']); + } + + // ── toRawSql() ── + + public function testToRawSql(): void + { + $sql = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->toRawSql(); + + $this->assertEquals( + "SELECT * FROM `users` WHERE `status` IN ('active') LIMIT 10", + $sql + ); + } + + public function testToRawSqlNumericBindings(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('age', 18)]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `t` WHERE `age` > 18", $sql); + } + + // ── Combined complex query ── + + public function testCombinedAggregationJoinGroupByHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'order_count') + ->sum('total', 'total_amount') + ->select(['users.name']) + ->join('users', 'orders.user_id', 'users.id') + ->groupBy(['users.name']) + ->having([Query::greaterThan('order_count', 5)]) + ->sortDesc('total_amount') + ->limit(10) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `order_count`, SUM(`total`) AS `total_amount`, `users.name` FROM `orders` JOIN `users` ON `orders.user_id` = `users.id` GROUP BY `users.name` HAVING `order_count` > ? ORDER BY `total_amount` DESC LIMIT ?', + $result['query'] + ); + $this->assertEquals([5, 10], $result['bindings']); + } + + // ── Reset clears unions ── + + public function testResetClearsUnions(): void + { + $other = (new Builder())->from('archive'); + $builder = (new Builder()) + ->from('current') + ->union($other); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('fresh')->build(); + + $this->assertEquals('SELECT * FROM `fresh`', $result['query']); + } } diff --git a/tests/Query/JoinQueryTest.php b/tests/Query/JoinQueryTest.php new file mode 100644 index 0000000..13197d8 --- /dev/null +++ b/tests/Query/JoinQueryTest.php @@ -0,0 +1,55 @@ +assertEquals(Query::TYPE_JOIN, $query->getMethod()); + $this->assertEquals('orders', $query->getAttribute()); + $this->assertEquals(['users.id', '=', 'orders.user_id'], $query->getValues()); + } + + public function testJoinWithOperator(): void + { + $query = Query::join('orders', 'users.id', 'orders.user_id', '!='); + $this->assertEquals(['users.id', '!=', 'orders.user_id'], $query->getValues()); + } + + public function testLeftJoin(): void + { + $query = Query::leftJoin('profiles', 'users.id', 'profiles.user_id'); + $this->assertEquals(Query::TYPE_LEFT_JOIN, $query->getMethod()); + $this->assertEquals('profiles', $query->getAttribute()); + $this->assertEquals(['users.id', '=', 'profiles.user_id'], $query->getValues()); + } + + public function testRightJoin(): void + { + $query = Query::rightJoin('orders', 'users.id', 'orders.user_id'); + $this->assertEquals(Query::TYPE_RIGHT_JOIN, $query->getMethod()); + $this->assertEquals('orders', $query->getAttribute()); + } + + public function testCrossJoin(): void + { + $query = Query::crossJoin('colors'); + $this->assertEquals(Query::TYPE_CROSS_JOIN, $query->getMethod()); + $this->assertEquals('colors', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testJoinTypesConstant(): void + { + $this->assertContains(Query::TYPE_JOIN, Query::JOIN_TYPES); + $this->assertContains(Query::TYPE_LEFT_JOIN, Query::JOIN_TYPES); + $this->assertContains(Query::TYPE_RIGHT_JOIN, Query::JOIN_TYPES); + $this->assertContains(Query::TYPE_CROSS_JOIN, Query::JOIN_TYPES); + $this->assertCount(4, Query::JOIN_TYPES); + } +} diff --git a/tests/Query/QueryHelperTest.php b/tests/Query/QueryHelperTest.php index 22807f4..ed09501 100644 --- a/tests/Query/QueryHelperTest.php +++ b/tests/Query/QueryHelperTest.php @@ -35,6 +35,21 @@ public function testIsMethodValid(): void $this->assertTrue(Query::isMethod('containsAll')); $this->assertTrue(Query::isMethod('elemMatch')); $this->assertTrue(Query::isMethod('regex')); + $this->assertTrue(Query::isMethod('count')); + $this->assertTrue(Query::isMethod('sum')); + $this->assertTrue(Query::isMethod('avg')); + $this->assertTrue(Query::isMethod('min')); + $this->assertTrue(Query::isMethod('max')); + $this->assertTrue(Query::isMethod('groupBy')); + $this->assertTrue(Query::isMethod('having')); + $this->assertTrue(Query::isMethod('distinct')); + $this->assertTrue(Query::isMethod('join')); + $this->assertTrue(Query::isMethod('leftJoin')); + $this->assertTrue(Query::isMethod('rightJoin')); + $this->assertTrue(Query::isMethod('crossJoin')); + $this->assertTrue(Query::isMethod('union')); + $this->assertTrue(Query::isMethod('unionAll')); + $this->assertTrue(Query::isMethod('raw')); } public function testIsMethodInvalid(): void @@ -266,4 +281,228 @@ public function testGroupByTypeSkipsNonQueryInstances(): void $grouped = Query::groupByType(['not a query', null, 42]); $this->assertEquals([], $grouped['filters']); } + + // ── groupByType with new types ── + + public function testGroupByTypeAggregations(): void + { + $queries = [ + Query::count('*', 'total'), + Query::sum('price'), + Query::avg('score'), + Query::min('age'), + Query::max('salary'), + ]; + + $grouped = Query::groupByType($queries); + $this->assertCount(5, $grouped['aggregations']); + $this->assertEquals(Query::TYPE_COUNT, $grouped['aggregations'][0]->getMethod()); + $this->assertEquals(Query::TYPE_MAX, $grouped['aggregations'][4]->getMethod()); + } + + public function testGroupByTypeGroupBy(): void + { + $queries = [Query::groupBy(['status', 'country'])]; + $grouped = Query::groupByType($queries); + $this->assertEquals(['status', 'country'], $grouped['groupBy']); + } + + public function testGroupByTypeHaving(): void + { + $queries = [Query::having([Query::greaterThan('total', 5)])]; + $grouped = Query::groupByType($queries); + $this->assertCount(1, $grouped['having']); + $this->assertEquals(Query::TYPE_HAVING, $grouped['having'][0]->getMethod()); + } + + public function testGroupByTypeDistinct(): void + { + $queries = [Query::distinct()]; + $grouped = Query::groupByType($queries); + $this->assertTrue($grouped['distinct']); + } + + public function testGroupByTypeDistinctDefaultFalse(): void + { + $grouped = Query::groupByType([]); + $this->assertFalse($grouped['distinct']); + } + + public function testGroupByTypeJoins(): void + { + $queries = [ + Query::join('orders', 'users.id', 'orders.user_id'), + Query::leftJoin('profiles', 'users.id', 'profiles.user_id'), + Query::crossJoin('colors'), + ]; + $grouped = Query::groupByType($queries); + $this->assertCount(3, $grouped['joins']); + $this->assertEquals(Query::TYPE_JOIN, $grouped['joins'][0]->getMethod()); + $this->assertEquals(Query::TYPE_CROSS_JOIN, $grouped['joins'][2]->getMethod()); + } + + public function testGroupByTypeUnions(): void + { + $queries = [ + Query::union([Query::equal('x', [1])]), + Query::unionAll([Query::equal('y', [2])]), + ]; + $grouped = Query::groupByType($queries); + $this->assertCount(2, $grouped['unions']); + } + + // ── merge() ── + + public function testMergeConcatenates(): void + { + $a = [Query::equal('name', ['John'])]; + $b = [Query::greaterThan('age', 18)]; + + $result = Query::merge($a, $b); + $this->assertCount(2, $result); + $this->assertEquals('equal', $result[0]->getMethod()); + $this->assertEquals('greaterThan', $result[1]->getMethod()); + } + + public function testMergeLimitOverrides(): void + { + $a = [Query::limit(10)]; + $b = [Query::limit(50)]; + + $result = Query::merge($a, $b); + $this->assertCount(1, $result); + $this->assertEquals(50, $result[0]->getValue()); + } + + public function testMergeOffsetOverrides(): void + { + $a = [Query::offset(5), Query::equal('x', [1])]; + $b = [Query::offset(100)]; + + $result = Query::merge($a, $b); + $this->assertCount(2, $result); + // equal stays, offset replaced + $this->assertEquals('equal', $result[0]->getMethod()); + $this->assertEquals(100, $result[1]->getValue()); + } + + public function testMergeCursorOverrides(): void + { + $a = [Query::cursorAfter('abc')]; + $b = [Query::cursorAfter('xyz')]; + + $result = Query::merge($a, $b); + $this->assertCount(1, $result); + $this->assertEquals('xyz', $result[0]->getValue()); + } + + // ── diff() ── + + public function testDiffReturnsUnique(): void + { + $shared = Query::equal('name', ['John']); + $a = [$shared, Query::greaterThan('age', 18)]; + $b = [$shared]; + + $result = Query::diff($a, $b); + $this->assertCount(1, $result); + $this->assertEquals('greaterThan', $result[0]->getMethod()); + } + + public function testDiffEmpty(): void + { + $q = Query::equal('x', [1]); + $result = Query::diff([$q], [$q]); + $this->assertCount(0, $result); + } + + public function testDiffNoOverlap(): void + { + $a = [Query::equal('x', [1])]; + $b = [Query::equal('y', [2])]; + $result = Query::diff($a, $b); + $this->assertCount(1, $result); + } + + // ── validate() ── + + public function testValidatePassesAllowed(): void + { + $queries = [ + Query::equal('name', ['John']), + Query::greaterThan('age', 18), + ]; + $errors = Query::validate($queries, ['name', 'age']); + $this->assertCount(0, $errors); + } + + public function testValidateFailsInvalid(): void + { + $queries = [ + Query::equal('name', ['John']), + Query::greaterThan('secret', 42), + ]; + $errors = Query::validate($queries, ['name', 'age']); + $this->assertCount(1, $errors); + $this->assertStringContainsString('secret', $errors[0]); + } + + public function testValidateSkipsNoAttribute(): void + { + $queries = [ + Query::limit(10), + Query::offset(5), + Query::distinct(), + Query::orderRandom(), + ]; + $errors = Query::validate($queries, []); + $this->assertCount(0, $errors); + } + + public function testValidateRecursesNested(): void + { + $queries = [ + Query::or([ + Query::equal('name', ['John']), + Query::equal('invalid', ['x']), + ]), + ]; + $errors = Query::validate($queries, ['name']); + $this->assertCount(1, $errors); + $this->assertStringContainsString('invalid', $errors[0]); + } + + public function testValidateGroupByColumns(): void + { + $queries = [Query::groupBy(['status', 'bad_col'])]; + $errors = Query::validate($queries, ['status']); + $this->assertCount(1, $errors); + $this->assertStringContainsString('bad_col', $errors[0]); + } + + public function testValidateSkipsStar(): void + { + $queries = [Query::count()]; // attribute = '*' + $errors = Query::validate($queries, []); + $this->assertCount(0, $errors); + } + + // ── page() static helper ── + + public function testPageStaticHelper(): void + { + $result = Query::page(3, 10); + $this->assertCount(2, $result); + $this->assertEquals(Query::TYPE_LIMIT, $result[0]->getMethod()); + $this->assertEquals(10, $result[0]->getValue()); + $this->assertEquals(Query::TYPE_OFFSET, $result[1]->getMethod()); + $this->assertEquals(20, $result[1]->getValue()); + } + + public function testPageStaticHelperFirstPage(): void + { + $result = Query::page(1); + $this->assertEquals(25, $result[0]->getValue()); + $this->assertEquals(0, $result[1]->getValue()); + } } diff --git a/tests/Query/QueryParseTest.php b/tests/Query/QueryParseTest.php index 39df897..0a66b41 100644 --- a/tests/Query/QueryParseTest.php +++ b/tests/Query/QueryParseTest.php @@ -187,4 +187,91 @@ public function testRoundTripNestedParseSerialization(): void $this->assertCount(2, $parsed->getValues()); $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); } + + // ── Round-trip tests for new types ── + + public function testRoundTripCount(): void + { + $original = Query::count('id', 'total'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('count', $parsed->getMethod()); + $this->assertEquals('id', $parsed->getAttribute()); + $this->assertEquals(['total'], $parsed->getValues()); + } + + public function testRoundTripSum(): void + { + $original = Query::sum('price'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('sum', $parsed->getMethod()); + $this->assertEquals('price', $parsed->getAttribute()); + } + + public function testRoundTripGroupBy(): void + { + $original = Query::groupBy(['status', 'country']); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('groupBy', $parsed->getMethod()); + $this->assertEquals(['status', 'country'], $parsed->getValues()); + } + + public function testRoundTripHaving(): void + { + $original = Query::having([Query::greaterThan('total', 5)]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('having', $parsed->getMethod()); + $this->assertCount(1, $parsed->getValues()); + $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); + } + + public function testRoundTripDistinct(): void + { + $original = Query::distinct(); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('distinct', $parsed->getMethod()); + } + + public function testRoundTripJoin(): void + { + $original = Query::join('orders', 'users.id', 'orders.user_id'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('join', $parsed->getMethod()); + $this->assertEquals('orders', $parsed->getAttribute()); + $this->assertEquals(['users.id', '=', 'orders.user_id'], $parsed->getValues()); + } + + public function testRoundTripCrossJoin(): void + { + $original = Query::crossJoin('colors'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('crossJoin', $parsed->getMethod()); + $this->assertEquals('colors', $parsed->getAttribute()); + } + + public function testRoundTripRaw(): void + { + $original = Query::raw('score > ?', [10]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('raw', $parsed->getMethod()); + $this->assertEquals('score > ?', $parsed->getAttribute()); + $this->assertEquals([10], $parsed->getValues()); + } + + public function testRoundTripUnion(): void + { + $original = Query::union([Query::equal('x', [1])]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('union', $parsed->getMethod()); + $this->assertCount(1, $parsed->getValues()); + $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); + } } diff --git a/tests/Query/QueryTest.php b/tests/Query/QueryTest.php index 1fb05bd..a9fd425 100644 --- a/tests/Query/QueryTest.php +++ b/tests/Query/QueryTest.php @@ -138,4 +138,73 @@ public function testEmptyValues(): void $query = Query::equal('name', []); $this->assertEquals([], $query->getValues()); } + + public function testTypesConstantContainsNewTypes(): void + { + $this->assertContains(Query::TYPE_COUNT, Query::TYPES); + $this->assertContains(Query::TYPE_SUM, Query::TYPES); + $this->assertContains(Query::TYPE_AVG, Query::TYPES); + $this->assertContains(Query::TYPE_MIN, Query::TYPES); + $this->assertContains(Query::TYPE_MAX, Query::TYPES); + $this->assertContains(Query::TYPE_GROUP_BY, Query::TYPES); + $this->assertContains(Query::TYPE_HAVING, Query::TYPES); + $this->assertContains(Query::TYPE_DISTINCT, Query::TYPES); + $this->assertContains(Query::TYPE_JOIN, Query::TYPES); + $this->assertContains(Query::TYPE_LEFT_JOIN, Query::TYPES); + $this->assertContains(Query::TYPE_RIGHT_JOIN, Query::TYPES); + $this->assertContains(Query::TYPE_CROSS_JOIN, Query::TYPES); + $this->assertContains(Query::TYPE_UNION, Query::TYPES); + $this->assertContains(Query::TYPE_UNION_ALL, Query::TYPES); + $this->assertContains(Query::TYPE_RAW, Query::TYPES); + } + + public function testIsMethodNewTypes(): void + { + $this->assertTrue(Query::isMethod('count')); + $this->assertTrue(Query::isMethod('sum')); + $this->assertTrue(Query::isMethod('avg')); + $this->assertTrue(Query::isMethod('min')); + $this->assertTrue(Query::isMethod('max')); + $this->assertTrue(Query::isMethod('groupBy')); + $this->assertTrue(Query::isMethod('having')); + $this->assertTrue(Query::isMethod('distinct')); + $this->assertTrue(Query::isMethod('join')); + $this->assertTrue(Query::isMethod('leftJoin')); + $this->assertTrue(Query::isMethod('rightJoin')); + $this->assertTrue(Query::isMethod('crossJoin')); + $this->assertTrue(Query::isMethod('union')); + $this->assertTrue(Query::isMethod('unionAll')); + $this->assertTrue(Query::isMethod('raw')); + } + + public function testDistinctFactory(): void + { + $query = Query::distinct(); + $this->assertEquals(Query::TYPE_DISTINCT, $query->getMethod()); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testRawFactory(): void + { + $query = Query::raw('score > ?', [10]); + $this->assertEquals(Query::TYPE_RAW, $query->getMethod()); + $this->assertEquals('score > ?', $query->getAttribute()); + $this->assertEquals([10], $query->getValues()); + } + + public function testUnionFactory(): void + { + $inner = [Query::equal('x', [1])]; + $query = Query::union($inner); + $this->assertEquals(Query::TYPE_UNION, $query->getMethod()); + $this->assertCount(1, $query->getValues()); + } + + public function testUnionAllFactory(): void + { + $inner = [Query::equal('x', [1])]; + $query = Query::unionAll($inner); + $this->assertEquals(Query::TYPE_UNION_ALL, $query->getMethod()); + } } From 8febdbe38c0829d865f86d0834cbb3ba5df62603 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 4 Mar 2026 23:38:11 +1300 Subject: [PATCH 07/29] (docs): Add documentation for aggregations, joins, distinct, union, raw, and helpers --- README.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/README.md b/README.md index 39b1bf2..2929b86 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,122 @@ $stmt->execute($result['bindings']); $rows = $stmt->fetchAll(); ``` +**Aggregations** — count, sum, avg, min, max with optional aliases: + +```php +$result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->sum('price', 'total_price') + ->select(['status']) + ->groupBy(['status']) + ->having([Query::greaterThan('total', 5)]) + ->build(); + +// SELECT COUNT(*) AS `total`, SUM(`price`) AS `total_price`, `status` +// FROM `orders` GROUP BY `status` HAVING `total` > ? +``` + +**Distinct:** + +```php +$result = (new Builder()) + ->from('users') + ->distinct() + ->select(['country']) + ->build(); + +// SELECT DISTINCT `country` FROM `users` +``` + +**Joins** — inner, left, right, and cross joins: + +```php +$result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->leftJoin('profiles', 'users.id', 'profiles.user_id') + ->crossJoin('colors') + ->build(); + +// SELECT * FROM `users` +// JOIN `orders` ON `users.id` = `orders.user_id` +// LEFT JOIN `profiles` ON `users.id` = `profiles.user_id` +// CROSS JOIN `colors` +``` + +**Raw expressions:** + +```php +$result = (new Builder()) + ->from('t') + ->filter([Query::raw('score > ? AND score < ?', [10, 100])]) + ->build(); + +// SELECT * FROM `t` WHERE score > ? AND score < ? +// bindings: [10, 100] +``` + +**Union:** + +```php +$admins = (new Builder())->from('admins')->filter([Query::equal('role', ['admin'])]); +$result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->union($admins) + ->build(); + +// SELECT * FROM `users` WHERE `status` IN (?) +// UNION SELECT * FROM `admins` WHERE `role` IN (?) +``` + +**Conditional building** — `when()` applies a callback only when the condition is true: + +```php +$result = (new Builder()) + ->from('users') + ->when($filterActive, fn(Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->build(); +``` + +**Page helper** — page-based pagination: + +```php +$result = (new Builder()) + ->from('users') + ->page(3, 10) // page 3, 10 per page → LIMIT 10 OFFSET 20 + ->build(); +``` + +**Debug** — `toRawSql()` inlines bindings for inspection (not for execution): + +```php +$sql = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->toRawSql(); + +// SELECT * FROM `users` WHERE `status` IN ('active') LIMIT 10 +``` + +**Query helpers** — merge, diff, and validate: + +```php +// Merge queries (later limit/offset/cursor overrides earlier) +$merged = Query::merge($defaultQueries, $userQueries); + +// Diff — queries in A not in B +$unique = Query::diff($queriesA, $queriesB); + +// Validate attributes against an allow-list +$errors = Query::validate($queries, ['name', 'age', 'status']); + +// Page helper — returns [limit, offset] queries +[$limit, $offset] = Query::page(3, 10); +``` + **Pluggable extensions** — customize attribute mapping, identifier wrapping, and inject extra conditions: ```php From 14c655cb726f1d03fd9f5eb6c44b966f1215c7ea Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 5 Mar 2026 10:10:22 +1300 Subject: [PATCH 08/29] fix: address code review findings - Fix compileIn/compileNotIn to return `1 = 0` / `1 = 1` for empty arrays instead of invalid `IN ()` / `NOT IN ()` - Fix NOT MATCH() syntax: wrap in parentheses for valid MySQL (`NOT (MATCH(...) AGAINST(?))`) - Fix toRawSql SQL injection: escape single quotes in string bindings - Fix toRawSql corruption: use substr_replace instead of preg_replace to avoid `?` and `$` in values corrupting output - Fix page(0, n) producing negative offset: clamp to max(0, ...) - Guard compileLogical/compileExists/compileNotExists against empty arrays producing bare `()` - Simplify null tracking in compileIn/compileNotIn with boolean flag Co-Authored-By: Claude Opus 4.6 --- README.md | 93 +- src/Query/Builder.php | 243 +- src/Query/Builder/ClickHouse.php | 133 + src/Query/Builder/SQL.php | 51 + src/Query/Query.php | 6 +- tests/Query/AggregationQueryTest.php | 160 + tests/Query/Builder/ClickHouseTest.php | 5227 +++++++++++++++++++ tests/Query/Builder/SQLTest.php | 6378 ++++++++++++++++++++++++ tests/Query/BuilderTest.php | 1060 ---- tests/Query/JoinQueryTest.php | 87 + tests/Query/QueryHelperTest.php | 383 ++ tests/Query/QueryParseTest.php | 319 ++ tests/Query/QueryTest.php | 219 + 13 files changed, 13186 insertions(+), 1173 deletions(-) create mode 100644 src/Query/Builder/ClickHouse.php create mode 100644 src/Query/Builder/SQL.php create mode 100644 tests/Query/Builder/ClickHouseTest.php create mode 100644 tests/Query/Builder/SQLTest.php delete mode 100644 tests/Query/BuilderTest.php diff --git a/README.md b/README.md index 2929b86..57ed507 100644 --- a/README.md +++ b/README.md @@ -261,12 +261,17 @@ class RedisCompiler implements Compiler This is the pattern used by [utopia-php/database](https://github.com/utopia-php/database) — it implements `Compiler` for each supported database engine, keeping application code fully decoupled from any particular storage backend. -### SQL Builder +### Builder Hierarchy + +The library includes a builder system for generating parameterized queries. The abstract `Builder` base class provides the fluent API and query orchestration, while concrete implementations handle dialect-specific compilation: + +- `Utopia\Query\Builder\SQL` — MySQL/MariaDB/SQLite (backtick quoting, `REGEXP`, `MATCH() AGAINST()`, `RAND()`) +- `Utopia\Query\Builder\ClickHouse` — ClickHouse (backtick quoting, `match()`, `rand()`, `PREWHERE`, `FINAL`, `SAMPLE`) -The library includes a built-in `Builder` class that implements `Compiler` and provides a fluent API for building parameterized SQL queries: +### SQL Builder ```php -use Utopia\Query\Builder; +use Utopia\Query\Builder\SQL as Builder; use Utopia\Query\Query; // Fluent API @@ -447,6 +452,88 @@ $result = (new Builder()) ->build(); ``` +### ClickHouse Builder + +The ClickHouse builder handles ClickHouse-specific SQL dialect differences: + +```php +use Utopia\Query\Builder\ClickHouse as Builder; +use Utopia\Query\Query; +``` + +**FINAL** — force merging of data parts (for ReplacingMergeTree, CollapsingMergeTree, etc.): + +```php +$result = (new Builder()) + ->from('events') + ->final() + ->filter([Query::equal('status', ['active'])]) + ->build(); + +// SELECT * FROM `events` FINAL WHERE `status` IN (?) +``` + +**SAMPLE** — approximate query processing on a fraction of data: + +```php +$result = (new Builder()) + ->from('events') + ->sample(0.1) + ->count('*', 'approx_total') + ->build(); + +// SELECT COUNT(*) AS `approx_total` FROM `events` SAMPLE 0.1 +``` + +**PREWHERE** — filter before reading all columns (major performance optimization for wide tables): + +```php +$result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('event_type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + +// SELECT * FROM `events` PREWHERE `event_type` IN (?) WHERE `count` > ? +``` + +**Combined** — all ClickHouse features work together: + +```php +$result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('event_type', ['purchase'])]) + ->join('users', 'events.user_id', 'users.id') + ->filter([Query::greaterThan('events.amount', 100)]) + ->count('*', 'total') + ->groupBy(['users.country']) + ->sortDesc('total') + ->limit(50) + ->build(); + +// SELECT COUNT(*) AS `total` FROM `events` FINAL SAMPLE 0.1 +// JOIN `users` ON `events.user_id` = `users.id` +// PREWHERE `event_type` IN (?) +// WHERE `events.amount` > ? +// GROUP BY `users.country` +// ORDER BY `total` DESC LIMIT ? +``` + +**Regex** — uses ClickHouse's `match()` function instead of `REGEXP`: + +```php +$result = (new Builder()) + ->from('logs') + ->filter([Query::regex('path', '^/api/v[0-9]+')]) + ->build(); + +// SELECT * FROM `logs` WHERE match(`path`, ?) +``` + +> **Note:** Full-text search (`Query::search()`) is not supported in the ClickHouse builder and will throw an exception. Use `Query::contains()` or a custom full-text index instead. + ## Contributing All code contributions should go through a pull request and be approved by a core developer before being merged. This is to ensure a proper review of all the code. diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 78be942..a55fa43 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -4,7 +4,7 @@ use Closure; -class Builder implements Compiler +abstract class Builder implements Compiler { protected string $table = ''; @@ -23,18 +23,56 @@ class Builder implements Compiler */ protected array $unions = []; - private string $wrapChar = '`'; - - private ?Closure $attributeResolver = null; + protected ?Closure $attributeResolver = null; /** * @var array */ - private array $conditionProviders = []; + protected array $conditionProviders = []; + + // ── Abstract (dialect-specific) ── + + abstract protected function wrapIdentifier(string $identifier): string; + + /** + * Compile a random ordering expression (e.g. RAND() or rand()) + */ + abstract protected function compileRandom(): string; /** - * Set the collection/table name + * Compile a regex filter + * + * @param array $values */ + abstract protected function compileRegex(string $attribute, array $values): string; + + /** + * Compile a full-text search filter + * + * @param array $values + */ + abstract protected function compileSearch(string $attribute, array $values, bool $not): string; + + // ── Hooks (overridable) ── + + protected function buildTableClause(): string + { + return 'FROM ' . $this->wrapIdentifier($this->table); + } + + /** + * Hook called after JOIN clauses, before WHERE. Override to inject e.g. PREWHERE. + * + * @param array $parts + * @param array $grouped + */ + protected function buildAfterJoins(array &$parts, array $grouped): void + { + // no-op by default + } + + // ── Fluent API ── + public function from(string $table): static { $this->table = $table; @@ -43,8 +81,6 @@ public function from(string $table): static } /** - * Add a SELECT clause - * * @param array $columns */ public function select(array $columns): static @@ -55,8 +91,6 @@ public function select(array $columns): static } /** - * Add filter queries - * * @param array $queries */ public function filter(array $queries): static @@ -68,9 +102,6 @@ public function filter(array $queries): static return $this; } - /** - * Add sort ascending - */ public function sortAsc(string $attribute): static { $this->pendingQueries[] = Query::orderAsc($attribute); @@ -78,9 +109,6 @@ public function sortAsc(string $attribute): static return $this; } - /** - * Add sort descending - */ public function sortDesc(string $attribute): static { $this->pendingQueries[] = Query::orderDesc($attribute); @@ -88,9 +116,6 @@ public function sortDesc(string $attribute): static return $this; } - /** - * Add sort random - */ public function sortRandom(): static { $this->pendingQueries[] = Query::orderRandom(); @@ -98,9 +123,6 @@ public function sortRandom(): static return $this; } - /** - * Set LIMIT - */ public function limit(int $value): static { $this->pendingQueries[] = Query::limit($value); @@ -108,9 +130,6 @@ public function limit(int $value): static return $this; } - /** - * Set OFFSET - */ public function offset(int $value): static { $this->pendingQueries[] = Query::offset($value); @@ -118,9 +137,6 @@ public function offset(int $value): static return $this; } - /** - * Set cursor after - */ public function cursorAfter(mixed $value): static { $this->pendingQueries[] = Query::cursorAfter($value); @@ -128,9 +144,6 @@ public function cursorAfter(mixed $value): static return $this; } - /** - * Set cursor before - */ public function cursorBefore(mixed $value): static { $this->pendingQueries[] = Query::cursorBefore($value); @@ -139,8 +152,6 @@ public function cursorBefore(mixed $value): static } /** - * Add multiple queries at once (batch mode) - * * @param array $queries */ public function queries(array $queries): static @@ -152,19 +163,6 @@ public function queries(array $queries): static return $this; } - /** - * Set the wrap character for identifiers - */ - public function setWrapChar(string $char): static - { - $this->wrapChar = $char; - - return $this; - } - - /** - * Set an attribute resolver closure - */ public function setAttributeResolver(Closure $resolver): static { $this->attributeResolver = $resolver; @@ -173,8 +171,6 @@ public function setAttributeResolver(Closure $resolver): static } /** - * Add a condition provider closure - * * @param Closure(string): array{0: string, 1: list} $provider */ public function addConditionProvider(Closure $provider): static @@ -280,7 +276,7 @@ public function crossJoin(string $table): static // ── Union fluent API ── - public function union(Builder $other): static + public function union(self $other): static { $result = $other->build(); $this->unions[] = [ @@ -292,7 +288,7 @@ public function union(Builder $other): static return $this; } - public function unionAll(Builder $other): static + public function unionAll(self $other): static { $result = $other->build(); $this->unions[] = [ @@ -318,7 +314,7 @@ public function when(bool $condition, Closure $callback): static public function page(int $page, int $perPage = 25): static { $this->pendingQueries[] = Query::limit($perPage); - $this->pendingQueries[] = Query::offset(($page - 1) * $perPage); + $this->pendingQueries[] = Query::offset(max(0, ($page - 1) * $perPage)); return $this; } @@ -327,10 +323,11 @@ public function toRawSql(): string { $result = $this->build(); $sql = $result['query']; + $offset = 0; foreach ($result['bindings'] as $binding) { if (\is_string($binding)) { - $value = "'" . $binding . "'"; + $value = "'" . str_replace("'", "''", $binding) . "'"; } elseif (\is_int($binding) || \is_float($binding)) { $value = (string) $binding; } elseif (\is_bool($binding)) { @@ -338,15 +335,18 @@ public function toRawSql(): string } else { $value = 'NULL'; } - $sql = \preg_replace('/\?/', $value, $sql, 1) ?? $sql; + + $pos = \strpos($sql, '?', $offset); + if ($pos !== false) { + $sql = \substr_replace($sql, $value, $pos, 1); + $offset = $pos + \strlen($value); + } } return $sql; } /** - * Build the query and bindings from accumulated state - * * @return array{query: string, bindings: list} */ public function build(): array @@ -376,7 +376,7 @@ public function build(): array $parts[] = $selectKeyword . ' ' . $selectSQL; // FROM - $parts[] = 'FROM ' . $this->wrapIdentifier($this->table); + $parts[] = $this->buildTableClause(); // JOINS if (! empty($grouped['joins'])) { @@ -385,15 +385,16 @@ public function build(): array } } + // Hook: after joins (e.g. ClickHouse PREWHERE) + $this->buildAfterJoins($parts, $grouped); + // WHERE $whereClauses = []; - // Compile filters foreach ($grouped['filters'] as $filter) { $whereClauses[] = $this->compileFilter($filter); } - // Condition providers $providerBindings = []; foreach ($this->conditionProviders as $provider) { /** @var array{0: string, 1: list} $result */ @@ -407,7 +408,6 @@ public function build(): array $this->addBinding($binding); } - // Cursor $cursorSQL = ''; if ($grouped['cursor'] !== null && $grouped['cursorDirection'] !== null) { $cursorQueries = Query::getCursorQueries($this->pendingQueries, false); @@ -489,8 +489,6 @@ public function build(): array } /** - * Get bindings from last build/compile - * * @return list */ public function getBindings(): array @@ -498,9 +496,6 @@ public function getBindings(): array return $this->bindings; } - /** - * Clear all accumulated state for reuse - */ public function reset(): static { $this->pendingQueries = []; @@ -556,7 +551,7 @@ public function compileOrder(Query $query): string return match ($query->getMethod()) { Query::TYPE_ORDER_ASC => $this->resolveAndWrap($query->getAttribute()) . ' ASC', Query::TYPE_ORDER_DESC => $this->resolveAndWrap($query->getAttribute()) . ' DESC', - Query::TYPE_ORDER_RANDOM => 'RAND()', + Query::TYPE_ORDER_RANDOM => $this->compileRandom(), default => throw new Exception('Unsupported order type: ' . $query->getMethod()), }; } @@ -655,7 +650,7 @@ public function compileJoin(Query $query): string return $type . ' ' . $table . ' ON ' . $left . ' ' . $operator . ' ' . $right; } - // ── Protected (overridable) ── + // ── Protected helpers ── protected function resolveAttribute(string $attribute): string { @@ -667,34 +662,55 @@ protected function resolveAttribute(string $attribute): string return $attribute; } - protected function wrapIdentifier(string $identifier): string - { - return $this->wrapChar . $identifier . $this->wrapChar; - } - protected function resolveAndWrap(string $attribute): string { return $this->wrapIdentifier($this->resolveAttribute($attribute)); } - // ── Private helpers ── - - private function addBinding(mixed $value): void + protected function addBinding(mixed $value): void { $this->bindings[] = $value; } + // ── Private helpers (shared SQL syntax) ── + /** * @param array $values */ private function compileIn(string $attribute, array $values): string { - $placeholders = \array_fill(0, \count($values), '?'); + if ($values === []) { + return '1 = 0'; + } + + $hasNulls = false; + $nonNulls = []; + foreach ($values as $value) { + if ($value === null) { + $hasNulls = true; + } else { + $nonNulls[] = $value; + } + } + + $hasNonNulls = $nonNulls !== []; + + if ($hasNulls && ! $hasNonNulls) { + return $attribute . ' IS NULL'; + } + + $placeholders = \array_fill(0, \count($nonNulls), '?'); + foreach ($nonNulls as $value) { $this->addBinding($value); } + $inClause = $attribute . ' IN (' . \implode(', ', $placeholders) . ')'; + + if ($hasNulls) { + return '(' . $inClause . ' OR ' . $attribute . ' IS NULL)'; + } - return $attribute . ' IN (' . \implode(', ', $placeholders) . ')'; + return $inClause; } /** @@ -702,18 +718,43 @@ private function compileIn(string $attribute, array $values): string */ private function compileNotIn(string $attribute, array $values): string { - if (\count($values) === 1) { - $this->addBinding($values[0]); - - return $attribute . ' != ?'; + if ($values === []) { + return '1 = 1'; } - $placeholders = \array_fill(0, \count($values), '?'); + $hasNulls = false; + $nonNulls = []; + foreach ($values as $value) { - $this->addBinding($value); + if ($value === null) { + $hasNulls = true; + } else { + $nonNulls[] = $value; + } } - return $attribute . ' NOT IN (' . \implode(', ', $placeholders) . ')'; + $hasNonNulls = $nonNulls !== []; + + if ($hasNulls && ! $hasNonNulls) { + return $attribute . ' IS NOT NULL'; + } + + if (\count($nonNulls) === 1) { + $this->addBinding($nonNulls[0]); + $notClause = $attribute . ' != ?'; + } else { + $placeholders = \array_fill(0, \count($nonNulls), '?'); + foreach ($nonNulls as $value) { + $this->addBinding($value); + } + $notClause = $attribute . ' NOT IN (' . \implode(', ', $placeholders) . ')'; + } + + if ($hasNulls) { + return '(' . $notClause . ' AND ' . $attribute . ' IS NOT NULL)'; + } + + return $notClause; } /** @@ -808,30 +849,6 @@ private function compileNotContains(string $attribute, array $values): string return '(' . \implode(' AND ', $parts) . ')'; } - /** - * @param array $values - */ - private function compileSearch(string $attribute, array $values, bool $not): string - { - $this->addBinding($values[0]); - - if ($not) { - return 'NOT MATCH(' . $attribute . ') AGAINST(?)'; - } - - return 'MATCH(' . $attribute . ') AGAINST(?)'; - } - - /** - * @param array $values - */ - private function compileRegex(string $attribute, array $values): string - { - $this->addBinding($values[0]); - - return $attribute . ' REGEXP ?'; - } - private function compileLogical(Query $query, string $operator): string { $parts = []; @@ -840,6 +857,10 @@ private function compileLogical(Query $query, string $operator): string $parts[] = $this->compileFilter($subQuery); } + if ($parts === []) { + return $operator === 'OR' ? '1 = 0' : '1 = 1'; + } + return '(' . \implode(' ' . $operator . ' ', $parts) . ')'; } @@ -851,6 +872,10 @@ private function compileExists(Query $query): string $parts[] = $this->resolveAndWrap($attr) . ' IS NOT NULL'; } + if ($parts === []) { + return '1 = 1'; + } + return '(' . \implode(' AND ', $parts) . ')'; } @@ -862,6 +887,10 @@ private function compileNotExists(Query $query): string $parts[] = $this->resolveAndWrap($attr) . ' IS NULL'; } + if ($parts === []) { + return '1 = 1'; + } + return '(' . \implode(' AND ', $parts) . ')'; } diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php new file mode 100644 index 0000000..1927e8d --- /dev/null +++ b/src/Query/Builder/ClickHouse.php @@ -0,0 +1,133 @@ + + */ + protected array $prewhereQueries = []; + + protected bool $useFinal = false; + + protected ?float $sampleFraction = null; + + // ── ClickHouse-specific fluent API ── + + /** + * Add PREWHERE filters (evaluated before reading all columns — major ClickHouse optimization) + * + * @param array $queries + */ + public function prewhere(array $queries): static + { + foreach ($queries as $query) { + $this->prewhereQueries[] = $query; + } + + return $this; + } + + /** + * Add FINAL keyword after table name (forces merging of data parts) + */ + public function final(): static + { + $this->useFinal = true; + + return $this; + } + + /** + * Add SAMPLE clause after table name (approximate query processing) + */ + public function sample(float $fraction): static + { + $this->sampleFraction = $fraction; + + return $this; + } + + public function reset(): static + { + parent::reset(); + $this->prewhereQueries = []; + $this->useFinal = false; + $this->sampleFraction = null; + + return $this; + } + + // ── Dialect-specific compilation ── + + protected function wrapIdentifier(string $identifier): string + { + return '`' . $identifier . '`'; + } + + protected function compileRandom(): string + { + return 'rand()'; + } + + /** + * ClickHouse uses the match(column, pattern) function instead of REGEXP + * + * @param array $values + */ + protected function compileRegex(string $attribute, array $values): string + { + $this->addBinding($values[0]); + + return 'match(' . $attribute . ', ?)'; + } + + /** + * ClickHouse does not support MATCH() AGAINST() full-text search + * + * @param array $values + * + * @throws Exception + */ + protected function compileSearch(string $attribute, array $values, bool $not): string + { + throw new Exception('Full-text search (MATCH AGAINST) is not supported in ClickHouse. Use contains() or a custom full-text index instead.'); + } + + // ── Hooks ── + + protected function buildTableClause(): string + { + $sql = 'FROM ' . $this->wrapIdentifier($this->table); + + if ($this->useFinal) { + $sql .= ' FINAL'; + } + + if ($this->sampleFraction !== null) { + $sql .= ' SAMPLE ' . $this->sampleFraction; + } + + return $sql; + } + + /** + * @param array $parts + * @param array $grouped + */ + protected function buildAfterJoins(array &$parts, array $grouped): void + { + if (! empty($this->prewhereQueries)) { + $clauses = []; + foreach ($this->prewhereQueries as $query) { + $clauses[] = $this->compileFilter($query); + } + $parts[] = 'PREWHERE ' . \implode(' AND ', $clauses); + } + } +} diff --git a/src/Query/Builder/SQL.php b/src/Query/Builder/SQL.php new file mode 100644 index 0000000..34eb6c0 --- /dev/null +++ b/src/Query/Builder/SQL.php @@ -0,0 +1,51 @@ +wrapChar = $char; + + return $this; + } + + protected function wrapIdentifier(string $identifier): string + { + return $this->wrapChar . $identifier . $this->wrapChar; + } + + protected function compileRandom(): string + { + return 'RAND()'; + } + + /** + * @param array $values + */ + protected function compileRegex(string $attribute, array $values): string + { + $this->addBinding($values[0]); + + return $attribute . ' REGEXP ?'; + } + + /** + * @param array $values + */ + protected function compileSearch(string $attribute, array $values, bool $not): string + { + $this->addBinding($values[0]); + + if ($not) { + return 'NOT (MATCH(' . $attribute . ') AGAINST(?))'; + } + + return 'MATCH(' . $attribute . ') AGAINST(?)'; + } +} diff --git a/src/Query/Query.php b/src/Query/Query.php index 11c831d..8f75c60 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -611,7 +611,7 @@ public function toString(): string /** * Helper method to create Query with equal method * - * @param array> $values + * @param array> $values */ public static function equal(string $attribute, array $values): static { @@ -621,9 +621,9 @@ public static function equal(string $attribute, array $values): static /** * Helper method to create Query with notEqual method * - * @param string|int|float|bool|array $value + * @param string|int|float|bool|null|array $value */ - public static function notEqual(string $attribute, string|int|float|bool|array $value): static + public static function notEqual(string $attribute, string|int|float|bool|array|null $value): static { // maps or not an array if ((is_array($value) && ! array_is_list($value)) || ! is_array($value)) { diff --git a/tests/Query/AggregationQueryTest.php b/tests/Query/AggregationQueryTest.php index 7962589..2b30d7a 100644 --- a/tests/Query/AggregationQueryTest.php +++ b/tests/Query/AggregationQueryTest.php @@ -94,4 +94,164 @@ public function testAggregateTypesConstant(): void $this->assertContains(Query::TYPE_MAX, Query::AGGREGATE_TYPES); $this->assertCount(5, Query::AGGREGATE_TYPES); } + + // ── Edge cases ── + + public function testCountWithEmptyStringAttribute(): void + { + $query = Query::count(''); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testSumWithEmptyAlias(): void + { + $query = Query::sum('price', ''); + $this->assertEquals([], $query->getValues()); + } + + public function testAvgWithAlias(): void + { + $query = Query::avg('score', 'avg_score'); + $this->assertEquals(['avg_score'], $query->getValues()); + $this->assertEquals('avg_score', $query->getValue()); + } + + public function testMinWithAlias(): void + { + $query = Query::min('price', 'min_price'); + $this->assertEquals(['min_price'], $query->getValues()); + } + + public function testMaxWithAlias(): void + { + $query = Query::max('price', 'max_price'); + $this->assertEquals(['max_price'], $query->getValues()); + } + + public function testGroupByEmpty(): void + { + $query = Query::groupBy([]); + $this->assertEquals(Query::TYPE_GROUP_BY, $query->getMethod()); + $this->assertEquals([], $query->getValues()); + } + + public function testGroupBySingleColumn(): void + { + $query = Query::groupBy(['status']); + $this->assertEquals(['status'], $query->getValues()); + } + + public function testGroupByManyColumns(): void + { + $cols = ['a', 'b', 'c', 'd', 'e', 'f', 'g']; + $query = Query::groupBy($cols); + $this->assertCount(7, $query->getValues()); + } + + public function testGroupByDuplicateColumns(): void + { + $query = Query::groupBy(['status', 'status']); + $this->assertEquals(['status', 'status'], $query->getValues()); + } + + public function testHavingEmpty(): void + { + $query = Query::having([]); + $this->assertEquals(Query::TYPE_HAVING, $query->getMethod()); + $this->assertEquals([], $query->getValues()); + } + + public function testHavingMultipleConditions(): void + { + $inner = [ + Query::greaterThan('count', 5), + Query::lessThan('total', 1000), + ]; + $query = Query::having($inner); + $this->assertCount(2, $query->getValues()); + $this->assertInstanceOf(Query::class, $query->getValues()[0]); + $this->assertInstanceOf(Query::class, $query->getValues()[1]); + } + + public function testHavingWithLogicalOr(): void + { + $inner = [ + Query::or([ + Query::greaterThan('count', 5), + Query::lessThan('count', 1), + ]), + ]; + $query = Query::having($inner); + $this->assertCount(1, $query->getValues()); + } + + public function testHavingIsNested(): void + { + $query = Query::having([Query::greaterThan('x', 1)]); + $this->assertTrue($query->isNested()); + } + + public function testDistinctIsNotNested(): void + { + $query = Query::distinct(); + $this->assertFalse($query->isNested()); + } + + public function testCountCompileDispatch(): void + { + $builder = new \Utopia\Query\Builder\SQL(); + $query = Query::count('id'); + $sql = $query->compile($builder); + $this->assertEquals('COUNT(`id`)', $sql); + } + + public function testSumCompileDispatch(): void + { + $builder = new \Utopia\Query\Builder\SQL(); + $query = Query::sum('price', 'total'); + $sql = $query->compile($builder); + $this->assertEquals('SUM(`price`) AS `total`', $sql); + } + + public function testAvgCompileDispatch(): void + { + $builder = new \Utopia\Query\Builder\SQL(); + $query = Query::avg('score'); + $sql = $query->compile($builder); + $this->assertEquals('AVG(`score`)', $sql); + } + + public function testMinCompileDispatch(): void + { + $builder = new \Utopia\Query\Builder\SQL(); + $query = Query::min('price'); + $sql = $query->compile($builder); + $this->assertEquals('MIN(`price`)', $sql); + } + + public function testMaxCompileDispatch(): void + { + $builder = new \Utopia\Query\Builder\SQL(); + $query = Query::max('price'); + $sql = $query->compile($builder); + $this->assertEquals('MAX(`price`)', $sql); + } + + public function testGroupByCompileDispatch(): void + { + $builder = new \Utopia\Query\Builder\SQL(); + $query = Query::groupBy(['status', 'country']); + $sql = $query->compile($builder); + $this->assertEquals('`status`, `country`', $sql); + } + + public function testHavingCompileDispatchUsesCompileFilter(): void + { + $builder = new \Utopia\Query\Builder\SQL(); + $query = Query::having([Query::greaterThan('total', 5)]); + $sql = $query->compile($builder); + $this->assertEquals('(`total` > ?)', $sql); + $this->assertEquals([5], $builder->getBindings()); + } } diff --git a/tests/Query/Builder/ClickHouseTest.php b/tests/Query/Builder/ClickHouseTest.php new file mode 100644 index 0000000..c0f43f9 --- /dev/null +++ b/tests/Query/Builder/ClickHouseTest.php @@ -0,0 +1,5227 @@ +assertInstanceOf(Compiler::class, $builder); + } + + // ── Basic queries work identically ── + + public function testBasicSelect(): void + { + $result = (new Builder()) + ->from('events') + ->select(['name', 'timestamp']) + ->build(); + + $this->assertEquals('SELECT `name`, `timestamp` FROM `events`', $result['query']); + } + + public function testFilterAndSort(): void + { + $result = (new Builder()) + ->from('events') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('count', 10), + ]) + ->sortDesc('timestamp') + ->limit(100) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` WHERE `status` IN (?) AND `count` > ? ORDER BY `timestamp` DESC LIMIT ?', + $result['query'] + ); + $this->assertEquals(['active', 10, 100], $result['bindings']); + } + + // ── ClickHouse-specific: regex uses match() ── + + public function testRegexUsesMatchFunction(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::regex('path', '^/api/v[0-9]+')]) + ->build(); + + $this->assertEquals('SELECT * FROM `logs` WHERE match(`path`, ?)', $result['query']); + $this->assertEquals(['^/api/v[0-9]+'], $result['bindings']); + } + + // ── ClickHouse-specific: search throws exception ── + + public function testSearchThrowsException(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Full-text search (MATCH AGAINST) is not supported in ClickHouse'); + + (new Builder()) + ->from('logs') + ->filter([Query::search('content', 'hello')]) + ->build(); + } + + public function testNotSearchThrowsException(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Full-text search (MATCH AGAINST) is not supported in ClickHouse'); + + (new Builder()) + ->from('logs') + ->filter([Query::notSearch('content', 'hello')]) + ->build(); + } + + // ── ClickHouse-specific: random ordering uses rand() ── + + public function testRandomOrderUsesLowercaseRand(): void + { + $result = (new Builder()) + ->from('events') + ->sortRandom() + ->build(); + + $this->assertEquals('SELECT * FROM `events` ORDER BY rand()', $result['query']); + } + + // ── FINAL keyword ── + + public function testFinalKeyword(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->build(); + + $this->assertEquals('SELECT * FROM `events` FINAL', $result['query']); + } + + public function testFinalWithFilters(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` FINAL WHERE `status` IN (?) LIMIT ?', + $result['query'] + ); + $this->assertEquals(['active', 10], $result['bindings']); + } + + // ── SAMPLE clause ── + + public function testSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->build(); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1', $result['query']); + } + + public function testSampleWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->build(); + + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.5', $result['query']); + } + + // ── PREWHERE clause ── + + public function testPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('event_type', ['click'])]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` PREWHERE `event_type` IN (?)', + $result['query'] + ); + $this->assertEquals(['click'], $result['bindings']); + } + + public function testPrewhereWithMultipleConditions(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([ + Query::equal('event_type', ['click']), + Query::greaterThan('timestamp', '2024-01-01'), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` PREWHERE `event_type` IN (?) AND `timestamp` > ?', + $result['query'] + ); + $this->assertEquals(['click', '2024-01-01'], $result['bindings']); + } + + public function testPrewhereWithWhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('event_type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` PREWHERE `event_type` IN (?) WHERE `count` > ?', + $result['query'] + ); + $this->assertEquals(['click', 5], $result['bindings']); + } + + public function testPrewhereWithJoinAndWhere(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.user_id', 'users.id') + ->prewhere([Query::equal('event_type', ['click'])]) + ->filter([Query::greaterThan('users.age', 18)]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` JOIN `users` ON `events.user_id` = `users.id` PREWHERE `event_type` IN (?) WHERE `users.age` > ?', + $result['query'] + ); + $this->assertEquals(['click', 18], $result['bindings']); + } + + // ── Combined ClickHouse features ── + + public function testFinalSamplePrewhereWhere(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('event_type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->sortDesc('timestamp') + ->limit(100) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `event_type` IN (?) WHERE `count` > ? ORDER BY `timestamp` DESC LIMIT ?', + $result['query'] + ); + $this->assertEquals(['click', 5, 100], $result['bindings']); + } + + // ── Aggregations work ── + + public function testAggregation(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'total') + ->sum('duration', 'total_duration') + ->groupBy(['event_type']) + ->having([Query::greaterThan('total', 10)]) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total`, SUM(`duration`) AS `total_duration` FROM `events` GROUP BY `event_type` HAVING `total` > ?', + $result['query'] + ); + $this->assertEquals([10], $result['bindings']); + } + + // ── Joins work ── + + public function testJoin(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.user_id', 'users.id') + ->leftJoin('sessions', 'events.session_id', 'sessions.id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` JOIN `users` ON `events.user_id` = `users.id` LEFT JOIN `sessions` ON `events.session_id` = `sessions.id`', + $result['query'] + ); + } + + // ── Distinct ── + + public function testDistinct(): void + { + $result = (new Builder()) + ->from('events') + ->distinct() + ->select(['user_id']) + ->build(); + + $this->assertEquals('SELECT DISTINCT `user_id` FROM `events`', $result['query']); + } + + // ── Union ── + + public function testUnion(): void + { + $other = (new Builder())->from('events_archive')->filter([Query::equal('year', [2023])]); + + $result = (new Builder()) + ->from('events') + ->filter([Query::equal('year', [2024])]) + ->union($other) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` WHERE `year` IN (?) UNION SELECT * FROM `events_archive` WHERE `year` IN (?)', + $result['query'] + ); + $this->assertEquals([2024, 2023], $result['bindings']); + } + + // ── toRawSql ── + + public function testToRawSql(): void + { + $sql = (new Builder()) + ->from('events') + ->final() + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->toRawSql(); + + $this->assertEquals( + "SELECT * FROM `events` FINAL WHERE `status` IN ('active') LIMIT 10", + $sql + ); + } + + // ── Reset clears ClickHouse state ── + + public function testResetClearsClickHouseState(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('event_type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('logs')->build(); + + $this->assertEquals('SELECT * FROM `logs`', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + // ── Fluent chaining ── + + public function testFluentChainingReturnsSameInstance(): void + { + $builder = new Builder(); + + $this->assertSame($builder, $builder->from('t')); + $this->assertSame($builder, $builder->final()); + $this->assertSame($builder, $builder->sample(0.1)); + $this->assertSame($builder, $builder->prewhere([])); + $this->assertSame($builder, $builder->select(['a'])); + $this->assertSame($builder, $builder->filter([])); + $this->assertSame($builder, $builder->sortAsc('a')); + $this->assertSame($builder, $builder->limit(1)); + $this->assertSame($builder, $builder->reset()); + } + + // ── Attribute resolver works ── + + public function testAttributeResolver(): void + { + $result = (new Builder()) + ->from('events') + ->setAttributeResolver(fn (string $a): string => match ($a) { + '$id' => '_uid', + default => $a, + }) + ->filter([Query::equal('$id', ['abc'])]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` WHERE `_uid` IN (?)', + $result['query'] + ); + } + + // ── Condition provider works ── + + public function testConditionProvider(): void + { + $result = (new Builder()) + ->from('events') + ->addConditionProvider(fn (string $table): array => [ + '_tenant = ?', + ['t1'], + ]) + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` WHERE `status` IN (?) AND _tenant = ?', + $result['query'] + ); + $this->assertEquals(['active', 't1'], $result['bindings']); + } + + // ── Prewhere binding order ── + + public function testPrewhereBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->limit(10) + ->build(); + + // prewhere bindings come before where bindings + $this->assertEquals(['click', 5, 10], $result['bindings']); + } + + // ── Combined PREWHERE + WHERE + JOIN + GROUP BY ── + + public function testCombinedPrewhereWhereJoinGroupBy(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->join('users', 'events.user_id', 'users.id') + ->prewhere([Query::equal('event_type', ['purchase'])]) + ->filter([Query::greaterThan('events.amount', 100)]) + ->count('*', 'total') + ->select(['users.country']) + ->groupBy(['users.country']) + ->having([Query::greaterThan('total', 5)]) + ->sortDesc('total') + ->limit(50) + ->build(); + + $query = $result['query']; + + // Verify clause ordering + $this->assertStringContainsString('SELECT', $query); + $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.1', $query); + $this->assertStringContainsString('JOIN `users`', $query); + $this->assertStringContainsString('PREWHERE `event_type` IN (?)', $query); + $this->assertStringContainsString('WHERE `events.amount` > ?', $query); + $this->assertStringContainsString('GROUP BY `users.country`', $query); + $this->assertStringContainsString('HAVING `total` > ?', $query); + $this->assertStringContainsString('ORDER BY `total` DESC', $query); + $this->assertStringContainsString('LIMIT ?', $query); + + // Verify ordering: PREWHERE before WHERE + $this->assertLessThan(strpos($query, 'WHERE'), strpos($query, 'PREWHERE')); + } + + // ══════════════════════════════════════════════════════════════════ + // 1. PREWHERE comprehensive (40+ tests) + // ══════════════════════════════════════════════════════════════════ + + public function testPrewhereEmptyArray(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([]) + ->build(); + + $this->assertEquals('SELECT * FROM `events`', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testPrewhereSingleEqual(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('status', ['active'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `status` IN (?)', $result['query']); + $this->assertEquals(['active'], $result['bindings']); + } + + public function testPrewhereSingleNotEqual(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notEqual('status', 'deleted')]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `status` != ?', $result['query']); + $this->assertEquals(['deleted'], $result['bindings']); + } + + public function testPrewhereLessThan(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::lessThan('age', 30)]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `age` < ?', $result['query']); + $this->assertEquals([30], $result['bindings']); + } + + public function testPrewhereLessThanEqual(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::lessThanEqual('age', 30)]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `age` <= ?', $result['query']); + $this->assertEquals([30], $result['bindings']); + } + + public function testPrewhereGreaterThan(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::greaterThan('score', 50)]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `score` > ?', $result['query']); + $this->assertEquals([50], $result['bindings']); + } + + public function testPrewhereGreaterThanEqual(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::greaterThanEqual('score', 50)]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `score` >= ?', $result['query']); + $this->assertEquals([50], $result['bindings']); + } + + public function testPrewhereBetween(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::between('age', 18, 65)]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `age` BETWEEN ? AND ?', $result['query']); + $this->assertEquals([18, 65], $result['bindings']); + } + + public function testPrewhereNotBetween(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notBetween('age', 0, 17)]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `age` NOT BETWEEN ? AND ?', $result['query']); + $this->assertEquals([0, 17], $result['bindings']); + } + + public function testPrewhereStartsWith(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::startsWith('path', '/api')]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `path` LIKE ?', $result['query']); + $this->assertEquals(['/api%'], $result['bindings']); + } + + public function testPrewhereNotStartsWith(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notStartsWith('path', '/admin')]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `path` NOT LIKE ?', $result['query']); + $this->assertEquals(['/admin%'], $result['bindings']); + } + + public function testPrewhereEndsWith(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::endsWith('file', '.csv')]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `file` LIKE ?', $result['query']); + $this->assertEquals(['%.csv'], $result['bindings']); + } + + public function testPrewhereNotEndsWith(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notEndsWith('file', '.tmp')]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `file` NOT LIKE ?', $result['query']); + $this->assertEquals(['%.tmp'], $result['bindings']); + } + + public function testPrewhereContainsSingle(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::contains('name', ['foo'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `name` LIKE ?', $result['query']); + $this->assertEquals(['%foo%'], $result['bindings']); + } + + public function testPrewhereContainsMultiple(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::contains('name', ['foo', 'bar'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE (`name` LIKE ? OR `name` LIKE ?)', $result['query']); + $this->assertEquals(['%foo%', '%bar%'], $result['bindings']); + } + + public function testPrewhereContainsAny(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::containsAny('tag', ['a', 'b', 'c'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `tag` IN (?, ?, ?)', $result['query']); + $this->assertEquals(['a', 'b', 'c'], $result['bindings']); + } + + public function testPrewhereContainsAll(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::containsAll('tag', ['x', 'y'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE (`tag` LIKE ? AND `tag` LIKE ?)', $result['query']); + $this->assertEquals(['%x%', '%y%'], $result['bindings']); + } + + public function testPrewhereNotContainsSingle(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notContains('name', ['bad'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `name` NOT LIKE ?', $result['query']); + $this->assertEquals(['%bad%'], $result['bindings']); + } + + public function testPrewhereNotContainsMultiple(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notContains('name', ['bad', 'ugly'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE (`name` NOT LIKE ? AND `name` NOT LIKE ?)', $result['query']); + $this->assertEquals(['%bad%', '%ugly%'], $result['bindings']); + } + + public function testPrewhereIsNull(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::isNull('deleted_at')]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `deleted_at` IS NULL', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testPrewhereIsNotNull(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::isNotNull('email')]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `email` IS NOT NULL', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testPrewhereExists(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::exists(['col_a', 'col_b'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE (`col_a` IS NOT NULL AND `col_b` IS NOT NULL)', $result['query']); + } + + public function testPrewhereNotExists(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notExists(['col_a'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE (`col_a` IS NULL)', $result['query']); + } + + public function testPrewhereRegex(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::regex('path', '^/api')]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE match(`path`, ?)', $result['query']); + $this->assertEquals(['^/api'], $result['bindings']); + } + + public function testPrewhereAndLogical(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::and([ + Query::equal('a', [1]), + Query::equal('b', [2]), + ])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE (`a` IN (?) AND `b` IN (?))', $result['query']); + $this->assertEquals([1, 2], $result['bindings']); + } + + public function testPrewhereOrLogical(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::or([ + Query::equal('a', [1]), + Query::equal('b', [2]), + ])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE (`a` IN (?) OR `b` IN (?))', $result['query']); + $this->assertEquals([1, 2], $result['bindings']); + } + + public function testPrewhereNestedAndOr(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::and([ + Query::or([ + Query::equal('x', [1]), + Query::equal('y', [2]), + ]), + Query::greaterThan('z', 0), + ])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE ((`x` IN (?) OR `y` IN (?)) AND `z` > ?)', $result['query']); + $this->assertEquals([1, 2, 0], $result['bindings']); + } + + public function testPrewhereRawExpression(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::raw('toDate(created) > ?', ['2024-01-01'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE toDate(created) > ?', $result['query']); + $this->assertEquals(['2024-01-01'], $result['bindings']); + } + + public function testPrewhereMultipleCallsAdditive(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('a', [1])]) + ->prewhere([Query::equal('b', [2])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `a` IN (?) AND `b` IN (?)', $result['query']); + $this->assertEquals([1, 2], $result['bindings']); + } + + public function testPrewhereWithWhereFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` FINAL PREWHERE `type` IN (?) WHERE `count` > ?', + $result['query'] + ); + } + + public function testPrewhereWithWhereSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` SAMPLE 0.5 PREWHERE `type` IN (?) WHERE `count` > ?', + $result['query'] + ); + } + + public function testPrewhereWithWhereFinalSample(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.3) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` FINAL SAMPLE 0.3 PREWHERE `type` IN (?) WHERE `count` > ?', + $result['query'] + ); + $this->assertEquals(['click', 5], $result['bindings']); + } + + public function testPrewhereWithGroupBy(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->count('*', 'total') + ->groupBy(['type']) + ->build(); + + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); + $this->assertStringContainsString('GROUP BY `type`', $result['query']); + } + + public function testPrewhereWithHaving(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->count('*', 'total') + ->groupBy(['type']) + ->having([Query::greaterThan('total', 10)]) + ->build(); + + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); + $this->assertStringContainsString('HAVING `total` > ?', $result['query']); + } + + public function testPrewhereWithOrderBy(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->sortAsc('name') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` PREWHERE `type` IN (?) ORDER BY `name` ASC', + $result['query'] + ); + } + + public function testPrewhereWithLimitOffset(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->limit(10) + ->offset(20) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` PREWHERE `type` IN (?) LIMIT ? OFFSET ?', + $result['query'] + ); + $this->assertEquals(['click', 10, 20], $result['bindings']); + } + + public function testPrewhereWithUnion(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->union($other) + ->build(); + + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); + $this->assertStringContainsString('UNION SELECT', $result['query']); + } + + public function testPrewhereWithDistinct(): void + { + $result = (new Builder()) + ->from('events') + ->distinct() + ->select(['user_id']) + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + + $this->assertStringContainsString('SELECT DISTINCT', $result['query']); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); + } + + public function testPrewhereWithAggregations(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->sum('amount', 'total_amount') + ->build(); + + $this->assertStringContainsString('SUM(`amount`) AS `total_amount`', $result['query']); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); + } + + public function testPrewhereBindingOrderWithProvider(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->addConditionProvider(fn (string $table): array => ['tenant_id = ?', ['t1']]) + ->build(); + + $this->assertEquals(['click', 5, 't1'], $result['bindings']); + } + + public function testPrewhereBindingOrderWithCursor(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->cursorAfter('abc123') + ->sortAsc('_cursor') + ->build(); + + // prewhere, where filter, cursor + $this->assertEquals('click', $result['bindings'][0]); + $this->assertEquals(5, $result['bindings'][1]); + $this->assertEquals('abc123', $result['bindings'][2]); + } + + public function testPrewhereBindingOrderComplex(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->addConditionProvider(fn (string $table): array => ['tenant = ?', ['t1']]) + ->cursorAfter('cur1') + ->sortAsc('_cursor') + ->count('*', 'total') + ->groupBy(['type']) + ->having([Query::greaterThan('total', 10)]) + ->limit(50) + ->offset(100) + ->union($other) + ->build(); + + // prewhere, filter, provider, cursor, having, limit, offset, union + $this->assertEquals('click', $result['bindings'][0]); + $this->assertEquals(5, $result['bindings'][1]); + $this->assertEquals('t1', $result['bindings'][2]); + $this->assertEquals('cur1', $result['bindings'][3]); + } + + public function testPrewhereWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('events') + ->setAttributeResolver(fn (string $a): string => match ($a) { + '$id' => '_uid', + default => $a, + }) + ->prewhere([Query::equal('$id', ['abc'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `_uid` IN (?)', $result['query']); + $this->assertEquals(['abc'], $result['bindings']); + } + + public function testPrewhereOnlyNoWhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::greaterThan('ts', 100)]) + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + // "PREWHERE" contains "WHERE" as a substring, so we check there is no standalone WHERE clause + $withoutPrewhere = str_replace('PREWHERE', '', $result['query']); + $this->assertStringNotContainsString('WHERE', $withoutPrewhere); + } + + public function testPrewhereWithEmptyWhereFilter(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['a'])]) + ->filter([]) + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + $withoutPrewhere = str_replace('PREWHERE', '', $result['query']); + $this->assertStringNotContainsString('WHERE', $withoutPrewhere); + } + + public function testPrewhereAppearsAfterJoinsBeforeWhere(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('age', 18)]) + ->build(); + + $query = $result['query']; + $joinPos = strpos($query, 'JOIN'); + $prewherePos = strpos($query, 'PREWHERE'); + $wherePos = strpos($query, 'WHERE'); + + $this->assertLessThan($prewherePos, $joinPos); + $this->assertLessThan($wherePos, $prewherePos); + } + + public function testPrewhereMultipleFiltersInSingleCall(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([ + Query::equal('a', [1]), + Query::greaterThan('b', 2), + Query::lessThan('c', 3), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` PREWHERE `a` IN (?) AND `b` > ? AND `c` < ?', + $result['query'] + ); + $this->assertEquals([1, 2, 3], $result['bindings']); + } + + public function testPrewhereResetClearsPrewhereQueries(): void + { + $builder = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertStringNotContainsString('PREWHERE', $result['query']); + } + + public function testPrewhereInToRawSqlOutput(): void + { + $sql = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->toRawSql(); + + $this->assertEquals( + "SELECT * FROM `events` PREWHERE `type` IN ('click') WHERE `count` > 5", + $sql + ); + } + + // ══════════════════════════════════════════════════════════════════ + // 2. FINAL comprehensive (20+ tests) + // ══════════════════════════════════════════════════════════════════ + + public function testFinalBasicSelect(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->select(['name', 'ts']) + ->build(); + + $this->assertEquals('SELECT `name`, `ts` FROM `events` FINAL', $result['query']); + } + + public function testFinalWithJoins(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->join('users', 'events.uid', 'users.id') + ->build(); + + $this->assertStringContainsString('FROM `events` FINAL', $result['query']); + $this->assertStringContainsString('JOIN `users`', $result['query']); + } + + public function testFinalWithAggregations(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->count('*', 'total') + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `total`', $result['query']); + $this->assertStringContainsString('FROM `events` FINAL', $result['query']); + } + + public function testFinalWithGroupByHaving(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->count('*', 'cnt') + ->groupBy(['type']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + + $this->assertStringContainsString('FROM `events` FINAL', $result['query']); + $this->assertStringContainsString('GROUP BY `type`', $result['query']); + $this->assertStringContainsString('HAVING `cnt` > ?', $result['query']); + } + + public function testFinalWithDistinct(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->distinct() + ->select(['user_id']) + ->build(); + + $this->assertEquals('SELECT DISTINCT `user_id` FROM `events` FINAL', $result['query']); + } + + public function testFinalWithSort(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sortAsc('name') + ->sortDesc('ts') + ->build(); + + $this->assertEquals('SELECT * FROM `events` FINAL ORDER BY `name` ASC, `ts` DESC', $result['query']); + } + + public function testFinalWithLimitOffset(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->limit(10) + ->offset(20) + ->build(); + + $this->assertEquals('SELECT * FROM `events` FINAL LIMIT ? OFFSET ?', $result['query']); + $this->assertEquals([10, 20], $result['bindings']); + } + + public function testFinalWithCursor(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + + $this->assertStringContainsString('FROM `events` FINAL', $result['query']); + $this->assertStringContainsString('_cursor > ?', $result['query']); + } + + public function testFinalWithUnion(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->final() + ->union($other) + ->build(); + + $this->assertStringContainsString('FROM `events` FINAL', $result['query']); + $this->assertStringContainsString('UNION SELECT', $result['query']); + } + + public function testFinalWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` FINAL PREWHERE `type` IN (?)', $result['query']); + } + + public function testFinalWithSampleAlone(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.25) + ->build(); + + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.25', $result['query']); + } + + public function testFinalWithPrewhereSample(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.5 PREWHERE `type` IN (?)', $result['query']); + } + + public function testFinalFullPipeline(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->select(['name']) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->sortDesc('ts') + ->limit(10) + ->offset(5) + ->build(); + + $query = $result['query']; + $this->assertStringContainsString('SELECT `name`', $query); + $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.1', $query); + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('WHERE', $query); + $this->assertStringContainsString('ORDER BY', $query); + $this->assertStringContainsString('LIMIT', $query); + $this->assertStringContainsString('OFFSET', $query); + } + + public function testFinalCalledMultipleTimesIdempotent(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->final() + ->final() + ->build(); + + $this->assertEquals('SELECT * FROM `events` FINAL', $result['query']); + // Ensure FINAL appears only once + $this->assertEquals(1, substr_count($result['query'], 'FINAL')); + } + + public function testFinalInToRawSql(): void + { + $sql = (new Builder()) + ->from('events') + ->final() + ->filter([Query::equal('status', ['ok'])]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `events` FINAL WHERE `status` IN ('ok')", $sql); + } + + public function testFinalPositionAfterTableBeforeJoins(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->join('users', 'events.uid', 'users.id') + ->build(); + + $query = $result['query']; + $finalPos = strpos($query, 'FINAL'); + $joinPos = strpos($query, 'JOIN'); + + $this->assertLessThan($joinPos, $finalPos); + } + + public function testFinalWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->setAttributeResolver(fn (string $a): string => 'col_' . $a) + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertStringContainsString('FROM `events` FINAL', $result['query']); + $this->assertStringContainsString('`col_status`', $result['query']); + } + + public function testFinalWithConditionProvider(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->addConditionProvider(fn (string $table): array => ['deleted = ?', [0]]) + ->build(); + + $this->assertStringContainsString('FROM `events` FINAL', $result['query']); + $this->assertStringContainsString('deleted = ?', $result['query']); + } + + public function testFinalResetClearsFlag(): void + { + $builder = (new Builder()) + ->from('events') + ->final(); + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertStringNotContainsString('FINAL', $result['query']); + } + + public function testFinalWithWhenConditional(): void + { + $result = (new Builder()) + ->from('events') + ->when(true, fn (Builder $b) => $b->final()) + ->build(); + + $this->assertStringContainsString('FINAL', $result['query']); + + $result2 = (new Builder()) + ->from('events') + ->when(false, fn (Builder $b) => $b->final()) + ->build(); + + $this->assertStringNotContainsString('FINAL', $result2['query']); + } + + // ══════════════════════════════════════════════════════════════════ + // 3. SAMPLE comprehensive (23 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testSample10Percent(): void + { + $result = (new Builder())->from('events')->sample(0.1)->build(); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1', $result['query']); + } + + public function testSample50Percent(): void + { + $result = (new Builder())->from('events')->sample(0.5)->build(); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5', $result['query']); + } + + public function testSample1Percent(): void + { + $result = (new Builder())->from('events')->sample(0.01)->build(); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.01', $result['query']); + } + + public function testSample99Percent(): void + { + $result = (new Builder())->from('events')->sample(0.99)->build(); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.99', $result['query']); + } + + public function testSampleWithFilters(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.2) + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.2 WHERE `status` IN (?)', $result['query']); + } + + public function testSampleWithJoins(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.3) + ->join('users', 'events.uid', 'users.id') + ->build(); + + $this->assertStringContainsString('SAMPLE 0.3', $result['query']); + $this->assertStringContainsString('JOIN `users`', $result['query']); + } + + public function testSampleWithAggregations(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->count('*', 'cnt') + ->build(); + + $this->assertStringContainsString('SAMPLE 0.1', $result['query']); + $this->assertStringContainsString('COUNT(*)', $result['query']); + } + + public function testSampleWithGroupByHaving(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->count('*', 'cnt') + ->groupBy(['type']) + ->having([Query::greaterThan('cnt', 2)]) + ->build(); + + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('GROUP BY', $result['query']); + $this->assertStringContainsString('HAVING', $result['query']); + } + + public function testSampleWithDistinct(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->distinct() + ->select(['user_id']) + ->build(); + + $this->assertStringContainsString('SELECT DISTINCT', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + } + + public function testSampleWithSort(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->sortDesc('ts') + ->build(); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 ORDER BY `ts` DESC', $result['query']); + } + + public function testSampleWithLimitOffset(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->limit(10) + ->offset(20) + ->build(); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 LIMIT ? OFFSET ?', $result['query']); + } + + public function testSampleWithCursor(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->cursorAfter('xyz') + ->sortAsc('_cursor') + ->build(); + + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('_cursor > ?', $result['query']); + } + + public function testSampleWithUnion(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->union($other) + ->build(); + + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('UNION', $result['query']); + } + + public function testSampleWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1 PREWHERE `type` IN (?)', $result['query']); + } + + public function testSampleWithFinalKeyword(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->build(); + + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.1', $result['query']); + } + + public function testSampleWithFinalPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.2) + ->prewhere([Query::equal('t', ['a'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.2 PREWHERE `t` IN (?)', $result['query']); + } + + public function testSampleFullPipeline(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->select(['name']) + ->filter([Query::greaterThan('count', 0)]) + ->sortDesc('ts') + ->limit(10) + ->build(); + + $query = $result['query']; + $this->assertStringContainsString('SAMPLE 0.1', $query); + $this->assertStringContainsString('SELECT `name`', $query); + $this->assertStringContainsString('WHERE `count` > ?', $query); + } + + public function testSampleInToRawSql(): void + { + $sql = (new Builder()) + ->from('events') + ->sample(0.1) + ->filter([Query::equal('x', [1])]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `events` SAMPLE 0.1 WHERE `x` IN (1)", $sql); + } + + public function testSamplePositionAfterFinalBeforeJoins(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->join('users', 'events.uid', 'users.id') + ->build(); + + $query = $result['query']; + $samplePos = strpos($query, 'SAMPLE'); + $joinPos = strpos($query, 'JOIN'); + $finalPos = strpos($query, 'FINAL'); + + $this->assertLessThan($samplePos, $finalPos); + $this->assertLessThan($joinPos, $samplePos); + } + + public function testSampleResetClearsFraction(): void + { + $builder = (new Builder())->from('events')->sample(0.5); + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertStringNotContainsString('SAMPLE', $result['query']); + } + + public function testSampleWithWhenConditional(): void + { + $result = (new Builder()) + ->from('events') + ->when(true, fn (Builder $b) => $b->sample(0.5)) + ->build(); + + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + + $result2 = (new Builder()) + ->from('events') + ->when(false, fn (Builder $b) => $b->sample(0.5)) + ->build(); + + $this->assertStringNotContainsString('SAMPLE', $result2['query']); + } + + public function testSampleCalledMultipleTimesLastWins(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->sample(0.5) + ->sample(0.9) + ->build(); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.9', $result['query']); + } + + public function testSampleWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->setAttributeResolver(fn (string $a): string => 'r_' . $a) + ->filter([Query::equal('col', ['v'])]) + ->build(); + + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('`r_col`', $result['query']); + } + + // ══════════════════════════════════════════════════════════════════ + // 4. ClickHouse regex: match() function (20 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testRegexBasicPattern(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::regex('msg', 'error|warn')]) + ->build(); + + $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result['query']); + $this->assertEquals(['error|warn'], $result['bindings']); + } + + public function testRegexWithEmptyPattern(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::regex('msg', '')]) + ->build(); + + $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result['query']); + $this->assertEquals([''], $result['bindings']); + } + + public function testRegexWithSpecialChars(): void + { + $pattern = '^/api/v[0-9]+\\.json$'; + $result = (new Builder()) + ->from('logs') + ->filter([Query::regex('path', $pattern)]) + ->build(); + + // Bindings preserve the pattern exactly as provided + $this->assertEquals([$pattern], $result['bindings']); + } + + public function testRegexWithVeryLongPattern(): void + { + $longPattern = str_repeat('a', 1000); + $result = (new Builder()) + ->from('logs') + ->filter([Query::regex('msg', $longPattern)]) + ->build(); + + $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result['query']); + $this->assertEquals([$longPattern], $result['bindings']); + } + + public function testRegexCombinedWithOtherFilters(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([ + Query::regex('path', '^/api'), + Query::equal('status', [200]), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `logs` WHERE match(`path`, ?) AND `status` IN (?)', + $result['query'] + ); + $this->assertEquals(['^/api', 200], $result['bindings']); + } + + public function testRegexInPrewhere(): void + { + $result = (new Builder()) + ->from('logs') + ->prewhere([Query::regex('path', '^/api')]) + ->build(); + + $this->assertEquals('SELECT * FROM `logs` PREWHERE match(`path`, ?)', $result['query']); + $this->assertEquals(['^/api'], $result['bindings']); + } + + public function testRegexInPrewhereAndWhere(): void + { + $result = (new Builder()) + ->from('logs') + ->prewhere([Query::regex('path', '^/api')]) + ->filter([Query::regex('msg', 'err')]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `logs` PREWHERE match(`path`, ?) WHERE match(`msg`, ?)', + $result['query'] + ); + $this->assertEquals(['^/api', 'err'], $result['bindings']); + } + + public function testRegexWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('logs') + ->setAttributeResolver(fn (string $a): string => 'col_' . $a) + ->filter([Query::regex('msg', 'test')]) + ->build(); + + $this->assertEquals('SELECT * FROM `logs` WHERE match(`col_msg`, ?)', $result['query']); + } + + public function testRegexBindingPreserved(): void + { + $pattern = '(foo|bar)\\d+'; + $result = (new Builder()) + ->from('logs') + ->filter([Query::regex('msg', $pattern)]) + ->build(); + + $this->assertEquals([$pattern], $result['bindings']); + } + + public function testMultipleRegexFilters(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([ + Query::regex('path', '^/api'), + Query::regex('msg', 'error'), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `logs` WHERE match(`path`, ?) AND match(`msg`, ?)', + $result['query'] + ); + } + + public function testRegexInAndLogical(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::and([ + Query::regex('path', '^/api'), + Query::greaterThan('status', 399), + ])]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `logs` WHERE (match(`path`, ?) AND `status` > ?)', + $result['query'] + ); + } + + public function testRegexInOrLogical(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::or([ + Query::regex('path', '^/api'), + Query::regex('path', '^/web'), + ])]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `logs` WHERE (match(`path`, ?) OR match(`path`, ?))', + $result['query'] + ); + } + + public function testRegexInNestedLogical(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::and([ + Query::or([ + Query::regex('path', '^/api'), + Query::regex('path', '^/web'), + ]), + Query::equal('status', [500]), + ])]) + ->build(); + + $this->assertStringContainsString('match(`path`, ?)', $result['query']); + $this->assertStringContainsString('`status` IN (?)', $result['query']); + } + + public function testRegexWithFinal(): void + { + $result = (new Builder()) + ->from('logs') + ->final() + ->filter([Query::regex('path', '^/api')]) + ->build(); + + $this->assertStringContainsString('FROM `logs` FINAL', $result['query']); + $this->assertStringContainsString('match(`path`, ?)', $result['query']); + } + + public function testRegexWithSample(): void + { + $result = (new Builder()) + ->from('logs') + ->sample(0.5) + ->filter([Query::regex('path', '^/api')]) + ->build(); + + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('match(`path`, ?)', $result['query']); + } + + public function testRegexInToRawSql(): void + { + $sql = (new Builder()) + ->from('logs') + ->filter([Query::regex('path', '^/api')]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `logs` WHERE match(`path`, '^/api')", $sql); + } + + public function testRegexCombinedWithContains(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([ + Query::regex('path', '^/api'), + Query::contains('msg', ['error']), + ]) + ->build(); + + $this->assertStringContainsString('match(`path`, ?)', $result['query']); + $this->assertStringContainsString('`msg` LIKE ?', $result['query']); + } + + public function testRegexCombinedWithStartsWith(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([ + Query::regex('path', 'complex.*pattern'), + Query::startsWith('msg', 'ERR'), + ]) + ->build(); + + $this->assertStringContainsString('match(`path`, ?)', $result['query']); + $this->assertStringContainsString('`msg` LIKE ?', $result['query']); + } + + public function testRegexPrewhereWithRegexWhere(): void + { + $result = (new Builder()) + ->from('logs') + ->prewhere([Query::regex('path', '^/api')]) + ->filter([Query::regex('msg', 'error')]) + ->build(); + + $this->assertStringContainsString('PREWHERE match(`path`, ?)', $result['query']); + $this->assertStringContainsString('WHERE match(`msg`, ?)', $result['query']); + $this->assertEquals(['^/api', 'error'], $result['bindings']); + } + + public function testRegexCombinedWithPrewhereContainsRegex(): void + { + $result = (new Builder()) + ->from('logs') + ->prewhere([ + Query::regex('path', '^/api'), + Query::equal('level', ['error']), + ]) + ->filter([Query::regex('msg', 'timeout')]) + ->build(); + + $this->assertEquals(['^/api', 'error', 'timeout'], $result['bindings']); + } + + // ══════════════════════════════════════════════════════════════════ + // 5. Search exception (10 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testSearchThrowsExceptionMessage(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Full-text search (MATCH AGAINST) is not supported in ClickHouse'); + + (new Builder()) + ->from('logs') + ->filter([Query::search('content', 'hello world')]) + ->build(); + } + + public function testNotSearchThrowsExceptionMessage(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Full-text search (MATCH AGAINST) is not supported in ClickHouse'); + + (new Builder()) + ->from('logs') + ->filter([Query::notSearch('content', 'hello world')]) + ->build(); + } + + public function testSearchExceptionContainsHelpfulText(): void + { + try { + (new Builder()) + ->from('logs') + ->filter([Query::search('content', 'test')]) + ->build(); + $this->fail('Expected Exception was not thrown'); + } catch (Exception $e) { + $this->assertStringContainsString('contains()', $e->getMessage()); + } + } + + public function testSearchInLogicalAndThrows(): void + { + $this->expectException(Exception::class); + + (new Builder()) + ->from('logs') + ->filter([Query::and([ + Query::equal('status', ['active']), + Query::search('content', 'hello'), + ])]) + ->build(); + } + + public function testSearchInLogicalOrThrows(): void + { + $this->expectException(Exception::class); + + (new Builder()) + ->from('logs') + ->filter([Query::or([ + Query::equal('status', ['active']), + Query::search('content', 'hello'), + ])]) + ->build(); + } + + public function testSearchCombinedWithValidFiltersFailsOnSearch(): void + { + $this->expectException(Exception::class); + + (new Builder()) + ->from('logs') + ->filter([ + Query::equal('status', ['active']), + Query::search('content', 'hello'), + ]) + ->build(); + } + + public function testSearchInPrewhereThrows(): void + { + $this->expectException(Exception::class); + + (new Builder()) + ->from('logs') + ->prewhere([Query::search('content', 'hello')]) + ->build(); + } + + public function testNotSearchInPrewhereThrows(): void + { + $this->expectException(Exception::class); + + (new Builder()) + ->from('logs') + ->prewhere([Query::notSearch('content', 'hello')]) + ->build(); + } + + public function testSearchWithFinalStillThrows(): void + { + $this->expectException(Exception::class); + + (new Builder()) + ->from('logs') + ->final() + ->filter([Query::search('content', 'hello')]) + ->build(); + } + + public function testSearchWithSampleStillThrows(): void + { + $this->expectException(Exception::class); + + (new Builder()) + ->from('logs') + ->sample(0.5) + ->filter([Query::search('content', 'hello')]) + ->build(); + } + + // ══════════════════════════════════════════════════════════════════ + // 6. ClickHouse rand() (10 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testRandomSortProducesLowercaseRand(): void + { + $result = (new Builder()) + ->from('events') + ->sortRandom() + ->build(); + + $this->assertStringContainsString('rand()', $result['query']); + $this->assertStringNotContainsString('RAND()', $result['query']); + } + + public function testRandomSortCombinedWithAsc(): void + { + $result = (new Builder()) + ->from('events') + ->sortAsc('name') + ->sortRandom() + ->build(); + + $this->assertEquals('SELECT * FROM `events` ORDER BY `name` ASC, rand()', $result['query']); + } + + public function testRandomSortCombinedWithDesc(): void + { + $result = (new Builder()) + ->from('events') + ->sortDesc('ts') + ->sortRandom() + ->build(); + + $this->assertEquals('SELECT * FROM `events` ORDER BY `ts` DESC, rand()', $result['query']); + } + + public function testRandomSortCombinedWithAscAndDesc(): void + { + $result = (new Builder()) + ->from('events') + ->sortAsc('name') + ->sortDesc('ts') + ->sortRandom() + ->build(); + + $this->assertEquals('SELECT * FROM `events` ORDER BY `name` ASC, `ts` DESC, rand()', $result['query']); + } + + public function testRandomSortWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sortRandom() + ->build(); + + $this->assertEquals('SELECT * FROM `events` FINAL ORDER BY rand()', $result['query']); + } + + public function testRandomSortWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->sortRandom() + ->build(); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 ORDER BY rand()', $result['query']); + } + + public function testRandomSortWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->sortRandom() + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` PREWHERE `type` IN (?) ORDER BY rand()', + $result['query'] + ); + } + + public function testRandomSortWithLimit(): void + { + $result = (new Builder()) + ->from('events') + ->sortRandom() + ->limit(10) + ->build(); + + $this->assertEquals('SELECT * FROM `events` ORDER BY rand() LIMIT ?', $result['query']); + $this->assertEquals([10], $result['bindings']); + } + + public function testRandomSortWithFiltersAndJoins(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->filter([Query::equal('status', ['active'])]) + ->sortRandom() + ->build(); + + $this->assertStringContainsString('JOIN `users`', $result['query']); + $this->assertStringContainsString('WHERE `status` IN (?)', $result['query']); + $this->assertStringContainsString('ORDER BY rand()', $result['query']); + } + + public function testRandomSortAlone(): void + { + $result = (new Builder()) + ->from('events') + ->sortRandom() + ->build(); + + $this->assertEquals('SELECT * FROM `events` ORDER BY rand()', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + // ══════════════════════════════════════════════════════════════════ + // 7. All filter types work correctly (31 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testFilterEqualSingleValue(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('a', ['x'])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $result['query']); + $this->assertEquals(['x'], $result['bindings']); + } + + public function testFilterEqualMultipleValues(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('a', ['x', 'y', 'z'])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?, ?, ?)', $result['query']); + $this->assertEquals(['x', 'y', 'z'], $result['bindings']); + } + + public function testFilterNotEqualSingleValue(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('a', 'x')])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` != ?', $result['query']); + $this->assertEquals(['x'], $result['bindings']); + } + + public function testFilterNotEqualMultipleValues(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('a', ['x', 'y'])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT IN (?, ?)', $result['query']); + $this->assertEquals(['x', 'y'], $result['bindings']); + } + + public function testFilterLessThanValue(): void + { + $result = (new Builder())->from('t')->filter([Query::lessThan('a', 10)])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` < ?', $result['query']); + $this->assertEquals([10], $result['bindings']); + } + + public function testFilterLessThanEqualValue(): void + { + $result = (new Builder())->from('t')->filter([Query::lessThanEqual('a', 10)])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` <= ?', $result['query']); + } + + public function testFilterGreaterThanValue(): void + { + $result = (new Builder())->from('t')->filter([Query::greaterThan('a', 10)])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` > ?', $result['query']); + } + + public function testFilterGreaterThanEqualValue(): void + { + $result = (new Builder())->from('t')->filter([Query::greaterThanEqual('a', 10)])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` >= ?', $result['query']); + } + + public function testFilterBetweenValues(): void + { + $result = (new Builder())->from('t')->filter([Query::between('a', 1, 10)])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` BETWEEN ? AND ?', $result['query']); + $this->assertEquals([1, 10], $result['bindings']); + } + + public function testFilterNotBetweenValues(): void + { + $result = (new Builder())->from('t')->filter([Query::notBetween('a', 1, 10)])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT BETWEEN ? AND ?', $result['query']); + } + + public function testFilterStartsWithValue(): void + { + $result = (new Builder())->from('t')->filter([Query::startsWith('a', 'foo')])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` LIKE ?', $result['query']); + $this->assertEquals(['foo%'], $result['bindings']); + } + + public function testFilterNotStartsWithValue(): void + { + $result = (new Builder())->from('t')->filter([Query::notStartsWith('a', 'foo')])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT LIKE ?', $result['query']); + $this->assertEquals(['foo%'], $result['bindings']); + } + + public function testFilterEndsWithValue(): void + { + $result = (new Builder())->from('t')->filter([Query::endsWith('a', 'bar')])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` LIKE ?', $result['query']); + $this->assertEquals(['%bar'], $result['bindings']); + } + + public function testFilterNotEndsWithValue(): void + { + $result = (new Builder())->from('t')->filter([Query::notEndsWith('a', 'bar')])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT LIKE ?', $result['query']); + $this->assertEquals(['%bar'], $result['bindings']); + } + + public function testFilterContainsSingleValue(): void + { + $result = (new Builder())->from('t')->filter([Query::contains('a', ['foo'])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` LIKE ?', $result['query']); + $this->assertEquals(['%foo%'], $result['bindings']); + } + + public function testFilterContainsMultipleValues(): void + { + $result = (new Builder())->from('t')->filter([Query::contains('a', ['foo', 'bar'])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` LIKE ? OR `a` LIKE ?)', $result['query']); + $this->assertEquals(['%foo%', '%bar%'], $result['bindings']); + } + + public function testFilterContainsAnyValues(): void + { + $result = (new Builder())->from('t')->filter([Query::containsAny('a', ['x', 'y'])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?, ?)', $result['query']); + } + + public function testFilterContainsAllValues(): void + { + $result = (new Builder())->from('t')->filter([Query::containsAll('a', ['x', 'y'])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` LIKE ? AND `a` LIKE ?)', $result['query']); + $this->assertEquals(['%x%', '%y%'], $result['bindings']); + } + + public function testFilterNotContainsSingleValue(): void + { + $result = (new Builder())->from('t')->filter([Query::notContains('a', ['foo'])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT LIKE ?', $result['query']); + $this->assertEquals(['%foo%'], $result['bindings']); + } + + public function testFilterNotContainsMultipleValues(): void + { + $result = (new Builder())->from('t')->filter([Query::notContains('a', ['foo', 'bar'])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` NOT LIKE ? AND `a` NOT LIKE ?)', $result['query']); + } + + public function testFilterIsNullValue(): void + { + $result = (new Builder())->from('t')->filter([Query::isNull('a')])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IS NULL', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testFilterIsNotNullValue(): void + { + $result = (new Builder())->from('t')->filter([Query::isNotNull('a')])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IS NOT NULL', $result['query']); + } + + public function testFilterExistsValue(): void + { + $result = (new Builder())->from('t')->filter([Query::exists(['a', 'b'])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IS NOT NULL AND `b` IS NOT NULL)', $result['query']); + } + + public function testFilterNotExistsValue(): void + { + $result = (new Builder())->from('t')->filter([Query::notExists(['a', 'b'])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IS NULL AND `b` IS NULL)', $result['query']); + } + + public function testFilterAndLogical(): void + { + $result = (new Builder())->from('t')->filter([ + Query::and([Query::equal('a', [1]), Query::equal('b', [2])]), + ])->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) AND `b` IN (?))', $result['query']); + } + + public function testFilterOrLogical(): void + { + $result = (new Builder())->from('t')->filter([ + Query::or([Query::equal('a', [1]), Query::equal('b', [2])]), + ])->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR `b` IN (?))', $result['query']); + } + + public function testFilterRaw(): void + { + $result = (new Builder())->from('t')->filter([Query::raw('x > ? AND y < ?', [1, 2])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE x > ? AND y < ?', $result['query']); + $this->assertEquals([1, 2], $result['bindings']); + } + + public function testFilterDeeplyNestedLogical(): void + { + $result = (new Builder())->from('t')->filter([ + Query::and([ + Query::or([ + Query::equal('a', [1]), + Query::and([ + Query::greaterThan('b', 2), + Query::lessThan('c', 3), + ]), + ]), + Query::equal('d', [4]), + ]), + ])->build(); + + $this->assertStringContainsString('(`a` IN (?) OR (`b` > ? AND `c` < ?))', $result['query']); + $this->assertStringContainsString('`d` IN (?)', $result['query']); + } + + public function testFilterWithFloats(): void + { + $result = (new Builder())->from('t')->filter([Query::greaterThan('price', 9.99)])->build(); + $this->assertEquals([9.99], $result['bindings']); + } + + public function testFilterWithNegativeNumbers(): void + { + $result = (new Builder())->from('t')->filter([Query::greaterThan('temp', -40)])->build(); + $this->assertEquals([-40], $result['bindings']); + } + + public function testFilterWithEmptyStrings(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('name', [''])])->build(); + $this->assertEquals([''], $result['bindings']); + } + + // ══════════════════════════════════════════════════════════════════ + // 8. Aggregation with ClickHouse features (15 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testAggregationCountWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->count('*', 'total') + ->build(); + + $this->assertEquals('SELECT COUNT(*) AS `total` FROM `events` FINAL', $result['query']); + } + + public function testAggregationSumWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->sum('amount', 'total_amount') + ->build(); + + $this->assertEquals('SELECT SUM(`amount`) AS `total_amount` FROM `events` SAMPLE 0.1', $result['query']); + } + + public function testAggregationAvgWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['sale'])]) + ->avg('price', 'avg_price') + ->build(); + + $this->assertStringContainsString('AVG(`price`) AS `avg_price`', $result['query']); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); + } + + public function testAggregationMinWithPrewhereWhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['sale'])]) + ->filter([Query::greaterThan('amount', 0)]) + ->min('price', 'min_price') + ->build(); + + $this->assertStringContainsString('MIN(`price`) AS `min_price`', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('WHERE', $result['query']); + } + + public function testAggregationMaxWithAllClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('type', ['sale'])]) + ->max('price', 'max_price') + ->build(); + + $this->assertStringContainsString('MAX(`price`) AS `max_price`', $result['query']); + $this->assertStringContainsString('FINAL SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + } + + public function testMultipleAggregationsWithPrewhereGroupByHaving(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['sale'])]) + ->count('*', 'cnt') + ->sum('amount', 'total') + ->groupBy(['region']) + ->having([Query::greaterThan('cnt', 10)]) + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result['query']); + $this->assertStringContainsString('SUM(`amount`) AS `total`', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('GROUP BY `region`', $result['query']); + $this->assertStringContainsString('HAVING `cnt` > ?', $result['query']); + } + + public function testAggregationWithJoinFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->join('users', 'events.uid', 'users.id') + ->count('*', 'total') + ->build(); + + $this->assertStringContainsString('FROM `events` FINAL', $result['query']); + $this->assertStringContainsString('JOIN `users`', $result['query']); + $this->assertStringContainsString('COUNT(*)', $result['query']); + } + + public function testAggregationWithDistinctSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->distinct() + ->count('user_id', 'unique_users') + ->build(); + + $this->assertStringContainsString('SELECT DISTINCT', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + } + + public function testAggregationWithAliasPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->count('*', 'click_count') + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `click_count`', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + } + + public function testAggregationWithoutAliasFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->count('*') + ->build(); + + $this->assertStringContainsString('COUNT(*)', $result['query']); + $this->assertStringNotContainsString(' AS ', $result['query']); + $this->assertStringContainsString('FINAL', $result['query']); + } + + public function testCountStarAllClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('type', ['click'])]) + ->count('*', 'total') + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `total`', $result['query']); + $this->assertStringContainsString('FINAL SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + } + + public function testAggregationAllFeaturesUnion(): void + { + $other = (new Builder())->from('archive')->count('*', 'total'); + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->count('*', 'total') + ->union($other) + ->build(); + + $this->assertStringContainsString('UNION', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + } + + public function testAggregationAttributeResolverPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->setAttributeResolver(fn (string $a): string => match ($a) { + 'amt' => 'amount_cents', + default => $a, + }) + ->prewhere([Query::equal('type', ['sale'])]) + ->sum('amt', 'total') + ->build(); + + $this->assertStringContainsString('SUM(`amount_cents`)', $result['query']); + } + + public function testAggregationConditionProviderPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['sale'])]) + ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->count('*', 'cnt') + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('tenant = ?', $result['query']); + } + + public function testGroupByHavingPrewhereFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->prewhere([Query::equal('type', ['sale'])]) + ->count('*', 'cnt') + ->groupBy(['region']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + + $query = $result['query']; + $this->assertStringContainsString('FINAL', $query); + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('GROUP BY', $query); + $this->assertStringContainsString('HAVING', $query); + } + + // ══════════════════════════════════════════════════════════════════ + // 9. Join with ClickHouse features (15 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testJoinWithFinalFeature(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->join('users', 'events.uid', 'users.id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` FINAL JOIN `users` ON `events.uid` = `users.id`', + $result['query'] + ); + } + + public function testJoinWithSampleFeature(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->join('users', 'events.uid', 'users.id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` SAMPLE 0.5 JOIN `users` ON `events.uid` = `users.id`', + $result['query'] + ); + } + + public function testJoinWithPrewhereFeature(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + + $this->assertStringContainsString('JOIN `users`', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + } + + public function testJoinWithPrewhereWhere(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('users.age', 18)]) + ->build(); + + $this->assertStringContainsString('JOIN', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('WHERE', $result['query']); + } + + public function testJoinAllClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('users.age', 18)]) + ->build(); + + $query = $result['query']; + $this->assertStringContainsString('FINAL SAMPLE 0.1', $query); + $this->assertStringContainsString('JOIN', $query); + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('WHERE', $query); + } + + public function testLeftJoinWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->leftJoin('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + + $this->assertStringContainsString('LEFT JOIN `users`', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + } + + public function testRightJoinWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->rightJoin('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + + $this->assertStringContainsString('RIGHT JOIN `users`', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + } + + public function testCrossJoinWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->crossJoin('config') + ->build(); + + $this->assertStringContainsString('FROM `events` FINAL', $result['query']); + $this->assertStringContainsString('CROSS JOIN `config`', $result['query']); + } + + public function testMultipleJoinsWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->leftJoin('sessions', 'events.sid', 'sessions.id') + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + + $this->assertStringContainsString('JOIN `users`', $result['query']); + $this->assertStringContainsString('LEFT JOIN `sessions`', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + } + + public function testJoinAggregationPrewhereGroupBy(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['sale'])]) + ->count('*', 'cnt') + ->groupBy(['users.country']) + ->build(); + + $this->assertStringContainsString('JOIN', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('GROUP BY', $result['query']); + } + + public function testJoinPrewhereBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('users.age', 18)]) + ->build(); + + $this->assertEquals(['click', 18], $result['bindings']); + } + + public function testJoinAttributeResolverPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->setAttributeResolver(fn (string $a): string => match ($a) { + 'uid' => 'user_id', + default => $a, + }) + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('uid', ['abc'])]) + ->build(); + + $this->assertStringContainsString('PREWHERE `user_id` IN (?)', $result['query']); + } + + public function testJoinConditionProviderPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('tenant = ?', $result['query']); + } + + public function testJoinPrewhereUnion(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->union($other) + ->build(); + + $this->assertStringContainsString('JOIN', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('UNION', $result['query']); + } + + public function testJoinClauseOrdering(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('age', 18)]) + ->build(); + + $query = $result['query']; + + $fromPos = strpos($query, 'FROM'); + $finalPos = strpos($query, 'FINAL'); + $samplePos = strpos($query, 'SAMPLE'); + $joinPos = strpos($query, 'JOIN'); + $prewherePos = strpos($query, 'PREWHERE'); + $wherePos = strpos($query, 'WHERE'); + + $this->assertLessThan($finalPos, $fromPos); + $this->assertLessThan($samplePos, $finalPos); + $this->assertLessThan($joinPos, $samplePos); + $this->assertLessThan($prewherePos, $joinPos); + $this->assertLessThan($wherePos, $prewherePos); + } + + // ══════════════════════════════════════════════════════════════════ + // 10. Union with ClickHouse features (10 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testUnionMainHasFinal(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->final() + ->union($other) + ->build(); + + $this->assertStringContainsString('FROM `events` FINAL', $result['query']); + $this->assertStringContainsString('UNION SELECT * FROM `archive`', $result['query']); + } + + public function testUnionMainHasSample(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->union($other) + ->build(); + + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('UNION', $result['query']); + } + + public function testUnionMainHasPrewhere(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->union($other) + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('UNION', $result['query']); + } + + public function testUnionMainHasAllClickHouseFeatures(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->union($other) + ->build(); + + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('UNION', $result['query']); + } + + public function testUnionAllWithPrewhere(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->unionAll($other) + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('UNION ALL', $result['query']); + } + + public function testUnionBindingOrderWithPrewhere(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::equal('year', [2024])]) + ->union($other) + ->build(); + + // prewhere, where, union + $this->assertEquals(['click', 2024, 2023], $result['bindings']); + } + + public function testMultipleUnionsWithPrewhere(): void + { + $other1 = (new Builder())->from('archive1'); + $other2 = (new Builder())->from('archive2'); + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->union($other1) + ->union($other2) + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertEquals(2, substr_count($result['query'], 'UNION')); + } + + public function testUnionJoinPrewhere(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->union($other) + ->build(); + + $this->assertStringContainsString('JOIN', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('UNION', $result['query']); + } + + public function testUnionAggregationPrewhereFinal(): void + { + $other = (new Builder())->from('archive')->count('*', 'total'); + $result = (new Builder()) + ->from('events') + ->final() + ->prewhere([Query::equal('type', ['click'])]) + ->count('*', 'total') + ->union($other) + ->build(); + + $this->assertStringContainsString('FINAL', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('COUNT(*)', $result['query']); + $this->assertStringContainsString('UNION', $result['query']); + } + + public function testUnionWithComplexMainQuery(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->select(['name', 'count']) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->sortDesc('count') + ->limit(10) + ->union($other) + ->build(); + + $query = $result['query']; + $this->assertStringContainsString('SELECT `name`, `count`', $query); + $this->assertStringContainsString('FINAL SAMPLE 0.1', $query); + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('WHERE', $query); + $this->assertStringContainsString('ORDER BY', $query); + $this->assertStringContainsString('LIMIT', $query); + $this->assertStringContainsString('UNION', $query); + } + + // ══════════════════════════════════════════════════════════════════ + // 11. toRawSql with ClickHouse features (15 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testToRawSqlWithFinalFeature(): void + { + $sql = (new Builder()) + ->from('events') + ->final() + ->toRawSql(); + + $this->assertEquals('SELECT * FROM `events` FINAL', $sql); + } + + public function testToRawSqlWithSampleFeature(): void + { + $sql = (new Builder()) + ->from('events') + ->sample(0.1) + ->toRawSql(); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1', $sql); + } + + public function testToRawSqlWithPrewhereFeature(): void + { + $sql = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `events` PREWHERE `type` IN ('click')", $sql); + } + + public function testToRawSqlWithPrewhereWhere(): void + { + $sql = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->toRawSql(); + + $this->assertEquals( + "SELECT * FROM `events` PREWHERE `type` IN ('click') WHERE `count` > 5", + $sql + ); + } + + public function testToRawSqlWithAllFeatures(): void + { + $sql = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->toRawSql(); + + $this->assertEquals( + "SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `type` IN ('click') WHERE `count` > 5", + $sql + ); + } + + public function testToRawSqlAllFeaturesCombined(): void + { + $sql = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->sortDesc('ts') + ->limit(10) + ->offset(20) + ->toRawSql(); + + $this->assertStringContainsString('FINAL SAMPLE 0.1', $sql); + $this->assertStringContainsString("PREWHERE `type` IN ('click')", $sql); + $this->assertStringContainsString('WHERE `count` > 5', $sql); + $this->assertStringContainsString('ORDER BY `ts` DESC', $sql); + $this->assertStringContainsString('LIMIT 10', $sql); + $this->assertStringContainsString('OFFSET 20', $sql); + } + + public function testToRawSqlWithStringBindings(): void + { + $sql = (new Builder()) + ->from('events') + ->filter([Query::equal('name', ['hello world'])]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `events` WHERE `name` IN ('hello world')", $sql); + } + + public function testToRawSqlWithNumericBindings(): void + { + $sql = (new Builder()) + ->from('events') + ->filter([Query::greaterThan('count', 42)]) + ->toRawSql(); + + $this->assertEquals('SELECT * FROM `events` WHERE `count` > 42', $sql); + } + + public function testToRawSqlWithBooleanBindings(): void + { + $sql = (new Builder()) + ->from('events') + ->filter([Query::equal('active', [true])]) + ->toRawSql(); + + $this->assertEquals('SELECT * FROM `events` WHERE `active` IN (1)', $sql); + } + + public function testToRawSqlWithNullBindings(): void + { + $sql = (new Builder()) + ->from('events') + ->filter([Query::raw('x = ?', [null])]) + ->toRawSql(); + + $this->assertEquals('SELECT * FROM `events` WHERE x = NULL', $sql); + } + + public function testToRawSqlWithFloatBindings(): void + { + $sql = (new Builder()) + ->from('events') + ->filter([Query::greaterThan('price', 9.99)]) + ->toRawSql(); + + $this->assertEquals('SELECT * FROM `events` WHERE `price` > 9.99', $sql); + } + + public function testToRawSqlCalledTwiceGivesSameResult(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]); + + $sql1 = $builder->toRawSql(); + $sql2 = $builder->toRawSql(); + + $this->assertEquals($sql1, $sql2); + } + + public function testToRawSqlWithUnionPrewhere(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $sql = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->union($other) + ->toRawSql(); + + $this->assertStringContainsString("PREWHERE `type` IN ('click')", $sql); + $this->assertStringContainsString('UNION', $sql); + } + + public function testToRawSqlWithJoinPrewhere(): void + { + $sql = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->toRawSql(); + + $this->assertStringContainsString('JOIN `users`', $sql); + $this->assertStringContainsString("PREWHERE `type` IN ('click')", $sql); + } + + public function testToRawSqlWithRegexMatch(): void + { + $sql = (new Builder()) + ->from('logs') + ->filter([Query::regex('path', '^/api')]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `logs` WHERE match(`path`, '^/api')", $sql); + } + + // ══════════════════════════════════════════════════════════════════ + // 12. Reset comprehensive (15 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testResetClearsPrewhereState(): void + { + $builder = (new Builder())->from('events')->prewhere([Query::equal('type', ['click'])]); + $builder->build(); + $builder->reset(); + $result = $builder->from('events')->build(); + + $this->assertStringNotContainsString('PREWHERE', $result['query']); + } + + public function testResetClearsFinalState(): void + { + $builder = (new Builder())->from('events')->final(); + $builder->build(); + $builder->reset(); + $result = $builder->from('events')->build(); + + $this->assertStringNotContainsString('FINAL', $result['query']); + } + + public function testResetClearsSampleState(): void + { + $builder = (new Builder())->from('events')->sample(0.5); + $builder->build(); + $builder->reset(); + $result = $builder->from('events')->build(); + + $this->assertStringNotContainsString('SAMPLE', $result['query']); + } + + public function testResetClearsAllThreeTogether(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('type', ['click'])]); + $builder->build(); + $builder->reset(); + $result = $builder->from('events')->build(); + + $this->assertEquals('SELECT * FROM `events`', $result['query']); + } + + public function testResetPreservesAttributeResolver(): void + { + $resolver = fn (string $a): string => 'r_' . $a; + $builder = (new Builder()) + ->from('events') + ->setAttributeResolver($resolver) + ->final(); + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->filter([Query::equal('col', ['v'])])->build(); + $this->assertStringContainsString('`r_col`', $result['query']); + } + + public function testResetPreservesConditionProviders(): void + { + $builder = (new Builder()) + ->from('events') + ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->final(); + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertStringContainsString('tenant = ?', $result['query']); + } + + public function testResetClearsTable(): void + { + $builder = (new Builder())->from('events'); + $builder->build(); + $builder->reset(); + + $result = $builder->from('logs')->build(); + $this->assertStringContainsString('FROM `logs`', $result['query']); + $this->assertStringNotContainsString('events', $result['query']); + } + + public function testResetClearsFilters(): void + { + $builder = (new Builder())->from('events')->filter([Query::equal('a', [1])]); + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertStringNotContainsString('WHERE', $result['query']); + } + + public function testResetClearsUnions(): void + { + $other = (new Builder())->from('archive'); + $builder = (new Builder())->from('events')->union($other); + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertStringNotContainsString('UNION', $result['query']); + } + + public function testResetClearsBindings(): void + { + $builder = (new Builder())->from('events')->filter([Query::equal('a', [1])]); + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertEquals([], $result['bindings']); + } + + public function testBuildAfterResetMinimalOutput(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->sortDesc('ts') + ->limit(10); + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testResetRebuildWithPrewhere(): void + { + $builder = new Builder(); + $builder->from('events')->final()->build(); + $builder->reset(); + + $result = $builder->from('events')->prewhere([Query::equal('x', [1])])->build(); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringNotContainsString('FINAL', $result['query']); + } + + public function testResetRebuildWithFinal(): void + { + $builder = new Builder(); + $builder->from('events')->prewhere([Query::equal('x', [1])])->build(); + $builder->reset(); + + $result = $builder->from('events')->final()->build(); + $this->assertStringContainsString('FINAL', $result['query']); + $this->assertStringNotContainsString('PREWHERE', $result['query']); + } + + public function testResetRebuildWithSample(): void + { + $builder = new Builder(); + $builder->from('events')->final()->build(); + $builder->reset(); + + $result = $builder->from('events')->sample(0.5)->build(); + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringNotContainsString('FINAL', $result['query']); + } + + public function testMultipleResets(): void + { + $builder = new Builder(); + + $builder->from('a')->final()->build(); + $builder->reset(); + $builder->from('b')->sample(0.5)->build(); + $builder->reset(); + $builder->from('c')->prewhere([Query::equal('x', [1])])->build(); + $builder->reset(); + + $result = $builder->from('d')->build(); + $this->assertEquals('SELECT * FROM `d`', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + // ══════════════════════════════════════════════════════════════════ + // 13. when() with ClickHouse features (10 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testWhenTrueAddsPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->when(true, fn (Builder $b) => $b->prewhere([Query::equal('type', ['click'])])) + ->build(); + + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); + } + + public function testWhenFalseDoesNotAddPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->when(false, fn (Builder $b) => $b->prewhere([Query::equal('type', ['click'])])) + ->build(); + + $this->assertStringNotContainsString('PREWHERE', $result['query']); + } + + public function testWhenTrueAddsFinal(): void + { + $result = (new Builder()) + ->from('events') + ->when(true, fn (Builder $b) => $b->final()) + ->build(); + + $this->assertStringContainsString('FINAL', $result['query']); + } + + public function testWhenFalseDoesNotAddFinal(): void + { + $result = (new Builder()) + ->from('events') + ->when(false, fn (Builder $b) => $b->final()) + ->build(); + + $this->assertStringNotContainsString('FINAL', $result['query']); + } + + public function testWhenTrueAddsSample(): void + { + $result = (new Builder()) + ->from('events') + ->when(true, fn (Builder $b) => $b->sample(0.5)) + ->build(); + + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + } + + public function testWhenWithBothPrewhereAndFilter(): void + { + $result = (new Builder()) + ->from('events') + ->when( + true, + fn (Builder $b) => $b + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ) + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('WHERE', $result['query']); + } + + public function testWhenNestedWithClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->when( + true, + fn (Builder $b) => $b + ->final() + ->when(true, fn (Builder $b2) => $b2->sample(0.5)) + ) + ->build(); + + $this->assertStringContainsString('FINAL SAMPLE 0.5', $result['query']); + } + + public function testWhenChainedMultipleTimesWithClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->when(true, fn (Builder $b) => $b->final()) + ->when(true, fn (Builder $b) => $b->sample(0.5)) + ->when(true, fn (Builder $b) => $b->prewhere([Query::equal('type', ['click'])])) + ->build(); + + $this->assertStringContainsString('FINAL SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + } + + public function testWhenAddsJoinAndPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->when( + true, + fn (Builder $b) => $b + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ) + ->build(); + + $this->assertStringContainsString('JOIN', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + } + + public function testWhenCombinedWithRegularWhen(): void + { + $result = (new Builder()) + ->from('events') + ->when(true, fn (Builder $b) => $b->final()) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->build(); + + $this->assertStringContainsString('FINAL', $result['query']); + $this->assertStringContainsString('WHERE `status` IN (?)', $result['query']); + } + + // ══════════════════════════════════════════════════════════════════ + // 14. Condition provider with ClickHouse (10 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testProviderWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->addConditionProvider(fn (string $t): array => ['deleted = ?', [0]]) + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('deleted = ?', $result['query']); + } + + public function testProviderWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->addConditionProvider(fn (string $t): array => ['deleted = ?', [0]]) + ->build(); + + $this->assertStringContainsString('FINAL', $result['query']); + $this->assertStringContainsString('deleted = ?', $result['query']); + } + + public function testProviderWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->addConditionProvider(fn (string $t): array => ['deleted = ?', [0]]) + ->build(); + + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('deleted = ?', $result['query']); + } + + public function testProviderPrewhereWhereBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->build(); + + // prewhere, filter, provider + $this->assertEquals(['click', 5, 't1'], $result['bindings']); + } + + public function testMultipleProvidersPrewhereBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->addConditionProvider(fn (string $t): array => ['org = ?', ['o1']]) + ->build(); + + $this->assertEquals(['click', 't1', 'o1'], $result['bindings']); + } + + public function testProviderPrewhereCursorLimitBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->cursorAfter('cur1') + ->sortAsc('_cursor') + ->limit(10) + ->build(); + + // prewhere, provider, cursor, limit + $this->assertEquals('click', $result['bindings'][0]); + $this->assertEquals('t1', $result['bindings'][1]); + $this->assertEquals('cur1', $result['bindings'][2]); + $this->assertEquals(10, $result['bindings'][3]); + } + + public function testProviderAllClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->build(); + + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('tenant = ?', $result['query']); + } + + public function testProviderPrewhereAggregation(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->count('*', 'cnt') + ->build(); + + $this->assertStringContainsString('COUNT(*)', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('tenant = ?', $result['query']); + } + + public function testProviderJoinsPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->build(); + + $this->assertStringContainsString('JOIN', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('tenant = ?', $result['query']); + } + + public function testProviderReferencesTableNameFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->addConditionProvider(fn (string $table): array => [ + $table . '.deleted = ?', + [0], + ]) + ->build(); + + $this->assertStringContainsString('events.deleted = ?', $result['query']); + $this->assertStringContainsString('FINAL', $result['query']); + } + + // ══════════════════════════════════════════════════════════════════ + // 15. Cursor with ClickHouse features (8 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testCursorAfterWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('_cursor > ?', $result['query']); + } + + public function testCursorBeforeWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->cursorBefore('abc') + ->sortAsc('_cursor') + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('_cursor < ?', $result['query']); + } + + public function testCursorPrewhereWhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('WHERE', $result['query']); + $this->assertStringContainsString('_cursor > ?', $result['query']); + } + + public function testCursorWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + + $this->assertStringContainsString('FINAL', $result['query']); + $this->assertStringContainsString('_cursor > ?', $result['query']); + } + + public function testCursorWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('_cursor > ?', $result['query']); + } + + public function testCursorPrewhereBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->cursorAfter('cur1') + ->sortAsc('_cursor') + ->build(); + + $this->assertEquals('click', $result['bindings'][0]); + $this->assertEquals('cur1', $result['bindings'][1]); + } + + public function testCursorPrewhereProviderBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->cursorAfter('cur1') + ->sortAsc('_cursor') + ->build(); + + $this->assertEquals('click', $result['bindings'][0]); + $this->assertEquals('t1', $result['bindings'][1]); + $this->assertEquals('cur1', $result['bindings'][2]); + } + + public function testCursorFullClickHousePipeline(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->cursorAfter('cur1') + ->sortAsc('_cursor') + ->limit(10) + ->build(); + + $query = $result['query']; + $this->assertStringContainsString('FINAL SAMPLE 0.1', $query); + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('WHERE', $query); + $this->assertStringContainsString('_cursor > ?', $query); + $this->assertStringContainsString('LIMIT', $query); + } + + // ══════════════════════════════════════════════════════════════════ + // 16. page() with ClickHouse features (5 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testPageWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->page(2, 25) + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('LIMIT ?', $result['query']); + $this->assertStringContainsString('OFFSET ?', $result['query']); + $this->assertEquals(['click', 25, 25], $result['bindings']); + } + + public function testPageWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->page(3, 10) + ->build(); + + $this->assertStringContainsString('FINAL', $result['query']); + $this->assertStringContainsString('LIMIT ?', $result['query']); + $this->assertStringContainsString('OFFSET ?', $result['query']); + $this->assertEquals([10, 20], $result['bindings']); + } + + public function testPageWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->page(1, 50) + ->build(); + + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertEquals([50, 0], $result['bindings']); + } + + public function testPageWithAllClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->page(2, 10) + ->build(); + + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('LIMIT', $result['query']); + $this->assertStringContainsString('OFFSET', $result['query']); + } + + public function testPageWithComplexClickHouseQuery(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->sortDesc('ts') + ->page(5, 20) + ->build(); + + $query = $result['query']; + $this->assertStringContainsString('FINAL', $query); + $this->assertStringContainsString('SAMPLE', $query); + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('WHERE', $query); + $this->assertStringContainsString('ORDER BY', $query); + $this->assertStringContainsString('LIMIT', $query); + $this->assertStringContainsString('OFFSET', $query); + } + + // ══════════════════════════════════════════════════════════════════ + // 17. Fluent chaining comprehensive (5 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testAllClickHouseMethodsReturnSameInstance(): void + { + $builder = new Builder(); + $this->assertSame($builder, $builder->final()); + $this->assertSame($builder, $builder->sample(0.5)); + $this->assertSame($builder, $builder->prewhere([])); + $this->assertSame($builder, $builder->reset()); + } + + public function testChainingClickHouseMethodsWithBaseMethods(): void + { + $builder = new Builder(); + $result = $builder + ->from('events') + ->final() + ->sample(0.1) + ->select(['name']) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->sortDesc('ts') + ->limit(10) + ->offset(20) + ->build(); + + $this->assertNotEmpty($result['query']); + } + + public function testChainingOrderDoesNotMatterForOutput(): void + { + $result1 = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + + $result2 = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->sample(0.1) + ->filter([Query::greaterThan('count', 5)]) + ->final() + ->build(); + + $this->assertEquals($result1['query'], $result2['query']); + } + + public function testSameComplexQueryDifferentOrders(): void + { + $result1 = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->sortDesc('ts') + ->limit(10) + ->build(); + + $result2 = (new Builder()) + ->from('events') + ->sortDesc('ts') + ->limit(10) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->sample(0.1) + ->final() + ->build(); + + $this->assertEquals($result1['query'], $result2['query']); + } + + public function testFluentResetThenRebuild(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.1); + $builder->build(); + + $result = $builder->reset() + ->from('logs') + ->sample(0.5) + ->build(); + + $this->assertEquals('SELECT * FROM `logs` SAMPLE 0.5', $result['query']); + $this->assertStringNotContainsString('FINAL', $result['query']); + } + + // ══════════════════════════════════════════════════════════════════ + // 18. SQL clause ordering verification (10 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testClauseOrderSelectFromFinalSampleJoinPrewhereWhereGroupByHavingOrderByLimitOffset(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->count('*', 'cnt') + ->select(['users.name']) + ->groupBy(['users.name']) + ->having([Query::greaterThan('cnt', 5)]) + ->sortDesc('cnt') + ->limit(50) + ->offset(10) + ->build(); + + $query = $result['query']; + + $selectPos = strpos($query, 'SELECT'); + $fromPos = strpos($query, 'FROM'); + $finalPos = strpos($query, 'FINAL'); + $samplePos = strpos($query, 'SAMPLE'); + $joinPos = strpos($query, 'JOIN'); + $prewherePos = strpos($query, 'PREWHERE'); + $wherePos = strpos($query, 'WHERE'); + $groupByPos = strpos($query, 'GROUP BY'); + $havingPos = strpos($query, 'HAVING'); + $orderByPos = strpos($query, 'ORDER BY'); + $limitPos = strpos($query, 'LIMIT'); + $offsetPos = strpos($query, 'OFFSET'); + + $this->assertLessThan($fromPos, $selectPos); + $this->assertLessThan($finalPos, $fromPos); + $this->assertLessThan($samplePos, $finalPos); + $this->assertLessThan($joinPos, $samplePos); + $this->assertLessThan($prewherePos, $joinPos); + $this->assertLessThan($wherePos, $prewherePos); + $this->assertLessThan($groupByPos, $wherePos); + $this->assertLessThan($havingPos, $groupByPos); + $this->assertLessThan($orderByPos, $havingPos); + $this->assertLessThan($limitPos, $orderByPos); + $this->assertLessThan($offsetPos, $limitPos); + } + + public function testFinalComesAfterTableBeforeJoin(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->join('users', 'events.uid', 'users.id') + ->build(); + + $query = $result['query']; + $tablePos = strpos($query, '`events`'); + $finalPos = strpos($query, 'FINAL'); + $joinPos = strpos($query, 'JOIN'); + + $this->assertLessThan($finalPos, $tablePos); + $this->assertLessThan($joinPos, $finalPos); + } + + public function testSampleComesAfterFinalBeforeJoin(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->join('users', 'events.uid', 'users.id') + ->build(); + + $query = $result['query']; + $finalPos = strpos($query, 'FINAL'); + $samplePos = strpos($query, 'SAMPLE'); + $joinPos = strpos($query, 'JOIN'); + + $this->assertLessThan($samplePos, $finalPos); + $this->assertLessThan($joinPos, $samplePos); + } + + public function testPrewhereComesAfterJoinBeforeWhere(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->build(); + + $query = $result['query']; + $joinPos = strpos($query, 'JOIN'); + $prewherePos = strpos($query, 'PREWHERE'); + $wherePos = strpos($query, 'WHERE'); + + $this->assertLessThan($prewherePos, $joinPos); + $this->assertLessThan($wherePos, $prewherePos); + } + + public function testPrewhereBeforeGroupBy(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->count('*', 'cnt') + ->groupBy(['type']) + ->build(); + + $query = $result['query']; + $prewherePos = strpos($query, 'PREWHERE'); + $groupByPos = strpos($query, 'GROUP BY'); + + $this->assertLessThan($groupByPos, $prewherePos); + } + + public function testPrewhereBeforeOrderBy(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->sortDesc('ts') + ->build(); + + $query = $result['query']; + $prewherePos = strpos($query, 'PREWHERE'); + $orderByPos = strpos($query, 'ORDER BY'); + + $this->assertLessThan($orderByPos, $prewherePos); + } + + public function testPrewhereBeforeLimit(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->limit(10) + ->build(); + + $query = $result['query']; + $prewherePos = strpos($query, 'PREWHERE'); + $limitPos = strpos($query, 'LIMIT'); + + $this->assertLessThan($limitPos, $prewherePos); + } + + public function testFinalSampleBeforePrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + + $query = $result['query']; + $finalPos = strpos($query, 'FINAL'); + $samplePos = strpos($query, 'SAMPLE'); + $prewherePos = strpos($query, 'PREWHERE'); + + $this->assertLessThan($samplePos, $finalPos); + $this->assertLessThan($prewherePos, $samplePos); + } + + public function testWhereBeforeHaving(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::greaterThan('count', 0)]) + ->count('*', 'cnt') + ->groupBy(['type']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + + $query = $result['query']; + $wherePos = strpos($query, 'WHERE'); + $havingPos = strpos($query, 'HAVING'); + + $this->assertLessThan($havingPos, $wherePos); + } + + public function testFullQueryAllClausesAllPositions(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->distinct() + ->select(['name']) + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->count('*', 'cnt') + ->groupBy(['name']) + ->having([Query::greaterThan('cnt', 5)]) + ->sortDesc('cnt') + ->limit(50) + ->offset(10) + ->union($other) + ->build(); + + $query = $result['query']; + + // All elements present + $this->assertStringContainsString('SELECT DISTINCT', $query); + $this->assertStringContainsString('FINAL', $query); + $this->assertStringContainsString('SAMPLE', $query); + $this->assertStringContainsString('JOIN', $query); + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('WHERE', $query); + $this->assertStringContainsString('GROUP BY', $query); + $this->assertStringContainsString('HAVING', $query); + $this->assertStringContainsString('ORDER BY', $query); + $this->assertStringContainsString('LIMIT', $query); + $this->assertStringContainsString('OFFSET', $query); + $this->assertStringContainsString('UNION', $query); + } + + // ══════════════════════════════════════════════════════════════════ + // 19. Batch mode with ClickHouse (5 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testQueriesMethodWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->queries([ + Query::equal('status', ['active']), + Query::orderDesc('ts'), + Query::limit(10), + ]) + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('WHERE `status` IN (?)', $result['query']); + $this->assertStringContainsString('ORDER BY', $result['query']); + $this->assertStringContainsString('LIMIT', $result['query']); + } + + public function testQueriesMethodWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->queries([ + Query::equal('status', ['active']), + Query::limit(10), + ]) + ->build(); + + $this->assertStringContainsString('FINAL', $result['query']); + $this->assertStringContainsString('WHERE `status` IN (?)', $result['query']); + } + + public function testQueriesMethodWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->queries([ + Query::equal('status', ['active']), + ]) + ->build(); + + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('WHERE', $result['query']); + } + + public function testQueriesMethodWithAllClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->queries([ + Query::equal('status', ['active']), + Query::orderDesc('ts'), + Query::limit(10), + ]) + ->build(); + + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('WHERE', $result['query']); + $this->assertStringContainsString('ORDER BY', $result['query']); + } + + public function testQueriesComparedToFluentApiSameSql(): void + { + $resultA = (new Builder()) + ->from('events') + ->filter([Query::equal('status', ['active'])]) + ->sortDesc('ts') + ->limit(10) + ->build(); + + $resultB = (new Builder()) + ->from('events') + ->queries([ + Query::equal('status', ['active']), + Query::orderDesc('ts'), + Query::limit(10), + ]) + ->build(); + + $this->assertEquals($resultA['query'], $resultB['query']); + $this->assertEquals($resultA['bindings'], $resultB['bindings']); + } + + // ══════════════════════════════════════════════════════════════════ + // 20. Edge cases (10 tests) + // ══════════════════════════════════════════════════════════════════ + + public function testEmptyTableNameWithFinal(): void + { + $result = (new Builder()) + ->from('') + ->final() + ->build(); + + $this->assertStringContainsString('FINAL', $result['query']); + } + + public function testEmptyTableNameWithSample(): void + { + $result = (new Builder()) + ->from('') + ->sample(0.5) + ->build(); + + $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + } + + public function testPrewhereWithEmptyFilterValues(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', [])]) + ->build(); + + $this->assertStringContainsString('PREWHERE', $result['query']); + } + + public function testVeryLongTableNameWithFinalSample(): void + { + $longName = str_repeat('a', 200); + $result = (new Builder()) + ->from($longName) + ->final() + ->sample(0.1) + ->build(); + + $this->assertStringContainsString('`' . $longName . '`', $result['query']); + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result['query']); + } + + public function testMultipleBuildsConsistentOutput(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]); + + $result1 = $builder->build(); + $result2 = $builder->build(); + $result3 = $builder->build(); + + $this->assertEquals($result1['query'], $result2['query']); + $this->assertEquals($result2['query'], $result3['query']); + $this->assertEquals($result1['bindings'], $result2['bindings']); + $this->assertEquals($result2['bindings'], $result3['bindings']); + } + + public function testBuildResetsBindingsButNotClickHouseState(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]); + + $result1 = $builder->build(); + $result2 = $builder->build(); + + // ClickHouse state persists + $this->assertStringContainsString('FINAL', $result2['query']); + $this->assertStringContainsString('SAMPLE', $result2['query']); + $this->assertStringContainsString('PREWHERE', $result2['query']); + + // Bindings are consistent + $this->assertEquals($result1['bindings'], $result2['bindings']); + } + + public function testSampleWithAllBindingTypes(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->cursorAfter('cur1') + ->sortAsc('_cursor') + ->filter([Query::greaterThan('count', 5)]) + ->count('*', 'cnt') + ->groupBy(['type']) + ->having([Query::greaterThan('cnt', 10)]) + ->limit(50) + ->offset(100) + ->union($other) + ->build(); + + // Verify all binding types present + $this->assertNotEmpty($result['bindings']); + $this->assertGreaterThan(5, count($result['bindings'])); + } + + public function testPrewhereAppearsCorrectlyWithoutJoins(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + + $query = $result['query']; + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('WHERE', $query); + + $prewherePos = strpos($query, 'PREWHERE'); + $wherePos = strpos($query, 'WHERE'); + $this->assertLessThan($wherePos, $prewherePos); + } + + public function testPrewhereAppearsCorrectlyWithJoins(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + + $query = $result['query']; + $joinPos = strpos($query, 'JOIN'); + $prewherePos = strpos($query, 'PREWHERE'); + $wherePos = strpos($query, 'WHERE'); + + $this->assertLessThan($prewherePos, $joinPos); + $this->assertLessThan($wherePos, $prewherePos); + } + + public function testFinalSampleTextInOutputWithJoins(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->join('users', 'events.uid', 'users.id') + ->leftJoin('sessions', 'events.sid', 'sessions.id') + ->build(); + + $query = $result['query']; + $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.1', $query); + $this->assertStringContainsString('JOIN `users`', $query); + $this->assertStringContainsString('LEFT JOIN `sessions`', $query); + + // FINAL SAMPLE appears before JOINs + $finalSamplePos = strpos($query, 'FINAL SAMPLE 0.1'); + $joinPos = strpos($query, 'JOIN'); + $this->assertLessThan($joinPos, $finalSamplePos); + } + + // ══════════════════════════════════════════════════════════════════ + // 1. Spatial/Vector/ElemMatch Exception Tests + // ══════════════════════════════════════════════════════════════════ + + public function testFilterCrossesThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::crosses('attr', [1])])->build(); + } + + public function testFilterNotCrossesThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::notCrosses('attr', [1])])->build(); + } + + public function testFilterDistanceEqualThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::distanceEqual('attr', [0, 0], 1)])->build(); + } + + public function testFilterDistanceNotEqualThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::distanceNotEqual('attr', [0, 0], 1)])->build(); + } + + public function testFilterDistanceGreaterThanThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::distanceGreaterThan('attr', [0, 0], 1)])->build(); + } + + public function testFilterDistanceLessThanThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::distanceLessThan('attr', [0, 0], 1)])->build(); + } + + public function testFilterIntersectsThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::intersects('attr', [1])])->build(); + } + + public function testFilterNotIntersectsThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::notIntersects('attr', [1])])->build(); + } + + public function testFilterOverlapsThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::overlaps('attr', [1])])->build(); + } + + public function testFilterNotOverlapsThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::notOverlaps('attr', [1])])->build(); + } + + public function testFilterTouchesThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::touches('attr', [1])])->build(); + } + + public function testFilterNotTouchesThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::notTouches('attr', [1])])->build(); + } + + public function testFilterVectorDotThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::vectorDot('attr', [1.0, 2.0])])->build(); + } + + public function testFilterVectorCosineThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::vectorCosine('attr', [1.0, 2.0])])->build(); + } + + public function testFilterVectorEuclideanThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::vectorEuclidean('attr', [1.0, 2.0])])->build(); + } + + public function testFilterElemMatchThrowsException(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::elemMatch('attr', [Query::equal('x', [1])])])->build(); + } + + // ══════════════════════════════════════════════════════════════════ + // 2. SAMPLE Boundary Values + // ══════════════════════════════════════════════════════════════════ + + public function testSampleZero(): void + { + $result = (new Builder())->from('t')->sample(0.0)->build(); + $this->assertStringContainsString('SAMPLE 0', $result['query']); + } + + public function testSampleOne(): void + { + $result = (new Builder())->from('t')->sample(1.0)->build(); + $this->assertStringContainsString('SAMPLE 1', $result['query']); + } + + public function testSampleNegative(): void + { + // Builder doesn't validate - it passes through + $result = (new Builder())->from('t')->sample(-0.5)->build(); + $this->assertStringContainsString('SAMPLE -0.5', $result['query']); + } + + public function testSampleGreaterThanOne(): void + { + $result = (new Builder())->from('t')->sample(2.0)->build(); + $this->assertStringContainsString('SAMPLE 2', $result['query']); + } + + public function testSampleVerySmall(): void + { + $result = (new Builder())->from('t')->sample(0.001)->build(); + $this->assertStringContainsString('SAMPLE 0.001', $result['query']); + } + + // ══════════════════════════════════════════════════════════════════ + // 3. Standalone Compiler Method Tests + // ══════════════════════════════════════════════════════════════════ + + public function testCompileFilterStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::greaterThan('age', 18)); + $this->assertEquals('`age` > ?', $sql); + $this->assertEquals([18], $builder->getBindings()); + } + + public function testCompileOrderAscStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderAsc('name')); + $this->assertEquals('`name` ASC', $sql); + } + + public function testCompileOrderDescStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderDesc('name')); + $this->assertEquals('`name` DESC', $sql); + } + + public function testCompileOrderRandomStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderRandom()); + $this->assertEquals('rand()', $sql); + } + + public function testCompileOrderExceptionStandalone(): void + { + $builder = new Builder(); + $this->expectException(\Utopia\Query\Exception::class); + $builder->compileOrder(Query::limit(10)); + } + + public function testCompileLimitStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileLimit(Query::limit(10)); + $this->assertEquals('LIMIT ?', $sql); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testCompileOffsetStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOffset(Query::offset(5)); + $this->assertEquals('OFFSET ?', $sql); + $this->assertEquals([5], $builder->getBindings()); + } + + public function testCompileSelectStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileSelect(Query::select(['a', 'b'])); + $this->assertEquals('`a`, `b`', $sql); + } + + public function testCompileSelectEmptyStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileSelect(Query::select([])); + $this->assertEquals('', $sql); + } + + public function testCompileCursorAfterStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileCursor(Query::cursorAfter('abc')); + $this->assertEquals('_cursor > ?', $sql); + $this->assertEquals(['abc'], $builder->getBindings()); + } + + public function testCompileCursorBeforeStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileCursor(Query::cursorBefore('xyz')); + $this->assertEquals('_cursor < ?', $sql); + $this->assertEquals(['xyz'], $builder->getBindings()); + } + + public function testCompileAggregateCountStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::count('*', 'total')); + $this->assertEquals('COUNT(*) AS `total`', $sql); + } + + public function testCompileAggregateSumStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::sum('price')); + $this->assertEquals('SUM(`price`)', $sql); + } + + public function testCompileAggregateAvgWithAliasStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::avg('score', 'avg_score')); + $this->assertEquals('AVG(`score`) AS `avg_score`', $sql); + } + + public function testCompileGroupByStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileGroupBy(Query::groupBy(['status', 'country'])); + $this->assertEquals('`status`, `country`', $sql); + } + + public function testCompileGroupByEmptyStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileGroupBy(Query::groupBy([])); + $this->assertEquals('', $sql); + } + + public function testCompileJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::join('orders', 'u.id', 'o.uid')); + $this->assertEquals('JOIN `orders` ON `u.id` = `o.uid`', $sql); + } + + public function testCompileJoinExceptionStandalone(): void + { + $builder = new Builder(); + $this->expectException(\Utopia\Query\Exception::class); + $builder->compileJoin(Query::equal('x', [1])); + } + + // ══════════════════════════════════════════════════════════════════ + // 4. Union with ClickHouse Features on Both Sides + // ══════════════════════════════════════════════════════════════════ + + public function testUnionBothWithClickHouseFeatures(): void + { + $sub = (new Builder())->from('archive') + ->final() + ->sample(0.5) + ->filter([Query::equal('status', ['closed'])]); + $result = (new Builder())->from('events') + ->final() + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->union($sub) + ->build(); + $this->assertStringContainsString('FROM `events` FINAL', $result['query']); + $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('UNION', $result['query']); + $this->assertStringContainsString('FROM `archive` FINAL SAMPLE 0.5', $result['query']); + } + + public function testUnionAllBothWithFinal(): void + { + $sub = (new Builder())->from('b')->final(); + $result = (new Builder())->from('a')->final() + ->unionAll($sub) + ->build(); + $this->assertStringContainsString('FROM `a` FINAL', $result['query']); + $this->assertStringContainsString('UNION ALL SELECT * FROM `b` FINAL', $result['query']); + } + + // ══════════════════════════════════════════════════════════════════ + // 5. PREWHERE Binding Order Exhaustive Tests + // ══════════════════════════════════════════════════════════════════ + + public function testPrewhereBindingOrderWithFilterAndHaving(): void + { + $result = (new Builder())->from('t') + ->count('*', 'total') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->groupBy(['type']) + ->having([Query::greaterThan('total', 10)]) + ->build(); + // Binding order: prewhere, filter, having + $this->assertEquals(['click', 5, 10], $result['bindings']); + } + + public function testPrewhereBindingOrderWithProviderAndCursor(): void + { + $result = (new Builder())->from('t') + ->prewhere([Query::equal('type', ['click'])]) + ->addConditionProvider(fn (string $t) => ["_tenant = ?", ['t1']]) + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + // Binding order: prewhere, filter(none), provider, cursor + $this->assertEquals(['click', 't1', 'abc'], $result['bindings']); + } + + public function testPrewhereMultipleFiltersBindingOrder(): void + { + $result = (new Builder())->from('t') + ->prewhere([ + Query::equal('type', ['a']), + Query::greaterThan('priority', 3), + ]) + ->filter([Query::lessThan('age', 30)]) + ->limit(10) + ->build(); + // prewhere bindings first, then filter, then limit + $this->assertEquals(['a', 3, 30, 10], $result['bindings']); + } + + // ══════════════════════════════════════════════════════════════════ + // 6. Search Exception in PREWHERE Interaction + // ══════════════════════════════════════════════════════════════════ + + public function testSearchInFilterThrowsExceptionWithMessage(): void + { + $this->expectException(\Utopia\Query\Exception::class); + $this->expectExceptionMessage('Full-text search'); + (new Builder())->from('t')->filter([Query::search('content', 'hello')])->build(); + } + + public function testSearchInPrewhereThrowsExceptionWithMessage(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->prewhere([Query::search('content', 'hello')])->build(); + } + + // ══════════════════════════════════════════════════════════════════ + // 7. Join Combinations with FINAL/SAMPLE + // ══════════════════════════════════════════════════════════════════ + + public function testLeftJoinWithFinalAndSample(): void + { + $result = (new Builder())->from('events') + ->final() + ->sample(0.1) + ->leftJoin('users', 'events.uid', 'users.id') + ->build(); + $this->assertEquals( + 'SELECT * FROM `events` FINAL SAMPLE 0.1 LEFT JOIN `users` ON `events.uid` = `users.id`', + $result['query'] + ); + } + + public function testRightJoinWithFinalFeature(): void + { + $result = (new Builder())->from('events') + ->final() + ->rightJoin('users', 'events.uid', 'users.id') + ->build(); + $this->assertStringContainsString('FROM `events` FINAL', $result['query']); + $this->assertStringContainsString('RIGHT JOIN', $result['query']); + } + + public function testCrossJoinWithPrewhereFeature(): void + { + $result = (new Builder())->from('events') + ->crossJoin('colors') + ->prewhere([Query::equal('type', ['a'])]) + ->build(); + $this->assertStringContainsString('CROSS JOIN `colors`', $result['query']); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); + $this->assertEquals(['a'], $result['bindings']); + } + + public function testJoinWithNonDefaultOperator(): void + { + $result = (new Builder())->from('t') + ->join('other', 'a', 'b', '!=') + ->build(); + $this->assertStringContainsString('JOIN `other` ON `a` != `b`', $result['query']); + } + + // ══════════════════════════════════════════════════════════════════ + // 8. Condition Provider Position Verification + // ══════════════════════════════════════════════════════════════════ + + public function testConditionProviderInWhereNotPrewhere(): void + { + $result = (new Builder())->from('t') + ->prewhere([Query::equal('type', ['click'])]) + ->addConditionProvider(fn (string $t) => ["_tenant = ?", ['t1']]) + ->build(); + $query = $result['query']; + $prewherePos = strpos($query, 'PREWHERE'); + $wherePos = strpos($query, 'WHERE'); + // Provider should be in WHERE which comes after PREWHERE + $this->assertNotFalse($prewherePos); + $this->assertNotFalse($wherePos); + $this->assertGreaterThan($prewherePos, $wherePos); + $this->assertStringContainsString('WHERE _tenant = ?', $query); + } + + public function testConditionProviderWithNoFiltersClickHouse(): void + { + $result = (new Builder())->from('t') + ->addConditionProvider(fn (string $t) => ["_deleted = ?", [0]]) + ->build(); + $this->assertEquals('SELECT * FROM `t` WHERE _deleted = ?', $result['query']); + $this->assertEquals([0], $result['bindings']); + } + + // ══════════════════════════════════════════════════════════════════ + // 9. Page Boundary Values + // ══════════════════════════════════════════════════════════════════ + + public function testPageZero(): void + { + $result = (new Builder())->from('t')->page(0, 10)->build(); + $this->assertStringContainsString('LIMIT ?', $result['query']); + $this->assertStringContainsString('OFFSET ?', $result['query']); + // page 0 -> offset clamped to 0 + $this->assertEquals([10, 0], $result['bindings']); + } + + public function testPageNegative(): void + { + $result = (new Builder())->from('t')->page(-1, 10)->build(); + $this->assertEquals([10, 0], $result['bindings']); + } + + public function testPageLargeNumber(): void + { + $result = (new Builder())->from('t')->page(1000000, 25)->build(); + $this->assertEquals([25, 24999975], $result['bindings']); + } + + // ══════════════════════════════════════════════════════════════════ + // 10. Build Without From + // ══════════════════════════════════════════════════════════════════ + + public function testBuildWithoutFrom(): void + { + $result = (new Builder())->filter([Query::equal('x', [1])])->build(); + $this->assertStringContainsString('FROM ``', $result['query']); + } + + // ══════════════════════════════════════════════════════════════════ + // 11. toRawSql Edge Cases for ClickHouse + // ══════════════════════════════════════════════════════════════════ + + public function testToRawSqlWithFinalAndSampleEdge(): void + { + $sql = (new Builder())->from('events') + ->final() + ->sample(0.1) + ->filter([Query::equal('type', ['click'])]) + ->toRawSql(); + $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.1', $sql); + $this->assertStringContainsString("'click'", $sql); + } + + public function testToRawSqlWithPrewhereEdge(): void + { + $sql = (new Builder())->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->toRawSql(); + $this->assertStringContainsString('PREWHERE', $sql); + $this->assertStringContainsString("'click'", $sql); + $this->assertStringContainsString('5', $sql); + } + + public function testToRawSqlWithUnionEdge(): void + { + $sub = (new Builder())->from('b')->filter([Query::equal('x', [1])]); + $sql = (new Builder())->from('a')->final() + ->filter([Query::equal('y', [2])]) + ->union($sub) + ->toRawSql(); + $this->assertStringContainsString('FINAL', $sql); + $this->assertStringContainsString('UNION', $sql); + } + + public function testToRawSqlWithBoolFalse(): void + { + $sql = (new Builder())->from('t')->filter([Query::equal('active', [false])])->toRawSql(); + $this->assertStringContainsString('0', $sql); + } + + public function testToRawSqlWithNull(): void + { + $sql = (new Builder())->from('t')->filter([Query::raw('col = ?', [null])])->toRawSql(); + $this->assertStringContainsString('NULL', $sql); + } + + public function testToRawSqlMixedTypes(): void + { + $sql = (new Builder())->from('t') + ->filter([ + Query::equal('name', ['str']), + Query::greaterThan('age', 42), + Query::lessThan('score', 9.99), + ]) + ->toRawSql(); + $this->assertStringContainsString("'str'", $sql); + $this->assertStringContainsString('42', $sql); + $this->assertStringContainsString('9.99', $sql); + } + + // ══════════════════════════════════════════════════════════════════ + // 12. Having with Multiple Sub-Queries + // ══════════════════════════════════════════════════════════════════ + + public function testHavingMultipleSubQueries(): void + { + $result = (new Builder())->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->having([ + Query::greaterThan('total', 5), + Query::lessThan('total', 100), + ]) + ->build(); + $this->assertStringContainsString('HAVING `total` > ? AND `total` < ?', $result['query']); + $this->assertContains(5, $result['bindings']); + $this->assertContains(100, $result['bindings']); + } + + public function testHavingWithOrLogic(): void + { + $result = (new Builder())->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->having([Query::or([ + Query::greaterThan('total', 100), + Query::lessThan('total', 5), + ])]) + ->build(); + $this->assertStringContainsString('HAVING (`total` > ? OR `total` < ?)', $result['query']); + } + + // ══════════════════════════════════════════════════════════════════ + // 13. Reset Property-by-Property Verification + // ══════════════════════════════════════════════════════════════════ + + public function testResetClearsClickHouseProperties(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->limit(10); + + $builder->reset()->from('other'); + $result = $builder->build(); + + $this->assertEquals('SELECT * FROM `other`', $result['query']); + $this->assertEquals([], $result['bindings']); + $this->assertStringNotContainsString('FINAL', $result['query']); + $this->assertStringNotContainsString('SAMPLE', $result['query']); + $this->assertStringNotContainsString('PREWHERE', $result['query']); + } + + public function testResetFollowedByUnion(): void + { + $builder = (new Builder())->from('a') + ->final() + ->union((new Builder())->from('old')); + $builder->reset()->from('b'); + $result = $builder->build(); + $this->assertEquals('SELECT * FROM `b`', $result['query']); + $this->assertStringNotContainsString('UNION', $result['query']); + $this->assertStringNotContainsString('FINAL', $result['query']); + } + + public function testConditionProviderPersistsAfterReset(): void + { + $builder = (new Builder()) + ->from('t') + ->final() + ->addConditionProvider(fn (string $t) => ["_tenant = ?", ['t1']]); + $builder->build(); + $builder->reset()->from('other'); + $result = $builder->build(); + $this->assertStringContainsString('FROM `other`', $result['query']); + $this->assertStringNotContainsString('FINAL', $result['query']); + $this->assertStringContainsString('_tenant = ?', $result['query']); + } + + // ══════════════════════════════════════════════════════════════════ + // 14. Exact Full SQL Assertions + // ══════════════════════════════════════════════════════════════════ + + public function testFinalSamplePrewhereFilterExactSql(): void + { + $result = (new Builder())->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('event_type', ['purchase'])]) + ->filter([Query::greaterThan('amount', 100)]) + ->sortDesc('amount') + ->limit(50) + ->build(); + $this->assertEquals( + 'SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `event_type` IN (?) WHERE `amount` > ? ORDER BY `amount` DESC LIMIT ?', + $result['query'] + ); + $this->assertEquals(['purchase', 100, 50], $result['bindings']); + } + + public function testKitchenSinkExactSql(): void + { + $sub = (new Builder())->from('archive')->final()->filter([Query::equal('status', ['closed'])]); + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->distinct() + ->count('*', 'total') + ->select(['event_type']) + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('event_type', ['purchase'])]) + ->filter([Query::greaterThan('amount', 100)]) + ->groupBy(['event_type']) + ->having([Query::greaterThan('total', 5)]) + ->sortDesc('total') + ->limit(50) + ->offset(10) + ->union($sub) + ->build(); + $this->assertEquals( + 'SELECT DISTINCT COUNT(*) AS `total`, `event_type` FROM `events` FINAL SAMPLE 0.1 JOIN `users` ON `events.uid` = `users.id` PREWHERE `event_type` IN (?) WHERE `amount` > ? GROUP BY `event_type` HAVING `total` > ? ORDER BY `total` DESC LIMIT ? OFFSET ? UNION SELECT * FROM `archive` FINAL WHERE `status` IN (?)', + $result['query'] + ); + $this->assertEquals(['purchase', 100, 5, 50, 10, 'closed'], $result['bindings']); + } + + // ══════════════════════════════════════════════════════════════════ + // 15. Query::compile() Integration Tests + // ══════════════════════════════════════════════════════════════════ + + public function testQueryCompileFilterViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::greaterThan('age', 18)->compile($builder); + $this->assertEquals('`age` > ?', $sql); + } + + public function testQueryCompileRegexViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::regex('path', '^/api')->compile($builder); + $this->assertEquals('match(`path`, ?)', $sql); + } + + public function testQueryCompileOrderRandomViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::orderRandom()->compile($builder); + $this->assertEquals('rand()', $sql); + } + + public function testQueryCompileLimitViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::limit(10)->compile($builder); + $this->assertEquals('LIMIT ?', $sql); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testQueryCompileSelectViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::select(['a', 'b'])->compile($builder); + $this->assertEquals('`a`, `b`', $sql); + } + + public function testQueryCompileJoinViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::join('orders', 'u.id', 'o.uid')->compile($builder); + $this->assertEquals('JOIN `orders` ON `u.id` = `o.uid`', $sql); + } + + public function testQueryCompileGroupByViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::groupBy(['status'])->compile($builder); + $this->assertEquals('`status`', $sql); + } + + // ══════════════════════════════════════════════════════════════════ + // 16. Binding Type Assertions with assertSame + // ══════════════════════════════════════════════════════════════════ + + public function testBindingTypesPreservedInt(): void + { + $result = (new Builder())->from('t')->filter([Query::greaterThan('age', 18)])->build(); + $this->assertSame([18], $result['bindings']); + } + + public function testBindingTypesPreservedFloat(): void + { + $result = (new Builder())->from('t')->filter([Query::greaterThan('score', 9.5)])->build(); + $this->assertSame([9.5], $result['bindings']); + } + + public function testBindingTypesPreservedBool(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('active', [true])])->build(); + $this->assertSame([true], $result['bindings']); + } + + public function testBindingTypesPreservedNull(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('val', [null])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `val` IS NULL', $result['query']); + $this->assertSame([], $result['bindings']); + } + + public function testEqualWithNullAndNonNull(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('col', ['a', null])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` IN (?) OR `col` IS NULL)', $result['query']); + $this->assertSame(['a'], $result['bindings']); + } + + public function testNotEqualWithNullOnly(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('col', [null])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NOT NULL', $result['query']); + $this->assertSame([], $result['bindings']); + } + + public function testNotEqualWithNullAndNonNull(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', 'b', null])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` NOT IN (?, ?) AND `col` IS NOT NULL)', $result['query']); + $this->assertSame(['a', 'b'], $result['bindings']); + } + + public function testBindingTypesPreservedString(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('name', ['hello'])])->build(); + $this->assertSame(['hello'], $result['bindings']); + } + + // ══════════════════════════════════════════════════════════════════ + // 17. Raw Inside Logical Groups + // ══════════════════════════════════════════════════════════════════ + + public function testRawInsideLogicalAnd(): void + { + $result = (new Builder())->from('t') + ->filter([Query::and([ + Query::greaterThan('x', 1), + Query::raw('custom_func(y) > ?', [5]), + ])]) + ->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`x` > ? AND custom_func(y) > ?)', $result['query']); + $this->assertEquals([1, 5], $result['bindings']); + } + + public function testRawInsideLogicalOr(): void + { + $result = (new Builder())->from('t') + ->filter([Query::or([ + Query::equal('a', [1]), + Query::raw('b IS NOT NULL', []), + ])]) + ->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result['query']); + $this->assertEquals([1], $result['bindings']); + } + + // ══════════════════════════════════════════════════════════════════ + // 18. Negative/Zero Limit and Offset + // ══════════════════════════════════════════════════════════════════ + + public function testNegativeLimit(): void + { + $result = (new Builder())->from('t')->limit(-1)->build(); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result['query']); + $this->assertEquals([-1], $result['bindings']); + } + + public function testNegativeOffset(): void + { + $result = (new Builder())->from('t')->offset(-5)->build(); + $this->assertEquals('SELECT * FROM `t` OFFSET ?', $result['query']); + $this->assertEquals([-5], $result['bindings']); + } + + public function testLimitZero(): void + { + $result = (new Builder())->from('t')->limit(0)->build(); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result['query']); + $this->assertEquals([0], $result['bindings']); + } + + // ══════════════════════════════════════════════════════════════════ + // 19. Multiple Limits/Offsets/Cursors First Wins + // ══════════════════════════════════════════════════════════════════ + + public function testMultipleLimitsFirstWins(): void + { + $result = (new Builder())->from('t')->limit(10)->limit(20)->build(); + $this->assertEquals([10], $result['bindings']); + } + + public function testMultipleOffsetsFirstWins(): void + { + $result = (new Builder())->from('t')->offset(5)->offset(50)->build(); + $this->assertEquals([5], $result['bindings']); + } + + public function testCursorAfterAndBeforeFirstWins(): void + { + $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->sortAsc('_cursor')->build(); + $this->assertStringContainsString('_cursor > ?', $result['query']); + } + + // ══════════════════════════════════════════════════════════════════ + // 20. Distinct + Union + // ══════════════════════════════════════════════════════════════════ + + public function testDistinctWithUnion(): void + { + $other = (new Builder())->from('b'); + $result = (new Builder())->from('a')->distinct()->union($other)->build(); + $this->assertEquals('SELECT DISTINCT * FROM `a` UNION SELECT * FROM `b`', $result['query']); + } +} diff --git a/tests/Query/Builder/SQLTest.php b/tests/Query/Builder/SQLTest.php new file mode 100644 index 0000000..7829db1 --- /dev/null +++ b/tests/Query/Builder/SQLTest.php @@ -0,0 +1,6378 @@ +assertInstanceOf(Compiler::class, $builder); + } + + public function testStandaloneCompile(): void + { + $builder = new Builder(); + + $filter = Query::greaterThan('age', 18); + $sql = $filter->compile($builder); + $this->assertEquals('`age` > ?', $sql); + $this->assertEquals([18], $builder->getBindings()); + } + + // ── Fluent API ── + + public function testFluentSelectFromFilterSortLimitOffset(): void + { + $result = (new Builder()) + ->select(['name', 'email']) + ->from('users') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->sortAsc('name') + ->limit(25) + ->offset(0) + ->build(); + + $this->assertEquals( + 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', + $result['query'] + ); + $this->assertEquals(['active', 18, 25, 0], $result['bindings']); + } + + // ── Batch mode ── + + public function testBatchModeProducesSameOutput(): void + { + $result = (new Builder()) + ->from('users') + ->queries([ + Query::select(['name', 'email']), + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + Query::orderAsc('name'), + Query::limit(25), + Query::offset(0), + ]) + ->build(); + + $this->assertEquals( + 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', + $result['query'] + ); + $this->assertEquals(['active', 18, 25, 0], $result['bindings']); + } + + // ── Filter types ── + + public function testEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active', 'pending'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?, ?)', $result['query']); + $this->assertEquals(['active', 'pending'], $result['bindings']); + } + + public function testNotEqualSingle(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('role', 'guest')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `role` != ?', $result['query']); + $this->assertEquals(['guest'], $result['bindings']); + } + + public function testNotEqualMultiple(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('role', ['guest', 'banned'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result['query']); + $this->assertEquals(['guest', 'banned'], $result['bindings']); + } + + public function testLessThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('price', 100)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `price` < ?', $result['query']); + $this->assertEquals([100], $result['bindings']); + } + + public function testLessThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThanEqual('price', 100)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `price` <= ?', $result['query']); + $this->assertEquals([100], $result['bindings']); + } + + public function testGreaterThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('age', 18)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `age` > ?', $result['query']); + $this->assertEquals([18], $result['bindings']); + } + + public function testGreaterThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('score', 90)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `score` >= ?', $result['query']); + $this->assertEquals([90], $result['bindings']); + } + + public function testBetween(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 18, 65)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result['query']); + $this->assertEquals([18, 65], $result['bindings']); + } + + public function testNotBetween(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notBetween('age', 18, 65)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `age` NOT BETWEEN ? AND ?', $result['query']); + $this->assertEquals([18, 65], $result['bindings']); + } + + public function testStartsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', 'Jo')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result['query']); + $this->assertEquals(['Jo%'], $result['bindings']); + } + + public function testNotStartsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notStartsWith('name', 'Jo')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` NOT LIKE ?', $result['query']); + $this->assertEquals(['Jo%'], $result['bindings']); + } + + public function testEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('email', '.com')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `email` LIKE ?', $result['query']); + $this->assertEquals(['%.com'], $result['bindings']); + } + + public function testNotEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEndsWith('email', '.com')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `email` NOT LIKE ?', $result['query']); + $this->assertEquals(['%.com'], $result['bindings']); + } + + public function testContainsSingle(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['php'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result['query']); + $this->assertEquals(['%php%'], $result['bindings']); + } + + public function testContainsMultiple(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['php', 'js'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`bio` LIKE ? OR `bio` LIKE ?)', $result['query']); + $this->assertEquals(['%php%', '%js%'], $result['bindings']); + } + + public function testContainsAny(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAny('tags', ['a', 'b'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `tags` IN (?, ?)', $result['query']); + $this->assertEquals(['a', 'b'], $result['bindings']); + } + + public function testContainsAll(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('perms', ['read', 'write'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`perms` LIKE ? AND `perms` LIKE ?)', $result['query']); + $this->assertEquals(['%read%', '%write%'], $result['bindings']); + } + + public function testNotContainsSingle(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', ['php'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result['query']); + $this->assertEquals(['%php%'], $result['bindings']); + } + + public function testNotContainsMultiple(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', ['php', 'js'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`bio` NOT LIKE ? AND `bio` NOT LIKE ?)', $result['query']); + $this->assertEquals(['%php%', '%js%'], $result['bindings']); + } + + public function testSearch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('content', 'hello')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?)', $result['query']); + $this->assertEquals(['hello'], $result['bindings']); + } + + public function testNotSearch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notSearch('content', 'hello')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?))', $result['query']); + $this->assertEquals(['hello'], $result['bindings']); + } + + public function testRegex(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('slug', '^[a-z]+$')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result['query']); + $this->assertEquals(['^[a-z]+$'], $result['bindings']); + } + + public function testIsNull(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNull('deleted')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `deleted` IS NULL', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testIsNotNull(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNotNull('verified')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `verified` IS NOT NULL', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testExists(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name', 'email'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`name` IS NOT NULL AND `email` IS NOT NULL)', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testNotExists(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists(['legacy'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`legacy` IS NULL)', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + // ── Logical / nested ── + + public function testAndLogical(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::greaterThan('age', 18), + Query::equal('status', ['active']), + ]), + ]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`age` > ? AND `status` IN (?))', $result['query']); + $this->assertEquals([18, 'active'], $result['bindings']); + } + + public function testOrLogical(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('role', ['admin']), + Query::equal('role', ['mod']), + ]), + ]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`role` IN (?) OR `role` IN (?))', $result['query']); + $this->assertEquals(['admin', 'mod'], $result['bindings']); + } + + public function testDeeplyNested(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::greaterThan('age', 18), + Query::or([ + Query::equal('role', ['admin']), + Query::equal('role', ['mod']), + ]), + ]), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`age` > ? AND (`role` IN (?) OR `role` IN (?)))', + $result['query'] + ); + $this->assertEquals([18, 'admin', 'mod'], $result['bindings']); + } + + // ── Sort ── + + public function testSortAsc(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->build(); + + $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC', $result['query']); + } + + public function testSortDesc(): void + { + $result = (new Builder()) + ->from('t') + ->sortDesc('score') + ->build(); + + $this->assertEquals('SELECT * FROM `t` ORDER BY `score` DESC', $result['query']); + } + + public function testSortRandom(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->build(); + + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND()', $result['query']); + } + + public function testMultipleSorts(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + + $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC, `age` DESC', $result['query']); + } + + // ── Pagination ── + + public function testLimitOnly(): void + { + $result = (new Builder()) + ->from('t') + ->limit(10) + ->build(); + + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result['query']); + $this->assertEquals([10], $result['bindings']); + } + + public function testOffsetOnly(): void + { + $result = (new Builder()) + ->from('t') + ->offset(50) + ->build(); + + $this->assertEquals('SELECT * FROM `t` OFFSET ?', $result['query']); + $this->assertEquals([50], $result['bindings']); + } + + public function testCursorAfter(): void + { + $result = (new Builder()) + ->from('t') + ->cursorAfter('abc123') + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE _cursor > ?', $result['query']); + $this->assertEquals(['abc123'], $result['bindings']); + } + + public function testCursorBefore(): void + { + $result = (new Builder()) + ->from('t') + ->cursorBefore('xyz789') + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE _cursor < ?', $result['query']); + $this->assertEquals(['xyz789'], $result['bindings']); + } + + // ── Combined full query ── + + public function testFullCombinedQuery(): void + { + $result = (new Builder()) + ->select(['id', 'name']) + ->from('users') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->sortAsc('name') + ->sortDesc('age') + ->limit(25) + ->offset(10) + ->build(); + + $this->assertEquals( + 'SELECT `id`, `name` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC, `age` DESC LIMIT ? OFFSET ?', + $result['query'] + ); + $this->assertEquals(['active', 18, 25, 10], $result['bindings']); + } + + // ── Multiple filter() calls (additive) ── + + public function testMultipleFilterCalls(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]) + ->filter([Query::equal('b', [2])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?)', $result['query']); + $this->assertEquals([1, 2], $result['bindings']); + } + + // ── Reset ── + + public function testResetClearsState(): void + { + $builder = (new Builder()) + ->select(['name']) + ->from('users') + ->filter([Query::equal('x', [1])]) + ->limit(10); + + $builder->build(); + + $builder->reset(); + + $result = $builder + ->from('orders') + ->filter([Query::greaterThan('total', 100)]) + ->build(); + + $this->assertEquals('SELECT * FROM `orders` WHERE `total` > ?', $result['query']); + $this->assertEquals([100], $result['bindings']); + } + + // ── Extension points ── + + public function testAttributeResolver(): void + { + $result = (new Builder()) + ->from('users') + ->setAttributeResolver(fn (string $a): string => match ($a) { + '$id' => '_uid', + '$createdAt' => '_createdAt', + default => $a, + }) + ->filter([Query::equal('$id', ['abc'])]) + ->sortAsc('$createdAt') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` WHERE `_uid` IN (?) ORDER BY `_createdAt` ASC', + $result['query'] + ); + $this->assertEquals(['abc'], $result['bindings']); + } + + public function testWrapChar(): void + { + $result = (new Builder()) + ->from('users') + ->setWrapChar('"') + ->select(['name']) + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertEquals( + 'SELECT "name" FROM "users" WHERE "status" IN (?)', + $result['query'] + ); + } + + public function testConditionProvider(): void + { + $result = (new Builder()) + ->from('users') + ->addConditionProvider(fn (string $table): array => [ + "_uid IN (SELECT _document FROM {$table}_perms WHERE _type = 'read')", + [], + ]) + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertEquals( + "SELECT * FROM `users` WHERE `status` IN (?) AND _uid IN (SELECT _document FROM users_perms WHERE _type = 'read')", + $result['query'] + ); + $this->assertEquals(['active'], $result['bindings']); + } + + public function testConditionProviderWithBindings(): void + { + $result = (new Builder()) + ->from('docs') + ->addConditionProvider(fn (string $table): array => [ + '_tenant = ?', + ['tenant_abc'], + ]) + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `docs` WHERE `status` IN (?) AND _tenant = ?', + $result['query'] + ); + // filter bindings first, then provider bindings + $this->assertEquals(['active', 'tenant_abc'], $result['bindings']); + } + + public function testBindingOrderingWithProviderAndCursor(): void + { + $result = (new Builder()) + ->from('docs') + ->addConditionProvider(fn (string $table): array => [ + '_tenant = ?', + ['t1'], + ]) + ->filter([Query::equal('status', ['active'])]) + ->cursorAfter('cursor_val') + ->limit(10) + ->offset(5) + ->build(); + + // binding order: filter, provider, cursor, limit, offset + $this->assertEquals(['active', 't1', 'cursor_val', 10, 5], $result['bindings']); + } + + // ── Select with no columns defaults to * ── + + public function testDefaultSelectStar(): void + { + $result = (new Builder()) + ->from('t') + ->build(); + + $this->assertEquals('SELECT * FROM `t`', $result['query']); + } + + // ── Aggregations ── + + public function testCountStar(): void + { + $result = (new Builder()) + ->from('t') + ->count() + ->build(); + + $this->assertEquals('SELECT COUNT(*) FROM `t`', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testCountWithAlias(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->build(); + + $this->assertEquals('SELECT COUNT(*) AS `total` FROM `t`', $result['query']); + } + + public function testSumColumn(): void + { + $result = (new Builder()) + ->from('orders') + ->sum('price', 'total_price') + ->build(); + + $this->assertEquals('SELECT SUM(`price`) AS `total_price` FROM `orders`', $result['query']); + } + + public function testAvgColumn(): void + { + $result = (new Builder()) + ->from('t') + ->avg('score') + ->build(); + + $this->assertEquals('SELECT AVG(`score`) FROM `t`', $result['query']); + } + + public function testMinColumn(): void + { + $result = (new Builder()) + ->from('t') + ->min('price') + ->build(); + + $this->assertEquals('SELECT MIN(`price`) FROM `t`', $result['query']); + } + + public function testMaxColumn(): void + { + $result = (new Builder()) + ->from('t') + ->max('price') + ->build(); + + $this->assertEquals('SELECT MAX(`price`) FROM `t`', $result['query']); + } + + public function testAggregationWithSelection(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->select(['status']) + ->groupBy(['status']) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total`, `status` FROM `orders` GROUP BY `status`', + $result['query'] + ); + } + + // ── Group By ── + + public function testGroupBy(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['status']) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`', + $result['query'] + ); + } + + public function testGroupByMultiple(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['status', 'country']) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`, `country`', + $result['query'] + ); + } + + // ── Having ── + + public function testHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['status']) + ->having([Query::greaterThan('total', 5)]) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status` HAVING `total` > ?', + $result['query'] + ); + $this->assertEquals([5], $result['bindings']); + } + + // ── Distinct ── + + public function testDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['status']) + ->build(); + + $this->assertEquals('SELECT DISTINCT `status` FROM `t`', $result['query']); + } + + public function testDistinctStar(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->build(); + + $this->assertEquals('SELECT DISTINCT * FROM `t`', $result['query']); + } + + // ── Joins ── + + public function testJoin(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` JOIN `orders` ON `users.id` = `orders.user_id`', + $result['query'] + ); + } + + public function testLeftJoin(): void + { + $result = (new Builder()) + ->from('users') + ->leftJoin('profiles', 'users.id', 'profiles.user_id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` LEFT JOIN `profiles` ON `users.id` = `profiles.user_id`', + $result['query'] + ); + } + + public function testRightJoin(): void + { + $result = (new Builder()) + ->from('users') + ->rightJoin('orders', 'users.id', 'orders.user_id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` RIGHT JOIN `orders` ON `users.id` = `orders.user_id`', + $result['query'] + ); + } + + public function testCrossJoin(): void + { + $result = (new Builder()) + ->from('sizes') + ->crossJoin('colors') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `sizes` CROSS JOIN `colors`', + $result['query'] + ); + } + + public function testJoinWithFilter(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->filter([Query::greaterThan('orders.total', 100)]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` JOIN `orders` ON `users.id` = `orders.user_id` WHERE `orders.total` > ?', + $result['query'] + ); + $this->assertEquals([100], $result['bindings']); + } + + // ── Raw ── + + public function testRawFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('score > ? AND score < ?', [10, 100])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE score > ? AND score < ?', $result['query']); + $this->assertEquals([10, 100], $result['bindings']); + } + + public function testRawFilterNoBindings(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('1 = 1')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + // ── Union ── + + public function testUnion(): void + { + $admins = (new Builder())->from('admins')->filter([Query::equal('role', ['admin'])]); + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->union($admins) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` WHERE `status` IN (?) UNION SELECT * FROM `admins` WHERE `role` IN (?)', + $result['query'] + ); + $this->assertEquals(['active', 'admin'], $result['bindings']); + } + + public function testUnionAll(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('current') + ->unionAll($other) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `current` UNION ALL SELECT * FROM `archive`', + $result['query'] + ); + } + + // ── when() ── + + public function testWhenTrue(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?)', $result['query']); + $this->assertEquals(['active'], $result['bindings']); + } + + public function testWhenFalse(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->build(); + + $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + // ── page() ── + + public function testPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(3, 10) + ->build(); + + $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result['query']); + $this->assertEquals([10, 20], $result['bindings']); + } + + public function testPageDefaultPerPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(1) + ->build(); + + $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result['query']); + $this->assertEquals([25, 0], $result['bindings']); + } + + // ── toRawSql() ── + + public function testToRawSql(): void + { + $sql = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->toRawSql(); + + $this->assertEquals( + "SELECT * FROM `users` WHERE `status` IN ('active') LIMIT 10", + $sql + ); + } + + public function testToRawSqlNumericBindings(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('age', 18)]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `t` WHERE `age` > 18", $sql); + } + + // ── Combined complex query ── + + public function testCombinedAggregationJoinGroupByHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'order_count') + ->sum('total', 'total_amount') + ->select(['users.name']) + ->join('users', 'orders.user_id', 'users.id') + ->groupBy(['users.name']) + ->having([Query::greaterThan('order_count', 5)]) + ->sortDesc('total_amount') + ->limit(10) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `order_count`, SUM(`total`) AS `total_amount`, `users.name` FROM `orders` JOIN `users` ON `orders.user_id` = `users.id` GROUP BY `users.name` HAVING `order_count` > ? ORDER BY `total_amount` DESC LIMIT ?', + $result['query'] + ); + $this->assertEquals([5, 10], $result['bindings']); + } + + // ── Reset clears unions ── + + public function testResetClearsUnions(): void + { + $other = (new Builder())->from('archive'); + $builder = (new Builder()) + ->from('current') + ->union($other); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('fresh')->build(); + + $this->assertEquals('SELECT * FROM `fresh`', $result['query']); + } + + // ══════════════════════════════════════════ + // EDGE CASES & COMBINATIONS + // ══════════════════════════════════════════ + + // ── Aggregation edge cases ── + + public function testCountWithNamedColumn(): void + { + $result = (new Builder()) + ->from('t') + ->count('id') + ->build(); + + $this->assertEquals('SELECT COUNT(`id`) FROM `t`', $result['query']); + } + + public function testCountWithEmptyStringAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->count('') + ->build(); + + $this->assertEquals('SELECT COUNT(``) FROM `t`', $result['query']); + } + + public function testMultipleAggregations(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->sum('price', 'total') + ->avg('score', 'avg_score') + ->min('age', 'youngest') + ->max('age', 'oldest') + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `cnt`, SUM(`price`) AS `total`, AVG(`score`) AS `avg_score`, MIN(`age`) AS `youngest`, MAX(`age`) AS `oldest` FROM `t`', + $result['query'] + ); + $this->assertEquals([], $result['bindings']); + } + + public function testAggregationWithoutGroupBy(): void + { + $result = (new Builder()) + ->from('orders') + ->sum('total', 'grand_total') + ->build(); + + $this->assertEquals('SELECT SUM(`total`) AS `grand_total` FROM `orders`', $result['query']); + } + + public function testAggregationWithFilter(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->filter([Query::equal('status', ['completed'])]) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total` FROM `orders` WHERE `status` IN (?)', + $result['query'] + ); + $this->assertEquals(['completed'], $result['bindings']); + } + + public function testAggregationWithoutAlias(): void + { + $result = (new Builder()) + ->from('t') + ->count() + ->sum('price') + ->build(); + + $this->assertEquals('SELECT COUNT(*), SUM(`price`) FROM `t`', $result['query']); + } + + // ── Group By edge cases ── + + public function testGroupByEmptyArray(): void + { + $result = (new Builder()) + ->from('t') + ->groupBy([]) + ->build(); + + $this->assertEquals('SELECT * FROM `t`', $result['query']); + } + + public function testMultipleGroupByCalls(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->groupBy(['country']) + ->build(); + + // Both groupBy calls should merge since groupByType merges values + $this->assertStringContainsString('GROUP BY', $result['query']); + $this->assertStringContainsString('`status`', $result['query']); + $this->assertStringContainsString('`country`', $result['query']); + } + + // ── Having edge cases ── + + public function testHavingEmptyArray(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->having([]) + ->build(); + + $this->assertStringNotContainsString('HAVING', $result['query']); + } + + public function testHavingMultipleConditions(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->sum('price', 'sum_price') + ->groupBy(['status']) + ->having([ + Query::greaterThan('total', 5), + Query::lessThan('sum_price', 1000), + ]) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total`, SUM(`price`) AS `sum_price` FROM `t` GROUP BY `status` HAVING `total` > ? AND `sum_price` < ?', + $result['query'] + ); + $this->assertEquals([5, 1000], $result['bindings']); + } + + public function testHavingWithLogicalOr(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->having([ + Query::or([ + Query::greaterThan('total', 10), + Query::lessThan('total', 2), + ]), + ]) + ->build(); + + $this->assertStringContainsString('HAVING (`total` > ? OR `total` < ?)', $result['query']); + $this->assertEquals([10, 2], $result['bindings']); + } + + public function testHavingWithoutGroupBy(): void + { + // SQL allows HAVING without GROUP BY in some engines + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->having([Query::greaterThan('total', 0)]) + ->build(); + + $this->assertStringContainsString('HAVING', $result['query']); + $this->assertStringNotContainsString('GROUP BY', $result['query']); + } + + public function testMultipleHavingCalls(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->having([Query::greaterThan('total', 1)]) + ->having([Query::lessThan('total', 100)]) + ->build(); + + $this->assertStringContainsString('HAVING `total` > ? AND `total` < ?', $result['query']); + $this->assertEquals([1, 100], $result['bindings']); + } + + // ── Distinct edge cases ── + + public function testDistinctWithAggregation(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->count('*', 'total') + ->build(); + + $this->assertEquals('SELECT DISTINCT COUNT(*) AS `total` FROM `t`', $result['query']); + } + + public function testDistinctMultipleCalls(): void + { + // Multiple distinct() calls should still produce single DISTINCT keyword + $result = (new Builder()) + ->from('t') + ->distinct() + ->distinct() + ->build(); + + $this->assertEquals('SELECT DISTINCT * FROM `t`', $result['query']); + } + + public function testDistinctWithJoin(): void + { + $result = (new Builder()) + ->from('users') + ->distinct() + ->select(['users.name']) + ->join('orders', 'users.id', 'orders.user_id') + ->build(); + + $this->assertEquals( + 'SELECT DISTINCT `users.name` FROM `users` JOIN `orders` ON `users.id` = `orders.user_id`', + $result['query'] + ); + } + + public function testDistinctWithFilterAndSort(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['status']) + ->filter([Query::isNotNull('status')]) + ->sortAsc('status') + ->build(); + + $this->assertEquals( + 'SELECT DISTINCT `status` FROM `t` WHERE `status` IS NOT NULL ORDER BY `status` ASC', + $result['query'] + ); + } + + // ── Join combinations ── + + public function testMultipleJoins(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->leftJoin('profiles', 'users.id', 'profiles.user_id') + ->rightJoin('departments', 'users.dept_id', 'departments.id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` JOIN `orders` ON `users.id` = `orders.user_id` LEFT JOIN `profiles` ON `users.id` = `profiles.user_id` RIGHT JOIN `departments` ON `users.dept_id` = `departments.id`', + $result['query'] + ); + } + + public function testJoinWithAggregationAndGroupBy(): void + { + $result = (new Builder()) + ->from('users') + ->count('*', 'order_count') + ->join('orders', 'users.id', 'orders.user_id') + ->groupBy(['users.name']) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `order_count` FROM `users` JOIN `orders` ON `users.id` = `orders.user_id` GROUP BY `users.name`', + $result['query'] + ); + } + + public function testJoinWithSortAndPagination(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->filter([Query::greaterThan('orders.total', 50)]) + ->sortDesc('orders.total') + ->limit(10) + ->offset(20) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` JOIN `orders` ON `users.id` = `orders.user_id` WHERE `orders.total` > ? ORDER BY `orders.total` DESC LIMIT ? OFFSET ?', + $result['query'] + ); + $this->assertEquals([50, 10, 20], $result['bindings']); + } + + public function testJoinWithCustomOperator(): void + { + $result = (new Builder()) + ->from('a') + ->join('b', 'a.val', 'b.val', '!=') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `a` JOIN `b` ON `a.val` != `b.val`', + $result['query'] + ); + } + + public function testCrossJoinWithOtherJoins(): void + { + $result = (new Builder()) + ->from('sizes') + ->crossJoin('colors') + ->leftJoin('inventory', 'sizes.id', 'inventory.size_id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `sizes` CROSS JOIN `colors` LEFT JOIN `inventory` ON `sizes.id` = `inventory.size_id`', + $result['query'] + ); + } + + // ── Raw edge cases ── + + public function testRawWithMixedBindings(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('a = ? AND b = ? AND c = ?', ['str', 42, 3.14])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE a = ? AND b = ? AND c = ?', $result['query']); + $this->assertEquals(['str', 42, 3.14], $result['bindings']); + } + + public function testRawCombinedWithRegularFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('status', ['active']), + Query::raw('custom_func(col) > ?', [10]), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `status` IN (?) AND custom_func(col) > ?', + $result['query'] + ); + $this->assertEquals(['active', 10], $result['bindings']); + } + + public function testRawWithEmptySql(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('')]) + ->build(); + + // Empty raw SQL still appears as a WHERE clause + $this->assertStringContainsString('WHERE', $result['query']); + } + + // ── Union edge cases ── + + public function testMultipleUnions(): void + { + $q1 = (new Builder())->from('admins'); + $q2 = (new Builder())->from('mods'); + + $result = (new Builder()) + ->from('users') + ->union($q1) + ->union($q2) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` UNION SELECT * FROM `admins` UNION SELECT * FROM `mods`', + $result['query'] + ); + } + + public function testMixedUnionAndUnionAll(): void + { + $q1 = (new Builder())->from('admins'); + $q2 = (new Builder())->from('mods'); + + $result = (new Builder()) + ->from('users') + ->union($q1) + ->unionAll($q2) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` UNION SELECT * FROM `admins` UNION ALL SELECT * FROM `mods`', + $result['query'] + ); + } + + public function testUnionWithFiltersAndBindings(): void + { + $q1 = (new Builder())->from('admins')->filter([Query::equal('level', [1])]); + $q2 = (new Builder())->from('mods')->filter([Query::greaterThan('score', 50)]); + + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->union($q1) + ->unionAll($q2) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` WHERE `status` IN (?) UNION SELECT * FROM `admins` WHERE `level` IN (?) UNION ALL SELECT * FROM `mods` WHERE `score` > ?', + $result['query'] + ); + $this->assertEquals(['active', 1, 50], $result['bindings']); + } + + public function testUnionWithAggregation(): void + { + $q1 = (new Builder())->from('orders_2023')->count('*', 'total'); + + $result = (new Builder()) + ->from('orders_2024') + ->count('*', 'total') + ->unionAll($q1) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total` FROM `orders_2024` UNION ALL SELECT COUNT(*) AS `total` FROM `orders_2023`', + $result['query'] + ); + } + + // ── when() edge cases ── + + public function testWhenNested(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, function (Builder $b) { + $b->when(true, fn (Builder $b2) => $b2->filter([Query::equal('a', [1])])); + }) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $result['query']); + } + + public function testWhenMultipleCalls(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, fn (Builder $b) => $b->filter([Query::equal('a', [1])])) + ->when(false, fn (Builder $b) => $b->filter([Query::equal('b', [2])])) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('c', [3])])) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?) AND `c` IN (?)', $result['query']); + $this->assertEquals([1, 3], $result['bindings']); + } + + // ── page() edge cases ── + + public function testPageZero(): void + { + $result = (new Builder()) + ->from('t') + ->page(0, 10) + ->build(); + + // page 0 → offset clamped to 0 + $this->assertEquals([10, 0], $result['bindings']); + } + + public function testPageOnePerPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(5, 1) + ->build(); + + $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result['query']); + $this->assertEquals([1, 4], $result['bindings']); + } + + public function testPageLargeValues(): void + { + $result = (new Builder()) + ->from('t') + ->page(1000, 100) + ->build(); + + $this->assertEquals([100, 99900], $result['bindings']); + } + + // ── toRawSql() edge cases ── + + public function testToRawSqlWithBooleanBindings(): void + { + // Booleans must be handled in toRawSql + $builder = (new Builder()) + ->from('t') + ->filter([Query::raw('active = ?', [true])]); + + $sql = $builder->toRawSql(); + $this->assertEquals("SELECT * FROM `t` WHERE active = 1", $sql); + } + + public function testToRawSqlWithNullBinding(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::raw('deleted_at = ?', [null])]); + + $sql = $builder->toRawSql(); + $this->assertEquals("SELECT * FROM `t` WHERE deleted_at = NULL", $sql); + } + + public function testToRawSqlWithFloatBinding(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::raw('price > ?', [9.99])]); + + $sql = $builder->toRawSql(); + $this->assertEquals("SELECT * FROM `t` WHERE price > 9.99", $sql); + } + + public function testToRawSqlComplexQuery(): void + { + $sql = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->sortAsc('name') + ->limit(25) + ->offset(10) + ->toRawSql(); + + $this->assertEquals( + "SELECT `name` FROM `users` WHERE `status` IN ('active') AND `age` > 18 ORDER BY `name` ASC LIMIT 25 OFFSET 10", + $sql + ); + } + + // ── Exception paths ── + + public function testCompileFilterUnsupportedType(): void + { + $builder = new Builder(); + $query = new Query('totallyInvalid', 'x', [1]); + + $this->expectException(\Utopia\Query\Exception::class); + $this->expectExceptionMessage('Unsupported filter type: totallyInvalid'); + $builder->compileFilter($query); + } + + public function testCompileOrderUnsupportedType(): void + { + $builder = new Builder(); + $query = new Query('equal', 'x', [1]); + + $this->expectException(\Utopia\Query\Exception::class); + $this->expectExceptionMessage('Unsupported order type: equal'); + $builder->compileOrder($query); + } + + public function testCompileJoinUnsupportedType(): void + { + $builder = new Builder(); + $query = new Query('equal', 't', ['a', '=', 'b']); + + $this->expectException(\Utopia\Query\Exception::class); + $this->expectExceptionMessage('Unsupported join type: equal'); + $builder->compileJoin($query); + } + + // ── Binding order edge cases ── + + public function testBindingOrderFilterProviderCursorLimitOffset(): void + { + $result = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $table): array => [ + '_tenant = ?', + ['tenant1'], + ]) + ->filter([ + Query::equal('a', ['x']), + Query::greaterThan('b', 5), + ]) + ->cursorAfter('cursor_abc') + ->limit(10) + ->offset(20) + ->build(); + + // Order: filter bindings, provider bindings, cursor, limit, offset + $this->assertEquals(['x', 5, 'tenant1', 'cursor_abc', 10, 20], $result['bindings']); + } + + public function testBindingOrderMultipleProviders(): void + { + $result = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $table): array => ['p1 = ?', ['v1']]) + ->addConditionProvider(fn (string $table): array => ['p2 = ?', ['v2']]) + ->filter([Query::equal('a', ['x'])]) + ->build(); + + $this->assertEquals(['x', 'v1', 'v2'], $result['bindings']); + } + + public function testBindingOrderHavingAfterFilters(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->filter([Query::equal('status', ['active'])]) + ->groupBy(['status']) + ->having([Query::greaterThan('total', 5)]) + ->limit(10) + ->build(); + + // Filter bindings, then having bindings, then limit + $this->assertEquals(['active', 5, 10], $result['bindings']); + } + + public function testBindingOrderUnionAppendedLast(): void + { + $sub = (new Builder())->from('other')->filter([Query::equal('x', ['y'])]); + + $result = (new Builder()) + ->from('main') + ->filter([Query::equal('a', ['b'])]) + ->limit(5) + ->union($sub) + ->build(); + + // Main filter, main limit, then union bindings + $this->assertEquals(['b', 5, 'y'], $result['bindings']); + } + + public function testBindingOrderComplexMixed(): void + { + $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->addConditionProvider(fn (string $t): array => ['_org = ?', ['org1']]) + ->filter([Query::equal('status', ['paid'])]) + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 1)]) + ->cursorAfter('cur1') + ->limit(10) + ->offset(5) + ->union($sub) + ->build(); + + // filter, provider, cursor, having, limit, offset, union + $this->assertEquals(['paid', 'org1', 'cur1', 1, 10, 5, 2023], $result['bindings']); + } + + // ── Attribute resolver with new features ── + + public function testAttributeResolverWithAggregation(): void + { + $result = (new Builder()) + ->from('t') + ->setAttributeResolver(fn (string $a): string => match ($a) { + '$price' => '_price', + default => $a, + }) + ->sum('$price', 'total') + ->build(); + + $this->assertEquals('SELECT SUM(`_price`) AS `total` FROM `t`', $result['query']); + } + + public function testAttributeResolverWithGroupBy(): void + { + $result = (new Builder()) + ->from('t') + ->setAttributeResolver(fn (string $a): string => match ($a) { + '$status' => '_status', + default => $a, + }) + ->count('*', 'total') + ->groupBy(['$status']) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total` FROM `t` GROUP BY `_status`', + $result['query'] + ); + } + + public function testAttributeResolverWithJoin(): void + { + $result = (new Builder()) + ->from('t') + ->setAttributeResolver(fn (string $a): string => match ($a) { + '$id' => '_uid', + '$ref' => '_ref', + default => $a, + }) + ->join('other', '$id', '$ref') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` JOIN `other` ON `_uid` = `_ref`', + $result['query'] + ); + } + + public function testAttributeResolverWithHaving(): void + { + $result = (new Builder()) + ->from('t') + ->setAttributeResolver(fn (string $a): string => match ($a) { + '$total' => '_total', + default => $a, + }) + ->count('*', 'cnt') + ->groupBy(['status']) + ->having([Query::greaterThan('$total', 5)]) + ->build(); + + $this->assertStringContainsString('HAVING `_total` > ?', $result['query']); + } + + // ── Wrap char with new features ── + + public function testWrapCharWithJoin(): void + { + $result = (new Builder()) + ->from('users') + ->setWrapChar('"') + ->join('orders', 'users.id', 'orders.uid') + ->build(); + + $this->assertEquals( + 'SELECT * FROM "users" JOIN "orders" ON "users.id" = "orders.uid"', + $result['query'] + ); + } + + public function testWrapCharWithAggregation(): void + { + $result = (new Builder()) + ->from('t') + ->setWrapChar('"') + ->count('id', 'total') + ->groupBy(['status']) + ->build(); + + $this->assertEquals( + 'SELECT COUNT("id") AS "total" FROM "t" GROUP BY "status"', + $result['query'] + ); + } + + public function testWrapCharEmpty(): void + { + $result = (new Builder()) + ->from('t') + ->setWrapChar('') + ->select(['name']) + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertEquals('SELECT name FROM t WHERE status IN (?)', $result['query']); + } + + // ── Condition provider with new features ── + + public function testConditionProviderWithJoins(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->addConditionProvider(fn (string $table): array => [ + 'users.org_id = ?', + ['org1'], + ]) + ->filter([Query::greaterThan('orders.total', 100)]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` JOIN `orders` ON `users.id` = `orders.user_id` WHERE `orders.total` > ? AND users.org_id = ?', + $result['query'] + ); + $this->assertEquals([100, 'org1'], $result['bindings']); + } + + public function testConditionProviderWithAggregation(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->addConditionProvider(fn (string $table): array => [ + 'org_id = ?', + ['org1'], + ]) + ->groupBy(['status']) + ->build(); + + $this->assertStringContainsString('WHERE org_id = ?', $result['query']); + $this->assertEquals(['org1'], $result['bindings']); + } + + // ── Multiple build() calls ── + + public function testMultipleBuildsConsistentOutput(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]) + ->limit(10); + + $result1 = $builder->build(); + $result2 = $builder->build(); + + $this->assertEquals($result1['query'], $result2['query']); + $this->assertEquals($result1['bindings'], $result2['bindings']); + } + + // ── Reset behavior ── + + public function testResetDoesNotClearWrapCharOrResolver(): void + { + $builder = (new Builder()) + ->from('t') + ->setWrapChar('"') + ->setAttributeResolver(fn (string $a): string => '_' . $a) + ->filter([Query::equal('x', [1])]); + + $builder->build(); + $builder->reset(); + + // wrapChar and resolver should persist since reset() only clears queries/bindings/table/unions + $result = $builder->from('t2')->filter([Query::equal('y', [2])])->build(); + $this->assertEquals('SELECT * FROM "t2" WHERE "_y" IN (?)', $result['query']); + } + + // ── Empty query ── + + public function testEmptyBuilderNoFrom(): void + { + $result = (new Builder())->from('')->build(); + $this->assertEquals('SELECT * FROM ``', $result['query']); + } + + // ── Cursor with other pagination ── + + public function testCursorWithLimitAndOffset(): void + { + $result = (new Builder()) + ->from('t') + ->cursorAfter('abc') + ->limit(10) + ->offset(5) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE _cursor > ? LIMIT ? OFFSET ?', + $result['query'] + ); + $this->assertEquals(['abc', 10, 5], $result['bindings']); + } + + public function testCursorWithPage(): void + { + $result = (new Builder()) + ->from('t') + ->cursorAfter('abc') + ->page(2, 10) + ->build(); + + // Cursor + limit from page + offset from page; first limit/offset wins + $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertStringContainsString('LIMIT ?', $result['query']); + } + + // ── Full kitchen sink ── + + public function testKitchenSinkQuery(): void + { + $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + + $result = (new Builder()) + ->from('orders') + ->distinct() + ->count('*', 'cnt') + ->sum('total', 'sum_total') + ->select(['status']) + ->join('users', 'orders.user_id', 'users.id') + ->leftJoin('coupons', 'orders.coupon_id', 'coupons.id') + ->filter([ + Query::equal('orders.status', ['paid']), + Query::greaterThan('orders.total', 0), + ]) + ->addConditionProvider(fn (string $t): array => ['org = ?', ['o1']]) + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 1)]) + ->sortDesc('sum_total') + ->limit(25) + ->offset(50) + ->union($sub) + ->build(); + + // Verify structural elements + $this->assertStringContainsString('SELECT DISTINCT', $result['query']); + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result['query']); + $this->assertStringContainsString('SUM(`total`) AS `sum_total`', $result['query']); + $this->assertStringContainsString('`status`', $result['query']); + $this->assertStringContainsString('FROM `orders`', $result['query']); + $this->assertStringContainsString('JOIN `users`', $result['query']); + $this->assertStringContainsString('LEFT JOIN `coupons`', $result['query']); + $this->assertStringContainsString('WHERE', $result['query']); + $this->assertStringContainsString('GROUP BY `status`', $result['query']); + $this->assertStringContainsString('HAVING `cnt` > ?', $result['query']); + $this->assertStringContainsString('ORDER BY `sum_total` DESC', $result['query']); + $this->assertStringContainsString('LIMIT ?', $result['query']); + $this->assertStringContainsString('OFFSET ?', $result['query']); + $this->assertStringContainsString('UNION', $result['query']); + + // Verify SQL clause ordering + $query = $result['query']; + $this->assertLessThan(strpos($query, 'FROM'), strpos($query, 'SELECT')); + $this->assertLessThan(strpos($query, 'JOIN'), (int) strpos($query, 'FROM')); + $this->assertLessThan(strpos($query, 'WHERE'), (int) strpos($query, 'JOIN')); + $this->assertLessThan(strpos($query, 'GROUP BY'), (int) strpos($query, 'WHERE')); + $this->assertLessThan(strpos($query, 'HAVING'), (int) strpos($query, 'GROUP BY')); + $this->assertLessThan(strpos($query, 'ORDER BY'), (int) strpos($query, 'HAVING')); + $this->assertLessThan(strpos($query, 'LIMIT'), (int) strpos($query, 'ORDER BY')); + $this->assertLessThan(strpos($query, 'OFFSET'), (int) strpos($query, 'LIMIT')); + $this->assertLessThan(strpos($query, 'UNION'), (int) strpos($query, 'OFFSET')); + } + + // ── Filter empty arrays ── + + public function testFilterEmptyArray(): void + { + $result = (new Builder()) + ->from('t') + ->filter([]) + ->build(); + + $this->assertEquals('SELECT * FROM `t`', $result['query']); + } + + public function testSelectEmptyArray(): void + { + $result = (new Builder()) + ->from('t') + ->select([]) + ->build(); + + // Empty select produces empty column list + $this->assertEquals('SELECT FROM `t`', $result['query']); + } + + // ── Limit/offset edge values ── + + public function testLimitZero(): void + { + $result = (new Builder()) + ->from('t') + ->limit(0) + ->build(); + + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result['query']); + $this->assertEquals([0], $result['bindings']); + } + + public function testOffsetZero(): void + { + $result = (new Builder()) + ->from('t') + ->offset(0) + ->build(); + + $this->assertEquals('SELECT * FROM `t` OFFSET ?', $result['query']); + $this->assertEquals([0], $result['bindings']); + } + + // ── Fluent chaining returns same instance ── + + public function testFluentChainingReturnsSameInstance(): void + { + $builder = new Builder(); + + $this->assertSame($builder, $builder->from('t')); + $this->assertSame($builder, $builder->select(['a'])); + $this->assertSame($builder, $builder->filter([])); + $this->assertSame($builder, $builder->sortAsc('a')); + $this->assertSame($builder, $builder->sortDesc('a')); + $this->assertSame($builder, $builder->sortRandom()); + $this->assertSame($builder, $builder->limit(1)); + $this->assertSame($builder, $builder->offset(0)); + $this->assertSame($builder, $builder->cursorAfter('x')); + $this->assertSame($builder, $builder->cursorBefore('x')); + $this->assertSame($builder, $builder->queries([])); + $this->assertSame($builder, $builder->setWrapChar('`')); + $this->assertSame($builder, $builder->count()); + $this->assertSame($builder, $builder->sum('a')); + $this->assertSame($builder, $builder->avg('a')); + $this->assertSame($builder, $builder->min('a')); + $this->assertSame($builder, $builder->max('a')); + $this->assertSame($builder, $builder->groupBy(['a'])); + $this->assertSame($builder, $builder->having([])); + $this->assertSame($builder, $builder->distinct()); + $this->assertSame($builder, $builder->join('t', 'a', 'b')); + $this->assertSame($builder, $builder->leftJoin('t', 'a', 'b')); + $this->assertSame($builder, $builder->rightJoin('t', 'a', 'b')); + $this->assertSame($builder, $builder->crossJoin('t')); + $this->assertSame($builder, $builder->when(false, fn ($b) => $b)); + $this->assertSame($builder, $builder->page(1)); + $this->assertSame($builder, $builder->reset()); + } + + public function testUnionFluentChainingReturnsSameInstance(): void + { + $builder = new Builder(); + $other = (new Builder())->from('t'); + $this->assertSame($builder, $builder->from('t')->union($other)); + + $builder->reset(); + $other2 = (new Builder())->from('t'); + $this->assertSame($builder, $builder->from('t')->unionAll($other2)); + } + + // ══════════════════════════════════════════ + // 1. SQL-Specific: REGEXP + // ══════════════════════════════════════════ + + public function testRegexWithEmptyPattern(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('slug', '')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result['query']); + $this->assertEquals([''], $result['bindings']); + } + + public function testRegexWithDotChar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', 'a.b')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` REGEXP ?', $result['query']); + $this->assertEquals(['a.b'], $result['bindings']); + } + + public function testRegexWithStarChar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', 'a*b')]) + ->build(); + + $this->assertEquals(['a*b'], $result['bindings']); + } + + public function testRegexWithPlusChar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', 'a+')]) + ->build(); + + $this->assertEquals(['a+'], $result['bindings']); + } + + public function testRegexWithQuestionMarkChar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', 'colou?r')]) + ->build(); + + $this->assertEquals(['colou?r'], $result['bindings']); + } + + public function testRegexWithCaretAndDollar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('code', '^[A-Z]+$')]) + ->build(); + + $this->assertEquals(['^[A-Z]+$'], $result['bindings']); + } + + public function testRegexWithPipeChar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('color', 'red|blue|green')]) + ->build(); + + $this->assertEquals(['red|blue|green'], $result['bindings']); + } + + public function testRegexWithBackslash(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('path', '\\\\server\\\\share')]) + ->build(); + + $this->assertEquals(['\\\\server\\\\share'], $result['bindings']); + } + + public function testRegexWithBracketsAndBraces(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('zip', '[0-9]{5}')]) + ->build(); + + $this->assertEquals('[0-9]{5}', $result['bindings'][0]); + } + + public function testRegexWithParentheses(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('phone', '(\\+1)?[0-9]{10}')]) + ->build(); + + $this->assertEquals(['(\\+1)?[0-9]{10}'], $result['bindings']); + } + + public function testRegexCombinedWithOtherFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('status', ['active']), + Query::regex('slug', '^[a-z-]+$'), + Query::greaterThan('age', 18), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `status` IN (?) AND `slug` REGEXP ? AND `age` > ?', + $result['query'] + ); + $this->assertEquals(['active', '^[a-z-]+$', 18], $result['bindings']); + } + + public function testRegexWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('t') + ->setAttributeResolver(fn (string $a): string => match ($a) { + '$slug' => '_slug', + default => $a, + }) + ->filter([Query::regex('$slug', '^test')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `_slug` REGEXP ?', $result['query']); + $this->assertEquals(['^test'], $result['bindings']); + } + + public function testRegexWithDifferentWrapChar(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->filter([Query::regex('slug', '^[a-z]+$')]) + ->build(); + + $this->assertEquals('SELECT * FROM "t" WHERE "slug" REGEXP ?', $result['query']); + } + + public function testRegexStandaloneCompileFilter(): void + { + $builder = new Builder(); + $query = Query::regex('col', '^abc'); + $sql = $builder->compileFilter($query); + + $this->assertEquals('`col` REGEXP ?', $sql); + $this->assertEquals(['^abc'], $builder->getBindings()); + } + + public function testRegexBindingPreservedExactly(): void + { + $pattern = '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$'; + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('email', $pattern)]) + ->build(); + + $this->assertSame($pattern, $result['bindings'][0]); + } + + public function testRegexWithVeryLongPattern(): void + { + $pattern = str_repeat('[a-z]', 500); + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('col', $pattern)]) + ->build(); + + $this->assertEquals($pattern, $result['bindings'][0]); + $this->assertStringContainsString('REGEXP ?', $result['query']); + } + + public function testMultipleRegexFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::regex('name', '^A'), + Query::regex('email', '@test\\.com$'), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `name` REGEXP ? AND `email` REGEXP ?', + $result['query'] + ); + $this->assertEquals(['^A', '@test\\.com$'], $result['bindings']); + } + + public function testRegexInAndLogicalGroup(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::regex('slug', '^[a-z]+$'), + Query::equal('status', ['active']), + ]), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`slug` REGEXP ? AND `status` IN (?))', + $result['query'] + ); + $this->assertEquals(['^[a-z]+$', 'active'], $result['bindings']); + } + + public function testRegexInOrLogicalGroup(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::regex('name', '^Admin'), + Query::regex('name', '^Mod'), + ]), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`name` REGEXP ? OR `name` REGEXP ?)', + $result['query'] + ); + $this->assertEquals(['^Admin', '^Mod'], $result['bindings']); + } + + // ══════════════════════════════════════════ + // 2. SQL-Specific: MATCH AGAINST / Search + // ══════════════════════════════════════════ + + public function testSearchWithEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('content', '')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?)', $result['query']); + $this->assertEquals([''], $result['bindings']); + } + + public function testSearchWithSpecialCharacters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('body', 'hello "world" +required -excluded')]) + ->build(); + + $this->assertEquals(['hello "world" +required -excluded'], $result['bindings']); + } + + public function testSearchCombinedWithOtherFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::search('content', 'hello'), + Query::equal('status', ['published']), + Query::greaterThan('views', 100), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?) AND `status` IN (?) AND `views` > ?', + $result['query'] + ); + $this->assertEquals(['hello', 'published', 100], $result['bindings']); + } + + public function testNotSearchCombinedWithOtherFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::notSearch('content', 'spam'), + Query::equal('status', ['published']), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?)) AND `status` IN (?)', + $result['query'] + ); + $this->assertEquals(['spam', 'published'], $result['bindings']); + } + + public function testSearchWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('t') + ->setAttributeResolver(fn (string $a): string => match ($a) { + '$body' => '_body', + default => $a, + }) + ->filter([Query::search('$body', 'hello')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`_body`) AGAINST(?)', $result['query']); + } + + public function testSearchWithDifferentWrapChar(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->filter([Query::search('content', 'hello')]) + ->build(); + + $this->assertEquals('SELECT * FROM "t" WHERE MATCH("content") AGAINST(?)', $result['query']); + } + + public function testSearchStandaloneCompileFilter(): void + { + $builder = new Builder(); + $query = Query::search('body', 'test'); + $sql = $builder->compileFilter($query); + + $this->assertEquals('MATCH(`body`) AGAINST(?)', $sql); + $this->assertEquals(['test'], $builder->getBindings()); + } + + public function testNotSearchStandaloneCompileFilter(): void + { + $builder = new Builder(); + $query = Query::notSearch('body', 'spam'); + $sql = $builder->compileFilter($query); + + $this->assertEquals('NOT (MATCH(`body`) AGAINST(?))', $sql); + $this->assertEquals(['spam'], $builder->getBindings()); + } + + public function testSearchBindingPreservedExactly(): void + { + $searchTerm = 'hello world "exact phrase" +required -excluded'; + $result = (new Builder()) + ->from('t') + ->filter([Query::search('content', $searchTerm)]) + ->build(); + + $this->assertSame($searchTerm, $result['bindings'][0]); + } + + public function testSearchWithVeryLongText(): void + { + $longText = str_repeat('keyword ', 1000); + $result = (new Builder()) + ->from('t') + ->filter([Query::search('content', $longText)]) + ->build(); + + $this->assertEquals($longText, $result['bindings'][0]); + } + + public function testMultipleSearchFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::search('title', 'hello'), + Query::search('body', 'world'), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE MATCH(`title`) AGAINST(?) AND MATCH(`body`) AGAINST(?)', + $result['query'] + ); + $this->assertEquals(['hello', 'world'], $result['bindings']); + } + + public function testSearchInAndLogicalGroup(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::search('content', 'hello'), + Query::equal('status', ['active']), + ]), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (MATCH(`content`) AGAINST(?) AND `status` IN (?))', + $result['query'] + ); + } + + public function testSearchInOrLogicalGroup(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::search('title', 'hello'), + Query::search('body', 'hello'), + ]), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (MATCH(`title`) AGAINST(?) OR MATCH(`body`) AGAINST(?))', + $result['query'] + ); + $this->assertEquals(['hello', 'hello'], $result['bindings']); + } + + public function testSearchAndRegexCombined(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::search('content', 'hello world'), + Query::regex('slug', '^[a-z-]+$'), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?) AND `slug` REGEXP ?', + $result['query'] + ); + $this->assertEquals(['hello world', '^[a-z-]+$'], $result['bindings']); + } + + public function testNotSearchStandalone(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notSearch('content', 'spam')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?))', $result['query']); + $this->assertEquals(['spam'], $result['bindings']); + } + + // ══════════════════════════════════════════ + // 3. SQL-Specific: RAND() + // ══════════════════════════════════════════ + + public function testRandomSortStandaloneCompile(): void + { + $builder = new Builder(); + $query = Query::orderRandom(); + $sql = $builder->compileOrder($query); + + $this->assertEquals('RAND()', $sql); + } + + public function testRandomSortCombinedWithAscDesc(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortRandom() + ->sortDesc('age') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` ORDER BY `name` ASC, RAND(), `age` DESC', + $result['query'] + ); + } + + public function testRandomSortWithFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active'])]) + ->sortRandom() + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `status` IN (?) ORDER BY RAND()', + $result['query'] + ); + $this->assertEquals(['active'], $result['bindings']); + } + + public function testRandomSortWithLimit(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->limit(5) + ->build(); + + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ?', $result['query']); + $this->assertEquals([5], $result['bindings']); + } + + public function testRandomSortWithAggregation(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['category']) + ->sortRandom() + ->build(); + + $this->assertStringContainsString('ORDER BY RAND()', $result['query']); + $this->assertStringContainsString('COUNT(*) AS `total`', $result['query']); + } + + public function testRandomSortWithJoins(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->sortRandom() + ->build(); + + $this->assertStringContainsString('JOIN `orders`', $result['query']); + $this->assertStringContainsString('ORDER BY RAND()', $result['query']); + } + + public function testRandomSortWithDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['status']) + ->sortRandom() + ->build(); + + $this->assertEquals( + 'SELECT DISTINCT `status` FROM `t` ORDER BY RAND()', + $result['query'] + ); + } + + public function testRandomSortInBatchMode(): void + { + $result = (new Builder()) + ->from('t') + ->queries([ + Query::orderRandom(), + Query::limit(10), + ]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ?', $result['query']); + $this->assertEquals([10], $result['bindings']); + } + + public function testRandomSortWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('t') + ->setAttributeResolver(fn (string $a): string => '_' . $a) + ->sortRandom() + ->build(); + + $this->assertStringContainsString('ORDER BY RAND()', $result['query']); + } + + public function testMultipleRandomSorts(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->sortRandom() + ->build(); + + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND(), RAND()', $result['query']); + } + + public function testRandomSortWithOffset(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->limit(10) + ->offset(5) + ->build(); + + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ? OFFSET ?', $result['query']); + $this->assertEquals([10, 5], $result['bindings']); + } + + // ══════════════════════════════════════════ + // 4. setWrapChar comprehensive + // ══════════════════════════════════════════ + + public function testWrapCharSingleQuote(): void + { + $result = (new Builder()) + ->setWrapChar("'") + ->from('t') + ->select(['name']) + ->build(); + + $this->assertEquals("SELECT 'name' FROM 't'", $result['query']); + } + + public function testWrapCharSquareBracket(): void + { + $result = (new Builder()) + ->setWrapChar('[') + ->from('t') + ->select(['name']) + ->build(); + + $this->assertEquals('SELECT [name[ FROM [t[', $result['query']); + } + + public function testWrapCharUnicode(): void + { + $result = (new Builder()) + ->setWrapChar("\xC2\xAB") + ->from('t') + ->select(['name']) + ->build(); + + $this->assertEquals("SELECT \xC2\xABname\xC2\xAB FROM \xC2\xABt\xC2\xAB", $result['query']); + } + + public function testWrapCharAffectsSelect(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->select(['a', 'b', 'c']) + ->build(); + + $this->assertEquals('SELECT "a", "b", "c" FROM "t"', $result['query']); + } + + public function testWrapCharAffectsFrom(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('my_table') + ->build(); + + $this->assertEquals('SELECT * FROM "my_table"', $result['query']); + } + + public function testWrapCharAffectsFilter(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->filter([Query::equal('col', [1])]) + ->build(); + + $this->assertEquals('SELECT * FROM "t" WHERE "col" IN (?)', $result['query']); + } + + public function testWrapCharAffectsSort(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + + $this->assertEquals('SELECT * FROM "t" ORDER BY "name" ASC, "age" DESC', $result['query']); + } + + public function testWrapCharAffectsJoin(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('users') + ->join('orders', 'users.id', 'orders.uid') + ->build(); + + $this->assertEquals( + 'SELECT * FROM "users" JOIN "orders" ON "users.id" = "orders.uid"', + $result['query'] + ); + } + + public function testWrapCharAffectsLeftJoin(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('users') + ->leftJoin('profiles', 'users.id', 'profiles.uid') + ->build(); + + $this->assertEquals( + 'SELECT * FROM "users" LEFT JOIN "profiles" ON "users.id" = "profiles.uid"', + $result['query'] + ); + } + + public function testWrapCharAffectsRightJoin(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('users') + ->rightJoin('orders', 'users.id', 'orders.uid') + ->build(); + + $this->assertEquals( + 'SELECT * FROM "users" RIGHT JOIN "orders" ON "users.id" = "orders.uid"', + $result['query'] + ); + } + + public function testWrapCharAffectsCrossJoin(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('a') + ->crossJoin('b') + ->build(); + + $this->assertEquals('SELECT * FROM "a" CROSS JOIN "b"', $result['query']); + } + + public function testWrapCharAffectsAggregation(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->sum('price', 'total') + ->build(); + + $this->assertEquals('SELECT SUM("price") AS "total" FROM "t"', $result['query']); + } + + public function testWrapCharAffectsGroupBy(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->count('*', 'cnt') + ->groupBy(['status', 'country']) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS "cnt" FROM "t" GROUP BY "status", "country"', + $result['query'] + ); + } + + public function testWrapCharAffectsHaving(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->count('*', 'cnt') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + + $this->assertStringContainsString('HAVING "cnt" > ?', $result['query']); + } + + public function testWrapCharAffectsDistinct(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->distinct() + ->select(['status']) + ->build(); + + $this->assertEquals('SELECT DISTINCT "status" FROM "t"', $result['query']); + } + + public function testWrapCharAffectsRegex(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->filter([Query::regex('slug', '^test')]) + ->build(); + + $this->assertEquals('SELECT * FROM "t" WHERE "slug" REGEXP ?', $result['query']); + } + + public function testWrapCharAffectsSearch(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->filter([Query::search('body', 'hello')]) + ->build(); + + $this->assertEquals('SELECT * FROM "t" WHERE MATCH("body") AGAINST(?)', $result['query']); + } + + public function testWrapCharEmptyForSelect(): void + { + $result = (new Builder()) + ->setWrapChar('') + ->from('t') + ->select(['a', 'b']) + ->build(); + + $this->assertEquals('SELECT a, b FROM t', $result['query']); + } + + public function testWrapCharEmptyForFilter(): void + { + $result = (new Builder()) + ->setWrapChar('') + ->from('t') + ->filter([Query::greaterThan('age', 18)]) + ->build(); + + $this->assertEquals('SELECT * FROM t WHERE age > ?', $result['query']); + } + + public function testWrapCharEmptyForSort(): void + { + $result = (new Builder()) + ->setWrapChar('') + ->from('t') + ->sortAsc('name') + ->build(); + + $this->assertEquals('SELECT * FROM t ORDER BY name ASC', $result['query']); + } + + public function testWrapCharEmptyForJoin(): void + { + $result = (new Builder()) + ->setWrapChar('') + ->from('users') + ->join('orders', 'users.id', 'orders.uid') + ->build(); + + $this->assertEquals('SELECT * FROM users JOIN orders ON users.id = orders.uid', $result['query']); + } + + public function testWrapCharEmptyForAggregation(): void + { + $result = (new Builder()) + ->setWrapChar('') + ->from('t') + ->count('id', 'total') + ->build(); + + $this->assertEquals('SELECT COUNT(id) AS total FROM t', $result['query']); + } + + public function testWrapCharEmptyForGroupBy(): void + { + $result = (new Builder()) + ->setWrapChar('') + ->from('t') + ->count('*', 'cnt') + ->groupBy(['status']) + ->build(); + + $this->assertEquals('SELECT COUNT(*) AS cnt FROM t GROUP BY status', $result['query']); + } + + public function testWrapCharEmptyForDistinct(): void + { + $result = (new Builder()) + ->setWrapChar('') + ->from('t') + ->distinct() + ->select(['name']) + ->build(); + + $this->assertEquals('SELECT DISTINCT name FROM t', $result['query']); + } + + public function testWrapCharDoubleQuoteForSelect(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->select(['x', 'y']) + ->build(); + + $this->assertEquals('SELECT "x", "y" FROM "t"', $result['query']); + } + + public function testWrapCharDoubleQuoteForIsNull(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->filter([Query::isNull('deleted')]) + ->build(); + + $this->assertEquals('SELECT * FROM "t" WHERE "deleted" IS NULL', $result['query']); + } + + public function testWrapCharCalledMultipleTimesLastWins(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->setWrapChar("'") + ->setWrapChar('`') + ->from('t') + ->select(['name']) + ->build(); + + $this->assertEquals('SELECT `name` FROM `t`', $result['query']); + } + + public function testWrapCharDoesNotAffectRawExpressions(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->filter([Query::raw('custom_func(col) > ?', [10])]) + ->build(); + + $this->assertEquals('SELECT * FROM "t" WHERE custom_func(col) > ?', $result['query']); + } + + public function testWrapCharPersistsAcrossMultipleBuilds(): void + { + $builder = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->select(['name']); + + $result1 = $builder->build(); + $result2 = $builder->build(); + + $this->assertEquals('SELECT "name" FROM "t"', $result1['query']); + $this->assertEquals('SELECT "name" FROM "t"', $result2['query']); + } + + public function testWrapCharWithConditionProviderNotWrapped(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->addConditionProvider(fn (string $table): array => [ + 'raw_condition = 1', + [], + ]) + ->build(); + + $this->assertStringContainsString('WHERE raw_condition = 1', $result['query']); + $this->assertStringContainsString('FROM "t"', $result['query']); + } + + public function testWrapCharEmptyForRegex(): void + { + $result = (new Builder()) + ->setWrapChar('') + ->from('t') + ->filter([Query::regex('slug', '^test')]) + ->build(); + + $this->assertEquals('SELECT * FROM t WHERE slug REGEXP ?', $result['query']); + } + + public function testWrapCharEmptyForSearch(): void + { + $result = (new Builder()) + ->setWrapChar('') + ->from('t') + ->filter([Query::search('body', 'hello')]) + ->build(); + + $this->assertEquals('SELECT * FROM t WHERE MATCH(body) AGAINST(?)', $result['query']); + } + + public function testWrapCharEmptyForHaving(): void + { + $result = (new Builder()) + ->setWrapChar('') + ->from('t') + ->count('*', 'cnt') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + + $this->assertStringContainsString('HAVING cnt > ?', $result['query']); + } + + // ══════════════════════════════════════════ + // 5. Standalone Compiler method calls + // ══════════════════════════════════════════ + + public function testCompileFilterEqual(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::equal('col', ['a', 'b'])); + $this->assertEquals('`col` IN (?, ?)', $sql); + $this->assertEquals(['a', 'b'], $builder->getBindings()); + } + + public function testCompileFilterNotEqual(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notEqual('col', 'a')); + $this->assertEquals('`col` != ?', $sql); + $this->assertEquals(['a'], $builder->getBindings()); + } + + public function testCompileFilterLessThan(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::lessThan('col', 10)); + $this->assertEquals('`col` < ?', $sql); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testCompileFilterLessThanEqual(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::lessThanEqual('col', 10)); + $this->assertEquals('`col` <= ?', $sql); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testCompileFilterGreaterThan(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::greaterThan('col', 10)); + $this->assertEquals('`col` > ?', $sql); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testCompileFilterGreaterThanEqual(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::greaterThanEqual('col', 10)); + $this->assertEquals('`col` >= ?', $sql); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testCompileFilterBetween(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::between('col', 1, 100)); + $this->assertEquals('`col` BETWEEN ? AND ?', $sql); + $this->assertEquals([1, 100], $builder->getBindings()); + } + + public function testCompileFilterNotBetween(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notBetween('col', 1, 100)); + $this->assertEquals('`col` NOT BETWEEN ? AND ?', $sql); + $this->assertEquals([1, 100], $builder->getBindings()); + } + + public function testCompileFilterStartsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::startsWith('col', 'abc')); + $this->assertEquals('`col` LIKE ?', $sql); + $this->assertEquals(['abc%'], $builder->getBindings()); + } + + public function testCompileFilterNotStartsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notStartsWith('col', 'abc')); + $this->assertEquals('`col` NOT LIKE ?', $sql); + $this->assertEquals(['abc%'], $builder->getBindings()); + } + + public function testCompileFilterEndsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::endsWith('col', 'xyz')); + $this->assertEquals('`col` LIKE ?', $sql); + $this->assertEquals(['%xyz'], $builder->getBindings()); + } + + public function testCompileFilterNotEndsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notEndsWith('col', 'xyz')); + $this->assertEquals('`col` NOT LIKE ?', $sql); + $this->assertEquals(['%xyz'], $builder->getBindings()); + } + + public function testCompileFilterContainsSingle(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::contains('col', ['val'])); + $this->assertEquals('`col` LIKE ?', $sql); + $this->assertEquals(['%val%'], $builder->getBindings()); + } + + public function testCompileFilterContainsMultiple(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::contains('col', ['a', 'b'])); + $this->assertEquals('(`col` LIKE ? OR `col` LIKE ?)', $sql); + $this->assertEquals(['%a%', '%b%'], $builder->getBindings()); + } + + public function testCompileFilterContainsAny(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::containsAny('col', ['a', 'b'])); + $this->assertEquals('`col` IN (?, ?)', $sql); + $this->assertEquals(['a', 'b'], $builder->getBindings()); + } + + public function testCompileFilterContainsAll(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::containsAll('col', ['a', 'b'])); + $this->assertEquals('(`col` LIKE ? AND `col` LIKE ?)', $sql); + $this->assertEquals(['%a%', '%b%'], $builder->getBindings()); + } + + public function testCompileFilterNotContainsSingle(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notContains('col', ['val'])); + $this->assertEquals('`col` NOT LIKE ?', $sql); + $this->assertEquals(['%val%'], $builder->getBindings()); + } + + public function testCompileFilterNotContainsMultiple(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notContains('col', ['a', 'b'])); + $this->assertEquals('(`col` NOT LIKE ? AND `col` NOT LIKE ?)', $sql); + $this->assertEquals(['%a%', '%b%'], $builder->getBindings()); + } + + public function testCompileFilterIsNull(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::isNull('col')); + $this->assertEquals('`col` IS NULL', $sql); + $this->assertEquals([], $builder->getBindings()); + } + + public function testCompileFilterIsNotNull(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::isNotNull('col')); + $this->assertEquals('`col` IS NOT NULL', $sql); + $this->assertEquals([], $builder->getBindings()); + } + + public function testCompileFilterAnd(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::and([ + Query::equal('a', [1]), + Query::greaterThan('b', 2), + ])); + $this->assertEquals('(`a` IN (?) AND `b` > ?)', $sql); + $this->assertEquals([1, 2], $builder->getBindings()); + } + + public function testCompileFilterOr(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::or([ + Query::equal('a', [1]), + Query::equal('b', [2]), + ])); + $this->assertEquals('(`a` IN (?) OR `b` IN (?))', $sql); + $this->assertEquals([1, 2], $builder->getBindings()); + } + + public function testCompileFilterExists(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::exists(['a', 'b'])); + $this->assertEquals('(`a` IS NOT NULL AND `b` IS NOT NULL)', $sql); + } + + public function testCompileFilterNotExists(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notExists(['a', 'b'])); + $this->assertEquals('(`a` IS NULL AND `b` IS NULL)', $sql); + } + + public function testCompileFilterRaw(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::raw('x > ? AND y < ?', [1, 2])); + $this->assertEquals('x > ? AND y < ?', $sql); + $this->assertEquals([1, 2], $builder->getBindings()); + } + + public function testCompileFilterSearch(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::search('body', 'hello')); + $this->assertEquals('MATCH(`body`) AGAINST(?)', $sql); + $this->assertEquals(['hello'], $builder->getBindings()); + } + + public function testCompileFilterNotSearch(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notSearch('body', 'spam')); + $this->assertEquals('NOT (MATCH(`body`) AGAINST(?))', $sql); + $this->assertEquals(['spam'], $builder->getBindings()); + } + + public function testCompileFilterRegex(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::regex('col', '^abc')); + $this->assertEquals('`col` REGEXP ?', $sql); + $this->assertEquals(['^abc'], $builder->getBindings()); + } + + public function testCompileOrderAsc(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderAsc('name')); + $this->assertEquals('`name` ASC', $sql); + } + + public function testCompileOrderDesc(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderDesc('name')); + $this->assertEquals('`name` DESC', $sql); + } + + public function testCompileOrderRandom(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderRandom()); + $this->assertEquals('RAND()', $sql); + } + + public function testCompileLimitStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileLimit(Query::limit(25)); + $this->assertEquals('LIMIT ?', $sql); + $this->assertEquals([25], $builder->getBindings()); + } + + public function testCompileOffsetStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOffset(Query::offset(50)); + $this->assertEquals('OFFSET ?', $sql); + $this->assertEquals([50], $builder->getBindings()); + } + + public function testCompileSelectStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileSelect(Query::select(['a', 'b', 'c'])); + $this->assertEquals('`a`, `b`, `c`', $sql); + } + + public function testCompileCursorAfterStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileCursor(Query::cursorAfter('abc')); + $this->assertEquals('_cursor > ?', $sql); + $this->assertEquals(['abc'], $builder->getBindings()); + } + + public function testCompileCursorBeforeStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileCursor(Query::cursorBefore('xyz')); + $this->assertEquals('_cursor < ?', $sql); + $this->assertEquals(['xyz'], $builder->getBindings()); + } + + public function testCompileAggregateCountStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::count('*', 'total')); + $this->assertEquals('COUNT(*) AS `total`', $sql); + } + + public function testCompileAggregateCountWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::count()); + $this->assertEquals('COUNT(*)', $sql); + } + + public function testCompileAggregateSumStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::sum('price', 'total')); + $this->assertEquals('SUM(`price`) AS `total`', $sql); + } + + public function testCompileAggregateAvgStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::avg('score', 'avg_score')); + $this->assertEquals('AVG(`score`) AS `avg_score`', $sql); + } + + public function testCompileAggregateMinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::min('price', 'lowest')); + $this->assertEquals('MIN(`price`) AS `lowest`', $sql); + } + + public function testCompileAggregateMaxStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::max('price', 'highest')); + $this->assertEquals('MAX(`price`) AS `highest`', $sql); + } + + public function testCompileGroupByStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileGroupBy(Query::groupBy(['status', 'country'])); + $this->assertEquals('`status`, `country`', $sql); + } + + public function testCompileJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::join('orders', 'users.id', 'orders.uid')); + $this->assertEquals('JOIN `orders` ON `users.id` = `orders.uid`', $sql); + } + + public function testCompileLeftJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::leftJoin('profiles', 'users.id', 'profiles.uid')); + $this->assertEquals('LEFT JOIN `profiles` ON `users.id` = `profiles.uid`', $sql); + } + + public function testCompileRightJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::rightJoin('orders', 'users.id', 'orders.uid')); + $this->assertEquals('RIGHT JOIN `orders` ON `users.id` = `orders.uid`', $sql); + } + + public function testCompileCrossJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::crossJoin('colors')); + $this->assertEquals('CROSS JOIN `colors`', $sql); + } + + // ══════════════════════════════════════════ + // 6. Filter edge cases + // ══════════════════════════════════════════ + + public function testEqualWithSingleValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?)', $result['query']); + $this->assertEquals(['active'], $result['bindings']); + } + + public function testEqualWithManyValues(): void + { + $values = range(1, 10); + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('id', $values)]) + ->build(); + + $placeholders = implode(', ', array_fill(0, 10, '?')); + $this->assertEquals("SELECT * FROM `t` WHERE `id` IN ({$placeholders})", $result['query']); + $this->assertEquals($values, $result['bindings']); + } + + public function testEqualWithEmptyArray(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('id', [])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE 1 = 0', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testNotEqualWithExactlyTwoValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('role', ['guest', 'banned'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result['query']); + $this->assertEquals(['guest', 'banned'], $result['bindings']); + } + + public function testBetweenWithSameMinAndMax(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 25, 25)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result['query']); + $this->assertEquals([25, 25], $result['bindings']); + } + + public function testStartsWithEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', '')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result['query']); + $this->assertEquals(['%'], $result['bindings']); + } + + public function testEndsWithEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('name', '')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result['query']); + $this->assertEquals(['%'], $result['bindings']); + } + + public function testContainsWithSingleEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', [''])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result['query']); + $this->assertEquals(['%%'], $result['bindings']); + } + + public function testContainsWithManyValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['a', 'b', 'c', 'd', 'e'])]) + ->build(); + + $this->assertStringContainsString('(`bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ?)', $result['query']); + $this->assertEquals(['%a%', '%b%', '%c%', '%d%', '%e%'], $result['bindings']); + } + + public function testContainsAllWithSingleValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('perms', ['read'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`perms` LIKE ?)', $result['query']); + $this->assertEquals(['%read%'], $result['bindings']); + } + + public function testNotContainsWithEmptyStringValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', [''])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result['query']); + $this->assertEquals(['%%'], $result['bindings']); + } + + public function testComparisonWithFloatValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('price', 9.99)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `price` > ?', $result['query']); + $this->assertEquals([9.99], $result['bindings']); + } + + public function testComparisonWithNegativeValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('balance', -100)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `balance` < ?', $result['query']); + $this->assertEquals([-100], $result['bindings']); + } + + public function testComparisonWithZero(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('score', 0)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `score` >= ?', $result['query']); + $this->assertEquals([0], $result['bindings']); + } + + public function testComparisonWithVeryLargeInteger(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('id', 9999999999999)]) + ->build(); + + $this->assertEquals([9999999999999], $result['bindings']); + } + + public function testComparisonWithStringValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('name', 'M')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` > ?', $result['query']); + $this->assertEquals(['M'], $result['bindings']); + } + + public function testBetweenWithStringValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('created_at', '2024-01-01', '2024-12-31')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `created_at` BETWEEN ? AND ?', $result['query']); + $this->assertEquals(['2024-01-01', '2024-12-31'], $result['bindings']); + } + + public function testIsNullCombinedWithIsNotNullOnDifferentColumns(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::isNull('deleted_at'), + Query::isNotNull('verified_at'), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `deleted_at` IS NULL AND `verified_at` IS NOT NULL', + $result['query'] + ); + $this->assertEquals([], $result['bindings']); + } + + public function testMultipleIsNullFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::isNull('a'), + Query::isNull('b'), + Query::isNull('c'), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `a` IS NULL AND `b` IS NULL AND `c` IS NULL', + $result['query'] + ); + } + + public function testExistsWithSingleAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`name` IS NOT NULL)', $result['query']); + } + + public function testExistsWithManyAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['a', 'b', 'c', 'd'])]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`a` IS NOT NULL AND `b` IS NOT NULL AND `c` IS NOT NULL AND `d` IS NOT NULL)', + $result['query'] + ); + } + + public function testNotExistsWithManyAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists(['a', 'b', 'c'])]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`a` IS NULL AND `b` IS NULL AND `c` IS NULL)', + $result['query'] + ); + } + + public function testAndWithSingleSubQuery(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::equal('a', [1]), + ]), + ]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?))', $result['query']); + $this->assertEquals([1], $result['bindings']); + } + + public function testOrWithSingleSubQuery(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('a', [1]), + ]), + ]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?))', $result['query']); + $this->assertEquals([1], $result['bindings']); + } + + public function testAndWithManySubQueries(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::equal('a', [1]), + Query::equal('b', [2]), + Query::equal('c', [3]), + Query::equal('d', [4]), + Query::equal('e', [5]), + ]), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`a` IN (?) AND `b` IN (?) AND `c` IN (?) AND `d` IN (?) AND `e` IN (?))', + $result['query'] + ); + $this->assertEquals([1, 2, 3, 4, 5], $result['bindings']); + } + + public function testOrWithManySubQueries(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('a', [1]), + Query::equal('b', [2]), + Query::equal('c', [3]), + Query::equal('d', [4]), + Query::equal('e', [5]), + ]), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`a` IN (?) OR `b` IN (?) OR `c` IN (?) OR `d` IN (?) OR `e` IN (?))', + $result['query'] + ); + } + + public function testDeeplyNestedAndOrAnd(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::or([ + Query::and([ + Query::equal('a', [1]), + Query::equal('b', [2]), + ]), + Query::equal('c', [3]), + ]), + Query::equal('d', [4]), + ]), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (((`a` IN (?) AND `b` IN (?)) OR `c` IN (?)) AND `d` IN (?))', + $result['query'] + ); + $this->assertEquals([1, 2, 3, 4], $result['bindings']); + } + + public function testRawWithManyBindings(): void + { + $bindings = range(1, 10); + $placeholders = implode(' AND ', array_map(fn ($i) => "col{$i} = ?", range(1, 10))); + $result = (new Builder()) + ->from('t') + ->filter([Query::raw($placeholders, $bindings)]) + ->build(); + + $this->assertEquals("SELECT * FROM `t` WHERE {$placeholders}", $result['query']); + $this->assertEquals($bindings, $result['bindings']); + } + + public function testFilterWithDotsInAttributeName(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('table.column', ['value'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `table.column` IN (?)', $result['query']); + } + + public function testFilterWithUnderscoresInAttributeName(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('my_column_name', ['value'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `my_column_name` IN (?)', $result['query']); + } + + public function testFilterWithNumericAttributeName(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('123', ['value'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `123` IN (?)', $result['query']); + } + + // ══════════════════════════════════════════ + // 7. Aggregation edge cases + // ══════════════════════════════════════════ + + public function testCountWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->count()->build(); + $this->assertEquals('SELECT COUNT(*) FROM `t`', $result['query']); + $this->assertStringNotContainsString(' AS ', $result['query']); + } + + public function testSumWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->sum('price')->build(); + $this->assertEquals('SELECT SUM(`price`) FROM `t`', $result['query']); + $this->assertStringNotContainsString(' AS ', $result['query']); + } + + public function testAvgWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->avg('score')->build(); + $this->assertEquals('SELECT AVG(`score`) FROM `t`', $result['query']); + $this->assertStringNotContainsString(' AS ', $result['query']); + } + + public function testMinWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->min('price')->build(); + $this->assertEquals('SELECT MIN(`price`) FROM `t`', $result['query']); + $this->assertStringNotContainsString(' AS ', $result['query']); + } + + public function testMaxWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->max('price')->build(); + $this->assertEquals('SELECT MAX(`price`) FROM `t`', $result['query']); + $this->assertStringNotContainsString(' AS ', $result['query']); + } + + public function testCountWithAlias2(): void + { + $result = (new Builder())->from('t')->count('*', 'cnt')->build(); + $this->assertStringContainsString('AS `cnt`', $result['query']); + } + + public function testSumWithAlias(): void + { + $result = (new Builder())->from('t')->sum('price', 'total')->build(); + $this->assertStringContainsString('AS `total`', $result['query']); + } + + public function testAvgWithAlias(): void + { + $result = (new Builder())->from('t')->avg('score', 'avg_s')->build(); + $this->assertStringContainsString('AS `avg_s`', $result['query']); + } + + public function testMinWithAlias(): void + { + $result = (new Builder())->from('t')->min('price', 'lowest')->build(); + $this->assertStringContainsString('AS `lowest`', $result['query']); + } + + public function testMaxWithAlias(): void + { + $result = (new Builder())->from('t')->max('price', 'highest')->build(); + $this->assertStringContainsString('AS `highest`', $result['query']); + } + + public function testMultipleSameAggregationType(): void + { + $result = (new Builder()) + ->from('t') + ->count('id', 'count_id') + ->count('*', 'count_all') + ->build(); + + $this->assertEquals( + 'SELECT COUNT(`id`) AS `count_id`, COUNT(*) AS `count_all` FROM `t`', + $result['query'] + ); + } + + public function testAggregationStarAndNamedColumnMixed(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->sum('price', 'price_sum') + ->select(['category']) + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `total`', $result['query']); + $this->assertStringContainsString('SUM(`price`) AS `price_sum`', $result['query']); + $this->assertStringContainsString('`category`', $result['query']); + } + + public function testAggregationFilterSortLimitCombined(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->filter([Query::equal('status', ['paid'])]) + ->groupBy(['category']) + ->sortDesc('cnt') + ->limit(5) + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result['query']); + $this->assertStringContainsString('WHERE `status` IN (?)', $result['query']); + $this->assertStringContainsString('GROUP BY `category`', $result['query']); + $this->assertStringContainsString('ORDER BY `cnt` DESC', $result['query']); + $this->assertStringContainsString('LIMIT ?', $result['query']); + $this->assertEquals(['paid', 5], $result['bindings']); + } + + public function testAggregationJoinGroupByHavingSortLimitFullPipeline(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->sum('total', 'revenue') + ->select(['users.name']) + ->join('users', 'orders.user_id', 'users.id') + ->filter([Query::greaterThan('orders.total', 0)]) + ->groupBy(['users.name']) + ->having([Query::greaterThan('cnt', 2)]) + ->sortDesc('revenue') + ->limit(20) + ->offset(10) + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result['query']); + $this->assertStringContainsString('SUM(`total`) AS `revenue`', $result['query']); + $this->assertStringContainsString('JOIN `users`', $result['query']); + $this->assertStringContainsString('WHERE `orders.total` > ?', $result['query']); + $this->assertStringContainsString('GROUP BY `users.name`', $result['query']); + $this->assertStringContainsString('HAVING `cnt` > ?', $result['query']); + $this->assertStringContainsString('ORDER BY `revenue` DESC', $result['query']); + $this->assertStringContainsString('LIMIT ?', $result['query']); + $this->assertStringContainsString('OFFSET ?', $result['query']); + $this->assertEquals([0, 2, 20, 10], $result['bindings']); + } + + public function testAggregationWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('t') + ->setAttributeResolver(fn (string $a): string => match ($a) { + '$amount' => '_amount', + default => $a, + }) + ->sum('$amount', 'total') + ->build(); + + $this->assertEquals('SELECT SUM(`_amount`) AS `total` FROM `t`', $result['query']); + } + + public function testAggregationWithWrapChar(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->avg('score', 'average') + ->build(); + + $this->assertEquals('SELECT AVG("score") AS "average" FROM "t"', $result['query']); + } + + public function testMinMaxWithStringColumns(): void + { + $result = (new Builder()) + ->from('t') + ->min('name', 'first_name') + ->max('name', 'last_name') + ->build(); + + $this->assertEquals( + 'SELECT MIN(`name`) AS `first_name`, MAX(`name`) AS `last_name` FROM `t`', + $result['query'] + ); + } + + // ══════════════════════════════════════════ + // 8. Join edge cases + // ══════════════════════════════════════════ + + public function testSelfJoin(): void + { + $result = (new Builder()) + ->from('employees') + ->join('employees', 'employees.manager_id', 'employees.id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `employees` JOIN `employees` ON `employees.manager_id` = `employees.id`', + $result['query'] + ); + } + + public function testJoinWithVeryLongTableAndColumnNames(): void + { + $longTable = str_repeat('a', 100); + $longLeft = str_repeat('b', 100); + $longRight = str_repeat('c', 100); + $result = (new Builder()) + ->from('main') + ->join($longTable, $longLeft, $longRight) + ->build(); + + $this->assertStringContainsString("JOIN `{$longTable}`", $result['query']); + $this->assertStringContainsString("ON `{$longLeft}` = `{$longRight}`", $result['query']); + } + + public function testJoinFilterSortLimitOffsetCombined(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->filter([ + Query::equal('orders.status', ['paid']), + Query::greaterThan('orders.total', 100), + ]) + ->sortDesc('orders.total') + ->limit(25) + ->offset(50) + ->build(); + + $this->assertStringContainsString('JOIN `orders`', $result['query']); + $this->assertStringContainsString('WHERE `orders.status` IN (?) AND `orders.total` > ?', $result['query']); + $this->assertStringContainsString('ORDER BY `orders.total` DESC', $result['query']); + $this->assertStringContainsString('LIMIT ?', $result['query']); + $this->assertStringContainsString('OFFSET ?', $result['query']); + $this->assertEquals(['paid', 100, 25, 50], $result['bindings']); + } + + public function testJoinAggregationGroupByHavingCombined(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->join('users', 'orders.user_id', 'users.id') + ->groupBy(['users.name']) + ->having([Query::greaterThan('cnt', 3)]) + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result['query']); + $this->assertStringContainsString('JOIN `users`', $result['query']); + $this->assertStringContainsString('GROUP BY `users.name`', $result['query']); + $this->assertStringContainsString('HAVING `cnt` > ?', $result['query']); + $this->assertEquals([3], $result['bindings']); + } + + public function testJoinWithDistinct(): void + { + $result = (new Builder()) + ->from('users') + ->distinct() + ->select(['users.name']) + ->join('orders', 'users.id', 'orders.user_id') + ->build(); + + $this->assertStringContainsString('SELECT DISTINCT `users.name`', $result['query']); + $this->assertStringContainsString('JOIN `orders`', $result['query']); + } + + public function testJoinWithUnion(): void + { + $sub = (new Builder()) + ->from('archived_users') + ->join('archived_orders', 'archived_users.id', 'archived_orders.user_id'); + + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->union($sub) + ->build(); + + $this->assertStringContainsString('JOIN `orders`', $result['query']); + $this->assertStringContainsString('UNION', $result['query']); + $this->assertStringContainsString('JOIN `archived_orders`', $result['query']); + } + + public function testFourJoins(): void + { + $result = (new Builder()) + ->from('orders') + ->join('users', 'orders.user_id', 'users.id') + ->leftJoin('products', 'orders.product_id', 'products.id') + ->rightJoin('categories', 'products.cat_id', 'categories.id') + ->crossJoin('promotions') + ->build(); + + $this->assertStringContainsString('JOIN `users`', $result['query']); + $this->assertStringContainsString('LEFT JOIN `products`', $result['query']); + $this->assertStringContainsString('RIGHT JOIN `categories`', $result['query']); + $this->assertStringContainsString('CROSS JOIN `promotions`', $result['query']); + } + + public function testJoinWithAttributeResolverOnJoinColumns(): void + { + $result = (new Builder()) + ->from('t') + ->setAttributeResolver(fn (string $a): string => match ($a) { + '$id' => '_uid', + '$ref' => '_ref_id', + default => $a, + }) + ->join('other', '$id', '$ref') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` JOIN `other` ON `_uid` = `_ref_id`', + $result['query'] + ); + } + + public function testCrossJoinCombinedWithFilter(): void + { + $result = (new Builder()) + ->from('sizes') + ->crossJoin('colors') + ->filter([Query::equal('sizes.active', [true])]) + ->build(); + + $this->assertStringContainsString('CROSS JOIN `colors`', $result['query']); + $this->assertStringContainsString('WHERE `sizes.active` IN (?)', $result['query']); + } + + public function testCrossJoinFollowedByRegularJoin(): void + { + $result = (new Builder()) + ->from('a') + ->crossJoin('b') + ->join('c', 'a.id', 'c.a_id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `a` CROSS JOIN `b` JOIN `c` ON `a.id` = `c.a_id`', + $result['query'] + ); + } + + public function testMultipleJoinsWithFiltersOnEach(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->leftJoin('profiles', 'users.id', 'profiles.user_id') + ->filter([ + Query::greaterThan('orders.total', 50), + Query::isNotNull('profiles.avatar'), + ]) + ->build(); + + $this->assertStringContainsString('JOIN `orders`', $result['query']); + $this->assertStringContainsString('LEFT JOIN `profiles`', $result['query']); + $this->assertStringContainsString('`orders.total` > ?', $result['query']); + $this->assertStringContainsString('`profiles.avatar` IS NOT NULL', $result['query']); + } + + public function testJoinWithCustomOperatorLessThan(): void + { + $result = (new Builder()) + ->from('a') + ->join('b', 'a.start', 'b.end', '<') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `a` JOIN `b` ON `a.start` < `b.end`', + $result['query'] + ); + } + + public function testFiveJoins(): void + { + $result = (new Builder()) + ->from('t1') + ->join('t2', 't1.id', 't2.t1_id') + ->join('t3', 't2.id', 't3.t2_id') + ->join('t4', 't3.id', 't4.t3_id') + ->join('t5', 't4.id', 't5.t4_id') + ->join('t6', 't5.id', 't6.t5_id') + ->build(); + + $query = $result['query']; + $this->assertEquals(5, substr_count($query, 'JOIN')); + } + + // ══════════════════════════════════════════ + // 9. Union edge cases + // ══════════════════════════════════════════ + + public function testUnionWithThreeSubQueries(): void + { + $q1 = (new Builder())->from('a'); + $q2 = (new Builder())->from('b'); + $q3 = (new Builder())->from('c'); + + $result = (new Builder()) + ->from('main') + ->union($q1) + ->union($q2) + ->union($q3) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `main` UNION SELECT * FROM `a` UNION SELECT * FROM `b` UNION SELECT * FROM `c`', + $result['query'] + ); + } + + public function testUnionAllWithThreeSubQueries(): void + { + $q1 = (new Builder())->from('a'); + $q2 = (new Builder())->from('b'); + $q3 = (new Builder())->from('c'); + + $result = (new Builder()) + ->from('main') + ->unionAll($q1) + ->unionAll($q2) + ->unionAll($q3) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `main` UNION ALL SELECT * FROM `a` UNION ALL SELECT * FROM `b` UNION ALL SELECT * FROM `c`', + $result['query'] + ); + } + + public function testMixedUnionAndUnionAllWithThreeSubQueries(): void + { + $q1 = (new Builder())->from('a'); + $q2 = (new Builder())->from('b'); + $q3 = (new Builder())->from('c'); + + $result = (new Builder()) + ->from('main') + ->union($q1) + ->unionAll($q2) + ->union($q3) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `main` UNION SELECT * FROM `a` UNION ALL SELECT * FROM `b` UNION SELECT * FROM `c`', + $result['query'] + ); + } + + public function testUnionWhereSubQueryHasJoins(): void + { + $sub = (new Builder()) + ->from('archived_users') + ->join('archived_orders', 'archived_users.id', 'archived_orders.user_id'); + + $result = (new Builder()) + ->from('users') + ->union($sub) + ->build(); + + $this->assertStringContainsString( + 'UNION SELECT * FROM `archived_users` JOIN `archived_orders`', + $result['query'] + ); + } + + public function testUnionWhereSubQueryHasAggregation(): void + { + $sub = (new Builder()) + ->from('orders_2023') + ->count('*', 'cnt') + ->groupBy(['status']); + + $result = (new Builder()) + ->from('orders_2024') + ->count('*', 'cnt') + ->groupBy(['status']) + ->union($sub) + ->build(); + + $this->assertStringContainsString('UNION SELECT COUNT(*) AS `cnt` FROM `orders_2023` GROUP BY `status`', $result['query']); + } + + public function testUnionWhereSubQueryHasSortAndLimit(): void + { + $sub = (new Builder()) + ->from('archive') + ->sortDesc('created_at') + ->limit(10); + + $result = (new Builder()) + ->from('current') + ->union($sub) + ->build(); + + $this->assertStringContainsString('UNION SELECT * FROM `archive` ORDER BY `created_at` DESC LIMIT ?', $result['query']); + } + + public function testUnionWithConditionProviders(): void + { + $sub = (new Builder()) + ->from('other') + ->addConditionProvider(fn (string $t): array => ['org = ?', ['org2']]); + + $result = (new Builder()) + ->from('main') + ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]) + ->union($sub) + ->build(); + + $this->assertStringContainsString('WHERE org = ?', $result['query']); + $this->assertStringContainsString('UNION SELECT * FROM `other` WHERE org = ?', $result['query']); + $this->assertEquals(['org1', 'org2'], $result['bindings']); + } + + public function testUnionBindingOrderWithComplexSubQueries(): void + { + $sub = (new Builder()) + ->from('archive') + ->filter([Query::equal('year', [2023])]) + ->limit(5); + + $result = (new Builder()) + ->from('current') + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->union($sub) + ->build(); + + $this->assertEquals(['active', 10, 2023, 5], $result['bindings']); + } + + public function testUnionWithDistinct(): void + { + $sub = (new Builder()) + ->from('archive') + ->distinct() + ->select(['name']); + + $result = (new Builder()) + ->from('current') + ->distinct() + ->select(['name']) + ->union($sub) + ->build(); + + $this->assertStringContainsString('SELECT DISTINCT `name` FROM `current`', $result['query']); + $this->assertStringContainsString('UNION SELECT DISTINCT `name` FROM `archive`', $result['query']); + } + + public function testUnionWithWrapChar(): void + { + $sub = (new Builder()) + ->setWrapChar('"') + ->from('archive'); + + $result = (new Builder()) + ->setWrapChar('"') + ->from('current') + ->union($sub) + ->build(); + + $this->assertEquals( + 'SELECT * FROM "current" UNION SELECT * FROM "archive"', + $result['query'] + ); + } + + public function testUnionAfterReset(): void + { + $builder = (new Builder())->from('old'); + $builder->build(); + $builder->reset(); + + $sub = (new Builder())->from('other'); + $result = $builder->from('fresh')->union($sub)->build(); + + $this->assertEquals( + 'SELECT * FROM `fresh` UNION SELECT * FROM `other`', + $result['query'] + ); + } + + public function testUnionChainedWithComplexBindings(): void + { + $q1 = (new Builder()) + ->from('a') + ->filter([Query::equal('x', [1]), Query::greaterThan('y', 2)]); + $q2 = (new Builder()) + ->from('b') + ->filter([Query::between('z', 10, 20)]); + + $result = (new Builder()) + ->from('main') + ->filter([Query::equal('status', ['active'])]) + ->union($q1) + ->unionAll($q2) + ->build(); + + $this->assertEquals(['active', 1, 2, 10, 20], $result['bindings']); + } + + public function testUnionWithFourSubQueries(): void + { + $q1 = (new Builder())->from('t1'); + $q2 = (new Builder())->from('t2'); + $q3 = (new Builder())->from('t3'); + $q4 = (new Builder())->from('t4'); + + $result = (new Builder()) + ->from('main') + ->union($q1) + ->union($q2) + ->union($q3) + ->union($q4) + ->build(); + + $this->assertEquals(4, substr_count($result['query'], 'UNION')); + } + + public function testUnionAllWithFilteredSubQueries(): void + { + $q1 = (new Builder())->from('orders_2022')->filter([Query::equal('status', ['paid'])]); + $q2 = (new Builder())->from('orders_2023')->filter([Query::equal('status', ['paid'])]); + $q3 = (new Builder())->from('orders_2024')->filter([Query::equal('status', ['paid'])]); + + $result = (new Builder()) + ->from('orders_2025') + ->filter([Query::equal('status', ['paid'])]) + ->unionAll($q1) + ->unionAll($q2) + ->unionAll($q3) + ->build(); + + $this->assertEquals(['paid', 'paid', 'paid', 'paid'], $result['bindings']); + $this->assertEquals(3, substr_count($result['query'], 'UNION ALL')); + } + + // ══════════════════════════════════════════ + // 10. toRawSql edge cases + // ══════════════════════════════════════════ + + public function testToRawSqlWithAllBindingTypesInOneQuery(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([ + Query::equal('name', ['Alice']), + Query::greaterThan('age', 18), + Query::raw('active = ?', [true]), + Query::raw('deleted = ?', [null]), + Query::raw('score > ?', [9.5]), + ]) + ->limit(10) + ->toRawSql(); + + $this->assertStringContainsString("'Alice'", $sql); + $this->assertStringContainsString('18', $sql); + $this->assertStringContainsString('= 1', $sql); + $this->assertStringContainsString('= NULL', $sql); + $this->assertStringContainsString('9.5', $sql); + $this->assertStringContainsString('10', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithEmptyStringBinding(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::equal('name', [''])]) + ->toRawSql(); + + $this->assertStringContainsString("''", $sql); + } + + public function testToRawSqlWithStringContainingSingleQuotes(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::equal('name', ["O'Brien"])]) + ->toRawSql(); + + $this->assertStringContainsString("O''Brien", $sql); + } + + public function testToRawSqlWithVeryLargeNumber(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('id', 99999999999)]) + ->toRawSql(); + + $this->assertStringContainsString('99999999999', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithNegativeNumber(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::lessThan('balance', -500)]) + ->toRawSql(); + + $this->assertStringContainsString('-500', $sql); + } + + public function testToRawSqlWithZero(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::equal('count', [0])]) + ->toRawSql(); + + $this->assertStringContainsString('IN (0)', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithFalseBoolean(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::raw('active = ?', [false])]) + ->toRawSql(); + + $this->assertStringContainsString('active = 0', $sql); + } + + public function testToRawSqlWithMultipleNullBindings(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::raw('a = ? AND b = ?', [null, null])]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `t` WHERE a = NULL AND b = NULL", $sql); + } + + public function testToRawSqlWithAggregationQuery(): void + { + $sql = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['status']) + ->having([Query::greaterThan('total', 5)]) + ->toRawSql(); + + $this->assertStringContainsString('COUNT(*) AS `total`', $sql); + $this->assertStringContainsString('HAVING `total` > 5', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithJoinQuery(): void + { + $sql = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.uid') + ->filter([Query::greaterThan('orders.total', 100)]) + ->toRawSql(); + + $this->assertStringContainsString('JOIN `orders`', $sql); + $this->assertStringContainsString('100', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithUnionQuery(): void + { + $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + + $sql = (new Builder()) + ->from('current') + ->filter([Query::equal('year', [2024])]) + ->union($sub) + ->toRawSql(); + + $this->assertStringContainsString('2024', $sql); + $this->assertStringContainsString('2023', $sql); + $this->assertStringContainsString('UNION', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithRegexAndSearch(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([ + Query::regex('slug', '^test'), + Query::search('content', 'hello'), + ]) + ->toRawSql(); + + $this->assertStringContainsString("REGEXP '^test'", $sql); + $this->assertStringContainsString("AGAINST('hello')", $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlCalledTwiceGivesSameResult(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active'])]) + ->limit(10); + + $sql1 = $builder->toRawSql(); + $sql2 = $builder->toRawSql(); + + $this->assertEquals($sql1, $sql2); + } + + public function testToRawSqlWithWrapChar(): void + { + $sql = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->filter([Query::equal('status', ['active'])]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM \"t\" WHERE \"status\" IN ('active')", $sql); + } + + // ══════════════════════════════════════════ + // 11. when() edge cases + // ══════════════════════════════════════════ + + public function testWhenWithComplexCallbackAddingMultipleFeatures(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, function (Builder $b) { + $b->filter([Query::equal('status', ['active'])]) + ->sortAsc('name') + ->limit(10); + }) + ->build(); + + $this->assertStringContainsString('WHERE `status` IN (?)', $result['query']); + $this->assertStringContainsString('ORDER BY `name` ASC', $result['query']); + $this->assertStringContainsString('LIMIT ?', $result['query']); + $this->assertEquals(['active', 10], $result['bindings']); + } + + public function testWhenChainedFiveTimes(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, fn (Builder $b) => $b->filter([Query::equal('a', [1])])) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('b', [2])])) + ->when(false, fn (Builder $b) => $b->filter([Query::equal('c', [3])])) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('d', [4])])) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('e', [5])])) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?) AND `d` IN (?) AND `e` IN (?)', + $result['query'] + ); + $this->assertEquals([1, 2, 4, 5], $result['bindings']); + } + + public function testWhenInsideWhenThreeLevelsDeep(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, function (Builder $b) { + $b->when(true, function (Builder $b2) { + $b2->when(true, fn (Builder $b3) => $b3->filter([Query::equal('deep', [1])])); + }); + }) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `deep` IN (?)', $result['query']); + $this->assertEquals([1], $result['bindings']); + } + + public function testWhenThatAddsJoins(): void + { + $result = (new Builder()) + ->from('users') + ->when(true, fn (Builder $b) => $b->join('orders', 'users.id', 'orders.uid')) + ->build(); + + $this->assertStringContainsString('JOIN `orders`', $result['query']); + } + + public function testWhenThatAddsAggregations(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, fn (Builder $b) => $b->count('*', 'total')->groupBy(['status'])) + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `total`', $result['query']); + $this->assertStringContainsString('GROUP BY `status`', $result['query']); + } + + public function testWhenThatAddsUnions(): void + { + $sub = (new Builder())->from('archive'); + + $result = (new Builder()) + ->from('current') + ->when(true, fn (Builder $b) => $b->union($sub)) + ->build(); + + $this->assertStringContainsString('UNION', $result['query']); + } + + public function testWhenFalseDoesNotAffectFilters(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->filter([Query::equal('status', ['banned'])])) + ->build(); + + $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testWhenFalseDoesNotAffectJoins(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->join('other', 'a', 'b')) + ->build(); + + $this->assertStringNotContainsString('JOIN', $result['query']); + } + + public function testWhenFalseDoesNotAffectAggregations(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->count('*', 'total')) + ->build(); + + $this->assertEquals('SELECT * FROM `t`', $result['query']); + } + + public function testWhenFalseDoesNotAffectSort(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->sortAsc('name')) + ->build(); + + $this->assertStringNotContainsString('ORDER BY', $result['query']); + } + + // ══════════════════════════════════════════ + // 12. Condition provider edge cases + // ══════════════════════════════════════════ + + public function testThreeConditionProviders(): void + { + $result = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $t): array => ['p1 = ?', ['v1']]) + ->addConditionProvider(fn (string $t): array => ['p2 = ?', ['v2']]) + ->addConditionProvider(fn (string $t): array => ['p3 = ?', ['v3']]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE p1 = ? AND p2 = ? AND p3 = ?', + $result['query'] + ); + $this->assertEquals(['v1', 'v2', 'v3'], $result['bindings']); + } + + public function testProviderReturningEmptyConditionString(): void + { + $result = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $t): array => ['', []]) + ->build(); + + // Empty string still appears as a WHERE clause element + $this->assertStringContainsString('WHERE', $result['query']); + } + + public function testProviderWithManyBindings(): void + { + $result = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $t): array => [ + 'a IN (?, ?, ?, ?, ?)', + [1, 2, 3, 4, 5], + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE a IN (?, ?, ?, ?, ?)', + $result['query'] + ); + $this->assertEquals([1, 2, 3, 4, 5], $result['bindings']); + } + + public function testProviderCombinedWithCursorFilterHaving(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]) + ->filter([Query::equal('status', ['active'])]) + ->cursorAfter('cur1') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + + $this->assertStringContainsString('WHERE', $result['query']); + $this->assertStringContainsString('HAVING', $result['query']); + // filter, provider, cursor, having + $this->assertEquals(['active', 'org1', 'cur1', 5], $result['bindings']); + } + + public function testProviderCombinedWithJoins(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.uid') + ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->build(); + + $this->assertStringContainsString('JOIN `orders`', $result['query']); + $this->assertStringContainsString('WHERE tenant = ?', $result['query']); + $this->assertEquals(['t1'], $result['bindings']); + } + + public function testProviderCombinedWithUnions(): void + { + $sub = (new Builder())->from('archive'); + + $result = (new Builder()) + ->from('current') + ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]) + ->union($sub) + ->build(); + + $this->assertStringContainsString('WHERE org = ?', $result['query']); + $this->assertStringContainsString('UNION', $result['query']); + $this->assertEquals(['org1'], $result['bindings']); + } + + public function testProviderCombinedWithAggregations(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]) + ->groupBy(['status']) + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `total`', $result['query']); + $this->assertStringContainsString('WHERE org = ?', $result['query']); + } + + public function testProviderReferencesTableName(): void + { + $result = (new Builder()) + ->from('users') + ->addConditionProvider(fn (string $table): array => [ + "EXISTS (SELECT 1 FROM {$table}_perms WHERE type = ?)", + ['read'], + ]) + ->build(); + + $this->assertStringContainsString('users_perms', $result['query']); + $this->assertEquals(['read'], $result['bindings']); + } + + public function testProviderWithWrapCharProviderSqlIsLiteral(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->addConditionProvider(fn (string $t): array => ['raw_col = ?', [1]]) + ->build(); + + // Provider SQL is NOT wrapped - only the FROM clause is + $this->assertStringContainsString('FROM "t"', $result['query']); + $this->assertStringContainsString('raw_col = ?', $result['query']); + } + + public function testProviderBindingOrderWithComplexQuery(): void + { + $result = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $t): array => ['p1 = ?', ['pv1']]) + ->addConditionProvider(fn (string $t): array => ['p2 = ?', ['pv2']]) + ->filter([ + Query::equal('a', ['va']), + Query::greaterThan('b', 10), + ]) + ->cursorAfter('cur') + ->limit(5) + ->offset(10) + ->build(); + + // filter, provider1, provider2, cursor, limit, offset + $this->assertEquals(['va', 10, 'pv1', 'pv2', 'cur', 5, 10], $result['bindings']); + } + + public function testProviderPreservedAcrossReset(): void + { + $builder = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t2')->build(); + $this->assertStringContainsString('WHERE org = ?', $result['query']); + $this->assertEquals(['org1'], $result['bindings']); + } + + public function testFourConditionProviders(): void + { + $result = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $t): array => ['a = ?', [1]]) + ->addConditionProvider(fn (string $t): array => ['b = ?', [2]]) + ->addConditionProvider(fn (string $t): array => ['c = ?', [3]]) + ->addConditionProvider(fn (string $t): array => ['d = ?', [4]]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE a = ? AND b = ? AND c = ? AND d = ?', + $result['query'] + ); + $this->assertEquals([1, 2, 3, 4], $result['bindings']); + } + + public function testProviderWithNoBindings(): void + { + $result = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $t): array => ['1 = 1', []]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + // ══════════════════════════════════════════ + // 13. Reset edge cases + // ══════════════════════════════════════════ + + public function testResetPreservesAttributeResolver(): void + { + $builder = (new Builder()) + ->from('t') + ->setAttributeResolver(fn (string $a): string => '_' . $a) + ->filter([Query::equal('x', [1])]); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t2')->filter([Query::equal('y', [2])])->build(); + $this->assertStringContainsString('`_y`', $result['query']); + } + + public function testResetPreservesConditionProviders(): void + { + $builder = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t2')->build(); + $this->assertStringContainsString('org = ?', $result['query']); + $this->assertEquals(['org1'], $result['bindings']); + } + + public function testResetPreservesWrapChar(): void + { + $builder = (new Builder()) + ->from('t') + ->setWrapChar('"'); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t2')->select(['name'])->build(); + $this->assertEquals('SELECT "name" FROM "t2"', $result['query']); + } + + public function testResetClearsPendingQueries(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]) + ->sortAsc('name') + ->limit(10); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t2')->build(); + $this->assertEquals('SELECT * FROM `t2`', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testResetClearsBindings(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]); + + $builder->build(); + $this->assertNotEmpty($builder->getBindings()); + + $builder->reset(); + $result = $builder->from('t2')->build(); + $this->assertEquals([], $result['bindings']); + } + + public function testResetClearsTable(): void + { + $builder = (new Builder())->from('old_table'); + $builder->build(); + $builder->reset(); + + $result = $builder->from('new_table')->build(); + $this->assertStringContainsString('`new_table`', $result['query']); + $this->assertStringNotContainsString('`old_table`', $result['query']); + } + + public function testResetClearsUnionsAfterBuild(): void + { + $sub = (new Builder())->from('other'); + $builder = (new Builder())->from('main')->union($sub); + $builder->build(); + $builder->reset(); + + $result = $builder->from('fresh')->build(); + $this->assertStringNotContainsString('UNION', $result['query']); + } + + public function testBuildAfterResetProducesMinimalQuery(): void + { + $builder = (new Builder()) + ->from('complex') + ->select(['a', 'b']) + ->filter([Query::equal('x', [1])]) + ->sortAsc('a') + ->limit(10) + ->offset(5); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertEquals('SELECT * FROM `t`', $result['query']); + } + + public function testMultipleResetCalls(): void + { + $builder = (new Builder())->from('t')->filter([Query::equal('a', [1])]); + $builder->build(); + $builder->reset(); + $builder->reset(); + $builder->reset(); + + $result = $builder->from('t2')->build(); + $this->assertEquals('SELECT * FROM `t2`', $result['query']); + } + + public function testResetBetweenDifferentQueryTypes(): void + { + $builder = new Builder(); + + // First: aggregation query + $builder->from('orders')->count('*', 'total')->groupBy(['status']); + $result1 = $builder->build(); + $this->assertStringContainsString('COUNT(*)', $result1['query']); + + $builder->reset(); + + // Second: simple select query + $builder->from('users')->select(['name'])->filter([Query::equal('active', [true])]); + $result2 = $builder->build(); + $this->assertStringNotContainsString('COUNT', $result2['query']); + $this->assertStringContainsString('`name`', $result2['query']); + } + + public function testResetAfterUnion(): void + { + $sub = (new Builder())->from('other'); + $builder = (new Builder())->from('main')->union($sub); + $builder->build(); + $builder->reset(); + + $result = $builder->from('new')->build(); + $this->assertEquals('SELECT * FROM `new`', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testResetAfterComplexQueryWithAllFeatures(): void + { + $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + + $builder = (new Builder()) + ->from('orders') + ->distinct() + ->count('*', 'cnt') + ->select(['status']) + ->join('users', 'orders.uid', 'users.id') + ->filter([Query::equal('status', ['paid'])]) + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 1)]) + ->sortDesc('cnt') + ->limit(10) + ->offset(5) + ->union($sub); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('simple')->build(); + $this->assertEquals('SELECT * FROM `simple`', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + // ══════════════════════════════════════════ + // 14. Multiple build() calls + // ══════════════════════════════════════════ + + public function testBuildTwiceModifyInBetween(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]); + + $result1 = $builder->build(); + + $builder->filter([Query::equal('b', [2])]); + $result2 = $builder->build(); + + $this->assertStringNotContainsString('`b`', $result1['query']); + $this->assertStringContainsString('`b`', $result2['query']); + } + + public function testBuildDoesNotMutatePendingQueries(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]) + ->limit(10); + + $result1 = $builder->build(); + $result2 = $builder->build(); + + $this->assertEquals($result1['query'], $result2['query']); + $this->assertEquals($result1['bindings'], $result2['bindings']); + } + + public function testBuildResetsBindingsEachTime(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]); + + $builder->build(); + $bindings1 = $builder->getBindings(); + + $builder->build(); + $bindings2 = $builder->getBindings(); + + $this->assertEquals($bindings1, $bindings2); + $this->assertCount(1, $bindings2); + } + + public function testBuildWithConditionProducesConsistentBindings(): void + { + $builder = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]) + ->filter([Query::equal('status', ['active'])]); + + $result1 = $builder->build(); + $result2 = $builder->build(); + $result3 = $builder->build(); + + $this->assertEquals($result1['bindings'], $result2['bindings']); + $this->assertEquals($result2['bindings'], $result3['bindings']); + } + + public function testBuildAfterAddingMoreQueries(): void + { + $builder = (new Builder())->from('t'); + + $result1 = $builder->build(); + $this->assertEquals('SELECT * FROM `t`', $result1['query']); + + $builder->filter([Query::equal('a', [1])]); + $result2 = $builder->build(); + $this->assertStringContainsString('WHERE', $result2['query']); + + $builder->sortAsc('a'); + $result3 = $builder->build(); + $this->assertStringContainsString('ORDER BY', $result3['query']); + } + + public function testBuildWithUnionProducesConsistentResults(): void + { + $sub = (new Builder())->from('other')->filter([Query::equal('x', [1])]); + $builder = (new Builder())->from('main')->union($sub); + + $result1 = $builder->build(); + $result2 = $builder->build(); + + $this->assertEquals($result1['query'], $result2['query']); + $this->assertEquals($result1['bindings'], $result2['bindings']); + } + + public function testBuildThreeTimesWithIncreasingComplexity(): void + { + $builder = (new Builder())->from('t'); + + $r1 = $builder->build(); + $this->assertEquals('SELECT * FROM `t`', $r1['query']); + + $builder->filter([Query::equal('a', [1])]); + $r2 = $builder->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $r2['query']); + + $builder->limit(10)->offset(5); + $r3 = $builder->build(); + $this->assertStringContainsString('LIMIT ?', $r3['query']); + $this->assertStringContainsString('OFFSET ?', $r3['query']); + } + + public function testBuildBindingsNotAccumulated(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]) + ->limit(10); + + $builder->build(); + $builder->build(); + $builder->build(); + + $this->assertCount(2, $builder->getBindings()); + } + + public function testMultipleBuildWithHavingBindings(): void + { + $builder = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 5)]); + + $r1 = $builder->build(); + $r2 = $builder->build(); + + $this->assertEquals([5], $r1['bindings']); + $this->assertEquals([5], $r2['bindings']); + } + + // ══════════════════════════════════════════ + // 15. Binding ordering comprehensive + // ══════════════════════════════════════════ + + public function testBindingOrderMultipleFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('a', ['v1']), + Query::greaterThan('b', 10), + Query::between('c', 1, 100), + ]) + ->build(); + + $this->assertEquals(['v1', 10, 1, 100], $result['bindings']); + } + + public function testBindingOrderThreeProviders(): void + { + $result = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $t): array => ['p1 = ?', ['pv1']]) + ->addConditionProvider(fn (string $t): array => ['p2 = ?', ['pv2']]) + ->addConditionProvider(fn (string $t): array => ['p3 = ?', ['pv3']]) + ->build(); + + $this->assertEquals(['pv1', 'pv2', 'pv3'], $result['bindings']); + } + + public function testBindingOrderMultipleUnions(): void + { + $q1 = (new Builder())->from('a')->filter([Query::equal('x', [1])]); + $q2 = (new Builder())->from('b')->filter([Query::equal('y', [2])]); + + $result = (new Builder()) + ->from('main') + ->filter([Query::equal('z', [3])]) + ->limit(5) + ->union($q1) + ->unionAll($q2) + ->build(); + + // main filter, main limit, union1 bindings, union2 bindings + $this->assertEquals([3, 5, 1, 2], $result['bindings']); + } + + public function testBindingOrderLogicalAndWithMultipleSubFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::equal('a', [1]), + Query::greaterThan('b', 2), + Query::lessThan('c', 3), + ]), + ]) + ->build(); + + $this->assertEquals([1, 2, 3], $result['bindings']); + } + + public function testBindingOrderLogicalOrWithMultipleSubFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('a', [1]), + Query::equal('b', [2]), + Query::equal('c', [3]), + ]), + ]) + ->build(); + + $this->assertEquals([1, 2, 3], $result['bindings']); + } + + public function testBindingOrderNestedAndOr(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::equal('a', [1]), + Query::or([ + Query::equal('b', [2]), + Query::equal('c', [3]), + ]), + ]), + ]) + ->build(); + + $this->assertEquals([1, 2, 3], $result['bindings']); + } + + public function testBindingOrderRawMixedWithRegularFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('a', ['v1']), + Query::raw('custom > ?', [10]), + Query::greaterThan('b', 20), + ]) + ->build(); + + $this->assertEquals(['v1', 10, 20], $result['bindings']); + } + + public function testBindingOrderAggregationHavingComplexConditions(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->sum('price', 'total') + ->filter([Query::equal('status', ['active'])]) + ->groupBy(['category']) + ->having([ + Query::greaterThan('cnt', 5), + Query::lessThan('total', 10000), + ]) + ->limit(10) + ->build(); + + // filter, having1, having2, limit + $this->assertEquals(['active', 5, 10000, 10], $result['bindings']); + } + + public function testBindingOrderFullPipelineWithEverything(): void + { + $sub = (new Builder())->from('archive')->filter([Query::equal('archived', [true])]); + + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->filter([ + Query::equal('status', ['paid']), + Query::greaterThan('total', 0), + ]) + ->cursorAfter('cursor_val') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 1)]) + ->limit(25) + ->offset(50) + ->union($sub) + ->build(); + + // filter(paid, 0), provider(t1), cursor(cursor_val), having(1), limit(25), offset(50), union(true) + $this->assertEquals(['paid', 0, 't1', 'cursor_val', 1, 25, 50, true], $result['bindings']); + } + + public function testBindingOrderContainsMultipleValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::contains('bio', ['php', 'js', 'go']), + Query::equal('status', ['active']), + ]) + ->build(); + + // contains produces three LIKE bindings, then equal + $this->assertEquals(['%php%', '%js%', '%go%', 'active'], $result['bindings']); + } + + public function testBindingOrderBetweenAndComparisons(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::between('age', 18, 65), + Query::greaterThan('score', 50), + Query::lessThan('rank', 100), + ]) + ->build(); + + $this->assertEquals([18, 65, 50, 100], $result['bindings']); + } + + public function testBindingOrderStartsWithEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::startsWith('name', 'A'), + Query::endsWith('email', '.com'), + ]) + ->build(); + + $this->assertEquals(['A%', '%.com'], $result['bindings']); + } + + public function testBindingOrderSearchAndRegex(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::search('content', 'hello'), + Query::regex('slug', '^test'), + ]) + ->build(); + + $this->assertEquals(['hello', '^test'], $result['bindings']); + } + + public function testBindingOrderWithCursorBeforeFilterAndLimit(): void + { + $result = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]) + ->filter([Query::equal('a', ['x'])]) + ->cursorBefore('my_cursor') + ->limit(10) + ->offset(0) + ->build(); + + // filter, provider, cursor, limit, offset + $this->assertEquals(['x', 'org1', 'my_cursor', 10, 0], $result['bindings']); + } + + // ══════════════════════════════════════════ + // 16. Empty/minimal queries + // ══════════════════════════════════════════ + + public function testBuildWithNoFromNoFilters(): void + { + $result = (new Builder())->from('')->build(); + $this->assertEquals('SELECT * FROM ``', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testBuildWithOnlyLimit(): void + { + $result = (new Builder()) + ->from('') + ->limit(10) + ->build(); + + $this->assertStringContainsString('LIMIT ?', $result['query']); + $this->assertEquals([10], $result['bindings']); + } + + public function testBuildWithOnlyOffset(): void + { + $result = (new Builder()) + ->from('') + ->offset(50) + ->build(); + + $this->assertStringContainsString('OFFSET ?', $result['query']); + $this->assertEquals([50], $result['bindings']); + } + + public function testBuildWithOnlySort(): void + { + $result = (new Builder()) + ->from('') + ->sortAsc('name') + ->build(); + + $this->assertStringContainsString('ORDER BY `name` ASC', $result['query']); + } + + public function testBuildWithOnlySelect(): void + { + $result = (new Builder()) + ->from('') + ->select(['a', 'b']) + ->build(); + + $this->assertStringContainsString('SELECT `a`, `b`', $result['query']); + } + + public function testBuildWithOnlyAggregationNoFrom(): void + { + $result = (new Builder()) + ->from('') + ->count('*', 'total') + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `total`', $result['query']); + } + + public function testBuildWithEmptyFilterArray(): void + { + $result = (new Builder()) + ->from('t') + ->filter([]) + ->build(); + + $this->assertEquals('SELECT * FROM `t`', $result['query']); + } + + public function testBuildWithEmptySelectArray(): void + { + $result = (new Builder()) + ->from('t') + ->select([]) + ->build(); + + $this->assertEquals('SELECT FROM `t`', $result['query']); + } + + public function testBuildWithOnlyHavingNoGroupBy(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->having([Query::greaterThan('cnt', 0)]) + ->build(); + + $this->assertStringContainsString('HAVING `cnt` > ?', $result['query']); + $this->assertStringNotContainsString('GROUP BY', $result['query']); + } + + public function testBuildWithOnlyDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->build(); + + $this->assertEquals('SELECT DISTINCT * FROM `t`', $result['query']); + } + + // ══════════════════════════════════════════ + // Spatial/Vector/ElemMatch Exception Tests + // ══════════════════════════════════════════ + + public function testUnsupportedFilterTypeCrosses(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::crosses('attr', ['val'])])->build(); + } + + public function testUnsupportedFilterTypeNotCrosses(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::notCrosses('attr', ['val'])])->build(); + } + + public function testUnsupportedFilterTypeDistanceEqual(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::distanceEqual('attr', [0, 0], 1)])->build(); + } + + public function testUnsupportedFilterTypeDistanceNotEqual(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::distanceNotEqual('attr', [0, 0], 1)])->build(); + } + + public function testUnsupportedFilterTypeDistanceGreaterThan(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::distanceGreaterThan('attr', [0, 0], 1)])->build(); + } + + public function testUnsupportedFilterTypeDistanceLessThan(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::distanceLessThan('attr', [0, 0], 1)])->build(); + } + + public function testUnsupportedFilterTypeIntersects(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::intersects('attr', ['val'])])->build(); + } + + public function testUnsupportedFilterTypeNotIntersects(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::notIntersects('attr', ['val'])])->build(); + } + + public function testUnsupportedFilterTypeOverlaps(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::overlaps('attr', ['val'])])->build(); + } + + public function testUnsupportedFilterTypeNotOverlaps(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::notOverlaps('attr', ['val'])])->build(); + } + + public function testUnsupportedFilterTypeTouches(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::touches('attr', ['val'])])->build(); + } + + public function testUnsupportedFilterTypeNotTouches(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::notTouches('attr', ['val'])])->build(); + } + + public function testUnsupportedFilterTypeVectorDot(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::vectorDot('attr', [1.0, 2.0])])->build(); + } + + public function testUnsupportedFilterTypeVectorCosine(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::vectorCosine('attr', [1.0, 2.0])])->build(); + } + + public function testUnsupportedFilterTypeVectorEuclidean(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::vectorEuclidean('attr', [1.0, 2.0])])->build(); + } + + public function testUnsupportedFilterTypeElemMatch(): void + { + $this->expectException(\Utopia\Query\Exception::class); + (new Builder())->from('t')->filter([Query::elemMatch('attr', [Query::equal('x', [1])])])->build(); + } + + // ══════════════════════════════════════════ + // toRawSql Edge Cases + // ══════════════════════════════════════════ + + public function testToRawSqlWithBoolFalse(): void + { + $sql = (new Builder())->from('t')->filter([Query::equal('active', [false])])->toRawSql(); + $this->assertEquals("SELECT * FROM `t` WHERE `active` IN (0)", $sql); + } + + public function testToRawSqlMixedBindingTypes(): void + { + $sql = (new Builder())->from('t') + ->filter([ + Query::equal('name', ['str']), + Query::greaterThan('age', 42), + Query::lessThan('score', 9.99), + Query::equal('active', [true]), + ])->toRawSql(); + $this->assertStringContainsString("'str'", $sql); + $this->assertStringContainsString('42', $sql); + $this->assertStringContainsString('9.99', $sql); + $this->assertStringContainsString('1', $sql); + } + + public function testToRawSqlWithNull(): void + { + $sql = (new Builder())->from('t') + ->filter([Query::raw('col = ?', [null])]) + ->toRawSql(); + $this->assertStringContainsString('NULL', $sql); + } + + public function testToRawSqlWithUnion(): void + { + $other = (new Builder())->from('b')->filter([Query::equal('x', [1])]); + $sql = (new Builder())->from('a')->filter([Query::equal('y', [2])])->union($other)->toRawSql(); + $this->assertStringContainsString("FROM `a`", $sql); + $this->assertStringContainsString('UNION', $sql); + $this->assertStringContainsString("FROM `b`", $sql); + $this->assertStringContainsString('2', $sql); + $this->assertStringContainsString('1', $sql); + } + + public function testToRawSqlWithAggregationJoinGroupByHaving(): void + { + $sql = (new Builder())->from('orders') + ->count('*', 'total') + ->join('users', 'orders.uid', 'users.id') + ->select(['users.country']) + ->groupBy(['users.country']) + ->having([Query::greaterThan('total', 5)]) + ->toRawSql(); + $this->assertStringContainsString('COUNT(*)', $sql); + $this->assertStringContainsString('JOIN', $sql); + $this->assertStringContainsString('GROUP BY', $sql); + $this->assertStringContainsString('HAVING', $sql); + $this->assertStringContainsString('5', $sql); + } + + // ══════════════════════════════════════════ + // Kitchen Sink Exact SQL + // ══════════════════════════════════════════ + + public function testKitchenSinkExactSql(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('status', ['closed'])]); + $result = (new Builder()) + ->from('orders') + ->distinct() + ->count('*', 'total') + ->select(['status']) + ->join('users', 'orders.uid', 'users.id') + ->filter([Query::greaterThan('amount', 100)]) + ->groupBy(['status']) + ->having([Query::greaterThan('total', 5)]) + ->sortAsc('status') + ->limit(10) + ->offset(20) + ->union($other) + ->build(); + + $this->assertEquals( + 'SELECT DISTINCT COUNT(*) AS `total`, `status` FROM `orders` JOIN `users` ON `orders.uid` = `users.id` WHERE `amount` > ? GROUP BY `status` HAVING `total` > ? ORDER BY `status` ASC LIMIT ? OFFSET ? UNION SELECT * FROM `archive` WHERE `status` IN (?)', + $result['query'] + ); + $this->assertEquals([100, 5, 10, 20, 'closed'], $result['bindings']); + } + + // ══════════════════════════════════════════ + // Feature Combination Tests + // ══════════════════════════════════════════ + + public function testDistinctWithUnion(): void + { + $other = (new Builder())->from('b'); + $result = (new Builder())->from('a')->distinct()->union($other)->build(); + $this->assertEquals('SELECT DISTINCT * FROM `a` UNION SELECT * FROM `b`', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testRawInsideLogicalAnd(): void + { + $result = (new Builder())->from('t') + ->filter([Query::and([ + Query::greaterThan('x', 1), + Query::raw('custom_func(y) > ?', [5]), + ])]) + ->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`x` > ? AND custom_func(y) > ?)', $result['query']); + $this->assertEquals([1, 5], $result['bindings']); + } + + public function testRawInsideLogicalOr(): void + { + $result = (new Builder())->from('t') + ->filter([Query::or([ + Query::equal('a', [1]), + Query::raw('b IS NOT NULL', []), + ])]) + ->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result['query']); + $this->assertEquals([1], $result['bindings']); + } + + public function testAggregationWithCursor(): void + { + $result = (new Builder())->from('t') + ->count('*', 'total') + ->cursorAfter('abc') + ->build(); + $this->assertStringContainsString('COUNT(*)', $result['query']); + $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertContains('abc', $result['bindings']); + } + + public function testGroupBySortCursorUnion(): void + { + $other = (new Builder())->from('b'); + $result = (new Builder())->from('a') + ->count('*', 'total') + ->groupBy(['status']) + ->sortDesc('total') + ->cursorAfter('xyz') + ->union($other) + ->build(); + $this->assertStringContainsString('GROUP BY', $result['query']); + $this->assertStringContainsString('ORDER BY', $result['query']); + $this->assertStringContainsString('UNION', $result['query']); + } + + public function testConditionProviderWithNoFilters(): void + { + $result = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $table) => ["_tenant = ?", ['t1']]) + ->build(); + $this->assertEquals('SELECT * FROM `t` WHERE _tenant = ?', $result['query']); + $this->assertEquals(['t1'], $result['bindings']); + } + + public function testConditionProviderWithCursorNoFilters(): void + { + $result = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $table) => ["_tenant = ?", ['t1']]) + ->cursorAfter('abc') + ->build(); + $this->assertStringContainsString('_tenant = ?', $result['query']); + $this->assertStringContainsString('_cursor > ?', $result['query']); + // Provider bindings come before cursor bindings + $this->assertEquals(['t1', 'abc'], $result['bindings']); + } + + public function testConditionProviderWithDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->addConditionProvider(fn (string $table) => ["_tenant = ?", ['t1']]) + ->build(); + $this->assertEquals('SELECT DISTINCT * FROM `t` WHERE _tenant = ?', $result['query']); + $this->assertEquals(['t1'], $result['bindings']); + } + + public function testConditionProviderPersistsAfterReset(): void + { + $builder = (new Builder()) + ->from('t') + ->addConditionProvider(fn (string $table) => ["_tenant = ?", ['t1']]); + $builder->build(); + $builder->reset()->from('other'); + $result = $builder->build(); + $this->assertStringContainsString('FROM `other`', $result['query']); + $this->assertStringContainsString('_tenant = ?', $result['query']); + $this->assertEquals(['t1'], $result['bindings']); + } + + public function testConditionProviderWithHaving(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->addConditionProvider(fn (string $table) => ["_tenant = ?", ['t1']]) + ->having([Query::greaterThan('total', 5)]) + ->build(); + // Provider should be in WHERE, not HAVING + $this->assertStringContainsString('WHERE _tenant = ?', $result['query']); + $this->assertStringContainsString('HAVING `total` > ?', $result['query']); + // Provider bindings before having bindings + $this->assertEquals(['t1', 5], $result['bindings']); + } + + public function testUnionWithConditionProvider(): void + { + $sub = (new Builder()) + ->from('b') + ->addConditionProvider(fn (string $table) => ["_deleted = ?", [0]]); + $result = (new Builder()) + ->from('a') + ->union($sub) + ->build(); + // Sub-query should include the condition provider + $this->assertStringContainsString('UNION SELECT * FROM `b` WHERE _deleted = ?', $result['query']); + $this->assertEquals([0], $result['bindings']); + } + + // ══════════════════════════════════════════ + // Boundary Value Tests + // ══════════════════════════════════════════ + + public function testNegativeLimit(): void + { + $result = (new Builder())->from('t')->limit(-1)->build(); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result['query']); + $this->assertEquals([-1], $result['bindings']); + } + + public function testNegativeOffset(): void + { + $result = (new Builder())->from('t')->offset(-5)->build(); + $this->assertEquals('SELECT * FROM `t` OFFSET ?', $result['query']); + $this->assertEquals([-5], $result['bindings']); + } + + public function testEqualWithNullOnly(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('col', [null])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NULL', $result['query']); + $this->assertSame([], $result['bindings']); + } + + public function testEqualWithNullAndNonNull(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('col', ['a', null])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` IN (?) OR `col` IS NULL)', $result['query']); + $this->assertSame(['a'], $result['bindings']); + } + + public function testNotEqualWithNullOnly(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('col', [null])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NOT NULL', $result['query']); + $this->assertSame([], $result['bindings']); + } + + public function testNotEqualWithNullAndNonNull(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', null])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` != ? AND `col` IS NOT NULL)', $result['query']); + $this->assertSame(['a'], $result['bindings']); + } + + public function testNotEqualWithMultipleNonNullAndNull(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', 'b', null])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` NOT IN (?, ?) AND `col` IS NOT NULL)', $result['query']); + $this->assertSame(['a', 'b'], $result['bindings']); + } + + public function testBetweenReversedMinMax(): void + { + $result = (new Builder())->from('t')->filter([Query::between('age', 65, 18)])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result['query']); + $this->assertEquals([65, 18], $result['bindings']); + } + + public function testContainsWithSqlWildcard(): void + { + $result = (new Builder())->from('t')->filter([Query::contains('bio', ['100%'])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result['query']); + $this->assertEquals(['%100%%'], $result['bindings']); + } + + public function testStartsWithWithWildcard(): void + { + $result = (new Builder())->from('t')->filter([Query::startsWith('name', '%admin')])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result['query']); + $this->assertEquals(['%admin%'], $result['bindings']); + } + + public function testCursorWithNullValue(): void + { + // Null cursor value is ignored by groupByType since cursor stays null + $result = (new Builder())->from('t')->cursorAfter(null)->build(); + $this->assertStringNotContainsString('_cursor', $result['query']); + $this->assertEquals([], $result['bindings']); + } + + public function testCursorWithIntegerValue(): void + { + $result = (new Builder())->from('t')->cursorAfter(42)->build(); + $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertSame([42], $result['bindings']); + } + + public function testCursorWithFloatValue(): void + { + $result = (new Builder())->from('t')->cursorAfter(3.14)->build(); + $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertSame([3.14], $result['bindings']); + } + + public function testMultipleLimitsFirstWins(): void + { + $result = (new Builder())->from('t')->limit(10)->limit(20)->build(); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result['query']); + $this->assertEquals([10], $result['bindings']); + } + + public function testMultipleOffsetsFirstWins(): void + { + $result = (new Builder())->from('t')->offset(5)->offset(50)->build(); + $this->assertEquals('SELECT * FROM `t` OFFSET ?', $result['query']); + $this->assertEquals([5], $result['bindings']); + } + + public function testCursorAfterAndBeforeFirstWins(): void + { + $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->build(); + $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertStringNotContainsString('_cursor < ?', $result['query']); + } + + public function testEmptyTableWithJoin(): void + { + $result = (new Builder())->from('')->join('other', 'a', 'b')->build(); + $this->assertEquals('SELECT * FROM `` JOIN `other` ON `a` = `b`', $result['query']); + } + + public function testBuildWithoutFromCall(): void + { + $result = (new Builder())->filter([Query::equal('x', [1])])->build(); + $this->assertStringContainsString('FROM ``', $result['query']); + $this->assertStringContainsString('`x` IN (?)', $result['query']); + } + + // ══════════════════════════════════════════ + // Standalone Compiler Method Tests + // ══════════════════════════════════════════ + + public function testCompileSelectEmpty(): void + { + $builder = new Builder(); + $result = $builder->compileSelect(Query::select([])); + $this->assertEquals('', $result); + } + + public function testCompileGroupByEmpty(): void + { + $builder = new Builder(); + $result = $builder->compileGroupBy(Query::groupBy([])); + $this->assertEquals('', $result); + } + + public function testCompileGroupBySingleColumn(): void + { + $builder = new Builder(); + $result = $builder->compileGroupBy(Query::groupBy(['status'])); + $this->assertEquals('`status`', $result); + } + + public function testCompileSumWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::sum('price')); + $this->assertEquals('SUM(`price`)', $sql); + } + + public function testCompileAvgWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::avg('score')); + $this->assertEquals('AVG(`score`)', $sql); + } + + public function testCompileMinWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::min('price')); + $this->assertEquals('MIN(`price`)', $sql); + } + + public function testCompileMaxWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::max('price')); + $this->assertEquals('MAX(`price`)', $sql); + } + + public function testCompileLimitZero(): void + { + $builder = new Builder(); + $sql = $builder->compileLimit(Query::limit(0)); + $this->assertEquals('LIMIT ?', $sql); + $this->assertSame([0], $builder->getBindings()); + } + + public function testCompileOffsetZero(): void + { + $builder = new Builder(); + $sql = $builder->compileOffset(Query::offset(0)); + $this->assertEquals('OFFSET ?', $sql); + $this->assertSame([0], $builder->getBindings()); + } + + public function testCompileOrderException(): void + { + $builder = new Builder(); + $this->expectException(\Utopia\Query\Exception::class); + $builder->compileOrder(Query::limit(10)); + } + + public function testCompileJoinException(): void + { + $builder = new Builder(); + $this->expectException(\Utopia\Query\Exception::class); + $builder->compileJoin(Query::equal('x', [1])); + } + + // ══════════════════════════════════════════ + // Query::compile() Integration Tests + // ══════════════════════════════════════════ + + public function testQueryCompileOrderAsc(): void + { + $builder = new Builder(); + $this->assertEquals('`name` ASC', Query::orderAsc('name')->compile($builder)); + } + + public function testQueryCompileOrderDesc(): void + { + $builder = new Builder(); + $this->assertEquals('`name` DESC', Query::orderDesc('name')->compile($builder)); + } + + public function testQueryCompileOrderRandom(): void + { + $builder = new Builder(); + $this->assertEquals('RAND()', Query::orderRandom()->compile($builder)); + } + + public function testQueryCompileLimit(): void + { + $builder = new Builder(); + $this->assertEquals('LIMIT ?', Query::limit(10)->compile($builder)); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testQueryCompileOffset(): void + { + $builder = new Builder(); + $this->assertEquals('OFFSET ?', Query::offset(5)->compile($builder)); + $this->assertEquals([5], $builder->getBindings()); + } + + public function testQueryCompileCursorAfter(): void + { + $builder = new Builder(); + $this->assertEquals('_cursor > ?', Query::cursorAfter('x')->compile($builder)); + $this->assertEquals(['x'], $builder->getBindings()); + } + + public function testQueryCompileCursorBefore(): void + { + $builder = new Builder(); + $this->assertEquals('_cursor < ?', Query::cursorBefore('x')->compile($builder)); + $this->assertEquals(['x'], $builder->getBindings()); + } + + public function testQueryCompileSelect(): void + { + $builder = new Builder(); + $this->assertEquals('`a`, `b`', Query::select(['a', 'b'])->compile($builder)); + } + + public function testQueryCompileGroupBy(): void + { + $builder = new Builder(); + $this->assertEquals('`status`', Query::groupBy(['status'])->compile($builder)); + } + + // ══════════════════════════════════════════ + // setWrapChar Edge Cases + // ══════════════════════════════════════════ + + public function testSetWrapCharWithIsNotNull(): void + { + $result = (new Builder())->setWrapChar('"') + ->from('t') + ->filter([Query::isNotNull('email')]) + ->build(); + $this->assertStringContainsString('"email" IS NOT NULL', $result['query']); + } + + public function testSetWrapCharWithExists(): void + { + $result = (new Builder())->setWrapChar('"') + ->from('t') + ->filter([Query::exists(['a', 'b'])]) + ->build(); + $this->assertStringContainsString('"a" IS NOT NULL', $result['query']); + $this->assertStringContainsString('"b" IS NOT NULL', $result['query']); + } + + public function testSetWrapCharWithNotExists(): void + { + $result = (new Builder())->setWrapChar('"') + ->from('t') + ->filter([Query::notExists('c')]) + ->build(); + $this->assertStringContainsString('"c" IS NULL', $result['query']); + } + + public function testSetWrapCharCursorNotAffected(): void + { + $result = (new Builder())->setWrapChar('"') + ->from('t') + ->cursorAfter('abc') + ->build(); + // _cursor is hardcoded, not wrapped + $this->assertStringContainsString('_cursor > ?', $result['query']); + } + + public function testSetWrapCharWithToRawSql(): void + { + $sql = (new Builder())->setWrapChar('"') + ->from('t') + ->filter([Query::equal('name', ['test'])]) + ->limit(5) + ->toRawSql(); + $this->assertStringContainsString('"t"', $sql); + $this->assertStringContainsString('"name"', $sql); + $this->assertStringContainsString("'test'", $sql); + $this->assertStringContainsString('5', $sql); + } + + // ══════════════════════════════════════════ + // Reset Behavior + // ══════════════════════════════════════════ + + public function testResetFollowedByUnion(): void + { + $builder = (new Builder()) + ->from('a') + ->union((new Builder())->from('old')); + $builder->reset()->from('b'); + $result = $builder->build(); + $this->assertEquals('SELECT * FROM `b`', $result['query']); + $this->assertStringNotContainsString('UNION', $result['query']); + } + + public function testResetClearsBindingsAfterBuild(): void + { + $builder = (new Builder())->from('t')->filter([Query::equal('x', [1])]); + $builder->build(); + $this->assertNotEmpty($builder->getBindings()); + $builder->reset()->from('t'); + $result = $builder->build(); + $this->assertEquals([], $result['bindings']); + } + + // ══════════════════════════════════════════ + // Missing Binding Assertions + // ══════════════════════════════════════════ + + public function testSortAscBindingsEmpty(): void + { + $result = (new Builder())->from('t')->sortAsc('name')->build(); + $this->assertEquals([], $result['bindings']); + } + + public function testSortDescBindingsEmpty(): void + { + $result = (new Builder())->from('t')->sortDesc('name')->build(); + $this->assertEquals([], $result['bindings']); + } + + public function testSortRandomBindingsEmpty(): void + { + $result = (new Builder())->from('t')->sortRandom()->build(); + $this->assertEquals([], $result['bindings']); + } + + public function testDistinctBindingsEmpty(): void + { + $result = (new Builder())->from('t')->distinct()->build(); + $this->assertEquals([], $result['bindings']); + } + + public function testJoinBindingsEmpty(): void + { + $result = (new Builder())->from('t')->join('other', 'a', 'b')->build(); + $this->assertEquals([], $result['bindings']); + } + + public function testCrossJoinBindingsEmpty(): void + { + $result = (new Builder())->from('t')->crossJoin('other')->build(); + $this->assertEquals([], $result['bindings']); + } + + public function testGroupByBindingsEmpty(): void + { + $result = (new Builder())->from('t')->groupBy(['status'])->build(); + $this->assertEquals([], $result['bindings']); + } + + public function testCountWithAliasBindingsEmpty(): void + { + $result = (new Builder())->from('t')->count('*', 'total')->build(); + $this->assertEquals([], $result['bindings']); + } +} diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php deleted file mode 100644 index e286909..0000000 --- a/tests/Query/BuilderTest.php +++ /dev/null @@ -1,1060 +0,0 @@ -assertInstanceOf(Compiler::class, $builder); - } - - public function testStandaloneCompile(): void - { - $builder = new Builder(); - - $filter = Query::greaterThan('age', 18); - $sql = $filter->compile($builder); - $this->assertEquals('`age` > ?', $sql); - $this->assertEquals([18], $builder->getBindings()); - } - - // ── Fluent API ── - - public function testFluentSelectFromFilterSortLimitOffset(): void - { - $result = (new Builder()) - ->select(['name', 'email']) - ->from('users') - ->filter([ - Query::equal('status', ['active']), - Query::greaterThan('age', 18), - ]) - ->sortAsc('name') - ->limit(25) - ->offset(0) - ->build(); - - $this->assertEquals( - 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', - $result['query'] - ); - $this->assertEquals(['active', 18, 25, 0], $result['bindings']); - } - - // ── Batch mode ── - - public function testBatchModeProducesSameOutput(): void - { - $result = (new Builder()) - ->from('users') - ->queries([ - Query::select(['name', 'email']), - Query::equal('status', ['active']), - Query::greaterThan('age', 18), - Query::orderAsc('name'), - Query::limit(25), - Query::offset(0), - ]) - ->build(); - - $this->assertEquals( - 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', - $result['query'] - ); - $this->assertEquals(['active', 18, 25, 0], $result['bindings']); - } - - // ── Filter types ── - - public function testEqual(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::equal('status', ['active', 'pending'])]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?, ?)', $result['query']); - $this->assertEquals(['active', 'pending'], $result['bindings']); - } - - public function testNotEqualSingle(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::notEqual('role', 'guest')]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `role` != ?', $result['query']); - $this->assertEquals(['guest'], $result['bindings']); - } - - public function testNotEqualMultiple(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::notEqual('role', ['guest', 'banned'])]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result['query']); - $this->assertEquals(['guest', 'banned'], $result['bindings']); - } - - public function testLessThan(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::lessThan('price', 100)]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `price` < ?', $result['query']); - $this->assertEquals([100], $result['bindings']); - } - - public function testLessThanEqual(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::lessThanEqual('price', 100)]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `price` <= ?', $result['query']); - $this->assertEquals([100], $result['bindings']); - } - - public function testGreaterThan(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::greaterThan('age', 18)]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `age` > ?', $result['query']); - $this->assertEquals([18], $result['bindings']); - } - - public function testGreaterThanEqual(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::greaterThanEqual('score', 90)]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `score` >= ?', $result['query']); - $this->assertEquals([90], $result['bindings']); - } - - public function testBetween(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::between('age', 18, 65)]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result['query']); - $this->assertEquals([18, 65], $result['bindings']); - } - - public function testNotBetween(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::notBetween('age', 18, 65)]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `age` NOT BETWEEN ? AND ?', $result['query']); - $this->assertEquals([18, 65], $result['bindings']); - } - - public function testStartsWith(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::startsWith('name', 'Jo')]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result['query']); - $this->assertEquals(['Jo%'], $result['bindings']); - } - - public function testNotStartsWith(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::notStartsWith('name', 'Jo')]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `name` NOT LIKE ?', $result['query']); - $this->assertEquals(['Jo%'], $result['bindings']); - } - - public function testEndsWith(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::endsWith('email', '.com')]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `email` LIKE ?', $result['query']); - $this->assertEquals(['%.com'], $result['bindings']); - } - - public function testNotEndsWith(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::notEndsWith('email', '.com')]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `email` NOT LIKE ?', $result['query']); - $this->assertEquals(['%.com'], $result['bindings']); - } - - public function testContainsSingle(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::contains('bio', ['php'])]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result['query']); - $this->assertEquals(['%php%'], $result['bindings']); - } - - public function testContainsMultiple(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::contains('bio', ['php', 'js'])]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE (`bio` LIKE ? OR `bio` LIKE ?)', $result['query']); - $this->assertEquals(['%php%', '%js%'], $result['bindings']); - } - - public function testContainsAny(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::containsAny('tags', ['a', 'b'])]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `tags` IN (?, ?)', $result['query']); - $this->assertEquals(['a', 'b'], $result['bindings']); - } - - public function testContainsAll(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::containsAll('perms', ['read', 'write'])]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE (`perms` LIKE ? AND `perms` LIKE ?)', $result['query']); - $this->assertEquals(['%read%', '%write%'], $result['bindings']); - } - - public function testNotContainsSingle(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::notContains('bio', ['php'])]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result['query']); - $this->assertEquals(['%php%'], $result['bindings']); - } - - public function testNotContainsMultiple(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::notContains('bio', ['php', 'js'])]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE (`bio` NOT LIKE ? AND `bio` NOT LIKE ?)', $result['query']); - $this->assertEquals(['%php%', '%js%'], $result['bindings']); - } - - public function testSearch(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::search('content', 'hello')]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?)', $result['query']); - $this->assertEquals(['hello'], $result['bindings']); - } - - public function testNotSearch(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::notSearch('content', 'hello')]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE NOT MATCH(`content`) AGAINST(?)', $result['query']); - $this->assertEquals(['hello'], $result['bindings']); - } - - public function testRegex(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::regex('slug', '^[a-z]+$')]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result['query']); - $this->assertEquals(['^[a-z]+$'], $result['bindings']); - } - - public function testIsNull(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::isNull('deleted')]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `deleted` IS NULL', $result['query']); - $this->assertEquals([], $result['bindings']); - } - - public function testIsNotNull(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::isNotNull('verified')]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `verified` IS NOT NULL', $result['query']); - $this->assertEquals([], $result['bindings']); - } - - public function testExists(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::exists(['name', 'email'])]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE (`name` IS NOT NULL AND `email` IS NOT NULL)', $result['query']); - $this->assertEquals([], $result['bindings']); - } - - public function testNotExists(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::notExists(['legacy'])]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE (`legacy` IS NULL)', $result['query']); - $this->assertEquals([], $result['bindings']); - } - - // ── Logical / nested ── - - public function testAndLogical(): void - { - $result = (new Builder()) - ->from('t') - ->filter([ - Query::and([ - Query::greaterThan('age', 18), - Query::equal('status', ['active']), - ]), - ]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE (`age` > ? AND `status` IN (?))', $result['query']); - $this->assertEquals([18, 'active'], $result['bindings']); - } - - public function testOrLogical(): void - { - $result = (new Builder()) - ->from('t') - ->filter([ - Query::or([ - Query::equal('role', ['admin']), - Query::equal('role', ['mod']), - ]), - ]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE (`role` IN (?) OR `role` IN (?))', $result['query']); - $this->assertEquals(['admin', 'mod'], $result['bindings']); - } - - public function testDeeplyNested(): void - { - $result = (new Builder()) - ->from('t') - ->filter([ - Query::and([ - Query::greaterThan('age', 18), - Query::or([ - Query::equal('role', ['admin']), - Query::equal('role', ['mod']), - ]), - ]), - ]) - ->build(); - - $this->assertEquals( - 'SELECT * FROM `t` WHERE (`age` > ? AND (`role` IN (?) OR `role` IN (?)))', - $result['query'] - ); - $this->assertEquals([18, 'admin', 'mod'], $result['bindings']); - } - - // ── Sort ── - - public function testSortAsc(): void - { - $result = (new Builder()) - ->from('t') - ->sortAsc('name') - ->build(); - - $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC', $result['query']); - } - - public function testSortDesc(): void - { - $result = (new Builder()) - ->from('t') - ->sortDesc('score') - ->build(); - - $this->assertEquals('SELECT * FROM `t` ORDER BY `score` DESC', $result['query']); - } - - public function testSortRandom(): void - { - $result = (new Builder()) - ->from('t') - ->sortRandom() - ->build(); - - $this->assertEquals('SELECT * FROM `t` ORDER BY RAND()', $result['query']); - } - - public function testMultipleSorts(): void - { - $result = (new Builder()) - ->from('t') - ->sortAsc('name') - ->sortDesc('age') - ->build(); - - $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC, `age` DESC', $result['query']); - } - - // ── Pagination ── - - public function testLimitOnly(): void - { - $result = (new Builder()) - ->from('t') - ->limit(10) - ->build(); - - $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result['query']); - $this->assertEquals([10], $result['bindings']); - } - - public function testOffsetOnly(): void - { - $result = (new Builder()) - ->from('t') - ->offset(50) - ->build(); - - $this->assertEquals('SELECT * FROM `t` OFFSET ?', $result['query']); - $this->assertEquals([50], $result['bindings']); - } - - public function testCursorAfter(): void - { - $result = (new Builder()) - ->from('t') - ->cursorAfter('abc123') - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE _cursor > ?', $result['query']); - $this->assertEquals(['abc123'], $result['bindings']); - } - - public function testCursorBefore(): void - { - $result = (new Builder()) - ->from('t') - ->cursorBefore('xyz789') - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE _cursor < ?', $result['query']); - $this->assertEquals(['xyz789'], $result['bindings']); - } - - // ── Combined full query ── - - public function testFullCombinedQuery(): void - { - $result = (new Builder()) - ->select(['id', 'name']) - ->from('users') - ->filter([ - Query::equal('status', ['active']), - Query::greaterThan('age', 18), - ]) - ->sortAsc('name') - ->sortDesc('age') - ->limit(25) - ->offset(10) - ->build(); - - $this->assertEquals( - 'SELECT `id`, `name` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC, `age` DESC LIMIT ? OFFSET ?', - $result['query'] - ); - $this->assertEquals(['active', 18, 25, 10], $result['bindings']); - } - - // ── Multiple filter() calls (additive) ── - - public function testMultipleFilterCalls(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::equal('a', [1])]) - ->filter([Query::equal('b', [2])]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?)', $result['query']); - $this->assertEquals([1, 2], $result['bindings']); - } - - // ── Reset ── - - public function testResetClearsState(): void - { - $builder = (new Builder()) - ->select(['name']) - ->from('users') - ->filter([Query::equal('x', [1])]) - ->limit(10); - - $builder->build(); - - $builder->reset(); - - $result = $builder - ->from('orders') - ->filter([Query::greaterThan('total', 100)]) - ->build(); - - $this->assertEquals('SELECT * FROM `orders` WHERE `total` > ?', $result['query']); - $this->assertEquals([100], $result['bindings']); - } - - // ── Extension points ── - - public function testAttributeResolver(): void - { - $result = (new Builder()) - ->from('users') - ->setAttributeResolver(fn (string $a): string => match ($a) { - '$id' => '_uid', - '$createdAt' => '_createdAt', - default => $a, - }) - ->filter([Query::equal('$id', ['abc'])]) - ->sortAsc('$createdAt') - ->build(); - - $this->assertEquals( - 'SELECT * FROM `users` WHERE `_uid` IN (?) ORDER BY `_createdAt` ASC', - $result['query'] - ); - $this->assertEquals(['abc'], $result['bindings']); - } - - public function testWrapChar(): void - { - $result = (new Builder()) - ->from('users') - ->setWrapChar('"') - ->select(['name']) - ->filter([Query::equal('status', ['active'])]) - ->build(); - - $this->assertEquals( - 'SELECT "name" FROM "users" WHERE "status" IN (?)', - $result['query'] - ); - } - - public function testConditionProvider(): void - { - $result = (new Builder()) - ->from('users') - ->addConditionProvider(fn (string $table): array => [ - "_uid IN (SELECT _document FROM {$table}_perms WHERE _type = 'read')", - [], - ]) - ->filter([Query::equal('status', ['active'])]) - ->build(); - - $this->assertEquals( - "SELECT * FROM `users` WHERE `status` IN (?) AND _uid IN (SELECT _document FROM users_perms WHERE _type = 'read')", - $result['query'] - ); - $this->assertEquals(['active'], $result['bindings']); - } - - public function testConditionProviderWithBindings(): void - { - $result = (new Builder()) - ->from('docs') - ->addConditionProvider(fn (string $table): array => [ - '_tenant = ?', - ['tenant_abc'], - ]) - ->filter([Query::equal('status', ['active'])]) - ->build(); - - $this->assertEquals( - 'SELECT * FROM `docs` WHERE `status` IN (?) AND _tenant = ?', - $result['query'] - ); - // filter bindings first, then provider bindings - $this->assertEquals(['active', 'tenant_abc'], $result['bindings']); - } - - public function testBindingOrderingWithProviderAndCursor(): void - { - $result = (new Builder()) - ->from('docs') - ->addConditionProvider(fn (string $table): array => [ - '_tenant = ?', - ['t1'], - ]) - ->filter([Query::equal('status', ['active'])]) - ->cursorAfter('cursor_val') - ->limit(10) - ->offset(5) - ->build(); - - // binding order: filter, provider, cursor, limit, offset - $this->assertEquals(['active', 't1', 'cursor_val', 10, 5], $result['bindings']); - } - - // ── Select with no columns defaults to * ── - - public function testDefaultSelectStar(): void - { - $result = (new Builder()) - ->from('t') - ->build(); - - $this->assertEquals('SELECT * FROM `t`', $result['query']); - } - - // ── Aggregations ── - - public function testCountStar(): void - { - $result = (new Builder()) - ->from('t') - ->count() - ->build(); - - $this->assertEquals('SELECT COUNT(*) FROM `t`', $result['query']); - $this->assertEquals([], $result['bindings']); - } - - public function testCountWithAlias(): void - { - $result = (new Builder()) - ->from('t') - ->count('*', 'total') - ->build(); - - $this->assertEquals('SELECT COUNT(*) AS `total` FROM `t`', $result['query']); - } - - public function testSumColumn(): void - { - $result = (new Builder()) - ->from('orders') - ->sum('price', 'total_price') - ->build(); - - $this->assertEquals('SELECT SUM(`price`) AS `total_price` FROM `orders`', $result['query']); - } - - public function testAvgColumn(): void - { - $result = (new Builder()) - ->from('t') - ->avg('score') - ->build(); - - $this->assertEquals('SELECT AVG(`score`) FROM `t`', $result['query']); - } - - public function testMinColumn(): void - { - $result = (new Builder()) - ->from('t') - ->min('price') - ->build(); - - $this->assertEquals('SELECT MIN(`price`) FROM `t`', $result['query']); - } - - public function testMaxColumn(): void - { - $result = (new Builder()) - ->from('t') - ->max('price') - ->build(); - - $this->assertEquals('SELECT MAX(`price`) FROM `t`', $result['query']); - } - - public function testAggregationWithSelection(): void - { - $result = (new Builder()) - ->from('orders') - ->count('*', 'total') - ->select(['status']) - ->groupBy(['status']) - ->build(); - - $this->assertEquals( - 'SELECT COUNT(*) AS `total`, `status` FROM `orders` GROUP BY `status`', - $result['query'] - ); - } - - // ── Group By ── - - public function testGroupBy(): void - { - $result = (new Builder()) - ->from('orders') - ->count('*', 'total') - ->groupBy(['status']) - ->build(); - - $this->assertEquals( - 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`', - $result['query'] - ); - } - - public function testGroupByMultiple(): void - { - $result = (new Builder()) - ->from('orders') - ->count('*', 'total') - ->groupBy(['status', 'country']) - ->build(); - - $this->assertEquals( - 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`, `country`', - $result['query'] - ); - } - - // ── Having ── - - public function testHaving(): void - { - $result = (new Builder()) - ->from('orders') - ->count('*', 'total') - ->groupBy(['status']) - ->having([Query::greaterThan('total', 5)]) - ->build(); - - $this->assertEquals( - 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status` HAVING `total` > ?', - $result['query'] - ); - $this->assertEquals([5], $result['bindings']); - } - - // ── Distinct ── - - public function testDistinct(): void - { - $result = (new Builder()) - ->from('t') - ->distinct() - ->select(['status']) - ->build(); - - $this->assertEquals('SELECT DISTINCT `status` FROM `t`', $result['query']); - } - - public function testDistinctStar(): void - { - $result = (new Builder()) - ->from('t') - ->distinct() - ->build(); - - $this->assertEquals('SELECT DISTINCT * FROM `t`', $result['query']); - } - - // ── Joins ── - - public function testJoin(): void - { - $result = (new Builder()) - ->from('users') - ->join('orders', 'users.id', 'orders.user_id') - ->build(); - - $this->assertEquals( - 'SELECT * FROM `users` JOIN `orders` ON `users.id` = `orders.user_id`', - $result['query'] - ); - } - - public function testLeftJoin(): void - { - $result = (new Builder()) - ->from('users') - ->leftJoin('profiles', 'users.id', 'profiles.user_id') - ->build(); - - $this->assertEquals( - 'SELECT * FROM `users` LEFT JOIN `profiles` ON `users.id` = `profiles.user_id`', - $result['query'] - ); - } - - public function testRightJoin(): void - { - $result = (new Builder()) - ->from('users') - ->rightJoin('orders', 'users.id', 'orders.user_id') - ->build(); - - $this->assertEquals( - 'SELECT * FROM `users` RIGHT JOIN `orders` ON `users.id` = `orders.user_id`', - $result['query'] - ); - } - - public function testCrossJoin(): void - { - $result = (new Builder()) - ->from('sizes') - ->crossJoin('colors') - ->build(); - - $this->assertEquals( - 'SELECT * FROM `sizes` CROSS JOIN `colors`', - $result['query'] - ); - } - - public function testJoinWithFilter(): void - { - $result = (new Builder()) - ->from('users') - ->join('orders', 'users.id', 'orders.user_id') - ->filter([Query::greaterThan('orders.total', 100)]) - ->build(); - - $this->assertEquals( - 'SELECT * FROM `users` JOIN `orders` ON `users.id` = `orders.user_id` WHERE `orders.total` > ?', - $result['query'] - ); - $this->assertEquals([100], $result['bindings']); - } - - // ── Raw ── - - public function testRawFilter(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::raw('score > ? AND score < ?', [10, 100])]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE score > ? AND score < ?', $result['query']); - $this->assertEquals([10, 100], $result['bindings']); - } - - public function testRawFilterNoBindings(): void - { - $result = (new Builder()) - ->from('t') - ->filter([Query::raw('1 = 1')]) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result['query']); - $this->assertEquals([], $result['bindings']); - } - - // ── Union ── - - public function testUnion(): void - { - $admins = (new Builder())->from('admins')->filter([Query::equal('role', ['admin'])]); - $result = (new Builder()) - ->from('users') - ->filter([Query::equal('status', ['active'])]) - ->union($admins) - ->build(); - - $this->assertEquals( - 'SELECT * FROM `users` WHERE `status` IN (?) UNION SELECT * FROM `admins` WHERE `role` IN (?)', - $result['query'] - ); - $this->assertEquals(['active', 'admin'], $result['bindings']); - } - - public function testUnionAll(): void - { - $other = (new Builder())->from('archive'); - $result = (new Builder()) - ->from('current') - ->unionAll($other) - ->build(); - - $this->assertEquals( - 'SELECT * FROM `current` UNION ALL SELECT * FROM `archive`', - $result['query'] - ); - } - - // ── when() ── - - public function testWhenTrue(): void - { - $result = (new Builder()) - ->from('t') - ->when(true, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) - ->build(); - - $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?)', $result['query']); - $this->assertEquals(['active'], $result['bindings']); - } - - public function testWhenFalse(): void - { - $result = (new Builder()) - ->from('t') - ->when(false, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) - ->build(); - - $this->assertEquals('SELECT * FROM `t`', $result['query']); - $this->assertEquals([], $result['bindings']); - } - - // ── page() ── - - public function testPage(): void - { - $result = (new Builder()) - ->from('t') - ->page(3, 10) - ->build(); - - $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result['query']); - $this->assertEquals([10, 20], $result['bindings']); - } - - public function testPageDefaultPerPage(): void - { - $result = (new Builder()) - ->from('t') - ->page(1) - ->build(); - - $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result['query']); - $this->assertEquals([25, 0], $result['bindings']); - } - - // ── toRawSql() ── - - public function testToRawSql(): void - { - $sql = (new Builder()) - ->from('users') - ->filter([Query::equal('status', ['active'])]) - ->limit(10) - ->toRawSql(); - - $this->assertEquals( - "SELECT * FROM `users` WHERE `status` IN ('active') LIMIT 10", - $sql - ); - } - - public function testToRawSqlNumericBindings(): void - { - $sql = (new Builder()) - ->from('t') - ->filter([Query::greaterThan('age', 18)]) - ->toRawSql(); - - $this->assertEquals("SELECT * FROM `t` WHERE `age` > 18", $sql); - } - - // ── Combined complex query ── - - public function testCombinedAggregationJoinGroupByHaving(): void - { - $result = (new Builder()) - ->from('orders') - ->count('*', 'order_count') - ->sum('total', 'total_amount') - ->select(['users.name']) - ->join('users', 'orders.user_id', 'users.id') - ->groupBy(['users.name']) - ->having([Query::greaterThan('order_count', 5)]) - ->sortDesc('total_amount') - ->limit(10) - ->build(); - - $this->assertEquals( - 'SELECT COUNT(*) AS `order_count`, SUM(`total`) AS `total_amount`, `users.name` FROM `orders` JOIN `users` ON `orders.user_id` = `users.id` GROUP BY `users.name` HAVING `order_count` > ? ORDER BY `total_amount` DESC LIMIT ?', - $result['query'] - ); - $this->assertEquals([5, 10], $result['bindings']); - } - - // ── Reset clears unions ── - - public function testResetClearsUnions(): void - { - $other = (new Builder())->from('archive'); - $builder = (new Builder()) - ->from('current') - ->union($other); - - $builder->build(); - $builder->reset(); - - $result = $builder->from('fresh')->build(); - - $this->assertEquals('SELECT * FROM `fresh`', $result['query']); - } -} diff --git a/tests/Query/JoinQueryTest.php b/tests/Query/JoinQueryTest.php index 13197d8..c605f5c 100644 --- a/tests/Query/JoinQueryTest.php +++ b/tests/Query/JoinQueryTest.php @@ -52,4 +52,91 @@ public function testJoinTypesConstant(): void $this->assertContains(Query::TYPE_CROSS_JOIN, Query::JOIN_TYPES); $this->assertCount(4, Query::JOIN_TYPES); } + + // ── Edge cases ── + + public function testJoinWithEmptyTableName(): void + { + $query = Query::join('', 'left', 'right'); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals(['left', '=', 'right'], $query->getValues()); + } + + public function testJoinWithEmptyLeftColumn(): void + { + $query = Query::join('t', '', 'right'); + $this->assertEquals(['', '=', 'right'], $query->getValues()); + } + + public function testJoinWithEmptyRightColumn(): void + { + $query = Query::join('t', 'left', ''); + $this->assertEquals(['left', '=', ''], $query->getValues()); + } + + public function testJoinWithSpecialOperators(): void + { + $ops = ['!=', '<>', '<', '>', '<=', '>=']; + foreach ($ops as $op) { + $query = Query::join('t', 'a', 'b', $op); + $this->assertEquals(['a', $op, 'b'], $query->getValues()); + } + } + + public function testLeftJoinValues(): void + { + $query = Query::leftJoin('t', 'a.id', 'b.aid', '!='); + $this->assertEquals(['a.id', '!=', 'b.aid'], $query->getValues()); + } + + public function testRightJoinValues(): void + { + $query = Query::rightJoin('t', 'a.id', 'b.aid'); + $this->assertEquals(['a.id', '=', 'b.aid'], $query->getValues()); + } + + public function testCrossJoinEmptyTableName(): void + { + $query = Query::crossJoin(''); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testJoinCompileDispatch(): void + { + $builder = new \Utopia\Query\Builder\SQL(); + $query = Query::join('orders', 'users.id', 'orders.uid'); + $sql = $query->compile($builder); + $this->assertEquals('JOIN `orders` ON `users.id` = `orders.uid`', $sql); + } + + public function testLeftJoinCompileDispatch(): void + { + $builder = new \Utopia\Query\Builder\SQL(); + $query = Query::leftJoin('p', 'u.id', 'p.uid'); + $sql = $query->compile($builder); + $this->assertEquals('LEFT JOIN `p` ON `u.id` = `p.uid`', $sql); + } + + public function testRightJoinCompileDispatch(): void + { + $builder = new \Utopia\Query\Builder\SQL(); + $query = Query::rightJoin('o', 'u.id', 'o.uid'); + $sql = $query->compile($builder); + $this->assertEquals('RIGHT JOIN `o` ON `u.id` = `o.uid`', $sql); + } + + public function testCrossJoinCompileDispatch(): void + { + $builder = new \Utopia\Query\Builder\SQL(); + $query = Query::crossJoin('colors'); + $sql = $query->compile($builder); + $this->assertEquals('CROSS JOIN `colors`', $sql); + } + + public function testJoinIsNotNested(): void + { + $query = Query::join('t', 'a', 'b'); + $this->assertFalse($query->isNested()); + } } diff --git a/tests/Query/QueryHelperTest.php b/tests/Query/QueryHelperTest.php index ed09501..460aa0c 100644 --- a/tests/Query/QueryHelperTest.php +++ b/tests/Query/QueryHelperTest.php @@ -505,4 +505,387 @@ public function testPageStaticHelperFirstPage(): void $this->assertEquals(25, $result[0]->getValue()); $this->assertEquals(0, $result[1]->getValue()); } + + public function testPageStaticHelperZero(): void + { + $result = Query::page(0, 10); + $this->assertEquals(10, $result[0]->getValue()); + $this->assertEquals(-10, $result[1]->getValue()); + } + + public function testPageStaticHelperLarge(): void + { + $result = Query::page(500, 50); + $this->assertEquals(50, $result[0]->getValue()); + $this->assertEquals(24950, $result[1]->getValue()); + } + + // ══════════════════════════════════════════ + // ADDITIONAL EDGE CASES + // ══════════════════════════════════════════ + + // ── groupByType with all new types combined ── + + public function testGroupByTypeAllNewTypes(): void + { + $queries = [ + Query::equal('name', ['John']), + Query::count('*', 'total'), + Query::sum('price'), + Query::groupBy(['status']), + Query::having([Query::greaterThan('total', 5)]), + Query::distinct(), + Query::join('orders', 'u.id', 'o.uid'), + Query::union([Query::equal('x', [1])]), + Query::select(['name']), + Query::orderAsc('name'), + Query::limit(10), + Query::offset(5), + ]; + + $grouped = Query::groupByType($queries); + + $this->assertCount(1, $grouped['filters']); + $this->assertCount(1, $grouped['selections']); + $this->assertCount(2, $grouped['aggregations']); + $this->assertEquals(['status'], $grouped['groupBy']); + $this->assertCount(1, $grouped['having']); + $this->assertTrue($grouped['distinct']); + $this->assertCount(1, $grouped['joins']); + $this->assertCount(1, $grouped['unions']); + $this->assertEquals(10, $grouped['limit']); + $this->assertEquals(5, $grouped['offset']); + $this->assertEquals(['name'], $grouped['orderAttributes']); + } + + public function testGroupByTypeMultipleGroupByMerges(): void + { + $queries = [ + Query::groupBy(['a', 'b']), + Query::groupBy(['c']), + ]; + $grouped = Query::groupByType($queries); + $this->assertEquals(['a', 'b', 'c'], $grouped['groupBy']); + } + + public function testGroupByTypeMultipleDistinct(): void + { + $queries = [ + Query::distinct(), + Query::distinct(), + ]; + $grouped = Query::groupByType($queries); + $this->assertTrue($grouped['distinct']); + } + + public function testGroupByTypeMultipleHaving(): void + { + $queries = [ + Query::having([Query::greaterThan('x', 1)]), + Query::having([Query::lessThan('y', 100)]), + ]; + $grouped = Query::groupByType($queries); + $this->assertCount(2, $grouped['having']); + } + + public function testGroupByTypeRawGoesToFilters(): void + { + $queries = [Query::raw('1 = 1')]; + $grouped = Query::groupByType($queries); + $this->assertCount(1, $grouped['filters']); + $this->assertEquals(Query::TYPE_RAW, $grouped['filters'][0]->getMethod()); + } + + public function testGroupByTypeEmptyNewKeys(): void + { + $grouped = Query::groupByType([]); + $this->assertEquals([], $grouped['aggregations']); + $this->assertEquals([], $grouped['groupBy']); + $this->assertEquals([], $grouped['having']); + $this->assertFalse($grouped['distinct']); + $this->assertEquals([], $grouped['joins']); + $this->assertEquals([], $grouped['unions']); + } + + // ── merge() additional edge cases ── + + public function testMergeEmptyA(): void + { + $b = [Query::equal('x', [1])]; + $result = Query::merge([], $b); + $this->assertCount(1, $result); + } + + public function testMergeEmptyB(): void + { + $a = [Query::equal('x', [1])]; + $result = Query::merge($a, []); + $this->assertCount(1, $result); + } + + public function testMergeBothEmpty(): void + { + $result = Query::merge([], []); + $this->assertCount(0, $result); + } + + public function testMergePreservesNonSingularFromBoth(): void + { + $a = [Query::equal('a', [1]), Query::greaterThan('b', 2)]; + $b = [Query::lessThan('c', 3), Query::equal('d', [4])]; + $result = Query::merge($a, $b); + $this->assertCount(4, $result); + } + + public function testMergeBothLimitAndOffset(): void + { + $a = [Query::limit(10), Query::offset(5)]; + $b = [Query::limit(50), Query::offset(100)]; + $result = Query::merge($a, $b); + // Both should be overridden + $this->assertCount(2, $result); + $limits = array_filter($result, fn (Query $q) => $q->getMethod() === Query::TYPE_LIMIT); + $offsets = array_filter($result, fn (Query $q) => $q->getMethod() === Query::TYPE_OFFSET); + $this->assertEquals(50, array_values($limits)[0]->getValue()); + $this->assertEquals(100, array_values($offsets)[0]->getValue()); + } + + public function testMergeCursorTypesIndependent(): void + { + $a = [Query::cursorAfter('abc')]; + $b = [Query::cursorBefore('xyz')]; + $result = Query::merge($a, $b); + // cursorAfter and cursorBefore are different types, both should exist + $this->assertCount(2, $result); + } + + public function testMergeMixedWithFilters(): void + { + $a = [Query::equal('x', [1]), Query::limit(10), Query::offset(0)]; + $b = [Query::greaterThan('y', 5), Query::limit(50)]; + $result = Query::merge($a, $b); + // equal stays, old limit removed, offset stays, greaterThan added, new limit added + $this->assertCount(4, $result); + } + + // ── diff() additional edge cases ── + + public function testDiffEmptyA(): void + { + $result = Query::diff([], [Query::equal('x', [1])]); + $this->assertCount(0, $result); + } + + public function testDiffEmptyB(): void + { + $a = [Query::equal('x', [1]), Query::limit(10)]; + $result = Query::diff($a, []); + $this->assertCount(2, $result); + } + + public function testDiffBothEmpty(): void + { + $result = Query::diff([], []); + $this->assertCount(0, $result); + } + + public function testDiffPartialOverlap(): void + { + $shared1 = Query::equal('a', [1]); + $shared2 = Query::equal('b', [2]); + $unique = Query::greaterThan('c', 3); + + $a = [$shared1, $shared2, $unique]; + $b = [$shared1, $shared2]; + $result = Query::diff($a, $b); + $this->assertCount(1, $result); + $this->assertEquals('greaterThan', $result[0]->getMethod()); + } + + public function testDiffByValueNotReference(): void + { + $a = [Query::equal('x', [1])]; + $b = [Query::equal('x', [1])]; // Different objects, same content + $result = Query::diff($a, $b); + $this->assertCount(0, $result); // Should match by value + } + + public function testDiffDoesNotRemoveDuplicatesInA(): void + { + $a = [Query::equal('x', [1]), Query::equal('x', [1])]; + $b = []; + $result = Query::diff($a, $b); + $this->assertCount(2, $result); + } + + public function testDiffComplexNested(): void + { + $nested = Query::or([Query::equal('a', [1]), Query::equal('b', [2])]); + $a = [$nested, Query::limit(10)]; + $b = [$nested]; + $result = Query::diff($a, $b); + $this->assertCount(1, $result); + $this->assertEquals('limit', $result[0]->getMethod()); + } + + // ── validate() additional edge cases ── + + public function testValidateEmptyQueries(): void + { + $errors = Query::validate([], ['name', 'age']); + $this->assertCount(0, $errors); + } + + public function testValidateEmptyAllowedAttributes(): void + { + $queries = [Query::equal('name', ['John'])]; + $errors = Query::validate($queries, []); + $this->assertCount(1, $errors); + } + + public function testValidateMixedValidAndInvalid(): void + { + $queries = [ + Query::equal('name', ['John']), + Query::greaterThan('age', 18), + Query::equal('secret', ['x']), + Query::lessThan('forbidden', 5), + ]; + $errors = Query::validate($queries, ['name', 'age']); + $this->assertCount(2, $errors); + } + + public function testValidateNestedMultipleLevels(): void + { + $queries = [ + Query::or([ + Query::and([ + Query::equal('name', ['John']), + Query::equal('bad', ['x']), + ]), + Query::equal('also_bad', ['y']), + ]), + ]; + $errors = Query::validate($queries, ['name']); + $this->assertCount(2, $errors); + } + + public function testValidateHavingInnerQueries(): void + { + $queries = [ + Query::having([ + Query::greaterThan('total', 5), + Query::lessThan('bad_col', 100), + ]), + ]; + $errors = Query::validate($queries, ['total']); + $this->assertCount(1, $errors); + $this->assertStringContainsString('bad_col', $errors[0]); + } + + public function testValidateGroupByAllValid(): void + { + $queries = [Query::groupBy(['status', 'country'])]; + $errors = Query::validate($queries, ['status', 'country']); + $this->assertCount(0, $errors); + } + + public function testValidateGroupByMultipleInvalid(): void + { + $queries = [Query::groupBy(['status', 'bad1', 'bad2'])]; + $errors = Query::validate($queries, ['status']); + $this->assertCount(2, $errors); + } + + public function testValidateAggregateWithAttribute(): void + { + $queries = [Query::sum('forbidden_col')]; + $errors = Query::validate($queries, ['allowed_col']); + $this->assertCount(1, $errors); + $this->assertStringContainsString('forbidden_col', $errors[0]); + } + + public function testValidateAggregateWithAllowedAttribute(): void + { + $queries = [Query::sum('price')]; + $errors = Query::validate($queries, ['price']); + $this->assertCount(0, $errors); + } + + public function testValidateDollarSignAttributes(): void + { + $queries = [ + Query::equal('$id', ['abc']), + Query::greaterThan('$createdAt', '2024-01-01'), + ]; + $errors = Query::validate($queries, ['$id', '$createdAt']); + $this->assertCount(0, $errors); + } + + public function testValidateJoinAttributeIsTableName(): void + { + // Join's attribute is the table name, not a column, so it gets validated + $queries = [Query::join('orders', 'u.id', 'o.uid')]; + $errors = Query::validate($queries, ['name']); + $this->assertCount(1, $errors); + $this->assertStringContainsString('orders', $errors[0]); + } + + public function testValidateSelectSkipped(): void + { + $queries = [Query::select(['any_col', 'other_col'])]; + $errors = Query::validate($queries, []); + $this->assertCount(0, $errors); + } + + public function testValidateExistsSkipped(): void + { + $queries = [Query::exists(['any_col'])]; + $errors = Query::validate($queries, []); + $this->assertCount(0, $errors); + } + + public function testValidateOrderAscAttribute(): void + { + $queries = [Query::orderAsc('forbidden')]; + $errors = Query::validate($queries, ['name']); + $this->assertCount(1, $errors); + } + + public function testValidateOrderDescAttribute(): void + { + $queries = [Query::orderDesc('allowed')]; + $errors = Query::validate($queries, ['allowed']); + $this->assertCount(0, $errors); + } + + public function testValidateEmptyAttributeSkipped(): void + { + // Queries with empty string attribute should be skipped + $queries = [Query::orderAsc('')]; + $errors = Query::validate($queries, []); + $this->assertCount(0, $errors); + } + + // ── getByType additional ── + + public function testGetByTypeWithNewTypes(): void + { + $queries = [ + Query::count('*', 'total'), + Query::sum('price'), + Query::join('t', 'a', 'b'), + Query::distinct(), + Query::groupBy(['status']), + ]; + + $aggs = Query::getByType($queries, Query::AGGREGATE_TYPES); + $this->assertCount(2, $aggs); + + $joins = Query::getByType($queries, Query::JOIN_TYPES); + $this->assertCount(1, $joins); + + $distinct = Query::getByType($queries, [Query::TYPE_DISTINCT]); + $this->assertCount(1, $distinct); + } } diff --git a/tests/Query/QueryParseTest.php b/tests/Query/QueryParseTest.php index 0a66b41..c6d2b34 100644 --- a/tests/Query/QueryParseTest.php +++ b/tests/Query/QueryParseTest.php @@ -274,4 +274,323 @@ public function testRoundTripUnion(): void $this->assertCount(1, $parsed->getValues()); $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); } + + // ══════════════════════════════════════════ + // ADDITIONAL EDGE CASES + // ══════════════════════════════════════════ + + // ── Round-trip additional ── + + public function testRoundTripAvg(): void + { + $original = Query::avg('score', 'avg_score'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('avg', $parsed->getMethod()); + $this->assertEquals('score', $parsed->getAttribute()); + $this->assertEquals(['avg_score'], $parsed->getValues()); + } + + public function testRoundTripMin(): void + { + $original = Query::min('price'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('min', $parsed->getMethod()); + $this->assertEquals('price', $parsed->getAttribute()); + $this->assertEquals([], $parsed->getValues()); + } + + public function testRoundTripMax(): void + { + $original = Query::max('age', 'oldest'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('max', $parsed->getMethod()); + $this->assertEquals(['oldest'], $parsed->getValues()); + } + + public function testRoundTripCountWithoutAlias(): void + { + $original = Query::count('id'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('count', $parsed->getMethod()); + $this->assertEquals('id', $parsed->getAttribute()); + $this->assertEquals([], $parsed->getValues()); + } + + public function testRoundTripGroupByEmpty(): void + { + $original = Query::groupBy([]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('groupBy', $parsed->getMethod()); + $this->assertEquals([], $parsed->getValues()); + } + + public function testRoundTripHavingMultiple(): void + { + $original = Query::having([ + Query::greaterThan('total', 5), + Query::lessThan('total', 100), + ]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertCount(2, $parsed->getValues()); + $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); + $this->assertInstanceOf(Query::class, $parsed->getValues()[1]); + } + + public function testRoundTripLeftJoin(): void + { + $original = Query::leftJoin('profiles', 'u.id', 'p.uid'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('leftJoin', $parsed->getMethod()); + $this->assertEquals('profiles', $parsed->getAttribute()); + $this->assertEquals(['u.id', '=', 'p.uid'], $parsed->getValues()); + } + + public function testRoundTripRightJoin(): void + { + $original = Query::rightJoin('orders', 'u.id', 'o.uid'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('rightJoin', $parsed->getMethod()); + } + + public function testRoundTripJoinWithSpecialOperator(): void + { + $original = Query::join('t', 'a.val', 'b.val', '!='); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals(['a.val', '!=', 'b.val'], $parsed->getValues()); + } + + public function testRoundTripUnionAll(): void + { + $original = Query::unionAll([Query::equal('y', [2])]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('unionAll', $parsed->getMethod()); + $this->assertCount(1, $parsed->getValues()); + $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); + } + + public function testRoundTripRawNoBindings(): void + { + $original = Query::raw('1 = 1'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('raw', $parsed->getMethod()); + $this->assertEquals('1 = 1', $parsed->getAttribute()); + $this->assertEquals([], $parsed->getValues()); + } + + public function testRoundTripRawWithMultipleBindings(): void + { + $original = Query::raw('a > ? AND b < ?', [10, 20]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals([10, 20], $parsed->getValues()); + } + + public function testRoundTripComplexNested(): void + { + $original = Query::or([ + Query::and([ + Query::equal('a', [1]), + Query::or([ + Query::equal('b', [2]), + Query::equal('c', [3]), + ]), + ]), + ]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals('or', $parsed->getMethod()); + $this->assertCount(1, $parsed->getValues()); + + /** @var Query $inner */ + $inner = $parsed->getValues()[0]; + $this->assertEquals('and', $inner->getMethod()); + $this->assertCount(2, $inner->getValues()); + } + + // ── Parse edge cases ── + + public function testParseEmptyStringThrows(): void + { + $this->expectException(Exception::class); + Query::parse(''); + } + + public function testParseWhitespaceThrows(): void + { + $this->expectException(Exception::class); + Query::parse(' '); + } + + public function testParseMissingMethodUsesEmptyString(): void + { + // method defaults to '' which is not a valid method + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid query method: '); + Query::parse('{"attribute":"x","values":[]}'); + } + + public function testParseMissingAttributeDefaultsToEmpty(): void + { + $query = Query::parse('{"method":"isNull","values":[]}'); + $this->assertEquals('', $query->getAttribute()); + } + + public function testParseMissingValuesDefaultsToEmpty(): void + { + $query = Query::parse('{"method":"isNull"}'); + $this->assertEquals([], $query->getValues()); + } + + public function testParseExtraFieldsIgnored(): void + { + $query = Query::parse('{"method":"equal","attribute":"x","values":[1],"extra":"ignored"}'); + $this->assertEquals('equal', $query->getMethod()); + $this->assertEquals('x', $query->getAttribute()); + } + + public function testParseNonObjectJsonThrows(): void + { + $this->expectException(Exception::class); + Query::parse('"just a string"'); + } + + public function testParseJsonArrayThrows(): void + { + $this->expectException(Exception::class); + Query::parse('[1,2,3]'); + } + + // ── toArray edge cases ── + + public function testToArrayCountWithAlias(): void + { + $query = Query::count('id', 'total'); + $array = $query->toArray(); + $this->assertEquals('count', $array['method']); + $this->assertEquals('id', $array['attribute']); + $this->assertEquals(['total'], $array['values']); + } + + public function testToArrayCountWithoutAlias(): void + { + $query = Query::count(); + $array = $query->toArray(); + $this->assertEquals('count', $array['method']); + $this->assertEquals('*', $array['attribute']); + $this->assertEquals([], $array['values']); + } + + public function testToArrayDistinct(): void + { + $query = Query::distinct(); + $array = $query->toArray(); + $this->assertEquals('distinct', $array['method']); + $this->assertArrayNotHasKey('attribute', $array); + $this->assertEquals([], $array['values']); + } + + public function testToArrayJoinPreservesOperator(): void + { + $query = Query::join('t', 'a', 'b', '!='); + $array = $query->toArray(); + $this->assertEquals(['a', '!=', 'b'], $array['values']); + } + + public function testToArrayCrossJoin(): void + { + $query = Query::crossJoin('t'); + $array = $query->toArray(); + $this->assertEquals('crossJoin', $array['method']); + $this->assertEquals('t', $array['attribute']); + $this->assertEquals([], $array['values']); + } + + public function testToArrayHaving(): void + { + $query = Query::having([Query::greaterThan('x', 1), Query::lessThan('y', 10)]); + $array = $query->toArray(); + $this->assertEquals('having', $array['method']); + + /** @var array> $values */ + $values = $array['values'] ?? []; + $this->assertCount(2, $values); + $this->assertEquals('greaterThan', $values[0]['method']); + } + + public function testToArrayUnionAll(): void + { + $query = Query::unionAll([Query::equal('x', [1])]); + $array = $query->toArray(); + $this->assertEquals('unionAll', $array['method']); + + /** @var array> $values */ + $values = $array['values'] ?? []; + $this->assertCount(1, $values); + } + + public function testToArrayRaw(): void + { + $query = Query::raw('a > ?', [10]); + $array = $query->toArray(); + $this->assertEquals('raw', $array['method']); + $this->assertEquals('a > ?', $array['attribute']); + $this->assertEquals([10], $array['values']); + } + + // ── parseQueries edge cases ── + + public function testParseQueriesEmpty(): void + { + $result = Query::parseQueries([]); + $this->assertCount(0, $result); + } + + public function testParseQueriesWithNewTypes(): void + { + $queries = Query::parseQueries([ + '{"method":"count","attribute":"*","values":["total"]}', + '{"method":"groupBy","values":["status","country"]}', + '{"method":"distinct","values":[]}', + '{"method":"join","attribute":"orders","values":["u.id","=","o.uid"]}', + ]); + $this->assertCount(4, $queries); + $this->assertEquals('count', $queries[0]->getMethod()); + $this->assertEquals('groupBy', $queries[1]->getMethod()); + $this->assertEquals('distinct', $queries[2]->getMethod()); + $this->assertEquals('join', $queries[3]->getMethod()); + } + + // ── toString edge cases ── + + public function testToStringGroupByProducesValidJson(): void + { + $query = Query::groupBy(['a', 'b']); + $json = $query->toString(); + $decoded = json_decode($json, true); + $this->assertIsArray($decoded); + $this->assertEquals('groupBy', $decoded['method']); + $this->assertEquals(['a', 'b'], $decoded['values']); + } + + public function testToStringRawProducesValidJson(): void + { + $query = Query::raw('x > ? AND y < ?', [1, 2]); + $json = $query->toString(); + $decoded = json_decode($json, true); + $this->assertIsArray($decoded); + $this->assertEquals('raw', $decoded['method']); + $this->assertEquals('x > ? AND y < ?', $decoded['attribute']); + $this->assertEquals([1, 2], $decoded['values']); + } } diff --git a/tests/Query/QueryTest.php b/tests/Query/QueryTest.php index a9fd425..adb01af 100644 --- a/tests/Query/QueryTest.php +++ b/tests/Query/QueryTest.php @@ -207,4 +207,223 @@ public function testUnionAllFactory(): void $query = Query::unionAll($inner); $this->assertEquals(Query::TYPE_UNION_ALL, $query->getMethod()); } + + // ══════════════════════════════════════════ + // ADDITIONAL EDGE CASES + // ══════════════════════════════════════════ + + public function testTypesNoDuplicates(): void + { + $this->assertEquals(count(Query::TYPES), count(array_unique(Query::TYPES))); + } + + public function testAggregateTypesNoDuplicates(): void + { + $this->assertEquals(count(Query::AGGREGATE_TYPES), count(array_unique(Query::AGGREGATE_TYPES))); + } + + public function testJoinTypesNoDuplicates(): void + { + $this->assertEquals(count(Query::JOIN_TYPES), count(array_unique(Query::JOIN_TYPES))); + } + + public function testAggregateTypesSubsetOfTypes(): void + { + foreach (Query::AGGREGATE_TYPES as $type) { + $this->assertContains($type, Query::TYPES); + } + } + + public function testJoinTypesSubsetOfTypes(): void + { + foreach (Query::JOIN_TYPES as $type) { + $this->assertContains($type, Query::TYPES); + } + } + + public function testIsMethodCaseSensitive(): void + { + $this->assertFalse(Query::isMethod('COUNT')); + $this->assertFalse(Query::isMethod('Sum')); + $this->assertFalse(Query::isMethod('JOIN')); + $this->assertFalse(Query::isMethod('DISTINCT')); + $this->assertFalse(Query::isMethod('GroupBy')); + $this->assertFalse(Query::isMethod('RAW')); + } + + public function testRawFactoryEmptySql(): void + { + $query = Query::raw(''); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testRawFactoryEmptyBindings(): void + { + $query = Query::raw('1 = 1', []); + $this->assertEquals([], $query->getValues()); + } + + public function testRawFactoryMixedBindings(): void + { + $query = Query::raw('a = ? AND b = ? AND c = ?', ['str', 42, 3.14]); + $this->assertEquals(['str', 42, 3.14], $query->getValues()); + } + + public function testUnionIsNested(): void + { + $query = Query::union([Query::equal('x', [1])]); + $this->assertTrue($query->isNested()); + } + + public function testUnionAllIsNested(): void + { + $query = Query::unionAll([Query::equal('x', [1])]); + $this->assertTrue($query->isNested()); + } + + public function testDistinctNotNested(): void + { + $this->assertFalse(Query::distinct()->isNested()); + } + + public function testCountNotNested(): void + { + $this->assertFalse(Query::count()->isNested()); + } + + public function testGroupByNotNested(): void + { + $this->assertFalse(Query::groupBy(['a'])->isNested()); + } + + public function testJoinNotNested(): void + { + $this->assertFalse(Query::join('t', 'a', 'b')->isNested()); + } + + public function testRawNotNested(): void + { + $this->assertFalse(Query::raw('1=1')->isNested()); + } + + public function testHavingNested(): void + { + $this->assertTrue(Query::having([Query::equal('x', [1])])->isNested()); + } + + public function testCloneDeepCopiesHavingQueries(): void + { + $inner = Query::greaterThan('total', 5); + $outer = Query::having([$inner]); + $cloned = clone $outer; + + $clonedValues = $cloned->getValues(); + $this->assertNotSame($inner, $clonedValues[0]); + $this->assertInstanceOf(Query::class, $clonedValues[0]); + + /** @var Query $clonedInner */ + $clonedInner = $clonedValues[0]; + $this->assertEquals('greaterThan', $clonedInner->getMethod()); + } + + public function testCloneDeepCopiesUnionQueries(): void + { + $inner = Query::equal('x', [1]); + $outer = Query::union([$inner]); + $cloned = clone $outer; + + $clonedValues = $cloned->getValues(); + $this->assertNotSame($inner, $clonedValues[0]); + } + + public function testCountConstantValue(): void + { + $this->assertEquals('count', Query::TYPE_COUNT); + } + + public function testSumConstantValue(): void + { + $this->assertEquals('sum', Query::TYPE_SUM); + } + + public function testAvgConstantValue(): void + { + $this->assertEquals('avg', Query::TYPE_AVG); + } + + public function testMinConstantValue(): void + { + $this->assertEquals('min', Query::TYPE_MIN); + } + + public function testMaxConstantValue(): void + { + $this->assertEquals('max', Query::TYPE_MAX); + } + + public function testGroupByConstantValue(): void + { + $this->assertEquals('groupBy', Query::TYPE_GROUP_BY); + } + + public function testHavingConstantValue(): void + { + $this->assertEquals('having', Query::TYPE_HAVING); + } + + public function testDistinctConstantValue(): void + { + $this->assertEquals('distinct', Query::TYPE_DISTINCT); + } + + public function testJoinConstantValue(): void + { + $this->assertEquals('join', Query::TYPE_JOIN); + } + + public function testLeftJoinConstantValue(): void + { + $this->assertEquals('leftJoin', Query::TYPE_LEFT_JOIN); + } + + public function testRightJoinConstantValue(): void + { + $this->assertEquals('rightJoin', Query::TYPE_RIGHT_JOIN); + } + + public function testCrossJoinConstantValue(): void + { + $this->assertEquals('crossJoin', Query::TYPE_CROSS_JOIN); + } + + public function testUnionConstantValue(): void + { + $this->assertEquals('union', Query::TYPE_UNION); + } + + public function testUnionAllConstantValue(): void + { + $this->assertEquals('unionAll', Query::TYPE_UNION_ALL); + } + + public function testRawConstantValue(): void + { + $this->assertEquals('raw', Query::TYPE_RAW); + } + + public function testCountIsSpatialQueryFalse(): void + { + $this->assertFalse(Query::count()->isSpatialQuery()); + } + + public function testJoinIsSpatialQueryFalse(): void + { + $this->assertFalse(Query::join('t', 'a', 'b')->isSpatialQuery()); + } + + public function testDistinctIsSpatialQueryFalse(): void + { + $this->assertFalse(Query::distinct()->isSpatialQuery()); + } } From 96ae766a4e705184b3944f6f83facbaabf6bb40e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 5 Mar 2026 10:34:07 +1300 Subject: [PATCH 09/29] fix: address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix dotted identifier wrapping (users.id → `users`.`id`) - Escape wrap character in identifiers to prevent SQL injection - Wrap _cursor column in identifier quotes - Return 1=1 for empty raw SQL to prevent invalid WHERE clauses - Treat COUNT('') as COUNT(*) - Only emit OFFSET when LIMIT is present - Escape LIKE metacharacters (% and _) in user input - Validate JOIN operator against allowlist --- src/Query/Builder.php | 41 +++++--- src/Query/Builder/ClickHouse.php | 12 ++- src/Query/Builder/SQL.php | 12 ++- tests/Query/Builder/ClickHouseTest.php | 50 +++++----- tests/Query/Builder/SQLTest.php | 129 +++++++++++++------------ tests/Query/JoinQueryTest.php | 6 +- 6 files changed, 148 insertions(+), 102 deletions(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index a55fa43..b1cecb6 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -466,8 +466,8 @@ public function build(): array $this->addBinding($grouped['limit']); } - // OFFSET - if ($grouped['offset'] !== null) { + // OFFSET (only emit if LIMIT is also present) + if ($grouped['offset'] !== null && $grouped['limit'] !== null) { $parts[] = 'OFFSET ?'; $this->addBinding($grouped['offset']); } @@ -589,14 +589,14 @@ public function compileCursor(Query $query): string $operator = $query->getMethod() === Query::TYPE_CURSOR_AFTER ? '>' : '<'; - return '_cursor ' . $operator . ' ?'; + return $this->wrapIdentifier('_cursor') . ' ' . $operator . ' ?'; } public function compileAggregate(Query $query): string { $func = \strtoupper($query->getMethod()); $attr = $query->getAttribute(); - $col = $attr === '*' ? '*' : $this->resolveAndWrap($attr); + $col = ($attr === '*' || $attr === '') ? '*' : $this->resolveAndWrap($attr); /** @var string $alias */ $alias = $query->getValue(''); $sql = $func . '(' . $col . ')'; @@ -644,6 +644,11 @@ public function compileJoin(Query $query): string /** @var string $rightCol */ $rightCol = $values[2]; + $allowedOperators = ['=', '!=', '<', '>', '<=', '>=', '<>']; + if (!\in_array($operator, $allowedOperators, true)) { + throw new \InvalidArgumentException('Invalid join operator: ' . $operator); + } + $left = $this->resolveAndWrap($leftCol); $right = $this->resolveAndWrap($rightCol); @@ -785,7 +790,7 @@ private function compileBetween(string $attribute, array $values, bool $not): st private function compileLike(string $attribute, array $values, string $prefix, string $suffix, bool $not): string { /** @var string $val */ - $val = $values[0]; + $val = $this->escapeLikeValue($values[0]); $this->addBinding($prefix . $val . $suffix); $keyword = $not ? 'NOT LIKE' : 'LIKE'; @@ -799,14 +804,14 @@ private function compileContains(string $attribute, array $values): string { /** @var array $values */ if (\count($values) === 1) { - $this->addBinding('%' . $values[0] . '%'); + $this->addBinding('%' . $this->escapeLikeValue($values[0]) . '%'); return $attribute . ' LIKE ?'; } $parts = []; foreach ($values as $value) { - $this->addBinding('%' . $value . '%'); + $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); $parts[] = $attribute . ' LIKE ?'; } @@ -821,7 +826,7 @@ private function compileContainsAll(string $attribute, array $values): string /** @var array $values */ $parts = []; foreach ($values as $value) { - $this->addBinding('%' . $value . '%'); + $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); $parts[] = $attribute . ' LIKE ?'; } @@ -835,20 +840,28 @@ private function compileNotContains(string $attribute, array $values): string { /** @var array $values */ if (\count($values) === 1) { - $this->addBinding('%' . $values[0] . '%'); + $this->addBinding('%' . $this->escapeLikeValue($values[0]) . '%'); return $attribute . ' NOT LIKE ?'; } $parts = []; foreach ($values as $value) { - $this->addBinding('%' . $value . '%'); + $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); $parts[] = $attribute . ' NOT LIKE ?'; } return '(' . \implode(' AND ', $parts) . ')'; } + /** + * Escape LIKE metacharacters in user input before wrapping with wildcards. + */ + private function escapeLikeValue(string $value): string + { + return \str_replace(['%', '_'], ['\\%', '\\_'], $value); + } + private function compileLogical(Query $query, string $operator): string { $parts = []; @@ -896,10 +909,16 @@ private function compileNotExists(Query $query): string private function compileRaw(Query $query): string { + $attribute = $query->getAttribute(); + + if ($attribute === '') { + return '1 = 1'; + } + foreach ($query->getValues() as $binding) { $this->addBinding($binding); } - return $query->getAttribute(); + return $attribute; } } diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 1927e8d..525b59e 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -67,7 +67,17 @@ public function reset(): static protected function wrapIdentifier(string $identifier): string { - return '`' . $identifier . '`'; + $segments = \explode('.', $identifier); + $wrapped = \array_map(function (string $segment): string { + if ($segment === '*') { + return '*'; + } + $escaped = \str_replace('`', '``', $segment); + + return '`' . $escaped . '`'; + }, $segments); + + return \implode('.', $wrapped); } protected function compileRandom(): string diff --git a/src/Query/Builder/SQL.php b/src/Query/Builder/SQL.php index 34eb6c0..0cb02d7 100644 --- a/src/Query/Builder/SQL.php +++ b/src/Query/Builder/SQL.php @@ -17,7 +17,17 @@ public function setWrapChar(string $char): static protected function wrapIdentifier(string $identifier): string { - return $this->wrapChar . $identifier . $this->wrapChar; + $segments = \explode('.', $identifier); + $wrapped = \array_map(function (string $segment): string { + if ($segment === '*') { + return '*'; + } + $escaped = \str_replace($this->wrapChar, $this->wrapChar . $this->wrapChar, $segment); + + return $this->wrapChar . $escaped . $this->wrapChar; + }, $segments); + + return \implode('.', $wrapped); } protected function compileRandom(): string diff --git a/tests/Query/Builder/ClickHouseTest.php b/tests/Query/Builder/ClickHouseTest.php index c0f43f9..362fdc7 100644 --- a/tests/Query/Builder/ClickHouseTest.php +++ b/tests/Query/Builder/ClickHouseTest.php @@ -207,7 +207,7 @@ public function testPrewhereWithJoinAndWhere(): void ->build(); $this->assertEquals( - 'SELECT * FROM `events` JOIN `users` ON `events.user_id` = `users.id` PREWHERE `event_type` IN (?) WHERE `users.age` > ?', + 'SELECT * FROM `events` JOIN `users` ON `events`.`user_id` = `users`.`id` PREWHERE `event_type` IN (?) WHERE `users`.`age` > ?', $result['query'] ); $this->assertEquals(['click', 18], $result['bindings']); @@ -264,7 +264,7 @@ public function testJoin(): void ->build(); $this->assertEquals( - 'SELECT * FROM `events` JOIN `users` ON `events.user_id` = `users.id` LEFT JOIN `sessions` ON `events.session_id` = `sessions.id`', + 'SELECT * FROM `events` JOIN `users` ON `events`.`user_id` = `users`.`id` LEFT JOIN `sessions` ON `events`.`session_id` = `sessions`.`id`', $result['query'] ); } @@ -435,8 +435,8 @@ public function testCombinedPrewhereWhereJoinGroupBy(): void $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.1', $query); $this->assertStringContainsString('JOIN `users`', $query); $this->assertStringContainsString('PREWHERE `event_type` IN (?)', $query); - $this->assertStringContainsString('WHERE `events.amount` > ?', $query); - $this->assertStringContainsString('GROUP BY `users.country`', $query); + $this->assertStringContainsString('WHERE `events`.`amount` > ?', $query); + $this->assertStringContainsString('GROUP BY `users`.`country`', $query); $this->assertStringContainsString('HAVING `total` > ?', $query); $this->assertStringContainsString('ORDER BY `total` DESC', $query); $this->assertStringContainsString('LIMIT ?', $query); @@ -1179,7 +1179,7 @@ public function testFinalWithCursor(): void ->build(); $this->assertStringContainsString('FROM `events` FINAL', $result['query']); - $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result['query']); } public function testFinalWithUnion(): void @@ -1471,7 +1471,7 @@ public function testSampleWithCursor(): void ->build(); $this->assertStringContainsString('SAMPLE 0.5', $result['query']); - $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result['query']); } public function testSampleWithUnion(): void @@ -2582,7 +2582,7 @@ public function testJoinWithFinalFeature(): void ->build(); $this->assertEquals( - 'SELECT * FROM `events` FINAL JOIN `users` ON `events.uid` = `users.id`', + 'SELECT * FROM `events` FINAL JOIN `users` ON `events`.`uid` = `users`.`id`', $result['query'] ); } @@ -2596,7 +2596,7 @@ public function testJoinWithSampleFeature(): void ->build(); $this->assertEquals( - 'SELECT * FROM `events` SAMPLE 0.5 JOIN `users` ON `events.uid` = `users.id`', + 'SELECT * FROM `events` SAMPLE 0.5 JOIN `users` ON `events`.`uid` = `users`.`id`', $result['query'] ); } @@ -3601,7 +3601,7 @@ public function testCursorAfterWithPrewhere(): void ->build(); $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result['query']); } public function testCursorBeforeWithPrewhere(): void @@ -3614,7 +3614,7 @@ public function testCursorBeforeWithPrewhere(): void ->build(); $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('_cursor < ?', $result['query']); + $this->assertStringContainsString('`_cursor` < ?', $result['query']); } public function testCursorPrewhereWhere(): void @@ -3629,7 +3629,7 @@ public function testCursorPrewhereWhere(): void $this->assertStringContainsString('PREWHERE', $result['query']); $this->assertStringContainsString('WHERE', $result['query']); - $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result['query']); } public function testCursorWithFinal(): void @@ -3642,7 +3642,7 @@ public function testCursorWithFinal(): void ->build(); $this->assertStringContainsString('FINAL', $result['query']); - $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result['query']); } public function testCursorWithSample(): void @@ -3655,7 +3655,7 @@ public function testCursorWithSample(): void ->build(); $this->assertStringContainsString('SAMPLE 0.5', $result['query']); - $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result['query']); } public function testCursorPrewhereBindingOrder(): void @@ -3703,7 +3703,7 @@ public function testCursorFullClickHousePipeline(): void $this->assertStringContainsString('FINAL SAMPLE 0.1', $query); $this->assertStringContainsString('PREWHERE', $query); $this->assertStringContainsString('WHERE', $query); - $this->assertStringContainsString('_cursor > ?', $query); + $this->assertStringContainsString('`_cursor` > ?', $query); $this->assertStringContainsString('LIMIT', $query); } @@ -4573,7 +4573,7 @@ public function testCompileCursorAfterStandalone(): void { $builder = new Builder(); $sql = $builder->compileCursor(Query::cursorAfter('abc')); - $this->assertEquals('_cursor > ?', $sql); + $this->assertEquals('`_cursor` > ?', $sql); $this->assertEquals(['abc'], $builder->getBindings()); } @@ -4581,7 +4581,7 @@ public function testCompileCursorBeforeStandalone(): void { $builder = new Builder(); $sql = $builder->compileCursor(Query::cursorBefore('xyz')); - $this->assertEquals('_cursor < ?', $sql); + $this->assertEquals('`_cursor` < ?', $sql); $this->assertEquals(['xyz'], $builder->getBindings()); } @@ -4624,7 +4624,7 @@ public function testCompileJoinStandalone(): void { $builder = new Builder(); $sql = $builder->compileJoin(Query::join('orders', 'u.id', 'o.uid')); - $this->assertEquals('JOIN `orders` ON `u.id` = `o.uid`', $sql); + $this->assertEquals('JOIN `orders` ON `u`.`id` = `o`.`uid`', $sql); } public function testCompileJoinExceptionStandalone(): void @@ -4738,7 +4738,7 @@ public function testLeftJoinWithFinalAndSample(): void ->leftJoin('users', 'events.uid', 'users.id') ->build(); $this->assertEquals( - 'SELECT * FROM `events` FINAL SAMPLE 0.1 LEFT JOIN `users` ON `events.uid` = `users.id`', + 'SELECT * FROM `events` FINAL SAMPLE 0.1 LEFT JOIN `users` ON `events`.`uid` = `users`.`id`', $result['query'] ); } @@ -5023,7 +5023,7 @@ public function testKitchenSinkExactSql(): void ->union($sub) ->build(); $this->assertEquals( - 'SELECT DISTINCT COUNT(*) AS `total`, `event_type` FROM `events` FINAL SAMPLE 0.1 JOIN `users` ON `events.uid` = `users.id` PREWHERE `event_type` IN (?) WHERE `amount` > ? GROUP BY `event_type` HAVING `total` > ? ORDER BY `total` DESC LIMIT ? OFFSET ? UNION SELECT * FROM `archive` FINAL WHERE `status` IN (?)', + 'SELECT DISTINCT COUNT(*) AS `total`, `event_type` FROM `events` FINAL SAMPLE 0.1 JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `event_type` IN (?) WHERE `amount` > ? GROUP BY `event_type` HAVING `total` > ? ORDER BY `total` DESC LIMIT ? OFFSET ? UNION SELECT * FROM `archive` FINAL WHERE `status` IN (?)', $result['query'] ); $this->assertEquals(['purchase', 100, 5, 50, 10, 'closed'], $result['bindings']); @@ -5073,7 +5073,7 @@ public function testQueryCompileJoinViaClickHouse(): void { $builder = new Builder(); $sql = Query::join('orders', 'u.id', 'o.uid')->compile($builder); - $this->assertEquals('JOIN `orders` ON `u.id` = `o.uid`', $sql); + $this->assertEquals('JOIN `orders` ON `u`.`id` = `o`.`uid`', $sql); } public function testQueryCompileGroupByViaClickHouse(): void @@ -5180,9 +5180,10 @@ public function testNegativeLimit(): void public function testNegativeOffset(): void { + // OFFSET without LIMIT is suppressed $result = (new Builder())->from('t')->offset(-5)->build(); - $this->assertEquals('SELECT * FROM `t` OFFSET ?', $result['query']); - $this->assertEquals([-5], $result['bindings']); + $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals([], $result['bindings']); } public function testLimitZero(): void @@ -5204,14 +5205,15 @@ public function testMultipleLimitsFirstWins(): void public function testMultipleOffsetsFirstWins(): void { + // OFFSET without LIMIT is suppressed $result = (new Builder())->from('t')->offset(5)->offset(50)->build(); - $this->assertEquals([5], $result['bindings']); + $this->assertEquals([], $result['bindings']); } public function testCursorAfterAndBeforeFirstWins(): void { $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->sortAsc('_cursor')->build(); - $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result['query']); } // ══════════════════════════════════════════════════════════════════ diff --git a/tests/Query/Builder/SQLTest.php b/tests/Query/Builder/SQLTest.php index 7829db1..9de7fd4 100644 --- a/tests/Query/Builder/SQLTest.php +++ b/tests/Query/Builder/SQLTest.php @@ -475,13 +475,14 @@ public function testLimitOnly(): void public function testOffsetOnly(): void { + // OFFSET without LIMIT is invalid in MySQL/ClickHouse, so offset is suppressed $result = (new Builder()) ->from('t') ->offset(50) ->build(); - $this->assertEquals('SELECT * FROM `t` OFFSET ?', $result['query']); - $this->assertEquals([50], $result['bindings']); + $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals([], $result['bindings']); } public function testCursorAfter(): void @@ -491,7 +492,7 @@ public function testCursorAfter(): void ->cursorAfter('abc123') ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE _cursor > ?', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE `_cursor` > ?', $result['query']); $this->assertEquals(['abc123'], $result['bindings']); } @@ -502,7 +503,7 @@ public function testCursorBefore(): void ->cursorBefore('xyz789') ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE _cursor < ?', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE `_cursor` < ?', $result['query']); $this->assertEquals(['xyz789'], $result['bindings']); } @@ -829,7 +830,7 @@ public function testJoin(): void ->build(); $this->assertEquals( - 'SELECT * FROM `users` JOIN `orders` ON `users.id` = `orders.user_id`', + 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result['query'] ); } @@ -842,7 +843,7 @@ public function testLeftJoin(): void ->build(); $this->assertEquals( - 'SELECT * FROM `users` LEFT JOIN `profiles` ON `users.id` = `profiles.user_id`', + 'SELECT * FROM `users` LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`user_id`', $result['query'] ); } @@ -855,7 +856,7 @@ public function testRightJoin(): void ->build(); $this->assertEquals( - 'SELECT * FROM `users` RIGHT JOIN `orders` ON `users.id` = `orders.user_id`', + 'SELECT * FROM `users` RIGHT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result['query'] ); } @@ -882,7 +883,7 @@ public function testJoinWithFilter(): void ->build(); $this->assertEquals( - 'SELECT * FROM `users` JOIN `orders` ON `users.id` = `orders.user_id` WHERE `orders.total` > ?', + 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ?', $result['query'] ); $this->assertEquals([100], $result['bindings']); @@ -1035,7 +1036,7 @@ public function testCombinedAggregationJoinGroupByHaving(): void ->build(); $this->assertEquals( - 'SELECT COUNT(*) AS `order_count`, SUM(`total`) AS `total_amount`, `users.name` FROM `orders` JOIN `users` ON `orders.user_id` = `users.id` GROUP BY `users.name` HAVING `order_count` > ? ORDER BY `total_amount` DESC LIMIT ?', + 'SELECT COUNT(*) AS `order_count`, SUM(`total`) AS `total_amount`, `users`.`name` FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` GROUP BY `users`.`name` HAVING `order_count` > ? ORDER BY `total_amount` DESC LIMIT ?', $result['query'] ); $this->assertEquals([5, 10], $result['bindings']); @@ -1081,7 +1082,7 @@ public function testCountWithEmptyStringAttribute(): void ->count('') ->build(); - $this->assertEquals('SELECT COUNT(``) FROM `t`', $result['query']); + $this->assertEquals('SELECT COUNT(*) FROM `t`', $result['query']); } public function testMultipleAggregations(): void @@ -1279,7 +1280,7 @@ public function testDistinctWithJoin(): void ->build(); $this->assertEquals( - 'SELECT DISTINCT `users.name` FROM `users` JOIN `orders` ON `users.id` = `orders.user_id`', + 'SELECT DISTINCT `users`.`name` FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result['query'] ); } @@ -1312,7 +1313,7 @@ public function testMultipleJoins(): void ->build(); $this->assertEquals( - 'SELECT * FROM `users` JOIN `orders` ON `users.id` = `orders.user_id` LEFT JOIN `profiles` ON `users.id` = `profiles.user_id` RIGHT JOIN `departments` ON `users.dept_id` = `departments.id`', + 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`user_id` RIGHT JOIN `departments` ON `users`.`dept_id` = `departments`.`id`', $result['query'] ); } @@ -1327,7 +1328,7 @@ public function testJoinWithAggregationAndGroupBy(): void ->build(); $this->assertEquals( - 'SELECT COUNT(*) AS `order_count` FROM `users` JOIN `orders` ON `users.id` = `orders.user_id` GROUP BY `users.name`', + 'SELECT COUNT(*) AS `order_count` FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` GROUP BY `users`.`name`', $result['query'] ); } @@ -1344,7 +1345,7 @@ public function testJoinWithSortAndPagination(): void ->build(); $this->assertEquals( - 'SELECT * FROM `users` JOIN `orders` ON `users.id` = `orders.user_id` WHERE `orders.total` > ? ORDER BY `orders.total` DESC LIMIT ? OFFSET ?', + 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ? ORDER BY `orders`.`total` DESC LIMIT ? OFFSET ?', $result['query'] ); $this->assertEquals([50, 10, 20], $result['bindings']); @@ -1358,7 +1359,7 @@ public function testJoinWithCustomOperator(): void ->build(); $this->assertEquals( - 'SELECT * FROM `a` JOIN `b` ON `a.val` != `b.val`', + 'SELECT * FROM `a` JOIN `b` ON `a`.`val` != `b`.`val`', $result['query'] ); } @@ -1372,7 +1373,7 @@ public function testCrossJoinWithOtherJoins(): void ->build(); $this->assertEquals( - 'SELECT * FROM `sizes` CROSS JOIN `colors` LEFT JOIN `inventory` ON `sizes.id` = `inventory.size_id`', + 'SELECT * FROM `sizes` CROSS JOIN `colors` LEFT JOIN `inventory` ON `sizes`.`id` = `inventory`.`size_id`', $result['query'] ); } @@ -1800,7 +1801,7 @@ public function testWrapCharWithJoin(): void ->build(); $this->assertEquals( - 'SELECT * FROM "users" JOIN "orders" ON "users.id" = "orders.uid"', + 'SELECT * FROM "users" JOIN "orders" ON "users"."id" = "orders"."uid"', $result['query'] ); } @@ -1847,7 +1848,7 @@ public function testConditionProviderWithJoins(): void ->build(); $this->assertEquals( - 'SELECT * FROM `users` JOIN `orders` ON `users.id` = `orders.user_id` WHERE `orders.total` > ? AND users.org_id = ?', + 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ? AND users.org_id = ?', $result['query'] ); $this->assertEquals([100, 'org1'], $result['bindings']); @@ -1923,7 +1924,7 @@ public function testCursorWithLimitAndOffset(): void ->build(); $this->assertEquals( - 'SELECT * FROM `t` WHERE _cursor > ? LIMIT ? OFFSET ?', + 'SELECT * FROM `t` WHERE `_cursor` > ? LIMIT ? OFFSET ?', $result['query'] ); $this->assertEquals(['abc', 10, 5], $result['bindings']); @@ -1938,7 +1939,7 @@ public function testCursorWithPage(): void ->build(); // Cursor + limit from page + offset from page; first limit/offset wins - $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result['query']); $this->assertStringContainsString('LIMIT ?', $result['query']); } @@ -2041,8 +2042,9 @@ public function testOffsetZero(): void ->offset(0) ->build(); - $this->assertEquals('SELECT * FROM `t` OFFSET ?', $result['query']); - $this->assertEquals([0], $result['bindings']); + // OFFSET without LIMIT is suppressed + $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals([], $result['bindings']); } // ── Fluent chaining returns same instance ── @@ -2772,7 +2774,7 @@ public function testWrapCharAffectsJoin(): void ->build(); $this->assertEquals( - 'SELECT * FROM "users" JOIN "orders" ON "users.id" = "orders.uid"', + 'SELECT * FROM "users" JOIN "orders" ON "users"."id" = "orders"."uid"', $result['query'] ); } @@ -2786,7 +2788,7 @@ public function testWrapCharAffectsLeftJoin(): void ->build(); $this->assertEquals( - 'SELECT * FROM "users" LEFT JOIN "profiles" ON "users.id" = "profiles.uid"', + 'SELECT * FROM "users" LEFT JOIN "profiles" ON "users"."id" = "profiles"."uid"', $result['query'] ); } @@ -2800,7 +2802,7 @@ public function testWrapCharAffectsRightJoin(): void ->build(); $this->assertEquals( - 'SELECT * FROM "users" RIGHT JOIN "orders" ON "users.id" = "orders.uid"', + 'SELECT * FROM "users" RIGHT JOIN "orders" ON "users"."id" = "orders"."uid"', $result['query'] ); } @@ -3358,7 +3360,7 @@ public function testCompileCursorAfterStandalone(): void { $builder = new Builder(); $sql = $builder->compileCursor(Query::cursorAfter('abc')); - $this->assertEquals('_cursor > ?', $sql); + $this->assertEquals('`_cursor` > ?', $sql); $this->assertEquals(['abc'], $builder->getBindings()); } @@ -3366,7 +3368,7 @@ public function testCompileCursorBeforeStandalone(): void { $builder = new Builder(); $sql = $builder->compileCursor(Query::cursorBefore('xyz')); - $this->assertEquals('_cursor < ?', $sql); + $this->assertEquals('`_cursor` < ?', $sql); $this->assertEquals(['xyz'], $builder->getBindings()); } @@ -3423,21 +3425,21 @@ public function testCompileJoinStandalone(): void { $builder = new Builder(); $sql = $builder->compileJoin(Query::join('orders', 'users.id', 'orders.uid')); - $this->assertEquals('JOIN `orders` ON `users.id` = `orders.uid`', $sql); + $this->assertEquals('JOIN `orders` ON `users`.`id` = `orders`.`uid`', $sql); } public function testCompileLeftJoinStandalone(): void { $builder = new Builder(); $sql = $builder->compileJoin(Query::leftJoin('profiles', 'users.id', 'profiles.uid')); - $this->assertEquals('LEFT JOIN `profiles` ON `users.id` = `profiles.uid`', $sql); + $this->assertEquals('LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`uid`', $sql); } public function testCompileRightJoinStandalone(): void { $builder = new Builder(); $sql = $builder->compileJoin(Query::rightJoin('orders', 'users.id', 'orders.uid')); - $this->assertEquals('RIGHT JOIN `orders` ON `users.id` = `orders.uid`', $sql); + $this->assertEquals('RIGHT JOIN `orders` ON `users`.`id` = `orders`.`uid`', $sql); } public function testCompileCrossJoinStandalone(): void @@ -3827,7 +3829,7 @@ public function testFilterWithDotsInAttributeName(): void ->filter([Query::equal('table.column', ['value'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `table.column` IN (?)', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE `table`.`column` IN (?)', $result['query']); } public function testFilterWithUnderscoresInAttributeName(): void @@ -3985,8 +3987,8 @@ public function testAggregationJoinGroupByHavingSortLimitFullPipeline(): void $this->assertStringContainsString('COUNT(*) AS `cnt`', $result['query']); $this->assertStringContainsString('SUM(`total`) AS `revenue`', $result['query']); $this->assertStringContainsString('JOIN `users`', $result['query']); - $this->assertStringContainsString('WHERE `orders.total` > ?', $result['query']); - $this->assertStringContainsString('GROUP BY `users.name`', $result['query']); + $this->assertStringContainsString('WHERE `orders`.`total` > ?', $result['query']); + $this->assertStringContainsString('GROUP BY `users`.`name`', $result['query']); $this->assertStringContainsString('HAVING `cnt` > ?', $result['query']); $this->assertStringContainsString('ORDER BY `revenue` DESC', $result['query']); $this->assertStringContainsString('LIMIT ?', $result['query']); @@ -4045,7 +4047,7 @@ public function testSelfJoin(): void ->build(); $this->assertEquals( - 'SELECT * FROM `employees` JOIN `employees` ON `employees.manager_id` = `employees.id`', + 'SELECT * FROM `employees` JOIN `employees` ON `employees`.`manager_id` = `employees`.`id`', $result['query'] ); } @@ -4079,8 +4081,8 @@ public function testJoinFilterSortLimitOffsetCombined(): void ->build(); $this->assertStringContainsString('JOIN `orders`', $result['query']); - $this->assertStringContainsString('WHERE `orders.status` IN (?) AND `orders.total` > ?', $result['query']); - $this->assertStringContainsString('ORDER BY `orders.total` DESC', $result['query']); + $this->assertStringContainsString('WHERE `orders`.`status` IN (?) AND `orders`.`total` > ?', $result['query']); + $this->assertStringContainsString('ORDER BY `orders`.`total` DESC', $result['query']); $this->assertStringContainsString('LIMIT ?', $result['query']); $this->assertStringContainsString('OFFSET ?', $result['query']); $this->assertEquals(['paid', 100, 25, 50], $result['bindings']); @@ -4098,7 +4100,7 @@ public function testJoinAggregationGroupByHavingCombined(): void $this->assertStringContainsString('COUNT(*) AS `cnt`', $result['query']); $this->assertStringContainsString('JOIN `users`', $result['query']); - $this->assertStringContainsString('GROUP BY `users.name`', $result['query']); + $this->assertStringContainsString('GROUP BY `users`.`name`', $result['query']); $this->assertStringContainsString('HAVING `cnt` > ?', $result['query']); $this->assertEquals([3], $result['bindings']); } @@ -4112,7 +4114,7 @@ public function testJoinWithDistinct(): void ->join('orders', 'users.id', 'orders.user_id') ->build(); - $this->assertStringContainsString('SELECT DISTINCT `users.name`', $result['query']); + $this->assertStringContainsString('SELECT DISTINCT `users`.`name`', $result['query']); $this->assertStringContainsString('JOIN `orders`', $result['query']); } @@ -4176,7 +4178,7 @@ public function testCrossJoinCombinedWithFilter(): void ->build(); $this->assertStringContainsString('CROSS JOIN `colors`', $result['query']); - $this->assertStringContainsString('WHERE `sizes.active` IN (?)', $result['query']); + $this->assertStringContainsString('WHERE `sizes`.`active` IN (?)', $result['query']); } public function testCrossJoinFollowedByRegularJoin(): void @@ -4188,7 +4190,7 @@ public function testCrossJoinFollowedByRegularJoin(): void ->build(); $this->assertEquals( - 'SELECT * FROM `a` CROSS JOIN `b` JOIN `c` ON `a.id` = `c.a_id`', + 'SELECT * FROM `a` CROSS JOIN `b` JOIN `c` ON `a`.`id` = `c`.`a_id`', $result['query'] ); } @@ -4207,8 +4209,8 @@ public function testMultipleJoinsWithFiltersOnEach(): void $this->assertStringContainsString('JOIN `orders`', $result['query']); $this->assertStringContainsString('LEFT JOIN `profiles`', $result['query']); - $this->assertStringContainsString('`orders.total` > ?', $result['query']); - $this->assertStringContainsString('`profiles.avatar` IS NOT NULL', $result['query']); + $this->assertStringContainsString('`orders`.`total` > ?', $result['query']); + $this->assertStringContainsString('`profiles`.`avatar` IS NOT NULL', $result['query']); } public function testJoinWithCustomOperatorLessThan(): void @@ -4219,7 +4221,7 @@ public function testJoinWithCustomOperatorLessThan(): void ->build(); $this->assertEquals( - 'SELECT * FROM `a` JOIN `b` ON `a.start` < `b.end`', + 'SELECT * FROM `a` JOIN `b` ON `a`.`start` < `b`.`end`', $result['query'] ); } @@ -5560,13 +5562,14 @@ public function testBuildWithOnlyLimit(): void public function testBuildWithOnlyOffset(): void { + // OFFSET without LIMIT is suppressed $result = (new Builder()) ->from('') ->offset(50) ->build(); - $this->assertStringContainsString('OFFSET ?', $result['query']); - $this->assertEquals([50], $result['bindings']); + $this->assertStringNotContainsString('OFFSET ?', $result['query']); + $this->assertEquals([], $result['bindings']); } public function testBuildWithOnlySort(): void @@ -5824,7 +5827,7 @@ public function testKitchenSinkExactSql(): void ->build(); $this->assertEquals( - 'SELECT DISTINCT COUNT(*) AS `total`, `status` FROM `orders` JOIN `users` ON `orders.uid` = `users.id` WHERE `amount` > ? GROUP BY `status` HAVING `total` > ? ORDER BY `status` ASC LIMIT ? OFFSET ? UNION SELECT * FROM `archive` WHERE `status` IN (?)', + 'SELECT DISTINCT COUNT(*) AS `total`, `status` FROM `orders` JOIN `users` ON `orders`.`uid` = `users`.`id` WHERE `amount` > ? GROUP BY `status` HAVING `total` > ? ORDER BY `status` ASC LIMIT ? OFFSET ? UNION SELECT * FROM `archive` WHERE `status` IN (?)', $result['query'] ); $this->assertEquals([100, 5, 10, 20, 'closed'], $result['bindings']); @@ -5873,7 +5876,7 @@ public function testAggregationWithCursor(): void ->cursorAfter('abc') ->build(); $this->assertStringContainsString('COUNT(*)', $result['query']); - $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result['query']); $this->assertContains('abc', $result['bindings']); } @@ -5910,7 +5913,7 @@ public function testConditionProviderWithCursorNoFilters(): void ->cursorAfter('abc') ->build(); $this->assertStringContainsString('_tenant = ?', $result['query']); - $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result['query']); // Provider bindings come before cursor bindings $this->assertEquals(['t1', 'abc'], $result['bindings']); } @@ -5982,9 +5985,10 @@ public function testNegativeLimit(): void public function testNegativeOffset(): void { + // OFFSET without LIMIT is suppressed $result = (new Builder())->from('t')->offset(-5)->build(); - $this->assertEquals('SELECT * FROM `t` OFFSET ?', $result['query']); - $this->assertEquals([-5], $result['bindings']); + $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals([], $result['bindings']); } public function testEqualWithNullOnly(): void @@ -6033,14 +6037,14 @@ public function testContainsWithSqlWildcard(): void { $result = (new Builder())->from('t')->filter([Query::contains('bio', ['100%'])])->build(); $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result['query']); - $this->assertEquals(['%100%%'], $result['bindings']); + $this->assertEquals(['%100\%%'], $result['bindings']); } public function testStartsWithWithWildcard(): void { $result = (new Builder())->from('t')->filter([Query::startsWith('name', '%admin')])->build(); $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result['query']); - $this->assertEquals(['%admin%'], $result['bindings']); + $this->assertEquals(['\%admin%'], $result['bindings']); } public function testCursorWithNullValue(): void @@ -6054,14 +6058,14 @@ public function testCursorWithNullValue(): void public function testCursorWithIntegerValue(): void { $result = (new Builder())->from('t')->cursorAfter(42)->build(); - $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result['query']); $this->assertSame([42], $result['bindings']); } public function testCursorWithFloatValue(): void { $result = (new Builder())->from('t')->cursorAfter(3.14)->build(); - $this->assertStringContainsString('_cursor > ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result['query']); $this->assertSame([3.14], $result['bindings']); } @@ -6074,16 +6078,17 @@ public function testMultipleLimitsFirstWins(): void public function testMultipleOffsetsFirstWins(): void { + // OFFSET without LIMIT is suppressed $result = (new Builder())->from('t')->offset(5)->offset(50)->build(); - $this->assertEquals('SELECT * FROM `t` OFFSET ?', $result['query']); - $this->assertEquals([5], $result['bindings']); + $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals([], $result['bindings']); } public function testCursorAfterAndBeforeFirstWins(): void { $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->build(); - $this->assertStringContainsString('_cursor > ?', $result['query']); - $this->assertStringNotContainsString('_cursor < ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result['query']); + $this->assertStringNotContainsString('`_cursor` < ?', $result['query']); } public function testEmptyTableWithJoin(): void @@ -6221,14 +6226,14 @@ public function testQueryCompileOffset(): void public function testQueryCompileCursorAfter(): void { $builder = new Builder(); - $this->assertEquals('_cursor > ?', Query::cursorAfter('x')->compile($builder)); + $this->assertEquals('`_cursor` > ?', Query::cursorAfter('x')->compile($builder)); $this->assertEquals(['x'], $builder->getBindings()); } public function testQueryCompileCursorBefore(): void { $builder = new Builder(); - $this->assertEquals('_cursor < ?', Query::cursorBefore('x')->compile($builder)); + $this->assertEquals('`_cursor` < ?', Query::cursorBefore('x')->compile($builder)); $this->assertEquals(['x'], $builder->getBindings()); } @@ -6282,8 +6287,8 @@ public function testSetWrapCharCursorNotAffected(): void ->from('t') ->cursorAfter('abc') ->build(); - // _cursor is hardcoded, not wrapped - $this->assertStringContainsString('_cursor > ?', $result['query']); + // _cursor is now properly wrapped with the configured wrap character + $this->assertStringContainsString('"_cursor" > ?', $result['query']); } public function testSetWrapCharWithToRawSql(): void diff --git a/tests/Query/JoinQueryTest.php b/tests/Query/JoinQueryTest.php index c605f5c..cddb42a 100644 --- a/tests/Query/JoinQueryTest.php +++ b/tests/Query/JoinQueryTest.php @@ -107,7 +107,7 @@ public function testJoinCompileDispatch(): void $builder = new \Utopia\Query\Builder\SQL(); $query = Query::join('orders', 'users.id', 'orders.uid'); $sql = $query->compile($builder); - $this->assertEquals('JOIN `orders` ON `users.id` = `orders.uid`', $sql); + $this->assertEquals('JOIN `orders` ON `users`.`id` = `orders`.`uid`', $sql); } public function testLeftJoinCompileDispatch(): void @@ -115,7 +115,7 @@ public function testLeftJoinCompileDispatch(): void $builder = new \Utopia\Query\Builder\SQL(); $query = Query::leftJoin('p', 'u.id', 'p.uid'); $sql = $query->compile($builder); - $this->assertEquals('LEFT JOIN `p` ON `u.id` = `p.uid`', $sql); + $this->assertEquals('LEFT JOIN `p` ON `u`.`id` = `p`.`uid`', $sql); } public function testRightJoinCompileDispatch(): void @@ -123,7 +123,7 @@ public function testRightJoinCompileDispatch(): void $builder = new \Utopia\Query\Builder\SQL(); $query = Query::rightJoin('o', 'u.id', 'o.uid'); $sql = $query->compile($builder); - $this->assertEquals('RIGHT JOIN `o` ON `u.id` = `o.uid`', $sql); + $this->assertEquals('RIGHT JOIN `o` ON `u`.`id` = `o`.`uid`', $sql); } public function testCrossJoinCompileDispatch(): void From fb06eb3a8caa7a6f03c28f318112d1bd454bdde5 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 5 Mar 2026 10:41:35 +1300 Subject: [PATCH 10/29] fix: address cycle 2 code review findings - Fix condition provider binding order mismatch with cursor - Wrap UNION queries in parentheses for correct precedence - Validate ClickHouse SAMPLE fraction range (0,1) - Use explicit map for aggregate SQL function names - Escape backslashes in LIKE pattern values --- src/Query/Builder.php | 22 +++++++++------ src/Query/Builder/ClickHouse.php | 4 +++ tests/Query/Builder/ClickHouseTest.php | 31 ++++++++++----------- tests/Query/Builder/SQLTest.php | 38 +++++++++++++------------- 4 files changed, 52 insertions(+), 43 deletions(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index b1cecb6..f960798 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -395,18 +395,14 @@ public function build(): array $whereClauses[] = $this->compileFilter($filter); } - $providerBindings = []; foreach ($this->conditionProviders as $provider) { /** @var array{0: string, 1: list} $result */ $result = $provider($this->table); $whereClauses[] = $result[0]; foreach ($result[1] as $binding) { - $providerBindings[] = $binding; + $this->addBinding($binding); } } - foreach ($providerBindings as $binding) { - $this->addBinding($binding); - } $cursorSQL = ''; if ($grouped['cursor'] !== null && $grouped['cursorDirection'] !== null) { @@ -475,8 +471,11 @@ public function build(): array $sql = \implode(' ', $parts); // UNION + if (!empty($this->unions)) { + $sql = '(' . $sql . ')'; + } foreach ($this->unions as $union) { - $sql .= ' ' . $union['type'] . ' ' . $union['query']; + $sql .= ' ' . $union['type'] . ' (' . $union['query'] . ')'; foreach ($union['bindings'] as $binding) { $this->addBinding($binding); } @@ -594,7 +593,14 @@ public function compileCursor(Query $query): string public function compileAggregate(Query $query): string { - $func = \strtoupper($query->getMethod()); + $funcMap = [ + Query::TYPE_COUNT => 'COUNT', + Query::TYPE_SUM => 'SUM', + Query::TYPE_AVG => 'AVG', + Query::TYPE_MIN => 'MIN', + Query::TYPE_MAX => 'MAX', + ]; + $func = $funcMap[$query->getMethod()] ?? throw new \InvalidArgumentException("Unknown aggregate: {$query->getMethod()}"); $attr = $query->getAttribute(); $col = ($attr === '*' || $attr === '') ? '*' : $this->resolveAndWrap($attr); /** @var string $alias */ @@ -859,7 +865,7 @@ private function compileNotContains(string $attribute, array $values): string */ private function escapeLikeValue(string $value): string { - return \str_replace(['%', '_'], ['\\%', '\\_'], $value); + return \str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); } private function compileLogical(Query $query, string $operator): string diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 525b59e..fb027bc 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -48,6 +48,10 @@ public function final(): static */ public function sample(float $fraction): static { + if ($fraction <= 0.0 || $fraction >= 1.0) { + throw new \InvalidArgumentException('Sample fraction must be between 0 and 1 exclusive'); + } + $this->sampleFraction = $fraction; return $this; diff --git a/tests/Query/Builder/ClickHouseTest.php b/tests/Query/Builder/ClickHouseTest.php index 362fdc7..b8a4961 100644 --- a/tests/Query/Builder/ClickHouseTest.php +++ b/tests/Query/Builder/ClickHouseTest.php @@ -295,7 +295,7 @@ public function testUnion(): void ->build(); $this->assertEquals( - 'SELECT * FROM `events` WHERE `year` IN (?) UNION SELECT * FROM `events_archive` WHERE `year` IN (?)', + '(SELECT * FROM `events` WHERE `year` IN (?)) UNION (SELECT * FROM `events_archive` WHERE `year` IN (?))', $result['query'] ); $this->assertEquals([2024, 2023], $result['bindings']); @@ -893,7 +893,7 @@ public function testPrewhereWithUnion(): void ->build(); $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); - $this->assertStringContainsString('UNION SELECT', $result['query']); + $this->assertStringContainsString('UNION (SELECT', $result['query']); } public function testPrewhereWithDistinct(): void @@ -1192,7 +1192,7 @@ public function testFinalWithUnion(): void ->build(); $this->assertStringContainsString('FROM `events` FINAL', $result['query']); - $this->assertStringContainsString('UNION SELECT', $result['query']); + $this->assertStringContainsString('UNION (SELECT', $result['query']); } public function testFinalWithPrewhere(): void @@ -2806,7 +2806,7 @@ public function testUnionMainHasFinal(): void ->build(); $this->assertStringContainsString('FROM `events` FINAL', $result['query']); - $this->assertStringContainsString('UNION SELECT * FROM `archive`', $result['query']); + $this->assertStringContainsString('UNION (SELECT * FROM `archive`)', $result['query']); } public function testUnionMainHasSample(): void @@ -4470,27 +4470,26 @@ public function testFilterElemMatchThrowsException(): void public function testSampleZero(): void { - $result = (new Builder())->from('t')->sample(0.0)->build(); - $this->assertStringContainsString('SAMPLE 0', $result['query']); + $this->expectException(\InvalidArgumentException::class); + (new Builder())->from('t')->sample(0.0); } public function testSampleOne(): void { - $result = (new Builder())->from('t')->sample(1.0)->build(); - $this->assertStringContainsString('SAMPLE 1', $result['query']); + $this->expectException(\InvalidArgumentException::class); + (new Builder())->from('t')->sample(1.0); } public function testSampleNegative(): void { - // Builder doesn't validate - it passes through - $result = (new Builder())->from('t')->sample(-0.5)->build(); - $this->assertStringContainsString('SAMPLE -0.5', $result['query']); + $this->expectException(\InvalidArgumentException::class); + (new Builder())->from('t')->sample(-0.5); } public function testSampleGreaterThanOne(): void { - $result = (new Builder())->from('t')->sample(2.0)->build(); - $this->assertStringContainsString('SAMPLE 2', $result['query']); + $this->expectException(\InvalidArgumentException::class); + (new Builder())->from('t')->sample(2.0); } public function testSampleVerySmall(): void @@ -4663,7 +4662,7 @@ public function testUnionAllBothWithFinal(): void ->unionAll($sub) ->build(); $this->assertStringContainsString('FROM `a` FINAL', $result['query']); - $this->assertStringContainsString('UNION ALL SELECT * FROM `b` FINAL', $result['query']); + $this->assertStringContainsString('UNION ALL (SELECT * FROM `b` FINAL)', $result['query']); } // ══════════════════════════════════════════════════════════════════ @@ -5023,7 +5022,7 @@ public function testKitchenSinkExactSql(): void ->union($sub) ->build(); $this->assertEquals( - 'SELECT DISTINCT COUNT(*) AS `total`, `event_type` FROM `events` FINAL SAMPLE 0.1 JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `event_type` IN (?) WHERE `amount` > ? GROUP BY `event_type` HAVING `total` > ? ORDER BY `total` DESC LIMIT ? OFFSET ? UNION SELECT * FROM `archive` FINAL WHERE `status` IN (?)', + '(SELECT DISTINCT COUNT(*) AS `total`, `event_type` FROM `events` FINAL SAMPLE 0.1 JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `event_type` IN (?) WHERE `amount` > ? GROUP BY `event_type` HAVING `total` > ? ORDER BY `total` DESC LIMIT ? OFFSET ?) UNION (SELECT * FROM `archive` FINAL WHERE `status` IN (?))', $result['query'] ); $this->assertEquals(['purchase', 100, 5, 50, 10, 'closed'], $result['bindings']); @@ -5224,6 +5223,6 @@ public function testDistinctWithUnion(): void { $other = (new Builder())->from('b'); $result = (new Builder())->from('a')->distinct()->union($other)->build(); - $this->assertEquals('SELECT DISTINCT * FROM `a` UNION SELECT * FROM `b`', $result['query']); + $this->assertEquals('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result['query']); } } diff --git a/tests/Query/Builder/SQLTest.php b/tests/Query/Builder/SQLTest.php index 9de7fd4..24c84e4 100644 --- a/tests/Query/Builder/SQLTest.php +++ b/tests/Query/Builder/SQLTest.php @@ -925,7 +925,7 @@ public function testUnion(): void ->build(); $this->assertEquals( - 'SELECT * FROM `users` WHERE `status` IN (?) UNION SELECT * FROM `admins` WHERE `role` IN (?)', + '(SELECT * FROM `users` WHERE `status` IN (?)) UNION (SELECT * FROM `admins` WHERE `role` IN (?))', $result['query'] ); $this->assertEquals(['active', 'admin'], $result['bindings']); @@ -940,7 +940,7 @@ public function testUnionAll(): void ->build(); $this->assertEquals( - 'SELECT * FROM `current` UNION ALL SELECT * FROM `archive`', + '(SELECT * FROM `current`) UNION ALL (SELECT * FROM `archive`)', $result['query'] ); } @@ -1433,7 +1433,7 @@ public function testMultipleUnions(): void ->build(); $this->assertEquals( - 'SELECT * FROM `users` UNION SELECT * FROM `admins` UNION SELECT * FROM `mods`', + '(SELECT * FROM `users`) UNION (SELECT * FROM `admins`) UNION (SELECT * FROM `mods`)', $result['query'] ); } @@ -1450,7 +1450,7 @@ public function testMixedUnionAndUnionAll(): void ->build(); $this->assertEquals( - 'SELECT * FROM `users` UNION SELECT * FROM `admins` UNION ALL SELECT * FROM `mods`', + '(SELECT * FROM `users`) UNION (SELECT * FROM `admins`) UNION ALL (SELECT * FROM `mods`)', $result['query'] ); } @@ -1468,7 +1468,7 @@ public function testUnionWithFiltersAndBindings(): void ->build(); $this->assertEquals( - 'SELECT * FROM `users` WHERE `status` IN (?) UNION SELECT * FROM `admins` WHERE `level` IN (?) UNION ALL SELECT * FROM `mods` WHERE `score` > ?', + '(SELECT * FROM `users` WHERE `status` IN (?)) UNION (SELECT * FROM `admins` WHERE `level` IN (?)) UNION ALL (SELECT * FROM `mods` WHERE `score` > ?)', $result['query'] ); $this->assertEquals(['active', 1, 50], $result['bindings']); @@ -1485,7 +1485,7 @@ public function testUnionWithAggregation(): void ->build(); $this->assertEquals( - 'SELECT COUNT(*) AS `total` FROM `orders_2024` UNION ALL SELECT COUNT(*) AS `total` FROM `orders_2023`', + '(SELECT COUNT(*) AS `total` FROM `orders_2024`) UNION ALL (SELECT COUNT(*) AS `total` FROM `orders_2023`)', $result['query'] ); } @@ -4259,7 +4259,7 @@ public function testUnionWithThreeSubQueries(): void ->build(); $this->assertEquals( - 'SELECT * FROM `main` UNION SELECT * FROM `a` UNION SELECT * FROM `b` UNION SELECT * FROM `c`', + '(SELECT * FROM `main`) UNION (SELECT * FROM `a`) UNION (SELECT * FROM `b`) UNION (SELECT * FROM `c`)', $result['query'] ); } @@ -4278,7 +4278,7 @@ public function testUnionAllWithThreeSubQueries(): void ->build(); $this->assertEquals( - 'SELECT * FROM `main` UNION ALL SELECT * FROM `a` UNION ALL SELECT * FROM `b` UNION ALL SELECT * FROM `c`', + '(SELECT * FROM `main`) UNION ALL (SELECT * FROM `a`) UNION ALL (SELECT * FROM `b`) UNION ALL (SELECT * FROM `c`)', $result['query'] ); } @@ -4297,7 +4297,7 @@ public function testMixedUnionAndUnionAllWithThreeSubQueries(): void ->build(); $this->assertEquals( - 'SELECT * FROM `main` UNION SELECT * FROM `a` UNION ALL SELECT * FROM `b` UNION SELECT * FROM `c`', + '(SELECT * FROM `main`) UNION (SELECT * FROM `a`) UNION ALL (SELECT * FROM `b`) UNION (SELECT * FROM `c`)', $result['query'] ); } @@ -4314,7 +4314,7 @@ public function testUnionWhereSubQueryHasJoins(): void ->build(); $this->assertStringContainsString( - 'UNION SELECT * FROM `archived_users` JOIN `archived_orders`', + 'UNION (SELECT * FROM `archived_users` JOIN `archived_orders`', $result['query'] ); } @@ -4333,7 +4333,7 @@ public function testUnionWhereSubQueryHasAggregation(): void ->union($sub) ->build(); - $this->assertStringContainsString('UNION SELECT COUNT(*) AS `cnt` FROM `orders_2023` GROUP BY `status`', $result['query']); + $this->assertStringContainsString('UNION (SELECT COUNT(*) AS `cnt` FROM `orders_2023` GROUP BY `status`)', $result['query']); } public function testUnionWhereSubQueryHasSortAndLimit(): void @@ -4348,7 +4348,7 @@ public function testUnionWhereSubQueryHasSortAndLimit(): void ->union($sub) ->build(); - $this->assertStringContainsString('UNION SELECT * FROM `archive` ORDER BY `created_at` DESC LIMIT ?', $result['query']); + $this->assertStringContainsString('UNION (SELECT * FROM `archive` ORDER BY `created_at` DESC LIMIT ?)', $result['query']); } public function testUnionWithConditionProviders(): void @@ -4364,7 +4364,7 @@ public function testUnionWithConditionProviders(): void ->build(); $this->assertStringContainsString('WHERE org = ?', $result['query']); - $this->assertStringContainsString('UNION SELECT * FROM `other` WHERE org = ?', $result['query']); + $this->assertStringContainsString('UNION (SELECT * FROM `other` WHERE org = ?)', $result['query']); $this->assertEquals(['org1', 'org2'], $result['bindings']); } @@ -4400,7 +4400,7 @@ public function testUnionWithDistinct(): void ->build(); $this->assertStringContainsString('SELECT DISTINCT `name` FROM `current`', $result['query']); - $this->assertStringContainsString('UNION SELECT DISTINCT `name` FROM `archive`', $result['query']); + $this->assertStringContainsString('UNION (SELECT DISTINCT `name` FROM `archive`)', $result['query']); } public function testUnionWithWrapChar(): void @@ -4416,7 +4416,7 @@ public function testUnionWithWrapChar(): void ->build(); $this->assertEquals( - 'SELECT * FROM "current" UNION SELECT * FROM "archive"', + '(SELECT * FROM "current") UNION (SELECT * FROM "archive")', $result['query'] ); } @@ -4431,7 +4431,7 @@ public function testUnionAfterReset(): void $result = $builder->from('fresh')->union($sub)->build(); $this->assertEquals( - 'SELECT * FROM `fresh` UNION SELECT * FROM `other`', + '(SELECT * FROM `fresh`) UNION (SELECT * FROM `other`)', $result['query'] ); } @@ -5827,7 +5827,7 @@ public function testKitchenSinkExactSql(): void ->build(); $this->assertEquals( - 'SELECT DISTINCT COUNT(*) AS `total`, `status` FROM `orders` JOIN `users` ON `orders`.`uid` = `users`.`id` WHERE `amount` > ? GROUP BY `status` HAVING `total` > ? ORDER BY `status` ASC LIMIT ? OFFSET ? UNION SELECT * FROM `archive` WHERE `status` IN (?)', + '(SELECT DISTINCT COUNT(*) AS `total`, `status` FROM `orders` JOIN `users` ON `orders`.`uid` = `users`.`id` WHERE `amount` > ? GROUP BY `status` HAVING `total` > ? ORDER BY `status` ASC LIMIT ? OFFSET ?) UNION (SELECT * FROM `archive` WHERE `status` IN (?))', $result['query'] ); $this->assertEquals([100, 5, 10, 20, 'closed'], $result['bindings']); @@ -5841,7 +5841,7 @@ public function testDistinctWithUnion(): void { $other = (new Builder())->from('b'); $result = (new Builder())->from('a')->distinct()->union($other)->build(); - $this->assertEquals('SELECT DISTINCT * FROM `a` UNION SELECT * FROM `b`', $result['query']); + $this->assertEquals('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result['query']); $this->assertEquals([], $result['bindings']); } @@ -5968,7 +5968,7 @@ public function testUnionWithConditionProvider(): void ->union($sub) ->build(); // Sub-query should include the condition provider - $this->assertStringContainsString('UNION SELECT * FROM `b` WHERE _deleted = ?', $result['query']); + $this->assertStringContainsString('UNION (SELECT * FROM `b` WHERE _deleted = ?)', $result['query']); $this->assertEquals([0], $result['bindings']); } From 4330afd1fa0890a297b6c1da8ea56e3705c0cd62 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 5 Mar 2026 11:47:48 +1300 Subject: [PATCH 11/29] (feat): Add hook interface system with FilterHook, AttributeHook, and built-in implementations --- src/Query/Condition.php | 26 ++++++++ src/Query/Hook.php | 7 +++ src/Query/Hook/AttributeHook.php | 10 +++ src/Query/Hook/AttributeMapHook.php | 16 +++++ src/Query/Hook/FilterHook.php | 11 ++++ src/Query/Hook/PermissionFilterHook.php | 33 ++++++++++ src/Query/Hook/TenantFilterHook.php | 27 ++++++++ tests/Query/ConditionTest.php | 35 +++++++++++ tests/Query/Hook/AttributeHookTest.php | 35 +++++++++++ tests/Query/Hook/FilterHookTest.php | 82 +++++++++++++++++++++++++ 10 files changed, 282 insertions(+) create mode 100644 src/Query/Condition.php create mode 100644 src/Query/Hook.php create mode 100644 src/Query/Hook/AttributeHook.php create mode 100644 src/Query/Hook/AttributeMapHook.php create mode 100644 src/Query/Hook/FilterHook.php create mode 100644 src/Query/Hook/PermissionFilterHook.php create mode 100644 src/Query/Hook/TenantFilterHook.php create mode 100644 tests/Query/ConditionTest.php create mode 100644 tests/Query/Hook/AttributeHookTest.php create mode 100644 tests/Query/Hook/FilterHookTest.php diff --git a/src/Query/Condition.php b/src/Query/Condition.php new file mode 100644 index 0000000..07ecb64 --- /dev/null +++ b/src/Query/Condition.php @@ -0,0 +1,26 @@ + $bindings + */ + public function __construct( + protected string $expression, + protected array $bindings = [], + ) { + } + + public function getExpression(): string + { + return $this->expression; + } + + /** @return list */ + public function getBindings(): array + { + return $this->bindings; + } +} diff --git a/src/Query/Hook.php b/src/Query/Hook.php new file mode 100644 index 0000000..c38dd67 --- /dev/null +++ b/src/Query/Hook.php @@ -0,0 +1,7 @@ + $map */ + public function __construct(protected array $map) + { + } + + public function resolve(string $attribute): string + { + return $this->map[$attribute] ?? $attribute; + } +} diff --git a/src/Query/Hook/FilterHook.php b/src/Query/Hook/FilterHook.php new file mode 100644 index 0000000..ddc232b --- /dev/null +++ b/src/Query/Hook/FilterHook.php @@ -0,0 +1,11 @@ + $roles + */ + public function __construct( + protected string $namespace, + protected array $roles, + protected string $type = 'read', + protected string $documentColumn = '_uid', + ) { + } + + public function filter(string $table): Condition + { + if (empty($this->roles)) { + return new Condition('1 = 0'); + } + + $placeholders = implode(', ', array_fill(0, count($this->roles), '?')); + + return new Condition( + "{$this->documentColumn} IN (SELECT DISTINCT _document FROM {$this->namespace}_{$table}_perms WHERE _permission IN ({$placeholders}) AND _type = ?)", + [...$this->roles, $this->type], + ); + } +} diff --git a/src/Query/Hook/TenantFilterHook.php b/src/Query/Hook/TenantFilterHook.php new file mode 100644 index 0000000..7575ed2 --- /dev/null +++ b/src/Query/Hook/TenantFilterHook.php @@ -0,0 +1,27 @@ + $tenantIds + */ + public function __construct( + protected array $tenantIds, + protected string $column = '_tenant', + ) { + } + + public function filter(string $table): Condition + { + $placeholders = implode(', ', array_fill(0, count($this->tenantIds), '?')); + + return new Condition( + "{$this->column} IN ({$placeholders})", + $this->tenantIds, + ); + } +} diff --git a/tests/Query/ConditionTest.php b/tests/Query/ConditionTest.php new file mode 100644 index 0000000..4ce3e81 --- /dev/null +++ b/tests/Query/ConditionTest.php @@ -0,0 +1,35 @@ +assertEquals('status = ?', $condition->getExpression()); + } + + public function testGetBindings(): void + { + $condition = new Condition('status = ?', ['active']); + $this->assertEquals(['active'], $condition->getBindings()); + } + + public function testEmptyBindings(): void + { + $condition = new Condition('1 = 1'); + $this->assertEquals('1 = 1', $condition->getExpression()); + $this->assertEquals([], $condition->getBindings()); + } + + public function testMultipleBindings(): void + { + $condition = new Condition('age BETWEEN ? AND ?', [18, 65]); + $this->assertEquals('age BETWEEN ? AND ?', $condition->getExpression()); + $this->assertEquals([18, 65], $condition->getBindings()); + } +} diff --git a/tests/Query/Hook/AttributeHookTest.php b/tests/Query/Hook/AttributeHookTest.php new file mode 100644 index 0000000..453c51a --- /dev/null +++ b/tests/Query/Hook/AttributeHookTest.php @@ -0,0 +1,35 @@ + '_uid', + '$createdAt' => '_createdAt', + ]); + + $this->assertEquals('_uid', $hook->resolve('$id')); + $this->assertEquals('_createdAt', $hook->resolve('$createdAt')); + } + + public function testUnmappedPassthrough(): void + { + $hook = new AttributeMapHook(['$id' => '_uid']); + + $this->assertEquals('name', $hook->resolve('name')); + $this->assertEquals('status', $hook->resolve('status')); + } + + public function testEmptyMap(): void + { + $hook = new AttributeMapHook([]); + + $this->assertEquals('anything', $hook->resolve('anything')); + } +} diff --git a/tests/Query/Hook/FilterHookTest.php b/tests/Query/Hook/FilterHookTest.php new file mode 100644 index 0000000..1e02b8a --- /dev/null +++ b/tests/Query/Hook/FilterHookTest.php @@ -0,0 +1,82 @@ +filter('users'); + + $this->assertEquals('_tenant IN (?)', $condition->getExpression()); + $this->assertEquals(['t1'], $condition->getBindings()); + } + + public function testTenantMultipleIds(): void + { + $hook = new TenantFilterHook(['t1', 't2', 't3']); + $condition = $hook->filter('users'); + + $this->assertEquals('_tenant IN (?, ?, ?)', $condition->getExpression()); + $this->assertEquals(['t1', 't2', 't3'], $condition->getBindings()); + } + + public function testTenantCustomColumn(): void + { + $hook = new TenantFilterHook(['t1'], 'organization_id'); + $condition = $hook->filter('users'); + + $this->assertEquals('organization_id IN (?)', $condition->getExpression()); + $this->assertEquals(['t1'], $condition->getBindings()); + } + + // ── PermissionFilterHook ── + + public function testPermissionWithRoles(): void + { + $hook = new PermissionFilterHook('mydb', ['role:admin', 'role:user']); + $condition = $hook->filter('documents'); + + $this->assertEquals( + '_uid IN (SELECT DISTINCT _document FROM mydb_documents_perms WHERE _permission IN (?, ?) AND _type = ?)', + $condition->getExpression() + ); + $this->assertEquals(['role:admin', 'role:user', 'read'], $condition->getBindings()); + } + + public function testPermissionEmptyRoles(): void + { + $hook = new PermissionFilterHook('mydb', []); + $condition = $hook->filter('documents'); + + $this->assertEquals('1 = 0', $condition->getExpression()); + $this->assertEquals([], $condition->getBindings()); + } + + public function testPermissionCustomType(): void + { + $hook = new PermissionFilterHook('mydb', ['role:admin'], 'write'); + $condition = $hook->filter('documents'); + + $this->assertEquals( + '_uid IN (SELECT DISTINCT _document FROM mydb_documents_perms WHERE _permission IN (?) AND _type = ?)', + $condition->getExpression() + ); + $this->assertEquals(['role:admin', 'write'], $condition->getBindings()); + } + + public function testPermissionCustomDocumentColumn(): void + { + $hook = new PermissionFilterHook('mydb', ['role:admin'], 'read', '_doc_id'); + $condition = $hook->filter('documents'); + + $this->assertStringStartsWith('_doc_id IN', $condition->getExpression()); + } +} From 57b17ca83e29577958fdb3b4732d4b89dde628f2 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 5 Mar 2026 11:47:55 +1300 Subject: [PATCH 12/29] (refactor): Replace closure-based extension API with hook system in Builder --- src/Query/Builder.php | 49 ++- tests/Query/Builder/ClickHouseTest.php | 229 +++++++++--- tests/Query/Builder/SQLTest.php | 497 +++++++++++++++++++------ 3 files changed, 589 insertions(+), 186 deletions(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index f960798..8249648 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -3,6 +3,8 @@ namespace Utopia\Query; use Closure; +use Utopia\Query\Hook\AttributeHook; +use Utopia\Query\Hook\FilterHook; abstract class Builder implements Compiler { @@ -23,12 +25,11 @@ abstract class Builder implements Compiler */ protected array $unions = []; - protected ?Closure $attributeResolver = null; + /** @var list */ + protected array $filterHooks = []; - /** - * @var array - */ - protected array $conditionProviders = []; + /** @var list */ + protected array $attributeHooks = []; // ── Abstract (dialect-specific) ── @@ -163,19 +164,14 @@ public function queries(array $queries): static return $this; } - public function setAttributeResolver(Closure $resolver): static + public function addHook(Hook $hook): static { - $this->attributeResolver = $resolver; - - return $this; - } - - /** - * @param Closure(string): array{0: string, 1: list} $provider - */ - public function addConditionProvider(Closure $provider): static - { - $this->conditionProviders[] = $provider; + if ($hook instanceof FilterHook) { + $this->filterHooks[] = $hook; + } + if ($hook instanceof AttributeHook) { + $this->attributeHooks[] = $hook; + } return $this; } @@ -395,11 +391,10 @@ public function build(): array $whereClauses[] = $this->compileFilter($filter); } - foreach ($this->conditionProviders as $provider) { - /** @var array{0: string, 1: list} $result */ - $result = $provider($this->table); - $whereClauses[] = $result[0]; - foreach ($result[1] as $binding) { + foreach ($this->filterHooks as $hook) { + $condition = $hook->filter($this->table); + $whereClauses[] = $condition->getExpression(); + foreach ($condition->getBindings() as $binding) { $this->addBinding($binding); } } @@ -665,9 +660,8 @@ public function compileJoin(Query $query): string protected function resolveAttribute(string $attribute): string { - if ($this->attributeResolver !== null) { - /** @var string */ - return ($this->attributeResolver)($attribute); + foreach ($this->attributeHooks as $hook) { + $attribute = $hook->resolve($attribute); } return $attribute; @@ -795,8 +789,9 @@ private function compileBetween(string $attribute, array $values, bool $not): st */ private function compileLike(string $attribute, array $values, string $prefix, string $suffix, bool $not): string { - /** @var string $val */ - $val = $this->escapeLikeValue($values[0]); + /** @var string $rawVal */ + $rawVal = $values[0]; + $val = $this->escapeLikeValue($rawVal); $this->addBinding($prefix . $val . $suffix); $keyword = $not ? 'NOT LIKE' : 'LIKE'; diff --git a/tests/Query/Builder/ClickHouseTest.php b/tests/Query/Builder/ClickHouseTest.php index b8a4961..49fe057 100644 --- a/tests/Query/Builder/ClickHouseTest.php +++ b/tests/Query/Builder/ClickHouseTest.php @@ -5,7 +5,10 @@ use PHPUnit\Framework\TestCase; use Utopia\Query\Builder\ClickHouse as Builder; use Utopia\Query\Compiler; +use Utopia\Query\Condition; use Utopia\Query\Exception; +use Utopia\Query\Hook\AttributeMapHook; +use Utopia\Query\Hook\FilterHook; use Utopia\Query\Query; class ClickHouseTest extends TestCase @@ -361,10 +364,7 @@ public function testAttributeResolver(): void { $result = (new Builder()) ->from('events') - ->setAttributeResolver(fn (string $a): string => match ($a) { - '$id' => '_uid', - default => $a, - }) + ->addHook(new AttributeMapHook(['$id' => '_uid'])) ->filter([Query::equal('$id', ['abc'])]) ->build(); @@ -378,12 +378,16 @@ public function testAttributeResolver(): void public function testConditionProvider(): void { + $hook = new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }; + $result = (new Builder()) ->from('events') - ->addConditionProvider(fn (string $table): array => [ - '_tenant = ?', - ['t1'], - ]) + ->addHook($hook) ->filter([Query::equal('status', ['active'])]) ->build(); @@ -927,7 +931,12 @@ public function testPrewhereBindingOrderWithProvider(): void ->from('events') ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('count', 5)]) - ->addConditionProvider(fn (string $table): array => ['tenant_id = ?', ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant_id = ?', ['t1']); + } + }) ->build(); $this->assertEquals(['click', 5, 't1'], $result['bindings']); @@ -956,7 +965,12 @@ public function testPrewhereBindingOrderComplex(): void ->from('events') ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('count', 5)]) - ->addConditionProvider(fn (string $table): array => ['tenant = ?', ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) ->cursorAfter('cur1') ->sortAsc('_cursor') ->count('*', 'total') @@ -978,10 +992,9 @@ public function testPrewhereWithAttributeResolver(): void { $result = (new Builder()) ->from('events') - ->setAttributeResolver(fn (string $a): string => match ($a) { + ->addHook(new AttributeMapHook([ '$id' => '_uid', - default => $a, - }) + ])) ->prewhere([Query::equal('$id', ['abc'])]) ->build(); @@ -1298,7 +1311,12 @@ public function testFinalWithAttributeResolver(): void $result = (new Builder()) ->from('events') ->final() - ->setAttributeResolver(fn (string $a): string => 'col_' . $a) + ->addHook(new class () implements \Utopia\Query\Hook\AttributeHook { + public function resolve(string $attribute): string + { + return 'col_' . $attribute; + } + }) ->filter([Query::equal('status', ['active'])]) ->build(); @@ -1311,7 +1329,12 @@ public function testFinalWithConditionProvider(): void $result = (new Builder()) ->from('events') ->final() - ->addConditionProvider(fn (string $table): array => ['deleted = ?', [0]]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('deleted = ?', [0]); + } + }) ->build(); $this->assertStringContainsString('FROM `events` FINAL', $result['query']); @@ -1611,7 +1634,12 @@ public function testSampleWithAttributeResolver(): void $result = (new Builder()) ->from('events') ->sample(0.5) - ->setAttributeResolver(fn (string $a): string => 'r_' . $a) + ->addHook(new class () implements \Utopia\Query\Hook\AttributeHook { + public function resolve(string $attribute): string + { + return 'r_' . $attribute; + } + }) ->filter([Query::equal('col', ['v'])]) ->build(); @@ -1716,7 +1744,12 @@ public function testRegexWithAttributeResolver(): void { $result = (new Builder()) ->from('logs') - ->setAttributeResolver(fn (string $a): string => 'col_' . $a) + ->addHook(new class () implements \Utopia\Query\Hook\AttributeHook { + public function resolve(string $attribute): string + { + return 'col_' . $attribute; + } + }) ->filter([Query::regex('msg', 'test')]) ->build(); @@ -2527,10 +2560,9 @@ public function testAggregationAttributeResolverPrewhere(): void { $result = (new Builder()) ->from('events') - ->setAttributeResolver(fn (string $a): string => match ($a) { + ->addHook(new AttributeMapHook([ 'amt' => 'amount_cents', - default => $a, - }) + ])) ->prewhere([Query::equal('type', ['sale'])]) ->sum('amt', 'total') ->build(); @@ -2543,7 +2575,12 @@ public function testAggregationConditionProviderPrewhere(): void $result = (new Builder()) ->from('events') ->prewhere([Query::equal('type', ['sale'])]) - ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) ->count('*', 'cnt') ->build(); @@ -2726,10 +2763,9 @@ public function testJoinAttributeResolverPrewhere(): void { $result = (new Builder()) ->from('events') - ->setAttributeResolver(fn (string $a): string => match ($a) { + ->addHook(new AttributeMapHook([ 'uid' => 'user_id', - default => $a, - }) + ])) ->join('users', 'events.uid', 'users.id') ->prewhere([Query::equal('uid', ['abc'])]) ->build(); @@ -2743,7 +2779,12 @@ public function testJoinConditionProviderPrewhere(): void ->from('events') ->join('users', 'events.uid', 'users.id') ->prewhere([Query::equal('type', ['click'])]) - ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) ->build(); $this->assertStringContainsString('PREWHERE', $result['query']); @@ -3185,10 +3226,15 @@ public function testResetClearsAllThreeTogether(): void public function testResetPreservesAttributeResolver(): void { - $resolver = fn (string $a): string => 'r_' . $a; + $hook = new class () implements \Utopia\Query\Hook\AttributeHook { + public function resolve(string $attribute): string + { + return 'r_' . $attribute; + } + }; $builder = (new Builder()) ->from('events') - ->setAttributeResolver($resolver) + ->addHook($hook) ->final(); $builder->build(); $builder->reset(); @@ -3201,7 +3247,12 @@ public function testResetPreservesConditionProviders(): void { $builder = (new Builder()) ->from('events') - ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) ->final(); $builder->build(); $builder->reset(); @@ -3454,7 +3505,12 @@ public function testProviderWithPrewhere(): void $result = (new Builder()) ->from('events') ->prewhere([Query::equal('type', ['click'])]) - ->addConditionProvider(fn (string $t): array => ['deleted = ?', [0]]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('deleted = ?', [0]); + } + }) ->build(); $this->assertStringContainsString('PREWHERE', $result['query']); @@ -3466,7 +3522,12 @@ public function testProviderWithFinal(): void $result = (new Builder()) ->from('events') ->final() - ->addConditionProvider(fn (string $t): array => ['deleted = ?', [0]]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('deleted = ?', [0]); + } + }) ->build(); $this->assertStringContainsString('FINAL', $result['query']); @@ -3478,7 +3539,12 @@ public function testProviderWithSample(): void $result = (new Builder()) ->from('events') ->sample(0.5) - ->addConditionProvider(fn (string $t): array => ['deleted = ?', [0]]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('deleted = ?', [0]); + } + }) ->build(); $this->assertStringContainsString('SAMPLE 0.5', $result['query']); @@ -3491,7 +3557,12 @@ public function testProviderPrewhereWhereBindingOrder(): void ->from('events') ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('count', 5)]) - ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) ->build(); // prewhere, filter, provider @@ -3503,8 +3574,18 @@ public function testMultipleProvidersPrewhereBindingOrder(): void $result = (new Builder()) ->from('events') ->prewhere([Query::equal('type', ['click'])]) - ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) - ->addConditionProvider(fn (string $t): array => ['org = ?', ['o1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['o1']); + } + }) ->build(); $this->assertEquals(['click', 't1', 'o1'], $result['bindings']); @@ -3515,7 +3596,12 @@ public function testProviderPrewhereCursorLimitBindingOrder(): void $result = (new Builder()) ->from('events') ->prewhere([Query::equal('type', ['click'])]) - ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) ->cursorAfter('cur1') ->sortAsc('_cursor') ->limit(10) @@ -3536,7 +3622,12 @@ public function testProviderAllClickHouseFeatures(): void ->sample(0.1) ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('count', 0)]) - ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) ->build(); $this->assertStringContainsString('FINAL SAMPLE 0.1', $result['query']); @@ -3549,7 +3640,12 @@ public function testProviderPrewhereAggregation(): void $result = (new Builder()) ->from('events') ->prewhere([Query::equal('type', ['click'])]) - ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) ->count('*', 'cnt') ->build(); @@ -3564,7 +3660,12 @@ public function testProviderJoinsPrewhere(): void ->from('events') ->join('users', 'events.uid', 'users.id') ->prewhere([Query::equal('type', ['click'])]) - ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) ->build(); $this->assertStringContainsString('JOIN', $result['query']); @@ -3577,10 +3678,12 @@ public function testProviderReferencesTableNameFinal(): void $result = (new Builder()) ->from('events') ->final() - ->addConditionProvider(fn (string $table): array => [ - $table . '.deleted = ?', - [0], - ]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition($table . '.deleted = ?', [0]); + } + }) ->build(); $this->assertStringContainsString('events.deleted = ?', $result['query']); @@ -3676,7 +3779,12 @@ public function testCursorPrewhereProviderBindingOrder(): void $result = (new Builder()) ->from('events') ->prewhere([Query::equal('type', ['click'])]) - ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) ->cursorAfter('cur1') ->sortAsc('_cursor') ->build(); @@ -4291,7 +4399,12 @@ public function testSampleWithAllBindingTypes(): void ->from('events') ->sample(0.1) ->prewhere([Query::equal('type', ['click'])]) - ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) ->cursorAfter('cur1') ->sortAsc('_cursor') ->filter([Query::greaterThan('count', 5)]) @@ -4686,7 +4799,12 @@ public function testPrewhereBindingOrderWithProviderAndCursor(): void { $result = (new Builder())->from('t') ->prewhere([Query::equal('type', ['click'])]) - ->addConditionProvider(fn (string $t) => ["_tenant = ?", ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) ->cursorAfter('abc') ->sortAsc('_cursor') ->build(); @@ -4779,7 +4897,12 @@ public function testConditionProviderInWhereNotPrewhere(): void { $result = (new Builder())->from('t') ->prewhere([Query::equal('type', ['click'])]) - ->addConditionProvider(fn (string $t) => ["_tenant = ?", ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) ->build(); $query = $result['query']; $prewherePos = strpos($query, 'PREWHERE'); @@ -4794,7 +4917,12 @@ public function testConditionProviderInWhereNotPrewhere(): void public function testConditionProviderWithNoFiltersClickHouse(): void { $result = (new Builder())->from('t') - ->addConditionProvider(fn (string $t) => ["_deleted = ?", [0]]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_deleted = ?', [0]); + } + }) ->build(); $this->assertEquals('SELECT * FROM `t` WHERE _deleted = ?', $result['query']); $this->assertEquals([0], $result['bindings']); @@ -4971,7 +5099,12 @@ public function testConditionProviderPersistsAfterReset(): void $builder = (new Builder()) ->from('t') ->final() - ->addConditionProvider(fn (string $t) => ["_tenant = ?", ['t1']]); + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }); $builder->build(); $builder->reset()->from('other'); $result = $builder->build(); diff --git a/tests/Query/Builder/SQLTest.php b/tests/Query/Builder/SQLTest.php index 24c84e4..ec53a58 100644 --- a/tests/Query/Builder/SQLTest.php +++ b/tests/Query/Builder/SQLTest.php @@ -5,6 +5,9 @@ use PHPUnit\Framework\TestCase; use Utopia\Query\Builder\SQL as Builder; use Utopia\Query\Compiler; +use Utopia\Query\Condition; +use Utopia\Query\Hook\AttributeMapHook; +use Utopia\Query\Hook\FilterHook; use Utopia\Query\Query; class SQLTest extends TestCase @@ -574,11 +577,10 @@ public function testAttributeResolver(): void { $result = (new Builder()) ->from('users') - ->setAttributeResolver(fn (string $a): string => match ($a) { + ->addHook(new AttributeMapHook([ '$id' => '_uid', '$createdAt' => '_createdAt', - default => $a, - }) + ])) ->filter([Query::equal('$id', ['abc'])]) ->sortAsc('$createdAt') ->build(); @@ -590,6 +592,59 @@ public function testAttributeResolver(): void $this->assertEquals(['abc'], $result['bindings']); } + public function testMultipleAttributeHooksChain(): void + { + $prefixHook = new class () implements \Utopia\Query\Hook\AttributeHook { + public function resolve(string $attribute): string + { + return 'col_' . $attribute; + } + }; + + $result = (new Builder()) + ->from('t') + ->addHook(new AttributeMapHook(['name' => 'full_name'])) + ->addHook($prefixHook) + ->filter([Query::equal('name', ['Alice'])]) + ->build(); + + // First hook maps name→full_name, second prepends col_ + $this->assertEquals( + 'SELECT * FROM `t` WHERE `col_full_name` IN (?)', + $result['query'] + ); + } + + public function testDualInterfaceHook(): void + { + $hook = new class () implements \Utopia\Query\Hook\FilterHook, \Utopia\Query\Hook\AttributeHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + + public function resolve(string $attribute): string + { + return match ($attribute) { + '$id' => '_uid', + default => $attribute, + }; + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook) + ->filter([Query::equal('$id', ['abc'])]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `users` WHERE `_uid` IN (?) AND _tenant = ?', + $result['query'] + ); + $this->assertEquals(['abc', 't1'], $result['bindings']); + } + public function testWrapChar(): void { $result = (new Builder()) @@ -607,12 +662,18 @@ public function testWrapChar(): void public function testConditionProvider(): void { + $hook = new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition( + "_uid IN (SELECT _document FROM {$table}_perms WHERE _type = 'read')", + ); + } + }; + $result = (new Builder()) ->from('users') - ->addConditionProvider(fn (string $table): array => [ - "_uid IN (SELECT _document FROM {$table}_perms WHERE _type = 'read')", - [], - ]) + ->addHook($hook) ->filter([Query::equal('status', ['active'])]) ->build(); @@ -625,12 +686,16 @@ public function testConditionProvider(): void public function testConditionProviderWithBindings(): void { + $hook = new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['tenant_abc']); + } + }; + $result = (new Builder()) ->from('docs') - ->addConditionProvider(fn (string $table): array => [ - '_tenant = ?', - ['tenant_abc'], - ]) + ->addHook($hook) ->filter([Query::equal('status', ['active'])]) ->build(); @@ -638,25 +703,29 @@ public function testConditionProviderWithBindings(): void 'SELECT * FROM `docs` WHERE `status` IN (?) AND _tenant = ?', $result['query'] ); - // filter bindings first, then provider bindings + // filter bindings first, then hook bindings $this->assertEquals(['active', 'tenant_abc'], $result['bindings']); } public function testBindingOrderingWithProviderAndCursor(): void { + $hook = new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }; + $result = (new Builder()) ->from('docs') - ->addConditionProvider(fn (string $table): array => [ - '_tenant = ?', - ['t1'], - ]) + ->addHook($hook) ->filter([Query::equal('status', ['active'])]) ->cursorAfter('cursor_val') ->limit(10) ->offset(5) ->build(); - // binding order: filter, provider, cursor, limit, offset + // binding order: filter, hook, cursor, limit, offset $this->assertEquals(['active', 't1', 'cursor_val', 10, 5], $result['bindings']); } @@ -1640,12 +1709,16 @@ public function testCompileJoinUnsupportedType(): void public function testBindingOrderFilterProviderCursorLimitOffset(): void { + $hook = new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['tenant1']); + } + }; + $result = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $table): array => [ - '_tenant = ?', - ['tenant1'], - ]) + ->addHook($hook) ->filter([ Query::equal('a', ['x']), Query::greaterThan('b', 5), @@ -1655,16 +1728,29 @@ public function testBindingOrderFilterProviderCursorLimitOffset(): void ->offset(20) ->build(); - // Order: filter bindings, provider bindings, cursor, limit, offset + // Order: filter bindings, hook bindings, cursor, limit, offset $this->assertEquals(['x', 5, 'tenant1', 'cursor_abc', 10, 20], $result['bindings']); } public function testBindingOrderMultipleProviders(): void { + $hook1 = new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('p1 = ?', ['v1']); + } + }; + $hook2 = new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('p2 = ?', ['v2']); + } + }; + $result = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $table): array => ['p1 = ?', ['v1']]) - ->addConditionProvider(fn (string $table): array => ['p2 = ?', ['v2']]) + ->addHook($hook1) + ->addHook($hook2) ->filter([Query::equal('a', ['x'])]) ->build(); @@ -1705,10 +1791,17 @@ public function testBindingOrderComplexMixed(): void { $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $hook = new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_org = ?', ['org1']); + } + }; + $result = (new Builder()) ->from('orders') ->count('*', 'cnt') - ->addConditionProvider(fn (string $t): array => ['_org = ?', ['org1']]) + ->addHook($hook) ->filter([Query::equal('status', ['paid'])]) ->groupBy(['status']) ->having([Query::greaterThan('cnt', 1)]) @@ -1718,7 +1811,7 @@ public function testBindingOrderComplexMixed(): void ->union($sub) ->build(); - // filter, provider, cursor, having, limit, offset, union + // filter, hook, cursor, having, limit, offset, union $this->assertEquals(['paid', 'org1', 'cur1', 1, 10, 5, 2023], $result['bindings']); } @@ -1728,10 +1821,7 @@ public function testAttributeResolverWithAggregation(): void { $result = (new Builder()) ->from('t') - ->setAttributeResolver(fn (string $a): string => match ($a) { - '$price' => '_price', - default => $a, - }) + ->addHook(new AttributeMapHook(['$price' => '_price'])) ->sum('$price', 'total') ->build(); @@ -1742,10 +1832,7 @@ public function testAttributeResolverWithGroupBy(): void { $result = (new Builder()) ->from('t') - ->setAttributeResolver(fn (string $a): string => match ($a) { - '$status' => '_status', - default => $a, - }) + ->addHook(new AttributeMapHook(['$status' => '_status'])) ->count('*', 'total') ->groupBy(['$status']) ->build(); @@ -1760,11 +1847,10 @@ public function testAttributeResolverWithJoin(): void { $result = (new Builder()) ->from('t') - ->setAttributeResolver(fn (string $a): string => match ($a) { + ->addHook(new AttributeMapHook([ '$id' => '_uid', '$ref' => '_ref', - default => $a, - }) + ])) ->join('other', '$id', '$ref') ->build(); @@ -1778,10 +1864,7 @@ public function testAttributeResolverWithHaving(): void { $result = (new Builder()) ->from('t') - ->setAttributeResolver(fn (string $a): string => match ($a) { - '$total' => '_total', - default => $a, - }) + ->addHook(new AttributeMapHook(['$total' => '_total'])) ->count('*', 'cnt') ->groupBy(['status']) ->having([Query::greaterThan('$total', 5)]) @@ -1837,13 +1920,17 @@ public function testWrapCharEmpty(): void public function testConditionProviderWithJoins(): void { + $hook = new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('users.org_id = ?', ['org1']); + } + }; + $result = (new Builder()) ->from('users') ->join('orders', 'users.id', 'orders.user_id') - ->addConditionProvider(fn (string $table): array => [ - 'users.org_id = ?', - ['org1'], - ]) + ->addHook($hook) ->filter([Query::greaterThan('orders.total', 100)]) ->build(); @@ -1856,13 +1943,17 @@ public function testConditionProviderWithJoins(): void public function testConditionProviderWithAggregation(): void { + $hook = new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('org_id = ?', ['org1']); + } + }; + $result = (new Builder()) ->from('orders') ->count('*', 'total') - ->addConditionProvider(fn (string $table): array => [ - 'org_id = ?', - ['org1'], - ]) + ->addHook($hook) ->groupBy(['status']) ->build(); @@ -1888,18 +1979,25 @@ public function testMultipleBuildsConsistentOutput(): void // ── Reset behavior ── - public function testResetDoesNotClearWrapCharOrResolver(): void + public function testResetDoesNotClearWrapCharOrHooks(): void { + $hook = new class () implements \Utopia\Query\Hook\AttributeHook { + public function resolve(string $attribute): string + { + return '_' . $attribute; + } + }; + $builder = (new Builder()) ->from('t') ->setWrapChar('"') - ->setAttributeResolver(fn (string $a): string => '_' . $a) + ->addHook($hook) ->filter([Query::equal('x', [1])]); $builder->build(); $builder->reset(); - // wrapChar and resolver should persist since reset() only clears queries/bindings/table/unions + // wrapChar and hooks should persist since reset() only clears queries/bindings/table/unions $result = $builder->from('t2')->filter([Query::equal('y', [2])])->build(); $this->assertEquals('SELECT * FROM "t2" WHERE "_y" IN (?)', $result['query']); } @@ -1961,7 +2059,12 @@ public function testKitchenSinkQuery(): void Query::equal('orders.status', ['paid']), Query::greaterThan('orders.total', 0), ]) - ->addConditionProvider(fn (string $t): array => ['org = ?', ['o1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['o1']); + } + }) ->groupBy(['status']) ->having([Query::greaterThan('cnt', 1)]) ->sortDesc('sum_total') @@ -2221,10 +2324,9 @@ public function testRegexWithAttributeResolver(): void { $result = (new Builder()) ->from('t') - ->setAttributeResolver(fn (string $a): string => match ($a) { + ->addHook(new AttributeMapHook([ '$slug' => '_slug', - default => $a, - }) + ])) ->filter([Query::regex('$slug', '^test')]) ->build(); @@ -2395,10 +2497,9 @@ public function testSearchWithAttributeResolver(): void { $result = (new Builder()) ->from('t') - ->setAttributeResolver(fn (string $a): string => match ($a) { + ->addHook(new AttributeMapHook([ '$body' => '_body', - default => $a, - }) + ])) ->filter([Query::search('$body', 'hello')]) ->build(); @@ -2653,7 +2754,12 @@ public function testRandomSortWithAttributeResolver(): void { $result = (new Builder()) ->from('t') - ->setAttributeResolver(fn (string $a): string => '_' . $a) + ->addHook(new class () implements \Utopia\Query\Hook\AttributeHook { + public function resolve(string $attribute): string + { + return '_' . $attribute; + } + }) ->sortRandom() ->build(); @@ -3035,10 +3141,12 @@ public function testWrapCharWithConditionProviderNotWrapped(): void $result = (new Builder()) ->setWrapChar('"') ->from('t') - ->addConditionProvider(fn (string $table): array => [ - 'raw_condition = 1', - [], - ]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('raw_condition = 1', []); + } + }) ->build(); $this->assertStringContainsString('WHERE raw_condition = 1', $result['query']); @@ -4000,10 +4108,9 @@ public function testAggregationWithAttributeResolver(): void { $result = (new Builder()) ->from('t') - ->setAttributeResolver(fn (string $a): string => match ($a) { + ->addHook(new AttributeMapHook([ '$amount' => '_amount', - default => $a, - }) + ])) ->sum('$amount', 'total') ->build(); @@ -4155,11 +4262,10 @@ public function testJoinWithAttributeResolverOnJoinColumns(): void { $result = (new Builder()) ->from('t') - ->setAttributeResolver(fn (string $a): string => match ($a) { + ->addHook(new AttributeMapHook([ '$id' => '_uid', '$ref' => '_ref_id', - default => $a, - }) + ])) ->join('other', '$id', '$ref') ->build(); @@ -4355,11 +4461,21 @@ public function testUnionWithConditionProviders(): void { $sub = (new Builder()) ->from('other') - ->addConditionProvider(fn (string $t): array => ['org = ?', ['org2']]); + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org2']); + } + }); $result = (new Builder()) ->from('main') - ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) ->union($sub) ->build(); @@ -4808,9 +4924,24 @@ public function testThreeConditionProviders(): void { $result = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $t): array => ['p1 = ?', ['v1']]) - ->addConditionProvider(fn (string $t): array => ['p2 = ?', ['v2']]) - ->addConditionProvider(fn (string $t): array => ['p3 = ?', ['v3']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('p1 = ?', ['v1']); + } + }) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('p2 = ?', ['v2']); + } + }) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('p3 = ?', ['v3']); + } + }) ->build(); $this->assertEquals( @@ -4824,7 +4955,12 @@ public function testProviderReturningEmptyConditionString(): void { $result = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $t): array => ['', []]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('', []); + } + }) ->build(); // Empty string still appears as a WHERE clause element @@ -4835,10 +4971,12 @@ public function testProviderWithManyBindings(): void { $result = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $t): array => [ - 'a IN (?, ?, ?, ?, ?)', - [1, 2, 3, 4, 5], - ]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('a IN (?, ?, ?, ?, ?)', [1, 2, 3, 4, 5]); + } + }) ->build(); $this->assertEquals( @@ -4853,7 +4991,12 @@ public function testProviderCombinedWithCursorFilterHaving(): void $result = (new Builder()) ->from('t') ->count('*', 'cnt') - ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) ->filter([Query::equal('status', ['active'])]) ->cursorAfter('cur1') ->groupBy(['status']) @@ -4871,7 +5014,12 @@ public function testProviderCombinedWithJoins(): void $result = (new Builder()) ->from('users') ->join('orders', 'users.id', 'orders.uid') - ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) ->build(); $this->assertStringContainsString('JOIN `orders`', $result['query']); @@ -4885,7 +5033,12 @@ public function testProviderCombinedWithUnions(): void $result = (new Builder()) ->from('current') - ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) ->union($sub) ->build(); @@ -4899,7 +5052,12 @@ public function testProviderCombinedWithAggregations(): void $result = (new Builder()) ->from('orders') ->count('*', 'total') - ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) ->groupBy(['status']) ->build(); @@ -4911,10 +5069,12 @@ public function testProviderReferencesTableName(): void { $result = (new Builder()) ->from('users') - ->addConditionProvider(fn (string $table): array => [ - "EXISTS (SELECT 1 FROM {$table}_perms WHERE type = ?)", - ['read'], - ]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition("EXISTS (SELECT 1 FROM {$table}_perms WHERE type = ?)", ['read']); + } + }) ->build(); $this->assertStringContainsString('users_perms', $result['query']); @@ -4926,7 +5086,12 @@ public function testProviderWithWrapCharProviderSqlIsLiteral(): void $result = (new Builder()) ->setWrapChar('"') ->from('t') - ->addConditionProvider(fn (string $t): array => ['raw_col = ?', [1]]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('raw_col = ?', [1]); + } + }) ->build(); // Provider SQL is NOT wrapped - only the FROM clause is @@ -4938,8 +5103,18 @@ public function testProviderBindingOrderWithComplexQuery(): void { $result = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $t): array => ['p1 = ?', ['pv1']]) - ->addConditionProvider(fn (string $t): array => ['p2 = ?', ['pv2']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('p1 = ?', ['pv1']); + } + }) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('p2 = ?', ['pv2']); + } + }) ->filter([ Query::equal('a', ['va']), Query::greaterThan('b', 10), @@ -4957,7 +5132,12 @@ public function testProviderPreservedAcrossReset(): void { $builder = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]); + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }); $builder->build(); $builder->reset(); @@ -4971,10 +5151,30 @@ public function testFourConditionProviders(): void { $result = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $t): array => ['a = ?', [1]]) - ->addConditionProvider(fn (string $t): array => ['b = ?', [2]]) - ->addConditionProvider(fn (string $t): array => ['c = ?', [3]]) - ->addConditionProvider(fn (string $t): array => ['d = ?', [4]]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('a = ?', [1]); + } + }) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('b = ?', [2]); + } + }) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('c = ?', [3]); + } + }) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('d = ?', [4]); + } + }) ->build(); $this->assertEquals( @@ -4988,7 +5188,12 @@ public function testProviderWithNoBindings(): void { $result = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $t): array => ['1 = 1', []]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('1 = 1', []); + } + }) ->build(); $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result['query']); @@ -5003,7 +5208,12 @@ public function testResetPreservesAttributeResolver(): void { $builder = (new Builder()) ->from('t') - ->setAttributeResolver(fn (string $a): string => '_' . $a) + ->addHook(new class () implements \Utopia\Query\Hook\AttributeHook { + public function resolve(string $attribute): string + { + return '_' . $attribute; + } + }) ->filter([Query::equal('x', [1])]); $builder->build(); @@ -5017,7 +5227,12 @@ public function testResetPreservesConditionProviders(): void { $builder = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]); + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }); $builder->build(); $builder->reset(); @@ -5230,7 +5445,12 @@ public function testBuildWithConditionProducesConsistentBindings(): void { $builder = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) ->filter([Query::equal('status', ['active'])]); $result1 = $builder->build(); @@ -5337,9 +5557,24 @@ public function testBindingOrderThreeProviders(): void { $result = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $t): array => ['p1 = ?', ['pv1']]) - ->addConditionProvider(fn (string $t): array => ['p2 = ?', ['pv2']]) - ->addConditionProvider(fn (string $t): array => ['p3 = ?', ['pv3']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('p1 = ?', ['pv1']); + } + }) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('p2 = ?', ['pv2']); + } + }) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('p3 = ?', ['pv3']); + } + }) ->build(); $this->assertEquals(['pv1', 'pv2', 'pv3'], $result['bindings']); @@ -5452,7 +5687,12 @@ public function testBindingOrderFullPipelineWithEverything(): void $result = (new Builder()) ->from('orders') ->count('*', 'cnt') - ->addConditionProvider(fn (string $t): array => ['tenant = ?', ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) ->filter([ Query::equal('status', ['paid']), Query::greaterThan('total', 0), @@ -5527,7 +5767,12 @@ public function testBindingOrderWithCursorBeforeFilterAndLimit(): void { $result = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $t): array => ['org = ?', ['org1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) ->filter([Query::equal('a', ['x'])]) ->cursorBefore('my_cursor') ->limit(10) @@ -5899,7 +6144,12 @@ public function testConditionProviderWithNoFilters(): void { $result = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $table) => ["_tenant = ?", ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) ->build(); $this->assertEquals('SELECT * FROM `t` WHERE _tenant = ?', $result['query']); $this->assertEquals(['t1'], $result['bindings']); @@ -5909,7 +6159,12 @@ public function testConditionProviderWithCursorNoFilters(): void { $result = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $table) => ["_tenant = ?", ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) ->cursorAfter('abc') ->build(); $this->assertStringContainsString('_tenant = ?', $result['query']); @@ -5923,7 +6178,12 @@ public function testConditionProviderWithDistinct(): void $result = (new Builder()) ->from('t') ->distinct() - ->addConditionProvider(fn (string $table) => ["_tenant = ?", ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) ->build(); $this->assertEquals('SELECT DISTINCT * FROM `t` WHERE _tenant = ?', $result['query']); $this->assertEquals(['t1'], $result['bindings']); @@ -5933,7 +6193,12 @@ public function testConditionProviderPersistsAfterReset(): void { $builder = (new Builder()) ->from('t') - ->addConditionProvider(fn (string $table) => ["_tenant = ?", ['t1']]); + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }); $builder->build(); $builder->reset()->from('other'); $result = $builder->build(); @@ -5948,7 +6213,12 @@ public function testConditionProviderWithHaving(): void ->from('t') ->count('*', 'total') ->groupBy(['status']) - ->addConditionProvider(fn (string $table) => ["_tenant = ?", ['t1']]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) ->having([Query::greaterThan('total', 5)]) ->build(); // Provider should be in WHERE, not HAVING @@ -5962,7 +6232,12 @@ public function testUnionWithConditionProvider(): void { $sub = (new Builder()) ->from('b') - ->addConditionProvider(fn (string $table) => ["_deleted = ?", [0]]); + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_deleted = ?', [0]); + } + }); $result = (new Builder()) ->from('a') ->union($sub) From 5a5294ba219f0219f689a3928d76aca78df5512f Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 5 Mar 2026 11:48:02 +1300 Subject: [PATCH 13/29] (docs): Update README with hook system documentation --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 57ed507..7fca262 100644 --- a/README.md +++ b/README.md @@ -435,21 +435,54 @@ $errors = Query::validate($queries, ['name', 'age', 'status']); [$limit, $offset] = Query::page(3, 10); ``` -**Pluggable extensions** — customize attribute mapping, identifier wrapping, and inject extra conditions: +**Hooks** — extend the builder with reusable, testable hook classes for attribute resolution and condition injection: ```php +use Utopia\Query\Hook\AttributeMapHook; +use Utopia\Query\Hook\TenantFilterHook; +use Utopia\Query\Hook\PermissionFilterHook; + $result = (new Builder()) ->from('users') - ->setAttributeResolver(fn(string $a) => match($a) { - '$id' => '_uid', '$createdAt' => '_createdAt', default => $a - }) + ->addHook(new AttributeMapHook([ + '$id' => '_uid', + '$createdAt' => '_createdAt', + ])) + ->addHook(new TenantFilterHook(['tenant_abc'])) ->setWrapChar('"') // PostgreSQL - ->addConditionProvider(fn(string $table) => [ - "_uid IN (SELECT _document FROM {$table}_perms WHERE _type = 'read')", - [], - ]) ->filter([Query::equal('status', ['active'])]) ->build(); + +// SELECT * FROM "users" WHERE "status" IN (?) AND _tenant IN (?) +// bindings: ['active', 'tenant_abc'] +``` + +Built-in hooks: + +- `AttributeMapHook` — maps query attribute names to underlying column names +- `TenantFilterHook` — injects a tenant ID filter (multi-tenancy) +- `PermissionFilterHook` — injects a permission subquery filter + +Custom hooks implement `FilterHook` or `AttributeHook`: + +```php +use Utopia\Query\Condition; +use Utopia\Query\Hook\FilterHook; + +class SoftDeleteHook implements FilterHook +{ + public function filter(string $table): Condition + { + return new Condition('deleted_at IS NULL'); + } +} + +$result = (new Builder()) + ->from('users') + ->addHook(new SoftDeleteHook()) + ->build(); + +// SELECT * FROM `users` WHERE deleted_at IS NULL ``` ### ClickHouse Builder From b47d9d38952c8e1d45f75290e140446cf30ef36a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 5 Mar 2026 14:16:38 +1300 Subject: [PATCH 14/29] (refactor): Extract Method, OrderDirection, CursorDirection enums and introduce value objects --- src/Query/Builder.php | 170 ++- src/Query/Builder/ClickHouse.php | 14 +- src/Query/Builder/SQL.php | 11 +- src/Query/CursorDirection.php | 9 + src/Query/Hook/AttributeMapHook.php | 4 +- src/Query/Method.php | 158 +++ src/Query/OrderDirection.php | 10 + src/Query/Query.php | 671 +++--------- tests/Query/AggregationQueryTest.php | 36 +- tests/Query/Builder/ClickHouseTest.php | 1148 ++++++++++----------- tests/Query/Builder/SQLTest.php | 1310 ++++++++++++------------ tests/Query/FilterQueryTest.php | 59 +- tests/Query/JoinQueryTest.php | 22 +- tests/Query/LogicalQueryTest.php | 9 +- tests/Query/QueryHelperTest.php | 173 ++-- tests/Query/QueryParseTest.php | 63 +- tests/Query/QueryTest.php | 183 ++-- tests/Query/SelectionQueryTest.php | 17 +- tests/Query/SpatialQueryTest.php | 25 +- tests/Query/VectorQueryTest.php | 7 +- 20 files changed, 1964 insertions(+), 2135 deletions(-) create mode 100644 src/Query/CursorDirection.php create mode 100644 src/Query/Method.php create mode 100644 src/Query/OrderDirection.php diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 8249648..a300730 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -3,6 +3,9 @@ namespace Utopia\Query; use Closure; +use Utopia\Query\Builder\BuildResult; +use Utopia\Query\Builder\GroupedQueries; +use Utopia\Query\Builder\UnionClause; use Utopia\Query\Hook\AttributeHook; use Utopia\Query\Hook\FilterHook; @@ -21,7 +24,7 @@ abstract class Builder implements Compiler protected array $bindings = []; /** - * @var array}> + * @var list */ protected array $unions = []; @@ -65,9 +68,8 @@ protected function buildTableClause(): string * Hook called after JOIN clauses, before WHERE. Override to inject e.g. PREWHERE. * * @param array $parts - * @param array $grouped */ - protected function buildAfterJoins(array &$parts, array $grouped): void + protected function buildAfterJoins(array &$parts, GroupedQueries $grouped): void { // no-op by default } @@ -275,11 +277,7 @@ public function crossJoin(string $table): static public function union(self $other): static { $result = $other->build(); - $this->unions[] = [ - 'type' => 'UNION', - 'query' => $result['query'], - 'bindings' => $result['bindings'], - ]; + $this->unions[] = new UnionClause('UNION', $result->query, $result->bindings); return $this; } @@ -287,11 +285,7 @@ public function union(self $other): static public function unionAll(self $other): static { $result = $other->build(); - $this->unions[] = [ - 'type' => 'UNION ALL', - 'query' => $result['query'], - 'bindings' => $result['bindings'], - ]; + $this->unions[] = new UnionClause('UNION ALL', $result->query, $result->bindings); return $this; } @@ -318,10 +312,10 @@ public function page(int $page, int $perPage = 25): static public function toRawSql(): string { $result = $this->build(); - $sql = $result['query']; + $sql = $result->query; $offset = 0; - foreach ($result['bindings'] as $binding) { + foreach ($result->bindings as $binding) { if (\is_string($binding)) { $value = "'" . str_replace("'", "''", $binding) . "'"; } elseif (\is_int($binding) || \is_float($binding)) { @@ -342,10 +336,7 @@ public function toRawSql(): string return $sql; } - /** - * @return array{query: string, bindings: list} - */ - public function build(): array + public function build(): BuildResult { $this->bindings = []; @@ -356,27 +347,27 @@ public function build(): array // SELECT $selectParts = []; - if (! empty($grouped['aggregations'])) { - foreach ($grouped['aggregations'] as $agg) { + if (! empty($grouped->aggregations)) { + foreach ($grouped->aggregations as $agg) { $selectParts[] = $this->compileAggregate($agg); } } - if (! empty($grouped['selections'])) { - $selectParts[] = $this->compileSelect($grouped['selections'][0]); + if (! empty($grouped->selections)) { + $selectParts[] = $this->compileSelect($grouped->selections[0]); } $selectSQL = ! empty($selectParts) ? \implode(', ', $selectParts) : '*'; - $selectKeyword = $grouped['distinct'] ? 'SELECT DISTINCT' : 'SELECT'; + $selectKeyword = $grouped->distinct ? 'SELECT DISTINCT' : 'SELECT'; $parts[] = $selectKeyword . ' ' . $selectSQL; // FROM $parts[] = $this->buildTableClause(); // JOINS - if (! empty($grouped['joins'])) { - foreach ($grouped['joins'] as $joinQuery) { + if (! empty($grouped->joins)) { + foreach ($grouped->joins as $joinQuery) { $parts[] = $this->compileJoin($joinQuery); } } @@ -387,7 +378,7 @@ public function build(): array // WHERE $whereClauses = []; - foreach ($grouped['filters'] as $filter) { + foreach ($grouped->filters as $filter) { $whereClauses[] = $this->compileFilter($filter); } @@ -400,7 +391,7 @@ public function build(): array } $cursorSQL = ''; - if ($grouped['cursor'] !== null && $grouped['cursorDirection'] !== null) { + if ($grouped->cursor !== null && $grouped->cursorDirection !== null) { $cursorQueries = Query::getCursorQueries($this->pendingQueries, false); if (! empty($cursorQueries)) { $cursorSQL = $this->compileCursor($cursorQueries[0]); @@ -415,18 +406,18 @@ public function build(): array } // GROUP BY - if (! empty($grouped['groupBy'])) { + if (! empty($grouped->groupBy)) { $groupByCols = \array_map( fn (string $col): string => $this->resolveAndWrap($col), - $grouped['groupBy'] + $grouped->groupBy ); $parts[] = 'GROUP BY ' . \implode(', ', $groupByCols); } // HAVING - if (! empty($grouped['having'])) { + if (! empty($grouped->having)) { $havingClauses = []; - foreach ($grouped['having'] as $havingQuery) { + foreach ($grouped->having as $havingQuery) { foreach ($havingQuery->getValues() as $subQuery) { /** @var Query $subQuery */ $havingClauses[] = $this->compileFilter($subQuery); @@ -440,9 +431,9 @@ public function build(): array // ORDER BY $orderClauses = []; $orderQueries = Query::getByType($this->pendingQueries, [ - Query::TYPE_ORDER_ASC, - Query::TYPE_ORDER_DESC, - Query::TYPE_ORDER_RANDOM, + Method::OrderAsc, + Method::OrderDesc, + Method::OrderRandom, ], false); foreach ($orderQueries as $orderQuery) { $orderClauses[] = $this->compileOrder($orderQuery); @@ -452,15 +443,15 @@ public function build(): array } // LIMIT - if ($grouped['limit'] !== null) { + if ($grouped->limit !== null) { $parts[] = 'LIMIT ?'; - $this->addBinding($grouped['limit']); + $this->addBinding($grouped->limit); } // OFFSET (only emit if LIMIT is also present) - if ($grouped['offset'] !== null && $grouped['limit'] !== null) { + if ($grouped->offset !== null && $grouped->limit !== null) { $parts[] = 'OFFSET ?'; - $this->addBinding($grouped['offset']); + $this->addBinding($grouped->offset); } $sql = \implode(' ', $parts); @@ -470,16 +461,13 @@ public function build(): array $sql = '(' . $sql . ')'; } foreach ($this->unions as $union) { - $sql .= ' ' . $union['type'] . ' (' . $union['query'] . ')'; - foreach ($union['bindings'] as $binding) { + $sql .= ' ' . $union->type . ' (' . $union->query . ')'; + foreach ($union->bindings as $binding) { $this->addBinding($binding); } } - return [ - 'query' => $sql, - 'bindings' => $this->bindings, - ]; + return new BuildResult($sql, $this->bindings); } /** @@ -509,44 +497,44 @@ public function compileFilter(Query $query): string $values = $query->getValues(); return match ($method) { - Query::TYPE_EQUAL => $this->compileIn($attribute, $values), - Query::TYPE_NOT_EQUAL => $this->compileNotIn($attribute, $values), - Query::TYPE_LESSER => $this->compileComparison($attribute, '<', $values), - Query::TYPE_LESSER_EQUAL => $this->compileComparison($attribute, '<=', $values), - Query::TYPE_GREATER => $this->compileComparison($attribute, '>', $values), - Query::TYPE_GREATER_EQUAL => $this->compileComparison($attribute, '>=', $values), - Query::TYPE_BETWEEN => $this->compileBetween($attribute, $values, false), - Query::TYPE_NOT_BETWEEN => $this->compileBetween($attribute, $values, true), - Query::TYPE_STARTS_WITH => $this->compileLike($attribute, $values, '', '%', false), - Query::TYPE_NOT_STARTS_WITH => $this->compileLike($attribute, $values, '', '%', true), - Query::TYPE_ENDS_WITH => $this->compileLike($attribute, $values, '%', '', false), - Query::TYPE_NOT_ENDS_WITH => $this->compileLike($attribute, $values, '%', '', true), - Query::TYPE_CONTAINS => $this->compileContains($attribute, $values), - Query::TYPE_CONTAINS_ANY => $this->compileIn($attribute, $values), - Query::TYPE_CONTAINS_ALL => $this->compileContainsAll($attribute, $values), - Query::TYPE_NOT_CONTAINS => $this->compileNotContains($attribute, $values), - Query::TYPE_SEARCH => $this->compileSearch($attribute, $values, false), - Query::TYPE_NOT_SEARCH => $this->compileSearch($attribute, $values, true), - Query::TYPE_REGEX => $this->compileRegex($attribute, $values), - Query::TYPE_IS_NULL => $attribute . ' IS NULL', - Query::TYPE_IS_NOT_NULL => $attribute . ' IS NOT NULL', - Query::TYPE_AND => $this->compileLogical($query, 'AND'), - Query::TYPE_OR => $this->compileLogical($query, 'OR'), - Query::TYPE_HAVING => $this->compileLogical($query, 'AND'), - Query::TYPE_EXISTS => $this->compileExists($query), - Query::TYPE_NOT_EXISTS => $this->compileNotExists($query), - Query::TYPE_RAW => $this->compileRaw($query), - default => throw new Exception('Unsupported filter type: ' . $method), + Method::Equal => $this->compileIn($attribute, $values), + Method::NotEqual => $this->compileNotIn($attribute, $values), + Method::LessThan => $this->compileComparison($attribute, '<', $values), + Method::LessThanEqual => $this->compileComparison($attribute, '<=', $values), + Method::GreaterThan => $this->compileComparison($attribute, '>', $values), + Method::GreaterThanEqual => $this->compileComparison($attribute, '>=', $values), + Method::Between => $this->compileBetween($attribute, $values, false), + Method::NotBetween => $this->compileBetween($attribute, $values, true), + Method::StartsWith => $this->compileLike($attribute, $values, '', '%', false), + Method::NotStartsWith => $this->compileLike($attribute, $values, '', '%', true), + Method::EndsWith => $this->compileLike($attribute, $values, '%', '', false), + Method::NotEndsWith => $this->compileLike($attribute, $values, '%', '', true), + Method::Contains => $this->compileContains($attribute, $values), + Method::ContainsAny => $this->compileIn($attribute, $values), + Method::ContainsAll => $this->compileContainsAll($attribute, $values), + Method::NotContains => $this->compileNotContains($attribute, $values), + Method::Search => $this->compileSearch($attribute, $values, false), + Method::NotSearch => $this->compileSearch($attribute, $values, true), + Method::Regex => $this->compileRegex($attribute, $values), + Method::IsNull => $attribute . ' IS NULL', + Method::IsNotNull => $attribute . ' IS NOT NULL', + Method::And => $this->compileLogical($query, 'AND'), + Method::Or => $this->compileLogical($query, 'OR'), + Method::Having => $this->compileLogical($query, 'AND'), + Method::Exists => $this->compileExists($query), + Method::NotExists => $this->compileNotExists($query), + Method::Raw => $this->compileRaw($query), + default => throw new Exception('Unsupported filter type: ' . $method->value), }; } public function compileOrder(Query $query): string { return match ($query->getMethod()) { - Query::TYPE_ORDER_ASC => $this->resolveAndWrap($query->getAttribute()) . ' ASC', - Query::TYPE_ORDER_DESC => $this->resolveAndWrap($query->getAttribute()) . ' DESC', - Query::TYPE_ORDER_RANDOM => $this->compileRandom(), - default => throw new Exception('Unsupported order type: ' . $query->getMethod()), + Method::OrderAsc => $this->resolveAndWrap($query->getAttribute()) . ' ASC', + Method::OrderDesc => $this->resolveAndWrap($query->getAttribute()) . ' DESC', + Method::OrderRandom => $this->compileRandom(), + default => throw new Exception('Unsupported order type: ' . $query->getMethod()->value), }; } @@ -581,21 +569,21 @@ public function compileCursor(Query $query): string $value = $query->getValue(); $this->addBinding($value); - $operator = $query->getMethod() === Query::TYPE_CURSOR_AFTER ? '>' : '<'; + $operator = $query->getMethod() === Method::CursorAfter ? '>' : '<'; return $this->wrapIdentifier('_cursor') . ' ' . $operator . ' ?'; } public function compileAggregate(Query $query): string { - $funcMap = [ - Query::TYPE_COUNT => 'COUNT', - Query::TYPE_SUM => 'SUM', - Query::TYPE_AVG => 'AVG', - Query::TYPE_MIN => 'MIN', - Query::TYPE_MAX => 'MAX', - ]; - $func = $funcMap[$query->getMethod()] ?? throw new \InvalidArgumentException("Unknown aggregate: {$query->getMethod()}"); + $func = match ($query->getMethod()) { + Method::Count => 'COUNT', + Method::Sum => 'SUM', + Method::Avg => 'AVG', + Method::Min => 'MIN', + Method::Max => 'MAX', + default => throw new \InvalidArgumentException("Unknown aggregate: {$query->getMethod()->value}"), + }; $attr = $query->getAttribute(); $col = ($attr === '*' || $attr === '') ? '*' : $this->resolveAndWrap($attr); /** @var string $alias */ @@ -624,11 +612,11 @@ public function compileGroupBy(Query $query): string public function compileJoin(Query $query): string { $type = match ($query->getMethod()) { - Query::TYPE_JOIN => 'JOIN', - Query::TYPE_LEFT_JOIN => 'LEFT JOIN', - Query::TYPE_RIGHT_JOIN => 'RIGHT JOIN', - Query::TYPE_CROSS_JOIN => 'CROSS JOIN', - default => throw new Exception('Unsupported join type: ' . $query->getMethod()), + Method::Join => 'JOIN', + Method::LeftJoin => 'LEFT JOIN', + Method::RightJoin => 'RIGHT JOIN', + Method::CrossJoin => 'CROSS JOIN', + default => throw new Exception('Unsupported join type: ' . $query->getMethod()->value), }; $table = $this->wrapIdentifier($query->getAttribute()); diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index fb027bc..23def63 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -72,14 +72,9 @@ public function reset(): static protected function wrapIdentifier(string $identifier): string { $segments = \explode('.', $identifier); - $wrapped = \array_map(function (string $segment): string { - if ($segment === '*') { - return '*'; - } - $escaped = \str_replace('`', '``', $segment); - - return '`' . $escaped . '`'; - }, $segments); + $wrapped = \array_map(fn (string $segment): string => $segment === '*' + ? '*' + : '`' . \str_replace('`', '``', $segment) . '`', $segments); return \implode('.', $wrapped); } @@ -132,9 +127,8 @@ protected function buildTableClause(): string /** * @param array $parts - * @param array $grouped */ - protected function buildAfterJoins(array &$parts, array $grouped): void + protected function buildAfterJoins(array &$parts, GroupedQueries $grouped): void { if (! empty($this->prewhereQueries)) { $clauses = []; diff --git a/src/Query/Builder/SQL.php b/src/Query/Builder/SQL.php index 0cb02d7..9275208 100644 --- a/src/Query/Builder/SQL.php +++ b/src/Query/Builder/SQL.php @@ -18,14 +18,9 @@ public function setWrapChar(string $char): static protected function wrapIdentifier(string $identifier): string { $segments = \explode('.', $identifier); - $wrapped = \array_map(function (string $segment): string { - if ($segment === '*') { - return '*'; - } - $escaped = \str_replace($this->wrapChar, $this->wrapChar . $this->wrapChar, $segment); - - return $this->wrapChar . $escaped . $this->wrapChar; - }, $segments); + $wrapped = \array_map(fn (string $segment): string => $segment === '*' + ? '*' + : $this->wrapChar . \str_replace($this->wrapChar, $this->wrapChar . $this->wrapChar, $segment) . $this->wrapChar, $segments); return \implode('.', $wrapped); } diff --git a/src/Query/CursorDirection.php b/src/Query/CursorDirection.php new file mode 100644 index 0000000..a6eec17 --- /dev/null +++ b/src/Query/CursorDirection.php @@ -0,0 +1,9 @@ + $map */ - public function __construct(protected array $map) + public function __construct(public array $map) { } diff --git a/src/Query/Method.php b/src/Query/Method.php new file mode 100644 index 0000000..a37e843 --- /dev/null +++ b/src/Query/Method.php @@ -0,0 +1,158 @@ + true, + default => false, + }; + } + + public function isNested(): bool + { + return match ($this) { + self::And, + self::Or, + self::ElemMatch, + self::Having, + self::Union, + self::UnionAll => true, + default => false, + }; + } + + public function isAggregate(): bool + { + return match ($this) { + self::Count, + self::Sum, + self::Avg, + self::Min, + self::Max => true, + default => false, + }; + } + + public function isJoin(): bool + { + return match ($this) { + self::Join, + self::LeftJoin, + self::RightJoin, + self::CrossJoin => true, + default => false, + }; + } + + public function isVector(): bool + { + return match ($this) { + self::VectorDot, + self::VectorCosine, + self::VectorEuclidean => true, + default => false, + }; + } +} diff --git a/src/Query/OrderDirection.php b/src/Query/OrderDirection.php new file mode 100644 index 0000000..f6e212f --- /dev/null +++ b/src/Query/OrderDirection.php @@ -0,0 +1,10 @@ + $values */ - public function __construct(string $method, string $attribute = '', array $values = []) + public function __construct(Method|string $method, string $attribute = '', array $values = []) { - $this->method = $method; + $this->method = $method instanceof Method ? $method : Method::from($method); $this->attribute = $attribute; $this->values = $values; } @@ -292,7 +45,7 @@ public function __clone(): void } } - public function getMethod(): string + public function getMethod(): Method { return $this->method; } @@ -318,9 +71,9 @@ public function getValue(mixed $default = null): mixed /** * Sets method */ - public function setMethod(string $method): static + public function setMethod(Method|string $method): static { - $this->method = $method; + $this->method = $method instanceof Method ? $method : Method::from($method); return $this; } @@ -362,73 +115,7 @@ public function setValue(mixed $value): static */ public static function isMethod(string $value): bool { - return match ($value) { - self::TYPE_EQUAL, - self::TYPE_NOT_EQUAL, - self::TYPE_LESSER, - self::TYPE_LESSER_EQUAL, - self::TYPE_GREATER, - self::TYPE_GREATER_EQUAL, - self::TYPE_CONTAINS, - self::TYPE_CONTAINS_ANY, - self::TYPE_NOT_CONTAINS, - self::TYPE_SEARCH, - self::TYPE_NOT_SEARCH, - self::TYPE_ORDER_ASC, - self::TYPE_ORDER_DESC, - self::TYPE_ORDER_RANDOM, - self::TYPE_LIMIT, - self::TYPE_OFFSET, - self::TYPE_CURSOR_AFTER, - self::TYPE_CURSOR_BEFORE, - self::TYPE_IS_NULL, - self::TYPE_IS_NOT_NULL, - self::TYPE_BETWEEN, - self::TYPE_NOT_BETWEEN, - self::TYPE_STARTS_WITH, - self::TYPE_NOT_STARTS_WITH, - self::TYPE_ENDS_WITH, - self::TYPE_NOT_ENDS_WITH, - self::TYPE_CROSSES, - self::TYPE_NOT_CROSSES, - self::TYPE_DISTANCE_EQUAL, - self::TYPE_DISTANCE_NOT_EQUAL, - self::TYPE_DISTANCE_GREATER_THAN, - self::TYPE_DISTANCE_LESS_THAN, - self::TYPE_INTERSECTS, - self::TYPE_NOT_INTERSECTS, - self::TYPE_OVERLAPS, - self::TYPE_NOT_OVERLAPS, - self::TYPE_TOUCHES, - self::TYPE_NOT_TOUCHES, - self::TYPE_OR, - self::TYPE_AND, - self::TYPE_CONTAINS_ALL, - self::TYPE_ELEM_MATCH, - self::TYPE_SELECT, - self::TYPE_VECTOR_DOT, - self::TYPE_VECTOR_COSINE, - self::TYPE_VECTOR_EUCLIDEAN, - self::TYPE_EXISTS, - self::TYPE_NOT_EXISTS, - self::TYPE_REGEX, - self::TYPE_COUNT, - self::TYPE_SUM, - self::TYPE_AVG, - self::TYPE_MIN, - self::TYPE_MAX, - self::TYPE_GROUP_BY, - self::TYPE_HAVING, - self::TYPE_DISTINCT, - self::TYPE_JOIN, - self::TYPE_LEFT_JOIN, - self::TYPE_RIGHT_JOIN, - self::TYPE_CROSS_JOIN, - self::TYPE_UNION, - self::TYPE_UNION_ALL, - self::TYPE_RAW => true, - default => false, - }; + return Method::tryFrom($value) !== null; } /** @@ -436,21 +123,7 @@ public static function isMethod(string $value): bool */ public function isSpatialQuery(): bool { - return match ($this->method) { - self::TYPE_CROSSES, - self::TYPE_NOT_CROSSES, - self::TYPE_DISTANCE_EQUAL, - self::TYPE_DISTANCE_NOT_EQUAL, - self::TYPE_DISTANCE_GREATER_THAN, - self::TYPE_DISTANCE_LESS_THAN, - self::TYPE_INTERSECTS, - self::TYPE_NOT_INTERSECTS, - self::TYPE_OVERLAPS, - self::TYPE_NOT_OVERLAPS, - self::TYPE_TOUCHES, - self::TYPE_NOT_TOUCHES => true, - default => false, - }; + return $this->method->isSpatial(); } /** @@ -503,14 +176,16 @@ public static function parseQuery(array $query): static throw new QueryException('Invalid query values. Must be an array, got '.\gettype($values)); } - if (\in_array($method, self::LOGICAL_TYPES, true)) { + $methodEnum = Method::from($method); + + if ($methodEnum->isNested()) { foreach ($values as $index => $value) { /** @var array $value */ $values[$index] = static::parseQuery($value); } } - return new static($method, $attribute, $values); + return new static($methodEnum, $attribute, $values); } /** @@ -537,13 +212,13 @@ public static function parseQueries(array $queries): array */ public function toArray(): array { - $array = ['method' => $this->method]; + $array = ['method' => $this->method->value]; if (! empty($this->attribute)) { $array['attribute'] = $this->attribute; } - if (\in_array($this->method, self::LOGICAL_TYPES, true)) { + if ($this->method->isNested()) { foreach ($this->values as $index => $value) { /** @var Query $value */ $array['values'][$index] = $value->toArray(); @@ -564,33 +239,33 @@ public function toArray(): array public function compile(Compiler $compiler): string { return match ($this->method) { - self::TYPE_ORDER_ASC, - self::TYPE_ORDER_DESC, - self::TYPE_ORDER_RANDOM => $compiler->compileOrder($this), + Method::OrderAsc, + Method::OrderDesc, + Method::OrderRandom => $compiler->compileOrder($this), - self::TYPE_LIMIT => $compiler->compileLimit($this), + Method::Limit => $compiler->compileLimit($this), - self::TYPE_OFFSET => $compiler->compileOffset($this), + Method::Offset => $compiler->compileOffset($this), - self::TYPE_CURSOR_AFTER, - self::TYPE_CURSOR_BEFORE => $compiler->compileCursor($this), + Method::CursorAfter, + Method::CursorBefore => $compiler->compileCursor($this), - self::TYPE_SELECT => $compiler->compileSelect($this), + Method::Select => $compiler->compileSelect($this), - self::TYPE_COUNT, - self::TYPE_SUM, - self::TYPE_AVG, - self::TYPE_MIN, - self::TYPE_MAX => $compiler->compileAggregate($this), + Method::Count, + Method::Sum, + Method::Avg, + Method::Min, + Method::Max => $compiler->compileAggregate($this), - self::TYPE_GROUP_BY => $compiler->compileGroupBy($this), + Method::GroupBy => $compiler->compileGroupBy($this), - self::TYPE_JOIN, - self::TYPE_LEFT_JOIN, - self::TYPE_RIGHT_JOIN, - self::TYPE_CROSS_JOIN => $compiler->compileJoin($this), + Method::Join, + Method::LeftJoin, + Method::RightJoin, + Method::CrossJoin => $compiler->compileJoin($this), - self::TYPE_HAVING => $compiler->compileFilter($this), + Method::Having => $compiler->compileFilter($this), default => $compiler->compileFilter($this), }; @@ -615,7 +290,7 @@ public function toString(): string */ public static function equal(string $attribute, array $values): static { - return new static(self::TYPE_EQUAL, $attribute, $values); + return new static(Method::Equal, $attribute, $values); } /** @@ -630,7 +305,7 @@ public static function notEqual(string $attribute, string|int|float|bool|array|n $value = [$value]; } - return new static(self::TYPE_NOT_EQUAL, $attribute, $value); + return new static(Method::NotEqual, $attribute, $value); } /** @@ -638,7 +313,7 @@ public static function notEqual(string $attribute, string|int|float|bool|array|n */ public static function lessThan(string $attribute, string|int|float|bool $value): static { - return new static(self::TYPE_LESSER, $attribute, [$value]); + return new static(Method::LessThan, $attribute, [$value]); } /** @@ -646,7 +321,7 @@ public static function lessThan(string $attribute, string|int|float|bool $value) */ public static function lessThanEqual(string $attribute, string|int|float|bool $value): static { - return new static(self::TYPE_LESSER_EQUAL, $attribute, [$value]); + return new static(Method::LessThanEqual, $attribute, [$value]); } /** @@ -654,7 +329,7 @@ public static function lessThanEqual(string $attribute, string|int|float|bool $v */ public static function greaterThan(string $attribute, string|int|float|bool $value): static { - return new static(self::TYPE_GREATER, $attribute, [$value]); + return new static(Method::GreaterThan, $attribute, [$value]); } /** @@ -662,7 +337,7 @@ public static function greaterThan(string $attribute, string|int|float|bool $val */ public static function greaterThanEqual(string $attribute, string|int|float|bool $value): static { - return new static(self::TYPE_GREATER_EQUAL, $attribute, [$value]); + return new static(Method::GreaterThanEqual, $attribute, [$value]); } /** @@ -674,7 +349,7 @@ public static function greaterThanEqual(string $attribute, string|int|float|bool */ public static function contains(string $attribute, array $values): static { - return new static(self::TYPE_CONTAINS, $attribute, $values); + return new static(Method::Contains, $attribute, $values); } /** @@ -685,7 +360,7 @@ public static function contains(string $attribute, array $values): static */ public static function containsAny(string $attribute, array $values): static { - return new static(self::TYPE_CONTAINS_ANY, $attribute, $values); + return new static(Method::ContainsAny, $attribute, $values); } /** @@ -695,7 +370,7 @@ public static function containsAny(string $attribute, array $values): static */ public static function notContains(string $attribute, array $values): static { - return new static(self::TYPE_NOT_CONTAINS, $attribute, $values); + return new static(Method::NotContains, $attribute, $values); } /** @@ -703,7 +378,7 @@ public static function notContains(string $attribute, array $values): static */ public static function between(string $attribute, string|int|float|bool $start, string|int|float|bool $end): static { - return new static(self::TYPE_BETWEEN, $attribute, [$start, $end]); + return new static(Method::Between, $attribute, [$start, $end]); } /** @@ -711,7 +386,7 @@ public static function between(string $attribute, string|int|float|bool $start, */ public static function notBetween(string $attribute, string|int|float|bool $start, string|int|float|bool $end): static { - return new static(self::TYPE_NOT_BETWEEN, $attribute, [$start, $end]); + return new static(Method::NotBetween, $attribute, [$start, $end]); } /** @@ -719,7 +394,7 @@ public static function notBetween(string $attribute, string|int|float|bool $star */ public static function search(string $attribute, string $value): static { - return new static(self::TYPE_SEARCH, $attribute, [$value]); + return new static(Method::Search, $attribute, [$value]); } /** @@ -727,7 +402,7 @@ public static function search(string $attribute, string $value): static */ public static function notSearch(string $attribute, string $value): static { - return new static(self::TYPE_NOT_SEARCH, $attribute, [$value]); + return new static(Method::NotSearch, $attribute, [$value]); } /** @@ -737,7 +412,7 @@ public static function notSearch(string $attribute, string $value): static */ public static function select(array $attributes): static { - return new static(self::TYPE_SELECT, values: $attributes); + return new static(Method::Select, values: $attributes); } /** @@ -745,7 +420,7 @@ public static function select(array $attributes): static */ public static function orderDesc(string $attribute = ''): static { - return new static(self::TYPE_ORDER_DESC, $attribute); + return new static(Method::OrderDesc, $attribute); } /** @@ -753,7 +428,7 @@ public static function orderDesc(string $attribute = ''): static */ public static function orderAsc(string $attribute = ''): static { - return new static(self::TYPE_ORDER_ASC, $attribute); + return new static(Method::OrderAsc, $attribute); } /** @@ -761,7 +436,7 @@ public static function orderAsc(string $attribute = ''): static */ public static function orderRandom(): static { - return new static(self::TYPE_ORDER_RANDOM); + return new static(Method::OrderRandom); } /** @@ -769,7 +444,7 @@ public static function orderRandom(): static */ public static function limit(int $value): static { - return new static(self::TYPE_LIMIT, values: [$value]); + return new static(Method::Limit, values: [$value]); } /** @@ -777,7 +452,7 @@ public static function limit(int $value): static */ public static function offset(int $value): static { - return new static(self::TYPE_OFFSET, values: [$value]); + return new static(Method::Offset, values: [$value]); } /** @@ -785,7 +460,7 @@ public static function offset(int $value): static */ public static function cursorAfter(mixed $value): static { - return new static(self::TYPE_CURSOR_AFTER, values: [$value]); + return new static(Method::CursorAfter, values: [$value]); } /** @@ -793,7 +468,7 @@ public static function cursorAfter(mixed $value): static */ public static function cursorBefore(mixed $value): static { - return new static(self::TYPE_CURSOR_BEFORE, values: [$value]); + return new static(Method::CursorBefore, values: [$value]); } /** @@ -801,7 +476,7 @@ public static function cursorBefore(mixed $value): static */ public static function isNull(string $attribute): static { - return new static(self::TYPE_IS_NULL, $attribute); + return new static(Method::IsNull, $attribute); } /** @@ -809,27 +484,27 @@ public static function isNull(string $attribute): static */ public static function isNotNull(string $attribute): static { - return new static(self::TYPE_IS_NOT_NULL, $attribute); + return new static(Method::IsNotNull, $attribute); } public static function startsWith(string $attribute, string $value): static { - return new static(self::TYPE_STARTS_WITH, $attribute, [$value]); + return new static(Method::StartsWith, $attribute, [$value]); } public static function notStartsWith(string $attribute, string $value): static { - return new static(self::TYPE_NOT_STARTS_WITH, $attribute, [$value]); + return new static(Method::NotStartsWith, $attribute, [$value]); } public static function endsWith(string $attribute, string $value): static { - return new static(self::TYPE_ENDS_WITH, $attribute, [$value]); + return new static(Method::EndsWith, $attribute, [$value]); } public static function notEndsWith(string $attribute, string $value): static { - return new static(self::TYPE_NOT_ENDS_WITH, $attribute, [$value]); + return new static(Method::NotEndsWith, $attribute, [$value]); } /** @@ -885,7 +560,7 @@ public static function updatedBetween(string $start, string $end): static */ public static function or(array $queries): static { - return new static(self::TYPE_OR, '', $queries); + return new static(Method::Or, '', $queries); } /** @@ -893,7 +568,7 @@ public static function or(array $queries): static */ public static function and(array $queries): static { - return new static(self::TYPE_AND, '', $queries); + return new static(Method::And, '', $queries); } /** @@ -901,14 +576,14 @@ public static function and(array $queries): static */ public static function containsAll(string $attribute, array $values): static { - return new static(self::TYPE_CONTAINS_ALL, $attribute, $values); + return new static(Method::ContainsAll, $attribute, $values); } /** * Filters $queries for $types * * @param array $queries - * @param array $types + * @param array $types * @return array */ public static function getByType(array $queries, array $types, bool $clone = true): array @@ -933,8 +608,8 @@ public static function getCursorQueries(array $queries, bool $clone = true): arr return self::getByType( $queries, [ - Query::TYPE_CURSOR_AFTER, - Query::TYPE_CURSOR_BEFORE, + Method::CursorAfter, + Method::CursorBefore, ], $clone ); @@ -944,24 +619,8 @@ public static function getCursorQueries(array $queries, bool $clone = true): arr * Iterates through queries and groups them by type * * @param array $queries - * @return array{ - * filters: list, - * selections: list, - * aggregations: list, - * groupBy: list, - * having: list, - * distinct: bool, - * joins: list, - * unions: list, - * limit: int|null, - * offset: int|null, - * orderAttributes: array, - * orderTypes: array, - * cursor: mixed, - * cursorDirection: string|null - * } - */ - public static function groupByType(array $queries): array + */ + public static function groupByType(array $queries): GroupedQueries { $filters = []; $selections = []; @@ -988,21 +647,21 @@ public static function groupByType(array $queries): array $values = $query->getValues(); switch ($method) { - case Query::TYPE_ORDER_ASC: - case Query::TYPE_ORDER_DESC: - case Query::TYPE_ORDER_RANDOM: + case Method::OrderAsc: + case Method::OrderDesc: + case Method::OrderRandom: if (! empty($attribute)) { $orderAttributes[] = $attribute; } $orderTypes[] = match ($method) { - Query::TYPE_ORDER_ASC => self::ORDER_ASC, - Query::TYPE_ORDER_DESC => self::ORDER_DESC, - Query::TYPE_ORDER_RANDOM => self::ORDER_RANDOM, + Method::OrderAsc => OrderDirection::Asc, + Method::OrderDesc => OrderDirection::Desc, + Method::OrderRandom => OrderDirection::Random, }; break; - case Query::TYPE_LIMIT: + case Method::Limit: // Keep the 1st limit encountered and ignore the rest if ($limit !== null) { break; @@ -1010,7 +669,7 @@ public static function groupByType(array $queries): array $limit = isset($values[0]) && \is_numeric($values[0]) ? \intval($values[0]) : $limit; break; - case Query::TYPE_OFFSET: + case Method::Offset: // Keep the 1st offset encountered and ignore the rest if ($offset !== null) { break; @@ -1018,53 +677,53 @@ public static function groupByType(array $queries): array $offset = isset($values[0]) && \is_numeric($values[0]) ? \intval($values[0]) : $offset; break; - case Query::TYPE_CURSOR_AFTER: - case Query::TYPE_CURSOR_BEFORE: + case Method::CursorAfter: + case Method::CursorBefore: // Keep the 1st cursor encountered and ignore the rest if ($cursor !== null) { break; } $cursor = $values[0] ?? $limit; - $cursorDirection = $method === Query::TYPE_CURSOR_AFTER ? self::CURSOR_AFTER : self::CURSOR_BEFORE; + $cursorDirection = $method === Method::CursorAfter ? CursorDirection::After : CursorDirection::Before; break; - case Query::TYPE_SELECT: + case Method::Select: $selections[] = clone $query; break; - case Query::TYPE_COUNT: - case Query::TYPE_SUM: - case Query::TYPE_AVG: - case Query::TYPE_MIN: - case Query::TYPE_MAX: + case Method::Count: + case Method::Sum: + case Method::Avg: + case Method::Min: + case Method::Max: $aggregations[] = clone $query; break; - case Query::TYPE_GROUP_BY: + case Method::GroupBy: /** @var array $values */ foreach ($values as $col) { $groupBy[] = $col; } break; - case Query::TYPE_HAVING: + case Method::Having: $having[] = clone $query; break; - case Query::TYPE_DISTINCT: + case Method::Distinct: $distinct = true; break; - case Query::TYPE_JOIN: - case Query::TYPE_LEFT_JOIN: - case Query::TYPE_RIGHT_JOIN: - case Query::TYPE_CROSS_JOIN: + case Method::Join: + case Method::LeftJoin: + case Method::RightJoin: + case Method::CrossJoin: $joins[] = clone $query; break; - case Query::TYPE_UNION: - case Query::TYPE_UNION_ALL: + case Method::Union: + case Method::UnionAll: $unions[] = clone $query; break; @@ -1074,22 +733,22 @@ public static function groupByType(array $queries): array } } - return [ - 'filters' => $filters, - 'selections' => $selections, - 'aggregations' => $aggregations, - 'groupBy' => $groupBy, - 'having' => $having, - 'distinct' => $distinct, - 'joins' => $joins, - 'unions' => $unions, - 'limit' => $limit, - 'offset' => $offset, - 'orderAttributes' => $orderAttributes, - 'orderTypes' => $orderTypes, - 'cursor' => $cursor, - 'cursorDirection' => $cursorDirection, - ]; + return new GroupedQueries( + filters: $filters, + selections: $selections, + aggregations: $aggregations, + groupBy: $groupBy, + having: $having, + distinct: $distinct, + joins: $joins, + unions: $unions, + limit: $limit, + offset: $offset, + orderAttributes: $orderAttributes, + orderTypes: $orderTypes, + cursor: $cursor, + cursorDirection: $cursorDirection, + ); } /** @@ -1097,11 +756,7 @@ public static function groupByType(array $queries): array */ public function isNested(): bool { - if (\in_array($this->getMethod(), self::LOGICAL_TYPES, true)) { - return true; - } - - return false; + return $this->method->isNested(); } public function onArray(): bool @@ -1133,7 +788,7 @@ public function getAttributeType(): string */ public static function distanceEqual(string $attribute, array $values, int|float $distance, bool $meters = false): static { - return new static(self::TYPE_DISTANCE_EQUAL, $attribute, [[$values, $distance, $meters]]); + return new static(Method::DistanceEqual, $attribute, [[$values, $distance, $meters]]); } /** @@ -1143,7 +798,7 @@ public static function distanceEqual(string $attribute, array $values, int|float */ public static function distanceNotEqual(string $attribute, array $values, int|float $distance, bool $meters = false): static { - return new static(self::TYPE_DISTANCE_NOT_EQUAL, $attribute, [[$values, $distance, $meters]]); + return new static(Method::DistanceNotEqual, $attribute, [[$values, $distance, $meters]]); } /** @@ -1153,7 +808,7 @@ public static function distanceNotEqual(string $attribute, array $values, int|fl */ public static function distanceGreaterThan(string $attribute, array $values, int|float $distance, bool $meters = false): static { - return new static(self::TYPE_DISTANCE_GREATER_THAN, $attribute, [[$values, $distance, $meters]]); + return new static(Method::DistanceGreaterThan, $attribute, [[$values, $distance, $meters]]); } /** @@ -1163,7 +818,7 @@ public static function distanceGreaterThan(string $attribute, array $values, int */ public static function distanceLessThan(string $attribute, array $values, int|float $distance, bool $meters = false): static { - return new static(self::TYPE_DISTANCE_LESS_THAN, $attribute, [[$values, $distance, $meters]]); + return new static(Method::DistanceLessThan, $attribute, [[$values, $distance, $meters]]); } /** @@ -1173,7 +828,7 @@ public static function distanceLessThan(string $attribute, array $values, int|fl */ public static function intersects(string $attribute, array $values): static { - return new static(self::TYPE_INTERSECTS, $attribute, [$values]); + return new static(Method::Intersects, $attribute, [$values]); } /** @@ -1183,7 +838,7 @@ public static function intersects(string $attribute, array $values): static */ public static function notIntersects(string $attribute, array $values): static { - return new static(self::TYPE_NOT_INTERSECTS, $attribute, [$values]); + return new static(Method::NotIntersects, $attribute, [$values]); } /** @@ -1193,7 +848,7 @@ public static function notIntersects(string $attribute, array $values): static */ public static function crosses(string $attribute, array $values): static { - return new static(self::TYPE_CROSSES, $attribute, [$values]); + return new static(Method::Crosses, $attribute, [$values]); } /** @@ -1203,7 +858,7 @@ public static function crosses(string $attribute, array $values): static */ public static function notCrosses(string $attribute, array $values): static { - return new static(self::TYPE_NOT_CROSSES, $attribute, [$values]); + return new static(Method::NotCrosses, $attribute, [$values]); } /** @@ -1213,7 +868,7 @@ public static function notCrosses(string $attribute, array $values): static */ public static function overlaps(string $attribute, array $values): static { - return new static(self::TYPE_OVERLAPS, $attribute, [$values]); + return new static(Method::Overlaps, $attribute, [$values]); } /** @@ -1223,7 +878,7 @@ public static function overlaps(string $attribute, array $values): static */ public static function notOverlaps(string $attribute, array $values): static { - return new static(self::TYPE_NOT_OVERLAPS, $attribute, [$values]); + return new static(Method::NotOverlaps, $attribute, [$values]); } /** @@ -1233,7 +888,7 @@ public static function notOverlaps(string $attribute, array $values): static */ public static function touches(string $attribute, array $values): static { - return new static(self::TYPE_TOUCHES, $attribute, [$values]); + return new static(Method::Touches, $attribute, [$values]); } /** @@ -1243,7 +898,7 @@ public static function touches(string $attribute, array $values): static */ public static function notTouches(string $attribute, array $values): static { - return new static(self::TYPE_NOT_TOUCHES, $attribute, [$values]); + return new static(Method::NotTouches, $attribute, [$values]); } /** @@ -1253,7 +908,7 @@ public static function notTouches(string $attribute, array $values): static */ public static function vectorDot(string $attribute, array $vector): static { - return new static(self::TYPE_VECTOR_DOT, $attribute, [$vector]); + return new static(Method::VectorDot, $attribute, [$vector]); } /** @@ -1263,7 +918,7 @@ public static function vectorDot(string $attribute, array $vector): static */ public static function vectorCosine(string $attribute, array $vector): static { - return new static(self::TYPE_VECTOR_COSINE, $attribute, [$vector]); + return new static(Method::VectorCosine, $attribute, [$vector]); } /** @@ -1273,7 +928,7 @@ public static function vectorCosine(string $attribute, array $vector): static */ public static function vectorEuclidean(string $attribute, array $vector): static { - return new static(self::TYPE_VECTOR_EUCLIDEAN, $attribute, [$vector]); + return new static(Method::VectorEuclidean, $attribute, [$vector]); } /** @@ -1281,7 +936,7 @@ public static function vectorEuclidean(string $attribute, array $vector): static */ public static function regex(string $attribute, string $pattern): static { - return new static(self::TYPE_REGEX, $attribute, [$pattern]); + return new static(Method::Regex, $attribute, [$pattern]); } /** @@ -1291,7 +946,7 @@ public static function regex(string $attribute, string $pattern): static */ public static function exists(array $attributes): static { - return new static(self::TYPE_EXISTS, '', $attributes); + return new static(Method::Exists, '', $attributes); } /** @@ -1301,7 +956,7 @@ public static function exists(array $attributes): static */ public static function notExists(string|int|float|bool|array $attribute): static { - return new static(self::TYPE_NOT_EXISTS, '', is_array($attribute) ? $attribute : [$attribute]); + return new static(Method::NotExists, '', is_array($attribute) ? $attribute : [$attribute]); } /** @@ -1309,34 +964,34 @@ public static function notExists(string|int|float|bool|array $attribute): static */ public static function elemMatch(string $attribute, array $queries): static { - return new static(self::TYPE_ELEM_MATCH, $attribute, $queries); + return new static(Method::ElemMatch, $attribute, $queries); } // Aggregation factory methods public static function count(string $attribute = '*', string $alias = ''): static { - return new static(self::TYPE_COUNT, $attribute, $alias !== '' ? [$alias] : []); + return new static(Method::Count, $attribute, $alias !== '' ? [$alias] : []); } public static function sum(string $attribute, string $alias = ''): static { - return new static(self::TYPE_SUM, $attribute, $alias !== '' ? [$alias] : []); + return new static(Method::Sum, $attribute, $alias !== '' ? [$alias] : []); } public static function avg(string $attribute, string $alias = ''): static { - return new static(self::TYPE_AVG, $attribute, $alias !== '' ? [$alias] : []); + return new static(Method::Avg, $attribute, $alias !== '' ? [$alias] : []); } public static function min(string $attribute, string $alias = ''): static { - return new static(self::TYPE_MIN, $attribute, $alias !== '' ? [$alias] : []); + return new static(Method::Min, $attribute, $alias !== '' ? [$alias] : []); } public static function max(string $attribute, string $alias = ''): static { - return new static(self::TYPE_MAX, $attribute, $alias !== '' ? [$alias] : []); + return new static(Method::Max, $attribute, $alias !== '' ? [$alias] : []); } /** @@ -1344,7 +999,7 @@ public static function max(string $attribute, string $alias = ''): static */ public static function groupBy(array $attributes): static { - return new static(self::TYPE_GROUP_BY, '', $attributes); + return new static(Method::GroupBy, '', $attributes); } /** @@ -1352,34 +1007,34 @@ public static function groupBy(array $attributes): static */ public static function having(array $queries): static { - return new static(self::TYPE_HAVING, '', $queries); + return new static(Method::Having, '', $queries); } public static function distinct(): static { - return new static(self::TYPE_DISTINCT); + return new static(Method::Distinct); } // Join factory methods public static function join(string $table, string $left, string $right, string $operator = '='): static { - return new static(self::TYPE_JOIN, $table, [$left, $operator, $right]); + return new static(Method::Join, $table, [$left, $operator, $right]); } public static function leftJoin(string $table, string $left, string $right, string $operator = '='): static { - return new static(self::TYPE_LEFT_JOIN, $table, [$left, $operator, $right]); + return new static(Method::LeftJoin, $table, [$left, $operator, $right]); } public static function rightJoin(string $table, string $left, string $right, string $operator = '='): static { - return new static(self::TYPE_RIGHT_JOIN, $table, [$left, $operator, $right]); + return new static(Method::RightJoin, $table, [$left, $operator, $right]); } public static function crossJoin(string $table): static { - return new static(self::TYPE_CROSS_JOIN, $table); + return new static(Method::CrossJoin, $table); } // Union factory methods @@ -1389,7 +1044,7 @@ public static function crossJoin(string $table): static */ public static function union(array $queries): static { - return new static(self::TYPE_UNION, '', $queries); + return new static(Method::Union, '', $queries); } /** @@ -1397,7 +1052,7 @@ public static function union(array $queries): static */ public static function unionAll(array $queries): static { - return new static(self::TYPE_UNION_ALL, '', $queries); + return new static(Method::UnionAll, '', $queries); } // Raw factory method @@ -1407,7 +1062,7 @@ public static function unionAll(array $queries): static */ public static function raw(string $sql, array $bindings = []): static { - return new static(self::TYPE_RAW, $sql, $bindings); + return new static(Method::Raw, $sql, $bindings); } // Convenience: page @@ -1437,10 +1092,10 @@ public static function page(int $page, int $perPage = 25): array public static function merge(array $queriesA, array $queriesB): array { $singularTypes = [ - self::TYPE_LIMIT, - self::TYPE_OFFSET, - self::TYPE_CURSOR_AFTER, - self::TYPE_CURSOR_BEFORE, + Method::Limit, + Method::Offset, + Method::CursorAfter, + Method::CursorBefore, ]; $result = $queriesA; @@ -1504,22 +1159,22 @@ public static function validate(array $queries, array $allowedAttributes): array { $errors = []; $skipTypes = [ - self::TYPE_LIMIT, - self::TYPE_OFFSET, - self::TYPE_CURSOR_AFTER, - self::TYPE_CURSOR_BEFORE, - self::TYPE_ORDER_RANDOM, - self::TYPE_DISTINCT, - self::TYPE_SELECT, - self::TYPE_EXISTS, - self::TYPE_NOT_EXISTS, + Method::Limit, + Method::Offset, + Method::CursorAfter, + Method::CursorBefore, + Method::OrderRandom, + Method::Distinct, + Method::Select, + Method::Exists, + Method::NotExists, ]; foreach ($queries as $query) { $method = $query->getMethod(); // Recursively validate nested queries - if (\in_array($method, self::LOGICAL_TYPES, true)) { + if ($method->isNested()) { /** @var array $nested */ $nested = $query->getValues(); $errors = \array_merge($errors, static::validate($nested, $allowedAttributes)); @@ -1532,12 +1187,12 @@ public static function validate(array $queries, array $allowedAttributes): array } // GROUP_BY stores attributes in values - if ($method === self::TYPE_GROUP_BY) { + if ($method === Method::GroupBy) { /** @var array $columns */ $columns = $query->getValues(); foreach ($columns as $col) { if (! \in_array($col, $allowedAttributes, true)) { - $errors[] = "Invalid attribute \"{$col}\" used in {$method}"; + $errors[] = "Invalid attribute \"{$col}\" used in {$method->value}"; } } @@ -1551,7 +1206,7 @@ public static function validate(array $queries, array $allowedAttributes): array } if (! \in_array($attribute, $allowedAttributes, true)) { - $errors[] = "Invalid attribute \"{$attribute}\" used in {$method}"; + $errors[] = "Invalid attribute \"{$attribute}\" used in {$method->value}"; } } diff --git a/tests/Query/AggregationQueryTest.php b/tests/Query/AggregationQueryTest.php index 2b30d7a..76c61fc 100644 --- a/tests/Query/AggregationQueryTest.php +++ b/tests/Query/AggregationQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; class AggregationQueryTest extends TestCase @@ -10,7 +11,7 @@ class AggregationQueryTest extends TestCase public function testCountDefaultAttribute(): void { $query = Query::count(); - $this->assertEquals(Query::TYPE_COUNT, $query->getMethod()); + $this->assertSame(Method::Count, $query->getMethod()); $this->assertEquals('*', $query->getAttribute()); $this->assertEquals([], $query->getValues()); } @@ -18,7 +19,7 @@ public function testCountDefaultAttribute(): void public function testCountWithAttribute(): void { $query = Query::count('id'); - $this->assertEquals(Query::TYPE_COUNT, $query->getMethod()); + $this->assertSame(Method::Count, $query->getMethod()); $this->assertEquals('id', $query->getAttribute()); $this->assertEquals([], $query->getValues()); } @@ -34,7 +35,7 @@ public function testCountWithAlias(): void public function testSum(): void { $query = Query::sum('price'); - $this->assertEquals(Query::TYPE_SUM, $query->getMethod()); + $this->assertSame(Method::Sum, $query->getMethod()); $this->assertEquals('price', $query->getAttribute()); $this->assertEquals([], $query->getValues()); } @@ -48,28 +49,28 @@ public function testSumWithAlias(): void public function testAvg(): void { $query = Query::avg('score'); - $this->assertEquals(Query::TYPE_AVG, $query->getMethod()); + $this->assertSame(Method::Avg, $query->getMethod()); $this->assertEquals('score', $query->getAttribute()); } public function testMin(): void { $query = Query::min('price'); - $this->assertEquals(Query::TYPE_MIN, $query->getMethod()); + $this->assertSame(Method::Min, $query->getMethod()); $this->assertEquals('price', $query->getAttribute()); } public function testMax(): void { $query = Query::max('price'); - $this->assertEquals(Query::TYPE_MAX, $query->getMethod()); + $this->assertSame(Method::Max, $query->getMethod()); $this->assertEquals('price', $query->getAttribute()); } public function testGroupBy(): void { $query = Query::groupBy(['status', 'country']); - $this->assertEquals(Query::TYPE_GROUP_BY, $query->getMethod()); + $this->assertSame(Method::GroupBy, $query->getMethod()); $this->assertEquals('', $query->getAttribute()); $this->assertEquals(['status', 'country'], $query->getValues()); } @@ -80,19 +81,20 @@ public function testHaving(): void Query::greaterThan('count', 5), ]; $query = Query::having($inner); - $this->assertEquals(Query::TYPE_HAVING, $query->getMethod()); + $this->assertSame(Method::Having, $query->getMethod()); $this->assertCount(1, $query->getValues()); $this->assertInstanceOf(Query::class, $query->getValues()[0]); } - public function testAggregateTypesConstant(): void + public function testAggregateMethodsAreAggregate(): void { - $this->assertContains(Query::TYPE_COUNT, Query::AGGREGATE_TYPES); - $this->assertContains(Query::TYPE_SUM, Query::AGGREGATE_TYPES); - $this->assertContains(Query::TYPE_AVG, Query::AGGREGATE_TYPES); - $this->assertContains(Query::TYPE_MIN, Query::AGGREGATE_TYPES); - $this->assertContains(Query::TYPE_MAX, Query::AGGREGATE_TYPES); - $this->assertCount(5, Query::AGGREGATE_TYPES); + $this->assertTrue(Method::Count->isAggregate()); + $this->assertTrue(Method::Sum->isAggregate()); + $this->assertTrue(Method::Avg->isAggregate()); + $this->assertTrue(Method::Min->isAggregate()); + $this->assertTrue(Method::Max->isAggregate()); + $aggMethods = array_filter(Method::cases(), fn (Method $m) => $m->isAggregate()); + $this->assertCount(5, $aggMethods); } // ── Edge cases ── @@ -132,7 +134,7 @@ public function testMaxWithAlias(): void public function testGroupByEmpty(): void { $query = Query::groupBy([]); - $this->assertEquals(Query::TYPE_GROUP_BY, $query->getMethod()); + $this->assertSame(Method::GroupBy, $query->getMethod()); $this->assertEquals([], $query->getValues()); } @@ -158,7 +160,7 @@ public function testGroupByDuplicateColumns(): void public function testHavingEmpty(): void { $query = Query::having([]); - $this->assertEquals(Query::TYPE_HAVING, $query->getMethod()); + $this->assertSame(Method::Having, $query->getMethod()); $this->assertEquals([], $query->getValues()); } diff --git a/tests/Query/Builder/ClickHouseTest.php b/tests/Query/Builder/ClickHouseTest.php index 49fe057..be282a0 100644 --- a/tests/Query/Builder/ClickHouseTest.php +++ b/tests/Query/Builder/ClickHouseTest.php @@ -4,8 +4,8 @@ use PHPUnit\Framework\TestCase; use Utopia\Query\Builder\ClickHouse as Builder; +use Utopia\Query\Builder\Condition; use Utopia\Query\Compiler; -use Utopia\Query\Condition; use Utopia\Query\Exception; use Utopia\Query\Hook\AttributeMapHook; use Utopia\Query\Hook\FilterHook; @@ -30,7 +30,7 @@ public function testBasicSelect(): void ->select(['name', 'timestamp']) ->build(); - $this->assertEquals('SELECT `name`, `timestamp` FROM `events`', $result['query']); + $this->assertEquals('SELECT `name`, `timestamp` FROM `events`', $result->query); } public function testFilterAndSort(): void @@ -47,9 +47,9 @@ public function testFilterAndSort(): void $this->assertEquals( 'SELECT * FROM `events` WHERE `status` IN (?) AND `count` > ? ORDER BY `timestamp` DESC LIMIT ?', - $result['query'] + $result->query ); - $this->assertEquals(['active', 10, 100], $result['bindings']); + $this->assertEquals(['active', 10, 100], $result->bindings); } // ── ClickHouse-specific: regex uses match() ── @@ -61,8 +61,8 @@ public function testRegexUsesMatchFunction(): void ->filter([Query::regex('path', '^/api/v[0-9]+')]) ->build(); - $this->assertEquals('SELECT * FROM `logs` WHERE match(`path`, ?)', $result['query']); - $this->assertEquals(['^/api/v[0-9]+'], $result['bindings']); + $this->assertEquals('SELECT * FROM `logs` WHERE match(`path`, ?)', $result->query); + $this->assertEquals(['^/api/v[0-9]+'], $result->bindings); } // ── ClickHouse-specific: search throws exception ── @@ -98,7 +98,7 @@ public function testRandomOrderUsesLowercaseRand(): void ->sortRandom() ->build(); - $this->assertEquals('SELECT * FROM `events` ORDER BY rand()', $result['query']); + $this->assertEquals('SELECT * FROM `events` ORDER BY rand()', $result->query); } // ── FINAL keyword ── @@ -110,7 +110,7 @@ public function testFinalKeyword(): void ->final() ->build(); - $this->assertEquals('SELECT * FROM `events` FINAL', $result['query']); + $this->assertEquals('SELECT * FROM `events` FINAL', $result->query); } public function testFinalWithFilters(): void @@ -124,9 +124,9 @@ public function testFinalWithFilters(): void $this->assertEquals( 'SELECT * FROM `events` FINAL WHERE `status` IN (?) LIMIT ?', - $result['query'] + $result->query ); - $this->assertEquals(['active', 10], $result['bindings']); + $this->assertEquals(['active', 10], $result->bindings); } // ── SAMPLE clause ── @@ -138,7 +138,7 @@ public function testSample(): void ->sample(0.1) ->build(); - $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1', $result['query']); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1', $result->query); } public function testSampleWithFinal(): void @@ -149,7 +149,7 @@ public function testSampleWithFinal(): void ->sample(0.5) ->build(); - $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.5', $result['query']); + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.5', $result->query); } // ── PREWHERE clause ── @@ -163,9 +163,9 @@ public function testPrewhere(): void $this->assertEquals( 'SELECT * FROM `events` PREWHERE `event_type` IN (?)', - $result['query'] + $result->query ); - $this->assertEquals(['click'], $result['bindings']); + $this->assertEquals(['click'], $result->bindings); } public function testPrewhereWithMultipleConditions(): void @@ -180,9 +180,9 @@ public function testPrewhereWithMultipleConditions(): void $this->assertEquals( 'SELECT * FROM `events` PREWHERE `event_type` IN (?) AND `timestamp` > ?', - $result['query'] + $result->query ); - $this->assertEquals(['click', '2024-01-01'], $result['bindings']); + $this->assertEquals(['click', '2024-01-01'], $result->bindings); } public function testPrewhereWithWhere(): void @@ -195,9 +195,9 @@ public function testPrewhereWithWhere(): void $this->assertEquals( 'SELECT * FROM `events` PREWHERE `event_type` IN (?) WHERE `count` > ?', - $result['query'] + $result->query ); - $this->assertEquals(['click', 5], $result['bindings']); + $this->assertEquals(['click', 5], $result->bindings); } public function testPrewhereWithJoinAndWhere(): void @@ -211,9 +211,9 @@ public function testPrewhereWithJoinAndWhere(): void $this->assertEquals( 'SELECT * FROM `events` JOIN `users` ON `events`.`user_id` = `users`.`id` PREWHERE `event_type` IN (?) WHERE `users`.`age` > ?', - $result['query'] + $result->query ); - $this->assertEquals(['click', 18], $result['bindings']); + $this->assertEquals(['click', 18], $result->bindings); } // ── Combined ClickHouse features ── @@ -232,9 +232,9 @@ public function testFinalSamplePrewhereWhere(): void $this->assertEquals( 'SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `event_type` IN (?) WHERE `count` > ? ORDER BY `timestamp` DESC LIMIT ?', - $result['query'] + $result->query ); - $this->assertEquals(['click', 5, 100], $result['bindings']); + $this->assertEquals(['click', 5, 100], $result->bindings); } // ── Aggregations work ── @@ -251,9 +251,9 @@ public function testAggregation(): void $this->assertEquals( 'SELECT COUNT(*) AS `total`, SUM(`duration`) AS `total_duration` FROM `events` GROUP BY `event_type` HAVING `total` > ?', - $result['query'] + $result->query ); - $this->assertEquals([10], $result['bindings']); + $this->assertEquals([10], $result->bindings); } // ── Joins work ── @@ -268,7 +268,7 @@ public function testJoin(): void $this->assertEquals( 'SELECT * FROM `events` JOIN `users` ON `events`.`user_id` = `users`.`id` LEFT JOIN `sessions` ON `events`.`session_id` = `sessions`.`id`', - $result['query'] + $result->query ); } @@ -282,7 +282,7 @@ public function testDistinct(): void ->select(['user_id']) ->build(); - $this->assertEquals('SELECT DISTINCT `user_id` FROM `events`', $result['query']); + $this->assertEquals('SELECT DISTINCT `user_id` FROM `events`', $result->query); } // ── Union ── @@ -299,9 +299,9 @@ public function testUnion(): void $this->assertEquals( '(SELECT * FROM `events` WHERE `year` IN (?)) UNION (SELECT * FROM `events_archive` WHERE `year` IN (?))', - $result['query'] + $result->query ); - $this->assertEquals([2024, 2023], $result['bindings']); + $this->assertEquals([2024, 2023], $result->bindings); } // ── toRawSql ── @@ -337,8 +337,8 @@ public function testResetClearsClickHouseState(): void $result = $builder->from('logs')->build(); - $this->assertEquals('SELECT * FROM `logs`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `logs`', $result->query); + $this->assertEquals([], $result->bindings); } // ── Fluent chaining ── @@ -370,7 +370,7 @@ public function testAttributeResolver(): void $this->assertEquals( 'SELECT * FROM `events` WHERE `_uid` IN (?)', - $result['query'] + $result->query ); } @@ -393,9 +393,9 @@ public function filter(string $table): Condition $this->assertEquals( 'SELECT * FROM `events` WHERE `status` IN (?) AND _tenant = ?', - $result['query'] + $result->query ); - $this->assertEquals(['active', 't1'], $result['bindings']); + $this->assertEquals(['active', 't1'], $result->bindings); } // ── Prewhere binding order ── @@ -410,7 +410,7 @@ public function testPrewhereBindingOrder(): void ->build(); // prewhere bindings come before where bindings - $this->assertEquals(['click', 5, 10], $result['bindings']); + $this->assertEquals(['click', 5, 10], $result->bindings); } // ── Combined PREWHERE + WHERE + JOIN + GROUP BY ── @@ -432,7 +432,7 @@ public function testCombinedPrewhereWhereJoinGroupBy(): void ->limit(50) ->build(); - $query = $result['query']; + $query = $result->query; // Verify clause ordering $this->assertStringContainsString('SELECT', $query); @@ -460,8 +460,8 @@ public function testPrewhereEmptyArray(): void ->prewhere([]) ->build(); - $this->assertEquals('SELECT * FROM `events`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `events`', $result->query); + $this->assertEquals([], $result->bindings); } public function testPrewhereSingleEqual(): void @@ -471,8 +471,8 @@ public function testPrewhereSingleEqual(): void ->prewhere([Query::equal('status', ['active'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `status` IN (?)', $result['query']); - $this->assertEquals(['active'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `status` IN (?)', $result->query); + $this->assertEquals(['active'], $result->bindings); } public function testPrewhereSingleNotEqual(): void @@ -482,8 +482,8 @@ public function testPrewhereSingleNotEqual(): void ->prewhere([Query::notEqual('status', 'deleted')]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `status` != ?', $result['query']); - $this->assertEquals(['deleted'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `status` != ?', $result->query); + $this->assertEquals(['deleted'], $result->bindings); } public function testPrewhereLessThan(): void @@ -493,8 +493,8 @@ public function testPrewhereLessThan(): void ->prewhere([Query::lessThan('age', 30)]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `age` < ?', $result['query']); - $this->assertEquals([30], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `age` < ?', $result->query); + $this->assertEquals([30], $result->bindings); } public function testPrewhereLessThanEqual(): void @@ -504,8 +504,8 @@ public function testPrewhereLessThanEqual(): void ->prewhere([Query::lessThanEqual('age', 30)]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `age` <= ?', $result['query']); - $this->assertEquals([30], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `age` <= ?', $result->query); + $this->assertEquals([30], $result->bindings); } public function testPrewhereGreaterThan(): void @@ -515,8 +515,8 @@ public function testPrewhereGreaterThan(): void ->prewhere([Query::greaterThan('score', 50)]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `score` > ?', $result['query']); - $this->assertEquals([50], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `score` > ?', $result->query); + $this->assertEquals([50], $result->bindings); } public function testPrewhereGreaterThanEqual(): void @@ -526,8 +526,8 @@ public function testPrewhereGreaterThanEqual(): void ->prewhere([Query::greaterThanEqual('score', 50)]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `score` >= ?', $result['query']); - $this->assertEquals([50], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `score` >= ?', $result->query); + $this->assertEquals([50], $result->bindings); } public function testPrewhereBetween(): void @@ -537,8 +537,8 @@ public function testPrewhereBetween(): void ->prewhere([Query::between('age', 18, 65)]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `age` BETWEEN ? AND ?', $result['query']); - $this->assertEquals([18, 65], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertEquals([18, 65], $result->bindings); } public function testPrewhereNotBetween(): void @@ -548,8 +548,8 @@ public function testPrewhereNotBetween(): void ->prewhere([Query::notBetween('age', 0, 17)]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `age` NOT BETWEEN ? AND ?', $result['query']); - $this->assertEquals([0, 17], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `age` NOT BETWEEN ? AND ?', $result->query); + $this->assertEquals([0, 17], $result->bindings); } public function testPrewhereStartsWith(): void @@ -559,8 +559,8 @@ public function testPrewhereStartsWith(): void ->prewhere([Query::startsWith('path', '/api')]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `path` LIKE ?', $result['query']); - $this->assertEquals(['/api%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `path` LIKE ?', $result->query); + $this->assertEquals(['/api%'], $result->bindings); } public function testPrewhereNotStartsWith(): void @@ -570,8 +570,8 @@ public function testPrewhereNotStartsWith(): void ->prewhere([Query::notStartsWith('path', '/admin')]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `path` NOT LIKE ?', $result['query']); - $this->assertEquals(['/admin%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `path` NOT LIKE ?', $result->query); + $this->assertEquals(['/admin%'], $result->bindings); } public function testPrewhereEndsWith(): void @@ -581,8 +581,8 @@ public function testPrewhereEndsWith(): void ->prewhere([Query::endsWith('file', '.csv')]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `file` LIKE ?', $result['query']); - $this->assertEquals(['%.csv'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `file` LIKE ?', $result->query); + $this->assertEquals(['%.csv'], $result->bindings); } public function testPrewhereNotEndsWith(): void @@ -592,8 +592,8 @@ public function testPrewhereNotEndsWith(): void ->prewhere([Query::notEndsWith('file', '.tmp')]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `file` NOT LIKE ?', $result['query']); - $this->assertEquals(['%.tmp'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `file` NOT LIKE ?', $result->query); + $this->assertEquals(['%.tmp'], $result->bindings); } public function testPrewhereContainsSingle(): void @@ -603,8 +603,8 @@ public function testPrewhereContainsSingle(): void ->prewhere([Query::contains('name', ['foo'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `name` LIKE ?', $result['query']); - $this->assertEquals(['%foo%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `name` LIKE ?', $result->query); + $this->assertEquals(['%foo%'], $result->bindings); } public function testPrewhereContainsMultiple(): void @@ -614,8 +614,8 @@ public function testPrewhereContainsMultiple(): void ->prewhere([Query::contains('name', ['foo', 'bar'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE (`name` LIKE ? OR `name` LIKE ?)', $result['query']); - $this->assertEquals(['%foo%', '%bar%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE (`name` LIKE ? OR `name` LIKE ?)', $result->query); + $this->assertEquals(['%foo%', '%bar%'], $result->bindings); } public function testPrewhereContainsAny(): void @@ -625,8 +625,8 @@ public function testPrewhereContainsAny(): void ->prewhere([Query::containsAny('tag', ['a', 'b', 'c'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `tag` IN (?, ?, ?)', $result['query']); - $this->assertEquals(['a', 'b', 'c'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `tag` IN (?, ?, ?)', $result->query); + $this->assertEquals(['a', 'b', 'c'], $result->bindings); } public function testPrewhereContainsAll(): void @@ -636,8 +636,8 @@ public function testPrewhereContainsAll(): void ->prewhere([Query::containsAll('tag', ['x', 'y'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE (`tag` LIKE ? AND `tag` LIKE ?)', $result['query']); - $this->assertEquals(['%x%', '%y%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE (`tag` LIKE ? AND `tag` LIKE ?)', $result->query); + $this->assertEquals(['%x%', '%y%'], $result->bindings); } public function testPrewhereNotContainsSingle(): void @@ -647,8 +647,8 @@ public function testPrewhereNotContainsSingle(): void ->prewhere([Query::notContains('name', ['bad'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `name` NOT LIKE ?', $result['query']); - $this->assertEquals(['%bad%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `name` NOT LIKE ?', $result->query); + $this->assertEquals(['%bad%'], $result->bindings); } public function testPrewhereNotContainsMultiple(): void @@ -658,8 +658,8 @@ public function testPrewhereNotContainsMultiple(): void ->prewhere([Query::notContains('name', ['bad', 'ugly'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE (`name` NOT LIKE ? AND `name` NOT LIKE ?)', $result['query']); - $this->assertEquals(['%bad%', '%ugly%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE (`name` NOT LIKE ? AND `name` NOT LIKE ?)', $result->query); + $this->assertEquals(['%bad%', '%ugly%'], $result->bindings); } public function testPrewhereIsNull(): void @@ -669,8 +669,8 @@ public function testPrewhereIsNull(): void ->prewhere([Query::isNull('deleted_at')]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `deleted_at` IS NULL', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `deleted_at` IS NULL', $result->query); + $this->assertEquals([], $result->bindings); } public function testPrewhereIsNotNull(): void @@ -680,8 +680,8 @@ public function testPrewhereIsNotNull(): void ->prewhere([Query::isNotNull('email')]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `email` IS NOT NULL', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `email` IS NOT NULL', $result->query); + $this->assertEquals([], $result->bindings); } public function testPrewhereExists(): void @@ -691,7 +691,7 @@ public function testPrewhereExists(): void ->prewhere([Query::exists(['col_a', 'col_b'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE (`col_a` IS NOT NULL AND `col_b` IS NOT NULL)', $result['query']); + $this->assertEquals('SELECT * FROM `events` PREWHERE (`col_a` IS NOT NULL AND `col_b` IS NOT NULL)', $result->query); } public function testPrewhereNotExists(): void @@ -701,7 +701,7 @@ public function testPrewhereNotExists(): void ->prewhere([Query::notExists(['col_a'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE (`col_a` IS NULL)', $result['query']); + $this->assertEquals('SELECT * FROM `events` PREWHERE (`col_a` IS NULL)', $result->query); } public function testPrewhereRegex(): void @@ -711,8 +711,8 @@ public function testPrewhereRegex(): void ->prewhere([Query::regex('path', '^/api')]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE match(`path`, ?)', $result['query']); - $this->assertEquals(['^/api'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE match(`path`, ?)', $result->query); + $this->assertEquals(['^/api'], $result->bindings); } public function testPrewhereAndLogical(): void @@ -725,8 +725,8 @@ public function testPrewhereAndLogical(): void ])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE (`a` IN (?) AND `b` IN (?))', $result['query']); - $this->assertEquals([1, 2], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE (`a` IN (?) AND `b` IN (?))', $result->query); + $this->assertEquals([1, 2], $result->bindings); } public function testPrewhereOrLogical(): void @@ -739,8 +739,8 @@ public function testPrewhereOrLogical(): void ])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE (`a` IN (?) OR `b` IN (?))', $result['query']); - $this->assertEquals([1, 2], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE (`a` IN (?) OR `b` IN (?))', $result->query); + $this->assertEquals([1, 2], $result->bindings); } public function testPrewhereNestedAndOr(): void @@ -756,8 +756,8 @@ public function testPrewhereNestedAndOr(): void ])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE ((`x` IN (?) OR `y` IN (?)) AND `z` > ?)', $result['query']); - $this->assertEquals([1, 2, 0], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE ((`x` IN (?) OR `y` IN (?)) AND `z` > ?)', $result->query); + $this->assertEquals([1, 2, 0], $result->bindings); } public function testPrewhereRawExpression(): void @@ -767,8 +767,8 @@ public function testPrewhereRawExpression(): void ->prewhere([Query::raw('toDate(created) > ?', ['2024-01-01'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE toDate(created) > ?', $result['query']); - $this->assertEquals(['2024-01-01'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE toDate(created) > ?', $result->query); + $this->assertEquals(['2024-01-01'], $result->bindings); } public function testPrewhereMultipleCallsAdditive(): void @@ -779,8 +779,8 @@ public function testPrewhereMultipleCallsAdditive(): void ->prewhere([Query::equal('b', [2])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `a` IN (?) AND `b` IN (?)', $result['query']); - $this->assertEquals([1, 2], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `a` IN (?) AND `b` IN (?)', $result->query); + $this->assertEquals([1, 2], $result->bindings); } public function testPrewhereWithWhereFinal(): void @@ -794,7 +794,7 @@ public function testPrewhereWithWhereFinal(): void $this->assertEquals( 'SELECT * FROM `events` FINAL PREWHERE `type` IN (?) WHERE `count` > ?', - $result['query'] + $result->query ); } @@ -809,7 +809,7 @@ public function testPrewhereWithWhereSample(): void $this->assertEquals( 'SELECT * FROM `events` SAMPLE 0.5 PREWHERE `type` IN (?) WHERE `count` > ?', - $result['query'] + $result->query ); } @@ -825,9 +825,9 @@ public function testPrewhereWithWhereFinalSample(): void $this->assertEquals( 'SELECT * FROM `events` FINAL SAMPLE 0.3 PREWHERE `type` IN (?) WHERE `count` > ?', - $result['query'] + $result->query ); - $this->assertEquals(['click', 5], $result['bindings']); + $this->assertEquals(['click', 5], $result->bindings); } public function testPrewhereWithGroupBy(): void @@ -839,8 +839,8 @@ public function testPrewhereWithGroupBy(): void ->groupBy(['type']) ->build(); - $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); - $this->assertStringContainsString('GROUP BY `type`', $result['query']); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); + $this->assertStringContainsString('GROUP BY `type`', $result->query); } public function testPrewhereWithHaving(): void @@ -853,8 +853,8 @@ public function testPrewhereWithHaving(): void ->having([Query::greaterThan('total', 10)]) ->build(); - $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); - $this->assertStringContainsString('HAVING `total` > ?', $result['query']); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); + $this->assertStringContainsString('HAVING `total` > ?', $result->query); } public function testPrewhereWithOrderBy(): void @@ -867,7 +867,7 @@ public function testPrewhereWithOrderBy(): void $this->assertEquals( 'SELECT * FROM `events` PREWHERE `type` IN (?) ORDER BY `name` ASC', - $result['query'] + $result->query ); } @@ -882,9 +882,9 @@ public function testPrewhereWithLimitOffset(): void $this->assertEquals( 'SELECT * FROM `events` PREWHERE `type` IN (?) LIMIT ? OFFSET ?', - $result['query'] + $result->query ); - $this->assertEquals(['click', 10, 20], $result['bindings']); + $this->assertEquals(['click', 10, 20], $result->bindings); } public function testPrewhereWithUnion(): void @@ -896,8 +896,8 @@ public function testPrewhereWithUnion(): void ->union($other) ->build(); - $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); - $this->assertStringContainsString('UNION (SELECT', $result['query']); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); + $this->assertStringContainsString('UNION (SELECT', $result->query); } public function testPrewhereWithDistinct(): void @@ -909,8 +909,8 @@ public function testPrewhereWithDistinct(): void ->prewhere([Query::equal('type', ['click'])]) ->build(); - $this->assertStringContainsString('SELECT DISTINCT', $result['query']); - $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); + $this->assertStringContainsString('SELECT DISTINCT', $result->query); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); } public function testPrewhereWithAggregations(): void @@ -921,8 +921,8 @@ public function testPrewhereWithAggregations(): void ->sum('amount', 'total_amount') ->build(); - $this->assertStringContainsString('SUM(`amount`) AS `total_amount`', $result['query']); - $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); + $this->assertStringContainsString('SUM(`amount`) AS `total_amount`', $result->query); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); } public function testPrewhereBindingOrderWithProvider(): void @@ -939,7 +939,7 @@ public function filter(string $table): Condition }) ->build(); - $this->assertEquals(['click', 5, 't1'], $result['bindings']); + $this->assertEquals(['click', 5, 't1'], $result->bindings); } public function testPrewhereBindingOrderWithCursor(): void @@ -953,9 +953,9 @@ public function testPrewhereBindingOrderWithCursor(): void ->build(); // prewhere, where filter, cursor - $this->assertEquals('click', $result['bindings'][0]); - $this->assertEquals(5, $result['bindings'][1]); - $this->assertEquals('abc123', $result['bindings'][2]); + $this->assertEquals('click', $result->bindings[0]); + $this->assertEquals(5, $result->bindings[1]); + $this->assertEquals('abc123', $result->bindings[2]); } public function testPrewhereBindingOrderComplex(): void @@ -982,10 +982,10 @@ public function filter(string $table): Condition ->build(); // prewhere, filter, provider, cursor, having, limit, offset, union - $this->assertEquals('click', $result['bindings'][0]); - $this->assertEquals(5, $result['bindings'][1]); - $this->assertEquals('t1', $result['bindings'][2]); - $this->assertEquals('cur1', $result['bindings'][3]); + $this->assertEquals('click', $result->bindings[0]); + $this->assertEquals(5, $result->bindings[1]); + $this->assertEquals('t1', $result->bindings[2]); + $this->assertEquals('cur1', $result->bindings[3]); } public function testPrewhereWithAttributeResolver(): void @@ -998,8 +998,8 @@ public function testPrewhereWithAttributeResolver(): void ->prewhere([Query::equal('$id', ['abc'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `_uid` IN (?)', $result['query']); - $this->assertEquals(['abc'], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` PREWHERE `_uid` IN (?)', $result->query); + $this->assertEquals(['abc'], $result->bindings); } public function testPrewhereOnlyNoWhere(): void @@ -1009,9 +1009,9 @@ public function testPrewhereOnlyNoWhere(): void ->prewhere([Query::greaterThan('ts', 100)]) ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('PREWHERE', $result->query); // "PREWHERE" contains "WHERE" as a substring, so we check there is no standalone WHERE clause - $withoutPrewhere = str_replace('PREWHERE', '', $result['query']); + $withoutPrewhere = str_replace('PREWHERE', '', $result->query); $this->assertStringNotContainsString('WHERE', $withoutPrewhere); } @@ -1023,8 +1023,8 @@ public function testPrewhereWithEmptyWhereFilter(): void ->filter([]) ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); - $withoutPrewhere = str_replace('PREWHERE', '', $result['query']); + $this->assertStringContainsString('PREWHERE', $result->query); + $withoutPrewhere = str_replace('PREWHERE', '', $result->query); $this->assertStringNotContainsString('WHERE', $withoutPrewhere); } @@ -1037,7 +1037,7 @@ public function testPrewhereAppearsAfterJoinsBeforeWhere(): void ->filter([Query::greaterThan('age', 18)]) ->build(); - $query = $result['query']; + $query = $result->query; $joinPos = strpos($query, 'JOIN'); $prewherePos = strpos($query, 'PREWHERE'); $wherePos = strpos($query, 'WHERE'); @@ -1059,9 +1059,9 @@ public function testPrewhereMultipleFiltersInSingleCall(): void $this->assertEquals( 'SELECT * FROM `events` PREWHERE `a` IN (?) AND `b` > ? AND `c` < ?', - $result['query'] + $result->query ); - $this->assertEquals([1, 2, 3], $result['bindings']); + $this->assertEquals([1, 2, 3], $result->bindings); } public function testPrewhereResetClearsPrewhereQueries(): void @@ -1074,7 +1074,7 @@ public function testPrewhereResetClearsPrewhereQueries(): void $builder->reset(); $result = $builder->from('events')->build(); - $this->assertStringNotContainsString('PREWHERE', $result['query']); + $this->assertStringNotContainsString('PREWHERE', $result->query); } public function testPrewhereInToRawSqlOutput(): void @@ -1103,7 +1103,7 @@ public function testFinalBasicSelect(): void ->select(['name', 'ts']) ->build(); - $this->assertEquals('SELECT `name`, `ts` FROM `events` FINAL', $result['query']); + $this->assertEquals('SELECT `name`, `ts` FROM `events` FINAL', $result->query); } public function testFinalWithJoins(): void @@ -1114,8 +1114,8 @@ public function testFinalWithJoins(): void ->join('users', 'events.uid', 'users.id') ->build(); - $this->assertStringContainsString('FROM `events` FINAL', $result['query']); - $this->assertStringContainsString('JOIN `users`', $result['query']); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('JOIN `users`', $result->query); } public function testFinalWithAggregations(): void @@ -1126,8 +1126,8 @@ public function testFinalWithAggregations(): void ->count('*', 'total') ->build(); - $this->assertStringContainsString('COUNT(*) AS `total`', $result['query']); - $this->assertStringContainsString('FROM `events` FINAL', $result['query']); + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); } public function testFinalWithGroupByHaving(): void @@ -1140,9 +1140,9 @@ public function testFinalWithGroupByHaving(): void ->having([Query::greaterThan('cnt', 5)]) ->build(); - $this->assertStringContainsString('FROM `events` FINAL', $result['query']); - $this->assertStringContainsString('GROUP BY `type`', $result['query']); - $this->assertStringContainsString('HAVING `cnt` > ?', $result['query']); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('GROUP BY `type`', $result->query); + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); } public function testFinalWithDistinct(): void @@ -1154,7 +1154,7 @@ public function testFinalWithDistinct(): void ->select(['user_id']) ->build(); - $this->assertEquals('SELECT DISTINCT `user_id` FROM `events` FINAL', $result['query']); + $this->assertEquals('SELECT DISTINCT `user_id` FROM `events` FINAL', $result->query); } public function testFinalWithSort(): void @@ -1166,7 +1166,7 @@ public function testFinalWithSort(): void ->sortDesc('ts') ->build(); - $this->assertEquals('SELECT * FROM `events` FINAL ORDER BY `name` ASC, `ts` DESC', $result['query']); + $this->assertEquals('SELECT * FROM `events` FINAL ORDER BY `name` ASC, `ts` DESC', $result->query); } public function testFinalWithLimitOffset(): void @@ -1178,8 +1178,8 @@ public function testFinalWithLimitOffset(): void ->offset(20) ->build(); - $this->assertEquals('SELECT * FROM `events` FINAL LIMIT ? OFFSET ?', $result['query']); - $this->assertEquals([10, 20], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` FINAL LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([10, 20], $result->bindings); } public function testFinalWithCursor(): void @@ -1191,8 +1191,8 @@ public function testFinalWithCursor(): void ->sortAsc('_cursor') ->build(); - $this->assertStringContainsString('FROM `events` FINAL', $result['query']); - $this->assertStringContainsString('`_cursor` > ?', $result['query']); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); } public function testFinalWithUnion(): void @@ -1204,8 +1204,8 @@ public function testFinalWithUnion(): void ->union($other) ->build(); - $this->assertStringContainsString('FROM `events` FINAL', $result['query']); - $this->assertStringContainsString('UNION (SELECT', $result['query']); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('UNION (SELECT', $result->query); } public function testFinalWithPrewhere(): void @@ -1216,7 +1216,7 @@ public function testFinalWithPrewhere(): void ->prewhere([Query::equal('type', ['click'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` FINAL PREWHERE `type` IN (?)', $result['query']); + $this->assertEquals('SELECT * FROM `events` FINAL PREWHERE `type` IN (?)', $result->query); } public function testFinalWithSampleAlone(): void @@ -1227,7 +1227,7 @@ public function testFinalWithSampleAlone(): void ->sample(0.25) ->build(); - $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.25', $result['query']); + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.25', $result->query); } public function testFinalWithPrewhereSample(): void @@ -1239,7 +1239,7 @@ public function testFinalWithPrewhereSample(): void ->prewhere([Query::equal('type', ['click'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.5 PREWHERE `type` IN (?)', $result['query']); + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.5 PREWHERE `type` IN (?)', $result->query); } public function testFinalFullPipeline(): void @@ -1256,7 +1256,7 @@ public function testFinalFullPipeline(): void ->offset(5) ->build(); - $query = $result['query']; + $query = $result->query; $this->assertStringContainsString('SELECT `name`', $query); $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.1', $query); $this->assertStringContainsString('PREWHERE', $query); @@ -1275,9 +1275,9 @@ public function testFinalCalledMultipleTimesIdempotent(): void ->final() ->build(); - $this->assertEquals('SELECT * FROM `events` FINAL', $result['query']); + $this->assertEquals('SELECT * FROM `events` FINAL', $result->query); // Ensure FINAL appears only once - $this->assertEquals(1, substr_count($result['query'], 'FINAL')); + $this->assertEquals(1, substr_count($result->query, 'FINAL')); } public function testFinalInToRawSql(): void @@ -1299,7 +1299,7 @@ public function testFinalPositionAfterTableBeforeJoins(): void ->join('users', 'events.uid', 'users.id') ->build(); - $query = $result['query']; + $query = $result->query; $finalPos = strpos($query, 'FINAL'); $joinPos = strpos($query, 'JOIN'); @@ -1320,8 +1320,8 @@ public function resolve(string $attribute): string ->filter([Query::equal('status', ['active'])]) ->build(); - $this->assertStringContainsString('FROM `events` FINAL', $result['query']); - $this->assertStringContainsString('`col_status`', $result['query']); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('`col_status`', $result->query); } public function testFinalWithConditionProvider(): void @@ -1337,8 +1337,8 @@ public function filter(string $table): Condition }) ->build(); - $this->assertStringContainsString('FROM `events` FINAL', $result['query']); - $this->assertStringContainsString('deleted = ?', $result['query']); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('deleted = ?', $result->query); } public function testFinalResetClearsFlag(): void @@ -1350,7 +1350,7 @@ public function testFinalResetClearsFlag(): void $builder->reset(); $result = $builder->from('events')->build(); - $this->assertStringNotContainsString('FINAL', $result['query']); + $this->assertStringNotContainsString('FINAL', $result->query); } public function testFinalWithWhenConditional(): void @@ -1360,14 +1360,14 @@ public function testFinalWithWhenConditional(): void ->when(true, fn (Builder $b) => $b->final()) ->build(); - $this->assertStringContainsString('FINAL', $result['query']); + $this->assertStringContainsString('FINAL', $result->query); $result2 = (new Builder()) ->from('events') ->when(false, fn (Builder $b) => $b->final()) ->build(); - $this->assertStringNotContainsString('FINAL', $result2['query']); + $this->assertStringNotContainsString('FINAL', $result2->query); } // ══════════════════════════════════════════════════════════════════ @@ -1377,25 +1377,25 @@ public function testFinalWithWhenConditional(): void public function testSample10Percent(): void { $result = (new Builder())->from('events')->sample(0.1)->build(); - $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1', $result['query']); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1', $result->query); } public function testSample50Percent(): void { $result = (new Builder())->from('events')->sample(0.5)->build(); - $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5', $result['query']); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5', $result->query); } public function testSample1Percent(): void { $result = (new Builder())->from('events')->sample(0.01)->build(); - $this->assertEquals('SELECT * FROM `events` SAMPLE 0.01', $result['query']); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.01', $result->query); } public function testSample99Percent(): void { $result = (new Builder())->from('events')->sample(0.99)->build(); - $this->assertEquals('SELECT * FROM `events` SAMPLE 0.99', $result['query']); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.99', $result->query); } public function testSampleWithFilters(): void @@ -1406,7 +1406,7 @@ public function testSampleWithFilters(): void ->filter([Query::equal('status', ['active'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` SAMPLE 0.2 WHERE `status` IN (?)', $result['query']); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.2 WHERE `status` IN (?)', $result->query); } public function testSampleWithJoins(): void @@ -1417,8 +1417,8 @@ public function testSampleWithJoins(): void ->join('users', 'events.uid', 'users.id') ->build(); - $this->assertStringContainsString('SAMPLE 0.3', $result['query']); - $this->assertStringContainsString('JOIN `users`', $result['query']); + $this->assertStringContainsString('SAMPLE 0.3', $result->query); + $this->assertStringContainsString('JOIN `users`', $result->query); } public function testSampleWithAggregations(): void @@ -1429,8 +1429,8 @@ public function testSampleWithAggregations(): void ->count('*', 'cnt') ->build(); - $this->assertStringContainsString('SAMPLE 0.1', $result['query']); - $this->assertStringContainsString('COUNT(*)', $result['query']); + $this->assertStringContainsString('SAMPLE 0.1', $result->query); + $this->assertStringContainsString('COUNT(*)', $result->query); } public function testSampleWithGroupByHaving(): void @@ -1443,9 +1443,9 @@ public function testSampleWithGroupByHaving(): void ->having([Query::greaterThan('cnt', 2)]) ->build(); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); - $this->assertStringContainsString('GROUP BY', $result['query']); - $this->assertStringContainsString('HAVING', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('GROUP BY', $result->query); + $this->assertStringContainsString('HAVING', $result->query); } public function testSampleWithDistinct(): void @@ -1457,8 +1457,8 @@ public function testSampleWithDistinct(): void ->select(['user_id']) ->build(); - $this->assertStringContainsString('SELECT DISTINCT', $result['query']); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('SELECT DISTINCT', $result->query); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); } public function testSampleWithSort(): void @@ -1469,7 +1469,7 @@ public function testSampleWithSort(): void ->sortDesc('ts') ->build(); - $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 ORDER BY `ts` DESC', $result['query']); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 ORDER BY `ts` DESC', $result->query); } public function testSampleWithLimitOffset(): void @@ -1481,7 +1481,7 @@ public function testSampleWithLimitOffset(): void ->offset(20) ->build(); - $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 LIMIT ? OFFSET ?', $result['query']); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 LIMIT ? OFFSET ?', $result->query); } public function testSampleWithCursor(): void @@ -1493,8 +1493,8 @@ public function testSampleWithCursor(): void ->sortAsc('_cursor') ->build(); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); - $this->assertStringContainsString('`_cursor` > ?', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); } public function testSampleWithUnion(): void @@ -1506,8 +1506,8 @@ public function testSampleWithUnion(): void ->union($other) ->build(); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); - $this->assertStringContainsString('UNION', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('UNION', $result->query); } public function testSampleWithPrewhere(): void @@ -1518,7 +1518,7 @@ public function testSampleWithPrewhere(): void ->prewhere([Query::equal('type', ['click'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1 PREWHERE `type` IN (?)', $result['query']); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1 PREWHERE `type` IN (?)', $result->query); } public function testSampleWithFinalKeyword(): void @@ -1529,7 +1529,7 @@ public function testSampleWithFinalKeyword(): void ->sample(0.1) ->build(); - $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.1', $result['query']); + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.1', $result->query); } public function testSampleWithFinalPrewhere(): void @@ -1541,7 +1541,7 @@ public function testSampleWithFinalPrewhere(): void ->prewhere([Query::equal('t', ['a'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.2 PREWHERE `t` IN (?)', $result['query']); + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.2 PREWHERE `t` IN (?)', $result->query); } public function testSampleFullPipeline(): void @@ -1555,7 +1555,7 @@ public function testSampleFullPipeline(): void ->limit(10) ->build(); - $query = $result['query']; + $query = $result->query; $this->assertStringContainsString('SAMPLE 0.1', $query); $this->assertStringContainsString('SELECT `name`', $query); $this->assertStringContainsString('WHERE `count` > ?', $query); @@ -1581,7 +1581,7 @@ public function testSamplePositionAfterFinalBeforeJoins(): void ->join('users', 'events.uid', 'users.id') ->build(); - $query = $result['query']; + $query = $result->query; $samplePos = strpos($query, 'SAMPLE'); $joinPos = strpos($query, 'JOIN'); $finalPos = strpos($query, 'FINAL'); @@ -1597,7 +1597,7 @@ public function testSampleResetClearsFraction(): void $builder->reset(); $result = $builder->from('events')->build(); - $this->assertStringNotContainsString('SAMPLE', $result['query']); + $this->assertStringNotContainsString('SAMPLE', $result->query); } public function testSampleWithWhenConditional(): void @@ -1607,14 +1607,14 @@ public function testSampleWithWhenConditional(): void ->when(true, fn (Builder $b) => $b->sample(0.5)) ->build(); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); $result2 = (new Builder()) ->from('events') ->when(false, fn (Builder $b) => $b->sample(0.5)) ->build(); - $this->assertStringNotContainsString('SAMPLE', $result2['query']); + $this->assertStringNotContainsString('SAMPLE', $result2->query); } public function testSampleCalledMultipleTimesLastWins(): void @@ -1626,7 +1626,7 @@ public function testSampleCalledMultipleTimesLastWins(): void ->sample(0.9) ->build(); - $this->assertEquals('SELECT * FROM `events` SAMPLE 0.9', $result['query']); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.9', $result->query); } public function testSampleWithAttributeResolver(): void @@ -1643,8 +1643,8 @@ public function resolve(string $attribute): string ->filter([Query::equal('col', ['v'])]) ->build(); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); - $this->assertStringContainsString('`r_col`', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('`r_col`', $result->query); } // ══════════════════════════════════════════════════════════════════ @@ -1658,8 +1658,8 @@ public function testRegexBasicPattern(): void ->filter([Query::regex('msg', 'error|warn')]) ->build(); - $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result['query']); - $this->assertEquals(['error|warn'], $result['bindings']); + $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result->query); + $this->assertEquals(['error|warn'], $result->bindings); } public function testRegexWithEmptyPattern(): void @@ -1669,8 +1669,8 @@ public function testRegexWithEmptyPattern(): void ->filter([Query::regex('msg', '')]) ->build(); - $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result['query']); - $this->assertEquals([''], $result['bindings']); + $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result->query); + $this->assertEquals([''], $result->bindings); } public function testRegexWithSpecialChars(): void @@ -1682,7 +1682,7 @@ public function testRegexWithSpecialChars(): void ->build(); // Bindings preserve the pattern exactly as provided - $this->assertEquals([$pattern], $result['bindings']); + $this->assertEquals([$pattern], $result->bindings); } public function testRegexWithVeryLongPattern(): void @@ -1693,8 +1693,8 @@ public function testRegexWithVeryLongPattern(): void ->filter([Query::regex('msg', $longPattern)]) ->build(); - $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result['query']); - $this->assertEquals([$longPattern], $result['bindings']); + $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result->query); + $this->assertEquals([$longPattern], $result->bindings); } public function testRegexCombinedWithOtherFilters(): void @@ -1709,9 +1709,9 @@ public function testRegexCombinedWithOtherFilters(): void $this->assertEquals( 'SELECT * FROM `logs` WHERE match(`path`, ?) AND `status` IN (?)', - $result['query'] + $result->query ); - $this->assertEquals(['^/api', 200], $result['bindings']); + $this->assertEquals(['^/api', 200], $result->bindings); } public function testRegexInPrewhere(): void @@ -1721,8 +1721,8 @@ public function testRegexInPrewhere(): void ->prewhere([Query::regex('path', '^/api')]) ->build(); - $this->assertEquals('SELECT * FROM `logs` PREWHERE match(`path`, ?)', $result['query']); - $this->assertEquals(['^/api'], $result['bindings']); + $this->assertEquals('SELECT * FROM `logs` PREWHERE match(`path`, ?)', $result->query); + $this->assertEquals(['^/api'], $result->bindings); } public function testRegexInPrewhereAndWhere(): void @@ -1735,9 +1735,9 @@ public function testRegexInPrewhereAndWhere(): void $this->assertEquals( 'SELECT * FROM `logs` PREWHERE match(`path`, ?) WHERE match(`msg`, ?)', - $result['query'] + $result->query ); - $this->assertEquals(['^/api', 'err'], $result['bindings']); + $this->assertEquals(['^/api', 'err'], $result->bindings); } public function testRegexWithAttributeResolver(): void @@ -1753,7 +1753,7 @@ public function resolve(string $attribute): string ->filter([Query::regex('msg', 'test')]) ->build(); - $this->assertEquals('SELECT * FROM `logs` WHERE match(`col_msg`, ?)', $result['query']); + $this->assertEquals('SELECT * FROM `logs` WHERE match(`col_msg`, ?)', $result->query); } public function testRegexBindingPreserved(): void @@ -1764,7 +1764,7 @@ public function testRegexBindingPreserved(): void ->filter([Query::regex('msg', $pattern)]) ->build(); - $this->assertEquals([$pattern], $result['bindings']); + $this->assertEquals([$pattern], $result->bindings); } public function testMultipleRegexFilters(): void @@ -1779,7 +1779,7 @@ public function testMultipleRegexFilters(): void $this->assertEquals( 'SELECT * FROM `logs` WHERE match(`path`, ?) AND match(`msg`, ?)', - $result['query'] + $result->query ); } @@ -1795,7 +1795,7 @@ public function testRegexInAndLogical(): void $this->assertEquals( 'SELECT * FROM `logs` WHERE (match(`path`, ?) AND `status` > ?)', - $result['query'] + $result->query ); } @@ -1811,7 +1811,7 @@ public function testRegexInOrLogical(): void $this->assertEquals( 'SELECT * FROM `logs` WHERE (match(`path`, ?) OR match(`path`, ?))', - $result['query'] + $result->query ); } @@ -1828,8 +1828,8 @@ public function testRegexInNestedLogical(): void ])]) ->build(); - $this->assertStringContainsString('match(`path`, ?)', $result['query']); - $this->assertStringContainsString('`status` IN (?)', $result['query']); + $this->assertStringContainsString('match(`path`, ?)', $result->query); + $this->assertStringContainsString('`status` IN (?)', $result->query); } public function testRegexWithFinal(): void @@ -1840,8 +1840,8 @@ public function testRegexWithFinal(): void ->filter([Query::regex('path', '^/api')]) ->build(); - $this->assertStringContainsString('FROM `logs` FINAL', $result['query']); - $this->assertStringContainsString('match(`path`, ?)', $result['query']); + $this->assertStringContainsString('FROM `logs` FINAL', $result->query); + $this->assertStringContainsString('match(`path`, ?)', $result->query); } public function testRegexWithSample(): void @@ -1852,8 +1852,8 @@ public function testRegexWithSample(): void ->filter([Query::regex('path', '^/api')]) ->build(); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); - $this->assertStringContainsString('match(`path`, ?)', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('match(`path`, ?)', $result->query); } public function testRegexInToRawSql(): void @@ -1876,8 +1876,8 @@ public function testRegexCombinedWithContains(): void ]) ->build(); - $this->assertStringContainsString('match(`path`, ?)', $result['query']); - $this->assertStringContainsString('`msg` LIKE ?', $result['query']); + $this->assertStringContainsString('match(`path`, ?)', $result->query); + $this->assertStringContainsString('`msg` LIKE ?', $result->query); } public function testRegexCombinedWithStartsWith(): void @@ -1890,8 +1890,8 @@ public function testRegexCombinedWithStartsWith(): void ]) ->build(); - $this->assertStringContainsString('match(`path`, ?)', $result['query']); - $this->assertStringContainsString('`msg` LIKE ?', $result['query']); + $this->assertStringContainsString('match(`path`, ?)', $result->query); + $this->assertStringContainsString('`msg` LIKE ?', $result->query); } public function testRegexPrewhereWithRegexWhere(): void @@ -1902,9 +1902,9 @@ public function testRegexPrewhereWithRegexWhere(): void ->filter([Query::regex('msg', 'error')]) ->build(); - $this->assertStringContainsString('PREWHERE match(`path`, ?)', $result['query']); - $this->assertStringContainsString('WHERE match(`msg`, ?)', $result['query']); - $this->assertEquals(['^/api', 'error'], $result['bindings']); + $this->assertStringContainsString('PREWHERE match(`path`, ?)', $result->query); + $this->assertStringContainsString('WHERE match(`msg`, ?)', $result->query); + $this->assertEquals(['^/api', 'error'], $result->bindings); } public function testRegexCombinedWithPrewhereContainsRegex(): void @@ -1918,7 +1918,7 @@ public function testRegexCombinedWithPrewhereContainsRegex(): void ->filter([Query::regex('msg', 'timeout')]) ->build(); - $this->assertEquals(['^/api', 'error', 'timeout'], $result['bindings']); + $this->assertEquals(['^/api', 'error', 'timeout'], $result->bindings); } // ══════════════════════════════════════════════════════════════════ @@ -2052,8 +2052,8 @@ public function testRandomSortProducesLowercaseRand(): void ->sortRandom() ->build(); - $this->assertStringContainsString('rand()', $result['query']); - $this->assertStringNotContainsString('RAND()', $result['query']); + $this->assertStringContainsString('rand()', $result->query); + $this->assertStringNotContainsString('RAND()', $result->query); } public function testRandomSortCombinedWithAsc(): void @@ -2064,7 +2064,7 @@ public function testRandomSortCombinedWithAsc(): void ->sortRandom() ->build(); - $this->assertEquals('SELECT * FROM `events` ORDER BY `name` ASC, rand()', $result['query']); + $this->assertEquals('SELECT * FROM `events` ORDER BY `name` ASC, rand()', $result->query); } public function testRandomSortCombinedWithDesc(): void @@ -2075,7 +2075,7 @@ public function testRandomSortCombinedWithDesc(): void ->sortRandom() ->build(); - $this->assertEquals('SELECT * FROM `events` ORDER BY `ts` DESC, rand()', $result['query']); + $this->assertEquals('SELECT * FROM `events` ORDER BY `ts` DESC, rand()', $result->query); } public function testRandomSortCombinedWithAscAndDesc(): void @@ -2087,7 +2087,7 @@ public function testRandomSortCombinedWithAscAndDesc(): void ->sortRandom() ->build(); - $this->assertEquals('SELECT * FROM `events` ORDER BY `name` ASC, `ts` DESC, rand()', $result['query']); + $this->assertEquals('SELECT * FROM `events` ORDER BY `name` ASC, `ts` DESC, rand()', $result->query); } public function testRandomSortWithFinal(): void @@ -2098,7 +2098,7 @@ public function testRandomSortWithFinal(): void ->sortRandom() ->build(); - $this->assertEquals('SELECT * FROM `events` FINAL ORDER BY rand()', $result['query']); + $this->assertEquals('SELECT * FROM `events` FINAL ORDER BY rand()', $result->query); } public function testRandomSortWithSample(): void @@ -2109,7 +2109,7 @@ public function testRandomSortWithSample(): void ->sortRandom() ->build(); - $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 ORDER BY rand()', $result['query']); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 ORDER BY rand()', $result->query); } public function testRandomSortWithPrewhere(): void @@ -2122,7 +2122,7 @@ public function testRandomSortWithPrewhere(): void $this->assertEquals( 'SELECT * FROM `events` PREWHERE `type` IN (?) ORDER BY rand()', - $result['query'] + $result->query ); } @@ -2134,8 +2134,8 @@ public function testRandomSortWithLimit(): void ->limit(10) ->build(); - $this->assertEquals('SELECT * FROM `events` ORDER BY rand() LIMIT ?', $result['query']); - $this->assertEquals([10], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` ORDER BY rand() LIMIT ?', $result->query); + $this->assertEquals([10], $result->bindings); } public function testRandomSortWithFiltersAndJoins(): void @@ -2147,9 +2147,9 @@ public function testRandomSortWithFiltersAndJoins(): void ->sortRandom() ->build(); - $this->assertStringContainsString('JOIN `users`', $result['query']); - $this->assertStringContainsString('WHERE `status` IN (?)', $result['query']); - $this->assertStringContainsString('ORDER BY rand()', $result['query']); + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); + $this->assertStringContainsString('ORDER BY rand()', $result->query); } public function testRandomSortAlone(): void @@ -2159,8 +2159,8 @@ public function testRandomSortAlone(): void ->sortRandom() ->build(); - $this->assertEquals('SELECT * FROM `events` ORDER BY rand()', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `events` ORDER BY rand()', $result->query); + $this->assertEquals([], $result->bindings); } // ══════════════════════════════════════════════════════════════════ @@ -2170,160 +2170,160 @@ public function testRandomSortAlone(): void public function testFilterEqualSingleValue(): void { $result = (new Builder())->from('t')->filter([Query::equal('a', ['x'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $result['query']); - $this->assertEquals(['x'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $result->query); + $this->assertEquals(['x'], $result->bindings); } public function testFilterEqualMultipleValues(): void { $result = (new Builder())->from('t')->filter([Query::equal('a', ['x', 'y', 'z'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?, ?, ?)', $result['query']); - $this->assertEquals(['x', 'y', 'z'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?, ?, ?)', $result->query); + $this->assertEquals(['x', 'y', 'z'], $result->bindings); } public function testFilterNotEqualSingleValue(): void { $result = (new Builder())->from('t')->filter([Query::notEqual('a', 'x')])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` != ?', $result['query']); - $this->assertEquals(['x'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` != ?', $result->query); + $this->assertEquals(['x'], $result->bindings); } public function testFilterNotEqualMultipleValues(): void { $result = (new Builder())->from('t')->filter([Query::notEqual('a', ['x', 'y'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT IN (?, ?)', $result['query']); - $this->assertEquals(['x', 'y'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT IN (?, ?)', $result->query); + $this->assertEquals(['x', 'y'], $result->bindings); } public function testFilterLessThanValue(): void { $result = (new Builder())->from('t')->filter([Query::lessThan('a', 10)])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` < ?', $result['query']); - $this->assertEquals([10], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` < ?', $result->query); + $this->assertEquals([10], $result->bindings); } public function testFilterLessThanEqualValue(): void { $result = (new Builder())->from('t')->filter([Query::lessThanEqual('a', 10)])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` <= ?', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` <= ?', $result->query); } public function testFilterGreaterThanValue(): void { $result = (new Builder())->from('t')->filter([Query::greaterThan('a', 10)])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` > ?', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` > ?', $result->query); } public function testFilterGreaterThanEqualValue(): void { $result = (new Builder())->from('t')->filter([Query::greaterThanEqual('a', 10)])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` >= ?', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` >= ?', $result->query); } public function testFilterBetweenValues(): void { $result = (new Builder())->from('t')->filter([Query::between('a', 1, 10)])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` BETWEEN ? AND ?', $result['query']); - $this->assertEquals([1, 10], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` BETWEEN ? AND ?', $result->query); + $this->assertEquals([1, 10], $result->bindings); } public function testFilterNotBetweenValues(): void { $result = (new Builder())->from('t')->filter([Query::notBetween('a', 1, 10)])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT BETWEEN ? AND ?', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT BETWEEN ? AND ?', $result->query); } public function testFilterStartsWithValue(): void { $result = (new Builder())->from('t')->filter([Query::startsWith('a', 'foo')])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` LIKE ?', $result['query']); - $this->assertEquals(['foo%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` LIKE ?', $result->query); + $this->assertEquals(['foo%'], $result->bindings); } public function testFilterNotStartsWithValue(): void { $result = (new Builder())->from('t')->filter([Query::notStartsWith('a', 'foo')])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT LIKE ?', $result['query']); - $this->assertEquals(['foo%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT LIKE ?', $result->query); + $this->assertEquals(['foo%'], $result->bindings); } public function testFilterEndsWithValue(): void { $result = (new Builder())->from('t')->filter([Query::endsWith('a', 'bar')])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` LIKE ?', $result['query']); - $this->assertEquals(['%bar'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` LIKE ?', $result->query); + $this->assertEquals(['%bar'], $result->bindings); } public function testFilterNotEndsWithValue(): void { $result = (new Builder())->from('t')->filter([Query::notEndsWith('a', 'bar')])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT LIKE ?', $result['query']); - $this->assertEquals(['%bar'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT LIKE ?', $result->query); + $this->assertEquals(['%bar'], $result->bindings); } public function testFilterContainsSingleValue(): void { $result = (new Builder())->from('t')->filter([Query::contains('a', ['foo'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` LIKE ?', $result['query']); - $this->assertEquals(['%foo%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` LIKE ?', $result->query); + $this->assertEquals(['%foo%'], $result->bindings); } public function testFilterContainsMultipleValues(): void { $result = (new Builder())->from('t')->filter([Query::contains('a', ['foo', 'bar'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` LIKE ? OR `a` LIKE ?)', $result['query']); - $this->assertEquals(['%foo%', '%bar%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` LIKE ? OR `a` LIKE ?)', $result->query); + $this->assertEquals(['%foo%', '%bar%'], $result->bindings); } public function testFilterContainsAnyValues(): void { $result = (new Builder())->from('t')->filter([Query::containsAny('a', ['x', 'y'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?, ?)', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?, ?)', $result->query); } public function testFilterContainsAllValues(): void { $result = (new Builder())->from('t')->filter([Query::containsAll('a', ['x', 'y'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` LIKE ? AND `a` LIKE ?)', $result['query']); - $this->assertEquals(['%x%', '%y%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` LIKE ? AND `a` LIKE ?)', $result->query); + $this->assertEquals(['%x%', '%y%'], $result->bindings); } public function testFilterNotContainsSingleValue(): void { $result = (new Builder())->from('t')->filter([Query::notContains('a', ['foo'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT LIKE ?', $result['query']); - $this->assertEquals(['%foo%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT LIKE ?', $result->query); + $this->assertEquals(['%foo%'], $result->bindings); } public function testFilterNotContainsMultipleValues(): void { $result = (new Builder())->from('t')->filter([Query::notContains('a', ['foo', 'bar'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` NOT LIKE ? AND `a` NOT LIKE ?)', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` NOT LIKE ? AND `a` NOT LIKE ?)', $result->query); } public function testFilterIsNullValue(): void { $result = (new Builder())->from('t')->filter([Query::isNull('a')])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` IS NULL', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IS NULL', $result->query); + $this->assertEquals([], $result->bindings); } public function testFilterIsNotNullValue(): void { $result = (new Builder())->from('t')->filter([Query::isNotNull('a')])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` IS NOT NULL', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IS NOT NULL', $result->query); } public function testFilterExistsValue(): void { $result = (new Builder())->from('t')->filter([Query::exists(['a', 'b'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` IS NOT NULL AND `b` IS NOT NULL)', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IS NOT NULL AND `b` IS NOT NULL)', $result->query); } public function testFilterNotExistsValue(): void { $result = (new Builder())->from('t')->filter([Query::notExists(['a', 'b'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` IS NULL AND `b` IS NULL)', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IS NULL AND `b` IS NULL)', $result->query); } public function testFilterAndLogical(): void @@ -2332,7 +2332,7 @@ public function testFilterAndLogical(): void Query::and([Query::equal('a', [1]), Query::equal('b', [2])]), ])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) AND `b` IN (?))', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) AND `b` IN (?))', $result->query); } public function testFilterOrLogical(): void @@ -2341,14 +2341,14 @@ public function testFilterOrLogical(): void Query::or([Query::equal('a', [1]), Query::equal('b', [2])]), ])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR `b` IN (?))', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR `b` IN (?))', $result->query); } public function testFilterRaw(): void { $result = (new Builder())->from('t')->filter([Query::raw('x > ? AND y < ?', [1, 2])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE x > ? AND y < ?', $result['query']); - $this->assertEquals([1, 2], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE x > ? AND y < ?', $result->query); + $this->assertEquals([1, 2], $result->bindings); } public function testFilterDeeplyNestedLogical(): void @@ -2366,26 +2366,26 @@ public function testFilterDeeplyNestedLogical(): void ]), ])->build(); - $this->assertStringContainsString('(`a` IN (?) OR (`b` > ? AND `c` < ?))', $result['query']); - $this->assertStringContainsString('`d` IN (?)', $result['query']); + $this->assertStringContainsString('(`a` IN (?) OR (`b` > ? AND `c` < ?))', $result->query); + $this->assertStringContainsString('`d` IN (?)', $result->query); } public function testFilterWithFloats(): void { $result = (new Builder())->from('t')->filter([Query::greaterThan('price', 9.99)])->build(); - $this->assertEquals([9.99], $result['bindings']); + $this->assertEquals([9.99], $result->bindings); } public function testFilterWithNegativeNumbers(): void { $result = (new Builder())->from('t')->filter([Query::greaterThan('temp', -40)])->build(); - $this->assertEquals([-40], $result['bindings']); + $this->assertEquals([-40], $result->bindings); } public function testFilterWithEmptyStrings(): void { $result = (new Builder())->from('t')->filter([Query::equal('name', [''])])->build(); - $this->assertEquals([''], $result['bindings']); + $this->assertEquals([''], $result->bindings); } // ══════════════════════════════════════════════════════════════════ @@ -2400,7 +2400,7 @@ public function testAggregationCountWithFinal(): void ->count('*', 'total') ->build(); - $this->assertEquals('SELECT COUNT(*) AS `total` FROM `events` FINAL', $result['query']); + $this->assertEquals('SELECT COUNT(*) AS `total` FROM `events` FINAL', $result->query); } public function testAggregationSumWithSample(): void @@ -2411,7 +2411,7 @@ public function testAggregationSumWithSample(): void ->sum('amount', 'total_amount') ->build(); - $this->assertEquals('SELECT SUM(`amount`) AS `total_amount` FROM `events` SAMPLE 0.1', $result['query']); + $this->assertEquals('SELECT SUM(`amount`) AS `total_amount` FROM `events` SAMPLE 0.1', $result->query); } public function testAggregationAvgWithPrewhere(): void @@ -2422,8 +2422,8 @@ public function testAggregationAvgWithPrewhere(): void ->avg('price', 'avg_price') ->build(); - $this->assertStringContainsString('AVG(`price`) AS `avg_price`', $result['query']); - $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); + $this->assertStringContainsString('AVG(`price`) AS `avg_price`', $result->query); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); } public function testAggregationMinWithPrewhereWhere(): void @@ -2435,9 +2435,9 @@ public function testAggregationMinWithPrewhereWhere(): void ->min('price', 'min_price') ->build(); - $this->assertStringContainsString('MIN(`price`) AS `min_price`', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('WHERE', $result['query']); + $this->assertStringContainsString('MIN(`price`) AS `min_price`', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('WHERE', $result->query); } public function testAggregationMaxWithAllClickHouseFeatures(): void @@ -2450,9 +2450,9 @@ public function testAggregationMaxWithAllClickHouseFeatures(): void ->max('price', 'max_price') ->build(); - $this->assertStringContainsString('MAX(`price`) AS `max_price`', $result['query']); - $this->assertStringContainsString('FINAL SAMPLE 0.5', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('MAX(`price`) AS `max_price`', $result->query); + $this->assertStringContainsString('FINAL SAMPLE 0.5', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); } public function testMultipleAggregationsWithPrewhereGroupByHaving(): void @@ -2466,11 +2466,11 @@ public function testMultipleAggregationsWithPrewhereGroupByHaving(): void ->having([Query::greaterThan('cnt', 10)]) ->build(); - $this->assertStringContainsString('COUNT(*) AS `cnt`', $result['query']); - $this->assertStringContainsString('SUM(`amount`) AS `total`', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('GROUP BY `region`', $result['query']); - $this->assertStringContainsString('HAVING `cnt` > ?', $result['query']); + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('SUM(`amount`) AS `total`', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('GROUP BY `region`', $result->query); + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); } public function testAggregationWithJoinFinal(): void @@ -2482,9 +2482,9 @@ public function testAggregationWithJoinFinal(): void ->count('*', 'total') ->build(); - $this->assertStringContainsString('FROM `events` FINAL', $result['query']); - $this->assertStringContainsString('JOIN `users`', $result['query']); - $this->assertStringContainsString('COUNT(*)', $result['query']); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('COUNT(*)', $result->query); } public function testAggregationWithDistinctSample(): void @@ -2496,8 +2496,8 @@ public function testAggregationWithDistinctSample(): void ->count('user_id', 'unique_users') ->build(); - $this->assertStringContainsString('SELECT DISTINCT', $result['query']); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('SELECT DISTINCT', $result->query); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); } public function testAggregationWithAliasPrewhere(): void @@ -2508,8 +2508,8 @@ public function testAggregationWithAliasPrewhere(): void ->count('*', 'click_count') ->build(); - $this->assertStringContainsString('COUNT(*) AS `click_count`', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('COUNT(*) AS `click_count`', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); } public function testAggregationWithoutAliasFinal(): void @@ -2520,9 +2520,9 @@ public function testAggregationWithoutAliasFinal(): void ->count('*') ->build(); - $this->assertStringContainsString('COUNT(*)', $result['query']); - $this->assertStringNotContainsString(' AS ', $result['query']); - $this->assertStringContainsString('FINAL', $result['query']); + $this->assertStringContainsString('COUNT(*)', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + $this->assertStringContainsString('FINAL', $result->query); } public function testCountStarAllClickHouseFeatures(): void @@ -2535,9 +2535,9 @@ public function testCountStarAllClickHouseFeatures(): void ->count('*', 'total') ->build(); - $this->assertStringContainsString('COUNT(*) AS `total`', $result['query']); - $this->assertStringContainsString('FINAL SAMPLE 0.5', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + $this->assertStringContainsString('FINAL SAMPLE 0.5', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); } public function testAggregationAllFeaturesUnion(): void @@ -2552,8 +2552,8 @@ public function testAggregationAllFeaturesUnion(): void ->union($other) ->build(); - $this->assertStringContainsString('UNION', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('UNION', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); } public function testAggregationAttributeResolverPrewhere(): void @@ -2567,7 +2567,7 @@ public function testAggregationAttributeResolverPrewhere(): void ->sum('amt', 'total') ->build(); - $this->assertStringContainsString('SUM(`amount_cents`)', $result['query']); + $this->assertStringContainsString('SUM(`amount_cents`)', $result->query); } public function testAggregationConditionProviderPrewhere(): void @@ -2584,8 +2584,8 @@ public function filter(string $table): Condition ->count('*', 'cnt') ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('tenant = ?', $result['query']); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('tenant = ?', $result->query); } public function testGroupByHavingPrewhereFinal(): void @@ -2599,7 +2599,7 @@ public function testGroupByHavingPrewhereFinal(): void ->having([Query::greaterThan('cnt', 5)]) ->build(); - $query = $result['query']; + $query = $result->query; $this->assertStringContainsString('FINAL', $query); $this->assertStringContainsString('PREWHERE', $query); $this->assertStringContainsString('GROUP BY', $query); @@ -2620,7 +2620,7 @@ public function testJoinWithFinalFeature(): void $this->assertEquals( 'SELECT * FROM `events` FINAL JOIN `users` ON `events`.`uid` = `users`.`id`', - $result['query'] + $result->query ); } @@ -2634,7 +2634,7 @@ public function testJoinWithSampleFeature(): void $this->assertEquals( 'SELECT * FROM `events` SAMPLE 0.5 JOIN `users` ON `events`.`uid` = `users`.`id`', - $result['query'] + $result->query ); } @@ -2646,8 +2646,8 @@ public function testJoinWithPrewhereFeature(): void ->prewhere([Query::equal('type', ['click'])]) ->build(); - $this->assertStringContainsString('JOIN `users`', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); } public function testJoinWithPrewhereWhere(): void @@ -2659,9 +2659,9 @@ public function testJoinWithPrewhereWhere(): void ->filter([Query::greaterThan('users.age', 18)]) ->build(); - $this->assertStringContainsString('JOIN', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('WHERE', $result['query']); + $this->assertStringContainsString('JOIN', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('WHERE', $result->query); } public function testJoinAllClickHouseFeatures(): void @@ -2675,7 +2675,7 @@ public function testJoinAllClickHouseFeatures(): void ->filter([Query::greaterThan('users.age', 18)]) ->build(); - $query = $result['query']; + $query = $result->query; $this->assertStringContainsString('FINAL SAMPLE 0.1', $query); $this->assertStringContainsString('JOIN', $query); $this->assertStringContainsString('PREWHERE', $query); @@ -2690,8 +2690,8 @@ public function testLeftJoinWithPrewhere(): void ->prewhere([Query::equal('type', ['click'])]) ->build(); - $this->assertStringContainsString('LEFT JOIN `users`', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('LEFT JOIN `users`', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); } public function testRightJoinWithPrewhere(): void @@ -2702,8 +2702,8 @@ public function testRightJoinWithPrewhere(): void ->prewhere([Query::equal('type', ['click'])]) ->build(); - $this->assertStringContainsString('RIGHT JOIN `users`', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('RIGHT JOIN `users`', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); } public function testCrossJoinWithFinal(): void @@ -2714,8 +2714,8 @@ public function testCrossJoinWithFinal(): void ->crossJoin('config') ->build(); - $this->assertStringContainsString('FROM `events` FINAL', $result['query']); - $this->assertStringContainsString('CROSS JOIN `config`', $result['query']); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('CROSS JOIN `config`', $result->query); } public function testMultipleJoinsWithPrewhere(): void @@ -2727,9 +2727,9 @@ public function testMultipleJoinsWithPrewhere(): void ->prewhere([Query::equal('type', ['click'])]) ->build(); - $this->assertStringContainsString('JOIN `users`', $result['query']); - $this->assertStringContainsString('LEFT JOIN `sessions`', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('LEFT JOIN `sessions`', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); } public function testJoinAggregationPrewhereGroupBy(): void @@ -2742,9 +2742,9 @@ public function testJoinAggregationPrewhereGroupBy(): void ->groupBy(['users.country']) ->build(); - $this->assertStringContainsString('JOIN', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('GROUP BY', $result['query']); + $this->assertStringContainsString('JOIN', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('GROUP BY', $result->query); } public function testJoinPrewhereBindingOrder(): void @@ -2756,7 +2756,7 @@ public function testJoinPrewhereBindingOrder(): void ->filter([Query::greaterThan('users.age', 18)]) ->build(); - $this->assertEquals(['click', 18], $result['bindings']); + $this->assertEquals(['click', 18], $result->bindings); } public function testJoinAttributeResolverPrewhere(): void @@ -2770,7 +2770,7 @@ public function testJoinAttributeResolverPrewhere(): void ->prewhere([Query::equal('uid', ['abc'])]) ->build(); - $this->assertStringContainsString('PREWHERE `user_id` IN (?)', $result['query']); + $this->assertStringContainsString('PREWHERE `user_id` IN (?)', $result->query); } public function testJoinConditionProviderPrewhere(): void @@ -2787,8 +2787,8 @@ public function filter(string $table): Condition }) ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('tenant = ?', $result['query']); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('tenant = ?', $result->query); } public function testJoinPrewhereUnion(): void @@ -2801,9 +2801,9 @@ public function testJoinPrewhereUnion(): void ->union($other) ->build(); - $this->assertStringContainsString('JOIN', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('UNION', $result['query']); + $this->assertStringContainsString('JOIN', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('UNION', $result->query); } public function testJoinClauseOrdering(): void @@ -2817,7 +2817,7 @@ public function testJoinClauseOrdering(): void ->filter([Query::greaterThan('age', 18)]) ->build(); - $query = $result['query']; + $query = $result->query; $fromPos = strpos($query, 'FROM'); $finalPos = strpos($query, 'FINAL'); @@ -2846,8 +2846,8 @@ public function testUnionMainHasFinal(): void ->union($other) ->build(); - $this->assertStringContainsString('FROM `events` FINAL', $result['query']); - $this->assertStringContainsString('UNION (SELECT * FROM `archive`)', $result['query']); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('UNION (SELECT * FROM `archive`)', $result->query); } public function testUnionMainHasSample(): void @@ -2859,8 +2859,8 @@ public function testUnionMainHasSample(): void ->union($other) ->build(); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); - $this->assertStringContainsString('UNION', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('UNION', $result->query); } public function testUnionMainHasPrewhere(): void @@ -2872,8 +2872,8 @@ public function testUnionMainHasPrewhere(): void ->union($other) ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('UNION', $result['query']); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('UNION', $result->query); } public function testUnionMainHasAllClickHouseFeatures(): void @@ -2888,9 +2888,9 @@ public function testUnionMainHasAllClickHouseFeatures(): void ->union($other) ->build(); - $this->assertStringContainsString('FINAL SAMPLE 0.1', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('UNION', $result['query']); + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('UNION', $result->query); } public function testUnionAllWithPrewhere(): void @@ -2902,8 +2902,8 @@ public function testUnionAllWithPrewhere(): void ->unionAll($other) ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('UNION ALL', $result['query']); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('UNION ALL', $result->query); } public function testUnionBindingOrderWithPrewhere(): void @@ -2917,7 +2917,7 @@ public function testUnionBindingOrderWithPrewhere(): void ->build(); // prewhere, where, union - $this->assertEquals(['click', 2024, 2023], $result['bindings']); + $this->assertEquals(['click', 2024, 2023], $result->bindings); } public function testMultipleUnionsWithPrewhere(): void @@ -2931,8 +2931,8 @@ public function testMultipleUnionsWithPrewhere(): void ->union($other2) ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertEquals(2, substr_count($result['query'], 'UNION')); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertEquals(2, substr_count($result->query, 'UNION')); } public function testUnionJoinPrewhere(): void @@ -2945,9 +2945,9 @@ public function testUnionJoinPrewhere(): void ->union($other) ->build(); - $this->assertStringContainsString('JOIN', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('UNION', $result['query']); + $this->assertStringContainsString('JOIN', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('UNION', $result->query); } public function testUnionAggregationPrewhereFinal(): void @@ -2961,10 +2961,10 @@ public function testUnionAggregationPrewhereFinal(): void ->union($other) ->build(); - $this->assertStringContainsString('FINAL', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('COUNT(*)', $result['query']); - $this->assertStringContainsString('UNION', $result['query']); + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('COUNT(*)', $result->query); + $this->assertStringContainsString('UNION', $result->query); } public function testUnionWithComplexMainQuery(): void @@ -2982,7 +2982,7 @@ public function testUnionWithComplexMainQuery(): void ->union($other) ->build(); - $query = $result['query']; + $query = $result->query; $this->assertStringContainsString('SELECT `name`, `count`', $query); $this->assertStringContainsString('FINAL SAMPLE 0.1', $query); $this->assertStringContainsString('PREWHERE', $query); @@ -3187,7 +3187,7 @@ public function testResetClearsPrewhereState(): void $builder->reset(); $result = $builder->from('events')->build(); - $this->assertStringNotContainsString('PREWHERE', $result['query']); + $this->assertStringNotContainsString('PREWHERE', $result->query); } public function testResetClearsFinalState(): void @@ -3197,7 +3197,7 @@ public function testResetClearsFinalState(): void $builder->reset(); $result = $builder->from('events')->build(); - $this->assertStringNotContainsString('FINAL', $result['query']); + $this->assertStringNotContainsString('FINAL', $result->query); } public function testResetClearsSampleState(): void @@ -3207,7 +3207,7 @@ public function testResetClearsSampleState(): void $builder->reset(); $result = $builder->from('events')->build(); - $this->assertStringNotContainsString('SAMPLE', $result['query']); + $this->assertStringNotContainsString('SAMPLE', $result->query); } public function testResetClearsAllThreeTogether(): void @@ -3221,7 +3221,7 @@ public function testResetClearsAllThreeTogether(): void $builder->reset(); $result = $builder->from('events')->build(); - $this->assertEquals('SELECT * FROM `events`', $result['query']); + $this->assertEquals('SELECT * FROM `events`', $result->query); } public function testResetPreservesAttributeResolver(): void @@ -3240,7 +3240,7 @@ public function resolve(string $attribute): string $builder->reset(); $result = $builder->from('events')->filter([Query::equal('col', ['v'])])->build(); - $this->assertStringContainsString('`r_col`', $result['query']); + $this->assertStringContainsString('`r_col`', $result->query); } public function testResetPreservesConditionProviders(): void @@ -3258,7 +3258,7 @@ public function filter(string $table): Condition $builder->reset(); $result = $builder->from('events')->build(); - $this->assertStringContainsString('tenant = ?', $result['query']); + $this->assertStringContainsString('tenant = ?', $result->query); } public function testResetClearsTable(): void @@ -3268,8 +3268,8 @@ public function testResetClearsTable(): void $builder->reset(); $result = $builder->from('logs')->build(); - $this->assertStringContainsString('FROM `logs`', $result['query']); - $this->assertStringNotContainsString('events', $result['query']); + $this->assertStringContainsString('FROM `logs`', $result->query); + $this->assertStringNotContainsString('events', $result->query); } public function testResetClearsFilters(): void @@ -3279,7 +3279,7 @@ public function testResetClearsFilters(): void $builder->reset(); $result = $builder->from('events')->build(); - $this->assertStringNotContainsString('WHERE', $result['query']); + $this->assertStringNotContainsString('WHERE', $result->query); } public function testResetClearsUnions(): void @@ -3290,7 +3290,7 @@ public function testResetClearsUnions(): void $builder->reset(); $result = $builder->from('events')->build(); - $this->assertStringNotContainsString('UNION', $result['query']); + $this->assertStringNotContainsString('UNION', $result->query); } public function testResetClearsBindings(): void @@ -3300,7 +3300,7 @@ public function testResetClearsBindings(): void $builder->reset(); $result = $builder->from('events')->build(); - $this->assertEquals([], $result['bindings']); + $this->assertEquals([], $result->bindings); } public function testBuildAfterResetMinimalOutput(): void @@ -3317,8 +3317,8 @@ public function testBuildAfterResetMinimalOutput(): void $builder->reset(); $result = $builder->from('t')->build(); - $this->assertEquals('SELECT * FROM `t`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); } public function testResetRebuildWithPrewhere(): void @@ -3328,8 +3328,8 @@ public function testResetRebuildWithPrewhere(): void $builder->reset(); $result = $builder->from('events')->prewhere([Query::equal('x', [1])])->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringNotContainsString('FINAL', $result['query']); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringNotContainsString('FINAL', $result->query); } public function testResetRebuildWithFinal(): void @@ -3339,8 +3339,8 @@ public function testResetRebuildWithFinal(): void $builder->reset(); $result = $builder->from('events')->final()->build(); - $this->assertStringContainsString('FINAL', $result['query']); - $this->assertStringNotContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringNotContainsString('PREWHERE', $result->query); } public function testResetRebuildWithSample(): void @@ -3350,8 +3350,8 @@ public function testResetRebuildWithSample(): void $builder->reset(); $result = $builder->from('events')->sample(0.5)->build(); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); - $this->assertStringNotContainsString('FINAL', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringNotContainsString('FINAL', $result->query); } public function testMultipleResets(): void @@ -3366,8 +3366,8 @@ public function testMultipleResets(): void $builder->reset(); $result = $builder->from('d')->build(); - $this->assertEquals('SELECT * FROM `d`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `d`', $result->query); + $this->assertEquals([], $result->bindings); } // ══════════════════════════════════════════════════════════════════ @@ -3381,7 +3381,7 @@ public function testWhenTrueAddsPrewhere(): void ->when(true, fn (Builder $b) => $b->prewhere([Query::equal('type', ['click'])])) ->build(); - $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); } public function testWhenFalseDoesNotAddPrewhere(): void @@ -3391,7 +3391,7 @@ public function testWhenFalseDoesNotAddPrewhere(): void ->when(false, fn (Builder $b) => $b->prewhere([Query::equal('type', ['click'])])) ->build(); - $this->assertStringNotContainsString('PREWHERE', $result['query']); + $this->assertStringNotContainsString('PREWHERE', $result->query); } public function testWhenTrueAddsFinal(): void @@ -3401,7 +3401,7 @@ public function testWhenTrueAddsFinal(): void ->when(true, fn (Builder $b) => $b->final()) ->build(); - $this->assertStringContainsString('FINAL', $result['query']); + $this->assertStringContainsString('FINAL', $result->query); } public function testWhenFalseDoesNotAddFinal(): void @@ -3411,7 +3411,7 @@ public function testWhenFalseDoesNotAddFinal(): void ->when(false, fn (Builder $b) => $b->final()) ->build(); - $this->assertStringNotContainsString('FINAL', $result['query']); + $this->assertStringNotContainsString('FINAL', $result->query); } public function testWhenTrueAddsSample(): void @@ -3421,7 +3421,7 @@ public function testWhenTrueAddsSample(): void ->when(true, fn (Builder $b) => $b->sample(0.5)) ->build(); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); } public function testWhenWithBothPrewhereAndFilter(): void @@ -3436,8 +3436,8 @@ public function testWhenWithBothPrewhereAndFilter(): void ) ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('WHERE', $result['query']); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('WHERE', $result->query); } public function testWhenNestedWithClickHouseFeatures(): void @@ -3452,7 +3452,7 @@ public function testWhenNestedWithClickHouseFeatures(): void ) ->build(); - $this->assertStringContainsString('FINAL SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('FINAL SAMPLE 0.5', $result->query); } public function testWhenChainedMultipleTimesWithClickHouseFeatures(): void @@ -3464,8 +3464,8 @@ public function testWhenChainedMultipleTimesWithClickHouseFeatures(): void ->when(true, fn (Builder $b) => $b->prewhere([Query::equal('type', ['click'])])) ->build(); - $this->assertStringContainsString('FINAL SAMPLE 0.5', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('FINAL SAMPLE 0.5', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); } public function testWhenAddsJoinAndPrewhere(): void @@ -3480,8 +3480,8 @@ public function testWhenAddsJoinAndPrewhere(): void ) ->build(); - $this->assertStringContainsString('JOIN', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('JOIN', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); } public function testWhenCombinedWithRegularWhen(): void @@ -3492,8 +3492,8 @@ public function testWhenCombinedWithRegularWhen(): void ->when(true, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) ->build(); - $this->assertStringContainsString('FINAL', $result['query']); - $this->assertStringContainsString('WHERE `status` IN (?)', $result['query']); + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); } // ══════════════════════════════════════════════════════════════════ @@ -3513,8 +3513,8 @@ public function filter(string $table): Condition }) ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('deleted = ?', $result['query']); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('deleted = ?', $result->query); } public function testProviderWithFinal(): void @@ -3530,8 +3530,8 @@ public function filter(string $table): Condition }) ->build(); - $this->assertStringContainsString('FINAL', $result['query']); - $this->assertStringContainsString('deleted = ?', $result['query']); + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringContainsString('deleted = ?', $result->query); } public function testProviderWithSample(): void @@ -3547,8 +3547,8 @@ public function filter(string $table): Condition }) ->build(); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); - $this->assertStringContainsString('deleted = ?', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('deleted = ?', $result->query); } public function testProviderPrewhereWhereBindingOrder(): void @@ -3566,7 +3566,7 @@ public function filter(string $table): Condition ->build(); // prewhere, filter, provider - $this->assertEquals(['click', 5, 't1'], $result['bindings']); + $this->assertEquals(['click', 5, 't1'], $result->bindings); } public function testMultipleProvidersPrewhereBindingOrder(): void @@ -3588,7 +3588,7 @@ public function filter(string $table): Condition }) ->build(); - $this->assertEquals(['click', 't1', 'o1'], $result['bindings']); + $this->assertEquals(['click', 't1', 'o1'], $result->bindings); } public function testProviderPrewhereCursorLimitBindingOrder(): void @@ -3608,10 +3608,10 @@ public function filter(string $table): Condition ->build(); // prewhere, provider, cursor, limit - $this->assertEquals('click', $result['bindings'][0]); - $this->assertEquals('t1', $result['bindings'][1]); - $this->assertEquals('cur1', $result['bindings'][2]); - $this->assertEquals(10, $result['bindings'][3]); + $this->assertEquals('click', $result->bindings[0]); + $this->assertEquals('t1', $result->bindings[1]); + $this->assertEquals('cur1', $result->bindings[2]); + $this->assertEquals(10, $result->bindings[3]); } public function testProviderAllClickHouseFeatures(): void @@ -3630,9 +3630,9 @@ public function filter(string $table): Condition }) ->build(); - $this->assertStringContainsString('FINAL SAMPLE 0.1', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('tenant = ?', $result['query']); + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('tenant = ?', $result->query); } public function testProviderPrewhereAggregation(): void @@ -3649,9 +3649,9 @@ public function filter(string $table): Condition ->count('*', 'cnt') ->build(); - $this->assertStringContainsString('COUNT(*)', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('tenant = ?', $result['query']); + $this->assertStringContainsString('COUNT(*)', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('tenant = ?', $result->query); } public function testProviderJoinsPrewhere(): void @@ -3668,9 +3668,9 @@ public function filter(string $table): Condition }) ->build(); - $this->assertStringContainsString('JOIN', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('tenant = ?', $result['query']); + $this->assertStringContainsString('JOIN', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('tenant = ?', $result->query); } public function testProviderReferencesTableNameFinal(): void @@ -3686,8 +3686,8 @@ public function filter(string $table): Condition }) ->build(); - $this->assertStringContainsString('events.deleted = ?', $result['query']); - $this->assertStringContainsString('FINAL', $result['query']); + $this->assertStringContainsString('events.deleted = ?', $result->query); + $this->assertStringContainsString('FINAL', $result->query); } // ══════════════════════════════════════════════════════════════════ @@ -3703,8 +3703,8 @@ public function testCursorAfterWithPrewhere(): void ->sortAsc('_cursor') ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('`_cursor` > ?', $result['query']); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); } public function testCursorBeforeWithPrewhere(): void @@ -3716,8 +3716,8 @@ public function testCursorBeforeWithPrewhere(): void ->sortAsc('_cursor') ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('`_cursor` < ?', $result['query']); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('`_cursor` < ?', $result->query); } public function testCursorPrewhereWhere(): void @@ -3730,9 +3730,9 @@ public function testCursorPrewhereWhere(): void ->sortAsc('_cursor') ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('WHERE', $result['query']); - $this->assertStringContainsString('`_cursor` > ?', $result['query']); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); } public function testCursorWithFinal(): void @@ -3744,8 +3744,8 @@ public function testCursorWithFinal(): void ->sortAsc('_cursor') ->build(); - $this->assertStringContainsString('FINAL', $result['query']); - $this->assertStringContainsString('`_cursor` > ?', $result['query']); + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); } public function testCursorWithSample(): void @@ -3757,8 +3757,8 @@ public function testCursorWithSample(): void ->sortAsc('_cursor') ->build(); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); - $this->assertStringContainsString('`_cursor` > ?', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); } public function testCursorPrewhereBindingOrder(): void @@ -3770,8 +3770,8 @@ public function testCursorPrewhereBindingOrder(): void ->sortAsc('_cursor') ->build(); - $this->assertEquals('click', $result['bindings'][0]); - $this->assertEquals('cur1', $result['bindings'][1]); + $this->assertEquals('click', $result->bindings[0]); + $this->assertEquals('cur1', $result->bindings[1]); } public function testCursorPrewhereProviderBindingOrder(): void @@ -3789,9 +3789,9 @@ public function filter(string $table): Condition ->sortAsc('_cursor') ->build(); - $this->assertEquals('click', $result['bindings'][0]); - $this->assertEquals('t1', $result['bindings'][1]); - $this->assertEquals('cur1', $result['bindings'][2]); + $this->assertEquals('click', $result->bindings[0]); + $this->assertEquals('t1', $result->bindings[1]); + $this->assertEquals('cur1', $result->bindings[2]); } public function testCursorFullClickHousePipeline(): void @@ -3807,7 +3807,7 @@ public function testCursorFullClickHousePipeline(): void ->limit(10) ->build(); - $query = $result['query']; + $query = $result->query; $this->assertStringContainsString('FINAL SAMPLE 0.1', $query); $this->assertStringContainsString('PREWHERE', $query); $this->assertStringContainsString('WHERE', $query); @@ -3827,10 +3827,10 @@ public function testPageWithPrewhere(): void ->page(2, 25) ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('LIMIT ?', $result['query']); - $this->assertStringContainsString('OFFSET ?', $result['query']); - $this->assertEquals(['click', 25, 25], $result['bindings']); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals(['click', 25, 25], $result->bindings); } public function testPageWithFinal(): void @@ -3841,10 +3841,10 @@ public function testPageWithFinal(): void ->page(3, 10) ->build(); - $this->assertStringContainsString('FINAL', $result['query']); - $this->assertStringContainsString('LIMIT ?', $result['query']); - $this->assertStringContainsString('OFFSET ?', $result['query']); - $this->assertEquals([10, 20], $result['bindings']); + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals([10, 20], $result->bindings); } public function testPageWithSample(): void @@ -3855,8 +3855,8 @@ public function testPageWithSample(): void ->page(1, 50) ->build(); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); - $this->assertEquals([50, 0], $result['bindings']); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertEquals([50, 0], $result->bindings); } public function testPageWithAllClickHouseFeatures(): void @@ -3869,10 +3869,10 @@ public function testPageWithAllClickHouseFeatures(): void ->page(2, 10) ->build(); - $this->assertStringContainsString('FINAL SAMPLE 0.1', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('LIMIT', $result['query']); - $this->assertStringContainsString('OFFSET', $result['query']); + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('LIMIT', $result->query); + $this->assertStringContainsString('OFFSET', $result->query); } public function testPageWithComplexClickHouseQuery(): void @@ -3887,7 +3887,7 @@ public function testPageWithComplexClickHouseQuery(): void ->page(5, 20) ->build(); - $query = $result['query']; + $query = $result->query; $this->assertStringContainsString('FINAL', $query); $this->assertStringContainsString('SAMPLE', $query); $this->assertStringContainsString('PREWHERE', $query); @@ -3925,7 +3925,7 @@ public function testChainingClickHouseMethodsWithBaseMethods(): void ->offset(20) ->build(); - $this->assertNotEmpty($result['query']); + $this->assertNotEmpty($result->query); } public function testChainingOrderDoesNotMatterForOutput(): void @@ -3946,7 +3946,7 @@ public function testChainingOrderDoesNotMatterForOutput(): void ->final() ->build(); - $this->assertEquals($result1['query'], $result2['query']); + $this->assertEquals($result1->query, $result2->query); } public function testSameComplexQueryDifferentOrders(): void @@ -3971,7 +3971,7 @@ public function testSameComplexQueryDifferentOrders(): void ->final() ->build(); - $this->assertEquals($result1['query'], $result2['query']); + $this->assertEquals($result1->query, $result2->query); } public function testFluentResetThenRebuild(): void @@ -3987,8 +3987,8 @@ public function testFluentResetThenRebuild(): void ->sample(0.5) ->build(); - $this->assertEquals('SELECT * FROM `logs` SAMPLE 0.5', $result['query']); - $this->assertStringNotContainsString('FINAL', $result['query']); + $this->assertEquals('SELECT * FROM `logs` SAMPLE 0.5', $result->query); + $this->assertStringNotContainsString('FINAL', $result->query); } // ══════════════════════════════════════════════════════════════════ @@ -4013,7 +4013,7 @@ public function testClauseOrderSelectFromFinalSampleJoinPrewhereWhereGroupByHavi ->offset(10) ->build(); - $query = $result['query']; + $query = $result->query; $selectPos = strpos($query, 'SELECT'); $fromPos = strpos($query, 'FROM'); @@ -4049,7 +4049,7 @@ public function testFinalComesAfterTableBeforeJoin(): void ->join('users', 'events.uid', 'users.id') ->build(); - $query = $result['query']; + $query = $result->query; $tablePos = strpos($query, '`events`'); $finalPos = strpos($query, 'FINAL'); $joinPos = strpos($query, 'JOIN'); @@ -4067,7 +4067,7 @@ public function testSampleComesAfterFinalBeforeJoin(): void ->join('users', 'events.uid', 'users.id') ->build(); - $query = $result['query']; + $query = $result->query; $finalPos = strpos($query, 'FINAL'); $samplePos = strpos($query, 'SAMPLE'); $joinPos = strpos($query, 'JOIN'); @@ -4085,7 +4085,7 @@ public function testPrewhereComesAfterJoinBeforeWhere(): void ->filter([Query::greaterThan('count', 0)]) ->build(); - $query = $result['query']; + $query = $result->query; $joinPos = strpos($query, 'JOIN'); $prewherePos = strpos($query, 'PREWHERE'); $wherePos = strpos($query, 'WHERE'); @@ -4103,7 +4103,7 @@ public function testPrewhereBeforeGroupBy(): void ->groupBy(['type']) ->build(); - $query = $result['query']; + $query = $result->query; $prewherePos = strpos($query, 'PREWHERE'); $groupByPos = strpos($query, 'GROUP BY'); @@ -4118,7 +4118,7 @@ public function testPrewhereBeforeOrderBy(): void ->sortDesc('ts') ->build(); - $query = $result['query']; + $query = $result->query; $prewherePos = strpos($query, 'PREWHERE'); $orderByPos = strpos($query, 'ORDER BY'); @@ -4133,7 +4133,7 @@ public function testPrewhereBeforeLimit(): void ->limit(10) ->build(); - $query = $result['query']; + $query = $result->query; $prewherePos = strpos($query, 'PREWHERE'); $limitPos = strpos($query, 'LIMIT'); @@ -4149,7 +4149,7 @@ public function testFinalSampleBeforePrewhere(): void ->prewhere([Query::equal('type', ['click'])]) ->build(); - $query = $result['query']; + $query = $result->query; $finalPos = strpos($query, 'FINAL'); $samplePos = strpos($query, 'SAMPLE'); $prewherePos = strpos($query, 'PREWHERE'); @@ -4168,7 +4168,7 @@ public function testWhereBeforeHaving(): void ->having([Query::greaterThan('cnt', 5)]) ->build(); - $query = $result['query']; + $query = $result->query; $wherePos = strpos($query, 'WHERE'); $havingPos = strpos($query, 'HAVING'); @@ -4196,7 +4196,7 @@ public function testFullQueryAllClausesAllPositions(): void ->union($other) ->build(); - $query = $result['query']; + $query = $result->query; // All elements present $this->assertStringContainsString('SELECT DISTINCT', $query); @@ -4229,10 +4229,10 @@ public function testQueriesMethodWithPrewhere(): void ]) ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('WHERE `status` IN (?)', $result['query']); - $this->assertStringContainsString('ORDER BY', $result['query']); - $this->assertStringContainsString('LIMIT', $result['query']); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); + $this->assertStringContainsString('ORDER BY', $result->query); + $this->assertStringContainsString('LIMIT', $result->query); } public function testQueriesMethodWithFinal(): void @@ -4246,8 +4246,8 @@ public function testQueriesMethodWithFinal(): void ]) ->build(); - $this->assertStringContainsString('FINAL', $result['query']); - $this->assertStringContainsString('WHERE `status` IN (?)', $result['query']); + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); } public function testQueriesMethodWithSample(): void @@ -4260,8 +4260,8 @@ public function testQueriesMethodWithSample(): void ]) ->build(); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); - $this->assertStringContainsString('WHERE', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('WHERE', $result->query); } public function testQueriesMethodWithAllClickHouseFeatures(): void @@ -4278,10 +4278,10 @@ public function testQueriesMethodWithAllClickHouseFeatures(): void ]) ->build(); - $this->assertStringContainsString('FINAL SAMPLE 0.1', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('WHERE', $result['query']); - $this->assertStringContainsString('ORDER BY', $result['query']); + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('ORDER BY', $result->query); } public function testQueriesComparedToFluentApiSameSql(): void @@ -4302,8 +4302,8 @@ public function testQueriesComparedToFluentApiSameSql(): void ]) ->build(); - $this->assertEquals($resultA['query'], $resultB['query']); - $this->assertEquals($resultA['bindings'], $resultB['bindings']); + $this->assertEquals($resultA->query, $resultB->query); + $this->assertEquals($resultA->bindings, $resultB->bindings); } // ══════════════════════════════════════════════════════════════════ @@ -4317,7 +4317,7 @@ public function testEmptyTableNameWithFinal(): void ->final() ->build(); - $this->assertStringContainsString('FINAL', $result['query']); + $this->assertStringContainsString('FINAL', $result->query); } public function testEmptyTableNameWithSample(): void @@ -4327,7 +4327,7 @@ public function testEmptyTableNameWithSample(): void ->sample(0.5) ->build(); - $this->assertStringContainsString('SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); } public function testPrewhereWithEmptyFilterValues(): void @@ -4337,7 +4337,7 @@ public function testPrewhereWithEmptyFilterValues(): void ->prewhere([Query::equal('type', [])]) ->build(); - $this->assertStringContainsString('PREWHERE', $result['query']); + $this->assertStringContainsString('PREWHERE', $result->query); } public function testVeryLongTableNameWithFinalSample(): void @@ -4349,8 +4349,8 @@ public function testVeryLongTableNameWithFinalSample(): void ->sample(0.1) ->build(); - $this->assertStringContainsString('`' . $longName . '`', $result['query']); - $this->assertStringContainsString('FINAL SAMPLE 0.1', $result['query']); + $this->assertStringContainsString('`' . $longName . '`', $result->query); + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); } public function testMultipleBuildsConsistentOutput(): void @@ -4366,10 +4366,10 @@ public function testMultipleBuildsConsistentOutput(): void $result2 = $builder->build(); $result3 = $builder->build(); - $this->assertEquals($result1['query'], $result2['query']); - $this->assertEquals($result2['query'], $result3['query']); - $this->assertEquals($result1['bindings'], $result2['bindings']); - $this->assertEquals($result2['bindings'], $result3['bindings']); + $this->assertEquals($result1->query, $result2->query); + $this->assertEquals($result2->query, $result3->query); + $this->assertEquals($result1->bindings, $result2->bindings); + $this->assertEquals($result2->bindings, $result3->bindings); } public function testBuildResetsBindingsButNotClickHouseState(): void @@ -4384,12 +4384,12 @@ public function testBuildResetsBindingsButNotClickHouseState(): void $result2 = $builder->build(); // ClickHouse state persists - $this->assertStringContainsString('FINAL', $result2['query']); - $this->assertStringContainsString('SAMPLE', $result2['query']); - $this->assertStringContainsString('PREWHERE', $result2['query']); + $this->assertStringContainsString('FINAL', $result2->query); + $this->assertStringContainsString('SAMPLE', $result2->query); + $this->assertStringContainsString('PREWHERE', $result2->query); // Bindings are consistent - $this->assertEquals($result1['bindings'], $result2['bindings']); + $this->assertEquals($result1->bindings, $result2->bindings); } public function testSampleWithAllBindingTypes(): void @@ -4417,8 +4417,8 @@ public function filter(string $table): Condition ->build(); // Verify all binding types present - $this->assertNotEmpty($result['bindings']); - $this->assertGreaterThan(5, count($result['bindings'])); + $this->assertNotEmpty($result->bindings); + $this->assertGreaterThan(5, count($result->bindings)); } public function testPrewhereAppearsCorrectlyWithoutJoins(): void @@ -4429,7 +4429,7 @@ public function testPrewhereAppearsCorrectlyWithoutJoins(): void ->filter([Query::greaterThan('count', 5)]) ->build(); - $query = $result['query']; + $query = $result->query; $this->assertStringContainsString('PREWHERE', $query); $this->assertStringContainsString('WHERE', $query); @@ -4447,7 +4447,7 @@ public function testPrewhereAppearsCorrectlyWithJoins(): void ->filter([Query::greaterThan('count', 5)]) ->build(); - $query = $result['query']; + $query = $result->query; $joinPos = strpos($query, 'JOIN'); $prewherePos = strpos($query, 'PREWHERE'); $wherePos = strpos($query, 'WHERE'); @@ -4466,7 +4466,7 @@ public function testFinalSampleTextInOutputWithJoins(): void ->leftJoin('sessions', 'events.sid', 'sessions.id') ->build(); - $query = $result['query']; + $query = $result->query; $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.1', $query); $this->assertStringContainsString('JOIN `users`', $query); $this->assertStringContainsString('LEFT JOIN `sessions`', $query); @@ -4608,7 +4608,7 @@ public function testSampleGreaterThanOne(): void public function testSampleVerySmall(): void { $result = (new Builder())->from('t')->sample(0.001)->build(); - $this->assertStringContainsString('SAMPLE 0.001', $result['query']); + $this->assertStringContainsString('SAMPLE 0.001', $result->query); } // ══════════════════════════════════════════════════════════════════ @@ -4762,10 +4762,10 @@ public function testUnionBothWithClickHouseFeatures(): void ->filter([Query::greaterThan('count', 5)]) ->union($sub) ->build(); - $this->assertStringContainsString('FROM `events` FINAL', $result['query']); - $this->assertStringContainsString('PREWHERE', $result['query']); - $this->assertStringContainsString('UNION', $result['query']); - $this->assertStringContainsString('FROM `archive` FINAL SAMPLE 0.5', $result['query']); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('UNION', $result->query); + $this->assertStringContainsString('FROM `archive` FINAL SAMPLE 0.5', $result->query); } public function testUnionAllBothWithFinal(): void @@ -4774,8 +4774,8 @@ public function testUnionAllBothWithFinal(): void $result = (new Builder())->from('a')->final() ->unionAll($sub) ->build(); - $this->assertStringContainsString('FROM `a` FINAL', $result['query']); - $this->assertStringContainsString('UNION ALL (SELECT * FROM `b` FINAL)', $result['query']); + $this->assertStringContainsString('FROM `a` FINAL', $result->query); + $this->assertStringContainsString('UNION ALL (SELECT * FROM `b` FINAL)', $result->query); } // ══════════════════════════════════════════════════════════════════ @@ -4792,7 +4792,7 @@ public function testPrewhereBindingOrderWithFilterAndHaving(): void ->having([Query::greaterThan('total', 10)]) ->build(); // Binding order: prewhere, filter, having - $this->assertEquals(['click', 5, 10], $result['bindings']); + $this->assertEquals(['click', 5, 10], $result->bindings); } public function testPrewhereBindingOrderWithProviderAndCursor(): void @@ -4809,7 +4809,7 @@ public function filter(string $table): Condition ->sortAsc('_cursor') ->build(); // Binding order: prewhere, filter(none), provider, cursor - $this->assertEquals(['click', 't1', 'abc'], $result['bindings']); + $this->assertEquals(['click', 't1', 'abc'], $result->bindings); } public function testPrewhereMultipleFiltersBindingOrder(): void @@ -4823,7 +4823,7 @@ public function testPrewhereMultipleFiltersBindingOrder(): void ->limit(10) ->build(); // prewhere bindings first, then filter, then limit - $this->assertEquals(['a', 3, 30, 10], $result['bindings']); + $this->assertEquals(['a', 3, 30, 10], $result->bindings); } // ══════════════════════════════════════════════════════════════════ @@ -4856,7 +4856,7 @@ public function testLeftJoinWithFinalAndSample(): void ->build(); $this->assertEquals( 'SELECT * FROM `events` FINAL SAMPLE 0.1 LEFT JOIN `users` ON `events`.`uid` = `users`.`id`', - $result['query'] + $result->query ); } @@ -4866,8 +4866,8 @@ public function testRightJoinWithFinalFeature(): void ->final() ->rightJoin('users', 'events.uid', 'users.id') ->build(); - $this->assertStringContainsString('FROM `events` FINAL', $result['query']); - $this->assertStringContainsString('RIGHT JOIN', $result['query']); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('RIGHT JOIN', $result->query); } public function testCrossJoinWithPrewhereFeature(): void @@ -4876,9 +4876,9 @@ public function testCrossJoinWithPrewhereFeature(): void ->crossJoin('colors') ->prewhere([Query::equal('type', ['a'])]) ->build(); - $this->assertStringContainsString('CROSS JOIN `colors`', $result['query']); - $this->assertStringContainsString('PREWHERE `type` IN (?)', $result['query']); - $this->assertEquals(['a'], $result['bindings']); + $this->assertStringContainsString('CROSS JOIN `colors`', $result->query); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); + $this->assertEquals(['a'], $result->bindings); } public function testJoinWithNonDefaultOperator(): void @@ -4886,7 +4886,7 @@ public function testJoinWithNonDefaultOperator(): void $result = (new Builder())->from('t') ->join('other', 'a', 'b', '!=') ->build(); - $this->assertStringContainsString('JOIN `other` ON `a` != `b`', $result['query']); + $this->assertStringContainsString('JOIN `other` ON `a` != `b`', $result->query); } // ══════════════════════════════════════════════════════════════════ @@ -4904,7 +4904,7 @@ public function filter(string $table): Condition } }) ->build(); - $query = $result['query']; + $query = $result->query; $prewherePos = strpos($query, 'PREWHERE'); $wherePos = strpos($query, 'WHERE'); // Provider should be in WHERE which comes after PREWHERE @@ -4924,8 +4924,8 @@ public function filter(string $table): Condition } }) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE _deleted = ?', $result['query']); - $this->assertEquals([0], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE _deleted = ?', $result->query); + $this->assertEquals([0], $result->bindings); } // ══════════════════════════════════════════════════════════════════ @@ -4935,22 +4935,22 @@ public function filter(string $table): Condition public function testPageZero(): void { $result = (new Builder())->from('t')->page(0, 10)->build(); - $this->assertStringContainsString('LIMIT ?', $result['query']); - $this->assertStringContainsString('OFFSET ?', $result['query']); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); // page 0 -> offset clamped to 0 - $this->assertEquals([10, 0], $result['bindings']); + $this->assertEquals([10, 0], $result->bindings); } public function testPageNegative(): void { $result = (new Builder())->from('t')->page(-1, 10)->build(); - $this->assertEquals([10, 0], $result['bindings']); + $this->assertEquals([10, 0], $result->bindings); } public function testPageLargeNumber(): void { $result = (new Builder())->from('t')->page(1000000, 25)->build(); - $this->assertEquals([25, 24999975], $result['bindings']); + $this->assertEquals([25, 24999975], $result->bindings); } // ══════════════════════════════════════════════════════════════════ @@ -4960,7 +4960,7 @@ public function testPageLargeNumber(): void public function testBuildWithoutFrom(): void { $result = (new Builder())->filter([Query::equal('x', [1])])->build(); - $this->assertStringContainsString('FROM ``', $result['query']); + $this->assertStringContainsString('FROM ``', $result->query); } // ══════════════════════════════════════════════════════════════════ @@ -5040,9 +5040,9 @@ public function testHavingMultipleSubQueries(): void Query::lessThan('total', 100), ]) ->build(); - $this->assertStringContainsString('HAVING `total` > ? AND `total` < ?', $result['query']); - $this->assertContains(5, $result['bindings']); - $this->assertContains(100, $result['bindings']); + $this->assertStringContainsString('HAVING `total` > ? AND `total` < ?', $result->query); + $this->assertContains(5, $result->bindings); + $this->assertContains(100, $result->bindings); } public function testHavingWithOrLogic(): void @@ -5055,7 +5055,7 @@ public function testHavingWithOrLogic(): void Query::lessThan('total', 5), ])]) ->build(); - $this->assertStringContainsString('HAVING (`total` > ? OR `total` < ?)', $result['query']); + $this->assertStringContainsString('HAVING (`total` > ? OR `total` < ?)', $result->query); } // ══════════════════════════════════════════════════════════════════ @@ -5075,11 +5075,11 @@ public function testResetClearsClickHouseProperties(): void $builder->reset()->from('other'); $result = $builder->build(); - $this->assertEquals('SELECT * FROM `other`', $result['query']); - $this->assertEquals([], $result['bindings']); - $this->assertStringNotContainsString('FINAL', $result['query']); - $this->assertStringNotContainsString('SAMPLE', $result['query']); - $this->assertStringNotContainsString('PREWHERE', $result['query']); + $this->assertEquals('SELECT * FROM `other`', $result->query); + $this->assertEquals([], $result->bindings); + $this->assertStringNotContainsString('FINAL', $result->query); + $this->assertStringNotContainsString('SAMPLE', $result->query); + $this->assertStringNotContainsString('PREWHERE', $result->query); } public function testResetFollowedByUnion(): void @@ -5089,9 +5089,9 @@ public function testResetFollowedByUnion(): void ->union((new Builder())->from('old')); $builder->reset()->from('b'); $result = $builder->build(); - $this->assertEquals('SELECT * FROM `b`', $result['query']); - $this->assertStringNotContainsString('UNION', $result['query']); - $this->assertStringNotContainsString('FINAL', $result['query']); + $this->assertEquals('SELECT * FROM `b`', $result->query); + $this->assertStringNotContainsString('UNION', $result->query); + $this->assertStringNotContainsString('FINAL', $result->query); } public function testConditionProviderPersistsAfterReset(): void @@ -5108,9 +5108,9 @@ public function filter(string $table): Condition $builder->build(); $builder->reset()->from('other'); $result = $builder->build(); - $this->assertStringContainsString('FROM `other`', $result['query']); - $this->assertStringNotContainsString('FINAL', $result['query']); - $this->assertStringContainsString('_tenant = ?', $result['query']); + $this->assertStringContainsString('FROM `other`', $result->query); + $this->assertStringNotContainsString('FINAL', $result->query); + $this->assertStringContainsString('_tenant = ?', $result->query); } // ══════════════════════════════════════════════════════════════════ @@ -5129,9 +5129,9 @@ public function testFinalSamplePrewhereFilterExactSql(): void ->build(); $this->assertEquals( 'SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `event_type` IN (?) WHERE `amount` > ? ORDER BY `amount` DESC LIMIT ?', - $result['query'] + $result->query ); - $this->assertEquals(['purchase', 100, 50], $result['bindings']); + $this->assertEquals(['purchase', 100, 50], $result->bindings); } public function testKitchenSinkExactSql(): void @@ -5156,9 +5156,9 @@ public function testKitchenSinkExactSql(): void ->build(); $this->assertEquals( '(SELECT DISTINCT COUNT(*) AS `total`, `event_type` FROM `events` FINAL SAMPLE 0.1 JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `event_type` IN (?) WHERE `amount` > ? GROUP BY `event_type` HAVING `total` > ? ORDER BY `total` DESC LIMIT ? OFFSET ?) UNION (SELECT * FROM `archive` FINAL WHERE `status` IN (?))', - $result['query'] + $result->query ); - $this->assertEquals(['purchase', 100, 5, 50, 10, 'closed'], $result['bindings']); + $this->assertEquals(['purchase', 100, 5, 50, 10, 'closed'], $result->bindings); } // ══════════════════════════════════════════════════════════════════ @@ -5222,53 +5222,53 @@ public function testQueryCompileGroupByViaClickHouse(): void public function testBindingTypesPreservedInt(): void { $result = (new Builder())->from('t')->filter([Query::greaterThan('age', 18)])->build(); - $this->assertSame([18], $result['bindings']); + $this->assertSame([18], $result->bindings); } public function testBindingTypesPreservedFloat(): void { $result = (new Builder())->from('t')->filter([Query::greaterThan('score', 9.5)])->build(); - $this->assertSame([9.5], $result['bindings']); + $this->assertSame([9.5], $result->bindings); } public function testBindingTypesPreservedBool(): void { $result = (new Builder())->from('t')->filter([Query::equal('active', [true])])->build(); - $this->assertSame([true], $result['bindings']); + $this->assertSame([true], $result->bindings); } public function testBindingTypesPreservedNull(): void { $result = (new Builder())->from('t')->filter([Query::equal('val', [null])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `val` IS NULL', $result['query']); - $this->assertSame([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `val` IS NULL', $result->query); + $this->assertSame([], $result->bindings); } public function testEqualWithNullAndNonNull(): void { $result = (new Builder())->from('t')->filter([Query::equal('col', ['a', null])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`col` IN (?) OR `col` IS NULL)', $result['query']); - $this->assertSame(['a'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` IN (?) OR `col` IS NULL)', $result->query); + $this->assertSame(['a'], $result->bindings); } public function testNotEqualWithNullOnly(): void { $result = (new Builder())->from('t')->filter([Query::notEqual('col', [null])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NOT NULL', $result['query']); - $this->assertSame([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NOT NULL', $result->query); + $this->assertSame([], $result->bindings); } public function testNotEqualWithNullAndNonNull(): void { $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', 'b', null])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`col` NOT IN (?, ?) AND `col` IS NOT NULL)', $result['query']); - $this->assertSame(['a', 'b'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` NOT IN (?, ?) AND `col` IS NOT NULL)', $result->query); + $this->assertSame(['a', 'b'], $result->bindings); } public function testBindingTypesPreservedString(): void { $result = (new Builder())->from('t')->filter([Query::equal('name', ['hello'])])->build(); - $this->assertSame(['hello'], $result['bindings']); + $this->assertSame(['hello'], $result->bindings); } // ══════════════════════════════════════════════════════════════════ @@ -5283,8 +5283,8 @@ public function testRawInsideLogicalAnd(): void Query::raw('custom_func(y) > ?', [5]), ])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`x` > ? AND custom_func(y) > ?)', $result['query']); - $this->assertEquals([1, 5], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`x` > ? AND custom_func(y) > ?)', $result->query); + $this->assertEquals([1, 5], $result->bindings); } public function testRawInsideLogicalOr(): void @@ -5295,8 +5295,8 @@ public function testRawInsideLogicalOr(): void Query::raw('b IS NOT NULL', []), ])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result['query']); - $this->assertEquals([1], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result->query); + $this->assertEquals([1], $result->bindings); } // ══════════════════════════════════════════════════════════════════ @@ -5306,23 +5306,23 @@ public function testRawInsideLogicalOr(): void public function testNegativeLimit(): void { $result = (new Builder())->from('t')->limit(-1)->build(); - $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result['query']); - $this->assertEquals([-1], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertEquals([-1], $result->bindings); } public function testNegativeOffset(): void { // OFFSET without LIMIT is suppressed $result = (new Builder())->from('t')->offset(-5)->build(); - $this->assertEquals('SELECT * FROM `t`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); } public function testLimitZero(): void { $result = (new Builder())->from('t')->limit(0)->build(); - $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result['query']); - $this->assertEquals([0], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertEquals([0], $result->bindings); } // ══════════════════════════════════════════════════════════════════ @@ -5332,20 +5332,20 @@ public function testLimitZero(): void public function testMultipleLimitsFirstWins(): void { $result = (new Builder())->from('t')->limit(10)->limit(20)->build(); - $this->assertEquals([10], $result['bindings']); + $this->assertEquals([10], $result->bindings); } public function testMultipleOffsetsFirstWins(): void { // OFFSET without LIMIT is suppressed $result = (new Builder())->from('t')->offset(5)->offset(50)->build(); - $this->assertEquals([], $result['bindings']); + $this->assertEquals([], $result->bindings); } public function testCursorAfterAndBeforeFirstWins(): void { $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->sortAsc('_cursor')->build(); - $this->assertStringContainsString('`_cursor` > ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result->query); } // ══════════════════════════════════════════════════════════════════ @@ -5356,6 +5356,6 @@ public function testDistinctWithUnion(): void { $other = (new Builder())->from('b'); $result = (new Builder())->from('a')->distinct()->union($other)->build(); - $this->assertEquals('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result['query']); + $this->assertEquals('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result->query); } } diff --git a/tests/Query/Builder/SQLTest.php b/tests/Query/Builder/SQLTest.php index ec53a58..c31056a 100644 --- a/tests/Query/Builder/SQLTest.php +++ b/tests/Query/Builder/SQLTest.php @@ -3,9 +3,9 @@ namespace Tests\Query\Builder; use PHPUnit\Framework\TestCase; +use Utopia\Query\Builder\Condition; use Utopia\Query\Builder\SQL as Builder; use Utopia\Query\Compiler; -use Utopia\Query\Condition; use Utopia\Query\Hook\AttributeMapHook; use Utopia\Query\Hook\FilterHook; use Utopia\Query\Query; @@ -48,9 +48,9 @@ public function testFluentSelectFromFilterSortLimitOffset(): void $this->assertEquals( 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', - $result['query'] + $result->query ); - $this->assertEquals(['active', 18, 25, 0], $result['bindings']); + $this->assertEquals(['active', 18, 25, 0], $result->bindings); } // ── Batch mode ── @@ -71,9 +71,9 @@ public function testBatchModeProducesSameOutput(): void $this->assertEquals( 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', - $result['query'] + $result->query ); - $this->assertEquals(['active', 18, 25, 0], $result['bindings']); + $this->assertEquals(['active', 18, 25, 0], $result->bindings); } // ── Filter types ── @@ -85,8 +85,8 @@ public function testEqual(): void ->filter([Query::equal('status', ['active', 'pending'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?, ?)', $result['query']); - $this->assertEquals(['active', 'pending'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?, ?)', $result->query); + $this->assertEquals(['active', 'pending'], $result->bindings); } public function testNotEqualSingle(): void @@ -96,8 +96,8 @@ public function testNotEqualSingle(): void ->filter([Query::notEqual('role', 'guest')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `role` != ?', $result['query']); - $this->assertEquals(['guest'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `role` != ?', $result->query); + $this->assertEquals(['guest'], $result->bindings); } public function testNotEqualMultiple(): void @@ -107,8 +107,8 @@ public function testNotEqualMultiple(): void ->filter([Query::notEqual('role', ['guest', 'banned'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result['query']); - $this->assertEquals(['guest', 'banned'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result->query); + $this->assertEquals(['guest', 'banned'], $result->bindings); } public function testLessThan(): void @@ -118,8 +118,8 @@ public function testLessThan(): void ->filter([Query::lessThan('price', 100)]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `price` < ?', $result['query']); - $this->assertEquals([100], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `price` < ?', $result->query); + $this->assertEquals([100], $result->bindings); } public function testLessThanEqual(): void @@ -129,8 +129,8 @@ public function testLessThanEqual(): void ->filter([Query::lessThanEqual('price', 100)]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `price` <= ?', $result['query']); - $this->assertEquals([100], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `price` <= ?', $result->query); + $this->assertEquals([100], $result->bindings); } public function testGreaterThan(): void @@ -140,8 +140,8 @@ public function testGreaterThan(): void ->filter([Query::greaterThan('age', 18)]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `age` > ?', $result['query']); - $this->assertEquals([18], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `age` > ?', $result->query); + $this->assertEquals([18], $result->bindings); } public function testGreaterThanEqual(): void @@ -151,8 +151,8 @@ public function testGreaterThanEqual(): void ->filter([Query::greaterThanEqual('score', 90)]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `score` >= ?', $result['query']); - $this->assertEquals([90], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `score` >= ?', $result->query); + $this->assertEquals([90], $result->bindings); } public function testBetween(): void @@ -162,8 +162,8 @@ public function testBetween(): void ->filter([Query::between('age', 18, 65)]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result['query']); - $this->assertEquals([18, 65], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertEquals([18, 65], $result->bindings); } public function testNotBetween(): void @@ -173,8 +173,8 @@ public function testNotBetween(): void ->filter([Query::notBetween('age', 18, 65)]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `age` NOT BETWEEN ? AND ?', $result['query']); - $this->assertEquals([18, 65], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `age` NOT BETWEEN ? AND ?', $result->query); + $this->assertEquals([18, 65], $result->bindings); } public function testStartsWith(): void @@ -184,8 +184,8 @@ public function testStartsWith(): void ->filter([Query::startsWith('name', 'Jo')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result['query']); - $this->assertEquals(['Jo%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertEquals(['Jo%'], $result->bindings); } public function testNotStartsWith(): void @@ -195,8 +195,8 @@ public function testNotStartsWith(): void ->filter([Query::notStartsWith('name', 'Jo')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `name` NOT LIKE ?', $result['query']); - $this->assertEquals(['Jo%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `name` NOT LIKE ?', $result->query); + $this->assertEquals(['Jo%'], $result->bindings); } public function testEndsWith(): void @@ -206,8 +206,8 @@ public function testEndsWith(): void ->filter([Query::endsWith('email', '.com')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `email` LIKE ?', $result['query']); - $this->assertEquals(['%.com'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `email` LIKE ?', $result->query); + $this->assertEquals(['%.com'], $result->bindings); } public function testNotEndsWith(): void @@ -217,8 +217,8 @@ public function testNotEndsWith(): void ->filter([Query::notEndsWith('email', '.com')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `email` NOT LIKE ?', $result['query']); - $this->assertEquals(['%.com'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `email` NOT LIKE ?', $result->query); + $this->assertEquals(['%.com'], $result->bindings); } public function testContainsSingle(): void @@ -228,8 +228,8 @@ public function testContainsSingle(): void ->filter([Query::contains('bio', ['php'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result['query']); - $this->assertEquals(['%php%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); + $this->assertEquals(['%php%'], $result->bindings); } public function testContainsMultiple(): void @@ -239,8 +239,8 @@ public function testContainsMultiple(): void ->filter([Query::contains('bio', ['php', 'js'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`bio` LIKE ? OR `bio` LIKE ?)', $result['query']); - $this->assertEquals(['%php%', '%js%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`bio` LIKE ? OR `bio` LIKE ?)', $result->query); + $this->assertEquals(['%php%', '%js%'], $result->bindings); } public function testContainsAny(): void @@ -250,8 +250,8 @@ public function testContainsAny(): void ->filter([Query::containsAny('tags', ['a', 'b'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `tags` IN (?, ?)', $result['query']); - $this->assertEquals(['a', 'b'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `tags` IN (?, ?)', $result->query); + $this->assertEquals(['a', 'b'], $result->bindings); } public function testContainsAll(): void @@ -261,8 +261,8 @@ public function testContainsAll(): void ->filter([Query::containsAll('perms', ['read', 'write'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`perms` LIKE ? AND `perms` LIKE ?)', $result['query']); - $this->assertEquals(['%read%', '%write%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`perms` LIKE ? AND `perms` LIKE ?)', $result->query); + $this->assertEquals(['%read%', '%write%'], $result->bindings); } public function testNotContainsSingle(): void @@ -272,8 +272,8 @@ public function testNotContainsSingle(): void ->filter([Query::notContains('bio', ['php'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result['query']); - $this->assertEquals(['%php%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result->query); + $this->assertEquals(['%php%'], $result->bindings); } public function testNotContainsMultiple(): void @@ -283,8 +283,8 @@ public function testNotContainsMultiple(): void ->filter([Query::notContains('bio', ['php', 'js'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`bio` NOT LIKE ? AND `bio` NOT LIKE ?)', $result['query']); - $this->assertEquals(['%php%', '%js%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`bio` NOT LIKE ? AND `bio` NOT LIKE ?)', $result->query); + $this->assertEquals(['%php%', '%js%'], $result->bindings); } public function testSearch(): void @@ -294,8 +294,8 @@ public function testSearch(): void ->filter([Query::search('content', 'hello')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?)', $result['query']); - $this->assertEquals(['hello'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?)', $result->query); + $this->assertEquals(['hello'], $result->bindings); } public function testNotSearch(): void @@ -305,8 +305,8 @@ public function testNotSearch(): void ->filter([Query::notSearch('content', 'hello')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?))', $result['query']); - $this->assertEquals(['hello'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?))', $result->query); + $this->assertEquals(['hello'], $result->bindings); } public function testRegex(): void @@ -316,8 +316,8 @@ public function testRegex(): void ->filter([Query::regex('slug', '^[a-z]+$')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result['query']); - $this->assertEquals(['^[a-z]+$'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result->query); + $this->assertEquals(['^[a-z]+$'], $result->bindings); } public function testIsNull(): void @@ -327,8 +327,8 @@ public function testIsNull(): void ->filter([Query::isNull('deleted')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `deleted` IS NULL', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `deleted` IS NULL', $result->query); + $this->assertEquals([], $result->bindings); } public function testIsNotNull(): void @@ -338,8 +338,8 @@ public function testIsNotNull(): void ->filter([Query::isNotNull('verified')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `verified` IS NOT NULL', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `verified` IS NOT NULL', $result->query); + $this->assertEquals([], $result->bindings); } public function testExists(): void @@ -349,8 +349,8 @@ public function testExists(): void ->filter([Query::exists(['name', 'email'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`name` IS NOT NULL AND `email` IS NOT NULL)', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`name` IS NOT NULL AND `email` IS NOT NULL)', $result->query); + $this->assertEquals([], $result->bindings); } public function testNotExists(): void @@ -360,8 +360,8 @@ public function testNotExists(): void ->filter([Query::notExists(['legacy'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`legacy` IS NULL)', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`legacy` IS NULL)', $result->query); + $this->assertEquals([], $result->bindings); } // ── Logical / nested ── @@ -378,8 +378,8 @@ public function testAndLogical(): void ]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`age` > ? AND `status` IN (?))', $result['query']); - $this->assertEquals([18, 'active'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`age` > ? AND `status` IN (?))', $result->query); + $this->assertEquals([18, 'active'], $result->bindings); } public function testOrLogical(): void @@ -394,8 +394,8 @@ public function testOrLogical(): void ]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`role` IN (?) OR `role` IN (?))', $result['query']); - $this->assertEquals(['admin', 'mod'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`role` IN (?) OR `role` IN (?))', $result->query); + $this->assertEquals(['admin', 'mod'], $result->bindings); } public function testDeeplyNested(): void @@ -415,9 +415,9 @@ public function testDeeplyNested(): void $this->assertEquals( 'SELECT * FROM `t` WHERE (`age` > ? AND (`role` IN (?) OR `role` IN (?)))', - $result['query'] + $result->query ); - $this->assertEquals([18, 'admin', 'mod'], $result['bindings']); + $this->assertEquals([18, 'admin', 'mod'], $result->bindings); } // ── Sort ── @@ -429,7 +429,7 @@ public function testSortAsc(): void ->sortAsc('name') ->build(); - $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC', $result['query']); + $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC', $result->query); } public function testSortDesc(): void @@ -439,7 +439,7 @@ public function testSortDesc(): void ->sortDesc('score') ->build(); - $this->assertEquals('SELECT * FROM `t` ORDER BY `score` DESC', $result['query']); + $this->assertEquals('SELECT * FROM `t` ORDER BY `score` DESC', $result->query); } public function testSortRandom(): void @@ -449,7 +449,7 @@ public function testSortRandom(): void ->sortRandom() ->build(); - $this->assertEquals('SELECT * FROM `t` ORDER BY RAND()', $result['query']); + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND()', $result->query); } public function testMultipleSorts(): void @@ -460,7 +460,7 @@ public function testMultipleSorts(): void ->sortDesc('age') ->build(); - $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC, `age` DESC', $result['query']); + $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC, `age` DESC', $result->query); } // ── Pagination ── @@ -472,8 +472,8 @@ public function testLimitOnly(): void ->limit(10) ->build(); - $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result['query']); - $this->assertEquals([10], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertEquals([10], $result->bindings); } public function testOffsetOnly(): void @@ -484,8 +484,8 @@ public function testOffsetOnly(): void ->offset(50) ->build(); - $this->assertEquals('SELECT * FROM `t`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); } public function testCursorAfter(): void @@ -495,8 +495,8 @@ public function testCursorAfter(): void ->cursorAfter('abc123') ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `_cursor` > ?', $result['query']); - $this->assertEquals(['abc123'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `_cursor` > ?', $result->query); + $this->assertEquals(['abc123'], $result->bindings); } public function testCursorBefore(): void @@ -506,8 +506,8 @@ public function testCursorBefore(): void ->cursorBefore('xyz789') ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `_cursor` < ?', $result['query']); - $this->assertEquals(['xyz789'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `_cursor` < ?', $result->query); + $this->assertEquals(['xyz789'], $result->bindings); } // ── Combined full query ── @@ -529,9 +529,9 @@ public function testFullCombinedQuery(): void $this->assertEquals( 'SELECT `id`, `name` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC, `age` DESC LIMIT ? OFFSET ?', - $result['query'] + $result->query ); - $this->assertEquals(['active', 18, 25, 10], $result['bindings']); + $this->assertEquals(['active', 18, 25, 10], $result->bindings); } // ── Multiple filter() calls (additive) ── @@ -544,8 +544,8 @@ public function testMultipleFilterCalls(): void ->filter([Query::equal('b', [2])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?)', $result['query']); - $this->assertEquals([1, 2], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?)', $result->query); + $this->assertEquals([1, 2], $result->bindings); } // ── Reset ── @@ -567,8 +567,8 @@ public function testResetClearsState(): void ->filter([Query::greaterThan('total', 100)]) ->build(); - $this->assertEquals('SELECT * FROM `orders` WHERE `total` > ?', $result['query']); - $this->assertEquals([100], $result['bindings']); + $this->assertEquals('SELECT * FROM `orders` WHERE `total` > ?', $result->query); + $this->assertEquals([100], $result->bindings); } // ── Extension points ── @@ -587,9 +587,9 @@ public function testAttributeResolver(): void $this->assertEquals( 'SELECT * FROM `users` WHERE `_uid` IN (?) ORDER BY `_createdAt` ASC', - $result['query'] + $result->query ); - $this->assertEquals(['abc'], $result['bindings']); + $this->assertEquals(['abc'], $result->bindings); } public function testMultipleAttributeHooksChain(): void @@ -611,7 +611,7 @@ public function resolve(string $attribute): string // First hook maps name→full_name, second prepends col_ $this->assertEquals( 'SELECT * FROM `t` WHERE `col_full_name` IN (?)', - $result['query'] + $result->query ); } @@ -640,9 +640,9 @@ public function resolve(string $attribute): string $this->assertEquals( 'SELECT * FROM `users` WHERE `_uid` IN (?) AND _tenant = ?', - $result['query'] + $result->query ); - $this->assertEquals(['abc', 't1'], $result['bindings']); + $this->assertEquals(['abc', 't1'], $result->bindings); } public function testWrapChar(): void @@ -656,7 +656,7 @@ public function testWrapChar(): void $this->assertEquals( 'SELECT "name" FROM "users" WHERE "status" IN (?)', - $result['query'] + $result->query ); } @@ -679,9 +679,9 @@ public function filter(string $table): Condition $this->assertEquals( "SELECT * FROM `users` WHERE `status` IN (?) AND _uid IN (SELECT _document FROM users_perms WHERE _type = 'read')", - $result['query'] + $result->query ); - $this->assertEquals(['active'], $result['bindings']); + $this->assertEquals(['active'], $result->bindings); } public function testConditionProviderWithBindings(): void @@ -701,10 +701,10 @@ public function filter(string $table): Condition $this->assertEquals( 'SELECT * FROM `docs` WHERE `status` IN (?) AND _tenant = ?', - $result['query'] + $result->query ); // filter bindings first, then hook bindings - $this->assertEquals(['active', 'tenant_abc'], $result['bindings']); + $this->assertEquals(['active', 'tenant_abc'], $result->bindings); } public function testBindingOrderingWithProviderAndCursor(): void @@ -726,7 +726,7 @@ public function filter(string $table): Condition ->build(); // binding order: filter, hook, cursor, limit, offset - $this->assertEquals(['active', 't1', 'cursor_val', 10, 5], $result['bindings']); + $this->assertEquals(['active', 't1', 'cursor_val', 10, 5], $result->bindings); } // ── Select with no columns defaults to * ── @@ -737,7 +737,7 @@ public function testDefaultSelectStar(): void ->from('t') ->build(); - $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals('SELECT * FROM `t`', $result->query); } // ── Aggregations ── @@ -749,8 +749,8 @@ public function testCountStar(): void ->count() ->build(); - $this->assertEquals('SELECT COUNT(*) FROM `t`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT COUNT(*) FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); } public function testCountWithAlias(): void @@ -760,7 +760,7 @@ public function testCountWithAlias(): void ->count('*', 'total') ->build(); - $this->assertEquals('SELECT COUNT(*) AS `total` FROM `t`', $result['query']); + $this->assertEquals('SELECT COUNT(*) AS `total` FROM `t`', $result->query); } public function testSumColumn(): void @@ -770,7 +770,7 @@ public function testSumColumn(): void ->sum('price', 'total_price') ->build(); - $this->assertEquals('SELECT SUM(`price`) AS `total_price` FROM `orders`', $result['query']); + $this->assertEquals('SELECT SUM(`price`) AS `total_price` FROM `orders`', $result->query); } public function testAvgColumn(): void @@ -780,7 +780,7 @@ public function testAvgColumn(): void ->avg('score') ->build(); - $this->assertEquals('SELECT AVG(`score`) FROM `t`', $result['query']); + $this->assertEquals('SELECT AVG(`score`) FROM `t`', $result->query); } public function testMinColumn(): void @@ -790,7 +790,7 @@ public function testMinColumn(): void ->min('price') ->build(); - $this->assertEquals('SELECT MIN(`price`) FROM `t`', $result['query']); + $this->assertEquals('SELECT MIN(`price`) FROM `t`', $result->query); } public function testMaxColumn(): void @@ -800,7 +800,7 @@ public function testMaxColumn(): void ->max('price') ->build(); - $this->assertEquals('SELECT MAX(`price`) FROM `t`', $result['query']); + $this->assertEquals('SELECT MAX(`price`) FROM `t`', $result->query); } public function testAggregationWithSelection(): void @@ -814,7 +814,7 @@ public function testAggregationWithSelection(): void $this->assertEquals( 'SELECT COUNT(*) AS `total`, `status` FROM `orders` GROUP BY `status`', - $result['query'] + $result->query ); } @@ -830,7 +830,7 @@ public function testGroupBy(): void $this->assertEquals( 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`', - $result['query'] + $result->query ); } @@ -844,7 +844,7 @@ public function testGroupByMultiple(): void $this->assertEquals( 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`, `country`', - $result['query'] + $result->query ); } @@ -861,9 +861,9 @@ public function testHaving(): void $this->assertEquals( 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status` HAVING `total` > ?', - $result['query'] + $result->query ); - $this->assertEquals([5], $result['bindings']); + $this->assertEquals([5], $result->bindings); } // ── Distinct ── @@ -876,7 +876,7 @@ public function testDistinct(): void ->select(['status']) ->build(); - $this->assertEquals('SELECT DISTINCT `status` FROM `t`', $result['query']); + $this->assertEquals('SELECT DISTINCT `status` FROM `t`', $result->query); } public function testDistinctStar(): void @@ -886,7 +886,7 @@ public function testDistinctStar(): void ->distinct() ->build(); - $this->assertEquals('SELECT DISTINCT * FROM `t`', $result['query']); + $this->assertEquals('SELECT DISTINCT * FROM `t`', $result->query); } // ── Joins ── @@ -900,7 +900,7 @@ public function testJoin(): void $this->assertEquals( 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id`', - $result['query'] + $result->query ); } @@ -913,7 +913,7 @@ public function testLeftJoin(): void $this->assertEquals( 'SELECT * FROM `users` LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`user_id`', - $result['query'] + $result->query ); } @@ -926,7 +926,7 @@ public function testRightJoin(): void $this->assertEquals( 'SELECT * FROM `users` RIGHT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', - $result['query'] + $result->query ); } @@ -939,7 +939,7 @@ public function testCrossJoin(): void $this->assertEquals( 'SELECT * FROM `sizes` CROSS JOIN `colors`', - $result['query'] + $result->query ); } @@ -953,9 +953,9 @@ public function testJoinWithFilter(): void $this->assertEquals( 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ?', - $result['query'] + $result->query ); - $this->assertEquals([100], $result['bindings']); + $this->assertEquals([100], $result->bindings); } // ── Raw ── @@ -967,8 +967,8 @@ public function testRawFilter(): void ->filter([Query::raw('score > ? AND score < ?', [10, 100])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE score > ? AND score < ?', $result['query']); - $this->assertEquals([10, 100], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE score > ? AND score < ?', $result->query); + $this->assertEquals([10, 100], $result->bindings); } public function testRawFilterNoBindings(): void @@ -978,8 +978,8 @@ public function testRawFilterNoBindings(): void ->filter([Query::raw('1 = 1')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result->query); + $this->assertEquals([], $result->bindings); } // ── Union ── @@ -995,9 +995,9 @@ public function testUnion(): void $this->assertEquals( '(SELECT * FROM `users` WHERE `status` IN (?)) UNION (SELECT * FROM `admins` WHERE `role` IN (?))', - $result['query'] + $result->query ); - $this->assertEquals(['active', 'admin'], $result['bindings']); + $this->assertEquals(['active', 'admin'], $result->bindings); } public function testUnionAll(): void @@ -1010,7 +1010,7 @@ public function testUnionAll(): void $this->assertEquals( '(SELECT * FROM `current`) UNION ALL (SELECT * FROM `archive`)', - $result['query'] + $result->query ); } @@ -1023,8 +1023,8 @@ public function testWhenTrue(): void ->when(true, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?)', $result['query']); - $this->assertEquals(['active'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?)', $result->query); + $this->assertEquals(['active'], $result->bindings); } public function testWhenFalse(): void @@ -1034,8 +1034,8 @@ public function testWhenFalse(): void ->when(false, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) ->build(); - $this->assertEquals('SELECT * FROM `t`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); } // ── page() ── @@ -1047,8 +1047,8 @@ public function testPage(): void ->page(3, 10) ->build(); - $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result['query']); - $this->assertEquals([10, 20], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([10, 20], $result->bindings); } public function testPageDefaultPerPage(): void @@ -1058,8 +1058,8 @@ public function testPageDefaultPerPage(): void ->page(1) ->build(); - $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result['query']); - $this->assertEquals([25, 0], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([25, 0], $result->bindings); } // ── toRawSql() ── @@ -1106,9 +1106,9 @@ public function testCombinedAggregationJoinGroupByHaving(): void $this->assertEquals( 'SELECT COUNT(*) AS `order_count`, SUM(`total`) AS `total_amount`, `users`.`name` FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` GROUP BY `users`.`name` HAVING `order_count` > ? ORDER BY `total_amount` DESC LIMIT ?', - $result['query'] + $result->query ); - $this->assertEquals([5, 10], $result['bindings']); + $this->assertEquals([5, 10], $result->bindings); } // ── Reset clears unions ── @@ -1125,7 +1125,7 @@ public function testResetClearsUnions(): void $result = $builder->from('fresh')->build(); - $this->assertEquals('SELECT * FROM `fresh`', $result['query']); + $this->assertEquals('SELECT * FROM `fresh`', $result->query); } // ══════════════════════════════════════════ @@ -1141,7 +1141,7 @@ public function testCountWithNamedColumn(): void ->count('id') ->build(); - $this->assertEquals('SELECT COUNT(`id`) FROM `t`', $result['query']); + $this->assertEquals('SELECT COUNT(`id`) FROM `t`', $result->query); } public function testCountWithEmptyStringAttribute(): void @@ -1151,7 +1151,7 @@ public function testCountWithEmptyStringAttribute(): void ->count('') ->build(); - $this->assertEquals('SELECT COUNT(*) FROM `t`', $result['query']); + $this->assertEquals('SELECT COUNT(*) FROM `t`', $result->query); } public function testMultipleAggregations(): void @@ -1167,9 +1167,9 @@ public function testMultipleAggregations(): void $this->assertEquals( 'SELECT COUNT(*) AS `cnt`, SUM(`price`) AS `total`, AVG(`score`) AS `avg_score`, MIN(`age`) AS `youngest`, MAX(`age`) AS `oldest` FROM `t`', - $result['query'] + $result->query ); - $this->assertEquals([], $result['bindings']); + $this->assertEquals([], $result->bindings); } public function testAggregationWithoutGroupBy(): void @@ -1179,7 +1179,7 @@ public function testAggregationWithoutGroupBy(): void ->sum('total', 'grand_total') ->build(); - $this->assertEquals('SELECT SUM(`total`) AS `grand_total` FROM `orders`', $result['query']); + $this->assertEquals('SELECT SUM(`total`) AS `grand_total` FROM `orders`', $result->query); } public function testAggregationWithFilter(): void @@ -1192,9 +1192,9 @@ public function testAggregationWithFilter(): void $this->assertEquals( 'SELECT COUNT(*) AS `total` FROM `orders` WHERE `status` IN (?)', - $result['query'] + $result->query ); - $this->assertEquals(['completed'], $result['bindings']); + $this->assertEquals(['completed'], $result->bindings); } public function testAggregationWithoutAlias(): void @@ -1205,7 +1205,7 @@ public function testAggregationWithoutAlias(): void ->sum('price') ->build(); - $this->assertEquals('SELECT COUNT(*), SUM(`price`) FROM `t`', $result['query']); + $this->assertEquals('SELECT COUNT(*), SUM(`price`) FROM `t`', $result->query); } // ── Group By edge cases ── @@ -1217,7 +1217,7 @@ public function testGroupByEmptyArray(): void ->groupBy([]) ->build(); - $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals('SELECT * FROM `t`', $result->query); } public function testMultipleGroupByCalls(): void @@ -1230,9 +1230,9 @@ public function testMultipleGroupByCalls(): void ->build(); // Both groupBy calls should merge since groupByType merges values - $this->assertStringContainsString('GROUP BY', $result['query']); - $this->assertStringContainsString('`status`', $result['query']); - $this->assertStringContainsString('`country`', $result['query']); + $this->assertStringContainsString('GROUP BY', $result->query); + $this->assertStringContainsString('`status`', $result->query); + $this->assertStringContainsString('`country`', $result->query); } // ── Having edge cases ── @@ -1246,7 +1246,7 @@ public function testHavingEmptyArray(): void ->having([]) ->build(); - $this->assertStringNotContainsString('HAVING', $result['query']); + $this->assertStringNotContainsString('HAVING', $result->query); } public function testHavingMultipleConditions(): void @@ -1264,9 +1264,9 @@ public function testHavingMultipleConditions(): void $this->assertEquals( 'SELECT COUNT(*) AS `total`, SUM(`price`) AS `sum_price` FROM `t` GROUP BY `status` HAVING `total` > ? AND `sum_price` < ?', - $result['query'] + $result->query ); - $this->assertEquals([5, 1000], $result['bindings']); + $this->assertEquals([5, 1000], $result->bindings); } public function testHavingWithLogicalOr(): void @@ -1283,8 +1283,8 @@ public function testHavingWithLogicalOr(): void ]) ->build(); - $this->assertStringContainsString('HAVING (`total` > ? OR `total` < ?)', $result['query']); - $this->assertEquals([10, 2], $result['bindings']); + $this->assertStringContainsString('HAVING (`total` > ? OR `total` < ?)', $result->query); + $this->assertEquals([10, 2], $result->bindings); } public function testHavingWithoutGroupBy(): void @@ -1296,8 +1296,8 @@ public function testHavingWithoutGroupBy(): void ->having([Query::greaterThan('total', 0)]) ->build(); - $this->assertStringContainsString('HAVING', $result['query']); - $this->assertStringNotContainsString('GROUP BY', $result['query']); + $this->assertStringContainsString('HAVING', $result->query); + $this->assertStringNotContainsString('GROUP BY', $result->query); } public function testMultipleHavingCalls(): void @@ -1310,8 +1310,8 @@ public function testMultipleHavingCalls(): void ->having([Query::lessThan('total', 100)]) ->build(); - $this->assertStringContainsString('HAVING `total` > ? AND `total` < ?', $result['query']); - $this->assertEquals([1, 100], $result['bindings']); + $this->assertStringContainsString('HAVING `total` > ? AND `total` < ?', $result->query); + $this->assertEquals([1, 100], $result->bindings); } // ── Distinct edge cases ── @@ -1324,7 +1324,7 @@ public function testDistinctWithAggregation(): void ->count('*', 'total') ->build(); - $this->assertEquals('SELECT DISTINCT COUNT(*) AS `total` FROM `t`', $result['query']); + $this->assertEquals('SELECT DISTINCT COUNT(*) AS `total` FROM `t`', $result->query); } public function testDistinctMultipleCalls(): void @@ -1336,7 +1336,7 @@ public function testDistinctMultipleCalls(): void ->distinct() ->build(); - $this->assertEquals('SELECT DISTINCT * FROM `t`', $result['query']); + $this->assertEquals('SELECT DISTINCT * FROM `t`', $result->query); } public function testDistinctWithJoin(): void @@ -1350,7 +1350,7 @@ public function testDistinctWithJoin(): void $this->assertEquals( 'SELECT DISTINCT `users`.`name` FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id`', - $result['query'] + $result->query ); } @@ -1366,7 +1366,7 @@ public function testDistinctWithFilterAndSort(): void $this->assertEquals( 'SELECT DISTINCT `status` FROM `t` WHERE `status` IS NOT NULL ORDER BY `status` ASC', - $result['query'] + $result->query ); } @@ -1383,7 +1383,7 @@ public function testMultipleJoins(): void $this->assertEquals( 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`user_id` RIGHT JOIN `departments` ON `users`.`dept_id` = `departments`.`id`', - $result['query'] + $result->query ); } @@ -1398,7 +1398,7 @@ public function testJoinWithAggregationAndGroupBy(): void $this->assertEquals( 'SELECT COUNT(*) AS `order_count` FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` GROUP BY `users`.`name`', - $result['query'] + $result->query ); } @@ -1415,9 +1415,9 @@ public function testJoinWithSortAndPagination(): void $this->assertEquals( 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ? ORDER BY `orders`.`total` DESC LIMIT ? OFFSET ?', - $result['query'] + $result->query ); - $this->assertEquals([50, 10, 20], $result['bindings']); + $this->assertEquals([50, 10, 20], $result->bindings); } public function testJoinWithCustomOperator(): void @@ -1429,7 +1429,7 @@ public function testJoinWithCustomOperator(): void $this->assertEquals( 'SELECT * FROM `a` JOIN `b` ON `a`.`val` != `b`.`val`', - $result['query'] + $result->query ); } @@ -1443,7 +1443,7 @@ public function testCrossJoinWithOtherJoins(): void $this->assertEquals( 'SELECT * FROM `sizes` CROSS JOIN `colors` LEFT JOIN `inventory` ON `sizes`.`id` = `inventory`.`size_id`', - $result['query'] + $result->query ); } @@ -1456,8 +1456,8 @@ public function testRawWithMixedBindings(): void ->filter([Query::raw('a = ? AND b = ? AND c = ?', ['str', 42, 3.14])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE a = ? AND b = ? AND c = ?', $result['query']); - $this->assertEquals(['str', 42, 3.14], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE a = ? AND b = ? AND c = ?', $result->query); + $this->assertEquals(['str', 42, 3.14], $result->bindings); } public function testRawCombinedWithRegularFilters(): void @@ -1472,9 +1472,9 @@ public function testRawCombinedWithRegularFilters(): void $this->assertEquals( 'SELECT * FROM `t` WHERE `status` IN (?) AND custom_func(col) > ?', - $result['query'] + $result->query ); - $this->assertEquals(['active', 10], $result['bindings']); + $this->assertEquals(['active', 10], $result->bindings); } public function testRawWithEmptySql(): void @@ -1485,7 +1485,7 @@ public function testRawWithEmptySql(): void ->build(); // Empty raw SQL still appears as a WHERE clause - $this->assertStringContainsString('WHERE', $result['query']); + $this->assertStringContainsString('WHERE', $result->query); } // ── Union edge cases ── @@ -1503,7 +1503,7 @@ public function testMultipleUnions(): void $this->assertEquals( '(SELECT * FROM `users`) UNION (SELECT * FROM `admins`) UNION (SELECT * FROM `mods`)', - $result['query'] + $result->query ); } @@ -1520,7 +1520,7 @@ public function testMixedUnionAndUnionAll(): void $this->assertEquals( '(SELECT * FROM `users`) UNION (SELECT * FROM `admins`) UNION ALL (SELECT * FROM `mods`)', - $result['query'] + $result->query ); } @@ -1538,9 +1538,9 @@ public function testUnionWithFiltersAndBindings(): void $this->assertEquals( '(SELECT * FROM `users` WHERE `status` IN (?)) UNION (SELECT * FROM `admins` WHERE `level` IN (?)) UNION ALL (SELECT * FROM `mods` WHERE `score` > ?)', - $result['query'] + $result->query ); - $this->assertEquals(['active', 1, 50], $result['bindings']); + $this->assertEquals(['active', 1, 50], $result->bindings); } public function testUnionWithAggregation(): void @@ -1555,7 +1555,7 @@ public function testUnionWithAggregation(): void $this->assertEquals( '(SELECT COUNT(*) AS `total` FROM `orders_2024`) UNION ALL (SELECT COUNT(*) AS `total` FROM `orders_2023`)', - $result['query'] + $result->query ); } @@ -1570,7 +1570,7 @@ public function testWhenNested(): void }) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $result->query); } public function testWhenMultipleCalls(): void @@ -1582,8 +1582,8 @@ public function testWhenMultipleCalls(): void ->when(true, fn (Builder $b) => $b->filter([Query::equal('c', [3])])) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?) AND `c` IN (?)', $result['query']); - $this->assertEquals([1, 3], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?) AND `c` IN (?)', $result->query); + $this->assertEquals([1, 3], $result->bindings); } // ── page() edge cases ── @@ -1596,7 +1596,7 @@ public function testPageZero(): void ->build(); // page 0 → offset clamped to 0 - $this->assertEquals([10, 0], $result['bindings']); + $this->assertEquals([10, 0], $result->bindings); } public function testPageOnePerPage(): void @@ -1606,8 +1606,8 @@ public function testPageOnePerPage(): void ->page(5, 1) ->build(); - $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result['query']); - $this->assertEquals([1, 4], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([1, 4], $result->bindings); } public function testPageLargeValues(): void @@ -1617,7 +1617,7 @@ public function testPageLargeValues(): void ->page(1000, 100) ->build(); - $this->assertEquals([100, 99900], $result['bindings']); + $this->assertEquals([100, 99900], $result->bindings); } // ── toRawSql() edge cases ── @@ -1677,12 +1677,8 @@ public function testToRawSqlComplexQuery(): void public function testCompileFilterUnsupportedType(): void { - $builder = new Builder(); - $query = new Query('totallyInvalid', 'x', [1]); - - $this->expectException(\Utopia\Query\Exception::class); - $this->expectExceptionMessage('Unsupported filter type: totallyInvalid'); - $builder->compileFilter($query); + $this->expectException(\ValueError::class); + new Query('totallyInvalid', 'x', [1]); } public function testCompileOrderUnsupportedType(): void @@ -1729,7 +1725,7 @@ public function filter(string $table): Condition ->build(); // Order: filter bindings, hook bindings, cursor, limit, offset - $this->assertEquals(['x', 5, 'tenant1', 'cursor_abc', 10, 20], $result['bindings']); + $this->assertEquals(['x', 5, 'tenant1', 'cursor_abc', 10, 20], $result->bindings); } public function testBindingOrderMultipleProviders(): void @@ -1754,7 +1750,7 @@ public function filter(string $table): Condition ->filter([Query::equal('a', ['x'])]) ->build(); - $this->assertEquals(['x', 'v1', 'v2'], $result['bindings']); + $this->assertEquals(['x', 'v1', 'v2'], $result->bindings); } public function testBindingOrderHavingAfterFilters(): void @@ -1769,7 +1765,7 @@ public function testBindingOrderHavingAfterFilters(): void ->build(); // Filter bindings, then having bindings, then limit - $this->assertEquals(['active', 5, 10], $result['bindings']); + $this->assertEquals(['active', 5, 10], $result->bindings); } public function testBindingOrderUnionAppendedLast(): void @@ -1784,7 +1780,7 @@ public function testBindingOrderUnionAppendedLast(): void ->build(); // Main filter, main limit, then union bindings - $this->assertEquals(['b', 5, 'y'], $result['bindings']); + $this->assertEquals(['b', 5, 'y'], $result->bindings); } public function testBindingOrderComplexMixed(): void @@ -1812,7 +1808,7 @@ public function filter(string $table): Condition ->build(); // filter, hook, cursor, having, limit, offset, union - $this->assertEquals(['paid', 'org1', 'cur1', 1, 10, 5, 2023], $result['bindings']); + $this->assertEquals(['paid', 'org1', 'cur1', 1, 10, 5, 2023], $result->bindings); } // ── Attribute resolver with new features ── @@ -1825,7 +1821,7 @@ public function testAttributeResolverWithAggregation(): void ->sum('$price', 'total') ->build(); - $this->assertEquals('SELECT SUM(`_price`) AS `total` FROM `t`', $result['query']); + $this->assertEquals('SELECT SUM(`_price`) AS `total` FROM `t`', $result->query); } public function testAttributeResolverWithGroupBy(): void @@ -1839,7 +1835,7 @@ public function testAttributeResolverWithGroupBy(): void $this->assertEquals( 'SELECT COUNT(*) AS `total` FROM `t` GROUP BY `_status`', - $result['query'] + $result->query ); } @@ -1856,7 +1852,7 @@ public function testAttributeResolverWithJoin(): void $this->assertEquals( 'SELECT * FROM `t` JOIN `other` ON `_uid` = `_ref`', - $result['query'] + $result->query ); } @@ -1870,7 +1866,7 @@ public function testAttributeResolverWithHaving(): void ->having([Query::greaterThan('$total', 5)]) ->build(); - $this->assertStringContainsString('HAVING `_total` > ?', $result['query']); + $this->assertStringContainsString('HAVING `_total` > ?', $result->query); } // ── Wrap char with new features ── @@ -1885,7 +1881,7 @@ public function testWrapCharWithJoin(): void $this->assertEquals( 'SELECT * FROM "users" JOIN "orders" ON "users"."id" = "orders"."uid"', - $result['query'] + $result->query ); } @@ -1900,7 +1896,7 @@ public function testWrapCharWithAggregation(): void $this->assertEquals( 'SELECT COUNT("id") AS "total" FROM "t" GROUP BY "status"', - $result['query'] + $result->query ); } @@ -1913,7 +1909,7 @@ public function testWrapCharEmpty(): void ->filter([Query::equal('status', ['active'])]) ->build(); - $this->assertEquals('SELECT name FROM t WHERE status IN (?)', $result['query']); + $this->assertEquals('SELECT name FROM t WHERE status IN (?)', $result->query); } // ── Condition provider with new features ── @@ -1936,9 +1932,9 @@ public function filter(string $table): Condition $this->assertEquals( 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ? AND users.org_id = ?', - $result['query'] + $result->query ); - $this->assertEquals([100, 'org1'], $result['bindings']); + $this->assertEquals([100, 'org1'], $result->bindings); } public function testConditionProviderWithAggregation(): void @@ -1957,8 +1953,8 @@ public function filter(string $table): Condition ->groupBy(['status']) ->build(); - $this->assertStringContainsString('WHERE org_id = ?', $result['query']); - $this->assertEquals(['org1'], $result['bindings']); + $this->assertStringContainsString('WHERE org_id = ?', $result->query); + $this->assertEquals(['org1'], $result->bindings); } // ── Multiple build() calls ── @@ -1973,8 +1969,8 @@ public function testMultipleBuildsConsistentOutput(): void $result1 = $builder->build(); $result2 = $builder->build(); - $this->assertEquals($result1['query'], $result2['query']); - $this->assertEquals($result1['bindings'], $result2['bindings']); + $this->assertEquals($result1->query, $result2->query); + $this->assertEquals($result1->bindings, $result2->bindings); } // ── Reset behavior ── @@ -1999,7 +1995,7 @@ public function resolve(string $attribute): string // wrapChar and hooks should persist since reset() only clears queries/bindings/table/unions $result = $builder->from('t2')->filter([Query::equal('y', [2])])->build(); - $this->assertEquals('SELECT * FROM "t2" WHERE "_y" IN (?)', $result['query']); + $this->assertEquals('SELECT * FROM "t2" WHERE "_y" IN (?)', $result->query); } // ── Empty query ── @@ -2007,7 +2003,7 @@ public function resolve(string $attribute): string public function testEmptyBuilderNoFrom(): void { $result = (new Builder())->from('')->build(); - $this->assertEquals('SELECT * FROM ``', $result['query']); + $this->assertEquals('SELECT * FROM ``', $result->query); } // ── Cursor with other pagination ── @@ -2023,9 +2019,9 @@ public function testCursorWithLimitAndOffset(): void $this->assertEquals( 'SELECT * FROM `t` WHERE `_cursor` > ? LIMIT ? OFFSET ?', - $result['query'] + $result->query ); - $this->assertEquals(['abc', 10, 5], $result['bindings']); + $this->assertEquals(['abc', 10, 5], $result->bindings); } public function testCursorWithPage(): void @@ -2037,8 +2033,8 @@ public function testCursorWithPage(): void ->build(); // Cursor + limit from page + offset from page; first limit/offset wins - $this->assertStringContainsString('`_cursor` > ?', $result['query']); - $this->assertStringContainsString('LIMIT ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); } // ── Full kitchen sink ── @@ -2074,23 +2070,23 @@ public function filter(string $table): Condition ->build(); // Verify structural elements - $this->assertStringContainsString('SELECT DISTINCT', $result['query']); - $this->assertStringContainsString('COUNT(*) AS `cnt`', $result['query']); - $this->assertStringContainsString('SUM(`total`) AS `sum_total`', $result['query']); - $this->assertStringContainsString('`status`', $result['query']); - $this->assertStringContainsString('FROM `orders`', $result['query']); - $this->assertStringContainsString('JOIN `users`', $result['query']); - $this->assertStringContainsString('LEFT JOIN `coupons`', $result['query']); - $this->assertStringContainsString('WHERE', $result['query']); - $this->assertStringContainsString('GROUP BY `status`', $result['query']); - $this->assertStringContainsString('HAVING `cnt` > ?', $result['query']); - $this->assertStringContainsString('ORDER BY `sum_total` DESC', $result['query']); - $this->assertStringContainsString('LIMIT ?', $result['query']); - $this->assertStringContainsString('OFFSET ?', $result['query']); - $this->assertStringContainsString('UNION', $result['query']); + $this->assertStringContainsString('SELECT DISTINCT', $result->query); + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('SUM(`total`) AS `sum_total`', $result->query); + $this->assertStringContainsString('`status`', $result->query); + $this->assertStringContainsString('FROM `orders`', $result->query); + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('LEFT JOIN `coupons`', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('GROUP BY `status`', $result->query); + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); + $this->assertStringContainsString('ORDER BY `sum_total` DESC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertStringContainsString('UNION', $result->query); // Verify SQL clause ordering - $query = $result['query']; + $query = $result->query; $this->assertLessThan(strpos($query, 'FROM'), strpos($query, 'SELECT')); $this->assertLessThan(strpos($query, 'JOIN'), (int) strpos($query, 'FROM')); $this->assertLessThan(strpos($query, 'WHERE'), (int) strpos($query, 'JOIN')); @@ -2111,7 +2107,7 @@ public function testFilterEmptyArray(): void ->filter([]) ->build(); - $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals('SELECT * FROM `t`', $result->query); } public function testSelectEmptyArray(): void @@ -2122,7 +2118,7 @@ public function testSelectEmptyArray(): void ->build(); // Empty select produces empty column list - $this->assertEquals('SELECT FROM `t`', $result['query']); + $this->assertEquals('SELECT FROM `t`', $result->query); } // ── Limit/offset edge values ── @@ -2134,8 +2130,8 @@ public function testLimitZero(): void ->limit(0) ->build(); - $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result['query']); - $this->assertEquals([0], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertEquals([0], $result->bindings); } public function testOffsetZero(): void @@ -2146,8 +2142,8 @@ public function testOffsetZero(): void ->build(); // OFFSET without LIMIT is suppressed - $this->assertEquals('SELECT * FROM `t`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); } // ── Fluent chaining returns same instance ── @@ -2207,8 +2203,8 @@ public function testRegexWithEmptyPattern(): void ->filter([Query::regex('slug', '')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result['query']); - $this->assertEquals([''], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result->query); + $this->assertEquals([''], $result->bindings); } public function testRegexWithDotChar(): void @@ -2218,8 +2214,8 @@ public function testRegexWithDotChar(): void ->filter([Query::regex('name', 'a.b')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `name` REGEXP ?', $result['query']); - $this->assertEquals(['a.b'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `name` REGEXP ?', $result->query); + $this->assertEquals(['a.b'], $result->bindings); } public function testRegexWithStarChar(): void @@ -2229,7 +2225,7 @@ public function testRegexWithStarChar(): void ->filter([Query::regex('name', 'a*b')]) ->build(); - $this->assertEquals(['a*b'], $result['bindings']); + $this->assertEquals(['a*b'], $result->bindings); } public function testRegexWithPlusChar(): void @@ -2239,7 +2235,7 @@ public function testRegexWithPlusChar(): void ->filter([Query::regex('name', 'a+')]) ->build(); - $this->assertEquals(['a+'], $result['bindings']); + $this->assertEquals(['a+'], $result->bindings); } public function testRegexWithQuestionMarkChar(): void @@ -2249,7 +2245,7 @@ public function testRegexWithQuestionMarkChar(): void ->filter([Query::regex('name', 'colou?r')]) ->build(); - $this->assertEquals(['colou?r'], $result['bindings']); + $this->assertEquals(['colou?r'], $result->bindings); } public function testRegexWithCaretAndDollar(): void @@ -2259,7 +2255,7 @@ public function testRegexWithCaretAndDollar(): void ->filter([Query::regex('code', '^[A-Z]+$')]) ->build(); - $this->assertEquals(['^[A-Z]+$'], $result['bindings']); + $this->assertEquals(['^[A-Z]+$'], $result->bindings); } public function testRegexWithPipeChar(): void @@ -2269,7 +2265,7 @@ public function testRegexWithPipeChar(): void ->filter([Query::regex('color', 'red|blue|green')]) ->build(); - $this->assertEquals(['red|blue|green'], $result['bindings']); + $this->assertEquals(['red|blue|green'], $result->bindings); } public function testRegexWithBackslash(): void @@ -2279,7 +2275,7 @@ public function testRegexWithBackslash(): void ->filter([Query::regex('path', '\\\\server\\\\share')]) ->build(); - $this->assertEquals(['\\\\server\\\\share'], $result['bindings']); + $this->assertEquals(['\\\\server\\\\share'], $result->bindings); } public function testRegexWithBracketsAndBraces(): void @@ -2289,7 +2285,7 @@ public function testRegexWithBracketsAndBraces(): void ->filter([Query::regex('zip', '[0-9]{5}')]) ->build(); - $this->assertEquals('[0-9]{5}', $result['bindings'][0]); + $this->assertEquals('[0-9]{5}', $result->bindings[0]); } public function testRegexWithParentheses(): void @@ -2299,7 +2295,7 @@ public function testRegexWithParentheses(): void ->filter([Query::regex('phone', '(\\+1)?[0-9]{10}')]) ->build(); - $this->assertEquals(['(\\+1)?[0-9]{10}'], $result['bindings']); + $this->assertEquals(['(\\+1)?[0-9]{10}'], $result->bindings); } public function testRegexCombinedWithOtherFilters(): void @@ -2315,9 +2311,9 @@ public function testRegexCombinedWithOtherFilters(): void $this->assertEquals( 'SELECT * FROM `t` WHERE `status` IN (?) AND `slug` REGEXP ? AND `age` > ?', - $result['query'] + $result->query ); - $this->assertEquals(['active', '^[a-z-]+$', 18], $result['bindings']); + $this->assertEquals(['active', '^[a-z-]+$', 18], $result->bindings); } public function testRegexWithAttributeResolver(): void @@ -2330,8 +2326,8 @@ public function testRegexWithAttributeResolver(): void ->filter([Query::regex('$slug', '^test')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `_slug` REGEXP ?', $result['query']); - $this->assertEquals(['^test'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `_slug` REGEXP ?', $result->query); + $this->assertEquals(['^test'], $result->bindings); } public function testRegexWithDifferentWrapChar(): void @@ -2342,7 +2338,7 @@ public function testRegexWithDifferentWrapChar(): void ->filter([Query::regex('slug', '^[a-z]+$')]) ->build(); - $this->assertEquals('SELECT * FROM "t" WHERE "slug" REGEXP ?', $result['query']); + $this->assertEquals('SELECT * FROM "t" WHERE "slug" REGEXP ?', $result->query); } public function testRegexStandaloneCompileFilter(): void @@ -2363,7 +2359,7 @@ public function testRegexBindingPreservedExactly(): void ->filter([Query::regex('email', $pattern)]) ->build(); - $this->assertSame($pattern, $result['bindings'][0]); + $this->assertSame($pattern, $result->bindings[0]); } public function testRegexWithVeryLongPattern(): void @@ -2374,8 +2370,8 @@ public function testRegexWithVeryLongPattern(): void ->filter([Query::regex('col', $pattern)]) ->build(); - $this->assertEquals($pattern, $result['bindings'][0]); - $this->assertStringContainsString('REGEXP ?', $result['query']); + $this->assertEquals($pattern, $result->bindings[0]); + $this->assertStringContainsString('REGEXP ?', $result->query); } public function testMultipleRegexFilters(): void @@ -2390,9 +2386,9 @@ public function testMultipleRegexFilters(): void $this->assertEquals( 'SELECT * FROM `t` WHERE `name` REGEXP ? AND `email` REGEXP ?', - $result['query'] + $result->query ); - $this->assertEquals(['^A', '@test\\.com$'], $result['bindings']); + $this->assertEquals(['^A', '@test\\.com$'], $result->bindings); } public function testRegexInAndLogicalGroup(): void @@ -2409,9 +2405,9 @@ public function testRegexInAndLogicalGroup(): void $this->assertEquals( 'SELECT * FROM `t` WHERE (`slug` REGEXP ? AND `status` IN (?))', - $result['query'] + $result->query ); - $this->assertEquals(['^[a-z]+$', 'active'], $result['bindings']); + $this->assertEquals(['^[a-z]+$', 'active'], $result->bindings); } public function testRegexInOrLogicalGroup(): void @@ -2428,9 +2424,9 @@ public function testRegexInOrLogicalGroup(): void $this->assertEquals( 'SELECT * FROM `t` WHERE (`name` REGEXP ? OR `name` REGEXP ?)', - $result['query'] + $result->query ); - $this->assertEquals(['^Admin', '^Mod'], $result['bindings']); + $this->assertEquals(['^Admin', '^Mod'], $result->bindings); } // ══════════════════════════════════════════ @@ -2444,8 +2440,8 @@ public function testSearchWithEmptyString(): void ->filter([Query::search('content', '')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?)', $result['query']); - $this->assertEquals([''], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?)', $result->query); + $this->assertEquals([''], $result->bindings); } public function testSearchWithSpecialCharacters(): void @@ -2455,7 +2451,7 @@ public function testSearchWithSpecialCharacters(): void ->filter([Query::search('body', 'hello "world" +required -excluded')]) ->build(); - $this->assertEquals(['hello "world" +required -excluded'], $result['bindings']); + $this->assertEquals(['hello "world" +required -excluded'], $result->bindings); } public function testSearchCombinedWithOtherFilters(): void @@ -2471,9 +2467,9 @@ public function testSearchCombinedWithOtherFilters(): void $this->assertEquals( 'SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?) AND `status` IN (?) AND `views` > ?', - $result['query'] + $result->query ); - $this->assertEquals(['hello', 'published', 100], $result['bindings']); + $this->assertEquals(['hello', 'published', 100], $result->bindings); } public function testNotSearchCombinedWithOtherFilters(): void @@ -2488,9 +2484,9 @@ public function testNotSearchCombinedWithOtherFilters(): void $this->assertEquals( 'SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?)) AND `status` IN (?)', - $result['query'] + $result->query ); - $this->assertEquals(['spam', 'published'], $result['bindings']); + $this->assertEquals(['spam', 'published'], $result->bindings); } public function testSearchWithAttributeResolver(): void @@ -2503,7 +2499,7 @@ public function testSearchWithAttributeResolver(): void ->filter([Query::search('$body', 'hello')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`_body`) AGAINST(?)', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`_body`) AGAINST(?)', $result->query); } public function testSearchWithDifferentWrapChar(): void @@ -2514,7 +2510,7 @@ public function testSearchWithDifferentWrapChar(): void ->filter([Query::search('content', 'hello')]) ->build(); - $this->assertEquals('SELECT * FROM "t" WHERE MATCH("content") AGAINST(?)', $result['query']); + $this->assertEquals('SELECT * FROM "t" WHERE MATCH("content") AGAINST(?)', $result->query); } public function testSearchStandaloneCompileFilter(): void @@ -2545,7 +2541,7 @@ public function testSearchBindingPreservedExactly(): void ->filter([Query::search('content', $searchTerm)]) ->build(); - $this->assertSame($searchTerm, $result['bindings'][0]); + $this->assertSame($searchTerm, $result->bindings[0]); } public function testSearchWithVeryLongText(): void @@ -2556,7 +2552,7 @@ public function testSearchWithVeryLongText(): void ->filter([Query::search('content', $longText)]) ->build(); - $this->assertEquals($longText, $result['bindings'][0]); + $this->assertEquals($longText, $result->bindings[0]); } public function testMultipleSearchFilters(): void @@ -2571,9 +2567,9 @@ public function testMultipleSearchFilters(): void $this->assertEquals( 'SELECT * FROM `t` WHERE MATCH(`title`) AGAINST(?) AND MATCH(`body`) AGAINST(?)', - $result['query'] + $result->query ); - $this->assertEquals(['hello', 'world'], $result['bindings']); + $this->assertEquals(['hello', 'world'], $result->bindings); } public function testSearchInAndLogicalGroup(): void @@ -2590,7 +2586,7 @@ public function testSearchInAndLogicalGroup(): void $this->assertEquals( 'SELECT * FROM `t` WHERE (MATCH(`content`) AGAINST(?) AND `status` IN (?))', - $result['query'] + $result->query ); } @@ -2608,9 +2604,9 @@ public function testSearchInOrLogicalGroup(): void $this->assertEquals( 'SELECT * FROM `t` WHERE (MATCH(`title`) AGAINST(?) OR MATCH(`body`) AGAINST(?))', - $result['query'] + $result->query ); - $this->assertEquals(['hello', 'hello'], $result['bindings']); + $this->assertEquals(['hello', 'hello'], $result->bindings); } public function testSearchAndRegexCombined(): void @@ -2625,9 +2621,9 @@ public function testSearchAndRegexCombined(): void $this->assertEquals( 'SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?) AND `slug` REGEXP ?', - $result['query'] + $result->query ); - $this->assertEquals(['hello world', '^[a-z-]+$'], $result['bindings']); + $this->assertEquals(['hello world', '^[a-z-]+$'], $result->bindings); } public function testNotSearchStandalone(): void @@ -2637,8 +2633,8 @@ public function testNotSearchStandalone(): void ->filter([Query::notSearch('content', 'spam')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?))', $result['query']); - $this->assertEquals(['spam'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?))', $result->query); + $this->assertEquals(['spam'], $result->bindings); } // ══════════════════════════════════════════ @@ -2665,7 +2661,7 @@ public function testRandomSortCombinedWithAscDesc(): void $this->assertEquals( 'SELECT * FROM `t` ORDER BY `name` ASC, RAND(), `age` DESC', - $result['query'] + $result->query ); } @@ -2679,9 +2675,9 @@ public function testRandomSortWithFilters(): void $this->assertEquals( 'SELECT * FROM `t` WHERE `status` IN (?) ORDER BY RAND()', - $result['query'] + $result->query ); - $this->assertEquals(['active'], $result['bindings']); + $this->assertEquals(['active'], $result->bindings); } public function testRandomSortWithLimit(): void @@ -2692,8 +2688,8 @@ public function testRandomSortWithLimit(): void ->limit(5) ->build(); - $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ?', $result['query']); - $this->assertEquals([5], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ?', $result->query); + $this->assertEquals([5], $result->bindings); } public function testRandomSortWithAggregation(): void @@ -2705,8 +2701,8 @@ public function testRandomSortWithAggregation(): void ->sortRandom() ->build(); - $this->assertStringContainsString('ORDER BY RAND()', $result['query']); - $this->assertStringContainsString('COUNT(*) AS `total`', $result['query']); + $this->assertStringContainsString('ORDER BY RAND()', $result->query); + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); } public function testRandomSortWithJoins(): void @@ -2717,8 +2713,8 @@ public function testRandomSortWithJoins(): void ->sortRandom() ->build(); - $this->assertStringContainsString('JOIN `orders`', $result['query']); - $this->assertStringContainsString('ORDER BY RAND()', $result['query']); + $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('ORDER BY RAND()', $result->query); } public function testRandomSortWithDistinct(): void @@ -2732,7 +2728,7 @@ public function testRandomSortWithDistinct(): void $this->assertEquals( 'SELECT DISTINCT `status` FROM `t` ORDER BY RAND()', - $result['query'] + $result->query ); } @@ -2746,8 +2742,8 @@ public function testRandomSortInBatchMode(): void ]) ->build(); - $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ?', $result['query']); - $this->assertEquals([10], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ?', $result->query); + $this->assertEquals([10], $result->bindings); } public function testRandomSortWithAttributeResolver(): void @@ -2763,7 +2759,7 @@ public function resolve(string $attribute): string ->sortRandom() ->build(); - $this->assertStringContainsString('ORDER BY RAND()', $result['query']); + $this->assertStringContainsString('ORDER BY RAND()', $result->query); } public function testMultipleRandomSorts(): void @@ -2774,7 +2770,7 @@ public function testMultipleRandomSorts(): void ->sortRandom() ->build(); - $this->assertEquals('SELECT * FROM `t` ORDER BY RAND(), RAND()', $result['query']); + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND(), RAND()', $result->query); } public function testRandomSortWithOffset(): void @@ -2786,8 +2782,8 @@ public function testRandomSortWithOffset(): void ->offset(5) ->build(); - $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ? OFFSET ?', $result['query']); - $this->assertEquals([10, 5], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([10, 5], $result->bindings); } // ══════════════════════════════════════════ @@ -2802,7 +2798,7 @@ public function testWrapCharSingleQuote(): void ->select(['name']) ->build(); - $this->assertEquals("SELECT 'name' FROM 't'", $result['query']); + $this->assertEquals("SELECT 'name' FROM 't'", $result->query); } public function testWrapCharSquareBracket(): void @@ -2813,7 +2809,7 @@ public function testWrapCharSquareBracket(): void ->select(['name']) ->build(); - $this->assertEquals('SELECT [name[ FROM [t[', $result['query']); + $this->assertEquals('SELECT [name[ FROM [t[', $result->query); } public function testWrapCharUnicode(): void @@ -2824,7 +2820,7 @@ public function testWrapCharUnicode(): void ->select(['name']) ->build(); - $this->assertEquals("SELECT \xC2\xABname\xC2\xAB FROM \xC2\xABt\xC2\xAB", $result['query']); + $this->assertEquals("SELECT \xC2\xABname\xC2\xAB FROM \xC2\xABt\xC2\xAB", $result->query); } public function testWrapCharAffectsSelect(): void @@ -2835,7 +2831,7 @@ public function testWrapCharAffectsSelect(): void ->select(['a', 'b', 'c']) ->build(); - $this->assertEquals('SELECT "a", "b", "c" FROM "t"', $result['query']); + $this->assertEquals('SELECT "a", "b", "c" FROM "t"', $result->query); } public function testWrapCharAffectsFrom(): void @@ -2845,7 +2841,7 @@ public function testWrapCharAffectsFrom(): void ->from('my_table') ->build(); - $this->assertEquals('SELECT * FROM "my_table"', $result['query']); + $this->assertEquals('SELECT * FROM "my_table"', $result->query); } public function testWrapCharAffectsFilter(): void @@ -2856,7 +2852,7 @@ public function testWrapCharAffectsFilter(): void ->filter([Query::equal('col', [1])]) ->build(); - $this->assertEquals('SELECT * FROM "t" WHERE "col" IN (?)', $result['query']); + $this->assertEquals('SELECT * FROM "t" WHERE "col" IN (?)', $result->query); } public function testWrapCharAffectsSort(): void @@ -2868,7 +2864,7 @@ public function testWrapCharAffectsSort(): void ->sortDesc('age') ->build(); - $this->assertEquals('SELECT * FROM "t" ORDER BY "name" ASC, "age" DESC', $result['query']); + $this->assertEquals('SELECT * FROM "t" ORDER BY "name" ASC, "age" DESC', $result->query); } public function testWrapCharAffectsJoin(): void @@ -2881,7 +2877,7 @@ public function testWrapCharAffectsJoin(): void $this->assertEquals( 'SELECT * FROM "users" JOIN "orders" ON "users"."id" = "orders"."uid"', - $result['query'] + $result->query ); } @@ -2895,7 +2891,7 @@ public function testWrapCharAffectsLeftJoin(): void $this->assertEquals( 'SELECT * FROM "users" LEFT JOIN "profiles" ON "users"."id" = "profiles"."uid"', - $result['query'] + $result->query ); } @@ -2909,7 +2905,7 @@ public function testWrapCharAffectsRightJoin(): void $this->assertEquals( 'SELECT * FROM "users" RIGHT JOIN "orders" ON "users"."id" = "orders"."uid"', - $result['query'] + $result->query ); } @@ -2921,7 +2917,7 @@ public function testWrapCharAffectsCrossJoin(): void ->crossJoin('b') ->build(); - $this->assertEquals('SELECT * FROM "a" CROSS JOIN "b"', $result['query']); + $this->assertEquals('SELECT * FROM "a" CROSS JOIN "b"', $result->query); } public function testWrapCharAffectsAggregation(): void @@ -2932,7 +2928,7 @@ public function testWrapCharAffectsAggregation(): void ->sum('price', 'total') ->build(); - $this->assertEquals('SELECT SUM("price") AS "total" FROM "t"', $result['query']); + $this->assertEquals('SELECT SUM("price") AS "total" FROM "t"', $result->query); } public function testWrapCharAffectsGroupBy(): void @@ -2946,7 +2942,7 @@ public function testWrapCharAffectsGroupBy(): void $this->assertEquals( 'SELECT COUNT(*) AS "cnt" FROM "t" GROUP BY "status", "country"', - $result['query'] + $result->query ); } @@ -2960,7 +2956,7 @@ public function testWrapCharAffectsHaving(): void ->having([Query::greaterThan('cnt', 5)]) ->build(); - $this->assertStringContainsString('HAVING "cnt" > ?', $result['query']); + $this->assertStringContainsString('HAVING "cnt" > ?', $result->query); } public function testWrapCharAffectsDistinct(): void @@ -2972,7 +2968,7 @@ public function testWrapCharAffectsDistinct(): void ->select(['status']) ->build(); - $this->assertEquals('SELECT DISTINCT "status" FROM "t"', $result['query']); + $this->assertEquals('SELECT DISTINCT "status" FROM "t"', $result->query); } public function testWrapCharAffectsRegex(): void @@ -2983,7 +2979,7 @@ public function testWrapCharAffectsRegex(): void ->filter([Query::regex('slug', '^test')]) ->build(); - $this->assertEquals('SELECT * FROM "t" WHERE "slug" REGEXP ?', $result['query']); + $this->assertEquals('SELECT * FROM "t" WHERE "slug" REGEXP ?', $result->query); } public function testWrapCharAffectsSearch(): void @@ -2994,7 +2990,7 @@ public function testWrapCharAffectsSearch(): void ->filter([Query::search('body', 'hello')]) ->build(); - $this->assertEquals('SELECT * FROM "t" WHERE MATCH("body") AGAINST(?)', $result['query']); + $this->assertEquals('SELECT * FROM "t" WHERE MATCH("body") AGAINST(?)', $result->query); } public function testWrapCharEmptyForSelect(): void @@ -3005,7 +3001,7 @@ public function testWrapCharEmptyForSelect(): void ->select(['a', 'b']) ->build(); - $this->assertEquals('SELECT a, b FROM t', $result['query']); + $this->assertEquals('SELECT a, b FROM t', $result->query); } public function testWrapCharEmptyForFilter(): void @@ -3016,7 +3012,7 @@ public function testWrapCharEmptyForFilter(): void ->filter([Query::greaterThan('age', 18)]) ->build(); - $this->assertEquals('SELECT * FROM t WHERE age > ?', $result['query']); + $this->assertEquals('SELECT * FROM t WHERE age > ?', $result->query); } public function testWrapCharEmptyForSort(): void @@ -3027,7 +3023,7 @@ public function testWrapCharEmptyForSort(): void ->sortAsc('name') ->build(); - $this->assertEquals('SELECT * FROM t ORDER BY name ASC', $result['query']); + $this->assertEquals('SELECT * FROM t ORDER BY name ASC', $result->query); } public function testWrapCharEmptyForJoin(): void @@ -3038,7 +3034,7 @@ public function testWrapCharEmptyForJoin(): void ->join('orders', 'users.id', 'orders.uid') ->build(); - $this->assertEquals('SELECT * FROM users JOIN orders ON users.id = orders.uid', $result['query']); + $this->assertEquals('SELECT * FROM users JOIN orders ON users.id = orders.uid', $result->query); } public function testWrapCharEmptyForAggregation(): void @@ -3049,7 +3045,7 @@ public function testWrapCharEmptyForAggregation(): void ->count('id', 'total') ->build(); - $this->assertEquals('SELECT COUNT(id) AS total FROM t', $result['query']); + $this->assertEquals('SELECT COUNT(id) AS total FROM t', $result->query); } public function testWrapCharEmptyForGroupBy(): void @@ -3061,7 +3057,7 @@ public function testWrapCharEmptyForGroupBy(): void ->groupBy(['status']) ->build(); - $this->assertEquals('SELECT COUNT(*) AS cnt FROM t GROUP BY status', $result['query']); + $this->assertEquals('SELECT COUNT(*) AS cnt FROM t GROUP BY status', $result->query); } public function testWrapCharEmptyForDistinct(): void @@ -3073,7 +3069,7 @@ public function testWrapCharEmptyForDistinct(): void ->select(['name']) ->build(); - $this->assertEquals('SELECT DISTINCT name FROM t', $result['query']); + $this->assertEquals('SELECT DISTINCT name FROM t', $result->query); } public function testWrapCharDoubleQuoteForSelect(): void @@ -3084,7 +3080,7 @@ public function testWrapCharDoubleQuoteForSelect(): void ->select(['x', 'y']) ->build(); - $this->assertEquals('SELECT "x", "y" FROM "t"', $result['query']); + $this->assertEquals('SELECT "x", "y" FROM "t"', $result->query); } public function testWrapCharDoubleQuoteForIsNull(): void @@ -3095,7 +3091,7 @@ public function testWrapCharDoubleQuoteForIsNull(): void ->filter([Query::isNull('deleted')]) ->build(); - $this->assertEquals('SELECT * FROM "t" WHERE "deleted" IS NULL', $result['query']); + $this->assertEquals('SELECT * FROM "t" WHERE "deleted" IS NULL', $result->query); } public function testWrapCharCalledMultipleTimesLastWins(): void @@ -3108,7 +3104,7 @@ public function testWrapCharCalledMultipleTimesLastWins(): void ->select(['name']) ->build(); - $this->assertEquals('SELECT `name` FROM `t`', $result['query']); + $this->assertEquals('SELECT `name` FROM `t`', $result->query); } public function testWrapCharDoesNotAffectRawExpressions(): void @@ -3119,7 +3115,7 @@ public function testWrapCharDoesNotAffectRawExpressions(): void ->filter([Query::raw('custom_func(col) > ?', [10])]) ->build(); - $this->assertEquals('SELECT * FROM "t" WHERE custom_func(col) > ?', $result['query']); + $this->assertEquals('SELECT * FROM "t" WHERE custom_func(col) > ?', $result->query); } public function testWrapCharPersistsAcrossMultipleBuilds(): void @@ -3132,8 +3128,8 @@ public function testWrapCharPersistsAcrossMultipleBuilds(): void $result1 = $builder->build(); $result2 = $builder->build(); - $this->assertEquals('SELECT "name" FROM "t"', $result1['query']); - $this->assertEquals('SELECT "name" FROM "t"', $result2['query']); + $this->assertEquals('SELECT "name" FROM "t"', $result1->query); + $this->assertEquals('SELECT "name" FROM "t"', $result2->query); } public function testWrapCharWithConditionProviderNotWrapped(): void @@ -3149,8 +3145,8 @@ public function filter(string $table): Condition }) ->build(); - $this->assertStringContainsString('WHERE raw_condition = 1', $result['query']); - $this->assertStringContainsString('FROM "t"', $result['query']); + $this->assertStringContainsString('WHERE raw_condition = 1', $result->query); + $this->assertStringContainsString('FROM "t"', $result->query); } public function testWrapCharEmptyForRegex(): void @@ -3161,7 +3157,7 @@ public function testWrapCharEmptyForRegex(): void ->filter([Query::regex('slug', '^test')]) ->build(); - $this->assertEquals('SELECT * FROM t WHERE slug REGEXP ?', $result['query']); + $this->assertEquals('SELECT * FROM t WHERE slug REGEXP ?', $result->query); } public function testWrapCharEmptyForSearch(): void @@ -3172,7 +3168,7 @@ public function testWrapCharEmptyForSearch(): void ->filter([Query::search('body', 'hello')]) ->build(); - $this->assertEquals('SELECT * FROM t WHERE MATCH(body) AGAINST(?)', $result['query']); + $this->assertEquals('SELECT * FROM t WHERE MATCH(body) AGAINST(?)', $result->query); } public function testWrapCharEmptyForHaving(): void @@ -3185,7 +3181,7 @@ public function testWrapCharEmptyForHaving(): void ->having([Query::greaterThan('cnt', 5)]) ->build(); - $this->assertStringContainsString('HAVING cnt > ?', $result['query']); + $this->assertStringContainsString('HAVING cnt > ?', $result->query); } // ══════════════════════════════════════════ @@ -3568,8 +3564,8 @@ public function testEqualWithSingleValue(): void ->filter([Query::equal('status', ['active'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?)', $result['query']); - $this->assertEquals(['active'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?)', $result->query); + $this->assertEquals(['active'], $result->bindings); } public function testEqualWithManyValues(): void @@ -3581,8 +3577,8 @@ public function testEqualWithManyValues(): void ->build(); $placeholders = implode(', ', array_fill(0, 10, '?')); - $this->assertEquals("SELECT * FROM `t` WHERE `id` IN ({$placeholders})", $result['query']); - $this->assertEquals($values, $result['bindings']); + $this->assertEquals("SELECT * FROM `t` WHERE `id` IN ({$placeholders})", $result->query); + $this->assertEquals($values, $result->bindings); } public function testEqualWithEmptyArray(): void @@ -3592,8 +3588,8 @@ public function testEqualWithEmptyArray(): void ->filter([Query::equal('id', [])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE 1 = 0', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE 1 = 0', $result->query); + $this->assertEquals([], $result->bindings); } public function testNotEqualWithExactlyTwoValues(): void @@ -3603,8 +3599,8 @@ public function testNotEqualWithExactlyTwoValues(): void ->filter([Query::notEqual('role', ['guest', 'banned'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result['query']); - $this->assertEquals(['guest', 'banned'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result->query); + $this->assertEquals(['guest', 'banned'], $result->bindings); } public function testBetweenWithSameMinAndMax(): void @@ -3614,8 +3610,8 @@ public function testBetweenWithSameMinAndMax(): void ->filter([Query::between('age', 25, 25)]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result['query']); - $this->assertEquals([25, 25], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertEquals([25, 25], $result->bindings); } public function testStartsWithEmptyString(): void @@ -3625,8 +3621,8 @@ public function testStartsWithEmptyString(): void ->filter([Query::startsWith('name', '')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result['query']); - $this->assertEquals(['%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertEquals(['%'], $result->bindings); } public function testEndsWithEmptyString(): void @@ -3636,8 +3632,8 @@ public function testEndsWithEmptyString(): void ->filter([Query::endsWith('name', '')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result['query']); - $this->assertEquals(['%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertEquals(['%'], $result->bindings); } public function testContainsWithSingleEmptyString(): void @@ -3647,8 +3643,8 @@ public function testContainsWithSingleEmptyString(): void ->filter([Query::contains('bio', [''])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result['query']); - $this->assertEquals(['%%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); + $this->assertEquals(['%%'], $result->bindings); } public function testContainsWithManyValues(): void @@ -3658,8 +3654,8 @@ public function testContainsWithManyValues(): void ->filter([Query::contains('bio', ['a', 'b', 'c', 'd', 'e'])]) ->build(); - $this->assertStringContainsString('(`bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ?)', $result['query']); - $this->assertEquals(['%a%', '%b%', '%c%', '%d%', '%e%'], $result['bindings']); + $this->assertStringContainsString('(`bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ?)', $result->query); + $this->assertEquals(['%a%', '%b%', '%c%', '%d%', '%e%'], $result->bindings); } public function testContainsAllWithSingleValue(): void @@ -3669,8 +3665,8 @@ public function testContainsAllWithSingleValue(): void ->filter([Query::containsAll('perms', ['read'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`perms` LIKE ?)', $result['query']); - $this->assertEquals(['%read%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`perms` LIKE ?)', $result->query); + $this->assertEquals(['%read%'], $result->bindings); } public function testNotContainsWithEmptyStringValue(): void @@ -3680,8 +3676,8 @@ public function testNotContainsWithEmptyStringValue(): void ->filter([Query::notContains('bio', [''])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result['query']); - $this->assertEquals(['%%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result->query); + $this->assertEquals(['%%'], $result->bindings); } public function testComparisonWithFloatValues(): void @@ -3691,8 +3687,8 @@ public function testComparisonWithFloatValues(): void ->filter([Query::greaterThan('price', 9.99)]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `price` > ?', $result['query']); - $this->assertEquals([9.99], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `price` > ?', $result->query); + $this->assertEquals([9.99], $result->bindings); } public function testComparisonWithNegativeValues(): void @@ -3702,8 +3698,8 @@ public function testComparisonWithNegativeValues(): void ->filter([Query::lessThan('balance', -100)]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `balance` < ?', $result['query']); - $this->assertEquals([-100], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `balance` < ?', $result->query); + $this->assertEquals([-100], $result->bindings); } public function testComparisonWithZero(): void @@ -3713,8 +3709,8 @@ public function testComparisonWithZero(): void ->filter([Query::greaterThanEqual('score', 0)]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `score` >= ?', $result['query']); - $this->assertEquals([0], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `score` >= ?', $result->query); + $this->assertEquals([0], $result->bindings); } public function testComparisonWithVeryLargeInteger(): void @@ -3724,7 +3720,7 @@ public function testComparisonWithVeryLargeInteger(): void ->filter([Query::lessThan('id', 9999999999999)]) ->build(); - $this->assertEquals([9999999999999], $result['bindings']); + $this->assertEquals([9999999999999], $result->bindings); } public function testComparisonWithStringValues(): void @@ -3734,8 +3730,8 @@ public function testComparisonWithStringValues(): void ->filter([Query::greaterThan('name', 'M')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `name` > ?', $result['query']); - $this->assertEquals(['M'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `name` > ?', $result->query); + $this->assertEquals(['M'], $result->bindings); } public function testBetweenWithStringValues(): void @@ -3745,8 +3741,8 @@ public function testBetweenWithStringValues(): void ->filter([Query::between('created_at', '2024-01-01', '2024-12-31')]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `created_at` BETWEEN ? AND ?', $result['query']); - $this->assertEquals(['2024-01-01', '2024-12-31'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `created_at` BETWEEN ? AND ?', $result->query); + $this->assertEquals(['2024-01-01', '2024-12-31'], $result->bindings); } public function testIsNullCombinedWithIsNotNullOnDifferentColumns(): void @@ -3761,9 +3757,9 @@ public function testIsNullCombinedWithIsNotNullOnDifferentColumns(): void $this->assertEquals( 'SELECT * FROM `t` WHERE `deleted_at` IS NULL AND `verified_at` IS NOT NULL', - $result['query'] + $result->query ); - $this->assertEquals([], $result['bindings']); + $this->assertEquals([], $result->bindings); } public function testMultipleIsNullFilters(): void @@ -3779,7 +3775,7 @@ public function testMultipleIsNullFilters(): void $this->assertEquals( 'SELECT * FROM `t` WHERE `a` IS NULL AND `b` IS NULL AND `c` IS NULL', - $result['query'] + $result->query ); } @@ -3790,7 +3786,7 @@ public function testExistsWithSingleAttribute(): void ->filter([Query::exists(['name'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`name` IS NOT NULL)', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE (`name` IS NOT NULL)', $result->query); } public function testExistsWithManyAttributes(): void @@ -3802,7 +3798,7 @@ public function testExistsWithManyAttributes(): void $this->assertEquals( 'SELECT * FROM `t` WHERE (`a` IS NOT NULL AND `b` IS NOT NULL AND `c` IS NOT NULL AND `d` IS NOT NULL)', - $result['query'] + $result->query ); } @@ -3815,7 +3811,7 @@ public function testNotExistsWithManyAttributes(): void $this->assertEquals( 'SELECT * FROM `t` WHERE (`a` IS NULL AND `b` IS NULL AND `c` IS NULL)', - $result['query'] + $result->query ); } @@ -3830,8 +3826,8 @@ public function testAndWithSingleSubQuery(): void ]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?))', $result['query']); - $this->assertEquals([1], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?))', $result->query); + $this->assertEquals([1], $result->bindings); } public function testOrWithSingleSubQuery(): void @@ -3845,8 +3841,8 @@ public function testOrWithSingleSubQuery(): void ]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?))', $result['query']); - $this->assertEquals([1], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?))', $result->query); + $this->assertEquals([1], $result->bindings); } public function testAndWithManySubQueries(): void @@ -3866,9 +3862,9 @@ public function testAndWithManySubQueries(): void $this->assertEquals( 'SELECT * FROM `t` WHERE (`a` IN (?) AND `b` IN (?) AND `c` IN (?) AND `d` IN (?) AND `e` IN (?))', - $result['query'] + $result->query ); - $this->assertEquals([1, 2, 3, 4, 5], $result['bindings']); + $this->assertEquals([1, 2, 3, 4, 5], $result->bindings); } public function testOrWithManySubQueries(): void @@ -3888,7 +3884,7 @@ public function testOrWithManySubQueries(): void $this->assertEquals( 'SELECT * FROM `t` WHERE (`a` IN (?) OR `b` IN (?) OR `c` IN (?) OR `d` IN (?) OR `e` IN (?))', - $result['query'] + $result->query ); } @@ -3912,9 +3908,9 @@ public function testDeeplyNestedAndOrAnd(): void $this->assertEquals( 'SELECT * FROM `t` WHERE (((`a` IN (?) AND `b` IN (?)) OR `c` IN (?)) AND `d` IN (?))', - $result['query'] + $result->query ); - $this->assertEquals([1, 2, 3, 4], $result['bindings']); + $this->assertEquals([1, 2, 3, 4], $result->bindings); } public function testRawWithManyBindings(): void @@ -3926,8 +3922,8 @@ public function testRawWithManyBindings(): void ->filter([Query::raw($placeholders, $bindings)]) ->build(); - $this->assertEquals("SELECT * FROM `t` WHERE {$placeholders}", $result['query']); - $this->assertEquals($bindings, $result['bindings']); + $this->assertEquals("SELECT * FROM `t` WHERE {$placeholders}", $result->query); + $this->assertEquals($bindings, $result->bindings); } public function testFilterWithDotsInAttributeName(): void @@ -3937,7 +3933,7 @@ public function testFilterWithDotsInAttributeName(): void ->filter([Query::equal('table.column', ['value'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `table`.`column` IN (?)', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE `table`.`column` IN (?)', $result->query); } public function testFilterWithUnderscoresInAttributeName(): void @@ -3947,7 +3943,7 @@ public function testFilterWithUnderscoresInAttributeName(): void ->filter([Query::equal('my_column_name', ['value'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `my_column_name` IN (?)', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE `my_column_name` IN (?)', $result->query); } public function testFilterWithNumericAttributeName(): void @@ -3957,7 +3953,7 @@ public function testFilterWithNumericAttributeName(): void ->filter([Query::equal('123', ['value'])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `123` IN (?)', $result['query']); + $this->assertEquals('SELECT * FROM `t` WHERE `123` IN (?)', $result->query); } // ══════════════════════════════════════════ @@ -3967,66 +3963,66 @@ public function testFilterWithNumericAttributeName(): void public function testCountWithoutAliasNoAsClause(): void { $result = (new Builder())->from('t')->count()->build(); - $this->assertEquals('SELECT COUNT(*) FROM `t`', $result['query']); - $this->assertStringNotContainsString(' AS ', $result['query']); + $this->assertEquals('SELECT COUNT(*) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); } public function testSumWithoutAliasNoAsClause(): void { $result = (new Builder())->from('t')->sum('price')->build(); - $this->assertEquals('SELECT SUM(`price`) FROM `t`', $result['query']); - $this->assertStringNotContainsString(' AS ', $result['query']); + $this->assertEquals('SELECT SUM(`price`) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); } public function testAvgWithoutAliasNoAsClause(): void { $result = (new Builder())->from('t')->avg('score')->build(); - $this->assertEquals('SELECT AVG(`score`) FROM `t`', $result['query']); - $this->assertStringNotContainsString(' AS ', $result['query']); + $this->assertEquals('SELECT AVG(`score`) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); } public function testMinWithoutAliasNoAsClause(): void { $result = (new Builder())->from('t')->min('price')->build(); - $this->assertEquals('SELECT MIN(`price`) FROM `t`', $result['query']); - $this->assertStringNotContainsString(' AS ', $result['query']); + $this->assertEquals('SELECT MIN(`price`) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); } public function testMaxWithoutAliasNoAsClause(): void { $result = (new Builder())->from('t')->max('price')->build(); - $this->assertEquals('SELECT MAX(`price`) FROM `t`', $result['query']); - $this->assertStringNotContainsString(' AS ', $result['query']); + $this->assertEquals('SELECT MAX(`price`) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); } public function testCountWithAlias2(): void { $result = (new Builder())->from('t')->count('*', 'cnt')->build(); - $this->assertStringContainsString('AS `cnt`', $result['query']); + $this->assertStringContainsString('AS `cnt`', $result->query); } public function testSumWithAlias(): void { $result = (new Builder())->from('t')->sum('price', 'total')->build(); - $this->assertStringContainsString('AS `total`', $result['query']); + $this->assertStringContainsString('AS `total`', $result->query); } public function testAvgWithAlias(): void { $result = (new Builder())->from('t')->avg('score', 'avg_s')->build(); - $this->assertStringContainsString('AS `avg_s`', $result['query']); + $this->assertStringContainsString('AS `avg_s`', $result->query); } public function testMinWithAlias(): void { $result = (new Builder())->from('t')->min('price', 'lowest')->build(); - $this->assertStringContainsString('AS `lowest`', $result['query']); + $this->assertStringContainsString('AS `lowest`', $result->query); } public function testMaxWithAlias(): void { $result = (new Builder())->from('t')->max('price', 'highest')->build(); - $this->assertStringContainsString('AS `highest`', $result['query']); + $this->assertStringContainsString('AS `highest`', $result->query); } public function testMultipleSameAggregationType(): void @@ -4039,7 +4035,7 @@ public function testMultipleSameAggregationType(): void $this->assertEquals( 'SELECT COUNT(`id`) AS `count_id`, COUNT(*) AS `count_all` FROM `t`', - $result['query'] + $result->query ); } @@ -4052,9 +4048,9 @@ public function testAggregationStarAndNamedColumnMixed(): void ->select(['category']) ->build(); - $this->assertStringContainsString('COUNT(*) AS `total`', $result['query']); - $this->assertStringContainsString('SUM(`price`) AS `price_sum`', $result['query']); - $this->assertStringContainsString('`category`', $result['query']); + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + $this->assertStringContainsString('SUM(`price`) AS `price_sum`', $result->query); + $this->assertStringContainsString('`category`', $result->query); } public function testAggregationFilterSortLimitCombined(): void @@ -4068,12 +4064,12 @@ public function testAggregationFilterSortLimitCombined(): void ->limit(5) ->build(); - $this->assertStringContainsString('COUNT(*) AS `cnt`', $result['query']); - $this->assertStringContainsString('WHERE `status` IN (?)', $result['query']); - $this->assertStringContainsString('GROUP BY `category`', $result['query']); - $this->assertStringContainsString('ORDER BY `cnt` DESC', $result['query']); - $this->assertStringContainsString('LIMIT ?', $result['query']); - $this->assertEquals(['paid', 5], $result['bindings']); + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); + $this->assertStringContainsString('GROUP BY `category`', $result->query); + $this->assertStringContainsString('ORDER BY `cnt` DESC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertEquals(['paid', 5], $result->bindings); } public function testAggregationJoinGroupByHavingSortLimitFullPipeline(): void @@ -4092,16 +4088,16 @@ public function testAggregationJoinGroupByHavingSortLimitFullPipeline(): void ->offset(10) ->build(); - $this->assertStringContainsString('COUNT(*) AS `cnt`', $result['query']); - $this->assertStringContainsString('SUM(`total`) AS `revenue`', $result['query']); - $this->assertStringContainsString('JOIN `users`', $result['query']); - $this->assertStringContainsString('WHERE `orders`.`total` > ?', $result['query']); - $this->assertStringContainsString('GROUP BY `users`.`name`', $result['query']); - $this->assertStringContainsString('HAVING `cnt` > ?', $result['query']); - $this->assertStringContainsString('ORDER BY `revenue` DESC', $result['query']); - $this->assertStringContainsString('LIMIT ?', $result['query']); - $this->assertStringContainsString('OFFSET ?', $result['query']); - $this->assertEquals([0, 2, 20, 10], $result['bindings']); + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('SUM(`total`) AS `revenue`', $result->query); + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('WHERE `orders`.`total` > ?', $result->query); + $this->assertStringContainsString('GROUP BY `users`.`name`', $result->query); + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); + $this->assertStringContainsString('ORDER BY `revenue` DESC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals([0, 2, 20, 10], $result->bindings); } public function testAggregationWithAttributeResolver(): void @@ -4114,7 +4110,7 @@ public function testAggregationWithAttributeResolver(): void ->sum('$amount', 'total') ->build(); - $this->assertEquals('SELECT SUM(`_amount`) AS `total` FROM `t`', $result['query']); + $this->assertEquals('SELECT SUM(`_amount`) AS `total` FROM `t`', $result->query); } public function testAggregationWithWrapChar(): void @@ -4125,7 +4121,7 @@ public function testAggregationWithWrapChar(): void ->avg('score', 'average') ->build(); - $this->assertEquals('SELECT AVG("score") AS "average" FROM "t"', $result['query']); + $this->assertEquals('SELECT AVG("score") AS "average" FROM "t"', $result->query); } public function testMinMaxWithStringColumns(): void @@ -4138,7 +4134,7 @@ public function testMinMaxWithStringColumns(): void $this->assertEquals( 'SELECT MIN(`name`) AS `first_name`, MAX(`name`) AS `last_name` FROM `t`', - $result['query'] + $result->query ); } @@ -4155,7 +4151,7 @@ public function testSelfJoin(): void $this->assertEquals( 'SELECT * FROM `employees` JOIN `employees` ON `employees`.`manager_id` = `employees`.`id`', - $result['query'] + $result->query ); } @@ -4169,8 +4165,8 @@ public function testJoinWithVeryLongTableAndColumnNames(): void ->join($longTable, $longLeft, $longRight) ->build(); - $this->assertStringContainsString("JOIN `{$longTable}`", $result['query']); - $this->assertStringContainsString("ON `{$longLeft}` = `{$longRight}`", $result['query']); + $this->assertStringContainsString("JOIN `{$longTable}`", $result->query); + $this->assertStringContainsString("ON `{$longLeft}` = `{$longRight}`", $result->query); } public function testJoinFilterSortLimitOffsetCombined(): void @@ -4187,12 +4183,12 @@ public function testJoinFilterSortLimitOffsetCombined(): void ->offset(50) ->build(); - $this->assertStringContainsString('JOIN `orders`', $result['query']); - $this->assertStringContainsString('WHERE `orders`.`status` IN (?) AND `orders`.`total` > ?', $result['query']); - $this->assertStringContainsString('ORDER BY `orders`.`total` DESC', $result['query']); - $this->assertStringContainsString('LIMIT ?', $result['query']); - $this->assertStringContainsString('OFFSET ?', $result['query']); - $this->assertEquals(['paid', 100, 25, 50], $result['bindings']); + $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('WHERE `orders`.`status` IN (?) AND `orders`.`total` > ?', $result->query); + $this->assertStringContainsString('ORDER BY `orders`.`total` DESC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals(['paid', 100, 25, 50], $result->bindings); } public function testJoinAggregationGroupByHavingCombined(): void @@ -4205,11 +4201,11 @@ public function testJoinAggregationGroupByHavingCombined(): void ->having([Query::greaterThan('cnt', 3)]) ->build(); - $this->assertStringContainsString('COUNT(*) AS `cnt`', $result['query']); - $this->assertStringContainsString('JOIN `users`', $result['query']); - $this->assertStringContainsString('GROUP BY `users`.`name`', $result['query']); - $this->assertStringContainsString('HAVING `cnt` > ?', $result['query']); - $this->assertEquals([3], $result['bindings']); + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('GROUP BY `users`.`name`', $result->query); + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); + $this->assertEquals([3], $result->bindings); } public function testJoinWithDistinct(): void @@ -4221,8 +4217,8 @@ public function testJoinWithDistinct(): void ->join('orders', 'users.id', 'orders.user_id') ->build(); - $this->assertStringContainsString('SELECT DISTINCT `users`.`name`', $result['query']); - $this->assertStringContainsString('JOIN `orders`', $result['query']); + $this->assertStringContainsString('SELECT DISTINCT `users`.`name`', $result->query); + $this->assertStringContainsString('JOIN `orders`', $result->query); } public function testJoinWithUnion(): void @@ -4237,9 +4233,9 @@ public function testJoinWithUnion(): void ->union($sub) ->build(); - $this->assertStringContainsString('JOIN `orders`', $result['query']); - $this->assertStringContainsString('UNION', $result['query']); - $this->assertStringContainsString('JOIN `archived_orders`', $result['query']); + $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('UNION', $result->query); + $this->assertStringContainsString('JOIN `archived_orders`', $result->query); } public function testFourJoins(): void @@ -4252,10 +4248,10 @@ public function testFourJoins(): void ->crossJoin('promotions') ->build(); - $this->assertStringContainsString('JOIN `users`', $result['query']); - $this->assertStringContainsString('LEFT JOIN `products`', $result['query']); - $this->assertStringContainsString('RIGHT JOIN `categories`', $result['query']); - $this->assertStringContainsString('CROSS JOIN `promotions`', $result['query']); + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('LEFT JOIN `products`', $result->query); + $this->assertStringContainsString('RIGHT JOIN `categories`', $result->query); + $this->assertStringContainsString('CROSS JOIN `promotions`', $result->query); } public function testJoinWithAttributeResolverOnJoinColumns(): void @@ -4271,7 +4267,7 @@ public function testJoinWithAttributeResolverOnJoinColumns(): void $this->assertEquals( 'SELECT * FROM `t` JOIN `other` ON `_uid` = `_ref_id`', - $result['query'] + $result->query ); } @@ -4283,8 +4279,8 @@ public function testCrossJoinCombinedWithFilter(): void ->filter([Query::equal('sizes.active', [true])]) ->build(); - $this->assertStringContainsString('CROSS JOIN `colors`', $result['query']); - $this->assertStringContainsString('WHERE `sizes`.`active` IN (?)', $result['query']); + $this->assertStringContainsString('CROSS JOIN `colors`', $result->query); + $this->assertStringContainsString('WHERE `sizes`.`active` IN (?)', $result->query); } public function testCrossJoinFollowedByRegularJoin(): void @@ -4297,7 +4293,7 @@ public function testCrossJoinFollowedByRegularJoin(): void $this->assertEquals( 'SELECT * FROM `a` CROSS JOIN `b` JOIN `c` ON `a`.`id` = `c`.`a_id`', - $result['query'] + $result->query ); } @@ -4313,10 +4309,10 @@ public function testMultipleJoinsWithFiltersOnEach(): void ]) ->build(); - $this->assertStringContainsString('JOIN `orders`', $result['query']); - $this->assertStringContainsString('LEFT JOIN `profiles`', $result['query']); - $this->assertStringContainsString('`orders`.`total` > ?', $result['query']); - $this->assertStringContainsString('`profiles`.`avatar` IS NOT NULL', $result['query']); + $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('LEFT JOIN `profiles`', $result->query); + $this->assertStringContainsString('`orders`.`total` > ?', $result->query); + $this->assertStringContainsString('`profiles`.`avatar` IS NOT NULL', $result->query); } public function testJoinWithCustomOperatorLessThan(): void @@ -4328,7 +4324,7 @@ public function testJoinWithCustomOperatorLessThan(): void $this->assertEquals( 'SELECT * FROM `a` JOIN `b` ON `a`.`start` < `b`.`end`', - $result['query'] + $result->query ); } @@ -4343,7 +4339,7 @@ public function testFiveJoins(): void ->join('t6', 't5.id', 't6.t5_id') ->build(); - $query = $result['query']; + $query = $result->query; $this->assertEquals(5, substr_count($query, 'JOIN')); } @@ -4366,7 +4362,7 @@ public function testUnionWithThreeSubQueries(): void $this->assertEquals( '(SELECT * FROM `main`) UNION (SELECT * FROM `a`) UNION (SELECT * FROM `b`) UNION (SELECT * FROM `c`)', - $result['query'] + $result->query ); } @@ -4385,7 +4381,7 @@ public function testUnionAllWithThreeSubQueries(): void $this->assertEquals( '(SELECT * FROM `main`) UNION ALL (SELECT * FROM `a`) UNION ALL (SELECT * FROM `b`) UNION ALL (SELECT * FROM `c`)', - $result['query'] + $result->query ); } @@ -4404,7 +4400,7 @@ public function testMixedUnionAndUnionAllWithThreeSubQueries(): void $this->assertEquals( '(SELECT * FROM `main`) UNION (SELECT * FROM `a`) UNION ALL (SELECT * FROM `b`) UNION (SELECT * FROM `c`)', - $result['query'] + $result->query ); } @@ -4421,7 +4417,7 @@ public function testUnionWhereSubQueryHasJoins(): void $this->assertStringContainsString( 'UNION (SELECT * FROM `archived_users` JOIN `archived_orders`', - $result['query'] + $result->query ); } @@ -4439,7 +4435,7 @@ public function testUnionWhereSubQueryHasAggregation(): void ->union($sub) ->build(); - $this->assertStringContainsString('UNION (SELECT COUNT(*) AS `cnt` FROM `orders_2023` GROUP BY `status`)', $result['query']); + $this->assertStringContainsString('UNION (SELECT COUNT(*) AS `cnt` FROM `orders_2023` GROUP BY `status`)', $result->query); } public function testUnionWhereSubQueryHasSortAndLimit(): void @@ -4454,7 +4450,7 @@ public function testUnionWhereSubQueryHasSortAndLimit(): void ->union($sub) ->build(); - $this->assertStringContainsString('UNION (SELECT * FROM `archive` ORDER BY `created_at` DESC LIMIT ?)', $result['query']); + $this->assertStringContainsString('UNION (SELECT * FROM `archive` ORDER BY `created_at` DESC LIMIT ?)', $result->query); } public function testUnionWithConditionProviders(): void @@ -4479,9 +4475,9 @@ public function filter(string $table): Condition ->union($sub) ->build(); - $this->assertStringContainsString('WHERE org = ?', $result['query']); - $this->assertStringContainsString('UNION (SELECT * FROM `other` WHERE org = ?)', $result['query']); - $this->assertEquals(['org1', 'org2'], $result['bindings']); + $this->assertStringContainsString('WHERE org = ?', $result->query); + $this->assertStringContainsString('UNION (SELECT * FROM `other` WHERE org = ?)', $result->query); + $this->assertEquals(['org1', 'org2'], $result->bindings); } public function testUnionBindingOrderWithComplexSubQueries(): void @@ -4498,7 +4494,7 @@ public function testUnionBindingOrderWithComplexSubQueries(): void ->union($sub) ->build(); - $this->assertEquals(['active', 10, 2023, 5], $result['bindings']); + $this->assertEquals(['active', 10, 2023, 5], $result->bindings); } public function testUnionWithDistinct(): void @@ -4515,8 +4511,8 @@ public function testUnionWithDistinct(): void ->union($sub) ->build(); - $this->assertStringContainsString('SELECT DISTINCT `name` FROM `current`', $result['query']); - $this->assertStringContainsString('UNION (SELECT DISTINCT `name` FROM `archive`)', $result['query']); + $this->assertStringContainsString('SELECT DISTINCT `name` FROM `current`', $result->query); + $this->assertStringContainsString('UNION (SELECT DISTINCT `name` FROM `archive`)', $result->query); } public function testUnionWithWrapChar(): void @@ -4533,7 +4529,7 @@ public function testUnionWithWrapChar(): void $this->assertEquals( '(SELECT * FROM "current") UNION (SELECT * FROM "archive")', - $result['query'] + $result->query ); } @@ -4548,7 +4544,7 @@ public function testUnionAfterReset(): void $this->assertEquals( '(SELECT * FROM `fresh`) UNION (SELECT * FROM `other`)', - $result['query'] + $result->query ); } @@ -4568,7 +4564,7 @@ public function testUnionChainedWithComplexBindings(): void ->unionAll($q2) ->build(); - $this->assertEquals(['active', 1, 2, 10, 20], $result['bindings']); + $this->assertEquals(['active', 1, 2, 10, 20], $result->bindings); } public function testUnionWithFourSubQueries(): void @@ -4586,7 +4582,7 @@ public function testUnionWithFourSubQueries(): void ->union($q4) ->build(); - $this->assertEquals(4, substr_count($result['query'], 'UNION')); + $this->assertEquals(4, substr_count($result->query, 'UNION')); } public function testUnionAllWithFilteredSubQueries(): void @@ -4603,8 +4599,8 @@ public function testUnionAllWithFilteredSubQueries(): void ->unionAll($q3) ->build(); - $this->assertEquals(['paid', 'paid', 'paid', 'paid'], $result['bindings']); - $this->assertEquals(3, substr_count($result['query'], 'UNION ALL')); + $this->assertEquals(['paid', 'paid', 'paid', 'paid'], $result->bindings); + $this->assertEquals(3, substr_count($result->query, 'UNION ALL')); } // ══════════════════════════════════════════ @@ -4803,10 +4799,10 @@ public function testWhenWithComplexCallbackAddingMultipleFeatures(): void }) ->build(); - $this->assertStringContainsString('WHERE `status` IN (?)', $result['query']); - $this->assertStringContainsString('ORDER BY `name` ASC', $result['query']); - $this->assertStringContainsString('LIMIT ?', $result['query']); - $this->assertEquals(['active', 10], $result['bindings']); + $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); + $this->assertStringContainsString('ORDER BY `name` ASC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertEquals(['active', 10], $result->bindings); } public function testWhenChainedFiveTimes(): void @@ -4822,9 +4818,9 @@ public function testWhenChainedFiveTimes(): void $this->assertEquals( 'SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?) AND `d` IN (?) AND `e` IN (?)', - $result['query'] + $result->query ); - $this->assertEquals([1, 2, 4, 5], $result['bindings']); + $this->assertEquals([1, 2, 4, 5], $result->bindings); } public function testWhenInsideWhenThreeLevelsDeep(): void @@ -4838,8 +4834,8 @@ public function testWhenInsideWhenThreeLevelsDeep(): void }) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `deep` IN (?)', $result['query']); - $this->assertEquals([1], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `deep` IN (?)', $result->query); + $this->assertEquals([1], $result->bindings); } public function testWhenThatAddsJoins(): void @@ -4849,7 +4845,7 @@ public function testWhenThatAddsJoins(): void ->when(true, fn (Builder $b) => $b->join('orders', 'users.id', 'orders.uid')) ->build(); - $this->assertStringContainsString('JOIN `orders`', $result['query']); + $this->assertStringContainsString('JOIN `orders`', $result->query); } public function testWhenThatAddsAggregations(): void @@ -4859,8 +4855,8 @@ public function testWhenThatAddsAggregations(): void ->when(true, fn (Builder $b) => $b->count('*', 'total')->groupBy(['status'])) ->build(); - $this->assertStringContainsString('COUNT(*) AS `total`', $result['query']); - $this->assertStringContainsString('GROUP BY `status`', $result['query']); + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + $this->assertStringContainsString('GROUP BY `status`', $result->query); } public function testWhenThatAddsUnions(): void @@ -4872,7 +4868,7 @@ public function testWhenThatAddsUnions(): void ->when(true, fn (Builder $b) => $b->union($sub)) ->build(); - $this->assertStringContainsString('UNION', $result['query']); + $this->assertStringContainsString('UNION', $result->query); } public function testWhenFalseDoesNotAffectFilters(): void @@ -4882,8 +4878,8 @@ public function testWhenFalseDoesNotAffectFilters(): void ->when(false, fn (Builder $b) => $b->filter([Query::equal('status', ['banned'])])) ->build(); - $this->assertEquals('SELECT * FROM `t`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); } public function testWhenFalseDoesNotAffectJoins(): void @@ -4893,7 +4889,7 @@ public function testWhenFalseDoesNotAffectJoins(): void ->when(false, fn (Builder $b) => $b->join('other', 'a', 'b')) ->build(); - $this->assertStringNotContainsString('JOIN', $result['query']); + $this->assertStringNotContainsString('JOIN', $result->query); } public function testWhenFalseDoesNotAffectAggregations(): void @@ -4903,7 +4899,7 @@ public function testWhenFalseDoesNotAffectAggregations(): void ->when(false, fn (Builder $b) => $b->count('*', 'total')) ->build(); - $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals('SELECT * FROM `t`', $result->query); } public function testWhenFalseDoesNotAffectSort(): void @@ -4913,7 +4909,7 @@ public function testWhenFalseDoesNotAffectSort(): void ->when(false, fn (Builder $b) => $b->sortAsc('name')) ->build(); - $this->assertStringNotContainsString('ORDER BY', $result['query']); + $this->assertStringNotContainsString('ORDER BY', $result->query); } // ══════════════════════════════════════════ @@ -4946,9 +4942,9 @@ public function filter(string $table): Condition $this->assertEquals( 'SELECT * FROM `t` WHERE p1 = ? AND p2 = ? AND p3 = ?', - $result['query'] + $result->query ); - $this->assertEquals(['v1', 'v2', 'v3'], $result['bindings']); + $this->assertEquals(['v1', 'v2', 'v3'], $result->bindings); } public function testProviderReturningEmptyConditionString(): void @@ -4964,7 +4960,7 @@ public function filter(string $table): Condition ->build(); // Empty string still appears as a WHERE clause element - $this->assertStringContainsString('WHERE', $result['query']); + $this->assertStringContainsString('WHERE', $result->query); } public function testProviderWithManyBindings(): void @@ -4981,9 +4977,9 @@ public function filter(string $table): Condition $this->assertEquals( 'SELECT * FROM `t` WHERE a IN (?, ?, ?, ?, ?)', - $result['query'] + $result->query ); - $this->assertEquals([1, 2, 3, 4, 5], $result['bindings']); + $this->assertEquals([1, 2, 3, 4, 5], $result->bindings); } public function testProviderCombinedWithCursorFilterHaving(): void @@ -5003,10 +4999,10 @@ public function filter(string $table): Condition ->having([Query::greaterThan('cnt', 5)]) ->build(); - $this->assertStringContainsString('WHERE', $result['query']); - $this->assertStringContainsString('HAVING', $result['query']); + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('HAVING', $result->query); // filter, provider, cursor, having - $this->assertEquals(['active', 'org1', 'cur1', 5], $result['bindings']); + $this->assertEquals(['active', 'org1', 'cur1', 5], $result->bindings); } public function testProviderCombinedWithJoins(): void @@ -5022,9 +5018,9 @@ public function filter(string $table): Condition }) ->build(); - $this->assertStringContainsString('JOIN `orders`', $result['query']); - $this->assertStringContainsString('WHERE tenant = ?', $result['query']); - $this->assertEquals(['t1'], $result['bindings']); + $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('WHERE tenant = ?', $result->query); + $this->assertEquals(['t1'], $result->bindings); } public function testProviderCombinedWithUnions(): void @@ -5042,9 +5038,9 @@ public function filter(string $table): Condition ->union($sub) ->build(); - $this->assertStringContainsString('WHERE org = ?', $result['query']); - $this->assertStringContainsString('UNION', $result['query']); - $this->assertEquals(['org1'], $result['bindings']); + $this->assertStringContainsString('WHERE org = ?', $result->query); + $this->assertStringContainsString('UNION', $result->query); + $this->assertEquals(['org1'], $result->bindings); } public function testProviderCombinedWithAggregations(): void @@ -5061,8 +5057,8 @@ public function filter(string $table): Condition ->groupBy(['status']) ->build(); - $this->assertStringContainsString('COUNT(*) AS `total`', $result['query']); - $this->assertStringContainsString('WHERE org = ?', $result['query']); + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + $this->assertStringContainsString('WHERE org = ?', $result->query); } public function testProviderReferencesTableName(): void @@ -5077,8 +5073,8 @@ public function filter(string $table): Condition }) ->build(); - $this->assertStringContainsString('users_perms', $result['query']); - $this->assertEquals(['read'], $result['bindings']); + $this->assertStringContainsString('users_perms', $result->query); + $this->assertEquals(['read'], $result->bindings); } public function testProviderWithWrapCharProviderSqlIsLiteral(): void @@ -5095,8 +5091,8 @@ public function filter(string $table): Condition ->build(); // Provider SQL is NOT wrapped - only the FROM clause is - $this->assertStringContainsString('FROM "t"', $result['query']); - $this->assertStringContainsString('raw_col = ?', $result['query']); + $this->assertStringContainsString('FROM "t"', $result->query); + $this->assertStringContainsString('raw_col = ?', $result->query); } public function testProviderBindingOrderWithComplexQuery(): void @@ -5125,7 +5121,7 @@ public function filter(string $table): Condition ->build(); // filter, provider1, provider2, cursor, limit, offset - $this->assertEquals(['va', 10, 'pv1', 'pv2', 'cur', 5, 10], $result['bindings']); + $this->assertEquals(['va', 10, 'pv1', 'pv2', 'cur', 5, 10], $result->bindings); } public function testProviderPreservedAcrossReset(): void @@ -5143,8 +5139,8 @@ public function filter(string $table): Condition $builder->reset(); $result = $builder->from('t2')->build(); - $this->assertStringContainsString('WHERE org = ?', $result['query']); - $this->assertEquals(['org1'], $result['bindings']); + $this->assertStringContainsString('WHERE org = ?', $result->query); + $this->assertEquals(['org1'], $result->bindings); } public function testFourConditionProviders(): void @@ -5179,9 +5175,9 @@ public function filter(string $table): Condition $this->assertEquals( 'SELECT * FROM `t` WHERE a = ? AND b = ? AND c = ? AND d = ?', - $result['query'] + $result->query ); - $this->assertEquals([1, 2, 3, 4], $result['bindings']); + $this->assertEquals([1, 2, 3, 4], $result->bindings); } public function testProviderWithNoBindings(): void @@ -5196,8 +5192,8 @@ public function filter(string $table): Condition }) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result->query); + $this->assertEquals([], $result->bindings); } // ══════════════════════════════════════════ @@ -5220,7 +5216,7 @@ public function resolve(string $attribute): string $builder->reset(); $result = $builder->from('t2')->filter([Query::equal('y', [2])])->build(); - $this->assertStringContainsString('`_y`', $result['query']); + $this->assertStringContainsString('`_y`', $result->query); } public function testResetPreservesConditionProviders(): void @@ -5238,8 +5234,8 @@ public function filter(string $table): Condition $builder->reset(); $result = $builder->from('t2')->build(); - $this->assertStringContainsString('org = ?', $result['query']); - $this->assertEquals(['org1'], $result['bindings']); + $this->assertStringContainsString('org = ?', $result->query); + $this->assertEquals(['org1'], $result->bindings); } public function testResetPreservesWrapChar(): void @@ -5252,7 +5248,7 @@ public function testResetPreservesWrapChar(): void $builder->reset(); $result = $builder->from('t2')->select(['name'])->build(); - $this->assertEquals('SELECT "name" FROM "t2"', $result['query']); + $this->assertEquals('SELECT "name" FROM "t2"', $result->query); } public function testResetClearsPendingQueries(): void @@ -5267,8 +5263,8 @@ public function testResetClearsPendingQueries(): void $builder->reset(); $result = $builder->from('t2')->build(); - $this->assertEquals('SELECT * FROM `t2`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t2`', $result->query); + $this->assertEquals([], $result->bindings); } public function testResetClearsBindings(): void @@ -5282,7 +5278,7 @@ public function testResetClearsBindings(): void $builder->reset(); $result = $builder->from('t2')->build(); - $this->assertEquals([], $result['bindings']); + $this->assertEquals([], $result->bindings); } public function testResetClearsTable(): void @@ -5292,8 +5288,8 @@ public function testResetClearsTable(): void $builder->reset(); $result = $builder->from('new_table')->build(); - $this->assertStringContainsString('`new_table`', $result['query']); - $this->assertStringNotContainsString('`old_table`', $result['query']); + $this->assertStringContainsString('`new_table`', $result->query); + $this->assertStringNotContainsString('`old_table`', $result->query); } public function testResetClearsUnionsAfterBuild(): void @@ -5304,7 +5300,7 @@ public function testResetClearsUnionsAfterBuild(): void $builder->reset(); $result = $builder->from('fresh')->build(); - $this->assertStringNotContainsString('UNION', $result['query']); + $this->assertStringNotContainsString('UNION', $result->query); } public function testBuildAfterResetProducesMinimalQuery(): void @@ -5321,7 +5317,7 @@ public function testBuildAfterResetProducesMinimalQuery(): void $builder->reset(); $result = $builder->from('t')->build(); - $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals('SELECT * FROM `t`', $result->query); } public function testMultipleResetCalls(): void @@ -5333,7 +5329,7 @@ public function testMultipleResetCalls(): void $builder->reset(); $result = $builder->from('t2')->build(); - $this->assertEquals('SELECT * FROM `t2`', $result['query']); + $this->assertEquals('SELECT * FROM `t2`', $result->query); } public function testResetBetweenDifferentQueryTypes(): void @@ -5343,15 +5339,15 @@ public function testResetBetweenDifferentQueryTypes(): void // First: aggregation query $builder->from('orders')->count('*', 'total')->groupBy(['status']); $result1 = $builder->build(); - $this->assertStringContainsString('COUNT(*)', $result1['query']); + $this->assertStringContainsString('COUNT(*)', $result1->query); $builder->reset(); // Second: simple select query $builder->from('users')->select(['name'])->filter([Query::equal('active', [true])]); $result2 = $builder->build(); - $this->assertStringNotContainsString('COUNT', $result2['query']); - $this->assertStringContainsString('`name`', $result2['query']); + $this->assertStringNotContainsString('COUNT', $result2->query); + $this->assertStringContainsString('`name`', $result2->query); } public function testResetAfterUnion(): void @@ -5362,8 +5358,8 @@ public function testResetAfterUnion(): void $builder->reset(); $result = $builder->from('new')->build(); - $this->assertEquals('SELECT * FROM `new`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `new`', $result->query); + $this->assertEquals([], $result->bindings); } public function testResetAfterComplexQueryWithAllFeatures(): void @@ -5388,8 +5384,8 @@ public function testResetAfterComplexQueryWithAllFeatures(): void $builder->reset(); $result = $builder->from('simple')->build(); - $this->assertEquals('SELECT * FROM `simple`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `simple`', $result->query); + $this->assertEquals([], $result->bindings); } // ══════════════════════════════════════════ @@ -5407,8 +5403,8 @@ public function testBuildTwiceModifyInBetween(): void $builder->filter([Query::equal('b', [2])]); $result2 = $builder->build(); - $this->assertStringNotContainsString('`b`', $result1['query']); - $this->assertStringContainsString('`b`', $result2['query']); + $this->assertStringNotContainsString('`b`', $result1->query); + $this->assertStringContainsString('`b`', $result2->query); } public function testBuildDoesNotMutatePendingQueries(): void @@ -5421,8 +5417,8 @@ public function testBuildDoesNotMutatePendingQueries(): void $result1 = $builder->build(); $result2 = $builder->build(); - $this->assertEquals($result1['query'], $result2['query']); - $this->assertEquals($result1['bindings'], $result2['bindings']); + $this->assertEquals($result1->query, $result2->query); + $this->assertEquals($result1->bindings, $result2->bindings); } public function testBuildResetsBindingsEachTime(): void @@ -5457,8 +5453,8 @@ public function filter(string $table): Condition $result2 = $builder->build(); $result3 = $builder->build(); - $this->assertEquals($result1['bindings'], $result2['bindings']); - $this->assertEquals($result2['bindings'], $result3['bindings']); + $this->assertEquals($result1->bindings, $result2->bindings); + $this->assertEquals($result2->bindings, $result3->bindings); } public function testBuildAfterAddingMoreQueries(): void @@ -5466,15 +5462,15 @@ public function testBuildAfterAddingMoreQueries(): void $builder = (new Builder())->from('t'); $result1 = $builder->build(); - $this->assertEquals('SELECT * FROM `t`', $result1['query']); + $this->assertEquals('SELECT * FROM `t`', $result1->query); $builder->filter([Query::equal('a', [1])]); $result2 = $builder->build(); - $this->assertStringContainsString('WHERE', $result2['query']); + $this->assertStringContainsString('WHERE', $result2->query); $builder->sortAsc('a'); $result3 = $builder->build(); - $this->assertStringContainsString('ORDER BY', $result3['query']); + $this->assertStringContainsString('ORDER BY', $result3->query); } public function testBuildWithUnionProducesConsistentResults(): void @@ -5485,8 +5481,8 @@ public function testBuildWithUnionProducesConsistentResults(): void $result1 = $builder->build(); $result2 = $builder->build(); - $this->assertEquals($result1['query'], $result2['query']); - $this->assertEquals($result1['bindings'], $result2['bindings']); + $this->assertEquals($result1->query, $result2->query); + $this->assertEquals($result1->bindings, $result2->bindings); } public function testBuildThreeTimesWithIncreasingComplexity(): void @@ -5494,16 +5490,16 @@ public function testBuildThreeTimesWithIncreasingComplexity(): void $builder = (new Builder())->from('t'); $r1 = $builder->build(); - $this->assertEquals('SELECT * FROM `t`', $r1['query']); + $this->assertEquals('SELECT * FROM `t`', $r1->query); $builder->filter([Query::equal('a', [1])]); $r2 = $builder->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $r2['query']); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $r2->query); $builder->limit(10)->offset(5); $r3 = $builder->build(); - $this->assertStringContainsString('LIMIT ?', $r3['query']); - $this->assertStringContainsString('OFFSET ?', $r3['query']); + $this->assertStringContainsString('LIMIT ?', $r3->query); + $this->assertStringContainsString('OFFSET ?', $r3->query); } public function testBuildBindingsNotAccumulated(): void @@ -5531,8 +5527,8 @@ public function testMultipleBuildWithHavingBindings(): void $r1 = $builder->build(); $r2 = $builder->build(); - $this->assertEquals([5], $r1['bindings']); - $this->assertEquals([5], $r2['bindings']); + $this->assertEquals([5], $r1->bindings); + $this->assertEquals([5], $r2->bindings); } // ══════════════════════════════════════════ @@ -5550,7 +5546,7 @@ public function testBindingOrderMultipleFilters(): void ]) ->build(); - $this->assertEquals(['v1', 10, 1, 100], $result['bindings']); + $this->assertEquals(['v1', 10, 1, 100], $result->bindings); } public function testBindingOrderThreeProviders(): void @@ -5577,7 +5573,7 @@ public function filter(string $table): Condition }) ->build(); - $this->assertEquals(['pv1', 'pv2', 'pv3'], $result['bindings']); + $this->assertEquals(['pv1', 'pv2', 'pv3'], $result->bindings); } public function testBindingOrderMultipleUnions(): void @@ -5594,7 +5590,7 @@ public function testBindingOrderMultipleUnions(): void ->build(); // main filter, main limit, union1 bindings, union2 bindings - $this->assertEquals([3, 5, 1, 2], $result['bindings']); + $this->assertEquals([3, 5, 1, 2], $result->bindings); } public function testBindingOrderLogicalAndWithMultipleSubFilters(): void @@ -5610,7 +5606,7 @@ public function testBindingOrderLogicalAndWithMultipleSubFilters(): void ]) ->build(); - $this->assertEquals([1, 2, 3], $result['bindings']); + $this->assertEquals([1, 2, 3], $result->bindings); } public function testBindingOrderLogicalOrWithMultipleSubFilters(): void @@ -5626,7 +5622,7 @@ public function testBindingOrderLogicalOrWithMultipleSubFilters(): void ]) ->build(); - $this->assertEquals([1, 2, 3], $result['bindings']); + $this->assertEquals([1, 2, 3], $result->bindings); } public function testBindingOrderNestedAndOr(): void @@ -5644,7 +5640,7 @@ public function testBindingOrderNestedAndOr(): void ]) ->build(); - $this->assertEquals([1, 2, 3], $result['bindings']); + $this->assertEquals([1, 2, 3], $result->bindings); } public function testBindingOrderRawMixedWithRegularFilters(): void @@ -5658,7 +5654,7 @@ public function testBindingOrderRawMixedWithRegularFilters(): void ]) ->build(); - $this->assertEquals(['v1', 10, 20], $result['bindings']); + $this->assertEquals(['v1', 10, 20], $result->bindings); } public function testBindingOrderAggregationHavingComplexConditions(): void @@ -5677,7 +5673,7 @@ public function testBindingOrderAggregationHavingComplexConditions(): void ->build(); // filter, having1, having2, limit - $this->assertEquals(['active', 5, 10000, 10], $result['bindings']); + $this->assertEquals(['active', 5, 10000, 10], $result->bindings); } public function testBindingOrderFullPipelineWithEverything(): void @@ -5706,7 +5702,7 @@ public function filter(string $table): Condition ->build(); // filter(paid, 0), provider(t1), cursor(cursor_val), having(1), limit(25), offset(50), union(true) - $this->assertEquals(['paid', 0, 't1', 'cursor_val', 1, 25, 50, true], $result['bindings']); + $this->assertEquals(['paid', 0, 't1', 'cursor_val', 1, 25, 50, true], $result->bindings); } public function testBindingOrderContainsMultipleValues(): void @@ -5720,7 +5716,7 @@ public function testBindingOrderContainsMultipleValues(): void ->build(); // contains produces three LIKE bindings, then equal - $this->assertEquals(['%php%', '%js%', '%go%', 'active'], $result['bindings']); + $this->assertEquals(['%php%', '%js%', '%go%', 'active'], $result->bindings); } public function testBindingOrderBetweenAndComparisons(): void @@ -5734,7 +5730,7 @@ public function testBindingOrderBetweenAndComparisons(): void ]) ->build(); - $this->assertEquals([18, 65, 50, 100], $result['bindings']); + $this->assertEquals([18, 65, 50, 100], $result->bindings); } public function testBindingOrderStartsWithEndsWith(): void @@ -5747,7 +5743,7 @@ public function testBindingOrderStartsWithEndsWith(): void ]) ->build(); - $this->assertEquals(['A%', '%.com'], $result['bindings']); + $this->assertEquals(['A%', '%.com'], $result->bindings); } public function testBindingOrderSearchAndRegex(): void @@ -5760,7 +5756,7 @@ public function testBindingOrderSearchAndRegex(): void ]) ->build(); - $this->assertEquals(['hello', '^test'], $result['bindings']); + $this->assertEquals(['hello', '^test'], $result->bindings); } public function testBindingOrderWithCursorBeforeFilterAndLimit(): void @@ -5780,7 +5776,7 @@ public function filter(string $table): Condition ->build(); // filter, provider, cursor, limit, offset - $this->assertEquals(['x', 'org1', 'my_cursor', 10, 0], $result['bindings']); + $this->assertEquals(['x', 'org1', 'my_cursor', 10, 0], $result->bindings); } // ══════════════════════════════════════════ @@ -5790,8 +5786,8 @@ public function filter(string $table): Condition public function testBuildWithNoFromNoFilters(): void { $result = (new Builder())->from('')->build(); - $this->assertEquals('SELECT * FROM ``', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM ``', $result->query); + $this->assertEquals([], $result->bindings); } public function testBuildWithOnlyLimit(): void @@ -5801,8 +5797,8 @@ public function testBuildWithOnlyLimit(): void ->limit(10) ->build(); - $this->assertStringContainsString('LIMIT ?', $result['query']); - $this->assertEquals([10], $result['bindings']); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertEquals([10], $result->bindings); } public function testBuildWithOnlyOffset(): void @@ -5813,8 +5809,8 @@ public function testBuildWithOnlyOffset(): void ->offset(50) ->build(); - $this->assertStringNotContainsString('OFFSET ?', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertStringNotContainsString('OFFSET ?', $result->query); + $this->assertEquals([], $result->bindings); } public function testBuildWithOnlySort(): void @@ -5824,7 +5820,7 @@ public function testBuildWithOnlySort(): void ->sortAsc('name') ->build(); - $this->assertStringContainsString('ORDER BY `name` ASC', $result['query']); + $this->assertStringContainsString('ORDER BY `name` ASC', $result->query); } public function testBuildWithOnlySelect(): void @@ -5834,7 +5830,7 @@ public function testBuildWithOnlySelect(): void ->select(['a', 'b']) ->build(); - $this->assertStringContainsString('SELECT `a`, `b`', $result['query']); + $this->assertStringContainsString('SELECT `a`, `b`', $result->query); } public function testBuildWithOnlyAggregationNoFrom(): void @@ -5844,7 +5840,7 @@ public function testBuildWithOnlyAggregationNoFrom(): void ->count('*', 'total') ->build(); - $this->assertStringContainsString('COUNT(*) AS `total`', $result['query']); + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); } public function testBuildWithEmptyFilterArray(): void @@ -5854,7 +5850,7 @@ public function testBuildWithEmptyFilterArray(): void ->filter([]) ->build(); - $this->assertEquals('SELECT * FROM `t`', $result['query']); + $this->assertEquals('SELECT * FROM `t`', $result->query); } public function testBuildWithEmptySelectArray(): void @@ -5864,7 +5860,7 @@ public function testBuildWithEmptySelectArray(): void ->select([]) ->build(); - $this->assertEquals('SELECT FROM `t`', $result['query']); + $this->assertEquals('SELECT FROM `t`', $result->query); } public function testBuildWithOnlyHavingNoGroupBy(): void @@ -5875,8 +5871,8 @@ public function testBuildWithOnlyHavingNoGroupBy(): void ->having([Query::greaterThan('cnt', 0)]) ->build(); - $this->assertStringContainsString('HAVING `cnt` > ?', $result['query']); - $this->assertStringNotContainsString('GROUP BY', $result['query']); + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); + $this->assertStringNotContainsString('GROUP BY', $result->query); } public function testBuildWithOnlyDistinct(): void @@ -5886,7 +5882,7 @@ public function testBuildWithOnlyDistinct(): void ->distinct() ->build(); - $this->assertEquals('SELECT DISTINCT * FROM `t`', $result['query']); + $this->assertEquals('SELECT DISTINCT * FROM `t`', $result->query); } // ══════════════════════════════════════════ @@ -6073,9 +6069,9 @@ public function testKitchenSinkExactSql(): void $this->assertEquals( '(SELECT DISTINCT COUNT(*) AS `total`, `status` FROM `orders` JOIN `users` ON `orders`.`uid` = `users`.`id` WHERE `amount` > ? GROUP BY `status` HAVING `total` > ? ORDER BY `status` ASC LIMIT ? OFFSET ?) UNION (SELECT * FROM `archive` WHERE `status` IN (?))', - $result['query'] + $result->query ); - $this->assertEquals([100, 5, 10, 20, 'closed'], $result['bindings']); + $this->assertEquals([100, 5, 10, 20, 'closed'], $result->bindings); } // ══════════════════════════════════════════ @@ -6086,8 +6082,8 @@ public function testDistinctWithUnion(): void { $other = (new Builder())->from('b'); $result = (new Builder())->from('a')->distinct()->union($other)->build(); - $this->assertEquals('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result->query); + $this->assertEquals([], $result->bindings); } public function testRawInsideLogicalAnd(): void @@ -6098,8 +6094,8 @@ public function testRawInsideLogicalAnd(): void Query::raw('custom_func(y) > ?', [5]), ])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`x` > ? AND custom_func(y) > ?)', $result['query']); - $this->assertEquals([1, 5], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`x` > ? AND custom_func(y) > ?)', $result->query); + $this->assertEquals([1, 5], $result->bindings); } public function testRawInsideLogicalOr(): void @@ -6110,8 +6106,8 @@ public function testRawInsideLogicalOr(): void Query::raw('b IS NOT NULL', []), ])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result['query']); - $this->assertEquals([1], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result->query); + $this->assertEquals([1], $result->bindings); } public function testAggregationWithCursor(): void @@ -6120,9 +6116,9 @@ public function testAggregationWithCursor(): void ->count('*', 'total') ->cursorAfter('abc') ->build(); - $this->assertStringContainsString('COUNT(*)', $result['query']); - $this->assertStringContainsString('`_cursor` > ?', $result['query']); - $this->assertContains('abc', $result['bindings']); + $this->assertStringContainsString('COUNT(*)', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertContains('abc', $result->bindings); } public function testGroupBySortCursorUnion(): void @@ -6135,9 +6131,9 @@ public function testGroupBySortCursorUnion(): void ->cursorAfter('xyz') ->union($other) ->build(); - $this->assertStringContainsString('GROUP BY', $result['query']); - $this->assertStringContainsString('ORDER BY', $result['query']); - $this->assertStringContainsString('UNION', $result['query']); + $this->assertStringContainsString('GROUP BY', $result->query); + $this->assertStringContainsString('ORDER BY', $result->query); + $this->assertStringContainsString('UNION', $result->query); } public function testConditionProviderWithNoFilters(): void @@ -6151,8 +6147,8 @@ public function filter(string $table): Condition } }) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE _tenant = ?', $result['query']); - $this->assertEquals(['t1'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE _tenant = ?', $result->query); + $this->assertEquals(['t1'], $result->bindings); } public function testConditionProviderWithCursorNoFilters(): void @@ -6167,10 +6163,10 @@ public function filter(string $table): Condition }) ->cursorAfter('abc') ->build(); - $this->assertStringContainsString('_tenant = ?', $result['query']); - $this->assertStringContainsString('`_cursor` > ?', $result['query']); + $this->assertStringContainsString('_tenant = ?', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); // Provider bindings come before cursor bindings - $this->assertEquals(['t1', 'abc'], $result['bindings']); + $this->assertEquals(['t1', 'abc'], $result->bindings); } public function testConditionProviderWithDistinct(): void @@ -6185,8 +6181,8 @@ public function filter(string $table): Condition } }) ->build(); - $this->assertEquals('SELECT DISTINCT * FROM `t` WHERE _tenant = ?', $result['query']); - $this->assertEquals(['t1'], $result['bindings']); + $this->assertEquals('SELECT DISTINCT * FROM `t` WHERE _tenant = ?', $result->query); + $this->assertEquals(['t1'], $result->bindings); } public function testConditionProviderPersistsAfterReset(): void @@ -6202,9 +6198,9 @@ public function filter(string $table): Condition $builder->build(); $builder->reset()->from('other'); $result = $builder->build(); - $this->assertStringContainsString('FROM `other`', $result['query']); - $this->assertStringContainsString('_tenant = ?', $result['query']); - $this->assertEquals(['t1'], $result['bindings']); + $this->assertStringContainsString('FROM `other`', $result->query); + $this->assertStringContainsString('_tenant = ?', $result->query); + $this->assertEquals(['t1'], $result->bindings); } public function testConditionProviderWithHaving(): void @@ -6222,10 +6218,10 @@ public function filter(string $table): Condition ->having([Query::greaterThan('total', 5)]) ->build(); // Provider should be in WHERE, not HAVING - $this->assertStringContainsString('WHERE _tenant = ?', $result['query']); - $this->assertStringContainsString('HAVING `total` > ?', $result['query']); + $this->assertStringContainsString('WHERE _tenant = ?', $result->query); + $this->assertStringContainsString('HAVING `total` > ?', $result->query); // Provider bindings before having bindings - $this->assertEquals(['t1', 5], $result['bindings']); + $this->assertEquals(['t1', 5], $result->bindings); } public function testUnionWithConditionProvider(): void @@ -6243,8 +6239,8 @@ public function filter(string $table): Condition ->union($sub) ->build(); // Sub-query should include the condition provider - $this->assertStringContainsString('UNION (SELECT * FROM `b` WHERE _deleted = ?)', $result['query']); - $this->assertEquals([0], $result['bindings']); + $this->assertStringContainsString('UNION (SELECT * FROM `b` WHERE _deleted = ?)', $result->query); + $this->assertEquals([0], $result->bindings); } // ══════════════════════════════════════════ @@ -6254,129 +6250,129 @@ public function filter(string $table): Condition public function testNegativeLimit(): void { $result = (new Builder())->from('t')->limit(-1)->build(); - $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result['query']); - $this->assertEquals([-1], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertEquals([-1], $result->bindings); } public function testNegativeOffset(): void { // OFFSET without LIMIT is suppressed $result = (new Builder())->from('t')->offset(-5)->build(); - $this->assertEquals('SELECT * FROM `t`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); } public function testEqualWithNullOnly(): void { $result = (new Builder())->from('t')->filter([Query::equal('col', [null])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NULL', $result['query']); - $this->assertSame([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NULL', $result->query); + $this->assertSame([], $result->bindings); } public function testEqualWithNullAndNonNull(): void { $result = (new Builder())->from('t')->filter([Query::equal('col', ['a', null])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`col` IN (?) OR `col` IS NULL)', $result['query']); - $this->assertSame(['a'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` IN (?) OR `col` IS NULL)', $result->query); + $this->assertSame(['a'], $result->bindings); } public function testNotEqualWithNullOnly(): void { $result = (new Builder())->from('t')->filter([Query::notEqual('col', [null])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NOT NULL', $result['query']); - $this->assertSame([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NOT NULL', $result->query); + $this->assertSame([], $result->bindings); } public function testNotEqualWithNullAndNonNull(): void { $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', null])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`col` != ? AND `col` IS NOT NULL)', $result['query']); - $this->assertSame(['a'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` != ? AND `col` IS NOT NULL)', $result->query); + $this->assertSame(['a'], $result->bindings); } public function testNotEqualWithMultipleNonNullAndNull(): void { $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', 'b', null])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`col` NOT IN (?, ?) AND `col` IS NOT NULL)', $result['query']); - $this->assertSame(['a', 'b'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` NOT IN (?, ?) AND `col` IS NOT NULL)', $result->query); + $this->assertSame(['a', 'b'], $result->bindings); } public function testBetweenReversedMinMax(): void { $result = (new Builder())->from('t')->filter([Query::between('age', 65, 18)])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result['query']); - $this->assertEquals([65, 18], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertEquals([65, 18], $result->bindings); } public function testContainsWithSqlWildcard(): void { $result = (new Builder())->from('t')->filter([Query::contains('bio', ['100%'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result['query']); - $this->assertEquals(['%100\%%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); + $this->assertEquals(['%100\%%'], $result->bindings); } public function testStartsWithWithWildcard(): void { $result = (new Builder())->from('t')->filter([Query::startsWith('name', '%admin')])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result['query']); - $this->assertEquals(['\%admin%'], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertEquals(['\%admin%'], $result->bindings); } public function testCursorWithNullValue(): void { // Null cursor value is ignored by groupByType since cursor stays null $result = (new Builder())->from('t')->cursorAfter(null)->build(); - $this->assertStringNotContainsString('_cursor', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertStringNotContainsString('_cursor', $result->query); + $this->assertEquals([], $result->bindings); } public function testCursorWithIntegerValue(): void { $result = (new Builder())->from('t')->cursorAfter(42)->build(); - $this->assertStringContainsString('`_cursor` > ?', $result['query']); - $this->assertSame([42], $result['bindings']); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertSame([42], $result->bindings); } public function testCursorWithFloatValue(): void { $result = (new Builder())->from('t')->cursorAfter(3.14)->build(); - $this->assertStringContainsString('`_cursor` > ?', $result['query']); - $this->assertSame([3.14], $result['bindings']); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertSame([3.14], $result->bindings); } public function testMultipleLimitsFirstWins(): void { $result = (new Builder())->from('t')->limit(10)->limit(20)->build(); - $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result['query']); - $this->assertEquals([10], $result['bindings']); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertEquals([10], $result->bindings); } public function testMultipleOffsetsFirstWins(): void { // OFFSET without LIMIT is suppressed $result = (new Builder())->from('t')->offset(5)->offset(50)->build(); - $this->assertEquals('SELECT * FROM `t`', $result['query']); - $this->assertEquals([], $result['bindings']); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); } public function testCursorAfterAndBeforeFirstWins(): void { $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->build(); - $this->assertStringContainsString('`_cursor` > ?', $result['query']); - $this->assertStringNotContainsString('`_cursor` < ?', $result['query']); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertStringNotContainsString('`_cursor` < ?', $result->query); } public function testEmptyTableWithJoin(): void { $result = (new Builder())->from('')->join('other', 'a', 'b')->build(); - $this->assertEquals('SELECT * FROM `` JOIN `other` ON `a` = `b`', $result['query']); + $this->assertEquals('SELECT * FROM `` JOIN `other` ON `a` = `b`', $result->query); } public function testBuildWithoutFromCall(): void { $result = (new Builder())->filter([Query::equal('x', [1])])->build(); - $this->assertStringContainsString('FROM ``', $result['query']); - $this->assertStringContainsString('`x` IN (?)', $result['query']); + $this->assertStringContainsString('FROM ``', $result->query); + $this->assertStringContainsString('`x` IN (?)', $result->query); } // ══════════════════════════════════════════ @@ -6534,7 +6530,7 @@ public function testSetWrapCharWithIsNotNull(): void ->from('t') ->filter([Query::isNotNull('email')]) ->build(); - $this->assertStringContainsString('"email" IS NOT NULL', $result['query']); + $this->assertStringContainsString('"email" IS NOT NULL', $result->query); } public function testSetWrapCharWithExists(): void @@ -6543,8 +6539,8 @@ public function testSetWrapCharWithExists(): void ->from('t') ->filter([Query::exists(['a', 'b'])]) ->build(); - $this->assertStringContainsString('"a" IS NOT NULL', $result['query']); - $this->assertStringContainsString('"b" IS NOT NULL', $result['query']); + $this->assertStringContainsString('"a" IS NOT NULL', $result->query); + $this->assertStringContainsString('"b" IS NOT NULL', $result->query); } public function testSetWrapCharWithNotExists(): void @@ -6553,7 +6549,7 @@ public function testSetWrapCharWithNotExists(): void ->from('t') ->filter([Query::notExists('c')]) ->build(); - $this->assertStringContainsString('"c" IS NULL', $result['query']); + $this->assertStringContainsString('"c" IS NULL', $result->query); } public function testSetWrapCharCursorNotAffected(): void @@ -6563,7 +6559,7 @@ public function testSetWrapCharCursorNotAffected(): void ->cursorAfter('abc') ->build(); // _cursor is now properly wrapped with the configured wrap character - $this->assertStringContainsString('"_cursor" > ?', $result['query']); + $this->assertStringContainsString('"_cursor" > ?', $result->query); } public function testSetWrapCharWithToRawSql(): void @@ -6590,8 +6586,8 @@ public function testResetFollowedByUnion(): void ->union((new Builder())->from('old')); $builder->reset()->from('b'); $result = $builder->build(); - $this->assertEquals('SELECT * FROM `b`', $result['query']); - $this->assertStringNotContainsString('UNION', $result['query']); + $this->assertEquals('SELECT * FROM `b`', $result->query); + $this->assertStringNotContainsString('UNION', $result->query); } public function testResetClearsBindingsAfterBuild(): void @@ -6601,7 +6597,7 @@ public function testResetClearsBindingsAfterBuild(): void $this->assertNotEmpty($builder->getBindings()); $builder->reset()->from('t'); $result = $builder->build(); - $this->assertEquals([], $result['bindings']); + $this->assertEquals([], $result->bindings); } // ══════════════════════════════════════════ @@ -6611,48 +6607,48 @@ public function testResetClearsBindingsAfterBuild(): void public function testSortAscBindingsEmpty(): void { $result = (new Builder())->from('t')->sortAsc('name')->build(); - $this->assertEquals([], $result['bindings']); + $this->assertEquals([], $result->bindings); } public function testSortDescBindingsEmpty(): void { $result = (new Builder())->from('t')->sortDesc('name')->build(); - $this->assertEquals([], $result['bindings']); + $this->assertEquals([], $result->bindings); } public function testSortRandomBindingsEmpty(): void { $result = (new Builder())->from('t')->sortRandom()->build(); - $this->assertEquals([], $result['bindings']); + $this->assertEquals([], $result->bindings); } public function testDistinctBindingsEmpty(): void { $result = (new Builder())->from('t')->distinct()->build(); - $this->assertEquals([], $result['bindings']); + $this->assertEquals([], $result->bindings); } public function testJoinBindingsEmpty(): void { $result = (new Builder())->from('t')->join('other', 'a', 'b')->build(); - $this->assertEquals([], $result['bindings']); + $this->assertEquals([], $result->bindings); } public function testCrossJoinBindingsEmpty(): void { $result = (new Builder())->from('t')->crossJoin('other')->build(); - $this->assertEquals([], $result['bindings']); + $this->assertEquals([], $result->bindings); } public function testGroupByBindingsEmpty(): void { $result = (new Builder())->from('t')->groupBy(['status'])->build(); - $this->assertEquals([], $result['bindings']); + $this->assertEquals([], $result->bindings); } public function testCountWithAliasBindingsEmpty(): void { $result = (new Builder())->from('t')->count('*', 'total')->build(); - $this->assertEquals([], $result['bindings']); + $this->assertEquals([], $result->bindings); } } diff --git a/tests/Query/FilterQueryTest.php b/tests/Query/FilterQueryTest.php index cd3f0ca..659a26a 100644 --- a/tests/Query/FilterQueryTest.php +++ b/tests/Query/FilterQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; class FilterQueryTest extends TestCase @@ -10,7 +11,7 @@ class FilterQueryTest extends TestCase public function testEqual(): void { $query = Query::equal('name', ['John', 'Jane']); - $this->assertEquals(Query::TYPE_EQUAL, $query->getMethod()); + $this->assertSame(Method::Equal, $query->getMethod()); $this->assertEquals('name', $query->getAttribute()); $this->assertEquals(['John', 'Jane'], $query->getValues()); } @@ -18,7 +19,7 @@ public function testEqual(): void public function testNotEqual(): void { $query = Query::notEqual('name', 'John'); - $this->assertEquals(Query::TYPE_NOT_EQUAL, $query->getMethod()); + $this->assertSame(Method::NotEqual, $query->getMethod()); $this->assertEquals(['John'], $query->getValues()); } @@ -37,7 +38,7 @@ public function testNotEqualWithMap(): void public function testLessThan(): void { $query = Query::lessThan('age', 30); - $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); + $this->assertSame(Method::LessThan, $query->getMethod()); $this->assertEquals('age', $query->getAttribute()); $this->assertEquals([30], $query->getValues()); } @@ -45,84 +46,84 @@ public function testLessThan(): void public function testLessThanEqual(): void { $query = Query::lessThanEqual('age', 30); - $this->assertEquals(Query::TYPE_LESSER_EQUAL, $query->getMethod()); + $this->assertSame(Method::LessThanEqual, $query->getMethod()); $this->assertEquals([30], $query->getValues()); } public function testGreaterThan(): void { $query = Query::greaterThan('age', 18); - $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); + $this->assertSame(Method::GreaterThan, $query->getMethod()); $this->assertEquals([18], $query->getValues()); } public function testGreaterThanEqual(): void { $query = Query::greaterThanEqual('age', 18); - $this->assertEquals(Query::TYPE_GREATER_EQUAL, $query->getMethod()); + $this->assertSame(Method::GreaterThanEqual, $query->getMethod()); $this->assertEquals([18], $query->getValues()); } public function testContains(): void { $query = Query::contains('tags', ['php', 'js']); - $this->assertEquals(Query::TYPE_CONTAINS, $query->getMethod()); + $this->assertSame(Method::Contains, $query->getMethod()); $this->assertEquals(['php', 'js'], $query->getValues()); } public function testContainsAny(): void { $query = Query::containsAny('tags', ['php', 'js']); - $this->assertEquals(Query::TYPE_CONTAINS_ANY, $query->getMethod()); + $this->assertSame(Method::ContainsAny, $query->getMethod()); $this->assertEquals(['php', 'js'], $query->getValues()); } public function testNotContains(): void { $query = Query::notContains('tags', ['php']); - $this->assertEquals(Query::TYPE_NOT_CONTAINS, $query->getMethod()); + $this->assertSame(Method::NotContains, $query->getMethod()); $this->assertEquals(['php'], $query->getValues()); } public function testContainsDeprecated(): void { $query = Query::contains('tags', ['a', 'b']); - $this->assertEquals(Query::TYPE_CONTAINS, $query->getMethod()); + $this->assertSame(Method::Contains, $query->getMethod()); $this->assertEquals(['a', 'b'], $query->getValues()); } public function testBetween(): void { $query = Query::between('age', 18, 65); - $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertSame(Method::Between, $query->getMethod()); $this->assertEquals([18, 65], $query->getValues()); } public function testNotBetween(): void { $query = Query::notBetween('age', 18, 65); - $this->assertEquals(Query::TYPE_NOT_BETWEEN, $query->getMethod()); + $this->assertSame(Method::NotBetween, $query->getMethod()); $this->assertEquals([18, 65], $query->getValues()); } public function testSearch(): void { $query = Query::search('content', 'hello world'); - $this->assertEquals(Query::TYPE_SEARCH, $query->getMethod()); + $this->assertSame(Method::Search, $query->getMethod()); $this->assertEquals(['hello world'], $query->getValues()); } public function testNotSearch(): void { $query = Query::notSearch('content', 'hello'); - $this->assertEquals(Query::TYPE_NOT_SEARCH, $query->getMethod()); + $this->assertSame(Method::NotSearch, $query->getMethod()); $this->assertEquals(['hello'], $query->getValues()); } public function testIsNull(): void { $query = Query::isNull('email'); - $this->assertEquals(Query::TYPE_IS_NULL, $query->getMethod()); + $this->assertSame(Method::IsNull, $query->getMethod()); $this->assertEquals('email', $query->getAttribute()); $this->assertEquals([], $query->getValues()); } @@ -130,46 +131,46 @@ public function testIsNull(): void public function testIsNotNull(): void { $query = Query::isNotNull('email'); - $this->assertEquals(Query::TYPE_IS_NOT_NULL, $query->getMethod()); + $this->assertSame(Method::IsNotNull, $query->getMethod()); } public function testStartsWith(): void { $query = Query::startsWith('name', 'Jo'); - $this->assertEquals(Query::TYPE_STARTS_WITH, $query->getMethod()); + $this->assertSame(Method::StartsWith, $query->getMethod()); $this->assertEquals(['Jo'], $query->getValues()); } public function testNotStartsWith(): void { $query = Query::notStartsWith('name', 'Jo'); - $this->assertEquals(Query::TYPE_NOT_STARTS_WITH, $query->getMethod()); + $this->assertSame(Method::NotStartsWith, $query->getMethod()); } public function testEndsWith(): void { $query = Query::endsWith('email', '.com'); - $this->assertEquals(Query::TYPE_ENDS_WITH, $query->getMethod()); + $this->assertSame(Method::EndsWith, $query->getMethod()); $this->assertEquals(['.com'], $query->getValues()); } public function testNotEndsWith(): void { $query = Query::notEndsWith('email', '.com'); - $this->assertEquals(Query::TYPE_NOT_ENDS_WITH, $query->getMethod()); + $this->assertSame(Method::NotEndsWith, $query->getMethod()); } public function testRegex(): void { $query = Query::regex('name', '^Jo.*'); - $this->assertEquals(Query::TYPE_REGEX, $query->getMethod()); + $this->assertSame(Method::Regex, $query->getMethod()); $this->assertEquals(['^Jo.*'], $query->getValues()); } public function testExists(): void { $query = Query::exists(['name', 'email']); - $this->assertEquals(Query::TYPE_EXISTS, $query->getMethod()); + $this->assertSame(Method::Exists, $query->getMethod()); $this->assertEquals('', $query->getAttribute()); $this->assertEquals(['name', 'email'], $query->getValues()); } @@ -177,7 +178,7 @@ public function testExists(): void public function testNotExistsArray(): void { $query = Query::notExists(['name']); - $this->assertEquals(Query::TYPE_NOT_EXISTS, $query->getMethod()); + $this->assertSame(Method::NotExists, $query->getMethod()); $this->assertEquals(['name'], $query->getValues()); } @@ -190,7 +191,7 @@ public function testNotExistsScalar(): void public function testCreatedBefore(): void { $query = Query::createdBefore('2024-01-01'); - $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); + $this->assertSame(Method::LessThan, $query->getMethod()); $this->assertEquals('$createdAt', $query->getAttribute()); $this->assertEquals(['2024-01-01'], $query->getValues()); } @@ -198,28 +199,28 @@ public function testCreatedBefore(): void public function testCreatedAfter(): void { $query = Query::createdAfter('2024-01-01'); - $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); + $this->assertSame(Method::GreaterThan, $query->getMethod()); $this->assertEquals('$createdAt', $query->getAttribute()); } public function testUpdatedBefore(): void { $query = Query::updatedBefore('2024-06-01'); - $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); + $this->assertSame(Method::LessThan, $query->getMethod()); $this->assertEquals('$updatedAt', $query->getAttribute()); } public function testUpdatedAfter(): void { $query = Query::updatedAfter('2024-06-01'); - $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); + $this->assertSame(Method::GreaterThan, $query->getMethod()); $this->assertEquals('$updatedAt', $query->getAttribute()); } public function testCreatedBetween(): void { $query = Query::createdBetween('2024-01-01', '2024-12-31'); - $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertSame(Method::Between, $query->getMethod()); $this->assertEquals('$createdAt', $query->getAttribute()); $this->assertEquals(['2024-01-01', '2024-12-31'], $query->getValues()); } @@ -227,7 +228,7 @@ public function testCreatedBetween(): void public function testUpdatedBetween(): void { $query = Query::updatedBetween('2024-01-01', '2024-12-31'); - $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertSame(Method::Between, $query->getMethod()); $this->assertEquals('$updatedAt', $query->getAttribute()); } } diff --git a/tests/Query/JoinQueryTest.php b/tests/Query/JoinQueryTest.php index cddb42a..6dcf599 100644 --- a/tests/Query/JoinQueryTest.php +++ b/tests/Query/JoinQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; class JoinQueryTest extends TestCase @@ -10,7 +11,7 @@ class JoinQueryTest extends TestCase public function testJoin(): void { $query = Query::join('orders', 'users.id', 'orders.user_id'); - $this->assertEquals(Query::TYPE_JOIN, $query->getMethod()); + $this->assertSame(Method::Join, $query->getMethod()); $this->assertEquals('orders', $query->getAttribute()); $this->assertEquals(['users.id', '=', 'orders.user_id'], $query->getValues()); } @@ -24,7 +25,7 @@ public function testJoinWithOperator(): void public function testLeftJoin(): void { $query = Query::leftJoin('profiles', 'users.id', 'profiles.user_id'); - $this->assertEquals(Query::TYPE_LEFT_JOIN, $query->getMethod()); + $this->assertSame(Method::LeftJoin, $query->getMethod()); $this->assertEquals('profiles', $query->getAttribute()); $this->assertEquals(['users.id', '=', 'profiles.user_id'], $query->getValues()); } @@ -32,25 +33,26 @@ public function testLeftJoin(): void public function testRightJoin(): void { $query = Query::rightJoin('orders', 'users.id', 'orders.user_id'); - $this->assertEquals(Query::TYPE_RIGHT_JOIN, $query->getMethod()); + $this->assertSame(Method::RightJoin, $query->getMethod()); $this->assertEquals('orders', $query->getAttribute()); } public function testCrossJoin(): void { $query = Query::crossJoin('colors'); - $this->assertEquals(Query::TYPE_CROSS_JOIN, $query->getMethod()); + $this->assertSame(Method::CrossJoin, $query->getMethod()); $this->assertEquals('colors', $query->getAttribute()); $this->assertEquals([], $query->getValues()); } - public function testJoinTypesConstant(): void + public function testJoinMethodsAreJoin(): void { - $this->assertContains(Query::TYPE_JOIN, Query::JOIN_TYPES); - $this->assertContains(Query::TYPE_LEFT_JOIN, Query::JOIN_TYPES); - $this->assertContains(Query::TYPE_RIGHT_JOIN, Query::JOIN_TYPES); - $this->assertContains(Query::TYPE_CROSS_JOIN, Query::JOIN_TYPES); - $this->assertCount(4, Query::JOIN_TYPES); + $this->assertTrue(Method::Join->isJoin()); + $this->assertTrue(Method::LeftJoin->isJoin()); + $this->assertTrue(Method::RightJoin->isJoin()); + $this->assertTrue(Method::CrossJoin->isJoin()); + $joinMethods = array_filter(Method::cases(), fn (Method $m) => $m->isJoin()); + $this->assertCount(4, $joinMethods); } // ── Edge cases ── diff --git a/tests/Query/LogicalQueryTest.php b/tests/Query/LogicalQueryTest.php index 6e951e9..a503361 100644 --- a/tests/Query/LogicalQueryTest.php +++ b/tests/Query/LogicalQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; class LogicalQueryTest extends TestCase @@ -12,7 +13,7 @@ public function testOr(): void $q1 = Query::equal('name', ['John']); $q2 = Query::equal('name', ['Jane']); $query = Query::or([$q1, $q2]); - $this->assertEquals(Query::TYPE_OR, $query->getMethod()); + $this->assertSame(Method::Or, $query->getMethod()); $this->assertCount(2, $query->getValues()); } @@ -21,14 +22,14 @@ public function testAnd(): void $q1 = Query::greaterThan('age', 18); $q2 = Query::lessThan('age', 65); $query = Query::and([$q1, $q2]); - $this->assertEquals(Query::TYPE_AND, $query->getMethod()); + $this->assertSame(Method::And, $query->getMethod()); $this->assertCount(2, $query->getValues()); } public function testContainsAll(): void { $query = Query::containsAll('tags', ['php', 'js']); - $this->assertEquals(Query::TYPE_CONTAINS_ALL, $query->getMethod()); + $this->assertSame(Method::ContainsAll, $query->getMethod()); $this->assertEquals(['php', 'js'], $query->getValues()); } @@ -36,7 +37,7 @@ public function testElemMatch(): void { $inner = [Query::equal('field', ['val'])]; $query = Query::elemMatch('items', $inner); - $this->assertEquals(Query::TYPE_ELEM_MATCH, $query->getMethod()); + $this->assertSame(Method::ElemMatch, $query->getMethod()); $this->assertEquals('items', $query->getAttribute()); } } diff --git a/tests/Query/QueryHelperTest.php b/tests/Query/QueryHelperTest.php index 460aa0c..d7beb36 100644 --- a/tests/Query/QueryHelperTest.php +++ b/tests/Query/QueryHelperTest.php @@ -3,6 +3,9 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\CursorDirection; +use Utopia\Query\Method; +use Utopia\Query\OrderDirection; use Utopia\Query\Query; class QueryHelperTest extends TestCase @@ -106,7 +109,7 @@ public function testCloneDeepCopiesNestedQueries(): void $clonedValues = $cloned->getValues(); $this->assertInstanceOf(Query::class, $clonedValues[0]); $this->assertNotSame($inner, $clonedValues[0]); - $this->assertEquals('equal', $clonedValues[0]->getMethod()); + $this->assertSame(Method::Equal, $clonedValues[0]->getMethod()); } public function testClonePreservesNonQueryValues(): void @@ -125,10 +128,10 @@ public function testGetByType(): void Query::offset(5), ]; - $filters = Query::getByType($queries, [Query::TYPE_EQUAL, Query::TYPE_GREATER]); + $filters = Query::getByType($queries, [Method::Equal, Method::GreaterThan]); $this->assertCount(2, $filters); - $this->assertEquals('equal', $filters[0]->getMethod()); - $this->assertEquals('greaterThan', $filters[1]->getMethod()); + $this->assertSame(Method::Equal, $filters[0]->getMethod()); + $this->assertSame(Method::GreaterThan, $filters[1]->getMethod()); } public function testGetByTypeClone(): void @@ -136,7 +139,7 @@ public function testGetByTypeClone(): void $original = Query::equal('name', ['John']); $queries = [$original]; - $result = Query::getByType($queries, [Query::TYPE_EQUAL], true); + $result = Query::getByType($queries, [Method::Equal], true); $this->assertNotSame($original, $result[0]); } @@ -145,14 +148,14 @@ public function testGetByTypeNoClone(): void $original = Query::equal('name', ['John']); $queries = [$original]; - $result = Query::getByType($queries, [Query::TYPE_EQUAL], false); + $result = Query::getByType($queries, [Method::Equal], false); $this->assertSame($original, $result[0]); } public function testGetByTypeEmpty(): void { $queries = [Query::equal('x', [1])]; - $result = Query::getByType($queries, [Query::TYPE_LIMIT]); + $result = Query::getByType($queries, [Method::Limit]); $this->assertCount(0, $result); } @@ -167,8 +170,8 @@ public function testGetCursorQueries(): void $cursors = Query::getCursorQueries($queries); $this->assertCount(2, $cursors); - $this->assertEquals(Query::TYPE_CURSOR_AFTER, $cursors[0]->getMethod()); - $this->assertEquals(Query::TYPE_CURSOR_BEFORE, $cursors[1]->getMethod()); + $this->assertSame(Method::CursorAfter, $cursors[0]->getMethod()); + $this->assertSame(Method::CursorBefore, $cursors[1]->getMethod()); } public function testGetCursorQueriesNone(): void @@ -193,21 +196,21 @@ public function testGroupByType(): void $grouped = Query::groupByType($queries); - $this->assertCount(2, $grouped['filters']); - $this->assertEquals('equal', $grouped['filters'][0]->getMethod()); - $this->assertEquals('greaterThan', $grouped['filters'][1]->getMethod()); + $this->assertCount(2, $grouped->filters); + $this->assertSame(Method::Equal, $grouped->filters[0]->getMethod()); + $this->assertSame(Method::GreaterThan, $grouped->filters[1]->getMethod()); - $this->assertCount(1, $grouped['selections']); - $this->assertEquals('select', $grouped['selections'][0]->getMethod()); + $this->assertCount(1, $grouped->selections); + $this->assertSame(Method::Select, $grouped->selections[0]->getMethod()); - $this->assertEquals(25, $grouped['limit']); - $this->assertEquals(10, $grouped['offset']); + $this->assertEquals(25, $grouped->limit); + $this->assertEquals(10, $grouped->offset); - $this->assertEquals(['name', 'age'], $grouped['orderAttributes']); - $this->assertEquals([Query::ORDER_ASC, Query::ORDER_DESC], $grouped['orderTypes']); + $this->assertEquals(['name', 'age'], $grouped->orderAttributes); + $this->assertEquals([OrderDirection::Asc, OrderDirection::Desc], $grouped->orderTypes); - $this->assertEquals('doc123', $grouped['cursor']); - $this->assertEquals(Query::CURSOR_AFTER, $grouped['cursorDirection']); + $this->assertEquals('doc123', $grouped->cursor); + $this->assertSame(CursorDirection::After, $grouped->cursorDirection); } public function testGroupByTypeFirstLimitWins(): void @@ -218,7 +221,7 @@ public function testGroupByTypeFirstLimitWins(): void ]; $grouped = Query::groupByType($queries); - $this->assertEquals(10, $grouped['limit']); + $this->assertEquals(10, $grouped->limit); } public function testGroupByTypeFirstOffsetWins(): void @@ -229,7 +232,7 @@ public function testGroupByTypeFirstOffsetWins(): void ]; $grouped = Query::groupByType($queries); - $this->assertEquals(5, $grouped['offset']); + $this->assertEquals(5, $grouped->offset); } public function testGroupByTypeFirstCursorWins(): void @@ -240,8 +243,8 @@ public function testGroupByTypeFirstCursorWins(): void ]; $grouped = Query::groupByType($queries); - $this->assertEquals('first', $grouped['cursor']); - $this->assertEquals(Query::CURSOR_AFTER, $grouped['cursorDirection']); + $this->assertEquals('first', $grouped->cursor); + $this->assertSame(CursorDirection::After, $grouped->cursorDirection); } public function testGroupByTypeCursorBefore(): void @@ -251,35 +254,35 @@ public function testGroupByTypeCursorBefore(): void ]; $grouped = Query::groupByType($queries); - $this->assertEquals('doc456', $grouped['cursor']); - $this->assertEquals(Query::CURSOR_BEFORE, $grouped['cursorDirection']); + $this->assertEquals('doc456', $grouped->cursor); + $this->assertSame(CursorDirection::Before, $grouped->cursorDirection); } public function testGroupByTypeEmpty(): void { $grouped = Query::groupByType([]); - $this->assertEquals([], $grouped['filters']); - $this->assertEquals([], $grouped['selections']); - $this->assertNull($grouped['limit']); - $this->assertNull($grouped['offset']); - $this->assertEquals([], $grouped['orderAttributes']); - $this->assertEquals([], $grouped['orderTypes']); - $this->assertNull($grouped['cursor']); - $this->assertNull($grouped['cursorDirection']); + $this->assertEquals([], $grouped->filters); + $this->assertEquals([], $grouped->selections); + $this->assertNull($grouped->limit); + $this->assertNull($grouped->offset); + $this->assertEquals([], $grouped->orderAttributes); + $this->assertEquals([], $grouped->orderTypes); + $this->assertNull($grouped->cursor); + $this->assertNull($grouped->cursorDirection); } public function testGroupByTypeOrderRandom(): void { $queries = [Query::orderRandom()]; $grouped = Query::groupByType($queries); - $this->assertEquals([Query::ORDER_RANDOM], $grouped['orderTypes']); - $this->assertEquals([], $grouped['orderAttributes']); + $this->assertEquals([OrderDirection::Random], $grouped->orderTypes); + $this->assertEquals([], $grouped->orderAttributes); } public function testGroupByTypeSkipsNonQueryInstances(): void { $grouped = Query::groupByType(['not a query', null, 42]); - $this->assertEquals([], $grouped['filters']); + $this->assertEquals([], $grouped->filters); } // ── groupByType with new types ── @@ -295,37 +298,37 @@ public function testGroupByTypeAggregations(): void ]; $grouped = Query::groupByType($queries); - $this->assertCount(5, $grouped['aggregations']); - $this->assertEquals(Query::TYPE_COUNT, $grouped['aggregations'][0]->getMethod()); - $this->assertEquals(Query::TYPE_MAX, $grouped['aggregations'][4]->getMethod()); + $this->assertCount(5, $grouped->aggregations); + $this->assertSame(Method::Count, $grouped->aggregations[0]->getMethod()); + $this->assertSame(Method::Max, $grouped->aggregations[4]->getMethod()); } public function testGroupByTypeGroupBy(): void { $queries = [Query::groupBy(['status', 'country'])]; $grouped = Query::groupByType($queries); - $this->assertEquals(['status', 'country'], $grouped['groupBy']); + $this->assertEquals(['status', 'country'], $grouped->groupBy); } public function testGroupByTypeHaving(): void { $queries = [Query::having([Query::greaterThan('total', 5)])]; $grouped = Query::groupByType($queries); - $this->assertCount(1, $grouped['having']); - $this->assertEquals(Query::TYPE_HAVING, $grouped['having'][0]->getMethod()); + $this->assertCount(1, $grouped->having); + $this->assertSame(Method::Having, $grouped->having[0]->getMethod()); } public function testGroupByTypeDistinct(): void { $queries = [Query::distinct()]; $grouped = Query::groupByType($queries); - $this->assertTrue($grouped['distinct']); + $this->assertTrue($grouped->distinct); } public function testGroupByTypeDistinctDefaultFalse(): void { $grouped = Query::groupByType([]); - $this->assertFalse($grouped['distinct']); + $this->assertFalse($grouped->distinct); } public function testGroupByTypeJoins(): void @@ -336,9 +339,9 @@ public function testGroupByTypeJoins(): void Query::crossJoin('colors'), ]; $grouped = Query::groupByType($queries); - $this->assertCount(3, $grouped['joins']); - $this->assertEquals(Query::TYPE_JOIN, $grouped['joins'][0]->getMethod()); - $this->assertEquals(Query::TYPE_CROSS_JOIN, $grouped['joins'][2]->getMethod()); + $this->assertCount(3, $grouped->joins); + $this->assertSame(Method::Join, $grouped->joins[0]->getMethod()); + $this->assertSame(Method::CrossJoin, $grouped->joins[2]->getMethod()); } public function testGroupByTypeUnions(): void @@ -348,7 +351,7 @@ public function testGroupByTypeUnions(): void Query::unionAll([Query::equal('y', [2])]), ]; $grouped = Query::groupByType($queries); - $this->assertCount(2, $grouped['unions']); + $this->assertCount(2, $grouped->unions); } // ── merge() ── @@ -360,8 +363,8 @@ public function testMergeConcatenates(): void $result = Query::merge($a, $b); $this->assertCount(2, $result); - $this->assertEquals('equal', $result[0]->getMethod()); - $this->assertEquals('greaterThan', $result[1]->getMethod()); + $this->assertSame(Method::Equal, $result[0]->getMethod()); + $this->assertSame(Method::GreaterThan, $result[1]->getMethod()); } public function testMergeLimitOverrides(): void @@ -382,7 +385,7 @@ public function testMergeOffsetOverrides(): void $result = Query::merge($a, $b); $this->assertCount(2, $result); // equal stays, offset replaced - $this->assertEquals('equal', $result[0]->getMethod()); + $this->assertSame(Method::Equal, $result[0]->getMethod()); $this->assertEquals(100, $result[1]->getValue()); } @@ -406,7 +409,7 @@ public function testDiffReturnsUnique(): void $result = Query::diff($a, $b); $this->assertCount(1, $result); - $this->assertEquals('greaterThan', $result[0]->getMethod()); + $this->assertSame(Method::GreaterThan, $result[0]->getMethod()); } public function testDiffEmpty(): void @@ -493,9 +496,9 @@ public function testPageStaticHelper(): void { $result = Query::page(3, 10); $this->assertCount(2, $result); - $this->assertEquals(Query::TYPE_LIMIT, $result[0]->getMethod()); + $this->assertSame(Method::Limit, $result[0]->getMethod()); $this->assertEquals(10, $result[0]->getValue()); - $this->assertEquals(Query::TYPE_OFFSET, $result[1]->getMethod()); + $this->assertSame(Method::Offset, $result[1]->getMethod()); $this->assertEquals(20, $result[1]->getValue()); } @@ -545,17 +548,17 @@ public function testGroupByTypeAllNewTypes(): void $grouped = Query::groupByType($queries); - $this->assertCount(1, $grouped['filters']); - $this->assertCount(1, $grouped['selections']); - $this->assertCount(2, $grouped['aggregations']); - $this->assertEquals(['status'], $grouped['groupBy']); - $this->assertCount(1, $grouped['having']); - $this->assertTrue($grouped['distinct']); - $this->assertCount(1, $grouped['joins']); - $this->assertCount(1, $grouped['unions']); - $this->assertEquals(10, $grouped['limit']); - $this->assertEquals(5, $grouped['offset']); - $this->assertEquals(['name'], $grouped['orderAttributes']); + $this->assertCount(1, $grouped->filters); + $this->assertCount(1, $grouped->selections); + $this->assertCount(2, $grouped->aggregations); + $this->assertEquals(['status'], $grouped->groupBy); + $this->assertCount(1, $grouped->having); + $this->assertTrue($grouped->distinct); + $this->assertCount(1, $grouped->joins); + $this->assertCount(1, $grouped->unions); + $this->assertEquals(10, $grouped->limit); + $this->assertEquals(5, $grouped->offset); + $this->assertEquals(['name'], $grouped->orderAttributes); } public function testGroupByTypeMultipleGroupByMerges(): void @@ -565,7 +568,7 @@ public function testGroupByTypeMultipleGroupByMerges(): void Query::groupBy(['c']), ]; $grouped = Query::groupByType($queries); - $this->assertEquals(['a', 'b', 'c'], $grouped['groupBy']); + $this->assertEquals(['a', 'b', 'c'], $grouped->groupBy); } public function testGroupByTypeMultipleDistinct(): void @@ -575,7 +578,7 @@ public function testGroupByTypeMultipleDistinct(): void Query::distinct(), ]; $grouped = Query::groupByType($queries); - $this->assertTrue($grouped['distinct']); + $this->assertTrue($grouped->distinct); } public function testGroupByTypeMultipleHaving(): void @@ -585,26 +588,26 @@ public function testGroupByTypeMultipleHaving(): void Query::having([Query::lessThan('y', 100)]), ]; $grouped = Query::groupByType($queries); - $this->assertCount(2, $grouped['having']); + $this->assertCount(2, $grouped->having); } public function testGroupByTypeRawGoesToFilters(): void { $queries = [Query::raw('1 = 1')]; $grouped = Query::groupByType($queries); - $this->assertCount(1, $grouped['filters']); - $this->assertEquals(Query::TYPE_RAW, $grouped['filters'][0]->getMethod()); + $this->assertCount(1, $grouped->filters); + $this->assertSame(Method::Raw, $grouped->filters[0]->getMethod()); } public function testGroupByTypeEmptyNewKeys(): void { $grouped = Query::groupByType([]); - $this->assertEquals([], $grouped['aggregations']); - $this->assertEquals([], $grouped['groupBy']); - $this->assertEquals([], $grouped['having']); - $this->assertFalse($grouped['distinct']); - $this->assertEquals([], $grouped['joins']); - $this->assertEquals([], $grouped['unions']); + $this->assertEquals([], $grouped->aggregations); + $this->assertEquals([], $grouped->groupBy); + $this->assertEquals([], $grouped->having); + $this->assertFalse($grouped->distinct); + $this->assertEquals([], $grouped->joins); + $this->assertEquals([], $grouped->unions); } // ── merge() additional edge cases ── @@ -644,8 +647,8 @@ public function testMergeBothLimitAndOffset(): void $result = Query::merge($a, $b); // Both should be overridden $this->assertCount(2, $result); - $limits = array_filter($result, fn (Query $q) => $q->getMethod() === Query::TYPE_LIMIT); - $offsets = array_filter($result, fn (Query $q) => $q->getMethod() === Query::TYPE_OFFSET); + $limits = array_filter($result, fn (Query $q) => $q->getMethod() === Method::Limit); + $offsets = array_filter($result, fn (Query $q) => $q->getMethod() === Method::Offset); $this->assertEquals(50, array_values($limits)[0]->getValue()); $this->assertEquals(100, array_values($offsets)[0]->getValue()); } @@ -699,7 +702,7 @@ public function testDiffPartialOverlap(): void $b = [$shared1, $shared2]; $result = Query::diff($a, $b); $this->assertCount(1, $result); - $this->assertEquals('greaterThan', $result[0]->getMethod()); + $this->assertSame(Method::GreaterThan, $result[0]->getMethod()); } public function testDiffByValueNotReference(): void @@ -725,7 +728,7 @@ public function testDiffComplexNested(): void $b = [$nested]; $result = Query::diff($a, $b); $this->assertCount(1, $result); - $this->assertEquals('limit', $result[0]->getMethod()); + $this->assertSame(Method::Limit, $result[0]->getMethod()); } // ── validate() additional edge cases ── @@ -879,13 +882,15 @@ public function testGetByTypeWithNewTypes(): void Query::groupBy(['status']), ]; - $aggs = Query::getByType($queries, Query::AGGREGATE_TYPES); + $aggTypes = array_values(array_filter(Method::cases(), fn (Method $m) => $m->isAggregate())); + $aggs = Query::getByType($queries, $aggTypes); $this->assertCount(2, $aggs); - $joins = Query::getByType($queries, Query::JOIN_TYPES); + $joinTypes = array_values(array_filter(Method::cases(), fn (Method $m) => $m->isJoin())); + $joins = Query::getByType($queries, $joinTypes); $this->assertCount(1, $joins); - $distinct = Query::getByType($queries, [Query::TYPE_DISTINCT]); + $distinct = Query::getByType($queries, [Method::Distinct]); $this->assertCount(1, $distinct); } } diff --git a/tests/Query/QueryParseTest.php b/tests/Query/QueryParseTest.php index c6d2b34..fa9d738 100644 --- a/tests/Query/QueryParseTest.php +++ b/tests/Query/QueryParseTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Utopia\Query\Exception; +use Utopia\Query\Method; use Utopia\Query\Query; class QueryParseTest extends TestCase @@ -12,7 +13,7 @@ public function testParseValidJson(): void { $json = '{"method":"equal","attribute":"name","values":["John"]}'; $query = Query::parse($json); - $this->assertEquals('equal', $query->getMethod()); + $this->assertSame(Method::Equal, $query->getMethod()); $this->assertEquals('name', $query->getAttribute()); $this->assertEquals(['John'], $query->getValues()); } @@ -56,7 +57,7 @@ public function testParseWithDefaultValues(): void { $json = '{"method":"isNull"}'; $query = Query::parse($json); - $this->assertEquals('isNull', $query->getMethod()); + $this->assertSame(Method::IsNull, $query->getMethod()); $this->assertEquals('', $query->getAttribute()); $this->assertEquals([], $query->getValues()); } @@ -68,7 +69,7 @@ public function testParseQueryFromArray(): void 'attribute' => 'name', 'values' => ['John'], ]); - $this->assertEquals('equal', $query->getMethod()); + $this->assertSame(Method::Equal, $query->getMethod()); } public function testParseNestedLogicalQuery(): void @@ -83,7 +84,7 @@ public function testParseNestedLogicalQuery(): void ]); $query = Query::parse($json); - $this->assertEquals(Query::TYPE_OR, $query->getMethod()); + $this->assertSame(Method::Or, $query->getMethod()); $this->assertCount(2, $query->getValues()); $this->assertInstanceOf(Query::class, $query->getValues()[0]); $this->assertEquals('John', $query->getValues()[0]->getValue()); @@ -96,8 +97,8 @@ public function testParseQueries(): void '{"method":"limit","values":[25]}', ]); $this->assertCount(2, $queries); - $this->assertEquals('equal', $queries[0]->getMethod()); - $this->assertEquals('limit', $queries[1]->getMethod()); + $this->assertSame(Method::Equal, $queries[0]->getMethod()); + $this->assertSame(Method::Limit, $queries[1]->getMethod()); } public function testToArray(): void @@ -195,7 +196,7 @@ public function testRoundTripCount(): void $original = Query::count('id', 'total'); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('count', $parsed->getMethod()); + $this->assertSame(Method::Count, $parsed->getMethod()); $this->assertEquals('id', $parsed->getAttribute()); $this->assertEquals(['total'], $parsed->getValues()); } @@ -205,7 +206,7 @@ public function testRoundTripSum(): void $original = Query::sum('price'); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('sum', $parsed->getMethod()); + $this->assertSame(Method::Sum, $parsed->getMethod()); $this->assertEquals('price', $parsed->getAttribute()); } @@ -214,7 +215,7 @@ public function testRoundTripGroupBy(): void $original = Query::groupBy(['status', 'country']); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('groupBy', $parsed->getMethod()); + $this->assertSame(Method::GroupBy, $parsed->getMethod()); $this->assertEquals(['status', 'country'], $parsed->getValues()); } @@ -223,7 +224,7 @@ public function testRoundTripHaving(): void $original = Query::having([Query::greaterThan('total', 5)]); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('having', $parsed->getMethod()); + $this->assertSame(Method::Having, $parsed->getMethod()); $this->assertCount(1, $parsed->getValues()); $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); } @@ -233,7 +234,7 @@ public function testRoundTripDistinct(): void $original = Query::distinct(); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('distinct', $parsed->getMethod()); + $this->assertSame(Method::Distinct, $parsed->getMethod()); } public function testRoundTripJoin(): void @@ -241,7 +242,7 @@ public function testRoundTripJoin(): void $original = Query::join('orders', 'users.id', 'orders.user_id'); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('join', $parsed->getMethod()); + $this->assertSame(Method::Join, $parsed->getMethod()); $this->assertEquals('orders', $parsed->getAttribute()); $this->assertEquals(['users.id', '=', 'orders.user_id'], $parsed->getValues()); } @@ -251,7 +252,7 @@ public function testRoundTripCrossJoin(): void $original = Query::crossJoin('colors'); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('crossJoin', $parsed->getMethod()); + $this->assertSame(Method::CrossJoin, $parsed->getMethod()); $this->assertEquals('colors', $parsed->getAttribute()); } @@ -260,7 +261,7 @@ public function testRoundTripRaw(): void $original = Query::raw('score > ?', [10]); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('raw', $parsed->getMethod()); + $this->assertSame(Method::Raw, $parsed->getMethod()); $this->assertEquals('score > ?', $parsed->getAttribute()); $this->assertEquals([10], $parsed->getValues()); } @@ -270,7 +271,7 @@ public function testRoundTripUnion(): void $original = Query::union([Query::equal('x', [1])]); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('union', $parsed->getMethod()); + $this->assertSame(Method::Union, $parsed->getMethod()); $this->assertCount(1, $parsed->getValues()); $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); } @@ -286,7 +287,7 @@ public function testRoundTripAvg(): void $original = Query::avg('score', 'avg_score'); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('avg', $parsed->getMethod()); + $this->assertSame(Method::Avg, $parsed->getMethod()); $this->assertEquals('score', $parsed->getAttribute()); $this->assertEquals(['avg_score'], $parsed->getValues()); } @@ -296,7 +297,7 @@ public function testRoundTripMin(): void $original = Query::min('price'); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('min', $parsed->getMethod()); + $this->assertSame(Method::Min, $parsed->getMethod()); $this->assertEquals('price', $parsed->getAttribute()); $this->assertEquals([], $parsed->getValues()); } @@ -306,7 +307,7 @@ public function testRoundTripMax(): void $original = Query::max('age', 'oldest'); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('max', $parsed->getMethod()); + $this->assertSame(Method::Max, $parsed->getMethod()); $this->assertEquals(['oldest'], $parsed->getValues()); } @@ -315,7 +316,7 @@ public function testRoundTripCountWithoutAlias(): void $original = Query::count('id'); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('count', $parsed->getMethod()); + $this->assertSame(Method::Count, $parsed->getMethod()); $this->assertEquals('id', $parsed->getAttribute()); $this->assertEquals([], $parsed->getValues()); } @@ -325,7 +326,7 @@ public function testRoundTripGroupByEmpty(): void $original = Query::groupBy([]); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('groupBy', $parsed->getMethod()); + $this->assertSame(Method::GroupBy, $parsed->getMethod()); $this->assertEquals([], $parsed->getValues()); } @@ -347,7 +348,7 @@ public function testRoundTripLeftJoin(): void $original = Query::leftJoin('profiles', 'u.id', 'p.uid'); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('leftJoin', $parsed->getMethod()); + $this->assertSame(Method::LeftJoin, $parsed->getMethod()); $this->assertEquals('profiles', $parsed->getAttribute()); $this->assertEquals(['u.id', '=', 'p.uid'], $parsed->getValues()); } @@ -357,7 +358,7 @@ public function testRoundTripRightJoin(): void $original = Query::rightJoin('orders', 'u.id', 'o.uid'); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('rightJoin', $parsed->getMethod()); + $this->assertSame(Method::RightJoin, $parsed->getMethod()); } public function testRoundTripJoinWithSpecialOperator(): void @@ -373,7 +374,7 @@ public function testRoundTripUnionAll(): void $original = Query::unionAll([Query::equal('y', [2])]); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('unionAll', $parsed->getMethod()); + $this->assertSame(Method::UnionAll, $parsed->getMethod()); $this->assertCount(1, $parsed->getValues()); $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); } @@ -383,7 +384,7 @@ public function testRoundTripRawNoBindings(): void $original = Query::raw('1 = 1'); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('raw', $parsed->getMethod()); + $this->assertSame(Method::Raw, $parsed->getMethod()); $this->assertEquals('1 = 1', $parsed->getAttribute()); $this->assertEquals([], $parsed->getValues()); } @@ -409,12 +410,12 @@ public function testRoundTripComplexNested(): void ]); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals('or', $parsed->getMethod()); + $this->assertSame(Method::Or, $parsed->getMethod()); $this->assertCount(1, $parsed->getValues()); /** @var Query $inner */ $inner = $parsed->getValues()[0]; - $this->assertEquals('and', $inner->getMethod()); + $this->assertSame(Method::And, $inner->getMethod()); $this->assertCount(2, $inner->getValues()); } @@ -455,7 +456,7 @@ public function testParseMissingValuesDefaultsToEmpty(): void public function testParseExtraFieldsIgnored(): void { $query = Query::parse('{"method":"equal","attribute":"x","values":[1],"extra":"ignored"}'); - $this->assertEquals('equal', $query->getMethod()); + $this->assertSame(Method::Equal, $query->getMethod()); $this->assertEquals('x', $query->getAttribute()); } @@ -565,10 +566,10 @@ public function testParseQueriesWithNewTypes(): void '{"method":"join","attribute":"orders","values":["u.id","=","o.uid"]}', ]); $this->assertCount(4, $queries); - $this->assertEquals('count', $queries[0]->getMethod()); - $this->assertEquals('groupBy', $queries[1]->getMethod()); - $this->assertEquals('distinct', $queries[2]->getMethod()); - $this->assertEquals('join', $queries[3]->getMethod()); + $this->assertSame(Method::Count, $queries[0]->getMethod()); + $this->assertSame(Method::GroupBy, $queries[1]->getMethod()); + $this->assertSame(Method::Distinct, $queries[2]->getMethod()); + $this->assertSame(Method::Join, $queries[3]->getMethod()); } // ── toString edge cases ── diff --git a/tests/Query/QueryTest.php b/tests/Query/QueryTest.php index adb01af..dda79f1 100644 --- a/tests/Query/QueryTest.php +++ b/tests/Query/QueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; class QueryTest extends TestCase @@ -10,7 +11,7 @@ class QueryTest extends TestCase public function testConstructorDefaults(): void { $query = new Query('equal'); - $this->assertEquals('equal', $query->getMethod()); + $this->assertSame(Method::Equal, $query->getMethod()); $this->assertEquals('', $query->getAttribute()); $this->assertEquals([], $query->getValues()); } @@ -18,26 +19,26 @@ public function testConstructorDefaults(): void public function testConstructorWithAllParams(): void { $query = new Query('equal', 'name', ['John']); - $this->assertEquals('equal', $query->getMethod()); + $this->assertSame(Method::Equal, $query->getMethod()); $this->assertEquals('name', $query->getAttribute()); $this->assertEquals(['John'], $query->getValues()); } public function testConstructorOrderAscDefaultAttribute(): void { - $query = new Query(Query::TYPE_ORDER_ASC); + $query = new Query(Method::OrderAsc); $this->assertEquals('', $query->getAttribute()); } public function testConstructorOrderDescDefaultAttribute(): void { - $query = new Query(Query::TYPE_ORDER_DESC); + $query = new Query(Method::OrderDesc); $this->assertEquals('', $query->getAttribute()); } public function testConstructorOrderAscWithAttribute(): void { - $query = new Query(Query::TYPE_ORDER_ASC, 'name'); + $query = new Query(Method::OrderAsc, 'name'); $this->assertEquals('name', $query->getAttribute()); } @@ -63,7 +64,7 @@ public function testSetMethod(): void { $query = new Query('equal', 'name', ['John']); $result = $query->setMethod('notEqual'); - $this->assertEquals('notEqual', $query->getMethod()); + $this->assertSame(Method::NotEqual, $query->getMethod()); $this->assertSame($query, $result); } @@ -106,31 +107,32 @@ public function testOnArray(): void $this->assertTrue($query->onArray()); } - public function testConstants(): void + public function testMethodEnumValues(): void { - $this->assertEquals('ASC', Query::ORDER_ASC); - $this->assertEquals('DESC', Query::ORDER_DESC); - $this->assertEquals('RANDOM', Query::ORDER_RANDOM); - $this->assertEquals('after', Query::CURSOR_AFTER); - $this->assertEquals('before', Query::CURSOR_BEFORE); + $this->assertEquals('ASC', \Utopia\Query\OrderDirection::Asc->value); + $this->assertEquals('DESC', \Utopia\Query\OrderDirection::Desc->value); + $this->assertEquals('RANDOM', \Utopia\Query\OrderDirection::Random->value); + $this->assertEquals('after', \Utopia\Query\CursorDirection::After->value); + $this->assertEquals('before', \Utopia\Query\CursorDirection::Before->value); } - public function testVectorTypesConstant(): void + public function testVectorMethodsAreVector(): void { - $this->assertContains(Query::TYPE_VECTOR_DOT, Query::VECTOR_TYPES); - $this->assertContains(Query::TYPE_VECTOR_COSINE, Query::VECTOR_TYPES); - $this->assertContains(Query::TYPE_VECTOR_EUCLIDEAN, Query::VECTOR_TYPES); - $this->assertCount(3, Query::VECTOR_TYPES); + $this->assertTrue(Method::VectorDot->isVector()); + $this->assertTrue(Method::VectorCosine->isVector()); + $this->assertTrue(Method::VectorEuclidean->isVector()); + $vectorMethods = array_filter(Method::cases(), fn (Method $m) => $m->isVector()); + $this->assertCount(3, $vectorMethods); } - public function testTypesConstantContainsAll(): void + public function testAllMethodCasesAreValid(): void { - $this->assertContains(Query::TYPE_EQUAL, Query::TYPES); - $this->assertContains(Query::TYPE_REGEX, Query::TYPES); - $this->assertContains(Query::TYPE_AND, Query::TYPES); - $this->assertContains(Query::TYPE_OR, Query::TYPES); - $this->assertContains(Query::TYPE_ELEM_MATCH, Query::TYPES); - $this->assertContains(Query::TYPE_VECTOR_DOT, Query::TYPES); + $this->assertTrue(Query::isMethod(Method::Equal->value)); + $this->assertTrue(Query::isMethod(Method::Regex->value)); + $this->assertTrue(Query::isMethod(Method::And->value)); + $this->assertTrue(Query::isMethod(Method::Or->value)); + $this->assertTrue(Query::isMethod(Method::ElemMatch->value)); + $this->assertTrue(Query::isMethod(Method::VectorDot->value)); } public function testEmptyValues(): void @@ -139,23 +141,23 @@ public function testEmptyValues(): void $this->assertEquals([], $query->getValues()); } - public function testTypesConstantContainsNewTypes(): void + public function testMethodContainsNewTypes(): void { - $this->assertContains(Query::TYPE_COUNT, Query::TYPES); - $this->assertContains(Query::TYPE_SUM, Query::TYPES); - $this->assertContains(Query::TYPE_AVG, Query::TYPES); - $this->assertContains(Query::TYPE_MIN, Query::TYPES); - $this->assertContains(Query::TYPE_MAX, Query::TYPES); - $this->assertContains(Query::TYPE_GROUP_BY, Query::TYPES); - $this->assertContains(Query::TYPE_HAVING, Query::TYPES); - $this->assertContains(Query::TYPE_DISTINCT, Query::TYPES); - $this->assertContains(Query::TYPE_JOIN, Query::TYPES); - $this->assertContains(Query::TYPE_LEFT_JOIN, Query::TYPES); - $this->assertContains(Query::TYPE_RIGHT_JOIN, Query::TYPES); - $this->assertContains(Query::TYPE_CROSS_JOIN, Query::TYPES); - $this->assertContains(Query::TYPE_UNION, Query::TYPES); - $this->assertContains(Query::TYPE_UNION_ALL, Query::TYPES); - $this->assertContains(Query::TYPE_RAW, Query::TYPES); + $this->assertSame(Method::Count, Method::from('count')); + $this->assertSame(Method::Sum, Method::from('sum')); + $this->assertSame(Method::Avg, Method::from('avg')); + $this->assertSame(Method::Min, Method::from('min')); + $this->assertSame(Method::Max, Method::from('max')); + $this->assertSame(Method::GroupBy, Method::from('groupBy')); + $this->assertSame(Method::Having, Method::from('having')); + $this->assertSame(Method::Distinct, Method::from('distinct')); + $this->assertSame(Method::Join, Method::from('join')); + $this->assertSame(Method::LeftJoin, Method::from('leftJoin')); + $this->assertSame(Method::RightJoin, Method::from('rightJoin')); + $this->assertSame(Method::CrossJoin, Method::from('crossJoin')); + $this->assertSame(Method::Union, Method::from('union')); + $this->assertSame(Method::UnionAll, Method::from('unionAll')); + $this->assertSame(Method::Raw, Method::from('raw')); } public function testIsMethodNewTypes(): void @@ -180,7 +182,7 @@ public function testIsMethodNewTypes(): void public function testDistinctFactory(): void { $query = Query::distinct(); - $this->assertEquals(Query::TYPE_DISTINCT, $query->getMethod()); + $this->assertSame(Method::Distinct, $query->getMethod()); $this->assertEquals('', $query->getAttribute()); $this->assertEquals([], $query->getValues()); } @@ -188,7 +190,7 @@ public function testDistinctFactory(): void public function testRawFactory(): void { $query = Query::raw('score > ?', [10]); - $this->assertEquals(Query::TYPE_RAW, $query->getMethod()); + $this->assertSame(Method::Raw, $query->getMethod()); $this->assertEquals('score > ?', $query->getAttribute()); $this->assertEquals([10], $query->getValues()); } @@ -197,7 +199,7 @@ public function testUnionFactory(): void { $inner = [Query::equal('x', [1])]; $query = Query::union($inner); - $this->assertEquals(Query::TYPE_UNION, $query->getMethod()); + $this->assertSame(Method::Union, $query->getMethod()); $this->assertCount(1, $query->getValues()); } @@ -205,39 +207,46 @@ public function testUnionAllFactory(): void { $inner = [Query::equal('x', [1])]; $query = Query::unionAll($inner); - $this->assertEquals(Query::TYPE_UNION_ALL, $query->getMethod()); + $this->assertSame(Method::UnionAll, $query->getMethod()); } // ══════════════════════════════════════════ // ADDITIONAL EDGE CASES // ══════════════════════════════════════════ - public function testTypesNoDuplicates(): void + public function testMethodNoDuplicateValues(): void { - $this->assertEquals(count(Query::TYPES), count(array_unique(Query::TYPES))); + $values = array_map(fn (Method $m) => $m->value, Method::cases()); + $this->assertEquals(count($values), count(array_unique($values))); } - public function testAggregateTypesNoDuplicates(): void + public function testAggregateMethodsNoDuplicates(): void { - $this->assertEquals(count(Query::AGGREGATE_TYPES), count(array_unique(Query::AGGREGATE_TYPES))); + $aggMethods = array_filter(Method::cases(), fn (Method $m) => $m->isAggregate()); + $values = array_map(fn (Method $m) => $m->value, $aggMethods); + $this->assertEquals(count($values), count(array_unique($values))); } - public function testJoinTypesNoDuplicates(): void + public function testJoinMethodsNoDuplicates(): void { - $this->assertEquals(count(Query::JOIN_TYPES), count(array_unique(Query::JOIN_TYPES))); + $joinMethods = array_filter(Method::cases(), fn (Method $m) => $m->isJoin()); + $values = array_map(fn (Method $m) => $m->value, $joinMethods); + $this->assertEquals(count($values), count(array_unique($values))); } - public function testAggregateTypesSubsetOfTypes(): void + public function testAggregateMethodsAreValidMethods(): void { - foreach (Query::AGGREGATE_TYPES as $type) { - $this->assertContains($type, Query::TYPES); + $aggMethods = array_filter(Method::cases(), fn (Method $m) => $m->isAggregate()); + foreach ($aggMethods as $method) { + $this->assertSame($method, Method::from($method->value)); } } - public function testJoinTypesSubsetOfTypes(): void + public function testJoinMethodsAreValidMethods(): void { - foreach (Query::JOIN_TYPES as $type) { - $this->assertContains($type, Query::TYPES); + $joinMethods = array_filter(Method::cases(), fn (Method $m) => $m->isJoin()); + foreach ($joinMethods as $method) { + $this->assertSame($method, Method::from($method->value)); } } @@ -324,7 +333,7 @@ public function testCloneDeepCopiesHavingQueries(): void /** @var Query $clonedInner */ $clonedInner = $clonedValues[0]; - $this->assertEquals('greaterThan', $clonedInner->getMethod()); + $this->assertSame(Method::GreaterThan, $clonedInner->getMethod()); } public function testCloneDeepCopiesUnionQueries(): void @@ -337,79 +346,79 @@ public function testCloneDeepCopiesUnionQueries(): void $this->assertNotSame($inner, $clonedValues[0]); } - public function testCountConstantValue(): void + public function testCountEnumValue(): void { - $this->assertEquals('count', Query::TYPE_COUNT); + $this->assertEquals('count', Method::Count->value); } - public function testSumConstantValue(): void + public function testSumEnumValue(): void { - $this->assertEquals('sum', Query::TYPE_SUM); + $this->assertEquals('sum', Method::Sum->value); } - public function testAvgConstantValue(): void + public function testAvgEnumValue(): void { - $this->assertEquals('avg', Query::TYPE_AVG); + $this->assertEquals('avg', Method::Avg->value); } - public function testMinConstantValue(): void + public function testMinEnumValue(): void { - $this->assertEquals('min', Query::TYPE_MIN); + $this->assertEquals('min', Method::Min->value); } - public function testMaxConstantValue(): void + public function testMaxEnumValue(): void { - $this->assertEquals('max', Query::TYPE_MAX); + $this->assertEquals('max', Method::Max->value); } - public function testGroupByConstantValue(): void + public function testGroupByEnumValue(): void { - $this->assertEquals('groupBy', Query::TYPE_GROUP_BY); + $this->assertEquals('groupBy', Method::GroupBy->value); } - public function testHavingConstantValue(): void + public function testHavingEnumValue(): void { - $this->assertEquals('having', Query::TYPE_HAVING); + $this->assertEquals('having', Method::Having->value); } - public function testDistinctConstantValue(): void + public function testDistinctEnumValue(): void { - $this->assertEquals('distinct', Query::TYPE_DISTINCT); + $this->assertEquals('distinct', Method::Distinct->value); } - public function testJoinConstantValue(): void + public function testJoinEnumValue(): void { - $this->assertEquals('join', Query::TYPE_JOIN); + $this->assertEquals('join', Method::Join->value); } - public function testLeftJoinConstantValue(): void + public function testLeftJoinEnumValue(): void { - $this->assertEquals('leftJoin', Query::TYPE_LEFT_JOIN); + $this->assertEquals('leftJoin', Method::LeftJoin->value); } - public function testRightJoinConstantValue(): void + public function testRightJoinEnumValue(): void { - $this->assertEquals('rightJoin', Query::TYPE_RIGHT_JOIN); + $this->assertEquals('rightJoin', Method::RightJoin->value); } - public function testCrossJoinConstantValue(): void + public function testCrossJoinEnumValue(): void { - $this->assertEquals('crossJoin', Query::TYPE_CROSS_JOIN); + $this->assertEquals('crossJoin', Method::CrossJoin->value); } - public function testUnionConstantValue(): void + public function testUnionEnumValue(): void { - $this->assertEquals('union', Query::TYPE_UNION); + $this->assertEquals('union', Method::Union->value); } - public function testUnionAllConstantValue(): void + public function testUnionAllEnumValue(): void { - $this->assertEquals('unionAll', Query::TYPE_UNION_ALL); + $this->assertEquals('unionAll', Method::UnionAll->value); } - public function testRawConstantValue(): void + public function testRawEnumValue(): void { - $this->assertEquals('raw', Query::TYPE_RAW); + $this->assertEquals('raw', Method::Raw->value); } public function testCountIsSpatialQueryFalse(): void diff --git a/tests/Query/SelectionQueryTest.php b/tests/Query/SelectionQueryTest.php index 582cd23..ad5f4b5 100644 --- a/tests/Query/SelectionQueryTest.php +++ b/tests/Query/SelectionQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; class SelectionQueryTest extends TestCase @@ -10,14 +11,14 @@ class SelectionQueryTest extends TestCase public function testSelect(): void { $query = Query::select(['name', 'email']); - $this->assertEquals(Query::TYPE_SELECT, $query->getMethod()); + $this->assertSame(Method::Select, $query->getMethod()); $this->assertEquals(['name', 'email'], $query->getValues()); } public function testOrderAsc(): void { $query = Query::orderAsc('name'); - $this->assertEquals(Query::TYPE_ORDER_ASC, $query->getMethod()); + $this->assertSame(Method::OrderAsc, $query->getMethod()); $this->assertEquals('name', $query->getAttribute()); } @@ -30,7 +31,7 @@ public function testOrderAscNoAttribute(): void public function testOrderDesc(): void { $query = Query::orderDesc('name'); - $this->assertEquals(Query::TYPE_ORDER_DESC, $query->getMethod()); + $this->assertSame(Method::OrderDesc, $query->getMethod()); $this->assertEquals('name', $query->getAttribute()); } @@ -43,34 +44,34 @@ public function testOrderDescNoAttribute(): void public function testOrderRandom(): void { $query = Query::orderRandom(); - $this->assertEquals(Query::TYPE_ORDER_RANDOM, $query->getMethod()); + $this->assertSame(Method::OrderRandom, $query->getMethod()); } public function testLimit(): void { $query = Query::limit(25); - $this->assertEquals(Query::TYPE_LIMIT, $query->getMethod()); + $this->assertSame(Method::Limit, $query->getMethod()); $this->assertEquals([25], $query->getValues()); } public function testOffset(): void { $query = Query::offset(10); - $this->assertEquals(Query::TYPE_OFFSET, $query->getMethod()); + $this->assertSame(Method::Offset, $query->getMethod()); $this->assertEquals([10], $query->getValues()); } public function testCursorAfter(): void { $query = Query::cursorAfter('doc123'); - $this->assertEquals(Query::TYPE_CURSOR_AFTER, $query->getMethod()); + $this->assertSame(Method::CursorAfter, $query->getMethod()); $this->assertEquals(['doc123'], $query->getValues()); } public function testCursorBefore(): void { $query = Query::cursorBefore('doc123'); - $this->assertEquals(Query::TYPE_CURSOR_BEFORE, $query->getMethod()); + $this->assertSame(Method::CursorBefore, $query->getMethod()); $this->assertEquals(['doc123'], $query->getValues()); } } diff --git a/tests/Query/SpatialQueryTest.php b/tests/Query/SpatialQueryTest.php index c65984e..f94f503 100644 --- a/tests/Query/SpatialQueryTest.php +++ b/tests/Query/SpatialQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; class SpatialQueryTest extends TestCase @@ -10,7 +11,7 @@ class SpatialQueryTest extends TestCase public function testDistanceEqual(): void { $query = Query::distanceEqual('location', [1.0, 2.0], 100); - $this->assertEquals(Query::TYPE_DISTANCE_EQUAL, $query->getMethod()); + $this->assertSame(Method::DistanceEqual, $query->getMethod()); $this->assertEquals([[[1.0, 2.0], 100, false]], $query->getValues()); } @@ -23,67 +24,67 @@ public function testDistanceEqualWithMeters(): void public function testDistanceNotEqual(): void { $query = Query::distanceNotEqual('location', [1.0, 2.0], 100); - $this->assertEquals(Query::TYPE_DISTANCE_NOT_EQUAL, $query->getMethod()); + $this->assertSame(Method::DistanceNotEqual, $query->getMethod()); } public function testDistanceGreaterThan(): void { $query = Query::distanceGreaterThan('location', [1.0, 2.0], 100); - $this->assertEquals(Query::TYPE_DISTANCE_GREATER_THAN, $query->getMethod()); + $this->assertSame(Method::DistanceGreaterThan, $query->getMethod()); } public function testDistanceLessThan(): void { $query = Query::distanceLessThan('location', [1.0, 2.0], 100); - $this->assertEquals(Query::TYPE_DISTANCE_LESS_THAN, $query->getMethod()); + $this->assertSame(Method::DistanceLessThan, $query->getMethod()); } public function testIntersects(): void { $query = Query::intersects('geo', [[0, 0], [1, 1]]); - $this->assertEquals(Query::TYPE_INTERSECTS, $query->getMethod()); + $this->assertSame(Method::Intersects, $query->getMethod()); $this->assertEquals([[[0, 0], [1, 1]]], $query->getValues()); } public function testNotIntersects(): void { $query = Query::notIntersects('geo', [[0, 0]]); - $this->assertEquals(Query::TYPE_NOT_INTERSECTS, $query->getMethod()); + $this->assertSame(Method::NotIntersects, $query->getMethod()); } public function testCrosses(): void { $query = Query::crosses('geo', [[0, 0]]); - $this->assertEquals(Query::TYPE_CROSSES, $query->getMethod()); + $this->assertSame(Method::Crosses, $query->getMethod()); } public function testNotCrosses(): void { $query = Query::notCrosses('geo', [[0, 0]]); - $this->assertEquals(Query::TYPE_NOT_CROSSES, $query->getMethod()); + $this->assertSame(Method::NotCrosses, $query->getMethod()); } public function testOverlaps(): void { $query = Query::overlaps('geo', [[0, 0]]); - $this->assertEquals(Query::TYPE_OVERLAPS, $query->getMethod()); + $this->assertSame(Method::Overlaps, $query->getMethod()); } public function testNotOverlaps(): void { $query = Query::notOverlaps('geo', [[0, 0]]); - $this->assertEquals(Query::TYPE_NOT_OVERLAPS, $query->getMethod()); + $this->assertSame(Method::NotOverlaps, $query->getMethod()); } public function testTouches(): void { $query = Query::touches('geo', [[0, 0]]); - $this->assertEquals(Query::TYPE_TOUCHES, $query->getMethod()); + $this->assertSame(Method::Touches, $query->getMethod()); } public function testNotTouches(): void { $query = Query::notTouches('geo', [[0, 0]]); - $this->assertEquals(Query::TYPE_NOT_TOUCHES, $query->getMethod()); + $this->assertSame(Method::NotTouches, $query->getMethod()); } } diff --git a/tests/Query/VectorQueryTest.php b/tests/Query/VectorQueryTest.php index 40cf24b..8593e92 100644 --- a/tests/Query/VectorQueryTest.php +++ b/tests/Query/VectorQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; class VectorQueryTest extends TestCase @@ -11,7 +12,7 @@ public function testVectorDot(): void { $vector = [0.1, 0.2, 0.3]; $query = Query::vectorDot('embedding', $vector); - $this->assertEquals(Query::TYPE_VECTOR_DOT, $query->getMethod()); + $this->assertSame(Method::VectorDot, $query->getMethod()); $this->assertEquals([$vector], $query->getValues()); } @@ -19,13 +20,13 @@ public function testVectorCosine(): void { $vector = [0.1, 0.2, 0.3]; $query = Query::vectorCosine('embedding', $vector); - $this->assertEquals(Query::TYPE_VECTOR_COSINE, $query->getMethod()); + $this->assertSame(Method::VectorCosine, $query->getMethod()); } public function testVectorEuclidean(): void { $vector = [0.1, 0.2, 0.3]; $query = Query::vectorEuclidean('embedding', $vector); - $this->assertEquals(Query::TYPE_VECTOR_EUCLIDEAN, $query->getMethod()); + $this->assertSame(Method::VectorEuclidean, $query->getMethod()); } } From 1c5afd6be079c694874444e5aada51d86c1e72c1 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 5 Mar 2026 14:16:44 +1300 Subject: [PATCH 15/29] (refactor): Move BuildResult, UnionClause, Condition, GroupedQueries into Builder namespace --- README.md | 62 +++++++++++++------------ src/Query/Builder/BuildResult.php | 15 ++++++ src/Query/{ => Builder}/Condition.php | 8 ++-- src/Query/Builder/GroupedQueries.php | 39 ++++++++++++++++ src/Query/Builder/UnionClause.php | 16 +++++++ src/Query/Hook/FilterHook.php | 2 +- src/Query/Hook/PermissionFilterHook.php | 2 +- src/Query/Hook/TenantFilterHook.php | 2 +- tests/Query/ConditionTest.php | 2 +- 9 files changed, 111 insertions(+), 37 deletions(-) create mode 100644 src/Query/Builder/BuildResult.php rename src/Query/{ => Builder}/Condition.php (71%) create mode 100644 src/Query/Builder/GroupedQueries.php create mode 100644 src/Query/Builder/UnionClause.php diff --git a/README.md b/README.md index 7fca262..46ec309 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ composer require utopia-php/query ```php use Utopia\Query\Query; +use Utopia\Query\Method; +use Utopia\Query\OrderDirection; +use Utopia\Query\CursorDirection; ``` ### Filter Queries @@ -138,7 +141,7 @@ $queries = Query::parseQueries([$json1, $json2, $json3]); ### Grouping Helpers -`groupByType` splits an array of queries into categorized buckets: +`groupByType` splits an array of queries into a `GroupedQueries` object with typed properties: ```php $queries = [ @@ -153,20 +156,20 @@ $queries = [ $grouped = Query::groupByType($queries); -// $grouped['filters'] — filter Query objects -// $grouped['selections'] — select Query objects -// $grouped['limit'] — int|null -// $grouped['offset'] — int|null -// $grouped['orderAttributes'] — ['name'] -// $grouped['orderTypes'] — ['ASC'] -// $grouped['cursor'] — 'abc123' -// $grouped['cursorDirection'] — 'after' +// $grouped->filters — filter Query objects +// $grouped->selections — select Query objects +// $grouped->limit — int|null +// $grouped->offset — int|null +// $grouped->orderAttributes — ['name'] +// $grouped->orderTypes — [OrderDirection::Asc] +// $grouped->cursor — 'abc123' +// $grouped->cursorDirection — CursorDirection::After ``` `getByType` filters queries by one or more method types: ```php -$cursors = Query::getByType($queries, [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); +$cursors = Query::getByType($queries, [Method::CursorAfter, Method::CursorBefore]); ``` ### Building a Compiler @@ -176,20 +179,21 @@ This library ships with a `Compiler` interface so you can translate queries into ```php use Utopia\Query\Compiler; use Utopia\Query\Query; +use Utopia\Query\Method; class SQLCompiler implements Compiler { public function compileFilter(Query $query): string { return match ($query->getMethod()) { - Query::TYPE_EQUAL => $query->getAttribute() . ' IN (' . $this->placeholders($query->getValues()) . ')', - Query::TYPE_NOT_EQUAL => $query->getAttribute() . ' != ?', - Query::TYPE_GREATER => $query->getAttribute() . ' > ?', - Query::TYPE_LESSER => $query->getAttribute() . ' < ?', - Query::TYPE_BETWEEN => $query->getAttribute() . ' BETWEEN ? AND ?', - Query::TYPE_IS_NULL => $query->getAttribute() . ' IS NULL', - Query::TYPE_IS_NOT_NULL => $query->getAttribute() . ' IS NOT NULL', - Query::TYPE_STARTS_WITH => $query->getAttribute() . " LIKE CONCAT(?, '%')", + Method::Equal => $query->getAttribute() . ' IN (' . $this->placeholders($query->getValues()) . ')', + Method::NotEqual => $query->getAttribute() . ' != ?', + Method::GreaterThan => $query->getAttribute() . ' > ?', + Method::LessThan => $query->getAttribute() . ' < ?', + Method::Between => $query->getAttribute() . ' BETWEEN ? AND ?', + Method::IsNull => $query->getAttribute() . ' IS NULL', + Method::IsNotNull => $query->getAttribute() . ' IS NOT NULL', + Method::StartsWith => $query->getAttribute() . " LIKE CONCAT(?, '%')", // ... handle remaining types }; } @@ -197,9 +201,9 @@ class SQLCompiler implements Compiler public function compileOrder(Query $query): string { return match ($query->getMethod()) { - Query::TYPE_ORDER_ASC => $query->getAttribute() . ' ASC', - Query::TYPE_ORDER_DESC => $query->getAttribute() . ' DESC', - Query::TYPE_ORDER_RANDOM => 'RAND()', + Method::OrderAsc => $query->getAttribute() . ' ASC', + Method::OrderDesc => $query->getAttribute() . ' DESC', + Method::OrderRandom => 'RAND()', }; } @@ -249,8 +253,8 @@ class RedisCompiler implements Compiler public function compileFilter(Query $query): string { return match ($query->getMethod()) { - Query::TYPE_BETWEEN => $query->getValues()[0] . ' ' . $query->getValues()[1], - Query::TYPE_GREATER => '(' . $query->getValue() . ' +inf', + Method::Between => $query->getValues()[0] . ' ' . $query->getValues()[1], + Method::GreaterThan => '(' . $query->getValue() . ' +inf', // ... handle remaining types }; } @@ -263,7 +267,7 @@ This is the pattern used by [utopia-php/database](https://github.com/utopia-php/ ### Builder Hierarchy -The library includes a builder system for generating parameterized queries. The abstract `Builder` base class provides the fluent API and query orchestration, while concrete implementations handle dialect-specific compilation: +The library includes a builder system for generating parameterized queries. The `build()` method returns a `BuildResult` object with `->query` and `->bindings` properties. The abstract `Builder` base class provides the fluent API and query orchestration, while concrete implementations handle dialect-specific compilation: - `Utopia\Query\Builder\SQL` — MySQL/MariaDB/SQLite (backtick quoting, `REGEXP`, `MATCH() AGAINST()`, `RAND()`) - `Utopia\Query\Builder\ClickHouse` — ClickHouse (backtick quoting, `match()`, `rand()`, `PREWHERE`, `FINAL`, `SAMPLE`) @@ -287,8 +291,8 @@ $result = (new Builder()) ->offset(0) ->build(); -$result['query']; // SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ? -$result['bindings']; // ['active', 18, 25, 0] +$result->query; // SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ? +$result->bindings; // ['active', 18, 25, 0] ``` **Batch mode** — pass all queries at once: @@ -314,8 +318,8 @@ $result = (new Builder()) ->limit(10) ->build(); -$stmt = $pdo->prepare($result['query']); -$stmt->execute($result['bindings']); +$stmt = $pdo->prepare($result->query); +$stmt->execute($result->bindings); $rows = $stmt->fetchAll(); ``` @@ -466,7 +470,7 @@ Built-in hooks: Custom hooks implement `FilterHook` or `AttributeHook`: ```php -use Utopia\Query\Condition; +use Utopia\Query\Builder\Condition; use Utopia\Query\Hook\FilterHook; class SoftDeleteHook implements FilterHook diff --git a/src/Query/Builder/BuildResult.php b/src/Query/Builder/BuildResult.php new file mode 100644 index 0000000..c0d6318 --- /dev/null +++ b/src/Query/Builder/BuildResult.php @@ -0,0 +1,15 @@ + $bindings + */ + public function __construct( + public string $query, + public array $bindings, + ) { + } +} diff --git a/src/Query/Condition.php b/src/Query/Builder/Condition.php similarity index 71% rename from src/Query/Condition.php rename to src/Query/Builder/Condition.php index 07ecb64..1028c1d 100644 --- a/src/Query/Condition.php +++ b/src/Query/Builder/Condition.php @@ -1,15 +1,15 @@ $bindings */ public function __construct( - protected string $expression, - protected array $bindings = [], + public string $expression, + public array $bindings = [], ) { } diff --git a/src/Query/Builder/GroupedQueries.php b/src/Query/Builder/GroupedQueries.php new file mode 100644 index 0000000..5d3fc3f --- /dev/null +++ b/src/Query/Builder/GroupedQueries.php @@ -0,0 +1,39 @@ + $filters + * @param list $selections + * @param list $aggregations + * @param list $groupBy + * @param list $having + * @param list $joins + * @param list $unions + * @param array $orderAttributes + * @param array $orderTypes + */ + public function __construct( + public array $filters = [], + public array $selections = [], + public array $aggregations = [], + public array $groupBy = [], + public array $having = [], + public bool $distinct = false, + public array $joins = [], + public array $unions = [], + public ?int $limit = null, + public ?int $offset = null, + public array $orderAttributes = [], + public array $orderTypes = [], + public mixed $cursor = null, + public ?CursorDirection $cursorDirection = null, + ) { + } +} diff --git a/src/Query/Builder/UnionClause.php b/src/Query/Builder/UnionClause.php new file mode 100644 index 0000000..61f013f --- /dev/null +++ b/src/Query/Builder/UnionClause.php @@ -0,0 +1,16 @@ + $bindings + */ + public function __construct( + public string $type, + public string $query, + public array $bindings, + ) { + } +} diff --git a/src/Query/Hook/FilterHook.php b/src/Query/Hook/FilterHook.php index ddc232b..d9adbc0 100644 --- a/src/Query/Hook/FilterHook.php +++ b/src/Query/Hook/FilterHook.php @@ -2,7 +2,7 @@ namespace Utopia\Query\Hook; -use Utopia\Query\Condition; +use Utopia\Query\Builder\Condition; use Utopia\Query\Hook; interface FilterHook extends Hook diff --git a/src/Query/Hook/PermissionFilterHook.php b/src/Query/Hook/PermissionFilterHook.php index b2df8ac..4720832 100644 --- a/src/Query/Hook/PermissionFilterHook.php +++ b/src/Query/Hook/PermissionFilterHook.php @@ -2,7 +2,7 @@ namespace Utopia\Query\Hook; -use Utopia\Query\Condition; +use Utopia\Query\Builder\Condition; class PermissionFilterHook implements FilterHook { diff --git a/src/Query/Hook/TenantFilterHook.php b/src/Query/Hook/TenantFilterHook.php index 7575ed2..b42ac4e 100644 --- a/src/Query/Hook/TenantFilterHook.php +++ b/src/Query/Hook/TenantFilterHook.php @@ -2,7 +2,7 @@ namespace Utopia\Query\Hook; -use Utopia\Query\Condition; +use Utopia\Query\Builder\Condition; class TenantFilterHook implements FilterHook { diff --git a/tests/Query/ConditionTest.php b/tests/Query/ConditionTest.php index 4ce3e81..c2b452e 100644 --- a/tests/Query/ConditionTest.php +++ b/tests/Query/ConditionTest.php @@ -3,7 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; -use Utopia\Query\Condition; +use Utopia\Query\Builder\Condition; class ConditionTest extends TestCase { From 4b4d14f1846061ed7efa1742856f4b1139cfa418 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Mar 2026 19:58:39 +1300 Subject: [PATCH 16/29] (chore): Add .idea to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5e20fe1..f77c093 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .phpunit.result.cache composer.phar /vendor/ +.idea From 882c06773005506b9b0ce8be54cae59a1de26e88 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Mar 2026 19:58:43 +1300 Subject: [PATCH 17/29] (refactor): Add QuotesIdentifiers trait, exceptions, and enum updates --- src/Query/Exception/UnsupportedException.php | 9 ++ src/Query/Exception/ValidationException.php | 9 ++ src/Query/Method.php | 83 ++++++++++++-- src/Query/Query.php | 113 ++++++++++++++++--- src/Query/QuotesIdentifiers.php | 18 +++ 5 files changed, 204 insertions(+), 28 deletions(-) create mode 100644 src/Query/Exception/UnsupportedException.php create mode 100644 src/Query/Exception/ValidationException.php create mode 100644 src/Query/QuotesIdentifiers.php diff --git a/src/Query/Exception/UnsupportedException.php b/src/Query/Exception/UnsupportedException.php new file mode 100644 index 0000000..f814a45 --- /dev/null +++ b/src/Query/Exception/UnsupportedException.php @@ -0,0 +1,9 @@ + true, + default => false, + }; + } + public function isSpatial(): bool { return match ($this) { @@ -105,7 +150,32 @@ public function isSpatial(): bool self::Overlaps, self::NotOverlaps, self::Touches, - self::NotTouches => true, + self::NotTouches, + self::Covers, + self::NotCovers, + self::SpatialEquals, + self::NotSpatialEquals => true, + default => false, + }; + } + + public function isVector(): bool + { + return match ($this) { + self::VectorDot, + self::VectorCosine, + self::VectorEuclidean => true, + default => false, + }; + } + + public function isJson(): bool + { + return match ($this) { + self::JsonContains, + self::JsonNotContains, + self::JsonOverlaps, + self::JsonPath => true, default => false, }; } @@ -127,6 +197,7 @@ public function isAggregate(): bool { return match ($this) { self::Count, + self::CountDistinct, self::Sum, self::Avg, self::Min, @@ -145,14 +216,4 @@ public function isJoin(): bool default => false, }; } - - public function isVector(): bool - { - return match ($this) { - self::VectorDot, - self::VectorCosine, - self::VectorEuclidean => true, - default => false, - }; - } } diff --git a/src/Query/Query.php b/src/Query/Query.php index 8a6e2b4..9f09de3 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -242,31 +242,23 @@ public function compile(Compiler $compiler): string Method::OrderAsc, Method::OrderDesc, Method::OrderRandom => $compiler->compileOrder($this), - Method::Limit => $compiler->compileLimit($this), - Method::Offset => $compiler->compileOffset($this), - Method::CursorAfter, Method::CursorBefore => $compiler->compileCursor($this), - Method::Select => $compiler->compileSelect($this), - Method::Count, + Method::CountDistinct, Method::Sum, Method::Avg, Method::Min, Method::Max => $compiler->compileAggregate($this), - Method::GroupBy => $compiler->compileGroupBy($this), - Method::Join, Method::LeftJoin, Method::RightJoin, Method::CrossJoin => $compiler->compileJoin($this), - Method::Having => $compiler->compileFilter($this), - default => $compiler->compileFilter($this), }; } @@ -693,6 +685,7 @@ public static function groupByType(array $queries): GroupedQueries break; case Method::Count: + case Method::CountDistinct: case Method::Sum: case Method::Avg: case Method::Min: @@ -974,6 +967,11 @@ public static function count(string $attribute = '*', string $alias = ''): stati return new static(Method::Count, $attribute, $alias !== '' ? [$alias] : []); } + public static function countDistinct(string $attribute, string $alias = ''): static + { + return new static(Method::CountDistinct, $attribute, $alias !== '' ? [$alias] : []); + } + public static function sum(string $attribute, string $alias = ''): static { return new static(Method::Sum, $attribute, $alias !== '' ? [$alias] : []); @@ -1017,24 +1015,39 @@ public static function distinct(): static // Join factory methods - public static function join(string $table, string $left, string $right, string $operator = '='): static + public static function join(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static { - return new static(Method::Join, $table, [$left, $operator, $right]); + $values = [$left, $operator, $right]; + if ($alias !== '') { + $values[] = $alias; + } + + return new static(Method::Join, $table, $values); } - public static function leftJoin(string $table, string $left, string $right, string $operator = '='): static + public static function leftJoin(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static { - return new static(Method::LeftJoin, $table, [$left, $operator, $right]); + $values = [$left, $operator, $right]; + if ($alias !== '') { + $values[] = $alias; + } + + return new static(Method::LeftJoin, $table, $values); } - public static function rightJoin(string $table, string $left, string $right, string $operator = '='): static + public static function rightJoin(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static { - return new static(Method::RightJoin, $table, [$left, $operator, $right]); + $values = [$left, $operator, $right]; + if ($alias !== '') { + $values[] = $alias; + } + + return new static(Method::RightJoin, $table, $values); } - public static function crossJoin(string $table): static + public static function crossJoin(string $table, string $alias = ''): static { - return new static(Method::CrossJoin, $table); + return new static(Method::CrossJoin, $table, $alias !== '' ? [$alias] : []); } // Union factory methods @@ -1055,6 +1068,65 @@ public static function unionAll(array $queries): static return new static(Method::UnionAll, '', $queries); } + // JSON factory methods + + public static function jsonContains(string $attribute, mixed $value): static + { + return new static(Method::JsonContains, $attribute, [$value]); + } + + public static function jsonNotContains(string $attribute, mixed $value): static + { + return new static(Method::JsonNotContains, $attribute, [$value]); + } + + /** + * @param array $values + */ + public static function jsonOverlaps(string $attribute, array $values): static + { + return new static(Method::JsonOverlaps, $attribute, [$values]); + } + + public static function jsonPath(string $attribute, string $path, string $operator, mixed $value): static + { + return new static(Method::JsonPath, $attribute, [$path, $operator, $value]); + } + + // Spatial predicate extras + + /** + * @param array $values + */ + public static function covers(string $attribute, array $values): static + { + return new static(Method::Covers, $attribute, [$values]); + } + + /** + * @param array $values + */ + public static function notCovers(string $attribute, array $values): static + { + return new static(Method::NotCovers, $attribute, [$values]); + } + + /** + * @param array $values + */ + public static function spatialEquals(string $attribute, array $values): static + { + return new static(Method::SpatialEquals, $attribute, [$values]); + } + + /** + * @param array $values + */ + public static function notSpatialEquals(string $attribute, array $values): static + { + return new static(Method::NotSpatialEquals, $attribute, [$values]); + } + // Raw factory method /** @@ -1074,6 +1146,13 @@ public static function raw(string $sql, array $bindings = []): static */ public static function page(int $page, int $perPage = 25): array { + if ($page < 1) { + throw new \Utopia\Query\Exception\ValidationException('Page must be >= 1, got ' . $page); + } + if ($perPage < 1) { + throw new \Utopia\Query\Exception\ValidationException('Per page must be >= 1, got ' . $perPage); + } + return [ static::limit($perPage), static::offset(($page - 1) * $perPage), diff --git a/src/Query/QuotesIdentifiers.php b/src/Query/QuotesIdentifiers.php new file mode 100644 index 0000000..2f30151 --- /dev/null +++ b/src/Query/QuotesIdentifiers.php @@ -0,0 +1,18 @@ + $segment === '*' + ? '*' + : $this->wrapChar . \str_replace($this->wrapChar, $this->wrapChar . $this->wrapChar, $segment) . $this->wrapChar, $segments); + + return \implode('.', $wrapped); + } +} From 8911cb7ef18e1dd7f51d70185125c12a5293650c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Mar 2026 19:58:48 +1300 Subject: [PATCH 18/29] (refactor): Reorganize hook system into Attribute, Filter, and Join namespaces --- .../Hook/{AttributeHook.php => Attribute.php} | 2 +- .../Map.php} | 8 +- src/Query/Hook/{FilterHook.php => Filter.php} | 2 +- src/Query/Hook/Filter/Permission.php | 93 +++++++++++++++++++ src/Query/Hook/Filter/Tenant.php | 50 ++++++++++ src/Query/Hook/Join/Condition.php | 14 +++ src/Query/Hook/Join/Filter.php | 10 ++ src/Query/Hook/Join/Placement.php | 9 ++ src/Query/Hook/PermissionFilterHook.php | 33 ------- src/Query/Hook/TenantFilterHook.php | 27 ------ 10 files changed, 183 insertions(+), 65 deletions(-) rename src/Query/Hook/{AttributeHook.php => Attribute.php} (76%) rename src/Query/Hook/{AttributeMapHook.php => Attribute/Map.php} (53%) rename src/Query/Hook/{FilterHook.php => Filter.php} (82%) create mode 100644 src/Query/Hook/Filter/Permission.php create mode 100644 src/Query/Hook/Filter/Tenant.php create mode 100644 src/Query/Hook/Join/Condition.php create mode 100644 src/Query/Hook/Join/Filter.php create mode 100644 src/Query/Hook/Join/Placement.php delete mode 100644 src/Query/Hook/PermissionFilterHook.php delete mode 100644 src/Query/Hook/TenantFilterHook.php diff --git a/src/Query/Hook/AttributeHook.php b/src/Query/Hook/Attribute.php similarity index 76% rename from src/Query/Hook/AttributeHook.php rename to src/Query/Hook/Attribute.php index 47e6d7a..220e1be 100644 --- a/src/Query/Hook/AttributeHook.php +++ b/src/Query/Hook/Attribute.php @@ -4,7 +4,7 @@ use Utopia\Query\Hook; -interface AttributeHook extends Hook +interface Attribute extends Hook { public function resolve(string $attribute): string; } diff --git a/src/Query/Hook/AttributeMapHook.php b/src/Query/Hook/Attribute/Map.php similarity index 53% rename from src/Query/Hook/AttributeMapHook.php rename to src/Query/Hook/Attribute/Map.php index 7e93d47..6718884 100644 --- a/src/Query/Hook/AttributeMapHook.php +++ b/src/Query/Hook/Attribute/Map.php @@ -1,11 +1,13 @@ $map */ - public function __construct(public array $map) + public function __construct(private array $map) { } diff --git a/src/Query/Hook/FilterHook.php b/src/Query/Hook/Filter.php similarity index 82% rename from src/Query/Hook/FilterHook.php rename to src/Query/Hook/Filter.php index d9adbc0..a6726de 100644 --- a/src/Query/Hook/FilterHook.php +++ b/src/Query/Hook/Filter.php @@ -5,7 +5,7 @@ use Utopia\Query\Builder\Condition; use Utopia\Query\Hook; -interface FilterHook extends Hook +interface Filter extends Hook { public function filter(string $table): Condition; } diff --git a/src/Query/Hook/Filter/Permission.php b/src/Query/Hook/Filter/Permission.php new file mode 100644 index 0000000..288533a --- /dev/null +++ b/src/Query/Hook/Filter/Permission.php @@ -0,0 +1,93 @@ + $roles + * @param \Closure(string): string $permissionsTable Receives the base table name, returns the permissions table name + * @param list|null $columns Column names to check permissions for. NULL rows (wildcard) are always included. + * @param Filter|null $subqueryFilter Optional filter applied inside the permissions subquery (e.g. tenant filtering) + */ + public function __construct( + protected array $roles, + protected \Closure $permissionsTable, + protected string $type = 'read', + protected ?array $columns = null, + protected string $documentColumn = 'id', + protected string $permDocumentColumn = 'document_id', + protected string $permRoleColumn = 'role', + protected string $permTypeColumn = 'type', + protected string $permColumnColumn = 'column', + protected ?Filter $subqueryFilter = null, + ) { + foreach ([$documentColumn, $permDocumentColumn, $permRoleColumn, $permTypeColumn, $permColumnColumn] as $col) { + if (!\preg_match(self::IDENTIFIER_PATTERN, $col)) { + throw new \InvalidArgumentException('Invalid column name: ' . $col); + } + } + } + + public function filter(string $table): Condition + { + if (empty($this->roles)) { + return new Condition('1 = 0'); + } + + /** @var string $permTable */ + $permTable = ($this->permissionsTable)($table); + + if (!\preg_match(self::IDENTIFIER_PATTERN, $permTable)) { + throw new \InvalidArgumentException('Invalid permissions table name: ' . $permTable); + } + + $rolePlaceholders = \implode(', ', \array_fill(0, \count($this->roles), '?')); + + $columnClause = ''; + $columnBindings = []; + + if ($this->columns !== null) { + if (empty($this->columns)) { + $columnClause = " AND {$this->permColumnColumn} IS NULL"; + } else { + $colPlaceholders = \implode(', ', \array_fill(0, \count($this->columns), '?')); + $columnClause = " AND ({$this->permColumnColumn} IS NULL OR {$this->permColumnColumn} IN ({$colPlaceholders}))"; + $columnBindings = $this->columns; + } + } + + $subFilterClause = ''; + $subFilterBindings = []; + if ($this->subqueryFilter !== null) { + $subCondition = $this->subqueryFilter->filter($permTable); + $subFilterClause = ' AND ' . $subCondition->getExpression(); + $subFilterBindings = $subCondition->getBindings(); + } + + return new Condition( + "{$this->documentColumn} IN (SELECT DISTINCT {$this->permDocumentColumn} FROM {$permTable} WHERE {$this->permRoleColumn} IN ({$rolePlaceholders}) AND {$this->permTypeColumn} = ?{$columnClause}{$subFilterClause})", + [...$this->roles, $this->type, ...$columnBindings, ...$subFilterBindings], + ); + } + + public function filterJoin(string $table, string $joinType): ?JoinCondition + { + $condition = $this->filter($table); + + $placement = match ($joinType) { + 'LEFT JOIN', 'RIGHT JOIN' => Placement::On, + default => Placement::Where, + }; + + return new JoinCondition($condition, $placement); + } +} diff --git a/src/Query/Hook/Filter/Tenant.php b/src/Query/Hook/Filter/Tenant.php new file mode 100644 index 0000000..fc65856 --- /dev/null +++ b/src/Query/Hook/Filter/Tenant.php @@ -0,0 +1,50 @@ + $tenantIds + */ + public function __construct( + protected array $tenantIds, + protected string $column = 'tenant_id', + ) { + if (!\preg_match('/^[a-zA-Z_][a-zA-Z0-9_.]*$/', $column)) { + throw new \InvalidArgumentException('Invalid column name: ' . $column); + } + } + + public function filter(string $table): Condition + { + if (empty($this->tenantIds)) { + return new Condition('1 = 0'); + } + + $placeholders = implode(', ', array_fill(0, count($this->tenantIds), '?')); + + return new Condition( + "{$this->column} IN ({$placeholders})", + $this->tenantIds, + ); + } + + public function filterJoin(string $table, string $joinType): ?JoinCondition + { + $condition = $this->filter($table); + + $placement = match ($joinType) { + 'LEFT JOIN', 'RIGHT JOIN' => Placement::On, + default => Placement::Where, + }; + + return new JoinCondition($condition, $placement); + } +} diff --git a/src/Query/Hook/Join/Condition.php b/src/Query/Hook/Join/Condition.php new file mode 100644 index 0000000..517feb8 --- /dev/null +++ b/src/Query/Hook/Join/Condition.php @@ -0,0 +1,14 @@ + $roles - */ - public function __construct( - protected string $namespace, - protected array $roles, - protected string $type = 'read', - protected string $documentColumn = '_uid', - ) { - } - - public function filter(string $table): Condition - { - if (empty($this->roles)) { - return new Condition('1 = 0'); - } - - $placeholders = implode(', ', array_fill(0, count($this->roles), '?')); - - return new Condition( - "{$this->documentColumn} IN (SELECT DISTINCT _document FROM {$this->namespace}_{$table}_perms WHERE _permission IN ({$placeholders}) AND _type = ?)", - [...$this->roles, $this->type], - ); - } -} diff --git a/src/Query/Hook/TenantFilterHook.php b/src/Query/Hook/TenantFilterHook.php deleted file mode 100644 index b42ac4e..0000000 --- a/src/Query/Hook/TenantFilterHook.php +++ /dev/null @@ -1,27 +0,0 @@ - $tenantIds - */ - public function __construct( - protected array $tenantIds, - protected string $column = '_tenant', - ) { - } - - public function filter(string $table): Condition - { - $placeholders = implode(', ', array_fill(0, count($this->tenantIds), '?')); - - return new Condition( - "{$this->column} IN ({$placeholders})", - $this->tenantIds, - ); - } -} From b18b9f5d01b915a15a680edd3ca22f2707d7af8e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Mar 2026 19:58:53 +1300 Subject: [PATCH 19/29] (feat): Add abstract Builder with feature interfaces, Case builder, and JoinBuilder --- src/Query/Builder.php | 1203 +++++++++++++++++--- src/Query/Builder/Case/Builder.php | 94 ++ src/Query/Builder/Case/Expression.php | 23 + src/Query/Builder/Feature/Aggregates.php | 28 + src/Query/Builder/Feature/CTEs.php | 12 + src/Query/Builder/Feature/Deletes.php | 12 + src/Query/Builder/Feature/Hints.php | 8 + src/Query/Builder/Feature/Hooks.php | 10 + src/Query/Builder/Feature/Inserts.php | 31 + src/Query/Builder/Feature/Joins.php | 19 + src/Query/Builder/Feature/Json.php | 47 + src/Query/Builder/Feature/Locking.php | 18 + src/Query/Builder/Feature/LockingOf.php | 10 + src/Query/Builder/Feature/Returning.php | 11 + src/Query/Builder/Feature/Selects.php | 62 + src/Query/Builder/Feature/Spatial.php | 71 ++ src/Query/Builder/Feature/Transactions.php | 20 + src/Query/Builder/Feature/Unions.php | 20 + src/Query/Builder/Feature/Updates.php | 22 + src/Query/Builder/Feature/Upsert.php | 12 + src/Query/Builder/Feature/VectorSearch.php | 14 + src/Query/Builder/Feature/Windows.php | 16 + src/Query/Builder/JoinBuilder.php | 86 ++ src/Query/Builder/SQL.php | 167 ++- 24 files changed, 1858 insertions(+), 158 deletions(-) create mode 100644 src/Query/Builder/Case/Builder.php create mode 100644 src/Query/Builder/Case/Expression.php create mode 100644 src/Query/Builder/Feature/Aggregates.php create mode 100644 src/Query/Builder/Feature/CTEs.php create mode 100644 src/Query/Builder/Feature/Deletes.php create mode 100644 src/Query/Builder/Feature/Hints.php create mode 100644 src/Query/Builder/Feature/Hooks.php create mode 100644 src/Query/Builder/Feature/Inserts.php create mode 100644 src/Query/Builder/Feature/Joins.php create mode 100644 src/Query/Builder/Feature/Json.php create mode 100644 src/Query/Builder/Feature/Locking.php create mode 100644 src/Query/Builder/Feature/LockingOf.php create mode 100644 src/Query/Builder/Feature/Returning.php create mode 100644 src/Query/Builder/Feature/Selects.php create mode 100644 src/Query/Builder/Feature/Spatial.php create mode 100644 src/Query/Builder/Feature/Transactions.php create mode 100644 src/Query/Builder/Feature/Unions.php create mode 100644 src/Query/Builder/Feature/Updates.php create mode 100644 src/Query/Builder/Feature/Upsert.php create mode 100644 src/Query/Builder/Feature/VectorSearch.php create mode 100644 src/Query/Builder/Feature/Windows.php create mode 100644 src/Query/Builder/JoinBuilder.php diff --git a/src/Query/Builder.php b/src/Query/Builder.php index a300730..e16b22b 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -4,15 +4,35 @@ use Closure; use Utopia\Query\Builder\BuildResult; +use Utopia\Query\Builder\Case\Expression as CaseExpression; +use Utopia\Query\Builder\Feature; use Utopia\Query\Builder\GroupedQueries; +use Utopia\Query\Builder\JoinBuilder; use Utopia\Query\Builder\UnionClause; -use Utopia\Query\Hook\AttributeHook; -use Utopia\Query\Hook\FilterHook; - -abstract class Builder implements Compiler +use Utopia\Query\Exception\UnsupportedException; +use Utopia\Query\Exception\ValidationException; +use Utopia\Query\Hook\Attribute; +use Utopia\Query\Hook\Filter; +use Utopia\Query\Hook\Join\Filter as JoinFilter; +use Utopia\Query\Hook\Join\Placement; + +abstract class Builder implements + Compiler, + Feature\Selects, + Feature\Aggregates, + Feature\Joins, + Feature\Unions, + Feature\CTEs, + Feature\Inserts, + Feature\Updates, + Feature\Deletes, + Feature\Hooks, + Feature\Windows { protected string $table = ''; + protected string $tableAlias = ''; + /** * @var array */ @@ -28,15 +48,83 @@ abstract class Builder implements Compiler */ protected array $unions = []; - /** @var list */ + /** @var list */ protected array $filterHooks = []; - /** @var list */ + /** @var list */ protected array $attributeHooks = []; - // ── Abstract (dialect-specific) ── + /** @var list */ + protected array $joinFilterHooks = []; + + /** @var list> */ + protected array $pendingRows = []; + + /** @var array */ + protected array $rawSets = []; + + /** @var array> */ + protected array $rawSetBindings = []; + + protected ?string $lockMode = null; + + protected ?Builder $insertSelectSource = null; + + /** @var list */ + protected array $insertSelectColumns = []; + + /** @var list, recursive: bool}> */ + protected array $ctes = []; + + /** @var list}> */ + protected array $rawSelects = []; + + /** @var list, orderBy: ?list}> */ + protected array $windowSelects = []; + + /** @var list}> */ + protected array $caseSelects = []; + + /** @var array}> */ + protected array $caseSets = []; + + /** @var string[] */ + protected array $conflictKeys = []; + + /** @var string[] */ + protected array $conflictUpdateColumns = []; + + /** @var array */ + protected array $conflictRawSets = []; - abstract protected function wrapIdentifier(string $identifier): string; + /** @var array> */ + protected array $conflictRawSetBindings = []; + + /** @var list */ + protected array $whereInSubqueries = []; + + /** @var list */ + protected array $subSelects = []; + + /** @var ?array{subquery: Builder, alias: string} */ + protected ?array $fromSubquery = null; + + /** @var list}> */ + protected array $rawOrders = []; + + /** @var list}> */ + protected array $rawGroups = []; + + /** @var list}> */ + protected array $rawHavings = []; + + /** @var array */ + protected array $joinBuilders = []; + + /** @var list */ + protected array $existsSubqueries = []; + + abstract protected function quote(string $identifier): string; /** * Compile a random ordering expression (e.g. RAND() or rand()) @@ -57,11 +145,25 @@ abstract protected function compileRegex(string $attribute, array $values): stri */ abstract protected function compileSearch(string $attribute, array $values, bool $not): string; - // ── Hooks (overridable) ── - protected function buildTableClause(): string { - return 'FROM ' . $this->wrapIdentifier($this->table); + $fromSub = $this->fromSubquery; + if ($fromSub !== null) { + $subResult = $fromSub['subquery']->build(); + foreach ($subResult->bindings as $binding) { + $this->addBinding($binding); + } + + return 'FROM (' . $subResult->query . ') AS ' . $this->quote($fromSub['alias']); + } + + $sql = 'FROM ' . $this->quote($this->table); + + if ($this->tableAlias !== '') { + $sql .= ' AS ' . $this->quote($this->tableAlias); + } + + return $sql; } /** @@ -74,15 +176,186 @@ protected function buildAfterJoins(array &$parts, GroupedQueries $grouped): void // no-op by default } - // ── Fluent API ── + public function from(string $table, string $alias = ''): static + { + $this->table = $table; + $this->tableAlias = $alias; + $this->fromSubquery = null; + + return $this; + } - public function from(string $table): static + public function into(string $table): static { $this->table = $table; return $this; } + /** + * @param array $row + */ + public function set(array $row): static + { + $this->pendingRows[] = $row; + + return $this; + } + + /** + * @param list $bindings + */ + public function setRaw(string $column, string $expression, array $bindings = []): static + { + $this->rawSets[$column] = $expression; + $this->rawSetBindings[$column] = $bindings; + + return $this; + } + + /** + * @param string[] $keys + * @param string[] $updateColumns + */ + public function onConflict(array $keys, array $updateColumns): static + { + $this->conflictKeys = $keys; + $this->conflictUpdateColumns = $updateColumns; + + return $this; + } + + /** + * @param list $bindings + */ + public function conflictSetRaw(string $column, string $expression, array $bindings = []): static + { + $this->conflictRawSets[$column] = $expression; + $this->conflictRawSetBindings[$column] = $bindings; + + return $this; + } + + public function filterWhereIn(string $column, Builder $subquery): static + { + $this->whereInSubqueries[] = ['column' => $column, 'subquery' => $subquery, 'not' => false]; + + return $this; + } + + public function filterWhereNotIn(string $column, Builder $subquery): static + { + $this->whereInSubqueries[] = ['column' => $column, 'subquery' => $subquery, 'not' => true]; + + return $this; + } + + public function selectSub(Builder $subquery, string $alias): static + { + $this->subSelects[] = ['subquery' => $subquery, 'alias' => $alias]; + + return $this; + } + + public function fromSub(Builder $subquery, string $alias): static + { + $this->fromSubquery = ['subquery' => $subquery, 'alias' => $alias]; + $this->table = ''; + + return $this; + } + + /** + * @param list $bindings + */ + public function orderByRaw(string $expression, array $bindings = []): static + { + $this->rawOrders[] = ['expression' => $expression, 'bindings' => $bindings]; + + return $this; + } + + /** + * @param list $bindings + */ + public function groupByRaw(string $expression, array $bindings = []): static + { + $this->rawGroups[] = ['expression' => $expression, 'bindings' => $bindings]; + + return $this; + } + + /** + * @param list $bindings + */ + public function havingRaw(string $expression, array $bindings = []): static + { + $this->rawHavings[] = ['expression' => $expression, 'bindings' => $bindings]; + + return $this; + } + + public function countDistinct(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::countDistinct($attribute, $alias); + + return $this; + } + + /** + * @param \Closure(JoinBuilder): void $callback + */ + public function joinWhere(string $table, Closure $callback, string $type = 'JOIN', string $alias = ''): static + { + $joinBuilder = new JoinBuilder(); + $callback($joinBuilder); + + $method = match ($type) { + 'LEFT JOIN' => Method::LeftJoin, + 'RIGHT JOIN' => Method::RightJoin, + 'CROSS JOIN' => Method::CrossJoin, + default => Method::Join, + }; + + if ($method === Method::CrossJoin) { + $this->pendingQueries[] = new Query($method, $table, $alias !== '' ? [$alias] : []); + } else { + // Use placeholder values; the JoinBuilder will handle the ON clause + $values = ['', '=', '']; + if ($alias !== '') { + $values[] = $alias; + } + $this->pendingQueries[] = new Query($method, $table, $values); + } + + $index = \count($this->pendingQueries) - 1; + $this->joinBuilders[$index] = $joinBuilder; + + return $this; + } + + public function filterExists(Builder $subquery): static + { + $this->existsSubqueries[] = ['subquery' => $subquery, 'not' => false]; + + return $this; + } + + public function filterNotExists(Builder $subquery): static + { + $this->existsSubqueries[] = ['subquery' => $subquery, 'not' => true]; + + return $this; + } + + public function explain(bool $analyze = false): BuildResult + { + $result = $this->build(); + $prefix = $analyze ? 'EXPLAIN ANALYZE ' : 'EXPLAIN '; + + return new BuildResult($prefix . $result->query, $result->bindings); + } + /** * @param array $columns */ @@ -168,18 +441,19 @@ public function queries(array $queries): static public function addHook(Hook $hook): static { - if ($hook instanceof FilterHook) { + if ($hook instanceof Filter) { $this->filterHooks[] = $hook; } - if ($hook instanceof AttributeHook) { + if ($hook instanceof Attribute) { $this->attributeHooks[] = $hook; } + if ($hook instanceof JoinFilter) { + $this->joinFilterHooks[] = $hook; + } return $this; } - // ── Aggregation fluent API ── - public function count(string $attribute = '*', string $alias = ''): static { $this->pendingQueries[] = Query::count($attribute, $alias); @@ -242,38 +516,34 @@ public function distinct(): static return $this; } - // ── Join fluent API ── - - public function join(string $table, string $left, string $right, string $operator = '='): static + public function join(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static { - $this->pendingQueries[] = Query::join($table, $left, $right, $operator); + $this->pendingQueries[] = Query::join($table, $left, $right, $operator, $alias); return $this; } - public function leftJoin(string $table, string $left, string $right, string $operator = '='): static + public function leftJoin(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static { - $this->pendingQueries[] = Query::leftJoin($table, $left, $right, $operator); + $this->pendingQueries[] = Query::leftJoin($table, $left, $right, $operator, $alias); return $this; } - public function rightJoin(string $table, string $left, string $right, string $operator = '='): static + public function rightJoin(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static { - $this->pendingQueries[] = Query::rightJoin($table, $left, $right, $operator); + $this->pendingQueries[] = Query::rightJoin($table, $left, $right, $operator, $alias); return $this; } - public function crossJoin(string $table): static + public function crossJoin(string $table, string $alias = ''): static { - $this->pendingQueries[] = Query::crossJoin($table); + $this->pendingQueries[] = Query::crossJoin($table, $alias); return $this; } - // ── Union fluent API ── - public function union(self $other): static { $result = $other->build(); @@ -290,7 +560,131 @@ public function unionAll(self $other): static return $this; } - // ── Convenience methods ── + public function intersect(self $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause('INTERSECT', $result->query, $result->bindings); + + return $this; + } + + public function intersectAll(self $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause('INTERSECT ALL', $result->query, $result->bindings); + + return $this; + } + + public function except(self $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause('EXCEPT', $result->query, $result->bindings); + + return $this; + } + + public function exceptAll(self $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause('EXCEPT ALL', $result->query, $result->bindings); + + return $this; + } + + /** + * @param list $columns + */ + public function fromSelect(array $columns, self $source): static + { + $this->insertSelectColumns = $columns; + $this->insertSelectSource = $source; + + return $this; + } + + public function insertSelect(): BuildResult + { + $this->bindings = []; + $this->validateTable(); + + if ($this->insertSelectSource === null) { + throw new ValidationException('No SELECT source specified. Call fromSelect() before insertSelect().'); + } + + if (empty($this->insertSelectColumns)) { + throw new ValidationException('No columns specified. Call fromSelect() with columns before insertSelect().'); + } + + $wrappedColumns = \array_map( + fn (string $col): string => $this->resolveAndWrap($col), + $this->insertSelectColumns + ); + + $sourceResult = $this->insertSelectSource->build(); + + $sql = 'INSERT INTO ' . $this->quote($this->table) + . ' (' . \implode(', ', $wrappedColumns) . ')' + . ' ' . $sourceResult->query; + + foreach ($sourceResult->bindings as $binding) { + $this->addBinding($binding); + } + + return new BuildResult($sql, $this->bindings); + } + + public function with(string $name, self $query): static + { + $result = $query->build(); + $this->ctes[] = ['name' => $name, 'query' => $result->query, 'bindings' => $result->bindings, 'recursive' => false]; + + return $this; + } + + public function withRecursive(string $name, self $query): static + { + $result = $query->build(); + $this->ctes[] = ['name' => $name, 'query' => $result->query, 'bindings' => $result->bindings, 'recursive' => true]; + + return $this; + } + + /** + * @param list $bindings + */ + public function selectRaw(string $expression, array $bindings = []): static + { + $this->rawSelects[] = ['expression' => $expression, 'bindings' => $bindings]; + + return $this; + } + + public function selectWindow(string $function, string $alias, ?array $partitionBy = null, ?array $orderBy = null): static + { + $this->windowSelects[] = [ + 'function' => $function, + 'alias' => $alias, + 'partitionBy' => $partitionBy, + 'orderBy' => $orderBy, + ]; + + return $this; + } + + public function selectCase(CaseExpression $case): static + { + $this->caseSelects[] = ['sql' => $case->sql, 'bindings' => $case->bindings]; + + return $this; + } + + public function setCase(string $column, CaseExpression $case): static + { + $this->caseSets[$column] = ['sql' => $case->sql, 'bindings' => $case->bindings]; + + return $this; + } public function when(bool $condition, Closure $callback): static { @@ -303,8 +697,15 @@ public function when(bool $condition, Closure $callback): static public function page(int $page, int $perPage = 25): static { + if ($page < 1) { + throw new ValidationException('Page must be >= 1, got ' . $page); + } + if ($perPage < 1) { + throw new ValidationException('Per page must be >= 1, got ' . $perPage); + } + $this->pendingQueries[] = Query::limit($perPage); - $this->pendingQueries[] = Query::offset(max(0, ($page - 1) * $perPage)); + $this->pendingQueries[] = Query::offset(($page - 1) * $perPage); return $this; } @@ -339,6 +740,25 @@ public function toRawSql(): string public function build(): BuildResult { $this->bindings = []; + $this->validateTable(); + + // CTE prefix + $ctePrefix = ''; + if (! empty($this->ctes)) { + $hasRecursive = false; + $cteParts = []; + foreach ($this->ctes as $cte) { + if ($cte['recursive']) { + $hasRecursive = true; + } + foreach ($cte['bindings'] as $binding) { + $this->addBinding($binding); + } + $cteParts[] = $this->quote($cte['name']) . ' AS (' . $cte['query'] . ')'; + } + $keyword = $hasRecursive ? 'WITH RECURSIVE' : 'WITH'; + $ctePrefix = $keyword . ' ' . \implode(', ', $cteParts) . ' '; + } $grouped = Query::groupByType($this->pendingQueries); @@ -357,6 +777,59 @@ public function build(): BuildResult $selectParts[] = $this->compileSelect($grouped->selections[0]); } + // Sub-selects + foreach ($this->subSelects as $subSelect) { + $subResult = $subSelect['subquery']->build(); + $selectParts[] = '(' . $subResult->query . ') AS ' . $this->quote($subSelect['alias']); + foreach ($subResult->bindings as $binding) { + $this->addBinding($binding); + } + } + + // Raw selects + foreach ($this->rawSelects as $rawSelect) { + $selectParts[] = $rawSelect['expression']; + foreach ($rawSelect['bindings'] as $binding) { + $this->addBinding($binding); + } + } + + // Window function selects + foreach ($this->windowSelects as $win) { + $overParts = []; + + if ($win['partitionBy'] !== null && $win['partitionBy'] !== []) { + $partCols = \array_map( + fn (string $col): string => $this->resolveAndWrap($col), + $win['partitionBy'] + ); + $overParts[] = 'PARTITION BY ' . \implode(', ', $partCols); + } + + if ($win['orderBy'] !== null && $win['orderBy'] !== []) { + $orderCols = []; + foreach ($win['orderBy'] as $col) { + if (\str_starts_with($col, '-')) { + $orderCols[] = $this->resolveAndWrap(\substr($col, 1)) . ' DESC'; + } else { + $orderCols[] = $this->resolveAndWrap($col) . ' ASC'; + } + } + $overParts[] = 'ORDER BY ' . \implode(', ', $orderCols); + } + + $overClause = \implode(' ', $overParts); + $selectParts[] = $win['function'] . ' OVER (' . $overClause . ') AS ' . $this->quote($win['alias']); + } + + // CASE selects + foreach ($this->caseSelects as $caseSelect) { + $selectParts[] = $caseSelect['sql']; + foreach ($caseSelect['bindings'] as $binding) { + $this->addBinding($binding); + } + } + $selectSQL = ! empty($selectParts) ? \implode(', ', $selectParts) : '*'; $selectKeyword = $grouped->distinct ? 'SELECT DISTINCT' : 'SELECT'; @@ -366,11 +839,57 @@ public function build(): BuildResult $parts[] = $this->buildTableClause(); // JOINS + $joinFilterWhereClauses = []; if (! empty($grouped->joins)) { - foreach ($grouped->joins as $joinQuery) { - $parts[] = $this->compileJoin($joinQuery); + // Build a map from pending query index to join index for JoinBuilder lookup + $joinQueryIndices = []; + foreach ($this->pendingQueries as $idx => $pq) { + if ($pq->getMethod()->isJoin()) { + $joinQueryIndices[] = $idx; + } } - } + + foreach ($grouped->joins as $joinIdx => $joinQuery) { + $pendingIdx = $joinQueryIndices[$joinIdx] ?? -1; + $joinBuilder = $this->joinBuilders[$pendingIdx] ?? null; + + if ($joinBuilder !== null) { + $joinSQL = $this->compileJoinWithBuilder($joinQuery, $joinBuilder); + } else { + $joinSQL = $this->compileJoin($joinQuery); + } + + $joinTable = $joinQuery->getAttribute(); + $joinType = match ($joinQuery->getMethod()) { + Method::Join => 'JOIN', + Method::LeftJoin => 'LEFT JOIN', + Method::RightJoin => 'RIGHT JOIN', + Method::CrossJoin => 'CROSS JOIN', + default => 'JOIN', + }; + $isCrossJoin = $joinQuery->getMethod() === Method::CrossJoin; + + foreach ($this->joinFilterHooks as $hook) { + $result = $hook->filterJoin($joinTable, $joinType); + if ($result === null) { + continue; + } + + $placement = $this->resolveJoinFilterPlacement($result->placement, $isCrossJoin); + + if ($placement === Placement::On) { + $joinSQL .= ' AND ' . $result->condition->getExpression(); + foreach ($result->condition->getBindings() as $binding) { + $this->addBinding($binding); + } + } else { + $joinFilterWhereClauses[] = $result->condition; + } + } + + $parts[] = $joinSQL; + } + } // Hook: after joins (e.g. ClickHouse PREWHERE) $this->buildAfterJoins($parts, $grouped); @@ -390,6 +909,33 @@ public function build(): BuildResult } } + foreach ($joinFilterWhereClauses as $condition) { + $whereClauses[] = $condition->getExpression(); + foreach ($condition->getBindings() as $binding) { + $this->addBinding($binding); + } + } + + // WHERE IN subqueries + foreach ($this->whereInSubqueries as $sub) { + $subResult = $sub['subquery']->build(); + $prefix = $sub['not'] ? 'NOT IN' : 'IN'; + $whereClauses[] = $this->resolveAndWrap($sub['column']) . ' ' . $prefix . ' (' . $subResult->query . ')'; + foreach ($subResult->bindings as $binding) { + $this->addBinding($binding); + } + } + + // EXISTS subqueries + foreach ($this->existsSubqueries as $sub) { + $subResult = $sub['subquery']->build(); + $prefix = $sub['not'] ? 'NOT EXISTS' : 'EXISTS'; + $whereClauses[] = $prefix . ' (' . $subResult->query . ')'; + foreach ($subResult->bindings as $binding) { + $this->addBinding($binding); + } + } + $cursorSQL = ''; if ($grouped->cursor !== null && $grouped->cursorDirection !== null) { $cursorQueries = Query::getCursorQueries($this->pendingQueries, false); @@ -406,30 +952,55 @@ public function build(): BuildResult } // GROUP BY + $groupByParts = []; if (! empty($grouped->groupBy)) { $groupByCols = \array_map( fn (string $col): string => $this->resolveAndWrap($col), $grouped->groupBy ); - $parts[] = 'GROUP BY ' . \implode(', ', $groupByCols); + $groupByParts = $groupByCols; + } + foreach ($this->rawGroups as $rawGroup) { + $groupByParts[] = $rawGroup['expression']; + foreach ($rawGroup['bindings'] as $binding) { + $this->addBinding($binding); + } + } + if (! empty($groupByParts)) { + $parts[] = 'GROUP BY ' . \implode(', ', $groupByParts); } // HAVING + $havingClauses = []; if (! empty($grouped->having)) { - $havingClauses = []; foreach ($grouped->having as $havingQuery) { foreach ($havingQuery->getValues() as $subQuery) { /** @var Query $subQuery */ $havingClauses[] = $this->compileFilter($subQuery); } } - if (! empty($havingClauses)) { - $parts[] = 'HAVING ' . \implode(' AND ', $havingClauses); + } + foreach ($this->rawHavings as $rawHaving) { + $havingClauses[] = $rawHaving['expression']; + foreach ($rawHaving['bindings'] as $binding) { + $this->addBinding($binding); } } + if (! empty($havingClauses)) { + $parts[] = 'HAVING ' . \implode(' AND ', $havingClauses); + } // ORDER BY $orderClauses = []; + + $vectorOrderExpr = $this->compileVectorOrderExpr(); + if ($vectorOrderExpr !== null) { + $orderClauses[] = $vectorOrderExpr['expression']; + foreach ($vectorOrderExpr['bindings'] as $binding) { + $this->addBinding($binding); + } + } + $orderQueries = Query::getByType($this->pendingQueries, [ Method::OrderAsc, Method::OrderDesc, @@ -438,6 +1009,12 @@ public function build(): BuildResult foreach ($orderQueries as $orderQuery) { $orderClauses[] = $this->compileOrder($orderQuery); } + foreach ($this->rawOrders as $rawOrder) { + $orderClauses[] = $rawOrder['expression']; + foreach ($rawOrder['bindings'] as $binding) { + $this->addBinding($binding); + } + } if (! empty($orderClauses)) { $parts[] = 'ORDER BY ' . \implode(', ', $orderClauses); } @@ -448,12 +1025,17 @@ public function build(): BuildResult $this->addBinding($grouped->limit); } - // OFFSET (only emit if LIMIT is also present) - if ($grouped->offset !== null && $grouped->limit !== null) { + // OFFSET + if ($this->shouldEmitOffset($grouped->offset, $grouped->limit)) { $parts[] = 'OFFSET ?'; $this->addBinding($grouped->offset); } + // LOCKING + if ($this->lockMode !== null) { + $parts[] = $this->lockMode; + } + $sql = \implode(' ', $parts); // UNION @@ -467,9 +1049,253 @@ public function build(): BuildResult } } + $sql = $ctePrefix . $sql; + return new BuildResult($sql, $this->bindings); } + /** + * Compile the INSERT INTO ... VALUES portion. + * + * @return array{0: string, 1: list} + */ + protected function compileInsertBody(): array + { + $this->validateTable(); + $this->validateRows('insert'); + $columns = $this->validateAndGetColumns(); + + $wrappedColumns = \array_map(fn (string $col): string => $this->resolveAndWrap($col), $columns); + + $bindings = []; + $rowPlaceholders = []; + foreach ($this->pendingRows as $row) { + $placeholders = []; + foreach ($columns as $col) { + $bindings[] = $row[$col] ?? null; + $placeholders[] = '?'; + } + $rowPlaceholders[] = '(' . \implode(', ', $placeholders) . ')'; + } + + $sql = 'INSERT INTO ' . $this->quote($this->table) + . ' (' . \implode(', ', $wrappedColumns) . ')' + . ' VALUES ' . \implode(', ', $rowPlaceholders); + + return [$sql, $bindings]; + } + + public function insert(): BuildResult + { + $this->bindings = []; + [$sql, $bindings] = $this->compileInsertBody(); + foreach ($bindings as $binding) { + $this->addBinding($binding); + } + + return new BuildResult($sql, $this->bindings); + } + + public function update(): BuildResult + { + $this->bindings = []; + $this->validateTable(); + + $assignments = []; + + if (! empty($this->pendingRows)) { + foreach ($this->pendingRows[0] as $col => $value) { + $assignments[] = $this->resolveAndWrap($col) . ' = ?'; + $this->addBinding($value); + } + } + + foreach ($this->rawSets as $col => $expression) { + $assignments[] = $this->resolveAndWrap($col) . ' = ' . $expression; + if (isset($this->rawSetBindings[$col])) { + foreach ($this->rawSetBindings[$col] as $binding) { + $this->addBinding($binding); + } + } + } + + foreach ($this->caseSets as $col => $caseData) { + $assignments[] = $this->resolveAndWrap($col) . ' = ' . $caseData['sql']; + foreach ($caseData['bindings'] as $binding) { + $this->addBinding($binding); + } + } + + if (empty($assignments)) { + throw new ValidationException('No assignments for UPDATE. Call set() or setRaw() before update().'); + } + + $parts = ['UPDATE ' . $this->quote($this->table) . ' SET ' . \implode(', ', $assignments)]; + + $this->compileWhereClauses($parts); + + $this->compileOrderAndLimit($parts); + + return new BuildResult(\implode(' ', $parts), $this->bindings); + } + + public function delete(): BuildResult + { + $this->bindings = []; + $this->validateTable(); + + $parts = ['DELETE FROM ' . $this->quote($this->table)]; + + $this->compileWhereClauses($parts); + + $this->compileOrderAndLimit($parts); + + return new BuildResult(\implode(' ', $parts), $this->bindings); + } + + /** + * @param array $parts + */ + protected function compileWhereClauses(array &$parts): void + { + $grouped = Query::groupByType($this->pendingQueries); + $whereClauses = []; + + foreach ($grouped->filters as $filter) { + $whereClauses[] = $this->compileFilter($filter); + } + + foreach ($this->filterHooks as $hook) { + $condition = $hook->filter($this->table); + $whereClauses[] = $condition->getExpression(); + foreach ($condition->getBindings() as $binding) { + $this->addBinding($binding); + } + } + + // WHERE IN subqueries + foreach ($this->whereInSubqueries as $sub) { + $subResult = $sub['subquery']->build(); + $prefix = $sub['not'] ? 'NOT IN' : 'IN'; + $whereClauses[] = $this->resolveAndWrap($sub['column']) . ' ' . $prefix . ' (' . $subResult->query . ')'; + foreach ($subResult->bindings as $binding) { + $this->addBinding($binding); + } + } + + // EXISTS subqueries + foreach ($this->existsSubqueries as $sub) { + $subResult = $sub['subquery']->build(); + $prefix = $sub['not'] ? 'NOT EXISTS' : 'EXISTS'; + $whereClauses[] = $prefix . ' (' . $subResult->query . ')'; + foreach ($subResult->bindings as $binding) { + $this->addBinding($binding); + } + } + + if (! empty($whereClauses)) { + $parts[] = 'WHERE ' . \implode(' AND ', $whereClauses); + } + } + + /** + * @param array $parts + */ + protected function compileOrderAndLimit(array &$parts): void + { + $orderClauses = []; + $orderQueries = Query::getByType($this->pendingQueries, [ + Method::OrderAsc, + Method::OrderDesc, + Method::OrderRandom, + ], false); + foreach ($orderQueries as $orderQuery) { + $orderClauses[] = $this->compileOrder($orderQuery); + } + foreach ($this->rawOrders as $rawOrder) { + $orderClauses[] = $rawOrder['expression']; + foreach ($rawOrder['bindings'] as $binding) { + $this->addBinding($binding); + } + } + if (! empty($orderClauses)) { + $parts[] = 'ORDER BY ' . \implode(', ', $orderClauses); + } + + $grouped = Query::groupByType($this->pendingQueries); + if ($grouped->limit !== null) { + $parts[] = 'LIMIT ?'; + $this->addBinding($grouped->limit); + } + } + + protected function shouldEmitOffset(?int $offset, ?int $limit): bool + { + return $offset !== null && $limit !== null; + } + + /** + * Hook for subclasses to inject a vector distance ORDER BY expression. + * + * @return array{expression: string, bindings: list}|null + */ + protected function compileVectorOrderExpr(): ?array + { + return null; + } + + protected function validateTable(): void + { + if ($this->table === '' && $this->fromSubquery === null) { + throw new ValidationException('No table specified. Call from() or into() before building a query.'); + } + } + + protected function validateRows(string $operation): void + { + if (empty($this->pendingRows)) { + throw new ValidationException("No rows to {$operation}. Call set() before {$operation}()."); + } + + foreach ($this->pendingRows as $row) { + if (empty($row)) { + throw new ValidationException('Cannot ' . $operation . ' an empty row. Each set() call must include at least one column.'); + } + } + } + + /** + * Validates that all rows have the same columns and returns the column list. + * + * @return list + */ + protected function validateAndGetColumns(): array + { + $columns = \array_keys($this->pendingRows[0]); + + foreach ($columns as $col) { + if ($col === '') { + throw new ValidationException('Column names must be non-empty strings.'); + } + } + + if (\count($this->pendingRows) > 1) { + $expectedKeys = $columns; + \sort($expectedKeys); + + foreach ($this->pendingRows as $i => $row) { + $rowKeys = \array_keys($row); + \sort($rowKeys); + + if ($rowKeys !== $expectedKeys) { + throw new ValidationException("Row {$i} has different columns than row 0. All rows in a batch must have the same columns."); + } + } + } + + return $columns; + } + /** * @return list */ @@ -483,13 +1309,35 @@ public function reset(): static $this->pendingQueries = []; $this->bindings = []; $this->table = ''; + $this->tableAlias = ''; $this->unions = []; + $this->pendingRows = []; + $this->rawSets = []; + $this->rawSetBindings = []; + $this->conflictKeys = []; + $this->conflictUpdateColumns = []; + $this->conflictRawSets = []; + $this->conflictRawSetBindings = []; + $this->lockMode = null; + $this->insertSelectSource = null; + $this->insertSelectColumns = []; + $this->ctes = []; + $this->rawSelects = []; + $this->windowSelects = []; + $this->caseSelects = []; + $this->caseSets = []; + $this->whereInSubqueries = []; + $this->subSelects = []; + $this->fromSubquery = null; + $this->rawOrders = []; + $this->rawGroups = []; + $this->rawHavings = []; + $this->joinBuilders = []; + $this->existsSubqueries = []; return $this; } - // ── Compiler interface ── - public function compileFilter(Query $query): string { $method = $query->getMethod(); @@ -524,7 +1372,7 @@ public function compileFilter(Query $query): string Method::Exists => $this->compileExists($query), Method::NotExists => $this->compileNotExists($query), Method::Raw => $this->compileRaw($query), - default => throw new Exception('Unsupported filter type: ' . $method->value), + default => throw new UnsupportedException('Unsupported filter type: ' . $method->value), }; } @@ -534,7 +1382,7 @@ public function compileOrder(Query $query): string Method::OrderAsc => $this->resolveAndWrap($query->getAttribute()) . ' ASC', Method::OrderDesc => $this->resolveAndWrap($query->getAttribute()) . ' DESC', Method::OrderRandom => $this->compileRandom(), - default => throw new Exception('Unsupported order type: ' . $query->getMethod()->value), + default => throw new UnsupportedException('Unsupported order type: ' . $query->getMethod()->value), }; } @@ -571,18 +1419,34 @@ public function compileCursor(Query $query): string $operator = $query->getMethod() === Method::CursorAfter ? '>' : '<'; - return $this->wrapIdentifier('_cursor') . ' ' . $operator . ' ?'; + return $this->quote('_cursor') . ' ' . $operator . ' ?'; } public function compileAggregate(Query $query): string { - $func = match ($query->getMethod()) { + $method = $query->getMethod(); + + if ($method === Method::CountDistinct) { + $attr = $query->getAttribute(); + $col = ($attr === '*' || $attr === '') ? '*' : $this->resolveAndWrap($attr); + /** @var string $alias */ + $alias = $query->getValue(''); + $sql = 'COUNT(DISTINCT ' . $col . ')'; + + if ($alias !== '') { + $sql .= ' AS ' . $this->quote($alias); + } + + return $sql; + } + + $func = match ($method) { Method::Count => 'COUNT', Method::Sum => 'SUM', Method::Avg => 'AVG', Method::Min => 'MIN', Method::Max => 'MAX', - default => throw new \InvalidArgumentException("Unknown aggregate: {$query->getMethod()->value}"), + default => throw new ValidationException("Unknown aggregate: {$method->value}"), }; $attr = $query->getAttribute(); $col = ($attr === '*' || $attr === '') ? '*' : $this->resolveAndWrap($attr); @@ -591,7 +1455,7 @@ public function compileAggregate(Query $query): string $sql = $func . '(' . $col . ')'; if ($alias !== '') { - $sql .= ' AS ' . $this->wrapIdentifier($alias); + $sql .= ' AS ' . $this->quote($alias); } return $sql; @@ -616,12 +1480,23 @@ public function compileJoin(Query $query): string Method::LeftJoin => 'LEFT JOIN', Method::RightJoin => 'RIGHT JOIN', Method::CrossJoin => 'CROSS JOIN', - default => throw new Exception('Unsupported join type: ' . $query->getMethod()->value), + default => throw new UnsupportedException('Unsupported join type: ' . $query->getMethod()->value), }; - $table = $this->wrapIdentifier($query->getAttribute()); + $table = $this->quote($query->getAttribute()); $values = $query->getValues(); + // Handle alias for cross join (alias is values[0]) + if ($query->getMethod() === Method::CrossJoin) { + /** @var string $alias */ + $alias = $values[0] ?? ''; + if ($alias !== '') { + $table .= ' AS ' . $this->quote($alias); + } + + return $type . ' ' . $table; + } + if (empty($values)) { return $type . ' ' . $table; } @@ -632,10 +1507,16 @@ public function compileJoin(Query $query): string $operator = $values[1]; /** @var string $rightCol */ $rightCol = $values[2]; + /** @var string $alias */ + $alias = $values[3] ?? ''; + + if ($alias !== '') { + $table .= ' AS ' . $this->quote($alias); + } $allowedOperators = ['=', '!=', '<', '>', '<=', '>=', '<>']; - if (!\in_array($operator, $allowedOperators, true)) { - throw new \InvalidArgumentException('Invalid join operator: ' . $operator); + if (! \in_array($operator, $allowedOperators, true)) { + throw new ValidationException('Invalid join operator: ' . $operator); } $left = $this->resolveAndWrap($leftCol); @@ -644,7 +1525,53 @@ public function compileJoin(Query $query): string return $type . ' ' . $table . ' ON ' . $left . ' ' . $operator . ' ' . $right; } - // ── Protected helpers ── + protected function compileJoinWithBuilder(Query $query, JoinBuilder $joinBuilder): string + { + $type = match ($query->getMethod()) { + Method::Join => 'JOIN', + Method::LeftJoin => 'LEFT JOIN', + Method::RightJoin => 'RIGHT JOIN', + Method::CrossJoin => 'CROSS JOIN', + default => throw new UnsupportedException('Unsupported join type: ' . $query->getMethod()->value), + }; + + $table = $this->quote($query->getAttribute()); + $values = $query->getValues(); + + // Handle alias + if ($query->getMethod() === Method::CrossJoin) { + /** @var string $alias */ + $alias = $values[0] ?? ''; + } else { + /** @var string $alias */ + $alias = $values[3] ?? ''; + } + + if ($alias !== '') { + $table .= ' AS ' . $this->quote($alias); + } + + $onParts = []; + + foreach ($joinBuilder->getOns() as $on) { + $left = $this->resolveAndWrap($on['left']); + $right = $this->resolveAndWrap($on['right']); + $onParts[] = $left . ' ' . $on['operator'] . ' ' . $right; + } + + foreach ($joinBuilder->getWheres() as $where) { + $onParts[] = $where['expression']; + foreach ($where['bindings'] as $binding) { + $this->addBinding($binding); + } + } + + if (empty($onParts)) { + return $type . ' ' . $table; + } + + return $type . ' ' . $table . ' ON ' . \implode(' AND ', $onParts); + } protected function resolveAttribute(string $attribute): string { @@ -657,7 +1584,7 @@ protected function resolveAttribute(string $attribute): string protected function resolveAndWrap(string $attribute): string { - return $this->wrapIdentifier($this->resolveAttribute($attribute)); + return $this->quote($this->resolveAttribute($attribute)); } protected function addBinding(mixed $value): void @@ -665,7 +1592,94 @@ protected function addBinding(mixed $value): void $this->bindings[] = $value; } - // ── Private helpers (shared SQL syntax) ── + /** + * @param array $values + */ + protected function compileLike(string $attribute, array $values, string $prefix, string $suffix, bool $not): string + { + /** @var string $rawVal */ + $rawVal = $values[0]; + $val = $this->escapeLikeValue($rawVal); + $this->addBinding($prefix . $val . $suffix); + $keyword = $not ? 'NOT LIKE' : 'LIKE'; + + return $attribute . ' ' . $keyword . ' ?'; + } + + /** + * @param array $values + */ + protected function compileContains(string $attribute, array $values): string + { + /** @var array $values */ + if (\count($values) === 1) { + $this->addBinding('%' . $this->escapeLikeValue($values[0]) . '%'); + + return $attribute . ' LIKE ?'; + } + + $parts = []; + foreach ($values as $value) { + $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); + $parts[] = $attribute . ' LIKE ?'; + } + + return '(' . \implode(' OR ', $parts) . ')'; + } + + /** + * @param array $values + */ + protected function compileContainsAll(string $attribute, array $values): string + { + /** @var array $values */ + $parts = []; + foreach ($values as $value) { + $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); + $parts[] = $attribute . ' LIKE ?'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } + + /** + * @param array $values + */ + protected function compileNotContains(string $attribute, array $values): string + { + /** @var array $values */ + if (\count($values) === 1) { + $this->addBinding('%' . $this->escapeLikeValue($values[0]) . '%'); + + return $attribute . ' NOT LIKE ?'; + } + + $parts = []; + foreach ($values as $value) { + $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); + $parts[] = $attribute . ' NOT LIKE ?'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } + + /** + * Escape LIKE metacharacters in user input before wrapping with wildcards. + */ + protected function escapeLikeValue(string $value): string + { + return \str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); + } + + /** + * Resolve the placement for a join filter condition. + * ClickHouse overrides this to always return Placement::Where since it + * does not support subqueries in JOIN ON conditions. + */ + protected function resolveJoinFilterPlacement(Placement $requested, bool $isCrossJoin): Placement + { + return $isCrossJoin ? Placement::Where : $requested; + } /** * @param array $values @@ -772,85 +1786,6 @@ private function compileBetween(string $attribute, array $values, bool $not): st return $attribute . ' ' . $keyword . ' ? AND ?'; } - /** - * @param array $values - */ - private function compileLike(string $attribute, array $values, string $prefix, string $suffix, bool $not): string - { - /** @var string $rawVal */ - $rawVal = $values[0]; - $val = $this->escapeLikeValue($rawVal); - $this->addBinding($prefix . $val . $suffix); - $keyword = $not ? 'NOT LIKE' : 'LIKE'; - - return $attribute . ' ' . $keyword . ' ?'; - } - - /** - * @param array $values - */ - private function compileContains(string $attribute, array $values): string - { - /** @var array $values */ - if (\count($values) === 1) { - $this->addBinding('%' . $this->escapeLikeValue($values[0]) . '%'); - - return $attribute . ' LIKE ?'; - } - - $parts = []; - foreach ($values as $value) { - $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); - $parts[] = $attribute . ' LIKE ?'; - } - - return '(' . \implode(' OR ', $parts) . ')'; - } - - /** - * @param array $values - */ - private function compileContainsAll(string $attribute, array $values): string - { - /** @var array $values */ - $parts = []; - foreach ($values as $value) { - $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); - $parts[] = $attribute . ' LIKE ?'; - } - - return '(' . \implode(' AND ', $parts) . ')'; - } - - /** - * @param array $values - */ - private function compileNotContains(string $attribute, array $values): string - { - /** @var array $values */ - if (\count($values) === 1) { - $this->addBinding('%' . $this->escapeLikeValue($values[0]) . '%'); - - return $attribute . ' NOT LIKE ?'; - } - - $parts = []; - foreach ($values as $value) { - $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); - $parts[] = $attribute . ' NOT LIKE ?'; - } - - return '(' . \implode(' AND ', $parts) . ')'; - } - - /** - * Escape LIKE metacharacters in user input before wrapping with wildcards. - */ - private function escapeLikeValue(string $value): string - { - return \str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); - } - private function compileLogical(Query $query, string $operator): string { $parts = []; diff --git a/src/Query/Builder/Case/Builder.php b/src/Query/Builder/Case/Builder.php new file mode 100644 index 0000000..4e19bd4 --- /dev/null +++ b/src/Query/Builder/Case/Builder.php @@ -0,0 +1,94 @@ +, resultBindings: list}> */ + private array $whens = []; + + private ?string $elseResult = null; + + /** @var list */ + private array $elseBindings = []; + + private string $alias = ''; + + /** + * @param list $conditionBindings + * @param list $resultBindings + */ + public function when(string $condition, string $result, array $conditionBindings = [], array $resultBindings = []): static + { + $this->whens[] = [ + 'condition' => $condition, + 'result' => $result, + 'conditionBindings' => $conditionBindings, + 'resultBindings' => $resultBindings, + ]; + + return $this; + } + + /** + * @param list $bindings + */ + public function elseResult(string $result, array $bindings = []): static + { + $this->elseResult = $result; + $this->elseBindings = $bindings; + + return $this; + } + + /** + * Set the alias for this CASE expression. + * + * The alias is used as-is in the generated SQL (e.g. `CASE ... END AS alias`). + * The caller must pass a pre-quoted identifier if quoting is required, since + * Case\Builder does not have access to the builder's quote() method. + */ + public function alias(string $alias): static + { + $this->alias = $alias; + + return $this; + } + + public function build(): Expression + { + if (empty($this->whens)) { + throw new ValidationException('CASE expression requires at least one WHEN clause.'); + } + + $sql = 'CASE'; + $bindings = []; + + foreach ($this->whens as $when) { + $sql .= ' WHEN ' . $when['condition'] . ' THEN ' . $when['result']; + foreach ($when['conditionBindings'] as $binding) { + $bindings[] = $binding; + } + foreach ($when['resultBindings'] as $binding) { + $bindings[] = $binding; + } + } + + if ($this->elseResult !== null) { + $sql .= ' ELSE ' . $this->elseResult; + foreach ($this->elseBindings as $binding) { + $bindings[] = $binding; + } + } + + $sql .= ' END'; + + if ($this->alias !== '') { + $sql .= ' AS ' . $this->alias; + } + + return new Expression($sql, $bindings); + } +} diff --git a/src/Query/Builder/Case/Expression.php b/src/Query/Builder/Case/Expression.php new file mode 100644 index 0000000..6625518 --- /dev/null +++ b/src/Query/Builder/Case/Expression.php @@ -0,0 +1,23 @@ + $bindings + */ + public function __construct( + public string $sql, + public array $bindings, + ) { + } + + /** + * @return array{sql: string, bindings: list} + */ + public function toSql(): array + { + return ['sql' => $this->sql, 'bindings' => $this->bindings]; + } +} diff --git a/src/Query/Builder/Feature/Aggregates.php b/src/Query/Builder/Feature/Aggregates.php new file mode 100644 index 0000000..f1a817d --- /dev/null +++ b/src/Query/Builder/Feature/Aggregates.php @@ -0,0 +1,28 @@ + $columns + */ + public function groupBy(array $columns): static; + + /** + * @param array<\Utopia\Query\Query> $queries + */ + public function having(array $queries): static; +} diff --git a/src/Query/Builder/Feature/CTEs.php b/src/Query/Builder/Feature/CTEs.php new file mode 100644 index 0000000..129a514 --- /dev/null +++ b/src/Query/Builder/Feature/CTEs.php @@ -0,0 +1,12 @@ + $row + */ + public function set(array $row): static; + + /** + * @param string[] $keys + * @param string[] $updateColumns + */ + public function onConflict(array $keys, array $updateColumns): static; + + public function insert(): BuildResult; + + /** + * @param list $columns + */ + public function fromSelect(array $columns, Builder $source): static; + + public function insertSelect(): BuildResult; +} diff --git a/src/Query/Builder/Feature/Joins.php b/src/Query/Builder/Feature/Joins.php new file mode 100644 index 0000000..2f644df --- /dev/null +++ b/src/Query/Builder/Feature/Joins.php @@ -0,0 +1,19 @@ + $values + */ + public function filterJsonOverlaps(string $attribute, array $values): static; + + public function filterJsonPath(string $attribute, string $path, string $operator, mixed $value): static; + + // Mutation operations (for UPDATE SET) + + /** + * @param array $values + */ + public function setJsonAppend(string $column, array $values): static; + + /** + * @param array $values + */ + public function setJsonPrepend(string $column, array $values): static; + + public function setJsonInsert(string $column, int $index, mixed $value): static; + + public function setJsonRemove(string $column, mixed $value): static; + + /** + * @param array $values + */ + public function setJsonIntersect(string $column, array $values): static; + + /** + * @param array $values + */ + public function setJsonDiff(string $column, array $values): static; + + public function setJsonUnique(string $column): static; +} diff --git a/src/Query/Builder/Feature/Locking.php b/src/Query/Builder/Feature/Locking.php new file mode 100644 index 0000000..eb70a8b --- /dev/null +++ b/src/Query/Builder/Feature/Locking.php @@ -0,0 +1,18 @@ + $columns + */ + public function returning(array $columns = ['*']): static; +} diff --git a/src/Query/Builder/Feature/Selects.php b/src/Query/Builder/Feature/Selects.php new file mode 100644 index 0000000..f83959e --- /dev/null +++ b/src/Query/Builder/Feature/Selects.php @@ -0,0 +1,62 @@ + $columns + */ + public function select(array $columns): static; + + /** + * @param list $bindings + */ + public function selectRaw(string $expression, array $bindings = []): static; + + public function distinct(): static; + + /** + * @param array<\Utopia\Query\Query> $queries + */ + public function filter(array $queries): static; + + /** + * @param array<\Utopia\Query\Query> $queries + */ + public function queries(array $queries): static; + + public function sortAsc(string $attribute): static; + + public function sortDesc(string $attribute): static; + + public function sortRandom(): static; + + public function limit(int $value): static; + + public function offset(int $value): static; + + public function page(int $page, int $perPage = 25): static; + + public function cursorAfter(mixed $value): static; + + public function cursorBefore(mixed $value): static; + + public function when(bool $condition, Closure $callback): static; + + public function build(): BuildResult; + + public function toRawSql(): string; + + /** + * @return list + */ + public function getBindings(): array; + + public function reset(): static; +} diff --git a/src/Query/Builder/Feature/Spatial.php b/src/Query/Builder/Feature/Spatial.php new file mode 100644 index 0000000..a276dc2 --- /dev/null +++ b/src/Query/Builder/Feature/Spatial.php @@ -0,0 +1,71 @@ + $point [longitude, latitude] + */ + public function filterDistance(string $attribute, array $point, string $operator, float $distance, bool $meters = false): static; + + /** + * @param array $geometry WKT-compatible geometry coordinates + */ + public function filterIntersects(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterNotIntersects(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterCrosses(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterNotCrosses(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterOverlaps(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterNotOverlaps(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterTouches(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterNotTouches(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterCovers(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterNotCovers(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterSpatialEquals(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterNotSpatialEquals(string $attribute, array $geometry): static; +} diff --git a/src/Query/Builder/Feature/Transactions.php b/src/Query/Builder/Feature/Transactions.php new file mode 100644 index 0000000..a8dd5e4 --- /dev/null +++ b/src/Query/Builder/Feature/Transactions.php @@ -0,0 +1,20 @@ + $row + */ + public function set(array $row): static; + + /** + * @param list $bindings + */ + public function setRaw(string $column, string $expression, array $bindings = []): static; + + public function update(): BuildResult; +} diff --git a/src/Query/Builder/Feature/Upsert.php b/src/Query/Builder/Feature/Upsert.php new file mode 100644 index 0000000..4646cfc --- /dev/null +++ b/src/Query/Builder/Feature/Upsert.php @@ -0,0 +1,12 @@ + $vector The query vector + * @param string $metric Distance metric: 'cosine', 'euclidean', 'dot' + */ + public function orderByVectorDistance(string $attribute, array $vector, string $metric = 'cosine'): static; +} diff --git a/src/Query/Builder/Feature/Windows.php b/src/Query/Builder/Feature/Windows.php new file mode 100644 index 0000000..31843b5 --- /dev/null +++ b/src/Query/Builder/Feature/Windows.php @@ -0,0 +1,16 @@ +|null $partitionBy Columns for PARTITION BY + * @param list|null $orderBy Columns for ORDER BY (prefix with - for DESC) + */ + public function selectWindow(string $function, string $alias, ?array $partitionBy = null, ?array $orderBy = null): static; +} diff --git a/src/Query/Builder/JoinBuilder.php b/src/Query/Builder/JoinBuilder.php new file mode 100644 index 0000000..b1c27c9 --- /dev/null +++ b/src/Query/Builder/JoinBuilder.php @@ -0,0 +1,86 @@ +', '<=', '>=', '<>']; + + /** @var list */ + private array $ons = []; + + /** @var list}> */ + private array $wheres = []; + + /** + * Add an ON condition to the join. + * + * Note: $left and $right should be raw column identifiers (e.g. "users.id"). + * The parent builder's compileJoinWithBuilder already calls resolveAndWrap on these values. + */ + public function on(string $left, string $right, string $operator = '='): static + { + if (!\in_array($operator, self::ALLOWED_OPERATORS, true)) { + throw new ValidationException('Invalid join operator: ' . $operator); + } + + $this->ons[] = ['left' => $left, 'operator' => $operator, 'right' => $right]; + + return $this; + } + + /** + * @param list $bindings + */ + public function onRaw(string $expression, array $bindings = []): static + { + $this->wheres[] = ['expression' => $expression, 'bindings' => $bindings]; + + return $this; + } + + /** + * Add a WHERE condition to the join. + * + * Note: $column is used as-is in the SQL expression. The caller is responsible + * for ensuring it is a safe, pre-validated column identifier. + */ + public function where(string $column, string $operator, mixed $value): static + { + if (!\preg_match('/^[a-zA-Z_][a-zA-Z0-9_.]*$/', $column)) { + throw new ValidationException('Invalid column name: ' . $column); + } + + if (!\in_array($operator, self::ALLOWED_OPERATORS, true)) { + throw new ValidationException('Invalid join operator: ' . $operator); + } + + $this->wheres[] = ['expression' => $column . ' ' . $operator . ' ?', 'bindings' => [$value]]; + + return $this; + } + + /** + * @param list $bindings + */ + public function whereRaw(string $expression, array $bindings = []): static + { + $this->wheres[] = ['expression' => $expression, 'bindings' => $bindings]; + + return $this; + } + + /** @return list */ + public function getOns(): array + { + return $this->ons; + } + + /** @return list}> */ + public function getWheres(): array + { + return $this->wheres; + } +} diff --git a/src/Query/Builder/SQL.php b/src/Query/Builder/SQL.php index 9275208..bb48f2f 100644 --- a/src/Query/Builder/SQL.php +++ b/src/Query/Builder/SQL.php @@ -3,54 +3,173 @@ namespace Utopia\Query\Builder; use Utopia\Query\Builder as BaseBuilder; +use Utopia\Query\Builder\Feature\Locking; +use Utopia\Query\Builder\Feature\Transactions; +use Utopia\Query\Builder\Feature\Upsert; +use Utopia\Query\Exception\ValidationException; +use Utopia\Query\QuotesIdentifiers; -class SQL extends BaseBuilder +abstract class SQL extends BaseBuilder implements Locking, Transactions, Upsert { - private string $wrapChar = '`'; + use QuotesIdentifiers; - public function setWrapChar(string $char): static + public function forUpdate(): static { - $this->wrapChar = $char; + $this->lockMode = 'FOR UPDATE'; return $this; } - protected function wrapIdentifier(string $identifier): string + public function forShare(): static { - $segments = \explode('.', $identifier); - $wrapped = \array_map(fn (string $segment): string => $segment === '*' - ? '*' - : $this->wrapChar . \str_replace($this->wrapChar, $this->wrapChar . $this->wrapChar, $segment) . $this->wrapChar, $segments); + $this->lockMode = 'FOR SHARE'; - return \implode('.', $wrapped); + return $this; } - protected function compileRandom(): string + public function forUpdateSkipLocked(): static { - return 'RAND()'; + $this->lockMode = 'FOR UPDATE SKIP LOCKED'; + + return $this; } - /** - * @param array $values - */ - protected function compileRegex(string $attribute, array $values): string + public function forUpdateNoWait(): static + { + $this->lockMode = 'FOR UPDATE NOWAIT'; + + return $this; + } + + public function forShareSkipLocked(): static + { + $this->lockMode = 'FOR SHARE SKIP LOCKED'; + + return $this; + } + + public function forShareNoWait(): static + { + $this->lockMode = 'FOR SHARE NOWAIT'; + + return $this; + } + + public function begin(): BuildResult + { + return new BuildResult('BEGIN', []); + } + + public function commit(): BuildResult + { + return new BuildResult('COMMIT', []); + } + + public function rollback(): BuildResult + { + return new BuildResult('ROLLBACK', []); + } + + public function savepoint(string $name): BuildResult + { + return new BuildResult('SAVEPOINT ' . $this->quote($name), []); + } + + public function releaseSavepoint(string $name): BuildResult + { + return new BuildResult('RELEASE SAVEPOINT ' . $this->quote($name), []); + } + + public function rollbackToSavepoint(string $name): BuildResult { - $this->addBinding($values[0]); + return new BuildResult('ROLLBACK TO SAVEPOINT ' . $this->quote($name), []); + } + + abstract protected function compileConflictClause(): string; + + public function upsert(): BuildResult + { + $this->bindings = []; + $this->validateTable(); + $this->validateRows('upsert'); + $columns = $this->validateAndGetColumns(); + + if (empty($this->conflictKeys)) { + throw new ValidationException('No conflict keys specified. Call onConflict() before upsert().'); + } + + if (empty($this->conflictUpdateColumns)) { + throw new ValidationException('No conflict update columns specified. Call onConflict() with update columns before upsert().'); + } + + $rowColumns = $columns; + foreach ($this->conflictUpdateColumns as $col) { + if (! \in_array($col, $rowColumns, true)) { + throw new ValidationException("Conflict update column '{$col}' is not present in the row data."); + } + } + + $wrappedColumns = \array_map(fn (string $col): string => $this->resolveAndWrap($col), $columns); + + $rowPlaceholders = []; + foreach ($this->pendingRows as $row) { + $placeholders = []; + foreach ($columns as $col) { + $this->addBinding($row[$col] ?? null); + $placeholders[] = '?'; + } + $rowPlaceholders[] = '(' . \implode(', ', $placeholders) . ')'; + } + + $sql = 'INSERT INTO ' . $this->quote($this->table) + . ' (' . \implode(', ', $wrappedColumns) . ')' + . ' VALUES ' . \implode(', ', $rowPlaceholders); - return $attribute . ' REGEXP ?'; + $sql .= ' ' . $this->compileConflictClause(); + + return new BuildResult($sql, $this->bindings); } + abstract public function insertOrIgnore(): BuildResult; + /** - * @param array $values + * Convert a geometry array to WKT string. + * + * @param array $geometry */ - protected function compileSearch(string $attribute, array $values, bool $not): string + protected function geometryToWkt(array $geometry): string { - $this->addBinding($values[0]); + // Simple array of [lon, lat] -> POINT + if (\count($geometry) === 2 && \is_numeric($geometry[0]) && \is_numeric($geometry[1])) { + return 'POINT(' . (float) $geometry[0] . ' ' . (float) $geometry[1] . ')'; + } + + // Array of points -> check depth + if (isset($geometry[0]) && \is_array($geometry[0])) { + // Array of arrays of arrays -> POLYGON + if (isset($geometry[0][0]) && \is_array($geometry[0][0])) { + $rings = []; + foreach ($geometry as $ring) { + /** @var array> $ring */ + $points = \array_map(fn (array $p): string => (float) $p[0] . ' ' . (float) $p[1], $ring); + $rings[] = '(' . \implode(', ', $points) . ')'; + } - if ($not) { - return 'NOT (MATCH(' . $attribute . ') AGAINST(?))'; + return 'POLYGON(' . \implode(', ', $rings) . ')'; + } + + // Array of [lon, lat] pairs -> LINESTRING + /** @var array> $geometry */ + $points = \array_map(fn (array $p): string => (float) $p[0] . ' ' . (float) $p[1], $geometry); + + return 'LINESTRING(' . \implode(', ', $points) . ')'; } - return 'MATCH(' . $attribute . ') AGAINST(?)'; + /** @var int|float|string $rawX */ + $rawX = $geometry[0] ?? 0; + /** @var int|float|string $rawY */ + $rawY = $geometry[1] ?? 0; + + return 'POINT(' . (float) $rawX . ' ' . (float) $rawY . ')'; } } From 5880814bcfa352f420019286e63fc382530f2eec Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Mar 2026 19:58:58 +1300 Subject: [PATCH 20/29] (feat): Add MySQL, PostgreSQL, and ClickHouse dialect builders --- src/Query/Builder/ClickHouse.php | 269 +++++++++++++-- src/Query/Builder/MySQL.php | 471 +++++++++++++++++++++++++ src/Query/Builder/PostgreSQL.php | 576 +++++++++++++++++++++++++++++++ 3 files changed, 1295 insertions(+), 21 deletions(-) create mode 100644 src/Query/Builder/MySQL.php create mode 100644 src/Query/Builder/PostgreSQL.php diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 23def63..84e6947 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -3,11 +3,16 @@ namespace Utopia\Query\Builder; use Utopia\Query\Builder as BaseBuilder; -use Utopia\Query\Exception; +use Utopia\Query\Builder\Feature\Hints; +use Utopia\Query\Exception\UnsupportedException; +use Utopia\Query\Exception\ValidationException; +use Utopia\Query\Hook\Join\Placement; use Utopia\Query\Query; +use Utopia\Query\QuotesIdentifiers; -class ClickHouse extends BaseBuilder +class ClickHouse extends BaseBuilder implements Hints { + use QuotesIdentifiers; /** * @var array */ @@ -17,7 +22,8 @@ class ClickHouse extends BaseBuilder protected ?float $sampleFraction = null; - // ── ClickHouse-specific fluent API ── + /** @var list */ + protected array $hints = []; /** * Add PREWHERE filters (evaluated before reading all columns — major ClickHouse optimization) @@ -49,7 +55,7 @@ public function final(): static public function sample(float $fraction): static { if ($fraction <= 0.0 || $fraction >= 1.0) { - throw new \InvalidArgumentException('Sample fraction must be between 0 and 1 exclusive'); + throw new ValidationException('Sample fraction must be between 0 and 1 exclusive'); } $this->sampleFraction = $fraction; @@ -57,26 +63,48 @@ public function sample(float $fraction): static return $this; } - public function reset(): static + public function hint(string $hint): static { - parent::reset(); - $this->prewhereQueries = []; - $this->useFinal = false; - $this->sampleFraction = null; + if (!\preg_match('/^[A-Za-z0-9_=., ]+$/', $hint)) { + throw new ValidationException('Invalid hint: ' . $hint); + } + + $this->hints[] = $hint; return $this; } - // ── Dialect-specific compilation ── + /** + * @param array $settings + */ + public function settings(array $settings): static + { + foreach ($settings as $key => $value) { + if (!\preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $key)) { + throw new ValidationException('Invalid ClickHouse setting key: ' . $key); + } + + $value = (string) $value; + + if (!\preg_match('/^[a-zA-Z0-9_.]+$/', $value)) { + throw new ValidationException('Invalid ClickHouse setting value: ' . $value); + } + + $this->hints[] = $key . '=' . $value; + } + + return $this; + } - protected function wrapIdentifier(string $identifier): string + public function reset(): static { - $segments = \explode('.', $identifier); - $wrapped = \array_map(fn (string $segment): string => $segment === '*' - ? '*' - : '`' . \str_replace('`', '``', $segment) . '`', $segments); + parent::reset(); + $this->prewhereQueries = []; + $this->useFinal = false; + $this->sampleFraction = null; + $this->hints = []; - return \implode('.', $wrapped); + return $this; } protected function compileRandom(): string @@ -101,25 +129,224 @@ protected function compileRegex(string $attribute, array $values): string * * @param array $values * - * @throws Exception + * @throws UnsupportedException */ protected function compileSearch(string $attribute, array $values, bool $not): string { - throw new Exception('Full-text search (MATCH AGAINST) is not supported in ClickHouse. Use contains() or a custom full-text index instead.'); + throw new UnsupportedException('Full-text search (MATCH AGAINST) is not supported in ClickHouse. Use contains() or a custom full-text index instead.'); + } + + /** + * ClickHouse uses startsWith()/endsWith() functions instead of LIKE with wildcards. + * + * @param array $values + */ + protected function compileLike(string $attribute, array $values, string $prefix, string $suffix, bool $not): string + { + /** @var string $rawVal */ + $rawVal = $values[0]; + + // startsWith: prefix='', suffix='%' + if ($prefix === '' && $suffix === '%') { + $func = $not ? 'NOT startsWith' : 'startsWith'; + $this->addBinding($rawVal); + + return $func . '(' . $attribute . ', ?)'; + } + + // endsWith: prefix='%', suffix='' + if ($prefix === '%' && $suffix === '') { + $func = $not ? 'NOT endsWith' : 'endsWith'; + $this->addBinding($rawVal); + + return $func . '(' . $attribute . ', ?)'; + } + + // Fallback for any other LIKE pattern (should not occur in practice) + $val = $this->escapeLikeValue($rawVal); + $this->addBinding($prefix . $val . $suffix); + $keyword = $not ? 'NOT LIKE' : 'LIKE'; + + return $attribute . ' ' . $keyword . ' ?'; + } + + /** + * ClickHouse uses position() instead of LIKE '%val%' for substring matching. + * + * @param array $values + */ + protected function compileContains(string $attribute, array $values): string + { + /** @var array $values */ + if (\count($values) === 1) { + $this->addBinding($values[0]); + + return 'position(' . $attribute . ', ?) > 0'; + } + + $parts = []; + foreach ($values as $value) { + $this->addBinding($value); + $parts[] = 'position(' . $attribute . ', ?) > 0'; + } + + return '(' . \implode(' OR ', $parts) . ')'; + } + + /** + * ClickHouse uses position() instead of LIKE '%val%' for substring matching (all values). + * + * @param array $values + */ + protected function compileContainsAll(string $attribute, array $values): string + { + /** @var array $values */ + $parts = []; + foreach ($values as $value) { + $this->addBinding($value); + $parts[] = 'position(' . $attribute . ', ?) > 0'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } + + /** + * ClickHouse uses position() = 0 instead of NOT LIKE '%val%'. + * + * @param array $values + */ + protected function compileNotContains(string $attribute, array $values): string + { + /** @var array $values */ + if (\count($values) === 1) { + $this->addBinding($values[0]); + + return 'position(' . $attribute . ', ?) = 0'; + } + + $parts = []; + foreach ($values as $value) { + $this->addBinding($value); + $parts[] = 'position(' . $attribute . ', ?) = 0'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } + + public function update(): BuildResult + { + $this->bindings = []; + $this->validateTable(); + + $assignments = []; + + if (! empty($this->pendingRows)) { + foreach ($this->pendingRows[0] as $col => $value) { + $assignments[] = $this->resolveAndWrap($col) . ' = ?'; + $this->addBinding($value); + } + } + + foreach ($this->rawSets as $col => $expression) { + $assignments[] = $this->resolveAndWrap($col) . ' = ' . $expression; + if (isset($this->rawSetBindings[$col])) { + foreach ($this->rawSetBindings[$col] as $binding) { + $this->addBinding($binding); + } + } + } + + foreach ($this->caseSets as $col => $caseData) { + $assignments[] = $this->resolveAndWrap($col) . ' = ' . $caseData['sql']; + foreach ($caseData['bindings'] as $binding) { + $this->addBinding($binding); + } + } + + if (empty($assignments)) { + throw new ValidationException('No assignments for UPDATE. Call set() or setRaw() before update().'); + } + + $parts = []; + + $this->compileWhereClauses($parts); + + if (empty($parts)) { + throw new ValidationException('ClickHouse UPDATE requires a WHERE clause.'); + } + + $sql = 'ALTER TABLE ' . $this->quote($this->table) + . ' UPDATE ' . \implode(', ', $assignments) + . ' ' . \implode(' ', $parts); + + return new BuildResult($sql, $this->bindings); + } + + public function delete(): BuildResult + { + $this->bindings = []; + $this->validateTable(); + + $parts = []; + + $this->compileWhereClauses($parts); + + if (empty($parts)) { + throw new ValidationException('ClickHouse DELETE requires a WHERE clause.'); + } + + $sql = 'ALTER TABLE ' . $this->quote($this->table) + . ' DELETE ' . \implode(' ', $parts); + + return new BuildResult($sql, $this->bindings); + } + + /** + * ClickHouse does not support subqueries in JOIN ON conditions. + * Force all join filter conditions to WHERE placement. + */ + protected function resolveJoinFilterPlacement(Placement $requested, bool $isCrossJoin): Placement + { + return Placement::Where; } - // ── Hooks ── + public function build(): BuildResult + { + $result = parent::build(); + + if (! empty($this->hints)) { + $settingsStr = \implode(', ', $this->hints); + + return new BuildResult($result->query . ' SETTINGS ' . $settingsStr, $result->bindings); + } + + return $result; + } protected function buildTableClause(): string { - $sql = 'FROM ' . $this->wrapIdentifier($this->table); + $fromSub = $this->fromSubquery; + if ($fromSub !== null) { + $subResult = $fromSub['subquery']->build(); + foreach ($subResult->bindings as $binding) { + $this->addBinding($binding); + } + + return 'FROM (' . $subResult->query . ') AS ' . $this->quote($fromSub['alias']); + } + + $sql = 'FROM ' . $this->quote($this->table); if ($this->useFinal) { $sql .= ' FINAL'; } if ($this->sampleFraction !== null) { - $sql .= ' SAMPLE ' . $this->sampleFraction; + $sql .= ' SAMPLE ' . \sprintf('%.10g', $this->sampleFraction); + } + + if ($this->tableAlias !== '') { + $sql .= ' AS ' . $this->quote($this->tableAlias); } return $sql; diff --git a/src/Query/Builder/MySQL.php b/src/Query/Builder/MySQL.php new file mode 100644 index 0000000..432fc10 --- /dev/null +++ b/src/Query/Builder/MySQL.php @@ -0,0 +1,471 @@ + */ + protected array $hints = []; + + /** @var array}> */ + protected array $jsonSets = []; + + protected function compileRandom(): string + { + return 'RAND()'; + } + + /** + * @param array $values + */ + protected function compileRegex(string $attribute, array $values): string + { + $this->addBinding($values[0]); + + return $attribute . ' REGEXP ?'; + } + + /** + * @param array $values + */ + protected function compileSearch(string $attribute, array $values, bool $not): string + { + $this->addBinding($values[0]); + + if ($not) { + return 'NOT (MATCH(' . $attribute . ') AGAINST(?))'; + } + + return 'MATCH(' . $attribute . ') AGAINST(?)'; + } + + protected function compileConflictClause(): string + { + $updates = []; + foreach ($this->conflictUpdateColumns as $col) { + $wrapped = $this->resolveAndWrap($col); + if (isset($this->conflictRawSets[$col])) { + $updates[] = $wrapped . ' = ' . $this->conflictRawSets[$col]; + foreach ($this->conflictRawSetBindings[$col] ?? [] as $binding) { + $this->addBinding($binding); + } + } else { + $updates[] = $wrapped . ' = VALUES(' . $wrapped . ')'; + } + } + + return 'ON DUPLICATE KEY UPDATE ' . \implode(', ', $updates); + } + + public function filterDistance(string $attribute, array $point, string $operator, float $distance, bool $meters = false): static + { + $wkt = 'POINT(' . (float) $point[0] . ' ' . (float) $point[1] . ')'; + $method = match ($operator) { + '<' => Method::DistanceLessThan, + '>' => Method::DistanceGreaterThan, + '=' => Method::DistanceEqual, + '!=' => Method::DistanceNotEqual, + default => Method::DistanceLessThan, + }; + + $this->pendingQueries[] = new Query($method, $attribute, [[$wkt, $distance, $meters]]); + + return $this; + } + + public function filterIntersects(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::intersects($attribute, $geometry); + + return $this; + } + + public function filterNotIntersects(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notIntersects($attribute, $geometry); + + return $this; + } + + public function filterCrosses(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::crosses($attribute, $geometry); + + return $this; + } + + public function filterNotCrosses(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notCrosses($attribute, $geometry); + + return $this; + } + + public function filterOverlaps(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::overlaps($attribute, $geometry); + + return $this; + } + + public function filterNotOverlaps(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notOverlaps($attribute, $geometry); + + return $this; + } + + public function filterTouches(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::touches($attribute, $geometry); + + return $this; + } + + public function filterNotTouches(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notTouches($attribute, $geometry); + + return $this; + } + + public function filterCovers(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::covers($attribute, $geometry); + + return $this; + } + + public function filterNotCovers(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notCovers($attribute, $geometry); + + return $this; + } + + public function filterSpatialEquals(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::spatialEquals($attribute, $geometry); + + return $this; + } + + public function filterNotSpatialEquals(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notSpatialEquals($attribute, $geometry); + + return $this; + } + + public function filterJsonContains(string $attribute, mixed $value): static + { + $this->pendingQueries[] = Query::jsonContains($attribute, $value); + + return $this; + } + + public function filterJsonNotContains(string $attribute, mixed $value): static + { + $this->pendingQueries[] = Query::jsonNotContains($attribute, $value); + + return $this; + } + + public function filterJsonOverlaps(string $attribute, array $values): static + { + $this->pendingQueries[] = Query::jsonOverlaps($attribute, $values); + + return $this; + } + + public function filterJsonPath(string $attribute, string $path, string $operator, mixed $value): static + { + $this->pendingQueries[] = Query::jsonPath($attribute, $path, $operator, $value); + + return $this; + } + + public function setJsonAppend(string $column, array $values): static + { + $this->jsonSets[$column] = [ + 'expression' => 'JSON_MERGE_PRESERVE(IFNULL(' . $this->resolveAndWrap($column) . ', JSON_ARRAY()), ?)', + 'bindings' => [\json_encode($values)], + ]; + + return $this; + } + + public function setJsonPrepend(string $column, array $values): static + { + $this->jsonSets[$column] = [ + 'expression' => 'JSON_MERGE_PRESERVE(?, IFNULL(' . $this->resolveAndWrap($column) . ', JSON_ARRAY()))', + 'bindings' => [\json_encode($values)], + ]; + + return $this; + } + + public function setJsonInsert(string $column, int $index, mixed $value): static + { + $this->jsonSets[$column] = [ + 'expression' => 'JSON_ARRAY_INSERT(' . $this->resolveAndWrap($column) . ', ?, ?)', + 'bindings' => ['$[' . $index . ']', $value], + ]; + + return $this; + } + + public function setJsonRemove(string $column, mixed $value): static + { + $this->jsonSets[$column] = [ + 'expression' => 'JSON_REMOVE(' . $this->resolveAndWrap($column) . ', JSON_UNQUOTE(JSON_SEARCH(' . $this->resolveAndWrap($column) . ', \'one\', ?)))', + 'bindings' => [$value], + ]; + + return $this; + } + + public function setJsonIntersect(string $column, array $values): static + { + $this->setRaw($column, '(SELECT JSON_ARRAYAGG(val) FROM JSON_TABLE(' . $this->resolveAndWrap($column) . ', \'$[*]\' COLUMNS(val JSON PATH \'$\')) AS jt WHERE JSON_CONTAINS(?, val))', [\json_encode($values)]); + + return $this; + } + + public function setJsonDiff(string $column, array $values): static + { + $this->setRaw($column, '(SELECT JSON_ARRAYAGG(val) FROM JSON_TABLE(' . $this->resolveAndWrap($column) . ', \'$[*]\' COLUMNS(val JSON PATH \'$\')) AS jt WHERE NOT JSON_CONTAINS(?, val))', [\json_encode($values)]); + + return $this; + } + + public function setJsonUnique(string $column): static + { + $this->setRaw($column, '(SELECT JSON_ARRAYAGG(val) FROM (SELECT DISTINCT val FROM JSON_TABLE(' . $this->resolveAndWrap($column) . ', \'$[*]\' COLUMNS(val JSON PATH \'$\')) AS jt) AS dt)'); + + return $this; + } + + public function hint(string $hint): static + { + if (!\preg_match('/^[A-Za-z0-9_()= ,]+$/', $hint)) { + throw new ValidationException('Invalid hint: ' . $hint); + } + + $this->hints[] = $hint; + + return $this; + } + + public function maxExecutionTime(int $ms): static + { + return $this->hint("MAX_EXECUTION_TIME({$ms})"); + } + + public function insertOrIgnore(): BuildResult + { + $this->bindings = []; + [$sql, $bindings] = $this->compileInsertBody(); + foreach ($bindings as $binding) { + $this->addBinding($binding); + } + + // Replace "INSERT INTO" with "INSERT IGNORE INTO" + $sql = \preg_replace('/^INSERT INTO/', 'INSERT IGNORE INTO', $sql, 1) ?? $sql; + + return new BuildResult($sql, $this->bindings); + } + + public function compileFilter(Query $query): string + { + $method = $query->getMethod(); + $attribute = $this->resolveAndWrap($query->getAttribute()); + + if ($method->isSpatial()) { + return $this->compileSpatialFilter($method, $attribute, $query); + } + + if ($method->isJson()) { + return $this->compileJsonFilter($method, $attribute, $query); + } + + return parent::compileFilter($query); + } + + public function build(): BuildResult + { + $result = parent::build(); + + if (! empty($this->hints)) { + $hintStr = '/*+ ' . \implode(' ', $this->hints) . ' */'; + $query = \preg_replace('/^SELECT(\s+DISTINCT)?/', 'SELECT$1 ' . $hintStr, $result->query, 1); + + return new BuildResult($query ?? $result->query, $result->bindings); + } + + return $result; + } + + public function update(): BuildResult + { + // Apply JSON sets as rawSets before calling parent + foreach ($this->jsonSets as $col => $data) { + $this->setRaw($col, $data['expression'], $data['bindings']); + } + + $result = parent::update(); + $this->jsonSets = []; + + return $result; + } + + public function reset(): static + { + parent::reset(); + $this->hints = []; + $this->jsonSets = []; + + return $this; + } + + private function compileSpatialFilter(Method $method, string $attribute, Query $query): string + { + $values = $query->getValues(); + + return match ($method) { + Method::DistanceLessThan, + Method::DistanceGreaterThan, + Method::DistanceEqual, + Method::DistanceNotEqual => $this->compileSpatialDistance($method, $attribute, $values), + Method::Intersects => $this->compileSpatialPredicate('ST_Intersects', $attribute, $values, false), + Method::NotIntersects => $this->compileSpatialPredicate('ST_Intersects', $attribute, $values, true), + Method::Crosses => $this->compileSpatialPredicate('ST_Crosses', $attribute, $values, false), + Method::NotCrosses => $this->compileSpatialPredicate('ST_Crosses', $attribute, $values, true), + Method::Overlaps => $this->compileSpatialPredicate('ST_Overlaps', $attribute, $values, false), + Method::NotOverlaps => $this->compileSpatialPredicate('ST_Overlaps', $attribute, $values, true), + Method::Touches => $this->compileSpatialPredicate('ST_Touches', $attribute, $values, false), + Method::NotTouches => $this->compileSpatialPredicate('ST_Touches', $attribute, $values, true), + Method::Covers => $this->compileSpatialPredicate('ST_Contains', $attribute, $values, false), + Method::NotCovers => $this->compileSpatialPredicate('ST_Contains', $attribute, $values, true), + Method::SpatialEquals => $this->compileSpatialPredicate('ST_Equals', $attribute, $values, false), + Method::NotSpatialEquals => $this->compileSpatialPredicate('ST_Equals', $attribute, $values, true), + default => parent::compileFilter($query), + }; + } + + /** + * @param array $values + */ + private function compileSpatialDistance(Method $method, string $attribute, array $values): string + { + /** @var array{0: string, 1: float, 2: bool} $data */ + $data = $values[0]; + $wkt = $data[0]; + $distance = $data[1]; + $meters = $data[2]; + + $operator = match ($method) { + Method::DistanceLessThan => '<', + Method::DistanceGreaterThan => '>', + Method::DistanceEqual => '=', + Method::DistanceNotEqual => '!=', + default => '<', + }; + + if ($meters) { + $this->addBinding($wkt); + $this->addBinding($distance); + + return 'ST_Distance(ST_SRID(' . $attribute . ', 4326), ST_GeomFromText(?, 4326), \'metre\') ' . $operator . ' ?'; + } + + $this->addBinding($wkt); + $this->addBinding($distance); + + return 'ST_Distance(' . $attribute . ', ST_GeomFromText(?)) ' . $operator . ' ?'; + } + + /** + * @param array $values + */ + private function compileSpatialPredicate(string $function, string $attribute, array $values, bool $not): string + { + /** @var array $geometry */ + $geometry = $values[0]; + $wkt = $this->geometryToWkt($geometry); + $this->addBinding($wkt); + + $expr = $function . '(' . $attribute . ', ST_GeomFromText(?, 4326))'; + + return $not ? 'NOT ' . $expr : $expr; + } + + private function compileJsonFilter(Method $method, string $attribute, Query $query): string + { + $values = $query->getValues(); + + return match ($method) { + Method::JsonContains => $this->compileJsonContains($attribute, $values, false), + Method::JsonNotContains => $this->compileJsonContains($attribute, $values, true), + Method::JsonOverlaps => $this->compileJsonOverlapsFilter($attribute, $values), + Method::JsonPath => $this->compileJsonPathFilter($attribute, $values), + default => parent::compileFilter($query), + }; + } + + /** + * @param array $values + */ + private function compileJsonContains(string $attribute, array $values, bool $not): string + { + $this->addBinding(\json_encode($values[0])); + $expr = 'JSON_CONTAINS(' . $attribute . ', ?)'; + + return $not ? 'NOT ' . $expr : $expr; + } + + /** + * @param array $values + */ + private function compileJsonOverlapsFilter(string $attribute, array $values): string + { + /** @var array $arr */ + $arr = $values[0]; + $this->addBinding(\json_encode($arr)); + + return 'JSON_OVERLAPS(' . $attribute . ', ?)'; + } + + /** + * @param array $values + */ + private function compileJsonPathFilter(string $attribute, array $values): string + { + /** @var string $path */ + $path = $values[0]; + /** @var string $operator */ + $operator = $values[1]; + $value = $values[2]; + + if (!\preg_match('/^[a-zA-Z0-9_.\[\]]+$/', $path)) { + throw new ValidationException('Invalid JSON path: ' . $path); + } + + $allowedOperators = ['=', '!=', '<', '>', '<=', '>=', '<>']; + if (!\in_array($operator, $allowedOperators, true)) { + throw new ValidationException('Invalid JSON path operator: ' . $operator); + } + + $this->addBinding($value); + + return 'JSON_EXTRACT(' . $attribute . ', \'$.' . $path . '\') ' . $operator . ' ?'; + } + +} diff --git a/src/Query/Builder/PostgreSQL.php b/src/Query/Builder/PostgreSQL.php new file mode 100644 index 0000000..06bf16a --- /dev/null +++ b/src/Query/Builder/PostgreSQL.php @@ -0,0 +1,576 @@ + */ + protected array $returningColumns = []; + + /** @var array}> */ + protected array $jsonSets = []; + + /** @var ?array{attribute: string, vector: array, metric: string} */ + protected ?array $vectorOrder = null; + + protected function compileRandom(): string + { + return 'RANDOM()'; + } + + /** + * @param array $values + */ + protected function compileRegex(string $attribute, array $values): string + { + $this->addBinding($values[0]); + + return $attribute . ' ~ ?'; + } + + /** + * @param array $values + */ + protected function compileSearch(string $attribute, array $values, bool $not): string + { + $this->addBinding($values[0]); + + if ($not) { + return 'NOT (to_tsvector(' . $attribute . ') @@ plainto_tsquery(?))'; + } + + return 'to_tsvector(' . $attribute . ') @@ plainto_tsquery(?)'; + } + + protected function compileConflictClause(): string + { + $wrappedKeys = \array_map( + fn (string $key): string => $this->resolveAndWrap($key), + $this->conflictKeys + ); + + $updates = []; + foreach ($this->conflictUpdateColumns as $col) { + $wrapped = $this->resolveAndWrap($col); + if (isset($this->conflictRawSets[$col])) { + $updates[] = $wrapped . ' = ' . $this->conflictRawSets[$col]; + foreach ($this->conflictRawSetBindings[$col] ?? [] as $binding) { + $this->addBinding($binding); + } + } else { + $updates[] = $wrapped . ' = EXCLUDED.' . $wrapped; + } + } + + return 'ON CONFLICT (' . \implode(', ', $wrappedKeys) . ') DO UPDATE SET ' . \implode(', ', $updates); + } + + protected function shouldEmitOffset(?int $offset, ?int $limit): bool + { + return $offset !== null; + } + + /** + * @param list $columns + */ + public function returning(array $columns = ['*']): static + { + $this->returningColumns = $columns; + + return $this; + } + + public function forUpdateOf(string $table): static + { + $this->lockMode = 'FOR UPDATE OF ' . $this->quote($table); + + return $this; + } + + public function forShareOf(string $table): static + { + $this->lockMode = 'FOR SHARE OF ' . $this->quote($table); + + return $this; + } + + public function insertOrIgnore(): BuildResult + { + $this->bindings = []; + [$sql, $bindings] = $this->compileInsertBody(); + foreach ($bindings as $binding) { + $this->addBinding($binding); + } + + $sql .= ' ON CONFLICT DO NOTHING'; + + return $this->appendReturning(new BuildResult($sql, $this->bindings)); + } + + public function insert(): BuildResult + { + $result = parent::insert(); + + return $this->appendReturning($result); + } + + public function update(): BuildResult + { + foreach ($this->jsonSets as $col => $data) { + $this->setRaw($col, $data['expression'], $data['bindings']); + } + + $result = parent::update(); + $this->jsonSets = []; + + return $this->appendReturning($result); + } + + public function delete(): BuildResult + { + $result = parent::delete(); + + return $this->appendReturning($result); + } + + public function upsert(): BuildResult + { + $result = parent::upsert(); + + return $this->appendReturning($result); + } + + private function appendReturning(BuildResult $result): BuildResult + { + if (empty($this->returningColumns)) { + return $result; + } + + $columns = \array_map( + fn (string $col): string => $col === '*' ? '*' : $this->resolveAndWrap($col), + $this->returningColumns + ); + + return new BuildResult( + $result->query . ' RETURNING ' . \implode(', ', $columns), + $result->bindings + ); + } + + public function filterDistance(string $attribute, array $point, string $operator, float $distance, bool $meters = false): static + { + $wkt = 'POINT(' . (float) $point[0] . ' ' . (float) $point[1] . ')'; + $method = match ($operator) { + '<' => Method::DistanceLessThan, + '>' => Method::DistanceGreaterThan, + '=' => Method::DistanceEqual, + '!=' => Method::DistanceNotEqual, + default => Method::DistanceLessThan, + }; + + $this->pendingQueries[] = new Query($method, $attribute, [[$wkt, $distance, $meters]]); + + return $this; + } + + public function filterIntersects(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::intersects($attribute, $geometry); + + return $this; + } + + public function filterNotIntersects(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notIntersects($attribute, $geometry); + + return $this; + } + + public function filterCrosses(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::crosses($attribute, $geometry); + + return $this; + } + + public function filterNotCrosses(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notCrosses($attribute, $geometry); + + return $this; + } + + public function filterOverlaps(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::overlaps($attribute, $geometry); + + return $this; + } + + public function filterNotOverlaps(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notOverlaps($attribute, $geometry); + + return $this; + } + + public function filterTouches(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::touches($attribute, $geometry); + + return $this; + } + + public function filterNotTouches(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notTouches($attribute, $geometry); + + return $this; + } + + public function filterCovers(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::covers($attribute, $geometry); + + return $this; + } + + public function filterNotCovers(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notCovers($attribute, $geometry); + + return $this; + } + + public function filterSpatialEquals(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::spatialEquals($attribute, $geometry); + + return $this; + } + + public function filterNotSpatialEquals(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notSpatialEquals($attribute, $geometry); + + return $this; + } + + public function orderByVectorDistance(string $attribute, array $vector, string $metric = 'cosine'): static + { + $this->vectorOrder = [ + 'attribute' => $attribute, + 'vector' => $vector, + 'metric' => $metric, + ]; + + return $this; + } + + public function filterJsonContains(string $attribute, mixed $value): static + { + $this->pendingQueries[] = Query::jsonContains($attribute, $value); + + return $this; + } + + public function filterJsonNotContains(string $attribute, mixed $value): static + { + $this->pendingQueries[] = Query::jsonNotContains($attribute, $value); + + return $this; + } + + public function filterJsonOverlaps(string $attribute, array $values): static + { + $this->pendingQueries[] = Query::jsonOverlaps($attribute, $values); + + return $this; + } + + public function filterJsonPath(string $attribute, string $path, string $operator, mixed $value): static + { + $this->pendingQueries[] = Query::jsonPath($attribute, $path, $operator, $value); + + return $this; + } + + public function setJsonAppend(string $column, array $values): static + { + $this->jsonSets[$column] = [ + 'expression' => 'COALESCE(' . $this->resolveAndWrap($column) . ', \'[]\'::jsonb) || ?::jsonb', + 'bindings' => [\json_encode($values)], + ]; + + return $this; + } + + public function setJsonPrepend(string $column, array $values): static + { + $this->jsonSets[$column] = [ + 'expression' => '?::jsonb || COALESCE(' . $this->resolveAndWrap($column) . ', \'[]\'::jsonb)', + 'bindings' => [\json_encode($values)], + ]; + + return $this; + } + + public function setJsonInsert(string $column, int $index, mixed $value): static + { + $this->jsonSets[$column] = [ + 'expression' => 'jsonb_insert(' . $this->resolveAndWrap($column) . ', \'{' . $index . '}\', ?::jsonb)', + 'bindings' => [\json_encode($value)], + ]; + + return $this; + } + + public function setJsonRemove(string $column, mixed $value): static + { + $this->jsonSets[$column] = [ + 'expression' => $this->resolveAndWrap($column) . ' - ?', + 'bindings' => [\json_encode($value)], + ]; + + return $this; + } + + public function setJsonIntersect(string $column, array $values): static + { + $this->setRaw($column, '(SELECT jsonb_agg(elem) FROM jsonb_array_elements(' . $this->resolveAndWrap($column) . ') AS elem WHERE elem <@ ?::jsonb)', [\json_encode($values)]); + + return $this; + } + + public function setJsonDiff(string $column, array $values): static + { + $this->setRaw($column, '(SELECT COALESCE(jsonb_agg(elem), \'[]\'::jsonb) FROM jsonb_array_elements(' . $this->resolveAndWrap($column) . ') AS elem WHERE NOT elem <@ ?::jsonb)', [\json_encode($values)]); + + return $this; + } + + public function setJsonUnique(string $column): static + { + $this->setRaw($column, '(SELECT jsonb_agg(DISTINCT elem) FROM jsonb_array_elements(' . $this->resolveAndWrap($column) . ') AS elem)'); + + return $this; + } + + public function compileFilter(Query $query): string + { + $method = $query->getMethod(); + $attribute = $this->resolveAndWrap($query->getAttribute()); + + if ($method->isSpatial()) { + return $this->compileSpatialFilter($method, $attribute, $query); + } + + if ($method->isJson()) { + return $this->compileJsonFilter($method, $attribute, $query); + } + + if ($method->isVector()) { + return $this->compileVectorFilter($method, $attribute, $query); + } + + return parent::compileFilter($query); + } + + /** + * @return array{expression: string, bindings: list}|null + */ + protected function compileVectorOrderExpr(): ?array + { + if ($this->vectorOrder === null) { + return null; + } + + $attr = $this->resolveAndWrap($this->vectorOrder['attribute']); + $operator = match ($this->vectorOrder['metric']) { + 'cosine' => '<=>', + 'euclidean' => '<->', + 'dot' => '<#>', + default => '<=>', + }; + $vectorJson = \json_encode($this->vectorOrder['vector']); + + return [ + 'expression' => '(' . $attr . ' ' . $operator . ' ?::vector) ASC', + 'bindings' => [$vectorJson], + ]; + } + + public function reset(): static + { + parent::reset(); + $this->jsonSets = []; + $this->vectorOrder = null; + $this->returningColumns = []; + + return $this; + } + + private function compileSpatialFilter(Method $method, string $attribute, Query $query): string + { + $values = $query->getValues(); + + return match ($method) { + Method::DistanceLessThan, + Method::DistanceGreaterThan, + Method::DistanceEqual, + Method::DistanceNotEqual => $this->compileSpatialDistance($method, $attribute, $values), + Method::Intersects => $this->compileSpatialPredicate('ST_Intersects', $attribute, $values, false), + Method::NotIntersects => $this->compileSpatialPredicate('ST_Intersects', $attribute, $values, true), + Method::Crosses => $this->compileSpatialPredicate('ST_Crosses', $attribute, $values, false), + Method::NotCrosses => $this->compileSpatialPredicate('ST_Crosses', $attribute, $values, true), + Method::Overlaps => $this->compileSpatialPredicate('ST_Overlaps', $attribute, $values, false), + Method::NotOverlaps => $this->compileSpatialPredicate('ST_Overlaps', $attribute, $values, true), + Method::Touches => $this->compileSpatialPredicate('ST_Touches', $attribute, $values, false), + Method::NotTouches => $this->compileSpatialPredicate('ST_Touches', $attribute, $values, true), + Method::Covers => $this->compileSpatialPredicate('ST_Covers', $attribute, $values, false), + Method::NotCovers => $this->compileSpatialPredicate('ST_Covers', $attribute, $values, true), + Method::SpatialEquals => $this->compileSpatialPredicate('ST_Equals', $attribute, $values, false), + Method::NotSpatialEquals => $this->compileSpatialPredicate('ST_Equals', $attribute, $values, true), + default => parent::compileFilter($query), + }; + } + + /** + * @param array $values + */ + private function compileSpatialDistance(Method $method, string $attribute, array $values): string + { + /** @var array{0: string, 1: float, 2: bool} $data */ + $data = $values[0]; + $wkt = $data[0]; + $distance = $data[1]; + $meters = $data[2]; + + $operator = match ($method) { + Method::DistanceLessThan => '<', + Method::DistanceGreaterThan => '>', + Method::DistanceEqual => '=', + Method::DistanceNotEqual => '!=', + default => '<', + }; + + if ($meters) { + $this->addBinding($wkt); + $this->addBinding($distance); + + return 'ST_Distance((' . $attribute . '::geography), ST_SetSRID(ST_GeomFromText(?), 4326)::geography) ' . $operator . ' ?'; + } + + $this->addBinding($wkt); + $this->addBinding($distance); + + return 'ST_Distance(' . $attribute . ', ST_GeomFromText(?)) ' . $operator . ' ?'; + } + + /** + * @param array $values + */ + private function compileSpatialPredicate(string $function, string $attribute, array $values, bool $not): string + { + /** @var array $geometry */ + $geometry = $values[0]; + $wkt = $this->geometryToWkt($geometry); + $this->addBinding($wkt); + + $expr = $function . '(' . $attribute . ', ST_GeomFromText(?, 4326))'; + + return $not ? 'NOT ' . $expr : $expr; + } + + private function compileJsonFilter(Method $method, string $attribute, Query $query): string + { + $values = $query->getValues(); + + return match ($method) { + Method::JsonContains => $this->compileJsonContainsExpr($attribute, $values, false), + Method::JsonNotContains => $this->compileJsonContainsExpr($attribute, $values, true), + Method::JsonOverlaps => $this->compileJsonOverlapsExpr($attribute, $values), + Method::JsonPath => $this->compileJsonPathExpr($attribute, $values), + default => parent::compileFilter($query), + }; + } + + /** + * @param array $values + */ + private function compileJsonContainsExpr(string $attribute, array $values, bool $not): string + { + $this->addBinding(\json_encode($values[0])); + $expr = $attribute . ' @> ?::jsonb'; + + return $not ? 'NOT (' . $expr . ')' : $expr; + } + + /** + * @param array $values + */ + private function compileJsonOverlapsExpr(string $attribute, array $values): string + { + /** @var array $arr */ + $arr = $values[0]; + $this->addBinding(\json_encode($arr)); + + return $attribute . ' ?| ARRAY(SELECT jsonb_array_elements_text(?::jsonb))'; + } + + /** + * @param array $values + */ + private function compileJsonPathExpr(string $attribute, array $values): string + { + /** @var string $path */ + $path = $values[0]; + /** @var string $operator */ + $operator = $values[1]; + $value = $values[2]; + + if (!\preg_match('/^[a-zA-Z0-9_.\[\]]+$/', $path)) { + throw new ValidationException('Invalid JSON path: ' . $path); + } + + $allowedOperators = ['=', '!=', '<', '>', '<=', '>=', '<>']; + if (!\in_array($operator, $allowedOperators, true)) { + throw new ValidationException('Invalid JSON path operator: ' . $operator); + } + + $this->addBinding($value); + + return $attribute . '->>\''. $path . '\' ' . $operator . ' ?'; + } + + private function compileVectorFilter(Method $method, string $attribute, Query $query): string + { + $values = $query->getValues(); + /** @var array $vector */ + $vector = $values[0]; + + $operator = match ($method) { + Method::VectorCosine => '<=>', + Method::VectorEuclidean => '<->', + Method::VectorDot => '<#>', + default => '<=>', + }; + + $this->addBinding(\json_encode($vector)); + + return '(' . $attribute . ' ' . $operator . ' ?::vector)'; + } + +} From 741593a399712d406d8d440a8b8870a80e52f186 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Mar 2026 19:59:03 +1300 Subject: [PATCH 21/29] (feat): Add Schema builder layer with MySQL, PostgreSQL, and ClickHouse support --- src/Query/Schema.php | 273 +++++++++++++++++++++ src/Query/Schema/Blueprint.php | 297 +++++++++++++++++++++++ src/Query/Schema/ClickHouse.php | 185 ++++++++++++++ src/Query/Schema/Column.php | 98 ++++++++ src/Query/Schema/Feature/ForeignKeys.php | 20 ++ src/Query/Schema/Feature/Procedures.php | 15 ++ src/Query/Schema/Feature/Triggers.php | 18 ++ src/Query/Schema/ForeignKey.php | 61 +++++ src/Query/Schema/Index.php | 30 +++ src/Query/Schema/MySQL.php | 32 +++ src/Query/Schema/PostgreSQL.php | 294 ++++++++++++++++++++++ src/Query/Schema/SQL.php | 141 +++++++++++ 12 files changed, 1464 insertions(+) create mode 100644 src/Query/Schema.php create mode 100644 src/Query/Schema/Blueprint.php create mode 100644 src/Query/Schema/ClickHouse.php create mode 100644 src/Query/Schema/Column.php create mode 100644 src/Query/Schema/Feature/ForeignKeys.php create mode 100644 src/Query/Schema/Feature/Procedures.php create mode 100644 src/Query/Schema/Feature/Triggers.php create mode 100644 src/Query/Schema/ForeignKey.php create mode 100644 src/Query/Schema/Index.php create mode 100644 src/Query/Schema/MySQL.php create mode 100644 src/Query/Schema/PostgreSQL.php create mode 100644 src/Query/Schema/SQL.php diff --git a/src/Query/Schema.php b/src/Query/Schema.php new file mode 100644 index 0000000..ded1f42 --- /dev/null +++ b/src/Query/Schema.php @@ -0,0 +1,273 @@ +getColumns() as $column) { + $def = $this->compileColumnDefinition($column); + $columnDefs[] = $def; + + if ($column->isPrimary) { + $primaryKeys[] = $this->quote($column->name); + } + if ($column->isUnique) { + $uniqueColumns[] = $column->name; + } + } + + // Inline PRIMARY KEY constraint + if (! empty($primaryKeys)) { + $columnDefs[] = 'PRIMARY KEY (' . \implode(', ', $primaryKeys) . ')'; + } + + // Inline UNIQUE constraints for columns marked unique + foreach ($uniqueColumns as $col) { + $columnDefs[] = 'UNIQUE (' . $this->quote($col) . ')'; + } + + // Indexes + foreach ($blueprint->getIndexes() as $index) { + $cols = \array_map(fn (string $c): string => $this->quote($c), $index->columns); + $keyword = $index->type === 'unique' ? 'UNIQUE INDEX' : 'INDEX'; + $columnDefs[] = $keyword . ' ' . $this->quote($index->name) + . ' (' . \implode(', ', $cols) . ')'; + } + + // Foreign keys + foreach ($blueprint->getForeignKeys() as $fk) { + $def = 'FOREIGN KEY (' . $this->quote($fk->column) . ')' + . ' REFERENCES ' . $this->quote($fk->refTable) + . ' (' . $this->quote($fk->refColumn) . ')'; + if ($fk->onDelete !== '') { + $def .= ' ON DELETE ' . $fk->onDelete; + } + if ($fk->onUpdate !== '') { + $def .= ' ON UPDATE ' . $fk->onUpdate; + } + $columnDefs[] = $def; + } + + $sql = 'CREATE TABLE ' . $this->quote($table) + . ' (' . \implode(', ', $columnDefs) . ')'; + + return new BuildResult($sql, []); + } + + /** + * @param callable(Blueprint): void $definition + */ + public function alter(string $table, callable $definition): BuildResult + { + $blueprint = new Blueprint(); + $definition($blueprint); + + $alterations = []; + + foreach ($blueprint->getColumns() as $column) { + $keyword = $column->isModify ? 'MODIFY COLUMN' : 'ADD COLUMN'; + $def = $keyword . ' ' . $this->compileColumnDefinition($column); + if ($column->after !== null) { + $def .= ' AFTER ' . $this->quote($column->after); + } + $alterations[] = $def; + } + + foreach ($blueprint->getRenameColumns() as $rename) { + $alterations[] = 'RENAME COLUMN ' . $this->quote($rename['from']) + . ' TO ' . $this->quote($rename['to']); + } + + foreach ($blueprint->getDropColumns() as $col) { + $alterations[] = 'DROP COLUMN ' . $this->quote($col); + } + + foreach ($blueprint->getIndexes() as $index) { + $cols = \array_map(fn (string $c): string => $this->quote($c), $index->columns); + $alterations[] = 'ADD INDEX ' . $this->quote($index->name) + . ' (' . \implode(', ', $cols) . ')'; + } + + foreach ($blueprint->getDropIndexes() as $name) { + $alterations[] = 'DROP INDEX ' . $this->quote($name); + } + + foreach ($blueprint->getForeignKeys() as $fk) { + $def = 'ADD FOREIGN KEY (' . $this->quote($fk->column) . ')' + . ' REFERENCES ' . $this->quote($fk->refTable) + . ' (' . $this->quote($fk->refColumn) . ')'; + if ($fk->onDelete !== '') { + $def .= ' ON DELETE ' . $fk->onDelete; + } + if ($fk->onUpdate !== '') { + $def .= ' ON UPDATE ' . $fk->onUpdate; + } + $alterations[] = $def; + } + + foreach ($blueprint->getDropForeignKeys() as $name) { + $alterations[] = 'DROP FOREIGN KEY ' . $this->quote($name); + } + + $sql = 'ALTER TABLE ' . $this->quote($table) + . ' ' . \implode(', ', $alterations); + + return new BuildResult($sql, []); + } + + public function drop(string $table): BuildResult + { + return new BuildResult('DROP TABLE ' . $this->quote($table), []); + } + + public function dropIfExists(string $table): BuildResult + { + return new BuildResult('DROP TABLE IF EXISTS ' . $this->quote($table), []); + } + + public function rename(string $from, string $to): BuildResult + { + return new BuildResult( + 'RENAME TABLE ' . $this->quote($from) . ' TO ' . $this->quote($to), + [] + ); + } + + public function truncate(string $table): BuildResult + { + return new BuildResult('TRUNCATE TABLE ' . $this->quote($table), []); + } + + /** + * @param string[] $columns + */ + public function createIndex( + string $table, + string $name, + array $columns, + bool $unique = false, + string $type = '', + ): BuildResult { + $cols = \array_map(fn (string $c): string => $this->quote($c), $columns); + + $keyword = match (true) { + $unique => 'CREATE UNIQUE INDEX', + $type === 'fulltext' => 'CREATE FULLTEXT INDEX', + $type === 'spatial' => 'CREATE SPATIAL INDEX', + default => 'CREATE INDEX', + }; + + $sql = $keyword . ' ' . $this->quote($name) + . ' ON ' . $this->quote($table) + . ' (' . \implode(', ', $cols) . ')'; + + return new BuildResult($sql, []); + } + + public function dropIndex(string $table, string $name): BuildResult + { + return new BuildResult( + 'DROP INDEX ' . $this->quote($name) . ' ON ' . $this->quote($table), + [] + ); + } + + public function createView(string $name, Builder $query): BuildResult + { + $result = $query->build(); + $sql = 'CREATE VIEW ' . $this->quote($name) . ' AS ' . $result->query; + + return new BuildResult($sql, $result->bindings); + } + + public function createOrReplaceView(string $name, Builder $query): BuildResult + { + $result = $query->build(); + $sql = 'CREATE OR REPLACE VIEW ' . $this->quote($name) . ' AS ' . $result->query; + + return new BuildResult($sql, $result->bindings); + } + + public function dropView(string $name): BuildResult + { + return new BuildResult('DROP VIEW ' . $this->quote($name), []); + } + + protected function compileColumnDefinition(Column $column): string + { + $parts = [ + $this->quote($column->name), + $this->compileColumnType($column), + ]; + + if ($column->isUnsigned) { + $unsigned = $this->compileUnsigned(); + if ($unsigned !== '') { + $parts[] = $unsigned; + } + } + + if ($column->isAutoIncrement) { + $parts[] = $this->compileAutoIncrement(); + } + + if (! $column->isNullable) { + $parts[] = 'NOT NULL'; + } else { + $parts[] = 'NULL'; + } + + if ($column->hasDefault) { + $parts[] = 'DEFAULT ' . $this->compileDefaultValue($column->default); + } + + if ($column->comment !== null) { + $parts[] = "COMMENT '" . \str_replace("'", "''", $column->comment) . "'"; + } + + return \implode(' ', $parts); + } + + protected function compileDefaultValue(mixed $value): string + { + if ($value === null) { + return 'NULL'; + } + if (\is_bool($value)) { + return $value ? '1' : '0'; + } + if (\is_int($value) || \is_float($value)) { + return (string) $value; + } + + /** @var string|int|float $value */ + return "'" . \str_replace("'", "''", (string) $value) . "'"; + } + + protected function compileUnsigned(): string + { + return 'UNSIGNED'; + } +} diff --git a/src/Query/Schema/Blueprint.php b/src/Query/Schema/Blueprint.php new file mode 100644 index 0000000..7057905 --- /dev/null +++ b/src/Query/Schema/Blueprint.php @@ -0,0 +1,297 @@ + */ + private array $columns = []; + + /** @var list */ + private array $indexes = []; + + /** @var list */ + private array $foreignKeys = []; + + /** @var list */ + private array $dropColumns = []; + + /** @var list */ + private array $renameColumns = []; + + /** @var list */ + private array $dropIndexes = []; + + /** @var list */ + private array $dropForeignKeys = []; + + public function id(string $name = 'id'): Column + { + $col = new Column($name, 'bigInteger'); + $col->isUnsigned = true; + $col->isAutoIncrement = true; + $col->isPrimary = true; + $this->columns[] = $col; + + return $col; + } + + public function string(string $name, int $length = 255): Column + { + $col = new Column($name, 'string', $length); + $this->columns[] = $col; + + return $col; + } + + public function text(string $name): Column + { + $col = new Column($name, 'text'); + $this->columns[] = $col; + + return $col; + } + + public function integer(string $name): Column + { + $col = new Column($name, 'integer'); + $this->columns[] = $col; + + return $col; + } + + public function bigInteger(string $name): Column + { + $col = new Column($name, 'bigInteger'); + $this->columns[] = $col; + + return $col; + } + + public function float(string $name): Column + { + $col = new Column($name, 'float'); + $this->columns[] = $col; + + return $col; + } + + public function boolean(string $name): Column + { + $col = new Column($name, 'boolean'); + $this->columns[] = $col; + + return $col; + } + + public function datetime(string $name, int $precision = 0): Column + { + $col = new Column($name, 'datetime', precision: $precision); + $this->columns[] = $col; + + return $col; + } + + public function timestamp(string $name, int $precision = 0): Column + { + $col = new Column($name, 'timestamp', precision: $precision); + $this->columns[] = $col; + + return $col; + } + + public function json(string $name): Column + { + $col = new Column($name, 'json'); + $this->columns[] = $col; + + return $col; + } + + public function binary(string $name): Column + { + $col = new Column($name, 'binary'); + $this->columns[] = $col; + + return $col; + } + + /** + * @param string[] $values + */ + public function enum(string $name, array $values): Column + { + $col = new Column($name, 'enum'); + $col->enumValues = $values; + $this->columns[] = $col; + + return $col; + } + + public function point(string $name, int $srid = 4326): Column + { + $col = new Column($name, 'point'); + $col->srid = $srid; + $this->columns[] = $col; + + return $col; + } + + public function linestring(string $name, int $srid = 4326): Column + { + $col = new Column($name, 'linestring'); + $col->srid = $srid; + $this->columns[] = $col; + + return $col; + } + + public function polygon(string $name, int $srid = 4326): Column + { + $col = new Column($name, 'polygon'); + $col->srid = $srid; + $this->columns[] = $col; + + return $col; + } + + public function vector(string $name, int $dimensions): Column + { + $col = new Column($name, 'vector'); + $col->dimensions = $dimensions; + $this->columns[] = $col; + + return $col; + } + + public function timestamps(int $precision = 3): void + { + $this->datetime('created_at', $precision); + $this->datetime('updated_at', $precision); + } + + /** + * @param string[] $columns + */ + public function index(array $columns, string $name = '', string $method = '', string $operatorClass = ''): void + { + if ($name === '') { + $name = 'idx_' . \implode('_', $columns); + } + $this->indexes[] = new Index($name, $columns, method: $method, operatorClass: $operatorClass); + } + + /** + * @param string[] $columns + */ + public function uniqueIndex(array $columns, string $name = ''): void + { + if ($name === '') { + $name = 'uniq_' . \implode('_', $columns); + } + $this->indexes[] = new Index($name, $columns, 'unique'); + } + + public function foreignKey(string $column): ForeignKey + { + $fk = new ForeignKey($column); + $this->foreignKeys[] = $fk; + + return $fk; + } + + public function addColumn(string $name, string $type, int|null $lengthOrPrecision = null): Column + { + $col = new Column($name, $type, $type === 'string' ? $lengthOrPrecision : null, $type !== 'string' ? $lengthOrPrecision : null); + $this->columns[] = $col; + + return $col; + } + + public function modifyColumn(string $name, string $type, int|null $lengthOrPrecision = null): Column + { + $col = new Column($name, $type, $type === 'string' ? $lengthOrPrecision : null, $type !== 'string' ? $lengthOrPrecision : null); + $col->isModify = true; + $this->columns[] = $col; + + return $col; + } + + public function renameColumn(string $from, string $to): void + { + $this->renameColumns[] = ['from' => $from, 'to' => $to]; + } + + public function dropColumn(string $name): void + { + $this->dropColumns[] = $name; + } + + /** + * @param string[] $columns + */ + public function addIndex(string $name, array $columns): void + { + $this->indexes[] = new Index($name, $columns); + } + + public function dropIndex(string $name): void + { + $this->dropIndexes[] = $name; + } + + public function addForeignKey(string $column): ForeignKey + { + $fk = new ForeignKey($column); + $this->foreignKeys[] = $fk; + + return $fk; + } + + public function dropForeignKey(string $name): void + { + $this->dropForeignKeys[] = $name; + } + + /** @return list */ + public function getColumns(): array + { + return $this->columns; + } + + /** @return list */ + public function getIndexes(): array + { + return $this->indexes; + } + + /** @return list */ + public function getForeignKeys(): array + { + return $this->foreignKeys; + } + + /** @return list */ + public function getDropColumns(): array + { + return $this->dropColumns; + } + + /** @return list */ + public function getRenameColumns(): array + { + return $this->renameColumns; + } + + /** @return list */ + public function getDropIndexes(): array + { + return $this->dropIndexes; + } + + /** @return list */ + public function getDropForeignKeys(): array + { + return $this->dropForeignKeys; + } +} diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php new file mode 100644 index 0000000..fd4f016 --- /dev/null +++ b/src/Query/Schema/ClickHouse.php @@ -0,0 +1,185 @@ +type) { + 'string' => 'String', + 'text' => 'String', + 'integer' => $column->isUnsigned ? 'UInt32' : 'Int32', + 'bigInteger' => $column->isUnsigned ? 'UInt64' : 'Int64', + 'float' => 'Float64', + 'boolean' => 'UInt8', + 'datetime' => $column->precision ? 'DateTime64(' . $column->precision . ')' : 'DateTime', + 'timestamp' => $column->precision ? 'DateTime64(' . $column->precision . ')' : 'DateTime', + 'json' => 'String', + 'binary' => 'String', + 'enum' => $this->compileClickHouseEnum($column->enumValues), + 'point' => 'Tuple(Float64, Float64)', + 'linestring' => 'Array(Tuple(Float64, Float64))', + 'polygon' => 'Array(Array(Tuple(Float64, Float64)))', + 'vector' => 'Array(Float64)', + default => throw new UnsupportedException('Unknown column type: ' . $column->type), + }; + + if ($column->isNullable) { + $type = 'Nullable(' . $type . ')'; + } + + return $type; + } + + protected function compileAutoIncrement(): string + { + return ''; + } + + protected function compileUnsigned(): string + { + return ''; + } + + protected function compileColumnDefinition(Column $column): string + { + $parts = [ + $this->quote($column->name), + $this->compileColumnType($column), + ]; + + if ($column->hasDefault) { + $parts[] = 'DEFAULT ' . $this->compileDefaultValue($column->default); + } + + if ($column->comment !== null) { + $parts[] = "COMMENT '" . \str_replace("'", "''", $column->comment) . "'"; + } + + return \implode(' ', $parts); + } + + public function dropIndex(string $table, string $name): BuildResult + { + return new BuildResult( + 'ALTER TABLE ' . $this->quote($table) + . ' DROP INDEX ' . $this->quote($name), + [] + ); + } + + /** + * @param callable(Blueprint): void $definition + */ + public function alter(string $table, callable $definition): BuildResult + { + $blueprint = new Blueprint(); + $definition($blueprint); + + $alterations = []; + + foreach ($blueprint->getColumns() as $column) { + $keyword = $column->isModify ? 'MODIFY COLUMN' : 'ADD COLUMN'; + $alterations[] = $keyword . ' ' . $this->compileColumnDefinition($column); + } + + foreach ($blueprint->getRenameColumns() as $rename) { + $alterations[] = 'RENAME COLUMN ' . $this->quote($rename['from']) + . ' TO ' . $this->quote($rename['to']); + } + + foreach ($blueprint->getDropColumns() as $col) { + $alterations[] = 'DROP COLUMN ' . $this->quote($col); + } + + foreach ($blueprint->getDropIndexes() as $name) { + $alterations[] = 'DROP INDEX ' . $this->quote($name); + } + + if (! empty($blueprint->getForeignKeys())) { + throw new UnsupportedException('Foreign keys are not supported in ClickHouse.'); + } + + if (! empty($blueprint->getDropForeignKeys())) { + throw new UnsupportedException('Foreign keys are not supported in ClickHouse.'); + } + + $sql = 'ALTER TABLE ' . $this->quote($table) + . ' ' . \implode(', ', $alterations); + + return new BuildResult($sql, []); + } + + /** + * @param callable(Blueprint): void $definition + */ + public function create(string $table, callable $definition): BuildResult + { + $blueprint = new Blueprint(); + $definition($blueprint); + + $columnDefs = []; + $primaryKeys = []; + + foreach ($blueprint->getColumns() as $column) { + $def = $this->compileColumnDefinition($column); + $columnDefs[] = $def; + + if ($column->isPrimary) { + $primaryKeys[] = $this->quote($column->name); + } + } + + // Indexes (ClickHouse uses INDEX ... TYPE ... GRANULARITY ...) + foreach ($blueprint->getIndexes() as $index) { + $cols = \array_map(fn (string $c): string => $this->quote($c), $index->columns); + $expr = \count($cols) === 1 ? $cols[0] : '(' . \implode(', ', $cols) . ')'; + $columnDefs[] = 'INDEX ' . $this->quote($index->name) + . ' ' . $expr . ' TYPE minmax GRANULARITY 3'; + } + + if (! empty($blueprint->getForeignKeys())) { + throw new UnsupportedException('Foreign keys are not supported in ClickHouse.'); + } + + $sql = 'CREATE TABLE ' . $this->quote($table) + . ' (' . \implode(', ', $columnDefs) . ')' + . ' ENGINE = MergeTree()'; + + if (! empty($primaryKeys)) { + $sql .= ' ORDER BY (' . \implode(', ', $primaryKeys) . ')'; + } + + return new BuildResult($sql, []); + } + + public function createView(string $name, Builder $query): BuildResult + { + $result = $query->build(); + $sql = 'CREATE VIEW ' . $this->quote($name) . ' AS ' . $result->query; + + return new BuildResult($sql, $result->bindings); + } + + /** + * @param string[] $values + */ + private function compileClickHouseEnum(array $values): string + { + $parts = []; + foreach (\array_values($values) as $i => $value) { + $parts[] = "'" . \str_replace("'", "\\'", $value) . "' = " . ($i + 1); + } + + return 'Enum8(' . \implode(', ', $parts) . ')'; + } +} diff --git a/src/Query/Schema/Column.php b/src/Query/Schema/Column.php new file mode 100644 index 0000000..3f1dfac --- /dev/null +++ b/src/Query/Schema/Column.php @@ -0,0 +1,98 @@ +isNullable = true; + + return $this; + } + + public function default(mixed $value): static + { + $this->default = $value; + $this->hasDefault = true; + + return $this; + } + + public function unsigned(): static + { + $this->isUnsigned = true; + + return $this; + } + + public function unique(): static + { + $this->isUnique = true; + + return $this; + } + + public function primary(): static + { + $this->isPrimary = true; + + return $this; + } + + public function after(string $column): static + { + $this->after = $column; + + return $this; + } + + public function autoIncrement(): static + { + $this->isAutoIncrement = true; + + return $this; + } + + public function comment(string $comment): static + { + $this->comment = $comment; + + return $this; + } +} diff --git a/src/Query/Schema/Feature/ForeignKeys.php b/src/Query/Schema/Feature/ForeignKeys.php new file mode 100644 index 0000000..f665c3d --- /dev/null +++ b/src/Query/Schema/Feature/ForeignKeys.php @@ -0,0 +1,20 @@ + $params + */ + public function createProcedure(string $name, array $params, string $body): BuildResult; + + public function dropProcedure(string $name): BuildResult; +} diff --git a/src/Query/Schema/Feature/Triggers.php b/src/Query/Schema/Feature/Triggers.php new file mode 100644 index 0000000..62ad02d --- /dev/null +++ b/src/Query/Schema/Feature/Triggers.php @@ -0,0 +1,18 @@ +column = $column; + } + + public function references(string $column): static + { + $this->refColumn = $column; + + return $this; + } + + public function on(string $table): static + { + $this->refTable = $table; + + return $this; + } + + private const ALLOWED_ACTIONS = ['CASCADE', 'SET NULL', 'SET DEFAULT', 'RESTRICT', 'NO ACTION']; + + public function onDelete(string $action): static + { + $action = \strtoupper($action); + if (!\in_array($action, self::ALLOWED_ACTIONS, true)) { + throw new \InvalidArgumentException('Invalid foreign key action: ' . $action); + } + + $this->onDelete = $action; + + return $this; + } + + public function onUpdate(string $action): static + { + $action = \strtoupper($action); + if (!\in_array($action, self::ALLOWED_ACTIONS, true)) { + throw new \InvalidArgumentException('Invalid foreign key action: ' . $action); + } + + $this->onUpdate = $action; + + return $this; + } +} diff --git a/src/Query/Schema/Index.php b/src/Query/Schema/Index.php new file mode 100644 index 0000000..8360f2f --- /dev/null +++ b/src/Query/Schema/Index.php @@ -0,0 +1,30 @@ + $lengths + * @param array $orders + */ + public function __construct( + public string $name, + public array $columns, + public string $type = 'index', + public array $lengths = [], + public array $orders = [], + public string $method = '', + public string $operatorClass = '', + ) { + if ($method !== '' && ! \preg_match('/^[A-Za-z0-9_]+$/', $method)) { + throw new ValidationException('Invalid index method: ' . $method); + } + if ($operatorClass !== '' && ! \preg_match('/^[A-Za-z0-9_.]+$/', $operatorClass)) { + throw new ValidationException('Invalid operator class: ' . $operatorClass); + } + } +} diff --git a/src/Query/Schema/MySQL.php b/src/Query/Schema/MySQL.php new file mode 100644 index 0000000..e151674 --- /dev/null +++ b/src/Query/Schema/MySQL.php @@ -0,0 +1,32 @@ +type) { + 'string' => 'VARCHAR(' . ($column->length ?? 255) . ')', + 'text' => 'TEXT', + 'integer' => 'INT', + 'bigInteger' => 'BIGINT', + 'float' => 'DOUBLE', + 'boolean' => 'TINYINT(1)', + 'datetime' => $column->precision ? 'DATETIME(' . $column->precision . ')' : 'DATETIME', + 'timestamp' => $column->precision ? 'TIMESTAMP(' . $column->precision . ')' : 'TIMESTAMP', + 'json' => 'JSON', + 'binary' => 'BLOB', + 'enum' => "ENUM('" . \implode("','", \array_map(fn ($v) => \str_replace("'", "''", $v), $column->enumValues)) . "')", + 'point' => 'POINT' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), + 'linestring' => 'LINESTRING' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), + 'polygon' => 'POLYGON' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), + default => throw new \Utopia\Query\Exception\UnsupportedException('Unknown column type: ' . $column->type), + }; + } + + protected function compileAutoIncrement(): string + { + return 'AUTO_INCREMENT'; + } +} diff --git a/src/Query/Schema/PostgreSQL.php b/src/Query/Schema/PostgreSQL.php new file mode 100644 index 0000000..606a7c1 --- /dev/null +++ b/src/Query/Schema/PostgreSQL.php @@ -0,0 +1,294 @@ +type) { + 'string' => 'VARCHAR(' . ($column->length ?? 255) . ')', + 'text' => 'TEXT', + 'integer' => 'INTEGER', + 'bigInteger' => 'BIGINT', + 'float' => 'DOUBLE PRECISION', + 'boolean' => 'BOOLEAN', + 'datetime' => $column->precision ? 'TIMESTAMP(' . $column->precision . ')' : 'TIMESTAMP', + 'timestamp' => $column->precision ? 'TIMESTAMP(' . $column->precision . ') WITHOUT TIME ZONE' : 'TIMESTAMP WITHOUT TIME ZONE', + 'json' => 'JSONB', + 'binary' => 'BYTEA', + 'enum' => 'TEXT', + 'point' => 'GEOMETRY(POINT' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', + 'linestring' => 'GEOMETRY(LINESTRING' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', + 'polygon' => 'GEOMETRY(POLYGON' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', + 'vector' => 'VECTOR(' . ($column->dimensions ?? 0) . ')', + default => throw new UnsupportedException('Unknown column type: ' . $column->type), + }; + } + + protected function compileAutoIncrement(): string + { + return 'GENERATED BY DEFAULT AS IDENTITY'; + } + + protected function compileUnsigned(): string + { + return ''; + } + + protected function compileColumnDefinition(Column $column): string + { + $parts = [ + $this->quote($column->name), + $this->compileColumnType($column), + ]; + + if ($column->isUnsigned) { + $unsigned = $this->compileUnsigned(); + if ($unsigned !== '') { + $parts[] = $unsigned; + } + } + + if ($column->isAutoIncrement) { + $parts[] = $this->compileAutoIncrement(); + } + + if (! $column->isNullable) { + $parts[] = 'NOT NULL'; + } else { + $parts[] = 'NULL'; + } + + if ($column->hasDefault) { + $parts[] = 'DEFAULT ' . $this->compileDefaultValue($column->default); + } + + // PostgreSQL enum emulation via CHECK constraint + if ($column->type === 'enum' && ! empty($column->enumValues)) { + $values = \array_map(fn (string $v): string => "'" . \str_replace("'", "''", $v) . "'", $column->enumValues); + $parts[] = 'CHECK (' . $this->quote($column->name) . ' IN (' . \implode(', ', $values) . '))'; + } + + // No inline COMMENT in PostgreSQL (use COMMENT ON COLUMN separately) + + return \implode(' ', $parts); + } + + /** + * @param string[] $columns + */ + public function createIndex( + string $table, + string $name, + array $columns, + bool $unique = false, + string $type = '', + string $method = '', + string $operatorClass = '', + ): BuildResult { + if ($method !== '' && ! \preg_match('/^[A-Za-z0-9_]+$/', $method)) { + throw new ValidationException('Invalid index method: ' . $method); + } + if ($operatorClass !== '' && ! \preg_match('/^[A-Za-z0-9_.]+$/', $operatorClass)) { + throw new ValidationException('Invalid operator class: ' . $operatorClass); + } + + $keyword = $unique ? 'CREATE UNIQUE INDEX' : 'CREATE INDEX'; + + $sql = $keyword . ' ' . $this->quote($name) + . ' ON ' . $this->quote($table); + + if ($method !== '') { + $sql .= ' USING ' . \strtoupper($method); + } + + $colParts = []; + foreach ($columns as $c) { + $part = $this->quote($c); + if ($operatorClass !== '') { + $part .= ' ' . $operatorClass; + } + $colParts[] = $part; + } + + $sql .= ' (' . \implode(', ', $colParts) . ')'; + + return new BuildResult($sql, []); + } + + public function dropIndex(string $table, string $name): BuildResult + { + return new BuildResult( + 'DROP INDEX ' . $this->quote($name), + [] + ); + } + + public function dropForeignKey(string $table, string $name): BuildResult + { + return new BuildResult( + 'ALTER TABLE ' . $this->quote($table) + . ' DROP CONSTRAINT ' . $this->quote($name), + [] + ); + } + + /** + * @param list $params + */ + public function createProcedure(string $name, array $params, string $body): BuildResult + { + $paramList = $this->compileProcedureParams($params); + + $sql = 'CREATE FUNCTION ' . $this->quote($name) + . '(' . \implode(', ', $paramList) . ')' + . ' RETURNS VOID LANGUAGE plpgsql AS $$ BEGIN ' . $body . ' END; $$'; + + return new BuildResult($sql, []); + } + + public function dropProcedure(string $name): BuildResult + { + return new BuildResult('DROP FUNCTION ' . $this->quote($name), []); + } + + public function createTrigger( + string $name, + string $table, + string $timing, + string $event, + string $body, + ): BuildResult { + $timing = \strtoupper($timing); + $event = \strtoupper($event); + + if (!\in_array($timing, ['BEFORE', 'AFTER', 'INSTEAD OF'], true)) { + throw new \Utopia\Query\Exception\ValidationException('Invalid trigger timing: ' . $timing); + } + if (!\in_array($event, ['INSERT', 'UPDATE', 'DELETE'], true)) { + throw new \Utopia\Query\Exception\ValidationException('Invalid trigger event: ' . $event); + } + + $funcName = $name . '_func'; + + $sql = 'CREATE FUNCTION ' . $this->quote($funcName) + . '() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN ' . $body . ' RETURN NEW; END; $$; ' + . 'CREATE TRIGGER ' . $this->quote($name) + . ' ' . $timing . ' ' . $event + . ' ON ' . $this->quote($table) + . ' FOR EACH ROW EXECUTE FUNCTION ' . $this->quote($funcName) . '()'; + + return new BuildResult($sql, []); + } + + /** + * @param callable(Blueprint): void $definition + */ + public function alter(string $table, callable $definition): BuildResult + { + $blueprint = new Blueprint(); + $definition($blueprint); + + $alterations = []; + + foreach ($blueprint->getColumns() as $column) { + $keyword = $column->isModify ? 'ALTER COLUMN' : 'ADD COLUMN'; + if ($column->isModify) { + $def = $keyword . ' ' . $this->quote($column->name) + . ' TYPE ' . $this->compileColumnType($column); + } else { + $def = $keyword . ' ' . $this->compileColumnDefinition($column); + } + $alterations[] = $def; + } + + foreach ($blueprint->getRenameColumns() as $rename) { + $alterations[] = 'RENAME COLUMN ' . $this->quote($rename['from']) + . ' TO ' . $this->quote($rename['to']); + } + + foreach ($blueprint->getDropColumns() as $col) { + $alterations[] = 'DROP COLUMN ' . $this->quote($col); + } + + foreach ($blueprint->getForeignKeys() as $fk) { + $def = 'ADD FOREIGN KEY (' . $this->quote($fk->column) . ')' + . ' REFERENCES ' . $this->quote($fk->refTable) + . ' (' . $this->quote($fk->refColumn) . ')'; + if ($fk->onDelete !== '') { + $def .= ' ON DELETE ' . $fk->onDelete; + } + if ($fk->onUpdate !== '') { + $def .= ' ON UPDATE ' . $fk->onUpdate; + } + $alterations[] = $def; + } + + foreach ($blueprint->getDropForeignKeys() as $name) { + $alterations[] = 'DROP CONSTRAINT ' . $this->quote($name); + } + + $statements = []; + + if (! empty($alterations)) { + $statements[] = 'ALTER TABLE ' . $this->quote($table) + . ' ' . \implode(', ', $alterations); + } + + // PostgreSQL indexes are standalone statements, not ALTER TABLE clauses + foreach ($blueprint->getIndexes() as $index) { + $cols = \array_map(fn (string $c): string => $this->quote($c), $index->columns); + $keyword = $index->type === 'unique' ? 'CREATE UNIQUE INDEX' : 'CREATE INDEX'; + + $indexSql = $keyword . ' ' . $this->quote($index->name) + . ' ON ' . $this->quote($table); + + if ($index->method !== '') { + $indexSql .= ' USING ' . \strtoupper($index->method); + } + + $colParts = []; + foreach ($cols as $c) { + $part = $c; + if ($index->operatorClass !== '') { + $part .= ' ' . $index->operatorClass; + } + $colParts[] = $part; + } + + $indexSql .= ' (' . \implode(', ', $colParts) . ')'; + $statements[] = $indexSql; + } + + foreach ($blueprint->getDropIndexes() as $name) { + $statements[] = 'DROP INDEX ' . $this->quote($name); + } + + return new BuildResult(\implode('; ', $statements), []); + } + + public function rename(string $from, string $to): BuildResult + { + return new BuildResult( + 'ALTER TABLE ' . $this->quote($from) . ' RENAME TO ' . $this->quote($to), + [] + ); + } + + public function createExtension(string $name): BuildResult + { + return new BuildResult('CREATE EXTENSION IF NOT EXISTS ' . $this->quote($name), []); + } + + public function dropExtension(string $name): BuildResult + { + return new BuildResult('DROP EXTENSION IF EXISTS ' . $this->quote($name), []); + } +} diff --git a/src/Query/Schema/SQL.php b/src/Query/Schema/SQL.php new file mode 100644 index 0000000..2452048 --- /dev/null +++ b/src/Query/Schema/SQL.php @@ -0,0 +1,141 @@ +quote($table) + . ' ADD CONSTRAINT ' . $this->quote($name) + . ' FOREIGN KEY (' . $this->quote($column) . ')' + . ' REFERENCES ' . $this->quote($refTable) + . ' (' . $this->quote($refColumn) . ')'; + + if ($onDelete !== '') { + $sql .= ' ON DELETE ' . $onDelete; + } + if ($onUpdate !== '') { + $sql .= ' ON UPDATE ' . $onUpdate; + } + + return new BuildResult($sql, []); + } + + public function dropForeignKey(string $table, string $name): BuildResult + { + return new BuildResult( + 'ALTER TABLE ' . $this->quote($table) + . ' DROP FOREIGN KEY ' . $this->quote($name), + [] + ); + } + + /** + * Validate and compile a procedure parameter list. + * + * @param list $params + * @return list + */ + protected function compileProcedureParams(array $params): array + { + $paramList = []; + foreach ($params as $param) { + $direction = \strtoupper($param[0]); + if (! \in_array($direction, ['IN', 'OUT', 'INOUT'], true)) { + throw new ValidationException('Invalid procedure parameter direction: ' . $param[0]); + } + + $name = $this->quote($param[1]); + + if (! \preg_match('/^[A-Za-z0-9_() ,]+$/', $param[2])) { + throw new ValidationException('Invalid procedure parameter type: ' . $param[2]); + } + + $paramList[] = $direction . ' ' . $name . ' ' . $param[2]; + } + + return $paramList; + } + + /** + * @param list $params + */ + public function createProcedure(string $name, array $params, string $body): BuildResult + { + $paramList = $this->compileProcedureParams($params); + + $sql = 'CREATE PROCEDURE ' . $this->quote($name) + . '(' . \implode(', ', $paramList) . ')' + . ' BEGIN ' . $body . ' END'; + + return new BuildResult($sql, []); + } + + public function dropProcedure(string $name): BuildResult + { + return new BuildResult('DROP PROCEDURE ' . $this->quote($name), []); + } + + public function createTrigger( + string $name, + string $table, + string $timing, + string $event, + string $body, + ): BuildResult { + $timing = \strtoupper($timing); + $event = \strtoupper($event); + + if (!\in_array($timing, ['BEFORE', 'AFTER', 'INSTEAD OF'], true)) { + throw new \Utopia\Query\Exception\ValidationException('Invalid trigger timing: ' . $timing); + } + if (!\in_array($event, ['INSERT', 'UPDATE', 'DELETE'], true)) { + throw new \Utopia\Query\Exception\ValidationException('Invalid trigger event: ' . $event); + } + + $sql = 'CREATE TRIGGER ' . $this->quote($name) + . ' ' . $timing . ' ' . $event + . ' ON ' . $this->quote($table) + . ' FOR EACH ROW BEGIN ' . $body . ' END'; + + return new BuildResult($sql, []); + } + + public function dropTrigger(string $name): BuildResult + { + return new BuildResult('DROP TRIGGER ' . $this->quote($name), []); + } +} From 16994534ef2332f18a3ff43f9b00681572b21642 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Mar 2026 19:59:09 +1300 Subject: [PATCH 22/29] (test): Add comprehensive tests for builders, schema, hooks, and exceptions --- tests/Query/AggregationQueryTest.php | 19 +- tests/Query/Builder/ClickHouseTest.php | 2204 ++++- .../Builder/{SQLTest.php => MySQLTest.php} | 8445 +++++++++++------ tests/Query/Builder/PostgreSQLTest.php | 2336 +++++ .../Exception/UnsupportedExceptionTest.php | 28 + .../Exception/ValidationExceptionTest.php | 28 + .../AttributeTest.php} | 12 +- tests/Query/Hook/Filter/FilterTest.php | 198 + tests/Query/Hook/FilterHookTest.php | 82 - tests/Query/Hook/Join/FilterTest.php | 298 + tests/Query/JoinQueryTest.php | 10 +- tests/Query/LogicalQueryTest.php | 47 + tests/Query/QueryHelperTest.php | 28 +- tests/Query/QueryParseTest.php | 14 - tests/Query/QueryTest.php | 211 +- tests/Query/Schema/ClickHouseTest.php | 372 + tests/Query/Schema/MySQLTest.php | 669 ++ tests/Query/Schema/PostgreSQLTest.php | 504 + tests/Query/SpatialQueryTest.php | 46 + 19 files changed, 12460 insertions(+), 3091 deletions(-) rename tests/Query/Builder/{SQLTest.php => MySQLTest.php} (61%) create mode 100644 tests/Query/Builder/PostgreSQLTest.php create mode 100644 tests/Query/Exception/UnsupportedExceptionTest.php create mode 100644 tests/Query/Exception/ValidationExceptionTest.php rename tests/Query/Hook/{AttributeHookTest.php => Attribute/AttributeTest.php} (72%) create mode 100644 tests/Query/Hook/Filter/FilterTest.php delete mode 100644 tests/Query/Hook/FilterHookTest.php create mode 100644 tests/Query/Hook/Join/FilterTest.php create mode 100644 tests/Query/Schema/ClickHouseTest.php create mode 100644 tests/Query/Schema/MySQLTest.php create mode 100644 tests/Query/Schema/PostgreSQLTest.php diff --git a/tests/Query/AggregationQueryTest.php b/tests/Query/AggregationQueryTest.php index 76c61fc..cac761d 100644 --- a/tests/Query/AggregationQueryTest.php +++ b/tests/Query/AggregationQueryTest.php @@ -93,12 +93,11 @@ public function testAggregateMethodsAreAggregate(): void $this->assertTrue(Method::Avg->isAggregate()); $this->assertTrue(Method::Min->isAggregate()); $this->assertTrue(Method::Max->isAggregate()); + $this->assertTrue(Method::CountDistinct->isAggregate()); $aggMethods = array_filter(Method::cases(), fn (Method $m) => $m->isAggregate()); - $this->assertCount(5, $aggMethods); + $this->assertCount(6, $aggMethods); } - // ── Edge cases ── - public function testCountWithEmptyStringAttribute(): void { $query = Query::count(''); @@ -202,7 +201,7 @@ public function testDistinctIsNotNested(): void public function testCountCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\SQL(); + $builder = new \Utopia\Query\Builder\MySQL(); $query = Query::count('id'); $sql = $query->compile($builder); $this->assertEquals('COUNT(`id`)', $sql); @@ -210,7 +209,7 @@ public function testCountCompileDispatch(): void public function testSumCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\SQL(); + $builder = new \Utopia\Query\Builder\MySQL(); $query = Query::sum('price', 'total'); $sql = $query->compile($builder); $this->assertEquals('SUM(`price`) AS `total`', $sql); @@ -218,7 +217,7 @@ public function testSumCompileDispatch(): void public function testAvgCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\SQL(); + $builder = new \Utopia\Query\Builder\MySQL(); $query = Query::avg('score'); $sql = $query->compile($builder); $this->assertEquals('AVG(`score`)', $sql); @@ -226,7 +225,7 @@ public function testAvgCompileDispatch(): void public function testMinCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\SQL(); + $builder = new \Utopia\Query\Builder\MySQL(); $query = Query::min('price'); $sql = $query->compile($builder); $this->assertEquals('MIN(`price`)', $sql); @@ -234,7 +233,7 @@ public function testMinCompileDispatch(): void public function testMaxCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\SQL(); + $builder = new \Utopia\Query\Builder\MySQL(); $query = Query::max('price'); $sql = $query->compile($builder); $this->assertEquals('MAX(`price`)', $sql); @@ -242,7 +241,7 @@ public function testMaxCompileDispatch(): void public function testGroupByCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\SQL(); + $builder = new \Utopia\Query\Builder\MySQL(); $query = Query::groupBy(['status', 'country']); $sql = $query->compile($builder); $this->assertEquals('`status`, `country`', $sql); @@ -250,7 +249,7 @@ public function testGroupByCompileDispatch(): void public function testHavingCompileDispatchUsesCompileFilter(): void { - $builder = new \Utopia\Query\Builder\SQL(); + $builder = new \Utopia\Query\Builder\MySQL(); $query = Query::having([Query::greaterThan('total', 5)]); $sql = $query->compile($builder); $this->assertEquals('(`total` > ?)', $sql); diff --git a/tests/Query/Builder/ClickHouseTest.php b/tests/Query/Builder/ClickHouseTest.php index be282a0..32ae559 100644 --- a/tests/Query/Builder/ClickHouseTest.php +++ b/tests/Query/Builder/ClickHouseTest.php @@ -3,25 +3,85 @@ namespace Tests\Query\Builder; use PHPUnit\Framework\TestCase; +use Utopia\Query\Builder\Case\Builder as CaseBuilder; use Utopia\Query\Builder\ClickHouse as Builder; use Utopia\Query\Builder\Condition; +use Utopia\Query\Builder\Feature\Aggregates; +use Utopia\Query\Builder\Feature\CTEs; +use Utopia\Query\Builder\Feature\Deletes; +use Utopia\Query\Builder\Feature\Hooks; +use Utopia\Query\Builder\Feature\Inserts; +use Utopia\Query\Builder\Feature\Joins; +use Utopia\Query\Builder\Feature\Locking; +use Utopia\Query\Builder\Feature\Selects; +use Utopia\Query\Builder\Feature\Transactions; +use Utopia\Query\Builder\Feature\Unions; +use Utopia\Query\Builder\Feature\Updates; +use Utopia\Query\Builder\Feature\Upsert; use Utopia\Query\Compiler; use Utopia\Query\Exception; -use Utopia\Query\Hook\AttributeMapHook; -use Utopia\Query\Hook\FilterHook; +use Utopia\Query\Exception\UnsupportedException; +use Utopia\Query\Exception\ValidationException; +use Utopia\Query\Hook\Attribute; +use Utopia\Query\Hook\Attribute\Map as AttributeMap; +use Utopia\Query\Hook\Filter; +use Utopia\Query\Hook\Join\Condition as JoinCondition; +use Utopia\Query\Hook\Join\Filter as JoinFilter; +use Utopia\Query\Hook\Join\Placement; use Utopia\Query\Query; class ClickHouseTest extends TestCase { - // ── Compiler compliance ── - public function testImplementsCompiler(): void { $builder = new Builder(); $this->assertInstanceOf(Compiler::class, $builder); } - // ── Basic queries work identically ── + public function testImplementsSelects(): void + { + $this->assertInstanceOf(Selects::class, new Builder()); + } + + public function testImplementsAggregates(): void + { + $this->assertInstanceOf(Aggregates::class, new Builder()); + } + + public function testImplementsJoins(): void + { + $this->assertInstanceOf(Joins::class, new Builder()); + } + + public function testImplementsUnions(): void + { + $this->assertInstanceOf(Unions::class, new Builder()); + } + + public function testImplementsCTEs(): void + { + $this->assertInstanceOf(CTEs::class, new Builder()); + } + + public function testImplementsInserts(): void + { + $this->assertInstanceOf(Inserts::class, new Builder()); + } + + public function testImplementsUpdates(): void + { + $this->assertInstanceOf(Updates::class, new Builder()); + } + + public function testImplementsDeletes(): void + { + $this->assertInstanceOf(Deletes::class, new Builder()); + } + + public function testImplementsHooks(): void + { + $this->assertInstanceOf(Hooks::class, new Builder()); + } public function testBasicSelect(): void { @@ -52,8 +112,6 @@ public function testFilterAndSort(): void $this->assertEquals(['active', 10, 100], $result->bindings); } - // ── ClickHouse-specific: regex uses match() ── - public function testRegexUsesMatchFunction(): void { $result = (new Builder()) @@ -65,11 +123,9 @@ public function testRegexUsesMatchFunction(): void $this->assertEquals(['^/api/v[0-9]+'], $result->bindings); } - // ── ClickHouse-specific: search throws exception ── - public function testSearchThrowsException(): void { - $this->expectException(Exception::class); + $this->expectException(UnsupportedException::class); $this->expectExceptionMessage('Full-text search (MATCH AGAINST) is not supported in ClickHouse'); (new Builder()) @@ -80,7 +136,7 @@ public function testSearchThrowsException(): void public function testNotSearchThrowsException(): void { - $this->expectException(Exception::class); + $this->expectException(UnsupportedException::class); $this->expectExceptionMessage('Full-text search (MATCH AGAINST) is not supported in ClickHouse'); (new Builder()) @@ -89,8 +145,6 @@ public function testNotSearchThrowsException(): void ->build(); } - // ── ClickHouse-specific: random ordering uses rand() ── - public function testRandomOrderUsesLowercaseRand(): void { $result = (new Builder()) @@ -101,8 +155,6 @@ public function testRandomOrderUsesLowercaseRand(): void $this->assertEquals('SELECT * FROM `events` ORDER BY rand()', $result->query); } - // ── FINAL keyword ── - public function testFinalKeyword(): void { $result = (new Builder()) @@ -129,8 +181,6 @@ public function testFinalWithFilters(): void $this->assertEquals(['active', 10], $result->bindings); } - // ── SAMPLE clause ── - public function testSample(): void { $result = (new Builder()) @@ -152,8 +202,6 @@ public function testSampleWithFinal(): void $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.5', $result->query); } - // ── PREWHERE clause ── - public function testPrewhere(): void { $result = (new Builder()) @@ -216,8 +264,6 @@ public function testPrewhereWithJoinAndWhere(): void $this->assertEquals(['click', 18], $result->bindings); } - // ── Combined ClickHouse features ── - public function testFinalSamplePrewhereWhere(): void { $result = (new Builder()) @@ -237,8 +283,6 @@ public function testFinalSamplePrewhereWhere(): void $this->assertEquals(['click', 5, 100], $result->bindings); } - // ── Aggregations work ── - public function testAggregation(): void { $result = (new Builder()) @@ -256,8 +300,6 @@ public function testAggregation(): void $this->assertEquals([10], $result->bindings); } - // ── Joins work ── - public function testJoin(): void { $result = (new Builder()) @@ -272,8 +314,6 @@ public function testJoin(): void ); } - // ── Distinct ── - public function testDistinct(): void { $result = (new Builder()) @@ -285,8 +325,6 @@ public function testDistinct(): void $this->assertEquals('SELECT DISTINCT `user_id` FROM `events`', $result->query); } - // ── Union ── - public function testUnion(): void { $other = (new Builder())->from('events_archive')->filter([Query::equal('year', [2023])]); @@ -304,8 +342,6 @@ public function testUnion(): void $this->assertEquals([2024, 2023], $result->bindings); } - // ── toRawSql ── - public function testToRawSql(): void { $sql = (new Builder()) @@ -321,8 +357,6 @@ public function testToRawSql(): void ); } - // ── Reset clears ClickHouse state ── - public function testResetClearsClickHouseState(): void { $builder = (new Builder()) @@ -341,8 +375,6 @@ public function testResetClearsClickHouseState(): void $this->assertEquals([], $result->bindings); } - // ── Fluent chaining ── - public function testFluentChainingReturnsSameInstance(): void { $builder = new Builder(); @@ -358,13 +390,11 @@ public function testFluentChainingReturnsSameInstance(): void $this->assertSame($builder, $builder->reset()); } - // ── Attribute resolver works ── - public function testAttributeResolver(): void { $result = (new Builder()) ->from('events') - ->addHook(new AttributeMapHook(['$id' => '_uid'])) + ->addHook(new AttributeMap(['$id' => '_uid'])) ->filter([Query::equal('$id', ['abc'])]) ->build(); @@ -374,11 +404,9 @@ public function testAttributeResolver(): void ); } - // ── Condition provider works ── - public function testConditionProvider(): void { - $hook = new class () implements FilterHook { + $hook = new class () implements Filter { public function filter(string $table): Condition { return new Condition('_tenant = ?', ['t1']); @@ -398,8 +426,6 @@ public function filter(string $table): Condition $this->assertEquals(['active', 't1'], $result->bindings); } - // ── Prewhere binding order ── - public function testPrewhereBindingOrder(): void { $result = (new Builder()) @@ -413,8 +439,6 @@ public function testPrewhereBindingOrder(): void $this->assertEquals(['click', 5, 10], $result->bindings); } - // ── Combined PREWHERE + WHERE + JOIN + GROUP BY ── - public function testCombinedPrewhereWhereJoinGroupBy(): void { $result = (new Builder()) @@ -448,10 +472,7 @@ public function testCombinedPrewhereWhereJoinGroupBy(): void // Verify ordering: PREWHERE before WHERE $this->assertLessThan(strpos($query, 'WHERE'), strpos($query, 'PREWHERE')); } - - // ══════════════════════════════════════════════════════════════════ // 1. PREWHERE comprehensive (40+ tests) - // ══════════════════════════════════════════════════════════════════ public function testPrewhereEmptyArray(): void { @@ -559,8 +580,8 @@ public function testPrewhereStartsWith(): void ->prewhere([Query::startsWith('path', '/api')]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `path` LIKE ?', $result->query); - $this->assertEquals(['/api%'], $result->bindings); + $this->assertEquals('SELECT * FROM `events` PREWHERE startsWith(`path`, ?)', $result->query); + $this->assertEquals(['/api'], $result->bindings); } public function testPrewhereNotStartsWith(): void @@ -570,8 +591,8 @@ public function testPrewhereNotStartsWith(): void ->prewhere([Query::notStartsWith('path', '/admin')]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `path` NOT LIKE ?', $result->query); - $this->assertEquals(['/admin%'], $result->bindings); + $this->assertEquals('SELECT * FROM `events` PREWHERE NOT startsWith(`path`, ?)', $result->query); + $this->assertEquals(['/admin'], $result->bindings); } public function testPrewhereEndsWith(): void @@ -581,8 +602,8 @@ public function testPrewhereEndsWith(): void ->prewhere([Query::endsWith('file', '.csv')]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `file` LIKE ?', $result->query); - $this->assertEquals(['%.csv'], $result->bindings); + $this->assertEquals('SELECT * FROM `events` PREWHERE endsWith(`file`, ?)', $result->query); + $this->assertEquals(['.csv'], $result->bindings); } public function testPrewhereNotEndsWith(): void @@ -592,8 +613,8 @@ public function testPrewhereNotEndsWith(): void ->prewhere([Query::notEndsWith('file', '.tmp')]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `file` NOT LIKE ?', $result->query); - $this->assertEquals(['%.tmp'], $result->bindings); + $this->assertEquals('SELECT * FROM `events` PREWHERE NOT endsWith(`file`, ?)', $result->query); + $this->assertEquals(['.tmp'], $result->bindings); } public function testPrewhereContainsSingle(): void @@ -603,8 +624,8 @@ public function testPrewhereContainsSingle(): void ->prewhere([Query::contains('name', ['foo'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `name` LIKE ?', $result->query); - $this->assertEquals(['%foo%'], $result->bindings); + $this->assertEquals('SELECT * FROM `events` PREWHERE position(`name`, ?) > 0', $result->query); + $this->assertEquals(['foo'], $result->bindings); } public function testPrewhereContainsMultiple(): void @@ -614,8 +635,8 @@ public function testPrewhereContainsMultiple(): void ->prewhere([Query::contains('name', ['foo', 'bar'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE (`name` LIKE ? OR `name` LIKE ?)', $result->query); - $this->assertEquals(['%foo%', '%bar%'], $result->bindings); + $this->assertEquals('SELECT * FROM `events` PREWHERE (position(`name`, ?) > 0 OR position(`name`, ?) > 0)', $result->query); + $this->assertEquals(['foo', 'bar'], $result->bindings); } public function testPrewhereContainsAny(): void @@ -636,8 +657,8 @@ public function testPrewhereContainsAll(): void ->prewhere([Query::containsAll('tag', ['x', 'y'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE (`tag` LIKE ? AND `tag` LIKE ?)', $result->query); - $this->assertEquals(['%x%', '%y%'], $result->bindings); + $this->assertEquals('SELECT * FROM `events` PREWHERE (position(`tag`, ?) > 0 AND position(`tag`, ?) > 0)', $result->query); + $this->assertEquals(['x', 'y'], $result->bindings); } public function testPrewhereNotContainsSingle(): void @@ -647,8 +668,8 @@ public function testPrewhereNotContainsSingle(): void ->prewhere([Query::notContains('name', ['bad'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE `name` NOT LIKE ?', $result->query); - $this->assertEquals(['%bad%'], $result->bindings); + $this->assertEquals('SELECT * FROM `events` PREWHERE position(`name`, ?) = 0', $result->query); + $this->assertEquals(['bad'], $result->bindings); } public function testPrewhereNotContainsMultiple(): void @@ -658,8 +679,8 @@ public function testPrewhereNotContainsMultiple(): void ->prewhere([Query::notContains('name', ['bad', 'ugly'])]) ->build(); - $this->assertEquals('SELECT * FROM `events` PREWHERE (`name` NOT LIKE ? AND `name` NOT LIKE ?)', $result->query); - $this->assertEquals(['%bad%', '%ugly%'], $result->bindings); + $this->assertEquals('SELECT * FROM `events` PREWHERE (position(`name`, ?) = 0 AND position(`name`, ?) = 0)', $result->query); + $this->assertEquals(['bad', 'ugly'], $result->bindings); } public function testPrewhereIsNull(): void @@ -931,7 +952,7 @@ public function testPrewhereBindingOrderWithProvider(): void ->from('events') ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('count', 5)]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('tenant_id = ?', ['t1']); @@ -965,7 +986,7 @@ public function testPrewhereBindingOrderComplex(): void ->from('events') ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('count', 5)]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('tenant = ?', ['t1']); @@ -992,7 +1013,7 @@ public function testPrewhereWithAttributeResolver(): void { $result = (new Builder()) ->from('events') - ->addHook(new AttributeMapHook([ + ->addHook(new AttributeMap([ '$id' => '_uid', ])) ->prewhere([Query::equal('$id', ['abc'])]) @@ -1090,10 +1111,7 @@ public function testPrewhereInToRawSqlOutput(): void $sql ); } - - // ══════════════════════════════════════════════════════════════════ // 2. FINAL comprehensive (20+ tests) - // ══════════════════════════════════════════════════════════════════ public function testFinalBasicSelect(): void { @@ -1311,7 +1329,7 @@ public function testFinalWithAttributeResolver(): void $result = (new Builder()) ->from('events') ->final() - ->addHook(new class () implements \Utopia\Query\Hook\AttributeHook { + ->addHook(new class () implements Attribute { public function resolve(string $attribute): string { return 'col_' . $attribute; @@ -1329,7 +1347,7 @@ public function testFinalWithConditionProvider(): void $result = (new Builder()) ->from('events') ->final() - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('deleted = ?', [0]); @@ -1369,10 +1387,7 @@ public function testFinalWithWhenConditional(): void $this->assertStringNotContainsString('FINAL', $result2->query); } - - // ══════════════════════════════════════════════════════════════════ // 3. SAMPLE comprehensive (23 tests) - // ══════════════════════════════════════════════════════════════════ public function testSample10Percent(): void { @@ -1634,7 +1649,7 @@ public function testSampleWithAttributeResolver(): void $result = (new Builder()) ->from('events') ->sample(0.5) - ->addHook(new class () implements \Utopia\Query\Hook\AttributeHook { + ->addHook(new class () implements Attribute { public function resolve(string $attribute): string { return 'r_' . $attribute; @@ -1646,10 +1661,7 @@ public function resolve(string $attribute): string $this->assertStringContainsString('SAMPLE 0.5', $result->query); $this->assertStringContainsString('`r_col`', $result->query); } - - // ══════════════════════════════════════════════════════════════════ // 4. ClickHouse regex: match() function (20 tests) - // ══════════════════════════════════════════════════════════════════ public function testRegexBasicPattern(): void { @@ -1744,7 +1756,7 @@ public function testRegexWithAttributeResolver(): void { $result = (new Builder()) ->from('logs') - ->addHook(new class () implements \Utopia\Query\Hook\AttributeHook { + ->addHook(new class () implements Attribute { public function resolve(string $attribute): string { return 'col_' . $attribute; @@ -1877,7 +1889,7 @@ public function testRegexCombinedWithContains(): void ->build(); $this->assertStringContainsString('match(`path`, ?)', $result->query); - $this->assertStringContainsString('`msg` LIKE ?', $result->query); + $this->assertStringContainsString('position(`msg`, ?) > 0', $result->query); } public function testRegexCombinedWithStartsWith(): void @@ -1891,7 +1903,7 @@ public function testRegexCombinedWithStartsWith(): void ->build(); $this->assertStringContainsString('match(`path`, ?)', $result->query); - $this->assertStringContainsString('`msg` LIKE ?', $result->query); + $this->assertStringContainsString('startsWith(`msg`, ?)', $result->query); } public function testRegexPrewhereWithRegexWhere(): void @@ -1920,14 +1932,11 @@ public function testRegexCombinedWithPrewhereContainsRegex(): void $this->assertEquals(['^/api', 'error', 'timeout'], $result->bindings); } - - // ══════════════════════════════════════════════════════════════════ // 5. Search exception (10 tests) - // ══════════════════════════════════════════════════════════════════ public function testSearchThrowsExceptionMessage(): void { - $this->expectException(Exception::class); + $this->expectException(UnsupportedException::class); $this->expectExceptionMessage('Full-text search (MATCH AGAINST) is not supported in ClickHouse'); (new Builder()) @@ -1938,7 +1947,7 @@ public function testSearchThrowsExceptionMessage(): void public function testNotSearchThrowsExceptionMessage(): void { - $this->expectException(Exception::class); + $this->expectException(UnsupportedException::class); $this->expectExceptionMessage('Full-text search (MATCH AGAINST) is not supported in ClickHouse'); (new Builder()) @@ -1962,7 +1971,7 @@ public function testSearchExceptionContainsHelpfulText(): void public function testSearchInLogicalAndThrows(): void { - $this->expectException(Exception::class); + $this->expectException(UnsupportedException::class); (new Builder()) ->from('logs') @@ -1975,7 +1984,7 @@ public function testSearchInLogicalAndThrows(): void public function testSearchInLogicalOrThrows(): void { - $this->expectException(Exception::class); + $this->expectException(UnsupportedException::class); (new Builder()) ->from('logs') @@ -1988,7 +1997,7 @@ public function testSearchInLogicalOrThrows(): void public function testSearchCombinedWithValidFiltersFailsOnSearch(): void { - $this->expectException(Exception::class); + $this->expectException(UnsupportedException::class); (new Builder()) ->from('logs') @@ -2001,7 +2010,7 @@ public function testSearchCombinedWithValidFiltersFailsOnSearch(): void public function testSearchInPrewhereThrows(): void { - $this->expectException(Exception::class); + $this->expectException(UnsupportedException::class); (new Builder()) ->from('logs') @@ -2011,7 +2020,7 @@ public function testSearchInPrewhereThrows(): void public function testNotSearchInPrewhereThrows(): void { - $this->expectException(Exception::class); + $this->expectException(UnsupportedException::class); (new Builder()) ->from('logs') @@ -2021,7 +2030,7 @@ public function testNotSearchInPrewhereThrows(): void public function testSearchWithFinalStillThrows(): void { - $this->expectException(Exception::class); + $this->expectException(UnsupportedException::class); (new Builder()) ->from('logs') @@ -2032,7 +2041,7 @@ public function testSearchWithFinalStillThrows(): void public function testSearchWithSampleStillThrows(): void { - $this->expectException(Exception::class); + $this->expectException(UnsupportedException::class); (new Builder()) ->from('logs') @@ -2040,10 +2049,7 @@ public function testSearchWithSampleStillThrows(): void ->filter([Query::search('content', 'hello')]) ->build(); } - - // ══════════════════════════════════════════════════════════════════ // 6. ClickHouse rand() (10 tests) - // ══════════════════════════════════════════════════════════════════ public function testRandomSortProducesLowercaseRand(): void { @@ -2162,10 +2168,7 @@ public function testRandomSortAlone(): void $this->assertEquals('SELECT * FROM `events` ORDER BY rand()', $result->query); $this->assertEquals([], $result->bindings); } - - // ══════════════════════════════════════════════════════════════════ // 7. All filter types work correctly (31 tests) - // ══════════════════════════════════════════════════════════════════ public function testFilterEqualSingleValue(): void { @@ -2236,43 +2239,43 @@ public function testFilterNotBetweenValues(): void public function testFilterStartsWithValue(): void { $result = (new Builder())->from('t')->filter([Query::startsWith('a', 'foo')])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` LIKE ?', $result->query); - $this->assertEquals(['foo%'], $result->bindings); + $this->assertEquals('SELECT * FROM `t` WHERE startsWith(`a`, ?)', $result->query); + $this->assertEquals(['foo'], $result->bindings); } public function testFilterNotStartsWithValue(): void { $result = (new Builder())->from('t')->filter([Query::notStartsWith('a', 'foo')])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT LIKE ?', $result->query); - $this->assertEquals(['foo%'], $result->bindings); + $this->assertEquals('SELECT * FROM `t` WHERE NOT startsWith(`a`, ?)', $result->query); + $this->assertEquals(['foo'], $result->bindings); } public function testFilterEndsWithValue(): void { $result = (new Builder())->from('t')->filter([Query::endsWith('a', 'bar')])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` LIKE ?', $result->query); - $this->assertEquals(['%bar'], $result->bindings); + $this->assertEquals('SELECT * FROM `t` WHERE endsWith(`a`, ?)', $result->query); + $this->assertEquals(['bar'], $result->bindings); } public function testFilterNotEndsWithValue(): void { $result = (new Builder())->from('t')->filter([Query::notEndsWith('a', 'bar')])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT LIKE ?', $result->query); - $this->assertEquals(['%bar'], $result->bindings); + $this->assertEquals('SELECT * FROM `t` WHERE NOT endsWith(`a`, ?)', $result->query); + $this->assertEquals(['bar'], $result->bindings); } public function testFilterContainsSingleValue(): void { $result = (new Builder())->from('t')->filter([Query::contains('a', ['foo'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` LIKE ?', $result->query); - $this->assertEquals(['%foo%'], $result->bindings); + $this->assertEquals('SELECT * FROM `t` WHERE position(`a`, ?) > 0', $result->query); + $this->assertEquals(['foo'], $result->bindings); } public function testFilterContainsMultipleValues(): void { $result = (new Builder())->from('t')->filter([Query::contains('a', ['foo', 'bar'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` LIKE ? OR `a` LIKE ?)', $result->query); - $this->assertEquals(['%foo%', '%bar%'], $result->bindings); + $this->assertEquals('SELECT * FROM `t` WHERE (position(`a`, ?) > 0 OR position(`a`, ?) > 0)', $result->query); + $this->assertEquals(['foo', 'bar'], $result->bindings); } public function testFilterContainsAnyValues(): void @@ -2284,21 +2287,21 @@ public function testFilterContainsAnyValues(): void public function testFilterContainsAllValues(): void { $result = (new Builder())->from('t')->filter([Query::containsAll('a', ['x', 'y'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` LIKE ? AND `a` LIKE ?)', $result->query); - $this->assertEquals(['%x%', '%y%'], $result->bindings); + $this->assertEquals('SELECT * FROM `t` WHERE (position(`a`, ?) > 0 AND position(`a`, ?) > 0)', $result->query); + $this->assertEquals(['x', 'y'], $result->bindings); } public function testFilterNotContainsSingleValue(): void { $result = (new Builder())->from('t')->filter([Query::notContains('a', ['foo'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT LIKE ?', $result->query); - $this->assertEquals(['%foo%'], $result->bindings); + $this->assertEquals('SELECT * FROM `t` WHERE position(`a`, ?) = 0', $result->query); + $this->assertEquals(['foo'], $result->bindings); } public function testFilterNotContainsMultipleValues(): void { $result = (new Builder())->from('t')->filter([Query::notContains('a', ['foo', 'bar'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` NOT LIKE ? AND `a` NOT LIKE ?)', $result->query); + $this->assertEquals('SELECT * FROM `t` WHERE (position(`a`, ?) = 0 AND position(`a`, ?) = 0)', $result->query); } public function testFilterIsNullValue(): void @@ -2387,10 +2390,7 @@ public function testFilterWithEmptyStrings(): void $result = (new Builder())->from('t')->filter([Query::equal('name', [''])])->build(); $this->assertEquals([''], $result->bindings); } - - // ══════════════════════════════════════════════════════════════════ // 8. Aggregation with ClickHouse features (15 tests) - // ══════════════════════════════════════════════════════════════════ public function testAggregationCountWithFinal(): void { @@ -2560,7 +2560,7 @@ public function testAggregationAttributeResolverPrewhere(): void { $result = (new Builder()) ->from('events') - ->addHook(new AttributeMapHook([ + ->addHook(new AttributeMap([ 'amt' => 'amount_cents', ])) ->prewhere([Query::equal('type', ['sale'])]) @@ -2575,7 +2575,7 @@ public function testAggregationConditionProviderPrewhere(): void $result = (new Builder()) ->from('events') ->prewhere([Query::equal('type', ['sale'])]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('tenant = ?', ['t1']); @@ -2605,10 +2605,7 @@ public function testGroupByHavingPrewhereFinal(): void $this->assertStringContainsString('GROUP BY', $query); $this->assertStringContainsString('HAVING', $query); } - - // ══════════════════════════════════════════════════════════════════ // 9. Join with ClickHouse features (15 tests) - // ══════════════════════════════════════════════════════════════════ public function testJoinWithFinalFeature(): void { @@ -2763,7 +2760,7 @@ public function testJoinAttributeResolverPrewhere(): void { $result = (new Builder()) ->from('events') - ->addHook(new AttributeMapHook([ + ->addHook(new AttributeMap([ 'uid' => 'user_id', ])) ->join('users', 'events.uid', 'users.id') @@ -2779,7 +2776,7 @@ public function testJoinConditionProviderPrewhere(): void ->from('events') ->join('users', 'events.uid', 'users.id') ->prewhere([Query::equal('type', ['click'])]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('tenant = ?', ['t1']); @@ -2832,10 +2829,7 @@ public function testJoinClauseOrdering(): void $this->assertLessThan($prewherePos, $joinPos); $this->assertLessThan($wherePos, $prewherePos); } - - // ══════════════════════════════════════════════════════════════════ // 10. Union with ClickHouse features (10 tests) - // ══════════════════════════════════════════════════════════════════ public function testUnionMainHasFinal(): void { @@ -2991,10 +2985,7 @@ public function testUnionWithComplexMainQuery(): void $this->assertStringContainsString('LIMIT', $query); $this->assertStringContainsString('UNION', $query); } - - // ══════════════════════════════════════════════════════════════════ // 11. toRawSql with ClickHouse features (15 tests) - // ══════════════════════════════════════════════════════════════════ public function testToRawSqlWithFinalFeature(): void { @@ -3175,10 +3166,7 @@ public function testToRawSqlWithRegexMatch(): void $this->assertEquals("SELECT * FROM `logs` WHERE match(`path`, '^/api')", $sql); } - - // ══════════════════════════════════════════════════════════════════ // 12. Reset comprehensive (15 tests) - // ══════════════════════════════════════════════════════════════════ public function testResetClearsPrewhereState(): void { @@ -3226,7 +3214,7 @@ public function testResetClearsAllThreeTogether(): void public function testResetPreservesAttributeResolver(): void { - $hook = new class () implements \Utopia\Query\Hook\AttributeHook { + $hook = new class () implements Attribute { public function resolve(string $attribute): string { return 'r_' . $attribute; @@ -3247,7 +3235,7 @@ public function testResetPreservesConditionProviders(): void { $builder = (new Builder()) ->from('events') - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('tenant = ?', ['t1']); @@ -3369,10 +3357,7 @@ public function testMultipleResets(): void $this->assertEquals('SELECT * FROM `d`', $result->query); $this->assertEquals([], $result->bindings); } - - // ══════════════════════════════════════════════════════════════════ // 13. when() with ClickHouse features (10 tests) - // ══════════════════════════════════════════════════════════════════ public function testWhenTrueAddsPrewhere(): void { @@ -3495,17 +3480,14 @@ public function testWhenCombinedWithRegularWhen(): void $this->assertStringContainsString('FINAL', $result->query); $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); } - - // ══════════════════════════════════════════════════════════════════ // 14. Condition provider with ClickHouse (10 tests) - // ══════════════════════════════════════════════════════════════════ public function testProviderWithPrewhere(): void { $result = (new Builder()) ->from('events') ->prewhere([Query::equal('type', ['click'])]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('deleted = ?', [0]); @@ -3522,7 +3504,7 @@ public function testProviderWithFinal(): void $result = (new Builder()) ->from('events') ->final() - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('deleted = ?', [0]); @@ -3539,7 +3521,7 @@ public function testProviderWithSample(): void $result = (new Builder()) ->from('events') ->sample(0.5) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('deleted = ?', [0]); @@ -3557,7 +3539,7 @@ public function testProviderPrewhereWhereBindingOrder(): void ->from('events') ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('count', 5)]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('tenant = ?', ['t1']); @@ -3574,13 +3556,13 @@ public function testMultipleProvidersPrewhereBindingOrder(): void $result = (new Builder()) ->from('events') ->prewhere([Query::equal('type', ['click'])]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('tenant = ?', ['t1']); } }) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('org = ?', ['o1']); @@ -3596,7 +3578,7 @@ public function testProviderPrewhereCursorLimitBindingOrder(): void $result = (new Builder()) ->from('events') ->prewhere([Query::equal('type', ['click'])]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('tenant = ?', ['t1']); @@ -3622,7 +3604,7 @@ public function testProviderAllClickHouseFeatures(): void ->sample(0.1) ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('count', 0)]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('tenant = ?', ['t1']); @@ -3640,7 +3622,7 @@ public function testProviderPrewhereAggregation(): void $result = (new Builder()) ->from('events') ->prewhere([Query::equal('type', ['click'])]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('tenant = ?', ['t1']); @@ -3660,7 +3642,7 @@ public function testProviderJoinsPrewhere(): void ->from('events') ->join('users', 'events.uid', 'users.id') ->prewhere([Query::equal('type', ['click'])]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('tenant = ?', ['t1']); @@ -3678,7 +3660,7 @@ public function testProviderReferencesTableNameFinal(): void $result = (new Builder()) ->from('events') ->final() - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition($table . '.deleted = ?', [0]); @@ -3689,10 +3671,7 @@ public function filter(string $table): Condition $this->assertStringContainsString('events.deleted = ?', $result->query); $this->assertStringContainsString('FINAL', $result->query); } - - // ══════════════════════════════════════════════════════════════════ // 15. Cursor with ClickHouse features (8 tests) - // ══════════════════════════════════════════════════════════════════ public function testCursorAfterWithPrewhere(): void { @@ -3779,7 +3758,7 @@ public function testCursorPrewhereProviderBindingOrder(): void $result = (new Builder()) ->from('events') ->prewhere([Query::equal('type', ['click'])]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('tenant = ?', ['t1']); @@ -3814,10 +3793,7 @@ public function testCursorFullClickHousePipeline(): void $this->assertStringContainsString('`_cursor` > ?', $query); $this->assertStringContainsString('LIMIT', $query); } - - // ══════════════════════════════════════════════════════════════════ // 16. page() with ClickHouse features (5 tests) - // ══════════════════════════════════════════════════════════════════ public function testPageWithPrewhere(): void { @@ -3896,10 +3872,7 @@ public function testPageWithComplexClickHouseQuery(): void $this->assertStringContainsString('LIMIT', $query); $this->assertStringContainsString('OFFSET', $query); } - - // ══════════════════════════════════════════════════════════════════ // 17. Fluent chaining comprehensive (5 tests) - // ══════════════════════════════════════════════════════════════════ public function testAllClickHouseMethodsReturnSameInstance(): void { @@ -3990,10 +3963,7 @@ public function testFluentResetThenRebuild(): void $this->assertEquals('SELECT * FROM `logs` SAMPLE 0.5', $result->query); $this->assertStringNotContainsString('FINAL', $result->query); } - - // ══════════════════════════════════════════════════════════════════ // 18. SQL clause ordering verification (10 tests) - // ══════════════════════════════════════════════════════════════════ public function testClauseOrderSelectFromFinalSampleJoinPrewhereWhereGroupByHavingOrderByLimitOffset(): void { @@ -4212,10 +4182,7 @@ public function testFullQueryAllClausesAllPositions(): void $this->assertStringContainsString('OFFSET', $query); $this->assertStringContainsString('UNION', $query); } - - // ══════════════════════════════════════════════════════════════════ // 19. Batch mode with ClickHouse (5 tests) - // ══════════════════════════════════════════════════════════════════ public function testQueriesMethodWithPrewhere(): void { @@ -4305,29 +4272,26 @@ public function testQueriesComparedToFluentApiSameSql(): void $this->assertEquals($resultA->query, $resultB->query); $this->assertEquals($resultA->bindings, $resultB->bindings); } - - // ══════════════════════════════════════════════════════════════════ // 20. Edge cases (10 tests) - // ══════════════════════════════════════════════════════════════════ public function testEmptyTableNameWithFinal(): void { - $result = (new Builder()) + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) ->from('') ->final() ->build(); - - $this->assertStringContainsString('FINAL', $result->query); } public function testEmptyTableNameWithSample(): void { - $result = (new Builder()) + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) ->from('') ->sample(0.5) ->build(); - - $this->assertStringContainsString('SAMPLE 0.5', $result->query); } public function testPrewhereWithEmptyFilterValues(): void @@ -4399,7 +4363,7 @@ public function testSampleWithAllBindingTypes(): void ->from('events') ->sample(0.1) ->prewhere([Query::equal('type', ['click'])]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('tenant = ?', ['t1']); @@ -4476,132 +4440,126 @@ public function testFinalSampleTextInOutputWithJoins(): void $joinPos = strpos($query, 'JOIN'); $this->assertLessThan($joinPos, $finalSamplePos); } - - // ══════════════════════════════════════════════════════════════════ // 1. Spatial/Vector/ElemMatch Exception Tests - // ══════════════════════════════════════════════════════════════════ public function testFilterCrossesThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::crosses('attr', [1])])->build(); } public function testFilterNotCrossesThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::notCrosses('attr', [1])])->build(); } public function testFilterDistanceEqualThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::distanceEqual('attr', [0, 0], 1)])->build(); } public function testFilterDistanceNotEqualThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::distanceNotEqual('attr', [0, 0], 1)])->build(); } public function testFilterDistanceGreaterThanThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::distanceGreaterThan('attr', [0, 0], 1)])->build(); } public function testFilterDistanceLessThanThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::distanceLessThan('attr', [0, 0], 1)])->build(); } public function testFilterIntersectsThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::intersects('attr', [1])])->build(); } public function testFilterNotIntersectsThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::notIntersects('attr', [1])])->build(); } public function testFilterOverlapsThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::overlaps('attr', [1])])->build(); } public function testFilterNotOverlapsThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::notOverlaps('attr', [1])])->build(); } public function testFilterTouchesThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::touches('attr', [1])])->build(); } public function testFilterNotTouchesThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::notTouches('attr', [1])])->build(); } public function testFilterVectorDotThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::vectorDot('attr', [1.0, 2.0])])->build(); } public function testFilterVectorCosineThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::vectorCosine('attr', [1.0, 2.0])])->build(); } public function testFilterVectorEuclideanThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::vectorEuclidean('attr', [1.0, 2.0])])->build(); } public function testFilterElemMatchThrowsException(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->filter([Query::elemMatch('attr', [Query::equal('x', [1])])])->build(); } - - // ══════════════════════════════════════════════════════════════════ // 2. SAMPLE Boundary Values - // ══════════════════════════════════════════════════════════════════ public function testSampleZero(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(ValidationException::class); (new Builder())->from('t')->sample(0.0); } public function testSampleOne(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(ValidationException::class); (new Builder())->from('t')->sample(1.0); } public function testSampleNegative(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(ValidationException::class); (new Builder())->from('t')->sample(-0.5); } public function testSampleGreaterThanOne(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(ValidationException::class); (new Builder())->from('t')->sample(2.0); } @@ -4610,10 +4568,7 @@ public function testSampleVerySmall(): void $result = (new Builder())->from('t')->sample(0.001)->build(); $this->assertStringContainsString('SAMPLE 0.001', $result->query); } - - // ══════════════════════════════════════════════════════════════════ // 3. Standalone Compiler Method Tests - // ══════════════════════════════════════════════════════════════════ public function testCompileFilterStandalone(): void { @@ -4647,7 +4602,7 @@ public function testCompileOrderRandomStandalone(): void public function testCompileOrderExceptionStandalone(): void { $builder = new Builder(); - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); $builder->compileOrder(Query::limit(10)); } @@ -4742,13 +4697,10 @@ public function testCompileJoinStandalone(): void public function testCompileJoinExceptionStandalone(): void { $builder = new Builder(); - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); $builder->compileJoin(Query::equal('x', [1])); } - - // ══════════════════════════════════════════════════════════════════ // 4. Union with ClickHouse Features on Both Sides - // ══════════════════════════════════════════════════════════════════ public function testUnionBothWithClickHouseFeatures(): void { @@ -4777,10 +4729,7 @@ public function testUnionAllBothWithFinal(): void $this->assertStringContainsString('FROM `a` FINAL', $result->query); $this->assertStringContainsString('UNION ALL (SELECT * FROM `b` FINAL)', $result->query); } - - // ══════════════════════════════════════════════════════════════════ // 5. PREWHERE Binding Order Exhaustive Tests - // ══════════════════════════════════════════════════════════════════ public function testPrewhereBindingOrderWithFilterAndHaving(): void { @@ -4799,7 +4748,7 @@ public function testPrewhereBindingOrderWithProviderAndCursor(): void { $result = (new Builder())->from('t') ->prewhere([Query::equal('type', ['click'])]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('_tenant = ?', ['t1']); @@ -4825,27 +4774,21 @@ public function testPrewhereMultipleFiltersBindingOrder(): void // prewhere bindings first, then filter, then limit $this->assertEquals(['a', 3, 30, 10], $result->bindings); } - - // ══════════════════════════════════════════════════════════════════ // 6. Search Exception in PREWHERE Interaction - // ══════════════════════════════════════════════════════════════════ public function testSearchInFilterThrowsExceptionWithMessage(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); $this->expectExceptionMessage('Full-text search'); (new Builder())->from('t')->filter([Query::search('content', 'hello')])->build(); } public function testSearchInPrewhereThrowsExceptionWithMessage(): void { - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); (new Builder())->from('t')->prewhere([Query::search('content', 'hello')])->build(); } - - // ══════════════════════════════════════════════════════════════════ // 7. Join Combinations with FINAL/SAMPLE - // ══════════════════════════════════════════════════════════════════ public function testLeftJoinWithFinalAndSample(): void { @@ -4888,16 +4831,13 @@ public function testJoinWithNonDefaultOperator(): void ->build(); $this->assertStringContainsString('JOIN `other` ON `a` != `b`', $result->query); } - - // ══════════════════════════════════════════════════════════════════ // 8. Condition Provider Position Verification - // ══════════════════════════════════════════════════════════════════ public function testConditionProviderInWhereNotPrewhere(): void { $result = (new Builder())->from('t') ->prewhere([Query::equal('type', ['click'])]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('_tenant = ?', ['t1']); @@ -4917,7 +4857,7 @@ public function filter(string $table): Condition public function testConditionProviderWithNoFiltersClickHouse(): void { $result = (new Builder())->from('t') - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('_deleted = ?', [0]); @@ -4927,24 +4867,18 @@ public function filter(string $table): Condition $this->assertEquals('SELECT * FROM `t` WHERE _deleted = ?', $result->query); $this->assertEquals([0], $result->bindings); } - - // ══════════════════════════════════════════════════════════════════ // 9. Page Boundary Values - // ══════════════════════════════════════════════════════════════════ public function testPageZero(): void { - $result = (new Builder())->from('t')->page(0, 10)->build(); - $this->assertStringContainsString('LIMIT ?', $result->query); - $this->assertStringContainsString('OFFSET ?', $result->query); - // page 0 -> offset clamped to 0 - $this->assertEquals([10, 0], $result->bindings); + $this->expectException(ValidationException::class); + (new Builder())->from('t')->page(0, 10)->build(); } public function testPageNegative(): void { - $result = (new Builder())->from('t')->page(-1, 10)->build(); - $this->assertEquals([10, 0], $result->bindings); + $this->expectException(ValidationException::class); + (new Builder())->from('t')->page(-1, 10)->build(); } public function testPageLargeNumber(): void @@ -4952,20 +4886,15 @@ public function testPageLargeNumber(): void $result = (new Builder())->from('t')->page(1000000, 25)->build(); $this->assertEquals([25, 24999975], $result->bindings); } - - // ══════════════════════════════════════════════════════════════════ // 10. Build Without From - // ══════════════════════════════════════════════════════════════════ public function testBuildWithoutFrom(): void { - $result = (new Builder())->filter([Query::equal('x', [1])])->build(); - $this->assertStringContainsString('FROM ``', $result->query); + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder())->filter([Query::equal('x', [1])])->build(); } - - // ══════════════════════════════════════════════════════════════════ // 11. toRawSql Edge Cases for ClickHouse - // ══════════════════════════════════════════════════════════════════ public function testToRawSqlWithFinalAndSampleEdge(): void { @@ -5025,10 +4954,7 @@ public function testToRawSqlMixedTypes(): void $this->assertStringContainsString('42', $sql); $this->assertStringContainsString('9.99', $sql); } - - // ══════════════════════════════════════════════════════════════════ // 12. Having with Multiple Sub-Queries - // ══════════════════════════════════════════════════════════════════ public function testHavingMultipleSubQueries(): void { @@ -5057,10 +4983,7 @@ public function testHavingWithOrLogic(): void ->build(); $this->assertStringContainsString('HAVING (`total` > ? OR `total` < ?)', $result->query); } - - // ══════════════════════════════════════════════════════════════════ // 13. Reset Property-by-Property Verification - // ══════════════════════════════════════════════════════════════════ public function testResetClearsClickHouseProperties(): void { @@ -5099,7 +5022,7 @@ public function testConditionProviderPersistsAfterReset(): void $builder = (new Builder()) ->from('t') ->final() - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('_tenant = ?', ['t1']); @@ -5112,10 +5035,7 @@ public function filter(string $table): Condition $this->assertStringNotContainsString('FINAL', $result->query); $this->assertStringContainsString('_tenant = ?', $result->query); } - - // ══════════════════════════════════════════════════════════════════ // 14. Exact Full SQL Assertions - // ══════════════════════════════════════════════════════════════════ public function testFinalSamplePrewhereFilterExactSql(): void { @@ -5160,10 +5080,7 @@ public function testKitchenSinkExactSql(): void ); $this->assertEquals(['purchase', 100, 5, 50, 10, 'closed'], $result->bindings); } - - // ══════════════════════════════════════════════════════════════════ // 15. Query::compile() Integration Tests - // ══════════════════════════════════════════════════════════════════ public function testQueryCompileFilterViaClickHouse(): void { @@ -5214,10 +5131,7 @@ public function testQueryCompileGroupByViaClickHouse(): void $sql = Query::groupBy(['status'])->compile($builder); $this->assertEquals('`status`', $sql); } - - // ══════════════════════════════════════════════════════════════════ // 16. Binding Type Assertions with assertSame - // ══════════════════════════════════════════════════════════════════ public function testBindingTypesPreservedInt(): void { @@ -5270,10 +5184,7 @@ public function testBindingTypesPreservedString(): void $result = (new Builder())->from('t')->filter([Query::equal('name', ['hello'])])->build(); $this->assertSame(['hello'], $result->bindings); } - - // ══════════════════════════════════════════════════════════════════ // 17. Raw Inside Logical Groups - // ══════════════════════════════════════════════════════════════════ public function testRawInsideLogicalAnd(): void { @@ -5298,10 +5209,7 @@ public function testRawInsideLogicalOr(): void $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result->query); $this->assertEquals([1], $result->bindings); } - - // ══════════════════════════════════════════════════════════════════ // 18. Negative/Zero Limit and Offset - // ══════════════════════════════════════════════════════════════════ public function testNegativeLimit(): void { @@ -5324,10 +5232,7 @@ public function testLimitZero(): void $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); $this->assertEquals([0], $result->bindings); } - - // ══════════════════════════════════════════════════════════════════ // 19. Multiple Limits/Offsets/Cursors First Wins - // ══════════════════════════════════════════════════════════════════ public function testMultipleLimitsFirstWins(): void { @@ -5347,10 +5252,7 @@ public function testCursorAfterAndBeforeFirstWins(): void $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->sortAsc('_cursor')->build(); $this->assertStringContainsString('`_cursor` > ?', $result->query); } - - // ══════════════════════════════════════════════════════════════════ // 20. Distinct + Union - // ══════════════════════════════════════════════════════════════════ public function testDistinctWithUnion(): void { @@ -5358,4 +5260,1746 @@ public function testDistinctWithUnion(): void $result = (new Builder())->from('a')->distinct()->union($other)->build(); $this->assertEquals('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result->query); } + // DML: INSERT (same as standard SQL) + + public function testInsertSingleRow(): void + { + $result = (new Builder()) + ->into('events') + ->set(['name' => 'click', 'timestamp' => '2024-01-01']) + ->insert(); + + $this->assertEquals( + 'INSERT INTO `events` (`name`, `timestamp`) VALUES (?, ?)', + $result->query + ); + $this->assertEquals(['click', '2024-01-01'], $result->bindings); + } + + public function testInsertBatch(): void + { + $result = (new Builder()) + ->into('events') + ->set(['name' => 'click', 'ts' => '2024-01-01']) + ->set(['name' => 'view', 'ts' => '2024-01-02']) + ->insert(); + + $this->assertEquals( + 'INSERT INTO `events` (`name`, `ts`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertEquals(['click', '2024-01-01', 'view', '2024-01-02'], $result->bindings); + } + // ClickHouse does not implement Upsert + + public function testDoesNotImplementUpsert(): void + { + $interfaces = \class_implements(Builder::class); + $this->assertIsArray($interfaces); + $this->assertArrayNotHasKey(Upsert::class, $interfaces); + } + // DML: UPDATE uses ALTER TABLE ... UPDATE + + public function testUpdateUsesAlterTable(): void + { + $result = (new Builder()) + ->from('events') + ->set(['status' => 'archived']) + ->filter([Query::equal('status', ['old'])]) + ->update(); + + $this->assertEquals( + 'ALTER TABLE `events` UPDATE `status` = ? WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['archived', 'old'], $result->bindings); + } + + public function testUpdateWithFilterHook(): void + { + $hook = new class () implements Filter, \Utopia\Query\Hook { + public function filter(string $table): Condition + { + return new Condition('`_tenant` = ?', ['tenant_123']); + } + }; + + $result = (new Builder()) + ->from('events') + ->set(['status' => 'active']) + ->filter([Query::equal('id', [1])]) + ->addHook($hook) + ->update(); + + $this->assertEquals( + 'ALTER TABLE `events` UPDATE `status` = ? WHERE `id` IN (?) AND `_tenant` = ?', + $result->query + ); + $this->assertEquals(['active', 1, 'tenant_123'], $result->bindings); + } + + public function testUpdateWithoutWhereThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('ClickHouse UPDATE requires a WHERE clause'); + + (new Builder()) + ->from('events') + ->set(['status' => 'active']) + ->update(); + } + // DML: DELETE uses ALTER TABLE ... DELETE + + public function testDeleteUsesAlterTable(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::lessThan('timestamp', '2024-01-01')]) + ->delete(); + + $this->assertEquals( + 'ALTER TABLE `events` DELETE WHERE `timestamp` < ?', + $result->query + ); + $this->assertEquals(['2024-01-01'], $result->bindings); + } + + public function testDeleteWithFilterHook(): void + { + $hook = new class () implements Filter, \Utopia\Query\Hook { + public function filter(string $table): Condition + { + return new Condition('`_tenant` = ?', ['tenant_123']); + } + }; + + $result = (new Builder()) + ->from('events') + ->filter([Query::equal('status', ['deleted'])]) + ->addHook($hook) + ->delete(); + + $this->assertEquals( + 'ALTER TABLE `events` DELETE WHERE `status` IN (?) AND `_tenant` = ?', + $result->query + ); + $this->assertEquals(['deleted', 'tenant_123'], $result->bindings); + } + + public function testDeleteWithoutWhereThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('ClickHouse DELETE requires a WHERE clause'); + + (new Builder()) + ->from('events') + ->delete(); + } + // INTERSECT / EXCEPT (supported in ClickHouse) + + public function testIntersect(): void + { + $other = (new Builder())->from('admins'); + $result = (new Builder()) + ->from('users') + ->intersect($other) + ->build(); + + $this->assertEquals( + '(SELECT * FROM `users`) INTERSECT (SELECT * FROM `admins`)', + $result->query + ); + } + + public function testExcept(): void + { + $other = (new Builder())->from('banned'); + $result = (new Builder()) + ->from('users') + ->except($other) + ->build(); + + $this->assertEquals( + '(SELECT * FROM `users`) EXCEPT (SELECT * FROM `banned`)', + $result->query + ); + } + // Feature interfaces (not implemented) + + public function testDoesNotImplementLocking(): void + { + $interfaces = \class_implements(Builder::class); + $this->assertIsArray($interfaces); + $this->assertArrayNotHasKey(Locking::class, $interfaces); + } + + public function testDoesNotImplementTransactions(): void + { + $interfaces = \class_implements(Builder::class); + $this->assertIsArray($interfaces); + $this->assertArrayNotHasKey(Transactions::class, $interfaces); + } + // INSERT...SELECT (supported in ClickHouse) + + public function testInsertSelect(): void + { + $source = (new Builder()) + ->from('events') + ->select(['name', 'timestamp']) + ->filter([Query::equal('type', ['click'])]); + + $result = (new Builder()) + ->into('archived_events') + ->fromSelect(['name', 'timestamp'], $source) + ->insertSelect(); + + $this->assertEquals( + 'INSERT INTO `archived_events` (`name`, `timestamp`) SELECT `name`, `timestamp` FROM `events` WHERE `type` IN (?)', + $result->query + ); + $this->assertEquals(['click'], $result->bindings); + } + // CTEs (supported in ClickHouse) + + public function testCteWith(): void + { + $cte = (new Builder()) + ->from('events') + ->filter([Query::equal('type', ['click'])]); + + $result = (new Builder()) + ->with('clicks', $cte) + ->from('clicks') + ->build(); + + $this->assertEquals( + 'WITH `clicks` AS (SELECT * FROM `events` WHERE `type` IN (?)) SELECT * FROM `clicks`', + $result->query + ); + $this->assertEquals(['click'], $result->bindings); + } + // setRaw with bindings (ClickHouse) + + public function testSetRawWithBindings(): void + { + $result = (new Builder()) + ->from('events') + ->setRaw('count', 'count + ?', [1]) + ->filter([Query::equal('id', [42])]) + ->update(); + + $this->assertEquals( + 'ALTER TABLE `events` UPDATE `count` = count + ? WHERE `id` IN (?)', + $result->query + ); + $this->assertEquals([1, 42], $result->bindings); + } + // Hints feature interface + + public function testImplementsHints(): void + { + $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Hints::class, new Builder()); + } + + public function testHintAppendsSettings(): void + { + $result = (new Builder()) + ->from('events') + ->hint('max_threads=4') + ->build(); + + $this->assertStringContainsString('SETTINGS max_threads=4', $result->query); + } + + public function testMultipleHints(): void + { + $result = (new Builder()) + ->from('events') + ->hint('max_threads=4') + ->hint('max_memory_usage=1000000000') + ->build(); + + $this->assertStringContainsString('SETTINGS max_threads=4, max_memory_usage=1000000000', $result->query); + } + + public function testSettingsMethod(): void + { + $result = (new Builder()) + ->from('events') + ->settings(['max_threads' => '4', 'max_memory_usage' => '1000000000']) + ->build(); + + $this->assertStringContainsString('SETTINGS max_threads=4, max_memory_usage=1000000000', $result->query); + } + // Window functions + + public function testImplementsWindows(): void + { + $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Windows::class, new Builder()); + } + + public function testSelectWindowRowNumber(): void + { + $result = (new Builder()) + ->from('events') + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['timestamp']) + ->build(); + + $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY `user_id` ORDER BY `timestamp` ASC) AS `rn`', $result->query); + } + // Does NOT implement Spatial/VectorSearch/Json + + public function testDoesNotImplementSpatial(): void + { + $builder = new Builder(); + $this->assertNotInstanceOf(\Utopia\Query\Builder\Feature\Spatial::class, $builder); // @phpstan-ignore method.alreadyNarrowedType + } + + public function testDoesNotImplementVectorSearch(): void + { + $builder = new Builder(); + $this->assertNotInstanceOf(\Utopia\Query\Builder\Feature\VectorSearch::class, $builder); // @phpstan-ignore method.alreadyNarrowedType + } + + public function testDoesNotImplementJson(): void + { + $builder = new Builder(); + $this->assertNotInstanceOf(\Utopia\Query\Builder\Feature\Json::class, $builder); // @phpstan-ignore method.alreadyNarrowedType + } + // Reset clears hints + + public function testResetClearsHints(): void + { + $builder = (new Builder()) + ->from('events') + ->hint('max_threads=4'); + + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertStringNotContainsString('SETTINGS', $result->query); + } + + // ==================== PREWHERE tests ==================== + + public function testPrewhereWithSingleFilter(): void + { + $result = (new Builder()) + ->from('t') + ->prewhere([Query::equal('status', ['active'])]) + ->build(); + + $this->assertStringContainsString('PREWHERE `status` IN (?)', $result->query); + $this->assertEquals(['active'], $result->bindings); + } + + public function testPrewhereWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('t') + ->prewhere([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->build(); + + $this->assertStringContainsString('PREWHERE `status` IN (?) AND `age` > ?', $result->query); + $this->assertEquals(['active', 18], $result->bindings); + } + + public function testPrewhereBeforeWhere(): void + { + $result = (new Builder()) + ->from('t') + ->prewhere([Query::equal('status', ['active'])]) + ->filter([Query::greaterThan('age', 18)]) + ->build(); + + $prewherePos = strpos($result->query, 'PREWHERE'); + $wherePos = strpos($result->query, 'WHERE'); + + $this->assertNotFalse($prewherePos); + $this->assertNotFalse($wherePos); + $this->assertLessThan($wherePos, $prewherePos); + } + + public function testPrewhereBindingOrderBeforeWhere(): void + { + $result = (new Builder()) + ->from('t') + ->prewhere([Query::equal('status', ['active'])]) + ->filter([Query::greaterThan('age', 18)]) + ->build(); + + $this->assertEquals(['active', 18], $result->bindings); + } + + public function testPrewhereWithJoin(): void + { + $result = (new Builder()) + ->from('t') + ->join('u', 't.uid', 'u.id') + ->prewhere([Query::equal('status', ['active'])]) + ->filter([Query::greaterThan('age', 18)]) + ->build(); + + $joinPos = strpos($result->query, 'JOIN'); + $prewherePos = strpos($result->query, 'PREWHERE'); + $wherePos = strpos($result->query, 'WHERE'); + + $this->assertNotFalse($joinPos); + $this->assertNotFalse($prewherePos); + $this->assertNotFalse($wherePos); + $this->assertLessThan($prewherePos, $joinPos); + $this->assertLessThan($wherePos, $prewherePos); + } + + // ==================== FINAL keyword tests ==================== + + public function testFinalKeywordInFromClause(): void + { + $result = (new Builder()) + ->from('t') + ->final() + ->build(); + + $this->assertStringContainsString('FROM `t` FINAL', $result->query); + } + + public function testFinalAppearsBeforeWhere(): void + { + $result = (new Builder()) + ->from('t') + ->final() + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $finalPos = strpos($result->query, 'FINAL'); + $wherePos = strpos($result->query, 'WHERE'); + + $this->assertNotFalse($finalPos); + $this->assertNotFalse($wherePos); + $this->assertLessThan($wherePos, $finalPos); + } + + public function testFinalWithSample(): void + { + $result = (new Builder()) + ->from('t') + ->final() + ->sample(0.5) + ->build(); + + $this->assertStringContainsString('FROM `t` FINAL SAMPLE 0.5', $result->query); + } + + // ==================== SAMPLE tests ==================== + + public function testSampleFraction(): void + { + $result = (new Builder()) + ->from('t') + ->sample(0.1) + ->build(); + + $this->assertStringContainsString('FROM `t` SAMPLE 0.1', $result->query); + } + + public function testSampleZeroThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('t') + ->sample(0.0); + } + + public function testSampleOneThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('t') + ->sample(1.0); + } + + public function testSampleNegativeThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('t') + ->sample(-0.5); + } + + // ==================== UPDATE (ALTER TABLE) tests ==================== + + public function testUpdateAlterTableSyntax(): void + { + $result = (new Builder()) + ->from('t') + ->set(['name' => 'Bob']) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertEquals( + 'ALTER TABLE `t` UPDATE `name` = ? WHERE `id` IN (?)', + $result->query + ); + $this->assertEquals(['Bob', 1], $result->bindings); + } + + public function testUpdateWithoutWhereClauseThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('WHERE'); + + (new Builder()) + ->from('t') + ->set(['name' => 'Bob']) + ->update(); + } + + public function testUpdateWithoutAssignmentsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('t') + ->filter([Query::equal('id', [1])]) + ->update(); + } + + public function testUpdateWithRawSet(): void + { + $result = (new Builder()) + ->from('t') + ->setRaw('counter', '`counter` + 1') + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringContainsString('`counter` = `counter` + 1', $result->query); + $this->assertStringContainsString('ALTER TABLE `t` UPDATE', $result->query); + } + + public function testUpdateWithRawSetBindings(): void + { + $result = (new Builder()) + ->from('t') + ->setRaw('name', 'CONCAT(?, ?)', ['hello', ' world']) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringContainsString('`name` = CONCAT(?, ?)', $result->query); + $this->assertEquals(['hello', ' world', 1], $result->bindings); + } + + // ==================== DELETE (ALTER TABLE) tests ==================== + + public function testDeleteAlterTableSyntax(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('id', [1])]) + ->delete(); + + $this->assertEquals( + 'ALTER TABLE `t` DELETE WHERE `id` IN (?)', + $result->query + ); + $this->assertEquals([1], $result->bindings); + } + + public function testDeleteWithoutWhereClauseThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('t') + ->delete(); + } + + public function testDeleteWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('status', ['old']), + Query::lessThan('age', 5), + ]) + ->delete(); + + $this->assertStringContainsString('WHERE `status` IN (?) AND `age` < ?', $result->query); + $this->assertEquals(['old', 5], $result->bindings); + } + + // ==================== LIKE/Contains overrides ==================== + + public function testStartsWithUsesStartsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', 'foo')]) + ->build(); + + $this->assertStringContainsString('startsWith(`name`, ?)', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testNotStartsWithUsesNotStartsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notStartsWith('name', 'foo')]) + ->build(); + + $this->assertStringContainsString('NOT startsWith(`name`, ?)', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testEndsWithUsesEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('name', 'foo')]) + ->build(); + + $this->assertStringContainsString('endsWith(`name`, ?)', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testNotEndsWithUsesNotEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEndsWith('name', 'foo')]) + ->build(); + + $this->assertStringContainsString('NOT endsWith(`name`, ?)', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testContainsSingleValueUsesPosition(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('name', ['foo'])]) + ->build(); + + $this->assertStringContainsString('position(`name`, ?) > 0', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testContainsMultipleValuesUsesOrPosition(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('name', ['foo', 'bar'])]) + ->build(); + + $this->assertStringContainsString('(position(`name`, ?) > 0 OR position(`name`, ?) > 0)', $result->query); + $this->assertEquals(['foo', 'bar'], $result->bindings); + } + + public function testContainsAllUsesAndPosition(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('name', ['foo', 'bar'])]) + ->build(); + + $this->assertStringContainsString('(position(`name`, ?) > 0 AND position(`name`, ?) > 0)', $result->query); + $this->assertEquals(['foo', 'bar'], $result->bindings); + } + + public function testNotContainsSingleValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('name', ['foo'])]) + ->build(); + + $this->assertStringContainsString('position(`name`, ?) = 0', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + // ==================== NotContains multiple ==================== + + public function testNotContainsMultipleValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('name', ['a', 'b'])]) + ->build(); + + $this->assertStringContainsString('(position(`name`, ?) = 0 AND position(`name`, ?) = 0)', $result->query); + $this->assertEquals(['a', 'b'], $result->bindings); + } + + // ==================== Regex ==================== + + public function testRegexUsesMatch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', '^test')]) + ->build(); + + $this->assertStringContainsString('match(`name`, ?)', $result->query); + $this->assertEquals(['^test'], $result->bindings); + } + + // ==================== Search throws ==================== + + public function testSearchThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('t') + ->filter([Query::search('body', 'hello')]) + ->build(); + } + + // ==================== Hints/Settings ==================== + + public function testSettingsKeyValue(): void + { + $result = (new Builder()) + ->from('t') + ->settings(['max_threads' => '4', 'enable_optimize_predicate_expression' => '1']) + ->build(); + + $this->assertStringContainsString('SETTINGS max_threads=4, enable_optimize_predicate_expression=1', $result->query); + } + + public function testHintAndSettingsCombined(): void + { + $result = (new Builder()) + ->from('t') + ->hint('max_threads=2') + ->settings(['enable_optimize_predicate_expression' => '1']) + ->build(); + + $this->assertStringContainsString('SETTINGS max_threads=2, enable_optimize_predicate_expression=1', $result->query); + } + + public function testHintsPreserveBindings(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active'])]) + ->hint('max_threads=4') + ->build(); + + $this->assertEquals(['active'], $result->bindings); + $this->assertStringContainsString('SETTINGS max_threads=4', $result->query); + } + + public function testHintsWithJoin(): void + { + $result = (new Builder()) + ->from('t') + ->join('u', 't.uid', 'u.id') + ->hint('max_threads=4') + ->build(); + + $this->assertStringContainsString('SETTINGS max_threads=4', $result->query); + // SETTINGS must be at the very end + $this->assertStringEndsWith('SETTINGS max_threads=4', $result->query); + } + + // ==================== CTE tests ==================== + + public function testCTE(): void + { + $sub = (new Builder()) + ->from('events') + ->filter([Query::equal('type', ['click'])]); + + $result = (new Builder()) + ->with('sub', $sub) + ->from('sub') + ->build(); + + $this->assertEquals( + 'WITH `sub` AS (SELECT * FROM `events` WHERE `type` IN (?)) SELECT * FROM `sub`', + $result->query + ); + $this->assertEquals(['click'], $result->bindings); + } + + public function testCTERecursive(): void + { + $sub = (new Builder()) + ->from('categories') + ->filter([Query::equal('parent_id', [0])]); + + $result = (new Builder()) + ->withRecursive('tree', $sub) + ->from('tree') + ->build(); + + $this->assertStringContainsString('WITH RECURSIVE `tree` AS', $result->query); + } + + public function testCTEBindingOrder(): void + { + $sub = (new Builder()) + ->from('events') + ->filter([Query::equal('type', ['click'])]); + + $result = (new Builder()) + ->with('sub', $sub) + ->from('sub') + ->filter([Query::greaterThan('count', 5)]) + ->build(); + + // CTE bindings come before main query bindings + $this->assertEquals(['click', 5], $result->bindings); + } + + // ==================== Window functions ==================== + + public function testWindowFunctionPartitionAndOrder(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['created_at']) + ->build(); + + $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY `user_id` ORDER BY `created_at` ASC) AS `rn`', $result->query); + } + + public function testWindowFunctionOrderDescending(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['-created_at']) + ->build(); + + $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY `user_id` ORDER BY `created_at` DESC) AS `rn`', $result->query); + } + + public function testMultipleWindowFunctions(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['created_at']) + ->selectWindow('SUM(`amount`)', 'total', ['user_id'], null) + ->build(); + + $this->assertStringContainsString('ROW_NUMBER() OVER', $result->query); + $this->assertStringContainsString('SUM(`amount`) OVER', $result->query); + } + + // ==================== CASE expression ==================== + + public function testSelectCaseExpression(): void + { + $case = (new CaseBuilder()) + ->when('`status` = ?', '?', ['active'], ['Active']) + ->elseResult('?', ['Unknown']) + ->alias('label') + ->build(); + + $result = (new Builder()) + ->from('t') + ->selectCase($case) + ->build(); + + $this->assertStringContainsString('CASE WHEN `status` = ? THEN ? ELSE ? END AS label', $result->query); + $this->assertEquals(['active', 'Active', 'Unknown'], $result->bindings); + } + + public function testSetCaseInUpdate(): void + { + $case = (new CaseBuilder()) + ->when('`role` = ?', '?', ['admin'], ['Admin']) + ->elseResult('?', ['User']) + ->build(); + + $result = (new Builder()) + ->from('t') + ->setRaw('label', $case->sql, $case->bindings) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringContainsString('ALTER TABLE `t` UPDATE', $result->query); + $this->assertStringContainsString('CASE WHEN `role` = ? THEN ? ELSE ? END', $result->query); + $this->assertEquals(['admin', 'Admin', 'User', 1], $result->bindings); + } + + // ==================== Union/Intersect/Except ==================== + + public function testUnionSimple(): void + { + $other = (new Builder())->from('b'); + $result = (new Builder()) + ->from('a') + ->union($other) + ->build(); + + $this->assertStringContainsString('UNION', $result->query); + $this->assertStringNotContainsString('UNION ALL', $result->query); + } + + public function testUnionAll(): void + { + $other = (new Builder())->from('b'); + $result = (new Builder()) + ->from('a') + ->unionAll($other) + ->build(); + + $this->assertStringContainsString('UNION ALL', $result->query); + } + + public function testUnionBindingsOrder(): void + { + $other = (new Builder())->from('b')->filter([Query::equal('y', [2])]); + $result = (new Builder()) + ->from('a') + ->filter([Query::equal('x', [1])]) + ->union($other) + ->build(); + + $this->assertEquals([1, 2], $result->bindings); + } + + // ==================== Pagination ==================== + + public function testPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(2, 25) + ->build(); + + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals([25, 25], $result->bindings); + } + + public function testCursorAfter(): void + { + $result = (new Builder()) + ->from('t') + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertEquals(['abc'], $result->bindings); + } + + // ==================== Validation errors ==================== + + public function testBuildWithoutTableThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->build(); + } + + public function testInsertWithoutRowsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('t') + ->insert(); + } + + public function testBatchInsertMismatchedColumnsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('t') + ->set(['name' => 'Alice', 'age' => 30]) + ->set(['name' => 'Bob', 'email' => 'bob@example.com']) + ->insert(); + } + + // ==================== Batch insert ==================== + + public function testBatchInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('t') + ->set(['name' => 'Alice', 'age' => 30]) + ->set(['name' => 'Bob', 'age' => 25]) + ->insert(); + + $this->assertEquals( + 'INSERT INTO `t` (`name`, `age`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertEquals(['Alice', 30, 'Bob', 25], $result->bindings); + } + + // ==================== Join filter placement ==================== + + public function testJoinFilterForcedToWhere(): void + { + $hook = new class () implements JoinFilter { + public function filterJoin(string $table, string $joinType): JoinCondition + { + return new JoinCondition( + new Condition('`active` = ?', [1]), + Placement::On, + ); + } + }; + + $result = (new Builder()) + ->from('t') + ->addHook($hook) + ->leftJoin('u', 't.uid', 'u.id') + ->build(); + + // ClickHouse forces all join filter conditions to WHERE placement + $this->assertStringContainsString('WHERE `active` = ?', $result->query); + $this->assertStringNotContainsString('ON `t`.`uid` = `u`.`id` AND', $result->query); + } + + // ==================== toRawSql ==================== + + public function testToRawSqlClickHouseSyntax(): void + { + $sql = (new Builder()) + ->from('t') + ->final() + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->toRawSql(); + + $this->assertStringContainsString('FROM `t` FINAL', $sql); + $this->assertStringContainsString("'active'", $sql); + $this->assertStringNotContainsString('?', $sql); + } + + // ==================== Reset comprehensive ==================== + + public function testResetClearsPrewhere(): void + { + $builder = (new Builder()) + ->from('t') + ->prewhere([Query::equal('status', ['active'])]); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertStringNotContainsString('PREWHERE', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testResetClearsSampleAndFinal(): void + { + $builder = (new Builder()) + ->from('t') + ->final() + ->sample(0.5); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertStringNotContainsString('FINAL', $result->query); + $this->assertStringNotContainsString('SAMPLE', $result->query); + } + + public function testEqualEmptyArrayReturnsFalse(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [])]) + ->build(); + + $this->assertStringContainsString('1 = 0', $result->query); + } + + public function testEqualWithNullOnly(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [null])]) + ->build(); + + $this->assertStringContainsString('`x` IS NULL', $result->query); + } + + public function testEqualWithNullAndValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [1, null])]) + ->build(); + + $this->assertStringContainsString('(`x` IN (?) OR `x` IS NULL)', $result->query); + $this->assertContains(1, $result->bindings); + } + + public function testNotEqualSingleValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', 42)]) + ->build(); + + $this->assertStringContainsString('`x` != ?', $result->query); + $this->assertContains(42, $result->bindings); + } + + public function testAndFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::and([Query::greaterThan('age', 18), Query::lessThan('age', 65)])]) + ->build(); + + $this->assertStringContainsString('(`age` > ? AND `age` < ?)', $result->query); + } + + public function testOrFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::or([Query::equal('role', ['admin']), Query::equal('role', ['editor'])])]) + ->build(); + + $this->assertStringContainsString('(`role` IN (?) OR `role` IN (?))', $result->query); + } + + public function testNestedAndInsideOr(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::or([ + Query::and([Query::greaterThan('age', 18), Query::lessThan('age', 30)]), + Query::and([Query::greaterThan('score', 80), Query::lessThan('score', 100)]), + ])]) + ->build(); + + $this->assertStringContainsString('((`age` > ? AND `age` < ?) OR (`score` > ? AND `score` < ?))', $result->query); + $this->assertEquals([18, 30, 80, 100], $result->bindings); + } + + public function testBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 18, 65)]) + ->build(); + + $this->assertStringContainsString('`age` BETWEEN ? AND ?', $result->query); + $this->assertEquals([18, 65], $result->bindings); + } + + public function testNotBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notBetween('score', 0, 50)]) + ->build(); + + $this->assertStringContainsString('`score` NOT BETWEEN ? AND ?', $result->query); + $this->assertEquals([0, 50], $result->bindings); + } + + public function testExistsMultipleAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name', 'email'])]) + ->build(); + + $this->assertStringContainsString('(`name` IS NOT NULL AND `email` IS NOT NULL)', $result->query); + } + + public function testNotExistsSingle(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists(['name'])]) + ->build(); + + $this->assertStringContainsString('(`name` IS NULL)', $result->query); + } + + public function testRawFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('score > ?', [10])]) + ->build(); + + $this->assertStringContainsString('score > ?', $result->query); + $this->assertContains(10, $result->bindings); + } + + public function testRawFilterEmpty(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('')]) + ->build(); + + $this->assertStringContainsString('1 = 1', $result->query); + } + + public function testDottedIdentifier(): void + { + $result = (new Builder()) + ->from('t') + ->select(['events.name']) + ->build(); + + $this->assertStringContainsString('`events`.`name`', $result->query); + } + + public function testMultipleOrderBy(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + + $this->assertStringContainsString('ORDER BY `name` ASC, `age` DESC', $result->query); + } + + public function testDistinctWithSelect(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['name']) + ->build(); + + $this->assertStringContainsString('SELECT DISTINCT `name`', $result->query); + } + + public function testSumWithAlias(): void + { + $result = (new Builder()) + ->from('t') + ->sum('amount', 'total') + ->build(); + + $this->assertStringContainsString('SUM(`amount`) AS `total`', $result->query); + } + + public function testMultipleAggregates(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->sum('amount', 'total') + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('SUM(`amount`) AS `total`', $result->query); + } + + public function testIsNullFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNull('deleted_at')]) + ->build(); + + $this->assertStringContainsString('`deleted_at` IS NULL', $result->query); + } + + public function testIsNotNullFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNotNull('name')]) + ->build(); + + $this->assertStringContainsString('`name` IS NOT NULL', $result->query); + } + + public function testLessThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('age', 30)]) + ->build(); + + $this->assertStringContainsString('`age` < ?', $result->query); + $this->assertEquals([30], $result->bindings); + } + + public function testLessThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThanEqual('age', 30)]) + ->build(); + + $this->assertStringContainsString('`age` <= ?', $result->query); + $this->assertEquals([30], $result->bindings); + } + + public function testGreaterThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('score', 50)]) + ->build(); + + $this->assertStringContainsString('`score` > ?', $result->query); + $this->assertEquals([50], $result->bindings); + } + + public function testGreaterThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('score', 50)]) + ->build(); + + $this->assertStringContainsString('`score` >= ?', $result->query); + $this->assertEquals([50], $result->bindings); + } + + public function testRightJoin(): void + { + $result = (new Builder()) + ->from('a') + ->rightJoin('b', 'a.id', 'b.a_id') + ->build(); + + $this->assertStringContainsString('RIGHT JOIN `b` ON `a`.`id` = `b`.`a_id`', $result->query); + } + + public function testCrossJoin(): void + { + $result = (new Builder()) + ->from('a') + ->crossJoin('b') + ->build(); + + $this->assertStringContainsString('CROSS JOIN `b`', $result->query); + $this->assertStringNotContainsString(' ON ', $result->query); + } + + public function testPrewhereAndFilterBindingOrderVerification(): void + { + $result = (new Builder()) + ->from('t') + ->prewhere([Query::equal('status', ['active'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + + $this->assertEquals(['active', 5], $result->bindings); + } + + public function testUpdateRawSetAndFilterBindingOrder(): void + { + $result = (new Builder()) + ->from('t') + ->setRaw('count', 'count + ?', [1]) + ->filter([Query::equal('status', ['active'])]) + ->update(); + + $this->assertEquals([1, 'active'], $result->bindings); + } + + public function testSortRandomUsesRand(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->build(); + + $this->assertStringContainsString('ORDER BY rand()', $result->query); + } + + // Feature 1: Table Aliases (ClickHouse - alias AFTER FINAL/SAMPLE) + + public function testTableAliasClickHouse(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->build(); + + $this->assertStringContainsString('FROM `events` AS `e`', $result->query); + } + + public function testTableAliasWithFinal(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->final() + ->build(); + + $this->assertStringContainsString('FROM `events` FINAL AS `e`', $result->query); + } + + public function testTableAliasWithSample(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->sample(0.1) + ->build(); + + $this->assertStringContainsString('FROM `events` SAMPLE 0.1 AS `e`', $result->query); + } + + public function testTableAliasWithFinalAndSample(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->final() + ->sample(0.5) + ->build(); + + $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.5 AS `e`', $result->query); + } + + // Feature 2: Subqueries (ClickHouse) + + public function testFromSubClickHouse(): void + { + $sub = (new Builder())->from('events')->select(['user_id'])->groupBy(['user_id']); + $result = (new Builder()) + ->fromSub($sub, 'sub') + ->select(['user_id']) + ->build(); + + $this->assertEquals( + 'SELECT `user_id` FROM (SELECT `user_id` FROM `events` GROUP BY `user_id`) AS `sub`', + $result->query + ); + } + + public function testFilterWhereInClickHouse(): void + { + $sub = (new Builder())->from('orders')->select(['user_id']); + $result = (new Builder()) + ->from('users') + ->filterWhereIn('id', $sub) + ->build(); + + $this->assertStringContainsString('`id` IN (SELECT `user_id` FROM `orders`)', $result->query); + } + + // Feature 3: Raw ORDER BY / GROUP BY / HAVING (ClickHouse) + + public function testOrderByRawClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->orderByRaw('toDate(`created_at`) ASC') + ->build(); + + $this->assertStringContainsString('ORDER BY toDate(`created_at`) ASC', $result->query); + } + + public function testGroupByRawClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupByRaw('toDate(`created_at`)') + ->build(); + + $this->assertStringContainsString('GROUP BY toDate(`created_at`)', $result->query); + } + + // Feature 4: countDistinct (ClickHouse) + + public function testCountDistinctClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->countDistinct('user_id', 'unique_users') + ->build(); + + $this->assertEquals( + 'SELECT COUNT(DISTINCT `user_id`) AS `unique_users` FROM `events`', + $result->query + ); + } + + // Feature 5: JoinBuilder (ClickHouse) + + public function testJoinWhereClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->joinWhere('users', function (\Utopia\Query\Builder\JoinBuilder $join): void { + $join->on('events.user_id', 'users.id'); + }) + ->build(); + + $this->assertStringContainsString('JOIN `users` ON `events`.`user_id` = `users`.`id`', $result->query); + } + + // Feature 6: EXISTS Subquery (ClickHouse) + + public function testFilterExistsClickHouse(): void + { + $sub = (new Builder())->from('orders')->select(['id'])->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + $result = (new Builder()) + ->from('users') + ->filterExists($sub) + ->build(); + + $this->assertStringContainsString('EXISTS (SELECT `id` FROM `orders`', $result->query); + } + + // Feature 9: EXPLAIN (ClickHouse) + + public function testExplainClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->explain(); + + $this->assertStringStartsWith('EXPLAIN SELECT', $result->query); + } + + public function testExplainAnalyzeClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->explain(true); + + $this->assertStringStartsWith('EXPLAIN ANALYZE SELECT', $result->query); + } + + // Feature: Cross Join Alias (ClickHouse) + + public function testCrossJoinAliasClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->crossJoin('dates', 'd') + ->build(); + + $this->assertStringContainsString('CROSS JOIN `dates` AS `d`', $result->query); + } + + // Subquery bindings (ClickHouse) + + public function testWhereInSubqueryClickHouse(): void + { + $sub = (new Builder())->from('active_users')->select(['id']); + + $result = (new Builder()) + ->from('events') + ->filterWhereIn('user_id', $sub) + ->build(); + + $this->assertStringContainsString('`user_id` IN (SELECT `id` FROM `active_users`)', $result->query); + } + + public function testWhereNotInSubqueryClickHouse(): void + { + $sub = (new Builder())->from('banned_users')->select(['id']); + + $result = (new Builder()) + ->from('events') + ->filterWhereNotIn('user_id', $sub) + ->build(); + + $this->assertStringContainsString('`user_id` NOT IN (SELECT', $result->query); + } + + public function testSelectSubClickHouse(): void + { + $sub = (new Builder())->from('events')->selectRaw('COUNT(*)'); + + $result = (new Builder()) + ->from('users') + ->selectSub($sub, 'event_count') + ->build(); + + $this->assertStringContainsString('(SELECT COUNT(*) FROM `events`) AS `event_count`', $result->query); + } + + public function testFromSubWithGroupByClickHouse(): void + { + $sub = (new Builder())->from('events')->select(['user_id'])->groupBy(['user_id']); + + $result = (new Builder()) + ->fromSub($sub, 'sub') + ->select(['user_id']) + ->build(); + + $this->assertStringContainsString('FROM (SELECT `user_id` FROM `events`', $result->query); + $this->assertStringContainsString(') AS `sub`', $result->query); + } + + // NOT EXISTS (ClickHouse) + + public function testFilterNotExistsClickHouse(): void + { + $sub = (new Builder())->from('banned')->select(['id']); + + $result = (new Builder()) + ->from('users') + ->filterNotExists($sub) + ->build(); + + $this->assertStringContainsString('NOT EXISTS (SELECT', $result->query); + } + + // HavingRaw (ClickHouse) + + public function testHavingRawClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['user_id']) + ->havingRaw('COUNT(*) > ?', [10]) + ->build(); + + $this->assertStringContainsString('HAVING COUNT(*) > ?', $result->query); + $this->assertEquals([10], $result->bindings); + } + + // Table alias with FINAL and SAMPLE and alias combined + + public function testTableAliasWithFinalSampleAndAlias(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->final() + ->sample(0.5) + ->build(); + + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringContainsString('SAMPLE', $result->query); + $this->assertStringContainsString('AS `e`', $result->query); + } + + // JoinWhere LEFT JOIN (ClickHouse) + + public function testJoinWhereLeftJoinClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->joinWhere('users', function (\Utopia\Query\Builder\JoinBuilder $join): void { + $join->on('events.user_id', 'users.id') + ->where('users.active', '=', 1); + }, 'LEFT JOIN') + ->build(); + + $this->assertStringContainsString('LEFT JOIN `users` ON', $result->query); + $this->assertEquals([1], $result->bindings); + } + + // JoinWhere with alias (ClickHouse) + + public function testJoinWhereWithAliasClickHouse(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->joinWhere('users', function (\Utopia\Query\Builder\JoinBuilder $join): void { + $join->on('e.user_id', 'u.id'); + }, 'JOIN', 'u') + ->build(); + + $this->assertStringContainsString('JOIN `users` AS `u`', $result->query); + } + + // JoinWhere with multiple ON conditions (ClickHouse) + + public function testJoinWhereMultipleOnsClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->joinWhere('users', function (\Utopia\Query\Builder\JoinBuilder $join): void { + $join->on('events.user_id', 'users.id') + ->on('events.tenant_id', 'users.tenant_id'); + }) + ->build(); + + $this->assertStringContainsString( + 'ON `events`.`user_id` = `users`.`id` AND `events`.`tenant_id` = `users`.`tenant_id`', + $result->query + ); + } + + // EXPLAIN preserves bindings (ClickHouse) + + public function testExplainPreservesBindings(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::equal('status', ['active'])]) + ->explain(); + + $this->assertStringStartsWith('EXPLAIN SELECT', $result->query); + $this->assertEquals(['active'], $result->bindings); + } + + // countDistinct without alias (ClickHouse) + + public function testCountDistinctWithoutAliasClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->countDistinct('user_id') + ->build(); + + $this->assertStringContainsString('COUNT(DISTINCT `user_id`)', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + // Multiple subqueries combined (ClickHouse) + + public function testMultipleSubqueriesCombined(): void + { + $sub1 = (new Builder())->from('active_users')->select(['id']); + $sub2 = (new Builder())->from('banned_users')->select(['id']); + + $result = (new Builder()) + ->from('events') + ->filterWhereIn('user_id', $sub1) + ->filterWhereNotIn('user_id', $sub2) + ->build(); + + $this->assertStringContainsString('IN (SELECT', $result->query); + $this->assertStringContainsString('NOT IN (SELECT', $result->query); + } + + // PREWHERE with subquery (ClickHouse) + + public function testPrewhereWithSubquery(): void + { + $sub = (new Builder())->from('active_users')->select(['id']); + + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filterWhereIn('user_id', $sub) + ->build(); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('IN (SELECT', $result->query); + } + + // Settings with subquery (ClickHouse) + + public function testSettingsStillAppear(): void + { + $result = (new Builder()) + ->from('events') + ->settings(['max_threads' => '4']) + ->orderByRaw('`created_at` DESC') + ->build(); + + $this->assertStringContainsString('SETTINGS max_threads=4', $result->query); + $this->assertStringContainsString('ORDER BY `created_at` DESC', $result->query); + } } diff --git a/tests/Query/Builder/SQLTest.php b/tests/Query/Builder/MySQLTest.php similarity index 61% rename from tests/Query/Builder/SQLTest.php rename to tests/Query/Builder/MySQLTest.php index c31056a..c33aeab 100644 --- a/tests/Query/Builder/SQLTest.php +++ b/tests/Query/Builder/MySQLTest.php @@ -3,23 +3,97 @@ namespace Tests\Query\Builder; use PHPUnit\Framework\TestCase; +use Utopia\Query\Builder\Case\Builder as CaseBuilder; use Utopia\Query\Builder\Condition; -use Utopia\Query\Builder\SQL as Builder; +use Utopia\Query\Builder\Feature\Aggregates; +use Utopia\Query\Builder\Feature\CTEs; +use Utopia\Query\Builder\Feature\Deletes; +use Utopia\Query\Builder\Feature\Hooks; +use Utopia\Query\Builder\Feature\Inserts; +use Utopia\Query\Builder\Feature\Joins; +use Utopia\Query\Builder\Feature\Locking; +use Utopia\Query\Builder\Feature\Selects; +use Utopia\Query\Builder\Feature\Transactions; +use Utopia\Query\Builder\Feature\Unions; +use Utopia\Query\Builder\Feature\Updates; +use Utopia\Query\Builder\Feature\Upsert; +use Utopia\Query\Builder\MySQL as Builder; use Utopia\Query\Compiler; -use Utopia\Query\Hook\AttributeMapHook; -use Utopia\Query\Hook\FilterHook; +use Utopia\Query\Exception\UnsupportedException; +use Utopia\Query\Exception\ValidationException; +use Utopia\Query\Hook\Attribute; +use Utopia\Query\Hook\Attribute\Map as AttributeMap; +use Utopia\Query\Hook\Filter; use Utopia\Query\Query; -class SQLTest extends TestCase +class MySQLTest extends TestCase { - // ── Compiler compliance ── - public function testImplementsCompiler(): void { $builder = new Builder(); $this->assertInstanceOf(Compiler::class, $builder); } + public function testImplementsTransactions(): void + { + $this->assertInstanceOf(Transactions::class, new Builder()); + } + + public function testImplementsLocking(): void + { + $this->assertInstanceOf(Locking::class, new Builder()); + } + + public function testImplementsUpsert(): void + { + $this->assertInstanceOf(Upsert::class, new Builder()); + } + + public function testImplementsSelects(): void + { + $this->assertInstanceOf(Selects::class, new Builder()); + } + + public function testImplementsAggregates(): void + { + $this->assertInstanceOf(Aggregates::class, new Builder()); + } + + public function testImplementsJoins(): void + { + $this->assertInstanceOf(Joins::class, new Builder()); + } + + public function testImplementsUnions(): void + { + $this->assertInstanceOf(Unions::class, new Builder()); + } + + public function testImplementsCTEs(): void + { + $this->assertInstanceOf(CTEs::class, new Builder()); + } + + public function testImplementsInserts(): void + { + $this->assertInstanceOf(Inserts::class, new Builder()); + } + + public function testImplementsUpdates(): void + { + $this->assertInstanceOf(Updates::class, new Builder()); + } + + public function testImplementsDeletes(): void + { + $this->assertInstanceOf(Deletes::class, new Builder()); + } + + public function testImplementsHooks(): void + { + $this->assertInstanceOf(Hooks::class, new Builder()); + } + public function testStandaloneCompile(): void { $builder = new Builder(); @@ -30,8 +104,6 @@ public function testStandaloneCompile(): void $this->assertEquals([18], $builder->getBindings()); } - // ── Fluent API ── - public function testFluentSelectFromFilterSortLimitOffset(): void { $result = (new Builder()) @@ -53,8 +125,6 @@ public function testFluentSelectFromFilterSortLimitOffset(): void $this->assertEquals(['active', 18, 25, 0], $result->bindings); } - // ── Batch mode ── - public function testBatchModeProducesSameOutput(): void { $result = (new Builder()) @@ -76,8 +146,6 @@ public function testBatchModeProducesSameOutput(): void $this->assertEquals(['active', 18, 25, 0], $result->bindings); } - // ── Filter types ── - public function testEqual(): void { $result = (new Builder()) @@ -364,8 +432,6 @@ public function testNotExists(): void $this->assertEquals([], $result->bindings); } - // ── Logical / nested ── - public function testAndLogical(): void { $result = (new Builder()) @@ -420,8 +486,6 @@ public function testDeeplyNested(): void $this->assertEquals([18, 'admin', 'mod'], $result->bindings); } - // ── Sort ── - public function testSortAsc(): void { $result = (new Builder()) @@ -463,8 +527,6 @@ public function testMultipleSorts(): void $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC, `age` DESC', $result->query); } - // ── Pagination ── - public function testLimitOnly(): void { $result = (new Builder()) @@ -510,8 +572,6 @@ public function testCursorBefore(): void $this->assertEquals(['xyz789'], $result->bindings); } - // ── Combined full query ── - public function testFullCombinedQuery(): void { $result = (new Builder()) @@ -534,8 +594,6 @@ public function testFullCombinedQuery(): void $this->assertEquals(['active', 18, 25, 10], $result->bindings); } - // ── Multiple filter() calls (additive) ── - public function testMultipleFilterCalls(): void { $result = (new Builder()) @@ -548,8 +606,6 @@ public function testMultipleFilterCalls(): void $this->assertEquals([1, 2], $result->bindings); } - // ── Reset ── - public function testResetClearsState(): void { $builder = (new Builder()) @@ -571,13 +627,11 @@ public function testResetClearsState(): void $this->assertEquals([100], $result->bindings); } - // ── Extension points ── - public function testAttributeResolver(): void { $result = (new Builder()) ->from('users') - ->addHook(new AttributeMapHook([ + ->addHook(new AttributeMap([ '$id' => '_uid', '$createdAt' => '_createdAt', ])) @@ -594,7 +648,7 @@ public function testAttributeResolver(): void public function testMultipleAttributeHooksChain(): void { - $prefixHook = new class () implements \Utopia\Query\Hook\AttributeHook { + $prefixHook = new class () implements Attribute { public function resolve(string $attribute): string { return 'col_' . $attribute; @@ -603,7 +657,7 @@ public function resolve(string $attribute): string $result = (new Builder()) ->from('t') - ->addHook(new AttributeMapHook(['name' => 'full_name'])) + ->addHook(new AttributeMap(['name' => 'full_name'])) ->addHook($prefixHook) ->filter([Query::equal('name', ['Alice'])]) ->build(); @@ -617,7 +671,7 @@ public function resolve(string $attribute): string public function testDualInterfaceHook(): void { - $hook = new class () implements \Utopia\Query\Hook\FilterHook, \Utopia\Query\Hook\AttributeHook { + $hook = new class () implements Filter, Attribute { public function filter(string $table): Condition { return new Condition('_tenant = ?', ['t1']); @@ -645,24 +699,9 @@ public function resolve(string $attribute): string $this->assertEquals(['abc', 't1'], $result->bindings); } - public function testWrapChar(): void - { - $result = (new Builder()) - ->from('users') - ->setWrapChar('"') - ->select(['name']) - ->filter([Query::equal('status', ['active'])]) - ->build(); - - $this->assertEquals( - 'SELECT "name" FROM "users" WHERE "status" IN (?)', - $result->query - ); - } - public function testConditionProvider(): void { - $hook = new class () implements FilterHook { + $hook = new class () implements Filter { public function filter(string $table): Condition { return new Condition( @@ -686,7 +725,7 @@ public function filter(string $table): Condition public function testConditionProviderWithBindings(): void { - $hook = new class () implements FilterHook { + $hook = new class () implements Filter { public function filter(string $table): Condition { return new Condition('_tenant = ?', ['tenant_abc']); @@ -709,7 +748,7 @@ public function filter(string $table): Condition public function testBindingOrderingWithProviderAndCursor(): void { - $hook = new class () implements FilterHook { + $hook = new class () implements Filter { public function filter(string $table): Condition { return new Condition('_tenant = ?', ['t1']); @@ -729,8 +768,6 @@ public function filter(string $table): Condition $this->assertEquals(['active', 't1', 'cursor_val', 10, 5], $result->bindings); } - // ── Select with no columns defaults to * ── - public function testDefaultSelectStar(): void { $result = (new Builder()) @@ -740,8 +777,6 @@ public function testDefaultSelectStar(): void $this->assertEquals('SELECT * FROM `t`', $result->query); } - // ── Aggregations ── - public function testCountStar(): void { $result = (new Builder()) @@ -818,8 +853,6 @@ public function testAggregationWithSelection(): void ); } - // ── Group By ── - public function testGroupBy(): void { $result = (new Builder()) @@ -848,8 +881,6 @@ public function testGroupByMultiple(): void ); } - // ── Having ── - public function testHaving(): void { $result = (new Builder()) @@ -866,8 +897,6 @@ public function testHaving(): void $this->assertEquals([5], $result->bindings); } - // ── Distinct ── - public function testDistinct(): void { $result = (new Builder()) @@ -889,8 +918,6 @@ public function testDistinctStar(): void $this->assertEquals('SELECT DISTINCT * FROM `t`', $result->query); } - // ── Joins ── - public function testJoin(): void { $result = (new Builder()) @@ -958,8 +985,6 @@ public function testJoinWithFilter(): void $this->assertEquals([100], $result->bindings); } - // ── Raw ── - public function testRawFilter(): void { $result = (new Builder()) @@ -982,8 +1007,6 @@ public function testRawFilterNoBindings(): void $this->assertEquals([], $result->bindings); } - // ── Union ── - public function testUnion(): void { $admins = (new Builder())->from('admins')->filter([Query::equal('role', ['admin'])]); @@ -1014,8 +1037,6 @@ public function testUnionAll(): void ); } - // ── when() ── - public function testWhenTrue(): void { $result = (new Builder()) @@ -1038,8 +1059,6 @@ public function testWhenFalse(): void $this->assertEquals([], $result->bindings); } - // ── page() ── - public function testPage(): void { $result = (new Builder()) @@ -1062,8 +1081,6 @@ public function testPageDefaultPerPage(): void $this->assertEquals([25, 0], $result->bindings); } - // ── toRawSql() ── - public function testToRawSql(): void { $sql = (new Builder()) @@ -1088,8 +1105,6 @@ public function testToRawSqlNumericBindings(): void $this->assertEquals("SELECT * FROM `t` WHERE `age` > 18", $sql); } - // ── Combined complex query ── - public function testCombinedAggregationJoinGroupByHaving(): void { $result = (new Builder()) @@ -1111,8 +1126,6 @@ public function testCombinedAggregationJoinGroupByHaving(): void $this->assertEquals([5, 10], $result->bindings); } - // ── Reset clears unions ── - public function testResetClearsUnions(): void { $other = (new Builder())->from('archive'); @@ -1127,12 +1140,8 @@ public function testResetClearsUnions(): void $this->assertEquals('SELECT * FROM `fresh`', $result->query); } - - // ══════════════════════════════════════════ // EDGE CASES & COMBINATIONS - // ══════════════════════════════════════════ - // ── Aggregation edge cases ── public function testCountWithNamedColumn(): void { @@ -1208,8 +1217,6 @@ public function testAggregationWithoutAlias(): void $this->assertEquals('SELECT COUNT(*), SUM(`price`) FROM `t`', $result->query); } - // ── Group By edge cases ── - public function testGroupByEmptyArray(): void { $result = (new Builder()) @@ -1235,8 +1242,6 @@ public function testMultipleGroupByCalls(): void $this->assertStringContainsString('`country`', $result->query); } - // ── Having edge cases ── - public function testHavingEmptyArray(): void { $result = (new Builder()) @@ -1314,8 +1319,6 @@ public function testMultipleHavingCalls(): void $this->assertEquals([1, 100], $result->bindings); } - // ── Distinct edge cases ── - public function testDistinctWithAggregation(): void { $result = (new Builder()) @@ -1370,8 +1373,6 @@ public function testDistinctWithFilterAndSort(): void ); } - // ── Join combinations ── - public function testMultipleJoins(): void { $result = (new Builder()) @@ -1447,8 +1448,6 @@ public function testCrossJoinWithOtherJoins(): void ); } - // ── Raw edge cases ── - public function testRawWithMixedBindings(): void { $result = (new Builder()) @@ -1488,8 +1487,6 @@ public function testRawWithEmptySql(): void $this->assertStringContainsString('WHERE', $result->query); } - // ── Union edge cases ── - public function testMultipleUnions(): void { $q1 = (new Builder())->from('admins'); @@ -1559,8 +1556,6 @@ public function testUnionWithAggregation(): void ); } - // ── when() edge cases ── - public function testWhenNested(): void { $result = (new Builder()) @@ -1586,17 +1581,13 @@ public function testWhenMultipleCalls(): void $this->assertEquals([1, 3], $result->bindings); } - // ── page() edge cases ── - public function testPageZero(): void { - $result = (new Builder()) + $this->expectException(ValidationException::class); + (new Builder()) ->from('t') ->page(0, 10) ->build(); - - // page 0 → offset clamped to 0 - $this->assertEquals([10, 0], $result->bindings); } public function testPageOnePerPage(): void @@ -1620,8 +1611,6 @@ public function testPageLargeValues(): void $this->assertEquals([100, 99900], $result->bindings); } - // ── toRawSql() edge cases ── - public function testToRawSqlWithBooleanBindings(): void { // Booleans must be handled in toRawSql @@ -1673,8 +1662,6 @@ public function testToRawSqlComplexQuery(): void ); } - // ── Exception paths ── - public function testCompileFilterUnsupportedType(): void { $this->expectException(\ValueError::class); @@ -1686,7 +1673,7 @@ public function testCompileOrderUnsupportedType(): void $builder = new Builder(); $query = new Query('equal', 'x', [1]); - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); $this->expectExceptionMessage('Unsupported order type: equal'); $builder->compileOrder($query); } @@ -1696,16 +1683,14 @@ public function testCompileJoinUnsupportedType(): void $builder = new Builder(); $query = new Query('equal', 't', ['a', '=', 'b']); - $this->expectException(\Utopia\Query\Exception::class); + $this->expectException(UnsupportedException::class); $this->expectExceptionMessage('Unsupported join type: equal'); $builder->compileJoin($query); } - // ── Binding order edge cases ── - public function testBindingOrderFilterProviderCursorLimitOffset(): void { - $hook = new class () implements FilterHook { + $hook = new class () implements Filter { public function filter(string $table): Condition { return new Condition('_tenant = ?', ['tenant1']); @@ -1730,13 +1715,13 @@ public function filter(string $table): Condition public function testBindingOrderMultipleProviders(): void { - $hook1 = new class () implements FilterHook { + $hook1 = new class () implements Filter { public function filter(string $table): Condition { return new Condition('p1 = ?', ['v1']); } }; - $hook2 = new class () implements FilterHook { + $hook2 = new class () implements Filter { public function filter(string $table): Condition { return new Condition('p2 = ?', ['v2']); @@ -1787,7 +1772,7 @@ public function testBindingOrderComplexMixed(): void { $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); - $hook = new class () implements FilterHook { + $hook = new class () implements Filter { public function filter(string $table): Condition { return new Condition('_org = ?', ['org1']); @@ -1811,13 +1796,11 @@ public function filter(string $table): Condition $this->assertEquals(['paid', 'org1', 'cur1', 1, 10, 5, 2023], $result->bindings); } - // ── Attribute resolver with new features ── - public function testAttributeResolverWithAggregation(): void { $result = (new Builder()) ->from('t') - ->addHook(new AttributeMapHook(['$price' => '_price'])) + ->addHook(new AttributeMap(['$price' => '_price'])) ->sum('$price', 'total') ->build(); @@ -1828,7 +1811,7 @@ public function testAttributeResolverWithGroupBy(): void { $result = (new Builder()) ->from('t') - ->addHook(new AttributeMapHook(['$status' => '_status'])) + ->addHook(new AttributeMap(['$status' => '_status'])) ->count('*', 'total') ->groupBy(['$status']) ->build(); @@ -1843,7 +1826,7 @@ public function testAttributeResolverWithJoin(): void { $result = (new Builder()) ->from('t') - ->addHook(new AttributeMapHook([ + ->addHook(new AttributeMap([ '$id' => '_uid', '$ref' => '_ref', ])) @@ -1860,7 +1843,7 @@ public function testAttributeResolverWithHaving(): void { $result = (new Builder()) ->from('t') - ->addHook(new AttributeMapHook(['$total' => '_total'])) + ->addHook(new AttributeMap(['$total' => '_total'])) ->count('*', 'cnt') ->groupBy(['status']) ->having([Query::greaterThan('$total', 5)]) @@ -1869,54 +1852,9 @@ public function testAttributeResolverWithHaving(): void $this->assertStringContainsString('HAVING `_total` > ?', $result->query); } - // ── Wrap char with new features ── - - public function testWrapCharWithJoin(): void - { - $result = (new Builder()) - ->from('users') - ->setWrapChar('"') - ->join('orders', 'users.id', 'orders.uid') - ->build(); - - $this->assertEquals( - 'SELECT * FROM "users" JOIN "orders" ON "users"."id" = "orders"."uid"', - $result->query - ); - } - - public function testWrapCharWithAggregation(): void - { - $result = (new Builder()) - ->from('t') - ->setWrapChar('"') - ->count('id', 'total') - ->groupBy(['status']) - ->build(); - - $this->assertEquals( - 'SELECT COUNT("id") AS "total" FROM "t" GROUP BY "status"', - $result->query - ); - } - - public function testWrapCharEmpty(): void - { - $result = (new Builder()) - ->from('t') - ->setWrapChar('') - ->select(['name']) - ->filter([Query::equal('status', ['active'])]) - ->build(); - - $this->assertEquals('SELECT name FROM t WHERE status IN (?)', $result->query); - } - - // ── Condition provider with new features ── - public function testConditionProviderWithJoins(): void { - $hook = new class () implements FilterHook { + $hook = new class () implements Filter { public function filter(string $table): Condition { return new Condition('users.org_id = ?', ['org1']); @@ -1939,7 +1877,7 @@ public function filter(string $table): Condition public function testConditionProviderWithAggregation(): void { - $hook = new class () implements FilterHook { + $hook = new class () implements Filter { public function filter(string $table): Condition { return new Condition('org_id = ?', ['org1']); @@ -1957,8 +1895,6 @@ public function filter(string $table): Condition $this->assertEquals(['org1'], $result->bindings); } - // ── Multiple build() calls ── - public function testMultipleBuildsConsistentOutput(): void { $builder = (new Builder()) @@ -1973,41 +1909,14 @@ public function testMultipleBuildsConsistentOutput(): void $this->assertEquals($result1->bindings, $result2->bindings); } - // ── Reset behavior ── - - public function testResetDoesNotClearWrapCharOrHooks(): void - { - $hook = new class () implements \Utopia\Query\Hook\AttributeHook { - public function resolve(string $attribute): string - { - return '_' . $attribute; - } - }; - - $builder = (new Builder()) - ->from('t') - ->setWrapChar('"') - ->addHook($hook) - ->filter([Query::equal('x', [1])]); - - $builder->build(); - $builder->reset(); - - // wrapChar and hooks should persist since reset() only clears queries/bindings/table/unions - $result = $builder->from('t2')->filter([Query::equal('y', [2])])->build(); - $this->assertEquals('SELECT * FROM "t2" WHERE "_y" IN (?)', $result->query); - } - - // ── Empty query ── public function testEmptyBuilderNoFrom(): void { - $result = (new Builder())->from('')->build(); - $this->assertEquals('SELECT * FROM ``', $result->query); + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder())->from('')->build(); } - // ── Cursor with other pagination ── - public function testCursorWithLimitAndOffset(): void { $result = (new Builder()) @@ -2037,8 +1946,6 @@ public function testCursorWithPage(): void $this->assertStringContainsString('LIMIT ?', $result->query); } - // ── Full kitchen sink ── - public function testKitchenSinkQuery(): void { $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); @@ -2055,7 +1962,7 @@ public function testKitchenSinkQuery(): void Query::equal('orders.status', ['paid']), Query::greaterThan('orders.total', 0), ]) - ->addHook(new class () implements FilterHook { + ->addHook(new class () implements Filter { public function filter(string $table): Condition { return new Condition('org = ?', ['o1']); @@ -2098,8 +2005,6 @@ public function filter(string $table): Condition $this->assertLessThan(strpos($query, 'UNION'), (int) strpos($query, 'OFFSET')); } - // ── Filter empty arrays ── - public function testFilterEmptyArray(): void { $result = (new Builder()) @@ -2121,8 +2026,6 @@ public function testSelectEmptyArray(): void $this->assertEquals('SELECT FROM `t`', $result->query); } - // ── Limit/offset edge values ── - public function testLimitZero(): void { $result = (new Builder()) @@ -2146,8 +2049,6 @@ public function testOffsetZero(): void $this->assertEquals([], $result->bindings); } - // ── Fluent chaining returns same instance ── - public function testFluentChainingReturnsSameInstance(): void { $builder = new Builder(); @@ -2163,7 +2064,6 @@ public function testFluentChainingReturnsSameInstance(): void $this->assertSame($builder, $builder->cursorAfter('x')); $this->assertSame($builder, $builder->cursorBefore('x')); $this->assertSame($builder, $builder->queries([])); - $this->assertSame($builder, $builder->setWrapChar('`')); $this->assertSame($builder, $builder->count()); $this->assertSame($builder, $builder->sum('a')); $this->assertSame($builder, $builder->avg('a')); @@ -2191,10 +2091,7 @@ public function testUnionFluentChainingReturnsSameInstance(): void $other2 = (new Builder())->from('t'); $this->assertSame($builder, $builder->from('t')->unionAll($other2)); } - - // ══════════════════════════════════════════ // 1. SQL-Specific: REGEXP - // ══════════════════════════════════════════ public function testRegexWithEmptyPattern(): void { @@ -2320,7 +2217,7 @@ public function testRegexWithAttributeResolver(): void { $result = (new Builder()) ->from('t') - ->addHook(new AttributeMapHook([ + ->addHook(new AttributeMap([ '$slug' => '_slug', ])) ->filter([Query::regex('$slug', '^test')]) @@ -2330,17 +2227,6 @@ public function testRegexWithAttributeResolver(): void $this->assertEquals(['^test'], $result->bindings); } - public function testRegexWithDifferentWrapChar(): void - { - $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->filter([Query::regex('slug', '^[a-z]+$')]) - ->build(); - - $this->assertEquals('SELECT * FROM "t" WHERE "slug" REGEXP ?', $result->query); - } - public function testRegexStandaloneCompileFilter(): void { $builder = new Builder(); @@ -2428,10 +2314,7 @@ public function testRegexInOrLogicalGroup(): void ); $this->assertEquals(['^Admin', '^Mod'], $result->bindings); } - - // ══════════════════════════════════════════ // 2. SQL-Specific: MATCH AGAINST / Search - // ══════════════════════════════════════════ public function testSearchWithEmptyString(): void { @@ -2493,7 +2376,7 @@ public function testSearchWithAttributeResolver(): void { $result = (new Builder()) ->from('t') - ->addHook(new AttributeMapHook([ + ->addHook(new AttributeMap([ '$body' => '_body', ])) ->filter([Query::search('$body', 'hello')]) @@ -2502,17 +2385,6 @@ public function testSearchWithAttributeResolver(): void $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`_body`) AGAINST(?)', $result->query); } - public function testSearchWithDifferentWrapChar(): void - { - $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->filter([Query::search('content', 'hello')]) - ->build(); - - $this->assertEquals('SELECT * FROM "t" WHERE MATCH("content") AGAINST(?)', $result->query); - } - public function testSearchStandaloneCompileFilter(): void { $builder = new Builder(); @@ -2636,10 +2508,7 @@ public function testNotSearchStandalone(): void $this->assertEquals('SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?))', $result->query); $this->assertEquals(['spam'], $result->bindings); } - - // ══════════════════════════════════════════ // 3. SQL-Specific: RAND() - // ══════════════════════════════════════════ public function testRandomSortStandaloneCompile(): void { @@ -2750,7 +2619,7 @@ public function testRandomSortWithAttributeResolver(): void { $result = (new Builder()) ->from('t') - ->addHook(new class () implements \Utopia\Query\Hook\AttributeHook { + ->addHook(new class () implements Attribute { public function resolve(string $attribute): string { return '_' . $attribute; @@ -2785,3870 +2654,7116 @@ public function testRandomSortWithOffset(): void $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ? OFFSET ?', $result->query); $this->assertEquals([10, 5], $result->bindings); } + // 5. Standalone Compiler method calls - // ══════════════════════════════════════════ - // 4. setWrapChar comprehensive - // ══════════════════════════════════════════ - - public function testWrapCharSingleQuote(): void + public function testCompileFilterEqual(): void { - $result = (new Builder()) - ->setWrapChar("'") - ->from('t') - ->select(['name']) - ->build(); - - $this->assertEquals("SELECT 'name' FROM 't'", $result->query); + $builder = new Builder(); + $sql = $builder->compileFilter(Query::equal('col', ['a', 'b'])); + $this->assertEquals('`col` IN (?, ?)', $sql); + $this->assertEquals(['a', 'b'], $builder->getBindings()); } - public function testWrapCharSquareBracket(): void + public function testCompileFilterNotEqual(): void { - $result = (new Builder()) - ->setWrapChar('[') - ->from('t') - ->select(['name']) - ->build(); - - $this->assertEquals('SELECT [name[ FROM [t[', $result->query); + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notEqual('col', 'a')); + $this->assertEquals('`col` != ?', $sql); + $this->assertEquals(['a'], $builder->getBindings()); } - public function testWrapCharUnicode(): void + public function testCompileFilterLessThan(): void { - $result = (new Builder()) - ->setWrapChar("\xC2\xAB") - ->from('t') - ->select(['name']) - ->build(); - - $this->assertEquals("SELECT \xC2\xABname\xC2\xAB FROM \xC2\xABt\xC2\xAB", $result->query); + $builder = new Builder(); + $sql = $builder->compileFilter(Query::lessThan('col', 10)); + $this->assertEquals('`col` < ?', $sql); + $this->assertEquals([10], $builder->getBindings()); } - public function testWrapCharAffectsSelect(): void + public function testCompileFilterLessThanEqual(): void { - $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->select(['a', 'b', 'c']) - ->build(); - - $this->assertEquals('SELECT "a", "b", "c" FROM "t"', $result->query); + $builder = new Builder(); + $sql = $builder->compileFilter(Query::lessThanEqual('col', 10)); + $this->assertEquals('`col` <= ?', $sql); + $this->assertEquals([10], $builder->getBindings()); } - public function testWrapCharAffectsFrom(): void + public function testCompileFilterGreaterThan(): void { - $result = (new Builder()) - ->setWrapChar('"') - ->from('my_table') - ->build(); - - $this->assertEquals('SELECT * FROM "my_table"', $result->query); + $builder = new Builder(); + $sql = $builder->compileFilter(Query::greaterThan('col', 10)); + $this->assertEquals('`col` > ?', $sql); + $this->assertEquals([10], $builder->getBindings()); } - public function testWrapCharAffectsFilter(): void + public function testCompileFilterGreaterThanEqual(): void { - $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->filter([Query::equal('col', [1])]) - ->build(); - - $this->assertEquals('SELECT * FROM "t" WHERE "col" IN (?)', $result->query); + $builder = new Builder(); + $sql = $builder->compileFilter(Query::greaterThanEqual('col', 10)); + $this->assertEquals('`col` >= ?', $sql); + $this->assertEquals([10], $builder->getBindings()); } - public function testWrapCharAffectsSort(): void + public function testCompileFilterBetween(): void { - $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->sortAsc('name') - ->sortDesc('age') - ->build(); - - $this->assertEquals('SELECT * FROM "t" ORDER BY "name" ASC, "age" DESC', $result->query); + $builder = new Builder(); + $sql = $builder->compileFilter(Query::between('col', 1, 100)); + $this->assertEquals('`col` BETWEEN ? AND ?', $sql); + $this->assertEquals([1, 100], $builder->getBindings()); } - public function testWrapCharAffectsJoin(): void + public function testCompileFilterNotBetween(): void { - $result = (new Builder()) - ->setWrapChar('"') - ->from('users') - ->join('orders', 'users.id', 'orders.uid') - ->build(); - - $this->assertEquals( - 'SELECT * FROM "users" JOIN "orders" ON "users"."id" = "orders"."uid"', - $result->query - ); + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notBetween('col', 1, 100)); + $this->assertEquals('`col` NOT BETWEEN ? AND ?', $sql); + $this->assertEquals([1, 100], $builder->getBindings()); } - public function testWrapCharAffectsLeftJoin(): void + public function testCompileFilterStartsWith(): void { - $result = (new Builder()) - ->setWrapChar('"') - ->from('users') - ->leftJoin('profiles', 'users.id', 'profiles.uid') - ->build(); + $builder = new Builder(); + $sql = $builder->compileFilter(Query::startsWith('col', 'abc')); + $this->assertEquals('`col` LIKE ?', $sql); + $this->assertEquals(['abc%'], $builder->getBindings()); + } - $this->assertEquals( - 'SELECT * FROM "users" LEFT JOIN "profiles" ON "users"."id" = "profiles"."uid"', - $result->query + public function testCompileFilterNotStartsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notStartsWith('col', 'abc')); + $this->assertEquals('`col` NOT LIKE ?', $sql); + $this->assertEquals(['abc%'], $builder->getBindings()); + } + + public function testCompileFilterEndsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::endsWith('col', 'xyz')); + $this->assertEquals('`col` LIKE ?', $sql); + $this->assertEquals(['%xyz'], $builder->getBindings()); + } + + public function testCompileFilterNotEndsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notEndsWith('col', 'xyz')); + $this->assertEquals('`col` NOT LIKE ?', $sql); + $this->assertEquals(['%xyz'], $builder->getBindings()); + } + + public function testCompileFilterContainsSingle(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::contains('col', ['val'])); + $this->assertEquals('`col` LIKE ?', $sql); + $this->assertEquals(['%val%'], $builder->getBindings()); + } + + public function testCompileFilterContainsMultiple(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::contains('col', ['a', 'b'])); + $this->assertEquals('(`col` LIKE ? OR `col` LIKE ?)', $sql); + $this->assertEquals(['%a%', '%b%'], $builder->getBindings()); + } + + public function testCompileFilterContainsAny(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::containsAny('col', ['a', 'b'])); + $this->assertEquals('`col` IN (?, ?)', $sql); + $this->assertEquals(['a', 'b'], $builder->getBindings()); + } + + public function testCompileFilterContainsAll(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::containsAll('col', ['a', 'b'])); + $this->assertEquals('(`col` LIKE ? AND `col` LIKE ?)', $sql); + $this->assertEquals(['%a%', '%b%'], $builder->getBindings()); + } + + public function testCompileFilterNotContainsSingle(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notContains('col', ['val'])); + $this->assertEquals('`col` NOT LIKE ?', $sql); + $this->assertEquals(['%val%'], $builder->getBindings()); + } + + public function testCompileFilterNotContainsMultiple(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notContains('col', ['a', 'b'])); + $this->assertEquals('(`col` NOT LIKE ? AND `col` NOT LIKE ?)', $sql); + $this->assertEquals(['%a%', '%b%'], $builder->getBindings()); + } + + public function testCompileFilterIsNull(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::isNull('col')); + $this->assertEquals('`col` IS NULL', $sql); + $this->assertEquals([], $builder->getBindings()); + } + + public function testCompileFilterIsNotNull(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::isNotNull('col')); + $this->assertEquals('`col` IS NOT NULL', $sql); + $this->assertEquals([], $builder->getBindings()); + } + + public function testCompileFilterAnd(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::and([ + Query::equal('a', [1]), + Query::greaterThan('b', 2), + ])); + $this->assertEquals('(`a` IN (?) AND `b` > ?)', $sql); + $this->assertEquals([1, 2], $builder->getBindings()); + } + + public function testCompileFilterOr(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::or([ + Query::equal('a', [1]), + Query::equal('b', [2]), + ])); + $this->assertEquals('(`a` IN (?) OR `b` IN (?))', $sql); + $this->assertEquals([1, 2], $builder->getBindings()); + } + + public function testCompileFilterExists(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::exists(['a', 'b'])); + $this->assertEquals('(`a` IS NOT NULL AND `b` IS NOT NULL)', $sql); + } + + public function testCompileFilterNotExists(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notExists(['a', 'b'])); + $this->assertEquals('(`a` IS NULL AND `b` IS NULL)', $sql); + } + + public function testCompileFilterRaw(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::raw('x > ? AND y < ?', [1, 2])); + $this->assertEquals('x > ? AND y < ?', $sql); + $this->assertEquals([1, 2], $builder->getBindings()); + } + + public function testCompileFilterSearch(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::search('body', 'hello')); + $this->assertEquals('MATCH(`body`) AGAINST(?)', $sql); + $this->assertEquals(['hello'], $builder->getBindings()); + } + + public function testCompileFilterNotSearch(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notSearch('body', 'spam')); + $this->assertEquals('NOT (MATCH(`body`) AGAINST(?))', $sql); + $this->assertEquals(['spam'], $builder->getBindings()); + } + + public function testCompileFilterRegex(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::regex('col', '^abc')); + $this->assertEquals('`col` REGEXP ?', $sql); + $this->assertEquals(['^abc'], $builder->getBindings()); + } + + public function testCompileOrderAsc(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderAsc('name')); + $this->assertEquals('`name` ASC', $sql); + } + + public function testCompileOrderDesc(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderDesc('name')); + $this->assertEquals('`name` DESC', $sql); + } + + public function testCompileOrderRandom(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderRandom()); + $this->assertEquals('RAND()', $sql); + } + + public function testCompileLimitStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileLimit(Query::limit(25)); + $this->assertEquals('LIMIT ?', $sql); + $this->assertEquals([25], $builder->getBindings()); + } + + public function testCompileOffsetStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOffset(Query::offset(50)); + $this->assertEquals('OFFSET ?', $sql); + $this->assertEquals([50], $builder->getBindings()); + } + + public function testCompileSelectStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileSelect(Query::select(['a', 'b', 'c'])); + $this->assertEquals('`a`, `b`, `c`', $sql); + } + + public function testCompileCursorAfterStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileCursor(Query::cursorAfter('abc')); + $this->assertEquals('`_cursor` > ?', $sql); + $this->assertEquals(['abc'], $builder->getBindings()); + } + + public function testCompileCursorBeforeStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileCursor(Query::cursorBefore('xyz')); + $this->assertEquals('`_cursor` < ?', $sql); + $this->assertEquals(['xyz'], $builder->getBindings()); + } + + public function testCompileAggregateCountStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::count('*', 'total')); + $this->assertEquals('COUNT(*) AS `total`', $sql); + } + + public function testCompileAggregateCountWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::count()); + $this->assertEquals('COUNT(*)', $sql); + } + + public function testCompileAggregateSumStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::sum('price', 'total')); + $this->assertEquals('SUM(`price`) AS `total`', $sql); + } + + public function testCompileAggregateAvgStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::avg('score', 'avg_score')); + $this->assertEquals('AVG(`score`) AS `avg_score`', $sql); + } + + public function testCompileAggregateMinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::min('price', 'lowest')); + $this->assertEquals('MIN(`price`) AS `lowest`', $sql); + } + + public function testCompileAggregateMaxStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::max('price', 'highest')); + $this->assertEquals('MAX(`price`) AS `highest`', $sql); + } + + public function testCompileGroupByStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileGroupBy(Query::groupBy(['status', 'country'])); + $this->assertEquals('`status`, `country`', $sql); + } + + public function testCompileJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::join('orders', 'users.id', 'orders.uid')); + $this->assertEquals('JOIN `orders` ON `users`.`id` = `orders`.`uid`', $sql); + } + + public function testCompileLeftJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::leftJoin('profiles', 'users.id', 'profiles.uid')); + $this->assertEquals('LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`uid`', $sql); + } + + public function testCompileRightJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::rightJoin('orders', 'users.id', 'orders.uid')); + $this->assertEquals('RIGHT JOIN `orders` ON `users`.`id` = `orders`.`uid`', $sql); + } + + public function testCompileCrossJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::crossJoin('colors')); + $this->assertEquals('CROSS JOIN `colors`', $sql); + } + // 6. Filter edge cases + + public function testEqualWithSingleValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?)', $result->query); + $this->assertEquals(['active'], $result->bindings); + } + + public function testEqualWithManyValues(): void + { + $values = range(1, 10); + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('id', $values)]) + ->build(); + + $placeholders = implode(', ', array_fill(0, 10, '?')); + $this->assertEquals("SELECT * FROM `t` WHERE `id` IN ({$placeholders})", $result->query); + $this->assertEquals($values, $result->bindings); + } + + public function testEqualWithEmptyArray(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('id', [])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE 1 = 0', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testNotEqualWithExactlyTwoValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('role', ['guest', 'banned'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result->query); + $this->assertEquals(['guest', 'banned'], $result->bindings); + } + + public function testBetweenWithSameMinAndMax(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 25, 25)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertEquals([25, 25], $result->bindings); + } + + public function testStartsWithEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', '')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertEquals(['%'], $result->bindings); + } + + public function testEndsWithEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('name', '')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertEquals(['%'], $result->bindings); + } + + public function testContainsWithSingleEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', [''])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); + $this->assertEquals(['%%'], $result->bindings); + } + + public function testContainsWithManyValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['a', 'b', 'c', 'd', 'e'])]) + ->build(); + + $this->assertStringContainsString('(`bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ?)', $result->query); + $this->assertEquals(['%a%', '%b%', '%c%', '%d%', '%e%'], $result->bindings); + } + + public function testContainsAllWithSingleValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('perms', ['read'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`perms` LIKE ?)', $result->query); + $this->assertEquals(['%read%'], $result->bindings); + } + + public function testNotContainsWithEmptyStringValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', [''])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result->query); + $this->assertEquals(['%%'], $result->bindings); + } + + public function testComparisonWithFloatValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('price', 9.99)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `price` > ?', $result->query); + $this->assertEquals([9.99], $result->bindings); + } + + public function testComparisonWithNegativeValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('balance', -100)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `balance` < ?', $result->query); + $this->assertEquals([-100], $result->bindings); + } + + public function testComparisonWithZero(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('score', 0)]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `score` >= ?', $result->query); + $this->assertEquals([0], $result->bindings); + } + + public function testComparisonWithVeryLargeInteger(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('id', 9999999999999)]) + ->build(); + + $this->assertEquals([9999999999999], $result->bindings); + } + + public function testComparisonWithStringValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('name', 'M')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` > ?', $result->query); + $this->assertEquals(['M'], $result->bindings); + } + + public function testBetweenWithStringValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('created_at', '2024-01-01', '2024-12-31')]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `created_at` BETWEEN ? AND ?', $result->query); + $this->assertEquals(['2024-01-01', '2024-12-31'], $result->bindings); + } + + public function testIsNullCombinedWithIsNotNullOnDifferentColumns(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::isNull('deleted_at'), + Query::isNotNull('verified_at'), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `deleted_at` IS NULL AND `verified_at` IS NOT NULL', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testMultipleIsNullFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::isNull('a'), + Query::isNull('b'), + Query::isNull('c'), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `a` IS NULL AND `b` IS NULL AND `c` IS NULL', + $result->query + ); + } + + public function testExistsWithSingleAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`name` IS NOT NULL)', $result->query); + } + + public function testExistsWithManyAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['a', 'b', 'c', 'd'])]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`a` IS NOT NULL AND `b` IS NOT NULL AND `c` IS NOT NULL AND `d` IS NOT NULL)', + $result->query + ); + } + + public function testNotExistsWithManyAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists(['a', 'b', 'c'])]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`a` IS NULL AND `b` IS NULL AND `c` IS NULL)', + $result->query + ); + } + + public function testAndWithSingleSubQuery(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::equal('a', [1]), + ]), + ]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?))', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testOrWithSingleSubQuery(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('a', [1]), + ]), + ]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?))', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testAndWithManySubQueries(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::equal('a', [1]), + Query::equal('b', [2]), + Query::equal('c', [3]), + Query::equal('d', [4]), + Query::equal('e', [5]), + ]), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`a` IN (?) AND `b` IN (?) AND `c` IN (?) AND `d` IN (?) AND `e` IN (?))', + $result->query + ); + $this->assertEquals([1, 2, 3, 4, 5], $result->bindings); + } + + public function testOrWithManySubQueries(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('a', [1]), + Query::equal('b', [2]), + Query::equal('c', [3]), + Query::equal('d', [4]), + Query::equal('e', [5]), + ]), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`a` IN (?) OR `b` IN (?) OR `c` IN (?) OR `d` IN (?) OR `e` IN (?))', + $result->query + ); + } + + public function testDeeplyNestedAndOrAnd(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::or([ + Query::and([ + Query::equal('a', [1]), + Query::equal('b', [2]), + ]), + Query::equal('c', [3]), + ]), + Query::equal('d', [4]), + ]), + ]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (((`a` IN (?) AND `b` IN (?)) OR `c` IN (?)) AND `d` IN (?))', + $result->query + ); + $this->assertEquals([1, 2, 3, 4], $result->bindings); + } + + public function testRawWithManyBindings(): void + { + $bindings = range(1, 10); + $placeholders = implode(' AND ', array_map(fn ($i) => "col{$i} = ?", range(1, 10))); + $result = (new Builder()) + ->from('t') + ->filter([Query::raw($placeholders, $bindings)]) + ->build(); + + $this->assertEquals("SELECT * FROM `t` WHERE {$placeholders}", $result->query); + $this->assertEquals($bindings, $result->bindings); + } + + public function testFilterWithDotsInAttributeName(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('table.column', ['value'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `table`.`column` IN (?)', $result->query); + } + + public function testFilterWithUnderscoresInAttributeName(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('my_column_name', ['value'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `my_column_name` IN (?)', $result->query); + } + + public function testFilterWithNumericAttributeName(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('123', ['value'])]) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `123` IN (?)', $result->query); + } + // 7. Aggregation edge cases + + public function testCountWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->count()->build(); + $this->assertEquals('SELECT COUNT(*) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testSumWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->sum('price')->build(); + $this->assertEquals('SELECT SUM(`price`) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testAvgWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->avg('score')->build(); + $this->assertEquals('SELECT AVG(`score`) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testMinWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->min('price')->build(); + $this->assertEquals('SELECT MIN(`price`) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testMaxWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->max('price')->build(); + $this->assertEquals('SELECT MAX(`price`) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testCountWithAlias2(): void + { + $result = (new Builder())->from('t')->count('*', 'cnt')->build(); + $this->assertStringContainsString('AS `cnt`', $result->query); + } + + public function testSumWithAlias(): void + { + $result = (new Builder())->from('t')->sum('price', 'total')->build(); + $this->assertStringContainsString('AS `total`', $result->query); + } + + public function testAvgWithAlias(): void + { + $result = (new Builder())->from('t')->avg('score', 'avg_s')->build(); + $this->assertStringContainsString('AS `avg_s`', $result->query); + } + + public function testMinWithAlias(): void + { + $result = (new Builder())->from('t')->min('price', 'lowest')->build(); + $this->assertStringContainsString('AS `lowest`', $result->query); + } + + public function testMaxWithAlias(): void + { + $result = (new Builder())->from('t')->max('price', 'highest')->build(); + $this->assertStringContainsString('AS `highest`', $result->query); + } + + public function testMultipleSameAggregationType(): void + { + $result = (new Builder()) + ->from('t') + ->count('id', 'count_id') + ->count('*', 'count_all') + ->build(); + + $this->assertEquals( + 'SELECT COUNT(`id`) AS `count_id`, COUNT(*) AS `count_all` FROM `t`', + $result->query + ); + } + + public function testAggregationStarAndNamedColumnMixed(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->sum('price', 'price_sum') + ->select(['category']) + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + $this->assertStringContainsString('SUM(`price`) AS `price_sum`', $result->query); + $this->assertStringContainsString('`category`', $result->query); + } + + public function testAggregationFilterSortLimitCombined(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->filter([Query::equal('status', ['paid'])]) + ->groupBy(['category']) + ->sortDesc('cnt') + ->limit(5) + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); + $this->assertStringContainsString('GROUP BY `category`', $result->query); + $this->assertStringContainsString('ORDER BY `cnt` DESC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertEquals(['paid', 5], $result->bindings); + } + + public function testAggregationJoinGroupByHavingSortLimitFullPipeline(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->sum('total', 'revenue') + ->select(['users.name']) + ->join('users', 'orders.user_id', 'users.id') + ->filter([Query::greaterThan('orders.total', 0)]) + ->groupBy(['users.name']) + ->having([Query::greaterThan('cnt', 2)]) + ->sortDesc('revenue') + ->limit(20) + ->offset(10) + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('SUM(`total`) AS `revenue`', $result->query); + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('WHERE `orders`.`total` > ?', $result->query); + $this->assertStringContainsString('GROUP BY `users`.`name`', $result->query); + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); + $this->assertStringContainsString('ORDER BY `revenue` DESC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals([0, 2, 20, 10], $result->bindings); + } + + public function testAggregationWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new AttributeMap([ + '$amount' => '_amount', + ])) + ->sum('$amount', 'total') + ->build(); + + $this->assertEquals('SELECT SUM(`_amount`) AS `total` FROM `t`', $result->query); + } + + public function testMinMaxWithStringColumns(): void + { + $result = (new Builder()) + ->from('t') + ->min('name', 'first_name') + ->max('name', 'last_name') + ->build(); + + $this->assertEquals( + 'SELECT MIN(`name`) AS `first_name`, MAX(`name`) AS `last_name` FROM `t`', + $result->query + ); + } + // 8. Join edge cases + + public function testSelfJoin(): void + { + $result = (new Builder()) + ->from('employees') + ->join('employees', 'employees.manager_id', 'employees.id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `employees` JOIN `employees` ON `employees`.`manager_id` = `employees`.`id`', + $result->query + ); + } + + public function testJoinWithVeryLongTableAndColumnNames(): void + { + $longTable = str_repeat('a', 100); + $longLeft = str_repeat('b', 100); + $longRight = str_repeat('c', 100); + $result = (new Builder()) + ->from('main') + ->join($longTable, $longLeft, $longRight) + ->build(); + + $this->assertStringContainsString("JOIN `{$longTable}`", $result->query); + $this->assertStringContainsString("ON `{$longLeft}` = `{$longRight}`", $result->query); + } + + public function testJoinFilterSortLimitOffsetCombined(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->filter([ + Query::equal('orders.status', ['paid']), + Query::greaterThan('orders.total', 100), + ]) + ->sortDesc('orders.total') + ->limit(25) + ->offset(50) + ->build(); + + $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('WHERE `orders`.`status` IN (?) AND `orders`.`total` > ?', $result->query); + $this->assertStringContainsString('ORDER BY `orders`.`total` DESC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals(['paid', 100, 25, 50], $result->bindings); + } + + public function testJoinAggregationGroupByHavingCombined(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->join('users', 'orders.user_id', 'users.id') + ->groupBy(['users.name']) + ->having([Query::greaterThan('cnt', 3)]) + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('GROUP BY `users`.`name`', $result->query); + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); + $this->assertEquals([3], $result->bindings); + } + + public function testJoinWithDistinct(): void + { + $result = (new Builder()) + ->from('users') + ->distinct() + ->select(['users.name']) + ->join('orders', 'users.id', 'orders.user_id') + ->build(); + + $this->assertStringContainsString('SELECT DISTINCT `users`.`name`', $result->query); + $this->assertStringContainsString('JOIN `orders`', $result->query); + } + + public function testJoinWithUnion(): void + { + $sub = (new Builder()) + ->from('archived_users') + ->join('archived_orders', 'archived_users.id', 'archived_orders.user_id'); + + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->union($sub) + ->build(); + + $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('UNION', $result->query); + $this->assertStringContainsString('JOIN `archived_orders`', $result->query); + } + + public function testFourJoins(): void + { + $result = (new Builder()) + ->from('orders') + ->join('users', 'orders.user_id', 'users.id') + ->leftJoin('products', 'orders.product_id', 'products.id') + ->rightJoin('categories', 'products.cat_id', 'categories.id') + ->crossJoin('promotions') + ->build(); + + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('LEFT JOIN `products`', $result->query); + $this->assertStringContainsString('RIGHT JOIN `categories`', $result->query); + $this->assertStringContainsString('CROSS JOIN `promotions`', $result->query); + } + + public function testJoinWithAttributeResolverOnJoinColumns(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new AttributeMap([ + '$id' => '_uid', + '$ref' => '_ref_id', + ])) + ->join('other', '$id', '$ref') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` JOIN `other` ON `_uid` = `_ref_id`', + $result->query + ); + } + + public function testCrossJoinCombinedWithFilter(): void + { + $result = (new Builder()) + ->from('sizes') + ->crossJoin('colors') + ->filter([Query::equal('sizes.active', [true])]) + ->build(); + + $this->assertStringContainsString('CROSS JOIN `colors`', $result->query); + $this->assertStringContainsString('WHERE `sizes`.`active` IN (?)', $result->query); + } + + public function testCrossJoinFollowedByRegularJoin(): void + { + $result = (new Builder()) + ->from('a') + ->crossJoin('b') + ->join('c', 'a.id', 'c.a_id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `a` CROSS JOIN `b` JOIN `c` ON `a`.`id` = `c`.`a_id`', + $result->query + ); + } + + public function testMultipleJoinsWithFiltersOnEach(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->leftJoin('profiles', 'users.id', 'profiles.user_id') + ->filter([ + Query::greaterThan('orders.total', 50), + Query::isNotNull('profiles.avatar'), + ]) + ->build(); + + $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('LEFT JOIN `profiles`', $result->query); + $this->assertStringContainsString('`orders`.`total` > ?', $result->query); + $this->assertStringContainsString('`profiles`.`avatar` IS NOT NULL', $result->query); + } + + public function testJoinWithCustomOperatorLessThan(): void + { + $result = (new Builder()) + ->from('a') + ->join('b', 'a.start', 'b.end', '<') + ->build(); + + $this->assertEquals( + 'SELECT * FROM `a` JOIN `b` ON `a`.`start` < `b`.`end`', + $result->query + ); + } + + public function testFiveJoins(): void + { + $result = (new Builder()) + ->from('t1') + ->join('t2', 't1.id', 't2.t1_id') + ->join('t3', 't2.id', 't3.t2_id') + ->join('t4', 't3.id', 't4.t3_id') + ->join('t5', 't4.id', 't5.t4_id') + ->join('t6', 't5.id', 't6.t5_id') + ->build(); + + $query = $result->query; + $this->assertEquals(5, substr_count($query, 'JOIN')); + } + // 9. Union edge cases + + public function testUnionWithThreeSubQueries(): void + { + $q1 = (new Builder())->from('a'); + $q2 = (new Builder())->from('b'); + $q3 = (new Builder())->from('c'); + + $result = (new Builder()) + ->from('main') + ->union($q1) + ->union($q2) + ->union($q3) + ->build(); + + $this->assertEquals( + '(SELECT * FROM `main`) UNION (SELECT * FROM `a`) UNION (SELECT * FROM `b`) UNION (SELECT * FROM `c`)', + $result->query + ); + } + + public function testUnionAllWithThreeSubQueries(): void + { + $q1 = (new Builder())->from('a'); + $q2 = (new Builder())->from('b'); + $q3 = (new Builder())->from('c'); + + $result = (new Builder()) + ->from('main') + ->unionAll($q1) + ->unionAll($q2) + ->unionAll($q3) + ->build(); + + $this->assertEquals( + '(SELECT * FROM `main`) UNION ALL (SELECT * FROM `a`) UNION ALL (SELECT * FROM `b`) UNION ALL (SELECT * FROM `c`)', + $result->query + ); + } + + public function testMixedUnionAndUnionAllWithThreeSubQueries(): void + { + $q1 = (new Builder())->from('a'); + $q2 = (new Builder())->from('b'); + $q3 = (new Builder())->from('c'); + + $result = (new Builder()) + ->from('main') + ->union($q1) + ->unionAll($q2) + ->union($q3) + ->build(); + + $this->assertEquals( + '(SELECT * FROM `main`) UNION (SELECT * FROM `a`) UNION ALL (SELECT * FROM `b`) UNION (SELECT * FROM `c`)', + $result->query + ); + } + + public function testUnionWhereSubQueryHasJoins(): void + { + $sub = (new Builder()) + ->from('archived_users') + ->join('archived_orders', 'archived_users.id', 'archived_orders.user_id'); + + $result = (new Builder()) + ->from('users') + ->union($sub) + ->build(); + + $this->assertStringContainsString( + 'UNION (SELECT * FROM `archived_users` JOIN `archived_orders`', + $result->query + ); + } + + public function testUnionWhereSubQueryHasAggregation(): void + { + $sub = (new Builder()) + ->from('orders_2023') + ->count('*', 'cnt') + ->groupBy(['status']); + + $result = (new Builder()) + ->from('orders_2024') + ->count('*', 'cnt') + ->groupBy(['status']) + ->union($sub) + ->build(); + + $this->assertStringContainsString('UNION (SELECT COUNT(*) AS `cnt` FROM `orders_2023` GROUP BY `status`)', $result->query); + } + + public function testUnionWhereSubQueryHasSortAndLimit(): void + { + $sub = (new Builder()) + ->from('archive') + ->sortDesc('created_at') + ->limit(10); + + $result = (new Builder()) + ->from('current') + ->union($sub) + ->build(); + + $this->assertStringContainsString('UNION (SELECT * FROM `archive` ORDER BY `created_at` DESC LIMIT ?)', $result->query); + } + + public function testUnionWithConditionProviders(): void + { + $sub = (new Builder()) + ->from('other') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org2']); + } + }); + + $result = (new Builder()) + ->from('main') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) + ->union($sub) + ->build(); + + $this->assertStringContainsString('WHERE org = ?', $result->query); + $this->assertStringContainsString('UNION (SELECT * FROM `other` WHERE org = ?)', $result->query); + $this->assertEquals(['org1', 'org2'], $result->bindings); + } + + public function testUnionBindingOrderWithComplexSubQueries(): void + { + $sub = (new Builder()) + ->from('archive') + ->filter([Query::equal('year', [2023])]) + ->limit(5); + + $result = (new Builder()) + ->from('current') + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->union($sub) + ->build(); + + $this->assertEquals(['active', 10, 2023, 5], $result->bindings); + } + + public function testUnionWithDistinct(): void + { + $sub = (new Builder()) + ->from('archive') + ->distinct() + ->select(['name']); + + $result = (new Builder()) + ->from('current') + ->distinct() + ->select(['name']) + ->union($sub) + ->build(); + + $this->assertStringContainsString('SELECT DISTINCT `name` FROM `current`', $result->query); + $this->assertStringContainsString('UNION (SELECT DISTINCT `name` FROM `archive`)', $result->query); + } + + public function testUnionAfterReset(): void + { + $builder = (new Builder())->from('old'); + $builder->build(); + $builder->reset(); + + $sub = (new Builder())->from('other'); + $result = $builder->from('fresh')->union($sub)->build(); + + $this->assertEquals( + '(SELECT * FROM `fresh`) UNION (SELECT * FROM `other`)', + $result->query + ); + } + + public function testUnionChainedWithComplexBindings(): void + { + $q1 = (new Builder()) + ->from('a') + ->filter([Query::equal('x', [1]), Query::greaterThan('y', 2)]); + $q2 = (new Builder()) + ->from('b') + ->filter([Query::between('z', 10, 20)]); + + $result = (new Builder()) + ->from('main') + ->filter([Query::equal('status', ['active'])]) + ->union($q1) + ->unionAll($q2) + ->build(); + + $this->assertEquals(['active', 1, 2, 10, 20], $result->bindings); + } + + public function testUnionWithFourSubQueries(): void + { + $q1 = (new Builder())->from('t1'); + $q2 = (new Builder())->from('t2'); + $q3 = (new Builder())->from('t3'); + $q4 = (new Builder())->from('t4'); + + $result = (new Builder()) + ->from('main') + ->union($q1) + ->union($q2) + ->union($q3) + ->union($q4) + ->build(); + + $this->assertEquals(4, substr_count($result->query, 'UNION')); + } + + public function testUnionAllWithFilteredSubQueries(): void + { + $q1 = (new Builder())->from('orders_2022')->filter([Query::equal('status', ['paid'])]); + $q2 = (new Builder())->from('orders_2023')->filter([Query::equal('status', ['paid'])]); + $q3 = (new Builder())->from('orders_2024')->filter([Query::equal('status', ['paid'])]); + + $result = (new Builder()) + ->from('orders_2025') + ->filter([Query::equal('status', ['paid'])]) + ->unionAll($q1) + ->unionAll($q2) + ->unionAll($q3) + ->build(); + + $this->assertEquals(['paid', 'paid', 'paid', 'paid'], $result->bindings); + $this->assertEquals(3, substr_count($result->query, 'UNION ALL')); + } + // 10. toRawSql edge cases + + public function testToRawSqlWithAllBindingTypesInOneQuery(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([ + Query::equal('name', ['Alice']), + Query::greaterThan('age', 18), + Query::raw('active = ?', [true]), + Query::raw('deleted = ?', [null]), + Query::raw('score > ?', [9.5]), + ]) + ->limit(10) + ->toRawSql(); + + $this->assertStringContainsString("'Alice'", $sql); + $this->assertStringContainsString('18', $sql); + $this->assertStringContainsString('= 1', $sql); + $this->assertStringContainsString('= NULL', $sql); + $this->assertStringContainsString('9.5', $sql); + $this->assertStringContainsString('10', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithEmptyStringBinding(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::equal('name', [''])]) + ->toRawSql(); + + $this->assertStringContainsString("''", $sql); + } + + public function testToRawSqlWithStringContainingSingleQuotes(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::equal('name', ["O'Brien"])]) + ->toRawSql(); + + $this->assertStringContainsString("O''Brien", $sql); + } + + public function testToRawSqlWithVeryLargeNumber(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('id', 99999999999)]) + ->toRawSql(); + + $this->assertStringContainsString('99999999999', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithNegativeNumber(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::lessThan('balance', -500)]) + ->toRawSql(); + + $this->assertStringContainsString('-500', $sql); + } + + public function testToRawSqlWithZero(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::equal('count', [0])]) + ->toRawSql(); + + $this->assertStringContainsString('IN (0)', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithFalseBoolean(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::raw('active = ?', [false])]) + ->toRawSql(); + + $this->assertStringContainsString('active = 0', $sql); + } + + public function testToRawSqlWithMultipleNullBindings(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::raw('a = ? AND b = ?', [null, null])]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `t` WHERE a = NULL AND b = NULL", $sql); + } + + public function testToRawSqlWithAggregationQuery(): void + { + $sql = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['status']) + ->having([Query::greaterThan('total', 5)]) + ->toRawSql(); + + $this->assertStringContainsString('COUNT(*) AS `total`', $sql); + $this->assertStringContainsString('HAVING `total` > 5', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithJoinQuery(): void + { + $sql = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.uid') + ->filter([Query::greaterThan('orders.total', 100)]) + ->toRawSql(); + + $this->assertStringContainsString('JOIN `orders`', $sql); + $this->assertStringContainsString('100', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithUnionQuery(): void + { + $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + + $sql = (new Builder()) + ->from('current') + ->filter([Query::equal('year', [2024])]) + ->union($sub) + ->toRawSql(); + + $this->assertStringContainsString('2024', $sql); + $this->assertStringContainsString('2023', $sql); + $this->assertStringContainsString('UNION', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithRegexAndSearch(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([ + Query::regex('slug', '^test'), + Query::search('content', 'hello'), + ]) + ->toRawSql(); + + $this->assertStringContainsString("REGEXP '^test'", $sql); + $this->assertStringContainsString("AGAINST('hello')", $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlCalledTwiceGivesSameResult(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active'])]) + ->limit(10); + + $sql1 = $builder->toRawSql(); + $sql2 = $builder->toRawSql(); + + $this->assertEquals($sql1, $sql2); + } + // 11. when() edge cases + + public function testWhenWithComplexCallbackAddingMultipleFeatures(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, function (Builder $b) { + $b->filter([Query::equal('status', ['active'])]) + ->sortAsc('name') + ->limit(10); + }) + ->build(); + + $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); + $this->assertStringContainsString('ORDER BY `name` ASC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertEquals(['active', 10], $result->bindings); + } + + public function testWhenChainedFiveTimes(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, fn (Builder $b) => $b->filter([Query::equal('a', [1])])) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('b', [2])])) + ->when(false, fn (Builder $b) => $b->filter([Query::equal('c', [3])])) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('d', [4])])) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('e', [5])])) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?) AND `d` IN (?) AND `e` IN (?)', + $result->query + ); + $this->assertEquals([1, 2, 4, 5], $result->bindings); + } + + public function testWhenInsideWhenThreeLevelsDeep(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, function (Builder $b) { + $b->when(true, function (Builder $b2) { + $b2->when(true, fn (Builder $b3) => $b3->filter([Query::equal('deep', [1])])); + }); + }) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE `deep` IN (?)', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testWhenThatAddsJoins(): void + { + $result = (new Builder()) + ->from('users') + ->when(true, fn (Builder $b) => $b->join('orders', 'users.id', 'orders.uid')) + ->build(); + + $this->assertStringContainsString('JOIN `orders`', $result->query); + } + + public function testWhenThatAddsAggregations(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, fn (Builder $b) => $b->count('*', 'total')->groupBy(['status'])) + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + $this->assertStringContainsString('GROUP BY `status`', $result->query); + } + + public function testWhenThatAddsUnions(): void + { + $sub = (new Builder())->from('archive'); + + $result = (new Builder()) + ->from('current') + ->when(true, fn (Builder $b) => $b->union($sub)) + ->build(); + + $this->assertStringContainsString('UNION', $result->query); + } + + public function testWhenFalseDoesNotAffectFilters(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->filter([Query::equal('status', ['banned'])])) + ->build(); + + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testWhenFalseDoesNotAffectJoins(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->join('other', 'a', 'b')) + ->build(); + + $this->assertStringNotContainsString('JOIN', $result->query); + } + + public function testWhenFalseDoesNotAffectAggregations(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->count('*', 'total')) + ->build(); + + $this->assertEquals('SELECT * FROM `t`', $result->query); + } + + public function testWhenFalseDoesNotAffectSort(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->sortAsc('name')) + ->build(); + + $this->assertStringNotContainsString('ORDER BY', $result->query); + } + // 12. Condition provider edge cases + + public function testThreeConditionProviders(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p1 = ?', ['v1']); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p2 = ?', ['v2']); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p3 = ?', ['v3']); + } + }) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE p1 = ? AND p2 = ? AND p3 = ?', + $result->query + ); + $this->assertEquals(['v1', 'v2', 'v3'], $result->bindings); + } + + public function testProviderReturningEmptyConditionString(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('', []); + } + }) + ->build(); + + // Empty string still appears as a WHERE clause element + $this->assertStringContainsString('WHERE', $result->query); + } + + public function testProviderWithManyBindings(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('a IN (?, ?, ?, ?, ?)', [1, 2, 3, 4, 5]); + } + }) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE a IN (?, ?, ?, ?, ?)', + $result->query + ); + $this->assertEquals([1, 2, 3, 4, 5], $result->bindings); + } + + public function testProviderCombinedWithCursorFilterHaving(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) + ->filter([Query::equal('status', ['active'])]) + ->cursorAfter('cur1') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('HAVING', $result->query); + // filter, provider, cursor, having + $this->assertEquals(['active', 'org1', 'cur1', 5], $result->bindings); + } + + public function testProviderCombinedWithJoins(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.uid') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->build(); + + $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('WHERE tenant = ?', $result->query); + $this->assertEquals(['t1'], $result->bindings); + } + + public function testProviderCombinedWithUnions(): void + { + $sub = (new Builder())->from('archive'); + + $result = (new Builder()) + ->from('current') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) + ->union($sub) + ->build(); + + $this->assertStringContainsString('WHERE org = ?', $result->query); + $this->assertStringContainsString('UNION', $result->query); + $this->assertEquals(['org1'], $result->bindings); + } + + public function testProviderCombinedWithAggregations(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) + ->groupBy(['status']) + ->build(); + + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + $this->assertStringContainsString('WHERE org = ?', $result->query); + } + + public function testProviderReferencesTableName(): void + { + $result = (new Builder()) + ->from('users') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition("EXISTS (SELECT 1 FROM {$table}_perms WHERE type = ?)", ['read']); + } + }) + ->build(); + + $this->assertStringContainsString('users_perms', $result->query); + $this->assertEquals(['read'], $result->bindings); + } + + public function testProviderBindingOrderWithComplexQuery(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p1 = ?', ['pv1']); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p2 = ?', ['pv2']); + } + }) + ->filter([ + Query::equal('a', ['va']), + Query::greaterThan('b', 10), + ]) + ->cursorAfter('cur') + ->limit(5) + ->offset(10) + ->build(); + + // filter, provider1, provider2, cursor, limit, offset + $this->assertEquals(['va', 10, 'pv1', 'pv2', 'cur', 5, 10], $result->bindings); + } + + public function testProviderPreservedAcrossReset(): void + { + $builder = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t2')->build(); + $this->assertStringContainsString('WHERE org = ?', $result->query); + $this->assertEquals(['org1'], $result->bindings); + } + + public function testFourConditionProviders(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('a = ?', [1]); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('b = ?', [2]); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('c = ?', [3]); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('d = ?', [4]); + } + }) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE a = ? AND b = ? AND c = ? AND d = ?', + $result->query + ); + $this->assertEquals([1, 2, 3, 4], $result->bindings); + } + + public function testProviderWithNoBindings(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('1 = 1', []); + } + }) + ->build(); + + $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result->query); + $this->assertEquals([], $result->bindings); + } + // 13. Reset edge cases + + public function testResetPreservesAttributeResolver(): void + { + $builder = (new Builder()) + ->from('t') + ->addHook(new class () implements Attribute { + public function resolve(string $attribute): string + { + return '_' . $attribute; + } + }) + ->filter([Query::equal('x', [1])]); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t2')->filter([Query::equal('y', [2])])->build(); + $this->assertStringContainsString('`_y`', $result->query); + } + + public function testResetPreservesConditionProviders(): void + { + $builder = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t2')->build(); + $this->assertStringContainsString('org = ?', $result->query); + $this->assertEquals(['org1'], $result->bindings); + } + + public function testResetClearsPendingQueries(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]) + ->sortAsc('name') + ->limit(10); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t2')->build(); + $this->assertEquals('SELECT * FROM `t2`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testResetClearsBindings(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]); + + $builder->build(); + $this->assertNotEmpty($builder->getBindings()); + + $builder->reset(); + $result = $builder->from('t2')->build(); + $this->assertEquals([], $result->bindings); + } + + public function testResetClearsTable(): void + { + $builder = (new Builder())->from('old_table'); + $builder->build(); + $builder->reset(); + + $result = $builder->from('new_table')->build(); + $this->assertStringContainsString('`new_table`', $result->query); + $this->assertStringNotContainsString('`old_table`', $result->query); + } + + public function testResetClearsUnionsAfterBuild(): void + { + $sub = (new Builder())->from('other'); + $builder = (new Builder())->from('main')->union($sub); + $builder->build(); + $builder->reset(); + + $result = $builder->from('fresh')->build(); + $this->assertStringNotContainsString('UNION', $result->query); + } + + public function testBuildAfterResetProducesMinimalQuery(): void + { + $builder = (new Builder()) + ->from('complex') + ->select(['a', 'b']) + ->filter([Query::equal('x', [1])]) + ->sortAsc('a') + ->limit(10) + ->offset(5); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertEquals('SELECT * FROM `t`', $result->query); + } + + public function testMultipleResetCalls(): void + { + $builder = (new Builder())->from('t')->filter([Query::equal('a', [1])]); + $builder->build(); + $builder->reset(); + $builder->reset(); + $builder->reset(); + + $result = $builder->from('t2')->build(); + $this->assertEquals('SELECT * FROM `t2`', $result->query); + } + + public function testResetBetweenDifferentQueryTypes(): void + { + $builder = new Builder(); + + // First: aggregation query + $builder->from('orders')->count('*', 'total')->groupBy(['status']); + $result1 = $builder->build(); + $this->assertStringContainsString('COUNT(*)', $result1->query); + + $builder->reset(); + + // Second: simple select query + $builder->from('users')->select(['name'])->filter([Query::equal('active', [true])]); + $result2 = $builder->build(); + $this->assertStringNotContainsString('COUNT', $result2->query); + $this->assertStringContainsString('`name`', $result2->query); + } + + public function testResetAfterUnion(): void + { + $sub = (new Builder())->from('other'); + $builder = (new Builder())->from('main')->union($sub); + $builder->build(); + $builder->reset(); + + $result = $builder->from('new')->build(); + $this->assertEquals('SELECT * FROM `new`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testResetAfterComplexQueryWithAllFeatures(): void + { + $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + + $builder = (new Builder()) + ->from('orders') + ->distinct() + ->count('*', 'cnt') + ->select(['status']) + ->join('users', 'orders.uid', 'users.id') + ->filter([Query::equal('status', ['paid'])]) + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 1)]) + ->sortDesc('cnt') + ->limit(10) + ->offset(5) + ->union($sub); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('simple')->build(); + $this->assertEquals('SELECT * FROM `simple`', $result->query); + $this->assertEquals([], $result->bindings); + } + // 14. Multiple build() calls + + public function testBuildTwiceModifyInBetween(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]); + + $result1 = $builder->build(); + + $builder->filter([Query::equal('b', [2])]); + $result2 = $builder->build(); + + $this->assertStringNotContainsString('`b`', $result1->query); + $this->assertStringContainsString('`b`', $result2->query); + } + + public function testBuildDoesNotMutatePendingQueries(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]) + ->limit(10); + + $result1 = $builder->build(); + $result2 = $builder->build(); + + $this->assertEquals($result1->query, $result2->query); + $this->assertEquals($result1->bindings, $result2->bindings); + } + + public function testBuildResetsBindingsEachTime(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]); + + $builder->build(); + $bindings1 = $builder->getBindings(); + + $builder->build(); + $bindings2 = $builder->getBindings(); + + $this->assertEquals($bindings1, $bindings2); + $this->assertCount(1, $bindings2); + } + + public function testBuildWithConditionProducesConsistentBindings(): void + { + $builder = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) + ->filter([Query::equal('status', ['active'])]); + + $result1 = $builder->build(); + $result2 = $builder->build(); + $result3 = $builder->build(); + + $this->assertEquals($result1->bindings, $result2->bindings); + $this->assertEquals($result2->bindings, $result3->bindings); + } + + public function testBuildAfterAddingMoreQueries(): void + { + $builder = (new Builder())->from('t'); + + $result1 = $builder->build(); + $this->assertEquals('SELECT * FROM `t`', $result1->query); + + $builder->filter([Query::equal('a', [1])]); + $result2 = $builder->build(); + $this->assertStringContainsString('WHERE', $result2->query); + + $builder->sortAsc('a'); + $result3 = $builder->build(); + $this->assertStringContainsString('ORDER BY', $result3->query); + } + + public function testBuildWithUnionProducesConsistentResults(): void + { + $sub = (new Builder())->from('other')->filter([Query::equal('x', [1])]); + $builder = (new Builder())->from('main')->union($sub); + + $result1 = $builder->build(); + $result2 = $builder->build(); + + $this->assertEquals($result1->query, $result2->query); + $this->assertEquals($result1->bindings, $result2->bindings); + } + + public function testBuildThreeTimesWithIncreasingComplexity(): void + { + $builder = (new Builder())->from('t'); + + $r1 = $builder->build(); + $this->assertEquals('SELECT * FROM `t`', $r1->query); + + $builder->filter([Query::equal('a', [1])]); + $r2 = $builder->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $r2->query); + + $builder->limit(10)->offset(5); + $r3 = $builder->build(); + $this->assertStringContainsString('LIMIT ?', $r3->query); + $this->assertStringContainsString('OFFSET ?', $r3->query); + } + + public function testBuildBindingsNotAccumulated(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]) + ->limit(10); + + $builder->build(); + $builder->build(); + $builder->build(); + + $this->assertCount(2, $builder->getBindings()); + } + + public function testMultipleBuildWithHavingBindings(): void + { + $builder = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 5)]); + + $r1 = $builder->build(); + $r2 = $builder->build(); + + $this->assertEquals([5], $r1->bindings); + $this->assertEquals([5], $r2->bindings); + } + // 15. Binding ordering comprehensive + + public function testBindingOrderMultipleFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('a', ['v1']), + Query::greaterThan('b', 10), + Query::between('c', 1, 100), + ]) + ->build(); + + $this->assertEquals(['v1', 10, 1, 100], $result->bindings); + } + + public function testBindingOrderThreeProviders(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p1 = ?', ['pv1']); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p2 = ?', ['pv2']); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p3 = ?', ['pv3']); + } + }) + ->build(); + + $this->assertEquals(['pv1', 'pv2', 'pv3'], $result->bindings); + } + + public function testBindingOrderMultipleUnions(): void + { + $q1 = (new Builder())->from('a')->filter([Query::equal('x', [1])]); + $q2 = (new Builder())->from('b')->filter([Query::equal('y', [2])]); + + $result = (new Builder()) + ->from('main') + ->filter([Query::equal('z', [3])]) + ->limit(5) + ->union($q1) + ->unionAll($q2) + ->build(); + + // main filter, main limit, union1 bindings, union2 bindings + $this->assertEquals([3, 5, 1, 2], $result->bindings); + } + + public function testBindingOrderLogicalAndWithMultipleSubFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::equal('a', [1]), + Query::greaterThan('b', 2), + Query::lessThan('c', 3), + ]), + ]) + ->build(); + + $this->assertEquals([1, 2, 3], $result->bindings); + } + + public function testBindingOrderLogicalOrWithMultipleSubFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('a', [1]), + Query::equal('b', [2]), + Query::equal('c', [3]), + ]), + ]) + ->build(); + + $this->assertEquals([1, 2, 3], $result->bindings); + } + + public function testBindingOrderNestedAndOr(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::equal('a', [1]), + Query::or([ + Query::equal('b', [2]), + Query::equal('c', [3]), + ]), + ]), + ]) + ->build(); + + $this->assertEquals([1, 2, 3], $result->bindings); + } + + public function testBindingOrderRawMixedWithRegularFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('a', ['v1']), + Query::raw('custom > ?', [10]), + Query::greaterThan('b', 20), + ]) + ->build(); + + $this->assertEquals(['v1', 10, 20], $result->bindings); + } + + public function testBindingOrderAggregationHavingComplexConditions(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->sum('price', 'total') + ->filter([Query::equal('status', ['active'])]) + ->groupBy(['category']) + ->having([ + Query::greaterThan('cnt', 5), + Query::lessThan('total', 10000), + ]) + ->limit(10) + ->build(); + + // filter, having1, having2, limit + $this->assertEquals(['active', 5, 10000, 10], $result->bindings); + } + + public function testBindingOrderFullPipelineWithEverything(): void + { + $sub = (new Builder())->from('archive')->filter([Query::equal('archived', [true])]); + + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->filter([ + Query::equal('status', ['paid']), + Query::greaterThan('total', 0), + ]) + ->cursorAfter('cursor_val') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 1)]) + ->limit(25) + ->offset(50) + ->union($sub) + ->build(); + + // filter(paid, 0), provider(t1), cursor(cursor_val), having(1), limit(25), offset(50), union(true) + $this->assertEquals(['paid', 0, 't1', 'cursor_val', 1, 25, 50, true], $result->bindings); + } + + public function testBindingOrderContainsMultipleValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::contains('bio', ['php', 'js', 'go']), + Query::equal('status', ['active']), + ]) + ->build(); + + // contains produces three LIKE bindings, then equal + $this->assertEquals(['%php%', '%js%', '%go%', 'active'], $result->bindings); + } + + public function testBindingOrderBetweenAndComparisons(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::between('age', 18, 65), + Query::greaterThan('score', 50), + Query::lessThan('rank', 100), + ]) + ->build(); + + $this->assertEquals([18, 65, 50, 100], $result->bindings); + } + + public function testBindingOrderStartsWithEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::startsWith('name', 'A'), + Query::endsWith('email', '.com'), + ]) + ->build(); + + $this->assertEquals(['A%', '%.com'], $result->bindings); + } + + public function testBindingOrderSearchAndRegex(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::search('content', 'hello'), + Query::regex('slug', '^test'), + ]) + ->build(); + + $this->assertEquals(['hello', '^test'], $result->bindings); + } + + public function testBindingOrderWithCursorBeforeFilterAndLimit(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) + ->filter([Query::equal('a', ['x'])]) + ->cursorBefore('my_cursor') + ->limit(10) + ->offset(0) + ->build(); + + // filter, provider, cursor, limit, offset + $this->assertEquals(['x', 'org1', 'my_cursor', 10, 0], $result->bindings); + } + // 16. Empty/minimal queries + + public function testBuildWithNoFromNoFilters(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder())->from('')->build(); + } + + public function testBuildWithOnlyLimit(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->from('') + ->limit(10) + ->build(); + } + + public function testBuildWithOnlyOffset(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->from('') + ->offset(50) + ->build(); + } + + public function testBuildWithOnlySort(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->from('') + ->sortAsc('name') + ->build(); + } + + public function testBuildWithOnlySelect(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->from('') + ->select(['a', 'b']) + ->build(); + } + + public function testBuildWithOnlyAggregationNoFrom(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->from('') + ->count('*', 'total') + ->build(); + } + + public function testBuildWithEmptyFilterArray(): void + { + $result = (new Builder()) + ->from('t') + ->filter([]) + ->build(); + + $this->assertEquals('SELECT * FROM `t`', $result->query); + } + + public function testBuildWithEmptySelectArray(): void + { + $result = (new Builder()) + ->from('t') + ->select([]) + ->build(); + + $this->assertEquals('SELECT FROM `t`', $result->query); + } + + public function testBuildWithOnlyHavingNoGroupBy(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->having([Query::greaterThan('cnt', 0)]) + ->build(); + + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); + $this->assertStringNotContainsString('GROUP BY', $result->query); + } + + public function testBuildWithOnlyDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->build(); + + $this->assertEquals('SELECT DISTINCT * FROM `t`', $result->query); + } + // Spatial/Vector/ElemMatch Exception Tests + + + public function testSpatialCrosses(): void + { + $result = (new Builder())->from('t')->filter([Query::crosses('attr', [1.0, 2.0])])->build(); + $this->assertStringContainsString('ST_Crosses', $result->query); + } + + public function testSpatialDistanceLessThan(): void + { + $result = (new Builder())->from('t')->filter([Query::distanceLessThan('attr', [0, 0], 1000, true)])->build(); + $this->assertStringContainsString('ST_Distance', $result->query); + $this->assertStringContainsString('metre', $result->query); + } + + public function testSpatialIntersects(): void + { + $result = (new Builder())->from('t')->filter([Query::intersects('attr', [1.0, 2.0])])->build(); + $this->assertStringContainsString('ST_Intersects', $result->query); + } + + public function testSpatialOverlaps(): void + { + $result = (new Builder())->from('t')->filter([Query::overlaps('attr', [[0, 0], [1, 1]])])->build(); + $this->assertStringContainsString('ST_Overlaps', $result->query); + } + + public function testSpatialTouches(): void + { + $result = (new Builder())->from('t')->filter([Query::touches('attr', [1.0, 2.0])])->build(); + $this->assertStringContainsString('ST_Touches', $result->query); + } + + public function testSpatialNotIntersects(): void + { + $result = (new Builder())->from('t')->filter([Query::notIntersects('attr', [1.0, 2.0])])->build(); + $this->assertStringContainsString('NOT ST_Intersects', $result->query); + } + + public function testUnsupportedFilterTypeVectorDot(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::vectorDot('attr', [1.0, 2.0])])->build(); + } + + public function testUnsupportedFilterTypeVectorCosine(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::vectorCosine('attr', [1.0, 2.0])])->build(); + } + + public function testUnsupportedFilterTypeVectorEuclidean(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::vectorEuclidean('attr', [1.0, 2.0])])->build(); + } + + public function testUnsupportedFilterTypeElemMatch(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::elemMatch('attr', [Query::equal('x', [1])])])->build(); + } + // toRawSql Edge Cases + + public function testToRawSqlWithBoolFalse(): void + { + $sql = (new Builder())->from('t')->filter([Query::equal('active', [false])])->toRawSql(); + $this->assertEquals("SELECT * FROM `t` WHERE `active` IN (0)", $sql); + } + + public function testToRawSqlMixedBindingTypes(): void + { + $sql = (new Builder())->from('t') + ->filter([ + Query::equal('name', ['str']), + Query::greaterThan('age', 42), + Query::lessThan('score', 9.99), + Query::equal('active', [true]), + ])->toRawSql(); + $this->assertStringContainsString("'str'", $sql); + $this->assertStringContainsString('42', $sql); + $this->assertStringContainsString('9.99', $sql); + $this->assertStringContainsString('1', $sql); + } + + public function testToRawSqlWithNull(): void + { + $sql = (new Builder())->from('t') + ->filter([Query::raw('col = ?', [null])]) + ->toRawSql(); + $this->assertStringContainsString('NULL', $sql); + } + + public function testToRawSqlWithUnion(): void + { + $other = (new Builder())->from('b')->filter([Query::equal('x', [1])]); + $sql = (new Builder())->from('a')->filter([Query::equal('y', [2])])->union($other)->toRawSql(); + $this->assertStringContainsString("FROM `a`", $sql); + $this->assertStringContainsString('UNION', $sql); + $this->assertStringContainsString("FROM `b`", $sql); + $this->assertStringContainsString('2', $sql); + $this->assertStringContainsString('1', $sql); + } + + public function testToRawSqlWithAggregationJoinGroupByHaving(): void + { + $sql = (new Builder())->from('orders') + ->count('*', 'total') + ->join('users', 'orders.uid', 'users.id') + ->select(['users.country']) + ->groupBy(['users.country']) + ->having([Query::greaterThan('total', 5)]) + ->toRawSql(); + $this->assertStringContainsString('COUNT(*)', $sql); + $this->assertStringContainsString('JOIN', $sql); + $this->assertStringContainsString('GROUP BY', $sql); + $this->assertStringContainsString('HAVING', $sql); + $this->assertStringContainsString('5', $sql); + } + // Kitchen Sink Exact SQL + + public function testKitchenSinkExactSql(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('status', ['closed'])]); + $result = (new Builder()) + ->from('orders') + ->distinct() + ->count('*', 'total') + ->select(['status']) + ->join('users', 'orders.uid', 'users.id') + ->filter([Query::greaterThan('amount', 100)]) + ->groupBy(['status']) + ->having([Query::greaterThan('total', 5)]) + ->sortAsc('status') + ->limit(10) + ->offset(20) + ->union($other) + ->build(); + + $this->assertEquals( + '(SELECT DISTINCT COUNT(*) AS `total`, `status` FROM `orders` JOIN `users` ON `orders`.`uid` = `users`.`id` WHERE `amount` > ? GROUP BY `status` HAVING `total` > ? ORDER BY `status` ASC LIMIT ? OFFSET ?) UNION (SELECT * FROM `archive` WHERE `status` IN (?))', + $result->query ); + $this->assertEquals([100, 5, 10, 20, 'closed'], $result->bindings); + } + // Feature Combination Tests + + public function testDistinctWithUnion(): void + { + $other = (new Builder())->from('b'); + $result = (new Builder())->from('a')->distinct()->union($other)->build(); + $this->assertEquals('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testRawInsideLogicalAnd(): void + { + $result = (new Builder())->from('t') + ->filter([Query::and([ + Query::greaterThan('x', 1), + Query::raw('custom_func(y) > ?', [5]), + ])]) + ->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`x` > ? AND custom_func(y) > ?)', $result->query); + $this->assertEquals([1, 5], $result->bindings); + } + + public function testRawInsideLogicalOr(): void + { + $result = (new Builder())->from('t') + ->filter([Query::or([ + Query::equal('a', [1]), + Query::raw('b IS NOT NULL', []), + ])]) + ->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testAggregationWithCursor(): void + { + $result = (new Builder())->from('t') + ->count('*', 'total') + ->cursorAfter('abc') + ->build(); + $this->assertStringContainsString('COUNT(*)', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertContains('abc', $result->bindings); + } + + public function testGroupBySortCursorUnion(): void + { + $other = (new Builder())->from('b'); + $result = (new Builder())->from('a') + ->count('*', 'total') + ->groupBy(['status']) + ->sortDesc('total') + ->cursorAfter('xyz') + ->union($other) + ->build(); + $this->assertStringContainsString('GROUP BY', $result->query); + $this->assertStringContainsString('ORDER BY', $result->query); + $this->assertStringContainsString('UNION', $result->query); + } + + public function testConditionProviderWithNoFilters(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) + ->build(); + $this->assertEquals('SELECT * FROM `t` WHERE _tenant = ?', $result->query); + $this->assertEquals(['t1'], $result->bindings); + } + + public function testConditionProviderWithCursorNoFilters(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) + ->cursorAfter('abc') + ->build(); + $this->assertStringContainsString('_tenant = ?', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + // Provider bindings come before cursor bindings + $this->assertEquals(['t1', 'abc'], $result->bindings); + } + + public function testConditionProviderWithDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) + ->build(); + $this->assertEquals('SELECT DISTINCT * FROM `t` WHERE _tenant = ?', $result->query); + $this->assertEquals(['t1'], $result->bindings); + } + + public function testConditionProviderPersistsAfterReset(): void + { + $builder = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }); + $builder->build(); + $builder->reset()->from('other'); + $result = $builder->build(); + $this->assertStringContainsString('FROM `other`', $result->query); + $this->assertStringContainsString('_tenant = ?', $result->query); + $this->assertEquals(['t1'], $result->bindings); + } + + public function testConditionProviderWithHaving(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) + ->having([Query::greaterThan('total', 5)]) + ->build(); + // Provider should be in WHERE, not HAVING + $this->assertStringContainsString('WHERE _tenant = ?', $result->query); + $this->assertStringContainsString('HAVING `total` > ?', $result->query); + // Provider bindings before having bindings + $this->assertEquals(['t1', 5], $result->bindings); + } + + public function testUnionWithConditionProvider(): void + { + $sub = (new Builder()) + ->from('b') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_deleted = ?', [0]); + } + }); + $result = (new Builder()) + ->from('a') + ->union($sub) + ->build(); + // Sub-query should include the condition provider + $this->assertStringContainsString('UNION (SELECT * FROM `b` WHERE _deleted = ?)', $result->query); + $this->assertEquals([0], $result->bindings); + } + // Boundary Value Tests + + public function testNegativeLimit(): void + { + $result = (new Builder())->from('t')->limit(-1)->build(); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertEquals([-1], $result->bindings); + } + + public function testNegativeOffset(): void + { + // OFFSET without LIMIT is suppressed + $result = (new Builder())->from('t')->offset(-5)->build(); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testEqualWithNullOnly(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('col', [null])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testEqualWithNullAndNonNull(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('col', ['a', null])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` IN (?) OR `col` IS NULL)', $result->query); + $this->assertSame(['a'], $result->bindings); + } + + public function testNotEqualWithNullOnly(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('col', [null])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NOT NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testNotEqualWithNullAndNonNull(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', null])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` != ? AND `col` IS NOT NULL)', $result->query); + $this->assertSame(['a'], $result->bindings); + } + + public function testNotEqualWithMultipleNonNullAndNull(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', 'b', null])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` NOT IN (?, ?) AND `col` IS NOT NULL)', $result->query); + $this->assertSame(['a', 'b'], $result->bindings); + } + + public function testBetweenReversedMinMax(): void + { + $result = (new Builder())->from('t')->filter([Query::between('age', 65, 18)])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertEquals([65, 18], $result->bindings); + } + + public function testContainsWithSqlWildcard(): void + { + $result = (new Builder())->from('t')->filter([Query::contains('bio', ['100%'])])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); + $this->assertEquals(['%100\%%'], $result->bindings); + } + + public function testStartsWithWithWildcard(): void + { + $result = (new Builder())->from('t')->filter([Query::startsWith('name', '%admin')])->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertEquals(['\%admin%'], $result->bindings); + } + + public function testCursorWithNullValue(): void + { + // Null cursor value is ignored by groupByType since cursor stays null + $result = (new Builder())->from('t')->cursorAfter(null)->build(); + $this->assertStringNotContainsString('_cursor', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testCursorWithIntegerValue(): void + { + $result = (new Builder())->from('t')->cursorAfter(42)->build(); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertSame([42], $result->bindings); + } + + public function testCursorWithFloatValue(): void + { + $result = (new Builder())->from('t')->cursorAfter(3.14)->build(); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertSame([3.14], $result->bindings); + } + + public function testMultipleLimitsFirstWins(): void + { + $result = (new Builder())->from('t')->limit(10)->limit(20)->build(); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertEquals([10], $result->bindings); + } + + public function testMultipleOffsetsFirstWins(): void + { + // OFFSET without LIMIT is suppressed + $result = (new Builder())->from('t')->offset(5)->offset(50)->build(); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testCursorAfterAndBeforeFirstWins(): void + { + $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->build(); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertStringNotContainsString('`_cursor` < ?', $result->query); + } + + public function testEmptyTableWithJoin(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder())->from('')->join('other', 'a', 'b')->build(); + } + + public function testBuildWithoutFromCall(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder())->filter([Query::equal('x', [1])])->build(); + } + // Standalone Compiler Method Tests + + public function testCompileSelectEmpty(): void + { + $builder = new Builder(); + $result = $builder->compileSelect(Query::select([])); + $this->assertEquals('', $result); + } + + public function testCompileGroupByEmpty(): void + { + $builder = new Builder(); + $result = $builder->compileGroupBy(Query::groupBy([])); + $this->assertEquals('', $result); + } + + public function testCompileGroupBySingleColumn(): void + { + $builder = new Builder(); + $result = $builder->compileGroupBy(Query::groupBy(['status'])); + $this->assertEquals('`status`', $result); + } + + public function testCompileSumWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::sum('price')); + $this->assertEquals('SUM(`price`)', $sql); + } + + public function testCompileAvgWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::avg('score')); + $this->assertEquals('AVG(`score`)', $sql); + } + + public function testCompileMinWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::min('price')); + $this->assertEquals('MIN(`price`)', $sql); + } + + public function testCompileMaxWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::max('price')); + $this->assertEquals('MAX(`price`)', $sql); + } + + public function testCompileLimitZero(): void + { + $builder = new Builder(); + $sql = $builder->compileLimit(Query::limit(0)); + $this->assertEquals('LIMIT ?', $sql); + $this->assertSame([0], $builder->getBindings()); + } + + public function testCompileOffsetZero(): void + { + $builder = new Builder(); + $sql = $builder->compileOffset(Query::offset(0)); + $this->assertEquals('OFFSET ?', $sql); + $this->assertSame([0], $builder->getBindings()); + } + + public function testCompileOrderException(): void + { + $builder = new Builder(); + $this->expectException(UnsupportedException::class); + $builder->compileOrder(Query::limit(10)); + } + + public function testCompileJoinException(): void + { + $builder = new Builder(); + $this->expectException(UnsupportedException::class); + $builder->compileJoin(Query::equal('x', [1])); + } + // Query::compile() Integration Tests + + public function testQueryCompileOrderAsc(): void + { + $builder = new Builder(); + $this->assertEquals('`name` ASC', Query::orderAsc('name')->compile($builder)); + } + + public function testQueryCompileOrderDesc(): void + { + $builder = new Builder(); + $this->assertEquals('`name` DESC', Query::orderDesc('name')->compile($builder)); + } + + public function testQueryCompileOrderRandom(): void + { + $builder = new Builder(); + $this->assertEquals('RAND()', Query::orderRandom()->compile($builder)); + } + + public function testQueryCompileLimit(): void + { + $builder = new Builder(); + $this->assertEquals('LIMIT ?', Query::limit(10)->compile($builder)); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testQueryCompileOffset(): void + { + $builder = new Builder(); + $this->assertEquals('OFFSET ?', Query::offset(5)->compile($builder)); + $this->assertEquals([5], $builder->getBindings()); + } + + public function testQueryCompileCursorAfter(): void + { + $builder = new Builder(); + $this->assertEquals('`_cursor` > ?', Query::cursorAfter('x')->compile($builder)); + $this->assertEquals(['x'], $builder->getBindings()); + } + + public function testQueryCompileCursorBefore(): void + { + $builder = new Builder(); + $this->assertEquals('`_cursor` < ?', Query::cursorBefore('x')->compile($builder)); + $this->assertEquals(['x'], $builder->getBindings()); + } + + public function testQueryCompileSelect(): void + { + $builder = new Builder(); + $this->assertEquals('`a`, `b`', Query::select(['a', 'b'])->compile($builder)); + } + + public function testQueryCompileGroupBy(): void + { + $builder = new Builder(); + $this->assertEquals('`status`', Query::groupBy(['status'])->compile($builder)); + } + // Reset Behavior + + public function testResetFollowedByUnion(): void + { + $builder = (new Builder()) + ->from('a') + ->union((new Builder())->from('old')); + $builder->reset()->from('b'); + $result = $builder->build(); + $this->assertEquals('SELECT * FROM `b`', $result->query); + $this->assertStringNotContainsString('UNION', $result->query); + } + + public function testResetClearsBindingsAfterBuild(): void + { + $builder = (new Builder())->from('t')->filter([Query::equal('x', [1])]); + $builder->build(); + $this->assertNotEmpty($builder->getBindings()); + $builder->reset()->from('t'); + $result = $builder->build(); + $this->assertEquals([], $result->bindings); + } + // Missing Binding Assertions + + public function testSortAscBindingsEmpty(): void + { + $result = (new Builder())->from('t')->sortAsc('name')->build(); + $this->assertEquals([], $result->bindings); + } + + public function testSortDescBindingsEmpty(): void + { + $result = (new Builder())->from('t')->sortDesc('name')->build(); + $this->assertEquals([], $result->bindings); + } + + public function testSortRandomBindingsEmpty(): void + { + $result = (new Builder())->from('t')->sortRandom()->build(); + $this->assertEquals([], $result->bindings); + } + + public function testDistinctBindingsEmpty(): void + { + $result = (new Builder())->from('t')->distinct()->build(); + $this->assertEquals([], $result->bindings); + } + + public function testJoinBindingsEmpty(): void + { + $result = (new Builder())->from('t')->join('other', 'a', 'b')->build(); + $this->assertEquals([], $result->bindings); + } + + public function testCrossJoinBindingsEmpty(): void + { + $result = (new Builder())->from('t')->crossJoin('other')->build(); + $this->assertEquals([], $result->bindings); + } + + public function testGroupByBindingsEmpty(): void + { + $result = (new Builder())->from('t')->groupBy(['status'])->build(); + $this->assertEquals([], $result->bindings); } - public function testWrapCharAffectsRightJoin(): void + public function testCountWithAliasBindingsEmpty(): void + { + $result = (new Builder())->from('t')->count('*', 'total')->build(); + $this->assertEquals([], $result->bindings); + } + // DML: INSERT + + public function testInsertSingleRow(): void { $result = (new Builder()) - ->setWrapChar('"') - ->from('users') - ->rightJoin('orders', 'users.id', 'orders.uid') - ->build(); + ->into('users') + ->set(['name' => 'Alice', 'email' => 'a@b.com']) + ->insert(); $this->assertEquals( - 'SELECT * FROM "users" RIGHT JOIN "orders" ON "users"."id" = "orders"."uid"', + 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?)', $result->query ); + $this->assertEquals(['Alice', 'a@b.com'], $result->bindings); } - public function testWrapCharAffectsCrossJoin(): void + public function testInsertBatch(): void { $result = (new Builder()) - ->setWrapChar('"') - ->from('a') - ->crossJoin('b') - ->build(); + ->into('users') + ->set(['name' => 'Alice', 'email' => 'a@b.com']) + ->set(['name' => 'Bob', 'email' => 'b@b.com']) + ->insert(); + + $this->assertEquals( + 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertEquals(['Alice', 'a@b.com', 'Bob', 'b@b.com'], $result->bindings); + } - $this->assertEquals('SELECT * FROM "a" CROSS JOIN "b"', $result->query); + public function testInsertNoRowsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('users') + ->insert(); + } + + public function testIntoAliasesFrom(): void + { + $builder = new Builder(); + $builder->into('users')->set(['name' => 'Alice'])->insert(); + $this->assertStringContainsString('users', $builder->insert()->query); } + // DML: UPSERT - public function testWrapCharAffectsAggregation(): void + public function testUpsertSingleRow(): void { $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->sum('price', 'total') - ->build(); + ->into('users') + ->set(['id' => 1, 'name' => 'Alice', 'email' => 'a@b.com']) + ->onConflict(['id'], ['name', 'email']) + ->upsert(); - $this->assertEquals('SELECT SUM("price") AS "total" FROM "t"', $result->query); + $this->assertEquals( + 'INSERT INTO `users` (`id`, `name`, `email`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `email` = VALUES(`email`)', + $result->query + ); + $this->assertEquals([1, 'Alice', 'a@b.com'], $result->bindings); } - public function testWrapCharAffectsGroupBy(): void + public function testUpsertMultipleConflictColumns(): void { $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->count('*', 'cnt') - ->groupBy(['status', 'country']) - ->build(); + ->into('user_roles') + ->set(['user_id' => 1, 'role_id' => 2, 'granted_at' => '2024-01-01']) + ->onConflict(['user_id', 'role_id'], ['granted_at']) + ->upsert(); $this->assertEquals( - 'SELECT COUNT(*) AS "cnt" FROM "t" GROUP BY "status", "country"', + 'INSERT INTO `user_roles` (`user_id`, `role_id`, `granted_at`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `granted_at` = VALUES(`granted_at`)', $result->query ); + $this->assertEquals([1, 2, '2024-01-01'], $result->bindings); } + // DML: UPDATE - public function testWrapCharAffectsHaving(): void + public function testUpdateWithWhere(): void { $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->count('*', 'cnt') - ->groupBy(['status']) - ->having([Query::greaterThan('cnt', 5)]) - ->build(); + ->from('users') + ->set(['status' => 'archived']) + ->filter([Query::equal('status', ['inactive'])]) + ->update(); - $this->assertStringContainsString('HAVING "cnt" > ?', $result->query); + $this->assertEquals( + 'UPDATE `users` SET `status` = ? WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['archived', 'inactive'], $result->bindings); } - public function testWrapCharAffectsDistinct(): void + public function testUpdateWithSetRaw(): void { $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->distinct() - ->select(['status']) - ->build(); + ->from('users') + ->set(['name' => 'Alice']) + ->setRaw('login_count', 'login_count + 1') + ->filter([Query::equal('id', [1])]) + ->update(); - $this->assertEquals('SELECT DISTINCT "status" FROM "t"', $result->query); + $this->assertEquals( + 'UPDATE `users` SET `name` = ?, `login_count` = login_count + 1 WHERE `id` IN (?)', + $result->query + ); + $this->assertEquals(['Alice', 1], $result->bindings); } - public function testWrapCharAffectsRegex(): void + public function testUpdateWithFilterHook(): void { + $hook = new class () implements Filter, \Utopia\Query\Hook { + public function filter(string $table): Condition + { + return new Condition('`_tenant` = ?', ['tenant_123']); + } + }; + $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->filter([Query::regex('slug', '^test')]) - ->build(); + ->from('users') + ->set(['status' => 'active']) + ->filter([Query::equal('id', [1])]) + ->addHook($hook) + ->update(); - $this->assertEquals('SELECT * FROM "t" WHERE "slug" REGEXP ?', $result->query); + $this->assertEquals( + 'UPDATE `users` SET `status` = ? WHERE `id` IN (?) AND `_tenant` = ?', + $result->query + ); + $this->assertEquals(['active', 1, 'tenant_123'], $result->bindings); } - public function testWrapCharAffectsSearch(): void + public function testUpdateWithoutWhere(): void { $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->filter([Query::search('body', 'hello')]) - ->build(); + ->from('users') + ->set(['status' => 'active']) + ->update(); - $this->assertEquals('SELECT * FROM "t" WHERE MATCH("body") AGAINST(?)', $result->query); + $this->assertEquals('UPDATE `users` SET `status` = ?', $result->query); + $this->assertEquals(['active'], $result->bindings); } - public function testWrapCharEmptyForSelect(): void + public function testUpdateWithOrderByAndLimit(): void { $result = (new Builder()) - ->setWrapChar('') - ->from('t') - ->select(['a', 'b']) - ->build(); + ->from('users') + ->set(['status' => 'archived']) + ->filter([Query::equal('active', [false])]) + ->sortAsc('created_at') + ->limit(100) + ->update(); + + $this->assertEquals( + 'UPDATE `users` SET `status` = ? WHERE `active` IN (?) ORDER BY `created_at` ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['archived', false, 100], $result->bindings); + } + + public function testUpdateNoAssignmentsThrows(): void + { + $this->expectException(ValidationException::class); - $this->assertEquals('SELECT a, b FROM t', $result->query); + (new Builder()) + ->from('users') + ->update(); } + // DML: DELETE - public function testWrapCharEmptyForFilter(): void + public function testDeleteWithWhere(): void { $result = (new Builder()) - ->setWrapChar('') - ->from('t') - ->filter([Query::greaterThan('age', 18)]) - ->build(); + ->from('users') + ->filter([Query::lessThan('last_login', '2024-01-01')]) + ->delete(); - $this->assertEquals('SELECT * FROM t WHERE age > ?', $result->query); + $this->assertEquals( + 'DELETE FROM `users` WHERE `last_login` < ?', + $result->query + ); + $this->assertEquals(['2024-01-01'], $result->bindings); } - public function testWrapCharEmptyForSort(): void + public function testDeleteWithFilterHook(): void { + $hook = new class () implements Filter, \Utopia\Query\Hook { + public function filter(string $table): Condition + { + return new Condition('`_tenant` = ?', ['tenant_123']); + } + }; + $result = (new Builder()) - ->setWrapChar('') - ->from('t') - ->sortAsc('name') - ->build(); + ->from('users') + ->filter([Query::equal('status', ['deleted'])]) + ->addHook($hook) + ->delete(); - $this->assertEquals('SELECT * FROM t ORDER BY name ASC', $result->query); + $this->assertEquals( + 'DELETE FROM `users` WHERE `status` IN (?) AND `_tenant` = ?', + $result->query + ); + $this->assertEquals(['deleted', 'tenant_123'], $result->bindings); } - public function testWrapCharEmptyForJoin(): void + public function testDeleteWithoutWhere(): void { $result = (new Builder()) - ->setWrapChar('') ->from('users') - ->join('orders', 'users.id', 'orders.uid') - ->build(); + ->delete(); - $this->assertEquals('SELECT * FROM users JOIN orders ON users.id = orders.uid', $result->query); + $this->assertEquals('DELETE FROM `users`', $result->query); + $this->assertEquals([], $result->bindings); } - public function testWrapCharEmptyForAggregation(): void + public function testDeleteWithOrderByAndLimit(): void { $result = (new Builder()) - ->setWrapChar('') - ->from('t') - ->count('id', 'total') - ->build(); + ->from('logs') + ->filter([Query::lessThan('created_at', '2023-01-01')]) + ->sortAsc('created_at') + ->limit(1000) + ->delete(); + + $this->assertEquals( + 'DELETE FROM `logs` WHERE `created_at` < ? ORDER BY `created_at` ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['2023-01-01', 1000], $result->bindings); + } + // DML: Reset clears new state + + public function testResetClearsDmlState(): void + { + $builder = (new Builder()) + ->into('users') + ->set(['name' => 'Alice']) + ->setRaw('count', 'count + 1') + ->onConflict(['id'], ['name']); + + $builder->reset(); + + $this->expectException(ValidationException::class); + $builder->into('users')->insert(); + } + // Validation: Missing table + + public function testInsertWithoutTableThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + + (new Builder())->set(['name' => 'Alice'])->insert(); + } + + public function testUpdateWithoutTableThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + + (new Builder())->set(['name' => 'Alice'])->update(); + } + + public function testDeleteWithoutTableThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + + (new Builder())->delete(); + } + + public function testSelectWithoutTableThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + + (new Builder())->build(); + } + // Validation: Empty rows + + public function testInsertEmptyRowThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('empty row'); + + (new Builder())->into('users')->set([])->insert(); + } + // Validation: Inconsistent batch columns + + public function testInsertInconsistentBatchThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('different columns'); + + (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'a@b.com']) + ->set(['name' => 'Bob', 'phone' => '555-1234']) + ->insert(); + } + // Validation: Upsert without onConflict + + public function testUpsertWithoutConflictKeysThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No conflict keys'); + + (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->upsert(); + } + + public function testUpsertWithoutConflictUpdateColumnsThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No conflict update columns'); + + (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], []) + ->upsert(); + } + + public function testUpsertConflictColumnNotInRowThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("not present in the row data"); - $this->assertEquals('SELECT COUNT(id) AS total FROM t', $result->query); + (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], ['email']) + ->upsert(); } + // INTERSECT / EXCEPT - public function testWrapCharEmptyForGroupBy(): void + public function testIntersect(): void { + $other = (new Builder())->from('admins'); $result = (new Builder()) - ->setWrapChar('') - ->from('t') - ->count('*', 'cnt') - ->groupBy(['status']) + ->from('users') + ->intersect($other) ->build(); - $this->assertEquals('SELECT COUNT(*) AS cnt FROM t GROUP BY status', $result->query); + $this->assertEquals( + '(SELECT * FROM `users`) INTERSECT (SELECT * FROM `admins`)', + $result->query + ); } - public function testWrapCharEmptyForDistinct(): void + public function testIntersectAll(): void { + $other = (new Builder())->from('admins'); $result = (new Builder()) - ->setWrapChar('') - ->from('t') - ->distinct() - ->select(['name']) + ->from('users') + ->intersectAll($other) ->build(); - $this->assertEquals('SELECT DISTINCT name FROM t', $result->query); + $this->assertEquals( + '(SELECT * FROM `users`) INTERSECT ALL (SELECT * FROM `admins`)', + $result->query + ); } - public function testWrapCharDoubleQuoteForSelect(): void + public function testExcept(): void { + $other = (new Builder())->from('banned'); $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->select(['x', 'y']) + ->from('users') + ->except($other) ->build(); - $this->assertEquals('SELECT "x", "y" FROM "t"', $result->query); + $this->assertEquals( + '(SELECT * FROM `users`) EXCEPT (SELECT * FROM `banned`)', + $result->query + ); } - public function testWrapCharDoubleQuoteForIsNull(): void + public function testExceptAll(): void { + $other = (new Builder())->from('banned'); $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->filter([Query::isNull('deleted')]) + ->from('users') + ->exceptAll($other) ->build(); - $this->assertEquals('SELECT * FROM "t" WHERE "deleted" IS NULL', $result->query); + $this->assertEquals( + '(SELECT * FROM `users`) EXCEPT ALL (SELECT * FROM `banned`)', + $result->query + ); } - public function testWrapCharCalledMultipleTimesLastWins(): void + public function testIntersectWithBindings(): void { + $other = (new Builder())->from('admins')->filter([Query::equal('role', ['admin'])]); $result = (new Builder()) - ->setWrapChar('"') - ->setWrapChar("'") - ->setWrapChar('`') - ->from('t') - ->select(['name']) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->intersect($other) ->build(); - $this->assertEquals('SELECT `name` FROM `t`', $result->query); + $this->assertEquals( + '(SELECT * FROM `users` WHERE `status` IN (?)) INTERSECT (SELECT * FROM `admins` WHERE `role` IN (?))', + $result->query + ); + $this->assertEquals(['active', 'admin'], $result->bindings); } - public function testWrapCharDoesNotAffectRawExpressions(): void + public function testExceptWithBindings(): void { + $other = (new Builder())->from('banned')->filter([Query::equal('reason', ['spam'])]); $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->filter([Query::raw('custom_func(col) > ?', [10])]) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->except($other) ->build(); - $this->assertEquals('SELECT * FROM "t" WHERE custom_func(col) > ?', $result->query); + $this->assertEquals(['active', 'spam'], $result->bindings); } - public function testWrapCharPersistsAcrossMultipleBuilds(): void + public function testMixedSetOperations(): void { - $builder = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->select(['name']); + $q1 = (new Builder())->from('a'); + $q2 = (new Builder())->from('b'); + $q3 = (new Builder())->from('c'); - $result1 = $builder->build(); - $result2 = $builder->build(); + $result = (new Builder()) + ->from('main') + ->union($q1) + ->intersect($q2) + ->except($q3) + ->build(); - $this->assertEquals('SELECT "name" FROM "t"', $result1->query); - $this->assertEquals('SELECT "name" FROM "t"', $result2->query); + $this->assertStringContainsString('UNION', $result->query); + $this->assertStringContainsString('INTERSECT', $result->query); + $this->assertStringContainsString('EXCEPT', $result->query); } - public function testWrapCharWithConditionProviderNotWrapped(): void + public function testIntersectFluentReturnsSameInstance(): void { - $result = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('raw_condition = 1', []); - } - }) - ->build(); + $builder = new Builder(); + $other = (new Builder())->from('t'); + $this->assertSame($builder, $builder->from('t')->intersect($other)); + } - $this->assertStringContainsString('WHERE raw_condition = 1', $result->query); - $this->assertStringContainsString('FROM "t"', $result->query); + public function testExceptFluentReturnsSameInstance(): void + { + $builder = new Builder(); + $other = (new Builder())->from('t'); + $this->assertSame($builder, $builder->from('t')->except($other)); } + // Row Locking - public function testWrapCharEmptyForRegex(): void + public function testForUpdate(): void { $result = (new Builder()) - ->setWrapChar('') - ->from('t') - ->filter([Query::regex('slug', '^test')]) + ->from('accounts') + ->filter([Query::equal('id', [1])]) + ->forUpdate() ->build(); - $this->assertEquals('SELECT * FROM t WHERE slug REGEXP ?', $result->query); + $this->assertEquals( + 'SELECT * FROM `accounts` WHERE `id` IN (?) FOR UPDATE', + $result->query + ); + $this->assertEquals([1], $result->bindings); } - public function testWrapCharEmptyForSearch(): void + public function testForShare(): void { $result = (new Builder()) - ->setWrapChar('') - ->from('t') - ->filter([Query::search('body', 'hello')]) + ->from('accounts') + ->filter([Query::equal('id', [1])]) + ->forShare() ->build(); - $this->assertEquals('SELECT * FROM t WHERE MATCH(body) AGAINST(?)', $result->query); + $this->assertEquals( + 'SELECT * FROM `accounts` WHERE `id` IN (?) FOR SHARE', + $result->query + ); } - public function testWrapCharEmptyForHaving(): void + public function testForUpdateWithLimitAndOffset(): void { $result = (new Builder()) - ->setWrapChar('') - ->from('t') - ->count('*', 'cnt') - ->groupBy(['status']) - ->having([Query::greaterThan('cnt', 5)]) + ->from('accounts') + ->limit(10) + ->offset(5) + ->forUpdate() ->build(); - $this->assertStringContainsString('HAVING cnt > ?', $result->query); + $this->assertEquals( + 'SELECT * FROM `accounts` LIMIT ? OFFSET ? FOR UPDATE', + $result->query + ); + $this->assertEquals([10, 5], $result->bindings); } - // ══════════════════════════════════════════ - // 5. Standalone Compiler method calls - // ══════════════════════════════════════════ - - public function testCompileFilterEqual(): void + public function testLockModeResetClears(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::equal('col', ['a', 'b'])); - $this->assertEquals('`col` IN (?, ?)', $sql); - $this->assertEquals(['a', 'b'], $builder->getBindings()); - } + $builder = (new Builder())->from('t')->forUpdate(); + $builder->build(); + $builder->reset(); - public function testCompileFilterNotEqual(): void - { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::notEqual('col', 'a')); - $this->assertEquals('`col` != ?', $sql); - $this->assertEquals(['a'], $builder->getBindings()); + $result = $builder->from('t')->build(); + $this->assertEquals('SELECT * FROM `t`', $result->query); } + // Transaction Statements - public function testCompileFilterLessThan(): void + public function testBegin(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::lessThan('col', 10)); - $this->assertEquals('`col` < ?', $sql); - $this->assertEquals([10], $builder->getBindings()); + $result = (new Builder())->begin(); + $this->assertEquals('BEGIN', $result->query); + $this->assertEquals([], $result->bindings); } - public function testCompileFilterLessThanEqual(): void + public function testCommit(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::lessThanEqual('col', 10)); - $this->assertEquals('`col` <= ?', $sql); - $this->assertEquals([10], $builder->getBindings()); + $result = (new Builder())->commit(); + $this->assertEquals('COMMIT', $result->query); + $this->assertEquals([], $result->bindings); } - public function testCompileFilterGreaterThan(): void + public function testRollback(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::greaterThan('col', 10)); - $this->assertEquals('`col` > ?', $sql); - $this->assertEquals([10], $builder->getBindings()); + $result = (new Builder())->rollback(); + $this->assertEquals('ROLLBACK', $result->query); + $this->assertEquals([], $result->bindings); } - public function testCompileFilterGreaterThanEqual(): void + public function testSavepoint(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::greaterThanEqual('col', 10)); - $this->assertEquals('`col` >= ?', $sql); - $this->assertEquals([10], $builder->getBindings()); + $result = (new Builder())->savepoint('sp1'); + $this->assertEquals('SAVEPOINT `sp1`', $result->query); + $this->assertEquals([], $result->bindings); } - public function testCompileFilterBetween(): void + public function testReleaseSavepoint(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::between('col', 1, 100)); - $this->assertEquals('`col` BETWEEN ? AND ?', $sql); - $this->assertEquals([1, 100], $builder->getBindings()); + $result = (new Builder())->releaseSavepoint('sp1'); + $this->assertEquals('RELEASE SAVEPOINT `sp1`', $result->query); + $this->assertEquals([], $result->bindings); } - public function testCompileFilterNotBetween(): void + public function testRollbackToSavepoint(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::notBetween('col', 1, 100)); - $this->assertEquals('`col` NOT BETWEEN ? AND ?', $sql); - $this->assertEquals([1, 100], $builder->getBindings()); + $result = (new Builder())->rollbackToSavepoint('sp1'); + $this->assertEquals('ROLLBACK TO SAVEPOINT `sp1`', $result->query); + $this->assertEquals([], $result->bindings); } + // INSERT...SELECT - public function testCompileFilterStartsWith(): void + public function testInsertSelect(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::startsWith('col', 'abc')); - $this->assertEquals('`col` LIKE ?', $sql); - $this->assertEquals(['abc%'], $builder->getBindings()); - } + $source = (new Builder()) + ->from('users') + ->select(['name', 'email']) + ->filter([Query::equal('status', ['active'])]); - public function testCompileFilterNotStartsWith(): void - { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::notStartsWith('col', 'abc')); - $this->assertEquals('`col` NOT LIKE ?', $sql); - $this->assertEquals(['abc%'], $builder->getBindings()); - } + $result = (new Builder()) + ->into('archive') + ->fromSelect(['name', 'email'], $source) + ->insertSelect(); - public function testCompileFilterEndsWith(): void - { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::endsWith('col', 'xyz')); - $this->assertEquals('`col` LIKE ?', $sql); - $this->assertEquals(['%xyz'], $builder->getBindings()); + $this->assertEquals( + 'INSERT INTO `archive` (`name`, `email`) SELECT `name`, `email` FROM `users` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); } - public function testCompileFilterNotEndsWith(): void + public function testInsertSelectWithoutSourceThrows(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::notEndsWith('col', 'xyz')); - $this->assertEquals('`col` NOT LIKE ?', $sql); - $this->assertEquals(['%xyz'], $builder->getBindings()); + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No SELECT source specified'); + + (new Builder()) + ->into('archive') + ->insertSelect(); } - public function testCompileFilterContainsSingle(): void + public function testInsertSelectWithoutTableThrows(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::contains('col', ['val'])); - $this->assertEquals('`col` LIKE ?', $sql); - $this->assertEquals(['%val%'], $builder->getBindings()); + $this->expectException(ValidationException::class); + + $source = (new Builder())->from('users'); + + (new Builder()) + ->fromSelect(['name'], $source) + ->insertSelect(); } - public function testCompileFilterContainsMultiple(): void + public function testInsertSelectWithAggregation(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::contains('col', ['a', 'b'])); - $this->assertEquals('(`col` LIKE ? OR `col` LIKE ?)', $sql); - $this->assertEquals(['%a%', '%b%'], $builder->getBindings()); + $source = (new Builder()) + ->from('orders') + ->select(['customer_id']) + ->count('*', 'order_count') + ->groupBy(['customer_id']); + + $result = (new Builder()) + ->into('customer_stats') + ->fromSelect(['customer_id', 'order_count'], $source) + ->insertSelect(); + + $this->assertStringContainsString('INSERT INTO `customer_stats`', $result->query); + $this->assertStringContainsString('COUNT(*) AS `order_count`', $result->query); } - public function testCompileFilterContainsAny(): void + public function testInsertSelectResetClears(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::containsAny('col', ['a', 'b'])); - $this->assertEquals('`col` IN (?, ?)', $sql); - $this->assertEquals(['a', 'b'], $builder->getBindings()); + $source = (new Builder())->from('users'); + $builder = (new Builder()) + ->into('archive') + ->fromSelect(['name'], $source); + + $builder->reset(); + + $this->expectException(ValidationException::class); + $builder->into('archive')->insertSelect(); } + // CTEs (WITH) - public function testCompileFilterContainsAll(): void + public function testCteWith(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::containsAll('col', ['a', 'b'])); - $this->assertEquals('(`col` LIKE ? AND `col` LIKE ?)', $sql); - $this->assertEquals(['%a%', '%b%'], $builder->getBindings()); + $cte = (new Builder()) + ->from('orders') + ->filter([Query::equal('status', ['paid'])]); + + $result = (new Builder()) + ->with('paid_orders', $cte) + ->from('paid_orders') + ->select(['customer_id']) + ->build(); + + $this->assertEquals( + 'WITH `paid_orders` AS (SELECT * FROM `orders` WHERE `status` IN (?)) SELECT `customer_id` FROM `paid_orders`', + $result->query + ); + $this->assertEquals(['paid'], $result->bindings); } - public function testCompileFilterNotContainsSingle(): void + public function testCteWithRecursive(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::notContains('col', ['val'])); - $this->assertEquals('`col` NOT LIKE ?', $sql); - $this->assertEquals(['%val%'], $builder->getBindings()); + $cte = (new Builder())->from('categories'); + + $result = (new Builder()) + ->withRecursive('tree', $cte) + ->from('tree') + ->build(); + + $this->assertEquals( + 'WITH RECURSIVE `tree` AS (SELECT * FROM `categories`) SELECT * FROM `tree`', + $result->query + ); } - public function testCompileFilterNotContainsMultiple(): void + public function testMultipleCtes(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::notContains('col', ['a', 'b'])); - $this->assertEquals('(`col` NOT LIKE ? AND `col` NOT LIKE ?)', $sql); - $this->assertEquals(['%a%', '%b%'], $builder->getBindings()); + $cte1 = (new Builder())->from('orders')->filter([Query::equal('status', ['paid'])]); + $cte2 = (new Builder())->from('returns')->filter([Query::equal('status', ['approved'])]); + + $result = (new Builder()) + ->with('paid', $cte1) + ->with('approved_returns', $cte2) + ->from('paid') + ->build(); + + $this->assertStringStartsWith('WITH `paid` AS', $result->query); + $this->assertStringContainsString('`approved_returns` AS', $result->query); + $this->assertEquals(['paid', 'approved'], $result->bindings); } - public function testCompileFilterIsNull(): void + public function testCteBindingsComeBefore(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::isNull('col')); - $this->assertEquals('`col` IS NULL', $sql); - $this->assertEquals([], $builder->getBindings()); + $cte = (new Builder())->from('orders')->filter([Query::equal('year', [2024])]); + + $result = (new Builder()) + ->with('recent', $cte) + ->from('recent') + ->filter([Query::greaterThan('amount', 100)]) + ->build(); + + $this->assertEquals([2024, 100], $result->bindings); } - public function testCompileFilterIsNotNull(): void + public function testCteResetClears(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::isNotNull('col')); - $this->assertEquals('`col` IS NOT NULL', $sql); - $this->assertEquals([], $builder->getBindings()); + $cte = (new Builder())->from('orders'); + $builder = (new Builder())->with('o', $cte)->from('o'); + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertEquals('SELECT * FROM `t`', $result->query); } - public function testCompileFilterAnd(): void + public function testMixedRecursiveAndNonRecursiveCte(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::and([ - Query::equal('a', [1]), - Query::greaterThan('b', 2), - ])); - $this->assertEquals('(`a` IN (?) AND `b` > ?)', $sql); - $this->assertEquals([1, 2], $builder->getBindings()); + $cte1 = (new Builder())->from('categories'); + $cte2 = (new Builder())->from('products'); + + $result = (new Builder()) + ->with('prods', $cte2) + ->withRecursive('tree', $cte1) + ->from('tree') + ->build(); + + $this->assertStringStartsWith('WITH RECURSIVE', $result->query); + $this->assertStringContainsString('`prods` AS', $result->query); + $this->assertStringContainsString('`tree` AS', $result->query); } + // CASE/WHEN + selectRaw() - public function testCompileFilterOr(): void + public function testCaseBuilder(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::or([ - Query::equal('a', [1]), - Query::equal('b', [2]), - ])); - $this->assertEquals('(`a` IN (?) OR `b` IN (?))', $sql); - $this->assertEquals([1, 2], $builder->getBindings()); + $case = (new CaseBuilder()) + ->when('status = ?', '?', ['active'], ['Active']) + ->when('status = ?', '?', ['inactive'], ['Inactive']) + ->elseResult('?', ['Unknown']) + ->alias('label') + ->build(); + + $this->assertEquals( + 'CASE WHEN status = ? THEN ? WHEN status = ? THEN ? ELSE ? END AS label', + $case->sql + ); + $this->assertEquals(['active', 'Active', 'inactive', 'Inactive', 'Unknown'], $case->bindings); } - public function testCompileFilterExists(): void + public function testCaseBuilderWithoutElse(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::exists(['a', 'b'])); - $this->assertEquals('(`a` IS NOT NULL AND `b` IS NOT NULL)', $sql); + $case = (new CaseBuilder()) + ->when('x > ?', '1', [10]) + ->build(); + + $this->assertEquals('CASE WHEN x > ? THEN 1 END', $case->sql); + $this->assertEquals([10], $case->bindings); } - public function testCompileFilterNotExists(): void + public function testCaseBuilderWithoutAlias(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::notExists(['a', 'b'])); - $this->assertEquals('(`a` IS NULL AND `b` IS NULL)', $sql); + $case = (new CaseBuilder()) + ->when('x = 1', "'yes'") + ->elseResult("'no'") + ->build(); + + $this->assertEquals("CASE WHEN x = 1 THEN 'yes' ELSE 'no' END", $case->sql); } - public function testCompileFilterRaw(): void + public function testCaseBuilderNoWhensThrows(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::raw('x > ? AND y < ?', [1, 2])); - $this->assertEquals('x > ? AND y < ?', $sql); - $this->assertEquals([1, 2], $builder->getBindings()); + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('at least one WHEN'); + + (new CaseBuilder())->build(); } - public function testCompileFilterSearch(): void + public function testCaseExpressionToSql(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::search('body', 'hello')); - $this->assertEquals('MATCH(`body`) AGAINST(?)', $sql); - $this->assertEquals(['hello'], $builder->getBindings()); + $case = (new CaseBuilder()) + ->when('a = ?', '1', [1]) + ->build(); + + $arr = $case->toSql(); + $this->assertEquals('CASE WHEN a = ? THEN 1 END', $arr['sql']); + $this->assertEquals([1], $arr['bindings']); } - public function testCompileFilterNotSearch(): void + public function testSelectRaw(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::notSearch('body', 'spam')); - $this->assertEquals('NOT (MATCH(`body`) AGAINST(?))', $sql); - $this->assertEquals(['spam'], $builder->getBindings()); + $result = (new Builder()) + ->from('orders') + ->selectRaw('SUM(amount) AS total') + ->build(); + + $this->assertEquals('SELECT SUM(amount) AS total FROM `orders`', $result->query); } - public function testCompileFilterRegex(): void + public function testSelectRawWithBindings(): void { - $builder = new Builder(); - $sql = $builder->compileFilter(Query::regex('col', '^abc')); - $this->assertEquals('`col` REGEXP ?', $sql); - $this->assertEquals(['^abc'], $builder->getBindings()); + $result = (new Builder()) + ->from('orders') + ->selectRaw('IF(amount > ?, 1, 0) AS big_order', [1000]) + ->build(); + + $this->assertEquals('SELECT IF(amount > ?, 1, 0) AS big_order FROM `orders`', $result->query); + $this->assertEquals([1000], $result->bindings); } - public function testCompileOrderAsc(): void + public function testSelectRawCombinedWithSelect(): void { - $builder = new Builder(); - $sql = $builder->compileOrder(Query::orderAsc('name')); - $this->assertEquals('`name` ASC', $sql); + $result = (new Builder()) + ->from('orders') + ->select(['id', 'customer_id']) + ->selectRaw('SUM(amount) AS total') + ->build(); + + $this->assertEquals('SELECT `id`, `customer_id`, SUM(amount) AS total FROM `orders`', $result->query); } - public function testCompileOrderDesc(): void + public function testSelectRawWithCaseExpression(): void { - $builder = new Builder(); - $sql = $builder->compileOrder(Query::orderDesc('name')); - $this->assertEquals('`name` DESC', $sql); + $case = (new CaseBuilder()) + ->when('status = ?', '?', ['active'], ['Active']) + ->elseResult('?', ['Other']) + ->alias('label') + ->build(); + + $result = (new Builder()) + ->from('users') + ->select(['id']) + ->selectRaw($case->sql, $case->bindings) + ->build(); + + $this->assertStringContainsString('CASE WHEN status = ? THEN ? ELSE ? END AS label', $result->query); + $this->assertEquals(['active', 'Active', 'Other'], $result->bindings); } - public function testCompileOrderRandom(): void + public function testSelectRawResetClears(): void { - $builder = new Builder(); - $sql = $builder->compileOrder(Query::orderRandom()); - $this->assertEquals('RAND()', $sql); + $builder = (new Builder())->from('t')->selectRaw('1 AS one'); + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertEquals('SELECT * FROM `t`', $result->query); } - public function testCompileLimitStandalone(): void + public function testSetRawWithBindings(): void { - $builder = new Builder(); - $sql = $builder->compileLimit(Query::limit(25)); - $this->assertEquals('LIMIT ?', $sql); - $this->assertEquals([25], $builder->getBindings()); + $result = (new Builder()) + ->from('accounts') + ->set(['name' => 'Alice']) + ->setRaw('balance', 'balance + ?', [100]) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertEquals( + 'UPDATE `accounts` SET `name` = ?, `balance` = balance + ? WHERE `id` IN (?)', + $result->query + ); + $this->assertEquals(['Alice', 100, 1], $result->bindings); } - public function testCompileOffsetStandalone(): void + public function testSetRawWithBindingsResetClears(): void { - $builder = new Builder(); - $sql = $builder->compileOffset(Query::offset(50)); - $this->assertEquals('OFFSET ?', $sql); - $this->assertEquals([50], $builder->getBindings()); + $builder = (new Builder())->from('t')->setRaw('x', 'x + ?', [1]); + $builder->reset(); + + $this->expectException(ValidationException::class); + $builder->from('t')->update(); } - public function testCompileSelectStandalone(): void + public function testMultipleSelectRaw(): void { - $builder = new Builder(); - $sql = $builder->compileSelect(Query::select(['a', 'b', 'c'])); - $this->assertEquals('`a`, `b`, `c`', $sql); + $result = (new Builder()) + ->from('t') + ->selectRaw('COUNT(*) AS cnt') + ->selectRaw('MAX(price) AS max_price') + ->build(); + + $this->assertEquals('SELECT COUNT(*) AS cnt, MAX(price) AS max_price FROM `t`', $result->query); } - public function testCompileCursorAfterStandalone(): void + public function testForUpdateNotInUnion(): void { - $builder = new Builder(); - $sql = $builder->compileCursor(Query::cursorAfter('abc')); - $this->assertEquals('`_cursor` > ?', $sql); - $this->assertEquals(['abc'], $builder->getBindings()); + $other = (new Builder())->from('b'); + $result = (new Builder()) + ->from('a') + ->forUpdate() + ->union($other) + ->build(); + + $this->assertStringContainsString('FOR UPDATE', $result->query); } - public function testCompileCursorBeforeStandalone(): void + public function testCteWithUnion(): void { - $builder = new Builder(); - $sql = $builder->compileCursor(Query::cursorBefore('xyz')); - $this->assertEquals('`_cursor` < ?', $sql); - $this->assertEquals(['xyz'], $builder->getBindings()); + $cte = (new Builder())->from('orders'); + $other = (new Builder())->from('archive_orders'); + + $result = (new Builder()) + ->with('o', $cte) + ->from('o') + ->union($other) + ->build(); + + $this->assertStringStartsWith('WITH `o` AS', $result->query); + $this->assertStringContainsString('UNION', $result->query); } + // Spatial feature interface - public function testCompileAggregateCountStandalone(): void + public function testImplementsSpatial(): void { - $builder = new Builder(); - $sql = $builder->compileAggregate(Query::count('*', 'total')); - $this->assertEquals('COUNT(*) AS `total`', $sql); + $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Spatial::class, new Builder()); } - public function testCompileAggregateCountWithoutAlias(): void + public function testFilterDistanceMeters(): void { - $builder = new Builder(); - $sql = $builder->compileAggregate(Query::count()); - $this->assertEquals('COUNT(*)', $sql); + $result = (new Builder()) + ->from('locations') + ->filterDistance('coords', [40.7128, -74.0060], '<', 5000.0, true) + ->build(); + + $this->assertStringContainsString('ST_Distance(ST_SRID(`coords`, 4326), ST_GeomFromText(?, 4326), \'metre\') < ?', $result->query); + $this->assertEquals('POINT(40.7128 -74.006)', $result->bindings[0]); + $this->assertEquals(5000.0, $result->bindings[1]); } - public function testCompileAggregateSumStandalone(): void + public function testFilterDistanceNoMeters(): void { - $builder = new Builder(); - $sql = $builder->compileAggregate(Query::sum('price', 'total')); - $this->assertEquals('SUM(`price`) AS `total`', $sql); + $result = (new Builder()) + ->from('locations') + ->filterDistance('coords', [1.0, 2.0], '>', 100.0) + ->build(); + + $this->assertStringContainsString('ST_Distance(`coords`, ST_GeomFromText(?)) > ?', $result->query); } - public function testCompileAggregateAvgStandalone(): void + public function testFilterIntersectsPoint(): void { - $builder = new Builder(); - $sql = $builder->compileAggregate(Query::avg('score', 'avg_score')); - $this->assertEquals('AVG(`score`) AS `avg_score`', $sql); + $result = (new Builder()) + ->from('zones') + ->filterIntersects('area', [1.0, 2.0]) + ->build(); + + $this->assertStringContainsString('ST_Intersects(`area`, ST_GeomFromText(?, 4326))', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); } - public function testCompileAggregateMinStandalone(): void + public function testFilterNotIntersects(): void { - $builder = new Builder(); - $sql = $builder->compileAggregate(Query::min('price', 'lowest')); - $this->assertEquals('MIN(`price`) AS `lowest`', $sql); + $result = (new Builder()) + ->from('zones') + ->filterNotIntersects('area', [1.0, 2.0]) + ->build(); + + $this->assertStringContainsString('NOT ST_Intersects', $result->query); } - public function testCompileAggregateMaxStandalone(): void + public function testFilterCovers(): void { - $builder = new Builder(); - $sql = $builder->compileAggregate(Query::max('price', 'highest')); - $this->assertEquals('MAX(`price`) AS `highest`', $sql); + $result = (new Builder()) + ->from('zones') + ->filterCovers('area', [1.0, 2.0]) + ->build(); + + $this->assertStringContainsString('ST_Contains(`area`, ST_GeomFromText(?, 4326))', $result->query); } - public function testCompileGroupByStandalone(): void + public function testFilterSpatialEquals(): void { - $builder = new Builder(); - $sql = $builder->compileGroupBy(Query::groupBy(['status', 'country'])); - $this->assertEquals('`status`, `country`', $sql); + $result = (new Builder()) + ->from('zones') + ->filterSpatialEquals('area', [1.0, 2.0]) + ->build(); + + $this->assertStringContainsString('ST_Equals', $result->query); } - public function testCompileJoinStandalone(): void + public function testSpatialWithLinestring(): void { - $builder = new Builder(); - $sql = $builder->compileJoin(Query::join('orders', 'users.id', 'orders.uid')); - $this->assertEquals('JOIN `orders` ON `users`.`id` = `orders`.`uid`', $sql); + $result = (new Builder()) + ->from('roads') + ->filterIntersects('path', [[0, 0], [1, 1], [2, 2]]) + ->build(); + + $this->assertEquals('LINESTRING(0 0, 1 1, 2 2)', $result->bindings[0]); } - public function testCompileLeftJoinStandalone(): void + public function testSpatialWithPolygon(): void { - $builder = new Builder(); - $sql = $builder->compileJoin(Query::leftJoin('profiles', 'users.id', 'profiles.uid')); - $this->assertEquals('LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`uid`', $sql); + $result = (new Builder()) + ->from('areas') + ->filterIntersects('zone', [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]) + ->build(); + + /** @var string $wkt */ + $wkt = $result->bindings[0]; + $this->assertStringContainsString('POLYGON', $wkt); } + // JSON feature interface - public function testCompileRightJoinStandalone(): void + public function testImplementsJson(): void { - $builder = new Builder(); - $sql = $builder->compileJoin(Query::rightJoin('orders', 'users.id', 'orders.uid')); - $this->assertEquals('RIGHT JOIN `orders` ON `users`.`id` = `orders`.`uid`', $sql); + $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Json::class, new Builder()); } - public function testCompileCrossJoinStandalone(): void + public function testFilterJsonContains(): void { - $builder = new Builder(); - $sql = $builder->compileJoin(Query::crossJoin('colors')); - $this->assertEquals('CROSS JOIN `colors`', $sql); - } + $result = (new Builder()) + ->from('docs') + ->filterJsonContains('tags', 'php') + ->build(); - // ══════════════════════════════════════════ - // 6. Filter edge cases - // ══════════════════════════════════════════ + $this->assertStringContainsString('JSON_CONTAINS(`tags`, ?)', $result->query); + $this->assertEquals('"php"', $result->bindings[0]); + } - public function testEqualWithSingleValue(): void + public function testFilterJsonNotContains(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::equal('status', ['active'])]) + ->from('docs') + ->filterJsonNotContains('tags', 'old') ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?)', $result->query); - $this->assertEquals(['active'], $result->bindings); + $this->assertStringContainsString('NOT JSON_CONTAINS(`tags`, ?)', $result->query); } - public function testEqualWithManyValues(): void + public function testFilterJsonOverlaps(): void { - $values = range(1, 10); $result = (new Builder()) - ->from('t') - ->filter([Query::equal('id', $values)]) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'go']) ->build(); - $placeholders = implode(', ', array_fill(0, 10, '?')); - $this->assertEquals("SELECT * FROM `t` WHERE `id` IN ({$placeholders})", $result->query); - $this->assertEquals($values, $result->bindings); + $this->assertStringContainsString('JSON_OVERLAPS(`tags`, ?)', $result->query); + $this->assertEquals('["php","go"]', $result->bindings[0]); } - public function testEqualWithEmptyArray(): void + public function testFilterJsonPath(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::equal('id', [])]) + ->from('users') + ->filterJsonPath('metadata', 'level', '>', 5) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE 1 = 0', $result->query); - $this->assertEquals([], $result->bindings); + $this->assertStringContainsString("JSON_EXTRACT(`metadata`, '$.level') > ?", $result->query); + $this->assertEquals(5, $result->bindings[0]); } - public function testNotEqualWithExactlyTwoValues(): void + public function testSetJsonAppend(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::notEqual('role', ['guest', 'banned'])]) - ->build(); + ->from('docs') + ->setJsonAppend('tags', ['new_tag']) + ->filter([Query::equal('id', [1])]) + ->update(); - $this->assertEquals('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result->query); - $this->assertEquals(['guest', 'banned'], $result->bindings); + $this->assertStringContainsString('JSON_MERGE_PRESERVE(IFNULL(`tags`, JSON_ARRAY()), ?)', $result->query); } - public function testBetweenWithSameMinAndMax(): void + public function testSetJsonPrepend(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::between('age', 25, 25)]) - ->build(); + ->from('docs') + ->setJsonPrepend('tags', ['first']) + ->filter([Query::equal('id', [1])]) + ->update(); - $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); - $this->assertEquals([25, 25], $result->bindings); + $this->assertStringContainsString('JSON_MERGE_PRESERVE(?, IFNULL(`tags`, JSON_ARRAY()))', $result->query); } - public function testStartsWithEmptyString(): void + public function testSetJsonInsert(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::startsWith('name', '')]) - ->build(); + ->from('docs') + ->setJsonInsert('tags', 0, 'inserted') + ->filter([Query::equal('id', [1])]) + ->update(); - $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); - $this->assertEquals(['%'], $result->bindings); + $this->assertStringContainsString('JSON_ARRAY_INSERT', $result->query); } - public function testEndsWithEmptyString(): void + public function testSetJsonRemove(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::endsWith('name', '')]) - ->build(); + ->from('docs') + ->setJsonRemove('tags', 'old_tag') + ->filter([Query::equal('id', [1])]) + ->update(); - $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); - $this->assertEquals(['%'], $result->bindings); + $this->assertStringContainsString('JSON_REMOVE', $result->query); } + // Hints feature interface - public function testContainsWithSingleEmptyString(): void + public function testImplementsHints(): void + { + $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Hints::class, new Builder()); + } + + public function testHintInSelect(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::contains('bio', [''])]) + ->from('users') + ->hint('NO_INDEX_MERGE(users)') ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); - $this->assertEquals(['%%'], $result->bindings); + $this->assertStringContainsString('/*+ NO_INDEX_MERGE(users) */', $result->query); } - public function testContainsWithManyValues(): void + public function testMaxExecutionTime(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::contains('bio', ['a', 'b', 'c', 'd', 'e'])]) + ->from('users') + ->maxExecutionTime(5000) ->build(); - $this->assertStringContainsString('(`bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ?)', $result->query); - $this->assertEquals(['%a%', '%b%', '%c%', '%d%', '%e%'], $result->bindings); + $this->assertStringContainsString('/*+ MAX_EXECUTION_TIME(5000) */', $result->query); } - public function testContainsAllWithSingleValue(): void + public function testMultipleHints(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::containsAll('perms', ['read'])]) + ->from('users') + ->hint('NO_INDEX_MERGE(users)') + ->hint('BKA(users)') ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`perms` LIKE ?)', $result->query); - $this->assertEquals(['%read%'], $result->bindings); + $this->assertStringContainsString('/*+ NO_INDEX_MERGE(users) BKA(users) */', $result->query); } + // Window functions - public function testNotContainsWithEmptyStringValue(): void + public function testImplementsWindows(): void + { + $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Windows::class, new Builder()); + } + + public function testSelectWindowRowNumber(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::notContains('bio', [''])]) + ->from('orders') + ->selectWindow('ROW_NUMBER()', 'rn', ['customer_id'], ['created_at']) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result->query); - $this->assertEquals(['%%'], $result->bindings); + $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY `customer_id` ORDER BY `created_at` ASC) AS `rn`', $result->query); } - public function testComparisonWithFloatValues(): void + public function testSelectWindowRank(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::greaterThan('price', 9.99)]) + ->from('scores') + ->selectWindow('RANK()', 'rank', null, ['-score']) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `price` > ?', $result->query); - $this->assertEquals([9.99], $result->bindings); + $this->assertStringContainsString('RANK() OVER (ORDER BY `score` DESC) AS `rank`', $result->query); } - public function testComparisonWithNegativeValues(): void + public function testSelectWindowPartitionOnly(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::lessThan('balance', -100)]) + ->from('orders') + ->selectWindow('SUM(amount)', 'total', ['dept']) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `balance` < ?', $result->query); - $this->assertEquals([-100], $result->bindings); + $this->assertStringContainsString('SUM(amount) OVER (PARTITION BY `dept`) AS `total`', $result->query); } - public function testComparisonWithZero(): void + public function testSelectWindowNoPartitionNoOrder(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::greaterThanEqual('score', 0)]) + ->from('orders') + ->selectWindow('COUNT(*)', 'total') ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `score` >= ?', $result->query); - $this->assertEquals([0], $result->bindings); + $this->assertStringContainsString('COUNT(*) OVER () AS `total`', $result->query); } + // CASE integration - public function testComparisonWithVeryLargeInteger(): void + public function testSelectCaseExpression(): void { + $case = (new CaseBuilder()) + ->when('status = ?', '?', ['active'], ['Active']) + ->elseResult('?', ['Other']) + ->alias('label') + ->build(); + $result = (new Builder()) - ->from('t') - ->filter([Query::lessThan('id', 9999999999999)]) + ->from('users') + ->select(['id']) + ->selectCase($case) ->build(); - $this->assertEquals([9999999999999], $result->bindings); + $this->assertStringContainsString('CASE WHEN status = ? THEN ? ELSE ? END AS label', $result->query); + $this->assertEquals(['active', 'Active', 'Other'], $result->bindings); } - public function testComparisonWithStringValues(): void + public function testSetCaseExpression(): void { - $result = (new Builder()) - ->from('t') - ->filter([Query::greaterThan('name', 'M')]) + $case = (new CaseBuilder()) + ->when('age >= ?', '?', [18], ['adult']) + ->elseResult('?', ['minor']) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `name` > ?', $result->query); - $this->assertEquals(['M'], $result->bindings); + $result = (new Builder()) + ->from('users') + ->setCase('category', $case) + ->filter([Query::greaterThan('id', 0)]) + ->update(); + + $this->assertStringContainsString('`category` = CASE WHEN age >= ? THEN ? ELSE ? END', $result->query); + $this->assertEquals([18, 'adult', 'minor', 0], $result->bindings); + } + // Query factory methods for JSON + + public function testQueryJsonContainsFactory(): void + { + $q = Query::jsonContains('tags', 'php'); + $this->assertEquals(\Utopia\Query\Method::JsonContains, $q->getMethod()); + $this->assertEquals('tags', $q->getAttribute()); + } + + public function testQueryJsonOverlapsFactory(): void + { + $q = Query::jsonOverlaps('tags', ['php', 'go']); + $this->assertEquals(\Utopia\Query\Method::JsonOverlaps, $q->getMethod()); } - public function testBetweenWithStringValues(): void + public function testQueryJsonPathFactory(): void { - $result = (new Builder()) - ->from('t') - ->filter([Query::between('created_at', '2024-01-01', '2024-12-31')]) - ->build(); + $q = Query::jsonPath('meta', 'level', '>', 5); + $this->assertEquals(\Utopia\Query\Method::JsonPath, $q->getMethod()); + $this->assertEquals(['level', '>', 5], $q->getValues()); + } + // Does NOT implement VectorSearch - $this->assertEquals('SELECT * FROM `t` WHERE `created_at` BETWEEN ? AND ?', $result->query); - $this->assertEquals(['2024-01-01', '2024-12-31'], $result->bindings); + public function testDoesNotImplementVectorSearch(): void + { + $builder = new Builder(); + $this->assertNotInstanceOf(\Utopia\Query\Builder\Feature\VectorSearch::class, $builder); // @phpstan-ignore method.alreadyNarrowedType } + // Reset clears new state - public function testIsNullCombinedWithIsNotNullOnDifferentColumns(): void + public function testResetClearsHintsAndJsonSets(): void { - $result = (new Builder()) - ->from('t') - ->filter([ - Query::isNull('deleted_at'), - Query::isNotNull('verified_at'), - ]) - ->build(); + $builder = (new Builder()) + ->from('users') + ->hint('test') + ->setJsonAppend('tags', ['a']); - $this->assertEquals( - 'SELECT * FROM `t` WHERE `deleted_at` IS NULL AND `verified_at` IS NOT NULL', - $result->query - ); - $this->assertEquals([], $result->bindings); + $builder->reset(); + + $result = $builder->from('users')->build(); + $this->assertStringNotContainsString('/*+', $result->query); } - public function testMultipleIsNullFilters(): void + public function testFilterNotIntersectsPoint(): void { $result = (new Builder()) - ->from('t') - ->filter([ - Query::isNull('a'), - Query::isNull('b'), - Query::isNull('c'), - ]) + ->from('zones') + ->filterNotIntersects('zone', [1.0, 2.0]) ->build(); - $this->assertEquals( - 'SELECT * FROM `t` WHERE `a` IS NULL AND `b` IS NULL AND `c` IS NULL', - $result->query - ); + $this->assertStringContainsString('NOT ST_Intersects', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); } - public function testExistsWithSingleAttribute(): void + public function testFilterNotCrossesLinestring(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::exists(['name'])]) + ->from('roads') + ->filterNotCrosses('path', [[0, 0], [1, 1]]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`name` IS NOT NULL)', $result->query); + $this->assertStringContainsString('NOT ST_Crosses', $result->query); + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertStringContainsString('LINESTRING', $binding); } - public function testExistsWithManyAttributes(): void + public function testFilterOverlapsPolygon(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::exists(['a', 'b', 'c', 'd'])]) + ->from('regions') + ->filterOverlaps('area', [[[0, 0], [1, 0], [1, 1], [0, 0]]]) ->build(); - $this->assertEquals( - 'SELECT * FROM `t` WHERE (`a` IS NOT NULL AND `b` IS NOT NULL AND `c` IS NOT NULL AND `d` IS NOT NULL)', - $result->query - ); + $this->assertStringContainsString('ST_Overlaps', $result->query); + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertStringContainsString('POLYGON', $binding); } - public function testNotExistsWithManyAttributes(): void + public function testFilterNotOverlaps(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::notExists(['a', 'b', 'c'])]) + ->from('regions') + ->filterNotOverlaps('area', [1.0, 2.0]) ->build(); - $this->assertEquals( - 'SELECT * FROM `t` WHERE (`a` IS NULL AND `b` IS NULL AND `c` IS NULL)', - $result->query - ); + $this->assertStringContainsString('NOT ST_Overlaps', $result->query); } - public function testAndWithSingleSubQuery(): void + public function testFilterTouches(): void { $result = (new Builder()) - ->from('t') - ->filter([ - Query::and([ - Query::equal('a', [1]), - ]), - ]) + ->from('zones') + ->filterTouches('zone', [5.0, 10.0]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?))', $result->query); - $this->assertEquals([1], $result->bindings); + $this->assertStringContainsString('ST_Touches', $result->query); } - public function testOrWithSingleSubQuery(): void + public function testFilterNotTouches(): void { $result = (new Builder()) - ->from('t') - ->filter([ - Query::or([ - Query::equal('a', [1]), - ]), - ]) + ->from('zones') + ->filterNotTouches('zone', [5.0, 10.0]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?))', $result->query); - $this->assertEquals([1], $result->bindings); + $this->assertStringContainsString('NOT ST_Touches', $result->query); } - public function testAndWithManySubQueries(): void + public function testFilterNotCovers(): void { $result = (new Builder()) - ->from('t') - ->filter([ - Query::and([ - Query::equal('a', [1]), - Query::equal('b', [2]), - Query::equal('c', [3]), - Query::equal('d', [4]), - Query::equal('e', [5]), - ]), - ]) + ->from('zones') + ->filterNotCovers('region', [1.0, 2.0]) ->build(); - $this->assertEquals( - 'SELECT * FROM `t` WHERE (`a` IN (?) AND `b` IN (?) AND `c` IN (?) AND `d` IN (?) AND `e` IN (?))', - $result->query - ); - $this->assertEquals([1, 2, 3, 4, 5], $result->bindings); + $this->assertStringContainsString('NOT ST_Contains', $result->query); } - public function testOrWithManySubQueries(): void + public function testFilterNotSpatialEquals(): void { $result = (new Builder()) - ->from('t') - ->filter([ - Query::or([ - Query::equal('a', [1]), - Query::equal('b', [2]), - Query::equal('c', [3]), - Query::equal('d', [4]), - Query::equal('e', [5]), - ]), - ]) + ->from('zones') + ->filterNotSpatialEquals('geom', [3.0, 4.0]) ->build(); - $this->assertEquals( - 'SELECT * FROM `t` WHERE (`a` IN (?) OR `b` IN (?) OR `c` IN (?) OR `d` IN (?) OR `e` IN (?))', - $result->query - ); + $this->assertStringContainsString('NOT ST_Equals', $result->query); } - public function testDeeplyNestedAndOrAnd(): void + public function testFilterDistanceGreaterThan(): void { $result = (new Builder()) - ->from('t') - ->filter([ - Query::and([ - Query::or([ - Query::and([ - Query::equal('a', [1]), - Query::equal('b', [2]), - ]), - Query::equal('c', [3]), - ]), - Query::equal('d', [4]), - ]), - ]) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '>', 500.0) ->build(); - $this->assertEquals( - 'SELECT * FROM `t` WHERE (((`a` IN (?) AND `b` IN (?)) OR `c` IN (?)) AND `d` IN (?))', - $result->query - ); - $this->assertEquals([1, 2, 3, 4], $result->bindings); + $this->assertStringContainsString('ST_Distance', $result->query); + $this->assertStringContainsString('> ?', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + $this->assertEquals(500.0, $result->bindings[1]); } - public function testRawWithManyBindings(): void + public function testFilterDistanceEqual(): void { - $bindings = range(1, 10); - $placeholders = implode(' AND ', array_map(fn ($i) => "col{$i} = ?", range(1, 10))); $result = (new Builder()) - ->from('t') - ->filter([Query::raw($placeholders, $bindings)]) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '=', 0.0) ->build(); - $this->assertEquals("SELECT * FROM `t` WHERE {$placeholders}", $result->query); - $this->assertEquals($bindings, $result->bindings); + $this->assertStringContainsString('ST_Distance', $result->query); + $this->assertStringContainsString('= ?', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + $this->assertEquals(0.0, $result->bindings[1]); } - public function testFilterWithDotsInAttributeName(): void + public function testFilterDistanceNotEqual(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::equal('table.column', ['value'])]) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '!=', 100.0) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `table`.`column` IN (?)', $result->query); + $this->assertStringContainsString('ST_Distance', $result->query); + $this->assertStringContainsString('!= ?', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + $this->assertEquals(100.0, $result->bindings[1]); } - public function testFilterWithUnderscoresInAttributeName(): void + public function testFilterDistanceWithoutMeters(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::equal('my_column_name', ['value'])]) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '<', 50.0, false) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `my_column_name` IN (?)', $result->query); + $this->assertStringContainsString('ST_Distance(`loc`, ST_GeomFromText(?)) < ?', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + $this->assertEquals(50.0, $result->bindings[1]); } - public function testFilterWithNumericAttributeName(): void + public function testFilterIntersectsLinestring(): void { $result = (new Builder()) - ->from('t') - ->filter([Query::equal('123', ['value'])]) + ->from('roads') + ->filterIntersects('path', [[0, 0], [1, 1], [2, 2]]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `123` IN (?)', $result->query); + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertStringContainsString('LINESTRING(0 0, 1 1, 2 2)', $binding); } - // ══════════════════════════════════════════ - // 7. Aggregation edge cases - // ══════════════════════════════════════════ - - public function testCountWithoutAliasNoAsClause(): void + public function testFilterSpatialEqualsPoint(): void { - $result = (new Builder())->from('t')->count()->build(); - $this->assertEquals('SELECT COUNT(*) FROM `t`', $result->query); - $this->assertStringNotContainsString(' AS ', $result->query); - } + $result = (new Builder()) + ->from('places') + ->filterSpatialEquals('pos', [42.5, -73.2]) + ->build(); - public function testSumWithoutAliasNoAsClause(): void - { - $result = (new Builder())->from('t')->sum('price')->build(); - $this->assertEquals('SELECT SUM(`price`) FROM `t`', $result->query); - $this->assertStringNotContainsString(' AS ', $result->query); + $this->assertStringContainsString('ST_Equals', $result->query); + $this->assertEquals('POINT(42.5 -73.2)', $result->bindings[0]); } - public function testAvgWithoutAliasNoAsClause(): void + public function testSetJsonIntersect(): void { - $result = (new Builder())->from('t')->avg('score')->build(); - $this->assertEquals('SELECT AVG(`score`) FROM `t`', $result->query); - $this->assertStringNotContainsString(' AS ', $result->query); - } + $result = (new Builder()) + ->from('t') + ->setJsonIntersect('tags', ['a', 'b']) + ->filter([Query::equal('id', [1])]) + ->update(); - public function testMinWithoutAliasNoAsClause(): void - { - $result = (new Builder())->from('t')->min('price')->build(); - $this->assertEquals('SELECT MIN(`price`) FROM `t`', $result->query); - $this->assertStringNotContainsString(' AS ', $result->query); + $this->assertStringContainsString('JSON_ARRAYAGG', $result->query); + $this->assertStringContainsString('JSON_CONTAINS(?, val)', $result->query); + $this->assertStringContainsString('UPDATE `t` SET', $result->query); } - public function testMaxWithoutAliasNoAsClause(): void + public function testSetJsonDiff(): void { - $result = (new Builder())->from('t')->max('price')->build(); - $this->assertEquals('SELECT MAX(`price`) FROM `t`', $result->query); - $this->assertStringNotContainsString(' AS ', $result->query); - } + $result = (new Builder()) + ->from('t') + ->setJsonDiff('tags', ['x']) + ->filter([Query::equal('id', [1])]) + ->update(); - public function testCountWithAlias2(): void - { - $result = (new Builder())->from('t')->count('*', 'cnt')->build(); - $this->assertStringContainsString('AS `cnt`', $result->query); + $this->assertStringContainsString('NOT JSON_CONTAINS(?, val)', $result->query); + $this->assertContains(\json_encode(['x']), $result->bindings); } - public function testSumWithAlias(): void + public function testSetJsonUnique(): void { - $result = (new Builder())->from('t')->sum('price', 'total')->build(); - $this->assertStringContainsString('AS `total`', $result->query); - } + $result = (new Builder()) + ->from('t') + ->setJsonUnique('tags') + ->filter([Query::equal('id', [1])]) + ->update(); - public function testAvgWithAlias(): void - { - $result = (new Builder())->from('t')->avg('score', 'avg_s')->build(); - $this->assertStringContainsString('AS `avg_s`', $result->query); + $this->assertStringContainsString('JSON_ARRAYAGG', $result->query); + $this->assertStringContainsString('DISTINCT', $result->query); } - public function testMinWithAlias(): void + public function testSetJsonPrependMergeOrder(): void { - $result = (new Builder())->from('t')->min('price', 'lowest')->build(); - $this->assertStringContainsString('AS `lowest`', $result->query); - } + $result = (new Builder()) + ->from('t') + ->setJsonPrepend('items', ['first']) + ->filter([Query::equal('id', [1])]) + ->update(); - public function testMaxWithAlias(): void - { - $result = (new Builder())->from('t')->max('price', 'highest')->build(); - $this->assertStringContainsString('AS `highest`', $result->query); + $this->assertStringContainsString('JSON_MERGE_PRESERVE(?, IFNULL(', $result->query); } - public function testMultipleSameAggregationType(): void + public function testSetJsonInsertWithIndex(): void { $result = (new Builder()) ->from('t') - ->count('id', 'count_id') - ->count('*', 'count_all') - ->build(); + ->setJsonInsert('items', 2, 'value') + ->filter([Query::equal('id', [1])]) + ->update(); - $this->assertEquals( - 'SELECT COUNT(`id`) AS `count_id`, COUNT(*) AS `count_all` FROM `t`', - $result->query - ); + $this->assertStringContainsString('JSON_ARRAY_INSERT', $result->query); + $this->assertContains('$[2]', $result->bindings); + $this->assertContains('value', $result->bindings); } - public function testAggregationStarAndNamedColumnMixed(): void + public function testFilterJsonNotContainsCompiles(): void { $result = (new Builder()) - ->from('t') - ->count('*', 'total') - ->sum('price', 'price_sum') - ->select(['category']) + ->from('docs') + ->filterJsonNotContains('meta', 'admin') ->build(); - $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); - $this->assertStringContainsString('SUM(`price`) AS `price_sum`', $result->query); - $this->assertStringContainsString('`category`', $result->query); + $this->assertStringContainsString('NOT JSON_CONTAINS(`meta`, ?)', $result->query); } - public function testAggregationFilterSortLimitCombined(): void + public function testFilterJsonOverlapsCompiles(): void { $result = (new Builder()) - ->from('orders') - ->count('*', 'cnt') - ->filter([Query::equal('status', ['paid'])]) - ->groupBy(['category']) - ->sortDesc('cnt') - ->limit(5) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'js']) ->build(); - $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); - $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); - $this->assertStringContainsString('GROUP BY `category`', $result->query); - $this->assertStringContainsString('ORDER BY `cnt` DESC', $result->query); - $this->assertStringContainsString('LIMIT ?', $result->query); - $this->assertEquals(['paid', 5], $result->bindings); + $this->assertStringContainsString('JSON_OVERLAPS(`tags`, ?)', $result->query); } - public function testAggregationJoinGroupByHavingSortLimitFullPipeline(): void + public function testFilterJsonPathCompiles(): void { $result = (new Builder()) - ->from('orders') - ->count('*', 'cnt') - ->sum('total', 'revenue') - ->select(['users.name']) - ->join('users', 'orders.user_id', 'users.id') - ->filter([Query::greaterThan('orders.total', 0)]) - ->groupBy(['users.name']) - ->having([Query::greaterThan('cnt', 2)]) - ->sortDesc('revenue') - ->limit(20) - ->offset(10) + ->from('users') + ->filterJsonPath('data', 'age', '>=', 21) ->build(); - $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); - $this->assertStringContainsString('SUM(`total`) AS `revenue`', $result->query); - $this->assertStringContainsString('JOIN `users`', $result->query); - $this->assertStringContainsString('WHERE `orders`.`total` > ?', $result->query); - $this->assertStringContainsString('GROUP BY `users`.`name`', $result->query); - $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); - $this->assertStringContainsString('ORDER BY `revenue` DESC', $result->query); - $this->assertStringContainsString('LIMIT ?', $result->query); - $this->assertStringContainsString('OFFSET ?', $result->query); - $this->assertEquals([0, 2, 20, 10], $result->bindings); + $this->assertStringContainsString("JSON_EXTRACT(`data`, '$.age') >= ?", $result->query); + $this->assertEquals(21, $result->bindings[0]); } - public function testAggregationWithAttributeResolver(): void + public function testMultipleHintsNoIcpAndBka(): void { $result = (new Builder()) ->from('t') - ->addHook(new AttributeMapHook([ - '$amount' => '_amount', - ])) - ->sum('$amount', 'total') + ->hint('NO_ICP(t)') + ->hint('BKA(t)') ->build(); - $this->assertEquals('SELECT SUM(`_amount`) AS `total` FROM `t`', $result->query); + $this->assertStringContainsString('/*+ NO_ICP(t) BKA(t) */', $result->query); } - public function testAggregationWithWrapChar(): void + public function testHintWithDistinct(): void { $result = (new Builder()) - ->setWrapChar('"') ->from('t') - ->avg('score', 'average') + ->distinct() + ->hint('SET_VAR(sort_buffer_size=16M)') ->build(); - $this->assertEquals('SELECT AVG("score") AS "average" FROM "t"', $result->query); + $this->assertStringContainsString('SELECT DISTINCT /*+', $result->query); } - public function testMinMaxWithStringColumns(): void + public function testHintPreservesBindings(): void { $result = (new Builder()) ->from('t') - ->min('name', 'first_name') - ->max('name', 'last_name') + ->hint('NO_ICP(t)') + ->filter([Query::equal('status', ['active'])]) ->build(); - $this->assertEquals( - 'SELECT MIN(`name`) AS `first_name`, MAX(`name`) AS `last_name` FROM `t`', - $result->query - ); + $this->assertEquals(['active'], $result->bindings); } - // ══════════════════════════════════════════ - // 8. Join edge cases - // ══════════════════════════════════════════ - - public function testSelfJoin(): void + public function testMaxExecutionTimeValue(): void { $result = (new Builder()) - ->from('employees') - ->join('employees', 'employees.manager_id', 'employees.id') + ->from('t') + ->maxExecutionTime(5000) ->build(); - $this->assertEquals( - 'SELECT * FROM `employees` JOIN `employees` ON `employees`.`manager_id` = `employees`.`id`', - $result->query - ); + $this->assertStringContainsString('/*+ MAX_EXECUTION_TIME(5000) */', $result->query); } - public function testJoinWithVeryLongTableAndColumnNames(): void + public function testSelectWindowWithPartitionOnly(): void { - $longTable = str_repeat('a', 100); - $longLeft = str_repeat('b', 100); - $longRight = str_repeat('c', 100); $result = (new Builder()) - ->from('main') - ->join($longTable, $longLeft, $longRight) + ->from('t') + ->selectWindow('SUM(amount)', 'total', ['dept']) ->build(); - $this->assertStringContainsString("JOIN `{$longTable}`", $result->query); - $this->assertStringContainsString("ON `{$longLeft}` = `{$longRight}`", $result->query); + $this->assertStringContainsString('SUM(amount) OVER (PARTITION BY `dept`) AS `total`', $result->query); } - public function testJoinFilterSortLimitOffsetCombined(): void + public function testSelectWindowWithOrderOnly(): void { $result = (new Builder()) - ->from('users') - ->join('orders', 'users.id', 'orders.user_id') - ->filter([ - Query::equal('orders.status', ['paid']), - Query::greaterThan('orders.total', 100), - ]) - ->sortDesc('orders.total') - ->limit(25) - ->offset(50) + ->from('t') + ->selectWindow('ROW_NUMBER()', 'rn', null, ['created_at']) ->build(); - $this->assertStringContainsString('JOIN `orders`', $result->query); - $this->assertStringContainsString('WHERE `orders`.`status` IN (?) AND `orders`.`total` > ?', $result->query); - $this->assertStringContainsString('ORDER BY `orders`.`total` DESC', $result->query); - $this->assertStringContainsString('LIMIT ?', $result->query); - $this->assertStringContainsString('OFFSET ?', $result->query); - $this->assertEquals(['paid', 100, 25, 50], $result->bindings); + $this->assertStringContainsString('ROW_NUMBER() OVER (ORDER BY `created_at` ASC) AS `rn`', $result->query); } - public function testJoinAggregationGroupByHavingCombined(): void + public function testSelectWindowNoPartitionNoOrderEmpty(): void { $result = (new Builder()) - ->from('orders') - ->count('*', 'cnt') - ->join('users', 'orders.user_id', 'users.id') - ->groupBy(['users.name']) - ->having([Query::greaterThan('cnt', 3)]) + ->from('t') + ->selectWindow('COUNT(*)', 'cnt') ->build(); - $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); - $this->assertStringContainsString('JOIN `users`', $result->query); - $this->assertStringContainsString('GROUP BY `users`.`name`', $result->query); - $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); - $this->assertEquals([3], $result->bindings); + $this->assertStringContainsString('COUNT(*) OVER () AS `cnt`', $result->query); } - public function testJoinWithDistinct(): void + public function testMultipleWindowFunctions(): void { $result = (new Builder()) - ->from('users') - ->distinct() - ->select(['users.name']) - ->join('orders', 'users.id', 'orders.user_id') + ->from('t') + ->selectWindow('ROW_NUMBER()', 'rn', null, ['id']) + ->selectWindow('SUM(amount)', 'running_total', null, ['id']) ->build(); - $this->assertStringContainsString('SELECT DISTINCT `users`.`name`', $result->query); - $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('ROW_NUMBER()', $result->query); + $this->assertStringContainsString('SUM(amount)', $result->query); } - public function testJoinWithUnion(): void + public function testSelectWindowWithDescOrder(): void { - $sub = (new Builder()) - ->from('archived_users') - ->join('archived_orders', 'archived_users.id', 'archived_orders.user_id'); - $result = (new Builder()) - ->from('users') - ->join('orders', 'users.id', 'orders.user_id') - ->union($sub) + ->from('t') + ->selectWindow('RANK()', 'r', null, ['-score']) ->build(); - $this->assertStringContainsString('JOIN `orders`', $result->query); - $this->assertStringContainsString('UNION', $result->query); - $this->assertStringContainsString('JOIN `archived_orders`', $result->query); + $this->assertStringContainsString('ORDER BY `score` DESC', $result->query); } - public function testFourJoins(): void + public function testCaseWithMultipleWhens(): void { - $result = (new Builder()) - ->from('orders') - ->join('users', 'orders.user_id', 'users.id') - ->leftJoin('products', 'orders.product_id', 'products.id') - ->rightJoin('categories', 'products.cat_id', 'categories.id') - ->crossJoin('promotions') + $case = (new CaseBuilder()) + ->when('x = ?', '?', [1], ['one']) + ->when('x = ?', '?', [2], ['two']) + ->when('x = ?', '?', [3], ['three']) ->build(); - $this->assertStringContainsString('JOIN `users`', $result->query); - $this->assertStringContainsString('LEFT JOIN `products`', $result->query); - $this->assertStringContainsString('RIGHT JOIN `categories`', $result->query); - $this->assertStringContainsString('CROSS JOIN `promotions`', $result->query); + $this->assertStringContainsString('WHEN x = ? THEN ?', $case->sql); + $this->assertEquals([1, 'one', 2, 'two', 3, 'three'], $case->bindings); } - public function testJoinWithAttributeResolverOnJoinColumns(): void + public function testCaseExpressionWithoutElseClause(): void { - $result = (new Builder()) - ->from('t') - ->addHook(new AttributeMapHook([ - '$id' => '_uid', - '$ref' => '_ref_id', - ])) - ->join('other', '$id', '$ref') + $case = (new CaseBuilder()) + ->when('x > ?', '1', [10]) + ->when('x < ?', '0', [0]) ->build(); - $this->assertEquals( - 'SELECT * FROM `t` JOIN `other` ON `_uid` = `_ref_id`', - $result->query - ); + $this->assertStringNotContainsString('ELSE', $case->sql); } - public function testCrossJoinCombinedWithFilter(): void + public function testCaseExpressionWithoutAliasClause(): void { - $result = (new Builder()) - ->from('sizes') - ->crossJoin('colors') - ->filter([Query::equal('sizes.active', [true])]) + $case = (new CaseBuilder()) + ->when('x = 1', "'yes'") ->build(); - $this->assertStringContainsString('CROSS JOIN `colors`', $result->query); - $this->assertStringContainsString('WHERE `sizes`.`active` IN (?)', $result->query); + $this->assertStringNotContainsString(' AS ', $case->sql); } - public function testCrossJoinFollowedByRegularJoin(): void + public function testSetCaseInUpdate(): void { - $result = (new Builder()) - ->from('a') - ->crossJoin('b') - ->join('c', 'a.id', 'c.a_id') + $case = (new CaseBuilder()) + ->when('age >= ?', '?', [18], ['adult']) + ->elseResult('?', ['minor']) ->build(); - $this->assertEquals( - 'SELECT * FROM `a` CROSS JOIN `b` JOIN `c` ON `a`.`id` = `c`.`a_id`', - $result->query - ); + $result = (new Builder()) + ->from('users') + ->setCase('status', $case) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringContainsString('UPDATE', $result->query); + $this->assertStringContainsString('CASE WHEN', $result->query); + $this->assertStringContainsString('END', $result->query); } - public function testMultipleJoinsWithFiltersOnEach(): void + public function testCaseBuilderThrowsWhenNoWhensAdded(): void { - $result = (new Builder()) - ->from('users') - ->join('orders', 'users.id', 'orders.user_id') - ->leftJoin('profiles', 'users.id', 'profiles.user_id') - ->filter([ - Query::greaterThan('orders.total', 50), - Query::isNotNull('profiles.avatar'), - ]) - ->build(); + $this->expectException(ValidationException::class); - $this->assertStringContainsString('JOIN `orders`', $result->query); - $this->assertStringContainsString('LEFT JOIN `profiles`', $result->query); - $this->assertStringContainsString('`orders`.`total` > ?', $result->query); - $this->assertStringContainsString('`profiles`.`avatar` IS NOT NULL', $result->query); + (new CaseBuilder())->build(); } - public function testJoinWithCustomOperatorLessThan(): void + public function testMultipleCTEsWithTwoSources(): void { + $cte1 = (new Builder())->from('orders'); + $cte2 = (new Builder())->from('returns'); + $result = (new Builder()) + ->with('a', $cte1) + ->with('b', $cte2) ->from('a') - ->join('b', 'a.start', 'b.end', '<') ->build(); - $this->assertEquals( - 'SELECT * FROM `a` JOIN `b` ON `a`.`start` < `b`.`end`', - $result->query - ); + $this->assertStringContainsString('WITH `a` AS', $result->query); + $this->assertStringContainsString('`b` AS', $result->query); } - public function testFiveJoins(): void + public function testCTEWithBindings(): void { + $cte = (new Builder())->from('orders')->filter([Query::equal('status', ['paid'])]); + $result = (new Builder()) - ->from('t1') - ->join('t2', 't1.id', 't2.t1_id') - ->join('t3', 't2.id', 't3.t2_id') - ->join('t4', 't3.id', 't4.t3_id') - ->join('t5', 't4.id', 't5.t4_id') - ->join('t6', 't5.id', 't6.t5_id') + ->with('paid_orders', $cte) + ->from('paid_orders') + ->filter([Query::greaterThan('amount', 100)]) ->build(); - $query = $result->query; - $this->assertEquals(5, substr_count($query, 'JOIN')); + // CTE bindings come BEFORE main query bindings + $this->assertEquals('paid', $result->bindings[0]); + $this->assertEquals(100, $result->bindings[1]); } - // ══════════════════════════════════════════ - // 9. Union edge cases - // ══════════════════════════════════════════ - - public function testUnionWithThreeSubQueries(): void + public function testCTEWithRecursiveMixed(): void { - $q1 = (new Builder())->from('a'); - $q2 = (new Builder())->from('b'); - $q3 = (new Builder())->from('c'); + $cte1 = (new Builder())->from('products'); + $cte2 = (new Builder())->from('categories'); $result = (new Builder()) - ->from('main') - ->union($q1) - ->union($q2) - ->union($q3) + ->with('prods', $cte1) + ->withRecursive('tree', $cte2) + ->from('tree') ->build(); - $this->assertEquals( - '(SELECT * FROM `main`) UNION (SELECT * FROM `a`) UNION (SELECT * FROM `b`) UNION (SELECT * FROM `c`)', - $result->query - ); + $this->assertStringStartsWith('WITH RECURSIVE', $result->query); + $this->assertStringContainsString('`prods` AS', $result->query); + $this->assertStringContainsString('`tree` AS', $result->query); } - public function testUnionAllWithThreeSubQueries(): void + public function testCTEResetClearedAfterBuild(): void { - $q1 = (new Builder())->from('a'); - $q2 = (new Builder())->from('b'); - $q3 = (new Builder())->from('c'); + $cte = (new Builder())->from('orders'); + $builder = (new Builder()) + ->with('o', $cte) + ->from('o'); - $result = (new Builder()) - ->from('main') - ->unionAll($q1) - ->unionAll($q2) - ->unionAll($q3) - ->build(); + $builder->reset(); - $this->assertEquals( - '(SELECT * FROM `main`) UNION ALL (SELECT * FROM `a`) UNION ALL (SELECT * FROM `b`) UNION ALL (SELECT * FROM `c`)', - $result->query - ); + $result = $builder->from('users')->build(); + $this->assertStringNotContainsString('WITH', $result->query); } - public function testMixedUnionAndUnionAllWithThreeSubQueries(): void + public function testInsertSelectWithFilter(): void { - $q1 = (new Builder())->from('a'); - $q2 = (new Builder())->from('b'); - $q3 = (new Builder())->from('c'); + $source = (new Builder()) + ->from('users') + ->select(['name', 'email']) + ->filter([Query::equal('status', ['active'])]); $result = (new Builder()) - ->from('main') - ->union($q1) - ->unionAll($q2) - ->union($q3) - ->build(); + ->into('archive') + ->fromSelect(['name', 'email'], $source) + ->insertSelect(); - $this->assertEquals( - '(SELECT * FROM `main`) UNION (SELECT * FROM `a`) UNION ALL (SELECT * FROM `b`) UNION (SELECT * FROM `c`)', - $result->query - ); + $this->assertStringContainsString('INSERT INTO `archive`', $result->query); + $this->assertEquals(['active'], $result->bindings); } - public function testUnionWhereSubQueryHasJoins(): void + public function testInsertSelectThrowsWithoutSource(): void { - $sub = (new Builder()) - ->from('archived_users') - ->join('archived_orders', 'archived_users.id', 'archived_orders.user_id'); - - $result = (new Builder()) - ->from('users') - ->union($sub) - ->build(); + $this->expectException(ValidationException::class); - $this->assertStringContainsString( - 'UNION (SELECT * FROM `archived_users` JOIN `archived_orders`', - $result->query - ); + (new Builder()) + ->into('archive') + ->insertSelect(); } - public function testUnionWhereSubQueryHasAggregation(): void + public function testInsertSelectThrowsWithoutColumns(): void { - $sub = (new Builder()) - ->from('orders_2023') - ->count('*', 'cnt') - ->groupBy(['status']); + $this->expectException(ValidationException::class); - $result = (new Builder()) - ->from('orders_2024') - ->count('*', 'cnt') - ->groupBy(['status']) - ->union($sub) - ->build(); + $source = (new Builder())->from('users'); - $this->assertStringContainsString('UNION (SELECT COUNT(*) AS `cnt` FROM `orders_2023` GROUP BY `status`)', $result->query); + (new Builder()) + ->into('archive') + ->fromSelect([], $source) + ->insertSelect(); } - public function testUnionWhereSubQueryHasSortAndLimit(): void + public function testInsertSelectMultipleColumns(): void { - $sub = (new Builder()) - ->from('archive') - ->sortDesc('created_at') - ->limit(10); + $source = (new Builder()) + ->from('users') + ->select(['name', 'email', 'age']); $result = (new Builder()) - ->from('current') - ->union($sub) - ->build(); + ->into('archive') + ->fromSelect(['name', 'email', 'age'], $source) + ->insertSelect(); - $this->assertStringContainsString('UNION (SELECT * FROM `archive` ORDER BY `created_at` DESC LIMIT ?)', $result->query); + $this->assertStringContainsString('`name`', $result->query); + $this->assertStringContainsString('`email`', $result->query); + $this->assertStringContainsString('`age`', $result->query); } - public function testUnionWithConditionProviders(): void + public function testUnionAllCompiles(): void { - $sub = (new Builder()) - ->from('other') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('org = ?', ['org2']); - } - }); - + $other = (new Builder())->from('archive'); $result = (new Builder()) - ->from('main') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('org = ?', ['org1']); - } - }) - ->union($sub) + ->from('current') + ->unionAll($other) ->build(); - $this->assertStringContainsString('WHERE org = ?', $result->query); - $this->assertStringContainsString('UNION (SELECT * FROM `other` WHERE org = ?)', $result->query); - $this->assertEquals(['org1', 'org2'], $result->bindings); + $this->assertStringContainsString('UNION ALL', $result->query); } - public function testUnionBindingOrderWithComplexSubQueries(): void + public function testIntersectCompiles(): void { - $sub = (new Builder()) - ->from('archive') - ->filter([Query::equal('year', [2023])]) - ->limit(5); - + $other = (new Builder())->from('admins'); $result = (new Builder()) - ->from('current') - ->filter([Query::equal('status', ['active'])]) - ->limit(10) - ->union($sub) + ->from('users') + ->intersect($other) ->build(); - $this->assertEquals(['active', 10, 2023, 5], $result->bindings); + $this->assertStringContainsString('INTERSECT', $result->query); } - public function testUnionWithDistinct(): void + public function testIntersectAllCompiles(): void { - $sub = (new Builder()) - ->from('archive') - ->distinct() - ->select(['name']); - + $other = (new Builder())->from('admins'); $result = (new Builder()) - ->from('current') - ->distinct() - ->select(['name']) - ->union($sub) + ->from('users') + ->intersectAll($other) ->build(); - $this->assertStringContainsString('SELECT DISTINCT `name` FROM `current`', $result->query); - $this->assertStringContainsString('UNION (SELECT DISTINCT `name` FROM `archive`)', $result->query); + $this->assertStringContainsString('INTERSECT ALL', $result->query); } - public function testUnionWithWrapChar(): void + public function testExceptCompiles(): void { - $sub = (new Builder()) - ->setWrapChar('"') - ->from('archive'); - + $other = (new Builder())->from('banned'); $result = (new Builder()) - ->setWrapChar('"') - ->from('current') - ->union($sub) + ->from('users') + ->except($other) ->build(); - $this->assertEquals( - '(SELECT * FROM "current") UNION (SELECT * FROM "archive")', - $result->query - ); + $this->assertStringContainsString('EXCEPT', $result->query); } - public function testUnionAfterReset(): void + public function testExceptAllCompiles(): void { - $builder = (new Builder())->from('old'); - $builder->build(); - $builder->reset(); - - $sub = (new Builder())->from('other'); - $result = $builder->from('fresh')->union($sub)->build(); + $other = (new Builder())->from('banned'); + $result = (new Builder()) + ->from('users') + ->exceptAll($other) + ->build(); - $this->assertEquals( - '(SELECT * FROM `fresh`) UNION (SELECT * FROM `other`)', - $result->query - ); + $this->assertStringContainsString('EXCEPT ALL', $result->query); } - public function testUnionChainedWithComplexBindings(): void + public function testUnionWithBindings(): void { - $q1 = (new Builder()) - ->from('a') - ->filter([Query::equal('x', [1]), Query::greaterThan('y', 2)]); - $q2 = (new Builder()) - ->from('b') - ->filter([Query::between('z', 10, 20)]); - + $other = (new Builder())->from('admins')->filter([Query::equal('role', ['admin'])]); $result = (new Builder()) - ->from('main') + ->from('users') ->filter([Query::equal('status', ['active'])]) - ->union($q1) - ->unionAll($q2) + ->union($other) ->build(); - $this->assertEquals(['active', 1, 2, 10, 20], $result->bindings); + $this->assertEquals(['active', 'admin'], $result->bindings); } - public function testUnionWithFourSubQueries(): void + public function testPageThreeWithTen(): void { - $q1 = (new Builder())->from('t1'); - $q2 = (new Builder())->from('t2'); - $q3 = (new Builder())->from('t3'); - $q4 = (new Builder())->from('t4'); - $result = (new Builder()) - ->from('main') - ->union($q1) - ->union($q2) - ->union($q3) - ->union($q4) + ->from('t') + ->page(3, 10) ->build(); - $this->assertEquals(4, substr_count($result->query, 'UNION')); + $this->assertStringContainsString('LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([10, 20], $result->bindings); } - public function testUnionAllWithFilteredSubQueries(): void + public function testPageFirstPage(): void { - $q1 = (new Builder())->from('orders_2022')->filter([Query::equal('status', ['paid'])]); - $q2 = (new Builder())->from('orders_2023')->filter([Query::equal('status', ['paid'])]); - $q3 = (new Builder())->from('orders_2024')->filter([Query::equal('status', ['paid'])]); - $result = (new Builder()) - ->from('orders_2025') - ->filter([Query::equal('status', ['paid'])]) - ->unionAll($q1) - ->unionAll($q2) - ->unionAll($q3) + ->from('t') + ->page(1, 25) ->build(); - $this->assertEquals(['paid', 'paid', 'paid', 'paid'], $result->bindings); - $this->assertEquals(3, substr_count($result->query, 'UNION ALL')); + $this->assertStringContainsString('LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([25, 0], $result->bindings); } - // ══════════════════════════════════════════ - // 10. toRawSql edge cases - // ══════════════════════════════════════════ - - public function testToRawSqlWithAllBindingTypesInOneQuery(): void + public function testCursorAfterWithSort(): void { - $sql = (new Builder()) + $result = (new Builder()) ->from('t') - ->filter([ - Query::equal('name', ['Alice']), - Query::greaterThan('age', 18), - Query::raw('active = ?', [true]), - Query::raw('deleted = ?', [null]), - Query::raw('score > ?', [9.5]), - ]) + ->sortAsc('id') + ->cursorAfter(5) ->limit(10) - ->toRawSql(); + ->build(); - $this->assertStringContainsString("'Alice'", $sql); - $this->assertStringContainsString('18', $sql); - $this->assertStringContainsString('= 1', $sql); - $this->assertStringContainsString('= NULL', $sql); - $this->assertStringContainsString('9.5', $sql); - $this->assertStringContainsString('10', $sql); - $this->assertStringNotContainsString('?', $sql); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertContains(5, $result->bindings); + $this->assertContains(10, $result->bindings); } - public function testToRawSqlWithEmptyStringBinding(): void + public function testCursorBeforeWithSort(): void { - $sql = (new Builder()) + $result = (new Builder()) ->from('t') - ->filter([Query::equal('name', [''])]) - ->toRawSql(); + ->sortAsc('id') + ->cursorBefore(5) + ->limit(10) + ->build(); - $this->assertStringContainsString("''", $sql); + $this->assertStringContainsString('`_cursor` < ?', $result->query); + $this->assertContains(5, $result->bindings); + $this->assertContains(10, $result->bindings); } - public function testToRawSqlWithStringContainingSingleQuotes(): void + public function testToRawSqlWithStrings(): void { $sql = (new Builder()) ->from('t') - ->filter([Query::equal('name', ["O'Brien"])]) + ->filter([Query::equal('name', ['Alice'])]) ->toRawSql(); - $this->assertStringContainsString("O''Brien", $sql); + $this->assertStringContainsString("'Alice'", $sql); } - public function testToRawSqlWithVeryLargeNumber(): void + public function testToRawSqlWithIntegers(): void { $sql = (new Builder()) ->from('t') - ->filter([Query::greaterThan('id', 99999999999)]) + ->filter([Query::greaterThan('age', 30)]) ->toRawSql(); - $this->assertStringContainsString('99999999999', $sql); - $this->assertStringNotContainsString('?', $sql); + $this->assertStringContainsString('30', $sql); + $this->assertStringNotContainsString("'30'", $sql); } - public function testToRawSqlWithNegativeNumber(): void + public function testToRawSqlWithNullValue(): void { $sql = (new Builder()) ->from('t') - ->filter([Query::lessThan('balance', -500)]) + ->filter([Query::raw('deleted_at = ?', [null])]) ->toRawSql(); - $this->assertStringContainsString('-500', $sql); + $this->assertStringContainsString('NULL', $sql); } - public function testToRawSqlWithZero(): void + public function testToRawSqlWithBooleans(): void { - $sql = (new Builder()) + $sqlTrue = (new Builder()) ->from('t') - ->filter([Query::equal('count', [0])]) + ->filter([Query::raw('active = ?', [true])]) ->toRawSql(); - $this->assertStringContainsString('IN (0)', $sql); - $this->assertStringNotContainsString('?', $sql); - } - - public function testToRawSqlWithFalseBoolean(): void - { - $sql = (new Builder()) + $sqlFalse = (new Builder()) ->from('t') ->filter([Query::raw('active = ?', [false])]) ->toRawSql(); - $this->assertStringContainsString('active = 0', $sql); + $this->assertStringContainsString('= 1', $sqlTrue); + $this->assertStringContainsString('= 0', $sqlFalse); } - public function testToRawSqlWithMultipleNullBindings(): void + public function testWhenTrueAppliesLimit(): void { - $sql = (new Builder()) + $result = (new Builder()) ->from('t') - ->filter([Query::raw('a = ? AND b = ?', [null, null])]) - ->toRawSql(); + ->when(true, fn (Builder $b) => $b->limit(5)) + ->build(); - $this->assertEquals("SELECT * FROM `t` WHERE a = NULL AND b = NULL", $sql); + $this->assertStringContainsString('LIMIT', $result->query); } - public function testToRawSqlWithAggregationQuery(): void + public function testWhenFalseSkipsLimit(): void { - $sql = (new Builder()) - ->from('orders') - ->count('*', 'total') - ->groupBy(['status']) - ->having([Query::greaterThan('total', 5)]) - ->toRawSql(); + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->limit(5)) + ->build(); - $this->assertStringContainsString('COUNT(*) AS `total`', $sql); - $this->assertStringContainsString('HAVING `total` > 5', $sql); - $this->assertStringNotContainsString('?', $sql); + $this->assertStringNotContainsString('LIMIT', $result->query); } - public function testToRawSqlWithJoinQuery(): void + public function testBuildWithoutTableThrows(): void { - $sql = (new Builder()) - ->from('users') - ->join('orders', 'users.id', 'orders.uid') - ->filter([Query::greaterThan('orders.total', 100)]) - ->toRawSql(); + $this->expectException(ValidationException::class); - $this->assertStringContainsString('JOIN `orders`', $sql); - $this->assertStringContainsString('100', $sql); - $this->assertStringNotContainsString('?', $sql); + (new Builder())->build(); } - public function testToRawSqlWithUnionQuery(): void + public function testInsertWithoutRowsThrows(): void { - $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $this->expectException(ValidationException::class); - $sql = (new Builder()) - ->from('current') - ->filter([Query::equal('year', [2024])]) - ->union($sub) - ->toRawSql(); + (new Builder())->into('t')->insert(); + } - $this->assertStringContainsString('2024', $sql); - $this->assertStringContainsString('2023', $sql); - $this->assertStringContainsString('UNION', $sql); - $this->assertStringNotContainsString('?', $sql); + public function testInsertWithEmptyRowThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->into('t')->set([])->insert(); } - public function testToRawSqlWithRegexAndSearch(): void + public function testUpdateWithoutAssignmentsThrows(): void { - $sql = (new Builder()) - ->from('t') - ->filter([ - Query::regex('slug', '^test'), - Query::search('content', 'hello'), - ]) - ->toRawSql(); + $this->expectException(ValidationException::class); - $this->assertStringContainsString("REGEXP '^test'", $sql); - $this->assertStringContainsString("AGAINST('hello')", $sql); - $this->assertStringNotContainsString('?', $sql); + (new Builder())->from('t')->update(); } - public function testToRawSqlCalledTwiceGivesSameResult(): void + public function testUpsertWithoutConflictKeysThrowsValidation(): void { - $builder = (new Builder()) - ->from('t') - ->filter([Query::equal('status', ['active'])]) - ->limit(10); + $this->expectException(ValidationException::class); - $sql1 = $builder->toRawSql(); - $sql2 = $builder->toRawSql(); + (new Builder()) + ->into('t') + ->set(['id' => 1, 'name' => 'Alice']) + ->upsert(); + } - $this->assertEquals($sql1, $sql2); + public function testBatchInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('t') + ->set(['a' => 1, 'b' => 2]) + ->set(['a' => 3, 'b' => 4]) + ->insert(); + + $this->assertStringContainsString('VALUES (?, ?), (?, ?)', $result->query); + $this->assertEquals([1, 2, 3, 4], $result->bindings); } - public function testToRawSqlWithWrapChar(): void + public function testBatchInsertMismatchedColumnsThrows(): void { - $sql = (new Builder()) - ->setWrapChar('"') - ->from('t') - ->filter([Query::equal('status', ['active'])]) - ->toRawSql(); + $this->expectException(ValidationException::class); - $this->assertEquals("SELECT * FROM \"t\" WHERE \"status\" IN ('active')", $sql); + (new Builder()) + ->into('t') + ->set(['a' => 1, 'b' => 2]) + ->set(['a' => 3, 'c' => 4]) + ->insert(); } - // ══════════════════════════════════════════ - // 11. when() edge cases - // ══════════════════════════════════════════ + public function testEmptyColumnNameThrows(): void + { + $this->expectException(ValidationException::class); - public function testWhenWithComplexCallbackAddingMultipleFeatures(): void + (new Builder()) + ->into('t') + ->set(['' => 'val']) + ->insert(); + } + + public function testSearchNotCompiles(): void { $result = (new Builder()) ->from('t') - ->when(true, function (Builder $b) { - $b->filter([Query::equal('status', ['active'])]) - ->sortAsc('name') - ->limit(10); - }) + ->filter([Query::notSearch('body', 'spam')]) ->build(); - $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); - $this->assertStringContainsString('ORDER BY `name` ASC', $result->query); - $this->assertStringContainsString('LIMIT ?', $result->query); - $this->assertEquals(['active', 10], $result->bindings); + $this->assertStringContainsString('NOT (MATCH(`body`) AGAINST(?))', $result->query); } - public function testWhenChainedFiveTimes(): void + public function testRegexpCompiles(): void { $result = (new Builder()) ->from('t') - ->when(true, fn (Builder $b) => $b->filter([Query::equal('a', [1])])) - ->when(true, fn (Builder $b) => $b->filter([Query::equal('b', [2])])) - ->when(false, fn (Builder $b) => $b->filter([Query::equal('c', [3])])) - ->when(true, fn (Builder $b) => $b->filter([Query::equal('d', [4])])) - ->when(true, fn (Builder $b) => $b->filter([Query::equal('e', [5])])) + ->filter([Query::regex('slug', '^test')]) ->build(); - $this->assertEquals( - 'SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?) AND `d` IN (?) AND `e` IN (?)', - $result->query - ); - $this->assertEquals([1, 2, 4, 5], $result->bindings); + $this->assertStringContainsString('`slug` REGEXP ?', $result->query); } - public function testWhenInsideWhenThreeLevelsDeep(): void + public function testUpsertUsesOnDuplicateKey(): void { $result = (new Builder()) - ->from('t') - ->when(true, function (Builder $b) { - $b->when(true, function (Builder $b2) { - $b2->when(true, fn (Builder $b3) => $b3->filter([Query::equal('deep', [1])])); - }); - }) + ->into('t') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], ['name']) + ->upsert(); + + $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $result->query); + } + + public function testForUpdateCompiles(): void + { + $result = (new Builder()) + ->from('accounts') + ->forUpdate() ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `deep` IN (?)', $result->query); - $this->assertEquals([1], $result->bindings); + $this->assertStringEndsWith('FOR UPDATE', $result->query); } - public function testWhenThatAddsJoins(): void + public function testForShareCompiles(): void { $result = (new Builder()) - ->from('users') - ->when(true, fn (Builder $b) => $b->join('orders', 'users.id', 'orders.uid')) + ->from('accounts') + ->forShare() ->build(); - $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringEndsWith('FOR SHARE', $result->query); } - public function testWhenThatAddsAggregations(): void + public function testForUpdateWithFilters(): void { $result = (new Builder()) - ->from('t') - ->when(true, fn (Builder $b) => $b->count('*', 'total')->groupBy(['status'])) + ->from('accounts') + ->filter([Query::equal('id', [1])]) + ->forUpdate() ->build(); - $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); - $this->assertStringContainsString('GROUP BY `status`', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringEndsWith('FOR UPDATE', $result->query); } - public function testWhenThatAddsUnions(): void + public function testBeginTransaction(): void { - $sub = (new Builder())->from('archive'); + $result = (new Builder())->begin(); + $this->assertEquals('BEGIN', $result->query); + } - $result = (new Builder()) + public function testCommitTransaction(): void + { + $result = (new Builder())->commit(); + $this->assertEquals('COMMIT', $result->query); + } + + public function testRollbackTransaction(): void + { + $result = (new Builder())->rollback(); + $this->assertEquals('ROLLBACK', $result->query); + } + + public function testReleaseSavepointCompiles(): void + { + $result = (new Builder())->releaseSavepoint('sp1'); + $this->assertEquals('RELEASE SAVEPOINT `sp1`', $result->query); + } + + public function testResetClearsCTEs(): void + { + $cte = (new Builder())->from('orders'); + $builder = (new Builder()) + ->with('o', $cte) + ->from('o'); + + $builder->reset(); + + $result = $builder->from('items')->build(); + $this->assertStringNotContainsString('WITH', $result->query); + } + + public function testResetClearsUnionsComprehensive(): void + { + $other = (new Builder())->from('archive'); + $builder = (new Builder()) ->from('current') - ->when(true, fn (Builder $b) => $b->union($sub)) + ->union($other); + + $builder->reset(); + + $result = $builder->from('items')->build(); + $this->assertStringNotContainsString('UNION', $result->query); + } + + public function testGroupByWithHavingCount(): void + { + $result = (new Builder()) + ->from('employees') + ->count('*', 'cnt') + ->groupBy(['dept']) + ->having([Query::and([Query::greaterThan('COUNT(*)', 5)])]) ->build(); - $this->assertStringContainsString('UNION', $result->query); + $this->assertStringContainsString('GROUP BY', $result->query); + $this->assertStringContainsString('HAVING', $result->query); } - public function testWhenFalseDoesNotAffectFilters(): void + public function testGroupByMultipleColumnsAB(): void { $result = (new Builder()) ->from('t') - ->when(false, fn (Builder $b) => $b->filter([Query::equal('status', ['banned'])])) + ->count('*', 'total') + ->groupBy(['a', 'b']) ->build(); - $this->assertEquals('SELECT * FROM `t`', $result->query); - $this->assertEquals([], $result->bindings); + $this->assertStringContainsString('GROUP BY `a`, `b`', $result->query); } - public function testWhenFalseDoesNotAffectJoins(): void + public function testEqualEmptyArrayReturnsFalse(): void { $result = (new Builder()) ->from('t') - ->when(false, fn (Builder $b) => $b->join('other', 'a', 'b')) + ->filter([Query::equal('x', [])]) ->build(); - $this->assertStringNotContainsString('JOIN', $result->query); + $this->assertStringContainsString('1 = 0', $result->query); } - public function testWhenFalseDoesNotAffectAggregations(): void + public function testEqualWithNullOnlyCompileIn(): void { $result = (new Builder()) ->from('t') - ->when(false, fn (Builder $b) => $b->count('*', 'total')) + ->filter([Query::equal('x', [null])]) ->build(); - $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertStringContainsString('`x` IS NULL', $result->query); + $this->assertEquals([], $result->bindings); } - public function testWhenFalseDoesNotAffectSort(): void + public function testEqualWithNullAndValues(): void { $result = (new Builder()) ->from('t') - ->when(false, fn (Builder $b) => $b->sortAsc('name')) + ->filter([Query::equal('x', [1, null])]) ->build(); - $this->assertStringNotContainsString('ORDER BY', $result->query); + $this->assertStringContainsString('(`x` IN (?) OR `x` IS NULL)', $result->query); + $this->assertEquals([1], $result->bindings); } - // ══════════════════════════════════════════ - // 12. Condition provider edge cases - // ══════════════════════════════════════════ - - public function testThreeConditionProviders(): void + public function testEqualMultipleValues(): void { $result = (new Builder()) ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('p1 = ?', ['v1']); - } - }) - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('p2 = ?', ['v2']); - } - }) - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('p3 = ?', ['v3']); - } - }) + ->filter([Query::equal('x', [1, 2, 3])]) ->build(); - $this->assertEquals( - 'SELECT * FROM `t` WHERE p1 = ? AND p2 = ? AND p3 = ?', - $result->query - ); - $this->assertEquals(['v1', 'v2', 'v3'], $result->bindings); + $this->assertStringContainsString('`x` IN (?, ?, ?)', $result->query); + $this->assertEquals([1, 2, 3], $result->bindings); } - public function testProviderReturningEmptyConditionString(): void + public function testNotEqualEmptyArrayReturnsTrue(): void { $result = (new Builder()) ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('', []); - } - }) + ->filter([Query::notEqual('x', [])]) ->build(); - // Empty string still appears as a WHERE clause element - $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('1 = 1', $result->query); } - public function testProviderWithManyBindings(): void + public function testNotEqualSingleValue(): void { $result = (new Builder()) ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('a IN (?, ?, ?, ?, ?)', [1, 2, 3, 4, 5]); - } - }) + ->filter([Query::notEqual('x', 5)]) ->build(); - $this->assertEquals( - 'SELECT * FROM `t` WHERE a IN (?, ?, ?, ?, ?)', - $result->query - ); - $this->assertEquals([1, 2, 3, 4, 5], $result->bindings); + $this->assertStringContainsString('`x` != ?', $result->query); + $this->assertEquals([5], $result->bindings); } - public function testProviderCombinedWithCursorFilterHaving(): void + public function testNotEqualWithNullOnlyCompileNotIn(): void { $result = (new Builder()) ->from('t') - ->count('*', 'cnt') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('org = ?', ['org1']); - } - }) - ->filter([Query::equal('status', ['active'])]) - ->cursorAfter('cur1') - ->groupBy(['status']) - ->having([Query::greaterThan('cnt', 5)]) + ->filter([Query::notEqual('x', [null])]) ->build(); - $this->assertStringContainsString('WHERE', $result->query); - $this->assertStringContainsString('HAVING', $result->query); - // filter, provider, cursor, having - $this->assertEquals(['active', 'org1', 'cur1', 5], $result->bindings); + $this->assertStringContainsString('`x` IS NOT NULL', $result->query); + $this->assertEquals([], $result->bindings); } - public function testProviderCombinedWithJoins(): void + public function testNotEqualWithNullAndValues(): void { $result = (new Builder()) - ->from('users') - ->join('orders', 'users.id', 'orders.uid') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('tenant = ?', ['t1']); - } - }) + ->from('t') + ->filter([Query::notEqual('x', [1, null])]) ->build(); - $this->assertStringContainsString('JOIN `orders`', $result->query); - $this->assertStringContainsString('WHERE tenant = ?', $result->query); - $this->assertEquals(['t1'], $result->bindings); + $this->assertStringContainsString('(`x` != ? AND `x` IS NOT NULL)', $result->query); + $this->assertEquals([1], $result->bindings); } - public function testProviderCombinedWithUnions(): void + public function testNotEqualMultipleValues(): void { - $sub = (new Builder())->from('archive'); - $result = (new Builder()) - ->from('current') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('org = ?', ['org1']); - } - }) - ->union($sub) + ->from('t') + ->filter([Query::notEqual('x', [1, 2, 3])]) ->build(); - $this->assertStringContainsString('WHERE org = ?', $result->query); - $this->assertStringContainsString('UNION', $result->query); - $this->assertEquals(['org1'], $result->bindings); + $this->assertStringContainsString('`x` NOT IN (?, ?, ?)', $result->query); + $this->assertEquals([1, 2, 3], $result->bindings); } - public function testProviderCombinedWithAggregations(): void + public function testNotEqualSingleNonNull(): void { $result = (new Builder()) - ->from('orders') - ->count('*', 'total') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('org = ?', ['org1']); - } - }) - ->groupBy(['status']) + ->from('t') + ->filter([Query::notEqual('x', 42)]) ->build(); - $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); - $this->assertStringContainsString('WHERE org = ?', $result->query); + $this->assertStringContainsString('`x` != ?', $result->query); + $this->assertEquals([42], $result->bindings); } - public function testProviderReferencesTableName(): void + public function testBetweenFilter(): void { $result = (new Builder()) - ->from('users') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition("EXISTS (SELECT 1 FROM {$table}_perms WHERE type = ?)", ['read']); - } - }) + ->from('t') + ->filter([Query::between('age', 18, 65)]) ->build(); - $this->assertStringContainsString('users_perms', $result->query); - $this->assertEquals(['read'], $result->bindings); + $this->assertStringContainsString('`age` BETWEEN ? AND ?', $result->query); + $this->assertEquals([18, 65], $result->bindings); } - public function testProviderWithWrapCharProviderSqlIsLiteral(): void + public function testNotBetweenFilter(): void { $result = (new Builder()) - ->setWrapChar('"') ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('raw_col = ?', [1]); - } - }) + ->filter([Query::notBetween('score', 0, 50)]) ->build(); - // Provider SQL is NOT wrapped - only the FROM clause is - $this->assertStringContainsString('FROM "t"', $result->query); - $this->assertStringContainsString('raw_col = ?', $result->query); + $this->assertStringContainsString('`score` NOT BETWEEN ? AND ?', $result->query); + $this->assertEquals([0, 50], $result->bindings); } - public function testProviderBindingOrderWithComplexQuery(): void + public function testBetweenWithStrings(): void { $result = (new Builder()) ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('p1 = ?', ['pv1']); - } - }) - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('p2 = ?', ['pv2']); - } - }) - ->filter([ - Query::equal('a', ['va']), - Query::greaterThan('b', 10), - ]) - ->cursorAfter('cur') - ->limit(5) - ->offset(10) + ->filter([Query::between('date', '2024-01-01', '2024-12-31')]) ->build(); - // filter, provider1, provider2, cursor, limit, offset - $this->assertEquals(['va', 10, 'pv1', 'pv2', 'cur', 5, 10], $result->bindings); + $this->assertStringContainsString('`date` BETWEEN ? AND ?', $result->query); + $this->assertEquals(['2024-01-01', '2024-12-31'], $result->bindings); } - public function testProviderPreservedAcrossReset(): void + public function testAndWithTwoFilters(): void { - $builder = (new Builder()) + $result = (new Builder()) ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('org = ?', ['org1']); - } - }); - - $builder->build(); - $builder->reset(); + ->filter([Query::and([Query::greaterThan('age', 18), Query::lessThan('age', 65)])]) + ->build(); - $result = $builder->from('t2')->build(); - $this->assertStringContainsString('WHERE org = ?', $result->query); - $this->assertEquals(['org1'], $result->bindings); + $this->assertStringContainsString('(`age` > ? AND `age` < ?)', $result->query); + $this->assertEquals([18, 65], $result->bindings); } - public function testFourConditionProviders(): void + public function testOrWithTwoFilters(): void { $result = (new Builder()) ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('a = ?', [1]); - } - }) - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('b = ?', [2]); - } - }) - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('c = ?', [3]); - } - }) - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('d = ?', [4]); - } - }) + ->filter([Query::or([Query::equal('role', ['admin']), Query::equal('role', ['mod'])])]) ->build(); - $this->assertEquals( - 'SELECT * FROM `t` WHERE a = ? AND b = ? AND c = ? AND d = ?', - $result->query - ); - $this->assertEquals([1, 2, 3, 4], $result->bindings); + $this->assertStringContainsString('(`role` IN (?) OR `role` IN (?))', $result->query); + $this->assertEquals(['admin', 'mod'], $result->bindings); + } + + public function testNestedAndInsideOr(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::and([Query::greaterThan('a', 1), Query::lessThan('b', 2)]), + Query::equal('c', [3]), + ]), + ]) + ->build(); + + $this->assertStringContainsString('((`a` > ? AND `b` < ?) OR `c` IN (?))', $result->query); + $this->assertEquals([1, 2, 3], $result->bindings); } - public function testProviderWithNoBindings(): void + public function testEmptyAndReturnsTrue(): void { $result = (new Builder()) ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('1 = 1', []); - } - }) + ->filter([Query::and([])]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result->query); - $this->assertEquals([], $result->bindings); + $this->assertStringContainsString('1 = 1', $result->query); } - // ══════════════════════════════════════════ - // 13. Reset edge cases - // ══════════════════════════════════════════ - - public function testResetPreservesAttributeResolver(): void + public function testEmptyOrReturnsFalse(): void { - $builder = (new Builder()) + $result = (new Builder()) ->from('t') - ->addHook(new class () implements \Utopia\Query\Hook\AttributeHook { - public function resolve(string $attribute): string - { - return '_' . $attribute; - } - }) - ->filter([Query::equal('x', [1])]); - - $builder->build(); - $builder->reset(); + ->filter([Query::or([])]) + ->build(); - $result = $builder->from('t2')->filter([Query::equal('y', [2])])->build(); - $this->assertStringContainsString('`_y`', $result->query); + $this->assertStringContainsString('1 = 0', $result->query); } - public function testResetPreservesConditionProviders(): void + public function testExistsSingleAttribute(): void { - $builder = (new Builder()) + $result = (new Builder()) ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('org = ?', ['org1']); - } - }); - - $builder->build(); - $builder->reset(); + ->filter([Query::exists(['name'])]) + ->build(); - $result = $builder->from('t2')->build(); - $this->assertStringContainsString('org = ?', $result->query); - $this->assertEquals(['org1'], $result->bindings); + $this->assertStringContainsString('(`name` IS NOT NULL)', $result->query); + $this->assertEquals([], $result->bindings); } - public function testResetPreservesWrapChar(): void + public function testExistsMultipleAttributes(): void { - $builder = (new Builder()) + $result = (new Builder()) ->from('t') - ->setWrapChar('"'); - - $builder->build(); - $builder->reset(); + ->filter([Query::exists(['name', 'email'])]) + ->build(); - $result = $builder->from('t2')->select(['name'])->build(); - $this->assertEquals('SELECT "name" FROM "t2"', $result->query); + $this->assertStringContainsString('(`name` IS NOT NULL AND `email` IS NOT NULL)', $result->query); + $this->assertEquals([], $result->bindings); } - public function testResetClearsPendingQueries(): void + public function testNotExistsSingleAttribute(): void { - $builder = (new Builder()) + $result = (new Builder()) ->from('t') - ->filter([Query::equal('a', [1])]) - ->sortAsc('name') - ->limit(10); - - $builder->build(); - $builder->reset(); + ->filter([Query::notExists('name')]) + ->build(); - $result = $builder->from('t2')->build(); - $this->assertEquals('SELECT * FROM `t2`', $result->query); + $this->assertStringContainsString('(`name` IS NULL)', $result->query); $this->assertEquals([], $result->bindings); } - public function testResetClearsBindings(): void + public function testNotExistsMultipleAttributes(): void { - $builder = (new Builder()) + $result = (new Builder()) ->from('t') - ->filter([Query::equal('a', [1])]); - - $builder->build(); - $this->assertNotEmpty($builder->getBindings()); + ->filter([Query::notExists(['a', 'b'])]) + ->build(); - $builder->reset(); - $result = $builder->from('t2')->build(); + $this->assertStringContainsString('(`a` IS NULL AND `b` IS NULL)', $result->query); $this->assertEquals([], $result->bindings); } - public function testResetClearsTable(): void + public function testRawFilterWithSql(): void { - $builder = (new Builder())->from('old_table'); - $builder->build(); - $builder->reset(); + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('score > ?', [10])]) + ->build(); - $result = $builder->from('new_table')->build(); - $this->assertStringContainsString('`new_table`', $result->query); - $this->assertStringNotContainsString('`old_table`', $result->query); + $this->assertStringContainsString('score > ?', $result->query); + $this->assertContains(10, $result->bindings); } - public function testResetClearsUnionsAfterBuild(): void + public function testRawFilterWithoutBindings(): void { - $sub = (new Builder())->from('other'); - $builder = (new Builder())->from('main')->union($sub); - $builder->build(); - $builder->reset(); + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('active = 1')]) + ->build(); - $result = $builder->from('fresh')->build(); - $this->assertStringNotContainsString('UNION', $result->query); + $this->assertStringContainsString('active = 1', $result->query); + $this->assertEquals([], $result->bindings); } - public function testBuildAfterResetProducesMinimalQuery(): void + public function testRawFilterEmpty(): void { - $builder = (new Builder()) - ->from('complex') - ->select(['a', 'b']) - ->filter([Query::equal('x', [1])]) - ->sortAsc('a') - ->limit(10) - ->offset(5); - - $builder->build(); - $builder->reset(); + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('')]) + ->build(); - $result = $builder->from('t')->build(); - $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertStringContainsString('1 = 1', $result->query); } - public function testMultipleResetCalls(): void + public function testStartsWithEscapesPercent(): void { - $builder = (new Builder())->from('t')->filter([Query::equal('a', [1])]); - $builder->build(); - $builder->reset(); - $builder->reset(); - $builder->reset(); + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', '100%')]) + ->build(); - $result = $builder->from('t2')->build(); - $this->assertEquals('SELECT * FROM `t2`', $result->query); + $this->assertEquals(['100\\%%'], $result->bindings); } - public function testResetBetweenDifferentQueryTypes(): void + public function testStartsWithEscapesUnderscore(): void { - $builder = new Builder(); + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', 'a_b')]) + ->build(); - // First: aggregation query - $builder->from('orders')->count('*', 'total')->groupBy(['status']); - $result1 = $builder->build(); - $this->assertStringContainsString('COUNT(*)', $result1->query); + $this->assertEquals(['a\\_b%'], $result->bindings); + } - $builder->reset(); + public function testStartsWithEscapesBackslash(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', 'path\\')]) + ->build(); - // Second: simple select query - $builder->from('users')->select(['name'])->filter([Query::equal('active', [true])]); - $result2 = $builder->build(); - $this->assertStringNotContainsString('COUNT', $result2->query); - $this->assertStringContainsString('`name`', $result2->query); + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertStringContainsString('\\\\', $binding); } - public function testResetAfterUnion(): void + public function testEndsWithEscapesSpecialChars(): void { - $sub = (new Builder())->from('other'); - $builder = (new Builder())->from('main')->union($sub); - $builder->build(); - $builder->reset(); + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('name', '%test_')]) + ->build(); - $result = $builder->from('new')->build(); - $this->assertEquals('SELECT * FROM `new`', $result->query); - $this->assertEquals([], $result->bindings); + $this->assertEquals(['%\\%test\\_'], $result->bindings); } - public function testResetAfterComplexQueryWithAllFeatures(): void + public function testContainsMultipleValuesUsesOr(): void { - $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['php', 'js'])]) + ->build(); - $builder = (new Builder()) - ->from('orders') - ->distinct() - ->count('*', 'cnt') - ->select(['status']) - ->join('users', 'orders.uid', 'users.id') - ->filter([Query::equal('status', ['paid'])]) - ->groupBy(['status']) - ->having([Query::greaterThan('cnt', 1)]) - ->sortDesc('cnt') - ->limit(10) - ->offset(5) - ->union($sub); + $this->assertStringContainsString('(`bio` LIKE ? OR `bio` LIKE ?)', $result->query); + $this->assertEquals(['%php%', '%js%'], $result->bindings); + } - $builder->build(); - $builder->reset(); + public function testContainsAllUsesAnd(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('bio', ['php', 'js'])]) + ->build(); - $result = $builder->from('simple')->build(); - $this->assertEquals('SELECT * FROM `simple`', $result->query); - $this->assertEquals([], $result->bindings); + $this->assertStringContainsString('(`bio` LIKE ? AND `bio` LIKE ?)', $result->query); + $this->assertEquals(['%php%', '%js%'], $result->bindings); } - // ══════════════════════════════════════════ - // 14. Multiple build() calls - // ══════════════════════════════════════════ - - public function testBuildTwiceModifyInBetween(): void + public function testNotContainsMultipleValues(): void { - $builder = (new Builder()) + $result = (new Builder()) ->from('t') - ->filter([Query::equal('a', [1])]); + ->filter([Query::notContains('bio', ['x', 'y'])]) + ->build(); - $result1 = $builder->build(); + $this->assertStringContainsString('(`bio` NOT LIKE ? AND `bio` NOT LIKE ?)', $result->query); + $this->assertEquals(['%x%', '%y%'], $result->bindings); + } - $builder->filter([Query::equal('b', [2])]); - $result2 = $builder->build(); + public function testContainsSingleValueNoParentheses(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['php'])]) + ->build(); - $this->assertStringNotContainsString('`b`', $result1->query); - $this->assertStringContainsString('`b`', $result2->query); + $this->assertStringContainsString('`bio` LIKE ?', $result->query); + $this->assertStringNotContainsString('(', $result->query); } - public function testBuildDoesNotMutatePendingQueries(): void + public function testDottedIdentifierInSelect(): void { - $builder = (new Builder()) + $result = (new Builder()) ->from('t') - ->filter([Query::equal('a', [1])]) - ->limit(10); - - $result1 = $builder->build(); - $result2 = $builder->build(); + ->select(['users.name', 'users.email']) + ->build(); - $this->assertEquals($result1->query, $result2->query); - $this->assertEquals($result1->bindings, $result2->bindings); + $this->assertStringContainsString('`users`.`name`, `users`.`email`', $result->query); } - public function testBuildResetsBindingsEachTime(): void + public function testDottedIdentifierInFilter(): void { - $builder = (new Builder()) + $result = (new Builder()) ->from('t') - ->filter([Query::equal('a', [1])]); + ->filter([Query::equal('users.id', [1])]) + ->build(); - $builder->build(); - $bindings1 = $builder->getBindings(); + $this->assertStringContainsString('`users`.`id` IN (?)', $result->query); + } - $builder->build(); - $bindings2 = $builder->getBindings(); + public function testMultipleOrderBy(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); - $this->assertEquals($bindings1, $bindings2); - $this->assertCount(1, $bindings2); + $this->assertStringContainsString('ORDER BY `name` ASC, `age` DESC', $result->query); } - public function testBuildWithConditionProducesConsistentBindings(): void + public function testOrderByWithRandomAndRegular(): void { - $builder = (new Builder()) + $result = (new Builder()) ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('org = ?', ['org1']); - } - }) - ->filter([Query::equal('status', ['active'])]); + ->sortAsc('name') + ->sortRandom() + ->build(); - $result1 = $builder->build(); - $result2 = $builder->build(); - $result3 = $builder->build(); + $this->assertStringContainsString('ORDER BY', $result->query); + $this->assertStringContainsString('`name` ASC', $result->query); + $this->assertStringContainsString('RAND()', $result->query); + } + + public function testDistinctWithSelect(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['name']) + ->build(); - $this->assertEquals($result1->bindings, $result2->bindings); - $this->assertEquals($result2->bindings, $result3->bindings); + $this->assertEquals('SELECT DISTINCT `name` FROM `t`', $result->query); } - public function testBuildAfterAddingMoreQueries(): void + public function testDistinctWithAggregate(): void { - $builder = (new Builder())->from('t'); + $result = (new Builder()) + ->from('t') + ->distinct() + ->count() + ->build(); - $result1 = $builder->build(); - $this->assertEquals('SELECT * FROM `t`', $result1->query); + $this->assertEquals('SELECT DISTINCT COUNT(*) FROM `t`', $result->query); + } - $builder->filter([Query::equal('a', [1])]); - $result2 = $builder->build(); - $this->assertStringContainsString('WHERE', $result2->query); + public function testSumWithAlias2(): void + { + $result = (new Builder()) + ->from('t') + ->sum('amount', 'total') + ->build(); - $builder->sortAsc('a'); - $result3 = $builder->build(); - $this->assertStringContainsString('ORDER BY', $result3->query); + $this->assertEquals('SELECT SUM(`amount`) AS `total` FROM `t`', $result->query); } - public function testBuildWithUnionProducesConsistentResults(): void + public function testAvgWithAlias2(): void { - $sub = (new Builder())->from('other')->filter([Query::equal('x', [1])]); - $builder = (new Builder())->from('main')->union($sub); - - $result1 = $builder->build(); - $result2 = $builder->build(); + $result = (new Builder()) + ->from('t') + ->avg('score', 'avg_score') + ->build(); - $this->assertEquals($result1->query, $result2->query); - $this->assertEquals($result1->bindings, $result2->bindings); + $this->assertEquals('SELECT AVG(`score`) AS `avg_score` FROM `t`', $result->query); } - public function testBuildThreeTimesWithIncreasingComplexity(): void + public function testMinWithAlias2(): void { - $builder = (new Builder())->from('t'); + $result = (new Builder()) + ->from('t') + ->min('price', 'cheapest') + ->build(); - $r1 = $builder->build(); - $this->assertEquals('SELECT * FROM `t`', $r1->query); + $this->assertEquals('SELECT MIN(`price`) AS `cheapest` FROM `t`', $result->query); + } - $builder->filter([Query::equal('a', [1])]); - $r2 = $builder->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $r2->query); + public function testMaxWithAlias2(): void + { + $result = (new Builder()) + ->from('t') + ->max('price', 'priciest') + ->build(); - $builder->limit(10)->offset(5); - $r3 = $builder->build(); - $this->assertStringContainsString('LIMIT ?', $r3->query); - $this->assertStringContainsString('OFFSET ?', $r3->query); + $this->assertEquals('SELECT MAX(`price`) AS `priciest` FROM `t`', $result->query); } - public function testBuildBindingsNotAccumulated(): void + public function testCountWithoutAlias(): void { - $builder = (new Builder()) + $result = (new Builder()) ->from('t') - ->filter([Query::equal('a', [1])]) - ->limit(10); - - $builder->build(); - $builder->build(); - $builder->build(); + ->count() + ->build(); - $this->assertCount(2, $builder->getBindings()); + $this->assertEquals('SELECT COUNT(*) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); } - public function testMultipleBuildWithHavingBindings(): void + public function testMultipleAggregates(): void { - $builder = (new Builder()) + $result = (new Builder()) ->from('t') ->count('*', 'cnt') - ->groupBy(['status']) - ->having([Query::greaterThan('cnt', 5)]); - - $r1 = $builder->build(); - $r2 = $builder->build(); + ->sum('amount', 'total') + ->build(); - $this->assertEquals([5], $r1->bindings); - $this->assertEquals([5], $r2->bindings); + $this->assertEquals('SELECT COUNT(*) AS `cnt`, SUM(`amount`) AS `total` FROM `t`', $result->query); } - // ══════════════════════════════════════════ - // 15. Binding ordering comprehensive - // ══════════════════════════════════════════ - - public function testBindingOrderMultipleFilters(): void + public function testSelectRawWithRegularSelect(): void { $result = (new Builder()) ->from('t') - ->filter([ - Query::equal('a', ['v1']), - Query::greaterThan('b', 10), - Query::between('c', 1, 100), - ]) + ->select(['id']) + ->selectRaw('NOW() as current_time') ->build(); - $this->assertEquals(['v1', 10, 1, 100], $result->bindings); + $this->assertEquals('SELECT `id`, NOW() as current_time FROM `t`', $result->query); } - public function testBindingOrderThreeProviders(): void + public function testSelectRawWithBindings2(): void { $result = (new Builder()) ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('p1 = ?', ['pv1']); - } - }) - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('p2 = ?', ['pv2']); - } - }) - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('p3 = ?', ['pv3']); - } - }) + ->selectRaw('COALESCE(?, ?) as result', ['a', 'b']) ->build(); - $this->assertEquals(['pv1', 'pv2', 'pv3'], $result->bindings); + $this->assertEquals(['a', 'b'], $result->bindings); } - public function testBindingOrderMultipleUnions(): void + public function testRightJoin2(): void { - $q1 = (new Builder())->from('a')->filter([Query::equal('x', [1])]); - $q2 = (new Builder())->from('b')->filter([Query::equal('y', [2])]); - $result = (new Builder()) - ->from('main') - ->filter([Query::equal('z', [3])]) - ->limit(5) - ->union($q1) - ->unionAll($q2) + ->from('a') + ->rightJoin('b', 'a.id', 'b.a_id') ->build(); - // main filter, main limit, union1 bindings, union2 bindings - $this->assertEquals([3, 5, 1, 2], $result->bindings); + $this->assertStringContainsString('RIGHT JOIN `b` ON `a`.`id` = `b`.`a_id`', $result->query); } - public function testBindingOrderLogicalAndWithMultipleSubFilters(): void + public function testCrossJoin2(): void { $result = (new Builder()) - ->from('t') - ->filter([ - Query::and([ - Query::equal('a', [1]), - Query::greaterThan('b', 2), - Query::lessThan('c', 3), - ]), - ]) + ->from('a') + ->crossJoin('b') ->build(); - $this->assertEquals([1, 2, 3], $result->bindings); + $this->assertStringContainsString('CROSS JOIN `b`', $result->query); + $this->assertStringNotContainsString(' ON ', $result->query); } - public function testBindingOrderLogicalOrWithMultipleSubFilters(): void + public function testJoinWithNonEqualOperator(): void { $result = (new Builder()) - ->from('t') - ->filter([ - Query::or([ - Query::equal('a', [1]), - Query::equal('b', [2]), - Query::equal('c', [3]), - ]), - ]) + ->from('a') + ->join('b', 'a.id', 'b.a_id', '!=') ->build(); - $this->assertEquals([1, 2, 3], $result->bindings); + $this->assertStringContainsString('ON `a`.`id` != `b`.`a_id`', $result->query); } - public function testBindingOrderNestedAndOr(): void + public function testJoinInvalidOperatorThrows(): void { - $result = (new Builder()) - ->from('t') - ->filter([ - Query::and([ - Query::equal('a', [1]), - Query::or([ - Query::equal('b', [2]), - Query::equal('c', [3]), - ]), - ]), - ]) - ->build(); + $this->expectException(ValidationException::class); - $this->assertEquals([1, 2, 3], $result->bindings); + (new Builder()) + ->from('a') + ->join('b', 'a.id', 'b.a_id', 'INVALID') + ->build(); } - public function testBindingOrderRawMixedWithRegularFilters(): void + public function testMultipleFiltersJoinedWithAnd(): void { $result = (new Builder()) ->from('t') ->filter([ - Query::equal('a', ['v1']), - Query::raw('custom > ?', [10]), - Query::greaterThan('b', 20), + Query::equal('a', [1]), + Query::greaterThan('b', 2), + Query::lessThan('c', 3), ]) ->build(); - $this->assertEquals(['v1', 10, 20], $result->bindings); + $this->assertStringContainsString('WHERE `a` IN (?) AND `b` > ? AND `c` < ?', $result->query); + $this->assertEquals([1, 2, 3], $result->bindings); } - public function testBindingOrderAggregationHavingComplexConditions(): void + public function testFilterWithRawCombined(): void { $result = (new Builder()) ->from('t') - ->count('*', 'cnt') - ->sum('price', 'total') - ->filter([Query::equal('status', ['active'])]) - ->groupBy(['category']) - ->having([ - Query::greaterThan('cnt', 5), - Query::lessThan('total', 10000), + ->filter([ + Query::equal('x', [1]), + Query::raw('y > 5'), ]) - ->limit(10) ->build(); - // filter, having1, having2, limit - $this->assertEquals(['active', 5, 10000, 10], $result->bindings); + $this->assertStringContainsString('`x` IN (?)', $result->query); + $this->assertStringContainsString('y > 5', $result->query); + $this->assertStringContainsString('AND', $result->query); } - public function testBindingOrderFullPipelineWithEverything(): void + public function testResetClearsRawSelects2(): void { - $sub = (new Builder())->from('archive')->filter([Query::equal('archived', [true])]); - - $result = (new Builder()) - ->from('orders') - ->count('*', 'cnt') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('tenant = ?', ['t1']); - } - }) - ->filter([ - Query::equal('status', ['paid']), - Query::greaterThan('total', 0), - ]) - ->cursorAfter('cursor_val') - ->groupBy(['status']) - ->having([Query::greaterThan('cnt', 1)]) - ->limit(25) - ->offset(50) - ->union($sub) - ->build(); + $builder = (new Builder())->from('t')->selectRaw('1 AS one'); + $builder->build(); + $builder->reset(); - // filter(paid, 0), provider(t1), cursor(cursor_val), having(1), limit(25), offset(50), union(true) - $this->assertEquals(['paid', 0, 't1', 'cursor_val', 1, 25, 50, true], $result->bindings); + $result = $builder->from('t')->build(); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertStringNotContainsString('one', $result->query); } - public function testBindingOrderContainsMultipleValues(): void + public function testAttributeHookResolvesColumn(): void { + $hook = new class () implements Attribute { + public function resolve(string $attribute): string + { + return match ($attribute) { + 'alias' => 'real_column', + default => $attribute, + }; + } + }; + $result = (new Builder()) ->from('t') - ->filter([ - Query::contains('bio', ['php', 'js', 'go']), - Query::equal('status', ['active']), - ]) + ->addHook($hook) + ->filter([Query::equal('alias', [1])]) ->build(); - // contains produces three LIKE bindings, then equal - $this->assertEquals(['%php%', '%js%', '%go%', 'active'], $result->bindings); + $this->assertStringContainsString('`real_column`', $result->query); + $this->assertStringNotContainsString('`alias`', $result->query); } - public function testBindingOrderBetweenAndComparisons(): void + public function testAttributeHookWithSelect(): void { + $hook = new class () implements Attribute { + public function resolve(string $attribute): string + { + return match ($attribute) { + 'alias' => 'real_column', + default => $attribute, + }; + } + }; + $result = (new Builder()) ->from('t') - ->filter([ - Query::between('age', 18, 65), - Query::greaterThan('score', 50), - Query::lessThan('rank', 100), - ]) + ->addHook($hook) + ->select(['alias']) ->build(); - $this->assertEquals([18, 65, 50, 100], $result->bindings); + $this->assertStringContainsString('SELECT `real_column`', $result->query); } - public function testBindingOrderStartsWithEndsWith(): void + public function testMultipleFilterHooks(): void { + $hook1 = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('`tenant` = ?', ['t1']); + } + }; + + $hook2 = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('`org` = ?', ['o1']); + } + }; + $result = (new Builder()) ->from('t') - ->filter([ - Query::startsWith('name', 'A'), - Query::endsWith('email', '.com'), - ]) + ->addHook($hook1) + ->addHook($hook2) + ->filter([Query::equal('x', [1])]) ->build(); - $this->assertEquals(['A%', '%.com'], $result->bindings); + $this->assertStringContainsString('`tenant` = ?', $result->query); + $this->assertStringContainsString('`org` = ?', $result->query); + $this->assertStringContainsString('AND', $result->query); + $this->assertContains('t1', $result->bindings); + $this->assertContains('o1', $result->bindings); } - public function testBindingOrderSearchAndRegex(): void + public function testSearchFilter(): void { $result = (new Builder()) ->from('t') - ->filter([ - Query::search('content', 'hello'), - Query::regex('slug', '^test'), - ]) + ->filter([Query::search('body', 'hello world')]) ->build(); - $this->assertEquals(['hello', '^test'], $result->bindings); + $this->assertStringContainsString('MATCH(`body`) AGAINST(?)', $result->query); + $this->assertContains('hello world', $result->bindings); } - public function testBindingOrderWithCursorBeforeFilterAndLimit(): void + public function testNotSearchFilter(): void { $result = (new Builder()) ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('org = ?', ['org1']); - } - }) - ->filter([Query::equal('a', ['x'])]) - ->cursorBefore('my_cursor') - ->limit(10) - ->offset(0) + ->filter([Query::notSearch('body', 'spam')]) ->build(); - // filter, provider, cursor, limit, offset - $this->assertEquals(['x', 'org1', 'my_cursor', 10, 0], $result->bindings); - } - - // ══════════════════════════════════════════ - // 16. Empty/minimal queries - // ══════════════════════════════════════════ - - public function testBuildWithNoFromNoFilters(): void - { - $result = (new Builder())->from('')->build(); - $this->assertEquals('SELECT * FROM ``', $result->query); - $this->assertEquals([], $result->bindings); + $this->assertStringContainsString('NOT (MATCH(`body`) AGAINST(?))', $result->query); + $this->assertContains('spam', $result->bindings); } - public function testBuildWithOnlyLimit(): void + public function testIsNullFilter(): void { $result = (new Builder()) - ->from('') - ->limit(10) + ->from('t') + ->filter([Query::isNull('deleted_at')]) ->build(); - $this->assertStringContainsString('LIMIT ?', $result->query); - $this->assertEquals([10], $result->bindings); + $this->assertStringContainsString('`deleted_at` IS NULL', $result->query); + $this->assertEquals([], $result->bindings); } - public function testBuildWithOnlyOffset(): void + public function testIsNotNullFilter(): void { - // OFFSET without LIMIT is suppressed $result = (new Builder()) - ->from('') - ->offset(50) + ->from('t') + ->filter([Query::isNotNull('name')]) ->build(); - $this->assertStringNotContainsString('OFFSET ?', $result->query); + $this->assertStringContainsString('`name` IS NOT NULL', $result->query); $this->assertEquals([], $result->bindings); } - public function testBuildWithOnlySort(): void + public function testLessThanFilter(): void { $result = (new Builder()) - ->from('') - ->sortAsc('name') + ->from('t') + ->filter([Query::lessThan('age', 30)]) ->build(); - $this->assertStringContainsString('ORDER BY `name` ASC', $result->query); + $this->assertStringContainsString('`age` < ?', $result->query); + $this->assertEquals([30], $result->bindings); } - public function testBuildWithOnlySelect(): void + public function testLessThanEqualFilter(): void { $result = (new Builder()) - ->from('') - ->select(['a', 'b']) + ->from('t') + ->filter([Query::lessThanEqual('age', 30)]) ->build(); - $this->assertStringContainsString('SELECT `a`, `b`', $result->query); + $this->assertStringContainsString('`age` <= ?', $result->query); + $this->assertEquals([30], $result->bindings); } - public function testBuildWithOnlyAggregationNoFrom(): void + public function testGreaterThanFilter(): void { $result = (new Builder()) - ->from('') - ->count('*', 'total') + ->from('t') + ->filter([Query::greaterThan('age', 18)]) ->build(); - $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + $this->assertStringContainsString('`age` > ?', $result->query); + $this->assertEquals([18], $result->bindings); } - public function testBuildWithEmptyFilterArray(): void + public function testGreaterThanEqualFilter(): void { $result = (new Builder()) ->from('t') - ->filter([]) + ->filter([Query::greaterThanEqual('age', 21)]) ->build(); - $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertStringContainsString('`age` >= ?', $result->query); + $this->assertEquals([21], $result->bindings); } - public function testBuildWithEmptySelectArray(): void + public function testNotStartsWithFilter(): void { $result = (new Builder()) ->from('t') - ->select([]) + ->filter([Query::notStartsWith('name', 'foo')]) ->build(); - $this->assertEquals('SELECT FROM `t`', $result->query); + $this->assertStringContainsString('`name` NOT LIKE ?', $result->query); + $this->assertEquals(['foo%'], $result->bindings); } - public function testBuildWithOnlyHavingNoGroupBy(): void + public function testNotEndsWithFilter(): void { $result = (new Builder()) ->from('t') - ->count('*', 'cnt') - ->having([Query::greaterThan('cnt', 0)]) + ->filter([Query::notEndsWith('name', 'bar')]) ->build(); - $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); - $this->assertStringNotContainsString('GROUP BY', $result->query); + $this->assertStringContainsString('`name` NOT LIKE ?', $result->query); + $this->assertEquals(['%bar'], $result->bindings); } - public function testBuildWithOnlyDistinct(): void + public function testDeleteWithOrderAndLimit(): void { $result = (new Builder()) ->from('t') - ->distinct() - ->build(); - - $this->assertEquals('SELECT DISTINCT * FROM `t`', $result->query); - } - - // ══════════════════════════════════════════ - // Spatial/Vector/ElemMatch Exception Tests - // ══════════════════════════════════════════ + ->filter([Query::lessThan('age', 18)]) + ->sortAsc('id') + ->limit(10) + ->delete(); - public function testUnsupportedFilterTypeCrosses(): void - { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::crosses('attr', ['val'])])->build(); + $this->assertStringContainsString('DELETE FROM `t`', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('ORDER BY `id` ASC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); } - public function testUnsupportedFilterTypeNotCrosses(): void + public function testUpdateWithOrderAndLimit(): void { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::notCrosses('attr', ['val'])])->build(); - } + $result = (new Builder()) + ->from('t') + ->set(['status' => 'archived']) + ->filter([Query::lessThan('age', 18)]) + ->sortAsc('id') + ->limit(10) + ->update(); - public function testUnsupportedFilterTypeDistanceEqual(): void - { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::distanceEqual('attr', [0, 0], 1)])->build(); + $this->assertStringContainsString('UPDATE `t` SET', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('ORDER BY `id` ASC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); } - public function testUnsupportedFilterTypeDistanceNotEqual(): void - { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::distanceNotEqual('attr', [0, 0], 1)])->build(); - } + // Feature 1: Table Aliases - public function testUnsupportedFilterTypeDistanceGreaterThan(): void + public function testTableAlias(): void { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::distanceGreaterThan('attr', [0, 0], 1)])->build(); - } + $result = (new Builder()) + ->from('users', 'u') + ->select(['u.name', 'u.email']) + ->build(); - public function testUnsupportedFilterTypeDistanceLessThan(): void - { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::distanceLessThan('attr', [0, 0], 1)])->build(); + $this->assertEquals('SELECT `u`.`name`, `u`.`email` FROM `users` AS `u`', $result->query); } - public function testUnsupportedFilterTypeIntersects(): void + public function testJoinAlias(): void { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::intersects('attr', ['val'])])->build(); - } + $result = (new Builder()) + ->from('users', 'u') + ->join('orders', 'u.id', 'o.user_id', '=', 'o') + ->build(); - public function testUnsupportedFilterTypeNotIntersects(): void - { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::notIntersects('attr', ['val'])])->build(); + $this->assertStringContainsString('FROM `users` AS `u`', $result->query); + $this->assertStringContainsString('JOIN `orders` AS `o` ON `u`.`id` = `o`.`user_id`', $result->query); } - public function testUnsupportedFilterTypeOverlaps(): void + public function testLeftJoinAlias(): void { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::overlaps('attr', ['val'])])->build(); - } + $result = (new Builder()) + ->from('users') + ->leftJoin('orders', 'users.id', 'o.user_id', '=', 'o') + ->build(); - public function testUnsupportedFilterTypeNotOverlaps(): void - { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::notOverlaps('attr', ['val'])])->build(); + $this->assertStringContainsString('LEFT JOIN `orders` AS `o` ON `users`.`id` = `o`.`user_id`', $result->query); } - public function testUnsupportedFilterTypeTouches(): void + public function testRightJoinAlias(): void { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::touches('attr', ['val'])])->build(); - } + $result = (new Builder()) + ->from('users') + ->rightJoin('orders', 'users.id', 'o.user_id', '=', 'o') + ->build(); - public function testUnsupportedFilterTypeNotTouches(): void - { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::notTouches('attr', ['val'])])->build(); + $this->assertStringContainsString('RIGHT JOIN `orders` AS `o` ON `users`.`id` = `o`.`user_id`', $result->query); } - public function testUnsupportedFilterTypeVectorDot(): void + public function testCrossJoinAlias(): void { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::vectorDot('attr', [1.0, 2.0])])->build(); - } + $result = (new Builder()) + ->from('users') + ->crossJoin('colors', 'c') + ->build(); - public function testUnsupportedFilterTypeVectorCosine(): void - { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::vectorCosine('attr', [1.0, 2.0])])->build(); + $this->assertStringContainsString('CROSS JOIN `colors` AS `c`', $result->query); } - public function testUnsupportedFilterTypeVectorEuclidean(): void - { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::vectorEuclidean('attr', [1.0, 2.0])])->build(); - } + // Feature 2: Subqueries - public function testUnsupportedFilterTypeElemMatch(): void + public function testFilterWhereIn(): void { - $this->expectException(\Utopia\Query\Exception::class); - (new Builder())->from('t')->filter([Query::elemMatch('attr', [Query::equal('x', [1])])])->build(); - } - - // ══════════════════════════════════════════ - // toRawSql Edge Cases - // ══════════════════════════════════════════ + $sub = (new Builder())->from('orders')->select(['user_id'])->filter([Query::greaterThan('total', 100)]); + $result = (new Builder()) + ->from('users') + ->filterWhereIn('id', $sub) + ->build(); - public function testToRawSqlWithBoolFalse(): void - { - $sql = (new Builder())->from('t')->filter([Query::equal('active', [false])])->toRawSql(); - $this->assertEquals("SELECT * FROM `t` WHERE `active` IN (0)", $sql); + $this->assertEquals( + 'SELECT * FROM `users` WHERE `id` IN (SELECT `user_id` FROM `orders` WHERE `total` > ?)', + $result->query + ); + $this->assertEquals([100], $result->bindings); } - public function testToRawSqlMixedBindingTypes(): void + public function testFilterWhereNotIn(): void { - $sql = (new Builder())->from('t') - ->filter([ - Query::equal('name', ['str']), - Query::greaterThan('age', 42), - Query::lessThan('score', 9.99), - Query::equal('active', [true]), - ])->toRawSql(); - $this->assertStringContainsString("'str'", $sql); - $this->assertStringContainsString('42', $sql); - $this->assertStringContainsString('9.99', $sql); - $this->assertStringContainsString('1', $sql); - } + $sub = (new Builder())->from('blacklist')->select(['user_id']); + $result = (new Builder()) + ->from('users') + ->filterWhereNotIn('id', $sub) + ->build(); - public function testToRawSqlWithNull(): void - { - $sql = (new Builder())->from('t') - ->filter([Query::raw('col = ?', [null])]) - ->toRawSql(); - $this->assertStringContainsString('NULL', $sql); + $this->assertStringContainsString('`id` NOT IN (SELECT `user_id` FROM `blacklist`)', $result->query); } - public function testToRawSqlWithUnion(): void + public function testSelectSub(): void { - $other = (new Builder())->from('b')->filter([Query::equal('x', [1])]); - $sql = (new Builder())->from('a')->filter([Query::equal('y', [2])])->union($other)->toRawSql(); - $this->assertStringContainsString("FROM `a`", $sql); - $this->assertStringContainsString('UNION', $sql); - $this->assertStringContainsString("FROM `b`", $sql); - $this->assertStringContainsString('2', $sql); - $this->assertStringContainsString('1', $sql); - } + $sub = (new Builder())->from('orders')->count('*', 'cnt')->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + $result = (new Builder()) + ->from('users') + ->select(['name']) + ->selectSub($sub, 'order_count') + ->build(); - public function testToRawSqlWithAggregationJoinGroupByHaving(): void - { - $sql = (new Builder())->from('orders') - ->count('*', 'total') - ->join('users', 'orders.uid', 'users.id') - ->select(['users.country']) - ->groupBy(['users.country']) - ->having([Query::greaterThan('total', 5)]) - ->toRawSql(); - $this->assertStringContainsString('COUNT(*)', $sql); - $this->assertStringContainsString('JOIN', $sql); - $this->assertStringContainsString('GROUP BY', $sql); - $this->assertStringContainsString('HAVING', $sql); - $this->assertStringContainsString('5', $sql); + $this->assertStringContainsString('`name`', $result->query); + $this->assertStringContainsString('(SELECT COUNT(*) AS `cnt` FROM `orders`', $result->query); + $this->assertStringContainsString(') AS `order_count`', $result->query); } - // ══════════════════════════════════════════ - // Kitchen Sink Exact SQL - // ══════════════════════════════════════════ - - public function testKitchenSinkExactSql(): void + public function testFromSub(): void { - $other = (new Builder())->from('archive')->filter([Query::equal('status', ['closed'])]); + $sub = (new Builder())->from('orders')->select(['user_id'])->groupBy(['user_id']); $result = (new Builder()) - ->from('orders') - ->distinct() - ->count('*', 'total') - ->select(['status']) - ->join('users', 'orders.uid', 'users.id') - ->filter([Query::greaterThan('amount', 100)]) - ->groupBy(['status']) - ->having([Query::greaterThan('total', 5)]) - ->sortAsc('status') - ->limit(10) - ->offset(20) - ->union($other) + ->fromSub($sub, 'sub') + ->select(['user_id']) ->build(); $this->assertEquals( - '(SELECT DISTINCT COUNT(*) AS `total`, `status` FROM `orders` JOIN `users` ON `orders`.`uid` = `users`.`id` WHERE `amount` > ? GROUP BY `status` HAVING `total` > ? ORDER BY `status` ASC LIMIT ? OFFSET ?) UNION (SELECT * FROM `archive` WHERE `status` IN (?))', + 'SELECT `user_id` FROM (SELECT `user_id` FROM `orders` GROUP BY `user_id`) AS `sub`', $result->query ); - $this->assertEquals([100, 5, 10, 20, 'closed'], $result->bindings); } - // ══════════════════════════════════════════ - // Feature Combination Tests - // ══════════════════════════════════════════ + // Feature 3: Raw ORDER BY / GROUP BY / HAVING - public function testDistinctWithUnion(): void + public function testOrderByRaw(): void { - $other = (new Builder())->from('b'); - $result = (new Builder())->from('a')->distinct()->union($other)->build(); - $this->assertEquals('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result->query); - $this->assertEquals([], $result->bindings); + $result = (new Builder()) + ->from('users') + ->orderByRaw('FIELD(`status`, ?, ?, ?)', ['active', 'pending', 'inactive']) + ->build(); + + $this->assertStringContainsString('ORDER BY FIELD(`status`, ?, ?, ?)', $result->query); + $this->assertEquals(['active', 'pending', 'inactive'], $result->bindings); } - public function testRawInsideLogicalAnd(): void + public function testGroupByRaw(): void { - $result = (new Builder())->from('t') - ->filter([Query::and([ - Query::greaterThan('x', 1), - Query::raw('custom_func(y) > ?', [5]), - ])]) + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupByRaw('YEAR(`created_at`)') ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`x` > ? AND custom_func(y) > ?)', $result->query); - $this->assertEquals([1, 5], $result->bindings); + + $this->assertStringContainsString('GROUP BY YEAR(`created_at`)', $result->query); } - public function testRawInsideLogicalOr(): void + public function testHavingRaw(): void { - $result = (new Builder())->from('t') - ->filter([Query::or([ - Query::equal('a', [1]), - Query::raw('b IS NOT NULL', []), - ])]) + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupBy(['user_id']) + ->havingRaw('COUNT(*) > ?', [5]) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result->query); - $this->assertEquals([1], $result->bindings); + + $this->assertStringContainsString('HAVING COUNT(*) > ?', $result->query); + $this->assertContains(5, $result->bindings); } - public function testAggregationWithCursor(): void + // Feature 4: countDistinct + + public function testCountDistinct(): void { - $result = (new Builder())->from('t') - ->count('*', 'total') - ->cursorAfter('abc') + $result = (new Builder()) + ->from('orders') + ->countDistinct('user_id', 'unique_users') ->build(); - $this->assertStringContainsString('COUNT(*)', $result->query); - $this->assertStringContainsString('`_cursor` > ?', $result->query); - $this->assertContains('abc', $result->bindings); - } - public function testGroupBySortCursorUnion(): void - { - $other = (new Builder())->from('b'); - $result = (new Builder())->from('a') - ->count('*', 'total') - ->groupBy(['status']) - ->sortDesc('total') - ->cursorAfter('xyz') - ->union($other) + $this->assertEquals( + 'SELECT COUNT(DISTINCT `user_id`) AS `unique_users` FROM `orders`', + $result->query + ); + } + + public function testCountDistinctNoAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->countDistinct('user_id') ->build(); - $this->assertStringContainsString('GROUP BY', $result->query); - $this->assertStringContainsString('ORDER BY', $result->query); - $this->assertStringContainsString('UNION', $result->query); + + $this->assertEquals( + 'SELECT COUNT(DISTINCT `user_id`) FROM `orders`', + $result->query + ); } - public function testConditionProviderWithNoFilters(): void + // Feature 5: JoinBuilder (complex JOIN ON) + + public function testJoinWhere(): void { $result = (new Builder()) - ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('_tenant = ?', ['t1']); - } + ->from('users') + ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + $join->on('users.id', 'orders.user_id') + ->where('orders.status', '=', 'active'); }) ->build(); - $this->assertEquals('SELECT * FROM `t` WHERE _tenant = ?', $result->query); - $this->assertEquals(['t1'], $result->bindings); + + $this->assertStringContainsString('JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND orders.status = ?', $result->query); + $this->assertEquals(['active'], $result->bindings); } - public function testConditionProviderWithCursorNoFilters(): void + public function testJoinWhereMultipleOns(): void { $result = (new Builder()) - ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('_tenant = ?', ['t1']); - } + ->from('users') + ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + $join->on('users.id', 'orders.user_id') + ->on('users.org_id', 'orders.org_id'); }) - ->cursorAfter('abc') ->build(); - $this->assertStringContainsString('_tenant = ?', $result->query); - $this->assertStringContainsString('`_cursor` > ?', $result->query); - // Provider bindings come before cursor bindings - $this->assertEquals(['t1', 'abc'], $result->bindings); + + $this->assertStringContainsString('JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND `users`.`org_id` = `orders`.`org_id`', $result->query); } - public function testConditionProviderWithDistinct(): void + public function testJoinWhereLeftJoin(): void { $result = (new Builder()) - ->from('t') - ->distinct() - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('_tenant = ?', ['t1']); - } - }) + ->from('users') + ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + $join->on('users.id', 'orders.user_id'); + }, 'LEFT JOIN') ->build(); - $this->assertEquals('SELECT DISTINCT * FROM `t` WHERE _tenant = ?', $result->query); - $this->assertEquals(['t1'], $result->bindings); + + $this->assertStringContainsString('LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); } - public function testConditionProviderPersistsAfterReset(): void + public function testJoinWhereWithAlias(): void { - $builder = (new Builder()) - ->from('t') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('_tenant = ?', ['t1']); - } - }); - $builder->build(); - $builder->reset()->from('other'); - $result = $builder->build(); - $this->assertStringContainsString('FROM `other`', $result->query); - $this->assertStringContainsString('_tenant = ?', $result->query); - $this->assertEquals(['t1'], $result->bindings); + $result = (new Builder()) + ->from('users', 'u') + ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + $join->on('u.id', 'o.user_id'); + }, 'JOIN', 'o') + ->build(); + + $this->assertStringContainsString('FROM `users` AS `u`', $result->query); + $this->assertStringContainsString('JOIN `orders` AS `o` ON `u`.`id` = `o`.`user_id`', $result->query); } - public function testConditionProviderWithHaving(): void + // Feature 6: EXISTS Subquery + + public function testFilterExists(): void { + $sub = (new Builder()) + ->from('orders') + ->select(['id']) + ->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + $result = (new Builder()) - ->from('t') - ->count('*', 'total') - ->groupBy(['status']) - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('_tenant = ?', ['t1']); - } - }) - ->having([Query::greaterThan('total', 5)]) + ->from('users') + ->filterExists($sub) ->build(); - // Provider should be in WHERE, not HAVING - $this->assertStringContainsString('WHERE _tenant = ?', $result->query); - $this->assertStringContainsString('HAVING `total` > ?', $result->query); - // Provider bindings before having bindings - $this->assertEquals(['t1', 5], $result->bindings); + + $this->assertStringContainsString('EXISTS (SELECT `id` FROM `orders`', $result->query); } - public function testUnionWithConditionProvider(): void + public function testFilterNotExists(): void { $sub = (new Builder()) - ->from('b') - ->addHook(new class () implements FilterHook { - public function filter(string $table): Condition - { - return new Condition('_deleted = ?', [0]); - } - }); + ->from('orders') + ->select(['id']) + ->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + $result = (new Builder()) - ->from('a') - ->union($sub) + ->from('users') + ->filterNotExists($sub) ->build(); - // Sub-query should include the condition provider - $this->assertStringContainsString('UNION (SELECT * FROM `b` WHERE _deleted = ?)', $result->query); - $this->assertEquals([0], $result->bindings); + + $this->assertStringContainsString('NOT EXISTS (SELECT `id` FROM `orders`', $result->query); } - // ══════════════════════════════════════════ - // Boundary Value Tests - // ══════════════════════════════════════════ + // Feature 7: insertOrIgnore - public function testNegativeLimit(): void + public function testInsertOrIgnore(): void { - $result = (new Builder())->from('t')->limit(-1)->build(); - $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); - $this->assertEquals([-1], $result->bindings); + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John', 'email' => 'john@example.com']) + ->insertOrIgnore(); + + $this->assertEquals( + 'INSERT IGNORE INTO `users` (`name`, `email`) VALUES (?, ?)', + $result->query + ); + $this->assertEquals(['John', 'john@example.com'], $result->bindings); } - public function testNegativeOffset(): void + // Feature 9: EXPLAIN + + public function testExplain(): void { - // OFFSET without LIMIT is suppressed - $result = (new Builder())->from('t')->offset(-5)->build(); - $this->assertEquals('SELECT * FROM `t`', $result->query); - $this->assertEquals([], $result->bindings); + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->explain(); + + $this->assertStringStartsWith('EXPLAIN SELECT', $result->query); + $this->assertStringContainsString('FROM `users`', $result->query); } - public function testEqualWithNullOnly(): void + public function testExplainAnalyze(): void { - $result = (new Builder())->from('t')->filter([Query::equal('col', [null])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NULL', $result->query); - $this->assertSame([], $result->bindings); + $result = (new Builder()) + ->from('users') + ->explain(true); + + $this->assertStringStartsWith('EXPLAIN ANALYZE SELECT', $result->query); } - public function testEqualWithNullAndNonNull(): void + // Feature 10: Locking Variants + + public function testForUpdateSkipLocked(): void { - $result = (new Builder())->from('t')->filter([Query::equal('col', ['a', null])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`col` IN (?) OR `col` IS NULL)', $result->query); - $this->assertSame(['a'], $result->bindings); + $result = (new Builder()) + ->from('users') + ->forUpdateSkipLocked() + ->build(); + + $this->assertStringContainsString('FOR UPDATE SKIP LOCKED', $result->query); } - public function testNotEqualWithNullOnly(): void + public function testForUpdateNoWait(): void { - $result = (new Builder())->from('t')->filter([Query::notEqual('col', [null])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NOT NULL', $result->query); - $this->assertSame([], $result->bindings); + $result = (new Builder()) + ->from('users') + ->forUpdateNoWait() + ->build(); + + $this->assertStringContainsString('FOR UPDATE NOWAIT', $result->query); } - public function testNotEqualWithNullAndNonNull(): void + public function testForShareSkipLocked(): void { - $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', null])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`col` != ? AND `col` IS NOT NULL)', $result->query); - $this->assertSame(['a'], $result->bindings); + $result = (new Builder()) + ->from('users') + ->forShareSkipLocked() + ->build(); + + $this->assertStringContainsString('FOR SHARE SKIP LOCKED', $result->query); } - public function testNotEqualWithMultipleNonNullAndNull(): void + public function testForShareNoWait(): void { - $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', 'b', null])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE (`col` NOT IN (?, ?) AND `col` IS NOT NULL)', $result->query); - $this->assertSame(['a', 'b'], $result->bindings); + $result = (new Builder()) + ->from('users') + ->forShareNoWait() + ->build(); + + $this->assertStringContainsString('FOR SHARE NOWAIT', $result->query); } - public function testBetweenReversedMinMax(): void + // Reset clears new properties + + public function testResetClearsNewProperties(): void { - $result = (new Builder())->from('t')->filter([Query::between('age', 65, 18)])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); - $this->assertEquals([65, 18], $result->bindings); + $builder = new Builder(); + $sub = (new Builder())->from('t')->select(['id']); + + $builder->from('users', 'u') + ->filterWhereIn('id', $sub) + ->selectSub($sub, 'cnt') + ->orderByRaw('RAND()') + ->groupByRaw('YEAR(created_at)') + ->havingRaw('COUNT(*) > 1') + ->countDistinct('id') + ->filterExists($sub) + ->reset(); + + // After reset, building without setting table should throw + $this->expectException(ValidationException::class); + $builder->build(); } - public function testContainsWithSqlWildcard(): void + // Case Builder — unit-level tests + + public function testCaseBuilderEmptyWhenThrows(): void { - $result = (new Builder())->from('t')->filter([Query::contains('bio', ['100%'])])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); - $this->assertEquals(['%100\%%'], $result->bindings); + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('at least one WHEN'); + + $case = new \Utopia\Query\Builder\Case\Builder(); + $case->build(); } - public function testStartsWithWithWildcard(): void + public function testCaseBuilderMultipleWhens(): void { - $result = (new Builder())->from('t')->filter([Query::startsWith('name', '%admin')])->build(); - $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); - $this->assertEquals(['\%admin%'], $result->bindings); + $case = (new \Utopia\Query\Builder\Case\Builder()) + ->when('`status` = ?', '?', ['active'], ['Active']) + ->when('`status` = ?', '?', ['inactive'], ['Inactive']) + ->elseResult('?', ['Unknown']) + ->alias('`label`') + ->build(); + + $this->assertEquals( + 'CASE WHEN `status` = ? THEN ? WHEN `status` = ? THEN ? ELSE ? END AS `label`', + $case->sql + ); + $this->assertEquals(['active', 'Active', 'inactive', 'Inactive', 'Unknown'], $case->bindings); } - public function testCursorWithNullValue(): void + public function testCaseBuilderWithoutElseClause(): void { - // Null cursor value is ignored by groupByType since cursor stays null - $result = (new Builder())->from('t')->cursorAfter(null)->build(); - $this->assertStringNotContainsString('_cursor', $result->query); - $this->assertEquals([], $result->bindings); + $case = (new \Utopia\Query\Builder\Case\Builder()) + ->when('`x` > ?', '1', [10]) + ->build(); + + $this->assertEquals('CASE WHEN `x` > ? THEN 1 END', $case->sql); + $this->assertEquals([10], $case->bindings); } - public function testCursorWithIntegerValue(): void + public function testCaseBuilderWithoutAliasClause(): void { - $result = (new Builder())->from('t')->cursorAfter(42)->build(); - $this->assertStringContainsString('`_cursor` > ?', $result->query); - $this->assertSame([42], $result->bindings); + $case = (new \Utopia\Query\Builder\Case\Builder()) + ->when('1=1', '?', [], ['yes']) + ->build(); + + $this->assertStringNotContainsString(' AS ', $case->sql); } - public function testCursorWithFloatValue(): void + public function testCaseExpressionToSqlOutput(): void { - $result = (new Builder())->from('t')->cursorAfter(3.14)->build(); - $this->assertStringContainsString('`_cursor` > ?', $result->query); - $this->assertSame([3.14], $result->bindings); + $expr = new \Utopia\Query\Builder\Case\Expression('CASE WHEN 1 THEN 2 END', []); + $arr = $expr->toSql(); + + $this->assertEquals('CASE WHEN 1 THEN 2 END', $arr['sql']); + $this->assertEquals([], $arr['bindings']); } - public function testMultipleLimitsFirstWins(): void + // JoinBuilder — unit-level tests + + public function testJoinBuilderOnReturnsConditions(): void { - $result = (new Builder())->from('t')->limit(10)->limit(20)->build(); - $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); - $this->assertEquals([10], $result->bindings); + $jb = new \Utopia\Query\Builder\JoinBuilder(); + $jb->on('a.id', 'b.a_id') + ->on('a.tenant', 'b.tenant', '='); + + $ons = $jb->getOns(); + $this->assertCount(2, $ons); + $this->assertEquals('a.id', $ons[0]['left']); + $this->assertEquals('b.a_id', $ons[0]['right']); + $this->assertEquals('=', $ons[0]['operator']); } - public function testMultipleOffsetsFirstWins(): void + public function testJoinBuilderWhereAddsCondition(): void { - // OFFSET without LIMIT is suppressed - $result = (new Builder())->from('t')->offset(5)->offset(50)->build(); - $this->assertEquals('SELECT * FROM `t`', $result->query); - $this->assertEquals([], $result->bindings); + $jb = new \Utopia\Query\Builder\JoinBuilder(); + $jb->where('status', '=', 'active'); + + $wheres = $jb->getWheres(); + $this->assertCount(1, $wheres); + $this->assertEquals('status = ?', $wheres[0]['expression']); + $this->assertEquals(['active'], $wheres[0]['bindings']); } - public function testCursorAfterAndBeforeFirstWins(): void + public function testJoinBuilderOnRaw(): void { - $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->build(); - $this->assertStringContainsString('`_cursor` > ?', $result->query); - $this->assertStringNotContainsString('`_cursor` < ?', $result->query); + $jb = new \Utopia\Query\Builder\JoinBuilder(); + $jb->onRaw('a.created_at > NOW() - INTERVAL ? DAY', [30]); + + $wheres = $jb->getWheres(); + $this->assertCount(1, $wheres); + $this->assertEquals([30], $wheres[0]['bindings']); } - public function testEmptyTableWithJoin(): void + public function testJoinBuilderWhereRaw(): void { - $result = (new Builder())->from('')->join('other', 'a', 'b')->build(); - $this->assertEquals('SELECT * FROM `` JOIN `other` ON `a` = `b`', $result->query); + $jb = new \Utopia\Query\Builder\JoinBuilder(); + $jb->whereRaw('`deleted_at` IS NULL'); + + $wheres = $jb->getWheres(); + $this->assertCount(1, $wheres); + $this->assertEquals('`deleted_at` IS NULL', $wheres[0]['expression']); + $this->assertEquals([], $wheres[0]['bindings']); } - public function testBuildWithoutFromCall(): void + public function testJoinBuilderCombinedOnAndWhere(): void { - $result = (new Builder())->filter([Query::equal('x', [1])])->build(); - $this->assertStringContainsString('FROM ``', $result->query); - $this->assertStringContainsString('`x` IN (?)', $result->query); + $jb = new \Utopia\Query\Builder\JoinBuilder(); + $jb->on('a.id', 'b.a_id') + ->where('b.active', '=', true) + ->onRaw('b.score > ?', [50]); + + $this->assertCount(1, $jb->getOns()); + $this->assertCount(2, $jb->getWheres()); } - // ══════════════════════════════════════════ - // Standalone Compiler Method Tests - // ══════════════════════════════════════════ + // Subquery binding order - public function testCompileSelectEmpty(): void + public function testSubqueryBindingOrderIsCorrect(): void { - $builder = new Builder(); - $result = $builder->compileSelect(Query::select([])); - $this->assertEquals('', $result); - } + $sub = (new Builder())->from('orders') + ->select(['user_id']) + ->filter([Query::equal('status', ['completed'])]); - public function testCompileGroupByEmpty(): void - { - $builder = new Builder(); - $result = $builder->compileGroupBy(Query::groupBy([])); - $this->assertEquals('', $result); + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('role', ['admin'])]) + ->filterWhereIn('id', $sub) + ->build(); + + // Main filter bindings come before subquery bindings + $this->assertEquals(['admin', 'completed'], $result->bindings); } - public function testCompileGroupBySingleColumn(): void + public function testSelectSubBindingOrder(): void { - $builder = new Builder(); - $result = $builder->compileGroupBy(Query::groupBy(['status'])); - $this->assertEquals('`status`', $result); + $sub = (new Builder())->from('orders') + ->selectRaw('COUNT(*)') + ->filter([Query::equal('orders.user_id', ['matched'])]); + + $result = (new Builder()) + ->from('users') + ->selectSub($sub, 'order_count') + ->filter([Query::equal('active', [true])]) + ->build(); + + // Sub-select bindings come before main WHERE bindings + $this->assertEquals(['matched', true], $result->bindings); } - public function testCompileSumWithoutAlias(): void + public function testFromSubBindingOrder(): void { - $builder = new Builder(); - $sql = $builder->compileAggregate(Query::sum('price')); - $this->assertEquals('SUM(`price`)', $sql); + $sub = (new Builder())->from('orders') + ->filter([Query::greaterThan('amount', 100)]); + + $result = (new Builder()) + ->fromSub($sub, 'expensive') + ->filter([Query::equal('status', ['shipped'])]) + ->build(); + + // FROM sub bindings come before main WHERE bindings + $this->assertEquals([100, 'shipped'], $result->bindings); } - public function testCompileAvgWithoutAlias(): void + // EXISTS with bindings + + public function testFilterExistsBindings(): void { - $builder = new Builder(); - $sql = $builder->compileAggregate(Query::avg('score')); - $this->assertEquals('AVG(`score`)', $sql); + $sub = (new Builder())->from('orders') + ->select(['id']) + ->filter([Query::equal('status', ['paid'])]); + + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('active', [true])]) + ->filterExists($sub) + ->build(); + + $this->assertStringContainsString('EXISTS (SELECT', $result->query); + $this->assertEquals([true, 'paid'], $result->bindings); } - public function testCompileMinWithoutAlias(): void + public function testFilterNotExistsQuery(): void { - $builder = new Builder(); - $sql = $builder->compileAggregate(Query::min('price')); - $this->assertEquals('MIN(`price`)', $sql); + $sub = (new Builder())->from('bans')->select(['id']); + + $result = (new Builder()) + ->from('users') + ->filterNotExists($sub) + ->build(); + + $this->assertStringContainsString('NOT EXISTS (SELECT', $result->query); } - public function testCompileMaxWithoutAlias(): void + // Combined features + + public function testExplainWithFilters(): void { - $builder = new Builder(); - $sql = $builder->compileAggregate(Query::max('price')); - $this->assertEquals('MAX(`price`)', $sql); + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('active', [true])]) + ->explain(); + + $this->assertStringStartsWith('EXPLAIN SELECT', $result->query); + $this->assertEquals([true], $result->bindings); } - public function testCompileLimitZero(): void + public function testExplainAnalyzeWithFilters(): void { - $builder = new Builder(); - $sql = $builder->compileLimit(Query::limit(0)); - $this->assertEquals('LIMIT ?', $sql); - $this->assertSame([0], $builder->getBindings()); + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('active', [true])]) + ->explain(true); + + $this->assertStringStartsWith('EXPLAIN ANALYZE SELECT', $result->query); + $this->assertEquals([true], $result->bindings); } - public function testCompileOffsetZero(): void + public function testTableAliasClearsOnNewFrom(): void { - $builder = new Builder(); - $sql = $builder->compileOffset(Query::offset(0)); - $this->assertEquals('OFFSET ?', $sql); - $this->assertSame([0], $builder->getBindings()); + $builder = (new Builder()) + ->from('users', 'u'); + + // Reset with new from() should clear alias + $result = $builder->from('orders')->build(); + + $this->assertStringContainsString('FROM `orders`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); } - public function testCompileOrderException(): void + public function testFromSubClearsTable(): void { - $builder = new Builder(); - $this->expectException(\Utopia\Query\Exception::class); - $builder->compileOrder(Query::limit(10)); + $sub = (new Builder())->from('orders')->select(['id']); + + $builder = (new Builder()) + ->from('users') + ->fromSub($sub, 'sub'); + + $result = $builder->build(); + + $this->assertStringNotContainsString('`users`', $result->query); + $this->assertStringContainsString('AS `sub`', $result->query); } - public function testCompileJoinException(): void + public function testFromClearsFromSub(): void { - $builder = new Builder(); - $this->expectException(\Utopia\Query\Exception::class); - $builder->compileJoin(Query::equal('x', [1])); + $sub = (new Builder())->from('orders')->select(['id']); + + $builder = (new Builder()) + ->fromSub($sub, 'sub') + ->from('users'); + + $result = $builder->build(); + + $this->assertStringContainsString('FROM `users`', $result->query); + $this->assertStringNotContainsString('sub', $result->query); } - // ══════════════════════════════════════════ - // Query::compile() Integration Tests - // ══════════════════════════════════════════ + // Raw clauses with bindings - public function testQueryCompileOrderAsc(): void + public function testOrderByRawWithBindings(): void { - $builder = new Builder(); - $this->assertEquals('`name` ASC', Query::orderAsc('name')->compile($builder)); + $result = (new Builder()) + ->from('users') + ->orderByRaw('FIELD(`status`, ?, ?, ?)', ['active', 'pending', 'inactive']) + ->build(); + + $this->assertStringContainsString('ORDER BY FIELD(`status`, ?, ?, ?)', $result->query); + $this->assertEquals(['active', 'pending', 'inactive'], $result->bindings); } - public function testQueryCompileOrderDesc(): void + public function testGroupByRawWithBindings(): void { - $builder = new Builder(); - $this->assertEquals('`name` DESC', Query::orderDesc('name')->compile($builder)); + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupByRaw('DATE_FORMAT(`created_at`, ?)', ['%Y-%m']) + ->build(); + + $this->assertStringContainsString("GROUP BY DATE_FORMAT(`created_at`, ?)", $result->query); + $this->assertEquals(['%Y-%m'], $result->bindings); } - public function testQueryCompileOrderRandom(): void + public function testHavingRawWithBindings(): void { - $builder = new Builder(); - $this->assertEquals('RAND()', Query::orderRandom()->compile($builder)); + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupBy(['user_id']) + ->havingRaw('SUM(`amount`) > ?', [1000]) + ->build(); + + $this->assertStringContainsString('HAVING SUM(`amount`) > ?', $result->query); + $this->assertEquals([1000], $result->bindings); } - public function testQueryCompileLimit(): void + public function testMultipleRawOrdersCombined(): void { - $builder = new Builder(); - $this->assertEquals('LIMIT ?', Query::limit(10)->compile($builder)); - $this->assertEquals([10], $builder->getBindings()); + $result = (new Builder()) + ->from('users') + ->sortAsc('name') + ->orderByRaw('FIELD(`role`, ?)', ['admin']) + ->build(); + + $this->assertStringContainsString('ORDER BY `name` ASC, FIELD(`role`, ?)', $result->query); } - public function testQueryCompileOffset(): void + public function testMultipleRawGroupsCombined(): void { - $builder = new Builder(); - $this->assertEquals('OFFSET ?', Query::offset(5)->compile($builder)); - $this->assertEquals([5], $builder->getBindings()); + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['type']) + ->groupByRaw('YEAR(`created_at`)') + ->build(); + + $this->assertStringContainsString('GROUP BY `type`, YEAR(`created_at`)', $result->query); } - public function testQueryCompileCursorAfter(): void + // countDistinct with alias and without + + public function testCountDistinctWithoutAlias(): void { - $builder = new Builder(); - $this->assertEquals('`_cursor` > ?', Query::cursorAfter('x')->compile($builder)); - $this->assertEquals(['x'], $builder->getBindings()); + $result = (new Builder()) + ->from('users') + ->countDistinct('email') + ->build(); + + $this->assertStringContainsString('COUNT(DISTINCT `email`)', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); } - public function testQueryCompileCursorBefore(): void + // Join alias with various join types + + public function testLeftJoinWithAlias(): void { - $builder = new Builder(); - $this->assertEquals('`_cursor` < ?', Query::cursorBefore('x')->compile($builder)); - $this->assertEquals(['x'], $builder->getBindings()); + $result = (new Builder()) + ->from('users', 'u') + ->leftJoin('orders', 'u.id', 'o.user_id', '=', 'o') + ->build(); + + $this->assertStringContainsString('LEFT JOIN `orders` AS `o`', $result->query); } - public function testQueryCompileSelect(): void + public function testRightJoinWithAlias(): void { - $builder = new Builder(); - $this->assertEquals('`a`, `b`', Query::select(['a', 'b'])->compile($builder)); + $result = (new Builder()) + ->from('users', 'u') + ->rightJoin('orders', 'u.id', 'o.user_id', '=', 'o') + ->build(); + + $this->assertStringContainsString('RIGHT JOIN `orders` AS `o`', $result->query); } - public function testQueryCompileGroupBy(): void + public function testCrossJoinWithAlias(): void { - $builder = new Builder(); - $this->assertEquals('`status`', Query::groupBy(['status'])->compile($builder)); + $result = (new Builder()) + ->from('users') + ->crossJoin('roles', 'r') + ->build(); + + $this->assertStringContainsString('CROSS JOIN `roles` AS `r`', $result->query); } - // ══════════════════════════════════════════ - // setWrapChar Edge Cases - // ══════════════════════════════════════════ + // JoinWhere with LEFT JOIN - public function testSetWrapCharWithIsNotNull(): void + public function testJoinWhereWithLeftJoinType(): void { - $result = (new Builder())->setWrapChar('"') - ->from('t') - ->filter([Query::isNotNull('email')]) + $result = (new Builder()) + ->from('users') + ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + $join->on('users.id', 'orders.user_id') + ->where('orders.status', '=', 'active'); + }, 'LEFT JOIN') ->build(); - $this->assertStringContainsString('"email" IS NOT NULL', $result->query); + + $this->assertStringContainsString('LEFT JOIN `orders` ON', $result->query); + $this->assertStringContainsString('orders.status = ?', $result->query); + $this->assertEquals(['active'], $result->bindings); } - public function testSetWrapCharWithExists(): void + public function testJoinWhereWithTableAlias(): void { - $result = (new Builder())->setWrapChar('"') - ->from('t') - ->filter([Query::exists(['a', 'b'])]) + $result = (new Builder()) + ->from('users', 'u') + ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + $join->on('u.id', 'o.user_id'); + }, 'JOIN', 'o') ->build(); - $this->assertStringContainsString('"a" IS NOT NULL', $result->query); - $this->assertStringContainsString('"b" IS NOT NULL', $result->query); + + $this->assertStringContainsString('JOIN `orders` AS `o`', $result->query); } - public function testSetWrapCharWithNotExists(): void + public function testJoinWhereWithMultipleOnConditions(): void { - $result = (new Builder())->setWrapChar('"') - ->from('t') - ->filter([Query::notExists('c')]) + $result = (new Builder()) + ->from('users') + ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + $join->on('users.id', 'orders.user_id') + ->on('users.tenant_id', 'orders.tenant_id'); + }) ->build(); - $this->assertStringContainsString('"c" IS NULL', $result->query); + + $this->assertStringContainsString( + 'ON `users`.`id` = `orders`.`user_id` AND `users`.`tenant_id` = `orders`.`tenant_id`', + $result->query + ); } - public function testSetWrapCharCursorNotAffected(): void + // WHERE IN subquery combined with regular filters + + public function testWhereInSubqueryWithRegularFilters(): void { - $result = (new Builder())->setWrapChar('"') - ->from('t') - ->cursorAfter('abc') + $sub = (new Builder())->from('vip_users')->select(['id']); + + $result = (new Builder()) + ->from('orders') + ->filter([ + Query::greaterThan('amount', 100), + Query::equal('status', ['paid']), + ]) + ->filterWhereIn('user_id', $sub) ->build(); - // _cursor is now properly wrapped with the configured wrap character - $this->assertStringContainsString('"_cursor" > ?', $result->query); - } - public function testSetWrapCharWithToRawSql(): void - { - $sql = (new Builder())->setWrapChar('"') - ->from('t') - ->filter([Query::equal('name', ['test'])]) - ->limit(5) - ->toRawSql(); - $this->assertStringContainsString('"t"', $sql); - $this->assertStringContainsString('"name"', $sql); - $this->assertStringContainsString("'test'", $sql); - $this->assertStringContainsString('5', $sql); + $this->assertStringContainsString('`amount` > ?', $result->query); + $this->assertStringContainsString('`status` IN (?)', $result->query); + $this->assertStringContainsString('`user_id` IN (SELECT', $result->query); } - // ══════════════════════════════════════════ - // Reset Behavior - // ══════════════════════════════════════════ + // Multiple subqueries - public function testResetFollowedByUnion(): void + public function testMultipleWhereInSubqueries(): void { - $builder = (new Builder()) - ->from('a') - ->union((new Builder())->from('old')); - $builder->reset()->from('b'); - $result = $builder->build(); - $this->assertEquals('SELECT * FROM `b`', $result->query); - $this->assertStringNotContainsString('UNION', $result->query); - } + $sub1 = (new Builder())->from('admins')->select(['id']); + $sub2 = (new Builder())->from('departments')->select(['id']); - public function testResetClearsBindingsAfterBuild(): void - { - $builder = (new Builder())->from('t')->filter([Query::equal('x', [1])]); - $builder->build(); - $this->assertNotEmpty($builder->getBindings()); - $builder->reset()->from('t'); - $result = $builder->build(); - $this->assertEquals([], $result->bindings); + $result = (new Builder()) + ->from('users') + ->filterWhereIn('id', $sub1) + ->filterWhereNotIn('dept_id', $sub2) + ->build(); + + $this->assertStringContainsString('`id` IN (SELECT', $result->query); + $this->assertStringContainsString('`dept_id` NOT IN (SELECT', $result->query); } - // ══════════════════════════════════════════ - // Missing Binding Assertions - // ══════════════════════════════════════════ + // insertOrIgnore - public function testSortAscBindingsEmpty(): void + public function testInsertOrIgnoreMySQL(): void { - $result = (new Builder())->from('t')->sortAsc('name')->build(); - $this->assertEquals([], $result->bindings); + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John', 'email' => 'john@example.com']) + ->insertOrIgnore(); + + $this->assertStringStartsWith('INSERT IGNORE INTO', $result->query); + $this->assertEquals(['John', 'john@example.com'], $result->bindings); } - public function testSortDescBindingsEmpty(): void + // toRawSql with various types + + public function testToRawSqlWithMixedTypes(): void { - $result = (new Builder())->from('t')->sortDesc('name')->build(); - $this->assertEquals([], $result->bindings); + $sql = (new Builder()) + ->from('users') + ->filter([ + Query::equal('name', ['O\'Brien']), + Query::equal('active', [true]), + Query::equal('age', [25]), + ]) + ->toRawSql(); + + $this->assertStringContainsString("'O''Brien'", $sql); + $this->assertStringContainsString('1', $sql); + $this->assertStringContainsString('25', $sql); } - public function testSortRandomBindingsEmpty(): void + // page() helper + + public function testPageFirstPageOffsetZero(): void { - $result = (new Builder())->from('t')->sortRandom()->build(); - $this->assertEquals([], $result->bindings); + $result = (new Builder()) + ->from('users') + ->page(1, 10) + ->build(); + + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertContains(10, $result->bindings); + $this->assertContains(0, $result->bindings); } - public function testDistinctBindingsEmpty(): void + public function testPageThirdPage(): void { - $result = (new Builder())->from('t')->distinct()->build(); - $this->assertEquals([], $result->bindings); + $result = (new Builder()) + ->from('users') + ->page(3, 25) + ->build(); + + $this->assertContains(25, $result->bindings); + $this->assertContains(50, $result->bindings); } - public function testJoinBindingsEmpty(): void + // when() conditional + + public function testWhenTrueAppliesCallback(): void { - $result = (new Builder())->from('t')->join('other', 'a', 'b')->build(); - $this->assertEquals([], $result->bindings); + $result = (new Builder()) + ->from('users') + ->when(true, fn (Builder $b) => $b->filter([Query::equal('active', [true])])) + ->build(); + + $this->assertStringContainsString('WHERE', $result->query); } - public function testCrossJoinBindingsEmpty(): void + public function testWhenFalseSkipsCallback(): void { - $result = (new Builder())->from('t')->crossJoin('other')->build(); - $this->assertEquals([], $result->bindings); + $result = (new Builder()) + ->from('users') + ->when(false, fn (Builder $b) => $b->filter([Query::equal('active', [true])])) + ->build(); + + $this->assertStringNotContainsString('WHERE', $result->query); } - public function testGroupByBindingsEmpty(): void + // Locking combined with query + + public function testLockingAppearsAtEnd(): void { - $result = (new Builder())->from('t')->groupBy(['status'])->build(); - $this->assertEquals([], $result->bindings); + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('id', [1])]) + ->limit(1) + ->forUpdate() + ->build(); + + $this->assertStringEndsWith('FOR UPDATE', $result->query); } - public function testCountWithAliasBindingsEmpty(): void + // CTE with main query bindings + + public function testCteBindingOrder(): void { - $result = (new Builder())->from('t')->count('*', 'total')->build(); - $this->assertEquals([], $result->bindings); + $cte = (new Builder())->from('orders') + ->filter([Query::equal('status', ['paid'])]); + + $result = (new Builder()) + ->with('paid_orders', $cte) + ->from('paid_orders') + ->filter([Query::greaterThan('amount', 100)]) + ->build(); + + // CTE bindings come first + $this->assertEquals(['paid', 100], $result->bindings); } } diff --git a/tests/Query/Builder/PostgreSQLTest.php b/tests/Query/Builder/PostgreSQLTest.php new file mode 100644 index 0000000..765db31 --- /dev/null +++ b/tests/Query/Builder/PostgreSQLTest.php @@ -0,0 +1,2336 @@ +assertInstanceOf(Compiler::class, new Builder()); + } + + public function testImplementsSelects(): void + { + $this->assertInstanceOf(Selects::class, new Builder()); + } + + public function testImplementsAggregates(): void + { + $this->assertInstanceOf(Aggregates::class, new Builder()); + } + + public function testImplementsJoins(): void + { + $this->assertInstanceOf(Joins::class, new Builder()); + } + + public function testImplementsUnions(): void + { + $this->assertInstanceOf(Unions::class, new Builder()); + } + + public function testImplementsCTEs(): void + { + $this->assertInstanceOf(CTEs::class, new Builder()); + } + + public function testImplementsInserts(): void + { + $this->assertInstanceOf(Inserts::class, new Builder()); + } + + public function testImplementsUpdates(): void + { + $this->assertInstanceOf(Updates::class, new Builder()); + } + + public function testImplementsDeletes(): void + { + $this->assertInstanceOf(Deletes::class, new Builder()); + } + + public function testImplementsHooks(): void + { + $this->assertInstanceOf(Hooks::class, new Builder()); + } + + public function testImplementsTransactions(): void + { + $this->assertInstanceOf(Transactions::class, new Builder()); + } + + public function testImplementsLocking(): void + { + $this->assertInstanceOf(Locking::class, new Builder()); + } + + public function testImplementsUpsert(): void + { + $this->assertInstanceOf(Upsert::class, new Builder()); + } + + public function testSelectWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->select(['a', 'b', 'c']) + ->build(); + + $this->assertEquals('SELECT "a", "b", "c" FROM "t"', $result->query); + } + + public function testFromWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('my_table') + ->build(); + + $this->assertEquals('SELECT * FROM "my_table"', $result->query); + } + + public function testFilterWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('col', [1])]) + ->build(); + + $this->assertEquals('SELECT * FROM "t" WHERE "col" IN (?)', $result->query); + } + + public function testSortWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + + $this->assertEquals('SELECT * FROM "t" ORDER BY "name" ASC, "age" DESC', $result->query); + } + + public function testJoinWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.uid') + ->build(); + + $this->assertEquals( + 'SELECT * FROM "users" JOIN "orders" ON "users"."id" = "orders"."uid"', + $result->query + ); + } + + public function testLeftJoinWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('users') + ->leftJoin('profiles', 'users.id', 'profiles.uid') + ->build(); + + $this->assertEquals( + 'SELECT * FROM "users" LEFT JOIN "profiles" ON "users"."id" = "profiles"."uid"', + $result->query + ); + } + + public function testRightJoinWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('users') + ->rightJoin('orders', 'users.id', 'orders.uid') + ->build(); + + $this->assertEquals( + 'SELECT * FROM "users" RIGHT JOIN "orders" ON "users"."id" = "orders"."uid"', + $result->query + ); + } + + public function testCrossJoinWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('a') + ->crossJoin('b') + ->build(); + + $this->assertEquals('SELECT * FROM "a" CROSS JOIN "b"', $result->query); + } + + public function testAggregationWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->sum('price', 'total') + ->build(); + + $this->assertEquals('SELECT SUM("price") AS "total" FROM "t"', $result->query); + } + + public function testGroupByWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->groupBy(['status', 'country']) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS "cnt" FROM "t" GROUP BY "status", "country"', + $result->query + ); + } + + public function testHavingWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + + $this->assertStringContainsString('HAVING "cnt" > ?', $result->query); + } + + public function testDistinctWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['status']) + ->build(); + + $this->assertEquals('SELECT DISTINCT "status" FROM "t"', $result->query); + } + + public function testIsNullWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNull('deleted')]) + ->build(); + + $this->assertEquals('SELECT * FROM "t" WHERE "deleted" IS NULL', $result->query); + } + + public function testRandomUsesRandomFunction(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->build(); + + $this->assertEquals('SELECT * FROM "t" ORDER BY RANDOM()', $result->query); + } + + public function testRegexUsesTildeOperator(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('slug', '^test')]) + ->build(); + + $this->assertEquals('SELECT * FROM "t" WHERE "slug" ~ ?', $result->query); + $this->assertEquals(['^test'], $result->bindings); + } + + public function testSearchUsesToTsvector(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('body', 'hello')]) + ->build(); + + $this->assertEquals('SELECT * FROM "t" WHERE to_tsvector("body") @@ plainto_tsquery(?)', $result->query); + $this->assertEquals(['hello'], $result->bindings); + } + + public function testNotSearchUsesToTsvector(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notSearch('body', 'spam')]) + ->build(); + + $this->assertEquals('SELECT * FROM "t" WHERE NOT (to_tsvector("body") @@ plainto_tsquery(?))', $result->query); + $this->assertEquals(['spam'], $result->bindings); + } + + public function testUpsertUsesOnConflict(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com']) + ->onConflict(['id'], ['name', 'email']) + ->upsert(); + + $this->assertEquals( + 'INSERT INTO "users" ("id", "name", "email") VALUES (?, ?, ?) ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name", "email" = EXCLUDED."email"', + $result->query + ); + $this->assertEquals([1, 'Alice', 'alice@example.com'], $result->bindings); + } + + public function testOffsetWithoutLimitEmitsOffset(): void + { + $result = (new Builder()) + ->from('t') + ->offset(10) + ->build(); + + $this->assertEquals('SELECT * FROM "t" OFFSET ?', $result->query); + $this->assertEquals([10], $result->bindings); + } + + public function testOffsetWithLimitEmitsBoth(): void + { + $result = (new Builder()) + ->from('t') + ->limit(25) + ->offset(10) + ->build(); + + $this->assertEquals('SELECT * FROM "t" LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([25, 10], $result->bindings); + } + + public function testConditionProviderWithDoubleQuotes(): void + { + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('raw_condition = 1', []); + } + }; + + $result = (new Builder()) + ->from('t') + ->addHook($hook) + ->build(); + + $this->assertStringContainsString('WHERE raw_condition = 1', $result->query); + $this->assertStringContainsString('FROM "t"', $result->query); + } + + public function testInsertWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'age' => 30]) + ->insert(); + + $this->assertEquals( + 'INSERT INTO "users" ("name", "age") VALUES (?, ?)', + $result->query + ); + $this->assertEquals(['Alice', 30], $result->bindings); + } + + public function testUpdateWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Bob']) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertEquals( + 'UPDATE "users" SET "name" = ? WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals(['Bob', 1], $result->bindings); + } + + public function testDeleteWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('id', [1])]) + ->delete(); + + $this->assertEquals( + 'DELETE FROM "users" WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals([1], $result->bindings); + } + + public function testSavepointWrapsWithDoubleQuotes(): void + { + $result = (new Builder())->savepoint('sp1'); + + $this->assertEquals('SAVEPOINT "sp1"', $result->query); + } + + public function testForUpdateWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->forUpdate() + ->build(); + + $this->assertStringContainsString('FOR UPDATE', $result->query); + $this->assertStringContainsString('FROM "t"', $result->query); + } + // Spatial feature interface + + public function testImplementsSpatial(): void + { + $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Spatial::class, new Builder()); + } + + public function testFilterDistanceMeters(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('coords', [40.7128, -74.0060], '<', 5000.0, true) + ->build(); + + $this->assertStringContainsString('ST_Distance(("coords"::geography), ST_SetSRID(ST_GeomFromText(?), 4326)::geography) < ?', $result->query); + $this->assertEquals('POINT(40.7128 -74.006)', $result->bindings[0]); + $this->assertEquals(5000.0, $result->bindings[1]); + } + + public function testFilterIntersectsPoint(): void + { + $result = (new Builder()) + ->from('zones') + ->filterIntersects('area', [1.0, 2.0]) + ->build(); + + $this->assertStringContainsString('ST_Intersects("area", ST_GeomFromText(?, 4326))', $result->query); + } + + public function testFilterCovers(): void + { + $result = (new Builder()) + ->from('zones') + ->filterCovers('area', [1.0, 2.0]) + ->build(); + + $this->assertStringContainsString('ST_Covers("area", ST_GeomFromText(?, 4326))', $result->query); + } + + public function testFilterCrosses(): void + { + $result = (new Builder()) + ->from('roads') + ->filterCrosses('path', [[0, 0], [1, 1]]) + ->build(); + + $this->assertStringContainsString('ST_Crosses', $result->query); + } + // VectorSearch feature interface + + public function testImplementsVectorSearch(): void + { + $this->assertInstanceOf(\Utopia\Query\Builder\Feature\VectorSearch::class, new Builder()); + } + + public function testOrderByVectorDistanceCosine(): void + { + $result = (new Builder()) + ->from('embeddings') + ->orderByVectorDistance('embedding', [0.1, 0.2, 0.3], 'cosine') + ->limit(10) + ->build(); + + $this->assertStringContainsString('("embedding" <=> ?::vector) ASC', $result->query); + $this->assertEquals('[0.1,0.2,0.3]', $result->bindings[0]); + } + + public function testOrderByVectorDistanceEuclidean(): void + { + $result = (new Builder()) + ->from('embeddings') + ->orderByVectorDistance('embedding', [1.0, 2.0], 'euclidean') + ->limit(5) + ->build(); + + $this->assertStringContainsString('("embedding" <-> ?::vector) ASC', $result->query); + } + + public function testOrderByVectorDistanceDot(): void + { + $result = (new Builder()) + ->from('embeddings') + ->orderByVectorDistance('embedding', [1.0, 2.0], 'dot') + ->limit(5) + ->build(); + + $this->assertStringContainsString('("embedding" <#> ?::vector) ASC', $result->query); + } + + public function testVectorFilterCosine(): void + { + $result = (new Builder()) + ->from('embeddings') + ->filter([Query::vectorCosine('embedding', [0.1, 0.2])]) + ->build(); + + $this->assertStringContainsString('("embedding" <=> ?::vector)', $result->query); + } + + public function testVectorFilterEuclidean(): void + { + $result = (new Builder()) + ->from('embeddings') + ->filter([Query::vectorEuclidean('embedding', [0.1, 0.2])]) + ->build(); + + $this->assertStringContainsString('("embedding" <-> ?::vector)', $result->query); + } + + public function testVectorFilterDot(): void + { + $result = (new Builder()) + ->from('embeddings') + ->filter([Query::vectorDot('embedding', [0.1, 0.2])]) + ->build(); + + $this->assertStringContainsString('("embedding" <#> ?::vector)', $result->query); + } + // JSON feature interface + + public function testImplementsJson(): void + { + $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Json::class, new Builder()); + } + + public function testFilterJsonContains(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonContains('tags', 'php') + ->build(); + + $this->assertStringContainsString('"tags" @> ?::jsonb', $result->query); + } + + public function testFilterJsonNotContains(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonNotContains('tags', 'old') + ->build(); + + $this->assertStringContainsString('NOT ("tags" @> ?::jsonb)', $result->query); + } + + public function testFilterJsonOverlaps(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'go']) + ->build(); + + $this->assertStringContainsString("\"tags\" ?| ARRAY", $result->query); + } + + public function testFilterJsonPath(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('metadata', 'level', '>', 5) + ->build(); + + $this->assertStringContainsString("\"metadata\"->>'level' > ?", $result->query); + $this->assertEquals(5, $result->bindings[0]); + } + + public function testSetJsonAppend(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonAppend('tags', ['new']) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringContainsString('|| ?::jsonb', $result->query); + } + + public function testSetJsonPrepend(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonPrepend('tags', ['first']) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringContainsString('?::jsonb ||', $result->query); + } + + public function testSetJsonInsert(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonInsert('tags', 0, 'inserted') + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringContainsString('jsonb_insert', $result->query); + } + // Window functions + + public function testImplementsWindows(): void + { + $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Windows::class, new Builder()); + } + + public function testSelectWindowRowNumber(): void + { + $result = (new Builder()) + ->from('orders') + ->selectWindow('ROW_NUMBER()', 'rn', ['customer_id'], ['created_at']) + ->build(); + + $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY "customer_id" ORDER BY "created_at" ASC) AS "rn"', $result->query); + } + + public function testSelectWindowRankDesc(): void + { + $result = (new Builder()) + ->from('scores') + ->selectWindow('RANK()', 'rank', null, ['-score']) + ->build(); + + $this->assertStringContainsString('RANK() OVER (ORDER BY "score" DESC) AS "rank"', $result->query); + } + // CASE integration + + public function testSelectCaseExpression(): void + { + $case = (new \Utopia\Query\Builder\Case\Builder()) + ->when('status = ?', '?', ['active'], ['Active']) + ->elseResult('?', ['Other']) + ->alias('label') + ->build(); + + $result = (new Builder()) + ->from('users') + ->select(['id']) + ->selectCase($case) + ->build(); + + $this->assertStringContainsString('CASE WHEN status = ? THEN ? ELSE ? END AS label', $result->query); + $this->assertEquals(['active', 'Active', 'Other'], $result->bindings); + } + // Does NOT implement Hints + + public function testDoesNotImplementHints(): void + { + $builder = new Builder(); + $this->assertNotInstanceOf(\Utopia\Query\Builder\Feature\Hints::class, $builder); // @phpstan-ignore method.alreadyNarrowedType + } + // Reset clears new state + + public function testResetClearsVectorOrder(): void + { + $builder = (new Builder()) + ->from('embeddings') + ->orderByVectorDistance('embedding', [0.1], 'cosine'); + + $builder->reset(); + + $result = $builder->from('embeddings')->build(); + $this->assertStringNotContainsString('<=>', $result->query); + } + + public function testFilterNotIntersectsPoint(): void + { + $result = (new Builder()) + ->from('zones') + ->filterNotIntersects('zone', [1.0, 2.0]) + ->build(); + + $this->assertStringContainsString('NOT ST_Intersects', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + } + + public function testFilterNotCrossesLinestring(): void + { + $result = (new Builder()) + ->from('roads') + ->filterNotCrosses('path', [[0, 0], [1, 1]]) + ->build(); + + $this->assertStringContainsString('NOT ST_Crosses', $result->query); + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertStringContainsString('LINESTRING', $binding); + } + + public function testFilterOverlapsPolygon(): void + { + $result = (new Builder()) + ->from('maps') + ->filterOverlaps('area', [[[0, 0], [1, 0], [1, 1], [0, 0]]]) + ->build(); + + $this->assertStringContainsString('ST_Overlaps', $result->query); + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertStringContainsString('POLYGON', $binding); + } + + public function testFilterNotOverlaps(): void + { + $result = (new Builder()) + ->from('maps') + ->filterNotOverlaps('area', [1.0, 2.0]) + ->build(); + + $this->assertStringContainsString('NOT ST_Overlaps', $result->query); + } + + public function testFilterTouches(): void + { + $result = (new Builder()) + ->from('zones') + ->filterTouches('zone', [5.0, 10.0]) + ->build(); + + $this->assertStringContainsString('ST_Touches', $result->query); + } + + public function testFilterNotTouches(): void + { + $result = (new Builder()) + ->from('zones') + ->filterNotTouches('zone', [5.0, 10.0]) + ->build(); + + $this->assertStringContainsString('NOT ST_Touches', $result->query); + } + + public function testFilterCoversUsesSTCovers(): void + { + $result = (new Builder()) + ->from('regions') + ->filterCovers('region', [1.0, 2.0]) + ->build(); + + $this->assertStringContainsString('ST_Covers', $result->query); + $this->assertStringNotContainsString('ST_Contains', $result->query); + } + + public function testFilterNotCovers(): void + { + $result = (new Builder()) + ->from('regions') + ->filterNotCovers('region', [1.0, 2.0]) + ->build(); + + $this->assertStringContainsString('NOT ST_Covers', $result->query); + } + + public function testFilterSpatialEquals(): void + { + $result = (new Builder()) + ->from('geoms') + ->filterSpatialEquals('geom', [3.0, 4.0]) + ->build(); + + $this->assertStringContainsString('ST_Equals', $result->query); + } + + public function testFilterNotSpatialEquals(): void + { + $result = (new Builder()) + ->from('geoms') + ->filterNotSpatialEquals('geom', [3.0, 4.0]) + ->build(); + + $this->assertStringContainsString('NOT ST_Equals', $result->query); + } + + public function testFilterDistanceGreaterThan(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '>', 500.0) + ->build(); + + $this->assertStringContainsString('> ?', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + $this->assertEquals(500.0, $result->bindings[1]); + } + + public function testFilterDistanceWithoutMeters(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '<', 50.0, false) + ->build(); + + $this->assertStringContainsString('ST_Distance("loc", ST_GeomFromText(?)) < ?', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + $this->assertEquals(50.0, $result->bindings[1]); + } + + public function testVectorOrderWithExistingOrderBy(): void + { + $result = (new Builder()) + ->from('items') + ->sortAsc('name') + ->orderByVectorDistance('embedding', [0.1], 'cosine') + ->build(); + + $this->assertStringContainsString('ORDER BY', $result->query); + $pos_vector = strpos($result->query, '<=>'); + $pos_name = strpos($result->query, '"name"'); + $this->assertNotFalse($pos_vector); + $this->assertNotFalse($pos_name); + $this->assertLessThan($pos_name, $pos_vector); + } + + public function testVectorOrderWithLimit(): void + { + $result = (new Builder()) + ->from('items') + ->orderByVectorDistance('emb', [0.1, 0.2], 'cosine') + ->limit(10) + ->build(); + + $this->assertStringContainsString('ORDER BY', $result->query); + $pos_order = strpos($result->query, 'ORDER BY'); + $pos_limit = strpos($result->query, 'LIMIT'); + $this->assertNotFalse($pos_order); + $this->assertNotFalse($pos_limit); + $this->assertLessThan($pos_limit, $pos_order); + + // Vector JSON binding comes before limit value binding + $vectorIdx = array_search('[0.1,0.2]', $result->bindings, true); + $limitIdx = array_search(10, $result->bindings, true); + $this->assertNotFalse($vectorIdx); + $this->assertNotFalse($limitIdx); + $this->assertLessThan($limitIdx, $vectorIdx); + } + + public function testVectorOrderDefaultMetric(): void + { + $result = (new Builder()) + ->from('items') + ->orderByVectorDistance('emb', [0.5]) + ->build(); + + $this->assertStringContainsString('<=>', $result->query); + } + + public function testVectorFilterCosineBindings(): void + { + $result = (new Builder()) + ->from('embeddings') + ->filter([Query::vectorCosine('embedding', [0.1, 0.2])]) + ->build(); + + $this->assertStringContainsString('("embedding" <=> ?::vector)', $result->query); + $this->assertEquals(json_encode([0.1, 0.2]), $result->bindings[0]); + } + + public function testVectorFilterEuclideanBindings(): void + { + $result = (new Builder()) + ->from('embeddings') + ->filter([Query::vectorEuclidean('embedding', [0.1])]) + ->build(); + + $this->assertStringContainsString('("embedding" <-> ?::vector)', $result->query); + $this->assertEquals(json_encode([0.1]), $result->bindings[0]); + } + + public function testFilterJsonNotContainsAdmin(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonNotContains('meta', 'admin') + ->build(); + + $this->assertStringContainsString('NOT ("meta" @> ?::jsonb)', $result->query); + } + + public function testFilterJsonOverlapsArray(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'js']) + ->build(); + + $this->assertStringContainsString('"tags" ?| ARRAY(SELECT jsonb_array_elements_text(?::jsonb))', $result->query); + } + + public function testFilterJsonPathComparison(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('data', 'age', '>=', 21) + ->build(); + + $this->assertStringContainsString("\"data\"->>'age' >= ?", $result->query); + $this->assertEquals(21, $result->bindings[0]); + } + + public function testFilterJsonPathEquality(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('meta', 'status', '=', 'active') + ->build(); + + $this->assertStringContainsString("\"meta\"->>'status' = ?", $result->query); + $this->assertEquals('active', $result->bindings[0]); + } + + public function testSetJsonRemove(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonRemove('tags', 'old') + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringContainsString('"tags" - ?', $result->query); + $this->assertContains(json_encode('old'), $result->bindings); + } + + public function testSetJsonIntersect(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonIntersect('tags', ['a', 'b']) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringContainsString('jsonb_agg(elem)', $result->query); + $this->assertStringContainsString('elem <@ ?::jsonb', $result->query); + } + + public function testSetJsonDiff(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonDiff('tags', ['x']) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringContainsString('NOT elem <@ ?::jsonb', $result->query); + } + + public function testSetJsonUnique(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonUnique('tags') + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringContainsString('jsonb_agg(DISTINCT elem)', $result->query); + } + + public function testSetJsonAppendBindings(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonAppend('tags', ['new']) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringContainsString('|| ?::jsonb', $result->query); + $this->assertContains(json_encode(['new']), $result->bindings); + } + + public function testSetJsonPrependPutsNewArrayFirst(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonPrepend('items', ['first']) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringContainsString('?::jsonb || COALESCE(', $result->query); + } + + public function testMultipleCTEs(): void + { + $a = (new Builder())->from('x')->filter([Query::equal('status', ['active'])]); + $b = (new Builder())->from('y')->filter([Query::equal('type', ['premium'])]); + + $result = (new Builder()) + ->with('a', $a) + ->with('b', $b) + ->from('a') + ->build(); + + $this->assertStringContainsString('WITH "a" AS (', $result->query); + $this->assertStringContainsString('), "b" AS (', $result->query); + } + + public function testCTEWithRecursive(): void + { + $sub = (new Builder())->from('categories'); + + $result = (new Builder()) + ->withRecursive('tree', $sub) + ->from('tree') + ->build(); + + $this->assertStringContainsString('WITH RECURSIVE', $result->query); + } + + public function testCTEBindingOrder(): void + { + $cteQuery = (new Builder())->from('orders')->filter([Query::equal('status', ['shipped'])]); + + $result = (new Builder()) + ->with('shipped', $cteQuery) + ->from('shipped') + ->filter([Query::equal('total', [100])]) + ->build(); + + // CTE bindings come first + $this->assertEquals('shipped', $result->bindings[0]); + $this->assertEquals(100, $result->bindings[1]); + } + + public function testInsertSelectWithFilter(): void + { + $source = (new Builder()) + ->from('orders') + ->select(['customer_id', 'total']) + ->filter([Query::greaterThan('total', 100)]); + + $result = (new Builder()) + ->into('big_orders') + ->fromSelect(['customer_id', 'total'], $source) + ->insertSelect(); + + $this->assertStringContainsString('INSERT INTO "big_orders"', $result->query); + $this->assertStringContainsString('SELECT', $result->query); + $this->assertContains(100, $result->bindings); + } + + public function testInsertSelectThrowsWithoutSource(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('target') + ->insertSelect(); + } + + public function testUnionAll(): void + { + $other = (new Builder())->from('b'); + + $result = (new Builder()) + ->from('a') + ->unionAll($other) + ->build(); + + $this->assertStringContainsString('UNION ALL', $result->query); + } + + public function testIntersect(): void + { + $other = (new Builder())->from('b'); + + $result = (new Builder()) + ->from('a') + ->intersect($other) + ->build(); + + $this->assertStringContainsString('INTERSECT', $result->query); + } + + public function testExcept(): void + { + $other = (new Builder())->from('b'); + + $result = (new Builder()) + ->from('a') + ->except($other) + ->build(); + + $this->assertStringContainsString('EXCEPT', $result->query); + } + + public function testUnionWithBindingsOrder(): void + { + $other = (new Builder())->from('b')->filter([Query::equal('type', ['beta'])]); + + $result = (new Builder()) + ->from('a') + ->filter([Query::equal('type', ['alpha'])]) + ->union($other) + ->build(); + + $this->assertEquals('alpha', $result->bindings[0]); + $this->assertEquals('beta', $result->bindings[1]); + } + + public function testPage(): void + { + $result = (new Builder()) + ->from('items') + ->page(3, 10) + ->build(); + + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals(10, $result->bindings[0]); + $this->assertEquals(20, $result->bindings[1]); + } + + public function testOffsetWithoutLimitEmitsOffsetPostgres(): void + { + $result = (new Builder()) + ->from('items') + ->offset(5) + ->build(); + + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals([5], $result->bindings); + } + + public function testCursorAfter(): void + { + $result = (new Builder()) + ->from('items') + ->sortAsc('id') + ->cursorAfter(5) + ->limit(10) + ->build(); + + $this->assertStringContainsString('> ?', $result->query); + $this->assertContains(5, $result->bindings); + $this->assertContains(10, $result->bindings); + } + + public function testCursorBefore(): void + { + $result = (new Builder()) + ->from('items') + ->sortAsc('id') + ->cursorBefore(5) + ->limit(10) + ->build(); + + $this->assertStringContainsString('< ?', $result->query); + $this->assertContains(5, $result->bindings); + } + + public function testSelectWindowWithPartitionOnly(): void + { + $result = (new Builder()) + ->from('employees') + ->selectWindow('SUM("salary")', 'dept_total', ['dept'], null) + ->build(); + + $this->assertStringContainsString('OVER (PARTITION BY "dept")', $result->query); + } + + public function testSelectWindowNoPartitionNoOrder(): void + { + $result = (new Builder()) + ->from('employees') + ->selectWindow('COUNT(*)', 'total', null, null) + ->build(); + + $this->assertStringContainsString('OVER ()', $result->query); + } + + public function testMultipleWindowFunctions(): void + { + $result = (new Builder()) + ->from('scores') + ->selectWindow('ROW_NUMBER()', 'rn', null, ['id']) + ->selectWindow('RANK()', 'rnk', null, ['-score']) + ->build(); + + $this->assertStringContainsString('ROW_NUMBER()', $result->query); + $this->assertStringContainsString('RANK()', $result->query); + } + + public function testWindowFunctionWithDescOrder(): void + { + $result = (new Builder()) + ->from('scores') + ->selectWindow('RANK()', 'rnk', null, ['-score']) + ->build(); + + $this->assertStringContainsString('ORDER BY "score" DESC', $result->query); + } + + public function testCaseMultipleWhens(): void + { + $case = (new CaseBuilder()) + ->when('status = ?', '?', ['active'], ['Active']) + ->when('status = ?', '?', ['pending'], ['Pending']) + ->when('status = ?', '?', ['closed'], ['Closed']) + ->alias('label') + ->build(); + + $result = (new Builder()) + ->from('tickets') + ->selectCase($case) + ->build(); + + $this->assertStringContainsString('WHEN status = ? THEN ?', $result->query); + $this->assertEquals(['active', 'Active', 'pending', 'Pending', 'closed', 'Closed'], $result->bindings); + } + + public function testCaseWithoutElse(): void + { + $case = (new CaseBuilder()) + ->when('active = ?', '?', [1], ['Yes']) + ->alias('lbl') + ->build(); + + $result = (new Builder()) + ->from('users') + ->selectCase($case) + ->build(); + + $this->assertStringContainsString('CASE WHEN active = ? THEN ? END AS lbl', $result->query); + $this->assertStringNotContainsString('ELSE', $result->query); + } + + public function testSetCaseInUpdate(): void + { + $case = (new CaseBuilder()) + ->when('age >= ?', '?', [18], ['adult']) + ->elseResult('?', ['minor']) + ->build(); + + $result = (new Builder()) + ->from('users') + ->setCase('category', $case) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringContainsString('UPDATE "users" SET', $result->query); + $this->assertStringContainsString('CASE WHEN age >= ? THEN ? ELSE ? END', $result->query); + $this->assertEquals([18, 'adult', 'minor', 1], $result->bindings); + } + + public function testToRawSqlWithStrings(): void + { + $raw = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ['Alice'])]) + ->toRawSql(); + + $this->assertStringContainsString("'Alice'", $raw); + $this->assertStringNotContainsString('?', $raw); + } + + public function testToRawSqlEscapesSingleQuotes(): void + { + $raw = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ["O'Brien"])]) + ->toRawSql(); + + $this->assertStringContainsString("'O''Brien'", $raw); + } + + public function testBuildWithoutTableThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->build(); + } + + public function testInsertWithoutRowsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->into('users')->insert(); + } + + public function testUpdateWithoutAssignmentsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->from('users')->filter([Query::equal('id', [1])])->update(); + } + + public function testUpsertWithoutConflictKeysThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->upsert(); + } + + public function testBatchInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'age' => 30]) + ->set(['name' => 'Bob', 'age' => 25]) + ->insert(); + + $this->assertStringContainsString('VALUES (?, ?), (?, ?)', $result->query); + $this->assertEquals(['Alice', 30, 'Bob', 25], $result->bindings); + } + + public function testBatchInsertMismatchedColumnsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'age' => 30]) + ->set(['name' => 'Bob', 'email' => 'bob@test.com']) + ->insert(); + } + + public function testRegexUsesTildeWithCaretPattern(): void + { + $result = (new Builder()) + ->from('items') + ->filter([Query::regex('s', '^t')]) + ->build(); + + $this->assertStringContainsString('"s" ~ ?', $result->query); + $this->assertEquals(['^t'], $result->bindings); + } + + public function testSearchUsesToTsvectorWithMultipleWords(): void + { + $result = (new Builder()) + ->from('articles') + ->filter([Query::search('body', 'hello world')]) + ->build(); + + $this->assertStringContainsString('to_tsvector("body") @@ plainto_tsquery(?)', $result->query); + $this->assertEquals(['hello world'], $result->bindings); + } + + public function testUpsertUsesOnConflictDoUpdateSet(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], ['name']) + ->upsert(); + + $this->assertStringContainsString('ON CONFLICT ("id") DO UPDATE SET', $result->query); + } + + public function testUpsertConflictUpdateColumnNotInRowThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], ['nonexistent']) + ->upsert(); + } + + public function testForUpdateLocking(): void + { + $result = (new Builder()) + ->from('accounts') + ->forUpdate() + ->build(); + + $this->assertStringContainsString('FOR UPDATE', $result->query); + } + + public function testForShareLocking(): void + { + $result = (new Builder()) + ->from('accounts') + ->forShare() + ->build(); + + $this->assertStringContainsString('FOR SHARE', $result->query); + } + + public function testBeginCommitRollback(): void + { + $builder = new Builder(); + + $begin = $builder->begin(); + $this->assertEquals('BEGIN', $begin->query); + + $commit = $builder->commit(); + $this->assertEquals('COMMIT', $commit->query); + + $rollback = $builder->rollback(); + $this->assertEquals('ROLLBACK', $rollback->query); + } + + public function testSavepointDoubleQuotes(): void + { + $result = (new Builder())->savepoint('sp1'); + + $this->assertEquals('SAVEPOINT "sp1"', $result->query); + } + + public function testReleaseSavepointDoubleQuotes(): void + { + $result = (new Builder())->releaseSavepoint('sp1'); + + $this->assertEquals('RELEASE SAVEPOINT "sp1"', $result->query); + } + + public function testGroupByWithHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupBy(['customer_id']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + + $this->assertStringContainsString('GROUP BY "customer_id"', $result->query); + $this->assertStringContainsString('HAVING "cnt" > ?', $result->query); + $this->assertContains(5, $result->bindings); + } + + public function testGroupByMultipleColumns(): void + { + $result = (new Builder()) + ->from('sales') + ->count('*', 'cnt') + ->groupBy(['a', 'b']) + ->build(); + + $this->assertStringContainsString('GROUP BY "a", "b"', $result->query); + } + + public function testWhenTrue(): void + { + $result = (new Builder()) + ->from('items') + ->when(true, fn (Builder $b) => $b->limit(5)) + ->build(); + + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertContains(5, $result->bindings); + } + + public function testWhenFalse(): void + { + $result = (new Builder()) + ->from('items') + ->when(false, fn (Builder $b) => $b->limit(5)) + ->build(); + + $this->assertStringNotContainsString('LIMIT', $result->query); + } + + public function testResetClearsCTEs(): void + { + $sub = (new Builder())->from('orders'); + + $builder = (new Builder()) + ->with('cte', $sub) + ->from('cte'); + + $builder->reset(); + + $result = $builder->from('items')->build(); + $this->assertStringNotContainsString('WITH', $result->query); + } + + public function testResetClearsJsonSets(): void + { + $builder = (new Builder()) + ->from('docs') + ->setJsonAppend('tags', ['new']); + + $builder->reset(); + + $result = $builder + ->from('docs') + ->set(['name' => 'test']) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertStringNotContainsString('jsonb', $result->query); + } + + public function testEqualEmptyArrayReturnsFalse(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [])]) + ->build(); + + $this->assertStringContainsString('1 = 0', $result->query); + } + + public function testEqualWithNullOnly(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [null])]) + ->build(); + + $this->assertStringContainsString('"x" IS NULL', $result->query); + } + + public function testEqualWithNullAndValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [1, null])]) + ->build(); + + $this->assertStringContainsString('("x" IN (?) OR "x" IS NULL)', $result->query); + $this->assertContains(1, $result->bindings); + } + + public function testNotEqualWithNullAndValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', [1, null])]) + ->build(); + + $this->assertStringContainsString('("x" != ? AND "x" IS NOT NULL)', $result->query); + } + + public function testAndWithTwoFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::and([Query::greaterThan('age', 18), Query::lessThan('age', 65)])]) + ->build(); + + $this->assertStringContainsString('("age" > ? AND "age" < ?)', $result->query); + } + + public function testOrWithTwoFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::or([Query::equal('role', ['admin']), Query::equal('role', ['editor'])])]) + ->build(); + + $this->assertStringContainsString('("role" IN (?) OR "role" IN (?))', $result->query); + } + + public function testEmptyAndReturnsTrue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::and([])]) + ->build(); + + $this->assertStringContainsString('1 = 1', $result->query); + } + + public function testEmptyOrReturnsFalse(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::or([])]) + ->build(); + + $this->assertStringContainsString('1 = 0', $result->query); + } + + public function testBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 18, 65)]) + ->build(); + + $this->assertStringContainsString('"age" BETWEEN ? AND ?', $result->query); + $this->assertEquals([18, 65], $result->bindings); + } + + public function testNotBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notBetween('score', 0, 50)]) + ->build(); + + $this->assertStringContainsString('"score" NOT BETWEEN ? AND ?', $result->query); + $this->assertEquals([0, 50], $result->bindings); + } + + public function testExistsSingleAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name'])]) + ->build(); + + $this->assertStringContainsString('("name" IS NOT NULL)', $result->query); + } + + public function testExistsMultipleAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name', 'email'])]) + ->build(); + + $this->assertStringContainsString('("name" IS NOT NULL AND "email" IS NOT NULL)', $result->query); + } + + public function testNotExistsSingleAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists(['name'])]) + ->build(); + + $this->assertStringContainsString('("name" IS NULL)', $result->query); + } + + public function testRawFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('score > ?', [10])]) + ->build(); + + $this->assertStringContainsString('score > ?', $result->query); + $this->assertContains(10, $result->bindings); + } + + public function testRawFilterEmpty(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('')]) + ->build(); + + $this->assertStringContainsString('1 = 1', $result->query); + } + + public function testStartsWithEscapesPercent(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('val', '100%')]) + ->build(); + + $this->assertStringContainsString('"val" LIKE ?', $result->query); + $this->assertEquals(['100\%%'], $result->bindings); + } + + public function testEndsWithEscapesUnderscore(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('val', 'a_b')]) + ->build(); + + $this->assertStringContainsString('"val" LIKE ?', $result->query); + $this->assertEquals(['%a\_b'], $result->bindings); + } + + public function testContainsEscapesBackslash(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('path', ['a\\b'])]) + ->build(); + + $this->assertStringContainsString('"path" LIKE ?', $result->query); + $this->assertEquals(['%a\\\\b%'], $result->bindings); + } + + public function testContainsMultipleUsesOr(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['foo', 'bar'])]) + ->build(); + + $this->assertStringContainsString('("bio" LIKE ? OR "bio" LIKE ?)', $result->query); + } + + public function testContainsAllUsesAnd(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('bio', ['foo', 'bar'])]) + ->build(); + + $this->assertStringContainsString('("bio" LIKE ? AND "bio" LIKE ?)', $result->query); + } + + public function testNotContainsMultipleUsesAnd(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', ['foo', 'bar'])]) + ->build(); + + $this->assertStringContainsString('("bio" NOT LIKE ? AND "bio" NOT LIKE ?)', $result->query); + } + + public function testDottedIdentifier(): void + { + $result = (new Builder()) + ->from('t') + ->select(['users.name']) + ->build(); + + $this->assertStringContainsString('"users"."name"', $result->query); + } + + public function testMultipleOrderBy(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + + $this->assertStringContainsString('ORDER BY "name" ASC, "age" DESC', $result->query); + } + + public function testDistinctWithSelect(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['name']) + ->build(); + + $this->assertStringContainsString('SELECT DISTINCT "name"', $result->query); + } + + public function testSumWithAlias(): void + { + $result = (new Builder()) + ->from('t') + ->sum('amount', 'total') + ->build(); + + $this->assertStringContainsString('SUM("amount") AS "total"', $result->query); + } + + public function testMultipleAggregates(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->sum('amount', 'total') + ->build(); + + $this->assertStringContainsString('COUNT(*) AS "cnt"', $result->query); + $this->assertStringContainsString('SUM("amount") AS "total"', $result->query); + } + + public function testCountWithoutAlias(): void + { + $result = (new Builder()) + ->from('t') + ->count() + ->build(); + + $this->assertStringContainsString('COUNT(*)', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testRightJoin(): void + { + $result = (new Builder()) + ->from('a') + ->rightJoin('b', 'a.id', 'b.a_id') + ->build(); + + $this->assertStringContainsString('RIGHT JOIN "b" ON "a"."id" = "b"."a_id"', $result->query); + } + + public function testCrossJoin(): void + { + $result = (new Builder()) + ->from('a') + ->crossJoin('b') + ->build(); + + $this->assertStringContainsString('CROSS JOIN "b"', $result->query); + $this->assertStringNotContainsString(' ON ', $result->query); + } + + public function testJoinInvalidOperatorThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('a') + ->join('b', 'a.id', 'b.a_id', 'INVALID') + ->build(); + } + + public function testIsNullFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNull('deleted_at')]) + ->build(); + + $this->assertStringContainsString('"deleted_at" IS NULL', $result->query); + } + + public function testIsNotNullFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNotNull('name')]) + ->build(); + + $this->assertStringContainsString('"name" IS NOT NULL', $result->query); + } + + public function testLessThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('age', 30)]) + ->build(); + + $this->assertStringContainsString('"age" < ?', $result->query); + $this->assertEquals([30], $result->bindings); + } + + public function testLessThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThanEqual('age', 30)]) + ->build(); + + $this->assertStringContainsString('"age" <= ?', $result->query); + $this->assertEquals([30], $result->bindings); + } + + public function testGreaterThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('score', 50)]) + ->build(); + + $this->assertStringContainsString('"score" > ?', $result->query); + $this->assertEquals([50], $result->bindings); + } + + public function testGreaterThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('score', 50)]) + ->build(); + + $this->assertStringContainsString('"score" >= ?', $result->query); + $this->assertEquals([50], $result->bindings); + } + + public function testDeleteWithOrderAndLimit(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['old'])]) + ->sortAsc('id') + ->limit(100) + ->delete(); + + $this->assertStringContainsString('DELETE FROM "t"', $result->query); + $this->assertStringContainsString('ORDER BY "id" ASC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + } + + public function testUpdateWithOrderAndLimit(): void + { + $result = (new Builder()) + ->from('t') + ->set(['status' => 'archived']) + ->filter([Query::equal('active', [false])]) + ->sortAsc('id') + ->limit(50) + ->update(); + + $this->assertStringContainsString('UPDATE "t" SET', $result->query); + $this->assertStringContainsString('ORDER BY "id" ASC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + } + + public function testVectorOrderBindingOrderWithFiltersAndLimit(): void + { + $result = (new Builder()) + ->from('items') + ->filter([Query::equal('status', ['active'])]) + ->orderByVectorDistance('embedding', [0.1, 0.2], 'cosine') + ->limit(10) + ->build(); + + // Bindings should be: filter bindings, then vector json, then limit value + $this->assertEquals('active', $result->bindings[0]); + $vectorJson = '[0.1,0.2]'; + $vectorIdx = array_search($vectorJson, $result->bindings, true); + $limitIdx = array_search(10, $result->bindings, true); + $this->assertNotFalse($vectorIdx); + $this->assertNotFalse($limitIdx); + $this->assertLessThan($limitIdx, $vectorIdx); + } + + // Feature 7: insertOrIgnore (PostgreSQL) + + public function testInsertOrIgnore(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John', 'email' => 'john@example.com']) + ->insertOrIgnore(); + + $this->assertEquals( + 'INSERT INTO "users" ("name", "email") VALUES (?, ?) ON CONFLICT DO NOTHING', + $result->query + ); + $this->assertEquals(['John', 'john@example.com'], $result->bindings); + } + + // Feature 8: RETURNING clause + + public function testInsertReturning(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning(['id', 'name']) + ->insert(); + + $this->assertStringContainsString('RETURNING "id", "name"', $result->query); + } + + public function testInsertReturningAll(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning() + ->insert(); + + $this->assertStringContainsString('RETURNING *', $result->query); + } + + public function testUpdateReturning(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Jane']) + ->filter([Query::equal('id', [1])]) + ->returning(['id', 'name']) + ->update(); + + $this->assertStringContainsString('RETURNING "id", "name"', $result->query); + } + + public function testDeleteReturning(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('id', [1])]) + ->returning(['id']) + ->delete(); + + $this->assertStringContainsString('RETURNING "id"', $result->query); + } + + public function testUpsertReturning(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'John', 'email' => 'john@example.com']) + ->onConflict(['id'], ['name', 'email']) + ->returning(['id']) + ->upsert(); + + $this->assertStringContainsString('RETURNING "id"', $result->query); + } + + public function testInsertOrIgnoreReturning(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning(['id']) + ->insertOrIgnore(); + + $this->assertStringContainsString('ON CONFLICT DO NOTHING RETURNING "id"', $result->query); + } + + // Feature 10: LockingOf (PostgreSQL only) + + public function testForUpdateOf(): void + { + $result = (new Builder()) + ->from('users') + ->forUpdateOf('users') + ->build(); + + $this->assertStringContainsString('FOR UPDATE OF "users"', $result->query); + } + + public function testForShareOf(): void + { + $result = (new Builder()) + ->from('users') + ->forShareOf('users') + ->build(); + + $this->assertStringContainsString('FOR SHARE OF "users"', $result->query); + } + + // Feature 1: Table Aliases (PostgreSQL quotes) + + public function testTableAliasPostgreSQL(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->build(); + + $this->assertStringContainsString('FROM "users" AS "u"', $result->query); + } + + public function testJoinAliasPostgreSQL(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->join('orders', 'u.id', 'o.user_id', '=', 'o') + ->build(); + + $this->assertStringContainsString('JOIN "orders" AS "o" ON "u"."id" = "o"."user_id"', $result->query); + } + + // Feature 2: Subqueries (PostgreSQL) + + public function testFromSubPostgreSQL(): void + { + $sub = (new Builder())->from('orders')->select(['user_id'])->groupBy(['user_id']); + $result = (new Builder()) + ->fromSub($sub, 'sub') + ->select(['user_id']) + ->build(); + + $this->assertEquals( + 'SELECT "user_id" FROM (SELECT "user_id" FROM "orders" GROUP BY "user_id") AS "sub"', + $result->query + ); + } + + // Feature 4: countDistinct (PostgreSQL) + + public function testCountDistinctPostgreSQL(): void + { + $result = (new Builder()) + ->from('orders') + ->countDistinct('user_id', 'unique_users') + ->build(); + + $this->assertEquals( + 'SELECT COUNT(DISTINCT "user_id") AS "unique_users" FROM "orders"', + $result->query + ); + } + + // Feature 9: EXPLAIN (PostgreSQL) + + public function testExplainPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->explain(); + + $this->assertStringStartsWith('EXPLAIN SELECT', $result->query); + } + + public function testExplainAnalyzePostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->explain(true); + + $this->assertStringStartsWith('EXPLAIN ANALYZE SELECT', $result->query); + } + + // Feature 10: Locking Variants (PostgreSQL) + + public function testForUpdateSkipLockedPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->forUpdateSkipLocked() + ->build(); + + $this->assertStringContainsString('FOR UPDATE SKIP LOCKED', $result->query); + } + + public function testForUpdateNoWaitPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->forUpdateNoWait() + ->build(); + + $this->assertStringContainsString('FOR UPDATE NOWAIT', $result->query); + } + + // Subquery bindings (PostgreSQL) + + public function testSubqueryBindingOrderPostgreSQL(): void + { + $sub = (new Builder())->from('orders') + ->select(['user_id']) + ->filter([Query::equal('status', ['completed'])]); + + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('role', ['admin'])]) + ->filterWhereIn('id', $sub) + ->build(); + + $this->assertEquals(['admin', 'completed'], $result->bindings); + } + + public function testFilterNotExistsPostgreSQL(): void + { + $sub = (new Builder())->from('bans')->select(['id']); + + $result = (new Builder()) + ->from('users') + ->filterNotExists($sub) + ->build(); + + $this->assertStringContainsString('NOT EXISTS (SELECT', $result->query); + } + + // Raw clauses (PostgreSQL) + + public function testOrderByRawPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->orderByRaw('NULLS LAST') + ->build(); + + $this->assertStringContainsString('ORDER BY NULLS LAST', $result->query); + } + + public function testGroupByRawPostgreSQL(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupByRaw('date_trunc(?, "created_at")', ['month']) + ->build(); + + $this->assertStringContainsString('GROUP BY date_trunc(?, "created_at")', $result->query); + $this->assertEquals(['month'], $result->bindings); + } + + public function testHavingRawPostgreSQL(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupBy(['user_id']) + ->havingRaw('SUM("amount") > ?', [1000]) + ->build(); + + $this->assertStringContainsString('HAVING SUM("amount") > ?', $result->query); + } + + // JoinWhere (PostgreSQL) + + public function testJoinWherePostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + $join->on('users.id', 'orders.user_id') + ->where('orders.amount', '>', 100); + }) + ->build(); + + $this->assertStringContainsString('JOIN "orders" ON "users"."id" = "orders"."user_id"', $result->query); + $this->assertStringContainsString('orders.amount > ?', $result->query); + $this->assertEquals([100], $result->bindings); + } + + // Insert or ignore (PostgreSQL) + + public function testInsertOrIgnorePostgreSQL(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->insertOrIgnore(); + + $this->assertStringContainsString('INSERT INTO', $result->query); + $this->assertStringContainsString('ON CONFLICT DO NOTHING', $result->query); + } + + // RETURNING with specific columns + + public function testReturningSpecificColumns(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning(['id', 'created_at']) + ->insert(); + + $this->assertStringContainsString('RETURNING "id", "created_at"', $result->query); + } + + // Locking OF combined + + public function testForUpdateOfWithFilter(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('id', [1])]) + ->forUpdateOf('users') + ->build(); + + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('FOR UPDATE OF "users"', $result->query); + } + + // PostgreSQL rename uses ALTER TABLE + + public function testFromSubClearsTablePostgreSQL(): void + { + $sub = (new Builder())->from('orders')->select(['id']); + + $result = (new Builder()) + ->fromSub($sub, 'sub') + ->build(); + + $this->assertStringContainsString('FROM (SELECT "id" FROM "orders") AS "sub"', $result->query); + } + + // countDistinct without alias + + public function testCountDistinctWithoutAliasPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->countDistinct('email') + ->build(); + + $this->assertStringContainsString('COUNT(DISTINCT "email")', $result->query); + } + + // Multiple EXISTS subqueries + + public function testMultipleExistsSubqueries(): void + { + $sub1 = (new Builder())->from('orders')->select(['id']); + $sub2 = (new Builder())->from('payments')->select(['id']); + + $result = (new Builder()) + ->from('users') + ->filterExists($sub1) + ->filterNotExists($sub2) + ->build(); + + $this->assertStringContainsString('EXISTS (SELECT', $result->query); + $this->assertStringContainsString('NOT EXISTS (SELECT', $result->query); + } + + // Left join alias PostgreSQL + + public function testLeftJoinAliasPostgreSQL(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->leftJoin('orders', 'u.id', 'o.user_id', '=', 'o') + ->build(); + + $this->assertStringContainsString('LEFT JOIN "orders" AS "o"', $result->query); + } + + // Cross join alias PostgreSQL + + public function testCrossJoinAliasPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->crossJoin('roles', 'r') + ->build(); + + $this->assertStringContainsString('CROSS JOIN "roles" AS "r"', $result->query); + } + + // ForShare locking variants + + public function testForShareSkipLockedPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->forShareSkipLocked() + ->build(); + + $this->assertStringContainsString('FOR SHARE SKIP LOCKED', $result->query); + } + + public function testForShareNoWaitPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->forShareNoWait() + ->build(); + + $this->assertStringContainsString('FOR SHARE NOWAIT', $result->query); + } + + // Reset clears new properties (PostgreSQL) + + public function testResetPostgreSQL(): void + { + $sub = (new Builder())->from('t')->select(['id']); + $builder = (new Builder()) + ->from('users', 'u') + ->filterWhereIn('id', $sub) + ->selectSub($sub, 'cnt') + ->orderByRaw('random()') + ->filterExists($sub) + ->reset(); + + $this->expectException(\Utopia\Query\Exception\ValidationException::class); + $builder->build(); + } +} diff --git a/tests/Query/Exception/UnsupportedExceptionTest.php b/tests/Query/Exception/UnsupportedExceptionTest.php new file mode 100644 index 0000000..07eefd2 --- /dev/null +++ b/tests/Query/Exception/UnsupportedExceptionTest.php @@ -0,0 +1,28 @@ +assertInstanceOf(Exception::class, $e); + } + + public function testCatchAllCompatibility(): void + { + $this->expectException(Exception::class); + throw new UnsupportedException('caught by base'); + } + + public function testMessagePreserved(): void + { + $e = new UnsupportedException('Not supported'); + $this->assertEquals('Not supported', $e->getMessage()); + } +} diff --git a/tests/Query/Exception/ValidationExceptionTest.php b/tests/Query/Exception/ValidationExceptionTest.php new file mode 100644 index 0000000..91470b6 --- /dev/null +++ b/tests/Query/Exception/ValidationExceptionTest.php @@ -0,0 +1,28 @@ +assertInstanceOf(Exception::class, $e); + } + + public function testCatchAllCompatibility(): void + { + $this->expectException(Exception::class); + throw new ValidationException('caught by base'); + } + + public function testMessagePreserved(): void + { + $e = new ValidationException('Missing table'); + $this->assertEquals('Missing table', $e->getMessage()); + } +} diff --git a/tests/Query/Hook/AttributeHookTest.php b/tests/Query/Hook/Attribute/AttributeTest.php similarity index 72% rename from tests/Query/Hook/AttributeHookTest.php rename to tests/Query/Hook/Attribute/AttributeTest.php index 453c51a..e5733c5 100644 --- a/tests/Query/Hook/AttributeHookTest.php +++ b/tests/Query/Hook/Attribute/AttributeTest.php @@ -1,15 +1,15 @@ '_uid', '$createdAt' => '_createdAt', ]); @@ -20,7 +20,7 @@ public function testMappedAttribute(): void public function testUnmappedPassthrough(): void { - $hook = new AttributeMapHook(['$id' => '_uid']); + $hook = new Map(['$id' => '_uid']); $this->assertEquals('name', $hook->resolve('name')); $this->assertEquals('status', $hook->resolve('status')); @@ -28,7 +28,7 @@ public function testUnmappedPassthrough(): void public function testEmptyMap(): void { - $hook = new AttributeMapHook([]); + $hook = new Map([]); $this->assertEquals('anything', $hook->resolve('anything')); } diff --git a/tests/Query/Hook/Filter/FilterTest.php b/tests/Query/Hook/Filter/FilterTest.php new file mode 100644 index 0000000..2bb5ed3 --- /dev/null +++ b/tests/Query/Hook/Filter/FilterTest.php @@ -0,0 +1,198 @@ +filter('users'); + + $this->assertEquals('tenant_id IN (?)', $condition->getExpression()); + $this->assertEquals(['t1'], $condition->getBindings()); + } + + public function testTenantMultipleIds(): void + { + $hook = new Tenant(['t1', 't2', 't3']); + $condition = $hook->filter('users'); + + $this->assertEquals('tenant_id IN (?, ?, ?)', $condition->getExpression()); + $this->assertEquals(['t1', 't2', 't3'], $condition->getBindings()); + } + + public function testTenantCustomColumn(): void + { + $hook = new Tenant(['t1'], 'organization_id'); + $condition = $hook->filter('users'); + + $this->assertEquals('organization_id IN (?)', $condition->getExpression()); + $this->assertEquals(['t1'], $condition->getBindings()); + } + + public function testPermissionWithRoles(): void + { + $hook = new Permission( + roles: ['role:admin', 'role:user'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + ); + $condition = $hook->filter('documents'); + + $this->assertEquals( + 'id IN (SELECT DISTINCT document_id FROM mydb_documents_perms WHERE role IN (?, ?) AND type = ?)', + $condition->getExpression() + ); + $this->assertEquals(['role:admin', 'role:user', 'read'], $condition->getBindings()); + } + + public function testPermissionEmptyRoles(): void + { + $hook = new Permission( + roles: [], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + ); + $condition = $hook->filter('documents'); + + $this->assertEquals('1 = 0', $condition->getExpression()); + $this->assertEquals([], $condition->getBindings()); + } + + public function testPermissionCustomType(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + type: 'write', + ); + $condition = $hook->filter('documents'); + + $this->assertEquals( + 'id IN (SELECT DISTINCT document_id FROM mydb_documents_perms WHERE role IN (?) AND type = ?)', + $condition->getExpression() + ); + $this->assertEquals(['role:admin', 'write'], $condition->getBindings()); + } + + public function testPermissionCustomDocumentColumn(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + documentColumn: 'doc_id', + ); + $condition = $hook->filter('documents'); + + $this->assertStringStartsWith('doc_id IN', $condition->getExpression()); + } + + public function testPermissionCustomColumns(): void + { + $hook = new Permission( + roles: ['admin'], + permissionsTable: fn (string $table) => 'acl', + documentColumn: 'uid', + permDocumentColumn: 'resource_id', + permRoleColumn: 'principal', + permTypeColumn: 'access', + ); + $condition = $hook->filter('documents'); + + $this->assertEquals( + 'uid IN (SELECT DISTINCT resource_id FROM acl WHERE principal IN (?) AND access = ?)', + $condition->getExpression() + ); + $this->assertEquals(['admin', 'read'], $condition->getBindings()); + } + + public function testPermissionStaticTable(): void + { + $hook = new Permission( + roles: ['user:123'], + permissionsTable: fn (string $table) => 'permissions', + ); + $condition = $hook->filter('any_table'); + + $this->assertStringContainsString('FROM permissions', $condition->getExpression()); + } + + public function testPermissionWithColumns(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + columns: ['email', 'phone'], + ); + $condition = $hook->filter('users'); + + $this->assertEquals( + 'id IN (SELECT DISTINCT document_id FROM mydb_users_perms WHERE role IN (?) AND type = ? AND (column IS NULL OR column IN (?, ?)))', + $condition->getExpression() + ); + $this->assertEquals(['role:admin', 'read', 'email', 'phone'], $condition->getBindings()); + } + + public function testPermissionWithSingleColumn(): void + { + $hook = new Permission( + roles: ['role:user'], + permissionsTable: fn (string $table) => "{$table}_perms", + columns: ['salary'], + ); + $condition = $hook->filter('employees'); + + $this->assertEquals( + 'id IN (SELECT DISTINCT document_id FROM employees_perms WHERE role IN (?) AND type = ? AND (column IS NULL OR column IN (?)))', + $condition->getExpression() + ); + $this->assertEquals(['role:user', 'read', 'salary'], $condition->getBindings()); + } + + public function testPermissionWithEmptyColumns(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + columns: [], + ); + $condition = $hook->filter('users'); + + $this->assertEquals( + 'id IN (SELECT DISTINCT document_id FROM mydb_users_perms WHERE role IN (?) AND type = ? AND column IS NULL)', + $condition->getExpression() + ); + $this->assertEquals(['role:admin', 'read'], $condition->getBindings()); + } + + public function testPermissionWithoutColumnsOmitsClause(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + ); + $condition = $hook->filter('users'); + + $this->assertStringNotContainsString('column', $condition->getExpression()); + } + + public function testPermissionCustomColumnColumn(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => 'acl', + columns: ['email'], + permColumnColumn: 'field', + ); + $condition = $hook->filter('users'); + + $this->assertEquals( + 'id IN (SELECT DISTINCT document_id FROM acl WHERE role IN (?) AND type = ? AND (field IS NULL OR field IN (?)))', + $condition->getExpression() + ); + $this->assertEquals(['role:admin', 'read', 'email'], $condition->getBindings()); + } +} diff --git a/tests/Query/Hook/FilterHookTest.php b/tests/Query/Hook/FilterHookTest.php deleted file mode 100644 index 1e02b8a..0000000 --- a/tests/Query/Hook/FilterHookTest.php +++ /dev/null @@ -1,82 +0,0 @@ -filter('users'); - - $this->assertEquals('_tenant IN (?)', $condition->getExpression()); - $this->assertEquals(['t1'], $condition->getBindings()); - } - - public function testTenantMultipleIds(): void - { - $hook = new TenantFilterHook(['t1', 't2', 't3']); - $condition = $hook->filter('users'); - - $this->assertEquals('_tenant IN (?, ?, ?)', $condition->getExpression()); - $this->assertEquals(['t1', 't2', 't3'], $condition->getBindings()); - } - - public function testTenantCustomColumn(): void - { - $hook = new TenantFilterHook(['t1'], 'organization_id'); - $condition = $hook->filter('users'); - - $this->assertEquals('organization_id IN (?)', $condition->getExpression()); - $this->assertEquals(['t1'], $condition->getBindings()); - } - - // ── PermissionFilterHook ── - - public function testPermissionWithRoles(): void - { - $hook = new PermissionFilterHook('mydb', ['role:admin', 'role:user']); - $condition = $hook->filter('documents'); - - $this->assertEquals( - '_uid IN (SELECT DISTINCT _document FROM mydb_documents_perms WHERE _permission IN (?, ?) AND _type = ?)', - $condition->getExpression() - ); - $this->assertEquals(['role:admin', 'role:user', 'read'], $condition->getBindings()); - } - - public function testPermissionEmptyRoles(): void - { - $hook = new PermissionFilterHook('mydb', []); - $condition = $hook->filter('documents'); - - $this->assertEquals('1 = 0', $condition->getExpression()); - $this->assertEquals([], $condition->getBindings()); - } - - public function testPermissionCustomType(): void - { - $hook = new PermissionFilterHook('mydb', ['role:admin'], 'write'); - $condition = $hook->filter('documents'); - - $this->assertEquals( - '_uid IN (SELECT DISTINCT _document FROM mydb_documents_perms WHERE _permission IN (?) AND _type = ?)', - $condition->getExpression() - ); - $this->assertEquals(['role:admin', 'write'], $condition->getBindings()); - } - - public function testPermissionCustomDocumentColumn(): void - { - $hook = new PermissionFilterHook('mydb', ['role:admin'], 'read', '_doc_id'); - $condition = $hook->filter('documents'); - - $this->assertStringStartsWith('_doc_id IN', $condition->getExpression()); - } -} diff --git a/tests/Query/Hook/Join/FilterTest.php b/tests/Query/Hook/Join/FilterTest.php new file mode 100644 index 0000000..99988a9 --- /dev/null +++ b/tests/Query/Hook/Join/FilterTest.php @@ -0,0 +1,298 @@ +from('users') + ->addHook($hook) + ->leftJoin('orders', 'users.id', 'orders.user_id') + ->build(); + + $this->assertStringContainsString('LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND active = ?', $result->query); + $this->assertStringNotContainsString('WHERE', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testWherePlacementForInnerJoin(): void + { + $hook = new class () implements JoinFilter { + public function filterJoin(string $table, string $joinType): JoinCondition + { + return new JoinCondition( + new Condition('active = ?', [1]), + Placement::Where, + ); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook) + ->join('orders', 'users.id', 'orders.user_id') + ->build(); + + $this->assertStringContainsString('JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); + $this->assertStringNotContainsString('ON `users`.`id` = `orders`.`user_id` AND', $result->query); + $this->assertStringContainsString('WHERE active = ?', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testReturnsNullSkipsJoin(): void + { + $hook = new class () implements JoinFilter { + public function filterJoin(string $table, string $joinType): ?JoinCondition + { + return null; + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook) + ->leftJoin('orders', 'users.id', 'orders.user_id') + ->build(); + + $this->assertEquals('SELECT * FROM `users` LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testCrossJoinForcesOnToWhere(): void + { + $hook = new class () implements JoinFilter { + public function filterJoin(string $table, string $joinType): JoinCondition + { + return new JoinCondition( + new Condition('active = ?', [1]), + Placement::On, + ); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook) + ->crossJoin('settings') + ->build(); + + $this->assertStringContainsString('CROSS JOIN `settings`', $result->query); + $this->assertStringNotContainsString('CROSS JOIN `settings` AND', $result->query); + $this->assertStringContainsString('WHERE active = ?', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testMultipleHooksOnSameJoin(): void + { + $hook1 = new class () implements JoinFilter { + public function filterJoin(string $table, string $joinType): JoinCondition + { + return new JoinCondition( + new Condition('active = ?', [1]), + Placement::On, + ); + } + }; + + $hook2 = new class () implements JoinFilter { + public function filterJoin(string $table, string $joinType): JoinCondition + { + return new JoinCondition( + new Condition('visible = ?', [true]), + Placement::On, + ); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook1) + ->addHook($hook2) + ->leftJoin('orders', 'users.id', 'orders.user_id') + ->build(); + + $this->assertStringContainsString( + 'LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND active = ? AND visible = ?', + $result->query + ); + $this->assertEquals([1, true], $result->bindings); + } + + public function testBindingOrderCorrectness(): void + { + $onHook = new class () implements JoinFilter { + public function filterJoin(string $table, string $joinType): JoinCondition + { + return new JoinCondition( + new Condition('on_col = ?', ['on_val']), + Placement::On, + ); + } + }; + + $whereHook = new class () implements JoinFilter { + public function filterJoin(string $table, string $joinType): JoinCondition + { + return new JoinCondition( + new Condition('where_col = ?', ['where_val']), + Placement::Where, + ); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($onHook) + ->addHook($whereHook) + ->leftJoin('orders', 'users.id', 'orders.user_id') + ->filter([Query::equal('status', ['active'])]) + ->build(); + + // ON bindings come first (during join compilation), then filter bindings, then WHERE join filter bindings + $this->assertEquals(['on_val', 'active', 'where_val'], $result->bindings); + } + + public function testFilterOnlyBackwardCompat(): void + { + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('deleted = ?', [0]); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook) + ->leftJoin('orders', 'users.id', 'orders.user_id') + ->build(); + + // Filter-only hooks should still apply to WHERE, not to joins + $this->assertStringContainsString('LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); + $this->assertStringNotContainsString('ON `users`.`id` = `orders`.`user_id` AND', $result->query); + $this->assertStringContainsString('WHERE deleted = ?', $result->query); + $this->assertEquals([0], $result->bindings); + } + + public function testDualInterfaceHook(): void + { + $hook = new class () implements Filter, JoinFilter { + public function filter(string $table): Condition + { + return new Condition('main_active = ?', [1]); + } + + public function filterJoin(string $table, string $joinType): JoinCondition + { + return new JoinCondition( + new Condition('join_active = ?', [1]), + Placement::On, + ); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook) + ->leftJoin('orders', 'users.id', 'orders.user_id') + ->build(); + + // Filter applies to WHERE for main table + $this->assertStringContainsString('WHERE main_active = ?', $result->query); + // JoinFilter applies to ON for join + $this->assertStringContainsString('ON `users`.`id` = `orders`.`user_id` AND join_active = ?', $result->query); + // ON binding first, then WHERE binding + $this->assertEquals([1, 1], $result->bindings); + } + + public function testPermissionLeftJoinOnPlacement(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + ); + $condition = $hook->filterJoin('orders', 'LEFT JOIN'); + + $this->assertNotNull($condition); + $this->assertEquals(Placement::On, $condition->placement); + $this->assertStringContainsString('id IN', $condition->condition->getExpression()); + } + + public function testPermissionInnerJoinWherePlacement(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + ); + $condition = $hook->filterJoin('orders', 'JOIN'); + + $this->assertNotNull($condition); + $this->assertEquals(Placement::Where, $condition->placement); + } + + public function testTenantLeftJoinOnPlacement(): void + { + $hook = new Tenant(['t1']); + $condition = $hook->filterJoin('orders', 'LEFT JOIN'); + + $this->assertNotNull($condition); + $this->assertEquals(Placement::On, $condition->placement); + $this->assertStringContainsString('tenant_id IN', $condition->condition->getExpression()); + } + + public function testTenantInnerJoinWherePlacement(): void + { + $hook = new Tenant(['t1']); + $condition = $hook->filterJoin('orders', 'JOIN'); + + $this->assertNotNull($condition); + $this->assertEquals(Placement::Where, $condition->placement); + } + + public function testHookReceivesCorrectTableAndJoinType(): void + { + // Tenant returns On for RIGHT JOIN — verifying it received the correct joinType + $hook = new Tenant(['t1']); + + $rightJoinResult = $hook->filterJoin('orders', 'RIGHT JOIN'); + $this->assertNotNull($rightJoinResult); + $this->assertEquals(Placement::On, $rightJoinResult->placement); + + // Same hook returns Where for JOIN — verifying joinType discrimination + $innerJoinResult = $hook->filterJoin('orders', 'JOIN'); + $this->assertNotNull($innerJoinResult); + $this->assertEquals(Placement::Where, $innerJoinResult->placement); + + // Verify table name is used in the condition expression + $permHook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + ); + $result = $permHook->filterJoin('orders', 'LEFT JOIN'); + $this->assertNotNull($result); + $this->assertStringContainsString('mydb_orders_perms', $result->condition->getExpression()); + } +} diff --git a/tests/Query/JoinQueryTest.php b/tests/Query/JoinQueryTest.php index 6dcf599..fd8b548 100644 --- a/tests/Query/JoinQueryTest.php +++ b/tests/Query/JoinQueryTest.php @@ -55,8 +55,6 @@ public function testJoinMethodsAreJoin(): void $this->assertCount(4, $joinMethods); } - // ── Edge cases ── - public function testJoinWithEmptyTableName(): void { $query = Query::join('', 'left', 'right'); @@ -106,7 +104,7 @@ public function testCrossJoinEmptyTableName(): void public function testJoinCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\SQL(); + $builder = new \Utopia\Query\Builder\MySQL(); $query = Query::join('orders', 'users.id', 'orders.uid'); $sql = $query->compile($builder); $this->assertEquals('JOIN `orders` ON `users`.`id` = `orders`.`uid`', $sql); @@ -114,7 +112,7 @@ public function testJoinCompileDispatch(): void public function testLeftJoinCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\SQL(); + $builder = new \Utopia\Query\Builder\MySQL(); $query = Query::leftJoin('p', 'u.id', 'p.uid'); $sql = $query->compile($builder); $this->assertEquals('LEFT JOIN `p` ON `u`.`id` = `p`.`uid`', $sql); @@ -122,7 +120,7 @@ public function testLeftJoinCompileDispatch(): void public function testRightJoinCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\SQL(); + $builder = new \Utopia\Query\Builder\MySQL(); $query = Query::rightJoin('o', 'u.id', 'o.uid'); $sql = $query->compile($builder); $this->assertEquals('RIGHT JOIN `o` ON `u`.`id` = `o`.`uid`', $sql); @@ -130,7 +128,7 @@ public function testRightJoinCompileDispatch(): void public function testCrossJoinCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\SQL(); + $builder = new \Utopia\Query\Builder\MySQL(); $query = Query::crossJoin('colors'); $sql = $query->compile($builder); $this->assertEquals('CROSS JOIN `colors`', $sql); diff --git a/tests/Query/LogicalQueryTest.php b/tests/Query/LogicalQueryTest.php index a503361..b3d7ce1 100644 --- a/tests/Query/LogicalQueryTest.php +++ b/tests/Query/LogicalQueryTest.php @@ -40,4 +40,51 @@ public function testElemMatch(): void $this->assertSame(Method::ElemMatch, $query->getMethod()); $this->assertEquals('items', $query->getAttribute()); } + + public function testOrIsNested(): void + { + $query = Query::or([Query::equal('x', [1])]); + $this->assertTrue($query->isNested()); + } + + public function testAndIsNested(): void + { + $query = Query::and([Query::equal('x', [1])]); + $this->assertTrue($query->isNested()); + } + + public function testElemMatchIsNested(): void + { + $query = Query::elemMatch('items', [Query::equal('field', ['val'])]); + $this->assertTrue($query->isNested()); + } + + public function testEmptyAnd(): void + { + $query = Query::and([]); + $this->assertEquals([], $query->getValues()); + } + + public function testEmptyOr(): void + { + $query = Query::or([]); + $this->assertEquals([], $query->getValues()); + } + + public function testNestedAndOr(): void + { + $query = Query::and([ + Query::or([ + Query::equal('a', [1]), + Query::equal('b', [2]), + ]), + ]); + $values = $query->getValues(); + $this->assertCount(1, $values); + /** @var Query $orQuery */ + $orQuery = $values[0]; + $this->assertSame(Method::Or, $orQuery->getMethod()); + $orValues = $orQuery->getValues(); + $this->assertCount(2, $orValues); + } } diff --git a/tests/Query/QueryHelperTest.php b/tests/Query/QueryHelperTest.php index d7beb36..61628a5 100644 --- a/tests/Query/QueryHelperTest.php +++ b/tests/Query/QueryHelperTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Utopia\Query\CursorDirection; +use Utopia\Query\Exception\ValidationException; use Utopia\Query\Method; use Utopia\Query\OrderDirection; use Utopia\Query\Query; @@ -285,8 +286,6 @@ public function testGroupByTypeSkipsNonQueryInstances(): void $this->assertEquals([], $grouped->filters); } - // ── groupByType with new types ── - public function testGroupByTypeAggregations(): void { $queries = [ @@ -354,8 +353,6 @@ public function testGroupByTypeUnions(): void $this->assertCount(2, $grouped->unions); } - // ── merge() ── - public function testMergeConcatenates(): void { $a = [Query::equal('name', ['John'])]; @@ -399,8 +396,6 @@ public function testMergeCursorOverrides(): void $this->assertEquals('xyz', $result[0]->getValue()); } - // ── diff() ── - public function testDiffReturnsUnique(): void { $shared = Query::equal('name', ['John']); @@ -427,8 +422,6 @@ public function testDiffNoOverlap(): void $this->assertCount(1, $result); } - // ── validate() ── - public function testValidatePassesAllowed(): void { $queries = [ @@ -490,8 +483,6 @@ public function testValidateSkipsStar(): void $this->assertCount(0, $errors); } - // ── page() static helper ── - public function testPageStaticHelper(): void { $result = Query::page(3, 10); @@ -511,9 +502,8 @@ public function testPageStaticHelperFirstPage(): void public function testPageStaticHelperZero(): void { - $result = Query::page(0, 10); - $this->assertEquals(10, $result[0]->getValue()); - $this->assertEquals(-10, $result[1]->getValue()); + $this->expectException(ValidationException::class); + Query::page(0, 10); } public function testPageStaticHelperLarge(): void @@ -522,12 +512,8 @@ public function testPageStaticHelperLarge(): void $this->assertEquals(50, $result[0]->getValue()); $this->assertEquals(24950, $result[1]->getValue()); } - - // ══════════════════════════════════════════ // ADDITIONAL EDGE CASES - // ══════════════════════════════════════════ - // ── groupByType with all new types combined ── public function testGroupByTypeAllNewTypes(): void { @@ -610,8 +596,6 @@ public function testGroupByTypeEmptyNewKeys(): void $this->assertEquals([], $grouped->unions); } - // ── merge() additional edge cases ── - public function testMergeEmptyA(): void { $b = [Query::equal('x', [1])]; @@ -671,8 +655,6 @@ public function testMergeMixedWithFilters(): void $this->assertCount(4, $result); } - // ── diff() additional edge cases ── - public function testDiffEmptyA(): void { $result = Query::diff([], [Query::equal('x', [1])]); @@ -731,8 +713,6 @@ public function testDiffComplexNested(): void $this->assertSame(Method::Limit, $result[0]->getMethod()); } - // ── validate() additional edge cases ── - public function testValidateEmptyQueries(): void { $errors = Query::validate([], ['name', 'age']); @@ -870,8 +850,6 @@ public function testValidateEmptyAttributeSkipped(): void $this->assertCount(0, $errors); } - // ── getByType additional ── - public function testGetByTypeWithNewTypes(): void { $queries = [ diff --git a/tests/Query/QueryParseTest.php b/tests/Query/QueryParseTest.php index fa9d738..5babc5b 100644 --- a/tests/Query/QueryParseTest.php +++ b/tests/Query/QueryParseTest.php @@ -189,8 +189,6 @@ public function testRoundTripNestedParseSerialization(): void $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); } - // ── Round-trip tests for new types ── - public function testRoundTripCount(): void { $original = Query::count('id', 'total'); @@ -275,12 +273,8 @@ public function testRoundTripUnion(): void $this->assertCount(1, $parsed->getValues()); $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); } - - // ══════════════════════════════════════════ // ADDITIONAL EDGE CASES - // ══════════════════════════════════════════ - // ── Round-trip additional ── public function testRoundTripAvg(): void { @@ -419,8 +413,6 @@ public function testRoundTripComplexNested(): void $this->assertCount(2, $inner->getValues()); } - // ── Parse edge cases ── - public function testParseEmptyStringThrows(): void { $this->expectException(Exception::class); @@ -472,8 +464,6 @@ public function testParseJsonArrayThrows(): void Query::parse('[1,2,3]'); } - // ── toArray edge cases ── - public function testToArrayCountWithAlias(): void { $query = Query::count('id', 'total'); @@ -549,8 +539,6 @@ public function testToArrayRaw(): void $this->assertEquals([10], $array['values']); } - // ── parseQueries edge cases ── - public function testParseQueriesEmpty(): void { $result = Query::parseQueries([]); @@ -572,8 +560,6 @@ public function testParseQueriesWithNewTypes(): void $this->assertSame(Method::Join, $queries[3]->getMethod()); } - // ── toString edge cases ── - public function testToStringGroupByProducesValidJson(): void { $query = Query::groupBy(['a', 'b']); diff --git a/tests/Query/QueryTest.php b/tests/Query/QueryTest.php index dda79f1..bdfd11c 100644 --- a/tests/Query/QueryTest.php +++ b/tests/Query/QueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Builder\MySQL as MySQLBuilder; use Utopia\Query\Method; use Utopia\Query\Query; @@ -209,10 +210,7 @@ public function testUnionAllFactory(): void $query = Query::unionAll($inner); $this->assertSame(Method::UnionAll, $query->getMethod()); } - - // ══════════════════════════════════════════ // ADDITIONAL EDGE CASES - // ══════════════════════════════════════════ public function testMethodNoDuplicateValues(): void { @@ -435,4 +433,211 @@ public function testDistinctIsSpatialQueryFalse(): void { $this->assertFalse(Query::distinct()->isSpatialQuery()); } + + public function testToStringReturnsJson(): void + { + $json = Query::equal('name', ['John'])->toString(); + $decoded = \json_decode($json, true); + $this->assertIsArray($decoded); + $this->assertEquals('equal', $decoded['method']); + $this->assertEquals('name', $decoded['attribute']); + $this->assertEquals(['John'], $decoded['values']); + } + + public function testToStringWithNestedQuery(): void + { + $json = Query::and([Query::equal('x', [1])])->toString(); + $decoded = \json_decode($json, true); + $this->assertIsArray($decoded); + /** @var array $decoded */ + $this->assertEquals('and', $decoded['method']); + $this->assertIsArray($decoded['values']); + $this->assertCount(1, $decoded['values']); + /** @var array $inner */ + $inner = $decoded['values'][0]; + $this->assertEquals('equal', $inner['method']); + } + + public function testToStringThrowsOnInvalidJson(): void + { + // Verify that toString returns valid JSON for complex queries + $query = Query::and([ + Query::or([ + Query::equal('a', [1]), + Query::greaterThan('b', 2), + ]), + Query::lessThan('c', 3), + ]); + $json = $query->toString(); + $this->assertJson($json); + } + + public function testSetMethodWithEnum(): void + { + $query = new Query('equal'); + $query->setMethod(Method::GreaterThan); + $this->assertSame(Method::GreaterThan, $query->getMethod()); + } + + public function testToArraySimpleFilter(): void + { + $array = Query::equal('age', [25])->toArray(); + $this->assertEquals('equal', $array['method']); + $this->assertEquals('age', $array['attribute']); + $this->assertEquals([25], $array['values']); + } + + public function testToArrayWithEmptyAttribute(): void + { + $array = Query::distinct()->toArray(); + $this->assertArrayNotHasKey('attribute', $array); + } + + public function testToArrayNestedQuery(): void + { + $array = Query::and([Query::equal('x', [1])])->toArray(); + $this->assertIsArray($array['values']); + $this->assertCount(1, $array['values']); + /** @var array $nested */ + $nested = $array['values'][0]; + $this->assertArrayHasKey('method', $nested); + $this->assertArrayHasKey('attribute', $nested); + $this->assertArrayHasKey('values', $nested); + $this->assertEquals('equal', $nested['method']); + } + + public function testCompileOrderAsc(): void + { + $builder = new MySQLBuilder(); + $result = Query::orderAsc('name')->compile($builder); + $this->assertStringContainsString('ASC', $result); + } + + public function testCompileOrderDesc(): void + { + $builder = new MySQLBuilder(); + $result = Query::orderDesc('name')->compile($builder); + $this->assertStringContainsString('DESC', $result); + } + + public function testCompileLimit(): void + { + $builder = new MySQLBuilder(); + $result = Query::limit(10)->compile($builder); + $this->assertStringContainsString('LIMIT ?', $result); + } + + public function testCompileOffset(): void + { + $builder = new MySQLBuilder(); + $result = Query::offset(5)->compile($builder); + $this->assertStringContainsString('OFFSET ?', $result); + } + + public function testCompileAggregate(): void + { + $builder = new MySQLBuilder(); + $result = Query::count('*', 'total')->compile($builder); + $this->assertStringContainsString('COUNT(*)', $result); + $this->assertStringContainsString('total', $result); + } + + public function testIsMethodReturnsFalseForGarbage(): void + { + $this->assertFalse(Query::isMethod('notAMethod')); + } + + public function testIsMethodReturnsFalseForEmpty(): void + { + $this->assertFalse(Query::isMethod('')); + } + + public function testJsonContainsFactory(): void + { + $query = Query::jsonContains('tags', 'php'); + $this->assertSame(Method::JsonContains, $query->getMethod()); + $this->assertEquals('tags', $query->getAttribute()); + $this->assertEquals(['php'], $query->getValues()); + } + + public function testJsonNotContainsFactory(): void + { + $query = Query::jsonNotContains('meta', 42); + $this->assertSame(Method::JsonNotContains, $query->getMethod()); + } + + public function testJsonOverlapsFactory(): void + { + $query = Query::jsonOverlaps('tags', ['a', 'b']); + $this->assertSame(Method::JsonOverlaps, $query->getMethod()); + $this->assertEquals([['a', 'b']], $query->getValues()); + } + + public function testJsonPathFactory(): void + { + $query = Query::jsonPath('data', 'name', '=', 'test'); + $this->assertSame(Method::JsonPath, $query->getMethod()); + $this->assertEquals(['name', '=', 'test'], $query->getValues()); + } + + public function testCoversFactory(): void + { + $query = Query::covers('zone', [1.0, 2.0]); + $this->assertSame(Method::Covers, $query->getMethod()); + } + + public function testNotCoversFactory(): void + { + $query = Query::notCovers('zone', [1.0, 2.0]); + $this->assertSame(Method::NotCovers, $query->getMethod()); + } + + public function testSpatialEqualsFactory(): void + { + $query = Query::spatialEquals('geom', [3.0, 4.0]); + $this->assertSame(Method::SpatialEquals, $query->getMethod()); + } + + public function testNotSpatialEqualsFactory(): void + { + $query = Query::notSpatialEquals('geom', [3.0, 4.0]); + $this->assertSame(Method::NotSpatialEquals, $query->getMethod()); + } + + public function testIsJsonMethod(): void + { + $this->assertTrue(Method::JsonContains->isJson()); + $this->assertTrue(Method::JsonNotContains->isJson()); + $this->assertTrue(Method::JsonOverlaps->isJson()); + $this->assertTrue(Method::JsonPath->isJson()); + } + + public function testIsJsonMethodFalseForNonJson(): void + { + $this->assertFalse(Method::Equal->isJson()); + } + + public function testIsSpatialMethodCovers(): void + { + $this->assertTrue(Method::Covers->isSpatial()); + $this->assertTrue(Method::NotCovers->isSpatial()); + $this->assertTrue(Method::SpatialEquals->isSpatial()); + $this->assertTrue(Method::NotSpatialEquals->isSpatial()); + } + + public function testIsSpatialMethodFalseForNonSpatial(): void + { + $this->assertFalse(Method::Equal->isSpatial()); + } + + public function testIsFilterMethod(): void + { + $this->assertTrue(Method::Equal->isFilter()); + $this->assertTrue(Method::NotEqual->isFilter()); + } + + public function testIsFilterMethodFalseForNonFilter(): void + { + $this->assertFalse(Method::OrderAsc->isFilter()); + } } diff --git a/tests/Query/Schema/ClickHouseTest.php b/tests/Query/Schema/ClickHouseTest.php new file mode 100644 index 0000000..37b9001 --- /dev/null +++ b/tests/Query/Schema/ClickHouseTest.php @@ -0,0 +1,372 @@ +create('events', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->datetime('created_at', 3); + }); + + $this->assertStringContainsString('CREATE TABLE `events`', $result->query); + $this->assertStringContainsString('`id` Int64', $result->query); + $this->assertStringContainsString('`name` String', $result->query); + $this->assertStringContainsString('`created_at` DateTime64(3)', $result->query); + $this->assertStringContainsString('ENGINE = MergeTree()', $result->query); + $this->assertStringContainsString('ORDER BY (`id`)', $result->query); + } + + public function testCreateTableColumnTypes(): void + { + $schema = new Schema(); + $result = $schema->create('test_types', function (Blueprint $table) { + $table->integer('int_col'); + $table->integer('uint_col')->unsigned(); + $table->bigInteger('big_col'); + $table->bigInteger('ubig_col')->unsigned(); + $table->float('float_col'); + $table->boolean('bool_col'); + $table->text('text_col'); + $table->json('json_col'); + $table->binary('bin_col'); + }); + + $this->assertStringContainsString('`int_col` Int32', $result->query); + $this->assertStringContainsString('`uint_col` UInt32', $result->query); + $this->assertStringContainsString('`big_col` Int64', $result->query); + $this->assertStringContainsString('`ubig_col` UInt64', $result->query); + $this->assertStringContainsString('`float_col` Float64', $result->query); + $this->assertStringContainsString('`bool_col` UInt8', $result->query); + $this->assertStringContainsString('`text_col` String', $result->query); + $this->assertStringContainsString('`json_col` String', $result->query); + $this->assertStringContainsString('`bin_col` String', $result->query); + } + + public function testCreateTableNullableWrapping(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->string('name')->nullable(); + }); + + $this->assertStringContainsString('Nullable(String)', $result->query); + } + + public function testCreateTableWithEnum(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->enum('status', ['active', 'inactive']); + }); + + $this->assertStringContainsString("Enum8('active' = 1, 'inactive' = 2)", $result->query); + } + + public function testCreateTableWithVector(): void + { + $schema = new Schema(); + $result = $schema->create('embeddings', function (Blueprint $table) { + $table->vector('embedding', 768); + }); + + $this->assertStringContainsString('Array(Float64)', $result->query); + } + + public function testCreateTableWithSpatialTypes(): void + { + $schema = new Schema(); + $result = $schema->create('geo', function (Blueprint $table) { + $table->point('coords'); + $table->linestring('path'); + $table->polygon('area'); + }); + + $this->assertStringContainsString('Tuple(Float64, Float64)', $result->query); + $this->assertStringContainsString('Array(Tuple(Float64, Float64))', $result->query); + $this->assertStringContainsString('Array(Array(Tuple(Float64, Float64)))', $result->query); + } + + public function testCreateTableForeignKeyThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Foreign keys are not supported in ClickHouse'); + + $schema = new Schema(); + $schema->create('t', function (Blueprint $table) { + $table->foreignKey('user_id')->references('id')->on('users'); + }); + } + + public function testCreateTableWithIndex(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->index(['name']); + }); + + $this->assertStringContainsString('INDEX `idx_name` `name` TYPE minmax GRANULARITY 3', $result->query); + } + // ALTER TABLE + + public function testAlterAddColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Blueprint $table) { + $table->addColumn('score', 'float'); + }); + + $this->assertEquals('ALTER TABLE `events` ADD COLUMN `score` Float64', $result->query); + } + + public function testAlterModifyColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Blueprint $table) { + $table->modifyColumn('name', 'string'); + }); + + $this->assertEquals('ALTER TABLE `events` MODIFY COLUMN `name` String', $result->query); + } + + public function testAlterRenameColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Blueprint $table) { + $table->renameColumn('old', 'new'); + }); + + $this->assertEquals('ALTER TABLE `events` RENAME COLUMN `old` TO `new`', $result->query); + } + + public function testAlterDropColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Blueprint $table) { + $table->dropColumn('old_col'); + }); + + $this->assertEquals('ALTER TABLE `events` DROP COLUMN `old_col`', $result->query); + } + + public function testAlterForeignKeyThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Foreign keys are not supported in ClickHouse'); + + $schema = new Schema(); + $schema->alter('events', function (Blueprint $table) { + $table->addForeignKey('user_id')->references('id')->on('users'); + }); + } + // DROP TABLE / TRUNCATE + + public function testDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('events'); + + $this->assertEquals('DROP TABLE `events`', $result->query); + } + + public function testTruncateTable(): void + { + $schema = new Schema(); + $result = $schema->truncate('events'); + + $this->assertEquals('TRUNCATE TABLE `events`', $result->query); + } + // VIEW + + public function testCreateView(): void + { + $schema = new Schema(); + $builder = (new ClickHouseBuilder())->from('events')->filter([Query::equal('status', ['active'])]); + $result = $schema->createView('active_events', $builder); + + $this->assertEquals( + 'CREATE VIEW `active_events` AS SELECT * FROM `events` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + } + + public function testDropView(): void + { + $schema = new Schema(); + $result = $schema->dropView('active_events'); + + $this->assertEquals('DROP VIEW `active_events`', $result->query); + } + // DROP INDEX (ClickHouse-specific) + + public function testDropIndex(): void + { + $schema = new Schema(); + $result = $schema->dropIndex('events', 'idx_name'); + + $this->assertEquals('ALTER TABLE `events` DROP INDEX `idx_name`', $result->query); + } + // Feature interface checks — ClickHouse does NOT implement these + + public function testDoesNotImplementForeignKeys(): void + { + $this->assertNotInstanceOf(\Utopia\Query\Schema\Feature\ForeignKeys::class, new Schema()); // @phpstan-ignore method.alreadyNarrowedType + } + + public function testDoesNotImplementProcedures(): void + { + $this->assertNotInstanceOf(\Utopia\Query\Schema\Feature\Procedures::class, new Schema()); // @phpstan-ignore method.alreadyNarrowedType + } + + public function testDoesNotImplementTriggers(): void + { + $this->assertNotInstanceOf(\Utopia\Query\Schema\Feature\Triggers::class, new Schema()); // @phpstan-ignore method.alreadyNarrowedType + } + + // Edge cases + + public function testDropIfExists(): void + { + $schema = new Schema(); + $result = $schema->dropIfExists('events'); + + $this->assertEquals('DROP TABLE IF EXISTS `events`', $result->query); + } + + public function testCreateTableWithDefaultValue(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->integer('count')->default(0); + }); + + $this->assertStringContainsString('DEFAULT 0', $result->query); + } + + public function testCreateTableWithComment(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->string('name')->comment('User name'); + }); + + $this->assertStringContainsString("COMMENT 'User name'", $result->query); + } + + public function testCreateTableMultiplePrimaryKeys(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->datetime('created_at', 3)->primary(); + $table->string('name'); + }); + + $this->assertStringContainsString('ORDER BY (`id`, `created_at`)', $result->query); + } + + public function testAlterMultipleOperations(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Blueprint $table) { + $table->addColumn('score', 'float'); + $table->dropColumn('old_col'); + $table->renameColumn('nm', 'name'); + }); + + $this->assertStringContainsString('ADD COLUMN `score` Float64', $result->query); + $this->assertStringContainsString('DROP COLUMN `old_col`', $result->query); + $this->assertStringContainsString('RENAME COLUMN `nm` TO `name`', $result->query); + } + + public function testAlterDropIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Blueprint $table) { + $table->dropIndex('idx_name'); + }); + + $this->assertStringContainsString('DROP INDEX `idx_name`', $result->query); + } + + public function testCreateTableWithMultipleIndexes(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->string('type'); + $table->index(['name']); + $table->index(['type']); + }); + + $this->assertStringContainsString('INDEX `idx_name`', $result->query); + $this->assertStringContainsString('INDEX `idx_type`', $result->query); + } + + public function testCreateTableTimestampWithoutPrecision(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->timestamp('ts_col'); + }); + + $this->assertStringContainsString('`ts_col` DateTime', $result->query); + $this->assertStringNotContainsString('DateTime64', $result->query); + } + + public function testCreateTableDatetimeWithoutPrecision(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->datetime('dt_col'); + }); + + $this->assertStringContainsString('`dt_col` DateTime', $result->query); + $this->assertStringNotContainsString('DateTime64', $result->query); + } + + public function testCreateTableWithCompositeIndex(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->string('type'); + $table->index(['name', 'type']); + }); + + // Composite index wraps in parentheses + $this->assertStringContainsString('INDEX `idx_name_type` (`name`, `type`) TYPE minmax GRANULARITY 3', $result->query); + } + + public function testAlterForeignKeyStillThrows(): void + { + $this->expectException(UnsupportedException::class); + + $schema = new Schema(); + $schema->alter('events', function (Blueprint $table) { + $table->dropForeignKey('fk_old'); + }); + } +} diff --git a/tests/Query/Schema/MySQLTest.php b/tests/Query/Schema/MySQLTest.php new file mode 100644 index 0000000..67fb823 --- /dev/null +++ b/tests/Query/Schema/MySQLTest.php @@ -0,0 +1,669 @@ +assertInstanceOf(\Utopia\Query\Schema\Feature\ForeignKeys::class, new Schema()); + } + + public function testImplementsProcedures(): void + { + $this->assertInstanceOf(\Utopia\Query\Schema\Feature\Procedures::class, new Schema()); + } + + public function testImplementsTriggers(): void + { + $this->assertInstanceOf(\Utopia\Query\Schema\Feature\Triggers::class, new Schema()); + } + + // CREATE TABLE + + public function testCreateTableBasic(): void + { + $schema = new Schema(); + $result = $schema->create('users', function (Blueprint $table) { + $table->id(); + $table->string('name', 255); + $table->string('email', 255)->unique(); + }); + + $this->assertEquals( + 'CREATE TABLE `users` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `name` VARCHAR(255) NOT NULL, `email` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`), UNIQUE (`email`))', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testCreateTableAllColumnTypes(): void + { + $schema = new Schema(); + $result = $schema->create('test_types', function (Blueprint $table) { + $table->integer('int_col'); + $table->bigInteger('big_col'); + $table->float('float_col'); + $table->boolean('bool_col'); + $table->text('text_col'); + $table->datetime('dt_col', 3); + $table->timestamp('ts_col', 6); + $table->json('json_col'); + $table->binary('bin_col'); + $table->enum('status', ['active', 'inactive']); + }); + + $this->assertStringContainsString('INT NOT NULL', $result->query); + $this->assertStringContainsString('BIGINT NOT NULL', $result->query); + $this->assertStringContainsString('DOUBLE NOT NULL', $result->query); + $this->assertStringContainsString('TINYINT(1) NOT NULL', $result->query); + $this->assertStringContainsString('TEXT NOT NULL', $result->query); + $this->assertStringContainsString('DATETIME(3) NOT NULL', $result->query); + $this->assertStringContainsString('TIMESTAMP(6) NOT NULL', $result->query); + $this->assertStringContainsString('JSON NOT NULL', $result->query); + $this->assertStringContainsString('BLOB NOT NULL', $result->query); + $this->assertStringContainsString("ENUM('active','inactive') NOT NULL", $result->query); + } + + public function testCreateTableWithNullableAndDefault(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Blueprint $table) { + $table->id(); + $table->text('bio')->nullable(); + $table->boolean('active')->default(true); + $table->integer('score')->default(0); + $table->string('status')->default('draft'); + }); + + $this->assertStringContainsString('`bio` TEXT NULL', $result->query); + $this->assertStringContainsString("DEFAULT 1", $result->query); + $this->assertStringContainsString('DEFAULT 0', $result->query); + $this->assertStringContainsString("DEFAULT 'draft'", $result->query); + } + + public function testCreateTableWithUnsigned(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->integer('age')->unsigned(); + }); + + $this->assertStringContainsString('INT UNSIGNED NOT NULL', $result->query); + } + + public function testCreateTableWithTimestamps(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Blueprint $table) { + $table->id(); + $table->timestamps(); + }); + + $this->assertStringContainsString('`created_at` DATETIME(3) NOT NULL', $result->query); + $this->assertStringContainsString('`updated_at` DATETIME(3) NOT NULL', $result->query); + } + + public function testCreateTableWithForeignKey(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Blueprint $table) { + $table->id(); + $table->foreignKey('user_id') + ->references('id')->on('users') + ->onDelete('CASCADE')->onUpdate('SET NULL'); + }); + + $this->assertStringContainsString( + 'FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE SET NULL', + $result->query + ); + } + + public function testCreateTableWithIndexes(): void + { + $schema = new Schema(); + $result = $schema->create('users', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('email'); + $table->index(['name', 'email']); + $table->uniqueIndex(['email']); + }); + + $this->assertStringContainsString('INDEX `idx_name_email` (`name`, `email`)', $result->query); + $this->assertStringContainsString('UNIQUE INDEX `uniq_email` (`email`)', $result->query); + } + + public function testCreateTableWithSpatialTypes(): void + { + $schema = new Schema(); + $result = $schema->create('locations', function (Blueprint $table) { + $table->id(); + $table->point('coords', 4326); + $table->linestring('path'); + $table->polygon('area'); + }); + + $this->assertStringContainsString('POINT SRID 4326 NOT NULL', $result->query); + $this->assertStringContainsString('LINESTRING SRID 4326 NOT NULL', $result->query); + $this->assertStringContainsString('POLYGON SRID 4326 NOT NULL', $result->query); + } + + public function testCreateTableVectorThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Unknown column type'); + + $schema = new Schema(); + $schema->create('embeddings', function (Blueprint $table) { + $table->vector('embedding', 768); + }); + } + + public function testCreateTableWithComment(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->string('name')->comment('User display name'); + }); + + $this->assertStringContainsString("COMMENT 'User display name'", $result->query); + } + // ALTER TABLE + + public function testAlterAddColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addColumn('avatar_url', 'string', 255)->nullable()->after('email'); + }); + + $this->assertEquals( + 'ALTER TABLE `users` ADD COLUMN `avatar_url` VARCHAR(255) NULL AFTER `email`', + $result->query + ); + } + + public function testAlterModifyColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->modifyColumn('name', 'string', 500); + }); + + $this->assertEquals( + 'ALTER TABLE `users` MODIFY COLUMN `name` VARCHAR(500) NOT NULL', + $result->query + ); + } + + public function testAlterRenameColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->renameColumn('bio', 'biography'); + }); + + $this->assertEquals( + 'ALTER TABLE `users` RENAME COLUMN `bio` TO `biography`', + $result->query + ); + } + + public function testAlterDropColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->dropColumn('age'); + }); + + $this->assertEquals( + 'ALTER TABLE `users` DROP COLUMN `age`', + $result->query + ); + } + + public function testAlterAddIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addIndex('idx_name', ['name']); + }); + + $this->assertEquals( + 'ALTER TABLE `users` ADD INDEX `idx_name` (`name`)', + $result->query + ); + } + + public function testAlterDropIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->dropIndex('idx_old'); + }); + + $this->assertEquals( + 'ALTER TABLE `users` DROP INDEX `idx_old`', + $result->query + ); + } + + public function testAlterAddForeignKey(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addForeignKey('dept_id') + ->references('id')->on('departments'); + }); + + $this->assertStringContainsString( + 'ADD FOREIGN KEY (`dept_id`) REFERENCES `departments` (`id`)', + $result->query + ); + } + + public function testAlterDropForeignKey(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->dropForeignKey('fk_old'); + }); + + $this->assertEquals( + 'ALTER TABLE `users` DROP FOREIGN KEY `fk_old`', + $result->query + ); + } + + public function testAlterMultipleOperations(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addColumn('avatar', 'string', 255)->nullable(); + $table->dropColumn('age'); + $table->renameColumn('bio', 'biography'); + }); + + $this->assertStringContainsString('ADD COLUMN', $result->query); + $this->assertStringContainsString('DROP COLUMN `age`', $result->query); + $this->assertStringContainsString('RENAME COLUMN `bio` TO `biography`', $result->query); + } + // DROP TABLE + + public function testDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('users'); + + $this->assertEquals('DROP TABLE `users`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testDropTableIfExists(): void + { + $schema = new Schema(); + $result = $schema->dropIfExists('users'); + + $this->assertEquals('DROP TABLE IF EXISTS `users`', $result->query); + } + // RENAME TABLE + + public function testRenameTable(): void + { + $schema = new Schema(); + $result = $schema->rename('users', 'members'); + + $this->assertEquals('RENAME TABLE `users` TO `members`', $result->query); + } + // TRUNCATE TABLE + + public function testTruncateTable(): void + { + $schema = new Schema(); + $result = $schema->truncate('users'); + + $this->assertEquals('TRUNCATE TABLE `users`', $result->query); + } + // CREATE / DROP INDEX (standalone) + + public function testCreateIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_email', ['email']); + + $this->assertEquals('CREATE INDEX `idx_email` ON `users` (`email`)', $result->query); + } + + public function testCreateUniqueIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_email', ['email'], unique: true); + + $this->assertEquals('CREATE UNIQUE INDEX `idx_email` ON `users` (`email`)', $result->query); + } + + public function testCreateFulltextIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('posts', 'idx_body_ft', ['body'], type: 'fulltext'); + + $this->assertEquals('CREATE FULLTEXT INDEX `idx_body_ft` ON `posts` (`body`)', $result->query); + } + + public function testCreateSpatialIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('locations', 'idx_geo', ['coords'], type: 'spatial'); + + $this->assertEquals('CREATE SPATIAL INDEX `idx_geo` ON `locations` (`coords`)', $result->query); + } + + public function testDropIndex(): void + { + $schema = new Schema(); + $result = $schema->dropIndex('users', 'idx_email'); + + $this->assertEquals('DROP INDEX `idx_email` ON `users`', $result->query); + } + // CREATE / DROP VIEW + + public function testCreateView(): void + { + $schema = new Schema(); + $builder = (new SQLBuilder())->from('users')->filter([Query::equal('active', [true])]); + $result = $schema->createView('active_users', $builder); + + $this->assertEquals( + 'CREATE VIEW `active_users` AS SELECT * FROM `users` WHERE `active` IN (?)', + $result->query + ); + $this->assertEquals([true], $result->bindings); + } + + public function testCreateOrReplaceView(): void + { + $schema = new Schema(); + $builder = (new SQLBuilder())->from('users')->filter([Query::equal('active', [true])]); + $result = $schema->createOrReplaceView('active_users', $builder); + + $this->assertEquals( + 'CREATE OR REPLACE VIEW `active_users` AS SELECT * FROM `users` WHERE `active` IN (?)', + $result->query + ); + $this->assertEquals([true], $result->bindings); + } + + public function testDropView(): void + { + $schema = new Schema(); + $result = $schema->dropView('active_users'); + + $this->assertEquals('DROP VIEW `active_users`', $result->query); + } + // FOREIGN KEY (standalone) + + public function testAddForeignKeyStandalone(): void + { + $schema = new Schema(); + $result = $schema->addForeignKey( + 'orders', + 'fk_user', + 'user_id', + 'users', + 'id', + onDelete: 'CASCADE', + onUpdate: 'SET NULL' + ); + + $this->assertEquals( + 'ALTER TABLE `orders` ADD CONSTRAINT `fk_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE SET NULL', + $result->query + ); + } + + public function testAddForeignKeyNoActions(): void + { + $schema = new Schema(); + $result = $schema->addForeignKey('orders', 'fk_user', 'user_id', 'users', 'id'); + + $this->assertEquals( + 'ALTER TABLE `orders` ADD CONSTRAINT `fk_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)', + $result->query + ); + } + + public function testDropForeignKeyStandalone(): void + { + $schema = new Schema(); + $result = $schema->dropForeignKey('orders', 'fk_user'); + + $this->assertEquals( + 'ALTER TABLE `orders` DROP FOREIGN KEY `fk_user`', + $result->query + ); + } + // STORED PROCEDURE + + public function testCreateProcedure(): void + { + $schema = new Schema(); + $result = $schema->createProcedure( + 'update_stats', + params: [['IN', 'user_id', 'INT'], ['OUT', 'total', 'INT']], + body: 'SELECT COUNT(*) INTO total FROM orders WHERE orders.user_id = user_id;' + ); + + $this->assertEquals( + 'CREATE PROCEDURE `update_stats`(IN `user_id` INT, OUT `total` INT) BEGIN SELECT COUNT(*) INTO total FROM orders WHERE orders.user_id = user_id; END', + $result->query + ); + } + + public function testDropProcedure(): void + { + $schema = new Schema(); + $result = $schema->dropProcedure('update_stats'); + + $this->assertEquals('DROP PROCEDURE `update_stats`', $result->query); + } + // TRIGGER + + public function testCreateTrigger(): void + { + $schema = new Schema(); + $result = $schema->createTrigger( + 'trg_updated_at', + 'users', + timing: 'BEFORE', + event: 'UPDATE', + body: 'SET NEW.updated_at = NOW(3);' + ); + + $this->assertEquals( + 'CREATE TRIGGER `trg_updated_at` BEFORE UPDATE ON `users` FOR EACH ROW BEGIN SET NEW.updated_at = NOW(3); END', + $result->query + ); + } + + public function testDropTrigger(): void + { + $schema = new Schema(); + $result = $schema->dropTrigger('trg_updated_at'); + + $this->assertEquals('DROP TRIGGER `trg_updated_at`', $result->query); + } + + // Schema edge cases + + public function testCreateTableWithMultiplePrimaryKeys(): void + { + $schema = new Schema(); + $result = $schema->create('order_items', function (Blueprint $table) { + $table->integer('order_id')->primary(); + $table->integer('product_id')->primary(); + $table->integer('quantity'); + }); + + $this->assertStringContainsString('PRIMARY KEY (`order_id`, `product_id`)', $result->query); + } + + public function testCreateTableWithDefaultNull(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->string('name')->nullable()->default(null); + }); + + $this->assertStringContainsString('DEFAULT NULL', $result->query); + } + + public function testCreateTableWithNumericDefault(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->float('score')->default(0.5); + }); + + $this->assertStringContainsString('DEFAULT 0.5', $result->query); + } + + public function testDropIfExists(): void + { + $schema = new Schema(); + $result = $schema->dropIfExists('users'); + + $this->assertEquals('DROP TABLE IF EXISTS `users`', $result->query); + } + + public function testCreateOrReplaceViewFromBuilder(): void + { + $schema = new Schema(); + $builder = (new SQLBuilder())->from('users'); + $result = $schema->createOrReplaceView('all_users', $builder); + + $this->assertStringStartsWith('CREATE OR REPLACE VIEW', $result->query); + } + + public function testAlterMultipleColumnsAndIndexes(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addColumn('first_name', 'string', 100); + $table->addColumn('last_name', 'string', 100); + $table->dropColumn('name'); + $table->addIndex('idx_names', ['first_name', 'last_name']); + }); + + $this->assertStringContainsString('ADD COLUMN `first_name`', $result->query); + $this->assertStringContainsString('ADD COLUMN `last_name`', $result->query); + $this->assertStringContainsString('DROP COLUMN `name`', $result->query); + $this->assertStringContainsString('ADD INDEX `idx_names`', $result->query); + } + + public function testCreateTableForeignKeyWithAllActions(): void + { + $schema = new Schema(); + $result = $schema->create('comments', function (Blueprint $table) { + $table->id(); + $table->foreignKey('post_id') + ->references('id')->on('posts') + ->onDelete('CASCADE')->onUpdate('RESTRICT'); + }); + + $this->assertStringContainsString('ON DELETE CASCADE', $result->query); + $this->assertStringContainsString('ON UPDATE RESTRICT', $result->query); + } + + public function testAddForeignKeyStandaloneNoActions(): void + { + $schema = new Schema(); + $result = $schema->addForeignKey('orders', 'fk_user', 'user_id', 'users', 'id'); + + $this->assertStringNotContainsString('ON DELETE', $result->query); + $this->assertStringNotContainsString('ON UPDATE', $result->query); + } + + public function testDropTriggerByName(): void + { + $schema = new Schema(); + $result = $schema->dropTrigger('trg_old'); + + $this->assertEquals('DROP TRIGGER `trg_old`', $result->query); + } + + public function testCreateTableTimestampWithoutPrecision(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->timestamp('ts_col'); + }); + + $this->assertStringContainsString('TIMESTAMP NOT NULL', $result->query); + $this->assertStringNotContainsString('TIMESTAMP(', $result->query); + } + + public function testCreateTableDatetimeWithoutPrecision(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->datetime('dt_col'); + }); + + $this->assertStringContainsString('DATETIME NOT NULL', $result->query); + $this->assertStringNotContainsString('DATETIME(', $result->query); + } + + public function testCreateCompositeIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_multi', ['first_name', 'last_name']); + + $this->assertEquals('CREATE INDEX `idx_multi` ON `users` (`first_name`, `last_name`)', $result->query); + } + + public function testAlterAddAndDropForeignKey(): void + { + $schema = new Schema(); + $result = $schema->alter('orders', function (Blueprint $table) { + $table->addForeignKey('user_id')->references('id')->on('users'); + $table->dropForeignKey('fk_old_user'); + }); + + $this->assertStringContainsString('ADD FOREIGN KEY', $result->query); + $this->assertStringContainsString('DROP FOREIGN KEY `fk_old_user`', $result->query); + } + + public function testBlueprintAutoGeneratedIndexName(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->string('first'); + $table->string('last'); + $table->index(['first', 'last']); + }); + + $this->assertStringContainsString('INDEX `idx_first_last`', $result->query); + } + + public function testBlueprintAutoGeneratedUniqueIndexName(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->string('email'); + $table->uniqueIndex(['email']); + }); + + $this->assertStringContainsString('UNIQUE INDEX `uniq_email`', $result->query); + } +} diff --git a/tests/Query/Schema/PostgreSQLTest.php b/tests/Query/Schema/PostgreSQLTest.php new file mode 100644 index 0000000..9abe5db --- /dev/null +++ b/tests/Query/Schema/PostgreSQLTest.php @@ -0,0 +1,504 @@ +assertInstanceOf(\Utopia\Query\Schema\Feature\ForeignKeys::class, new Schema()); + } + + public function testImplementsProcedures(): void + { + $this->assertInstanceOf(\Utopia\Query\Schema\Feature\Procedures::class, new Schema()); + } + + public function testImplementsTriggers(): void + { + $this->assertInstanceOf(\Utopia\Query\Schema\Feature\Triggers::class, new Schema()); + } + + // CREATE TABLE — PostgreSQL types + + public function testCreateTableBasic(): void + { + $schema = new Schema(); + $result = $schema->create('users', function (Blueprint $table) { + $table->id(); + $table->string('name', 255); + $table->string('email', 255)->unique(); + }); + + $this->assertStringContainsString('"id" BIGINT', $result->query); + $this->assertStringContainsString('GENERATED BY DEFAULT AS IDENTITY', $result->query); + $this->assertStringContainsString('"name" VARCHAR(255)', $result->query); + $this->assertStringContainsString('PRIMARY KEY ("id")', $result->query); + $this->assertStringContainsString('UNIQUE ("email")', $result->query); + } + + public function testCreateTableColumnTypes(): void + { + $schema = new Schema(); + $result = $schema->create('test_types', function (Blueprint $table) { + $table->integer('int_col'); + $table->bigInteger('big_col'); + $table->float('float_col'); + $table->boolean('bool_col'); + $table->text('text_col'); + $table->datetime('dt_col', 3); + $table->timestamp('ts_col', 6); + $table->json('json_col'); + $table->binary('bin_col'); + $table->enum('status', ['active', 'inactive']); + }); + + $this->assertStringContainsString('INTEGER NOT NULL', $result->query); + $this->assertStringContainsString('BIGINT NOT NULL', $result->query); + $this->assertStringContainsString('DOUBLE PRECISION NOT NULL', $result->query); + $this->assertStringContainsString('BOOLEAN NOT NULL', $result->query); + $this->assertStringContainsString('TEXT NOT NULL', $result->query); + $this->assertStringContainsString('TIMESTAMP(3) NOT NULL', $result->query); + $this->assertStringContainsString('TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL', $result->query); + $this->assertStringContainsString('JSONB NOT NULL', $result->query); + $this->assertStringContainsString('BYTEA NOT NULL', $result->query); + $this->assertStringContainsString("CHECK (\"status\" IN ('active', 'inactive'))", $result->query); + } + + public function testCreateTableSpatialTypes(): void + { + $schema = new Schema(); + $result = $schema->create('locations', function (Blueprint $table) { + $table->id(); + $table->point('coords', 4326); + $table->linestring('path'); + $table->polygon('area'); + }); + + $this->assertStringContainsString('GEOMETRY(POINT, 4326)', $result->query); + $this->assertStringContainsString('GEOMETRY(LINESTRING, 4326)', $result->query); + $this->assertStringContainsString('GEOMETRY(POLYGON, 4326)', $result->query); + } + + public function testCreateTableVectorType(): void + { + $schema = new Schema(); + $result = $schema->create('embeddings', function (Blueprint $table) { + $table->id(); + $table->vector('embedding', 128); + }); + + $this->assertStringContainsString('VECTOR(128)', $result->query); + } + + public function testCreateTableUnsignedIgnored(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->integer('age')->unsigned(); + }); + + // PostgreSQL doesn't support UNSIGNED + $this->assertStringNotContainsString('UNSIGNED', $result->query); + $this->assertStringContainsString('INTEGER NOT NULL', $result->query); + } + + public function testCreateTableNoInlineComment(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->string('name')->comment('User display name'); + }); + + // PostgreSQL doesn't use inline COMMENT + $this->assertStringNotContainsString('COMMENT', $result->query); + } + // AUTO INCREMENT + + public function testAutoIncrementUsesIdentity(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->id(); + }); + + $this->assertStringContainsString('GENERATED BY DEFAULT AS IDENTITY', $result->query); + $this->assertStringNotContainsString('AUTO_INCREMENT', $result->query); + } + // DROP INDEX — no ON table + + public function testDropIndexNoOnTable(): void + { + $schema = new Schema(); + $result = $schema->dropIndex('users', 'idx_email'); + + $this->assertEquals('DROP INDEX "idx_email"', $result->query); + } + // CREATE INDEX — USING method + operator class + + public function testCreateIndexWithGin(): void + { + $schema = new Schema(); + $result = $schema->createIndex('documents', 'idx_content_gin', ['content'], method: 'gin', operatorClass: 'gin_trgm_ops'); + + $this->assertEquals( + 'CREATE INDEX "idx_content_gin" ON "documents" USING GIN ("content" gin_trgm_ops)', + $result->query + ); + } + + public function testCreateIndexWithHnsw(): void + { + $schema = new Schema(); + $result = $schema->createIndex('embeddings', 'idx_embedding_hnsw', ['embedding'], method: 'hnsw', operatorClass: 'vector_cosine_ops'); + + $this->assertEquals( + 'CREATE INDEX "idx_embedding_hnsw" ON "embeddings" USING HNSW ("embedding" vector_cosine_ops)', + $result->query + ); + } + + public function testCreateIndexWithGist(): void + { + $schema = new Schema(); + $result = $schema->createIndex('locations', 'idx_coords_gist', ['coords'], method: 'gist'); + + $this->assertEquals( + 'CREATE INDEX "idx_coords_gist" ON "locations" USING GIST ("coords")', + $result->query + ); + } + // PROCEDURES — CREATE FUNCTION + + public function testCreateProcedureUsesFunction(): void + { + $schema = new Schema(); + $result = $schema->createProcedure( + 'update_stats', + params: [['IN', 'user_id', 'INT'], ['OUT', 'total', 'INT']], + body: 'SELECT COUNT(*) INTO total FROM orders WHERE orders.user_id = user_id;' + ); + + $this->assertStringContainsString('CREATE FUNCTION "update_stats"', $result->query); + $this->assertStringContainsString('LANGUAGE plpgsql', $result->query); + $this->assertStringNotContainsString('CREATE PROCEDURE', $result->query); + } + + public function testDropProcedureUsesFunction(): void + { + $schema = new Schema(); + $result = $schema->dropProcedure('update_stats'); + + $this->assertEquals('DROP FUNCTION "update_stats"', $result->query); + } + // TRIGGERS — EXECUTE FUNCTION + + public function testCreateTriggerUsesExecuteFunction(): void + { + $schema = new Schema(); + $result = $schema->createTrigger( + 'trg_updated_at', + 'users', + timing: 'BEFORE', + event: 'UPDATE', + body: 'NEW.updated_at = NOW();' + ); + + $this->assertStringContainsString('EXECUTE FUNCTION', $result->query); + $this->assertStringContainsString('CREATE TRIGGER "trg_updated_at"', $result->query); + $this->assertStringNotContainsString('BEGIN SET', $result->query); + } + // FOREIGN KEY — DROP CONSTRAINT + + public function testDropForeignKeyUsesConstraint(): void + { + $schema = new Schema(); + $result = $schema->dropForeignKey('orders', 'fk_user'); + + $this->assertEquals( + 'ALTER TABLE "orders" DROP CONSTRAINT "fk_user"', + $result->query + ); + } + // ALTER — PostgreSQL specifics + + public function testAlterModifyUsesAlterColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->modifyColumn('name', 'string', 500); + }); + + $this->assertStringContainsString('ALTER COLUMN "name" TYPE VARCHAR(500)', $result->query); + } + + public function testAlterAddIndexUsesCreateIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addIndex('idx_email', ['email']); + }); + + $this->assertStringNotContainsString('ADD INDEX', $result->query); + $this->assertStringContainsString('CREATE INDEX "idx_email" ON "users" ("email")', $result->query); + } + + public function testAlterDropIndexIsStandalone(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->dropIndex('idx_email'); + }); + + $this->assertEquals('DROP INDEX "idx_email"', $result->query); + } + + public function testAlterColumnAndIndexSeparateStatements(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addColumn('score', 'integer'); + $table->addIndex('idx_score', ['score']); + }); + + $this->assertStringContainsString('ALTER TABLE "users" ADD COLUMN', $result->query); + $this->assertStringContainsString('; CREATE INDEX "idx_score" ON "users" ("score")', $result->query); + } + + public function testAlterDropForeignKeyUsesConstraint(): void + { + $schema = new Schema(); + $result = $schema->alter('orders', function (Blueprint $table) { + $table->dropForeignKey('fk_old'); + }); + + $this->assertStringContainsString('DROP CONSTRAINT "fk_old"', $result->query); + } + // EXTENSIONS + + public function testCreateExtension(): void + { + $schema = new Schema(); + $result = $schema->createExtension('vector'); + + $this->assertEquals('CREATE EXTENSION IF NOT EXISTS "vector"', $result->query); + } + + public function testDropExtension(): void + { + $schema = new Schema(); + $result = $schema->dropExtension('vector'); + + $this->assertEquals('DROP EXTENSION IF EXISTS "vector"', $result->query); + } + // Views — double-quote wrapping + + public function testCreateView(): void + { + $schema = new Schema(); + $builder = (new PgBuilder())->from('users')->filter([Query::equal('active', [true])]); + $result = $schema->createView('active_users', $builder); + + $this->assertEquals( + 'CREATE VIEW "active_users" AS SELECT * FROM "users" WHERE "active" IN (?)', + $result->query + ); + } + + public function testDropView(): void + { + $schema = new Schema(); + $result = $schema->dropView('active_users'); + + $this->assertEquals('DROP VIEW "active_users"', $result->query); + } + // Shared operations — still work with double quotes + + public function testDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('users'); + + $this->assertEquals('DROP TABLE "users"', $result->query); + } + + public function testTruncateTable(): void + { + $schema = new Schema(); + $result = $schema->truncate('users'); + + $this->assertEquals('TRUNCATE TABLE "users"', $result->query); + } + + public function testRenameTableUsesAlterTable(): void + { + $schema = new Schema(); + $result = $schema->rename('users', 'members'); + + $this->assertEquals('ALTER TABLE "users" RENAME TO "members"', $result->query); + } + + // Edge cases + + public function testDropIfExists(): void + { + $schema = new Schema(); + $result = $schema->dropIfExists('users'); + + $this->assertEquals('DROP TABLE IF EXISTS "users"', $result->query); + } + + public function testCreateOrReplaceView(): void + { + $schema = new Schema(); + $builder = (new PgBuilder())->from('users'); + $result = $schema->createOrReplaceView('all_users', $builder); + + $this->assertStringStartsWith('CREATE OR REPLACE VIEW', $result->query); + } + + public function testCreateTableWithMultiplePrimaryKeys(): void + { + $schema = new Schema(); + $result = $schema->create('order_items', function (Blueprint $table) { + $table->integer('order_id')->primary(); + $table->integer('product_id')->primary(); + }); + + $this->assertStringContainsString('PRIMARY KEY ("order_id", "product_id")', $result->query); + } + + public function testCreateTableWithDefaultNull(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->string('name')->nullable()->default(null); + }); + + $this->assertStringContainsString('DEFAULT NULL', $result->query); + } + + public function testAlterAddMultipleColumns(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addColumn('first_name', 'string', 100); + $table->addColumn('last_name', 'string', 100); + $table->dropColumn('name'); + }); + + $this->assertStringContainsString('ADD COLUMN "first_name"', $result->query); + $this->assertStringContainsString('DROP COLUMN "name"', $result->query); + } + + public function testAlterAddForeignKey(): void + { + $schema = new Schema(); + $result = $schema->alter('orders', function (Blueprint $table) { + $table->addForeignKey('user_id')->references('id')->on('users')->onDelete('CASCADE'); + }); + + $this->assertStringContainsString('ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE', $result->query); + } + + public function testCreateIndexDefault(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_email', ['email']); + + $this->assertEquals('CREATE INDEX "idx_email" ON "users" ("email")', $result->query); + } + + public function testCreateUniqueIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_email', ['email'], unique: true); + + $this->assertEquals('CREATE UNIQUE INDEX "idx_email" ON "users" ("email")', $result->query); + } + + public function testCreateIndexMultiColumn(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_name', ['first_name', 'last_name']); + + $this->assertEquals('CREATE INDEX "idx_name" ON "users" ("first_name", "last_name")', $result->query); + } + + public function testAlterRenameColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->renameColumn('bio', 'biography'); + }); + + $this->assertStringContainsString('RENAME COLUMN "bio" TO "biography"', $result->query); + } + + public function testCreateTableWithTimestamps(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Blueprint $table) { + $table->id(); + $table->timestamps(); + }); + + $this->assertStringContainsString('"created_at" TIMESTAMP(3)', $result->query); + $this->assertStringContainsString('"updated_at" TIMESTAMP(3)', $result->query); + } + + public function testCreateTableWithForeignKey(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Blueprint $table) { + $table->id(); + $table->foreignKey('user_id') + ->references('id')->on('users') + ->onDelete('CASCADE'); + }); + + $this->assertStringContainsString('FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE', $result->query); + } + + public function testAddForeignKeyStandalone(): void + { + $schema = new Schema(); + $result = $schema->addForeignKey('orders', 'fk_user', 'user_id', 'users', 'id', 'CASCADE', 'SET NULL'); + + $this->assertEquals( + 'ALTER TABLE "orders" ADD CONSTRAINT "fk_user" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE SET NULL', + $result->query + ); + } + + public function testDropTriggerFunction(): void + { + $schema = new Schema(); + + // dropTrigger should use base SQL dropTrigger + $result = $schema->dropTrigger('trg_old'); + + $this->assertEquals('DROP TRIGGER "trg_old"', $result->query); + } + + public function testAlterWithUniqueIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addIndex('idx_email', ['email']); + $table->addIndex('idx_name', ['name']); + }); + + // Both should be standalone CREATE INDEX statements + $this->assertStringContainsString('CREATE INDEX "idx_email" ON "users" ("email")', $result->query); + $this->assertStringContainsString('CREATE INDEX "idx_name" ON "users" ("name")', $result->query); + } +} diff --git a/tests/Query/SpatialQueryTest.php b/tests/Query/SpatialQueryTest.php index f94f503..51a70a6 100644 --- a/tests/Query/SpatialQueryTest.php +++ b/tests/Query/SpatialQueryTest.php @@ -87,4 +87,50 @@ public function testNotTouches(): void $query = Query::notTouches('geo', [[0, 0]]); $this->assertSame(Method::NotTouches, $query->getMethod()); } + + public function testCoversFactory(): void + { + $query = Query::covers('zone', [1.0, 2.0]); + $this->assertSame(Method::Covers, $query->getMethod()); + $this->assertEquals('zone', $query->getAttribute()); + } + + public function testNotCoversFactory(): void + { + $query = Query::notCovers('zone', [1.0, 2.0]); + $this->assertSame(Method::NotCovers, $query->getMethod()); + } + + public function testSpatialEqualsFactory(): void + { + $query = Query::spatialEquals('geom', [3.0, 4.0]); + $this->assertSame(Method::SpatialEquals, $query->getMethod()); + $this->assertEquals([[3.0, 4.0]], $query->getValues()); + } + + public function testNotSpatialEqualsFactory(): void + { + $query = Query::notSpatialEquals('geom', [3.0, 4.0]); + $this->assertSame(Method::NotSpatialEquals, $query->getMethod()); + } + + public function testDistanceLessThanWithMeters(): void + { + $query = Query::distanceLessThan('location', [1.0, 2.0], 500, true); + $values = $query->getValues(); + $this->assertIsArray($values[0]); + $this->assertTrue($values[0][2]); + } + + public function testIsSpatialQueryTrue(): void + { + $query = Query::intersects('geo', [[0, 0]]); + $this->assertTrue($query->isSpatialQuery()); + } + + public function testIsSpatialQueryFalseForFilter(): void + { + $query = Query::equal('x', [1]); + $this->assertFalse($query->isSpatialQuery()); + } } From 51f467e912d2f00adb4674f4022bf34b4e424353 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Mar 2026 19:59:13 +1300 Subject: [PATCH 23/29] (docs): Update README with builder, schema, and hook documentation --- README.md | 951 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 678 insertions(+), 273 deletions(-) diff --git a/README.md b/README.md index 46ec309..2b4b7d6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Linter](https://github.com/utopia-php/query/actions/workflows/linter.yml/badge.svg)](https://github.com/utopia-php/query/actions/workflows/linter.yml) [![Static Analysis](https://github.com/utopia-php/query/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/utopia-php/query/actions/workflows/static-analysis.yml) -A simple PHP library providing a query abstraction for filtering, ordering, and pagination. It offers a fluent, type-safe API for building queries that can be serialized to JSON and parsed back, making it easy to pass query definitions between client and server or between services. +A PHP library for building type-safe, dialect-aware SQL queries and DDL statements. Provides a fluent builder API with parameterized output for MySQL, PostgreSQL, and ClickHouse, plus a serializable `Query` value object for passing query definitions between services. ## Installation @@ -12,84 +12,129 @@ A simple PHP library providing a query abstraction for filtering, ordering, and composer require utopia-php/query ``` -## System Requirements - -- PHP 8.4+ - -## Usage +**Requires PHP 8.4+** + +## Table of Contents + +- [Query Object](#query-object) + - [Filters](#filters) + - [Ordering and Pagination](#ordering-and-pagination) + - [Logical Combinations](#logical-combinations) + - [Spatial Queries](#spatial-queries) + - [Vector Similarity](#vector-similarity) + - [JSON Queries](#json-queries) + - [Selection](#selection) + - [Raw Expressions](#raw-expressions) + - [Serialization](#serialization) + - [Helpers](#helpers) +- [Query Builder](#query-builder) + - [Basic Usage](#basic-usage) + - [Aggregations](#aggregations) + - [Joins](#joins) + - [Unions and Set Operations](#unions-and-set-operations) + - [CTEs (Common Table Expressions)](#ctes-common-table-expressions) + - [Window Functions](#window-functions) + - [CASE Expressions](#case-expressions) + - [Inserts](#inserts) + - [Updates](#updates) + - [Deletes](#deletes) + - [Upsert](#upsert) + - [Locking](#locking) + - [Transactions](#transactions) + - [Conditional Building](#conditional-building) + - [Debugging](#debugging) + - [Hooks](#hooks) +- [Dialect-Specific Features](#dialect-specific-features) + - [MySQL](#mysql) + - [PostgreSQL](#postgresql) + - [ClickHouse](#clickhouse) + - [Feature Matrix](#feature-matrix) +- [Schema Builder](#schema-builder) + - [Creating Tables](#creating-tables) + - [Altering Tables](#altering-tables) + - [Indexes](#indexes) + - [Foreign Keys](#foreign-keys) + - [Views](#views) + - [Procedures and Triggers](#procedures-and-triggers) + - [PostgreSQL Schema Extensions](#postgresql-schema-extensions) + - [ClickHouse Schema](#clickhouse-schema) +- [Compiler Interface](#compiler-interface) +- [Contributing](#contributing) +- [License](#license) + +## Query Object + +The `Query` class is a serializable value object representing a single query predicate. It serves as the input to the builder's `filter()`, `having()`, and other methods. ```php use Utopia\Query\Query; -use Utopia\Query\Method; -use Utopia\Query\OrderDirection; -use Utopia\Query\CursorDirection; ``` -### Filter Queries +### Filters ```php // Equality -$query = Query::equal('status', ['active', 'pending']); -$query = Query::notEqual('role', 'guest'); +Query::equal('status', ['active', 'pending']); +Query::notEqual('role', 'guest'); // Comparison -$query = Query::greaterThan('age', 18); -$query = Query::greaterThanEqual('score', 90); -$query = Query::lessThan('price', 100); -$query = Query::lessThanEqual('quantity', 0); +Query::greaterThan('age', 18); +Query::greaterThanEqual('score', 90); +Query::lessThan('price', 100); +Query::lessThanEqual('quantity', 0); // Range -$query = Query::between('createdAt', '2024-01-01', '2024-12-31'); -$query = Query::notBetween('priority', 1, 3); +Query::between('createdAt', '2024-01-01', '2024-12-31'); +Query::notBetween('priority', 1, 3); // String matching -$query = Query::startsWith('email', 'admin'); -$query = Query::endsWith('filename', '.pdf'); -$query = Query::search('content', 'hello world'); -$query = Query::regex('slug', '^[a-z0-9-]+$'); +Query::startsWith('email', 'admin'); +Query::endsWith('filename', '.pdf'); +Query::search('content', 'hello world'); +Query::regex('slug', '^[a-z0-9-]+$'); // Array / contains -$query = Query::contains('tags', ['php', 'utopia']); -$query = Query::containsAny('categories', ['news', 'blog']); -$query = Query::containsAll('permissions', ['read', 'write']); -$query = Query::notContains('labels', ['deprecated']); +Query::contains('tags', ['php', 'utopia']); +Query::containsAny('categories', ['news', 'blog']); +Query::containsAll('permissions', ['read', 'write']); +Query::notContains('labels', ['deprecated']); // Null checks -$query = Query::isNull('deletedAt'); -$query = Query::isNotNull('verifiedAt'); +Query::isNull('deletedAt'); +Query::isNotNull('verifiedAt'); -// Existence -$query = Query::exists(['name', 'email']); -$query = Query::notExists('legacyField'); +// Existence (compiles to IS NOT NULL / IS NULL) +Query::exists(['name', 'email']); +Query::notExists('legacyField'); // Date helpers -$query = Query::createdAfter('2024-01-01'); -$query = Query::updatedBetween('2024-01-01', '2024-06-30'); +Query::createdAfter('2024-01-01'); +Query::updatedBetween('2024-01-01', '2024-06-30'); ``` ### Ordering and Pagination ```php -$query = Query::orderAsc('createdAt'); -$query = Query::orderDesc('score'); -$query = Query::orderRandom(); +Query::orderAsc('createdAt'); +Query::orderDesc('score'); +Query::orderRandom(); -$query = Query::limit(25); -$query = Query::offset(50); +Query::limit(25); +Query::offset(50); -$query = Query::cursorAfter('doc_abc123'); -$query = Query::cursorBefore('doc_xyz789'); +Query::cursorAfter('doc_abc123'); +Query::cursorBefore('doc_xyz789'); ``` ### Logical Combinations ```php -$query = Query::and([ +Query::and([ Query::greaterThan('age', 18), Query::equal('status', ['active']), ]); -$query = Query::or([ +Query::or([ Query::equal('role', ['admin']), Query::equal('role', ['moderator']), ]); @@ -98,27 +143,44 @@ $query = Query::or([ ### Spatial Queries ```php -$query = Query::distanceLessThan('location', [40.7128, -74.0060], 5000, meters: true); -$query = Query::distanceGreaterThan('location', [51.5074, -0.1278], 100); +Query::distanceLessThan('location', [40.7128, -74.0060], 5000, meters: true); +Query::distanceGreaterThan('location', [51.5074, -0.1278], 100); -$query = Query::intersects('area', [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]); -$query = Query::overlaps('region', [[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]]); -$query = Query::touches('boundary', [[0, 0], [1, 1]]); -$query = Query::crosses('path', [[0, 0], [5, 5]]); +Query::intersects('area', [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]); +Query::overlaps('region', [[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]]); +Query::touches('boundary', [[0, 0], [1, 1]]); +Query::crosses('path', [[0, 0], [5, 5]]); +Query::covers('zone', [1.0, 2.0]); +Query::spatialEquals('geom', [3.0, 4.0]); ``` ### Vector Similarity ```php -$query = Query::vectorDot('embedding', [0.1, 0.2, 0.3, 0.4]); -$query = Query::vectorCosine('embedding', [0.1, 0.2, 0.3, 0.4]); -$query = Query::vectorEuclidean('embedding', [0.1, 0.2, 0.3, 0.4]); +Query::vectorDot('embedding', [0.1, 0.2, 0.3, 0.4]); +Query::vectorCosine('embedding', [0.1, 0.2, 0.3, 0.4]); +Query::vectorEuclidean('embedding', [0.1, 0.2, 0.3, 0.4]); +``` + +### JSON Queries + +```php +Query::jsonContains('tags', 'php'); +Query::jsonNotContains('tags', 'legacy'); +Query::jsonOverlaps('categories', ['news', 'blog']); +Query::jsonPath('metadata', 'address.city', '=', 'London'); ``` ### Selection ```php -$query = Query::select(['name', 'email', 'createdAt']); +Query::select(['name', 'email', 'createdAt']); +``` + +### Raw Expressions + +```php +Query::raw('score > ? AND score < ?', [10, 100]); ``` ### Serialization @@ -128,157 +190,58 @@ Queries serialize to JSON and can be parsed back: ```php $query = Query::equal('status', ['active']); -// Serialize to JSON string +// Serialize $json = $query->toString(); // '{"method":"equal","attribute":"status","values":["active"]}' -// Parse back from JSON string +// Parse back $parsed = Query::parse($json); -// Parse multiple queries -$queries = Query::parseQueries([$json1, $json2, $json3]); +// Parse multiple +$queries = Query::parseQueries([$json1, $json2]); ``` -### Grouping Helpers - -`groupByType` splits an array of queries into a `GroupedQueries` object with typed properties: +### Helpers ```php -$queries = [ - Query::equal('status', ['active']), - Query::greaterThan('age', 18), - Query::orderAsc('name'), - Query::limit(25), - Query::offset(10), - Query::select(['name', 'email']), - Query::cursorAfter('abc123'), -]; - +// Group queries by type $grouped = Query::groupByType($queries); +// $grouped->filters, $grouped->limit, $grouped->orderAttributes, etc. -// $grouped->filters — filter Query objects -// $grouped->selections — select Query objects -// $grouped->limit — int|null -// $grouped->offset — int|null -// $grouped->orderAttributes — ['name'] -// $grouped->orderTypes — [OrderDirection::Asc] -// $grouped->cursor — 'abc123' -// $grouped->cursorDirection — CursorDirection::After -``` - -`getByType` filters queries by one or more method types: - -```php +// Filter by method type $cursors = Query::getByType($queries, [Method::CursorAfter, Method::CursorBefore]); -``` - -### Building a Compiler - -This library ships with a `Compiler` interface so you can translate queries into any backend syntax. Each query delegates to the correct compiler method via `$query->compile($compiler)`: - -```php -use Utopia\Query\Compiler; -use Utopia\Query\Query; -use Utopia\Query\Method; - -class SQLCompiler implements Compiler -{ - public function compileFilter(Query $query): string - { - return match ($query->getMethod()) { - Method::Equal => $query->getAttribute() . ' IN (' . $this->placeholders($query->getValues()) . ')', - Method::NotEqual => $query->getAttribute() . ' != ?', - Method::GreaterThan => $query->getAttribute() . ' > ?', - Method::LessThan => $query->getAttribute() . ' < ?', - Method::Between => $query->getAttribute() . ' BETWEEN ? AND ?', - Method::IsNull => $query->getAttribute() . ' IS NULL', - Method::IsNotNull => $query->getAttribute() . ' IS NOT NULL', - Method::StartsWith => $query->getAttribute() . " LIKE CONCAT(?, '%')", - // ... handle remaining types - }; - } - - public function compileOrder(Query $query): string - { - return match ($query->getMethod()) { - Method::OrderAsc => $query->getAttribute() . ' ASC', - Method::OrderDesc => $query->getAttribute() . ' DESC', - Method::OrderRandom => 'RAND()', - }; - } - - public function compileLimit(Query $query): string - { - return 'LIMIT ' . $query->getValue(); - } - - public function compileOffset(Query $query): string - { - return 'OFFSET ' . $query->getValue(); - } - - public function compileSelect(Query $query): string - { - return implode(', ', $query->getValues()); - } - public function compileCursor(Query $query): string - { - // Cursor-based pagination is adapter-specific - return ''; - } -} -``` - -Then calling `compile()` on any query routes to the right method automatically: - -```php -$compiler = new SQLCompiler(); +// Merge (later limit/offset/cursor overrides earlier) +$merged = Query::merge($defaultQueries, $userQueries); -$filter = Query::greaterThan('age', 18); -echo $filter->compile($compiler); // "age > ?" +// Diff — queries in A not in B +$unique = Query::diff($queriesA, $queriesB); -$order = Query::orderAsc('name'); -echo $order->compile($compiler); // "name ASC" +// Validate attributes against an allow-list +$errors = Query::validate($queries, ['name', 'age', 'status']); -$limit = Query::limit(25); -echo $limit->compile($compiler); // "LIMIT 25" +// Page helper — returns [limit, offset] queries +[$limit, $offset] = Query::page(3, 10); ``` -The same interface works for any backend — implement `Compiler` for Redis, MongoDB, Elasticsearch, etc. and every query compiles without changes: +## Query Builder -```php -class RedisCompiler implements Compiler -{ - public function compileFilter(Query $query): string - { - return match ($query->getMethod()) { - Method::Between => $query->getValues()[0] . ' ' . $query->getValues()[1], - Method::GreaterThan => '(' . $query->getValue() . ' +inf', - // ... handle remaining types - }; - } +The builder generates parameterized SQL from the fluent API. Every `build()`, `insert()`, `update()`, and `delete()` call returns a `BuildResult` with `->query` (the SQL string) and `->bindings` (the parameter array). - // ... implement remaining methods -} -``` - -This is the pattern used by [utopia-php/database](https://github.com/utopia-php/database) — it implements `Compiler` for each supported database engine, keeping application code fully decoupled from any particular storage backend. - -### Builder Hierarchy +Three dialect implementations are provided: -The library includes a builder system for generating parameterized queries. The `build()` method returns a `BuildResult` object with `->query` and `->bindings` properties. The abstract `Builder` base class provides the fluent API and query orchestration, while concrete implementations handle dialect-specific compilation: +- `Utopia\Query\Builder\MySQL` — MySQL/MariaDB +- `Utopia\Query\Builder\PostgreSQL` — PostgreSQL +- `Utopia\Query\Builder\ClickHouse` — ClickHouse -- `Utopia\Query\Builder\SQL` — MySQL/MariaDB/SQLite (backtick quoting, `REGEXP`, `MATCH() AGAINST()`, `RAND()`) -- `Utopia\Query\Builder\ClickHouse` — ClickHouse (backtick quoting, `match()`, `rand()`, `PREWHERE`, `FINAL`, `SAMPLE`) +MySQL and PostgreSQL extend `Builder\SQL` which adds locking, transactions, and upsert. ClickHouse extends `Builder` directly with its own `ALTER TABLE` mutation syntax. -### SQL Builder +### Basic Usage ```php -use Utopia\Query\Builder\SQL as Builder; +use Utopia\Query\Builder\MySQL as Builder; use Utopia\Query\Query; -// Fluent API $result = (new Builder()) ->select(['name', 'email']) ->from('users') @@ -323,7 +286,7 @@ $stmt->execute($result->bindings); $rows = $stmt->fetchAll(); ``` -**Aggregations** — count, sum, avg, min, max with optional aliases: +### Aggregations ```php $result = (new Builder()) @@ -351,7 +314,7 @@ $result = (new Builder()) // SELECT DISTINCT `country` FROM `users` ``` -**Joins** — inner, left, right, and cross joins: +### Joins ```php $result = (new Builder()) @@ -362,56 +325,200 @@ $result = (new Builder()) ->build(); // SELECT * FROM `users` -// JOIN `orders` ON `users.id` = `orders.user_id` -// LEFT JOIN `profiles` ON `users.id` = `profiles.user_id` +// JOIN `orders` ON `users`.`id` = `orders`.`user_id` +// LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`user_id` // CROSS JOIN `colors` ``` -**Raw expressions:** +### Unions and Set Operations ```php +$admins = (new Builder())->from('admins')->filter([Query::equal('role', ['admin'])]); + $result = (new Builder()) - ->from('t') - ->filter([Query::raw('score > ? AND score < ?', [10, 100])]) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->union($admins) ->build(); -// SELECT * FROM `t` WHERE score > ? AND score < ? -// bindings: [10, 100] +// SELECT * FROM `users` WHERE `status` IN (?) +// UNION SELECT * FROM `admins` WHERE `role` IN (?) ``` -**Union:** +Also available: `unionAll()`, `intersect()`, `intersectAll()`, `except()`, `exceptAll()`. + +### CTEs (Common Table Expressions) ```php -$admins = (new Builder())->from('admins')->filter([Query::equal('role', ['admin'])]); +$activeUsers = (new Builder())->from('users')->filter([Query::equal('status', ['active'])]); + $result = (new Builder()) - ->from('users') - ->filter([Query::equal('status', ['active'])]) - ->union($admins) + ->with('active_users', $activeUsers) + ->from('active_users') + ->select(['name']) ->build(); -// SELECT * FROM `users` WHERE `status` IN (?) -// UNION SELECT * FROM `admins` WHERE `role` IN (?) +// WITH `active_users` AS (SELECT * FROM `users` WHERE `status` IN (?)) +// SELECT `name` FROM `active_users` ``` -**Conditional building** — `when()` applies a callback only when the condition is true: +Use `withRecursive()` for recursive CTEs. + +### Window Functions + +```php +$result = (new Builder()) + ->from('sales') + ->select(['employee', 'amount']) + ->selectWindow('ROW_NUMBER()', 'row_num', partitionBy: ['department'], orderBy: ['amount']) + ->selectWindow('SUM(amount)', 'running_total', partitionBy: ['department'], orderBy: ['date']) + ->build(); + +// SELECT `employee`, `amount`, +// ROW_NUMBER() OVER (PARTITION BY `department` ORDER BY `amount` ASC) AS `row_num`, +// SUM(amount) OVER (PARTITION BY `department` ORDER BY `date` ASC) AS `running_total` +// FROM `sales` +``` + +Prefix an `orderBy` column with `-` for descending order (e.g., `['-amount']`). + +### CASE Expressions + +```php +$result = (new Builder()) + ->from('orders') + ->select(['id']) + ->selectCase( + (new Builder())->case() + ->when('amount > ?', 'high', conditionBindings: [1000]) + ->when('amount > ?', 'medium', conditionBindings: [100]) + ->elseResult('low') + ->alias('priority') + ->build() + ) + ->build(); + +// SELECT `id`, CASE WHEN amount > ? THEN ? WHEN amount > ? THEN ? ELSE ? END AS `priority` +// FROM `orders` +``` + +### Inserts + +```php +// Single row +$result = (new Builder()) + ->into('users') + ->set('name', 'Alice') + ->set('email', 'alice@example.com') + ->insert(); + +// Batch insert +$result = (new Builder()) + ->into('users') + ->set('name', 'Alice')->set('email', 'alice@example.com') + ->addRow() + ->set('name', 'Bob')->set('email', 'bob@example.com') + ->insert(); + +// INSERT ... SELECT +$source = (new Builder())->from('archived_users')->filter([Query::equal('status', ['active'])]); + +$result = (new Builder()) + ->into('users') + ->fromSelect($source, ['name', 'email']) + ->insertSelect(); +``` + +### Updates ```php $result = (new Builder()) ->from('users') - ->when($filterActive, fn(Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->set('status', 'inactive') + ->setRaw('updated_at', 'NOW()') + ->filter([Query::equal('id', [42])]) + ->update(); + +// UPDATE `users` SET `status` = ?, `updated_at` = NOW() WHERE `id` IN (?) +``` + +### Deletes + +```php +$result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['deleted'])]) + ->delete(); + +// DELETE FROM `users` WHERE `status` IN (?) +``` + +### Upsert + +Available on MySQL and PostgreSQL builders (`Builder\SQL` subclasses): + +```php +// MySQL — ON DUPLICATE KEY UPDATE +$result = (new Builder()) + ->into('counters') + ->set('key', 'visits') + ->set('value', 1) + ->onConflict(['key']) + ->upsert(); + +// PostgreSQL — ON CONFLICT (...) DO UPDATE SET +$result = (new \Utopia\Query\Builder\PostgreSQL()) + ->into('counters') + ->set('key', 'visits') + ->set('value', 1) + ->onConflict(['key']) + ->upsert(); +``` + +### Locking + +Available on MySQL and PostgreSQL builders: + +```php +$result = (new Builder()) + ->from('accounts') + ->filter([Query::equal('id', [1])]) + ->forUpdate() ->build(); + +// SELECT * FROM `accounts` WHERE `id` IN (?) FOR UPDATE +``` + +Also available: `forShare()`. + +### Transactions + +Available on MySQL and PostgreSQL builders: + +```php +$builder = new Builder(); + +$builder->begin(); // BEGIN +$builder->savepoint('sp1'); // SAVEPOINT `sp1` +$builder->rollbackToSavepoint('sp1'); +$builder->commit(); // COMMIT +$builder->rollback(); // ROLLBACK ``` -**Page helper** — page-based pagination: +### Conditional Building + +`when()` applies a callback only when the condition is true: ```php $result = (new Builder()) ->from('users') - ->page(3, 10) // page 3, 10 per page → LIMIT 10 OFFSET 20 + ->when($filterActive, fn(Builder $b) => $b->filter([Query::equal('status', ['active'])])) ->build(); ``` -**Debug** — `toRawSql()` inlines bindings for inspection (not for execution): +### Debugging + +`toRawSql()` inlines bindings for inspection (not for execution): ```php $sql = (new Builder()) @@ -423,94 +530,224 @@ $sql = (new Builder()) // SELECT * FROM `users` WHERE `status` IN ('active') LIMIT 10 ``` -**Query helpers** — merge, diff, and validate: - -```php -// Merge queries (later limit/offset/cursor overrides earlier) -$merged = Query::merge($defaultQueries, $userQueries); - -// Diff — queries in A not in B -$unique = Query::diff($queriesA, $queriesB); - -// Validate attributes against an allow-list -$errors = Query::validate($queries, ['name', 'age', 'status']); +### Hooks -// Page helper — returns [limit, offset] queries -[$limit, $offset] = Query::page(3, 10); -``` +Hooks extend the builder with reusable, testable classes for attribute resolution and condition injection. -**Hooks** — extend the builder with reusable, testable hook classes for attribute resolution and condition injection: +**Attribute hooks** map virtual field names to real column names: ```php -use Utopia\Query\Hook\AttributeMapHook; -use Utopia\Query\Hook\TenantFilterHook; -use Utopia\Query\Hook\PermissionFilterHook; +use Utopia\Query\Hook\Attribute\Map; $result = (new Builder()) ->from('users') - ->addHook(new AttributeMapHook([ + ->addHook(new Map([ '$id' => '_uid', '$createdAt' => '_createdAt', ])) - ->addHook(new TenantFilterHook(['tenant_abc'])) - ->setWrapChar('"') // PostgreSQL - ->filter([Query::equal('status', ['active'])]) + ->filter([Query::equal('$id', ['abc'])]) ->build(); -// SELECT * FROM "users" WHERE "status" IN (?) AND _tenant IN (?) -// bindings: ['active', 'tenant_abc'] +// SELECT * FROM `users` WHERE `_uid` IN (?) ``` -Built-in hooks: +**Filter hooks** inject conditions into every query: -- `AttributeMapHook` — maps query attribute names to underlying column names -- `TenantFilterHook` — injects a tenant ID filter (multi-tenancy) -- `PermissionFilterHook` — injects a permission subquery filter +```php +use Utopia\Query\Hook\Filter\Tenant; +use Utopia\Query\Hook\Filter\Permission; + +$result = (new Builder()) + ->from('users') + ->addHook(new Tenant(['tenant_abc'])) + ->addHook(new Permission( + roles: ['role:member'], + permissionsTable: fn(string $table) => "mydb_{$table}_perms", + )) + ->filter([Query::equal('status', ['active'])]) + ->build(); -Custom hooks implement `FilterHook` or `AttributeHook`: +// SELECT * FROM `users` +// WHERE `status` IN (?) AND `tenant_id` IN (?) +// AND `id` IN (SELECT DISTINCT `document_id` FROM `mydb_users_perms` WHERE `role` IN (?) AND `type` = ?) +``` + +**Custom filter hooks** implement `Hook\Filter`: ```php use Utopia\Query\Builder\Condition; -use Utopia\Query\Hook\FilterHook; +use Utopia\Query\Hook\Filter; -class SoftDeleteHook implements FilterHook +class SoftDeleteHook implements Filter { public function filter(string $table): Condition { return new Condition('deleted_at IS NULL'); } } +``` +**Join filter hooks** inject per-join conditions with placement control (ON vs WHERE): + +```php +use Utopia\Query\Hook\Join\Filter as JoinFilter; +use Utopia\Query\Hook\Join\Condition as JoinCondition; +use Utopia\Query\Hook\Join\Placement; + +class ActiveJoinFilter implements JoinFilter +{ + public function filterJoin(string $table, string $joinType): ?JoinCondition + { + return new JoinCondition( + new Condition('active = ?', [1]), + $joinType === 'LEFT JOIN' ? Placement::On : Placement::Where, + ); + } +} +``` + +Built-in `Tenant` and `Permission` hooks implement both `Filter` and `JoinFilter` — they automatically apply ON placement for LEFT/RIGHT joins and WHERE placement for INNER/CROSS joins. + +## Dialect-Specific Features + +### MySQL + +```php +use Utopia\Query\Builder\MySQL as Builder; +``` + +**Spatial queries** — uses `ST_Distance()`, `ST_Intersects()`, `ST_Contains()`, etc.: + +```php +$result = (new Builder()) + ->from('stores') + ->filterDistance('location', [40.7128, -74.0060], '<', 5000, meters: true) + ->build(); + +// WHERE ST_Distance(ST_SRID(`location`, 4326), ST_GeomFromText(?, 4326), 'metre') < ? +``` + +All spatial predicates: `filterDistance`, `filterIntersects`, `filterNotIntersects`, `filterCrosses`, `filterNotCrosses`, `filterOverlaps`, `filterNotOverlaps`, `filterTouches`, `filterNotTouches`, `filterCovers`, `filterNotCovers`, `filterSpatialEquals`, `filterNotSpatialEquals`. + +**JSON operations:** + +```php +// Filtering +$result = (new Builder()) + ->from('products') + ->filterJsonContains('tags', 'sale') + ->filterJsonPath('metadata', 'color', '=', 'red') + ->build(); + +// WHERE JSON_CONTAINS(`tags`, ?) AND JSON_EXTRACT(`metadata`, '$.color') = ? + +// Mutations (in UPDATE) +$result = (new Builder()) + ->from('products') + ->filter([Query::equal('id', [1])]) + ->setJsonAppend('tags', ['new-tag']) + ->update(); +``` + +JSON mutation methods: `setJsonAppend`, `setJsonPrepend`, `setJsonInsert`, `setJsonRemove`, `setJsonIntersect`, `setJsonDiff`, `setJsonUnique`. + +**Query hints:** + +```php $result = (new Builder()) ->from('users') - ->addHook(new SoftDeleteHook()) + ->hint('NO_INDEX_MERGE(users)') + ->maxExecutionTime(5000) ->build(); -// SELECT * FROM `users` WHERE deleted_at IS NULL +// SELECT /*+ NO_INDEX_MERGE(users) max_execution_time(5000) */ * FROM `users` ``` -### ClickHouse Builder +**Full-text search** — `MATCH() AGAINST()`: + +```php +$result = (new Builder()) + ->from('articles') + ->filter([Query::search('content', 'hello world')]) + ->build(); + +// WHERE MATCH(`content`) AGAINST (?) +``` -The ClickHouse builder handles ClickHouse-specific SQL dialect differences: +### PostgreSQL + +```php +use Utopia\Query\Builder\PostgreSQL as Builder; +``` + +**Spatial queries** — uses PostGIS functions with geography casting for meter-based distance: + +```php +$result = (new Builder()) + ->from('stores') + ->filterDistance('location', [40.7128, -74.0060], '<', 5000, meters: true) + ->build(); + +// WHERE ST_Distance(("location"::geography), ST_SetSRID(ST_GeomFromText(?), 4326)::geography) < ? +``` + +**Vector search** — uses pgvector operators (`<=>`, `<->`, `<#>`): + +```php +$result = (new Builder()) + ->from('documents') + ->select(['title']) + ->orderByVectorDistance('embedding', [0.1, 0.2, 0.3], 'cosine') + ->limit(10) + ->build(); + +// SELECT "title" FROM "documents" ORDER BY ("embedding" <=> ?::vector) ASC LIMIT ? +``` + +Metrics: `cosine` (`<=>`), `euclidean` (`<->`), `dot` (`<#>`). + +**JSON operations** — uses native JSONB operators: + +```php +$result = (new Builder()) + ->from('products') + ->filterJsonContains('tags', 'sale') + ->build(); + +// WHERE "tags" @> ?::jsonb +``` + +**Full-text search** — `to_tsvector() @@ plainto_tsquery()`: + +```php +$result = (new Builder()) + ->from('articles') + ->filter([Query::search('content', 'hello world')]) + ->build(); + +// WHERE to_tsvector("content") @@ plainto_tsquery(?) +``` + +**Regex** — uses PostgreSQL `~` operator instead of `REGEXP`. + +### ClickHouse ```php use Utopia\Query\Builder\ClickHouse as Builder; -use Utopia\Query\Query; ``` -**FINAL** — force merging of data parts (for ReplacingMergeTree, CollapsingMergeTree, etc.): +**FINAL** — force merging of data parts: ```php $result = (new Builder()) ->from('events') ->final() - ->filter([Query::equal('status', ['active'])]) ->build(); -// SELECT * FROM `events` FINAL WHERE `status` IN (?) +// SELECT * FROM `events` FINAL ``` -**SAMPLE** — approximate query processing on a fraction of data: +**SAMPLE** — approximate query processing: ```php $result = (new Builder()) @@ -522,7 +759,7 @@ $result = (new Builder()) // SELECT COUNT(*) AS `approx_total` FROM `events` SAMPLE 0.1 ``` -**PREWHERE** — filter before reading all columns (major performance optimization for wide tables): +**PREWHERE** — filter before reading columns (optimization for wide tables): ```php $result = (new Builder()) @@ -534,62 +771,230 @@ $result = (new Builder()) // SELECT * FROM `events` PREWHERE `event_type` IN (?) WHERE `count` > ? ``` -**Combined** — all ClickHouse features work together: +**SETTINGS:** ```php $result = (new Builder()) ->from('events') - ->final() - ->sample(0.1) - ->prewhere([Query::equal('event_type', ['purchase'])]) - ->join('users', 'events.user_id', 'users.id') - ->filter([Query::greaterThan('events.amount', 100)]) - ->count('*', 'total') - ->groupBy(['users.country']) - ->sortDesc('total') - ->limit(50) + ->settings(['max_threads' => '4', 'optimize_read_in_order' => '1']) ->build(); -// SELECT COUNT(*) AS `total` FROM `events` FINAL SAMPLE 0.1 -// JOIN `users` ON `events.user_id` = `users.id` -// PREWHERE `event_type` IN (?) -// WHERE `events.amount` > ? -// GROUP BY `users.country` -// ORDER BY `total` DESC LIMIT ? +// SELECT * FROM `events` SETTINGS max_threads=4, optimize_read_in_order=1 ``` -**Regex** — uses ClickHouse's `match()` function instead of `REGEXP`: +**String matching** — uses native ClickHouse functions instead of LIKE: + +```php +// startsWith/endsWith → native functions +Query::startsWith('name', 'Al'); // startsWith(`name`, ?) +Query::endsWith('file', '.pdf'); // endsWith(`file`, ?) + +// contains/notContains → position() +Query::contains('tags', ['php']); // position(`tags`, ?) > 0 +``` + +**Regex** — uses `match()` function instead of `REGEXP`. + +**UPDATE/DELETE** — compiles to `ALTER TABLE ... UPDATE/DELETE` with mandatory WHERE: ```php $result = (new Builder()) - ->from('logs') - ->filter([Query::regex('path', '^/api/v[0-9]+')]) - ->build(); + ->from('events') + ->set('status', 'archived') + ->filter([Query::lessThan('created_at', '2024-01-01')]) + ->update(); -// SELECT * FROM `logs` WHERE match(`path`, ?) +// ALTER TABLE `events` UPDATE `status` = ? WHERE `created_at` < ? ``` -> **Note:** Full-text search (`Query::search()`) is not supported in the ClickHouse builder and will throw an exception. Use `Query::contains()` or a custom full-text index instead. +> **Note:** Full-text search (`Query::search()`) is not supported in ClickHouse and throws `UnsupportedException`. The ClickHouse builder also forces all join filter hook conditions to WHERE placement, since ClickHouse does not support subqueries in JOIN ON. -## Contributing +### Feature Matrix -All code contributions should go through a pull request and be approved by a core developer before being merged. This is to ensure a proper review of all the code. +Unsupported features are not on the class — consumers type-hint the interface to check capability (e.g., `if ($builder instanceof Spatial)`). -```bash -# Install dependencies -composer install +| Feature | Builder | SQL | MySQL | PostgreSQL | ClickHouse | +|---------|:-------:|:---:|:-----:|:----------:|:----------:| +| Selects, Filters, Aggregates, Joins, Unions, CTEs, Inserts, Updates, Deletes, Hooks | x | | | | | +| Windows | x | | | | | +| Locking, Transactions, Upsert | | x | | | | +| Spatial | | | x | x | | +| Vector Search | | | | x | | +| JSON | | | x | x | | +| Hints | | | x | | x | +| PREWHERE, FINAL, SAMPLE | | | | | x | + +## Schema Builder + +The schema builder generates DDL statements for table creation, alteration, indexes, views, and more. + +```php +use Utopia\Query\Schema\MySQL as Schema; +// or: PostgreSQL, ClickHouse +``` + +### Creating Tables + +```php +$schema = new Schema(); + +$result = $schema->create('users', function ($table) { + $table->id(); + $table->string('name', 255); + $table->string('email', 255)->unique(); + $table->integer('age')->nullable(); + $table->boolean('active')->default(true); + $table->json('metadata'); + $table->timestamps(); +}); + +$result->query; // CREATE TABLE `users` (...) +``` -# Run tests -composer test +Available column types: `id`, `string`, `text`, `integer`, `bigInteger`, `float`, `boolean`, `datetime`, `timestamp`, `json`, `binary`, `enum`, `point`, `linestring`, `polygon`, `vector` (PostgreSQL only), `timestamps`. -# Run linter -composer lint +Column modifiers: `nullable()`, `default($value)`, `unsigned()`, `unique()`, `primary()`, `autoIncrement()`, `after($column)`, `comment($text)`. -# Auto-format code -composer format +### Altering Tables -# Run static analysis -composer check +```php +$result = $schema->alter('users', function ($table) { + $table->string('phone', 20)->nullable(); + $table->modifyColumn('name', 'string', 500); + $table->renameColumn('email', 'email_address'); + $table->dropColumn('legacy_field'); +}); +``` + +### Indexes + +```php +$result = $schema->createIndex('users', 'idx_email', ['email'], unique: true); +$result = $schema->dropIndex('users', 'idx_email'); +``` + +PostgreSQL supports index methods and operator classes: + +```php +$schema = new \Utopia\Query\Schema\PostgreSQL(); + +// GIN trigram index +$result = $schema->createIndex('users', 'idx_name_trgm', ['name'], + method: 'gin', operatorClass: 'gin_trgm_ops'); + +// HNSW vector index +$result = $schema->createIndex('documents', 'idx_embedding', ['embedding'], + method: 'hnsw', operatorClass: 'vector_cosine_ops'); +``` + +### Foreign Keys + +```php +$result = $schema->addForeignKey('orders', 'fk_user', 'user_id', + 'users', 'id', onDelete: 'CASCADE'); + +$result = $schema->dropForeignKey('orders', 'fk_user'); +``` + +### Views + +```php +$query = (new Builder())->from('users')->filter([Query::equal('active', [true])]); + +$result = $schema->createView('active_users', $query); +$result = $schema->createOrReplaceView('active_users', $query); +$result = $schema->dropView('active_users'); +``` + +### Procedures and Triggers + +```php +// MySQL +$result = $schema->createProcedure('update_stats', ['IN user_id INT'], 'UPDATE stats SET count = count + 1 WHERE id = user_id;'); + +// Trigger +$result = $schema->createTrigger('before_insert_users', 'users', 'BEFORE', 'INSERT', 'SET NEW.created_at = NOW();'); +``` + +### PostgreSQL Schema Extensions + +```php +$schema = new \Utopia\Query\Schema\PostgreSQL(); + +// Extensions (e.g., pgvector, pg_trgm) +$result = $schema->createExtension('vector'); +// CREATE EXTENSION IF NOT EXISTS "vector" + +// Procedures → CREATE FUNCTION ... LANGUAGE plpgsql +$result = $schema->createProcedure('increment', ['p_id INTEGER'], ' +BEGIN + UPDATE counters SET value = value + 1 WHERE id = p_id; +END; +'); + +// DROP CONSTRAINT instead of DROP FOREIGN KEY +$result = $schema->dropForeignKey('orders', 'fk_user'); +// ALTER TABLE "orders" DROP CONSTRAINT "fk_user" + +// DROP INDEX without table name +$result = $schema->dropIndex('orders', 'idx_status'); +// DROP INDEX "idx_status" +``` + +Type differences from MySQL: `INTEGER` (not `INT`), `DOUBLE PRECISION` (not `DOUBLE`), `BOOLEAN` (not `TINYINT(1)`), `JSONB` (not `JSON`), `BYTEA` (not `BLOB`), `VECTOR(n)` for pgvector, `GEOMETRY(type, srid)` for PostGIS. Enums use `TEXT CHECK (col IN (...))`. Auto-increment uses `GENERATED BY DEFAULT AS IDENTITY`. + +### ClickHouse Schema + +```php +$schema = new \Utopia\Query\Schema\ClickHouse(); + +$result = $schema->create('events', function ($table) { + $table->string('event_id', 36)->primary(); + $table->string('event_type', 50); + $table->integer('count'); + $table->datetime('created_at'); +}); + +// CREATE TABLE `events` (...) ENGINE = MergeTree() ORDER BY (...) +``` + +ClickHouse uses `Nullable(type)` wrapping for nullable columns, `Enum8(...)` for enums, `Tuple(Float64, Float64)` for points, and `TYPE minmax GRANULARITY 3` for indexes. Foreign keys, stored procedures, and triggers throw `UnsupportedException`. + +## Compiler Interface + +The `Compiler` interface lets you build custom backends. Each `Query` dispatches to the correct compiler method via `$query->compile($compiler)`: + +```php +use Utopia\Query\Compiler; +use Utopia\Query\Query; +use Utopia\Query\Method; + +class MyCompiler implements Compiler +{ + public function compileFilter(Query $query): string { /* ... */ } + public function compileOrder(Query $query): string { /* ... */ } + public function compileLimit(Query $query): string { /* ... */ } + public function compileOffset(Query $query): string { /* ... */ } + public function compileSelect(Query $query): string { /* ... */ } + public function compileCursor(Query $query): string { /* ... */ } + public function compileAggregate(Query $query): string { /* ... */ } + public function compileGroupBy(Query $query): string { /* ... */ } + public function compileJoin(Query $query): string { /* ... */ } +} +``` + +This is the pattern used by [utopia-php/database](https://github.com/utopia-php/database) — it implements `Compiler` for each supported database engine, keeping application code decoupled from storage backends. + +## Contributing + +All code contributions should go through a pull request and be approved by a core developer before being merged. + +```bash +composer install # Install dependencies +composer test # Run tests +composer lint # Check formatting +composer format # Auto-format code +composer check # Run static analysis (PHPStan level max) ``` ## License From aced40dc35767dbd95471670eb0c1588d27dc6b4 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 11 Mar 2026 00:05:55 +1300 Subject: [PATCH 24/29] (refactor): Refine builder APIs with enums, value objects, and strict types --- src/Query/Builder.php | 309 +++++++++++++-------- src/Query/Builder/Case/Builder.php | 15 +- src/Query/Builder/Case/Expression.php | 7 - src/Query/Builder/Case/WhenClause.php | 18 ++ src/Query/Builder/ClickHouse.php | 8 +- src/Query/Builder/Condition.php | 10 - src/Query/Builder/CteClause.php | 17 ++ src/Query/Builder/ExistsSubquery.php | 14 + src/Query/Builder/Feature/Joins.php | 4 +- src/Query/Builder/Feature/VectorSearch.php | 5 +- src/Query/Builder/JoinBuilder.php | 27 +- src/Query/Builder/JoinOn.php | 13 + src/Query/Builder/JoinType.php | 11 + src/Query/Builder/LockMode.php | 18 ++ src/Query/Builder/MySQL.php | 38 +-- src/Query/Builder/PostgreSQL.php | 68 +++-- src/Query/Builder/SQL.php | 28 +- src/Query/Builder/SubSelect.php | 14 + src/Query/Builder/UnionClause.php | 2 +- src/Query/Builder/UnionType.php | 13 + src/Query/Builder/VectorMetric.php | 19 ++ src/Query/Builder/WhereInSubquery.php | 15 + src/Query/Builder/WindowSelect.php | 18 ++ src/Query/Hook/Filter/Permission.php | 9 +- src/Query/Hook/Filter/Tenant.php | 5 +- src/Query/Hook/Join/Filter.php | 3 +- src/Query/Query.php | 14 +- 27 files changed, 474 insertions(+), 248 deletions(-) create mode 100644 src/Query/Builder/Case/WhenClause.php create mode 100644 src/Query/Builder/CteClause.php create mode 100644 src/Query/Builder/ExistsSubquery.php create mode 100644 src/Query/Builder/JoinOn.php create mode 100644 src/Query/Builder/JoinType.php create mode 100644 src/Query/Builder/LockMode.php create mode 100644 src/Query/Builder/SubSelect.php create mode 100644 src/Query/Builder/UnionType.php create mode 100644 src/Query/Builder/VectorMetric.php create mode 100644 src/Query/Builder/WhereInSubquery.php create mode 100644 src/Query/Builder/WindowSelect.php diff --git a/src/Query/Builder.php b/src/Query/Builder.php index e16b22b..d303000 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -5,10 +5,19 @@ use Closure; use Utopia\Query\Builder\BuildResult; use Utopia\Query\Builder\Case\Expression as CaseExpression; +use Utopia\Query\Builder\Condition; +use Utopia\Query\Builder\CteClause; +use Utopia\Query\Builder\ExistsSubquery; use Utopia\Query\Builder\Feature; use Utopia\Query\Builder\GroupedQueries; use Utopia\Query\Builder\JoinBuilder; +use Utopia\Query\Builder\JoinType; +use Utopia\Query\Builder\LockMode; +use Utopia\Query\Builder\SubSelect; use Utopia\Query\Builder\UnionClause; +use Utopia\Query\Builder\UnionType; +use Utopia\Query\Builder\WhereInSubquery; +use Utopia\Query\Builder\WindowSelect; use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Hook\Attribute; @@ -66,26 +75,28 @@ abstract class Builder implements /** @var array> */ protected array $rawSetBindings = []; - protected ?string $lockMode = null; + protected ?LockMode $lockMode = null; + + protected ?string $lockOfTable = null; protected ?Builder $insertSelectSource = null; /** @var list */ protected array $insertSelectColumns = []; - /** @var list, recursive: bool}> */ + /** @var list */ protected array $ctes = []; - /** @var list}> */ + /** @var list */ protected array $rawSelects = []; - /** @var list, orderBy: ?list}> */ + /** @var list */ protected array $windowSelects = []; - /** @var list}> */ + /** @var list */ protected array $caseSelects = []; - /** @var array}> */ + /** @var array */ protected array $caseSets = []; /** @var string[] */ @@ -100,28 +111,37 @@ abstract class Builder implements /** @var array> */ protected array $conflictRawSetBindings = []; - /** @var list */ + /** @var array Column-specific expressions for INSERT (e.g. 'location' => 'ST_GeomFromText(?)') */ + protected array $insertColumnExpressions = []; + + /** @var array> Extra bindings for insert column expressions */ + protected array $insertColumnExpressionBindings = []; + + protected string $insertAlias = ''; + + /** @var list */ protected array $whereInSubqueries = []; - /** @var list */ + /** @var list */ protected array $subSelects = []; - /** @var ?array{subquery: Builder, alias: string} */ - protected ?array $fromSubquery = null; + protected ?SubSelect $fromSubquery = null; + + protected bool $noTable = false; - /** @var list}> */ + /** @var list */ protected array $rawOrders = []; - /** @var list}> */ + /** @var list */ protected array $rawGroups = []; - /** @var list}> */ + /** @var list */ protected array $rawHavings = []; /** @var array */ protected array $joinBuilders = []; - /** @var list */ + /** @var list */ protected array $existsSubqueries = []; abstract protected function quote(string $identifier): string; @@ -147,14 +167,18 @@ abstract protected function compileSearch(string $attribute, array $values, bool protected function buildTableClause(): string { + if ($this->noTable) { + return ''; + } + $fromSub = $this->fromSubquery; if ($fromSub !== null) { - $subResult = $fromSub['subquery']->build(); + $subResult = $fromSub->subquery->build(); foreach ($subResult->bindings as $binding) { $this->addBinding($binding); } - return 'FROM (' . $subResult->query . ') AS ' . $this->quote($fromSub['alias']); + return 'FROM (' . $subResult->query . ') AS ' . $this->quote($fromSub->alias); } $sql = 'FROM ' . $this->quote($this->table); @@ -181,6 +205,20 @@ public function from(string $table, string $alias = ''): static $this->table = $table; $this->tableAlias = $alias; $this->fromSubquery = null; + $this->noTable = false; + + return $this; + } + + /** + * Build a query without a FROM clause (e.g. SELECT 1, SELECT CONNECTION_ID()). + */ + public function fromNone(): static + { + $this->noTable = true; + $this->table = ''; + $this->tableAlias = ''; + $this->fromSubquery = null; return $this; } @@ -192,6 +230,17 @@ public function into(string $table): static return $this; } + /** + * Set an alias for the INSERT target table (e.g. INSERT INTO table AS alias). + * Used by PostgreSQL ON CONFLICT to reference the existing row. + */ + public function insertAs(string $alias): static + { + $this->insertAlias = $alias; + + return $this; + } + /** * @param array $row */ @@ -236,30 +285,48 @@ public function conflictSetRaw(string $column, string $expression, array $bindin return $this; } + /** + * Register a raw expression wrapper for a column in INSERT statements. + * + * The expression must contain exactly one `?` placeholder which will receive + * the column's value from each row. E.g. `ST_GeomFromText(?, 4326)`. + * + * @param list $extraBindings Additional bindings beyond the column value (e.g. SRID) + */ + public function insertColumnExpression(string $column, string $expression, array $extraBindings = []): static + { + $this->insertColumnExpressions[$column] = $expression; + if (! empty($extraBindings)) { + $this->insertColumnExpressionBindings[$column] = $extraBindings; + } + + return $this; + } + public function filterWhereIn(string $column, Builder $subquery): static { - $this->whereInSubqueries[] = ['column' => $column, 'subquery' => $subquery, 'not' => false]; + $this->whereInSubqueries[] = new WhereInSubquery($column, $subquery, false); return $this; } public function filterWhereNotIn(string $column, Builder $subquery): static { - $this->whereInSubqueries[] = ['column' => $column, 'subquery' => $subquery, 'not' => true]; + $this->whereInSubqueries[] = new WhereInSubquery($column, $subquery, true); return $this; } public function selectSub(Builder $subquery, string $alias): static { - $this->subSelects[] = ['subquery' => $subquery, 'alias' => $alias]; + $this->subSelects[] = new SubSelect($subquery, $alias); return $this; } public function fromSub(Builder $subquery, string $alias): static { - $this->fromSubquery = ['subquery' => $subquery, 'alias' => $alias]; + $this->fromSubquery = new SubSelect($subquery, $alias); $this->table = ''; return $this; @@ -270,7 +337,7 @@ public function fromSub(Builder $subquery, string $alias): static */ public function orderByRaw(string $expression, array $bindings = []): static { - $this->rawOrders[] = ['expression' => $expression, 'bindings' => $bindings]; + $this->rawOrders[] = new Condition($expression, $bindings); return $this; } @@ -280,7 +347,7 @@ public function orderByRaw(string $expression, array $bindings = []): static */ public function groupByRaw(string $expression, array $bindings = []): static { - $this->rawGroups[] = ['expression' => $expression, 'bindings' => $bindings]; + $this->rawGroups[] = new Condition($expression, $bindings); return $this; } @@ -290,7 +357,7 @@ public function groupByRaw(string $expression, array $bindings = []): static */ public function havingRaw(string $expression, array $bindings = []): static { - $this->rawHavings[] = ['expression' => $expression, 'bindings' => $bindings]; + $this->rawHavings[] = new Condition($expression, $bindings); return $this; } @@ -305,15 +372,15 @@ public function countDistinct(string $attribute, string $alias = ''): static /** * @param \Closure(JoinBuilder): void $callback */ - public function joinWhere(string $table, Closure $callback, string $type = 'JOIN', string $alias = ''): static + public function joinWhere(string $table, Closure $callback, JoinType $type = JoinType::Inner, string $alias = ''): static { $joinBuilder = new JoinBuilder(); $callback($joinBuilder); $method = match ($type) { - 'LEFT JOIN' => Method::LeftJoin, - 'RIGHT JOIN' => Method::RightJoin, - 'CROSS JOIN' => Method::CrossJoin, + JoinType::Left => Method::LeftJoin, + JoinType::Right => Method::RightJoin, + JoinType::Cross => Method::CrossJoin, default => Method::Join, }; @@ -336,14 +403,14 @@ public function joinWhere(string $table, Closure $callback, string $type = 'JOIN public function filterExists(Builder $subquery): static { - $this->existsSubqueries[] = ['subquery' => $subquery, 'not' => false]; + $this->existsSubqueries[] = new ExistsSubquery($subquery, false); return $this; } public function filterNotExists(Builder $subquery): static { - $this->existsSubqueries[] = ['subquery' => $subquery, 'not' => true]; + $this->existsSubqueries[] = new ExistsSubquery($subquery, true); return $this; } @@ -547,7 +614,7 @@ public function crossJoin(string $table, string $alias = ''): static public function union(self $other): static { $result = $other->build(); - $this->unions[] = new UnionClause('UNION', $result->query, $result->bindings); + $this->unions[] = new UnionClause(UnionType::Union, $result->query, $result->bindings); return $this; } @@ -555,7 +622,7 @@ public function union(self $other): static public function unionAll(self $other): static { $result = $other->build(); - $this->unions[] = new UnionClause('UNION ALL', $result->query, $result->bindings); + $this->unions[] = new UnionClause(UnionType::UnionAll, $result->query, $result->bindings); return $this; } @@ -563,7 +630,7 @@ public function unionAll(self $other): static public function intersect(self $other): static { $result = $other->build(); - $this->unions[] = new UnionClause('INTERSECT', $result->query, $result->bindings); + $this->unions[] = new UnionClause(UnionType::Intersect, $result->query, $result->bindings); return $this; } @@ -571,7 +638,7 @@ public function intersect(self $other): static public function intersectAll(self $other): static { $result = $other->build(); - $this->unions[] = new UnionClause('INTERSECT ALL', $result->query, $result->bindings); + $this->unions[] = new UnionClause(UnionType::IntersectAll, $result->query, $result->bindings); return $this; } @@ -579,7 +646,7 @@ public function intersectAll(self $other): static public function except(self $other): static { $result = $other->build(); - $this->unions[] = new UnionClause('EXCEPT', $result->query, $result->bindings); + $this->unions[] = new UnionClause(UnionType::Except, $result->query, $result->bindings); return $this; } @@ -587,7 +654,7 @@ public function except(self $other): static public function exceptAll(self $other): static { $result = $other->build(); - $this->unions[] = new UnionClause('EXCEPT ALL', $result->query, $result->bindings); + $this->unions[] = new UnionClause(UnionType::ExceptAll, $result->query, $result->bindings); return $this; } @@ -637,7 +704,7 @@ public function insertSelect(): BuildResult public function with(string $name, self $query): static { $result = $query->build(); - $this->ctes[] = ['name' => $name, 'query' => $result->query, 'bindings' => $result->bindings, 'recursive' => false]; + $this->ctes[] = new CteClause($name, $result->query, $result->bindings, false); return $this; } @@ -645,7 +712,7 @@ public function with(string $name, self $query): static public function withRecursive(string $name, self $query): static { $result = $query->build(); - $this->ctes[] = ['name' => $name, 'query' => $result->query, 'bindings' => $result->bindings, 'recursive' => true]; + $this->ctes[] = new CteClause($name, $result->query, $result->bindings, true); return $this; } @@ -655,33 +722,28 @@ public function withRecursive(string $name, self $query): static */ public function selectRaw(string $expression, array $bindings = []): static { - $this->rawSelects[] = ['expression' => $expression, 'bindings' => $bindings]; + $this->rawSelects[] = new Condition($expression, $bindings); return $this; } public function selectWindow(string $function, string $alias, ?array $partitionBy = null, ?array $orderBy = null): static { - $this->windowSelects[] = [ - 'function' => $function, - 'alias' => $alias, - 'partitionBy' => $partitionBy, - 'orderBy' => $orderBy, - ]; + $this->windowSelects[] = new WindowSelect($function, $alias, $partitionBy, $orderBy); return $this; } public function selectCase(CaseExpression $case): static { - $this->caseSelects[] = ['sql' => $case->sql, 'bindings' => $case->bindings]; + $this->caseSelects[] = $case; return $this; } public function setCase(string $column, CaseExpression $case): static { - $this->caseSets[$column] = ['sql' => $case->sql, 'bindings' => $case->bindings]; + $this->caseSets[$column] = $case; return $this; } @@ -748,13 +810,13 @@ public function build(): BuildResult $hasRecursive = false; $cteParts = []; foreach ($this->ctes as $cte) { - if ($cte['recursive']) { + if ($cte->recursive) { $hasRecursive = true; } - foreach ($cte['bindings'] as $binding) { + foreach ($cte->bindings as $binding) { $this->addBinding($binding); } - $cteParts[] = $this->quote($cte['name']) . ' AS (' . $cte['query'] . ')'; + $cteParts[] = $this->quote($cte->name) . ' AS (' . $cte->query . ')'; } $keyword = $hasRecursive ? 'WITH RECURSIVE' : 'WITH'; $ctePrefix = $keyword . ' ' . \implode(', ', $cteParts) . ' '; @@ -779,8 +841,8 @@ public function build(): BuildResult // Sub-selects foreach ($this->subSelects as $subSelect) { - $subResult = $subSelect['subquery']->build(); - $selectParts[] = '(' . $subResult->query . ') AS ' . $this->quote($subSelect['alias']); + $subResult = $subSelect->subquery->build(); + $selectParts[] = '(' . $subResult->query . ') AS ' . $this->quote($subSelect->alias); foreach ($subResult->bindings as $binding) { $this->addBinding($binding); } @@ -788,8 +850,8 @@ public function build(): BuildResult // Raw selects foreach ($this->rawSelects as $rawSelect) { - $selectParts[] = $rawSelect['expression']; - foreach ($rawSelect['bindings'] as $binding) { + $selectParts[] = $rawSelect->expression; + foreach ($rawSelect->bindings as $binding) { $this->addBinding($binding); } } @@ -798,17 +860,17 @@ public function build(): BuildResult foreach ($this->windowSelects as $win) { $overParts = []; - if ($win['partitionBy'] !== null && $win['partitionBy'] !== []) { + if ($win->partitionBy !== null && $win->partitionBy !== []) { $partCols = \array_map( fn (string $col): string => $this->resolveAndWrap($col), - $win['partitionBy'] + $win->partitionBy ); $overParts[] = 'PARTITION BY ' . \implode(', ', $partCols); } - if ($win['orderBy'] !== null && $win['orderBy'] !== []) { + if ($win->orderBy !== null && $win->orderBy !== []) { $orderCols = []; - foreach ($win['orderBy'] as $col) { + foreach ($win->orderBy as $col) { if (\str_starts_with($col, '-')) { $orderCols[] = $this->resolveAndWrap(\substr($col, 1)) . ' DESC'; } else { @@ -819,13 +881,13 @@ public function build(): BuildResult } $overClause = \implode(' ', $overParts); - $selectParts[] = $win['function'] . ' OVER (' . $overClause . ') AS ' . $this->quote($win['alias']); + $selectParts[] = $win->function . ' OVER (' . $overClause . ') AS ' . $this->quote($win->alias); } // CASE selects foreach ($this->caseSelects as $caseSelect) { - $selectParts[] = $caseSelect['sql']; - foreach ($caseSelect['bindings'] as $binding) { + $selectParts[] = $caseSelect->sql; + foreach ($caseSelect->bindings as $binding) { $this->addBinding($binding); } } @@ -836,7 +898,10 @@ public function build(): BuildResult $parts[] = $selectKeyword . ' ' . $selectSQL; // FROM - $parts[] = $this->buildTableClause(); + $tableClause = $this->buildTableClause(); + if ($tableClause !== '') { + $parts[] = $tableClause; + } // JOINS $joinFilterWhereClauses = []; @@ -861,13 +926,13 @@ public function build(): BuildResult $joinTable = $joinQuery->getAttribute(); $joinType = match ($joinQuery->getMethod()) { - Method::Join => 'JOIN', - Method::LeftJoin => 'LEFT JOIN', - Method::RightJoin => 'RIGHT JOIN', - Method::CrossJoin => 'CROSS JOIN', - default => 'JOIN', + Method::Join => JoinType::Inner, + Method::LeftJoin => JoinType::Left, + Method::RightJoin => JoinType::Right, + Method::CrossJoin => JoinType::Cross, + default => JoinType::Inner, }; - $isCrossJoin = $joinQuery->getMethod() === Method::CrossJoin; + $isCrossJoin = $joinType === JoinType::Cross; foreach ($this->joinFilterHooks as $hook) { $result = $hook->filterJoin($joinTable, $joinType); @@ -878,8 +943,8 @@ public function build(): BuildResult $placement = $this->resolveJoinFilterPlacement($result->placement, $isCrossJoin); if ($placement === Placement::On) { - $joinSQL .= ' AND ' . $result->condition->getExpression(); - foreach ($result->condition->getBindings() as $binding) { + $joinSQL .= ' AND ' . $result->condition->expression; + foreach ($result->condition->bindings as $binding) { $this->addBinding($binding); } } else { @@ -903,24 +968,24 @@ public function build(): BuildResult foreach ($this->filterHooks as $hook) { $condition = $hook->filter($this->table); - $whereClauses[] = $condition->getExpression(); - foreach ($condition->getBindings() as $binding) { + $whereClauses[] = $condition->expression; + foreach ($condition->bindings as $binding) { $this->addBinding($binding); } } foreach ($joinFilterWhereClauses as $condition) { - $whereClauses[] = $condition->getExpression(); - foreach ($condition->getBindings() as $binding) { + $whereClauses[] = $condition->expression; + foreach ($condition->bindings as $binding) { $this->addBinding($binding); } } // WHERE IN subqueries foreach ($this->whereInSubqueries as $sub) { - $subResult = $sub['subquery']->build(); - $prefix = $sub['not'] ? 'NOT IN' : 'IN'; - $whereClauses[] = $this->resolveAndWrap($sub['column']) . ' ' . $prefix . ' (' . $subResult->query . ')'; + $subResult = $sub->subquery->build(); + $prefix = $sub->not ? 'NOT IN' : 'IN'; + $whereClauses[] = $this->resolveAndWrap($sub->column) . ' ' . $prefix . ' (' . $subResult->query . ')'; foreach ($subResult->bindings as $binding) { $this->addBinding($binding); } @@ -928,8 +993,8 @@ public function build(): BuildResult // EXISTS subqueries foreach ($this->existsSubqueries as $sub) { - $subResult = $sub['subquery']->build(); - $prefix = $sub['not'] ? 'NOT EXISTS' : 'EXISTS'; + $subResult = $sub->subquery->build(); + $prefix = $sub->not ? 'NOT EXISTS' : 'EXISTS'; $whereClauses[] = $prefix . ' (' . $subResult->query . ')'; foreach ($subResult->bindings as $binding) { $this->addBinding($binding); @@ -961,8 +1026,8 @@ public function build(): BuildResult $groupByParts = $groupByCols; } foreach ($this->rawGroups as $rawGroup) { - $groupByParts[] = $rawGroup['expression']; - foreach ($rawGroup['bindings'] as $binding) { + $groupByParts[] = $rawGroup->expression; + foreach ($rawGroup->bindings as $binding) { $this->addBinding($binding); } } @@ -981,8 +1046,8 @@ public function build(): BuildResult } } foreach ($this->rawHavings as $rawHaving) { - $havingClauses[] = $rawHaving['expression']; - foreach ($rawHaving['bindings'] as $binding) { + $havingClauses[] = $rawHaving->expression; + foreach ($rawHaving->bindings as $binding) { $this->addBinding($binding); } } @@ -995,8 +1060,8 @@ public function build(): BuildResult $vectorOrderExpr = $this->compileVectorOrderExpr(); if ($vectorOrderExpr !== null) { - $orderClauses[] = $vectorOrderExpr['expression']; - foreach ($vectorOrderExpr['bindings'] as $binding) { + $orderClauses[] = $vectorOrderExpr->expression; + foreach ($vectorOrderExpr->bindings as $binding) { $this->addBinding($binding); } } @@ -1010,8 +1075,8 @@ public function build(): BuildResult $orderClauses[] = $this->compileOrder($orderQuery); } foreach ($this->rawOrders as $rawOrder) { - $orderClauses[] = $rawOrder['expression']; - foreach ($rawOrder['bindings'] as $binding) { + $orderClauses[] = $rawOrder->expression; + foreach ($rawOrder->bindings as $binding) { $this->addBinding($binding); } } @@ -1033,7 +1098,11 @@ public function build(): BuildResult // LOCKING if ($this->lockMode !== null) { - $parts[] = $this->lockMode; + $lockSql = $this->lockMode->toSql(); + if ($this->lockOfTable !== null) { + $lockSql .= ' OF ' . $this->quote($this->lockOfTable); + } + $parts[] = $lockSql; } $sql = \implode(' ', $parts); @@ -1043,7 +1112,7 @@ public function build(): BuildResult $sql = '(' . $sql . ')'; } foreach ($this->unions as $union) { - $sql .= ' ' . $union->type . ' (' . $union->query . ')'; + $sql .= ' ' . $union->type->value . ' (' . $union->query . ')'; foreach ($union->bindings as $binding) { $this->addBinding($binding); } @@ -1073,12 +1142,24 @@ protected function compileInsertBody(): array $placeholders = []; foreach ($columns as $col) { $bindings[] = $row[$col] ?? null; - $placeholders[] = '?'; + if (isset($this->insertColumnExpressions[$col])) { + $placeholders[] = $this->insertColumnExpressions[$col]; + foreach ($this->insertColumnExpressionBindings[$col] ?? [] as $extra) { + $bindings[] = $extra; + } + } else { + $placeholders[] = '?'; + } } $rowPlaceholders[] = '(' . \implode(', ', $placeholders) . ')'; } - $sql = 'INSERT INTO ' . $this->quote($this->table) + $tablePart = $this->quote($this->table); + if ($this->insertAlias !== '') { + $tablePart .= ' AS ' . $this->insertAlias; + } + + $sql = 'INSERT INTO ' . $tablePart . ' (' . \implode(', ', $wrappedColumns) . ')' . ' VALUES ' . \implode(', ', $rowPlaceholders); @@ -1120,8 +1201,8 @@ public function update(): BuildResult } foreach ($this->caseSets as $col => $caseData) { - $assignments[] = $this->resolveAndWrap($col) . ' = ' . $caseData['sql']; - foreach ($caseData['bindings'] as $binding) { + $assignments[] = $this->resolveAndWrap($col) . ' = ' . $caseData->sql; + foreach ($caseData->bindings as $binding) { $this->addBinding($binding); } } @@ -1167,17 +1248,17 @@ protected function compileWhereClauses(array &$parts): void foreach ($this->filterHooks as $hook) { $condition = $hook->filter($this->table); - $whereClauses[] = $condition->getExpression(); - foreach ($condition->getBindings() as $binding) { + $whereClauses[] = $condition->expression; + foreach ($condition->bindings as $binding) { $this->addBinding($binding); } } // WHERE IN subqueries foreach ($this->whereInSubqueries as $sub) { - $subResult = $sub['subquery']->build(); - $prefix = $sub['not'] ? 'NOT IN' : 'IN'; - $whereClauses[] = $this->resolveAndWrap($sub['column']) . ' ' . $prefix . ' (' . $subResult->query . ')'; + $subResult = $sub->subquery->build(); + $prefix = $sub->not ? 'NOT IN' : 'IN'; + $whereClauses[] = $this->resolveAndWrap($sub->column) . ' ' . $prefix . ' (' . $subResult->query . ')'; foreach ($subResult->bindings as $binding) { $this->addBinding($binding); } @@ -1185,8 +1266,8 @@ protected function compileWhereClauses(array &$parts): void // EXISTS subqueries foreach ($this->existsSubqueries as $sub) { - $subResult = $sub['subquery']->build(); - $prefix = $sub['not'] ? 'NOT EXISTS' : 'EXISTS'; + $subResult = $sub->subquery->build(); + $prefix = $sub->not ? 'NOT EXISTS' : 'EXISTS'; $whereClauses[] = $prefix . ' (' . $subResult->query . ')'; foreach ($subResult->bindings as $binding) { $this->addBinding($binding); @@ -1213,8 +1294,8 @@ protected function compileOrderAndLimit(array &$parts): void $orderClauses[] = $this->compileOrder($orderQuery); } foreach ($this->rawOrders as $rawOrder) { - $orderClauses[] = $rawOrder['expression']; - foreach ($rawOrder['bindings'] as $binding) { + $orderClauses[] = $rawOrder->expression; + foreach ($rawOrder->bindings as $binding) { $this->addBinding($binding); } } @@ -1236,16 +1317,17 @@ protected function shouldEmitOffset(?int $offset, ?int $limit): bool /** * Hook for subclasses to inject a vector distance ORDER BY expression. - * - * @return array{expression: string, bindings: list}|null */ - protected function compileVectorOrderExpr(): ?array + protected function compileVectorOrderExpr(): ?Condition { return null; } protected function validateTable(): void { + if ($this->noTable) { + return; + } if ($this->table === '' && $this->fromSubquery === null) { throw new ValidationException('No table specified. Call from() or into() before building a query.'); } @@ -1318,7 +1400,11 @@ public function reset(): static $this->conflictUpdateColumns = []; $this->conflictRawSets = []; $this->conflictRawSetBindings = []; + $this->insertColumnExpressions = []; + $this->insertColumnExpressionBindings = []; + $this->insertAlias = ''; $this->lockMode = null; + $this->lockOfTable = null; $this->insertSelectSource = null; $this->insertSelectColumns = []; $this->ctes = []; @@ -1329,6 +1415,7 @@ public function reset(): static $this->whereInSubqueries = []; $this->subSelects = []; $this->fromSubquery = null; + $this->noTable = false; $this->rawOrders = []; $this->rawGroups = []; $this->rawHavings = []; @@ -1553,15 +1640,15 @@ protected function compileJoinWithBuilder(Query $query, JoinBuilder $joinBuilder $onParts = []; - foreach ($joinBuilder->getOns() as $on) { - $left = $this->resolveAndWrap($on['left']); - $right = $this->resolveAndWrap($on['right']); - $onParts[] = $left . ' ' . $on['operator'] . ' ' . $right; + foreach ($joinBuilder->ons as $on) { + $left = $this->resolveAndWrap($on->left); + $right = $this->resolveAndWrap($on->right); + $onParts[] = $left . ' ' . $on->operator . ' ' . $right; } - foreach ($joinBuilder->getWheres() as $where) { - $onParts[] = $where['expression']; - foreach ($where['bindings'] as $binding) { + foreach ($joinBuilder->wheres as $where) { + $onParts[] = $where->expression; + foreach ($where->bindings as $binding) { $this->addBinding($binding); } } diff --git a/src/Query/Builder/Case/Builder.php b/src/Query/Builder/Case/Builder.php index 4e19bd4..9accf2a 100644 --- a/src/Query/Builder/Case/Builder.php +++ b/src/Query/Builder/Case/Builder.php @@ -6,7 +6,7 @@ class Builder { - /** @var list, resultBindings: list}> */ + /** @var list */ private array $whens = []; private ?string $elseResult = null; @@ -22,12 +22,7 @@ class Builder */ public function when(string $condition, string $result, array $conditionBindings = [], array $resultBindings = []): static { - $this->whens[] = [ - 'condition' => $condition, - 'result' => $result, - 'conditionBindings' => $conditionBindings, - 'resultBindings' => $resultBindings, - ]; + $this->whens[] = new WhenClause($condition, $result, $conditionBindings, $resultBindings); return $this; } @@ -67,11 +62,11 @@ public function build(): Expression $bindings = []; foreach ($this->whens as $when) { - $sql .= ' WHEN ' . $when['condition'] . ' THEN ' . $when['result']; - foreach ($when['conditionBindings'] as $binding) { + $sql .= ' WHEN ' . $when->condition . ' THEN ' . $when->result; + foreach ($when->conditionBindings as $binding) { $bindings[] = $binding; } - foreach ($when['resultBindings'] as $binding) { + foreach ($when->resultBindings as $binding) { $bindings[] = $binding; } } diff --git a/src/Query/Builder/Case/Expression.php b/src/Query/Builder/Case/Expression.php index 6625518..ecd8b51 100644 --- a/src/Query/Builder/Case/Expression.php +++ b/src/Query/Builder/Case/Expression.php @@ -13,11 +13,4 @@ public function __construct( ) { } - /** - * @return array{sql: string, bindings: list} - */ - public function toSql(): array - { - return ['sql' => $this->sql, 'bindings' => $this->bindings]; - } } diff --git a/src/Query/Builder/Case/WhenClause.php b/src/Query/Builder/Case/WhenClause.php new file mode 100644 index 0000000..1de49cf --- /dev/null +++ b/src/Query/Builder/Case/WhenClause.php @@ -0,0 +1,18 @@ + $conditionBindings + * @param list $resultBindings + */ + public function __construct( + public string $condition, + public string $result, + public array $conditionBindings, + public array $resultBindings, + ) { + } +} diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 84e6947..a5b0c0c 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -257,8 +257,8 @@ public function update(): BuildResult } foreach ($this->caseSets as $col => $caseData) { - $assignments[] = $this->resolveAndWrap($col) . ' = ' . $caseData['sql']; - foreach ($caseData['bindings'] as $binding) { + $assignments[] = $this->resolveAndWrap($col) . ' = ' . $caseData->sql; + foreach ($caseData->bindings as $binding) { $this->addBinding($binding); } } @@ -327,12 +327,12 @@ protected function buildTableClause(): string { $fromSub = $this->fromSubquery; if ($fromSub !== null) { - $subResult = $fromSub['subquery']->build(); + $subResult = $fromSub->subquery->build(); foreach ($subResult->bindings as $binding) { $this->addBinding($binding); } - return 'FROM (' . $subResult->query . ') AS ' . $this->quote($fromSub['alias']); + return 'FROM (' . $subResult->query . ') AS ' . $this->quote($fromSub->alias); } $sql = 'FROM ' . $this->quote($this->table); diff --git a/src/Query/Builder/Condition.php b/src/Query/Builder/Condition.php index 1028c1d..ec95211 100644 --- a/src/Query/Builder/Condition.php +++ b/src/Query/Builder/Condition.php @@ -13,14 +13,4 @@ public function __construct( ) { } - public function getExpression(): string - { - return $this->expression; - } - - /** @return list */ - public function getBindings(): array - { - return $this->bindings; - } } diff --git a/src/Query/Builder/CteClause.php b/src/Query/Builder/CteClause.php new file mode 100644 index 0000000..43265fa --- /dev/null +++ b/src/Query/Builder/CteClause.php @@ -0,0 +1,17 @@ + $bindings + */ + public function __construct( + public string $name, + public string $query, + public array $bindings, + public bool $recursive, + ) { + } +} diff --git a/src/Query/Builder/ExistsSubquery.php b/src/Query/Builder/ExistsSubquery.php new file mode 100644 index 0000000..ffd040d --- /dev/null +++ b/src/Query/Builder/ExistsSubquery.php @@ -0,0 +1,14 @@ + $vector The query vector - * @param string $metric Distance metric: 'cosine', 'euclidean', 'dot' */ - public function orderByVectorDistance(string $attribute, array $vector, string $metric = 'cosine'): static; + public function orderByVectorDistance(string $attribute, array $vector, VectorMetric $metric = VectorMetric::Cosine): static; } diff --git a/src/Query/Builder/JoinBuilder.php b/src/Query/Builder/JoinBuilder.php index b1c27c9..7f15e68 100644 --- a/src/Query/Builder/JoinBuilder.php +++ b/src/Query/Builder/JoinBuilder.php @@ -8,11 +8,11 @@ class JoinBuilder { private const ALLOWED_OPERATORS = ['=', '!=', '<', '>', '<=', '>=', '<>']; - /** @var list */ - private array $ons = []; + /** @var list */ + public private(set) array $ons = []; - /** @var list}> */ - private array $wheres = []; + /** @var list */ + public private(set) array $wheres = []; /** * Add an ON condition to the join. @@ -26,7 +26,7 @@ public function on(string $left, string $right, string $operator = '='): static throw new ValidationException('Invalid join operator: ' . $operator); } - $this->ons[] = ['left' => $left, 'operator' => $operator, 'right' => $right]; + $this->ons[] = new JoinOn($left, $operator, $right); return $this; } @@ -36,7 +36,7 @@ public function on(string $left, string $right, string $operator = '='): static */ public function onRaw(string $expression, array $bindings = []): static { - $this->wheres[] = ['expression' => $expression, 'bindings' => $bindings]; + $this->wheres[] = new Condition($expression, $bindings); return $this; } @@ -57,7 +57,7 @@ public function where(string $column, string $operator, mixed $value): static throw new ValidationException('Invalid join operator: ' . $operator); } - $this->wheres[] = ['expression' => $column . ' ' . $operator . ' ?', 'bindings' => [$value]]; + $this->wheres[] = new Condition($column . ' ' . $operator . ' ?', [$value]); return $this; } @@ -67,20 +67,9 @@ public function where(string $column, string $operator, mixed $value): static */ public function whereRaw(string $expression, array $bindings = []): static { - $this->wheres[] = ['expression' => $expression, 'bindings' => $bindings]; + $this->wheres[] = new Condition($expression, $bindings); return $this; } - /** @return list */ - public function getOns(): array - { - return $this->ons; - } - - /** @return list}> */ - public function getWheres(): array - { - return $this->wheres; - } } diff --git a/src/Query/Builder/JoinOn.php b/src/Query/Builder/JoinOn.php new file mode 100644 index 0000000..ca4c14d --- /dev/null +++ b/src/Query/Builder/JoinOn.php @@ -0,0 +1,13 @@ +value; + } +} diff --git a/src/Query/Builder/MySQL.php b/src/Query/Builder/MySQL.php index 432fc10..11ceb04 100644 --- a/src/Query/Builder/MySQL.php +++ b/src/Query/Builder/MySQL.php @@ -14,7 +14,7 @@ class MySQL extends SQL implements Spatial, Json, Hints /** @var list */ protected array $hints = []; - /** @var array}> */ + /** @var array */ protected array $jsonSets = []; protected function compileRandom(): string @@ -194,40 +194,40 @@ public function filterJsonPath(string $attribute, string $path, string $operator public function setJsonAppend(string $column, array $values): static { - $this->jsonSets[$column] = [ - 'expression' => 'JSON_MERGE_PRESERVE(IFNULL(' . $this->resolveAndWrap($column) . ', JSON_ARRAY()), ?)', - 'bindings' => [\json_encode($values)], - ]; + $this->jsonSets[$column] = new Condition( + 'JSON_MERGE_PRESERVE(IFNULL(' . $this->resolveAndWrap($column) . ', JSON_ARRAY()), ?)', + [\json_encode($values)], + ); return $this; } public function setJsonPrepend(string $column, array $values): static { - $this->jsonSets[$column] = [ - 'expression' => 'JSON_MERGE_PRESERVE(?, IFNULL(' . $this->resolveAndWrap($column) . ', JSON_ARRAY()))', - 'bindings' => [\json_encode($values)], - ]; + $this->jsonSets[$column] = new Condition( + 'JSON_MERGE_PRESERVE(?, IFNULL(' . $this->resolveAndWrap($column) . ', JSON_ARRAY()))', + [\json_encode($values)], + ); return $this; } public function setJsonInsert(string $column, int $index, mixed $value): static { - $this->jsonSets[$column] = [ - 'expression' => 'JSON_ARRAY_INSERT(' . $this->resolveAndWrap($column) . ', ?, ?)', - 'bindings' => ['$[' . $index . ']', $value], - ]; + $this->jsonSets[$column] = new Condition( + 'JSON_ARRAY_INSERT(' . $this->resolveAndWrap($column) . ', ?, ?)', + ['$[' . $index . ']', $value], + ); return $this; } public function setJsonRemove(string $column, mixed $value): static { - $this->jsonSets[$column] = [ - 'expression' => 'JSON_REMOVE(' . $this->resolveAndWrap($column) . ', JSON_UNQUOTE(JSON_SEARCH(' . $this->resolveAndWrap($column) . ', \'one\', ?)))', - 'bindings' => [$value], - ]; + $this->jsonSets[$column] = new Condition( + 'JSON_REMOVE(' . $this->resolveAndWrap($column) . ', JSON_UNQUOTE(JSON_SEARCH(' . $this->resolveAndWrap($column) . ', \'one\', ?)))', + [$value], + ); return $this; } @@ -316,8 +316,8 @@ public function build(): BuildResult public function update(): BuildResult { // Apply JSON sets as rawSets before calling parent - foreach ($this->jsonSets as $col => $data) { - $this->setRaw($col, $data['expression'], $data['bindings']); + foreach ($this->jsonSets as $col => $condition) { + $this->setRaw($col, $condition->expression, $condition->bindings); } $result = parent::update(); diff --git a/src/Query/Builder/PostgreSQL.php b/src/Query/Builder/PostgreSQL.php index 06bf16a..0213ed5 100644 --- a/src/Query/Builder/PostgreSQL.php +++ b/src/Query/Builder/PostgreSQL.php @@ -18,10 +18,10 @@ class PostgreSQL extends SQL implements Spatial, VectorSearch, Json, Returning, /** @var list */ protected array $returningColumns = []; - /** @var array}> */ + /** @var array */ protected array $jsonSets = []; - /** @var ?array{attribute: string, vector: array, metric: string} */ + /** @var ?array{attribute: string, vector: array, metric: VectorMetric} */ protected ?array $vectorOrder = null; protected function compileRandom(): string @@ -93,14 +93,16 @@ public function returning(array $columns = ['*']): static public function forUpdateOf(string $table): static { - $this->lockMode = 'FOR UPDATE OF ' . $this->quote($table); + $this->lockMode = LockMode::ForUpdate; + $this->lockOfTable = $table; return $this; } public function forShareOf(string $table): static { - $this->lockMode = 'FOR SHARE OF ' . $this->quote($table); + $this->lockMode = LockMode::ForShare; + $this->lockOfTable = $table; return $this; } @@ -127,8 +129,8 @@ public function insert(): BuildResult public function update(): BuildResult { - foreach ($this->jsonSets as $col => $data) { - $this->setRaw($col, $data['expression'], $data['bindings']); + foreach ($this->jsonSets as $col => $condition) { + $this->setRaw($col, $condition->expression, $condition->bindings); } $result = parent::update(); @@ -268,7 +270,7 @@ public function filterNotSpatialEquals(string $attribute, array $geometry): stat return $this; } - public function orderByVectorDistance(string $attribute, array $vector, string $metric = 'cosine'): static + public function orderByVectorDistance(string $attribute, array $vector, VectorMetric $metric = VectorMetric::Cosine): static { $this->vectorOrder = [ 'attribute' => $attribute, @@ -309,40 +311,40 @@ public function filterJsonPath(string $attribute, string $path, string $operator public function setJsonAppend(string $column, array $values): static { - $this->jsonSets[$column] = [ - 'expression' => 'COALESCE(' . $this->resolveAndWrap($column) . ', \'[]\'::jsonb) || ?::jsonb', - 'bindings' => [\json_encode($values)], - ]; + $this->jsonSets[$column] = new Condition( + 'COALESCE(' . $this->resolveAndWrap($column) . ', \'[]\'::jsonb) || ?::jsonb', + [\json_encode($values)], + ); return $this; } public function setJsonPrepend(string $column, array $values): static { - $this->jsonSets[$column] = [ - 'expression' => '?::jsonb || COALESCE(' . $this->resolveAndWrap($column) . ', \'[]\'::jsonb)', - 'bindings' => [\json_encode($values)], - ]; + $this->jsonSets[$column] = new Condition( + '?::jsonb || COALESCE(' . $this->resolveAndWrap($column) . ', \'[]\'::jsonb)', + [\json_encode($values)], + ); return $this; } public function setJsonInsert(string $column, int $index, mixed $value): static { - $this->jsonSets[$column] = [ - 'expression' => 'jsonb_insert(' . $this->resolveAndWrap($column) . ', \'{' . $index . '}\', ?::jsonb)', - 'bindings' => [\json_encode($value)], - ]; + $this->jsonSets[$column] = new Condition( + 'jsonb_insert(' . $this->resolveAndWrap($column) . ', \'{' . $index . '}\', ?::jsonb)', + [\json_encode($value)], + ); return $this; } public function setJsonRemove(string $column, mixed $value): static { - $this->jsonSets[$column] = [ - 'expression' => $this->resolveAndWrap($column) . ' - ?', - 'bindings' => [\json_encode($value)], - ]; + $this->jsonSets[$column] = new Condition( + $this->resolveAndWrap($column) . ' - ?', + [\json_encode($value)], + ); return $this; } @@ -388,28 +390,20 @@ public function compileFilter(Query $query): string return parent::compileFilter($query); } - /** - * @return array{expression: string, bindings: list}|null - */ - protected function compileVectorOrderExpr(): ?array + protected function compileVectorOrderExpr(): ?Condition { if ($this->vectorOrder === null) { return null; } $attr = $this->resolveAndWrap($this->vectorOrder['attribute']); - $operator = match ($this->vectorOrder['metric']) { - 'cosine' => '<=>', - 'euclidean' => '<->', - 'dot' => '<#>', - default => '<=>', - }; + $operator = $this->vectorOrder['metric']->toOperator(); $vectorJson = \json_encode($this->vectorOrder['vector']); - return [ - 'expression' => '(' . $attr . ' ' . $operator . ' ?::vector) ASC', - 'bindings' => [$vectorJson], - ]; + return new Condition( + '(' . $attr . ' ' . $operator . ' ?::vector) ASC', + [$vectorJson], + ); } public function reset(): static diff --git a/src/Query/Builder/SQL.php b/src/Query/Builder/SQL.php index bb48f2f..5786f4d 100644 --- a/src/Query/Builder/SQL.php +++ b/src/Query/Builder/SQL.php @@ -15,42 +15,42 @@ abstract class SQL extends BaseBuilder implements Locking, Transactions, Upsert public function forUpdate(): static { - $this->lockMode = 'FOR UPDATE'; + $this->lockMode = LockMode::ForUpdate; return $this; } public function forShare(): static { - $this->lockMode = 'FOR SHARE'; + $this->lockMode = LockMode::ForShare; return $this; } public function forUpdateSkipLocked(): static { - $this->lockMode = 'FOR UPDATE SKIP LOCKED'; + $this->lockMode = LockMode::ForUpdateSkipLocked; return $this; } public function forUpdateNoWait(): static { - $this->lockMode = 'FOR UPDATE NOWAIT'; + $this->lockMode = LockMode::ForUpdateNoWait; return $this; } public function forShareSkipLocked(): static { - $this->lockMode = 'FOR SHARE SKIP LOCKED'; + $this->lockMode = LockMode::ForShareSkipLocked; return $this; } public function forShareNoWait(): static { - $this->lockMode = 'FOR SHARE NOWAIT'; + $this->lockMode = LockMode::ForShareNoWait; return $this; } @@ -116,12 +116,24 @@ public function upsert(): BuildResult $placeholders = []; foreach ($columns as $col) { $this->addBinding($row[$col] ?? null); - $placeholders[] = '?'; + if (isset($this->insertColumnExpressions[$col])) { + $placeholders[] = $this->insertColumnExpressions[$col]; + foreach ($this->insertColumnExpressionBindings[$col] ?? [] as $extra) { + $this->addBinding($extra); + } + } else { + $placeholders[] = '?'; + } } $rowPlaceholders[] = '(' . \implode(', ', $placeholders) . ')'; } - $sql = 'INSERT INTO ' . $this->quote($this->table) + $tablePart = $this->quote($this->table); + if ($this->insertAlias !== '') { + $tablePart .= ' AS ' . $this->insertAlias; + } + + $sql = 'INSERT INTO ' . $tablePart . ' (' . \implode(', ', $wrappedColumns) . ')' . ' VALUES ' . \implode(', ', $rowPlaceholders); diff --git a/src/Query/Builder/SubSelect.php b/src/Query/Builder/SubSelect.php new file mode 100644 index 0000000..3a01b80 --- /dev/null +++ b/src/Query/Builder/SubSelect.php @@ -0,0 +1,14 @@ + $bindings */ public function __construct( - public string $type, + public UnionType $type, public string $query, public array $bindings, ) { diff --git a/src/Query/Builder/UnionType.php b/src/Query/Builder/UnionType.php new file mode 100644 index 0000000..3172d37 --- /dev/null +++ b/src/Query/Builder/UnionType.php @@ -0,0 +1,13 @@ + '<=>', + self::Euclidean => '<->', + self::Dot => '<#>', + }; + } +} diff --git a/src/Query/Builder/WhereInSubquery.php b/src/Query/Builder/WhereInSubquery.php new file mode 100644 index 0000000..9ba63a6 --- /dev/null +++ b/src/Query/Builder/WhereInSubquery.php @@ -0,0 +1,15 @@ + $partitionBy + * @param ?list $orderBy + */ + public function __construct( + public string $function, + public string $alias, + public ?array $partitionBy, + public ?array $orderBy, + ) { + } +} diff --git a/src/Query/Hook/Filter/Permission.php b/src/Query/Hook/Filter/Permission.php index 288533a..840c7b9 100644 --- a/src/Query/Hook/Filter/Permission.php +++ b/src/Query/Hook/Filter/Permission.php @@ -3,6 +3,7 @@ namespace Utopia\Query\Hook\Filter; use Utopia\Query\Builder\Condition; +use Utopia\Query\Builder\JoinType; use Utopia\Query\Hook\Filter; use Utopia\Query\Hook\Join\Condition as JoinCondition; use Utopia\Query\Hook\Join\Filter as JoinFilter; @@ -69,8 +70,8 @@ public function filter(string $table): Condition $subFilterBindings = []; if ($this->subqueryFilter !== null) { $subCondition = $this->subqueryFilter->filter($permTable); - $subFilterClause = ' AND ' . $subCondition->getExpression(); - $subFilterBindings = $subCondition->getBindings(); + $subFilterClause = ' AND ' . $subCondition->expression; + $subFilterBindings = $subCondition->bindings; } return new Condition( @@ -79,12 +80,12 @@ public function filter(string $table): Condition ); } - public function filterJoin(string $table, string $joinType): ?JoinCondition + public function filterJoin(string $table, JoinType $joinType): ?JoinCondition { $condition = $this->filter($table); $placement = match ($joinType) { - 'LEFT JOIN', 'RIGHT JOIN' => Placement::On, + JoinType::Left, JoinType::Right => Placement::On, default => Placement::Where, }; diff --git a/src/Query/Hook/Filter/Tenant.php b/src/Query/Hook/Filter/Tenant.php index fc65856..d806b78 100644 --- a/src/Query/Hook/Filter/Tenant.php +++ b/src/Query/Hook/Filter/Tenant.php @@ -3,6 +3,7 @@ namespace Utopia\Query\Hook\Filter; use Utopia\Query\Builder\Condition; +use Utopia\Query\Builder\JoinType; use Utopia\Query\Hook\Filter; use Utopia\Query\Hook\Join\Condition as JoinCondition; use Utopia\Query\Hook\Join\Filter as JoinFilter; @@ -36,12 +37,12 @@ public function filter(string $table): Condition ); } - public function filterJoin(string $table, string $joinType): ?JoinCondition + public function filterJoin(string $table, JoinType $joinType): ?JoinCondition { $condition = $this->filter($table); $placement = match ($joinType) { - 'LEFT JOIN', 'RIGHT JOIN' => Placement::On, + JoinType::Left, JoinType::Right => Placement::On, default => Placement::Where, }; diff --git a/src/Query/Hook/Join/Filter.php b/src/Query/Hook/Join/Filter.php index b340643..690355d 100644 --- a/src/Query/Hook/Join/Filter.php +++ b/src/Query/Hook/Join/Filter.php @@ -2,9 +2,10 @@ namespace Utopia\Query\Hook\Join; +use Utopia\Query\Builder\JoinType; use Utopia\Query\Hook; interface Filter extends Hook { - public function filterJoin(string $table, string $joinType): ?Condition; + public function filterJoin(string $table, JoinType $joinType): ?Condition; } diff --git a/src/Query/Query.php b/src/Query/Query.php index 9f09de3..033ee44 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -335,10 +335,9 @@ public static function greaterThanEqual(string $attribute, string|int|float|bool /** * Helper method to create Query with contains method * - * @deprecated Use containsAny() for array attributes, or keep using contains() for string substring matching. - * * @param array $values */ + #[\Deprecated('Use containsAny() for array attributes, or keep using contains() for string substring matching.')] public static function contains(string $attribute, array $values): static { return new static(Method::Contains, $attribute, $values); @@ -1210,16 +1209,7 @@ public static function diff(array $queriesA, array $queriesB): array $result = []; foreach ($queriesA as $queryA) { $aArray = $queryA->toArray(); - $found = false; - - foreach ($bArrays as $bArray) { - if ($aArray === $bArray) { - $found = true; - break; - } - } - - if (! $found) { + if (! array_any($bArrays, fn (array $b): bool => $aArray === $b)) { $result[] = $queryA; } } From 6d39bd048fb3f7e852fe077f1f3ba1342be47b0e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 11 Mar 2026 00:06:07 +1300 Subject: [PATCH 25/29] (refactor): Refine schema APIs with enums and strict types --- src/Query/Schema.php | 156 ++++++++++++++---- src/Query/Schema/Blueprint.php | 209 +++++++++++++++--------- src/Query/Schema/ClickHouse.php | 54 +++--- src/Query/Schema/Column.php | 11 +- src/Query/Schema/ColumnType.php | 24 +++ src/Query/Schema/ForeignKey.php | 20 +-- src/Query/Schema/ForeignKeyAction.php | 12 ++ src/Query/Schema/Index.php | 11 +- src/Query/Schema/IndexType.php | 11 ++ src/Query/Schema/MySQL.php | 66 ++++++-- src/Query/Schema/ParameterDirection.php | 10 ++ src/Query/Schema/PostgreSQL.php | 181 +++++++++++++------- src/Query/Schema/RenameColumn.php | 12 ++ src/Query/Schema/SQL.php | 77 +++++---- src/Query/Schema/TriggerEvent.php | 10 ++ src/Query/Schema/TriggerTiming.php | 10 ++ 16 files changed, 617 insertions(+), 257 deletions(-) create mode 100644 src/Query/Schema/ColumnType.php create mode 100644 src/Query/Schema/ForeignKeyAction.php create mode 100644 src/Query/Schema/IndexType.php create mode 100644 src/Query/Schema/ParameterDirection.php create mode 100644 src/Query/Schema/RenameColumn.php create mode 100644 src/Query/Schema/TriggerEvent.php create mode 100644 src/Query/Schema/TriggerTiming.php diff --git a/src/Query/Schema.php b/src/Query/Schema.php index ded1f42..d60892f 100644 --- a/src/Query/Schema.php +++ b/src/Query/Schema.php @@ -5,6 +5,7 @@ use Utopia\Query\Builder\BuildResult; use Utopia\Query\Schema\Blueprint; use Utopia\Query\Schema\Column; +use Utopia\Query\Schema\IndexType; abstract class Schema { @@ -26,7 +27,7 @@ public function create(string $table, callable $definition): BuildResult $primaryKeys = []; $uniqueColumns = []; - foreach ($blueprint->getColumns() as $column) { + foreach ($blueprint->columns as $column) { $def = $this->compileColumnDefinition($column); $columnDefs[] = $def; @@ -38,6 +39,11 @@ public function create(string $table, callable $definition): BuildResult } } + // Raw column definitions (bypass typed Column objects) + foreach ($blueprint->rawColumnDefs as $rawDef) { + $columnDefs[] = $rawDef; + } + // Inline PRIMARY KEY constraint if (! empty($primaryKeys)) { $columnDefs[] = 'PRIMARY KEY (' . \implode(', ', $primaryKeys) . ')'; @@ -49,23 +55,32 @@ public function create(string $table, callable $definition): BuildResult } // Indexes - foreach ($blueprint->getIndexes() as $index) { - $cols = \array_map(fn (string $c): string => $this->quote($c), $index->columns); - $keyword = $index->type === 'unique' ? 'UNIQUE INDEX' : 'INDEX'; + foreach ($blueprint->indexes as $index) { + $keyword = match ($index->type) { + IndexType::Unique => 'UNIQUE INDEX', + IndexType::Fulltext => 'FULLTEXT INDEX', + IndexType::Spatial => 'SPATIAL INDEX', + default => 'INDEX', + }; $columnDefs[] = $keyword . ' ' . $this->quote($index->name) - . ' (' . \implode(', ', $cols) . ')'; + . ' (' . $this->compileIndexColumns($index) . ')'; + } + + // Raw index definitions (bypass typed Index objects) + foreach ($blueprint->rawIndexDefs as $rawIdx) { + $columnDefs[] = $rawIdx; } // Foreign keys - foreach ($blueprint->getForeignKeys() as $fk) { + foreach ($blueprint->foreignKeys as $fk) { $def = 'FOREIGN KEY (' . $this->quote($fk->column) . ')' . ' REFERENCES ' . $this->quote($fk->refTable) . ' (' . $this->quote($fk->refColumn) . ')'; - if ($fk->onDelete !== '') { - $def .= ' ON DELETE ' . $fk->onDelete; + if ($fk->onDelete !== null) { + $def .= ' ON DELETE ' . $fk->onDelete->value; } - if ($fk->onUpdate !== '') { - $def .= ' ON UPDATE ' . $fk->onUpdate; + if ($fk->onUpdate !== null) { + $def .= ' ON UPDATE ' . $fk->onUpdate->value; } $columnDefs[] = $def; } @@ -86,7 +101,7 @@ public function alter(string $table, callable $definition): BuildResult $alterations = []; - foreach ($blueprint->getColumns() as $column) { + foreach ($blueprint->columns as $column) { $keyword = $column->isModify ? 'MODIFY COLUMN' : 'ADD COLUMN'; $def = $keyword . ' ' . $this->compileColumnDefinition($column); if ($column->after !== null) { @@ -95,39 +110,44 @@ public function alter(string $table, callable $definition): BuildResult $alterations[] = $def; } - foreach ($blueprint->getRenameColumns() as $rename) { - $alterations[] = 'RENAME COLUMN ' . $this->quote($rename['from']) - . ' TO ' . $this->quote($rename['to']); + foreach ($blueprint->renameColumns as $rename) { + $alterations[] = 'RENAME COLUMN ' . $this->quote($rename->from) + . ' TO ' . $this->quote($rename->to); } - foreach ($blueprint->getDropColumns() as $col) { + foreach ($blueprint->dropColumns as $col) { $alterations[] = 'DROP COLUMN ' . $this->quote($col); } - foreach ($blueprint->getIndexes() as $index) { - $cols = \array_map(fn (string $c): string => $this->quote($c), $index->columns); - $alterations[] = 'ADD INDEX ' . $this->quote($index->name) - . ' (' . \implode(', ', $cols) . ')'; + foreach ($blueprint->indexes as $index) { + $keyword = match ($index->type) { + IndexType::Unique => 'ADD UNIQUE INDEX', + IndexType::Fulltext => 'ADD FULLTEXT INDEX', + IndexType::Spatial => 'ADD SPATIAL INDEX', + default => 'ADD INDEX', + }; + $alterations[] = $keyword . ' ' . $this->quote($index->name) + . ' (' . $this->compileIndexColumns($index) . ')'; } - foreach ($blueprint->getDropIndexes() as $name) { + foreach ($blueprint->dropIndexes as $name) { $alterations[] = 'DROP INDEX ' . $this->quote($name); } - foreach ($blueprint->getForeignKeys() as $fk) { + foreach ($blueprint->foreignKeys as $fk) { $def = 'ADD FOREIGN KEY (' . $this->quote($fk->column) . ')' . ' REFERENCES ' . $this->quote($fk->refTable) . ' (' . $this->quote($fk->refColumn) . ')'; - if ($fk->onDelete !== '') { - $def .= ' ON DELETE ' . $fk->onDelete; + if ($fk->onDelete !== null) { + $def .= ' ON DELETE ' . $fk->onDelete->value; } - if ($fk->onUpdate !== '') { - $def .= ' ON UPDATE ' . $fk->onUpdate; + if ($fk->onUpdate !== null) { + $def .= ' ON UPDATE ' . $fk->onUpdate->value; } $alterations[] = $def; } - foreach ($blueprint->getDropForeignKeys() as $name) { + foreach ($blueprint->dropForeignKeys as $name) { $alterations[] = 'DROP FOREIGN KEY ' . $this->quote($name); } @@ -162,6 +182,10 @@ public function truncate(string $table): BuildResult /** * @param string[] $columns + * @param array $lengths + * @param array $orders + * @param array $collations + * @param list $rawColumns Raw SQL expressions appended to column list (bypass quoting) */ public function createIndex( string $table, @@ -169,9 +193,13 @@ public function createIndex( array $columns, bool $unique = false, string $type = '', + string $method = '', + string $operatorClass = '', + array $lengths = [], + array $orders = [], + array $collations = [], + array $rawColumns = [], ): BuildResult { - $cols = \array_map(fn (string $c): string => $this->quote($c), $columns); - $keyword = match (true) { $unique => 'CREATE UNIQUE INDEX', $type === 'fulltext' => 'CREATE FULLTEXT INDEX', @@ -179,9 +207,17 @@ public function createIndex( default => 'CREATE INDEX', }; + $indexType = $unique ? IndexType::Unique : ($type !== '' ? IndexType::from($type) : IndexType::Index); + $index = new Schema\Index($name, $columns, $indexType, $lengths, $orders, $method, $operatorClass, $collations, $rawColumns); + $sql = $keyword . ' ' . $this->quote($name) - . ' ON ' . $this->quote($table) - . ' (' . \implode(', ', $cols) . ')'; + . ' ON ' . $this->quote($table); + + if ($method !== '') { + $sql .= ' USING ' . \strtoupper($method); + } + + $sql .= ' (' . $this->compileIndexColumns($index) . ')'; return new BuildResult($sql, []); } @@ -270,4 +306,64 @@ protected function compileUnsigned(): string { return 'UNSIGNED'; } + + /** + * Compile index column list with lengths, orders, collations, and operator classes. + */ + protected function compileIndexColumns(Schema\Index $index): string + { + $parts = []; + + foreach ($index->columns as $col) { + $part = $this->quote($col); + + if (isset($index->collations[$col])) { + $part .= ' COLLATE ' . $index->collations[$col]; + } + + if (isset($index->lengths[$col])) { + $part .= '(' . $index->lengths[$col] . ')'; + } + + if ($index->operatorClass !== '') { + $part .= ' ' . $index->operatorClass; + } + + if (isset($index->orders[$col])) { + $part .= ' ' . \strtoupper($index->orders[$col]); + } + + $parts[] = $part; + } + + // Append raw expressions (bypass quoting) — for CAST ARRAY, JSONB paths, etc. + foreach ($index->rawColumns as $raw) { + $parts[] = $raw; + } + + return \implode(', ', $parts); + } + + public function renameIndex(string $table, string $from, string $to): BuildResult + { + return new BuildResult( + 'ALTER TABLE ' . $this->quote($table) . ' RENAME INDEX ' . $this->quote($from) . ' TO ' . $this->quote($to), + [] + ); + } + + public function createDatabase(string $name): BuildResult + { + return new BuildResult('CREATE DATABASE ' . $this->quote($name), []); + } + + public function dropDatabase(string $name): BuildResult + { + return new BuildResult('DROP DATABASE ' . $this->quote($name), []); + } + + public function analyzeTable(string $table): BuildResult + { + return new BuildResult('ANALYZE TABLE ' . $this->quote($table), []); + } } diff --git a/src/Query/Schema/Blueprint.php b/src/Query/Schema/Blueprint.php index 7057905..c15d2f6 100644 --- a/src/Query/Schema/Blueprint.php +++ b/src/Query/Schema/Blueprint.php @@ -5,29 +5,35 @@ class Blueprint { /** @var list */ - private array $columns = []; + public private(set) array $columns = []; /** @var list */ - private array $indexes = []; + public private(set) array $indexes = []; /** @var list */ - private array $foreignKeys = []; + public private(set) array $foreignKeys = []; /** @var list */ - private array $dropColumns = []; + public private(set) array $dropColumns = []; - /** @var list */ - private array $renameColumns = []; + /** @var list */ + public private(set) array $renameColumns = []; /** @var list */ - private array $dropIndexes = []; + public private(set) array $dropIndexes = []; /** @var list */ - private array $dropForeignKeys = []; + public private(set) array $dropForeignKeys = []; + + /** @var list Raw SQL column definitions (bypass typed Column objects) */ + public private(set) array $rawColumnDefs = []; + + /** @var list Raw SQL index definitions (bypass typed Index objects) */ + public private(set) array $rawIndexDefs = []; public function id(string $name = 'id'): Column { - $col = new Column($name, 'bigInteger'); + $col = new Column($name, ColumnType::BigInteger); $col->isUnsigned = true; $col->isAutoIncrement = true; $col->isPrimary = true; @@ -38,7 +44,7 @@ public function id(string $name = 'id'): Column public function string(string $name, int $length = 255): Column { - $col = new Column($name, 'string', $length); + $col = new Column($name, ColumnType::String, $length); $this->columns[] = $col; return $col; @@ -46,7 +52,23 @@ public function string(string $name, int $length = 255): Column public function text(string $name): Column { - $col = new Column($name, 'text'); + $col = new Column($name, ColumnType::Text); + $this->columns[] = $col; + + return $col; + } + + public function mediumText(string $name): Column + { + $col = new Column($name, ColumnType::MediumText); + $this->columns[] = $col; + + return $col; + } + + public function longText(string $name): Column + { + $col = new Column($name, ColumnType::LongText); $this->columns[] = $col; return $col; @@ -54,7 +76,7 @@ public function text(string $name): Column public function integer(string $name): Column { - $col = new Column($name, 'integer'); + $col = new Column($name, ColumnType::Integer); $this->columns[] = $col; return $col; @@ -62,7 +84,7 @@ public function integer(string $name): Column public function bigInteger(string $name): Column { - $col = new Column($name, 'bigInteger'); + $col = new Column($name, ColumnType::BigInteger); $this->columns[] = $col; return $col; @@ -70,7 +92,7 @@ public function bigInteger(string $name): Column public function float(string $name): Column { - $col = new Column($name, 'float'); + $col = new Column($name, ColumnType::Float); $this->columns[] = $col; return $col; @@ -78,7 +100,7 @@ public function float(string $name): Column public function boolean(string $name): Column { - $col = new Column($name, 'boolean'); + $col = new Column($name, ColumnType::Boolean); $this->columns[] = $col; return $col; @@ -86,7 +108,7 @@ public function boolean(string $name): Column public function datetime(string $name, int $precision = 0): Column { - $col = new Column($name, 'datetime', precision: $precision); + $col = new Column($name, ColumnType::Datetime, precision: $precision); $this->columns[] = $col; return $col; @@ -94,7 +116,7 @@ public function datetime(string $name, int $precision = 0): Column public function timestamp(string $name, int $precision = 0): Column { - $col = new Column($name, 'timestamp', precision: $precision); + $col = new Column($name, ColumnType::Timestamp, precision: $precision); $this->columns[] = $col; return $col; @@ -102,7 +124,7 @@ public function timestamp(string $name, int $precision = 0): Column public function json(string $name): Column { - $col = new Column($name, 'json'); + $col = new Column($name, ColumnType::Json); $this->columns[] = $col; return $col; @@ -110,7 +132,7 @@ public function json(string $name): Column public function binary(string $name): Column { - $col = new Column($name, 'binary'); + $col = new Column($name, ColumnType::Binary); $this->columns[] = $col; return $col; @@ -121,7 +143,7 @@ public function binary(string $name): Column */ public function enum(string $name, array $values): Column { - $col = new Column($name, 'enum'); + $col = new Column($name, ColumnType::Enum); $col->enumValues = $values; $this->columns[] = $col; @@ -130,7 +152,7 @@ public function enum(string $name, array $values): Column public function point(string $name, int $srid = 4326): Column { - $col = new Column($name, 'point'); + $col = new Column($name, ColumnType::Point); $col->srid = $srid; $this->columns[] = $col; @@ -139,7 +161,7 @@ public function point(string $name, int $srid = 4326): Column public function linestring(string $name, int $srid = 4326): Column { - $col = new Column($name, 'linestring'); + $col = new Column($name, ColumnType::Linestring); $col->srid = $srid; $this->columns[] = $col; @@ -148,7 +170,7 @@ public function linestring(string $name, int $srid = 4326): Column public function polygon(string $name, int $srid = 4326): Column { - $col = new Column($name, 'polygon'); + $col = new Column($name, ColumnType::Polygon); $col->srid = $srid; $this->columns[] = $col; @@ -157,7 +179,7 @@ public function polygon(string $name, int $srid = 4326): Column public function vector(string $name, int $dimensions): Column { - $col = new Column($name, 'vector'); + $col = new Column($name, ColumnType::Vector); $col->dimensions = $dimensions; $this->columns[] = $col; @@ -172,24 +194,64 @@ public function timestamps(int $precision = 3): void /** * @param string[] $columns + * @param array $lengths + * @param array $orders + * @param array $collations */ - public function index(array $columns, string $name = '', string $method = '', string $operatorClass = ''): void - { + public function index( + array $columns, + string $name = '', + string $method = '', + string $operatorClass = '', + array $lengths = [], + array $orders = [], + array $collations = [], + ): void { if ($name === '') { $name = 'idx_' . \implode('_', $columns); } - $this->indexes[] = new Index($name, $columns, method: $method, operatorClass: $operatorClass); + $this->indexes[] = new Index($name, $columns, IndexType::Index, $lengths, $orders, $method, $operatorClass, $collations); } /** * @param string[] $columns + * @param array $lengths + * @param array $orders + * @param array $collations */ - public function uniqueIndex(array $columns, string $name = ''): void - { + public function uniqueIndex( + array $columns, + string $name = '', + array $lengths = [], + array $orders = [], + array $collations = [], + ): void { if ($name === '') { $name = 'uniq_' . \implode('_', $columns); } - $this->indexes[] = new Index($name, $columns, 'unique'); + $this->indexes[] = new Index($name, $columns, IndexType::Unique, $lengths, $orders, collations: $collations); + } + + /** + * @param string[] $columns + */ + public function fulltextIndex(array $columns, string $name = ''): void + { + if ($name === '') { + $name = 'ft_' . \implode('_', $columns); + } + $this->indexes[] = new Index($name, $columns, IndexType::Fulltext); + } + + /** + * @param string[] $columns + */ + public function spatialIndex(array $columns, string $name = ''): void + { + if ($name === '') { + $name = 'sp_' . \implode('_', $columns); + } + $this->indexes[] = new Index($name, $columns, IndexType::Spatial); } public function foreignKey(string $column): ForeignKey @@ -200,17 +262,23 @@ public function foreignKey(string $column): ForeignKey return $fk; } - public function addColumn(string $name, string $type, int|null $lengthOrPrecision = null): Column + public function addColumn(string $name, ColumnType|string $type, int|null $lengthOrPrecision = null): Column { - $col = new Column($name, $type, $type === 'string' ? $lengthOrPrecision : null, $type !== 'string' ? $lengthOrPrecision : null); + if (\is_string($type)) { + $type = ColumnType::from($type); + } + $col = new Column($name, $type, $type === ColumnType::String ? $lengthOrPrecision : null, $type !== ColumnType::String ? $lengthOrPrecision : null); $this->columns[] = $col; return $col; } - public function modifyColumn(string $name, string $type, int|null $lengthOrPrecision = null): Column + public function modifyColumn(string $name, ColumnType|string $type, int|null $lengthOrPrecision = null): Column { - $col = new Column($name, $type, $type === 'string' ? $lengthOrPrecision : null, $type !== 'string' ? $lengthOrPrecision : null); + if (\is_string($type)) { + $type = ColumnType::from($type); + } + $col = new Column($name, $type, $type === ColumnType::String ? $lengthOrPrecision : null, $type !== ColumnType::String ? $lengthOrPrecision : null); $col->isModify = true; $this->columns[] = $col; @@ -219,7 +287,7 @@ public function modifyColumn(string $name, string $type, int|null $lengthOrPreci public function renameColumn(string $from, string $to): void { - $this->renameColumns[] = ['from' => $from, 'to' => $to]; + $this->renameColumns[] = new RenameColumn($from, $to); } public function dropColumn(string $name): void @@ -229,10 +297,26 @@ public function dropColumn(string $name): void /** * @param string[] $columns + * @param array $lengths + * @param array $orders + * @param array $collations + * @param list $rawColumns Raw SQL expressions appended to column list (bypass quoting) */ - public function addIndex(string $name, array $columns): void - { - $this->indexes[] = new Index($name, $columns); + public function addIndex( + string $name, + array $columns, + IndexType|string $type = IndexType::Index, + array $lengths = [], + array $orders = [], + string $method = '', + string $operatorClass = '', + array $collations = [], + array $rawColumns = [], + ): void { + if (\is_string($type)) { + $type = IndexType::from($type); + } + $this->indexes[] = new Index($name, $columns, $type, $lengths, $orders, $method, $operatorClass, $collations, $rawColumns); } public function dropIndex(string $name): void @@ -253,45 +337,24 @@ public function dropForeignKey(string $name): void $this->dropForeignKeys[] = $name; } - /** @return list */ - public function getColumns(): array - { - return $this->columns; - } - - /** @return list */ - public function getIndexes(): array - { - return $this->indexes; - } - - /** @return list */ - public function getForeignKeys(): array - { - return $this->foreignKeys; - } - - /** @return list */ - public function getDropColumns(): array - { - return $this->dropColumns; - } - - /** @return list */ - public function getRenameColumns(): array + /** + * Add a raw SQL column definition (bypass typed Column objects). + * + * Example: $table->rawColumn('`my_col` VARCHAR(255) NOT NULL DEFAULT ""') + */ + public function rawColumn(string $definition): void { - return $this->renameColumns; + $this->rawColumnDefs[] = $definition; } - /** @return list */ - public function getDropIndexes(): array + /** + * Add a raw SQL index definition (bypass typed Index objects). + * + * Example: $table->rawIndex('INDEX `idx_name` (`col1`, `col2`)') + */ + public function rawIndex(string $definition): void { - return $this->dropIndexes; + $this->rawIndexDefs[] = $definition; } - /** @return list */ - public function getDropForeignKeys(): array - { - return $this->dropForeignKeys; - } } diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php index fd4f016..c592132 100644 --- a/src/Query/Schema/ClickHouse.php +++ b/src/Query/Schema/ClickHouse.php @@ -15,22 +15,22 @@ class ClickHouse extends Schema protected function compileColumnType(Column $column): string { $type = match ($column->type) { - 'string' => 'String', - 'text' => 'String', - 'integer' => $column->isUnsigned ? 'UInt32' : 'Int32', - 'bigInteger' => $column->isUnsigned ? 'UInt64' : 'Int64', - 'float' => 'Float64', - 'boolean' => 'UInt8', - 'datetime' => $column->precision ? 'DateTime64(' . $column->precision . ')' : 'DateTime', - 'timestamp' => $column->precision ? 'DateTime64(' . $column->precision . ')' : 'DateTime', - 'json' => 'String', - 'binary' => 'String', - 'enum' => $this->compileClickHouseEnum($column->enumValues), - 'point' => 'Tuple(Float64, Float64)', - 'linestring' => 'Array(Tuple(Float64, Float64))', - 'polygon' => 'Array(Array(Tuple(Float64, Float64)))', - 'vector' => 'Array(Float64)', - default => throw new UnsupportedException('Unknown column type: ' . $column->type), + ColumnType::String => 'String', + ColumnType::Text => 'String', + ColumnType::MediumText, ColumnType::LongText => 'String', + ColumnType::Integer => $column->isUnsigned ? 'UInt32' : 'Int32', + ColumnType::BigInteger => $column->isUnsigned ? 'UInt64' : 'Int64', + ColumnType::Float => 'Float64', + ColumnType::Boolean => 'UInt8', + ColumnType::Datetime => $column->precision ? 'DateTime64(' . $column->precision . ')' : 'DateTime', + ColumnType::Timestamp => $column->precision ? 'DateTime64(' . $column->precision . ')' : 'DateTime', + ColumnType::Json => 'String', + ColumnType::Binary => 'String', + ColumnType::Enum => $this->compileClickHouseEnum($column->enumValues), + ColumnType::Point => 'Tuple(Float64, Float64)', + ColumnType::Linestring => 'Array(Tuple(Float64, Float64))', + ColumnType::Polygon => 'Array(Array(Tuple(Float64, Float64)))', + ColumnType::Vector => 'Array(Float64)', }; if ($column->isNullable) { @@ -87,29 +87,29 @@ public function alter(string $table, callable $definition): BuildResult $alterations = []; - foreach ($blueprint->getColumns() as $column) { + foreach ($blueprint->columns as $column) { $keyword = $column->isModify ? 'MODIFY COLUMN' : 'ADD COLUMN'; $alterations[] = $keyword . ' ' . $this->compileColumnDefinition($column); } - foreach ($blueprint->getRenameColumns() as $rename) { - $alterations[] = 'RENAME COLUMN ' . $this->quote($rename['from']) - . ' TO ' . $this->quote($rename['to']); + foreach ($blueprint->renameColumns as $rename) { + $alterations[] = 'RENAME COLUMN ' . $this->quote($rename->from) + . ' TO ' . $this->quote($rename->to); } - foreach ($blueprint->getDropColumns() as $col) { + foreach ($blueprint->dropColumns as $col) { $alterations[] = 'DROP COLUMN ' . $this->quote($col); } - foreach ($blueprint->getDropIndexes() as $name) { + foreach ($blueprint->dropIndexes as $name) { $alterations[] = 'DROP INDEX ' . $this->quote($name); } - if (! empty($blueprint->getForeignKeys())) { + if (! empty($blueprint->foreignKeys)) { throw new UnsupportedException('Foreign keys are not supported in ClickHouse.'); } - if (! empty($blueprint->getDropForeignKeys())) { + if (! empty($blueprint->dropForeignKeys)) { throw new UnsupportedException('Foreign keys are not supported in ClickHouse.'); } @@ -130,7 +130,7 @@ public function create(string $table, callable $definition): BuildResult $columnDefs = []; $primaryKeys = []; - foreach ($blueprint->getColumns() as $column) { + foreach ($blueprint->columns as $column) { $def = $this->compileColumnDefinition($column); $columnDefs[] = $def; @@ -140,14 +140,14 @@ public function create(string $table, callable $definition): BuildResult } // Indexes (ClickHouse uses INDEX ... TYPE ... GRANULARITY ...) - foreach ($blueprint->getIndexes() as $index) { + foreach ($blueprint->indexes as $index) { $cols = \array_map(fn (string $c): string => $this->quote($c), $index->columns); $expr = \count($cols) === 1 ? $cols[0] : '(' . \implode(', ', $cols) . ')'; $columnDefs[] = 'INDEX ' . $this->quote($index->name) . ' ' . $expr . ' TYPE minmax GRANULARITY 3'; } - if (! empty($blueprint->getForeignKeys())) { + if (! empty($blueprint->foreignKeys)) { throw new UnsupportedException('Foreign keys are not supported in ClickHouse.'); } diff --git a/src/Query/Schema/Column.php b/src/Query/Schema/Column.php index 3f1dfac..f4702db 100644 --- a/src/Query/Schema/Column.php +++ b/src/Query/Schema/Column.php @@ -31,9 +31,11 @@ class Column public bool $isModify = false; + public ?string $collation = null; + public function __construct( public string $name, - public string $type, + public ColumnType $type, public ?int $length = null, public ?int $precision = null, ) { @@ -95,4 +97,11 @@ public function comment(string $comment): static return $this; } + + public function collation(string $collation): static + { + $this->collation = $collation; + + return $this; + } } diff --git a/src/Query/Schema/ColumnType.php b/src/Query/Schema/ColumnType.php new file mode 100644 index 0000000..854c7c0 --- /dev/null +++ b/src/Query/Schema/ColumnType.php @@ -0,0 +1,24 @@ +onDelete = $action; @@ -47,11 +44,10 @@ public function onDelete(string $action): static return $this; } - public function onUpdate(string $action): static + public function onUpdate(ForeignKeyAction|string $action): static { - $action = \strtoupper($action); - if (!\in_array($action, self::ALLOWED_ACTIONS, true)) { - throw new \InvalidArgumentException('Invalid foreign key action: ' . $action); + if (\is_string($action)) { + $action = ForeignKeyAction::from(\strtoupper($action)); } $this->onUpdate = $action; diff --git a/src/Query/Schema/ForeignKeyAction.php b/src/Query/Schema/ForeignKeyAction.php new file mode 100644 index 0000000..959a8a2 --- /dev/null +++ b/src/Query/Schema/ForeignKeyAction.php @@ -0,0 +1,12 @@ + $lengths * @param array $orders + * @param array $collations Column-specific collations (column name => collation) + * @param list $rawColumns Raw SQL expressions appended to the column list (bypass quoting) */ public function __construct( public string $name, public array $columns, - public string $type = 'index', + public IndexType $type = IndexType::Index, public array $lengths = [], public array $orders = [], public string $method = '', public string $operatorClass = '', + public array $collations = [], + public array $rawColumns = [], ) { if ($method !== '' && ! \preg_match('/^[A-Za-z0-9_]+$/', $method)) { throw new ValidationException('Invalid index method: ' . $method); @@ -26,5 +30,10 @@ public function __construct( if ($operatorClass !== '' && ! \preg_match('/^[A-Za-z0-9_.]+$/', $operatorClass)) { throw new ValidationException('Invalid operator class: ' . $operatorClass); } + foreach ($collations as $collation) { + if (! \preg_match('/^[A-Za-z0-9_]+$/', $collation)) { + throw new ValidationException('Invalid collation: ' . $collation); + } + } } } diff --git a/src/Query/Schema/IndexType.php b/src/Query/Schema/IndexType.php new file mode 100644 index 0000000..237f6a8 --- /dev/null +++ b/src/Query/Schema/IndexType.php @@ -0,0 +1,11 @@ +type) { - 'string' => 'VARCHAR(' . ($column->length ?? 255) . ')', - 'text' => 'TEXT', - 'integer' => 'INT', - 'bigInteger' => 'BIGINT', - 'float' => 'DOUBLE', - 'boolean' => 'TINYINT(1)', - 'datetime' => $column->precision ? 'DATETIME(' . $column->precision . ')' : 'DATETIME', - 'timestamp' => $column->precision ? 'TIMESTAMP(' . $column->precision . ')' : 'TIMESTAMP', - 'json' => 'JSON', - 'binary' => 'BLOB', - 'enum' => "ENUM('" . \implode("','", \array_map(fn ($v) => \str_replace("'", "''", $v), $column->enumValues)) . "')", - 'point' => 'POINT' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), - 'linestring' => 'LINESTRING' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), - 'polygon' => 'POLYGON' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), - default => throw new \Utopia\Query\Exception\UnsupportedException('Unknown column type: ' . $column->type), + ColumnType::String => 'VARCHAR(' . ($column->length ?? 255) . ')', + ColumnType::Text => 'TEXT', + ColumnType::MediumText => 'MEDIUMTEXT', + ColumnType::LongText => 'LONGTEXT', + ColumnType::Integer => 'INT', + ColumnType::BigInteger => 'BIGINT', + ColumnType::Float => 'DOUBLE', + ColumnType::Boolean => 'TINYINT(1)', + ColumnType::Datetime => $column->precision ? 'DATETIME(' . $column->precision . ')' : 'DATETIME', + ColumnType::Timestamp => $column->precision ? 'TIMESTAMP(' . $column->precision . ')' : 'TIMESTAMP', + ColumnType::Json => 'JSON', + ColumnType::Binary => 'BLOB', + ColumnType::Enum => "ENUM('" . \implode("','", \array_map(fn ($v) => \str_replace("'", "''", $v), $column->enumValues)) . "')", + ColumnType::Point => 'POINT' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), + ColumnType::Linestring => 'LINESTRING' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), + ColumnType::Polygon => 'POLYGON' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), + ColumnType::Vector => throw new \Utopia\Query\Exception\UnsupportedException('Vector type is not supported in MySQL.'), }; } @@ -29,4 +33,36 @@ protected function compileAutoIncrement(): string { return 'AUTO_INCREMENT'; } + + public function createDatabase(string $name): BuildResult + { + return new BuildResult( + 'CREATE DATABASE ' . $this->quote($name) . ' /*!40100 DEFAULT CHARACTER SET utf8mb4 */', + [] + ); + } + + /** + * MySQL CHANGE COLUMN: rename and/or retype a column in one statement. + */ + public function changeColumn(string $table, string $oldName, string $newName, string $type): BuildResult + { + return new BuildResult( + 'ALTER TABLE ' . $this->quote($table) + . ' CHANGE COLUMN ' . $this->quote($oldName) . ' ' . $this->quote($newName) . ' ' . $type, + [] + ); + } + + /** + * MySQL MODIFY COLUMN: retype a column without renaming. + */ + public function modifyColumn(string $table, string $name, string $type): BuildResult + { + return new BuildResult( + 'ALTER TABLE ' . $this->quote($table) + . ' MODIFY ' . $this->quote($name) . ' ' . $type, + [] + ); + } } diff --git a/src/Query/Schema/ParameterDirection.php b/src/Query/Schema/ParameterDirection.php new file mode 100644 index 0000000..25ab6d6 --- /dev/null +++ b/src/Query/Schema/ParameterDirection.php @@ -0,0 +1,10 @@ +type) { - 'string' => 'VARCHAR(' . ($column->length ?? 255) . ')', - 'text' => 'TEXT', - 'integer' => 'INTEGER', - 'bigInteger' => 'BIGINT', - 'float' => 'DOUBLE PRECISION', - 'boolean' => 'BOOLEAN', - 'datetime' => $column->precision ? 'TIMESTAMP(' . $column->precision . ')' : 'TIMESTAMP', - 'timestamp' => $column->precision ? 'TIMESTAMP(' . $column->precision . ') WITHOUT TIME ZONE' : 'TIMESTAMP WITHOUT TIME ZONE', - 'json' => 'JSONB', - 'binary' => 'BYTEA', - 'enum' => 'TEXT', - 'point' => 'GEOMETRY(POINT' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', - 'linestring' => 'GEOMETRY(LINESTRING' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', - 'polygon' => 'GEOMETRY(POLYGON' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', - 'vector' => 'VECTOR(' . ($column->dimensions ?? 0) . ')', - default => throw new UnsupportedException('Unknown column type: ' . $column->type), + ColumnType::String => 'VARCHAR(' . ($column->length ?? 255) . ')', + ColumnType::Text, ColumnType::MediumText, ColumnType::LongText => 'TEXT', + ColumnType::Integer => 'INTEGER', + ColumnType::BigInteger => 'BIGINT', + ColumnType::Float => 'DOUBLE PRECISION', + ColumnType::Boolean => 'BOOLEAN', + ColumnType::Datetime => $column->precision ? 'TIMESTAMP(' . $column->precision . ')' : 'TIMESTAMP', + ColumnType::Timestamp => $column->precision ? 'TIMESTAMP(' . $column->precision . ') WITHOUT TIME ZONE' : 'TIMESTAMP WITHOUT TIME ZONE', + ColumnType::Json => 'JSONB', + ColumnType::Binary => 'BYTEA', + ColumnType::Enum => 'TEXT', + ColumnType::Point => 'GEOMETRY(POINT' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', + ColumnType::Linestring => 'GEOMETRY(LINESTRING' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', + ColumnType::Polygon => 'GEOMETRY(POLYGON' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', + ColumnType::Vector => 'VECTOR(' . ($column->dimensions ?? 0) . ')', }; } @@ -71,7 +69,7 @@ protected function compileColumnDefinition(Column $column): string } // PostgreSQL enum emulation via CHECK constraint - if ($column->type === 'enum' && ! empty($column->enumValues)) { + if ($column->type === ColumnType::Enum && ! empty($column->enumValues)) { $values = \array_map(fn (string $v): string => "'" . \str_replace("'", "''", $v) . "'", $column->enumValues); $parts[] = 'CHECK (' . $this->quote($column->name) . ' IN (' . \implode(', ', $values) . '))'; } @@ -83,6 +81,10 @@ protected function compileColumnDefinition(Column $column): string /** * @param string[] $columns + * @param array $lengths + * @param array $orders + * @param array $collations + * @param list $rawColumns */ public function createIndex( string $table, @@ -92,6 +94,10 @@ public function createIndex( string $type = '', string $method = '', string $operatorClass = '', + array $lengths = [], + array $orders = [], + array $collations = [], + array $rawColumns = [], ): BuildResult { if ($method !== '' && ! \preg_match('/^[A-Za-z0-9_]+$/', $method)) { throw new ValidationException('Invalid index method: ' . $method); @@ -109,16 +115,10 @@ public function createIndex( $sql .= ' USING ' . \strtoupper($method); } - $colParts = []; - foreach ($columns as $c) { - $part = $this->quote($c); - if ($operatorClass !== '') { - $part .= ' ' . $operatorClass; - } - $colParts[] = $part; - } + $indexType = $unique ? IndexType::Unique : ($type !== '' ? IndexType::from($type) : IndexType::Index); + $index = new Index($name, $columns, $indexType, $lengths, $orders, $method, $operatorClass, $collations, $rawColumns); - $sql .= ' (' . \implode(', ', $colParts) . ')'; + $sql .= ' (' . $this->compileIndexColumns($index) . ')'; return new BuildResult($sql, []); } @@ -141,7 +141,7 @@ public function dropForeignKey(string $table, string $name): BuildResult } /** - * @param list $params + * @param list $params */ public function createProcedure(string $name, array $params, string $body): BuildResult { @@ -162,18 +162,22 @@ public function dropProcedure(string $name): BuildResult public function createTrigger( string $name, string $table, - string $timing, - string $event, + TriggerTiming|string $timing, + TriggerEvent|string $event, string $body, ): BuildResult { - $timing = \strtoupper($timing); - $event = \strtoupper($event); - - if (!\in_array($timing, ['BEFORE', 'AFTER', 'INSTEAD OF'], true)) { - throw new \Utopia\Query\Exception\ValidationException('Invalid trigger timing: ' . $timing); + if ($timing instanceof TriggerTiming) { + $timingValue = $timing->value; + } else { + $timingValue = \strtoupper($timing); + TriggerTiming::from($timingValue); } - if (!\in_array($event, ['INSERT', 'UPDATE', 'DELETE'], true)) { - throw new \Utopia\Query\Exception\ValidationException('Invalid trigger event: ' . $event); + + if ($event instanceof TriggerEvent) { + $eventValue = $event->value; + } else { + $eventValue = \strtoupper($event); + TriggerEvent::from($eventValue); } $funcName = $name . '_func'; @@ -181,7 +185,7 @@ public function createTrigger( $sql = 'CREATE FUNCTION ' . $this->quote($funcName) . '() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN ' . $body . ' RETURN NEW; END; $$; ' . 'CREATE TRIGGER ' . $this->quote($name) - . ' ' . $timing . ' ' . $event + . ' ' . $timingValue . ' ' . $eventValue . ' ON ' . $this->quote($table) . ' FOR EACH ROW EXECUTE FUNCTION ' . $this->quote($funcName) . '()'; @@ -198,7 +202,7 @@ public function alter(string $table, callable $definition): BuildResult $alterations = []; - foreach ($blueprint->getColumns() as $column) { + foreach ($blueprint->columns as $column) { $keyword = $column->isModify ? 'ALTER COLUMN' : 'ADD COLUMN'; if ($column->isModify) { $def = $keyword . ' ' . $this->quote($column->name) @@ -209,29 +213,29 @@ public function alter(string $table, callable $definition): BuildResult $alterations[] = $def; } - foreach ($blueprint->getRenameColumns() as $rename) { - $alterations[] = 'RENAME COLUMN ' . $this->quote($rename['from']) - . ' TO ' . $this->quote($rename['to']); + foreach ($blueprint->renameColumns as $rename) { + $alterations[] = 'RENAME COLUMN ' . $this->quote($rename->from) + . ' TO ' . $this->quote($rename->to); } - foreach ($blueprint->getDropColumns() as $col) { + foreach ($blueprint->dropColumns as $col) { $alterations[] = 'DROP COLUMN ' . $this->quote($col); } - foreach ($blueprint->getForeignKeys() as $fk) { + foreach ($blueprint->foreignKeys as $fk) { $def = 'ADD FOREIGN KEY (' . $this->quote($fk->column) . ')' . ' REFERENCES ' . $this->quote($fk->refTable) . ' (' . $this->quote($fk->refColumn) . ')'; - if ($fk->onDelete !== '') { - $def .= ' ON DELETE ' . $fk->onDelete; + if ($fk->onDelete !== null) { + $def .= ' ON DELETE ' . $fk->onDelete->value; } - if ($fk->onUpdate !== '') { - $def .= ' ON UPDATE ' . $fk->onUpdate; + if ($fk->onUpdate !== null) { + $def .= ' ON UPDATE ' . $fk->onUpdate->value; } $alterations[] = $def; } - foreach ($blueprint->getDropForeignKeys() as $name) { + foreach ($blueprint->dropForeignKeys as $name) { $alterations[] = 'DROP CONSTRAINT ' . $this->quote($name); } @@ -243,9 +247,8 @@ public function alter(string $table, callable $definition): BuildResult } // PostgreSQL indexes are standalone statements, not ALTER TABLE clauses - foreach ($blueprint->getIndexes() as $index) { - $cols = \array_map(fn (string $c): string => $this->quote($c), $index->columns); - $keyword = $index->type === 'unique' ? 'CREATE UNIQUE INDEX' : 'CREATE INDEX'; + foreach ($blueprint->indexes as $index) { + $keyword = $index->type === IndexType::Unique ? 'CREATE UNIQUE INDEX' : 'CREATE INDEX'; $indexSql = $keyword . ' ' . $this->quote($index->name) . ' ON ' . $this->quote($table); @@ -254,20 +257,11 @@ public function alter(string $table, callable $definition): BuildResult $indexSql .= ' USING ' . \strtoupper($index->method); } - $colParts = []; - foreach ($cols as $c) { - $part = $c; - if ($index->operatorClass !== '') { - $part .= ' ' . $index->operatorClass; - } - $colParts[] = $part; - } - - $indexSql .= ' (' . \implode(', ', $colParts) . ')'; + $indexSql .= ' (' . $this->compileIndexColumns($index) . ')'; $statements[] = $indexSql; } - foreach ($blueprint->getDropIndexes() as $name) { + foreach ($blueprint->dropIndexes as $name) { $statements[] = 'DROP INDEX ' . $this->quote($name); } @@ -291,4 +285,65 @@ public function dropExtension(string $name): BuildResult { return new BuildResult('DROP EXTENSION IF EXISTS ' . $this->quote($name), []); } + + /** + * Create a collation. + * + * @param array $options Key-value pairs (e.g. ['provider' => 'icu', 'locale' => 'und-u-ks-level1']) + */ + public function createCollation(string $name, array $options, bool $deterministic = true): BuildResult + { + $optParts = []; + foreach ($options as $key => $value) { + $optParts[] = $key . " = '" . \str_replace("'", "''", $value) . "'"; + } + $optParts[] = 'deterministic = ' . ($deterministic ? 'true' : 'false'); + + $sql = 'CREATE COLLATION IF NOT EXISTS ' . $this->quote($name) + . ' (' . \implode(', ', $optParts) . ')'; + + return new BuildResult($sql, []); + } + + public function renameIndex(string $table, string $from, string $to): BuildResult + { + return new BuildResult( + 'ALTER INDEX ' . $this->quote($from) . ' RENAME TO ' . $this->quote($to), + [] + ); + } + + /** + * PostgreSQL uses schemas instead of databases for namespace isolation. + */ + public function createDatabase(string $name): BuildResult + { + return new BuildResult('CREATE SCHEMA ' . $this->quote($name), []); + } + + public function dropDatabase(string $name): BuildResult + { + return new BuildResult('DROP SCHEMA IF EXISTS ' . $this->quote($name) . ' CASCADE', []); + } + + public function analyzeTable(string $table): BuildResult + { + return new BuildResult('ANALYZE ' . $this->quote($table), []); + } + + /** + * Alter a column's type with an optional USING expression for type casting. + */ + public function alterColumnType(string $table, string $column, string $type, string $using = ''): BuildResult + { + $sql = 'ALTER TABLE ' . $this->quote($table) + . ' ALTER COLUMN ' . $this->quote($column) + . ' TYPE ' . $type; + + if ($using !== '') { + $sql .= ' USING ' . $using; + } + + return new BuildResult($sql, []); + } } diff --git a/src/Query/Schema/RenameColumn.php b/src/Query/Schema/RenameColumn.php new file mode 100644 index 0000000..c72dfff --- /dev/null +++ b/src/Query/Schema/RenameColumn.php @@ -0,0 +1,12 @@ +resolveForeignKeyAction($onDelete); + $onUpdateAction = $this->resolveForeignKeyAction($onUpdate); $sql = 'ALTER TABLE ' . $this->quote($table) . ' ADD CONSTRAINT ' . $this->quote($name) @@ -44,11 +32,11 @@ public function addForeignKey( . ' REFERENCES ' . $this->quote($refTable) . ' (' . $this->quote($refColumn) . ')'; - if ($onDelete !== '') { - $sql .= ' ON DELETE ' . $onDelete; + if ($onDeleteAction !== null) { + $sql .= ' ON DELETE ' . $onDeleteAction->value; } - if ($onUpdate !== '') { - $sql .= ' ON UPDATE ' . $onUpdate; + if ($onUpdateAction !== null) { + $sql .= ' ON UPDATE ' . $onUpdateAction->value; } return new BuildResult($sql, []); @@ -66,16 +54,18 @@ public function dropForeignKey(string $table, string $name): BuildResult /** * Validate and compile a procedure parameter list. * - * @param list $params + * @param list $params * @return list */ protected function compileProcedureParams(array $params): array { $paramList = []; foreach ($params as $param) { - $direction = \strtoupper($param[0]); - if (! \in_array($direction, ['IN', 'OUT', 'INOUT'], true)) { - throw new ValidationException('Invalid procedure parameter direction: ' . $param[0]); + if ($param[0] instanceof ParameterDirection) { + $direction = $param[0]->value; + } else { + $direction = \strtoupper($param[0]); + ParameterDirection::from($direction); } $name = $this->quote($param[1]); @@ -91,7 +81,7 @@ protected function compileProcedureParams(array $params): array } /** - * @param list $params + * @param list $params */ public function createProcedure(string $name, array $params, string $body): BuildResult { @@ -112,22 +102,26 @@ public function dropProcedure(string $name): BuildResult public function createTrigger( string $name, string $table, - string $timing, - string $event, + TriggerTiming|string $timing, + TriggerEvent|string $event, string $body, ): BuildResult { - $timing = \strtoupper($timing); - $event = \strtoupper($event); - - if (!\in_array($timing, ['BEFORE', 'AFTER', 'INSTEAD OF'], true)) { - throw new \Utopia\Query\Exception\ValidationException('Invalid trigger timing: ' . $timing); + if ($timing instanceof TriggerTiming) { + $timingValue = $timing->value; + } else { + $timingValue = \strtoupper($timing); + TriggerTiming::from($timingValue); } - if (!\in_array($event, ['INSERT', 'UPDATE', 'DELETE'], true)) { - throw new \Utopia\Query\Exception\ValidationException('Invalid trigger event: ' . $event); + + if ($event instanceof TriggerEvent) { + $eventValue = $event->value; + } else { + $eventValue = \strtoupper($event); + TriggerEvent::from($eventValue); } $sql = 'CREATE TRIGGER ' . $this->quote($name) - . ' ' . $timing . ' ' . $event + . ' ' . $timingValue . ' ' . $eventValue . ' ON ' . $this->quote($table) . ' FOR EACH ROW BEGIN ' . $body . ' END'; @@ -138,4 +132,17 @@ public function dropTrigger(string $name): BuildResult { return new BuildResult('DROP TRIGGER ' . $this->quote($name), []); } + + private function resolveForeignKeyAction(ForeignKeyAction|string $action): ?ForeignKeyAction + { + if ($action instanceof ForeignKeyAction) { + return $action; + } + + if ($action === '') { + return null; + } + + return ForeignKeyAction::from(\strtoupper($action)); + } } diff --git a/src/Query/Schema/TriggerEvent.php b/src/Query/Schema/TriggerEvent.php new file mode 100644 index 0000000..573e241 --- /dev/null +++ b/src/Query/Schema/TriggerEvent.php @@ -0,0 +1,10 @@ + Date: Wed, 11 Mar 2026 00:06:17 +1300 Subject: [PATCH 26/29] (test): Add binding count assertions, exact query tests, and fix type mismatches --- tests/Query/AggregationQueryTest.php | 15 +- tests/Query/AssertsBindingCount.php | 25 + tests/Query/Builder/ClickHouseTest.php | 991 ++++++++++++++++++-- tests/Query/Builder/MySQLTest.php | 1166 ++++++++++++++++++++++-- tests/Query/Builder/PostgreSQLTest.php | 651 ++++++++++++- tests/Query/ConditionTest.php | 55 +- tests/Query/Hook/Filter/FilterTest.php | 122 ++- tests/Query/Hook/Join/FilterTest.php | 49 +- tests/Query/JoinQueryTest.php | 9 +- tests/Query/QueryHelperTest.php | 94 ++ tests/Query/QueryTest.php | 12 +- tests/Query/Schema/BlueprintTest.php | 400 ++++++++ tests/Query/Schema/ClickHouseTest.php | 76 +- tests/Query/Schema/MySQLTest.php | 109 ++- tests/Query/Schema/PostgreSQLTest.php | 77 +- 15 files changed, 3654 insertions(+), 197 deletions(-) create mode 100644 tests/Query/AssertsBindingCount.php create mode 100644 tests/Query/Schema/BlueprintTest.php diff --git a/tests/Query/AggregationQueryTest.php b/tests/Query/AggregationQueryTest.php index cac761d..fa3d6b8 100644 --- a/tests/Query/AggregationQueryTest.php +++ b/tests/Query/AggregationQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Builder\MySQL; use Utopia\Query\Method; use Utopia\Query\Query; @@ -201,7 +202,7 @@ public function testDistinctIsNotNested(): void public function testCountCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\MySQL(); + $builder = new MySQL(); $query = Query::count('id'); $sql = $query->compile($builder); $this->assertEquals('COUNT(`id`)', $sql); @@ -209,7 +210,7 @@ public function testCountCompileDispatch(): void public function testSumCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\MySQL(); + $builder = new MySQL(); $query = Query::sum('price', 'total'); $sql = $query->compile($builder); $this->assertEquals('SUM(`price`) AS `total`', $sql); @@ -217,7 +218,7 @@ public function testSumCompileDispatch(): void public function testAvgCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\MySQL(); + $builder = new MySQL(); $query = Query::avg('score'); $sql = $query->compile($builder); $this->assertEquals('AVG(`score`)', $sql); @@ -225,7 +226,7 @@ public function testAvgCompileDispatch(): void public function testMinCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\MySQL(); + $builder = new MySQL(); $query = Query::min('price'); $sql = $query->compile($builder); $this->assertEquals('MIN(`price`)', $sql); @@ -233,7 +234,7 @@ public function testMinCompileDispatch(): void public function testMaxCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\MySQL(); + $builder = new MySQL(); $query = Query::max('price'); $sql = $query->compile($builder); $this->assertEquals('MAX(`price`)', $sql); @@ -241,7 +242,7 @@ public function testMaxCompileDispatch(): void public function testGroupByCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\MySQL(); + $builder = new MySQL(); $query = Query::groupBy(['status', 'country']); $sql = $query->compile($builder); $this->assertEquals('`status`, `country`', $sql); @@ -249,7 +250,7 @@ public function testGroupByCompileDispatch(): void public function testHavingCompileDispatchUsesCompileFilter(): void { - $builder = new \Utopia\Query\Builder\MySQL(); + $builder = new MySQL(); $query = Query::having([Query::greaterThan('total', 5)]); $sql = $query->compile($builder); $this->assertEquals('(`total` > ?)', $sql); diff --git a/tests/Query/AssertsBindingCount.php b/tests/Query/AssertsBindingCount.php new file mode 100644 index 0000000..5b52741 --- /dev/null +++ b/tests/Query/AssertsBindingCount.php @@ -0,0 +1,25 @@ +countPlaceholders($result->query); + $this->assertSame( + $placeholders, + count($result->bindings), + "Placeholder count ({$placeholders}) != binding count (" . count($result->bindings) . ")\nQuery: {$result->query}" + ); + } + + private function countPlaceholders(string $sql): int + { + // Match `?` but NOT `?|` or `?&` (PostgreSQL JSONB operators) + // and NOT `??` (escaped question mark) + return (int) preg_match_all('/(?from('events') ->select(['name', 'timestamp']) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT `name`, `timestamp` FROM `events`', $result->query); } @@ -104,6 +115,7 @@ public function testFilterAndSort(): void ->sortDesc('timestamp') ->limit(100) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` WHERE `status` IN (?) AND `count` > ? ORDER BY `timestamp` DESC LIMIT ?', @@ -118,6 +130,7 @@ public function testRegexUsesMatchFunction(): void ->from('logs') ->filter([Query::regex('path', '^/api/v[0-9]+')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `logs` WHERE match(`path`, ?)', $result->query); $this->assertEquals(['^/api/v[0-9]+'], $result->bindings); @@ -151,6 +164,7 @@ public function testRandomOrderUsesLowercaseRand(): void ->from('events') ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` ORDER BY rand()', $result->query); } @@ -161,6 +175,7 @@ public function testFinalKeyword(): void ->from('events') ->final() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` FINAL', $result->query); } @@ -173,6 +188,7 @@ public function testFinalWithFilters(): void ->filter([Query::equal('status', ['active'])]) ->limit(10) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` FINAL WHERE `status` IN (?) LIMIT ?', @@ -187,6 +203,7 @@ public function testSample(): void ->from('events') ->sample(0.1) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1', $result->query); } @@ -198,6 +215,7 @@ public function testSampleWithFinal(): void ->final() ->sample(0.5) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.5', $result->query); } @@ -208,6 +226,7 @@ public function testPrewhere(): void ->from('events') ->prewhere([Query::equal('event_type', ['click'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` PREWHERE `event_type` IN (?)', @@ -225,6 +244,7 @@ public function testPrewhereWithMultipleConditions(): void Query::greaterThan('timestamp', '2024-01-01'), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` PREWHERE `event_type` IN (?) AND `timestamp` > ?', @@ -240,6 +260,7 @@ public function testPrewhereWithWhere(): void ->prewhere([Query::equal('event_type', ['click'])]) ->filter([Query::greaterThan('count', 5)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` PREWHERE `event_type` IN (?) WHERE `count` > ?', @@ -256,6 +277,7 @@ public function testPrewhereWithJoinAndWhere(): void ->prewhere([Query::equal('event_type', ['click'])]) ->filter([Query::greaterThan('users.age', 18)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` JOIN `users` ON `events`.`user_id` = `users`.`id` PREWHERE `event_type` IN (?) WHERE `users`.`age` > ?', @@ -275,6 +297,7 @@ public function testFinalSamplePrewhereWhere(): void ->sortDesc('timestamp') ->limit(100) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `event_type` IN (?) WHERE `count` > ? ORDER BY `timestamp` DESC LIMIT ?', @@ -292,6 +315,7 @@ public function testAggregation(): void ->groupBy(['event_type']) ->having([Query::greaterThan('total', 10)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(*) AS `total`, SUM(`duration`) AS `total_duration` FROM `events` GROUP BY `event_type` HAVING `total` > ?', @@ -307,6 +331,7 @@ public function testJoin(): void ->join('users', 'events.user_id', 'users.id') ->leftJoin('sessions', 'events.session_id', 'sessions.id') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` JOIN `users` ON `events`.`user_id` = `users`.`id` LEFT JOIN `sessions` ON `events`.`session_id` = `sessions`.`id`', @@ -321,6 +346,7 @@ public function testDistinct(): void ->distinct() ->select(['user_id']) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT DISTINCT `user_id` FROM `events`', $result->query); } @@ -334,6 +360,7 @@ public function testUnion(): void ->filter([Query::equal('year', [2024])]) ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `events` WHERE `year` IN (?)) UNION (SELECT * FROM `events_archive` WHERE `year` IN (?))', @@ -370,6 +397,7 @@ public function testResetClearsClickHouseState(): void $builder->reset(); $result = $builder->from('logs')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `logs`', $result->query); $this->assertEquals([], $result->bindings); @@ -397,6 +425,7 @@ public function testAttributeResolver(): void ->addHook(new AttributeMap(['$id' => '_uid'])) ->filter([Query::equal('$id', ['abc'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` WHERE `_uid` IN (?)', @@ -418,6 +447,7 @@ public function filter(string $table): Condition ->addHook($hook) ->filter([Query::equal('status', ['active'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` WHERE `status` IN (?) AND _tenant = ?', @@ -434,6 +464,7 @@ public function testPrewhereBindingOrder(): void ->filter([Query::greaterThan('count', 5)]) ->limit(10) ->build(); + $this->assertBindingCount($result); // prewhere bindings come before where bindings $this->assertEquals(['click', 5, 10], $result->bindings); @@ -455,6 +486,7 @@ public function testCombinedPrewhereWhereJoinGroupBy(): void ->sortDesc('total') ->limit(50) ->build(); + $this->assertBindingCount($result); $query = $result->query; @@ -480,6 +512,7 @@ public function testPrewhereEmptyArray(): void ->from('events') ->prewhere([]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events`', $result->query); $this->assertEquals([], $result->bindings); @@ -491,6 +524,7 @@ public function testPrewhereSingleEqual(): void ->from('events') ->prewhere([Query::equal('status', ['active'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE `status` IN (?)', $result->query); $this->assertEquals(['active'], $result->bindings); @@ -502,6 +536,7 @@ public function testPrewhereSingleNotEqual(): void ->from('events') ->prewhere([Query::notEqual('status', 'deleted')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE `status` != ?', $result->query); $this->assertEquals(['deleted'], $result->bindings); @@ -513,6 +548,7 @@ public function testPrewhereLessThan(): void ->from('events') ->prewhere([Query::lessThan('age', 30)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE `age` < ?', $result->query); $this->assertEquals([30], $result->bindings); @@ -524,6 +560,7 @@ public function testPrewhereLessThanEqual(): void ->from('events') ->prewhere([Query::lessThanEqual('age', 30)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE `age` <= ?', $result->query); $this->assertEquals([30], $result->bindings); @@ -535,6 +572,7 @@ public function testPrewhereGreaterThan(): void ->from('events') ->prewhere([Query::greaterThan('score', 50)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE `score` > ?', $result->query); $this->assertEquals([50], $result->bindings); @@ -546,6 +584,7 @@ public function testPrewhereGreaterThanEqual(): void ->from('events') ->prewhere([Query::greaterThanEqual('score', 50)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE `score` >= ?', $result->query); $this->assertEquals([50], $result->bindings); @@ -557,6 +596,7 @@ public function testPrewhereBetween(): void ->from('events') ->prewhere([Query::between('age', 18, 65)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE `age` BETWEEN ? AND ?', $result->query); $this->assertEquals([18, 65], $result->bindings); @@ -568,6 +608,7 @@ public function testPrewhereNotBetween(): void ->from('events') ->prewhere([Query::notBetween('age', 0, 17)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE `age` NOT BETWEEN ? AND ?', $result->query); $this->assertEquals([0, 17], $result->bindings); @@ -579,6 +620,7 @@ public function testPrewhereStartsWith(): void ->from('events') ->prewhere([Query::startsWith('path', '/api')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE startsWith(`path`, ?)', $result->query); $this->assertEquals(['/api'], $result->bindings); @@ -590,6 +632,7 @@ public function testPrewhereNotStartsWith(): void ->from('events') ->prewhere([Query::notStartsWith('path', '/admin')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE NOT startsWith(`path`, ?)', $result->query); $this->assertEquals(['/admin'], $result->bindings); @@ -601,6 +644,7 @@ public function testPrewhereEndsWith(): void ->from('events') ->prewhere([Query::endsWith('file', '.csv')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE endsWith(`file`, ?)', $result->query); $this->assertEquals(['.csv'], $result->bindings); @@ -612,6 +656,7 @@ public function testPrewhereNotEndsWith(): void ->from('events') ->prewhere([Query::notEndsWith('file', '.tmp')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE NOT endsWith(`file`, ?)', $result->query); $this->assertEquals(['.tmp'], $result->bindings); @@ -623,6 +668,7 @@ public function testPrewhereContainsSingle(): void ->from('events') ->prewhere([Query::contains('name', ['foo'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE position(`name`, ?) > 0', $result->query); $this->assertEquals(['foo'], $result->bindings); @@ -634,6 +680,7 @@ public function testPrewhereContainsMultiple(): void ->from('events') ->prewhere([Query::contains('name', ['foo', 'bar'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE (position(`name`, ?) > 0 OR position(`name`, ?) > 0)', $result->query); $this->assertEquals(['foo', 'bar'], $result->bindings); @@ -645,6 +692,7 @@ public function testPrewhereContainsAny(): void ->from('events') ->prewhere([Query::containsAny('tag', ['a', 'b', 'c'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE `tag` IN (?, ?, ?)', $result->query); $this->assertEquals(['a', 'b', 'c'], $result->bindings); @@ -656,6 +704,7 @@ public function testPrewhereContainsAll(): void ->from('events') ->prewhere([Query::containsAll('tag', ['x', 'y'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE (position(`tag`, ?) > 0 AND position(`tag`, ?) > 0)', $result->query); $this->assertEquals(['x', 'y'], $result->bindings); @@ -667,6 +716,7 @@ public function testPrewhereNotContainsSingle(): void ->from('events') ->prewhere([Query::notContains('name', ['bad'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE position(`name`, ?) = 0', $result->query); $this->assertEquals(['bad'], $result->bindings); @@ -678,6 +728,7 @@ public function testPrewhereNotContainsMultiple(): void ->from('events') ->prewhere([Query::notContains('name', ['bad', 'ugly'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE (position(`name`, ?) = 0 AND position(`name`, ?) = 0)', $result->query); $this->assertEquals(['bad', 'ugly'], $result->bindings); @@ -689,6 +740,7 @@ public function testPrewhereIsNull(): void ->from('events') ->prewhere([Query::isNull('deleted_at')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE `deleted_at` IS NULL', $result->query); $this->assertEquals([], $result->bindings); @@ -700,6 +752,7 @@ public function testPrewhereIsNotNull(): void ->from('events') ->prewhere([Query::isNotNull('email')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE `email` IS NOT NULL', $result->query); $this->assertEquals([], $result->bindings); @@ -711,6 +764,7 @@ public function testPrewhereExists(): void ->from('events') ->prewhere([Query::exists(['col_a', 'col_b'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE (`col_a` IS NOT NULL AND `col_b` IS NOT NULL)', $result->query); } @@ -721,6 +775,7 @@ public function testPrewhereNotExists(): void ->from('events') ->prewhere([Query::notExists(['col_a'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE (`col_a` IS NULL)', $result->query); } @@ -731,6 +786,7 @@ public function testPrewhereRegex(): void ->from('events') ->prewhere([Query::regex('path', '^/api')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE match(`path`, ?)', $result->query); $this->assertEquals(['^/api'], $result->bindings); @@ -745,6 +801,7 @@ public function testPrewhereAndLogical(): void Query::equal('b', [2]), ])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE (`a` IN (?) AND `b` IN (?))', $result->query); $this->assertEquals([1, 2], $result->bindings); @@ -759,6 +816,7 @@ public function testPrewhereOrLogical(): void Query::equal('b', [2]), ])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE (`a` IN (?) OR `b` IN (?))', $result->query); $this->assertEquals([1, 2], $result->bindings); @@ -776,6 +834,7 @@ public function testPrewhereNestedAndOr(): void Query::greaterThan('z', 0), ])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE ((`x` IN (?) OR `y` IN (?)) AND `z` > ?)', $result->query); $this->assertEquals([1, 2, 0], $result->bindings); @@ -787,6 +846,7 @@ public function testPrewhereRawExpression(): void ->from('events') ->prewhere([Query::raw('toDate(created) > ?', ['2024-01-01'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE toDate(created) > ?', $result->query); $this->assertEquals(['2024-01-01'], $result->bindings); @@ -799,6 +859,7 @@ public function testPrewhereMultipleCallsAdditive(): void ->prewhere([Query::equal('a', [1])]) ->prewhere([Query::equal('b', [2])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE `a` IN (?) AND `b` IN (?)', $result->query); $this->assertEquals([1, 2], $result->bindings); @@ -812,6 +873,7 @@ public function testPrewhereWithWhereFinal(): void ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('count', 5)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` FINAL PREWHERE `type` IN (?) WHERE `count` > ?', @@ -827,6 +889,7 @@ public function testPrewhereWithWhereSample(): void ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('count', 5)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` SAMPLE 0.5 PREWHERE `type` IN (?) WHERE `count` > ?', @@ -843,6 +906,7 @@ public function testPrewhereWithWhereFinalSample(): void ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('count', 5)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` FINAL SAMPLE 0.3 PREWHERE `type` IN (?) WHERE `count` > ?', @@ -859,6 +923,7 @@ public function testPrewhereWithGroupBy(): void ->count('*', 'total') ->groupBy(['type']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); $this->assertStringContainsString('GROUP BY `type`', $result->query); @@ -873,6 +938,7 @@ public function testPrewhereWithHaving(): void ->groupBy(['type']) ->having([Query::greaterThan('total', 10)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); $this->assertStringContainsString('HAVING `total` > ?', $result->query); @@ -885,6 +951,7 @@ public function testPrewhereWithOrderBy(): void ->prewhere([Query::equal('type', ['click'])]) ->sortAsc('name') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` PREWHERE `type` IN (?) ORDER BY `name` ASC', @@ -900,6 +967,7 @@ public function testPrewhereWithLimitOffset(): void ->limit(10) ->offset(20) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` PREWHERE `type` IN (?) LIMIT ? OFFSET ?', @@ -916,6 +984,7 @@ public function testPrewhereWithUnion(): void ->prewhere([Query::equal('type', ['click'])]) ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); $this->assertStringContainsString('UNION (SELECT', $result->query); @@ -929,6 +998,7 @@ public function testPrewhereWithDistinct(): void ->select(['user_id']) ->prewhere([Query::equal('type', ['click'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SELECT DISTINCT', $result->query); $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); @@ -941,6 +1011,7 @@ public function testPrewhereWithAggregations(): void ->prewhere([Query::equal('type', ['click'])]) ->sum('amount', 'total_amount') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SUM(`amount`) AS `total_amount`', $result->query); $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); @@ -959,6 +1030,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['click', 5, 't1'], $result->bindings); } @@ -972,6 +1044,7 @@ public function testPrewhereBindingOrderWithCursor(): void ->cursorAfter('abc123') ->sortAsc('_cursor') ->build(); + $this->assertBindingCount($result); // prewhere, where filter, cursor $this->assertEquals('click', $result->bindings[0]); @@ -1001,6 +1074,7 @@ public function filter(string $table): Condition ->offset(100) ->union($other) ->build(); + $this->assertBindingCount($result); // prewhere, filter, provider, cursor, having, limit, offset, union $this->assertEquals('click', $result->bindings[0]); @@ -1018,6 +1092,7 @@ public function testPrewhereWithAttributeResolver(): void ])) ->prewhere([Query::equal('$id', ['abc'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` PREWHERE `_uid` IN (?)', $result->query); $this->assertEquals(['abc'], $result->bindings); @@ -1029,6 +1104,7 @@ public function testPrewhereOnlyNoWhere(): void ->from('events') ->prewhere([Query::greaterThan('ts', 100)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); // "PREWHERE" contains "WHERE" as a substring, so we check there is no standalone WHERE clause @@ -1043,6 +1119,7 @@ public function testPrewhereWithEmptyWhereFilter(): void ->prewhere([Query::equal('type', ['a'])]) ->filter([]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $withoutPrewhere = str_replace('PREWHERE', '', $result->query); @@ -1057,6 +1134,7 @@ public function testPrewhereAppearsAfterJoinsBeforeWhere(): void ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('age', 18)]) ->build(); + $this->assertBindingCount($result); $query = $result->query; $joinPos = strpos($query, 'JOIN'); @@ -1077,6 +1155,7 @@ public function testPrewhereMultipleFiltersInSingleCall(): void Query::lessThan('c', 3), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` PREWHERE `a` IN (?) AND `b` > ? AND `c` < ?', @@ -1095,6 +1174,7 @@ public function testPrewhereResetClearsPrewhereQueries(): void $builder->reset(); $result = $builder->from('events')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('PREWHERE', $result->query); } @@ -1120,6 +1200,7 @@ public function testFinalBasicSelect(): void ->final() ->select(['name', 'ts']) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT `name`, `ts` FROM `events` FINAL', $result->query); } @@ -1131,6 +1212,7 @@ public function testFinalWithJoins(): void ->final() ->join('users', 'events.uid', 'users.id') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` FINAL', $result->query); $this->assertStringContainsString('JOIN `users`', $result->query); @@ -1143,6 +1225,7 @@ public function testFinalWithAggregations(): void ->final() ->count('*', 'total') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); $this->assertStringContainsString('FROM `events` FINAL', $result->query); @@ -1157,6 +1240,7 @@ public function testFinalWithGroupByHaving(): void ->groupBy(['type']) ->having([Query::greaterThan('cnt', 5)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` FINAL', $result->query); $this->assertStringContainsString('GROUP BY `type`', $result->query); @@ -1171,6 +1255,7 @@ public function testFinalWithDistinct(): void ->distinct() ->select(['user_id']) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT DISTINCT `user_id` FROM `events` FINAL', $result->query); } @@ -1183,6 +1268,7 @@ public function testFinalWithSort(): void ->sortAsc('name') ->sortDesc('ts') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` FINAL ORDER BY `name` ASC, `ts` DESC', $result->query); } @@ -1195,6 +1281,7 @@ public function testFinalWithLimitOffset(): void ->limit(10) ->offset(20) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` FINAL LIMIT ? OFFSET ?', $result->query); $this->assertEquals([10, 20], $result->bindings); @@ -1208,6 +1295,7 @@ public function testFinalWithCursor(): void ->cursorAfter('abc') ->sortAsc('_cursor') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` FINAL', $result->query); $this->assertStringContainsString('`_cursor` > ?', $result->query); @@ -1221,6 +1309,7 @@ public function testFinalWithUnion(): void ->final() ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` FINAL', $result->query); $this->assertStringContainsString('UNION (SELECT', $result->query); @@ -1233,6 +1322,7 @@ public function testFinalWithPrewhere(): void ->final() ->prewhere([Query::equal('type', ['click'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` FINAL PREWHERE `type` IN (?)', $result->query); } @@ -1244,6 +1334,7 @@ public function testFinalWithSampleAlone(): void ->final() ->sample(0.25) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.25', $result->query); } @@ -1256,6 +1347,7 @@ public function testFinalWithPrewhereSample(): void ->sample(0.5) ->prewhere([Query::equal('type', ['click'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.5 PREWHERE `type` IN (?)', $result->query); } @@ -1273,6 +1365,7 @@ public function testFinalFullPipeline(): void ->limit(10) ->offset(5) ->build(); + $this->assertBindingCount($result); $query = $result->query; $this->assertStringContainsString('SELECT `name`', $query); @@ -1292,6 +1385,7 @@ public function testFinalCalledMultipleTimesIdempotent(): void ->final() ->final() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` FINAL', $result->query); // Ensure FINAL appears only once @@ -1316,6 +1410,7 @@ public function testFinalPositionAfterTableBeforeJoins(): void ->final() ->join('users', 'events.uid', 'users.id') ->build(); + $this->assertBindingCount($result); $query = $result->query; $finalPos = strpos($query, 'FINAL'); @@ -1337,6 +1432,7 @@ public function resolve(string $attribute): string }) ->filter([Query::equal('status', ['active'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` FINAL', $result->query); $this->assertStringContainsString('`col_status`', $result->query); @@ -1354,6 +1450,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` FINAL', $result->query); $this->assertStringContainsString('deleted = ?', $result->query); @@ -1368,6 +1465,7 @@ public function testFinalResetClearsFlag(): void $builder->reset(); $result = $builder->from('events')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('FINAL', $result->query); } @@ -1377,6 +1475,7 @@ public function testFinalWithWhenConditional(): void ->from('events') ->when(true, fn (Builder $b) => $b->final()) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL', $result->query); @@ -1392,24 +1491,28 @@ public function testFinalWithWhenConditional(): void public function testSample10Percent(): void { $result = (new Builder())->from('events')->sample(0.1)->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1', $result->query); } public function testSample50Percent(): void { $result = (new Builder())->from('events')->sample(0.5)->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5', $result->query); } public function testSample1Percent(): void { $result = (new Builder())->from('events')->sample(0.01)->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` SAMPLE 0.01', $result->query); } public function testSample99Percent(): void { $result = (new Builder())->from('events')->sample(0.99)->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` SAMPLE 0.99', $result->query); } @@ -1420,6 +1523,7 @@ public function testSampleWithFilters(): void ->sample(0.2) ->filter([Query::equal('status', ['active'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` SAMPLE 0.2 WHERE `status` IN (?)', $result->query); } @@ -1431,6 +1535,7 @@ public function testSampleWithJoins(): void ->sample(0.3) ->join('users', 'events.uid', 'users.id') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.3', $result->query); $this->assertStringContainsString('JOIN `users`', $result->query); @@ -1443,6 +1548,7 @@ public function testSampleWithAggregations(): void ->sample(0.1) ->count('*', 'cnt') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.1', $result->query); $this->assertStringContainsString('COUNT(*)', $result->query); @@ -1457,6 +1563,7 @@ public function testSampleWithGroupByHaving(): void ->groupBy(['type']) ->having([Query::greaterThan('cnt', 2)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.5', $result->query); $this->assertStringContainsString('GROUP BY', $result->query); @@ -1471,6 +1578,7 @@ public function testSampleWithDistinct(): void ->distinct() ->select(['user_id']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SELECT DISTINCT', $result->query); $this->assertStringContainsString('SAMPLE 0.5', $result->query); @@ -1483,6 +1591,7 @@ public function testSampleWithSort(): void ->sample(0.5) ->sortDesc('ts') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 ORDER BY `ts` DESC', $result->query); } @@ -1495,6 +1604,7 @@ public function testSampleWithLimitOffset(): void ->limit(10) ->offset(20) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 LIMIT ? OFFSET ?', $result->query); } @@ -1507,6 +1617,7 @@ public function testSampleWithCursor(): void ->cursorAfter('xyz') ->sortAsc('_cursor') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.5', $result->query); $this->assertStringContainsString('`_cursor` > ?', $result->query); @@ -1520,6 +1631,7 @@ public function testSampleWithUnion(): void ->sample(0.5) ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.5', $result->query); $this->assertStringContainsString('UNION', $result->query); @@ -1532,6 +1644,7 @@ public function testSampleWithPrewhere(): void ->sample(0.1) ->prewhere([Query::equal('type', ['click'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1 PREWHERE `type` IN (?)', $result->query); } @@ -1543,6 +1656,7 @@ public function testSampleWithFinalKeyword(): void ->final() ->sample(0.1) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.1', $result->query); } @@ -1555,6 +1669,7 @@ public function testSampleWithFinalPrewhere(): void ->sample(0.2) ->prewhere([Query::equal('t', ['a'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.2 PREWHERE `t` IN (?)', $result->query); } @@ -1569,6 +1684,7 @@ public function testSampleFullPipeline(): void ->sortDesc('ts') ->limit(10) ->build(); + $this->assertBindingCount($result); $query = $result->query; $this->assertStringContainsString('SAMPLE 0.1', $query); @@ -1595,6 +1711,7 @@ public function testSamplePositionAfterFinalBeforeJoins(): void ->sample(0.1) ->join('users', 'events.uid', 'users.id') ->build(); + $this->assertBindingCount($result); $query = $result->query; $samplePos = strpos($query, 'SAMPLE'); @@ -1612,6 +1729,7 @@ public function testSampleResetClearsFraction(): void $builder->reset(); $result = $builder->from('events')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('SAMPLE', $result->query); } @@ -1621,6 +1739,7 @@ public function testSampleWithWhenConditional(): void ->from('events') ->when(true, fn (Builder $b) => $b->sample(0.5)) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.5', $result->query); @@ -1640,6 +1759,7 @@ public function testSampleCalledMultipleTimesLastWins(): void ->sample(0.5) ->sample(0.9) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` SAMPLE 0.9', $result->query); } @@ -1657,6 +1777,7 @@ public function resolve(string $attribute): string }) ->filter([Query::equal('col', ['v'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.5', $result->query); $this->assertStringContainsString('`r_col`', $result->query); @@ -1669,6 +1790,7 @@ public function testRegexBasicPattern(): void ->from('logs') ->filter([Query::regex('msg', 'error|warn')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result->query); $this->assertEquals(['error|warn'], $result->bindings); @@ -1680,6 +1802,7 @@ public function testRegexWithEmptyPattern(): void ->from('logs') ->filter([Query::regex('msg', '')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result->query); $this->assertEquals([''], $result->bindings); @@ -1692,6 +1815,7 @@ public function testRegexWithSpecialChars(): void ->from('logs') ->filter([Query::regex('path', $pattern)]) ->build(); + $this->assertBindingCount($result); // Bindings preserve the pattern exactly as provided $this->assertEquals([$pattern], $result->bindings); @@ -1704,6 +1828,7 @@ public function testRegexWithVeryLongPattern(): void ->from('logs') ->filter([Query::regex('msg', $longPattern)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result->query); $this->assertEquals([$longPattern], $result->bindings); @@ -1718,6 +1843,7 @@ public function testRegexCombinedWithOtherFilters(): void Query::equal('status', [200]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `logs` WHERE match(`path`, ?) AND `status` IN (?)', @@ -1732,6 +1858,7 @@ public function testRegexInPrewhere(): void ->from('logs') ->prewhere([Query::regex('path', '^/api')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `logs` PREWHERE match(`path`, ?)', $result->query); $this->assertEquals(['^/api'], $result->bindings); @@ -1744,6 +1871,7 @@ public function testRegexInPrewhereAndWhere(): void ->prewhere([Query::regex('path', '^/api')]) ->filter([Query::regex('msg', 'err')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `logs` PREWHERE match(`path`, ?) WHERE match(`msg`, ?)', @@ -1764,6 +1892,7 @@ public function resolve(string $attribute): string }) ->filter([Query::regex('msg', 'test')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `logs` WHERE match(`col_msg`, ?)', $result->query); } @@ -1775,6 +1904,7 @@ public function testRegexBindingPreserved(): void ->from('logs') ->filter([Query::regex('msg', $pattern)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals([$pattern], $result->bindings); } @@ -1788,6 +1918,7 @@ public function testMultipleRegexFilters(): void Query::regex('msg', 'error'), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `logs` WHERE match(`path`, ?) AND match(`msg`, ?)', @@ -1804,6 +1935,7 @@ public function testRegexInAndLogical(): void Query::greaterThan('status', 399), ])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `logs` WHERE (match(`path`, ?) AND `status` > ?)', @@ -1820,6 +1952,7 @@ public function testRegexInOrLogical(): void Query::regex('path', '^/web'), ])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `logs` WHERE (match(`path`, ?) OR match(`path`, ?))', @@ -1839,6 +1972,7 @@ public function testRegexInNestedLogical(): void Query::equal('status', [500]), ])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('match(`path`, ?)', $result->query); $this->assertStringContainsString('`status` IN (?)', $result->query); @@ -1851,6 +1985,7 @@ public function testRegexWithFinal(): void ->final() ->filter([Query::regex('path', '^/api')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `logs` FINAL', $result->query); $this->assertStringContainsString('match(`path`, ?)', $result->query); @@ -1863,6 +1998,7 @@ public function testRegexWithSample(): void ->sample(0.5) ->filter([Query::regex('path', '^/api')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.5', $result->query); $this->assertStringContainsString('match(`path`, ?)', $result->query); @@ -1887,6 +2023,7 @@ public function testRegexCombinedWithContains(): void Query::contains('msg', ['error']), ]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('match(`path`, ?)', $result->query); $this->assertStringContainsString('position(`msg`, ?) > 0', $result->query); @@ -1901,6 +2038,7 @@ public function testRegexCombinedWithStartsWith(): void Query::startsWith('msg', 'ERR'), ]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('match(`path`, ?)', $result->query); $this->assertStringContainsString('startsWith(`msg`, ?)', $result->query); @@ -1913,6 +2051,7 @@ public function testRegexPrewhereWithRegexWhere(): void ->prewhere([Query::regex('path', '^/api')]) ->filter([Query::regex('msg', 'error')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE match(`path`, ?)', $result->query); $this->assertStringContainsString('WHERE match(`msg`, ?)', $result->query); @@ -1929,6 +2068,7 @@ public function testRegexCombinedWithPrewhereContainsRegex(): void ]) ->filter([Query::regex('msg', 'timeout')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['^/api', 'error', 'timeout'], $result->bindings); } @@ -2057,6 +2197,7 @@ public function testRandomSortProducesLowercaseRand(): void ->from('events') ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('rand()', $result->query); $this->assertStringNotContainsString('RAND()', $result->query); @@ -2069,6 +2210,7 @@ public function testRandomSortCombinedWithAsc(): void ->sortAsc('name') ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` ORDER BY `name` ASC, rand()', $result->query); } @@ -2080,6 +2222,7 @@ public function testRandomSortCombinedWithDesc(): void ->sortDesc('ts') ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` ORDER BY `ts` DESC, rand()', $result->query); } @@ -2092,6 +2235,7 @@ public function testRandomSortCombinedWithAscAndDesc(): void ->sortDesc('ts') ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` ORDER BY `name` ASC, `ts` DESC, rand()', $result->query); } @@ -2103,6 +2247,7 @@ public function testRandomSortWithFinal(): void ->final() ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` FINAL ORDER BY rand()', $result->query); } @@ -2114,6 +2259,7 @@ public function testRandomSortWithSample(): void ->sample(0.5) ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 ORDER BY rand()', $result->query); } @@ -2125,6 +2271,7 @@ public function testRandomSortWithPrewhere(): void ->prewhere([Query::equal('type', ['click'])]) ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` PREWHERE `type` IN (?) ORDER BY rand()', @@ -2139,6 +2286,7 @@ public function testRandomSortWithLimit(): void ->sortRandom() ->limit(10) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` ORDER BY rand() LIMIT ?', $result->query); $this->assertEquals([10], $result->bindings); @@ -2152,6 +2300,7 @@ public function testRandomSortWithFiltersAndJoins(): void ->filter([Query::equal('status', ['active'])]) ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `users`', $result->query); $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); @@ -2164,6 +2313,7 @@ public function testRandomSortAlone(): void ->from('events') ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events` ORDER BY rand()', $result->query); $this->assertEquals([], $result->bindings); @@ -2173,6 +2323,7 @@ public function testRandomSortAlone(): void public function testFilterEqualSingleValue(): void { $result = (new Builder())->from('t')->filter([Query::equal('a', ['x'])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $result->query); $this->assertEquals(['x'], $result->bindings); } @@ -2180,6 +2331,7 @@ public function testFilterEqualSingleValue(): void public function testFilterEqualMultipleValues(): void { $result = (new Builder())->from('t')->filter([Query::equal('a', ['x', 'y', 'z'])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?, ?, ?)', $result->query); $this->assertEquals(['x', 'y', 'z'], $result->bindings); } @@ -2187,6 +2339,7 @@ public function testFilterEqualMultipleValues(): void public function testFilterNotEqualSingleValue(): void { $result = (new Builder())->from('t')->filter([Query::notEqual('a', 'x')])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` != ?', $result->query); $this->assertEquals(['x'], $result->bindings); } @@ -2194,6 +2347,7 @@ public function testFilterNotEqualSingleValue(): void public function testFilterNotEqualMultipleValues(): void { $result = (new Builder())->from('t')->filter([Query::notEqual('a', ['x', 'y'])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT IN (?, ?)', $result->query); $this->assertEquals(['x', 'y'], $result->bindings); } @@ -2201,6 +2355,7 @@ public function testFilterNotEqualMultipleValues(): void public function testFilterLessThanValue(): void { $result = (new Builder())->from('t')->filter([Query::lessThan('a', 10)])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` < ?', $result->query); $this->assertEquals([10], $result->bindings); } @@ -2208,24 +2363,28 @@ public function testFilterLessThanValue(): void public function testFilterLessThanEqualValue(): void { $result = (new Builder())->from('t')->filter([Query::lessThanEqual('a', 10)])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` <= ?', $result->query); } public function testFilterGreaterThanValue(): void { $result = (new Builder())->from('t')->filter([Query::greaterThan('a', 10)])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` > ?', $result->query); } public function testFilterGreaterThanEqualValue(): void { $result = (new Builder())->from('t')->filter([Query::greaterThanEqual('a', 10)])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` >= ?', $result->query); } public function testFilterBetweenValues(): void { $result = (new Builder())->from('t')->filter([Query::between('a', 1, 10)])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` BETWEEN ? AND ?', $result->query); $this->assertEquals([1, 10], $result->bindings); } @@ -2233,12 +2392,14 @@ public function testFilterBetweenValues(): void public function testFilterNotBetweenValues(): void { $result = (new Builder())->from('t')->filter([Query::notBetween('a', 1, 10)])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT BETWEEN ? AND ?', $result->query); } public function testFilterStartsWithValue(): void { $result = (new Builder())->from('t')->filter([Query::startsWith('a', 'foo')])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE startsWith(`a`, ?)', $result->query); $this->assertEquals(['foo'], $result->bindings); } @@ -2246,6 +2407,7 @@ public function testFilterStartsWithValue(): void public function testFilterNotStartsWithValue(): void { $result = (new Builder())->from('t')->filter([Query::notStartsWith('a', 'foo')])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE NOT startsWith(`a`, ?)', $result->query); $this->assertEquals(['foo'], $result->bindings); } @@ -2253,6 +2415,7 @@ public function testFilterNotStartsWithValue(): void public function testFilterEndsWithValue(): void { $result = (new Builder())->from('t')->filter([Query::endsWith('a', 'bar')])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE endsWith(`a`, ?)', $result->query); $this->assertEquals(['bar'], $result->bindings); } @@ -2260,6 +2423,7 @@ public function testFilterEndsWithValue(): void public function testFilterNotEndsWithValue(): void { $result = (new Builder())->from('t')->filter([Query::notEndsWith('a', 'bar')])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE NOT endsWith(`a`, ?)', $result->query); $this->assertEquals(['bar'], $result->bindings); } @@ -2267,6 +2431,7 @@ public function testFilterNotEndsWithValue(): void public function testFilterContainsSingleValue(): void { $result = (new Builder())->from('t')->filter([Query::contains('a', ['foo'])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE position(`a`, ?) > 0', $result->query); $this->assertEquals(['foo'], $result->bindings); } @@ -2274,6 +2439,7 @@ public function testFilterContainsSingleValue(): void public function testFilterContainsMultipleValues(): void { $result = (new Builder())->from('t')->filter([Query::contains('a', ['foo', 'bar'])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (position(`a`, ?) > 0 OR position(`a`, ?) > 0)', $result->query); $this->assertEquals(['foo', 'bar'], $result->bindings); } @@ -2281,12 +2447,14 @@ public function testFilterContainsMultipleValues(): void public function testFilterContainsAnyValues(): void { $result = (new Builder())->from('t')->filter([Query::containsAny('a', ['x', 'y'])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?, ?)', $result->query); } public function testFilterContainsAllValues(): void { $result = (new Builder())->from('t')->filter([Query::containsAll('a', ['x', 'y'])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (position(`a`, ?) > 0 AND position(`a`, ?) > 0)', $result->query); $this->assertEquals(['x', 'y'], $result->bindings); } @@ -2294,6 +2462,7 @@ public function testFilterContainsAllValues(): void public function testFilterNotContainsSingleValue(): void { $result = (new Builder())->from('t')->filter([Query::notContains('a', ['foo'])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE position(`a`, ?) = 0', $result->query); $this->assertEquals(['foo'], $result->bindings); } @@ -2301,12 +2470,14 @@ public function testFilterNotContainsSingleValue(): void public function testFilterNotContainsMultipleValues(): void { $result = (new Builder())->from('t')->filter([Query::notContains('a', ['foo', 'bar'])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (position(`a`, ?) = 0 AND position(`a`, ?) = 0)', $result->query); } public function testFilterIsNullValue(): void { $result = (new Builder())->from('t')->filter([Query::isNull('a')])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` IS NULL', $result->query); $this->assertEquals([], $result->bindings); } @@ -2314,18 +2485,21 @@ public function testFilterIsNullValue(): void public function testFilterIsNotNullValue(): void { $result = (new Builder())->from('t')->filter([Query::isNotNull('a')])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` IS NOT NULL', $result->query); } public function testFilterExistsValue(): void { $result = (new Builder())->from('t')->filter([Query::exists(['a', 'b'])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`a` IS NOT NULL AND `b` IS NOT NULL)', $result->query); } public function testFilterNotExistsValue(): void { $result = (new Builder())->from('t')->filter([Query::notExists(['a', 'b'])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`a` IS NULL AND `b` IS NULL)', $result->query); } @@ -2334,6 +2508,7 @@ public function testFilterAndLogical(): void $result = (new Builder())->from('t')->filter([ Query::and([Query::equal('a', [1]), Query::equal('b', [2])]), ])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) AND `b` IN (?))', $result->query); } @@ -2343,6 +2518,7 @@ public function testFilterOrLogical(): void $result = (new Builder())->from('t')->filter([ Query::or([Query::equal('a', [1]), Query::equal('b', [2])]), ])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR `b` IN (?))', $result->query); } @@ -2350,6 +2526,7 @@ public function testFilterOrLogical(): void public function testFilterRaw(): void { $result = (new Builder())->from('t')->filter([Query::raw('x > ? AND y < ?', [1, 2])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE x > ? AND y < ?', $result->query); $this->assertEquals([1, 2], $result->bindings); } @@ -2368,6 +2545,7 @@ public function testFilterDeeplyNestedLogical(): void Query::equal('d', [4]), ]), ])->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`a` IN (?) OR (`b` > ? AND `c` < ?))', $result->query); $this->assertStringContainsString('`d` IN (?)', $result->query); @@ -2376,18 +2554,21 @@ public function testFilterDeeplyNestedLogical(): void public function testFilterWithFloats(): void { $result = (new Builder())->from('t')->filter([Query::greaterThan('price', 9.99)])->build(); + $this->assertBindingCount($result); $this->assertEquals([9.99], $result->bindings); } public function testFilterWithNegativeNumbers(): void { $result = (new Builder())->from('t')->filter([Query::greaterThan('temp', -40)])->build(); + $this->assertBindingCount($result); $this->assertEquals([-40], $result->bindings); } public function testFilterWithEmptyStrings(): void { $result = (new Builder())->from('t')->filter([Query::equal('name', [''])])->build(); + $this->assertBindingCount($result); $this->assertEquals([''], $result->bindings); } // 8. Aggregation with ClickHouse features (15 tests) @@ -2399,6 +2580,7 @@ public function testAggregationCountWithFinal(): void ->final() ->count('*', 'total') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT COUNT(*) AS `total` FROM `events` FINAL', $result->query); } @@ -2410,6 +2592,7 @@ public function testAggregationSumWithSample(): void ->sample(0.1) ->sum('amount', 'total_amount') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT SUM(`amount`) AS `total_amount` FROM `events` SAMPLE 0.1', $result->query); } @@ -2421,6 +2604,7 @@ public function testAggregationAvgWithPrewhere(): void ->prewhere([Query::equal('type', ['sale'])]) ->avg('price', 'avg_price') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('AVG(`price`) AS `avg_price`', $result->query); $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); @@ -2434,6 +2618,7 @@ public function testAggregationMinWithPrewhereWhere(): void ->filter([Query::greaterThan('amount', 0)]) ->min('price', 'min_price') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('MIN(`price`) AS `min_price`', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -2449,6 +2634,7 @@ public function testAggregationMaxWithAllClickHouseFeatures(): void ->prewhere([Query::equal('type', ['sale'])]) ->max('price', 'max_price') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('MAX(`price`) AS `max_price`', $result->query); $this->assertStringContainsString('FINAL SAMPLE 0.5', $result->query); @@ -2465,6 +2651,7 @@ public function testMultipleAggregationsWithPrewhereGroupByHaving(): void ->groupBy(['region']) ->having([Query::greaterThan('cnt', 10)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); $this->assertStringContainsString('SUM(`amount`) AS `total`', $result->query); @@ -2481,6 +2668,7 @@ public function testAggregationWithJoinFinal(): void ->join('users', 'events.uid', 'users.id') ->count('*', 'total') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` FINAL', $result->query); $this->assertStringContainsString('JOIN `users`', $result->query); @@ -2495,6 +2683,7 @@ public function testAggregationWithDistinctSample(): void ->distinct() ->count('user_id', 'unique_users') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SELECT DISTINCT', $result->query); $this->assertStringContainsString('SAMPLE 0.5', $result->query); @@ -2507,6 +2696,7 @@ public function testAggregationWithAliasPrewhere(): void ->prewhere([Query::equal('type', ['click'])]) ->count('*', 'click_count') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*) AS `click_count`', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -2519,6 +2709,7 @@ public function testAggregationWithoutAliasFinal(): void ->final() ->count('*') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*)', $result->query); $this->assertStringNotContainsString(' AS ', $result->query); @@ -2534,6 +2725,7 @@ public function testCountStarAllClickHouseFeatures(): void ->prewhere([Query::equal('type', ['click'])]) ->count('*', 'total') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); $this->assertStringContainsString('FINAL SAMPLE 0.5', $result->query); @@ -2551,6 +2743,7 @@ public function testAggregationAllFeaturesUnion(): void ->count('*', 'total') ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('UNION', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -2566,6 +2759,7 @@ public function testAggregationAttributeResolverPrewhere(): void ->prewhere([Query::equal('type', ['sale'])]) ->sum('amt', 'total') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SUM(`amount_cents`)', $result->query); } @@ -2583,6 +2777,7 @@ public function filter(string $table): Condition }) ->count('*', 'cnt') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertStringContainsString('tenant = ?', $result->query); @@ -2598,6 +2793,7 @@ public function testGroupByHavingPrewhereFinal(): void ->groupBy(['region']) ->having([Query::greaterThan('cnt', 5)]) ->build(); + $this->assertBindingCount($result); $query = $result->query; $this->assertStringContainsString('FINAL', $query); @@ -2614,6 +2810,7 @@ public function testJoinWithFinalFeature(): void ->final() ->join('users', 'events.uid', 'users.id') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` FINAL JOIN `users` ON `events`.`uid` = `users`.`id`', @@ -2628,6 +2825,7 @@ public function testJoinWithSampleFeature(): void ->sample(0.5) ->join('users', 'events.uid', 'users.id') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` SAMPLE 0.5 JOIN `users` ON `events`.`uid` = `users`.`id`', @@ -2642,6 +2840,7 @@ public function testJoinWithPrewhereFeature(): void ->join('users', 'events.uid', 'users.id') ->prewhere([Query::equal('type', ['click'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `users`', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -2655,6 +2854,7 @@ public function testJoinWithPrewhereWhere(): void ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('users.age', 18)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -2671,6 +2871,7 @@ public function testJoinAllClickHouseFeatures(): void ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('users.age', 18)]) ->build(); + $this->assertBindingCount($result); $query = $result->query; $this->assertStringContainsString('FINAL SAMPLE 0.1', $query); @@ -2686,6 +2887,7 @@ public function testLeftJoinWithPrewhere(): void ->leftJoin('users', 'events.uid', 'users.id') ->prewhere([Query::equal('type', ['click'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LEFT JOIN `users`', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -2698,6 +2900,7 @@ public function testRightJoinWithPrewhere(): void ->rightJoin('users', 'events.uid', 'users.id') ->prewhere([Query::equal('type', ['click'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('RIGHT JOIN `users`', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -2710,6 +2913,7 @@ public function testCrossJoinWithFinal(): void ->final() ->crossJoin('config') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` FINAL', $result->query); $this->assertStringContainsString('CROSS JOIN `config`', $result->query); @@ -2723,6 +2927,7 @@ public function testMultipleJoinsWithPrewhere(): void ->leftJoin('sessions', 'events.sid', 'sessions.id') ->prewhere([Query::equal('type', ['click'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `users`', $result->query); $this->assertStringContainsString('LEFT JOIN `sessions`', $result->query); @@ -2738,6 +2943,7 @@ public function testJoinAggregationPrewhereGroupBy(): void ->count('*', 'cnt') ->groupBy(['users.country']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -2752,6 +2958,7 @@ public function testJoinPrewhereBindingOrder(): void ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('users.age', 18)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['click', 18], $result->bindings); } @@ -2766,6 +2973,7 @@ public function testJoinAttributeResolverPrewhere(): void ->join('users', 'events.uid', 'users.id') ->prewhere([Query::equal('uid', ['abc'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE `user_id` IN (?)', $result->query); } @@ -2783,6 +2991,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertStringContainsString('tenant = ?', $result->query); @@ -2797,6 +3006,7 @@ public function testJoinPrewhereUnion(): void ->prewhere([Query::equal('type', ['click'])]) ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -2813,6 +3023,7 @@ public function testJoinClauseOrdering(): void ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('age', 18)]) ->build(); + $this->assertBindingCount($result); $query = $result->query; @@ -2839,6 +3050,7 @@ public function testUnionMainHasFinal(): void ->final() ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` FINAL', $result->query); $this->assertStringContainsString('UNION (SELECT * FROM `archive`)', $result->query); @@ -2852,6 +3064,7 @@ public function testUnionMainHasSample(): void ->sample(0.5) ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.5', $result->query); $this->assertStringContainsString('UNION', $result->query); @@ -2865,6 +3078,7 @@ public function testUnionMainHasPrewhere(): void ->prewhere([Query::equal('type', ['click'])]) ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertStringContainsString('UNION', $result->query); @@ -2881,6 +3095,7 @@ public function testUnionMainHasAllClickHouseFeatures(): void ->filter([Query::greaterThan('count', 0)]) ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -2895,6 +3110,7 @@ public function testUnionAllWithPrewhere(): void ->prewhere([Query::equal('type', ['click'])]) ->unionAll($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertStringContainsString('UNION ALL', $result->query); @@ -2909,6 +3125,7 @@ public function testUnionBindingOrderWithPrewhere(): void ->filter([Query::equal('year', [2024])]) ->union($other) ->build(); + $this->assertBindingCount($result); // prewhere, where, union $this->assertEquals(['click', 2024, 2023], $result->bindings); @@ -2924,6 +3141,7 @@ public function testMultipleUnionsWithPrewhere(): void ->union($other1) ->union($other2) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertEquals(2, substr_count($result->query, 'UNION')); @@ -2938,6 +3156,7 @@ public function testUnionJoinPrewhere(): void ->prewhere([Query::equal('type', ['click'])]) ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -2954,6 +3173,7 @@ public function testUnionAggregationPrewhereFinal(): void ->count('*', 'total') ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -2975,6 +3195,7 @@ public function testUnionWithComplexMainQuery(): void ->limit(10) ->union($other) ->build(); + $this->assertBindingCount($result); $query = $result->query; $this->assertStringContainsString('SELECT `name`, `count`', $query); @@ -3174,6 +3395,7 @@ public function testResetClearsPrewhereState(): void $builder->build(); $builder->reset(); $result = $builder->from('events')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('PREWHERE', $result->query); } @@ -3184,6 +3406,7 @@ public function testResetClearsFinalState(): void $builder->build(); $builder->reset(); $result = $builder->from('events')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('FINAL', $result->query); } @@ -3194,6 +3417,7 @@ public function testResetClearsSampleState(): void $builder->build(); $builder->reset(); $result = $builder->from('events')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('SAMPLE', $result->query); } @@ -3208,6 +3432,7 @@ public function testResetClearsAllThreeTogether(): void $builder->build(); $builder->reset(); $result = $builder->from('events')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `events`', $result->query); } @@ -3228,6 +3453,7 @@ public function resolve(string $attribute): string $builder->reset(); $result = $builder->from('events')->filter([Query::equal('col', ['v'])])->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`r_col`', $result->query); } @@ -3246,6 +3472,7 @@ public function filter(string $table): Condition $builder->reset(); $result = $builder->from('events')->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('tenant = ?', $result->query); } @@ -3256,6 +3483,7 @@ public function testResetClearsTable(): void $builder->reset(); $result = $builder->from('logs')->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `logs`', $result->query); $this->assertStringNotContainsString('events', $result->query); } @@ -3267,6 +3495,7 @@ public function testResetClearsFilters(): void $builder->reset(); $result = $builder->from('events')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('WHERE', $result->query); } @@ -3278,6 +3507,7 @@ public function testResetClearsUnions(): void $builder->reset(); $result = $builder->from('events')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('UNION', $result->query); } @@ -3288,6 +3518,7 @@ public function testResetClearsBindings(): void $builder->reset(); $result = $builder->from('events')->build(); + $this->assertBindingCount($result); $this->assertEquals([], $result->bindings); } @@ -3305,6 +3536,7 @@ public function testBuildAfterResetMinimalOutput(): void $builder->reset(); $result = $builder->from('t')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); $this->assertEquals([], $result->bindings); } @@ -3316,6 +3548,7 @@ public function testResetRebuildWithPrewhere(): void $builder->reset(); $result = $builder->from('events')->prewhere([Query::equal('x', [1])])->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertStringNotContainsString('FINAL', $result->query); } @@ -3327,6 +3560,7 @@ public function testResetRebuildWithFinal(): void $builder->reset(); $result = $builder->from('events')->final()->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL', $result->query); $this->assertStringNotContainsString('PREWHERE', $result->query); } @@ -3338,6 +3572,7 @@ public function testResetRebuildWithSample(): void $builder->reset(); $result = $builder->from('events')->sample(0.5)->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.5', $result->query); $this->assertStringNotContainsString('FINAL', $result->query); } @@ -3354,6 +3589,7 @@ public function testMultipleResets(): void $builder->reset(); $result = $builder->from('d')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `d`', $result->query); $this->assertEquals([], $result->bindings); } @@ -3365,6 +3601,7 @@ public function testWhenTrueAddsPrewhere(): void ->from('events') ->when(true, fn (Builder $b) => $b->prewhere([Query::equal('type', ['click'])])) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); } @@ -3375,6 +3612,7 @@ public function testWhenFalseDoesNotAddPrewhere(): void ->from('events') ->when(false, fn (Builder $b) => $b->prewhere([Query::equal('type', ['click'])])) ->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('PREWHERE', $result->query); } @@ -3385,6 +3623,7 @@ public function testWhenTrueAddsFinal(): void ->from('events') ->when(true, fn (Builder $b) => $b->final()) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL', $result->query); } @@ -3395,6 +3634,7 @@ public function testWhenFalseDoesNotAddFinal(): void ->from('events') ->when(false, fn (Builder $b) => $b->final()) ->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('FINAL', $result->query); } @@ -3405,6 +3645,7 @@ public function testWhenTrueAddsSample(): void ->from('events') ->when(true, fn (Builder $b) => $b->sample(0.5)) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.5', $result->query); } @@ -3420,6 +3661,7 @@ public function testWhenWithBothPrewhereAndFilter(): void ->filter([Query::greaterThan('count', 5)]) ) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertStringContainsString('WHERE', $result->query); @@ -3436,6 +3678,7 @@ public function testWhenNestedWithClickHouseFeatures(): void ->when(true, fn (Builder $b2) => $b2->sample(0.5)) ) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL SAMPLE 0.5', $result->query); } @@ -3448,6 +3691,7 @@ public function testWhenChainedMultipleTimesWithClickHouseFeatures(): void ->when(true, fn (Builder $b) => $b->sample(0.5)) ->when(true, fn (Builder $b) => $b->prewhere([Query::equal('type', ['click'])])) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL SAMPLE 0.5', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -3464,6 +3708,7 @@ public function testWhenAddsJoinAndPrewhere(): void ->prewhere([Query::equal('type', ['click'])]) ) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -3476,6 +3721,7 @@ public function testWhenCombinedWithRegularWhen(): void ->when(true, fn (Builder $b) => $b->final()) ->when(true, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL', $result->query); $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); @@ -3494,6 +3740,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertStringContainsString('deleted = ?', $result->query); @@ -3511,6 +3758,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL', $result->query); $this->assertStringContainsString('deleted = ?', $result->query); @@ -3528,6 +3776,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.5', $result->query); $this->assertStringContainsString('deleted = ?', $result->query); @@ -3546,6 +3795,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); // prewhere, filter, provider $this->assertEquals(['click', 5, 't1'], $result->bindings); @@ -3569,6 +3819,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['click', 't1', 'o1'], $result->bindings); } @@ -3588,6 +3839,7 @@ public function filter(string $table): Condition ->sortAsc('_cursor') ->limit(10) ->build(); + $this->assertBindingCount($result); // prewhere, provider, cursor, limit $this->assertEquals('click', $result->bindings[0]); @@ -3611,6 +3863,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -3630,6 +3883,7 @@ public function filter(string $table): Condition }) ->count('*', 'cnt') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*)', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -3649,6 +3903,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -3667,6 +3922,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('events.deleted = ?', $result->query); $this->assertStringContainsString('FINAL', $result->query); @@ -3681,6 +3937,7 @@ public function testCursorAfterWithPrewhere(): void ->cursorAfter('abc') ->sortAsc('_cursor') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertStringContainsString('`_cursor` > ?', $result->query); @@ -3694,6 +3951,7 @@ public function testCursorBeforeWithPrewhere(): void ->cursorBefore('abc') ->sortAsc('_cursor') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertStringContainsString('`_cursor` < ?', $result->query); @@ -3708,6 +3966,7 @@ public function testCursorPrewhereWhere(): void ->cursorAfter('abc') ->sortAsc('_cursor') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertStringContainsString('WHERE', $result->query); @@ -3722,6 +3981,7 @@ public function testCursorWithFinal(): void ->cursorAfter('abc') ->sortAsc('_cursor') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL', $result->query); $this->assertStringContainsString('`_cursor` > ?', $result->query); @@ -3735,6 +3995,7 @@ public function testCursorWithSample(): void ->cursorAfter('abc') ->sortAsc('_cursor') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.5', $result->query); $this->assertStringContainsString('`_cursor` > ?', $result->query); @@ -3748,6 +4009,7 @@ public function testCursorPrewhereBindingOrder(): void ->cursorAfter('cur1') ->sortAsc('_cursor') ->build(); + $this->assertBindingCount($result); $this->assertEquals('click', $result->bindings[0]); $this->assertEquals('cur1', $result->bindings[1]); @@ -3767,6 +4029,7 @@ public function filter(string $table): Condition ->cursorAfter('cur1') ->sortAsc('_cursor') ->build(); + $this->assertBindingCount($result); $this->assertEquals('click', $result->bindings[0]); $this->assertEquals('t1', $result->bindings[1]); @@ -3785,6 +4048,7 @@ public function testCursorFullClickHousePipeline(): void ->sortAsc('_cursor') ->limit(10) ->build(); + $this->assertBindingCount($result); $query = $result->query; $this->assertStringContainsString('FINAL SAMPLE 0.1', $query); @@ -3802,6 +4066,7 @@ public function testPageWithPrewhere(): void ->prewhere([Query::equal('type', ['click'])]) ->page(2, 25) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertStringContainsString('LIMIT ?', $result->query); @@ -3816,6 +4081,7 @@ public function testPageWithFinal(): void ->final() ->page(3, 10) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL', $result->query); $this->assertStringContainsString('LIMIT ?', $result->query); @@ -3830,6 +4096,7 @@ public function testPageWithSample(): void ->sample(0.5) ->page(1, 50) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.5', $result->query); $this->assertEquals([50, 0], $result->bindings); @@ -3844,6 +4111,7 @@ public function testPageWithAllClickHouseFeatures(): void ->prewhere([Query::equal('type', ['click'])]) ->page(2, 10) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -3862,6 +4130,7 @@ public function testPageWithComplexClickHouseQuery(): void ->sortDesc('ts') ->page(5, 20) ->build(); + $this->assertBindingCount($result); $query = $result->query; $this->assertStringContainsString('FINAL', $query); @@ -3897,6 +4166,7 @@ public function testChainingClickHouseMethodsWithBaseMethods(): void ->limit(10) ->offset(20) ->build(); + $this->assertBindingCount($result); $this->assertNotEmpty($result->query); } @@ -3959,6 +4229,7 @@ public function testFluentResetThenRebuild(): void ->from('logs') ->sample(0.5) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `logs` SAMPLE 0.5', $result->query); $this->assertStringNotContainsString('FINAL', $result->query); @@ -3982,6 +4253,7 @@ public function testClauseOrderSelectFromFinalSampleJoinPrewhereWhereGroupByHavi ->limit(50) ->offset(10) ->build(); + $this->assertBindingCount($result); $query = $result->query; @@ -4018,6 +4290,7 @@ public function testFinalComesAfterTableBeforeJoin(): void ->final() ->join('users', 'events.uid', 'users.id') ->build(); + $this->assertBindingCount($result); $query = $result->query; $tablePos = strpos($query, '`events`'); @@ -4036,6 +4309,7 @@ public function testSampleComesAfterFinalBeforeJoin(): void ->sample(0.1) ->join('users', 'events.uid', 'users.id') ->build(); + $this->assertBindingCount($result); $query = $result->query; $finalPos = strpos($query, 'FINAL'); @@ -4054,6 +4328,7 @@ public function testPrewhereComesAfterJoinBeforeWhere(): void ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('count', 0)]) ->build(); + $this->assertBindingCount($result); $query = $result->query; $joinPos = strpos($query, 'JOIN'); @@ -4072,6 +4347,7 @@ public function testPrewhereBeforeGroupBy(): void ->count('*', 'cnt') ->groupBy(['type']) ->build(); + $this->assertBindingCount($result); $query = $result->query; $prewherePos = strpos($query, 'PREWHERE'); @@ -4087,6 +4363,7 @@ public function testPrewhereBeforeOrderBy(): void ->prewhere([Query::equal('type', ['click'])]) ->sortDesc('ts') ->build(); + $this->assertBindingCount($result); $query = $result->query; $prewherePos = strpos($query, 'PREWHERE'); @@ -4102,6 +4379,7 @@ public function testPrewhereBeforeLimit(): void ->prewhere([Query::equal('type', ['click'])]) ->limit(10) ->build(); + $this->assertBindingCount($result); $query = $result->query; $prewherePos = strpos($query, 'PREWHERE'); @@ -4118,6 +4396,7 @@ public function testFinalSampleBeforePrewhere(): void ->sample(0.1) ->prewhere([Query::equal('type', ['click'])]) ->build(); + $this->assertBindingCount($result); $query = $result->query; $finalPos = strpos($query, 'FINAL'); @@ -4137,6 +4416,7 @@ public function testWhereBeforeHaving(): void ->groupBy(['type']) ->having([Query::greaterThan('cnt', 5)]) ->build(); + $this->assertBindingCount($result); $query = $result->query; $wherePos = strpos($query, 'WHERE'); @@ -4165,6 +4445,7 @@ public function testFullQueryAllClausesAllPositions(): void ->offset(10) ->union($other) ->build(); + $this->assertBindingCount($result); $query = $result->query; @@ -4195,6 +4476,7 @@ public function testQueriesMethodWithPrewhere(): void Query::limit(10), ]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); @@ -4212,6 +4494,7 @@ public function testQueriesMethodWithFinal(): void Query::limit(10), ]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL', $result->query); $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); @@ -4226,6 +4509,7 @@ public function testQueriesMethodWithSample(): void Query::equal('status', ['active']), ]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.5', $result->query); $this->assertStringContainsString('WHERE', $result->query); @@ -4244,6 +4528,7 @@ public function testQueriesMethodWithAllClickHouseFeatures(): void Query::limit(10), ]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); @@ -4300,6 +4585,7 @@ public function testPrewhereWithEmptyFilterValues(): void ->from('events') ->prewhere([Query::equal('type', [])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); } @@ -4312,6 +4598,7 @@ public function testVeryLongTableNameWithFinalSample(): void ->final() ->sample(0.1) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`' . $longName . '`', $result->query); $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); @@ -4379,6 +4666,7 @@ public function filter(string $table): Condition ->offset(100) ->union($other) ->build(); + $this->assertBindingCount($result); // Verify all binding types present $this->assertNotEmpty($result->bindings); @@ -4392,6 +4680,7 @@ public function testPrewhereAppearsCorrectlyWithoutJoins(): void ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('count', 5)]) ->build(); + $this->assertBindingCount($result); $query = $result->query; $this->assertStringContainsString('PREWHERE', $query); @@ -4410,6 +4699,7 @@ public function testPrewhereAppearsCorrectlyWithJoins(): void ->prewhere([Query::equal('type', ['click'])]) ->filter([Query::greaterThan('count', 5)]) ->build(); + $this->assertBindingCount($result); $query = $result->query; $joinPos = strpos($query, 'JOIN'); @@ -4429,6 +4719,7 @@ public function testFinalSampleTextInOutputWithJoins(): void ->join('users', 'events.uid', 'users.id') ->leftJoin('sessions', 'events.sid', 'sessions.id') ->build(); + $this->assertBindingCount($result); $query = $result->query; $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.1', $query); @@ -4566,6 +4857,7 @@ public function testSampleGreaterThanOne(): void public function testSampleVerySmall(): void { $result = (new Builder())->from('t')->sample(0.001)->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SAMPLE 0.001', $result->query); } // 3. Standalone Compiler Method Tests @@ -4714,6 +5006,7 @@ public function testUnionBothWithClickHouseFeatures(): void ->filter([Query::greaterThan('count', 5)]) ->union($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` FINAL', $result->query); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertStringContainsString('UNION', $result->query); @@ -4726,6 +5019,7 @@ public function testUnionAllBothWithFinal(): void $result = (new Builder())->from('a')->final() ->unionAll($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `a` FINAL', $result->query); $this->assertStringContainsString('UNION ALL (SELECT * FROM `b` FINAL)', $result->query); } @@ -4740,6 +5034,7 @@ public function testPrewhereBindingOrderWithFilterAndHaving(): void ->groupBy(['type']) ->having([Query::greaterThan('total', 10)]) ->build(); + $this->assertBindingCount($result); // Binding order: prewhere, filter, having $this->assertEquals(['click', 5, 10], $result->bindings); } @@ -4757,6 +5052,7 @@ public function filter(string $table): Condition ->cursorAfter('abc') ->sortAsc('_cursor') ->build(); + $this->assertBindingCount($result); // Binding order: prewhere, filter(none), provider, cursor $this->assertEquals(['click', 't1', 'abc'], $result->bindings); } @@ -4771,6 +5067,7 @@ public function testPrewhereMultipleFiltersBindingOrder(): void ->filter([Query::lessThan('age', 30)]) ->limit(10) ->build(); + $this->assertBindingCount($result); // prewhere bindings first, then filter, then limit $this->assertEquals(['a', 3, 30, 10], $result->bindings); } @@ -4797,6 +5094,7 @@ public function testLeftJoinWithFinalAndSample(): void ->sample(0.1) ->leftJoin('users', 'events.uid', 'users.id') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` FINAL SAMPLE 0.1 LEFT JOIN `users` ON `events`.`uid` = `users`.`id`', $result->query @@ -4809,6 +5107,7 @@ public function testRightJoinWithFinalFeature(): void ->final() ->rightJoin('users', 'events.uid', 'users.id') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` FINAL', $result->query); $this->assertStringContainsString('RIGHT JOIN', $result->query); } @@ -4819,6 +5118,7 @@ public function testCrossJoinWithPrewhereFeature(): void ->crossJoin('colors') ->prewhere([Query::equal('type', ['a'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CROSS JOIN `colors`', $result->query); $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); $this->assertEquals(['a'], $result->bindings); @@ -4829,6 +5129,7 @@ public function testJoinWithNonDefaultOperator(): void $result = (new Builder())->from('t') ->join('other', 'a', 'b', '!=') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `other` ON `a` != `b`', $result->query); } // 8. Condition Provider Position Verification @@ -4844,6 +5145,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $query = $result->query; $prewherePos = strpos($query, 'PREWHERE'); $wherePos = strpos($query, 'WHERE'); @@ -4864,6 +5166,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE _deleted = ?', $result->query); $this->assertEquals([0], $result->bindings); } @@ -4884,6 +5187,7 @@ public function testPageNegative(): void public function testPageLargeNumber(): void { $result = (new Builder())->from('t')->page(1000000, 25)->build(); + $this->assertBindingCount($result); $this->assertEquals([25, 24999975], $result->bindings); } // 10. Build Without From @@ -4966,6 +5270,7 @@ public function testHavingMultipleSubQueries(): void Query::lessThan('total', 100), ]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('HAVING `total` > ? AND `total` < ?', $result->query); $this->assertContains(5, $result->bindings); $this->assertContains(100, $result->bindings); @@ -4981,6 +5286,7 @@ public function testHavingWithOrLogic(): void Query::lessThan('total', 5), ])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('HAVING (`total` > ? OR `total` < ?)', $result->query); } // 13. Reset Property-by-Property Verification @@ -4997,6 +5303,7 @@ public function testResetClearsClickHouseProperties(): void $builder->reset()->from('other'); $result = $builder->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `other`', $result->query); $this->assertEquals([], $result->bindings); @@ -5012,6 +5319,7 @@ public function testResetFollowedByUnion(): void ->union((new Builder())->from('old')); $builder->reset()->from('b'); $result = $builder->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `b`', $result->query); $this->assertStringNotContainsString('UNION', $result->query); $this->assertStringNotContainsString('FINAL', $result->query); @@ -5031,6 +5339,7 @@ public function filter(string $table): Condition $builder->build(); $builder->reset()->from('other'); $result = $builder->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `other`', $result->query); $this->assertStringNotContainsString('FINAL', $result->query); $this->assertStringContainsString('_tenant = ?', $result->query); @@ -5047,6 +5356,7 @@ public function testFinalSamplePrewhereFilterExactSql(): void ->sortDesc('amount') ->limit(50) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `event_type` IN (?) WHERE `amount` > ? ORDER BY `amount` DESC LIMIT ?', $result->query @@ -5074,6 +5384,7 @@ public function testKitchenSinkExactSql(): void ->offset(10) ->union($sub) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT DISTINCT COUNT(*) AS `total`, `event_type` FROM `events` FINAL SAMPLE 0.1 JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `event_type` IN (?) WHERE `amount` > ? GROUP BY `event_type` HAVING `total` > ? ORDER BY `total` DESC LIMIT ? OFFSET ?) UNION (SELECT * FROM `archive` FINAL WHERE `status` IN (?))', $result->query @@ -5136,24 +5447,28 @@ public function testQueryCompileGroupByViaClickHouse(): void public function testBindingTypesPreservedInt(): void { $result = (new Builder())->from('t')->filter([Query::greaterThan('age', 18)])->build(); + $this->assertBindingCount($result); $this->assertSame([18], $result->bindings); } public function testBindingTypesPreservedFloat(): void { $result = (new Builder())->from('t')->filter([Query::greaterThan('score', 9.5)])->build(); + $this->assertBindingCount($result); $this->assertSame([9.5], $result->bindings); } public function testBindingTypesPreservedBool(): void { $result = (new Builder())->from('t')->filter([Query::equal('active', [true])])->build(); + $this->assertBindingCount($result); $this->assertSame([true], $result->bindings); } public function testBindingTypesPreservedNull(): void { $result = (new Builder())->from('t')->filter([Query::equal('val', [null])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `val` IS NULL', $result->query); $this->assertSame([], $result->bindings); } @@ -5161,6 +5476,7 @@ public function testBindingTypesPreservedNull(): void public function testEqualWithNullAndNonNull(): void { $result = (new Builder())->from('t')->filter([Query::equal('col', ['a', null])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`col` IN (?) OR `col` IS NULL)', $result->query); $this->assertSame(['a'], $result->bindings); } @@ -5168,6 +5484,7 @@ public function testEqualWithNullAndNonNull(): void public function testNotEqualWithNullOnly(): void { $result = (new Builder())->from('t')->filter([Query::notEqual('col', [null])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NOT NULL', $result->query); $this->assertSame([], $result->bindings); } @@ -5175,6 +5492,7 @@ public function testNotEqualWithNullOnly(): void public function testNotEqualWithNullAndNonNull(): void { $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', 'b', null])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`col` NOT IN (?, ?) AND `col` IS NOT NULL)', $result->query); $this->assertSame(['a', 'b'], $result->bindings); } @@ -5182,6 +5500,7 @@ public function testNotEqualWithNullAndNonNull(): void public function testBindingTypesPreservedString(): void { $result = (new Builder())->from('t')->filter([Query::equal('name', ['hello'])])->build(); + $this->assertBindingCount($result); $this->assertSame(['hello'], $result->bindings); } // 17. Raw Inside Logical Groups @@ -5194,6 +5513,7 @@ public function testRawInsideLogicalAnd(): void Query::raw('custom_func(y) > ?', [5]), ])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`x` > ? AND custom_func(y) > ?)', $result->query); $this->assertEquals([1, 5], $result->bindings); } @@ -5206,6 +5526,7 @@ public function testRawInsideLogicalOr(): void Query::raw('b IS NOT NULL', []), ])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result->query); $this->assertEquals([1], $result->bindings); } @@ -5214,6 +5535,7 @@ public function testRawInsideLogicalOr(): void public function testNegativeLimit(): void { $result = (new Builder())->from('t')->limit(-1)->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); $this->assertEquals([-1], $result->bindings); } @@ -5222,6 +5544,7 @@ public function testNegativeOffset(): void { // OFFSET without LIMIT is suppressed $result = (new Builder())->from('t')->offset(-5)->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); $this->assertEquals([], $result->bindings); } @@ -5229,6 +5552,7 @@ public function testNegativeOffset(): void public function testLimitZero(): void { $result = (new Builder())->from('t')->limit(0)->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); $this->assertEquals([0], $result->bindings); } @@ -5237,6 +5561,7 @@ public function testLimitZero(): void public function testMultipleLimitsFirstWins(): void { $result = (new Builder())->from('t')->limit(10)->limit(20)->build(); + $this->assertBindingCount($result); $this->assertEquals([10], $result->bindings); } @@ -5244,12 +5569,14 @@ public function testMultipleOffsetsFirstWins(): void { // OFFSET without LIMIT is suppressed $result = (new Builder())->from('t')->offset(5)->offset(50)->build(); + $this->assertBindingCount($result); $this->assertEquals([], $result->bindings); } public function testCursorAfterAndBeforeFirstWins(): void { $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->sortAsc('_cursor')->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`_cursor` > ?', $result->query); } // 20. Distinct + Union @@ -5258,6 +5585,7 @@ public function testDistinctWithUnion(): void { $other = (new Builder())->from('b'); $result = (new Builder())->from('a')->distinct()->union($other)->build(); + $this->assertBindingCount($result); $this->assertEquals('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result->query); } // DML: INSERT (same as standard SQL) @@ -5268,6 +5596,7 @@ public function testInsertSingleRow(): void ->into('events') ->set(['name' => 'click', 'timestamp' => '2024-01-01']) ->insert(); + $this->assertBindingCount($result); $this->assertEquals( 'INSERT INTO `events` (`name`, `timestamp`) VALUES (?, ?)', @@ -5283,6 +5612,7 @@ public function testInsertBatch(): void ->set(['name' => 'click', 'ts' => '2024-01-01']) ->set(['name' => 'view', 'ts' => '2024-01-02']) ->insert(); + $this->assertBindingCount($result); $this->assertEquals( 'INSERT INTO `events` (`name`, `ts`) VALUES (?, ?), (?, ?)', @@ -5307,6 +5637,7 @@ public function testUpdateUsesAlterTable(): void ->set(['status' => 'archived']) ->filter([Query::equal('status', ['old'])]) ->update(); + $this->assertBindingCount($result); $this->assertEquals( 'ALTER TABLE `events` UPDATE `status` = ? WHERE `status` IN (?)', @@ -5317,7 +5648,7 @@ public function testUpdateUsesAlterTable(): void public function testUpdateWithFilterHook(): void { - $hook = new class () implements Filter, \Utopia\Query\Hook { + $hook = new class () implements Filter, Hook { public function filter(string $table): Condition { return new Condition('`_tenant` = ?', ['tenant_123']); @@ -5330,6 +5661,7 @@ public function filter(string $table): Condition ->filter([Query::equal('id', [1])]) ->addHook($hook) ->update(); + $this->assertBindingCount($result); $this->assertEquals( 'ALTER TABLE `events` UPDATE `status` = ? WHERE `id` IN (?) AND `_tenant` = ?', @@ -5356,6 +5688,7 @@ public function testDeleteUsesAlterTable(): void ->from('events') ->filter([Query::lessThan('timestamp', '2024-01-01')]) ->delete(); + $this->assertBindingCount($result); $this->assertEquals( 'ALTER TABLE `events` DELETE WHERE `timestamp` < ?', @@ -5366,7 +5699,7 @@ public function testDeleteUsesAlterTable(): void public function testDeleteWithFilterHook(): void { - $hook = new class () implements Filter, \Utopia\Query\Hook { + $hook = new class () implements Filter, Hook { public function filter(string $table): Condition { return new Condition('`_tenant` = ?', ['tenant_123']); @@ -5378,6 +5711,7 @@ public function filter(string $table): Condition ->filter([Query::equal('status', ['deleted'])]) ->addHook($hook) ->delete(); + $this->assertBindingCount($result); $this->assertEquals( 'ALTER TABLE `events` DELETE WHERE `status` IN (?) AND `_tenant` = ?', @@ -5404,6 +5738,7 @@ public function testIntersect(): void ->from('users') ->intersect($other) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `users`) INTERSECT (SELECT * FROM `admins`)', @@ -5418,6 +5753,7 @@ public function testExcept(): void ->from('users') ->except($other) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `users`) EXCEPT (SELECT * FROM `banned`)', @@ -5471,6 +5807,7 @@ public function testCteWith(): void ->with('clicks', $cte) ->from('clicks') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'WITH `clicks` AS (SELECT * FROM `events` WHERE `type` IN (?)) SELECT * FROM `clicks`', @@ -5487,6 +5824,7 @@ public function testSetRawWithBindings(): void ->setRaw('count', 'count + ?', [1]) ->filter([Query::equal('id', [42])]) ->update(); + $this->assertBindingCount($result); $this->assertEquals( 'ALTER TABLE `events` UPDATE `count` = count + ? WHERE `id` IN (?)', @@ -5498,7 +5836,7 @@ public function testSetRawWithBindings(): void public function testImplementsHints(): void { - $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Hints::class, new Builder()); + $this->assertInstanceOf(Hints::class, new Builder()); } public function testHintAppendsSettings(): void @@ -5507,6 +5845,7 @@ public function testHintAppendsSettings(): void ->from('events') ->hint('max_threads=4') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SETTINGS max_threads=4', $result->query); } @@ -5518,6 +5857,7 @@ public function testMultipleHints(): void ->hint('max_threads=4') ->hint('max_memory_usage=1000000000') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SETTINGS max_threads=4, max_memory_usage=1000000000', $result->query); } @@ -5528,6 +5868,7 @@ public function testSettingsMethod(): void ->from('events') ->settings(['max_threads' => '4', 'max_memory_usage' => '1000000000']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SETTINGS max_threads=4, max_memory_usage=1000000000', $result->query); } @@ -5535,7 +5876,7 @@ public function testSettingsMethod(): void public function testImplementsWindows(): void { - $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Windows::class, new Builder()); + $this->assertInstanceOf(Windows::class, new Builder()); } public function testSelectWindowRowNumber(): void @@ -5544,6 +5885,7 @@ public function testSelectWindowRowNumber(): void ->from('events') ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['timestamp']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY `user_id` ORDER BY `timestamp` ASC) AS `rn`', $result->query); } @@ -5552,19 +5894,19 @@ public function testSelectWindowRowNumber(): void public function testDoesNotImplementSpatial(): void { $builder = new Builder(); - $this->assertNotInstanceOf(\Utopia\Query\Builder\Feature\Spatial::class, $builder); // @phpstan-ignore method.alreadyNarrowedType + $this->assertNotInstanceOf(Spatial::class, $builder); // @phpstan-ignore method.alreadyNarrowedType } public function testDoesNotImplementVectorSearch(): void { $builder = new Builder(); - $this->assertNotInstanceOf(\Utopia\Query\Builder\Feature\VectorSearch::class, $builder); // @phpstan-ignore method.alreadyNarrowedType + $this->assertNotInstanceOf(VectorSearch::class, $builder); // @phpstan-ignore method.alreadyNarrowedType } public function testDoesNotImplementJson(): void { $builder = new Builder(); - $this->assertNotInstanceOf(\Utopia\Query\Builder\Feature\Json::class, $builder); // @phpstan-ignore method.alreadyNarrowedType + $this->assertNotInstanceOf(Json::class, $builder); // @phpstan-ignore method.alreadyNarrowedType } // Reset clears hints @@ -5577,17 +5919,17 @@ public function testResetClearsHints(): void $builder->reset(); $result = $builder->from('events')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('SETTINGS', $result->query); } - // ==================== PREWHERE tests ==================== - public function testPrewhereWithSingleFilter(): void { $result = (new Builder()) ->from('t') ->prewhere([Query::equal('status', ['active'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE `status` IN (?)', $result->query); $this->assertEquals(['active'], $result->bindings); @@ -5602,6 +5944,7 @@ public function testPrewhereWithMultipleFilters(): void Query::greaterThan('age', 18), ]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE `status` IN (?) AND `age` > ?', $result->query); $this->assertEquals(['active', 18], $result->bindings); @@ -5614,6 +5957,7 @@ public function testPrewhereBeforeWhere(): void ->prewhere([Query::equal('status', ['active'])]) ->filter([Query::greaterThan('age', 18)]) ->build(); + $this->assertBindingCount($result); $prewherePos = strpos($result->query, 'PREWHERE'); $wherePos = strpos($result->query, 'WHERE'); @@ -5630,6 +5974,7 @@ public function testPrewhereBindingOrderBeforeWhere(): void ->prewhere([Query::equal('status', ['active'])]) ->filter([Query::greaterThan('age', 18)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['active', 18], $result->bindings); } @@ -5642,6 +5987,7 @@ public function testPrewhereWithJoin(): void ->prewhere([Query::equal('status', ['active'])]) ->filter([Query::greaterThan('age', 18)]) ->build(); + $this->assertBindingCount($result); $joinPos = strpos($result->query, 'JOIN'); $prewherePos = strpos($result->query, 'PREWHERE'); @@ -5654,14 +6000,13 @@ public function testPrewhereWithJoin(): void $this->assertLessThan($wherePos, $prewherePos); } - // ==================== FINAL keyword tests ==================== - public function testFinalKeywordInFromClause(): void { $result = (new Builder()) ->from('t') ->final() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `t` FINAL', $result->query); } @@ -5673,6 +6018,7 @@ public function testFinalAppearsBeforeWhere(): void ->final() ->filter([Query::equal('status', ['active'])]) ->build(); + $this->assertBindingCount($result); $finalPos = strpos($result->query, 'FINAL'); $wherePos = strpos($result->query, 'WHERE'); @@ -5689,18 +6035,18 @@ public function testFinalWithSample(): void ->final() ->sample(0.5) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `t` FINAL SAMPLE 0.5', $result->query); } - // ==================== SAMPLE tests ==================== - public function testSampleFraction(): void { $result = (new Builder()) ->from('t') ->sample(0.1) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `t` SAMPLE 0.1', $result->query); } @@ -5732,8 +6078,6 @@ public function testSampleNegativeThrows(): void ->sample(-0.5); } - // ==================== UPDATE (ALTER TABLE) tests ==================== - public function testUpdateAlterTableSyntax(): void { $result = (new Builder()) @@ -5741,6 +6085,7 @@ public function testUpdateAlterTableSyntax(): void ->set(['name' => 'Bob']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertEquals( 'ALTER TABLE `t` UPDATE `name` = ? WHERE `id` IN (?)', @@ -5777,6 +6122,7 @@ public function testUpdateWithRawSet(): void ->setRaw('counter', '`counter` + 1') ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('`counter` = `counter` + 1', $result->query); $this->assertStringContainsString('ALTER TABLE `t` UPDATE', $result->query); @@ -5789,19 +6135,19 @@ public function testUpdateWithRawSetBindings(): void ->setRaw('name', 'CONCAT(?, ?)', ['hello', ' world']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('`name` = CONCAT(?, ?)', $result->query); $this->assertEquals(['hello', ' world', 1], $result->bindings); } - // ==================== DELETE (ALTER TABLE) tests ==================== - public function testDeleteAlterTableSyntax(): void { $result = (new Builder()) ->from('t') ->filter([Query::equal('id', [1])]) ->delete(); + $this->assertBindingCount($result); $this->assertEquals( 'ALTER TABLE `t` DELETE WHERE `id` IN (?)', @@ -5828,19 +6174,19 @@ public function testDeleteWithMultipleFilters(): void Query::lessThan('age', 5), ]) ->delete(); + $this->assertBindingCount($result); $this->assertStringContainsString('WHERE `status` IN (?) AND `age` < ?', $result->query); $this->assertEquals(['old', 5], $result->bindings); } - // ==================== LIKE/Contains overrides ==================== - public function testStartsWithUsesStartsWith(): void { $result = (new Builder()) ->from('t') ->filter([Query::startsWith('name', 'foo')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('startsWith(`name`, ?)', $result->query); $this->assertEquals(['foo'], $result->bindings); @@ -5852,6 +6198,7 @@ public function testNotStartsWithUsesNotStartsWith(): void ->from('t') ->filter([Query::notStartsWith('name', 'foo')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT startsWith(`name`, ?)', $result->query); $this->assertEquals(['foo'], $result->bindings); @@ -5863,6 +6210,7 @@ public function testEndsWithUsesEndsWith(): void ->from('t') ->filter([Query::endsWith('name', 'foo')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('endsWith(`name`, ?)', $result->query); $this->assertEquals(['foo'], $result->bindings); @@ -5874,6 +6222,7 @@ public function testNotEndsWithUsesNotEndsWith(): void ->from('t') ->filter([Query::notEndsWith('name', 'foo')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT endsWith(`name`, ?)', $result->query); $this->assertEquals(['foo'], $result->bindings); @@ -5885,6 +6234,7 @@ public function testContainsSingleValueUsesPosition(): void ->from('t') ->filter([Query::contains('name', ['foo'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('position(`name`, ?) > 0', $result->query); $this->assertEquals(['foo'], $result->bindings); @@ -5896,6 +6246,7 @@ public function testContainsMultipleValuesUsesOrPosition(): void ->from('t') ->filter([Query::contains('name', ['foo', 'bar'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(position(`name`, ?) > 0 OR position(`name`, ?) > 0)', $result->query); $this->assertEquals(['foo', 'bar'], $result->bindings); @@ -5907,6 +6258,7 @@ public function testContainsAllUsesAndPosition(): void ->from('t') ->filter([Query::containsAll('name', ['foo', 'bar'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(position(`name`, ?) > 0 AND position(`name`, ?) > 0)', $result->query); $this->assertEquals(['foo', 'bar'], $result->bindings); @@ -5918,39 +6270,36 @@ public function testNotContainsSingleValue(): void ->from('t') ->filter([Query::notContains('name', ['foo'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('position(`name`, ?) = 0', $result->query); $this->assertEquals(['foo'], $result->bindings); } - // ==================== NotContains multiple ==================== - public function testNotContainsMultipleValues(): void { $result = (new Builder()) ->from('t') ->filter([Query::notContains('name', ['a', 'b'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(position(`name`, ?) = 0 AND position(`name`, ?) = 0)', $result->query); $this->assertEquals(['a', 'b'], $result->bindings); } - // ==================== Regex ==================== - public function testRegexUsesMatch(): void { $result = (new Builder()) ->from('t') ->filter([Query::regex('name', '^test')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('match(`name`, ?)', $result->query); $this->assertEquals(['^test'], $result->bindings); } - // ==================== Search throws ==================== - public function testSearchThrowsUnsupported(): void { $this->expectException(UnsupportedException::class); @@ -5961,14 +6310,13 @@ public function testSearchThrowsUnsupported(): void ->build(); } - // ==================== Hints/Settings ==================== - public function testSettingsKeyValue(): void { $result = (new Builder()) ->from('t') ->settings(['max_threads' => '4', 'enable_optimize_predicate_expression' => '1']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SETTINGS max_threads=4, enable_optimize_predicate_expression=1', $result->query); } @@ -5980,6 +6328,7 @@ public function testHintAndSettingsCombined(): void ->hint('max_threads=2') ->settings(['enable_optimize_predicate_expression' => '1']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SETTINGS max_threads=2, enable_optimize_predicate_expression=1', $result->query); } @@ -5991,6 +6340,7 @@ public function testHintsPreserveBindings(): void ->filter([Query::equal('status', ['active'])]) ->hint('max_threads=4') ->build(); + $this->assertBindingCount($result); $this->assertEquals(['active'], $result->bindings); $this->assertStringContainsString('SETTINGS max_threads=4', $result->query); @@ -6003,14 +6353,13 @@ public function testHintsWithJoin(): void ->join('u', 't.uid', 'u.id') ->hint('max_threads=4') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SETTINGS max_threads=4', $result->query); // SETTINGS must be at the very end $this->assertStringEndsWith('SETTINGS max_threads=4', $result->query); } - // ==================== CTE tests ==================== - public function testCTE(): void { $sub = (new Builder()) @@ -6021,6 +6370,7 @@ public function testCTE(): void ->with('sub', $sub) ->from('sub') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'WITH `sub` AS (SELECT * FROM `events` WHERE `type` IN (?)) SELECT * FROM `sub`', @@ -6039,6 +6389,7 @@ public function testCTERecursive(): void ->withRecursive('tree', $sub) ->from('tree') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WITH RECURSIVE `tree` AS', $result->query); } @@ -6054,19 +6405,19 @@ public function testCTEBindingOrder(): void ->from('sub') ->filter([Query::greaterThan('count', 5)]) ->build(); + $this->assertBindingCount($result); // CTE bindings come before main query bindings $this->assertEquals(['click', 5], $result->bindings); } - // ==================== Window functions ==================== - public function testWindowFunctionPartitionAndOrder(): void { $result = (new Builder()) ->from('t') ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['created_at']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY `user_id` ORDER BY `created_at` ASC) AS `rn`', $result->query); } @@ -6077,6 +6428,7 @@ public function testWindowFunctionOrderDescending(): void ->from('t') ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['-created_at']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY `user_id` ORDER BY `created_at` DESC) AS `rn`', $result->query); } @@ -6088,13 +6440,12 @@ public function testMultipleWindowFunctions(): void ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['created_at']) ->selectWindow('SUM(`amount`)', 'total', ['user_id'], null) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ROW_NUMBER() OVER', $result->query); $this->assertStringContainsString('SUM(`amount`) OVER', $result->query); } - // ==================== CASE expression ==================== - public function testSelectCaseExpression(): void { $case = (new CaseBuilder()) @@ -6107,6 +6458,7 @@ public function testSelectCaseExpression(): void ->from('t') ->selectCase($case) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CASE WHEN `status` = ? THEN ? ELSE ? END AS label', $result->query); $this->assertEquals(['active', 'Active', 'Unknown'], $result->bindings); @@ -6124,14 +6476,13 @@ public function testSetCaseInUpdate(): void ->setRaw('label', $case->sql, $case->bindings) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('ALTER TABLE `t` UPDATE', $result->query); $this->assertStringContainsString('CASE WHEN `role` = ? THEN ? ELSE ? END', $result->query); $this->assertEquals(['admin', 'Admin', 'User', 1], $result->bindings); } - // ==================== Union/Intersect/Except ==================== - public function testUnionSimple(): void { $other = (new Builder())->from('b'); @@ -6139,6 +6490,7 @@ public function testUnionSimple(): void ->from('a') ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('UNION', $result->query); $this->assertStringNotContainsString('UNION ALL', $result->query); @@ -6151,6 +6503,7 @@ public function testUnionAll(): void ->from('a') ->unionAll($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('UNION ALL', $result->query); } @@ -6163,18 +6516,18 @@ public function testUnionBindingsOrder(): void ->filter([Query::equal('x', [1])]) ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertEquals([1, 2], $result->bindings); } - // ==================== Pagination ==================== - public function testPage(): void { $result = (new Builder()) ->from('t') ->page(2, 25) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LIMIT ?', $result->query); $this->assertStringContainsString('OFFSET ?', $result->query); @@ -6188,13 +6541,12 @@ public function testCursorAfter(): void ->cursorAfter('abc') ->sortAsc('_cursor') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`_cursor` > ?', $result->query); $this->assertEquals(['abc'], $result->bindings); } - // ==================== Validation errors ==================== - public function testBuildWithoutTableThrows(): void { $this->expectException(ValidationException::class); @@ -6222,8 +6574,6 @@ public function testBatchInsertMismatchedColumnsThrows(): void ->insert(); } - // ==================== Batch insert ==================== - public function testBatchInsertMultipleRows(): void { $result = (new Builder()) @@ -6231,6 +6581,7 @@ public function testBatchInsertMultipleRows(): void ->set(['name' => 'Alice', 'age' => 30]) ->set(['name' => 'Bob', 'age' => 25]) ->insert(); + $this->assertBindingCount($result); $this->assertEquals( 'INSERT INTO `t` (`name`, `age`) VALUES (?, ?), (?, ?)', @@ -6239,12 +6590,10 @@ public function testBatchInsertMultipleRows(): void $this->assertEquals(['Alice', 30, 'Bob', 25], $result->bindings); } - // ==================== Join filter placement ==================== - public function testJoinFilterForcedToWhere(): void { $hook = new class () implements JoinFilter { - public function filterJoin(string $table, string $joinType): JoinCondition + public function filterJoin(string $table, JoinType $joinType): JoinCondition { return new JoinCondition( new Condition('`active` = ?', [1]), @@ -6258,14 +6607,13 @@ public function filterJoin(string $table, string $joinType): JoinCondition ->addHook($hook) ->leftJoin('u', 't.uid', 'u.id') ->build(); + $this->assertBindingCount($result); // ClickHouse forces all join filter conditions to WHERE placement $this->assertStringContainsString('WHERE `active` = ?', $result->query); $this->assertStringNotContainsString('ON `t`.`uid` = `u`.`id` AND', $result->query); } - // ==================== toRawSql ==================== - public function testToRawSqlClickHouseSyntax(): void { $sql = (new Builder()) @@ -6280,8 +6628,6 @@ public function testToRawSqlClickHouseSyntax(): void $this->assertStringNotContainsString('?', $sql); } - // ==================== Reset comprehensive ==================== - public function testResetClearsPrewhere(): void { $builder = (new Builder()) @@ -6292,6 +6638,7 @@ public function testResetClearsPrewhere(): void $builder->reset(); $result = $builder->from('t')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('PREWHERE', $result->query); $this->assertEquals([], $result->bindings); } @@ -6307,6 +6654,7 @@ public function testResetClearsSampleAndFinal(): void $builder->reset(); $result = $builder->from('t')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('FINAL', $result->query); $this->assertStringNotContainsString('SAMPLE', $result->query); } @@ -6317,6 +6665,7 @@ public function testEqualEmptyArrayReturnsFalse(): void ->from('t') ->filter([Query::equal('x', [])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('1 = 0', $result->query); } @@ -6327,6 +6676,7 @@ public function testEqualWithNullOnly(): void ->from('t') ->filter([Query::equal('x', [null])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`x` IS NULL', $result->query); } @@ -6337,6 +6687,7 @@ public function testEqualWithNullAndValues(): void ->from('t') ->filter([Query::equal('x', [1, null])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`x` IN (?) OR `x` IS NULL)', $result->query); $this->assertContains(1, $result->bindings); @@ -6348,6 +6699,7 @@ public function testNotEqualSingleValue(): void ->from('t') ->filter([Query::notEqual('x', 42)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`x` != ?', $result->query); $this->assertContains(42, $result->bindings); @@ -6359,6 +6711,7 @@ public function testAndFilter(): void ->from('t') ->filter([Query::and([Query::greaterThan('age', 18), Query::lessThan('age', 65)])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`age` > ? AND `age` < ?)', $result->query); } @@ -6369,6 +6722,7 @@ public function testOrFilter(): void ->from('t') ->filter([Query::or([Query::equal('role', ['admin']), Query::equal('role', ['editor'])])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`role` IN (?) OR `role` IN (?))', $result->query); } @@ -6382,6 +6736,7 @@ public function testNestedAndInsideOr(): void Query::and([Query::greaterThan('score', 80), Query::lessThan('score', 100)]), ])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('((`age` > ? AND `age` < ?) OR (`score` > ? AND `score` < ?))', $result->query); $this->assertEquals([18, 30, 80, 100], $result->bindings); @@ -6393,6 +6748,7 @@ public function testBetweenFilter(): void ->from('t') ->filter([Query::between('age', 18, 65)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`age` BETWEEN ? AND ?', $result->query); $this->assertEquals([18, 65], $result->bindings); @@ -6404,6 +6760,7 @@ public function testNotBetweenFilter(): void ->from('t') ->filter([Query::notBetween('score', 0, 50)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`score` NOT BETWEEN ? AND ?', $result->query); $this->assertEquals([0, 50], $result->bindings); @@ -6415,6 +6772,7 @@ public function testExistsMultipleAttributes(): void ->from('t') ->filter([Query::exists(['name', 'email'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`name` IS NOT NULL AND `email` IS NOT NULL)', $result->query); } @@ -6425,6 +6783,7 @@ public function testNotExistsSingle(): void ->from('t') ->filter([Query::notExists(['name'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`name` IS NULL)', $result->query); } @@ -6435,6 +6794,7 @@ public function testRawFilter(): void ->from('t') ->filter([Query::raw('score > ?', [10])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('score > ?', $result->query); $this->assertContains(10, $result->bindings); @@ -6446,6 +6806,7 @@ public function testRawFilterEmpty(): void ->from('t') ->filter([Query::raw('')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('1 = 1', $result->query); } @@ -6456,6 +6817,7 @@ public function testDottedIdentifier(): void ->from('t') ->select(['events.name']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`events`.`name`', $result->query); } @@ -6467,6 +6829,7 @@ public function testMultipleOrderBy(): void ->sortAsc('name') ->sortDesc('age') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY `name` ASC, `age` DESC', $result->query); } @@ -6478,6 +6841,7 @@ public function testDistinctWithSelect(): void ->distinct() ->select(['name']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SELECT DISTINCT `name`', $result->query); } @@ -6488,6 +6852,7 @@ public function testSumWithAlias(): void ->from('t') ->sum('amount', 'total') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SUM(`amount`) AS `total`', $result->query); } @@ -6499,6 +6864,7 @@ public function testMultipleAggregates(): void ->count('*', 'cnt') ->sum('amount', 'total') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); $this->assertStringContainsString('SUM(`amount`) AS `total`', $result->query); @@ -6510,6 +6876,7 @@ public function testIsNullFilter(): void ->from('t') ->filter([Query::isNull('deleted_at')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`deleted_at` IS NULL', $result->query); } @@ -6520,6 +6887,7 @@ public function testIsNotNullFilter(): void ->from('t') ->filter([Query::isNotNull('name')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`name` IS NOT NULL', $result->query); } @@ -6530,6 +6898,7 @@ public function testLessThan(): void ->from('t') ->filter([Query::lessThan('age', 30)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`age` < ?', $result->query); $this->assertEquals([30], $result->bindings); @@ -6541,6 +6910,7 @@ public function testLessThanEqual(): void ->from('t') ->filter([Query::lessThanEqual('age', 30)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`age` <= ?', $result->query); $this->assertEquals([30], $result->bindings); @@ -6552,6 +6922,7 @@ public function testGreaterThan(): void ->from('t') ->filter([Query::greaterThan('score', 50)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`score` > ?', $result->query); $this->assertEquals([50], $result->bindings); @@ -6563,6 +6934,7 @@ public function testGreaterThanEqual(): void ->from('t') ->filter([Query::greaterThanEqual('score', 50)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`score` >= ?', $result->query); $this->assertEquals([50], $result->bindings); @@ -6574,6 +6946,7 @@ public function testRightJoin(): void ->from('a') ->rightJoin('b', 'a.id', 'b.a_id') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('RIGHT JOIN `b` ON `a`.`id` = `b`.`a_id`', $result->query); } @@ -6584,6 +6957,7 @@ public function testCrossJoin(): void ->from('a') ->crossJoin('b') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CROSS JOIN `b`', $result->query); $this->assertStringNotContainsString(' ON ', $result->query); @@ -6596,6 +6970,7 @@ public function testPrewhereAndFilterBindingOrderVerification(): void ->prewhere([Query::equal('status', ['active'])]) ->filter([Query::greaterThan('count', 5)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['active', 5], $result->bindings); } @@ -6607,6 +6982,7 @@ public function testUpdateRawSetAndFilterBindingOrder(): void ->setRaw('count', 'count + ?', [1]) ->filter([Query::equal('status', ['active'])]) ->update(); + $this->assertBindingCount($result); $this->assertEquals([1, 'active'], $result->bindings); } @@ -6617,6 +6993,7 @@ public function testSortRandomUsesRand(): void ->from('t') ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY rand()', $result->query); } @@ -6628,6 +7005,7 @@ public function testTableAliasClickHouse(): void $result = (new Builder()) ->from('events', 'e') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` AS `e`', $result->query); } @@ -6638,6 +7016,7 @@ public function testTableAliasWithFinal(): void ->from('events', 'e') ->final() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` FINAL AS `e`', $result->query); } @@ -6648,6 +7027,7 @@ public function testTableAliasWithSample(): void ->from('events', 'e') ->sample(0.1) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` SAMPLE 0.1 AS `e`', $result->query); } @@ -6659,6 +7039,7 @@ public function testTableAliasWithFinalAndSample(): void ->final() ->sample(0.5) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.5 AS `e`', $result->query); } @@ -6672,6 +7053,7 @@ public function testFromSubClickHouse(): void ->fromSub($sub, 'sub') ->select(['user_id']) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT `user_id` FROM (SELECT `user_id` FROM `events` GROUP BY `user_id`) AS `sub`', @@ -6686,6 +7068,7 @@ public function testFilterWhereInClickHouse(): void ->from('users') ->filterWhereIn('id', $sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`id` IN (SELECT `user_id` FROM `orders`)', $result->query); } @@ -6698,6 +7081,7 @@ public function testOrderByRawClickHouse(): void ->from('events') ->orderByRaw('toDate(`created_at`) ASC') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY toDate(`created_at`) ASC', $result->query); } @@ -6709,6 +7093,7 @@ public function testGroupByRawClickHouse(): void ->count('*', 'cnt') ->groupByRaw('toDate(`created_at`)') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('GROUP BY toDate(`created_at`)', $result->query); } @@ -6721,6 +7106,7 @@ public function testCountDistinctClickHouse(): void ->from('events') ->countDistinct('user_id', 'unique_users') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(DISTINCT `user_id`) AS `unique_users` FROM `events`', @@ -6734,10 +7120,11 @@ public function testJoinWhereClickHouse(): void { $result = (new Builder()) ->from('events') - ->joinWhere('users', function (\Utopia\Query\Builder\JoinBuilder $join): void { + ->joinWhere('users', function (JoinBuilder $join): void { $join->on('events.user_id', 'users.id'); }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `users` ON `events`.`user_id` = `users`.`id`', $result->query); } @@ -6751,6 +7138,7 @@ public function testFilterExistsClickHouse(): void ->from('users') ->filterExists($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('EXISTS (SELECT `id` FROM `orders`', $result->query); } @@ -6783,6 +7171,7 @@ public function testCrossJoinAliasClickHouse(): void ->from('events') ->crossJoin('dates', 'd') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CROSS JOIN `dates` AS `d`', $result->query); } @@ -6797,6 +7186,7 @@ public function testWhereInSubqueryClickHouse(): void ->from('events') ->filterWhereIn('user_id', $sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`user_id` IN (SELECT `id` FROM `active_users`)', $result->query); } @@ -6809,6 +7199,7 @@ public function testWhereNotInSubqueryClickHouse(): void ->from('events') ->filterWhereNotIn('user_id', $sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`user_id` NOT IN (SELECT', $result->query); } @@ -6821,6 +7212,7 @@ public function testSelectSubClickHouse(): void ->from('users') ->selectSub($sub, 'event_count') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(SELECT COUNT(*) FROM `events`) AS `event_count`', $result->query); } @@ -6833,6 +7225,7 @@ public function testFromSubWithGroupByClickHouse(): void ->fromSub($sub, 'sub') ->select(['user_id']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM (SELECT `user_id` FROM `events`', $result->query); $this->assertStringContainsString(') AS `sub`', $result->query); @@ -6848,6 +7241,7 @@ public function testFilterNotExistsClickHouse(): void ->from('users') ->filterNotExists($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT EXISTS (SELECT', $result->query); } @@ -6862,6 +7256,7 @@ public function testHavingRawClickHouse(): void ->groupBy(['user_id']) ->havingRaw('COUNT(*) > ?', [10]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('HAVING COUNT(*) > ?', $result->query); $this->assertEquals([10], $result->bindings); @@ -6876,6 +7271,7 @@ public function testTableAliasWithFinalSampleAndAlias(): void ->final() ->sample(0.5) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FINAL', $result->query); $this->assertStringContainsString('SAMPLE', $result->query); @@ -6888,11 +7284,12 @@ public function testJoinWhereLeftJoinClickHouse(): void { $result = (new Builder()) ->from('events') - ->joinWhere('users', function (\Utopia\Query\Builder\JoinBuilder $join): void { + ->joinWhere('users', function (JoinBuilder $join): void { $join->on('events.user_id', 'users.id') ->where('users.active', '=', 1); - }, 'LEFT JOIN') + }, JoinType::Left) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LEFT JOIN `users` ON', $result->query); $this->assertEquals([1], $result->bindings); @@ -6904,10 +7301,11 @@ public function testJoinWhereWithAliasClickHouse(): void { $result = (new Builder()) ->from('events', 'e') - ->joinWhere('users', function (\Utopia\Query\Builder\JoinBuilder $join): void { + ->joinWhere('users', function (JoinBuilder $join): void { $join->on('e.user_id', 'u.id'); - }, 'JOIN', 'u') + }, JoinType::Inner, 'u') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `users` AS `u`', $result->query); } @@ -6918,11 +7316,12 @@ public function testJoinWhereMultipleOnsClickHouse(): void { $result = (new Builder()) ->from('events') - ->joinWhere('users', function (\Utopia\Query\Builder\JoinBuilder $join): void { + ->joinWhere('users', function (JoinBuilder $join): void { $join->on('events.user_id', 'users.id') ->on('events.tenant_id', 'users.tenant_id'); }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString( 'ON `events`.`user_id` = `users`.`id` AND `events`.`tenant_id` = `users`.`tenant_id`', @@ -6951,6 +7350,7 @@ public function testCountDistinctWithoutAliasClickHouse(): void ->from('events') ->countDistinct('user_id') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(DISTINCT `user_id`)', $result->query); $this->assertStringNotContainsString(' AS ', $result->query); @@ -6968,6 +7368,7 @@ public function testMultipleSubqueriesCombined(): void ->filterWhereIn('user_id', $sub1) ->filterWhereNotIn('user_id', $sub2) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('IN (SELECT', $result->query); $this->assertStringContainsString('NOT IN (SELECT', $result->query); @@ -6984,6 +7385,7 @@ public function testPrewhereWithSubquery(): void ->prewhere([Query::equal('type', ['click'])]) ->filterWhereIn('user_id', $sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('PREWHERE', $result->query); $this->assertStringContainsString('IN (SELECT', $result->query); @@ -6998,8 +7400,489 @@ public function testSettingsStillAppear(): void ->settings(['max_threads' => '4']) ->orderByRaw('`created_at` DESC') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SETTINGS max_threads=4', $result->query); $this->assertStringContainsString('ORDER BY `created_at` DESC', $result->query); } + + public function testExactSimpleSelect(): void + { + $result = (new Builder()) + ->from('users') + ->select(['name', 'email']) + ->filter([Query::equal('status', ['active'])]) + ->sortAsc('name') + ->limit(25) + ->build(); + + $this->assertSame( + 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) ORDER BY `name` ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['active', 25], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactSelectWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['id', 'total']) + ->filter([ + Query::greaterThan('total', 100), + Query::lessThanEqual('total', 5000), + Query::equal('status', ['paid', 'shipped']), + Query::isNotNull('shipped_at'), + ]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `total` FROM `orders` WHERE `total` > ? AND `total` <= ? AND `status` IN (?, ?) AND `shipped_at` IS NOT NULL', + $result->query + ); + $this->assertEquals([100, 5000, 'paid', 'shipped'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactPrewhere(): void + { + $result = (new Builder()) + ->from('hits') + ->select(['url', 'count']) + ->prewhere([Query::equal('site_id', [42])]) + ->filter([Query::greaterThan('count', 10)]) + ->build(); + + $this->assertSame( + 'SELECT `url`, `count` FROM `hits` PREWHERE `site_id` IN (?) WHERE `count` > ?', + $result->query + ); + $this->assertEquals([42, 10], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->select(['user_id', 'event_type']) + ->build(); + + $this->assertSame( + 'SELECT `user_id`, `event_type` FROM `events` FINAL', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactSample(): void + { + $result = (new Builder()) + ->from('pageviews') + ->sample(0.1) + ->select(['url']) + ->build(); + + $this->assertSame( + 'SELECT `url` FROM `pageviews` SAMPLE 0.1', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactFinalSamplePrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('event_type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->sortDesc('timestamp') + ->limit(100) + ->build(); + + $this->assertSame( + 'SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `event_type` IN (?) WHERE `count` > ? ORDER BY `timestamp` DESC LIMIT ?', + $result->query + ); + $this->assertEquals(['click', 5, 100], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactSettings(): void + { + $result = (new Builder()) + ->from('logs') + ->select(['message']) + ->filter([Query::equal('level', ['error'])]) + ->settings(['max_threads' => '8']) + ->build(); + + $this->assertSame( + 'SELECT `message` FROM `logs` WHERE `level` IN (?) SETTINGS max_threads=8', + $result->query + ); + $this->assertEquals(['error'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'age' => 30]) + ->set(['name' => 'Bob', 'age' => 25]) + ->insert(); + + $this->assertSame( + 'INSERT INTO `users` (`name`, `age`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertEquals(['Alice', 30, 'Bob', 25], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAlterTableUpdate(): void + { + $result = (new Builder()) + ->from('events') + ->set(['status' => 'archived']) + ->filter([Query::equal('year', [2023])]) + ->update(); + + $this->assertSame( + 'ALTER TABLE `events` UPDATE `status` = ? WHERE `year` IN (?)', + $result->query + ); + $this->assertEquals(['archived', 2023], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAlterTableDelete(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::lessThan('created_at', '2023-01-01')]) + ->delete(); + + $this->assertSame( + 'ALTER TABLE `events` DELETE WHERE `created_at` < ?', + $result->query + ); + $this->assertEquals(['2023-01-01'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactMultipleJoins(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['orders.id', 'users.name', 'products.title']) + ->join('users', 'orders.user_id', 'users.id') + ->leftJoin('products', 'orders.product_id', 'products.id') + ->filter([Query::greaterThan('orders.total', 50)]) + ->build(); + + $this->assertSame( + 'SELECT `orders`.`id`, `users`.`name`, `products`.`title` FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` LEFT JOIN `products` ON `orders`.`product_id` = `products`.`id` WHERE `orders`.`total` > ?', + $result->query + ); + $this->assertEquals([50], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactCte(): void + { + $cteQuery = (new Builder()) + ->from('events') + ->select(['user_id']) + ->filter([Query::equal('event_type', ['purchase'])]); + + $result = (new Builder()) + ->with('buyers', $cteQuery) + ->from('users') + ->select(['name', 'email']) + ->filterWhereIn('id', (new Builder())->from('buyers')->select(['user_id'])) + ->build(); + + $this->assertSame( + 'WITH `buyers` AS (SELECT `user_id` FROM `events` WHERE `event_type` IN (?)) SELECT `name`, `email` FROM `users` WHERE `id` IN (SELECT `user_id` FROM `buyers`)', + $result->query + ); + $this->assertEquals(['purchase'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactUnionAll(): void + { + $archive = (new Builder()) + ->from('events_2023') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]); + + $result = (new Builder()) + ->from('events_2024') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]) + ->unionAll($archive) + ->build(); + + $this->assertSame( + '(SELECT `id`, `name` FROM `events_2024` WHERE `status` IN (?)) UNION ALL (SELECT `id`, `name` FROM `events_2023` WHERE `status` IN (?))', + $result->query + ); + $this->assertEquals(['active', 'active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactWindowFunction(): void + { + $result = (new Builder()) + ->from('sales') + ->select(['employee_id', 'amount']) + ->selectWindow('ROW_NUMBER()', 'rn', ['department_id'], ['-amount']) + ->build(); + + $this->assertSame( + 'SELECT `employee_id`, `amount`, ROW_NUMBER() OVER (PARTITION BY `department_id` ORDER BY `amount` DESC) AS `rn` FROM `sales`', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAggregationGroupByHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'order_count') + ->select(['customer_id']) + ->groupBy(['customer_id']) + ->having([Query::greaterThan('order_count', 5)]) + ->sortDesc('order_count') + ->build(); + + $this->assertSame( + 'SELECT COUNT(*) AS `order_count`, `customer_id` FROM `orders` GROUP BY `customer_id` HAVING `order_count` > ? ORDER BY `order_count` DESC', + $result->query + ); + $this->assertEquals([5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactSubqueryWhereIn(): void + { + $sub = (new Builder()) + ->from('blacklist') + ->select(['user_id']) + ->filter([Query::equal('active', [1])]); + + $result = (new Builder()) + ->from('events') + ->select(['id', 'user_id', 'action']) + ->filterWhereNotIn('user_id', $sub) + ->build(); + + $this->assertSame( + 'SELECT `id`, `user_id`, `action` FROM `events` WHERE `user_id` NOT IN (SELECT `user_id` FROM `blacklist` WHERE `active` IN (?))', + $result->query + ); + $this->assertEquals([1], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactExistsSubquery(): void + { + $sub = (new Builder()) + ->from('orders') + ->selectRaw('1') + ->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filterExists($sub) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE EXISTS (SELECT 1 FROM `orders` WHERE `orders`.`user_id` = `users`.`id`)', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactFromSubquery(): void + { + $sub = (new Builder()) + ->from('events') + ->select(['user_id']) + ->count('*', 'cnt') + ->groupBy(['user_id']); + + $result = (new Builder()) + ->fromSub($sub, 'sub') + ->select(['user_id', 'cnt']) + ->filter([Query::greaterThan('cnt', 10)]) + ->build(); + + $this->assertSame( + 'SELECT `user_id`, `cnt` FROM (SELECT COUNT(*) AS `cnt`, `user_id` FROM `events` GROUP BY `user_id`) AS `sub` WHERE `cnt` > ?', + $result->query + ); + $this->assertEquals([10], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactSelectSubquery(): void + { + $sub = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->selectSub($sub, 'order_count') + ->build(); + + $this->assertSame( + 'SELECT `id`, `name`, (SELECT COUNT(*) AS `cnt` FROM `orders` WHERE `orders`.`user_id` = `users`.`id`) AS `order_count` FROM `users`', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactNestedWhereGroups(): void + { + $result = (new Builder()) + ->from('products') + ->select(['id', 'name', 'price']) + ->filter([ + Query::and([ + Query::or([ + Query::equal('category', ['electronics']), + Query::equal('category', ['books']), + ]), + Query::greaterThan('price', 10), + Query::lessThan('price', 1000), + ]), + ]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name`, `price` FROM `products` WHERE ((`category` IN (?) OR `category` IN (?)) AND `price` > ? AND `price` < ?)', + $result->query + ); + $this->assertEquals(['electronics', 'books', 10, 1000], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactInsertSelect(): void + { + $source = (new Builder()) + ->from('events') + ->select(['user_id', 'event_type']) + ->filter([Query::equal('year', [2024])]); + + $result = (new Builder()) + ->into('events_archive') + ->fromSelect(['user_id', 'event_type'], $source) + ->insertSelect(); + + $this->assertSame( + 'INSERT INTO `events_archive` (`user_id`, `event_type`) SELECT `user_id`, `event_type` FROM `events` WHERE `year` IN (?)', + $result->query + ); + $this->assertEquals([2024], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDistinctWithOffset(): void + { + $result = (new Builder()) + ->from('logs') + ->distinct() + ->select(['source', 'level']) + ->limit(20) + ->offset(40) + ->build(); + + $this->assertSame( + 'SELECT DISTINCT `source`, `level` FROM `logs` LIMIT ? OFFSET ?', + $result->query + ); + $this->assertEquals([20, 40], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactCaseInSelect(): void + { + $case = (new CaseBuilder()) + ->when('`status` = ?', '?', ['active'], ['Active']) + ->when('`status` = ?', '?', ['inactive'], ['Inactive']) + ->elseResult('?', ['Unknown']) + ->alias('`status_label`') + ->build(); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->selectCase($case) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name`, CASE WHEN `status` = ? THEN ? WHEN `status` = ? THEN ? ELSE ? END AS `status_label` FROM `users`', + $result->query + ); + $this->assertEquals(['active', 'Active', 'inactive', 'Inactive', 'Unknown'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactHintSettings(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->filter([Query::equal('type', ['click'])]) + ->settings([ + 'max_threads' => '4', + 'max_memory_usage' => '10000000000', + ]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `events` WHERE `type` IN (?) SETTINGS max_threads=4, max_memory_usage=10000000000', + $result->query + ); + $this->assertEquals(['click'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactPrewhereWithJoin(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.user_id', 'users.id') + ->select(['events.id', 'users.name']) + ->prewhere([Query::equal('events.event_type', ['purchase'])]) + ->filter([Query::greaterThan('users.age', 21)]) + ->sortDesc('events.created_at') + ->limit(50) + ->build(); + + $this->assertSame( + 'SELECT `events`.`id`, `users`.`name` FROM `events` JOIN `users` ON `events`.`user_id` = `users`.`id` PREWHERE `events`.`event_type` IN (?) WHERE `users`.`age` > ? ORDER BY `events`.`created_at` DESC LIMIT ?', + $result->query + ); + $this->assertEquals(['purchase', 21, 50], $result->bindings); + $this->assertBindingCount($result); + } } diff --git a/tests/Query/Builder/MySQLTest.php b/tests/Query/Builder/MySQLTest.php index c33aeab..3436d0d 100644 --- a/tests/Query/Builder/MySQLTest.php +++ b/tests/Query/Builder/MySQLTest.php @@ -3,31 +3,43 @@ namespace Tests\Query\Builder; use PHPUnit\Framework\TestCase; +use Tests\Query\AssertsBindingCount; use Utopia\Query\Builder\Case\Builder as CaseBuilder; +use Utopia\Query\Builder\Case\Expression; use Utopia\Query\Builder\Condition; use Utopia\Query\Builder\Feature\Aggregates; use Utopia\Query\Builder\Feature\CTEs; use Utopia\Query\Builder\Feature\Deletes; +use Utopia\Query\Builder\Feature\Hints; use Utopia\Query\Builder\Feature\Hooks; use Utopia\Query\Builder\Feature\Inserts; use Utopia\Query\Builder\Feature\Joins; +use Utopia\Query\Builder\Feature\Json; use Utopia\Query\Builder\Feature\Locking; use Utopia\Query\Builder\Feature\Selects; +use Utopia\Query\Builder\Feature\Spatial; use Utopia\Query\Builder\Feature\Transactions; use Utopia\Query\Builder\Feature\Unions; use Utopia\Query\Builder\Feature\Updates; use Utopia\Query\Builder\Feature\Upsert; +use Utopia\Query\Builder\Feature\VectorSearch; +use Utopia\Query\Builder\Feature\Windows; +use Utopia\Query\Builder\JoinBuilder; +use Utopia\Query\Builder\JoinType; use Utopia\Query\Builder\MySQL as Builder; use Utopia\Query\Compiler; use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Exception\ValidationException; +use Utopia\Query\Hook; use Utopia\Query\Hook\Attribute; use Utopia\Query\Hook\Attribute\Map as AttributeMap; use Utopia\Query\Hook\Filter; +use Utopia\Query\Method; use Utopia\Query\Query; class MySQLTest extends TestCase { + use AssertsBindingCount; public function testImplementsCompiler(): void { $builder = new Builder(); @@ -117,6 +129,7 @@ public function testFluentSelectFromFilterSortLimitOffset(): void ->limit(25) ->offset(0) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', @@ -138,6 +151,7 @@ public function testBatchModeProducesSameOutput(): void Query::offset(0), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', @@ -152,6 +166,7 @@ public function testEqual(): void ->from('t') ->filter([Query::equal('status', ['active', 'pending'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?, ?)', $result->query); $this->assertEquals(['active', 'pending'], $result->bindings); @@ -163,6 +178,7 @@ public function testNotEqualSingle(): void ->from('t') ->filter([Query::notEqual('role', 'guest')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `role` != ?', $result->query); $this->assertEquals(['guest'], $result->bindings); @@ -174,6 +190,7 @@ public function testNotEqualMultiple(): void ->from('t') ->filter([Query::notEqual('role', ['guest', 'banned'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result->query); $this->assertEquals(['guest', 'banned'], $result->bindings); @@ -185,6 +202,7 @@ public function testLessThan(): void ->from('t') ->filter([Query::lessThan('price', 100)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `price` < ?', $result->query); $this->assertEquals([100], $result->bindings); @@ -196,6 +214,7 @@ public function testLessThanEqual(): void ->from('t') ->filter([Query::lessThanEqual('price', 100)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `price` <= ?', $result->query); $this->assertEquals([100], $result->bindings); @@ -207,6 +226,7 @@ public function testGreaterThan(): void ->from('t') ->filter([Query::greaterThan('age', 18)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `age` > ?', $result->query); $this->assertEquals([18], $result->bindings); @@ -218,6 +238,7 @@ public function testGreaterThanEqual(): void ->from('t') ->filter([Query::greaterThanEqual('score', 90)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `score` >= ?', $result->query); $this->assertEquals([90], $result->bindings); @@ -229,6 +250,7 @@ public function testBetween(): void ->from('t') ->filter([Query::between('age', 18, 65)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); $this->assertEquals([18, 65], $result->bindings); @@ -240,6 +262,7 @@ public function testNotBetween(): void ->from('t') ->filter([Query::notBetween('age', 18, 65)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `age` NOT BETWEEN ? AND ?', $result->query); $this->assertEquals([18, 65], $result->bindings); @@ -251,6 +274,7 @@ public function testStartsWith(): void ->from('t') ->filter([Query::startsWith('name', 'Jo')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); $this->assertEquals(['Jo%'], $result->bindings); @@ -262,6 +286,7 @@ public function testNotStartsWith(): void ->from('t') ->filter([Query::notStartsWith('name', 'Jo')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `name` NOT LIKE ?', $result->query); $this->assertEquals(['Jo%'], $result->bindings); @@ -273,6 +298,7 @@ public function testEndsWith(): void ->from('t') ->filter([Query::endsWith('email', '.com')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `email` LIKE ?', $result->query); $this->assertEquals(['%.com'], $result->bindings); @@ -284,6 +310,7 @@ public function testNotEndsWith(): void ->from('t') ->filter([Query::notEndsWith('email', '.com')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `email` NOT LIKE ?', $result->query); $this->assertEquals(['%.com'], $result->bindings); @@ -295,6 +322,7 @@ public function testContainsSingle(): void ->from('t') ->filter([Query::contains('bio', ['php'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); $this->assertEquals(['%php%'], $result->bindings); @@ -306,6 +334,7 @@ public function testContainsMultiple(): void ->from('t') ->filter([Query::contains('bio', ['php', 'js'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`bio` LIKE ? OR `bio` LIKE ?)', $result->query); $this->assertEquals(['%php%', '%js%'], $result->bindings); @@ -317,6 +346,7 @@ public function testContainsAny(): void ->from('t') ->filter([Query::containsAny('tags', ['a', 'b'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `tags` IN (?, ?)', $result->query); $this->assertEquals(['a', 'b'], $result->bindings); @@ -328,6 +358,7 @@ public function testContainsAll(): void ->from('t') ->filter([Query::containsAll('perms', ['read', 'write'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`perms` LIKE ? AND `perms` LIKE ?)', $result->query); $this->assertEquals(['%read%', '%write%'], $result->bindings); @@ -339,6 +370,7 @@ public function testNotContainsSingle(): void ->from('t') ->filter([Query::notContains('bio', ['php'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result->query); $this->assertEquals(['%php%'], $result->bindings); @@ -350,6 +382,7 @@ public function testNotContainsMultiple(): void ->from('t') ->filter([Query::notContains('bio', ['php', 'js'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`bio` NOT LIKE ? AND `bio` NOT LIKE ?)', $result->query); $this->assertEquals(['%php%', '%js%'], $result->bindings); @@ -361,6 +394,7 @@ public function testSearch(): void ->from('t') ->filter([Query::search('content', 'hello')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?)', $result->query); $this->assertEquals(['hello'], $result->bindings); @@ -372,6 +406,7 @@ public function testNotSearch(): void ->from('t') ->filter([Query::notSearch('content', 'hello')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?))', $result->query); $this->assertEquals(['hello'], $result->bindings); @@ -383,6 +418,7 @@ public function testRegex(): void ->from('t') ->filter([Query::regex('slug', '^[a-z]+$')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result->query); $this->assertEquals(['^[a-z]+$'], $result->bindings); @@ -394,6 +430,7 @@ public function testIsNull(): void ->from('t') ->filter([Query::isNull('deleted')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `deleted` IS NULL', $result->query); $this->assertEquals([], $result->bindings); @@ -405,6 +442,7 @@ public function testIsNotNull(): void ->from('t') ->filter([Query::isNotNull('verified')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `verified` IS NOT NULL', $result->query); $this->assertEquals([], $result->bindings); @@ -416,6 +454,7 @@ public function testExists(): void ->from('t') ->filter([Query::exists(['name', 'email'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`name` IS NOT NULL AND `email` IS NOT NULL)', $result->query); $this->assertEquals([], $result->bindings); @@ -427,6 +466,7 @@ public function testNotExists(): void ->from('t') ->filter([Query::notExists(['legacy'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`legacy` IS NULL)', $result->query); $this->assertEquals([], $result->bindings); @@ -443,6 +483,7 @@ public function testAndLogical(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`age` > ? AND `status` IN (?))', $result->query); $this->assertEquals([18, 'active'], $result->bindings); @@ -459,6 +500,7 @@ public function testOrLogical(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`role` IN (?) OR `role` IN (?))', $result->query); $this->assertEquals(['admin', 'mod'], $result->bindings); @@ -478,6 +520,7 @@ public function testDeeplyNested(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE (`age` > ? AND (`role` IN (?) OR `role` IN (?)))', @@ -492,6 +535,7 @@ public function testSortAsc(): void ->from('t') ->sortAsc('name') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC', $result->query); } @@ -502,6 +546,7 @@ public function testSortDesc(): void ->from('t') ->sortDesc('score') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` ORDER BY `score` DESC', $result->query); } @@ -512,6 +557,7 @@ public function testSortRandom(): void ->from('t') ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` ORDER BY RAND()', $result->query); } @@ -523,6 +569,7 @@ public function testMultipleSorts(): void ->sortAsc('name') ->sortDesc('age') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC, `age` DESC', $result->query); } @@ -533,6 +580,7 @@ public function testLimitOnly(): void ->from('t') ->limit(10) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); $this->assertEquals([10], $result->bindings); @@ -545,6 +593,7 @@ public function testOffsetOnly(): void ->from('t') ->offset(50) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); $this->assertEquals([], $result->bindings); @@ -556,6 +605,7 @@ public function testCursorAfter(): void ->from('t') ->cursorAfter('abc123') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `_cursor` > ?', $result->query); $this->assertEquals(['abc123'], $result->bindings); @@ -567,6 +617,7 @@ public function testCursorBefore(): void ->from('t') ->cursorBefore('xyz789') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `_cursor` < ?', $result->query); $this->assertEquals(['xyz789'], $result->bindings); @@ -586,6 +637,7 @@ public function testFullCombinedQuery(): void ->limit(25) ->offset(10) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT `id`, `name` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC, `age` DESC LIMIT ? OFFSET ?', @@ -601,6 +653,7 @@ public function testMultipleFilterCalls(): void ->filter([Query::equal('a', [1])]) ->filter([Query::equal('b', [2])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?)', $result->query); $this->assertEquals([1, 2], $result->bindings); @@ -622,6 +675,7 @@ public function testResetClearsState(): void ->from('orders') ->filter([Query::greaterThan('total', 100)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `orders` WHERE `total` > ?', $result->query); $this->assertEquals([100], $result->bindings); @@ -638,6 +692,7 @@ public function testAttributeResolver(): void ->filter([Query::equal('$id', ['abc'])]) ->sortAsc('$createdAt') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `users` WHERE `_uid` IN (?) ORDER BY `_createdAt` ASC', @@ -661,6 +716,7 @@ public function resolve(string $attribute): string ->addHook($prefixHook) ->filter([Query::equal('name', ['Alice'])]) ->build(); + $this->assertBindingCount($result); // First hook maps name→full_name, second prepends col_ $this->assertEquals( @@ -691,6 +747,7 @@ public function resolve(string $attribute): string ->addHook($hook) ->filter([Query::equal('$id', ['abc'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `users` WHERE `_uid` IN (?) AND _tenant = ?', @@ -715,6 +772,7 @@ public function filter(string $table): Condition ->addHook($hook) ->filter([Query::equal('status', ['active'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( "SELECT * FROM `users` WHERE `status` IN (?) AND _uid IN (SELECT _document FROM users_perms WHERE _type = 'read')", @@ -737,6 +795,7 @@ public function filter(string $table): Condition ->addHook($hook) ->filter([Query::equal('status', ['active'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `docs` WHERE `status` IN (?) AND _tenant = ?', @@ -763,6 +822,7 @@ public function filter(string $table): Condition ->limit(10) ->offset(5) ->build(); + $this->assertBindingCount($result); // binding order: filter, hook, cursor, limit, offset $this->assertEquals(['active', 't1', 'cursor_val', 10, 5], $result->bindings); @@ -773,6 +833,7 @@ public function testDefaultSelectStar(): void $result = (new Builder()) ->from('t') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); } @@ -783,6 +844,7 @@ public function testCountStar(): void ->from('t') ->count() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT COUNT(*) FROM `t`', $result->query); $this->assertEquals([], $result->bindings); @@ -794,6 +856,7 @@ public function testCountWithAlias(): void ->from('t') ->count('*', 'total') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT COUNT(*) AS `total` FROM `t`', $result->query); } @@ -804,6 +867,7 @@ public function testSumColumn(): void ->from('orders') ->sum('price', 'total_price') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT SUM(`price`) AS `total_price` FROM `orders`', $result->query); } @@ -814,6 +878,7 @@ public function testAvgColumn(): void ->from('t') ->avg('score') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT AVG(`score`) FROM `t`', $result->query); } @@ -824,6 +889,7 @@ public function testMinColumn(): void ->from('t') ->min('price') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT MIN(`price`) FROM `t`', $result->query); } @@ -834,6 +900,7 @@ public function testMaxColumn(): void ->from('t') ->max('price') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT MAX(`price`) FROM `t`', $result->query); } @@ -846,6 +913,7 @@ public function testAggregationWithSelection(): void ->select(['status']) ->groupBy(['status']) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(*) AS `total`, `status` FROM `orders` GROUP BY `status`', @@ -860,6 +928,7 @@ public function testGroupBy(): void ->count('*', 'total') ->groupBy(['status']) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`', @@ -874,6 +943,7 @@ public function testGroupByMultiple(): void ->count('*', 'total') ->groupBy(['status', 'country']) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`, `country`', @@ -889,6 +959,7 @@ public function testHaving(): void ->groupBy(['status']) ->having([Query::greaterThan('total', 5)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status` HAVING `total` > ?', @@ -904,6 +975,7 @@ public function testDistinct(): void ->distinct() ->select(['status']) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT DISTINCT `status` FROM `t`', $result->query); } @@ -914,6 +986,7 @@ public function testDistinctStar(): void ->from('t') ->distinct() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT DISTINCT * FROM `t`', $result->query); } @@ -924,6 +997,7 @@ public function testJoin(): void ->from('users') ->join('orders', 'users.id', 'orders.user_id') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id`', @@ -937,6 +1011,7 @@ public function testLeftJoin(): void ->from('users') ->leftJoin('profiles', 'users.id', 'profiles.user_id') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `users` LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`user_id`', @@ -950,6 +1025,7 @@ public function testRightJoin(): void ->from('users') ->rightJoin('orders', 'users.id', 'orders.user_id') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `users` RIGHT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', @@ -963,6 +1039,7 @@ public function testCrossJoin(): void ->from('sizes') ->crossJoin('colors') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `sizes` CROSS JOIN `colors`', @@ -977,6 +1054,7 @@ public function testJoinWithFilter(): void ->join('orders', 'users.id', 'orders.user_id') ->filter([Query::greaterThan('orders.total', 100)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ?', @@ -991,6 +1069,7 @@ public function testRawFilter(): void ->from('t') ->filter([Query::raw('score > ? AND score < ?', [10, 100])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE score > ? AND score < ?', $result->query); $this->assertEquals([10, 100], $result->bindings); @@ -1002,6 +1081,7 @@ public function testRawFilterNoBindings(): void ->from('t') ->filter([Query::raw('1 = 1')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result->query); $this->assertEquals([], $result->bindings); @@ -1015,6 +1095,7 @@ public function testUnion(): void ->filter([Query::equal('status', ['active'])]) ->union($admins) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `users` WHERE `status` IN (?)) UNION (SELECT * FROM `admins` WHERE `role` IN (?))', @@ -1030,6 +1111,7 @@ public function testUnionAll(): void ->from('current') ->unionAll($other) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `current`) UNION ALL (SELECT * FROM `archive`)', @@ -1043,6 +1125,7 @@ public function testWhenTrue(): void ->from('t') ->when(true, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?)', $result->query); $this->assertEquals(['active'], $result->bindings); @@ -1054,6 +1137,7 @@ public function testWhenFalse(): void ->from('t') ->when(false, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); $this->assertEquals([], $result->bindings); @@ -1065,6 +1149,7 @@ public function testPage(): void ->from('t') ->page(3, 10) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); $this->assertEquals([10, 20], $result->bindings); @@ -1076,6 +1161,7 @@ public function testPageDefaultPerPage(): void ->from('t') ->page(1) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); $this->assertEquals([25, 0], $result->bindings); @@ -1118,6 +1204,7 @@ public function testCombinedAggregationJoinGroupByHaving(): void ->sortDesc('total_amount') ->limit(10) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(*) AS `order_count`, SUM(`total`) AS `total_amount`, `users`.`name` FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` GROUP BY `users`.`name` HAVING `order_count` > ? ORDER BY `total_amount` DESC LIMIT ?', @@ -1137,6 +1224,7 @@ public function testResetClearsUnions(): void $builder->reset(); $result = $builder->from('fresh')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `fresh`', $result->query); } @@ -1149,6 +1237,7 @@ public function testCountWithNamedColumn(): void ->from('t') ->count('id') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT COUNT(`id`) FROM `t`', $result->query); } @@ -1159,6 +1248,7 @@ public function testCountWithEmptyStringAttribute(): void ->from('t') ->count('') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT COUNT(*) FROM `t`', $result->query); } @@ -1173,6 +1263,7 @@ public function testMultipleAggregations(): void ->min('age', 'youngest') ->max('age', 'oldest') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(*) AS `cnt`, SUM(`price`) AS `total`, AVG(`score`) AS `avg_score`, MIN(`age`) AS `youngest`, MAX(`age`) AS `oldest` FROM `t`', @@ -1187,6 +1278,7 @@ public function testAggregationWithoutGroupBy(): void ->from('orders') ->sum('total', 'grand_total') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT SUM(`total`) AS `grand_total` FROM `orders`', $result->query); } @@ -1198,6 +1290,7 @@ public function testAggregationWithFilter(): void ->count('*', 'total') ->filter([Query::equal('status', ['completed'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(*) AS `total` FROM `orders` WHERE `status` IN (?)', @@ -1213,6 +1306,7 @@ public function testAggregationWithoutAlias(): void ->count() ->sum('price') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT COUNT(*), SUM(`price`) FROM `t`', $result->query); } @@ -1223,6 +1317,7 @@ public function testGroupByEmptyArray(): void ->from('t') ->groupBy([]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); } @@ -1235,6 +1330,7 @@ public function testMultipleGroupByCalls(): void ->groupBy(['status']) ->groupBy(['country']) ->build(); + $this->assertBindingCount($result); // Both groupBy calls should merge since groupByType merges values $this->assertStringContainsString('GROUP BY', $result->query); @@ -1250,6 +1346,7 @@ public function testHavingEmptyArray(): void ->groupBy(['status']) ->having([]) ->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('HAVING', $result->query); } @@ -1266,6 +1363,7 @@ public function testHavingMultipleConditions(): void Query::lessThan('sum_price', 1000), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(*) AS `total`, SUM(`price`) AS `sum_price` FROM `t` GROUP BY `status` HAVING `total` > ? AND `sum_price` < ?', @@ -1287,6 +1385,7 @@ public function testHavingWithLogicalOr(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('HAVING (`total` > ? OR `total` < ?)', $result->query); $this->assertEquals([10, 2], $result->bindings); @@ -1300,6 +1399,7 @@ public function testHavingWithoutGroupBy(): void ->count('*', 'total') ->having([Query::greaterThan('total', 0)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('HAVING', $result->query); $this->assertStringNotContainsString('GROUP BY', $result->query); @@ -1314,6 +1414,7 @@ public function testMultipleHavingCalls(): void ->having([Query::greaterThan('total', 1)]) ->having([Query::lessThan('total', 100)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('HAVING `total` > ? AND `total` < ?', $result->query); $this->assertEquals([1, 100], $result->bindings); @@ -1326,6 +1427,7 @@ public function testDistinctWithAggregation(): void ->distinct() ->count('*', 'total') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT DISTINCT COUNT(*) AS `total` FROM `t`', $result->query); } @@ -1338,6 +1440,7 @@ public function testDistinctMultipleCalls(): void ->distinct() ->distinct() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT DISTINCT * FROM `t`', $result->query); } @@ -1350,6 +1453,7 @@ public function testDistinctWithJoin(): void ->select(['users.name']) ->join('orders', 'users.id', 'orders.user_id') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT DISTINCT `users`.`name` FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id`', @@ -1366,6 +1470,7 @@ public function testDistinctWithFilterAndSort(): void ->filter([Query::isNotNull('status')]) ->sortAsc('status') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT DISTINCT `status` FROM `t` WHERE `status` IS NOT NULL ORDER BY `status` ASC', @@ -1381,6 +1486,7 @@ public function testMultipleJoins(): void ->leftJoin('profiles', 'users.id', 'profiles.user_id') ->rightJoin('departments', 'users.dept_id', 'departments.id') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`user_id` RIGHT JOIN `departments` ON `users`.`dept_id` = `departments`.`id`', @@ -1396,6 +1502,7 @@ public function testJoinWithAggregationAndGroupBy(): void ->join('orders', 'users.id', 'orders.user_id') ->groupBy(['users.name']) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(*) AS `order_count` FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` GROUP BY `users`.`name`', @@ -1413,6 +1520,7 @@ public function testJoinWithSortAndPagination(): void ->limit(10) ->offset(20) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ? ORDER BY `orders`.`total` DESC LIMIT ? OFFSET ?', @@ -1427,6 +1535,7 @@ public function testJoinWithCustomOperator(): void ->from('a') ->join('b', 'a.val', 'b.val', '!=') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `a` JOIN `b` ON `a`.`val` != `b`.`val`', @@ -1441,6 +1550,7 @@ public function testCrossJoinWithOtherJoins(): void ->crossJoin('colors') ->leftJoin('inventory', 'sizes.id', 'inventory.size_id') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `sizes` CROSS JOIN `colors` LEFT JOIN `inventory` ON `sizes`.`id` = `inventory`.`size_id`', @@ -1454,6 +1564,7 @@ public function testRawWithMixedBindings(): void ->from('t') ->filter([Query::raw('a = ? AND b = ? AND c = ?', ['str', 42, 3.14])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE a = ? AND b = ? AND c = ?', $result->query); $this->assertEquals(['str', 42, 3.14], $result->bindings); @@ -1468,6 +1579,7 @@ public function testRawCombinedWithRegularFilters(): void Query::raw('custom_func(col) > ?', [10]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE `status` IN (?) AND custom_func(col) > ?', @@ -1482,6 +1594,7 @@ public function testRawWithEmptySql(): void ->from('t') ->filter([Query::raw('')]) ->build(); + $this->assertBindingCount($result); // Empty raw SQL still appears as a WHERE clause $this->assertStringContainsString('WHERE', $result->query); @@ -1497,6 +1610,7 @@ public function testMultipleUnions(): void ->union($q1) ->union($q2) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `users`) UNION (SELECT * FROM `admins`) UNION (SELECT * FROM `mods`)', @@ -1514,6 +1628,7 @@ public function testMixedUnionAndUnionAll(): void ->union($q1) ->unionAll($q2) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `users`) UNION (SELECT * FROM `admins`) UNION ALL (SELECT * FROM `mods`)', @@ -1532,6 +1647,7 @@ public function testUnionWithFiltersAndBindings(): void ->union($q1) ->unionAll($q2) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `users` WHERE `status` IN (?)) UNION (SELECT * FROM `admins` WHERE `level` IN (?)) UNION ALL (SELECT * FROM `mods` WHERE `score` > ?)', @@ -1549,6 +1665,7 @@ public function testUnionWithAggregation(): void ->count('*', 'total') ->unionAll($q1) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT COUNT(*) AS `total` FROM `orders_2024`) UNION ALL (SELECT COUNT(*) AS `total` FROM `orders_2023`)', @@ -1564,6 +1681,7 @@ public function testWhenNested(): void $b->when(true, fn (Builder $b2) => $b2->filter([Query::equal('a', [1])])); }) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $result->query); } @@ -1576,6 +1694,7 @@ public function testWhenMultipleCalls(): void ->when(false, fn (Builder $b) => $b->filter([Query::equal('b', [2])])) ->when(true, fn (Builder $b) => $b->filter([Query::equal('c', [3])])) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?) AND `c` IN (?)', $result->query); $this->assertEquals([1, 3], $result->bindings); @@ -1596,6 +1715,7 @@ public function testPageOnePerPage(): void ->from('t') ->page(5, 1) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); $this->assertEquals([1, 4], $result->bindings); @@ -1607,6 +1727,7 @@ public function testPageLargeValues(): void ->from('t') ->page(1000, 100) ->build(); + $this->assertBindingCount($result); $this->assertEquals([100, 99900], $result->bindings); } @@ -1708,6 +1829,7 @@ public function filter(string $table): Condition ->limit(10) ->offset(20) ->build(); + $this->assertBindingCount($result); // Order: filter bindings, hook bindings, cursor, limit, offset $this->assertEquals(['x', 5, 'tenant1', 'cursor_abc', 10, 20], $result->bindings); @@ -1734,6 +1856,7 @@ public function filter(string $table): Condition ->addHook($hook2) ->filter([Query::equal('a', ['x'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['x', 'v1', 'v2'], $result->bindings); } @@ -1748,6 +1871,7 @@ public function testBindingOrderHavingAfterFilters(): void ->having([Query::greaterThan('total', 5)]) ->limit(10) ->build(); + $this->assertBindingCount($result); // Filter bindings, then having bindings, then limit $this->assertEquals(['active', 5, 10], $result->bindings); @@ -1763,6 +1887,7 @@ public function testBindingOrderUnionAppendedLast(): void ->limit(5) ->union($sub) ->build(); + $this->assertBindingCount($result); // Main filter, main limit, then union bindings $this->assertEquals(['b', 5, 'y'], $result->bindings); @@ -1791,6 +1916,7 @@ public function filter(string $table): Condition ->offset(5) ->union($sub) ->build(); + $this->assertBindingCount($result); // filter, hook, cursor, having, limit, offset, union $this->assertEquals(['paid', 'org1', 'cur1', 1, 10, 5, 2023], $result->bindings); @@ -1803,6 +1929,7 @@ public function testAttributeResolverWithAggregation(): void ->addHook(new AttributeMap(['$price' => '_price'])) ->sum('$price', 'total') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT SUM(`_price`) AS `total` FROM `t`', $result->query); } @@ -1815,6 +1942,7 @@ public function testAttributeResolverWithGroupBy(): void ->count('*', 'total') ->groupBy(['$status']) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(*) AS `total` FROM `t` GROUP BY `_status`', @@ -1832,6 +1960,7 @@ public function testAttributeResolverWithJoin(): void ])) ->join('other', '$id', '$ref') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` JOIN `other` ON `_uid` = `_ref`', @@ -1848,6 +1977,7 @@ public function testAttributeResolverWithHaving(): void ->groupBy(['status']) ->having([Query::greaterThan('$total', 5)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('HAVING `_total` > ?', $result->query); } @@ -1867,6 +1997,7 @@ public function filter(string $table): Condition ->addHook($hook) ->filter([Query::greaterThan('orders.total', 100)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ? AND users.org_id = ?', @@ -1890,6 +2021,7 @@ public function filter(string $table): Condition ->addHook($hook) ->groupBy(['status']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WHERE org_id = ?', $result->query); $this->assertEquals(['org1'], $result->bindings); @@ -1925,6 +2057,7 @@ public function testCursorWithLimitAndOffset(): void ->limit(10) ->offset(5) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE `_cursor` > ? LIMIT ? OFFSET ?', @@ -1940,6 +2073,7 @@ public function testCursorWithPage(): void ->cursorAfter('abc') ->page(2, 10) ->build(); + $this->assertBindingCount($result); // Cursor + limit from page + offset from page; first limit/offset wins $this->assertStringContainsString('`_cursor` > ?', $result->query); @@ -1975,6 +2109,7 @@ public function filter(string $table): Condition ->offset(50) ->union($sub) ->build(); + $this->assertBindingCount($result); // Verify structural elements $this->assertStringContainsString('SELECT DISTINCT', $result->query); @@ -2011,6 +2146,7 @@ public function testFilterEmptyArray(): void ->from('t') ->filter([]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); } @@ -2021,6 +2157,7 @@ public function testSelectEmptyArray(): void ->from('t') ->select([]) ->build(); + $this->assertBindingCount($result); // Empty select produces empty column list $this->assertEquals('SELECT FROM `t`', $result->query); @@ -2032,6 +2169,7 @@ public function testLimitZero(): void ->from('t') ->limit(0) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); $this->assertEquals([0], $result->bindings); @@ -2043,6 +2181,7 @@ public function testOffsetZero(): void ->from('t') ->offset(0) ->build(); + $this->assertBindingCount($result); // OFFSET without LIMIT is suppressed $this->assertEquals('SELECT * FROM `t`', $result->query); @@ -2099,6 +2238,7 @@ public function testRegexWithEmptyPattern(): void ->from('t') ->filter([Query::regex('slug', '')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result->query); $this->assertEquals([''], $result->bindings); @@ -2110,6 +2250,7 @@ public function testRegexWithDotChar(): void ->from('t') ->filter([Query::regex('name', 'a.b')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `name` REGEXP ?', $result->query); $this->assertEquals(['a.b'], $result->bindings); @@ -2121,6 +2262,7 @@ public function testRegexWithStarChar(): void ->from('t') ->filter([Query::regex('name', 'a*b')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['a*b'], $result->bindings); } @@ -2131,6 +2273,7 @@ public function testRegexWithPlusChar(): void ->from('t') ->filter([Query::regex('name', 'a+')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['a+'], $result->bindings); } @@ -2141,6 +2284,7 @@ public function testRegexWithQuestionMarkChar(): void ->from('t') ->filter([Query::regex('name', 'colou?r')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['colou?r'], $result->bindings); } @@ -2151,6 +2295,7 @@ public function testRegexWithCaretAndDollar(): void ->from('t') ->filter([Query::regex('code', '^[A-Z]+$')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['^[A-Z]+$'], $result->bindings); } @@ -2161,6 +2306,7 @@ public function testRegexWithPipeChar(): void ->from('t') ->filter([Query::regex('color', 'red|blue|green')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['red|blue|green'], $result->bindings); } @@ -2171,6 +2317,7 @@ public function testRegexWithBackslash(): void ->from('t') ->filter([Query::regex('path', '\\\\server\\\\share')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['\\\\server\\\\share'], $result->bindings); } @@ -2181,6 +2328,7 @@ public function testRegexWithBracketsAndBraces(): void ->from('t') ->filter([Query::regex('zip', '[0-9]{5}')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('[0-9]{5}', $result->bindings[0]); } @@ -2191,6 +2339,7 @@ public function testRegexWithParentheses(): void ->from('t') ->filter([Query::regex('phone', '(\\+1)?[0-9]{10}')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['(\\+1)?[0-9]{10}'], $result->bindings); } @@ -2205,6 +2354,7 @@ public function testRegexCombinedWithOtherFilters(): void Query::greaterThan('age', 18), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE `status` IN (?) AND `slug` REGEXP ? AND `age` > ?', @@ -2222,6 +2372,7 @@ public function testRegexWithAttributeResolver(): void ])) ->filter([Query::regex('$slug', '^test')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `_slug` REGEXP ?', $result->query); $this->assertEquals(['^test'], $result->bindings); @@ -2244,6 +2395,7 @@ public function testRegexBindingPreservedExactly(): void ->from('t') ->filter([Query::regex('email', $pattern)]) ->build(); + $this->assertBindingCount($result); $this->assertSame($pattern, $result->bindings[0]); } @@ -2255,6 +2407,7 @@ public function testRegexWithVeryLongPattern(): void ->from('t') ->filter([Query::regex('col', $pattern)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals($pattern, $result->bindings[0]); $this->assertStringContainsString('REGEXP ?', $result->query); @@ -2269,6 +2422,7 @@ public function testMultipleRegexFilters(): void Query::regex('email', '@test\\.com$'), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE `name` REGEXP ? AND `email` REGEXP ?', @@ -2288,6 +2442,7 @@ public function testRegexInAndLogicalGroup(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE (`slug` REGEXP ? AND `status` IN (?))', @@ -2307,6 +2462,7 @@ public function testRegexInOrLogicalGroup(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE (`name` REGEXP ? OR `name` REGEXP ?)', @@ -2322,6 +2478,7 @@ public function testSearchWithEmptyString(): void ->from('t') ->filter([Query::search('content', '')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?)', $result->query); $this->assertEquals([''], $result->bindings); @@ -2333,6 +2490,7 @@ public function testSearchWithSpecialCharacters(): void ->from('t') ->filter([Query::search('body', 'hello "world" +required -excluded')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['hello "world" +required -excluded'], $result->bindings); } @@ -2347,6 +2505,7 @@ public function testSearchCombinedWithOtherFilters(): void Query::greaterThan('views', 100), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?) AND `status` IN (?) AND `views` > ?', @@ -2364,6 +2523,7 @@ public function testNotSearchCombinedWithOtherFilters(): void Query::equal('status', ['published']), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?)) AND `status` IN (?)', @@ -2381,6 +2541,7 @@ public function testSearchWithAttributeResolver(): void ])) ->filter([Query::search('$body', 'hello')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`_body`) AGAINST(?)', $result->query); } @@ -2412,6 +2573,7 @@ public function testSearchBindingPreservedExactly(): void ->from('t') ->filter([Query::search('content', $searchTerm)]) ->build(); + $this->assertBindingCount($result); $this->assertSame($searchTerm, $result->bindings[0]); } @@ -2423,6 +2585,7 @@ public function testSearchWithVeryLongText(): void ->from('t') ->filter([Query::search('content', $longText)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals($longText, $result->bindings[0]); } @@ -2436,6 +2599,7 @@ public function testMultipleSearchFilters(): void Query::search('body', 'world'), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE MATCH(`title`) AGAINST(?) AND MATCH(`body`) AGAINST(?)', @@ -2455,6 +2619,7 @@ public function testSearchInAndLogicalGroup(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE (MATCH(`content`) AGAINST(?) AND `status` IN (?))', @@ -2473,6 +2638,7 @@ public function testSearchInOrLogicalGroup(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE (MATCH(`title`) AGAINST(?) OR MATCH(`body`) AGAINST(?))', @@ -2490,6 +2656,7 @@ public function testSearchAndRegexCombined(): void Query::regex('slug', '^[a-z-]+$'), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?) AND `slug` REGEXP ?', @@ -2504,6 +2671,7 @@ public function testNotSearchStandalone(): void ->from('t') ->filter([Query::notSearch('content', 'spam')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?))', $result->query); $this->assertEquals(['spam'], $result->bindings); @@ -2527,6 +2695,7 @@ public function testRandomSortCombinedWithAscDesc(): void ->sortRandom() ->sortDesc('age') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` ORDER BY `name` ASC, RAND(), `age` DESC', @@ -2541,6 +2710,7 @@ public function testRandomSortWithFilters(): void ->filter([Query::equal('status', ['active'])]) ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE `status` IN (?) ORDER BY RAND()', @@ -2556,6 +2726,7 @@ public function testRandomSortWithLimit(): void ->sortRandom() ->limit(5) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ?', $result->query); $this->assertEquals([5], $result->bindings); @@ -2569,6 +2740,7 @@ public function testRandomSortWithAggregation(): void ->groupBy(['category']) ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY RAND()', $result->query); $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); @@ -2581,6 +2753,7 @@ public function testRandomSortWithJoins(): void ->join('orders', 'users.id', 'orders.user_id') ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `orders`', $result->query); $this->assertStringContainsString('ORDER BY RAND()', $result->query); @@ -2594,6 +2767,7 @@ public function testRandomSortWithDistinct(): void ->select(['status']) ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT DISTINCT `status` FROM `t` ORDER BY RAND()', @@ -2610,6 +2784,7 @@ public function testRandomSortInBatchMode(): void Query::limit(10), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ?', $result->query); $this->assertEquals([10], $result->bindings); @@ -2627,6 +2802,7 @@ public function resolve(string $attribute): string }) ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY RAND()', $result->query); } @@ -2638,6 +2814,7 @@ public function testMultipleRandomSorts(): void ->sortRandom() ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` ORDER BY RAND(), RAND()', $result->query); } @@ -2650,6 +2827,7 @@ public function testRandomSortWithOffset(): void ->limit(10) ->offset(5) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ? OFFSET ?', $result->query); $this->assertEquals([10, 5], $result->bindings); @@ -3028,6 +3206,7 @@ public function testEqualWithSingleValue(): void ->from('t') ->filter([Query::equal('status', ['active'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?)', $result->query); $this->assertEquals(['active'], $result->bindings); @@ -3040,6 +3219,7 @@ public function testEqualWithManyValues(): void ->from('t') ->filter([Query::equal('id', $values)]) ->build(); + $this->assertBindingCount($result); $placeholders = implode(', ', array_fill(0, 10, '?')); $this->assertEquals("SELECT * FROM `t` WHERE `id` IN ({$placeholders})", $result->query); @@ -3052,6 +3232,7 @@ public function testEqualWithEmptyArray(): void ->from('t') ->filter([Query::equal('id', [])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE 1 = 0', $result->query); $this->assertEquals([], $result->bindings); @@ -3063,6 +3244,7 @@ public function testNotEqualWithExactlyTwoValues(): void ->from('t') ->filter([Query::notEqual('role', ['guest', 'banned'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result->query); $this->assertEquals(['guest', 'banned'], $result->bindings); @@ -3074,6 +3256,7 @@ public function testBetweenWithSameMinAndMax(): void ->from('t') ->filter([Query::between('age', 25, 25)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); $this->assertEquals([25, 25], $result->bindings); @@ -3085,6 +3268,7 @@ public function testStartsWithEmptyString(): void ->from('t') ->filter([Query::startsWith('name', '')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); $this->assertEquals(['%'], $result->bindings); @@ -3096,6 +3280,7 @@ public function testEndsWithEmptyString(): void ->from('t') ->filter([Query::endsWith('name', '')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); $this->assertEquals(['%'], $result->bindings); @@ -3107,6 +3292,7 @@ public function testContainsWithSingleEmptyString(): void ->from('t') ->filter([Query::contains('bio', [''])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); $this->assertEquals(['%%'], $result->bindings); @@ -3118,6 +3304,7 @@ public function testContainsWithManyValues(): void ->from('t') ->filter([Query::contains('bio', ['a', 'b', 'c', 'd', 'e'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ?)', $result->query); $this->assertEquals(['%a%', '%b%', '%c%', '%d%', '%e%'], $result->bindings); @@ -3129,6 +3316,7 @@ public function testContainsAllWithSingleValue(): void ->from('t') ->filter([Query::containsAll('perms', ['read'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`perms` LIKE ?)', $result->query); $this->assertEquals(['%read%'], $result->bindings); @@ -3140,6 +3328,7 @@ public function testNotContainsWithEmptyStringValue(): void ->from('t') ->filter([Query::notContains('bio', [''])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result->query); $this->assertEquals(['%%'], $result->bindings); @@ -3151,6 +3340,7 @@ public function testComparisonWithFloatValues(): void ->from('t') ->filter([Query::greaterThan('price', 9.99)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `price` > ?', $result->query); $this->assertEquals([9.99], $result->bindings); @@ -3162,6 +3352,7 @@ public function testComparisonWithNegativeValues(): void ->from('t') ->filter([Query::lessThan('balance', -100)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `balance` < ?', $result->query); $this->assertEquals([-100], $result->bindings); @@ -3173,6 +3364,7 @@ public function testComparisonWithZero(): void ->from('t') ->filter([Query::greaterThanEqual('score', 0)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `score` >= ?', $result->query); $this->assertEquals([0], $result->bindings); @@ -3184,6 +3376,7 @@ public function testComparisonWithVeryLargeInteger(): void ->from('t') ->filter([Query::lessThan('id', 9999999999999)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals([9999999999999], $result->bindings); } @@ -3194,6 +3387,7 @@ public function testComparisonWithStringValues(): void ->from('t') ->filter([Query::greaterThan('name', 'M')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `name` > ?', $result->query); $this->assertEquals(['M'], $result->bindings); @@ -3205,6 +3399,7 @@ public function testBetweenWithStringValues(): void ->from('t') ->filter([Query::between('created_at', '2024-01-01', '2024-12-31')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `created_at` BETWEEN ? AND ?', $result->query); $this->assertEquals(['2024-01-01', '2024-12-31'], $result->bindings); @@ -3219,6 +3414,7 @@ public function testIsNullCombinedWithIsNotNullOnDifferentColumns(): void Query::isNotNull('verified_at'), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE `deleted_at` IS NULL AND `verified_at` IS NOT NULL', @@ -3237,6 +3433,7 @@ public function testMultipleIsNullFilters(): void Query::isNull('c'), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE `a` IS NULL AND `b` IS NULL AND `c` IS NULL', @@ -3250,6 +3447,7 @@ public function testExistsWithSingleAttribute(): void ->from('t') ->filter([Query::exists(['name'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`name` IS NOT NULL)', $result->query); } @@ -3260,6 +3458,7 @@ public function testExistsWithManyAttributes(): void ->from('t') ->filter([Query::exists(['a', 'b', 'c', 'd'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE (`a` IS NOT NULL AND `b` IS NOT NULL AND `c` IS NOT NULL AND `d` IS NOT NULL)', @@ -3273,6 +3472,7 @@ public function testNotExistsWithManyAttributes(): void ->from('t') ->filter([Query::notExists(['a', 'b', 'c'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE (`a` IS NULL AND `b` IS NULL AND `c` IS NULL)', @@ -3290,6 +3490,7 @@ public function testAndWithSingleSubQuery(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?))', $result->query); $this->assertEquals([1], $result->bindings); @@ -3305,6 +3506,7 @@ public function testOrWithSingleSubQuery(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?))', $result->query); $this->assertEquals([1], $result->bindings); @@ -3324,6 +3526,7 @@ public function testAndWithManySubQueries(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE (`a` IN (?) AND `b` IN (?) AND `c` IN (?) AND `d` IN (?) AND `e` IN (?))', @@ -3346,6 +3549,7 @@ public function testOrWithManySubQueries(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE (`a` IN (?) OR `b` IN (?) OR `c` IN (?) OR `d` IN (?) OR `e` IN (?))', @@ -3370,6 +3574,7 @@ public function testDeeplyNestedAndOrAnd(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE (((`a` IN (?) AND `b` IN (?)) OR `c` IN (?)) AND `d` IN (?))', @@ -3386,6 +3591,7 @@ public function testRawWithManyBindings(): void ->from('t') ->filter([Query::raw($placeholders, $bindings)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals("SELECT * FROM `t` WHERE {$placeholders}", $result->query); $this->assertEquals($bindings, $result->bindings); @@ -3397,6 +3603,7 @@ public function testFilterWithDotsInAttributeName(): void ->from('t') ->filter([Query::equal('table.column', ['value'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `table`.`column` IN (?)', $result->query); } @@ -3407,6 +3614,7 @@ public function testFilterWithUnderscoresInAttributeName(): void ->from('t') ->filter([Query::equal('my_column_name', ['value'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `my_column_name` IN (?)', $result->query); } @@ -3417,6 +3625,7 @@ public function testFilterWithNumericAttributeName(): void ->from('t') ->filter([Query::equal('123', ['value'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `123` IN (?)', $result->query); } @@ -3425,6 +3634,7 @@ public function testFilterWithNumericAttributeName(): void public function testCountWithoutAliasNoAsClause(): void { $result = (new Builder())->from('t')->count()->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT COUNT(*) FROM `t`', $result->query); $this->assertStringNotContainsString(' AS ', $result->query); } @@ -3432,6 +3642,7 @@ public function testCountWithoutAliasNoAsClause(): void public function testSumWithoutAliasNoAsClause(): void { $result = (new Builder())->from('t')->sum('price')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT SUM(`price`) FROM `t`', $result->query); $this->assertStringNotContainsString(' AS ', $result->query); } @@ -3439,6 +3650,7 @@ public function testSumWithoutAliasNoAsClause(): void public function testAvgWithoutAliasNoAsClause(): void { $result = (new Builder())->from('t')->avg('score')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT AVG(`score`) FROM `t`', $result->query); $this->assertStringNotContainsString(' AS ', $result->query); } @@ -3446,6 +3658,7 @@ public function testAvgWithoutAliasNoAsClause(): void public function testMinWithoutAliasNoAsClause(): void { $result = (new Builder())->from('t')->min('price')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT MIN(`price`) FROM `t`', $result->query); $this->assertStringNotContainsString(' AS ', $result->query); } @@ -3453,6 +3666,7 @@ public function testMinWithoutAliasNoAsClause(): void public function testMaxWithoutAliasNoAsClause(): void { $result = (new Builder())->from('t')->max('price')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT MAX(`price`) FROM `t`', $result->query); $this->assertStringNotContainsString(' AS ', $result->query); } @@ -3460,30 +3674,35 @@ public function testMaxWithoutAliasNoAsClause(): void public function testCountWithAlias2(): void { $result = (new Builder())->from('t')->count('*', 'cnt')->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('AS `cnt`', $result->query); } public function testSumWithAlias(): void { $result = (new Builder())->from('t')->sum('price', 'total')->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('AS `total`', $result->query); } public function testAvgWithAlias(): void { $result = (new Builder())->from('t')->avg('score', 'avg_s')->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('AS `avg_s`', $result->query); } public function testMinWithAlias(): void { $result = (new Builder())->from('t')->min('price', 'lowest')->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('AS `lowest`', $result->query); } public function testMaxWithAlias(): void { $result = (new Builder())->from('t')->max('price', 'highest')->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('AS `highest`', $result->query); } @@ -3494,6 +3713,7 @@ public function testMultipleSameAggregationType(): void ->count('id', 'count_id') ->count('*', 'count_all') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(`id`) AS `count_id`, COUNT(*) AS `count_all` FROM `t`', @@ -3509,6 +3729,7 @@ public function testAggregationStarAndNamedColumnMixed(): void ->sum('price', 'price_sum') ->select(['category']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); $this->assertStringContainsString('SUM(`price`) AS `price_sum`', $result->query); @@ -3525,6 +3746,7 @@ public function testAggregationFilterSortLimitCombined(): void ->sortDesc('cnt') ->limit(5) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); @@ -3549,6 +3771,7 @@ public function testAggregationJoinGroupByHavingSortLimitFullPipeline(): void ->limit(20) ->offset(10) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); $this->assertStringContainsString('SUM(`total`) AS `revenue`', $result->query); @@ -3571,6 +3794,7 @@ public function testAggregationWithAttributeResolver(): void ])) ->sum('$amount', 'total') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT SUM(`_amount`) AS `total` FROM `t`', $result->query); } @@ -3582,6 +3806,7 @@ public function testMinMaxWithStringColumns(): void ->min('name', 'first_name') ->max('name', 'last_name') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT MIN(`name`) AS `first_name`, MAX(`name`) AS `last_name` FROM `t`', @@ -3596,6 +3821,7 @@ public function testSelfJoin(): void ->from('employees') ->join('employees', 'employees.manager_id', 'employees.id') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `employees` JOIN `employees` ON `employees`.`manager_id` = `employees`.`id`', @@ -3612,6 +3838,7 @@ public function testJoinWithVeryLongTableAndColumnNames(): void ->from('main') ->join($longTable, $longLeft, $longRight) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString("JOIN `{$longTable}`", $result->query); $this->assertStringContainsString("ON `{$longLeft}` = `{$longRight}`", $result->query); @@ -3630,6 +3857,7 @@ public function testJoinFilterSortLimitOffsetCombined(): void ->limit(25) ->offset(50) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `orders`', $result->query); $this->assertStringContainsString('WHERE `orders`.`status` IN (?) AND `orders`.`total` > ?', $result->query); @@ -3648,6 +3876,7 @@ public function testJoinAggregationGroupByHavingCombined(): void ->groupBy(['users.name']) ->having([Query::greaterThan('cnt', 3)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); $this->assertStringContainsString('JOIN `users`', $result->query); @@ -3664,6 +3893,7 @@ public function testJoinWithDistinct(): void ->select(['users.name']) ->join('orders', 'users.id', 'orders.user_id') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SELECT DISTINCT `users`.`name`', $result->query); $this->assertStringContainsString('JOIN `orders`', $result->query); @@ -3680,6 +3910,7 @@ public function testJoinWithUnion(): void ->join('orders', 'users.id', 'orders.user_id') ->union($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `orders`', $result->query); $this->assertStringContainsString('UNION', $result->query); @@ -3695,6 +3926,7 @@ public function testFourJoins(): void ->rightJoin('categories', 'products.cat_id', 'categories.id') ->crossJoin('promotions') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `users`', $result->query); $this->assertStringContainsString('LEFT JOIN `products`', $result->query); @@ -3712,6 +3944,7 @@ public function testJoinWithAttributeResolverOnJoinColumns(): void ])) ->join('other', '$id', '$ref') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` JOIN `other` ON `_uid` = `_ref_id`', @@ -3726,6 +3959,7 @@ public function testCrossJoinCombinedWithFilter(): void ->crossJoin('colors') ->filter([Query::equal('sizes.active', [true])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CROSS JOIN `colors`', $result->query); $this->assertStringContainsString('WHERE `sizes`.`active` IN (?)', $result->query); @@ -3738,6 +3972,7 @@ public function testCrossJoinFollowedByRegularJoin(): void ->crossJoin('b') ->join('c', 'a.id', 'c.a_id') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `a` CROSS JOIN `b` JOIN `c` ON `a`.`id` = `c`.`a_id`', @@ -3756,6 +3991,7 @@ public function testMultipleJoinsWithFiltersOnEach(): void Query::isNotNull('profiles.avatar'), ]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `orders`', $result->query); $this->assertStringContainsString('LEFT JOIN `profiles`', $result->query); @@ -3769,6 +4005,7 @@ public function testJoinWithCustomOperatorLessThan(): void ->from('a') ->join('b', 'a.start', 'b.end', '<') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `a` JOIN `b` ON `a`.`start` < `b`.`end`', @@ -3786,6 +4023,7 @@ public function testFiveJoins(): void ->join('t5', 't4.id', 't5.t4_id') ->join('t6', 't5.id', 't6.t5_id') ->build(); + $this->assertBindingCount($result); $query = $result->query; $this->assertEquals(5, substr_count($query, 'JOIN')); @@ -3804,6 +4042,7 @@ public function testUnionWithThreeSubQueries(): void ->union($q2) ->union($q3) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `main`) UNION (SELECT * FROM `a`) UNION (SELECT * FROM `b`) UNION (SELECT * FROM `c`)', @@ -3823,6 +4062,7 @@ public function testUnionAllWithThreeSubQueries(): void ->unionAll($q2) ->unionAll($q3) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `main`) UNION ALL (SELECT * FROM `a`) UNION ALL (SELECT * FROM `b`) UNION ALL (SELECT * FROM `c`)', @@ -3842,6 +4082,7 @@ public function testMixedUnionAndUnionAllWithThreeSubQueries(): void ->unionAll($q2) ->union($q3) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `main`) UNION (SELECT * FROM `a`) UNION ALL (SELECT * FROM `b`) UNION (SELECT * FROM `c`)', @@ -3859,6 +4100,7 @@ public function testUnionWhereSubQueryHasJoins(): void ->from('users') ->union($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString( 'UNION (SELECT * FROM `archived_users` JOIN `archived_orders`', @@ -3879,6 +4121,7 @@ public function testUnionWhereSubQueryHasAggregation(): void ->groupBy(['status']) ->union($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('UNION (SELECT COUNT(*) AS `cnt` FROM `orders_2023` GROUP BY `status`)', $result->query); } @@ -3894,6 +4137,7 @@ public function testUnionWhereSubQueryHasSortAndLimit(): void ->from('current') ->union($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('UNION (SELECT * FROM `archive` ORDER BY `created_at` DESC LIMIT ?)', $result->query); } @@ -3919,6 +4163,7 @@ public function filter(string $table): Condition }) ->union($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WHERE org = ?', $result->query); $this->assertStringContainsString('UNION (SELECT * FROM `other` WHERE org = ?)', $result->query); @@ -3938,6 +4183,7 @@ public function testUnionBindingOrderWithComplexSubQueries(): void ->limit(10) ->union($sub) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['active', 10, 2023, 5], $result->bindings); } @@ -3955,6 +4201,7 @@ public function testUnionWithDistinct(): void ->select(['name']) ->union($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SELECT DISTINCT `name` FROM `current`', $result->query); $this->assertStringContainsString('UNION (SELECT DISTINCT `name` FROM `archive`)', $result->query); @@ -3968,6 +4215,7 @@ public function testUnionAfterReset(): void $sub = (new Builder())->from('other'); $result = $builder->from('fresh')->union($sub)->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `fresh`) UNION (SELECT * FROM `other`)', @@ -3990,6 +4238,7 @@ public function testUnionChainedWithComplexBindings(): void ->union($q1) ->unionAll($q2) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['active', 1, 2, 10, 20], $result->bindings); } @@ -4008,6 +4257,7 @@ public function testUnionWithFourSubQueries(): void ->union($q3) ->union($q4) ->build(); + $this->assertBindingCount($result); $this->assertEquals(4, substr_count($result->query, 'UNION')); } @@ -4025,6 +4275,7 @@ public function testUnionAllWithFilteredSubQueries(): void ->unionAll($q2) ->unionAll($q3) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['paid', 'paid', 'paid', 'paid'], $result->bindings); $this->assertEquals(3, substr_count($result->query, 'UNION ALL')); @@ -4208,6 +4459,7 @@ public function testWhenWithComplexCallbackAddingMultipleFeatures(): void ->limit(10); }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); $this->assertStringContainsString('ORDER BY `name` ASC', $result->query); @@ -4225,6 +4477,7 @@ public function testWhenChainedFiveTimes(): void ->when(true, fn (Builder $b) => $b->filter([Query::equal('d', [4])])) ->when(true, fn (Builder $b) => $b->filter([Query::equal('e', [5])])) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?) AND `d` IN (?) AND `e` IN (?)', @@ -4243,6 +4496,7 @@ public function testWhenInsideWhenThreeLevelsDeep(): void }); }) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `deep` IN (?)', $result->query); $this->assertEquals([1], $result->bindings); @@ -4254,6 +4508,7 @@ public function testWhenThatAddsJoins(): void ->from('users') ->when(true, fn (Builder $b) => $b->join('orders', 'users.id', 'orders.uid')) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `orders`', $result->query); } @@ -4264,6 +4519,7 @@ public function testWhenThatAddsAggregations(): void ->from('t') ->when(true, fn (Builder $b) => $b->count('*', 'total')->groupBy(['status'])) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); $this->assertStringContainsString('GROUP BY `status`', $result->query); @@ -4277,6 +4533,7 @@ public function testWhenThatAddsUnions(): void ->from('current') ->when(true, fn (Builder $b) => $b->union($sub)) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('UNION', $result->query); } @@ -4287,6 +4544,7 @@ public function testWhenFalseDoesNotAffectFilters(): void ->from('t') ->when(false, fn (Builder $b) => $b->filter([Query::equal('status', ['banned'])])) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); $this->assertEquals([], $result->bindings); @@ -4298,6 +4556,7 @@ public function testWhenFalseDoesNotAffectJoins(): void ->from('t') ->when(false, fn (Builder $b) => $b->join('other', 'a', 'b')) ->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('JOIN', $result->query); } @@ -4308,6 +4567,7 @@ public function testWhenFalseDoesNotAffectAggregations(): void ->from('t') ->when(false, fn (Builder $b) => $b->count('*', 'total')) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); } @@ -4318,6 +4578,7 @@ public function testWhenFalseDoesNotAffectSort(): void ->from('t') ->when(false, fn (Builder $b) => $b->sortAsc('name')) ->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('ORDER BY', $result->query); } @@ -4346,6 +4607,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE p1 = ? AND p2 = ? AND p3 = ?', @@ -4365,6 +4627,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); // Empty string still appears as a WHERE clause element $this->assertStringContainsString('WHERE', $result->query); @@ -4381,6 +4644,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE a IN (?, ?, ?, ?, ?)', @@ -4405,6 +4669,7 @@ public function filter(string $table): Condition ->groupBy(['status']) ->having([Query::greaterThan('cnt', 5)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WHERE', $result->query); $this->assertStringContainsString('HAVING', $result->query); @@ -4424,6 +4689,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `orders`', $result->query); $this->assertStringContainsString('WHERE tenant = ?', $result->query); @@ -4444,6 +4710,7 @@ public function filter(string $table): Condition }) ->union($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WHERE org = ?', $result->query); $this->assertStringContainsString('UNION', $result->query); @@ -4463,6 +4730,7 @@ public function filter(string $table): Condition }) ->groupBy(['status']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); $this->assertStringContainsString('WHERE org = ?', $result->query); @@ -4479,6 +4747,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('users_perms', $result->query); $this->assertEquals(['read'], $result->bindings); @@ -4508,6 +4777,7 @@ public function filter(string $table): Condition ->limit(5) ->offset(10) ->build(); + $this->assertBindingCount($result); // filter, provider1, provider2, cursor, limit, offset $this->assertEquals(['va', 10, 'pv1', 'pv2', 'cur', 5, 10], $result->bindings); @@ -4528,6 +4798,7 @@ public function filter(string $table): Condition $builder->reset(); $result = $builder->from('t2')->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WHERE org = ?', $result->query); $this->assertEquals(['org1'], $result->bindings); } @@ -4561,6 +4832,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `t` WHERE a = ? AND b = ? AND c = ? AND d = ?', @@ -4580,6 +4852,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result->query); $this->assertEquals([], $result->bindings); @@ -4602,6 +4875,7 @@ public function resolve(string $attribute): string $builder->reset(); $result = $builder->from('t2')->filter([Query::equal('y', [2])])->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`_y`', $result->query); } @@ -4620,6 +4894,7 @@ public function filter(string $table): Condition $builder->reset(); $result = $builder->from('t2')->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('org = ?', $result->query); $this->assertEquals(['org1'], $result->bindings); } @@ -4636,6 +4911,7 @@ public function testResetClearsPendingQueries(): void $builder->reset(); $result = $builder->from('t2')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t2`', $result->query); $this->assertEquals([], $result->bindings); } @@ -4651,6 +4927,7 @@ public function testResetClearsBindings(): void $builder->reset(); $result = $builder->from('t2')->build(); + $this->assertBindingCount($result); $this->assertEquals([], $result->bindings); } @@ -4661,6 +4938,7 @@ public function testResetClearsTable(): void $builder->reset(); $result = $builder->from('new_table')->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`new_table`', $result->query); $this->assertStringNotContainsString('`old_table`', $result->query); } @@ -4673,6 +4951,7 @@ public function testResetClearsUnionsAfterBuild(): void $builder->reset(); $result = $builder->from('fresh')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('UNION', $result->query); } @@ -4690,6 +4969,7 @@ public function testBuildAfterResetProducesMinimalQuery(): void $builder->reset(); $result = $builder->from('t')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); } @@ -4702,6 +4982,7 @@ public function testMultipleResetCalls(): void $builder->reset(); $result = $builder->from('t2')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t2`', $result->query); } @@ -4731,6 +5012,7 @@ public function testResetAfterUnion(): void $builder->reset(); $result = $builder->from('new')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `new`', $result->query); $this->assertEquals([], $result->bindings); } @@ -4757,6 +5039,7 @@ public function testResetAfterComplexQueryWithAllFeatures(): void $builder->reset(); $result = $builder->from('simple')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `simple`', $result->query); $this->assertEquals([], $result->bindings); } @@ -4912,6 +5195,7 @@ public function testBindingOrderMultipleFilters(): void Query::between('c', 1, 100), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['v1', 10, 1, 100], $result->bindings); } @@ -4939,6 +5223,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['pv1', 'pv2', 'pv3'], $result->bindings); } @@ -4955,6 +5240,7 @@ public function testBindingOrderMultipleUnions(): void ->union($q1) ->unionAll($q2) ->build(); + $this->assertBindingCount($result); // main filter, main limit, union1 bindings, union2 bindings $this->assertEquals([3, 5, 1, 2], $result->bindings); @@ -4972,6 +5258,7 @@ public function testBindingOrderLogicalAndWithMultipleSubFilters(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals([1, 2, 3], $result->bindings); } @@ -4988,6 +5275,7 @@ public function testBindingOrderLogicalOrWithMultipleSubFilters(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals([1, 2, 3], $result->bindings); } @@ -5006,6 +5294,7 @@ public function testBindingOrderNestedAndOr(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals([1, 2, 3], $result->bindings); } @@ -5020,6 +5309,7 @@ public function testBindingOrderRawMixedWithRegularFilters(): void Query::greaterThan('b', 20), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['v1', 10, 20], $result->bindings); } @@ -5038,6 +5328,7 @@ public function testBindingOrderAggregationHavingComplexConditions(): void ]) ->limit(10) ->build(); + $this->assertBindingCount($result); // filter, having1, having2, limit $this->assertEquals(['active', 5, 10000, 10], $result->bindings); @@ -5067,6 +5358,7 @@ public function filter(string $table): Condition ->offset(50) ->union($sub) ->build(); + $this->assertBindingCount($result); // filter(paid, 0), provider(t1), cursor(cursor_val), having(1), limit(25), offset(50), union(true) $this->assertEquals(['paid', 0, 't1', 'cursor_val', 1, 25, 50, true], $result->bindings); @@ -5081,6 +5373,7 @@ public function testBindingOrderContainsMultipleValues(): void Query::equal('status', ['active']), ]) ->build(); + $this->assertBindingCount($result); // contains produces three LIKE bindings, then equal $this->assertEquals(['%php%', '%js%', '%go%', 'active'], $result->bindings); @@ -5096,6 +5389,7 @@ public function testBindingOrderBetweenAndComparisons(): void Query::lessThan('rank', 100), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals([18, 65, 50, 100], $result->bindings); } @@ -5109,6 +5403,7 @@ public function testBindingOrderStartsWithEndsWith(): void Query::endsWith('email', '.com'), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['A%', '%.com'], $result->bindings); } @@ -5122,6 +5417,7 @@ public function testBindingOrderSearchAndRegex(): void Query::regex('slug', '^test'), ]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['hello', '^test'], $result->bindings); } @@ -5141,6 +5437,7 @@ public function filter(string $table): Condition ->limit(10) ->offset(0) ->build(); + $this->assertBindingCount($result); // filter, provider, cursor, limit, offset $this->assertEquals(['x', 'org1', 'my_cursor', 10, 0], $result->bindings); @@ -5210,6 +5507,7 @@ public function testBuildWithEmptyFilterArray(): void ->from('t') ->filter([]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); } @@ -5220,6 +5518,7 @@ public function testBuildWithEmptySelectArray(): void ->from('t') ->select([]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT FROM `t`', $result->query); } @@ -5231,6 +5530,7 @@ public function testBuildWithOnlyHavingNoGroupBy(): void ->count('*', 'cnt') ->having([Query::greaterThan('cnt', 0)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); $this->assertStringNotContainsString('GROUP BY', $result->query); @@ -5242,6 +5542,7 @@ public function testBuildWithOnlyDistinct(): void ->from('t') ->distinct() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT DISTINCT * FROM `t`', $result->query); } @@ -5251,12 +5552,14 @@ public function testBuildWithOnlyDistinct(): void public function testSpatialCrosses(): void { $result = (new Builder())->from('t')->filter([Query::crosses('attr', [1.0, 2.0])])->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Crosses', $result->query); } public function testSpatialDistanceLessThan(): void { $result = (new Builder())->from('t')->filter([Query::distanceLessThan('attr', [0, 0], 1000, true)])->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Distance', $result->query); $this->assertStringContainsString('metre', $result->query); } @@ -5264,24 +5567,28 @@ public function testSpatialDistanceLessThan(): void public function testSpatialIntersects(): void { $result = (new Builder())->from('t')->filter([Query::intersects('attr', [1.0, 2.0])])->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Intersects', $result->query); } public function testSpatialOverlaps(): void { $result = (new Builder())->from('t')->filter([Query::overlaps('attr', [[0, 0], [1, 1]])])->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Overlaps', $result->query); } public function testSpatialTouches(): void { $result = (new Builder())->from('t')->filter([Query::touches('attr', [1.0, 2.0])])->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Touches', $result->query); } public function testSpatialNotIntersects(): void { $result = (new Builder())->from('t')->filter([Query::notIntersects('attr', [1.0, 2.0])])->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ST_Intersects', $result->query); } @@ -5384,6 +5691,7 @@ public function testKitchenSinkExactSql(): void ->offset(20) ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT DISTINCT COUNT(*) AS `total`, `status` FROM `orders` JOIN `users` ON `orders`.`uid` = `users`.`id` WHERE `amount` > ? GROUP BY `status` HAVING `total` > ? ORDER BY `status` ASC LIMIT ? OFFSET ?) UNION (SELECT * FROM `archive` WHERE `status` IN (?))', @@ -5397,6 +5705,7 @@ public function testDistinctWithUnion(): void { $other = (new Builder())->from('b'); $result = (new Builder())->from('a')->distinct()->union($other)->build(); + $this->assertBindingCount($result); $this->assertEquals('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result->query); $this->assertEquals([], $result->bindings); } @@ -5409,6 +5718,7 @@ public function testRawInsideLogicalAnd(): void Query::raw('custom_func(y) > ?', [5]), ])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`x` > ? AND custom_func(y) > ?)', $result->query); $this->assertEquals([1, 5], $result->bindings); } @@ -5421,6 +5731,7 @@ public function testRawInsideLogicalOr(): void Query::raw('b IS NOT NULL', []), ])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result->query); $this->assertEquals([1], $result->bindings); } @@ -5431,6 +5742,7 @@ public function testAggregationWithCursor(): void ->count('*', 'total') ->cursorAfter('abc') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*)', $result->query); $this->assertStringContainsString('`_cursor` > ?', $result->query); $this->assertContains('abc', $result->bindings); @@ -5446,6 +5758,7 @@ public function testGroupBySortCursorUnion(): void ->cursorAfter('xyz') ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('GROUP BY', $result->query); $this->assertStringContainsString('ORDER BY', $result->query); $this->assertStringContainsString('UNION', $result->query); @@ -5462,6 +5775,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE _tenant = ?', $result->query); $this->assertEquals(['t1'], $result->bindings); } @@ -5478,6 +5792,7 @@ public function filter(string $table): Condition }) ->cursorAfter('abc') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('_tenant = ?', $result->query); $this->assertStringContainsString('`_cursor` > ?', $result->query); // Provider bindings come before cursor bindings @@ -5496,6 +5811,7 @@ public function filter(string $table): Condition } }) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT DISTINCT * FROM `t` WHERE _tenant = ?', $result->query); $this->assertEquals(['t1'], $result->bindings); } @@ -5513,6 +5829,7 @@ public function filter(string $table): Condition $builder->build(); $builder->reset()->from('other'); $result = $builder->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `other`', $result->query); $this->assertStringContainsString('_tenant = ?', $result->query); $this->assertEquals(['t1'], $result->bindings); @@ -5532,6 +5849,7 @@ public function filter(string $table): Condition }) ->having([Query::greaterThan('total', 5)]) ->build(); + $this->assertBindingCount($result); // Provider should be in WHERE, not HAVING $this->assertStringContainsString('WHERE _tenant = ?', $result->query); $this->assertStringContainsString('HAVING `total` > ?', $result->query); @@ -5553,6 +5871,7 @@ public function filter(string $table): Condition ->from('a') ->union($sub) ->build(); + $this->assertBindingCount($result); // Sub-query should include the condition provider $this->assertStringContainsString('UNION (SELECT * FROM `b` WHERE _deleted = ?)', $result->query); $this->assertEquals([0], $result->bindings); @@ -5562,6 +5881,7 @@ public function filter(string $table): Condition public function testNegativeLimit(): void { $result = (new Builder())->from('t')->limit(-1)->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); $this->assertEquals([-1], $result->bindings); } @@ -5570,6 +5890,7 @@ public function testNegativeOffset(): void { // OFFSET without LIMIT is suppressed $result = (new Builder())->from('t')->offset(-5)->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); $this->assertEquals([], $result->bindings); } @@ -5577,6 +5898,7 @@ public function testNegativeOffset(): void public function testEqualWithNullOnly(): void { $result = (new Builder())->from('t')->filter([Query::equal('col', [null])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NULL', $result->query); $this->assertSame([], $result->bindings); } @@ -5584,6 +5906,7 @@ public function testEqualWithNullOnly(): void public function testEqualWithNullAndNonNull(): void { $result = (new Builder())->from('t')->filter([Query::equal('col', ['a', null])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`col` IN (?) OR `col` IS NULL)', $result->query); $this->assertSame(['a'], $result->bindings); } @@ -5591,6 +5914,7 @@ public function testEqualWithNullAndNonNull(): void public function testNotEqualWithNullOnly(): void { $result = (new Builder())->from('t')->filter([Query::notEqual('col', [null])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NOT NULL', $result->query); $this->assertSame([], $result->bindings); } @@ -5598,6 +5922,7 @@ public function testNotEqualWithNullOnly(): void public function testNotEqualWithNullAndNonNull(): void { $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', null])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`col` != ? AND `col` IS NOT NULL)', $result->query); $this->assertSame(['a'], $result->bindings); } @@ -5605,6 +5930,7 @@ public function testNotEqualWithNullAndNonNull(): void public function testNotEqualWithMultipleNonNullAndNull(): void { $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', 'b', null])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE (`col` NOT IN (?, ?) AND `col` IS NOT NULL)', $result->query); $this->assertSame(['a', 'b'], $result->bindings); } @@ -5612,6 +5938,7 @@ public function testNotEqualWithMultipleNonNullAndNull(): void public function testBetweenReversedMinMax(): void { $result = (new Builder())->from('t')->filter([Query::between('age', 65, 18)])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); $this->assertEquals([65, 18], $result->bindings); } @@ -5619,6 +5946,7 @@ public function testBetweenReversedMinMax(): void public function testContainsWithSqlWildcard(): void { $result = (new Builder())->from('t')->filter([Query::contains('bio', ['100%'])])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); $this->assertEquals(['%100\%%'], $result->bindings); } @@ -5626,6 +5954,7 @@ public function testContainsWithSqlWildcard(): void public function testStartsWithWithWildcard(): void { $result = (new Builder())->from('t')->filter([Query::startsWith('name', '%admin')])->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); $this->assertEquals(['\%admin%'], $result->bindings); } @@ -5634,6 +5963,7 @@ public function testCursorWithNullValue(): void { // Null cursor value is ignored by groupByType since cursor stays null $result = (new Builder())->from('t')->cursorAfter(null)->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('_cursor', $result->query); $this->assertEquals([], $result->bindings); } @@ -5641,6 +5971,7 @@ public function testCursorWithNullValue(): void public function testCursorWithIntegerValue(): void { $result = (new Builder())->from('t')->cursorAfter(42)->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`_cursor` > ?', $result->query); $this->assertSame([42], $result->bindings); } @@ -5648,6 +5979,7 @@ public function testCursorWithIntegerValue(): void public function testCursorWithFloatValue(): void { $result = (new Builder())->from('t')->cursorAfter(3.14)->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`_cursor` > ?', $result->query); $this->assertSame([3.14], $result->bindings); } @@ -5655,6 +5987,7 @@ public function testCursorWithFloatValue(): void public function testMultipleLimitsFirstWins(): void { $result = (new Builder())->from('t')->limit(10)->limit(20)->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); $this->assertEquals([10], $result->bindings); } @@ -5663,6 +5996,7 @@ public function testMultipleOffsetsFirstWins(): void { // OFFSET without LIMIT is suppressed $result = (new Builder())->from('t')->offset(5)->offset(50)->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); $this->assertEquals([], $result->bindings); } @@ -5670,6 +6004,7 @@ public function testMultipleOffsetsFirstWins(): void public function testCursorAfterAndBeforeFirstWins(): void { $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`_cursor` > ?', $result->query); $this->assertStringNotContainsString('`_cursor` < ?', $result->query); } @@ -5835,6 +6170,7 @@ public function testResetFollowedByUnion(): void ->union((new Builder())->from('old')); $builder->reset()->from('b'); $result = $builder->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `b`', $result->query); $this->assertStringNotContainsString('UNION', $result->query); } @@ -5846,6 +6182,7 @@ public function testResetClearsBindingsAfterBuild(): void $this->assertNotEmpty($builder->getBindings()); $builder->reset()->from('t'); $result = $builder->build(); + $this->assertBindingCount($result); $this->assertEquals([], $result->bindings); } // Missing Binding Assertions @@ -5853,48 +6190,56 @@ public function testResetClearsBindingsAfterBuild(): void public function testSortAscBindingsEmpty(): void { $result = (new Builder())->from('t')->sortAsc('name')->build(); + $this->assertBindingCount($result); $this->assertEquals([], $result->bindings); } public function testSortDescBindingsEmpty(): void { $result = (new Builder())->from('t')->sortDesc('name')->build(); + $this->assertBindingCount($result); $this->assertEquals([], $result->bindings); } public function testSortRandomBindingsEmpty(): void { $result = (new Builder())->from('t')->sortRandom()->build(); + $this->assertBindingCount($result); $this->assertEquals([], $result->bindings); } public function testDistinctBindingsEmpty(): void { $result = (new Builder())->from('t')->distinct()->build(); + $this->assertBindingCount($result); $this->assertEquals([], $result->bindings); } public function testJoinBindingsEmpty(): void { $result = (new Builder())->from('t')->join('other', 'a', 'b')->build(); + $this->assertBindingCount($result); $this->assertEquals([], $result->bindings); } public function testCrossJoinBindingsEmpty(): void { $result = (new Builder())->from('t')->crossJoin('other')->build(); + $this->assertBindingCount($result); $this->assertEquals([], $result->bindings); } public function testGroupByBindingsEmpty(): void { $result = (new Builder())->from('t')->groupBy(['status'])->build(); + $this->assertBindingCount($result); $this->assertEquals([], $result->bindings); } public function testCountWithAliasBindingsEmpty(): void { $result = (new Builder())->from('t')->count('*', 'total')->build(); + $this->assertBindingCount($result); $this->assertEquals([], $result->bindings); } // DML: INSERT @@ -5905,6 +6250,7 @@ public function testInsertSingleRow(): void ->into('users') ->set(['name' => 'Alice', 'email' => 'a@b.com']) ->insert(); + $this->assertBindingCount($result); $this->assertEquals( 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?)', @@ -5920,6 +6266,7 @@ public function testInsertBatch(): void ->set(['name' => 'Alice', 'email' => 'a@b.com']) ->set(['name' => 'Bob', 'email' => 'b@b.com']) ->insert(); + $this->assertBindingCount($result); $this->assertEquals( 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?), (?, ?)', @@ -5952,6 +6299,7 @@ public function testUpsertSingleRow(): void ->set(['id' => 1, 'name' => 'Alice', 'email' => 'a@b.com']) ->onConflict(['id'], ['name', 'email']) ->upsert(); + $this->assertBindingCount($result); $this->assertEquals( 'INSERT INTO `users` (`id`, `name`, `email`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `email` = VALUES(`email`)', @@ -5967,6 +6315,7 @@ public function testUpsertMultipleConflictColumns(): void ->set(['user_id' => 1, 'role_id' => 2, 'granted_at' => '2024-01-01']) ->onConflict(['user_id', 'role_id'], ['granted_at']) ->upsert(); + $this->assertBindingCount($result); $this->assertEquals( 'INSERT INTO `user_roles` (`user_id`, `role_id`, `granted_at`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `granted_at` = VALUES(`granted_at`)', @@ -5983,6 +6332,7 @@ public function testUpdateWithWhere(): void ->set(['status' => 'archived']) ->filter([Query::equal('status', ['inactive'])]) ->update(); + $this->assertBindingCount($result); $this->assertEquals( 'UPDATE `users` SET `status` = ? WHERE `status` IN (?)', @@ -5999,6 +6349,7 @@ public function testUpdateWithSetRaw(): void ->setRaw('login_count', 'login_count + 1') ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertEquals( 'UPDATE `users` SET `name` = ?, `login_count` = login_count + 1 WHERE `id` IN (?)', @@ -6009,7 +6360,7 @@ public function testUpdateWithSetRaw(): void public function testUpdateWithFilterHook(): void { - $hook = new class () implements Filter, \Utopia\Query\Hook { + $hook = new class () implements Filter, Hook { public function filter(string $table): Condition { return new Condition('`_tenant` = ?', ['tenant_123']); @@ -6022,6 +6373,7 @@ public function filter(string $table): Condition ->filter([Query::equal('id', [1])]) ->addHook($hook) ->update(); + $this->assertBindingCount($result); $this->assertEquals( 'UPDATE `users` SET `status` = ? WHERE `id` IN (?) AND `_tenant` = ?', @@ -6036,6 +6388,7 @@ public function testUpdateWithoutWhere(): void ->from('users') ->set(['status' => 'active']) ->update(); + $this->assertBindingCount($result); $this->assertEquals('UPDATE `users` SET `status` = ?', $result->query); $this->assertEquals(['active'], $result->bindings); @@ -6050,6 +6403,7 @@ public function testUpdateWithOrderByAndLimit(): void ->sortAsc('created_at') ->limit(100) ->update(); + $this->assertBindingCount($result); $this->assertEquals( 'UPDATE `users` SET `status` = ? WHERE `active` IN (?) ORDER BY `created_at` ASC LIMIT ?', @@ -6074,6 +6428,7 @@ public function testDeleteWithWhere(): void ->from('users') ->filter([Query::lessThan('last_login', '2024-01-01')]) ->delete(); + $this->assertBindingCount($result); $this->assertEquals( 'DELETE FROM `users` WHERE `last_login` < ?', @@ -6084,7 +6439,7 @@ public function testDeleteWithWhere(): void public function testDeleteWithFilterHook(): void { - $hook = new class () implements Filter, \Utopia\Query\Hook { + $hook = new class () implements Filter, Hook { public function filter(string $table): Condition { return new Condition('`_tenant` = ?', ['tenant_123']); @@ -6096,6 +6451,7 @@ public function filter(string $table): Condition ->filter([Query::equal('status', ['deleted'])]) ->addHook($hook) ->delete(); + $this->assertBindingCount($result); $this->assertEquals( 'DELETE FROM `users` WHERE `status` IN (?) AND `_tenant` = ?', @@ -6109,6 +6465,7 @@ public function testDeleteWithoutWhere(): void $result = (new Builder()) ->from('users') ->delete(); + $this->assertBindingCount($result); $this->assertEquals('DELETE FROM `users`', $result->query); $this->assertEquals([], $result->bindings); @@ -6122,6 +6479,7 @@ public function testDeleteWithOrderByAndLimit(): void ->sortAsc('created_at') ->limit(1000) ->delete(); + $this->assertBindingCount($result); $this->assertEquals( 'DELETE FROM `logs` WHERE `created_at` < ? ORDER BY `created_at` ASC LIMIT ?', @@ -6244,6 +6602,7 @@ public function testIntersect(): void ->from('users') ->intersect($other) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `users`) INTERSECT (SELECT * FROM `admins`)', @@ -6258,6 +6617,7 @@ public function testIntersectAll(): void ->from('users') ->intersectAll($other) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `users`) INTERSECT ALL (SELECT * FROM `admins`)', @@ -6272,6 +6632,7 @@ public function testExcept(): void ->from('users') ->except($other) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `users`) EXCEPT (SELECT * FROM `banned`)', @@ -6286,6 +6647,7 @@ public function testExceptAll(): void ->from('users') ->exceptAll($other) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `users`) EXCEPT ALL (SELECT * FROM `banned`)', @@ -6301,6 +6663,7 @@ public function testIntersectWithBindings(): void ->filter([Query::equal('status', ['active'])]) ->intersect($other) ->build(); + $this->assertBindingCount($result); $this->assertEquals( '(SELECT * FROM `users` WHERE `status` IN (?)) INTERSECT (SELECT * FROM `admins` WHERE `role` IN (?))', @@ -6317,6 +6680,7 @@ public function testExceptWithBindings(): void ->filter([Query::equal('status', ['active'])]) ->except($other) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['active', 'spam'], $result->bindings); } @@ -6333,6 +6697,7 @@ public function testMixedSetOperations(): void ->intersect($q2) ->except($q3) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('UNION', $result->query); $this->assertStringContainsString('INTERSECT', $result->query); @@ -6361,6 +6726,7 @@ public function testForUpdate(): void ->filter([Query::equal('id', [1])]) ->forUpdate() ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `accounts` WHERE `id` IN (?) FOR UPDATE', @@ -6376,6 +6742,7 @@ public function testForShare(): void ->filter([Query::equal('id', [1])]) ->forShare() ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `accounts` WHERE `id` IN (?) FOR SHARE', @@ -6391,6 +6758,7 @@ public function testForUpdateWithLimitAndOffset(): void ->offset(5) ->forUpdate() ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `accounts` LIMIT ? OFFSET ? FOR UPDATE', @@ -6406,6 +6774,7 @@ public function testLockModeResetClears(): void $builder->reset(); $result = $builder->from('t')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); } // Transaction Statements @@ -6535,6 +6904,7 @@ public function testCteWith(): void ->from('paid_orders') ->select(['customer_id']) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'WITH `paid_orders` AS (SELECT * FROM `orders` WHERE `status` IN (?)) SELECT `customer_id` FROM `paid_orders`', @@ -6551,6 +6921,7 @@ public function testCteWithRecursive(): void ->withRecursive('tree', $cte) ->from('tree') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'WITH RECURSIVE `tree` AS (SELECT * FROM `categories`) SELECT * FROM `tree`', @@ -6568,6 +6939,7 @@ public function testMultipleCtes(): void ->with('approved_returns', $cte2) ->from('paid') ->build(); + $this->assertBindingCount($result); $this->assertStringStartsWith('WITH `paid` AS', $result->query); $this->assertStringContainsString('`approved_returns` AS', $result->query); @@ -6583,6 +6955,7 @@ public function testCteBindingsComeBefore(): void ->from('recent') ->filter([Query::greaterThan('amount', 100)]) ->build(); + $this->assertBindingCount($result); $this->assertEquals([2024, 100], $result->bindings); } @@ -6595,6 +6968,7 @@ public function testCteResetClears(): void $builder->reset(); $result = $builder->from('t')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); } @@ -6608,6 +6982,7 @@ public function testMixedRecursiveAndNonRecursiveCte(): void ->withRecursive('tree', $cte1) ->from('tree') ->build(); + $this->assertBindingCount($result); $this->assertStringStartsWith('WITH RECURSIVE', $result->query); $this->assertStringContainsString('`prods` AS', $result->query); @@ -6665,9 +7040,8 @@ public function testCaseExpressionToSql(): void ->when('a = ?', '1', [1]) ->build(); - $arr = $case->toSql(); - $this->assertEquals('CASE WHEN a = ? THEN 1 END', $arr['sql']); - $this->assertEquals([1], $arr['bindings']); + $this->assertEquals('CASE WHEN a = ? THEN 1 END', $case->sql); + $this->assertEquals([1], $case->bindings); } public function testSelectRaw(): void @@ -6676,6 +7050,7 @@ public function testSelectRaw(): void ->from('orders') ->selectRaw('SUM(amount) AS total') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT SUM(amount) AS total FROM `orders`', $result->query); } @@ -6686,6 +7061,7 @@ public function testSelectRawWithBindings(): void ->from('orders') ->selectRaw('IF(amount > ?, 1, 0) AS big_order', [1000]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT IF(amount > ?, 1, 0) AS big_order FROM `orders`', $result->query); $this->assertEquals([1000], $result->bindings); @@ -6698,6 +7074,7 @@ public function testSelectRawCombinedWithSelect(): void ->select(['id', 'customer_id']) ->selectRaw('SUM(amount) AS total') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT `id`, `customer_id`, SUM(amount) AS total FROM `orders`', $result->query); } @@ -6715,6 +7092,7 @@ public function testSelectRawWithCaseExpression(): void ->select(['id']) ->selectRaw($case->sql, $case->bindings) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CASE WHEN status = ? THEN ? ELSE ? END AS label', $result->query); $this->assertEquals(['active', 'Active', 'Other'], $result->bindings); @@ -6727,6 +7105,7 @@ public function testSelectRawResetClears(): void $builder->reset(); $result = $builder->from('t')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); } @@ -6738,6 +7117,7 @@ public function testSetRawWithBindings(): void ->setRaw('balance', 'balance + ?', [100]) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertEquals( 'UPDATE `accounts` SET `name` = ?, `balance` = balance + ? WHERE `id` IN (?)', @@ -6762,6 +7142,7 @@ public function testMultipleSelectRaw(): void ->selectRaw('COUNT(*) AS cnt') ->selectRaw('MAX(price) AS max_price') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT COUNT(*) AS cnt, MAX(price) AS max_price FROM `t`', $result->query); } @@ -6774,6 +7155,7 @@ public function testForUpdateNotInUnion(): void ->forUpdate() ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FOR UPDATE', $result->query); } @@ -6788,6 +7170,7 @@ public function testCteWithUnion(): void ->from('o') ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertStringStartsWith('WITH `o` AS', $result->query); $this->assertStringContainsString('UNION', $result->query); @@ -6796,7 +7179,7 @@ public function testCteWithUnion(): void public function testImplementsSpatial(): void { - $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Spatial::class, new Builder()); + $this->assertInstanceOf(Spatial::class, new Builder()); } public function testFilterDistanceMeters(): void @@ -6805,6 +7188,7 @@ public function testFilterDistanceMeters(): void ->from('locations') ->filterDistance('coords', [40.7128, -74.0060], '<', 5000.0, true) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Distance(ST_SRID(`coords`, 4326), ST_GeomFromText(?, 4326), \'metre\') < ?', $result->query); $this->assertEquals('POINT(40.7128 -74.006)', $result->bindings[0]); @@ -6817,6 +7201,7 @@ public function testFilterDistanceNoMeters(): void ->from('locations') ->filterDistance('coords', [1.0, 2.0], '>', 100.0) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Distance(`coords`, ST_GeomFromText(?)) > ?', $result->query); } @@ -6827,6 +7212,7 @@ public function testFilterIntersectsPoint(): void ->from('zones') ->filterIntersects('area', [1.0, 2.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Intersects(`area`, ST_GeomFromText(?, 4326))', $result->query); $this->assertEquals('POINT(1 2)', $result->bindings[0]); @@ -6838,6 +7224,7 @@ public function testFilterNotIntersects(): void ->from('zones') ->filterNotIntersects('area', [1.0, 2.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ST_Intersects', $result->query); } @@ -6848,6 +7235,7 @@ public function testFilterCovers(): void ->from('zones') ->filterCovers('area', [1.0, 2.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Contains(`area`, ST_GeomFromText(?, 4326))', $result->query); } @@ -6858,6 +7246,7 @@ public function testFilterSpatialEquals(): void ->from('zones') ->filterSpatialEquals('area', [1.0, 2.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Equals', $result->query); } @@ -6868,6 +7257,7 @@ public function testSpatialWithLinestring(): void ->from('roads') ->filterIntersects('path', [[0, 0], [1, 1], [2, 2]]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('LINESTRING(0 0, 1 1, 2 2)', $result->bindings[0]); } @@ -6878,6 +7268,7 @@ public function testSpatialWithPolygon(): void ->from('areas') ->filterIntersects('zone', [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]) ->build(); + $this->assertBindingCount($result); /** @var string $wkt */ $wkt = $result->bindings[0]; @@ -6887,7 +7278,7 @@ public function testSpatialWithPolygon(): void public function testImplementsJson(): void { - $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Json::class, new Builder()); + $this->assertInstanceOf(Json::class, new Builder()); } public function testFilterJsonContains(): void @@ -6896,6 +7287,7 @@ public function testFilterJsonContains(): void ->from('docs') ->filterJsonContains('tags', 'php') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JSON_CONTAINS(`tags`, ?)', $result->query); $this->assertEquals('"php"', $result->bindings[0]); @@ -6907,6 +7299,7 @@ public function testFilterJsonNotContains(): void ->from('docs') ->filterJsonNotContains('tags', 'old') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT JSON_CONTAINS(`tags`, ?)', $result->query); } @@ -6917,6 +7310,7 @@ public function testFilterJsonOverlaps(): void ->from('docs') ->filterJsonOverlaps('tags', ['php', 'go']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JSON_OVERLAPS(`tags`, ?)', $result->query); $this->assertEquals('["php","go"]', $result->bindings[0]); @@ -6928,6 +7322,7 @@ public function testFilterJsonPath(): void ->from('users') ->filterJsonPath('metadata', 'level', '>', 5) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString("JSON_EXTRACT(`metadata`, '$.level') > ?", $result->query); $this->assertEquals(5, $result->bindings[0]); @@ -6940,6 +7335,7 @@ public function testSetJsonAppend(): void ->setJsonAppend('tags', ['new_tag']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('JSON_MERGE_PRESERVE(IFNULL(`tags`, JSON_ARRAY()), ?)', $result->query); } @@ -6951,6 +7347,7 @@ public function testSetJsonPrepend(): void ->setJsonPrepend('tags', ['first']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('JSON_MERGE_PRESERVE(?, IFNULL(`tags`, JSON_ARRAY()))', $result->query); } @@ -6962,6 +7359,7 @@ public function testSetJsonInsert(): void ->setJsonInsert('tags', 0, 'inserted') ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('JSON_ARRAY_INSERT', $result->query); } @@ -6973,6 +7371,7 @@ public function testSetJsonRemove(): void ->setJsonRemove('tags', 'old_tag') ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('JSON_REMOVE', $result->query); } @@ -6980,7 +7379,7 @@ public function testSetJsonRemove(): void public function testImplementsHints(): void { - $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Hints::class, new Builder()); + $this->assertInstanceOf(Hints::class, new Builder()); } public function testHintInSelect(): void @@ -6989,6 +7388,7 @@ public function testHintInSelect(): void ->from('users') ->hint('NO_INDEX_MERGE(users)') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('/*+ NO_INDEX_MERGE(users) */', $result->query); } @@ -6999,6 +7399,7 @@ public function testMaxExecutionTime(): void ->from('users') ->maxExecutionTime(5000) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('/*+ MAX_EXECUTION_TIME(5000) */', $result->query); } @@ -7010,6 +7411,7 @@ public function testMultipleHints(): void ->hint('NO_INDEX_MERGE(users)') ->hint('BKA(users)') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('/*+ NO_INDEX_MERGE(users) BKA(users) */', $result->query); } @@ -7017,7 +7419,7 @@ public function testMultipleHints(): void public function testImplementsWindows(): void { - $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Windows::class, new Builder()); + $this->assertInstanceOf(Windows::class, new Builder()); } public function testSelectWindowRowNumber(): void @@ -7026,6 +7428,7 @@ public function testSelectWindowRowNumber(): void ->from('orders') ->selectWindow('ROW_NUMBER()', 'rn', ['customer_id'], ['created_at']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY `customer_id` ORDER BY `created_at` ASC) AS `rn`', $result->query); } @@ -7036,6 +7439,7 @@ public function testSelectWindowRank(): void ->from('scores') ->selectWindow('RANK()', 'rank', null, ['-score']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('RANK() OVER (ORDER BY `score` DESC) AS `rank`', $result->query); } @@ -7046,6 +7450,7 @@ public function testSelectWindowPartitionOnly(): void ->from('orders') ->selectWindow('SUM(amount)', 'total', ['dept']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SUM(amount) OVER (PARTITION BY `dept`) AS `total`', $result->query); } @@ -7056,6 +7461,7 @@ public function testSelectWindowNoPartitionNoOrder(): void ->from('orders') ->selectWindow('COUNT(*)', 'total') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*) OVER () AS `total`', $result->query); } @@ -7074,6 +7480,7 @@ public function testSelectCaseExpression(): void ->select(['id']) ->selectCase($case) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CASE WHEN status = ? THEN ? ELSE ? END AS label', $result->query); $this->assertEquals(['active', 'Active', 'Other'], $result->bindings); @@ -7091,6 +7498,7 @@ public function testSetCaseExpression(): void ->setCase('category', $case) ->filter([Query::greaterThan('id', 0)]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('`category` = CASE WHEN age >= ? THEN ? ELSE ? END', $result->query); $this->assertEquals([18, 'adult', 'minor', 0], $result->bindings); @@ -7100,20 +7508,20 @@ public function testSetCaseExpression(): void public function testQueryJsonContainsFactory(): void { $q = Query::jsonContains('tags', 'php'); - $this->assertEquals(\Utopia\Query\Method::JsonContains, $q->getMethod()); + $this->assertEquals(Method::JsonContains, $q->getMethod()); $this->assertEquals('tags', $q->getAttribute()); } public function testQueryJsonOverlapsFactory(): void { $q = Query::jsonOverlaps('tags', ['php', 'go']); - $this->assertEquals(\Utopia\Query\Method::JsonOverlaps, $q->getMethod()); + $this->assertEquals(Method::JsonOverlaps, $q->getMethod()); } public function testQueryJsonPathFactory(): void { $q = Query::jsonPath('meta', 'level', '>', 5); - $this->assertEquals(\Utopia\Query\Method::JsonPath, $q->getMethod()); + $this->assertEquals(Method::JsonPath, $q->getMethod()); $this->assertEquals(['level', '>', 5], $q->getValues()); } // Does NOT implement VectorSearch @@ -7121,7 +7529,7 @@ public function testQueryJsonPathFactory(): void public function testDoesNotImplementVectorSearch(): void { $builder = new Builder(); - $this->assertNotInstanceOf(\Utopia\Query\Builder\Feature\VectorSearch::class, $builder); // @phpstan-ignore method.alreadyNarrowedType + $this->assertNotInstanceOf(VectorSearch::class, $builder); // @phpstan-ignore method.alreadyNarrowedType } // Reset clears new state @@ -7135,6 +7543,7 @@ public function testResetClearsHintsAndJsonSets(): void $builder->reset(); $result = $builder->from('users')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('/*+', $result->query); } @@ -7144,6 +7553,7 @@ public function testFilterNotIntersectsPoint(): void ->from('zones') ->filterNotIntersects('zone', [1.0, 2.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ST_Intersects', $result->query); $this->assertEquals('POINT(1 2)', $result->bindings[0]); @@ -7155,6 +7565,7 @@ public function testFilterNotCrossesLinestring(): void ->from('roads') ->filterNotCrosses('path', [[0, 0], [1, 1]]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ST_Crosses', $result->query); /** @var string $binding */ @@ -7168,6 +7579,7 @@ public function testFilterOverlapsPolygon(): void ->from('regions') ->filterOverlaps('area', [[[0, 0], [1, 0], [1, 1], [0, 0]]]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Overlaps', $result->query); /** @var string $binding */ @@ -7181,6 +7593,7 @@ public function testFilterNotOverlaps(): void ->from('regions') ->filterNotOverlaps('area', [1.0, 2.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ST_Overlaps', $result->query); } @@ -7191,6 +7604,7 @@ public function testFilterTouches(): void ->from('zones') ->filterTouches('zone', [5.0, 10.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Touches', $result->query); } @@ -7201,6 +7615,7 @@ public function testFilterNotTouches(): void ->from('zones') ->filterNotTouches('zone', [5.0, 10.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ST_Touches', $result->query); } @@ -7211,6 +7626,7 @@ public function testFilterNotCovers(): void ->from('zones') ->filterNotCovers('region', [1.0, 2.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ST_Contains', $result->query); } @@ -7221,6 +7637,7 @@ public function testFilterNotSpatialEquals(): void ->from('zones') ->filterNotSpatialEquals('geom', [3.0, 4.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ST_Equals', $result->query); } @@ -7231,6 +7648,7 @@ public function testFilterDistanceGreaterThan(): void ->from('locations') ->filterDistance('loc', [1.0, 2.0], '>', 500.0) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Distance', $result->query); $this->assertStringContainsString('> ?', $result->query); @@ -7244,6 +7662,7 @@ public function testFilterDistanceEqual(): void ->from('locations') ->filterDistance('loc', [1.0, 2.0], '=', 0.0) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Distance', $result->query); $this->assertStringContainsString('= ?', $result->query); @@ -7257,6 +7676,7 @@ public function testFilterDistanceNotEqual(): void ->from('locations') ->filterDistance('loc', [1.0, 2.0], '!=', 100.0) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Distance', $result->query); $this->assertStringContainsString('!= ?', $result->query); @@ -7270,6 +7690,7 @@ public function testFilterDistanceWithoutMeters(): void ->from('locations') ->filterDistance('loc', [1.0, 2.0], '<', 50.0, false) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Distance(`loc`, ST_GeomFromText(?)) < ?', $result->query); $this->assertEquals('POINT(1 2)', $result->bindings[0]); @@ -7282,6 +7703,7 @@ public function testFilterIntersectsLinestring(): void ->from('roads') ->filterIntersects('path', [[0, 0], [1, 1], [2, 2]]) ->build(); + $this->assertBindingCount($result); /** @var string $binding */ $binding = $result->bindings[0]; @@ -7294,6 +7716,7 @@ public function testFilterSpatialEqualsPoint(): void ->from('places') ->filterSpatialEquals('pos', [42.5, -73.2]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Equals', $result->query); $this->assertEquals('POINT(42.5 -73.2)', $result->bindings[0]); @@ -7306,6 +7729,7 @@ public function testSetJsonIntersect(): void ->setJsonIntersect('tags', ['a', 'b']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('JSON_ARRAYAGG', $result->query); $this->assertStringContainsString('JSON_CONTAINS(?, val)', $result->query); @@ -7319,6 +7743,7 @@ public function testSetJsonDiff(): void ->setJsonDiff('tags', ['x']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT JSON_CONTAINS(?, val)', $result->query); $this->assertContains(\json_encode(['x']), $result->bindings); @@ -7331,6 +7756,7 @@ public function testSetJsonUnique(): void ->setJsonUnique('tags') ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('JSON_ARRAYAGG', $result->query); $this->assertStringContainsString('DISTINCT', $result->query); @@ -7343,6 +7769,7 @@ public function testSetJsonPrependMergeOrder(): void ->setJsonPrepend('items', ['first']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('JSON_MERGE_PRESERVE(?, IFNULL(', $result->query); } @@ -7354,6 +7781,7 @@ public function testSetJsonInsertWithIndex(): void ->setJsonInsert('items', 2, 'value') ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('JSON_ARRAY_INSERT', $result->query); $this->assertContains('$[2]', $result->bindings); @@ -7366,6 +7794,7 @@ public function testFilterJsonNotContainsCompiles(): void ->from('docs') ->filterJsonNotContains('meta', 'admin') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT JSON_CONTAINS(`meta`, ?)', $result->query); } @@ -7376,6 +7805,7 @@ public function testFilterJsonOverlapsCompiles(): void ->from('docs') ->filterJsonOverlaps('tags', ['php', 'js']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JSON_OVERLAPS(`tags`, ?)', $result->query); } @@ -7386,6 +7816,7 @@ public function testFilterJsonPathCompiles(): void ->from('users') ->filterJsonPath('data', 'age', '>=', 21) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString("JSON_EXTRACT(`data`, '$.age') >= ?", $result->query); $this->assertEquals(21, $result->bindings[0]); @@ -7398,6 +7829,7 @@ public function testMultipleHintsNoIcpAndBka(): void ->hint('NO_ICP(t)') ->hint('BKA(t)') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('/*+ NO_ICP(t) BKA(t) */', $result->query); } @@ -7409,6 +7841,7 @@ public function testHintWithDistinct(): void ->distinct() ->hint('SET_VAR(sort_buffer_size=16M)') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SELECT DISTINCT /*+', $result->query); } @@ -7420,6 +7853,7 @@ public function testHintPreservesBindings(): void ->hint('NO_ICP(t)') ->filter([Query::equal('status', ['active'])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['active'], $result->bindings); } @@ -7430,6 +7864,7 @@ public function testMaxExecutionTimeValue(): void ->from('t') ->maxExecutionTime(5000) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('/*+ MAX_EXECUTION_TIME(5000) */', $result->query); } @@ -7440,6 +7875,7 @@ public function testSelectWindowWithPartitionOnly(): void ->from('t') ->selectWindow('SUM(amount)', 'total', ['dept']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SUM(amount) OVER (PARTITION BY `dept`) AS `total`', $result->query); } @@ -7450,6 +7886,7 @@ public function testSelectWindowWithOrderOnly(): void ->from('t') ->selectWindow('ROW_NUMBER()', 'rn', null, ['created_at']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ROW_NUMBER() OVER (ORDER BY `created_at` ASC) AS `rn`', $result->query); } @@ -7460,6 +7897,7 @@ public function testSelectWindowNoPartitionNoOrderEmpty(): void ->from('t') ->selectWindow('COUNT(*)', 'cnt') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*) OVER () AS `cnt`', $result->query); } @@ -7471,6 +7909,7 @@ public function testMultipleWindowFunctions(): void ->selectWindow('ROW_NUMBER()', 'rn', null, ['id']) ->selectWindow('SUM(amount)', 'running_total', null, ['id']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ROW_NUMBER()', $result->query); $this->assertStringContainsString('SUM(amount)', $result->query); @@ -7482,6 +7921,7 @@ public function testSelectWindowWithDescOrder(): void ->from('t') ->selectWindow('RANK()', 'r', null, ['-score']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY `score` DESC', $result->query); } @@ -7529,6 +7969,7 @@ public function testSetCaseInUpdate(): void ->setCase('status', $case) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('UPDATE', $result->query); $this->assertStringContainsString('CASE WHEN', $result->query); @@ -7552,6 +7993,7 @@ public function testMultipleCTEsWithTwoSources(): void ->with('b', $cte2) ->from('a') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WITH `a` AS', $result->query); $this->assertStringContainsString('`b` AS', $result->query); @@ -7566,6 +8008,7 @@ public function testCTEWithBindings(): void ->from('paid_orders') ->filter([Query::greaterThan('amount', 100)]) ->build(); + $this->assertBindingCount($result); // CTE bindings come BEFORE main query bindings $this->assertEquals('paid', $result->bindings[0]); @@ -7582,6 +8025,7 @@ public function testCTEWithRecursiveMixed(): void ->withRecursive('tree', $cte2) ->from('tree') ->build(); + $this->assertBindingCount($result); $this->assertStringStartsWith('WITH RECURSIVE', $result->query); $this->assertStringContainsString('`prods` AS', $result->query); @@ -7598,6 +8042,7 @@ public function testCTEResetClearedAfterBuild(): void $builder->reset(); $result = $builder->from('users')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('WITH', $result->query); } @@ -7661,6 +8106,7 @@ public function testUnionAllCompiles(): void ->from('current') ->unionAll($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('UNION ALL', $result->query); } @@ -7672,6 +8118,7 @@ public function testIntersectCompiles(): void ->from('users') ->intersect($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('INTERSECT', $result->query); } @@ -7683,6 +8130,7 @@ public function testIntersectAllCompiles(): void ->from('users') ->intersectAll($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('INTERSECT ALL', $result->query); } @@ -7694,6 +8142,7 @@ public function testExceptCompiles(): void ->from('users') ->except($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('EXCEPT', $result->query); } @@ -7705,6 +8154,7 @@ public function testExceptAllCompiles(): void ->from('users') ->exceptAll($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('EXCEPT ALL', $result->query); } @@ -7717,6 +8167,7 @@ public function testUnionWithBindings(): void ->filter([Query::equal('status', ['active'])]) ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['active', 'admin'], $result->bindings); } @@ -7727,6 +8178,7 @@ public function testPageThreeWithTen(): void ->from('t') ->page(3, 10) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LIMIT ? OFFSET ?', $result->query); $this->assertEquals([10, 20], $result->bindings); @@ -7738,6 +8190,7 @@ public function testPageFirstPage(): void ->from('t') ->page(1, 25) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LIMIT ? OFFSET ?', $result->query); $this->assertEquals([25, 0], $result->bindings); @@ -7751,6 +8204,7 @@ public function testCursorAfterWithSort(): void ->cursorAfter(5) ->limit(10) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`_cursor` > ?', $result->query); $this->assertContains(5, $result->bindings); @@ -7765,6 +8219,7 @@ public function testCursorBeforeWithSort(): void ->cursorBefore(5) ->limit(10) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`_cursor` < ?', $result->query); $this->assertContains(5, $result->bindings); @@ -7824,6 +8279,7 @@ public function testWhenTrueAppliesLimit(): void ->from('t') ->when(true, fn (Builder $b) => $b->limit(5)) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LIMIT', $result->query); } @@ -7834,6 +8290,7 @@ public function testWhenFalseSkipsLimit(): void ->from('t') ->when(false, fn (Builder $b) => $b->limit(5)) ->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('LIMIT', $result->query); } @@ -7883,6 +8340,7 @@ public function testBatchInsertMultipleRows(): void ->set(['a' => 1, 'b' => 2]) ->set(['a' => 3, 'b' => 4]) ->insert(); + $this->assertBindingCount($result); $this->assertStringContainsString('VALUES (?, ?), (?, ?)', $result->query); $this->assertEquals([1, 2, 3, 4], $result->bindings); @@ -7915,6 +8373,7 @@ public function testSearchNotCompiles(): void ->from('t') ->filter([Query::notSearch('body', 'spam')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT (MATCH(`body`) AGAINST(?))', $result->query); } @@ -7925,6 +8384,7 @@ public function testRegexpCompiles(): void ->from('t') ->filter([Query::regex('slug', '^test')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`slug` REGEXP ?', $result->query); } @@ -7936,6 +8396,7 @@ public function testUpsertUsesOnDuplicateKey(): void ->set(['id' => 1, 'name' => 'Alice']) ->onConflict(['id'], ['name']) ->upsert(); + $this->assertBindingCount($result); $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $result->query); } @@ -7946,6 +8407,7 @@ public function testForUpdateCompiles(): void ->from('accounts') ->forUpdate() ->build(); + $this->assertBindingCount($result); $this->assertStringEndsWith('FOR UPDATE', $result->query); } @@ -7956,6 +8418,7 @@ public function testForShareCompiles(): void ->from('accounts') ->forShare() ->build(); + $this->assertBindingCount($result); $this->assertStringEndsWith('FOR SHARE', $result->query); } @@ -7967,6 +8430,7 @@ public function testForUpdateWithFilters(): void ->filter([Query::equal('id', [1])]) ->forUpdate() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WHERE', $result->query); $this->assertStringEndsWith('FOR UPDATE', $result->query); @@ -8006,6 +8470,7 @@ public function testResetClearsCTEs(): void $builder->reset(); $result = $builder->from('items')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('WITH', $result->query); } @@ -8019,6 +8484,7 @@ public function testResetClearsUnionsComprehensive(): void $builder->reset(); $result = $builder->from('items')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('UNION', $result->query); } @@ -8030,6 +8496,7 @@ public function testGroupByWithHavingCount(): void ->groupBy(['dept']) ->having([Query::and([Query::greaterThan('COUNT(*)', 5)])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('GROUP BY', $result->query); $this->assertStringContainsString('HAVING', $result->query); @@ -8042,6 +8509,7 @@ public function testGroupByMultipleColumnsAB(): void ->count('*', 'total') ->groupBy(['a', 'b']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('GROUP BY `a`, `b`', $result->query); } @@ -8052,6 +8520,7 @@ public function testEqualEmptyArrayReturnsFalse(): void ->from('t') ->filter([Query::equal('x', [])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('1 = 0', $result->query); } @@ -8062,6 +8531,7 @@ public function testEqualWithNullOnlyCompileIn(): void ->from('t') ->filter([Query::equal('x', [null])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`x` IS NULL', $result->query); $this->assertEquals([], $result->bindings); @@ -8073,6 +8543,7 @@ public function testEqualWithNullAndValues(): void ->from('t') ->filter([Query::equal('x', [1, null])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`x` IN (?) OR `x` IS NULL)', $result->query); $this->assertEquals([1], $result->bindings); @@ -8084,6 +8555,7 @@ public function testEqualMultipleValues(): void ->from('t') ->filter([Query::equal('x', [1, 2, 3])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`x` IN (?, ?, ?)', $result->query); $this->assertEquals([1, 2, 3], $result->bindings); @@ -8095,6 +8567,7 @@ public function testNotEqualEmptyArrayReturnsTrue(): void ->from('t') ->filter([Query::notEqual('x', [])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('1 = 1', $result->query); } @@ -8105,6 +8578,7 @@ public function testNotEqualSingleValue(): void ->from('t') ->filter([Query::notEqual('x', 5)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`x` != ?', $result->query); $this->assertEquals([5], $result->bindings); @@ -8116,6 +8590,7 @@ public function testNotEqualWithNullOnlyCompileNotIn(): void ->from('t') ->filter([Query::notEqual('x', [null])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`x` IS NOT NULL', $result->query); $this->assertEquals([], $result->bindings); @@ -8127,6 +8602,7 @@ public function testNotEqualWithNullAndValues(): void ->from('t') ->filter([Query::notEqual('x', [1, null])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`x` != ? AND `x` IS NOT NULL)', $result->query); $this->assertEquals([1], $result->bindings); @@ -8138,6 +8614,7 @@ public function testNotEqualMultipleValues(): void ->from('t') ->filter([Query::notEqual('x', [1, 2, 3])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`x` NOT IN (?, ?, ?)', $result->query); $this->assertEquals([1, 2, 3], $result->bindings); @@ -8149,6 +8626,7 @@ public function testNotEqualSingleNonNull(): void ->from('t') ->filter([Query::notEqual('x', 42)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`x` != ?', $result->query); $this->assertEquals([42], $result->bindings); @@ -8160,6 +8638,7 @@ public function testBetweenFilter(): void ->from('t') ->filter([Query::between('age', 18, 65)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`age` BETWEEN ? AND ?', $result->query); $this->assertEquals([18, 65], $result->bindings); @@ -8171,6 +8650,7 @@ public function testNotBetweenFilter(): void ->from('t') ->filter([Query::notBetween('score', 0, 50)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`score` NOT BETWEEN ? AND ?', $result->query); $this->assertEquals([0, 50], $result->bindings); @@ -8182,6 +8662,7 @@ public function testBetweenWithStrings(): void ->from('t') ->filter([Query::between('date', '2024-01-01', '2024-12-31')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`date` BETWEEN ? AND ?', $result->query); $this->assertEquals(['2024-01-01', '2024-12-31'], $result->bindings); @@ -8193,6 +8674,7 @@ public function testAndWithTwoFilters(): void ->from('t') ->filter([Query::and([Query::greaterThan('age', 18), Query::lessThan('age', 65)])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`age` > ? AND `age` < ?)', $result->query); $this->assertEquals([18, 65], $result->bindings); @@ -8204,6 +8686,7 @@ public function testOrWithTwoFilters(): void ->from('t') ->filter([Query::or([Query::equal('role', ['admin']), Query::equal('role', ['mod'])])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`role` IN (?) OR `role` IN (?))', $result->query); $this->assertEquals(['admin', 'mod'], $result->bindings); @@ -8220,6 +8703,7 @@ public function testNestedAndInsideOr(): void ]), ]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('((`a` > ? AND `b` < ?) OR `c` IN (?))', $result->query); $this->assertEquals([1, 2, 3], $result->bindings); @@ -8231,6 +8715,7 @@ public function testEmptyAndReturnsTrue(): void ->from('t') ->filter([Query::and([])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('1 = 1', $result->query); } @@ -8241,6 +8726,7 @@ public function testEmptyOrReturnsFalse(): void ->from('t') ->filter([Query::or([])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('1 = 0', $result->query); } @@ -8251,6 +8737,7 @@ public function testExistsSingleAttribute(): void ->from('t') ->filter([Query::exists(['name'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`name` IS NOT NULL)', $result->query); $this->assertEquals([], $result->bindings); @@ -8262,6 +8749,7 @@ public function testExistsMultipleAttributes(): void ->from('t') ->filter([Query::exists(['name', 'email'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`name` IS NOT NULL AND `email` IS NOT NULL)', $result->query); $this->assertEquals([], $result->bindings); @@ -8273,6 +8761,7 @@ public function testNotExistsSingleAttribute(): void ->from('t') ->filter([Query::notExists('name')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`name` IS NULL)', $result->query); $this->assertEquals([], $result->bindings); @@ -8284,6 +8773,7 @@ public function testNotExistsMultipleAttributes(): void ->from('t') ->filter([Query::notExists(['a', 'b'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`a` IS NULL AND `b` IS NULL)', $result->query); $this->assertEquals([], $result->bindings); @@ -8295,6 +8785,7 @@ public function testRawFilterWithSql(): void ->from('t') ->filter([Query::raw('score > ?', [10])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('score > ?', $result->query); $this->assertContains(10, $result->bindings); @@ -8306,6 +8797,7 @@ public function testRawFilterWithoutBindings(): void ->from('t') ->filter([Query::raw('active = 1')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('active = 1', $result->query); $this->assertEquals([], $result->bindings); @@ -8317,6 +8809,7 @@ public function testRawFilterEmpty(): void ->from('t') ->filter([Query::raw('')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('1 = 1', $result->query); } @@ -8327,6 +8820,7 @@ public function testStartsWithEscapesPercent(): void ->from('t') ->filter([Query::startsWith('name', '100%')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['100\\%%'], $result->bindings); } @@ -8337,6 +8831,7 @@ public function testStartsWithEscapesUnderscore(): void ->from('t') ->filter([Query::startsWith('name', 'a_b')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['a\\_b%'], $result->bindings); } @@ -8347,6 +8842,7 @@ public function testStartsWithEscapesBackslash(): void ->from('t') ->filter([Query::startsWith('name', 'path\\')]) ->build(); + $this->assertBindingCount($result); /** @var string $binding */ $binding = $result->bindings[0]; @@ -8359,6 +8855,7 @@ public function testEndsWithEscapesSpecialChars(): void ->from('t') ->filter([Query::endsWith('name', '%test_')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['%\\%test\\_'], $result->bindings); } @@ -8369,6 +8866,7 @@ public function testContainsMultipleValuesUsesOr(): void ->from('t') ->filter([Query::contains('bio', ['php', 'js'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`bio` LIKE ? OR `bio` LIKE ?)', $result->query); $this->assertEquals(['%php%', '%js%'], $result->bindings); @@ -8380,6 +8878,7 @@ public function testContainsAllUsesAnd(): void ->from('t') ->filter([Query::containsAll('bio', ['php', 'js'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`bio` LIKE ? AND `bio` LIKE ?)', $result->query); $this->assertEquals(['%php%', '%js%'], $result->bindings); @@ -8391,6 +8890,7 @@ public function testNotContainsMultipleValues(): void ->from('t') ->filter([Query::notContains('bio', ['x', 'y'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('(`bio` NOT LIKE ? AND `bio` NOT LIKE ?)', $result->query); $this->assertEquals(['%x%', '%y%'], $result->bindings); @@ -8402,6 +8902,7 @@ public function testContainsSingleValueNoParentheses(): void ->from('t') ->filter([Query::contains('bio', ['php'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`bio` LIKE ?', $result->query); $this->assertStringNotContainsString('(', $result->query); @@ -8413,6 +8914,7 @@ public function testDottedIdentifierInSelect(): void ->from('t') ->select(['users.name', 'users.email']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`users`.`name`, `users`.`email`', $result->query); } @@ -8423,6 +8925,7 @@ public function testDottedIdentifierInFilter(): void ->from('t') ->filter([Query::equal('users.id', [1])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`users`.`id` IN (?)', $result->query); } @@ -8434,6 +8937,7 @@ public function testMultipleOrderBy(): void ->sortAsc('name') ->sortDesc('age') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY `name` ASC, `age` DESC', $result->query); } @@ -8445,6 +8949,7 @@ public function testOrderByWithRandomAndRegular(): void ->sortAsc('name') ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY', $result->query); $this->assertStringContainsString('`name` ASC', $result->query); @@ -8458,6 +8963,7 @@ public function testDistinctWithSelect(): void ->distinct() ->select(['name']) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT DISTINCT `name` FROM `t`', $result->query); } @@ -8469,6 +8975,7 @@ public function testDistinctWithAggregate(): void ->distinct() ->count() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT DISTINCT COUNT(*) FROM `t`', $result->query); } @@ -8479,6 +8986,7 @@ public function testSumWithAlias2(): void ->from('t') ->sum('amount', 'total') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT SUM(`amount`) AS `total` FROM `t`', $result->query); } @@ -8489,6 +8997,7 @@ public function testAvgWithAlias2(): void ->from('t') ->avg('score', 'avg_score') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT AVG(`score`) AS `avg_score` FROM `t`', $result->query); } @@ -8499,6 +9008,7 @@ public function testMinWithAlias2(): void ->from('t') ->min('price', 'cheapest') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT MIN(`price`) AS `cheapest` FROM `t`', $result->query); } @@ -8509,6 +9019,7 @@ public function testMaxWithAlias2(): void ->from('t') ->max('price', 'priciest') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT MAX(`price`) AS `priciest` FROM `t`', $result->query); } @@ -8519,6 +9030,7 @@ public function testCountWithoutAlias(): void ->from('t') ->count() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT COUNT(*) FROM `t`', $result->query); $this->assertStringNotContainsString(' AS ', $result->query); @@ -8531,6 +9043,7 @@ public function testMultipleAggregates(): void ->count('*', 'cnt') ->sum('amount', 'total') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT COUNT(*) AS `cnt`, SUM(`amount`) AS `total` FROM `t`', $result->query); } @@ -8542,6 +9055,7 @@ public function testSelectRawWithRegularSelect(): void ->select(['id']) ->selectRaw('NOW() as current_time') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT `id`, NOW() as current_time FROM `t`', $result->query); } @@ -8552,6 +9066,7 @@ public function testSelectRawWithBindings2(): void ->from('t') ->selectRaw('COALESCE(?, ?) as result', ['a', 'b']) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['a', 'b'], $result->bindings); } @@ -8562,6 +9077,7 @@ public function testRightJoin2(): void ->from('a') ->rightJoin('b', 'a.id', 'b.a_id') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('RIGHT JOIN `b` ON `a`.`id` = `b`.`a_id`', $result->query); } @@ -8572,6 +9088,7 @@ public function testCrossJoin2(): void ->from('a') ->crossJoin('b') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CROSS JOIN `b`', $result->query); $this->assertStringNotContainsString(' ON ', $result->query); @@ -8583,6 +9100,7 @@ public function testJoinWithNonEqualOperator(): void ->from('a') ->join('b', 'a.id', 'b.a_id', '!=') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ON `a`.`id` != `b`.`a_id`', $result->query); } @@ -8607,6 +9125,7 @@ public function testMultipleFiltersJoinedWithAnd(): void Query::lessThan('c', 3), ]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WHERE `a` IN (?) AND `b` > ? AND `c` < ?', $result->query); $this->assertEquals([1, 2, 3], $result->bindings); @@ -8621,6 +9140,7 @@ public function testFilterWithRawCombined(): void Query::raw('y > 5'), ]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`x` IN (?)', $result->query); $this->assertStringContainsString('y > 5', $result->query); @@ -8634,6 +9154,7 @@ public function testResetClearsRawSelects2(): void $builder->reset(); $result = $builder->from('t')->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `t`', $result->query); $this->assertStringNotContainsString('one', $result->query); } @@ -8655,6 +9176,7 @@ public function resolve(string $attribute): string ->addHook($hook) ->filter([Query::equal('alias', [1])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`real_column`', $result->query); $this->assertStringNotContainsString('`alias`', $result->query); @@ -8677,6 +9199,7 @@ public function resolve(string $attribute): string ->addHook($hook) ->select(['alias']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SELECT `real_column`', $result->query); } @@ -8703,6 +9226,7 @@ public function filter(string $table): Condition ->addHook($hook2) ->filter([Query::equal('x', [1])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`tenant` = ?', $result->query); $this->assertStringContainsString('`org` = ?', $result->query); @@ -8717,6 +9241,7 @@ public function testSearchFilter(): void ->from('t') ->filter([Query::search('body', 'hello world')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('MATCH(`body`) AGAINST(?)', $result->query); $this->assertContains('hello world', $result->bindings); @@ -8728,6 +9253,7 @@ public function testNotSearchFilter(): void ->from('t') ->filter([Query::notSearch('body', 'spam')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT (MATCH(`body`) AGAINST(?))', $result->query); $this->assertContains('spam', $result->bindings); @@ -8739,6 +9265,7 @@ public function testIsNullFilter(): void ->from('t') ->filter([Query::isNull('deleted_at')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`deleted_at` IS NULL', $result->query); $this->assertEquals([], $result->bindings); @@ -8750,6 +9277,7 @@ public function testIsNotNullFilter(): void ->from('t') ->filter([Query::isNotNull('name')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`name` IS NOT NULL', $result->query); $this->assertEquals([], $result->bindings); @@ -8761,6 +9289,7 @@ public function testLessThanFilter(): void ->from('t') ->filter([Query::lessThan('age', 30)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`age` < ?', $result->query); $this->assertEquals([30], $result->bindings); @@ -8772,6 +9301,7 @@ public function testLessThanEqualFilter(): void ->from('t') ->filter([Query::lessThanEqual('age', 30)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`age` <= ?', $result->query); $this->assertEquals([30], $result->bindings); @@ -8783,6 +9313,7 @@ public function testGreaterThanFilter(): void ->from('t') ->filter([Query::greaterThan('age', 18)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`age` > ?', $result->query); $this->assertEquals([18], $result->bindings); @@ -8794,6 +9325,7 @@ public function testGreaterThanEqualFilter(): void ->from('t') ->filter([Query::greaterThanEqual('age', 21)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`age` >= ?', $result->query); $this->assertEquals([21], $result->bindings); @@ -8805,6 +9337,7 @@ public function testNotStartsWithFilter(): void ->from('t') ->filter([Query::notStartsWith('name', 'foo')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`name` NOT LIKE ?', $result->query); $this->assertEquals(['foo%'], $result->bindings); @@ -8816,6 +9349,7 @@ public function testNotEndsWithFilter(): void ->from('t') ->filter([Query::notEndsWith('name', 'bar')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`name` NOT LIKE ?', $result->query); $this->assertEquals(['%bar'], $result->bindings); @@ -8829,6 +9363,7 @@ public function testDeleteWithOrderAndLimit(): void ->sortAsc('id') ->limit(10) ->delete(); + $this->assertBindingCount($result); $this->assertStringContainsString('DELETE FROM `t`', $result->query); $this->assertStringContainsString('WHERE', $result->query); @@ -8845,6 +9380,7 @@ public function testUpdateWithOrderAndLimit(): void ->sortAsc('id') ->limit(10) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('UPDATE `t` SET', $result->query); $this->assertStringContainsString('WHERE', $result->query); @@ -8860,6 +9396,7 @@ public function testTableAlias(): void ->from('users', 'u') ->select(['u.name', 'u.email']) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT `u`.`name`, `u`.`email` FROM `users` AS `u`', $result->query); } @@ -8870,6 +9407,7 @@ public function testJoinAlias(): void ->from('users', 'u') ->join('orders', 'u.id', 'o.user_id', '=', 'o') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `users` AS `u`', $result->query); $this->assertStringContainsString('JOIN `orders` AS `o` ON `u`.`id` = `o`.`user_id`', $result->query); @@ -8881,6 +9419,7 @@ public function testLeftJoinAlias(): void ->from('users') ->leftJoin('orders', 'users.id', 'o.user_id', '=', 'o') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LEFT JOIN `orders` AS `o` ON `users`.`id` = `o`.`user_id`', $result->query); } @@ -8891,6 +9430,7 @@ public function testRightJoinAlias(): void ->from('users') ->rightJoin('orders', 'users.id', 'o.user_id', '=', 'o') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('RIGHT JOIN `orders` AS `o` ON `users`.`id` = `o`.`user_id`', $result->query); } @@ -8901,6 +9441,7 @@ public function testCrossJoinAlias(): void ->from('users') ->crossJoin('colors', 'c') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CROSS JOIN `colors` AS `c`', $result->query); } @@ -8914,6 +9455,7 @@ public function testFilterWhereIn(): void ->from('users') ->filterWhereIn('id', $sub) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM `users` WHERE `id` IN (SELECT `user_id` FROM `orders` WHERE `total` > ?)', @@ -8929,6 +9471,7 @@ public function testFilterWhereNotIn(): void ->from('users') ->filterWhereNotIn('id', $sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`id` NOT IN (SELECT `user_id` FROM `blacklist`)', $result->query); } @@ -8941,6 +9484,7 @@ public function testSelectSub(): void ->select(['name']) ->selectSub($sub, 'order_count') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`name`', $result->query); $this->assertStringContainsString('(SELECT COUNT(*) AS `cnt` FROM `orders`', $result->query); @@ -8954,6 +9498,7 @@ public function testFromSub(): void ->fromSub($sub, 'sub') ->select(['user_id']) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT `user_id` FROM (SELECT `user_id` FROM `orders` GROUP BY `user_id`) AS `sub`', @@ -8969,6 +9514,7 @@ public function testOrderByRaw(): void ->from('users') ->orderByRaw('FIELD(`status`, ?, ?, ?)', ['active', 'pending', 'inactive']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY FIELD(`status`, ?, ?, ?)', $result->query); $this->assertEquals(['active', 'pending', 'inactive'], $result->bindings); @@ -8981,6 +9527,7 @@ public function testGroupByRaw(): void ->count('*', 'cnt') ->groupByRaw('YEAR(`created_at`)') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('GROUP BY YEAR(`created_at`)', $result->query); } @@ -8993,6 +9540,7 @@ public function testHavingRaw(): void ->groupBy(['user_id']) ->havingRaw('COUNT(*) > ?', [5]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('HAVING COUNT(*) > ?', $result->query); $this->assertContains(5, $result->bindings); @@ -9006,6 +9554,7 @@ public function testCountDistinct(): void ->from('orders') ->countDistinct('user_id', 'unique_users') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(DISTINCT `user_id`) AS `unique_users` FROM `orders`', @@ -9019,6 +9568,7 @@ public function testCountDistinctNoAlias(): void ->from('orders') ->countDistinct('user_id') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(DISTINCT `user_id`) FROM `orders`', @@ -9032,11 +9582,12 @@ public function testJoinWhere(): void { $result = (new Builder()) ->from('users') - ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + ->joinWhere('orders', function (JoinBuilder $join): void { $join->on('users.id', 'orders.user_id') ->where('orders.status', '=', 'active'); }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND orders.status = ?', $result->query); $this->assertEquals(['active'], $result->bindings); @@ -9046,11 +9597,12 @@ public function testJoinWhereMultipleOns(): void { $result = (new Builder()) ->from('users') - ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + ->joinWhere('orders', function (JoinBuilder $join): void { $join->on('users.id', 'orders.user_id') ->on('users.org_id', 'orders.org_id'); }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND `users`.`org_id` = `orders`.`org_id`', $result->query); } @@ -9059,10 +9611,11 @@ public function testJoinWhereLeftJoin(): void { $result = (new Builder()) ->from('users') - ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + ->joinWhere('orders', function (JoinBuilder $join): void { $join->on('users.id', 'orders.user_id'); - }, 'LEFT JOIN') + }, JoinType::Left) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); } @@ -9071,10 +9624,11 @@ public function testJoinWhereWithAlias(): void { $result = (new Builder()) ->from('users', 'u') - ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + ->joinWhere('orders', function (JoinBuilder $join): void { $join->on('u.id', 'o.user_id'); - }, 'JOIN', 'o') + }, JoinType::Inner, 'o') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `users` AS `u`', $result->query); $this->assertStringContainsString('JOIN `orders` AS `o` ON `u`.`id` = `o`.`user_id`', $result->query); @@ -9093,6 +9647,7 @@ public function testFilterExists(): void ->from('users') ->filterExists($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('EXISTS (SELECT `id` FROM `orders`', $result->query); } @@ -9108,6 +9663,7 @@ public function testFilterNotExists(): void ->from('users') ->filterNotExists($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT EXISTS (SELECT `id` FROM `orders`', $result->query); } @@ -9158,6 +9714,7 @@ public function testForUpdateSkipLocked(): void ->from('users') ->forUpdateSkipLocked() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FOR UPDATE SKIP LOCKED', $result->query); } @@ -9168,6 +9725,7 @@ public function testForUpdateNoWait(): void ->from('users') ->forUpdateNoWait() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FOR UPDATE NOWAIT', $result->query); } @@ -9178,6 +9736,7 @@ public function testForShareSkipLocked(): void ->from('users') ->forShareSkipLocked() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FOR SHARE SKIP LOCKED', $result->query); } @@ -9188,6 +9747,7 @@ public function testForShareNoWait(): void ->from('users') ->forShareNoWait() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FOR SHARE NOWAIT', $result->query); } @@ -9221,13 +9781,13 @@ public function testCaseBuilderEmptyWhenThrows(): void $this->expectException(ValidationException::class); $this->expectExceptionMessage('at least one WHEN'); - $case = new \Utopia\Query\Builder\Case\Builder(); + $case = new CaseBuilder(); $case->build(); } public function testCaseBuilderMultipleWhens(): void { - $case = (new \Utopia\Query\Builder\Case\Builder()) + $case = (new CaseBuilder()) ->when('`status` = ?', '?', ['active'], ['Active']) ->when('`status` = ?', '?', ['inactive'], ['Inactive']) ->elseResult('?', ['Unknown']) @@ -9243,7 +9803,7 @@ public function testCaseBuilderMultipleWhens(): void public function testCaseBuilderWithoutElseClause(): void { - $case = (new \Utopia\Query\Builder\Case\Builder()) + $case = (new CaseBuilder()) ->when('`x` > ?', '1', [10]) ->build(); @@ -9253,7 +9813,7 @@ public function testCaseBuilderWithoutElseClause(): void public function testCaseBuilderWithoutAliasClause(): void { - $case = (new \Utopia\Query\Builder\Case\Builder()) + $case = (new CaseBuilder()) ->when('1=1', '?', [], ['yes']) ->build(); @@ -9262,69 +9822,67 @@ public function testCaseBuilderWithoutAliasClause(): void public function testCaseExpressionToSqlOutput(): void { - $expr = new \Utopia\Query\Builder\Case\Expression('CASE WHEN 1 THEN 2 END', []); - $arr = $expr->toSql(); - - $this->assertEquals('CASE WHEN 1 THEN 2 END', $arr['sql']); - $this->assertEquals([], $arr['bindings']); + $expr = new Expression('CASE WHEN 1 THEN 2 END', []); + $this->assertEquals('CASE WHEN 1 THEN 2 END', $expr->sql); + $this->assertEquals([], $expr->bindings); } // JoinBuilder — unit-level tests public function testJoinBuilderOnReturnsConditions(): void { - $jb = new \Utopia\Query\Builder\JoinBuilder(); + $jb = new JoinBuilder(); $jb->on('a.id', 'b.a_id') ->on('a.tenant', 'b.tenant', '='); - $ons = $jb->getOns(); + $ons = $jb->ons; $this->assertCount(2, $ons); - $this->assertEquals('a.id', $ons[0]['left']); - $this->assertEquals('b.a_id', $ons[0]['right']); - $this->assertEquals('=', $ons[0]['operator']); + $this->assertEquals('a.id', $ons[0]->left); + $this->assertEquals('b.a_id', $ons[0]->right); + $this->assertEquals('=', $ons[0]->operator); } public function testJoinBuilderWhereAddsCondition(): void { - $jb = new \Utopia\Query\Builder\JoinBuilder(); + $jb = new JoinBuilder(); $jb->where('status', '=', 'active'); - $wheres = $jb->getWheres(); + $wheres = $jb->wheres; $this->assertCount(1, $wheres); - $this->assertEquals('status = ?', $wheres[0]['expression']); - $this->assertEquals(['active'], $wheres[0]['bindings']); + $this->assertEquals('status = ?', $wheres[0]->expression); + $this->assertEquals(['active'], $wheres[0]->bindings); } public function testJoinBuilderOnRaw(): void { - $jb = new \Utopia\Query\Builder\JoinBuilder(); + $jb = new JoinBuilder(); $jb->onRaw('a.created_at > NOW() - INTERVAL ? DAY', [30]); - $wheres = $jb->getWheres(); + $wheres = $jb->wheres; $this->assertCount(1, $wheres); - $this->assertEquals([30], $wheres[0]['bindings']); + $this->assertEquals([30], $wheres[0]->bindings); } public function testJoinBuilderWhereRaw(): void { - $jb = new \Utopia\Query\Builder\JoinBuilder(); + $jb = new JoinBuilder(); $jb->whereRaw('`deleted_at` IS NULL'); - $wheres = $jb->getWheres(); + $wheres = $jb->wheres; $this->assertCount(1, $wheres); - $this->assertEquals('`deleted_at` IS NULL', $wheres[0]['expression']); - $this->assertEquals([], $wheres[0]['bindings']); + $this->assertEquals('`deleted_at` IS NULL', $wheres[0]->expression); + $this->assertEquals([], $wheres[0]->bindings); } public function testJoinBuilderCombinedOnAndWhere(): void { - $jb = new \Utopia\Query\Builder\JoinBuilder(); + $jb = new JoinBuilder(); $jb->on('a.id', 'b.a_id') ->where('b.active', '=', true) ->onRaw('b.score > ?', [50]); - $this->assertCount(1, $jb->getOns()); - $this->assertCount(2, $jb->getWheres()); + $this->assertCount(1, $jb->ons); + $this->assertCount(2, $jb->wheres); } // Subquery binding order @@ -9340,6 +9898,7 @@ public function testSubqueryBindingOrderIsCorrect(): void ->filter([Query::equal('role', ['admin'])]) ->filterWhereIn('id', $sub) ->build(); + $this->assertBindingCount($result); // Main filter bindings come before subquery bindings $this->assertEquals(['admin', 'completed'], $result->bindings); @@ -9356,6 +9915,7 @@ public function testSelectSubBindingOrder(): void ->selectSub($sub, 'order_count') ->filter([Query::equal('active', [true])]) ->build(); + $this->assertBindingCount($result); // Sub-select bindings come before main WHERE bindings $this->assertEquals(['matched', true], $result->bindings); @@ -9370,6 +9930,7 @@ public function testFromSubBindingOrder(): void ->fromSub($sub, 'expensive') ->filter([Query::equal('status', ['shipped'])]) ->build(); + $this->assertBindingCount($result); // FROM sub bindings come before main WHERE bindings $this->assertEquals([100, 'shipped'], $result->bindings); @@ -9388,6 +9949,7 @@ public function testFilterExistsBindings(): void ->filter([Query::equal('active', [true])]) ->filterExists($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('EXISTS (SELECT', $result->query); $this->assertEquals([true, 'paid'], $result->bindings); @@ -9401,6 +9963,7 @@ public function testFilterNotExistsQuery(): void ->from('users') ->filterNotExists($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT EXISTS (SELECT', $result->query); } @@ -9436,6 +9999,7 @@ public function testTableAliasClearsOnNewFrom(): void // Reset with new from() should clear alias $result = $builder->from('orders')->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `orders`', $result->query); $this->assertStringNotContainsString(' AS ', $result->query); @@ -9450,6 +10014,7 @@ public function testFromSubClearsTable(): void ->fromSub($sub, 'sub'); $result = $builder->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('`users`', $result->query); $this->assertStringContainsString('AS `sub`', $result->query); @@ -9464,6 +10029,7 @@ public function testFromClearsFromSub(): void ->from('users'); $result = $builder->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM `users`', $result->query); $this->assertStringNotContainsString('sub', $result->query); @@ -9477,6 +10043,7 @@ public function testOrderByRawWithBindings(): void ->from('users') ->orderByRaw('FIELD(`status`, ?, ?, ?)', ['active', 'pending', 'inactive']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY FIELD(`status`, ?, ?, ?)', $result->query); $this->assertEquals(['active', 'pending', 'inactive'], $result->bindings); @@ -9489,6 +10056,7 @@ public function testGroupByRawWithBindings(): void ->count('*', 'cnt') ->groupByRaw('DATE_FORMAT(`created_at`, ?)', ['%Y-%m']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString("GROUP BY DATE_FORMAT(`created_at`, ?)", $result->query); $this->assertEquals(['%Y-%m'], $result->bindings); @@ -9502,6 +10070,7 @@ public function testHavingRawWithBindings(): void ->groupBy(['user_id']) ->havingRaw('SUM(`amount`) > ?', [1000]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('HAVING SUM(`amount`) > ?', $result->query); $this->assertEquals([1000], $result->bindings); @@ -9514,6 +10083,7 @@ public function testMultipleRawOrdersCombined(): void ->sortAsc('name') ->orderByRaw('FIELD(`role`, ?)', ['admin']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY `name` ASC, FIELD(`role`, ?)', $result->query); } @@ -9526,6 +10096,7 @@ public function testMultipleRawGroupsCombined(): void ->groupBy(['type']) ->groupByRaw('YEAR(`created_at`)') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('GROUP BY `type`, YEAR(`created_at`)', $result->query); } @@ -9538,6 +10109,7 @@ public function testCountDistinctWithoutAlias(): void ->from('users') ->countDistinct('email') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(DISTINCT `email`)', $result->query); $this->assertStringNotContainsString(' AS ', $result->query); @@ -9551,6 +10123,7 @@ public function testLeftJoinWithAlias(): void ->from('users', 'u') ->leftJoin('orders', 'u.id', 'o.user_id', '=', 'o') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LEFT JOIN `orders` AS `o`', $result->query); } @@ -9561,6 +10134,7 @@ public function testRightJoinWithAlias(): void ->from('users', 'u') ->rightJoin('orders', 'u.id', 'o.user_id', '=', 'o') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('RIGHT JOIN `orders` AS `o`', $result->query); } @@ -9571,6 +10145,7 @@ public function testCrossJoinWithAlias(): void ->from('users') ->crossJoin('roles', 'r') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CROSS JOIN `roles` AS `r`', $result->query); } @@ -9581,11 +10156,12 @@ public function testJoinWhereWithLeftJoinType(): void { $result = (new Builder()) ->from('users') - ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + ->joinWhere('orders', function (JoinBuilder $join): void { $join->on('users.id', 'orders.user_id') ->where('orders.status', '=', 'active'); - }, 'LEFT JOIN') + }, JoinType::Left) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LEFT JOIN `orders` ON', $result->query); $this->assertStringContainsString('orders.status = ?', $result->query); @@ -9596,10 +10172,11 @@ public function testJoinWhereWithTableAlias(): void { $result = (new Builder()) ->from('users', 'u') - ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + ->joinWhere('orders', function (JoinBuilder $join): void { $join->on('u.id', 'o.user_id'); - }, 'JOIN', 'o') + }, JoinType::Inner, 'o') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `orders` AS `o`', $result->query); } @@ -9608,11 +10185,12 @@ public function testJoinWhereWithMultipleOnConditions(): void { $result = (new Builder()) ->from('users') - ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + ->joinWhere('orders', function (JoinBuilder $join): void { $join->on('users.id', 'orders.user_id') ->on('users.tenant_id', 'orders.tenant_id'); }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString( 'ON `users`.`id` = `orders`.`user_id` AND `users`.`tenant_id` = `orders`.`tenant_id`', @@ -9634,6 +10212,7 @@ public function testWhereInSubqueryWithRegularFilters(): void ]) ->filterWhereIn('user_id', $sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`amount` > ?', $result->query); $this->assertStringContainsString('`status` IN (?)', $result->query); @@ -9652,6 +10231,7 @@ public function testMultipleWhereInSubqueries(): void ->filterWhereIn('id', $sub1) ->filterWhereNotIn('dept_id', $sub2) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('`id` IN (SELECT', $result->query); $this->assertStringContainsString('`dept_id` NOT IN (SELECT', $result->query); @@ -9696,6 +10276,7 @@ public function testPageFirstPageOffsetZero(): void ->from('users') ->page(1, 10) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LIMIT ?', $result->query); $this->assertStringContainsString('OFFSET ?', $result->query); @@ -9709,6 +10290,7 @@ public function testPageThirdPage(): void ->from('users') ->page(3, 25) ->build(); + $this->assertBindingCount($result); $this->assertContains(25, $result->bindings); $this->assertContains(50, $result->bindings); @@ -9722,6 +10304,7 @@ public function testWhenTrueAppliesCallback(): void ->from('users') ->when(true, fn (Builder $b) => $b->filter([Query::equal('active', [true])])) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WHERE', $result->query); } @@ -9732,6 +10315,7 @@ public function testWhenFalseSkipsCallback(): void ->from('users') ->when(false, fn (Builder $b) => $b->filter([Query::equal('active', [true])])) ->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('WHERE', $result->query); } @@ -9746,6 +10330,7 @@ public function testLockingAppearsAtEnd(): void ->limit(1) ->forUpdate() ->build(); + $this->assertBindingCount($result); $this->assertStringEndsWith('FOR UPDATE', $result->query); } @@ -9762,8 +10347,485 @@ public function testCteBindingOrder(): void ->from('paid_orders') ->filter([Query::greaterThan('amount', 100)]) ->build(); + $this->assertBindingCount($result); // CTE bindings come first $this->assertEquals(['paid', 100], $result->bindings); } + + public function testExactSimpleSelect(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name', 'email']) + ->filter([Query::equal('status', ['active'])]) + ->sortAsc('name') + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name`, `email` FROM `users` WHERE `status` IN (?) ORDER BY `name` ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['active', 10], $result->bindings); + } + + public function testExactSelectWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('products') + ->select(['id', 'name', 'price']) + ->filter([ + Query::greaterThan('price', 10), + Query::lessThanEqual('price', 500), + Query::equal('category', ['electronics']), + Query::startsWith('name', 'Pro'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name`, `price` FROM `products` WHERE `price` > ? AND `price` <= ? AND `category` IN (?) AND `name` LIKE ?', + $result->query + ); + $this->assertEquals([10, 500, 'electronics', 'Pro%'], $result->bindings); + } + + public function testExactMultipleJoins(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['orders.id', 'users.name', 'products.title']) + ->join('users', 'orders.user_id', 'users.id') + ->leftJoin('products', 'orders.product_id', 'products.id') + ->rightJoin('categories', 'products.category_id', 'categories.id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `orders`.`id`, `users`.`name`, `products`.`title` FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` LEFT JOIN `products` ON `orders`.`product_id` = `products`.`id` RIGHT JOIN `categories` ON `products`.`category_id` = `categories`.`id`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactCrossJoin(): void + { + $result = (new Builder()) + ->from('sizes') + ->select(['sizes.label', 'colors.name']) + ->crossJoin('colors') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `sizes`.`label`, `colors`.`name` FROM `sizes` CROSS JOIN `colors`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'alice@test.com']) + ->set(['name' => 'Bob', 'email' => 'bob@test.com']) + ->set(['name' => 'Charlie', 'email' => 'charlie@test.com']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?), (?, ?), (?, ?)', + $result->query + ); + $this->assertEquals(['Alice', 'alice@test.com', 'Bob', 'bob@test.com', 'Charlie', 'charlie@test.com'], $result->bindings); + } + + public function testExactUpdateWithOrderAndLimit(): void + { + $result = (new Builder()) + ->from('users') + ->set(['status' => 'archived']) + ->filter([Query::lessThan('last_login', '2023-06-01')]) + ->sortAsc('last_login') + ->limit(50) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame( + 'UPDATE `users` SET `status` = ? WHERE `last_login` < ? ORDER BY `last_login` ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['archived', '2023-06-01', 50], $result->bindings); + } + + public function testExactDeleteWithOrderAndLimit(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::lessThan('created_at', '2023-01-01')]) + ->sortAsc('created_at') + ->limit(500) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame( + 'DELETE FROM `logs` WHERE `created_at` < ? ORDER BY `created_at` ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['2023-01-01', 500], $result->bindings); + } + + public function testExactUpsertOnDuplicateKey(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice', 'email' => 'alice@new.com']) + ->onConflict(['id'], ['name', 'email']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO `users` (`id`, `name`, `email`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `email` = VALUES(`email`)', + $result->query + ); + $this->assertEquals([1, 'Alice', 'alice@new.com'], $result->bindings); + } + + public function testExactSubqueryWhereIn(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->filter([Query::greaterThan('total', 1000)]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filterWhereIn('id', $sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE `id` IN (SELECT `user_id` FROM `orders` WHERE `total` > ?)', + $result->query + ); + $this->assertEquals([1000], $result->bindings); + } + + public function testExactExistsSubquery(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['id']) + ->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filterExists($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE EXISTS (SELECT `id` FROM `orders` WHERE `orders`.`user_id` = `users`.`id`)', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactCte(): void + { + $cte = (new Builder()) + ->from('orders') + ->select(['user_id', 'total']) + ->filter([Query::equal('status', ['paid'])]); + + $result = (new Builder()) + ->with('paid_orders', $cte) + ->from('paid_orders') + ->select(['user_id']) + ->sum('total', 'total_spent') + ->groupBy(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'WITH `paid_orders` AS (SELECT `user_id`, `total` FROM `orders` WHERE `status` IN (?)) SELECT SUM(`total`) AS `total_spent`, `user_id` FROM `paid_orders` GROUP BY `user_id`', + $result->query + ); + $this->assertEquals(['paid'], $result->bindings); + } + + public function testExactCaseInSelect(): void + { + $case = (new CaseBuilder()) + ->when('status = ?', '?', ['active'], ['Active']) + ->when('status = ?', '?', ['inactive'], ['Inactive']) + ->elseResult('?', ['Unknown']) + ->alias('status_label') + ->build(); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->selectCase($case) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name`, CASE WHEN status = ? THEN ? WHEN status = ? THEN ? ELSE ? END AS status_label FROM `users`', + $result->query + ); + $this->assertEquals(['active', 'Active', 'inactive', 'Inactive', 'Unknown'], $result->bindings); + } + + public function testExactAggregationGroupByHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->count('*', 'order_count') + ->sum('total', 'total_spent') + ->groupBy(['user_id']) + ->having([Query::greaterThan('order_count', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT COUNT(*) AS `order_count`, SUM(`total`) AS `total_spent`, `user_id` FROM `orders` GROUP BY `user_id` HAVING `order_count` > ?', + $result->query + ); + $this->assertEquals([5], $result->bindings); + } + + public function testExactUnion(): void + { + $admins = (new Builder()) + ->from('admins') + ->select(['id', 'name']) + ->filter([Query::equal('role', ['admin'])]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]) + ->union($admins) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + '(SELECT `id`, `name` FROM `users` WHERE `status` IN (?)) UNION (SELECT `id`, `name` FROM `admins` WHERE `role` IN (?))', + $result->query + ); + $this->assertEquals(['active', 'admin'], $result->bindings); + } + + public function testExactUnionAll(): void + { + $archive = (new Builder()) + ->from('orders_archive') + ->select(['id', 'total', 'created_at']); + + $result = (new Builder()) + ->from('orders') + ->select(['id', 'total', 'created_at']) + ->unionAll($archive) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + '(SELECT `id`, `total`, `created_at` FROM `orders`) UNION ALL (SELECT `id`, `total`, `created_at` FROM `orders_archive`)', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactWindowFunction(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['id', 'customer_id', 'total']) + ->selectWindow('ROW_NUMBER()', 'rn', ['customer_id'], ['total']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `customer_id`, `total`, ROW_NUMBER() OVER (PARTITION BY `customer_id` ORDER BY `total` ASC) AS `rn` FROM `orders`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactForUpdate(): void + { + $result = (new Builder()) + ->from('accounts') + ->select(['id', 'balance']) + ->filter([Query::equal('id', [42])]) + ->forUpdate() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `balance` FROM `accounts` WHERE `id` IN (?) FOR UPDATE', + $result->query + ); + $this->assertEquals([42], $result->bindings); + } + + public function testExactForShareSkipLocked(): void + { + $result = (new Builder()) + ->from('inventory') + ->select(['id', 'quantity']) + ->filter([Query::greaterThan('quantity', 0)]) + ->limit(5) + ->forShareSkipLocked() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `quantity` FROM `inventory` WHERE `quantity` > ? LIMIT ? FOR SHARE SKIP LOCKED', + $result->query + ); + $this->assertEquals([0, 5], $result->bindings); + } + + public function testExactHintMaxExecutionTime(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->maxExecutionTime(5000) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT /*+ MAX_EXECUTION_TIME(5000) */ `id`, `name` FROM `users`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactRawExpressions(): void + { + $result = (new Builder()) + ->from('users') + ->selectRaw('COUNT(*) AS `total`') + ->selectRaw('MAX(`created_at`) AS `latest`') + ->filter([Query::equal('active', [true])]) + ->orderByRaw('FIELD(`role`, ?, ?, ?)', ['admin', 'editor', 'viewer']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT COUNT(*) AS `total`, MAX(`created_at`) AS `latest` FROM `users` WHERE `active` IN (?) ORDER BY FIELD(`role`, ?, ?, ?)', + $result->query + ); + $this->assertEquals([true, 'admin', 'editor', 'viewer'], $result->bindings); + } + + public function testExactNestedWhereGroups(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([ + Query::and([ + Query::equal('active', [true]), + Query::or([ + Query::equal('role', ['admin']), + Query::greaterThan('karma', 100), + ]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE (`active` IN (?) AND (`role` IN (?) OR `karma` > ?))', + $result->query + ); + $this->assertEquals([true, 'admin', 100], $result->bindings); + } + + public function testExactDistinctWithOffset(): void + { + $result = (new Builder()) + ->from('tags') + ->distinct() + ->select(['name']) + ->limit(20) + ->offset(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT DISTINCT `name` FROM `tags` LIMIT ? OFFSET ?', + $result->query + ); + $this->assertEquals([20, 10], $result->bindings); + } + + public function testExactInsertOrIgnore(): void + { + $result = (new Builder()) + ->into('tags') + ->set(['name' => 'php', 'slug' => 'php']) + ->set(['name' => 'mysql', 'slug' => 'mysql']) + ->insertOrIgnore(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT IGNORE INTO `tags` (`name`, `slug`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertEquals(['php', 'php', 'mysql', 'mysql'], $result->bindings); + } + + public function testExactFromSubquery(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->sum('total', 'user_total') + ->groupBy(['user_id']); + + $result = (new Builder()) + ->fromSub($sub, 'sub') + ->select(['user_id', 'user_total']) + ->filter([Query::greaterThan('user_total', 500)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `user_id`, `user_total` FROM (SELECT SUM(`total`) AS `user_total`, `user_id` FROM `orders` GROUP BY `user_id`) AS `sub` WHERE `user_total` > ?', + $result->query + ); + $this->assertEquals([500], $result->bindings); + } + + public function testExactSelectSubquery(): void + { + $sub = (new Builder()) + ->from('orders') + ->selectRaw('COUNT(*)') + ->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + + $result = (new Builder()) + ->from('users') + ->selectSub($sub, 'order_count') + ->select(['name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `name`, (SELECT COUNT(*) FROM `orders` WHERE `orders`.`user_id` = `users`.`id`) AS `order_count` FROM `users`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } } diff --git a/tests/Query/Builder/PostgreSQLTest.php b/tests/Query/Builder/PostgreSQLTest.php index 765db31..b1f7306 100644 --- a/tests/Query/Builder/PostgreSQLTest.php +++ b/tests/Query/Builder/PostgreSQLTest.php @@ -3,21 +3,29 @@ namespace Tests\Query\Builder; use PHPUnit\Framework\TestCase; +use Tests\Query\AssertsBindingCount; use Utopia\Query\Builder\Case\Builder as CaseBuilder; use Utopia\Query\Builder\Condition; use Utopia\Query\Builder\Feature\Aggregates; use Utopia\Query\Builder\Feature\CTEs; use Utopia\Query\Builder\Feature\Deletes; +use Utopia\Query\Builder\Feature\Hints; use Utopia\Query\Builder\Feature\Hooks; use Utopia\Query\Builder\Feature\Inserts; use Utopia\Query\Builder\Feature\Joins; +use Utopia\Query\Builder\Feature\Json; use Utopia\Query\Builder\Feature\Locking; use Utopia\Query\Builder\Feature\Selects; +use Utopia\Query\Builder\Feature\Spatial; use Utopia\Query\Builder\Feature\Transactions; use Utopia\Query\Builder\Feature\Unions; use Utopia\Query\Builder\Feature\Updates; use Utopia\Query\Builder\Feature\Upsert; +use Utopia\Query\Builder\Feature\VectorSearch; +use Utopia\Query\Builder\Feature\Windows; +use Utopia\Query\Builder\JoinBuilder; use Utopia\Query\Builder\PostgreSQL as Builder; +use Utopia\Query\Builder\VectorMetric; use Utopia\Query\Compiler; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Hook\Filter; @@ -25,6 +33,7 @@ class PostgreSQLTest extends TestCase { + use AssertsBindingCount; public function testImplementsCompiler(): void { $this->assertInstanceOf(Compiler::class, new Builder()); @@ -96,6 +105,7 @@ public function testSelectWrapsWithDoubleQuotes(): void ->from('t') ->select(['a', 'b', 'c']) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT "a", "b", "c" FROM "t"', $result->query); } @@ -105,6 +115,7 @@ public function testFromWrapsWithDoubleQuotes(): void $result = (new Builder()) ->from('my_table') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM "my_table"', $result->query); } @@ -115,6 +126,7 @@ public function testFilterWrapsWithDoubleQuotes(): void ->from('t') ->filter([Query::equal('col', [1])]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM "t" WHERE "col" IN (?)', $result->query); } @@ -126,6 +138,7 @@ public function testSortWrapsWithDoubleQuotes(): void ->sortAsc('name') ->sortDesc('age') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM "t" ORDER BY "name" ASC, "age" DESC', $result->query); } @@ -136,6 +149,7 @@ public function testJoinWrapsWithDoubleQuotes(): void ->from('users') ->join('orders', 'users.id', 'orders.uid') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM "users" JOIN "orders" ON "users"."id" = "orders"."uid"', @@ -149,6 +163,7 @@ public function testLeftJoinWrapsWithDoubleQuotes(): void ->from('users') ->leftJoin('profiles', 'users.id', 'profiles.uid') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM "users" LEFT JOIN "profiles" ON "users"."id" = "profiles"."uid"', @@ -162,6 +177,7 @@ public function testRightJoinWrapsWithDoubleQuotes(): void ->from('users') ->rightJoin('orders', 'users.id', 'orders.uid') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT * FROM "users" RIGHT JOIN "orders" ON "users"."id" = "orders"."uid"', @@ -175,6 +191,7 @@ public function testCrossJoinWrapsWithDoubleQuotes(): void ->from('a') ->crossJoin('b') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM "a" CROSS JOIN "b"', $result->query); } @@ -185,6 +202,7 @@ public function testAggregationWrapsWithDoubleQuotes(): void ->from('t') ->sum('price', 'total') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT SUM("price") AS "total" FROM "t"', $result->query); } @@ -196,6 +214,7 @@ public function testGroupByWrapsWithDoubleQuotes(): void ->count('*', 'cnt') ->groupBy(['status', 'country']) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(*) AS "cnt" FROM "t" GROUP BY "status", "country"', @@ -211,6 +230,7 @@ public function testHavingWrapsWithDoubleQuotes(): void ->groupBy(['status']) ->having([Query::greaterThan('cnt', 5)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('HAVING "cnt" > ?', $result->query); } @@ -222,6 +242,7 @@ public function testDistinctWrapsWithDoubleQuotes(): void ->distinct() ->select(['status']) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT DISTINCT "status" FROM "t"', $result->query); } @@ -232,6 +253,7 @@ public function testIsNullWrapsWithDoubleQuotes(): void ->from('t') ->filter([Query::isNull('deleted')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM "t" WHERE "deleted" IS NULL', $result->query); } @@ -242,6 +264,7 @@ public function testRandomUsesRandomFunction(): void ->from('t') ->sortRandom() ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM "t" ORDER BY RANDOM()', $result->query); } @@ -252,6 +275,7 @@ public function testRegexUsesTildeOperator(): void ->from('t') ->filter([Query::regex('slug', '^test')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM "t" WHERE "slug" ~ ?', $result->query); $this->assertEquals(['^test'], $result->bindings); @@ -263,6 +287,7 @@ public function testSearchUsesToTsvector(): void ->from('t') ->filter([Query::search('body', 'hello')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM "t" WHERE to_tsvector("body") @@ plainto_tsquery(?)', $result->query); $this->assertEquals(['hello'], $result->bindings); @@ -274,6 +299,7 @@ public function testNotSearchUsesToTsvector(): void ->from('t') ->filter([Query::notSearch('body', 'spam')]) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM "t" WHERE NOT (to_tsvector("body") @@ plainto_tsquery(?))', $result->query); $this->assertEquals(['spam'], $result->bindings); @@ -286,6 +312,7 @@ public function testUpsertUsesOnConflict(): void ->set(['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com']) ->onConflict(['id'], ['name', 'email']) ->upsert(); + $this->assertBindingCount($result); $this->assertEquals( 'INSERT INTO "users" ("id", "name", "email") VALUES (?, ?, ?) ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name", "email" = EXCLUDED."email"', @@ -300,6 +327,7 @@ public function testOffsetWithoutLimitEmitsOffset(): void ->from('t') ->offset(10) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM "t" OFFSET ?', $result->query); $this->assertEquals([10], $result->bindings); @@ -312,6 +340,7 @@ public function testOffsetWithLimitEmitsBoth(): void ->limit(25) ->offset(10) ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM "t" LIMIT ? OFFSET ?', $result->query); $this->assertEquals([25, 10], $result->bindings); @@ -330,6 +359,7 @@ public function filter(string $table): Condition ->from('t') ->addHook($hook) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WHERE raw_condition = 1', $result->query); $this->assertStringContainsString('FROM "t"', $result->query); @@ -341,6 +371,7 @@ public function testInsertWrapsWithDoubleQuotes(): void ->into('users') ->set(['name' => 'Alice', 'age' => 30]) ->insert(); + $this->assertBindingCount($result); $this->assertEquals( 'INSERT INTO "users" ("name", "age") VALUES (?, ?)', @@ -356,6 +387,7 @@ public function testUpdateWrapsWithDoubleQuotes(): void ->set(['name' => 'Bob']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertEquals( 'UPDATE "users" SET "name" = ? WHERE "id" IN (?)', @@ -370,6 +402,7 @@ public function testDeleteWrapsWithDoubleQuotes(): void ->from('users') ->filter([Query::equal('id', [1])]) ->delete(); + $this->assertBindingCount($result); $this->assertEquals( 'DELETE FROM "users" WHERE "id" IN (?)', @@ -391,6 +424,7 @@ public function testForUpdateWithDoubleQuotes(): void ->from('t') ->forUpdate() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FOR UPDATE', $result->query); $this->assertStringContainsString('FROM "t"', $result->query); @@ -399,7 +433,7 @@ public function testForUpdateWithDoubleQuotes(): void public function testImplementsSpatial(): void { - $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Spatial::class, new Builder()); + $this->assertInstanceOf(Spatial::class, new Builder()); } public function testFilterDistanceMeters(): void @@ -408,6 +442,7 @@ public function testFilterDistanceMeters(): void ->from('locations') ->filterDistance('coords', [40.7128, -74.0060], '<', 5000.0, true) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Distance(("coords"::geography), ST_SetSRID(ST_GeomFromText(?), 4326)::geography) < ?', $result->query); $this->assertEquals('POINT(40.7128 -74.006)', $result->bindings[0]); @@ -420,6 +455,7 @@ public function testFilterIntersectsPoint(): void ->from('zones') ->filterIntersects('area', [1.0, 2.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Intersects("area", ST_GeomFromText(?, 4326))', $result->query); } @@ -430,6 +466,7 @@ public function testFilterCovers(): void ->from('zones') ->filterCovers('area', [1.0, 2.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Covers("area", ST_GeomFromText(?, 4326))', $result->query); } @@ -440,6 +477,7 @@ public function testFilterCrosses(): void ->from('roads') ->filterCrosses('path', [[0, 0], [1, 1]]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Crosses', $result->query); } @@ -447,16 +485,17 @@ public function testFilterCrosses(): void public function testImplementsVectorSearch(): void { - $this->assertInstanceOf(\Utopia\Query\Builder\Feature\VectorSearch::class, new Builder()); + $this->assertInstanceOf(VectorSearch::class, new Builder()); } public function testOrderByVectorDistanceCosine(): void { $result = (new Builder()) ->from('embeddings') - ->orderByVectorDistance('embedding', [0.1, 0.2, 0.3], 'cosine') + ->orderByVectorDistance('embedding', [0.1, 0.2, 0.3], VectorMetric::Cosine) ->limit(10) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("embedding" <=> ?::vector) ASC', $result->query); $this->assertEquals('[0.1,0.2,0.3]', $result->bindings[0]); @@ -466,9 +505,10 @@ public function testOrderByVectorDistanceEuclidean(): void { $result = (new Builder()) ->from('embeddings') - ->orderByVectorDistance('embedding', [1.0, 2.0], 'euclidean') + ->orderByVectorDistance('embedding', [1.0, 2.0], VectorMetric::Euclidean) ->limit(5) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("embedding" <-> ?::vector) ASC', $result->query); } @@ -477,9 +517,10 @@ public function testOrderByVectorDistanceDot(): void { $result = (new Builder()) ->from('embeddings') - ->orderByVectorDistance('embedding', [1.0, 2.0], 'dot') + ->orderByVectorDistance('embedding', [1.0, 2.0], VectorMetric::Dot) ->limit(5) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("embedding" <#> ?::vector) ASC', $result->query); } @@ -490,6 +531,7 @@ public function testVectorFilterCosine(): void ->from('embeddings') ->filter([Query::vectorCosine('embedding', [0.1, 0.2])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("embedding" <=> ?::vector)', $result->query); } @@ -500,6 +542,7 @@ public function testVectorFilterEuclidean(): void ->from('embeddings') ->filter([Query::vectorEuclidean('embedding', [0.1, 0.2])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("embedding" <-> ?::vector)', $result->query); } @@ -510,6 +553,7 @@ public function testVectorFilterDot(): void ->from('embeddings') ->filter([Query::vectorDot('embedding', [0.1, 0.2])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("embedding" <#> ?::vector)', $result->query); } @@ -517,7 +561,7 @@ public function testVectorFilterDot(): void public function testImplementsJson(): void { - $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Json::class, new Builder()); + $this->assertInstanceOf(Json::class, new Builder()); } public function testFilterJsonContains(): void @@ -526,6 +570,7 @@ public function testFilterJsonContains(): void ->from('docs') ->filterJsonContains('tags', 'php') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"tags" @> ?::jsonb', $result->query); } @@ -536,6 +581,7 @@ public function testFilterJsonNotContains(): void ->from('docs') ->filterJsonNotContains('tags', 'old') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ("tags" @> ?::jsonb)', $result->query); } @@ -546,6 +592,7 @@ public function testFilterJsonOverlaps(): void ->from('docs') ->filterJsonOverlaps('tags', ['php', 'go']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString("\"tags\" ?| ARRAY", $result->query); } @@ -556,6 +603,7 @@ public function testFilterJsonPath(): void ->from('users') ->filterJsonPath('metadata', 'level', '>', 5) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString("\"metadata\"->>'level' > ?", $result->query); $this->assertEquals(5, $result->bindings[0]); @@ -568,6 +616,7 @@ public function testSetJsonAppend(): void ->setJsonAppend('tags', ['new']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('|| ?::jsonb', $result->query); } @@ -579,6 +628,7 @@ public function testSetJsonPrepend(): void ->setJsonPrepend('tags', ['first']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('?::jsonb ||', $result->query); } @@ -590,6 +640,7 @@ public function testSetJsonInsert(): void ->setJsonInsert('tags', 0, 'inserted') ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('jsonb_insert', $result->query); } @@ -597,7 +648,7 @@ public function testSetJsonInsert(): void public function testImplementsWindows(): void { - $this->assertInstanceOf(\Utopia\Query\Builder\Feature\Windows::class, new Builder()); + $this->assertInstanceOf(Windows::class, new Builder()); } public function testSelectWindowRowNumber(): void @@ -606,6 +657,7 @@ public function testSelectWindowRowNumber(): void ->from('orders') ->selectWindow('ROW_NUMBER()', 'rn', ['customer_id'], ['created_at']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY "customer_id" ORDER BY "created_at" ASC) AS "rn"', $result->query); } @@ -616,6 +668,7 @@ public function testSelectWindowRankDesc(): void ->from('scores') ->selectWindow('RANK()', 'rank', null, ['-score']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('RANK() OVER (ORDER BY "score" DESC) AS "rank"', $result->query); } @@ -623,7 +676,7 @@ public function testSelectWindowRankDesc(): void public function testSelectCaseExpression(): void { - $case = (new \Utopia\Query\Builder\Case\Builder()) + $case = (new CaseBuilder()) ->when('status = ?', '?', ['active'], ['Active']) ->elseResult('?', ['Other']) ->alias('label') @@ -634,6 +687,7 @@ public function testSelectCaseExpression(): void ->select(['id']) ->selectCase($case) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CASE WHEN status = ? THEN ? ELSE ? END AS label', $result->query); $this->assertEquals(['active', 'Active', 'Other'], $result->bindings); @@ -643,7 +697,7 @@ public function testSelectCaseExpression(): void public function testDoesNotImplementHints(): void { $builder = new Builder(); - $this->assertNotInstanceOf(\Utopia\Query\Builder\Feature\Hints::class, $builder); // @phpstan-ignore method.alreadyNarrowedType + $this->assertNotInstanceOf(Hints::class, $builder); // @phpstan-ignore method.alreadyNarrowedType } // Reset clears new state @@ -651,11 +705,12 @@ public function testResetClearsVectorOrder(): void { $builder = (new Builder()) ->from('embeddings') - ->orderByVectorDistance('embedding', [0.1], 'cosine'); + ->orderByVectorDistance('embedding', [0.1], VectorMetric::Cosine); $builder->reset(); $result = $builder->from('embeddings')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('<=>', $result->query); } @@ -665,6 +720,7 @@ public function testFilterNotIntersectsPoint(): void ->from('zones') ->filterNotIntersects('zone', [1.0, 2.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ST_Intersects', $result->query); $this->assertEquals('POINT(1 2)', $result->bindings[0]); @@ -676,6 +732,7 @@ public function testFilterNotCrossesLinestring(): void ->from('roads') ->filterNotCrosses('path', [[0, 0], [1, 1]]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ST_Crosses', $result->query); /** @var string $binding */ @@ -689,6 +746,7 @@ public function testFilterOverlapsPolygon(): void ->from('maps') ->filterOverlaps('area', [[[0, 0], [1, 0], [1, 1], [0, 0]]]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Overlaps', $result->query); /** @var string $binding */ @@ -702,6 +760,7 @@ public function testFilterNotOverlaps(): void ->from('maps') ->filterNotOverlaps('area', [1.0, 2.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ST_Overlaps', $result->query); } @@ -712,6 +771,7 @@ public function testFilterTouches(): void ->from('zones') ->filterTouches('zone', [5.0, 10.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Touches', $result->query); } @@ -722,6 +782,7 @@ public function testFilterNotTouches(): void ->from('zones') ->filterNotTouches('zone', [5.0, 10.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ST_Touches', $result->query); } @@ -732,6 +793,7 @@ public function testFilterCoversUsesSTCovers(): void ->from('regions') ->filterCovers('region', [1.0, 2.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Covers', $result->query); $this->assertStringNotContainsString('ST_Contains', $result->query); @@ -743,6 +805,7 @@ public function testFilterNotCovers(): void ->from('regions') ->filterNotCovers('region', [1.0, 2.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ST_Covers', $result->query); } @@ -753,6 +816,7 @@ public function testFilterSpatialEquals(): void ->from('geoms') ->filterSpatialEquals('geom', [3.0, 4.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Equals', $result->query); } @@ -763,6 +827,7 @@ public function testFilterNotSpatialEquals(): void ->from('geoms') ->filterNotSpatialEquals('geom', [3.0, 4.0]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ST_Equals', $result->query); } @@ -773,6 +838,7 @@ public function testFilterDistanceGreaterThan(): void ->from('locations') ->filterDistance('loc', [1.0, 2.0], '>', 500.0) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('> ?', $result->query); $this->assertEquals('POINT(1 2)', $result->bindings[0]); @@ -785,6 +851,7 @@ public function testFilterDistanceWithoutMeters(): void ->from('locations') ->filterDistance('loc', [1.0, 2.0], '<', 50.0, false) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ST_Distance("loc", ST_GeomFromText(?)) < ?', $result->query); $this->assertEquals('POINT(1 2)', $result->bindings[0]); @@ -796,8 +863,9 @@ public function testVectorOrderWithExistingOrderBy(): void $result = (new Builder()) ->from('items') ->sortAsc('name') - ->orderByVectorDistance('embedding', [0.1], 'cosine') + ->orderByVectorDistance('embedding', [0.1], VectorMetric::Cosine) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY', $result->query); $pos_vector = strpos($result->query, '<=>'); @@ -811,9 +879,10 @@ public function testVectorOrderWithLimit(): void { $result = (new Builder()) ->from('items') - ->orderByVectorDistance('emb', [0.1, 0.2], 'cosine') + ->orderByVectorDistance('emb', [0.1, 0.2], VectorMetric::Cosine) ->limit(10) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY', $result->query); $pos_order = strpos($result->query, 'ORDER BY'); @@ -836,6 +905,7 @@ public function testVectorOrderDefaultMetric(): void ->from('items') ->orderByVectorDistance('emb', [0.5]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('<=>', $result->query); } @@ -846,6 +916,7 @@ public function testVectorFilterCosineBindings(): void ->from('embeddings') ->filter([Query::vectorCosine('embedding', [0.1, 0.2])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("embedding" <=> ?::vector)', $result->query); $this->assertEquals(json_encode([0.1, 0.2]), $result->bindings[0]); @@ -857,6 +928,7 @@ public function testVectorFilterEuclideanBindings(): void ->from('embeddings') ->filter([Query::vectorEuclidean('embedding', [0.1])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("embedding" <-> ?::vector)', $result->query); $this->assertEquals(json_encode([0.1]), $result->bindings[0]); @@ -868,6 +940,7 @@ public function testFilterJsonNotContainsAdmin(): void ->from('docs') ->filterJsonNotContains('meta', 'admin') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT ("meta" @> ?::jsonb)', $result->query); } @@ -878,6 +951,7 @@ public function testFilterJsonOverlapsArray(): void ->from('docs') ->filterJsonOverlaps('tags', ['php', 'js']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"tags" ?| ARRAY(SELECT jsonb_array_elements_text(?::jsonb))', $result->query); } @@ -888,6 +962,7 @@ public function testFilterJsonPathComparison(): void ->from('users') ->filterJsonPath('data', 'age', '>=', 21) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString("\"data\"->>'age' >= ?", $result->query); $this->assertEquals(21, $result->bindings[0]); @@ -899,6 +974,7 @@ public function testFilterJsonPathEquality(): void ->from('users') ->filterJsonPath('meta', 'status', '=', 'active') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString("\"meta\"->>'status' = ?", $result->query); $this->assertEquals('active', $result->bindings[0]); @@ -911,6 +987,7 @@ public function testSetJsonRemove(): void ->setJsonRemove('tags', 'old') ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('"tags" - ?', $result->query); $this->assertContains(json_encode('old'), $result->bindings); @@ -923,6 +1000,7 @@ public function testSetJsonIntersect(): void ->setJsonIntersect('tags', ['a', 'b']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('jsonb_agg(elem)', $result->query); $this->assertStringContainsString('elem <@ ?::jsonb', $result->query); @@ -935,6 +1013,7 @@ public function testSetJsonDiff(): void ->setJsonDiff('tags', ['x']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT elem <@ ?::jsonb', $result->query); } @@ -946,6 +1025,7 @@ public function testSetJsonUnique(): void ->setJsonUnique('tags') ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('jsonb_agg(DISTINCT elem)', $result->query); } @@ -957,6 +1037,7 @@ public function testSetJsonAppendBindings(): void ->setJsonAppend('tags', ['new']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('|| ?::jsonb', $result->query); $this->assertContains(json_encode(['new']), $result->bindings); @@ -969,6 +1050,7 @@ public function testSetJsonPrependPutsNewArrayFirst(): void ->setJsonPrepend('items', ['first']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('?::jsonb || COALESCE(', $result->query); } @@ -983,6 +1065,7 @@ public function testMultipleCTEs(): void ->with('b', $b) ->from('a') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WITH "a" AS (', $result->query); $this->assertStringContainsString('), "b" AS (', $result->query); @@ -996,6 +1079,7 @@ public function testCTEWithRecursive(): void ->withRecursive('tree', $sub) ->from('tree') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WITH RECURSIVE', $result->query); } @@ -1009,6 +1093,7 @@ public function testCTEBindingOrder(): void ->from('shipped') ->filter([Query::equal('total', [100])]) ->build(); + $this->assertBindingCount($result); // CTE bindings come first $this->assertEquals('shipped', $result->bindings[0]); @@ -1049,6 +1134,7 @@ public function testUnionAll(): void ->from('a') ->unionAll($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('UNION ALL', $result->query); } @@ -1061,6 +1147,7 @@ public function testIntersect(): void ->from('a') ->intersect($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('INTERSECT', $result->query); } @@ -1073,6 +1160,7 @@ public function testExcept(): void ->from('a') ->except($other) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('EXCEPT', $result->query); } @@ -1086,6 +1174,7 @@ public function testUnionWithBindingsOrder(): void ->filter([Query::equal('type', ['alpha'])]) ->union($other) ->build(); + $this->assertBindingCount($result); $this->assertEquals('alpha', $result->bindings[0]); $this->assertEquals('beta', $result->bindings[1]); @@ -1097,6 +1186,7 @@ public function testPage(): void ->from('items') ->page(3, 10) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LIMIT ?', $result->query); $this->assertStringContainsString('OFFSET ?', $result->query); @@ -1110,6 +1200,7 @@ public function testOffsetWithoutLimitEmitsOffsetPostgres(): void ->from('items') ->offset(5) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('OFFSET ?', $result->query); $this->assertEquals([5], $result->bindings); @@ -1123,6 +1214,7 @@ public function testCursorAfter(): void ->cursorAfter(5) ->limit(10) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('> ?', $result->query); $this->assertContains(5, $result->bindings); @@ -1137,6 +1229,7 @@ public function testCursorBefore(): void ->cursorBefore(5) ->limit(10) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('< ?', $result->query); $this->assertContains(5, $result->bindings); @@ -1148,6 +1241,7 @@ public function testSelectWindowWithPartitionOnly(): void ->from('employees') ->selectWindow('SUM("salary")', 'dept_total', ['dept'], null) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('OVER (PARTITION BY "dept")', $result->query); } @@ -1158,6 +1252,7 @@ public function testSelectWindowNoPartitionNoOrder(): void ->from('employees') ->selectWindow('COUNT(*)', 'total', null, null) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('OVER ()', $result->query); } @@ -1169,6 +1264,7 @@ public function testMultipleWindowFunctions(): void ->selectWindow('ROW_NUMBER()', 'rn', null, ['id']) ->selectWindow('RANK()', 'rnk', null, ['-score']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ROW_NUMBER()', $result->query); $this->assertStringContainsString('RANK()', $result->query); @@ -1180,6 +1276,7 @@ public function testWindowFunctionWithDescOrder(): void ->from('scores') ->selectWindow('RANK()', 'rnk', null, ['-score']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY "score" DESC', $result->query); } @@ -1197,6 +1294,7 @@ public function testCaseMultipleWhens(): void ->from('tickets') ->selectCase($case) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WHEN status = ? THEN ?', $result->query); $this->assertEquals(['active', 'Active', 'pending', 'Pending', 'closed', 'Closed'], $result->bindings); @@ -1213,6 +1311,7 @@ public function testCaseWithoutElse(): void ->from('users') ->selectCase($case) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CASE WHEN active = ? THEN ? END AS lbl', $result->query); $this->assertStringNotContainsString('ELSE', $result->query); @@ -1230,6 +1329,7 @@ public function testSetCaseInUpdate(): void ->setCase('category', $case) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('UPDATE "users" SET', $result->query); $this->assertStringContainsString('CASE WHEN age >= ? THEN ? ELSE ? END', $result->query); @@ -1295,6 +1395,7 @@ public function testBatchInsertMultipleRows(): void ->set(['name' => 'Alice', 'age' => 30]) ->set(['name' => 'Bob', 'age' => 25]) ->insert(); + $this->assertBindingCount($result); $this->assertStringContainsString('VALUES (?, ?), (?, ?)', $result->query); $this->assertEquals(['Alice', 30, 'Bob', 25], $result->bindings); @@ -1317,6 +1418,7 @@ public function testRegexUsesTildeWithCaretPattern(): void ->from('items') ->filter([Query::regex('s', '^t')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"s" ~ ?', $result->query); $this->assertEquals(['^t'], $result->bindings); @@ -1328,6 +1430,7 @@ public function testSearchUsesToTsvectorWithMultipleWords(): void ->from('articles') ->filter([Query::search('body', 'hello world')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('to_tsvector("body") @@ plainto_tsquery(?)', $result->query); $this->assertEquals(['hello world'], $result->bindings); @@ -1340,6 +1443,7 @@ public function testUpsertUsesOnConflictDoUpdateSet(): void ->set(['id' => 1, 'name' => 'Alice']) ->onConflict(['id'], ['name']) ->upsert(); + $this->assertBindingCount($result); $this->assertStringContainsString('ON CONFLICT ("id") DO UPDATE SET', $result->query); } @@ -1361,6 +1465,7 @@ public function testForUpdateLocking(): void ->from('accounts') ->forUpdate() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FOR UPDATE', $result->query); } @@ -1371,6 +1476,7 @@ public function testForShareLocking(): void ->from('accounts') ->forShare() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FOR SHARE', $result->query); } @@ -1411,6 +1517,7 @@ public function testGroupByWithHaving(): void ->groupBy(['customer_id']) ->having([Query::greaterThan('cnt', 5)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('GROUP BY "customer_id"', $result->query); $this->assertStringContainsString('HAVING "cnt" > ?', $result->query); @@ -1424,6 +1531,7 @@ public function testGroupByMultipleColumns(): void ->count('*', 'cnt') ->groupBy(['a', 'b']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('GROUP BY "a", "b"', $result->query); } @@ -1434,6 +1542,7 @@ public function testWhenTrue(): void ->from('items') ->when(true, fn (Builder $b) => $b->limit(5)) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LIMIT ?', $result->query); $this->assertContains(5, $result->bindings); @@ -1445,6 +1554,7 @@ public function testWhenFalse(): void ->from('items') ->when(false, fn (Builder $b) => $b->limit(5)) ->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('LIMIT', $result->query); } @@ -1460,6 +1570,7 @@ public function testResetClearsCTEs(): void $builder->reset(); $result = $builder->from('items')->build(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('WITH', $result->query); } @@ -1476,6 +1587,7 @@ public function testResetClearsJsonSets(): void ->set(['name' => 'test']) ->filter([Query::equal('id', [1])]) ->update(); + $this->assertBindingCount($result); $this->assertStringNotContainsString('jsonb', $result->query); } @@ -1486,6 +1598,7 @@ public function testEqualEmptyArrayReturnsFalse(): void ->from('t') ->filter([Query::equal('x', [])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('1 = 0', $result->query); } @@ -1496,6 +1609,7 @@ public function testEqualWithNullOnly(): void ->from('t') ->filter([Query::equal('x', [null])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"x" IS NULL', $result->query); } @@ -1506,6 +1620,7 @@ public function testEqualWithNullAndValues(): void ->from('t') ->filter([Query::equal('x', [1, null])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("x" IN (?) OR "x" IS NULL)', $result->query); $this->assertContains(1, $result->bindings); @@ -1517,6 +1632,7 @@ public function testNotEqualWithNullAndValues(): void ->from('t') ->filter([Query::notEqual('x', [1, null])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("x" != ? AND "x" IS NOT NULL)', $result->query); } @@ -1527,6 +1643,7 @@ public function testAndWithTwoFilters(): void ->from('t') ->filter([Query::and([Query::greaterThan('age', 18), Query::lessThan('age', 65)])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("age" > ? AND "age" < ?)', $result->query); } @@ -1537,6 +1654,7 @@ public function testOrWithTwoFilters(): void ->from('t') ->filter([Query::or([Query::equal('role', ['admin']), Query::equal('role', ['editor'])])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("role" IN (?) OR "role" IN (?))', $result->query); } @@ -1547,6 +1665,7 @@ public function testEmptyAndReturnsTrue(): void ->from('t') ->filter([Query::and([])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('1 = 1', $result->query); } @@ -1557,6 +1676,7 @@ public function testEmptyOrReturnsFalse(): void ->from('t') ->filter([Query::or([])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('1 = 0', $result->query); } @@ -1567,6 +1687,7 @@ public function testBetweenFilter(): void ->from('t') ->filter([Query::between('age', 18, 65)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"age" BETWEEN ? AND ?', $result->query); $this->assertEquals([18, 65], $result->bindings); @@ -1578,6 +1699,7 @@ public function testNotBetweenFilter(): void ->from('t') ->filter([Query::notBetween('score', 0, 50)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"score" NOT BETWEEN ? AND ?', $result->query); $this->assertEquals([0, 50], $result->bindings); @@ -1589,6 +1711,7 @@ public function testExistsSingleAttribute(): void ->from('t') ->filter([Query::exists(['name'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("name" IS NOT NULL)', $result->query); } @@ -1599,6 +1722,7 @@ public function testExistsMultipleAttributes(): void ->from('t') ->filter([Query::exists(['name', 'email'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("name" IS NOT NULL AND "email" IS NOT NULL)', $result->query); } @@ -1609,6 +1733,7 @@ public function testNotExistsSingleAttribute(): void ->from('t') ->filter([Query::notExists(['name'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("name" IS NULL)', $result->query); } @@ -1619,6 +1744,7 @@ public function testRawFilter(): void ->from('t') ->filter([Query::raw('score > ?', [10])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('score > ?', $result->query); $this->assertContains(10, $result->bindings); @@ -1630,6 +1756,7 @@ public function testRawFilterEmpty(): void ->from('t') ->filter([Query::raw('')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('1 = 1', $result->query); } @@ -1640,6 +1767,7 @@ public function testStartsWithEscapesPercent(): void ->from('t') ->filter([Query::startsWith('val', '100%')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"val" LIKE ?', $result->query); $this->assertEquals(['100\%%'], $result->bindings); @@ -1651,6 +1779,7 @@ public function testEndsWithEscapesUnderscore(): void ->from('t') ->filter([Query::endsWith('val', 'a_b')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"val" LIKE ?', $result->query); $this->assertEquals(['%a\_b'], $result->bindings); @@ -1662,6 +1791,7 @@ public function testContainsEscapesBackslash(): void ->from('t') ->filter([Query::contains('path', ['a\\b'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"path" LIKE ?', $result->query); $this->assertEquals(['%a\\\\b%'], $result->bindings); @@ -1673,6 +1803,7 @@ public function testContainsMultipleUsesOr(): void ->from('t') ->filter([Query::contains('bio', ['foo', 'bar'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("bio" LIKE ? OR "bio" LIKE ?)', $result->query); } @@ -1683,6 +1814,7 @@ public function testContainsAllUsesAnd(): void ->from('t') ->filter([Query::containsAll('bio', ['foo', 'bar'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("bio" LIKE ? AND "bio" LIKE ?)', $result->query); } @@ -1693,6 +1825,7 @@ public function testNotContainsMultipleUsesAnd(): void ->from('t') ->filter([Query::notContains('bio', ['foo', 'bar'])]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('("bio" NOT LIKE ? AND "bio" NOT LIKE ?)', $result->query); } @@ -1703,6 +1836,7 @@ public function testDottedIdentifier(): void ->from('t') ->select(['users.name']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"users"."name"', $result->query); } @@ -1714,6 +1848,7 @@ public function testMultipleOrderBy(): void ->sortAsc('name') ->sortDesc('age') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY "name" ASC, "age" DESC', $result->query); } @@ -1725,6 +1860,7 @@ public function testDistinctWithSelect(): void ->distinct() ->select(['name']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SELECT DISTINCT "name"', $result->query); } @@ -1735,6 +1871,7 @@ public function testSumWithAlias(): void ->from('t') ->sum('amount', 'total') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('SUM("amount") AS "total"', $result->query); } @@ -1746,6 +1883,7 @@ public function testMultipleAggregates(): void ->count('*', 'cnt') ->sum('amount', 'total') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*) AS "cnt"', $result->query); $this->assertStringContainsString('SUM("amount") AS "total"', $result->query); @@ -1757,6 +1895,7 @@ public function testCountWithoutAlias(): void ->from('t') ->count() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(*)', $result->query); $this->assertStringNotContainsString(' AS ', $result->query); @@ -1768,6 +1907,7 @@ public function testRightJoin(): void ->from('a') ->rightJoin('b', 'a.id', 'b.a_id') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('RIGHT JOIN "b" ON "a"."id" = "b"."a_id"', $result->query); } @@ -1778,6 +1918,7 @@ public function testCrossJoin(): void ->from('a') ->crossJoin('b') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CROSS JOIN "b"', $result->query); $this->assertStringNotContainsString(' ON ', $result->query); @@ -1799,6 +1940,7 @@ public function testIsNullFilter(): void ->from('t') ->filter([Query::isNull('deleted_at')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"deleted_at" IS NULL', $result->query); } @@ -1809,6 +1951,7 @@ public function testIsNotNullFilter(): void ->from('t') ->filter([Query::isNotNull('name')]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"name" IS NOT NULL', $result->query); } @@ -1819,6 +1962,7 @@ public function testLessThan(): void ->from('t') ->filter([Query::lessThan('age', 30)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"age" < ?', $result->query); $this->assertEquals([30], $result->bindings); @@ -1830,6 +1974,7 @@ public function testLessThanEqual(): void ->from('t') ->filter([Query::lessThanEqual('age', 30)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"age" <= ?', $result->query); $this->assertEquals([30], $result->bindings); @@ -1841,6 +1986,7 @@ public function testGreaterThan(): void ->from('t') ->filter([Query::greaterThan('score', 50)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"score" > ?', $result->query); $this->assertEquals([50], $result->bindings); @@ -1852,6 +1998,7 @@ public function testGreaterThanEqual(): void ->from('t') ->filter([Query::greaterThanEqual('score', 50)]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('"score" >= ?', $result->query); $this->assertEquals([50], $result->bindings); @@ -1865,6 +2012,7 @@ public function testDeleteWithOrderAndLimit(): void ->sortAsc('id') ->limit(100) ->delete(); + $this->assertBindingCount($result); $this->assertStringContainsString('DELETE FROM "t"', $result->query); $this->assertStringContainsString('ORDER BY "id" ASC', $result->query); @@ -1880,6 +2028,7 @@ public function testUpdateWithOrderAndLimit(): void ->sortAsc('id') ->limit(50) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('UPDATE "t" SET', $result->query); $this->assertStringContainsString('ORDER BY "id" ASC', $result->query); @@ -1891,9 +2040,10 @@ public function testVectorOrderBindingOrderWithFiltersAndLimit(): void $result = (new Builder()) ->from('items') ->filter([Query::equal('status', ['active'])]) - ->orderByVectorDistance('embedding', [0.1, 0.2], 'cosine') + ->orderByVectorDistance('embedding', [0.1, 0.2], VectorMetric::Cosine) ->limit(10) ->build(); + $this->assertBindingCount($result); // Bindings should be: filter bindings, then vector json, then limit value $this->assertEquals('active', $result->bindings[0]); @@ -1930,6 +2080,7 @@ public function testInsertReturning(): void ->set(['name' => 'John']) ->returning(['id', 'name']) ->insert(); + $this->assertBindingCount($result); $this->assertStringContainsString('RETURNING "id", "name"', $result->query); } @@ -1941,6 +2092,7 @@ public function testInsertReturningAll(): void ->set(['name' => 'John']) ->returning() ->insert(); + $this->assertBindingCount($result); $this->assertStringContainsString('RETURNING *', $result->query); } @@ -1953,6 +2105,7 @@ public function testUpdateReturning(): void ->filter([Query::equal('id', [1])]) ->returning(['id', 'name']) ->update(); + $this->assertBindingCount($result); $this->assertStringContainsString('RETURNING "id", "name"', $result->query); } @@ -1964,6 +2117,7 @@ public function testDeleteReturning(): void ->filter([Query::equal('id', [1])]) ->returning(['id']) ->delete(); + $this->assertBindingCount($result); $this->assertStringContainsString('RETURNING "id"', $result->query); } @@ -1976,6 +2130,7 @@ public function testUpsertReturning(): void ->onConflict(['id'], ['name', 'email']) ->returning(['id']) ->upsert(); + $this->assertBindingCount($result); $this->assertStringContainsString('RETURNING "id"', $result->query); } @@ -1999,6 +2154,7 @@ public function testForUpdateOf(): void ->from('users') ->forUpdateOf('users') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FOR UPDATE OF "users"', $result->query); } @@ -2009,6 +2165,7 @@ public function testForShareOf(): void ->from('users') ->forShareOf('users') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FOR SHARE OF "users"', $result->query); } @@ -2020,6 +2177,7 @@ public function testTableAliasPostgreSQL(): void $result = (new Builder()) ->from('users', 'u') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM "users" AS "u"', $result->query); } @@ -2030,6 +2188,7 @@ public function testJoinAliasPostgreSQL(): void ->from('users', 'u') ->join('orders', 'u.id', 'o.user_id', '=', 'o') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN "orders" AS "o" ON "u"."id" = "o"."user_id"', $result->query); } @@ -2043,6 +2202,7 @@ public function testFromSubPostgreSQL(): void ->fromSub($sub, 'sub') ->select(['user_id']) ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT "user_id" FROM (SELECT "user_id" FROM "orders" GROUP BY "user_id") AS "sub"', @@ -2058,6 +2218,7 @@ public function testCountDistinctPostgreSQL(): void ->from('orders') ->countDistinct('user_id', 'unique_users') ->build(); + $this->assertBindingCount($result); $this->assertEquals( 'SELECT COUNT(DISTINCT "user_id") AS "unique_users" FROM "orders"', @@ -2093,6 +2254,7 @@ public function testForUpdateSkipLockedPostgreSQL(): void ->from('users') ->forUpdateSkipLocked() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FOR UPDATE SKIP LOCKED', $result->query); } @@ -2103,6 +2265,7 @@ public function testForUpdateNoWaitPostgreSQL(): void ->from('users') ->forUpdateNoWait() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FOR UPDATE NOWAIT', $result->query); } @@ -2120,6 +2283,7 @@ public function testSubqueryBindingOrderPostgreSQL(): void ->filter([Query::equal('role', ['admin'])]) ->filterWhereIn('id', $sub) ->build(); + $this->assertBindingCount($result); $this->assertEquals(['admin', 'completed'], $result->bindings); } @@ -2132,6 +2296,7 @@ public function testFilterNotExistsPostgreSQL(): void ->from('users') ->filterNotExists($sub) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('NOT EXISTS (SELECT', $result->query); } @@ -2144,6 +2309,7 @@ public function testOrderByRawPostgreSQL(): void ->from('users') ->orderByRaw('NULLS LAST') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY NULLS LAST', $result->query); } @@ -2155,6 +2321,7 @@ public function testGroupByRawPostgreSQL(): void ->count('*', 'cnt') ->groupByRaw('date_trunc(?, "created_at")', ['month']) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('GROUP BY date_trunc(?, "created_at")', $result->query); $this->assertEquals(['month'], $result->bindings); @@ -2168,6 +2335,7 @@ public function testHavingRawPostgreSQL(): void ->groupBy(['user_id']) ->havingRaw('SUM("amount") > ?', [1000]) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('HAVING SUM("amount") > ?', $result->query); } @@ -2178,11 +2346,12 @@ public function testJoinWherePostgreSQL(): void { $result = (new Builder()) ->from('users') - ->joinWhere('orders', function (\Utopia\Query\Builder\JoinBuilder $join): void { + ->joinWhere('orders', function (JoinBuilder $join): void { $join->on('users.id', 'orders.user_id') ->where('orders.amount', '>', 100); }) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN "orders" ON "users"."id" = "orders"."user_id"', $result->query); $this->assertStringContainsString('orders.amount > ?', $result->query); @@ -2211,6 +2380,7 @@ public function testReturningSpecificColumns(): void ->set(['name' => 'John']) ->returning(['id', 'created_at']) ->insert(); + $this->assertBindingCount($result); $this->assertStringContainsString('RETURNING "id", "created_at"', $result->query); } @@ -2224,6 +2394,7 @@ public function testForUpdateOfWithFilter(): void ->filter([Query::equal('id', [1])]) ->forUpdateOf('users') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('WHERE', $result->query); $this->assertStringContainsString('FOR UPDATE OF "users"', $result->query); @@ -2238,6 +2409,7 @@ public function testFromSubClearsTablePostgreSQL(): void $result = (new Builder()) ->fromSub($sub, 'sub') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FROM (SELECT "id" FROM "orders") AS "sub"', $result->query); } @@ -2250,6 +2422,7 @@ public function testCountDistinctWithoutAliasPostgreSQL(): void ->from('users') ->countDistinct('email') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('COUNT(DISTINCT "email")', $result->query); } @@ -2266,6 +2439,7 @@ public function testMultipleExistsSubqueries(): void ->filterExists($sub1) ->filterNotExists($sub2) ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('EXISTS (SELECT', $result->query); $this->assertStringContainsString('NOT EXISTS (SELECT', $result->query); @@ -2279,6 +2453,7 @@ public function testLeftJoinAliasPostgreSQL(): void ->from('users', 'u') ->leftJoin('orders', 'u.id', 'o.user_id', '=', 'o') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LEFT JOIN "orders" AS "o"', $result->query); } @@ -2291,6 +2466,7 @@ public function testCrossJoinAliasPostgreSQL(): void ->from('users') ->crossJoin('roles', 'r') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CROSS JOIN "roles" AS "r"', $result->query); } @@ -2303,6 +2479,7 @@ public function testForShareSkipLockedPostgreSQL(): void ->from('users') ->forShareSkipLocked() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FOR SHARE SKIP LOCKED', $result->query); } @@ -2313,6 +2490,7 @@ public function testForShareNoWaitPostgreSQL(): void ->from('users') ->forShareNoWait() ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('FOR SHARE NOWAIT', $result->query); } @@ -2330,7 +2508,450 @@ public function testResetPostgreSQL(): void ->filterExists($sub) ->reset(); - $this->expectException(\Utopia\Query\Exception\ValidationException::class); + $this->expectException(ValidationException::class); $builder->build(); } + + public function testExactSimpleSelect(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name', 'email']) + ->filter([Query::equal('status', ['active'])]) + ->sortAsc('name') + ->limit(10) + ->offset(5) + ->build(); + + $this->assertSame( + 'SELECT "id", "name", "email" FROM "users" WHERE "status" IN (?) ORDER BY "name" ASC LIMIT ? OFFSET ?', + $result->query + ); + $this->assertEquals(['active', 10, 5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactSelectWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('products') + ->select(['id', 'name', 'price']) + ->filter([ + Query::greaterThan('price', 10), + Query::lessThan('price', 100), + Query::equal('category', ['electronics']), + Query::isNotNull('name'), + ]) + ->build(); + + $this->assertSame( + 'SELECT "id", "name", "price" FROM "products" WHERE "price" > ? AND "price" < ? AND "category" IN (?) AND "name" IS NOT NULL', + $result->query + ); + $this->assertEquals([10, 100, 'electronics'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactMultipleJoins(): void + { + $result = (new Builder()) + ->from('users') + ->select(['users.id', 'orders.total', 'profiles.bio']) + ->join('orders', 'users.id', 'orders.user_id') + ->leftJoin('profiles', 'users.id', 'profiles.user_id') + ->build(); + + $this->assertSame( + 'SELECT "users"."id", "orders"."total", "profiles"."bio" FROM "users" JOIN "orders" ON "users"."id" = "orders"."user_id" LEFT JOIN "profiles" ON "users"."id" = "profiles"."user_id"', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'alice@test.com']) + ->set(['name' => 'Bob', 'email' => 'bob@test.com']) + ->insert(); + + $this->assertSame( + 'INSERT INTO "users" ("name", "email") VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertEquals(['Alice', 'alice@test.com', 'Bob', 'bob@test.com'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactInsertReturning(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'alice@test.com']) + ->returning(['id']) + ->insert(); + + $this->assertSame( + 'INSERT INTO "users" ("name", "email") VALUES (?, ?) RETURNING "id"', + $result->query + ); + $this->assertEquals(['Alice', 'alice@test.com'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactUpdateReturning(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Updated']) + ->filter([Query::equal('id', [1])]) + ->returning(['*']) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "name" = ? WHERE "id" IN (?) RETURNING *', + $result->query + ); + $this->assertEquals(['Updated', 1], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDeleteReturning(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('id', [5])]) + ->returning(['id']) + ->delete(); + + $this->assertSame( + 'DELETE FROM "users" WHERE "id" IN (?) RETURNING "id"', + $result->query + ); + $this->assertEquals([5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactUpsertOnConflict(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice', 'email' => 'alice@test.com']) + ->onConflict(['id'], ['name', 'email']) + ->upsert(); + + $this->assertSame( + 'INSERT INTO "users" ("id", "name", "email") VALUES (?, ?, ?) ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name", "email" = EXCLUDED."email"', + $result->query + ); + $this->assertEquals([1, 'Alice', 'alice@test.com'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactUpsertOnConflictReturning(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], ['name']) + ->returning(['id', 'name']) + ->upsert(); + + $this->assertSame( + 'INSERT INTO "users" ("id", "name") VALUES (?, ?) ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name" RETURNING "id", "name"', + $result->query + ); + $this->assertEquals([1, 'Alice'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactInsertOrIgnore(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->insertOrIgnore(); + + $this->assertSame( + 'INSERT INTO "users" ("id", "name") VALUES (?, ?) ON CONFLICT DO NOTHING', + $result->query + ); + $this->assertEquals([1, 'Alice'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactVectorSearchCosine(): void + { + $result = (new Builder()) + ->from('embeddings') + ->select(['id', 'title']) + ->orderByVectorDistance('embedding', [0.1, 0.2, 0.3], VectorMetric::Cosine) + ->limit(5) + ->build(); + + $this->assertSame( + 'SELECT "id", "title" FROM "embeddings" ORDER BY ("embedding" <=> ?::vector) ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['[0.1,0.2,0.3]', 5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactVectorSearchEuclidean(): void + { + $result = (new Builder()) + ->from('embeddings') + ->select(['id', 'title']) + ->orderByVectorDistance('embedding', [0.5, 0.6], VectorMetric::Euclidean) + ->limit(10) + ->build(); + + $this->assertSame( + 'SELECT "id", "title" FROM "embeddings" ORDER BY ("embedding" <-> ?::vector) ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['[0.5,0.6]', 10], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactJsonbContains(): void + { + $result = (new Builder()) + ->from('documents') + ->select(['id', 'title']) + ->filterJsonContains('tags', 'php') + ->build(); + + $this->assertSame( + 'SELECT "id", "title" FROM "documents" WHERE "tags" @> ?::jsonb', + $result->query + ); + $this->assertEquals(['"php"'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactJsonbOverlaps(): void + { + $result = (new Builder()) + ->from('documents') + ->filterJsonOverlaps('tags', ['php', 'js']) + ->build(); + + $this->assertSame( + 'SELECT * FROM "documents" WHERE "tags" ?| ARRAY(SELECT jsonb_array_elements_text(?::jsonb))', + $result->query + ); + $this->assertEquals(['["php","js"]'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactJsonPath(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filterJsonPath('metadata', 'key', '=', 'value') + ->build(); + + $this->assertSame( + 'SELECT "id", "name" FROM "users" WHERE "metadata"->>\'key\' = ?', + $result->query + ); + $this->assertEquals(['value'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactCte(): void + { + $cteQuery = (new Builder()) + ->from('orders') + ->select(['user_id', 'total']) + ->filter([Query::greaterThan('total', 100)]); + + $result = (new Builder()) + ->with('big_orders', $cteQuery) + ->from('big_orders') + ->select(['user_id', 'total']) + ->build(); + + $this->assertSame( + 'WITH "big_orders" AS (SELECT "user_id", "total" FROM "orders" WHERE "total" > ?) SELECT "user_id", "total" FROM "big_orders"', + $result->query + ); + $this->assertEquals([100], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactWindowFunction(): void + { + $result = (new Builder()) + ->from('employees') + ->select(['id', 'name', 'department']) + ->selectWindow('ROW_NUMBER()', 'row_num', ['department'], ['-salary']) + ->build(); + + $this->assertSame( + 'SELECT "id", "name", "department", ROW_NUMBER() OVER (PARTITION BY "department" ORDER BY "salary" DESC) AS "row_num" FROM "employees"', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactUnion(): void + { + $second = (new Builder()) + ->from('archived_users') + ->select(['id', 'name']); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->union($second) + ->build(); + + $this->assertSame( + '(SELECT "id", "name" FROM "users") UNION (SELECT "id", "name" FROM "archived_users")', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactForUpdateOf(): void + { + $result = (new Builder()) + ->from('accounts') + ->select(['id', 'balance']) + ->filter([Query::equal('id', [42])]) + ->forUpdateOf('accounts') + ->build(); + + $this->assertSame( + 'SELECT "id", "balance" FROM "accounts" WHERE "id" IN (?) FOR UPDATE OF "accounts"', + $result->query + ); + $this->assertEquals([42], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactForShareSkipLocked(): void + { + $result = (new Builder()) + ->from('jobs') + ->select(['id', 'payload']) + ->filter([Query::equal('status', ['pending'])]) + ->forShareSkipLocked() + ->limit(1) + ->build(); + + $this->assertSame( + 'SELECT "id", "payload" FROM "jobs" WHERE "status" IN (?) LIMIT ? FOR SHARE SKIP LOCKED', + $result->query + ); + $this->assertEquals(['pending', 1], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAggregationGroupByHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'order_count') + ->groupBy(['user_id']) + ->having([Query::greaterThan('order_count', 5)]) + ->build(); + + $this->assertSame( + 'SELECT COUNT(*) AS "order_count" FROM "orders" GROUP BY "user_id" HAVING "order_count" > ?', + $result->query + ); + $this->assertEquals([5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactSubqueryWhereIn(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->filter([Query::greaterThan('total', 500)]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filterWhereIn('id', $subquery) + ->build(); + + $this->assertSame( + 'SELECT "id", "name" FROM "users" WHERE "id" IN (SELECT "user_id" FROM "orders" WHERE "total" > ?)', + $result->query + ); + $this->assertEquals([500], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactExistsSubquery(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['id']) + ->filter([Query::equal('orders.user_id', [1])]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filterExists($subquery) + ->build(); + + $this->assertSame( + 'SELECT "id", "name" FROM "users" WHERE EXISTS (SELECT "id" FROM "orders" WHERE "orders"."user_id" IN (?))', + $result->query + ); + $this->assertEquals([1], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactNestedWhereGroups(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([ + Query::equal('status', ['active']), + Query::or([ + Query::greaterThan('age', 18), + Query::equal('role', ['admin']), + ]), + ]) + ->build(); + + $this->assertSame( + 'SELECT "id", "name" FROM "users" WHERE "status" IN (?) AND ("age" > ? OR "role" IN (?))', + $result->query + ); + $this->assertEquals(['active', 18, 'admin'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDistinctWithOffset(): void + { + $result = (new Builder()) + ->from('users') + ->select(['name', 'email']) + ->distinct() + ->sortAsc('name') + ->limit(20) + ->offset(10) + ->build(); + + $this->assertSame( + 'SELECT DISTINCT "name", "email" FROM "users" ORDER BY "name" ASC LIMIT ? OFFSET ?', + $result->query + ); + $this->assertEquals([20, 10], $result->bindings); + $this->assertBindingCount($result); + } } diff --git a/tests/Query/ConditionTest.php b/tests/Query/ConditionTest.php index c2b452e..8d35e84 100644 --- a/tests/Query/ConditionTest.php +++ b/tests/Query/ConditionTest.php @@ -10,26 +10,69 @@ class ConditionTest extends TestCase public function testGetExpression(): void { $condition = new Condition('status = ?', ['active']); - $this->assertEquals('status = ?', $condition->getExpression()); + $this->assertEquals('status = ?', $condition->expression); } public function testGetBindings(): void { $condition = new Condition('status = ?', ['active']); - $this->assertEquals(['active'], $condition->getBindings()); + $this->assertEquals(['active'], $condition->bindings); } public function testEmptyBindings(): void { $condition = new Condition('1 = 1'); - $this->assertEquals('1 = 1', $condition->getExpression()); - $this->assertEquals([], $condition->getBindings()); + $this->assertEquals('1 = 1', $condition->expression); + $this->assertEquals([], $condition->bindings); } public function testMultipleBindings(): void { $condition = new Condition('age BETWEEN ? AND ?', [18, 65]); - $this->assertEquals('age BETWEEN ? AND ?', $condition->getExpression()); - $this->assertEquals([18, 65], $condition->getBindings()); + $this->assertEquals('age BETWEEN ? AND ?', $condition->expression); + $this->assertEquals([18, 65], $condition->bindings); + } + + public function testPropertiesAreReadonly(): void + { + $condition = new Condition('x = ?', [1]); + + $ref = new \ReflectionClass($condition); + $this->assertTrue($ref->isReadOnly()); + $this->assertTrue($ref->getProperty('expression')->isReadOnly()); + $this->assertTrue($ref->getProperty('bindings')->isReadOnly()); + } + + public function testExpressionPropertyNotWritable(): void + { + $condition = new Condition('x = ?', [1]); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line */ + $condition->expression = 'y = ?'; + } + + public function testBindingsPropertyNotWritable(): void + { + $condition = new Condition('x = ?', [1]); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line */ + $condition->bindings = [2]; + } + + public function testSingleBinding(): void + { + $condition = new Condition('id = ?', [42]); + $this->assertSame('id = ?', $condition->expression); + $this->assertSame([42], $condition->bindings); + } + + public function testBindingsPreserveTypes(): void + { + $condition = new Condition('a = ? AND b = ? AND c = ?', [1, 'two', 3.0]); + $this->assertIsInt($condition->bindings[0]); + $this->assertIsString($condition->bindings[1]); + $this->assertIsFloat($condition->bindings[2]); } } diff --git a/tests/Query/Hook/Filter/FilterTest.php b/tests/Query/Hook/Filter/FilterTest.php index 2bb5ed3..2bd6fc7 100644 --- a/tests/Query/Hook/Filter/FilterTest.php +++ b/tests/Query/Hook/Filter/FilterTest.php @@ -13,8 +13,8 @@ public function testTenantSingleId(): void $hook = new Tenant(['t1']); $condition = $hook->filter('users'); - $this->assertEquals('tenant_id IN (?)', $condition->getExpression()); - $this->assertEquals(['t1'], $condition->getBindings()); + $this->assertEquals('tenant_id IN (?)', $condition->expression); + $this->assertEquals(['t1'], $condition->bindings); } public function testTenantMultipleIds(): void @@ -22,8 +22,8 @@ public function testTenantMultipleIds(): void $hook = new Tenant(['t1', 't2', 't3']); $condition = $hook->filter('users'); - $this->assertEquals('tenant_id IN (?, ?, ?)', $condition->getExpression()); - $this->assertEquals(['t1', 't2', 't3'], $condition->getBindings()); + $this->assertEquals('tenant_id IN (?, ?, ?)', $condition->expression); + $this->assertEquals(['t1', 't2', 't3'], $condition->bindings); } public function testTenantCustomColumn(): void @@ -31,8 +31,8 @@ public function testTenantCustomColumn(): void $hook = new Tenant(['t1'], 'organization_id'); $condition = $hook->filter('users'); - $this->assertEquals('organization_id IN (?)', $condition->getExpression()); - $this->assertEquals(['t1'], $condition->getBindings()); + $this->assertEquals('organization_id IN (?)', $condition->expression); + $this->assertEquals(['t1'], $condition->bindings); } public function testPermissionWithRoles(): void @@ -45,9 +45,9 @@ public function testPermissionWithRoles(): void $this->assertEquals( 'id IN (SELECT DISTINCT document_id FROM mydb_documents_perms WHERE role IN (?, ?) AND type = ?)', - $condition->getExpression() + $condition->expression ); - $this->assertEquals(['role:admin', 'role:user', 'read'], $condition->getBindings()); + $this->assertEquals(['role:admin', 'role:user', 'read'], $condition->bindings); } public function testPermissionEmptyRoles(): void @@ -58,8 +58,8 @@ public function testPermissionEmptyRoles(): void ); $condition = $hook->filter('documents'); - $this->assertEquals('1 = 0', $condition->getExpression()); - $this->assertEquals([], $condition->getBindings()); + $this->assertEquals('1 = 0', $condition->expression); + $this->assertEquals([], $condition->bindings); } public function testPermissionCustomType(): void @@ -73,9 +73,9 @@ public function testPermissionCustomType(): void $this->assertEquals( 'id IN (SELECT DISTINCT document_id FROM mydb_documents_perms WHERE role IN (?) AND type = ?)', - $condition->getExpression() + $condition->expression ); - $this->assertEquals(['role:admin', 'write'], $condition->getBindings()); + $this->assertEquals(['role:admin', 'write'], $condition->bindings); } public function testPermissionCustomDocumentColumn(): void @@ -87,7 +87,7 @@ public function testPermissionCustomDocumentColumn(): void ); $condition = $hook->filter('documents'); - $this->assertStringStartsWith('doc_id IN', $condition->getExpression()); + $this->assertStringStartsWith('doc_id IN', $condition->expression); } public function testPermissionCustomColumns(): void @@ -104,9 +104,9 @@ public function testPermissionCustomColumns(): void $this->assertEquals( 'uid IN (SELECT DISTINCT resource_id FROM acl WHERE principal IN (?) AND access = ?)', - $condition->getExpression() + $condition->expression ); - $this->assertEquals(['admin', 'read'], $condition->getBindings()); + $this->assertEquals(['admin', 'read'], $condition->bindings); } public function testPermissionStaticTable(): void @@ -117,7 +117,7 @@ public function testPermissionStaticTable(): void ); $condition = $hook->filter('any_table'); - $this->assertStringContainsString('FROM permissions', $condition->getExpression()); + $this->assertStringContainsString('FROM permissions', $condition->expression); } public function testPermissionWithColumns(): void @@ -131,9 +131,9 @@ public function testPermissionWithColumns(): void $this->assertEquals( 'id IN (SELECT DISTINCT document_id FROM mydb_users_perms WHERE role IN (?) AND type = ? AND (column IS NULL OR column IN (?, ?)))', - $condition->getExpression() + $condition->expression ); - $this->assertEquals(['role:admin', 'read', 'email', 'phone'], $condition->getBindings()); + $this->assertEquals(['role:admin', 'read', 'email', 'phone'], $condition->bindings); } public function testPermissionWithSingleColumn(): void @@ -147,9 +147,9 @@ public function testPermissionWithSingleColumn(): void $this->assertEquals( 'id IN (SELECT DISTINCT document_id FROM employees_perms WHERE role IN (?) AND type = ? AND (column IS NULL OR column IN (?)))', - $condition->getExpression() + $condition->expression ); - $this->assertEquals(['role:user', 'read', 'salary'], $condition->getBindings()); + $this->assertEquals(['role:user', 'read', 'salary'], $condition->bindings); } public function testPermissionWithEmptyColumns(): void @@ -163,9 +163,9 @@ public function testPermissionWithEmptyColumns(): void $this->assertEquals( 'id IN (SELECT DISTINCT document_id FROM mydb_users_perms WHERE role IN (?) AND type = ? AND column IS NULL)', - $condition->getExpression() + $condition->expression ); - $this->assertEquals(['role:admin', 'read'], $condition->getBindings()); + $this->assertEquals(['role:admin', 'read'], $condition->bindings); } public function testPermissionWithoutColumnsOmitsClause(): void @@ -176,7 +176,7 @@ public function testPermissionWithoutColumnsOmitsClause(): void ); $condition = $hook->filter('users'); - $this->assertStringNotContainsString('column', $condition->getExpression()); + $this->assertStringNotContainsString('column', $condition->expression); } public function testPermissionCustomColumnColumn(): void @@ -191,8 +191,80 @@ public function testPermissionCustomColumnColumn(): void $this->assertEquals( 'id IN (SELECT DISTINCT document_id FROM acl WHERE role IN (?) AND type = ? AND (field IS NULL OR field IN (?)))', - $condition->getExpression() + $condition->expression ); - $this->assertEquals(['role:admin', 'read', 'email'], $condition->getBindings()); + $this->assertEquals(['role:admin', 'read', 'email'], $condition->bindings); + } + + // ══════════════════════════════════════════════════════════════ + // Coverage: Permission.php uncovered lines + // ══════════════════════════════════════════════════════════════ + + // ── Invalid column name (line 36) ──────────────────────────── + + public function testPermissionInvalidColumnNameThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid column name'); + new Permission( + roles: ['admin'], + permissionsTable: fn (string $table) => 'perms', + documentColumn: '123bad', + ); + } + + // ── Invalid permissions table name (line 51) ───────────────── + + public function testPermissionInvalidTableNameThrows(): void + { + $hook = new Permission( + roles: ['admin'], + permissionsTable: fn (string $table) => 'invalid table!', + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid permissions table name'); + $hook->filter('users'); + } + + // ── subqueryFilter (lines 72-74) ───────────────────────────── + + public function testPermissionWithSubqueryFilter(): void + { + $tenantFilter = new Tenant(['t1']); + + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => 'perms', + subqueryFilter: $tenantFilter, + ); + $condition = $hook->filter('users'); + + $this->assertStringContainsString('AND tenant_id IN (?)', $condition->expression); + $this->assertContains('t1', $condition->bindings); + } + + // ══════════════════════════════════════════════════════════════ + // Coverage: Tenant.php uncovered lines + // ══════════════════════════════════════════════════════════════ + + // ── Invalid column name (line 22) ──────────────────────────── + + public function testTenantInvalidColumnNameThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid column name'); + new Tenant(['t1'], '123bad'); + } + + // ── Empty tenantIds (line 29) ──────────────────────────────── + + public function testTenantEmptyTenantIdsReturnsNoMatch(): void + { + $hook = new Tenant([]); + $condition = $hook->filter('users'); + + $this->assertSame('1 = 0', $condition->expression); + $this->assertSame([], $condition->bindings); } } diff --git a/tests/Query/Hook/Join/FilterTest.php b/tests/Query/Hook/Join/FilterTest.php index 99988a9..9299287 100644 --- a/tests/Query/Hook/Join/FilterTest.php +++ b/tests/Query/Hook/Join/FilterTest.php @@ -3,7 +3,9 @@ namespace Tests\Query\Hook\Join; use PHPUnit\Framework\TestCase; +use Tests\Query\AssertsBindingCount; use Utopia\Query\Builder\Condition; +use Utopia\Query\Builder\JoinType; use Utopia\Query\Builder\MySQL as Builder; use Utopia\Query\Hook\Filter; use Utopia\Query\Hook\Filter\Permission; @@ -15,10 +17,11 @@ class FilterTest extends TestCase { + use AssertsBindingCount; public function testOnPlacementForLeftJoin(): void { $hook = new class () implements JoinFilter { - public function filterJoin(string $table, string $joinType): JoinCondition + public function filterJoin(string $table, JoinType $joinType): JoinCondition { return new JoinCondition( new Condition('active = ?', [1]), @@ -32,6 +35,7 @@ public function filterJoin(string $table, string $joinType): JoinCondition ->addHook($hook) ->leftJoin('orders', 'users.id', 'orders.user_id') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND active = ?', $result->query); $this->assertStringNotContainsString('WHERE', $result->query); @@ -41,7 +45,7 @@ public function filterJoin(string $table, string $joinType): JoinCondition public function testWherePlacementForInnerJoin(): void { $hook = new class () implements JoinFilter { - public function filterJoin(string $table, string $joinType): JoinCondition + public function filterJoin(string $table, JoinType $joinType): JoinCondition { return new JoinCondition( new Condition('active = ?', [1]), @@ -55,6 +59,7 @@ public function filterJoin(string $table, string $joinType): JoinCondition ->addHook($hook) ->join('orders', 'users.id', 'orders.user_id') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); $this->assertStringNotContainsString('ON `users`.`id` = `orders`.`user_id` AND', $result->query); @@ -65,7 +70,7 @@ public function filterJoin(string $table, string $joinType): JoinCondition public function testReturnsNullSkipsJoin(): void { $hook = new class () implements JoinFilter { - public function filterJoin(string $table, string $joinType): ?JoinCondition + public function filterJoin(string $table, JoinType $joinType): ?JoinCondition { return null; } @@ -76,6 +81,7 @@ public function filterJoin(string $table, string $joinType): ?JoinCondition ->addHook($hook) ->leftJoin('orders', 'users.id', 'orders.user_id') ->build(); + $this->assertBindingCount($result); $this->assertEquals('SELECT * FROM `users` LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); $this->assertEquals([], $result->bindings); @@ -84,7 +90,7 @@ public function filterJoin(string $table, string $joinType): ?JoinCondition public function testCrossJoinForcesOnToWhere(): void { $hook = new class () implements JoinFilter { - public function filterJoin(string $table, string $joinType): JoinCondition + public function filterJoin(string $table, JoinType $joinType): JoinCondition { return new JoinCondition( new Condition('active = ?', [1]), @@ -98,6 +104,7 @@ public function filterJoin(string $table, string $joinType): JoinCondition ->addHook($hook) ->crossJoin('settings') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString('CROSS JOIN `settings`', $result->query); $this->assertStringNotContainsString('CROSS JOIN `settings` AND', $result->query); @@ -108,7 +115,7 @@ public function filterJoin(string $table, string $joinType): JoinCondition public function testMultipleHooksOnSameJoin(): void { $hook1 = new class () implements JoinFilter { - public function filterJoin(string $table, string $joinType): JoinCondition + public function filterJoin(string $table, JoinType $joinType): JoinCondition { return new JoinCondition( new Condition('active = ?', [1]), @@ -118,7 +125,7 @@ public function filterJoin(string $table, string $joinType): JoinCondition }; $hook2 = new class () implements JoinFilter { - public function filterJoin(string $table, string $joinType): JoinCondition + public function filterJoin(string $table, JoinType $joinType): JoinCondition { return new JoinCondition( new Condition('visible = ?', [true]), @@ -133,6 +140,7 @@ public function filterJoin(string $table, string $joinType): JoinCondition ->addHook($hook2) ->leftJoin('orders', 'users.id', 'orders.user_id') ->build(); + $this->assertBindingCount($result); $this->assertStringContainsString( 'LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND active = ? AND visible = ?', @@ -144,7 +152,7 @@ public function filterJoin(string $table, string $joinType): JoinCondition public function testBindingOrderCorrectness(): void { $onHook = new class () implements JoinFilter { - public function filterJoin(string $table, string $joinType): JoinCondition + public function filterJoin(string $table, JoinType $joinType): JoinCondition { return new JoinCondition( new Condition('on_col = ?', ['on_val']), @@ -154,7 +162,7 @@ public function filterJoin(string $table, string $joinType): JoinCondition }; $whereHook = new class () implements JoinFilter { - public function filterJoin(string $table, string $joinType): JoinCondition + public function filterJoin(string $table, JoinType $joinType): JoinCondition { return new JoinCondition( new Condition('where_col = ?', ['where_val']), @@ -170,6 +178,7 @@ public function filterJoin(string $table, string $joinType): JoinCondition ->leftJoin('orders', 'users.id', 'orders.user_id') ->filter([Query::equal('status', ['active'])]) ->build(); + $this->assertBindingCount($result); // ON bindings come first (during join compilation), then filter bindings, then WHERE join filter bindings $this->assertEquals(['on_val', 'active', 'where_val'], $result->bindings); @@ -189,6 +198,7 @@ public function filter(string $table): Condition ->addHook($hook) ->leftJoin('orders', 'users.id', 'orders.user_id') ->build(); + $this->assertBindingCount($result); // Filter-only hooks should still apply to WHERE, not to joins $this->assertStringContainsString('LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); @@ -205,7 +215,7 @@ public function filter(string $table): Condition return new Condition('main_active = ?', [1]); } - public function filterJoin(string $table, string $joinType): JoinCondition + public function filterJoin(string $table, JoinType $joinType): JoinCondition { return new JoinCondition( new Condition('join_active = ?', [1]), @@ -219,6 +229,7 @@ public function filterJoin(string $table, string $joinType): JoinCondition ->addHook($hook) ->leftJoin('orders', 'users.id', 'orders.user_id') ->build(); + $this->assertBindingCount($result); // Filter applies to WHERE for main table $this->assertStringContainsString('WHERE main_active = ?', $result->query); @@ -234,11 +245,11 @@ public function testPermissionLeftJoinOnPlacement(): void roles: ['role:admin'], permissionsTable: fn (string $table) => "mydb_{$table}_perms", ); - $condition = $hook->filterJoin('orders', 'LEFT JOIN'); + $condition = $hook->filterJoin('orders', JoinType::Left); $this->assertNotNull($condition); $this->assertEquals(Placement::On, $condition->placement); - $this->assertStringContainsString('id IN', $condition->condition->getExpression()); + $this->assertStringContainsString('id IN', $condition->condition->expression); } public function testPermissionInnerJoinWherePlacement(): void @@ -247,7 +258,7 @@ public function testPermissionInnerJoinWherePlacement(): void roles: ['role:admin'], permissionsTable: fn (string $table) => "mydb_{$table}_perms", ); - $condition = $hook->filterJoin('orders', 'JOIN'); + $condition = $hook->filterJoin('orders', JoinType::Inner); $this->assertNotNull($condition); $this->assertEquals(Placement::Where, $condition->placement); @@ -256,17 +267,17 @@ public function testPermissionInnerJoinWherePlacement(): void public function testTenantLeftJoinOnPlacement(): void { $hook = new Tenant(['t1']); - $condition = $hook->filterJoin('orders', 'LEFT JOIN'); + $condition = $hook->filterJoin('orders', JoinType::Left); $this->assertNotNull($condition); $this->assertEquals(Placement::On, $condition->placement); - $this->assertStringContainsString('tenant_id IN', $condition->condition->getExpression()); + $this->assertStringContainsString('tenant_id IN', $condition->condition->expression); } public function testTenantInnerJoinWherePlacement(): void { $hook = new Tenant(['t1']); - $condition = $hook->filterJoin('orders', 'JOIN'); + $condition = $hook->filterJoin('orders', JoinType::Inner); $this->assertNotNull($condition); $this->assertEquals(Placement::Where, $condition->placement); @@ -277,12 +288,12 @@ public function testHookReceivesCorrectTableAndJoinType(): void // Tenant returns On for RIGHT JOIN — verifying it received the correct joinType $hook = new Tenant(['t1']); - $rightJoinResult = $hook->filterJoin('orders', 'RIGHT JOIN'); + $rightJoinResult = $hook->filterJoin('orders', JoinType::Right); $this->assertNotNull($rightJoinResult); $this->assertEquals(Placement::On, $rightJoinResult->placement); // Same hook returns Where for JOIN — verifying joinType discrimination - $innerJoinResult = $hook->filterJoin('orders', 'JOIN'); + $innerJoinResult = $hook->filterJoin('orders', JoinType::Inner); $this->assertNotNull($innerJoinResult); $this->assertEquals(Placement::Where, $innerJoinResult->placement); @@ -291,8 +302,8 @@ public function testHookReceivesCorrectTableAndJoinType(): void roles: ['role:admin'], permissionsTable: fn (string $table) => "mydb_{$table}_perms", ); - $result = $permHook->filterJoin('orders', 'LEFT JOIN'); + $result = $permHook->filterJoin('orders', JoinType::Left); $this->assertNotNull($result); - $this->assertStringContainsString('mydb_orders_perms', $result->condition->getExpression()); + $this->assertStringContainsString('mydb_orders_perms', $result->condition->expression); } } diff --git a/tests/Query/JoinQueryTest.php b/tests/Query/JoinQueryTest.php index fd8b548..da6a145 100644 --- a/tests/Query/JoinQueryTest.php +++ b/tests/Query/JoinQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Builder\MySQL; use Utopia\Query\Method; use Utopia\Query\Query; @@ -104,7 +105,7 @@ public function testCrossJoinEmptyTableName(): void public function testJoinCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\MySQL(); + $builder = new MySQL(); $query = Query::join('orders', 'users.id', 'orders.uid'); $sql = $query->compile($builder); $this->assertEquals('JOIN `orders` ON `users`.`id` = `orders`.`uid`', $sql); @@ -112,7 +113,7 @@ public function testJoinCompileDispatch(): void public function testLeftJoinCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\MySQL(); + $builder = new MySQL(); $query = Query::leftJoin('p', 'u.id', 'p.uid'); $sql = $query->compile($builder); $this->assertEquals('LEFT JOIN `p` ON `u`.`id` = `p`.`uid`', $sql); @@ -120,7 +121,7 @@ public function testLeftJoinCompileDispatch(): void public function testRightJoinCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\MySQL(); + $builder = new MySQL(); $query = Query::rightJoin('o', 'u.id', 'o.uid'); $sql = $query->compile($builder); $this->assertEquals('RIGHT JOIN `o` ON `u`.`id` = `o`.`uid`', $sql); @@ -128,7 +129,7 @@ public function testRightJoinCompileDispatch(): void public function testCrossJoinCompileDispatch(): void { - $builder = new \Utopia\Query\Builder\MySQL(); + $builder = new MySQL(); $query = Query::crossJoin('colors'); $sql = $query->compile($builder); $this->assertEquals('CROSS JOIN `colors`', $sql); diff --git a/tests/Query/QueryHelperTest.php b/tests/Query/QueryHelperTest.php index 61628a5..d04049f 100644 --- a/tests/Query/QueryHelperTest.php +++ b/tests/Query/QueryHelperTest.php @@ -871,4 +871,98 @@ public function testGetByTypeWithNewTypes(): void $distinct = Query::getByType($queries, [Method::Distinct]); $this->assertCount(1, $distinct); } + + // ── Query::diff() edge cases (exercises array_any) ───────── + + public function testDiffIdenticalArraysReturnEmpty(): void + { + $queries = [Query::equal('a', [1]), Query::limit(10), Query::orderAsc('name')]; + $result = Query::diff($queries, $queries); + $this->assertCount(0, $result); + } + + public function testDiffLargeArrayUsesArrayAny(): void + { + $a = []; + $b = []; + for ($i = 0; $i < 100; $i++) { + $a[] = Query::equal('col', [$i]); + if ($i % 2 === 0) { + $b[] = Query::equal('col', [$i]); + } + } + $result = Query::diff($a, $b); + $this->assertCount(50, $result); + } + + public function testDiffPreservesOrder(): void + { + $a = [Query::equal('x', [3]), Query::equal('x', [1]), Query::equal('x', [2])]; + $b = [Query::equal('x', [1])]; + $result = Query::diff($a, $b); + $this->assertCount(2, $result); + $this->assertSame([3], $result[0]->getValues()); + $this->assertSame([2], $result[1]->getValues()); + } + + public function testDiffWithDifferentMethodsSameAttribute(): void + { + $a = [Query::equal('name', ['John']), Query::notEqual('name', 'John')]; + $b = [Query::equal('name', ['John'])]; + $result = Query::diff($a, $b); + $this->assertCount(1, $result); + $this->assertSame(Method::NotEqual, $result[0]->getMethod()); + } + + public function testDiffSingleElementArrays(): void + { + $a = [Query::limit(10)]; + $b = [Query::limit(10)]; + $this->assertCount(0, Query::diff($a, $b)); + + $b = [Query::limit(20)]; + $this->assertCount(1, Query::diff($a, $b)); + } + + // ── #[\Deprecated] on Query::contains() ──────────────────── + + public function testContainsHasDeprecatedAttribute(): void + { + $ref = new \ReflectionMethod(Query::class, 'contains'); + $attrs = $ref->getAttributes(\Deprecated::class); + $this->assertCount(1, $attrs); + + /** @var \Deprecated $instance */ + $instance = $attrs[0]->newInstance(); + $this->assertNotNull($instance->message); + $this->assertStringContainsString('containsAny', $instance->message); + } + + public function testContainsStillFunctions(): void + { + $query = @Query::contains('tags', ['a', 'b']); + $this->assertSame(Method::Contains, $query->getMethod()); + $this->assertSame('tags', $query->getAttribute()); + $this->assertSame(['a', 'b'], $query->getValues()); + } + + // ══════════════════════════════════════════════════════════════ + // Coverage: Query.php uncovered lines + // ══════════════════════════════════════════════════════════════ + + // ── Query::page() with perPage < 1 (line 1152) ────────────── + + public function testPageThrowsOnZeroPerPage(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Per page must be >= 1'); + Query::page(1, 0); + } + + public function testPageThrowsOnNegativePerPage(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Per page must be >= 1'); + Query::page(1, -5); + } } diff --git a/tests/Query/QueryTest.php b/tests/Query/QueryTest.php index bdfd11c..5cee4ea 100644 --- a/tests/Query/QueryTest.php +++ b/tests/Query/QueryTest.php @@ -4,7 +4,9 @@ use PHPUnit\Framework\TestCase; use Utopia\Query\Builder\MySQL as MySQLBuilder; +use Utopia\Query\CursorDirection; use Utopia\Query\Method; +use Utopia\Query\OrderDirection; use Utopia\Query\Query; class QueryTest extends TestCase @@ -110,11 +112,11 @@ public function testOnArray(): void public function testMethodEnumValues(): void { - $this->assertEquals('ASC', \Utopia\Query\OrderDirection::Asc->value); - $this->assertEquals('DESC', \Utopia\Query\OrderDirection::Desc->value); - $this->assertEquals('RANDOM', \Utopia\Query\OrderDirection::Random->value); - $this->assertEquals('after', \Utopia\Query\CursorDirection::After->value); - $this->assertEquals('before', \Utopia\Query\CursorDirection::Before->value); + $this->assertEquals('ASC', OrderDirection::Asc->value); + $this->assertEquals('DESC', OrderDirection::Desc->value); + $this->assertEquals('RANDOM', OrderDirection::Random->value); + $this->assertEquals('after', CursorDirection::After->value); + $this->assertEquals('before', CursorDirection::Before->value); } public function testVectorMethodsAreVector(): void diff --git a/tests/Query/Schema/BlueprintTest.php b/tests/Query/Schema/BlueprintTest.php new file mode 100644 index 0000000..5c9a928 --- /dev/null +++ b/tests/Query/Schema/BlueprintTest.php @@ -0,0 +1,400 @@ +assertSame([], $bp->columns); + } + + public function testColumnsPropertyPopulatedByString(): void + { + $bp = new Blueprint(); + $col = $bp->string('name'); + + $this->assertCount(1, $bp->columns); + $this->assertSame($col, $bp->columns[0]); + $this->assertSame('name', $bp->columns[0]->name); + $this->assertSame(ColumnType::String, $bp->columns[0]->type); + } + + public function testColumnsPropertyPopulatedByMultipleMethods(): void + { + $bp = new Blueprint(); + $bp->integer('age'); + $bp->boolean('active'); + $bp->text('bio'); + + $this->assertCount(3, $bp->columns); + $this->assertSame('age', $bp->columns[0]->name); + $this->assertSame('active', $bp->columns[1]->name); + $this->assertSame('bio', $bp->columns[2]->name); + } + + public function testColumnsPropertyNotWritableExternally(): void + { + $bp = new Blueprint(); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line */ + $bp->columns = [new Column('x', ColumnType::String)]; + } + + public function testColumnsPopulatedById(): void + { + $bp = new Blueprint(); + $bp->id('pk'); + + $this->assertCount(1, $bp->columns); + $this->assertSame('pk', $bp->columns[0]->name); + $this->assertTrue($bp->columns[0]->isPrimary); + $this->assertTrue($bp->columns[0]->isAutoIncrement); + $this->assertTrue($bp->columns[0]->isUnsigned); + } + + public function testColumnsPopulatedByAddColumn(): void + { + $bp = new Blueprint(); + $bp->addColumn('score', ColumnType::Integer); + + $this->assertCount(1, $bp->columns); + $this->assertSame('score', $bp->columns[0]->name); + } + + public function testColumnsPopulatedByModifyColumn(): void + { + $bp = new Blueprint(); + $bp->modifyColumn('score', 'integer'); + + $this->assertCount(1, $bp->columns); + $this->assertTrue($bp->columns[0]->isModify); + } + + // ── indexes (public private(set)) ────────────────────────── + + public function testIndexesPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->indexes); + } + + public function testIndexesPopulatedByIndex(): void + { + $bp = new Blueprint(); + $bp->index(['email']); + + $this->assertCount(1, $bp->indexes); + $this->assertInstanceOf(Index::class, $bp->indexes[0]); + $this->assertSame('idx_email', $bp->indexes[0]->name); + } + + public function testIndexesPopulatedByUniqueIndex(): void + { + $bp = new Blueprint(); + $bp->uniqueIndex(['email']); + + $this->assertCount(1, $bp->indexes); + $this->assertSame('uniq_email', $bp->indexes[0]->name); + } + + public function testIndexesPopulatedByFulltextIndex(): void + { + $bp = new Blueprint(); + $bp->fulltextIndex(['body']); + + $this->assertCount(1, $bp->indexes); + $this->assertSame('ft_body', $bp->indexes[0]->name); + } + + public function testIndexesPopulatedBySpatialIndex(): void + { + $bp = new Blueprint(); + $bp->spatialIndex(['location']); + + $this->assertCount(1, $bp->indexes); + $this->assertSame('sp_location', $bp->indexes[0]->name); + } + + public function testIndexesPopulatedByAddIndex(): void + { + $bp = new Blueprint(); + $bp->addIndex('my_idx', ['col1', 'col2']); + + $this->assertCount(1, $bp->indexes); + $this->assertSame('my_idx', $bp->indexes[0]->name); + $this->assertSame(['col1', 'col2'], $bp->indexes[0]->columns); + } + + public function testIndexesPropertyNotWritableExternally(): void + { + $bp = new Blueprint(); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line */ + $bp->indexes = []; + } + + // ── foreignKeys (public private(set)) ────────────────────── + + public function testForeignKeysPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->foreignKeys); + } + + public function testForeignKeysPopulatedByForeignKey(): void + { + $bp = new Blueprint(); + $bp->foreignKey('user_id')->references('id')->on('users'); + + $this->assertCount(1, $bp->foreignKeys); + $this->assertInstanceOf(ForeignKey::class, $bp->foreignKeys[0]); + $this->assertSame('user_id', $bp->foreignKeys[0]->column); + } + + public function testForeignKeysPopulatedByAddForeignKey(): void + { + $bp = new Blueprint(); + $bp->addForeignKey('order_id')->references('id')->on('orders'); + + $this->assertCount(1, $bp->foreignKeys); + $this->assertSame('order_id', $bp->foreignKeys[0]->column); + } + + public function testForeignKeysPropertyNotWritableExternally(): void + { + $bp = new Blueprint(); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line */ + $bp->foreignKeys = []; + } + + // ── dropColumns (public private(set)) ────────────────────── + + public function testDropColumnsPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->dropColumns); + } + + public function testDropColumnsPopulatedByDropColumn(): void + { + $bp = new Blueprint(); + $bp->dropColumn('old_field'); + + $this->assertCount(1, $bp->dropColumns); + $this->assertSame('old_field', $bp->dropColumns[0]); + } + + public function testDropColumnsMultiple(): void + { + $bp = new Blueprint(); + $bp->dropColumn('a'); + $bp->dropColumn('b'); + $bp->dropColumn('c'); + + $this->assertCount(3, $bp->dropColumns); + $this->assertSame(['a', 'b', 'c'], $bp->dropColumns); + } + + // ── renameColumns (public private(set)) ──────────────────── + + public function testRenameColumnsPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->renameColumns); + } + + public function testRenameColumnsPopulatedByRenameColumn(): void + { + $bp = new Blueprint(); + $bp->renameColumn('old', 'new'); + + $this->assertCount(1, $bp->renameColumns); + $this->assertInstanceOf(RenameColumn::class, $bp->renameColumns[0]); + $this->assertSame('old', $bp->renameColumns[0]->from); + $this->assertSame('new', $bp->renameColumns[0]->to); + } + + // ── dropIndexes (public private(set)) ────────────────────── + + public function testDropIndexesPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->dropIndexes); + } + + public function testDropIndexesPopulatedByDropIndex(): void + { + $bp = new Blueprint(); + $bp->dropIndex('idx_old'); + + $this->assertCount(1, $bp->dropIndexes); + $this->assertSame('idx_old', $bp->dropIndexes[0]); + } + + // ── dropForeignKeys (public private(set)) ────────────────── + + public function testDropForeignKeysPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->dropForeignKeys); + } + + public function testDropForeignKeysPopulatedByDropForeignKey(): void + { + $bp = new Blueprint(); + $bp->dropForeignKey('fk_user'); + + $this->assertCount(1, $bp->dropForeignKeys); + $this->assertSame('fk_user', $bp->dropForeignKeys[0]); + } + + // ── rawColumnDefs (public private(set)) ──────────────────── + + public function testRawColumnDefsPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->rawColumnDefs); + } + + public function testRawColumnDefsPopulatedByRawColumn(): void + { + $bp = new Blueprint(); + $bp->rawColumn('`my_col` VARCHAR(100) NOT NULL'); + + $this->assertCount(1, $bp->rawColumnDefs); + $this->assertSame('`my_col` VARCHAR(100) NOT NULL', $bp->rawColumnDefs[0]); + } + + // ── rawIndexDefs (public private(set)) ───────────────────── + + public function testRawIndexDefsPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->rawIndexDefs); + } + + public function testRawIndexDefsPopulatedByRawIndex(): void + { + $bp = new Blueprint(); + $bp->rawIndex('INDEX `idx_custom` (`col1`)'); + + $this->assertCount(1, $bp->rawIndexDefs); + $this->assertSame('INDEX `idx_custom` (`col1`)', $bp->rawIndexDefs[0]); + } + + // ── Combined / integration ───────────────────────────────── + + public function testAllPropertiesStartEmpty(): void + { + $bp = new Blueprint(); + + $this->assertSame([], $bp->columns); + $this->assertSame([], $bp->indexes); + $this->assertSame([], $bp->foreignKeys); + $this->assertSame([], $bp->dropColumns); + $this->assertSame([], $bp->renameColumns); + $this->assertSame([], $bp->dropIndexes); + $this->assertSame([], $bp->dropForeignKeys); + $this->assertSame([], $bp->rawColumnDefs); + $this->assertSame([], $bp->rawIndexDefs); + } + + public function testMultiplePropertiesPopulatedTogether(): void + { + $bp = new Blueprint(); + $bp->string('name'); + $bp->integer('age'); + $bp->index(['name']); + $bp->foreignKey('team_id')->references('id')->on('teams'); + $bp->rawColumn('`extra` TEXT'); + $bp->rawIndex('INDEX `idx_extra` (`extra`)'); + + $this->assertCount(2, $bp->columns); + $this->assertCount(1, $bp->indexes); + $this->assertCount(1, $bp->foreignKeys); + $this->assertCount(1, $bp->rawColumnDefs); + $this->assertCount(1, $bp->rawIndexDefs); + } + + public function testAlterOperationsPopulateCorrectProperties(): void + { + $bp = new Blueprint(); + $bp->modifyColumn('score', ColumnType::BigInteger); + $bp->renameColumn('old_name', 'new_name'); + $bp->dropColumn('obsolete'); + $bp->dropIndex('idx_dead'); + $bp->dropForeignKey('fk_dead'); + + $this->assertCount(1, $bp->columns); + $this->assertTrue($bp->columns[0]->isModify); + $this->assertCount(1, $bp->renameColumns); + $this->assertCount(1, $bp->dropColumns); + $this->assertCount(1, $bp->dropIndexes); + $this->assertCount(1, $bp->dropForeignKeys); + } + + public function testColumnTypeVariants(): void + { + $bp = new Blueprint(); + $bp->text('body'); + $bp->mediumText('summary'); + $bp->longText('content'); + $bp->bigInteger('count'); + $bp->float('price'); + $bp->boolean('active'); + $bp->datetime('created_at', 3); + $bp->timestamp('updated_at', 6); + $bp->json('meta'); + $bp->binary('data'); + $bp->enum('status', ['draft', 'published']); + $bp->point('location'); + $bp->linestring('route'); + $bp->polygon('area'); + $bp->vector('embedding', 768); + + $this->assertCount(15, $bp->columns); + $this->assertSame(ColumnType::Text, $bp->columns[0]->type); + $this->assertSame(ColumnType::MediumText, $bp->columns[1]->type); + $this->assertSame(ColumnType::LongText, $bp->columns[2]->type); + $this->assertSame(ColumnType::BigInteger, $bp->columns[3]->type); + $this->assertSame(ColumnType::Float, $bp->columns[4]->type); + $this->assertSame(ColumnType::Boolean, $bp->columns[5]->type); + $this->assertSame(ColumnType::Datetime, $bp->columns[6]->type); + $this->assertSame(ColumnType::Timestamp, $bp->columns[7]->type); + $this->assertSame(ColumnType::Json, $bp->columns[8]->type); + $this->assertSame(ColumnType::Binary, $bp->columns[9]->type); + $this->assertSame(ColumnType::Enum, $bp->columns[10]->type); + $this->assertSame(ColumnType::Point, $bp->columns[11]->type); + $this->assertSame(ColumnType::Linestring, $bp->columns[12]->type); + $this->assertSame(ColumnType::Polygon, $bp->columns[13]->type); + $this->assertSame(ColumnType::Vector, $bp->columns[14]->type); + } + + public function testTimestampsHelperAddsTwoColumns(): void + { + $bp = new Blueprint(); + $bp->timestamps(6); + + $this->assertCount(2, $bp->columns); + $this->assertSame('created_at', $bp->columns[0]->name); + $this->assertSame('updated_at', $bp->columns[1]->name); + $this->assertSame(ColumnType::Datetime, $bp->columns[0]->type); + $this->assertSame(ColumnType::Datetime, $bp->columns[1]->type); + } +} diff --git a/tests/Query/Schema/ClickHouseTest.php b/tests/Query/Schema/ClickHouseTest.php index 37b9001..3ec9cd0 100644 --- a/tests/Query/Schema/ClickHouseTest.php +++ b/tests/Query/Schema/ClickHouseTest.php @@ -3,14 +3,19 @@ namespace Tests\Query\Schema; use PHPUnit\Framework\TestCase; +use Tests\Query\AssertsBindingCount; use Utopia\Query\Builder\ClickHouse as ClickHouseBuilder; use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Query; use Utopia\Query\Schema\Blueprint; use Utopia\Query\Schema\ClickHouse as Schema; +use Utopia\Query\Schema\Feature\ForeignKeys; +use Utopia\Query\Schema\Feature\Procedures; +use Utopia\Query\Schema\Feature\Triggers; class ClickHouseTest extends TestCase { + use AssertsBindingCount; // CREATE TABLE public function testCreateTableBasic(): void @@ -21,6 +26,7 @@ public function testCreateTableBasic(): void $table->string('name'); $table->datetime('created_at', 3); }); + $this->assertBindingCount($result); $this->assertStringContainsString('CREATE TABLE `events`', $result->query); $this->assertStringContainsString('`id` Int64', $result->query); @@ -44,6 +50,7 @@ public function testCreateTableColumnTypes(): void $table->json('json_col'); $table->binary('bin_col'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('`int_col` Int32', $result->query); $this->assertStringContainsString('`uint_col` UInt32', $result->query); @@ -62,6 +69,7 @@ public function testCreateTableNullableWrapping(): void $result = $schema->create('t', function (Blueprint $table) { $table->string('name')->nullable(); }); + $this->assertBindingCount($result); $this->assertStringContainsString('Nullable(String)', $result->query); } @@ -72,6 +80,7 @@ public function testCreateTableWithEnum(): void $result = $schema->create('t', function (Blueprint $table) { $table->enum('status', ['active', 'inactive']); }); + $this->assertBindingCount($result); $this->assertStringContainsString("Enum8('active' = 1, 'inactive' = 2)", $result->query); } @@ -82,6 +91,7 @@ public function testCreateTableWithVector(): void $result = $schema->create('embeddings', function (Blueprint $table) { $table->vector('embedding', 768); }); + $this->assertBindingCount($result); $this->assertStringContainsString('Array(Float64)', $result->query); } @@ -94,6 +104,7 @@ public function testCreateTableWithSpatialTypes(): void $table->linestring('path'); $table->polygon('area'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('Tuple(Float64, Float64)', $result->query); $this->assertStringContainsString('Array(Tuple(Float64, Float64))', $result->query); @@ -119,6 +130,7 @@ public function testCreateTableWithIndex(): void $table->string('name'); $table->index(['name']); }); + $this->assertBindingCount($result); $this->assertStringContainsString('INDEX `idx_name` `name` TYPE minmax GRANULARITY 3', $result->query); } @@ -130,6 +142,7 @@ public function testAlterAddColumn(): void $result = $schema->alter('events', function (Blueprint $table) { $table->addColumn('score', 'float'); }); + $this->assertBindingCount($result); $this->assertEquals('ALTER TABLE `events` ADD COLUMN `score` Float64', $result->query); } @@ -140,6 +153,7 @@ public function testAlterModifyColumn(): void $result = $schema->alter('events', function (Blueprint $table) { $table->modifyColumn('name', 'string'); }); + $this->assertBindingCount($result); $this->assertEquals('ALTER TABLE `events` MODIFY COLUMN `name` String', $result->query); } @@ -150,6 +164,7 @@ public function testAlterRenameColumn(): void $result = $schema->alter('events', function (Blueprint $table) { $table->renameColumn('old', 'new'); }); + $this->assertBindingCount($result); $this->assertEquals('ALTER TABLE `events` RENAME COLUMN `old` TO `new`', $result->query); } @@ -160,6 +175,7 @@ public function testAlterDropColumn(): void $result = $schema->alter('events', function (Blueprint $table) { $table->dropColumn('old_col'); }); + $this->assertBindingCount($result); $this->assertEquals('ALTER TABLE `events` DROP COLUMN `old_col`', $result->query); } @@ -180,6 +196,7 @@ public function testDropTable(): void { $schema = new Schema(); $result = $schema->drop('events'); + $this->assertBindingCount($result); $this->assertEquals('DROP TABLE `events`', $result->query); } @@ -188,6 +205,7 @@ public function testTruncateTable(): void { $schema = new Schema(); $result = $schema->truncate('events'); + $this->assertBindingCount($result); $this->assertEquals('TRUNCATE TABLE `events`', $result->query); } @@ -226,17 +244,17 @@ public function testDropIndex(): void public function testDoesNotImplementForeignKeys(): void { - $this->assertNotInstanceOf(\Utopia\Query\Schema\Feature\ForeignKeys::class, new Schema()); // @phpstan-ignore method.alreadyNarrowedType + $this->assertNotInstanceOf(ForeignKeys::class, new Schema()); // @phpstan-ignore method.alreadyNarrowedType } public function testDoesNotImplementProcedures(): void { - $this->assertNotInstanceOf(\Utopia\Query\Schema\Feature\Procedures::class, new Schema()); // @phpstan-ignore method.alreadyNarrowedType + $this->assertNotInstanceOf(Procedures::class, new Schema()); // @phpstan-ignore method.alreadyNarrowedType } public function testDoesNotImplementTriggers(): void { - $this->assertNotInstanceOf(\Utopia\Query\Schema\Feature\Triggers::class, new Schema()); // @phpstan-ignore method.alreadyNarrowedType + $this->assertNotInstanceOf(Triggers::class, new Schema()); // @phpstan-ignore method.alreadyNarrowedType } // Edge cases @@ -256,6 +274,7 @@ public function testCreateTableWithDefaultValue(): void $table->bigInteger('id')->primary(); $table->integer('count')->default(0); }); + $this->assertBindingCount($result); $this->assertStringContainsString('DEFAULT 0', $result->query); } @@ -267,6 +286,7 @@ public function testCreateTableWithComment(): void $table->bigInteger('id')->primary(); $table->string('name')->comment('User name'); }); + $this->assertBindingCount($result); $this->assertStringContainsString("COMMENT 'User name'", $result->query); } @@ -279,6 +299,7 @@ public function testCreateTableMultiplePrimaryKeys(): void $table->datetime('created_at', 3)->primary(); $table->string('name'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('ORDER BY (`id`, `created_at`)', $result->query); } @@ -291,6 +312,7 @@ public function testAlterMultipleOperations(): void $table->dropColumn('old_col'); $table->renameColumn('nm', 'name'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('ADD COLUMN `score` Float64', $result->query); $this->assertStringContainsString('DROP COLUMN `old_col`', $result->query); @@ -303,6 +325,7 @@ public function testAlterDropIndex(): void $result = $schema->alter('events', function (Blueprint $table) { $table->dropIndex('idx_name'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('DROP INDEX `idx_name`', $result->query); } @@ -317,6 +340,7 @@ public function testCreateTableWithMultipleIndexes(): void $table->index(['name']); $table->index(['type']); }); + $this->assertBindingCount($result); $this->assertStringContainsString('INDEX `idx_name`', $result->query); $this->assertStringContainsString('INDEX `idx_type`', $result->query); @@ -329,6 +353,7 @@ public function testCreateTableTimestampWithoutPrecision(): void $table->bigInteger('id')->primary(); $table->timestamp('ts_col'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('`ts_col` DateTime', $result->query); $this->assertStringNotContainsString('DateTime64', $result->query); @@ -341,6 +366,7 @@ public function testCreateTableDatetimeWithoutPrecision(): void $table->bigInteger('id')->primary(); $table->datetime('dt_col'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('`dt_col` DateTime', $result->query); $this->assertStringNotContainsString('DateTime64', $result->query); @@ -355,6 +381,7 @@ public function testCreateTableWithCompositeIndex(): void $table->string('type'); $table->index(['name', 'type']); }); + $this->assertBindingCount($result); // Composite index wraps in parentheses $this->assertStringContainsString('INDEX `idx_name_type` (`name`, `type`) TYPE minmax GRANULARITY 3', $result->query); @@ -369,4 +396,47 @@ public function testAlterForeignKeyStillThrows(): void $table->dropForeignKey('fk_old'); }); } + + public function testExactCreateTableWithEngine(): void + { + $schema = new Schema(); + $result = $schema->create('metrics', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->float('value'); + $table->datetime('recorded_at', 3); + }); + + $this->assertSame( + 'CREATE TABLE `metrics` (`id` Int64, `name` String, `value` Float64, `recorded_at` DateTime64(3)) ENGINE = MergeTree() ORDER BY (`id`)', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAlterTableAddColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('metrics', function (Blueprint $table) { + $table->addColumn('description', 'text')->nullable(); + }); + + $this->assertSame( + 'ALTER TABLE `metrics` ADD COLUMN `description` Nullable(String)', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('metrics'); + + $this->assertSame('DROP TABLE `metrics`', $result->query); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } } diff --git a/tests/Query/Schema/MySQLTest.php b/tests/Query/Schema/MySQLTest.php index 67fb823..7f4c9ff 100644 --- a/tests/Query/Schema/MySQLTest.php +++ b/tests/Query/Schema/MySQLTest.php @@ -3,29 +3,34 @@ namespace Tests\Query\Schema; use PHPUnit\Framework\TestCase; +use Tests\Query\AssertsBindingCount; use Utopia\Query\Builder\MySQL as SQLBuilder; use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Query; use Utopia\Query\Schema\Blueprint; +use Utopia\Query\Schema\Feature\ForeignKeys; +use Utopia\Query\Schema\Feature\Procedures; +use Utopia\Query\Schema\Feature\Triggers; use Utopia\Query\Schema\MySQL as Schema; class MySQLTest extends TestCase { + use AssertsBindingCount; // Feature interfaces public function testImplementsForeignKeys(): void { - $this->assertInstanceOf(\Utopia\Query\Schema\Feature\ForeignKeys::class, new Schema()); + $this->assertInstanceOf(ForeignKeys::class, new Schema()); } public function testImplementsProcedures(): void { - $this->assertInstanceOf(\Utopia\Query\Schema\Feature\Procedures::class, new Schema()); + $this->assertInstanceOf(Procedures::class, new Schema()); } public function testImplementsTriggers(): void { - $this->assertInstanceOf(\Utopia\Query\Schema\Feature\Triggers::class, new Schema()); + $this->assertInstanceOf(Triggers::class, new Schema()); } // CREATE TABLE @@ -38,6 +43,7 @@ public function testCreateTableBasic(): void $table->string('name', 255); $table->string('email', 255)->unique(); }); + $this->assertBindingCount($result); $this->assertEquals( 'CREATE TABLE `users` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `name` VARCHAR(255) NOT NULL, `email` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`), UNIQUE (`email`))', @@ -61,6 +67,7 @@ public function testCreateTableAllColumnTypes(): void $table->binary('bin_col'); $table->enum('status', ['active', 'inactive']); }); + $this->assertBindingCount($result); $this->assertStringContainsString('INT NOT NULL', $result->query); $this->assertStringContainsString('BIGINT NOT NULL', $result->query); @@ -84,6 +91,7 @@ public function testCreateTableWithNullableAndDefault(): void $table->integer('score')->default(0); $table->string('status')->default('draft'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('`bio` TEXT NULL', $result->query); $this->assertStringContainsString("DEFAULT 1", $result->query); @@ -97,6 +105,7 @@ public function testCreateTableWithUnsigned(): void $result = $schema->create('t', function (Blueprint $table) { $table->integer('age')->unsigned(); }); + $this->assertBindingCount($result); $this->assertStringContainsString('INT UNSIGNED NOT NULL', $result->query); } @@ -108,6 +117,7 @@ public function testCreateTableWithTimestamps(): void $table->id(); $table->timestamps(); }); + $this->assertBindingCount($result); $this->assertStringContainsString('`created_at` DATETIME(3) NOT NULL', $result->query); $this->assertStringContainsString('`updated_at` DATETIME(3) NOT NULL', $result->query); @@ -122,6 +132,7 @@ public function testCreateTableWithForeignKey(): void ->references('id')->on('users') ->onDelete('CASCADE')->onUpdate('SET NULL'); }); + $this->assertBindingCount($result); $this->assertStringContainsString( 'FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE SET NULL', @@ -139,6 +150,7 @@ public function testCreateTableWithIndexes(): void $table->index(['name', 'email']); $table->uniqueIndex(['email']); }); + $this->assertBindingCount($result); $this->assertStringContainsString('INDEX `idx_name_email` (`name`, `email`)', $result->query); $this->assertStringContainsString('UNIQUE INDEX `uniq_email` (`email`)', $result->query); @@ -153,6 +165,7 @@ public function testCreateTableWithSpatialTypes(): void $table->linestring('path'); $table->polygon('area'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('POINT SRID 4326 NOT NULL', $result->query); $this->assertStringContainsString('LINESTRING SRID 4326 NOT NULL', $result->query); @@ -162,7 +175,7 @@ public function testCreateTableWithSpatialTypes(): void public function testCreateTableVectorThrows(): void { $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('Unknown column type'); + $this->expectExceptionMessage('Vector type is not supported in MySQL.'); $schema = new Schema(); $schema->create('embeddings', function (Blueprint $table) { @@ -176,6 +189,7 @@ public function testCreateTableWithComment(): void $result = $schema->create('t', function (Blueprint $table) { $table->string('name')->comment('User display name'); }); + $this->assertBindingCount($result); $this->assertStringContainsString("COMMENT 'User display name'", $result->query); } @@ -187,6 +201,7 @@ public function testAlterAddColumn(): void $result = $schema->alter('users', function (Blueprint $table) { $table->addColumn('avatar_url', 'string', 255)->nullable()->after('email'); }); + $this->assertBindingCount($result); $this->assertEquals( 'ALTER TABLE `users` ADD COLUMN `avatar_url` VARCHAR(255) NULL AFTER `email`', @@ -200,6 +215,7 @@ public function testAlterModifyColumn(): void $result = $schema->alter('users', function (Blueprint $table) { $table->modifyColumn('name', 'string', 500); }); + $this->assertBindingCount($result); $this->assertEquals( 'ALTER TABLE `users` MODIFY COLUMN `name` VARCHAR(500) NOT NULL', @@ -213,6 +229,7 @@ public function testAlterRenameColumn(): void $result = $schema->alter('users', function (Blueprint $table) { $table->renameColumn('bio', 'biography'); }); + $this->assertBindingCount($result); $this->assertEquals( 'ALTER TABLE `users` RENAME COLUMN `bio` TO `biography`', @@ -226,6 +243,7 @@ public function testAlterDropColumn(): void $result = $schema->alter('users', function (Blueprint $table) { $table->dropColumn('age'); }); + $this->assertBindingCount($result); $this->assertEquals( 'ALTER TABLE `users` DROP COLUMN `age`', @@ -239,6 +257,7 @@ public function testAlterAddIndex(): void $result = $schema->alter('users', function (Blueprint $table) { $table->addIndex('idx_name', ['name']); }); + $this->assertBindingCount($result); $this->assertEquals( 'ALTER TABLE `users` ADD INDEX `idx_name` (`name`)', @@ -252,6 +271,7 @@ public function testAlterDropIndex(): void $result = $schema->alter('users', function (Blueprint $table) { $table->dropIndex('idx_old'); }); + $this->assertBindingCount($result); $this->assertEquals( 'ALTER TABLE `users` DROP INDEX `idx_old`', @@ -266,6 +286,7 @@ public function testAlterAddForeignKey(): void $table->addForeignKey('dept_id') ->references('id')->on('departments'); }); + $this->assertBindingCount($result); $this->assertStringContainsString( 'ADD FOREIGN KEY (`dept_id`) REFERENCES `departments` (`id`)', @@ -279,6 +300,7 @@ public function testAlterDropForeignKey(): void $result = $schema->alter('users', function (Blueprint $table) { $table->dropForeignKey('fk_old'); }); + $this->assertBindingCount($result); $this->assertEquals( 'ALTER TABLE `users` DROP FOREIGN KEY `fk_old`', @@ -294,6 +316,7 @@ public function testAlterMultipleOperations(): void $table->dropColumn('age'); $table->renameColumn('bio', 'biography'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('ADD COLUMN', $result->query); $this->assertStringContainsString('DROP COLUMN `age`', $result->query); @@ -305,6 +328,7 @@ public function testDropTable(): void { $schema = new Schema(); $result = $schema->drop('users'); + $this->assertBindingCount($result); $this->assertEquals('DROP TABLE `users`', $result->query); $this->assertEquals([], $result->bindings); @@ -323,6 +347,7 @@ public function testRenameTable(): void { $schema = new Schema(); $result = $schema->rename('users', 'members'); + $this->assertBindingCount($result); $this->assertEquals('RENAME TABLE `users` TO `members`', $result->query); } @@ -332,6 +357,7 @@ public function testTruncateTable(): void { $schema = new Schema(); $result = $schema->truncate('users'); + $this->assertBindingCount($result); $this->assertEquals('TRUNCATE TABLE `users`', $result->query); } @@ -514,6 +540,7 @@ public function testCreateTableWithMultiplePrimaryKeys(): void $table->integer('product_id')->primary(); $table->integer('quantity'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('PRIMARY KEY (`order_id`, `product_id`)', $result->query); } @@ -524,6 +551,7 @@ public function testCreateTableWithDefaultNull(): void $result = $schema->create('t', function (Blueprint $table) { $table->string('name')->nullable()->default(null); }); + $this->assertBindingCount($result); $this->assertStringContainsString('DEFAULT NULL', $result->query); } @@ -534,6 +562,7 @@ public function testCreateTableWithNumericDefault(): void $result = $schema->create('t', function (Blueprint $table) { $table->float('score')->default(0.5); }); + $this->assertBindingCount($result); $this->assertStringContainsString('DEFAULT 0.5', $result->query); } @@ -564,6 +593,7 @@ public function testAlterMultipleColumnsAndIndexes(): void $table->dropColumn('name'); $table->addIndex('idx_names', ['first_name', 'last_name']); }); + $this->assertBindingCount($result); $this->assertStringContainsString('ADD COLUMN `first_name`', $result->query); $this->assertStringContainsString('ADD COLUMN `last_name`', $result->query); @@ -580,6 +610,7 @@ public function testCreateTableForeignKeyWithAllActions(): void ->references('id')->on('posts') ->onDelete('CASCADE')->onUpdate('RESTRICT'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('ON DELETE CASCADE', $result->query); $this->assertStringContainsString('ON UPDATE RESTRICT', $result->query); @@ -608,6 +639,7 @@ public function testCreateTableTimestampWithoutPrecision(): void $result = $schema->create('t', function (Blueprint $table) { $table->timestamp('ts_col'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('TIMESTAMP NOT NULL', $result->query); $this->assertStringNotContainsString('TIMESTAMP(', $result->query); @@ -619,6 +651,7 @@ public function testCreateTableDatetimeWithoutPrecision(): void $result = $schema->create('t', function (Blueprint $table) { $table->datetime('dt_col'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('DATETIME NOT NULL', $result->query); $this->assertStringNotContainsString('DATETIME(', $result->query); @@ -639,6 +672,7 @@ public function testAlterAddAndDropForeignKey(): void $table->addForeignKey('user_id')->references('id')->on('users'); $table->dropForeignKey('fk_old_user'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('ADD FOREIGN KEY', $result->query); $this->assertStringContainsString('DROP FOREIGN KEY `fk_old_user`', $result->query); @@ -652,6 +686,7 @@ public function testBlueprintAutoGeneratedIndexName(): void $table->string('last'); $table->index(['first', 'last']); }); + $this->assertBindingCount($result); $this->assertStringContainsString('INDEX `idx_first_last`', $result->query); } @@ -663,7 +698,73 @@ public function testBlueprintAutoGeneratedUniqueIndexName(): void $table->string('email'); $table->uniqueIndex(['email']); }); + $this->assertBindingCount($result); $this->assertStringContainsString('UNIQUE INDEX `uniq_email`', $result->query); } + + public function testExactCreateTableWithColumnsAndIndexes(): void + { + $schema = new Schema(); + $result = $schema->create('products', function (Blueprint $table) { + $table->id(); + $table->string('name', 100); + $table->integer('price'); + $table->boolean('active')->default(true); + $table->index(['name']); + $table->uniqueIndex(['price']); + }); + + $this->assertSame( + 'CREATE TABLE `products` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `name` VARCHAR(100) NOT NULL, `price` INT NOT NULL, `active` TINYINT(1) NOT NULL DEFAULT 1, PRIMARY KEY (`id`), INDEX `idx_name` (`name`), UNIQUE INDEX `uniq_price` (`price`))', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAlterTableAddAndDropColumns(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addColumn('phone', 'string', 20)->nullable(); + $table->dropColumn('legacy_field'); + }); + + $this->assertSame( + 'ALTER TABLE `users` ADD COLUMN `phone` VARCHAR(20) NULL, DROP COLUMN `legacy_field`', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactCreateTableWithForeignKey(): void + { + $schema = new Schema(); + $result = $schema->create('orders', function (Blueprint $table) { + $table->id(); + $table->integer('customer_id'); + $table->foreignKey('customer_id') + ->references('id')->on('customers') + ->onDelete('CASCADE')->onUpdate('CASCADE'); + }); + + $this->assertSame( + 'CREATE TABLE `orders` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `customer_id` INT NOT NULL, PRIMARY KEY (`id`), FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE)', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('sessions'); + + $this->assertSame('DROP TABLE `sessions`', $result->query); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } } diff --git a/tests/Query/Schema/PostgreSQLTest.php b/tests/Query/Schema/PostgreSQLTest.php index 9abe5db..d1ca2c1 100644 --- a/tests/Query/Schema/PostgreSQLTest.php +++ b/tests/Query/Schema/PostgreSQLTest.php @@ -3,28 +3,33 @@ namespace Tests\Query\Schema; use PHPUnit\Framework\TestCase; +use Tests\Query\AssertsBindingCount; use Utopia\Query\Builder\PostgreSQL as PgBuilder; use Utopia\Query\Query; use Utopia\Query\Schema\Blueprint; +use Utopia\Query\Schema\Feature\ForeignKeys; +use Utopia\Query\Schema\Feature\Procedures; +use Utopia\Query\Schema\Feature\Triggers; use Utopia\Query\Schema\PostgreSQL as Schema; class PostgreSQLTest extends TestCase { + use AssertsBindingCount; // Feature interfaces public function testImplementsForeignKeys(): void { - $this->assertInstanceOf(\Utopia\Query\Schema\Feature\ForeignKeys::class, new Schema()); + $this->assertInstanceOf(ForeignKeys::class, new Schema()); } public function testImplementsProcedures(): void { - $this->assertInstanceOf(\Utopia\Query\Schema\Feature\Procedures::class, new Schema()); + $this->assertInstanceOf(Procedures::class, new Schema()); } public function testImplementsTriggers(): void { - $this->assertInstanceOf(\Utopia\Query\Schema\Feature\Triggers::class, new Schema()); + $this->assertInstanceOf(Triggers::class, new Schema()); } // CREATE TABLE — PostgreSQL types @@ -37,6 +42,7 @@ public function testCreateTableBasic(): void $table->string('name', 255); $table->string('email', 255)->unique(); }); + $this->assertBindingCount($result); $this->assertStringContainsString('"id" BIGINT', $result->query); $this->assertStringContainsString('GENERATED BY DEFAULT AS IDENTITY', $result->query); @@ -60,6 +66,7 @@ public function testCreateTableColumnTypes(): void $table->binary('bin_col'); $table->enum('status', ['active', 'inactive']); }); + $this->assertBindingCount($result); $this->assertStringContainsString('INTEGER NOT NULL', $result->query); $this->assertStringContainsString('BIGINT NOT NULL', $result->query); @@ -82,6 +89,7 @@ public function testCreateTableSpatialTypes(): void $table->linestring('path'); $table->polygon('area'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('GEOMETRY(POINT, 4326)', $result->query); $this->assertStringContainsString('GEOMETRY(LINESTRING, 4326)', $result->query); @@ -95,6 +103,7 @@ public function testCreateTableVectorType(): void $table->id(); $table->vector('embedding', 128); }); + $this->assertBindingCount($result); $this->assertStringContainsString('VECTOR(128)', $result->query); } @@ -105,6 +114,7 @@ public function testCreateTableUnsignedIgnored(): void $result = $schema->create('t', function (Blueprint $table) { $table->integer('age')->unsigned(); }); + $this->assertBindingCount($result); // PostgreSQL doesn't support UNSIGNED $this->assertStringNotContainsString('UNSIGNED', $result->query); @@ -117,6 +127,7 @@ public function testCreateTableNoInlineComment(): void $result = $schema->create('t', function (Blueprint $table) { $table->string('name')->comment('User display name'); }); + $this->assertBindingCount($result); // PostgreSQL doesn't use inline COMMENT $this->assertStringNotContainsString('COMMENT', $result->query); @@ -129,6 +140,7 @@ public function testAutoIncrementUsesIdentity(): void $result = $schema->create('t', function (Blueprint $table) { $table->id(); }); + $this->assertBindingCount($result); $this->assertStringContainsString('GENERATED BY DEFAULT AS IDENTITY', $result->query); $this->assertStringNotContainsString('AUTO_INCREMENT', $result->query); @@ -236,6 +248,7 @@ public function testAlterModifyUsesAlterColumn(): void $result = $schema->alter('users', function (Blueprint $table) { $table->modifyColumn('name', 'string', 500); }); + $this->assertBindingCount($result); $this->assertStringContainsString('ALTER COLUMN "name" TYPE VARCHAR(500)', $result->query); } @@ -246,6 +259,7 @@ public function testAlterAddIndexUsesCreateIndex(): void $result = $schema->alter('users', function (Blueprint $table) { $table->addIndex('idx_email', ['email']); }); + $this->assertBindingCount($result); $this->assertStringNotContainsString('ADD INDEX', $result->query); $this->assertStringContainsString('CREATE INDEX "idx_email" ON "users" ("email")', $result->query); @@ -257,6 +271,7 @@ public function testAlterDropIndexIsStandalone(): void $result = $schema->alter('users', function (Blueprint $table) { $table->dropIndex('idx_email'); }); + $this->assertBindingCount($result); $this->assertEquals('DROP INDEX "idx_email"', $result->query); } @@ -268,6 +283,7 @@ public function testAlterColumnAndIndexSeparateStatements(): void $table->addColumn('score', 'integer'); $table->addIndex('idx_score', ['score']); }); + $this->assertBindingCount($result); $this->assertStringContainsString('ALTER TABLE "users" ADD COLUMN', $result->query); $this->assertStringContainsString('; CREATE INDEX "idx_score" ON "users" ("score")', $result->query); @@ -279,6 +295,7 @@ public function testAlterDropForeignKeyUsesConstraint(): void $result = $schema->alter('orders', function (Blueprint $table) { $table->dropForeignKey('fk_old'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('DROP CONSTRAINT "fk_old"', $result->query); } @@ -326,6 +343,7 @@ public function testDropTable(): void { $schema = new Schema(); $result = $schema->drop('users'); + $this->assertBindingCount($result); $this->assertEquals('DROP TABLE "users"', $result->query); } @@ -334,6 +352,7 @@ public function testTruncateTable(): void { $schema = new Schema(); $result = $schema->truncate('users'); + $this->assertBindingCount($result); $this->assertEquals('TRUNCATE TABLE "users"', $result->query); } @@ -342,6 +361,7 @@ public function testRenameTableUsesAlterTable(): void { $schema = new Schema(); $result = $schema->rename('users', 'members'); + $this->assertBindingCount($result); $this->assertEquals('ALTER TABLE "users" RENAME TO "members"', $result->query); } @@ -372,6 +392,7 @@ public function testCreateTableWithMultiplePrimaryKeys(): void $table->integer('order_id')->primary(); $table->integer('product_id')->primary(); }); + $this->assertBindingCount($result); $this->assertStringContainsString('PRIMARY KEY ("order_id", "product_id")', $result->query); } @@ -382,6 +403,7 @@ public function testCreateTableWithDefaultNull(): void $result = $schema->create('t', function (Blueprint $table) { $table->string('name')->nullable()->default(null); }); + $this->assertBindingCount($result); $this->assertStringContainsString('DEFAULT NULL', $result->query); } @@ -394,6 +416,7 @@ public function testAlterAddMultipleColumns(): void $table->addColumn('last_name', 'string', 100); $table->dropColumn('name'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('ADD COLUMN "first_name"', $result->query); $this->assertStringContainsString('DROP COLUMN "name"', $result->query); @@ -405,6 +428,7 @@ public function testAlterAddForeignKey(): void $result = $schema->alter('orders', function (Blueprint $table) { $table->addForeignKey('user_id')->references('id')->on('users')->onDelete('CASCADE'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE', $result->query); } @@ -439,6 +463,7 @@ public function testAlterRenameColumn(): void $result = $schema->alter('users', function (Blueprint $table) { $table->renameColumn('bio', 'biography'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('RENAME COLUMN "bio" TO "biography"', $result->query); } @@ -450,6 +475,7 @@ public function testCreateTableWithTimestamps(): void $table->id(); $table->timestamps(); }); + $this->assertBindingCount($result); $this->assertStringContainsString('"created_at" TIMESTAMP(3)', $result->query); $this->assertStringContainsString('"updated_at" TIMESTAMP(3)', $result->query); @@ -464,6 +490,7 @@ public function testCreateTableWithForeignKey(): void ->references('id')->on('users') ->onDelete('CASCADE'); }); + $this->assertBindingCount($result); $this->assertStringContainsString('FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE', $result->query); } @@ -496,9 +523,53 @@ public function testAlterWithUniqueIndex(): void $table->addIndex('idx_email', ['email']); $table->addIndex('idx_name', ['name']); }); + $this->assertBindingCount($result); // Both should be standalone CREATE INDEX statements $this->assertStringContainsString('CREATE INDEX "idx_email" ON "users" ("email")', $result->query); $this->assertStringContainsString('CREATE INDEX "idx_name" ON "users" ("name")', $result->query); } + + public function testExactCreateTableWithTypes(): void + { + $schema = new Schema(); + $result = $schema->create('accounts', function (Blueprint $table) { + $table->id(); + $table->string('username', 50); + $table->boolean('verified'); + $table->json('metadata'); + }); + + $this->assertSame( + 'CREATE TABLE "accounts" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "username" VARCHAR(50) NOT NULL, "verified" BOOLEAN NOT NULL, "metadata" JSONB NOT NULL, PRIMARY KEY ("id"))', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAlterTableAddColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('accounts', function (Blueprint $table) { + $table->addColumn('bio', 'text')->nullable(); + }); + + $this->assertSame( + 'ALTER TABLE "accounts" ADD COLUMN "bio" TEXT NULL', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('sessions'); + + $this->assertSame('DROP TABLE "sessions"', $result->query); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } } From 54cc13f3eb5be1fb107aae21fcffc59e66bca008 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 11 Mar 2026 00:06:27 +1300 Subject: [PATCH 27/29] (test): Add Docker integration tests for MySQL, PostgreSQL, and ClickHouse --- .github/workflows/integration.yml | 64 +++ composer.json | 6 +- docker-compose.test.yml | 28 + phpunit.xml | 5 +- .../Builder/ClickHouseIntegrationTest.php | 354 +++++++++++++ .../Builder/MySQLIntegrationTest.php | 501 ++++++++++++++++++ .../Builder/PostgreSQLIntegrationTest.php | 456 ++++++++++++++++ tests/Integration/ClickHouseClient.php | 104 ++++ tests/Integration/IntegrationTestCase.php | 153 ++++++ .../Schema/ClickHouseIntegrationTest.php | 153 ++++++ .../Schema/MySQLIntegrationTest.php | 315 +++++++++++ .../Schema/PostgreSQLIntegrationTest.php | 284 ++++++++++ 12 files changed, 2420 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/integration.yml create mode 100644 docker-compose.test.yml create mode 100644 tests/Integration/Builder/ClickHouseIntegrationTest.php create mode 100644 tests/Integration/Builder/MySQLIntegrationTest.php create mode 100644 tests/Integration/Builder/PostgreSQLIntegrationTest.php create mode 100644 tests/Integration/ClickHouseClient.php create mode 100644 tests/Integration/IntegrationTestCase.php create mode 100644 tests/Integration/Schema/ClickHouseIntegrationTest.php create mode 100644 tests/Integration/Schema/MySQLIntegrationTest.php create mode 100644 tests/Integration/Schema/PostgreSQLIntegrationTest.php diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..9846749 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,64 @@ +name: Integration Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + integration: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.4 + ports: + - 13306:3306 + env: + MYSQL_ROOT_PASSWORD: test + MYSQL_DATABASE: query_test + options: >- + --health-cmd="mysqladmin ping -h localhost" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + postgres: + image: postgres:16 + ports: + - 15432:5432 + env: + POSTGRES_PASSWORD: test + POSTGRES_DB: query_test + options: >- + --health-cmd="pg_isready" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + clickhouse: + image: clickhouse/clickhouse-server:24 + ports: + - 18123:8123 + - 19000:9000 + options: >- + --health-cmd="wget --spider -q http://localhost:8123/ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: pdo, pdo_mysql, pdo_pgsql + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run integration tests + run: composer test:integration diff --git a/composer.json b/composer.json index e645108..9886c69 100644 --- a/composer.json +++ b/composer.json @@ -12,11 +12,13 @@ }, "autoload-dev": { "psr-4": { - "Tests\\Query\\": "tests/Query" + "Tests\\Query\\": "tests/Query", + "Tests\\Integration\\": "tests/Integration" } }, "scripts": { - "test": "vendor/bin/phpunit --configuration phpunit.xml", + "test": "vendor/bin/phpunit --testsuite Query", + "test:integration": "vendor/bin/phpunit --testsuite Integration", "lint": "php -d memory_limit=2G ./vendor/bin/pint --test", "format": "php -d memory_limit=2G ./vendor/bin/pint", "check": "./vendor/bin/phpstan analyse --level max src tests --memory-limit 2G" diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..344101b --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,28 @@ +services: + mysql: + image: mysql:8.4 + ports: + - "13306:3306" + environment: + MYSQL_ROOT_PASSWORD: test + MYSQL_DATABASE: query_test + tmpfs: + - /var/lib/mysql + + postgres: + image: postgres:16 + ports: + - "15432:5432" + environment: + POSTGRES_PASSWORD: test + POSTGRES_DB: query_test + tmpfs: + - /var/lib/postgresql/data + + clickhouse: + image: clickhouse/clickhouse-server:24 + ports: + - "18123:8123" + - "19000:9000" + tmpfs: + - /var/lib/clickhouse diff --git a/phpunit.xml b/phpunit.xml index 2ac99d0..2536aa0 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -6,7 +6,10 @@ bootstrap="vendor/autoload.php"> - tests + tests/Query + + + tests/Integration diff --git a/tests/Integration/Builder/ClickHouseIntegrationTest.php b/tests/Integration/Builder/ClickHouseIntegrationTest.php new file mode 100644 index 0000000..80da5c4 --- /dev/null +++ b/tests/Integration/Builder/ClickHouseIntegrationTest.php @@ -0,0 +1,354 @@ +connectClickhouse(); + + $this->trackClickhouseTable('ch_events'); + $this->trackClickhouseTable('ch_users'); + + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_events`'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_users`'); + + $this->clickhouseStatement(' + CREATE TABLE `ch_users` ( + `id` UInt32, + `name` String, + `email` String, + `age` UInt32, + `country` String + ) ENGINE = MergeTree() + ORDER BY `id` + '); + + $this->clickhouseStatement(' + CREATE TABLE `ch_events` ( + `id` UInt32, + `user_id` UInt32, + `action` String, + `value` Float64, + `created_at` DateTime + ) ENGINE = MergeTree() + ORDER BY (`id`, `created_at`) + '); + + $this->clickhouseStatement(" + INSERT INTO `ch_users` (`id`, `name`, `email`, `age`, `country`) VALUES + (1, 'Alice', 'alice@test.com', 30, 'US'), + (2, 'Bob', 'bob@test.com', 25, 'UK'), + (3, 'Charlie', 'charlie@test.com', 35, 'US'), + (4, 'Diana', 'diana@test.com', 28, 'DE'), + (5, 'Eve', 'eve@test.com', 22, 'UK') + "); + + $this->clickhouseStatement(" + INSERT INTO `ch_events` (`id`, `user_id`, `action`, `value`, `created_at`) VALUES + (1, 1, 'click', 1.5, '2024-01-01 10:00:00'), + (2, 1, 'purchase', 99.99, '2024-01-02 11:00:00'), + (3, 2, 'click', 2.0, '2024-01-01 12:00:00'), + (4, 2, 'click', 3.5, '2024-01-03 09:00:00'), + (5, 3, 'purchase', 49.99, '2024-01-02 14:00:00'), + (6, 3, 'view', 0.0, '2024-01-04 08:00:00'), + (7, 4, 'click', 1.0, '2024-01-05 10:00:00'), + (8, 5, 'purchase', 199.99, '2024-01-06 16:00:00') + "); + } + + public function testSelectWithWhere(): void + { + $result = (new Builder()) + ->from('ch_users') + ->select(['id', 'name', 'country']) + ->filter([Query::equal('country', ['US'])]) + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(2, $rows); + $names = array_column($rows, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + } + + public function testSelectWithOrderByAndLimit(): void + { + $result = (new Builder()) + ->from('ch_users') + ->select(['id', 'name', 'age']) + ->sortDesc('age') + ->limit(3) + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(3, $rows); + $this->assertEquals('Charlie', $rows[0]['name']); + $this->assertEquals('Alice', $rows[1]['name']); + $this->assertEquals('Diana', $rows[2]['name']); + } + + public function testSelectWithJoin(): void + { + $result = (new Builder()) + ->from('ch_events', 'e') + ->select(['e.id', 'e.action', 'u.name']) + ->join('ch_users', 'e.user_id', 'u.id', '=', 'u') + ->filter([Query::equal('e.action', ['purchase'])]) + ->sortAsc('e.id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(3, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals('Charlie', $rows[1]['name']); + $this->assertEquals('Eve', $rows[2]['name']); + } + + public function testSelectWithPrewhere(): void + { + $result = (new Builder()) + ->from('ch_events') + ->select(['id', 'action', 'value']) + ->prewhere([Query::equal('action', ['click'])]) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(3, $rows); + foreach ($rows as $row) { + $this->assertEquals('click', $row['action']); + } + } + + public function testSelectWithFinal(): void + { + $result = (new Builder()) + ->from('ch_users') + ->select(['id', 'name']) + ->final() + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(5, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + } + + public function testInsertSingleRow(): void + { + $insert = (new Builder()) + ->into('ch_events') + ->set([ + 'id' => 100, + 'user_id' => 1, + 'action' => 'signup', + 'value' => 0.0, + 'created_at' => '2024-02-01 00:00:00', + ]) + ->insert(); + + $this->executeOnClickhouse($insert); + + $select = (new Builder()) + ->from('ch_events') + ->select(['id', 'action']) + ->filter([Query::equal('id', [100])]) + ->build(); + + $rows = $this->executeOnClickhouse($select); + + $this->assertCount(1, $rows); + $this->assertEquals('signup', $rows[0]['action']); + } + + public function testInsertMultipleRows(): void + { + $insert = (new Builder()) + ->into('ch_users') + ->set(['id' => 10, 'name' => 'Frank', 'email' => 'frank@test.com', 'age' => 40, 'country' => 'FR']) + ->set(['id' => 11, 'name' => 'Grace', 'email' => 'grace@test.com', 'age' => 33, 'country' => 'FR']) + ->insert(); + + $this->executeOnClickhouse($insert); + + $select = (new Builder()) + ->from('ch_users') + ->select(['id', 'name']) + ->filter([Query::equal('country', ['FR'])]) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($select); + + $this->assertCount(2, $rows); + $this->assertEquals('Frank', $rows[0]['name']); + $this->assertEquals('Grace', $rows[1]['name']); + } + + public function testSelectWithGroupByAndHaving(): void + { + $result = (new Builder()) + ->from('ch_events') + ->select(['action']) + ->count('*', 'cnt') + ->groupBy(['action']) + ->having([Query::greaterThan('cnt', 1)]) + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $actions = array_column($rows, 'action'); + $this->assertContains('click', $actions); + $this->assertContains('purchase', $actions); + foreach ($rows as $row) { + $this->assertGreaterThan(1, (int) $row['cnt']); // @phpstan-ignore cast.int + } + } + + public function testSelectWithUnionAll(): void + { + $first = (new Builder()) + ->from('ch_users') + ->select(['name']) + ->filter([Query::equal('country', ['US'])]); + + $second = (new Builder()) + ->from('ch_users') + ->select(['name']) + ->filter([Query::equal('country', ['UK'])]); + + $result = $first->unionAll($second)->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(4, $rows); + $names = array_column($rows, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + $this->assertContains('Bob', $names); + $this->assertContains('Eve', $names); + } + + public function testSelectWithCte(): void + { + $cteQuery = (new Builder()) + ->from('ch_users') + ->select(['id', 'name', 'country']) + ->filter([Query::equal('country', ['US'])]); + + $result = (new Builder()) + ->with('us_users', $cteQuery) + ->from('us_users') + ->select(['id', 'name']) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(2, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals('Charlie', $rows[1]['name']); + } + + public function testSelectWithWindowFunction(): void + { + $result = (new Builder()) + ->from('ch_events') + ->select(['id', 'action', 'value']) + ->selectWindow('row_number()', 'rn', ['action'], ['id']) + ->filter([Query::equal('action', ['click'])]) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(3, $rows); + $this->assertEquals(1, (int) $rows[0]['rn']); // @phpstan-ignore cast.int + $this->assertEquals(2, (int) $rows[1]['rn']); // @phpstan-ignore cast.int + $this->assertEquals(3, (int) $rows[2]['rn']); // @phpstan-ignore cast.int + } + + public function testSelectWithDistinct(): void + { + $result = (new Builder()) + ->from('ch_users') + ->select(['country']) + ->distinct() + ->sortAsc('country') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(3, $rows); + $countries = array_column($rows, 'country'); + $this->assertEquals(['DE', 'UK', 'US'], $countries); + } + + public function testSelectWithSubqueryInWhere(): void + { + $subquery = (new Builder()) + ->from('ch_events') + ->select(['user_id']) + ->filter([Query::equal('action', ['purchase'])]); + + $result = (new Builder()) + ->from('ch_users') + ->select(['id', 'name']) + ->filterWhereIn('id', $subquery) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(3, $rows); + $names = array_column($rows, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + $this->assertContains('Eve', $names); + } + + public function testSelectWithSample(): void + { + $result = (new Builder()) + ->from('ch_users') + ->select(['id', 'name']) + ->sample(0.5) + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertLessThanOrEqual(5, count($rows)); + foreach ($rows as $row) { + $this->assertArrayHasKey('id', $row); + $this->assertArrayHasKey('name', $row); + } + } + + public function testSelectWithSettings(): void + { + $result = (new Builder()) + ->from('ch_events') + ->select(['id', 'action', 'value']) + ->sortAsc('id') + ->settings(['max_threads' => '2']) + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(8, $rows); + $this->assertEquals('click', $rows[0]['action']); + } +} diff --git a/tests/Integration/Builder/MySQLIntegrationTest.php b/tests/Integration/Builder/MySQLIntegrationTest.php new file mode 100644 index 0000000..c141ee3 --- /dev/null +++ b/tests/Integration/Builder/MySQLIntegrationTest.php @@ -0,0 +1,501 @@ +builder = new Builder(); + $pdo = $this->connectMysql(); + + $this->trackMysqlTable('users'); + $this->trackMysqlTable('orders'); + + $this->mysqlStatement('DROP TABLE IF EXISTS `orders`'); + $this->mysqlStatement('DROP TABLE IF EXISTS `users`'); + + $this->mysqlStatement(' + CREATE TABLE `users` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL, + `email` VARCHAR(150) NOT NULL UNIQUE, + `age` INT NOT NULL DEFAULT 0, + `city` VARCHAR(100) NOT NULL DEFAULT \'\', + `active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB + '); + + $this->mysqlStatement(' + CREATE TABLE `orders` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL, + `product` VARCHAR(100) NOT NULL, + `amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `status` VARCHAR(20) NOT NULL DEFAULT \'pending\', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) + ) ENGINE=InnoDB + '); + + $stmt = $pdo->prepare(' + INSERT INTO `users` (`name`, `email`, `age`, `city`, `active`) VALUES + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?) + '); + $stmt->execute([ + 'Alice', 'alice@example.com', 30, 'New York', 1, + 'Bob', 'bob@example.com', 25, 'London', 1, + 'Charlie', 'charlie@example.com', 35, 'New York', 0, + 'Diana', 'diana@example.com', 28, 'Paris', 1, + 'Eve', 'eve@example.com', 22, 'London', 1, + ]); + + $stmt = $pdo->prepare(' + INSERT INTO `orders` (`user_id`, `product`, `amount`, `status`) VALUES + (?, ?, ?, ?), + (?, ?, ?, ?), + (?, ?, ?, ?), + (?, ?, ?, ?), + (?, ?, ?, ?), + (?, ?, ?, ?) + '); + $stmt->execute([ + 1, 'Widget', 29.99, 'completed', + 1, 'Gadget', 49.99, 'completed', + 2, 'Widget', 29.99, 'pending', + 3, 'Gizmo', 19.99, 'completed', + 4, 'Widget', 29.99, 'cancelled', + 4, 'Gadget', 49.99, 'pending', + ]); + } + + private function fresh(): Builder + { + return $this->builder->reset(); + } + + public function testSelectWithWhere(): void + { + $result = $this->fresh() + ->from('users') + ->select(['name', 'email']) + ->filter([Query::equal('city', ['New York'])]) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(2, $rows); + $names = array_column($rows, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + } + + public function testSelectWithOrderByAndLimit(): void + { + $result = $this->fresh() + ->from('users') + ->select(['name', 'age']) + ->sortDesc('age') + ->limit(3) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(3, $rows); + $this->assertEquals('Charlie', $rows[0]['name']); + $this->assertEquals('Alice', $rows[1]['name']); + $this->assertEquals('Diana', $rows[2]['name']); + } + + public function testSelectWithJoin(): void + { + $result = $this->fresh() + ->from('users', 'u') + ->select(['u.name', 'o.product', 'o.amount']) + ->join('orders', 'u.id', 'o.user_id', '=', 'o') + ->filter([Query::equal('o.status', ['completed'])]) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(3, $rows); + $products = array_column($rows, 'product'); + $this->assertContains('Widget', $products); + $this->assertContains('Gadget', $products); + $this->assertContains('Gizmo', $products); + } + + public function testSelectWithLeftJoin(): void + { + $result = $this->fresh() + ->from('users', 'u') + ->select(['u.name', 'o.product']) + ->leftJoin('orders', 'u.id', 'o.user_id', '=', 'o') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertNotEmpty($rows); + $names = array_column($rows, 'name'); + $this->assertContains('Eve', $names); + } + + public function testInsertSingleRow(): void + { + $result = $this->fresh() + ->into('users') + ->set(['name' => 'Frank', 'email' => 'frank@example.com', 'age' => 40, 'city' => 'Berlin', 'active' => 1]) + ->insert(); + + $this->executeOnMysql($result); + + $rows = $this->executeOnMysql( + $this->fresh() + ->from('users') + ->select(['name']) + ->filter([Query::equal('email', ['frank@example.com'])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertEquals('Frank', $rows[0]['name']); + } + + public function testInsertMultipleRows(): void + { + $result = $this->fresh() + ->into('users') + ->set(['name' => 'Grace', 'email' => 'grace@example.com', 'age' => 33, 'city' => 'Tokyo', 'active' => 1]) + ->set(['name' => 'Hank', 'email' => 'hank@example.com', 'age' => 45, 'city' => 'Tokyo', 'active' => 0]) + ->insert(); + + $this->executeOnMysql($result); + + $rows = $this->executeOnMysql( + $this->fresh() + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['Tokyo'])]) + ->build() + ); + + $this->assertCount(2, $rows); + } + + public function testUpdateWithWhere(): void + { + $result = $this->fresh() + ->from('users') + ->set(['active' => 0]) + ->filter([Query::equal('name', ['Bob'])]) + ->update(); + + $this->executeOnMysql($result); + + $rows = $this->executeOnMysql( + $this->fresh() + ->from('users') + ->select(['active']) + ->filter([Query::equal('name', ['Bob'])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertEquals(0, $rows[0]['active']); + } + + public function testDeleteWithWhere(): void + { + $this->mysqlStatement('DELETE FROM `orders` WHERE `user_id` = 3'); + + $result = $this->fresh() + ->from('users') + ->filter([Query::equal('name', ['Charlie'])]) + ->delete(); + + $this->executeOnMysql($result); + + $rows = $this->executeOnMysql( + $this->fresh() + ->from('users') + ->select(['name']) + ->filter([Query::equal('name', ['Charlie'])]) + ->build() + ); + + $this->assertCount(0, $rows); + } + + public function testSelectWithGroupByAndHaving(): void + { + $result = $this->fresh() + ->from('orders') + ->select(['user_id']) + ->count('*', 'order_count') + ->groupBy(['user_id']) + ->having([Query::greaterThan('order_count', 1)]) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(2, $rows); + foreach ($rows as $row) { + $this->assertGreaterThan(1, (int) $row['order_count']); // @phpstan-ignore cast.int + } + } + + public function testSelectWithUnion(): void + { + $result = $this->fresh() + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['New York'])]) + ->union( + (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['London'])]) + ) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(4, $rows); + $names = array_column($rows, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + $this->assertContains('Bob', $names); + $this->assertContains('Eve', $names); + } + + public function testSelectWithCaseExpression(): void + { + $case = (new CaseBuilder()) + ->when('`age` < 25', "'young'") + ->when('`age` BETWEEN 25 AND 30', "'mid'") + ->elseResult("'senior'") + ->alias('`age_group`') + ->build(); + + $result = $this->fresh() + ->from('users') + ->select(['name']) + ->selectCase($case) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(5, $rows); + $map = array_column($rows, 'age_group', 'name'); + $this->assertEquals('mid', $map['Alice']); + $this->assertEquals('mid', $map['Bob']); + $this->assertEquals('senior', $map['Charlie']); + $this->assertEquals('mid', $map['Diana']); + $this->assertEquals('young', $map['Eve']); + } + + public function testSelectWithWhereInSubquery(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->filter([Query::equal('status', ['completed'])]); + + $result = $this->fresh() + ->from('users') + ->select(['name']) + ->filterWhereIn('id', $subquery) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(2, $rows); + $names = array_column($rows, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + } + + public function testSelectWithExistsSubquery(): void + { + $subquery = (new Builder()) + ->from('orders', 'o') + ->selectRaw('1') + ->filter([Query::equal('o.status', ['completed'])]); + + $result = $this->fresh() + ->from('users', 'u') + ->select(['u.name']) + ->filterExists($subquery) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(5, $rows); + + $noMatchSubquery = (new Builder()) + ->from('orders', 'o') + ->selectRaw('1') + ->filter([Query::equal('o.status', ['refunded'])]); + + $emptyResult = $this->fresh() + ->from('users', 'u') + ->select(['u.name']) + ->filterExists($noMatchSubquery) + ->build(); + + $emptyRows = $this->executeOnMysql($emptyResult); + + $this->assertCount(0, $emptyRows); + } + + public function testSelectWithCte(): void + { + $cteQuery = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->sum('amount', 'total') + ->groupBy(['user_id']); + + $result = $this->fresh() + ->with('user_totals', $cteQuery) + ->from('user_totals') + ->select(['user_id', 'total']) + ->filter([Query::greaterThan('total', 30)]) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertNotEmpty($rows); + foreach ($rows as $row) { + $this->assertGreaterThan(30, (float) $row['total']); // @phpstan-ignore cast.double + } + } + + public function testUpsertOnDuplicateKeyUpdate(): void + { + $result = $this->fresh() + ->into('users') + ->set(['name' => 'Alice', 'email' => 'alice@example.com', 'age' => 31, 'city' => 'New York', 'active' => 1]) + ->onConflict(['email'], ['age']) + ->upsert(); + + $this->executeOnMysql($result); + + $rows = $this->executeOnMysql( + $this->fresh() + ->from('users') + ->select(['age']) + ->filter([Query::equal('email', ['alice@example.com'])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertEquals(31, (int) $rows[0]['age']); // @phpstan-ignore cast.int + } + + public function testSelectWithWindowFunction(): void + { + $result = $this->fresh() + ->from('orders') + ->select(['user_id', 'product', 'amount']) + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['-amount']) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertNotEmpty($rows); + foreach ($rows as $row) { + $this->assertArrayHasKey('rn', $row); + $this->assertGreaterThanOrEqual(1, (int) $row['rn']); // @phpstan-ignore cast.int + } + } + + public function testSelectWithDistinct(): void + { + $result = $this->fresh() + ->from('orders') + ->select(['product']) + ->distinct() + ->sortAsc('product') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(3, $rows); + $products = array_column($rows, 'product'); + $this->assertEquals(['Gadget', 'Gizmo', 'Widget'], $products); + } + + public function testSelectWithBetween(): void + { + $result = $this->fresh() + ->from('users') + ->select(['name', 'age']) + ->filter([Query::between('age', 25, 30)]) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(3, $rows); + foreach ($rows as $row) { + $this->assertGreaterThanOrEqual(25, (int) $row['age']); // @phpstan-ignore cast.int + $this->assertLessThanOrEqual(30, (int) $row['age']); // @phpstan-ignore cast.int + } + } + + public function testSelectWithStartsWith(): void + { + $result = $this->fresh() + ->from('users') + ->select(['name', 'email']) + ->filter([Query::startsWith('name', 'Al')]) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(1, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + } + + public function testSelectForUpdate(): void + { + $pdo = $this->connectMysql(); + $pdo->beginTransaction(); + + try { + $result = $this->fresh() + ->from('users') + ->select(['name', 'age']) + ->filter([Query::equal('name', ['Alice'])]) + ->forUpdate() + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(1, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + + $pdo->commit(); + } catch (\Throwable $e) { + $pdo->rollBack(); + throw $e; + } + } +} diff --git a/tests/Integration/Builder/PostgreSQLIntegrationTest.php b/tests/Integration/Builder/PostgreSQLIntegrationTest.php new file mode 100644 index 0000000..0cf2ad5 --- /dev/null +++ b/tests/Integration/Builder/PostgreSQLIntegrationTest.php @@ -0,0 +1,456 @@ +trackPostgresTable('users'); + $this->trackPostgresTable('orders'); + + $this->postgresStatement('DROP TABLE IF EXISTS "orders" CASCADE'); + $this->postgresStatement('DROP TABLE IF EXISTS "users" CASCADE'); + + $this->postgresStatement(' + CREATE TABLE "users" ( + "id" SERIAL PRIMARY KEY, + "name" VARCHAR(255) NOT NULL, + "email" VARCHAR(255) NOT NULL UNIQUE, + "age" INT NOT NULL DEFAULT 0, + "city" VARCHAR(100) DEFAULT NULL, + "active" BOOLEAN NOT NULL DEFAULT TRUE, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW() + ) + '); + + $this->postgresStatement(' + CREATE TABLE "orders" ( + "id" SERIAL PRIMARY KEY, + "user_id" INT NOT NULL REFERENCES "users"("id"), + "product" VARCHAR(255) NOT NULL, + "amount" DECIMAL(10,2) NOT NULL, + "status" VARCHAR(50) NOT NULL DEFAULT \'pending\', + "created_at" TIMESTAMP NOT NULL DEFAULT NOW() + ) + '); + + $this->postgresStatement(" + INSERT INTO \"users\" (\"name\", \"email\", \"age\", \"city\", \"active\") VALUES + ('Alice', 'alice@example.com', 30, 'New York', TRUE), + ('Bob', 'bob@example.com', 25, 'London', TRUE), + ('Charlie', 'charlie@example.com', 35, 'New York', FALSE), + ('Diana', 'diana@example.com', 28, 'Paris', TRUE), + ('Eve', 'eve@example.com', 22, 'London', TRUE) + "); + + $this->postgresStatement(" + INSERT INTO \"orders\" (\"user_id\", \"product\", \"amount\", \"status\") VALUES + (1, 'Widget', 29.99, 'completed'), + (1, 'Gadget', 49.99, 'completed'), + (2, 'Widget', 29.99, 'pending'), + (3, 'Gizmo', 99.99, 'completed'), + (4, 'Widget', 29.99, 'cancelled'), + (4, 'Gadget', 49.99, 'pending'), + (5, 'Gizmo', 99.99, 'completed') + "); + } + + public function testSelectWithWhere(): void + { + $result = (new Builder()) + ->from('users') + ->select(['name', 'email']) + ->filter([Query::equal('city', ['New York'])]) + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(2, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals('Charlie', $rows[1]['name']); + } + + public function testSelectWithOrderByLimitOffset(): void + { + $result = (new Builder()) + ->from('users') + ->select(['name']) + ->sortAsc('name') + ->limit(2) + ->offset(1) + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(2, $rows); + $this->assertEquals('Bob', $rows[0]['name']); + $this->assertEquals('Charlie', $rows[1]['name']); + } + + public function testSelectWithJoin(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->select(['u.name', 'o.product', 'o.amount']) + ->join('orders', 'u.id', 'o.user_id', '=', 'o') + ->filter([Query::equal('o.status', ['completed'])]) + ->sortAsc('u.name') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(4, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals('Widget', $rows[0]['product']); + } + + public function testSelectWithLeftJoin(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->select(['u.name', 'o.product']) + ->leftJoin('orders', 'u.id', 'o.user_id', '=', 'o') + ->filter([Query::equal('o.status', ['cancelled'])]) + ->sortAsc('u.name') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertEquals('Diana', $rows[0]['name']); + } + + public function testInsertSingleRow(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Frank', 'email' => 'frank@example.com', 'age' => 40, 'city' => 'Berlin', 'active' => true]) + ->insert(); + + $this->executeOnPostgres($result); + + $check = (new Builder()) + ->from('users') + ->select(['name', 'city']) + ->filter([Query::equal('email', ['frank@example.com'])]) + ->build(); + + $rows = $this->executeOnPostgres($check); + + $this->assertCount(1, $rows); + $this->assertEquals('Frank', $rows[0]['name']); + $this->assertEquals('Berlin', $rows[0]['city']); + } + + public function testInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Grace', 'email' => 'grace@example.com', 'age' => 33, 'city' => 'Tokyo', 'active' => true]) + ->set(['name' => 'Hank', 'email' => 'hank@example.com', 'age' => 45, 'city' => 'Tokyo', 'active' => false]) + ->insert(); + + $this->executeOnPostgres($result); + + $check = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['Tokyo'])]) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnPostgres($check); + + $this->assertCount(2, $rows); + $this->assertEquals('Grace', $rows[0]['name']); + $this->assertEquals('Hank', $rows[1]['name']); + } + + public function testInsertWithReturning(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Ivy', 'email' => 'ivy@example.com', 'age' => 27, 'city' => 'Madrid', 'active' => true]) + ->returning(['id', 'name', 'email']) + ->insert(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertEquals('Ivy', $rows[0]['name']); + $this->assertEquals('ivy@example.com', $rows[0]['email']); + $this->assertArrayHasKey('id', $rows[0]); + $this->assertGreaterThan(0, (int) $rows[0]['id']); // @phpstan-ignore cast.int + } + + public function testUpdateWithWhere(): void + { + $result = (new Builder()) + ->from('users') + ->set(['city' => 'San Francisco']) + ->filter([Query::equal('name', ['Alice'])]) + ->update(); + + $this->executeOnPostgres($result); + + $check = (new Builder()) + ->from('users') + ->select(['city']) + ->filter([Query::equal('name', ['Alice'])]) + ->build(); + + $rows = $this->executeOnPostgres($check); + + $this->assertCount(1, $rows); + $this->assertEquals('San Francisco', $rows[0]['city']); + } + + public function testUpdateWithReturning(): void + { + $result = (new Builder()) + ->from('users') + ->set(['age' => 31]) + ->filter([Query::equal('name', ['Alice'])]) + ->returning(['name', 'age']) + ->update(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals(31, (int) $rows[0]['age']); // @phpstan-ignore cast.int + } + + public function testDeleteWithWhere(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ['Eve'])]) + ->delete(); + + $this->executeOnPostgres($result); + + $check = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ['Eve'])]) + ->build(); + + $rows = $this->executeOnPostgres($check); + + $this->assertCount(0, $rows); + } + + public function testDeleteWithReturning(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ['Charlie'])]) + ->returning(['id', 'name']) + ->delete(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertEquals('Charlie', $rows[0]['name']); + } + + public function testSelectWithGroupByAndHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->count('*', 'order_count') + ->groupBy(['user_id']) + ->having([Query::greaterThan('order_count', 1)]) + ->sortAsc('user_id') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(2, $rows); + $this->assertEquals(1, (int) $rows[0]['user_id']); // @phpstan-ignore cast.int + $this->assertEquals(2, (int) $rows[0]['order_count']); // @phpstan-ignore cast.int + $this->assertEquals(4, (int) $rows[1]['user_id']); // @phpstan-ignore cast.int + $this->assertEquals(2, (int) $rows[1]['order_count']); // @phpstan-ignore cast.int + } + + public function testSelectWithUnion(): void + { + $query1 = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['New York'])]); + + $result = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['London'])]) + ->union($query1) + ->build(); + + $rows = $this->executeOnPostgres($result); + + $names = array_column($rows, 'name'); + sort($names); + + $this->assertCount(4, $rows); + $this->assertEquals(['Alice', 'Bob', 'Charlie', 'Eve'], $names); + } + + public function testUpsertOnConflictDoUpdate(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice Updated', 'email' => 'alice@example.com', 'age' => 31, 'city' => 'Boston', 'active' => true]) + ->onConflict(['email'], ['name', 'age', 'city']) + ->upsert(); + + $this->executeOnPostgres($result); + + $check = (new Builder()) + ->from('users') + ->select(['name', 'age', 'city']) + ->filter([Query::equal('email', ['alice@example.com'])]) + ->build(); + + $rows = $this->executeOnPostgres($check); + + $this->assertCount(1, $rows); + $this->assertEquals('Alice Updated', $rows[0]['name']); + $this->assertEquals(31, (int) $rows[0]['age']); // @phpstan-ignore cast.int + $this->assertEquals('Boston', $rows[0]['city']); + } + + public function testInsertOrIgnoreOnConflictDoNothing(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice Duplicate', 'email' => 'alice@example.com', 'age' => 99, 'city' => 'Nowhere', 'active' => false]) + ->insertOrIgnore(); + + $this->executeOnPostgres($result); + + $check = (new Builder()) + ->from('users') + ->select(['name', 'age']) + ->filter([Query::equal('email', ['alice@example.com'])]) + ->build(); + + $rows = $this->executeOnPostgres($check); + + $this->assertCount(1, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals(30, (int) $rows[0]['age']); // @phpstan-ignore cast.int + } + + public function testSelectWithCte(): void + { + $cteQuery = (new Builder()) + ->from('users') + ->select(['id', 'name', 'city']) + ->filter([Query::equal('active', [true])]); + + $result = (new Builder()) + ->with('active_users', $cteQuery) + ->from('active_users') + ->select(['name', 'city']) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(4, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals('Bob', $rows[1]['name']); + $this->assertEquals('Diana', $rows[2]['name']); + $this->assertEquals('Eve', $rows[3]['name']); + } + + public function testSelectWithWindowFunction(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['user_id', 'product', 'amount']) + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['-amount']) + ->sortAsc('user_id') + ->sortDesc('amount') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertGreaterThan(0, count($rows)); + $this->assertArrayHasKey('rn', $rows[0]); + + $user1Rows = array_filter($rows, fn ($r) => (int) $r['user_id'] === 1); // @phpstan-ignore cast.int + $user1Rows = array_values($user1Rows); + $this->assertEquals(1, (int) $user1Rows[0]['rn']); // @phpstan-ignore cast.int + $this->assertEquals(2, (int) $user1Rows[1]['rn']); // @phpstan-ignore cast.int + } + + public function testSelectWithDistinct(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['product']) + ->distinct() + ->sortAsc('product') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(3, $rows); + $products = array_column($rows, 'product'); + $this->assertEquals(['Gadget', 'Gizmo', 'Widget'], $products); + } + + public function testSelectWithSubqueryInWhere(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->filter([Query::equal('status', ['completed'])]); + + $result = (new Builder()) + ->from('users') + ->select(['name']) + ->filterWhereIn('id', $subquery) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $names = array_column($rows, 'name'); + + $this->assertCount(3, $rows); + $this->assertEquals(['Alice', 'Charlie', 'Eve'], $names); + } + + public function testSelectForUpdate(): void + { + $pdo = $this->connectPostgres(); + $pdo->beginTransaction(); + + try { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('name', ['Alice'])]) + ->forUpdate() + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + } finally { + $pdo->rollBack(); + } + } +} diff --git a/tests/Integration/ClickHouseClient.php b/tests/Integration/ClickHouseClient.php new file mode 100644 index 0000000..6edac4e --- /dev/null +++ b/tests/Integration/ClickHouseClient.php @@ -0,0 +1,104 @@ + $params + * @return list> + */ + public function execute(string $query, array $params = []): array + { + $url = $this->host . '/?database=' . urlencode($this->database); + + $placeholderIndex = 0; + $paramMap = []; + $sql = preg_replace_callback('/\?/', function () use (&$placeholderIndex, $params, &$paramMap, &$url) { + $key = 'param_p' . $placeholderIndex; + $value = $params[$placeholderIndex] ?? null; + $paramMap[$key] = $value; + $placeholderIndex++; + + $type = match (true) { + is_int($value) => 'Int64', + is_float($value) => 'Float64', + is_bool($value) => 'UInt8', + default => 'String', + }; + + $url .= '¶m_' . $key . '=' . urlencode((string) $value); // @phpstan-ignore cast.string + + return '{' . $key . ':' . $type . '}'; + }, $query); + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: text/plain\r\n", + 'content' => $sql . ' FORMAT JSONEachRow', + 'ignore_errors' => true, + 'timeout' => 10, + ], + ]); + + $response = file_get_contents($url, false, $context); + + if ($response === false) { + throw new \RuntimeException('ClickHouse request failed'); + } + + $statusLine = $http_response_header[0] ?? ''; + if (! str_contains($statusLine, '200')) { + throw new \RuntimeException('ClickHouse error: ' . $response); + } + + $trimmed = trim($response); + if ($trimmed === '') { + return []; + } + + $rows = []; + foreach (explode("\n", $trimmed) as $line) { + $decoded = json_decode($line, true); + if (is_array($decoded)) { + /** @var array $decoded */ + $rows[] = $decoded; + } + } + + return $rows; + } + + public function statement(string $sql): void + { + $url = $this->host . '/?database=' . urlencode($this->database); + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: text/plain\r\n", + 'content' => $sql, + 'ignore_errors' => true, + 'timeout' => 10, + ], + ]); + + $response = file_get_contents($url, false, $context); + + if ($response === false) { + throw new \RuntimeException('ClickHouse request failed'); + } + + $statusLine = $http_response_header[0] ?? ''; + if (! str_contains($statusLine, '200')) { + throw new \RuntimeException('ClickHouse error: ' . $response); + } + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000..3bcf80c --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,153 @@ + */ + private array $mysqlCleanup = []; + + /** @var list */ + private array $postgresCleanup = []; + + /** @var list */ + private array $clickhouseCleanup = []; + + protected function connectMysql(): PDO + { + if ($this->mysql === null) { + $this->mysql = new PDO( + 'mysql:host=127.0.0.1;port=13306;dbname=query_test', + 'root', + 'test', + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], + ); + } + + return $this->mysql; + } + + protected function connectPostgres(): PDO + { + if ($this->postgres === null) { + $this->postgres = new PDO( + 'pgsql:host=127.0.0.1;port=15432;dbname=query_test', + 'postgres', + 'test', + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], + ); + } + + return $this->postgres; + } + + protected function connectClickhouse(): ClickHouseClient + { + if ($this->clickhouse === null) { + $this->clickhouse = new ClickHouseClient(); + } + + return $this->clickhouse; + } + + /** + * @return list> + */ + protected function executeOnMysql(BuildResult $result): array + { + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare($result->query); + $stmt->execute($result->bindings); + + /** @var list> */ + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * @return list> + */ + protected function executeOnPostgres(BuildResult $result): array + { + $pdo = $this->connectPostgres(); + $stmt = $pdo->prepare($result->query); + $stmt->execute($result->bindings); + + /** @var list> */ + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * @return list> + */ + protected function executeOnClickhouse(BuildResult $result): array + { + $ch = $this->connectClickhouse(); + + return $ch->execute($result->query, $result->bindings); + } + + protected function mysqlStatement(string $sql): void + { + $this->connectMysql()->prepare($sql)->execute(); + } + + protected function postgresStatement(string $sql): void + { + $this->connectPostgres()->prepare($sql)->execute(); + } + + protected function clickhouseStatement(string $sql): void + { + $this->connectClickhouse()->statement($sql); + } + + protected function trackMysqlTable(string $table): void + { + $this->mysqlCleanup[] = $table; + } + + protected function trackPostgresTable(string $table): void + { + $this->postgresCleanup[] = $table; + } + + protected function trackClickhouseTable(string $table): void + { + $this->clickhouseCleanup[] = $table; + } + + protected function tearDown(): void + { + foreach ($this->mysqlCleanup as $table) { + $stmt = $this->mysql?->prepare("DROP TABLE IF EXISTS `{$table}`"); + if ($stmt !== null && $stmt !== false) { + $stmt->execute(); + } + } + + foreach ($this->postgresCleanup as $table) { + $stmt = $this->postgres?->prepare("DROP TABLE IF EXISTS \"{$table}\" CASCADE"); + if ($stmt !== null && $stmt !== false) { + $stmt->execute(); + } + } + + foreach ($this->clickhouseCleanup as $table) { + $this->clickhouse?->statement("DROP TABLE IF EXISTS `{$table}`"); + } + + $this->mysqlCleanup = []; + $this->postgresCleanup = []; + $this->clickhouseCleanup = []; + } +} diff --git a/tests/Integration/Schema/ClickHouseIntegrationTest.php b/tests/Integration/Schema/ClickHouseIntegrationTest.php new file mode 100644 index 0000000..94d2de8 --- /dev/null +++ b/tests/Integration/Schema/ClickHouseIntegrationTest.php @@ -0,0 +1,153 @@ +schema = new ClickHouse(); + } + + public function testCreateTableWithMergeTreeEngine(): void + { + $table = 'test_mergetree_' . uniqid(); + $this->trackClickhouseTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('name', 100); + $bp->integer('value'); + }); + + $this->clickhouseStatement($result->query); + + $ch = $this->connectClickhouse(); + $rows = $ch->execute( + "SELECT name, type FROM system.columns WHERE database = 'query_test' AND table = '{$table}' ORDER BY position" + ); + + $columnNames = array_column($rows, 'name'); + $this->assertContains('id', $columnNames); + $this->assertContains('name', $columnNames); + $this->assertContains('value', $columnNames); + + $tables = $ch->execute( + "SELECT engine FROM system.tables WHERE database = 'query_test' AND name = '{$table}'" + ); + $this->assertSame('MergeTree', $tables[0]['engine']); + } + + public function testCreateTableWithNullableColumns(): void + { + $table = 'test_nullable_' . uniqid(); + $this->trackClickhouseTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('optional_name', 100)->nullable(); + $bp->integer('optional_count')->nullable(); + }); + + $this->clickhouseStatement($result->query); + + $ch = $this->connectClickhouse(); + $rows = $ch->execute( + "SELECT name, type FROM system.columns WHERE database = 'query_test' AND table = '{$table}'" + ); + + $typeMap = []; + foreach ($rows as $row) { + $name = $row['name']; + $type = $row['type']; + \assert(\is_string($name) && \is_string($type)); + $typeMap[$name] = $type; + } + + $this->assertStringContainsString('Nullable', $typeMap['optional_name']); + $this->assertStringContainsString('Nullable', $typeMap['optional_count']); + $this->assertStringNotContainsString('Nullable', $typeMap['id']); + } + + public function testAlterTableAddColumn(): void + { + $table = 'test_alter_add_' . uniqid(); + $this->trackClickhouseTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + }); + $this->clickhouseStatement($create->query); + + $alter = $this->schema->alter($table, function (Blueprint $bp) { + $bp->addColumn('description', ColumnType::String, 200); + }); + $this->clickhouseStatement($alter->query); + + $ch = $this->connectClickhouse(); + $rows = $ch->execute( + "SELECT name FROM system.columns WHERE database = 'query_test' AND table = '{$table}'" + ); + + $columnNames = array_column($rows, 'name'); + $this->assertContains('description', $columnNames); + } + + public function testDropTable(): void + { + $table = 'test_drop_' . uniqid(); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + }); + $this->clickhouseStatement($create->query); + + $drop = $this->schema->drop($table); + $this->clickhouseStatement($drop->query); + + $ch = $this->connectClickhouse(); + $rows = $ch->execute( + "SELECT count() as cnt FROM system.tables WHERE database = 'query_test' AND name = '{$table}'" + ); + + $this->assertSame('0', (string) $rows[0]['cnt']); // @phpstan-ignore cast.string + } + + public function testCreateTableWithDateTimePrecision(): void + { + $table = 'test_dt64_' . uniqid(); + $this->trackClickhouseTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->datetime('created_at', 3); + $bp->datetime('updated_at', 6); + }); + + $this->clickhouseStatement($result->query); + + $ch = $this->connectClickhouse(); + $rows = $ch->execute( + "SELECT name, type FROM system.columns WHERE database = 'query_test' AND table = '{$table}'" + ); + + $typeMap = []; + foreach ($rows as $row) { + $name = $row['name']; + $type = $row['type']; + \assert(\is_string($name) && \is_string($type)); + $typeMap[$name] = $type; + } + + $this->assertSame('DateTime64(3)', $typeMap['created_at']); + $this->assertSame('DateTime64(6)', $typeMap['updated_at']); + } +} diff --git a/tests/Integration/Schema/MySQLIntegrationTest.php b/tests/Integration/Schema/MySQLIntegrationTest.php new file mode 100644 index 0000000..09c9034 --- /dev/null +++ b/tests/Integration/Schema/MySQLIntegrationTest.php @@ -0,0 +1,315 @@ +schema = new MySQL(); + } + + public function testCreateTableWithBasicColumns(): void + { + $table = 'test_basic_' . uniqid(); + $this->trackMysqlTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('age'); + $bp->string('name', 100); + $bp->boolean('active'); + }); + + $this->mysqlStatement($result->query); + + $columns = $this->fetchMysqlColumns($table); + $columnNames = array_column($columns, 'COLUMN_NAME'); + + $this->assertContains('age', $columnNames); + $this->assertContains('name', $columnNames); + $this->assertContains('active', $columnNames); + + $nameCol = $this->findColumn($columns, 'name'); + $this->assertSame('varchar', $nameCol['DATA_TYPE']); + $this->assertSame('100', (string) $nameCol['CHARACTER_MAXIMUM_LENGTH']); // @phpstan-ignore cast.string + } + + public function testCreateTableWithPrimaryKeyAndUnique(): void + { + $table = 'test_pk_uniq_' . uniqid(); + $this->trackMysqlTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('email', 255)->unique(); + }); + + $this->mysqlStatement($result->query); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT COLUMN_NAME, COLUMN_KEY FROM information_schema.COLUMNS " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ?" + ); + \assert($stmt !== false); + $stmt->execute([$table]); + /** @var list> $rows */ + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + $idRow = $this->findColumn($rows, 'id'); + $this->assertSame('PRI', $idRow['COLUMN_KEY']); + + $emailRow = $this->findColumn($rows, 'email'); + $this->assertSame('UNI', $emailRow['COLUMN_KEY']); + } + + public function testCreateTableWithAutoIncrement(): void + { + $table = 'test_autoinc_' . uniqid(); + $this->trackMysqlTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->id(); + $bp->string('label', 50); + }); + + $this->mysqlStatement($result->query); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT EXTRA FROM information_schema.COLUMNS " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ? AND COLUMN_NAME = 'id'" + ); + \assert($stmt !== false); + $stmt->execute([$table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertStringContainsString('auto_increment', (string) $row['EXTRA']); // @phpstan-ignore cast.string + } + + public function testAlterTableAddColumn(): void + { + $table = 'test_alter_add_' . uniqid(); + $this->trackMysqlTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + }); + $this->mysqlStatement($create->query); + + $alter = $this->schema->alter($table, function (Blueprint $bp) { + $bp->addColumn('description', ColumnType::Text); + }); + $this->mysqlStatement($alter->query); + + $columns = $this->fetchMysqlColumns($table); + $columnNames = array_column($columns, 'COLUMN_NAME'); + + $this->assertContains('description', $columnNames); + } + + public function testAlterTableDropColumn(): void + { + $table = 'test_alter_drop_' . uniqid(); + $this->trackMysqlTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('temp', 100); + }); + $this->mysqlStatement($create->query); + + $alter = $this->schema->alter($table, function (Blueprint $bp) { + $bp->dropColumn('temp'); + }); + $this->mysqlStatement($alter->query); + + $columns = $this->fetchMysqlColumns($table); + $columnNames = array_column($columns, 'COLUMN_NAME'); + + $this->assertNotContains('temp', $columnNames); + } + + public function testAlterTableAddIndex(): void + { + $table = 'test_alter_idx_' . uniqid(); + $this->trackMysqlTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('email', 255); + }); + $this->mysqlStatement($create->query); + + $alter = $this->schema->alter($table, function (Blueprint $bp) { + $bp->addIndex('idx_email', ['email']); + }); + $this->mysqlStatement($alter->query); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT INDEX_NAME FROM information_schema.STATISTICS " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ? AND INDEX_NAME = 'idx_email'" + ); + \assert($stmt !== false); + $stmt->execute([$table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + $this->assertNotFalse($row); + \assert(\is_array($row)); + $this->assertSame('idx_email', $row['INDEX_NAME']); + } + + public function testDropTable(): void + { + $table = 'test_drop_' . uniqid(); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + }); + $this->mysqlStatement($create->query); + + $drop = $this->schema->drop($table); + $this->mysqlStatement($drop->query); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT COUNT(*) as cnt FROM information_schema.TABLES " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ?" + ); + \assert($stmt !== false); + $stmt->execute([$table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertSame('0', (string) $row['cnt']); // @phpstan-ignore cast.string + } + + public function testCreateTableWithForeignKey(): void + { + $parentTable = 'test_fk_parent_' . uniqid(); + $childTable = 'test_fk_child_' . uniqid(); + $this->trackMysqlTable($childTable); + $this->trackMysqlTable($parentTable); + + $createParent = $this->schema->create($parentTable, function (Blueprint $bp) { + $bp->id(); + }); + $this->mysqlStatement($createParent->query); + + $createChild = $this->schema->create($childTable, function (Blueprint $bp) use ($parentTable) { + $bp->id(); + $bp->bigInteger('parent_id')->unsigned(); + $bp->foreignKey('parent_id') + ->references('id') + ->on($parentTable) + ->onDelete(ForeignKeyAction::Cascade); + }); + $this->mysqlStatement($createChild->query); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT REFERENCED_TABLE_NAME FROM information_schema.KEY_COLUMN_USAGE " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL" + ); + \assert($stmt !== false); + $stmt->execute([$childTable]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + $this->assertNotFalse($row); + \assert(\is_array($row)); + $this->assertSame($parentTable, $row['REFERENCED_TABLE_NAME']); + } + + public function testCreateTableWithNullableAndDefault(): void + { + $table = 'test_null_def_' . uniqid(); + $this->trackMysqlTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('nickname', 100)->nullable()->default('anonymous'); + $bp->integer('score')->default(0); + }); + + $this->mysqlStatement($result->query); + + $columns = $this->fetchMysqlColumns($table); + + $nicknameCol = $this->findColumn($columns, 'nickname'); + $this->assertSame('YES', $nicknameCol['IS_NULLABLE']); + $this->assertSame('anonymous', $nicknameCol['COLUMN_DEFAULT']); + + $scoreCol = $this->findColumn($columns, 'score'); + $this->assertSame('0', (string) $scoreCol['COLUMN_DEFAULT']); // @phpstan-ignore cast.string + } + + public function testTruncateTable(): void + { + $table = 'test_truncate_' . uniqid(); + $this->trackMysqlTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('name', 50); + }); + $this->mysqlStatement($create->query); + + $pdo = $this->connectMysql(); + $insertStmt = $pdo->prepare("INSERT INTO `{$table}` (`id`, `name`) VALUES (1, 'a'), (2, 'b')"); + \assert($insertStmt !== false); + $insertStmt->execute(); + + $truncate = $this->schema->truncate($table); + $this->mysqlStatement($truncate->query); + + $stmt = $pdo->prepare("SELECT COUNT(*) as cnt FROM `{$table}`"); + \assert($stmt !== false); + $stmt->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertSame('0', (string) $row['cnt']); // @phpstan-ignore cast.string + } + + /** + * @return list> + */ + private function fetchMysqlColumns(string $table): array + { + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT * FROM information_schema.COLUMNS " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ?" + ); + $stmt->execute([$table]); + + /** @var list> */ + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * @param list> $columns + * @return array + */ + private function findColumn(array $columns, string $name): array + { + foreach ($columns as $col) { + if ($col['COLUMN_NAME'] === $name) { + return $col; + } + } + + $this->fail("Column '{$name}' not found"); + } +} diff --git a/tests/Integration/Schema/PostgreSQLIntegrationTest.php b/tests/Integration/Schema/PostgreSQLIntegrationTest.php new file mode 100644 index 0000000..b65dbe6 --- /dev/null +++ b/tests/Integration/Schema/PostgreSQLIntegrationTest.php @@ -0,0 +1,284 @@ +schema = new PostgreSQL(); + } + + public function testCreateTableWithBasicColumns(): void + { + $table = 'test_basic_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('age'); + $bp->string('name', 100); + $bp->float('score'); + }); + + $this->postgresStatement($result->query); + + $columns = $this->fetchPostgresColumns($table); + $columnNames = array_column($columns, 'column_name'); + + $this->assertContains('age', $columnNames); + $this->assertContains('name', $columnNames); + $this->assertContains('score', $columnNames); + + $nameCol = $this->findColumn($columns, 'name'); + $this->assertSame('character varying', $nameCol['data_type']); + $this->assertSame('100', (string) $nameCol['character_maximum_length']); // @phpstan-ignore cast.string + } + + public function testCreateTableWithIdentityColumn(): void + { + $table = 'test_identity_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->id(); + $bp->string('label', 50); + }); + + $this->postgresStatement($result->query); + + $pdo = $this->connectPostgres(); + $stmt = $pdo->prepare( + "SELECT is_identity, identity_generation FROM information_schema.columns " + . "WHERE table_catalog = 'query_test' AND table_name = :table AND column_name = 'id'" + ); + $stmt->execute(['table' => $table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertSame('YES', $row['is_identity']); + $this->assertSame('BY DEFAULT', $row['identity_generation']); + } + + public function testCreateTableWithJsonbColumn(): void + { + $table = 'test_jsonb_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->json('metadata'); + }); + + $this->postgresStatement($result->query); + + $columns = $this->fetchPostgresColumns($table); + $metaCol = $this->findColumn($columns, 'metadata'); + + $this->assertSame('jsonb', $metaCol['data_type']); + } + + public function testAlterTableAddColumn(): void + { + $table = 'test_alter_add_' . uniqid(); + $this->trackPostgresTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + }); + $this->postgresStatement($create->query); + + $alter = $this->schema->alter($table, function (Blueprint $bp) { + $bp->addColumn('description', ColumnType::Text); + }); + $this->postgresStatement($alter->query); + + $columns = $this->fetchPostgresColumns($table); + $columnNames = array_column($columns, 'column_name'); + + $this->assertContains('description', $columnNames); + } + + public function testAlterTableDropColumn(): void + { + $table = 'test_alter_drop_' . uniqid(); + $this->trackPostgresTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('temp', 100); + }); + $this->postgresStatement($create->query); + + $alter = $this->schema->alter($table, function (Blueprint $bp) { + $bp->dropColumn('temp'); + }); + $this->postgresStatement($alter->query); + + $columns = $this->fetchPostgresColumns($table); + $columnNames = array_column($columns, 'column_name'); + + $this->assertNotContains('temp', $columnNames); + } + + public function testDropTable(): void + { + $table = 'test_drop_' . uniqid(); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + }); + $this->postgresStatement($create->query); + + $drop = $this->schema->drop($table); + $this->postgresStatement($drop->query); + + $pdo = $this->connectPostgres(); + $stmt = $pdo->prepare( + "SELECT COUNT(*) as cnt FROM information_schema.tables " + . "WHERE table_catalog = 'query_test' AND table_schema = 'public' AND table_name = :table" + ); + $stmt->execute(['table' => $table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertSame('0', (string) $row['cnt']); // @phpstan-ignore cast.string + } + + public function testCreateTableWithBooleanAndText(): void + { + $table = 'test_bool_text_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->boolean('is_active'); + $bp->text('bio'); + }); + + $this->postgresStatement($result->query); + + $columns = $this->fetchPostgresColumns($table); + + $boolCol = $this->findColumn($columns, 'is_active'); + $this->assertSame('boolean', $boolCol['data_type']); + + $textCol = $this->findColumn($columns, 'bio'); + $this->assertSame('text', $textCol['data_type']); + } + + public function testCreateTableWithUniqueConstraint(): void + { + $table = 'test_unique_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('email', 255)->unique(); + }); + + $this->postgresStatement($result->query); + + $pdo = $this->connectPostgres(); + $stmt = $pdo->prepare( + "SELECT tc.constraint_type FROM information_schema.table_constraints tc " + . "JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name " + . "WHERE tc.table_name = :table AND ccu.column_name = 'email' AND tc.constraint_type = 'UNIQUE'" + ); + $stmt->execute(['table' => $table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + $this->assertNotFalse($row); + \assert(\is_array($row)); + $this->assertSame('UNIQUE', $row['constraint_type']); + } + + public function testCreateTableWithNullableAndDefault(): void + { + $table = 'test_null_def_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('nickname', 100)->nullable()->default('anonymous'); + $bp->integer('score')->default(0); + }); + + $this->postgresStatement($result->query); + + $columns = $this->fetchPostgresColumns($table); + + $nicknameCol = $this->findColumn($columns, 'nickname'); + $this->assertSame('YES', $nicknameCol['is_nullable']); + $this->assertStringContainsString('anonymous', (string) $nicknameCol['column_default']); // @phpstan-ignore cast.string + + $scoreCol = $this->findColumn($columns, 'score'); + $this->assertSame('0', (string) $scoreCol['column_default']); // @phpstan-ignore cast.string + } + + public function testTruncateTable(): void + { + $table = 'test_truncate_' . uniqid(); + $this->trackPostgresTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('name', 50); + }); + $this->postgresStatement($create->query); + + $pdo = $this->connectPostgres(); + $insertStmt = $pdo->prepare("INSERT INTO \"{$table}\" (\"id\", \"name\") VALUES (1, 'a'), (2, 'b')"); + \assert($insertStmt !== false); + $insertStmt->execute(); + + $truncate = $this->schema->truncate($table); + $this->postgresStatement($truncate->query); + + $stmt = $pdo->prepare("SELECT COUNT(*) as cnt FROM \"{$table}\""); + \assert($stmt !== false); + $stmt->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertSame('0', (string) $row['cnt']); // @phpstan-ignore cast.string + } + + /** + * @return list> + */ + private function fetchPostgresColumns(string $table): array + { + $pdo = $this->connectPostgres(); + $stmt = $pdo->prepare( + "SELECT * FROM information_schema.columns " + . "WHERE table_catalog = 'query_test' AND table_schema = 'public' AND table_name = :table" + ); + $stmt->execute(['table' => $table]); + + /** @var list> */ + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * @param list> $columns + * @return array + */ + private function findColumn(array $columns, string $name): array + { + foreach ($columns as $col) { + if ($col['column_name'] === $name) { + return $col; + } + } + + $this->fail("Column '{$name}' not found"); + } +} From 1c747485ba4afe709f70da43fdf39117c6b7ac59 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 11 Mar 2026 00:06:37 +1300 Subject: [PATCH 28/29] (chore): Add CLAUDE.md project coding rules --- .gitignore | 1 + CLAUDE.md | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index f77c093..30a6d9b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ composer.phar /vendor/ .idea +coverage diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c183c8d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,4 @@ +# Project Rules + +- Never add decorative section-style comment headers (e.g. `// ==================`, `// ----------`, `// ~~~~` or similar). Use plain single-line comments only when necessary. +- Always use imports (`use` statements) instead of fully qualified class names in test files and source code. From b563fb83ab27d54b60530155c5077360e340d2e3 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 11 Mar 2026 00:19:36 +1300 Subject: [PATCH 29/29] (test): Add advanced exact query assertions for all dialects --- tests/Query/Builder/ClickHouseTest.php | 458 ++++++++++++++++++++++ tests/Query/Builder/MySQLTest.php | 514 +++++++++++++++++++++++++ tests/Query/Builder/PostgreSQLTest.php | 443 +++++++++++++++++++++ 3 files changed, 1415 insertions(+) diff --git a/tests/Query/Builder/ClickHouseTest.php b/tests/Query/Builder/ClickHouseTest.php index 1885d83..9f5aa2a 100644 --- a/tests/Query/Builder/ClickHouseTest.php +++ b/tests/Query/Builder/ClickHouseTest.php @@ -7885,4 +7885,462 @@ public function testExactPrewhereWithJoin(): void $this->assertEquals(['purchase', 21, 50], $result->bindings); $this->assertBindingCount($result); } + + public function testExactAdvancedWhenTrue(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedWhenFalse(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->when(false, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users`', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedExplain(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]) + ->explain(); + + $this->assertSame( + 'EXPLAIN SELECT `id`, `name` FROM `events` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedCursorAfterWithFilters(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->filter([Query::greaterThan('age', 18)]) + ->cursorAfter('abc123') + ->sortDesc('created_at') + ->limit(25) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `events` WHERE `age` > ? AND `_cursor` > ? ORDER BY `created_at` DESC LIMIT ?', + $result->query + ); + $this->assertEquals([18, 'abc123', 25], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedCursorBefore(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->cursorBefore('xyz789') + ->sortAsc('id') + ->limit(10) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `events` WHERE `_cursor` < ? ORDER BY `id` ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['xyz789', 10], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedMultipleCtes(): void + { + $cteA = (new Builder()) + ->from('orders') + ->select(['customer_id']) + ->filter([Query::greaterThan('total', 100)]); + + $cteB = (new Builder()) + ->from('customers') + ->select(['id', 'name']) + ->filter([Query::equal('tier', ['gold'])]); + + $result = (new Builder()) + ->with('a', $cteA) + ->with('b', $cteB) + ->from('a') + ->select(['customer_id']) + ->build(); + + $this->assertSame( + 'WITH `a` AS (SELECT `customer_id` FROM `orders` WHERE `total` > ?), `b` AS (SELECT `id`, `name` FROM `customers` WHERE `tier` IN (?)) SELECT `customer_id` FROM `a`', + $result->query + ); + $this->assertEquals([100, 'gold'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedMultipleWindowFunctions(): void + { + $result = (new Builder()) + ->from('sales') + ->select(['employee_id', 'amount']) + ->selectWindow('ROW_NUMBER()', 'rn', ['department_id'], ['-amount']) + ->selectWindow('SUM(`amount`)', 'running_total', ['department_id'], ['created_at']) + ->build(); + + $this->assertSame( + 'SELECT `employee_id`, `amount`, ROW_NUMBER() OVER (PARTITION BY `department_id` ORDER BY `amount` DESC) AS `rn`, SUM(`amount`) OVER (PARTITION BY `department_id` ORDER BY `created_at` ASC) AS `running_total` FROM `sales`', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedUnionWithOrderAndLimit(): void + { + $archive = (new Builder()) + ->from('events_archive') + ->select(['id', 'name']); + + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->sortAsc('id') + ->limit(50) + ->union($archive) + ->build(); + + $this->assertSame( + '(SELECT `id`, `name` FROM `events` ORDER BY `id` ASC LIMIT ?) UNION (SELECT `id`, `name` FROM `events_archive`)', + $result->query + ); + $this->assertEquals([50], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedDeeplyNestedConditions(): void + { + $result = (new Builder()) + ->from('products') + ->select(['id', 'name']) + ->filter([ + Query::and([ + Query::or([ + Query::and([ + Query::equal('brand', ['acme']), + Query::greaterThan('price', 50), + ]), + Query::and([ + Query::equal('brand', ['globex']), + Query::lessThan('price', 20), + ]), + ]), + Query::equal('in_stock', [true]), + ]), + ]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `products` WHERE (((`brand` IN (?) AND `price` > ?) OR (`brand` IN (?) AND `price` < ?)) AND `in_stock` IN (?))', + $result->query + ); + $this->assertEquals(['acme', 50, 'globex', 20, true], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedStartsWith(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::startsWith('name', 'John')]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE startsWith(`name`, ?)', + $result->query + ); + $this->assertEquals(['John'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedEndsWith(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'email']) + ->filter([Query::endsWith('email', '@example.com')]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `email` FROM `users` WHERE endsWith(`email`, ?)', + $result->query + ); + $this->assertEquals(['@example.com'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedContainsSingle(): void + { + $result = (new Builder()) + ->from('articles') + ->select(['id', 'title']) + ->filter([Query::contains('title', ['php'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `title` FROM `articles` WHERE position(`title`, ?) > 0', + $result->query + ); + $this->assertEquals(['php'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedContainsMultiple(): void + { + $result = (new Builder()) + ->from('articles') + ->select(['id', 'title']) + ->filter([Query::contains('title', ['php', 'laravel'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `title` FROM `articles` WHERE (position(`title`, ?) > 0 OR position(`title`, ?) > 0)', + $result->query + ); + $this->assertEquals(['php', 'laravel'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedContainsAll(): void + { + $result = (new Builder()) + ->from('articles') + ->select(['id', 'title']) + ->filter([Query::containsAll('title', ['php', 'laravel'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `title` FROM `articles` WHERE (position(`title`, ?) > 0 AND position(`title`, ?) > 0)', + $result->query + ); + $this->assertEquals(['php', 'laravel'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedNotContainsSingle(): void + { + $result = (new Builder()) + ->from('articles') + ->select(['id', 'title']) + ->filter([Query::notContains('title', ['spam'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `title` FROM `articles` WHERE position(`title`, ?) = 0', + $result->query + ); + $this->assertEquals(['spam'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedNotContainsMultiple(): void + { + $result = (new Builder()) + ->from('articles') + ->select(['id', 'title']) + ->filter([Query::notContains('title', ['spam', 'junk'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `title` FROM `articles` WHERE (position(`title`, ?) = 0 AND position(`title`, ?) = 0)', + $result->query + ); + $this->assertEquals(['spam', 'junk'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedRegex(): void + { + $result = (new Builder()) + ->from('logs') + ->select(['id', 'message']) + ->filter([Query::regex('message', '^ERROR.*timeout$')]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `message` FROM `logs` WHERE match(`message`, ?)', + $result->query + ); + $this->assertEquals(['^ERROR.*timeout$'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedPrewhereMultipleConditions(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->prewhere([ + Query::equal('event_type', ['click']), + Query::greaterThan('timestamp', 1000000), + ]) + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `events` PREWHERE `event_type` IN (?) AND `timestamp` > ? WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['click', 1000000, 'active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedFinalWithFiltersAndOrder(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->final() + ->filter([Query::equal('status', ['active'])]) + ->sortDesc('created_at') + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `events` FINAL WHERE `status` IN (?) ORDER BY `created_at` DESC', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSampleWithPrewhereAndWhere(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->sample(0.1) + ->prewhere([Query::equal('event_type', ['purchase'])]) + ->filter([Query::greaterThan('amount', 50)]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `events` SAMPLE 0.1 PREWHERE `event_type` IN (?) WHERE `amount` > ?', + $result->query + ); + $this->assertEquals(['purchase', 50], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSettingsMultiple(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->settings([ + 'max_threads' => '4', + 'max_memory_usage' => '10000000', + ]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `events` SETTINGS max_threads=4, max_memory_usage=10000000', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedAlterTableUpdateWithSetRaw(): void + { + $result = (new Builder()) + ->from('events') + ->setRaw('views', '`views` + 1') + ->filter([Query::equal('id', [42])]) + ->update(); + + $this->assertSame( + 'ALTER TABLE `events` UPDATE `views` = `views` + 1 WHERE `id` IN (?)', + $result->query + ); + $this->assertEquals([42], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedAlterTableDeleteWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('events') + ->filter([ + Query::equal('status', ['deleted']), + Query::lessThan('created_at', '2023-01-01'), + ]) + ->delete(); + + $this->assertSame( + 'ALTER TABLE `events` DELETE WHERE `status` IN (?) AND `created_at` < ?', + $result->query + ); + $this->assertEquals(['deleted', '2023-01-01'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedEmptyInClause(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('status', [])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE 1 = 0', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedResetClearsPrewhereAndFinal(): void + { + $builder = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->prewhere([Query::equal('event_type', ['click'])]) + ->final() + ->filter([Query::equal('status', ['active'])]); + + $builder->reset(); + + $result = $builder + ->from('users') + ->select(['id', 'email']) + ->build(); + + $this->assertSame( + 'SELECT `id`, `email` FROM `users`', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } } diff --git a/tests/Query/Builder/MySQLTest.php b/tests/Query/Builder/MySQLTest.php index 3436d0d..c051d80 100644 --- a/tests/Query/Builder/MySQLTest.php +++ b/tests/Query/Builder/MySQLTest.php @@ -34,6 +34,8 @@ use Utopia\Query\Hook\Attribute; use Utopia\Query\Hook\Attribute\Map as AttributeMap; use Utopia\Query\Hook\Filter; +use Utopia\Query\Hook\Filter\Permission; +use Utopia\Query\Hook\Filter\Tenant; use Utopia\Query\Method; use Utopia\Query\Query; @@ -10828,4 +10830,516 @@ public function testExactSelectSubquery(): void ); $this->assertEquals([], $result->bindings); } + + public function testExactAdvancedWhenTrue(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->when(true, function (Builder $b) { + $b->filter([Query::equal('status', ['active'])]); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + } + + public function testExactAdvancedWhenFalse(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->when(false, function (Builder $b) { + $b->filter([Query::equal('status', ['active'])]); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedWhenSequence(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->when(true, function (Builder $b) { + $b->filter([Query::equal('status', ['active'])]); + }) + ->when(false, function (Builder $b) { + $b->filter([Query::equal('role', ['admin'])]); + }) + ->when(true, function (Builder $b) { + $b->filter([Query::greaterThan('age', 18)]); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE `status` IN (?) AND `age` > ?', + $result->query + ); + $this->assertEquals(['active', 18], $result->bindings); + } + + public function testExactAdvancedExplain(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]) + ->explain(); + $this->assertBindingCount($result); + + $this->assertSame( + 'EXPLAIN SELECT `id`, `name` FROM `users` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + } + + public function testExactAdvancedExplainAnalyze(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]) + ->explain(true); + $this->assertBindingCount($result); + + $this->assertSame( + 'EXPLAIN ANALYZE SELECT `id`, `name` FROM `users` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + } + + public function testExactAdvancedCursorAfter(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->sortAsc('name') + ->cursorAfter('abc123') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE `_cursor` > ? ORDER BY `name` ASC', + $result->query + ); + $this->assertEquals(['abc123'], $result->bindings); + } + + public function testExactAdvancedCursorBefore(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->sortDesc('name') + ->cursorBefore('xyz789') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE `_cursor` < ? ORDER BY `name` DESC', + $result->query + ); + $this->assertEquals(['xyz789'], $result->bindings); + } + + public function testExactAdvancedTransactionBegin(): void + { + $result = (new Builder())->begin(); + $this->assertBindingCount($result); + + $this->assertSame('BEGIN', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedTransactionCommit(): void + { + $result = (new Builder())->commit(); + $this->assertBindingCount($result); + + $this->assertSame('COMMIT', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedTransactionRollback(): void + { + $result = (new Builder())->rollback(); + $this->assertBindingCount($result); + + $this->assertSame('ROLLBACK', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedSavepoint(): void + { + $result = (new Builder())->savepoint('sp1'); + $this->assertBindingCount($result); + + $this->assertSame('SAVEPOINT `sp1`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedReleaseSavepoint(): void + { + $result = (new Builder())->releaseSavepoint('sp1'); + $this->assertBindingCount($result); + + $this->assertSame('RELEASE SAVEPOINT `sp1`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedRollbackToSavepoint(): void + { + $result = (new Builder())->rollbackToSavepoint('sp1'); + $this->assertBindingCount($result); + + $this->assertSame('ROLLBACK TO SAVEPOINT `sp1`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedMultipleCtes(): void + { + $cteA = (new Builder()) + ->from('orders') + ->select(['user_id', 'total']) + ->filter([Query::equal('status', ['paid'])]); + + $cteB = (new Builder()) + ->from('returns') + ->select(['user_id', 'amount']) + ->filter([Query::equal('status', ['approved'])]); + + $result = (new Builder()) + ->with('a', $cteA) + ->with('b', $cteB) + ->from('a') + ->select(['user_id']) + ->sum('total', 'total_paid') + ->groupBy(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'WITH `a` AS (SELECT `user_id`, `total` FROM `orders` WHERE `status` IN (?)), `b` AS (SELECT `user_id`, `amount` FROM `returns` WHERE `status` IN (?)) SELECT SUM(`total`) AS `total_paid`, `user_id` FROM `a` GROUP BY `user_id`', + $result->query + ); + $this->assertEquals(['paid', 'approved'], $result->bindings); + } + + public function testExactAdvancedMultipleWindowFunctions(): void + { + $result = (new Builder()) + ->from('employees') + ->select(['id', 'department', 'salary']) + ->selectWindow('ROW_NUMBER()', 'row_num', ['department'], ['salary']) + ->selectWindow('RANK()', 'salary_rank', ['department'], ['-salary']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `department`, `salary`, ROW_NUMBER() OVER (PARTITION BY `department` ORDER BY `salary` ASC) AS `row_num`, RANK() OVER (PARTITION BY `department` ORDER BY `salary` DESC) AS `salary_rank` FROM `employees`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedUnionWithOrderAndLimit(): void + { + $archive = (new Builder()) + ->from('orders_archive') + ->select(['id', 'total', 'created_at']); + + $result = (new Builder()) + ->from('orders') + ->select(['id', 'total', 'created_at']) + ->sortDesc('created_at') + ->limit(10) + ->unionAll($archive) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + '(SELECT `id`, `total`, `created_at` FROM `orders` ORDER BY `created_at` DESC LIMIT ?) UNION ALL (SELECT `id`, `total`, `created_at` FROM `orders_archive`)', + $result->query + ); + $this->assertEquals([10], $result->bindings); + } + + public function testExactAdvancedDeeplyNestedConditions(): void + { + $result = (new Builder()) + ->from('products') + ->select(['id', 'name']) + ->filter([ + Query::and([ + Query::equal('category', ['electronics']), + Query::or([ + Query::greaterThan('price', 100), + Query::and([ + Query::equal('brand', ['acme']), + Query::lessThan('stock', 50), + ]), + ]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `products` WHERE (`category` IN (?) AND (`price` > ? OR (`brand` IN (?) AND `stock` < ?)))', + $result->query + ); + $this->assertEquals(['electronics', 100, 'acme', 50], $result->bindings); + } + + public function testExactAdvancedForUpdateNoWait(): void + { + $result = (new Builder()) + ->from('accounts') + ->select(['id', 'balance']) + ->filter([Query::equal('id', [1])]) + ->forUpdateNoWait() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `balance` FROM `accounts` WHERE `id` IN (?) FOR UPDATE NOWAIT', + $result->query + ); + $this->assertEquals([1], $result->bindings); + } + + public function testExactAdvancedForShareNoWait(): void + { + $result = (new Builder()) + ->from('accounts') + ->select(['id', 'balance']) + ->filter([Query::equal('id', [1])]) + ->forShareNoWait() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `balance` FROM `accounts` WHERE `id` IN (?) FOR SHARE NOWAIT', + $result->query + ); + $this->assertEquals([1], $result->bindings); + } + + public function testExactAdvancedConflictSetRaw(): void + { + $result = (new Builder()) + ->from('counters') + ->set(['id' => 1, 'count' => 1, 'updated_at' => '2024-01-01']) + ->onConflict(['id'], ['count', 'updated_at']) + ->conflictSetRaw('count', '`count` + VALUES(`count`)') + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO `counters` (`id`, `count`, `updated_at`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `count` = `count` + VALUES(`count`), `updated_at` = VALUES(`updated_at`)', + $result->query + ); + $this->assertEquals([1, 1, '2024-01-01'], $result->bindings); + } + + public function testExactAdvancedSetRawWithBindings(): void + { + $result = (new Builder()) + ->from('products') + ->setRaw('price', '`price` * ?', [1.1]) + ->filter([Query::equal('category', ['electronics'])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame( + 'UPDATE `products` SET `price` = `price` * ? WHERE `category` IN (?)', + $result->query + ); + $this->assertEquals([1.1, 'electronics'], $result->bindings); + } + + public function testExactAdvancedSetCaseInUpdate(): void + { + $case = (new CaseBuilder()) + ->when('`category` = ?', '`price` * ?', ['electronics'], [1.2]) + ->when('`category` = ?', '`price` * ?', ['clothing'], [0.8]) + ->elseResult('`price`') + ->build(); + + $result = (new Builder()) + ->from('products') + ->setCase('price', $case) + ->filter([Query::greaterThan('stock', 0)]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame( + 'UPDATE `products` SET `price` = CASE WHEN `category` = ? THEN `price` * ? WHEN `category` = ? THEN `price` * ? ELSE `price` END WHERE `stock` > ?', + $result->query + ); + $this->assertEquals(['electronics', 1.2, 'clothing', 0.8, 0], $result->bindings); + } + + public function testExactAdvancedEmptyFilterArray(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedEmptyInClause(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('id', [])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE 1 = 0', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedEmptyAndGroup(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::and([])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE 1 = 1', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedEmptyOrGroup(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::or([])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE 1 = 0', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedSelectRawWithGroupByRawAndHavingRaw(): void + { + $result = (new Builder()) + ->from('orders') + ->selectRaw('DATE(`created_at`) AS `order_date`') + ->selectRaw('SUM(`total`) AS `daily_total`') + ->groupByRaw('DATE(`created_at`)') + ->havingRaw('SUM(`total`) > ?', [1000]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT DATE(`created_at`) AS `order_date`, SUM(`total`) AS `daily_total` FROM `orders` GROUP BY DATE(`created_at`) HAVING SUM(`total`) > ?', + $result->query + ); + $this->assertEquals([1000], $result->bindings); + } + + public function testExactAdvancedMultipleHooks(): void + { + $result = (new Builder()) + ->from('documents') + ->select(['id', 'title']) + ->filter([Query::equal('status', ['published'])]) + ->addHook(new Tenant(['tenant_a', 'tenant_b'])) + ->addHook(new Permission( + ['role:member', 'role:admin'], + fn (string $table) => $table . '_permissions', + )) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `title` FROM `documents` WHERE `status` IN (?) AND tenant_id IN (?, ?) AND id IN (SELECT DISTINCT document_id FROM documents_permissions WHERE role IN (?, ?) AND type = ?)', + $result->query + ); + $this->assertEquals(['published', 'tenant_a', 'tenant_b', 'role:member', 'role:admin', 'read'], $result->bindings); + } + + public function testExactAdvancedAttributeMapHook(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'display_name', 'email_address']) + ->filter([Query::equal('display_name', ['Alice'])]) + ->addHook(new AttributeMap([ + 'display_name' => 'full_name', + 'email_address' => 'email', + ])) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `full_name`, `email` FROM `users` WHERE `full_name` IN (?)', + $result->query + ); + $this->assertEquals(['Alice'], $result->bindings); + } + + public function testExactAdvancedResetClearsState(): void + { + $builder = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]); + + $builder->build(); + + $builder->reset(); + + $result = $builder + ->from('orders') + ->select(['id', 'total']) + ->filter([Query::greaterThan('total', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `total` FROM `orders` WHERE `total` > ?', + $result->query + ); + $this->assertEquals([100], $result->bindings); + } } diff --git a/tests/Query/Builder/PostgreSQLTest.php b/tests/Query/Builder/PostgreSQLTest.php index b1f7306..1b2449b 100644 --- a/tests/Query/Builder/PostgreSQLTest.php +++ b/tests/Query/Builder/PostgreSQLTest.php @@ -2954,4 +2954,447 @@ public function testExactDistinctWithOffset(): void $this->assertEquals([20, 10], $result->bindings); $this->assertBindingCount($result); } + + public function testExactAdvancedWhenTrue(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->when(true, function (Builder $b) { + $b->filter([Query::equal('status', ['active'])]); + }) + ->build(); + + $this->assertSame( + 'SELECT "id", "name" FROM "users" WHERE "status" IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedWhenFalse(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->when(false, function (Builder $b) { + $b->filter([Query::equal('status', ['active'])]); + }) + ->build(); + + $this->assertSame( + 'SELECT "id", "name" FROM "users"', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedExplain(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]) + ->explain(); + + $this->assertSame( + 'EXPLAIN SELECT "id", "name" FROM "users" WHERE "status" IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedExplainAnalyze(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::greaterThan('age', 18)]) + ->explain(true); + + $this->assertSame( + 'EXPLAIN ANALYZE SELECT "id", "name" FROM "users" WHERE "age" > ?', + $result->query + ); + $this->assertEquals([18], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedCursorAfterWithFilters(): void + { + $result = (new Builder()) + ->from('posts') + ->select(['id', 'title']) + ->filter([Query::equal('status', ['published'])]) + ->cursorAfter('abc123') + ->limit(10) + ->build(); + + $this->assertSame( + 'SELECT "id", "title" FROM "posts" WHERE "status" IN (?) AND "_cursor" > ? LIMIT ?', + $result->query + ); + $this->assertEquals(['published', 'abc123', 10], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedMultipleCtes(): void + { + $cteA = (new Builder()) + ->from('orders') + ->select(['customer_id']) + ->filter([Query::greaterThan('total', 100)]); + + $cteB = (new Builder()) + ->from('customers') + ->select(['id', 'name']) + ->filter([Query::equal('active', [true])]); + + $result = (new Builder()) + ->with('a', $cteA) + ->with('b', $cteB) + ->from('a') + ->select(['customer_id']) + ->join('b', 'a.customer_id', 'b.id') + ->build(); + + $this->assertSame( + 'WITH "a" AS (SELECT "customer_id" FROM "orders" WHERE "total" > ?), "b" AS (SELECT "id", "name" FROM "customers" WHERE "active" IN (?)) SELECT "customer_id" FROM "a" JOIN "b" ON "a"."customer_id" = "b"."id"', + $result->query + ); + $this->assertEquals([100, true], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedMultipleWindowFunctions(): void + { + $result = (new Builder()) + ->from('employees') + ->select(['id', 'name', 'department', 'salary']) + ->selectWindow('ROW_NUMBER()', 'row_num', ['department'], ['salary']) + ->selectWindow('RANK()', 'salary_rank', ['department'], ['-salary']) + ->build(); + + $this->assertSame( + 'SELECT "id", "name", "department", "salary", ROW_NUMBER() OVER (PARTITION BY "department" ORDER BY "salary" ASC) AS "row_num", RANK() OVER (PARTITION BY "department" ORDER BY "salary" DESC) AS "salary_rank" FROM "employees"', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedUnionWithOrderAndLimit(): void + { + $second = (new Builder()) + ->from('archived_users') + ->select(['id', 'name']); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->sortAsc('name') + ->limit(50) + ->union($second) + ->build(); + + $this->assertSame( + '(SELECT "id", "name" FROM "users" ORDER BY "name" ASC LIMIT ?) UNION (SELECT "id", "name" FROM "archived_users")', + $result->query + ); + $this->assertEquals([50], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedDeeplyNestedConditions(): void + { + $result = (new Builder()) + ->from('products') + ->select(['id', 'name']) + ->filter([ + Query::and([ + Query::greaterThan('price', 10), + Query::or([ + Query::equal('category', ['electronics']), + Query::and([ + Query::equal('brand', ['acme']), + Query::lessThan('stock', 5), + ]), + ]), + ]), + ]) + ->build(); + + $this->assertSame( + 'SELECT "id", "name" FROM "products" WHERE ("price" > ? AND ("category" IN (?) OR ("brand" IN (?) AND "stock" < ?)))', + $result->query + ); + $this->assertEquals([10, 'electronics', 'acme', 5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedForUpdateOfWithJoin(): void + { + $result = (new Builder()) + ->from('accounts') + ->select(['accounts.id', 'accounts.balance', 'users.name']) + ->join('users', 'accounts.user_id', 'users.id') + ->filter([Query::greaterThan('accounts.balance', 0)]) + ->forUpdateOf('accounts') + ->build(); + + $this->assertSame( + 'SELECT "accounts"."id", "accounts"."balance", "users"."name" FROM "accounts" JOIN "users" ON "accounts"."user_id" = "users"."id" WHERE "accounts"."balance" > ? FOR UPDATE OF "accounts"', + $result->query + ); + $this->assertEquals([0], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedForShareOf(): void + { + $result = (new Builder()) + ->from('inventory') + ->select(['id', 'quantity']) + ->filter([Query::equal('warehouse', ['main'])]) + ->forShareOf('inventory') + ->build(); + + $this->assertSame( + 'SELECT "id", "quantity" FROM "inventory" WHERE "warehouse" IN (?) FOR SHARE OF "inventory"', + $result->query + ); + $this->assertEquals(['main'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedConflictSetRaw(): void + { + $result = (new Builder()) + ->from('counters') + ->set(['id' => 'page_views', 'count' => 1]) + ->onConflict(['id'], ['count']) + ->conflictSetRaw('count', '"counters"."count" + EXCLUDED."count"') + ->upsert(); + + $this->assertSame( + 'INSERT INTO "counters" ("id", "count") VALUES (?, ?) ON CONFLICT ("id") DO UPDATE SET "count" = "counters"."count" + EXCLUDED."count"', + $result->query + ); + $this->assertEquals(['page_views', 1], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedUpsertReturningAll(): void + { + $result = (new Builder()) + ->from('settings') + ->set(['key' => 'theme', 'value' => 'dark']) + ->onConflict(['key'], ['value']) + ->returning(['*']) + ->upsert(); + + $this->assertSame( + 'INSERT INTO "settings" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO UPDATE SET "value" = EXCLUDED."value" RETURNING *', + $result->query + ); + $this->assertEquals(['theme', 'dark'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedDeleteReturningMultiple(): void + { + $result = (new Builder()) + ->from('sessions') + ->filter([Query::lessThan('expires_at', '2024-01-01')]) + ->returning(['id', 'user_id']) + ->delete(); + + $this->assertSame( + 'DELETE FROM "sessions" WHERE "expires_at" < ? RETURNING "id", "user_id"', + $result->query + ); + $this->assertEquals(['2024-01-01'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSetJsonAppend(): void + { + $result = (new Builder()) + ->from('users') + ->setJsonAppend('tags', ['vip']) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "tags" = COALESCE("tags", \'[]\'::jsonb) || ?::jsonb WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals(['["vip"]', 1], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSetJsonPrepend(): void + { + $result = (new Builder()) + ->from('users') + ->setJsonPrepend('tags', ['urgent']) + ->filter([Query::equal('id', [2])]) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "tags" = ?::jsonb || COALESCE("tags", \'[]\'::jsonb) WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals(['["urgent"]', 2], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSetJsonInsert(): void + { + $result = (new Builder()) + ->from('users') + ->setJsonInsert('tags', 0, 'first') + ->filter([Query::equal('id', [3])]) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "tags" = jsonb_insert("tags", \'{0}\', ?::jsonb) WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals(['"first"', 3], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSetJsonRemove(): void + { + $result = (new Builder()) + ->from('users') + ->setJsonRemove('tags', 'obsolete') + ->filter([Query::equal('id', [4])]) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "tags" = "tags" - ? WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals(['"obsolete"', 4], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSetJsonIntersect(): void + { + $result = (new Builder()) + ->from('users') + ->setJsonIntersect('tags', ['a', 'b']) + ->filter([Query::equal('id', [5])]) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "tags" = (SELECT jsonb_agg(elem) FROM jsonb_array_elements("tags") AS elem WHERE elem <@ ?::jsonb) WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals(['["a","b"]', 5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSetJsonDiff(): void + { + $result = (new Builder()) + ->from('users') + ->setJsonDiff('tags', ['x', 'y']) + ->filter([Query::equal('id', [6])]) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "tags" = (SELECT COALESCE(jsonb_agg(elem), \'[]\'::jsonb) FROM jsonb_array_elements("tags") AS elem WHERE NOT elem <@ ?::jsonb) WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals(['["x","y"]', 6], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSetJsonUnique(): void + { + $result = (new Builder()) + ->from('users') + ->setJsonUnique('tags') + ->filter([Query::equal('id', [7])]) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "tags" = (SELECT jsonb_agg(DISTINCT elem) FROM jsonb_array_elements("tags") AS elem) WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals([7], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedEmptyInClause(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id']) + ->filter([Query::equal('status', [])]) + ->build(); + + $this->assertSame( + 'SELECT "id" FROM "users" WHERE 1 = 0', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedEmptyAndGroup(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id']) + ->filter([Query::and([])]) + ->build(); + + $this->assertSame( + 'SELECT "id" FROM "users" WHERE 1 = 1', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedEmptyOrGroup(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id']) + ->filter([Query::or([])]) + ->build(); + + $this->assertSame( + 'SELECT "id" FROM "users" WHERE 1 = 0', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedVectorSearchWithFilters(): void + { + $result = (new Builder()) + ->from('documents') + ->select(['id', 'title']) + ->filter([Query::equal('status', ['published'])]) + ->orderByVectorDistance('embedding', [0.1, 0.2, 0.3], VectorMetric::Cosine) + ->limit(5) + ->build(); + + $this->assertSame( + 'SELECT "id", "title" FROM "documents" WHERE "status" IN (?) ORDER BY ("embedding" <=> ?::vector) ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['published', '[0.1,0.2,0.3]', 5], $result->bindings); + $this->assertBindingCount($result); + } }