From a9c3b9f66fca465f29bcee239a10f5856d54396d Mon Sep 17 00:00:00 2001 From: Oliver Gierke Date: Mon, 12 Dec 2016 20:07:37 +0100 Subject: [PATCH] DATACMNS-940 - Support for Javaslang collections as repository return types. Javaslang's collection and map types can now be used on repository query methods and get adapted similarly to nullable wrapper types (like JDK's Optional). Also made TypeInformation infrastructure aware of the map type so that a value type lookup gets handle correctly. --- .../repository/util/JavaslangCollections.java | 148 ++++++++++++++++++ .../util/QueryExecutionConverters.java | 20 ++- .../data/util/TypeDiscoverer.java | 43 ++++- .../QueryExecutionConvertersUnitTests.java | 102 +++++++++++- .../util/ClassTypeInformationUnitTests.java | 31 +++- 5 files changed, 335 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/springframework/data/repository/util/JavaslangCollections.java diff --git a/src/main/java/org/springframework/data/repository/util/JavaslangCollections.java b/src/main/java/org/springframework/data/repository/util/JavaslangCollections.java new file mode 100644 index 000000000..3d1fbf847 --- /dev/null +++ b/src/main/java/org/springframework/data/repository/util/JavaslangCollections.java @@ -0,0 +1,148 @@ +/* + * Copyright 2016 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.util; + +import javaslang.collection.Traversable; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.ConditionalGenericConverter; +import org.springframework.core.convert.converter.Converter; +import org.springframework.util.ReflectionUtils; + +/** + * Converter implementations to map from and to Javaslang collections. + * + * @author Oliver Gierke + * @since 1.13 + */ +class JavaslangCollections { + + public enum ToJavaConverter implements Converter { + + INSTANCE; + + public Class getWrapperType() { + return javaslang.collection.Traversable.class; + } + + /* + * (non-Javadoc) + * @see org.springframework.core.convert.converter.Converter#convert(java.lang.Object) + */ + @Override + public Object convert(Object source) { + + if (source instanceof javaslang.collection.Seq) { + return ((javaslang.collection.Seq) source).toJavaList(); + } + + if (source instanceof javaslang.collection.Map) { + return ((javaslang.collection.Map) source).toJavaMap(); + } + + if (source instanceof javaslang.collection.Set) { + return ((javaslang.collection.Set) source).toJavaSet(); + } + + throw new IllegalArgumentException("Unsupported Javaslang collection " + source); + } + } + + public enum FromJavaConverter implements ConditionalGenericConverter { + + INSTANCE { + + /* + * (non-Javadoc) + * @see org.springframework.core.convert.converter.GenericConverter#getConvertibleTypes() + */ + @Override + public java.util.Set getConvertibleTypes() { + return CONVERTIBLE_PAIRS; + } + + /* + * (non-Javadoc) + * @see org.springframework.core.convert.converter.ConditionalConverter#matches(org.springframework.core.convert.TypeDescriptor, org.springframework.core.convert.TypeDescriptor) + */ + @Override + public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + + // Prevent collections to be mapped to maps + if (sourceType.isCollection() && javaslang.collection.Map.class.isAssignableFrom(targetType.getType())) { + return false; + } + + // Prevent maps to be mapped to collections + if (sourceType.isMap() && !(javaslang.collection.Map.class.isAssignableFrom(targetType.getType()) + || targetType.getType().equals(Traversable.class))) { + return false; + } + + return true; + } + + /* + * (non-Javadoc) + * @see org.springframework.core.convert.converter.GenericConverter#convert(java.lang.Object, org.springframework.core.convert.TypeDescriptor, org.springframework.core.convert.TypeDescriptor) + */ + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + + if (source instanceof List) { + return ReflectionUtils.invokeMethod(LIST_FACTORY_METHOD, null, source); + } + + if (source instanceof java.util.Set) { + return ReflectionUtils.invokeMethod(SET_FACTORY_METHOD, null, source); + } + + if (source instanceof java.util.Map) { + return ReflectionUtils.invokeMethod(MAP_FACTORY_METHOD, null, source); + } + + return source; + } + }; + + private static final Set CONVERTIBLE_PAIRS; + private static final Method LIST_FACTORY_METHOD; + private static final Method SET_FACTORY_METHOD; + private static final Method MAP_FACTORY_METHOD; + + static { + + Set pairs = new HashSet(); + pairs.add(new ConvertiblePair(Collection.class, javaslang.collection.Traversable.class)); + pairs.add(new ConvertiblePair(Map.class, javaslang.collection.Traversable.class)); + + CONVERTIBLE_PAIRS = Collections.unmodifiableSet(pairs); + + MAP_FACTORY_METHOD = ReflectionUtils.findMethod(javaslang.collection.LinkedHashMap.class, "ofAll", Map.class); + LIST_FACTORY_METHOD = ReflectionUtils.findMethod(javaslang.collection.List.class, "ofAll", Iterable.class); + SET_FACTORY_METHOD = ReflectionUtils.findMethod(javaslang.collection.LinkedHashSet.class, "ofAll", + Iterable.class); + } + } +} diff --git a/src/main/java/org/springframework/data/repository/util/QueryExecutionConverters.java b/src/main/java/org/springframework/data/repository/util/QueryExecutionConverters.java index 354ccb4ec..88a07f24f 100644 --- a/src/main/java/org/springframework/data/repository/util/QueryExecutionConverters.java +++ b/src/main/java/org/springframework/data/repository/util/QueryExecutionConverters.java @@ -15,11 +15,13 @@ */ package org.springframework.data.repository.util; +import javaslang.collection.Traversable; import scala.Function0; import scala.Option; import scala.runtime.AbstractFunction0; import java.lang.reflect.Method; +import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.Set; @@ -105,7 +107,10 @@ public abstract class QueryExecutionConverters { } if (JAVASLANG_PRESENT) { + WRAPPER_TYPES.add(NullableWrapperToJavaslangOptionConverter.getWrapperType()); + WRAPPER_TYPES.add(JavaslangCollections.ToJavaConverter.INSTANCE.getWrapperType()); + UNWRAPPERS.add(JavaslangOptionUnwrapper.INSTANCE); } @@ -165,6 +170,8 @@ public abstract class QueryExecutionConverters { Assert.notNull(conversionService, "ConversionService must not be null!"); + conversionService.removeConvertible(Collection.class, Object.class); + if (GUAVA_PRESENT) { conversionService.addConverter(new NullableWrapperToGuavaOptionalConverter(conversionService)); } @@ -180,6 +187,7 @@ public abstract class QueryExecutionConverters { if (JAVASLANG_PRESENT) { conversionService.addConverter(new NullableWrapperToJavaslangOptionConverter(conversionService)); + conversionService.addConverter(JavaslangCollections.FromJavaConverter.INSTANCE); } conversionService.addConverter(new NullableWrapperToFutureConverter(conversionService)); @@ -566,8 +574,16 @@ public abstract class QueryExecutionConverters { @Override @SuppressWarnings("unchecked") public Object convert(Object source) { - return source instanceof javaslang.control.Option - ? ((javaslang.control.Option) source).getOrElse(NULL_SUPPLIER) : source; + + if (source instanceof javaslang.control.Option) { + return ((javaslang.control.Option) source).getOrElse(NULL_SUPPLIER); + } + + if (source instanceof Traversable) { + return JavaslangCollections.ToJavaConverter.INSTANCE.convert(source); + } + + return source; } } } diff --git a/src/main/java/org/springframework/data/util/TypeDiscoverer.java b/src/main/java/org/springframework/data/util/TypeDiscoverer.java index f26863075..52cdc0596 100644 --- a/src/main/java/org/springframework/data/util/TypeDiscoverer.java +++ b/src/main/java/org/springframework/data/util/TypeDiscoverer.java @@ -35,13 +35,16 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.springframework.beans.BeanUtils; import org.springframework.core.GenericTypeResolver; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; import org.springframework.util.ReflectionUtils; /** @@ -51,6 +54,22 @@ import org.springframework.util.ReflectionUtils; */ class TypeDiscoverer implements TypeInformation { + private static final Iterable> MAP_TYPES; + + static { + + ClassLoader classLoader = TypeDiscoverer.class.getClassLoader(); + + Set> mapTypes = new HashSet>(); + mapTypes.add(Map.class); + + try { + mapTypes.add(ClassUtils.forName("javaslang.collection.Map", classLoader)); + } catch (ClassNotFoundException o_O) {} + + MAP_TYPES = Collections.unmodifiableSet(mapTypes); + } + private final Type type; private final Map, Type> typeVariableMap; private final Map fieldTypes = new ConcurrentHashMap(); @@ -329,7 +348,14 @@ class TypeDiscoverer implements TypeInformation { * @see org.springframework.data.util.TypeInformation#isMap() */ public boolean isMap() { - return Map.class.isAssignableFrom(getType()); + + for (Class mapType : MAP_TYPES) { + if (mapType.isAssignableFrom(getType())) { + return true; + } + } + + return false; } /* @@ -349,7 +375,7 @@ class TypeDiscoverer implements TypeInformation { protected TypeInformation doGetMapValueType() { if (isMap()) { - return getTypeArgument(Map.class, 1); + return getTypeArgument(getBaseType(MAP_TYPES), 1); } List> arguments = getTypeArguments(); @@ -399,7 +425,7 @@ class TypeDiscoverer implements TypeInformation { } if (isMap()) { - return getTypeArgument(Map.class, 0); + return getTypeArgument(getBaseType(MAP_TYPES), 0); } if (Iterable.class.isAssignableFrom(rawType)) { @@ -525,6 +551,17 @@ class TypeDiscoverer implements TypeInformation { return createInfo(arguments[index]); } + private Class getBaseType(Iterable> candidates) { + + for (Class candidate : candidates) { + if (candidate.isAssignableFrom(getType())) { + return candidate; + } + } + + throw new IllegalArgumentException(String.format("Type %s not contained in candidates %s!", getType(), candidates)); + } + /* * (non-Javadoc) * @see java.lang.Object#equals(java.lang.Object) diff --git a/src/test/java/org/springframework/data/repository/util/QueryExecutionConvertersUnitTests.java b/src/test/java/org/springframework/data/repository/util/QueryExecutionConvertersUnitTests.java index 98580224e..a6ae57677 100644 --- a/src/test/java/org/springframework/data/repository/util/QueryExecutionConvertersUnitTests.java +++ b/src/test/java/org/springframework/data/repository/util/QueryExecutionConvertersUnitTests.java @@ -17,7 +17,11 @@ package org.springframework.data.repository.util; import static org.hamcrest.CoreMatchers.*; import static org.junit.Assert.*; +import static org.springframework.data.repository.util.QueryExecutionConverters.*; +import javaslang.collection.HashMap; +import javaslang.collection.HashSet; +import javaslang.collection.Traversable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import rx.Completable; @@ -26,6 +30,11 @@ import rx.Single; import scala.Option; import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; @@ -136,7 +145,7 @@ public class QueryExecutionConvertersUnitTests { public void turnsNullIntoGuavaOptional() { Optional optional = conversionService.convert(new NullableWrapper(null), Optional.class); - assertThat(optional, is(Optional.absent())); + assertThat(optional, is(Optional. absent())); } /** @@ -148,7 +157,7 @@ public class QueryExecutionConvertersUnitTests { java.util.Optional optional = conversionService.convert(new NullableWrapper(null), java.util.Optional.class); - assertThat(optional, is(java.util.Optional.empty())); + assertThat(optional, is(java.util.Optional. empty())); } /** @@ -205,7 +214,7 @@ public class QueryExecutionConvertersUnitTests { public void turnsNullIntoScalaOptionEmpty() { assertThat((Option) conversionService.convert(new NullableWrapper(null), Option.class), - is(Option.empty())); + is(Option. empty())); } /** @@ -262,6 +271,71 @@ public class QueryExecutionConvertersUnitTests { assertThat(QueryExecutionConverters.unwrap(option("string")), is((Object) "string")); } + /** + * @see DATACMNS-940 + */ + @Test + public void conversListToJavaslang() { + + assertThat(conversionService.canConvert(List.class, javaslang.collection.Traversable.class), is(true)); + assertThat(conversionService.canConvert(List.class, javaslang.collection.List.class), is(true)); + assertThat(conversionService.canConvert(List.class, javaslang.collection.Set.class), is(true)); + assertThat(conversionService.canConvert(List.class, javaslang.collection.Map.class), is(false)); + + List integers = Arrays.asList(1, 2, 3); + + Traversable result = conversionService.convert(integers, Traversable.class); + + assertThat(result, is(instanceOf(javaslang.collection.List.class))); + } + + /** + * @see DATACMNS-940 + */ + @Test + public void convertsSetToJavaslang() { + + assertThat(conversionService.canConvert(Set.class, javaslang.collection.Traversable.class), is(true)); + assertThat(conversionService.canConvert(Set.class, javaslang.collection.Set.class), is(true)); + assertThat(conversionService.canConvert(Set.class, javaslang.collection.List.class), is(true)); + assertThat(conversionService.canConvert(Set.class, javaslang.collection.Map.class), is(false)); + + Set integers = Collections.singleton(1); + + Traversable result = conversionService.convert(integers, Traversable.class); + + assertThat(result, is(instanceOf(javaslang.collection.Set.class))); + } + + /** + * @see DATACMNS-940 + */ + @Test + public void convertsMapToJavaslang() { + + assertThat(conversionService.canConvert(Map.class, javaslang.collection.Traversable.class), is(true)); + assertThat(conversionService.canConvert(Map.class, javaslang.collection.Map.class), is(true)); + assertThat(conversionService.canConvert(Map.class, javaslang.collection.Set.class), is(false)); + assertThat(conversionService.canConvert(Map.class, javaslang.collection.List.class), is(false)); + + Map map = Collections.singletonMap("key", "value"); + + Traversable result = conversionService.convert(map, Traversable.class); + + assertThat(result, is(instanceOf(javaslang.collection.Map.class))); + } + + /** + * @see DATACMNS-940 + */ + @Test + public void unwrapsJavaslangCollectionsToJavaOnes() { + + assertThat(unwrap(javaslangList(1, 2, 3)), is(instanceOf(List.class))); + assertThat(unwrap(javaslangSet(1, 2, 3)), is(instanceOf(Set.class))); + assertThat(unwrap(javaslangMap("key", "value")), is(instanceOf(Map.class))); + } + @SuppressWarnings("unchecked") private static javaslang.control.Option optionNone() { @@ -275,4 +349,26 @@ public class QueryExecutionConvertersUnitTests { Method method = ReflectionUtils.findMethod(javaslang.control.Option.class, "of", Object.class); return (javaslang.control.Option) ReflectionUtils.invokeMethod(method, null, source); } + + @SuppressWarnings("unchecked") + private static javaslang.collection.List javaslangList(T... values) { + + Method method = ReflectionUtils.findMethod(javaslang.collection.List.class, "ofAll", Iterable.class); + return (javaslang.collection.List) ReflectionUtils.invokeMethod(method, null, Arrays.asList(values)); + } + + @SuppressWarnings("unchecked") + private static javaslang.collection.Set javaslangSet(T... values) { + + Method method = ReflectionUtils.findMethod(HashSet.class, "ofAll", Iterable.class); + return (javaslang.collection.Set) ReflectionUtils.invokeMethod(method, null, Arrays.asList(values)); + } + + @SuppressWarnings("unchecked") + private static javaslang.collection.Map javaslangMap(K key, V value) { + + Method method = ReflectionUtils.findMethod(HashMap.class, "ofAll", Map.class); + return (javaslang.collection.Map) ReflectionUtils.invokeMethod(method, null, + Collections.singletonMap(key, value)); + } } diff --git a/src/test/java/org/springframework/data/util/ClassTypeInformationUnitTests.java b/src/test/java/org/springframework/data/util/ClassTypeInformationUnitTests.java index c4a4af0fa..2d1405efa 100644 --- a/src/test/java/org/springframework/data/util/ClassTypeInformationUnitTests.java +++ b/src/test/java/org/springframework/data/util/ClassTypeInformationUnitTests.java @@ -19,6 +19,8 @@ import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; import static org.springframework.data.util.ClassTypeInformation.*; +import javaslang.collection.Traversable; + import java.lang.reflect.Method; import java.util.Calendar; import java.util.Collection; @@ -106,7 +108,7 @@ public class ClassTypeInformationUnitTests { property = information.getProperty("rawSet"); assertEquals(Set.class, property.getType()); - assertThat(property.getComponentType().getType(), is(Matchers.>equalTo(Object.class))); + assertThat(property.getComponentType().getType(), is(Matchers.> equalTo(Object.class))); assertNull(property.getMapValueType()); } @@ -413,6 +415,29 @@ public class ClassTypeInformationUnitTests { assertThat(information.getProperty("field").getType(), is(typeCompatibleWith(Nested.class))); } + /** + * @see DATACMNS-940 + */ + @Test + public void detectsJavaslangTraversableComponentType() { + + ClassTypeInformation information = ClassTypeInformation.from(SampleTraversable.class); + + assertThat(information.getComponentType().getType(), is(typeCompatibleWith(Integer.class))); + } + + /** + * @see DATACMNS-940 + */ + @Test + public void detectsJavaslangMapComponentAndValueType() { + + ClassTypeInformation information = ClassTypeInformation.from(SampleMap.class); + + assertThat(information.getComponentType().getType(), is(typeCompatibleWith(String.class))); + assertThat(information.getMapValueType().getType(), is(typeCompatibleWith(Integer.class))); + } + static class StringMapContainer extends MapContainer { } @@ -611,4 +636,8 @@ public class ClassTypeInformationUnitTests { static class Nested extends SomeType {} static class Concrete extends SomeType {} + + static interface SampleTraversable extends Traversable {} + + static interface SampleMap extends javaslang.collection.Map {} }