Skip to content

Commit

Permalink
Use Spring's Nullness utility to determine JSpecify nullness.
Browse files Browse the repository at this point in the history
We now use Nullness.forMethodParameter(…) to introspect method return types and argument types for nullness in addition to Spring's NonNullApi and JSR-305 annotations.

Closes #3100
  • Loading branch information
mp911de committed Jan 23, 2025
1 parent 1541ad7 commit 9cdf0ce
Show file tree
Hide file tree
Showing 3 changed files with 107 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -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.
Expand Down Expand Up @@ -59,6 +126,7 @@ interface UserRepository extends Repository<User, Long> {
Optional<User> 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`.
Expand All @@ -85,6 +153,7 @@ interface UserRepository : Repository<User, String> {
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@

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;
Expand All @@ -47,6 +49,7 @@
* @see ReflectionUtils#isNullable(MethodParameter)
* @see NullableUtils
*/
@SuppressWarnings("deprecation")
public class MethodInvocationValidator implements MethodInterceptor {

private final ParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
Expand All @@ -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);
Expand Down Expand Up @@ -153,7 +161,13 @@ boolean isNullableParameter(int index) {

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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
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;
Expand Down Expand Up @@ -120,7 +121,7 @@ void invokesCustomQueryCreationListenerForSpecialRepositoryQueryOnly() {
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
Expand Down Expand Up @@ -252,7 +253,8 @@ void capturesFailureFromInvocation() {
@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(),
Expand Down Expand Up @@ -409,6 +411,20 @@ void considersRequiredParameter() {
() -> 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
Expand Down Expand Up @@ -540,8 +556,10 @@ interface ObjectRepository extends Repository<Object, Object>, ObjectRepositoryC
@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);
Expand Down

0 comments on commit 9cdf0ce

Please sign in to comment.