DATACMNS-1157 - Enforce nullable/non-null API constraints on repository query methods.

We now validate query method invocations to check method parameters whether they accept null values and reject execution if null values are not supported. We derive nullability support from repository interfaces declared using Kotlin and Spring's NonNullApi/Nullable annotations.

We also check whether a query method can return null. If a query method returns null which isn't supposed to do so, then we throw EmptyResultDataAccessException to prevent null return values.

interface UserRepository extends Repository<User, String> {

  List<User> findByLastname(@Nullable String firstname);

  @Nullable
  User findByFirstnameAndLastname(String firstname, String lastname);
}

interface UserRepository : Repository<User, String> {

  fun findByLastname(username: String?): List<User>

  fun findByFirstnameAndLastname(firstname: String, lastname: String): User?
}

Original pull request: #241.
This commit is contained in:
Mark Paluch
2017-09-12 10:14:56 +02:00
committed by Oliver Gierke
parent 2c20377cfa
commit fcf97e1e71
19 changed files with 1026 additions and 9 deletions

View File

@@ -0,0 +1,163 @@
/*
* Copyright 2017 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
*
* http://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.repository.core.support;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import java.lang.annotation.ElementType;
import java.lang.reflect.Method;
import java.util.Map;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.MethodParameter;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.dao.EmptyResultDataAccessException;
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;
/**
* Interceptor enforcing required return value and method parameter constraints declared on repository query methods.
* Supports Kotlin nullability markers and JSR-305 Non-null annotations.
*
* @author Mark Paluch
* @since 2.0
* @see javax.annotation.Nonnull
* @see ReflectionUtils#isNullable(MethodParameter)
* @see NullableUtils
*/
public class MethodInvocationValidator implements MethodInterceptor {
private final ParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
private final Map<Method, Nullability> nullabilityCache = new ConcurrentReferenceHashMap<>(16, ReferenceType.WEAK);
/**
* 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 ReflectionUtils.isKotlinClass(repositoryInterface)
|| NullableUtils.isNonNull(repositoryInterface, ElementType.METHOD)
|| NullableUtils.isNonNull(repositoryInterface, ElementType.PARAMETER);
}
/*
* (non-Javadoc)
* @see org.aopalliance.intercept.MethodInterceptor#invoke(org.aopalliance.intercept.MethodInvocation)
*/
@Nullable
@Override
public Object invoke(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;
}
@Value
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
static class Nullability {
boolean nullableReturn;
boolean[] nullableParameters;
MethodParameter 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)
|| (ReflectionUtils.isKotlinClass(parameter.getDeclaringClass()) && ReflectionUtils.isNullable(parameter));
}
private static boolean requiresNoValue(MethodParameter parameter) {
return parameter.getParameterType().equals(Void.class) || parameter.getParameterType().equals(Void.TYPE);
}
}
}

View File

