diff --git a/java/vortex-jni/src/test/java/dev/vortex/api/DataSourceContractTest.java b/java/vortex-jni/src/test/java/dev/vortex/api/DataSourceContractTest.java new file mode 100644 index 00000000000..de169b0a324 --- /dev/null +++ b/java/vortex-jni/src/test/java/dev/vortex/api/DataSourceContractTest.java @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +package dev.vortex.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.vortex.arrow.ArrowAllocation; +import dev.vortex.io.NativeReadable; +import dev.vortex.jni.NativeLoader; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; +import org.apache.arrow.c.ArrowArray; +import org.apache.arrow.c.ArrowSchema; +import org.apache.arrow.c.Data; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.Schema; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Contract tests for {@link DataSource#open} and its precision-aware counts. + * + *

The argument checks in {@code open} are the guard on the JNI boundary: every one of them runs before the native + * call, so dropping one does not surface as an {@link IllegalArgumentException} but as a null or a negative length + * crossing into native code. They are asserted here by message so that a refactor which keeps the check but + * reclassifies the failure is still caught. + * + *

{@link DataSource.RowCount} and {@link DataSource.ByteSize} are two separate ladders over the same three + * precisions. Only {@code Exact} was covered before; {@code Unknown} losing its empty {@link OptionalLong} would let a + * caller read a non-empty file as zero rows. + */ +public final class DataSourceContractTest { + @TempDir + static Path tempDir; + + private static String vortexFile; + + @BeforeAll + public static void loadLibrary() { + NativeLoader.loadJni(); + } + + @BeforeAll + static void writeFixture() throws IOException { + vortexFile = tempDir.resolve("counts.vortex").toAbsolutePath().toUri().toString(); + BufferAllocator allocator = ArrowAllocation.rootAllocator(); + Schema schema = new Schema(List.of(Field.notNullable("id", new ArrowType.Int(32, true)))); + Session session = Session.create(); + try (VortexWriter writer = VortexWriter.builder(session, vortexFile, schema, allocator) + .build(); + VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { + IntVector ids = (IntVector) root.getVector("id"); + ids.allocateNew(3); + for (int i = 0; i < 3; i++) { + ids.setSafe(i, i); + } + root.setRowCount(3); + try (ArrowArray arrowArray = ArrowArray.allocateNew(allocator); + ArrowSchema arrowSchema = ArrowSchema.allocateNew(allocator)) { + Data.exportVectorSchemaRoot(allocator, root, null, arrowArray, arrowSchema); + writer.writeBatch(arrowArray.memoryAddress(), arrowSchema.memoryAddress()); + } + } + } + + @Test + public void openRejectsAnEmptyUriList() { + Session session = Session.create(); + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, () -> DataSource.open(session, List.of(), Collections.emptyMap())); + assertTrue(e.getMessage().contains("at least one uri is required"), e::getMessage); + } + + @Test + public void openRejectsANullUriInTheList() { + Session session = Session.create(); + List uris = Arrays.asList(vortexFile, null); + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, () -> DataSource.open(session, uris, Collections.emptyMap())); + assertTrue(e.getMessage().contains("uris must not contain null values"), e::getMessage); + } + + @Test + public void openRejectsANullSessionAndANullUriList() { + Session session = Session.create(); + assertThrows( + NullPointerException.class, () -> DataSource.open(null, List.of(vortexFile), Collections.emptyMap())); + assertThrows(NullPointerException.class, () -> DataSource.open(session, (List) null, Map.of())); + } + + @Test + public void openRejectsAnEmptyReadableList() { + Session session = Session.create(); + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> DataSource.open(session, List.of(), 0)); + assertTrue(e.getMessage().contains("at least one readable is required"), e::getMessage); + } + + @Test + public void openRejectsNegativeReadConcurrency() { + Session session = Session.create(); + List readables = List.of(new StubReadable("a.vortex", 1)); + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> DataSource.open(session, readables, -1)); + assertTrue(e.getMessage().contains("readConcurrency must not be negative"), e::getMessage); + } + + @Test + public void openRejectsANullReadableInTheList() { + Session session = Session.create(); + List readables = Arrays.asList(new StubReadable("a.vortex", 1), null); + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> DataSource.open(session, readables, 0)); + assertTrue(e.getMessage().contains("readables must not contain null values"), e::getMessage); + } + + @Test + public void openRejectsAReadableWithoutAName() { + Session session = Session.create(); + List readables = List.of(new StubReadable(null, 1)); + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> DataSource.open(session, readables, 0)); + assertTrue(e.getMessage().contains("readable at index 0 returned a null name"), e::getMessage); + } + + @Test + public void openRejectsAReadableReportingNegativeLength() { + Session session = Session.create(); + List readables = List.of(new StubReadable("short.vortex", -1)); + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> DataSource.open(session, readables, 0)); + assertTrue(e.getMessage().contains("readable for short.vortex reported negative length"), e::getMessage); + } + + @Test + public void openRejectsANullReadableList() { + Session session = Session.create(); + assertThrows(NullPointerException.class, () -> DataSource.open(session, (List) null, 0)); + } + + @Test + public void openAcceptsAValidReadableAndReportsExactCounts() throws IOException { + Session session = Session.create(); + DataSource source = DataSource.open(session, vortexFile); + assertEquals(new DataSource.RowCount.Exact(3L), source.rowCount()); + assertEquals(OptionalLong.of(3L), source.rowCount().asOptional()); + assertTrue(source.byteSize() instanceof DataSource.ByteSize.Exact, () -> String.valueOf(source.byteSize())); + assertTrue(source.byteSize().asOptional().orElseThrow() > 0); + } + + @Test + public void unknownCountsCarryNoValue() { + assertEquals(OptionalLong.empty(), DataSource.RowCount.Unknown.INSTANCE.asOptional()); + assertEquals(OptionalLong.empty(), DataSource.ByteSize.Unknown.INSTANCE.asOptional()); + assertFalse(DataSource.RowCount.Unknown.INSTANCE.asOptional().isPresent()); + assertFalse(DataSource.ByteSize.Unknown.INSTANCE.asOptional().isPresent()); + } + + @Test + public void estimateAndExactCarryTheirValue() { + assertEquals(OptionalLong.of(7L), new DataSource.RowCount.Estimate(7L).asOptional()); + assertEquals(OptionalLong.of(7L), new DataSource.RowCount.Exact(7L).asOptional()); + assertEquals(OptionalLong.of(9L), new DataSource.ByteSize.Estimate(9L).asOptional()); + assertEquals(OptionalLong.of(9L), new DataSource.ByteSize.Exact(9L).asOptional()); + // An estimate is not interchangeable with an exact count of the same magnitude. + assertNotEquals(new DataSource.RowCount.Exact(7L), new DataSource.RowCount.Estimate(7L)); + assertNotEquals(new DataSource.ByteSize.Exact(9L), new DataSource.ByteSize.Estimate(9L)); + } + + private static final class StubReadable implements NativeReadable { + private final String name; + private final long length; + + StubReadable(String name, long length) { + this.name = name; + this.length = length; + } + + @Override + public String name() { + return name; + } + + @Override + public long length() { + return length; + } + + @Override + public void readFully(long position, ByteBuffer buffer) throws IOException { + throw new IOException("StubReadable is only used to reach the argument checks"); + } + + @Override + public void close() {} + } +}