diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/JoinSpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/JoinSpecification.java new file mode 100644 index 0000000000..3ad9894fa7 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/JoinSpecification.java @@ -0,0 +1,35 @@ +package org.springframework.data.jpa.domain; + +import jakarta.persistence.criteria.*; + +/** + * Abstract base class for specifications which apply specifications on another joined entity. + * + * @param + * @param + */ +public abstract class JoinSpecification implements NestableSpecification { + + private NestableSpecification specification; + + protected JoinSpecification(Specification specification) { + if (!(specification instanceof NestableSpecification)) { + throw new IllegalArgumentException("specification is non-nestable"); + } + this.specification = (NestableSpecification) specification; + } + + @Override + public final Predicate toPredicate(From from, CriteriaQuery query, CriteriaBuilder builder) { + var joined = join(from); + + return specification.toPredicate(joined, query, builder); + } + + /** + * Join another entity. + * @param from entity to join from + * @return join + */ + protected abstract Join join(From from); +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/NestableSpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/NestableSpecification.java new file mode 100644 index 0000000000..0f8bcf7343 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/NestableSpecification.java @@ -0,0 +1,50 @@ +/* + * Copyright 2008-2025 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.domain; + +import jakarta.persistence.criteria.*; +import org.springframework.lang.Nullable; + +import java.io.Serializable; + +/** + * Specification in the sense of Domain Driven Design. + *

+ * A {@link Specification} working with {@link From} instead of {@link Root}. + * + * @author Sven Meier + */ +@FunctionalInterface +public interface NestableSpecification extends Specification { + + @Nullable + default Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder criteriaBuilder) { + return toPredicate((From)root, query, criteriaBuilder); + } + + /** + * Creates a WHERE clause for a query of the referenced entity in form of a {@link Predicate} for the given + * {@link From} and {@link CriteriaBuilder}. + * + * @param from must not be {@literal null}. + * @param query the criteria query. + * @param criteriaBuilder must not be {@literal null}. + * @return a {@link Predicate}, may be {@literal null}. + */ + @Nullable + Predicate toPredicate(From from, CriteriaQuery query, CriteriaBuilder criteriaBuilder); + +} 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 c8f67bc0bf..a29f12deb7 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,10 +15,7 @@ */ package org.springframework.data.jpa.domain; -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.CriteriaQuery; -import jakarta.persistence.criteria.Predicate; -import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.*; import java.io.Serial; import java.io.Serializable; @@ -55,13 +52,21 @@ public interface Specification extends Serializable { */ static Specification not(@Nullable Specification spec) { - return spec == null // - ? (root, query, builder) -> null // - : (root, query, builder) -> { - - Predicate predicate = spec.toPredicate(root, query, builder); - return predicate != null ? builder.not(predicate) : builder.disjunction(); - }; + if (spec == null) { + return (root, query, builder) -> null; + } + + if (spec instanceof NestableSpecification nestable) { + return (NestableSpecification) (from, query, builder) -> { + Predicate predicate = nestable.toPredicate(from, query, builder); + return predicate != null ? builder.not(predicate) : builder.disjunction(); + }; + } else { + return (root, query, builder) -> { + Predicate predicate = spec.toPredicate(root, query, builder); + return predicate != null ? builder.not(predicate) : builder.disjunction(); + }; + } } /** @@ -76,7 +81,7 @@ static Specification not(@Nullable Specification spec) { */ @Deprecated(since = "3.5.0", forRemoval = true) static Specification where(@Nullable Specification spec) { - return spec == null ? (root, query, builder) -> null : spec; + return spec == null ? (NestableSpecification)(from, query, builder) -> null : spec; } /** diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/SpecificationComposition.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/SpecificationComposition.java index ad78749e39..fd69a543f2 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/SpecificationComposition.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/SpecificationComposition.java @@ -17,10 +17,7 @@ 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 jakarta.persistence.criteria.*; import org.springframework.lang.Nullable; @@ -43,6 +40,20 @@ interface Combiner extends Serializable { static Specification composed(@Nullable Specification lhs, @Nullable Specification rhs, Combiner combiner) { + if (lhs instanceof NestableSpecification nlhs && rhs instanceof NestableSpecification nrhs) { + return (NestableSpecification)(from, query, builder) -> { + + Predicate thisPredicate = SpecificationComposition.toPredicate(nlhs, from, query, builder); + Predicate otherPredicate = toPredicate(nrhs, from, query, builder); + + if (thisPredicate == null) { + return otherPredicate; + } + + return otherPredicate == null ? thisPredicate : combiner.combine(builder, thisPredicate, otherPredicate); + }; + } + return (root, query, builder) -> { Predicate thisPredicate = toPredicate(lhs, root, query, builder); @@ -61,4 +72,10 @@ private static Predicate toPredicate(@Nullable Specification specificatio CriteriaBuilder builder) { return specification == null ? null : specification.toPredicate(root, query, builder); } + + @Nullable + private static Predicate toPredicate(@Nullable NestableSpecification specification, From from, @Nullable CriteriaQuery query, + CriteriaBuilder builder) { + return specification == null ? null : specification.toPredicate(from, query, builder); + } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserSpecifications.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserSpecifications.java index 304dcb5607..3020b834e4 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserSpecifications.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserSpecifications.java @@ -15,6 +15,10 @@ */ package org.springframework.data.jpa.domain.sample; +import jakarta.persistence.criteria.From; +import jakarta.persistence.criteria.Join; +import org.springframework.data.jpa.domain.JoinSpecification; +import org.springframework.data.jpa.domain.NestableSpecification; import org.springframework.data.jpa.domain.Specification; /** @@ -25,38 +29,48 @@ */ public class UserSpecifications { - public static Specification userHasFirstname(final String firstname) { + public static NestableSpecification userHasFirstname(final String firstname) { return simplePropertySpec("firstname", firstname); } - public static Specification userHasLastname(final String lastname) { + public static NestableSpecification userHasLastname(final String lastname) { return simplePropertySpec("lastname", lastname); } - public static Specification userHasFirstnameLike(final String expression) { + public static NestableSpecification userHasFirstnameLike(final String expression) { - return (root, query, cb) -> cb.like(root.get("firstname").as(String.class), String.format("%%%s%%", expression)); + return (from, query, cb) -> cb.like(from.get("firstname").as(String.class), String.format("%%%s%%", expression)); } - public static Specification userHasAgeLess(final Integer age) { + public static NestableSpecification userHasAgeLess(final Integer age) { - return (root, query, cb) -> cb.lessThan(root.get("age").as(Integer.class), age); + return (from, query, cb) -> cb.lessThan(from.get("age").as(Integer.class), age); } - public static Specification userHasLastnameLikeWithSort(final String expression) { + public static NestableSpecification userHasLastnameLikeWithSort(final String expression) { - return (root, query, cb) -> { + return (from, query, cb) -> { - query.orderBy(cb.asc(root.get("firstname"))); + query.orderBy(cb.asc(from.get("firstname"))); - return cb.like(root.get("lastname").as(String.class), String.format("%%%s%%", expression)); + return cb.like(from.get("lastname").as(String.class), String.format("%%%s%%", expression)); }; } - private static Specification simplePropertySpec(final String property, final Object value) { + private static NestableSpecification simplePropertySpec(final String property, final Object value) { - return (root, query, builder) -> builder.equal(root.get(property), value); + return (from, query, builder) -> builder.equal(from.get(property), value); + } + + public static NestableSpecification withManager(Specification specification) { + + return new JoinSpecification<>(specification) { + @Override + protected Join join(From from) { + return from.join("manager"); + } + }; } } 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 9ebddf394b..8c1387e13d 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 @@ -479,6 +479,22 @@ void executesSingleEntitySpecificationCorrectly() { assertThat(repository.findOne(userHasFirstname("Oliver"))).contains(firstUser); } + @Test + void executesJoinedEntitySpecificationCorrectly() { + + firstUser.setManager(secondUser); + flushTestUsers(); + + assertThat(repository.findOne( + withManager( + allOf( + userHasFirstname(secondUser.getFirstname()), + userHasLastname(secondUser.getLastname()) + ) + ))).contains(firstUser); + } + + @Test void returnsNullIfNoEntityFoundForSingleEntitySpecification() {