Skip to content

fix!: derive KeyDelegate nullability from the underlying field - #437

Merged
zantvoort merged 2 commits into
mainfrom
fix/key-delegate-nullable
Aug 10, 2026
Merged

fix!: derive KeyDelegate nullability from the underlying field#437
zantvoort merged 2 commits into
mainfrom
fix/key-delegate-nullable

Conversation

@zantvoort

Copy link
Copy Markdown
Collaborator

Fixes #403

Problem

Metamodel.key() returns the argument unchanged when it already implements Key and wraps it in KeyDelegate otherwise. KeyDelegate.isNullable() returned delegate instanceof Key<?, ?> key && key.isNullable(), but by construction the delegate is never a Key, so every factory-built delegate reported non-nullable. That is precisely the check keyset pagination relies on to reject nullable cursor keys, so a wrapped nullable metamodel used as a pagination key silently skipped NULL rows across page boundaries: the exact failure mode the key() docs warn about.

Fix

KeyDelegate.isNullable() now derives the answer from the underlying metamodel. A delegate around a Key still answers through that key; any other delegate resolves the record field at the metamodel's path through a new MetamodelFactory.isNullable(Metamodel), bridged from storm-foundation via the reflective MetamodelHelper (the same pattern as root/of/flatten):

  • a unique field applies its nullsDistinct setting, matching factory-built key metamodels;
  • an inline record derives from its constituent fields, mirroring SimpleKeyMetamodel;
  • a plain field reports its own nullability, because no unique constraint restricts its NULL values.

Nullability comes from the same RecordField.nullable() source the rest of the framework uses (null-marked-by-default contract; Kotlin resolves from the language type via the Kotlin reflection provider), so Java and Kotlin behave identically and no new dependency is introduced. The sealed-entity field-resolution block that appeared three times in MetamodelFactory is extracted into a shared fieldResolutionClass helper.

Breaking change

  • scroll with a Metamodel.key()-wrapped nullable field now throws the descriptive PersistenceException instead of silently skipping NULL rows. This also applies to previously issued Scrollable tokens revalidated on their next scroll call. Callers that genuinely need such a key can implement Key/AbstractKeyMetamodel explicitly.
  • A wrapped custom metamodel whose path does not resolve to a record field previously reported non-nullable; it now fails at validation time with a descriptive error.

findBy/getBy/getByRef do not consult isNullable() and are unaffected; non-nullable wrapped keys scroll exactly as before; KeyDelegate's record shape and equality semantics are unchanged.

Tests

  • MetamodelTest: wrapped nullable and non-nullable scalar fields, the documented Metamodel.of factory route, nested paths resolving at the leaf field, and a nullable @FK column.
  • RepositoryPreparedStatementIntegrationTest: scroll rejects a wrapped nullable key and still accepts a wrapped non-nullable key; testMetamodelKeyFactory tightened with assertSame for the inline-record case, which already carries the Key marker.
  • KeyDelegateTest (storm-foundation, runs without storm-core): the test that asserted the always-false behavior is replaced by direct-key coverage of both nullability answers; derivation coverage lives in storm-core, where the factory is on the classpath.

Full reactor green (mvn test, all modules).

Metamodel.key() only wraps metamodels that do not carry the Key marker,
so KeyDelegate.isNullable()'s instanceof check on the delegate could
never match: every factory-built key reported non-nullable, and keyset
pagination accepted nullable cursor keys that silently skip NULL rows.

KeyDelegate now derives nullability from the record field the metamodel
designates, through a new MetamodelFactory.isNullable bridged via
MetamodelHelper: a Key answers for itself, a unique field applies its
nullsDistinct setting, an inline record derives from its constituent
fields, and a plain field reports its own nullability. The sealed-entity
field-resolution block in MetamodelFactory is extracted into a shared
helper.

BREAKING CHANGE: scroll now rejects a Metamodel.key()-wrapped nullable
field with a descriptive PersistenceException instead of silently
skipping NULL rows; a wrapped metamodel whose path does not resolve to
a record field fails the same way at validation time.

Fixes #403
Comment thread storm-foundation/src/main/java/st/orm/MetamodelHelper.java Fixed
Method.invoke's only remaining checked reflective failure once
InvocationTargetException is handled is IllegalAccessException; the
broader ReflectiveOperationException catch is flagged as masked by
CodeQL. The static initializer keeps the broad catch, where
Class.forName and getMethod throw other reflective exceptions. Also
names MetamodelFactory.root in root()'s failure message.
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

@zantvoort
zantvoort merged commit 17d1c4e into main Aug 10, 2026
8 checks passed
@zantvoort
zantvoort deleted the fix/key-delegate-nullable branch August 10, 2026 13:46
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.

Metamodel.KeyDelegate.isNullable() is always false through the documented factory

1 participant