Skip to content

Commit 3d49192

Browse files
committed
feat(clickhouse): support groupBy dimension columns in aggregated find
Extends findAggregatedFromTable so callers can bucket aggregated rows by arbitrary dimension columns (service, path, status, ...) alongside the existing time bucket. - parseQueries() collects groupBy attributes into parsed[groupBy] and validates each against the type-specific column set (Metric::EVENT_COLUMNS / Metric::GAUGE_COLUMNS) via the new validateGroupByAttribute() helper. Unknown columns raise a descriptive Exception listing what is allowed. - findFromTable() rejects groupBy without groupByInterval - keeps the contract simple (caller always supplies a time bucket too) and matches the Database adapter behaviour. - findAggregatedFromTable() appends the escaped dim columns to both SELECT and GROUP BY. parseAggregatedResults() already passes unknown keys through to the Metric so the dim values surface as plain getters (getService, getPath, ...). - Integration tests: single-dim groupBy(service) over 1d aggregates to the expected per-service sums; multi-dim groupBy(service)+groupBy(path) over 1h produces the cartesian grouping with correct sums per pair.
1 parent aac7b95 commit 3d49192

2 files changed

Lines changed: 126 additions & 4 deletions

File tree

src/Usage/Adapter/ClickHouse.php

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,6 +1179,27 @@ private function validateAttributeName(string $attributeName, string $type = 'ev
11791179
throw new Exception("Invalid attribute name: {$attributeName}");
11801180
}
11811181

1182+
/**
1183+
* Validate that a groupBy attribute is an aggregable dimension column.
1184+
*
1185+
* Restricted to the indexed dimension columns for the table type — `metric`,
1186+
* `value` and `time` are excluded since they are already part of the
1187+
* aggregation (metric is in the SELECT, time is bucketed via
1188+
* groupByInterval, value is the measured quantity).
1189+
*
1190+
* @throws Exception
1191+
*/
1192+
private function validateGroupByAttribute(string $attribute, string $type): bool
1193+
{
1194+
$allowed = $type === Usage::TYPE_GAUGE ? Metric::GAUGE_COLUMNS : Metric::EVENT_COLUMNS;
1195+
1196+
if (in_array($attribute, $allowed, true)) {
1197+
return true;
1198+
}
1199+
1200+
throw new Exception("Invalid groupBy attribute '{$attribute}' for {$type}. Allowed: " . implode(', ', $allowed));
1201+
}
1202+
11821203
/**
11831204
* Columns available in the events daily (pre-aggregated) table.
11841205
*/
@@ -1567,6 +1588,10 @@ private function findFromTable(array $queries, string $type): array
15671588
throw new Exception('Cursor pagination cannot be combined with groupByInterval');
15681589
}
15691590

