From 8fc13b34fb6a57dcb96929068cadca702508006f Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 6 Jul 2026 03:42:08 +0000 Subject: [PATCH] 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 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() filters are rewritten to IN / NOT IN before reaching the builder to keep set-membership semantics (the builder compiles Contains as substring LIKE). --- CHANGELOG.md | 17 +- composer.json | 2 +- composer.lock | 17 +- src/Usage/Adapter/ClickHouse.php | 1459 ++++++++++++------------ 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, 822 insertions(+), 798 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75b08dcc..4b5487d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,24 @@ # Changelog -## Unreleased — resourceType rename + queued time + ip dim + gauge fill +## Unreleased — resourceType rename + queued time + ip dim + gauge fill + 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. + - Renamed the `resource` dimension to `resourceType` across the events, gauges, and events-daily tables. All Metric column constants, schema definitions, indexes, projections 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..7090532b 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.2", "source": { "type": "git", "url": "https://github.com/utopia-php/query.git", - "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27" + "reference": "84d31822f57adc9ee60d6d5a07fa4a5175278550" }, "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/84d31822f57adc9ee60d6d5a07fa4a5175278550", + "reference": "84d31822f57adc9ee60d6d5a07fa4a5175278550", "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.2" }, - "time": "2026-03-03T09:05:14+00:00" + "time": "2026-05-20T05:27:42+00:00" }, { "name": "utopia-php/span", diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 8a888993..ee64a79c 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; @@ -505,7 +512,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); @@ -585,13 +592,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; @@ -602,10 +610,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'; @@ -616,7 +623,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); @@ -668,6 +675,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 @@ -854,7 +1032,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' @@ -863,25 +1041,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']; @@ -889,34 +1069,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')); - $this->query($createTableSql); + return; + } + + $columnType = $this->getColumnType($id, $type); + + 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); + } } /** @@ -930,40 +1147,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)); } /** @@ -977,9 +1189,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'; @@ -993,18 +1203,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)); } /** @@ -1197,6 +1412,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. * @@ -1301,6 +1535,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 = []; @@ -1339,12 +1579,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. * @@ -1387,10 +1650,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]; @@ -1455,8 +1718,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 @@ -1467,7 +1730,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); @@ -1480,48 +1742,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']); + } + + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; - $result = $this->query($sql, $params); + $result = $this->query($sql, array_merge($statement->namedBindings ?? [], $extraBindings)); $rows = $this->parseResults($result, $type); @@ -1537,50 +1793,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`'; } - $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); - $whereClause = $whereData['clause']; - $params = $whereData['params']; + foreach ($parsed['groupBy'] ?? [] as $dim) { + $escapedDim = $this->escapeIdentifier($dim); + $builder->selectRaw($escapedDim); + $groupParts[] = $escapedDim; + } + + $this->applyFilters($builder, $tenant, $parsed); + + $builder->groupByRaw(implode(', ', $groupParts)); // Default ORDER BY: // - With time bucketing: bucket ASC (chronological time series). @@ -1588,44 +1842,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); } @@ -1742,40 +1989,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); } /** @@ -1849,31 +2092,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; } @@ -1885,7 +2136,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; @@ -1893,11 +2144,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)); } @@ -2009,33 +2260,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 @@ -2057,22 +2281,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; } } @@ -2142,12 +2366,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 = []; @@ -2204,7 +2428,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; @@ -2214,7 +2438,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; @@ -2224,7 +2448,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) { @@ -2266,67 +2490,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; } /** @@ -2408,7 +2625,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 @@ -2417,45 +2636,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); } /** @@ -2470,31 +2695,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 - "; - - $result = $this->query($sql, $params); + $builder = $this->newBuilder($type) + ->from($tableName) + ->sum($attribute, 'total'); - $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 ?? [])); } /** @@ -2508,7 +2723,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(); @@ -2517,7 +2732,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'; @@ -2526,22 +2740,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); } /** @@ -2568,9 +2786,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(); @@ -2579,14 +2796,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 ?? [])); } /** @@ -2614,38 +2834,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) { @@ -2771,52 +2976,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 @@ -3010,25 +3194,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 ?? [])); } /** @@ -3070,44 +3248,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) { @@ -3139,27 +3299,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. * @@ -3220,7 +3359,7 @@ private function formatTypedValue(string $chType, mixed $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)) { @@ -3270,7 +3409,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 ... @@ -3283,14 +3424,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']; @@ -3340,65 +3481,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; @@ -3406,7 +3543,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(); @@ -3419,95 +3555,58 @@ 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: + 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_GREATER: + case Method::Contains: + case Method::ContainsAny: + case Method::NotContains: + // Set-membership semantics: compile to IN / NOT IN rather + // than the builder's substring-LIKE interpretation. $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); + $clone = clone $query; + $clone->setMethod($method === Method::NotContains ? Method::NotEqual : Method::Equal); + $filters[] = $clone; break; - case Query::TYPE_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} BETWEEN {{$paramName1}:{$chType}} AND {{$paramName2}:{$chType}}"; - $params[$paramName1] = $this->formatTypedValue($chType, $values[0] ?? null); - $params[$paramName2] = $this->formatTypedValue($chType, $values[1] ?? null); - break; - - case Query::TYPE_NOT_BETWEEN: - $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; @@ -3517,112 +3616,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'; + $cursorDirection = $method === Method::CursorAfter ? '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); - break; - - case Query::TYPE_CONTAINS: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $inParams = []; - foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; - $inParams[] = "{{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $value); - } - if (!empty($inParams)) { - $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")"; - } - break; - - case Query::TYPE_NOT_CONTAINS: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $inParams = []; - foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; - $inParams[] = "{{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $value); - } - if (!empty($inParams)) { - $filters[] = "{$escapedAttr} NOT IN (" . implode(', ', $inParams) . ")"; - } - 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)) { @@ -3640,25 +3653,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; } @@ -3742,26 +3758,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; } /** @@ -3793,19 +3807,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); @@ -3854,7 +3868,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 @@ -3895,21 +3909,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 4d25bdb1..4eb8d089 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 @@ -392,25 +392,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]; @@ -419,22 +419,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]; @@ -443,44 +443,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; @@ -488,8 +488,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. @@ -516,7 +516,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 83aea812..c1f67117 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; @@ -1164,7 +1165,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]);