Skip to content

Commit fce3a56

Browse files
committed
GH-2099 GH-1986 Consider Embedded properties in QBE
Signed-off-by: mipo256 <[email protected]>
1 parent 38f2af0 commit fce3a56

File tree

2 files changed

+245
-40
lines changed

2 files changed

+245
-40
lines changed

spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/RelationalExampleMapper.java

Lines changed: 145 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,31 @@
2222
import java.util.List;
2323
import java.util.Optional;
2424

25+
import org.jetbrains.annotations.NotNull;
26+
import org.jspecify.annotations.NonNull;
27+
import org.jspecify.annotations.Nullable;
28+
2529
import org.springframework.data.domain.Example;
30+
import org.springframework.data.domain.ExampleMatcher;
2631
import org.springframework.data.mapping.PersistentPropertyAccessor;
2732
import org.springframework.data.mapping.PropertyHandler;
33+
import org.springframework.data.mapping.PropertyPath;
2834
import org.springframework.data.mapping.context.MappingContext;
2935
import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
3036
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
3137
import org.springframework.data.relational.core.query.Criteria;
3238
import org.springframework.data.relational.core.query.Query;
3339
import org.springframework.data.support.ExampleMatcherAccessor;
3440
import org.springframework.util.Assert;
41+
import org.springframework.util.StringUtils;
3542

