From 88abb0dbc3ed33e8c63390476f09032bfbf5f79d Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Tue, 19 Jan 2016 19:46:06 +0100 Subject: [PATCH] DATAJPA-218 - Add Predicate based QBE implementation. We convert a given Example to a set of and combined Predicates using CriteriaBuilder. Cycles within associations are not allowed and result in an InvalidDataAccessApiUsageException. At this time only SingularAttributes are taken into concern. Switched to types used in DATACMNS-810. Related tickets: DATACMNS-810. Original pull request: #164. --- .../QueryByExamplePredicateBuilder.java | 248 +++++++++++++ .../data/jpa/domain/Example.java | 138 ------- .../jpa/provider/PersistenceProvider.java | 14 +- .../data/jpa/repository/JpaRepository.java | 36 +- .../repository/query/AbstractJpaQuery.java | 4 +- .../support/SimpleJpaRepository.java | 85 +++-- ...eryByExamplePredicateBuilderUnitTests.java | 271 ++++++++++++++ .../PersistenceProviderIntegrationTests.java | 2 - .../jpa/repository/UserRepositoryTests.java | 344 +++++++++++++++++- 9 files changed, 938 insertions(+), 204 deletions(-) create mode 100644 src/main/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilder.java delete mode 100644 src/main/java/org/springframework/data/jpa/domain/Example.java create mode 100644 src/test/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilderUnitTests.java diff --git a/src/main/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilder.java b/src/main/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilder.java new file mode 100644 index 000000000..2e205e337 --- /dev/null +++ b/src/main/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilder.java @@ -0,0 +1,248 @@ +/* + * Copyright 2016 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 + * + * http://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.jpa.convert; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.Expression; +import javax.persistence.criteria.From; +import javax.persistence.criteria.Path; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import javax.persistence.metamodel.Attribute; +import javax.persistence.metamodel.Attribute.PersistentAttributeType; +import javax.persistence.metamodel.ManagedType; +import javax.persistence.metamodel.SingularAttribute; + +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Example.NullHandler; +import org.springframework.data.util.DirectFieldAccessFallbackBeanWrapper; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * {@link QueryByExamplePredicateBuilder} creates a single {@link CriteriaBuilder#and(Predicate...)} combined + * {@link Predicate} for a given {@link Example}.
+ * The builder includes any {@link SingularAttribute} of the {@link Example#getProbe()} applying {@link String} and + * {@literal null} matching strategies configured on the {@link Example}. Ignored paths are no matter of their actual + * value not considered.
+ * + * @author Christoph Strobl + * @since 1.10 + */ +public class QueryByExamplePredicateBuilder { + + private static final Set ASSOCIATION_TYPES; + + static { + ASSOCIATION_TYPES = new HashSet(Arrays.asList(PersistentAttributeType.MANY_TO_MANY, + PersistentAttributeType.MANY_TO_ONE, PersistentAttributeType.ONE_TO_MANY, PersistentAttributeType.ONE_TO_ONE)); + } + + /** + * Extract the {@link Predicate} representing the {@link Example}. + * + * @param root must not be {@literal null}. + * @param cb must not be {@literal null}. + * @param example must not be {@literal null}. + * @return never {@literal null}. + */ + public static Predicate getPredicate(Root root, CriteriaBuilder cb, Example example) { + + Assert.notNull(root, "Root must not be null!"); + Assert.notNull(cb, "CriteriaBuilder must not be null!"); + Assert.notNull(example, "Root must not be null!"); + + List predicates = getPredicates("", cb, root, root.getModel(), example.getSampleObject(), example, + new PathNode("root", null, example.getSampleObject())); + + if (predicates.isEmpty()) { + return cb.isTrue(cb.literal(false)); + } + + if (predicates.size() == 1) { + return predicates.iterator().next(); + } + + return cb.and(predicates.toArray(new Predicate[predicates.size()])); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + static List getPredicates(String path, CriteriaBuilder cb, Path from, ManagedType type, + Object value, Example example, PathNode currentNode) { + + List predicates = new ArrayList(); + DirectFieldAccessFallbackBeanWrapper beanWrapper = new DirectFieldAccessFallbackBeanWrapper(value); + + for (SingularAttribute attribute : type.getSingularAttributes()) { + + String currentPath = !StringUtils.hasText(path) ? attribute.getName() : path + "." + attribute.getName(); + + if (example.isIgnoredPath(currentPath)) { + continue; + } + + Object attributeValue = example.getValueTransformerForPath(currentPath).convert( + beanWrapper.getPropertyValue(attribute.getName())); + + if (attributeValue == null) { + + if (example.getNullHandler().equals(NullHandler.INCLUDE)) { + predicates.add(cb.isNull(from.get(attribute))); + } + continue; + } + + if (attribute.getPersistentAttributeType().equals(PersistentAttributeType.EMBEDDED)) { + + predicates.addAll(getPredicates(currentPath, cb, from.get(attribute.getName()), + (ManagedType) attribute.getType(), attributeValue, example, currentNode)); + continue; + } + + if (isAssociation(attribute)) { + + if (!(from instanceof From)) { + throw new JpaSystemException(new IllegalArgumentException(String.format( + "Unexpected path type for %s. Found % where From.class was expected.", currentPath, from))); + } + + PathNode node = currentNode.add(attribute.getName(), attributeValue); + if (node.spansCycle()) { + throw new InvalidDataAccessApiUsageException(String.format( + "Path '%s' from root %s must not span a cyclic property reference!\r\n%s", currentPath, + ClassUtils.getShortName(example.getSampleType()), node)); + } + + predicates.addAll(getPredicates(currentPath, cb, ((From) from).join(attribute.getName()), + (ManagedType) attribute.getType(), attributeValue, example, node)); + + continue; + } + + if (attribute.getJavaType().equals(String.class)) { + + Expression expression = from.get(attribute); + if (example.isIgnoreCaseForPath(currentPath)) { + expression = cb.lower(expression); + attributeValue = attributeValue.toString().toLowerCase(); + } + + switch (example.getStringMatcherForPath(currentPath)) { + + case DEFAULT: + case EXACT: + predicates.add(cb.equal(expression, attributeValue)); + break; + case CONTAINING: + predicates.add(cb.like(expression, "%" + attributeValue + "%")); + break; + case STARTING: + predicates.add(cb.like(expression, attributeValue + "%")); + break; + case ENDING: + predicates.add(cb.like(expression, "%" + attributeValue)); + break; + default: + throw new IllegalArgumentException("Unsupported StringMatcher " + + example.getStringMatcherForPath(currentPath)); + } + } else { + predicates.add(cb.equal(from.get(attribute), attributeValue)); + } + } + + return predicates; + } + + private static boolean isAssociation(Attribute attribute) { + return ASSOCIATION_TYPES.contains(attribute.getPersistentAttributeType()); + } + + /** + * {@link PathNode} is used to dynamically grow a directed graph structure that allows to detect cycles within its + * direct predecessor nodes by comparing parent node values using {@link System#identityHashCode(Object)}. + * + * @author Christoph Strobl + */ + private static class PathNode { + + String name; + PathNode parent; + List siblings = new ArrayList();; + Object value; + + public PathNode(String edge, PathNode parent, Object value) { + + this.name = edge; + this.parent = parent; + this.value = value; + } + + PathNode add(String attribute, Object value) { + + PathNode node = new PathNode(attribute, this, value); + siblings.add(node); + return node; + } + + boolean spansCycle() { + + if (value == null) { + return false; + } + + String identityHex = ObjectUtils.getIdentityHexString(value); + PathNode tmp = parent; + + while (tmp != null) { + + if (ObjectUtils.getIdentityHexString(tmp.value).equals(identityHex)) { + return true; + } + tmp = tmp.parent; + } + + return false; + } + + @Override + public String toString() { + + StringBuilder sb = new StringBuilder(); + if (parent != null) { + sb.append(parent.toString()); + sb.append(" -"); + sb.append(name); + sb.append("-> "); + } + + sb.append("[{ "); + sb.append(ObjectUtils.nullSafeToString(value)); + sb.append(" }]"); + return sb.toString(); + } + } +} diff --git a/src/main/java/org/springframework/data/jpa/domain/Example.java b/src/main/java/org/springframework/data/jpa/domain/Example.java deleted file mode 100644 index e3cc4d507..000000000 --- a/src/main/java/org/springframework/data/jpa/domain/Example.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright 2015 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 - * - * http://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.jpa.domain; - -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; - -import org.springframework.util.Assert; - -/** - * A wrapper around a prototype object that can be used in Query by Example queries - * - * @author Thomas Darimont - * @param - */ -public class Example { - - private final T prototype; - private final Set ignoredAttributes; - - /** - * Creates a new {@link Example} with the given {@code prototype}. - * - * @param prototype must not be {@literal null} - */ - public Example(T prototype) { - this(prototype, Collections. emptySet()); - } - - /** - * Creates a new {@link Example} with the given {@code prototype} ignoring the given attributes. - * - * @param prototype prototype must not be {@literal null} - * @param attributeNames prototype must not be {@literal null} - */ - public Example(T prototype, Set attributeNames) { - - Assert.notNull(prototype, "Prototype must not be null!"); - Assert.notNull(attributeNames, "attributeNames must not be null!"); - - this.prototype = prototype; - this.ignoredAttributes = attributeNames; - } - - public T getPrototype() { - return prototype; - } - - public Set getIgnoredAttributes() { - return Collections.unmodifiableSet(ignoredAttributes); - } - - public boolean isAttributeIgnored(String attributePath) { - return ignoredAttributes.contains(attributePath); - } - - public static Example exampleOf(T prototype) { - return new Example(prototype); - } - - public static Builder newExample(T prototype) { - return new Builder(prototype); - } - - /** - * A {@link Builder} for {@link Example}s. - * - * @author Thomas Darimont - * @param - */ - public static class Builder { - - private final T prototype; - private Set ignoredAttributeNames; - - /** - * @param prototype - */ - public Builder(T prototype) { - - Assert.notNull(prototype, "Prototype must not be null!"); - - this.prototype = prototype; - } - - /** - * Allows to specify attribute names that should be ignored. - * - * @param attributeNames - * @return - */ - public Builder ignoring(String... attributeNames) { - - Assert.notNull(attributeNames, "attributeNames must not be null!"); - - return ignoring(Arrays.asList(attributeNames)); - } - - /** - * Allows to specify attribute names that should be ignored. - * - * @param attributeNames - * @return - */ - public Builder ignoring(Collection attributeNames) { - - Assert.notNull(attributeNames, "attributeNames must not be null!"); - - this.ignoredAttributeNames = new HashSet(attributeNames); - return this; - } - - /** - * Constructs the actual {@link Example} instance. - * - * @return - */ - public Example build() { - return new Example(prototype, ignoredAttributeNames); - } - } -} diff --git a/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java b/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java index 35c28e8ec..901dd92d6 100644 --- a/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java +++ b/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java @@ -52,7 +52,7 @@ import org.springframework.util.ConcurrentReferenceHashMap; * @author Oliver Gierke * @author Thomas Darimont */ -public enum PersistenceProvider implements QueryExtractor,ProxyIdAccessor { +public enum PersistenceProvider implements QueryExtractor, ProxyIdAccessor { /** * Hibernate persistence provider. @@ -117,13 +117,14 @@ public enum PersistenceProvider implements QueryExtractor,ProxyIdAccessor { public CloseableIterator executeQueryWithResultStream(Query jpaQuery) { return new HibernateScrollableResultsIterator(jpaQuery); } + }, /** * EclipseLink persistence provider. */ - ECLIPSELINK(Collections.singleton(ECLIPSELINK_ENTITY_MANAGER_INTERFACE), - Collections.singleton(ECLIPSELINK_JPA_METAMODEL_TYPE)) { + ECLIPSELINK(Collections.singleton(ECLIPSELINK_ENTITY_MANAGER_INTERFACE), Collections + .singleton(ECLIPSELINK_JPA_METAMODEL_TYPE)) { public String extractQueryString(Query query) { return ((JpaQuery) query).getDatabaseQuery().getJPQLString(); @@ -163,6 +164,7 @@ public enum PersistenceProvider implements QueryExtractor,ProxyIdAccessor { public CloseableIterator executeQueryWithResultStream(Query jpaQuery) { return new EclipseLinkScrollableResultsIterator(jpaQuery); } + }, /** @@ -201,6 +203,7 @@ public enum PersistenceProvider implements QueryExtractor,ProxyIdAccessor { public CloseableIterator executeQueryWithResultStream(Query jpaQuery) { return new OpenJpaResultStreamingIterator(jpaQuery); } + }, /** @@ -243,6 +246,7 @@ public enum PersistenceProvider implements QueryExtractor,ProxyIdAccessor { public Object getIdentifierFrom(Object entity) { return null; } + }; /** @@ -384,8 +388,8 @@ public enum PersistenceProvider implements QueryExtractor,ProxyIdAccessor { } public CloseableIterator executeQueryWithResultStream(Query jpaQuery) { - throw new UnsupportedOperationException( - "Streaming results is not implement for this PersistenceProvider: " + name()); + throw new UnsupportedOperationException("Streaming results is not implement for this PersistenceProvider: " + + name()); } /** diff --git a/src/main/java/org/springframework/data/jpa/repository/JpaRepository.java b/src/main/java/org/springframework/data/jpa/repository/JpaRepository.java index 9132b8a45..885afd867 100644 --- a/src/main/java/org/springframework/data/jpa/repository/JpaRepository.java +++ b/src/main/java/org/springframework/data/jpa/repository/JpaRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2013 the original author or authors. + * Copyright 2008-2016 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. @@ -20,8 +20,10 @@ import java.util.List; import javax.persistence.EntityManager; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; -import org.springframework.data.jpa.domain.Example; import org.springframework.data.repository.NoRepositoryBean; import org.springframework.data.repository.PagingAndSortingRepository; @@ -29,6 +31,7 @@ import org.springframework.data.repository.PagingAndSortingRepository; * JPA specific extension of {@link org.springframework.data.repository.Repository}. * * @author Oliver Gierke + * @author Christoph Strobl */ @NoRepositoryBean public interface JpaRepository extends PagingAndSortingRepository { @@ -91,15 +94,34 @@ public interface JpaRepository extends PagingAndSort * @see EntityManager#getReference(Class, Object) */ T getOne(ID id); - + /** * Returns all instances of the type specified by the given {@link Example}. * - * This method is deliberately not named {@code findByExample} to not interfere - * with existing repository methods that rely on query derivation. - * * @param example must not be {@literal null}. * @return + * @since 1.10 */ - List findWithExample(Example example); + List findAllByExample(Example example); + + /** + * Returns all instances of the type specified by the given {@link Example}. + * + * @param example must not be {@literal null}. + * @param sort can be {@literal null}. + * @return all entities sorted by the given options + * @since 1.10 + */ + List findAllByExample(Example example, Sort sort); + + /** + * Returns a {@link Page} of entities meeting the paging restriction specified by the given {@link Example} limited to + * criteria provided in the {@code Pageable} object. + * + * @param example must not be {@literal null}. + * @param pageable can be {@literal null}. + * @return a {@link Page} of entities + * @since 1.10 + */ + Page findAllByExample(Example example, Pageable pageable); } diff --git a/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java b/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java index b8ec62d3f..d81cf30e2 100644 --- a/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java +++ b/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java @@ -189,8 +189,8 @@ public abstract class AbstractJpaQuery implements RepositoryQuery { Assert.notNull(query, "Query must not be null!"); Assert.notNull(method, "JpaQueryMethod must not be null!"); - Map hints = Jpa21Utils.tryGetFetchGraphHints(em, method.getEntityGraph(), - getQueryMethod().getEntityInformation().getJavaType()); + Map hints = Jpa21Utils.tryGetFetchGraphHints(em, method.getEntityGraph(), getQueryMethod() + .getEntityInformation().getJavaType()); for (Map.Entry hint : hints.entrySet()) { query.setHint(hint.getKey(), hint.getValue()); diff --git a/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java b/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java index 0191dfb7a..6d4b2f8e6 100644 --- a/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java +++ b/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java @@ -19,7 +19,6 @@ import static org.springframework.data.jpa.repository.query.QueryUtils.*; import java.io.Serializable; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -38,16 +37,14 @@ import javax.persistence.criteria.ParameterExpression; import javax.persistence.criteria.Path; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; -import javax.persistence.metamodel.Attribute; -import org.springframework.beans.BeanWrapper; -import org.springframework.beans.BeanWrapperImpl; import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.data.domain.Example; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; -import org.springframework.data.jpa.domain.Example; +import org.springframework.data.jpa.convert.QueryByExamplePredicateBuilder; import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.jpa.repository.EntityGraph; @@ -59,7 +56,6 @@ import org.springframework.data.jpa.repository.query.QueryUtils; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.Assert; -import org.springframework.util.CollectionUtils; /** * Default implementation of the {@link org.springframework.data.repository.CrudRepository} interface. This will offer @@ -420,33 +416,27 @@ public class SimpleJpaRepository implements JpaRepos /* (non-Javadoc) * @see org.springframework.data.jpa.repository.JpaRepository#findWithExample(org.springframework.data.jpa.domain.Example) */ - public List findWithExample(Example example) { + @Override + public List findAllByExample(Example example) { + return findAll(new ExampleSpecification(example)); + } - Assert.notNull(example, "Example must not be null!"); + /* + * (non-Javadoc) + * @see org.springframework.data.jpa.repository.JpaRepository#findAllByExample(org.springframework.data.domain.Example, org.springframework.data.domain.Sort) + */ + @Override + public List findAllByExample(Example example, Sort sort) { + return findAll(new ExampleSpecification(example), sort); + } - CriteriaBuilder builder = em.getCriteriaBuilder(); - CriteriaQuery query = builder.createQuery(getDomainClass()); - Root root = query.from(getDomainClass()); - - BeanWrapper bean = new BeanWrapperImpl(example.getPrototype()); - - List predicates = new ArrayList(); - for (Attribute attribute : em.getMetamodel().managedType(getDomainClass()).getAttributes()) { - - Object value = bean.getPropertyValue(attribute.getName()); - - // TODO support different matching modes configured on the provided Example - if (value == null // - || (value instanceof Collection && CollectionUtils.isEmpty((Collection) value)) - || (value instanceof Map && CollectionUtils.isEmpty((Map) value)) - || example.isAttributeIgnored(attribute.getName())) { - continue; - } - - predicates.add(builder.equal(root.get(attribute.getName()), value)); - } - - return em.createQuery(query.where(predicates.toArray(new Predicate[predicates.size()]))).getResultList(); + /* + * (non-Javadoc) + * @see org.springframework.data.jpa.repository.JpaRepository#findAllByExample(org.springframework.data.domain.Example, org.springframework.data.domain.Pageable) + */ + @Override + public Page findAllByExample(Example example, Pageable pageable) { + return findAll(new ExampleSpecification(example), pageable); } /* @@ -698,4 +688,37 @@ public class SimpleJpaRepository implements JpaRepos return path.in(parameter); } } + + /** + * {@link Specification} that gives access to the {@link Predicate} instance representing the values contained in the + * {@link Example}. + * + * @author Christoph Strobl + * @since 1.10 + * @param + */ + private static class ExampleSpecification implements Specification { + + private final Example example; + + /** + * Creates new {@link ExampleSpecification}. + * + * @param example + */ + public ExampleSpecification(Example example) { + + Assert.notNull(example, "Example must not be null!"); + this.example = example; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.jpa.domain.Specification#toPredicate(javax.persistence.criteria.Root, javax.persistence.criteria.CriteriaQuery, javax.persistence.criteria.CriteriaBuilder) + */ + @Override + public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder cb) { + return QueryByExamplePredicateBuilder.getPredicate(root, cb, example); + } + } } diff --git a/src/test/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilderUnitTests.java b/src/test/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilderUnitTests.java new file mode 100644 index 000000000..d97eb7a9c --- /dev/null +++ b/src/test/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilderUnitTests.java @@ -0,0 +1,271 @@ +/* + * Copyright 2016 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 + * + * http://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.jpa.convert; + +import static org.hamcrest.core.IsEqual.*; +import static org.junit.Assert.*; +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.data.domain.Example.*; + +import java.lang.reflect.Member; +import java.util.LinkedHashSet; +import java.util.Set; + +import javax.persistence.Id; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.Expression; +import javax.persistence.criteria.Path; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import javax.persistence.metamodel.Attribute.PersistentAttributeType; +import javax.persistence.metamodel.EntityType; +import javax.persistence.metamodel.ManagedType; +import javax.persistence.metamodel.SingularAttribute; +import javax.persistence.metamodel.Type; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Matchers; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.util.ObjectUtils; + +/** + * @author Christoph Strobl + */ +@RunWith(MockitoJUnitRunner.class) +public class QueryByExamplePredicateBuilderUnitTests { + + @Mock CriteriaBuilder cb; + @Mock Root root; + @Mock EntityType personEntityType; + @Mock Expression expressionMock; + @Mock Predicate falsePredicate; + @Mock Predicate dummyPredicate; + @Mock Predicate listPredicate; + @Mock Path dummyPath; + + Set> personEntityAttribtues; + + SingularAttribute personIdAttribute; + SingularAttribute personFirstnameAttribute; + SingularAttribute personAgeAttribute; + SingularAttribute personFatherAttribute; + SingularAttribute personSkillAttribute; + SingularAttribute personAddressAttribute; + + @Before + public void setUp() { + + personIdAttribute = new SingluarAttributeStub("id", PersistentAttributeType.BASIC, Long.class); + personFirstnameAttribute = new SingluarAttributeStub("firstname", PersistentAttributeType.BASIC, + String.class); + personAgeAttribute = new SingluarAttributeStub("age", PersistentAttributeType.BASIC, Long.class); + personFatherAttribute = new SingluarAttributeStub("father", PersistentAttributeType.MANY_TO_ONE, + Person.class); + personSkillAttribute = new SingluarAttributeStub("skill", PersistentAttributeType.MANY_TO_ONE, + Skill.class); + personAddressAttribute = new SingluarAttributeStub("address", PersistentAttributeType.EMBEDDED, + Address.class); + + personEntityAttribtues = new LinkedHashSet>(); + personEntityAttribtues.add(personIdAttribute); + personEntityAttribtues.add(personFirstnameAttribute); + personEntityAttribtues.add(personAgeAttribute); + personEntityAttribtues.add(personFatherAttribute); + personEntityAttribtues.add(personAddressAttribute); + personEntityAttribtues.add(personSkillAttribute); + + when(root.get(any(SingularAttribute.class))).thenReturn(dummyPath); + when(root.getModel()).thenReturn(personEntityType); + when(personEntityType.getSingularAttributes()).thenReturn(personEntityAttribtues); + + when(cb.equal(any(Expression.class), any(String.class))).thenReturn(dummyPredicate); + when(cb.equal(any(Expression.class), any(Long.class))).thenReturn(dummyPredicate); + when(cb.like(any(Expression.class), any(String.class))).thenReturn(dummyPredicate); + + when(cb.literal(any(Boolean.class))).thenReturn(expressionMock); + when(cb.isTrue(eq(expressionMock))).thenReturn(falsePredicate); + when(cb.and(Matchers. anyVararg())).thenReturn(listPredicate); + } + + /** + * @see DATAJPA-218 + */ + @Test(expected = IllegalArgumentException.class) + public void getPredicateShouldThrowExceptionOnNullRoot() { + QueryByExamplePredicateBuilder.getPredicate(null, cb, exampleOf(new Person())); + } + + /** + * @see DATAJPA-218 + */ + @Test(expected = IllegalArgumentException.class) + public void getPredicateShouldThrowExceptionOnNullCriteriaBuilder() { + QueryByExamplePredicateBuilder.getPredicate(root, null, exampleOf(new Person())); + } + + /** + * @see DATAJPA-218 + */ + @Test(expected = IllegalArgumentException.class) + public void getPredicateShouldThrowExceptionOnNullExample() { + QueryByExamplePredicateBuilder.getPredicate(root, null, null); + } + + /** + * @see DATAJPA-218 + */ + @Test + public void emptyCriteriaListShouldResultFalsePredicate() { + assertThat(QueryByExamplePredicateBuilder.getPredicate(root, cb, exampleOf(new Person())), equalTo(falsePredicate)); + } + + /** + * @see DATAJPA-218 + */ + @Test + public void singleElementCriteriaShouldJustReturnIt() { + + Person p = new Person(); + p.firstname = "foo"; + + assertThat(QueryByExamplePredicateBuilder.getPredicate(root, cb, exampleOf(p)), equalTo(dummyPredicate)); + verify(cb, times(1)).equal(any(Expression.class), eq("foo")); + } + + /** + * @see DATAJPA-218 + */ + @Test + public void multiPredicateCriteriaShouldReturnCombinedOnes() { + + Person p = new Person(); + p.firstname = "foo"; + p.age = 2L; + + assertThat(QueryByExamplePredicateBuilder.getPredicate(root, cb, exampleOf(p)), equalTo(listPredicate)); + + verify(cb, times(1)).equal(any(Expression.class), eq("foo")); + verify(cb, times(1)).equal(any(Expression.class), eq(2L)); + } + + static class Person { + + @Id Long id; + String firstname; + Long age; + + Person father; + Address address; + Skill skill; + } + + static class Address { + + String city; + String country; + } + + static class Skill { + + @Id Long id; + String name; + } + + static class SingluarAttributeStub implements SingularAttribute { + + private String name; + private PersistentAttributeType attributeType; + private Class type; + + public SingluarAttributeStub(String name, + javax.persistence.metamodel.Attribute.PersistentAttributeType attributeType, Class type) { + this.name = name; + this.attributeType = attributeType; + this.type = type; + } + + @Override + public String getName() { + return name; + } + + @Override + public javax.persistence.metamodel.Attribute.PersistentAttributeType getPersistentAttributeType() { + return attributeType; + } + + @Override + public ManagedType getDeclaringType() { + return null; + } + + @Override + public Class getJavaType() { + return type; + } + + @Override + public Member getJavaMember() { + return null; + } + + @Override + public boolean isAssociation() { + return !attributeType.equals(PersistentAttributeType.BASIC) + && !attributeType.equals(PersistentAttributeType.EMBEDDED); + } + + @Override + public boolean isCollection() { + return false; + } + + @Override + public javax.persistence.metamodel.Bindable.BindableType getBindableType() { + return BindableType.SINGULAR_ATTRIBUTE; + } + + @Override + public Class getBindableJavaType() { + return type; + } + + @Override + public boolean isId() { + return ObjectUtils.nullSafeEquals(name, "id"); + } + + @Override + public boolean isVersion() { + return false; + } + + @Override + public boolean isOptional() { + return false; + } + + @Override + public Type getType() { + return null; + } + + } +} diff --git a/src/test/java/org/springframework/data/jpa/provider/PersistenceProviderIntegrationTests.java b/src/test/java/org/springframework/data/jpa/provider/PersistenceProviderIntegrationTests.java index c72792c09..543ebbc20 100644 --- a/src/test/java/org/springframework/data/jpa/provider/PersistenceProviderIntegrationTests.java +++ b/src/test/java/org/springframework/data/jpa/provider/PersistenceProviderIntegrationTests.java @@ -30,8 +30,6 @@ import org.springframework.context.annotation.FilterType; import org.springframework.context.annotation.ImportResource; import org.springframework.data.jpa.domain.sample.Category; import org.springframework.data.jpa.domain.sample.Product; -import org.springframework.data.jpa.provider.PersistenceProvider; -import org.springframework.data.jpa.provider.ProxyIdAccessor; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.sample.CategoryRepository; import org.springframework.data.jpa.repository.sample.ProductRepository; diff --git a/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java b/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java index 7d99804e9..a92559d5c 100644 --- a/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java +++ b/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java @@ -16,11 +16,10 @@ package org.springframework.data.jpa.repository; import static org.hamcrest.Matchers.*; -import static org.hamcrest.Matchers.not; import static org.junit.Assert.*; +import static org.springframework.data.domain.Example.*; import static org.springframework.data.domain.Sort.Direction.*; import static org.springframework.data.jpa.domain.Specifications.*; -import static org.springframework.data.jpa.domain.Specifications.not; import static org.springframework.data.jpa.domain.sample.UserSpecifications.*; import java.util.ArrayList; @@ -42,6 +41,7 @@ import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; import org.hamcrest.Matchers; +import org.junit.Assume; import org.junit.Before; import org.junit.Ignore; import org.junit.Test; @@ -49,20 +49,24 @@ import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Example.StringMatcher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.PropertySpecifier; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; import org.springframework.data.domain.Sort.Order; -import org.springframework.data.jpa.domain.Example; import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.sample.Address; import org.springframework.data.jpa.domain.sample.Role; import org.springframework.data.jpa.domain.sample.SpecialUser; import org.springframework.data.jpa.domain.sample.User; +import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.jpa.repository.sample.SampleEvaluationContextExtension.SampleSecurityContextHolder; import org.springframework.data.jpa.repository.sample.UserRepository; import org.springframework.test.context.ContextConfiguration; @@ -1907,42 +1911,343 @@ public class UserRepositoryTests { assertThat(users, hasSize(2)); } - + /** - * @see DATAJPA-218 + * @see DATAJPA-218 */ @Test - public void queryByExample() { - + public void findAllByExample() { + flushTestUsers(); - + User prototype = new User(); prototype.setAge(28); prototype.setCreatedAt(null); - - List users = repository.findWithExample(Example.exampleOf(prototype)); - + + List users = repository.findAllByExample(exampleOf(prototype)); + assertThat(users, hasSize(1)); assertThat(users.get(0), is(firstUser)); } - + /** - * @see DATAJPA-218 + * @see DATAJPA-218 + */ + @Test(expected = InvalidDataAccessApiUsageException.class) + public void findAllByNullExample() { + repository.findAllByExample(null); + } + + /** + * @see DATAJPA-218 */ @Test - public void queryByExampleWithExcludedAttributes() { - + public void findAllByExampleWithExcludedAttributes() { + flushTestUsers(); - + User prototype = new User(); prototype.setAge(28); - - List users = repository.findWithExample(Example.newExample(prototype).ignoring("createdAt").build()); - + + List users = repository.findAllByExample(newExampleOf(prototype).ignore("createdAt").get()); + assertThat(users, hasSize(1)); assertThat(users.get(0), is(firstUser)); } + /** + * @see DATAJPA-218 + */ + @Test + public void findAllByExampleWithAssociation() { + + flushTestUsers(); + + firstUser.setManager(secondUser); + thirdUser.setManager(firstUser); + repository.save(Arrays.asList(firstUser, thirdUser)); + + User manager = new User(); + manager.setLastname("Arrasz"); + manager.setAge(secondUser.getAge()); + manager.setCreatedAt(null); + + User prototype = new User(); + prototype.setCreatedAt(null); + prototype.setManager(manager); + + List users = repository.findAllByExample(newExampleOf(prototype).ignore("age").get()); + + assertThat(users, hasSize(1)); + assertThat(users.get(0), is(firstUser)); + } + + /** + * @see DATAJPA-218 + */ + @Test + public void findAllByExampleWithEmbedded() { + + flushTestUsers(); + + firstUser.setAddress(new Address("germany", "dresden", "", "")); + repository.save(firstUser); + + User prototype = new User(); + prototype.setCreatedAt(null); + prototype.setAddress(new Address("germany", null, null, null)); + + List users = repository.findAllByExample(newExampleOf(prototype).ignore("age").get()); + + assertThat(users, hasSize(1)); + assertThat(users.get(0), is(firstUser)); + } + + /** + * @see DATAJPA-218 + */ + @Test + public void findAllByExampleWithStartingStringMatcher() { + + flushTestUsers(); + + User prototype = new User(); + prototype.setFirstname("Ol"); + + Example example = newExampleOf(prototype).matchStringsStartingWith().ignore("age", "createdAt").get(); + + List users = repository.findAllByExample(example); + + assertThat(users, hasSize(1)); + assertThat(users.get(0), is(firstUser)); + } + + /** + * @see DATAJPA-218 + */ + @Test + public void findAllByExampleWithEndingStringMatcher() { + + flushTestUsers(); + + User prototype = new User(); + prototype.setFirstname("ver"); + + Example example = newExampleOf(prototype).matchStringsEndingWith().ignore("age", "createdAt").get(); + + List users = repository.findAllByExample(example); + + assertThat(users, hasSize(1)); + assertThat(users.get(0), is(firstUser)); + } + + /** + * @see DATAJPA-218 + */ + @Test(expected = InvalidDataAccessApiUsageException.class) + public void findAllByExampleWithRegexStringMatcher() { + + flushTestUsers(); + + User prototype = new User(); + prototype.setFirstname("^Oliver$"); + + Example example = newExampleOf(prototype).withStringMatcher(StringMatcher.REGEX).ignore("age", "createdAt") + .get(); + + repository.findAllByExample(example); + } + + /** + * @see DATAJPA-218 + */ + @Test + public void findAllByExampleWithIgnoreCase() { + + flushTestUsers(); + + User prototype = new User(); + prototype.setFirstname("oLiVer"); + + Example example = newExampleOf(prototype).matchStringsWithIgnoreCase().ignore("age", "createdAt").get(); + + List users = repository.findAllByExample(example); + + assertThat(users, hasSize(1)); + assertThat(users.get(0), is(firstUser)); + } + + /** + * @see DATAJPA-218 + */ + @Test + public void findAllByExampleWithStringMatcherAndIgnoreCase() { + + flushTestUsers(); + + User prototype = new User(); + prototype.setFirstname("oLiV"); + + Example example = newExampleOf(prototype).matchStringsStartingWith().matchStringsWithIgnoreCase() + .ignore("age", "createdAt").get(); + + List users = repository.findAllByExample(example); + + assertThat(users, hasSize(1)); + assertThat(users.get(0), is(firstUser)); + } + + /** + * @see DATAJPA-218 + */ + @Test + public void findAllByExampleWithIncludeNull() { + + // something is wrong with OpenJPA - I do not know what + Assume.assumeThat(PersistenceProvider.fromEntityManager(em), not(equalTo(PersistenceProvider.OPEN_JPA))); + + flushTestUsers(); + + firstUser.setAddress(new Address("andor", "caemlyn", "", "")); + + User fifthUser = new User(); + fifthUser.setEmailAddress("foo@bar.com"); + fifthUser.setActive(firstUser.isActive()); + fifthUser.setAge(firstUser.getAge()); + fifthUser.setFirstname(firstUser.getFirstname()); + fifthUser.setLastname(firstUser.getLastname()); + + repository.save(Arrays.asList(firstUser, fifthUser)); + + User prototype = new User(); + prototype.setFirstname(firstUser.getFirstname()); + + Example example = newExampleOf(prototype).includeNullValues() + .ignore("id", "binaryData", "lastname", "emailAddress", "age", "createdAt").get(); + + List users = repository.findAllByExample(example); + + assertThat(users, hasSize(1)); + assertThat(users.get(0), is(fifthUser)); + } + + /** + * @see DATAJPA-218 + */ + @Test + public void findAllByExampleWithPropertySpecifier() { + + flushTestUsers(); + + User prototype = new User(); + prototype.setFirstname("oLi"); + + Example example = newExampleOf(prototype).matchStringsWithIgnoreCase().ignore("age", "createdAt") + .withPropertySpecifier(PropertySpecifier.newPropertySpecifier("firstname").matchStringStartingWith().get()) + .get(); + + List users = repository.findAllByExample(example); + + assertThat(users, hasSize(1)); + assertThat(users.get(0), is(firstUser)); + } + + /** + * @see DATAJPA-218 + */ + @Test + public void findAllByExampleWithSort() { + + flushTestUsers(); + + User user1 = new User("Oliver", "Srping", "o@s.de"); + user1.setAge(30); + + repository.save(user1); + + User prototype = new User(); + prototype.setFirstname("oLi"); + + Example example = newExampleOf(prototype).matchStringsStartingWith().matchStringsWithIgnoreCase() + .ignore("age", "createdAt").get(); + + List users = repository.findAllByExample(example, new Sort(DESC, "age")); + + assertThat(users, hasSize(2)); + assertThat(users.get(0), is(user1)); + assertThat(users.get(1), is(firstUser)); + } + + /** + * @see DATAJPA-218 + */ + @Test + public void findAllByExampleWithPageable() { + + flushTestUsers(); + + for (int i = 0; i < 99; i++) { + User user1 = new User("Oliver-" + i, "Srping", "o" + i + "@s.de"); + user1.setAge(30 + i); + + repository.save(user1); + } + + User prototype = new User(); + prototype.setFirstname("oLi"); + + Example example = newExampleOf(prototype).matchStringsStartingWith().matchStringsWithIgnoreCase() + .ignore("age", "createdAt").get(); + + Page users = repository.findAllByExample(example, new PageRequest(0, 10, new Sort(DESC, "age"))); + + assertThat(users.getSize(), is(10)); + assertThat(users.hasNext(), is(true)); + assertThat(users.getTotalElements(), is(100L)); + } + + /** + * @see DATAJPA-218 + */ + @Test(expected = InvalidDataAccessApiUsageException.class) + public void findAllByExampleShouldNotAllowCycles() { + + flushTestUsers(); + + User user1 = new User(); + user1.setFirstname("user1"); + + user1.setManager(user1); + + Example example = newExampleOf(user1).matchStringsStartingWith().matchStringsWithIgnoreCase() + .ignore("age", "createdAt").get(); + + repository.findAllByExample(example, new PageRequest(0, 10, new Sort(DESC, "age"))); + } + + /** + * @see DATAJPA-218 + */ + @Test(expected = InvalidDataAccessApiUsageException.class) + public void findAllByExampleShouldNotAllowCyclesOverSeveralInstances() { + + flushTestUsers(); + + User user1 = new User(); + user1.setFirstname("user1"); + + User user2 = new User(); + user2.setFirstname("user2"); + + user1.setManager(user2); + user2.setManager(user1); + + Example example = newExampleOf(user1).matchStringsStartingWith().matchStringsWithIgnoreCase() + .ignore("age", "createdAt").get(); + + repository.findAllByExample(example, new PageRequest(0, 10, new Sort(DESC, "age"))); + } + private Page executeSpecWithSort(Sort sort) { flushTestUsers(); @@ -1953,4 +2258,5 @@ public class UserRepositoryTests { assertThat(result.getTotalElements(), is(2L)); return result; } + }