From 295070793f1e4c107dadb35ea8e939bc3f1cc364 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 07:09:42 +0000 Subject: [PATCH 01/16] feat(clickhouse): INSERT ... FORMAT JSONEachRow on the builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `Builder\ClickHouse::insertFormat(string $format, array $columns = [])` which flips the builder into FORMAT-pragma mode for the next `insert()` call. The compiled output is `INSERT INTO \`t\` (\`col1\`, \`col2\`) FORMAT ` with no VALUES and no bindings — the row payload is streamed into the HTTP body by the calling adapter. The returned `FormattedInsertStatement` extends `Statement` with two extra read-only properties — `columns` and `format` — so adapters can map row arrays to the correct column order and pick the right body encoder without having to re-parse the SQL. Motivates the next-step migration of utopia-php/audit's `INSERT INTO t FORMAT JSONEachRow` POSTs to the ClickHouse HTTP interface onto the builder. --- src/Query/Builder/ClickHouse.php | 75 ++++++++++ .../ClickHouse/FormattedInsertStatement.php | 24 +++ .../Builder/ClickHouse/InsertFormatTest.php | 141 ++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 src/Query/Builder/ClickHouse/FormattedInsertStatement.php create mode 100644 tests/Query/Builder/ClickHouse/InsertFormatTest.php diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 32290cb..1ba68e7 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,11 @@ class ClickHouse extends BaseBuilder implements Hints, ConditionalAggregates, Ta /** @var list */ protected array $rawJoinClauses = []; + protected ?string $insertFormat = null; + + /** @var list */ + protected array $insertFormatColumns = []; + /** * Add PREWHERE filters (evaluated before reading all columns — major ClickHouse optimization) * @@ -106,6 +112,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,6 +295,8 @@ public function reset(): static $this->limitByClause = null; $this->arrayJoins = []; $this->rawJoinClauses = []; + $this->insertFormat = null; + $this->insertFormatColumns = []; $this->resetGroupByModifier(); return $this; @@ -390,6 +422,49 @@ protected function compileNotContains(string $attribute, array $values): string return '(' . \implode(' AND ', $parts) . ')'; } + #[\Override] + public function insert(): Statement + { + $format = $this->insertFormat; + if ($format === null) { + return 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 { diff --git a/src/Query/Builder/ClickHouse/FormattedInsertStatement.php b/src/Query/Builder/ClickHouse/FormattedInsertStatement.php new file mode 100644 index 0000000..b5d1bc8 --- /dev/null +++ b/src/Query/Builder/ClickHouse/FormattedInsertStatement.php @@ -0,0 +1,24 @@ + $columns + * @param list $bindings + * @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); + } +} diff --git a/tests/Query/Builder/ClickHouse/InsertFormatTest.php b/tests/Query/Builder/ClickHouse/InsertFormatTest.php new file mode 100644 index 0000000..cb7a872 --- /dev/null +++ b/tests/Query/Builder/ClickHouse/InsertFormatTest.php @@ -0,0 +1,141 @@ +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 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 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); + } +} From a6c3497552135469102cc73bbcd3c9f433202448 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 07:11:04 +0000 Subject: [PATCH 02/16] feat(clickhouse): emit trailing SETTINGS clause on DELETE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Builder\ClickHouse::delete()` now appends the same SETTINGS fragment as SELECT when `hint()` or `settings()` has been called on the builder. The compiled output becomes `ALTER TABLE \`t\` DELETE WHERE ... SETTINGS k1 = v1, k2 = v2`. This is what utopia-php/audit's async cleanup needs to emit so the HTTP DELETE returns as soon as the mutation is scheduled rather than after it runs to completion — i.e. `lightweight_deletes_sync = 0`. The two stores stay merged (a `hint()` validated as `key=value` is just a SETTINGS entry on ClickHouse), so no parallel `deleteSettings()` API is introduced. --- src/Query/Builder/ClickHouse.php | 5 + .../Builder/ClickHouse/DeleteSettingsTest.php | 106 ++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 tests/Query/Builder/ClickHouse/DeleteSettingsTest.php diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 1ba68e7..eb0bc93 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -509,6 +509,11 @@ public function delete(): Statement $sql = 'ALTER TABLE ' . $this->quote($this->table) . ' DELETE ' . \implode(' ', $parts); + $settings = $this->buildSettingsClause(); + if ($settings !== '') { + $sql .= ' ' . $settings; + } + return new Statement($sql, $this->bindings, executor: $this->executor); } diff --git a/tests/Query/Builder/ClickHouse/DeleteSettingsTest.php b/tests/Query/Builder/ClickHouse/DeleteSettingsTest.php new file mode 100644 index 0000000..b273341 --- /dev/null +++ b/tests/Query/Builder/ClickHouse/DeleteSettingsTest.php @@ -0,0 +1,106 @@ +from('audit_log') + ->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 testDeleteWithAsyncCleanupSetting(): 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( + 'ALTER TABLE `audit_log` DELETE WHERE `time` < ? SETTINGS lightweight_deletes_sync=0', + $result->query + ); + $this->assertSame(['2024-01-01 00:00:00'], $result->bindings); + } + + public function testDeleteWithMultipleSettings(): void + { + $result = (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( + 'ALTER TABLE `audit_log` DELETE WHERE `time` < ? SETTINGS lightweight_deletes_sync=0, mutations_sync=0', + $result->query + ); + } + + public function testDeleteWithHintAlsoEmitsSettings(): 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( + 'ALTER TABLE `audit_log` DELETE WHERE `time` < ? SETTINGS lightweight_deletes_sync=0', + $result->query + ); + } + + public function testDeleteWithCompoundWhereAndSettings(): 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( + 'ALTER TABLE `audit_log` DELETE 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 testSelectStillEmitsSettingsAfterDeleteFeatureAdded(): void + { + $result = (new Builder()) + ->from('events') + ->settings(['max_threads' => '4']) + ->build(); + + $this->assertSame( + 'SELECT * FROM `events` SETTINGS max_threads=4', + $result->query + ); + } +} From 605376809ecd61051cbd1235c14606a0b9d59a1c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 07:12:35 +0000 Subject: [PATCH 03/16] feat(clickhouse): CREATE/DROP MATERIALIZED VIEW ... TO target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `Schema\ClickHouse::createMaterializedView()` and `dropMaterializedView()`. `createMaterializedView(string $name, string $targetTable, Builder|string $body, bool $ifNotExists = true)` emits `CREATE MATERIALIZED VIEW [IF NOT EXISTS] \`name\` TO \`target\` AS `. The body accepts either a `Builder` (its compiled SQL is inlined and its bindings ride the returned `Statement`) or a raw SQL string, mirroring the flexibility we need for MV bodies whose subqueries do not yet round-trip through the builder. `dropMaterializedView(string $name, bool $ifExists = true)` emits the symmetric `DROP VIEW [IF EXISTS] \`name\`` — ClickHouse uses the regular `DROP VIEW` form for both regular and materialized views. Drop-in replacement for the inline DDL utopia-php/usage builds today for its SummingMergeTree daily rollup MV. --- src/Query/Schema/ClickHouse.php | 37 ++++++ .../ClickHouse/MaterializedViewTest.php | 125 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/Query/Schema/ClickHouse/MaterializedViewTest.php diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php index 6cf2318..2073e96 100644 --- a/src/Query/Schema/ClickHouse.php +++ b/src/Query/Schema/ClickHouse.php @@ -2,6 +2,7 @@ namespace Utopia\Query\Schema; +use Utopia\Query\Builder; use Utopia\Query\Builder\Statement; use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Exception\ValidationException; @@ -389,4 +390,40 @@ public function dropPartition(string $table, string $name): Statement executor: $this->executor, ); } + + /** + * Emit `CREATE MATERIALIZED VIEW [IF NOT EXISTS] \`name\` TO \`target\` AS `. + * + * Accepts either a {@see Builder} (whose `build()` SQL is inlined and whose + * bindings ride along on the returned Statement) or a raw SQL string for + * bodies that do not yet round-trip through the builder. + */ + public function createMaterializedView(string $name, string $targetTable, Builder|string $body, bool $ifNotExists = true): Statement + { + $bindings = []; + if ($body instanceof Builder) { + $built = $body->build(); + $bodySql = $built->query; + $bindings = $built->bindings; + } else { + $bodySql = $body; + } + + $sql = 'CREATE MATERIALIZED VIEW ' + . ($ifNotExists ? 'IF NOT EXISTS ' : '') + . $this->quote($name) + . ' 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/Schema/ClickHouse/MaterializedViewTest.php b/tests/Query/Schema/ClickHouse/MaterializedViewTest.php new file mode 100644 index 0000000..502beec --- /dev/null +++ b/tests/Query/Schema/ClickHouse/MaterializedViewTest.php @@ -0,0 +1,125 @@ +createMaterializedView( + 'usage_events_daily_mv', + 'usage_events_daily', + $body, + ); + + $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', + 'daily', + 'SELECT * FROM `events`', + 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', + 'active_events', + $builder, + ); + + $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 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', + 'usage_events_daily', + $body, + ); + + $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, + ); + } +} From 2edfb2629534258d7339581d662e6786b703f2a9 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 09:18:37 +0000 Subject: [PATCH 04/16] feat(clickhouse): groupByTimeBucket compiles to toStartOf* dialect functions Add a first-class base-library method for time bucketing so adapters do not have to subclass `Query` to model `GROUP BY toStartOfHour(time)`-style clauses. The new method is dialect-aware: only ClickHouse implements it today; other dialects throw `UnsupportedException` from the base builder. API - `Method::GroupByTimeBucket` enum case + `Query::groupByTimeBucket($attr, $interval)` factory; intervals validated against `Query::GROUP_BY_TIME_BUCKET_INTERVALS` (`1m`, `5m`, `15m`, `1h`, `1d`, `1w`, `1M`). - `Feature\Aggregates::groupByTimeBucket()` interface method + trait implementation that pushes a `GroupByTimeBucket` query onto the pending list. AST shape - `ParsedQuery` gains a new readonly `timeBuckets` field (`list`) rather than folding into `groupBy`: bucket call sites are structurally different from plain columns (function call vs identifier) and downstream builders need to dispatch on that distinction without parsing strings. `Query::groupByType` routes `Method::GroupByTimeBucket` queries into this field; `Builder.compile()` routes the method to `compileGroupBy`. Compilation - `Builder::compileGroupByTimeBucket()` is `protected` and throws by default; `buildGroupByClause` calls it for every entry in `$grouped->timeBuckets`, so unsupported dialects fail loudly at build-time rather than silently dropping the clause. - `Builder\ClickHouse::compileGroupByTimeBucket()` maps each allowed interval to its `toStartOf*` function name via a closed lookup table. Selecting / ordering on the bucket follows the same pattern as `groupByRaw`: callers re-emit the bucket expression through `selectRaw` or `orderByRaw` when they need to reference it in the SELECT list or ORDER BY. Keeping the call sites explicit avoids ambiguity about which alias the GROUP BY clause is referring to. Tests - `tests/Query/Builder/ClickHouse/GroupByTimeBucketTest.php` snapshots the compiled SQL for all seven intervals, exercises composition with plain `groupBy()` and `selectRaw/orderByRaw`, and pins the `ParsedQuery::timeBuckets` shape. - `tests/Query/Builder/MariaDBTest.php` covers the unsupported-dialect path with an `UnsupportedException` assertion. README updated with a Time bucketing subsection under ClickHouse and a `groupByTimeBucket` row in the feature matrix. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 20 ++++ src/Query/Builder.php | 27 +++++ src/Query/Builder/ClickHouse.php | 28 +++++ src/Query/Builder/Feature/Aggregates.php | 10 ++ src/Query/Builder/ParsedQuery.php | 2 + src/Query/Builder/Trait/Aggregates.php | 8 ++ src/Query/Method.php | 1 + src/Query/Query.php | 44 +++++++- .../ClickHouse/GroupByTimeBucketTest.php | 102 ++++++++++++++++++ tests/Query/Builder/MariaDBTest.php | 12 +++ 10 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 tests/Query/Builder/ClickHouse/GroupByTimeBucketTest.php diff --git a/README.md b/README.md index 1793450..18e5a15 100644 --- a/README.md +++ b/README.md @@ -1472,6 +1472,25 @@ Query::containsString('tags', ['php']); // position(`tags`, ?) > 0 **Regex** — uses `match()` function instead of `REGEXP`. +**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`). + **UPDATE/DELETE** — compiles to `ALTER TABLE ... UPDATE/DELETE` with mandatory WHERE: ```php @@ -1726,6 +1745,7 @@ 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 | | | 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..5831130 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -776,6 +776,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); @@ -1410,6 +1414,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 +1431,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 { diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index eb0bc93..9755704 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -308,6 +308,34 @@ 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 * 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/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/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/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/tests/Query/Builder/ClickHouse/GroupByTimeBucketTest.php b/tests/Query/Builder/ClickHouse/GroupByTimeBucketTest.php new file mode 100644 index 0000000..02fc1eb --- /dev/null +++ b/tests/Query/Builder/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/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(); + } } From 7b211bced4b7d422ff7b98493c0cc35101790ee1 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 09:24:40 +0000 Subject: [PATCH 05/16] feat(clickhouse): named-typed `{name:Type}` placeholder bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClickHouse over HTTP requires `{name:Type}`-style parameter placeholders for type-safe parameterization. The builder previously emitted positional `?` only, which forced adapters to post-process the compiled SQL — fragile and easy to get wrong against complex predicates. This commit makes the named-typed form a first-class, opt-in feature of the ClickHouse builder without disturbing the positional contract every other dialect relies on. API - `Builder\ClickHouse::useNamedBindings(bool $enabled = true)` — toggle. Off by default; positional `?` and `Statement::$bindings` keep working unchanged. - `Builder\ClickHouse::withParamType(string $column, string $type)` / `withParamTypes(array $map)` — register a ClickHouse type for a column. Type strings are validated against `^[A-Za-z][A-Za-z0-9_]*(?:\([^)]*\))?$` so we reject anything that isn't a plain type name with an optional parenthesised parameter list (e.g. `DateTime64(3)`, `Nullable(String)`). - New `Builder\Binding` value object scaffolds the binding-with-metadata shape for future per-call type overrides; the placeholder rewriter uses a parallel `list` keyed by binding index for now. Wiring - Base `Builder::addBinding(mixed $value, ?string $column = null)` takes an optional column hint. Existing callers pass nothing and continue to push to `list $bindings` unchanged. - Base `Builder::compileFilter()` snapshots `$bindingColumn` from the current query attribute before dispatching the match, and restores in a `finally` so nested filters (AND/OR/Having) don't leak column hints. - `Builder\ClickHouse` overrides `addBinding`/`addBindings` to mirror the column hint into `$bindingMeta` in lockstep with `$bindings`. Index N in either array always corresponds to the N-th `?` in the compiled SQL. Statement - `Statement` gains a readonly `?array $namedBindings` (default null) so callers that read the typed map directly don't have to parse the SQL. `FormattedInsertStatement` keeps working — its positional `parent::` call hits `Statement::__construct` with the new param defaulted. Rewriter - `ClickHouse::applyNamedTypedBindings(Statement)` runs at every CH Statement boundary (`build()`, `insert()`, `update()`, `delete()`), walks `?` placeholders left-to-right with the same regex `AssertsBindingCount` uses, looks up `$paramTypes[$column]` per binding, and falls back to `inferClickHouseType($value)` when no registration matches. The positional `$bindings` payload is untouched so consumers can keep using it. Tests - `tests/Query/Builder/ClickHouse/NamedBindingsTest.php` snapshots both paths — explicit registration and value-based inference — plus the DELETE rewrite, LIMIT/OFFSET inference, default-off behavior, type validation, and `reset()` clearing of binding metadata. README updated with a Named-typed bindings subsection under ClickHouse and a new row in the feature matrix. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 30 +++ src/Query/Builder.php | 88 +++++--- src/Query/Builder/Binding.php | 36 ++++ src/Query/Builder/ClickHouse.php | 199 +++++++++++++++++- src/Query/Builder/Statement.php | 4 +- .../Builder/ClickHouse/NamedBindingsTest.php | 192 +++++++++++++++++ 6 files changed, 513 insertions(+), 36 deletions(-) create mode 100644 src/Query/Builder/Binding.php create mode 100644 tests/Query/Builder/ClickHouse/NamedBindingsTest.php diff --git a/README.md b/README.md index 18e5a15..f2a1baa 100644 --- a/README.md +++ b/README.md @@ -1491,6 +1491,35 @@ $result = (new Builder()) 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/DELETE** — compiles to `ALTER TABLE ... UPDATE/DELETE` with mandatory WHERE: ```php @@ -1746,6 +1775,7 @@ Unsupported features are not on the class — consumers type-hint the interface | 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 5831130..1e12a3f 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -89,6 +89,13 @@ abstract class Builder implements */ protected array $bindings = []; + /** + * Raw column name (pre-resolution) attached to the next-to-be-added + * bindings. Used by dialects with typed named placeholders to look up + * a registered parameter type for the column. + */ + protected ?string $bindingColumn = null; + /** * @var list */ @@ -1261,39 +1268,47 @@ 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(); - 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::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::IsNull => $attribute . ' IS NULL', - Method::IsNotNull => $attribute . ' IS NOT NULL', - Method::And => $this->compileLogical($query, 'AND'), - Method::Or => $this->compileLogical($query, 'OR'), - Method::Having => $this->compileLogical($query, 'AND'), - Method::Exists => $this->compileExists($query), - Method::NotExists => $this->compileNotExists($query), - Method::Raw => $this->compileRaw($query), - default => throw new UnsupportedException('Unsupported filter type: ' . $method->value), - }; + $previousBindingColumn = $this->bindingColumn; + $this->bindingColumn = $rawAttribute !== '' ? $rawAttribute : $previousBindingColumn; + + try { + 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::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::IsNull => $attribute . ' IS NULL', + Method::IsNotNull => $attribute . ' IS NOT NULL', + Method::And => $this->compileLogical($query, 'AND'), + Method::Or => $this->compileLogical($query, 'OR'), + Method::Having => $this->compileLogical($query, 'AND'), + Method::Exists => $this->compileExists($query), + Method::NotExists => $this->compileNotExists($query), + Method::Raw => $this->compileRaw($query), + default => throw new UnsupportedException('Unsupported filter type: ' . $method->value), + }; + } finally { + $this->bindingColumn = $previousBindingColumn; + } } protected function compileHavingCondition(Query $query, string $expression): string @@ -1581,7 +1596,16 @@ protected function resolveAndWrap(string $attribute): string return $this->quote($resolved); } - protected function addBinding(mixed $value): void + /** + * Append a single parameter binding. + * + * Most call sites pass just the value — the base builder produces + * positional `?` placeholders and only cares about ordering. Dialects + * that emit typed named placeholders (e.g. ClickHouse `{name:Type}`) + * may capture the optional `$column` hint to attach a registered type + * to the corresponding placeholder. + */ + protected function addBinding(mixed $value, ?string $column = null): void { $this->bindings[] = $value; } diff --git a/src/Query/Builder/Binding.php b/src/Query/Builder/Binding.php new file mode 100644 index 0000000..23e07d2 --- /dev/null +++ b/src/Query/Builder/Binding.php @@ -0,0 +1,36 @@ +` and emits `?` + * placeholders, which positional-protocol drivers consume directly. + * + * Dialects that need typed named placeholders — ClickHouse HTTP, where + * parameters are passed as `{name:Type}` query-string params — keep a + * parallel `list` so they can rewrite `?` to `{name:Type}` and + * publish `Statement::$namedBindings` without disturbing the positional + * path used by every other dialect. + */ +readonly class Binding +{ + public function __construct( + public mixed $value, + public ?string $name = null, + public ?string $type = null, + public ?string $column = null, + ) { + } + + public function withName(string $name): self + { + return new self($this->value, $name, $this->type, $this->column); + } + + public function withType(string $type): self + { + return new self($this->value, $this->name, $type, $this->column); + } +} diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 9755704..29b1f7c 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -62,6 +62,30 @@ class ClickHouse extends BaseBuilder implements Hints, ConditionalAggregates, Ta /** @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 = []; + + /** + * Per-binding column hint captured at `addBinding()` time, kept in + * lockstep with `$this->bindings`. Index N here corresponds to the + * N-th `?` placeholder in the compiled SQL. + * + * @var list + */ + protected array $bindingMeta = []; + + /** + * Whether to rewrite `?` placeholders to ClickHouse `{name:Type}` form + * at Statement creation time. Enabled by `useNamedBindings()`. + */ + protected bool $namedBindings = false; + /** * Add PREWHERE filters (evaluated before reading all columns — major ClickHouse optimization) * @@ -297,11 +321,163 @@ public function reset(): static $this->rawJoinClauses = []; $this->insertFormat = null; $this->insertFormatColumns = []; + $this->bindingMeta = []; $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; + } + + /** + * Track each binding's column hint in lockstep with the positional + * list so the placeholder rewriter can attach the right ClickHouse + * type to the right `?`. + */ + #[\Override] + protected function addBinding(mixed $value, ?string $column = null): void + { + parent::addBinding($value, $column); + $this->bindingMeta[] = $column ?? $this->bindingColumn; + } + + /** + * @param array $bindings + */ + #[\Override] + protected function addBindings(array $bindings): void + { + parent::addBindings($bindings); + foreach ($bindings as $_) { + $this->bindingMeta[] = $this->bindingColumn; + } + } + + /** + * 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 + { + /** @var list $bindings */ + $bindings = $this->bindings; + $value = $bindings[$index] ?? null; + + $column = $this->bindingMeta[$index] ?? null; + if ($column !== null && isset($this->paramTypes[$column])) { + return $this->paramTypes[$column]; + } + + return $this->inferClickHouseType($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 { @@ -450,15 +626,26 @@ protected function compileNotContains(string $attribute, array $values): string return '(' . \implode(' AND ', $parts) . ')'; } + #[\Override] + public function build(): Statement + { + $this->bindingMeta = []; + + return $this->applyNamedTypedBindings(parent::build()); + } + #[\Override] public function insert(): Statement { $format = $this->insertFormat; if ($format === null) { - return parent::insert(); + $this->bindingMeta = []; + + return $this->applyNamedTypedBindings(parent::insert()); } $this->bindings = []; + $this->bindingMeta = []; $this->validateTable(); $columns = !empty($this->insertFormatColumns) @@ -497,6 +684,7 @@ public function insert(): Statement public function update(): Statement { $this->bindings = []; + $this->bindingMeta = []; $this->validateTable(); $assignments = $this->compileAssignments(); @@ -517,13 +705,16 @@ 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->bindings, executor: $this->executor) + ); } #[\Override] public function delete(): Statement { $this->bindings = []; + $this->bindingMeta = []; $this->validateTable(); $parts = []; @@ -542,7 +733,9 @@ public function delete(): Statement $sql .= ' ' . $settings; } - return new Statement($sql, $this->bindings, executor: $this->executor); + return $this->applyNamedTypedBindings( + new Statement($sql, $this->bindings, executor: $this->executor) + ); } /** 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/tests/Query/Builder/ClickHouse/NamedBindingsTest.php b/tests/Query/Builder/ClickHouse/NamedBindingsTest.php new file mode 100644 index 0000000..7d45a1e --- /dev/null +++ b/tests/Query/Builder/ClickHouse/NamedBindingsTest.php @@ -0,0 +1,192 @@ +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( + 'ALTER TABLE `audit_log` DELETE 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 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); + } +} From c77f2280f4e899236d737dd0ea8f54398fd20710 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 09:28:32 +0000 Subject: [PATCH 06/16] fix(clickhouse): support lightweight DELETE FROM alongside ALTER TABLE DELETE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Builder\ClickHouse::delete()` previously emitted only the mutation form (`ALTER TABLE … DELETE`), which rewrites parts asynchronously. That is not equivalent to the lightweight form (`DELETE FROM … WHERE …`), which marks rows deleted via a mask and is async by default. Adapters that expected the lightweight semantics — e.g. audit's `cleanup()` pre-migration — would observe a silent storage-path regression after switching to this builder. Make the choice explicit, with lightweight as the default to match the ClickHouse server default and the audit baseline. API - `Builder\ClickHouse::deleteMode(string $mode)` — pick either `DELETE_MODE_LIGHTWEIGHT` (`'lightweight'`, the default) or `DELETE_MODE_MUTATION` (`'mutation'`, opt-in). Unknown modes throw `ValidationException`. - Class constants `DELETE_MODE_LIGHTWEIGHT` and `DELETE_MODE_MUTATION` expose the wire strings so call sites can avoid magic strings. Compilation - `delete()` branches on `$deleteMode` to emit either `DELETE FROM `table` WHERE …` or `ALTER TABLE `table` DELETE WHERE …`. - The trailing `SETTINGS …` clause is unchanged — the builder emits whatever `settings()`/`hint()` registered. We do not pair `lightweight_deletes_sync = 0` with the lightweight mode nor `mutations_sync = 0` with the mutation mode automatically; callers pick the setting that matches their chosen storage path. - `reset()` restores the lightweight default. Tests - `tests/Query/Builder/ClickHouse/DeleteSettingsTest.php` extended with coverage of both forms, the explicit mutation opt-in, settings-clause composition for each form, the validation error path, and reset behavior. Renamed the original `testDeleteWithoutSettingsEmitsAlterTableDelete` to `testDefaultDeleteEmitsLightweightDeleteFrom` to reflect the new default. - `tests/Query/Builder/ClickHouseTest.php` — existing tests that asserted the mutation SQL now either call `deleteMode(Builder::DELETE_MODE_MUTATION)` explicitly (when they are there to lock down the mutation form) or assert the new lightweight SQL (when they were testing generic delete behavior). - `tests/Query/Builder/ClickHouse/NamedBindingsTest.php` — `testDeleteUsesNamedTypedPlaceholdersWhenEnabled` updated to match the new default DELETE form. README updated with a DELETE subsection describing both forms and the storage-path tradeoff. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 27 +++++- src/Query/Builder/ClickHouse.php | 40 +++++++- .../Builder/ClickHouse/DeleteSettingsTest.php | 97 ++++++++++++++++--- .../Builder/ClickHouse/NamedBindingsTest.php | 2 +- tests/Query/Builder/ClickHouseTest.php | 26 ++++- 5 files changed, 174 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f2a1baa..9569102 100644 --- a/README.md +++ b/README.md @@ -1520,7 +1520,7 @@ $result->namedBindings; 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/DELETE** — compiles to `ALTER TABLE ... UPDATE/DELETE` with mandatory WHERE: +**UPDATE** — compiles to `ALTER TABLE ... UPDATE` with mandatory WHERE: ```php $result = (new Builder()) @@ -1532,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 diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 29b1f7c..502bbad 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -86,6 +86,18 @@ class ClickHouse extends BaseBuilder implements Hints, ConditionalAggregates, Ta */ 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) * @@ -322,6 +334,7 @@ public function reset(): static $this->insertFormat = null; $this->insertFormatColumns = []; $this->bindingMeta = []; + $this->deleteMode = self::DELETE_MODE_LIGHTWEIGHT; $this->resetGroupByModifier(); return $this; @@ -710,6 +723,28 @@ public function update(): Statement ); } + /** + * 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] public function delete(): Statement { @@ -725,8 +760,9 @@ 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); $settings = $this->buildSettingsClause(); if ($settings !== '') { diff --git a/tests/Query/Builder/ClickHouse/DeleteSettingsTest.php b/tests/Query/Builder/ClickHouse/DeleteSettingsTest.php index b273341..e1063d1 100644 --- a/tests/Query/Builder/ClickHouse/DeleteSettingsTest.php +++ b/tests/Query/Builder/ClickHouse/DeleteSettingsTest.php @@ -5,13 +5,14 @@ use PHPUnit\Framework\TestCase; use Tests\Query\AssertsBindingCount; use Utopia\Query\Builder\ClickHouse as Builder; +use Utopia\Query\Exception\ValidationException; use Utopia\Query\Query; class DeleteSettingsTest extends TestCase { use AssertsBindingCount; - public function testDeleteWithoutSettingsEmitsAlterTableDelete(): void + public function testDefaultDeleteEmitsLightweightDeleteFrom(): void { $result = (new Builder()) ->from('audit_log') @@ -20,13 +21,13 @@ public function testDeleteWithoutSettingsEmitsAlterTableDelete(): void $this->assertBindingCount($result); $this->assertSame( - 'ALTER TABLE `audit_log` DELETE WHERE `time` < ?', + 'DELETE FROM `audit_log` WHERE `time` < ?', $result->query ); $this->assertSame(['2024-01-01 00:00:00'], $result->bindings); } - public function testDeleteWithAsyncCleanupSetting(): void + public function testLightweightDeleteWithAsyncSetting(): void { $result = (new Builder()) ->from('audit_log') @@ -36,15 +37,46 @@ public function testDeleteWithAsyncCleanupSetting(): void $this->assertBindingCount($result); $this->assertSame( - 'ALTER TABLE `audit_log` DELETE WHERE `time` < ? SETTINGS lightweight_deletes_sync=0', + '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 testDeleteWithMultipleSettings(): void + 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', @@ -54,12 +86,29 @@ public function testDeleteWithMultipleSettings(): void ->delete(); $this->assertSame( - 'ALTER TABLE `audit_log` DELETE WHERE `time` < ? SETTINGS lightweight_deletes_sync=0, mutations_sync=0', - $result->query + '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 testDeleteWithHintAlsoEmitsSettings(): void + public function testLightweightDeleteWithHint(): void { $result = (new Builder()) ->from('audit_log') @@ -68,12 +117,12 @@ public function testDeleteWithHintAlsoEmitsSettings(): void ->delete(); $this->assertSame( - 'ALTER TABLE `audit_log` DELETE WHERE `time` < ? SETTINGS lightweight_deletes_sync=0', + 'DELETE FROM `audit_log` WHERE `time` < ? SETTINGS lightweight_deletes_sync=0', $result->query ); } - public function testDeleteWithCompoundWhereAndSettings(): void + public function testLightweightDeleteWithCompoundWhereAndSettings(): void { $result = (new Builder()) ->from('audit_log') @@ -85,12 +134,38 @@ public function testDeleteWithCompoundWhereAndSettings(): void ->delete(); $this->assertSame( - 'ALTER TABLE `audit_log` DELETE WHERE `time` < ? AND `tenant` IN (?) SETTINGS lightweight_deletes_sync=0', + '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()) diff --git a/tests/Query/Builder/ClickHouse/NamedBindingsTest.php b/tests/Query/Builder/ClickHouse/NamedBindingsTest.php index 7d45a1e..132800f 100644 --- a/tests/Query/Builder/ClickHouse/NamedBindingsTest.php +++ b/tests/Query/Builder/ClickHouse/NamedBindingsTest.php @@ -139,7 +139,7 @@ public function testDeleteUsesNamedTypedPlaceholdersWhenEnabled(): void ->delete(); $this->assertSame( - 'ALTER TABLE `audit_log` DELETE WHERE `time` < {param0:DateTime64(3)}', + 'DELETE FROM `audit_log` WHERE `time` < {param0:DateTime64(3)}', $result->query ); $this->assertSame( 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]), From 5cbe98853a28e8865c3e53d5448e8c373c4cdeaa Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 18 May 2026 01:44:59 +0000 Subject: [PATCH 07/16] fix(clickhouse): allow underscores in INSERT FORMAT name Standard ClickHouse formats are CamelCase, but user-registered or future format names may use underscores (e.g. `My_Format`). The previous regex threw `ValidationException` for valid identifiers. --- src/Query/Builder/ClickHouse.php | 2 +- .../Query/Builder/ClickHouse/InsertFormatTest.php | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 502bbad..38958c7 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -162,7 +162,7 @@ public function hint(string $hint): static */ public function insertFormat(string $format, array $columns = []): static { - if (!\preg_match('/^[A-Za-z][A-Za-z0-9]*$/', $format)) { + if (!\preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $format)) { throw new ValidationException('Invalid ClickHouse INSERT format: ' . $format); } diff --git a/tests/Query/Builder/ClickHouse/InsertFormatTest.php b/tests/Query/Builder/ClickHouse/InsertFormatTest.php index cb7a872..68fef93 100644 --- a/tests/Query/Builder/ClickHouse/InsertFormatTest.php +++ b/tests/Query/Builder/ClickHouse/InsertFormatTest.php @@ -78,6 +78,21 @@ public function testInsertFormatSupportsOtherClickHouseFormats(): void $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); From fed48b11f93c472af605df428e79e630d4040a62 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 18 May 2026 01:45:26 +0000 Subject: [PATCH 08/16] fix(clickhouse): accept nested parameterized types in withParamType The previous regex only allowed a single set of parentheses, so common ClickHouse types like `Nullable(DateTime64(3))` or `Array(Decimal(38, 18))` were rejected. Widened the pattern to allow one level of nested parentheses, which covers every ClickHouse type that has a parameterized inner type. --- src/Query/Builder/ClickHouse.php | 2 +- .../Builder/ClickHouse/NamedBindingsTest.php | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 38958c7..2cc32c6 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -361,7 +361,7 @@ public function useNamedBindings(bool $enabled = true): static */ public function withParamType(string $column, string $type): static { - if (! \preg_match('/^[A-Za-z][A-Za-z0-9_]*(?:\([^)]*\))?$/', $type)) { + if (! \preg_match('/^[A-Za-z][A-Za-z0-9_]*(?:\((?:[^()]*|\([^()]*\))*\))?$/', $type)) { throw new ValidationException('Invalid ClickHouse type: ' . $type); } diff --git a/tests/Query/Builder/ClickHouse/NamedBindingsTest.php b/tests/Query/Builder/ClickHouse/NamedBindingsTest.php index 132800f..4cf99af 100644 --- a/tests/Query/Builder/ClickHouse/NamedBindingsTest.php +++ b/tests/Query/Builder/ClickHouse/NamedBindingsTest.php @@ -166,6 +166,25 @@ public function testWithParamTypeRejectsInvalidTypeString(): void (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()) From 8b621541298a30ddb4ab1048a1cf51bce960221c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 18 May 2026 01:45:57 +0000 Subject: [PATCH 09/16] fix(clickhouse): reset clears namedBindings and paramTypes Previously a reused builder kept emitting `{paramN:Type}` placeholders after `reset()` even when the caller expected fresh positional bindings, and stale entries in `$paramTypes` could attach the wrong type to a column that shared a name across queries. Reset now restores both fields to their defaults. --- src/Query/Builder/ClickHouse.php | 2 + .../Builder/ClickHouse/NamedBindingsTest.php | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 2cc32c6..147eecf 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -335,6 +335,8 @@ public function reset(): static $this->insertFormatColumns = []; $this->bindingMeta = []; $this->deleteMode = self::DELETE_MODE_LIGHTWEIGHT; + $this->namedBindings = false; + $this->paramTypes = []; $this->resetGroupByModifier(); return $this; diff --git a/tests/Query/Builder/ClickHouse/NamedBindingsTest.php b/tests/Query/Builder/ClickHouse/NamedBindingsTest.php index 4cf99af..78169ab 100644 --- a/tests/Query/Builder/ClickHouse/NamedBindingsTest.php +++ b/tests/Query/Builder/ClickHouse/NamedBindingsTest.php @@ -208,4 +208,46 @@ public function testResetClearsBindingMetadata(): void ); $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 + ); + } } From d88f8dc7e549f00d2466e6c0afd77ee58b6addbf Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 18 May 2026 01:46:35 +0000 Subject: [PATCH 10/16] fix(clickhouse): preserve format metadata across FormattedInsertStatement::withExecutor `FormattedInsertStatement` previously inherited `Statement::withExecutor()`, which called `new self()` on the parent class and silently dropped the `columns` and `format` properties, returning a plain `Statement`. Adapters that chain `withExecutor()` on the result of a FORMAT INSERT would then crash on property-access. Added a covariant override that rebuilds a full `FormattedInsertStatement`, plus a regression test that asserts the returned instance keeps both fields. The constructor docblock is also realigned with the actual parameter order. --- .../ClickHouse/FormattedInsertStatement.php | 23 ++++++++++++++++--- .../Builder/ClickHouse/InsertFormatTest.php | 19 +++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/Query/Builder/ClickHouse/FormattedInsertStatement.php b/src/Query/Builder/ClickHouse/FormattedInsertStatement.php index b5d1bc8..7b3c079 100644 --- a/src/Query/Builder/ClickHouse/FormattedInsertStatement.php +++ b/src/Query/Builder/ClickHouse/FormattedInsertStatement.php @@ -2,14 +2,18 @@ namespace Utopia\Query\Builder\ClickHouse; +use Closure; use Utopia\Query\Builder\Statement; readonly class FormattedInsertStatement extends Statement { /** - * @param list $columns + * @param string $query * @param list $bindings - * @param (\Closure(Statement): (array|int))|null $executor + * @param list $columns + * @param string $format + * @param bool $readOnly + * @param (Closure(Statement): (array|int))|null $executor */ public function __construct( string $query, @@ -17,8 +21,21 @@ public function __construct( public array $columns, public string $format, bool $readOnly = false, - ?\Closure $executor = null, + ?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/tests/Query/Builder/ClickHouse/InsertFormatTest.php b/tests/Query/Builder/ClickHouse/InsertFormatTest.php index 68fef93..f38923c 100644 --- a/tests/Query/Builder/ClickHouse/InsertFormatTest.php +++ b/tests/Query/Builder/ClickHouse/InsertFormatTest.php @@ -137,6 +137,25 @@ public function testInsertWithoutFormatStillEmitsValues(): void $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()) From 8742417fff572bf990ac58ee3aca25a8a4f65180 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 18 May 2026 01:47:03 +0000 Subject: [PATCH 11/16] docs(clickhouse): flag raw-string body of createMaterializedView as caller-trusted The string overload of `createMaterializedView()` inlines its argument verbatim, so a caller who derives the body from any external source can inject SQL into the resulting DDL. Added an `@security` docblock notice that points callers at the Builder overload for parameterised inputs and makes the trust boundary explicit at the call site. --- src/Query/Schema/ClickHouse.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php index 2073e96..208debd 100644 --- a/src/Query/Schema/ClickHouse.php +++ b/src/Query/Schema/ClickHouse.php @@ -397,6 +397,12 @@ public function dropPartition(string $table, string $name): Statement * Accepts either a {@see Builder} (whose `build()` SQL is inlined and whose * bindings ride along on the returned Statement) or a raw SQL string for * bodies that do not yet round-trip through the builder. + * + * @security When `$body` is a string, its contents are inlined verbatim + * into the DDL with no escaping or validation. Pass only SQL + * that the caller fully controls — never a value derived from + * an untrusted source. Prefer the {@see Builder} overload when + * any part of the body is parameterised. */ public function createMaterializedView(string $name, string $targetTable, Builder|string $body, bool $ifNotExists = true): Statement { From 600beff67c98c99d005439bf1bd9aea43435eb78 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 18 May 2026 02:03:52 +0000 Subject: [PATCH 12/16] refactor(clickhouse): wire Binding value object through the rewriter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ClickHouse builder kept a parallel `list $bindingMeta` to remember which column produced each `?` placeholder, while `Builder\Binding` sat declared but unused. Replace the bare string array with `list` and trim `Binding` to the fields actually read at the rewrite site — `value` plus `column`. The `name` and `type` fields were never set by any caller and the `withName`/`withType` factories were never invoked. `resolveBindingType()` now reads `$bindingMeta[$index]->{column,value}` directly instead of indexing `$this->bindings` in parallel, and `addBinding()` / `addBindings()` construct the typed value objects so the meta list and the positional bindings list stay in lockstep. --- src/Query/Builder/Binding.php | 21 +++++---------------- src/Query/Builder/ClickHouse.php | 31 ++++++++++++++----------------- 2 files changed, 19 insertions(+), 33 deletions(-) diff --git a/src/Query/Builder/Binding.php b/src/Query/Builder/Binding.php index 23e07d2..744b278 100644 --- a/src/Query/Builder/Binding.php +++ b/src/Query/Builder/Binding.php @@ -3,14 +3,15 @@ namespace Utopia\Query\Builder; /** - * A single parameter binding annotated with optional name + type metadata. + * A single parameter binding annotated with its source column. * - * The base `Builder` keeps `$bindings` as a `list` and emits `?` - * placeholders, which positional-protocol drivers consume directly. + * The base {@see \Utopia\Query\Builder} keeps `$bindings` as a `list` + * and emits `?` placeholders, which positional-protocol drivers consume + * directly. * * Dialects that need typed named placeholders — ClickHouse HTTP, where * parameters are passed as `{name:Type}` query-string params — keep a - * parallel `list` so they can rewrite `?` to `{name:Type}` and + * parallel `list` so they can rewrite `?` to `{name:Type}` and * publish `Statement::$namedBindings` without disturbing the positional * path used by every other dialect. */ @@ -18,19 +19,7 @@ { public function __construct( public mixed $value, - public ?string $name = null, - public ?string $type = null, public ?string $column = null, ) { } - - public function withName(string $name): self - { - return new self($this->value, $name, $this->type, $this->column); - } - - public function withType(string $type): self - { - return new self($this->value, $this->name, $type, $this->column); - } } diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 147eecf..5013315 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -72,11 +72,11 @@ class ClickHouse extends BaseBuilder implements Hints, ConditionalAggregates, Ta protected array $paramTypes = []; /** - * Per-binding column hint captured at `addBinding()` time, kept in - * lockstep with `$this->bindings`. Index N here corresponds to the - * N-th `?` placeholder in the compiled SQL. + * Per-binding metadata captured at `addBinding()` time, kept in lockstep + * with `$this->bindings`. Index N here corresponds to the N-th `?` + * placeholder in the compiled SQL. * - * @var list + * @var list */ protected array $bindingMeta = []; @@ -385,15 +385,15 @@ public function withParamTypes(array $types): static } /** - * Track each binding's column hint in lockstep with the positional - * list so the placeholder rewriter can attach the right ClickHouse - * type to the right `?`. + * Track each binding's value + column hint in lockstep with the positional + * list so the placeholder rewriter can attach the right ClickHouse type to + * the right `?`. */ #[\Override] protected function addBinding(mixed $value, ?string $column = null): void { parent::addBinding($value, $column); - $this->bindingMeta[] = $column ?? $this->bindingColumn; + $this->bindingMeta[] = new Binding($value, $column ?? $this->bindingColumn); } /** @@ -403,8 +403,8 @@ protected function addBinding(mixed $value, ?string $column = null): void protected function addBindings(array $bindings): void { parent::addBindings($bindings); - foreach ($bindings as $_) { - $this->bindingMeta[] = $this->bindingColumn; + foreach ($bindings as $binding) { + $this->bindingMeta[] = new Binding($binding, $this->bindingColumn); } } @@ -432,16 +432,13 @@ private function inferClickHouseType(mixed $value): string */ private function resolveBindingType(int $index): string { - /** @var list $bindings */ - $bindings = $this->bindings; - $value = $bindings[$index] ?? null; + $binding = $this->bindingMeta[$index] ?? null; - $column = $this->bindingMeta[$index] ?? null; - if ($column !== null && isset($this->paramTypes[$column])) { - return $this->paramTypes[$column]; + if ($binding !== null && $binding->column !== null && isset($this->paramTypes[$binding->column])) { + return $this->paramTypes[$binding->column]; } - return $this->inferClickHouseType($value); + return $this->inferClickHouseType($binding?->value); } /** From e901374d4e0d7a4677d5cc2f6567288691e5bafb Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 18 May 2026 02:05:41 +0000 Subject: [PATCH 13/16] refactor(clickhouse): factor materialized views into Feature/Trait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createMaterializedView` and `dropMaterializedView` landed as inline public methods on `Schema\ClickHouse`, which deviated from the established Feature interface + Trait pattern that every other Schema feature (Views, Databases, Triggers, …) follows. There is no precedent for dialect-scoped Schema features in the codebase today, but the Builder side already mirrors `Feature/ClickHouse` + `Trait/ClickHouse` for CH-only features (ApproximateAggregates, ArrayJoins, AsofJoins, LimitBy, WithFill). Adopt the same segments here: new `Schema\Feature\ClickHouse\MaterializedViews` interface and `Schema\Trait\ClickHouse\MaterializedViews` trait. `Schema\ClickHouse` now `implements MaterializedViews` and `use`s the trait, matching how it already consumes `Views` and `Databases`. The `Builder|string` body union is left as-is — no `RawExpression` wrapper exists yet that could replace it, and introducing one would expand the change well beyond this refactor. --- src/Query/Schema/ClickHouse.php | 47 ++----------------- .../Feature/ClickHouse/MaterializedViews.php | 26 ++++++++++ .../Trait/ClickHouse/MaterializedViews.php | 38 +++++++++++++++ 3 files changed, 67 insertions(+), 44 deletions(-) create mode 100644 src/Query/Schema/Feature/ClickHouse/MaterializedViews.php create mode 100644 src/Query/Schema/Trait/ClickHouse/MaterializedViews.php diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php index 208debd..8b3f253 100644 --- a/src/Query/Schema/ClickHouse.php +++ b/src/Query/Schema/ClickHouse.php @@ -2,22 +2,23 @@ namespace Utopia\Query\Schema; -use Utopia\Query\Builder; use Utopia\Query\Builder\Statement; use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Exception\ValidationException; use Utopia\Query\QuotesIdentifiers; use Utopia\Query\Schema; use Utopia\Query\Schema\ClickHouse\Engine; +use Utopia\Query\Schema\Feature\ClickHouse\MaterializedViews; use Utopia\Query\Schema\Feature\ColumnComments; use Utopia\Query\Schema\Feature\Databases; use Utopia\Query\Schema\Feature\DropPartition; 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\ClickHouse\MaterializedViews; use Trait\Databases; use Trait\Views; @@ -390,46 +391,4 @@ public function dropPartition(string $table, string $name): Statement executor: $this->executor, ); } - - /** - * Emit `CREATE MATERIALIZED VIEW [IF NOT EXISTS] \`name\` TO \`target\` AS `. - * - * Accepts either a {@see Builder} (whose `build()` SQL is inlined and whose - * bindings ride along on the returned Statement) or a raw SQL string for - * bodies that do not yet round-trip through the builder. - * - * @security When `$body` is a string, its contents are inlined verbatim - * into the DDL with no escaping or validation. Pass only SQL - * that the caller fully controls — never a value derived from - * an untrusted source. Prefer the {@see Builder} overload when - * any part of the body is parameterised. - */ - public function createMaterializedView(string $name, string $targetTable, Builder|string $body, bool $ifNotExists = true): Statement - { - $bindings = []; - if ($body instanceof Builder) { - $built = $body->build(); - $bodySql = $built->query; - $bindings = $built->bindings; - } else { - $bodySql = $body; - } - - $sql = 'CREATE MATERIALIZED VIEW ' - . ($ifNotExists ? 'IF NOT EXISTS ' : '') - . $this->quote($name) - . ' 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/src/Query/Schema/Feature/ClickHouse/MaterializedViews.php b/src/Query/Schema/Feature/ClickHouse/MaterializedViews.php new file mode 100644 index 0000000..c112ad6 --- /dev/null +++ b/src/Query/Schema/Feature/ClickHouse/MaterializedViews.php @@ -0,0 +1,26 @@ +`. + * + * Accepts either a {@see Builder} (whose `build()` SQL is inlined and whose + * bindings ride along on the returned Statement) or a raw SQL string for + * bodies that do not yet round-trip through the builder. + * + * @security When `$body` is a string, its contents are inlined verbatim + * into the DDL with no escaping or validation. Pass only SQL + * that the caller fully controls — never a value derived from + * an untrusted source. Prefer the {@see Builder} overload when + * any part of the body is parameterised. + */ + public function createMaterializedView(string $name, string $targetTable, Builder|string $body, bool $ifNotExists = true): Statement; + + public function dropMaterializedView(string $name, bool $ifExists = true): Statement; +} diff --git a/src/Query/Schema/Trait/ClickHouse/MaterializedViews.php b/src/Query/Schema/Trait/ClickHouse/MaterializedViews.php new file mode 100644 index 0000000..cf6c6ac --- /dev/null +++ b/src/Query/Schema/Trait/ClickHouse/MaterializedViews.php @@ -0,0 +1,38 @@ +build(); + $bodySql = $built->query; + $bindings = $built->bindings; + } else { + $bodySql = $body; + } + + $sql = 'CREATE MATERIALIZED VIEW ' + . ($ifNotExists ? 'IF NOT EXISTS ' : '') + . $this->quote($name) + . ' 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); + } +} From 47af52fa52888055bad0288307d11a9c2f36f7e9 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 18 May 2026 02:07:14 +0000 Subject: [PATCH 14/16] test(clickhouse): move new builder + schema tests under Feature/ClickHouse/ Four new builder tests landed at `tests/Query/Builder/ClickHouse/`, a third layout next to the existing `tests/Query/Builder/Feature/ClickHouse/` (ApproximateAggregatesTest, ArrayJoinsTest, AsofJoinsTest). Move them into the established location and re-namespace from `Tests\Query\Builder\ClickHouse` to `Tests\Query\Builder\Feature\ClickHouse`: - InsertFormatTest - DeleteSettingsTest - GroupByTimeBucketTest - NamedBindingsTest The schema MV test (previously `tests/Query/Schema/ClickHouse/MaterializedViewTest`) also moves to `tests/Query/Schema/Feature/ClickHouse/MaterializedViewsTest` to mirror the new `Schema\Feature\ClickHouse\MaterializedViews` location that the previous commit introduced. Class renamed to `MaterializedViewsTest` to match the source feature name. All moves use `git mv` so file history follows. Test count is unchanged (5227 tests, 12166 assertions). --- .../Builder/{ => Feature}/ClickHouse/DeleteSettingsTest.php | 2 +- .../{ => Feature}/ClickHouse/GroupByTimeBucketTest.php | 2 +- .../Builder/{ => Feature}/ClickHouse/InsertFormatTest.php | 2 +- .../Builder/{ => Feature}/ClickHouse/NamedBindingsTest.php | 2 +- .../ClickHouse/MaterializedViewsTest.php} | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) rename tests/Query/Builder/{ => Feature}/ClickHouse/DeleteSettingsTest.php (99%) rename tests/Query/Builder/{ => Feature}/ClickHouse/GroupByTimeBucketTest.php (98%) rename tests/Query/Builder/{ => Feature}/ClickHouse/InsertFormatTest.php (99%) rename tests/Query/Builder/{ => Feature}/ClickHouse/NamedBindingsTest.php (99%) rename tests/Query/Schema/{ClickHouse/MaterializedViewTest.php => Feature/ClickHouse/MaterializedViewsTest.php} (97%) diff --git a/tests/Query/Builder/ClickHouse/DeleteSettingsTest.php b/tests/Query/Builder/Feature/ClickHouse/DeleteSettingsTest.php similarity index 99% rename from tests/Query/Builder/ClickHouse/DeleteSettingsTest.php rename to tests/Query/Builder/Feature/ClickHouse/DeleteSettingsTest.php index e1063d1..7be0caf 100644 --- a/tests/Query/Builder/ClickHouse/DeleteSettingsTest.php +++ b/tests/Query/Builder/Feature/ClickHouse/DeleteSettingsTest.php @@ -1,6 +1,6 @@ Date: Tue, 19 May 2026 01:25:50 +0000 Subject: [PATCH 15/16] refactor(builder): store binding column hint at bind time, drop parallel meta array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote $bindings on the base Builder from list to list, capturing each binding's source column at the moment addBinding($v, $col) is called. Expose getBindingValues() so dialects keep emitting Statement::$bindings as list — the public Statement API is unchanged. ClickHouse drops its parallel $bindingMeta array and the base Builder drops the $bindingColumn snapshot/try-finally machinery in compileFilter; the column hint now threads through compileIn, compileNotIn, compileComparison, compileBetween, compileLike, compileContains, compileContainsAll, compileNotContains, and compileRegex as an explicit parameter so dialect overrides forward it to addBinding(). Co-Authored-By: Claude Opus 4.7 --- src/Query/Builder.php | 164 ++++++++++--------- src/Query/Builder/Binding.php | 18 +- src/Query/Builder/ClickHouse.php | 75 ++------- src/Query/Builder/MongoDB.php | 18 +- src/Query/Builder/MySQL.php | 12 +- src/Query/Builder/PostgreSQL.php | 10 +- src/Query/Builder/SQLite.php | 4 +- src/Query/Builder/Trait/Deletes.php | 2 +- src/Query/Builder/Trait/Inserts.php | 6 +- src/Query/Builder/Trait/PostgreSQL/Merge.php | 2 +- src/Query/Builder/Trait/Selects.php | 2 +- src/Query/Builder/Trait/Updates.php | 2 +- src/Query/Builder/Trait/Upsert.php | 2 +- src/Query/Builder/Trait/UpsertSelect.php | 2 +- 14 files changed, 144 insertions(+), 175 deletions(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 1e12a3f..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,17 +86,16 @@ 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 = []; - /** - * Raw column name (pre-resolution) attached to the next-to-be-added - * bindings. Used by dialects with typed named placeholders to look up - * a registered parameter type for the column. - */ - protected ?string $bindingColumn = null; - /** * @var list */ @@ -233,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 { @@ -440,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); @@ -1271,44 +1271,38 @@ public function compileFilter(Query $query): string $rawAttribute = $query->getAttribute(); $attribute = $this->resolveAndWrap($rawAttribute); $values = $query->getValues(); + $column = $rawAttribute !== '' ? $rawAttribute : null; - $previousBindingColumn = $this->bindingColumn; - $this->bindingColumn = $rawAttribute !== '' ? $rawAttribute : $previousBindingColumn; - - try { - 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::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::IsNull => $attribute . ' IS NULL', - Method::IsNotNull => $attribute . ' IS NOT NULL', - Method::And => $this->compileLogical($query, 'AND'), - Method::Or => $this->compileLogical($query, 'OR'), - Method::Having => $this->compileLogical($query, 'AND'), - Method::Exists => $this->compileExists($query), - Method::NotExists => $this->compileNotExists($query), - Method::Raw => $this->compileRaw($query), - default => throw new UnsupportedException('Unsupported filter type: ' . $method->value), - }; - } finally { - $this->bindingColumn = $previousBindingColumn; - } + return match ($method) { + 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, $column), + Method::IsNull => $attribute . ' IS NULL', + Method::IsNotNull => $attribute . ' IS NOT NULL', + Method::And => $this->compileLogical($query, 'AND'), + Method::Or => $this->compileLogical($query, 'OR'), + Method::Having => $this->compileLogical($query, 'AND'), + Method::Exists => $this->compileExists($query), + Method::NotExists => $this->compileNotExists($query), + Method::Raw => $this->compileRaw($query), + default => throw new UnsupportedException('Unsupported filter type: ' . $method->value), + }; } protected function compileHavingCondition(Query $query, string $expression): string @@ -1597,36 +1591,54 @@ protected function resolveAndWrap(string $attribute): string } /** - * Append a single parameter binding. - * - * Most call sites pass just the value — the base builder produces - * positional `?` placeholders and only cares about ordering. Dialects - * that emit typed named placeholders (e.g. ClickHouse `{name:Type}`) - * may capture the optional `$column` hint to attach a registered type - * to the corresponding placeholder. + * 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; @@ -1636,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 . ' ?'; } @@ -1658,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 . ' ?'; } @@ -1674,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 . ' ?'; } @@ -1727,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'; @@ -1752,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) . ')'; @@ -1766,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'; @@ -1790,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) . ')'; } @@ -1810,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 . ' ?'; } @@ -1820,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 index 744b278..374a6c9 100644 --- a/src/Query/Builder/Binding.php +++ b/src/Query/Builder/Binding.php @@ -3,17 +3,15 @@ namespace Utopia\Query\Builder; /** - * A single parameter binding annotated with its source column. + * A single parameter binding captured at bind time, carrying its source + * column hint alongside the value. * - * The base {@see \Utopia\Query\Builder} keeps `$bindings` as a `list` - * and emits `?` placeholders, which positional-protocol drivers consume - * directly. - * - * Dialects that need typed named placeholders — ClickHouse HTTP, where - * parameters are passed as `{name:Type}` query-string params — keep a - * parallel `list` so they can rewrite `?` to `{name:Type}` and - * publish `Statement::$namedBindings` without disturbing the positional - * path used by every other dialect. + * The base {@see \Utopia\Query\Builder} stores `$bindings` as `list` + * 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 { diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php index 5013315..06fbc1d 100644 --- a/src/Query/Builder/ClickHouse.php +++ b/src/Query/Builder/ClickHouse.php @@ -71,15 +71,6 @@ class ClickHouse extends BaseBuilder implements Hints, ConditionalAggregates, Ta */ protected array $paramTypes = []; - /** - * Per-binding metadata captured at `addBinding()` time, kept in lockstep - * with `$this->bindings`. Index N here corresponds to the N-th `?` - * placeholder in the compiled SQL. - * - * @var list - */ - protected array $bindingMeta = []; - /** * Whether to rewrite `?` placeholders to ClickHouse `{name:Type}` form * at Statement creation time. Enabled by `useNamedBindings()`. @@ -333,7 +324,6 @@ public function reset(): static $this->rawJoinClauses = []; $this->insertFormat = null; $this->insertFormatColumns = []; - $this->bindingMeta = []; $this->deleteMode = self::DELETE_MODE_LIGHTWEIGHT; $this->namedBindings = false; $this->paramTypes = []; @@ -384,30 +374,6 @@ public function withParamTypes(array $types): static return $this; } - /** - * Track each binding's value + column hint in lockstep with the positional - * list so the placeholder rewriter can attach the right ClickHouse type to - * the right `?`. - */ - #[\Override] - protected function addBinding(mixed $value, ?string $column = null): void - { - parent::addBinding($value, $column); - $this->bindingMeta[] = new Binding($value, $column ?? $this->bindingColumn); - } - - /** - * @param array $bindings - */ - #[\Override] - protected function addBindings(array $bindings): void - { - parent::addBindings($bindings); - foreach ($bindings as $binding) { - $this->bindingMeta[] = new Binding($binding, $this->bindingColumn); - } - } - /** * Infer a ClickHouse type from a PHP value when no explicit registration * is available. Covers the four scalars used by the audit and usage @@ -432,7 +398,7 @@ private function inferClickHouseType(mixed $value): string */ private function resolveBindingType(int $index): string { - $binding = $this->bindingMeta[$index] ?? null; + $binding = $this->bindings[$index] ?? null; if ($binding !== null && $binding->column !== null && isset($this->paramTypes[$binding->column])) { return $this->paramTypes[$binding->column]; @@ -530,9 +496,9 @@ protected function compileGroupByTimeBucket(string $attribute, string $interval) * @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 . ', ?)'; } @@ -543,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]; @@ -551,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 . ', ?)'; } @@ -559,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 . ' ?'; @@ -578,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'; } @@ -602,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'; } @@ -620,18 +586,18 @@ 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'; } @@ -641,8 +607,6 @@ protected function compileNotContains(string $attribute, array $values): string #[\Override] public function build(): Statement { - $this->bindingMeta = []; - return $this->applyNamedTypedBindings(parent::build()); } @@ -651,13 +615,10 @@ public function insert(): Statement { $format = $this->insertFormat; if ($format === null) { - $this->bindingMeta = []; - return $this->applyNamedTypedBindings(parent::insert()); } $this->bindings = []; - $this->bindingMeta = []; $this->validateTable(); $columns = !empty($this->insertFormatColumns) @@ -696,7 +657,6 @@ public function insert(): Statement public function update(): Statement { $this->bindings = []; - $this->bindingMeta = []; $this->validateTable(); $assignments = $this->compileAssignments(); @@ -718,7 +678,7 @@ public function update(): Statement . ' ' . \implode(' ', $parts); return $this->applyNamedTypedBindings( - new Statement($sql, $this->bindings, executor: $this->executor) + new Statement($sql, $this->getBindingValues(), executor: $this->executor) ); } @@ -748,7 +708,6 @@ public function deleteMode(string $mode): static public function delete(): Statement { $this->bindings = []; - $this->bindingMeta = []; $this->validateTable(); $parts = []; @@ -769,7 +728,7 @@ public function delete(): Statement } return $this->applyNamedTypedBindings( - new Statement($sql, $this->bindings, executor: $this->executor) + new Statement($sql, $this->getBindingValues(), executor: $this->executor) ); } 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/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/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); } } From 0af2688dc5541b2b0b02e3c8892f97e52d9f175e Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 19 May 2026 01:28:52 +0000 Subject: [PATCH 16/16] refactor(schema): promote MaterializedViews feature to cross-dialect level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the MaterializedViews Feature interface and Trait out of the ClickHouse subnamespace so other dialects (e.g. PostgreSQL, which has its own MV form) can opt in by implementing/using them. Reorder `createMaterializedView()` so `$targetTable` is optional — ClickHouse uses it to emit `… TO target AS …`, dialects whose MVs own their own storage omit it. Co-Authored-By: Claude Opus 4.7 --- src/Query/Schema/ClickHouse.php | 4 ++-- .../{ClickHouse => }/MaterializedViews.php | 17 +++++++++----- .../{ClickHouse => }/MaterializedViews.php | 6 ++--- .../ClickHouse/MaterializedViewsTest.php | 23 +++++++++++++++---- 4 files changed, 35 insertions(+), 15 deletions(-) rename src/Query/Schema/Feature/{ClickHouse => }/MaterializedViews.php (50%) rename src/Query/Schema/Trait/{ClickHouse => }/MaterializedViews.php (76%) diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php index 8b3f253..5cbcbb6 100644 --- a/src/Query/Schema/ClickHouse.php +++ b/src/Query/Schema/ClickHouse.php @@ -8,18 +8,18 @@ use Utopia\Query\QuotesIdentifiers; use Utopia\Query\Schema; use Utopia\Query\Schema\ClickHouse\Engine; -use Utopia\Query\Schema\Feature\ClickHouse\MaterializedViews; 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, MaterializedViews { use QuotesIdentifiers; - use Trait\ClickHouse\MaterializedViews; use Trait\Databases; + use Trait\MaterializedViews; use Trait\Views; #[\Override] diff --git a/src/Query/Schema/Feature/ClickHouse/MaterializedViews.php b/src/Query/Schema/Feature/MaterializedViews.php similarity index 50% rename from src/Query/Schema/Feature/ClickHouse/MaterializedViews.php rename to src/Query/Schema/Feature/MaterializedViews.php index c112ad6..8c5b5cd 100644 --- a/src/Query/Schema/Feature/ClickHouse/MaterializedViews.php +++ b/src/Query/Schema/Feature/MaterializedViews.php @@ -1,6 +1,6 @@ `. + * Emit a `CREATE MATERIALIZED VIEW` DDL. * - * Accepts either a {@see Builder} (whose `build()` SQL is inlined and whose - * bindings ride along on the returned Statement) or a raw SQL string for - * bodies that do not yet round-trip through the builder. + * Accepts either a {@see Builder} (whose `build()` SQL is inlined and + * whose bindings ride along on the returned Statement) or a raw SQL + * string for bodies that do not yet round-trip through the builder. + * + * `$targetTable` is the ClickHouse-specific destination for the + * materialised aggregate (`CREATE MATERIALIZED VIEW … TO target AS …`). + * Dialects whose materialised views own their own storage (e.g. + * PostgreSQL) ignore the hint. * * @security When `$body` is a string, its contents are inlined verbatim * into the DDL with no escaping or validation. Pass only SQL @@ -20,7 +25,7 @@ interface MaterializedViews * an untrusted source. Prefer the {@see Builder} overload when * any part of the body is parameterised. */ - public function createMaterializedView(string $name, string $targetTable, Builder|string $body, bool $ifNotExists = true): Statement; + public function createMaterializedView(string $name, Builder|string $body, ?string $targetTable = null, bool $ifNotExists = true): Statement; public function dropMaterializedView(string $name, bool $ifExists = true): Statement; } diff --git a/src/Query/Schema/Trait/ClickHouse/MaterializedViews.php b/src/Query/Schema/Trait/MaterializedViews.php similarity index 76% rename from src/Query/Schema/Trait/ClickHouse/MaterializedViews.php rename to src/Query/Schema/Trait/MaterializedViews.php index cf6c6ac..0950de7 100644 --- a/src/Query/Schema/Trait/ClickHouse/MaterializedViews.php +++ b/src/Query/Schema/Trait/MaterializedViews.php @@ -1,13 +1,13 @@ quote($name) - . ' TO ' . $this->quote($targetTable) + . ($targetTable !== null ? ' TO ' . $this->quote($targetTable) : '') . ' AS ' . $bodySql; return new Statement($sql, $bindings, executor: $this->executor); diff --git a/tests/Query/Schema/Feature/ClickHouse/MaterializedViewsTest.php b/tests/Query/Schema/Feature/ClickHouse/MaterializedViewsTest.php index 9ca782f..fb2538c 100644 --- a/tests/Query/Schema/Feature/ClickHouse/MaterializedViewsTest.php +++ b/tests/Query/Schema/Feature/ClickHouse/MaterializedViewsTest.php @@ -20,8 +20,8 @@ public function testCreateMaterializedViewFromRawBody(): void $result = $schema->createMaterializedView( 'usage_events_daily_mv', - 'usage_events_daily', $body, + 'usage_events_daily', ); $this->assertBindingCount($result); @@ -38,8 +38,8 @@ public function testCreateMaterializedViewWithoutIfNotExists(): void $result = $schema->createMaterializedView( 'daily_mv', - 'daily', 'SELECT * FROM `events`', + 'daily', false, ); @@ -59,8 +59,8 @@ public function testCreateMaterializedViewFromBuilder(): void $result = $schema->createMaterializedView( 'active_events_mv', - 'active_events', $builder, + 'active_events', ); $this->assertSame( @@ -71,6 +71,21 @@ public function testCreateMaterializedViewFromBuilder(): void $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(); @@ -88,8 +103,8 @@ public function testCreateMaterializedViewDropInReplacementForUsageAdapter(): vo $result = $schema->createMaterializedView( 'usage_events_daily_mv', - 'usage_events_daily', $body, + 'usage_events_daily', ); $expected = 'CREATE MATERIALIZED VIEW IF NOT EXISTS `usage_events_daily_mv` TO `usage_events_daily` AS '