3643
/**
3744
* Transform an {@link Example} into a {@link Query}.
3845
*
3946
* @since 2.2
4047
* @author Greg Turnquist
4148
* @author Jens Schauder
49+
* @author Mikhail Polivakha
4250
*/
4351
public class RelationalExampleMapper {
4452

@@ -64,92 +72,192 @@ public <T> Query getMappedExample(Example<T> example) {
6472
* {@link Query}.
6573
*
6674
* @param example
67-
* @param entity
75+
* @param persistentEntity
6876
* @return query
6977
*/
70-
private <T> Query getMappedExample(Example<T> example, RelationalPersistentEntity<?> entity) {
78+
private <T> Query getMappedExample(Example<T> example, RelationalPersistentEntity<?> persistentEntity) {
7179

7280
Assert.notNull(example, "Example must not be null");
73-
Assert.notNull(entity, "RelationalPersistentEntity must not be null");
81+
Assert.notNull(persistentEntity, "RelationalPersistentEntity must not be null");
7482

75-
PersistentPropertyAccessor<T> propertyAccessor = entity.getPropertyAccessor(example.getProbe());
83+
PersistentPropertyAccessor<T> probePropertyAccessor = persistentEntity.getPropertyAccessor(example.getProbe());
7684
ExampleMatcherAccessor matcherAccessor = new ExampleMatcherAccessor(example.getMatcher());
7785

78-
final List<Criteria> criteriaBasedOnProperties = new ArrayList<>();
86+
final List<Criteria> criteriaBasedOnProperties = buildCriteriaRecursive( //
87+
persistentEntity, //
88+
matcherAccessor, //
89+
probePropertyAccessor //
90+
);
7991

80-
entity.doWithProperties((PropertyHandler<RelationalPersistentProperty>) property -> {
92+
// Criteria, assemble!
93+
Criteria criteria = Criteria.empty();
8194

82-
if (property.isCollectionLike() || property.isMap()) {
83-
return;
84-
}
95+
for (Criteria propertyCriteria : criteriaBasedOnProperties) {
8596

86-
if (matcherAccessor.isIgnoredPath(property.getName())) {
87-
return;
97+
if (example.getMatcher().isAllMatching()) {
98+
criteria = criteria.and(propertyCriteria);
99+
} else {
100+
criteria = criteria.or(propertyCriteria);
88101
}
102+
}
89103

104+
return Query.query(criteria);
105+
}
106+
107+
private <T> @NotNull List<Criteria> buildCriteriaRecursive( //
108+
RelationalPersistentEntity<?> persistentEntity, //
109+
ExampleMatcherAccessor matcherAccessor, //
110+
PersistentPropertyAccessor<T> probePropertyAccessor //
111+
) {
112+
final List<Criteria> criteriaBasedOnProperties = new ArrayList<>();
113+
114+
persistentEntity.doWithProperties((PropertyHandler<RelationalPersistentProperty>) property -> {
115+
potentiallyEnrichCriteriaByProcessingProperty(
116+
null,
117+
matcherAccessor, //
118+
probePropertyAccessor, //
119+
property, //
120+
criteriaBasedOnProperties //
121+
);
122+
});
123+
return criteriaBasedOnProperties;
124+
}
125+
126+
/**
127+
* Analyzes the incoming {@code property} and potentially enriches the {@code criteriaBasedOnProperties} with the new
128+
* {@link Criteria} for this property.
129+
* <p>
130+
* This algorithm is recursive in order to take the embedded properties into account. The caller can expect that the result
131+
* of this method call is fully processed subtree of an aggreagte where the passed {@code property} serves as the root.
132+
*
133+
* @param propertyPath the {@link PropertyPath} of the passed {@code property}.
134+
* @param matcherAccessor the accessor for the original {@link ExampleMatcher}.
135+
* @param entityPropertiesAccessor the accessor for the properties of the current entity that holds the given {@code property}
136+
* @param property the property under analysis
137+
* @param criteriaBasedOnProperties the {@link List} of criteria objects that potentially gets enriched as a
138+
* result of the incoming {@code property} processing
139+
*/
140+
private <T> void potentiallyEnrichCriteriaByProcessingProperty(
141+
@Nullable PropertyPath propertyPath,
142+
ExampleMatcherAccessor matcherAccessor, //
143+
PersistentPropertyAccessor<T> entityPropertiesAccessor, //
144+
RelationalPersistentProperty property, //
145+
List<Criteria> criteriaBasedOnProperties //
146+
) {
147+
148+
// QBE do not support queries on Child aggregates yet
149+
if (property.isCollectionLike() || property.isMap()) {
150+
return;
151+
}
152+
153+
PropertyPath currentPropertyPath = resolveCurrentPropertyPath(propertyPath, property);
154+
String currentPropertyDotPath = currentPropertyPath.toDotPath();
155+
156+
if (matcherAccessor.isIgnoredPath(currentPropertyDotPath)) {
157+
return;
158+
}
159+
160+
if (property.isEmbedded()) {
161+
processEmbeddedRecursively( //
162+
matcherAccessor, //
163+
entityPropertiesAccessor.getProperty(property),
164+
property, //
165+
criteriaBasedOnProperties, //
166+
currentPropertyPath //
167+
);
168+
} else {
90169
Optional<?> optionalConvertedPropValue = matcherAccessor //
91-
.getValueTransformerForPath(property.getName()) //
92-
.apply(Optional.ofNullable(propertyAccessor.getProperty(property)));
170+
.getValueTransformerForPath(currentPropertyDotPath) //
171+
.apply(Optional.ofNullable(entityPropertiesAccessor.getProperty(property)));
93172

94173
// If the value is empty, don't try to match against it
95-
if (!optionalConvertedPropValue.isPresent()) {
174+
if (optionalConvertedPropValue.isEmpty()) {
96175
return;
97176
}
98177

99178
Object convPropValue = optionalConvertedPropValue.get();
100-
boolean ignoreCase = matcherAccessor.isIgnoreCaseForPath(property.getName());
179+
boolean ignoreCase = matcherAccessor.isIgnoreCaseForPath(currentPropertyDotPath);
101180

102181
String column = property.getName();
103182

104-
switch (matcherAccessor.getStringMatcherForPath(property.getName())) {
183+
switch (matcherAccessor.getStringMatcherForPath(currentPropertyDotPath)) {
105184
case DEFAULT:
106185
case EXACT:
107-
criteriaBasedOnProperties.add(includeNulls(example) //
186+
criteriaBasedOnProperties.add(includeNulls(matcherAccessor) //
108187
? Criteria.where(column).isNull().or(column).is(convPropValue).ignoreCase(ignoreCase)
109188
: Criteria.where(column).is(convPropValue).ignoreCase(ignoreCase));
110189
break;
111190
case ENDING:
112-
criteriaBasedOnProperties.add(includeNulls(example) //
191+
criteriaBasedOnProperties.add(includeNulls(matcherAccessor) //
113192
? Criteria.where(column).isNull().or(column).like("%" + convPropValue).ignoreCase(ignoreCase)
114193
: Criteria.where(column).like("%" + convPropValue).ignoreCase(ignoreCase));
115194
break;
116195
case STARTING:
117-
criteriaBasedOnProperties.add(includeNulls(example) //
196+
criteriaBasedOnProperties.add(includeNulls(matcherAccessor) //
118197
? Criteria.where(column).isNull().or(column).like(convPropValue + "%").ignoreCase(ignoreCase)
119198
: Criteria.where(column).like(convPropValue + "%").ignoreCase(ignoreCase));
120199
break;
121200
case CONTAINING:
122-
criteriaBasedOnProperties.add(includeNulls(example) //
201+
criteriaBasedOnProperties.add(includeNulls(matcherAccessor) //
123202
? Criteria.where(column).isNull().or(column).like("%" + convPropValue + "%").ignoreCase(ignoreCase)
124203
: Criteria.where(column).like("%" + convPropValue + "%").ignoreCase(ignoreCase));
125204
break;
126205
default:
127-
throw new IllegalStateException(example.getMatcher().getDefaultStringMatcher() + " is not supported");
206+
throw new IllegalStateException(matcherAccessor.getDefaultStringMatcher() + " is not supported");
128207
}
129-
});
208+
}
130209

131-
// Criteria, assemble!
132-
Criteria criteria = Criteria.empty();
210+
}
133211

134-
for (Criteria propertyCriteria : criteriaBasedOnProperties) {
212+
/**
213+
* Processes an embedded entity's properties recursively.
214+
*
215+
* @param matcherAccessor the input matcher on the {@link Example#getProbe() original probe}.
216+
* @param value the actual embedded object.
217+
* @param property the embedded property.
218+
* @param criteriaBasedOnProperties collection of {@link Criteria} objects to potentially enrich.
219+
* @param currentPropertyPath the dot-separated path of the passed {@code property}.
220+
*/
221+
private void processEmbeddedRecursively(
222+
ExampleMatcherAccessor matcherAccessor,
223+
Object value,
224+
RelationalPersistentProperty property,
225+
List<Criteria> criteriaBasedOnProperties,
226+
PropertyPath currentPropertyPath
227+
) {
228+
RelationalPersistentEntity<?> embeddedPersistentEntity = mappingContext.getPersistentEntity(property.getTypeInformation());
135229

136-
if (example.getMatcher().isAllMatching()) {
137-
criteria = criteria.and(propertyCriteria);
138-
} else {
139-
criteria = criteria.or(propertyCriteria);
140-
}
141-
}
230+
PersistentPropertyAccessor<?> embeddedEntityPropertyAccessor = embeddedPersistentEntity.getPropertyAccessor(value);
142231

143-
return Query.query(criteria);
232+
embeddedPersistentEntity.doWithProperties((PropertyHandler<RelationalPersistentProperty>) embeddedProperty ->
233+
potentiallyEnrichCriteriaByProcessingProperty(
234+
currentPropertyPath,
235+
matcherAccessor,
236+
embeddedEntityPropertyAccessor,
237+
embeddedProperty,
238+
criteriaBasedOnProperties
239+
)
240+
);
241+
}
242+
243+
@NonNull
244+
private static PropertyPath resolveCurrentPropertyPath(@Nullable PropertyPath propertyPath, RelationalPersistentProperty property) {
245+
PropertyPath currentPropertyPath;
246+
247+
if (propertyPath == null) {
248+
currentPropertyPath = PropertyPath.from(property.getName(), property.getOwner().getTypeInformation());
249+
} else {
250+
currentPropertyPath = propertyPath.nested(property.getName());
251+
}
252+
return currentPropertyPath;
144253
}
145254

146255
/**
147-
* Does this {@link Example} need to include {@literal NULL} values in its {@link Criteria}?
256+
* Does this {@link ExampleMatcherAccessor} need to include {@literal NULL} values in its {@link Criteria}?
148257
*
149-
* @param example
150-
* @return whether or not to include nulls.
258+
* @return whether to include nulls.
151259
*/
152-
private static <T> boolean includeNulls(Example<T> example) {
153-
return example.getMatcher().getNullHandler() == NullHandler.INCLUDE;
260+
private static <T> boolean includeNulls(ExampleMatcherAccessor exampleMatcher) {
261+
return exampleMatcher.getNullHandler() == NullHandler.INCLUDE;
154262
}
155263
}

spring-data-relational/src/test/java/org/springframework/data/relational/repository/query/RelationalExampleMapperTests.java

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,25 @@
1717
package org.springframework.data.relational.repository.query;
1818

1919
import static org.assertj.core.api.Assertions.*;
20+
import static org.assertj.core.api.SoftAssertions.assertSoftly;
2021
import static org.springframework.data.domain.ExampleMatcher.*;
2122
import static org.springframework.data.domain.ExampleMatcher.GenericPropertyMatchers.*;
2223
import static org.springframework.data.domain.ExampleMatcher.StringMatcher.*;
2324

2425
import java.util.List;
2526
import java.util.Map;
2627
import java.util.Objects;
28+
import java.util.Optional;
2729

30+
import org.apache.commons.logging.Log;
2831
import org.junit.jupiter.api.BeforeEach;
2932
import org.junit.jupiter.api.Test;
3033
import org.springframework.data.annotation.Id;
3134
import org.springframework.data.domain.Example;
3235
import org.springframework.data.domain.ExampleMatcher;
36+
import org.springframework.data.relational.core.mapping.Embedded;
3337
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
38+
import org.springframework.data.relational.core.query.CriteriaDefinition;
3439
import org.springframework.data.relational.core.query.Query;
3540
import org.springframework.lang.Nullable;
3641

@@ -201,7 +206,7 @@ void queryByExampleWithFirstnameWithStringMatchingRegEx() {
201206

202207
Person person = new Person(null, "do", null, null, null, null);
203208

204-
ExampleMatcher matcher = matching().withStringMatcher(ExampleMatcher.StringMatcher.REGEX);
209+
ExampleMatcher matcher = matching().withStringMatcher(REGEX);
205210
Example<Person> example = Example.of(person, matcher);
206211

207212
assertThatIllegalStateException().isThrownBy(() -> exampleMapper.getMappedExample(example))
@@ -413,8 +418,100 @@ void mapAttributesGetIgnored() {
413418
assertThat(query.getCriteria().orElseThrow().toString()).doesNotContainIgnoringCase("address");
414419
}
415420

416-
record Person(@Id @Nullable String id, @Nullable String firstname, @Nullable String lastname, @Nullable String secret,
417-
@Nullable List<Possession> possessions, @Nullable Map<String, Address> addresses) {
421+
// GH-1986
422+
@Test
423+
void shouldConsiderNullabilityForEmbeddedProperties() {
424+
Example<EnclosingObject> example = Example.of( //
425+
new EnclosingObject( //
426+
12L, //
427+
null, //
428+
new EmbeddableObject(null, "Potsdam", null)
429+
),
430+
matching().withIgnorePaths("id").withIgnoreNullValues()
431+
);
432+
433+
Query mappedExample = exampleMapper.getMappedExample(example);
434+
435+
Optional<CriteriaDefinition> criteria = mappedExample.getCriteria();
436+
437+
assertThat(criteria).isPresent();
438+
assertThat(criteria.get().toString()).isEqualTo("(city = 'Potsdam')");
439+
}
440+
441+
// GH-2099
442+
@Test
443+
void shouldConsiderDeeplyEmbeddedPropertiesWithSpecifiers() {
444+
Example<EnclosingObject> example = Example.of( //
445+
new EnclosingObject( //
446+
12L, // explicitly ignored
447+
null, // ignored because it is null
448+
new EmbeddableObject(
449+
null, // ignored because it is null
450+
"Potsdam", // should be included
451+
new SecondLevelEmbeddable(
452+
"postCodeContains", // should be included
453+
"regionThatShouldBeIgnored", // explicitly ignored
454+
12L // should be included with value being transformed
455+
)
456+
)
457+
),
458+
matchingAny()
459+
.withIgnorePaths(
460+
"id",
461+
"embeddableObject.secondLevelEmbeddable.region"
462+
)
463+
.withMatcher(
464+
"embeddableObject.secondLevelEmbeddable.postCode",
465+
matcher -> matcher.ignoreCase().contains()
466+
)
467+
.withMatcher(
468+
"embeddableObject.secondLevelEmbeddable.forTransformation",
469+
matcher -> matcher.transform(o ->
470+
o.map(value -> (long) value * (long) value)
471+
)
472+
)
473+
.withIgnoreNullValues()
474+
);
475+
476+
Query mappedExample = exampleMapper.getMappedExample(example);
477+
478+
Optional<CriteriaDefinition> criteria = mappedExample.getCriteria();
479+
480+
assertThat(criteria).isPresent();
481+
assertThat(criteria.get().toString()).isEqualTo("(city = 'Potsdam') OR (postCode LIKE '%postCodeContains%') OR (forTransformation = 144)");
482+
}
483+
484+
record Person(
485+
@Id @Nullable String id,
486+
@Nullable String firstname,
487+
@Nullable String lastname,
488+
@Nullable String secret,
489+
@Nullable List<Possession> possessions,
490+
@Nullable Map<String, Address> addresses
491+
) {
492+
}
493+
494+
public static class EnclosingObject {
495+
Long id;
496+
String name;
497+
@Embedded.Nullable EmbeddableObject embeddableObject;
498+
499+
public EnclosingObject(Long id, String name, EmbeddableObject embeddableObject) {
500+
this.id = id;
501+
this.name = name;
502+
this.embeddableObject = embeddableObject;
503+
}
504+
}
505+
506+
record EmbeddableObject( //
507+
String street, //
508+
String city, //
509+
@Embedded.Nullable SecondLevelEmbeddable secondLevelEmbeddable) {
510+
511+
}
512+
513+
record SecondLevelEmbeddable(String postCode, String region, Long forTransformation) {
514+
418515
}
419516

420517
record Possession(String name) {

0 commit comments

Comments
 (0)