Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,11 @@ public boolean isOrdered() {

@Override
public boolean isEmbedded() {
return isEmbedded || (isIdProperty() && isEntity());
return isEmbedded || isCompositeId();
}

private boolean isCompositeId() {
return isIdProperty() && isEntity();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -39,6 +44,7 @@
* @since 2.2
* @author Greg Turnquist
* @author Jens Schauder
* @author Mikhail Polivakha
*/
public class RelationalExampleMapper {

Expand All @@ -64,92 +70,168 @@ public <T> Query getMappedExample(Example<T> example) {
* {@link Query}.
*
* @param example
* @param entity
* @param persistentEntity
* @return query
*/
private <T> Query getMappedExample(Example<T> example, RelationalPersistentEntity<?> entity) {
private <T> Query getMappedExample(Example<T> example, RelationalPersistentEntity<?> persistentEntity) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason behind the renaming?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because one can confuse the PersistenceEntity with the entity as the actual instance/probe. I do not insist here at all, just my observation when working with code, what do you think, @mp911de ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually do like the rename and the idea behind it.


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<T> propertyAccessor = entity.getPropertyAccessor(example.getProbe());
PersistentPropertyAccessor<T> probePropertyAccessor = persistentEntity.getPropertyAccessor(example.getProbe());
ExampleMatcherAccessor matcherAccessor = new ExampleMatcherAccessor(example.getMatcher());

final List<Criteria> criteriaBasedOnProperties = new ArrayList<>();
final List<Criteria> criteriaBasedOnProperties = buildCriteria( //
null, //
persistentEntity, //
matcherAccessor, //
probePropertyAccessor //
);

entity.doWithProperties((PropertyHandler<RelationalPersistentProperty>) 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 <T> List<Criteria> buildCriteria( //
@Nullable PropertyPath propertyPath, //
RelationalPersistentEntity<?> persistentEntity, //
ExampleMatcherAccessor matcherAccessor, //
PersistentPropertyAccessor<T> probePropertyAccessor //
) {
final List<Criteria> criteriaBasedOnProperties = new ArrayList<>();

for (RelationalPersistentProperty property : persistentEntity) {
potentiallyEnrichCriteria(
propertyPath,
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.
* <p>
* 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 <T> void potentiallyEnrichCriteria(
@Nullable PropertyPath propertyPath,
ExampleMatcherAccessor matcherAccessor, //
PersistentPropertyAccessor<T> entityPropertiesAccessor, //
RelationalPersistentProperty property, //
List<Criteria> 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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, we don't distinguish based on whether the property is an embedded one but rather whether it is an entity. At some point it should be possible to express criteria based across objects that are part of the aggregate (like it is done in JPA, we consider embeddables and references).

RelationalPersistentEntity<?> embeddedPersistentEntity = mappingContext.getRequiredPersistentEntity(property.getTypeInformation());

criteriaBasedOnProperties.addAll(
buildCriteria(
currentPropertyPath,
embeddedPersistentEntity,
matcherAccessor,
embeddedPersistentEntity.getPropertyAccessor(actualPropertyValue)
)
);
} 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) {
private static PropertyPath resolveCurrentPropertyPath(@Nullable PropertyPath propertyPath, RelationalPersistentProperty property) {
PropertyPath currentPropertyPath;

if (example.getMatcher().isAllMatching()) {
criteria = criteria.and(propertyCriteria);
} else {
criteria = criteria.or(propertyCriteria);
}
if (propertyPath == null) {
currentPropertyPath = PropertyPath.from(property.getName(), property.getOwner().getTypeInformation());
} else {
currentPropertyPath = propertyPath.nested(property.getName());
}

return Query.query(criteria);
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 <T> boolean includeNulls(Example<T> example) {
return example.getMatcher().getNullHandler() == NullHandler.INCLUDE;
private static <T> boolean includeNulls(ExampleMatcherAccessor exampleMatcher) {
return exampleMatcher.getNullHandler() == NullHandler.INCLUDE;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,25 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

import org.jspecify.annotations.Nullable;
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;

/**
* Verify that the {@link RelationalExampleMapper} properly turns {@link Example}s into {@link Query}'s.
*
* @author Greg Turnquist
* @author Jens Schauder
* @author Mikhail Polivakha
*/
class RelationalExampleMapperTests {

Expand Down Expand Up @@ -201,7 +205,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<Person> example = Example.of(person, matcher);

assertThatIllegalStateException().isThrownBy(() -> exampleMapper.getMappedExample(example))
Expand Down Expand Up @@ -413,8 +417,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<Possession> possessions, @Nullable Map<String, Address> addresses) {
// GH-1986
@Test
void shouldConsiderNullabilityForEmbeddedProperties() {
Example<EnclosingObject> example = Example.of( //
new EnclosingObject( //
12L, //
null, //
new EmbeddableObject(null, "Potsdam", null)
),
matching().withIgnorePaths("id").withIgnoreNullValues()
);

Query mappedExample = exampleMapper.getMappedExample(example);

Optional<CriteriaDefinition> criteria = mappedExample.getCriteria();

assertThat(criteria).isPresent();
assertThat(criteria.get().toString()).isEqualTo("(city = 'Potsdam')");
}

// GH-2099
@Test
void shouldConsiderDeeplyEmbeddedPropertiesWithSpecifiers() {
Example<EnclosingObject> 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<CriteriaDefinition> 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<Possession> possessions,
@Nullable Map<String, Address> 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) {
Expand Down