From 5cdb3139a4a224417899ebaa00dbb7192e8899a7 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Mon, 17 Dec 2018 18:20:33 +0100 Subject: [PATCH] DATACMNS-1610 - Improved property path access. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We now expose a dedicated PersistentPropertyPathAccessor and optionally take options for both the read and write access to properties. On read, clients can define to eagerly receive null in case one of the path segments is null. By default, we reject those as it indicates that there might be an issue with the backing object if the client assumes it can look up the more deeply nested path to receive a result. On write access clients can define to either reject, skip or log intermediate null segments where skip means, that the setting is just silently aborted. Clients can also control how to deal with collection and map intermediates. By default we now propagate the attempt to set a value to all collection or map items respectively. This could be potentially extended to provide a filter receiving both the property and property value to selectively propagate those calls in the future. The separation of these APIs (PersistentPropertyAccessor and PersistentPropertyPathAccessor) is introduced as the latter can be implemented on top of the former and the former potentially being dynamically generated. The logic of the latter can then be clearly separated from the actual individual property lookup. ConvertingPropertyAccessor is now a PersistentPropertyPathAccessor, too, and overrides SimplePersistentPropertyPathAccessor.getTypedProperty(…) to plug in conversion logic. This allows custom collection types being used if the ConversionService backing the accessor can convert from and to Collection or Map respectively. Deprecated all PersistentPropertyPath-related methods on PersistentPropertyAccessor for removal in 2.3. MappingAuditableBeanWrapperFactory now uses these new settings to skip both null values as well as collection and map values when setting auditing metadata. Related tickets: DATACMNS-1438, DATACMNS-1461, DATACMNS-1609. --- .../MappingAuditableBeanWrapperFactory.java | 38 +- .../data/mapping/AccessOptions.java | 333 ++++++++++++++++++ .../data/mapping/PersistentEntity.java | 9 + .../data/mapping/PersistentProperty.java | 14 + .../mapping/PersistentPropertyAccessor.java | 19 +- .../PersistentPropertyPathAccessor.java | 78 ++++ .../mapping/model/BasicPersistentEntity.java | 9 + .../model/ConvertingPropertyAccessor.java | 33 +- .../SimplePersistentPropertyPathAccessor.java | 277 +++++++++++++++ .../ConvertingPropertyAccessorUnitTests.java | 36 ++ ...rsistentPropertyPathAccessorUnitTests.java | 127 +++++++ 11 files changed, 933 insertions(+), 40 deletions(-) create mode 100644 src/main/java/org/springframework/data/mapping/AccessOptions.java create mode 100644 src/main/java/org/springframework/data/mapping/PersistentPropertyPathAccessor.java create mode 100644 src/main/java/org/springframework/data/mapping/model/SimplePersistentPropertyPathAccessor.java create mode 100644 src/test/java/org/springframework/data/mapping/model/SimplePersistentPropertyPathAccessorUnitTests.java diff --git a/src/main/java/org/springframework/data/auditing/MappingAuditableBeanWrapperFactory.java b/src/main/java/org/springframework/data/auditing/MappingAuditableBeanWrapperFactory.java index a89df7ec2..3595212d4 100644 --- a/src/main/java/org/springframework/data/auditing/MappingAuditableBeanWrapperFactory.java +++ b/src/main/java/org/springframework/data/auditing/MappingAuditableBeanWrapperFactory.java @@ -27,10 +27,13 @@ import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedBy; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.domain.Auditable; -import org.springframework.data.mapping.MappingException; +import org.springframework.data.mapping.AccessOptions; +import org.springframework.data.mapping.AccessOptions.SetOptions; +import org.springframework.data.mapping.AccessOptions.SetOptions.Propagation; import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.PersistentPropertyPathAccessor; import org.springframework.data.mapping.PersistentPropertyPaths; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mapping.context.PersistentEntities; @@ -84,7 +87,7 @@ public class MappingAuditableBeanWrapperFactory extends DefaultAuditableBeanWrap key -> new MappingAuditingMetadata(context, it.getClass())); return Optional.> ofNullable(metadata.isAuditable() // - ? new MappingMetadataAuditableBeanWrapper(entity.getPropertyAccessor(it), metadata) + ? new MappingMetadataAuditableBeanWrapper(entity.getPropertyPathAccessor(it), metadata) : null); }).orElseGet(() -> super.getBeanWrapperFor(source)); @@ -160,7 +163,11 @@ public class MappingAuditableBeanWrapperFactory extends DefaultAuditableBeanWrap */ static class MappingMetadataAuditableBeanWrapper extends DateConvertingAuditableBeanWrapper { - private final PersistentPropertyAccessor accessor; + private static final SetOptions OPTIONS = AccessOptions.defaultSetOptions() // + .skipNulls() // ; + .withCollectionAndMapPropagation(Propagation.SKIP); + + private final PersistentPropertyPathAccessor accessor; private final MappingAuditingMetadata metadata; /** @@ -170,7 +177,7 @@ public class MappingAuditableBeanWrapperFactory extends DefaultAuditableBeanWrap * @param accessor must not be {@literal null}. * @param metadata must not be {@literal null}. */ - public MappingMetadataAuditableBeanWrapper(PersistentPropertyAccessor accessor, + public MappingMetadataAuditableBeanWrapper(PersistentPropertyPathAccessor accessor, MappingAuditingMetadata metadata) { Assert.notNull(accessor, "PersistentPropertyAccessor must not be null!"); @@ -241,20 +248,7 @@ public class MappingAuditableBeanWrapperFactory extends DefaultAuditableBeanWrap private > S setProperty( PersistentPropertyPaths> paths, S value) { - paths.forEach(it -> { - - try { - - this.accessor.setProperty(it, value); - - } catch (MappingException o_O) { - - // Ignore null intermediate errors temporarily - if (!o_O.getMessage().contains("on null intermediate")) { - throw o_O; - } - } - }); + paths.forEach(it -> this.accessor.setProperty(it, value, OPTIONS)); return value; } @@ -262,8 +256,12 @@ public class MappingAuditableBeanWrapperFactory extends DefaultAuditableBeanWrap private

> TemporalAccessor setDateProperty( PersistentPropertyPaths> property, TemporalAccessor value) { - property.forEach(it -> this.accessor.setProperty(it, - getDateValueToSet(value, it.getRequiredLeafProperty().getType(), accessor.getBean()))); + property.forEach(it -> { + + Class type = it.getRequiredLeafProperty().getType(); + + this.accessor.setProperty(it, getDateValueToSet(value, type, accessor.getBean()), OPTIONS); + }); return value; } diff --git a/src/main/java/org/springframework/data/mapping/AccessOptions.java b/src/main/java/org/springframework/data/mapping/AccessOptions.java new file mode 100644 index 000000000..0a09dc9df --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/AccessOptions.java @@ -0,0 +1,333 @@ +/* + * Copyright 2020 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.mapping; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.With; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import org.springframework.data.mapping.AccessOptions.SetOptions.SetNulls; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Access options when using {@link PersistentPropertyPathAccessor} to get and set properties. Allows defining how to + * handle {@literal null} values, register custom transforming handlers when accessing collections and maps and + * propagation settings for how to handle intermediate collection and map values when setting values. + * + * @author Oliver Drotbohm + * @since 2.3 + */ +public class AccessOptions { + + /** + * Returns the default {@link SetOptions} rejecting setting values when finding an intermediate property value to be + * {@literal null}. + * + * @return + */ + public static SetOptions defaultSetOptions() { + return SetOptions.DEFAULT; + } + + /** + * Returns the default {@link GetOptions} rejecting intermediate {@literal null} values when accessing property paths. + * + * @return + */ + public static GetOptions defaultGetOptions() { + return GetOptions.DEFAULT; + } + + /** + * Access options for getting values for property paths. + * + * @author Oliver Drotbohm + */ + @AllArgsConstructor + public static class GetOptions { + + private static final GetOptions DEFAULT = new GetOptions(new HashMap<>(), GetNulls.REJECT); + + private final Map, Function> handlers; + private final @With @Getter GetNulls nullValues; + + /** + * How to handle null values during a {@link PersistentPropertyPath} traversal. + * + * @author Oliver Drotbohm + */ + public enum GetNulls { + + /** + * Reject the path lookup as a {@literal null} value cannot be traversed any further. + */ + REJECT, + + /** + * Returns {@literal null} as the entire path's traversal result. + */ + EARLY_RETURN; + + public SetOptions.SetNulls toNullHandling() { + return REJECT == this ? SetNulls.REJECT : SetNulls.SKIP; + } + } + + /** + * Registers a {@link Function} to post-process values for the given property. + * + * @param property must not be {@literal null}. + * @param handler must not be {@literal null}. + * @return + */ + public GetOptions registerHandler(PersistentProperty property, Function handler) { + + Assert.notNull(property, "Property must not be null!"); + Assert.notNull(handler, "Handler must not be null!"); + + Map, Function> newHandlers = new HashMap<>(handlers); + newHandlers.put(property, handler); + + return new GetOptions(newHandlers, nullValues); + } + + /** + * Registers a {@link Function} to handle {@link Collection} values for the given property. + * + * @param property must not be {@literal null}. + * @param handler must not be {@literal null}. + * @return + */ + @SuppressWarnings("unchecked") + public GetOptions registerCollectionHandler(PersistentProperty property, + Function, Object> handler) { + return registerHandler(property, Collection.class, (Function) handler); + } + + /** + * Registers a {@link Function} to handle {@link List} values for the given property. + * + * @param property must not be {@literal null}. + * @param handler must not be {@literal null}. + * @return + */ + @SuppressWarnings("unchecked") + public GetOptions registerListHandler(PersistentProperty property, Function, Object> handler) { + return registerHandler(property, List.class, (Function) handler); + } + + /** + * Registers a {@link Function} to handle {@link Set} values for the given property. + * + * @param property must not be {@literal null}. + * @param handler must not be {@literal null}. + * @return + */ + @SuppressWarnings("unchecked") + public GetOptions registerSetHandler(PersistentProperty property, Function, Object> handler) { + return registerHandler(property, Set.class, (Function) handler); + } + + /** + * Registers a {@link Function} to handle {@link Map} values for the given property. + * + * @param property must not be {@literal null}. + * @param handler must not be {@literal null}. + * @return + */ + @SuppressWarnings("unchecked") + public GetOptions registerMapHandler(PersistentProperty property, Function, Object> handler) { + return registerHandler(property, Map.class, (Function) handler); + } + + /** + * Registers the given {@link Function} to post-process values obtained for the given {@link PersistentProperty} for + * the given type. + * + * @param the type of the value to handle. + * @param property must not be {@literal null}. + * @param type must not be {@literal null}. + * @param handler must not be {@literal null}. + * @return + */ + public GetOptions registerHandler(PersistentProperty property, Class type, + Function handler) { + + Assert.isTrue(type.isAssignableFrom(property.getType()), () -> String + .format("Cannot register a property handler for %s on a property of type %s!", type, property.getType())); + + Function caster = it -> type.cast(it); + + return registerHandler(property, caster.andThen(handler)); + } + + /** + * Post-processes the value obtained for the given {@link PersistentProperty} using the registered handler. + * + * @param property must not be {@literal null}. + * @param value can be {@literal null}. + * @return the post-processed value or the value itself if no handlers registered. + */ + @Nullable + Object postProcess(PersistentProperty property, @Nullable Object value) { + + Function handler = handlers.get(property); + + return handler == null ? value : handler.apply(value); + } + } + + /** + * Access options for setting values for property paths. + * + * @author Oliver Drotbohm + */ + @With + @AllArgsConstructor + public static class SetOptions { + + /** + * How to handle intermediate {@literal null} values when setting + * + * @author Oliver Drotbohm + */ + public enum SetNulls { + + /** + * Reject {@literal null} values detected when traversing a path to eventually set the leaf property. This will + * cause a {@link MappingException} being thrown in that case. + */ + REJECT, + + /** + * Skip setting the value but log an info message to leave a trace why the value wasn't actually set. + */ + SKIP_AND_LOG, + + /** + * Silently skip the attempt to set the value. + */ + SKIP; + } + + /** + * How to propagate setting values that cross collection and map properties. + * + * @author Oliver Drotbohm + */ + public enum Propagation { + + /** + * Skip the setting of values when encountering a collection or map value within the path to traverse. + */ + SKIP, + + /** + * Propagate the setting of values when encountering a collection or map value and set it on all collection or map + * members. + */ + PROPAGATE; + } + + private static final SetOptions DEFAULT = new SetOptions(); + + private final @Getter SetNulls nullHandling; + private final Propagation collectionPropagation, mapPropagation; + + private SetOptions() { + + this.nullHandling = SetNulls.REJECT; + this.collectionPropagation = Propagation.PROPAGATE; + this.mapPropagation = Propagation.PROPAGATE; + } + + /** + * Returns a new {@link AccessOptions} that will cause paths that contain {@literal null} values to be skipped when + * setting a property. + * + * @return + */ + public SetOptions skipNulls() { + return withNullHandling(SetNulls.SKIP); + } + + /** + * Returns a new {@link AccessOptions} that will cause paths that contain {@literal null} values to be skipped when + * setting a property but a log message produced in TRACE level. + * + * @return + */ + public SetOptions skipAndLogNulls() { + return withNullHandling(SetNulls.SKIP_AND_LOG); + } + + /** + * Returns a new {@link AccessOptions} that will cause paths that contain {@literal null} values to be skipped when + * setting a property. + * + * @return + */ + public SetOptions rejectNulls() { + return withNullHandling(SetNulls.REJECT); + } + + /** + * Shortcut to configure the same {@link Propagation} for both collection and map property path segments. + * + * @param propagation must not be {@literal null}. + * @return + */ + public SetOptions withCollectionAndMapPropagation(Propagation propagation) { + + Assert.notNull(propagation, "Propagation must not be null!"); + + return withCollectionPropagation(propagation) // + .withMapPropagation(propagation); + } + + /** + * Returns whether the given property is supposed to be propagated, i.e. if values for it are supposed to be set at + * all. + * + * @param property can be {@literal null}. + * @return + */ + public boolean propagate(@Nullable PersistentProperty property) { + + if (property == null) { + return true; + } + + if (property.isCollectionLike() && collectionPropagation.equals(Propagation.SKIP)) { + return false; + } + + if (property.isMap() && mapPropagation.equals(Propagation.SKIP)) { + return false; + } + + return true; + } + } +} diff --git a/src/main/java/org/springframework/data/mapping/PersistentEntity.java b/src/main/java/org/springframework/data/mapping/PersistentEntity.java index 75c2312a9..f592257a5 100644 --- a/src/main/java/org/springframework/data/mapping/PersistentEntity.java +++ b/src/main/java/org/springframework/data/mapping/PersistentEntity.java @@ -288,6 +288,15 @@ public interface PersistentEntity> extends It */ PersistentPropertyAccessor getPropertyAccessor(B bean); + /** + * Returns a {@link PersistentPropertyPathAccessor} to access property values of the given bean. + * + * @param bean must not be {@literal null}. + * @return a new {@link PersistentPropertyPathAccessor} + * @since 2.3 + */ + PersistentPropertyPathAccessor getPropertyPathAccessor(B bean); + /** * Returns the {@link IdentifierAccessor} for the given bean. * diff --git a/src/main/java/org/springframework/data/mapping/PersistentProperty.java b/src/main/java/org/springframework/data/mapping/PersistentProperty.java index eed92cbbe..33d9dd988 100644 --- a/src/main/java/org/springframework/data/mapping/PersistentProperty.java +++ b/src/main/java/org/springframework/data/mapping/PersistentProperty.java @@ -392,4 +392,18 @@ public interface PersistentProperty

> { */ @Nullable Class getAssociationTargetType(); + + /** + * Returns a {@link PersistentPropertyAccessor} for the current property's owning value. + * + * @param owner must not be {@literal null}. + * @return will never be {@literal null}. + * @since 2.3 + */ + default PersistentPropertyAccessor getAccessorForOwner(T owner) { + + Assert.notNull(owner, "Owner must not be null!"); + + return getOwner().getPropertyAccessor(owner); + } } diff --git a/src/main/java/org/springframework/data/mapping/PersistentPropertyAccessor.java b/src/main/java/org/springframework/data/mapping/PersistentPropertyAccessor.java index 3089028c0..03297d998 100644 --- a/src/main/java/org/springframework/data/mapping/PersistentPropertyAccessor.java +++ b/src/main/java/org/springframework/data/mapping/PersistentPropertyAccessor.java @@ -52,7 +52,10 @@ public interface PersistentPropertyAccessor { * @param path must not be {@literal null} or empty. * @param value can be {@literal null}. * @since 2.1 + * @deprecated since 2.3, use {@link PersistentPropertyPathAccessor#setProperty(PersistentPropertyPath, Object)} + * instead. */ + @Deprecated default void setProperty(PersistentPropertyPath> path, @Nullable Object value) { Assert.notNull(path, "PersistentPropertyPath must not be null!"); @@ -60,6 +63,12 @@ public interface PersistentPropertyAccessor { PersistentPropertyPath> parentPath = path.getParentPath(); PersistentProperty leafProperty = path.getRequiredLeafProperty(); + PersistentProperty parentProperty = parentPath.isEmpty() ? null : parentPath.getLeafProperty(); + + if (parentProperty != null && (parentProperty.isCollectionLike() || parentProperty.isMap())) { + throw new MappingException( + String.format("Cannot traverse collection or map intermediate %s", parentPath.toDotPath())); + } Object parent = parentPath.isEmpty() ? getBean() : getProperty(parentPath); @@ -67,8 +76,8 @@ public interface PersistentPropertyAccessor { String nullIntermediateMessage = "Cannot lookup property %s on null intermediate! Original path was: %s on %s."; - throw new MappingException(String.format(nullIntermediateMessage, parentPath.getLeafProperty(), path.toDotPath(), - getBean().getClass().getName())); + throw new MappingException( + String.format(nullIntermediateMessage, parentProperty, path.toDotPath(), getBean().getClass().getName())); } PersistentPropertyAccessor accessor = parent == getBean() // @@ -104,7 +113,9 @@ public interface PersistentPropertyAccessor { * @param path must not be {@literal null}. * @return * @since 2.1 + * @deprecated since 2.3, use {@link PersistentPropertyPathAccessor#getProperty(PersistentPropertyPath)} instead */ + @Deprecated @Nullable default Object getProperty(PersistentPropertyPath> path) { return getProperty(path, new TraversalContext()); @@ -120,8 +131,12 @@ public interface PersistentPropertyAccessor { * @param context must not be {@literal null}. * @return * @since 2.2 + * @deprecated since 2.3, use + * {@link PersistentPropertyPathAccessor#getProperty(PersistentPropertyPath, org.springframework.data.mapping.AccessOptions.GetOptions)} + * instead. */ @Nullable + @Deprecated default Object getProperty(PersistentPropertyPath> path, TraversalContext context) { Object bean = getBean(); diff --git a/src/main/java/org/springframework/data/mapping/PersistentPropertyPathAccessor.java b/src/main/java/org/springframework/data/mapping/PersistentPropertyPathAccessor.java new file mode 100644 index 000000000..da667f526 --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/PersistentPropertyPathAccessor.java @@ -0,0 +1,78 @@ +/* + * Copyright 2018-2020 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.mapping; + +import org.springframework.data.mapping.AccessOptions.GetOptions; +import org.springframework.data.mapping.AccessOptions.SetOptions; +import org.springframework.lang.Nullable; + +/** + * Extension of {@link PersistentPropertyAccessor} that is also able to obtain and set values for + * {@link PersistentPropertyPath}s. + * + * @author Oliver Drotbohm + * @since 2.3 + * @soundtrack Sophia Wahnschaffe - Leuchten (Live Session) https://www.youtube.com/watch?v=izZxWiPto5Q + */ +public interface PersistentPropertyPathAccessor extends PersistentPropertyAccessor { + + /** + * Return the value pointed to by the given {@link PersistentPropertyPath}. If the given path is empty, the wrapped + * bean is returned. + * + * @param path must not be {@literal null}. + * @return + */ + @Nullable + @SuppressWarnings("deprecation") + default Object getProperty(PersistentPropertyPath> path) { + return PersistentPropertyAccessor.super.getProperty(path); + } + + /** + * Return the value pointed to by the given {@link PersistentPropertyPath}. If the given path is empty, the wrapped + * bean is returned. On each path segment value lookup, the resulting value is post-processed by handlers registered + * on the given {@link TraversalContext} context. This can be used to unwrap container types that are encountered + * during the traversal. + * + * @param path must not be {@literal null}. + * @param context must not be {@literal null}. + * @return + */ + @Nullable + Object getProperty(PersistentPropertyPath> path, GetOptions context); + + /** + * Sets the given value for the {@link PersistentProperty} pointed to by the given {@link PersistentPropertyPath}. The + * lookup of intermediate values must not yield {@literal null}. + * + * @param path must not be {@literal null} or empty. + * @param value can be {@literal null}. + * @see AccessOptions#DEFAULT + */ + void setProperty(PersistentPropertyPath> path, @Nullable Object value); + + /** + * Sets the given value for the {@link PersistentProperty} pointed to by the given {@link PersistentPropertyPath} + * considering the given {@link AccessOptions}. + * + * @param path must not be {@literal null}. + * @param value must not be {@literal null}. + * @param options must not be {@literal null}. + */ + void setProperty(PersistentPropertyPath> path, @Nullable Object value, + SetOptions options); +} diff --git a/src/main/java/org/springframework/data/mapping/model/BasicPersistentEntity.java b/src/main/java/org/springframework/data/mapping/model/BasicPersistentEntity.java index 68539e7f2..a25110144 100644 --- a/src/main/java/org/springframework/data/mapping/model/BasicPersistentEntity.java +++ b/src/main/java/org/springframework/data/mapping/model/BasicPersistentEntity.java @@ -455,6 +455,15 @@ public class BasicPersistentEntity> implement return propertyAccessorFactory.getPropertyAccessor(this, bean); } + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.PersistentEntity#getPropertyPathAccessor(java.lang.Object) + */ + @Override + public PersistentPropertyPathAccessor getPropertyPathAccessor(B bean) { + return new SimplePersistentPropertyPathAccessor<>(getPropertyAccessor(bean)); + } + /* * (non-Javadoc) * @see org.springframework.data.mapping.PersistentEntity#getIdentifierAccessor(java.lang.Object) diff --git a/src/main/java/org/springframework/data/mapping/model/ConvertingPropertyAccessor.java b/src/main/java/org/springframework/data/mapping/model/ConvertingPropertyAccessor.java index 2e744f080..758c84561 100644 --- a/src/main/java/org/springframework/data/mapping/model/ConvertingPropertyAccessor.java +++ b/src/main/java/org/springframework/data/mapping/model/ConvertingPropertyAccessor.java @@ -31,7 +31,7 @@ import org.springframework.util.Assert; * @author Oliver Gierke * @author Mark Paluch */ -public class ConvertingPropertyAccessor implements PersistentPropertyAccessor { +public class ConvertingPropertyAccessor extends SimplePersistentPropertyPathAccessor { private final PersistentPropertyAccessor accessor; private final ConversionService conversionService; @@ -45,6 +45,8 @@ public class ConvertingPropertyAccessor implements PersistentPropertyAccessor */ public ConvertingPropertyAccessor(PersistentPropertyAccessor accessor, ConversionService conversionService) { + super(accessor); + Assert.notNull(accessor, "PersistentPropertyAccessor must not be null!"); Assert.notNull(conversionService, "ConversionService must not be null!"); @@ -70,17 +72,7 @@ public class ConvertingPropertyAccessor implements PersistentPropertyAccessor Object converted = convertIfNecessary(value, path.getRequiredLeafProperty().getType()); - PersistentPropertyAccessor.super.setProperty(path, converted); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.mapping.PersistentPropertyAccessor#getProperty(org.springframework.data.mapping.PersistentProperty) - */ - @Nullable - @Override - public Object getProperty(PersistentProperty property) { - return accessor.getProperty(property); + super.setProperty(path, converted); } /** @@ -99,13 +91,14 @@ public class ConvertingPropertyAccessor implements PersistentPropertyAccessor return convertIfNecessary(getProperty(property), targetType); } - /* + /* * (non-Javadoc) - * @see org.springframework.data.mapping.PersistentPropertyAccessor#getBean() + * @see org.springframework.data.mapping.model.SimplePersistentPropertyPathAccessor#getTypedProperty(org.springframework.data.mapping.PersistentProperty, java.lang.Class) */ + @Nullable @Override - public T getBean() { - return accessor.getBean(); + protected S getTypedProperty(PersistentProperty property, Class type) { + return convertIfNecessary(super.getTypedProperty(property, type), type); } /** @@ -119,7 +112,11 @@ public class ConvertingPropertyAccessor implements PersistentPropertyAccessor @Nullable @SuppressWarnings("unchecked") private S convertIfNecessary(@Nullable Object source, Class type) { - return (S) (source == null ? null - : type.isAssignableFrom(source.getClass()) ? source : conversionService.convert(source, type)); + + return (S) (source == null // + ? null // + : type.isAssignableFrom(source.getClass()) // + ? source // + : conversionService.convert(source, type)); } } diff --git a/src/main/java/org/springframework/data/mapping/model/SimplePersistentPropertyPathAccessor.java b/src/main/java/org/springframework/data/mapping/model/SimplePersistentPropertyPathAccessor.java new file mode 100644 index 000000000..2daca33a2 --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/model/SimplePersistentPropertyPathAccessor.java @@ -0,0 +1,277 @@ +/* + * Copyright 2018-2020 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.mapping.model; + +import static org.springframework.data.mapping.AccessOptions.SetOptions.SetNulls.*; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Collection; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +import org.springframework.core.CollectionFactory; +import org.springframework.data.mapping.AccessOptions; +import org.springframework.data.mapping.AccessOptions.GetOptions; +import org.springframework.data.mapping.AccessOptions.SetOptions; +import org.springframework.data.mapping.AccessOptions.SetOptions.SetNulls; +import org.springframework.data.mapping.MappingException; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.PersistentPropertyPath; +import org.springframework.data.mapping.PersistentPropertyPathAccessor; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * {@link PersistentPropertyPathAccessor} that propagates attempts to set property values through collections and map + * values. I.e. if a {@link PersistentPropertyPath} contains a path segment pointing to a collection or map based + * property, the nested property will be set on all collection elements and map values. + * + * @author Oliver Gierke + * @since 2.3 + * @soundtrack Ron Spielman - Nineth Song (Tip of My Tongue) + */ +@Slf4j +@RequiredArgsConstructor +class SimplePersistentPropertyPathAccessor implements PersistentPropertyPathAccessor { + + private final @NonNull PersistentPropertyAccessor delegate; + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.PersistentPropertyAccessor#getBean() + */ + @Override + public T getBean() { + return delegate.getBean(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.PersistentPropertyAccessor#getProperty(org.springframework.data.mapping.PersistentProperty) + */ + @Nullable + @Override + public Object getProperty(PersistentProperty property) { + return delegate.getProperty(property); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.PersistentPropertyPathAccessor#getProperty(org.springframework.data.mapping.PersistentPropertyPath) + */ + @Nullable + @Override + public Object getProperty(PersistentPropertyPath> path) { + return getProperty(path, AccessOptions.defaultGetOptions()); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.PersistentPropertyPathAccessor#getProperty(org.springframework.data.mapping.PersistentPropertyPath, org.springframework.data.mapping.PersistentPropertyPathAccessor.Options) + */ + @Nullable + @Override + public Object getProperty(PersistentPropertyPath> path, GetOptions options) { + + Object bean = getBean(); + Object current = bean; + + if (path.isEmpty()) { + return bean; + } + + for (PersistentProperty property : path) { + + if (current == null) { + return handleNull(path, options.getNullValues().toNullHandling()); + } + + PersistentEntity> entity = property.getOwner(); + PersistentPropertyAccessor accessor = entity.getPropertyAccessor(current); + + current = accessor.getProperty(property); + } + + return current; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.PersistentPropertyAccessor#setProperty(org.springframework.data.mapping.PersistentProperty, java.lang.Object) + */ + @Override + public void setProperty(PersistentProperty property, @Nullable Object value) { + delegate.setProperty(property, value); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.PersistentPropertyPathAccessor#setProperty(org.springframework.data.mapping.PersistentPropertyPath, java.lang.Object) + */ + @Override + public void setProperty(PersistentPropertyPath> path, @Nullable Object value) { + setProperty(path, value, AccessOptions.defaultSetOptions()); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.model.ConvertingPropertyAccessor#setProperty(org.springframework.data.mapping.PersistentPropertyPath, java.lang.Object) + */ + @SuppressWarnings("unchecked") + @Override + public void setProperty(PersistentPropertyPath> path, @Nullable Object value, + SetOptions options) { + + Assert.notNull(path, "PersistentPropertyPath must not be null!"); + Assert.isTrue(!path.isEmpty(), "PersistentPropertyPath must not be empty!"); + + PersistentPropertyPath> parentPath = path.getParentPath(); + PersistentProperty leafProperty = path.getRequiredLeafProperty(); + + if (!options.propagate(parentPath.getLeafProperty())) { + return; + } + + Object parent = parentPath.isEmpty() ? getBean() : getProperty(parentPath); + + if (parent == null) { + handleNull(path, options.getNullHandling()); + return; + } + + if (parent == getBean()) { + + setProperty(leafProperty, value); + return; + } + + PersistentProperty parentProperty = parentPath.getRequiredLeafProperty(); + + Object newValue; + + if (parentProperty.isCollectionLike()) { + + Collection source = getTypedProperty(parentProperty, Collection.class); + + if (source == null) { + return; + } + + newValue = source.stream() // + .map(it -> setValue(it, leafProperty, value)) // + .collect(Collectors.toCollection(() -> CollectionFactory.createApproximateCollection(source, source.size()))); + + } else if (Map.class.isInstance(parent)) { + + Map source = getTypedProperty(parentProperty, Map.class); + + if (source == null) { + return; + } + + Map result = CollectionFactory.createApproximateMap(source, source.size()); + + for (Entry entry : source.entrySet()) { + result.put(entry.getKey(), setValue(entry.getValue(), leafProperty, value)); + } + + newValue = result; + + } else { + newValue = setValue(parent, leafProperty, value); + } + + if (newValue != parent) { + setProperty(parentPath, newValue); + } + } + + /** + * @param path must not be {@literal null}. + * @param handling must not be {@literal null}. + * @return + */ + @Nullable + private Object handleNull(PersistentPropertyPath> path, SetNulls handling) { + + if (SKIP.equals(handling)) { + return null; + } + + String nullIntermediateMessage = "Cannot lookup property %s on null intermediate! Original path was: %s on %s."; + + if (SetNulls.SKIP_AND_LOG.equals(handling)) { + LOG.info(nullIntermediateMessage); + return null; + } + + PersistentPropertyPath> parentPath = path.getParentPath(); + + throw new MappingException(String.format(nullIntermediateMessage, parentPath.getLeafProperty(), path.toDotPath(), + getBean().getClass().getName())); + } + + /** + * Sets the value for the given {@link PersistentProperty} on the given parent object and returns the potentially + * newly created instance. + * + * @param parent must not be {@literal null}. + * @param property must not be {@literal null}. + * @param newValue can be {@literal null}. + * @return will never be {@literal null}. + */ + private static Object setValue(Object parent, PersistentProperty property, @Nullable Object newValue) { + + PersistentPropertyAccessor accessor = property.getAccessorForOwner(parent); + accessor.setProperty(property, newValue); + return accessor.getBean(); + } + + /** + * Returns the value of the given {@link PersistentProperty} potentially applying type conversion to the given target + * type. The default implementation will not attempt any conversion and reject a type mismatch with a + * {@link MappingException}. + * + * @param property will never be {@literal null}. + * @param type will never be {@literal null}. + * @return can be {@literal null}. + */ + @Nullable + protected S getTypedProperty(PersistentProperty property, Class type) { + + Assert.notNull(property, "Property must not be null!"); + Assert.notNull(type, "Type must not be null!"); + + Object value = getProperty(property); + + if (value == null) { + return null; + } + + if (!type.isInstance(value)) { + throw new MappingException(String.format("Invalid property value type! Need %s but got %s!", // + type.getName(), value.getClass().getName())); + } + + return type.cast(value); + } +} diff --git a/src/test/java/org/springframework/data/mapping/model/ConvertingPropertyAccessorUnitTests.java b/src/test/java/org/springframework/data/mapping/model/ConvertingPropertyAccessorUnitTests.java index 697480dcf..cad9e3a84 100755 --- a/src/test/java/org/springframework/data/mapping/model/ConvertingPropertyAccessorUnitTests.java +++ b/src/test/java/org/springframework/data/mapping/model/ConvertingPropertyAccessorUnitTests.java @@ -18,9 +18,15 @@ package org.springframework.data.mapping.model; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Value; + import org.junit.Test; import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.PersistentPropertyPath; import org.springframework.data.mapping.context.SampleMappingContext; import org.springframework.data.mapping.context.SamplePersistentProperty; import org.springframework.format.support.DefaultFormattingConversionService; @@ -108,6 +114,25 @@ public class ConvertingPropertyAccessorUnitTests { }); } + @Test // DATACMNS-1377 + public void shouldConvertToPropertyPathLeafType() { + + Order order = new Order(new Customer("1")); + + SampleMappingContext context = new SampleMappingContext(); + + PersistentPropertyAccessor accessor = context.getPersistentEntity(Order.class).getPropertyAccessor(order); + ConvertingPropertyAccessor convertingAccessor = new ConvertingPropertyAccessor<>(accessor, + new DefaultConversionService()); + + PersistentPropertyPath path = context.getPersistentPropertyPath("customer.firstname", + Order.class); + + convertingAccessor.setProperty(path, 2); + + assertThat(convertingAccessor.getBean().getCustomer().getFirstname()).isEqualTo("2"); + } + private static ConvertingPropertyAccessor getAccessor(Object entity, ConversionService conversionService) { PersistentPropertyAccessor wrapper = new BeanWrapper<>(entity); @@ -125,4 +150,15 @@ public class ConvertingPropertyAccessorUnitTests { static class Entity { Long id; } + + @Value + static class Order { + Customer customer; + } + + @Data + @AllArgsConstructor + static class Customer { + String firstname; + } } diff --git a/src/test/java/org/springframework/data/mapping/model/SimplePersistentPropertyPathAccessorUnitTests.java b/src/test/java/org/springframework/data/mapping/model/SimplePersistentPropertyPathAccessorUnitTests.java new file mode 100644 index 000000000..9596a27fd --- /dev/null +++ b/src/test/java/org/springframework/data/mapping/model/SimplePersistentPropertyPathAccessorUnitTests.java @@ -0,0 +1,127 @@ +/* + * Copyright 2018 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.mapping.model; + +import static org.assertj.core.api.Assertions.*; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Value; +import lombok.experimental.Wither; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.Test; +import org.springframework.data.mapping.AccessOptions; +import org.springframework.data.mapping.AccessOptions.SetOptions.SetNulls; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentPropertyPath; +import org.springframework.data.mapping.PersistentPropertyPathAccessor; +import org.springframework.data.mapping.context.SampleMappingContext; +import org.springframework.data.mapping.context.SamplePersistentProperty; + +/** + * Unit tests for {@link SimplePersistentPropertyPathAccessor}. + * + * @author Oliver Gierke + * @since 2.3 + * @soundtrack Ron Spielman Trio - Raindrops (Electric Tales) + */ +public class SimplePersistentPropertyPathAccessorUnitTests { + + SampleMappingContext context = new SampleMappingContext(); + + Customer first = new Customer("1"); + Customer second = new Customer("2"); + + @Test // DATACMNS-1438 + public void setsPropertyContainingCollectionPathForAllElements() { + + Customers customers = new Customers(Arrays.asList(first, second), Collections.emptyMap()); + + assertFirstnamesSetFor(customers, "customers.firstname"); + } + + @Test // DATACMNS-1438 + public void setsPropertyContainingMapPathForAllValues() { + + Map map = new HashMap<>(); + map.put("1", first); + map.put("2", second); + + Customers customers = new Customers(Collections.emptyList(), map); + + assertFirstnamesSetFor(customers, "customerMap.firstname"); + } + + @Test // DATACMNS-1461 + public void skipsNullValueIfConfigured() { + + CustomerWrapper wrapper = new CustomerWrapper(null); + + PersistentPropertyPathAccessor accessor = getAccessor(wrapper); + PersistentPropertyPath path = context.getPersistentPropertyPath("customer.firstname", + CustomerWrapper.class); + + assertThatCode(() -> { + accessor.setProperty(path, "Dave", AccessOptions.defaultSetOptions().withNullHandling(SetNulls.SKIP)); + }).doesNotThrowAnyException(); + } + + private void assertFirstnamesSetFor(Customers customers, String path) { + + PersistentPropertyPath propertyPath = context.getPersistentPropertyPath(path, + Customers.class); + + getAccessor(customers).setProperty(propertyPath, "firstname"); + + Stream.of(first, second).forEach(it -> { + assertThat(it.firstname).isEqualTo("firstname"); + }); + } + + private PersistentPropertyPathAccessor getAccessor(T source) { + + Class type = source.getClass(); + + PersistentEntity entity = context.getRequiredPersistentEntity(type); + PersistentPropertyPathAccessor accessor = entity.getPropertyPathAccessor(source); + + return accessor; + } + + @Data + @AllArgsConstructor + static class Customer { + String firstname; + } + + @Value + static class Customers { + @Wither List customers; + @Wither Map customerMap; + } + + @AllArgsConstructor + static class CustomerWrapper { + Customer customer; + } +}