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 new file mode 100644 index 00000000..c242ac43 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/RelationalExampleMapper.java @@ -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 RelationalPersistentProperty> mappingContext; + + public RelationalExampleMapper( + MappingContext, ? extends RelationalPersistentProperty> mappingContext) { + this.mappingContext = mappingContext; + } + + /** + * Use the {@link Example} to extract a {@link Query}. + * + * @param example + * @return query + */ + public Query getMappedExample(Example 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 Query getMappedExample(Example example, RelationalPersistentEntity entity) { + + Assert.notNull(example, "Example must not be null!"); + Assert.notNull(entity, "RelationalPersistentEntity must not be null!"); + + PersistentPropertyAccessor propertyAccessor = entity.getPropertyAccessor(example.getProbe()); + ExampleMatcherAccessor matcherAccessor = new ExampleMatcherAccessor(example.getMatcher()); + + final List criteriaBasedOnProperties = new ArrayList<>(); + + entity.doWithProperties((PropertyHandler) 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 boolean includeNulls(Example example) { + return example.getMatcher().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 new file mode 100644 index 00000000..30f2016b --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/repository/query/RelationalExampleMapperTests.java @@ -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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; + } + } +}