Add support for Query by Example.

Resolves #929.
This commit is contained in:
Greg L. Turnquist
2021-02-22 09:41:10 -06:00
parent 802e4a773d
commit 0058752754
2 changed files with 588 additions and 0 deletions

View File

@@ -0,0 +1,150 @@
/*
* 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.relational.repository.query;
import static org.springframework.data.domain.ExampleMatcher.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Example;
import org.springframework.data.mapping.PersistentPropertyAccessor;
import org.springframework.data.mapping.PropertyHandler;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
import org.springframework.data.relational.core.query.Criteria;
import org.springframework.data.relational.core.query.Query;
import org.springframework.data.support.ExampleMatcherAccessor;
import org.springframework.util.Assert;
/**
* Transform an {@link Example} into a {@link Query}.
*
* @since 2.2
* @author Greg Turnquist
*/
public class RelationalExampleMapper {
private final MappingContext<? extends RelationalPersistentEntity<?>, ? extends RelationalPersistentProperty> mappingContext;
public RelationalExampleMapper(
MappingContext<? extends RelationalPersistentEntity<?>, ? extends RelationalPersistentProperty> mappingContext) {
this.mappingContext = mappingContext;
}
/**
* Use the {@link Example} to extract a {@link Query}.
*
* @param example
* @return query
*/
public <T> Query getMappedExample(Example<T> example) {
return getMappedExample(example, mappingContext.getRequiredPersistentEntity(example.getProbeType()));
}
/**
* Transform each property of the {@link Example}'s probe into a {@link Criteria} and assemble them into a
* {@link Query}.
*
* @param example
* @param entity
* @return query
*/
private <T> Query getMappedExample(Example<T> example, RelationalPersistentEntity<?> entity) {
Assert.notNull(example, "Example must not be null!");
Assert.notNull(entity, "RelationalPersistentEntity must not be null!");
PersistentPropertyAccessor<T> propertyAccessor = entity.getPropertyAccessor(example.getProbe());
ExampleMatcherAccessor matcherAccessor = new ExampleMatcherAccessor(example.getMatcher());
final List<Criteria> criteriaBasedOnProperties = new ArrayList<>();
entity.doWithProperties((PropertyHandler<RelationalPersistentProperty>) property -> {
if (matcherAccessor.isIgnoredPath(property.getName())) {
return;
}
Optional<?> optionalConvertedPropValue = matcherAccessor //
.getValueTransformerForPath(property.getName()) //
.apply(Optional.ofNullable(propertyAccessor.getProperty(property)));
// If the value is empty, don't try to match against it
if (!optionalConvertedPropValue.isPresent()) {
return;
}
Object convPropValue = optionalConvertedPropValue.get();
boolean ignoreCase = matcherAccessor.isIgnoreCaseForPath(property.getName());
String column = property.getName();
switch (matcherAccessor.getStringMatcherForPath(property.getName())) {
case DEFAULT:
case EXACT:
criteriaBasedOnProperties.add(includeNulls(example) //
? Criteria.where(column).isNull().or(column).is(convPropValue).ignoreCase(ignoreCase)
: Criteria.where(column).is(convPropValue).ignoreCase(ignoreCase));
break;
case ENDING:
criteriaBasedOnProperties.add(includeNulls(example) //
? Criteria.where(column).isNull().or(column).like("%" + convPropValue).ignoreCase(ignoreCase)
: Criteria.where(column).like("%" + convPropValue).ignoreCase(ignoreCase));
break;
case STARTING:
criteriaBasedOnProperties.add(includeNulls(example) //
? Criteria.where(column).isNull().or(column).like(convPropValue + "%").ignoreCase(ignoreCase)
: Criteria.where(column).like(convPropValue + "%").ignoreCase(ignoreCase));
break;
case CONTAINING:
criteriaBasedOnProperties.add(includeNulls(example) //
? 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!");
}
});
// Criteria, assemble!
Criteria criteria = Criteria.empty();
for (Criteria propertyCriteria : criteriaBasedOnProperties) {
if (example.getMatcher().isAllMatching()) {
criteria = criteria.and(propertyCriteria);
} else {
criteria = criteria.or(propertyCriteria);
}
}
return Query.query(criteria);
}
/**
* Does this {@link Example} need to include {@literal NULL} values in its {@link Criteria}?
*
* @param example
* @return whether or not to include nulls.
*/
private static <T> boolean includeNulls(Example<T> example) {
return example.getMatcher().getNullHandler() == NullHandler.INCLUDE;
}
}

View File

@@ -0,0 +1,438 @@
/*
* 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.relational.repository.query;
import static org.assertj.core.api.Assertions.*;
import static org.springframework.data.domain.ExampleMatcher.*;
import static org.springframework.data.domain.ExampleMatcher.GenericPropertyMatchers.*;
import static org.springframework.data.domain.ExampleMatcher.StringMatcher.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Objects;
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.RelationalMappingContext;
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
*/
public class RelationalExampleMapperTests {
RelationalExampleMapper exampleMapper;
@BeforeEach
public void before() {
exampleMapper = new RelationalExampleMapper(new RelationalMappingContext());
}
@Test // #929
void queryByExampleWithId() {
Person person = new Person();
person.setId("id1");
Example<Person> example = Example.of(person);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Objects::toString) //
.hasValue("(id = 'id1')");
}
@Test // #929
void queryByExampleWithFirstname() {
Person person = new Person();
person.setFirstname("Frodo");
Example<Person> example = Example.of(person);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname = 'Frodo')");
}
@Test // #929
void queryByExampleWithFirstnameAndLastname() {
Person person = new Person();
person.setFirstname("Frodo");
person.setLastname("Baggins");
Example<Person> example = Example.of(person);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname = 'Frodo') AND (lastname = 'Baggins')");
}
@Test // #929
void queryByExampleWithNullMatchingLastName() {
Person person = new Person();
person.setLastname("Baggins");
ExampleMatcher matcher = matching().withIncludeNullValues();
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(lastname IS NULL OR lastname = 'Baggins')");
}
@Test // #929
void queryByExampleWithNullMatchingFirstnameAndLastname() {
Person person = new Person();
person.setFirstname("Bilbo");
person.setLastname("Baggins");
ExampleMatcher matcher = matching().withIncludeNullValues();
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname IS NULL OR firstname = 'Bilbo') AND (lastname IS NULL OR lastname = 'Baggins')");
}
@Test // #929
void queryByExampleWithFirstnameAndLastnameIgnoringFirstname() {
Person person = new Person();
person.setFirstname("Frodo");
person.setLastname("Baggins");
ExampleMatcher matcher = matching().withIgnorePaths("firstname");
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(lastname = 'Baggins')");
}
@Test // #929
void queryByExampleWithFirstnameAndLastnameWithNullMatchingIgnoringFirstName() {
Person person = new Person();
person.setFirstname("Frodo");
person.setLastname("Baggins");
ExampleMatcher matcher = matching().withIncludeNullValues().withIgnorePaths("firstname");
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(lastname IS NULL OR lastname = 'Baggins')");
}
@Test // #929
void queryByExampleWithFirstnameWithStringMatchingAtTheBeginning() {
Person person = new Person();
person.setFirstname("Fro");
ExampleMatcher matcher = matching().withStringMatcher(STARTING);
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname LIKE 'Fro%')");
}
@Test // #929
void queryByExampleWithFirstnameWithStringMatchingOnTheEnding() {
Person person = new Person();
person.setFirstname("do");
ExampleMatcher matcher = matching().withStringMatcher(ENDING);
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname LIKE '%do')");
}
@Test // #929
void queryByExampleWithFirstnameWithStringMatchingContaining() {
Person person = new Person();
person.setFirstname("do");
ExampleMatcher matcher = matching().withStringMatcher(CONTAINING);
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname LIKE '%do%')");
}
@Test // #929
void queryByExampleWithFirstnameWithStringMatchingRegEx() {
Person person = new Person();
person.setFirstname("do");
ExampleMatcher matcher = matching().withStringMatcher(ExampleMatcher.StringMatcher.REGEX);
Example<Person> example = Example.of(person, matcher);
assertThatIllegalStateException().isThrownBy(() -> exampleMapper.getMappedExample(example))
.withMessageContaining("REGEX is not supported!");
}
@Test // #929
void queryByExampleWithFirstnameWithFieldSpecificStringMatcherEndsWith() {
Person person = new Person();
person.setFirstname("do");
ExampleMatcher matcher = matching().withMatcher("firstname", endsWith());
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname LIKE '%do')");
}
@Test // #929
void queryByExampleWithFirstnameWithFieldSpecificStringMatcherStartsWith() {
Person person = new Person();
person.setFirstname("Fro");
ExampleMatcher matcher = matching().withMatcher("firstname", startsWith());
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname LIKE 'Fro%')");
}
@Test // #929
void queryByExampleWithFirstnameWithFieldSpecificStringMatcherContains() {
Person person = new Person();
person.setFirstname("do");
ExampleMatcher matcher = matching().withMatcher("firstname", contains());
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname LIKE '%do%')");
}
@Test // #929
void queryByExampleWithFirstnameWithStringMatchingAtTheBeginningIncludingNull() {
Person person = new Person();
person.setFirstname("Fro");
ExampleMatcher matcher = matching().withStringMatcher(STARTING).withIncludeNullValues();
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname IS NULL OR firstname LIKE 'Fro%')");
}
@Test // #929
void queryByExampleWithFirstnameWithStringMatchingOnTheEndingIncludingNull() {
Person person = new Person();
person.setFirstname("do");
ExampleMatcher matcher = matching().withStringMatcher(ENDING).withIncludeNullValues();
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname IS NULL OR firstname LIKE '%do')");
}
@Test // #929
void queryByExampleWithFirstnameIgnoreCaseFieldLevel() {
Person person = new Person();
person.setFirstname("fro");
ExampleMatcher matcher = matching().withMatcher("firstname", startsWith().ignoreCase());
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname LIKE 'fro%')");
assertThat(example.getMatcher().getPropertySpecifiers().getForPath("firstname").getIgnoreCase()).isTrue();
}
@Test // #929
void queryByExampleWithFirstnameWithStringMatchingContainingIncludingNull() {
Person person = new Person();
person.setFirstname("do");
ExampleMatcher matcher = matching().withStringMatcher(CONTAINING).withIncludeNullValues();
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname IS NULL OR firstname LIKE '%do%')");
}
@Test // #929
void queryByExampleWithFirstnameIgnoreCase() {
Person person = new Person();
person.setFirstname("Frodo");
ExampleMatcher matcher = matching().withIgnoreCase(true);
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname = 'Frodo')");
assertThat(example.getMatcher().isIgnoreCaseEnabled()).isTrue();
}
@Test // #929
void queryByExampleWithFirstnameOrLastname() {
Person person = new Person();
person.setFirstname("Frodo");
person.setLastname("Baggins");
ExampleMatcher matcher = matchingAny();
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname = 'Frodo') OR (lastname = 'Baggins')");
}
@Test // #929
void queryByExampleEvenHandlesInvisibleFields() {
Person person = new Person();
person.setFirstname("Frodo");
person.setSecret("I have the ring!");
Example<Person> example = Example.of(person);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname = 'Frodo') AND (secret = 'I have the ring!')");
}
@Test // #929
void queryByExampleSupportsPropertyTransforms() {
Person person = new Person();
person.setFirstname("Frodo");
person.setLastname("Baggins");
person.setSecret("I have the ring!");
ExampleMatcher matcher = matching() //
.withTransformer("firstname", o -> {
if (o.isPresent()) {
return o.map(o1 -> ((String) o1).toUpperCase());
}
return o;
}) //
.withTransformer("lastname", o -> {
if (o.isPresent()) {
return o.map(o1 -> ((String) o1).toLowerCase());
}
return o;
});
Example<Person> example = Example.of(person, matcher);
Query query = exampleMapper.getMappedExample(example);
assertThat(query.getCriteria()) //
.map(Object::toString) //
.hasValue("(firstname = 'FRODO') AND (lastname = 'baggins') AND (secret = 'I have the ring!')");
}
@Data
@AllArgsConstructor
@NoArgsConstructor
static class Person {
@Id String id;
String firstname;
String lastname;
String secret;
// Override default visibility of getting the secret.
private String getSecret() {
return this.secret;
}
}
}