From 2894ff3f04d165846ee5d86d3e6ccfb7d0101bd2 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Mon, 24 Feb 2025 13:20:05 +0100 Subject: [PATCH] Move and reuse existing nullability validator. Original Pull Request: #3244 --- .../ProjectingMethodInterceptor.java | 11 - .../projection/ProxyProjectionFactory.java | 9 + .../support/MethodInvocationValidator.java | 190 +------------- .../support/RepositoryFactorySupport.java | 5 +- .../NullabilityMethodInvocationValidator.java | 236 ++++++++++++++++++ .../example/NoNullableMarkedInterface.java | 25 ++ .../ProjectingMethodInterceptorUnitTests.java | 25 -- .../ProjectionIntegrationTests.java | 3 +- .../ProxyProjectionFactoryUnitTests.java | 47 ++++ ...tingMethodInterceptorFactoryUnitTests.java | 3 +- .../springframework/data/projection/Person.kt | 2 +- 11 files changed, 335 insertions(+), 221 deletions(-) create mode 100644 src/main/java/org/springframework/data/util/NullabilityMethodInvocationValidator.java create mode 100644 src/test/java/example/NoNullableMarkedInterface.java diff --git a/src/main/java/org/springframework/data/projection/ProjectingMethodInterceptor.java b/src/main/java/org/springframework/data/projection/ProjectingMethodInterceptor.java index 662b42743..3169c0838 100644 --- a/src/main/java/org/springframework/data/projection/ProjectingMethodInterceptor.java +++ b/src/main/java/org/springframework/data/projection/ProjectingMethodInterceptor.java @@ -24,13 +24,10 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; -import kotlin.reflect.KFunction; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; import org.springframework.core.CollectionFactory; -import org.springframework.core.KotlinDetector; import org.springframework.core.convert.ConversionService; -import org.springframework.data.util.KotlinReflectionUtils; import org.springframework.data.util.NullableWrapper; import org.springframework.data.util.NullableWrapperConverters; import org.springframework.data.util.TypeInformation; @@ -90,14 +87,6 @@ class ProjectingMethodInterceptor implements MethodInterceptor { return conversionService.convert(new NullableWrapper(result), typeToReturn.getType()); } - if (result == null) { - KFunction function = KotlinDetector.isKotlinType(method.getDeclaringClass()) ? - KotlinReflectionUtils.findKotlinFunction(method) : null; - if (function != null && !function.getReturnType().isMarkedNullable()) { - throw new IllegalArgumentException("Kotlin function '%s' requires non-null return value".formatted(method.toString())); - } - } - return result; } diff --git a/src/main/java/org/springframework/data/projection/ProxyProjectionFactory.java b/src/main/java/org/springframework/data/projection/ProxyProjectionFactory.java index b52b2a9aa..36b0e77b1 100644 --- a/src/main/java/org/springframework/data/projection/ProxyProjectionFactory.java +++ b/src/main/java/org/springframework/data/projection/ProxyProjectionFactory.java @@ -31,6 +31,7 @@ import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.convert.support.GenericConversionService; import org.springframework.data.convert.Jsr310Converters; import org.springframework.data.util.Lazy; +import org.springframework.data.util.NullabilityMethodInvocationValidator; import org.springframework.data.util.NullableWrapperConverters; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -66,6 +67,9 @@ class ProxyProjectionFactory implements ProjectionFactory, BeanClassLoaderAware private final Lazy defaultMethodInvokingMethodInterceptor = Lazy .of(DefaultMethodInvokingMethodInterceptor::new); + private final Lazy nullabilityValidator = Lazy + .of(NullabilityMethodInvocationValidator::new); + /** * Creates a new {@link ProxyProjectionFactory}. */ @@ -119,6 +123,11 @@ class ProxyProjectionFactory implements ProjectionFactory, BeanClassLoaderAware } factory.addAdvice(new TargetAwareMethodInterceptor(source.getClass())); + + if(NullabilityMethodInvocationValidator.supports(projectionType)) { + factory.addAdvice(nullabilityValidator.get()); + } + factory.addAdvice(getMethodInterceptor(source, projectionType)); return (T) factory.getProxy(classLoader == null ? ClassUtils.getDefaultClassLoader() : classLoader); 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 0457e34d1..9241b6f2b 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 @@ -15,26 +15,8 @@ */ package org.springframework.data.repository.core.support; -import java.lang.annotation.ElementType; -import java.lang.reflect.Method; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -import org.aopalliance.intercept.MethodInterceptor; -import org.aopalliance.intercept.MethodInvocation; -import org.springframework.core.DefaultParameterNameDiscoverer; -import org.springframework.core.KotlinDetector; -import org.springframework.core.MethodParameter; -import org.springframework.core.ParameterNameDiscoverer; import org.springframework.dao.EmptyResultDataAccessException; -import org.springframework.data.util.KotlinReflectionUtils; -import org.springframework.data.util.NullableUtils; -import org.springframework.data.util.ReflectionUtils; -import org.springframework.lang.Nullable; -import org.springframework.util.ClassUtils; -import org.springframework.util.ConcurrentReferenceHashMap; -import org.springframework.util.ConcurrentReferenceHashMap.ReferenceType; -import org.springframework.util.ObjectUtils; +import org.springframework.data.util.NullabilityMethodInvocationValidator; /** * Interceptor enforcing required return value and method parameter constraints declared on repository query methods. @@ -42,169 +24,17 @@ import org.springframework.util.ObjectUtils; * * @author Mark Paluch * @author Johannes Englmeier + * @author Christoph Strobl * @since 2.0 * @see org.springframework.lang.NonNull - * @see ReflectionUtils#isNullable(MethodParameter) - * @see NullableUtils + * @see org.springframework.data.util.ReflectionUtils#isNullable(org.springframework.core.MethodParameter) + * @see org.springframework.data.util.NullableUtils + * @deprecated use {@link NullabilityMethodInvocationValidator} instead. */ -public class MethodInvocationValidator implements MethodInterceptor { - - private final ParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer(); - private final Map nullabilityCache = new ConcurrentHashMap<>(16); - - /** - * Returns {@literal true} if the {@code repositoryInterface} is supported by this interceptor. - * - * @param repositoryInterface the interface class. - * @return {@literal true} if the {@code repositoryInterface} is supported by this interceptor. - */ - public static boolean supports(Class repositoryInterface) { - - return KotlinDetector.isKotlinPresent() && KotlinReflectionUtils.isSupportedKotlinClass(repositoryInterface) - || NullableUtils.isNonNull(repositoryInterface, ElementType.METHOD) - || NullableUtils.isNonNull(repositoryInterface, ElementType.PARAMETER); - } - - @Nullable - @Override - public Object invoke(@SuppressWarnings("null") MethodInvocation invocation) throws Throwable { - - Method method = invocation.getMethod(); - Nullability nullability = nullabilityCache.get(method); - - if (nullability == null) { - - nullability = Nullability.of(method, discoverer); - nullabilityCache.put(method, nullability); - } - - Object[] arguments = invocation.getArguments(); - - for (int i = 0; i < method.getParameterCount(); i++) { - - if (nullability.isNullableParameter(i)) { - continue; - } - - if ((arguments.length < i) || (arguments[i] == null)) { - throw new IllegalArgumentException( - String.format("Parameter %s in %s.%s must not be null", nullability.getMethodParameterName(i), - ClassUtils.getShortName(method.getDeclaringClass()), method.getName())); - } - } - - Object result = invocation.proceed(); - - if ((result == null) && !nullability.isNullableReturn()) { - throw new EmptyResultDataAccessException("Result must not be null", 1); - } - - return result; - } - - static final class Nullability { - - private final boolean nullableReturn; - private final boolean[] nullableParameters; - private final MethodParameter[] methodParameters; - - private Nullability(boolean nullableReturn, boolean[] nullableParameters, MethodParameter[] methodParameters) { - this.nullableReturn = nullableReturn; - this.nullableParameters = nullableParameters; - this.methodParameters = methodParameters; - } - - static Nullability of(Method method, ParameterNameDiscoverer discoverer) { - - boolean nullableReturn = isNullableParameter(new MethodParameter(method, -1)); - boolean[] nullableParameters = new boolean[method.getParameterCount()]; - MethodParameter[] methodParameters = new MethodParameter[method.getParameterCount()]; - - for (int i = 0; i < method.getParameterCount(); i++) { - - MethodParameter parameter = new MethodParameter(method, i); - parameter.initParameterNameDiscovery(discoverer); - nullableParameters[i] = isNullableParameter(parameter); - methodParameters[i] = parameter; - } - - return new Nullability(nullableReturn, nullableParameters, methodParameters); - } - - String getMethodParameterName(int index) { - - String parameterName = methodParameters[index].getParameterName(); - - if (parameterName == null) { - parameterName = String.format("of type %s at index %d", - ClassUtils.getShortName(methodParameters[index].getParameterType()), index); - } - - return parameterName; - } - - boolean isNullableReturn() { - return nullableReturn; - } - - boolean isNullableParameter(int index) { - return nullableParameters[index]; - } - - private static boolean isNullableParameter(MethodParameter parameter) { - - return requiresNoValue(parameter) || NullableUtils.isExplicitNullable(parameter) - || (KotlinReflectionUtils.isSupportedKotlinClass(parameter.getDeclaringClass()) - && ReflectionUtils.isNullable(parameter)); - } - - private static boolean requiresNoValue(MethodParameter parameter) { - return ReflectionUtils.isVoid(parameter.getParameterType()); - } - - public boolean[] getNullableParameters() { - return this.nullableParameters; - } - - public MethodParameter[] getMethodParameters() { - return this.methodParameters; - } - - @Override - public boolean equals(@Nullable Object o) { - - if (this == o) { - return true; - } - - if (!(o instanceof Nullability that)) { - return false; - } - - if (nullableReturn != that.nullableReturn) { - return false; - } - - if (!ObjectUtils.nullSafeEquals(nullableParameters, that.nullableParameters)) { - return false; - } - - return ObjectUtils.nullSafeEquals(methodParameters, that.methodParameters); - } - - @Override - public int hashCode() { - int result = (nullableReturn ? 1 : 0); - result = (31 * result) + ObjectUtils.nullSafeHashCode(nullableParameters); - result = (31 * result) + ObjectUtils.nullSafeHashCode(methodParameters); - return result; - } +@Deprecated // TODO: do we want to remove this with next major +public class MethodInvocationValidator extends NullabilityMethodInvocationValidator { - @Override - public String toString() { - return "MethodInvocationValidator.Nullability(nullableReturn=" + this.isNullableReturn() + ", nullableParameters=" - + java.util.Arrays.toString(this.getNullableParameters()) + ", methodParameters=" - + java.util.Arrays.deepToString(this.getMethodParameters()) + ")"; - } - } + public MethodInvocationValidator() { + super((invocation) -> new EmptyResultDataAccessException("Result must not be null", 1)); + } } diff --git a/src/main/java/org/springframework/data/repository/core/support/RepositoryFactorySupport.java b/src/main/java/org/springframework/data/repository/core/support/RepositoryFactorySupport.java index c1e158454..b60b53304 100644 --- a/src/main/java/org/springframework/data/repository/core/support/RepositoryFactorySupport.java +++ b/src/main/java/org/springframework/data/repository/core/support/RepositoryFactorySupport.java @@ -73,6 +73,7 @@ import org.springframework.data.repository.query.ValueExpressionDelegate; import org.springframework.data.repository.util.QueryExecutionConverters; import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.data.util.Lazy; +import org.springframework.data.util.NullabilityMethodInvocationValidator; import org.springframework.data.util.ReflectionUtils; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; @@ -334,7 +335,7 @@ public abstract class RepositoryFactorySupport * @return the implemented repository interface. * @since 2.0 */ - @SuppressWarnings({ "unchecked" }) + @SuppressWarnings({ "unchecked", "deprecation" }) public T getRepository(Class repositoryInterface, RepositoryFragments fragments) { if (logger.isDebugEnabled()) { @@ -399,7 +400,7 @@ public abstract class RepositoryFactorySupport result.setTarget(target); result.setInterfaces(repositoryInterface, Repository.class, TransactionalProxy.class); - if (MethodInvocationValidator.supports(repositoryInterface)) { + if (NullabilityMethodInvocationValidator.supports(repositoryInterface)) { if (logger.isTraceEnabled()) { logger.trace(LogMessage.format("Register MethodInvocationValidator for %s…", repositoryInterface.getName())); } diff --git a/src/main/java/org/springframework/data/util/NullabilityMethodInvocationValidator.java b/src/main/java/org/springframework/data/util/NullabilityMethodInvocationValidator.java new file mode 100644 index 000000000..8aef5ce85 --- /dev/null +++ b/src/main/java/org/springframework/data/util/NullabilityMethodInvocationValidator.java @@ -0,0 +1,236 @@ +/* + * Copyright 2017-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.util; + +import java.lang.annotation.ElementType; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +import kotlin.reflect.KFunction; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.KotlinDetector; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.lang.Nullable; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; + +/** + * Interceptor enforcing required return value and method parameter constraints declared on repository query methods. + * Supports Kotlin nullability markers and JSR-305 Non-null annotations. + * Originally implemented via {@link org.springframework.data.repository.core.support.MethodInvocationValidator}. + * + * @author Mark Paluch + * @author Johannes Englmeier + * @author Christoph Strobl + * @since 3.5 + * @see org.springframework.lang.NonNull + * @see ReflectionUtils#isNullable(MethodParameter) + * @see NullableUtils + */ +public class NullabilityMethodInvocationValidator implements MethodInterceptor { + + private final ParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer(); + private final Map nullabilityCache = new ConcurrentHashMap<>(16); + private final Function errorFunction; + + public NullabilityMethodInvocationValidator() { + this((invocation) -> new NullPointerException("Method marked non nullable used with null value. If this is by design consider providing additional metadata using @Nullable annotations.")); + } + + /** + * @param errorFunction custom function creating the error in case of failure. + */ + protected NullabilityMethodInvocationValidator(Function errorFunction) { + this.errorFunction = errorFunction; + } + + /** + * Returns {@literal true} if the {@code type} is supported by this interceptor. + * + * @param type the interface class. + * @return {@literal true} if the {@code type} is supported by this interceptor. + */ + public static boolean supports(Class type) { + + return KotlinDetector.isKotlinPresent() && KotlinReflectionUtils.isSupportedKotlinClass(type) + || NullableUtils.isNonNull(type, ElementType.METHOD) + || NullableUtils.isNonNull(type, ElementType.PARAMETER); + } + + @Nullable + @Override + public Object invoke(@SuppressWarnings("null") MethodInvocation invocation) throws Throwable { + + Method method = invocation.getMethod(); + Nullability nullability = nullabilityCache.get(method); + + if (nullability == null) { + + nullability = Nullability.of(method, discoverer); + nullabilityCache.put(method, nullability); + } + + Object[] arguments = invocation.getArguments(); + + for (int i = 0; i < method.getParameterCount(); i++) { + + if (nullability.isNullableParameter(i)) { + continue; + } + + if ((arguments.length < i) || (arguments[i] == null)) { + throw new IllegalArgumentException( + String.format("Parameter %s in %s.%s must not be null", nullability.getMethodParameterName(i), + ClassUtils.getShortName(method.getDeclaringClass()), method.getName())); + } + } + + Object result = invocation.proceed(); + + if ((result == null) && !nullability.isNullableReturn()) { + throw errorFunction.apply(invocation); + } + + return result; + } + + static final class Nullability { + + private final boolean nullableReturn; + private final boolean[] nullableParameters; + private final MethodParameter[] methodParameters; + + private Nullability(boolean nullableReturn, boolean[] nullableParameters, MethodParameter[] methodParameters) { + this.nullableReturn = nullableReturn; + this.nullableParameters = nullableParameters; + this.methodParameters = methodParameters; + } + + static Nullability of(Method method, ParameterNameDiscoverer discoverer) { + + boolean nullableReturn = isNullableParameter(new MethodParameter(method, -1)); + boolean[] nullableParameters = new boolean[method.getParameterCount()]; + MethodParameter[] methodParameters = new MethodParameter[method.getParameterCount()]; + + for (int i = 0; i < method.getParameterCount(); i++) { + + MethodParameter parameter = new MethodParameter(method, i); + parameter.initParameterNameDiscovery(discoverer); + nullableParameters[i] = isNullableParameter(parameter); + methodParameters[i] = parameter; + } + + return new Nullability(nullableReturn, nullableParameters, methodParameters); + } + + String getMethodParameterName(int index) { + + String parameterName = methodParameters[index].getParameterName(); + + if (parameterName == null) { + parameterName = String.format("of type %s at index %d", + ClassUtils.getShortName(methodParameters[index].getParameterType()), index); + } + + return parameterName; + } + + boolean isNullableReturn() { + return nullableReturn; + } + + boolean isNullableParameter(int index) { + return nullableParameters[index]; + } + + private static boolean isNullableParameter(MethodParameter parameter) { + + return requiresNoValue(parameter) || NullableUtils.isExplicitNullable(parameter) + || (KotlinReflectionUtils.isSupportedKotlinClass(parameter.getDeclaringClass()) + && (ReflectionUtils.isNullable(parameter) || allowNullableReturn(parameter.getMethod()))); + } + + /** + * Check method return nullability + * @param method + * @return + */ + private static boolean allowNullableReturn(@Nullable Method method) { + + if(method == null) { + return false; + } + + KFunction function = KotlinDetector.isKotlinType(method.getDeclaringClass()) ? + KotlinReflectionUtils.findKotlinFunction(method) : null; + return function != null && function.getReturnType().isMarkedNullable(); + } + + private static boolean requiresNoValue(MethodParameter parameter) { + return ReflectionUtils.isVoid(parameter.getParameterType()); + } + + public boolean[] getNullableParameters() { + return this.nullableParameters; + } + + public MethodParameter[] getMethodParameters() { + return this.methodParameters; + } + + @Override + public boolean equals(@Nullable Object o) { + + if (this == o) { + return true; + } + + if (!(o instanceof Nullability that)) { + return false; + } + + if (nullableReturn != that.nullableReturn) { + return false; + } + + if (!ObjectUtils.nullSafeEquals(nullableParameters, that.nullableParameters)) { + return false; + } + + return ObjectUtils.nullSafeEquals(methodParameters, that.methodParameters); + } + + @Override + public int hashCode() { + int result = (nullableReturn ? 1 : 0); + result = (31 * result) + ObjectUtils.nullSafeHashCode(nullableParameters); + result = (31 * result) + ObjectUtils.nullSafeHashCode(methodParameters); + return result; + } + + @Override + public String toString() { + return "MethodInvocationValidator.Nullability(nullableReturn=" + this.isNullableReturn() + ", nullableParameters=" + + java.util.Arrays.toString(this.getNullableParameters()) + ", methodParameters=" + + java.util.Arrays.deepToString(this.getMethodParameters()) + ")"; + } + } +} diff --git a/src/test/java/example/NoNullableMarkedInterface.java b/src/test/java/example/NoNullableMarkedInterface.java new file mode 100644 index 000000000..068456d48 --- /dev/null +++ b/src/test/java/example/NoNullableMarkedInterface.java @@ -0,0 +1,25 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example; + +/** + * @author Christoph Strobl + */ +public interface NoNullableMarkedInterface { + + String getFirstname(); + String getLastname(); +} diff --git a/src/test/java/org/springframework/data/projection/ProjectingMethodInterceptorUnitTests.java b/src/test/java/org/springframework/data/projection/ProjectingMethodInterceptorUnitTests.java index e9e533603..9e242a7a0 100755 --- a/src/test/java/org/springframework/data/projection/ProjectingMethodInterceptorUnitTests.java +++ b/src/test/java/org/springframework/data/projection/ProjectingMethodInterceptorUnitTests.java @@ -43,7 +43,6 @@ import org.springframework.core.convert.support.DefaultConversionService; * @author Saulo Medeiros de Araujo * @author Mark Paluch * @author Christoph Strobl - * @author Yanming Zhou */ @ExtendWith(MockitoExtension.class) class ProjectingMethodInterceptorUnitTests { @@ -205,30 +204,6 @@ class ProjectingMethodInterceptorUnitTests { assertThat(collection).containsOnly(HelperEnum.Helpful); } - @Test - void throwExceptionIfKotlinProjectionRequiresNonNullWithNullResult() throws Throwable { - - MethodInterceptor methodInterceptor = new ProjectingMethodInterceptor(new ProxyProjectionFactory(), interceptor, - conversionService); - - when(invocation.getMethod()).thenReturn(Person.class.getMethod("getName")); - when(interceptor.invoke(invocation)).thenReturn(null); - - assertThatIllegalArgumentException().isThrownBy(() -> methodInterceptor.invoke(invocation)); - } - - @Test - void returnsNullIfKotlinProjectionDoesNotRequiresNonNullWithNullResult() throws Throwable { - - MethodInterceptor methodInterceptor = new ProjectingMethodInterceptor(new ProxyProjectionFactory(), interceptor, - conversionService); - - when(invocation.getMethod()).thenReturn(Person.class.getMethod("getAge")); - when(interceptor.invoke(invocation)).thenReturn(null); - - assertThat(methodInterceptor.invoke(invocation)).isNull(); - } - /** * Mocks the {@link Helper} method of the given name to return the given value. * diff --git a/src/test/java/org/springframework/data/projection/ProjectionIntegrationTests.java b/src/test/java/org/springframework/data/projection/ProjectionIntegrationTests.java index 791b189b5..5ffa62b5e 100755 --- a/src/test/java/org/springframework/data/projection/ProjectionIntegrationTests.java +++ b/src/test/java/org/springframework/data/projection/ProjectionIntegrationTests.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.jayway.jsonpath.Configuration.ConfigurationBuilder; import com.jayway.jsonpath.JsonPath; import com.jayway.jsonpath.Option; +import org.springframework.lang.Nullable; /** * Integration tests for projections. @@ -44,6 +45,6 @@ class ProjectionIntegrationTests { } interface SampleProjection { - String getName(); + @Nullable String getName(); } } diff --git a/src/test/java/org/springframework/data/projection/ProxyProjectionFactoryUnitTests.java b/src/test/java/org/springframework/data/projection/ProxyProjectionFactoryUnitTests.java index 0d8a17d58..106395155 100755 --- a/src/test/java/org/springframework/data/projection/ProxyProjectionFactoryUnitTests.java +++ b/src/test/java/org/springframework/data/projection/ProxyProjectionFactoryUnitTests.java @@ -17,6 +17,8 @@ package org.springframework.data.projection; import static org.assertj.core.api.Assertions.*; +import example.NoNullableMarkedInterface; + import java.lang.reflect.Proxy; import java.time.LocalDateTime; import java.util.Calendar; @@ -32,6 +34,7 @@ import org.springframework.aop.Advisor; import org.springframework.aop.TargetClassAware; import org.springframework.aop.framework.Advised; import org.springframework.beans.factory.annotation.Value; +import org.springframework.lang.Nullable; import org.springframework.test.util.ReflectionTestUtils; /** @@ -342,6 +345,44 @@ class ProxyProjectionFactoryUnitTests { assertThat(excerpt.getBirthdate()).contains(LocalDateTime.of(1967, 1, 9, 0, 0)); } + @Test // GH-3242 + void projectionFactoryConsidersKotlinNullabilityConstraints() { + + var source = new HashMap(2); + source.put("name", null); + source.put("age", null); + + Person projection = factory.createProjection(Person.class, source); + + assertThatNoException().isThrownBy(projection::getAge); + assertThatExceptionOfType(NullPointerException.class).isThrownBy(projection::getName); + } + + @Test // GH-3242 + void projectionFactoryConsidersNullabilityAnnotations() { + + var source = new HashMap(2); + source.put("firstname", null); + source.put("lastname", null); + + CustomerProjectionWithNullables projection = factory.createProjection(CustomerProjectionWithNullables.class, source); + + assertThatNoException().isThrownBy(projection::getFirstname); + assertThatExceptionOfType(NullPointerException.class).isThrownBy(projection::getLastname); + } + + @Test // GH-3242 + void projectionFactoryIgnoresNullabilityAnnotationsOnUnmanagedPackage() { + + var source = new HashMap(2); + source.put("firstname", null); + source.put("lastname", null); + + NoNullableMarkedInterface projection = factory.createProjection(NoNullableMarkedInterface.class, source); + + assertThatNoException().isThrownBy(projection::getFirstname); + assertThatNoException().isThrownBy(projection::getLastname); + } interface Contact {} @@ -352,6 +393,12 @@ class ProxyProjectionFactoryUnitTests { LocalDateTime getBirthdate(); } + interface CustomerProjectionWithNullables { + + @Nullable String getFirstname(); + String getLastname(); + } + static class Address { String zipCode, city; diff --git a/src/test/java/org/springframework/data/web/JsonProjectingMethodInterceptorFactoryUnitTests.java b/src/test/java/org/springframework/data/web/JsonProjectingMethodInterceptorFactoryUnitTests.java index 001db1273..cccc8fe6c 100755 --- a/src/test/java/org/springframework/data/web/JsonProjectingMethodInterceptorFactoryUnitTests.java +++ b/src/test/java/org/springframework/data/web/JsonProjectingMethodInterceptorFactoryUnitTests.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.lang.Nullable; import org.springframework.util.ObjectUtils; import com.fasterxml.jackson.databind.ObjectMapper; @@ -223,7 +224,7 @@ class JsonProjectingMethodInterceptorFactoryUnitTests { // Not available in the payload @JsonPath("$.lastname") - String getLastname(); + @Nullable String getLastname(); // First one not available in the payload @JsonPath({ "$.lastname", "$.firstname" }) diff --git a/src/test/kotlin/org/springframework/data/projection/Person.kt b/src/test/kotlin/org/springframework/data/projection/Person.kt index 3646c7b79..45e23b156 100644 --- a/src/test/kotlin/org/springframework/data/projection/Person.kt +++ b/src/test/kotlin/org/springframework/data/projection/Person.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License.