diff --git a/README.md b/README.md index b0452ca..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,118 +156,420 @@ $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 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; +use Utopia\Query\Method; -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()) { + 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 ''; } } ``` -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 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 + }; + } + + // ... 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 + +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`) + +### SQL Builder + +```php +use Utopia\Query\Builder\SQL as 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(); +``` + +**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 -class RedisAdapter +$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); +``` + +**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') + ->addHook(new AttributeMapHook([ + '$id' => '_uid', + '$createdAt' => '_createdAt', + ])) + ->addHook(new TenantFilterHook(['tenant_abc'])) + ->setWrapChar('"') // PostgreSQL + ->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\Builder\Condition; +use Utopia\Query\Hook\FilterHook; + +class SoftDeleteHook implements FilterHook { - /** - * @param array $queries - */ - public function find(string $key, array $queries): array + public function filter(string $table): Condition { - $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 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 + +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`, ?) ``` -This keeps your application code decoupled from any particular storage engine — swap adapters without changing a single query. +> **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 diff --git a/src/Query/Builder.php b/src/Query/Builder.php new file mode 100644 index 0000000..a300730 --- /dev/null +++ b/src/Query/Builder.php @@ -0,0 +1,913 @@ + + */ + protected array $pendingQueries = []; + + /** + * @var list + */ + protected array $bindings = []; + + /** + * @var list + */ + protected array $unions = []; + + /** @var list */ + protected array $filterHooks = []; + + /** @var list */ + protected array $attributeHooks = []; + + // ── 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; + + /** + * 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 + */ + protected function buildAfterJoins(array &$parts, GroupedQueries $grouped): void + { + // no-op by default + } + + // ── Fluent API ── + + public function from(string $table): static + { + $this->table = $table; + + return $this; + } + + /** + * @param array $columns + */ + public function select(array $columns): static + { + $this->pendingQueries[] = Query::select($columns); + + return $this; + } + + /** + * @param array $queries + */ + public function filter(array $queries): static + { + foreach ($queries as $query) { + $this->pendingQueries[] = $query; + } + + return $this; + } + + public function sortAsc(string $attribute): static + { + $this->pendingQueries[] = Query::orderAsc($attribute); + + return $this; + } + + public function sortDesc(string $attribute): static + { + $this->pendingQueries[] = Query::orderDesc($attribute); + + return $this; + } + + public function sortRandom(): static + { + $this->pendingQueries[] = Query::orderRandom(); + + return $this; + } + + public function limit(int $value): static + { + $this->pendingQueries[] = Query::limit($value); + + return $this; + } + + public function offset(int $value): static + { + $this->pendingQueries[] = Query::offset($value); + + return $this; + } + + public function cursorAfter(mixed $value): static + { + $this->pendingQueries[] = Query::cursorAfter($value); + + return $this; + } + + public function cursorBefore(mixed $value): static + { + $this->pendingQueries[] = Query::cursorBefore($value); + + return $this; + } + + /** + * @param array $queries + */ + public function queries(array $queries): static + { + foreach ($queries as $query) { + $this->pendingQueries[] = $query; + } + + return $this; + } + + public function addHook(Hook $hook): static + { + if ($hook instanceof FilterHook) { + $this->filterHooks[] = $hook; + } + if ($hook instanceof AttributeHook) { + $this->attributeHooks[] = $hook; + } + + 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(self $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause('UNION', $result->query, $result->bindings); + + return $this; + } + + public function unionAll(self $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause('UNION ALL', $result->query, $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(max(0, ($page - 1) * $perPage)); + + return $this; + } + + public function toRawSql(): string + { + $result = $this->build(); + $sql = $result->query; + $offset = 0; + + foreach ($result->bindings as $binding) { + if (\is_string($binding)) { + $value = "'" . str_replace("'", "''", $binding) . "'"; + } elseif (\is_int($binding) || \is_float($binding)) { + $value = (string) $binding; + } elseif (\is_bool($binding)) { + $value = $binding ? '1' : '0'; + } else { + $value = 'NULL'; + } + + $pos = \strpos($sql, '?', $offset); + if ($pos !== false) { + $sql = \substr_replace($sql, $value, $pos, 1); + $offset = $pos + \strlen($value); + } + } + + return $sql; + } + + public function build(): BuildResult + { + $this->bindings = []; + + $grouped = Query::groupByType($this->pendingQueries); + + $parts = []; + + // SELECT + $selectParts = []; + + if (! empty($grouped->aggregations)) { + foreach ($grouped->aggregations as $agg) { + $selectParts[] = $this->compileAggregate($agg); + } + } + + if (! empty($grouped->selections)) { + $selectParts[] = $this->compileSelect($grouped->selections[0]); + } + + $selectSQL = ! empty($selectParts) ? \implode(', ', $selectParts) : '*'; + + $selectKeyword = $grouped->distinct ? 'SELECT DISTINCT' : 'SELECT'; + $parts[] = $selectKeyword . ' ' . $selectSQL; + + // FROM + $parts[] = $this->buildTableClause(); + + // JOINS + if (! empty($grouped->joins)) { + foreach ($grouped->joins as $joinQuery) { + $parts[] = $this->compileJoin($joinQuery); + } + } + + // Hook: after joins (e.g. ClickHouse PREWHERE) + $this->buildAfterJoins($parts, $grouped); + + // WHERE + $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); + } + } + + $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); + } + + // 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, [ + Method::OrderAsc, + Method::OrderDesc, + Method::OrderRandom, + ], 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 (only emit if LIMIT is also present) + if ($grouped->offset !== null && $grouped->limit !== null) { + $parts[] = 'OFFSET ?'; + $this->addBinding($grouped->offset); + } + + $sql = \implode(' ', $parts); + + // UNION + if (!empty($this->unions)) { + $sql = '(' . $sql . ')'; + } + foreach ($this->unions as $union) { + $sql .= ' ' . $union->type . ' (' . $union->query . ')'; + foreach ($union->bindings as $binding) { + $this->addBinding($binding); + } + } + + return new BuildResult($sql, $this->bindings); + } + + /** + * @return list + */ + public function getBindings(): array + { + return $this->bindings; + } + + public function reset(): static + { + $this->pendingQueries = []; + $this->bindings = []; + $this->table = ''; + $this->unions = []; + + return $this; + } + + // ── Compiler interface ── + + public function compileFilter(Query $query): string + { + $method = $query->getMethod(); + $attribute = $this->resolveAndWrap($query->getAttribute()); + $values = $query->getValues(); + + return match ($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()) { + 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), + }; + } + + 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() === Method::CursorAfter ? '>' : '<'; + + return $this->wrapIdentifier('_cursor') . ' ' . $operator . ' ?'; + } + + public function compileAggregate(Query $query): string + { + $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 */ + $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()) { + 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()); + $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]; + + $allowedOperators = ['=', '!=', '<', '>', '<=', '>=', '<>']; + if (!\in_array($operator, $allowedOperators, true)) { + throw new \InvalidArgumentException('Invalid join operator: ' . $operator); + } + + $left = $this->resolveAndWrap($leftCol); + $right = $this->resolveAndWrap($rightCol); + + return $type . ' ' . $table . ' ON ' . $left . ' ' . $operator . ' ' . $right; + } + + // ── Protected helpers ── + + protected function resolveAttribute(string $attribute): string + { + foreach ($this->attributeHooks as $hook) { + $attribute = $hook->resolve($attribute); + } + + return $attribute; + } + + protected function resolveAndWrap(string $attribute): string + { + return $this->wrapIdentifier($this->resolveAttribute($attribute)); + } + + 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 + { + 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 $inClause; + } + + /** + * @param array $values + */ + private function compileNotIn(string $attribute, array $values): string + { + if ($values === []) { + return '1 = 1'; + } + + $hasNulls = false; + $nonNulls = []; + + foreach ($values as $value) { + if ($value === null) { + $hasNulls = true; + } else { + $nonNulls[] = $value; + } + } + + $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; + } + + /** + * @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 $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 = []; + foreach ($query->getValues() as $subQuery) { + /** @var Query $subQuery */ + $parts[] = $this->compileFilter($subQuery); + } + + if ($parts === []) { + return $operator === 'OR' ? '1 = 0' : '1 = 1'; + } + + 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'; + } + + if ($parts === []) { + return '1 = 1'; + } + + 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'; + } + + if ($parts === []) { + return '1 = 1'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } + + private function compileRaw(Query $query): string + { + $attribute = $query->getAttribute(); + + if ($attribute === '') { + return '1 = 1'; + } + + foreach ($query->getValues() as $binding) { + $this->addBinding($binding); + } + + return $attribute; + } +} 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/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php new file mode 100644 index 0000000..23def63 --- /dev/null +++ b/src/Query/Builder/ClickHouse.php @@ -0,0 +1,141 @@ + + */ + 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 + { + if ($fraction <= 0.0 || $fraction >= 1.0) { + throw new \InvalidArgumentException('Sample fraction must be between 0 and 1 exclusive'); + } + + $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 + { + $segments = \explode('.', $identifier); + $wrapped = \array_map(fn (string $segment): string => $segment === '*' + ? '*' + : '`' . \str_replace('`', '``', $segment) . '`', $segments); + + return \implode('.', $wrapped); + } + + 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 + */ + protected function buildAfterJoins(array &$parts, GroupedQueries $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/Condition.php b/src/Query/Builder/Condition.php new file mode 100644 index 0000000..1028c1d --- /dev/null +++ b/src/Query/Builder/Condition.php @@ -0,0 +1,26 @@ + $bindings + */ + public function __construct( + public string $expression, + public array $bindings = [], + ) { + } + + public function getExpression(): string + { + return $this->expression; + } + + /** @return list */ + public function getBindings(): array + { + return $this->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/SQL.php b/src/Query/Builder/SQL.php new file mode 100644 index 0000000..9275208 --- /dev/null +++ b/src/Query/Builder/SQL.php @@ -0,0 +1,56 @@ +wrapChar = $char; + + return $this; + } + + protected function wrapIdentifier(string $identifier): string + { + $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); + + return \implode('.', $wrapped); + } + + 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/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/Compiler.php b/src/Query/Compiler.php new file mode 100644 index 0000000..f7c0a24 --- /dev/null +++ b/src/Query/Compiler.php @@ -0,0 +1,51 @@ + $map */ + public function __construct(public 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..d9adbc0 --- /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..b42ac4e --- /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/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; } @@ -224,7 +45,7 @@ public function __clone(): void } } - public function getMethod(): string + public function getMethod(): Method { return $this->method; } @@ -250,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; } @@ -294,58 +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 => true, - default => false, - }; + return Method::tryFrom($value) !== null; } /** @@ -353,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(); } /** @@ -420,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); } /** @@ -454,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(); @@ -475,6 +233,44 @@ public function toArray(): array return $array; } + /** + * Compile this query using the given compiler + */ + public function compile(Compiler $compiler): string + { + return match ($this->method) { + 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::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), + }; + } + /** * @throws QueryException */ @@ -490,26 +286,26 @@ 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 { - return new static(self::TYPE_EQUAL, $attribute, $values); + return new static(Method::Equal, $attribute, $values); } /** * 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)) { $value = [$value]; } - return new static(self::TYPE_NOT_EQUAL, $attribute, $value); + return new static(Method::NotEqual, $attribute, $value); } /** @@ -517,7 +313,7 @@ public static function notEqual(string $attribute, string|int|float|bool|array $ */ 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]); } /** @@ -525,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]); } /** @@ -533,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]); } /** @@ -541,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]); } /** @@ -553,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); } /** @@ -564,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); } /** @@ -574,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); } /** @@ -582,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]); } /** @@ -590,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]); } /** @@ -598,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]); } /** @@ -606,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]); } /** @@ -616,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); } /** @@ -624,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); } /** @@ -632,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); } /** @@ -640,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); } /** @@ -648,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]); } /** @@ -656,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]); } /** @@ -664,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]); } /** @@ -672,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]); } /** @@ -680,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); } /** @@ -688,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]); } /** @@ -764,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); } /** @@ -772,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); } /** @@ -780,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 @@ -812,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 ); @@ -823,21 +619,17 @@ public static function getCursorQueries(array $queries, bool $clone = true): arr * Iterates through queries and groups them by type * * @param array $queries - * @return array{ - * filters: array, - * selections: array, - * 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 = []; + $aggregations = []; + $groupBy = []; + $having = []; + $distinct = false; + $joins = []; + $unions = []; $limit = null; $offset = null; $orderAttributes = []; @@ -855,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; @@ -877,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; @@ -885,37 +677,78 @@ 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 Method::Count: + case Method::Sum: + case Method::Avg: + case Method::Min: + case Method::Max: + $aggregations[] = clone $query; + break; + + case Method::GroupBy: + /** @var array $values */ + foreach ($values as $col) { + $groupBy[] = $col; + } + break; + + case Method::Having: + $having[] = clone $query; + break; + + case Method::Distinct: + $distinct = true; + break; + + case Method::Join: + case Method::LeftJoin: + case Method::RightJoin: + case Method::CrossJoin: + $joins[] = clone $query; + break; + + case Method::Union: + case Method::UnionAll: + $unions[] = clone $query; + break; + default: $filters[] = clone $query; break; } } - return [ - 'filters' => $filters, - 'selections' => $selections, - '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, + ); } /** @@ -923,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 @@ -959,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]]); } /** @@ -969,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]]); } /** @@ -979,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]]); } /** @@ -989,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]]); } /** @@ -999,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]); } /** @@ -1009,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]); } /** @@ -1019,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]); } /** @@ -1029,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]); } /** @@ -1039,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]); } /** @@ -1049,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]); } /** @@ -1059,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]); } /** @@ -1069,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]); } /** @@ -1079,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]); } /** @@ -1089,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]); } /** @@ -1099,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]); } /** @@ -1107,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]); } /** @@ -1117,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); } /** @@ -1127,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]); } /** @@ -1135,6 +964,252 @@ 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(Method::Count, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function sum(string $attribute, string $alias = ''): static + { + return new static(Method::Sum, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function avg(string $attribute, string $alias = ''): static + { + return new static(Method::Avg, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function min(string $attribute, string $alias = ''): static + { + return new static(Method::Min, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function max(string $attribute, string $alias = ''): static + { + return new static(Method::Max, $attribute, $alias !== '' ? [$alias] : []); + } + + /** + * @param array $attributes + */ + public static function groupBy(array $attributes): static + { + return new static(Method::GroupBy, '', $attributes); + } + + /** + * @param array $queries + */ + public static function having(array $queries): static + { + return new static(Method::Having, '', $queries); + } + + public static function distinct(): static + { + return new static(Method::Distinct); + } + + // Join factory methods + + public static function join(string $table, string $left, string $right, string $operator = '='): static + { + 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(Method::LeftJoin, $table, [$left, $operator, $right]); + } + + public static function rightJoin(string $table, string $left, string $right, string $operator = '='): static + { + return new static(Method::RightJoin, $table, [$left, $operator, $right]); + } + + public static function crossJoin(string $table): static + { + return new static(Method::CrossJoin, $table); + } + + // Union factory methods + + /** + * @param array $queries + */ + public static function union(array $queries): static + { + return new static(Method::Union, '', $queries); + } + + /** + * @param array $queries + */ + public static function unionAll(array $queries): static + { + return new static(Method::UnionAll, '', $queries); + } + + // Raw factory method + + /** + * @param array $bindings + */ + public static function raw(string $sql, array $bindings = []): static + { + return new static(Method::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 = [ + Method::Limit, + Method::Offset, + Method::CursorAfter, + Method::CursorBefore, + ]; + + $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 = [ + 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 ($method->isNested()) { + /** @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 === 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->value}"; + } + } + + continue; + } + + $attribute = $query->getAttribute(); + + if ($attribute === '' || $attribute === '*') { + continue; + } + + if (! \in_array($attribute, $allowedAttributes, true)) { + $errors[] = "Invalid attribute \"{$attribute}\" used in {$method->value}"; + } + } + + return $errors; } } diff --git a/tests/Query/AggregationQueryTest.php b/tests/Query/AggregationQueryTest.php new file mode 100644 index 0000000..76c61fc --- /dev/null +++ b/tests/Query/AggregationQueryTest.php @@ -0,0 +1,259 @@ +assertSame(Method::Count, $query->getMethod()); + $this->assertEquals('*', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testCountWithAttribute(): void + { + $query = Query::count('id'); + $this->assertSame(Method::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->assertSame(Method::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->assertSame(Method::Avg, $query->getMethod()); + $this->assertEquals('score', $query->getAttribute()); + } + + public function testMin(): void + { + $query = Query::min('price'); + $this->assertSame(Method::Min, $query->getMethod()); + $this->assertEquals('price', $query->getAttribute()); + } + + public function testMax(): void + { + $query = Query::max('price'); + $this->assertSame(Method::Max, $query->getMethod()); + $this->assertEquals('price', $query->getAttribute()); + } + + public function testGroupBy(): void + { + $query = Query::groupBy(['status', 'country']); + $this->assertSame(Method::GroupBy, $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->assertSame(Method::Having, $query->getMethod()); + $this->assertCount(1, $query->getValues()); + $this->assertInstanceOf(Query::class, $query->getValues()[0]); + } + + public function testAggregateMethodsAreAggregate(): void + { + $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 ── + + 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->assertSame(Method::GroupBy, $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->assertSame(Method::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..be282a0 --- /dev/null +++ b/tests/Query/Builder/ClickHouseTest.php @@ -0,0 +1,5361 @@ +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') + ->addHook(new AttributeMapHook(['$id' => '_uid'])) + ->filter([Query::equal('$id', ['abc'])]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `events` WHERE `_uid` IN (?)', + $result->query + ); + } + + // ── Condition provider works ── + + 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') + ->addHook($hook) + ->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)]) + ->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); + } + + 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)]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('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') + ->addHook(new AttributeMapHook([ + '$id' => '_uid', + ])) + ->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() + ->addHook(new class () implements \Utopia\Query\Hook\AttributeHook { + public function resolve(string $attribute): string + { + return 'col_' . $attribute; + } + }) + ->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() + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('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) + ->addHook(new class () implements \Utopia\Query\Hook\AttributeHook { + public function resolve(string $attribute): string + { + return 'r_' . $attribute; + } + }) + ->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') + ->addHook(new class () implements \Utopia\Query\Hook\AttributeHook { + public function resolve(string $attribute): string + { + return 'col_' . $attribute; + } + }) + ->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') + ->addHook(new AttributeMapHook([ + 'amt' => 'amount_cents', + ])) + ->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'])]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('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') + ->addHook(new AttributeMapHook([ + 'uid' => 'user_id', + ])) + ->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'])]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('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 + { + $hook = new class () implements \Utopia\Query\Hook\AttributeHook { + public function resolve(string $attribute): string + { + return 'r_' . $attribute; + } + }; + $builder = (new Builder()) + ->from('events') + ->addHook($hook) + ->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') + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('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'])]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('deleted = ?', [0]); + } + }) + ->build(); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('deleted = ?', $result->query); + } + + public function testProviderWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('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) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('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)]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('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'])]) + ->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); + } + + public function testProviderPrewhereCursorLimitBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('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)]) + ->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); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('tenant = ?', $result->query); + } + + public function testProviderPrewhereAggregation(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('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'])]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('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() + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition($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'])]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('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'])]) + ->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)]) + ->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 + { + $this->expectException(\InvalidArgumentException::class); + (new Builder())->from('t')->sample(0.0); + } + + public function testSampleOne(): void + { + $this->expectException(\InvalidArgumentException::class); + (new Builder())->from('t')->sample(1.0); + } + + public function testSampleNegative(): void + { + $this->expectException(\InvalidArgumentException::class); + (new Builder())->from('t')->sample(-0.5); + } + + public function testSampleGreaterThanOne(): void + { + $this->expectException(\InvalidArgumentException::class); + (new Builder())->from('t')->sample(2.0); + } + + 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'])]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_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'])]) + ->addHook(new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_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') + ->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); + } + + // ══════════════════════════════════════════════════════════════════ + // 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() + ->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->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 + { + // 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 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 + { + // OFFSET without LIMIT is suppressed + $result = (new Builder())->from('t')->offset(5)->offset(50)->build(); + $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); + } + + // ══════════════════════════════════════════════════════════════════ + // 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..c31056a --- /dev/null +++ b/tests/Query/Builder/SQLTest.php @@ -0,0 +1,6654 @@ +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 + { + // 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`', $result->query); + $this->assertEquals([], $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') + ->addHook(new AttributeMapHook([ + '$id' => '_uid', + '$createdAt' => '_createdAt', + ])) + ->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 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()) + ->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 { + public function filter(string $table): Condition + { + return new Condition( + "_uid IN (SELECT _document FROM {$table}_perms WHERE _type = 'read')", + ); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook) + ->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 + { + $hook = new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['tenant_abc']); + } + }; + + $result = (new Builder()) + ->from('docs') + ->addHook($hook) + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM `docs` WHERE `status` IN (?) AND _tenant = ?', + $result->query + ); + // 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') + ->addHook($hook) + ->filter([Query::equal('status', ['active'])]) + ->cursorAfter('cursor_val') + ->limit(10) + ->offset(5) + ->build(); + + // binding order: filter, hook, 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 + { + $this->expectException(\ValueError::class); + new Query('totallyInvalid', 'x', [1]); + } + + 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 + { + $hook = new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['tenant1']); + } + }; + + $result = (new Builder()) + ->from('t') + ->addHook($hook) + ->filter([ + Query::equal('a', ['x']), + Query::greaterThan('b', 5), + ]) + ->cursorAfter('cursor_abc') + ->limit(10) + ->offset(20) + ->build(); + + // 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') + ->addHook($hook1) + ->addHook($hook2) + ->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])]); + + $hook = new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('_org = ?', ['org1']); + } + }; + + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->addHook($hook) + ->filter([Query::equal('status', ['paid'])]) + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 1)]) + ->cursorAfter('cur1') + ->limit(10) + ->offset(5) + ->union($sub) + ->build(); + + // filter, hook, 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') + ->addHook(new AttributeMapHook(['$price' => '_price'])) + ->sum('$price', 'total') + ->build(); + + $this->assertEquals('SELECT SUM(`_price`) AS `total` FROM `t`', $result->query); + } + + public function testAttributeResolverWithGroupBy(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new AttributeMapHook(['$status' => '_status'])) + ->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') + ->addHook(new AttributeMapHook([ + '$id' => '_uid', + '$ref' => '_ref', + ])) + ->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') + ->addHook(new AttributeMapHook(['$total' => '_total'])) + ->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 + { + $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') + ->addHook($hook) + ->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 + { + $hook = new class () implements FilterHook { + public function filter(string $table): Condition + { + return new Condition('org_id = ?', ['org1']); + } + }; + + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->addHook($hook) + ->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 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); + } + + // ── 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), + ]) + ->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') + ->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(); + + // OFFSET without LIMIT is suppressed + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $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') + ->addHook(new AttributeMapHook([ + '$slug' => '_slug', + ])) + ->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') + ->addHook(new AttributeMapHook([ + '$body' => '_body', + ])) + ->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') + ->addHook(new class () implements \Utopia\Query\Hook\AttributeHook { + public function resolve(string $attribute): string + { + return '_' . $attribute; + } + }) + ->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') + ->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); + $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') + ->addHook(new AttributeMapHook([ + '$amount' => '_amount', + ])) + ->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') + ->addHook(new AttributeMapHook([ + '$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 FilterHook { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org2']); + } + }); + + $result = (new Builder()) + ->from('main') + ->addHook(new class () implements FilterHook { + 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 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') + ->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( + '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 FilterHook { + 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 FilterHook { + 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 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)]) + ->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 FilterHook { + 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 FilterHook { + 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 FilterHook { + 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 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); + $this->assertEquals(['read'], $result->bindings); + } + + public function testProviderWithWrapCharProviderSqlIsLiteral(): void + { + $result = (new Builder()) + ->setWrapChar('"') + ->from('t') + ->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 + $this->assertStringContainsString('FROM "t"', $result->query); + $this->assertStringContainsString('raw_col = ?', $result->query); + } + + public function testProviderBindingOrderWithComplexQuery(): 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) + ->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 FilterHook { + 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 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( + '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 FilterHook { + 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 \Utopia\Query\Hook\AttributeHook { + 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 FilterHook { + 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 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') + ->addHook(new class () implements FilterHook { + 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 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); + } + + 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 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(); + + // 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 FilterHook { + 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 + { + $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 + { + // OFFSET without LIMIT is suppressed + $result = (new Builder()) + ->from('') + ->offset(50) + ->build(); + + $this->assertStringNotContainsString('OFFSET ?', $result->query); + $this->assertEquals([], $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') + ->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); + } + + public function testConditionProviderWithCursorNoFilters(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements FilterHook { + 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 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); + } + + public function testConditionProviderPersistsAfterReset(): 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); + } + + public function testConditionProviderWithHaving(): void + { + $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)]) + ->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 FilterHook { + 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 + { + $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 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); + } + + // ══════════════════════════════════════════ + // 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/ConditionTest.php b/tests/Query/ConditionTest.php new file mode 100644 index 0000000..c2b452e --- /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/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/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()); + } +} diff --git a/tests/Query/JoinQueryTest.php b/tests/Query/JoinQueryTest.php new file mode 100644 index 0000000..6dcf599 --- /dev/null +++ b/tests/Query/JoinQueryTest.php @@ -0,0 +1,144 @@ +assertSame(Method::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->assertSame(Method::LeftJoin, $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->assertSame(Method::RightJoin, $query->getMethod()); + $this->assertEquals('orders', $query->getAttribute()); + } + + public function testCrossJoin(): void + { + $query = Query::crossJoin('colors'); + $this->assertSame(Method::CrossJoin, $query->getMethod()); + $this->assertEquals('colors', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testJoinMethodsAreJoin(): void + { + $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 ── + + 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/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 22807f4..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 @@ -35,6 +38,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 @@ -91,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 @@ -110,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 @@ -121,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]); } @@ -130,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); } @@ -152,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 @@ -178,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 @@ -203,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 @@ -214,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 @@ -225,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 @@ -236,34 +254,643 @@ 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 ── + + 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->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); + } + + public function testGroupByTypeHaving(): void + { + $queries = [Query::having([Query::greaterThan('total', 5)])]; + $grouped = Query::groupByType($queries); + $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); + } + + 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->assertSame(Method::Join, $grouped->joins[0]->getMethod()); + $this->assertSame(Method::CrossJoin, $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->assertSame(Method::Equal, $result[0]->getMethod()); + $this->assertSame(Method::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->assertSame(Method::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->assertSame(Method::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->assertSame(Method::Limit, $result[0]->getMethod()); + $this->assertEquals(10, $result[0]->getValue()); + $this->assertSame(Method::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()); + } + + 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->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); + } + + // ── 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() === 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()); + } + + 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->assertSame(Method::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->assertSame(Method::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']), + ]; + + $aggTypes = array_values(array_filter(Method::cases(), fn (Method $m) => $m->isAggregate())); + $aggs = Query::getByType($queries, $aggTypes); + $this->assertCount(2, $aggs); + + $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, [Method::Distinct]); + $this->assertCount(1, $distinct); } } diff --git a/tests/Query/QueryParseTest.php b/tests/Query/QueryParseTest.php index 39df897..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 @@ -187,4 +188,410 @@ 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->assertSame(Method::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->assertSame(Method::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->assertSame(Method::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->assertSame(Method::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->assertSame(Method::Distinct, $parsed->getMethod()); + } + + public function testRoundTripJoin(): void + { + $original = Query::join('orders', 'users.id', 'orders.user_id'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::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->assertSame(Method::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->assertSame(Method::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->assertSame(Method::Union, $parsed->getMethod()); + $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->assertSame(Method::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->assertSame(Method::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->assertSame(Method::Max, $parsed->getMethod()); + $this->assertEquals(['oldest'], $parsed->getValues()); + } + + public function testRoundTripCountWithoutAlias(): void + { + $original = Query::count('id'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::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->assertSame(Method::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->assertSame(Method::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->assertSame(Method::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->assertSame(Method::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->assertSame(Method::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->assertSame(Method::Or, $parsed->getMethod()); + $this->assertCount(1, $parsed->getValues()); + + /** @var Query $inner */ + $inner = $parsed->getValues()[0]; + $this->assertSame(Method::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->assertSame(Method::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->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 ── + + 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 1fb05bd..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 @@ -138,4 +140,299 @@ public function testEmptyValues(): void $query = Query::equal('name', []); $this->assertEquals([], $query->getValues()); } + + public function testMethodContainsNewTypes(): void + { + $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 + { + $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->assertSame(Method::Distinct, $query->getMethod()); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testRawFactory(): void + { + $query = Query::raw('score > ?', [10]); + $this->assertSame(Method::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->assertSame(Method::Union, $query->getMethod()); + $this->assertCount(1, $query->getValues()); + } + + public function testUnionAllFactory(): void + { + $inner = [Query::equal('x', [1])]; + $query = Query::unionAll($inner); + $this->assertSame(Method::UnionAll, $query->getMethod()); + } + + // ══════════════════════════════════════════ + // ADDITIONAL EDGE CASES + // ══════════════════════════════════════════ + + public function testMethodNoDuplicateValues(): void + { + $values = array_map(fn (Method $m) => $m->value, Method::cases()); + $this->assertEquals(count($values), count(array_unique($values))); + } + + public function testAggregateMethodsNoDuplicates(): void + { + $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 testJoinMethodsNoDuplicates(): void + { + $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 testAggregateMethodsAreValidMethods(): void + { + $aggMethods = array_filter(Method::cases(), fn (Method $m) => $m->isAggregate()); + foreach ($aggMethods as $method) { + $this->assertSame($method, Method::from($method->value)); + } + } + + public function testJoinMethodsAreValidMethods(): void + { + $joinMethods = array_filter(Method::cases(), fn (Method $m) => $m->isJoin()); + foreach ($joinMethods as $method) { + $this->assertSame($method, Method::from($method->value)); + } + } + + 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->assertSame(Method::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 testCountEnumValue(): void + { + $this->assertEquals('count', Method::Count->value); + } + + public function testSumEnumValue(): void + { + $this->assertEquals('sum', Method::Sum->value); + } + + public function testAvgEnumValue(): void + { + $this->assertEquals('avg', Method::Avg->value); + } + + public function testMinEnumValue(): void + { + $this->assertEquals('min', Method::Min->value); + } + + public function testMaxEnumValue(): void + { + $this->assertEquals('max', Method::Max->value); + } + + public function testGroupByEnumValue(): void + { + $this->assertEquals('groupBy', Method::GroupBy->value); + } + + public function testHavingEnumValue(): void + { + $this->assertEquals('having', Method::Having->value); + } + + public function testDistinctEnumValue(): void + { + $this->assertEquals('distinct', Method::Distinct->value); + } + + public function testJoinEnumValue(): void + { + $this->assertEquals('join', Method::Join->value); + } + + public function testLeftJoinEnumValue(): void + { + $this->assertEquals('leftJoin', Method::LeftJoin->value); + } + + public function testRightJoinEnumValue(): void + { + $this->assertEquals('rightJoin', Method::RightJoin->value); + } + + public function testCrossJoinEnumValue(): void + { + $this->assertEquals('crossJoin', Method::CrossJoin->value); + } + + public function testUnionEnumValue(): void + { + $this->assertEquals('union', Method::Union->value); + } + + public function testUnionAllEnumValue(): void + { + $this->assertEquals('unionAll', Method::UnionAll->value); + } + + public function testRawEnumValue(): void + { + $this->assertEquals('raw', Method::Raw->value); + } + + 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()); + } } 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()); } }