Skip to content

Commit 52a32ba

Browse files
authored
fix(isthmus): handle LITERAL_AGG in aggregate conversion
1 parent a1e48c6 commit 52a32ba

2 files changed

Lines changed: 276 additions & 15 deletions

File tree

isthmus/src/main/java/io/substrait/isthmus/SubstraitRelVisitor.java

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.substrait.isthmus;
22

3+
import com.google.common.collect.Iterables;
34
import io.substrait.expression.AggregateFunctionInvocation;
45
import io.substrait.expression.Expression;
56
import io.substrait.expression.ExpressionCreator;
@@ -57,6 +58,7 @@
5758
import org.apache.calcite.rex.RexFieldAccess;
5859
import org.apache.calcite.rex.RexInputRef;
5960
import org.apache.calcite.rex.RexNode;
61+
import org.apache.calcite.sql.SqlKind;
6062
import org.apache.calcite.sql.fun.SqlStdOperatorTable;
6163
import org.apache.calcite.util.ImmutableBitSet;
6264
import org.immutables.value.Value;
@@ -343,15 +345,35 @@ public Rel visit(org.apache.calcite.rel.core.Aggregate aggregate) {
343345
sets.filter(s -> s != null).map(s -> fromGroupSet(s, input)).collect(Collectors.toList());
344346

345347
// get GROUP_ID() function calls
346-
List<AggregateCall> groupIdCalls =
348+
java.util.Set<AggregateCall> groupIdCalls =
347349
aggregate.getAggCallList().stream()
348350
.filter(c -> c.getAggregation().equals(SqlStdOperatorTable.GROUP_ID))
349-
.collect(Collectors.toList());
351+
.collect(Collectors.toSet());
352+
353+
// get LITERAL_AGG() function calls — injected by SubQueryRemoveRule (CALCITE-6945) as a
354+
// null-presence indicator; they carry a RexLiteral in rexList and have no Substrait binding.
355+
java.util.Set<AggregateCall> literalAggCalls =
356+
aggregate.getAggCallList().stream()
357+
.filter(c -> c.getAggregation().getKind() == SqlKind.LITERAL_AGG)
358+
.collect(Collectors.toSet());
359+
360+
if (!literalAggCalls.isEmpty() && groupings.size() > 1) {
361+
throw new UnsupportedOperationException(
362+
"LITERAL_AGG combined with GROUPING SETS / CUBE / ROLLUP is not supported");
363+
}
364+
365+
// Number of distinct grouping-expression output fields produced by the aggregate.
366+
// Used by the no-GROUP_ID remap and the LITERAL_AGG project wrapper below.
367+
// The GROUP_ID remap branch intentionally uses a non-distinct count instead (see comment
368+
// there).
369+
final int groupingFieldCount =
370+
Math.toIntExact(
371+
groupings.stream().flatMap(g -> g.getExpressions().stream()).distinct().count());
350372

351373
List<AggregateCall> filteredAggCalls =
352374
aggregate.getAggCallList().stream()
353-
// remove GROUP_ID() function calls
354-
.filter(c -> !groupIdCalls.contains(c))
375+
// remove GROUP_ID() and LITERAL_AGG() function calls
376+
.filter(c -> !groupIdCalls.contains(c) && !literalAggCalls.contains(c))
355377
.collect(Collectors.toList());
356378

357379
List<Measure> aggCalls =
@@ -365,29 +387,30 @@ public Rel visit(org.apache.calcite.rel.core.Aggregate aggregate) {
365387
if (groupings.size() > 1) {
366388
// remove the grouping set index if there was no explicit GROUP_ID() function call
367389
if (groupIdCalls.isEmpty()) {
368-
int groupingExprSize =
369-
Math.toIntExact(
370-
groupings.stream().flatMap(g -> g.getExpressions().stream()).distinct().count());
371-
builder.remap(Remap.offset(0, groupingExprSize + aggCalls.size()));
390+
builder.remap(Remap.offset(0, groupingFieldCount + aggCalls.size()));
372391
} else {
373-
// remap grouping set index at the field positions where the GROUP_ID() function calls were
374-
final int groupingFieldCount =
392+
// remap grouping set index at the field positions where the GROUP_ID() function calls were.
393+
// Use the non-distinct total here: when grouping sets share expressions the aggregate
394+
// output
395+
// contains one slot per (groupingSet × expression) entry, not one per distinct expression.
396+
final int groupingFieldCountWithDuplicates =
375397
Math.toIntExact(groupings.stream().flatMap(g -> g.getExpressions().stream()).count());
376398
final int filterAggCallCount = aggCalls.size();
377-
final Integer groupingSetIndex = groupingFieldCount + filterAggCallCount;
399+
final Integer groupingSetIndex = groupingFieldCountWithDuplicates + filterAggCallCount;
378400

379401
final List<Integer> remap =
380-
IntStream.range(0, groupingFieldCount)
402+
IntStream.range(0, groupingFieldCountWithDuplicates)
381403
.mapToObj(i -> i)
382404
.collect(Collectors.toCollection(ArrayList::new));
383405

384406
for (int i = 0; i < aggregate.getAggCallList().size(); i++) {
385407
AggregateCall aggCall = aggregate.getAggCallList().get(i);
386408
if (filteredAggCalls.contains(aggCall)) {
387409
remap.add(
388-
i + groupingFieldCount, filteredAggCalls.indexOf(aggCall) + groupingFieldCount);
410+
i + groupingFieldCountWithDuplicates,
411+
filteredAggCalls.indexOf(aggCall) + groupingFieldCountWithDuplicates);
389412
} else if (groupIdCalls.contains(aggCall)) {
390-
remap.add(i + groupingFieldCount, groupingSetIndex);
413+
remap.add(i + groupingFieldCountWithDuplicates, groupingSetIndex);
391414
} else {
392415
// this should never get triggered
393416
throw new IllegalStateException(
@@ -400,7 +423,49 @@ public Rel visit(org.apache.calcite.rel.core.Aggregate aggregate) {
400423
}
401424
}
402425

403-
return builder.build();
426+
Rel aggRel = builder.build();
427+
428+
if (literalAggCalls.isEmpty()) {
429+
return aggRel;
430+
}
431+
432+
// Wrap the aggregate in a Project that replaces LITERAL_AGG output positions with their
433+
// literal values and passes through all other fields via FieldReference.
434+
//
435+
// The aggregate output schema is: [grouping fields..., real agg measures...]
436+
// The full output schema requested is: [grouping fields..., all agg calls (in original order)]
437+
// For each position in the original agg call list:
438+
// - real measure → FieldReference into the aggregate output
439+
// - LITERAL_AGG → the literal value from aggCall.rexList
440+
final int realAggCount = aggCalls.size();
441+
final int totalAggOutputFields = groupingFieldCount + realAggCount;
442+
443+
// Build the project expression list: grouping fields first, then one expression per original
444+
// agg call in declaration order.
445+
List<Expression> projectExprs = new ArrayList<>();
446+
for (int i = 0; i < groupingFieldCount; i++) {
447+
projectExprs.add(FieldReference.newInputRelReference(i, aggRel));
448+
}
449+
int realAggIndex = groupingFieldCount; // tracks next real-measure field index in aggRel output
450+
for (AggregateCall aggCall : aggregate.getAggCallList()) {
451+
if (literalAggCalls.contains(aggCall)) {
452+
// Convert the RexLiteral stored in rexList to a Substrait literal expression
453+
RexNode rexLiteral = Iterables.getOnlyElement(aggCall.rexList);
454+
projectExprs.add(toExpression(rexLiteral));
455+
} else if (!groupIdCalls.contains(aggCall)) {
456+
// real measure: pass through by reference
457+
projectExprs.add(FieldReference.newInputRelReference(realAggIndex, aggRel));
458+
realAggIndex++;
459+
}
460+
// GROUP_ID calls are not present in the outer schema here (groupings.size() <= 1 branch);
461+
// if groupings.size() > 1 they are handled by the remap above and should not appear here
462+
}
463+
464+
return Project.builder()
465+
.remap(Remap.offset(totalAggOutputFields, projectExprs.size()))
466+
.expressions(projectExprs)
467+
.input(aggRel)
468+
.build();
404469
}
405470

406471
Aggregate.Grouping fromGroupSet(ImmutableBitSet bitSet, Rel input) {

isthmus/src/test/java/io/substrait/isthmus/OptimizerIntegrationTest.java

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,41 @@
11
package io.substrait.isthmus;
22

33
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
4+
import static org.junit.jupiter.api.Assertions.assertEquals;
5+
import static org.junit.jupiter.api.Assertions.assertThrows;
6+
import static org.junit.jupiter.api.Assertions.assertTrue;
47

58
import io.substrait.extension.DefaultExtensionCatalog;
9+
import io.substrait.extension.ExtensionCollector;
610
import io.substrait.extension.SimpleExtension;
711
import io.substrait.isthmus.sql.SubstraitSqlToCalcite;
12+
import io.substrait.relation.Aggregate;
13+
import io.substrait.relation.Project;
14+
import io.substrait.relation.ProtoRelConverter;
15+
import io.substrait.relation.Rel;
16+
import io.substrait.relation.RelProtoConverter;
17+
import io.substrait.type.Type;
818
import java.io.IOException;
19+
import java.util.List;
20+
import java.util.Optional;
921
import org.apache.calcite.plan.hep.HepPlanner;
1022
import org.apache.calcite.plan.hep.HepProgram;
1123
import org.apache.calcite.plan.hep.HepProgramBuilder;
24+
import org.apache.calcite.rel.RelCollations;
1225
import org.apache.calcite.rel.RelNode;
1326
import org.apache.calcite.rel.RelRoot;
27+
import org.apache.calcite.rel.core.AggregateCall;
28+
import org.apache.calcite.rel.core.RelFactories;
29+
import org.apache.calcite.rel.logical.LogicalAggregate;
1430
import org.apache.calcite.rel.rules.CoreRules;
31+
import org.apache.calcite.rex.RexBuilder;
32+
import org.apache.calcite.sql.SqlKind;
33+
import org.apache.calcite.sql.fun.SqlInternalOperators;
34+
import org.apache.calcite.sql.fun.SqlStdOperatorTable;
1535
import org.apache.calcite.sql.parser.SqlParseException;
36+
import org.apache.calcite.sql.type.SqlTypeName;
37+
import org.apache.calcite.sql2rel.RelDecorrelator;
38+
import org.apache.calcite.util.ImmutableBitSet;
1639
import org.junit.jupiter.api.Test;
1740

1841
class OptimizerIntegrationTest extends PlanTestBase {
@@ -48,4 +71,177 @@ void conversionHandlesBuiltInSum0CallAddedByRule() throws SqlParseException, IOE
4871
// Conversion of the new plan should succeed
4972
SubstraitRelVisitor.convert(RelRoot.of(newPlan, relRoot.kind), EXTENSION_COLLECTION));
5073
}
74+
75+
/**
76+
* Regression test for LITERAL_AGG handling in SubstraitRelVisitor.
77+
*
78+
* <p>Calcite's SubQueryRemoveRule (CALCITE-6945, landed in 1.38.0) rewrites correlated quantified
79+
* comparisons (e.g. {@code <> SOME}) using {@code LITERAL_AGG(true)} as a null-presence
80+
* indicator. SubstraitRelVisitor has no Substrait binding for {@code LITERAL_AGG}, so the
81+
* conversion previously crashed with "UnsupportedOperationException: Unable to find binding for
82+
* call LITERAL_AGG(true)".
83+
*
84+
* @see <a href="https://github.com/apache/calcite/pull/4296">CALCITE-6945 PR</a>
85+
*/
86+
@Test
87+
void conversionHandlesLiteralAggInsertedBySubQueryRemoveRule()
88+
throws SqlParseException, IOException {
89+
String query =
90+
"select e1.l_orderkey from lineitem e1 "
91+
+ "where e1.l_quantity <> some ("
92+
+ " select l_quantity from lineitem e2 where e2.l_partkey = e1.l_partkey"
93+
+ ")";
94+
95+
RelRoot relRoot = SubstraitSqlToCalcite.convertQuery(query, TPCH_CATALOG);
96+
HepPlanner hepPlanner =
97+
new HepPlanner(
98+
new HepProgramBuilder()
99+
.addRuleInstance(CoreRules.FILTER_SUB_QUERY_TO_CORRELATE)
100+
.build());
101+
hepPlanner.setRoot(relRoot.rel);
102+
RelNode decorrelated =
103+
RelDecorrelator.decorrelateQuery(
104+
hepPlanner.findBestExp(),
105+
RelFactories.LOGICAL_BUILDER.create(relRoot.rel.getCluster(), null));
106+
107+
// Pin the trigger so a future Calcite bump can't silently stop exercising this path.
108+
assertTrue(containsLiteralAgg(decorrelated), "test setup no longer produces LITERAL_AGG");
109+
110+
io.substrait.plan.Plan.Root planRoot =
111+
assertDoesNotThrow(
112+
() ->
113+
SubstraitRelVisitor.convert(
114+
RelRoot.of(decorrelated, relRoot.kind), EXTENSION_COLLECTION));
115+
116+
// The fix inserts a Project directly over the Aggregate; inspect THAT, not the outer SELECT.
117+
Project wrapper =
118+
findProjectOverAggregate(planRoot.getInput())
119+
.orElseThrow(() -> new AssertionError("expected a Project wrapping the Aggregate"));
120+
121+
assertTrue(
122+
wrapper.getExpressions().stream()
123+
.anyMatch(
124+
e ->
125+
e instanceof io.substrait.expression.Expression.BoolLiteral
126+
&& ((io.substrait.expression.Expression.BoolLiteral) e).value()),
127+
"LITERAL_AGG(true) should be re-inserted as a boolean true literal");
128+
129+
// Passthroughs must carry the scalar field type, not the whole aggregate struct.
130+
assertTrue(
131+
wrapper.getRecordType().fields().stream().noneMatch(f -> f instanceof Type.Struct),
132+
"wrapper columns must be scalar; fields=" + wrapper.getRecordType().fields());
133+
134+
// The wrapper subtree must survive a proto round-trip with its schema intact.
135+
ExtensionCollector ec = new ExtensionCollector();
136+
io.substrait.proto.Rel proto = new RelProtoConverter(ec).toProto(wrapper);
137+
Rel rt = new ProtoRelConverter(ec, extensions).from(proto);
138+
assertEquals(wrapper.getRecordType(), rt.getRecordType(), "wrapper schema must round-trip");
139+
}
140+
141+
@Test
142+
void literalAggCombinedWithGroupingSetsIsRejected() {
143+
RelNode input = builder.values(new String[] {"a", "b"}, 1, 2, 3, 4).build();
144+
RexBuilder rexBuilder = creator.rex();
145+
AggregateCall literalAgg =
146+
AggregateCall.create(
147+
SqlInternalOperators.LITERAL_AGG,
148+
false,
149+
false,
150+
false,
151+
List.of(rexBuilder.makeLiteral(true)),
152+
List.of(),
153+
-1,
154+
null,
155+
RelCollations.EMPTY,
156+
typeFactory.createSqlType(SqlTypeName.BOOLEAN),
157+
"li");
158+
ImmutableBitSet g0 = ImmutableBitSet.of(0);
159+
ImmutableBitSet g1 = ImmutableBitSet.of(1);
160+
RelNode aggregate =
161+
LogicalAggregate.create(
162+
input, List.of(), g0.union(g1), List.of(g0, g1), List.of(literalAgg));
163+
164+
UnsupportedOperationException ex =
165+
assertThrows(
166+
UnsupportedOperationException.class,
167+
() ->
168+
SubstraitRelVisitor.convert(
169+
RelRoot.of(aggregate, org.apache.calcite.sql.SqlKind.SELECT),
170+
EXTENSION_COLLECTION));
171+
assertTrue(ex.getMessage().contains("GROUPING SETS"), ex.getMessage());
172+
}
173+
174+
/**
175+
* Reproduces the crash from the review comment: LITERAL_AGG first in the agg-call list, followed
176+
* by GROUP_ID, with multiple grouping sets. The remap loop used to leave a gap and throw
177+
* IndexOutOfBoundsException; now it should throw the clean UnsupportedOperationException before
178+
* reaching the remap work.
179+
*/
180+
@Test
181+
void literalAggBeforeGroupIdWithGroupingSetsIsRejected() {
182+
RelNode input = builder.values(new String[] {"a", "b"}, 1, 2, 3, 4).build();
183+
RexBuilder rexBuilder = creator.rex();
184+
AggregateCall literalAgg =
185+
AggregateCall.create(
186+
SqlInternalOperators.LITERAL_AGG,
187+
false,
188+
false,
189+
false,
190+
List.of(rexBuilder.makeLiteral(true)),
191+
List.of(),
192+
-1,
193+
null,
194+
RelCollations.EMPTY,
195+
typeFactory.createSqlType(SqlTypeName.BOOLEAN),
196+
"li");
197+
AggregateCall groupIdCall =
198+
AggregateCall.create(
199+
SqlStdOperatorTable.GROUP_ID,
200+
false,
201+
false,
202+
false,
203+
List.of(),
204+
List.of(),
205+
-1,
206+
null,
207+
RelCollations.EMPTY,
208+
typeFactory.createSqlType(SqlTypeName.BIGINT),
209+
"gid");
210+
ImmutableBitSet g0 = ImmutableBitSet.of(0);
211+
ImmutableBitSet g1 = ImmutableBitSet.of(1);
212+
// LITERAL_AGG at index 0, GROUP_ID at index 1 — the ordering that triggered the crash
213+
RelNode aggregate =
214+
LogicalAggregate.create(
215+
input, List.of(), g0.union(g1), List.of(g0, g1), List.of(literalAgg, groupIdCall));
216+
217+
UnsupportedOperationException ex =
218+
assertThrows(
219+
UnsupportedOperationException.class,
220+
() ->
221+
SubstraitRelVisitor.convert(
222+
RelRoot.of(aggregate, org.apache.calcite.sql.SqlKind.SELECT),
223+
EXTENSION_COLLECTION));
224+
assertTrue(ex.getMessage().contains("GROUPING SETS"), ex.getMessage());
225+
}
226+
227+
private static boolean containsLiteralAgg(RelNode node) {
228+
if (node instanceof org.apache.calcite.rel.core.Aggregate agg) {
229+
if (agg.getAggCallList().stream()
230+
.anyMatch(c -> c.getAggregation().getKind() == SqlKind.LITERAL_AGG)) {
231+
return true;
232+
}
233+
}
234+
return node.getInputs().stream().anyMatch(OptimizerIntegrationTest::containsLiteralAgg);
235+
}
236+
237+
private static Optional<Project> findProjectOverAggregate(Rel rel) {
238+
if (rel instanceof Project p && p.getInput() instanceof Aggregate) {
239+
return Optional.of(p);
240+
}
241+
return rel.getInputs().stream()
242+
.map(OptimizerIntegrationTest::findProjectOverAggregate)
243+
.filter(Optional::isPresent)
244+
.map(Optional::get)
245+
.findFirst();
246+
}
51247
}

0 commit comments

Comments
 (0)