Skip to content
62 changes: 49 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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;
Expand All @@ -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')
Expand Down Expand Up @@ -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:**

Expand Down Expand Up @@ -1871,28 +1881,42 @@ $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 |
| Upsert Select | | | x | x | x | x | | |
| 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 | |
| JSON (incl. `setJsonPath`) | | | x | x | x | x | | |
| 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 | |
Expand All @@ -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

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions src/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}
}
}
Expand Down
10 changes: 8 additions & 2 deletions src/Query/Builder/ClickHouse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down
16 changes: 16 additions & 0 deletions src/Query/Builder/Feature/CrossJoins.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Utopia\Query\Builder\Feature;

/**
* Unqualified joins -- CROSS JOIN and NATURAL JOIN.
*
* Separate from {@see Joins} because MongoDB's $lookup always joins on a
* field pair, so it has no way to express either form.
*/
interface CrossJoins
{
public function crossJoin(string $table, string $alias = ''): static;

public function naturalJoin(string $table, string $alias = ''): static;
}
11 changes: 11 additions & 0 deletions src/Query/Builder/Feature/Cube.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Utopia\Query\Builder\Feature;

interface Cube
{
/**
* Add subtotal rows for every combination of grouping columns, plus a grand total.
*/
public function withCube(): static;
}
2 changes: 0 additions & 2 deletions src/Query/Builder/Feature/FullTextSearch.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,4 @@
interface FullTextSearch
{
public function filterSearch(string $attribute, string $value): static;

public function filterNotSearch(string $attribute, string $value): static;
}
21 changes: 0 additions & 21 deletions src/Query/Builder/Feature/GroupByModifiers.php

This file was deleted.

4 changes: 0 additions & 4 deletions src/Query/Builder/Feature/Joins.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ public function leftJoin(string $table, string $left, string $right, string $ope

public function rightJoin(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static;

public function crossJoin(string $table, string $alias = ''): static;

public function naturalJoin(string $table, string $alias = ''): static;

/**
* @param \Closure(\Utopia\Query\Builder\JoinBuilder): void $callback
*/
Expand Down
13 changes: 13 additions & 0 deletions src/Query/Builder/Feature/NegatedFullTextSearch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Utopia\Query\Builder\Feature;

/**
* Separate from {@see FullTextSearch} because some engines can match a
* full-text index but cannot negate that match. MongoDB's `$text` operator,
* for example, has no negated form.
*/
interface NegatedFullTextSearch
{
public function filterNotSearch(string $attribute, string $value): static;
}
63 changes: 63 additions & 0 deletions src/Query/Builder/Feature/RawSql.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace Utopia\Query\Builder\Feature;

use Utopia\Query\Builder\Case\Expression as CaseExpression;

/**
* Splicing raw SQL fragments and CASE expressions into a statement.
*
* Every dialect whose statements are SQL text implements this. The MongoDB
* builder does not: it emits operation documents, so there is no position in
* its output where a SQL fragment could be placed.
*/
interface RawSql
{
/**
* @param list<mixed> $bindings
*/
public function selectRaw(string $expression, array $bindings = []): static;

public function selectCast(string $column, string $type, string $alias = ''): static;

/**
* @param list<mixed> $bindings
*/
public function orderByRaw(string $expression, array $bindings = []): static;

/**
* @param list<mixed> $bindings
*/
public function groupByRaw(string $expression, array $bindings = []): static;

/**
* @param list<mixed> $bindings
*/
public function havingRaw(string $expression, array $bindings = []): static;

/**
* @param list<mixed> $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<mixed> $bindings
*/
public function setRaw(string $column, string $expression, array $bindings = []): static;

/**
* @param list<mixed> $bindings
*/
public function conflictSetRaw(string $column, string $expression, array $bindings = []): static;

/**
* @param list<mixed> $extraBindings
*/
public function insertColumnExpression(string $column, string $expression, array $extraBindings = []): static;
}
11 changes: 11 additions & 0 deletions src/Query/Builder/Feature/Rollup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Utopia\Query\Builder\Feature;

interface Rollup
{
/**
* Add hierarchical subtotal rows for each grouping level, plus a grand total.
*/
public function withRollup(): static;
}
2 changes: 0 additions & 2 deletions src/Query/Builder/Feature/Selects.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ public function filter(array $queries): static;
*/
public function queries(array $queries): static;

public function selectCast(string $column, string $type, string $alias = ''): static;

public function sortAsc(string $attribute, ?NullsPosition $nulls = null): static;

public function sortDesc(string $attribute, ?NullsPosition $nulls = null): static;
Expand Down
11 changes: 11 additions & 0 deletions src/Query/Builder/Feature/Totals.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Utopia\Query\Builder\Feature;

interface Totals
{
/**
* Add a grand total row to GROUP BY results (no intermediate subtotals).
*/
public function withTotals(): static;
}
5 changes: 0 additions & 5 deletions src/Query/Builder/Feature/Updates.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,5 @@ public function from(string $table): static;
*/
public function set(array $row): static;

/**
* @param list<mixed> $bindings
*/
public function setRaw(string $column, string $expression, array $bindings = []): static;

public function update(): Statement;
}
Loading
Loading