Skip to content

Commit c761fef

Browse files
dfa1claude
andcommitted
fix: render nested struct columns as JSON object cells in CSV export (#217)
A nested struct column (StructArray, decodable since #207) now renders as one JSON object cell {"field":value,...} with fields in declared order: numbers/booleans unquoted via the shared leaf rules, strings JSON-escaped and quoted, nested structs recursed, and null fields as JSON null (inside JSON the bare-CSV empty-field convention would be ambiguous). Only the JSON layer escapes; fastcsv independently quotes the cell. VortexWriter.arrayLength learns StructData so struct columns can be written in tests. Pinned by countries-of-the-world (Raincloud corpus, #205): its data struct column blocked CSV export after the #207 scan fix. Closes #217 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9af5a27 commit c761fef

4 files changed

Lines changed: 250 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- CSV export renders nested struct columns as JSON object cells (`{"field":value,...}`, strings JSON-escaped, null fields as JSON `null`, nested structs recursed) instead of throwing `unsupported array type: StructArray`. ([#217](https://github.com/dfa1/vortex-java/issues/217))
1213
- Scanning a struct whose columns include a nested struct no longer fails chunk planning. A nested `vortex.struct` layout column (e.g. Raincloud `countries-of-the-world`'s `data` column) is now treated as a single full-range chunk source and decoded through the layout registry's new struct decoder into a `StructArray`, matching the Rust reference (a nested struct spans its parent's row range, not an independent chunking). `schema`/`count`/`inspect` already worked; plain scans and `select` of sibling columns now work too. CSV export of the struct column itself remains unsupported (a separate rendering limitation). ([#207](https://github.com/dfa1/vortex-java/issues/207))
1314
- CSV export renders unsigned integer columns (U8–U64) with their unsigned values — high-half values previously printed as two's-complement negatives (uci-wine `magnesium` U8 132 exported as -124), silent corruption found by the Raincloud conformance suite. ([#208](https://github.com/dfa1/vortex-java/issues/208))
1415
- Unsigned integer columns are handled unsigned in the remaining consumers: the CLI `filter` predicates (`magnesium >= 130` now matches its high-half U8 rows instead of silently dropping them — the worst class of the bug: wrong query results), the TUI grid and inspector views, and the Calcite adapter (U8/U16/U32 map to the next wider signed SQL type so their full range fits; U64 stays `BIGINT` and fails loud rather than surfacing a negative for values ≥ 2^63, both when read and when summed). ([#216](https://github.com/dfa1/vortex-java/issues/216))

csv/src/main/java/io/github/dfa1/vortex/csv/CsvExporter.java

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import io.github.dfa1.vortex.reader.array.ShortArray;
1515
import io.github.dfa1.vortex.reader.array.MaskedArray;
1616
import io.github.dfa1.vortex.reader.array.NullArray;
17+
import io.github.dfa1.vortex.reader.array.StructArray;
1718
import io.github.dfa1.vortex.reader.array.VarBinArray;
1819
import io.github.dfa1.vortex.reader.VortexReader;
1920
import io.github.dfa1.vortex.reader.ScanIterator;
@@ -139,6 +140,22 @@ private static void writeChunk(Chunk chunk, List<String> colNames, int colCount,
139140
state[1] = nextNotify;
140141
}
141142

143+
/// Renders one cell for the row `rowIdx` of column array `arr`.
144+
///
145+
/// Leaf columns follow the JDK canonical rendering per type (signed/unsigned integer decimal,
146+
/// `Double.toString`, `Boolean.toString`, the raw utf8 string); a null row (a [MaskedArray]
147+
/// invalid row or a [NullArray]) renders as an empty CSV field.
148+
///
149+
/// A nested struct column ([StructArray], possibly wrapped in a [MaskedArray] when nullable)
150+
/// renders as a single JSON object cell `{"field":value,...}` with fields in the struct dtype's
151+
/// declared order. Field values reuse the same leaf rendering rules — numbers and booleans
152+
/// unquoted, strings JSON-escaped and double-quoted, nested structs recursed — and a null field
153+
/// (or null nested row) becomes a JSON `null`. Only the JSON layer is escaped here; the CSV
154+
/// writer independently quotes any cell containing the delimiter or a quote character.
155+
///
156+
/// @param arr the column array to read from
157+
/// @param rowIdx the zero-based row index within `arr`
158+
/// @return the rendered cell text
142159
private static String cellValue(Array arr, long rowIdx) {
143160
return switch (arr) {
144161
// Long/IntArray have no dtype-aware getter, so gate the unsigned rendering on the
@@ -163,11 +180,79 @@ private static String cellValue(Array arr, long rowIdx) {
163180
// All-null columns (DType.Null) hold only a row count: every cell is an empty
164181
// field, same rule as a MaskedArray null row.
165182
case NullArray ignored -> "";
183+
// Nested struct column: render the whole row as a JSON object cell.
184+
case StructArray sa -> jsonObject(sa, rowIdx);
166185
default -> throw new VortexException(
167186
"unsupported array type for CSV export: " + arr.getClass().getSimpleName());
168187
};
169188
}
170189

190+
/// Renders one struct row as a JSON object `{"field":value,...}` with fields in the struct
191+
/// dtype's declared order.
192+
private static String jsonObject(StructArray struct, long rowIdx) {
193+
DType.Struct dtype = (DType.Struct) struct.dtype();
194+
List<ColumnName> names = dtype.fieldNames();
195+
StringBuilder sb = new StringBuilder();
196+
sb.append('{');
197+
for (int i = 0; i < names.size(); i++) {
198+
if (i > 0) {
199+
sb.append(',');
200+
}
201+
jsonString(sb, names.get(i).value());
202+
sb.append(':');
203+
sb.append(jsonValue(struct.field(i), rowIdx));
204+
}
205+
sb.append('}');
206+
return sb.toString();
207+
}
208+
209+
/// Renders one struct field value as JSON text: a nested object for structs, a quoted escaped
210+
/// string for utf8, JSON `null` for a null row/field, and the bare leaf rendering (unquoted
211+
/// number/boolean) for everything else. Unlike [#cellValue(Array, long)], a null here is the
212+
/// JSON token `null` rather than an empty field, because inside a JSON object the empty-field
213+
/// convention of bare CSV would be ambiguous.
214+
private static String jsonValue(Array arr, long rowIdx) {
215+
return switch (arr) {
216+
case StructArray sa -> jsonObject(sa, rowIdx);
217+
case MaskedArray ma -> ma.isValid(rowIdx) ? jsonValue(ma.inner(), rowIdx) : "null";
218+
case NullArray ignored -> "null";
219+
case VarBinArray va -> {
220+
StringBuilder sb = new StringBuilder();
221+
jsonString(sb, va.getString(rowIdx));
222+
yield sb.toString();
223+
}
224+
// Numeric and boolean leaves render identically to a top-level cell, unquoted.
225+
default -> cellValue(arr, rowIdx);
226+
};
227+
}
228+
229+
/// Appends `value` to `sb` as a JSON string literal: wrapped in double quotes with `"`,
230+
/// backslash, the standard short escapes, and any other control character below `U+0020`
231+
/// escaped as `\\uXXXX`.
232+
private static void jsonString(StringBuilder sb, String value) {
233+
sb.append('"');
234+
for (int i = 0; i < value.length(); i++) {
235+
char c = value.charAt(i);
236+
switch (c) {
237+
case '"' -> sb.append("\\\"");
238+
case '\\' -> sb.append("\\\\");
239+
case '\n' -> sb.append("\\n");
240+
case '\r' -> sb.append("\\r");
241+
case '\t' -> sb.append("\\t");
242+
case '\b' -> sb.append("\\b");
243+
case '\f' -> sb.append("\\f");
244+
default -> {
245+
if (c < 0x20) {
246+
sb.append(String.format("\\u%04x", (int) c));
247+
} else {
248+
sb.append(c);
249+
}
250+
}
251+
}
252+
}
253+
sb.append('"');
254+
}
255+
171256
/// Whether the array's dtype is an unsigned integer, so high-half values must render as
172257
/// unsigned decimal rather than the two's-complement negative that the raw signed getter
173258
/// returns. Callers only pass leaf value arrays: the [MaskedArray] arm of `cellValue`

csv/src/test/java/io/github/dfa1/vortex/csv/CsvExporterTest.java

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@
55
import io.github.dfa1.vortex.core.model.PType;
66
import io.github.dfa1.vortex.writer.VortexWriter;
77
import io.github.dfa1.vortex.writer.WriteOptions;
8+
import io.github.dfa1.vortex.writer.encode.BoolEncodingEncoder;
9+
import io.github.dfa1.vortex.writer.encode.MaskedEncodingEncoder;
810
import io.github.dfa1.vortex.writer.encode.NullEncodingEncoder;
911
import io.github.dfa1.vortex.writer.encode.NullableData;
1012
import io.github.dfa1.vortex.writer.encode.PrimitiveEncodingEncoder;
13+
import io.github.dfa1.vortex.writer.encode.StructData;
14+
import io.github.dfa1.vortex.writer.encode.StructEncodingEncoder;
15+
import io.github.dfa1.vortex.writer.encode.VarBinEncodingEncoder;
1116
import org.junit.jupiter.api.Test;
1217
import org.junit.jupiter.api.io.TempDir;
1318

@@ -259,4 +264,160 @@ void rendersU64AboveLongMaxAsUnsigned(@TempDir Path tmp) throws Exception {
259264
List<String> result = Files.readAllLines(csv);
260265
assertThat(result).containsExactly("v", "1", "9223372036854775808", "18446744073709551615");
261266
}
267+
268+
@Test
269+
void rendersNestedStructColumnAsJsonObject(@TempDir Path tmp) throws Exception {
270+
// Given a struct column of mixed field types (i64, utf8, f64), the shape #217 threw on:
271+
// StructArray had no cellValue arm. The sibling plain columns confirm structs coexist
272+
// with scalar columns in the same file.
273+
Path vortex = tmp.resolve("nested.vortex");
274+
DType.Struct inner = new DType.Struct(
275+
List.of(ColumnName.of("id"), ColumnName.of("name"), ColumnName.of("score")),
276+
List.of(DType.I64, DType.UTF8, DType.F64),
277+
false);
278+
DType.Struct schema = new DType.Struct(
279+
List.of(ColumnName.of("region"), ColumnName.of("data")),
280+
List.of(DType.UTF8, inner),
281+
false);
282+
try (FileChannel ch = FileChannel.open(vortex, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
283+
// Struct columns route through the recursive cascade (the flat first-match path has
284+
// no struct encoder), so enable cascading here and in the other struct cases.
285+
VortexWriter writer = VortexWriter.create(ch, schema, WriteOptions.cascading(3))) {
286+
writer.writeChunk(Map.of(
287+
ColumnName.of("region"), new String[]{"north", "south"},
288+
ColumnName.of("data"), new StructData(List.of(
289+
new long[]{1L, 2L},
290+
new String[]{"Alice", "Bob"},
291+
new double[]{9.5, 3.25}))));
292+
}
293+
Path csv = tmp.resolve("out.csv");
294+
295+
// When
296+
CsvExporter.exportCsv(vortex, csv);
297+
298+
// Then — the struct cell is a JSON object in declared field order; numbers/strings quoted
299+
// per JSON, not CSV.
300+
List<String> result = Files.readAllLines(csv);
301+
assertThat(result).containsExactly(
302+
"region,data",
303+
"north,\"{\"\"id\"\":1,\"\"name\"\":\"\"Alice\"\",\"\"score\"\":9.5}\"",
304+
"south,\"{\"\"id\"\":2,\"\"name\"\":\"\"Bob\"\",\"\"score\"\":3.25}\"");
305+
}
306+
307+
@Test
308+
void rendersNullStructFieldAsJsonNull(@TempDir Path tmp) throws Exception {
309+
// Given a struct whose second field is nullable and null on the first row — the field must
310+
// become the JSON token null (not an empty field, which is bare CSV's null convention and
311+
// ambiguous inside a JSON object). Written on the flat path with an explicit struct encoder:
312+
// StructEncodingEncoder wraps a NullableData field in the masked encoder, whereas the
313+
// recursive cascade does not yet accept nullable struct fields. A `region` sibling keeps the
314+
// struct column off the single-column path; `label` needs two struct fields to survive decode
315+
// (a one-field struct is unwrapped to its bare field by StructEncodingDecoder).
316+
Path vortex = tmp.resolve("nullfield.vortex");
317+
DType.Struct inner = new DType.Struct(
318+
List.of(ColumnName.of("id"), ColumnName.of("label")),
319+
List.of(DType.I64, new DType.Primitive(PType.I64, true)),
320+
false);
321+
DType.Struct schema = new DType.Struct(
322+
List.of(ColumnName.of("region"), ColumnName.of("data")),
323+
List.of(DType.UTF8, inner),
324+
false);
325+
try (FileChannel ch = FileChannel.open(vortex, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
326+
VortexWriter writer = VortexWriter.create(ch, schema, WriteOptions.defaults(),
327+
List.of(new StructEncodingEncoder(), new PrimitiveEncodingEncoder(),
328+
new VarBinEncodingEncoder(), new MaskedEncodingEncoder(),
329+
new BoolEncodingEncoder()))) {
330+
writer.writeChunk(Map.of(
331+
ColumnName.of("region"), new String[]{"north", "south"},
332+
ColumnName.of("data"), new StructData(List.of(
333+
new long[]{1L, 2L},
334+
new NullableData(new long[]{0L, 42L}, new boolean[]{false, true})))));
335+
}
336+
Path csv = tmp.resolve("out.csv");
337+
338+
// When
339+
CsvExporter.exportCsv(vortex, csv);
340+
341+
// Then the null field is the JSON token null; the valid field is the bare number.
342+
List<String> result = Files.readAllLines(csv);
343+
assertThat(result).containsExactly(
344+
"region,data",
345+
"north,\"{\"\"id\"\":1,\"\"label\"\":null}\"",
346+
"south,\"{\"\"id\"\":2,\"\"label\"\":42}\"");
347+
}
348+
349+
@Test
350+
void escapesQuotesAndCommasInsideStructStringField(@TempDir Path tmp) throws Exception {
351+
// Given a struct string field value containing a JSON-significant quote and a comma: the
352+
// quote must be JSON-escaped (\") inside the object, and the CSV layer independently doubles
353+
// the quotes of the whole cell. The comma must NOT split the CSV cell. Two struct fields
354+
// (note, rank) keep the struct from being unwrapped on decode; the `label` sibling keeps it
355+
// off the single-column path.
356+
Path vortex = tmp.resolve("escape.vortex");
357+
DType.Struct inner = new DType.Struct(
358+
List.of(ColumnName.of("note"), ColumnName.of("rank")),
359+
List.of(DType.UTF8, DType.I64),
360+
false);
361+
DType.Struct schema = new DType.Struct(
362+
List.of(ColumnName.of("label"), ColumnName.of("data")),
363+
List.of(DType.UTF8, inner),
364+
false);
365+
try (FileChannel ch = FileChannel.open(vortex, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
366+
VortexWriter writer = VortexWriter.create(ch, schema, WriteOptions.cascading(3))) {
367+
writer.writeChunk(Map.of(
368+
ColumnName.of("label"), new String[]{"x"},
369+
ColumnName.of("data"), new StructData(List.of(
370+
new String[]{"a\"b,c"},
371+
new long[]{7L}))));
372+
}
373+
Path csv = tmp.resolve("out.csv");
374+
375+
// When
376+
CsvExporter.exportCsv(vortex, csv);
377+
378+
// Then — JSON escapes the inner quote to \"; the CSV layer then doubles every quote in the
379+
// whole field. The comma stays inside the single quoted cell.
380+
List<String> result = Files.readAllLines(csv);
381+
assertThat(result).containsExactly(
382+
"label,data",
383+
"x,\"{\"\"note\"\":\"\"a\\\"\"b,c\"\",\"\"rank\"\":7}\"");
384+
}
385+
386+
@Test
387+
void rendersStructInStructAsNestedJsonObject(@TempDir Path tmp) throws Exception {
388+
// Given a struct whose field is itself a struct: the inner struct must recurse into a nested
389+
// JSON object, not throw. A `region` sibling keeps the struct column off the single-column
390+
// path; every struct here has two fields so none is unwrapped on decode.
391+
Path vortex = tmp.resolve("deep.vortex");
392+
DType.Struct innermost = new DType.Struct(
393+
List.of(ColumnName.of("lat"), ColumnName.of("lon")),
394+
List.of(DType.F64, DType.F64),
395+
false);
396+
DType.Struct inner = new DType.Struct(
397+
List.of(ColumnName.of("name"), ColumnName.of("coord")),
398+
List.of(DType.UTF8, innermost),
399+
false);
400+
DType.Struct schema = new DType.Struct(
401+
List.of(ColumnName.of("region"), ColumnName.of("data")),
402+
List.of(DType.UTF8, inner),
403+
false);
404+
try (FileChannel ch = FileChannel.open(vortex, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
405+
VortexWriter writer = VortexWriter.create(ch, schema, WriteOptions.cascading(3))) {
406+
writer.writeChunk(Map.of(
407+
ColumnName.of("region"), new String[]{"north"},
408+
ColumnName.of("data"), new StructData(List.of(
409+
new String[]{"origin"},
410+
new StructData(List.of(new double[]{1.0}, new double[]{2.0}))))));
411+
}
412+
Path csv = tmp.resolve("out.csv");
413+
414+
// When
415+
CsvExporter.exportCsv(vortex, csv);
416+
417+
// Then
418+
List<String> result = Files.readAllLines(csv);
419+
assertThat(result).containsExactly(
420+
"region,data",
421+
"north,\"{\"\"name\"\":\"\"origin\"\",\"\"coord\"\":{\"\"lat\"\":1.0,\"\"lon\"\":2.0}}\"");
422+
}
262423
}

writer/src/main/java/io/github/dfa1/vortex/writer/VortexWriter.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,9 @@ private static long arrayLength(Object data) {
275275
case double[] a -> a.length;
276276
case boolean[] a -> a.length;
277277
case String[] a -> a.length;
278+
// A struct column's row count is its fields' row count (all fields share length,
279+
// enforced by StructEncodingEncoder); an empty struct carries no rows.
280+
case StructData d -> d.fieldArrays().isEmpty() ? 0L : arrayLength(d.fieldArrays().getFirst());
278281
case ListData d -> d.outerLen();
279282
case ListViewData d -> d.outerLen();
280283
case DateTimePartsData d -> d.timestamps().length;

0 commit comments

Comments
 (0)