|
| 1 | +package io.github.dfa1.vortex.integration; |
| 2 | + |
| 3 | +import dev.vortex.api.DataSource; |
| 4 | +import dev.vortex.api.Scan; |
| 5 | +import dev.vortex.api.ScanOptions; |
| 6 | +import dev.vortex.api.Session; |
| 7 | +import dev.vortex.api.VortexWriter; |
| 8 | +import dev.vortex.arrow.ArrowAllocation; |
| 9 | +import dev.vortex.jni.NativeLoader; |
| 10 | +import io.github.dfa1.vortex.core.model.DType; |
| 11 | +import io.github.dfa1.vortex.core.model.PType; |
| 12 | +import io.github.dfa1.vortex.reader.VortexReader; |
| 13 | +import io.github.dfa1.vortex.reader.array.LongArray; |
| 14 | +import org.apache.arrow.c.ArrowArray; |
| 15 | +import org.apache.arrow.c.ArrowSchema; |
| 16 | +import org.apache.arrow.c.Data; |
| 17 | +import org.apache.arrow.memory.BufferAllocator; |
| 18 | +import org.apache.arrow.vector.BigIntVector; |
| 19 | +import org.apache.arrow.vector.VectorSchemaRoot; |
| 20 | +import org.apache.arrow.vector.ipc.ArrowReader; |
| 21 | +import org.apache.arrow.vector.types.pojo.ArrowType; |
| 22 | +import org.apache.arrow.vector.types.pojo.Field; |
| 23 | +import org.apache.arrow.vector.types.pojo.Schema; |
| 24 | +import org.junit.jupiter.api.Test; |
| 25 | +import org.junit.jupiter.api.io.TempDir; |
| 26 | + |
| 27 | +import java.io.IOException; |
| 28 | +import java.nio.channels.FileChannel; |
| 29 | +import java.nio.file.Path; |
| 30 | +import java.nio.file.StandardOpenOption; |
| 31 | +import java.util.ArrayList; |
| 32 | +import java.util.HashMap; |
| 33 | +import java.util.List; |
| 34 | +import java.util.Map; |
| 35 | + |
| 36 | +import static org.assertj.core.api.Assertions.assertThat; |
| 37 | +import static org.assertj.core.api.Assertions.catchThrowable; |
| 38 | + |
| 39 | +/// Cross-compatibility for column-name edge cases nobody advertises, measured against the |
| 40 | +/// Rust (JNI) reference: |
| 41 | +/// |
| 42 | +/// - `""` is a legal column name: round-trips in BOTH directions (Rust writes/Java reads and |
| 43 | +/// Java writes/Rust reads). |
| 44 | +/// - Duplicate field names are legal in Rust's in-memory `StructFields` |
| 45 | +/// (`vortex-array/src/dtype/struct_.rs`, first-match name access) but REJECTED by its file |
| 46 | +/// writer: "StructLayout must have unique field names" — the wire contract both writers |
| 47 | +/// must enforce. Reader-side handling of foreign duplicate-name files is tracked in |
| 48 | +/// TODO.md §"Column identity". |
| 49 | +class ColumnNameEdgeCasesIntegrationTest { |
| 50 | + |
| 51 | + private static final Session SESSION = Session.create(); |
| 52 | + private static final BufferAllocator ALLOCATOR = ArrowAllocation.rootAllocator(); |
| 53 | + |
| 54 | + static { |
| 55 | + NativeLoader.loadJni(); |
| 56 | + } |
| 57 | + |
| 58 | + @Test |
| 59 | + void jniWritesEmptyColumnName_javaReadsIt(@TempDir Path tmp) throws IOException { |
| 60 | + // Given — the Rust (JNI) writer produces a file whose first column is named "" |
| 61 | + Path file = tmp.resolve("jni_empty_name.vortex"); |
| 62 | + Schema schema = new Schema(List.of( |
| 63 | + Field.notNullable("", new ArrowType.Int(64, true)), |
| 64 | + Field.notNullable("x", new ArrowType.Int(64, true)))); |
| 65 | + writeJni(file, schema, new long[][]{{1, 2}, {30, 40}}); |
| 66 | + |
| 67 | + // When |
| 68 | + Map<String, long[]> result = javaReadAllLongColumns(file); |
| 69 | + |
| 70 | + // Then — the empty name is a plain map key, values intact |
| 71 | + assertThat(result.keySet()).containsExactlyInAnyOrder("", "x"); |
| 72 | + assertThat(result.get("")).containsExactly(1, 2); |
| 73 | + assertThat(result.get("x")).containsExactly(30, 40); |
| 74 | + } |
| 75 | + |
| 76 | + @Test |
| 77 | + void javaWritesEmptyColumnName_jniReadsIt(@TempDir Path tmp) throws IOException { |
| 78 | + // Given — the Java writer produces a file with an empty column name (the Struct record |
| 79 | + // constructor performs no name validation; only StructBuilder rejects duplicates) |
| 80 | + Path file = tmp.resolve("java_empty_name.vortex"); |
| 81 | + var dtype = new DType.Struct( |
| 82 | + List.of("", "x"), |
| 83 | + List.of(new DType.Primitive(PType.I64, false), new DType.Primitive(PType.I64, false)), |
| 84 | + false); |
| 85 | + try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE); |
| 86 | + var writer = io.github.dfa1.vortex.writer.VortexWriter.create( |
| 87 | + ch, dtype, io.github.dfa1.vortex.writer.WriteOptions.defaults())) { |
| 88 | + writer.writeChunk(Map.of("", new long[]{1, 2}, "x", new long[]{30, 40})); |
| 89 | + } |
| 90 | + |
| 91 | + // When — the Rust (JNI) reader scans it |
| 92 | + List<String> result = new ArrayList<>(); |
| 93 | + long rows = jniReadFieldNames(file, result); |
| 94 | + |
| 95 | + // Then — the reference reader accepts the file and reports the empty-named field |
| 96 | + assertThat(rows).isEqualTo(2); |
| 97 | + assertThat(result).containsExactly("", "x"); |
| 98 | + } |
| 99 | + |
| 100 | + @Test |
| 101 | + void jniWriterRejectsDuplicateColumnNames(@TempDir Path tmp) { |
| 102 | + // Given — a schema with two fields named "dup". Rust's in-memory StructFields documents |
| 103 | + // duplicates as legal (first-match name access), but its FILE writer enforces uniqueness: |
| 104 | + // "StructLayout must have unique field names". This test pins that wire contract — the |
| 105 | + // Java writer must mirror the same rejection (see VortexWriterTest). |
| 106 | + Path file = tmp.resolve("jni_dup_names.vortex"); |
| 107 | + Schema schema = new Schema(List.of( |
| 108 | + Field.notNullable("dup", new ArrowType.Int(64, true)), |
| 109 | + Field.notNullable("dup", new ArrowType.Int(64, true)))); |
| 110 | + |
| 111 | + // When |
| 112 | + Throwable result = catchThrowable(() -> writeJni(file, schema, new long[][]{{1, 2}, {30, 40}})); |
| 113 | + |
| 114 | + // Then — the reference writer refuses to produce a duplicate-name file |
| 115 | + assertThat(result) |
| 116 | + .isInstanceOf(IOException.class) |
| 117 | + .hasRootCauseMessage("Vortex Error: Other error: StructLayout must have unique field names"); |
| 118 | + } |
| 119 | + |
| 120 | + // ── helpers ─────────────────────────────────────────────────────────────── |
| 121 | + |
| 122 | + /// Writes one batch through the Rust (JNI) writer: one `long[]` per schema field, in order. |
| 123 | + private static void writeJni(Path file, Schema schema, long[][] columns) throws IOException { |
| 124 | + String uri = file.toAbsolutePath().toUri().toString(); |
| 125 | + try (VortexWriter writer = VortexWriter.create(SESSION, uri, schema, new HashMap<>(), ALLOCATOR); |
| 126 | + VectorSchemaRoot root = VectorSchemaRoot.create(schema, ALLOCATOR)) { |
| 127 | + int n = columns[0].length; |
| 128 | + for (int c = 0; c < columns.length; c++) { |
| 129 | + BigIntVector vec = (BigIntVector) root.getVector(c); |
| 130 | + vec.allocateNew(n); |
| 131 | + for (int i = 0; i < n; i++) { |
| 132 | + vec.setSafe(i, columns[c][i]); |
| 133 | + } |
| 134 | + } |
| 135 | + root.setRowCount(n); |
| 136 | + try (ArrowArray arr = ArrowArray.allocateNew(ALLOCATOR); |
| 137 | + ArrowSchema arrowSchema = ArrowSchema.allocateNew(ALLOCATOR)) { |
| 138 | + Data.exportVectorSchemaRoot(ALLOCATOR, root, null, arr, arrowSchema); |
| 139 | + writer.writeBatch(arr.memoryAddress(), arrowSchema.memoryAddress()); |
| 140 | + } |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + /// Scans the whole file with the Java reader, materializing every column as `long[]`. |
| 145 | + private static Map<String, long[]> javaReadAllLongColumns(Path file) throws IOException { |
| 146 | + Map<String, long[]> out = new HashMap<>(); |
| 147 | + try (var reader = VortexReader.open(file); |
| 148 | + var iter = reader.scan(io.github.dfa1.vortex.reader.ScanOptions.all())) { |
| 149 | + while (iter.hasNext()) { |
| 150 | + try (var chunk = iter.next()) { |
| 151 | + for (var entry : chunk.columns().entrySet()) { |
| 152 | + LongArray col = (LongArray) entry.getValue(); |
| 153 | + long[] values = new long[(int) col.length()]; |
| 154 | + for (int i = 0; i < values.length; i++) { |
| 155 | + values[i] = col.getLong(i); |
| 156 | + } |
| 157 | + out.put(entry.getKey(), values); |
| 158 | + } |
| 159 | + } |
| 160 | + } |
| 161 | + } |
| 162 | + return out; |
| 163 | + } |
| 164 | + |
| 165 | + /// Scans the file with the Rust (JNI) reader, returning the row count and collecting the |
| 166 | + /// Arrow schema field names. |
| 167 | + private static long jniReadFieldNames(Path file, List<String> namesOut) throws IOException { |
| 168 | + long rows = 0; |
| 169 | + DataSource ds = DataSource.open(SESSION, file.toAbsolutePath().toUri().toString()); |
| 170 | + Scan scan = ds.scan(ScanOptions.of()); |
| 171 | + while (scan.hasNext()) { |
| 172 | + var partition = scan.next(); |
| 173 | + try (ArrowReader reader = partition.scanArrow(ALLOCATOR)) { |
| 174 | + while (reader.loadNextBatch()) { |
| 175 | + var root = reader.getVectorSchemaRoot(); |
| 176 | + rows += root.getRowCount(); |
| 177 | + if (namesOut.isEmpty()) { |
| 178 | + for (Field field : root.getSchema().getFields()) { |
| 179 | + namesOut.add(field.getName()); |
| 180 | + } |
| 181 | + } |
| 182 | + } |
| 183 | + } |
| 184 | + } |
| 185 | + return rows; |
| 186 | + } |
| 187 | +} |
0 commit comments