From 500bdb86b7b310e729104695325c50f43223cb5b Mon Sep 17 00:00:00 2001 From: "Greg L. Turnquist" Date: Mon, 28 Mar 2022 11:41:18 -0500 Subject: [PATCH] Add fluent findBy API to JpaSpecificationExecutor. Extend fluent findBy support through the usage of Specifications. See #2274. --- .../data/jpa/domain/Specification.java | 4 +- .../repository/JpaSpecificationExecutor.java | 14 ++ .../FetchableFluentQueryBySpecification.java | 196 ++++++++++++++++++ .../support/SimpleJpaRepository.java | 34 ++- .../jpa/repository/UserRepositoryTests.java | 184 +++++++++++++++- 5 files changed, 413 insertions(+), 19 deletions(-) create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/Specification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/Specification.java index 8fa8f766e..75708b0ec 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/Specification.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/Specification.java @@ -15,13 +15,13 @@ */ package org.springframework.data.jpa.domain; -import java.io.Serializable; - import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; +import java.io.Serializable; + import org.springframework.lang.Nullable; /** diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaSpecificationExecutor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaSpecificationExecutor.java index e310f8709..b90b88d8f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaSpecificationExecutor.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaSpecificationExecutor.java @@ -17,11 +17,13 @@ package org.springframework.data.jpa.repository; import java.util.List; import java.util.Optional; +import java.util.function.Function; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.repository.query.FluentQuery; import org.springframework.lang.Nullable; /** @@ -84,4 +86,16 @@ public interface JpaSpecificationExecutor { * false. */ boolean exists(Specification spec); + + /** + * Returns entities matching the given {@link Specification} applying the {@code queryFunction} that defines the query + * and its result type. + * + * @param spec – must not be null. + * @param queryFunction – the query function defining projection, sorting, and the result type + * @return all entities matching the given Example. + * @since 3.0 + */ + R findBy(Specification spec, Function, R> queryFunction); + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java new file mode 100644 index 000000000..b88111855 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java @@ -0,0 +1,196 @@ +/* + * Copyright 2021-2022 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.jpa.repository.support; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; + +import org.springframework.dao.IncorrectResultSizeDataAccessException; +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.Specification; +import org.springframework.data.repository.query.FluentQuery; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.util.Assert; + +/** + * Immutable implementation of {@link FetchableFluentQuery} based on a {@link Specification}. All methods that return a + * {@link FetchableFluentQuery} will return a new instance, not the original. + * + * @param Domain type + * @param Result type + * @author Greg Turnquist + * @since 3.0 + */ +class FetchableFluentQueryBySpecification extends FluentQuerySupport + implements FluentQuery.FetchableFluentQuery { + + private final Specification spec; + private final Function> finder; + private final Function, Long> countOperation; + private final Function, Boolean> existsOperation; + private final EntityManager entityManager; + + public FetchableFluentQueryBySpecification(Specification spec, Class entityType, Sort sort, + Collection properties, Function> finder, + Function, Long> countOperation, Function, Boolean> existsOperation, + EntityManager entityManager) { + this(spec, entityType, (Class) entityType, Sort.unsorted(), Collections.emptySet(), finder, countOperation, + existsOperation, entityManager); + } + + private FetchableFluentQueryBySpecification(Specification spec, Class entityType, Class resultType, + Sort sort, Collection properties, Function> finder, + Function, Long> countOperation, Function, Boolean> existsOperation, + EntityManager entityManager) { + + super(resultType, sort, properties, entityType); + this.spec = spec; + this.finder = finder; + this.countOperation = countOperation; + this.existsOperation = existsOperation; + this.entityManager = entityManager; + } + + @Override + public FetchableFluentQuery sortBy(Sort sort) { + + Assert.notNull(sort, "Sort must not be null!"); + + return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, this.sort.and(sort), properties, + finder, countOperation, existsOperation, entityManager); + } + + @Override + public FetchableFluentQuery as(Class resultType) { + + Assert.notNull(resultType, "Projection target type must not be null!"); + if (!resultType.isInterface()) { + throw new UnsupportedOperationException("Class-based DTOs are not yet supported."); + } + + return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, sort, properties, finder, + countOperation, existsOperation, entityManager); + } + + @Override + public FetchableFluentQuery project(Collection properties) { + + return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, sort, properties, finder, + countOperation, existsOperation, entityManager); + } + + @Override + public R oneValue() { + + List results = createSortedAndProjectedQuery() // + .setMaxResults(2) // Never need more than 2 values + .getResultList(); + + if (results.size() > 1) { + throw new IncorrectResultSizeDataAccessException(1); + } + + return results.isEmpty() ? null : getConversionFunction().apply(results.get(0)); + } + + @Override + public R firstValue() { + + List results = createSortedAndProjectedQuery() // + .setMaxResults(1) // Never need more than 1 value + .getResultList(); + + return results.isEmpty() ? null : getConversionFunction().apply(results.get(0)); + } + + @Override + public List all() { + return convert(createSortedAndProjectedQuery().getResultList()); + } + + @Override + public Page page(Pageable pageable) { + return pageable.isUnpaged() ? new PageImpl<>(all()) : readPage(pageable); + } + + @Override + public Stream stream() { + + return createSortedAndProjectedQuery() // + .getResultStream() // + .map(getConversionFunction()); + } + + @Override + public long count() { + return countOperation.apply(spec); + } + + @Override + public boolean exists() { + return existsOperation.apply(spec); + } + + private TypedQuery createSortedAndProjectedQuery() { + + TypedQuery query = finder.apply(sort); + + if (!properties.isEmpty()) { + query.setHint(EntityGraphFactory.HINT, EntityGraphFactory.create(entityManager, entityType, properties)); + } + + return query; + } + + private Page readPage(Pageable pageable) { + + TypedQuery pagedQuery = createSortedAndProjectedQuery(); + + if (pageable.isPaged()) { + pagedQuery.setFirstResult((int) pageable.getOffset()); + pagedQuery.setMaxResults(pageable.getPageSize()); + } + + List paginatedResults = convert(pagedQuery.getResultList()); + + return PageableExecutionUtils.getPage(paginatedResults, pageable, () -> countOperation.apply(spec)); + } + + private List convert(List resultList) { + + Function conversionFunction = getConversionFunction(); + List mapped = new ArrayList<>(resultList.size()); + + for (S s : resultList) { + mapped.add(conversionFunction.apply(s)); + } + return mapped; + } + + private Function getConversionFunction() { + return getConversionFunction(entityType, resultType); + } +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java index 6ef501c06..f98555f8c 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java @@ -17,15 +17,6 @@ package org.springframework.data.jpa.repository.support; import static org.springframework.data.jpa.repository.query.QueryUtils.*; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; - import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; import jakarta.persistence.NoResultException; @@ -39,6 +30,15 @@ import jakarta.persistence.criteria.Path; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.data.domain.Example; import org.springframework.data.domain.Page; @@ -515,6 +515,22 @@ public class SimpleJpaRepository implements JpaRepositoryImplementation R findBy(Specification spec, Function, R> queryFunction) { + + Assert.notNull(spec, "Specification must not be null!"); + Assert.notNull(queryFunction, "Query function must not be null!"); + + Function> finder = sort -> { + return getQuery(spec, getDomainClass(), sort); + }; + + FetchableFluentQuery fluentQuery = new FetchableFluentQueryBySpecification(spec, getDomainClass(), + Sort.unsorted(), null, finder, this::count, this::exists, this.em); + + return queryFunction.apply((FetchableFluentQuery) fluentQuery); + } + @Override public long count() { return em.createQuery(getCountQueryString(), Long.class).getSingleResult(); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java index f25713650..fcf384fac 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java @@ -24,6 +24,13 @@ import static org.springframework.data.jpa.domain.Specification.*; import static org.springframework.data.jpa.domain.Specification.not; import static org.springframework.data.jpa.domain.sample.UserSpecifications.*; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; import lombok.Data; import java.util.ArrayList; @@ -36,14 +43,6 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Stream; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.Query; -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.CriteriaQuery; -import jakarta.persistence.criteria.Predicate; -import jakarta.persistence.criteria.Root; - import org.assertj.core.api.SoftAssertions; import org.hibernate.LazyInitializationException; import org.junit.jupiter.api.BeforeEach; @@ -2298,6 +2297,175 @@ public class UserRepositoryTests { assertThat(exists).isTrue(); } + @Test // GH-2274 + void findByFluentSpecificationWithSorting() { + + flushTestUsers(); + + List users = repository.findBy(userHasFirstnameLike("v"), q -> q.sortBy(Sort.by("firstname")).all()); + + assertThat(users).containsExactly(thirdUser, firstUser, fourthUser); + } + + @Test // GH-2274 + void findByFluentSpecificationFirstValue() { + + flushTestUsers(); + + User firstUser = repository.findBy(userHasFirstnameLike("v"), q -> q.sortBy(Sort.by("firstname")).firstValue()); + + assertThat(firstUser).isEqualTo(thirdUser); + } + + @Test // GH-2274 + void findByFluentSpecificationOneValue() { + + flushTestUsers(); + + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> repository.findBy(userHasFirstnameLike("v"), q -> q.sortBy(Sort.by("firstname")).oneValue())); + } + + @Test // GH-2274 + void findByFluentSpecificationStream() { + + flushTestUsers(); + + Stream userStream = repository.findBy(userHasFirstnameLike("v"), + q -> q.sortBy(Sort.by("firstname")).stream()); + + assertThat(userStream).containsExactly(thirdUser, firstUser, fourthUser); + } + + @Test // GH-2274 + void findByFluentSpecificationPage() { + + flushTestUsers(); + + Page page0 = repository.findBy(userHasFirstnameLike("v"), + q -> q.sortBy(Sort.by("firstname")).page(PageRequest.of(0, 2))); + + Page page1 = repository.findBy(userHasFirstnameLike("v"), + q -> q.sortBy(Sort.by("firstname")).page(PageRequest.of(1, 2))); + + assertThat(page0.getContent()).containsExactly(thirdUser, firstUser); + assertThat(page1.getContent()).containsExactly(fourthUser); + } + + @Test // GH-2274 + void findByFluentSpecificationWithInterfaceBasedProjection() { + + flushTestUsers(); + + List users = repository.findBy(userHasFirstnameLike("v"), + q -> q.as(UserProjectionInterfaceBased.class).all()); + + assertThat(users).extracting(UserProjectionInterfaceBased::getFirstname) + .containsExactlyInAnyOrder(firstUser.getFirstname(), thirdUser.getFirstname(), fourthUser.getFirstname()); + } + + @Test // GH-2274 + void findByFluentSpecificationWithSimplePropertyPathsDoesntLoadUnrequestedPaths() { + + flushTestUsers(); + // make sure we don't get preinitialized entities back: + em.clear(); + + List users = repository.findBy(userHasFirstnameLike("v"), q -> q.project("firstname").all()); + + // remove the entities, so lazy loading throws an exception + em.clear(); + + assertThat(users).extracting(User::getFirstname).containsExactlyInAnyOrder(firstUser.getFirstname(), + thirdUser.getFirstname(), fourthUser.getFirstname()); + + assertThatExceptionOfType(LazyInitializationException.class) // + .isThrownBy( // + () -> users.forEach(u -> u.getRoles().size()) // forces loading of roles + ); + } + + @Test // GH-2274 + void findByFluentSpecificationWithCollectionPropertyPathsDoesntLoadUnrequestedPaths() { + + flushTestUsers(); + // make sure we don't get preinitialized entities back: + em.clear(); + + List users = repository.findBy(userHasFirstnameLike("v"), q -> q.project("firstname", "roles").all()); + + // remove the entities, so lazy loading throws an exception + em.clear(); + + assertThat(users).extracting(User::getFirstname).containsExactlyInAnyOrder(firstUser.getFirstname(), + thirdUser.getFirstname(), fourthUser.getFirstname()); + + assertThat(users).allMatch(u -> u.getRoles().isEmpty()); + } + + @Test // GH-2274 + void findByFluentSpecificationWithComplexPropertyPathsDoesntLoadUnrequestedPaths() { + + flushTestUsers(); + // make sure we don't get preinitialized entities back: + em.clear(); + + List users = repository.findBy(userHasFirstnameLike("v"), q -> q.project("roles.name").all()); + + // remove the entities, so lazy loading throws an exception + em.clear(); + + assertThat(users).extracting(User::getFirstname).containsExactlyInAnyOrder(firstUser.getFirstname(), + thirdUser.getFirstname(), fourthUser.getFirstname()); + + assertThat(users).allMatch(u -> u.getRoles().isEmpty()); + } + + @Test // GH-2274 + void findByFluentSpecificationWithSortedInterfaceBasedProjection() { + + flushTestUsers(); + + List users = repository.findBy(userHasFirstnameLike("v"), + q -> q.as(UserProjectionInterfaceBased.class).sortBy(Sort.by("firstname")).all()); + + assertThat(users).extracting(UserProjectionInterfaceBased::getFirstname) + .containsExactlyInAnyOrder(thirdUser.getFirstname(), firstUser.getFirstname(), fourthUser.getFirstname()); + } + + @Test // GH-2274 + void fluentSpecificationWithClassBasedDtosNotYetSupported() { + + @Data + class UserDto { + String firstname; + } + + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> { + repository.findBy(userHasFirstnameLike("v"), q -> q.as(UserDto.class).sortBy(Sort.by("firstname")).all()); + }); + } + + @Test // GH-2274 + void countByFluentSpecification() { + + flushTestUsers(); + + long numOfUsers = repository.findBy(userHasFirstnameLike("v"), q -> q.sortBy(Sort.by("firstname")).count()); + + assertThat(numOfUsers).isEqualTo(3); + } + + @Test // GH-2274 + void existsByFluentSpecification() { + + flushTestUsers(); + + boolean exists = repository.findBy(userHasFirstnameLike("v"), q -> q.sortBy(Sort.by("firstname")).exists()); + + assertThat(exists).isTrue(); + } + @Test // DATAJPA-218 void countByExampleWithExcludedAttributes() {