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; + } +}