|
| 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