Skip to content

Commit 175ad07

Browse files
dfa1claude
andcommitted
refactor(core): Extension sealed hierarchy replaces Extensions utility class
Mirrors the Encoding / EncodingId pattern. The new sealed interface io.github.dfa1.vortex.core.Extension fuses the closed-world id classification with the typed decode behaviour: public sealed interface Extension permits Date, Time, Timestamp, Uuid, Custom { String id(); ... static Extension of(String id); } Each spec-defined variant (Date, Time, Timestamp, Uuid) is a final class with its own statically-typed decode methods — no Object return type, no caller-side downcasts: LocalDate d = Extension.DATE.decode(storage, i); LocalTime t = Extension.TIME.decode(ext, storage, i); Instant ts = Extension.TIMESTAMP.instant(ext, storage, i); java.util.UUID u = Extension.UUID.decode(storage, i); Optional<ZoneId> z = Extension.TIMESTAMP.timezone(ext); Custom(String id) carries any non-spec id verbatim so unknown extensions round-trip without loss. DType.Extension.kind() returns the matching record so callers pattern-match exhaustively: switch (ext.kind()) { case Extension.Date d -> d.decode(storage, i); case Extension.Time t -> t.decode(ext, storage, i); case Extension.Timestamp ts -> ts.instant(ext, storage, i); case Extension.Uuid u -> u.decode(storage, i); case Extension.Custom c -> renderPlaceholder(c.id()); } Drops core/array/Extensions.java entirely. Its String constants (DATE / TIME / TIMESTAMP / UUID_ID) move onto the records as ID constants; its static helpers move onto the records as instance methods; its shared utilities (epochInteger, readUnit, instantFromRaw, checkBounds) live as private static helpers inside the sealed interface. VortexInspectorTui's date format switch now binds the Extension.Date record and calls date.decode(array, i) directly, replacing the previous Extensions.localDate(ext, array, i) call. docs/compatibility.md updated with the new dispatch example and a table row for Extension.Custom. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 99417ad commit 175ad07

7 files changed

Lines changed: 650 additions & 781 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,16 @@ record Extension(
132132
ByteBuffer metadata,
133133
boolean nullable
134134
) implements DType {
135+
136+
/// Returns the closed-world classification of this extension's id.
137+
/// Pattern-match exhaustively: known ids resolve to the matching
138+
/// record, anything else lands in {@link io.github.dfa1.vortex.core.Extension.Custom}.
139+
///
140+
/// @return the {@link io.github.dfa1.vortex.core.Extension} record
141+
/// for this extension's id
142+
public io.github.dfa1.vortex.core.Extension kind() {
143+
return io.github.dfa1.vortex.core.Extension.of(extensionId);
144+
}
135145
}
136146

