From ce7683c6727dad9e48f3cf725f1a8573c330840f Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 14 Nov 2019 14:05:04 +0100 Subject: [PATCH] DATACMNS-1508 - Support for Kotlin Coroutines repositories. We now support Kotlin Coroutines repositories backed by reactive repositories. Coroutine repositories can either invoke reactive query methods or implemented methods that are backed either by methods returning a reactive wrapper or that are native suspended functions. Exclude Coroutines Continuation from bindable parameters and name discovery and do not unwrap results for suspended functions returning a reactive type. interface CoroutinesRepository : CoroutineCrudRepository { suspend fun findOne(id: String): User fun findByFirstname(firstname: String): Flow } Original pull request: #415. --- pom.xml | 22 ++- src/main/asciidoc/kotlin-coroutines.adoc | 30 ++- .../ImplementationInvocationMetadata.java | 109 +++++++++++ .../core/support/MethodLookups.java | 27 +-- .../core/support/RepositoryComposition.java | 23 ++- .../support/RepositoryFactorySupport.java | 99 ++++++++-- .../data/repository/query/Parameter.java | 22 ++- .../repository/reactive/package-info.java | 2 +- .../util/ReactiveWrapperConverters.java | 65 +++++++ .../repository/util/ReactiveWrappers.java | 19 +- .../data/util/KotlinReflectionUtils.java | 171 ++++++++++++++++++ .../data/util/ReflectionUtils.java | 90 --------- .../repository/kotlin/CoCrudRepository.kt | 139 ++++++++++++++ .../repository/kotlin/CoSortingRepository.kt | 42 +++++ .../data/repository/kotlin/package-info.java | 5 + .../DummyReactiveRepositoryFactory.java | 108 +++++++++++ .../core/support/DummyRepositoryFactory.java | 8 +- .../RepositoryFactorySupportUnitTests.java | 12 +- ...RepositoryCustomImplementationUnitTests.kt | 77 ++++++++ .../kotlin/CoCrudRepositoryUnitTests.kt | 170 +++++++++++++++++ .../repository/query/ParameterUnitTests.kt | 46 +++++ 21 files changed, 1147 insertions(+), 139 deletions(-) create mode 100644 src/main/java/org/springframework/data/repository/core/support/ImplementationInvocationMetadata.java create mode 100644 src/main/java/org/springframework/data/util/KotlinReflectionUtils.java create mode 100644 src/main/kotlin/org/springframework/data/repository/kotlin/CoCrudRepository.kt create mode 100644 src/main/kotlin/org/springframework/data/repository/kotlin/CoSortingRepository.kt create mode 100644 src/main/kotlin/org/springframework/data/repository/kotlin/package-info.java create mode 100644 src/test/java/org/springframework/data/repository/core/support/DummyReactiveRepositoryFactory.java create mode 100644 src/test/kotlin/org/springframework/data/repository/kotlin/CoCrudRepositoryCustomImplementationUnitTests.kt create mode 100644 src/test/kotlin/org/springframework/data/repository/kotlin/CoCrudRepositoryUnitTests.kt create mode 100644 src/test/kotlin/org/springframework/data/repository/query/ParameterUnitTests.kt diff --git a/pom.xml b/pom.xml index ac5bb1204..df8759fd5 100644 --- a/pom.xml +++ b/pom.xml @@ -1,5 +1,7 @@ - + 4.0.0 @@ -265,6 +267,24 @@ true + + org.jetbrains.kotlinx + kotlinx-coroutines-core + true + + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactive + true + + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactor + true + + org.jetbrains.kotlin kotlin-test diff --git a/src/main/asciidoc/kotlin-coroutines.adoc b/src/main/asciidoc/kotlin-coroutines.adoc index 366df0d69..5afeac07d 100644 --- a/src/main/asciidoc/kotlin-coroutines.adoc +++ b/src/main/asciidoc/kotlin-coroutines.adoc @@ -11,7 +11,7 @@ Spring Data modules provide support for Coroutines on the following scope: [[kotlin.coroutines.dependencies]] == Dependencies -Coroutines support is enabled when `kotlinx-coroutines-core` and `kotlinx-coroutines-reactor` dependencies are in the classpath: +Coroutines support is enabled when `kotlinx-coroutines-core`, `kotlinx-coroutines-reactive` and `kotlinx-coroutines-reactor` dependencies are in the classpath: .Dependencies to add in Maven pom.xml ==== @@ -22,6 +22,11 @@ Coroutines support is enabled when `kotlinx-coroutines-core` and `kotlinx-corout kotlinx-coroutines-core + + org.jetbrains.kotlinx + kotlinx-coroutines-reactive + + org.jetbrains.kotlinx kotlinx-coroutines-reactor @@ -51,3 +56,26 @@ https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coro * https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/map.html[`map` operator] supports asynchronous operation (no need for `flatMap`) since it takes a suspending function parameter Read this blog post about https://spring.io/blog/2019/04/12/going-reactive-with-spring-coroutines-and-kotlin-flow[Going Reactive with Spring, Coroutines and Kotlin Flow] for more details, including how to run code concurrently with Coroutines. + +[[kotlin.coroutines.repositories]] +== Repositories + +Here is an example of a Coroutines repository: + +==== +[source,kotlin] +---- +interface CoroutinesRepository : CoCrudRepository { + + suspend fun findOne(id: String): User + + fun findByFirstname(firstname: String): Flow +} +---- +==== + +Coroutines repositories are built on reactive repositories to expose the non-blocking nature of data access through Kotlin's Coroutines. +Methods on a Coroutines repository can be backed either by a query method or a custom implementation. +Invoking a custom implementation method propagates the Coroutines invocation to the actual implementation method if the custom method is `suspend`able without requiring the implementation method to return a reactive type such as `Mono` or `Flux`. + +NOTE: Coroutines repositories are only discovered when the repository extends the `CoCrudRepository` interface. diff --git a/src/main/java/org/springframework/data/repository/core/support/ImplementationInvocationMetadata.java b/src/main/java/org/springframework/data/repository/core/support/ImplementationInvocationMetadata.java new file mode 100644 index 000000000..1b43ed9c5 --- /dev/null +++ b/src/main/java/org/springframework/data/repository/core/support/ImplementationInvocationMetadata.java @@ -0,0 +1,109 @@ +/* + * Copyright 2019 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.repository.core.support; + +import kotlin.coroutines.Continuation; +import kotlin.reflect.KFunction; +import kotlinx.coroutines.reactive.AwaitKt; + +import java.lang.reflect.Method; + +import org.reactivestreams.Publisher; + +import org.springframework.core.KotlinDetector; +import org.springframework.data.repository.util.ReactiveWrapperConverters; +import org.springframework.data.util.KotlinReflectionUtils; +import org.springframework.data.util.ReflectionUtils; +import org.springframework.lang.Nullable; + +/** + * Metadata for a implementation {@link Method} invocation. This value object encapsulates whether the called and the + * backing method are regular methods or suspendable Kotlin coroutines methods. It also allows invocation of suspendable + * methods by backing the invocation using methods returning reactive types. + * + * @author Mark Paluch + * @since 2.3 + */ +class ImplementationInvocationMetadata { + + private final boolean suspendedDeclaredMethod; + private final boolean suspendedBaseClassMethod; + private final boolean reactiveBaseClassMethod; + + ImplementationInvocationMetadata(Method declaredMethod, Method baseClassMethod) { + + if (!KotlinDetector.isKotlinReflectPresent()) { + suspendedDeclaredMethod = false; + suspendedBaseClassMethod = false; + reactiveBaseClassMethod = false; + return; + } + + KFunction declaredFunction = ReflectionUtils.isKotlinClass(declaredMethod.getDeclaringClass()) + ? KotlinReflectionUtils.findKotlinFunction(declaredMethod) + : null; + KFunction baseClassFunction = ReflectionUtils.isKotlinClass(baseClassMethod.getDeclaringClass()) + ? KotlinReflectionUtils.findKotlinFunction(baseClassMethod) + : null; + + suspendedDeclaredMethod = declaredFunction != null && declaredFunction.isSuspend(); + suspendedBaseClassMethod = baseClassFunction != null && baseClassFunction.isSuspend(); + this.reactiveBaseClassMethod = !suspendedBaseClassMethod + && ReactiveWrapperConverters.supports(baseClassMethod.getReturnType()); + } + + @Nullable + public Object invoke(Method methodToCall, Object instance, Object[] args) throws Throwable { + + if (suspendedDeclaredMethod && !suspendedBaseClassMethod && reactiveBaseClassMethod) { + return invokeReactiveToSuspend(methodToCall, instance, args); + } + + return methodToCall.invoke(instance, args); + } + + @Nullable + @SuppressWarnings({ "unchecked", "ConstantConditions" }) + private Object invokeReactiveToSuspend(Method methodToCall, Object instance, Object[] args) + throws ReflectiveOperationException { + + Object[] invocationArguments = new Object[args.length - 1]; + System.arraycopy(args, 0, invocationArguments, 0, invocationArguments.length); + Object result = methodToCall.invoke(instance, invocationArguments); + + Publisher publisher; + if (result instanceof Publisher) { + publisher = (Publisher) result; + } else { + publisher = ReactiveWrapperConverters.toWrapper(result, Publisher.class); + } + + return AwaitKt.awaitFirstOrNull(publisher, (Continuation) args[args.length - 1]); + } + + boolean canInvoke(Method invokedMethod, Method backendMethod) { + + if (suspendedDeclaredMethod == suspendedBaseClassMethod) { + return invokedMethod.getParameterCount() == backendMethod.getParameterCount(); + } + + if (suspendedDeclaredMethod && reactiveBaseClassMethod) { + return invokedMethod.getParameterCount() - 1 == backendMethod.getParameterCount(); + } + + return false; + } +} diff --git a/src/main/java/org/springframework/data/repository/core/support/MethodLookups.java b/src/main/java/org/springframework/data/repository/core/support/MethodLookups.java index 11f3c95a1..8f1358f03 100644 --- a/src/main/java/org/springframework/data/repository/core/support/MethodLookups.java +++ b/src/main/java/org/springframework/data/repository/core/support/MethodLookups.java @@ -331,23 +331,22 @@ interface MethodLookups { return Optional.of(candidate)// .filter(it -> invokedMethod.getName().equals(it.getName()))// - .filter(it -> invokedMethod.getParameterCount() == it.getParameterCount())// - .filter(it -> parametersMatch(it, invokedMethod.getMethod(), predicate)); + .filter(it -> parameterCountMatch(invokedMethod, it))// + .filter(it -> parametersMatch(invokedMethod.getMethod(), it, predicate)); } /** * Checks the given method's parameters to match the ones of the given base class method. Matches generic arguments * against the ones bound in the given repository interface. * - * @param baseClassMethod must not be {@literal null}. * @param declaredMethod must not be {@literal null}. + * @param baseClassMethod must not be {@literal null}. * @param predicate must not be {@literal null}. * @return */ - private static boolean parametersMatch(Method baseClassMethod, Method declaredMethod, + private static boolean parametersMatch(Method declaredMethod, Method baseClassMethod, Predicate predicate) { - - return methodParameters(baseClassMethod, declaredMethod).allMatch(predicate); + return methodParameters(declaredMethod, baseClassMethod).allMatch(predicate); } /** @@ -378,13 +377,17 @@ interface MethodLookups { && parameterCriteria.getBaseType().isAssignableFrom(parameterCriteria.getDeclaredType()); } - private static Stream methodParameters(Method first, Method second) { + private static boolean parameterCountMatch(InvokedMethod invokedMethod, Method baseClassMethod) { - Assert.isTrue(first.getParameterCount() == second.getParameterCount(), "Method parameter count must be equal!"); + ImplementationInvocationMetadata invocationMetadata = new ImplementationInvocationMetadata( + invokedMethod.getMethod(), baseClassMethod); + return invocationMetadata.canInvoke(invokedMethod.getMethod(), baseClassMethod); + } - return IntStream.range(0, first.getParameterCount()) // - .mapToObj(index -> ParameterOverrideCriteria.of(new MethodParameter(first, index), - new MethodParameter(second, index))); + private static Stream methodParameters(Method invokedMethod, Method baseClassMethod) { + return IntStream.range(0, baseClassMethod.getParameterCount()) // + .mapToObj(index -> ParameterOverrideCriteria.of(new MethodParameter(invokedMethod, index), + new MethodParameter(baseClassMethod, index))); } /** @@ -395,8 +398,8 @@ interface MethodLookups { @Value(staticConstructor = "of") static class ParameterOverrideCriteria { - private final MethodParameter base; private final MethodParameter declared; + private final MethodParameter base; /** * @return base method parameter type. diff --git a/src/main/java/org/springframework/data/repository/core/support/RepositoryComposition.java b/src/main/java/org/springframework/data/repository/core/support/RepositoryComposition.java index 1228ca5d0..3f5988e21 100644 --- a/src/main/java/org/springframework/data/repository/core/support/RepositoryComposition.java +++ b/src/main/java/org/springframework/data/repository/core/support/RepositoryComposition.java @@ -28,6 +28,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiFunction; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -201,7 +202,7 @@ public class RepositoryComposition { ReflectionUtils.makeAccessible(methodToCall); - return fragments.invoke(methodToCall, argumentConverter.apply(methodToCall, args)); + return fragments.invoke(method, methodToCall, argumentConverter.apply(methodToCall, args)); } /** @@ -250,6 +251,7 @@ public class RepositoryComposition { static final RepositoryFragments EMPTY = new RepositoryFragments(Collections.emptyList()); private final Map> fragmentCache = new ConcurrentReferenceHashMap<>(); + private final Map invocationMetadataCache = new ConcurrentHashMap<>(); private final List> fragments; /** @@ -354,21 +356,30 @@ public class RepositoryComposition { /** * Invoke {@link Method} by resolving the * - * @param method + * @param invokedMethod invoked method as per invocation on the interface. + * @param methodToCall backend method that is backing the call. * @param args * @return * @throws Throwable */ - public Object invoke(Method method, Object[] args) throws Throwable { + @Nullable + public Object invoke(Method invokedMethod, Method methodToCall, Object[] args) throws Throwable { - RepositoryFragment fragment = fragmentCache.computeIfAbsent(method, this::findImplementationFragment); + RepositoryFragment fragment = fragmentCache.computeIfAbsent(methodToCall, this::findImplementationFragment); Optional optional = fragment.getImplementation(); if (!optional.isPresent()) { - throw new IllegalArgumentException(String.format("No implementation found for method %s", method)); + throw new IllegalArgumentException(String.format("No implementation found for method %s", methodToCall)); } - return method.invoke(optional.get(), args); + ImplementationInvocationMetadata invocationMetadata = invocationMetadataCache.get(invokedMethod); + + if (invocationMetadata == null) { + invocationMetadata = new ImplementationInvocationMetadata(invokedMethod, methodToCall); + invocationMetadataCache.put(invokedMethod, invocationMetadata); + } + + return invocationMetadata.invoke(methodToCall, optional.get(), args); } private RepositoryFragment findImplementationFragment(Method key) { 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 d0b23b192..c60d848b0 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 @@ -15,6 +15,9 @@ */ package org.springframework.data.repository.core.support; +import kotlin.coroutines.Continuation; +import kotlin.reflect.KFunction; +import kotlinx.coroutines.reactive.AwaitKt; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NonNull; @@ -35,6 +38,7 @@ import java.util.stream.Collectors; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; +import org.reactivestreams.Publisher; import org.springframework.aop.framework.ProxyFactory; import org.springframework.aop.interceptor.ExposeInvocationInterceptor; @@ -43,6 +47,7 @@ import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanClassLoaderAware; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.core.KotlinDetector; import org.springframework.core.ResolvableType; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.convert.support.GenericConversionService; @@ -64,6 +69,7 @@ import org.springframework.data.repository.util.ClassUtils; import org.springframework.data.repository.util.QueryExecutionConverters; import org.springframework.data.repository.util.ReactiveWrapperConverters; import org.springframework.data.repository.util.ReactiveWrappers; +import org.springframework.data.util.KotlinReflectionUtils; import org.springframework.data.util.Pair; import org.springframework.data.util.ReflectionUtils; import org.springframework.lang.Nullable; @@ -85,35 +91,35 @@ import org.springframework.util.ConcurrentReferenceHashMap.ReferenceType; @Slf4j public abstract class RepositoryFactorySupport implements BeanClassLoaderAware, BeanFactoryAware { - private static final BiFunction REACTIVE_ARGS_CONVERTER = (method, o) -> { + private static final BiFunction REACTIVE_ARGS_CONVERTER = (method, args) -> { if (ReactiveWrappers.isAvailable()) { Class[] parameterTypes = method.getParameterTypes(); - Object[] converted = new Object[o.length]; - for (int i = 0; i < parameterTypes.length; i++) { + Object[] converted = new Object[args.length]; + for (int i = 0; i < args.length; i++) { - Class parameterType = parameterTypes[i]; - Object value = o[i]; + Object value = args[i]; + Object convertedArg = value; - if (value == null) { - continue; + Class parameterType = parameterTypes.length > i ? parameterTypes[i] : null; + + if (value != null && parameterType != null) { + if (!parameterType.isAssignableFrom(value.getClass()) + && ReactiveWrapperConverters.canConvert(value.getClass(), parameterType)) { + + convertedArg = ReactiveWrapperConverters.toWrapper(value, parameterType); + } } - if (!parameterType.isAssignableFrom(value.getClass()) - && ReactiveWrapperConverters.canConvert(value.getClass(), parameterType)) { - - converted[i] = ReactiveWrapperConverters.toWrapper(value, parameterType); - } else { - converted[i] = value; - } + converted[i] = convertedArg; } return converted; } - return o; + return args; }; final static GenericConversionService CONVERSION_SERVICE = new DefaultConversionService(); @@ -534,6 +540,7 @@ public abstract class RepositoryFactorySupport implements BeanClassLoaderAware, public class QueryExecutorMethodInterceptor implements MethodInterceptor { private final Map queries; + private final Map invocationMetadataCache = new ConcurrentReferenceHashMap<>(); private final QueryExecutionResultHandler resultHandler; /** @@ -615,7 +622,16 @@ public abstract class RepositoryFactorySupport implements BeanClassLoaderAware, Method method = invocation.getMethod(); if (hasQueryFor(method)) { - return queries.get(method).execute(invocation.getArguments()); + + QueryMethodInvoker invocationMetadata = invocationMetadataCache.get(method); + + if (invocationMetadata == null) { + invocationMetadata = new QueryMethodInvoker(method); + invocationMetadataCache.put(method, invocationMetadata); + } + + RepositoryQuery repositoryQuery = queries.get(method); + return invocationMetadata.invoke(repositoryQuery, invocation.getArguments()); } return invocation.proceed(); @@ -711,4 +727,55 @@ public abstract class RepositoryFactorySupport implements BeanClassLoaderAware, this.compositionHash = composition.hashCode(); } } + + static class QueryMethodInvoker { + + private final boolean suspendedDeclaredMethod; + private final Class returnedType; + private final boolean returnsReactiveType; + + QueryMethodInvoker(Method invokedMethod) { + + this.suspendedDeclaredMethod = KotlinDetector.isKotlinReflectPresent() && isSuspendedMethod(invokedMethod); + this.returnedType = this.suspendedDeclaredMethod ? KotlinReflectionUtils.getReturnType(invokedMethod) + : invokedMethod.getReturnType(); + this.returnsReactiveType = ReactiveWrappers.supports(returnedType); + } + + private static boolean isSuspendedMethod(Method invokedMethod) { + + KFunction invokedFunction = ReflectionUtils.isKotlinClass(invokedMethod.getDeclaringClass()) + ? KotlinReflectionUtils.findKotlinFunction(invokedMethod) + : null; + + return invokedFunction != null && invokedFunction.isSuspend(); + } + + @Nullable + public Object invoke(RepositoryQuery query, Object[] args) { + return suspendedDeclaredMethod ? invokeReactiveToSuspend(query, args) : query.execute(args); + } + + @Nullable + @SuppressWarnings({ "unchecked", "ConstantConditions" }) + private Object invokeReactiveToSuspend(RepositoryQuery query, Object[] args) { + + Continuation continuation = (Continuation) args[args.length - 1]; + args[args.length - 1] = null; + Object result = query.execute(args); + + if (returnsReactiveType) { + return ReactiveWrapperConverters.toWrapper(result, returnedType); + } + + Publisher publisher; + if (result instanceof Publisher) { + publisher = (Publisher) result; + } else { + publisher = ReactiveWrapperConverters.toWrapper(result, Publisher.class); + } + + return AwaitKt.awaitFirstOrNull(publisher, continuation); + } + } } diff --git a/src/main/java/org/springframework/data/repository/query/Parameter.java b/src/main/java/org/springframework/data/repository/query/Parameter.java index a83a2aaa6..7e7e61cbc 100644 --- a/src/main/java/org/springframework/data/repository/query/Parameter.java +++ b/src/main/java/org/springframework/data/repository/query/Parameter.java @@ -18,7 +18,9 @@ package org.springframework.data.repository.query; import static java.lang.String.*; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Optional; @@ -31,6 +33,7 @@ import org.springframework.data.util.ClassTypeInformation; import org.springframework.data.util.Lazy; import org.springframework.data.util.TypeInformation; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; /** * Class to abstract a single parameter of a query method. It is held in the context of a {@link Parameters} instance. @@ -41,7 +44,7 @@ import org.springframework.util.Assert; */ public class Parameter { - static final List> TYPES = Arrays.asList(Pageable.class, Sort.class); + static final List> TYPES; private static final String NAMED_PARAMETER_TEMPLATE = ":%s"; private static final String POSITION_PARAMETER_TEMPLATE = "?%s"; @@ -51,6 +54,21 @@ public class Parameter { private final boolean isDynamicProjectionParameter; private final Lazy> name; + static { + + List> types = new ArrayList<>(Arrays.asList(Pageable.class, Sort.class)); + + try { + // consider Kotlin Coroutines Continuation a special parameter. That parameter is synthetic and should not get + // bound to any query. + types.add(ClassUtils.forName("kotlin.coroutines.Continuation", Parameter.class.getClassLoader())); + } catch (ClassNotFoundException e) { + // ignore + } + + TYPES = Collections.unmodifiableList(types); + } + /** * Creates a new {@link Parameter} for the given {@link MethodParameter}. * @@ -63,7 +81,7 @@ public class Parameter { this.parameter = parameter; this.parameterType = potentiallyUnwrapParameterType(parameter); this.isDynamicProjectionParameter = isDynamicProjectionParameter(parameter); - this.name = Lazy.of(() -> { + this.name = TYPES.contains(parameter.getParameterType()) ? Lazy.of(Optional.empty()) : Lazy.of(() -> { Param annotation = parameter.getParameterAnnotation(Param.class); return Optional.ofNullable(annotation == null ? parameter.getParameterName() : annotation.value()); }); diff --git a/src/main/java/org/springframework/data/repository/reactive/package-info.java b/src/main/java/org/springframework/data/repository/reactive/package-info.java index 8b110b5dc..26c672284 100644 --- a/src/main/java/org/springframework/data/repository/reactive/package-info.java +++ b/src/main/java/org/springframework/data/repository/reactive/package-info.java @@ -1,5 +1,5 @@ /** - * Support for reactive repository. + * Support for reactive repositories. */ @org.springframework.lang.NonNullApi package org.springframework.data.repository.reactive; diff --git a/src/main/java/org/springframework/data/repository/util/ReactiveWrapperConverters.java b/src/main/java/org/springframework/data/repository/util/ReactiveWrapperConverters.java index fdd8c6498..da1b22569 100644 --- a/src/main/java/org/springframework/data/repository/util/ReactiveWrapperConverters.java +++ b/src/main/java/org/springframework/data/repository/util/ReactiveWrapperConverters.java @@ -20,6 +20,8 @@ import static org.springframework.data.repository.util.ReactiveWrapperConverters import io.reactivex.BackpressureStrategy; import io.reactivex.Flowable; import io.reactivex.Maybe; +import kotlinx.coroutines.flow.Flow; +import kotlinx.coroutines.reactive.ReactiveFlowKt; import lombok.experimental.UtilityClass; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -34,10 +36,14 @@ import java.util.function.Function; import javax.annotation.Nonnull; import org.reactivestreams.Publisher; + import org.springframework.core.ReactiveAdapter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.ConditionalConverter; import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.ConverterFactory; import org.springframework.core.convert.support.ConfigurableConversionService; import org.springframework.core.convert.support.GenericConversionService; import org.springframework.data.repository.util.ReactiveWrappers.ReactiveLibrary; @@ -156,6 +162,12 @@ public class ReactiveWrapperConverters { conversionService.addConverter(RxJava2ObservableToSingleConverter.INSTANCE); conversionService.addConverter(RxJava2ObservableToMaybeConverter.INSTANCE); } + + if (ReactiveWrappers.isAvailable(ReactiveLibrary.KOTLIN_COROUTINES)) { + conversionService.addConverter(PublisherToFlowConverter.INSTANCE); + } + + conversionService.addConverterFactory(ReactiveAdapterConverterFactory.INSTANCE); } return conversionService; @@ -492,6 +504,27 @@ public class ReactiveWrapperConverters { } } + // ------------------------------------------------------------------------- + // Coroutine converters + // ------------------------------------------------------------------------- + + /** + * A {@link Converter} to convert a {@link Publisher} to {@link Flow}. + * + * @author Mark Paluch + * @author 2.3 + */ + private enum PublisherToFlowConverter implements Converter, Flow> { + + INSTANCE; + + @Nonnull + @Override + public Flow convert(Publisher source) { + return ReactiveFlowKt.asFlow(source); + } + } + // ------------------------------------------------------------------------- // RxJava 1 converters // ------------------------------------------------------------------------- @@ -1064,6 +1097,38 @@ public class ReactiveWrapperConverters { } } + /** + * A {@link ConverterFactory} that adapts between reactive types using {@link ReactiveAdapterRegistry}. + */ + private enum ReactiveAdapterConverterFactory implements ConverterFactory, ConditionalConverter { + + INSTANCE; + + @Override + public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + return isSupported(sourceType) || isSupported(targetType); + } + + private boolean isSupported(TypeDescriptor typeDescriptor) { + return REACTIVE_ADAPTER_REGISTRY != null + && REACTIVE_ADAPTER_REGISTRY.getAdapter(typeDescriptor.getType()) != null; + } + + @Override + @SuppressWarnings({ "ConstantConditions", "unchecked" }) + public Converter getConverter(Class targetType) { + return source -> { + + Publisher publisher = source instanceof Publisher ? (Publisher) source + : REACTIVE_ADAPTER_REGISTRY.getAdapter(Publisher.class, source).toPublisher(source); + + ReactiveAdapter adapter = REACTIVE_ADAPTER_REGISTRY.getAdapter(targetType); + + return (T) adapter.fromPublisher(publisher); + }; + } + } + /** * Holder for delayed initialization of {@link ReactiveAdapterRegistry}. * diff --git a/src/main/java/org/springframework/data/repository/util/ReactiveWrappers.java b/src/main/java/org/springframework/data/repository/util/ReactiveWrappers.java index d9f090d12..c2e5c06e8 100644 --- a/src/main/java/org/springframework/data/repository/util/ReactiveWrappers.java +++ b/src/main/java/org/springframework/data/repository/util/ReactiveWrappers.java @@ -15,6 +15,8 @@ */ package org.springframework.data.repository.util; +import kotlin.Unit; +import kotlinx.coroutines.flow.Flow; import lombok.experimental.UtilityClass; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -30,6 +32,7 @@ import java.util.Optional; import java.util.stream.Collectors; import org.reactivestreams.Publisher; + import org.springframework.core.ReactiveTypeDescriptor; import org.springframework.data.util.ProxyUtils; import org.springframework.data.util.ReflectionUtils; @@ -71,6 +74,11 @@ public class ReactiveWrappers { private static final boolean RXJAVA2_PRESENT = ClassUtils.isPresent("io.reactivex.Flowable", ReactiveWrappers.class.getClassLoader()); + private static final boolean KOTLIN_COROUTINES_PRESENT = ClassUtils.isPresent("kotlinx.coroutines.flow.Flow", + ReactiveWrappers.class.getClassLoader()) + && ClassUtils.isPresent("kotlinx.coroutines.reactive.ReactiveFlowKt", ReactiveWrappers.class.getClassLoader()) + && ClassUtils.isPresent("kotlinx.coroutines.reactor.ReactorFlowKt", ReactiveWrappers.class.getClassLoader()); + private static final Collection REACTIVE_WRAPPERS; /** @@ -79,7 +87,7 @@ public class ReactiveWrappers { * @author Mark Paluch */ static enum ReactiveLibrary { - PROJECT_REACTOR, RXJAVA1, RXJAVA2; + PROJECT_REACTOR, RXJAVA1, RXJAVA2, KOTLIN_COROUTINES; } static { @@ -111,6 +119,13 @@ public class ReactiveWrappers { reactiveWrappers.add(ReactiveTypeDescriptor.singleOptionalValue(Mono.class, Mono::empty)); reactiveWrappers.add(ReactiveTypeDescriptor.multiValue(Flux.class, Flux::empty)); reactiveWrappers.add(ReactiveTypeDescriptor.multiValue(Publisher.class, Flux::empty)); + + if (KOTLIN_COROUTINES_PRESENT) { + + reactiveWrappers.add(ReactiveTypeDescriptor.multiValue(Flow.class, () -> { + return (Flow) (flowCollector, continuation) -> Unit.INSTANCE; + })); + } } REACTIVE_WRAPPERS = Collections.unmodifiableCollection(reactiveWrappers); @@ -143,6 +158,8 @@ public class ReactiveWrappers { return RXJAVA1_PRESENT; case RXJAVA2: return RXJAVA2_PRESENT; + case KOTLIN_COROUTINES: + return PROJECT_REACTOR_PRESENT && KOTLIN_COROUTINES_PRESENT; default: throw new IllegalArgumentException(String.format("Reactive library %s not supported", reactiveLibrary)); } diff --git a/src/main/java/org/springframework/data/util/KotlinReflectionUtils.java b/src/main/java/org/springframework/data/util/KotlinReflectionUtils.java new file mode 100644 index 000000000..b4704eb34 --- /dev/null +++ b/src/main/java/org/springframework/data/util/KotlinReflectionUtils.java @@ -0,0 +1,171 @@ +/* + * Copyright 2019 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 kotlin.jvm.JvmClassMappingKt; +import kotlin.reflect.KCallable; +import kotlin.reflect.KClass; +import kotlin.reflect.KFunction; +import kotlin.reflect.KMutableProperty; +import kotlin.reflect.KProperty; +import kotlin.reflect.KType; +import kotlin.reflect.jvm.KTypesJvm; +import kotlin.reflect.jvm.ReflectJvmMapping; + +import java.lang.reflect.Method; +import java.util.Optional; +import java.util.stream.Stream; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; + +/** + * Reflection utility methods specific to Kotlin reflection. Requires Kotlin classes to be present to avoid linkage + * errors. + * + * @author Mark Paluch + * @since 2.3 + */ +public final class KotlinReflectionUtils { + + private KotlinReflectionUtils() {} + + /** + * Returns a {@link KFunction} instance corresponding to the given Java {@link Method} instance, or {@code null} if + * this method cannot be represented by a Kotlin function. + * + * @param method the method to look up. + * @return the {@link KFunction} or {@code null} if the method cannot be looked up. + */ + @Nullable + public static KFunction findKotlinFunction(Method method) { + + KFunction kotlinFunction = ReflectJvmMapping.getKotlinFunction(method); + + if (kotlinFunction == null) { + + // Fallback to own lookup because there's no public Kotlin API for that kind of lookup until + // https://youtrack.jetbrains.com/issue/KT-20768 gets resolved. + return findKFunction(method).orElse(null); + } + + return kotlinFunction; + } + + /** + * Returns the {@link Class return type} of a Kotlin {@link Method}. Supports regular and suspended methods. + * + * @param method the method to inspect, typically any synthetic JVM {@link Method}. + * @return return type of the method. + */ + public static Class getReturnType(Method method) { + + KFunction kotlinFunction = KotlinReflectionUtils.findKotlinFunction(method); + + if (kotlinFunction == null) { + throw new IllegalArgumentException(String.format("Cannot resolve %s to a KFunction!", method)); + } + + return JvmClassMappingKt.getJavaClass(KTypesJvm.getJvmErasure(kotlinFunction.getReturnType())); + } + + /** + * Returns {@literal} whether the given {@link MethodParameter} is nullable. Its declaring method can reference a + * Kotlin function, property or interface property. + * + * @return {@literal true} if {@link MethodParameter} is nullable. + * @since 2.0.1 + */ + static boolean isNullable(MethodParameter parameter) { + + Method method = parameter.getMethod(); + + if (method == null) { + throw new IllegalStateException(String.format("Cannot obtain method from parameter %s!", parameter)); + } + + KFunction kotlinFunction = findKotlinFunction(method); + + if (kotlinFunction == null) { + throw new IllegalArgumentException(String.format("Cannot resolve %s to a Kotlin function!", parameter)); + } + + // Special handling for Coroutines + if (kotlinFunction.isSuspend() && isLast(parameter)) { + return false; + } + + // see https://github.com/spring-projects/spring-framework/issues/23991 + if (kotlinFunction.getParameters().size() > parameter.getParameterIndex() + 1) { + + KType type = parameter.getParameterIndex() == -1 // + ? kotlinFunction.getReturnType() // + : kotlinFunction.getParameters().get(parameter.getParameterIndex() + 1).getType(); + + return type.isMarkedNullable(); + } + + return true; + } + + private static boolean isLast(MethodParameter parameter) { + return parameter.getParameterIndex() == parameter.getMethod().getParameterCount() - 1; + } + + /** + * Lookup a {@link Method} to a {@link KFunction}. + * + * @param method the JVM {@link Method} to look up. + * @return {@link Optional} wrapping a possibly existing {@link KFunction}. + */ + private static Optional> findKFunction(Method method) { + + KClass kotlinClass = JvmClassMappingKt.getKotlinClass(method.getDeclaringClass()); + + return kotlinClass.getMembers() // + .stream() // + .flatMap(KotlinReflectionUtils::toKFunctionStream) // + .filter(it -> isSame(it, method)) // + .findFirst(); + } + + private static Stream> toKFunctionStream(KCallable it) { + + if (it instanceof KMutableProperty) { + + KMutableProperty property = (KMutableProperty) it; + return Stream.of(property.getGetter(), property.getSetter()); + } + + if (it instanceof KProperty) { + + KProperty property = (KProperty) it; + return Stream.of(property.getGetter()); + } + + if (it instanceof KFunction) { + return Stream.of((KFunction) it); + } + + return Stream.empty(); + } + + private static boolean isSame(KFunction function, Method method) { + + Method javaMethod = ReflectJvmMapping.getJavaMethod(function); + return javaMethod != null && javaMethod.equals(method); + } +} diff --git a/src/main/java/org/springframework/data/util/ReflectionUtils.java b/src/main/java/org/springframework/data/util/ReflectionUtils.java index d5dcfc6c2..f4a83ad7b 100644 --- a/src/main/java/org/springframework/data/util/ReflectionUtils.java +++ b/src/main/java/org/springframework/data/util/ReflectionUtils.java @@ -15,14 +15,6 @@ */ package org.springframework.data.util; -import kotlin.jvm.JvmClassMappingKt; -import kotlin.reflect.KCallable; -import kotlin.reflect.KClass; -import kotlin.reflect.KFunction; -import kotlin.reflect.KMutableProperty; -import kotlin.reflect.KProperty; -import kotlin.reflect.KType; -import kotlin.reflect.jvm.ReflectJvmMapping; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.experimental.UtilityClass; @@ -449,86 +441,4 @@ public class ReflectionUtils { throw new IllegalArgumentException(String.format("Primitive type %s not supported!", type)); } - /** - * Reflection utility methods specific to Kotlin reflection. - */ - static class KotlinReflectionUtils { - - /** - * Returns {@literal} whether the given {@link MethodParameter} is nullable. Its declaring method can reference a - * Kotlin function, property or interface property. - * - * @return {@literal true} if {@link MethodParameter} is nullable. - * @since 2.0.1 - */ - static boolean isNullable(MethodParameter parameter) { - - Method method = parameter.getMethod(); - - if (method == null) { - throw new IllegalStateException(String.format("Cannot obtain method from parameter %s!", parameter)); - } - - KFunction kotlinFunction = ReflectJvmMapping.getKotlinFunction(method); - - if (kotlinFunction == null) { - - // Fallback to own lookup because there's no public Kotlin API for that kind of lookup until - // https://youtrack.jetbrains.com/issue/KT-20768 gets resolved. - kotlinFunction = findKFunction(method)// - .orElseThrow(() -> 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(); - } - - /** - * Lookup a {@link Method} to a {@link KFunction}. - * - * @param method the JVM {@link Method} to look up. - * @return {@link Optional} wrapping a possibly existing {@link KFunction}. - */ - private static Optional> findKFunction(Method method) { - - KClass kotlinClass = JvmClassMappingKt.getKotlinClass(method.getDeclaringClass()); - - return kotlinClass.getMembers() // - .stream() // - .flatMap(KotlinReflectionUtils::toKFunctionStream) // - .filter(it -> isSame(it, method)) // - .findFirst(); - } - - private static Stream> toKFunctionStream(KCallable it) { - - if (it instanceof KMutableProperty) { - - KMutableProperty property = (KMutableProperty) it; - return Stream.of(property.getGetter(), property.getSetter()); - } - - if (it instanceof KProperty) { - - KProperty property = (KProperty) it; - return Stream.of(property.getGetter()); - } - - if (it instanceof KFunction) { - return Stream.of((KFunction) it); - } - - return Stream.empty(); - } - - private static boolean isSame(KFunction function, Method method) { - - Method javaMethod = ReflectJvmMapping.getJavaMethod(function); - return javaMethod != null && javaMethod.equals(method); - } - } } diff --git a/src/main/kotlin/org/springframework/data/repository/kotlin/CoCrudRepository.kt b/src/main/kotlin/org/springframework/data/repository/kotlin/CoCrudRepository.kt new file mode 100644 index 000000000..4b3b08d38 --- /dev/null +++ b/src/main/kotlin/org/springframework/data/repository/kotlin/CoCrudRepository.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2019 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.repository.kotlin + +import kotlinx.coroutines.flow.Flow +import org.springframework.data.repository.NoRepositoryBean +import org.springframework.data.repository.Repository +import reactor.core.publisher.Mono + +/** + * Interface for generic CRUD operations using Kotlin Coroutines on a repository for a specific type. + * + * @author Mark Paluch + * @since 2.3 + * @see Flow + */ +@NoRepositoryBean +interface CoCrudRepository : Repository { + + /** + * Saves a given entity. Use the returned instance for further operations as the save operation might have changed the + * entity instance completely. + * + * @param entity must not be null. + * @return the saved entity. + * @throws IllegalArgumentException in case the given entity is null. + */ + suspend fun save(entity: S): T + + /** + * Saves all given entities. + * + * @param entities must not be null. + * @return [Flow] emitting the saved entities. + * @throws IllegalArgumentException in case the given [entities][Flow] or one of its entities is + * null. + */ + fun saveAll(entities: Iterable): Flow + + /** + * Saves all given entities. + * + * @param entityStream must not be null. + * @return [Flow] emitting the saved entities. + * @throws IllegalArgumentException in case the given [entityStream][Flow] is null. + */ + fun saveAll(entityStream: Flow): Flow + + /** + * Retrieves an entity by its id. + * + * @param id must not be null. + * @return [Mono] emitting the entity with the given id or empty if none found. + * @throws IllegalArgumentException in case the given id is null. + */ + suspend fun findById(id: ID): T? + + /** + * Returns whether an entity with the given id exists. + * + * @param id must not be null. + * @return true if an entity with the given id exists, false otherwise. + * @throws IllegalArgumentException in case the given id is null. + */ + suspend fun existsById(id: ID): Boolean + + /** + * Returns all instances of the type. + * + * @return [Flow] emitting all entities. + */ + fun findAll(): Flow + + /** + * Returns all instances of the type `T` with the given IDs. + * + * + * If some or all ids are not found, no entities are returned for these IDs. + * + * + * Note that the order of elements in the result is not guaranteed. + * + * @param ids must not be null nor contain any null values. + * @return [Flow] emitting the found entities. The size can be equal or less than the number of given + * ids. + * @throws IllegalArgumentException in case the given [ids][Iterable] or one of its items is null. + */ + fun findAllById(ids: Iterable): Flow + + /** + * Returns the number of entities available. + * + * @return number of entities. + */ + suspend fun count(): Long + + /** + * Deletes the entity with the given id. + * + * @param id must not be null. + * @throws IllegalArgumentException in case the given id is null. + */ + suspend fun deleteById(id: ID) + + /** + * Deletes a given entity. + * + * @param entity must not be null. + * @throws IllegalArgumentException in case the given entity is null. + */ + suspend fun delete(entity: T) + + /** + * Deletes the given entities. + * + * @param entities must not be null. + * @throws IllegalArgumentException in case the given [entities][Iterable] or one of its entities is + * null. + */ + suspend fun deleteAll(entities: Iterable) + + /** + * Deletes all entities managed by the repository. + */ + suspend fun deleteAll() +} diff --git a/src/main/kotlin/org/springframework/data/repository/kotlin/CoSortingRepository.kt b/src/main/kotlin/org/springframework/data/repository/kotlin/CoSortingRepository.kt new file mode 100644 index 000000000..e7476b9bb --- /dev/null +++ b/src/main/kotlin/org/springframework/data/repository/kotlin/CoSortingRepository.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2019 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.repository.kotlin + +import kotlinx.coroutines.flow.Flow +import org.springframework.data.domain.Sort +import org.springframework.data.repository.NoRepositoryBean + +/** + * Extension of [CoCrudRepository] to provide additional methods to retrieve entities using the sorting + * abstraction. + * + * @author Mark Paluch + * @since 2.3 + * @see Flow + * @see Sort + */ +@NoRepositoryBean +interface CoSortingRepository : CoCrudRepository { + + /** + * Returns all entities sorted by the given options. + * + * @param sort must not be null. + * @return all entities sorted by the given options. + * @throws IllegalArgumentException in case the given [Sort] is null. + */ + fun findAll(sort: Sort): Flow +} diff --git a/src/main/kotlin/org/springframework/data/repository/kotlin/package-info.java b/src/main/kotlin/org/springframework/data/repository/kotlin/package-info.java new file mode 100644 index 000000000..a8b71d97a --- /dev/null +++ b/src/main/kotlin/org/springframework/data/repository/kotlin/package-info.java @@ -0,0 +1,5 @@ +/** + * Support for Kotlin Coroutines repositories. + */ +@org.springframework.lang.NonNullApi +package org.springframework.data.repository.kotlin; diff --git a/src/test/java/org/springframework/data/repository/core/support/DummyReactiveRepositoryFactory.java b/src/test/java/org/springframework/data/repository/core/support/DummyReactiveRepositoryFactory.java new file mode 100644 index 000000000..162d648e0 --- /dev/null +++ b/src/test/java/org/springframework/data/repository/core/support/DummyReactiveRepositoryFactory.java @@ -0,0 +1,108 @@ +/* + * Copyright 2019 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.repository.core.support; + +import static org.mockito.Mockito.*; + +import java.lang.reflect.Method; +import java.util.Optional; + +import org.mockito.Mockito; + +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.data.repository.core.EntityInformation; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.query.QueryLookupStrategy; +import org.springframework.data.repository.query.QueryLookupStrategy.Key; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.RepositoryQuery; + +/** + * @author Mark Paluch + */ +public class DummyReactiveRepositoryFactory extends ReactiveRepositoryFactorySupport { + + public final DummyRepositoryFactory.MyRepositoryQuery queryOne = mock(DummyRepositoryFactory.MyRepositoryQuery.class); + public final RepositoryQuery queryTwo = mock(RepositoryQuery.class); + public final QueryLookupStrategy strategy = mock(QueryLookupStrategy.class); + + @SuppressWarnings("unchecked") private final QuerydslPredicateExecutor querydsl = mock( + QuerydslPredicateExecutor.class); + private final Object repository; + + public DummyReactiveRepositoryFactory(Object repository) { + + this.repository = repository; + + when(strategy.resolveQuery(Mockito.any(Method.class), Mockito.any(RepositoryMetadata.class), + Mockito.any(ProjectionFactory.class), Mockito.any(NamedQueries.class))).thenReturn(queryOne); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryFactorySupport#getEntityInformation(java.lang.Class) + */ + @Override + @SuppressWarnings("unchecked") + public EntityInformation getEntityInformation(Class domainClass) { + return mock(EntityInformation.class); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryFactorySupport#getTargetRepository(org.springframework.data.repository.core.RepositoryMetadata) + */ + @Override + protected Object getTargetRepository(RepositoryInformation information) { + return repository; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryFactorySupport#getRepositoryBaseClass(org.springframework.data.repository.core.RepositoryMetadata) + */ + @Override + protected Class getRepositoryBaseClass(RepositoryMetadata metadata) { + return repository.getClass(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryFactorySupport#getQueryLookupStrategy(org.springframework.data.repository.query.QueryLookupStrategy.Key, org.springframework.data.repository.query.EvaluationContextProvider) + */ + @Override + protected Optional getQueryLookupStrategy(Key key, + QueryMethodEvaluationContextProvider evaluationContextProvider) { + return Optional.of(strategy); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryFactorySupport#getRepositoryFragments(org.springframework.data.repository.core.RepositoryMetadata) + */ + @Override + protected RepositoryComposition.RepositoryFragments getRepositoryFragments(RepositoryMetadata metadata) { + + RepositoryComposition.RepositoryFragments fragments = super.getRepositoryFragments(metadata); + + return QuerydslPredicateExecutor.class.isAssignableFrom(metadata.getRepositoryInterface()) // + ? fragments.append(RepositoryComposition.RepositoryFragments.just(querydsl)) // + : fragments; + } +} diff --git a/src/test/java/org/springframework/data/repository/core/support/DummyRepositoryFactory.java b/src/test/java/org/springframework/data/repository/core/support/DummyRepositoryFactory.java index 44b685eeb..36b64eeb0 100644 --- a/src/test/java/org/springframework/data/repository/core/support/DummyRepositoryFactory.java +++ b/src/test/java/org/springframework/data/repository/core/support/DummyRepositoryFactory.java @@ -28,7 +28,6 @@ import org.springframework.data.repository.core.NamedQueries; import org.springframework.data.repository.core.RepositoryInformation; import org.springframework.data.repository.core.RepositoryMetadata; import org.springframework.data.repository.core.support.RepositoryComposition.RepositoryFragments; -import org.springframework.data.repository.core.support.RepositoryFactorySupportUnitTests.MyRepositoryQuery; import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; import org.springframework.data.repository.query.QueryLookupStrategy; import org.springframework.data.repository.query.QueryLookupStrategy.Key; @@ -109,4 +108,11 @@ public class DummyRepositoryFactory extends RepositoryFactorySupport { ? fragments.append(RepositoryFragments.just(querydsl)) // : fragments; } + + /** + * @author Mark Paluch + */ + public static interface MyRepositoryQuery extends RepositoryQuery { + + } } diff --git a/src/test/java/org/springframework/data/repository/core/support/RepositoryFactorySupportUnitTests.java b/src/test/java/org/springframework/data/repository/core/support/RepositoryFactorySupportUnitTests.java index 086689cd1..c72d0498e 100755 --- a/src/test/java/org/springframework/data/repository/core/support/RepositoryFactorySupportUnitTests.java +++ b/src/test/java/org/springframework/data/repository/core/support/RepositoryFactorySupportUnitTests.java @@ -19,6 +19,7 @@ import static java.util.Collections.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import static org.springframework.data.repository.core.support.DummyRepositoryFactory.*; import java.io.Serializable; import java.lang.reflect.Method; @@ -93,8 +94,7 @@ public class RepositoryFactorySupportUnitTests { Mockito.reset(factory.strategy); when(factory.strategy.resolveQuery(any(Method.class), any(RepositoryMetadata.class), any(ProjectionFactory.class), - any(NamedQueries.class))).thenReturn(factory.queryOne, - factory.queryTwo); + any(NamedQueries.class))).thenReturn(factory.queryOne, factory.queryTwo); factory.addQueryCreationListener(listener); factory.addQueryCreationListener(otherListener); @@ -309,7 +309,7 @@ public class RepositoryFactorySupportUnitTests { assertThatThrownBy( // () -> repository.findById("")) // .isInstanceOf(EmptyResultDataAccessException.class) // - .hasMessageContaining("Result must not be null!"); + .hasMessageContaining("Result must not be null!"); assertThat(repository.findByUsername("")).isNull(); } @@ -322,7 +322,7 @@ public class RepositoryFactorySupportUnitTests { assertThatThrownBy( // () -> repository.findByClass(null)) // .isInstanceOf(IllegalArgumentException.class) // - .hasMessageContaining("must not be null!"); + .hasMessageContaining("must not be null!"); } @Test // DATACMNS-1154 @@ -427,10 +427,6 @@ public class RepositoryFactorySupportUnitTests { } - interface MyRepositoryQuery extends RepositoryQuery { - - } - interface ReadOnlyRepository extends Repository { Optional findById(ID id); diff --git a/src/test/kotlin/org/springframework/data/repository/kotlin/CoCrudRepositoryCustomImplementationUnitTests.kt b/src/test/kotlin/org/springframework/data/repository/kotlin/CoCrudRepositoryCustomImplementationUnitTests.kt new file mode 100644 index 000000000..1e10928a6 --- /dev/null +++ b/src/test/kotlin/org/springframework/data/repository/kotlin/CoCrudRepositoryCustomImplementationUnitTests.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2019 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.repository.kotlin + +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.springframework.data.repository.core.RepositoryMetadata +import org.springframework.data.repository.core.support.DummyReactiveRepositoryFactory +import org.springframework.data.repository.core.support.RepositoryComposition +import org.springframework.data.repository.core.support.RepositoryFragment +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.data.repository.sample.User + +/** + * Unit tests for Coroutine repositories. + * + * @author Mark Paluch + */ +class CoCrudRepositoryCustomImplementationUnitTests { + + val backingRepository = mockk>() + lateinit var factory: DummyReactiveRepositoryFactory; + lateinit var coRepository: MyCoRepository; + + @Before + fun before() { + factory = CustomDummyReactiveRepositoryFactory(backingRepository) + coRepository = factory.getRepository(MyCoRepository::class.java) + } + + @Test // DATACMNS-1508 + fun shouldInvokeFindAll() { + + val result = runBlocking { + coRepository.findOne("foo") + } + + assertThat(result).isNotNull() + } + + class CustomDummyReactiveRepositoryFactory(repository: Any) : DummyReactiveRepositoryFactory(repository) { + + override fun getRepositoryFragments(metadata: RepositoryMetadata): RepositoryComposition.RepositoryFragments { + return super.getRepositoryFragments(metadata).append(RepositoryFragment.implemented(MyCustomCoRepository::class.java, MyCustomCoRepositoryImpl())) + } + } + + interface MyCoRepository : CoCrudRepository, MyCustomCoRepository + + interface MyCustomCoRepository { + + suspend fun findOne(id: String): User + } + + class MyCustomCoRepositoryImpl : MyCustomCoRepository { + + override suspend fun findOne(id: String): User { + return User() + } + } +} diff --git a/src/test/kotlin/org/springframework/data/repository/kotlin/CoCrudRepositoryUnitTests.kt b/src/test/kotlin/org/springframework/data/repository/kotlin/CoCrudRepositoryUnitTests.kt new file mode 100644 index 000000000..905c99fbf --- /dev/null +++ b/src/test/kotlin/org/springframework/data/repository/kotlin/CoCrudRepositoryUnitTests.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2019 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.repository.kotlin + +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.springframework.data.repository.core.support.DummyReactiveRepositoryFactory +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.data.repository.sample.User +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import rx.Observable +import rx.Single + +/** + * Unit tests for Coroutine repositories. + * + * @author Mark Paluch + */ +class CoCrudRepositoryUnitTests { + + val backingRepository = mockk>() + lateinit var factory: DummyReactiveRepositoryFactory; + lateinit var coRepository: MyCoRepository; + + @Before + fun before() { + factory = DummyReactiveRepositoryFactory(backingRepository) + coRepository = factory.getRepository(MyCoRepository::class.java) + } + + @Test // DATACMNS-1508 + fun shouldInvokeFindAll() { + + val sample = User() + + every { backingRepository.findAll() }.returns(Flux.just(sample)) + + val result = runBlocking { + coRepository.findAll().toList() + } + + assertThat(result).hasSize(1).containsOnly(sample) + } + + @Test // DATACMNS-1508 + fun shouldInvokeFindById() { + + val sample = User() + + every { backingRepository.findById("foo") }.returns(Mono.just(sample)) + + val result = runBlocking { + coRepository.findById("foo") + } + + assertThat(result).isNotNull().isEqualTo(sample) + } + + @Test // DATACMNS-1508 + fun shouldBridgeQueryMethod() { + + val sample = User() + + Mockito.`when`(factory.queryOne.execute(arrayOf("foo", null))).thenReturn(Mono.just(sample)) + + val result = runBlocking { + coRepository.findOne("foo") + } + + assertThat(result).isNotNull().isEqualTo(sample) + } + + @Test // DATACMNS-1508 + fun shouldBridgeRxJavaQueryMethod() { + + val sample = User() + + Mockito.`when`(factory.queryOne.execute(arrayOf("foo", null))).thenReturn(Single.just(sample)) + + val result = runBlocking { + coRepository.findOne("foo") + } + + assertThat(result).isNotNull().isEqualTo(sample) + } + + @Test // DATACMNS-1508 + fun shouldBridgeFlowMethod() { + + val sample = User() + + Mockito.`when`(factory.queryOne.execute(arrayOf("foo"))).thenReturn(Flux.just(sample), Flux.empty()) + + val result = runBlocking { + coRepository.findMultiple("foo").toList() + } + + assertThat(result).hasSize(1).containsOnly(sample) + + val emptyResult = runBlocking { + coRepository.findMultiple("foo").toList() + } + + assertThat(emptyResult).isEmpty() + } + + @Test // DATACMNS-1508 + fun shouldBridgeRxJavaToFlowMethod() { + + val sample = User() + + Mockito.`when`(factory.queryOne.execute(arrayOf("foo"))).thenReturn(Observable.just(sample)) + + val result = runBlocking { + coRepository.findMultiple("foo").toList() + } + + assertThat(result).hasSize(1).containsOnly(sample) + } + + @Test // DATACMNS-1508 + fun shouldBridgeSuspendedFlowMethod() { + + val sample = User() + + Mockito.`when`(factory.queryOne.execute(arrayOf("foo", null))).thenReturn(Flux.just(sample), Flux.empty()) + + val result = runBlocking { + coRepository.findSuspendedMultiple("foo").toList() + } + + assertThat(result).hasSize(1).containsOnly(sample) + + val emptyResult = runBlocking { + coRepository.findSuspendedMultiple("foo").toList() + } + + assertThat(emptyResult).isEmpty() + } + + interface MyCoRepository : CoCrudRepository { + + suspend fun findOne(id: String): User + + fun findMultiple(id: String): Flow + + suspend fun findSuspendedMultiple(id: String): Flow + } +} diff --git a/src/test/kotlin/org/springframework/data/repository/query/ParameterUnitTests.kt b/src/test/kotlin/org/springframework/data/repository/query/ParameterUnitTests.kt new file mode 100644 index 000000000..298053ee0 --- /dev/null +++ b/src/test/kotlin/org/springframework/data/repository/query/ParameterUnitTests.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2019 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.repository.query + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.springframework.core.DefaultParameterNameDiscoverer +import org.springframework.core.MethodParameter +import org.springframework.core.ParameterNameDiscoverer +import kotlin.reflect.jvm.javaMethod + +/** + * Unit tests for [Parameter]. + * + * @author Mark Paluch + */ +class ParameterUnitTests { + + @Test // DATACMNS-1508 + fun `should consider Continuation a special parameter`() { + + val methodParameter = MethodParameter(MyCoroutineRepository::hello.javaMethod, 0) + methodParameter.initParameterNameDiscovery(DefaultParameterNameDiscoverer()) + val parameter = Parameter(methodParameter) + + assertThat(parameter.name).isEmpty() + assertThat(parameter.isBindable).isFalse() + } + + interface MyCoroutineRepository { + suspend fun hello(): Any + } +}