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/repository/core/support/MethodInvocationValidator.java b/src/main/java/org/springframework/data/repository/core/support/MethodInvocationValidator.java index ef1b49b21..61ac758ce 100644 --- a/src/main/java/org/springframework/data/repository/core/support/MethodInvocationValidator.java +++ b/src/main/java/org/springframework/data/repository/core/support/MethodInvocationValidator.java @@ -23,10 +23,12 @@ 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; import org.springframework.core.MethodParameter; +import org.springframework.core.Nullness; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.data.util.KotlinReflectionUtils; @@ -47,6 +49,7 @@ import org.springframework.util.ObjectUtils; * @see ReflectionUtils#isNullable(MethodParameter) * @see NullableUtils */ +@SuppressWarnings("deprecation") public class MethodInvocationValidator implements MethodInterceptor { private final ParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer(); @@ -60,6 +63,11 @@ public class MethodInvocationValidator implements MethodInterceptor { */ public static boolean supports(Class repositoryInterface) { + if (repositoryInterface.getPackage() != null + && repositoryInterface.getPackage().isAnnotationPresent(NullMarked.class)) { + return true; + } + return KotlinDetector.isKotlinPresent() && KotlinReflectionUtils.isSupportedKotlinClass(repositoryInterface) || NullableUtils.isNonNull(repositoryInterface, ElementType.METHOD) || NullableUtils.isNonNull(repositoryInterface, ElementType.PARAMETER); @@ -153,7 +161,13 @@ public class MethodInvocationValidator implements MethodInterceptor { private static boolean isNullableParameter(MethodParameter parameter) { - return requiresNoValue(parameter) || NullableUtils.isExplicitNullable(parameter) + Nullness nullness = Nullness.forMethodParameter(parameter); + + if (nullness == Nullness.NON_NULL) { + return false; + } + + return nullness == Nullness.NULLABLE || requiresNoValue(parameter) || NullableUtils.isExplicitNullable(parameter) || (KotlinReflectionUtils.isSupportedKotlinClass(parameter.getDeclaringClass()) && ReflectionUtils.isNullable(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 79ffb2d39..21a0766eb 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 @@ -31,6 +31,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; @@ -120,7 +121,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 @@ -252,7 +253,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(), @@ -409,6 +411,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 @@ -540,8 +556,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);