@@ -122,7 +122,7 @@ public abstract class RepositoryFactorySupport implements BeanClassLoaderAware,
private EvaluationContextProvider evaluationContextProvider;
private BeanFactory beanFactory;
private QueryCollectingQueryCreationListener collectingListener = new QueryCollectingQueryCreationListener();
private final QueryCollectingQueryCreationListener collectingListener = new QueryCollectingQueryCreationListener();
@SuppressWarnings("null")
public RepositoryFactorySupport() {
@@ -302,6 +302,10 @@ public abstract class RepositoryFactorySupport implements BeanClassLoaderAware,
result.setTarget(target);
result.setInterfaces(repositoryInterface, Repository.class, TransactionalProxy.class);
if (MethodInvocationValidator.supports(repositoryInterface)) {
result.addAdvice(new MethodInvocationValidator());
}
result.addAdvice(SurroundingTransactionDetectorMethodInterceptor.INSTANCE);
result.addAdvisor(ExposeInvocationInterceptor.ADVISOR);
@@ -329,7 +333,7 @@ public abstract class RepositoryFactorySupport implements BeanClassLoaderAware,
/**
* Returns the {@link RepositoryInformation} for the given {@link RepositoryMetadata} and custom
* {@link RepositoryFragments}.
*
*
* @param metadata must not be {@literal null}.
* @param fragments must not be {@literal null}.
* @return will never be {@literal null}.
@@ -341,7 +345,7 @@ public abstract class RepositoryFactorySupport implements BeanClassLoaderAware,
/**
* Returns the {@link RepositoryComposition} for the given {@link RepositoryMetadata} and extra
* {@link RepositoryFragments}.
*
*
* @param metadata must not be {@literal null}.
* @param fragments must not be {@literal null}.
* @return will never be {@literal null}.
@@ -543,6 +547,7 @@ public abstract class RepositoryFactorySupport implements BeanClassLoaderAware,
* (non-Javadoc)
* @see org.aopalliance.intercept.MethodInterceptor#invoke(org.aopalliance.intercept.MethodInvocation)
*/
@Override
@Nullable
public Object invoke(@SuppressWarnings("null") MethodInvocation invocation) throws Throwable {
@@ -579,6 +584,7 @@ public abstract class RepositoryFactorySupport implements BeanClassLoaderAware,
}
}
/**
* Method interceptor that calls methods on the {@link RepositoryComposition}.
*
@@ -622,11 +628,12 @@ public abstract class RepositoryFactorySupport implements BeanClassLoaderAware,
/**
* All {@link QueryMethod}s.
*/
private List<QueryMethod> queryMethods = new ArrayList<>();
private final List<QueryMethod> queryMethods = new ArrayList<>();
/* (non-Javadoc)
* @see org.springframework.data.repository.core.support.QueryCreationListener#onCreation(org.springframework.data.repository.query.RepositoryQuery)
*/
@Override
public void onCreation(RepositoryQuery query) {
this.queryMethods.add(query.getQueryMethod());
}

View File

