feat: null-marked models by default, aligning nullability with JSpecify#257
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
@Nullableas the marked case.Contract
@Nullable(JSpecify's type-use annotation,jakarta.annotation.Nullable, orjavax.annotation.Nullable), or@NullUnmarkedscope — resolved by walking constructor → class → enclosing classes → package → module, nearest marker wins.@Nonnull/@NonNullis honored (and restores strictness inside@NullUnmarked);@NullMarkedis honored and now matches the default.Implementation
st.orm.core.spi.Nullabilitycentralizes 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.Metadata).@UKnullable-field warning) mirrors the runtime contract, including the scope walk, JSpecify type-use positions, and JetBrains annotations covering Kotlin kapt stubs.@Nullable(JSpecify or jakarta) or opt out with@NullUnmarked.Breaking changes (release-note material)
@Nullable(or their scope marked@NullUnmarked).@FK City cityflips 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 @FKto 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@Nonnullremoved,@Nullableadded 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
@NullMarked/@NullUnmarkedtest package plusJSpecifyNullMarkedIntegrationTestcovering the semantic square: marked-scope rejection, happy path,@Nullableopt-out,@NullUnmarkedcancellation.@Nullable; exactly one test assertion inverted (PlainPreparedStatementIntegrationTest.testSelectPetTypedWithLocalRecordAndEnumNullThrows, which existed to assert the old lenient default and now asserts the new contract). Test modelAddress.citygained@Nullableto match the schema, correctly restoring LEFT JOIN in the SQL assertions.Closes #254
Closes #256