From 9948fefe692f09a9bd59c9bac720fe72450e3fed Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 15 Nov 2021 15:20:31 +0100 Subject: [PATCH] Provide class for inspecting nested projections. Original Pull Request: #2420 --- .../context/EntityProjectionDiscoverer.java | 354 ++++++++++++++++++ .../EntityProjectionDiscovererUnitTests.java | 215 +++++++++++ 2 files changed, 569 insertions(+) create mode 100644 src/main/java/org/springframework/data/mapping/context/EntityProjectionDiscoverer.java create mode 100644 src/test/java/org/springframework/data/mapping/context/EntityProjectionDiscovererUnitTests.java diff --git a/src/main/java/org/springframework/data/mapping/context/EntityProjectionDiscoverer.java b/src/main/java/org/springframework/data/mapping/context/EntityProjectionDiscoverer.java new file mode 100644 index 000000000..d99565a7c --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/context/EntityProjectionDiscoverer.java @@ -0,0 +1,354 @@ +/* + * Copyright 2021 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.context; + +import java.beans.PropertyDescriptor; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.PropertyPath; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.ProjectionInformation; +import org.springframework.data.util.Pair; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * This class is introspects the returned type in the context of a domain type for all reachable properties (w/o cycles) + * to determine which property paths are subject to projection. + * + * @author Gerrit Meier + * @author Mark Paluch + * @since 2.7 + */ +public class EntityProjectionDiscoverer { + + private final ProjectionFactory projectionFactory; + private final ProjectionPredicate projectionPredicate; + private final MappingContext mappingContext; + + private EntityProjectionDiscoverer(ProjectionFactory projectionFactory, ProjectionPredicate projectionPredicate, + MappingContext mappingContext) { + this.projectionFactory = projectionFactory; + this.projectionPredicate = projectionPredicate; + this.mappingContext = mappingContext; + } + + /** + * Create a new {@link EntityProjectionDiscoverer} given {@link ProjectionFactory}, {@link ProjectionPredicate} and + * {@link MappingContext}. + * + * @param projectionFactory must not be {@literal null}. + * @param projectionPredicate must not be {@literal null}. + * @param mappingContext must not be {@literal null}. + * @return a new {@link EntityProjectionDiscoverer} instance. + */ + public static EntityProjectionDiscoverer create(ProjectionFactory projectionFactory, + ProjectionPredicate projectionPredicate, MappingContext mappingContext) { + + Assert.notNull(projectionFactory, "ProjectionFactory must not be null"); + Assert.notNull(projectionPredicate, "ProjectionPredicate must not be null"); + Assert.notNull(mappingContext, "MappingContext must not be null"); + + return new EntityProjectionDiscoverer(projectionFactory, projectionPredicate, mappingContext); + } + + /** + * Introspect a {@link Class return type} in the context of a {@link Class domain type} whether the returned type is a + * projection and what property paths are participating in the projection. + *

