Skip to content

Commit 5adb63e

Browse files
dfa1claude
andcommitted
test(calcite): cover filter push-down + aggregate-rule branches (Sonar new-coverage gate)
SonarCloud quality gate was ERROR: new_coverage 74% < 80%, concentrated in the new calcite adapter. Add tests for the uncovered paths — no production changes: - FilterPushDownTest: JDBC WHERE over every supported comparison, Utf8/float literal coercion, multi-conjunct AND, non-pushable bool/bare-ref predicates (exercises toRowFilter/binary/collectColumns). - AggregateRuleBranchTest: Hep planner asserts rewrite-to-Values vs abandon for COUNT(*), COUNT(col), SUM (no stat), MIN(varchar), MIN(expr), GROUP BY. - VortexAdapterCoverageTest: missing-file UncheckedIOException paths, reset()/ close-with-open-chunk, i32/f32 sums, non-numeric-column throw. calcite new-code coverage 74% -> 88.5% lines / 83.2% line+branch units. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9f893b8 commit 5adb63e

3 files changed

Lines changed: 290 additions & 0 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package io.github.dfa1.vortex.calcite;
2+
3+
import org.apache.calcite.avatica.util.Casing;
4+
import org.apache.calcite.plan.RelOptUtil;
5+
import org.apache.calcite.plan.hep.HepPlanner;
6+
import org.apache.calcite.plan.hep.HepProgram;
7+
import org.apache.calcite.plan.hep.HepProgramBuilder;
8+
import org.apache.calcite.rel.RelNode;
9+
import org.apache.calcite.schema.SchemaPlus;
10+
import org.apache.calcite.sql.SqlNode;
11+
import org.apache.calcite.sql.parser.SqlParser;
12+
import org.apache.calcite.tools.FrameworkConfig;
13+
import org.apache.calcite.tools.Frameworks;
14+
import org.apache.calcite.tools.Planner;
15+
import org.junit.jupiter.api.BeforeAll;
16+
import org.junit.jupiter.api.Test;
17+
import org.junit.jupiter.api.io.TempDir;
18+
19+
import java.nio.file.Path;
20+
import java.util.Map;
21+
22+
import static org.assertj.core.api.Assertions.assertThat;
23+
24+
/// Branch coverage for [VortexAggregatePushDownRule]: each query either rewrites to a single-row
25+
/// `LogicalValues` (answerable from zone-map stats) or is left with its `Aggregate`/`TableScan`
26+
/// intact (the rule must abandon — wrong stats would give a wrong answer).
27+
class AggregateRuleBranchTest {
28+
29+
private static final int ROWS = 30_000;
30+
private static final int CHUNK = 10_000;
31+
32+
@TempDir
33+
static Path tmp;
34+
private static SchemaPlus schema;
35+
36+
@BeforeAll
37+
static void writeFile() throws Exception {
38+
Path file = tmp.resolve("ohlc.vortex");
39+
OhlcGenerator.write(file, ROWS, CHUNK);
40+
SchemaPlus root = Frameworks.createRootSchema(true);
41+
schema = root.add("vtx", new VortexSchema(Map.of("ohlc", file)));
42+
}
43+
44+
@Test
45+
void countStar_noProjectPath_rewritesToValues() {
46+
// Given a bare COUNT(*) — Aggregate(TableScan), the NO_PROJECT operand with project == null
47+
// When / Then — answered from the footer row count
48+
assertThat(optimize("select count(*) from ohlc")).contains("LogicalValues").doesNotContain("Aggregate");
49+
}
50+
51+
@Test
52+
void countColumn_withProjectPath_rewritesToValues() {
53+
// Given COUNT(volume) — Aggregate(Project(TableScan)); COUNT(col) = rows − nulls from stats
54+
// When / Then
55+
assertThat(optimize("select count(volume) from ohlc")).contains("LogicalValues").doesNotContain("Aggregate");
56+
}
57+
58+
@Test
59+
void sum_hasNoZoneStat_abandonsRewrite() {
60+
// Given SUM(volume) — no SUM zone statistic exists, so evaluate() yields null and the whole
61+
// rewrite is abandoned
62+
// When / Then — the Aggregate survives for the normal scan path
63+
assertThat(optimize("select sum(volume) from ohlc")).contains("Aggregate");
64+
}
65+
66+
@Test
67+
void minOnNonNumericColumn_abandonsRewrite() {
68+
// Given MIN(symbol) over a VARCHAR column — the stat value is non-numeric, numericLiteral
69+
// returns null, the rewrite is abandoned
70+
// When / Then
71+
assertThat(optimize("select min(symbol) from ohlc")).contains("Aggregate");
72+
}
73+
74+
@Test
75+
void minOverComputedExpression_abandonsRewrite() {
76+
// Given MIN(low + 1) — the projected input is an expression, not a bare column ref, so
77+
// resolveColumn returns null and the rewrite is abandoned
78+
// When / Then
79+
assertThat(optimize("select min(low + 1) from ohlc")).contains("Aggregate");
80+
}
81+
82+
@Test
83+
void groupedAggregate_isLeftUntouched() {
84+
// Given a GROUP BY — group count != 0, the rule returns immediately
85+
// When / Then — Aggregate stays
86+
assertThat(optimize("select symbol, max(high) from ohlc group by symbol")).contains("Aggregate");
87+
}
88+
89+
private static String optimize(String sql) {
90+
FrameworkConfig config = Frameworks.newConfigBuilder()
91+
.defaultSchema(schema)
92+
.parserConfig(SqlParser.config().withUnquotedCasing(Casing.UNCHANGED))
93+
.build();
94+
Planner planner = Frameworks.getPlanner(config);
95+
try {
96+
SqlNode parsed = planner.parse(sql);
97+
RelNode logical = planner.rel(planner.validate(parsed)).rel;
98+
HepProgram program = new HepProgramBuilder()
99+
.addRuleCollection(VortexAggregatePushDownRule.RULES)
100+
.build();
101+
HepPlanner hep = new HepPlanner(program);
102+
hep.setRoot(logical);
103+
return RelOptUtil.toString(hep.findBestExp());
104+
} catch (Exception e) {
105+
throw new IllegalStateException("planning failed for: " + sql, e);
106+
}
107+
}
108+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package io.github.dfa1.vortex.calcite;
2+
3+
import io.github.dfa1.vortex.core.model.DType;
4+
import io.github.dfa1.vortex.writer.VortexWriter;
5+
import io.github.dfa1.vortex.writer.WriteOptions;
6+
7+
import org.apache.calcite.jdbc.CalciteConnection;
8+
import org.junit.jupiter.api.BeforeAll;
9+
import org.junit.jupiter.api.io.TempDir;
10+
import org.junit.jupiter.params.ParameterizedTest;
11+
import org.junit.jupiter.params.provider.CsvSource;
12+
13+
import java.nio.channels.FileChannel;
14+
import java.nio.file.Path;
15+
import java.nio.file.StandardOpenOption;
16+
import java.sql.Connection;
17+
import java.sql.DriverManager;
18+
import java.sql.ResultSet;
19+
import java.sql.Statement;
20+
import java.util.Map;
21+
import java.util.Properties;
22+
23+
import static org.assertj.core.api.Assertions.assertThat;
24+
25+
/// Drives [VortexTable]'s filter push-down (`scan(root, filters, projects)`) through a real Calcite
26+
/// JDBC planner: every supported `WHERE` comparison must be translated into a zone-map [RowFilter]
27+
/// (so the scan can prune chunks) while Calcite still returns the exact rows. Predicates the
28+
/// translator does not understand must be left untouched, not break the query.
29+
class FilterPushDownTest {
30+
31+
// Two chunks of three rows so the pushed RowFilter has chunks to (potentially) prune.
32+
private static final DType.Struct SCHEMA = DType.structBuilder()
33+
.field("i64", DType.I64)
34+
.field("i32", DType.I32)
35+
.field("f64", DType.F64)
36+
.field("s", DType.UTF8)
37+
.field("b", DType.BOOL)
38+
.build();
39+
40+
@TempDir
41+
static Path tmp;
42+
private static Path file;
43+
44+
@BeforeAll
45+
static void write() throws Exception {
46+
file = tmp.resolve("filter.vortex");
47+
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
48+
var w = VortexWriter.create(ch, SCHEMA, WriteOptions.defaults())) {
49+
w.writeChunk(Map.of(
50+
"i64", new long[]{1000L, 2000L, 3000L},
51+
"i32", new int[]{100, 200, 300},
52+
"f64", new double[]{1.0, 2.0, 3.0},
53+
"s", new String[]{"a", "b", "c"},
54+
"b", new boolean[]{true, false, true}));
55+
w.writeChunk(Map.of(
56+
"i64", new long[]{4000L, 5000L, 6000L},
57+
"i32", new int[]{400, 500, 600},
58+
"f64", new double[]{4.0, 5.0, 6.0},
59+
"s", new String[]{"d", "e", "f"},
60+
"b", new boolean[]{false, true, false}));
61+
}
62+
}
63+
64+
@ParameterizedTest(name = "[{index}] WHERE {0} -> {1} rows")
65+
@CsvSource({
66+
// every comparison kind the RowFilter translator supports, over a Long column
67+
"i64 = 1000, 1", // EQUALS -> RowFilter.Eq
68+
"i64 <> 1000, 5", // NOT_EQUALS -> RowFilter.Neq
69+
"i64 < 3000, 2", // LESS_THAN -> RowFilter.Lt
70+
"i64 <= 3000, 3", // LESS_THAN_EQ -> RowFilter.Lte
71+
"i64 > 4000, 2", // GREATER_THAN -> RowFilter.Gt
72+
"i64 >= 4000, 3", // GREATER_EQ -> RowFilter.Gte
73+
"s = 'a', 1", // Utf8 literal coercion
74+
"f64 > 3.0, 3", // floating literal coercion
75+
// multiple conjuncts arrive as separate filters -> RowFilter.and over the list
76+
"i64 > 1000 AND i32 < 600, 4",
77+
// bare boolean ref is not a RexCall -> not pushed, query still exact
78+
"b, 3",
79+
// comparison on a BOOLEAN column has no zone-map coercion -> not pushed, still exact
80+
"b = true, 3"
81+
})
82+
void whereClauseIsPushedAndRowsStayExact(String where, int expected) throws Exception {
83+
// Given a Calcite JDBC connection over the Vortex file
84+
Properties info = new Properties();
85+
info.setProperty("lex", "JAVA");
86+
try (Connection conn = DriverManager.getConnection("jdbc:calcite:", info)) {
87+
conn.unwrap(CalciteConnection.class).getRootSchema()
88+
.add("vtx", new VortexSchema(Map.of("data", file)));
89+
90+
// When the filtered query runs (Calcite pushes the predicate into VortexTable.scan)
91+
int rows = 0;
92+
try (Statement st = conn.createStatement();
93+
ResultSet rs = st.executeQuery("select i64 from vtx.data where " + where)) {
94+
while (rs.next()) {
95+
rows++;
96+
}
97+
}
98+
99+
// Then the row count is exact regardless of whether the predicate was pushed
100+
assertThat(rows).isEqualTo(expected);
101+
}
102+
}
103+
}

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,52 @@ void table_totalRowsAndStats() {
131131
assertThat(table.statsOf("i64")).isNotNull();
132132
}
133133

