diff --git a/src/main/antora/modules/ROOT/pages/repositories/null-handling.adoc b/src/main/antora/modules/ROOT/pages/repositories/null-handling.adoc index b8ccd5c83..0951dd964 100644 --- a/src/main/antora/modules/ROOT/pages/repositories/null-handling.adoc +++ b/src/main/antora/modules/ROOT/pages/repositories/null-handling.adoc @@ -1,7 +1,7 @@ [[repositories.nullability]] = Null Handling of Repository Methods -As of Spring Data 2.0, repository CRUD methods that return an individual aggregate instance use Java 8's `Optional` to indicate the potential absence of a value. +Repository CRUD methods that return an individual aggregate instances can use `Optional` to indicate the potential absence of a value. Besides that, Spring Data supports returning the following wrapper types on query methods: * `com.google.common.base.Optional` @@ -16,7 +16,74 @@ See "`xref:repositories/query-return-types-reference.adoc[Repository query retur [[repositories.nullability.annotations]] == Nullability Annotations +=== JSpecify + +As on Spring Framework 7 and Spring Data 4, you can express nullability constraints for repository methods by using https://jspecify.dev/docs/start-here/[JSpecify]. +JSpecify is well integrated into IntelliJ and Eclipse to provide a tooling-friendly approach and opt-in `null` checks during runtime, as follows: + +* https://jspecify.dev/docs/api/org/jspecify/annotations/NullMarked.html[`@NullMarked`]: Used on the module-, package- and class-level to declare that the default behavior for parameters and return values is, respectively, neither to accept nor to produce `null` values. +* https://jspecify.dev/docs/api/org/jspecify/annotations/NonNull.html[`@NonNull`]: Used on a type level for parameter or return values that must not be `null` (not needed value where `@NullMarked` applies). +* https://jspecify.dev/docs/api/org/jspecify/annotations/Nullable.html[`@Nullable`]: Used on the type level for parameter or return values that can be `null`. +* https://jspecify.dev/docs/api/org/jspecify/annotations/NullUnmarked.html[`@NullUnmarked`]: Used on the package-, class-, and method-level to roll back nullness declaration and opt-out from a previous `@NullMarked`. +Nullness changes to unspecified in such a case. + +.`@NullMarked` at the package level via a `package-info.java` file +[source,java,subs="verbatim,quotes",chomp="-packages",fold="none"] +---- +@NullMarked +package org.springframework.core; + +import org.jspecify.annotations.NullMarked; +---- + +In the various Java files belonging to the package, nullable type usages are defined explicitly with +https://jspecify.dev/docs/api/org/jspecify/annotations/Nullable.html[`@Nullable`]. +It is recommended that this annotation is specified just before the related type. + +For example, for a field: + +[source,java,subs="verbatim,quotes"] +---- +private @Nullable String fileEncoding; +---- + +Or for method parameters and return value: + +[source,java,subs="verbatim,quotes"] +---- +public static @Nullable String buildMessage(@Nullable String message, + @Nullable Throwable cause) { + // ... +} +---- + +When overriding a method, nullness annotations are not inherited from the superclass method. +That means those nullness annotations should be repeated if you just want to override the implementation and keep the same API nullness. + +With arrays and varargs, you need to be able to differentiate the nullness of the elements from the nullness of the array itself. +Pay attention to the syntax +https://docs.oracle.com/javase/specs/jls/se17/html/jls-9.html#jls-9.7.4[defined by the Java specification] which may be initially surprising: + +- `@Nullable Object[] array` means individual elements can be null but the array itself can't. +- `Object @Nullable [] array` means individual elements can't be null but the array itself can. +- `@Nullable Object @Nullable [] array` means both individual elements and the array can be null. + +The Java specifications also enforces that annotations defined with `@Target(ElementType.TYPE_USE)` like JSpecify +`@Nullable` should be specified after the last `.` with inner or fully qualified types: + +- `Cache.@Nullable ValueWrapper` +- `jakarta.validation.@Nullable Validator` + +https://jspecify.dev/docs/api/org/jspecify/annotations/NonNull.html[`@NonNull`] and +https://jspecify.dev/docs/api/org/jspecify/annotations/NullUnmarked.html[`@NullUnmarked`] should rarely be needed for typical use cases. + +=== Spring Framework Nullability and JSR-305 Annotations + You can express nullability constraints for repository methods by using {spring-framework-docs}/core/null-safety.html[Spring Framework's nullability annotations]. + +NOTE: As on Spring Framework 7, Spring's nullability annotations are deprecated in favor of JSpecify. +Consult the framework documentation on {spring-framework-docs}/core/null-safety.html[Migrating from Spring null-safety annotations to JSpecify] for more information. + They provide a tooling-friendly approach and opt-in `null` checks during runtime, as follows: * {spring-framework-javadoc}/org/springframework/lang/NonNullApi.html[`@NonNullApi`]: Used on the package level to declare that the default behavior for parameters and return values is, respectively, neither to accept nor to produce `null` values. @@ -59,6 +126,7 @@ interface UserRepository extends Repository { Optional findOptionalByEmailAddress(EmailAddress emailAddress); <4> } ---- + <1> The repository resides in a package (or sub-package) for which we have defined non-null behavior. <2> Throws an `EmptyResultDataAccessException` when the query does not produce a result. Throws an `IllegalArgumentException` when the `emailAddress` handed to the method is `null`. @@ -85,6 +153,7 @@ interface UserRepository : Repository { fun findByFirstname(firstname: String?): User? <2> } ---- + <1> The method defines both the parameter and the result as non-nullable (the Kotlin default). The Kotlin compiler rejects method invocations that pass `null` to the method. If the query yields an empty result, an `EmptyResultDataAccessException` is thrown. diff --git a/src/main/java/org/springframework/data/util/NullnessMethodInvocationValidator.java b/src/main/java/org/springframework/data/util/NullnessMethodInvocationValidator.java index 67f17250d..0933f489c 100644 --- a/src/main/java/org/springframework/data/util/NullnessMethodInvocationValidator.java +++ b/src/main/java/org/springframework/data/util/NullnessMethodInvocationValidator.java @@ -24,6 +24,7 @@ import java.util.concurrent.ConcurrentHashMap; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; +import org.jspecify.annotations.NullMarked; import org.springframework.core.DefaultParameterNameDiscoverer; import org.springframework.core.KotlinDetector; @@ -60,6 +61,11 @@ public class NullnessMethodInvocationValidator implements MethodInterceptor { */ public static boolean supports(Class type) { + if (type.getPackage() != null + && type.getPackage().isAnnotationPresent(NullMarked.class)) { + return true; + } + return KotlinDetector.isKotlinPresent() && KotlinReflectionUtils.isSupportedKotlinClass(type) || NullableUtils.isNonNull(type, ElementType.METHOD) || NullableUtils.isNonNull(type, ElementType.PARAMETER); diff --git a/src/test/java/org/springframework/data/repository/core/support/RepositoryFactorySupportUnitTests.java b/src/test/java/org/springframework/data/repository/core/support/RepositoryFactorySupportUnitTests.java index 1733dd2f1..4ce3088b9 100755 --- a/src/test/java/org/springframework/data/repository/core/support/RepositoryFactorySupportUnitTests.java +++ b/src/test/java/org/springframework/data/repository/core/support/RepositoryFactorySupportUnitTests.java @@ -32,6 +32,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import org.aopalliance.intercept.MethodInvocation; +import org.jspecify.annotations.NonNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -121,7 +122,7 @@ class RepositoryFactorySupportUnitTests { factory.getRepository(ObjectRepository.class); verify(listener, times(1)).onCreation(any(MyRepositoryQuery.class)); - verify(otherListener, times(3)).onCreation(any(RepositoryQuery.class)); + verify(otherListener, times(4)).onCreation(any(RepositoryQuery.class)); } @Test // DATACMNS-1538 @@ -253,7 +254,8 @@ class RepositoryFactorySupportUnitTests { @Test // GH-3090 void capturesRepositoryMetadata() { - record Metadata(RepositoryMethodContext context, MethodInvocation methodInvocation) {} + record Metadata(RepositoryMethodContext context, MethodInvocation methodInvocation) { + } when(factory.queryOne.execute(any(Object[].class))) .then(invocation -> new Metadata(RepositoryMethodContextHolder.getContext(), @@ -298,7 +300,7 @@ class RepositoryFactorySupportUnitTests { var repository1 = factory.getRepository(ObjectAndQuerydslRepository.class, backingRepo); var repository2 = factory.getRepository(ObjectAndQuerydslRepository.class, backingRepo); - repository1.findByFoo(); + repository1.findByFoo(null); repository2.deleteAll(); for (int i = 0; i < 10; i++) { @@ -430,6 +432,20 @@ class RepositoryFactorySupportUnitTests { () -> repository.findByClass(null)) // .isInstanceOf(IllegalArgumentException.class) // .hasMessageContaining("must not be null"); + + } + + @Test // GH-3100 + void considersRequiredParameterThroughJspecify() { + + var repository = factory.getRepository(ObjectRepository.class); + + assertThatNoException().isThrownBy(() -> repository.findByFoo(null)); + + assertThatThrownBy( // + () -> repository.findByNonNullFoo(null)) // + .isInstanceOf(IllegalArgumentException.class) // + .hasMessageContaining("must not be null"); } @Test // DATACMNS-1154 @@ -565,8 +581,10 @@ class RepositoryFactorySupportUnitTests { @Nullable Object findByClass(Class clazz); - @Nullable - Object findByFoo(); + @org.jspecify.annotations.Nullable + Object findByFoo(@org.jspecify.annotations.Nullable Object foo); + + Object findByNonNullFoo(@NonNull Object foo); @Nullable Object save(Object entity);