|
10 | 10 | import org.junit.jupiter.api.BeforeAll; |
11 | 11 | import org.junit.jupiter.api.Test; |
12 | 12 | import org.junit.jupiter.api.io.TempDir; |
| 13 | +import org.junit.jupiter.params.ParameterizedTest; |
| 14 | +import org.junit.jupiter.params.provider.MethodSource; |
13 | 15 |
|
14 | 16 | import java.nio.channels.FileChannel; |
15 | 17 | import java.nio.file.Path; |
@@ -318,6 +320,88 @@ void isNotNullBoundaryChunkAbandonsToTheScan() throws Exception { |
318 | 320 | } |
319 | 321 | } |
320 | 322 |
|
| 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 | + |
321 | 405 | /// Writes a two-chunk file whose nullable `val` partitions the zones cleanly by null-ness: |
322 | 406 | /// chunk 0 entirely NULL, chunk 1 entirely non-null `{10,20,30,40}`. Shared by the |
323 | 407 | /// `IS NULL` / `IS NOT NULL` clean-partition tests. |
|
0 commit comments