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