134+
@Test
135+
void enumerator_resetUnsupportedAndCloseReleasesOpenChunk() {
136+
// Given a scan positioned inside the first chunk
137+
Enumerator<Object[]> en = new VortexTable(file).scan(null, List.of(), null).enumerator();
138+
assertThat(en.moveNext()).isTrue();
139+
140+
// When / Then — reset is unsupported, and close must release the still-open chunk cleanly
141+
assertThatThrownBy(en::reset).isInstanceOf(UnsupportedOperationException.class);
142+
en.close();
143+
}
144+
145+
@Nested
146+
class MissingFile {
147+
148+
// A path that does not exist makes VortexReader.open throw IOException, which every entry
149+
// point wraps as UncheckedIOException — these are the file-open failure branches.
150+
private final VortexTable table = new VortexTable(tmp.resolve("does-not-exist.vortex"));
151+
152+
@Test
153+
void getRowType_wrapsOpenFailure() {
154+
// When / Then
155+
assertThatThrownBy(() -> table.getRowType(new JavaTypeFactoryImpl()))
156+
.isInstanceOf(java.io.UncheckedIOException.class);
157+
}
158+
159+
@Test
160+
void statsOf_wrapsOpenFailure() {
161+
// When / Then
162+
assertThatThrownBy(() -> table.statsOf("i64"))
163+
.isInstanceOf(java.io.UncheckedIOException.class);
164+
}
165+
166+
@Test
167+
void totalRows_wrapsOpenFailure() {
168+
// When / Then
169+
assertThatThrownBy(table::totalRows).isInstanceOf(java.io.UncheckedIOException.class);
170+
}
171+
172+
@Test
173+
void scan_wrapsOpenFailure() {
174+
// When / Then — the enumerator constructor opens the reader and wraps the failure
175+
assertThatThrownBy(() -> table.scan(null, List.of(), null).enumerator())
176+
.isInstanceOf(java.io.UncheckedIOException.class);
177+
}
178+
}
179+
134180
@Nested
135181
class Schema {
136182

@@ -187,5 +233,38 @@ void floatColumn_sumIsDouble() throws Exception {
187233
assertThat(s.avg()).isEqualTo(2.25);
188234
}
189235
}
236+
237+
@Test
238+
void narrowIntColumn_sumsViaIntArrayIntoLong() throws Exception {
239+
// Given / When — i32 decodes to IntArray, summed into a long (exact)
240+
try (VortexReader reader = VortexReader.open(file, registry())) {
241+
VortexAggregates.Summary s = VortexAggregates.of(reader, "i32");
242+
243+
// Then
244+
assertThat(s.sum()).isInstanceOf(Long.class).isEqualTo(600L); // 100+200+300
245+
}
246+
}
247+
248+
@Test
249+
void floatColumn_sumsViaFloatArrayIntoDouble() throws Exception {
250+
// Given / When — f32 decodes to FloatArray, accumulated into a double
251+
try (VortexReader reader = VortexReader.open(file, registry())) {
252+
VortexAggregates.Summary s = VortexAggregates.of(reader, "f32");
253+
254+
// Then
255+
assertThat(s.sum()).isInstanceOf(Double.class);
256+
assertThat(s.sum().doubleValue()).isEqualTo(7.5); // 1.5+2.5+3.5
257+
}
258+
}
259+
260+
@Test
261+
void nonNumericColumn_throws() throws Exception {
262+
// Given / When / Then — a UTF8 column has no numeric array branch
263+
try (VortexReader reader = VortexReader.open(file, registry())) {
264+
assertThatThrownBy(() -> VortexAggregates.of(reader, "s"))
265+
.isInstanceOf(IllegalArgumentException.class)
266+
.hasMessageContaining("not a numeric column");
267+
}
268+
}
190269
}
191270
}

0 commit comments

Comments
 (0)