Skip to content

Commit 11fb577

Browse files
Merge pull request #7 from utopia-php/feat/usage-groupby-no-interval
feat(adapter): allow groupBy without groupByInterval (dim-only aggregate)
2 parents 07b44b9 + 01306f3 commit 11fb577

3 files changed

Lines changed: 60 additions & 52 deletions

File tree

src/Usage/Adapter/ClickHouse.php

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1588,12 +1588,9 @@ private function findFromTable(array $queries, string $type): array
15881588
throw new Exception('Cursor pagination cannot be combined with groupByInterval');
15891589
}
15901590

1591-
if (!empty($parsed['groupBy']) && !isset($parsed['groupByInterval'])) {
1592-
throw new Exception('groupBy requires groupByInterval to be specified');
1593-
}
1594-
1595-
// Check if groupByInterval is requested
1596-
if (isset($parsed['groupByInterval'])) {
1591+
// Route through the aggregated path whenever any aggregation
1592+
// hint is present — time bucketing, dimension breakdown, or both.
1593+
if (isset($parsed['groupByInterval']) || !empty($parsed['groupBy'])) {
15971594
return $this->findAggregatedFromTable($parsed, $fromTable, $type);
15981595
}
15991596

@@ -1662,18 +1659,23 @@ private function findFromTable(array $queries, string $type): array
16621659
*/
16631660
private function findAggregatedFromTable(array $parsed, string $fromTable, string $type): array
16641661
{
1665-
/** @var string $interval */
1666-
$interval = $parsed['groupByInterval'] ?? '1h';
1667-
$intervalSql = UsageQuery::VALID_INTERVALS[$interval];
1662+
$hasInterval = isset($parsed['groupByInterval']);
16681663

16691664
// Choose aggregation function based on metric type
16701665
$valueExpr = $type === Usage::TYPE_GAUGE
16711666
? 'argMax(value, time) as value'
16721667
: 'SUM(value) as value';
16731668

1674-
// Use 'bucket' alias to avoid collision with the raw 'time' column,
1675-
// then alias back to 'time' in outer context for consistent Metric parsing.
1676-
$timeBucketExpr = "toStartOfInterval(time, {$intervalSql})";
1669+
// Bucket column is only emitted when time bucketing is requested.
1670+
// Without it the result is a flat aggregate per (metric, …dims).
1671+
$bucketSelect = '';
1672+
$bucketGroup = '';
1673+
if ($hasInterval) {
1674+
$interval = $parsed['groupByInterval'];
1675+
$intervalSql = UsageQuery::VALID_INTERVALS[$interval];
1676+
$bucketSelect = ", toStartOfInterval(time, {$intervalSql}) as bucket";
1677+
$bucketGroup = ', bucket';
1678+
}
16771679

16781680
$groupByDims = $parsed['groupBy'] ?? [];
16791681
$dimSelect = '';
@@ -1691,31 +1693,46 @@ private function findAggregatedFromTable(array $parsed, string $fromTable, strin
16911693
$whereClause = $whereData['clause'];
16921694
$params = $whereData['params'];
16931695

1694-
// Use custom ORDER BY if specified, otherwise default to bucket ASC.
1695-
// In aggregated mode the SELECT exposes `bucket` instead of `time`,
1696-
// so any user-supplied ORDER BY on `time` must be rewritten to
1697-
// reference the bucket alias — otherwise ClickHouse errors with
1698-
// "Unknown identifier: time".
1699-
$orderClause = ' ORDER BY bucket ASC';
1700-
if (!empty($parsed['orderBy'])) {
1701-
$rewrittenOrderBy = array_map(
1702-
fn (string $clause): string => preg_replace(
1703-
'/^`time`(\s+(?:ASC|DESC))?$/',
1704-
'`bucket`$1',
1705-
$clause
1706-
) ?? $clause,
1707-
$parsed['orderBy']
1708-
);
1709-
$orderClause = ' ORDER BY ' . implode(', ', $rewrittenOrderBy);
1696+
// Default ORDER BY:
1697+
// - With time bucketing: bucket ASC (chronological time series).
1698+
// - Dim-only: value DESC (top-N table semantics).
1699+
// For caller-supplied ORDER BY, `time` is rewritten to `bucket`
1700+
// only when bucket is present; otherwise sorting by time is
1701+
// invalid (the column is no longer in the SELECT after GROUP BY).
1702+
if ($hasInterval) {
1703+
$orderClause = ' ORDER BY bucket ASC';
1704+
if (!empty($parsed['orderBy'])) {
1705+
$rewrittenOrderBy = array_map(
1706+
fn (string $clause): string => preg_replace(
1707+
'/^`time`(\s+(?:ASC|DESC))?$/',
1708+
'`bucket`$1',
1709+
$clause
1710+
) ?? $clause,
1711+
$parsed['orderBy']
1712+
);
1713+
$orderClause = ' ORDER BY ' . implode(', ', $rewrittenOrderBy);
1714+
}
1715+
} else {
1716+
$orderClause = ' ORDER BY value DESC';
1717+
if (!empty($parsed['orderBy'])) {
1718+
foreach ($parsed['orderBy'] as $clause) {
1719+
if (preg_match('/^`time`/', $clause)) {
1720+
throw new Exception(
1721+
'orderBy("time") requires groupByInterval — without time bucketing the result has no time column'
1722+
);
1723+
}
1724+
}
1725+
$orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']);
1726+
}
17101727
}
17111728

17121729
$limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : '';
17131730
$offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : '';
17141731

17151732
$sql = "
1716-
SELECT metric, {$valueExpr}, {$timeBucketExpr} as bucket{$dimSelect}
1733+
SELECT metric, {$valueExpr}{$bucketSelect}{$dimSelect}
17171734
FROM {$fromTable}{$whereClause}
1718-
GROUP BY metric, bucket{$dimGroup}{$orderClause}{$limitClause}{$offsetClause}
1735+
GROUP BY metric{$bucketGroup}{$dimGroup}{$orderClause}{$limitClause}{$offsetClause}
17191736
FORMAT JSON
17201737
";
17211738

src/Usage/Adapter/Database.php

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -495,31 +495,20 @@ private function convertQueriesToDatabase(array $queries): array
495495
* Mirrors the ClickHouse adapter contract: groupBy attributes must exist on
496496
* the matching schema (event vs gauge — we default to the broader event set
497497
* for the Database adapter since both share one collection), and groupBy
498-
* must always be paired with groupByInterval so the cloud-facing API stays
499-
* consistent across backends.
498+
* does not push the aggregation hints down to SQL — it just validates them.
500499
*
501500
* @param array<Query> $queries
502501
* @throws \Exception
503502
*/
504503
private function validateGroupByQueries(array $queries): void
505504
{
506-
$hasGroupBy = false;
507-
$hasGroupByInterval = false;
508505
$allowed = array_unique(array_merge(Metric::EVENT_COLUMNS, Metric::GAUGE_COLUMNS));
509506

510507
foreach ($queries as $query) {
511-
$method = $query->getMethod();
512-
513-
if ($method === UsageQuery::TYPE_GROUP_BY_INTERVAL) {
514-
$hasGroupByInterval = true;
508+
if ($query->getMethod() !== UsageQuery::TYPE_GROUP_BY) {
515509
continue;
516510
}
517511

518-
if ($method !== UsageQuery::TYPE_GROUP_BY) {
519-
continue;
520-
}
521-
522-
$hasGroupBy = true;
523512
$attribute = $query->getAttribute();
524513

525514
if (!in_array($attribute, $allowed, true)) {
@@ -528,10 +517,6 @@ private function validateGroupByQueries(array $queries): void
528517
);
529518
}
530519
}
531-
532-
if ($hasGroupBy && !$hasGroupByInterval) {
533-
throw new \Exception('groupBy requires groupByInterval to be specified');
534-
}
535520
}
536521

537522
/**

tests/Usage/UsageBase.php

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -652,14 +652,20 @@ public function testGroupByUnknownAttributeThrows(): void
652652
], Usage::TYPE_EVENT);
653653
}
654654

655-
public function testGroupByWithoutGroupByIntervalThrows(): void
655+
public function testGroupByWithoutGroupByIntervalReturnsDimOnlyAggregate(): void
656656
{
657-
$this->expectException(\Exception::class);
658-
$this->expectExceptionMessageMatches('/groupBy requires groupByInterval/');
659-
660-
$this->usage->find([
657+
// Without groupByInterval the result is a flat aggregate per
658+
// (metric, …dims) — no time bucketing, ordered by value DESC by
659+
// default (top-N table semantics).
660+
$rows = $this->usage->find([
661661
UsageQuery::groupBy('service'),
662662
Query::equal('metric', ['gbi-requests']),
663663
], Usage::TYPE_EVENT);
664+
665+
$this->assertIsArray($rows);
666+
foreach ($rows as $row) {
667+
$this->assertArrayNotHasKey('time', $row->getArrayCopy());
668+
$this->assertArrayHasKey('service', $row->getArrayCopy());
669+
}
664670
}
665671
}

0 commit comments

Comments
 (0)