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 diff --git a/src/Query/Builder.php b/src/Query/Builder.php index b884e74..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; } @@ -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..3684cef 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -12,18 +12,22 @@ 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\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, CrossJoins { use QuotesIdentifiers; use Trait\BitwiseAggregates; @@ -33,7 +37,9 @@ 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; use Trait\StringAggregates; 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 @@ + $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..27845d5 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); @@ -745,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); @@ -1287,7 +1259,10 @@ private function buildJoinStages(Query $joinQuery): array $stages = []; if ($joinQuery->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)) { @@ -1496,7 +1471,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); @@ -1549,7 +1524,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/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..200617d 100644 --- a/src/Query/Builder/SQL.php +++ b/src/Query/Builder/SQL.php @@ -4,7 +4,9 @@ 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; use Utopia\Query\Builder\Feature\Transactions; use Utopia\Query\Method; @@ -12,12 +14,14 @@ 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, CrossJoins { use QuotesIdentifiers; use Trait\BitwiseAggregates; use Trait\Json; + use Trait\CrossJoins; use Trait\Locking; + use Trait\RawSql; use Trait\StatisticalAggregates; use Trait\Transactions; 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/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/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/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/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/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php index bedea0a..7d65db4 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 . ')'; @@ -92,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', @@ -106,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().' ), @@ -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), @@ -211,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.' @@ -271,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/src/Query/Schema/Column.php b/src/Query/Schema/Column.php index 51af14d..b7d144a 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 */ @@ -356,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 */ @@ -446,18 +354,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..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 { @@ -196,18 +178,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/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 1592f07..59a3657 100644 --- a/src/Query/Schema/Forwarder/MySQL.php +++ b/src/Query/Schema/Forwarder/MySQL.php @@ -58,4 +58,27 @@ 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); + } + 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 ddda5a2..538d723 100644 --- a/src/Query/Schema/Forwarder/PostgreSQL.php +++ b/src/Query/Schema/Forwarder/PostgreSQL.php @@ -63,4 +63,27 @@ 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); + } + 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 9f8623c..eaf1509 100644 --- a/src/Query/Schema/Forwarder/SQLite.php +++ b/src/Query/Schema/Forwarder/SQLite.php @@ -20,4 +20,27 @@ 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); + } + 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/MongoDB.php b/src/Query/Schema/MongoDB.php index d3a6a9c..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; @@ -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 = []; @@ -281,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/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..92f0f2d 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; @@ -273,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 { @@ -520,20 +472,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/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 19a3e72..6ff618c 100644 --- a/src/Query/Schema/Table/MySQL.php +++ b/src/Query/Schema/Table/MySQL.php @@ -12,7 +12,10 @@ */ class MySQL extends Table { + /** @use Trait\Serial */ + use Trait\Serial; 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..5087cf0 100644 --- a/src/Query/Schema/Table/PostgreSQL.php +++ b/src/Query/Schema/Table/PostgreSQL.php @@ -12,7 +12,10 @@ */ class PostgreSQL extends Table { + /** @use Trait\Serial */ + use Trait\Serial; 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..9e7dce4 100644 --- a/src/Query/Schema/Table/SQLite.php +++ b/src/Query/Schema/Table/SQLite.php @@ -12,7 +12,10 @@ */ class SQLite extends Table { + /** @use Trait\Serial */ + use Trait\Serial; 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/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/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..fe3407a 100644 --- a/tests/Query/Builder/MongoDBTest.php +++ b/tests/Query/Builder/MongoDBTest.php @@ -4,9 +4,8 @@ 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\CrossJoins; use Utopia\Query\Builder\Feature\CTEs; use Utopia\Query\Builder\Feature\Deletes; use Utopia\Query\Builder\Feature\FullTextSearch; @@ -18,6 +17,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 +1117,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 +1831,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 @@ -3812,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(); } @@ -5647,14 +5639,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()); 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(); diff --git a/tests/Query/Schema/FluentBuilderTest.php b/tests/Query/Schema/FluentBuilderTest.php index 5705b1a..f78b19d 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; @@ -63,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(); @@ -430,7 +467,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 +477,7 @@ public function testDetachedTableThrowsOnAlter(): void $bp = new Table(); $bp->dropColumn('x'); - $this->expectException(UnsupportedException::class); + $this->expectException(ValidationException::class); $bp->alter(); } @@ -448,7 +485,7 @@ public function testDetachedTableThrowsOnDrop(): void { $bp = new Table(); - $this->expectException(UnsupportedException::class); + $this->expectException(ValidationException::class); $bp->drop(); } @@ -456,7 +493,7 @@ public function testDetachedTableThrowsOnTruncate(): void { $bp = new Table(); - $this->expectException(UnsupportedException::class); + $this->expectException(ValidationException::class); $bp->truncate(); } @@ -464,7 +501,7 @@ public function testDetachedTableThrowsOnRename(): void { $bp = new Table(); - $this->expectException(UnsupportedException::class); + $this->expectException(ValidationException::class); $bp->rename('to'); } 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();