1591+
if (!empty($parsed['groupBy']) && !isset($parsed['groupByInterval'])) {
1592+
throw new Exception('groupBy requires groupByInterval to be specified');
1593+
}
1594+
15701595
// Check if groupByInterval is requested
15711596
if (isset($parsed['groupByInterval'])) {
15721597
return $this->findAggregatedFromTable($parsed, $fromTable, $type);
@@ -1629,7 +1654,7 @@ private function findFromTable(array $queries, string $type): array
16291654
* toStartOfInterval(time, INTERVAL 1 HOUR) as time
16301655
* FROM table WHERE ... GROUP BY metric, time ORDER BY time ASC
16311656
*
1632-
* @param array{filters: array<string>, params: array<string, mixed>, orderBy?: array<string>, limit?: int, offset?: int, groupByInterval?: string} $parsed Parsed query data from parseQueries()
1657+
* @param array{filters: array<string>, params: array<string, mixed>, orderBy?: array<string>, limit?: int, offset?: int, groupByInterval?: string, groupBy?: array<int, string>} $parsed Parsed query data from parseQueries()
16331658
* @param string $fromTable Fully qualified table reference
16341659
* @param string $type 'event' or 'gauge'
16351660
* @return array<Metric>
@@ -1650,6 +1675,18 @@ private function findAggregatedFromTable(array $parsed, string $fromTable, strin
16501675
// then alias back to 'time' in outer context for consistent Metric parsing.
16511676
$timeBucketExpr = "toStartOfInterval(time, {$intervalSql})";
16521677

1678+
$groupByDims = $parsed['groupBy'] ?? [];
1679+
$dimSelect = '';
1680+
$dimGroup = '';
1681+
if (!empty($groupByDims)) {
1682+
$escapedDims = array_map(
1683+
fn (string $dim): string => $this->escapeIdentifier($dim),
1684+
$groupByDims
1685+
);
1686+
$dimSelect = ', ' . implode(', ', $escapedDims);
1687+
$dimGroup = ', ' . implode(', ', $escapedDims);
1688+
}
1689+
16531690
$whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']);
16541691
$whereClause = $whereData['clause'];
16551692
$params = $whereData['params'];
@@ -1676,9 +1713,9 @@ private function findAggregatedFromTable(array $parsed, string $fromTable, strin
16761713
$offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : '';
16771714

16781715
$sql = "
1679-
SELECT metric, {$valueExpr}, {$timeBucketExpr} as bucket
1716+
SELECT metric, {$valueExpr}, {$timeBucketExpr} as bucket{$dimSelect}
16801717
FROM {$fromTable}{$whereClause}
1681-
GROUP BY metric, bucket{$orderClause}{$limitClause}{$offsetClause}
1718+
GROUP BY metric, bucket{$dimGroup}{$orderClause}{$limitClause}{$offsetClause}
16821719
FORMAT JSON
16831720
";
16841721

@@ -2764,7 +2801,7 @@ private function buildOrderBySql(array $orderAttributes, bool $flip = false): ar
27642801
*
27652802
* @param array<Query> $queries
27662803
* @param string $type 'event' or 'gauge' — used for attribute validation
2767-
* @return array{filters: array<string>, params: array<string, mixed>, orderBy?: array<string>, orderAttributes?: array<int, array{attribute: string, direction: string}>, limit?: int, offset?: int, groupByInterval?: string, cursor?: array<string, mixed>, cursorDirection?: string}
2804+
* @return array{filters: array<string>, params: array<string, mixed>, orderBy?: array<string>, orderAttributes?: array<int, array{attribute: string, direction: string}>, limit?: int, offset?: int, groupByInterval?: string, groupBy?: array<int, string>, cursor?: array<string, mixed>, cursorDirection?: string}
27682805
* @throws Exception
27692806
*/
27702807
private function parseQueries(array $queries, string $type = 'event'): array
@@ -2776,6 +2813,7 @@ private function parseQueries(array $queries, string $type = 'event'): array
27762813
$limit = null;
27772814
$offset = null;
27782815
$groupByInterval = null;
2816+
$groupBy = [];
27792817
$cursor = null;
27802818
$cursorDirection = null;
27812819
$paramCounter = 0;
@@ -3011,6 +3049,13 @@ private function parseQueries(array $queries, string $type = 'event'): array
30113049
}
30123050
$groupByInterval = $interval;
30133051
break;
3052+
3053+
case UsageQuery::TYPE_GROUP_BY:
3054+
$this->validateGroupByAttribute($attribute, $type);
3055+
if (!in_array($attribute, $groupBy, true)) {
3056+
$groupBy[] = $attribute;
3057+
}
3058+
break;
30143059
}
30153060
}
30163061

@@ -3036,6 +3081,10 @@ private function parseQueries(array $queries, string $type = 'event'): array
30363081
$result['groupByInterval'] = $groupByInterval;
30373082
}
30383083

3084+
if (!empty($groupBy)) {
3085+
$result['groupBy'] = $groupBy;
3086+
}
3087+
30393088
if ($cursor !== null && $cursorDirection !== null) {
30403089
$result['cursor'] = $cursor;
30413090
$result['cursorDirection'] = $cursorDirection;

tests/Usage/Adapter/ClickHouseTest.php

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,6 +1241,79 @@ public function testCursorWithGroupByIntervalThrows(): void
12411241
], Usage::TYPE_EVENT);
12421242
}
12431243

