diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentProperty.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentProperty.java index b6824fa0e0..6cbf5ef87d 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentProperty.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentProperty.java @@ -262,7 +262,11 @@ public boolean isOrdered() { @Override public boolean isEmbedded() { - return isEmbedded || (isIdProperty() && isEntity()); + return isEmbedded || isCompositeId(); + } + + private boolean isCompositeId() { + return isIdProperty() && isEntity(); } @Override diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/RelationalExampleMapper.java b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/RelationalExampleMapper.java index 5bfd13f583..7abdb98196 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/RelationalExampleMapper.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/RelationalExampleMapper.java @@ -22,9 +22,14 @@ import java.util.List; import java.util.Optional; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + import org.springframework.data.domain.Example; +import org.springframework.data.domain.ExampleMatcher; import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.PropertyHandler; +import org.springframework.data.mapping.PropertyPath; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; @@ -39,6 +44,7 @@ * @since 2.2 * @author Greg Turnquist * @author Jens Schauder + * @author Mikhail Polivakha */ public class RelationalExampleMapper { @@ -64,92 +70,193 @@ public Query getMappedExample(Example example) { * {@link Query}. * * @param example - * @param entity + * @param persistentEntity * @return query */ - private Query getMappedExample(Example example, RelationalPersistentEntity entity) { + private Query getMappedExample(Example example, RelationalPersistentEntity persistentEntity) { Assert.notNull(example, "Example must not be null"); - Assert.notNull(entity, "RelationalPersistentEntity must not be null"); + Assert.notNull(persistentEntity, "RelationalPersistentEntity must not be null"); - PersistentPropertyAccessor propertyAccessor = entity.getPropertyAccessor(example.getProbe()); + PersistentPropertyAccessor probePropertyAccessor = persistentEntity.getPropertyAccessor(example.getProbe()); ExampleMatcherAccessor matcherAccessor = new ExampleMatcherAccessor(example.getMatcher()); - final List criteriaBasedOnProperties = new ArrayList<>(); + final List criteriaBasedOnProperties = buildCriteria( // + persistentEntity, // + matcherAccessor, // + probePropertyAccessor // + ); - entity.doWithProperties((PropertyHandler) property -> { + // Criteria, assemble! + Criteria criteria = Criteria.empty(); - if (property.isCollectionLike() || property.isMap()) { - return; - } + for (Criteria propertyCriteria : criteriaBasedOnProperties) { - if (matcherAccessor.isIgnoredPath(property.getName())) { - return; + if (example.getMatcher().isAllMatching()) { + criteria = criteria.and(propertyCriteria); + } else { + criteria = criteria.or(propertyCriteria); } + } + + return Query.query(criteria); + } + + private List buildCriteria( // + RelationalPersistentEntity persistentEntity, // + ExampleMatcherAccessor matcherAccessor, // + PersistentPropertyAccessor probePropertyAccessor // + ) { + final List criteriaBasedOnProperties = new ArrayList<>(); + + persistentEntity.doWithProperties((PropertyHandler) property -> { + potentiallyEnrichCriteria( + null, + matcherAccessor, // + probePropertyAccessor, // + property, // + criteriaBasedOnProperties // + ); + }); + return criteriaBasedOnProperties; + } + + /** + * Analyzes the incoming {@code property} and potentially enriches the {@code criteriaBasedOnProperties} with the new + * {@link Criteria} for this property. + *

+ * This algorithm is recursive in order to take the embedded properties into account. The caller can expect that the result + * of this method call is fully processed subtree of an aggreagte where the passed {@code property} serves as the root. + * + * @param propertyPath the {@link PropertyPath} of the passed {@code property}. + * @param matcherAccessor the accessor for the original {@link ExampleMatcher}. + * @param entityPropertiesAccessor the accessor for the properties of the current entity that holds the given {@code property} + * @param property the property under analysis + * @param criteriaBasedOnProperties the {@link List} of criteria objects that potentially gets enriched as a + * result of the incoming {@code property} processing + */ + private void potentiallyEnrichCriteria( + @Nullable PropertyPath propertyPath, + ExampleMatcherAccessor matcherAccessor, // + PersistentPropertyAccessor entityPropertiesAccessor, // + RelationalPersistentProperty property, // + List criteriaBasedOnProperties // + ) { + + // QBE do not support queries on Child aggregates yet + if (property.isCollectionLike() || property.isMap()) { + return; + } + + PropertyPath currentPropertyPath = resolveCurrentPropertyPath(propertyPath, property); + String currentPropertyDotPath = currentPropertyPath.toDotPath(); + + if (matcherAccessor.isIgnoredPath(currentPropertyDotPath)) { + return; + } + Object actualPropertyValue = entityPropertiesAccessor.getProperty(property); + + if (property.isEmbedded() && actualPropertyValue != null) { + processEmbeddedRecursively( // + matcherAccessor, // + actualPropertyValue, + property, // + criteriaBasedOnProperties, // + currentPropertyPath // + ); + } else { Optional optionalConvertedPropValue = matcherAccessor // - .getValueTransformerForPath(property.getName()) // - .apply(Optional.ofNullable(propertyAccessor.getProperty(property))); + .getValueTransformerForPath(currentPropertyDotPath) // + .apply(Optional.ofNullable(actualPropertyValue)); // If the value is empty, don't try to match against it - if (!optionalConvertedPropValue.isPresent()) { + if (optionalConvertedPropValue.isEmpty()) { return; } Object convPropValue = optionalConvertedPropValue.get(); - boolean ignoreCase = matcherAccessor.isIgnoreCaseForPath(property.getName()); + boolean ignoreCase = matcherAccessor.isIgnoreCaseForPath(currentPropertyDotPath); String column = property.getName(); - switch (matcherAccessor.getStringMatcherForPath(property.getName())) { + switch (matcherAccessor.getStringMatcherForPath(currentPropertyDotPath)) { case DEFAULT: case EXACT: - criteriaBasedOnProperties.add(includeNulls(example) // + criteriaBasedOnProperties.add(includeNulls(matcherAccessor) // ? Criteria.where(column).isNull().or(column).is(convPropValue).ignoreCase(ignoreCase) : Criteria.where(column).is(convPropValue).ignoreCase(ignoreCase)); break; case ENDING: - criteriaBasedOnProperties.add(includeNulls(example) // + criteriaBasedOnProperties.add(includeNulls(matcherAccessor) // ? Criteria.where(column).isNull().or(column).like("%" + convPropValue).ignoreCase(ignoreCase) : Criteria.where(column).like("%" + convPropValue).ignoreCase(ignoreCase)); break; case STARTING: - criteriaBasedOnProperties.add(includeNulls(example) // + criteriaBasedOnProperties.add(includeNulls(matcherAccessor) // ? Criteria.where(column).isNull().or(column).like(convPropValue + "%").ignoreCase(ignoreCase) : Criteria.where(column).like(convPropValue + "%").ignoreCase(ignoreCase)); break; case CONTAINING: - criteriaBasedOnProperties.add(includeNulls(example) // + criteriaBasedOnProperties.add(includeNulls(matcherAccessor) // ? Criteria.where(column).isNull().or(column).like("%" + convPropValue + "%").ignoreCase(ignoreCase) : Criteria.where(column).like("%" + convPropValue + "%").ignoreCase(ignoreCase)); break; default: - throw new IllegalStateException(example.getMatcher().getDefaultStringMatcher() + " is not supported"); + throw new IllegalStateException(matcherAccessor.getDefaultStringMatcher() + " is not supported"); } - }); + } - // Criteria, assemble! - Criteria criteria = Criteria.empty(); + } - for (Criteria propertyCriteria : criteriaBasedOnProperties) { + /** + * Processes an embedded entity's properties recursively. + * + * @param matcherAccessor the input matcher on the {@link Example#getProbe() original probe}. + * @param value the actual embedded object. + * @param property the embedded property. + * @param criteriaBasedOnProperties collection of {@link Criteria} objects to potentially enrich. + * @param currentPropertyPath the dot-separated path of the passed {@code property}. + */ + private void processEmbeddedRecursively( + ExampleMatcherAccessor matcherAccessor, + Object value, + RelationalPersistentProperty property, + List criteriaBasedOnProperties, + PropertyPath currentPropertyPath + ) { + RelationalPersistentEntity embeddedPersistentEntity = mappingContext.getPersistentEntity(property.getTypeInformation()); - if (example.getMatcher().isAllMatching()) { - criteria = criteria.and(propertyCriteria); - } else { - criteria = criteria.or(propertyCriteria); - } - } + PersistentPropertyAccessor embeddedEntityPropertyAccessor = embeddedPersistentEntity.getPropertyAccessor(value); - return Query.query(criteria); + embeddedPersistentEntity.doWithProperties((PropertyHandler) embeddedProperty -> + potentiallyEnrichCriteria( + currentPropertyPath, + matcherAccessor, + embeddedEntityPropertyAccessor, + embeddedProperty, + criteriaBasedOnProperties + ) + ); + } + + private static PropertyPath resolveCurrentPropertyPath(@Nullable PropertyPath propertyPath, RelationalPersistentProperty property) { + PropertyPath currentPropertyPath; + + if (propertyPath == null) { + currentPropertyPath = PropertyPath.from(property.getName(), property.getOwner().getTypeInformation()); + } else { + currentPropertyPath = propertyPath.nested(property.getName()); + } + return currentPropertyPath; } /** - * Does this {@link Example} need to include {@literal NULL} values in its {@link Criteria}? + * Does this {@link ExampleMatcherAccessor} need to include {@literal NULL} values in its {@link Criteria}? * - * @param example - * @return whether or not to include nulls. + * @return whether to include nulls. */ - private static boolean includeNulls(Example example) { - return example.getMatcher().getNullHandler() == NullHandler.INCLUDE; + private static boolean includeNulls(ExampleMatcherAccessor exampleMatcher) { + return exampleMatcher.getNullHandler() == NullHandler.INCLUDE; } } diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/repository/query/RelationalExampleMapperTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/repository/query/RelationalExampleMapperTests.java index a29ef24845..82de1e4062 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/repository/query/RelationalExampleMapperTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/repository/query/RelationalExampleMapperTests.java @@ -17,6 +17,7 @@ package org.springframework.data.relational.repository.query; import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.springframework.data.domain.ExampleMatcher.*; import static org.springframework.data.domain.ExampleMatcher.GenericPropertyMatchers.*; import static org.springframework.data.domain.ExampleMatcher.StringMatcher.*; @@ -24,13 +25,17 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; +import org.apache.commons.logging.Log; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.data.annotation.Id; import org.springframework.data.domain.Example; import org.springframework.data.domain.ExampleMatcher; +import org.springframework.data.relational.core.mapping.Embedded; import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.core.query.CriteriaDefinition; import org.springframework.data.relational.core.query.Query; import org.springframework.lang.Nullable; @@ -39,6 +44,7 @@ * * @author Greg Turnquist * @author Jens Schauder + * @author Mikhail Polivakha */ class RelationalExampleMapperTests { @@ -201,7 +207,7 @@ void queryByExampleWithFirstnameWithStringMatchingRegEx() { Person person = new Person(null, "do", null, null, null, null); - ExampleMatcher matcher = matching().withStringMatcher(ExampleMatcher.StringMatcher.REGEX); + ExampleMatcher matcher = matching().withStringMatcher(REGEX); Example example = Example.of(person, matcher); assertThatIllegalStateException().isThrownBy(() -> exampleMapper.getMappedExample(example)) @@ -413,8 +419,100 @@ void mapAttributesGetIgnored() { assertThat(query.getCriteria().orElseThrow().toString()).doesNotContainIgnoringCase("address"); } - record Person(@Id @Nullable String id, @Nullable String firstname, @Nullable String lastname, @Nullable String secret, - @Nullable List possessions, @Nullable Map addresses) { + // GH-1986 + @Test + void shouldConsiderNullabilityForEmbeddedProperties() { + Example example = Example.of( // + new EnclosingObject( // + 12L, // + null, // + new EmbeddableObject(null, "Potsdam", null) + ), + matching().withIgnorePaths("id").withIgnoreNullValues() + ); + + Query mappedExample = exampleMapper.getMappedExample(example); + + Optional criteria = mappedExample.getCriteria(); + + assertThat(criteria).isPresent(); + assertThat(criteria.get().toString()).isEqualTo("(city = 'Potsdam')"); + } + + // GH-2099 + @Test + void shouldConsiderDeeplyEmbeddedPropertiesWithSpecifiers() { + Example example = Example.of( // + new EnclosingObject( // + 12L, // explicitly ignored + null, // ignored because it is null + new EmbeddableObject( + null, // ignored because it is null + "Potsdam", // should be included + new SecondLevelEmbeddable( + "postCodeContains", // should be included + "regionThatShouldBeIgnored", // explicitly ignored + 12L // should be included with value being transformed + ) + ) + ), + matchingAny() + .withIgnorePaths( + "id", + "embeddableObject.secondLevelEmbeddable.region" + ) + .withMatcher( + "embeddableObject.secondLevelEmbeddable.postCode", + matcher -> matcher.ignoreCase().contains() + ) + .withMatcher( + "embeddableObject.secondLevelEmbeddable.forTransformation", + matcher -> matcher.transform(o -> + o.map(value -> (long) value * (long) value) + ) + ) + .withIgnoreNullValues() + ); + + Query mappedExample = exampleMapper.getMappedExample(example); + + Optional criteria = mappedExample.getCriteria(); + + assertThat(criteria).isPresent(); + assertThat(criteria.get().toString()).isEqualTo("(city = 'Potsdam') OR (postCode LIKE '%postCodeContains%') OR (forTransformation = 144)"); + } + + record Person( + @Id @Nullable String id, + @Nullable String firstname, + @Nullable String lastname, + @Nullable String secret, + @Nullable List possessions, + @Nullable Map addresses + ) { + } + + public static class EnclosingObject { + Long id; + String name; + @Embedded.Nullable EmbeddableObject embeddableObject; + + public EnclosingObject(Long id, String name, EmbeddableObject embeddableObject) { + this.id = id; + this.name = name; + this.embeddableObject = embeddableObject; + } + } + + record EmbeddableObject( // + String street, // + String city, // + @Embedded.Nullable SecondLevelEmbeddable secondLevelEmbeddable) { + + } + + record SecondLevelEmbeddable(String postCode, String region, Long forTransformation) { + } record Possession(String name) {