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 {} }