Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ When stuck on encode/decode behavior, consult **in this order**:
## Design decisions

- **DType is pluggable only via `Extension`.** `DType` is a sealed interface; downstream code must
not add variants. Use `new DType.Extension("ip.address", new DType.Primitive(PType.I32, false),
not add variants. Use `new DType.Extension(ExtensionId.parse("ip.address"), new DType.Primitive(PType.I32, false),
OpaqueMetadata.EMPTY, false)` and register an `ExtensionDecoder`/`ExtensionEncoder` on
`ReadRegistry`/`WriteRegistry`. Mirrors Rust's `ExtVTable` (`vortex.date`, `vortex.uuid`, …): an
unregistered id fails the read unless `allowUnknown()`, then reads as storage. No SPI for DType
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Breaking:** CSV import stores an empty cell in a number or boolean column as null instead of `0` or `false`, so inferred number and boolean columns are now nullable (read through `MaskedArray`), and an empty cell or a missing column fails the import where the schema declares the column non-nullable. ([#592](https://github.com/dfa1/vortex-java/issues/592))
- **Breaking:** the shared non-nullable dtypes move from `DType` to the new `DTypes` class: `DTypes.I64`, `DTypes.UTF8`, … ([#624](https://github.com/dfa1/vortex-java/pull/624))
- **Breaking:** an extension dtype's metadata is a typed `ExtensionMetadata` record instead of a `MemorySegment`, parsed when a file opens and serialized when it is written, as Rust does: `TimeUnitMetadata` for date and time, `TimestampMetadata` (its zone kept as stored) for timestamp, `OpaqueMetadata` for bytes nothing parses, and a third-party record through `ExtensionDecoder#parseMetadata` and `ExtensionEncoder#serializeMetadata`. ([#625](https://github.com/dfa1/vortex-java/pull/625))
- **Breaking:** `DType.Extension#extensionId()` is an `ExtensionId` instead of a `String`; build one with `ExtensionId.VORTEX_DATE` or `ExtensionId.parse("acme.money")`. ([#626](https://github.com/dfa1/vortex-java/pull/626))
- **Breaking:** `MemorySize` moves from `io.github.dfa1.vortex.core.model` to `io.github.dfa1.vortex.core.io`. ([#625](https://github.com/dfa1/vortex-java/pull/625))

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ record Temporal(ExtensionId.WellKnown kind, TimeUnit unit) {
/// @throws VortexException for a unit the extension does not allow, as the decoders do
static Temporal of(DType type) {
if (!(type instanceof DType.Extension ext)
|| !(ExtensionId.parse(ext.extensionId()) instanceof ExtensionId.WellKnown kind)
|| !(ext.extensionId() instanceof ExtensionId.WellKnown kind)
|| kind != ExtensionId.WellKnown.VORTEX_DATE && kind != ExtensionId.WellKnown.VORTEX_TIME
&& kind != ExtensionId.WellKnown.VORTEX_TIMESTAMP) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ private static Object value(Object array, DType type, long r) {
case DType.Decimal _ -> ((DecimalArray) array).getDecimal(r);
// Calcite's internal forms: DATE is days since the epoch (int), TIME milliseconds of the
// day (int), TIMESTAMP milliseconds since the epoch (long)
case DType.Extension ext -> switch (ExtensionId.parse(ext.extensionId())) {
case DType.Extension ext -> switch (ext.extensionId()) {
case ExtensionId.WellKnown.VORTEX_DATE ->
Math.toIntExact(DateExtensionDecoder.INSTANCE.decode(ext, (Array) array, r).toEpochDay());
case ExtensionId.WellKnown.VORTEX_TIME ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1557,7 +1557,7 @@ private static RelDataType toSqlType(RelDataTypeFactory factory, DType type) {
case DType.Bool _ -> factory.createSqlType(SqlTypeName.BOOLEAN);
case DType.Null _ -> factory.createSqlType(SqlTypeName.NULL);
case DType.Decimal d -> factory.createSqlType(SqlTypeName.DECIMAL, d.precision(), d.scale());
case DType.Extension ext -> switch (ExtensionId.parse(ext.extensionId())) {
case DType.Extension ext -> switch (ext.extensionId()) {
case ExtensionId.WellKnown.VORTEX_DATE -> factory.createSqlType(SqlTypeName.DATE);
// millisecond precision, the precision the scan delivers: at Calcite's default of 0 a
// literal built for the column (a pushed-down MIN/MAX, a top-N row) lost its millis
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ private static boolean isUnsigned(Array arr) {
}

private static Optional<String> formatExtension(DType.Extension ext, Array storage, long i) {
if (!(ExtensionId.parse(ext.extensionId()) instanceof ExtensionId.WellKnown id)) {
if (!(ext.extensionId() instanceof ExtensionId.WellKnown id)) {
return Optional.empty();
}
return switch (id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ private InspectorRender() {
/// @return formatted value, or an angle-bracketed fallback for unknown shapes
static String formatValue(Array array, int i, DType declared) {
if (declared instanceof DType.Extension ext
&& ExtensionId.parse(ext.extensionId()) == ExtensionId.VORTEX_DATE) {
&& ext.extensionId() == ExtensionId.VORTEX_DATE) {
try {
return DateExtensionDecoder.INSTANCE.decode(ext, array, i).toString();
} catch (RuntimeException _) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.github.dfa1.vortex.core.model.ColumnName;
import io.github.dfa1.vortex.core.model.DType;
import io.github.dfa1.vortex.core.model.ExtensionId;
import io.github.dfa1.vortex.core.model.DTypes;
import io.github.dfa1.vortex.core.model.OpaqueMetadata;
import io.github.dfa1.vortex.core.model.PType;
Expand Down Expand Up @@ -91,8 +92,8 @@ static Stream<Arguments> dtypeCases() {
arguments(new DType.Map(DTypes.UTF8, i64, true, false), "map<utf8, I64, keys_sorted=true>"),
arguments(new DType.Map(DTypes.UTF8, new DType.Primitive(PType.I64, true), false, false),
"map<utf8, I64?, keys_sorted=false>"),
arguments(new DType.Extension("vortex.uuid", i64, OpaqueMetadata.EMPTY, false), "ext<vortex.uuid>"),
arguments(new DType.Extension("vortex.uuid", i64, OpaqueMetadata.EMPTY, true), "ext<vortex.uuid>?"),
arguments(new DType.Extension(ExtensionId.VORTEX_UUID, i64, OpaqueMetadata.EMPTY, false), "ext<vortex.uuid>"),
arguments(new DType.Extension(ExtensionId.VORTEX_UUID, i64, OpaqueMetadata.EMPTY, true), "ext<vortex.uuid>?"),
arguments(DTypes.VARIANT, "variant"),
arguments(new DType.Variant(true), "variant?"),
arguments(new DType.Struct(List.of(ColumnName.of("a"), ColumnName.of("b")), List.of(i64, DTypes.UTF8), false),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.github.dfa1.vortex.core.model.ColumnName;
import io.github.dfa1.vortex.core.model.DType;
import io.github.dfa1.vortex.core.model.ExtensionId;
import io.github.dfa1.vortex.core.model.DTypes;
import io.github.dfa1.vortex.writer.VortexWriter;
import io.github.dfa1.vortex.writer.WriteOptions;
Expand Down Expand Up @@ -160,7 +161,7 @@ void allowUnknown_doesNotLeakIntoTheNextInvocation(@TempDir Path tmp) throws IOE
private static Path writeCustomExtensionVortex(Path dir) throws IOException {
Path file = dir.resolve("money.vortex");
DType.Struct schema = new DType.Struct(List.of(ColumnName.of("price")),
List.of(new DType.Extension("acme.money", DTypes.I64,
List.of(new DType.Extension(ExtensionId.parse("acme.money"), DTypes.I64,
new io.github.dfa1.vortex.core.model.OpaqueMetadata(
java.lang.foreign.MemorySegment.ofArray("EUR".getBytes(StandardCharsets.US_ASCII))), false)),
false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.github.dfa1.vortex.core.model.ColumnName;
import io.github.dfa1.vortex.core.model.DType;
import io.github.dfa1.vortex.core.model.ExtensionId;
import io.github.dfa1.vortex.core.model.DTypes;
import io.github.dfa1.vortex.core.model.TimeUnit;
import io.github.dfa1.vortex.reader.array.Array;
Expand Down Expand Up @@ -205,7 +206,7 @@ void truncatesBinaryHexBeyond16Bytes() {
void extensionDecodeFailureRendersDiagnostic() {
try (Arena arena = Arena.ofConfined()) {
// Given — vortex.date declared but storage is F64, which epochInteger rejects
DType dateExt = new DType.Extension("vortex.date",
DType dateExt = new DType.Extension(ExtensionId.VORTEX_DATE,
DTypes.I32, new io.github.dfa1.vortex.core.model.TimeUnitMetadata(
io.github.dfa1.vortex.core.model.TimeUnit.Days), false);
Array badStorage = ArrayFixtures.doubles(arena, 1.5);
Expand All @@ -222,7 +223,7 @@ void extensionDecodeFailureRendersDiagnostic() {
void unknownExtensionIdFallsThroughToStorageRendering() {
try (Arena arena = Arena.ofConfined()) {
// Given — an extension id this reader does not know: render the raw storage value
DType unknownExt = new DType.Extension("custom.thing",
DType unknownExt = new DType.Extension(ExtensionId.parse("custom.thing"),
DTypes.I32, io.github.dfa1.vortex.core.model.OpaqueMetadata.EMPTY, false);
IntArray storage = ArrayFixtures.ints(arena, 42);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.github.dfa1.vortex.core.model.ColumnName;
import io.github.dfa1.vortex.core.model.DType;
import io.github.dfa1.vortex.core.model.ExtensionId;
import io.github.dfa1.vortex.core.model.DTypes;
import io.github.dfa1.vortex.core.model.PType;
import io.github.dfa1.vortex.reader.array.Array;
Expand Down Expand Up @@ -146,7 +147,7 @@ void rendersBinaryVarBinAsShortHex() {
void dateExtensionDecodeFailureFallsThroughToGeneric() {
try (Arena arena = Arena.ofConfined()) {
// Given — vortex.date declared but storage is F64; the decode throws and is swallowed
DType dateExt = new DType.Extension("vortex.date",
DType dateExt = new DType.Extension(ExtensionId.VORTEX_DATE,
DTypes.I32, new io.github.dfa1.vortex.core.model.TimeUnitMetadata(
io.github.dfa1.vortex.core.model.TimeUnit.Days), false);
Array badStorage = ArrayFixtures.doubles(arena, 1.5);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,22 +335,24 @@ public Struct entriesDtype() {

/// Extension logical type with user-defined semantics layered over a storage type.
///
/// @param extensionId unique string identifier for the extension type (e.g. `"vortex.timestamp"`)
/// @param extensionId the extension type's id ([ExtensionId#VORTEX_TIMESTAMP], or a custom id
/// from [ExtensionId#parse(String)])
/// @param storageDType underlying storage dtype used for physical encoding
/// @param metadata the extension's metadata, [OpaqueMetadata#EMPTY] for none
/// @param nullable whether null values are permitted
record Extension(
String extensionId,
ExtensionId extensionId,
DType storageDType,
ExtensionMetadata metadata,
boolean nullable
) implements DType {

/// Requires metadata that is a record, so the dtype compares and hashes by value.
///
/// @throws NullPointerException if `metadata` is `null`
/// @throws NullPointerException if `extensionId` or `metadata` is `null`
/// @throws IllegalArgumentException if `metadata` is not a record
public Extension {
Objects.requireNonNull(extensionId, "extensionId");
Objects.requireNonNull(metadata, "metadata");
if (!metadata.getClass().isRecord()) {
throw new IllegalArgumentException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public static DType.Extension of(TimeUnit unit, boolean nullable) {
case Days -> throw new IllegalArgumentException("Days unit not valid for vortex.time");
};
return new DType.Extension(
ExtensionId.VORTEX_TIME.id(),
ExtensionId.VORTEX_TIME,
new DType.Primitive(storage, nullable),
new TimeUnitMetadata(unit),
nullable);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public static DType.Extension of(boolean nullable) {
/// @return matching extension dtype
public static DType.Extension of(TimeUnit unit, ZoneId zone, boolean nullable) {
return new DType.Extension(
ExtensionId.VORTEX_TIMESTAMP.id(),
ExtensionId.VORTEX_TIMESTAMP,
new DType.Primitive(PType.I64, nullable),
TimestampMetadata.of(unit, zone),
nullable);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,19 @@ class Metadata {
@Test
void nullMetadata_rejected() {
// Given / When / Then — no metadata is OpaqueMetadata.EMPTY, so a dtype always has one
assertThatThrownBy(() -> new DType.Extension("vortex.date", I32, null, false))
assertThatThrownBy(() -> new DType.Extension(ExtensionId.VORTEX_DATE, I32, null, false))
.isInstanceOf(NullPointerException.class);
}

@Test
void nullId_rejected() {
// Given / When / Then — refused where it is built, not later as an NPE in the serializer
// (`extensionId().id()`) or a registry lookup
assertThatThrownBy(() -> new DType.Extension(null, I32, OpaqueMetadata.EMPTY, false))
.isInstanceOf(NullPointerException.class)
.hasMessage("extensionId");
}

@Test
void nonRecordMetadata_rejected() {
// Given metadata whose equality is identity: a dtype holding it would not compare by
Expand All @@ -34,7 +43,7 @@ void nonRecordMetadata_rejected() {
};

// When / Then
assertThatThrownBy(() -> new DType.Extension("acme.custom", I32, identity, false))
assertThatThrownBy(() -> new DType.Extension(ExtensionId.parse("acme.custom"), I32, identity, false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must be a record");
}
Expand All @@ -46,10 +55,10 @@ void sameOpaqueBytes_equalDtypes() {
MemorySegment offHeap = Arena.ofAuto().allocate(2);
offHeap.set(ValueLayout.JAVA_BYTE, 0, (byte) 1);
offHeap.set(ValueLayout.JAVA_BYTE, 1, (byte) 2);
var built = new DType.Extension("acme.custom", I32, new OpaqueMetadata(MemorySegment.ofArray(new byte[]{1, 2})), false);
var built = new DType.Extension(ExtensionId.parse("acme.custom"), I32, new OpaqueMetadata(MemorySegment.ofArray(new byte[]{1, 2})), false);

// When
var result = new DType.Extension("acme.custom", I32, new OpaqueMetadata(offHeap), false);
var result = new DType.Extension(ExtensionId.parse("acme.custom"), I32, new OpaqueMetadata(offHeap), false);

// Then
assertThat(result).isEqualTo(built).hasSameHashCodeAs(built);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ static Stream<Arguments> nonNullableSamples() {
Arguments.of("Struct", new DType.Struct(List.of(ColumnName.of("a"), ColumnName.of("b")), List.of(i32, DTypes.UTF8), false)),
Arguments.of("List", new DType.List(i32, false)),
Arguments.of("FixedSizeList", new DType.FixedSizeList(i32, 4, false)),
Arguments.of("Extension", new DType.Extension("ip.address", i32, OpaqueMetadata.EMPTY, false)),
Arguments.of("Extension", new DType.Extension(ExtensionId.parse("ip.address"), i32, OpaqueMetadata.EMPTY, false)),
Arguments.of("Variant", DTypes.VARIANT)
);
}
Expand Down Expand Up @@ -97,15 +97,15 @@ void withNullable_preservesCompoundPayload_struct() {
void withNullable_preservesCompoundPayload_extensionAndFixedSizeList() {
// Given
DType storage = DTypes.I32;
DType.Extension ext = new DType.Extension("ip.address", storage, OpaqueMetadata.EMPTY, false);
DType.Extension ext = new DType.Extension(ExtensionId.parse("ip.address"), storage, OpaqueMetadata.EMPTY, false);
DType.FixedSizeList fsl = new DType.FixedSizeList(storage, 16, false);

// When
DType.Extension extResult = (DType.Extension) ext.withNullable(true);
DType.FixedSizeList fslResult = (DType.FixedSizeList) fsl.withNullable(true);

// Then
assertThat(extResult.extensionId()).isEqualTo("ip.address");
assertThat(extResult.extensionId().id()).isEqualTo("ip.address");
assertThat(extResult.storageDType()).isEqualTo(storage);
assertThat(extResult.metadata()).isEqualTo(OpaqueMetadata.EMPTY);
assertThat(fslResult.fixedSize()).isEqualTo(16);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ static Stream<Arguments> invalid() {
@MethodSource("valid")
void validate_validDtype_passes(String name, DType.Extension dtype) {
// Given a dtype Rust accepts
ExtensionId.WellKnown sut = (ExtensionId.WellKnown) ExtensionId.parse(dtype.extensionId());
ExtensionId.WellKnown sut = (ExtensionId.WellKnown) dtype.extensionId();

// When / Then
sut.validate(dtype);
Expand All @@ -185,7 +185,7 @@ void validate_validDtype_passes(String name, DType.Extension dtype) {
@MethodSource("invalid")
void validate_invalidDtype_throws(DType.Extension dtype, String message) {
// Given a dtype Rust refuses
ExtensionId.WellKnown sut = (ExtensionId.WellKnown) ExtensionId.parse(dtype.extensionId());
ExtensionId.WellKnown sut = (ExtensionId.WellKnown) dtype.extensionId();

// When / Then
assertThatThrownBy(() -> sut.validate(dtype))
Expand Down Expand Up @@ -214,16 +214,16 @@ private static DType.Extension ext(ExtensionId id, DType storage, int... meta) {
for (int i = 0; i < meta.length; i++) {
bytes[i] = (byte) meta[i];
}
return new DType.Extension(id.id(), storage, new OpaqueMetadata(MemorySegment.ofArray(bytes)),
return new DType.Extension(id, storage, new OpaqueMetadata(MemorySegment.ofArray(bytes)),
storage.nullable());
}

private static DType.Extension unit(ExtensionId id, DType storage, TimeUnit unit) {
return new DType.Extension(id.id(), storage, new TimeUnitMetadata(unit), storage.nullable());
return new DType.Extension(id, storage, new TimeUnitMetadata(unit), storage.nullable());
}

private static DType.Extension timestamp(DType storage, TimeUnit unit, ZoneId zone) {
return new DType.Extension(ExtensionId.VORTEX_TIMESTAMP.id(), storage, TimestampMetadata.of(unit, zone),
return new DType.Extension(ExtensionId.VORTEX_TIMESTAMP, storage, TimestampMetadata.of(unit, zone),
storage.nullable());
}
}
Expand Down
2 changes: 1 addition & 1 deletion docs/how-to.md
Original file line number Diff line number Diff line change
Expand Up @@ -1044,7 +1044,7 @@ final class MoneyEncoder implements ExtensionEncoder {
static final ExtensionId ID = ExtensionId.parse("acme.money");

static DType.Extension dtype(Currency currency) {
return new DType.Extension(ID.id(), DTypes.I64, new MoneyMetadata(currency), false);
return new DType.Extension(ID, DTypes.I64, new MoneyMetadata(currency), false);
}

@Override
Expand Down
Loading
Loading