Skip to content

Commit ab0b5e0

Browse files
dfa1claude
andcommitted
feat: implement DType.Map and the vortex.map encoding
- New sealed DType.Map(keyType, valueType, keysSorted, nullable) variant. Non-nullable key enforced by the compact constructor (VortexException, same reasoning as Extension's metadata bound). entriesDtype() returns the non-nullable {key, value} struct backing a map's physical storage. - Wire format: dtype.fbs/dtype.proto gain a Map table/message at union tag 13, matching upstream vortex-data/vortex field-for-field. Wired through PostscriptParser (decode) and VortexWriter (encode). - vortex.map array encoding: one `entries` child (a bare vortex.listview of {key, value} structs), no buffers, no metadata. MapEncodingDecoder rejects anything other than a bare vortex.listview entries child (matching Rust's validate_entries); MapEncodingEncoder delegates straight to ListViewEncodingEncoder, since a map's entries literally are a ListView<Struct>. - vortex.listview gains a real validity slot: a nullable list-view now carries validity in its own optional fourth child instead of always going through a vortex.masked wrapper. This was needed because vortex.map's entries child must stay a bare listview. Fixes a data-loss bug on read: a 4-child listview previously decoded to a bare ListViewArray with its validity silently dropped. A 4th child under a declared-non-nullable dtype is now rejected outright (contradiction between decoded and declared dtype otherwise). BREAKING: adding DType.Map to the sealed interface's permits clause breaks any downstream exhaustive switch (dtype) with no default arm. - ListViewEncodingEncoder's FALLBACK gains StructEncodingEncoder, enabling ListView<Struct> columns generally (map's entries shape specifically). - Editions.CORE_2026_08_0's vortex.map member flips from the placeholder EncodingId.Custom("vortex.map") to the real EncodingId.VORTEX_MAP. - Fixes the performance module, left broken by the earlier vortex-jni 0.84.0 bump (dev.vortex.api.VortexWriter.create(...) was replaced by builder(...).build() upstream). - Cross-language round-trip verified against the real vortex-jni 0.84.0 native library (MapEncodingInteropIntegrationTest): Java-writes/Rust-reads and Rust-writes/Java-reads, both nullable and non-nullable maps, plus keys_sorted survives the postscript round-trip. Closes #351. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 73e3067 commit ab0b5e0

42 files changed

Lines changed: 1845 additions & 66 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `DType.Map` logical type and the `vortex.map` encoding, read and write: a map column stores one `entries` child, a listview of non-nullable `{key, value}` structs (nullable map rows carry their validity in that listview's own validity slot), decoding to the new `MapArray`. Adding `Map` to the sealed `DType`'s `permits` clause is **breaking** for downstream exhaustive `switch` statements over `DType`. ([#351](https://github.com/dfa1/vortex-java/issues/351))
13+
1014
### Changed
1115

1216
- A `fastlanes.rle` column reads its run values and per-row index table in place instead of copying them onto the heap; `LazyRle{Long,Int,Short,Byte,Double,Float,Bool}Array` now take `MemorySegment` values/indices plus a `wideIndices` flag, **breaking** for code constructing them directly. ([#342](https://github.com/dfa1/vortex-java/issues/342))
13-
- `WriteOptions` now defaults to the `core2026.08.0` edition, tracking upstream's new frozen edition (adds `vortex.map`, which vortex-java doesn't implement yet, so this changes no default write's output). ([#351](https://github.com/dfa1/vortex-java/issues/351))
17+
- `WriteOptions` now defaults to the `core2026.08.0` edition, tracking upstream's new frozen edition. ([#351](https://github.com/dfa1/vortex-java/issues/351))
1418

1519
### Fixed
1620

21+
- A `vortex.listview` column no longer silently drops its validity bitmap on decode, and a validity child under a dtype the file declares non-nullable now fails as `VortexException` instead of decoding to an array that contradicts its own column dtype. ([#351](https://github.com/dfa1/vortex-java/issues/351))
22+
- The JMH benchmarks in `performance` compile again against vortex-jni 0.84.0, which replaced `VortexWriter.create(…)` with a builder. ([#351](https://github.com/dfa1/vortex-java/issues/351))
1723
- A malformed `fastlanes.rle` column now fails as `VortexException` where an absurd declared length, an empty or undersized child segment, or an out-of-range chunk offset previously escaped as `OutOfMemoryError`, `NegativeArraySizeException`, `ArithmeticException`, or `IndexOutOfBoundsException`. ([#342](https://github.com/dfa1/vortex-java/issues/342))
1824
- A `vortex.dict` layout over a StringView or `vortex.constant` values pool no longer expands every row into a fresh buffer, and no longer silently drops that pool's validity. ([#341](https://github.com/dfa1/vortex-java/issues/341))
1925
- A `vortex.dict` layout whose values pool declares more entries than it stores — a `vortex.constant` pool can claim any length in a few hundred bytes — no longer allocates against that claim; codes are bounded against the pool first, and an out-of-pool code fails as `VortexException`. ([#341](https://github.com/dfa1/vortex-java/issues/341))

cli/src/main/java/io/github/dfa1/vortex/cli/SchemaCommand.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ static String formatDType(DType dtype) {
8282
case DType.List(var elem, var nullable) -> "list<" + formatDType(elem) + ">" + (nullable ? "?" : "");
8383
case DType.FixedSizeList(var elem, var size, var nullable) ->
8484
"list<" + formatDType(elem) + ">[" + size + "]" + (nullable ? "?" : "");
85+
// keys_sorted is part of a map's identity — two schemas differing only in it are
86+
// different types, so it is rendered rather than dropped (Rust prints it the same way).
87+
case DType.Map(var key, var value, var keysSorted, var nullable) ->
88+
"map<" + formatDType(key) + ", " + formatDType(value)
89+
+ ", keys_sorted=" + keysSorted + ">" + (nullable ? "?" : "");
8590
case DType.Extension(var id, var _, var _, var nullable) ->
8691
"ext<" + id + ">" + (nullable ? "?" : "");
8792
case DType.Variant(var nullable) -> "variant" + (nullable ? "?" : "");

cli/src/test/java/io/github/dfa1/vortex/cli/SchemaCommandTest.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ static Stream<Arguments> dtypeCases() {
8282
arguments(new DType.List(i64, true), "list<I64>?"),
8383
arguments(new DType.FixedSizeList(i64, 4, false), "list<I64>[4]"),
8484
arguments(new DType.FixedSizeList(i64, 4, true), "list<I64>[4]?"),
85+
arguments(new DType.Map(DType.UTF8, i64, false, false), "map<utf8, I64, keys_sorted=false>"),
86+
arguments(new DType.Map(DType.UTF8, i64, false, true), "map<utf8, I64, keys_sorted=false>?"),
87+
// keys_sorted is part of a map's identity, so two maps differing only in it must
88+
// not render identically
89+
arguments(new DType.Map(DType.UTF8, i64, true, false), "map<utf8, I64, keys_sorted=true>"),
90+
arguments(new DType.Map(DType.UTF8, new DType.Primitive(PType.I64, true), false, false),
91+
"map<utf8, I64?, keys_sorted=false>"),
8592
arguments(new DType.Extension("vortex.uuid", i64, null, false), "ext<vortex.uuid>"),
8693
arguments(new DType.Extension("vortex.uuid", i64, null, true), "ext<vortex.uuid>?"),
8794
arguments(DType.VARIANT, "variant"),

core/src/main/fbs/dtype.fbs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ table Union {
7373
nullable: bool;
7474
}
7575

76+
table Map {
77+
key_type: DType;
78+
value_type: DType;
79+
keys_sorted: bool;
80+
nullable: bool;
81+
}
82+
7683
union Type {
7784
Null = 1,
7885
Bool = 2,
@@ -86,6 +93,7 @@ union Type {
8693
FixedSizeList = 10, // This is after `Extension` for backwards compatibility.
8794
Variant = 11,
8895
Union = 12,
96+
Map = 13,
8997
}
9098

9199
table DType {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Generated by vortex-fbs-gen. Do not edit.
2+
3+
package io.github.dfa1.vortex.core.fbs;
4+
5+
import java.lang.foreign.MemorySegment;
6+
import javax.annotation.processing.Generated;
7+
8+
/// Reader and builder for the `Map` FlatBuffers table.
9+
@Generated("io.github.dfa1.vortex.fbsgen.CodeGen")
10+
public final class FbsMap extends FbsTable {
11+
12+
/// Positions a reader at the root table of a finished buffer.
13+
/// @param seg the buffer
14+
/// @return a reader positioned at the root
15+
public static FbsMap getRootAsFbsMap(MemorySegment seg) {
16+
return new FbsMap().assign(seg, FbsTable.rootPosition(seg, 0));
17+
}
18+
19+
/// Positions this reader at a table.
20+
/// @param seg the buffer
21+
/// @param position the table position
22+
/// @return this
23+
public FbsMap assign(MemorySegment seg, long position) {
24+
init(seg, position);
25+
return this;
26+
}
27+
28+
/// @return the `keyType` child, or null if absent
29+
public FbsDType keyType() {
30+
int o = fieldOffset(4);
31+
return o != 0 ? new FbsDType().assign(seg, indirect(pos + o)) : null;
32+
}
33+
34+
/// @return the `valueType` child, or null if absent
35+
public FbsDType valueType() {
36+
int o = fieldOffset(6);
37+
return o != 0 ? new FbsDType().assign(seg, indirect(pos + o)) : null;
38+
}
39+
40+
/// @return the `keysSorted` field
41+
public boolean keysSorted() {
42+
int o = fieldOffset(8);
43+
return o != 0 ? readByte(pos + o) != 0 : false;
44+
}
45+
46+
/// @return the `nullable` field
47+
public boolean nullable() {
48+
int o = fieldOffset(10);
49+
return o != 0 ? readByte(pos + o) != 0 : false;
50+
}
51+
52+
/// Sets the `keyType` offset field.
53+
/// @param b the builder
54+
/// @param offset the referenced offset
55+
public static void addKeyType(FbsBuilder b, int offset) {
56+
b.addOffset(0, offset, 0);
57+
}
58+
59+
/// Sets the `valueType` offset field.
60+
/// @param b the builder
61+
/// @param offset the referenced offset
62+
public static void addValueType(FbsBuilder b, int offset) {
63+
b.addOffset(1, offset, 0);
64+
}
65+
66+
/// Sets the `keysSorted` field.
67+
/// @param b the builder
68+
/// @param keysSorted value
69+
public static void addKeysSorted(FbsBuilder b, boolean keysSorted) {
70+
b.addBoolean(2, keysSorted, false);
71+
}
72+
73+
/// Sets the `nullable` field.
74+
/// @param b the builder
75+
/// @param nullable value
76+
public static void addNullable(FbsBuilder b, boolean nullable) {
77+
b.addBoolean(3, nullable, false);
78+
}
79+
80+
/// Builds a `FbsMap` table.
81+
/// @param b the builder
82+
/// @param keyTypeOffset field value
83+
/// @param valueTypeOffset field value
84+
/// @param keysSorted field value
85+
/// @param nullable field value
86+
/// @return the table offset
87+
public static int createFbsMap(FbsBuilder b, int keyTypeOffset, int valueTypeOffset, boolean keysSorted, boolean nullable) {
88+
b.startTable(4);
89+
b.addOffset(1, valueTypeOffset, 0);
90+
b.addOffset(0, keyTypeOffset, 0);
91+
b.addBoolean(3, nullable, false);
92+
b.addBoolean(2, keysSorted, false);
93+
return b.endTable();
94+
}
95+
96+
/// Begins a `FbsMap` table.
97+
/// @param b the builder
98+
public static void startFbsMap(FbsBuilder b) {
99+
b.startTable(4);
100+
}
101+
102+
/// Finishes a `FbsMap` table.
103+
/// @param b the builder
104+
/// @return the table offset
105+
public static int endFbsMap(FbsBuilder b) {
106+
return b.endTable();
107+
}
108+
109+
/// Finishes the buffer with a `FbsMap` root.
110+
/// @param b the builder
111+
/// @param offset the root table offset
112+
public static void finishFbsMapBuffer(FbsBuilder b, int offset) {
113+
b.finish(offset);
114+
}
115+
}

core/src/main/java/io/github/dfa1/vortex/core/fbs/FbsType.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,7 @@ private FbsType() {
4949

5050
/// Member `Union` = 12
5151
public static final byte FbsUnion = (byte) 12;
52+
53+
/// Member `Map` = 13
54+
public static final byte FbsMap = (byte) 13;
5255
}

core/src/main/java/io/github/dfa1/vortex/core/model/DType.java

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.lang.foreign.MemorySegment;
66
import java.util.ArrayList;
77
import java.util.LinkedHashMap;
8+
import java.util.Objects;
89

910
/// Vortex logical data type. Strictly logical — defines value domain, not physical storage.
1011
///
@@ -19,7 +20,7 @@
1920
public sealed interface DType
2021
permits DType.Null, DType.Bool, DType.Primitive, DType.Decimal,
2122
DType.Utf8, DType.Binary, DType.Struct,
22-
DType.List, DType.FixedSizeList, DType.Extension, DType.Variant {
23+
DType.List, DType.FixedSizeList, DType.Map, DType.Extension, DType.Variant {
2324

2425
/// Returns whether this type allows null values.
2526
///
@@ -36,7 +37,7 @@ default boolean isUnsigned() {
3637
return switch (this) {
3738
case Primitive(var pt, _) -> pt.isUnsigned();
3839
case Null _, Bool _, Decimal _, Utf8 _, Binary _, Struct _, List _,
39-
FixedSizeList _, Extension _, Variant _ -> false;
40+
FixedSizeList _, Map _, Extension _, Variant _ -> false;
4041
};
4142
}
4243

@@ -64,6 +65,7 @@ default DType withNullable(boolean nullable) {
6465
case Struct(var names, var types, _) -> new Struct(names, types, nullable);
6566
case List(var elem, _) -> new List(elem, nullable);
6667
case FixedSizeList(var elem, var size, _) -> new FixedSizeList(elem, size, nullable);
68+
case Map(var key, var value, var keysSorted, _) -> new Map(key, value, keysSorted, nullable);
6769
case Extension(var id, var storage, var meta, _) -> new Extension(id, storage, meta, nullable);
6870
case Variant _ -> new Variant(nullable);
6971
};
@@ -310,6 +312,44 @@ record List(DType elementType, boolean nullable) implements DType {
310312
record FixedSizeList(DType elementType, int fixedSize, boolean nullable) implements DType {
311313
}
312314

315+
/// Map logical type: an unordered collection of key/value entries per row.
316+
///
317+
/// Physically a list of non-nullable `{key, value}` structs — see [#entriesDtype()]. The key
318+
/// type must be non-nullable; the value type may be nullable.
319+
///
320+
/// @param keyType logical type of each entry key; must be non-nullable
321+
/// @param valueType logical type of each entry value
322+
/// @param keysSorted producer assertion that every row's keys are sorted ascending; never
323+
/// validated against the data, by either this implementation or the Rust
324+
/// reference
325+
/// @param nullable whether null values are permitted
326+
record Map(DType keyType, DType valueType, boolean keysSorted, boolean nullable) implements DType {
327+
328+
/// Rejects a nullable key type. Runs both on programmatic construction and while parsing
329+
/// an untrusted file's DType blob, so it raises [VortexException] rather than
330+
/// [IllegalArgumentException] — same reasoning as [Extension]'s metadata bound.
331+
///
332+
/// @throws NullPointerException if `keyType` is `null`
333+
/// @throws VortexException if `keyType` is nullable
334+
public Map {
335+
Objects.requireNonNull(keyType, "keyType");
336+
if (keyType.nullable()) {
337+
throw new VortexException("map key dtype must be non-nullable: " + keyType);
338+
}
339+
}
340+
341+
/// Returns the dtype of a single map entry: a non-nullable [Struct] of `key` and `value`.
342+
/// This is the element type of the list that physically backs a map column.
343+
///
344+
/// @return the non-nullable `{key, value}` [Struct] dtype
345+
public Struct entriesDtype() {
346+
return new Struct(
347+
java.util.List.of(ColumnName.of("key"), ColumnName.of("value")),
348+
java.util.List.of(keyType, valueType),
349+
false);
350+
}
351+
}
352+
313353
/// Extension logical type with user-defined semantics layered over a storage type.
314354
///
315355
/// @param extensionId unique string identifier for the extension type (e.g. `"vortex.timestamp"`)

core/src/main/java/io/github/dfa1/vortex/core/model/Editions.java

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,10 @@
1818
/// the union of everything it and every earlier edition of the same family added; see
1919
/// [#cumulativeMembers(Edition)].
2020
///
21-
/// vortex-java implements every `core`-family encoding through `core2026.07.0`, referenced below
22-
/// by their [EncodingId.WellKnown] constants. `core2026.08.0` adds `vortex.map`, the canonical
23-
/// encoding for a `Map` dtype vortex-java does not model yet ([DType] has no `Map` variant), so it
24-
/// is named as [EncodingId.Custom] like the unimplemented `unstable` ids below. Of `unstable`, it
25-
/// implements only `fastlanes.delta` and `vortex.patched` — the remaining ids
21+
/// vortex-java implements every `core`-family encoding through `core2026.08.0`, referenced below
22+
/// by their [EncodingId.WellKnown] constants — including `vortex.map`, the canonical encoding for
23+
/// the [DType.Map] logical type. Of `unstable`, it implements only `fastlanes.delta` and
24+
/// `vortex.patched` — the remaining ids
2625
/// (`vortex.zstd_buffers`, `vortex.parquet.variant`, the `vortex.tensor.*` family, `vortex.onpair`)
2726
/// have no `WellKnown` constant yet, so they are named as [EncodingId.Custom] instead; the catalog
2827
/// stores both uniformly and mirrors upstream faithfully rather than being truncated to what is
@@ -62,7 +61,7 @@ public final class Editions {
6261
/// The `core` edition adding the canonical Map encoding, released through August 2026.
6362
public static final Edition CORE_2026_08_0 = new Edition(
6463
new EditionId(EditionFamily.CORE, YearMonth.of(2026, 8), 0),
65-
Set.of(new EncodingId.Custom("vortex.map")));
64+
Set.of(EncodingId.VORTEX_MAP));
6665

6766
/// The May 2025 draft edition of the `unstable` family.
6867
public static final Edition UNSTABLE_2025_05_0 = new Edition(

core/src/main/java/io/github/dfa1/vortex/core/model/EncodingId.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ enum WellKnown implements EncodingId {
8686
VORTEX_LISTVIEW("vortex.listview"),
8787
/// ALP-RD (ALP with remainder dictionary) encoding (`vortex.alprd`).
8888
VORTEX_ALPRD("vortex.alprd"),
89+
/// Map encoding (`vortex.map`): a list-view of `{key, value}` entry structs.
90+
VORTEX_MAP("vortex.map"),
8991

9092
// Layout encoding IDs included so parser/registry can represent them safely
9193
/// Chunked layout encoding (`vortex.chunked`).
@@ -227,6 +229,8 @@ public String toString() {
227229
WellKnown VORTEX_LISTVIEW = WellKnown.VORTEX_LISTVIEW;
228230
/// Well-known `vortex.alprd` id.
229231
WellKnown VORTEX_ALPRD = WellKnown.VORTEX_ALPRD;
232+
/// Well-known `vortex.map` id.
233+
WellKnown VORTEX_MAP = WellKnown.VORTEX_MAP;
230234
/// Well-known `vortex.chunked` id.
231235
WellKnown VORTEX_CHUNKED = WellKnown.VORTEX_CHUNKED;
232236
/// Well-known `vortex.struct` id.

0 commit comments

Comments
 (0)