Skip to content

Commit 9cdf0ce

Browse files
committed
Use Spring's Nullness utility to determine JSpecify nullness.
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
1 parent 1541ad7 commit 9cdf0ce

File tree

3 files changed

+107
-6
lines changed

3 files changed

+107
-6
lines changed

Diff for: src/main/antora/modules/ROOT/pages/repositories/null-handling.adoc

+70-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[[repositories.nullability]]
22
= Null Handling of Repository Methods
33

4-
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.
4+
Repository CRUD methods that return an individual aggregate instances can use `Optional` to indicate the potential absence of a value.
55
Besides that, Spring Data supports returning the following wrapper types on query methods:
66

77
* `com.google.common.base.Optional`
@@ -16,7 +16,74 @@ See "`xref:repositories/query-return-types-reference.adoc[Repository query retur
1616
[[repositories.nullability.annotations]]
1717
== Nullability Annotations
1818

19+
=== JSpecify
20+
21+
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].
22+
JSpecify is well integrated into IntelliJ and Eclipse to provide a tooling-friendly approach and opt-in `null` checks during runtime, as follows:
23+
24+
* 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.
25+
* 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).
26+
* 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`.
27+
* 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`.
28+
Nullness changes to unspecified in such a case.
29+
30+
.`@NullMarked` at the package level via a `package-info.java` file
31+
[source,java,subs="verbatim,quotes",chomp="-packages",fold="none"]
32+
----
33+
@NullMarked
34+
package org.springframework.core;
35+
36+
import org.jspecify.annotations.NullMarked;
37+
----
38+
39+
In the various Java files belonging to the package, nullable type usages are defined explicitly with
40+
https://jspecify.dev/docs/api/org/jspecify/annotations/Nullable.html[`@Nullable`].
41+
It is recommended that this annotation is specified just before the related type.
42+
43+
For example, for a field:
44+
45+
[source,java,subs="verbatim,quotes"]
46+
----
47+
private @Nullable String fileEncoding;
48+
----
49+
50+
Or for method parameters and return value:
51+
52+
[source,java,subs="verbatim,quotes"]
53+
----
54+
public static @Nullable String buildMessage(@Nullable String message,
55+
@Nullable Throwable cause) {
56+
// ...
57+
}
58+
----
59+
60+
When overriding a method, nullness annotations are not inherited from the superclass method.
61+
That means those nullness annotations should be repeated if you just want to override the implementation and keep the same API nullness.
62+
63+
With arrays and varargs, you need to be able to differentiate the nullness of the elements from the nullness of the array itself.
64+
Pay attention to the syntax
65+
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:
66+
67+
- `@Nullable Object[] array` means individual elements can be null but the array itself can't.
68+
- `Object @Nullable [] array` means individual elements can't be null but the array itself can.
69+
- `@Nullable Object @Nullable [] array` means both individual elements and the array can be null.
70+
71+
The Java specifications also enforces that annotations defined with `@Target(ElementType.TYPE_USE)` like JSpecify
72+
`@Nullable` should be specified after the last `.` with inner or fully qualified types:
73+
74+
- `Cache.@Nullable ValueWrapper`
75+
- `jakarta.validation.@Nullable Validator`
76+
77+
https://jspecify.dev/docs/api/org/jspecify/annotations/NonNull.html[`@NonNull`] and
78+
https://jspecify.dev/docs/api/org/jspecify/annotations/NullUnmarked.html[`@NullUnmarked`] should rarely be needed for typical use cases.
79+
80+
=== Spring Framework Nullability and JSR-305 Annotations
81+
1982
You can express nullability constraints for repository methods by using {spring-framework-docs}/core/null-safety.html[Spring Framework's nullability annotations].
83+
84+
NOTE: As on Spring Framework 7, Spring's nullability annotations are deprecated in favor of JSpecify.
85+
Consult the framework documentation on {spring-framework-docs}/core/null-safety.html[Migrating from Spring null-safety annotations to JSpecify] for more information.
86+
2087
They provide a tooling-friendly approach and opt-in `null` checks during runtime, as follows:
2188

2289
* {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<User, Long> {
59126
Optional<User> findOptionalByEmailAddress(EmailAddress emailAddress); <4>
60127
}
61128
----
129+
62130
<1> The repository resides in a package (or sub-package) for which we have defined non-null behavior.
63131
<2> Throws an `EmptyResultDataAccessException` when the query does not produce a result.
64132
Throws an `IllegalArgumentException` when the `emailAddress` handed to the method is `null`.
@@ -85,6 +153,7 @@ interface UserRepository : Repository<User, String> {
85153
fun findByFirstname(firstname: String?): User? <2>
86154
}
87155
----
156+
88157
<1> The method defines both the parameter and the result as non-nullable (the Kotlin default).
89158
The Kotlin compiler rejects method invocations that pass `null` to the method.
90159
If the query yields an empty result, an `EmptyResultDataAccessException` is thrown.

Diff for: src/main/java/org/springframework/data/repository/core/support/MethodInvocationValidator.java

+15-1
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@
2323

2424
import org.aopalliance.intercept.MethodInterceptor;
2525
import org.aopalliance.intercept.MethodInvocation;
26+
import org.jspecify.annotations.NullMarked;
2627

2728
import org.springframework.core.DefaultParameterNameDiscoverer;
2829
import org.springframework.core.KotlinDetector;
2930
import org.springframework.core.MethodParameter;
31+
import org.springframework.core.Nullness;
3032
import org.springframework.core.ParameterNameDiscoverer;
3133
import org.springframework.dao.EmptyResultDataAccessException;
3234
import org.springframework.data.util.KotlinReflectionUtils;
@@ -47,6 +49,7 @@
4749
* @see ReflectionUtils#isNullable(MethodParameter)
4850
* @see NullableUtils
4951
*/
52+
@SuppressWarnings("deprecation")
5053
public class MethodInvocationValidator implements MethodInterceptor {
5154

5255
private final ParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
@@ -60,6 +63,11 @@ public class MethodInvocationValidator implements MethodInterceptor {
6063
*/
6164
public static boolean supports(Class<?> repositoryInterface) {
6265

66+
if (repositoryInterface.getPackage() != null
67+
&& repositoryInterface.getPackage().isAnnotationPresent(NullMarked.class)) {
68+
return true;
69+
}
70+
6371
return KotlinDetector.isKotlinPresent() && KotlinReflectionUtils.isSupportedKotlinClass(repositoryInterface)
6472
|| NullableUtils.isNonNull(repositoryInterface, ElementType.METHOD)
6573
|| NullableUtils.isNonNull(repositoryInterface, ElementType.PARAMETER);
@@ -153,7 +161,13 @@ boolean isNullableParameter(int index) {
153161

154162
private static boolean isNullableParameter(MethodParameter parameter) {
155163

156-
return requiresNoValue(parameter) || NullableUtils.isExplicitNullable(parameter)
164+
Nullness nullness = Nullness.forMethodParameter(parameter);
165+
166+
if (nullness == Nullness.NON_NULL) {
167+
return false;
168+
}
169+
170+
return nullness == Nullness.NULLABLE || requiresNoValue(parameter) || NullableUtils.isExplicitNullable(parameter)
157171
|| (KotlinReflectionUtils.isSupportedKotlinClass(parameter.getDeclaringClass())
158172
&& ReflectionUtils.isNullable(parameter));
159173
}

Diff for: src/test/java/org/springframework/data/repository/core/support/RepositoryFactorySupportUnitTests.java

+22-4
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import java.util.concurrent.TimeUnit;
3232

3333
import org.aopalliance.intercept.MethodInvocation;
34+
import org.jspecify.annotations.NonNull;
3435
import org.junit.jupiter.api.BeforeEach;
3536
import org.junit.jupiter.api.Test;
3637
import org.junit.jupiter.api.extension.ExtendWith;
@@ -120,7 +121,7 @@ void invokesCustomQueryCreationListenerForSpecialRepositoryQueryOnly() {
120121
factory.getRepository(ObjectRepository.class);
121122

122123
verify(listener, times(1)).onCreation(any(MyRepositoryQuery.class));
123-
verify(otherListener, times(3)).onCreation(any(RepositoryQuery.class));
124+
verify(otherListener, times(4)).onCreation(any(RepositoryQuery.class));
124125
}
125126

126127
@Test // DATACMNS-1538
@@ -252,7 +253,8 @@ void capturesFailureFromInvocation() {
252253
@Test // GH-3090
253254
void capturesRepositoryMetadata() {
254255

255-
record Metadata(RepositoryMethodContext context, MethodInvocation methodInvocation) {}
256+
record Metadata(RepositoryMethodContext context, MethodInvocation methodInvocation) {
257+
}
256258

257259
when(factory.queryOne.execute(any(Object[].class)))
258260
.then(invocation -> new Metadata(RepositoryMethodContextHolder.getContext(),
@@ -409,6 +411,20 @@ void considersRequiredParameter() {
409411
() -> repository.findByClass(null)) //
410412
.isInstanceOf(IllegalArgumentException.class) //
411413
.hasMessageContaining("must not be null");
414+
415+
}
416+
417+
@Test // GH-3100
418+
void considersRequiredParameterThroughJspecify() {
419+
420+
var repository = factory.getRepository(ObjectRepository.class);
421+
422+
assertThatNoException().isThrownBy(() -> repository.findByFoo(null));
423+
424+
assertThatThrownBy( //
425+
() -> repository.findByNonNullFoo(null)) //
426+
.isInstanceOf(IllegalArgumentException.class) //
427+
.hasMessageContaining("must not be null");
412428
}
413429

414430
@Test // DATACMNS-1154
@@ -540,8 +556,10 @@ interface ObjectRepository extends Repository<Object, Object>, ObjectRepositoryC
540556
@Nullable
541557
Object findByClass(Class<?> clazz);
542558

543-
@Nullable
544-
Object findByFoo();
559+
@org.jspecify.annotations.Nullable
560+
Object findByFoo(@org.jspecify.annotations.Nullable Object foo);
561+
562+
Object findByNonNullFoo(@NonNull Object foo);
545563

546564
@Nullable
547565
Object save(Object entity);

0 commit comments

Comments
 (0)