Skip to content

Commit 62d2e3e

Browse files
committed
GROOVY-11915: GINQ: Add groupby...into with first-class GroupResult type
1 parent 77e8752 commit 62d2e3e

11 files changed

Lines changed: 406 additions & 2 deletions

File tree

subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/GinqAstBuilder.java

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,12 @@ public void visitMethodCallExpression(MethodCallExpression call) {
231231

232232
if (latestGinqExpressionClause instanceof JoinExpression && filterExpression instanceof OnExpression) {
233233
((JoinExpression) latestGinqExpressionClause).setOnExpression((OnExpression) filterExpression);
234+
} else if (latestGinqExpressionClause instanceof GroupExpression && filterExpression instanceof WhereExpression
235+
&& ((GroupExpression) latestGinqExpressionClause).getIntoAlias() != null) {
236+
this.collectSyntaxError(new GinqSyntaxError(
237+
"`where` after `groupby...into` is not yet supported; use `having` instead",
238+
call.getLineNumber(), call.getColumnNumber()
239+
));
234240
} else if (latestGinqExpressionClause instanceof DataSourceHolder && filterExpression instanceof WhereExpression) {
235241
if (null != currentGinqExpression.getGroupExpression() || null != currentGinqExpression.getOrderExpression() || null != currentGinqExpression.getLimitExpression()) {
236242
this.collectSyntaxError(new GinqSyntaxError(
@@ -297,6 +303,27 @@ public void visitMethodCallExpression(MethodCallExpression call) {
297303
return;
298304
}
299305

306+
if (KW_INTO.equals(methodName)) {
307+
if (!(latestGinqExpressionClause instanceof GroupExpression)) {
308+
this.collectSyntaxError(new GinqSyntaxError(
309+
"`into` is only supported after `groupby`",
310+
call.getLineNumber(), call.getColumnNumber()
311+
));
312+
return;
313+
}
314+
ArgumentListExpression arguments = (ArgumentListExpression) call.getArguments();
315+
if (arguments.getExpressions().size() != 1 || !(arguments.getExpression(0) instanceof VariableExpression)) {
316+
this.collectSyntaxError(new GinqSyntaxError(
317+
"`into` requires a single alias name, e.g. `groupby x into g`",
318+
call.getLineNumber(), call.getColumnNumber()
319+
));
320+
return;
321+
}
322+
String aliasName = ((VariableExpression) arguments.getExpression(0)).getName();
323+
((GroupExpression) latestGinqExpressionClause).setIntoAlias(aliasName);
324+
return;
325+
}
326+
300327
if (KW_ORDERBY.equals(methodName) && !visitingOverClause) {
301328
OrderExpression orderExpression = new OrderExpression(call.getArguments());
302329
orderExpression.setSourcePosition(call.getMethod());
@@ -491,12 +518,13 @@ public SourceUnit getSourceUnit() {
491518
private static final String KW_WITHINGROUP = "withingroup"; // reserved keyword
492519
private static final String KW_OVER = "over";
493520
private static final String KW_AS = "as";
521+
private static final String KW_INTO = "into";
494522
private static final String KW_SHUTDOWN = "shutdown";
495523
private static final Set<String> KEYWORD_SET;
496524
static {
497525
Set<String> keywordSet = new HashSet<>();
498526
keywordSet.addAll(Arrays.asList(KW_WITH, KW_FROM, KW_IN, KW_ON, KW_WHERE, KW_EXISTS, KW_GROUPBY, KW_HAVING, KW_ORDERBY,
499-
KW_LIMIT, KW_OFFSET, KW_SELECT, KW_DISTINCT, KW_WITHINGROUP, KW_OVER, KW_AS, KW_SHUTDOWN));
527+
KW_LIMIT, KW_OFFSET, KW_SELECT, KW_DISTINCT, KW_WITHINGROUP, KW_OVER, KW_AS, KW_INTO, KW_SHUTDOWN));
500528
keywordSet.addAll(JoinExpression.JOIN_NAME_LIST);
501529
KEYWORD_SET = Collections.unmodifiableSet(keywordSet);
502530
}

subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/expression/GroupExpression.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
public class GroupExpression extends ProcessExpression {
3030
private final Expression classifierExpr;
3131
private HavingExpression havingExpression;
32+
private String intoAlias;
3233

3334
public GroupExpression(Expression classifierExpr) {
3435
this.classifierExpr = classifierExpr;
@@ -51,9 +52,18 @@ public void setHavingExpression(HavingExpression havingExpression) {
5152
this.havingExpression = havingExpression;
5253
}
5354

55+
public String getIntoAlias() {
56+
return intoAlias;
57+
}
58+
59+
public void setIntoAlias(String intoAlias) {
60+
this.intoAlias = intoAlias;
61+
}
62+
5463
@Override
5564
public String getText() {
5665
return "groupby " + classifierExpr.getText() +
66+
(null == intoAlias ? "" : " into " + intoAlias) +
5767
(null == havingExpression ? "" : " " + havingExpression.getText());
5868
}
5969

subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/provider/collection/GinqAstWalker.groovy

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,24 @@ class GinqAstWalker implements GinqAstVisitor<Expression>, SyntaxErrorReportable
588588

589589
getCurrentGinqExpression().putNodeMetaData(__GROUPBY_VISITED, true)
590590

591+
String intoAlias = groupExpression.intoAlias
592+
if (intoAlias) {
593+
getCurrentGinqExpression().putNodeMetaData(__GROUPBY_INTO_ALIAS, intoAlias)
594+
595+
HavingExpression havingExpression = groupExpression.havingExpression
596+
if (havingExpression) {
597+
// In into-mode, the having lambda parameter is the alias (a GroupResult)
598+
def havingLambda = lambdaX(
599+
params(param(dynamicType(), intoAlias)),
600+
stmt(havingExpression.filterExpr))
601+
argList << havingLambda
602+
}
603+
604+
MethodCallExpression groupMethodCallExpression = callX(groupMethodCallReceiver, "groupByInto", args(argList))
605+
groupMethodCallExpression.setSourcePosition(groupExpression)
606+
return groupMethodCallExpression
607+
}
608+
591609
HavingExpression havingExpression = groupExpression.havingExpression
592610
if (havingExpression) {
593611
Expression filterExpr = havingExpression.filterExpr
@@ -1015,6 +1033,9 @@ class GinqAstWalker implements GinqAstVisitor<Expression>, SyntaxErrorReportable
10151033
}
10161034

10171035
private void validateGroupCols(List<Expression> expressionList) {
1036+
if (groupByIntoAlias) {
1037+
return // In into-mode, access is through the alias; validation handled by the type system
1038+
}
10181039
if (groupByVisited) {
10191040
for (Expression expression : expressionList) {
10201041
new ListExpression(Collections.singletonList(expression)).transformExpression(new ExpressionTransformer() {
@@ -1451,6 +1472,11 @@ class GinqAstWalker implements GinqAstVisitor<Expression>, SyntaxErrorReportable
14511472

14521473
private String getLambdaParamName(DataSourceExpression dataSourceExpression, Expression lambdaCode) {
14531474
boolean groupByVisited = isGroupByVisited()
1475+
String intoAlias = groupByIntoAlias
1476+
if (groupByVisited && intoAlias) {
1477+
lambdaCode.putNodeMetaData(__LAMBDA_PARAM_NAME, intoAlias)
1478+
return intoAlias
1479+
}
14541480
String lambdaParamName
14551481
if (dataSourceExpression instanceof JoinExpression || groupByVisited || visitingWindowFunction) {
14561482
lambdaParamName = lambdaCode.getNodeMetaData(__LAMBDA_PARAM_NAME)
@@ -1466,8 +1492,16 @@ class GinqAstWalker implements GinqAstVisitor<Expression>, SyntaxErrorReportable
14661492

14671493
private Tuple3<String, List<DeclarationExpression>, Expression> correctVariablesOfLambdaExpression(DataSourceExpression dataSourceExpression, Expression lambdaCode) {
14681494
boolean groupByVisited = isGroupByVisited()
1495+
String intoAlias = groupByIntoAlias
14691496
List<DeclarationExpression> declarationExpressionList = Collections.emptyList()
14701497
String lambdaParamName = getLambdaParamName(dataSourceExpression, lambdaCode)
1498+
1499+
// In into-mode, the lambda parameter IS the alias (a GroupResult).
1500+
// No variable rewriting or __sourceRecord/__group injection needed.
1501+
if (groupByVisited && intoAlias) {
1502+
return tuple(lambdaParamName, declarationExpressionList, lambdaCode)
1503+
}
1504+
14711505
if (dataSourceExpression instanceof JoinExpression || groupByVisited) {
14721506
Tuple2<List<DeclarationExpression>, Expression> declarationAndLambdaCode = correctVariablesOfGinqExpression(dataSourceExpression, lambdaCode)
14731507
if (!visitingAggregateFunctionStack) {
@@ -1516,6 +1550,10 @@ class GinqAstWalker implements GinqAstVisitor<Expression>, SyntaxErrorReportable
15161550
return currentGinqExpression.getNodeMetaData(__GROUPBY_VISITED)
15171551
}
15181552

1553+
private String getGroupByIntoAlias() {
1554+
return (String) currentGinqExpression.getNodeMetaData(__GROUPBY_INTO_ALIAS)
1555+
}
1556+
15191557
private boolean isVisitingSelect() {
15201558
currentGinqExpression.getNodeMetaData(__VISITING_SELECT)
15211559
}
@@ -1613,6 +1651,7 @@ class GinqAstWalker implements GinqAstVisitor<Expression>, SyntaxErrorReportable
16131651
private static final String __SUPPLY_ASYNC_LAMBDA_PARAM_NAME_PREFIX = "__salp_"
16141652
private static final String __SOURCE_RECORD = "__sourceRecord"
16151653
private static final String __GROUP = "__group"
1654+
private static final String __GROUPBY_INTO_ALIAS = "__GROUPBY_INTO_ALIAS"
16161655
private static final String MD_GROUP_NAME_LIST = "groupNameList"
16171656
private static final String MD_SELECT_NAME_LIST = "selectNameList"
16181657
private static final String MD_ALIAS_NAME_LIST = 'aliasNameList'
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.groovy.ginq.provider.collection.runtime;
20+
21+
import groovy.transform.Internal;
22+
23+
/**
24+
* Represents a group result from a {@code groupby...into} clause.
25+
* Extends {@link Queryable} to provide aggregate and query methods
26+
* on the group's elements, with a {@code key} property for accessing
27+
* the group key.
28+
*
29+
* @param <K> the type of the group key
30+
* @param <T> the type of the grouped elements
31+
* @since 6.0.0
32+
*/
33+
@Internal
34+
public interface GroupResult<K, T> extends Queryable<T> {
35+
36+
/**
37+
* Returns the group key.
38+
* For single-key groupby, this is the raw key value.
39+
* For multi-key groupby, this is a {@link NamedRecord} with named access.
40+
*
41+
* @return the group key
42+
*/
43+
K getKey();
44+
45+
/**
46+
* Returns a named component of a multi-key group key.
47+
*
48+
* @param name the key component name (from {@code as} alias in groupby)
49+
* @return the value of the named key component
50+
* @throws UnsupportedOperationException if this is a single-key group
51+
*/
52+
Object key(String name);
53+
54+
/**
55+
* Factory method to create a {@link GroupResult} instance.
56+
*
57+
* @param key the group key
58+
* @param group the grouped elements as a Queryable
59+
* @param <K> the type of the group key
60+
* @param <T> the type of the grouped elements
61+
* @return a new GroupResult
62+
*/
63+
static <K, T> GroupResult<K, T> of(K key, Queryable<T> group) {
64+
return new GroupResultImpl<>(key, group);
65+
}
66+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.groovy.ginq.provider.collection.runtime;
20+
21+
import java.io.Serial;
22+
23+
/**
24+
* Default implementation of {@link GroupResult}.
25+
*
26+
* @param <K> the type of the group key
27+
* @param <T> the type of the grouped elements
28+
* @since 6.0.0
29+
*/
30+
class GroupResultImpl<K, T> extends QueryableCollection<T> implements GroupResult<K, T> {
31+
@Serial private static final long serialVersionUID = -4637595210702145661L;
32+
33+
private final K key;
34+
35+
GroupResultImpl(K key, Queryable<T> group) {
36+
super(group.toList());
37+
this.key = key;
38+
}
39+
40+
@SuppressWarnings("unchecked")
41+
@Override
42+
public K getKey() {
43+
// For single-key groupby, the classifier wraps the key in a NamedRecord;
44+
// unwrap it so g.key returns the raw value rather than a single-element tuple
45+
if (key instanceof NamedRecord && ((NamedRecord<?, ?>) key).size() == 1) {
46+
return (K) ((NamedRecord<?, ?>) key).get(0);
47+
}
48+
return key;
49+
}
50+
51+
@Override
52+
public Object key(String name) {
53+
if (key instanceof NamedRecord) {
54+
return ((NamedRecord<?, ?>) key).get(name);
55+
}
56+
throw new UnsupportedOperationException(
57+
"key(String) is only supported for multi-key groupby. Use getKey() for single-key.");
58+
}
59+
}

subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/provider/collection/runtime/Queryable.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,30 @@ default Queryable<Tuple2<?, Queryable<T>>> groupBy(Function<? super T, ?> classi
245245
return groupBy(classifier, null);
246246
}
247247

248+
/**
249+
* Group by {@link Queryable} instance, returning {@link GroupResult} instances
250+
* for use with the {@code groupby...into} syntax.
251+
*
252+
* @param classifier the classifier for group by
253+
* @param having the filter condition (may be null)
254+
* @param <K> the type of the group key
255+
* @return the result of group by as GroupResult instances
256+
* @since 6.0.0
257+
*/
258+
<K> Queryable<GroupResult<K, T>> groupByInto(Function<? super T, ? extends K> classifier, Predicate<? super GroupResult<K, T>> having);
259+
260+
/**
261+
* Group by {@link Queryable} instance without {@code having} clause, returning {@link GroupResult} instances.
262+
*
263+
* @param classifier the classifier for group by
264+
* @param <K> the type of the group key
265+
* @return the result of group by as GroupResult instances
266+
* @since 6.0.0
267+
*/
268+
default <K> Queryable<GroupResult<K, T>> groupByInto(Function<? super T, ? extends K> classifier) {
269+
return groupByInto(classifier, null);
270+
}
271+
248272
/**
249273
* Sort {@link Queryable} instance, similar to SQL's {@code order by}
250274
*

subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/provider/collection/runtime/QueryableCollection.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,24 @@ public Queryable<Tuple2<?, Queryable<T>>> groupBy(Function<? super T, ?> classif
262262
return Group.of(stream);
263263
}
264264

265+
@Override
266+
public <K> Queryable<GroupResult<K, T>> groupByInto(Function<? super T, ? extends K> classifier, Predicate<? super GroupResult<K, T>> having) {
267+
Collector<T, ?, ? extends Map<K, List<T>>> groupingBy =
268+
isParallel() ? Collectors.groupingByConcurrent(classifier, Collectors.toList())
269+
: Collectors.groupingBy(classifier, Collectors.toList());
270+
271+
// Materialize group elements as lists so they can be iterated multiple times
272+
// (e.g., having g.count() > 1 followed by select g.count())
273+
Stream<GroupResult<K, T>> stream =
274+
this.stream()
275+
.collect(groupingBy)
276+
.entrySet().stream()
277+
.map(m -> GroupResult.<K, T>of(m.getKey(), from(m.getValue())))
278+
.filter(gr -> null == having || having.test(gr));
279+
280+
return from(stream);
281+
}
282+
265283
@SafeVarargs
266284
@Override
267285
public final <U extends Comparable<? super U>> Queryable<T> orderBy(Order<? super T, ? extends U>... orders) {

0 commit comments

Comments
 (0)