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 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..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 @@ -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,22 +86,41 @@ 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()) { + 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"); } + // 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); } + 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) { @@ -137,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() {} + } 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..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 @@ -167,6 +167,62 @@ 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 // 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/query/JpqlQueryBuilderUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java index 70add8e717..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 @@ -248,6 +248,83 @@ 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"); + } + + @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); } @@ -348,6 +425,13 @@ static class Groups { } + @jakarta.persistence.Entity + static class Race { + + @Id long id; + @OneToMany Set lineup; + } + @jakarta.persistence.Entity static class Group { @@ -362,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; + } + } 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..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 @@ -146,6 +146,18 @@ Window findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc(S List findByFirstnameNotIn(Collection firstnames); + List findByColleaguesRolesIsEmpty(); + + 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);