From a190c082f7fc3df58bce64ba60d5f6abae8aab79 Mon Sep 17 00:00:00 2001 From: Oliver Gierke Date: Thu, 20 Apr 2017 10:09:10 +0200 Subject: [PATCH] DATACMNS-1034 - Introduced API to easily register converters using Java 8 lambdas. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduced ConverterBuilder which exposes API to register Spring GenericConverters for the use with a store module's CustomConversions. This allows simple registration of such converters using Java 8 lambdas. ConverterAware converters = ConverterBuilder.reading(String.class, Long.class, it -> Long.valueOf(it)).andWriting(it -> Object::toString); The setup can also be done from the reading side which would just need the method invocations inverted. The resulting ConverterAware will expose the registered converters so that they can be easily handed to a CustomConversions instance. Partial creation of either reading or writing converters is possible, too with the returned instance then only exposing one of the two converters. CustomConversions now considers ConverterAware and treats them appropriately for ConvertiblePair registration as well as during the registration in the ConversionService. Tweaked parameter types in CustomConversions to rather accept a Collection for the converters (previously List). Also, registerConverterIn(…) now takes a ConverterRegistry over a GenericConversionService. Polished Javadoc and non-null assertions. Original pull request: #209. --- .../data/convert/ConverterBuilder.java | 157 +++++++++++++++++ .../data/convert/CustomConversions.java | 72 +++++--- .../data/convert/DefaultConverterBuilder.java | 162 ++++++++++++++++++ .../convert/ConverterBuilderUnitTests.java | 89 ++++++++++ .../convert/CustomConversionsUnitTests.java | 24 +++ 5 files changed, 479 insertions(+), 25 deletions(-) create mode 100644 src/main/java/org/springframework/data/convert/ConverterBuilder.java create mode 100644 src/main/java/org/springframework/data/convert/DefaultConverterBuilder.java create mode 100644 src/test/java/org/springframework/data/convert/ConverterBuilderUnitTests.java diff --git a/src/main/java/org/springframework/data/convert/ConverterBuilder.java b/src/main/java/org/springframework/data/convert/ConverterBuilder.java new file mode 100644 index 000000000..ee79e8a31 --- /dev/null +++ b/src/main/java/org/springframework/data/convert/ConverterBuilder.java @@ -0,0 +1,157 @@ +/* + * 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.convert; + +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.core.convert.converter.GenericConverter.ConvertiblePair; +import org.springframework.util.Assert; + +/** + * API to easily set up {@link GenericConverter} instances using Java 8 lambdas, mostly in bidirectional fashion for + * easy registration as custom type converters of the Spring Data mapping subsystem. The registration starts either with + * the definition of a reading or writing converter that can then be completed. + * + * @author Oliver Gierke + * @since 2.0 + * @see #reading(Class, Class, Function) + * @see #writing(Class, Class, Function) + * @soundtrack John Mayer - Moving On and Getting Over (The Search for Everything) + */ +public interface ConverterBuilder { + + /** + * Creates a new {@link ReadingConverterBuilder} to produce a converter to read values of the given source (the store + * type) into the given target (the domain type). + * + * @param source must not be {@literal null}. + * @param target must not be {@literal null}. + * @param function must not be {@literal null}. + * @return + */ + static ReadingConverterBuilder reading(Class source, Class target, + Function function) { + + Assert.notNull(source, "Source type must not be null!"); + Assert.notNull(target, "Target type must not be null!"); + Assert.notNull(function, "Conversion function must not be null!"); + + return new DefaultConverterBuilder<>(new ConvertiblePair(source, target), Optional.empty(), Optional.of(function)); + } + + /** + * Creates a new {@link WritingConverterBuilder} to produce a converter to write values of the given source (the + * domain type) into the given target (the store type). + * + * @param source must not be {@literal null}. + * @param target must not be {@literal null}. + * @param function must not be {@literal null}. + * @return + */ + static WritingConverterBuilder writing(Class source, Class target, + Function function) { + + Assert.notNull(source, "Source type must not be null!"); + Assert.notNull(target, "Target type must not be null!"); + Assert.notNull(function, "Conversion function must not be null!"); + + return new DefaultConverterBuilder<>(new ConvertiblePair(target, source), Optional.of(function), Optional.empty()); + } + + /** + * Returns all {@link GenericConverter} instances to be registered for the current {@link ConverterBuilder}. + * + * @return + */ + Set getConverters(); + + /** + * Exposes a writing converter. + * + * @author Oliver Gierke + * @since 2.0 + */ + interface WritingConverterAware { + + /** + * Returns the writing converter already created. + * + * @return + */ + GenericConverter getWritingConverter(); + } + + /** + * Exposes a reading converter. + * + * @author Oliver Gierke + * @since 2.0 + */ + interface ReadingConverterAware { + + /** + * Returns the reading converter already created. + * + * @return + */ + GenericConverter getReadingConverter(); + } + + /** + * Interface to represent an intermediate setup step of {@link ConverterAware} defining a reading converter first. + * + * @author Oliver Gierke + * @since 2.0 + */ + interface ReadingConverterBuilder extends ConverterBuilder, ReadingConverterAware { + + /** + * Creates a new {@link ConverterAware} by registering the given {@link Function} to add a write converter. + * + * @param function must not be {@literal null}. + * @return + */ + ConverterAware andWriting(Function function); + } + + /** + * Interface to represent an intermediate setup step of {@link ConverterAware} defining a writing converter first. + * + * @author Oliver Gierke + * @since 2.0 + */ + interface WritingConverterBuilder extends ConverterBuilder, WritingConverterAware { + + /** + * Creates a new {@link ConverterAware} by registering the given {@link Function} to add a write converter. + * + * @param function must not be {@literal null}. + * @return + */ + ConverterAware andReading(Function function); + } + + /** + * A {@link ConverterBuilder} aware of both a reading and writing converter. + * + * @author Oliver Gierke + * @since 2.0 + */ + interface ConverterAware extends ConverterBuilder, ReadingConverterAware, WritingConverterAware {} +} diff --git a/src/main/java/org/springframework/data/convert/CustomConversions.java b/src/main/java/org/springframework/data/convert/CustomConversions.java index d8d1cc750..c17e1d4de 100644 --- a/src/main/java/org/springframework/data/convert/CustomConversions.java +++ b/src/main/java/org/springframework/data/convert/CustomConversions.java @@ -37,9 +37,11 @@ import java.util.concurrent.ConcurrentHashMap; import org.springframework.core.GenericTypeResolver; import org.springframework.core.convert.converter.Converter; import org.springframework.core.convert.converter.ConverterFactory; +import org.springframework.core.convert.converter.ConverterRegistry; import org.springframework.core.convert.converter.GenericConverter; import org.springframework.core.convert.converter.GenericConverter.ConvertiblePair; import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.data.convert.ConverterBuilder.ConverterAware; import org.springframework.data.mapping.model.SimpleTypeHolder; import org.springframework.data.util.Optionals; import org.springframework.data.util.Streamable; @@ -90,10 +92,12 @@ public class CustomConversions { /** * Creates a new {@link CustomConversions} instance registering the given converters. * - * @param converters + * @param storeConversions must not be {@literal null}. + * @param converters must not be {@literal null}. */ - public CustomConversions(StoreConversions storeConversions, List converters) { + public CustomConversions(StoreConversions storeConversions, Collection converters) { + Assert.notNull(storeConversions, "StoreConversions must not be null!"); Assert.notNull(converters, "List of converters must not be null!"); this.readingPairs = new LinkedHashSet<>(); @@ -149,33 +153,46 @@ public class CustomConversions { * * @param conversionService */ - public void registerConvertersIn(GenericConversionService conversionService) { + public void registerConvertersIn(ConverterRegistry conversionService) { Assert.notNull(conversionService, "ConversionService must not be null!"); - converters.forEach(it -> { + converters.forEach(it -> registerConverterIn(it, conversionService)); + } - boolean added = false; + /** + * Registers the given converter in the given {@link GenericConversionService}. + * + * @param candidate must not be {@literal null}. + * @param conversionService must not be {@literal null}. + */ + private void registerConverterIn(Object candidate, ConverterRegistry conversionService) { - if (it instanceof Converter) { - conversionService.addConverter(Converter.class.cast(it)); - added = true; - } + boolean added = false; - if (it instanceof ConverterFactory) { - conversionService.addConverterFactory(ConverterFactory.class.cast(it)); - added = true; - } + if (candidate instanceof Converter) { + conversionService.addConverter(Converter.class.cast(candidate)); + added = true; + } - if (it instanceof GenericConverter) { - conversionService.addConverter(GenericConverter.class.cast(it)); - added = true; - } + if (candidate instanceof ConverterFactory) { + conversionService.addConverterFactory(ConverterFactory.class.cast(candidate)); + added = true; + } - if (!added) { - throw new IllegalArgumentException(String.format(NOT_A_CONVERTER, it)); - } - }); + if (candidate instanceof GenericConverter) { + conversionService.addConverter(GenericConverter.class.cast(candidate)); + added = true; + } + + if (candidate instanceof ConverterAware) { + ConverterAware.class.cast(candidate).getConverters().forEach(it -> registerConverterIn(it, conversionService)); + added = true; + } + + if (!added) { + throw new IllegalArgumentException(String.format(NOT_A_CONVERTER, candidate)); + } } /** @@ -460,10 +477,15 @@ public class CustomConversions { boolean isWriting = type.isAnnotationPresent(WritingConverter.class); boolean isReading = type.isAnnotationPresent(ReadingConverter.class); - if (converter instanceof GenericConverter) { + if (converter instanceof ConverterAware) { - GenericConverter genericConverter = (GenericConverter) converter; - return Streamable.of(genericConverter.getConvertibleTypes()).map(it -> register(it, isReading, isWriting)); + return Streamable.of(() -> ConverterAware.class.cast(converter).getConverters().stream()// + .flatMap(it -> getRegistrationsFor(it).stream())); + + } else if (converter instanceof GenericConverter) { + + return Streamable.of(GenericConverter.class.cast(converter).getConvertibleTypes())// + .map(it -> register(it, isReading, isWriting)); } else if (converter instanceof ConverterFactory) { @@ -474,7 +496,7 @@ public class CustomConversions { return getRegistrationFor(converter, Converter.class, isReading, isWriting); } else { - throw new IllegalArgumentException("Unsupported converter type!"); + throw new IllegalArgumentException(String.format("Unsupported converter type %s!", converter)); } } diff --git a/src/main/java/org/springframework/data/convert/DefaultConverterBuilder.java b/src/main/java/org/springframework/data/convert/DefaultConverterBuilder.java new file mode 100644 index 000000000..ddbfbd3ce --- /dev/null +++ b/src/main/java/org/springframework/data/convert/DefaultConverterBuilder.java @@ -0,0 +1,162 @@ +/* + * 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.convert; + +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Wither; + +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.core.convert.converter.GenericConverter.ConvertiblePair; +import org.springframework.data.convert.ConverterBuilder.ConverterAware; +import org.springframework.data.convert.ConverterBuilder.ReadingConverterBuilder; +import org.springframework.data.convert.ConverterBuilder.WritingConverterBuilder; +import org.springframework.data.util.Optionals; + +/** + * Builder to easily set up (bi-directional) {@link Converter} instances for Spring Data type mapping using Lambdas. Use + * factory methods on {@link ConverterBuilder} to create instances of this class. + * + * @author Oliver Gierke + * @since 2.0 + * @see ConverterBuilder#writing(Class, Class, Function) + * @see ConverterBuilder#reading(Class, Class, Function) + * @soundtrack John Mayer - Still Feel Like Your Man (The Search for Everything) + */ +@Wither(AccessLevel.PACKAGE) +@RequiredArgsConstructor(access = AccessLevel.PACKAGE) +class DefaultConverterBuilder + implements ConverterAware, ReadingConverterBuilder, WritingConverterBuilder { + + private final @NonNull ConvertiblePair convertiblePair; + private final @NonNull Optional> writing; + private final @NonNull Optional> reading; + + /* + * (non-Javadoc) + * @see org.springframework.data.convert.WritingConverterBuilder#andReading(java.util.function.Function) + */ + @Override + public ConverterAware andReading(Function function) { + return withReading(Optional.of(function)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.convert.ReadingConverterBuilder#andWriting(java.util.function.Function) + */ + @Override + public ConverterAware andWriting(Function function) { + return withWriting(Optional.of(function)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.convert.ReadingConverterBuilder#getRequiredReadingConverter() + */ + @Override + public GenericConverter getReadingConverter() { + return getOptionalReadingConverter() + .orElseThrow(() -> new IllegalStateException("No reading converter specified!")); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.convert.WritingConverterBuilder#getRequiredWritingConverter() + */ + @Override + public GenericConverter getWritingConverter() { + return getOptionalWritingConverter() + .orElseThrow(() -> new IllegalStateException("No writing converter specified!")); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.convert.ConverterBuilder#getConverters() + */ + @Override + public Set getConverters() { + + return Optionals// + .toStream(getOptionalReadingConverter(), getOptionalWritingConverter())// + .collect(Collectors.toSet()); + } + + private Optional getOptionalReadingConverter() { + return reading.map(it -> new ConfigurableGenericConverter.Reading<>(convertiblePair, it)); + } + + private Optional getOptionalWritingConverter() { + return writing.map(it -> new ConfigurableGenericConverter.Writing<>(invertedPair(), it)); + } + + private ConvertiblePair invertedPair() { + return new ConvertiblePair(convertiblePair.getTargetType(), convertiblePair.getSourceType()); + } + + @RequiredArgsConstructor + @EqualsAndHashCode + private static class ConfigurableGenericConverter implements GenericConverter { + + private final ConvertiblePair convertiblePair; + private final Function function; + + /* + * (non-Javadoc) + * @see org.springframework.core.convert.converter.GenericConverter#convert(java.lang.Object, org.springframework.core.convert.TypeDescriptor, org.springframework.core.convert.TypeDescriptor) + */ + @Override + @SuppressWarnings("unchecked") + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + return function.apply((S) source); + } + + /* + * (non-Javadoc) + * @see org.springframework.core.convert.converter.GenericConverter#getConvertibleTypes() + */ + @Override + public Set getConvertibleTypes() { + return Collections.singleton(convertiblePair); + } + + @WritingConverter + private static class Writing extends ConfigurableGenericConverter { + + Writing(ConvertiblePair convertiblePair, Function function) { + super(convertiblePair, function); + } + } + + @ReadingConverter + private static class Reading extends ConfigurableGenericConverter { + + Reading(ConvertiblePair convertiblePair, Function function) { + super(convertiblePair, function); + } + } + } +} diff --git a/src/test/java/org/springframework/data/convert/ConverterBuilderUnitTests.java b/src/test/java/org/springframework/data/convert/ConverterBuilderUnitTests.java new file mode 100644 index 000000000..7213e9408 --- /dev/null +++ b/src/test/java/org/springframework/data/convert/ConverterBuilderUnitTests.java @@ -0,0 +1,89 @@ +/* + * 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.convert; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.Test; +import org.mockito.internal.util.Supplier; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.core.convert.converter.GenericConverter.ConvertiblePair; +import org.springframework.data.convert.ConverterBuilder.ConverterAware; +import org.springframework.data.convert.ConverterBuilder.ReadingConverterBuilder; +import org.springframework.data.convert.ConverterBuilder.WritingConverterBuilder; + +/** + * Unit tests for {@link DefaultConverterBuilder}. + * + * @author Oliver Gierke + * @since 2.0 + * @soundtrack John Mayer - In the Blood (The Search for Everything) + */ +public class ConverterBuilderUnitTests { + + @Test // DATACMNS-1034 + public void setsUpBidirectionalConvertersFromReading() { + + ConverterAware builder = ConverterBuilder.reading(String.class, Long.class, it -> Long.valueOf(it)) + .andWriting(Object::toString); + + assertConverter(builder.getReadingConverter(), "1", 1L); + assertConverter(builder.getWritingConverter(), 1L, "1"); + } + + @Test // DATACMNS-1034 + public void setsUpBidirectionalConvertersFromWriting() { + + ConverterAware builder = ConverterBuilder.writing(Long.class, String.class, Object::toString) + .andReading(it -> Long.valueOf(it)); + + assertConverter(builder.getReadingConverter(), "1", 1L); + assertConverter(builder.getWritingConverter(), 1L, "1"); + } + + @Test // DATACMNS-1034 + public void setsUpReadingConverter() { + + ReadingConverterBuilder builder = ConverterBuilder.reading(String.class, Long.class, + string -> Long.valueOf(string)); + + assertConverter(builder.getReadingConverter(), "1", 1L); + assertOnlyConverter(builder, builder::getReadingConverter); + } + + @Test // DATACMNS-1034 + public void setsUpWritingConverter() { + + WritingConverterBuilder builder = ConverterBuilder.writing(Long.class, String.class, + Object::toString); + + assertConverter(builder.getWritingConverter(), 1L, "1"); + assertOnlyConverter(builder, builder::getWritingConverter); + } + + private static void assertConverter(GenericConverter converter, Object source, Object target) { + + assertThat(converter.getConvertibleTypes()) + .containsExactly(new ConvertiblePair(source.getClass(), target.getClass())); + assertThat(converter.convert(source, TypeDescriptor.forObject(source), TypeDescriptor.forObject(target))) + .isEqualTo(target); + } + + private static void assertOnlyConverter(ConverterBuilder builder, Supplier supplier) { + assertThat(builder.getConverters()).containsExactly(supplier.get()); + } +} diff --git a/src/test/java/org/springframework/data/convert/CustomConversionsUnitTests.java b/src/test/java/org/springframework/data/convert/CustomConversionsUnitTests.java index 371824cb0..8b349f492 100644 --- a/src/test/java/org/springframework/data/convert/CustomConversionsUnitTests.java +++ b/src/test/java/org/springframework/data/convert/CustomConversionsUnitTests.java @@ -32,8 +32,10 @@ import org.junit.Test; import org.springframework.aop.framework.ProxyFactory; 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.DefaultConversionService; import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.data.convert.ConverterBuilder.ConverterAware; import org.springframework.data.convert.CustomConversions.StoreConversions; import org.threeten.bp.LocalDateTime; @@ -158,6 +160,24 @@ public class CustomConversionsUnitTests { assertThat(customConversions.getCustomWriteTarget(String.class, SimpleDateFormat.class)).isPresent(); } + @Test // DATACMNS-1034 + public void registersConverterFromConverterAware() { + + ConverterAware converters = ConverterBuilder.reading(Left.class, Right.class, left -> new Right()) + .andWriting(right -> new Left()); + + CustomConversions conversions = new CustomConversions(StoreConversions.NONE, Collections.singletonList(converters)); + + assertThat(conversions.hasCustomWriteTarget(Right.class)).isTrue(); + assertThat(conversions.hasCustomReadTarget(Left.class, Right.class)).isTrue(); + + ConfigurableConversionService conversionService = new GenericConversionService(); + conversions.registerConvertersIn(conversionService); + + assertThat(conversionService.canConvert(Left.class, Right.class)).isTrue(); + assertThat(conversionService.canConvert(Right.class, Left.class)).isTrue(); + } + private static Class createProxyTypeFor(Class type) { ProxyFactory factory = new ProxyFactory(); @@ -274,4 +294,8 @@ public class CustomConversionsUnitTests { } } } + + static class Left {} + + static class Right {} }