This is an automated email from the ASF dual-hosted git repository.
paulk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new 28e473f0f6 GROOVY-11919: Provide set operators for GINQ, e.g. union,
intersect
28e473f0f6 is described below
commit 28e473f0f6f403d935c63f24b9c5b68d485af632
Author: Paul King <[email protected]>
AuthorDate: Sat Apr 11 19:18:43 2026 +1000
GROOVY-11919: Provide set operators for GINQ, e.g. union, intersect
---
.../apache/groovy/ginq/dsl/GinqAstBaseVisitor.java | 8 ++
.../org/apache/groovy/ginq/dsl/GinqAstBuilder.java | 65 +++++++++++++--
.../org/apache/groovy/ginq/dsl/GinqAstVisitor.java | 2 +
.../dsl/expression/SetOperationExpression.java | 66 +++++++++++++++
.../ginq/provider/collection/GinqAstWalker.groovy | 18 +++++
.../groovy-ginq/src/spec/doc/ginq-userguide.adoc | 76 ++++++++++++-----
.../test/org/apache/groovy/ginq/GinqTest.groovy | 94 ++++++++++++++++++++++
7 files changed, 301 insertions(+), 28 deletions(-)
diff --git
a/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/GinqAstBaseVisitor.java
b/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/GinqAstBaseVisitor.java
index 4f5ab27b65..2cad321b45 100644
---
a/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/GinqAstBaseVisitor.java
+++
b/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/GinqAstBaseVisitor.java
@@ -28,6 +28,7 @@ import org.apache.groovy.ginq.dsl.expression.LimitExpression;
import org.apache.groovy.ginq.dsl.expression.OnExpression;
import org.apache.groovy.ginq.dsl.expression.OrderExpression;
import org.apache.groovy.ginq.dsl.expression.SelectExpression;
+import org.apache.groovy.ginq.dsl.expression.SetOperationExpression;
import org.apache.groovy.ginq.dsl.expression.ShutdownExpression;
import org.apache.groovy.ginq.dsl.expression.WhereExpression;
import org.codehaus.groovy.ast.CodeVisitorSupport;
@@ -128,6 +129,13 @@ public class GinqAstBaseVisitor extends CodeVisitorSupport
implements GinqAstVis
return null;
}
+ @Override
+ public Void visitSetOperationExpression(SetOperationExpression
setOperationExpression) {
+ setOperationExpression.getLeft().accept(this);
+ visit(setOperationExpression.getRight());
+ return null;
+ }
+
@Override
public Void visitShutdownExpression(ShutdownExpression shutdownExpression)
{
visit(shutdownExpression.getExpr());
diff --git
a/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/GinqAstBuilder.java
b/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/GinqAstBuilder.java
index 74cfdf4301..a56feebf4b 100644
---
a/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/GinqAstBuilder.java
+++
b/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/GinqAstBuilder.java
@@ -31,6 +31,7 @@ import org.apache.groovy.ginq.dsl.expression.LimitExpression;
import org.apache.groovy.ginq.dsl.expression.OnExpression;
import org.apache.groovy.ginq.dsl.expression.OrderExpression;
import org.apache.groovy.ginq.dsl.expression.SelectExpression;
+import org.apache.groovy.ginq.dsl.expression.SetOperationExpression;
import org.apache.groovy.ginq.dsl.expression.ShutdownExpression;
import org.apache.groovy.ginq.dsl.expression.WhereExpression;
import org.codehaus.groovy.ast.ASTNode;
@@ -77,6 +78,8 @@ public class GinqAstBuilder extends CodeVisitorSupport
implements SyntaxErrorRep
}
private final List<MethodCallExpression> ignoredMethodCallExpressionList =
new ArrayList<>();
+ private AbstractGinqExpression setOperationLeft;
+ private String setOperationOp;
private static final List<String> SHUTDOWN_OPTION_LIST =
Arrays.asList("immediate", "abort");
public AbstractGinqExpression buildAST(ASTNode astNode) {
@@ -110,20 +113,39 @@ public class GinqAstBuilder extends CodeVisitorSupport
implements SyntaxErrorRep
return getGinqExpression(astNode);
}
- private GinqExpression getGinqExpression(ASTNode astNode) {
+ private AbstractGinqExpression getGinqExpression(ASTNode astNode) {
if (null == latestGinqExpression) {
ASTNode node = ginqExpressionStack.isEmpty() ? astNode :
ginqExpressionStack.peek();
- this.collectSyntaxError(new GinqSyntaxError("`select` clause is
missing",
- node.getLineNumber(), node.getColumnNumber()));
+ if (setOperationLeft != null) {
+ this.collectSyntaxError(new GinqSyntaxError(
+ "Right-hand query is missing after `" + setOperationOp
+ "`",
+ node.getLineNumber(), node.getColumnNumber()));
+ } else {
+ this.collectSyntaxError(new GinqSyntaxError("`select` clause
is missing",
+ node.getLineNumber(), node.getColumnNumber()));
+ }
+ return setOperationLeft != null ? setOperationLeft : new
GinqExpression();
}
- latestGinqExpression.visit(new GinqAstBaseVisitor() {
+ AbstractGinqExpression result;
+ if (setOperationLeft != null) {
+ latestGinqExpression.putNodeMetaData(ROOT_GINQ_EXPRESSION,
latestGinqExpression);
+ SetOperationExpression setOpExpr = new
SetOperationExpression(setOperationLeft, setOperationOp, latestGinqExpression);
+ setOpExpr.setSourcePosition(setOperationLeft);
+ result = setOpExpr;
+ } else {
+ latestGinqExpression.putNodeMetaData(ROOT_GINQ_EXPRESSION,
latestGinqExpression);
+ result = latestGinqExpression;
+ }
+
+ GinqAstBaseVisitor cleanupVisitor = new GinqAstBaseVisitor() {
@Override
public void visitMethodCallExpression(MethodCallExpression call) {
ignoredMethodCallExpressionList.remove(call);
super.visitMethodCallExpression(call);
}
- });
+ };
+ result.accept(cleanupVisitor);
if (!ignoredMethodCallExpressionList.isEmpty()) {
MethodCallExpression methodCallExpression =
ignoredMethodCallExpressionList.get(0);
@@ -131,9 +153,7 @@ public class GinqAstBuilder extends CodeVisitorSupport
implements SyntaxErrorRep
methodCallExpression.getLineNumber(),
methodCallExpression.getColumnNumber()));
}
- latestGinqExpression.putNodeMetaData(ROOT_GINQ_EXPRESSION,
latestGinqExpression);
-
- return latestGinqExpression;
+ return result;
}
private void setLatestGinqExpressionClause(AbstractGinqExpression
ginqExpressionClause) {
@@ -420,6 +440,28 @@ public class GinqAstBuilder extends CodeVisitorSupport
implements SyntaxErrorRep
@Override
public void visitVariableExpression(VariableExpression expression) {
+ if (SET_OP_SET.contains(expression.getText())) {
+ if (latestGinqExpression == null) {
+ this.collectSyntaxError(new GinqSyntaxError(
+ "`" + expression.getText() + "` must follow a complete
GINQ expression (from...select)",
+ expression.getLineNumber(),
expression.getColumnNumber()));
+ } else {
+ if (setOperationLeft != null) {
+ // chaining: wrap previous left + op + current right into
a SetOperationExpression
+ latestGinqExpression.putNodeMetaData(ROOT_GINQ_EXPRESSION,
latestGinqExpression);
+ SetOperationExpression prev = new
SetOperationExpression(setOperationLeft, setOperationOp, latestGinqExpression);
+ prev.setSourcePosition(setOperationLeft);
+ setOperationLeft = prev;
+ } else {
+ latestGinqExpression.putNodeMetaData(ROOT_GINQ_EXPRESSION,
latestGinqExpression);
+ setOperationLeft = latestGinqExpression;
+ }
+ setOperationOp = expression.getText();
+ latestGinqExpression = null;
+ }
+ return;
+ }
+
if (KEYWORD_SET.contains(expression.getText())) {
this.collectSyntaxError(
new GinqSyntaxError(
@@ -519,12 +561,19 @@ public class GinqAstBuilder extends CodeVisitorSupport
implements SyntaxErrorRep
private static final String KW_OVER = "over";
private static final String KW_AS = "as";
private static final String KW_INTO = "into";
+ private static final String KW_UNION = "union";
+ private static final String KW_UNIONALL = "unionall";
+ private static final String KW_INTERSECT = "intersect";
+ private static final String KW_MINUS = "minus";
private static final String KW_SHUTDOWN = "shutdown";
private static final Set<String> KEYWORD_SET;
+ private static final Set<String> SET_OP_SET = Collections.unmodifiableSet(
+ new HashSet<>(Arrays.asList(KW_UNION, KW_UNIONALL, KW_INTERSECT,
KW_MINUS)));
static {
Set<String> keywordSet = new HashSet<>();
keywordSet.addAll(Arrays.asList(KW_WITH, KW_FROM, KW_IN, KW_ON,
KW_WHERE, KW_EXISTS, KW_GROUPBY, KW_HAVING, KW_ORDERBY,
KW_LIMIT, KW_OFFSET, KW_SELECT,
KW_DISTINCT, KW_WITHINGROUP, KW_OVER, KW_AS, KW_INTO, KW_SHUTDOWN));
+ keywordSet.addAll(SET_OP_SET);
keywordSet.addAll(JoinExpression.JOIN_NAME_LIST);
KEYWORD_SET = Collections.unmodifiableSet(keywordSet);
}
diff --git
a/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/GinqAstVisitor.java
b/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/GinqAstVisitor.java
index 24dccec980..fbbb52d639 100644
---
a/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/GinqAstVisitor.java
+++
b/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/GinqAstVisitor.java
@@ -28,6 +28,7 @@ import org.apache.groovy.ginq.dsl.expression.LimitExpression;
import org.apache.groovy.ginq.dsl.expression.OnExpression;
import org.apache.groovy.ginq.dsl.expression.OrderExpression;
import org.apache.groovy.ginq.dsl.expression.SelectExpression;
+import org.apache.groovy.ginq.dsl.expression.SetOperationExpression;
import org.apache.groovy.ginq.dsl.expression.ShutdownExpression;
import org.apache.groovy.ginq.dsl.expression.WhereExpression;
@@ -51,6 +52,7 @@ public interface GinqAstVisitor<R> {
R visitOrderExpression(OrderExpression orderExpression);
R visitLimitExpression(LimitExpression limitExpression);
R visitSelectExpression(SelectExpression selectExpression);
+ R visitSetOperationExpression(SetOperationExpression
setOperationExpression);
R visitShutdownExpression(ShutdownExpression shutdownExpression);
R visit(AbstractGinqExpression expression);
default void setConfiguration(Map<String, String> configuration) {}
diff --git
a/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/expression/SetOperationExpression.java
b/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/expression/SetOperationExpression.java
new file mode 100644
index 0000000000..76bb5e96a9
--- /dev/null
+++
b/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/dsl/expression/SetOperationExpression.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.ginq.dsl.expression;
+
+import org.apache.groovy.ginq.dsl.GinqAstVisitor;
+
+/**
+ * Represents a set operation (union, unionall, intersect, minus) combining
two GINQ expressions.
+ *
+ * @since 6.0.0
+ */
+public class SetOperationExpression extends AbstractGinqExpression {
+
+ private final AbstractGinqExpression left;
+ private final String operation;
+ private final GinqExpression right;
+
+ public SetOperationExpression(AbstractGinqExpression left, String
operation, GinqExpression right) {
+ this.left = left;
+ this.operation = operation;
+ this.right = right;
+ }
+
+ public AbstractGinqExpression getLeft() {
+ return left;
+ }
+
+ public String getOperation() {
+ return operation;
+ }
+
+ public GinqExpression getRight() {
+ return right;
+ }
+
+ @Override
+ public <R> R accept(GinqAstVisitor<R> visitor) {
+ return visitor.visitSetOperationExpression(this);
+ }
+
+ @Override
+ public String getText() {
+ return left.getText() + " " + operation + " " + right.getText();
+ }
+
+ @Override
+ public String toString() {
+ return getText();
+ }
+}
diff --git
a/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/provider/collection/GinqAstWalker.groovy
b/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/provider/collection/GinqAstWalker.groovy
index 3541bd7550..9d62d13f11 100644
---
a/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/provider/collection/GinqAstWalker.groovy
+++
b/subprojects/groovy-ginq/src/main/groovy/org/apache/groovy/ginq/provider/collection/GinqAstWalker.groovy
@@ -36,6 +36,7 @@ import org.apache.groovy.ginq.dsl.expression.LimitExpression
import org.apache.groovy.ginq.dsl.expression.OnExpression
import org.apache.groovy.ginq.dsl.expression.OrderExpression
import org.apache.groovy.ginq.dsl.expression.SelectExpression
+import org.apache.groovy.ginq.dsl.expression.SetOperationExpression
import org.apache.groovy.ginq.dsl.expression.ShutdownExpression
import org.apache.groovy.ginq.dsl.expression.WhereExpression
import org.apache.groovy.ginq.provider.collection.runtime.NamedRecord
@@ -870,6 +871,23 @@ class GinqAstWalker implements GinqAstVisitor<Expression>,
SyntaxErrorReportable
return selectMethodCallExpression
}
+ @Override
+ Expression visitSetOperationExpression(SetOperationExpression
setOperationExpression) {
+ Expression leftExpr = visit(setOperationExpression.left)
+ Expression rightExpr =
visitGinqExpression(setOperationExpression.right)
+
+ String methodName
+ switch (setOperationExpression.operation) {
+ case 'union': methodName = 'union'; break
+ case 'unionall': methodName = 'unionAll'; break
+ case 'intersect': methodName = 'intersect'; break
+ case 'minus': methodName = 'minus'; break
+ default: throw new GroovyBugError("Unknown set operation:
${setOperationExpression.operation}")
+ }
+
+ return callX(leftExpr, methodName, args(rightExpr))
+ }
+
@Override
Expression visitShutdownExpression(ShutdownExpression shutdownExpression) {
return callX(classX(makeCached(QueryableHelper)), 'shutdown',
constX(shutdownExpression.mode))
diff --git a/subprojects/groovy-ginq/src/spec/doc/ginq-userguide.adoc
b/subprojects/groovy-ginq/src/spec/doc/ginq-userguide.adoc
index c3a9ec2273..802bc69a11 100644
--- a/subprojects/groovy-ginq/src/spec/doc/ginq-userguide.adoc
+++ b/subprojects/groovy-ginq/src/spec/doc/ginq-userguide.adoc
@@ -48,7 +48,9 @@ A GINQ expression can include the following clauses:
* **`limit`** — restricts output to a given size, with optional offset for
pagination.
* **`select`** — the projection (required). Defines the output columns, with
optional `as` aliases.
-GINQ also supports window functions, nested subqueries, and set operations
(`union`, `intersect`, `minus`) — see <<Advanced Topics>> for details.
+* **`union`**, **`unionall`**, **`intersect`**, **`minus`** — set operations
that combine two complete `from...select` expressions.
+
+GINQ also supports window functions and nested subqueries — see <<Advanced
Topics>> for details.
****
*How it works*: Under the covers GINQ transforms the query into calls within a
fluent API. For example, the above query is transformed into:
@@ -555,6 +557,36 @@
include::../test/org/apache/groovy/ginq/GinqTest.groovy[tags=ginq_pagination_01,
include::../test/org/apache/groovy/ginq/GinqTest.groovy[tags=ginq_pagination_02,indent=0]
----
+=== Set Operations
+Set operations combine the results of two complete GINQ expressions.
+
+`union` returns distinct rows from both queries (duplicates removed):
+[source, groovy]
+----
+include::../test/org/apache/groovy/ginq/GinqTest.groovy[tags=ginq_setop_union,indent=0]
+----
+
+`unionall` returns all rows from both queries (duplicates preserved):
+[source, groovy]
+----
+include::../test/org/apache/groovy/ginq/GinqTest.groovy[tags=ginq_setop_unionall,indent=0]
+----
+
+`intersect` returns only rows that appear in both queries:
+[source, groovy]
+----
+include::../test/org/apache/groovy/ginq/GinqTest.groovy[tags=ginq_setop_intersect,indent=0]
+----
+
+`minus` returns rows from the first query that do not appear in the second:
+[source, groovy]
+----
+include::../test/org/apache/groovy/ginq/GinqTest.groovy[tags=ginq_setop_minus,indent=0]
+----
+
+Each side of a set operation is a complete `from...select` expression and can
include
+`where`, `groupby`, `orderby`, `limit`, and any other clauses.
+
== Common Recipes
Recipes for everyday tasks: row numbering, list comprehensions, querying JSON,
@@ -1141,28 +1173,32 @@
include::../test/org/apache/groovy/ginq/GinqTest.groovy[tags=ginq_optimize_01,in
The full GINQ clause structure for reference:
```sql
GQ, i.e. abbreviation for GINQ
-|__ from
-| |__ <data_source_alias> in <data_source>
-|__ [join/innerjoin/leftjoin/rightjoin/fulljoin/crossjoin]*
-| |__ <data_source_alias> in <data_source>
-| |__ on <condition> ((&& | ||) <condition>)* (NOTE: `crossjoin` does not
need `on` clause)
-|__ [where]
-| |__ <condition> ((&& | ||) <condition>)*
-|__ [groupby]
-| |__ <expression> [as <alias>] (, <expression> [as <alias>])* [into
<group_alias>]
-| |__ [having]
-| |__ <condition> ((&& | ||) <condition>)*
-|__ [orderby]
-| |__ <expression> [in (asc|desc)] (, <expression> [in (asc|desc)])*
-|__ [limit]
-| |__ [<offset>,] <size>
-|__ select
- |__ <expression> [as <alias>] (, <expression> [as <alias>])*
+|__ <query>
+| |__ from
+| | |__ <data_source_alias> in <data_source>
+| |__ [join/innerjoin/leftjoin/rightjoin/fulljoin/crossjoin]*
+| | |__ <data_source_alias> in <data_source>
+| | |__ on <condition> ((&& | ||) <condition>)* (NOTE: `crossjoin` does
not need `on` clause)
+| |__ [where]
+| | |__ <condition> ((&& | ||) <condition>)*
+| |__ [groupby]
+| | |__ <expression> [as <alias>] (, <expression> [as <alias>])* [into
<group_alias>]
+| | |__ [having]
+| | |__ <condition> ((&& | ||) <condition>)*
+| |__ [orderby]
+| | |__ <expression> [in (asc|desc)] (, <expression> [in (asc|desc)])*
+| |__ [limit]
+| | |__ [<offset>,] <size>
+| |__ select
+| |__ <expression> [as <alias>] (, <expression> [as <alias>])*
+|__ [union/unionall/intersect/minus]*
+ |__ <query>
```
[NOTE]
-`[]` means the related clause is optional, `*` means zero or more times, and
`+` means one or more times. Also, the clauses of GINQ are order sensitive,
+`[]` means the related clause is optional, `*` means zero or more times, and
`+` means one or more times. Also, the clauses within each query are order
sensitive,
so the order of clauses should be kept as the above structure.
-__ONLY ONE__ `from` clause is required in GINQ. Multiple data sources are
supported through `from` and the related joins.
+__ONLY ONE__ `from` clause is required per query. Multiple data sources are
supported through `from` and the related joins.
+Set operations chain left-to-right, so `Q1 union Q2 minus Q3` is evaluated as
`(Q1 union Q2) minus Q3`.
=== Known Limitations
diff --git
a/subprojects/groovy-ginq/src/spec/test/org/apache/groovy/ginq/GinqTest.groovy
b/subprojects/groovy-ginq/src/spec/test/org/apache/groovy/ginq/GinqTest.groovy
index d27bcd8e53..822da7659a 100644
---
a/subprojects/groovy-ginq/src/spec/test/org/apache/groovy/ginq/GinqTest.groovy
+++
b/subprojects/groovy-ginq/src/spec/test/org/apache/groovy/ginq/GinqTest.groovy
@@ -3845,6 +3845,100 @@ class GinqTest {
'''
}
+ // set operation tests
+
+ @Test
+ void "testGinq - union"() {
+ assertGinqScript '''
+// tag::ginq_setop_union[]
+ assert [1, 2, 3, 4, 5] == GQ {
+ from n in [1, 2, 3]
+ select n
+ union
+ from m in [3, 4, 5]
+ select m
+ }.toList()
+// end::ginq_setop_union[]
+ '''
+ }
+
+ @Test
+ void "testGinq - unionall"() {
+ assertGinqScript '''
+// tag::ginq_setop_unionall[]
+ assert [1, 2, 3, 3, 4, 5] == GQ {
+ from n in [1, 2, 3]
+ select n
+ unionall
+ from m in [3, 4, 5]
+ select m
+ }.toList()
+// end::ginq_setop_unionall[]
+ '''
+ }
+
+ @Test
+ void "testGinq - intersect"() {
+ assertGinqScript '''
+// tag::ginq_setop_intersect[]
+ assert [3, 4] == GQ {
+ from n in [1, 2, 3, 4]
+ select n
+ intersect
+ from m in [3, 4, 5, 6]
+ select m
+ }.toList()
+// end::ginq_setop_intersect[]
+ '''
+ }
+
+ @Test
+ void "testGinq - minus"() {
+ assertGinqScript '''
+// tag::ginq_setop_minus[]
+ assert [1, 2] == GQ {
+ from n in [1, 2, 3, 4]
+ select n
+ minus
+ from m in [3, 4, 5, 6]
+ select m
+ }.toList()
+// end::ginq_setop_minus[]
+ '''
+ }
+
+ @Test
+ void "testGinq - chained set operations"() {
+ assertGinqScript '''
+ // [1,2,3] union [3,4,5] = [1,2,3,4,5], then minus [4,5,6] =
[1,2,3]
+ assert [1, 2, 3] == GQ {
+ from n in [1, 2, 3]
+ select n
+ union
+ from m in [3, 4, 5]
+ select m
+ minus
+ from k in [4, 5, 6]
+ select k
+ }.toList()
+ '''
+ }
+
+ @Test
+ void "testGinq - union with where"() {
+ assertGinqScript '''
+ assert [2, 4, 5] == GQ {
+ from n in [1, 2, 3]
+ where n % 2 == 0
+ select n
+ union
+ from m in [3, 4, 5]
+ where m > 3
+ select m
+ }.toList()
+ '''
+ }
+
@Test
void "testGinq - GINQ examples - 1"() {
assertGinqScript '''