From 597c4fa8047f88805cea452f9cfc66880799614e Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 6 Jul 2026 03:42:08 +0000 Subject: [PATCH 1/6] feat!: migrate ClickHouse adapter to utopia-php/query 0.3.x builder Bump utopia-php/query from 0.1.* to 0.3.* and compile the ClickHouse adapter's SQL through the shared builder and schema layer instead of hand-assembled strings: - Reads (find, count, sum, totals, time series, daily, hybrid daily+raw union) build through the ClickHouse query builder with typed named bindings; every schema column is pinned to its ClickHouse parameter type so placeholder types never fall back to PHP value inference. - CREATE TABLE for the events/gauges/daily tables and the daily materialized view go through the schema layer. Column shapes the typed API cannot express (DateTime64 timezone argument, LowCardinality(Nullable(...)), hyphenated skip-index names) are emitted as raw definitions so deployed DDL is byte-for-byte unchanged. Projections, ALTER-based dim backfills, the retention TTL ALTERs and the MV body stay raw SQL, which the schema layer cannot express yet. - INSERT envelopes compile via insertFormat(); the JSONEachRow body assembly and transport are unchanged. - Query 0.3 API: getMethod() returns the Method enum, so all string TYPE_* comparisons are gone. UsageQuery keeps its groupByInterval() and groupBy() factories, now mapped to Method::GroupByTimeBucket and Method::GroupBy; groupBy() also accepts an array of columns to stay signature-compatible with the base class. - contains()/containsAny()/notContains() pass straight through to the builder, which compiles them to position(column, needle) on ClickHouse. That keeps the utopia-php/database substring semantics while matching needles literally, so the adapter no longer has to escape LIKE wildcards itself. --- CHANGELOG.md | 24 + composer.json | 2 +- composer.lock | 17 +- src/Usage/Adapter/ClickHouse.php | 1483 ++++++++++++------------ src/Usage/Adapter/Database.php | 40 +- src/Usage/UsageQuery.php | 55 +- tests/Usage/Adapter/ClickHouseTest.php | 3 +- tests/Usage/UsageQueryTest.php | 27 +- 8 files changed, 835 insertions(+), 816 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f01de9af..0fd63d5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## Unreleased — query 0.3.x builder + +### Breaking + +- Bumped `utopia-php/query` from `0.1.*` to `0.3.*`. + `Query::getMethod()` now returns the `Utopia\Query\Method` enum + instead of a string, and the `Query::TYPE_*` / + `UsageQuery::TYPE_GROUP_BY_INTERVAL` / `UsageQuery::TYPE_GROUP_BY` + string constants are gone. Compare against `Method::GroupByTimeBucket` + and `Method::GroupBy` instead; the `UsageQuery::groupByInterval()` + and `UsageQuery::groupBy()` factories are unchanged (`groupBy()` also + accepts an array of columns now, matching the base class). +- The ClickHouse adapter compiles its SQL through the utopia-php/query + ClickHouse builder and schema layer (typed named bindings, schema-level + CREATE TABLE / materialized view). Emitted DDL and query semantics are + unchanged; projections, dim-column backfills and the daily + materialized-view body stay raw SQL because the schema layer cannot + express them yet. +- `contains()` / `containsAny()` / `notContains()` keep the + utopia-php/database substring semantics, but now compile through the + builder to `position(column, needle)` instead of `LIKE '%needle%'`. + Needles are matched literally, so `%` and `_` no longer need + escaping. Matching behaviour is unchanged. + ## 0.11.0 — sdk dimensions ### Added diff --git a/composer.json b/composer.json index e08545fd..dcf2043b 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "psr/http-client": "^1.0", "utopia-php/client": "^0.1|^0.2", "utopia-php/database": "^6.0.0", - "utopia-php/query": "0.1.*" + "utopia-php/query": "0.3.*" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/composer.lock b/composer.lock index 1a974dbc..fb198c79 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5278bda64bd4289d43cdbc2c6599d7e0", + "content-hash": "c9712fe08ef553e837cce3c44267c320", "packages": [ { "name": "brick/math", @@ -2427,24 +2427,27 @@ }, { "name": "utopia-php/query", - "version": "0.1.1", + "version": "0.3.3", "source": { "type": "git", "url": "https://github.com/utopia-php/query.git", - "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27" + "reference": "76cd71071cd59cb45ef6b30eb11449f3832bf18f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/query/zipball/964a10ed3185490505f4c0062f2eb7b89287fb27", - "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27", + "url": "https://api.github.com/repos/utopia-php/query/zipball/76cd71071cd59cb45ef6b30eb11449f3832bf18f", + "reference": "76cd71071cd59cb45ef6b30eb11449f3832bf18f", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { + "brianium/paratest": "*", "laravel/pint": "*", + "mongodb/mongodb": "^2.0", "phpstan/phpstan": "*", + "phpunit/phpcov": "*", "phpunit/phpunit": "^12.0" }, "type": "library", @@ -2467,9 +2470,9 @@ ], "support": { "issues": "https://github.com/utopia-php/query/issues", - "source": "https://github.com/utopia-php/query/tree/0.1.1" + "source": "https://github.com/utopia-php/query/tree/0.3.3" }, - "time": "2026-03-03T09:05:14+00:00" + "time": "2026-07-06T04:55:56+00:00" }, { "name": "utopia-php/span", diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 068b5bcd..74765058 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -2,6 +2,7 @@ namespace Utopia\Usage\Adapter; +use ArrayObject; use DateTime; use DateTimeZone; use Exception; @@ -9,9 +10,14 @@ use Throwable; use Utopia\Client; use Utopia\Client\Adapter\Curl\Client as CurlAdapter; -use Utopia\Psr7\Method; +use Utopia\Psr7\Method as HttpMethod; use Utopia\Psr7\Request\Factory as RequestFactory; +use Utopia\Query\Builder\ClickHouse as ClickHouseBuilder; +use Utopia\Query\Method; use Utopia\Query\Query; +use Utopia\Query\Schema\ClickHouse as ClickHouseSchema; +use Utopia\Query\Schema\ClickHouse\Engine; +use Utopia\Query\Schema\Table\ClickHouse as ClickHouseTable; use Utopia\Usage\Metric; use Utopia\Usage\Usage; use Utopia\Usage\UsageQuery; @@ -62,21 +68,22 @@ class ClickHouse extends SQL * arrays for these methods are rejected up front so they can't silently * compile into a "no filter applied" WHERE clause. * - * @var list + * @var list */ private const VALUE_REQUIRED_METHODS = [ - Query::TYPE_EQUAL, - Query::TYPE_NOT_EQUAL, - Query::TYPE_LESSER, - Query::TYPE_LESSER_EQUAL, - Query::TYPE_GREATER, - Query::TYPE_GREATER_EQUAL, - Query::TYPE_BETWEEN, - Query::TYPE_NOT_BETWEEN, - Query::TYPE_CONTAINS, - Query::TYPE_NOT_CONTAINS, - Query::TYPE_STARTS_WITH, - Query::TYPE_ENDS_WITH, + Method::Equal, + Method::NotEqual, + Method::LessThan, + Method::LessThanEqual, + Method::GreaterThan, + Method::GreaterThanEqual, + Method::Between, + Method::NotBetween, + Method::Contains, + Method::ContainsAny, + Method::NotContains, + Method::StartsWith, + Method::EndsWith, ]; private readonly string $host; @@ -522,7 +529,7 @@ private function query(string $sql, array $params = []): string $this->requestCount++; - $request = $this->requestFactory->multipart(Method::POST, $url, $parts, $this->buildHeaders()); + $request = $this->requestFactory->multipart(HttpMethod::POST, $url, $parts, $this->buildHeaders()); try { $response = $this->client->sendRequest($request); @@ -602,13 +609,14 @@ private function buildHeaders(): array } /** - * Execute a ClickHouse INSERT using JSONEachRow format. + * Execute a ClickHouse INSERT using a FORMAT JSONEachRow envelope. * - * @param string $table Table name + * @param string $table Table name (for error messages) + * @param string $sql INSERT envelope compiled by the builder * @param array $data Array of JSON strings (one per row) * @throws Exception */ - private function insert(string $table, array $data): void + private function insert(string $table, string $sql, array $data): void { if (empty($data)) { return; @@ -619,10 +627,9 @@ private function insert(string $table, array $data): void // leaves duplicate rows behind. The default transport does not retry // POST; any injected retry strategy must keep it that way. $scheme = $this->secure ? 'https' : 'http'; - $escapedTable = $this->escapeIdentifier($table); $rowCount = count($data); - $queryParams = ['query' => "INSERT INTO {$escapedTable} FORMAT JSONEachRow"]; + $queryParams = ['query' => $sql]; if ($this->asyncInserts) { $queryParams['async_insert'] = '1'; $queryParams['wait_for_async_insert'] = $this->asyncInsertWait ? '1' : '0'; @@ -633,7 +640,7 @@ private function insert(string $table, array $data): void $body = implode("\n", $data); - $request = $this->requestFactory->body(Method::POST, $url, $body, 'application/x-ndjson', $this->buildHeaders()); + $request = $this->requestFactory->body(HttpMethod::POST, $url, $body, 'application/x-ndjson', $this->buildHeaders()); try { $response = $this->client->sendRequest($request); @@ -685,6 +692,177 @@ private function formatParamValue(mixed $value): string return ''; } + /** + * Column-to-ClickHouse-type map used for the builder's typed named + * bindings. Every schema column is pinned so placeholder types never + * fall back to PHP value inference — binding an int against a String + * column (or vice versa) would produce a ClickHouse type mismatch. + * + * @return array + */ + private function getParamTypeMap(string $type): array + { + $map = [ + 'id' => 'String', + 'metric' => 'String', + ]; + + foreach ($this->getAttributes($type) as $attribute) { + $id = $attribute['$id']; + if (!is_string($id)) { + continue; + } + $map[$id] = $this->getParamType($id); + } + + if ($this->sharedTables) { + $map['tenant'] = 'String'; + } + + return $map; + } + + private function newBuilder(string $type = Usage::TYPE_EVENT): ClickHouseBuilder + { + $builder = new ClickHouseBuilder(); + $builder->useNamedBindings()->withParamTypes($this->getParamTypeMap($type)); + + return $builder; + } + + private function newSchema(): ClickHouseSchema + { + return new ClickHouseSchema(); + } + + /** + * The schema layer and builder emit bare table identifiers (`name`). + * The runtime adapter operates against a specific database, so emitted + * SQL is rewritten with the qualified `db`.`name` form. + */ + private function qualifyDdl(string $sql, string ...$tables): string + { + foreach ($tables as $table) { + $bare = $this->escapeIdentifier($table); + $qualified = $this->buildTableReference($table); + $sql = str_replace($bare, $qualified, $sql); + } + + return $sql; + } + + /** + * Rename the builder's named bindings with a prefix so two compiled + * statements can be merged into one SQL string (e.g. UNION ALL) without + * `param0`, `param1`, … colliding between the two sides. + * + * @param array $bindings + * @return array{0: string, 1: array} + */ + private function prefixNamedBindings(string $sql, array $bindings, string $prefix): array + { + $renamed = []; + foreach ($bindings as $key => $value) { + $newKey = $prefix . $key; + $pattern = '/\{' . preg_quote($key, '/') . '(:[^}]+)\}/'; + $sql = preg_replace($pattern, '{' . $newKey . '$1}', $sql) ?? $sql; + $renamed[$newKey] = $value; + } + + return [$sql, $renamed]; + } + + /** + * Bake the tenant scope into the builder's WHERE chain. In shared-tables + * mode an empty tenant would compile to `tenant = ''` and silently read + * an empty scope — fail fast instead, like the write side. ("0" is a + * valid tenant id, so check for '' specifically.) + * + * @throws Exception + */ + private function applyTenantFilter(ClickHouseBuilder $builder, string $tenant): void + { + if (!$this->sharedTables) { + return; + } + + if ($tenant === '') { + throw new Exception('Tenant cannot be empty in shared-tables mode'); + } + + $builder->filter([Query::equal('tenant', [$tenant])]); + } + + /** + * Push the parsed filter queries plus tenant scope through the builder. + * + * @param array{filters: array} $parsed + */ + private function applyFilters(ClickHouseBuilder $builder, string $tenant, array $parsed): void + { + $this->applyTenantFilter($builder, $tenant); + + if (!empty($parsed['filters'])) { + $builder->filter($parsed['filters']); + } + } + + /** + * Walk an array of Query objects and rewrite `time` values into ClickHouse + * wire format (`Y-m-d H:i:s.v`). The builder forwards values verbatim, so + * datetime normalisation must happen up front before the values reach the + * `{paramN:DateTime64(3, 'UTC')}` placeholder slot. + * + * @param array $queries + * @return array + * + * @throws Exception + */ + private function normalizeTimeValues(array $queries): array + { + $normalized = []; + foreach ($queries as $query) { + if ($query->getAttribute() !== 'time' || !$query->getMethod()->isFilter()) { + $normalized[] = $query; + continue; + } + + $values = $query->getValues(); + $rewritten = []; + foreach ($values as $value) { + if ($value === null) { + $rewritten[] = null; + continue; + } + if ($value instanceof DateTime || is_string($value)) { + $rewritten[] = $this->formatDateTime($value); + continue; + } + $rewritten[] = $value; + } + + $clone = clone $query; + $clone->setValues($rewritten); + $normalized[] = $clone; + } + + return $normalized; + } + + /** + * Decode a single integer aggregate (`data[0].total`) from a ClickHouse + * `FORMAT JSON` response. Returns 0 when the payload is absent. + */ + private function decodeTotal(string $result): int + { + $rows = $this->decodeRows($result); + if (!isset($rows[0]['total'])) { + return 0; + } + + return self::toInt($rows[0]['total']); + } + /** * Per-dim projection slate. Each entry declares an `ADD PROJECTION` on * the base events table. The ClickHouse optimizer transparently routes @@ -916,7 +1094,7 @@ private function addProjection(string $baseTable, string $name, array $dims, str } /** - * Create a MergeTree table for the given type. + * Create a MergeTree table for the given type via the schema layer. * * @param string $tableName * @param string $type 'event' or 'gauge' @@ -925,25 +1103,27 @@ private function addProjection(string $baseTable, string $name, array $dims, str */ private function createTable(string $tableName, string $type, array $indexes): void { - $columns = ['id String ' . $this->getColumnCodec('id')]; + $table = $this->newSchema()->table($tableName); + + $idColumn = $table->string('id'); + foreach ($this->getColumnCodecParts('id') as $codec) { + $idColumn->codec($codec); + } foreach ($this->getAttributes($type) as $attribute) { /** @var string $id */ $id = $attribute['$id']; - - if ($id === 'time') { - $columns[] = "time DateTime64(3, 'UTC') " . $this->getColumnCodec('time'); - } else { - $columns[] = $this->getColumnDefinition($id, $type); - } + $this->declareColumn($table, $id, $type); } // Add tenant column only if tables are shared across tenants if ($this->sharedTables) { - $columns[] = 'tenant Nullable(String)'; + $table->string('tenant')->nullable(); } - $indexDefs = []; + // Index names carry hyphens (`index-path`), which the schema layer's + // typed index API rejects for ClickHouse skip indexes, so the INDEX + // clauses are emitted raw to keep the deployed names unchanged. foreach ($indexes as $index) { /** @var string $indexName */ $indexName = $index['$id']; @@ -951,34 +1131,71 @@ private function createTable(string $tableName, string $type, array $indexes): v $attributes = $index['attributes']; $indexType = is_string($index['indexType'] ?? null) ? $index['indexType'] : 'bloom_filter'; $escapedIndexName = $this->escapeIdentifier($indexName); - $escapedAttributes = array_map(fn ($attr) => $this->escapeIdentifier($attr), $attributes); - $attributeList = implode(', ', $escapedAttributes); - $indexDefs[] = "INDEX {$escapedIndexName} ({$attributeList}) TYPE {$indexType} GRANULARITY 1"; + $attributeList = implode(', ', array_map($this->escapeIdentifier(...), $attributes)); + $table->rawColumn("INDEX {$escapedIndexName} ({$attributeList}) TYPE {$indexType} GRANULARITY 1"); } - $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - - $columnDefs = implode(",\n ", $columns); - $indexDefsStr = !empty($indexDefs) ? ",\n " . implode(",\n ", $indexDefs) : ''; - // Primary key matches the most common filter pattern: // tenant (multi-tenant isolation) → metric (per-metric series) → // time (range scans). id is the tiebreaker for stable physical // ordering. This shape lets ClickHouse skip whole granules on // metric+time predicates instead of doing a full-table scan. - $orderByExpr = $this->sharedTables ? '(tenant, metric, time, id)' : '(metric, time, id)'; + $table->engine(Engine::MergeTree) + ->orderBy($this->sharedTables ? ['tenant', 'metric', 'time', 'id'] : ['metric', 'time', 'id']) + ->partitionBy('toYYYYMM(time)') + ->settings(['index_granularity' => 8192, 'allow_nullable_key' => 1]); - $createTableSql = " - CREATE TABLE IF NOT EXISTS {$escapedDatabaseAndTable} ( - {$columnDefs}{$indexDefsStr} - ) - ENGINE = MergeTree() - ORDER BY {$orderByExpr} - PARTITION BY toYYYYMM(time) - SETTINGS index_granularity = 8192, allow_nullable_key = 1 - "; + $statement = $table->createIfNotExists(); + + $this->query($this->qualifyDdl($statement->query, $tableName)); + } + + /** + * Declare a column on the schema table, mapping the Metric attribute + * schema to typed column kinds. Column shapes the typed API cannot + * express — `DateTime64(3, 'UTC')` (timezone argument) and + * `LowCardinality(Nullable(String))` (nullable inside the wrapper) — + * fall back to a raw definition so the emitted DDL stays identical. + * + * @throws Exception + */ + private function declareColumn(ClickHouseTable $table, string $id, string $type): void + { + if ($id === 'time') { + $table->rawColumn("`time` DateTime64(3, 'UTC') " . $this->getColumnCodec('time')); + + return; + } + + $columnType = $this->getColumnType($id, $type); - $this->query($createTableSql); + if (str_contains($columnType, 'LowCardinality(') || str_contains($columnType, 'DateTime64(')) { + $table->rawColumn($this->getColumnDefinition($id, $type)); + return; + } + + $attribute = $this->getAttribute($id, $type); + if ($attribute === null) { + throw new Exception("Attribute {$id} not found in {$type} schema"); + } + + $attributeType = is_string($attribute['type'] ?? null) ? $attribute['type'] : 'string'; + $required = (bool) ($attribute['required'] ?? false); + + $column = match ($attributeType) { + 'integer' => $table->bigInteger($id), + 'float' => $table->float($id), + 'boolean' => $table->boolean($id), + default => $table->string($id), + }; + + if (!$required) { + $column->nullable(); + } + + foreach ($this->getColumnCodecParts($id) as $codec) { + $column->codec($codec); + } } /** @@ -992,40 +1209,35 @@ private function createTable(string $tableName, string $type, array $indexes): v private function createDailyTable(): void { $dailyTableName = $this->getEventsDailyTableName(); - $escapedDailyTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($dailyTableName); - - $columns = [ - 'metric String', - 'value Int64', - "time DateTime64(3, 'UTC')", - 'resourceType LowCardinality(Nullable(String))', - 'resourceId Nullable(String)', - 'resourceInternalId Nullable(String)', - 'teamId Nullable(String)', - 'teamInternalId Nullable(String)', - ]; + + $table = $this->newSchema()->table($dailyTableName); + + $table->string('metric'); + $table->bigInteger('value'); + $table->rawColumn("time DateTime64(3, 'UTC')"); + $table->rawColumn('resourceType LowCardinality(Nullable(String))'); + $table->string('resourceId')->nullable(); + $table->string('resourceInternalId')->nullable(); + $table->string('teamId')->nullable(); + $table->string('teamInternalId')->nullable(); if ($this->sharedTables) { - $columns[] = 'tenant Nullable(String)'; + $table->string('tenant')->nullable(); } - $columnDefs = implode(",\n ", $columns); + $dailyOrderBy = ['metric', 'time', 'resourceType', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId']; + if ($this->sharedTables) { + array_unshift($dailyOrderBy, 'tenant'); + } - $dailyOrderBy = $this->sharedTables - ? '(tenant, metric, time, resourceType, resourceId, resourceInternalId, teamId, teamInternalId)' - : '(metric, time, resourceType, resourceId, resourceInternalId, teamId, teamInternalId)'; + $table->engine(Engine::SummingMergeTree) + ->orderBy($dailyOrderBy) + ->partitionBy('toYYYYMM(time)') + ->settings(['index_granularity' => 8192, 'allow_nullable_key' => 1]); - $createDailyTableSql = " - CREATE TABLE IF NOT EXISTS {$escapedDailyTable} ( - {$columnDefs} - ) - ENGINE = SummingMergeTree() - ORDER BY {$dailyOrderBy} - PARTITION BY toYYYYMM(time) - SETTINGS index_granularity = 8192, allow_nullable_key = 1 - "; + $statement = $table->createIfNotExists(); - $this->query($createDailyTableSql); + $this->query($this->qualifyDdl($statement->query, $dailyTableName)); } /** @@ -1039,9 +1251,7 @@ private function createDailyMaterializedView(): void $dailyTableName = $this->getEventsDailyTableName(); $dailyMvName = $this->getTableName() . '_events_daily_mv'; - $escapedEventsTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($eventsTable); - $escapedDailyTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($dailyTableName); - $escapedDailyMv = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($dailyMvName); + $escapedEventsTable = $this->buildTableReference($eventsTable); $dimensions = 'resourceType, resourceId, resourceInternalId, teamId, teamInternalId'; @@ -1055,18 +1265,23 @@ private function createDailyMaterializedView(): void $outerSelect = "metric, value, d as time, {$dimensions}"; } - $createDailyMvSql = " - CREATE MATERIALIZED VIEW IF NOT EXISTS {$escapedDailyMv} - TO {$escapedDailyTable} - AS SELECT {$outerSelect} - FROM ( - SELECT {$innerSelect} - FROM {$escapedEventsTable} - GROUP BY {$innerGroupBy} - ) - "; + // The MV body needs an inner aggregation subquery, which the builder + // does not round-trip cleanly yet, so the SELECT stays hand-written. + $body = "SELECT {$outerSelect}" + . " FROM (" + . " SELECT {$innerSelect}" + . " FROM {$escapedEventsTable}" + . " GROUP BY {$innerGroupBy}" + . " )"; + + $statement = $this->newSchema()->createMaterializedView( + $dailyMvName, + $body, + $dailyTableName, + true, + ); - $this->query($createDailyMvSql); + $this->query($this->qualifyDdl($statement->query, $dailyMvName, $dailyTableName)); } /** @@ -1271,6 +1486,25 @@ private function getColumnCodec(string $id): string return ''; } + /** + * Return the per-column codec clauses as a list consumable by the schema + * layer's `Column::codec()` (e.g. ['Delta(4)', 'LZ4']). Empty when no + * codec is overridden for this column. + * + * @return list + */ + private function getColumnCodecParts(string $id): array + { + $codec = $this->getColumnCodec($id); + if ($codec === '') { + return []; + } + + $inner = substr($codec, strlen('CODEC('), -1); + + return array_map(trim(...), explode(',', $inner)); + } + /** * Validate metric data for batch operations. * @@ -1375,6 +1609,12 @@ public function addBatch(array $metrics, string $type, int $batchSize = self::IN $tableName = $this->getTableForType($type); + $statement = $this->newBuilder($type) + ->into($tableName) + ->insertFormat('JSONEachRow', $this->getInsertColumns($type)) + ->insert(); + $insertSql = $this->qualifyDdl($statement->query, $tableName); + foreach (\array_chunk($metrics, $batchSize) as $metricsBatch) { $rows = []; @@ -1413,12 +1653,35 @@ public function addBatch(array $metrics, string $type, int $batchSize = self::IN $rows[] = $encoded; } - $this->insert($tableName, $rows); + $this->insert($tableName, $insertSql, $rows); } return true; } + /** + * Columns declared in the INSERT envelope for the given type. Matches + * the row shape produced by addBatch(): base columns, the type's + * dimension columns, and tenant in shared-tables mode. + * + * @return list + */ + private function getInsertColumns(string $type): array + { + $columns = ['id', 'metric', 'value', 'time']; + + $dimensions = $type === Usage::TYPE_GAUGE ? Metric::GAUGE_COLUMNS : Metric::EVENT_COLUMNS; + foreach ($dimensions as $column) { + $columns[] = $column; + } + + if ($this->sharedTables) { + $columns[] = 'tenant'; + } + + return $columns; + } + /** * Resolve tenant for a single metric entry. * @@ -1461,10 +1724,10 @@ public function find(string $tenant, array $queries = [], ?string $type = null): $userLimit = null; foreach ($queries as $query) { $method = $query->getMethod(); - if ($method === Query::TYPE_CURSOR_AFTER || $method === Query::TYPE_CURSOR_BEFORE) { + if ($method === Method::CursorAfter || $method === Method::CursorBefore) { throw new Exception('Cursor pagination requires an explicit $type (event or gauge)'); } - if ($method === Query::TYPE_LIMIT) { + if ($method === Method::Limit) { $values = $query->getValues(); if (!empty($values) && is_numeric($values[0])) { $userLimit = (int) $values[0]; @@ -1529,8 +1792,8 @@ private function queriesMatchType(array $queries, string $type): bool * Find metrics from a specific table. * * When a `groupByInterval` query is present, switches to aggregated mode: - * - Events: SELECT metric, SUM(value) as value, toStartOfInterval(time, INTERVAL ...) as time - * - Gauges: SELECT metric, argMax(value, time) as value, toStartOfInterval(time, INTERVAL ...) as time + * - Events: SELECT metric, SUM(value) as value, toStartOfInterval(time, INTERVAL ...) as bucket + * - Gauges: SELECT metric, argMax(value, time) as value, toStartOfInterval(time, INTERVAL ...) as bucket * Results are grouped by metric and time bucket, ordered by time ASC. * * @param array $queries @@ -1541,7 +1804,6 @@ private function queriesMatchType(array $queries, string $type): bool private function findFromTable(string $tenant, array $queries, string $type): array { $tableName = $this->getTableForType($type); - $fromTable = $this->buildTableReference($tableName); $parsed = $this->parseQueries($tenant, $queries, $type); @@ -1554,48 +1816,42 @@ private function findFromTable(string $tenant, array $queries, string $type): ar // Route through the aggregated path whenever any aggregation // hint is present — time bucketing, dimension breakdown, or both. if (isset($parsed['groupByInterval']) || !empty($parsed['groupBy'])) { - return $this->findAggregatedFromTable($parsed, $fromTable, $type); + return $this->findAggregatedFromTable($tenant, $parsed, $tableName, $type); } - $selectColumns = $this->getSelectColumns($type); - - $filters = $parsed['filters']; - $params = $parsed['params']; - $orderAttributes = $parsed['orderAttributes'] ?? []; + $orderAttributes = $parsed['orderAttributes']; $cursorDirection = $parsed['cursorDirection'] ?? null; - if (isset($parsed['cursor'])) { - $resolvedOrder = $this->resolveCursorOrder($orderAttributes); - $cursorWhere = $this->buildCursorWhere($resolvedOrder, $parsed['cursor'], $cursorDirection ?? 'after', $params); - $filters[] = $cursorWhere['clause']; - $params = $cursorWhere['params']; - $orderAttributes = $resolvedOrder; - } + $builder = $this->newBuilder($type) + ->from($tableName) + ->select($this->getSelectColumns($type)); - $whereData = $this->buildWhereClause($filters, $params); - $whereClause = $whereData['clause']; - $params = $whereData['params']; + $this->applyFilters($builder, $tenant, $parsed); - $orderClause = ''; + $extraBindings = []; if (isset($parsed['cursor'])) { - // $orderAttributes is always non-empty here — resolveCursorOrder - // appends an `id` tiebreaker when no order is specified. - $orderSql = $this->buildOrderBySql($orderAttributes, flip: $cursorDirection === 'before'); - $orderClause = ' ORDER BY ' . implode(', ', $orderSql); - } elseif (!empty($parsed['orderBy'])) { - $orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']); + $orderAttributes = $this->resolveCursorOrder($orderAttributes); + $extraBindings = $this->applyCursorWhere( + $builder, + $orderAttributes, + $parsed['cursor'], + $cursorDirection ?? 'after', + ); } - $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : ''; - $offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : ''; + $this->applyOrderBy($builder, $orderAttributes, flip: $cursorDirection === 'before'); - $sql = " - SELECT {$selectColumns} - FROM {$fromTable}{$whereClause}{$orderClause}{$limitClause}{$offsetClause} - FORMAT JSON - "; + if (isset($parsed['limit'])) { + $builder->limit($parsed['limit']); + } + if (isset($parsed['offset'])) { + $builder->offset($parsed['offset']); + } - $result = $this->query($sql, $params); + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; + + $result = $this->query($sql, array_merge($statement->namedBindings ?? [], $extraBindings)); $rows = $this->parseResults($result, $type); @@ -1611,50 +1867,48 @@ private function findFromTable(string $tenant, array $queries, string $type): ar * * Produces SQL like: * SELECT metric, SUM(value) as value, - * toStartOfInterval(time, INTERVAL 1 HOUR) as time - * FROM table WHERE ... GROUP BY metric, time ORDER BY time ASC + * toStartOfInterval(time, INTERVAL 1 HOUR) as bucket + * FROM table WHERE ... GROUP BY metric, bucket ORDER BY bucket ASC * - * @param array{filters: array, params: array, orderBy?: array, limit?: int, offset?: int, groupByInterval?: string, groupBy?: array} $parsed Parsed query data from parseQueries() - * @param string $fromTable Fully qualified table reference + * @param array{filters: array, orderAttributes: array, limit?: int, offset?: int, groupByInterval?: string, groupBy?: array} $parsed Parsed query data from parseQueries() + * @param string $tableName Unqualified table name * @param string $type 'event' or 'gauge' * @return array * @throws Exception */ - private function findAggregatedFromTable(array $parsed, string $fromTable, string $type): array + private function findAggregatedFromTable(string $tenant, array $parsed, string $tableName, string $type): array { $hasInterval = isset($parsed['groupByInterval']); // Choose aggregation function based on metric type $valueExpr = $type === Usage::TYPE_GAUGE - ? 'argMax(value, time) as value' - : 'SUM(value) as value'; + ? 'argMax(`value`, `time`) AS `value`' + : 'SUM(`value`) AS `value`'; + + $builder = $this->newBuilder($type) + ->from($tableName) + ->select(['metric']) + ->selectRaw($valueExpr); + + $groupParts = ['`metric`']; // Bucket column is only emitted when time bucketing is requested. // Without it the result is a flat aggregate per (metric, …dims). - $bucketSelect = ''; - $bucketGroup = ''; if ($hasInterval) { - $interval = $parsed['groupByInterval']; - $intervalSql = UsageQuery::VALID_INTERVALS[$interval]; - $bucketSelect = ", toStartOfInterval(time, {$intervalSql}) as bucket"; - $bucketGroup = ', bucket'; - } - - $groupByDims = $parsed['groupBy'] ?? []; - $dimSelect = ''; - $dimGroup = ''; - if (!empty($groupByDims)) { - $escapedDims = array_map( - fn (string $dim): string => $this->escapeIdentifier($dim), - $groupByDims - ); - $dimSelect = ', ' . implode(', ', $escapedDims); - $dimGroup = ', ' . implode(', ', $escapedDims); + $intervalSql = UsageQuery::VALID_INTERVALS[$parsed['groupByInterval']]; + $builder->selectRaw("toStartOfInterval(`time`, {$intervalSql}) AS `bucket`"); + $groupParts[] = '`bucket`'; + } + + foreach ($parsed['groupBy'] ?? [] as $dim) { + $escapedDim = $this->escapeIdentifier($dim); + $builder->selectRaw($escapedDim); + $groupParts[] = $escapedDim; } - $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); - $whereClause = $whereData['clause']; - $params = $whereData['params']; + $this->applyFilters($builder, $tenant, $parsed); + + $builder->groupByRaw(implode(', ', $groupParts)); // Default ORDER BY: // - With time bucketing: bucket ASC (chronological time series). @@ -1662,44 +1916,37 @@ private function findAggregatedFromTable(array $parsed, string $fromTable, strin // For caller-supplied ORDER BY, `time` is rewritten to `bucket` // only when bucket is present; otherwise sorting by time is // invalid (the column is no longer in the SELECT after GROUP BY). - if ($hasInterval) { - $orderClause = ' ORDER BY bucket ASC'; - if (!empty($parsed['orderBy'])) { - $rewrittenOrderBy = array_map( - fn (string $clause): string => preg_replace( - '/^`time`(\s+(?:ASC|DESC))?$/', - '`bucket`$1', - $clause - ) ?? $clause, - $parsed['orderBy'] - ); - $orderClause = ' ORDER BY ' . implode(', ', $rewrittenOrderBy); - } - } else { - $orderClause = ' ORDER BY value DESC'; - if (!empty($parsed['orderBy'])) { - foreach ($parsed['orderBy'] as $clause) { - if (preg_match('/^`time`/', $clause)) { + $orderAttributes = $parsed['orderAttributes']; + if (!empty($orderAttributes)) { + foreach ($orderAttributes as $entry) { + $attribute = $entry['attribute']; + if ($attribute === 'time') { + if (!$hasInterval) { throw new Exception( 'orderBy("time") requires groupByInterval — without time bucketing the result has no time column' ); } + $attribute = 'bucket'; } - $orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']); + $builder->orderByRaw($this->escapeIdentifier($attribute) . ' ' . $entry['direction']); } + } elseif ($hasInterval) { + $builder->orderByRaw('`bucket` ASC'); + } else { + $builder->orderByRaw('`value` DESC'); } - $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : ''; - $offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : ''; + if (isset($parsed['limit'])) { + $builder->limit($parsed['limit']); + } + if (isset($parsed['offset'])) { + $builder->offset($parsed['offset']); + } - $sql = " - SELECT metric, {$valueExpr}{$bucketSelect}{$dimSelect} - FROM {$fromTable}{$whereClause} - GROUP BY metric{$bucketGroup}{$dimGroup}{$orderClause}{$limitClause}{$offsetClause} - FORMAT JSON - "; + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; - $result = $this->query($sql, $params); + $result = $this->query($sql, $statement->namedBindings ?? []); return $this->parseAggregatedResults($result, $type); } @@ -1816,40 +2063,36 @@ public function count(string $tenant, array $queries = [], ?string $type = null, private function countFromTable(string $tenant, array $queries, string $type, ?int $max = null): int { $tableName = $this->getTableForType($type); - $fromTable = $this->buildTableReference($tableName); $parsed = $this->parseQueries($tenant, $queries, $type); - $params = $parsed['params']; - unset($params['limit'], $params['offset']); + if ($max !== null) { + $innerBuilder = $this->newBuilder($type) + ->from($tableName) + ->selectRaw('1') + ->limit($max); - $whereData = $this->buildWhereClause($parsed['filters'], $params); - $whereClause = $whereData['clause']; - $params = $whereData['params']; + $this->applyFilters($innerBuilder, $tenant, $parsed); - if ($max !== null) { - $params['max'] = $max; - $sql = " - SELECT COUNT(*) as total FROM ( - SELECT 1 FROM {$fromTable}{$whereClause} LIMIT {max:UInt64} - ) sub - FORMAT JSON - "; + $innerStatement = $innerBuilder->build(); + $innerSql = $this->qualifyDdl($innerStatement->query, $tableName); + $sql = "SELECT COUNT(*) as total FROM ({$innerSql}) sub FORMAT JSON"; + + $result = $this->query($sql, $innerStatement->namedBindings ?? []); } else { - $sql = " - SELECT COUNT(*) as total FROM {$fromTable}{$whereClause} - FORMAT JSON - "; - } + $builder = $this->newBuilder($type) + ->from($tableName) + ->count('*', 'total'); - $result = $this->query($sql, $params); - $rows = $this->decodeRows($result); + $this->applyFilters($builder, $tenant, $parsed); - if (!isset($rows[0]['total'])) { - return 0; + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; + + $result = $this->query($sql, $statement->namedBindings ?? []); } - return self::toInt($rows[0]['total']); + return $this->decodeTotal($result); } /** @@ -1923,31 +2166,39 @@ private function extractRoutingPlan(array $queries): array $attribute = $query->getAttribute(); $values = $query->getValues(); - if ($method === UsageQuery::TYPE_GROUP_BY) { - if (!in_array($attribute, $dimensions, true)) { - $dimensions[] = $attribute; + if ($method === Method::GroupBy) { + $dims = $attribute !== '' ? [$attribute] : []; + foreach ($values as $value) { + if (is_string($value) && $value !== '') { + $dims[] = $value; + } + } + foreach ($dims as $dim) { + if (!in_array($dim, $dimensions, true)) { + $dimensions[] = $dim; + } } continue; } - if ($method === UsageQuery::TYPE_GROUP_BY_INTERVAL) { + if ($method === Method::GroupByTimeBucket) { $intervalValue = $values[0] ?? null; $interval = is_string($intervalValue) ? $intervalValue : null; continue; } - if ($method === Query::TYPE_CURSOR_AFTER || $method === Query::TYPE_CURSOR_BEFORE) { + if ($method === Method::CursorAfter || $method === Method::CursorBefore) { $rawCursor = $values[0] ?? null; if ($rawCursor !== null) { $hasCursor = true; } continue; } - if ($method === Query::TYPE_ORDER_ASC || $method === Query::TYPE_ORDER_DESC) { + if ($method === Method::OrderAsc || $method === Method::OrderDesc) { if ($attribute !== '' && !in_array($attribute, $orderColumns, true)) { $orderColumns[] = $attribute; } continue; } - if (in_array($method, [Query::TYPE_LIMIT, Query::TYPE_OFFSET], true)) { + if (in_array($method, [Method::Limit, Method::Offset], true)) { continue; } @@ -1959,7 +2210,7 @@ private function extractRoutingPlan(array $queries): array $filterColumns[] = $attribute; } - if ($attribute === 'metric' && $method === Query::TYPE_EQUAL) { + if ($attribute === 'metric' && $method === Method::Equal) { $first = $values[0] ?? null; if (is_string($first) && count($values) === 1) { $metric = $first; @@ -1967,11 +2218,11 @@ private function extractRoutingPlan(array $queries): array } if ($attribute === 'time') { - if ($method === Query::TYPE_GREATER_EQUAL || $method === Query::TYPE_GREATER) { + if ($method === Method::GreaterThanEqual || $method === Method::GreaterThan) { $start = $this->tightenLowerBound($start, $this->stringifyTime($values[0] ?? null)); - } elseif ($method === Query::TYPE_LESSER_EQUAL || $method === Query::TYPE_LESSER) { + } elseif ($method === Method::LessThanEqual || $method === Method::LessThan) { $end = $this->tightenUpperBound($end, $this->stringifyTime($values[0] ?? null)); - } elseif ($method === Query::TYPE_BETWEEN) { + } elseif ($method === Method::Between) { $start = $this->tightenLowerBound($start, $this->stringifyTime($values[0] ?? null)); $end = $this->tightenUpperBound($end, $this->stringifyTime($values[1] ?? null)); } @@ -2083,33 +2334,6 @@ private function isMidnightString(?string $ts): bool } } - /** - * Rewrite `param_N` bind names in a parseQueries() result so a second - * parse can be merged with the first without colliding on `param_0`, - * `param_1`, …. The hybrid sum path parses the full query list once for - * the raw branch and the non-time subset once for the daily branch; both - * counters restart at zero, so without a prefix the merged params dict - * silently overwrites with whichever value lands last (often a string in - * a slot the SQL expects to be a DateTime). - * - * @param array{filters: array, params: array} $parsed - * @return array{filters: array, params: array} - */ - private function prefixParsedParams(array $parsed, string $prefix): array - { - $renamedParams = []; - $renamedFilters = $parsed['filters']; - foreach ($parsed['params'] as $key => $value) { - $newKey = $prefix . $key; - $renamedParams[$newKey] = $value; - $pattern = '/\{' . preg_quote($key, '/') . '(:[^}]+)\}/'; - foreach ($renamedFilters as $i => $filter) { - $renamedFilters[$i] = preg_replace($pattern, '{' . $newKey . '$1}', $filter) ?? $filter; - } - } - return ['filters' => $renamedFilters, 'params' => $renamedParams]; - } - /** * Daily MV rows are keyed at toStartOfDay(time), so an inclusive * `<= midnight` upper bound matches the row representing the entire @@ -2131,22 +2355,22 @@ private function translateInclusiveMidnightForDaily(array $queries): array $method = $q->getMethod(); $values = $q->getValues(); - if ($method === Query::TYPE_LESSER_EQUAL) { + if ($method === Method::LessThanEqual) { $upper = $this->stringifyTime($values[0] ?? null); if ($upper !== null && $this->isMidnightString($upper)) { - $result[] = new Query(Query::TYPE_LESSER, 'time', [$upper]); + $result[] = Query::lessThan('time', $upper); continue; } } - if ($method === Query::TYPE_BETWEEN && count($values) >= 2) { + if ($method === Method::Between && count($values) >= 2) { $upper = $this->stringifyTime($values[1] ?? null); if ($upper !== null && $this->isMidnightString($upper)) { $lower = $this->stringifyTime($values[0] ?? null); if ($lower !== null) { - $result[] = new Query(Query::TYPE_GREATER_EQUAL, 'time', [$lower]); + $result[] = Query::greaterThanEqual('time', $lower); } - $result[] = new Query(Query::TYPE_LESSER, 'time', [$upper]); + $result[] = Query::lessThan('time', $upper); continue; } } @@ -2216,12 +2440,12 @@ private function tightenUpperBound(?string $current, ?string $candidate): ?strin private function splitTimeQueries(array $queries): array { $timeMethods = [ - Query::TYPE_GREATER, - Query::TYPE_GREATER_EQUAL, - Query::TYPE_LESSER, - Query::TYPE_LESSER_EQUAL, - Query::TYPE_BETWEEN, - Query::TYPE_NOT_BETWEEN, + Method::GreaterThan, + Method::GreaterThanEqual, + Method::LessThan, + Method::LessThanEqual, + Method::Between, + Method::NotBetween, ]; $nonTime = []; @@ -2278,7 +2502,7 @@ private function translateTimeQueriesToDayBoundaries(array $queries): array $method = $query->getMethod(); $values = $query->getValues(); - if ($method === Query::TYPE_GREATER_EQUAL || $method === Query::TYPE_GREATER) { + if ($method === Method::GreaterThanEqual || $method === Method::GreaterThan) { $ceiled = $this->ceilLowerToFullyCoveredDayStart($this->stringifyTime($values[0] ?? null)); if ($ceiled === null) { $output[] = $query; @@ -2288,7 +2512,7 @@ private function translateTimeQueriesToDayBoundaries(array $queries): array continue; } - if ($method === Query::TYPE_LESSER_EQUAL || $method === Query::TYPE_LESSER) { + if ($method === Method::LessThanEqual || $method === Method::LessThan) { $floored = $this->floorToStartOfDay($this->stringifyTime($values[0] ?? null)); if ($floored === null) { $output[] = $query; @@ -2298,7 +2522,7 @@ private function translateTimeQueriesToDayBoundaries(array $queries): array continue; } - if ($method === Query::TYPE_BETWEEN) { + if ($method === Method::Between) { $lower = $this->ceilLowerToFullyCoveredDayStart($this->stringifyTime($values[0] ?? null)); $upper = $this->floorToStartOfDay($this->stringifyTime($values[1] ?? null)); if ($lower !== null) { @@ -2340,67 +2564,60 @@ private function ceilLowerToFullyCoveredDayStart(?string $value): ?string } /** - * Translate the caller's time-bound queries into filter fragments suited - * to a day-bucketed rollup. Lower bounds are floored to start-of-day so - * a mid-day start still picks up that day's rollup row; upper bounds - * pass through unchanged. + * Translate the caller's time-bound queries into filters suited to a + * day-bucketed rollup. Lower bounds are floored to start-of-day so a + * mid-day start still picks up that day's rollup row; upper bounds pass + * through unchanged, except inclusive midnight upper bounds which + * tighten to exclusive so the row representing the entire end day is + * skipped. * - * @param array $existing Pre-built non-time filter fragments. * @param array $timeQueries - * @param array $params Mutated with the new bind values. - * @return array + * @return array */ - private function buildDailyTimeFilters(array $existing, array $timeQueries, array &$params): array + private function buildDailyTimeQueries(array $timeQueries): array { - $filters = $existing; - $counter = 0; + $result = []; foreach ($timeQueries as $query) { $method = $query->getMethod(); $values = $query->getValues(); - if ($method === Query::TYPE_GREATER_EQUAL || $method === Query::TYPE_GREATER) { + if ($method === Method::GreaterThanEqual || $method === Method::GreaterThan) { $floored = $this->floorToStartOfDay($this->stringifyTime($values[0] ?? null)); if ($floored === null) { continue; } - $name = 'daily_time_lower_' . $counter++; - $params[$name] = $floored; - $filters[] = '`time` >= {' . $name . ':DateTime64(3, \'UTC\')}'; + $result[] = Query::greaterThanEqual('time', $floored); continue; } - if ($method === Query::TYPE_LESSER_EQUAL || $method === Query::TYPE_LESSER) { + if ($method === Method::LessThanEqual || $method === Method::LessThan) { $upper = $this->stringifyTime($values[0] ?? null); if ($upper === null) { continue; } - $name = 'daily_time_upper_' . $counter++; - $params[$name] = $upper; - $inclusiveOnMidnight = $method === Query::TYPE_LESSER_EQUAL && $this->isMidnightString($upper); - $op = ($method === Query::TYPE_LESSER || $inclusiveOnMidnight) ? '<' : '<='; - $filters[] = '`time` ' . $op . ' {' . $name . ':DateTime64(3, \'UTC\')}'; + $inclusiveOnMidnight = $method === Method::LessThanEqual && $this->isMidnightString($upper); + $result[] = ($method === Method::LessThan || $inclusiveOnMidnight) + ? Query::lessThan('time', $upper) + : Query::lessThanEqual('time', $upper); continue; } - if ($method === Query::TYPE_BETWEEN) { + if ($method === Method::Between) { $lower = $this->floorToStartOfDay($this->stringifyTime($values[0] ?? null)); $upper = $this->stringifyTime($values[1] ?? null); if ($lower !== null) { - $name = 'daily_time_lower_' . $counter++; - $params[$name] = $lower; - $filters[] = '`time` >= {' . $name . ':DateTime64(3, \'UTC\')}'; + $result[] = Query::greaterThanEqual('time', $lower); } if ($upper !== null) { - $name = 'daily_time_upper_' . $counter++; - $params[$name] = $upper; - $op = $this->isMidnightString($upper) ? '<' : '<='; - $filters[] = '`time` ' . $op . ' {' . $name . ':DateTime64(3, \'UTC\')}'; + $result[] = $this->isMidnightString($upper) + ? Query::lessThan('time', $upper) + : Query::lessThanEqual('time', $upper); } continue; } } - return $filters; + return $result; } /** @@ -2482,7 +2699,9 @@ private function maybeDualRead(string $tenant, array $queries, string $route, ar /** * Hybrid daily + raw read: closed days from the daily MV, today's * partial from the raw events table, combined via outer SUM over - * UNION ALL. + * UNION ALL. The two sides compile independently; the daily side's + * named bindings are prefixed so the merged statement has no + * placeholder collisions. * * @param array $queries * @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array, dimensions: array, interval: ?string} $plan @@ -2491,45 +2710,51 @@ private function sumHybridDailyAndRaw(string $tenant, array $queries, array $pla { $startOfToday = (new DateTime('today', new DateTimeZone('UTC')))->format('Y-m-d H:i:s.v'); - $dailyTable = $this->buildTableReference($this->getEventsDailyTableName()); - $eventsTable = $this->buildTableReference($this->getEventsTableName()); + $dailyTableName = $this->getEventsDailyTableName(); + $eventsTableName = $this->getEventsTableName(); $split = $this->splitTimeQueries($queries); - $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); - $dailyParsed = $this->prefixParsedParams( - $this->parseQueries($tenant, $split['nonTime'], Usage::TYPE_EVENT), - 'd_' - ); - - $params = array_merge($parsed['params'], $dailyParsed['params']); - - $rawFilters = $parsed['filters']; - $dailyFilters = $this->buildDailyTimeFilters($dailyParsed['filters'], $split['time'], $params); - $params['hybrid_boundary'] = $startOfToday; - $dailyFilters[] = '`time` < {hybrid_boundary:DateTime64(3, \'UTC\')}'; - $rawFilters[] = '`time` >= {hybrid_boundary:DateTime64(3, \'UTC\')}'; + $rawQueries = array_merge($queries, [Query::greaterThanEqual('time', $startOfToday)]); + $dailyQueries = array_merge( + $split['nonTime'], + $this->buildDailyTimeQueries($split['time']), + [Query::lessThan('time', $startOfToday)], + ); - $dailyWhere = $this->buildWhereClause($dailyFilters, $params); - $rawWhere = $this->buildWhereClause($rawFilters, $dailyWhere['params']); + $rawParsed = $this->parseQueries($tenant, $rawQueries, Usage::TYPE_EVENT); + $dailyParsed = $this->parseQueries($tenant, $dailyQueries, Usage::TYPE_EVENT); + + $rawBuilder = $this->newBuilder(Usage::TYPE_EVENT) + ->from($eventsTableName) + ->sum('value', 'total'); + $this->applyFilters($rawBuilder, $tenant, $rawParsed); + $rawStatement = $rawBuilder->build(); + $rawSql = $this->qualifyDdl($rawStatement->query, $eventsTableName); + + $dailyBuilder = $this->newBuilder(Usage::TYPE_EVENT) + ->from($dailyTableName) + ->sum('value', 'total'); + $this->applyFilters($dailyBuilder, $tenant, $dailyParsed); + $dailyStatement = $dailyBuilder->build(); + [$dailySql, $dailyBindings] = $this->prefixNamedBindings( + $this->qualifyDdl($dailyStatement->query, $dailyTableName), + $dailyStatement->namedBindings ?? [], + 'd_', + ); $sql = " SELECT sum(total) AS total FROM ( - SELECT sum(value) AS total FROM {$dailyTable}{$dailyWhere['clause']} + {$dailySql} UNION ALL - SELECT sum(value) AS total FROM {$eventsTable}{$rawWhere['clause']} + {$rawSql} ) FORMAT JSON "; - $result = $this->query($sql, $rawWhere['params']); - $rows = $this->decodeRows($result); - - if (!isset($rows[0]['total'])) { - return 0; - } + $result = $this->query($sql, array_merge($rawStatement->namedBindings ?? [], $dailyBindings)); - return self::toInt($rows[0]['total']); + return $this->decodeTotal($result); } /** @@ -2544,31 +2769,21 @@ private function sumHybridDailyAndRaw(string $tenant, array $queries, array $pla private function sumFromTable(string $tenant, array $queries, string $attribute, string $type): int { $tableName = $this->getTableForType($type); - $fromTable = $this->buildTableReference($tableName); $this->validateAttributeName($attribute, $type); - $escapedAttribute = $this->escapeIdentifier($attribute); $parsed = $this->parseQueries($tenant, $queries, $type); - $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); - $whereClause = $whereData['clause']; - $params = $whereData['params']; - - $sql = " - SELECT sum({$escapedAttribute}) as total FROM {$fromTable}{$whereClause} - FORMAT JSON - "; + $builder = $this->newBuilder($type) + ->from($tableName) + ->sum($attribute, 'total'); - $result = $this->query($sql, $params); + $this->applyFilters($builder, $tenant, $parsed); - $rows = $this->decodeRows($result); + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; - if (!isset($rows[0]['total'])) { - return 0; - } - - return self::toInt($rows[0]['total']); + return $this->decodeTotal($this->query($sql, $statement->namedBindings ?? [])); } /** @@ -2582,7 +2797,7 @@ public function findDaily(string $tenant, array $queries = []): array { $this->setOperationContext('findDaily()'); - $fromTable = $this->buildTableReference($this->getEventsDailyTableName()); + $tableName = $this->getEventsDailyTableName(); foreach ($queries as $query) { $attr = $query->getAttribute(); @@ -2591,7 +2806,6 @@ public function findDaily(string $tenant, array $queries = []): array } } $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); - $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); $groupByColumns = $this->sharedTables ? ['tenant'] : []; $groupByColumns[] = 'metric'; @@ -2600,22 +2814,26 @@ public function findDaily(string $tenant, array $queries = []): array $groupByColumns[] = $dim; } - $selectExpressions = []; - foreach ($groupByColumns as $column) { - $selectExpressions[] = $this->escapeIdentifier($column); - } - $selectExpressions[] = 'sum(`value`) AS `value`'; + $builder = $this->newBuilder(Usage::TYPE_EVENT) + ->from($tableName) + ->select($groupByColumns) + ->selectRaw('sum(`value`) AS `value`') + ->groupByRaw(implode(', ', array_map($this->escapeIdentifier(...), $groupByColumns))); - $selectColumns = implode(', ', $selectExpressions); - $groupBySql = implode(', ', array_map(fn ($c) => $this->escapeIdentifier($c), $groupByColumns)); + $this->applyFilters($builder, $tenant, $parsed); + $this->applyOrderBy($builder, $parsed['orderAttributes']); - $orderClause = !empty($parsed['orderBy']) ? ' ORDER BY ' . implode(', ', $parsed['orderBy']) : ''; - $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : ''; - $offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : ''; + if (isset($parsed['limit'])) { + $builder->limit($parsed['limit']); + } + if (isset($parsed['offset'])) { + $builder->offset($parsed['offset']); + } - $sql = "SELECT {$selectColumns} FROM {$fromTable}{$whereData['clause']} GROUP BY {$groupBySql}{$orderClause}{$limitClause}{$offsetClause} FORMAT JSON"; + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; - return $this->parseResults($this->query($sql, $whereData['params']), Usage::TYPE_EVENT); + return $this->parseResults($this->query($sql, $statement->namedBindings ?? []), Usage::TYPE_EVENT); } /** @@ -2642,9 +2860,8 @@ public function sumDaily(string $tenant, array $queries = [], string $attribute */ private function sumDailyTotal(string $tenant, array $queries, string $attribute = 'value'): int { - $fromTable = $this->buildTableReference($this->getEventsDailyTableName()); + $tableName = $this->getEventsDailyTableName(); $this->validateDailyAttributeName($attribute); - $escapedAttribute = $this->escapeIdentifier($attribute); foreach ($queries as $query) { $attr = $query->getAttribute(); @@ -2653,14 +2870,17 @@ private function sumDailyTotal(string $tenant, array $queries, string $attribute } } $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); - $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); - $sql = "SELECT sum({$escapedAttribute}) as total FROM {$fromTable}{$whereData['clause']} FORMAT JSON"; + $builder = $this->newBuilder(Usage::TYPE_EVENT) + ->from($tableName) + ->sum($attribute, 'total'); - $result = $this->query($sql, $whereData['params']); - $rows = $this->decodeRows($result); + $this->applyFilters($builder, $tenant, $parsed); + + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; - return isset($rows[0]['total']) ? self::toInt($rows[0]['total']) : 0; + return $this->decodeTotal($this->query($sql, $statement->namedBindings ?? [])); } /** @@ -2688,38 +2908,23 @@ public function sumDailyBatch(string $tenant, array $metrics, array $queries = [ $totals = \array_fill_keys($metrics, 0); - $fromTable = $this->buildTableReference($this->getEventsDailyTableName()); - - // Build metric IN params - $metricParams = []; - $metricPlaceholders = []; - foreach ($metrics as $i => $metric) { - $paramName = 'metric_' . $i; - $metricParams[$paramName] = $metric; - $metricPlaceholders[] = "{{$paramName}:String}"; - } - $metricInClause = implode(', ', $metricPlaceholders); + $tableName = $this->getEventsDailyTableName(); $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_EVENT); - $params = array_merge($metricParams, $parsed['params']); - $whereData = $this->buildWhereClause($parsed['filters'], $params); - $whereClause = $whereData['clause']; - $params = $whereData['params']; + $builder = $this->newBuilder(Usage::TYPE_EVENT) + ->from($tableName) + ->select(['metric']) + ->selectRaw('SUM(`value`) AS `total`') + ->filter([Query::equal('metric', $metrics)]) + ->groupByRaw('`metric`'); - $metricFilter = $this->escapeIdentifier('metric') . " IN ({$metricInClause})"; - $whereClause = !empty($whereClause) - ? $whereClause . ' AND ' . $metricFilter - : ' WHERE ' . $metricFilter; + $this->applyFilters($builder, $tenant, $parsed); - $sql = " - SELECT metric, SUM(value) as total - FROM {$fromTable}{$whereClause} - GROUP BY metric - FORMAT JSON - "; + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; - $result = $this->query($sql, $params); + $result = $this->query($sql, $statement->namedBindings ?? []); $rows = $this->decodeRows($result); foreach ($rows as $row) { @@ -2845,52 +3050,31 @@ private function getTimeSeriesFromTable(string $tenant, array $metrics, string $ { $timeFunction = self::INTERVAL_FUNCTIONS[$interval]; $tableName = $this->getTableForType($type); - $fromTable = $this->buildTableReference($tableName); - - // Build metric IN params - $metricParams = []; - $metricPlaceholders = []; - foreach ($metrics as $i => $metric) { - $paramName = 'metric_' . $i; - $metricParams[$paramName] = $metric; - $metricPlaceholders[] = "{{$paramName}:String}"; - } - $metricInClause = implode(', ', $metricPlaceholders); - - // Build additional WHERE conditions from queries (tenant baked in) $parsed = $this->parseQueries($tenant, $queries, $type); - $additionalFilters = $parsed['filters']; - $params = array_merge($metricParams, $parsed['params']); - - $params['start_date'] = $this->formatDateTime($startDate); - $params['end_date'] = $this->formatDateTime($endDate); - - // Tenant scoping is already folded into $additionalFilters by parseQueries(). - $additionalWhere = ''; - if (!empty($additionalFilters)) { - $additionalWhere = ' AND ' . implode(' AND ', $additionalFilters); - } $valueExpr = $type === Usage::TYPE_EVENT - ? 'SUM(value) as agg_value' - : 'argMax(value, time) as agg_value'; - - $sql = " - SELECT - metric, - {$timeFunction}(time, 'UTC') as bucket, - {$valueExpr} - FROM {$fromTable} - WHERE metric IN ({$metricInClause}) - AND time BETWEEN {start_date:DateTime64(3, 'UTC')} AND {end_date:DateTime64(3, 'UTC')} - {$additionalWhere} - GROUP BY metric, bucket - ORDER BY bucket ASC - FORMAT JSON - "; - - $result = $this->query($sql, $params); + ? 'SUM(`value`) AS `agg_value`' + : 'argMax(`value`, `time`) AS `agg_value`'; + + $builder = $this->newBuilder($type) + ->from($tableName) + ->select(['metric']) + ->selectRaw("{$timeFunction}(`time`, 'UTC') AS `bucket`") + ->selectRaw($valueExpr) + ->filter([ + Query::equal('metric', $metrics), + Query::between('time', $this->formatDateTime($startDate), $this->formatDateTime($endDate)), + ]) + ->groupByRaw('`metric`, `bucket`') + ->orderByRaw('`bucket` ASC'); + + $this->applyFilters($builder, $tenant, $parsed); + + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; + + $result = $this->query($sql, $statement->namedBindings ?? []); $rows = $this->decodeRows($result); // Initialize result structure @@ -3084,25 +3268,19 @@ private function getTotalFromGauges(string $tenant, string $metric, array $queri $this->recordRoute('getTotal', $plan, 'raw'); $tableName = $this->getGaugesTableName(); - $fromTable = $this->buildTableReference($tableName); $parsed = $this->parseQueries($tenant, $queries, Usage::TYPE_GAUGE); - $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); - $sql = " - SELECT argMax(value, time) as total - FROM {$fromTable}{$whereData['clause']} - FORMAT JSON - "; + $builder = $this->newBuilder(Usage::TYPE_GAUGE) + ->from($tableName) + ->selectRaw('argMax(`value`, `time`) AS `total`'); - $result = $this->query($sql, $whereData['params']); - $rows = $this->decodeRows($result); + $this->applyFilters($builder, $tenant, $parsed); - if (!isset($rows[0]['total'])) { - return 0; - } + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; - return self::toInt($rows[0]['total']); + return $this->decodeTotal($this->query($sql, $statement->namedBindings ?? [])); } /** @@ -3144,44 +3322,26 @@ public function getTotalBatch(string $tenant, array $metrics, array $queries = [ foreach ($typesToQuery as $queryType) { $tableName = $this->getTableForType($queryType); - $fromTable = $this->buildTableReference($tableName); - - // Build metric IN params - $metricParams = []; - $metricPlaceholders = []; - foreach ($metrics as $i => $metric) { - $paramName = 'metric_' . $i; - $metricParams[$paramName] = $metric; - $metricPlaceholders[] = "{{$paramName}:String}"; - } - $metricInClause = implode(', ', $metricPlaceholders); $parsed = $this->parseQueries($tenant, $queries, $queryType); - $params = array_merge($metricParams, $parsed['params']); - $whereData = $this->buildWhereClause($parsed['filters'], $params); - $whereClause = $whereData['clause']; - $params = $whereData['params']; + $valueExpr = $queryType === Usage::TYPE_EVENT + ? 'SUM(`value`) AS `agg_val`' + : 'argMax(`value`, `time`) AS `agg_val`'; - $metricFilter = $this->escapeIdentifier('metric') . " IN ({$metricInClause})"; - $whereClause = !empty($whereClause) - ? $whereClause . ' AND ' . $metricFilter - : ' WHERE ' . $metricFilter; + $builder = $this->newBuilder($queryType) + ->from($tableName) + ->select(['metric']) + ->selectRaw($valueExpr) + ->filter([Query::equal('metric', $metrics)]) + ->groupByRaw('`metric`'); - $valueExpr = $queryType === Usage::TYPE_EVENT - ? 'SUM(value) as agg_val' - : 'argMax(value, time) as agg_val'; - - $sql = " - SELECT - metric, - {$valueExpr} - FROM {$fromTable}{$whereClause} - GROUP BY metric - FORMAT JSON - "; - - $result = $this->query($sql, $params); + $this->applyFilters($builder, $tenant, $parsed); + + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; + + $result = $this->query($sql, $statement->namedBindings ?? []); $rows = $this->decodeRows($result); foreach ($rows as $row) { @@ -3213,27 +3373,6 @@ public function getTotalBatch(string $tenant, array $metrics, array $queries = [ return $totals; } - /** - * Build a WHERE clause from already-parsed filters. - * - * Tenant scoping is baked into parseQueries(), so by the time filters - * reach here they are already tenant-scoped — there is no separate step - * to forget. - * - * @param array $filters - * @param array $params - * @return array{clause: string, params: array} - */ - private function buildWhereClause(array $filters, array $params = []): array - { - $clause = !empty($filters) ? ' WHERE ' . implode(' AND ', $filters) : ''; - - return [ - 'clause' => $clause, - 'params' => $params - ]; - } - /** * Resolve the ClickHouse parameter type for a column. * @@ -3280,21 +3419,6 @@ private function formatTypedValue(string $chType, mixed $value): string return $this->formatParamValue($value); } - /** - * Escape ClickHouse LIKE-pattern wildcards in a user-supplied needle. - * - * Backslash is escaped first so already-escaped characters aren't - * double-escaped. Keeps `contains('metric', ['100%'])` a literal - * substring match instead of a wildcard. - * - * @param string $value - * @return string - */ - private function escapeLikeWildcards(string $value): string - { - return \str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); - } - /** * Normalize a user-supplied cursor row into a column-keyed array. * @@ -3309,7 +3433,7 @@ private function escapeLikeWildcards(string $value): string */ private function normalizeCursorRow(mixed $rawCursor): array { - if ($rawCursor instanceof \ArrayObject) { + if ($rawCursor instanceof ArrayObject) { /** @var array $row */ $row = $rawCursor->getArrayCopy(); } elseif (is_array($rawCursor)) { @@ -3359,7 +3483,9 @@ private function resolveCursorOrder(array $orderAttributes): array } /** - * Build keyset-pagination WHERE fragments for cursor support. + * Compile keyset-pagination WHERE fragments for cursor support and + * register them on the builder via `whereRaw`. Returns the named + * bindings to merge into the statement's bindings at execute time. * * Produces a tuple-compare clause across the order attributes: * (a > A) OR (a = A AND b > B) OR ... @@ -3372,14 +3498,14 @@ private function resolveCursorOrder(array $orderAttributes): array * @param array $orderAttributes * @param array $cursor * @param string $cursorDirection 'after' or 'before' - * @param array $params Existing params (mutated by adding cursor binds) - * @return array{clause: string, params: array} + * @return array * @throws Exception */ - private function buildCursorWhere(array $orderAttributes, array $cursor, string $cursorDirection, array $params): array + private function applyCursorWhere(ClickHouseBuilder $builder, array $orderAttributes, array $cursor, string $cursorDirection): array { $orderAttributes = $this->resolveCursorOrder($orderAttributes); + $params = []; $tuples = []; foreach ($orderAttributes as $i => $entry) { $attr = $entry['attribute']; @@ -3429,65 +3555,61 @@ private function buildCursorWhere(array $orderAttributes, array $cursor, string $tuples[] = '(' . implode(' AND ', $conditions) . ')'; } - return [ - 'clause' => '(' . implode(' OR ', $tuples) . ')', - 'params' => $params, - ]; + $builder->whereRaw('(' . implode(' OR ', $tuples) . ')'); + + return $params; } /** - * Build the ORDER BY SQL fragment list, optionally flipping all directions. + * Apply the ORDER BY chain on the builder, optionally flipping all + * directions. * * Used when cursor direction is `before` — we run the query in reverse to * grab the previous-page rows, then `array_reverse` the result. * * @param array $orderAttributes * @param bool $flip Whether to flip ASC↔DESC - * @return array */ - private function buildOrderBySql(array $orderAttributes, bool $flip = false): array + private function applyOrderBy(ClickHouseBuilder $builder, array $orderAttributes, bool $flip = false): void { - $sql = []; foreach ($orderAttributes as $entry) { $direction = $entry['direction']; if ($flip) { $direction = $direction === 'DESC' ? 'ASC' : 'DESC'; } - $sql[] = $this->escapeIdentifier($entry['attribute']) . ' ' . $direction; + + if ($direction === 'DESC') { + $builder->sortDesc($entry['attribute']); + } else { + $builder->sortAsc($entry['attribute']); + } } - return $sql; } /** - * Parse Query objects into SQL clauses. + * Parse Query objects into the builder-consumable pieces: filter query + * list, order attributes, pagination, grouping metadata and cursor. * - * Tenant scoping is baked in here rather than left to callers: in - * shared-tables mode a `tenant = …` filter is prepended before parsing, - * so there is no way to produce a WHERE clause that isn't tenant-scoped. - * Every read/delete path funnels through this method, which is why it - * takes the tenant as a required first argument. + * Validation is centralised here — attribute names, value requirements, + * pagination types and grouping arguments are all checked before any + * query reaches the builder. Tenant scoping is applied at execution time + * via applyFilters(), which every read/delete path funnels through; the + * empty-tenant guard lives here so no parse can succeed without a valid + * tenant in shared-tables mode. * * @param string $tenant Tenant scope (shared-tables mode) * @param array $queries * @param string $type 'event' or 'gauge' — used for attribute validation - * @return array{filters: array, params: array, orderBy?: array, orderAttributes?: array, limit?: int, offset?: int, groupByInterval?: string, groupBy?: array, cursor?: array, cursorDirection?: string} + * @return array{filters: array, orderAttributes: array, limit?: int, offset?: int, groupByInterval?: string, groupBy?: array, cursor?: array, cursorDirection?: string} * @throws Exception */ private function parseQueries(string $tenant, array $queries, string $type = 'event'): array { - if ($this->sharedTables) { - // An empty tenant would compile to `tenant = ''` and silently read - // an empty scope. Fail fast instead, like the write side. ("0" is - // a valid tenant id, so check for '' specifically.) - if ($tenant === '') { - throw new Exception('Tenant cannot be empty in shared-tables mode'); - } - array_unshift($queries, Query::equal('tenant', [$tenant])); + if ($this->sharedTables && $tenant === '') { + throw new Exception('Tenant cannot be empty in shared-tables mode'); } $filters = []; - $params = []; - $orderBy = []; $orderAttributes = []; $limit = null; $offset = null; @@ -3495,7 +3617,6 @@ private function parseQueries(string $tenant, array $queries, string $type = 'ev $groupBy = []; $cursor = null; $cursorDirection = null; - $paramCounter = 0; foreach ($queries as $query) { $method = $query->getMethod(); @@ -3508,95 +3629,63 @@ private function parseQueries(string $tenant, array $queries, string $type = 'ev // otherwise turn `Query::contains('attr', [])` into a full-table // match instead of an empty result. if (\in_array($method, self::VALUE_REQUIRED_METHODS, true) && empty($values)) { - throw new Exception(\ucfirst($method) . ' queries require at least one value.'); + throw new Exception(\ucfirst($method->value) . ' queries require at least one value.'); } switch ($method) { - case Query::TYPE_EQUAL: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - - if (count($values) > 1) { - $inParams = []; - foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; - $inParams[] = "{{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $value); - } - $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")"; - } else { - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} = {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - } - break; - - case Query::TYPE_NOT_EQUAL: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} != {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; - - case Query::TYPE_LESSER: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} < {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; - - case Query::TYPE_GREATER: + case Method::Equal: + case Method::NotEqual: + case Method::LessThan: + case Method::LessThanEqual: + case Method::GreaterThan: + case Method::GreaterThanEqual: + case Method::Between: + case Method::NotBetween: + case Method::IsNull: + case Method::IsNotNull: $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} > {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); + $filters[] = $query; break; - case Query::TYPE_BETWEEN: + case Method::Contains: + case Method::ContainsAny: + case Method::NotContains: + // Substring match, mirroring utopia-php/database. The + // ClickHouse builder compiles these to `position(attr, ?)`, + // so needles are matched literally and `%` / `_` carry no + // wildcard meaning. $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName1 = 'param_' . $paramCounter++; - $paramName2 = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} BETWEEN {{$paramName1}:{$chType}} AND {{$paramName2}:{$chType}}"; - $params[$paramName1] = $this->formatTypedValue($chType, $values[0] ?? null); - $params[$paramName2] = $this->formatTypedValue($chType, $values[1] ?? null); + foreach ($values as $value) { + if (!is_string($value)) { + throw new Exception("{$method->value} value must be a string for attribute '{$attribute}'"); + } + } + $filters[] = $query; break; - case Query::TYPE_NOT_BETWEEN: + case Method::StartsWith: + case Method::EndsWith: $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName1 = 'param_' . $paramCounter++; - $paramName2 = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} NOT BETWEEN {{$paramName1}:{$chType}} AND {{$paramName2}:{$chType}}"; - $params[$paramName1] = $this->formatTypedValue($chType, $values[0] ?? null); - $params[$paramName2] = $this->formatTypedValue($chType, $values[1] ?? null); + $needle = $values[0] ?? null; + if (!is_string($needle)) { + $word = $method === Method::StartsWith ? 'startsWith' : 'endsWith'; + throw new Exception("{$word} needle must be a string for attribute '{$attribute}'"); + } + $filters[] = $query; break; - case Query::TYPE_ORDER_DESC: + case Method::OrderDesc: $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $orderBy[] = "{$escapedAttr} DESC"; $orderAttributes[] = ['attribute' => $attribute, 'direction' => 'DESC']; break; - case Query::TYPE_ORDER_ASC: + case Method::OrderAsc: $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $orderBy[] = "{$escapedAttr} ASC"; $orderAttributes[] = ['attribute' => $attribute, 'direction' => 'ASC']; break; - case Query::TYPE_CURSOR_AFTER: - case Query::TYPE_CURSOR_BEFORE: + case Method::CursorAfter: + case Method::CursorBefore: if ($cursor !== null) { // Keep the first cursor encountered (matches base groupByType semantics) break; @@ -3606,116 +3695,26 @@ private function parseQueries(string $tenant, array $queries, string $type = 'ev break; // no-op cursor } $cursor = $this->normalizeCursorRow($rawCursor); - $cursorDirection = $method === Query::TYPE_CURSOR_AFTER ? 'after' : 'before'; - break; - - case Query::TYPE_LESSER_EQUAL: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} <= {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; - - case Query::TYPE_GREATER_EQUAL: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} >= {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); + $cursorDirection = $method === Method::CursorAfter ? 'after' : 'before'; break; - case Query::TYPE_CONTAINS: - // Substring match, mirroring utopia-php/database: each - // value becomes `LIKE '%value%'`, OR'd together. - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $conditions = []; - foreach ($values as $value) { - if (!is_string($value)) { - throw new Exception("contains value must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $conditions[] = "{$escapedAttr} LIKE {{$paramName}:String}"; - $params[$paramName] = '%' . $this->escapeLikeWildcards($value) . '%'; - } - $filters[] = '(' . implode(' OR ', $conditions) . ')'; - break; - - case Query::TYPE_NOT_CONTAINS: - // Negated substring match, mirroring utopia-php/database: - // each value becomes `NOT LIKE '%value%'`, AND'd together. - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $conditions = []; - foreach ($values as $value) { - if (!is_string($value)) { - throw new Exception("notContains value must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $conditions[] = "{$escapedAttr} NOT LIKE {{$paramName}:String}"; - $params[$paramName] = '%' . $this->escapeLikeWildcards($value) . '%'; - } - $filters[] = '(' . implode(' AND ', $conditions) . ')'; - break; - - case Query::TYPE_IS_NULL: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $filters[] = "{$escapedAttr} IS NULL"; - break; - - case Query::TYPE_IS_NOT_NULL: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $filters[] = "{$escapedAttr} IS NOT NULL"; - break; - - case Query::TYPE_STARTS_WITH: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $needle = $values[0] ?? null; - if (!is_string($needle)) { - throw new Exception("startsWith needle must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $filters[] = "startsWith({$escapedAttr}, {{$paramName}:String})"; - $params[$paramName] = $needle; - break; - - case Query::TYPE_ENDS_WITH: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $needle = $values[0] ?? null; - if (!is_string($needle)) { - throw new Exception("endsWith needle must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $filters[] = "endsWith({$escapedAttr}, {{$paramName}:String})"; - $params[$paramName] = $needle; - break; - - case Query::TYPE_LIMIT: + case Method::Limit: $limitVal = !empty($values) ? $values[0] : $values; if (!\is_int($limitVal)) { throw new Exception('Invalid limit value. Expected int'); } $limit = $limitVal; - $params['limit'] = $limit; break; - case Query::TYPE_OFFSET: + case Method::Offset: $offsetVal = !empty($values) ? $values[0] : $values; if (!\is_int($offsetVal)) { throw new Exception('Invalid offset value. Expected int'); } $offset = $offsetVal; - $params['offset'] = $offset; break; - case UsageQuery::TYPE_GROUP_BY_INTERVAL: + case Method::GroupByTimeBucket: $this->validateAttributeName($attribute, $type); $interval = $values[0] ?? '1h'; if (!is_string($interval)) { @@ -3733,25 +3732,28 @@ private function parseQueries(string $tenant, array $queries, string $type = 'ev $groupByInterval = $interval; break; - case UsageQuery::TYPE_GROUP_BY: - $this->validateGroupByAttribute($attribute, $type); - if (!in_array($attribute, $groupBy, true)) { - $groupBy[] = $attribute; + case Method::GroupBy: + $dims = $attribute !== '' ? [$attribute] : []; + foreach ($values as $value) { + if (is_string($value) && $value !== '') { + $dims[] = $value; + } + } + foreach ($dims as $dim) { + $this->validateGroupByAttribute($dim, $type); + if (!in_array($dim, $groupBy, true)) { + $groupBy[] = $dim; + } } break; } } $result = [ - 'filters' => $filters, - 'params' => $params, + 'filters' => $this->normalizeTimeValues($filters), + 'orderAttributes' => $orderAttributes, ]; - if (!empty($orderBy)) { - $result['orderBy'] = $orderBy; - $result['orderAttributes'] = $orderAttributes; - } - if ($limit !== null) { $result['limit'] = $limit; } @@ -3835,26 +3837,24 @@ private function parseResults(string $result, string $type = 'event'): array * Get the SELECT column list for queries. * * @param string $type 'event' or 'gauge' - * @return string + * @return list */ - private function getSelectColumns(string $type = 'event'): string + private function getSelectColumns(string $type = 'event'): array { - $columns = []; - - $columns[] = $this->escapeIdentifier('id'); + $columns = ['id']; foreach ($this->getAttributes($type) as $attribute) { $id = $attribute['$id']; if (is_string($id)) { - $columns[] = $this->escapeIdentifier($id); + $columns[] = $id; } } if ($this->sharedTables) { - $columns[] = $this->escapeIdentifier('tenant'); + $columns[] = 'tenant'; } - return implode(', ', $columns); + return $columns; } /** @@ -3886,19 +3886,19 @@ public function purge(string $tenant, array $queries = [], ?string $type = null) foreach ($typesToPurge as $purgeType) { $tableName = $this->getTableForType($purgeType); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $parsed = $this->parseQueries($tenant, $queries, $purgeType); - $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); - $whereClause = $whereData['clause']; - $params = $whereData['params']; - if (empty($whereClause)) { - $whereClause = ' WHERE 1=1'; - } + $builder = $this->newBuilder($purgeType)->from($tableName); + + $this->applyFilters($builder, $tenant, $parsed); + + $builder->whereRaw('1 = 1'); - $sql = "DELETE FROM {$escapedTable}{$whereClause}"; - $this->query($sql, $params); + $statement = $builder->delete(); + $sql = $this->qualifyDdl($statement->query, $tableName); + + $this->query($sql, $statement->namedBindings ?? []); if ($purgeType === Usage::TYPE_EVENT) { $this->purgeDaily($tenant, $queries); @@ -3947,7 +3947,7 @@ private function buildStaleRollupPurgeQueries(array $queries, array $safeAttribu * rollup with stale rows over-reports under routed reads. * * An empty `$queries` argument means "purge everything" and issues - * `DELETE WHERE 1=1`. A non-empty argument whose filters cannot be + * `DELETE WHERE 1 = 1`. A non-empty argument whose filters cannot be * expressed on the daily schema AND leave no time bound is treated * as a no-op on the daily side: an unbounded delete here would wipe * unrelated metrics. The next ingest cycle will overwrite any rows @@ -3988,21 +3988,22 @@ private function purgeDaily(string $tenant, array $queries): void return; } - // parseQueries() folds the tenant into the WHERE. The compatibility - // decision above deliberately runs on the caller's tenant-free filters, - // so the tenant scope can never make an imprecise purge look safe to - // forward to the rollup. - $dailyTable = $this->buildTableReference($this->getEventsDailyTableName()); + // The compatibility decision above deliberately runs on the caller's + // tenant-free filters, so the tenant scope can never make an + // imprecise purge look safe to forward to the rollup. + $tableName = $this->getEventsDailyTableName(); $parsed = $this->parseQueries($tenant, $dailyQueries, Usage::TYPE_EVENT); - $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); - $whereClause = $whereData['clause']; - if (empty($whereClause)) { - $whereClause = ' WHERE 1=1'; - } + $builder = $this->newBuilder(Usage::TYPE_EVENT)->from($tableName); + + $this->applyFilters($builder, $tenant, $parsed); + + $builder->whereRaw('1 = 1'); + + $statement = $builder->delete(); + $sql = $this->qualifyDdl($statement->query, $tableName); - $sql = "DELETE FROM {$dailyTable}{$whereClause}"; - $this->query($sql, $whereData['params']); + $this->query($sql, $statement->namedBindings ?? []); } } diff --git a/src/Usage/Adapter/Database.php b/src/Usage/Adapter/Database.php index dae9526c..badc7c92 100644 --- a/src/Usage/Adapter/Database.php +++ b/src/Usage/Adapter/Database.php @@ -8,7 +8,7 @@ use Utopia\Database\Query as DatabaseQuery; use Utopia\Usage\Metric; use Utopia\Usage\Usage; -use Utopia\Usage\UsageQuery; +use Utopia\Query\Method; use Utopia\Query\Query; class Database extends SQL @@ -409,25 +409,25 @@ private function convertQueriesToDatabase(array $queries): array $values = $query->getValues(); switch ($method) { - case Query::TYPE_EQUAL: + case Method::Equal: /** @var array|bool|float|int|string> $values */ $dbQueries[] = DatabaseQuery::equal($attribute, $values); break; - case Query::TYPE_GREATER: + case Method::GreaterThan: if (!empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::greaterThan($attribute, $value); } break; - case Query::TYPE_LESSER: + case Method::LessThan: if (!empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::lessThan($attribute, $value); } break; - case Query::TYPE_BETWEEN: + case Method::Between: if (count($values) >= 2) { /** @var bool|float|int|string $start */ $start = $values[0]; @@ -436,22 +436,22 @@ private function convertQueriesToDatabase(array $queries): array $dbQueries[] = DatabaseQuery::between($attribute, $start, $end); } break; - case Query::TYPE_CONTAINS: + case Method::Contains: /** @var array|bool|float|int|string> $values */ $dbQueries[] = DatabaseQuery::contains($attribute, $values); break; - case Query::TYPE_NOT_EQUAL: + case Method::NotEqual: if (!empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::notEqual($attribute, $value); } break; - case Query::TYPE_NOT_CONTAINS: + case Method::NotContains: /** @var array|bool|float|int|string> $values */ $dbQueries[] = DatabaseQuery::notContains($attribute, $values); break; - case Query::TYPE_NOT_BETWEEN: + case Method::NotBetween: if (count($values) >= 2) { /** @var bool|float|int|string $start */ $start = $values[0]; @@ -460,44 +460,44 @@ private function convertQueriesToDatabase(array $queries): array $dbQueries[] = DatabaseQuery::notBetween($attribute, $start, $end); } break; - case Query::TYPE_STARTS_WITH: + case Method::StartsWith: if (!empty($values) && is_string($values[0])) { $dbQueries[] = DatabaseQuery::startsWith($attribute, $values[0]); } break; - case Query::TYPE_ENDS_WITH: + case Method::EndsWith: if (!empty($values) && is_string($values[0])) { $dbQueries[] = DatabaseQuery::endsWith($attribute, $values[0]); } break; - case Query::TYPE_LESSER_EQUAL: + case Method::LessThanEqual: if (!empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::lessThanEqual($attribute, $value); } break; - case Query::TYPE_GREATER_EQUAL: + case Method::GreaterThanEqual: if (!empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::greaterThanEqual($attribute, $value); } break; - case Query::TYPE_ORDER_DESC: + case Method::OrderDesc: $dbQueries[] = DatabaseQuery::orderDesc($attribute); break; - case Query::TYPE_ORDER_ASC: + case Method::OrderAsc: $dbQueries[] = DatabaseQuery::orderAsc($attribute); break; - case Query::TYPE_LIMIT: + case Method::Limit: if (!empty($values)) { /** @var int|string $val */ $val = $values[0] ?? 0; $dbQueries[] = DatabaseQuery::limit((int) $val); } break; - case Query::TYPE_OFFSET: + case Method::Offset: if (!empty($values)) { /** @var int|string $val */ $val = $values[0] ?? 0; @@ -505,8 +505,8 @@ private function convertQueriesToDatabase(array $queries): array } break; - case UsageQuery::TYPE_GROUP_BY_INTERVAL: - case UsageQuery::TYPE_GROUP_BY: + case Method::GroupByTimeBucket: + case Method::GroupBy: // groupByInterval and groupBy are not pushed down to the // Database adapter; callers get raw (non-aggregated) results. // Validation runs in validateGroupByQueries() before this loop. @@ -533,7 +533,7 @@ private function validateGroupByQueries(array $queries): void $allowed = array_unique(array_merge(Metric::EVENT_COLUMNS, Metric::GAUGE_COLUMNS)); foreach ($queries as $query) { - if ($query->getMethod() !== UsageQuery::TYPE_GROUP_BY) { + if ($query->getMethod() !== Method::GroupBy) { continue; } diff --git a/src/Usage/UsageQuery.php b/src/Usage/UsageQuery.php index 1976a136..94da2cbf 100644 --- a/src/Usage/UsageQuery.php +++ b/src/Usage/UsageQuery.php @@ -2,14 +2,17 @@ namespace Utopia\Usage; +use Utopia\Query\Method; use Utopia\Query\Query; /** * Usage Query * - * Extends the base Query class with usage-specific query types. - * Currently adds support for `groupByInterval` which enables time-bucketed - * aggregated queries in the ClickHouse adapter. + * Extends the base Query class with usage-specific query factories. + * `groupByInterval` enables time-bucketed aggregated queries in the + * ClickHouse adapter (compiled as `Method::GroupByTimeBucket`), and + * `groupBy` buckets results by a dimension column + * (compiled as `Method::GroupBy`). * * Example usage: * ```php @@ -19,19 +22,17 @@ * Query::greaterThanEqual('time', '2026-03-01'), * Query::lessThanEqual('time', '2026-04-01'), * ]; - * $results = $usage->find($queries, 'event'); + * $results = $usage->find($tenant, $queries, 'event'); * ``` * - * When `groupByInterval` is present in the queries array, the ClickHouse adapter - * switches from raw row returns to aggregated results grouped by time bucket: + * When a `groupByInterval` query is present in the queries array, the + * ClickHouse adapter switches from raw row returns to aggregated results + * grouped by time bucket: * - Events: SUM(value) per bucket * - Gauges: argMax(value, time) per bucket */ class UsageQuery extends Query { - public const TYPE_GROUP_BY_INTERVAL = 'groupByInterval'; - public const TYPE_GROUP_BY = 'groupBy'; - /** * Valid interval values and their ClickHouse INTERVAL equivalents. */ @@ -46,18 +47,6 @@ class UsageQuery extends Query '1M' => 'INTERVAL 1 MONTH', ]; - /** - * Override isMethod to accept groupByInterval and groupBy in addition to all base Query methods. - */ - public static function isMethod(string $value): bool - { - if ($value === self::TYPE_GROUP_BY_INTERVAL || $value === self::TYPE_GROUP_BY) { - return true; - } - - return parent::isMethod($value); - } - /** * Create a groupByInterval query. * @@ -65,7 +54,7 @@ public static function isMethod(string $value): bool * aggregated results instead of raw rows. * * @param string $attribute The time attribute to bucket (usually 'time') - * @param string $interval The bucket size: '1m', '5m', '15m', '1h', '1d', '1w', '1M' + * @param string $interval The bucket size: '1m', '5m', '15m', '30m', '1h', '1d', '1w', '1M' * @return self */ public static function groupByInterval(string $attribute, string $interval): self @@ -76,7 +65,7 @@ public static function groupByInterval(string $attribute, string $interval): sel ); } - return new self(self::TYPE_GROUP_BY_INTERVAL, $attribute, [$interval]); + return new self(Method::GroupByTimeBucket, $attribute, [$interval]); } /** @@ -87,22 +76,19 @@ public static function groupByInterval(string $attribute, string $interval): sel */ public static function isGroupByInterval(Query $query): bool { - return $query->getMethod() === self::TYPE_GROUP_BY_INTERVAL; + return $query->getMethod() === Method::GroupByTimeBucket; } /** * Extract the groupByInterval query from an array of queries, if present. * - * Queries parsed via `Query::parse()` are base `Query` objects rather than - * `UsageQuery` instances, so we match on the method string alone. - * * @param array $queries * @return Query|null The groupByInterval query, or null if not present */ public static function extractGroupByInterval(array $queries): ?Query { foreach ($queries as $query) { - if ($query->getMethod() === self::TYPE_GROUP_BY_INTERVAL) { + if (self::isGroupByInterval($query)) { return $query; } } @@ -132,12 +118,15 @@ public static function removeGroupByInterval(array $queries): array * supplied via `groupByInterval`. Multiple `groupBy` queries may be * combined to bucket by several dimensions at once (e.g. service x status). * - * @param string $attribute The dimension column to bucket on (service, path, status, ...). - * @return self + * @param array|string $attributes The dimension column(s) to bucket on (service, path, status, ...). */ - public static function groupBy(string $attribute): self + public static function groupBy(array|string $attributes): static { - return new self(self::TYPE_GROUP_BY, $attribute, []); + if (is_string($attributes)) { + return new static(Method::GroupBy, $attributes, []); + } + + return parent::groupBy($attributes); } /** @@ -148,7 +137,7 @@ public static function groupBy(string $attribute): self */ public static function isGroupBy(Query $query): bool { - return $query->getMethod() === self::TYPE_GROUP_BY; + return $query->getMethod() === Method::GroupBy; } /** diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index b2940651..c5453cb6 100644 --- a/tests/Usage/Adapter/ClickHouseTest.php +++ b/tests/Usage/Adapter/ClickHouseTest.php @@ -3,6 +3,7 @@ namespace Utopia\Tests\Adapter; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; use Utopia\Tests\Usage\UsageBase; use Utopia\Usage\Adapter\ClickHouse as ClickHouseAdapter; @@ -1214,7 +1215,7 @@ public function testEqualRejectsEmptyValues(): void $this->expectExceptionMessage('Equal queries require at least one value.'); $this->usage->find('1', [ - new Query(Query::TYPE_EQUAL, 'metric', []), + new Query(Method::Equal, 'metric', []), ], Usage::TYPE_EVENT); } diff --git a/tests/Usage/UsageQueryTest.php b/tests/Usage/UsageQueryTest.php index 924c6e37..22cfa325 100644 --- a/tests/Usage/UsageQueryTest.php +++ b/tests/Usage/UsageQueryTest.php @@ -3,6 +3,7 @@ namespace Utopia\Tests\Usage; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; use Utopia\Usage\UsageQuery; @@ -13,7 +14,7 @@ public function testGroupByIntervalCreation(): void $query = UsageQuery::groupByInterval('time', '1h'); $this->assertInstanceOf(UsageQuery::class, $query); - $this->assertEquals(UsageQuery::TYPE_GROUP_BY_INTERVAL, $query->getMethod()); + $this->assertEquals(Method::GroupByTimeBucket, $query->getMethod()); $this->assertEquals('time', $query->getAttribute()); $this->assertEquals(['1h'], $query->getValues()); $this->assertEquals('1h', $query->getValue()); @@ -63,14 +64,14 @@ public function testExtractGroupByInterval(): void $this->assertNotNull($extracted); $this->assertInstanceOf(Query::class, $extracted); - $this->assertEquals(UsageQuery::TYPE_GROUP_BY_INTERVAL, $extracted->getMethod()); + $this->assertEquals(Method::GroupByTimeBucket, $extracted->getMethod()); $this->assertEquals('1h', $extracted->getValue()); } public function testExtractGroupByIntervalFromParsedQuery(): void { // Queries created via Query::parse() are base Query objects, not UsageQuery. - $parsedGroupBy = new Query(UsageQuery::TYPE_GROUP_BY_INTERVAL, 'time', ['1h']); + $parsedGroupBy = new Query(Method::GroupByTimeBucket, 'time', ['1h']); $equalQuery = Query::equal('metric', ['bandwidth']); $queries = [$equalQuery, $parsedGroupBy]; @@ -78,7 +79,7 @@ public function testExtractGroupByIntervalFromParsedQuery(): void $extracted = UsageQuery::extractGroupByInterval($queries); $this->assertNotNull($extracted); - $this->assertEquals(UsageQuery::TYPE_GROUP_BY_INTERVAL, $extracted->getMethod()); + $this->assertEquals(Method::GroupByTimeBucket, $extracted->getMethod()); $this->assertEquals('1h', $extracted->getValue()); } @@ -104,7 +105,7 @@ public function testRemoveGroupByInterval(): void $this->assertCount(2, $remaining); foreach ($remaining as $query) { - $this->assertNotEquals(UsageQuery::TYPE_GROUP_BY_INTERVAL, $query->getMethod()); + $this->assertNotEquals(Method::GroupByTimeBucket, $query->getMethod()); } } @@ -137,16 +138,16 @@ public function testGroupByCreation(): void $query = UsageQuery::groupBy('service'); $this->assertInstanceOf(UsageQuery::class, $query); - $this->assertEquals(UsageQuery::TYPE_GROUP_BY, $query->getMethod()); + $this->assertEquals(Method::GroupBy, $query->getMethod()); $this->assertEquals('service', $query->getAttribute()); $this->assertEquals([], $query->getValues()); } public function testGroupByIsMethod(): void { - $this->assertTrue(UsageQuery::isMethod(UsageQuery::TYPE_GROUP_BY)); - $this->assertTrue(UsageQuery::isMethod(UsageQuery::TYPE_GROUP_BY_INTERVAL)); - $this->assertTrue(UsageQuery::isMethod(Query::TYPE_EQUAL)); + $this->assertTrue(UsageQuery::isMethod(Method::GroupBy->value)); + $this->assertTrue(UsageQuery::isMethod(Method::GroupByTimeBucket->value)); + $this->assertTrue(UsageQuery::isMethod(Method::Equal->value)); $this->assertFalse(UsageQuery::isMethod('notARealMethod')); } @@ -200,14 +201,14 @@ public function testRemoveGroupBy(): void $this->assertCount(2, $remaining); foreach ($remaining as $query) { - $this->assertNotEquals(UsageQuery::TYPE_GROUP_BY, $query->getMethod()); + $this->assertNotEquals(Method::GroupBy, $query->getMethod()); } } public function testGroupByParseRoundTrip(): void { $json = json_encode([ - 'method' => UsageQuery::TYPE_GROUP_BY, + 'method' => Method::GroupBy->value, 'attribute' => 'service', 'values' => [], ]); @@ -215,7 +216,7 @@ public function testGroupByParseRoundTrip(): void $parsed = UsageQuery::parse($json); - $this->assertEquals(UsageQuery::TYPE_GROUP_BY, $parsed->getMethod()); + $this->assertEquals(Method::GroupBy, $parsed->getMethod()); $this->assertEquals('service', $parsed->getAttribute()); $this->assertEquals([], $parsed->getValues()); } @@ -223,7 +224,7 @@ public function testGroupByParseRoundTrip(): void public function testExtractGroupByFromParsedQuery(): void { // Queries created via Query::parse() are base Query objects, not UsageQuery. - $parsedGroupBy = new Query(UsageQuery::TYPE_GROUP_BY, 'service', []); + $parsedGroupBy = new Query(Method::GroupBy, 'service', []); $equal = Query::equal('metric', ['bandwidth']); $extracted = UsageQuery::extractGroupBy([$equal, $parsedGroupBy]); From 7f08e8f13e0dbf861b424c7eb5bed52b763c4b0f Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 25 Aug 2026 07:16:07 +0545 Subject: [PATCH 2/6] chore: move to utopia-php/query 0.6 The branch was pinned at 0.3.* while query had released 0.4, 0.5 and 0.6. No adapter changes were needed: the Parser -> Classifier rename, the UnsupportedException removal, nested join conditions and Schema\Order are all symbols this package does not use. Query::contains() is deprecated in 0.6 in favour of containsString() for substring matching. One test called it; containsString() returns the same Method::Contains the adapter switches on, so the swap changes no SQL. The two other hits are unrelated - one is Utopia\Database\Query, a different package, and one is a comment. pint pass, phpstan [OK] No errors, UsageQueryTest 29 tests / 84 assertions. The ClickHouse suite needs a live server and runs in CI. --- composer.json | 2 +- composer.lock | 14 +++++++------- tests/Usage/Adapter/ClickHouseTest.php | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/composer.json b/composer.json index 89e2d048..b7595c14 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "psr/http-client": "^1.0", "utopia-php/client": "^0.3", "utopia-php/database": "^7.0.0", - "utopia-php/query": "0.3.*" + "utopia-php/query": "0.6.*" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/composer.lock b/composer.lock index 9ebfaed4..5f326bb4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f39933edd84fe55f74527cf10c17057f", + "content-hash": "25a217c352c136f58d569b5d78e464ee", "packages": [ { "name": "brick/math", @@ -2480,16 +2480,16 @@ }, { "name": "utopia-php/query", - "version": "0.3.3", + "version": "0.6.0", "source": { "type": "git", "url": "https://github.com/utopia-php/query.git", - "reference": "76cd71071cd59cb45ef6b30eb11449f3832bf18f" + "reference": "abaebb2f3426bdbc6f44bab04254a668de937148" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/query/zipball/76cd71071cd59cb45ef6b30eb11449f3832bf18f", - "reference": "76cd71071cd59cb45ef6b30eb11449f3832bf18f", + "url": "https://api.github.com/repos/utopia-php/query/zipball/abaebb2f3426bdbc6f44bab04254a668de937148", + "reference": "abaebb2f3426bdbc6f44bab04254a668de937148", "shasum": "" }, "require": { @@ -2523,9 +2523,9 @@ ], "support": { "issues": "https://github.com/utopia-php/query/issues", - "source": "https://github.com/utopia-php/query/tree/0.3.3" + "source": "https://github.com/utopia-php/query/tree/0.6.0" }, - "time": "2026-07-06T04:55:56+00:00" + "time": "2026-08-21T11:03:36+00:00" }, { "name": "utopia-php/span", diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index c5453cb6..1a0a1617 100644 --- a/tests/Usage/Adapter/ClickHouseTest.php +++ b/tests/Usage/Adapter/ClickHouseTest.php @@ -710,7 +710,7 @@ public function testFindComprehensive(): void // 6. Contains (IN alias) $results = $this->usage->find('1', [ - \Utopia\Query\Query::contains('metric', ['metric-A']), + \Utopia\Query\Query::containsString('metric', ['metric-A']), ], Usage::TYPE_EVENT); $this->assertGreaterThanOrEqual(1, count($results)); From 8bbbd9379c5e8a4a76fbe72db9ab433f1e80195e Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 25 Aug 2026 07:51:41 +0545 Subject: [PATCH 3/6] refactor: drop the UsageQuery helpers nothing calls Eight public statics - isGroupByInterval, extractGroupByInterval, removeGroupByInterval, isGroupBy, extractGroupBy, removeGroupBy, isAggregate and removeAggregate - had no caller outside their own tests. The only references in src were the helpers calling each other, a closed loop. They are left over from the pre-0.3 design, where the adapter pulled hints out of the query list before compiling it. parseQueries() now switches on Method directly (GroupByTimeBucket, GroupBy, Max), so nothing needs to ask a Query what kind it is or to filter it back out of a list. Checked the consumer, not just this package: cloud calls groupBy, groupByInterval, aggregate and the inherited parse, and none of the removed methods. What stays is what is actually used - VALID_INTERVALS and VALID_AGGREGATES, the three factories, and extractAggregate, which the adapter uses to read an aggregate's name back off its Method. 278 -> 171 lines. Also dropped testGroupByIsMethod and testAggregateIsMethod: after the isMethod() override went away in the 0.3 migration they only asserted that the base library resolves its own enum. phpstan [OK] No errors, pint pass, UsageQueryTest 15 tests / 49 assertions. --- src/Usage/UsageQuery.php | 107 -------------------- tests/Usage/UsageQueryTest.php | 172 --------------------------------- 2 files changed, 279 deletions(-) diff --git a/src/Usage/UsageQuery.php b/src/Usage/UsageQuery.php index e1bf7b77..aa1a6645 100644 --- a/src/Usage/UsageQuery.php +++ b/src/Usage/UsageQuery.php @@ -104,49 +104,6 @@ public static function groupByInterval(string $attribute, string $interval): sel return new self(Method::GroupByTimeBucket, $attribute, [$interval]); } - /** - * Check if a query is a groupByInterval query. - * - * @param Query $query - * @return bool - */ - public static function isGroupByInterval(Query $query): bool - { - return $query->getMethod() === Method::GroupByTimeBucket; - } - - /** - * Extract the groupByInterval query from an array of queries, if present. - * - * @param array $queries - * @return Query|null The groupByInterval query, or null if not present - */ - public static function extractGroupByInterval(array $queries): ?Query - { - foreach ($queries as $query) { - if (self::isGroupByInterval($query)) { - return $query; - } - } - - return null; - } - - /** - * Remove groupByInterval queries from an array of queries. - * - * Returns the remaining queries that should be processed normally. - * - * @param array $queries - * @return array - */ - public static function removeGroupByInterval(array $queries): array - { - return array_values(array_filter($queries, function (Query $query) { - return !self::isGroupByInterval($query); - })); - } - /** * Create a groupBy query for dimensional aggregation. * @@ -165,47 +122,6 @@ public static function groupBy(array|string $attributes): static return parent::groupBy($attributes); } - /** - * Check if a query is a groupBy query. - * - * @param Query $query - * @return bool - */ - public static function isGroupBy(Query $query): bool - { - return $query->getMethod() === Method::GroupBy; - } - - /** - * Extract all groupBy queries from an array of queries. - * - * Multiple groupBy queries can coexist (group by service AND status), so - * this returns every match rather than the single-instance form used by - * groupByInterval. - * - * @param array $queries - * @return array - */ - public static function extractGroupBy(array $queries): array - { - return array_values(array_filter($queries, function (Query $query) { - return self::isGroupBy($query); - })); - } - - /** - * Remove all groupBy queries from an array of queries. - * - * @param array $queries - * @return array - */ - public static function removeGroupBy(array $queries): array - { - return array_values(array_filter($queries, function (Query $query) { - return !self::isGroupBy($query); - })); - } - /** * Create an aggregate query selecting the aggregation function. * @@ -230,17 +146,6 @@ public static function aggregate(string $function): self return new self(self::AGGREGATE_METHODS[$function], 'value', []); } - /** - * Check if a query is an aggregate query. - * - * @param Query $query - * @return bool - */ - public static function isAggregate(Query $query): bool - { - return \in_array($query->getMethod(), self::AGGREGATE_METHODS, true); - } - /** * Extract the aggregation function from an array of queries, if present. * @@ -263,16 +168,4 @@ public static function extractAggregate(array $queries): ?string return null; } - /** - * Remove all aggregate queries from an array of queries. - * - * @param array $queries - * @return array - */ - public static function removeAggregate(array $queries): array - { - return array_values(array_filter($queries, function (Query $query) { - return !self::isAggregate($query); - })); - } } diff --git a/tests/Usage/UsageQueryTest.php b/tests/Usage/UsageQueryTest.php index c42f6e0e..c74c4b55 100644 --- a/tests/Usage/UsageQueryTest.php +++ b/tests/Usage/UsageQueryTest.php @@ -43,72 +43,6 @@ public function testGroupByIntervalInvalidIntervalEmpty(): void UsageQuery::groupByInterval('time', ''); } - public function testIsGroupByInterval(): void - { - $groupByQuery = UsageQuery::groupByInterval('time', '1h'); - $regularQuery = Query::equal('metric', ['bandwidth']); - - $this->assertTrue(UsageQuery::isGroupByInterval($groupByQuery)); - $this->assertFalse(UsageQuery::isGroupByInterval($regularQuery)); - } - - public function testExtractGroupByInterval(): void - { - $groupByQuery = UsageQuery::groupByInterval('time', '1h'); - $equalQuery = Query::equal('metric', ['bandwidth']); - $timeQuery = Query::greaterThanEqual('time', '2026-03-01'); - - $queries = [$equalQuery, $groupByQuery, $timeQuery]; - - $extracted = UsageQuery::extractGroupByInterval($queries); - - $this->assertNotNull($extracted); - $this->assertInstanceOf(Query::class, $extracted); - $this->assertEquals(Method::GroupByTimeBucket, $extracted->getMethod()); - $this->assertEquals('1h', $extracted->getValue()); - } - - public function testExtractGroupByIntervalFromParsedQuery(): void - { - // Queries created via Query::parse() are base Query objects, not UsageQuery. - $parsedGroupBy = new Query(Method::GroupByTimeBucket, 'time', ['1h']); - $equalQuery = Query::equal('metric', ['bandwidth']); - - $queries = [$equalQuery, $parsedGroupBy]; - - $extracted = UsageQuery::extractGroupByInterval($queries); - - $this->assertNotNull($extracted); - $this->assertEquals(Method::GroupByTimeBucket, $extracted->getMethod()); - $this->assertEquals('1h', $extracted->getValue()); - } - - public function testExtractGroupByIntervalReturnsNullWhenMissing(): void - { - $queries = [ - Query::equal('metric', ['bandwidth']), - Query::greaterThanEqual('time', '2026-03-01'), - ]; - - $this->assertNull(UsageQuery::extractGroupByInterval($queries)); - } - - public function testRemoveGroupByInterval(): void - { - $groupByQuery = UsageQuery::groupByInterval('time', '1h'); - $equalQuery = Query::equal('metric', ['bandwidth']); - $timeQuery = Query::greaterThanEqual('time', '2026-03-01'); - - $queries = [$equalQuery, $groupByQuery, $timeQuery]; - $remaining = UsageQuery::removeGroupByInterval($queries); - - $this->assertCount(2, $remaining); - - foreach ($remaining as $query) { - $this->assertNotEquals(Method::GroupByTimeBucket, $query->getMethod()); - } - } - public function testValidIntervalsConstant(): void { $this->assertArrayHasKey('1m', UsageQuery::VALID_INTERVALS); @@ -143,68 +77,6 @@ public function testGroupByCreation(): void $this->assertEquals([], $query->getValues()); } - public function testGroupByIsMethod(): void - { - $this->assertTrue(UsageQuery::isMethod(Method::GroupBy->value)); - $this->assertTrue(UsageQuery::isMethod(Method::GroupByTimeBucket->value)); - $this->assertTrue(UsageQuery::isMethod(Method::Equal->value)); - $this->assertFalse(UsageQuery::isMethod('notARealMethod')); - } - - public function testIsGroupBy(): void - { - $groupBy = UsageQuery::groupBy('service'); - $groupByInterval = UsageQuery::groupByInterval('time', '1h'); - $regular = Query::equal('metric', ['bandwidth']); - - $this->assertTrue(UsageQuery::isGroupBy($groupBy)); - $this->assertFalse(UsageQuery::isGroupBy($groupByInterval)); - $this->assertFalse(UsageQuery::isGroupBy($regular)); - } - - public function testExtractGroupByReturnsAllMatches(): void - { - $byService = UsageQuery::groupBy('service'); - $byPath = UsageQuery::groupBy('path'); - $interval = UsageQuery::groupByInterval('time', '1h'); - $equal = Query::equal('metric', ['bandwidth']); - - $queries = [$equal, $byService, $interval, $byPath]; - - $extracted = UsageQuery::extractGroupBy($queries); - - $this->assertCount(2, $extracted); - $this->assertEquals('service', $extracted[0]->getAttribute()); - $this->assertEquals('path', $extracted[1]->getAttribute()); - } - - public function testExtractGroupByReturnsEmptyWhenAbsent(): void - { - $queries = [ - Query::equal('metric', ['bandwidth']), - UsageQuery::groupByInterval('time', '1h'), - ]; - - $this->assertSame([], UsageQuery::extractGroupBy($queries)); - } - - public function testRemoveGroupBy(): void - { - $byService = UsageQuery::groupBy('service'); - $byPath = UsageQuery::groupBy('path'); - $interval = UsageQuery::groupByInterval('time', '1h'); - $equal = Query::equal('metric', ['bandwidth']); - - $queries = [$equal, $byService, $interval, $byPath]; - - $remaining = UsageQuery::removeGroupBy($queries); - - $this->assertCount(2, $remaining); - foreach ($remaining as $query) { - $this->assertNotEquals(Method::GroupBy, $query->getMethod()); - } - } - public function testGroupByParseRoundTrip(): void { $json = json_encode([ @@ -221,18 +93,6 @@ public function testGroupByParseRoundTrip(): void $this->assertEquals([], $parsed->getValues()); } - public function testExtractGroupByFromParsedQuery(): void - { - // Queries created via Query::parse() are base Query objects, not UsageQuery. - $parsedGroupBy = new Query(Method::GroupBy, 'service', []); - $equal = Query::equal('metric', ['bandwidth']); - - $extracted = UsageQuery::extractGroupBy([$equal, $parsedGroupBy]); - - $this->assertCount(1, $extracted); - $this->assertEquals('service', $extracted[0]->getAttribute()); - } - public function testAggregateCreation(): void { $query = UsageQuery::aggregate('max'); @@ -262,22 +122,6 @@ public function testAggregateRejectsInvalidFunction(): void UsageQuery::aggregate('peak'); } - public function testAggregateIsMethod(): void - { - // No override needed any more - 'max' is a real method in query 0.3, - // so the base implementation resolves it through Method::tryFrom(). - $this->assertTrue(UsageQuery::isMethod('max')); - } - - public function testIsAggregate(): void - { - $aggregate = UsageQuery::aggregate('max'); - $regular = Query::equal('metric', ['bandwidth']); - - $this->assertTrue(UsageQuery::isAggregate($aggregate)); - $this->assertFalse(UsageQuery::isAggregate($regular)); - } - public function testExtractAggregate(): void { $queries = [ @@ -307,22 +151,6 @@ public function testExtractAggregateReturnsNullWhenMissing(): void $this->assertNull(UsageQuery::extractAggregate($queries)); } - public function testRemoveAggregate(): void - { - $queries = [ - Query::equal('metric', ['realtime.connections']), - UsageQuery::aggregate('max'), - UsageQuery::groupByInterval('time', '1h'), - ]; - - $remaining = UsageQuery::removeAggregate($queries); - - $this->assertCount(2, $remaining); - foreach ($remaining as $query) { - $this->assertNotSame(Method::Max, $query->getMethod()); - } - } - public function testValidAggregatesConstant(): void { // `max` is the only selectable aggregate: it overrides the per-type From 48c1cda3d44208c734d636fd2986927a094f8dcc Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 25 Aug 2026 08:49:00 +0545 Subject: [PATCH 4/6] fix: stop the Database adapter silently dropping unsupported filters Review caught that Method::ContainsAny is handled by the ClickHouse adapter - it is in VALUE_REQUIRED_METHODS and has an arm in parseQueries - while Database::convertQueriesToDatabase had neither a case for it nor a default. The predicate fell out of the switch, contributed no WHERE fragment, and the query returned every row: a wrong answer wearing the shape of a working one, and the two backends disagreeing on the same input. Verified before fixing: no 'case Method::ContainsAny' and no 'default:' in that switch, against an arm at ClickHouse.php:3743. Mapped it to DatabaseQuery::containsAny(), and added a default that throws. The missing case was the bug; the missing default is why it could happen quietly, and why the next method added upstream would repeat it. That is the same reasoning the ClickHouse adapter already applies to empty filter values, where it refuses rather than emit a full-table match. testContainsAnyFiltersRatherThanBeingDropped asserts the filtered set is genuinely smaller than the unfiltered one, which is what a dropped predicate would break. It lives in UsageBase so both adapters run it, and needs the live MariaDB/ClickHouse services - so its red has not been observed locally, only the static gates have run here. --- src/Usage/Adapter/Database.php | 13 +++++++++++++ tests/Usage/UsageBase.php | 29 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/Usage/Adapter/Database.php b/src/Usage/Adapter/Database.php index 5d02c7aa..08761cdd 100644 --- a/src/Usage/Adapter/Database.php +++ b/src/Usage/Adapter/Database.php @@ -4,6 +4,7 @@ use Utopia\Database\Database as UtopiaDatabase; use Utopia\Database\Document; +use Exception; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Query as DatabaseQuery; use Utopia\Usage\Metric; @@ -446,6 +447,10 @@ private function convertQueriesToDatabase(array $queries): array /** @var array|bool|float|int|string> $values */ $dbQueries[] = DatabaseQuery::contains($attribute, $values); break; + case Method::ContainsAny: + /** @var array|bool|float|int|string> $values */ + $dbQueries[] = DatabaseQuery::containsAny($attribute, $values); + break; case Method::NotEqual: if (!empty($values)) { /** @var bool|float|int|string $value */ @@ -517,6 +522,14 @@ private function convertQueriesToDatabase(array $queries): array // Database adapter; callers get raw (non-aggregated) results. // Validation runs in validateGroupByQueries() before this loop. break; + default: + // A method with no arm used to fall out of this switch and + // contribute no WHERE fragment, so the filter silently did + // not apply and the caller got a wider result set than it + // asked for - a wrong answer that looks like a working + // query. Refuse instead: a method this adapter cannot push + // down has to be visible. + throw new Exception('Unsupported query method for the Database adapter: ' . $method->value); } } diff --git a/tests/Usage/UsageBase.php b/tests/Usage/UsageBase.php index da6a1250..e348c4d0 100644 --- a/tests/Usage/UsageBase.php +++ b/tests/Usage/UsageBase.php @@ -237,6 +237,35 @@ public function testContainsQuery(): void } } + /** + * `containsAny` has to reach the adapter, not fall out of it. + * + * The Database adapter's switch had no arm for Method::ContainsAny and no + * default, so the predicate contributed no WHERE fragment: the query still + * succeeded and returned every row, which is a wrong answer wearing the + * shape of a working one. The ClickHouse adapter had handled it all along, + * so the two backends disagreed on the same query. + */ + public function testContainsAnyFiltersRatherThanBeingDropped(): void + { + $matching = $this->usage->find('1', [ + Query::containsAny('metric', ['requests']), + ], Usage::TYPE_EVENT); + + $everything = $this->usage->find('1', [], Usage::TYPE_EVENT); + + $this->assertNotEmpty($matching, 'the filter must still match its own rows'); + $this->assertLessThan( + \count($everything), + \count($matching), + 'a dropped predicate returns the unfiltered set, which is the failure this guards', + ); + + foreach ($matching as $row) { + $this->assertSame('requests', $row->getMetric()); + } + } + public function testNotContainsQuery(): void { // notContains is a negated substring match — excludes any metric From 9bae11675de0ef3099ba202e1f2cf8b68217fe0d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 25 Aug 2026 08:58:12 +0545 Subject: [PATCH 5/6] fix: give the Database adapter an arm for every method, not just a default The default-throw added in 48c1cda made the silent drops visible, and re-review immediately used that to find four more: IsNull, IsNotNull, CursorAfter, CursorBefore and Max were all reaching it. Turning a silent wrong answer into a loud failure is progress, but throwing on methods the adapter can actually serve is its own regression. - IsNull / IsNotNull now delegate to DatabaseQuery::isNull()/isNotNull(). They take no values, which is why they are absent from VALUE_REQUIRED_METHODS. - Max joins GroupBy and GroupByTimeBucket as explicitly not pushed down: this adapter returns raw rows, so an aggregate hint is ignored rather than refused. - CursorAfter / CursorBefore refuse with a message naming the limitation. This adapter has never implemented cursor pagination and DatabaseQuery's cursors take a Document rather than the value carried here, so the honest answer is to say so instead of returning an unpaginated set that reads as success. Every Method the ClickHouse adapter handles now has an explicit arm here - verified by diffing the two switches - so the default is left for methods that genuinely do not exist yet, which is what it is for. --- src/Usage/Adapter/Database.php | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Usage/Adapter/Database.php b/src/Usage/Adapter/Database.php index 08761cdd..b5c21de9 100644 --- a/src/Usage/Adapter/Database.php +++ b/src/Usage/Adapter/Database.php @@ -451,6 +451,13 @@ private function convertQueriesToDatabase(array $queries): array /** @var array|bool|float|int|string> $values */ $dbQueries[] = DatabaseQuery::containsAny($attribute, $values); break; + case Method::IsNull: + // Takes no values, so it is not in VALUE_REQUIRED_METHODS. + $dbQueries[] = DatabaseQuery::isNull($attribute); + break; + case Method::IsNotNull: + $dbQueries[] = DatabaseQuery::isNotNull($attribute); + break; case Method::NotEqual: if (!empty($values)) { /** @var bool|float|int|string $value */ @@ -518,10 +525,20 @@ private function convertQueriesToDatabase(array $queries): array case Method::GroupByTimeBucket: case Method::GroupBy: - // groupByInterval and groupBy are not pushed down to the - // Database adapter; callers get raw (non-aggregated) results. - // Validation runs in validateGroupByQueries() before this loop. + case Method::Max: + // groupByInterval, groupBy and aggregate() are not pushed + // down to the Database adapter; callers get raw + // (non-aggregated) results. Validation runs in + // validateGroupByQueries() before this loop. break; + case Method::CursorAfter: + case Method::CursorBefore: + // This adapter has never implemented cursor pagination, and + // DatabaseQuery's cursors take a Document rather than the + // value carried here. Refusing says so; the previous + // behaviour was to drop the cursor and return an unpaginated + // set, which reads as success. + throw new Exception('The Database adapter does not support cursor pagination; use the ClickHouse adapter or limit/offset.'); default: // A method with no arm used to fall out of this switch and // contribute no WHERE fragment, so the filter silently did From 7b4f778efb8bbd7391bb924ee355b30898aa0a99 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 25 Aug 2026 09:04:52 +0545 Subject: [PATCH 6/6] chore: correct the changelog version and guard the ClickHouse switch too Two findings from the re-review, both accurate. The CHANGELOG still announced 'query 0.3.x' and a bump 'to 0.3.*' after composer.json moved to 0.6.*. That is the file a consumer reads to decide whether an upgrade will break them, so a stale version label there is worse than a stale comment. parseQueries() in the ClickHouse adapter had no default arm, the same shape that let ContainsAny be silently dropped by the Database adapter in 48c1cda. It has 24 arms and accepts a whitelist, so nothing reaches the end today - but that was equally true of the Database switch until a method was added upstream. Both adapters now refuse an unhandled method rather than compiling a query without it. pint pass, phpstan [OK] No errors, UsageQueryTest 15/49. --- CHANGELOG.md | 4 ++-- src/Usage/Adapter/ClickHouse.php | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd63d5d..776ea8bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Changelog -## Unreleased — query 0.3.x builder +## Unreleased — query 0.6.x builder ### Breaking -- Bumped `utopia-php/query` from `0.1.*` to `0.3.*`. +- Bumped `utopia-php/query` from `0.1.*` to `0.6.*`. `Query::getMethod()` now returns the `Utopia\Query\Method` enum instead of a string, and the `Query::TYPE_*` / `UsageQuery::TYPE_GROUP_BY_INTERVAL` / `UsageQuery::TYPE_GROUP_BY` diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 17754e79..c9050068 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -3847,6 +3847,11 @@ private function parseQueries(?string $tenant, array $queries, string $type = 'e // name so the rest of the adapter still works in strings. $aggregate = UsageQuery::extractAggregate([$query]); break; + default: + // Same guard the Database adapter carries: a method with no + // arm would contribute nothing and the caller would get a + // wider result than it asked for. Fail where it can be seen. + throw new Exception('Unsupported query method for the ClickHouse adapter: ' . $method->value); } }