+ * Nested properties (direct types, within maps, collections) are introspected for nested projections and contain + * property paths for closed projections. + * + * @param returnType + * @param domainType + * @return + */ + public ReturnedTypeDescriptor introspectReturnType(Class returnType, Class domainType) { + + boolean isProjection = projectionPredicate.test(returnType, domainType); + + if (!isProjection) { + return ReturnedTypeDescriptor.nonProjecting(returnType, domainType, Collections.emptyList()); + } + + ProjectionInformation projectionInformation = projectionFactory.getProjectionInformation(returnType); + + if (!projectionInformation.isClosed()) { + return ReturnedTypeDescriptor.projecting(returnType, domainType, Collections.emptyList()); + } + + Set, Class>> cycleGuard = new HashSet<>(); + + PersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(domainType); + List propertyDescriptors = getProperties(null, projectionInformation, + persistentEntity, cycleGuard); + + return ReturnedTypeDescriptor.projecting(returnType, domainType, propertyDescriptors); + } + + private List getProperties(@Nullable PropertyPath propertyPath, + ProjectionInformation projectionInformation, PersistentEntity persistentEntity, + Set, Class>> cycleGuard) { + + List propertyDescriptors = new ArrayList<>(); + for (PropertyDescriptor inputProperty : projectionInformation.getInputProperties()) { + + PersistentProperty persistentProperty = persistentEntity.getPersistentProperty(inputProperty.getName()); + + if (persistentProperty == null) { + continue; + } + + Class returnedType = inputProperty.getPropertyType(); + Class domainType = persistentProperty.getActualType(); + + PropertyPath nestedPropertyPath = propertyPath == null + ? PropertyPath.from(persistentProperty.getName(), persistentEntity.getTypeInformation()) + : propertyPath.nested(persistentProperty.getName()); + + if (projectionPredicate.test(returnedType, domainType)) { + + List nestedPropertyDescriptors; + + if (cycleGuard.add(Pair.of(returnedType, domainType))) { + nestedPropertyDescriptors = getProjectedProperties(nestedPropertyPath, returnedType, domainType, cycleGuard); + } else { + nestedPropertyDescriptors = Collections.emptyList(); + } + + propertyDescriptors.add(PropertyProjectionDescriptor.projecting(nestedPropertyPath, returnedType, domainType, + nestedPropertyDescriptors)); + } else { + propertyDescriptors + .add(PropertyProjectionDescriptor.nonProjecting(nestedPropertyPath, returnedType, domainType)); + } + } + + return propertyDescriptors; + } + + private List getProjectedProperties(PropertyPath propertyPath, Class returnedType, + Class domainType, Set, Class>> cycleGuard) { + + ProjectionInformation projectionInformation = projectionFactory.getProjectionInformation(returnedType); + PersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(domainType); + + // Closed projection should get handled as above (recursion) + return projectionInformation.isClosed() + ? getProperties(propertyPath, projectionInformation, persistentEntity, cycleGuard) + : Collections.emptyList(); + } + + /** + * Descriptor for a top-level return type. + */ + public static class ReturnedTypeDescriptor { + + private final Class returnedType; + private final Class domainType; + private final List nested; + private final boolean projecting; + + ReturnedTypeDescriptor(Class returnedType, Class domainType, List nested, + boolean projecting) { + this.domainType = domainType; + this.returnedType = returnedType; + this.nested = nested; + this.projecting = projecting; + } + + /** + * Create a projecting variant of a return type. + * + * @param returnedType + * @param domainType + * @param nested + * @return + */ + public static ReturnedTypeDescriptor projecting(Class returnedType, Class domainType, + List nested) { + return new ReturnedTypeDescriptor(returnedType, domainType, nested, true); + } + + /** + * Create a non-projecting variant of a return type. + * + * @param returnedType + * @param domainType + * @param nested + * @return + */ + public static ReturnedTypeDescriptor nonProjecting(Class returnedType, Class domainType, + List nested) { + return new ReturnedTypeDescriptor(returnedType, domainType, nested, false); + } + + public Class getDomainType() { + return domainType; + } + + public Class getReturnedType() { + return returnedType; + } + + public boolean isProjecting() { + return projecting; + } + + List getNested() { + return nested; + } + + /** + * Perform the given {@code action} for each element of the {@code ReturnedTypeDescriptor} until all elements have + * been processed or the action throws an exception. + * + * @param action the action to be performed for each element + */ + public void forEach(Consumer action) { + + for (PropertyProjectionDescriptor descriptor : nested) { + + if (descriptor.getNested().isEmpty()) { + action.accept(descriptor.getPropertyPath()); + } else { + descriptor.forEach(action); + } + } + } + + @Override + public String toString() { + + if (isProjecting()) { + return String.format("Projection(%s AS %s): %s", getDomainType().getName(), getReturnedType().getName(), + nested); + } + + return String.format("Domain(%s): %s", getReturnedType().getName(), nested); + } + } + + /** + * Descriptor for a property-level type along its potential projection. + */ + public static class PropertyProjectionDescriptor extends ReturnedTypeDescriptor { + + private final PropertyPath propertyPath; + + PropertyProjectionDescriptor(PropertyPath propertyPath, Class returnedType, Class domainType, + List nested, boolean projecting) { + super(returnedType, domainType, nested, projecting); + this.propertyPath = propertyPath; + } + + /** + * Create a projecting variant of a return type. + * + * @param propertyPath + * @param returnedType + * @param domainType + * @param nested + * @return + */ + public static PropertyProjectionDescriptor projecting(PropertyPath propertyPath, Class returnedType, + Class domainType, List nested) { + return new PropertyProjectionDescriptor(propertyPath, returnedType, domainType, nested, true); + } + + /** + * Create a non-projecting variant of a return type. + * + * @param propertyPath + * @param returnedType + * @param domainType + * @return + */ + public static PropertyProjectionDescriptor nonProjecting(PropertyPath propertyPath, Class returnedType, + Class domainType) { + return new PropertyProjectionDescriptor(propertyPath, returnedType, domainType, Collections.emptyList(), false); + } + + public PropertyPath getPropertyPath() { + return propertyPath; + } + + @Override + public String toString() { + return String.format("%s AS %s", propertyPath.toDotPath(), getReturnedType().getName()); + } + } + + /** + * Represents a predicate (boolean-valued function) of a {@link Class target type} and its {@link Class underlying + * type}. + */ + public interface ProjectionPredicate { + + /** + * Evaluates this predicate on the given arguments. + * + * @param target the target type. + * @param target the underlying type. + * @return {@code true} if the input argument matches the predicate, otherwise {@code false}. + */ + boolean test(Class target, Class underlyingType); + + /** + * Return a composed predicate that represents a short-circuiting logical AND of this predicate and another. When + * evaluating the composed predicate, if this predicate is {@code false}, then the {@code other} predicate is not + * evaluated. + *

+ * Any exceptions thrown during evaluation of either predicate are relayed to the caller; if evaluation of this + * predicate throws an exception, the {@code other} predicate will not be evaluated. + * + * @param other a predicate that will be logically-ANDed with this predicate + * @return a composed predicate that represents the short-circuiting logical AND of this predicate and the + * {@code other} predicate + */ + default ProjectionPredicate and(ProjectionPredicate other) { + return (target, underlyingType) -> test(target, underlyingType) && other.test(target, underlyingType); + } + + /** + * Return a predicate that represents the logical negation of this predicate. + * + * @return a predicate that represents the logical negation of this predicate + */ + default ProjectionPredicate negate() { + return (target, underlyingType) -> !test(target, underlyingType); + } + + /** + * Return a predicate that considers whether the {@code target type} is participating in the type hierarchy. + */ + static ProjectionPredicate typeHierarchy() { + + ProjectionPredicate predicate = (target, underlyingType) -> target.isAssignableFrom(underlyingType) || // hierarchy + underlyingType.isAssignableFrom(target); + return predicate.negate(); + } + + } + +} diff --git a/src/test/java/org/springframework/data/mapping/context/EntityProjectionDiscovererUnitTests.java b/src/test/java/org/springframework/data/mapping/context/EntityProjectionDiscovererUnitTests.java new file mode 100644 index 000000000..ccc97f58e --- /dev/null +++ b/src/test/java/org/springframework/data/mapping/context/EntityProjectionDiscovererUnitTests.java @@ -0,0 +1,215 @@ +/* + * Copyright 2021 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.context; + +import static org.assertj.core.api.Assertions.*; + +import lombok.Value; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.mapping.PropertyPath; +import org.springframework.data.mapping.context.EntityProjectionDiscoverer.ReturnedTypeDescriptor; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; + +/** + * Unit tests for {@link EntityProjectionDiscoverer}. + * + * @author Mark Paluch + */ +class EntityProjectionDiscovererUnitTests { + + SampleMappingContext mappingContext = new SampleMappingContext(); + SpelAwareProxyProjectionFactory projectionFactory = new SpelAwareProxyProjectionFactory(); + EntityProjectionDiscoverer.ProjectionPredicate predicate = (target, + underlyingType) -> !SimpleTypeHolder.DEFAULT.isSimpleType(target); + EntityProjectionDiscoverer discoverer = EntityProjectionDiscoverer.create(projectionFactory, + predicate.and(EntityProjectionDiscoverer.ProjectionPredicate.typeHierarchy()), mappingContext); + + @Test + void shouldDiscoverTypeHierarchy() { + + // super type + assertThat(discoverer.introspectReturnType(Root.class, Middle.class).isProjecting()).isFalse(); + + assertThat(discoverer.introspectReturnType(SuperInterface.class, Middle.class).isProjecting()).isFalse(); + + // subtypes + assertThat(discoverer.introspectReturnType(Leaf.class, Middle.class).isProjecting()).isFalse(); + } + + @Test + void shouldConsiderTopLevelInterfaceProperties() { + + ReturnedTypeDescriptor descriptor = discoverer.introspectReturnType(DomainClassProjection.class, DomainClass.class); + + assertThat(descriptor.isProjecting()).isTrue(); + + List paths = new ArrayList<>(); + descriptor.forEach(paths::add); + + assertThat(paths).hasSize(2).extracting(PropertyPath::toDotPath).containsOnly("id", "value"); + } + + @Test + void shouldConsiderTopLevelDtoProperties() { + + ReturnedTypeDescriptor descriptor = discoverer.introspectReturnType(DomainClassDto.class, DomainClass.class); + + assertThat(descriptor.isProjecting()).isTrue(); + + List paths = new ArrayList<>(); + descriptor.forEach(paths::add); + + assertThat(paths).hasSize(2).extracting(PropertyPath::toDotPath).containsOnly("id", "value"); + } + + @Test + void shouldConsiderNestedProjectionProperties() { + + ReturnedTypeDescriptor descriptor = discoverer.introspectReturnType(WithNestedProjection.class, + WithComplexObject.class); + + assertThat(descriptor.isProjecting()).isTrue(); + + List paths = new ArrayList<>(); + descriptor.forEach(paths::add); + + assertThat(paths).hasSize(3).extracting(PropertyPath::toDotPath).containsOnly("domain.id", "domain.value", + "domain2"); + } + + @Test + void shouldConsiderOpenProjection() { + + ReturnedTypeDescriptor descriptor = discoverer.introspectReturnType(OpenProjection.class, DomainClass.class); + + assertThat(descriptor.isProjecting()).isTrue(); + + List paths = new ArrayList<>(); + descriptor.forEach(paths::add); + + assertThat(paths).isEmpty(); + } + + @Test + void shouldConsiderCyclicProjections() { + + ReturnedTypeDescriptor descriptor = discoverer.introspectReturnType(CyclicProjection1.class, CyclicDomain1.class); + + assertThat(descriptor.isProjecting()).isTrue(); + + List paths = new ArrayList<>(); + descriptor.forEach(paths::add); + + assertThat(paths).hasSize(4).extracting(PropertyPath::toDotPath).containsOnly("name", "level1.name", + "level1.level2.name", "level1.level2.level1"); + } + + interface SuperInterface { + + } + + static class Root implements SuperInterface { + + } + + static class Middle extends Root { + + } + + static class Leaf extends Middle { + + } + + static class DomainClass { + + String id; + long value; + String name; + } + + static class WithComplexObject { + + DomainClass domain; + DomainClass domain2; + } + + interface WithNestedProjection { + + DomainClassProjection getDomain(); + + DomainClass getDomain2(); + } + + interface DomainClassProjection { + + String getId(); + + long getValue(); + + String getFoo(); + } + + interface OpenProjection { + + @org.springframework.beans.factory.annotation.Value("#{target.foo}") + String getFoo(); + } + + @Value + static class DomainClassDto { + + String id; + long value; + + public DomainClassDto(String id, long value) { + this.id = id; + this.value = value; + } + } + + static class CyclicDomain1 { + + CyclicDomain2 level1; + String name; + } + + static class CyclicDomain2 { + + CyclicDomain1 level2; + String name; + } + + static interface CyclicProjection1 { + + CyclicProjection2 getLevel1(); + + String getName(); + } + + static interface CyclicProjection2 { + + CyclicProjection1 getLevel2(); + + String getName(); + } + +}