137147
/// Variant logical type for semi-structured data (analogous to Parquet variant / JSON).
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
package io.github.dfa1.vortex.core;
2+
3+
import io.github.dfa1.vortex.core.array.Array;
4+
import io.github.dfa1.vortex.core.array.ByteArray;
5+
import io.github.dfa1.vortex.core.array.FixedSizeListArray;
6+
import io.github.dfa1.vortex.core.array.IntArray;
7+
import io.github.dfa1.vortex.core.array.LongArray;
8+
import io.github.dfa1.vortex.core.array.MaskedArray;
9+
import io.github.dfa1.vortex.core.array.ShortArray;
10+
import io.github.dfa1.vortex.encoding.TimeUnit;
11+
12+
import java.nio.ByteBuffer;
13+
import java.nio.ByteOrder;
14+
import java.nio.charset.StandardCharsets;
15+
import java.time.Instant;
16+
import java.time.LocalDate;
17+
import java.time.LocalTime;
18+
import java.time.ZoneId;
19+
import java.time.ZoneOffset;
20+
import java.time.ZonedDateTime;
21+
import java.util.Optional;
22+
23+
/// Sealed hierarchy of Vortex extension dtypes — closed-world view of the
24+
/// four spec-defined extensions ({@code vortex.date}, {@code vortex.time},
25+
/// {@code vortex.timestamp}, {@code vortex.uuid}) plus a {@link Custom}
26+
/// fallback record carrying any other id.
27+
///
28+
/// <p>Mirrors the {@link io.github.dfa1.vortex.encoding.Encoding} /
29+
/// {@link io.github.dfa1.vortex.encoding.EncodingId} pairing in spirit but
30+
/// merges the kind classification with the typed decode behaviour: each
31+
/// record exposes its own statically-typed decode methods rather than a
32+
/// single {@code Object decode(...)} contract that callers would have to
33+
/// downcast. Pattern-match exhaustively to dispatch:
34+
///
35+
/// ```java
36+
/// switch (ext.kind()) {
37+
/// case Extension.Date d -> d.decode(storage, i); // LocalDate
38+
/// case Extension.Time t -> t.decode(ext, storage, i); // LocalTime
39+
/// case Extension.Timestamp ts -> ts.instant(ext, storage, i); // Instant
40+
/// case Extension.Uuid u -> u.decode(storage, i); // UUID
41+
/// case Extension.Custom c -> renderPlaceholder(c.id());
42+
/// }
43+
/// ```
44+
///
45+
/// <p>{@link DType.Extension} carries the wire-format id as a {@code String}
46+
/// so unknown ids round-trip without loss; {@link #of(String)} translates to
47+
/// the matching record.
48+
public sealed interface Extension {
49+
50+
/// Singleton for {@link Date}.
51+
Date DATE = new Date();
52+
/// Singleton for {@link Time}.
53+
Time TIME = new Time();
54+
/// Singleton for {@link Timestamp}.
55+
Timestamp TIMESTAMP = new Timestamp();
56+
/// Singleton for {@link Uuid}.
57+
Uuid UUID = new Uuid();
58+
59+
/// Returns the wire-format id string.
60+
///
61+
/// @return canonical extension id
62+
String id();
63+
64+
/// Resolves a wire-format id string to its {@link Extension} record.
65+
/// Unknown ids land in {@link Custom}.
66+
///
67+
/// @param id raw extension id from the file footer
68+
/// @return matching record, or {@link Custom} when {@code id} isn't recognised
69+
static Extension of(String id) {
70+
return switch (id) {
71+
case Date.ID -> DATE;
72+
case Time.ID -> TIME;
73+
case Timestamp.ID -> TIMESTAMP;
74+
case Uuid.ID -> UUID;
75+
default -> new Custom(id);
76+
};
77+
}
78+
79+
/// {@code vortex.date} — days (any signed integer width) since the
80+
/// Unix epoch. Per Arrow's canonical Date type.
81+
final class Date implements Extension {
82+
/// Wire id.
83+
public static final String ID = "vortex.date";
84+
85+
private Date() {
86+
}
87+
88+
@Override public String id() {
89+
return ID;
90+
}
91+
92+
/// Decodes the date cell at row {@code i}.
93+
///
94+
/// @param storage signed-integer storage (Byte/Short/Int/Long, possibly Masked)
95+
/// @param i row index, {@code 0 <= i < storage.length()}
96+
/// @return decoded date
97+
/// @throws VortexException if storage isn't an integer primitive
98+
public LocalDate decode(Array storage, long i) {
99+
checkBounds(i, storage.length());
100+
return LocalDate.ofEpochDay(epochInteger(storage, i));
101+
}
102+
}
103+
104+
/// {@code vortex.time} — sub-day count in the {@link TimeUnit} recorded
105+
/// in {@code ext.metadata()} byte 0.
106+
final class Time implements Extension {
107+
/// Wire id.
108+
public static final String ID = "vortex.time";
109+
110+
private Time() {
111+
}
112+
113+
@Override public String id() {
114+
return ID;
115+
}
116+
117+
/// Decodes the time-of-day cell at row {@code i}.
118+
///
119+
/// @param ext declared extension dtype carrying the {@link TimeUnit} byte
120+
/// @param storage signed-integer storage (I32 for s/ms, I64 for μs/ns)
121+
/// @param i row index, {@code 0 <= i < storage.length()}
122+
/// @return decoded local time
123+
/// @throws VortexException if the metadata unit is {@link TimeUnit#Days}
124+
/// or storage isn't an integer primitive
125+
public LocalTime decode(DType.Extension ext, Array storage, long i) {
126+
checkBounds(i, storage.length());
127+
TimeUnit unit = readUnit(ext);
128+
if (unit == TimeUnit.Days) {
129+
throw new VortexException("Time.decode: Days unit not valid for vortex.time");
130+
}
131+
long raw = epochInteger(storage, i);
132+
long nanos = raw * (1_000_000_000L / unit.divisor());
133+
return LocalTime.ofNanoOfDay(nanos);
134+
}
135+
136+
/// Returns the {@link TimeUnit} recorded in the extension metadata.
137+
///
138+
/// @param ext extension dtype
139+
/// @return decoded time unit
140+
public TimeUnit unit(DType.Extension ext) {
141+
return readUnit(ext);
142+
}
143+
}
144+
145+
/// {@code vortex.timestamp} — I64 epoch count plus optional IANA timezone.
146+
/// Metadata layout: {@code byte[0] = TimeUnit tag, bytes[1..3] = tz_len
147+
/// (u16 LE), bytes[3..3+tz_len] = tz UTF-8}.
148+
final class Timestamp implements Extension {
149+
/// Wire id.
150+
public static final String ID = "vortex.timestamp";
151+
152+
private Timestamp() {
153+
}
154+
155+
@Override public String id() {
156+
return ID;
157+
}
158+
159+
/// Decodes the timestamp cell at row {@code i} to an {@link Instant},
160+
/// ignoring any timezone the metadata carries.
161+
///
162+
/// @param ext declared extension dtype
163+
/// @param storage signed-integer storage array
164+
/// @param i row index, {@code 0 <= i < storage.length()}
165+
/// @return decoded instant
166+
/// @throws VortexException if the metadata unit is {@link TimeUnit#Days}
167+
/// or storage isn't an integer primitive
168+
public Instant instant(DType.Extension ext, Array storage, long i) {
169+
checkBounds(i, storage.length());
170+
TimeUnit unit = readUnit(ext);
171+
if (unit == TimeUnit.Days) {
172+
throw new VortexException("Timestamp.instant: Days unit not valid");
173+
}
174+
return instantFromRaw(epochInteger(storage, i), unit);
175+
}
176+
177+
/// Decodes the timestamp cell at row {@code i} to a {@link ZonedDateTime}
178+
/// using the timezone from the metadata, defaulting to UTC when absent.
179+
///
180+
/// @param ext declared extension dtype
181+
/// @param storage signed-integer storage array
182+
/// @param i row index, {@code 0 <= i < storage.length()}
183+
/// @return decoded zoned date-time
184+
public ZonedDateTime zonedDateTime(DType.Extension ext, Array storage, long i) {
185+
return instant(ext, storage, i).atZone(timezone(ext).orElse(ZoneOffset.UTC));
186+
}
187+
188+
/// Returns the IANA timezone string recorded in the extension metadata.
189+
///
190+
/// @param ext declared extension dtype
191+
/// @return parsed zone id, or empty when {@code tz_len == 0}
192+
/// @throws VortexException if the metadata is truncated mid-string
193+
public Optional<ZoneId> timezone(DType.Extension ext) {
194+
ByteBuffer meta = ext.metadata();
195+
if (meta == null || meta.remaining() < 3) {
196+
return Optional.empty();
197+
}
198+
ByteBuffer le = meta.duplicate().order(ByteOrder.LITTLE_ENDIAN);
199+
int basePos = le.position();
200+
int tzLen = Short.toUnsignedInt(le.getShort(basePos + 1));
201+
if (tzLen == 0) {
202+
return Optional.empty();
203+
}
204+
if (le.remaining() < 3 + tzLen) {
205+
throw new VortexException("timestamp metadata truncated: declared tz_len="
206+
+ tzLen + " but only " + (le.remaining() - 3) + " bytes available");
207+
}
208+
byte[] tzBytes = new byte[tzLen];
209+
for (int k = 0; k < tzLen; k++) {
210+
tzBytes[k] = le.get(basePos + 3 + k);
211+
}
212+
return Optional.of(ZoneId.of(new String(tzBytes, StandardCharsets.UTF_8)));
213+
}
214+
215+
/// Returns the {@link TimeUnit} recorded in the extension metadata.
216+
///
217+
/// @param ext extension dtype
218+
/// @return decoded time unit
219+
public TimeUnit unit(DType.Extension ext) {
220+
return readUnit(ext);
221+
}
222+
}
223+
224+
/// {@code vortex.uuid} — 16-byte UUID stored as
225+
/// {@code FixedSizeList(Primitive(U8), 16)}.
226+
final class Uuid implements Extension {
227+
/// Wire id.
228+
public static final String ID = "vortex.uuid";
229+
230+
private Uuid() {
231+
}
232+
233+
@Override public String id() {
234+
return ID;
235+
}
236+
237+
/// Decodes the UUID cell at row {@code i}.
238+
///
239+
/// @param storage UUID storage array
240+
/// @param i row index, {@code 0 <= i < storage.length()}
241+
/// @return decoded {@link java.util.UUID}
242+
/// @throws VortexException if storage isn't a {@code FixedSizeListArray<ByteArray>}
243+
/// of size 16
244+
public java.util.UUID decode(Array storage, long i) {
245+
checkBounds(i, storage.length());
246+
if (!(storage instanceof FixedSizeListArray fsl)) {
247+
throw new VortexException("Uuid.decode: expected FixedSizeListArray, got "
248+
+ storage.getClass().getSimpleName());
249+
}
250+
if (fsl.fixedSize() != 16) {
251+
throw new VortexException("Uuid.decode: expected fixedSize 16, got " + fsl.fixedSize());
252+
}
253+
if (!(fsl.elements() instanceof ByteArray bytes)) {
254+
throw new VortexException("Uuid.decode: expected ByteArray elements, got "
255+
+ fsl.elements().getClass().getSimpleName());
256+
}
257+
long base = i * 16;
258+
long msb = 0L;
259+
long lsb = 0L;
260+
for (int k = 0; k < 8; k++) {
261+
msb = (msb << 8) | (bytes.getByte(base + k) & 0xffL);
262+
}
263+
for (int k = 0; k < 8; k++) {
264+
lsb = (lsb << 8) | (bytes.getByte(base + 8 + k) & 0xffL);
265+
}
266+
return new java.util.UUID(msb, lsb);
267+
}
268+
}
269+
270+
/// Open-world escape hatch for any extension id Vortex-java doesn't
271+
/// know about. Pattern-match branches that need to render or decode an
272+
/// unknown extension read its raw id via {@link #id()}.
273+
///
274+
/// @param id raw extension id string
275+
record Custom(String id) implements Extension {
276+
}
277+
278+
// ── Shared helpers ────────────────────────────────────────────────────
279+
280+
/// Reads a signed integer from any of the integer primitive arrays as
281+
/// {@code long}. Recurses through {@link MaskedArray}; throws on null
282+
/// cells so callers don't silently get garbage for nullable columns.
283+
private static long epochInteger(Array storage, long i) {
284+
return switch (storage) {
285+
case ByteArray a -> a.getByte(i);
286+
case ShortArray a -> a.getShort(i);
287+
case IntArray a -> a.getInt(i);
288+
case LongArray a -> a.getLong(i);
289+
case MaskedArray a -> {
290+
if (!a.isValid(i)) {
291+
throw new VortexException("null cell at index " + i);
292+
}
293+
yield epochInteger(a.inner(), i);
294+
}
295+
default -> throw new VortexException(
296+
"unsupported storage type " + storage.getClass().getSimpleName());
297+
};
298+
}
299+
300+
/// Reads the {@link TimeUnit} metadata byte at the buffer's current
301+
/// position; throws if the buffer is null or empty.
302+
private static TimeUnit readUnit(DType.Extension ext) {
303+
ByteBuffer meta = ext.metadata();
304+
if (meta == null || !meta.hasRemaining()) {
305+
throw new VortexException("missing TimeUnit metadata byte for " + ext.extensionId());
306+
}
307+
return TimeUnit.fromTag(meta.get(meta.position()));
308+
}
309+
310+
private static Instant instantFromRaw(long raw, TimeUnit unit) {
311+
return switch (unit) {
312+
case Seconds -> Instant.ofEpochSecond(raw);
313+
case Milliseconds -> Instant.ofEpochMilli(raw);
314+
case Microseconds -> {
315+
long secs = Math.floorDiv(raw, 1_000_000L);
316+
long nanos = Math.floorMod(raw, 1_000_000L) * 1_000L;
317+
yield Instant.ofEpochSecond(secs, nanos);
318+
}
319+
case Nanoseconds -> {
320+
long secs = Math.floorDiv(raw, 1_000_000_000L);
321+
long nanos = Math.floorMod(raw, 1_000_000_000L);
322+
yield Instant.ofEpochSecond(secs, nanos);
323+
}
324+
case Days -> throw new VortexException("Days unit not valid for instant");
325+
};
326+
}
327+
328+
private static void checkBounds(long i, long length) {
329+
if (i < 0 || i >= length) {
330+
throw new IndexOutOfBoundsException("index " + i + " out of bounds for length " + length);
331+
}
332+
}
333+
}

0 commit comments

Comments
 (0)