Skip to content

Commit 22c2e35

Browse files
dfa1claude
andcommitted
test(calcite): cover untranslatable WHERE predicates abandoning to the scan
Harden the aggregate push-down translation boundary: predicates strictComparison cannot fully translate (OR, column-to-column comparison, IS NULL over a computed expression) must abandon the rewrite and let the scan compute the answer — no crash, no accidental fold, correct result. No production bug found; the boundary already fails closed, so this is pure negative coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6628ddf commit 22c2e35

1 file changed

Lines changed: 84 additions & 0 deletions

File tree

calcite/src/test/java/io/github/dfa1/vortex/calcite/AggregateWhereCleanPartitionTest.java

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import org.junit.jupiter.api.BeforeAll;
1111
import org.junit.jupiter.api.Test;
1212
import org.junit.jupiter.api.io.TempDir;
13+
import org.junit.jupiter.params.ParameterizedTest;
14+
import org.junit.jupiter.params.provider.MethodSource;
1315

1416
import java.nio.channels.FileChannel;
1517
import java.nio.file.Path;
@@ -318,6 +320,88 @@ void isNotNullBoundaryChunkAbandonsToTheScan() throws Exception {
318320
}
319321
}
320322

323+
/// One untranslatable `WHERE` shape: its predicate, the SQL `SUM(val)` ground truth (`null` for
324+
/// an empty selection, where `SUM` is SQL NULL), and the `COUNT(*)` ground truth. All run over
325+
/// the shared 8-chunk `id == val` fixture (`0 .. ROWS-1`, non-null).
326+
private record AbandonCase(String label, String where, Long expectedSum, long expectedCount) {
327+
@Override
328+
public String toString() {
329+
return label;
330+
}
331+
}
332+
333+
static java.util.stream.Stream<AbandonCase> untranslatableShapes() {
334+
// Each shape is a predicate strictComparison cannot fully translate to a RowFilter, so the
335+
// aggregate rewrite must abandon and let the scan compute the answer. The ground truth uses
336+
// val == id over 0..ROWS-1.
337+
return java.util.stream.Stream.of(
338+
// OR has no case in strictComparison's switch (only AND / comparison / null check),
339+
// so it hits the default -> empty. ids 0..999 match; val<0 never does.
340+
new AbandonCase("OR predicate", "id < 1000 or val < 0",
341+
999L * 1000 / 2, 1000),
342+
// column-vs-column: binary() requires a RexInputRef + a RexLiteral; two refs abandon.
343+
// id <= val is always true, so the scan reduces over the whole table.
344+
new AbandonCase("column <= column (all rows)", "id <= val",
345+
(long) (ROWS - 1) * ROWS / 2, ROWS),
346+
// column-vs-column with an empty selection (id < val is never true): still abandons,
347+
// and the scan over zero rows makes SUM the SQL NULL, COUNT(*) zero.
348+
new AbandonCase("column < column (no rows)", "id < val", null, 0));
349+
}
350+
351+
@ParameterizedTest(name = "{0}")
352+
@MethodSource("untranslatableShapes")
353+
void untranslatablePredicateAbandonsToScan(AbandonCase shape) throws Exception {
354+
String sql = "select sum(val) s, count(*) c from vtx.t where " + shape.where();
355+
356+
try (Connection conn = connect()) {
357+
// When the predicate is not fully translatable, the rewrite is abandoned: the plan keeps
358+
// a scan (BindableTableScan) rather than folding to a Values row
359+
assertThat(explain(conn, sql))
360+
.as("abandon for %s", shape.label())
361+
.contains("TableScan");
362+
363+
// And the scan still produces the exact aggregate over the filtered rows
364+
try (Statement st = conn.createStatement();
365+
ResultSet rs = st.executeQuery(sql)) {
366+
rs.next();
367+
long sum = rs.getLong("s");
368+
if (shape.expectedSum() == null) {
369+
assertThat(rs.wasNull()).as("SUM over empty selection is SQL NULL").isTrue();
370+
} else {
371+
assertThat(sum).as("SUM for %s", shape.label()).isEqualTo(shape.expectedSum());
372+
}
373+
assertThat(rs.getLong("c")).as("COUNT(*) for %s", shape.label())
374+
.isEqualTo(shape.expectedCount());
375+
}
376+
}
377+
}
378+
379+
@Test
380+
void isNullOverExpressionAbandonsToScan() throws Exception {
381+
// Given a nullable val: `abs(val) IS NULL` is an IS NULL over a *computed* expression, not a
382+
// bare column ref, so nullCheck's RexInputRef guard rejects it and the rewrite abandons.
383+
// (abs() is chosen deliberately — Calcite simplifies `(val + 1) IS NULL` down to a bare
384+
// `val IS NULL`, which would translate and fold, but it leaves `IS NULL(ABS(val))` intact.)
385+
Path f = nullPartitionedFile("isnull-expr.vortex");
386+
String sql = "select sum(val) s, count(*) c, count(val) cv from vtx.t where abs(val) is null";
387+
388+
try (Connection conn = connect(f)) {
389+
// When EXPLAIN runs, a scan is present — the expression IS NULL did not translate
390+
assertThat(explain(conn, sql)).contains("TableScan");
391+
392+
// And the scan computes the exact result: abs(val) IS NULL selects chunk 0's all-null
393+
// rows, so SUM is SQL NULL, COUNT(*) the 4 rows, COUNT(val) zero
394+
try (Statement st = conn.createStatement();
395+
ResultSet rs = st.executeQuery(sql)) {
396+
rs.next();
397+
assertThat(rs.getLong("s")).isZero();
398+
assertThat(rs.wasNull()).isTrue(); // SUM over an all-null selection is SQL NULL
399+
assertThat(rs.getLong("c")).isEqualTo(4);
400+
assertThat(rs.getLong("cv")).isZero();
401+
}
402+
}
403+
}
404+
321405
/// Writes a two-chunk file whose nullable `val` partitions the zones cleanly by null-ness:
322406
/// chunk 0 entirely NULL, chunk 1 entirely non-null `{10,20,30,40}`. Shared by the
323407
/// `IS NULL` / `IS NOT NULL` clean-partition tests.

0 commit comments

Comments
 (0)