Skip to content

feat: null-marked models by default, aligning nullability with JSpecify#257

Merged
zantvoort merged 2 commits into
mainfrom
feat/jspecify-null-marked
Jul 14, 2026
Merged

feat: null-marked models by default, aligning nullability with JSpecify#257
zantvoort merged 2 commits into
mainfrom
feat/jspecify-null-marked

Conversation

@zantvoort

Copy link
Copy Markdown
Collaborator

Implements #254 and #256: Storm's nullability contract is now fully aligned with JSpecify, one deliberate step further — models are null-marked by default. Java record components behave exactly like Kotlin properties: non-null unless marked, with @Nullable as the marked case.

Contract

  • Kotlin (unchanged): the language type is the contract, resolved from Kotlin metadata by the Kotlin reflection provider.
  • Java: a record component or constructor parameter is non-null unless
    • explicitly @Nullable (JSpecify's type-use annotation, jakarta.annotation.Nullable, or javax.annotation.Nullable), or
    • inside a @NullUnmarked scope — resolved by walking constructor → class → enclosing classes → package → module, nearest marker wins.
  • Explicit @Nonnull/@NonNull is honored (and restores strictness inside @NullUnmarked); @NullMarked is honored and now matches the default.
  • JSpecify treats unmarked code as unspecified and asks static tools to be lenient; Storm is a runtime boundary and interprets unspecified as non-null, because a silent NULL in an assumed-non-null field is the worst failure mode. This is the stated deviation, and it buys exact Java/Kotlin symmetry.

Implementation

  • st.orm.core.spi.Nullability centralizes resolution and is shared by the record-component path (DefaultORMReflectionImpl) and the constructor-parameter path (ObjectMapperFactory), closing the drift Complete JSpecify null-safety support: honor @NullMarked scope and align the constructor-parameter check #254 identified — nullable annotations are load-bearing for the first time. JSpecify remains reflectively resolved and optional at runtime; resolution runs only when cached mapping metadata is built, never per row.
  • Kotlin-compiled classes are exempt from the Java default (guarded via kotlin.Metadata).
  • The annotation processor's own nullability determination (the @UK nullable-field warning) mirrors the runtime contract, including the scope walk, JSpecify type-use positions, and JetBrains annotations covering Kotlin kapt stubs.
  • Error hints teach the new vocabulary: annotate @Nullable (JSpecify or jakarta) or opt out with @NullUnmarked.

Breaking changes (release-note material)

  1. Hydration: unannotated nullable fields in existing Java models fail with the descriptive non-nullable error until annotated @Nullable (or their scope marked @NullUnmarked).
  2. Join generation — the silent one: a bare @FK City city flips from LEFT JOIN to INNER JOIN, filtering rows without the reference instead of fetching them with a null field. This matches Kotlin's existing behavior for non-null FKs. Nullable FK columns must be @Nullable @FK to keep the LEFT JOIN.

Documentation and skills

docs/ rewritten from the old "nullable by default, use @Nonnull" contract across 12 pages (prose and every Java example; ~70 redundant @Nonnull removed, @Nullable added where the Kotlin counterpart is nullable — including join-semantics-bearing cases). The site's AI skills (website/static/skills/) updated the same way, including the inverted FK guidance. versioned_docs/ snapshots untouched; skills deploy with the next site build.

Testing

  • New @NullMarked/@NullUnmarked test package plus JSpecifyNullMarkedIntegrationTest covering the semantic square: marked-scope rejection, happy path, @Nullable opt-out, @NullUnmarked cancellation.
  • Suite fallout resolved under a strict policy: intended-nullable test records annotated @Nullable; exactly one test assertion inverted (PlainPreparedStatementIntegrationTest.testSelectPetTypedWithLocalRecordAndEnumNullThrows, which existed to assert the old lenient default and now asserts the new contract). Test model Address.city gained @Nullable to match the schema, correctly restoring LEFT JOIN in the SQL assertions.
  • Suites green: storm-core 2271, storm-kotlin 1627 (unchanged, confirming the Kotlin exemption), storm-java21 517 (no fallout; its models were already annotated).

Closes #254
Closes #256

Java record components and constructor parameters are now non-null by
default, exactly like Kotlin: nullable is the marked case. A type use is
nullable only when explicitly annotated (JSpecify's type-use Nullable,
jakarta.annotation.Nullable, or javax.annotation.Nullable) or inside a
NullUnmarked scope, resolved by walking the constructor, the declaring
and enclosing classes, the package, and the module; nearest marker wins.
NullMarked remains honored and now matches the default. Nullable
annotations are load-bearing for the first time; JSpecify stays a
reflectively resolved, optional dependency, and resolution only runs
when mapping metadata is built.

JSpecify treats unmarked code as unspecified and asks static tools to be
lenient. Storm is a runtime boundary: the lenient reading lets NULL flow
silently into fields the developer assumed non-null. Interpreting
unspecified as non-null fails fast with a descriptive error naming the
component and the fix, and gives Java models the exact semantics of
Kotlin models, whose nullness continues to come from the language via
the Kotlin reflection provider.

Resolution is centralized in Nullability and shared by the record
component path and the constructor parameter path, closing the drift
between the two checks. The annotation processor's own nullability
determination for the unique-key warning mirrors the runtime contract,
including the scope walk and the JetBrains annotations that cover Kotlin
kapt stubs.

Breaking changes: unannotated nullable fields in Java models now fail
hydration with the descriptive non-nullable error until annotated, and a
bare FK component flips from LEFT JOIN to INNER JOIN, filtering rows
without the reference; nullable FK columns must be annotated Nullable to
keep the LEFT JOIN. Documentation and the site skills are rewritten from
the old nullable-by-default contract, and the error hints teach the
JSpecify vocabulary and the NullUnmarked opt-out.

Closes #254
Closes #256
NullableCompoundUK relied on the old nullable-by-default contract; the
locally passing runs reused stale generated metamodels, so the new
processor default only surfaced on CI's clean build. The fields now
carry the Nullable annotation the model's name always implied.
@zantvoort zantvoort merged commit 35997cb into main Jul 14, 2026
6 of 7 checks passed
@zantvoort zantvoort deleted the feat/jspecify-null-marked branch July 14, 2026 08:33
zantvoort added a commit that referenced this pull request Jul 14, 2026
Following #257 (null-marked models by default), record components that were
implicitly nullable now require explicit @nullable. Annotate the Vet, Address,
and Owner records in the H2/MariaDB/MySQL/MSSQLServer/Oracle/PostgreSQL/SQLite
schema validator tests, and pass a non-null Instant for Visit inserts in the
Spring transaction bridge test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Java models are null-marked by default Complete JSpecify null-safety support: honor @NullMarked scope and align the constructor-parameter check

1 participant