@@ -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
0 commit comments