|
1 | 1 | package io.substrait.isthmus; |
2 | 2 |
|
3 | 3 | 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; |
4 | 7 |
|
5 | 8 | import io.substrait.extension.DefaultExtensionCatalog; |
| 9 | +import io.substrait.extension.ExtensionCollector; |
6 | 10 | import io.substrait.extension.SimpleExtension; |
7 | 11 | 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; |
8 | 18 | import java.io.IOException; |
| 19 | +import java.util.List; |
| 20 | +import java.util.Optional; |
9 | 21 | import org.apache.calcite.plan.hep.HepPlanner; |
10 | 22 | import org.apache.calcite.plan.hep.HepProgram; |
11 | 23 | import org.apache.calcite.plan.hep.HepProgramBuilder; |
| 24 | +import org.apache.calcite.rel.RelCollations; |
12 | 25 | import org.apache.calcite.rel.RelNode; |
13 | 26 | 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; |
14 | 30 | 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; |
15 | 35 | 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; |
16 | 39 | import org.junit.jupiter.api.Test; |
17 | 40 |
|
18 | 41 | class OptimizerIntegrationTest extends PlanTestBase { |
@@ -48,4 +71,177 @@ void conversionHandlesBuiltInSum0CallAddedByRule() throws SqlParseException, IOE |
48 | 71 | // Conversion of the new plan should succeed |
49 | 72 | SubstraitRelVisitor.convert(RelRoot.of(newPlan, relRoot.kind), EXTENSION_COLLECTION)); |
50 | 73 | } |
| 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 | + } |
51 | 247 | } |
0 commit comments