From 08496cc11ab84f8ec7bf95f8e71a9e9a5f5baaa1 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Thu, 25 Jun 2026 20:42:29 +0200 Subject: [PATCH 1/2] test(integration): finish OHLC test-data dedup onto OhlcData.Batch Drop the integration OhlcGenerator/OhlcBatch adapter and switch the JNI round-trip callers (FileSizeComparisonIntegrationTest, JavaWritesRustReadsIntegrationTest) to core.testing.OhlcData.Batch directly, so the OHLC shape is single-sourced rather than mirrored. Verified against the JNI integration suite (OHLC round-trips + file-size comparison, native libs present). Co-Authored-By: Claude Opus 4.8 --- TODO.md | 10 ----- .../FileSizeComparisonIntegrationTest.java | 39 ++++++++++--------- .../JavaWritesRustReadsIntegrationTest.java | 15 +++---- .../vortex/integration/OhlcGenerator.java | 30 -------------- 4 files changed, 28 insertions(+), 66 deletions(-) delete mode 100644 integration/src/test/java/io/github/dfa1/vortex/integration/OhlcGenerator.java diff --git a/TODO.md b/TODO.md index 2068b6e9..5cf4c394 100644 --- a/TODO.md +++ b/TODO.md @@ -6,16 +6,6 @@ - [ ] Create website - build something like hardwood.dev but for vortex files -## Testing - -- [ ] **Finish OHLC test-data dedup** — the random-walk generator is single-sourced in - `core.testing.OhlcData` (core test-jar). `integration`'s `OhlcGenerator` is now a thin adapter - that maps `OhlcData.Batch` to its own `OhlcBatch` (`symbols`/`dates` field names) only to avoid - churning the JNI/Arrow callers. Align fully: drop `OhlcGenerator`/`OhlcBatch`, switch the - integration callers (`FileSizeComparisonIntegrationTest`, `JavaWritesRustReadsIntegrationTest`) - to `OhlcData.Batch` directly so there is one shape, not two. Verify with the JNI integration - suite (needs vortex-jni native libs). - ## Performance - [ ] **Benchmark publishing** — drop CI workflow, add `bench-publish` script; see [ADR-0006](docs/adr/0006-benchmark-publishing.md). diff --git a/integration/src/test/java/io/github/dfa1/vortex/integration/FileSizeComparisonIntegrationTest.java b/integration/src/test/java/io/github/dfa1/vortex/integration/FileSizeComparisonIntegrationTest.java index 931fc92f..6ad12f67 100644 --- a/integration/src/test/java/io/github/dfa1/vortex/integration/FileSizeComparisonIntegrationTest.java +++ b/integration/src/test/java/io/github/dfa1/vortex/integration/FileSizeComparisonIntegrationTest.java @@ -4,6 +4,7 @@ import dev.vortex.arrow.ArrowAllocation; import dev.vortex.jni.NativeLoader; import io.github.dfa1.vortex.core.model.DType; +import io.github.dfa1.vortex.core.testing.OhlcData; import io.github.dfa1.vortex.reader.array.LongArray; import io.github.dfa1.vortex.reader.ReadRegistry; import io.github.dfa1.vortex.reader.VortexReader; @@ -83,13 +84,13 @@ class FileSizeComparisonIntegrationTest { // ── Writers ─────────────────────────────────────────────────────────────── - private static Path writeCsv(Path dir, List batches) throws IOException { + private static Path writeCsv(Path dir, List batches) throws IOException { Path file = dir.resolve("ohlc.csv"); try (BufferedWriter csv = Files.newBufferedWriter(file)) { csv.write("symbol,date,open,high,low,close,volume\n"); - for (OhlcGenerator.OhlcBatch b : batches) { - for (int i = 0; i < b.dates().length; i++) { - csv.write(b.symbols()[i] + "," + LocalDate.ofEpochDay(b.dates()[i]) + "," + for (OhlcData.Batch b : batches) { + for (int i = 0; i < b.date().length; i++) { + csv.write(b.symbol()[i] + "," + LocalDate.ofEpochDay(b.date()[i]) + "," + b.open()[i] + "," + b.high()[i] + "," + b.low()[i] + "," + b.close()[i] + "," + b.volume()[i] + "\n"); } @@ -98,28 +99,28 @@ private static Path writeCsv(Path dir, List batches) th return file; } - private static Path writeJava(Path dir, List batches) throws IOException { + private static Path writeJava(Path dir, List batches) throws IOException { return writeJava(dir, "ohlc-java.vtx", WriteOptions.cascading(3), batches); } - private static Path writeJavaZstd(Path dir, List batches) throws IOException { + private static Path writeJavaZstd(Path dir, List batches) throws IOException { return writeJava(dir, "ohlc-java-zstd.vtx", WriteOptions.cascading(3).withZstd(true), batches); } private static Path writeJavaGlobalDict(Path dir, boolean globalDict, - List batches) throws IOException { + List batches) throws IOException { String name = globalDict ? "ohlc-java-globaldict.vtx" : "ohlc-java-perchunkdict.vtx"; return writeJava(dir, name, WriteOptions.cascading(3).withGlobalDict(globalDict), batches); } private static Path writeJava(Path dir, String filename, WriteOptions opts, - List batches) throws IOException { + List batches) throws IOException { Path file = dir.resolve(filename); try (FileChannel ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE); VortexWriter writer = VortexWriter.create(ch, JAVA_SCHEMA, opts)) { - for (OhlcGenerator.OhlcBatch b : batches) { + for (OhlcData.Batch b : batches) { writer.writeChunk(Map.of( - "symbol", b.symbols(), "date", b.dates(), + "symbol", b.symbol(), "date", b.date(), "open", b.open(), "high", b.high(), "low", b.low(), "close", b.close(), "volume", b.volume())); } @@ -127,12 +128,12 @@ private static Path writeJava(Path dir, String filename, WriteOptions opts, return file; } - private static Path writeJni(Path dir, List batches) throws IOException { + private static Path writeJni(Path dir, List batches) throws IOException { Path file = dir.resolve("ohlc-jni.vtx"); String uri = file.toAbsolutePath().toUri().toString(); try (dev.vortex.api.VortexWriter writer = dev.vortex.api.VortexWriter.create( SESSION, uri, JNI_SCHEMA, new HashMap<>(), ALLOCATOR)) { - for (OhlcGenerator.OhlcBatch b : batches) { + for (OhlcData.Batch b : batches) { try (VectorSchemaRoot root = VectorSchemaRoot.create(JNI_SCHEMA, ALLOCATOR)) { VarCharVector symbolVec = (VarCharVector) root.getVector("symbol"); DateDayVector dateVec = (DateDayVector) root.getVector("date"); @@ -142,7 +143,7 @@ private static Path writeJni(Path dir, List batches) th Float8Vector closeVec = (Float8Vector) root.getVector("close"); BigIntVector volVec = (BigIntVector) root.getVector("volume"); - int n = b.dates().length; + int n = b.date().length; symbolVec.allocateNew(); dateVec.allocateNew(n); openVec.allocateNew(n); @@ -152,8 +153,8 @@ private static Path writeJni(Path dir, List batches) th volVec.allocateNew(n); for (int i = 0; i < n; i++) { - symbolVec.setSafe(i, b.symbols()[i].getBytes(StandardCharsets.UTF_8)); - dateVec.setSafe(i, b.dates()[i]); + symbolVec.setSafe(i, b.symbol()[i].getBytes(StandardCharsets.UTF_8)); + dateVec.setSafe(i, b.date()[i]); openVec.setSafe(i, b.open()[i]); highVec.setSafe(i, b.high()[i]); lowVec.setSafe(i, b.low()[i]); @@ -178,7 +179,7 @@ private static Path writeJni(Path dir, List batches) th @Test void fileSizeComparison(@TempDir Path tmp) throws IOException { // Given - List batches = OhlcGenerator.generate(TOTAL_ROWS, BATCH_SIZE); + List batches = OhlcData.generate(TOTAL_ROWS, BATCH_SIZE); // When Path csvFile = writeCsv(tmp, batches); @@ -217,7 +218,7 @@ void fileSizeComparison(@TempDir Path tmp) throws IOException { @Test void withZstd_smallerFile_and_readable(@TempDir Path tmp) throws IOException { // Given - List batches = OhlcGenerator.generate(TOTAL_ROWS, BATCH_SIZE); + List batches = OhlcData.generate(TOTAL_ROWS, BATCH_SIZE); // When Path noZstd = writeJava(tmp, batches); @@ -246,12 +247,12 @@ void withZstd_smallerFile_and_readable(@TempDir Path tmp) throws IOException { @Test void globalDict_multiSymbol_smallerThanPerChunkDict(@TempDir Path tmp) throws IOException { - // Given — multi-symbol OHLC (30 tickers from OhlcGenerator) split across multiple chunks + // Given — multi-symbol OHLC (30 tickers from OhlcData) split across multiple chunks // so per-chunk-dict mode emits the same ticker dictionary in every chunk while global-dict // mode emits it once. Small chunkSize amplifies the saving. int rows = 200_000; int batch = 20_000; - List batches = OhlcGenerator.generate(rows, batch); + List batches = OhlcData.generate(rows, batch); // When Path globalDictFile = writeJavaGlobalDict(tmp, true, batches); diff --git a/integration/src/test/java/io/github/dfa1/vortex/integration/JavaWritesRustReadsIntegrationTest.java b/integration/src/test/java/io/github/dfa1/vortex/integration/JavaWritesRustReadsIntegrationTest.java index 9f958efc..3a1a5e40 100644 --- a/integration/src/test/java/io/github/dfa1/vortex/integration/JavaWritesRustReadsIntegrationTest.java +++ b/integration/src/test/java/io/github/dfa1/vortex/integration/JavaWritesRustReadsIntegrationTest.java @@ -10,6 +10,7 @@ import dev.vortex.jni.NativeLoader; import io.github.dfa1.vortex.core.model.DType; import io.github.dfa1.vortex.core.model.PType; +import io.github.dfa1.vortex.core.testing.OhlcData; import io.github.dfa1.vortex.writer.encode.BoolEncodingEncoder; import io.github.dfa1.vortex.writer.encode.PcoEncodingEncoder; import io.github.dfa1.vortex.writer.encode.ByteBoolEncodingEncoder; @@ -428,14 +429,14 @@ private static void deleteDir(Path dir) throws IOException { void javaWriter_jniReader_cascading_ohlc(@TempDir Path tmp) throws IOException { // Given — OHLC data written with cascading(3): exercises ALP→FOR→bitpacked chain Path file = tmp.resolve("java_cascade_ohlc.vtx"); - List batches = OhlcGenerator.generate(10_000, 1_000); + List batches = OhlcData.generate(10_000, 1_000); try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE); var sut = VortexWriter.create(ch, OHLC_SCHEMA, WriteOptions.cascading(3))) { - for (OhlcGenerator.OhlcBatch b : batches) { + for (OhlcData.Batch b : batches) { sut.writeChunk(Map.of( - "date", b.dates(), - "symbol", b.symbols(), + "date", b.date(), + "symbol", b.symbol(), "open", b.open(), "high", b.high(), "low", b.low(), @@ -708,12 +709,12 @@ void javaWriter_jniReader_monotonic_i64_cascading(@TempDir Path tmp) throws IOEx void javaWriter_jniReader_cascading_ohlc_columnProjection(@TempDir Path tmp) throws IOException { // Given Path file = tmp.resolve("java_cascade_proj.vtx"); - List batches = OhlcGenerator.generate(2_000, 1_000); + List batches = OhlcData.generate(2_000, 1_000); try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE); var sut = VortexWriter.create(ch, OHLC_SCHEMA, WriteOptions.cascading(3))) { - for (OhlcGenerator.OhlcBatch b : batches) { + for (OhlcData.Batch b : batches) { sut.writeChunk(Map.of( - "date", b.dates(), "symbol", b.symbols(), + "date", b.date(), "symbol", b.symbol(), "open", b.open(), "high", b.high(), "low", b.low(), "close", b.close(), "volume", b.volume())); } diff --git a/integration/src/test/java/io/github/dfa1/vortex/integration/OhlcGenerator.java b/integration/src/test/java/io/github/dfa1/vortex/integration/OhlcGenerator.java deleted file mode 100644 index 6c317d3a..00000000 --- a/integration/src/test/java/io/github/dfa1/vortex/integration/OhlcGenerator.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.github.dfa1.vortex.integration; - -import io.github.dfa1.vortex.core.testing.OhlcData; - -import java.util.List; - -/// Thin adapter over the shared [OhlcData] generator (core test-jar), mapping each batch to the -/// field order the integration writers expect (`symbols`/`dates` first). The random-walk lives in -/// [OhlcData]; this only adapts the shape. -final class OhlcGenerator { - - static final String[] TICKERS = OhlcData.TICKERS; - - private OhlcGenerator() { - } - - static List generate(int totalRows, int batchSize) { - return generate(totalRows, batchSize, 42L); - } - - static List generate(int totalRows, int batchSize, long seed) { - return OhlcData.generate(totalRows, batchSize, seed).stream() - .map(b -> new OhlcBatch(b.symbol(), b.date(), b.open(), b.high(), b.low(), b.close(), b.volume())) - .toList(); - } - - record OhlcBatch(String[] symbols, int[] dates, double[] open, double[] high, - double[] low, double[] close, long[] volume) { - } -} From f8cec2e9f9a723bfd69adbc973695695256a9671 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Thu, 25 Jun 2026 20:48:56 +0200 Subject: [PATCH 2/2] test(calcite): cover filter push-down + aggregate-rule branches (Sonar new-coverage gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../calcite/AggregateRuleBranchTest.java | 108 ++++++++++++++++++ .../vortex/calcite/FilterPushDownTest.java | 103 +++++++++++++++++ .../calcite/VortexAdapterCoverageTest.java | 79 +++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 calcite/src/test/java/io/github/dfa1/vortex/calcite/AggregateRuleBranchTest.java create mode 100644 calcite/src/test/java/io/github/dfa1/vortex/calcite/FilterPushDownTest.java diff --git a/calcite/src/test/java/io/github/dfa1/vortex/calcite/AggregateRuleBranchTest.java b/calcite/src/test/java/io/github/dfa1/vortex/calcite/AggregateRuleBranchTest.java new file mode 100644 index 00000000..752d4b50 --- /dev/null +++ b/calcite/src/test/java/io/github/dfa1/vortex/calcite/AggregateRuleBranchTest.java @@ -0,0 +1,108 @@ +package io.github.dfa1.vortex.calcite; + +import org.apache.calcite.avatica.util.Casing; +import org.apache.calcite.plan.RelOptUtil; +import org.apache.calcite.plan.hep.HepPlanner; +import org.apache.calcite.plan.hep.HepProgram; +import org.apache.calcite.plan.hep.HepProgramBuilder; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.schema.SchemaPlus; +import org.apache.calcite.sql.SqlNode; +import org.apache.calcite.sql.parser.SqlParser; +import org.apache.calcite.tools.FrameworkConfig; +import org.apache.calcite.tools.Frameworks; +import org.apache.calcite.tools.Planner; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/// Branch coverage for [VortexAggregatePushDownRule]: each query either rewrites to a single-row +/// `LogicalValues` (answerable from zone-map stats) or is left with its `Aggregate`/`TableScan` +/// intact (the rule must abandon — wrong stats would give a wrong answer). +class AggregateRuleBranchTest { + + private static final int ROWS = 30_000; + private static final int CHUNK = 10_000; + + @TempDir + static Path tmp; + private static SchemaPlus schema; + + @BeforeAll + static void writeFile() throws Exception { + Path file = tmp.resolve("ohlc.vortex"); + OhlcGenerator.write(file, ROWS, CHUNK); + SchemaPlus root = Frameworks.createRootSchema(true); + schema = root.add("vtx", new VortexSchema(Map.of("ohlc", file))); + } + + @Test + void countStar_noProjectPath_rewritesToValues() { + // Given a bare COUNT(*) — Aggregate(TableScan), the NO_PROJECT operand with project == null + // When / Then — answered from the footer row count + assertThat(optimize("select count(*) from ohlc")).contains("LogicalValues").doesNotContain("Aggregate"); + } + + @Test + void countColumn_withProjectPath_rewritesToValues() { + // Given COUNT(volume) — Aggregate(Project(TableScan)); COUNT(col) = rows − nulls from stats + // When / Then + assertThat(optimize("select count(volume) from ohlc")).contains("LogicalValues").doesNotContain("Aggregate"); + } + + @Test + void sum_hasNoZoneStat_abandonsRewrite() { + // Given SUM(volume) — no SUM zone statistic exists, so evaluate() yields null and the whole + // rewrite is abandoned + // When / Then — the Aggregate survives for the normal scan path + assertThat(optimize("select sum(volume) from ohlc")).contains("Aggregate"); + } + + @Test + void minOnNonNumericColumn_abandonsRewrite() { + // Given MIN(symbol) over a VARCHAR column — the stat value is non-numeric, numericLiteral + // returns null, the rewrite is abandoned + // When / Then + assertThat(optimize("select min(symbol) from ohlc")).contains("Aggregate"); + } + + @Test + void minOverComputedExpression_abandonsRewrite() { + // Given MIN(low + 1) — the projected input is an expression, not a bare column ref, so + // resolveColumn returns null and the rewrite is abandoned + // When / Then + assertThat(optimize("select min(low + 1) from ohlc")).contains("Aggregate"); + } + + @Test + void groupedAggregate_isLeftUntouched() { + // Given a GROUP BY — group count != 0, the rule returns immediately + // When / Then — Aggregate stays + assertThat(optimize("select symbol, max(high) from ohlc group by symbol")).contains("Aggregate"); + } + + private static String optimize(String sql) { + FrameworkConfig config = Frameworks.newConfigBuilder() + .defaultSchema(schema) + .parserConfig(SqlParser.config().withUnquotedCasing(Casing.UNCHANGED)) + .build(); + Planner planner = Frameworks.getPlanner(config); + try { + SqlNode parsed = planner.parse(sql); + RelNode logical = planner.rel(planner.validate(parsed)).rel; + HepProgram program = new HepProgramBuilder() + .addRuleCollection(VortexAggregatePushDownRule.RULES) + .build(); + HepPlanner hep = new HepPlanner(program); + hep.setRoot(logical); + return RelOptUtil.toString(hep.findBestExp()); + } catch (Exception e) { + throw new IllegalStateException("planning failed for: " + sql, e); + } + } +} diff --git a/calcite/src/test/java/io/github/dfa1/vortex/calcite/FilterPushDownTest.java b/calcite/src/test/java/io/github/dfa1/vortex/calcite/FilterPushDownTest.java new file mode 100644 index 00000000..59b3ce06 --- /dev/null +++ b/calcite/src/test/java/io/github/dfa1/vortex/calcite/FilterPushDownTest.java @@ -0,0 +1,103 @@ +package io.github.dfa1.vortex.calcite; + +import io.github.dfa1.vortex.core.model.DType; +import io.github.dfa1.vortex.writer.VortexWriter; +import io.github.dfa1.vortex.writer.WriteOptions; + +import org.apache.calcite.jdbc.CalciteConnection; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.nio.channels.FileChannel; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.Map; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +/// Drives [VortexTable]'s filter push-down (`scan(root, filters, projects)`) through a real Calcite +/// JDBC planner: every supported `WHERE` comparison must be translated into a zone-map [RowFilter] +/// (so the scan can prune chunks) while Calcite still returns the exact rows. Predicates the +/// translator does not understand must be left untouched, not break the query. +class FilterPushDownTest { + + // Two chunks of three rows so the pushed RowFilter has chunks to (potentially) prune. + private static final DType.Struct SCHEMA = DType.structBuilder() + .field("i64", DType.I64) + .field("i32", DType.I32) + .field("f64", DType.F64) + .field("s", DType.UTF8) + .field("b", DType.BOOL) + .build(); + + @TempDir + static Path tmp; + private static Path file; + + @BeforeAll + static void write() throws Exception { + file = tmp.resolve("filter.vortex"); + try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + var w = VortexWriter.create(ch, SCHEMA, WriteOptions.defaults())) { + w.writeChunk(Map.of( + "i64", new long[]{1000L, 2000L, 3000L}, + "i32", new int[]{100, 200, 300}, + "f64", new double[]{1.0, 2.0, 3.0}, + "s", new String[]{"a", "b", "c"}, + "b", new boolean[]{true, false, true})); + w.writeChunk(Map.of( + "i64", new long[]{4000L, 5000L, 6000L}, + "i32", new int[]{400, 500, 600}, + "f64", new double[]{4.0, 5.0, 6.0}, + "s", new String[]{"d", "e", "f"}, + "b", new boolean[]{false, true, false})); + } + } + + @ParameterizedTest(name = "[{index}] WHERE {0} -> {1} rows") + @CsvSource({ + // every comparison kind the RowFilter translator supports, over a Long column + "i64 = 1000, 1", // EQUALS -> RowFilter.Eq + "i64 <> 1000, 5", // NOT_EQUALS -> RowFilter.Neq + "i64 < 3000, 2", // LESS_THAN -> RowFilter.Lt + "i64 <= 3000, 3", // LESS_THAN_EQ -> RowFilter.Lte + "i64 > 4000, 2", // GREATER_THAN -> RowFilter.Gt + "i64 >= 4000, 3", // GREATER_EQ -> RowFilter.Gte + "s = 'a', 1", // Utf8 literal coercion + "f64 > 3.0, 3", // floating literal coercion + // multiple conjuncts arrive as separate filters -> RowFilter.and over the list + "i64 > 1000 AND i32 < 600, 4", + // bare boolean ref is not a RexCall -> not pushed, query still exact + "b, 3", + // comparison on a BOOLEAN column has no zone-map coercion -> not pushed, still exact + "b = true, 3" + }) + void whereClauseIsPushedAndRowsStayExact(String where, int expected) throws Exception { + // Given a Calcite JDBC connection over the Vortex file + Properties info = new Properties(); + info.setProperty("lex", "JAVA"); + try (Connection conn = DriverManager.getConnection("jdbc:calcite:", info)) { + conn.unwrap(CalciteConnection.class).getRootSchema() + .add("vtx", new VortexSchema(Map.of("data", file))); + + // When the filtered query runs (Calcite pushes the predicate into VortexTable.scan) + int rows = 0; + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery("select i64 from vtx.data where " + where)) { + while (rs.next()) { + rows++; + } + } + + // Then the row count is exact regardless of whether the predicate was pushed + assertThat(rows).isEqualTo(expected); + } + } +} diff --git a/calcite/src/test/java/io/github/dfa1/vortex/calcite/VortexAdapterCoverageTest.java b/calcite/src/test/java/io/github/dfa1/vortex/calcite/VortexAdapterCoverageTest.java index d654c438..b4772210 100644 --- a/calcite/src/test/java/io/github/dfa1/vortex/calcite/VortexAdapterCoverageTest.java +++ b/calcite/src/test/java/io/github/dfa1/vortex/calcite/VortexAdapterCoverageTest.java @@ -131,6 +131,52 @@ void table_totalRowsAndStats() { assertThat(table.statsOf("i64")).isNotNull(); } + @Test + void enumerator_resetUnsupportedAndCloseReleasesOpenChunk() { + // Given a scan positioned inside the first chunk + Enumerator en = new VortexTable(file).scan(null, List.of(), null).enumerator(); + assertThat(en.moveNext()).isTrue(); + + // When / Then — reset is unsupported, and close must release the still-open chunk cleanly + assertThatThrownBy(en::reset).isInstanceOf(UnsupportedOperationException.class); + en.close(); + } + + @Nested + class MissingFile { + + // A path that does not exist makes VortexReader.open throw IOException, which every entry + // point wraps as UncheckedIOException — these are the file-open failure branches. + private final VortexTable table = new VortexTable(tmp.resolve("does-not-exist.vortex")); + + @Test + void getRowType_wrapsOpenFailure() { + // When / Then + assertThatThrownBy(() -> table.getRowType(new JavaTypeFactoryImpl())) + .isInstanceOf(java.io.UncheckedIOException.class); + } + + @Test + void statsOf_wrapsOpenFailure() { + // When / Then + assertThatThrownBy(() -> table.statsOf("i64")) + .isInstanceOf(java.io.UncheckedIOException.class); + } + + @Test + void totalRows_wrapsOpenFailure() { + // When / Then + assertThatThrownBy(table::totalRows).isInstanceOf(java.io.UncheckedIOException.class); + } + + @Test + void scan_wrapsOpenFailure() { + // When / Then — the enumerator constructor opens the reader and wraps the failure + assertThatThrownBy(() -> table.scan(null, List.of(), null).enumerator()) + .isInstanceOf(java.io.UncheckedIOException.class); + } + } + @Nested class Schema { @@ -187,5 +233,38 @@ void floatColumn_sumIsDouble() throws Exception { assertThat(s.avg()).isEqualTo(2.25); } } + + @Test + void narrowIntColumn_sumsViaIntArrayIntoLong() throws Exception { + // Given / When — i32 decodes to IntArray, summed into a long (exact) + try (VortexReader reader = VortexReader.open(file, registry())) { + VortexAggregates.Summary s = VortexAggregates.of(reader, "i32"); + + // Then + assertThat(s.sum()).isInstanceOf(Long.class).isEqualTo(600L); // 100+200+300 + } + } + + @Test + void floatColumn_sumsViaFloatArrayIntoDouble() throws Exception { + // Given / When — f32 decodes to FloatArray, accumulated into a double + try (VortexReader reader = VortexReader.open(file, registry())) { + VortexAggregates.Summary s = VortexAggregates.of(reader, "f32"); + + // Then + assertThat(s.sum()).isInstanceOf(Double.class); + assertThat(s.sum().doubleValue()).isEqualTo(7.5); // 1.5+2.5+3.5 + } + } + + @Test + void nonNumericColumn_throws() throws Exception { + // Given / When / Then — a UTF8 column has no numeric array branch + try (VortexReader reader = VortexReader.open(file, registry())) { + assertThatThrownBy(() -> VortexAggregates.of(reader, "s")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a numeric column"); + } + } } }