1244+
public function testGroupByServiceDailyAggregates(): void
1245+
{
1246+
$this->usage->purge();
1247+
1248+
$this->assertTrue($this->usage->addBatch([
1249+
['metric' => 'gb-service', 'value' => 10, 'tags' => ['service' => 'storage']],
1250+
['metric' => 'gb-service', 'value' => 25, 'tags' => ['service' => 'storage']],
1251+
['metric' => 'gb-service', 'value' => 5, 'tags' => ['service' => 'databases']],
1252+
], Usage::TYPE_EVENT));
1253+
1254+
$start = (new \DateTime())->modify('-1 day')->format('Y-m-d\TH:i:s');
1255+
$end = (new \DateTime())->modify('+1 day')->format('Y-m-d\TH:i:s');
1256+
1257+
$results = $this->usage->find([
1258+
UsageQuery::groupByInterval('time', '1d'),
1259+
UsageQuery::groupBy('service'),
1260+
Query::equal('metric', ['gb-service']),
1261+
Query::greaterThanEqual('time', $start),
1262+
Query::lessThanEqual('time', $end),
1263+
], Usage::TYPE_EVENT);
1264+
1265+
$this->assertGreaterThanOrEqual(2, count($results));
1266+
1267+
$byService = [];
1268+
foreach ($results as $row) {
1269+
$service = $row->getService();
1270+
$this->assertNotNull($service, 'groupBy(service) should surface the service dimension on each Metric');
1271+
$byService[$service] = ($byService[$service] ?? 0) + $row->getValue();
1272+
}
1273+
1274+
$this->assertEquals(35, $byService['storage'] ?? null);
1275+
$this->assertEquals(5, $byService['databases'] ?? null);
1276+
}
1277+
1278+
public function testGroupByMultipleDimensionsHourly(): void
1279+
{
1280+
$this->usage->purge();
1281+
1282+
$this->assertTrue($this->usage->addBatch([
1283+
['metric' => 'gb-multi', 'value' => 1, 'tags' => ['service' => 'storage', 'path' => '/v1/a']],
1284+
['metric' => 'gb-multi', 'value' => 2, 'tags' => ['service' => 'storage', 'path' => '/v1/a']],
1285+
['metric' => 'gb-multi', 'value' => 4, 'tags' => ['service' => 'storage', 'path' => '/v1/b']],
1286+
['metric' => 'gb-multi', 'value' => 8, 'tags' => ['service' => 'databases', 'path' => '/v1/a']],
1287+
], Usage::TYPE_EVENT));
1288+
1289+
$start = (new \DateTime())->modify('-1 hour')->format('Y-m-d\TH:i:s');
1290+
$end = (new \DateTime())->modify('+1 hour')->format('Y-m-d\TH:i:s');
1291+
1292+
$results = $this->usage->find([
1293+
UsageQuery::groupByInterval('time', '1h'),
1294+
UsageQuery::groupBy('service'),
1295+
UsageQuery::groupBy('path'),
1296+
Query::equal('metric', ['gb-multi']),
1297+
Query::greaterThanEqual('time', $start),
1298+
Query::lessThanEqual('time', $end),
1299+
], Usage::TYPE_EVENT);
1300+
1301+
$this->assertGreaterThanOrEqual(3, count($results));
1302+
1303+
$byPair = [];
1304+
foreach ($results as $row) {
1305+
$svc = $row->getService();
1306+
$path = $row->getPath();
1307+
$this->assertNotNull($svc);
1308+
$this->assertNotNull($path);
1309+
$byPair["{$svc}|{$path}"] = ($byPair["{$svc}|{$path}"] ?? 0) + $row->getValue();
1310+
}
1311+
1312+
$this->assertEquals(3, $byPair['storage|/v1/a'] ?? null);
1313+
$this->assertEquals(4, $byPair['storage|/v1/b'] ?? null);
1314+
$this->assertEquals(8, $byPair['databases|/v1/a'] ?? null);
1315+
}
1316+
12441317
public function testNotEqualQuery(): void
12451318
{
12461319
// Fixture: requests x2, bandwidth x1 in events

0 commit comments

Comments
 (0)