Skip to content

Commit 5918492

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 ca04045 commit 5918492

File tree

3 files changed

+98
-5
lines changed

3 files changed

+98
-5
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/util/NullnessMethodInvocationValidator.java

+6
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
import org.aopalliance.intercept.MethodInterceptor;
2626
import org.aopalliance.intercept.MethodInvocation;
27+
import org.jspecify.annotations.NullMarked;
2728

2829
import org.springframework.core.DefaultParameterNameDiscoverer;
2930
import org.springframework.core.KotlinDetector;
@@ -60,6 +61,11 @@ public class NullnessMethodInvocationValidator implements MethodInterceptor {
6061
*/
6162
public static boolean supports(Class<?> type) {
6263

64+
if (type.getPackage() != null
65+
&& type.getPackage().isAnnotationPresent(NullMarked.class)) {
66+
return true;
67+
}
68+
6369
return KotlinDetector.isKotlinPresent() && KotlinReflectionUtils.isSupportedKotlinClass(type)
6470
|| NullableUtils.isNonNull(type, ElementType.METHOD)
6571
|| NullableUtils.isNonNull(type, ElementType.PARAMETER);

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

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

3434
import org.aopalliance.intercept.MethodInvocation;
35+
import org.jspecify.annotations.NonNull;
3536
import org.junit.jupiter.api.BeforeEach;
3637
import org.junit.jupiter.api.Test;
3738
import org.junit.jupiter.api.extension.ExtendWith;
@@ -121,7 +122,7 @@ void invokesCustomQueryCreationListenerForSpecialRepositoryQueryOnly() {
121122
factory.getRepository(ObjectRepository.class);
122123

123124
verify(listener, times(1)).onCreation(any(MyRepositoryQuery.class));
124-
verify(otherListener, times(3)).onCreation(any(RepositoryQuery.class));
125+
verify(otherListener, times(4)).onCreation(any(RepositoryQuery.class));
125126
}
126127

127128
@Test // DATACMNS-1538
@@ -253,7 +254,8 @@ void capturesFailureFromInvocation() {
253254
@Test // GH-3090
254255
void capturesRepositoryMetadata() {
255256

256-
record Metadata(RepositoryMethodContext context, MethodInvocation methodInvocation) {}
257+
record Metadata(RepositoryMethodContext context, MethodInvocation methodInvocation) {
258+
}
257259

258260
when(factory.queryOne.execute(any(Object[].class)))
259261
.then(invocation -> new Metadata(RepositoryMethodContextHolder.getContext(),
@@ -430,6 +432,20 @@ void considersRequiredParameter() {
430432
() -> repository.findByClass(null)) //
431433
.isInstanceOf(IllegalArgumentException.class) //
432434
.hasMessageContaining("must not be null");
435+
436+
}
437+
438+
@Test // GH-3100
439+
void considersRequiredParameterThroughJspecify() {
440+
441+
var repository = factory.getRepository(ObjectRepository.class);
442+
443+
assertThatNoException().isThrownBy(() -> repository.findByFoo(null));
444+
445+
assertThatThrownBy( //
446+
() -> repository.findByNonNullFoo(null)) //
447+
.isInstanceOf(IllegalArgumentException.class) //
448+
.hasMessageContaining("must not be null");
433449
}
434450

435451
@Test // DATACMNS-1154
@@ -565,8 +581,10 @@ interface ObjectRepository extends Repository<Object, Object>, ObjectRepositoryC
565581
@Nullable
566582
Object findByClass(Class<?> clazz);
567583

568-
@Nullable
569-
Object findByFoo();
584+
@org.jspecify.annotations.Nullable
585+
Object findByFoo(@org.jspecify.annotations.Nullable Object foo);
586+
587+
Object findByNonNullFoo(@NonNull Object foo);
570588

571589
@Nullable
572590
Object save(Object entity);

0 commit comments

Comments
 (0)