diff --git a/src/main/java/org/springframework/data/convert/PropertyValueConversionService.java b/src/main/java/org/springframework/data/convert/PropertyValueConversionService.java
new file mode 100644
index 000000000..4df6bdeda
--- /dev/null
+++ b/src/main/java/org/springframework/data/convert/PropertyValueConversionService.java
@@ -0,0 +1,111 @@
+/*
+ * 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.convert;
+
+import org.springframework.data.mapping.PersistentProperty;
+import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+
+/**
+ * Conversion service based on {@link CustomConversions} to convert domain and store values using
+ * {@link PropertyValueConverter property-specific converters}.
+ *
+ * @author Mark Paluch
+ * @since 2.7
+ */
+public class PropertyValueConversionService {
+
+ private final CustomConversions conversions;
+
+ public PropertyValueConversionService(CustomConversions conversions) {
+
+ Assert.notNull(conversions, "CustomConversions must not be null");
+
+ this.conversions = conversions;
+ }
+
+ /**
+ * Return {@literal true} there is a converter registered for {@link PersistentProperty}.
+ *
+ * If this method returns {@literal true}, it means {@link #read(Object, PersistentProperty, ValueConversionContext)}
+ * and {@link #write(Object, PersistentProperty, ValueConversionContext)} are capable to invoke conversion.
+ *
+ * @param property the underlying property.
+ * @return {@literal true} there is a converter registered for {@link PersistentProperty}.
+ */
+ public boolean hasConverter(PersistentProperty> property) {
+ return conversions.hasPropertyValueConverter(property);
+ }
+
+ /**
+ * Convert a value from its store-native representation into its domain-specific type.
+ *
+ * @param value the value to convert. Can be {@code null}.
+ * @param property the underlying property.
+ * @param context the context object.
+ * @param
property type.
+ * @param value conversion context type.
+ * @return the value to be used in the domain model. Can be {@code null}.
+ */
+ @Nullable
+ public , VCC extends ValueConversionContext
> Object read(@Nullable Object value,
+ P property, VCC context) {
+
+ PropertyValueConverter> converter = getRequiredConverter(property);
+
+ if (value == null) {
+ return converter.readNull(context);
+ }
+
+ return converter.read(value, context);
+ }
+
+ /**
+ * Convert a value from its domain-specific value into its store-native representation.
+ *
+ * @param value the value to convert. Can be {@code null}.
+ * @param property the underlying property.
+ * @param context the context object.
+ * @param property type.
+ * @param value conversion context type.
+ * @return the value to be written to the data store. Can be {@code null}.
+ */
+ @Nullable
+ public , VCC extends ValueConversionContext
> Object write(@Nullable Object value,
+ P property, VCC context) {
+
+ PropertyValueConverter> converter = getRequiredConverter(property);
+
+ if (value == null) {
+ return converter.writeNull(context);
+ }
+
+ return converter.write(value, context);
+ }
+
+ private > PropertyValueConverter> getRequiredConverter(
+ P property) {
+
+ PropertyValueConverter> converter = conversions
+ .getPropertyValueConverter(property);
+
+ if (converter == null) {
+ throw new IllegalArgumentException(String.format("No converter registered for property %s", property));
+ }
+
+ return converter;
+ }
+}
diff --git a/src/main/java/org/springframework/data/convert/PropertyValueConverter.java b/src/main/java/org/springframework/data/convert/PropertyValueConverter.java
index f89aba108..b58532d7b 100644
--- a/src/main/java/org/springframework/data/convert/PropertyValueConverter.java
+++ b/src/main/java/org/springframework/data/convert/PropertyValueConverter.java
@@ -26,8 +26,13 @@ import org.springframework.lang.Nullable;
*
* A {@link PropertyValueConverter} is, other than a {@link ReadingConverter} or {@link WritingConverter}, only applied
* to special annotated fields which allows a fine-grained conversion of certain values within a specific context.
+ *
+ * Converter methods are called with non-null values only and provide specific hooks for {@code null} value handling.
+ * {@link #readNull(ValueConversionContext)} and {@link #writeNull(ValueConversionContext)} methods are specifically
+ * designated to either retain {@code null} values or return a different value to indicate {@code null} values.
*
* @author Christoph Strobl
+ * @author Mark Paluch
* @param domain-specific type.
* @param store-native type.
* @param the store specific {@link ValueConversionContext conversion context}.
@@ -39,23 +44,47 @@ public interface PropertyValueConverter context) {
+ public SV write(DV value, ValueConversionContext context) {
return writer.apply(value, context);
}
+ @Override
+ public SV writeNull(ValueConversionContext
context) {
+ return writer.apply(null, context);
+ }
+
@Nullable
@Override
public DV read(@Nullable SV value, ValueConversionContext
context) {
return reader.apply(value, context);
}
+
+ @Override
+ public DV readNull(ValueConversionContext
context) {
+ return reader.apply(null, context);
+ }
}
}
diff --git a/src/test/benchmark/org/springframework/data/convert/PropertyValueConversionServiceUnitTests.java b/src/test/benchmark/org/springframework/data/convert/PropertyValueConversionServiceUnitTests.java
new file mode 100644
index 000000000..cab3f8f20
--- /dev/null
+++ b/src/test/benchmark/org/springframework/data/convert/PropertyValueConversionServiceUnitTests.java
@@ -0,0 +1,138 @@
+/*
+ * 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.convert;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.data.mapping.Person;
+import org.springframework.data.mapping.context.SampleMappingContext;
+import org.springframework.data.mapping.context.SamplePersistentProperty;
+import org.springframework.data.mapping.model.BasicPersistentEntity;
+import org.springframework.data.util.Predicates;
+
+/**
+ * Unit tests for {@link PropertyValueConversionService}.
+ *
+ * @author Mark Paluch
+ */
+class PropertyValueConversionServiceUnitTests {
+
+ SampleMappingContext mappingContext = new SampleMappingContext();
+
+ PropertyValueConversions conversions = PropertyValueConversions.simple(it -> {
+ it.registerConverter(Person.class, "firstName", String.class).writing(w -> "Writing " + w)
+ .reading(r -> "Reading " + r);
+ });
+ PropertyValueConversionService service = createConversionService(conversions);
+
+ @Test // GH-2557
+ void shouldReportConverter() {
+
+ BasicPersistentEntity entity = mappingContext
+ .getRequiredPersistentEntity(Person.class);
+
+ assertThat(service.hasConverter(entity.getRequiredPersistentProperty("firstName"))).isTrue();
+ assertThat(service.hasConverter(entity.getRequiredPersistentProperty("lastName"))).isFalse();
+ }
+
+ @Test // GH-2557
+ void conversionWithoutConverterShouldFail() {
+
+ BasicPersistentEntity entity = mappingContext
+ .getRequiredPersistentEntity(Person.class);
+
+ SamplePersistentProperty property = entity.getRequiredPersistentProperty("lastName");
+ assertThatIllegalArgumentException().isThrownBy(() -> service.read("foo", property, () -> property));
+ assertThatIllegalArgumentException().isThrownBy(() -> service.write("foo", property, () -> property));
+ }
+
+ @Test // GH-2557
+ void readShouldUseReadConverter() {
+
+ BasicPersistentEntity entity = mappingContext
+ .getRequiredPersistentEntity(Person.class);
+
+ SamplePersistentProperty property = entity.getRequiredPersistentProperty("firstName");
+ assertThat(service.read("Walter", property, () -> property)).isEqualTo("Reading Walter");
+ assertThat(service.read(null, property, () -> property)).isEqualTo("Reading null");
+ }
+
+ @Test // GH-2557
+ void readShouldUseWriteConverter() {
+
+ BasicPersistentEntity entity = mappingContext
+ .getRequiredPersistentEntity(Person.class);
+
+ SamplePersistentProperty property = entity.getRequiredPersistentProperty("firstName");
+ assertThat(service.write("Walter", property, () -> property)).isEqualTo("Writing Walter");
+ assertThat(service.write(null, property, () -> property)).isEqualTo("Writing null");
+ }
+
+ @Test // GH-2557
+ void readShouldUseNullConvertersConverter() {
+
+ PropertyValueConversions conversions = PropertyValueConversions.simple(it -> {
+ it.registerConverter(Person.class, "firstName", WithNullConverters.INSTANCE);
+ });
+
+ PropertyValueConversionService service = createConversionService(conversions);
+
+ BasicPersistentEntity entity = mappingContext
+ .getRequiredPersistentEntity(Person.class);
+
+ SamplePersistentProperty property = entity.getRequiredPersistentProperty("firstName");
+
+ assertThat(service.read(null, property, () -> property)).isEqualTo("readNull");
+ assertThat(service.write(null, property, () -> property)).isEqualTo("writeNull");
+ }
+
+ private static PropertyValueConversionService createConversionService(PropertyValueConversions conversions) {
+
+ CustomConversions.ConverterConfiguration configuration = new CustomConversions.ConverterConfiguration(
+ CustomConversions.StoreConversions.NONE, Collections.emptyList(), Predicates.isTrue(), conversions);
+
+ return new PropertyValueConversionService(new CustomConversions(configuration));
+ }
+
+ enum WithNullConverters implements PropertyValueConverter> {
+ INSTANCE;
+
+ @Override
+ public String read(String value, ValueConversionContext> context) {
+ return value;
+ }
+
+ @Override
+ public String readNull(ValueConversionContext> context) {
+ return "readNull";
+ }
+
+ @Override
+ public String write(String value, ValueConversionContext> context) {
+ return value;
+ }
+
+ @Override
+ public String writeNull(ValueConversionContext> context) {
+ return "writeNull";
+ }
+ }
+
+}
diff --git a/src/test/java/org/springframework/data/convert/PropertyValueConverterFactoryUnitTests.java b/src/test/java/org/springframework/data/convert/PropertyValueConverterFactoryUnitTests.java
index 89961a326..c8883157e 100644
--- a/src/test/java/org/springframework/data/convert/PropertyValueConverterFactoryUnitTests.java
+++ b/src/test/java/org/springframework/data/convert/PropertyValueConverterFactoryUnitTests.java
@@ -226,13 +226,13 @@ public class PropertyValueConverterFactoryUnitTests {
@Nullable
@Override
- public String read(@Nullable UUID value, ValueConversionContext context) {
+ public String read(UUID value, ValueConversionContext context) {
return value.toString();
}
@Nullable
@Override
- public UUID write(@Nullable String value, ValueConversionContext context) {
+ public UUID write(String value, ValueConversionContext context) {
return UUID.fromString(value);
}
}
@@ -248,7 +248,7 @@ public class PropertyValueConverterFactoryUnitTests {
@Nullable
@Override
- public String read(@Nullable UUID value, ValueConversionContext context) {
+ public String read(UUID value, ValueConversionContext context) {
assertThat(someDependency).isNotNull();
return value.toString();
@@ -256,7 +256,7 @@ public class PropertyValueConverterFactoryUnitTests {
@Nullable
@Override
- public UUID write(@Nullable String value, ValueConversionContext context) {
+ public UUID write(String value, ValueConversionContext context) {
assertThat(someDependency).isNotNull();
return UUID.fromString(value);