From 13730bb01359ec63baced39369e03781a01da048 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 13 Aug 2026 22:46:40 +1200 Subject: [PATCH 1/7] refactor(builder): express dialect capability in types, not runtime throws A builder method that exists only to throw is a broken promise: it passes `instanceof`, satisfies the interface, autocompletes, and then fails at runtime. Replace the throwing stubs with capability interfaces narrow enough that each dialect implements only what it can actually do. Feature\GroupByModifiers bundled three unrelated capabilities, so every dialect that supported any of them advertised all three. Split into Rollup, Cube and Totals, matching what the dialects really support: withRollup MySQL, MariaDB, PostgreSQL, ClickHouse withCube PostgreSQL, ClickHouse withTotals ClickHouse That removes four footguns -- MySQL::withCube(), MySQL::withTotals(), MariaDB::withCube(), MariaDB::withTotals() and PostgreSQL::withTotals() no longer exist rather than throwing. Feature\FullTextSearch required filterNotSearch(), which MongoDB cannot implement: $text has no negated form. Negation moves to Feature\NegatedFullTextSearch, so MongoDB keeps full-text search without advertising a negation it has to reject. The larger problem was raw SQL. MongoDB emits operation documents, so there is nowhere in its output a SQL fragment could go, yet it inherited twelve methods for splicing SQL in. Two threw; the other ten -- among them selectRaw, orderByRaw, groupByRaw, havingRaw, selectCast, selectCase and insertColumnExpression -- accepted the input and silently discarded it, which is the worse failure. All twelve now live in Feature\RawSql, implemented by Builder\SQL and ClickHouse only. Builder::applyAstOrderBy() wrote through orderByRaw(); it now appends to rawOrders directly, so the base class no longer depends on a capability its subclasses may not have. Absence is asserted against the runtime published surface (class_implements/get_class_methods) rather than assertNotInstanceOf, because PHPStan at level max proves the latter statically true and fails the build -- the type system now knows these capabilities are gone, which is the point. Breaking change for callers of the removed methods, though the only possible use was catching the exception. Pre-1.0. Co-Authored-By: Claude Opus 5 --- src/Query/Builder.php | 2 +- src/Query/Builder/ClickHouse.php | 8 +- src/Query/Builder/Feature/Cube.php | 11 ++ src/Query/Builder/Feature/FullTextSearch.php | 2 - .../Builder/Feature/GroupByModifiers.php | 21 --- .../Builder/Feature/NegatedFullTextSearch.php | 13 ++ src/Query/Builder/Feature/RawSql.php | 63 ++++++++ src/Query/Builder/Feature/Rollup.php | 11 ++ src/Query/Builder/Feature/Selects.php | 2 - src/Query/Builder/Feature/Totals.php | 11 ++ src/Query/Builder/Feature/Updates.php | 5 - src/Query/Builder/MongoDB.php | 28 ---- src/Query/Builder/MySQL.php | 7 +- src/Query/Builder/PostgreSQL.php | 9 +- src/Query/Builder/SQL.php | 4 +- src/Query/Builder/Trait/FullTextSearch.php | 8 - src/Query/Builder/Trait/GroupByModifiers.php | 20 --- src/Query/Builder/Trait/Inserts.php | 29 ---- .../Builder/Trait/NegatedFullTextSearch.php | 16 ++ src/Query/Builder/Trait/RawSql.php | 152 ++++++++++++++++++ src/Query/Builder/Trait/Selects.php | 102 ------------ src/Query/Builder/Trait/Updates.php | 12 -- tests/Query/Builder/ClickHouseTest.php | 10 +- tests/Query/Builder/MariaDBTest.php | 13 ++ tests/Query/Builder/MongoDBTest.php | 66 ++++---- tests/Query/Builder/MySQLTest.php | 16 ++ tests/Query/Builder/PostgreSQLTest.php | 16 ++ tests/Query/Builder/SQLiteTest.php | 12 ++ 28 files changed, 393 insertions(+), 276 deletions(-) create mode 100644 src/Query/Builder/Feature/Cube.php delete mode 100644 src/Query/Builder/Feature/GroupByModifiers.php create mode 100644 src/Query/Builder/Feature/NegatedFullTextSearch.php create mode 100644 src/Query/Builder/Feature/RawSql.php create mode 100644 src/Query/Builder/Feature/Rollup.php create mode 100644 src/Query/Builder/Feature/Totals.php create mode 100644 src/Query/Builder/Trait/NegatedFullTextSearch.php create mode 100644 src/Query/Builder/Trait/RawSql.php diff --git a/src/Query/Builder.php b/src/Query/Builder.php index b884e74..53c8abd 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -2749,7 +2749,7 @@ private function applyAstOrderBy(Select $ast): void $serializer = $this->createAstSerializer(); $rawExpr = $serializer->serializeExpression($item->expression); $dir = $item->direction === OrderDirection::Desc ? ' DESC' : ' ASC'; - $this->orderByRaw($rawExpr . $dir); + $this->rawOrders[] = new Condition($rawExpr . $dir); } } } diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 9717e02..d52828c 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -12,18 +12,21 @@ use Utopia\Query\Builder\Feature\ClickHouse\LimitBy; use Utopia\Query\Builder\Feature\ClickHouse\WithFill; use Utopia\Query\Builder\Feature\ConditionalAggregates; +use Utopia\Query\Builder\Feature\Cube; use Utopia\Query\Builder\Feature\FullOuterJoins; -use Utopia\Query\Builder\Feature\GroupByModifiers; use Utopia\Query\Builder\Feature\Hints; +use Utopia\Query\Builder\Feature\RawSql; +use Utopia\Query\Builder\Feature\Rollup; use Utopia\Query\Builder\Feature\StatisticalAggregates; use Utopia\Query\Builder\Feature\StringAggregates; use Utopia\Query\Builder\Feature\TableSampling; +use Utopia\Query\Builder\Feature\Totals; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Hook\Join\Placement; use Utopia\Query\Query; use Utopia\Query\QuotesIdentifiers; -class ClickHouse extends BaseBuilder implements Hints, ConditionalAggregates, TableSampling, FullOuterJoins, StringAggregates, StatisticalAggregates, BitwiseAggregates, LimitBy, ArrayJoins, AsofJoins, WithFill, GroupByModifiers, ApproximateAggregates +class ClickHouse extends BaseBuilder implements Hints, ConditionalAggregates, TableSampling, FullOuterJoins, StringAggregates, StatisticalAggregates, BitwiseAggregates, LimitBy, ArrayJoins, AsofJoins, WithFill, Rollup, Cube, Totals, ApproximateAggregates, RawSql { use QuotesIdentifiers; use Trait\BitwiseAggregates; @@ -34,6 +37,7 @@ class ClickHouse extends BaseBuilder implements Hints, ConditionalAggregates, Ta use Trait\ClickHouse\WithFill; use Trait\FullOuterJoins; use Trait\GroupByModifiers; + use Trait\RawSql; use Trait\StatisticalAggregates; use Trait\StringAggregates; diff --git a/src/Query/Builder/Feature/Cube.php b/src/Query/Builder/Feature/Cube.php new file mode 100644 index 0000000..bea0292 --- /dev/null +++ b/src/Query/Builder/Feature/Cube.php @@ -0,0 +1,11 @@ + $bindings + */ + public function selectRaw(string $expression, array $bindings = []): static; + + public function selectCast(string $column, string $type, string $alias = ''): static; + + /** + * @param list $bindings + */ + public function orderByRaw(string $expression, array $bindings = []): static; + + /** + * @param list $bindings + */ + public function groupByRaw(string $expression, array $bindings = []): static; + + /** + * @param list $bindings + */ + public function havingRaw(string $expression, array $bindings = []): static; + + /** + * @param list $bindings + */ + public function whereRaw(string $expression, array $bindings = []): static; + + public function whereColumn(string $left, string $operator, string $right): static; + + public function selectCase(CaseExpression $case): static; + + public function setCase(string $column, CaseExpression $case): static; + + /** + * @param list $bindings + */ + public function setRaw(string $column, string $expression, array $bindings = []): static; + + /** + * @param list $bindings + */ + public function conflictSetRaw(string $column, string $expression, array $bindings = []): static; + + /** + * @param list $extraBindings + */ + public function insertColumnExpression(string $column, string $expression, array $extraBindings = []): static; +} diff --git a/src/Query/Builder/Feature/Rollup.php b/src/Query/Builder/Feature/Rollup.php new file mode 100644 index 0000000..a482afc --- /dev/null +++ b/src/Query/Builder/Feature/Rollup.php @@ -0,0 +1,11 @@ + $bindings - */ - public function setRaw(string $column, string $expression, array $bindings = []): static; - public function update(): Statement; } diff --git a/src/Query/Builder/MongoDB.php b/src/Query/Builder/MongoDB.php index 2977290..f27d2c1 100644 --- a/src/Query/Builder/MongoDB.php +++ b/src/Query/Builder/MongoDB.php @@ -180,12 +180,6 @@ public function filterSearch(string $attribute, string $value): static return $this; } - #[\Override] - public function filterNotSearch(string $attribute, string $value): static - { - throw new UnsupportedException('MongoDB does not support negated full-text search.'); - } - #[\Override] public function tablesample(float $percent, string $method = 'BERNOULLI'): static { @@ -227,21 +221,6 @@ public function reset(): static return $this; } - /** - * @param list $bindings - */ - #[\Override] - public function whereRaw(string $expression, array $bindings = []): static - { - throw new ValidationException('whereRaw() is not supported on the MongoDB builder.'); - } - - #[\Override] - public function whereColumn(string $left, string $operator, string $right): static - { - throw new ValidationException('whereColumn() is not supported on the MongoDB builder.'); - } - #[\Override] public function build(): Statement { @@ -304,13 +283,6 @@ public function update(): Statement $this->bindings = []; $this->validateTable(); - if (! empty($this->rawSets) || ! empty($this->caseSets) || ! empty($this->conflictRawSets)) { - throw new UnsupportedException( - 'setRaw()/setCase() are not supported on the MongoDB builder. ' - . 'Use typed set()/updateInc/updatePush/etc. or raw pipeline stages instead.' - ); - } - $grouped = Query::groupByType($this->pendingQueries); $filter = $this->buildFilter($grouped); diff --git a/src/Query/Builder/MySQL.php b/src/Query/Builder/MySQL.php index 69d3bc3..faf69f6 100644 --- a/src/Query/Builder/MySQL.php +++ b/src/Query/Builder/MySQL.php @@ -4,11 +4,12 @@ use Utopia\Query\Builder\Feature\ConditionalAggregates; use Utopia\Query\Builder\Feature\FullTextSearch; -use Utopia\Query\Builder\Feature\GroupByModifiers; use Utopia\Query\Builder\Feature\Hints; use Utopia\Query\Builder\Feature\InsertOrIgnore; use Utopia\Query\Builder\Feature\Json; use Utopia\Query\Builder\Feature\LateralJoins; +use Utopia\Query\Builder\Feature\NegatedFullTextSearch; +use Utopia\Query\Builder\Feature\Rollup; use Utopia\Query\Builder\Feature\Spatial; use Utopia\Query\Builder\Feature\StringAggregates; use Utopia\Query\Builder\Feature\Upsert; @@ -22,15 +23,17 @@ class MySQL extends SQL implements ConditionalAggregates, LateralJoins, StringAggregates, - GroupByModifiers, + Rollup, Spatial, FullTextSearch, + NegatedFullTextSearch, Upsert, UpsertSelect, InsertOrIgnore { use Trait\ConditionalAggregates; use Trait\FullTextSearch; + use Trait\NegatedFullTextSearch; use Trait\GroupByModifiers; use Trait\Hints; use Trait\LateralJoins; diff --git a/src/Query/Builder/PostgreSQL.php b/src/Query/Builder/PostgreSQL.php index 984a081..bee28fc 100644 --- a/src/Query/Builder/PostgreSQL.php +++ b/src/Query/Builder/PostgreSQL.php @@ -5,12 +5,13 @@ use Utopia\Query\AST\Serializer; use Utopia\Query\AST\Serializer\PostgreSQL as PostgreSQLSerializer; use Utopia\Query\Builder\Feature\ConditionalAggregates; +use Utopia\Query\Builder\Feature\Cube; use Utopia\Query\Builder\Feature\FullOuterJoins; use Utopia\Query\Builder\Feature\FullTextSearch; -use Utopia\Query\Builder\Feature\GroupByModifiers; use Utopia\Query\Builder\Feature\InsertOrIgnore; use Utopia\Query\Builder\Feature\Json; use Utopia\Query\Builder\Feature\LateralJoins; +use Utopia\Query\Builder\Feature\NegatedFullTextSearch; use Utopia\Query\Builder\Feature\PostgreSQL\AggregateFilter; use Utopia\Query\Builder\Feature\PostgreSQL\DistinctOn; use Utopia\Query\Builder\Feature\PostgreSQL\LockingOf; @@ -18,6 +19,7 @@ use Utopia\Query\Builder\Feature\PostgreSQL\OrderedSetAggregates; use Utopia\Query\Builder\Feature\PostgreSQL\Returning; use Utopia\Query\Builder\Feature\PostgreSQL\VectorSearch; +use Utopia\Query\Builder\Feature\Rollup; use Utopia\Query\Builder\Feature\Sequences; use Utopia\Query\Builder\Feature\Spatial; use Utopia\Query\Builder\Feature\StringAggregates; @@ -46,16 +48,19 @@ class PostgreSQL extends SQL implements OrderedSetAggregates, DistinctOn, AggregateFilter, - GroupByModifiers, + Rollup, + Cube, Sequences, Spatial, FullTextSearch, + NegatedFullTextSearch, Upsert, UpsertSelect, InsertOrIgnore { use Trait\FullOuterJoins; use Trait\FullTextSearch; + use Trait\NegatedFullTextSearch; use Trait\GroupByModifiers; use Trait\LateralJoins; use Trait\PostgreSQL\AggregateFilter; diff --git a/src/Query/Builder/SQL.php b/src/Query/Builder/SQL.php index d55ede9..9d328b5 100644 --- a/src/Query/Builder/SQL.php +++ b/src/Query/Builder/SQL.php @@ -5,6 +5,7 @@ use Utopia\Query\Builder as BaseBuilder; use Utopia\Query\Builder\Feature\BitwiseAggregates; use Utopia\Query\Builder\Feature\Locking; +use Utopia\Query\Builder\Feature\RawSql; use Utopia\Query\Builder\Feature\StatisticalAggregates; use Utopia\Query\Builder\Feature\Transactions; use Utopia\Query\Method; @@ -12,12 +13,13 @@ use Utopia\Query\QuotesIdentifiers; use Utopia\Query\Schema\ColumnType; -abstract class SQL extends BaseBuilder implements Locking, Transactions, StatisticalAggregates, BitwiseAggregates +abstract class SQL extends BaseBuilder implements Locking, Transactions, StatisticalAggregates, BitwiseAggregates, RawSql { use QuotesIdentifiers; use Trait\BitwiseAggregates; use Trait\Json; use Trait\Locking; + use Trait\RawSql; use Trait\StatisticalAggregates; use Trait\Transactions; diff --git a/src/Query/Builder/Trait/FullTextSearch.php b/src/Query/Builder/Trait/FullTextSearch.php index aa2c9df..d3a1885 100644 --- a/src/Query/Builder/Trait/FullTextSearch.php +++ b/src/Query/Builder/Trait/FullTextSearch.php @@ -13,12 +13,4 @@ public function filterSearch(string $attribute, string $value): static return $this; } - - #[\Override] - public function filterNotSearch(string $attribute, string $value): static - { - $this->pendingQueries[] = Query::notSearch($attribute, $value); - - return $this; - } } diff --git a/src/Query/Builder/Trait/GroupByModifiers.php b/src/Query/Builder/Trait/GroupByModifiers.php index 35c5a14..47b26e5 100644 --- a/src/Query/Builder/Trait/GroupByModifiers.php +++ b/src/Query/Builder/Trait/GroupByModifiers.php @@ -2,30 +2,10 @@ namespace Utopia\Query\Builder\Trait; -use Utopia\Query\Exception\UnsupportedException; - trait GroupByModifiers { protected ?string $groupByModifier = null; - #[\Override] - public function withRollup(): static - { - throw new UnsupportedException('WITH ROLLUP is not supported by this dialect.'); - } - - #[\Override] - public function withCube(): static - { - throw new UnsupportedException('WITH CUBE is not supported by this dialect.'); - } - - #[\Override] - public function withTotals(): static - { - throw new UnsupportedException('WITH TOTALS is not supported by this dialect.'); - } - protected function resetGroupByModifier(): void { $this->groupByModifier = null; diff --git a/src/Query/Builder/Trait/Inserts.php b/src/Query/Builder/Trait/Inserts.php index 8ff681d..6e519e3 100644 --- a/src/Query/Builder/Trait/Inserts.php +++ b/src/Query/Builder/Trait/Inserts.php @@ -63,35 +63,6 @@ public function onConflict(array $keys, array $updateColumns): static return $this; } - /** - * @param list $bindings - */ - public function conflictSetRaw(string $column, string $expression, array $bindings = []): static - { - $this->conflictRawSets[$column] = $expression; - $this->conflictRawSetBindings[$column] = $bindings; - - return $this; - } - - /** - * Register a raw expression wrapper for a column in INSERT statements. - * - * The expression must contain exactly one `?` placeholder which will receive - * the column's value from each row. E.g. `ST_GeomFromText(?, 4326)`. - * - * @param list $extraBindings Additional bindings beyond the column value (e.g. SRID) - */ - public function insertColumnExpression(string $column, string $expression, array $extraBindings = []): static - { - $this->insertColumnExpressions[$column] = $expression; - if (! empty($extraBindings)) { - $this->insertColumnExpressionBindings[$column] = $extraBindings; - } - - return $this; - } - /** * @param list $columns */ diff --git a/src/Query/Builder/Trait/NegatedFullTextSearch.php b/src/Query/Builder/Trait/NegatedFullTextSearch.php new file mode 100644 index 0000000..d300925 --- /dev/null +++ b/src/Query/Builder/Trait/NegatedFullTextSearch.php @@ -0,0 +1,16 @@ +pendingQueries[] = Query::notSearch($attribute, $value); + + return $this; + } +} diff --git a/src/Query/Builder/Trait/RawSql.php b/src/Query/Builder/Trait/RawSql.php new file mode 100644 index 0000000..40c3b0d --- /dev/null +++ b/src/Query/Builder/Trait/RawSql.php @@ -0,0 +1,152 @@ + $bindings + */ + public function selectRaw(string $expression, array $bindings = []): static + { + return $this->select($expression, $bindings); + } + + #[\Override] + public function selectCast(string $column, string $type, string $alias = ''): static + { + if (!\preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\s+[A-Za-z_][A-Za-z0-9_]*)*(\s*\(\s*[A-Za-z0-9_,\s]+\s*\))?$/', $type)) { + throw new ValidationException('Invalid cast type: ' . $type); + } + + $expr = 'CAST(' . $this->resolveAndWrap($column) . ' AS ' . $type . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + $this->rawSelects[] = new Condition($expr, []); + + return $this; + } + + /** + * @param list $bindings + */ + public function orderByRaw(string $expression, array $bindings = []): static + { + $this->rawOrders[] = new Condition($expression, $bindings); + + return $this; + } + + /** + * @param list $bindings + */ + public function groupByRaw(string $expression, array $bindings = []): static + { + $this->rawGroups[] = new Condition($expression, $bindings); + + return $this; + } + + /** + * @param list $bindings + */ + public function havingRaw(string $expression, array $bindings = []): static + { + $this->rawHavings[] = new Condition($expression, $bindings); + + return $this; + } + + /** + * Append a raw WHERE fragment with its own bindings. + * + * Caller owns the SQL fragment - no column or operator validation is performed. + * Use this sparingly; prefer `filter()` with typed `Query::*` factories when possible. + * + * @param list $bindings + */ + public function whereRaw(string $expression, array $bindings = []): static + { + $this->rawWheres[] = new Condition($expression, $bindings); + + return $this; + } + + /** + * Append a column-to-column WHERE predicate (e.g. `users.id = orders.user_id`). + * + * Both columns are quoted per dialect. The operator is validated against + * an allowlist: =, !=, <>, <, >, <=, >=. + */ + public function whereColumn(string $left, string $operator, string $right): static + { + if (! \in_array($operator, self::COLUMN_PREDICATE_OPERATORS, true)) { + throw new ValidationException('Invalid whereColumn operator: ' . $operator); + } + + $this->columnPredicates[] = new ColumnPredicate($left, $operator, $right); + + return $this; + } + + public function selectCase(CaseExpression $case): static + { + $this->cases[] = $case; + + return $this; + } + + public function setCase(string $column, CaseExpression $case): static + { + $this->caseSets[$column] = $case; + + return $this; + } + + /** + * @param list $bindings + */ + #[\Override] + public function setRaw(string $column, string $expression, array $bindings = []): static + { + $this->rawSets[$column] = $expression; + $this->rawSetBindings[$column] = $bindings; + + return $this; + } + + /** + * @param list $bindings + */ + public function conflictSetRaw(string $column, string $expression, array $bindings = []): static + { + $this->conflictRawSets[$column] = $expression; + $this->conflictRawSetBindings[$column] = $bindings; + + return $this; + } + + /** + * Register a raw expression wrapper for a column in INSERT statements. + * + * The expression must contain exactly one `?` placeholder which will receive + * the column's value from each row. E.g. `ST_GeomFromText(?, 4326)`. + * + * @param list $extraBindings Additional bindings beyond the column value (e.g. SRID) + */ + public function insertColumnExpression(string $column, string $expression, array $extraBindings = []): static + { + $this->insertColumnExpressions[$column] = $expression; + if (! empty($extraBindings)) { + $this->insertColumnExpressionBindings[$column] = $extraBindings; + } + + return $this; + } +} diff --git a/src/Query/Builder/Trait/Selects.php b/src/Query/Builder/Trait/Selects.php index 9a42ecd..de2b8cb 100644 --- a/src/Query/Builder/Trait/Selects.php +++ b/src/Query/Builder/Trait/Selects.php @@ -4,8 +4,6 @@ use Closure; use Utopia\Query\Builder; -use Utopia\Query\Builder\Case\Expression as CaseExpression; -use Utopia\Query\Builder\ColumnPredicate; use Utopia\Query\Builder\Condition; use Utopia\Query\Builder\ExistsSubquery; use Utopia\Query\Builder\Statement; @@ -92,14 +90,6 @@ public function select(string|array $columns, array $bindings = []): static return $this; } - /** - * @param list $bindings - */ - public function selectRaw(string $expression, array $bindings = []): static - { - return $this->select($expression, $bindings); - } - #[\Override] public function distinct(): static { @@ -134,22 +124,6 @@ public function queries(array $queries): static return $this; } - #[\Override] - public function selectCast(string $column, string $type, string $alias = ''): static - { - if (!\preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\s+[A-Za-z_][A-Za-z0-9_]*)*(\s*\(\s*[A-Za-z0-9_,\s]+\s*\))?$/', $type)) { - throw new ValidationException('Invalid cast type: ' . $type); - } - - $expr = 'CAST(' . $this->resolveAndWrap($column) . ' AS ' . $type . ')'; - if ($alias !== '') { - $expr .= ' AS ' . $this->quote($alias); - } - $this->rawSelects[] = new Condition($expr, []); - - return $this; - } - #[\Override] public function sortAsc(string $attribute, ?NullsPosition $nulls = null): static { @@ -241,82 +215,6 @@ public function when(bool $condition, Closure $callback): static return $this; } - /** - * @param list $bindings - */ - public function orderByRaw(string $expression, array $bindings = []): static - { - $this->rawOrders[] = new Condition($expression, $bindings); - - return $this; - } - - /** - * @param list $bindings - */ - public function groupByRaw(string $expression, array $bindings = []): static - { - $this->rawGroups[] = new Condition($expression, $bindings); - - return $this; - } - - /** - * @param list $bindings - */ - public function havingRaw(string $expression, array $bindings = []): static - { - $this->rawHavings[] = new Condition($expression, $bindings); - - return $this; - } - - /** - * Append a raw WHERE fragment with its own bindings. - * - * Caller owns the SQL fragment - no column or operator validation is performed. - * Use this sparingly; prefer `filter()` with typed `Query::*` factories when possible. - * - * @param list $bindings - */ - public function whereRaw(string $expression, array $bindings = []): static - { - $this->rawWheres[] = new Condition($expression, $bindings); - - return $this; - } - - /** - * Append a column-to-column WHERE predicate (e.g. `users.id = orders.user_id`). - * - * Both columns are quoted per dialect. The operator is validated against - * an allowlist: =, !=, <>, <, >, <=, >=. - */ - public function whereColumn(string $left, string $operator, string $right): static - { - if (! \in_array($operator, self::COLUMN_PREDICATE_OPERATORS, true)) { - throw new ValidationException('Invalid whereColumn operator: ' . $operator); - } - - $this->columnPredicates[] = new ColumnPredicate($left, $operator, $right); - - return $this; - } - - public function selectCase(CaseExpression $case): static - { - $this->cases[] = $case; - - return $this; - } - - public function setCase(string $column, CaseExpression $case): static - { - $this->caseSets[$column] = $case; - - return $this; - } - public function beforeBuild(Closure $callback): static { $this->beforeBuildCallbacks[] = $callback; diff --git a/src/Query/Builder/Trait/Updates.php b/src/Query/Builder/Trait/Updates.php index 8db8494..b4ba2e6 100644 --- a/src/Query/Builder/Trait/Updates.php +++ b/src/Query/Builder/Trait/Updates.php @@ -8,18 +8,6 @@ trait Updates { - /** - * @param list $bindings - */ - #[\Override] - public function setRaw(string $column, string $expression, array $bindings = []): static - { - $this->rawSets[$column] = $expression; - $this->rawSetBindings[$column] = $bindings; - - return $this; - } - #[\Override] public function update(): Statement { diff --git a/tests/Query/Builder/ClickHouseTest.php b/tests/Query/Builder/ClickHouseTest.php index b3247b8..f4108ea 100644 --- a/tests/Query/Builder/ClickHouseTest.php +++ b/tests/Query/Builder/ClickHouseTest.php @@ -18,9 +18,9 @@ use Utopia\Query\Builder\Feature\ClickHouse\WithFill; use Utopia\Query\Builder\Feature\ConditionalAggregates; use Utopia\Query\Builder\Feature\CTEs; +use Utopia\Query\Builder\Feature\Cube; use Utopia\Query\Builder\Feature\Deletes; use Utopia\Query\Builder\Feature\FullOuterJoins; -use Utopia\Query\Builder\Feature\GroupByModifiers; use Utopia\Query\Builder\Feature\Hints; use Utopia\Query\Builder\Feature\Hooks; use Utopia\Query\Builder\Feature\Inserts; @@ -28,11 +28,13 @@ use Utopia\Query\Builder\Feature\Json; use Utopia\Query\Builder\Feature\Locking; use Utopia\Query\Builder\Feature\PostgreSQL\VectorSearch; +use Utopia\Query\Builder\Feature\Rollup; use Utopia\Query\Builder\Feature\Selects; use Utopia\Query\Builder\Feature\Spatial; use Utopia\Query\Builder\Feature\StatisticalAggregates; use Utopia\Query\Builder\Feature\StringAggregates; use Utopia\Query\Builder\Feature\TableSampling; +use Utopia\Query\Builder\Feature\Totals; use Utopia\Query\Builder\Feature\Transactions; use Utopia\Query\Builder\Feature\Unions; use Utopia\Query\Builder\Feature\Updates; @@ -9245,7 +9247,11 @@ public function testImplementsWithFill(): void public function testImplementsGroupByModifiers(): void { - $this->assertInstanceOf(GroupByModifiers::class, new Builder()); + $builder = new Builder(); + + $this->assertInstanceOf(Rollup::class, $builder); + $this->assertInstanceOf(Cube::class, $builder); + $this->assertInstanceOf(Totals::class, $builder); } public function testImplementsApproximateAggregates(): void diff --git a/tests/Query/Builder/MariaDBTest.php b/tests/Query/Builder/MariaDBTest.php index e00d5e3..a659ec1 100644 --- a/tests/Query/Builder/MariaDBTest.php +++ b/tests/Query/Builder/MariaDBTest.php @@ -7,10 +7,13 @@ use Utopia\Query\Builder\Case\Expression as CaseExpression; use Utopia\Query\Builder\Case\Operator; use Utopia\Query\Builder\Feature\ConditionalAggregates; +use Utopia\Query\Builder\Feature\Cube; use Utopia\Query\Builder\Feature\Hints; use Utopia\Query\Builder\Feature\Json; use Utopia\Query\Builder\Feature\LateralJoins; +use Utopia\Query\Builder\Feature\Rollup; use Utopia\Query\Builder\Feature\Sequences; +use Utopia\Query\Builder\Feature\Totals; use Utopia\Query\Builder\MariaDB as Builder; use Utopia\Query\Builder\Statement; use Utopia\Query\Compiler; @@ -28,6 +31,16 @@ public function testImplementsCompiler(): void $this->assertInstanceOf(Compiler::class, new Builder()); } + public function testInheritsRollupButNotCubeOrTotals(): void + { + $builder = new Builder(); + $interfaces = \class_implements($builder); + + $this->assertInstanceOf(Rollup::class, $builder); + $this->assertArrayNotHasKey(Cube::class, $interfaces); + $this->assertArrayNotHasKey(Totals::class, $interfaces); + } + public function testImplementsJson(): void { $this->assertInstanceOf(Json::class, new Builder()); diff --git a/tests/Query/Builder/MongoDBTest.php b/tests/Query/Builder/MongoDBTest.php index ed96b4c..73f3449 100644 --- a/tests/Query/Builder/MongoDBTest.php +++ b/tests/Query/Builder/MongoDBTest.php @@ -4,8 +4,6 @@ use PHPUnit\Framework\TestCase; use Tests\Query\AssertsBindingCount; -use Utopia\Query\Builder\Case\Expression as CaseExpression; -use Utopia\Query\Builder\Case\Operator; use Utopia\Query\Builder\Feature\Aggregates; use Utopia\Query\Builder\Feature\CTEs; use Utopia\Query\Builder\Feature\Deletes; @@ -18,6 +16,8 @@ use Utopia\Query\Builder\Feature\MongoDB\ConditionalArrayUpdates; use Utopia\Query\Builder\Feature\MongoDB\FieldUpdates; use Utopia\Query\Builder\Feature\MongoDB\PipelineStages; +use Utopia\Query\Builder\Feature\NegatedFullTextSearch; +use Utopia\Query\Builder\Feature\RawSql; use Utopia\Query\Builder\Feature\Selects; use Utopia\Query\Builder\Feature\TableSampling; use Utopia\Query\Builder\Feature\Unions; @@ -1116,14 +1116,13 @@ public function testTableSampling(): void $this->assertSame(100, $sampleBody['size']); } - public function testFilterNotSearchThrowsException(): void + public function testDoesNotExposeNegatedFullTextSearch(): void { - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('MongoDB does not support negated full-text search.'); + $builder = new Builder(); - (new Builder()) - ->from('articles') - ->filterNotSearch('content', 'bad term'); + $this->assertInstanceOf(FullTextSearch::class, $builder); + $this->assertArrayNotHasKey(NegatedFullTextSearch::class, \class_implements($builder)); + $this->assertNotContains('filterNotSearch', \get_class_methods($builder)); } public function testFilterExistsSubquery(): void @@ -1831,31 +1830,13 @@ public function testJoinWithGreaterThanOperatorThrows(): void ->build(); } - public function testUpdateWithSetRawThrows(): void + public function testDoesNotExposeRawSqlSetters(): void { - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('setRaw()/setCase()'); + $methods = \get_class_methods(new Builder()); - (new Builder()) - ->from('users') - ->setRaw('counter', 'counter + 1') - ->update(); - } - - public function testUpdateWithSetCaseThrows(): void - { - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('setRaw()/setCase()'); - - (new Builder()) - ->from('users') - ->setCase( - 'status', - (new CaseExpression()) - ->when('age', Operator::GreaterThan, 18, 'adult') - ->else('minor') - ) - ->update(); + $this->assertNotContains('setRaw', $methods); + $this->assertNotContains('setCase', $methods); + $this->assertNotContains('conflictSetRaw', $methods); } public function testWindowFunctionMultiArgumentThrows(): void @@ -5647,14 +5628,25 @@ public function testUpdateOperatorEnumValuesMatchMongoStrings(): void $this->assertSame('$currentDate', UpdateOperator::CurrentDate->value); } - public function testWhereColumnIsNotSupportedOnMongoDB(): void + public function testDoesNotImplementRawSql(): void { - $this->expectException(ValidationException::class); - $this->expectExceptionMessage('whereColumn() is not supported on the MongoDB builder.'); + $builder = new Builder(); - (new Builder()) - ->from('users') - ->whereColumn('users.id', '=', 'orders.user_id'); + $this->assertArrayNotHasKey(RawSql::class, \class_implements($builder)); + + $methods = \get_class_methods($builder); + + foreach ([ + 'selectRaw', 'selectCast', 'orderByRaw', 'groupByRaw', 'havingRaw', + 'whereRaw', 'whereColumn', 'selectCase', 'setCase', 'setRaw', + 'conflictSetRaw', 'insertColumnExpression', + ] as $method) { + $this->assertNotContains( + $method, + $methods, + "MongoDB builder must not expose {$method}()" + ); + } } } diff --git a/tests/Query/Builder/MySQLTest.php b/tests/Query/Builder/MySQLTest.php index e5f24a2..712ce16 100644 --- a/tests/Query/Builder/MySQLTest.php +++ b/tests/Query/Builder/MySQLTest.php @@ -10,6 +10,7 @@ use Utopia\Query\Builder\Condition; use Utopia\Query\Builder\Feature\Aggregates; use Utopia\Query\Builder\Feature\CTEs; +use Utopia\Query\Builder\Feature\Cube; use Utopia\Query\Builder\Feature\Deletes; use Utopia\Query\Builder\Feature\Hints; use Utopia\Query\Builder\Feature\Hooks; @@ -18,8 +19,10 @@ use Utopia\Query\Builder\Feature\Json; use Utopia\Query\Builder\Feature\Locking; use Utopia\Query\Builder\Feature\PostgreSQL\VectorSearch; +use Utopia\Query\Builder\Feature\Rollup; use Utopia\Query\Builder\Feature\Selects; use Utopia\Query\Builder\Feature\Spatial; +use Utopia\Query\Builder\Feature\Totals; use Utopia\Query\Builder\Feature\Transactions; use Utopia\Query\Builder\Feature\Unions; use Utopia\Query\Builder\Feature\Updates; @@ -50,6 +53,19 @@ public function testImplementsCompiler(): void $this->assertInstanceOf(Compiler::class, $builder); } + public function testImplementsRollup(): void + { + $this->assertInstanceOf(Rollup::class, new Builder()); + } + + public function testDoesNotImplementCubeOrTotals(): void + { + $interfaces = \class_implements(new Builder()); + + $this->assertArrayNotHasKey(Cube::class, $interfaces); + $this->assertArrayNotHasKey(Totals::class, $interfaces); + } + public function testImplementsTransactions(): void { $this->assertInstanceOf(Transactions::class, new Builder()); diff --git a/tests/Query/Builder/PostgreSQLTest.php b/tests/Query/Builder/PostgreSQLTest.php index 34f6b37..cf0e419 100644 --- a/tests/Query/Builder/PostgreSQLTest.php +++ b/tests/Query/Builder/PostgreSQLTest.php @@ -10,6 +10,7 @@ use Utopia\Query\Builder\Feature\Aggregates; use Utopia\Query\Builder\Feature\ConditionalAggregates; use Utopia\Query\Builder\Feature\CTEs; +use Utopia\Query\Builder\Feature\Cube; use Utopia\Query\Builder\Feature\Deletes; use Utopia\Query\Builder\Feature\FullOuterJoins; use Utopia\Query\Builder\Feature\Hints; @@ -21,10 +22,12 @@ use Utopia\Query\Builder\Feature\Locking; use Utopia\Query\Builder\Feature\PostgreSQL\Merge; use Utopia\Query\Builder\Feature\PostgreSQL\VectorSearch; +use Utopia\Query\Builder\Feature\Rollup; use Utopia\Query\Builder\Feature\Selects; use Utopia\Query\Builder\Feature\Sequences; use Utopia\Query\Builder\Feature\Spatial; use Utopia\Query\Builder\Feature\TableSampling; +use Utopia\Query\Builder\Feature\Totals; use Utopia\Query\Builder\Feature\Transactions; use Utopia\Query\Builder\Feature\Unions; use Utopia\Query\Builder\Feature\Updates; @@ -48,6 +51,19 @@ public function testImplementsCompiler(): void $this->assertInstanceOf(Compiler::class, new Builder()); } + public function testImplementsRollupAndCube(): void + { + $builder = new Builder(); + + $this->assertInstanceOf(Rollup::class, $builder); + $this->assertInstanceOf(Cube::class, $builder); + } + + public function testDoesNotImplementTotals(): void + { + $this->assertArrayNotHasKey(Totals::class, \class_implements(new Builder())); + } + public function testImplementsSelects(): void { $this->assertInstanceOf(Selects::class, new Builder()); diff --git a/tests/Query/Builder/SQLiteTest.php b/tests/Query/Builder/SQLiteTest.php index 55f0789..98b541d 100644 --- a/tests/Query/Builder/SQLiteTest.php +++ b/tests/Query/Builder/SQLiteTest.php @@ -7,7 +7,10 @@ use Utopia\Query\Builder\Case\Expression as CaseExpression; use Utopia\Query\Builder\Case\Operator; use Utopia\Query\Builder\Feature\ConditionalAggregates; +use Utopia\Query\Builder\Feature\Cube; use Utopia\Query\Builder\Feature\Json; +use Utopia\Query\Builder\Feature\Rollup; +use Utopia\Query\Builder\Feature\Totals; use Utopia\Query\Builder\SQLite as Builder; use Utopia\Query\Builder\Statement; use Utopia\Query\Compiler; @@ -24,6 +27,15 @@ public function testImplementsCompiler(): void $this->assertInstanceOf(Compiler::class, new Builder()); } + public function testDoesNotImplementGroupByModifiers(): void + { + $interfaces = \class_implements(new Builder()); + + $this->assertArrayNotHasKey(Rollup::class, $interfaces); + $this->assertArrayNotHasKey(Cube::class, $interfaces); + $this->assertArrayNotHasKey(Totals::class, $interfaces); + } + public function testImplementsJson(): void { $this->assertInstanceOf(Json::class, new Builder()); From b57f2884fd9ef9572caa7e3fc0f58ca0161d34aa Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 13 Aug 2026 22:53:40 +1200 Subject: [PATCH 2/7] refactor(schema): move column modifiers onto the dialects that honour them The Column class carried every modifier for every dialect, so the schema builder let you call things the target could not do. Some throws were loud, but the worse cases were silent: MongoDB accepted check(), generatedAs() and stored() and dropped them, and ttl() was accepted by all five dialects while only ClickHouse read it. Modifiers now sit with the dialects that honour them: check MySQL, PostgreSQL, SQLite (already overridden there) generatedAs MySQL, PostgreSQL, SQLite -> Column\Trait\Generated stored MySQL, PostgreSQL, SQLite -> Column\Trait\Generated virtual MySQL, SQLite -> Column\Trait\VirtualGenerated ttl ClickHouse userType PostgreSQL virtual() is a separate trait from generatedAs()/stored() because PostgreSQL supports STORED only; that constraint is now in the type rather than in a throw from compileGeneratedClause(). dropColumn()/renameColumn() move off Table into Table\Trait\ColumnAlterations, used by the four SQL-family dialects. MongoDB reshapes documents with $unset/$rename, so its Table no longer offers a schema-level equivalent it would only reject. The matching forwarders move from Column and ForeignKey into the per-dialect Forwarder traits, following the existing pattern. Six throws are deleted as unreachable: user-defined types on MySQL, SQLite, ClickHouse and MongoDB; generated columns and CHECK on ClickHouse; VIRTUAL generated columns on PostgreSQL; and MongoDB's composite-primary-key guard, whose Table never used Trait\CompositePrimary and so could never set the field. No behaviour change for supported combinations -- every dialect emits byte-identical DDL. The generics already in place (@template TColumn, @extends Table) mean chained calls still resolve to the dialect column, so callers see the narrowed surface statically. Co-Authored-By: Claude Opus 5 --- src/Query/Schema/ClickHouse.php | 12 --- src/Query/Schema/Column.php | 86 ------------------- src/Query/Schema/Column/ClickHouse.php | 21 +++++ src/Query/Schema/Column/MySQL.php | 2 + src/Query/Schema/Column/PostgreSQL.php | 17 ++++ src/Query/Schema/Column/SQLite.php | 2 + src/Query/Schema/Column/Trait/Generated.php | 27 ++++++ .../Schema/Column/Trait/VirtualGenerated.php | 17 ++++ src/Query/Schema/ForeignKey.php | 12 --- src/Query/Schema/Forwarder/ClickHouse.php | 9 ++ src/Query/Schema/Forwarder/MySQL.php | 9 ++ src/Query/Schema/Forwarder/PostgreSQL.php | 9 ++ src/Query/Schema/Forwarder/SQLite.php | 9 ++ src/Query/Schema/MongoDB.php | 12 --- src/Query/Schema/MySQL.php | 4 - src/Query/Schema/PostgreSQL.php | 12 +-- src/Query/Schema/SQLite.php | 4 - src/Query/Schema/Table.php | 14 --- src/Query/Schema/Table/ClickHouse.php | 1 + src/Query/Schema/Table/MySQL.php | 1 + src/Query/Schema/Table/PostgreSQL.php | 1 + src/Query/Schema/Table/SQLite.php | 1 + .../Schema/Table/Trait/ColumnAlterations.php | 27 ++++++ tests/Query/Schema/MongoDBTest.php | 22 ++--- tests/Query/Schema/MySQLTest.php | 11 +-- tests/Query/Schema/PostgreSQLTest.php | 4 +- tests/Query/Schema/SQLiteTest.php | 11 +-- tests/Query/Schema/TableTest.php | 21 +++-- 28 files changed, 185 insertions(+), 193 deletions(-) create mode 100644 src/Query/Schema/Column/Trait/Generated.php create mode 100644 src/Query/Schema/Column/Trait/VirtualGenerated.php create mode 100644 src/Query/Schema/Table/Trait/ColumnAlterations.php diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php index bedea0a..c172bf4 100644 --- a/src/Query/Schema/ClickHouse.php +++ b/src/Query/Schema/ClickHouse.php @@ -30,10 +30,6 @@ public function table(string $name): Table\ClickHouse protected function compileColumnType(Column $column): string { - if ($column->userTypeName !== null) { - throw new UnsupportedException('User-defined types are not supported in ClickHouse.'); - } - if ($column instanceof Column\ClickHouse && $column->isFixedString()) { $type = 'FixedString(' . $column->fixedStringLength . ')'; @@ -135,14 +131,6 @@ protected function compileUnsigned(): string protected function compileColumnDefinition(Column $column): string { - if ($column->generatedExpression !== null) { - throw new UnsupportedException('Generated columns are not supported in ClickHouse.'); - } - - if ($column->checkExpression !== null) { - throw new UnsupportedException('CHECK constraints are not supported in ClickHouse.'); - } - $parts = [ $this->quoteLiteral($column->name), $this->compileColumnType($column), diff --git a/src/Query/Schema/Column.php b/src/Query/Schema/Column.php index 51af14d..21a0773 100644 --- a/src/Query/Schema/Column.php +++ b/src/Query/Schema/Column.php @@ -216,80 +216,6 @@ public function modify(): static return $this; } - /** - * Attach a column-level CHECK constraint. Dialect Column subclasses that - * support table-level CHECK constraints also accept a name and expression - * pair to declare a named table-level CHECK. - */ - public function check(string $expression): static|Table - { - $this->checkExpression = $expression; - - return $this; - } - - /** - * Mark the column as a generated column computed from the given expression. - */ - public function generatedAs(string $expression): static - { - $this->generatedExpression = $expression; - - return $this; - } - - public function stored(): static - { - $this->generatedStored = true; - - return $this; - } - - public function virtual(): static - { - $this->generatedStored = false; - - return $this; - } - - /** - * Attach a column-level TTL expression (ClickHouse only). - * - * @throws ValidationException if the expression is empty or contains a semicolon. - */ - public function ttl(string $expression): static - { - $trimmed = \trim($expression); - - if ($trimmed === '') { - throw new ValidationException('TTL expression must not be empty.'); - } - - if (\str_contains($trimmed, ';')) { - throw new ValidationException('TTL expression must not contain ";".'); - } - - $this->ttl = $trimmed; - - return $this; - } - - /** - * Reference a user-defined type (e.g. a PostgreSQL enum type created via CREATE TYPE). - * - * @throws ValidationException if $name is not a valid identifier. - */ - public function userType(string $name): static - { - if (! \preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name)) { - throw new ValidationException('Invalid user-defined type name: ' . $name); - } - - $this->userTypeName = $name; - - return $this; - } - public function id(string $name = 'id'): static { /** @var static */ @@ -446,18 +372,6 @@ public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPreci return $this->table->modifyColumn($name, $type, $lengthOrPrecision); } - /** @return TTable */ - public function renameColumn(string $from, string $to): Table - { - return $this->table->renameColumn($from, $to); - } - - /** @return TTable */ - public function dropColumn(string $name): Table - { - return $this->table->dropColumn($name); - } - /** * @param string[] $columns * @param array $lengths diff --git a/src/Query/Schema/Column/ClickHouse.php b/src/Query/Schema/Column/ClickHouse.php index eee41ec..78c7c48 100644 --- a/src/Query/Schema/Column/ClickHouse.php +++ b/src/Query/Schema/Column/ClickHouse.php @@ -149,6 +149,27 @@ public function codec(string $codec): static $this->codecs[] = $trimmed; + return $this; + } + /** + * Attach a column-level TTL expression. + * + * @throws ValidationException if the expression is empty or contains a semicolon. + */ + public function ttl(string $expression): static + { + $trimmed = \trim($expression); + + if ($trimmed === '') { + throw new ValidationException('TTL expression must not be empty.'); + } + + if (\str_contains($trimmed, ';')) { + throw new ValidationException('TTL expression must not contain ";".'); + } + + $this->ttl = $trimmed; + return $this; } } diff --git a/src/Query/Schema/Column/MySQL.php b/src/Query/Schema/Column/MySQL.php index 6a34499..dc57dbc 100644 --- a/src/Query/Schema/Column/MySQL.php +++ b/src/Query/Schema/Column/MySQL.php @@ -11,6 +11,8 @@ */ class MySQL extends Column { + use Trait\Generated; + use Trait\VirtualGenerated; use Forwarder\MySQL; /** diff --git a/src/Query/Schema/Column/PostgreSQL.php b/src/Query/Schema/Column/PostgreSQL.php index aeafb50..f3ae8b7 100644 --- a/src/Query/Schema/Column/PostgreSQL.php +++ b/src/Query/Schema/Column/PostgreSQL.php @@ -2,6 +2,7 @@ namespace Utopia\Query\Schema\Column; +use Utopia\Query\Exception\ValidationException; use Utopia\Query\Schema\Column; use Utopia\Query\Schema\Forwarder; use Utopia\Query\Schema\Table; @@ -11,6 +12,7 @@ */ class PostgreSQL extends Column { + use Trait\Generated; use Forwarder\PostgreSQL; /** @@ -42,4 +44,19 @@ public function check(string $expressionOrName, ?string $expression = null): sta return $this->table->check($expressionOrName, $expression); } + /** + * Reference a user-defined type (e.g. a PostgreSQL enum type created via CREATE TYPE). + * + * @throws ValidationException if $name is not a valid identifier. + */ + public function userType(string $name): static + { + if (! \preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name)) { + throw new ValidationException('Invalid user-defined type name: ' . $name); + } + + $this->userTypeName = $name; + + return $this; + } } diff --git a/src/Query/Schema/Column/SQLite.php b/src/Query/Schema/Column/SQLite.php index 39649c2..2045a10 100644 --- a/src/Query/Schema/Column/SQLite.php +++ b/src/Query/Schema/Column/SQLite.php @@ -11,6 +11,8 @@ */ class SQLite extends Column { + use Trait\Generated; + use Trait\VirtualGenerated; use Forwarder\SQLite; /** diff --git a/src/Query/Schema/Column/Trait/Generated.php b/src/Query/Schema/Column/Trait/Generated.php new file mode 100644 index 0000000..baac938 --- /dev/null +++ b/src/Query/Schema/Column/Trait/Generated.php @@ -0,0 +1,27 @@ +generatedExpression = $expression; + + return $this; + } + + public function stored(): static + { + $this->generatedStored = true; + + return $this; + } +} diff --git a/src/Query/Schema/Column/Trait/VirtualGenerated.php b/src/Query/Schema/Column/Trait/VirtualGenerated.php new file mode 100644 index 0000000..17f1dd1 --- /dev/null +++ b/src/Query/Schema/Column/Trait/VirtualGenerated.php @@ -0,0 +1,17 @@ +generatedStored = false; + + return $this; + } +} diff --git a/src/Query/Schema/ForeignKey.php b/src/Query/Schema/ForeignKey.php index 2cfb079..2d0b9d0 100644 --- a/src/Query/Schema/ForeignKey.php +++ b/src/Query/Schema/ForeignKey.php @@ -196,18 +196,6 @@ public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPreci return $this->table->modifyColumn($name, $type, $lengthOrPrecision); } - /** @return TTable */ - public function renameColumn(string $from, string $to): Table - { - return $this->table->renameColumn($from, $to); - } - - /** @return TTable */ - public function dropColumn(string $name): Table - { - return $this->table->dropColumn($name); - } - /** * @param string[] $columns * @param array $lengths diff --git a/src/Query/Schema/Forwarder/ClickHouse.php b/src/Query/Schema/Forwarder/ClickHouse.php index 3d98b46..49dcc0a 100644 --- a/src/Query/Schema/Forwarder/ClickHouse.php +++ b/src/Query/Schema/Forwarder/ClickHouse.php @@ -72,4 +72,13 @@ public function sampleBy(string $expression): Table\ClickHouse { return $this->table->sampleBy($expression); } + public function renameColumn(string $from, string $to): Table\ClickHouse + { + return $this->table->renameColumn($from, $to); + } + + public function dropColumn(string $name): Table\ClickHouse + { + return $this->table->dropColumn($name); + } } diff --git a/src/Query/Schema/Forwarder/MySQL.php b/src/Query/Schema/Forwarder/MySQL.php index 1592f07..df49e63 100644 --- a/src/Query/Schema/Forwarder/MySQL.php +++ b/src/Query/Schema/Forwarder/MySQL.php @@ -58,4 +58,13 @@ public function spatialIndex(array $columns, string $name = ''): Table\MySQL return $this->table->spatialIndex($columns, $name); } + public function renameColumn(string $from, string $to): Table\MySQL + { + return $this->table->renameColumn($from, $to); + } + + public function dropColumn(string $name): Table\MySQL + { + return $this->table->dropColumn($name); + } } diff --git a/src/Query/Schema/Forwarder/PostgreSQL.php b/src/Query/Schema/Forwarder/PostgreSQL.php index ddda5a2..850906d 100644 --- a/src/Query/Schema/Forwarder/PostgreSQL.php +++ b/src/Query/Schema/Forwarder/PostgreSQL.php @@ -63,4 +63,13 @@ public function vector(string $name, int $dimensions): Column\PostgreSQL return $this->table->vector($name, $dimensions); } + public function renameColumn(string $from, string $to): Table\PostgreSQL + { + return $this->table->renameColumn($from, $to); + } + + public function dropColumn(string $name): Table\PostgreSQL + { + return $this->table->dropColumn($name); + } } diff --git a/src/Query/Schema/Forwarder/SQLite.php b/src/Query/Schema/Forwarder/SQLite.php index 9f8623c..e963271 100644 --- a/src/Query/Schema/Forwarder/SQLite.php +++ b/src/Query/Schema/Forwarder/SQLite.php @@ -20,4 +20,13 @@ public function foreignKey(string $column): ForeignKey\SQLite { return $this->table->foreignKey($column); } + public function renameColumn(string $from, string $to): Table\SQLite + { + return $this->table->renameColumn($from, $to); + } + + public function dropColumn(string $name): Table\SQLite + { + return $this->table->dropColumn($name); + } } diff --git a/src/Query/Schema/MongoDB.php b/src/Query/Schema/MongoDB.php index d3a6a9c..d75f0c9 100644 --- a/src/Query/Schema/MongoDB.php +++ b/src/Query/Schema/MongoDB.php @@ -31,10 +31,6 @@ protected function quoteLiteral(string $identifier): string protected function compileColumnType(Column $column): string { - if ($column->userTypeName !== null) { - throw new UnsupportedException('User-defined types are not supported in MongoDB.'); - } - return match ($column->type) { ColumnType::String, ColumnType::Varchar, ColumnType::Relationship => 'string', ColumnType::Text, ColumnType::MediumText, ColumnType::LongText => 'string', @@ -64,10 +60,6 @@ protected function compileAutoIncrement(): string #[\Override] public function compileCreate(Table $table, bool $ifNotExists = false): Statement { - if (! empty($table->compositePrimaryKey)) { - throw new UnsupportedException('Composite primary keys are not supported in MongoDB; documents use "_id" implicitly.'); - } - $properties = []; $required = []; @@ -122,10 +114,6 @@ public function compileCreate(Table $table, bool $ifNotExists = false): Statemen #[\Override] public function compileAlter(Table $table): Statement { - if (! empty($table->dropColumns) || ! empty($table->renameColumns)) { - throw new UnsupportedException('MongoDB does not support dropping or renaming columns via schema. Use $unset/$rename update operators.'); - } - $properties = []; $required = []; diff --git a/src/Query/Schema/MySQL.php b/src/Query/Schema/MySQL.php index b5747de..93da7a8 100644 --- a/src/Query/Schema/MySQL.php +++ b/src/Query/Schema/MySQL.php @@ -49,10 +49,6 @@ public function table(string $name): Table\MySQL protected function compileColumnType(Column $column): string { - if ($column->userTypeName !== null) { - throw new UnsupportedException('User-defined types are not supported in MySQL.'); - } - return match ($column->type) { ColumnType::String, ColumnType::Varchar, ColumnType::Relationship => 'VARCHAR(' . ($column->length ?? 255) . ')', ColumnType::Text => 'TEXT', diff --git a/src/Query/Schema/PostgreSQL.php b/src/Query/Schema/PostgreSQL.php index 616886f..06831d2 100644 --- a/src/Query/Schema/PostgreSQL.php +++ b/src/Query/Schema/PostgreSQL.php @@ -162,20 +162,12 @@ protected function compileColumnDefinition(Column $column): string } /** - * PostgreSQL only supports STORED generated columns. Virtual generated columns - * are rejected with UnsupportedException. - * - * @throws UnsupportedException if a VIRTUAL generated column is requested. + * PostgreSQL supports STORED generated columns only, which is why + * {@see Column\PostgreSQL} does not expose virtual(). */ #[\Override] protected function compileGeneratedClause(Column $column): string { - if ($column->generatedStored === false) { - throw new UnsupportedException( - 'PostgreSQL does not support VIRTUAL generated columns. Use stored() instead.' - ); - } - return 'GENERATED ALWAYS AS (' . $column->generatedExpression . ') STORED'; } diff --git a/src/Query/Schema/SQLite.php b/src/Query/Schema/SQLite.php index 8ed59b3..e749135 100644 --- a/src/Query/Schema/SQLite.php +++ b/src/Query/Schema/SQLite.php @@ -18,10 +18,6 @@ public function table(string $name): Table\SQLite protected function compileColumnType(Column $column): string { - if ($column->userTypeName !== null) { - throw new UnsupportedException('User-defined types are not supported in SQLite.'); - } - return match ($column->type) { ColumnType::String, ColumnType::Varchar, ColumnType::Relationship => 'VARCHAR(' . ($column->length ?? 255) . ')', ColumnType::Text, ColumnType::MediumText, ColumnType::LongText => 'TEXT', diff --git a/src/Query/Schema/Table.php b/src/Query/Schema/Table.php index da8331d..b69e562 100644 --- a/src/Query/Schema/Table.php +++ b/src/Query/Schema/Table.php @@ -520,20 +520,6 @@ public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPreci return $col; } - public function renameColumn(string $from, string $to): static - { - $this->renameColumns[] = new RenameColumn($from, $to); - - return $this; - } - - public function dropColumn(string $name): static - { - $this->dropColumns[] = $name; - - return $this; - } - /** * @param string[] $columns * @param array $lengths diff --git a/src/Query/Schema/Table/ClickHouse.php b/src/Query/Schema/Table/ClickHouse.php index 4ae2100..8db2775 100644 --- a/src/Query/Schema/Table/ClickHouse.php +++ b/src/Query/Schema/Table/ClickHouse.php @@ -14,6 +14,7 @@ */ class ClickHouse extends Table { + use Trait\ColumnAlterations; use Trait\CompositePrimary; /** ClickHouse SAMPLE BY expression. Emitted after ORDER BY when set. */ diff --git a/src/Query/Schema/Table/MySQL.php b/src/Query/Schema/Table/MySQL.php index 19a3e72..b61d053 100644 --- a/src/Query/Schema/Table/MySQL.php +++ b/src/Query/Schema/Table/MySQL.php @@ -13,6 +13,7 @@ class MySQL extends Table { use Trait\Checks; + use Trait\ColumnAlterations; use Trait\CompositePrimary; /** @use Trait\ForeignKeys */ use Trait\ForeignKeys; diff --git a/src/Query/Schema/Table/PostgreSQL.php b/src/Query/Schema/Table/PostgreSQL.php index a6bc755..1465808 100644 --- a/src/Query/Schema/Table/PostgreSQL.php +++ b/src/Query/Schema/Table/PostgreSQL.php @@ -13,6 +13,7 @@ class PostgreSQL extends Table { use Trait\Checks; + use Trait\ColumnAlterations; use Trait\CompositePrimary; /** @use Trait\ForeignKeys */ use Trait\ForeignKeys; diff --git a/src/Query/Schema/Table/SQLite.php b/src/Query/Schema/Table/SQLite.php index fe0218e..6d8008f 100644 --- a/src/Query/Schema/Table/SQLite.php +++ b/src/Query/Schema/Table/SQLite.php @@ -13,6 +13,7 @@ class SQLite extends Table { use Trait\Checks; + use Trait\ColumnAlterations; use Trait\CompositePrimary; /** @use Trait\InlineForeignKey */ use Trait\InlineForeignKey; diff --git a/src/Query/Schema/Table/Trait/ColumnAlterations.php b/src/Query/Schema/Table/Trait/ColumnAlterations.php new file mode 100644 index 0000000..59d33ba --- /dev/null +++ b/src/Query/Schema/Table/Trait/ColumnAlterations.php @@ -0,0 +1,27 @@ +renameColumns[] = new RenameColumn($from, $to); + + return $this; + } + + public function dropColumn(string $name): static + { + $this->dropColumns[] = $name; + + return $this; + } +} diff --git a/tests/Query/Schema/MongoDBTest.php b/tests/Query/Schema/MongoDBTest.php index 93909be..7e40b9c 100644 --- a/tests/Query/Schema/MongoDBTest.php +++ b/tests/Query/Schema/MongoDBTest.php @@ -4,7 +4,6 @@ use PHPUnit\Framework\TestCase; use Utopia\Query\Builder\MongoDB as Builder; -use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Query; use Utopia\Query\Schema\ColumnType; use Utopia\Query\Schema\MongoDB as Schema; @@ -304,26 +303,17 @@ public function testAlterWithColumnComment(): void $this->assertSame('User phone number', $props['phone']['description']); } - public function testAlterDropColumnThrows(): void + public function testTableDoesNotExposeColumnAlterations(): void { - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('MongoDB does not support dropping or renaming columns via schema'); + $methods = \get_class_methods((new Schema())->table('users')); - $schema = new Schema(); - $schema->table('users') - ->dropColumn('old_field') - ->alter(); + $this->assertNotContains('dropColumn', $methods); + $this->assertNotContains('renameColumn', $methods); } - public function testAlterRenameColumnThrows(): void + public function testTableDoesNotExposeCompositePrimaryKey(): void { - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('MongoDB does not support dropping or renaming columns via schema'); - - $schema = new Schema(); - $schema->table('users') - ->renameColumn('old_name', 'new_name') - ->alter(); + $this->assertNotContains('primary', \get_class_methods((new Schema())->table('users'))); } public function testCreateView(): void diff --git a/tests/Query/Schema/MySQLTest.php b/tests/Query/Schema/MySQLTest.php index 1d6dfcf..0ea34e6 100644 --- a/tests/Query/Schema/MySQLTest.php +++ b/tests/Query/Schema/MySQLTest.php @@ -5,7 +5,6 @@ use PHPUnit\Framework\TestCase; use Tests\Query\AssertsBindingCount; use Utopia\Query\Builder\MySQL as SQLBuilder; -use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Query; use Utopia\Query\Schema\Column; @@ -1206,15 +1205,11 @@ public function testBigSerialColumnMapsToBigIntWithAutoIncrement(): void $this->assertSame('CREATE TABLE `t` (`id` BIGINT AUTO_INCREMENT NOT NULL, PRIMARY KEY (`id`))', $result->query); } - public function testUserTypeColumnThrowsUnsupported(): void + public function testColumnDoesNotExposeUserType(): void { - $this->expectException(UnsupportedException::class); + $column = (new Schema())->table('t')->string('mood'); - $schema = new Schema(); - $schema->table('t') - ->integer('id')->primary() - ->string('mood')->userType('mood_type') - ->create(); + $this->assertNotContains('userType', \get_class_methods($column)); } public function testTinyIntegerColumn(): void diff --git a/tests/Query/Schema/PostgreSQLTest.php b/tests/Query/Schema/PostgreSQLTest.php index 906d83e..495af3b 100644 --- a/tests/Query/Schema/PostgreSQLTest.php +++ b/tests/Query/Schema/PostgreSQLTest.php @@ -7,7 +7,7 @@ use Utopia\Query\Builder\PostgreSQL as PgBuilder; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Query; -use Utopia\Query\Schema\Column; +use Utopia\Query\Schema\Column\PostgreSQL as PostgreSQLColumn; use Utopia\Query\Schema\ColumnType; use Utopia\Query\Schema\Feature\ColumnComments; use Utopia\Query\Schema\Feature\CreatePartition; @@ -1297,7 +1297,7 @@ public function testUserTypeRejectsInvalidIdentifier(): void $this->expectExceptionMessage('Invalid user-defined type name'); $bp = (new Schema())->table('t'); - $col = new Column($bp, 'mood', ColumnType::String); + $col = new PostgreSQLColumn($bp, 'mood', ColumnType::String); $col->userType('bad; DROP TABLE users'); } diff --git a/tests/Query/Schema/SQLiteTest.php b/tests/Query/Schema/SQLiteTest.php index a9583a5..8c1b2f1 100644 --- a/tests/Query/Schema/SQLiteTest.php +++ b/tests/Query/Schema/SQLiteTest.php @@ -5,7 +5,6 @@ use PHPUnit\Framework\TestCase; use Tests\Query\AssertsBindingCount; use Utopia\Query\Builder\SQLite as SQLBuilder; -use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Query; use Utopia\Query\Schema\ColumnType; @@ -546,15 +545,11 @@ public function testSerialColumnMapsToInteger(): void $this->assertSame('CREATE TABLE `t` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)', $result->query); } - public function testUserTypeColumnThrowsUnsupported(): void + public function testColumnDoesNotExposeUserType(): void { - $this->expectException(UnsupportedException::class); + $column = (new Schema())->table('t')->string('mood'); - $schema = new Schema(); - $schema->table('t') - ->integer('id')->primary() - ->string('mood')->userType('mood_type') - ->create(); + $this->assertNotContains('userType', \get_class_methods($column)); } public function testTinyIntegerMapsToInteger(): void diff --git a/tests/Query/Schema/TableTest.php b/tests/Query/Schema/TableTest.php index 2cdbeac..dbd1eb5 100644 --- a/tests/Query/Schema/TableTest.php +++ b/tests/Query/Schema/TableTest.php @@ -5,11 +5,13 @@ use PHPUnit\Framework\TestCase; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Schema\CheckConstraint; -use Utopia\Query\Schema\Column; +use Utopia\Query\Schema\Column\MySQL as MySQLColumn; +use Utopia\Query\Schema\Column\PostgreSQL as Column; use Utopia\Query\Schema\ColumnType; use Utopia\Query\Schema\ForeignKey; use Utopia\Query\Schema\Index; use Utopia\Query\Schema\RenameColumn; +use Utopia\Query\Schema\Table\MySQL as MySQLTable; use Utopia\Query\Schema\Table\PostgreSQL as Table; class TableTest extends TestCase @@ -437,8 +439,8 @@ public function testColumnCheckAttachesExpression(): void public function testColumnGeneratedAsDefaultsToVirtualOnCompile(): void { - $bp = new Table(); - $col = new Column($bp, 'area', ColumnType::Integer); + $bp = new MySQLTable(); + $col = new MySQLColumn($bp, 'area', ColumnType::Integer); $col->generatedAs('`width` * `height`'); $this->assertSame('`width` * `height`', $col->generatedExpression); @@ -447,8 +449,8 @@ public function testColumnGeneratedAsDefaultsToVirtualOnCompile(): void public function testColumnStoredAndVirtualAreMutuallyExclusive(): void { - $bp = new Table(); - $col = new Column($bp, 'area', ColumnType::Integer); + $bp = new MySQLTable(); + $col = new MySQLColumn($bp, 'area', ColumnType::Integer); $col->generatedAs('`width` * `height`')->stored(); $this->assertTrue($col->generatedStored); @@ -459,6 +461,15 @@ public function testColumnStoredAndVirtualAreMutuallyExclusive(): void $this->assertTrue($col->generatedStored); } + public function testPostgreSQLColumnDoesNotExposeVirtual(): void + { + $methods = \get_class_methods(new Column((new Table()), 'area', ColumnType::Integer)); + + $this->assertContains('generatedAs', $methods); + $this->assertContains('stored', $methods); + $this->assertNotContains('virtual', $methods); + } + public function testPartitionByHashWithCount(): void { $bp = new Table(); From 9425a11072e81173c47b1aa31c31da164287a327 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 13 Aug 2026 22:55:02 +1200 Subject: [PATCH 3/7] refactor(exception): use ValidationException for parse and misuse failures Six throws described neither a missing capability nor an unsupported dialect feature, so UnsupportedException was the wrong type: - Table::compile* when the Table has no Schema. Constructing a detached Table and compiling it is a usage error, not an unsupported operation. - Five json_decode() === null guards in the MongoDB builder and schema (view creation, unions, WHERE IN and EXISTS subqueries, $facet). These fire when a nested builder yields something that is not a JSON operation document -- a malformed input, not a capability limit. All six now raise ValidationException, matching how the rest of the codebase reports bad input. Co-Authored-By: Claude Opus 5 --- src/Query/Builder/MongoDB.php | 6 +++--- src/Query/Builder/Trait/MongoDB/PipelineStages.php | 4 ++-- src/Query/Schema/MongoDB.php | 4 ++-- src/Query/Schema/Table.php | 3 +-- tests/Query/Schema/FluentBuilderTest.php | 11 +++++------ 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/Query/Builder/MongoDB.php b/src/Query/Builder/MongoDB.php index f27d2c1..cb240ad 100644 --- a/src/Query/Builder/MongoDB.php +++ b/src/Query/Builder/MongoDB.php @@ -717,7 +717,7 @@ private function appendUnionStages(array &$pipeline): void /** @var array|null $subOp */ $subOp = \json_decode($union->query, true); if ($subOp === null) { - throw new UnsupportedException('Cannot parse union query for MongoDB.'); + throw new ValidationException('Cannot parse union query for MongoDB.'); } $subPipeline = $this->operationToPipeline($subOp); @@ -1468,7 +1468,7 @@ private function buildWhereInSubquery(WhereInSubquery $sub, int $idx): array /** @var array|null $subOp */ $subOp = \json_decode($subResult->query, true); if ($subOp === null) { - throw new UnsupportedException('Cannot parse subquery for MongoDB WHERE IN.'); + throw new ValidationException('Cannot parse subquery for MongoDB WHERE IN.'); } $this->addBindings($subResult->bindings); @@ -1521,7 +1521,7 @@ private function buildExistsSubquery(ExistsSubquery $sub, int $idx): array /** @var array|null $subOp */ $subOp = \json_decode($subResult->query, true); if ($subOp === null) { - throw new UnsupportedException('Cannot parse subquery for MongoDB EXISTS.'); + throw new ValidationException('Cannot parse subquery for MongoDB EXISTS.'); } $this->addBindings($subResult->bindings); diff --git a/src/Query/Builder/Trait/MongoDB/PipelineStages.php b/src/Query/Builder/Trait/MongoDB/PipelineStages.php index 5e3237e..a832480 100644 --- a/src/Query/Builder/Trait/MongoDB/PipelineStages.php +++ b/src/Query/Builder/Trait/MongoDB/PipelineStages.php @@ -2,7 +2,7 @@ namespace Utopia\Query\Builder\Trait\MongoDB; -use Utopia\Query\Exception\UnsupportedException; +use Utopia\Query\Exception\ValidationException; trait PipelineStages { @@ -58,7 +58,7 @@ public function facet(array $facets): static /** @var array|null $subOp */ $subOp = \json_decode($result->query, true); if ($subOp === null) { - throw new UnsupportedException('Cannot parse facet query for MongoDB.'); + throw new ValidationException('Cannot parse facet query for MongoDB.'); } $this->facetStages[$name] = [ 'pipeline' => $this->operationToPipeline($subOp), diff --git a/src/Query/Schema/MongoDB.php b/src/Query/Schema/MongoDB.php index d75f0c9..bdb6e11 100644 --- a/src/Query/Schema/MongoDB.php +++ b/src/Query/Schema/MongoDB.php @@ -5,7 +5,7 @@ use stdClass; use Utopia\Query\Builder; use Utopia\Query\Builder\Statement; -use Utopia\Query\Exception\UnsupportedException; +use Utopia\Query\Exception\ValidationException; use Utopia\Query\Schema; use Utopia\Query\Schema\Feature\AnalyzeTable; use Utopia\Query\Schema\Feature\Databases; @@ -269,7 +269,7 @@ public function createView(string $name, Builder $query): Statement /** @var array|null $op */ $op = \json_decode($result->query, true); if ($op === null) { - throw new UnsupportedException('Cannot parse query for MongoDB view creation.'); + throw new ValidationException('Cannot parse query for MongoDB view creation.'); } $command = [ diff --git a/src/Query/Schema/Table.php b/src/Query/Schema/Table.php index b69e562..9d94a54 100644 --- a/src/Query/Schema/Table.php +++ b/src/Query/Schema/Table.php @@ -3,7 +3,6 @@ namespace Utopia\Query\Schema; use Utopia\Query\Builder\Statement; -use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Schema; use Utopia\Query\Schema\ClickHouse\Engine; @@ -108,7 +107,7 @@ public function rename(string $to): Statement private function requireSchema(): Schema { if ($this->schema === null) { - throw new UnsupportedException('Cannot compile a Table without a Schema. Use Schema::table($name) to obtain a builder.'); + throw new ValidationException('Cannot compile a Table without a Schema. Use Schema::table($name) to obtain a builder.'); } return $this->schema; diff --git a/tests/Query/Schema/FluentBuilderTest.php b/tests/Query/Schema/FluentBuilderTest.php index 5705b1a..4a705bc 100644 --- a/tests/Query/Schema/FluentBuilderTest.php +++ b/tests/Query/Schema/FluentBuilderTest.php @@ -4,7 +4,6 @@ use PHPUnit\Framework\TestCase; use Tests\Query\AssertsBindingCount; -use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Schema\ClickHouse; use Utopia\Query\Schema\ClickHouse\Engine; @@ -430,7 +429,7 @@ public function testDetachedTableThrowsOnCreate(): void $bp = new Table(); $bp->string('name'); - $this->expectException(UnsupportedException::class); + $this->expectException(ValidationException::class); $this->expectExceptionMessage('Cannot compile a Table without a Schema'); $bp->create(); } @@ -440,7 +439,7 @@ public function testDetachedTableThrowsOnAlter(): void $bp = new Table(); $bp->dropColumn('x'); - $this->expectException(UnsupportedException::class); + $this->expectException(ValidationException::class); $bp->alter(); } @@ -448,7 +447,7 @@ public function testDetachedTableThrowsOnDrop(): void { $bp = new Table(); - $this->expectException(UnsupportedException::class); + $this->expectException(ValidationException::class); $bp->drop(); } @@ -456,7 +455,7 @@ public function testDetachedTableThrowsOnTruncate(): void { $bp = new Table(); - $this->expectException(UnsupportedException::class); + $this->expectException(ValidationException::class); $bp->truncate(); } @@ -464,7 +463,7 @@ public function testDetachedTableThrowsOnRename(): void { $bp = new Table(); - $this->expectException(UnsupportedException::class); + $this->expectException(ValidationException::class); $bp->rename('to'); } From e6e560087b469aa9ce7f6705a8e8cf8877cf8a9e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 13 Aug 2026 22:58:07 +1200 Subject: [PATCH 4/7] refactor: split CrossJoins out of Joins, drop dead ClickHouse guards Feature\Joins required crossJoin() and naturalJoin(), which MongoDB cannot express: $lookup always joins on a field pair. Both move to Feature\CrossJoins, implemented by Builder\SQL and ClickHouse, so the MongoDB builder no longer offers them. The compileJoinStage() guard stays, because Query::crossJoin() can still arrive through queries() at runtime -- that path is a value, not a method, so the type system cannot close it. Its message now says so, and a test covers it. Three ClickHouse foreign-key guards and one CHECK guard were already dead: Table\ClickHouse uses neither Trait\ForeignKeys nor Trait\InlineForeignKey and its Forwarder has no foreignKey(), so foreignKeys/dropForeignKeys could never be populated, and after the previous commit neither Table\ClickHouse nor Column\ClickHouse exposes check(). Deleted. Builder::applyAstJoins() went through crossJoin()/naturalJoin(); like applyAstOrderBy() before it, it now appends the Query directly so the base class does not depend on a capability its subclasses may lack. Co-Authored-By: Claude Opus 5 --- src/Query/Builder.php | 4 ++-- src/Query/Builder/ClickHouse.php | 4 +++- src/Query/Builder/Feature/CrossJoins.php | 16 ++++++++++++++++ src/Query/Builder/Feature/Joins.php | 4 ---- src/Query/Builder/MongoDB.php | 5 ++++- src/Query/Builder/SQL.php | 4 +++- src/Query/Builder/Trait/CrossJoins.php | 24 ++++++++++++++++++++++++ src/Query/Builder/Trait/Joins.php | 16 ---------------- src/Query/Schema/ClickHouse.php | 16 ---------------- tests/Query/Builder/MongoDBTest.php | 23 +++++++++++++++++------ 10 files changed, 69 insertions(+), 47 deletions(-) create mode 100644 src/Query/Builder/Feature/CrossJoins.php create mode 100644 src/Query/Builder/Trait/CrossJoins.php diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 53c8abd..eb2f8c9 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -2513,12 +2513,12 @@ private function applyAstJoins(Select $ast): void $type = \strtoupper($join->type); if ($type === 'CROSS JOIN') { - $this->crossJoin($table, $alias); + $this->pendingQueries[] = Query::crossJoin($table, $alias); continue; } if ($type === 'NATURAL JOIN') { - $this->naturalJoin($table, $alias); + $this->pendingQueries[] = Query::naturalJoin($table, $alias); continue; } diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index d52828c..3684cef 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -12,6 +12,7 @@ use Utopia\Query\Builder\Feature\ClickHouse\LimitBy; use Utopia\Query\Builder\Feature\ClickHouse\WithFill; use Utopia\Query\Builder\Feature\ConditionalAggregates; +use Utopia\Query\Builder\Feature\CrossJoins; use Utopia\Query\Builder\Feature\Cube; use Utopia\Query\Builder\Feature\FullOuterJoins; use Utopia\Query\Builder\Feature\Hints; @@ -26,7 +27,7 @@ use Utopia\Query\Query; use Utopia\Query\QuotesIdentifiers; -class ClickHouse extends BaseBuilder implements Hints, ConditionalAggregates, TableSampling, FullOuterJoins, StringAggregates, StatisticalAggregates, BitwiseAggregates, LimitBy, ArrayJoins, AsofJoins, WithFill, Rollup, Cube, Totals, ApproximateAggregates, RawSql +class ClickHouse extends BaseBuilder implements Hints, ConditionalAggregates, TableSampling, FullOuterJoins, StringAggregates, StatisticalAggregates, BitwiseAggregates, LimitBy, ArrayJoins, AsofJoins, WithFill, Rollup, Cube, Totals, ApproximateAggregates, RawSql, CrossJoins { use QuotesIdentifiers; use Trait\BitwiseAggregates; @@ -36,6 +37,7 @@ class ClickHouse extends BaseBuilder implements Hints, ConditionalAggregates, Ta use Trait\ClickHouse\LimitBy; use Trait\ClickHouse\WithFill; use Trait\FullOuterJoins; + use Trait\CrossJoins; use Trait\GroupByModifiers; use Trait\RawSql; use Trait\StatisticalAggregates; diff --git a/src/Query/Builder/Feature/CrossJoins.php b/src/Query/Builder/Feature/CrossJoins.php new file mode 100644 index 0000000..296a1ad --- /dev/null +++ b/src/Query/Builder/Feature/CrossJoins.php @@ -0,0 +1,16 @@ +getMethod() === Method::CrossJoin || $joinQuery->getMethod() === Method::NaturalJoin) { - throw new UnsupportedException('Cross/natural joins are not supported in MongoDB builder.'); + throw new UnsupportedException( + 'Cross and natural joins cannot be expressed as a MongoDB $lookup. ' + . 'Reached via queries([Query::crossJoin(...)]); the MongoDB builder does not implement Feature\\CrossJoins.' + ); } if (empty($values)) { diff --git a/src/Query/Builder/SQL.php b/src/Query/Builder/SQL.php index 9d328b5..200617d 100644 --- a/src/Query/Builder/SQL.php +++ b/src/Query/Builder/SQL.php @@ -4,6 +4,7 @@ use Utopia\Query\Builder as BaseBuilder; use Utopia\Query\Builder\Feature\BitwiseAggregates; +use Utopia\Query\Builder\Feature\CrossJoins; use Utopia\Query\Builder\Feature\Locking; use Utopia\Query\Builder\Feature\RawSql; use Utopia\Query\Builder\Feature\StatisticalAggregates; @@ -13,11 +14,12 @@ use Utopia\Query\QuotesIdentifiers; use Utopia\Query\Schema\ColumnType; -abstract class SQL extends BaseBuilder implements Locking, Transactions, StatisticalAggregates, BitwiseAggregates, RawSql +abstract class SQL extends BaseBuilder implements Locking, Transactions, StatisticalAggregates, BitwiseAggregates, RawSql, CrossJoins { use QuotesIdentifiers; use Trait\BitwiseAggregates; use Trait\Json; + use Trait\CrossJoins; use Trait\Locking; use Trait\RawSql; use Trait\StatisticalAggregates; diff --git a/src/Query/Builder/Trait/CrossJoins.php b/src/Query/Builder/Trait/CrossJoins.php new file mode 100644 index 0000000..6718bae --- /dev/null +++ b/src/Query/Builder/Trait/CrossJoins.php @@ -0,0 +1,24 @@ +pendingQueries[] = Query::crossJoin($table, $alias); + + return $this; + } + + #[\Override] + public function naturalJoin(string $table, string $alias = ''): static + { + $this->pendingQueries[] = Query::naturalJoin($table, $alias); + + return $this; + } +} diff --git a/src/Query/Builder/Trait/Joins.php b/src/Query/Builder/Trait/Joins.php index 93352f0..e2db84b 100644 --- a/src/Query/Builder/Trait/Joins.php +++ b/src/Query/Builder/Trait/Joins.php @@ -34,22 +34,6 @@ public function rightJoin(string $table, string $left, string $right, string $op return $this; } - #[\Override] - public function crossJoin(string $table, string $alias = ''): static - { - $this->pendingQueries[] = Query::crossJoin($table, $alias); - - return $this; - } - - #[\Override] - public function naturalJoin(string $table, string $alias = ''): static - { - $this->pendingQueries[] = Query::naturalJoin($table, $alias); - - return $this; - } - /** * @param \Closure(JoinBuilder): void $callback */ diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php index c172bf4..765539c 100644 --- a/src/Query/Schema/ClickHouse.php +++ b/src/Query/Schema/ClickHouse.php @@ -199,14 +199,6 @@ public function compileAlter(Table $table): Statement $alterations[] = 'ADD ' . $this->compileSkipIndex($index); } - if (! empty($table->foreignKeys)) { - throw new UnsupportedException('Foreign keys are not supported in ClickHouse.'); - } - - if (! empty($table->dropForeignKeys)) { - throw new UnsupportedException('Foreign keys are not supported in ClickHouse.'); - } - if (! empty($table->settings)) { throw new UnsupportedException( 'Table SETTINGS can only be set on CREATE TABLE; emit `ALTER TABLE ... MODIFY SETTING` directly to change them.' @@ -259,14 +251,6 @@ public function compileCreate(Table $table, bool $ifNotExists = false): Statemen $columnDefs[] = $this->compileSkipIndex($index); } - if (! empty($table->foreignKeys)) { - throw new UnsupportedException('Foreign keys are not supported in ClickHouse.'); - } - - if (! empty($table->checks)) { - throw new UnsupportedException('CHECK constraints are not supported in ClickHouse.'); - } - $engine = $table->engine ?? Engine::MergeTree; $sql = 'CREATE TABLE ' . ($ifNotExists ? 'IF NOT EXISTS ' : '') . $this->quote($table->name) diff --git a/tests/Query/Builder/MongoDBTest.php b/tests/Query/Builder/MongoDBTest.php index 73f3449..fe3407a 100644 --- a/tests/Query/Builder/MongoDBTest.php +++ b/tests/Query/Builder/MongoDBTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use Tests\Query\AssertsBindingCount; use Utopia\Query\Builder\Feature\Aggregates; +use Utopia\Query\Builder\Feature\CrossJoins; use Utopia\Query\Builder\Feature\CTEs; use Utopia\Query\Builder\Feature\Deletes; use Utopia\Query\Builder\Feature\FullTextSearch; @@ -3793,25 +3794,35 @@ public function testGroupByProjectReshape(): void $this->assertSame(1, $projectBody['total_sales']); } - public function testCrossJoinThrowsUnsupportedException(): void + public function testDoesNotExposeCrossOrNaturalJoins(): void + { + $builder = new Builder(); + $methods = \get_class_methods($builder); + + $this->assertArrayNotHasKey(CrossJoins::class, \class_implements($builder)); + $this->assertNotContains('crossJoin', $methods); + $this->assertNotContains('naturalJoin', $methods); + } + + public function testCrossJoinViaQueryStillReports(): void { $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('Cross/natural joins are not supported'); + $this->expectExceptionMessage('cannot be expressed as a MongoDB $lookup'); (new Builder()) ->from('users') - ->crossJoin('roles') + ->queries([Query::crossJoin('roles')]) ->build(); } - public function testNaturalJoinThrowsUnsupportedException(): void + public function testNaturalJoinViaQueryStillReports(): void { $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('Cross/natural joins are not supported'); + $this->expectExceptionMessage('cannot be expressed as a MongoDB $lookup'); (new Builder()) ->from('users') - ->naturalJoin('roles') + ->queries([Query::naturalJoin('roles')]) ->build(); } From de3504420a025539594f397a109ab1f62e8c1306 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 13 Aug 2026 23:01:37 +1200 Subject: [PATCH 5/7] refactor(schema): move SERIAL factories off the ClickHouse table Table exposed serial(), bigSerial() and smallSerial() on every dialect, but ClickHouse has no server-generated sequence type, so all three threw. They move to Table\Trait\Serial, used by MySQL, PostgreSQL, SQLite and MongoDB. The throw in ClickHouse::compileColumnType() stays, because addColumn('id', ColumnType::Serial) reaches it with a runtime enum value that no interface can exclude. Its message now names that path so the reader knows the factory is gone but the enum is still possible, and a test covers both halves. The matching Column and ForeignKey forwarders move into the per-dialect Forwarder traits. Trait\Serial is generic over TColumn, declared at the use site as @use Trait\Serial, matching Trait\ForeignKeys. Co-Authored-By: Claude Opus 5 --- src/Query/Schema/ClickHouse.php | 2 +- src/Query/Schema/Column.php | 18 ------- src/Query/Schema/ForeignKey.php | 18 ------- src/Query/Schema/Forwarder/MongoDB.php | 14 +++++ src/Query/Schema/Forwarder/MySQL.php | 14 +++++ src/Query/Schema/Forwarder/PostgreSQL.php | 14 +++++ src/Query/Schema/Forwarder/SQLite.php | 14 +++++ src/Query/Schema/Table.php | 47 ---------------- src/Query/Schema/Table/MongoDB.php | 2 + src/Query/Schema/Table/MySQL.php | 2 + src/Query/Schema/Table/PostgreSQL.php | 2 + src/Query/Schema/Table/SQLite.php | 2 + src/Query/Schema/Table/Trait/Serial.php | 66 +++++++++++++++++++++++ tests/Query/Schema/ClickHouseTest.php | 22 ++++++++ 14 files changed, 153 insertions(+), 84 deletions(-) create mode 100644 src/Query/Schema/Table/Trait/Serial.php diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php index 765539c..7d65db4 100644 --- a/src/Query/Schema/ClickHouse.php +++ b/src/Query/Schema/ClickHouse.php @@ -88,6 +88,7 @@ protected function compileColumnType(Column $column): string ColumnType::SmallInteger => $column->isUnsigned ? 'UInt16' : 'Int16', ColumnType::Integer => $column->isUnsigned ? 'UInt32' : 'Int32', ColumnType::BigInteger, ColumnType::Id => $column->isUnsigned ? 'UInt64' : 'Int64', + ColumnType::Serial, ColumnType::BigSerial, ColumnType::SmallSerial => throw new UnsupportedException('SERIAL types are not supported in ClickHouse. Reached via addColumn(); Table\\ClickHouse has no serial() factory.'), ColumnType::Float, ColumnType::Double => 'Float64', ColumnType::Decimal => 'Decimal(' . ($column->precision ?? 10) . ', ' . ($column->scale ?? 0) . ')', ColumnType::Boolean => 'UInt8', @@ -102,7 +103,6 @@ protected function compileColumnType(Column $column): string ColumnType::Uuid => 'UUID', ColumnType::Uuid7 => 'FixedString(36)', ColumnType::Vector => 'Array(Float64)', - ColumnType::Serial, ColumnType::BigSerial, ColumnType::SmallSerial => throw new UnsupportedException('SERIAL types are not supported in ClickHouse.'), ColumnType::Array, ColumnType::Tuple => throw new UnsupportedException( 'Array/Tuple columns must be declared via Table\\ClickHouse::array() or ::tuple().' ), diff --git a/src/Query/Schema/Column.php b/src/Query/Schema/Column.php index 21a0773..b7d144a 100644 --- a/src/Query/Schema/Column.php +++ b/src/Query/Schema/Column.php @@ -282,24 +282,6 @@ public function uuid(string $name): static return $this->table->uuid($name); } - public function serial(string $name): static - { - /** @var static */ - return $this->table->serial($name); - } - - public function bigSerial(string $name): static - { - /** @var static */ - return $this->table->bigSerial($name); - } - - public function smallSerial(string $name): static - { - /** @var static */ - return $this->table->smallSerial($name); - } - public function float(string $name): static { /** @var static */ diff --git a/src/Query/Schema/ForeignKey.php b/src/Query/Schema/ForeignKey.php index 2d0b9d0..51b54a1 100644 --- a/src/Query/Schema/ForeignKey.php +++ b/src/Query/Schema/ForeignKey.php @@ -97,24 +97,6 @@ public function bigInteger(string $name): Column return $this->table->bigInteger($name); } - /** @return TColumn */ - public function serial(string $name): Column - { - return $this->table->serial($name); - } - - /** @return TColumn */ - public function bigSerial(string $name): Column - { - return $this->table->bigSerial($name); - } - - /** @return TColumn */ - public function smallSerial(string $name): Column - { - return $this->table->smallSerial($name); - } - /** @return TColumn */ public function float(string $name): Column { diff --git a/src/Query/Schema/Forwarder/MongoDB.php b/src/Query/Schema/Forwarder/MongoDB.php index 91e49f4..a77597e 100644 --- a/src/Query/Schema/Forwarder/MongoDB.php +++ b/src/Query/Schema/Forwarder/MongoDB.php @@ -15,4 +15,18 @@ public function vector(string $name, int $dimensions): Column\MongoDB { return $this->table->vector($name, $dimensions); } + public function serial(string $name): Column\MongoDB + { + return $this->table->serial($name); + } + + public function bigSerial(string $name): Column\MongoDB + { + return $this->table->bigSerial($name); + } + + public function smallSerial(string $name): Column\MongoDB + { + return $this->table->smallSerial($name); + } } diff --git a/src/Query/Schema/Forwarder/MySQL.php b/src/Query/Schema/Forwarder/MySQL.php index df49e63..59a3657 100644 --- a/src/Query/Schema/Forwarder/MySQL.php +++ b/src/Query/Schema/Forwarder/MySQL.php @@ -67,4 +67,18 @@ public function dropColumn(string $name): Table\MySQL { return $this->table->dropColumn($name); } + public function serial(string $name): Column\MySQL + { + return $this->table->serial($name); + } + + public function bigSerial(string $name): Column\MySQL + { + return $this->table->bigSerial($name); + } + + public function smallSerial(string $name): Column\MySQL + { + return $this->table->smallSerial($name); + } } diff --git a/src/Query/Schema/Forwarder/PostgreSQL.php b/src/Query/Schema/Forwarder/PostgreSQL.php index 850906d..538d723 100644 --- a/src/Query/Schema/Forwarder/PostgreSQL.php +++ b/src/Query/Schema/Forwarder/PostgreSQL.php @@ -72,4 +72,18 @@ public function dropColumn(string $name): Table\PostgreSQL { return $this->table->dropColumn($name); } + public function serial(string $name): Column\PostgreSQL + { + return $this->table->serial($name); + } + + public function bigSerial(string $name): Column\PostgreSQL + { + return $this->table->bigSerial($name); + } + + public function smallSerial(string $name): Column\PostgreSQL + { + return $this->table->smallSerial($name); + } } diff --git a/src/Query/Schema/Forwarder/SQLite.php b/src/Query/Schema/Forwarder/SQLite.php index e963271..eaf1509 100644 --- a/src/Query/Schema/Forwarder/SQLite.php +++ b/src/Query/Schema/Forwarder/SQLite.php @@ -29,4 +29,18 @@ public function dropColumn(string $name): Table\SQLite { return $this->table->dropColumn($name); } + public function serial(string $name): Column\SQLite + { + return $this->table->serial($name); + } + + public function bigSerial(string $name): Column\SQLite + { + return $this->table->bigSerial($name); + } + + public function smallSerial(string $name): Column\SQLite + { + return $this->table->smallSerial($name); + } } diff --git a/src/Query/Schema/Table.php b/src/Query/Schema/Table.php index 9d94a54..92f0f2d 100644 --- a/src/Query/Schema/Table.php +++ b/src/Query/Schema/Table.php @@ -272,53 +272,6 @@ public function uuid(string $name): Column return $col; } - /** - * Auto-incrementing integer column (PostgreSQL SERIAL; INT AUTO_INCREMENT - * on MySQL; INTEGER on SQLite). Not exposed on ClickHouse/MongoDB. - * - * @return TColumn - */ - public function serial(string $name): Column - { - $col = $this->newColumn($name, ColumnType::Serial); - $col->autoIncrement(); - $this->columns[] = $col; - - return $col; - } - - /** - * Auto-incrementing big integer column (PostgreSQL BIGSERIAL; - * BIGINT AUTO_INCREMENT on MySQL; INTEGER on SQLite). Not exposed on - * ClickHouse/MongoDB. - * - * @return TColumn - */ - public function bigSerial(string $name): Column - { - $col = $this->newColumn($name, ColumnType::BigSerial); - $col->autoIncrement(); - $this->columns[] = $col; - - return $col; - } - - /** - * Auto-incrementing small integer column (PostgreSQL SMALLSERIAL; - * SMALLINT AUTO_INCREMENT on MySQL; INTEGER on SQLite). Not exposed on - * ClickHouse/MongoDB. - * - * @return TColumn - */ - public function smallSerial(string $name): Column - { - $col = $this->newColumn($name, ColumnType::SmallSerial); - $col->autoIncrement(); - $this->columns[] = $col; - - return $col; - } - /** @return TColumn */ public function float(string $name): Column { diff --git a/src/Query/Schema/Table/MongoDB.php b/src/Query/Schema/Table/MongoDB.php index 7a64f6d..9b1ee1e 100644 --- a/src/Query/Schema/Table/MongoDB.php +++ b/src/Query/Schema/Table/MongoDB.php @@ -12,6 +12,8 @@ */ class MongoDB extends Table { + /** @use Trait\Serial */ + use Trait\Serial; #[\Override] protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null, ?int $scale = null): Column\MongoDB { diff --git a/src/Query/Schema/Table/MySQL.php b/src/Query/Schema/Table/MySQL.php index b61d053..6ff618c 100644 --- a/src/Query/Schema/Table/MySQL.php +++ b/src/Query/Schema/Table/MySQL.php @@ -12,6 +12,8 @@ */ class MySQL extends Table { + /** @use Trait\Serial */ + use Trait\Serial; use Trait\Checks; use Trait\ColumnAlterations; use Trait\CompositePrimary; diff --git a/src/Query/Schema/Table/PostgreSQL.php b/src/Query/Schema/Table/PostgreSQL.php index 1465808..5087cf0 100644 --- a/src/Query/Schema/Table/PostgreSQL.php +++ b/src/Query/Schema/Table/PostgreSQL.php @@ -12,6 +12,8 @@ */ class PostgreSQL extends Table { + /** @use Trait\Serial */ + use Trait\Serial; use Trait\Checks; use Trait\ColumnAlterations; use Trait\CompositePrimary; diff --git a/src/Query/Schema/Table/SQLite.php b/src/Query/Schema/Table/SQLite.php index 6d8008f..9e7dce4 100644 --- a/src/Query/Schema/Table/SQLite.php +++ b/src/Query/Schema/Table/SQLite.php @@ -12,6 +12,8 @@ */ class SQLite extends Table { + /** @use Trait\Serial */ + use Trait\Serial; use Trait\Checks; use Trait\ColumnAlterations; use Trait\CompositePrimary; diff --git a/src/Query/Schema/Table/Trait/Serial.php b/src/Query/Schema/Table/Trait/Serial.php new file mode 100644 index 0000000..453941a --- /dev/null +++ b/src/Query/Schema/Table/Trait/Serial.php @@ -0,0 +1,66 @@ +newColumn($name, ColumnType::Serial); + $col->autoIncrement(); + $this->columns[] = $col; + + return $col; + } + + /** + * Auto-incrementing big integer column (PostgreSQL BIGSERIAL; + * BIGINT AUTO_INCREMENT on MySQL; INTEGER on SQLite). Not exposed on + * ClickHouse/MongoDB. + * + * @return TColumn + */ + public function bigSerial(string $name): Column + { + $col = $this->newColumn($name, ColumnType::BigSerial); + $col->autoIncrement(); + $this->columns[] = $col; + + return $col; + } + + /** + * Auto-incrementing small integer column (PostgreSQL SMALLSERIAL; + * SMALLINT AUTO_INCREMENT on MySQL; INTEGER on SQLite). Not exposed on + * ClickHouse/MongoDB. + * + * @return TColumn + */ + public function smallSerial(string $name): Column + { + $col = $this->newColumn($name, ColumnType::SmallSerial); + $col->autoIncrement(); + $this->columns[] = $col; + + return $col; + } +} diff --git a/tests/Query/Schema/ClickHouseTest.php b/tests/Query/Schema/ClickHouseTest.php index 74dd2dd..bc9de52 100644 --- a/tests/Query/Schema/ClickHouseTest.php +++ b/tests/Query/Schema/ClickHouseTest.php @@ -23,6 +23,28 @@ class ClickHouseTest extends TestCase { use AssertsBindingCount; + public function testTableDoesNotExposeSerialFactories(): void + { + $methods = \get_class_methods((new Schema())->table('t')); + + $this->assertNotContains('serial', $methods); + $this->assertNotContains('bigSerial', $methods); + $this->assertNotContains('smallSerial', $methods); + } + + public function testSerialViaAddColumnStillReports(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('SERIAL types are not supported in ClickHouse'); + + (new Schema())->table('t') + ->addColumn('id', ColumnType::Serial) + ->table + ->engine(Engine::MergeTree) + ->orderBy(['id']) + ->create(); + } + public function testCreateTableBasic(): void { $schema = new Schema(); From 50a15b1e84292b99f79f061a0e3dd1e6b975c6a8 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 13 Aug 2026 23:04:11 +1200 Subject: [PATCH 6/7] docs(readme): document capability interfaces and the narrowed surface Records the new shape and states what UnsupportedException now means: it is reserved for an unsupported value arriving through a correctly typed API -- Query::regex() passed to filter() on SQLite, ColumnType::Serial passed to addColumn() on ClickHouse -- because filter(array $queries) and addColumn(string, ColumnType) accept any query or column type by contract. Everything a dialect cannot do is now absent from its class, so the check is instanceof, not catch. Adds Feature Matrix rows for Raw SQL, Cross/Natural Joins and Negated Full-Text Search, and splits Group By Modifiers into Rollup, Cube and Totals. Fixes an example that was wrong before this branch: the withCube() snippet used the MySQL builder, which never supported WITH CUBE and threw. It now uses PostgreSQL, and the surrounding table gives the real per-modifier support. Column modifiers are split into those every dialect honours and those scoped to particular dialects, since check(), generatedAs(), stored(), virtual(), ttl() and userType() no longer exist everywhere. Every capability claim in the new content was verified by reflecting over the built classes rather than read off the source. Co-Authored-By: Claude Opus 5 --- README.md | 62 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index fbb288e..23e584b 100644 --- a/README.md +++ b/README.md @@ -353,7 +353,7 @@ $rows = $stmt->fetchAll(); ### Raw and Column Predicates -In addition to the typed `filter()` API, two escape hatches are available on every SQL dialect (MySQL, MariaDB, PostgreSQL, SQLite, ClickHouse). Both throw `ValidationException` on the MongoDB builder. +In addition to the typed `filter()` API, two escape hatches are available on every SQL dialect (MySQL, MariaDB, PostgreSQL, SQLite, ClickHouse). Neither exists on the MongoDB builder: they are part of the `RawSql` capability, which only the SQL dialects and ClickHouse implement. **`whereRaw()`** — emit a raw SQL fragment with its own bindings. The caller owns the SQL: @@ -500,7 +500,13 @@ $result = (new Builder()) ### Group By Modifiers -Available on MySQL, MariaDB, PostgreSQL, and ClickHouse via the `GroupByModifiers` interface: +Each modifier is its own capability interface, because dialect support does not line up: + +| Modifier | Interface | Dialects | +|---|---|---| +| `withRollup()` | `Rollup` | MySQL, MariaDB, PostgreSQL, ClickHouse | +| `withCube()` | `Cube` | PostgreSQL, ClickHouse | +| `withTotals()` | `Totals` | ClickHouse | ```php use Utopia\Query\Builder\MySQL as Builder; @@ -514,8 +520,10 @@ $result = (new Builder()) ->withRollup() ->build(); -// WITH CUBE — adds subtotals for all dimension combinations (MySQL 8.0.1+, PostgreSQL, ClickHouse) -$result = (new Builder()) +// WITH CUBE — adds subtotals for all dimension combinations +use Utopia\Query\Builder\PostgreSQL as PgBuilder; + +$result = (new PgBuilder()) ->from('sales') ->select(['region', 'product']) ->sum('amount', 'total') @@ -1668,7 +1676,9 @@ The trailing `SETTINGS` clause is whatever the caller registers via `settings()` use Utopia\Query\Builder\MongoDB as Builder; ``` -The MongoDB builder generates JSON operation documents instead of SQL. The `Statement->query` contains a JSON-encoded operation and `Statement->bindings` contains parameter values. `whereRaw()` and `whereColumn()` are not supported and throw `ValidationException`. +The MongoDB builder generates JSON operation documents instead of SQL. The `Statement->query` contains a JSON-encoded operation and `Statement->bindings` contains parameter values. + +Because there is no position in an operation document where a SQL fragment could go, the MongoDB builder does not implement `RawSql` — `selectRaw()`, `selectCast()`, `orderByRaw()`, `groupByRaw()`, `havingRaw()`, `whereRaw()`, `whereColumn()`, `selectCase()`, `setCase()`, `setRaw()`, `conflictSetRaw()` and `insertColumnExpression()` do not exist on it. It also does not implement `CrossJoins` (`$lookup` always joins on a field pair) or `NegatedFullTextSearch` (`$text` has no negated form). Use the typed `set()`, field-update and pipeline-stage methods instead. **Basic queries:** @@ -1871,13 +1881,24 @@ $result = (new Builder()) ### Feature Matrix -Unsupported features are not on the class — consumers type-hint the interface to check capability (e.g., `if ($builder instanceof Spatial)`). +Capability is expressed in the type, not at runtime: if a dialect cannot do +something, the method is not on its builder. Check with `instanceof` against the +capability interface (e.g. `if ($builder instanceof Spatial)`) rather than +catching an exception. + +`UnsupportedException` is therefore reserved for what the type system cannot +exclude — an unsupported *value* arriving through a correctly typed API. Passing +`Query::regex(...)` to `filter()` on SQLite, or `ColumnType::Serial` to +`addColumn()` on ClickHouse, still raises it, because `filter(array $queries)` +and `addColumn(string $name, ColumnType $type)` accept any query or column type +by contract. | Feature | Builder | SQL | MySQL | MariaDB | PostgreSQL | SQLite | ClickHouse | MongoDB | |---------|:-------:|:---:|:-----:|:-------:|:----------:|:------:|:----------:|:-------:| | Selects, Filters, Aggregates, Joins, Unions, CTEs, Inserts, Updates, Deletes, Hooks | x | | | | | | | | | Windows | x | | | | | | | | -| `whereRaw` / `whereColumn` | | x | | | | | x | | +| Raw SQL (`whereRaw`, `selectRaw`, `setRaw`, `selectCase`, …) | | x | | | | | x | | +| Cross / Natural Joins | | x | | | | | x | | | Locking, Transactions | | x | | | | | | | | Locking `OF` (`forUpdateOf`/`forShareOf`) | | | | | x | | | | | Upsert | | | x | x | x | x | | x | @@ -1885,6 +1906,7 @@ Unsupported features are not on the class — consumers type-hint the interface | Insert or Ignore | | | x | x | x | x | | x | | Spatial | | | x | x | x | | | | | Full-Text Search | | | x | x | x | | | x | +| Negated Full-Text Search | | | x | x | x | | | | | Statistical Aggregates | | | x | x | x | x | x | | | Bitwise Aggregates | | | x | x | x | x | x | | | Conditional Aggregates | | | x | x | x | x | x | | @@ -1892,7 +1914,9 @@ Unsupported features are not on the class — consumers type-hint the interface | Hints | | | x | x | | | x | | | Lateral Joins | | | x | x | x | | | | | String Aggregates | | | x | x | x | x | x | | -| Group By Modifiers | | | x | x | x | | x | | +| Rollup | | | x | x | x | | x | | +| Cube | | | | | x | | x | | +| Totals | | | | | | | x | | | Sequences (`nextVal`/`currVal`) | | | | x | x | | | | | `RETURNING` | | | | x | x | | | | | Full Outer Joins | | | | | x | | x | | @@ -1916,7 +1940,7 @@ Unsupported features are not on the class — consumers type-hint the interface | Pipeline Stages | | | | | | | | x | | Atlas Search | | | | | | | | x | -MongoDB implements the same `Upsert` and `FullTextSearch` interfaces as the SQL dialects, so `instanceof` checks pass, but both emit MongoDB operation documents with document semantics rather than SQL — see [MongoDB](#mongodb). +MongoDB implements the same `Upsert` and `FullTextSearch` interfaces as the SQL dialects, so `instanceof` checks pass, but both emit MongoDB operation documents with document semantics rather than SQL — see [MongoDB](#mongodb). It does not implement `NegatedFullTextSearch`, because `$text` has no negated form. ## Schema Builder @@ -1959,11 +1983,23 @@ $result = $schema->table('users') Available column types: `id`, `uuid`, `string`, `text`, `mediumText`, `longText`, `tinyInteger`, `smallInteger`, `integer`, `bigInteger`, `serial`, `bigSerial`, `smallSerial`, `float`, `decimal`, `boolean`, `datetime`, `timestamp`, `json`, `binary`, `enum`, `point`, `linestring`, `polygon`, `vector` (PostgreSQL, ClickHouse, MongoDB), `timestamps`. -Column modifiers: `nullable()`, `default($value)`, `defaultRaw($expression)`, `unsigned()`, `unique()`, `primary()`, `autoIncrement()`, `after($column)`, `comment($text)`, `collation($collation)`, `check($expression)`, `generatedAs($expression)` + `stored()` / `virtual()`, `srid($srid)` (spatial columns), `dimensions($dimensions)` (vector columns), `ttl($expression)` (ClickHouse), `userType($name)` (PostgreSQL). +Column modifiers available on every dialect: `nullable()`, `default($value)`, `defaultRaw($expression)`, `unsigned()`, `unique()`, `primary()`, `autoIncrement()`, `after($column)`, `comment($text)`, `collation($collation)`, `srid($srid)` (spatial columns), `dimensions($dimensions)` (vector columns). + +The rest are only on the dialects that honour them, so an unsupported combination is a type error rather than a runtime one: + +| Modifier | Dialects | +|---|---| +| `check($expression)` | MySQL, MariaDB, PostgreSQL, SQLite | +| `generatedAs($expression)`, `stored()` | MySQL, MariaDB, PostgreSQL, SQLite | +| `virtual()` | MySQL, MariaDB, SQLite (PostgreSQL supports `STORED` only) | +| `ttl($expression)` | ClickHouse | +| `userType($name)` | PostgreSQL | + +Likewise `serial()` / `bigSerial()` / `smallSerial()` are absent from the ClickHouse table, and `dropColumn()` / `renameColumn()` are absent from the MongoDB table. **Raw default expressions** — use `defaultRaw($expression)` for dialect-specific server-generated defaults that `default()` would otherwise quote as a string literal (`now()`, `CURRENT_TIMESTAMP`, `gen_random_uuid()`, `generateUUIDv4()`, `UUID()`, …). The expression is emitted verbatim and must come from a trusted source; it must not be empty or contain a semicolon. Takes precedence over `default()` when both are set. -**SERIAL types** — auto-incrementing integers. PostgreSQL emits native `SERIAL` / `BIGSERIAL` / `SMALLSERIAL`; MySQL/MariaDB compile to `INT AUTO_INCREMENT` / `BIGINT AUTO_INCREMENT` / `SMALLINT AUTO_INCREMENT`; SQLite maps to `INTEGER`; MongoDB maps them to the BSON `int` type. ClickHouse throws `UnsupportedException`: +**SERIAL types** — auto-incrementing integers. PostgreSQL emits native `SERIAL` / `BIGSERIAL` / `SMALLSERIAL`; MySQL/MariaDB compile to `INT AUTO_INCREMENT` / `BIGINT AUTO_INCREMENT` / `SMALLINT AUTO_INCREMENT`; SQLite maps to `INTEGER`; MongoDB maps them to the BSON `int` type. ClickHouse has no sequence type, so `Table\ClickHouse` does not expose these factories at all: ```php $result = $schema->table('orders') @@ -2254,7 +2290,7 @@ $result = $schema->table('events') // CREATE TABLE `events` (...) ENGINE = MergeTree() ORDER BY (...) ``` -ClickHouse uses `Nullable(type)` wrapping for nullable columns, `Enum8(...)` for enums, `Tuple(Float64, Float64)` for points, and `TYPE minmax GRANULARITY 3` for indexes. Foreign keys, generated columns, and CHECK constraints throw `UnsupportedException`. Stored procedures and triggers are absent from the class entirely — check with `instanceof` rather than catching. +ClickHouse uses `Nullable(type)` wrapping for nullable columns, `Enum8(...)` for enums, `Tuple(Float64, Float64)` for points, and `TYPE minmax GRANULARITY 3` for indexes. Foreign keys, generated columns, CHECK constraints, stored procedures, triggers and the `serial()` factories are all absent from the ClickHouse table and column classes rather than throwing — check with `instanceof` rather than catching. `ttl()` is exposed only on `Column\ClickHouse`. Supports the `TableComments`, `ColumnComments`, `DropPartition`, `Views`, `MaterializedViews`, and `Databases` interfaces. @@ -2587,7 +2623,7 @@ $result = $schema->createDatabase('analytics'); $result = $schema->dropDatabase('analytics'); ``` -Column types map to BSON types: `string` → `string`, `integer`/`bigInteger` → `int`, `float`/`double` → `double`, `boolean` → `bool`, `datetime`/`timestamp` → `date`, `json` → `object`, `binary` → `binData`. Composite primary keys and user-defined types throw `UnsupportedException`, as does dropping or renaming a column. SERIAL types map to `int`. CHECK constraints and generated columns are silently dropped — the JSON Schema validator has no equivalent, so enforce them in application code. +Column types map to BSON types: `string` → `string`, `integer`/`bigInteger` → `int`, `float`/`double` → `double`, `boolean` → `bool`, `datetime`/`timestamp` → `date`, `json` → `object`, `binary` → `binData`. SERIAL types map to `int`. Composite primary keys, `dropColumn()`/`renameColumn()`, CHECK constraints, generated columns and `userType()` are not exposed on the MongoDB table and column classes — a JSON Schema validator has no equivalent for any of them, so enforce those constraints in application code. ## SQL Tokenizer and AST From fc4848fd36f3461e46b58116e5e5f0ef37d8723e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 13 Aug 2026 23:11:18 +1200 Subject: [PATCH 7/7] test(schema): pin the fluent chain through ForeignKey into column factories The serial and column-alteration forwarders live on the per-dialect Forwarder traits, which Column\X and ForeignKey\X both use. Nothing covered the ForeignKey half, so moving them off the base class looked like it dropped them from foreign keys. It did not -- the chain works on MySQL, PostgreSQL and SQLite -- but the sharing is easy to miss, so pin both the chain and the forwarded method list. Co-Authored-By: Claude Opus 5 --- tests/Query/Schema/FluentBuilderTest.php | 38 ++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/Query/Schema/FluentBuilderTest.php b/tests/Query/Schema/FluentBuilderTest.php index 4a705bc..f78b19d 100644 --- a/tests/Query/Schema/FluentBuilderTest.php +++ b/tests/Query/Schema/FluentBuilderTest.php @@ -62,6 +62,44 @@ public function testColumnHoldsBackPointerToParentTable(): void $this->assertSame($bp, $col->table); } + /** + * The serial and column-alteration forwarders live on the per-dialect + * Forwarder traits, which are shared by Column\X and ForeignKey\X. This + * pins the ForeignKey half of that: a chain may continue through a + * foreign key into a column factory and back out to a terminal call. + */ + public function testChainContinuesThroughForeignKeyIntoColumnFactories(): void + { + foreach ([MySQL::class, PostgreSQL::class, SQLite::class] as $schemaClass) { + $schema = new $schemaClass(); + + $result = $schema->table('posts') + ->integer('user_id') + ->foreignKey('user_id')->references('id')->on('users') + ->onDelete(ForeignKeyAction::Cascade) + ->serial('seq') + ->create(); + + $this->assertStringContainsString('seq', $result->query); + $this->assertStringContainsString('FOREIGN KEY', $result->query); + } + } + + public function testForeignKeyExposesForwardersForSupportedDialects(): void + { + foreach ([ + \Utopia\Query\Schema\ForeignKey\MySQL::class, + \Utopia\Query\Schema\ForeignKey\PostgreSQL::class, + \Utopia\Query\Schema\ForeignKey\SQLite::class, + ] as $class) { + $methods = \get_class_methods($class); + + foreach (['serial', 'bigSerial', 'smallSerial', 'renameColumn', 'dropColumn'] as $method) { + $this->assertContains($method, $methods, "{$class} must forward {$method}()"); + } + } + } + public function testForeignKeyHoldsBackPointerToParentTable(): void { $schema = new MySQL();