From f6b2fd4a792bf310de7684e2b324cc8f0c31218b Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Tue, 14 Jul 2026 10:13:06 +0200 Subject: [PATCH 1/2] feat: null-marked models by default, aligning nullability with JSpecify 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 --- docs/configuration.md | 4 +- docs/dirty-checking.md | 4 +- docs/entities.md | 76 ++++----- docs/faq.md | 2 +- docs/first-entity.md | 2 +- docs/hydration.md | 6 +- docs/metamodel.md | 16 +- docs/migration-from-jpa.md | 4 +- docs/projections.md | 30 ++-- docs/relationships.md | 8 +- docs/sql-templates.md | 10 +- docs/validation.md | 8 +- .../impl/DefaultORMReflectionImpl.java | 34 +--- .../java/st/orm/core/spi/Nullability.java | 151 ++++++++++++++++++ .../template/impl/ObjectMapperFactory.java | 23 +-- .../core/EqualitySupportIntegrationTest.java | 5 +- .../JSpecifyNullMarkedIntegrationTest.java | 82 ++++++++++ ...PlainPreparedStatementIntegrationTest.java | 25 +-- .../QueryImplTypeMappingIntegrationTest.java | 17 +- .../SqlTemplateAndQueryIntegrationTest.java | 17 +- ...platePreparedStatementIntegrationTest.java | 6 +- .../test/java/st/orm/core/model/Address.java | 3 +- .../java/st/orm/core/model/PetOwnerRef.java | 3 +- .../core/model/PetWithNullableOwnerRef.java | 3 +- .../core/model/nullmarked/MarkedComment.java | 23 +++ .../orm/core/model/nullmarked/MarkedNote.java | 21 +++ .../core/model/nullmarked/UnmarkedNote.java | 24 +++ .../core/model/nullmarked/package-info.java | 23 +++ .../impl/DefaultORMReflectionImplTest.java | 6 +- .../core/template/SchemaValidatorTest.java | 4 +- .../orm/core/template/impl/QueryImplTest.java | 5 +- .../st/orm/metamodel/MetamodelProcessor.java | 63 +++++++- .../static/skills/storm-entity-from-schema.md | 2 +- website/static/skills/storm-entity-java.md | 28 ++-- website/static/skills/storm-query-java.md | 2 +- .../static/skills/storm-repository-java.md | 2 +- 36 files changed, 544 insertions(+), 198 deletions(-) create mode 100644 storm-core/src/main/java/st/orm/core/spi/Nullability.java create mode 100644 storm-core/src/test/java/st/orm/core/JSpecifyNullMarkedIntegrationTest.java create mode 100644 storm-core/src/test/java/st/orm/core/model/nullmarked/MarkedComment.java create mode 100644 storm-core/src/test/java/st/orm/core/model/nullmarked/MarkedNote.java create mode 100644 storm-core/src/test/java/st/orm/core/model/nullmarked/UnmarkedNote.java create mode 100644 storm-core/src/test/java/st/orm/core/model/nullmarked/package-info.java diff --git a/docs/configuration.md b/docs/configuration.md index 389de6800..56a469c78 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -678,8 +678,8 @@ data class Article( @DynamicUpdate(FIELD) record Article( @PK Integer id, - @Nonnull String title, - @Nonnull String content + String title, + String content ) implements Entity {} ``` diff --git a/docs/dirty-checking.md b/docs/dirty-checking.md index f78987927..2e8249ee4 100644 --- a/docs/dirty-checking.md +++ b/docs/dirty-checking.md @@ -309,8 +309,8 @@ data class User( ```java @DynamicUpdate(FIELD) record User(@PK Integer id, - @Nonnull String email, - @Nonnull String name, + String email, + String name, @FK City city ) implements Entity {} ``` diff --git a/docs/entities.md b/docs/entities.md index c7ed9ced0..77c876465 100644 --- a/docs/entities.md +++ b/docs/entities.md @@ -46,7 +46,7 @@ record User(@PK Integer id, String email, LocalDate birthDate, String street, - String postalCode, + @Nullable String postalCode, @FK City city ) implements Entity {} ``` @@ -86,13 +86,13 @@ data class User( -In Java, record components are nullable by default. Use `@Nonnull` to mark fields that must always have a value — Storm recognizes `jakarta.annotation.Nonnull`, `javax.annotation.Nonnull`, and JSpecify's `org.jspecify.annotations.NonNull` on the component; other null-marker annotations (Lombok, JetBrains, Spring) are not recognized, and JSpecify `@NullMarked` scope defaults are not applied. `@PK` fields and primitive types (`int`, `long`, etc.) are inherently non-nullable. As with Kotlin, nullability determines JOIN behavior: a non-nullable `@FK` field produces an `INNER JOIN`, while a nullable one — including a bare `@FK` field without `@Nonnull` — produces a `LEFT JOIN`. If the FK column is `NOT NULL` in the database, annotate the field with `@Nonnull`, or joins silently degrade to `LEFT JOIN`. +In Java, record components are non-null by default, exactly like Kotlin: `String` means a value is always present, and nullable is the marked case. Mark nullable fields with `@Nullable` — Storm recognizes JSpecify's `org.jspecify.annotations.Nullable` (as a type-use annotation), `jakarta.annotation.Nullable`, and `javax.annotation.Nullable`. This matches JSpecify's `@NullMarked` semantics: annotating your model package `@NullMarked` is welcome documentation for static checkers, and `@NullUnmarked` on a class or package opts back into lenient, nullable-by-default components for that scope. As with Kotlin, nullability determines JOIN behavior: a non-nullable `@FK` field — including a bare, unannotated one — produces an `INNER JOIN`, while a `@Nullable @FK` field produces a `LEFT JOIN`. If the FK column allows `NULL` in the database, annotate the field `@Nullable`, or rows without the reference are silently filtered by the inner join. ```java record User(@PK Integer id, - @Nonnull String email, // Non-nullable - @Nonnull LocalDate birthDate, // Non-nullable - String postalCode, // Nullable (default) + String email, // Non-nullable (default) + LocalDate birthDate, // Non-nullable (default) + @Nullable String postalCode, // Nullable @Nullable @FK City city // Nullable (results in LEFT JOIN) ) implements Entity {} ``` @@ -163,7 +163,7 @@ Use `NONE` when: ```java record User(@PK Integer id, // Database generates via auto-increment - @Nonnull String name + String name ) implements Entity {} ``` @@ -178,7 +178,7 @@ var inserted = orm.entity(User.class).insert(user); // Returns User with genera ```java record Order(@PK(generation = SEQUENCE, sequence = "order_seq") Long id, - @Nonnull BigDecimal total + BigDecimal total ) implements Entity {} ``` @@ -188,7 +188,7 @@ Storm fetches the next value from the sequence before inserting. ```java record Country(@PK(generation = NONE) String code, // Caller provides the value - @Nonnull String name + String name ) implements Entity {} ``` @@ -229,8 +229,8 @@ data class UserRole( record UserRolePk(int userId, int roleId) {} record UserRole(@PK UserRolePk userRolePk, - @Nonnull @FK User user, - @Nonnull @FK Role role + @FK User user, + @FK Role role ) implements Entity {} ``` @@ -330,8 +330,8 @@ data class SomeEntity( record UserEmailUk(int userId, String email) {} record SomeEntity(@PK Integer id, - @Nonnull @FK User user, - @Nonnull String email, + @FK User user, + String email, @UK @Persist(insertable = false, updatable = false) UserEmailUk uniqueKey ) implements Entity {} ``` @@ -377,13 +377,13 @@ data class Owner( Use records for embedded components: ```java -record Address(String street, - @FK City city) {} +record Address(@Nullable String street, + @Nullable @FK City city) {} record Owner(@PK Integer id, - @Nonnull String firstName, - @Nonnull String lastName, - @Nonnull Address address, + String firstName, + String lastName, + Address address, @Nullable String telephone ) implements Entity {} ``` @@ -419,9 +419,9 @@ In this example, the `owner` and `city` foreign keys define the actual persisted record OwnerCityKey(int ownerId, int cityId) {} record Pet(@PK Integer id, - @Nonnull String name, - @Nonnull @FK Owner owner, - @Nonnull @FK City city, + String name, + @FK Owner owner, + @FK City city, @Persist(insertable = false, updatable = false) OwnerCityKey ownerCityKey ) implements Entity {} ``` @@ -477,8 +477,8 @@ enum RoleType { } record Role(@PK Integer id, - @Nonnull String name, - @Nonnull RoleType type // Stored as "USER" or "ADMIN" + String name, + RoleType type // Stored as "USER" or "ADMIN" ) implements Entity {} ``` @@ -486,8 +486,8 @@ To store by ordinal: ```java record Role(@PK Integer id, - @Nonnull String name, - @Nonnull @DbEnum(ORDINAL) RoleType type // Stored as 0 or 1 + String name, + @DbEnum(ORDINAL) RoleType type // Stored as 0 or 1 ) implements Entity {} ``` @@ -522,7 +522,7 @@ record Money(BigDecimal amount) {} @DbTable("product") record Product(@PK Integer id, - @Nonnull String name, + String name, @Convert(converter = MoneyConverter.class) Money price ) implements Entity {} ``` @@ -571,8 +571,8 @@ Use `@Version` for optimistic locking: ```java record Owner(@PK Integer id, - @Nonnull String firstName, - @Nonnull String lastName, + String firstName, + String lastName, @Version int version ) implements Entity {} ``` @@ -581,10 +581,10 @@ Timestamps are also supported: ```java record Visit(@PK Integer id, - @Nonnull LocalDate visitDate, + LocalDate visitDate, @Nullable String description, - @Nonnull @FK Pet pet, - @Version Instant timestamp + @FK Pet pet, + @Nullable @Version Instant timestamp ) implements Entity {} ``` @@ -619,9 +619,9 @@ Use `@Persist(updatable = false)` for fields that should only be set on insert: ```java record Pet(@PK Integer id, - @Nonnull String name, - @Nonnull @Persist(updatable = false) LocalDate birthDate, - @Nonnull @FK @Persist(updatable = false) PetType type, + String name, + @Persist(updatable = false) LocalDate birthDate, + @FK @Persist(updatable = false) PetType type, @Nullable @FK Owner owner ) implements Entity {} ``` @@ -638,8 +638,8 @@ Since Storm entities are immutable, updating a field means creating a new instan ```java @Builder(toBuilder = true) record User(@PK Integer id, - @Nonnull String email, - @Nonnull String name, + String email, + String name, @FK City city ) implements Entity {} ``` @@ -802,14 +802,14 @@ data class User( // Suppress all schema validation for a legacy entity. @DbIgnore record LegacyUser(@PK Integer id, - @Nonnull String name + String name ) implements Entity {} // Suppress schema validation for a specific field. record User(@PK Integer id, - @Nonnull String name, + String name, @DbIgnore("DB uses FLOAT, but column only stores whole numbers") - @Nonnull Integer age + Integer age ) implements Entity {} ``` diff --git a/docs/faq.md b/docs/faq.md index 4008e59d2..3837e404c 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -110,7 +110,7 @@ var updated = new User(user.id(), "new@example.com", user.name(), user.city()); **Custom wither methods:** Define `with*` methods on the record that return a new instance with a single field changed. Clean API but requires a method per field. ```java -record User(@PK Integer id, @Nonnull String email, @Nonnull String name, @FK City city +record User(@PK Integer id, String email, String name, @FK City city ) implements Entity { User withEmail(String email) { return new User(id, email, name, city); } } diff --git a/docs/first-entity.md b/docs/first-entity.md index c54abdeac..8b77df21a 100644 --- a/docs/first-entity.md +++ b/docs/first-entity.md @@ -47,7 +47,7 @@ record User(@PK Integer id, ) implements Entity {} ``` -In Java, record components are nullable by default. Use `@Nonnull` on fields that must always have a value. Primitive types (`int`, `long`, etc.) are inherently non-nullable. +In Java, record components are non-null by default, exactly like Kotlin. Mark nullable fields with `@Nullable` (JSpecify's `org.jspecify.annotations.Nullable` or `jakarta.annotation.Nullable`); see [Defining Entities](entities.md) for the full nullability rules. The `@Builder` annotation is from [Lombok](https://projectlombok.org/) and is optional. It generates a builder that lets you construct entities without specifying the primary key, and creates modified copies via `toBuilder()`. Without Lombok, you can pass `null` as the primary key (e.g., `new City(null, "Sunnyvale", 161_884)`) or define a convenience constructor that omits it. See [Modifying Entities](entities.md#modifying-entities) for details. diff --git a/docs/hydration.md b/docs/hydration.md index 07937f8c3..0f259a412 100644 --- a/docs/hydration.md +++ b/docs/hydration.md @@ -564,16 +564,18 @@ If a non-nullable field receives NULL from the database, Storm throws an excepti -Use `@Nonnull` and `@Nullable` annotations: +Record components are non-null by default, exactly like Kotlin. Mark nullable fields with `@Nullable`: ```java record User( int id, // Primitive = non-nullable - @Nonnull String email, // Non-nullable + String email, // Non-nullable (default) @Nullable String nickname // Nullable ) {} ``` +If a non-nullable field receives NULL from the database, Storm throws an exception. + diff --git a/docs/metamodel.md b/docs/metamodel.md index 7e0812545..fd68a1cbe 100644 --- a/docs/metamodel.md +++ b/docs/metamodel.md @@ -414,8 +414,8 @@ data class SomeEntity( record UserEmailUk(int userId, String email) {} record SomeEntity(@PK Integer id, - @Nonnull @FK User user, - @Nonnull String email, + @FK User user, + String email, @UK @Persist(insertable = false, updatable = false) UserEmailUk uniqueKey ) implements Entity {} ``` @@ -549,7 +549,7 @@ In standard SQL, `NULL != NULL`. This means a `UNIQUE` constraint typically allo Because of this, Storm validates nullable unique keys at two levels: -1. **Compile-time warning.** The metamodel processor emits a warning when a `@UK` field is nullable (a nullable type in Kotlin, or a reference type without `@Nonnull` in Java) and the default `nullsDistinct = true` applies. +1. **Compile-time warning.** The metamodel processor emits a warning when a `@UK` field is nullable (a nullable type in Kotlin, a `@Nullable` reference type in Java, or any reference type in a `@NullUnmarked` scope) and the default `nullsDistinct = true` applies. 2. **Runtime check.** The `scroll` method throws a `PersistenceException` if the key's metamodel indicates that nulls are distinct for a nullable field, preventing silent data loss. Database behavior varies. Some databases offer stricter NULL handling for unique constraints: @@ -562,10 +562,10 @@ The `@UK` annotation provides a `nullsDistinct` attribute to control this behavi | Field | `nullsDistinct` | Effect | |-------|-----------------|--------| -| `@UK @Nonnull String email` | (irrelevant) | Safe. No warning, no runtime check. | +| `@UK String email` | (irrelevant) | Safe. Non-null by default. No warning, no runtime check. | | `@UK int count` | (irrelevant) | Safe. Primitive is never null. | -| `@UK String email` | `true` (default) | Compile-time warning. `scroll` throws `PersistenceException`. | -| `@UK(nullsDistinct = false) String email` | `false` | No warning. `scroll` works (user asserts DB prevents duplicate NULLs). | +| `@UK @Nullable String email` | `true` (default) | Compile-time warning. `scroll` throws `PersistenceException`. | +| `@UK(nullsDistinct = false) @Nullable String email` | `false` | No warning. `scroll` works (user asserts DB prevents duplicate NULLs). | When `nullsDistinct` is set to `false`, you are telling Storm that your database constraint prevents duplicate `NULL` values in the column. Storm trusts this assertion and skips both the compile-time warning and the runtime check. Use this only when your database actually enforces this guarantee (for example, with a `NULLS NOT DISTINCT` unique index in PostgreSQL 15+, or on SQL Server where unique indexes allow at most one `NULL` by default). @@ -596,13 +596,13 @@ data class User( ```java // Safe (non-nullable) record User(@PK Integer id, - @UK @Nonnull String email, // Non-nullable, safe for scrolling + @UK String email, // Non-null by default, safe for scrolling String name ) implements Entity {} // Opt-in for nullable keys record User(@PK Integer id, - @UK(nullsDistinct = false) String email, // DB prevents duplicate NULLs + @UK(nullsDistinct = false) @Nullable String email, // DB prevents duplicate NULLs String name ) implements Entity {} ``` diff --git a/docs/migration-from-jpa.md b/docs/migration-from-jpa.md index a739f669f..592715c89 100644 --- a/docs/migration-from-jpa.md +++ b/docs/migration-from-jpa.md @@ -77,8 +77,8 @@ Storm derives the table name (`user`) from the class name and column names (`ema ```java record User( @PK Long id, - @Nonnull String email, - @Nonnull String name, + String email, + String name, @Nullable @FK City city, @Nullable LocalDateTime createdAt ) implements Entity {} diff --git a/docs/projections.md b/docs/projections.md index 8106f8993..82785279d 100644 --- a/docs/projections.md +++ b/docs/projections.md @@ -60,8 +60,8 @@ data class OwnerView( @DbTable("owner") record OwnerView( @PK Integer id, - @Nonnull String firstName, - @Nonnull String lastName, + String firstName, + String lastName, @Nullable String telephone ) implements Projection {} ``` @@ -91,9 +91,9 @@ data class VisitSummary( ```java record VisitSummary( - @Nonnull LocalDate visitDate, + LocalDate visitDate, @Nullable String description, - @Nonnull String petName + String petName ) implements Projection {} ``` @@ -124,7 +124,7 @@ data class PetView( ```java @DbTable("pet") record PetView(@PK Integer id, - @Nonnull String name, + String name, @FK OwnerView owner // References another projection ) implements Projection {} ``` @@ -419,10 +419,10 @@ data class OwnerDetail( ```java // Full entity for writes record Owner(@PK Integer id, - @Nonnull String firstName, - @Nonnull String lastName, - @Nonnull String address, - @Nonnull String city, + String firstName, + String lastName, + String address, + String city, @Nullable String telephone, @Version int version ) implements Entity {} @@ -430,17 +430,17 @@ record Owner(@PK Integer id, // Lightweight projection for list views @DbTable("owner") record OwnerListItem(@PK Integer id, - @Nonnull String firstName, - @Nonnull String lastName + String firstName, + String lastName ) implements Projection {} // Detailed projection for detail views @DbTable("owner") record OwnerDetail(@PK Integer id, - @Nonnull String firstName, - @Nonnull String lastName, - @Nonnull String address, - @Nonnull String city, + String firstName, + String lastName, + String address, + String city, @Nullable String telephone ) implements Projection {} ``` diff --git a/docs/relationships.md b/docs/relationships.md index 92127867f..bc476af9b 100644 --- a/docs/relationships.md +++ b/docs/relationships.md @@ -253,8 +253,8 @@ val roles: List = orm.entity() record UserRolePk(int userId, int roleId) {} record UserRole(@PK UserRolePk userRolePk, - @Nonnull @FK @Persist(insertable = false, updatable = false) User user, - @Nonnull @FK @Persist(insertable = false, updatable = false) Role role + @FK @Persist(insertable = false, updatable = false) User user, + @FK @Persist(insertable = false, updatable = false) Role role ) implements Entity {} ``` @@ -350,8 +350,8 @@ data class AuditLog( record UserRolePk(int userId, int roleId) {} record UserRole(@PK UserRolePk pk, - @Nonnull @FK User user, - @Nonnull @FK Role role, + @FK User user, + @FK Role role, Instant grantedAt ) implements Entity {} diff --git a/docs/sql-templates.md b/docs/sql-templates.md index 47ab9573a..475f275f9 100644 --- a/docs/sql-templates.md +++ b/docs/sql-templates.md @@ -82,7 +82,7 @@ val pets = orm.query { """ ```java @DbTable("pet") record PetWithOwner( - @Nonnull String name, + String name, @Nullable LocalDate birthDate, @FK Owner owner ) implements Data {} @@ -165,17 +165,17 @@ orm.query { """ ```java record Country(@PK Integer id, - @Nonnull String name, - @Nonnull String code + String name, + String code ) implements Entity {} record City(@PK Integer id, - @Nonnull String name, + String name, @FK Country country ) implements Entity {} record User(@PK Integer id, - @Nonnull String email, + String email, @FK City city ) implements Entity {} ``` diff --git a/docs/validation.md b/docs/validation.md index 5084cfd8e..13d503fe0 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -248,7 +248,7 @@ data class LegacyUser( ```java @DbIgnore record LegacyUser(@PK Integer id, - @Nonnull String name + String name ) implements Entity {} ``` @@ -274,9 +274,9 @@ data class User( ```java record User(@PK Integer id, - @Nonnull String name, + String name, @DbIgnore("DB uses FLOAT, but column only stores whole numbers") - @Nonnull Integer age + Integer age ) implements Entity {} ``` @@ -306,7 +306,7 @@ data class Report( ```java @DbTable(schema = "reporting") record Report(@PK Integer id, - @Nonnull String name + String name ) implements Entity {} ``` diff --git a/storm-core/src/main/java/st/orm/core/repository/impl/DefaultORMReflectionImpl.java b/storm-core/src/main/java/st/orm/core/repository/impl/DefaultORMReflectionImpl.java index d51bb9a27..11731c6a4 100644 --- a/storm-core/src/main/java/st/orm/core/repository/impl/DefaultORMReflectionImpl.java +++ b/storm-core/src/main/java/st/orm/core/repository/impl/DefaultORMReflectionImpl.java @@ -22,7 +22,6 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; -import java.lang.annotation.Annotation; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; @@ -34,10 +33,10 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Supplier; import st.orm.Data; import st.orm.PK; import st.orm.PersistenceException; +import st.orm.core.spi.Nullability; import st.orm.core.spi.ORMReflection; import st.orm.mapping.RecordField; import st.orm.mapping.RecordType; @@ -240,39 +239,10 @@ public boolean isSupportedType(@Nonnull Object clazz) { return clazz instanceof Class; } - @SuppressWarnings("unchecked") - static final Class JAVAX_NONNULL = ((Supplier>) () -> { - try { - return (Class) Class.forName("javax.annotation.Nonnull"); - } catch (ClassNotFoundException e) { - return null; - } - }).get(); - @SuppressWarnings("unchecked") - static final Class JAKARTA_NONNULL = ((Supplier>) () -> { - try { - return (Class) Class.forName("jakarta.annotation.Nonnull"); - } catch (ClassNotFoundException e) { - return null; - } - }).get(); - @SuppressWarnings("unchecked") - static final Class JSPECIFY_NONNULL = ((Supplier>) () -> { - try { - return (Class) Class.forName("org.jspecify.annotations.NonNull"); - } catch (ClassNotFoundException e) { - return null; - } - }).get(); - private boolean isNonnull(@Nonnull RecordComponent component) { return component.isAnnotationPresent(PK.class) || component.getType().isPrimitive() - || (JAVAX_NONNULL != null && component.isAnnotationPresent(JAVAX_NONNULL)) - || (JAKARTA_NONNULL != null && component.isAnnotationPresent(JAKARTA_NONNULL)) - // JSpecify's NonNull is a TYPE_USE annotation: it annotates the component's type - // rather than the component declaration. - || (JSPECIFY_NONNULL != null && component.getAnnotatedType().isAnnotationPresent(JSPECIFY_NONNULL)); + || Nullability.isNonNull(component, component.getAnnotatedType(), null, component.getDeclaringRecord()); } @Override diff --git a/storm-core/src/main/java/st/orm/core/spi/Nullability.java b/storm-core/src/main/java/st/orm/core/spi/Nullability.java new file mode 100644 index 000000000..834df976f --- /dev/null +++ b/storm-core/src/main/java/st/orm/core/spi/Nullability.java @@ -0,0 +1,151 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package st.orm.core.spi; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.AnnotatedType; +import java.lang.reflect.Executable; + +/** + * Resolves the nullability of record components and constructor parameters under Storm's null-marked-by-default + * contract. + * + *

Storm aligns its nullability semantics with JSpecify, one deliberate step further: models are null-marked + * by default. For Kotlin classes the language type is the contract, resolved from Kotlin metadata by the + * Kotlin reflection provider. For Java, a type use is non-null unless declared otherwise:

+ *
    + *
  • An explicit {@code @Nullable} — JSpecify's type-use annotation, {@code jakarta.annotation.Nullable}, or + * {@code javax.annotation.Nullable} — makes the type use nullable regardless of scope.
  • + *
  • An explicit {@code @NonNull} / {@code @Nonnull} makes it non-null regardless of scope.
  • + *
  • Otherwise the nearest {@code @NullUnmarked} / {@code @NullMarked} marker decides, walking the constructor + * (if any), the declaring class and its enclosing classes, the package, and the module. Without a marker, + * the type use is non-null: unmarked code is interpreted as null-marked.
  • + *
+ * + *

JSpecify treats unmarked code as unspecified and asks static tools to be lenient. Storm is a runtime + * boundary: the lenient interpretation lets {@code NULL} flow silently into fields the developer assumed + * non-null, surfacing far from the cause. Interpreting unspecified as non-null fails fast with a descriptive + * error naming the component and the fix, and makes Java models behave exactly like Kotlin models, where + * nullable is the marked case. {@code @NullUnmarked} is the opt-out for models that want the lenient + * behavior.

+ * + *

The JSpecify annotations are resolved reflectively: they are not a runtime dependency of the framework. + * Resolution only runs when record mapping metadata is built, never on the row mapping hot path.

+ */ +public final class Nullability { + + private static final Class JSPECIFY_NON_NULL = load("org.jspecify.annotations.NonNull"); + private static final Class JSPECIFY_NULLABLE = load("org.jspecify.annotations.Nullable"); + private static final Class NULL_MARKED = load("org.jspecify.annotations.NullMarked"); + private static final Class NULL_UNMARKED = load("org.jspecify.annotations.NullUnmarked"); + private static final Class JAVAX_NONNULL = load("javax.annotation.Nonnull"); + private static final Class JAVAX_NULLABLE = load("javax.annotation.Nullable"); + private static final Class JAKARTA_NONNULL = load("jakarta.annotation.Nonnull"); + private static final Class JAKARTA_NULLABLE = load("jakarta.annotation.Nullable"); + private static final Class KOTLIN_METADATA = load("kotlin.Metadata"); + + @Nullable + @SuppressWarnings("unchecked") + private static Class load(@Nonnull String name) { + try { + return (Class) Class.forName(name); + } catch (ClassNotFoundException e) { + return null; + } + } + + private Nullability() { + } + + /** + * Returns whether the given type use is non-null under the null-marked-by-default contract. + * + * @param declaration the record component or parameter declaration, carrying declaration annotations. + * @param annotatedType the annotated type of the declaration, carrying type-use annotations. + * @param executable the constructor the type use belongs to, or null for record components. + * @param declaringClass the class declaring the record component or constructor. + * @return {@code true} if the type use is non-null. + */ + public static boolean isNonNull(@Nonnull AnnotatedElement declaration, + @Nonnull AnnotatedType annotatedType, + @Nullable Executable executable, + @Nonnull Class declaringClass) { + // Explicit annotations always win, nullable before non-null. + if (isPresent(declaration, JAVAX_NULLABLE) || isPresent(declaration, JAKARTA_NULLABLE) + || isPresent(annotatedType, JSPECIFY_NULLABLE)) { + return false; + } + if (isPresent(declaration, JAVAX_NONNULL) || isPresent(declaration, JAKARTA_NONNULL) + || isPresent(annotatedType, JSPECIFY_NON_NULL)) { + return true; + } + if (isPresent(declaringClass, KOTLIN_METADATA)) { + // Kotlin nullness comes from the language, resolved by the Kotlin reflection provider. + return false; + } + // The nearest scope marker decides; without one, the model is null-marked. + return !isNullUnmarked(executable, declaringClass); + } + + /** + * Resolves the nearest {@code @NullUnmarked} / {@code @NullMarked} marker for the given declaration site. + */ + private static boolean isNullUnmarked(@Nullable Executable executable, @Nonnull Class declaringClass) { + if (NULL_UNMARKED == null) { + return false; // JSpecify is not on the class path; there is no way to opt out of the default. + } + if (executable != null) { + Boolean unmarked = marker(executable); + if (unmarked != null) { + return unmarked; + } + } + for (Class enclosing = declaringClass; enclosing != null; enclosing = enclosing.getEnclosingClass()) { + Boolean unmarked = marker(enclosing); + if (unmarked != null) { + return unmarked; + } + } + Package declaringPackage = declaringClass.getPackage(); + if (declaringPackage != null) { + Boolean unmarked = marker(declaringPackage); + if (unmarked != null) { + return unmarked; + } + } + Boolean unmarked = marker(declaringClass.getModule()); + return unmarked != null && unmarked; + } + + @Nullable + private static Boolean marker(@Nonnull AnnotatedElement element) { + if (element.isAnnotationPresent(NULL_UNMARKED)) { + return Boolean.TRUE; + } + if (NULL_MARKED != null && element.isAnnotationPresent(NULL_MARKED)) { + return Boolean.FALSE; + } + return null; + } + + private static boolean isPresent(@Nonnull AnnotatedElement element, + @Nullable Class annotation) { + return annotation != null && element.isAnnotationPresent(annotation); + } +} diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java index 3427e7a17..5873e7557 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java @@ -36,6 +36,7 @@ import st.orm.Data; import st.orm.PK; import st.orm.core.spi.Instantiators; +import st.orm.core.spi.Nullability; import st.orm.core.spi.ORMReflection; import st.orm.core.spi.Providers; import st.orm.core.spi.RefFactory; @@ -291,25 +292,9 @@ static boolean isKotlinClass(@Nonnull Class clazz) { static String nullableHint(@Nonnull Class clazz) { return isKotlinClass(clazz) ? "make the property nullable (e.g., String?)" - : "annotate the field with @Nullable"; + : "annotate the field with @Nullable (org.jspecify.annotations or jakarta.annotation), or opt the class or package out of null-marked defaults with @NullUnmarked"; } - @SuppressWarnings("unchecked") - static final Class JAVAX_NONNULL = ((Supplier>) () -> { - try { - return (Class) Class.forName("javax.annotation.Nonnull"); - } catch (ClassNotFoundException e) { - return null; - } - }).get(); - @SuppressWarnings("unchecked") - static final Class JAKARTA_NONNULL = ((Supplier>) () -> { - try { - return (Class) Class.forName("jakarta.annotation.Nonnull"); - } catch (ClassNotFoundException e) { - return null; - } - }).get(); /** * Returns true if the specified parameter is marked as non-null, false otherwise. @@ -321,7 +306,7 @@ static String nullableHint(@Nonnull Class clazz) { */ static boolean isNonnull(@Nonnull Parameter parameter) { return parameter.isAnnotationPresent(PK.class) - || (JAVAX_NONNULL != null && parameter.isAnnotationPresent(JAVAX_NONNULL)) - || (JAKARTA_NONNULL != null && parameter.isAnnotationPresent(JAKARTA_NONNULL)); + || Nullability.isNonNull(parameter, parameter.getAnnotatedType(), parameter.getDeclaringExecutable(), + parameter.getDeclaringExecutable().getDeclaringClass()); } } diff --git a/storm-core/src/test/java/st/orm/core/EqualitySupportIntegrationTest.java b/storm-core/src/test/java/st/orm/core/EqualitySupportIntegrationTest.java index 8f639b962..f5d4a03a8 100644 --- a/storm-core/src/test/java/st/orm/core/EqualitySupportIntegrationTest.java +++ b/storm-core/src/test/java/st/orm/core/EqualitySupportIntegrationTest.java @@ -9,6 +9,7 @@ import static st.orm.core.template.SqlInterceptor.observe; import static st.orm.core.template.TemplateString.raw; +import jakarta.annotation.Nullable; import java.util.concurrent.atomic.AtomicReference; import javax.sql.DataSource; import lombok.Builder; @@ -58,7 +59,7 @@ public record PrimitiveEntity( short shortVal, float floatVal, double doubleVal, - String stringVal + @Nullable String stringVal ) implements Entity {} @DynamicUpdate(FIELD) @@ -73,7 +74,7 @@ public record PrimitiveEntityDefault( short shortVal, float floatVal, double doubleVal, - String stringVal + @Nullable String stringVal ) implements Entity {} @BeforeEach diff --git a/storm-core/src/test/java/st/orm/core/JSpecifyNullMarkedIntegrationTest.java b/storm-core/src/test/java/st/orm/core/JSpecifyNullMarkedIntegrationTest.java new file mode 100644 index 000000000..b45de47b4 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/JSpecifyNullMarkedIntegrationTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package st.orm.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import st.orm.PersistenceException; +import st.orm.core.model.nullmarked.MarkedComment; +import st.orm.core.model.nullmarked.MarkedNote; +import st.orm.core.model.nullmarked.UnmarkedNote; +import st.orm.core.template.ORMTemplate; +import st.orm.core.template.SqlTemplateException; + +/** + * Verifies JSpecify {@code @NullMarked} scope semantics: inside a null-marked package, unannotated components are + * non-null by default, {@code @Nullable} opts out per type use, and {@code @NullUnmarked} cancels the scope per + * class. + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = IntegrationConfig.class) +@DataJpaTest(showSql = false) +public class JSpecifyNullMarkedIntegrationTest { + + @Autowired + private DataSource dataSource; + + @Test + public void testNullMarkedScopeMakesUnannotatedComponentNonNull() { + PersistenceException e = assertThrows(PersistenceException.class, () -> { + var query = ORMTemplate.of(dataSource).query("SELECT 1 AS id, CAST(NULL AS VARCHAR) AS label"); + query.getResultList(MarkedNote.class); + }); + assertInstanceOf(SqlTemplateException.class, e.getCause()); + assertTrue(e.getCause().getMessage().contains("non-nullable"), + "Expected a non-nullable violation, got: " + e.getCause().getMessage()); + } + + @Test + public void testNullMarkedScopeMapsNonNullValues() { + var query = ORMTemplate.of(dataSource).query("SELECT 1 AS id, 'note' AS label"); + var notes = query.getResultList(MarkedNote.class); + assertEquals(new MarkedNote(1, "note"), notes.getFirst()); + } + + @Test + public void testNullableOptOutAllowsNullWithinNullMarkedScope() { + var query = ORMTemplate.of(dataSource).query("SELECT 1 AS id, CAST(NULL AS VARCHAR) AS remark"); + var comments = query.getResultList(MarkedComment.class); + assertNull(comments.getFirst().remark()); + } + + @Test + public void testNullUnmarkedClassCancelsPackageScope() { + var query = ORMTemplate.of(dataSource).query("SELECT 1 AS id, CAST(NULL AS VARCHAR) AS label"); + var notes = query.getResultList(UnmarkedNote.class); + assertNull(notes.getFirst().label()); + } +} diff --git a/storm-core/src/test/java/st/orm/core/PlainPreparedStatementIntegrationTest.java b/storm-core/src/test/java/st/orm/core/PlainPreparedStatementIntegrationTest.java index fd16744d2..3da67a764 100644 --- a/storm-core/src/test/java/st/orm/core/PlainPreparedStatementIntegrationTest.java +++ b/storm-core/src/test/java/st/orm/core/PlainPreparedStatementIntegrationTest.java @@ -3,11 +3,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static st.orm.core.template.TemplateString.raw; import jakarta.annotation.Nonnull; import java.time.LocalDate; -import java.util.Objects; import javax.sql.DataSource; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -118,16 +118,21 @@ SELECT p.id, p.name, p.birth_date, UPPER(pt.name) pet_type } @Test - public void testSelectPetTypedWithLocalRecordAndEnumNull() { - // Selecting NULL as pet_type for all 13 pets. All types should be null. + public void testSelectPetTypedWithLocalRecordAndEnumNullThrows() { + // Unannotated components are non-null by default (null-marked contract). Selecting NULL for the + // unannotated enum field should throw PersistenceException (via SqlTemplateException). record Pet(int id, String name, LocalDate birthDate, PetTypeEnum type) {} - try (var query = ORMTemplate.of(dataSource).query(""" - SELECT p.id, p.name, p.birth_date, NULL pet_type - FROM pet p - INNER JOIN pet_type pt ON p.type_id = pt.id""").prepare(); - var stream = query.getResultStream(Pet.class)) { - assertEquals(13, stream.map(Pet::type).filter(Objects::isNull).count()); - } + PersistenceException e = assertThrows(PersistenceException.class, () -> { + try (var query = ORMTemplate.of(dataSource).query(""" + SELECT p.id, p.name, p.birth_date, NULL pet_type + FROM pet p + INNER JOIN pet_type pt ON p.type_id = pt.id""").prepare(); + var stream = query.getResultStream(Pet.class)) { + stream.toList(); + } + }); + assertInstanceOf(SqlTemplateException.class, e.getCause()); + assertTrue(e.getCause().getMessage().contains("non-nullable")); } @Test diff --git a/storm-core/src/test/java/st/orm/core/QueryImplTypeMappingIntegrationTest.java b/storm-core/src/test/java/st/orm/core/QueryImplTypeMappingIntegrationTest.java index 1d3d67e14..5a5c6c51a 100644 --- a/storm-core/src/test/java/st/orm/core/QueryImplTypeMappingIntegrationTest.java +++ b/storm-core/src/test/java/st/orm/core/QueryImplTypeMappingIntegrationTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static st.orm.core.template.TemplateString.raw; +import jakarta.annotation.Nullable; import java.math.BigDecimal; import java.time.Instant; import java.time.LocalDate; @@ -118,7 +119,7 @@ public void testBooleanTypeMapping() { // String type mapping via record - record StringResult(String value) {} + record StringResult(@Nullable String value) {} @Test public void testStringTypeMapping() { @@ -142,7 +143,7 @@ public void testBigDecimalTypeMapping() { // LocalDate type mapping via record - record LocalDateResult(LocalDate value) {} + record LocalDateResult(@Nullable LocalDate value) {} @Test public void testLocalDateTypeMapping() { @@ -153,7 +154,7 @@ public void testLocalDateTypeMapping() { // LocalDateTime type mapping via record - record LocalDateTimeResult(LocalDateTime value) {} + record LocalDateTimeResult(@Nullable LocalDateTime value) {} @Test public void testLocalDateTimeTypeMapping() { @@ -168,7 +169,7 @@ public void testLocalDateTimeTypeMapping() { // LocalTime type mapping via record - record LocalTimeResult(LocalTime value) {} + record LocalTimeResult(@Nullable LocalTime value) {} @Test public void testLocalTimeTypeMapping() { @@ -182,7 +183,7 @@ public void testLocalTimeTypeMapping() { // Instant type mapping via record - record InstantResult(Instant value) {} + record InstantResult(@Nullable Instant value) {} @Test public void testInstantTypeMapping() { @@ -196,7 +197,7 @@ public void testInstantTypeMapping() { // OffsetDateTime type mapping via record - record OffsetDateTimeResult(OffsetDateTime value) {} + record OffsetDateTimeResult(@Nullable OffsetDateTime value) {} @Test public void testOffsetDateTimeTypeMapping() { @@ -209,7 +210,7 @@ public void testOffsetDateTimeTypeMapping() { // ZonedDateTime type mapping via record - record ZonedDateTimeResult(ZonedDateTime value) {} + record ZonedDateTimeResult(@Nullable ZonedDateTime value) {} @Test public void testZonedDateTimeTypeMapping() { @@ -258,7 +259,7 @@ public void testSqlTimeTypeMapping() { // java.util.Date type mapping via record - record JavaUtilDateResult(java.util.Date value) {} + record JavaUtilDateResult(@Nullable java.util.Date value) {} @Test public void testJavaUtilDateTypeMapping() { diff --git a/storm-core/src/test/java/st/orm/core/SqlTemplateAndQueryIntegrationTest.java b/storm-core/src/test/java/st/orm/core/SqlTemplateAndQueryIntegrationTest.java index bdcb8ad86..58dfa3275 100644 --- a/storm-core/src/test/java/st/orm/core/SqlTemplateAndQueryIntegrationTest.java +++ b/storm-core/src/test/java/st/orm/core/SqlTemplateAndQueryIntegrationTest.java @@ -8,6 +8,7 @@ import static st.orm.core.template.ORMTemplate.of; import static st.orm.core.template.TemplateString.raw; +import jakarta.annotation.Nullable; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; @@ -74,7 +75,7 @@ public void getResultStreamWithEmptyResultShouldReturnEmptyStream() { // readColumnValue with null values for temporal types - record NullableIntegerResult(Integer value) {} + record NullableIntegerResult(@Nullable Integer value) {} @Test public void readColumnValueWithNullShouldReturnNull() { @@ -83,7 +84,7 @@ public void readColumnValueWithNullShouldReturnNull() { assertNull(result.value()); } - record UtilDateResult(java.util.Date value) {} + record UtilDateResult(@Nullable java.util.Date value) {} @Test public void readColumnValueWithNullUtilDateShouldReturnNull() { @@ -92,7 +93,7 @@ public void readColumnValueWithNullUtilDateShouldReturnNull() { assertNull(result.value()); } - record OffsetDateTimeResult(OffsetDateTime value) {} + record OffsetDateTimeResult(@Nullable OffsetDateTime value) {} @Test public void readColumnValueWithNullOffsetDateTimeShouldReturnNull() { @@ -102,7 +103,7 @@ public void readColumnValueWithNullOffsetDateTimeShouldReturnNull() { assertNull(result.value()); } - record ZonedDateTimeResult(ZonedDateTime value) {} + record ZonedDateTimeResult(@Nullable ZonedDateTime value) {} @Test public void readColumnValueWithNullZonedDateTimeShouldReturnNull() { @@ -112,7 +113,7 @@ public void readColumnValueWithNullZonedDateTimeShouldReturnNull() { assertNull(result.value()); } - record InstantResult(Instant value) {} + record InstantResult(@Nullable Instant value) {} @Test public void readColumnValueWithNullInstantShouldReturnNull() { @@ -122,7 +123,7 @@ public void readColumnValueWithNullInstantShouldReturnNull() { assertNull(result.value()); } - record LocalDateTimeResult(LocalDateTime value) {} + record LocalDateTimeResult(@Nullable LocalDateTime value) {} @Test public void readColumnValueWithNullLocalDateTimeShouldReturnNull() { @@ -132,7 +133,7 @@ public void readColumnValueWithNullLocalDateTimeShouldReturnNull() { assertNull(result.value()); } - record NullableLocalDateResult(LocalDate value) {} + record NullableLocalDateResult(@Nullable LocalDate value) {} @Test public void readColumnValueWithNullLocalDateShouldReturnNull() { @@ -142,7 +143,7 @@ public void readColumnValueWithNullLocalDateShouldReturnNull() { assertNull(result.value()); } - record NullableLocalTimeResult(LocalTime value) {} + record NullableLocalTimeResult(@Nullable LocalTime value) {} @Test public void readColumnValueWithNullLocalTimeShouldReturnNull() { diff --git a/storm-core/src/test/java/st/orm/core/TemplatePreparedStatementIntegrationTest.java b/storm-core/src/test/java/st/orm/core/TemplatePreparedStatementIntegrationTest.java index ef9788534..310ff1caa 100644 --- a/storm-core/src/test/java/st/orm/core/TemplatePreparedStatementIntegrationTest.java +++ b/storm-core/src/test/java/st/orm/core/TemplatePreparedStatementIntegrationTest.java @@ -171,7 +171,7 @@ public void testSelectPetWithTableAndJoins() { public void testSelectPetWithRecord() { // Custom local records for Owner/Pet. Same '%y%' filter; 5 distinct owner first names. record Owner(String firstName, String lastName, String telephone) implements Data {} - record Pet(String name, LocalDate birthDate, Owner owner) implements Data {} + record Pet(String name, LocalDate birthDate, @Nullable Owner owner) implements Data {} String nameFilter = "%y%"; var query = ORMTemplate.of(dataSource).query(raw(""" SELECT \0 @@ -608,7 +608,7 @@ public void testInsertInlined() { public void testWith() { // CTE filters pets by '%y%', then LEFT JOIN to owner. 5 distinct owner first names // (Sly matches but has null owner, filtered out). - record FilteredPet(int id, @FK Owner owner) implements Data {} + record FilteredPet(int id, @Nullable @FK Owner owner) implements Data {} String nameFilter = "%y%"; try (var query = ORMTemplate.of(dataSource).query(raw(""" WITH filtered_pet AS ( @@ -632,7 +632,7 @@ record Owner( String lastName, Address address, String telephone) implements Data {} - record FilteredPet(int id, @FK Owner owner) implements Data {} + record FilteredPet(int id, @Nullable @FK Owner owner) implements Data {} String nameFilter = "%y%"; try (var query = ORMTemplate.of(dataSource).query(raw(""" WITH filtered_pet AS ( diff --git a/storm-core/src/test/java/st/orm/core/model/Address.java b/storm-core/src/test/java/st/orm/core/model/Address.java index ee00ae6fa..9bd12cfde 100644 --- a/storm-core/src/test/java/st/orm/core/model/Address.java +++ b/storm-core/src/test/java/st/orm/core/model/Address.java @@ -1,5 +1,6 @@ package st.orm.core.model; +import jakarta.annotation.Nullable; import lombok.Builder; import st.orm.FK; @@ -9,5 +10,5 @@ @Builder(toBuilder = true) public record Address( String address, - @FK City city + @Nullable @FK City city ) {} diff --git a/storm-core/src/test/java/st/orm/core/model/PetOwnerRef.java b/storm-core/src/test/java/st/orm/core/model/PetOwnerRef.java index f33b75b81..433919890 100644 --- a/storm-core/src/test/java/st/orm/core/model/PetOwnerRef.java +++ b/storm-core/src/test/java/st/orm/core/model/PetOwnerRef.java @@ -1,6 +1,7 @@ package st.orm.core.model; import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import java.time.LocalDate; import lombok.Builder; import st.orm.DbColumn; @@ -18,6 +19,6 @@ public record PetOwnerRef( @Nonnull String name, @Nonnull @Persist(updatable = false) LocalDate birthDate, @Nonnull @FK @DbColumn("type_id") @Persist(updatable = false) PetType petType, - @FK @DbColumn("owner_id") Ref owner + @Nullable @FK @DbColumn("owner_id") Ref owner ) implements Entity { } diff --git a/storm-core/src/test/java/st/orm/core/model/PetWithNullableOwnerRef.java b/storm-core/src/test/java/st/orm/core/model/PetWithNullableOwnerRef.java index b6457ebbe..01dacbb90 100644 --- a/storm-core/src/test/java/st/orm/core/model/PetWithNullableOwnerRef.java +++ b/storm-core/src/test/java/st/orm/core/model/PetWithNullableOwnerRef.java @@ -1,6 +1,7 @@ package st.orm.core.model; import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import java.time.LocalDate; import st.orm.DbColumn; import st.orm.DbTable; @@ -16,6 +17,6 @@ public record PetWithNullableOwnerRef( @Nonnull String name, @Nonnull @Persist(updatable = false) LocalDate birthDate, @Nonnull @FK @Persist(updatable = false) @DbColumn("type_id") PetType petType, - @FK Ref owner + @Nullable @FK Ref owner ) implements Entity { } diff --git a/storm-core/src/test/java/st/orm/core/model/nullmarked/MarkedComment.java b/storm-core/src/test/java/st/orm/core/model/nullmarked/MarkedComment.java new file mode 100644 index 000000000..5d83b9ee1 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/model/nullmarked/MarkedComment.java @@ -0,0 +1,23 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package st.orm.core.model.nullmarked; + +import org.jspecify.annotations.Nullable; + +/** + * The {@code @Nullable} type use opts out of the package-level {@code @NullMarked} scope. + */ +public record MarkedComment(Integer id, @Nullable String remark) {} diff --git a/storm-core/src/test/java/st/orm/core/model/nullmarked/MarkedNote.java b/storm-core/src/test/java/st/orm/core/model/nullmarked/MarkedNote.java new file mode 100644 index 000000000..99ddb5403 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/model/nullmarked/MarkedNote.java @@ -0,0 +1,21 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package st.orm.core.model.nullmarked; + +/** + * Unannotated components: non-null through the package-level {@code @NullMarked} scope. + */ +public record MarkedNote(Integer id, String label) {} diff --git a/storm-core/src/test/java/st/orm/core/model/nullmarked/UnmarkedNote.java b/storm-core/src/test/java/st/orm/core/model/nullmarked/UnmarkedNote.java new file mode 100644 index 000000000..30d354d98 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/model/nullmarked/UnmarkedNote.java @@ -0,0 +1,24 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package st.orm.core.model.nullmarked; + +import org.jspecify.annotations.NullUnmarked; + +/** + * The class-level {@code @NullUnmarked} cancels the package-level scope: unannotated components stay nullable. + */ +@NullUnmarked +public record UnmarkedNote(Integer id, String label) {} diff --git a/storm-core/src/test/java/st/orm/core/model/nullmarked/package-info.java b/storm-core/src/test/java/st/orm/core/model/nullmarked/package-info.java new file mode 100644 index 000000000..e984dc893 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/model/nullmarked/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Test models for JSpecify {@code @NullMarked} scope semantics: every type use in this package is non-null by + * default, with {@code @Nullable} as the opt-out. + */ +@NullMarked +package st.orm.core.model.nullmarked; + +import org.jspecify.annotations.NullMarked; diff --git a/storm-core/src/test/java/st/orm/core/repository/impl/DefaultORMReflectionImplTest.java b/storm-core/src/test/java/st/orm/core/repository/impl/DefaultORMReflectionImplTest.java index 643ac4055..a8bc29aff 100644 --- a/storm-core/src/test/java/st/orm/core/repository/impl/DefaultORMReflectionImplTest.java +++ b/storm-core/src/test/java/st/orm/core/repository/impl/DefaultORMReflectionImplTest.java @@ -27,7 +27,8 @@ record NullabilityMarkers( int primitiveValue, String unmarked, @jakarta.annotation.Nonnull String jakartaNonnull, - @org.jspecify.annotations.NonNull String jspecifyNonNull + @org.jspecify.annotations.NonNull String jspecifyNonNull, + @jakarta.annotation.Nullable String jakartaNullable ) implements Entity {} sealed interface SealedData extends Data permits SubData1, SubData2 {} @@ -74,9 +75,10 @@ public void testFieldNullability() { var fields = reflection.findRecordType(NullabilityMarkers.class).orElseThrow().fields(); assertFalse(fields.get(0).nullable(), "@PK implies non-null"); assertFalse(fields.get(1).nullable(), "primitives are non-null"); - assertTrue(fields.get(2).nullable(), "unmarked components are nullable by default"); + assertFalse(fields.get(2).nullable(), "unmarked components are non-null by default"); assertFalse(fields.get(3).nullable(), "jakarta.annotation.Nonnull marks non-null"); assertFalse(fields.get(4).nullable(), "org.jspecify.annotations.NonNull (TYPE_USE) marks non-null"); + assertTrue(fields.get(5).nullable(), "jakarta.annotation.Nullable marks nullable"); } // getType diff --git a/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java b/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java index 9afb97ea2..6aae38dc3 100644 --- a/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java +++ b/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java @@ -106,8 +106,8 @@ public record SequenceEntity( ) implements Entity {} public record InlinedAddress( - String street, - String zipCode + @Nullable String street, + @Nullable String zipCode ) {} public record EntityWithInline( diff --git a/storm-core/src/test/java/st/orm/core/template/impl/QueryImplTest.java b/storm-core/src/test/java/st/orm/core/template/impl/QueryImplTest.java index 7cc3c022e..e7601f862 100644 --- a/storm-core/src/test/java/st/orm/core/template/impl/QueryImplTest.java +++ b/storm-core/src/test/java/st/orm/core/template/impl/QueryImplTest.java @@ -8,6 +8,7 @@ import static st.orm.core.template.TemplateString.raw; import static st.orm.core.template.Templates.param; +import jakarta.annotation.Nullable; import java.nio.ByteBuffer; import java.sql.Timestamp; import java.util.Calendar; @@ -46,7 +47,7 @@ public class QueryImplTest { @Autowired private DataSource dataSource; - record CalendarResult(Calendar value) {} + record CalendarResult(@Nullable Calendar value) {} @Test public void testCalendarTypeMapping() { @@ -76,7 +77,7 @@ public void testTimestampTypeMapping() { assertNotNull(result.value()); } - record ByteBufferResult(ByteBuffer value) {} + record ByteBufferResult(@Nullable ByteBuffer value) {} @Test public void testByteBufferReadTypeMapping() { diff --git a/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java b/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java index b99ef4cc9..f9456e43f 100644 --- a/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java +++ b/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java @@ -545,16 +545,61 @@ private static boolean isUniqueField(@Nonnull Element recordElement, @Nonnull St return false; } - private static boolean hasNonnullAnnotation(@Nonnull Element element) { + /** Nullable markers, matching the runtime contract (JetBrains covers Kotlin kapt stubs). */ + private static final Set NULLABLE_ANNOTATIONS = Set.of( + "org.jspecify.annotations.Nullable", + "jakarta.annotation.Nullable", + "javax.annotation.Nullable", + "org.jetbrains.annotations.Nullable"); + + /** Non-null markers, matching the runtime contract (JetBrains covers Kotlin kapt stubs). */ + private static final Set NONNULL_ANNOTATIONS = Set.of( + "org.jspecify.annotations.NonNull", + "jakarta.annotation.Nonnull", + "javax.annotation.Nonnull", + "org.jetbrains.annotations.NotNull"); + + private static final String NULL_MARKED = "org.jspecify.annotations.NullMarked"; + private static final String NULL_UNMARKED = "org.jspecify.annotations.NullUnmarked"; + + /** + * Returns whether the element carries any of the given annotations, checking both the declaration and the + * type use (JSpecify annotations annotate the type rather than the declaration). + */ + private static boolean hasAnyAnnotation(@Nonnull Element element, @Nonnull Set names) { for (AnnotationMirror am : element.getAnnotationMirrors()) { - String name = am.getAnnotationType().toString(); - if ("jakarta.annotation.Nonnull".equals(name) || "javax.annotation.Nonnull".equals(name)) { + if (names.contains(am.getAnnotationType().toString())) { + return true; + } + } + for (AnnotationMirror am : element.asType().getAnnotationMirrors()) { + if (names.contains(am.getAnnotationType().toString())) { return true; } } return false; } + /** + * Resolves the nearest {@code @NullUnmarked} / {@code @NullMarked} marker by walking the enclosing elements + * (class, enclosing classes, package, module). Without a marker the scope is null-marked: this mirrors the + * runtime contract, where models are null-marked by default. + */ + private static boolean isNullUnmarkedScope(@Nonnull Element element) { + for (Element enclosing = element; enclosing != null; enclosing = enclosing.getEnclosingElement()) { + for (AnnotationMirror am : enclosing.getAnnotationMirrors()) { + String name = am.getAnnotationType().toString(); + if (NULL_UNMARKED.equals(name)) { + return true; + } + if (NULL_MARKED.equals(name)) { + return false; + } + } + } + return false; + } + private static boolean isPrimaryKeyField(@Nonnull Element recordElement, @Nonnull String fieldName) { if (recordElement.getKind() == RECORD && recordElement instanceof TypeElement te) { for (RecordComponentElement rc : te.getRecordComponents()) { @@ -582,11 +627,13 @@ private boolean isNullableUniqueField(@Nonnull Element recordElement, @Nonnull S TypeMirror fieldType = getTypeElement(recordElement, fieldName); if (fieldType != null && isPrimitiveReturn(fieldType)) return false; - // Check for @Nonnull annotations on record components. + // Explicit annotations win, nullable before non-null, matching the runtime contract. + // Check record components first. if (recordElement.getKind() == RECORD && recordElement instanceof TypeElement te) { for (RecordComponentElement rc : te.getRecordComponents()) { if (rc.getSimpleName().toString().equals(fieldName)) { - if (hasNonnullAnnotation(rc)) return false; + if (hasAnyAnnotation(rc, NULLABLE_ANNOTATIONS)) return true; + if (hasAnyAnnotation(rc, NONNULL_ANNOTATIONS)) return false; } } } @@ -595,11 +642,13 @@ private boolean isNullableUniqueField(@Nonnull Element recordElement, @Nonnull S if (enclosed.getKind() != CONSTRUCTOR) continue; for (VariableElement param : ((ExecutableElement) enclosed).getParameters()) { if (param.getSimpleName().toString().equals(fieldName)) { - if (hasNonnullAnnotation(param)) return false; + if (hasAnyAnnotation(param, NULLABLE_ANNOTATIONS)) return true; + if (hasAnyAnnotation(param, NONNULL_ANNOTATIONS)) return false; } } } - return true; + // Models are null-marked by default; only a @NullUnmarked scope makes unannotated fields nullable. + return isNullUnmarkedScope(recordElement); } /** diff --git a/website/static/skills/storm-entity-from-schema.md b/website/static/skills/storm-entity-from-schema.md index 231ead3bc..af81e845f 100644 --- a/website/static/skills/storm-entity-from-schema.md +++ b/website/static/skills/storm-entity-from-schema.md @@ -22,7 +22,7 @@ Generation/update rules: - **Every column with a FK constraint must be modeled with `@FK`.** Without `@FK`, Storm cannot resolve joins automatically. Prefer full entity types (`@FK val city: City` / `@FK City city`) over `Ref`. Use `Ref` when the entity hierarchy gets too deep or loading the full related entity is overkill. - **FK columns in primary keys:** When a PK column is also a FK (both PK and FK constraint on the same column), use raw IDs in the PK class and place `@FK @Persist(insertable = false, updatable = false)` fields on the entity for join metadata. Add a convenience constructor that accepts the FK entities/refs and constructs the PK internally. - Auto-increment PKs: IDENTITY. Others: NONE. -- NOT NULL FKs: non-nullable. Nullable FKs: nullable. +- NOT NULL FKs: non-nullable. Nullable FKs: nullable. In Kotlin the type expresses this (`City` / `City?`); in Java components are non-null by default, so mark nullable columns `@Nullable` (JSpecify or jakarta) and leave NOT NULL columns unannotated. - CIRCULAR NOT SUPPORTED. Two tables referencing each other: one must use Ref. Self-ref: always Ref. - **Single-column unique constraints** (apply by default): use `@UK` on the field. Generates a `Metamodel.Key` for type-safe lookups and scrolling. Always add `@UK` when `describe_table` shows a single-column unique constraint — it's one annotation for free value. - **Composite unique constraints** (only when needed): composite unique constraints do NOT need to be modeled unless the user explicitly needs a composite `Metamodel.Key` (for keyset pagination or type-safe lookups). When needed, use an inline record + `@UK @Persist(insertable = false, updatable = false)` — ask the user if they need this. diff --git a/website/static/skills/storm-entity-java.md b/website/static/skills/storm-entity-java.md index 52587b714..003a13542 100644 --- a/website/static/skills/storm-entity-java.md +++ b/website/static/skills/storm-entity-java.md @@ -22,7 +22,7 @@ Before generating, ask about their relationship loading preference: Generation rules: 1. Use Java records implementing \`Entity\`: - \`record City(@PK Integer id, @Nonnull String name, long population) implements Entity {}\` + \`record City(@PK Integer id, String name, long population) implements Entity {}\` 2. Primary keys (\`@PK\`): - IDENTITY (default): \`@PK Integer id\` with null on insert. @@ -30,7 +30,9 @@ Generation rules: - NONE: \`@PK(generation = NONE) String code\` for natural keys. 3. Nullability: - - Record components nullable by default. Use \`@Nonnull\` for required fields. + - Record components are non-null by default, exactly like Kotlin. Mark nullable fields with \`@Nullable\` + (JSpecify \`org.jspecify.annotations.Nullable\`, \`jakarta.annotation.Nullable\`, or \`javax.annotation.Nullable\`). + \`@NullUnmarked\` on a class or package restores nullable-by-default components for that scope. - Storm recognizes \`jakarta.annotation.Nonnull\`, \`javax.annotation.Nonnull\`, and JSpecify's \`org.jspecify.annotations.NonNull\` on the component. Other null-marker annotations (Lombok, JetBrains, Spring) are NOT recognized, and JSpecify \`@NullMarked\` scope defaults are NOT @@ -39,13 +41,13 @@ Generation rules: 4. Foreign keys (\`@FK\`): - **Every column with a FK constraint in the database must be modeled with `@FK` in the entity.** Without `@FK`, Storm has no FK metadata and cannot resolve joins automatically — forcing template-based joins that defeat the QueryBuilder. - - Prefer full entity types (`@Nonnull @FK City city`) over `Ref` (`@FK Ref city`). Full entities load the related data in one query with automatic JOINs. + - Prefer full entity types (`@FK City city`) over `Ref` (`@FK Ref city`). Full entities load the related data in one query with automatic JOINs. - Use `Ref` when the entity hierarchy gets too deep or loading the full related entity is overkill for the use case. - - Non-nullable: \`@Nonnull @FK City city\` produces INNER JOIN. + - Non-nullable: \`@FK City city\` produces INNER JOIN (non-null is the default). - Nullable: \`@Nullable @FK City city\` produces LEFT JOIN. - - **A bare \`@FK City city\` without a non-null marker is treated as nullable and produces a LEFT JOIN.** - Match the database: if the FK column is NOT NULL, the field MUST carry \`@Nonnull\` - (\`jakarta.annotation.Nonnull\`) or JSpecify \`@NonNull\`, otherwise queries silently degrade to LEFT JOINs. + - **A bare \`@FK City city\` is non-null and produces an INNER JOIN.** + Match the database: if the FK column allows NULL, the field MUST carry \`@Nullable\`, + otherwise the INNER JOIN silently filters rows without the reference. 5. CIRCULAR REFERENCES ARE NOT SUPPORTED. At least one side MUST use \`Ref\`. Self-references MUST use \`Ref\`: \`@Nullable @FK Ref invitedBy\`. @@ -73,8 +75,8 @@ Generation rules: record UserRolePk(int userId, int roleId) {} record UserRole(@PK(generation = NONE) UserRolePk id, - @Nonnull @FK @Persist(insertable = false, updatable = false) User user, - @Nonnull @FK @Persist(insertable = false, updatable = false) Role role + @FK @Persist(insertable = false, updatable = false) User user, + @FK @Persist(insertable = false, updatable = false) Role role ) implements Entity { UserRole(User user, Role role) { this(new UserRolePk(user.id(), role.id()), user, role); @@ -88,8 +90,8 @@ Generation rules: record UserAddressPk(int userId, int addressNumber) {} record UserAddress(@PK(generation = NONE) UserAddressPk id, - @Nonnull @FK @Persist(insertable = false, updatable = false) User user, // userId in PK → non-insertable - @Nonnull @FK City city // city_id NOT in PK → insertable + @FK @Persist(insertable = false, updatable = false) User user, // userId in PK → non-insertable + @FK City city // city_id NOT in PK → insertable ) implements Entity { UserAddress(User user, int addressNumber, City city) { this(new UserAddressPk(user.id(), addressNumber), user, city); @@ -108,7 +110,7 @@ Generation rules: ``` 10. Unique keys: - - **Single-column** (apply by default): `@UK @Nonnull String email`. Generates a `Metamodel.Key` for type-safe lookups and scrolling. Always add `@UK` when the database has a single-column unique constraint — it's one annotation for free value. + - **Single-column** (apply by default): `@UK String email`. Generates a `Metamodel.Key` for type-safe lookups and scrolling. Always add `@UK` when the database has a single-column unique constraint — it's one annotation for free value. - **Composite** (only when needed in code): use an inline record + `@UK @Persist(insertable = false, updatable = false)`. Only add this when the user explicitly needs a composite `Metamodel.Key` for keyset pagination or type-safe lookups. Composite unique constraints that don't need a Key don't need to be modeled. - `@UK(constraint = false)` suppresses schema validation when no database constraint exists. @@ -117,7 +119,7 @@ Generation rules: 11b. Database-managed columns: annotate columns the database computes or maintains (e.g. \`DEFAULT CURRENT_TIMESTAMP\`, \`ON UPDATE\` timestamps, computed values) with \`@Persist(insertable = false, updatable = false)\`. Storm then never writes the column and always reads it back: \`\`\`java record User(@PK Integer id, - @Nonnull String email, + String email, @Persist(insertable = false, updatable = false) Instant registeredAt ) implements Entity {} \`\`\` diff --git a/website/static/skills/storm-query-java.md b/website/static/skills/storm-query-java.md index fd22f4a60..2a1a8dbf1 100644 --- a/website/static/skills/storm-query-java.md +++ b/website/static/skills/storm-query-java.md @@ -111,7 +111,7 @@ Limit/Offset: `.limit(10)`, `.offset(20)` Pagination: `.page(0, 20)` or `.page(Pageable.ofSize(20).sortBy(User_.name))` Scrolling (keyset): `.scroll(Scrollable.of(User_.id, 20))` — do NOT combine with `orderBy()` (Scrollable manages ORDER BY internally, see Keyset Scrolling section) Explicit joins: `.innerJoin(Entity.class).on(OtherEntity.class)`, `.leftJoin(Entity.class).on(OtherEntity.class)`, `.rightJoin(Entity.class).on(OtherEntity.class)` -**Auto-join types follow FK nullability.** A `@FK` record component without a non-null marker (`jakarta.annotation.Nonnull` or JSpecify `@NonNull`) counts as nullable, so its auto-join is a LEFT JOIN. If generated SQL shows LEFT JOIN where you expect INNER JOIN, the FK component is missing the marker in the entity. +**Auto-join types follow FK nullability.** A `@FK` record component is non-null by default, so its auto-join is an INNER JOIN. Mark the component `@Nullable` (JSpecify `org.jspecify.annotations.Nullable` or `jakarta.annotation.Nullable`) when the FK column allows NULL; that produces a LEFT JOIN. If generated SQL shows INNER JOIN where you expect LEFT JOIN, the FK component is missing `@Nullable` in the entity. Result type: `.select(ResultType.class)` to return a different type than the root entity. **Cross-entity pitfall:** Selecting a different entity type from the wrong root repository can fail with "Cannot find alias for column" when both entities have columns with the same name (e.g., `id`). Put the query on the target entity's repository instead. Operators: EQUALS, NOT_EQUALS, LESS_THAN, LESS_THAN_OR_EQUAL, GREATER_THAN, GREATER_THAN_OR_EQUAL, LIKE, NOT_LIKE, IS_NULL, IS_NOT_NULL, IN, NOT_IN diff --git a/website/static/skills/storm-repository-java.md b/website/static/skills/storm-repository-java.md index 61b37aa78..9d38c5a0a 100644 --- a/website/static/skills/storm-repository-java.md +++ b/website/static/skills/storm-repository-java.md @@ -94,7 +94,7 @@ Key rules: 6. DELETE/UPDATE without WHERE throws. Use `unsafe()` for intentional bulk ops. 7. Pagination: `page(0, 20)` for offset-based. `scroll(scrollable)` for keyset on large tables. 8. **Prefer entity/metamodel-based methods over templates.** Use `.innerJoin(Entity.class).on(OtherEntity.class)` for joins unless it cannot be expressed with entity classes. Only fall back to template lambdas when QueryBuilder cannot express the query. - **Auto-join types follow FK nullability.** A `@FK` record component without a non-null marker (`jakarta.annotation.Nonnull` or JSpecify `@NonNull`) counts as nullable, so its auto-join is a LEFT JOIN. If generated SQL shows LEFT JOIN where you expect INNER JOIN, the FK component is missing the marker in the entity. + **Auto-join types follow FK nullability.** A `@FK` record component is non-null by default, so its auto-join is an INNER JOIN. Mark the component `@Nullable` (JSpecify `org.jspecify.annotations.Nullable` or `jakarta.annotation.Nullable`) when the FK column allows NULL; that produces a LEFT JOIN. If generated SQL shows INNER JOIN where you expect LEFT JOIN, the FK component is missing `@Nullable` in the entity. **Template joins are a code smell.** If you need a template-based ON clause (`.innerJoin(T.class).on(RAW."...")`) or a full `orm.query(RAW."...")` to express a join that follows a database FK constraint, the entity model is missing an `@FK` annotation. Fix the entity first — add `@FK` (with `Ref` for PK fields, full entity for non-PK fields) — then the join becomes `.innerJoin(Entity.class).on(OtherEntity.class)`, pure code with no templates. Template joins are only justified when there is genuinely no FK constraint in the database. Projections join like entities: `.on(ProjectionType.class)` resolves the foreign key by matching the referenced entity's table against the projection's table. When multiple foreign keys reference that table the join is ambiguous — Storm fails with an error naming the candidate fields; disambiguate with a template ON clause. 9. **Use `Ref` for map keys and set membership**: Prefer `Ref` (via `.ref()`) for map keys, set membership, and identity-based lookups. `Ref` provides identity-based `equals`/`hashCode` on the primary key. 10. **Prefer typed parameters over raw IDs — full entities by default.** Repository method signatures take the full entity for FK parameters when callers naturally hold one (the common case): predicates accept entities directly, so no ref conversion is needed at the call sites. `Ref` parameters remain fine — use them for identity-only flows, where callers hold refs (e.g. from `Ref` fields) or only an id, converted at the system boundary using `Ref.of(Entity.class, id)`. Never accept raw IDs like `String` or `int` — they are untyped and lose the entity association. From e267394b984c89d57adbd2b08ed611c5dd9984da Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Tue, 14 Jul 2026 10:26:25 +0200 Subject: [PATCH 2/2] test: declare the nullable compound key fields explicitly 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. --- .../src/test/java/st/orm/core/model/NullableCompoundUK.java | 6 ++++-- .../src/test/java/st/orm/core/template/MetamodelTest.java | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/storm-core/src/test/java/st/orm/core/model/NullableCompoundUK.java b/storm-core/src/test/java/st/orm/core/model/NullableCompoundUK.java index 8a4cc8810..52033e879 100644 --- a/storm-core/src/test/java/st/orm/core/model/NullableCompoundUK.java +++ b/storm-core/src/test/java/st/orm/core/model/NullableCompoundUK.java @@ -1,10 +1,12 @@ package st.orm.core.model; +import jakarta.annotation.Nullable; + /** * An inline record with nullable constituent fields (String, not @Nonnull, not primitive). * Used to test that compound keys with nullable constituents are detected. */ public record NullableCompoundUK( - String userId, - String email + @Nullable String userId, + @Nullable String email ) {} diff --git a/storm-core/src/test/java/st/orm/core/template/MetamodelTest.java b/storm-core/src/test/java/st/orm/core/template/MetamodelTest.java index b53412979..9bb02ac35 100644 --- a/storm-core/src/test/java/st/orm/core/template/MetamodelTest.java +++ b/storm-core/src/test/java/st/orm/core/template/MetamodelTest.java @@ -252,7 +252,7 @@ public void testCompoundKeyWithPrimitiveFieldsIsNotNullable() { @Test public void testCompoundKeyWithNullableFieldsIsNullable() { // EntityWithNullableUK has @UK NullableCompoundUK(String userId, String email). - // Both String fields are nullable (not primitive, not @Nonnull), so isNullable() should be true. + // Both String fields are marked @Nullable, so isNullable() should be true. assertInstanceOf(Metamodel.Key.class, EntityWithNullableUK_.uniqueKey); Metamodel.Key key = EntityWithNullableUK_.uniqueKey; assertTrue(key.isNullable());