From a39c4c503798667780893a92106036ea4970044f Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Mon, 15 Dec 2025 10:30:04 +0100 Subject: [PATCH 1/5] Prepare issue branch. --- pom.xml | 2 +- spring-data-envers/pom.xml | 4 ++-- spring-data-jpa-distribution/pom.xml | 2 +- spring-data-jpa/pom.xml | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pom.xml b/pom.xml index 04c71fe23e..4d64df6e37 100755 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-jpa-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-4110-SNAPSHOT pom Spring Data JPA Parent diff --git a/spring-data-envers/pom.xml b/spring-data-envers/pom.xml index c54fa73c20..095e8bd9c1 100755 --- a/spring-data-envers/pom.xml +++ b/spring-data-envers/pom.xml @@ -5,12 +5,12 @@ org.springframework.data spring-data-envers - 4.1.0-SNAPSHOT + 4.1.x-GH-4110-SNAPSHOT org.springframework.data spring-data-jpa-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-4110-SNAPSHOT ../pom.xml diff --git a/spring-data-jpa-distribution/pom.xml b/spring-data-jpa-distribution/pom.xml index 954a3a249b..ceacf5342e 100644 --- a/spring-data-jpa-distribution/pom.xml +++ b/spring-data-jpa-distribution/pom.xml @@ -14,7 +14,7 @@ org.springframework.data spring-data-jpa-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-4110-SNAPSHOT ../pom.xml diff --git a/spring-data-jpa/pom.xml b/spring-data-jpa/pom.xml index d26ca6b94b..03561d26d7 100644 --- a/spring-data-jpa/pom.xml +++ b/spring-data-jpa/pom.xml @@ -7,7 +7,7 @@ org.springframework.data spring-data-jpa - 4.1.0-SNAPSHOT + 4.1.x-GH-4110-SNAPSHOT Spring Data JPA Spring Data module for JPA repositories. @@ -16,7 +16,7 @@ org.springframework.data spring-data-jpa-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-4110-SNAPSHOT ../pom.xml From 0079908ca68889b536672d0106dd931d2c0e8405 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Wed, 17 Dec 2025 11:07:37 +0100 Subject: [PATCH 2/5] Reference collection via join in jpql. --- .../jpa/repository/query/JpaQueryCreator.java | 10 ++++++-- .../data/jpa/repository/query/JpqlUtils.java | 5 ++++ .../repository/UserRepositoryFinderTests.java | 11 +++++++++ .../query/JpqlQueryBuilderUnitTests.java | 23 +++++++++++++++++++ .../jpa/repository/sample/UserRepository.java | 4 ++++ 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java index 9361a50a9f..1be903171a 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java @@ -481,7 +481,10 @@ public JpqlQueryBuilder.Predicate build() { case NOT_CONTAINING: if (property.getLeafProperty().isCollection()) { - where = JpqlQueryBuilder.where(entity, property); + + if (!property.hasNext()) { + where = JpqlQueryBuilder.where(entity, property); + } return type.equals(NOT_CONTAINING) ? where.notMemberOf(placeholder(provider.next(part))) : where.memberOf(placeholder(provider.next(part))); @@ -522,7 +525,10 @@ public JpqlQueryBuilder.Predicate build() { throw new IllegalArgumentException("IsEmpty / IsNotEmpty can only be used on collection properties"); } - where = JpqlQueryBuilder.where(entity, property); + if (!property.hasNext()) { + where = JpqlQueryBuilder.where(entity, property); + } + return type.equals(IS_NOT_EMPTY) ? where.isNotEmpty() : where.isEmpty(); case WITHIN: case NEAR: diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java index e1aa748554..8932d2aa24 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java @@ -97,6 +97,11 @@ public JpqlQueryBuilder.PathExpression toExpressionRecursively(Metamodel metamod throw new IllegalStateException("Binding property is null"); } + // this is a reference to a collection property (eg. for an is empty check) + if (nextAttribute.isCollection() && !nextProperty.hasNext()) { + return new JpqlQueryBuilder.PathAndOrigin(nextProperty, joinSource, false); + } + return toExpressionRecursively(metamodel, joinSource, (Bindable) nextAttribute, nextProperty, isForSelection, requiresOuterJoin); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java index 2c73a64803..d623e5ad4a 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java @@ -167,6 +167,17 @@ void executesNotInQueryCorrectly() { assertThat(result).containsExactly(oliver); } + @Test // GH-4110 + void executesQueryWithEmptyOnCollection() { + + dave.addColleague(oliver); + userRepository.save(dave); + userRepository.save(oliver); + + assertThat(userRepository.findByColleaguesRolesIsEmpty()).containsExactly(dave, carter); + assertThat(userRepository.findByColleaguesRolesIsNotEmpty()).containsExactlyInAnyOrder(oliver); + } + @Test // DATAJPA-92 void findsByLastnameIgnoringCase() { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java index 70add8e717..6b7b20e878 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java @@ -248,6 +248,22 @@ void shouldRenderJoinsWithSamePathSegmentCorrectly() { } + @Test // GH-4110 + void referencesCollectionViaJoin() { + + TestMetaModel model = TestMetaModel.hibernateModel(Race.class, Groups.class, Group.class, GroupId.class, + Person.class); + Entity entity = entity(Race.class); + + EntityType entityType = model.entity(Race.class); + JpqlQueryBuilder.PathExpression pas = JpqlUtils.toExpressionRecursively(model, entity, entityType, + PropertyPath.from("lineup.groups", Race.class)); + String jpql = JpqlQueryBuilder.selectFrom(entity).entity().where(JpqlQueryBuilder.where(pas).isNotEmpty()).render(); + + assertThat(jpql).isEqualTo( + "SELECT r FROM JpqlQueryBuilderUnitTests$Race r LEFT JOIN r.lineup l WHERE l.groups IS NOT EMPTY"); + } + static ContextualAssert contextual(RenderContext context) { return new ContextualAssert(context); } @@ -348,6 +364,13 @@ static class Groups { } + @jakarta.persistence.Entity + static class Race { + + @Id long id; + @OneToMany Set lineup; + } + @jakarta.persistence.Entity static class Group { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java index 004488e471..def0d3024e 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java @@ -146,6 +146,10 @@ Window findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc(S List findByFirstnameNotIn(Collection firstnames); + List findByColleaguesRolesIsEmpty(); + + List findByColleaguesRolesIsNotEmpty(); + // DATAJPA-292 @Query("select u from User u where u.firstname like ?1%") List findByFirstnameLike(String firstname); From d201bcfb279a0a30db2576c58a68630e3fb221db Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Thu, 18 Dec 2025 10:02:17 +0100 Subject: [PATCH 3/5] Fix issue with collection types directly referenced on the root entity --- .../data/jpa/repository/query/JpqlUtils.java | 6 ++ .../query/JpqlQueryBuilderUnitTests.java | 77 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java index 8932d2aa24..824b2a8a97 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java @@ -17,6 +17,7 @@ import jakarta.persistence.metamodel.Attribute; import jakarta.persistence.metamodel.Bindable; +import jakarta.persistence.metamodel.EntityType; import jakarta.persistence.metamodel.ManagedType; import jakarta.persistence.metamodel.Metamodel; @@ -85,6 +86,11 @@ public JpqlQueryBuilder.PathExpression toExpressionRecursively(Metamodel metamod // if it's a leaf, return the join if (isLeafProperty) { + + // except its a collection type on the root + if (from instanceof EntityType && property.isCollection()) { + return new JpqlQueryBuilder.PathAndOrigin(property, source, false); + } return new JpqlQueryBuilder.PathAndOrigin(property, joinSource, true); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java index 6b7b20e878..81bb79c2a7 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java @@ -264,6 +264,67 @@ void referencesCollectionViaJoin() { "SELECT r FROM JpqlQueryBuilderUnitTests$Race r LEFT JOIN r.lineup l WHERE l.groups IS NOT EMPTY"); } + @Test // GH-4110 + void referencesCollectionViaMultipleJoins() { + + TestMetaModel model = TestMetaModel.hibernateModel(TestUser.class, TestRole.class); + Entity entity = entity(TestUser.class); + + EntityType entityType = model.entity(TestUser.class); + JpqlQueryBuilder.PathExpression pas = JpqlUtils.toExpressionRecursively(model, entity, entityType, + PropertyPath.from("manager.colleagues.roles", TestUser.class)); + String jpql = JpqlQueryBuilder.selectFrom(entity).entity().where(JpqlQueryBuilder.where(pas).isEmpty()).render(); + + assertThat(jpql).isEqualTo( + "SELECT t FROM JpqlQueryBuilderUnitTests$TestUser t LEFT JOIN t.manager m LEFT JOIN m.colleagues c WHERE c.roles IS EMPTY"); + } + + @Test // GH-4110 + void referencesCollectionViaJoinWithMemberOf() { + + TestMetaModel model = TestMetaModel.hibernateModel(TestUser.class, TestRole.class); + Entity entity = entity(TestUser.class); + + EntityType entityType = model.entity(TestUser.class); + JpqlQueryBuilder.PathExpression pas = JpqlUtils.toExpressionRecursively(model, entity, entityType, + PropertyPath.from("colleagues.roles", TestUser.class)); + String jpql = JpqlQueryBuilder.selectFrom(entity).entity() + .where(JpqlQueryBuilder.where(pas).memberOf(JpqlQueryBuilder.parameter("?1"))).render(); + + assertThat(jpql).isEqualTo( + "SELECT t FROM JpqlQueryBuilderUnitTests$TestUser t LEFT JOIN t.colleagues c WHERE ?1 MEMBER OF c.roles"); + } + + @Test // GH-4110 + void directCollectionPropertyDoesNotCreateJoin() { + + TestMetaModel model = TestMetaModel.hibernateModel(TestUser.class, TestRole.class); + Entity entity = entity(TestUser.class); + + EntityType entityType = model.entity(TestUser.class); + JpqlQueryBuilder.PathExpression pas = JpqlUtils.toExpressionRecursively(model, entity, entityType, + PropertyPath.from("roles", TestUser.class)); + String jpql = JpqlQueryBuilder.selectFrom(entity).entity().where(JpqlQueryBuilder.where(pas).isEmpty()).render(); + + assertThat(jpql).isEqualTo("SELECT t FROM JpqlQueryBuilderUnitTests$TestUser t WHERE t.roles IS EMPTY"); + } + + @Test // GH-4110 + void collectionWithAdditionalPathSegments() { + + TestMetaModel model = TestMetaModel.hibernateModel(TestUser.class, TestRole.class); + Entity entity = entity(TestUser.class); + + EntityType entityType = model.entity(TestUser.class); + JpqlQueryBuilder.PathExpression pas = JpqlUtils.toExpressionRecursively(model, entity, entityType, + PropertyPath.from("colleagues.roles.name", TestUser.class)); + String jpql = JpqlQueryBuilder.selectFrom(entity).entity() + .where(JpqlQueryBuilder.where(pas).eq(literal("ADMIN"))).render(); + + assertThat(jpql).isEqualTo( + "SELECT t FROM JpqlQueryBuilderUnitTests$TestUser t LEFT JOIN t.colleagues c LEFT JOIN c.roles r WHERE r.name = 'ADMIN'"); + } + static ContextualAssert contextual(RenderContext context) { return new ContextualAssert(context); } @@ -385,4 +446,20 @@ static class GroupId { } + @jakarta.persistence.Entity + static class TestUser { + + @Id long id; + @ManyToOne TestUser manager; + @OneToMany Set colleagues = new HashSet<>(); + @OneToMany Set roles = new HashSet<>(); + } + + @jakarta.persistence.Entity + static class TestRole { + + @Id long id; + String name; + } + } From b47b8e283aa6a9b82745d3517e96dcb864639278 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Thu, 18 Dec 2025 10:39:22 +0100 Subject: [PATCH 4/5] Additional integration tests --- .../repository/UserRepositoryFinderTests.java | 45 +++++++++++++++++++ .../jpa/repository/sample/UserRepository.java | 8 ++++ 2 files changed, 53 insertions(+) diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java index d623e5ad4a..7074d9d7c2 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java @@ -178,6 +178,51 @@ void executesQueryWithEmptyOnCollection() { assertThat(userRepository.findByColleaguesRolesIsNotEmpty()).containsExactlyInAnyOrder(oliver); } + @Test // GH-4110 + void executesQueryWithEmptyOnCollectionViaMultipleJoins() { + + carter.setManager(dave); + dave.addColleague(oliver); + dave.addColleague(carter); + userRepository.save(dave); + userRepository.save(carter); + userRepository.save(oliver); + + userRepository.save(oliver); + + assertThat(userRepository.findByManagerColleaguesRolesIsNotEmpty()).containsExactly(carter); + } + + @Test // GH-4110 + void executesQueryWithContainingOnCollectionViaJoin() { + + dave.addColleague(oliver); + oliver.addRole(singer); + userRepository.save(dave); + userRepository.save(oliver); + + assertThat(userRepository.findByColleaguesRolesContaining(singer)).containsExactlyInAnyOrder(dave, oliver); + assertThat(userRepository.findByColleaguesRolesNotContaining(drummer)).containsExactlyInAnyOrder(dave, carter, oliver); + } + + @Test // GH-4110 + void executesQueryWithMultipleCollectionPredicates() { + + dave.addColleague(oliver); + dave.getAttributes().add("test"); + userRepository.save(dave); + userRepository.save(oliver); + + assertThat(userRepository.findByColleaguesRolesIsEmptyAndAttributesIsNotEmpty()) + .containsExactlyInAnyOrder(dave); + } + + @Test // GH-4110 + void executesQueryWithEmptyOnCollectionWithNoColleagues() { + + assertThat(userRepository.findByColleaguesRolesIsEmpty()).containsExactly(dave, carter, oliver); + } + @Test // DATAJPA-92 void findsByLastnameIgnoringCase() { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java index def0d3024e..1cdc072fb2 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java @@ -150,6 +150,14 @@ Window findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc(S List findByColleaguesRolesIsNotEmpty(); + List findByManagerColleaguesRolesIsNotEmpty(); + + List findByColleaguesRolesContaining(Role role); + + List findByColleaguesRolesNotContaining(Role role); + + List findByColleaguesRolesIsEmptyAndAttributesIsNotEmpty(); + // DATAJPA-292 @Query("select u from User u where u.firstname like ?1%") List findByFirstnameLike(String firstname); From 62c6caf4ba6b05decf6bb800c394123aae051588 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Thu, 18 Dec 2025 10:54:30 +0100 Subject: [PATCH 5/5] check attribute type for association --- .../data/jpa/repository/query/JpqlUtils.java | 18 +++++++++++++----- .../EclipseLinkUserRepositoryFinderTests.java | 4 ++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java index 824b2a8a97..4355ee69e0 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java @@ -89,15 +89,17 @@ public JpqlQueryBuilder.PathExpression toExpressionRecursively(Metamodel metamod // except its a collection type on the root if (from instanceof EntityType && property.isCollection()) { - return new JpqlQueryBuilder.PathAndOrigin(property, source, false); + Attribute nextAttribute = resolveAttribute(metamodel, from, property); + if(nextAttribute != null && nextAttribute.isAssociation()) { + return new JpqlQueryBuilder.PathAndOrigin(property, source, false); + } } return new JpqlQueryBuilder.PathAndOrigin(property, joinSource, true); } PropertyPath nextProperty = Objects.requireNonNull(property.next(), "An element of the property path is null"); - ManagedType managedTypeForModel = getManagedTypeForModel(from); - Attribute nextAttribute = getModelForPath(metamodel, property, managedTypeForModel, from); + Attribute nextAttribute = resolveAttribute(metamodel, from, property); if (nextAttribute == null) { throw new IllegalStateException("Binding property is null"); @@ -112,6 +114,13 @@ public JpqlQueryBuilder.PathExpression toExpressionRecursively(Metamodel metamod requiresOuterJoin); } + private static @Nullable Attribute resolveAttribute(Metamodel metamodel, Bindable from, + PropertyPath property) { + + ManagedType managedType = getManagedTypeForModel(from); + return getModelForPath(metamodel, property, managedType, from); + } + private static @Nullable Attribute getModelForPath(@Nullable Metamodel metamodel, PropertyPath path, @Nullable ManagedType managedType, @Nullable Bindable fallback) { @@ -148,8 +157,7 @@ record BindablePathResolver(Metamodel metamodel, } private @Nullable Attribute resolveAttribute(PropertyPath propertyPath) { - ManagedType managedType = getManagedTypeForModel(bindable); - return getModelForPath(metamodel, propertyPath, managedType, bindable); + return JpqlExpressionFactory.resolveAttribute(metamodel, bindable, propertyPath); } @Override diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java index 9ad2fe8f3c..5d6367a71c 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java @@ -40,4 +40,8 @@ void executesInKeywordForPageCorrectly() {} @Override void shouldProjectWithKeysetScrolling() {} + @Disabled + @Override + void executesQueryWithContainingOnCollectionViaJoin() {} + }