From 5f90e9ee6902a1a2c886ce549e6b952ef2f591d8 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Fri, 19 Jun 2026 18:37:25 +0200 Subject: [PATCH] test(parquet): unit-test ParquetImporter with mapping tests + offline fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The importer was only exercised by network-downloading integration tests. Add fast, offline coverage in the parquet module itself: - Deterministic type-mapping tests (mapDType / filterColumns made package-private) covering every supported physical type, all INT32 widths, signed/unsigned INT64, timestamp units, string-like BYTE_ARRAY annotations, and the unsupported-type / unknown-column error paths. - A committed 11 KB fixture (delta_encoding_optional_column.parquet from apache/parquet-testing, Apache-2.0) drives end-to-end import → VortexReader assertions: schema, row count, column values, projection, and multi-chunk split. Adds vortex-reader + aircompressor-v3 as test-scope deps so the imported Vortex output can be decoded in-module. Co-Authored-By: Claude Opus 4.8 --- parquet/pom.xml | 12 + .../dfa1/vortex/parquet/ParquetImporter.java | 4 +- .../vortex/parquet/ParquetImporterTest.java | 296 ++++++++++++++++++ .../delta_encoding_optional_column.parquet | Bin 0 -> 11567 bytes 4 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 parquet/src/test/java/io/github/dfa1/vortex/parquet/ParquetImporterTest.java create mode 100644 parquet/src/test/resources/fixtures/delta_encoding_optional_column.parquet diff --git a/parquet/pom.xml b/parquet/pom.xml index b848ece6..aa2c64e6 100644 --- a/parquet/pom.xml +++ b/parquet/pom.xml @@ -29,6 +29,18 @@ zstd-jni + + + io.github.dfa1.vortex + vortex-reader + test + + + + io.airlift + aircompressor-v3 + test + org.junit.jupiter junit-jupiter diff --git a/parquet/src/main/java/io/github/dfa1/vortex/parquet/ParquetImporter.java b/parquet/src/main/java/io/github/dfa1/vortex/parquet/ParquetImporter.java index 4b7ed2b7..4a4384f9 100644 --- a/parquet/src/main/java/io/github/dfa1/vortex/parquet/ParquetImporter.java +++ b/parquet/src/main/java/io/github/dfa1/vortex/parquet/ParquetImporter.java @@ -115,7 +115,7 @@ public static void importParquet(Path parquetPath, Path vortexPath, ImportOption } } - private static DType mapDType(ColumnSchema col) { + static DType mapDType(ColumnSchema col) { boolean nullable = col.repetitionType() == RepetitionType.OPTIONAL; return switch (col.type()) { case BOOLEAN -> new DType.Bool(nullable); @@ -239,7 +239,7 @@ private static Map buildChunk(List columns, List filterColumns(List all, List names) { + static List filterColumns(List all, List names) { List result = new ArrayList<>(names.size()); for (String name : names) { boolean found = false; diff --git a/parquet/src/test/java/io/github/dfa1/vortex/parquet/ParquetImporterTest.java b/parquet/src/test/java/io/github/dfa1/vortex/parquet/ParquetImporterTest.java new file mode 100644 index 00000000..35ec9f74 --- /dev/null +++ b/parquet/src/test/java/io/github/dfa1/vortex/parquet/ParquetImporterTest.java @@ -0,0 +1,296 @@ +package io.github.dfa1.vortex.parquet; + +import dev.hardwood.metadata.FieldPath; +import dev.hardwood.metadata.LogicalType; +import dev.hardwood.metadata.PhysicalType; +import dev.hardwood.metadata.RepetitionType; +import dev.hardwood.schema.ColumnSchema; +import io.github.dfa1.vortex.core.DType; +import io.github.dfa1.vortex.core.PType; +import io.github.dfa1.vortex.reader.Chunk; +import io.github.dfa1.vortex.reader.ScanIterator; +import io.github.dfa1.vortex.reader.ScanOptions; +import io.github.dfa1.vortex.reader.VortexReader; +import io.github.dfa1.vortex.reader.array.LongArray; +import io.github.dfa1.vortex.reader.array.VarBinArray; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ParquetImporterTest { + + private static ColumnSchema col(String name, PhysicalType type, RepetitionType rep, LogicalType logical) { + return new ColumnSchema(FieldPath.of(name), type, rep, null, 0, 0, 0, logical); + } + + @Nested + class TypeMapping { + + @Test + void boolean_mapsToBool_carryingNullability() { + // Given / When / Then — REQUIRED is non-null, OPTIONAL is nullable + assertThat(ParquetImporter.mapDType(col("b", PhysicalType.BOOLEAN, RepetitionType.REQUIRED, null))) + .isEqualTo(new DType.Bool(false)); + assertThat(ParquetImporter.mapDType(col("b", PhysicalType.BOOLEAN, RepetitionType.OPTIONAL, null))) + .isEqualTo(new DType.Bool(true)); + } + + @Test + void int32_withoutAnnotation_mapsToI32() { + // When + DType result = ParquetImporter.mapDType(col("i", PhysicalType.INT32, RepetitionType.REQUIRED, null)); + + // Then + assertThat(result).isEqualTo(new DType.Primitive(PType.I32, false)); + } + + @ParameterizedTest + @CsvSource({ + "8, true, I8", + "8, false, U8", + "16, true, I16", + "16, false, U16", + "32, true, I32", + "32, false, U32", + }) + void int32_withIntAnnotation_mapsToSizedPType(int bitWidth, boolean signed, PType expected) { + // Given — INT32 carrying a width/sign annotation selects the narrow PType + ColumnSchema schema = col("i", PhysicalType.INT32, RepetitionType.REQUIRED, + new LogicalType.IntType(bitWidth, signed)); + + // When + DType result = ParquetImporter.mapDType(schema); + + // Then + assertThat(result).isEqualTo(new DType.Primitive(expected, false)); + } + + @Test + void int64_signedAndUnsigned_mapToI64AndU64() { + // Given / When / Then + assertThat(ParquetImporter.mapDType(col("l", PhysicalType.INT64, RepetitionType.REQUIRED, null))) + .isEqualTo(new DType.Primitive(PType.I64, false)); + assertThat(ParquetImporter.mapDType(col("l", PhysicalType.INT64, RepetitionType.REQUIRED, + new LogicalType.IntType(64, true)))).isEqualTo(new DType.Primitive(PType.I64, false)); + assertThat(ParquetImporter.mapDType(col("l", PhysicalType.INT64, RepetitionType.REQUIRED, + new LogicalType.IntType(64, false)))).isEqualTo(new DType.Primitive(PType.U64, false)); + } + + @ParameterizedTest + @CsvSource({"MILLIS", "MICROS", "NANOS"}) + void int64_timestamp_mapsToTimestampExtensionOverI64(LogicalType.TimeUnit unit) { + // Given — a TIMESTAMP-annotated INT64 + ColumnSchema schema = col("ts", PhysicalType.INT64, RepetitionType.OPTIONAL, + new LogicalType.TimestampType(true, unit)); + + // When + DType result = ParquetImporter.mapDType(schema); + + // Then — vortex.timestamp extension over nullable I64 storage + assertThat(result).isInstanceOf(DType.Extension.class); + DType.Extension ext = (DType.Extension) result; + assertThat(ext.extensionId()).isEqualTo("vortex.timestamp"); + assertThat(ext.storageDType()).isEqualTo(new DType.Primitive(PType.I64, true)); + assertThat(ext.nullable()).isTrue(); + } + + @Test + void float_and_double_mapToF32AndF64() { + // Given / When / Then + assertThat(ParquetImporter.mapDType(col("f", PhysicalType.FLOAT, RepetitionType.REQUIRED, null))) + .isEqualTo(new DType.Primitive(PType.F32, false)); + assertThat(ParquetImporter.mapDType(col("d", PhysicalType.DOUBLE, RepetitionType.REQUIRED, null))) + .isEqualTo(new DType.Primitive(PType.F64, false)); + } + + @Test + void byteArray_stringLikeAnnotations_mapToUtf8() { + // Given — STRING / ENUM / JSON are all logical strings + for (LogicalType logical : List.of(new LogicalType.StringType(), + new LogicalType.EnumType(), new LogicalType.JsonType())) { + // When + DType result = ParquetImporter.mapDType( + col("s", PhysicalType.BYTE_ARRAY, RepetitionType.OPTIONAL, logical)); + + // Then + assertThat(result).as("logical %s", logical).isEqualTo(new DType.Utf8(true)); + } + } + + @Test + void byteArray_withoutStringAnnotation_throws() { + // Given — raw BYTE_ARRAY with no string logical type is unsupported + ColumnSchema schema = col("blob", PhysicalType.BYTE_ARRAY, RepetitionType.REQUIRED, null); + + // When / Then + assertThatThrownBy(() -> ParquetImporter.mapDType(schema)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("blob"); + } + + @ParameterizedTest + @CsvSource({"INT96", "FIXED_LEN_BYTE_ARRAY"}) + void unsupportedPhysicalType_throws(PhysicalType type) { + // Given + ColumnSchema schema = col("x", type, RepetitionType.REQUIRED, null); + + // When / Then + assertThatThrownBy(() -> ParquetImporter.mapDType(schema)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("unsupported Parquet physical type"); + } + } + + @Nested + class FilterColumns { + + @Test + void keepsRequestedColumnsInRequestedOrder() { + // Given — schema a, b, c; request c, a + List all = List.of( + col("a", PhysicalType.INT32, RepetitionType.REQUIRED, null), + col("b", PhysicalType.INT32, RepetitionType.REQUIRED, null), + col("c", PhysicalType.INT32, RepetitionType.REQUIRED, null)); + + // When + List result = ParquetImporter.filterColumns(all, List.of("c", "a")); + + // Then — projection order wins over schema order + assertThat(result).extracting(ColumnSchema::name).containsExactly("c", "a"); + } + + @Test + void unknownColumn_throws() { + // Given + List all = List.of(col("a", PhysicalType.INT32, RepetitionType.REQUIRED, null)); + + // When / Then + assertThatThrownBy(() -> ParquetImporter.filterColumns(all, List.of("missing"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("missing"); + } + } + + @Nested + class Import { + + @Test + void importsFixture_schemaAndRowCount(@TempDir Path tmp) throws Exception { + // Given — 100-row TPC-DS customer fixture (INT64 + STRING, all nullable) + Path vortex = tmp.resolve("out.vortex"); + + // When + ParquetImporter.importParquet(fixture(), vortex); + + // Then + try (VortexReader reader = VortexReader.open(vortex)) { + assertThat(reader.dtype()).isInstanceOf(DType.Struct.class); + DType.Struct schema = (DType.Struct) reader.dtype(); + assertThat(schema.fieldNames()).contains("c_customer_sk", "c_first_name"); + assertThat(countRows(reader)).isEqualTo(100L); + } + } + + @Test + void importsFixture_columnValuesRoundTrip(@TempDir Path tmp) throws Exception { + // Given + Path vortex = tmp.resolve("out.vortex"); + + // When + ParquetImporter.importParquet(fixture(), vortex); + + // Then — known first three values of each column + try (VortexReader reader = VortexReader.open(vortex); + ScanIterator iter = reader.scan(ScanOptions.all())) { + assertThat(iter.hasNext()).isTrue(); + try (Chunk first = iter.next()) { + LongArray sk = first.column("c_customer_sk"); + assertThat(sk.getLong(0)).isEqualTo(100L); + assertThat(sk.getLong(1)).isEqualTo(99L); + assertThat(sk.getLong(2)).isEqualTo(98L); + + VarBinArray name = first.column("c_first_name"); + assertThat(name.getString(0)).isEqualTo("Jeannette"); + assertThat(name.getString(1)).isEqualTo("Austin"); + assertThat(name.getString(2)).isEqualTo("David"); + } + } + } + + @Test + void projection_importsOnlyRequestedColumns(@TempDir Path tmp) throws Exception { + // Given — project a single column out of the fixture + Path vortex = tmp.resolve("out.vortex"); + ImportOptions options = ImportOptions.defaults().withColumns(List.of("c_customer_sk")); + + // When + ParquetImporter.importParquet(fixture(), vortex, options); + + // Then — only the projected column survives + try (VortexReader reader = VortexReader.open(vortex)) { + DType.Struct schema = (DType.Struct) reader.dtype(); + assertThat(schema.fieldNames()).containsExactly("c_customer_sk"); + assertThat(countRows(reader)).isEqualTo(100L); + } + } + + @Test + void smallChunkSize_splitsIntoMultipleChunks(@TempDir Path tmp) throws Exception { + // Given — chunk size 30 forces 4 chunks over 100 rows (exercises trim + chunk flush) + Path vortex = tmp.resolve("out.vortex"); + ImportOptions options = ImportOptions.defaults().withChunkSize(30); + + // When + ParquetImporter.importParquet(fixture(), vortex, options); + + // Then — row count is preserved across the chunk boundaries + try (VortexReader reader = VortexReader.open(vortex); + ScanIterator iter = reader.scan(ScanOptions.all())) { + long chunks = 0; + long rows = 0; + while (iter.hasNext()) { + try (Chunk c = iter.next()) { + chunks++; + rows += c.rowCount(); + } + } + assertThat(rows).isEqualTo(100L); + assertThat(chunks).isGreaterThan(1L); + } + } + + @Test + void projection_unknownColumn_throws(@TempDir Path tmp) { + // Given + Path vortex = tmp.resolve("out.vortex"); + ImportOptions options = ImportOptions.defaults().withColumns(List.of("does_not_exist")); + + // When / Then + assertThatThrownBy(() -> ParquetImporter.importParquet(fixture(), vortex, options)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does_not_exist"); + } + } + + private static Path fixture() throws Exception { + return Path.of(ParquetImporterTest.class + .getResource("/fixtures/delta_encoding_optional_column.parquet").toURI()); + } + + private static long countRows(VortexReader reader) { + AtomicLong total = new AtomicLong(); + try (ScanIterator iter = reader.scan(ScanOptions.all())) { + iter.forEachRemaining(c -> total.addAndGet(c.rowCount())); + } + return total.get(); + } +} diff --git a/parquet/src/test/resources/fixtures/delta_encoding_optional_column.parquet b/parquet/src/test/resources/fixtures/delta_encoding_optional_column.parquet new file mode 100644 index 0000000000000000000000000000000000000000..3b06caae2a4615ea3bd6d9b7b1f58dacdc71da16 GIT binary patch literal 11567 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 P?|Z50;awo8_G