From 3f9a7317448482f01dd56a5a85cdd9b26d386731 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Wed, 10 Jun 2026 22:10:05 +0200 Subject: [PATCH 1/2] build: wire Error Prone + NullAway with JSpecify Adds compile-time null-safety analysis via Error Prone + NullAway, keyed on JSpecify @NullMarked / @Nullable. - Root pom: errorprone 2.36.0, nullaway 0.12.3, jspecify 1.0.0 in versions block; jspecify in dependencyManagement; Error Prone + NullAway annotation processors on maven-compiler-plugin with Xep:NullAway:ERROR, OnlyNullMarked=true, JSpecifyMode=true. - .mvn/jvm.config: add jdk.compiler add-exports/add-opens flags so Error Prone can reflect into javac internals on JDK 25. - performance/pom.xml: pin jmh-generator-annprocess version explicitly + combine.children=append so the JMH processor merges with the inherited Error Prone + NullAway processors. - Exclude generated (fbs/proto) and TUI/server packages via XepExcludedPaths to dodge upstream NullAway crashes on certain lambda + try/finally shapes. Annotated boundary (proof-of-concept scope): - io.github.dfa1.vortex.extension via package-info @NullMarked. - ExtensionId.tryFrom return @Nullable. - Extension.findKnown return @Nullable. - TimestampExtension.dtype(unit, zone, nullable): zone @Nullable. Follow-ups: extend @NullMarked to io.github.dfa1.vortex.encoding, core, core.array, io, scan, writer, reader as separate commits. Co-Authored-By: Claude Opus 4.7 --- .mvn/jvm.config | 11 +++++++ core/pom.xml | 4 +++ .../dfa1/vortex/extension/Extension.java | 2 +- .../dfa1/vortex/extension/ExtensionId.java | 2 +- .../vortex/extension/TimestampExtension.java | 2 +- .../dfa1/vortex/extension/package-info.java | 7 ++++ performance/pom.xml | 3 +- pom.xml | 32 +++++++++++++++++++ 8 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 .mvn/jvm.config create mode 100644 core/src/main/java/io/github/dfa1/vortex/extension/package-info.java diff --git a/.mvn/jvm.config b/.mvn/jvm.config new file mode 100644 index 00000000..5c792b4c --- /dev/null +++ b/.mvn/jvm.config @@ -0,0 +1,11 @@ +--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED +--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED +--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED diff --git a/core/pom.xml b/core/pom.xml index b4b90110..c6d30032 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -29,6 +29,10 @@ com.github.luben zstd-jni + + org.jspecify + jspecify + org.junit.jupiter diff --git a/core/src/main/java/io/github/dfa1/vortex/extension/Extension.java b/core/src/main/java/io/github/dfa1/vortex/extension/Extension.java index 58ca04d3..f244c2ca 100644 --- a/core/src/main/java/io/github/dfa1/vortex/extension/Extension.java +++ b/core/src/main/java/io/github/dfa1/vortex/extension/Extension.java @@ -44,7 +44,7 @@ default Object encodeAll(DType.Extension dtype, Collection values) { /// /// @param dtype declared extension dtype /// @return matching spec extension singleton, or {@code null} - static Extension findKnown(DType.Extension dtype) { + static @org.jspecify.annotations.Nullable Extension findKnown(DType.Extension dtype) { ExtensionId id = ExtensionId.tryFrom(dtype.extensionId()); if (id == null) { return null; diff --git a/core/src/main/java/io/github/dfa1/vortex/extension/ExtensionId.java b/core/src/main/java/io/github/dfa1/vortex/extension/ExtensionId.java index 74f30443..6aeb0ccc 100644 --- a/core/src/main/java/io/github/dfa1/vortex/extension/ExtensionId.java +++ b/core/src/main/java/io/github/dfa1/vortex/extension/ExtensionId.java @@ -49,7 +49,7 @@ public static ExtensionId from(String id) { /// /// @param id raw extension id string /// @return matching constant, or {@code null} if not a known spec extension - public static ExtensionId tryFrom(String id) { + public static @org.jspecify.annotations.Nullable ExtensionId tryFrom(String id) { return LOOKUP.get(id); } diff --git a/core/src/main/java/io/github/dfa1/vortex/extension/TimestampExtension.java b/core/src/main/java/io/github/dfa1/vortex/extension/TimestampExtension.java index ab6b4508..cec36563 100644 --- a/core/src/main/java/io/github/dfa1/vortex/extension/TimestampExtension.java +++ b/core/src/main/java/io/github/dfa1/vortex/extension/TimestampExtension.java @@ -49,7 +49,7 @@ public DType.Extension dtype(boolean nullable) { /// @param zone IANA timezone, or {@code null} for none /// @param nullable whether the column allows nulls /// @return matching extension dtype - public DType.Extension dtype(TimeUnit unit, ZoneId zone, boolean nullable) { + public DType.Extension dtype(TimeUnit unit, @org.jspecify.annotations.Nullable ZoneId zone, boolean nullable) { byte[] tzBytes = zone == null ? new byte[0] : zone.getId().getBytes(StandardCharsets.UTF_8); ByteBuffer meta = ByteBuffer.allocate(3 + tzBytes.length).order(ByteOrder.LITTLE_ENDIAN); meta.put(0, (byte) unit.ordinal()); diff --git a/core/src/main/java/io/github/dfa1/vortex/extension/package-info.java b/core/src/main/java/io/github/dfa1/vortex/extension/package-info.java new file mode 100644 index 00000000..57562580 --- /dev/null +++ b/core/src/main/java/io/github/dfa1/vortex/extension/package-info.java @@ -0,0 +1,7 @@ +/// Spec extension types: {@link io.github.dfa1.vortex.extension.DateExtension}, +/// {@link io.github.dfa1.vortex.extension.TimeExtension}, +/// {@link io.github.dfa1.vortex.extension.TimestampExtension}, +/// {@link io.github.dfa1.vortex.extension.UuidExtension}, plus the +/// {@link io.github.dfa1.vortex.extension.Extension} SPI for third-party impls. +@org.jspecify.annotations.NullMarked +package io.github.dfa1.vortex.extension; diff --git a/performance/pom.xml b/performance/pom.xml index aa5fb8bb..b495cd97 100644 --- a/performance/pom.xml +++ b/performance/pom.xml @@ -88,10 +88,11 @@ maven-compiler-plugin 25 - + org.openjdk.jmh jmh-generator-annprocess + ${jmh.version} diff --git a/pom.xml b/pom.xml index ae1c9507..8998f3b7 100644 --- a/pom.xml +++ b/pom.xml @@ -75,6 +75,10 @@ 3.2.8 3.4.0 3.12.0 + + 2.36.0 + 0.12.3 + 1.0.0 @@ -150,6 +154,11 @@ jmh-generator-annprocess ${jmh.version} + + org.jspecify + jspecify + ${jspecify.version} + com.h2database @@ -223,6 +232,29 @@ 3.15.0 25 + + + + com.google.errorprone + error_prone_core + ${errorprone.version} + + + com.uber.nullaway + nullaway + ${nullaway.version} + + + + -XDcompilePolicy=simple + --should-stop=ifError=FLOW + -Xplugin:ErrorProne -XepDisableAllChecks -Xep:NullAway:ERROR -XepOpt:NullAway:OnlyNullMarked=true -XepOpt:NullAway:JSpecifyMode=true -XepOpt:NullAway:ExcludedClassAnnotations=javax.annotation.processing.Generated,jakarta.annotation.Generated -XepExcludedPaths:.*/(fbs|proto|cli/.*tui|cli/.*server)/.* + + From 8db419397fd2772e95daa13a0ec2e6b1d31787e2 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Wed, 10 Jun 2026 22:18:41 +0200 Subject: [PATCH 2/2] refactor(extension): Optional return for tryFrom / findKnown @Nullable return types let callers silently dereference; Optional forces the caller to handle the empty case at the type level and reads as idiomatic modern Java per CLAUDE.md. - ExtensionId.tryFrom: Optional - Extension.findKnown: Optional - Callers updated: Chunk.as, VortexWriter.writeChunk, JdbcImporter.fillExtensionCell, VortexInspectorTui.formatValue. - ExtensionIdTest assertions: isSameAs -> contains / isNull -> isEmpty. - ExtensionTestSupport.tzMeta tz arg stays @Nullable (string, not a result). Co-Authored-By: Claude Opus 4.7 --- .../dfa1/vortex/cli/tui/VortexInspectorTui.java | 3 ++- .../github/dfa1/vortex/extension/Extension.java | 17 +++++++---------- .../dfa1/vortex/extension/ExtensionId.java | 7 ++++--- .../dfa1/vortex/extension/ExtensionIdTest.java | 6 +++--- .../vortex/extension/ExtensionTestSupport.java | 2 +- .../github/dfa1/vortex/jdbc/JdbcImporter.java | 6 ++---- .../java/io/github/dfa1/vortex/scan/Chunk.java | 6 ++---- .../github/dfa1/vortex/writer/VortexWriter.java | 6 +++--- 8 files changed, 24 insertions(+), 29 deletions(-) diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexInspectorTui.java b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexInspectorTui.java index e02ef48a..a2461dd8 100644 --- a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexInspectorTui.java +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexInspectorTui.java @@ -680,7 +680,8 @@ record Failed(String message) implements DataState { private static String formatValue(Array array, int i, DType declared) { if (declared instanceof DType.Extension ext && io.github.dfa1.vortex.extension.ExtensionId.tryFrom(ext.extensionId()) - == io.github.dfa1.vortex.extension.ExtensionId.VORTEX_DATE) { + .filter(id -> id == io.github.dfa1.vortex.extension.ExtensionId.VORTEX_DATE) + .isPresent()) { try { return io.github.dfa1.vortex.extension.DateExtension.INSTANCE.decode(array, i).toString(); } catch (RuntimeException e) { diff --git a/core/src/main/java/io/github/dfa1/vortex/extension/Extension.java b/core/src/main/java/io/github/dfa1/vortex/extension/Extension.java index f244c2ca..55b3e237 100644 --- a/core/src/main/java/io/github/dfa1/vortex/extension/Extension.java +++ b/core/src/main/java/io/github/dfa1/vortex/extension/Extension.java @@ -4,6 +4,7 @@ import io.github.dfa1.vortex.core.VortexException; import java.util.Collection; +import java.util.Optional; /// Contract for a Vortex extension type — pairs the wire-format identity /// (an [ExtensionId]) with a factory for the matching [DType.Extension] @@ -37,23 +38,19 @@ default Object encodeAll(DType.Extension dtype, Collection values) { throw new VortexException("encode not supported for " + extensionId()); } - /// Resolves a {@link DType.Extension} to its spec-defined singleton, or - /// {@code null} when the wire id isn't one of the four spec extensions. + /// Resolves a {@link DType.Extension} to its spec-defined singleton. /// Closes over the closed-set spec impls; third-party extensions go /// through {@link io.github.dfa1.vortex.encoding.Registry#lookup(ExtensionId)}. /// /// @param dtype declared extension dtype - /// @return matching spec extension singleton, or {@code null} - static @org.jspecify.annotations.Nullable Extension findKnown(DType.Extension dtype) { - ExtensionId id = ExtensionId.tryFrom(dtype.extensionId()); - if (id == null) { - return null; - } - return switch (id) { + /// @return matching spec extension singleton, or empty when the wire id + /// isn't one of the four spec extensions + static Optional findKnown(DType.Extension dtype) { + return ExtensionId.tryFrom(dtype.extensionId()).map(id -> switch (id) { case VORTEX_DATE -> DateExtension.INSTANCE; case VORTEX_TIME -> TimeExtension.INSTANCE; case VORTEX_TIMESTAMP -> TimestampExtension.INSTANCE; case VORTEX_UUID -> UuidExtension.INSTANCE; - }; + }); } } diff --git a/core/src/main/java/io/github/dfa1/vortex/extension/ExtensionId.java b/core/src/main/java/io/github/dfa1/vortex/extension/ExtensionId.java index 6aeb0ccc..792d738b 100644 --- a/core/src/main/java/io/github/dfa1/vortex/extension/ExtensionId.java +++ b/core/src/main/java/io/github/dfa1/vortex/extension/ExtensionId.java @@ -3,6 +3,7 @@ import io.github.dfa1.vortex.core.VortexException; import java.util.Map; +import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -48,9 +49,9 @@ public static ExtensionId from(String id) { /// Non-throwing lookup for a raw extension id string. /// /// @param id raw extension id string - /// @return matching constant, or {@code null} if not a known spec extension - public static @org.jspecify.annotations.Nullable ExtensionId tryFrom(String id) { - return LOOKUP.get(id); + /// @return matching constant, or empty if not a known spec extension + public static Optional tryFrom(String id) { + return Optional.ofNullable(LOOKUP.get(id)); } /// Returns the canonical wire-format id string. diff --git a/core/src/test/java/io/github/dfa1/vortex/extension/ExtensionIdTest.java b/core/src/test/java/io/github/dfa1/vortex/extension/ExtensionIdTest.java index 05d8f0df..7d618554 100644 --- a/core/src/test/java/io/github/dfa1/vortex/extension/ExtensionIdTest.java +++ b/core/src/test/java/io/github/dfa1/vortex/extension/ExtensionIdTest.java @@ -20,14 +20,14 @@ class ExtensionIdTest { void tryFrom_knownIds_returnEnumConstant(String wire, ExtensionId expected) { // Given / When / Then — wire string round-trips to the enum constant // so the LOOKUP map stays in sync with the enum definition - assertThat(ExtensionId.tryFrom(wire)).isSameAs(expected); + assertThat(ExtensionId.tryFrom(wire)).contains(expected); } @Test - void tryFrom_unknownId_returnsNull() { + void tryFrom_unknownId_returnsEmpty() { // Given — open-world extension id; library doesn't recognise it // When / Then — non-throwing miss so the registry can route to passthrough - assertThat(ExtensionId.tryFrom("acme.geopoint")).isNull(); + assertThat(ExtensionId.tryFrom("acme.geopoint")).isEmpty(); } @Test diff --git a/core/src/test/java/io/github/dfa1/vortex/extension/ExtensionTestSupport.java b/core/src/test/java/io/github/dfa1/vortex/extension/ExtensionTestSupport.java index 3fcc8ad5..13cdf086 100644 --- a/core/src/test/java/io/github/dfa1/vortex/extension/ExtensionTestSupport.java +++ b/core/src/test/java/io/github/dfa1/vortex/extension/ExtensionTestSupport.java @@ -34,7 +34,7 @@ static ByteBuffer unitByte(byte tag) { return meta; } - static ByteBuffer tzMeta(byte unitTag, String tz) { + static ByteBuffer tzMeta(byte unitTag, @org.jspecify.annotations.Nullable String tz) { byte[] tzBytes = tz == null ? new byte[0] : tz.getBytes(StandardCharsets.UTF_8); ByteBuffer meta = ByteBuffer.allocate(3 + tzBytes.length).order(ByteOrder.LITTLE_ENDIAN); meta.put(0, unitTag); diff --git a/jdbc/src/main/java/io/github/dfa1/vortex/jdbc/JdbcImporter.java b/jdbc/src/main/java/io/github/dfa1/vortex/jdbc/JdbcImporter.java index 63ef7bdc..c1ccf50c 100644 --- a/jdbc/src/main/java/io/github/dfa1/vortex/jdbc/JdbcImporter.java +++ b/jdbc/src/main/java/io/github/dfa1/vortex/jdbc/JdbcImporter.java @@ -216,10 +216,8 @@ private static boolean fillCell(Object buffer, int rowIdx, ResultSet rs, int col @SuppressWarnings("unchecked") private static void fillExtensionCell(List buffer, ResultSet rs, int colIdx, DType.Extension ext) throws SQLException { - ExtensionId id = ExtensionId.tryFrom(ext.extensionId()); - if (id == null) { - throw new UnsupportedOperationException("unsupported extension: " + ext.extensionId()); - } + ExtensionId id = ExtensionId.tryFrom(ext.extensionId()) + .orElseThrow(() -> new UnsupportedOperationException("unsupported extension: " + ext.extensionId())); // SQL NULL → null in the buffer. Nullable extension columns round-trip through the // writer's ExtEncoding → MaskedEncoding → primitive layout (validity child preserved); // NOT NULL columns reject any null element with VortexException during encode. diff --git a/reader/src/main/java/io/github/dfa1/vortex/scan/Chunk.java b/reader/src/main/java/io/github/dfa1/vortex/scan/Chunk.java index 91ae0e22..81dbeea7 100644 --- a/reader/src/main/java/io/github/dfa1/vortex/scan/Chunk.java +++ b/reader/src/main/java/io/github/dfa1/vortex/scan/Chunk.java @@ -117,10 +117,8 @@ public List as(String name, Class domainType) { if (!(colDtype instanceof DType.Extension ext)) { throw new VortexException("not an extension column: " + name); } - ExtensionId id = ExtensionId.tryFrom(ext.extensionId()); - if (id == null) { - throw new VortexException("not a spec extension id: " + ext.extensionId()); - } + ExtensionId id = ExtensionId.tryFrom(ext.extensionId()) + .orElseThrow(() -> new VortexException("not a spec extension id: " + ext.extensionId())); Array storage = column(name); Object result = switch (id) { case VORTEX_DATE -> { diff --git a/writer/src/main/java/io/github/dfa1/vortex/writer/VortexWriter.java b/writer/src/main/java/io/github/dfa1/vortex/writer/VortexWriter.java index cdce13d9..2caad580 100644 --- a/writer/src/main/java/io/github/dfa1/vortex/writer/VortexWriter.java +++ b/writer/src/main/java/io/github/dfa1/vortex/writer/VortexWriter.java @@ -310,10 +310,10 @@ public void writeChunk(Map columns) throws IOException { // ExtEncoding wraps the storage child below — matches Rust's nested layout // (ExtEncoding → PrimitiveEncoding) and lets Registry skip its unwrap path. if (colDtype instanceof DType.Extension extDtype && data instanceof java.util.Collection coll) { - io.github.dfa1.vortex.extension.ExtensionId extId = - io.github.dfa1.vortex.extension.ExtensionId.tryFrom(extDtype.extensionId()); io.github.dfa1.vortex.extension.Extension impl = - extId == null ? null : defaultRegistry.lookup(extId); + io.github.dfa1.vortex.extension.ExtensionId.tryFrom(extDtype.extensionId()) + .map(defaultRegistry::lookup) + .orElse(null); if (impl != null) { data = impl.encodeAll(extDtype, coll); }