@@ -0,0 +1,274 @@
/*
* Copyright 2017 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
*
* http://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 lombok.experimental.UtilityClass;
import java.lang.annotation.Annotation;
import java.lang.annotation.ElementType;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.Nullable;
import org.springframework.util.ClassUtils;
import org.springframework.util.MultiValueMap;
/**
* Utility methods to introspect nullability rules declared in packages, classes and methods.
* <p/>
* Nullability rules are declared using {@link NonNullApi} and {@link Nullable} and JSR-305
* {@link javax.annotation.Nonnull} annotations. By default (no annotation use), a package and its types are considered
* allowing {@literal null} values in return values and method parameters. Nullability rules are expressed by annotating
* a package with a JSR-305 meta annotation such as Spring's {@link NonNullApi}. All types of the package inherit the
* package rule. Subpackages do not inherit nullability rules and must be annotated themself.
*
* <pre class="code">
* &#64;org.springframework.lang.NonNullApi
* package com.example;
* </pre>
*
* {@link Nullable} selectively permits {@literal null} values for method return values or method parameters by
* annotating the method respectively the parameters:
*
* <pre class="code">
* public class ExampleClass {
*
* String shouldNotReturnNull(@Nullable String acceptsNull, String doesNotAcceptNull) {
* // …
* }
*
* &#64;Nullable
* String nullableReturn(String parameter) {
* // …
* }
* }
* </pre>
* <p/>
* {@link javax.annotation.Nonnull} is suitable for composition of meta-annotations and expresses via
* {@link Nonnull#when()} in which cases non-nullability is applicable.
*
* @author Mark Paluch
* @since 2.0
* @see NonNullApi
* @see Nullable
* @see Nonnull
*/
@UtilityClass
public class NullableUtils {
private static final String NON_NULL_CLASS_NAME = "javax.annotation.Nonnull";
private static final String TYPE_QUALIFIER_CLASS_NAME = "javax.annotation.meta.TypeQualifierDefault";
private static final Optional<Class<Annotation>> NON_NULL_ANNOTATION_CLASS = findClass(NON_NULL_CLASS_NAME);
private static final Set<Class<?>> NULLABLE_ANNOTATIONS = findClasses(Nullable.class.getName());
private static final Set<Class<?>> NON_NULLABLE_ANNOTATIONS = findClasses("reactor.util.lang.NonNullApi",
NonNullApi.class.getName());
private static final Set<String> WHEN_NULLABLE = new HashSet<>(Arrays.asList("UNKNOWN", "MAYBE", "NEVER"));
private static final Set<String> WHEN_NON_NULLABLE = new HashSet<>(Collections.singletonList("ALWAYS"));
/**
* Determine whether {@link ElementType} in the scope of {@link Method} requires non-{@literal null} values.
* Non-nullability rules are discovered from class and package annotations. Non-null is applied when
* {@link javax.annotation.Nonnull} is set to {@link javax.annotation.meta.When#ALWAYS}.
*
* @param type the class to inspect.
* @param elementType the element type.
* @return {@literal true} if {@link ElementType} allows {@literal null} values by default.
* @see #isNonNull(Annotation, ElementType)
*/
public boolean isNonNull(Method method, ElementType elementType) {
return isNonNull(method.getDeclaringClass(), elementType) || isNonNull((AnnotatedElement) method, elementType);
}
/**
* Determine whether {@link ElementType} in the scope of {@code type} requires non-{@literal null} values.
* Non-nullability rules are discovered from class and package annotations. Non-null is applied when
* {@link javax.annotation.Nonnull} is set to {@link javax.annotation.meta.When#ALWAYS}.
*
* @param type the class to inspect.
* @param elementType the element type.
* @return {@literal true} if {@link ElementType} allows {@literal null} values by default.
* @see #isNonNull(Annotation, ElementType)
*/
public boolean isNonNull(Class<?> type, ElementType elementType) {
return isNonNull(type.getPackage(), elementType) || isNonNull((AnnotatedElement) type, elementType);
}
/**
* Determine whether {@link ElementType} in the scope of {@link AnnotatedElement} requires non-{@literal null} values.
* This method determines default {@link javax.annotation.Nonnull nullability} rules from the annotated element
*
* @param element the scope of declaration, may be a {@link Package}, {@link Class}, or
* {@link java.lang.reflect.Method}.
* @param elementType the element type.
* @return {@literal true} if {@link ElementType} allows {@literal null} values by default.
*/
public boolean isNonNull(AnnotatedElement element, ElementType elementType) {
for (Annotation annotation : element.getAnnotations()) {
boolean isNonNull = NON_NULL_ANNOTATION_CLASS.isPresent() ? isNonNull(annotation, elementType)
: NON_NULLABLE_ANNOTATIONS.contains(annotation.annotationType());
if (isNonNull) {
return true;
}
}
return false;
}
private static boolean isNonNull(Annotation annotation, ElementType elementType) {
if (!NON_NULL_ANNOTATION_CLASS.isPresent()) {
return false;
}
Class<Annotation> annotationClass = NON_NULL_ANNOTATION_CLASS.get();
if (annotation.annotationType().equals(annotationClass)) {
return true;
}
if (!AnnotationUtils.isAnnotationMetaPresent(annotation.annotationType(), annotationClass)
|| !isNonNull(annotation)) {
return false;
}
return test(annotation, TYPE_QUALIFIER_CLASS_NAME, "value",
(ElementType[] o) -> Arrays.binarySearch(o, elementType) >= 0);
}
/**
* Determine whether a {@link MethodParameter} is explicitly annotated to be considered nullable. Nullability rules
* are discovered from method and parameter annotations. A {@link MethodParameter} is considered nullable when
* {@link javax.annotation.Nonnull} is set to one of {@link javax.annotation.meta.When#UNKNOWN},
* {@link javax.annotation.meta.When#NEVER}, or {@link javax.annotation.meta.When#MAYBE}.
*
* @param methodParameter the method parameter to inspect.
* @return {@literal true} if the parameter is nullable, {@literal false} otherwise.
*/
public static boolean isExplicitNullable(MethodParameter methodParameter) {
if (methodParameter.getParameterIndex() == -1) {
return isExplicitNullable(methodParameter.getMethodAnnotations());
}
return isExplicitNullable(methodParameter.getParameterAnnotations());
}
private static boolean isExplicitNullable(Annotation[] annotations) {
for (Annotation annotation : annotations) {
boolean isNullable = NON_NULL_ANNOTATION_CLASS.isPresent() ? isNullable(annotation)
: NULLABLE_ANNOTATIONS.contains(annotation.annotationType());
if (isNullable) {
return true;
}
}
return false;
}
/**
* Introspect {@link Annotation} for being either a meta-annotation composed from {@link Nonnull} or {@link Nonnull}
* itself expressing non-nullability.
*
* @param annotation
* @return {@literal true} if the annotation expresses non-nullability.
*/
private static boolean isNonNull(Annotation annotation) {
return test(annotation, NON_NULL_CLASS_NAME, "when", o -> WHEN_NON_NULLABLE.contains(o.toString()));
}
/**
* Introspect {@link Annotation} for being either a meta-annotation composed from {@link Nonnull} or {@link Nonnull}
* itself expressing nullability.
*
* @param annotation
* @return {@literal true} if the annotation expresses nullability.
*/
private static boolean isNullable(Annotation annotation) {
return test(annotation, NON_NULL_CLASS_NAME, "when", o -> WHEN_NULLABLE.contains(o.toString()));
}
@SuppressWarnings("unchecked")
private static <T> boolean test(Annotation annotation, String metaAnnotationName, String attribute,
Predicate<T> filter) {
if (annotation.annotationType().getName().equals(metaAnnotationName)) {
Map<String, Object> attributes = AnnotationUtils.getAnnotationAttributes(annotation);
return !attributes.isEmpty() && filter.test((T) attributes.get(attribute));
}
MultiValueMap<String, Object> attributes = AnnotatedElementUtils
.getAllAnnotationAttributes(annotation.annotationType(), metaAnnotationName);
if (attributes == null || attributes.isEmpty()) {
return false;
}
List<Object> elementTypes = attributes.get(attribute);
for (Object value : elementTypes) {
if (filter.test((T) value)) {
return true;
}
}
return false;
}
private static Set<Class<?>> findClasses(String... classNames) {
return Arrays.stream(classNames) //
.map(NullableUtils::findClass) //
.filter(Optional::isPresent) //
.map(Optional::get) //
.collect(Collectors.toSet());
}
@SuppressWarnings("unchecked")
private static <T> Optional<Class<T>> findClass(String className) {
try {
return Optional.of((Class) ClassUtils.forName(className, NullableUtils.class.getClassLoader()));
} catch (ClassNotFoundException e) {
return Optional.empty();
}
}
}

