diff --git a/pom.xml b/pom.xml index 0e9257f0f0..a85511590e 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-mongodb-parent - 3.4.0-SNAPSHOT + 3.4.0-GH-3596-SNAPSHOT pom Spring Data MongoDB @@ -26,7 +26,7 @@ multi spring-data-mongodb - 2.7.0-SNAPSHOT + 2.7.0-GH-1484-SNAPSHOT 4.5.0 ${mongo} 1.19 diff --git a/spring-data-mongodb-benchmarks/pom.xml b/spring-data-mongodb-benchmarks/pom.xml index e2704a6753..c35e5f996e 100644 --- a/spring-data-mongodb-benchmarks/pom.xml +++ b/spring-data-mongodb-benchmarks/pom.xml @@ -7,7 +7,7 @@ org.springframework.data spring-data-mongodb-parent - 3.4.0-SNAPSHOT + 3.4.0-GH-3596-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb-distribution/pom.xml b/spring-data-mongodb-distribution/pom.xml index b75f8bf624..6b698467b7 100644 --- a/spring-data-mongodb-distribution/pom.xml +++ b/spring-data-mongodb-distribution/pom.xml @@ -14,7 +14,7 @@ org.springframework.data spring-data-mongodb-parent - 3.4.0-SNAPSHOT + 3.4.0-GH-3596-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index ca96626cc9..e0562c6ccd 100644 --- a/spring-data-mongodb/pom.xml +++ b/spring-data-mongodb/pom.xml @@ -11,7 +11,7 @@ org.springframework.data spring-data-mongodb-parent - 3.4.0-SNAPSHOT + 3.4.0-GH-3596-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MappingMongoConverter.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MappingMongoConverter.java index c58f8d1e41..2e2d9fc6fd 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MappingMongoConverter.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MappingMongoConverter.java @@ -38,7 +38,6 @@ import org.bson.conversions.Bson; import org.bson.json.JsonReader; import org.bson.types.ObjectId; - import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanClassLoaderAware; import org.springframework.context.ApplicationContext; @@ -177,7 +176,7 @@ protected ConversionContext getConversionContext(ObjectPath path) { Assert.notNull(path, "ObjectPath must not be null"); - return new ConversionContext(conversions, path, this::readDocument, this::readCollectionOrArray, this::readMap, + return new ConversionContext(this, conversions, path, this::readDocument, this::readCollectionOrArray, this::readMap, this::readDBRef, this::getPotentiallyConvertedSimpleRead); } @@ -307,7 +306,7 @@ public R project(EntityProjection projection, Bson bson) { return (R) read(typeToRead, bson); } - ProjectingConversionContext context = new ProjectingConversionContext(conversions, ObjectPath.ROOT, + ProjectingConversionContext context = new ProjectingConversionContext(this, conversions, ObjectPath.ROOT, this::readCollectionOrArray, this::readMap, this::readDBRef, this::getPotentiallyConvertedSimpleRead, projection); @@ -391,11 +390,11 @@ class ProjectingConversionContext extends ConversionContext { private final EntityProjection returnedTypeDescriptor; - ProjectingConversionContext(CustomConversions customConversions, ObjectPath path, + ProjectingConversionContext(MongoConverter sourceConverter, CustomConversions customConversions, ObjectPath path, ContainerValueConverter> collectionConverter, ContainerValueConverter mapConverter, ContainerValueConverter dbRefConverter, ValueConverter elementConverter, EntityProjection projection) { - super(customConversions, path, + super(sourceConverter, customConversions, path, (context, source, typeHint) -> doReadOrProject(context, source, typeHint, projection), collectionConverter, mapConverter, dbRefConverter, elementConverter); @@ -410,13 +409,13 @@ public ConversionContext forProperty(String name) { return super.forProperty(name); } - return new ProjectingConversionContext(conversions, path, collectionConverter, mapConverter, dbRefConverter, + return new ProjectingConversionContext(sourceConverter, conversions, path, collectionConverter, mapConverter, dbRefConverter, elementConverter, property); } @Override public ConversionContext withPath(ObjectPath currentPath) { - return new ProjectingConversionContext(conversions, currentPath, collectionConverter, mapConverter, + return new ProjectingConversionContext(sourceConverter, conversions, currentPath, collectionConverter, mapConverter, dbRefConverter, elementConverter, returnedTypeDescriptor); } } @@ -958,6 +957,11 @@ protected void writePropertyInternal(@Nullable Object obj, DocumentAccessor acce TypeInformation valueType = ClassTypeInformation.from(obj.getClass()); TypeInformation type = prop.getTypeInformation(); + if(conversions.hasPropertyValueConverter(prop)) { + accessor.put(prop, conversions.getPropertyValueConverter(prop).write(obj, new MongoConversionContext(prop, this))); + return; + } + if (prop.isUnwrapped()) { Document target = new Document(); @@ -1287,6 +1291,12 @@ private void writeSimpleInternal(@Nullable Object value, Bson bson, String key) private void writeSimpleInternal(@Nullable Object value, Bson bson, MongoPersistentProperty property) { DocumentAccessor accessor = new DocumentAccessor(bson); + + if(conversions.hasPropertyValueConverter(property)) { + accessor.put(property, conversions.getPropertyValueConverter(property).write(value, new MongoConversionContext(property, this))); + return; + } + accessor.put(property, getPotentiallyConvertedSimpleWrite(value, property.hasExplicitWriteTarget() ? property.getFieldType() : Object.class)); } @@ -1950,6 +1960,11 @@ public T getPropertyValue(MongoPersistentProperty property) { return null; } + if(context.conversions.hasPropertyValueConverter(property)) { + + return (T) context.conversions.getPropertyValueConverter(property).read(value, new MongoConversionContext(property, context.sourceConverter)); + } + return (T) context.convert(value, property.getTypeInformation()); } @@ -2179,6 +2194,7 @@ public org.springframework.data.util.TypeInformation specialize(Cla */ protected static class ConversionContext { + final MongoConverter sourceConverter; final org.springframework.data.convert.CustomConversions conversions; final ObjectPath path; final ContainerValueConverter documentConverter; @@ -2187,11 +2203,12 @@ protected static class ConversionContext { final ContainerValueConverter dbRefConverter; final ValueConverter elementConverter; - ConversionContext(org.springframework.data.convert.CustomConversions customConversions, ObjectPath path, + ConversionContext(MongoConverter sourceConverter, org.springframework.data.convert.CustomConversions customConversions, ObjectPath path, ContainerValueConverter documentConverter, ContainerValueConverter> collectionConverter, ContainerValueConverter mapConverter, ContainerValueConverter dbRefConverter, ValueConverter elementConverter) { + this.sourceConverter = sourceConverter; this.conversions = customConversions; this.path = path; this.documentConverter = documentConverter; @@ -2273,7 +2290,7 @@ public ConversionContext withPath(ObjectPath currentPath) { Assert.notNull(currentPath, "ObjectPath must not be null"); - return new ConversionContext(conversions, currentPath, documentConverter, collectionConverter, mapConverter, + return new ConversionContext(sourceConverter, conversions, currentPath, documentConverter, collectionConverter, mapConverter, dbRefConverter, elementConverter); } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConversionContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConversionContext.java new file mode 100644 index 0000000000..9a83832d4c --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConversionContext.java @@ -0,0 +1,60 @@ +/* + * Copyright 2022 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.mongodb.core.convert; + +import org.bson.conversions.Bson; +import org.springframework.data.convert.ValueConversionContext; +import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; + +/** + * {@link ValueConversionContext} that allows to delegate read/write to an underlying {@link MongoConverter}. + * + * @author Christoph Strobl + * @since 3.4 + */ +public class MongoConversionContext implements ValueConversionContext { + + private final MongoPersistentProperty persistentProperty; + private final MongoConverter mongoConverter; + + public MongoConversionContext(MongoPersistentProperty persistentProperty, MongoConverter mongoConverter) { + + this.persistentProperty = persistentProperty; + this.mongoConverter = mongoConverter; + } + + @Override + public MongoPersistentProperty getProperty() { + return persistentProperty; + } + + @Override + public T write(@Nullable Object value, TypeInformation target) { + return (T) mongoConverter.convertToMongoType(value, target); + } + + @Override + public T read(@Nullable Object value, TypeInformation target) { + + if (!(value instanceof Bson)) { + return ValueConversionContext.super.read(value, target); + } + + return mongoConverter.read(target.getType(), (Bson) value); + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoCustomConversions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoCustomConversions.java index 394d71984a..52927d21de 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoCustomConversions.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoCustomConversions.java @@ -37,8 +37,14 @@ import org.springframework.core.convert.converter.ConverterFactory; import org.springframework.core.convert.converter.GenericConverter; import org.springframework.data.convert.JodaTimeConverters; +import org.springframework.data.convert.PropertyValueConversions; +import org.springframework.data.convert.PropertyValueConverter; +import org.springframework.data.convert.PropertyValueConverterFactory; +import org.springframework.data.convert.PropertyValueConverterRegistrar; +import org.springframework.data.convert.SimplePropertyValueConversions; import org.springframework.data.convert.WritingConverter; import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; import org.springframework.data.mongodb.core.mapping.MongoSimpleTypes; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -159,6 +165,8 @@ public static class MongoConverterConfigurationAdapter { private boolean useNativeDriverJavaTimeCodecs = false; private final List customConverters = new ArrayList<>(); + private PropertyValueConversions propertyValueConversions = new SimplePropertyValueConversions(); + /** * Create a {@link MongoConverterConfigurationAdapter} using the provided {@code converters} and our own codecs for * JSR-310 types. @@ -230,6 +238,27 @@ public MongoConverterConfigurationAdapter registerConverter(Converter conv return this; } + /** + * Gateway to register property specific converters. + * + * @param configurationAdapter must not be {@literal null}. + * @return this. + * @since 3.4 + */ + public MongoConverterConfigurationAdapter configurePropertyConversions( + Consumer> configurationAdapter) { + + Assert.state(valueConversions() instanceof SimplePropertyValueConversions, + "Configured PropertyValueConversions does not allow setting custom ConverterRegistry."); + + PropertyValueConverterRegistrar propertyValueConverterRegistrar = new PropertyValueConverterRegistrar(); + configurationAdapter.accept(propertyValueConverterRegistrar); + + ((SimplePropertyValueConversions) valueConversions()) + .setValueConverterRegistry(propertyValueConverterRegistrar.buildRegistry()); + return this; + } + /** * Add a custom {@link ConverterFactory} implementation. * @@ -258,10 +287,54 @@ public MongoConverterConfigurationAdapter registerConverters(Collection conve return this; } + /** + * Add a custom/default {@link PropertyValueConverterFactory} implementation used to serve + * {@link PropertyValueConverter}. + * + * @param converterFactory must not be {@literal null}. + * @return this. + * @since 3.4 + */ + public MongoConverterConfigurationAdapter registerPropertyValueConverterFactory( + PropertyValueConverterFactory converterFactory) { + + Assert.state(valueConversions() instanceof SimplePropertyValueConversions, + "Configured PropertyValueConversions does not allow setting custom ConverterRegistry."); + + ((SimplePropertyValueConversions) valueConversions()).setConverterFactory(converterFactory); + return this; + } + + /** + * Optionally set the {@link PropertyValueConversions} to be applied during mapping. + *

+ * Use this method if {@link #configurePropertyConversions(Consumer)} and + * {@link #registerPropertyValueConverterFactory(PropertyValueConverterFactory)} are not sufficient. + * + * @param valueConversions must not be {@literal null}. + * @return this. + * @since 3.4 + */ + public MongoConverterConfigurationAdapter setPropertyValueConversions(PropertyValueConversions valueConversions) { + + this.propertyValueConversions = valueConversions; + return this; + } + + PropertyValueConversions valueConversions() { + + if (this.propertyValueConversions == null) { + this.propertyValueConversions = new SimplePropertyValueConversions(); + } + + return this.propertyValueConversions; + } + ConverterConfiguration createConverterConfiguration() { if (!useNativeDriverJavaTimeCodecs) { - return new ConverterConfiguration(STORE_CONVERSIONS, this.customConverters); + return new ConverterConfiguration(STORE_CONVERSIONS, this.customConverters, convertiblePair -> true, + this.propertyValueConversions); } /* @@ -286,7 +359,7 @@ ConverterConfiguration createConverterConfiguration() { } return true; - }); + }, this.propertyValueConversions); } private enum DateToUtcLocalDateTimeConverter implements Converter { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoValueConverter.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoValueConverter.java new file mode 100644 index 0000000000..49b9021cc0 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoValueConverter.java @@ -0,0 +1,28 @@ +/* + * Copyright 2022 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.mongodb.core.convert; + +import org.springframework.data.convert.PropertyValueConverter; + +/** + * Pre typed {@link PropertyValueConverter} specific for the Data MongoDB module. + * + * @author Christoph Strobl + * @since 3.4 + */ +public interface MongoValueConverter extends PropertyValueConverter { + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java index ac1794b87d..174667435a 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java @@ -434,6 +434,10 @@ protected Object getMappedValue(Field documentField, Object sourceValue) { Object value = applyFieldTargetTypeHintToValue(documentField, sourceValue); + if(documentField.getProperty() != null && converter.getCustomConversions().hasPropertyValueConverter(documentField.getProperty())) { + return converter.getCustomConversions().getPropertyValueConverter(documentField.getProperty()).write(value, new MongoConversionContext(documentField.getProperty(), converter)); + } + if (documentField.isIdField() && !documentField.isAssociation()) { if (isDBObject(value)) { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java index 40ab7aa11f..99797db9a4 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java @@ -43,10 +43,12 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; - import org.springframework.aop.framework.ProxyFactory; import org.springframework.beans.ConversionNotSupportedException; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.context.ApplicationContext; import org.springframework.context.support.StaticApplicationContext; import org.springframework.core.convert.ConverterNotFoundException; @@ -56,7 +58,10 @@ import org.springframework.data.annotation.Transient; import org.springframework.data.annotation.TypeAlias; import org.springframework.data.convert.CustomConversions; +import org.springframework.data.convert.PropertyValueConverter; +import org.springframework.data.convert.PropertyValueConverterFactory; import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.ValueConverter; import org.springframework.data.convert.WritingConverter; import org.springframework.data.geo.Box; import org.springframework.data.geo.Circle; @@ -2437,7 +2442,6 @@ void shouldUseMostConcreteCustomConversionTargetOnRead() { verify(subTypeOfGenericTypeConverter).convert(eq(source)); } - @Test // GH-3660 void usesCustomConverterForMapTypesOnWrite() { @@ -2481,9 +2485,8 @@ void usesCustomConverterForTypesImplementingMapOnRead() { })); converter.afterPropertiesSet(); - org.bson.Document source = new org.bson.Document("1st", "one") - .append("2nd", 2) - .append("_class", TypeImplementingMap.class.getName()); + org.bson.Document source = new org.bson.Document("1st", "one").append("2nd", 2).append("_class", + TypeImplementingMap.class.getName()); TypeImplementingMap target = converter.read(TypeImplementingMap.class, source); @@ -2518,9 +2521,8 @@ void usesCustomConverterForPropertiesUsingTypesImplementingMapOnRead() { converter.afterPropertiesSet(); org.bson.Document source = new org.bson.Document("typeImplementingMap", - new org.bson.Document("1st", "one") - .append("2nd", 2)) - .append("_class", TypeWrappingTypeImplementingMap.class.getName()); + new org.bson.Document("1st", "one").append("2nd", 2)).append("_class", + TypeWrappingTypeImplementingMap.class.getName()); TypeWrappingTypeImplementingMap target = converter.read(TypeWrappingTypeImplementingMap.class, source); @@ -2542,13 +2544,12 @@ void shouldWriteNullPropertyCorrectly() { @Test // GH-3686 void readsCollectionContainingNullValue() { - org.bson.Document source = new org.bson.Document("items", Arrays.asList(new org.bson.Document("itemKey", "i1"), null, new org.bson.Document("itemKey", "i3"))); + org.bson.Document source = new org.bson.Document("items", + Arrays.asList(new org.bson.Document("itemKey", "i1"), null, new org.bson.Document("itemKey", "i3"))); Order target = converter.read(Order.class, source); - assertThat(target.items) - .map(it -> it != null ? it.itemKey : null) - .containsExactly("i1", null, "i3"); + assertThat(target.items).map(it -> it != null ? it.itemKey : null).containsExactly("i1", null, "i3"); } @Test // GH-3686 @@ -2564,14 +2565,13 @@ void readsArrayContainingNullValue() { @Test // GH-3686 void readsMapContainingNullValue() { - org.bson.Document source = new org.bson.Document("mapOfObjects", new org.bson.Document("item1", "i1").append("item2", null).append("item3", "i3")); + org.bson.Document source = new org.bson.Document("mapOfObjects", + new org.bson.Document("item1", "i1").append("item2", null).append("item3", "i3")); ClassWithMapProperty target = converter.read(ClassWithMapProperty.class, source); - assertThat(target.mapOfObjects) - .containsEntry("item1", "i1") - .containsEntry("item2", null) - .containsEntry("item3", "i3"); + assertThat(target.mapOfObjects).containsEntry("item1", "i1").containsEntry("item2", null).containsEntry("item3", + "i3"); } @Test // GH-3670 @@ -2583,7 +2583,7 @@ void appliesCustomConverterEvenToSimpleTypes() { })); converter.afterPropertiesSet(); - org.bson.Document source = new org.bson.Document("content", new Binary(new byte[] {0x00, 0x42})); + org.bson.Document source = new org.bson.Document("content", new Binary(new byte[] { 0x00, 0x42 })); GenericType target = converter.read(GenericType.class, source); assertThat(target.content).isInstanceOf(byte[].class); @@ -2592,17 +2592,20 @@ void appliesCustomConverterEvenToSimpleTypes() { @Test // GH-3702 void readsRawDocument() { - org.bson.Document source = new org.bson.Document("_id", "id-1").append("raw", new org.bson.Document("simple", 1).append("document", new org.bson.Document("inner-doc", 1))); + org.bson.Document source = new org.bson.Document("_id", "id-1").append("raw", + new org.bson.Document("simple", 1).append("document", new org.bson.Document("inner-doc", 1))); WithRawDocumentProperties target = converter.read(WithRawDocumentProperties.class, source); - assertThat(target.raw).isInstanceOf(org.bson.Document.class).isEqualTo( new org.bson.Document("simple", 1).append("document", new org.bson.Document("inner-doc", 1))); + assertThat(target.raw).isInstanceOf(org.bson.Document.class) + .isEqualTo(new org.bson.Document("simple", 1).append("document", new org.bson.Document("inner-doc", 1))); } @Test // GH-3702 void readsListOfRawDocument() { - org.bson.Document source = new org.bson.Document("_id", "id-1").append("listOfRaw", Arrays.asList(new org.bson.Document("simple", 1).append("document", new org.bson.Document("inner-doc", 1)))); + org.bson.Document source = new org.bson.Document("_id", "id-1").append("listOfRaw", + Arrays.asList(new org.bson.Document("simple", 1).append("document", new org.bson.Document("inner-doc", 1)))); WithRawDocumentProperties target = converter.read(WithRawDocumentProperties.class, source); @@ -2613,11 +2616,12 @@ void readsListOfRawDocument() { @Test // GH-3692 void readsMapThatDoesNotComeAsDocument() { - org.bson.Document source = new org.bson.Document("_id", "id-1").append("mapOfObjects", Collections.singletonMap("simple", 1)); + org.bson.Document source = new org.bson.Document("_id", "id-1").append("mapOfObjects", + Collections.singletonMap("simple", 1)); ClassWithMapProperty target = converter.read(ClassWithMapProperty.class, source); - assertThat(target.mapOfObjects).containsEntry("simple",1); + assertThat(target.mapOfObjects).containsEntry("simple", 1); } @Test // GH-3851 @@ -2637,7 +2641,8 @@ void readsMapThatDoesNotComeAsDocument() { converter.writePropertyInternal(sourceValue, accessor, persistentProperty); - assertThat(accessor.getDocument()).isEqualTo(new org.bson.Document("pName", new org.bson.Document("_id", id.toString()))); + assertThat(accessor.getDocument()) + .isEqualTo(new org.bson.Document("pName", new org.bson.Document("_id", id.toString()))); } @Test // GH-2860 @@ -2651,8 +2656,7 @@ void projectShouldReadSimpleInterfaceProjection() { .and((target, underlyingType) -> !converter.conversions.isSimpleType(target)), mappingContext); - EntityProjection projection = discoverer - .introspect(PersonProjection.class, Person.class); + EntityProjection projection = discoverer.introspect(PersonProjection.class, Person.class); PersonProjection person = converter.project(projection, source); assertThat(person.getBirthDate()).isEqualTo(new LocalDate(1999, 12, 1)); @@ -2670,8 +2674,7 @@ void projectShouldReadSimpleDtoProjection() { .and((target, underlyingType) -> !converter.conversions.isSimpleType(target)), mappingContext); - EntityProjection projection = introspector - .introspect(PersonDto.class, Person.class); + EntityProjection projection = introspector.introspect(PersonDto.class, Person.class); PersonDto person = converter.project(projection, source); assertThat(person.getBirthDate()).isEqualTo(new LocalDate(1999, 12, 1)); @@ -2689,8 +2692,8 @@ void projectShouldReadNestedProjection() { .and((target, underlyingType) -> !converter.conversions.isSimpleType(target)), mappingContext); - EntityProjection projection = introspector - .introspect(WithNestedProjection.class, Person.class); + EntityProjection projection = introspector.introspect(WithNestedProjection.class, + Person.class); WithNestedProjection person = converter.project(projection, source); assertThat(person.getAddresses()).extracting(AddressProjection::getStreet).hasSize(1).containsOnly("hwy"); @@ -2714,6 +2717,104 @@ void projectShouldReadProjectionWithNestedEntity() { assertThat(person.getAddresses()).extracting(Address::getStreet).hasSize(1).containsOnly("hwy"); } + @Test // GH-3596 + void simpleConverter() { + + WithValueConverters wvc = new WithValueConverters(); + wvc.converterWithDefaultCtor = "spring"; + + org.bson.Document target = new org.bson.Document(); + converter.write(wvc, target); + + assertThat(target).containsEntry("converterWithDefaultCtor", new org.bson.Document("foo", "spring")); + + WithValueConverters read = converter.read(WithValueConverters.class, target); + assertThat(read.converterWithDefaultCtor).startsWith("spring"); + } + + @Test // GH-3596 + void enumConverter() { + + WithValueConverters wvc = new WithValueConverters(); + wvc.converterEnum = "spring"; + + org.bson.Document target = new org.bson.Document(); + converter.write(wvc, target); + + assertThat(target).containsEntry("converterEnum", new org.bson.Document("bar", "spring")); + + WithValueConverters read = converter.read(WithValueConverters.class, target); + assertThat(read.converterEnum).isEqualTo("spring"); + } + + @Test // GH-3596 + void beanConverter() { + + DefaultListableBeanFactory defaultListableBeanFactory = new DefaultListableBeanFactory(); + defaultListableBeanFactory.registerBeanDefinition("someDependency", + BeanDefinitionBuilder.rootBeanDefinition(SomeDependency.class).getBeanDefinition()); + + converter = new MappingMongoConverter(resolver, mappingContext); + + converter.setCustomConversions(MongoCustomConversions.create(it -> { + it.registerPropertyValueConverterFactory( + PropertyValueConverterFactory.beanFactoryAware(defaultListableBeanFactory)); + })); + converter.afterPropertiesSet(); + + WithValueConverters wvc = new WithValueConverters(); + wvc.converterBean = "spring"; + + org.bson.Document target = new org.bson.Document(); + converter.write(wvc, target); + + assertThat(target.get("converterBean", org.bson.Document.class)).satisfies(it -> { + assertThat(it).containsKey("ooo"); + assertThat((String) it.get("ooo")).startsWith("spring - "); + }); + + WithValueConverters read = converter.read(WithValueConverters.class, target); + assertThat(read.converterBean).startsWith("spring -"); + } + + @Test // GH-3596 + void pathConfiguredConverter/*no annotation required*/() { + + converter = new MappingMongoConverter(resolver, mappingContext); + + converter.setCustomConversions(MongoCustomConversions.create(it -> { + + it.configurePropertyConversions(registrar -> { + registrar.registerConverter(WithValueConverters.class, "viaRegisteredConverter", + new PropertyValueConverter() { + + @Nullable + @Override + public String read(@Nullable org.bson.Document nativeValue, MongoConversionContext context) { + return nativeValue.getString("bar"); + } + + @Nullable + @Override + public org.bson.Document write(@Nullable String domainValue, MongoConversionContext context) { + return new org.bson.Document("bar", domainValue); + } + }); + }); + })); + + WithValueConverters wvc = new WithValueConverters(); + wvc.viaRegisteredConverter = "spring"; + + org.bson.Document target = new org.bson.Document(); + converter.write(wvc, target); + + assertThat(target).containsEntry("viaRegisteredConverter", new org.bson.Document("bar", "spring")); + + WithValueConverters read = converter.read(WithValueConverters.class, target); + assertThat(read.viaRegisteredConverter).isEqualTo("spring"); + } + static class GenericType { T content; } @@ -2746,8 +2847,7 @@ interface InterfaceType { @EqualsAndHashCode @Getter static class Address implements InterfaceType { - @Field("s") - String street; + @Field("s") String street; String city; } @@ -3348,7 +3448,7 @@ static class TypeWrappingTypeImplementingMap { } @EqualsAndHashCode - static class TypeImplementingMap implements Map { + static class TypeImplementingMap implements Map { String val1; int val2; @@ -3445,4 +3545,70 @@ static class WithFieldWrite { write = org.springframework.data.mongodb.core.mapping.Field.Write.ALWAYS) Person writeAlwaysPerson; } + + static class WithValueConverters { + + @ValueConverter(Converter1.class) String converterWithDefaultCtor; + + @ValueConverter(Converter2.class) String converterEnum; + + @ValueConverter(Converter3.class) String converterBean; + + String viaRegisteredConverter; + } + + static class Converter3 implements MongoValueConverter { + + private final SomeDependency someDependency; + + public Converter3(@Autowired SomeDependency someDependency) { + this.someDependency = someDependency; + } + + @Override + public Object read(org.bson.Document value, MongoConversionContext context) { + return value.get("ooo"); + } + + @Override + public org.bson.Document write(Object value, MongoConversionContext context) { + return new org.bson.Document("ooo", value + " - " + someDependency.toString()); + } + } + + static class SomeDependency { + + } + + enum Converter2 implements MongoValueConverter { + + INSTANCE; + + @Nullable + @Override + public String read(@Nullable org.bson.Document value, MongoConversionContext context) { + return value.getString("bar"); + } + + @Nullable + @Override + public org.bson.Document write(@Nullable String value, MongoConversionContext context) { + return new org.bson.Document("bar", value); + } + } + + static class Converter1 implements MongoValueConverter { + + @Nullable + @Override + public String read(@Nullable org.bson.Document value, MongoConversionContext context) { + return value.getString("foo"); + } + + @Nullable + @Override + public org.bson.Document write(@Nullable String value, MongoConversionContext context) { + return new org.bson.Document("foo", value); + } + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MongoCustomConversionsUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MongoCustomConversionsUnitTests.java index 1509a8df77..493eabfdf0 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MongoCustomConversionsUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MongoCustomConversionsUnitTests.java @@ -16,14 +16,18 @@ package org.springframework.data.mongodb.core.convert; import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; import java.time.ZonedDateTime; import java.util.Collections; import java.util.Date; import org.junit.jupiter.api.Test; - import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.PropertyValueConverter; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mongodb.core.convert.QueryMapperUnitTests.Foo; /** * Unit tests for {@link MongoCustomConversions}. @@ -42,6 +46,24 @@ void nonAnnotatedConverterForJavaTimeTypeShouldOnlyBeRegisteredAsReadingConverte assertThat(conversions.hasCustomWriteTarget(Date.class)).isFalse(); } + @Test // GH-3596 + void propertyValueConverterRegistrationWorksAsExpected() { + + PersistentProperty persistentProperty = mock(PersistentProperty.class); + PersistentEntity owner = mock(PersistentEntity.class); + when(persistentProperty.getName()).thenReturn("name"); + when(persistentProperty.getOwner()).thenReturn(owner); + when(owner.getType()).thenReturn(Foo.class); + + MongoCustomConversions conversions = MongoCustomConversions.create(config -> { + + config.configurePropertyConversions( + registry -> registry.registerConverter(Foo.class, "name", mock(PropertyValueConverter.class))); + }); + + assertThat(conversions.hasPropertyValueConverter(persistentProperty)).isTrue(); + } + static class DateToZonedDateTimeConverter implements Converter { @Override diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/QueryMapperUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/QueryMapperUnitTests.java index b2de941355..d3b73379b2 100755 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/QueryMapperUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/QueryMapperUnitTests.java @@ -33,10 +33,10 @@ import org.bson.types.ObjectId; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; - import org.springframework.core.convert.converter.Converter; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Transient; +import org.springframework.data.convert.ValueConverter; import org.springframework.data.convert.WritingConverter; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; @@ -1428,6 +1428,13 @@ void mapStringIdFieldProjection() { assertThat(mappedQuery.get("_id")) .isEqualTo(org.bson.Document.parse("{ $in: [ {$oid: \"5b8bedceb1e0bfc07b008828\" } ]}")); } + + @Test // GH-3596 + void considersValueConverterWhenPresent() { + + org.bson.Document mappedObject = mapper.getMappedObject(new org.bson.Document("text", "value"), context.getPersistentEntity(WithPropertyValueConverter.class)); + assertThat(mappedObject).isEqualTo(new org.bson.Document("text", "eulav")); + } class WithDeepArrayNesting { @@ -1707,6 +1714,12 @@ static class Customer { static class MyAddress { private String street; } + + static class WithPropertyValueConverter { + + @ValueConverter(ReversingValueConverter.class) + String text; + } @WritingConverter public static class MyAddressToDocumentConverter implements Converter { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/ReversingValueConverter.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/ReversingValueConverter.java new file mode 100644 index 0000000000..9a90acf633 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/ReversingValueConverter.java @@ -0,0 +1,45 @@ +/* + * Copyright 2022 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.mongodb.core.convert; + +import org.springframework.lang.Nullable; + +/** + * @author Christoph Strobl + */ +class ReversingValueConverter implements MongoValueConverter { + + @Nullable + @Override + public String read(@Nullable String value, MongoConversionContext context) { + return reverse(value); + } + + @Nullable + @Override + public String write(@Nullable String value, MongoConversionContext context) { + return reverse(value); + } + + private String reverse(String source) { + + if (source == null) { + return null; + } + + return new StringBuilder(source).reverse().toString(); + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/UpdateMapperUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/UpdateMapperUnitTests.java index 3e744f675b..5cc3276d87 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/UpdateMapperUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/UpdateMapperUnitTests.java @@ -42,6 +42,7 @@ import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Transient; import org.springframework.data.convert.CustomConversions; +import org.springframework.data.convert.ValueConverter; import org.springframework.data.convert.WritingConverter; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; @@ -1341,6 +1342,15 @@ void mapNumericKeyInPathPartiallyMatchingExistingProperties() { assertThat(mappedUpdate).isEqualTo("{ $set: { 'testInnerData.testMap.1.nonExistingProperty.2.someValue': '4' }}"); } + @Test // GH-3596 + void updateConsidersValueConverterWhenPresent() { + + Update update = new Update().set("text", "value"); + Document mappedUpdate = mapper.getMappedObject(update.getUpdateObject(), context.getPersistentEntity(WithPropertyValueConverter.class)); + + assertThat(mappedUpdate).isEqualTo("{ $set : { 'text' : 'eulav' } }"); + } + static class DomainTypeWrappingConcreteyTypeHavingListOfInterfaceTypeAttributes { ListModelWrapper concreteTypeWithListAttributeOfInterfaceType; } @@ -1752,4 +1762,10 @@ private static class TestInnerData { private static class TestValue { private int intValue; } + + static class WithPropertyValueConverter { + + @ValueConverter(ReversingValueConverter.class) + String text; + } } diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index 91b4708b0f..e80f2ba290 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -5,6 +5,7 @@ == What's New in Spring Data MongoDB 3.4 * Find and update ``Document``s via <>. +* Property specific <>. [[new-features.3.3]] == What's New in Spring Data MongoDB 3.3 diff --git a/src/main/asciidoc/reference/mapping.adoc b/src/main/asciidoc/reference/mapping.adoc index ab074ba574..c94c6e64d3 100644 --- a/src/main/asciidoc/reference/mapping.adoc +++ b/src/main/asciidoc/reference/mapping.adoc @@ -900,3 +900,4 @@ Declaring these beans in your Spring ApplicationContext causes them to be invoke include::unwrapping-entities.adoc[] include::mongo-custom-conversions.adoc[] +include::mongo-property-converters.adoc[] diff --git a/src/main/asciidoc/reference/mongo-property-converters.adoc b/src/main/asciidoc/reference/mongo-property-converters.adoc new file mode 100644 index 0000000000..66b0e13742 --- /dev/null +++ b/src/main/asciidoc/reference/mongo-property-converters.adoc @@ -0,0 +1,108 @@ +[[mongo.property-converters]] +== Property Converters - Mapping specific fields + +Although to the <> already offers means to influence the representation of certain types within the target store it has its limitations when not all potential values of that type should be considered as a conversion targets. +Property based converters allow to specify conversion instructions on a per property basis either declarative, via `@ValueConverter`, or programmatic by registering a `PropertyValueConverter` for a specific field. + +A `PropertyValueConverter` is responsible of transforming a given value into its store representation (write) and back (read) as shown in the snippet below. +Please mind the presence of the `ValueConversionContext` providing additional information, such as mapping metadata. + +.PropertyValueConverter +==== +[source,java] +---- +class ReversingValueConverter implements PropertyValueConverter { + + @Override + public String read(String value, ValueConversionContext context) { + return reverse(value); + } + + @Override + public String write(String value, ValueConversionContext context) { + return reverse(value); + } +} +---- +==== + +`PropertyValueConverter` instances can be obtained via `CustomConversions#getPropertyValueConverter(...)` delegating to `PropertyValueConversions` typically using a `PropertyValueConverterFactory` to provide the actual converter. +Depending on the applications needs multiple instances of `PropertyValueConverterFactory` can be chained or decorated (eg. for caching). +By default a caching implementation is used that is capable of serving types with a default constructor or enum values. +A set of predefined factories is available via `PropertyValueConverterFactory`. +To obtain a `PropertyValueConverter` from an `ApplicationContext` make sure to use the `PropertyValueConverterFactory.beanFactoryAware(...)` factory. + +Changing the default behavior can be done via the `ConverterConfiguration`. + +=== Declarative Value Converter + +The most straight forward usage of a `PropertyValueConverter` is via the `@ValueConverter` annotation referring to the target converter type. + +.Declarative PropertyValueConverter +==== +[source,java] +---- +public class Person { + // ... + @ValueConverter(ReversingValueConverter.class) + String ssn; +} +---- +==== + +=== Programmatic Value Converter + +Following the programmatic approach does not require to put additional annotations on the domain model but registers `PropertyValueConverter` instances for certain paths in a `PropertyValueConverterRegistrar` as shown below. + +.Programmatic PropertyValueConverter +==== +[source,java] +---- +PropertyValueConverterRegistrar registrar = new PropertyValueConverterRegistrar(); + +registrar.registerConverter(Address.class, "street", new PropertyValueConverter() { ... }); <1> + +// type safe registration +registrar.registerConverter(Person.class, Person::getSsn()) <2> + .writing(value -> encrypt(value)) + .reading(value -> decrypt(value)); +---- +<1> Register a converter for the field identified by its name. +<2> Type safe variant that allows to register a converter and its conversion functions. +==== + +[WARNING] +==== +Dot notation (eg. `registerConverter(Person.class, "address.street", ...)`) is *not* supported when registering converters. +==== + +=== MongoDB property value conversions + +The above sections outlined the purpose an overall structure of `PropertyValueConverters`. +This section will focus on MongoDB specific aspects. + +==== MongoValueConverter & MongoConversionContext + +The `MongoValueConverter` offers a pre typed `PropertyValueConverter` interface leveraging the `MongoConversionContext`. + +==== MongoCustomConversions configuration + +`MongoCustomConversions` are by default capable of dealing with declarative value converters depending on the configured `PropertyValueConverterFactory`. +The `MongoConverterConfigurationAdapter` is there to help set up programmatic value conversions or define the `PropertyValueConverterFactory` to be used. + +.Configuration Sample +==== +[source,java] +---- +MongoCustomConversions.create(configurationAdapter -> { + + SimplePropertyValueConversions valueConversions = new SimplePropertyValueConversions(); + valueConversions.setConverterFactory(...); + valueConversions.setValueConverterRegistry(new PropertyValueConverterRegistrar() + .registerConverter(...) + .buildRegistry()); + + configurationAdapter.setPropertyValueConversions(valueConversions); +}); +---- +====