diff --git a/README.md b/README.md index 1793450..9569102 100644 --- a/README.md +++ b/README.md @@ -1472,7 +1472,55 @@ Query::containsString('tags', ['php']); // position(`tags`, ?) > 0 **Regex** — uses `match()` function instead of `REGEXP`. -**UPDATE/DELETE** — compiles to `ALTER TABLE ... UPDATE/DELETE` with mandatory WHERE: +**Time bucketing** — groups rows into fixed-width windows on a timestamp column. Allowed intervals: `1m`, `5m`, `15m`, `1h`, `1d`, `1w`, `1M`. Compiles to `toStartOfMinute / toStartOfFiveMinutes / toStartOfFifteenMinutes / toStartOfHour / toStartOfDay / toStartOfWeek / toStartOfMonth`: + +```php +$result = (new Builder()) + ->from('events') + ->selectRaw('toStartOfHour(`time`) AS `bucket`') + ->count('*', 'cnt') + ->groupByTimeBucket('time', '1h') + ->orderByRaw('`bucket` ASC') + ->build(); + +// SELECT COUNT(*) AS `cnt`, toStartOfHour(`time`) AS `bucket` +// FROM `events` +// GROUP BY toStartOfHour(`time`) +// ORDER BY `bucket` ASC +``` + +Other dialects throw `UnsupportedException` from `compileGroupByTimeBucket`. Re-emit the bucket function via `selectRaw` / `orderByRaw` when you need to reference it in the SELECT list or ORDER BY (same pattern as `groupByRaw`). + +**Named-typed bindings** — opt into ClickHouse `{name:Type}` placeholders for safe parameterization over the HTTP interface. Off by default; positional `?` placeholders remain the default and behave identically to every other dialect: + +```php +$result = (new Builder()) + ->useNamedBindings() + ->withParamTypes([ + 'time' => 'DateTime64(3)', + 'tenant' => 'String', + 'value' => 'Int64', + ]) + ->from('events') + ->filter([ + Query::greaterThan('time', '2024-01-01 00:00:00'), + Query::equal('tenant', ['acme']), + Query::lessThanEqual('value', 100), + ]) + ->build(); + +// SELECT * FROM `events` +// WHERE `time` > {param0:DateTime64(3)} +// AND `tenant` IN ({param1:String}) +// AND `value` <= {param2:Int64} + +$result->namedBindings; +// ['param0' => '2024-01-01 00:00:00', 'param1' => 'acme', 'param2' => 100] +``` + +Unregistered columns fall through to value-based inference: `int → Int64`, `float → Float64`, `bool → UInt8`, `null → Nullable(String)`, `DateTimeInterface → DateTime64(3)`, everything else → `String`. Register types via `withParamType($column, $type)` or `withParamTypes($map)` whenever the inference rule doesn't match the column's ClickHouse declaration. The positional `$bindings` array is still exposed on the resulting `Statement` for callers that prefer it. + +**UPDATE** — compiles to `ALTER TABLE ... UPDATE` with mandatory WHERE: ```php $result = (new Builder()) @@ -1484,6 +1532,31 @@ $result = (new Builder()) // ALTER TABLE `events` UPDATE `status` = ? WHERE `created_at` < ? ``` +**DELETE** — two forms. `delete()` defaults to the lightweight `DELETE FROM …` form, which marks rows deleted via a mask and is async by default. Opt into the heavier mutation form (`ALTER TABLE … DELETE`) when you need parts rewritten on disk; the two are not interchangeable, so the builder never auto-translates between them. + +```php +// Lightweight (default) — pair with `lightweight_deletes_sync = 0` for async +$result = (new Builder()) + ->from('audit_log') + ->settings(['lightweight_deletes_sync' => '0']) + ->filter([Query::lessThan('time', '2024-01-01 00:00:00')]) + ->delete(); + +// DELETE FROM `audit_log` WHERE `time` < ? SETTINGS lightweight_deletes_sync=0 + +// Mutation — opt in. Pair with `mutations_sync = 0` for async +$result = (new Builder()) + ->from('audit_log') + ->deleteMode(Builder::DELETE_MODE_MUTATION) + ->settings(['mutations_sync' => '0']) + ->filter([Query::lessThan('time', '2024-01-01 00:00:00')]) + ->delete(); + +// ALTER TABLE `audit_log` DELETE WHERE `time` < ? SETTINGS mutations_sync=0 +``` + +The trailing `SETTINGS` clause is whatever the caller registers via `settings()` — the builder does not auto-pair a sync setting to a chosen delete mode. + > **Note:** Full-text search (`Query::search()`) is not supported in ClickHouse and throws `UnsupportedException`. The ClickHouse builder also forces all join filter hook conditions to WHERE placement, since ClickHouse does not support subqueries in JOIN ON. ### MongoDB @@ -1726,6 +1799,8 @@ Unsupported features are not on the class — consumers type-hint the interface | ARRAY JOIN | | | | | | | x | | | ASOF JOIN (typed operator) | | | | | | | x | | | WITH FILL | | | | | | | x | | +| `groupByTimeBucket` | | | | | | | x | | +| Named-typed `{name:Type}` bindings | | | | | | | x | | | Approximate Aggregates (incl. `quantiles`) | | | | | | | x | | | Upsert (Mongo-style) | | | | | | | | x | | Full-Text Search (Mongo) | | | | | | | | x | diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 48ce8b6..b884e74 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -21,6 +21,7 @@ use Utopia\Query\AST\Serializer; use Utopia\Query\AST\Star; use Utopia\Query\AST\Statement\Select; +use Utopia\Query\Builder\Binding; use Utopia\Query\Builder\Case\Expression as CaseExpression; use Utopia\Query\Builder\Case\Kind as CaseKind; use Utopia\Query\Builder\Case\WhenClause; @@ -85,7 +86,13 @@ abstract class Builder implements protected array $pendingQueries = []; /** - * @var list + * Parameter bindings produced during compilation. Each entry carries its + * value plus the raw column name supplied at bind time (when known). + * `Statement::$bindings` stays `list` for public consumption — the + * `Binding` wrapper is an internal capture so dialects with typed named + * placeholders can look up a registered type without a parallel array. + * + * @var list */ protected array $bindings = []; @@ -226,7 +233,7 @@ abstract protected function compileRandom(): string; * * @param array $values */ - abstract protected function compileRegex(string $attribute, array $values): string; + abstract protected function compileRegex(string $attribute, array $values, ?string $column = null): string; protected function buildTableClause(): string { @@ -433,7 +440,7 @@ public function build(): Statement $sql = $ctePrefix . $sql; - $result = new Statement($sql, $this->bindings, readOnly: true, executor: $this->executor); + $result = new Statement($sql, $this->getBindingValues(), readOnly: true, executor: $this->executor); foreach ($this->afterBuildCallbacks as $callback) { $result = $callback($result); @@ -776,6 +783,10 @@ private function buildGroupByClause(ParsedQuery $grouped): string } } + foreach ($grouped->timeBuckets as $bucket) { + $groupByParts[] = $this->compileGroupByTimeBucket($bucket['attribute'], $bucket['interval']); + } + foreach ($this->rawGroups as $rawGroup) { $groupByParts[] = $rawGroup->expression; $this->addBindings($rawGroup->bindings); @@ -1257,29 +1268,31 @@ public function __clone(): void public function compileFilter(Query $query): string { $method = $query->getMethod(); - $attribute = $this->resolveAndWrap($query->getAttribute()); + $rawAttribute = $query->getAttribute(); + $attribute = $this->resolveAndWrap($rawAttribute); $values = $query->getValues(); + $column = $rawAttribute !== '' ? $rawAttribute : null; return match ($method) { - Method::Equal => $this->compileIn($attribute, $values), - Method::NotEqual => $this->compileNotIn($attribute, $values), - Method::LessThan => $this->compileComparison($attribute, '<', $values), - Method::LessThanEqual => $this->compileComparison($attribute, '<=', $values), - Method::GreaterThan => $this->compileComparison($attribute, '>', $values), - Method::GreaterThanEqual => $this->compileComparison($attribute, '>=', $values), - Method::Between => $this->compileBetween($attribute, $values, false), - Method::NotBetween => $this->compileBetween($attribute, $values, true), - Method::StartsWith => $this->compileLike($attribute, $values, '', '%', false), - Method::NotStartsWith => $this->compileLike($attribute, $values, '', '%', true), - Method::EndsWith => $this->compileLike($attribute, $values, '%', '', false), - Method::NotEndsWith => $this->compileLike($attribute, $values, '%', '', true), - Method::Contains => $this->compileContains($attribute, $values), - Method::ContainsAny => $query->onArray() ? $this->compileIn($attribute, $values) : $this->compileContains($attribute, $values), - Method::ContainsAll => $this->compileContainsAll($attribute, $values), - Method::NotContains => $this->compileNotContains($attribute, $values), + Method::Equal => $this->compileIn($attribute, $values, $column), + Method::NotEqual => $this->compileNotIn($attribute, $values, $column), + Method::LessThan => $this->compileComparison($attribute, '<', $values, $column), + Method::LessThanEqual => $this->compileComparison($attribute, '<=', $values, $column), + Method::GreaterThan => $this->compileComparison($attribute, '>', $values, $column), + Method::GreaterThanEqual => $this->compileComparison($attribute, '>=', $values, $column), + Method::Between => $this->compileBetween($attribute, $values, false, $column), + Method::NotBetween => $this->compileBetween($attribute, $values, true, $column), + Method::StartsWith => $this->compileLike($attribute, $values, '', '%', false, $column), + Method::NotStartsWith => $this->compileLike($attribute, $values, '', '%', true, $column), + Method::EndsWith => $this->compileLike($attribute, $values, '%', '', false, $column), + Method::NotEndsWith => $this->compileLike($attribute, $values, '%', '', true, $column), + Method::Contains => $this->compileContains($attribute, $values, $column), + Method::ContainsAny => $query->onArray() ? $this->compileIn($attribute, $values, $column) : $this->compileContains($attribute, $values, $column), + Method::ContainsAll => $this->compileContainsAll($attribute, $values, $column), + Method::NotContains => $this->compileNotContains($attribute, $values, $column), Method::Search => throw new UnsupportedException('Full-text search is not supported by this dialect.'), Method::NotSearch => throw new UnsupportedException('Full-text search is not supported by this dialect.'), - Method::Regex => $this->compileRegex($attribute, $values), + Method::Regex => $this->compileRegex($attribute, $values, $column), Method::IsNull => $attribute . ' IS NULL', Method::IsNotNull => $attribute . ' IS NOT NULL', Method::And => $this->compileLogical($query, 'AND'), @@ -1410,6 +1423,13 @@ public function compileAggregate(Query $query): string #[\Override] public function compileGroupBy(Query $query): string { + if ($query->getMethod() === Method::GroupByTimeBucket) { + /** @var string $interval */ + $interval = $query->getValue(''); + + return $this->compileGroupByTimeBucket($query->getAttribute(), $interval); + } + /** @var array $values */ $values = $query->getValues(); $columns = \array_map( @@ -1420,6 +1440,22 @@ public function compileGroupBy(Query $query): string return \implode(', ', $columns); } + /** + * Compile a `groupByTimeBucket` clause for this dialect. + * + * The default builder cannot model time bucketing portably — dialects + * differ in both function names (e.g. `toStartOfHour` vs `date_trunc`) + * and the set of supported buckets. Concrete dialects override this + * method; base throws so unsupported dialects fail loudly at build time + * rather than silently dropping the clause. + */ + protected function compileGroupByTimeBucket(string $attribute, string $interval): string + { + throw new UnsupportedException( + 'groupByTimeBucket is not supported by ' . static::class + ); + } + #[\Override] public function compileJoin(Query $query): string { @@ -1554,28 +1590,55 @@ protected function resolveAndWrap(string $attribute): string return $this->quote($resolved); } - protected function addBinding(mixed $value): void + /** + * Append a single parameter binding, capturing its source column at the + * moment of binding so dialects with typed named placeholders can + * resolve a registered type without a parallel array. Most call sites + * pass just the value; the column hint is null for literals (LIMIT, + * cursor) and for sub-statement bindings that have already been + * compiled. + */ + protected function addBinding(mixed $value, ?string $column = null): void { - $this->bindings[] = $value; + $this->bindings[] = new Binding($value, $column); } /** + * Append raw values produced by a sub-statement. The values arrive as a + * `list` (from `Statement::$bindings`) and are wrapped without a + * column hint — the originating builder already consumed its own + * column-aware hints when it compiled. + * * @param array $bindings */ protected function addBindings(array $bindings): void { - \array_push($this->bindings, ...$bindings); + foreach ($bindings as $value) { + $this->bindings[] = new Binding($value, null); + } + } + + /** + * Return the binding values in compile order as a plain `list` + * for `Statement::$bindings`. The wrapped `Binding` objects stay + * internal to the builder. + * + * @return list + */ + protected function getBindingValues(): array + { + return \array_map(static fn (Binding $binding): mixed => $binding->value, $this->bindings); } /** * @param array $values */ - protected function compileLike(string $attribute, array $values, string $prefix, string $suffix, bool $not): string + protected function compileLike(string $attribute, array $values, string $prefix, string $suffix, bool $not, ?string $column = null): string { /** @var string $rawVal */ $rawVal = $values[0]; $val = $this->escapeLikeValue($rawVal); - $this->addBinding($prefix . $val . $suffix); + $this->addBinding($prefix . $val . $suffix, $column); $like = $this->getLikeKeyword(); $keyword = $not ? 'NOT ' . $like : $like; @@ -1585,19 +1648,19 @@ protected function compileLike(string $attribute, array $values, string $prefix, /** * @param array $values */ - protected function compileContains(string $attribute, array $values): string + protected function compileContains(string $attribute, array $values, ?string $column = null): string { $like = $this->getLikeKeyword(); /** @var array $values */ if (\count($values) === 1) { - $this->addBinding('%' . $this->escapeLikeValue($values[0]) . '%'); + $this->addBinding('%' . $this->escapeLikeValue($values[0]) . '%', $column); return $attribute . ' ' . $like . ' ?'; } $parts = []; foreach ($values as $value) { - $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); + $this->addBinding('%' . $this->escapeLikeValue($value) . '%', $column); $parts[] = $attribute . ' ' . $like . ' ?'; } @@ -1607,13 +1670,13 @@ protected function compileContains(string $attribute, array $values): string /** * @param array $values */ - protected function compileContainsAll(string $attribute, array $values): string + protected function compileContainsAll(string $attribute, array $values, ?string $column = null): string { $like = $this->getLikeKeyword(); /** @var array $values */ $parts = []; foreach ($values as $value) { - $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); + $this->addBinding('%' . $this->escapeLikeValue($value) . '%', $column); $parts[] = $attribute . ' ' . $like . ' ?'; } @@ -1623,19 +1686,19 @@ protected function compileContainsAll(string $attribute, array $values): string /** * @param array $values */ - protected function compileNotContains(string $attribute, array $values): string + protected function compileNotContains(string $attribute, array $values, ?string $column = null): string { $like = $this->getLikeKeyword(); /** @var array $values */ if (\count($values) === 1) { - $this->addBinding('%' . $this->escapeLikeValue($values[0]) . '%'); + $this->addBinding('%' . $this->escapeLikeValue($values[0]) . '%', $column); return $attribute . ' NOT ' . $like . ' ?'; } $parts = []; foreach ($values as $value) { - $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); + $this->addBinding('%' . $this->escapeLikeValue($value) . '%', $column); $parts[] = $attribute . ' NOT ' . $like . ' ?'; } @@ -1676,7 +1739,7 @@ protected function resolveJoinFilterPlacement(Placement $requested, bool $isCros /** * @param array $values */ - protected function compileIn(string $attribute, array $values): string + protected function compileIn(string $attribute, array $values, ?string $column = null): string { if ($values === []) { return '1 = 0'; @@ -1701,7 +1764,7 @@ protected function compileIn(string $attribute, array $values): string $placeholders = \array_fill(0, \count($nonNulls), '?'); foreach ($nonNulls as $value) { - $this->addBinding($value); + $this->addBinding($value, $column); } $inClause = $attribute . ' IN (' . \implode(', ', $placeholders) . ')'; @@ -1715,7 +1778,7 @@ protected function compileIn(string $attribute, array $values): string /** * @param array $values */ - protected function compileNotIn(string $attribute, array $values): string + protected function compileNotIn(string $attribute, array $values, ?string $column = null): string { if ($values === []) { return '1 = 1'; @@ -1739,12 +1802,12 @@ protected function compileNotIn(string $attribute, array $values): string } if (\count($nonNulls) === 1) { - $this->addBinding($nonNulls[0]); + $this->addBinding($nonNulls[0], $column); $notClause = $attribute . ' != ?'; } else { $placeholders = \array_fill(0, \count($nonNulls), '?'); foreach ($nonNulls as $value) { - $this->addBinding($value); + $this->addBinding($value, $column); } $notClause = $attribute . ' NOT IN (' . \implode(', ', $placeholders) . ')'; } @@ -1759,9 +1822,9 @@ protected function compileNotIn(string $attribute, array $values): string /** * @param array $values */ - protected function compileComparison(string $attribute, string $operator, array $values): string + protected function compileComparison(string $attribute, string $operator, array $values, ?string $column = null): string { - $this->addBinding($values[0]); + $this->addBinding($values[0], $column); return $attribute . ' ' . $operator . ' ?'; } @@ -1769,10 +1832,10 @@ protected function compileComparison(string $attribute, string $operator, array /** * @param array $values */ - private function compileBetween(string $attribute, array $values, bool $not): string + private function compileBetween(string $attribute, array $values, bool $not, ?string $column = null): string { - $this->addBinding($values[0]); - $this->addBinding($values[1]); + $this->addBinding($values[0], $column); + $this->addBinding($values[1], $column); $keyword = $not ? 'NOT BETWEEN' : 'BETWEEN'; return $attribute . ' ' . $keyword . ' ? AND ?'; diff --git a/src/Query/Builder/Binding.php b/src/Query/Builder/Binding.php new file mode 100644 index 0000000..374a6c9 --- /dev/null +++ b/src/Query/Builder/Binding.php @@ -0,0 +1,23 @@ +` + * internally; `Statement::$bindings` stays `list` for public + * consumption via `Builder::getBindingValues()`. Dialects that need typed + * named placeholders — ClickHouse HTTP, where parameters are passed as + * `{name:Type}` query-string params — read the column hint to look up a + * registered type without maintaining a separate parallel array. + */ +readonly class Binding +{ + public function __construct( + public mixed $value, + public ?string $column = null, + ) { + } +} diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 32290cb..06fbc1d 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -3,6 +3,7 @@ namespace Utopia\Query\Builder; use Utopia\Query\Builder as BaseBuilder; +use Utopia\Query\Builder\ClickHouse\FormattedInsertStatement; use Utopia\Query\Builder\Feature\BitwiseAggregates; use Utopia\Query\Builder\Feature\ClickHouse\ApproximateAggregates; use Utopia\Query\Builder\Feature\ClickHouse\ArrayJoins; @@ -56,6 +57,38 @@ class ClickHouse extends BaseBuilder implements Hints, ConditionalAggregates, Ta /** @var list */ protected array $rawJoinClauses = []; + protected ?string $insertFormat = null; + + /** @var list */ + protected array $insertFormatColumns = []; + + /** + * Caller-registered column → ClickHouse type map. Populated via + * `withParamType()`; consumed at compile-time to attach `{name:Type}` + * placeholder metadata to bindings whose column we recognise. + * + * @var array + */ + protected array $paramTypes = []; + + /** + * Whether to rewrite `?` placeholders to ClickHouse `{name:Type}` form + * at Statement creation time. Enabled by `useNamedBindings()`. + */ + protected bool $namedBindings = false; + + public const DELETE_MODE_LIGHTWEIGHT = 'lightweight'; + + public const DELETE_MODE_MUTATION = 'mutation'; + + /** + * Which DELETE form `delete()` emits. Lightweight by default — matches + * the ClickHouse server default and is the form most callers want for + * row-level cleanup. The mutation form is heavier (rewrites parts + * asynchronously) and is opt-in via `deleteMode('mutation')`. + */ + protected string $deleteMode = self::DELETE_MODE_LIGHTWEIGHT; + /** * Add PREWHERE filters (evaluated before reading all columns — major ClickHouse optimization) * @@ -106,6 +139,30 @@ public function hint(string $hint): static return $this; } + /** + * Declare a ClickHouse FORMAT pragma for the next INSERT. + * + * When a format is set, `insert()` emits + * `INSERT INTO \`t\` (\`col1\`, \`col2\`) FORMAT ` with no VALUES. + * The row payload must be streamed into the HTTP body by the caller. + * Column names are derived from the most recent `set()` call (values are + * ignored). Pass `$columns` to declare them explicitly when no `set()` + * call has been made. + * + * @param list $columns + */ + public function insertFormat(string $format, array $columns = []): static + { + if (!\preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $format)) { + throw new ValidationException('Invalid ClickHouse INSERT format: ' . $format); + } + + $this->insertFormat = $format; + $this->insertFormatColumns = $columns; + + return $this; + } + /** * @param array $settings */ @@ -265,26 +322,183 @@ public function reset(): static $this->limitByClause = null; $this->arrayJoins = []; $this->rawJoinClauses = []; + $this->insertFormat = null; + $this->insertFormatColumns = []; + $this->deleteMode = self::DELETE_MODE_LIGHTWEIGHT; + $this->namedBindings = false; + $this->paramTypes = []; $this->resetGroupByModifier(); return $this; } + /** + * Enable rewriting of `?` placeholders to ClickHouse `{name:Type}` form + * at Statement-emission time. Off by default — the positional form is + * what every other dialect uses and what the existing test fixtures + * expect. + */ + public function useNamedBindings(bool $enabled = true): static + { + $this->namedBindings = $enabled; + + return $this; + } + + /** + * Register a ClickHouse type for a column. When a `?` placeholder is + * produced for a binding whose column hint matches `$column`, the + * rewritten placeholder uses `$type`. Otherwise we fall back to the + * type inference rules in `inferClickHouseType()`. + */ + public function withParamType(string $column, string $type): static + { + if (! \preg_match('/^[A-Za-z][A-Za-z0-9_]*(?:\((?:[^()]*|\([^()]*\))*\))?$/', $type)) { + throw new ValidationException('Invalid ClickHouse type: ' . $type); + } + + $this->paramTypes[$column] = $type; + + return $this; + } + + /** + * @param array $types + */ + public function withParamTypes(array $types): static + { + foreach ($types as $column => $type) { + $this->withParamType($column, $type); + } + + return $this; + } + + /** + * Infer a ClickHouse type from a PHP value when no explicit registration + * is available. Covers the four scalars used by the audit and usage + * schemas plus DateTime objects. Falls back to `String`, which is the + * safest default for unknown payloads. + */ + private function inferClickHouseType(mixed $value): string + { + return match (true) { + \is_int($value) => 'Int64', + \is_float($value) => 'Float64', + \is_bool($value) => 'UInt8', + $value === null => 'Nullable(String)', + $value instanceof \DateTimeInterface => 'DateTime64(3)', + default => 'String', + }; + } + + /** + * Resolve the ClickHouse type for the `$index`-th positional binding, + * preferring an explicit `withParamType()` registration over inference. + */ + private function resolveBindingType(int $index): string + { + $binding = $this->bindings[$index] ?? null; + + if ($binding !== null && $binding->column !== null && isset($this->paramTypes[$binding->column])) { + return $this->paramTypes[$binding->column]; + } + + return $this->inferClickHouseType($binding?->value); + } + + /** + * Rewrite a `?`-placeholder statement to ClickHouse `{paramN:Type}` + * form, attaching `namedBindings` to the returned Statement so HTTP + * callers can post parameters by name. + * + * The positional `$stmt->bindings` array stays intact so existing + * callers that read it unchanged keep working. + */ + protected function applyNamedTypedBindings(Statement $stmt): Statement + { + if (! $this->namedBindings) { + return $stmt; + } + + $sql = $stmt->query; + $bindings = $stmt->bindings; + + if (\count($bindings) === 0) { + return $stmt; + } + + $named = []; + $index = 0; + $rewritten = \preg_replace_callback( + '/(?resolveBindingType($index); + $name = 'param' . $index; + $named[$name] = $bindings[$index] ?? null; + $index++; + + return '{' . $name . ':' . $type . '}'; + }, + $sql + ); + + if ($rewritten === null) { + return $stmt; + } + + return new Statement( + $rewritten, + $bindings, + $stmt->readOnly, + $this->executor, + namedBindings: $named, + ); + } + #[\Override] protected function compileRandom(): string { return 'rand()'; } + /** + * Map a supported `groupByTimeBucket` interval to its ClickHouse + * `toStartOf*` function name. + */ + private const array TIME_BUCKET_FUNCTIONS = [ + '1m' => 'toStartOfMinute', + '5m' => 'toStartOfFiveMinutes', + '15m' => 'toStartOfFifteenMinutes', + '1h' => 'toStartOfHour', + '1d' => 'toStartOfDay', + '1w' => 'toStartOfWeek', + '1M' => 'toStartOfMonth', + ]; + + #[\Override] + protected function compileGroupByTimeBucket(string $attribute, string $interval): string + { + $function = self::TIME_BUCKET_FUNCTIONS[$interval] ?? null; + + if ($function === null) { + throw new ValidationException( + 'Invalid groupByTimeBucket interval for ClickHouse: ' . $interval + ); + } + + return $function . '(' . $this->resolveAndWrap($attribute) . ')'; + } + /** * ClickHouse uses the match(column, pattern) function instead of REGEXP * * @param array $values */ #[\Override] - protected function compileRegex(string $attribute, array $values): string + protected function compileRegex(string $attribute, array $values, ?string $column = null): string { - $this->addBinding($values[0]); + $this->addBinding($values[0], $column); return 'match(' . $attribute . ', ?)'; } @@ -295,7 +509,7 @@ protected function compileRegex(string $attribute, array $values): string * @param array $values */ #[\Override] - protected function compileLike(string $attribute, array $values, string $prefix, string $suffix, bool $not): string + protected function compileLike(string $attribute, array $values, string $prefix, string $suffix, bool $not, ?string $column = null): string { /** @var string $rawVal */ $rawVal = $values[0]; @@ -303,7 +517,7 @@ protected function compileLike(string $attribute, array $values, string $prefix, // startsWith: prefix='', suffix='%' if ($prefix === '' && $suffix === '%') { $func = $not ? 'NOT startsWith' : 'startsWith'; - $this->addBinding($rawVal); + $this->addBinding($rawVal, $column); return $func . '(' . $attribute . ', ?)'; } @@ -311,14 +525,14 @@ protected function compileLike(string $attribute, array $values, string $prefix, // endsWith: prefix='%', suffix='' if ($prefix === '%' && $suffix === '') { $func = $not ? 'NOT endsWith' : 'endsWith'; - $this->addBinding($rawVal); + $this->addBinding($rawVal, $column); return $func . '(' . $attribute . ', ?)'; } // Fallback for any other LIKE pattern (should not occur in practice) $val = $this->escapeLikeValue($rawVal); - $this->addBinding($prefix . $val . $suffix); + $this->addBinding($prefix . $val . $suffix, $column); $keyword = $not ? 'NOT LIKE' : 'LIKE'; return $attribute . ' ' . $keyword . ' ?'; @@ -330,18 +544,18 @@ protected function compileLike(string $attribute, array $values, string $prefix, * @param array $values */ #[\Override] - protected function compileContains(string $attribute, array $values): string + protected function compileContains(string $attribute, array $values, ?string $column = null): string { /** @var array $values */ if (\count($values) === 1) { - $this->addBinding($values[0]); + $this->addBinding($values[0], $column); return 'position(' . $attribute . ', ?) > 0'; } $parts = []; foreach ($values as $value) { - $this->addBinding($value); + $this->addBinding($value, $column); $parts[] = 'position(' . $attribute . ', ?) > 0'; } @@ -354,12 +568,12 @@ protected function compileContains(string $attribute, array $values): string * @param array $values */ #[\Override] - protected function compileContainsAll(string $attribute, array $values): string + protected function compileContainsAll(string $attribute, array $values, ?string $column = null): string { /** @var array $values */ $parts = []; foreach ($values as $value) { - $this->addBinding($value); + $this->addBinding($value, $column); $parts[] = 'position(' . $attribute . ', ?) > 0'; } @@ -372,24 +586,73 @@ protected function compileContainsAll(string $attribute, array $values): string * @param array $values */ #[\Override] - protected function compileNotContains(string $attribute, array $values): string + protected function compileNotContains(string $attribute, array $values, ?string $column = null): string { /** @var array $values */ if (\count($values) === 1) { - $this->addBinding($values[0]); + $this->addBinding($values[0], $column); return 'position(' . $attribute . ', ?) = 0'; } $parts = []; foreach ($values as $value) { - $this->addBinding($value); + $this->addBinding($value, $column); $parts[] = 'position(' . $attribute . ', ?) = 0'; } return '(' . \implode(' AND ', $parts) . ')'; } + #[\Override] + public function build(): Statement + { + return $this->applyNamedTypedBindings(parent::build()); + } + + #[\Override] + public function insert(): Statement + { + $format = $this->insertFormat; + if ($format === null) { + return $this->applyNamedTypedBindings(parent::insert()); + } + + $this->bindings = []; + $this->validateTable(); + + $columns = !empty($this->insertFormatColumns) + ? $this->insertFormatColumns + : (!empty($this->rows) ? \array_keys($this->rows[0]) : []); + + if (empty($columns)) { + throw new ValidationException('No columns specified for FORMAT INSERT. Pass columns to insertFormat() or call set() before insert().'); + } + + foreach ($columns as $col) { + if ($col === '') { + throw new ValidationException('Column names for FORMAT INSERT must be non-empty strings.'); + } + } + + $wrappedColumns = \array_map( + fn (string $col): string => $this->resolveAndWrap($col), + $columns + ); + + $sql = 'INSERT INTO ' . $this->quote($this->table) + . ' (' . \implode(', ', $wrappedColumns) . ')' + . ' FORMAT ' . $format; + + return new FormattedInsertStatement( + $sql, + [], + $columns, + $format, + executor: $this->executor, + ); + } + #[\Override] public function update(): Statement { @@ -414,7 +677,31 @@ public function update(): Statement . ' UPDATE ' . \implode(', ', $assignments) . ' ' . \implode(' ', $parts); - return new Statement($sql, $this->bindings, executor: $this->executor); + return $this->applyNamedTypedBindings( + new Statement($sql, $this->getBindingValues(), executor: $this->executor) + ); + } + + /** + * Pick which DELETE form `delete()` emits. Lightweight (`DELETE FROM + * t WHERE …`) marks rows deleted via a mask and is async by default; + * mutation (`ALTER TABLE t DELETE WHERE …`) rewrites parts on disk + * and is heavier. The choice is storage-path-significant: the two + * forms are not interchangeable, so the builder never auto-translates + * between them. + */ + public function deleteMode(string $mode): static + { + if ($mode !== self::DELETE_MODE_LIGHTWEIGHT && $mode !== self::DELETE_MODE_MUTATION) { + throw new ValidationException( + 'Invalid ClickHouse delete mode: ' . $mode + . '. Allowed: ' . self::DELETE_MODE_LIGHTWEIGHT . ', ' . self::DELETE_MODE_MUTATION + ); + } + + $this->deleteMode = $mode; + + return $this; } #[\Override] @@ -431,10 +718,18 @@ public function delete(): Statement throw new ValidationException('ClickHouse DELETE requires a WHERE clause.'); } - $sql = 'ALTER TABLE ' . $this->quote($this->table) - . ' DELETE ' . \implode(' ', $parts); + $sql = $this->deleteMode === self::DELETE_MODE_LIGHTWEIGHT + ? 'DELETE FROM ' . $this->quote($this->table) . ' ' . \implode(' ', $parts) + : 'ALTER TABLE ' . $this->quote($this->table) . ' DELETE ' . \implode(' ', $parts); - return new Statement($sql, $this->bindings, executor: $this->executor); + $settings = $this->buildSettingsClause(); + if ($settings !== '') { + $sql .= ' ' . $settings; + } + + return $this->applyNamedTypedBindings( + new Statement($sql, $this->getBindingValues(), executor: $this->executor) + ); } /** diff --git a/src/Query/Builder/ClickHouse/FormattedInsertStatement.php b/src/Query/Builder/ClickHouse/FormattedInsertStatement.php new file mode 100644 index 0000000..7b3c079 --- /dev/null +++ b/src/Query/Builder/ClickHouse/FormattedInsertStatement.php @@ -0,0 +1,41 @@ + $bindings + * @param list $columns + * @param string $format + * @param bool $readOnly + * @param (Closure(Statement): (array|int))|null $executor + */ + public function __construct( + string $query, + array $bindings, + public array $columns, + public string $format, + bool $readOnly = false, + ?Closure $executor = null, + ) { + parent::__construct($query, $bindings, $readOnly, $executor); + } + + #[\Override] + public function withExecutor(Closure $executor): self + { + return new self( + $this->query, + $this->bindings, + $this->columns, + $this->format, + $this->readOnly, + $executor, + ); + } +} diff --git a/src/Query/Builder/Feature/Aggregates.php b/src/Query/Builder/Feature/Aggregates.php index f1a817d..c7f80d5 100644 --- a/src/Query/Builder/Feature/Aggregates.php +++ b/src/Query/Builder/Feature/Aggregates.php @@ -21,6 +21,16 @@ public function max(string $attribute, string $alias = ''): static; */ public function groupBy(array $columns): static; + /** + * Group rows by a time bucket of `$attribute` (e.g. hourly, daily). + * + * Allowed intervals are listed in + * `\Utopia\Query\Query::GROUP_BY_TIME_BUCKET_INTERVALS`. Compilation is + * dialect-specific — only dialects that support time bucketing accept + * this call; others throw `UnsupportedException` at build time. + */ + public function groupByTimeBucket(string $attribute, string $interval): static; + /** * @param array<\Utopia\Query\Query> $queries */ diff --git a/src/Query/Builder/MongoDB.php b/src/Query/Builder/MongoDB.php index 11d714e..2977290 100644 --- a/src/Query/Builder/MongoDB.php +++ b/src/Query/Builder/MongoDB.php @@ -101,9 +101,9 @@ protected function compileRandom(): string * @param array $values */ #[\Override] - protected function compileRegex(string $attribute, array $values): string + protected function compileRegex(string $attribute, array $values, ?string $column = null): string { - $this->addBinding($values[0]); + $this->addBinding($values[0], $column); return $attribute . ' REGEX ?'; } @@ -293,7 +293,7 @@ public function insert(): Statement return new Statement( \json_encode($operation, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), - $this->bindings, + $this->getBindingValues(), executor: $this->executor, ); } @@ -333,7 +333,7 @@ public function update(): Statement return new Statement( \json_encode($operation, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), - $this->bindings, + $this->getBindingValues(), executor: $this->executor, ); } @@ -355,7 +355,7 @@ public function delete(): Statement return new Statement( \json_encode($operation, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), - $this->bindings, + $this->getBindingValues(), executor: $this->executor, ); } @@ -398,7 +398,7 @@ public function upsert(): Statement return new Statement( \json_encode($operation, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), - $this->bindings, + $this->getBindingValues(), executor: $this->executor, ); } @@ -432,7 +432,7 @@ public function insertOrIgnore(): Statement return new Statement( \json_encode($operation, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), - $this->bindings, + $this->getBindingValues(), executor: $this->executor, ); } @@ -517,7 +517,7 @@ private function buildFind(ParsedQuery $grouped): Statement return new Statement( \json_encode($operation, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), - $this->bindings, + $this->getBindingValues(), readOnly: true, executor: $this->executor, ); @@ -565,7 +565,7 @@ private function buildAggregateStatement(array $pipeline): Statement return new Statement( \json_encode($operation, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), - $this->bindings, + $this->getBindingValues(), readOnly: true, executor: $this->executor, ); diff --git a/src/Query/Builder/MySQL.php b/src/Query/Builder/MySQL.php index 4ffb62d..69d3bc3 100644 --- a/src/Query/Builder/MySQL.php +++ b/src/Query/Builder/MySQL.php @@ -65,9 +65,9 @@ protected function compileRandom(): string * @param array $values */ #[\Override] - protected function compileRegex(string $attribute, array $values): string + protected function compileRegex(string $attribute, array $values, ?string $column = null): string { - $this->addBinding($values[0]); + $this->addBinding($values[0], $column); return $attribute . ' REGEXP ?'; } @@ -216,7 +216,7 @@ public function insertOrIgnore(): Statement // Replace "INSERT INTO" with "INSERT IGNORE INTO" $sql = \preg_replace('/^INSERT INTO/', 'INSERT IGNORE INTO', $sql, 1) ?? $sql; - return new Statement($sql, $this->bindings, executor: $this->executor); + return new Statement($sql, $this->getBindingValues(), executor: $this->executor); } #[\Override] @@ -323,7 +323,7 @@ private function buildUpdateJoin(): Statement $parts = [$sql]; $this->compileWhereClauses($parts); - return new Statement(\implode(' ', $parts), $this->bindings, executor: $this->executor); + return new Statement(\implode(' ', $parts), $this->getBindingValues(), executor: $this->executor); } public function deleteJoin(string $alias, string $table, string $left, string $right): static @@ -359,7 +359,7 @@ private function buildDeleteJoin(): Statement $parts = [$sql]; $this->compileWhereClauses($parts); - return new Statement(\implode(' ', $parts), $this->bindings, executor: $this->executor); + return new Statement(\implode(' ', $parts), $this->getBindingValues(), executor: $this->executor); } #[\Override] @@ -388,7 +388,7 @@ public function insertDefaultValues(): Statement $this->bindings = []; $this->validateTable(); - return new Statement('INSERT INTO ' . $this->quote($this->table) . ' () VALUES ()', $this->bindings, executor: $this->executor); + return new Statement('INSERT INTO ' . $this->quote($this->table) . ' () VALUES ()', $this->getBindingValues(), executor: $this->executor); } #[\Override] diff --git a/src/Query/Builder/ParsedQuery.php b/src/Query/Builder/ParsedQuery.php index b064dc5..6030b28 100644 --- a/src/Query/Builder/ParsedQuery.php +++ b/src/Query/Builder/ParsedQuery.php @@ -15,6 +15,7 @@ * @param list $having * @param list $joins * @param list $unions + * @param list $timeBuckets */ public function __construct( public array $filters = [], @@ -29,6 +30,7 @@ public function __construct( public ?int $offset = null, public mixed $cursor = null, public ?CursorDirection $cursorDirection = null, + public array $timeBuckets = [], ) { } } diff --git a/src/Query/Builder/PostgreSQL.php b/src/Query/Builder/PostgreSQL.php index 42c7fe0..984a081 100644 --- a/src/Query/Builder/PostgreSQL.php +++ b/src/Query/Builder/PostgreSQL.php @@ -108,9 +108,9 @@ protected function compileRandom(): string * @param array $values */ #[\Override] - protected function compileRegex(string $attribute, array $values): string + protected function compileRegex(string $attribute, array $values, ?string $column = null): string { - $this->addBinding($values[0]); + $this->addBinding($values[0], $column); return $attribute . ' ~ ?'; } @@ -194,7 +194,7 @@ public function insertOrIgnore(): Statement $sql .= ' ON CONFLICT DO NOTHING'; - return $this->appendReturning(new Statement($sql, $this->bindings, executor: $this->executor)); + return $this->appendReturning(new Statement($sql, $this->getBindingValues(), executor: $this->executor)); } #[\Override] @@ -289,7 +289,7 @@ private function buildUpdateFrom(): Statement $this->compileWhereClauses($parts); $this->mergeIntoWhereClause($parts, $extraWhere); - return new Statement(\implode(' ', $parts), $this->bindings, executor: $this->executor); + return new Statement(\implode(' ', $parts), $this->getBindingValues(), executor: $this->executor); } public function deleteUsing(string $table, string $condition, mixed ...$bindings): static @@ -343,7 +343,7 @@ private function buildDeleteUsing(): Statement $this->compileWhereClauses($parts); $this->mergeIntoWhereClause($parts, $extraWhere); - return new Statement(\implode(' ', $parts), $this->bindings, executor: $this->executor); + return new Statement(\implode(' ', $parts), $this->getBindingValues(), executor: $this->executor); } /** diff --git a/src/Query/Builder/SQLite.php b/src/Query/Builder/SQLite.php index 04441cf..bcde2c9 100644 --- a/src/Query/Builder/SQLite.php +++ b/src/Query/Builder/SQLite.php @@ -40,7 +40,7 @@ protected function compileRandom(): string * @param array $values */ #[\Override] - protected function compileRegex(string $attribute, array $values): string + protected function compileRegex(string $attribute, array $values, ?string $column = null): string { throw new UnsupportedException('REGEXP is not natively supported in SQLite.'); } @@ -82,7 +82,7 @@ public function insertOrIgnore(): Statement $sql = \preg_replace('/^INSERT INTO/', 'INSERT OR IGNORE INTO', $sql, 1) ?? $sql; - return new Statement($sql, $this->bindings, executor: $this->executor); + return new Statement($sql, $this->getBindingValues(), executor: $this->executor); } #[\Override] diff --git a/src/Query/Builder/Statement.php b/src/Query/Builder/Statement.php index 5f550aa..71c0684 100644 --- a/src/Query/Builder/Statement.php +++ b/src/Query/Builder/Statement.php @@ -6,6 +6,7 @@ { /** * @param list $bindings + * @param array|null $namedBindings * @param (\Closure(Statement): (array|int))|null $executor */ public function __construct( @@ -13,6 +14,7 @@ public function __construct( public array $bindings, public bool $readOnly = false, private ?\Closure $executor = null, + public ?array $namedBindings = null, ) { } @@ -30,6 +32,6 @@ public function execute(): array|int public function withExecutor(\Closure $executor): self { - return new self($this->query, $this->bindings, $this->readOnly, $executor); + return new self($this->query, $this->bindings, $this->readOnly, $executor, $this->namedBindings); } } diff --git a/src/Query/Builder/Trait/Aggregates.php b/src/Query/Builder/Trait/Aggregates.php index 390a0ca..c00378e 100644 --- a/src/Query/Builder/Trait/Aggregates.php +++ b/src/Query/Builder/Trait/Aggregates.php @@ -65,6 +65,14 @@ public function groupBy(array $columns): static return $this; } + #[\Override] + public function groupByTimeBucket(string $attribute, string $interval): static + { + $this->pendingQueries[] = Query::groupByTimeBucket($attribute, $interval); + + return $this; + } + /** * @param array $queries */ diff --git a/src/Query/Builder/Trait/Deletes.php b/src/Query/Builder/Trait/Deletes.php index 9575b0c..e0f7cc4 100644 --- a/src/Query/Builder/Trait/Deletes.php +++ b/src/Query/Builder/Trait/Deletes.php @@ -21,6 +21,6 @@ public function delete(): Statement $this->compileOrderAndLimit($parts, $grouped); - return new Statement(\implode(' ', $parts), $this->bindings, executor: $this->executor); + return new Statement(\implode(' ', $parts), $this->getBindingValues(), executor: $this->executor); } } diff --git a/src/Query/Builder/Trait/Inserts.php b/src/Query/Builder/Trait/Inserts.php index a6424c2..8ff681d 100644 --- a/src/Query/Builder/Trait/Inserts.php +++ b/src/Query/Builder/Trait/Inserts.php @@ -111,7 +111,7 @@ public function insert(): Statement [$sql, $bindings] = $this->compileInsertBody(); $this->addBindings($bindings); - return new Statement($sql, $this->bindings, executor: $this->executor); + return new Statement($sql, $this->getBindingValues(), executor: $this->executor); } #[\Override] @@ -122,7 +122,7 @@ public function insertDefaultValues(): Statement $sql = 'INSERT INTO ' . $this->quote($this->table) . ' DEFAULT VALUES'; - return new Statement($sql, $this->bindings, executor: $this->executor); + return new Statement($sql, $this->getBindingValues(), executor: $this->executor); } #[\Override] @@ -152,6 +152,6 @@ public function insertSelect(): Statement $this->addBindings($sourceResult->bindings); - return new Statement($sql, $this->bindings, executor: $this->executor); + return new Statement($sql, $this->getBindingValues(), executor: $this->executor); } } diff --git a/src/Query/Builder/Trait/PostgreSQL/Merge.php b/src/Query/Builder/Trait/PostgreSQL/Merge.php index ca0ba33..cf0f8aa 100644 --- a/src/Query/Builder/Trait/PostgreSQL/Merge.php +++ b/src/Query/Builder/Trait/PostgreSQL/Merge.php @@ -105,6 +105,6 @@ public function executeMerge(): Statement $this->addBindings($clause->bindings); } - return new Statement($sql, $this->bindings, executor: $this->executor); + return new Statement($sql, $this->getBindingValues(), executor: $this->executor); } } diff --git a/src/Query/Builder/Trait/Selects.php b/src/Query/Builder/Trait/Selects.php index a53dc6e..9a42ecd 100644 --- a/src/Query/Builder/Trait/Selects.php +++ b/src/Query/Builder/Trait/Selects.php @@ -391,7 +391,7 @@ public function toRawSql(): string #[\Override] public function getBindings(): array { - return $this->bindings; + return $this->getBindingValues(); } #[\Override] diff --git a/src/Query/Builder/Trait/Updates.php b/src/Query/Builder/Trait/Updates.php index 5af443e..8db8494 100644 --- a/src/Query/Builder/Trait/Updates.php +++ b/src/Query/Builder/Trait/Updates.php @@ -40,6 +40,6 @@ public function update(): Statement $this->compileOrderAndLimit($parts, $grouped); - return new Statement(\implode(' ', $parts), $this->bindings, executor: $this->executor); + return new Statement(\implode(' ', $parts), $this->getBindingValues(), executor: $this->executor); } } diff --git a/src/Query/Builder/Trait/Upsert.php b/src/Query/Builder/Trait/Upsert.php index a81a42b..3fc9dbf 100644 --- a/src/Query/Builder/Trait/Upsert.php +++ b/src/Query/Builder/Trait/Upsert.php @@ -59,6 +59,6 @@ public function upsert(): Statement $sql .= ' ' . $this->compileConflictClause(); - return new Statement($sql, $this->bindings, executor: $this->executor); + return new Statement($sql, $this->getBindingValues(), executor: $this->executor); } } diff --git a/src/Query/Builder/Trait/UpsertSelect.php b/src/Query/Builder/Trait/UpsertSelect.php index a87499e..8845a3d 100644 --- a/src/Query/Builder/Trait/UpsertSelect.php +++ b/src/Query/Builder/Trait/UpsertSelect.php @@ -40,6 +40,6 @@ public function upsertSelect(): Statement $sql .= ' ' . $this->compileConflictClause(); - return new Statement($sql, $this->bindings, executor: $this->executor); + return new Statement($sql, $this->getBindingValues(), executor: $this->executor); } } diff --git a/src/Query/Method.php b/src/Query/Method.php index bcd57f4..c32801d 100644 --- a/src/Query/Method.php +++ b/src/Query/Method.php @@ -83,6 +83,7 @@ enum Method: string case BitOr = 'bitOr'; case BitXor = 'bitXor'; case GroupBy = 'groupBy'; + case GroupByTimeBucket = 'groupByTimeBucket'; case Having = 'having'; // Distinct diff --git a/src/Query/Query.php b/src/Query/Query.php index c78a744..1c8fe54 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -382,7 +382,8 @@ public function compile(Compiler $compiler): string Method::BitAnd, Method::BitOr, Method::BitXor => $compiler->compileAggregate($this), - Method::GroupBy => $compiler->compileGroupBy($this), + Method::GroupBy, + Method::GroupByTimeBucket => $compiler->compileGroupBy($this), Method::Join, Method::LeftJoin, Method::RightJoin, @@ -761,6 +762,7 @@ public static function groupByType(array $queries): ParsedQuery $selections = []; $aggregations = []; $groupBy = []; + $timeBuckets = []; $having = []; $distinct = false; $joins = []; @@ -838,6 +840,15 @@ public static function groupByType(array $queries): ParsedQuery } break; + case $method === Method::GroupByTimeBucket: + /** @var string $interval */ + $interval = $values[0] ?? ''; + $timeBuckets[] = [ + 'attribute' => $query->getAttribute(), + 'interval' => $interval, + ]; + break; + case $method === Method::Having: $having[] = clone $query; break; @@ -879,6 +890,7 @@ public static function groupByType(array $queries): ParsedQuery offset: $offset, cursor: $cursor, cursorDirection: $cursorDirection, + timeBuckets: $timeBuckets, ); } @@ -1183,6 +1195,36 @@ public static function groupBy(array $attributes): static return new static(Method::GroupBy, '', $attributes); } + /** + * Allowed bucket sizes for `groupByTimeBucket`. + * + * Kept narrow on purpose: each bucket maps to a single ClickHouse + * `toStartOf*` function (see `Builder\ClickHouse::compileGroupByTimeBucket`) + * so the set is closed and changes are explicit. + * + * @var list + */ + public const array GROUP_BY_TIME_BUCKET_INTERVALS = ['1m', '5m', '15m', '1h', '1d', '1w', '1M']; + + /** + * Helper method to create Query with groupByTimeBucket method. + * + * Buckets `$attribute` into fixed-width windows of size `$interval` and + * groups by the bucket. Compilation is dialect-specific; see + * `Builder\ClickHouse::compileGroupByTimeBucket`. + */ + public static function groupByTimeBucket(string $attribute, string $interval): static + { + if (! \in_array($interval, self::GROUP_BY_TIME_BUCKET_INTERVALS, true)) { + throw new ValidationException( + 'Invalid groupByTimeBucket interval: ' . $interval + . '. Allowed: ' . \implode(', ', self::GROUP_BY_TIME_BUCKET_INTERVALS) + ); + } + + return new static(Method::GroupByTimeBucket, $attribute, [$interval]); + } + /** * @param array $queries */ diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php index 6cf2318..5cbcbb6 100644 --- a/src/Query/Schema/ClickHouse.php +++ b/src/Query/Schema/ClickHouse.php @@ -11,13 +11,15 @@ use Utopia\Query\Schema\Feature\ColumnComments; use Utopia\Query\Schema\Feature\Databases; use Utopia\Query\Schema\Feature\DropPartition; +use Utopia\Query\Schema\Feature\MaterializedViews; use Utopia\Query\Schema\Feature\TableComments; use Utopia\Query\Schema\Feature\Views; -class ClickHouse extends Schema implements TableComments, ColumnComments, DropPartition, Views, Databases +class ClickHouse extends Schema implements TableComments, ColumnComments, DropPartition, Views, Databases, MaterializedViews { use QuotesIdentifiers; use Trait\Databases; + use Trait\MaterializedViews; use Trait\Views; #[\Override] diff --git a/src/Query/Schema/Feature/MaterializedViews.php b/src/Query/Schema/Feature/MaterializedViews.php new file mode 100644 index 0000000..8c5b5cd --- /dev/null +++ b/src/Query/Schema/Feature/MaterializedViews.php @@ -0,0 +1,31 @@ +build(); + $bodySql = $built->query; + $bindings = $built->bindings; + } else { + $bodySql = $body; + } + + $sql = 'CREATE MATERIALIZED VIEW ' + . ($ifNotExists ? 'IF NOT EXISTS ' : '') + . $this->quote($name) + . ($targetTable !== null ? ' TO ' . $this->quote($targetTable) : '') + . ' AS ' . $bodySql; + + return new Statement($sql, $bindings, executor: $this->executor); + } + + public function dropMaterializedView(string $name, bool $ifExists = true): Statement + { + $sql = 'DROP VIEW ' + . ($ifExists ? 'IF EXISTS ' : '') + . $this->quote($name); + + return new Statement($sql, [], executor: $this->executor); + } +} diff --git a/tests/Query/Builder/ClickHouseTest.php b/tests/Query/Builder/ClickHouseTest.php index c2ad4a1..b3247b8 100644 --- a/tests/Query/Builder/ClickHouseTest.php +++ b/tests/Query/Builder/ClickHouseTest.php @@ -5469,7 +5469,7 @@ public function testUpdateWithoutWhereThrows(): void ->update(); } - public function testDeleteUsesAlterTable(): void + public function testDeleteDefaultsToLightweightDeleteFrom(): void { $result = (new Builder()) ->from('events') @@ -5477,6 +5477,22 @@ public function testDeleteUsesAlterTable(): void ->delete(); $this->assertBindingCount($result); + $this->assertSame( + 'DELETE FROM `events` WHERE `timestamp` < ?', + $result->query + ); + $this->assertSame(['2024-01-01'], $result->bindings); + } + + public function testDeleteUsesAlterTableWhenMutationModeOptedIn(): void + { + $result = (new Builder()) + ->from('events') + ->deleteMode(Builder::DELETE_MODE_MUTATION) + ->filter([Query::lessThan('timestamp', '2024-01-01')]) + ->delete(); + $this->assertBindingCount($result); + $this->assertSame( 'ALTER TABLE `events` DELETE WHERE `timestamp` < ?', $result->query @@ -5501,7 +5517,7 @@ public function filter(string $table): Condition $this->assertBindingCount($result); $this->assertSame( - 'ALTER TABLE `events` DELETE WHERE `status` IN (?) AND `_tenant` = ?', + 'DELETE FROM `events` WHERE `status` IN (?) AND `_tenant` = ?', $result->query ); $this->assertSame(['deleted', 'tenant_123'], $result->bindings); @@ -5922,6 +5938,7 @@ public function testDeleteAlterTableSyntax(): void { $result = (new Builder()) ->from('t') + ->deleteMode(Builder::DELETE_MODE_MUTATION) ->filter([Query::equal('id', [1])]) ->delete(); $this->assertBindingCount($result); @@ -5953,7 +5970,7 @@ public function testDeleteWithMultipleFilters(): void ->delete(); $this->assertBindingCount($result); - $this->assertSame('ALTER TABLE `t` DELETE WHERE `status` IN (?) AND `age` < ?', $result->query); + $this->assertSame('DELETE FROM `t` WHERE `status` IN (?) AND `age` < ?', $result->query); $this->assertSame(['old', 5], $result->bindings); } @@ -7314,6 +7331,7 @@ public function testExactAlterTableDelete(): void { $result = (new Builder()) ->from('events') + ->deleteMode(Builder::DELETE_MODE_MUTATION) ->filter([Query::lessThan('created_at', '2023-01-01')]) ->delete(); @@ -8036,6 +8054,7 @@ public function testExactAdvancedAlterTableDeleteWithMultipleFilters(): void { $result = (new Builder()) ->from('events') + ->deleteMode(Builder::DELETE_MODE_MUTATION) ->filter([ Query::equal('status', ['deleted']), Query::lessThan('created_at', '2023-01-01'), @@ -9146,6 +9165,7 @@ public function testDeleteAlterTableWithMultipleFilters(): void { $result = (new Builder()) ->from('events') + ->deleteMode(Builder::DELETE_MODE_MUTATION) ->filter([ Query::lessThan('created_at', '2020-01-01'), Query::equal('archived', [1]), diff --git a/tests/Query/Builder/Feature/ClickHouse/DeleteSettingsTest.php b/tests/Query/Builder/Feature/ClickHouse/DeleteSettingsTest.php new file mode 100644 index 0000000..7be0caf --- /dev/null +++ b/tests/Query/Builder/Feature/ClickHouse/DeleteSettingsTest.php @@ -0,0 +1,181 @@ +from('audit_log') + ->filter([Query::lessThan('time', '2024-01-01 00:00:00')]) + ->delete(); + + $this->assertBindingCount($result); + $this->assertSame( + 'DELETE FROM `audit_log` WHERE `time` < ?', + $result->query + ); + $this->assertSame(['2024-01-01 00:00:00'], $result->bindings); + } + + public function testLightweightDeleteWithAsyncSetting(): void + { + $result = (new Builder()) + ->from('audit_log') + ->settings(['lightweight_deletes_sync' => '0']) + ->filter([Query::lessThan('time', '2024-01-01 00:00:00')]) + ->delete(); + + $this->assertBindingCount($result); + $this->assertSame( + 'DELETE FROM `audit_log` WHERE `time` < ? SETTINGS lightweight_deletes_sync=0', + $result->query + ); + $this->assertSame(['2024-01-01 00:00:00'], $result->bindings); + } + + public function testMutationDeleteOptIn(): void + { + $result = (new Builder()) + ->from('audit_log') + ->deleteMode(Builder::DELETE_MODE_MUTATION) + ->filter([Query::lessThan('time', '2024-01-01 00:00:00')]) + ->delete(); + + $this->assertBindingCount($result); + $this->assertSame( + 'ALTER TABLE `audit_log` DELETE WHERE `time` < ?', + $result->query + ); + $this->assertSame(['2024-01-01 00:00:00'], $result->bindings); + } + + public function testMutationDeleteWithAsyncSetting(): void + { + $result = (new Builder()) + ->from('audit_log') + ->deleteMode(Builder::DELETE_MODE_MUTATION) + ->settings(['mutations_sync' => '0']) + ->filter([Query::lessThan('time', '2024-01-01 00:00:00')]) + ->delete(); + + $this->assertSame( + 'ALTER TABLE `audit_log` DELETE WHERE `time` < ? SETTINGS mutations_sync=0', + $result->query + ); + } + + public function testBuilderEmitsSettingsClauseUnchangedForBothModes(): void + { + $lightweight = (new Builder()) + ->from('audit_log') + ->settings([ + 'lightweight_deletes_sync' => '0', + 'mutations_sync' => '0', + ]) + ->filter([Query::lessThan('time', '2024-01-01 00:00:00')]) + ->delete(); + + $this->assertSame( + 'DELETE FROM `audit_log` WHERE `time` < ?' + . ' SETTINGS lightweight_deletes_sync=0, mutations_sync=0', + $lightweight->query + ); + + $mutation = (new Builder()) + ->from('audit_log') + ->deleteMode(Builder::DELETE_MODE_MUTATION) + ->settings([ + 'lightweight_deletes_sync' => '0', + 'mutations_sync' => '0', + ]) + ->filter([Query::lessThan('time', '2024-01-01 00:00:00')]) + ->delete(); + + $this->assertSame( + 'ALTER TABLE `audit_log` DELETE WHERE `time` < ?' + . ' SETTINGS lightweight_deletes_sync=0, mutations_sync=0', + $mutation->query + ); + } + + public function testLightweightDeleteWithHint(): void + { + $result = (new Builder()) + ->from('audit_log') + ->hint('lightweight_deletes_sync=0') + ->filter([Query::lessThan('time', '2024-01-01 00:00:00')]) + ->delete(); + + $this->assertSame( + 'DELETE FROM `audit_log` WHERE `time` < ? SETTINGS lightweight_deletes_sync=0', + $result->query + ); + } + + public function testLightweightDeleteWithCompoundWhereAndSettings(): void + { + $result = (new Builder()) + ->from('audit_log') + ->settings(['lightweight_deletes_sync' => '0']) + ->filter([ + Query::lessThan('time', '2024-01-01 00:00:00'), + Query::equal('tenant', ['acme']), + ]) + ->delete(); + + $this->assertSame( + 'DELETE FROM `audit_log` WHERE `time` < ? AND `tenant` IN (?) SETTINGS lightweight_deletes_sync=0', + $result->query + ); + $this->assertSame(['2024-01-01 00:00:00', 'acme'], $result->bindings); + } + + public function testDeleteModeRejectsUnknownMode(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->deleteMode('truncate'); + } + + public function testResetRestoresLightweightDefault(): void + { + $builder = (new Builder()) + ->from('audit_log') + ->deleteMode(Builder::DELETE_MODE_MUTATION); + + $builder->reset(); + + $result = $builder + ->from('audit_log') + ->filter([Query::lessThan('time', '2024-01-01 00:00:00')]) + ->delete(); + + $this->assertSame( + 'DELETE FROM `audit_log` WHERE `time` < ?', + $result->query + ); + } + + public function testSelectStillEmitsSettingsAfterDeleteFeatureAdded(): void + { + $result = (new Builder()) + ->from('events') + ->settings(['max_threads' => '4']) + ->build(); + + $this->assertSame( + 'SELECT * FROM `events` SETTINGS max_threads=4', + $result->query + ); + } +} diff --git a/tests/Query/Builder/Feature/ClickHouse/GroupByTimeBucketTest.php b/tests/Query/Builder/Feature/ClickHouse/GroupByTimeBucketTest.php new file mode 100644 index 0000000..bf122b0 --- /dev/null +++ b/tests/Query/Builder/Feature/ClickHouse/GroupByTimeBucketTest.php @@ -0,0 +1,102 @@ + + */ + public static function intervalProvider(): array + { + return [ + '1m' => ['1m', 'toStartOfMinute'], + '5m' => ['5m', 'toStartOfFiveMinutes'], + '15m' => ['15m', 'toStartOfFifteenMinutes'], + '1h' => ['1h', 'toStartOfHour'], + '1d' => ['1d', 'toStartOfDay'], + '1w' => ['1w', 'toStartOfWeek'], + '1M' => ['1M', 'toStartOfMonth'], + ]; + } + + #[DataProvider('intervalProvider')] + public function testGroupByTimeBucketEmitsToStartOfFunction(string $interval, string $function): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'count') + ->groupByTimeBucket('time', $interval) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame( + 'SELECT COUNT(*) AS `count` FROM `events` GROUP BY ' . $function . '(`time`)', + $result->query + ); + } + + public function testGroupByTimeBucketComposesWithSelectRawAndOrderRaw(): void + { + $result = (new Builder()) + ->from('events') + ->selectRaw('toStartOfHour(`time`) AS `bucket`') + ->count('*', 'count') + ->groupByTimeBucket('time', '1h') + ->orderByRaw('`bucket` ASC') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame( + 'SELECT COUNT(*) AS `count`, toStartOfHour(`time`) AS `bucket`' + . ' FROM `events`' + . ' GROUP BY toStartOfHour(`time`)' + . ' ORDER BY `bucket` ASC', + $result->query + ); + } + + public function testGroupByTimeBucketComposesWithPlainGroupBy(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'count') + ->groupBy(['tenant']) + ->groupByTimeBucket('time', '1d') + ->build(); + + $this->assertSame( + 'SELECT COUNT(*) AS `count` FROM `events` GROUP BY `tenant`, toStartOfDay(`time`)', + $result->query + ); + } + + public function testGroupByTimeBucketRejectsUnknownInterval(): void + { + $this->expectException(ValidationException::class); + + Query::groupByTimeBucket('time', '2h'); + } + + public function testQueryGroupByTimeBucketParsedQueryShape(): void + { + $queries = [Query::groupByTimeBucket('time', '1h')]; + + $parsed = Query::groupByType($queries); + + $this->assertSame( + [['attribute' => 'time', 'interval' => '1h']], + $parsed->timeBuckets + ); + $this->assertSame([], $parsed->groupBy); + } +} diff --git a/tests/Query/Builder/Feature/ClickHouse/InsertFormatTest.php b/tests/Query/Builder/Feature/ClickHouse/InsertFormatTest.php new file mode 100644 index 0000000..6e0138c --- /dev/null +++ b/tests/Query/Builder/Feature/ClickHouse/InsertFormatTest.php @@ -0,0 +1,175 @@ +into('events') + ->insertFormat('JSONEachRow', ['id', 'event', 'time']) + ->insert(); + + $this->assertInstanceOf(FormattedInsertStatement::class, $result); + $this->assertSame( + 'INSERT INTO `events` (`id`, `event`, `time`) FORMAT JSONEachRow', + $result->query + ); + $this->assertSame([], $result->bindings); + $this->assertSame(['id', 'event', 'time'], $result->columns); + $this->assertSame('JSONEachRow', $result->format); + } + + public function testInsertFormatDerivesColumnsFromSet(): void + { + $result = (new Builder()) + ->into('events') + ->set(['id' => null, 'event' => null, 'time' => null]) + ->insertFormat('JSONEachRow') + ->insert(); + + $this->assertInstanceOf(FormattedInsertStatement::class, $result); + $this->assertSame( + 'INSERT INTO `events` (`id`, `event`, `time`) FORMAT JSONEachRow', + $result->query + ); + $this->assertSame([], $result->bindings); + $this->assertSame(['id', 'event', 'time'], $result->columns); + $this->assertSame('JSONEachRow', $result->format); + } + + public function testInsertFormatExplicitColumnsTakePrecedenceOverSet(): void + { + $result = (new Builder()) + ->into('events') + ->set(['a' => 1, 'b' => 2]) + ->insertFormat('JSONEachRow', ['x', 'y']) + ->insert(); + + $this->assertInstanceOf(FormattedInsertStatement::class, $result); + $this->assertSame( + 'INSERT INTO `events` (`x`, `y`) FORMAT JSONEachRow', + $result->query + ); + $this->assertSame(['x', 'y'], $result->columns); + } + + public function testInsertFormatSupportsOtherClickHouseFormats(): void + { + $result = (new Builder()) + ->into('events') + ->insertFormat('TSV', ['id', 'name']) + ->insert(); + + $this->assertInstanceOf(FormattedInsertStatement::class, $result); + $this->assertSame( + 'INSERT INTO `events` (`id`, `name`) FORMAT TSV', + $result->query + ); + $this->assertSame('TSV', $result->format); + } + + public function testInsertFormatAcceptsUnderscoreInFormatName(): void + { + $result = (new Builder()) + ->into('events') + ->insertFormat('My_Format', ['id']) + ->insert(); + + $this->assertInstanceOf(FormattedInsertStatement::class, $result); + $this->assertSame( + 'INSERT INTO `events` (`id`) FORMAT My_Format', + $result->query + ); + $this->assertSame('My_Format', $result->format); + } + + public function testInsertFormatRejectsInvalidFormatName(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('events') + ->insertFormat('Bad Format!', ['id']); + } + + public function testInsertFormatRejectsEmptyColumnName(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('events') + ->insertFormat('JSONEachRow', ['id', '']) + ->insert(); + } + + public function testInsertFormatRequiresColumns(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('events') + ->insertFormat('JSONEachRow') + ->insert(); + } + + public function testInsertWithoutFormatStillEmitsValues(): void + { + $result = (new Builder()) + ->into('events') + ->set(['id' => 1, 'event' => 'login']) + ->insert(); + + $this->assertNotInstanceOf(FormattedInsertStatement::class, $result); + $this->assertSame( + 'INSERT INTO `events` (`id`, `event`) VALUES (?, ?)', + $result->query + ); + $this->assertSame([1, 'login'], $result->bindings); + } + + public function testFormattedInsertStatementWithExecutorPreservesFormatMetadata(): void + { + $result = (new Builder()) + ->into('events') + ->insertFormat('JSONEachRow', ['id', 'event']) + ->insert(); + + $this->assertInstanceOf(FormattedInsertStatement::class, $result); + + $executor = fn (): int => 0; + $rebound = $result->withExecutor($executor); + + $this->assertInstanceOf(FormattedInsertStatement::class, $rebound); + $this->assertSame($result->query, $rebound->query); + $this->assertSame($result->bindings, $rebound->bindings); + $this->assertSame($result->columns, $rebound->columns); + $this->assertSame($result->format, $rebound->format); + } + + public function testResetClearsInsertFormatState(): void + { + $builder = (new Builder()) + ->into('events') + ->insertFormat('JSONEachRow', ['id']); + + $builder->reset(); + + $result = $builder + ->into('events') + ->set(['id' => 1]) + ->insert(); + + $this->assertNotInstanceOf(FormattedInsertStatement::class, $result); + $this->assertSame('INSERT INTO `events` (`id`) VALUES (?)', $result->query); + } +} diff --git a/tests/Query/Builder/Feature/ClickHouse/NamedBindingsTest.php b/tests/Query/Builder/Feature/ClickHouse/NamedBindingsTest.php new file mode 100644 index 0000000..7684d43 --- /dev/null +++ b/tests/Query/Builder/Feature/ClickHouse/NamedBindingsTest.php @@ -0,0 +1,253 @@ +from('events') + ->filter([Query::equal('tenant', ['acme'])]) + ->build(); + + $this->assertSame( + 'SELECT * FROM `events` WHERE `tenant` IN (?)', + $result->query + ); + $this->assertSame(['acme'], $result->bindings); + $this->assertNull($result->namedBindings); + } + + public function testWhereChainCompilesToNamedTypedPlaceholdersWhenEnabled(): void + { + $result = (new Builder()) + ->useNamedBindings() + ->withParamTypes([ + 'time' => 'DateTime64(3)', + 'tenant' => 'String', + 'value' => 'Int64', + ]) + ->from('events') + ->filter([ + Query::greaterThan('time', '2024-01-01 00:00:00'), + Query::equal('tenant', ['acme']), + Query::lessThanEqual('value', 100), + ]) + ->build(); + + $this->assertSame( + 'SELECT * FROM `events`' + . ' WHERE `time` > {param0:DateTime64(3)}' + . ' AND `tenant` IN ({param1:String})' + . ' AND `value` <= {param2:Int64}', + $result->query + ); + $this->assertSame(['2024-01-01 00:00:00', 'acme', 100], $result->bindings); + $this->assertSame( + [ + 'param0' => '2024-01-01 00:00:00', + 'param1' => 'acme', + 'param2' => 100, + ], + $result->namedBindings + ); + } + + public function testTypeInferenceFallsBackWhenNoRegistration(): void + { + $result = (new Builder()) + ->useNamedBindings() + ->from('events') + ->filter([ + Query::greaterThan('time', '2024-01-01 00:00:00'), + Query::lessThanEqual('value', 100), + Query::equal('tenant', [true]), + ]) + ->build(); + + $this->assertSame( + 'SELECT * FROM `events`' + . ' WHERE `time` > {param0:String}' + . ' AND `value` <= {param1:Int64}' + . ' AND `tenant` IN ({param2:UInt8})', + $result->query + ); + $this->assertSame( + [ + 'param0' => '2024-01-01 00:00:00', + 'param1' => 100, + 'param2' => true, + ], + $result->namedBindings + ); + } + + public function testRegistrationOverridesInference(): void + { + $result = (new Builder()) + ->useNamedBindings() + ->withParamType('time', 'DateTime64(3)') + ->from('events') + ->filter([Query::greaterThan('time', '2024-01-01 00:00:00')]) + ->build(); + + $this->assertSame( + 'SELECT * FROM `events` WHERE `time` > {param0:DateTime64(3)}', + $result->query + ); + $this->assertSame( + ['param0' => '2024-01-01 00:00:00'], + $result->namedBindings + ); + } + + public function testLimitAndOffsetBindingsGetInferredTypes(): void + { + $result = (new Builder()) + ->useNamedBindings() + ->from('events') + ->limit(10) + ->offset(20) + ->build(); + + $this->assertSame( + 'SELECT * FROM `events` LIMIT {param0:Int64} OFFSET {param1:Int64}', + $result->query + ); + $this->assertSame( + ['param0' => 10, 'param1' => 20], + $result->namedBindings + ); + } + + public function testDeleteUsesNamedTypedPlaceholdersWhenEnabled(): void + { + $result = (new Builder()) + ->useNamedBindings() + ->withParamType('time', 'DateTime64(3)') + ->from('audit_log') + ->filter([Query::lessThan('time', '2024-01-01 00:00:00')]) + ->delete(); + + $this->assertSame( + 'DELETE FROM `audit_log` WHERE `time` < {param0:DateTime64(3)}', + $result->query + ); + $this->assertSame( + ['param0' => '2024-01-01 00:00:00'], + $result->namedBindings + ); + } + + public function testStatementWithoutBindingsHasNoNamedBindings(): void + { + $result = (new Builder()) + ->useNamedBindings() + ->from('events') + ->build(); + + $this->assertSame('SELECT * FROM `events`', $result->query); + $this->assertNull($result->namedBindings); + } + + public function testWithParamTypeRejectsInvalidTypeString(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->withParamType('time', 'DROP TABLE x; --'); + } + + public function testWithParamTypeAcceptsNestedParameterizedType(): void + { + $result = (new Builder()) + ->useNamedBindings() + ->withParamType('time', 'Nullable(DateTime64(3))') + ->from('events') + ->filter([Query::greaterThan('time', '2024-01-01 00:00:00')]) + ->build(); + + $this->assertSame( + 'SELECT * FROM `events` WHERE `time` > {param0:Nullable(DateTime64(3))}', + $result->query + ); + $this->assertSame( + ['param0' => '2024-01-01 00:00:00'], + $result->namedBindings + ); + } + + public function testResetClearsBindingMetadata(): void + { + $builder = (new Builder()) + ->useNamedBindings() + ->withParamType('tenant', 'String') + ->from('events') + ->filter([Query::equal('tenant', ['acme'])]); + + $builder->reset(); + + $result = $builder + ->useNamedBindings() + ->withParamType('value', 'Int64') + ->from('events') + ->filter([Query::greaterThan('value', 1)]) + ->build(); + + $this->assertSame( + 'SELECT * FROM `events` WHERE `value` > {param0:Int64}', + $result->query + ); + $this->assertSame(['param0' => 1], $result->namedBindings); + } + + public function testResetDisablesNamedBindingsMode(): void + { + $builder = (new Builder()) + ->useNamedBindings() + ->withParamType('tenant', 'String') + ->from('events') + ->filter([Query::equal('tenant', ['acme'])]); + + $builder->reset(); + + $result = $builder + ->from('events') + ->filter([Query::equal('tenant', ['acme'])]) + ->build(); + + $this->assertSame( + 'SELECT * FROM `events` WHERE `tenant` IN (?)', + $result->query + ); + $this->assertSame(['acme'], $result->bindings); + $this->assertNull($result->namedBindings); + } + + public function testResetClearsRegisteredParamTypes(): void + { + $builder = (new Builder()) + ->withParamType('tenant', 'FixedString(36)'); + + $builder->reset(); + + $result = $builder + ->useNamedBindings() + ->from('events') + ->filter([Query::equal('tenant', ['acme'])]) + ->build(); + + $this->assertSame( + 'SELECT * FROM `events` WHERE `tenant` IN ({param0:String})', + $result->query + ); + } +} diff --git a/tests/Query/Builder/MariaDBTest.php b/tests/Query/Builder/MariaDBTest.php index 2724310..e00d5e3 100644 --- a/tests/Query/Builder/MariaDBTest.php +++ b/tests/Query/Builder/MariaDBTest.php @@ -14,6 +14,7 @@ use Utopia\Query\Builder\MariaDB as Builder; use Utopia\Query\Builder\Statement; use Utopia\Query\Compiler; +use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Method; use Utopia\Query\Query; @@ -1574,4 +1575,15 @@ public function testNextValRejectsInvalidName(): void (new Builder())->nextVal('bad name; DROP TABLE x'); } + + public function testGroupByTimeBucketUnsupported(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('events') + ->count('*', 'count') + ->groupByTimeBucket('time', '1h') + ->build(); + } } diff --git a/tests/Query/Schema/Feature/ClickHouse/MaterializedViewsTest.php b/tests/Query/Schema/Feature/ClickHouse/MaterializedViewsTest.php new file mode 100644 index 0000000..fb2538c --- /dev/null +++ b/tests/Query/Schema/Feature/ClickHouse/MaterializedViewsTest.php @@ -0,0 +1,140 @@ +createMaterializedView( + 'usage_events_daily_mv', + $body, + 'usage_events_daily', + ); + + $this->assertBindingCount($result); + $this->assertSame( + 'CREATE MATERIALIZED VIEW IF NOT EXISTS `usage_events_daily_mv` TO `usage_events_daily` AS ' + . 'SELECT metric, sum(value) AS value, toStartOfDay(time) AS d FROM `events` GROUP BY metric, d', + $result->query, + ); + } + + public function testCreateMaterializedViewWithoutIfNotExists(): void + { + $schema = new Schema(); + + $result = $schema->createMaterializedView( + 'daily_mv', + 'SELECT * FROM `events`', + 'daily', + false, + ); + + $this->assertSame( + 'CREATE MATERIALIZED VIEW `daily_mv` TO `daily` AS SELECT * FROM `events`', + $result->query, + ); + } + + public function testCreateMaterializedViewFromBuilder(): void + { + $schema = new Schema(); + + $builder = (new ClickHouseBuilder()) + ->from('events') + ->filter([Query::equal('status', ['active'])]); + + $result = $schema->createMaterializedView( + 'active_events_mv', + $builder, + 'active_events', + ); + + $this->assertSame( + 'CREATE MATERIALIZED VIEW IF NOT EXISTS `active_events_mv` TO `active_events` AS ' + . 'SELECT * FROM `events` WHERE `status` IN (?)', + $result->query, + ); + $this->assertSame(['active'], $result->bindings); + } + + public function testCreateMaterializedViewWithoutTargetTable(): void + { + $schema = new Schema(); + + $result = $schema->createMaterializedView( + 'snapshot_mv', + 'SELECT * FROM `events`', + ); + + $this->assertSame( + 'CREATE MATERIALIZED VIEW IF NOT EXISTS `snapshot_mv` AS SELECT * FROM `events`', + $result->query, + ); + } + + public function testCreateMaterializedViewDropInReplacementForUsageAdapter(): void + { + $schema = new Schema(); + + $innerSelect = 'metric, sum(value) as value, toStartOfDay(time) as d'; + $innerGroupBy = 'metric, d'; + $outerSelect = 'metric, value, d as time'; + + $body = "SELECT {$outerSelect}" + . ' FROM (' + . " SELECT {$innerSelect}" + . ' FROM `usage`.`usage_events`' + . " GROUP BY {$innerGroupBy}" + . ' )'; + + $result = $schema->createMaterializedView( + 'usage_events_daily_mv', + $body, + 'usage_events_daily', + ); + + $expected = 'CREATE MATERIALIZED VIEW IF NOT EXISTS `usage_events_daily_mv` TO `usage_events_daily` AS ' + . 'SELECT metric, value, d as time FROM ( SELECT metric, sum(value) as value, toStartOfDay(time) as d FROM `usage`.`usage_events` GROUP BY metric, d )'; + + $this->assertSame($expected, $result->query); + } + + public function testDropMaterializedView(): void + { + $schema = new Schema(); + + $result = $schema->dropMaterializedView('usage_events_daily_mv'); + + $this->assertBindingCount($result); + $this->assertSame( + 'DROP VIEW IF EXISTS `usage_events_daily_mv`', + $result->query, + ); + } + + public function testDropMaterializedViewWithoutIfExists(): void + { + $schema = new Schema(); + + $result = $schema->dropMaterializedView('daily_mv', false); + + $this->assertSame( + 'DROP VIEW `daily_mv`', + $result->query, + ); + } +}