|
5 | 5 | import io.github.dfa1.vortex.core.model.PType; |
6 | 6 | import io.github.dfa1.vortex.writer.VortexWriter; |
7 | 7 | 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; |
8 | 10 | import io.github.dfa1.vortex.writer.encode.NullEncodingEncoder; |
9 | 11 | import io.github.dfa1.vortex.writer.encode.NullableData; |
10 | 12 | 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; |
11 | 16 | import org.junit.jupiter.api.Test; |
12 | 17 | import org.junit.jupiter.api.io.TempDir; |
13 | 18 |
|
@@ -259,4 +264,160 @@ void rendersU64AboveLongMaxAsUnsigned(@TempDir Path tmp) throws Exception { |
259 | 264 | List<String> result = Files.readAllLines(csv); |
260 | 265 | assertThat(result).containsExactly("v", "1", "9223372036854775808", "18446744073709551615"); |
261 | 266 | } |
| 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 | + } |
262 | 423 | } |
0 commit comments