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/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/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/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()); 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.