View File

@@ -15,6 +15,9 @@
*/
package org.springframework.data.util;
import kotlin.reflect.KFunction;
import kotlin.reflect.KType;
import kotlin.reflect.jvm.ReflectJvmMapping;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.experimental.UtilityClass;
@@ -31,6 +34,7 @@ import java.util.stream.IntStream;
import java.util.stream.Stream;
import org.springframework.beans.BeanUtils;
import org.springframework.core.MethodParameter;
import org.springframework.core.ResolvableType;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.lang.Nullable;
@@ -355,4 +359,34 @@ public class ReflectionUtils {
.map(Annotation::annotationType) //
.anyMatch(annotation -> annotation.getName().equals("kotlin.Metadata"));
}
/**
* Returns {@literal} whether the given {@link MethodParameter} is nullable. Nullable parameters are reference types
* and ones that are defined in Kotlin as such.
*
* @return {@literal true} if {@link MethodParameter} is nullable.
* @since 2.0
*/
public static boolean isNullable(MethodParameter parameter) {
if (Void.class.equals(parameter.getParameterType()) || Void.TYPE.equals(parameter.getParameterType())) {
return true;
}
if (isKotlinClass(parameter.getDeclaringClass())) {
KFunction<?> kotlinFunction = ReflectJvmMapping.getKotlinFunction(parameter.getMethod());
if (kotlinFunction == null) {
throw new IllegalArgumentException(String.format("Cannot resolve %s to a Kotlin function!", parameter));
}
KType type = parameter.getParameterIndex() == -1 ? kotlinFunction.getReturnType()
: kotlinFunction.getParameters().get(parameter.getParameterIndex() + 1).getType();
return type.isMarkedNullable();
}
return !parameter.getParameterType().isPrimitive();
}
}