From 5b85718632705fbb6be71ba64765b6c110c2b5c0 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Fri, 23 Aug 2024 10:22:02 +0200 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 ade6f22b06..8ba05e1147 100755 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-jpa-parent - 4.0.0-SNAPSHOT + 4.0.0-GH-3588-SNAPSHOT pom Spring Data JPA Parent diff --git a/spring-data-envers/pom.xml b/spring-data-envers/pom.xml index 0bdf2c8e7e..3782a4af3e 100755 --- a/spring-data-envers/pom.xml +++ b/spring-data-envers/pom.xml @@ -5,12 +5,12 @@ org.springframework.data spring-data-envers - 4.0.0-SNAPSHOT + 4.0.0-GH-3588-SNAPSHOT org.springframework.data spring-data-jpa-parent - 4.0.0-SNAPSHOT + 4.0.0-GH-3588-SNAPSHOT ../pom.xml diff --git a/spring-data-jpa-distribution/pom.xml b/spring-data-jpa-distribution/pom.xml index af5244a230..e2e4a70a89 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.0.0-SNAPSHOT + 4.0.0-GH-3588-SNAPSHOT ../pom.xml diff --git a/spring-data-jpa/pom.xml b/spring-data-jpa/pom.xml index ae5a4738dc..7fff0fe902 100644 --- a/spring-data-jpa/pom.xml +++ b/spring-data-jpa/pom.xml @@ -6,7 +6,7 @@ org.springframework.data spring-data-jpa - 4.0.0-SNAPSHOT + 4.0.0-GH-3588-SNAPSHOT Spring Data JPA Spring Data module for JPA repositories. @@ -15,7 +15,7 @@ org.springframework.data spring-data-jpa-parent - 4.0.0-SNAPSHOT + 4.0.0-GH-3588-SNAPSHOT ../pom.xml From b1e9483e722d995d7f432bf592ca1ae2a5f9e22d Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Fri, 23 Aug 2024 10:23:22 +0200 Subject: [PATCH 2/5] Replace `CriteriaQuery` in `JpaQueryCreator` with String-based queries. Introduce new DSL to construct JPQL queries. Refactor ParameterMetadata to PartTreeParameterBinding. Disable Keyset pagination with projections for Eclipselink as Eclipselink doesn't consider type hints for JPQL queries. --- .../repository/query/AbstractJpaQuery.java | 4 +- .../query/AbstractStringBasedJpaQuery.java | 10 +- ...bernateJpaParametersParameterAccessor.java | 2 +- .../query/JpaCountQueryCreator.java | 46 +- .../query/JpaKeysetScrollQueryCreator.java | 65 +- .../query/JpaParametersParameterAccessor.java | 9 +- .../jpa/repository/query/JpaQueryCreator.java | 382 +++--- .../repository/query/JpqlQueryBuilder.java | 1219 +++++++++++++++++ .../repository/query/JpqlQueryCreator.java | 34 + .../data/jpa/repository/query/JpqlUtils.java | 82 ++ .../query/KeysetScrollDelegate.java | 25 + .../query/KeysetScrollSpecification.java | 88 +- .../data/jpa/repository/query/NamedQuery.java | 10 +- .../jpa/repository/query/ParameterBinder.java | 16 +- .../query/ParameterBinderFactory.java | 34 +- .../repository/query/ParameterBinding.java | 180 ++- .../query/ParameterMetadataProvider.java | 141 +- .../repository/query/PartTreeJpaQuery.java | 230 ++-- .../query/QueryParameterSetter.java | 227 ++- .../query/QueryParameterSetterFactory.java | 137 +- .../data/jpa/repository/query/QueryUtils.java | 4 +- .../query/StoredProcedureJpaQuery.java | 5 +- .../support/JpqlQueryTemplates.java | 49 + .../EclipseLinkUserRepositoryFinderTests.java | 6 + .../jpa/repository/UserRepositoryTests.java | 19 +- .../JpaCountQueryCreatorIntegrationTests.java | 28 +- .../JpaKeysetScrollQueryCreatorTests.java | 95 ++ .../JpaParametersParameterAccessorTests.java | 2 +- ...rIndexedQueryParameterSetterUnitTests.java | 20 +- .../query/ParameterBinderUnitTests.java | 6 +- .../ParameterExpressionProviderTests.java | 70 - ...meterMetadataProviderIntegrationTests.java | 15 +- .../ParameterMetadataProviderUnitTests.java | 16 +- .../PartTreeJpaQueryIntegrationTests.java | 26 +- .../QueryParameterSetterFactoryUnitTests.java | 21 +- 35 files changed, 2553 insertions(+), 770 deletions(-) create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryCreator.java create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpqlQueryTemplates.java create mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreatorTests.java delete mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterExpressionProviderTests.java diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java index 5742a1ea4e..1ecedcee11 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java @@ -235,8 +235,8 @@ private Query applyLockMode(Query query, JpaQueryMethod method) { return lockModeType == null ? query : query.setLockMode(lockModeType); } - protected ParameterBinder createBinder() { - return ParameterBinderFactory.createBinder(getQueryMethod().getParameters()); + ParameterBinder createBinder() { + return ParameterBinderFactory.createBinder(getQueryMethod().getParameters(), false); } protected Query createQuery(JpaParametersParameterAccessor parameters) { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java index 0d257fe5a2..9c83985546 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java @@ -51,7 +51,6 @@ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery { private final DeclaredQuery query; private final Lazy countQuery; private final ValueExpressionDelegate valueExpressionDelegate; - private final QueryParameterSetter.QueryMetadataCache metadataCache = new QueryParameterSetter.QueryMetadataCache(); private final QueryRewriter queryRewriter; private final QuerySortRewriter querySortRewriter; private final Lazy countParameterBinder; @@ -121,11 +120,9 @@ public Query doCreateQuery(JpaParametersParameterAccessor accessor) { Query query = createJpaQuery(sortedQueryString, sort, accessor.getPageable(), processor.getReturnedType()); - QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(sortedQueryString, query); - // it is ok to reuse the binding contained in the ParameterBinder although we create a new query String because the // parameters in the query do not change. - return parameterBinder.get().bindAndPrepare(query, metadata, accessor); + return parameterBinder.get().bindAndPrepare(query, accessor); } String getSortedQueryString(Sort sort) { @@ -152,9 +149,8 @@ protected Query doCreateCountQuery(JpaParametersParameterAccessor accessor) { ? em.createNativeQuery(queryString) // : em.createQuery(queryString, Long.class); - QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(queryString, query); - - countParameterBinder.get().bind(metadata.withQuery(query), accessor, QueryParameterSetter.ErrorHandling.LENIENT); + countParameterBinder.get().bind(new QueryParameterSetter.BindableQuery(query), accessor, + QueryParameterSetter.ErrorHandling.LENIENT); return query; } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateJpaParametersParameterAccessor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateJpaParametersParameterAccessor.java index 53291a0ea0..22ea109db7 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateJpaParametersParameterAccessor.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateJpaParametersParameterAccessor.java @@ -51,7 +51,7 @@ class HibernateJpaParametersParameterAccessor extends JpaParametersParameterAcce * @param values must not be {@literal null}. * @param em must not be {@literal null}. */ - HibernateJpaParametersParameterAccessor(Parameters parameters, Object[] values, EntityManager em) { + HibernateJpaParametersParameterAccessor(JpaParameters parameters, Object[] values, EntityManager em) { super(parameters, values); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreator.java index 851a867214..455e3dd865 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreator.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreator.java @@ -15,16 +15,12 @@ */ package org.springframework.data.jpa.repository.query; -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.CriteriaQuery; -import jakarta.persistence.criteria.Expression; -import jakarta.persistence.criteria.Predicate; -import jakarta.persistence.criteria.Root; +import jakarta.persistence.EntityManager; import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.repository.query.parser.PartTree; -import org.springframework.lang.Nullable; /** * Special {@link JpaQueryCreator} that creates a count projecting query. @@ -36,41 +32,35 @@ */ public class JpaCountQueryCreator extends JpaQueryCreator { - private boolean distinct; + private final boolean distinct; + private final ReturnedType returnedType; /** - * Creates a new {@link JpaCountQueryCreator}. + * Creates a new {@link JpaCountQueryCreator} * * @param tree - * @param type - * @param builder + * @param returnedType * @param provider + * @param templates + * @param em */ - public JpaCountQueryCreator(PartTree tree, ReturnedType type, CriteriaBuilder builder, - ParameterMetadataProvider provider) { + public JpaCountQueryCreator(PartTree tree, ReturnedType returnedType, ParameterMetadataProvider provider, + JpqlQueryTemplates templates, EntityManager em) { - super(tree, type, builder, provider); + super(tree, returnedType, provider, templates, em); this.distinct = tree.isDistinct(); + this.returnedType = returnedType; } @Override - protected CriteriaQuery createCriteriaQuery(CriteriaBuilder builder, ReturnedType type) { + protected JpqlQueryBuilder.Select buildQuery(Sort sort) { - return builder.createQuery(Long.class); - } - - @Override - @SuppressWarnings("unchecked") - protected CriteriaQuery complete(@Nullable Predicate predicate, Sort sort, - CriteriaQuery query, CriteriaBuilder builder, Root root) { - - CriteriaQuery select = query.select(getCountQuery(query, builder, root)); - return predicate == null ? select : select.where(predicate); - } + JpqlQueryBuilder.SelectStep selectStep = JpqlQueryBuilder.selectFrom(returnedType.getDomainType()); + if (this.distinct) { + selectStep = selectStep.distinct(); + } - @SuppressWarnings("rawtypes") - private Expression getCountQuery(CriteriaQuery query, CriteriaBuilder builder, Root root) { - return distinct ? builder.countDistinct(root) : builder.count(root); + return selectStep.count(); } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java index 25e7c25ca9..47d093deff 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java @@ -15,18 +15,19 @@ */ package org.springframework.data.jpa.repository.query; -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.CriteriaQuery; -import jakarta.persistence.criteria.Predicate; -import jakarta.persistence.criteria.Root; +import jakarta.persistence.EntityManager; +import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import org.springframework.data.domain.KeysetScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.support.JpaEntityInformation; +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.repository.query.parser.PartTree; import org.springframework.lang.Nullable; @@ -41,35 +42,67 @@ class JpaKeysetScrollQueryCreator extends JpaQueryCreator { private final JpaEntityInformation entityInformation; private final KeysetScrollPosition scrollPosition; + private final ParameterMetadataProvider provider; + private final List syntheticBindings = new ArrayList<>(); - public JpaKeysetScrollQueryCreator(PartTree tree, ReturnedType type, CriteriaBuilder builder, - ParameterMetadataProvider provider, JpaEntityInformation entityInformation, - KeysetScrollPosition scrollPosition) { + public JpaKeysetScrollQueryCreator(PartTree tree, ReturnedType type, ParameterMetadataProvider provider, + JpqlQueryTemplates templates, JpaEntityInformation entityInformation, KeysetScrollPosition scrollPosition, + EntityManager em) { - super(tree, type, builder, provider); + super(tree, type, provider, templates, em); this.entityInformation = entityInformation; this.scrollPosition = scrollPosition; + this.provider = provider; } @Override - protected CriteriaQuery complete(@Nullable Predicate predicate, Sort sort, CriteriaQuery query, - CriteriaBuilder builder, Root root) { + public List getBindings() { + + List partTreeBindings = super.getBindings(); + List bindings = new ArrayList<>(partTreeBindings.size() + this.syntheticBindings.size()); + bindings.addAll(partTreeBindings); + bindings.addAll(this.syntheticBindings); + + return bindings; + } + + @Override + protected JpqlQueryBuilder.AbstractJpqlQuery createQuery(@Nullable JpqlQueryBuilder.Predicate predicate, Sort sort) { KeysetScrollSpecification keysetSpec = new KeysetScrollSpecification<>(scrollPosition, sort, entityInformation); - Predicate keysetPredicate = keysetSpec.createPredicate(root, builder); - CriteriaQuery queryToUse = super.complete(predicate, keysetSpec.sort(), query, builder, root); + JpqlQueryBuilder.Select query = buildQuery(keysetSpec.sort()); + + AtomicInteger counter = new AtomicInteger(provider.getBindings().size()); + JpqlQueryBuilder.Predicate keysetPredicate = keysetSpec.createJpqlPredicate(getFrom(), getEntity(), value -> { + + syntheticBindings.add(provider.nextSynthetic(value, scrollPosition)); + return JpqlQueryBuilder.expression(render(counter.incrementAndGet())); + }); + JpqlQueryBuilder.Predicate predicateToUse = getPredicate(predicate, keysetPredicate); + + if (predicateToUse != null) { + return query.where(predicateToUse); + } + + return query; + } + + @Nullable + private static JpqlQueryBuilder.Predicate getPredicate(@Nullable JpqlQueryBuilder.Predicate predicate, + @Nullable JpqlQueryBuilder.Predicate keysetPredicate) { if (keysetPredicate != null) { - if (queryToUse.getRestriction() != null) { - return queryToUse.where(builder.and(queryToUse.getRestriction(), keysetPredicate)); + if (predicate != null) { + return predicate.nest().and(keysetPredicate.nest()); + } else { + return keysetPredicate; } - return queryToUse.where(keysetPredicate); } - return queryToUse; + return predicate; } @Override diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessor.java index 6d760d5a3a..90babb4382 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessor.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessor.java @@ -31,14 +31,21 @@ */ public class JpaParametersParameterAccessor extends ParametersParameterAccessor { + private final JpaParameters parameters; + /** * Creates a new {@link ParametersParameterAccessor}. * * @param parameters must not be {@literal null}. * @param values must not be {@literal null}. */ - public JpaParametersParameterAccessor(Parameters parameters, Object[] values) { + public JpaParametersParameterAccessor(JpaParameters parameters, Object[] values) { super(parameters, values); + this.parameters = parameters; + } + + public JpaParameters getParameters() { + return parameters; } @Nullable 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 255ac86dc3..a7f9fc3861 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 @@ -15,28 +15,28 @@ */ package org.springframework.data.jpa.repository.query; -import static org.springframework.data.jpa.repository.query.QueryUtils.*; import static org.springframework.data.repository.query.parser.Part.Type.*; -import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.EntityManager; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Expression; -import jakarta.persistence.criteria.ParameterExpression; -import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.From; import jakarta.persistence.criteria.Predicate; -import jakarta.persistence.criteria.Root; -import jakarta.persistence.criteria.Selection; +import jakarta.persistence.metamodel.EntityType; import jakarta.persistence.metamodel.SingularAttribute; import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; import java.util.List; -import java.util.stream.Collectors; import org.springframework.data.domain.Sort; -import org.springframework.data.jpa.repository.query.ParameterMetadataProvider.ParameterMetadata; +import org.springframework.data.jpa.domain.JpaSort; +import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.PathAndOrigin; +import org.springframework.data.jpa.repository.query.ParameterBinding.PartTreeParameterBinding; +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; import org.springframework.data.mapping.PropertyPath; +import org.springframework.data.mapping.PropertyReferenceException; import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.repository.query.parser.AbstractQueryCreator; import org.springframework.data.repository.query.parser.Part; @@ -57,54 +57,50 @@ * @author Andrey Kovalev * @author Greg Turnquist */ -public class JpaQueryCreator extends AbstractQueryCreator, Predicate> { +class JpaQueryCreator extends AbstractQueryCreator implements JpqlQueryCreator { - private final CriteriaBuilder builder; - private final Root root; - private final CriteriaQuery query; - private final ParameterMetadataProvider provider; private final ReturnedType returnedType; + private final ParameterMetadataProvider provider; + private final JpqlQueryTemplates templates; private final PartTree tree; private final EscapeCharacter escape; + private final EntityType entityType; + private final From from; + private final JpqlQueryBuilder.Entity entity; /** * Create a new {@link JpaQueryCreator}. * * @param tree must not be {@literal null}. * @param type must not be {@literal null}. - * @param builder must not be {@literal null}. + * @param templates must not be {@literal null}. * @param provider must not be {@literal null}. + * @param em must not be {@literal null}. */ - public JpaQueryCreator(PartTree tree, ReturnedType type, CriteriaBuilder builder, - ParameterMetadataProvider provider) { + public JpaQueryCreator(PartTree tree, ReturnedType type, ParameterMetadataProvider provider, + JpqlQueryTemplates templates, EntityManager em) { super(tree); this.tree = tree; - - CriteriaQuery criteriaQuery = createCriteriaQuery(builder, type); - - this.builder = builder; - this.query = criteriaQuery.distinct(tree.isDistinct() && !tree.isCountProjection()); - this.root = query.from(type.getDomainType()); - this.provider = provider; this.returnedType = type; + this.provider = provider; + this.templates = templates; this.escape = provider.getEscape(); + this.entityType = em.getMetamodel().entity(type.getDomainType()); + this.from = em.getCriteriaBuilder().createQuery().from(type.getDomainType()); + this.entity = JpqlQueryBuilder.entity(returnedType.getDomainType()); } - /** - * Creates the {@link CriteriaQuery} to apply predicates on. - * - * @param builder will never be {@literal null}. - * @param type will never be {@literal null}. - * @return must not be {@literal null}. - */ - protected CriteriaQuery createCriteriaQuery(CriteriaBuilder builder, ReturnedType type) { + From getFrom() { + return from; + } - Class typeToRead = tree.isDelete() ? type.getDomainType() : type.getTypeToRead(); + JpqlQueryBuilder.Entity getEntity() { + return entity; + } - return (typeToRead == null) || tree.isExistsProjection() // - ? builder.createTupleQuery() // - : builder.createQuery(typeToRead); + public boolean useTupleQuery() { + return returnedType.needsCustomConstruction() && returnedType.getReturnedType().isInterface(); } /** @@ -112,102 +108,168 @@ protected CriteriaQuery createCriteriaQuery(CriteriaBuilder bu * * @return the parameterExpressions */ - public List> getParameterExpressions() { - return provider.getExpressions(); + public List getBindings() { + return provider.getBindings(); } @Override - protected Predicate create(Part part, Iterator iterator) { - return toPredicate(part, root); + public ParameterBinder getBinder() { + return ParameterBinderFactory.createBinder(provider.getParameters(), getBindings()); } @Override - protected Predicate and(Part part, Predicate base, Iterator iterator) { - return builder.and(base, toPredicate(part, root)); + protected JpqlQueryBuilder.Predicate create(Part part, Iterator iterator) { + return toPredicate(part); } @Override - protected Predicate or(Predicate base, Predicate predicate) { - return builder.or(base, predicate); + protected JpqlQueryBuilder.Predicate and(Part part, JpqlQueryBuilder.Predicate base, Iterator iterator) { + return base.and(toPredicate(part)); + } + + @Override + protected JpqlQueryBuilder.Predicate or(JpqlQueryBuilder.Predicate base, JpqlQueryBuilder.Predicate predicate) { + return base.or(predicate); } /** - * Finalizes the given {@link Predicate} and applies the given sort. Delegates to - * {@link #complete(Predicate, Sort, CriteriaQuery, CriteriaBuilder, Root)} and hands it the current - * {@link CriteriaQuery} and {@link CriteriaBuilder}. + * Finalizes the given {@link Predicate} and applies the given sort. Delegates to {@link #buildQuery(Sort)} and hands + * it the current {@link JpqlQueryBuilder.Predicate}. */ @Override - protected final CriteriaQuery complete(Predicate predicate, Sort sort) { - return complete(predicate, sort, query, builder, root); + protected final String complete(@Nullable JpqlQueryBuilder.Predicate predicate, Sort sort) { + + JpqlQueryBuilder.AbstractJpqlQuery query = createQuery(predicate, sort); + return query.render(); + } + + protected JpqlQueryBuilder.AbstractJpqlQuery createQuery(@Nullable JpqlQueryBuilder.Predicate predicate, Sort sort) { + + JpqlQueryBuilder.Select query = buildQuery(sort); + + if (predicate != null) { + return query.where(predicate); + } + + return query; } /** - * Template method to finalize the given {@link Predicate} using the given {@link CriteriaQuery} and - * {@link CriteriaBuilder}. + * Template method to build a query stub using the given {@link Sort}. * - * @param predicate * @param sort - * @param query - * @param builder * @return */ - @SuppressWarnings({ "unchecked", "rawtypes" }) - protected CriteriaQuery complete(@Nullable Predicate predicate, Sort sort, - CriteriaQuery query, CriteriaBuilder builder, Root root) { + protected JpqlQueryBuilder.Select buildQuery(Sort sort) { + + JpqlQueryBuilder.Select select = doSelect(sort); + + if (tree.isDelete() || tree.isCountProjection()) { + return select; + } + + for (Sort.Order order : sort) { + + JpqlQueryBuilder.Expression expression; + QueryUtils.checkSortExpression(order); + + try { + expression = JpqlQueryBuilder.expression(JpqlUtils.toExpressionRecursively(entity, from, + PropertyPath.from(order.getProperty(), entityType.getJavaType()))); + } catch (PropertyReferenceException e) { + + if (order instanceof JpaSort.JpaOrder jpaOrder && jpaOrder.isUnsafe()) { + expression = JpqlQueryBuilder.expression(order.getProperty()); + } else { + throw e; + } + } + + if (order.isIgnoreCase()) { + expression = JpqlQueryBuilder.function(templates.getIgnoreCaseOperator(), expression); + } + + select.orderBy(JpqlQueryBuilder.orderBy(expression, order)); + } + + return select; + } + + private JpqlQueryBuilder.Select doSelect(Sort sort) { + + JpqlQueryBuilder.SelectStep selectStep = JpqlQueryBuilder.selectFrom(entity); + + if (tree.isDelete()) { + return selectStep.entity(); + } + + if (tree.isDistinct()) { + selectStep = selectStep.distinct(); + } if (returnedType.needsCustomConstruction()) { Collection requiredSelection = getRequiredSelection(sort, returnedType); - List> selections = new ArrayList<>(); - - for (String property : requiredSelection) { - PropertyPath path = PropertyPath.from(property, returnedType.getDomainType()); - selections.add(toExpressionRecursively(root, path, true).alias(property)); + List paths = new ArrayList<>(requiredSelection.size()); + for (String selection : requiredSelection) { + paths.add( + JpqlUtils.toExpressionRecursively(entity, from, PropertyPath.from(selection, from.getJavaType()), true)); } - Class typeToRead = returnedType.getReturnedType(); + if (useTupleQuery()) { - query = typeToRead.isInterface() // - ? query.multiselect(selections) // - : query.select((Selection) builder.construct(typeToRead, // - selections.toArray(new Selection[0]))); + return selectStep.select(paths); + } else { + return selectStep.instantiate(returnedType.getReturnedType(), paths); + } + } - } else if (tree.isExistsProjection()) { + if (tree.isExistsProjection()) { - if (root.getModel().hasSingleIdAttribute()) { + if (entityType.hasSingleIdAttribute()) { - SingularAttribute id = root.getModel().getId(root.getModel().getIdType().getJavaType()); - query = query.multiselect(root.get((SingularAttribute) id).alias(id.getName())); + SingularAttribute id = entityType.getId(entityType.getIdType().getJavaType()); + return selectStep.select( + JpqlUtils.toExpressionRecursively(entity, from, PropertyPath.from(id.getName(), from.getJavaType()), true)); } else { - query = query.multiselect(root.getModel().getIdClassAttributes().stream()// - .map(it -> (Selection) root.get((SingularAttribute) it).alias(it.getName())) - .collect(Collectors.toList())); + List paths = entityType.getIdClassAttributes().stream()// + .map(it -> JpqlUtils.toExpressionRecursively(entity, from, + PropertyPath.from(it.getName(), from.getJavaType()), true)) + .toList(); + return selectStep.select(paths); } + } + if (tree.isCountProjection()) { + return selectStep.count(); } else { - query = query.select((Root) root); + return selectStep.entity(); } - - CriteriaQuery select = query.orderBy(QueryUtils.toOrders(sort, root, builder)); - return predicate == null ? select : select.where(predicate); } Collection getRequiredSelection(Sort sort, ReturnedType returnedType) { return returnedType.getInputProperties(); } + String render(ParameterBinding binding) { + return render(binding.getRequiredPosition()); + } + + String render(int position) { + return "?" + position; + } + /** * Creates a {@link Predicate} from the given {@link Part}. * * @param part - * @param root * @return */ - private Predicate toPredicate(Part part, Root root) { - return new PredicateBuilder(part, root).build(); + private JpqlQueryBuilder.Predicate toPredicate(Part part) { + return new PredicateBuilder(part).build(); } /** @@ -216,24 +278,20 @@ private Predicate toPredicate(Part part, Root root) { * @author Phil Webb * @author Oliver Gierke */ - @SuppressWarnings({ "unchecked", "rawtypes" }) private class PredicateBuilder { private final Part part; - private final Root root; /** - * Creates a new {@link PredicateBuilder} for the given {@link Part} and {@link Root}. + * Creates a new {@link PredicateBuilder} for the given {@link Part}. * * @param part must not be {@literal null}. - * @param root must not be {@literal null}. */ - public PredicateBuilder(Part part, Root root) { + public PredicateBuilder(Part part) { Assert.notNull(part, "Part must not be null"); - Assert.notNull(root, "Root must not be null"); + this.part = part; - this.root = root; } /** @@ -241,78 +299,78 @@ public PredicateBuilder(Part part, Root root) { * * @return */ - public Predicate build() { + public JpqlQueryBuilder.Predicate build() { PropertyPath property = part.getProperty(); Type type = part.getType(); + PathAndOrigin pas = JpqlUtils.toExpressionRecursively(entity, from, property); + JpqlQueryBuilder.WhereStep where = JpqlQueryBuilder.where(pas); + JpqlQueryBuilder.WhereStep whereIgnoreCase = JpqlQueryBuilder.where(potentiallyIgnoreCase(pas)); + switch (type) { case BETWEEN: - ParameterMetadata first = provider.next(part); - ParameterMetadata second = provider.next(part); - return builder.between(getComparablePath(root, part), first.getExpression(), second.getExpression()); + PartTreeParameterBinding first = provider.next(part); + ParameterBinding second = provider.next(part); + return where.between(render(first), render(second)); case AFTER: case GREATER_THAN: - return builder.greaterThan(getComparablePath(root, part), - provider.next(part, Comparable.class).getExpression()); + return where.gt(render(provider.next(part))); case GREATER_THAN_EQUAL: - return builder.greaterThanOrEqualTo(getComparablePath(root, part), - provider.next(part, Comparable.class).getExpression()); + return where.gte(render(provider.next(part))); case BEFORE: case LESS_THAN: - return builder.lessThan(getComparablePath(root, part), provider.next(part, Comparable.class).getExpression()); + return where.lt(render(provider.next(part))); case LESS_THAN_EQUAL: - return builder.lessThanOrEqualTo(getComparablePath(root, part), - provider.next(part, Comparable.class).getExpression()); + return where.lte(render(provider.next(part))); case IS_NULL: - return getTypedPath(root, part).isNull(); + return where.isNull(); case IS_NOT_NULL: - return getTypedPath(root, part).isNotNull(); + return where.isNotNull(); case NOT_IN: - // cast required for eclipselink workaround, see DATAJPA-433 - return upperIfIgnoreCase(getTypedPath(root, part)) - .in((Expression>) provider.next(part, Collection.class).getExpression()).not(); + return whereIgnoreCase.notIn(render(provider.next(part, Collection.class))); case IN: - // cast required for eclipselink workaround, see DATAJPA-433 - return upperIfIgnoreCase(getTypedPath(root, part)) - .in((Expression>) provider.next(part, Collection.class).getExpression()); + return whereIgnoreCase.in(render(provider.next(part, Collection.class))); case STARTING_WITH: case ENDING_WITH: case CONTAINING: case NOT_CONTAINING: if (property.getLeafProperty().isCollection()) { + where = JpqlQueryBuilder.where(entity, property); - Expression> propertyExpression = traversePath(root, property); - ParameterExpression parameterExpression = provider.next(part).getExpression(); - - // Can't just call .not() in case of negation as EclipseLink chokes on that. - return type.equals(NOT_CONTAINING) // - ? isNotMember(builder, parameterExpression, propertyExpression) // - : isMember(builder, parameterExpression, propertyExpression); + return type.equals(NOT_CONTAINING) ? where.notMemberOf(render(provider.next(part))) + : where.memberOf(render(provider.next(part))); } case LIKE: case NOT_LIKE: - Expression stringPath = getTypedPath(root, part); - Expression propertyExpression = upperIfIgnoreCase(stringPath); - Expression parameterExpression = upperIfIgnoreCase(provider.next(part, String.class).getExpression()); - Predicate like = builder.like(propertyExpression, parameterExpression, escape.getEscapeCharacter()); - return type.equals(NOT_LIKE) || type.equals(NOT_CONTAINING) ? like.not() : like; + + PartTreeParameterBinding parameter = provider.next(part, String.class); + JpqlQueryBuilder.Expression parameterExpression = potentiallyIgnoreCase(part.getProperty(), + JpqlQueryBuilder.parameter(render(parameter))); + // Predicate like = builder.like(propertyExpression, parameterExpression, escape.getEscapeCharacter()); + String escapeChar = Character.toString(escape.getEscapeCharacter()); + return + + type.equals(NOT_LIKE) || type.equals(NOT_CONTAINING) + ? whereIgnoreCase.notLike(parameterExpression, escapeChar) + : whereIgnoreCase.like(parameterExpression, escapeChar); case TRUE: - Expression truePath = getTypedPath(root, part); - return builder.isTrue(truePath); + return where.isTrue(); case FALSE: - Expression falsePath = getTypedPath(root, part); - return builder.isFalse(falsePath); + return where.isFalse(); case SIMPLE_PROPERTY: - ParameterMetadata expression = provider.next(part); - Expression path = getTypedPath(root, part); - return expression.isIsNullParameter() ? path.isNull() - : builder.equal(upperIfIgnoreCase(path), upperIfIgnoreCase(expression.getExpression())); + PartTreeParameterBinding metadata = provider.next(part); + + if (metadata.isIsNullParameter()) { + return where.isNull(); + } + + return whereIgnoreCase.eq(potentiallyIgnoreCase(property, JpqlQueryBuilder.expression(render(metadata)))); case NEGATING_SIMPLE_PROPERTY: - return builder.notEqual(upperIfIgnoreCase(getTypedPath(root, part)), - upperIfIgnoreCase(provider.next(part).getExpression())); + return whereIgnoreCase + .neq(potentiallyIgnoreCase(property, JpqlQueryBuilder.expression(render(provider.next(part))))); case IS_EMPTY: case IS_NOT_EMPTY: @@ -320,77 +378,69 @@ public Predicate build() { throw new IllegalArgumentException("IsEmpty / IsNotEmpty can only be used on collection properties"); } - Expression> collectionPath = traversePath(root, property); - return type.equals(IS_NOT_EMPTY) ? builder.isNotEmpty(collectionPath) : builder.isEmpty(collectionPath); + where = JpqlQueryBuilder.where(entity, property); + return type.equals(IS_NOT_EMPTY) ? where.isNotEmpty() : where.isEmpty(); default: throw new IllegalArgumentException("Unsupported keyword " + type); } } - private Predicate isMember(CriteriaBuilder builder, Expression parameter, - Expression> property) { - return builder.isMember(parameter, property); + /** + * Applies an {@code UPPERCASE} conversion to the given {@link Expression} in case the underlying {@link Part} + * requires ignoring case. + * + * @param path must not be {@literal null}. + * @return + */ + private JpqlQueryBuilder.Expression potentiallyIgnoreCase(JpqlQueryBuilder.Origin source, PropertyPath path) { + return potentiallyIgnoreCase(path, JpqlQueryBuilder.expression(source, path)); } - private Predicate isNotMember(CriteriaBuilder builder, Expression parameter, - Expression> property) { - return builder.isNotMember(parameter, property); + /** + * Applies an {@code UPPERCASE} conversion to the given {@link Expression} in case the underlying {@link Part} + * requires ignoring case. + * + * @param path must not be {@literal null}. + * @return + */ + private JpqlQueryBuilder.Expression potentiallyIgnoreCase(PathAndOrigin pas) { + return potentiallyIgnoreCase(pas.path(), JpqlQueryBuilder.expression(pas)); } /** * Applies an {@code UPPERCASE} conversion to the given {@link Expression} in case the underlying {@link Part} * requires ignoring case. * - * @param expression must not be {@literal null}. * @return */ - private Expression upperIfIgnoreCase(Expression expression) { + private JpqlQueryBuilder.Expression potentiallyIgnoreCase(PropertyPath path, + JpqlQueryBuilder.Expression expressionValue) { switch (part.shouldIgnoreCase()) { case ALWAYS: - Assert.state(canUpperCase(expression), "Unable to ignore case of " + expression.getJavaType().getName() + Assert.isTrue(canUpperCase(path), "Unable to ignore case of " + path.getType().getName() + " types, the property '" + part.getProperty().getSegment() + "' must reference a String"); - return (Expression) builder.upper((Expression) expression); + return JpqlQueryBuilder.function(templates.getIgnoreCaseOperator(), expressionValue); case WHEN_POSSIBLE: - if (canUpperCase(expression)) { - return (Expression) builder.upper((Expression) expression); + if (canUpperCase(path)) { + return JpqlQueryBuilder.function(templates.getIgnoreCaseOperator(), expressionValue); } case NEVER: default: - return (Expression) expression; + return expressionValue; } } - private boolean canUpperCase(Expression expression) { - return String.class.equals(expression.getJavaType()); - } - - /** - * Returns a path to a {@link Comparable}. - * - * @param root - * @param part - * @return - */ - private Expression getComparablePath(Root root, Part part) { - return getTypedPath(root, part); - } - - private Expression getTypedPath(Root root, Part part) { - return toExpressionRecursively(root, part.getProperty()); - } - - private Expression traversePath(Path root, PropertyPath path) { - - Path result = root.get(path.getSegment()); - return (Expression) (path.hasNext() ? traversePath(result, path.next()) : result); + private boolean canUpperCase(PropertyPath path) { + return String.class.equals(path.getType()); } } + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java new file mode 100644 index 0000000000..42c8ee95d7 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java @@ -0,0 +1,1219 @@ +/* + * Copyright 2024 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.query; + +import static org.springframework.data.jpa.repository.query.QueryTokens.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Supplier; + +import org.springframework.data.domain.Sort; +import org.springframework.data.mapping.PropertyPath; +import org.springframework.data.util.Predicates; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * A Domain-Specific Language to build JPQL queries using Java code. + * + * @author Mark Paluch + */ +@SuppressWarnings("JavadocDeclaration") +public final class JpqlQueryBuilder { + + private JpqlQueryBuilder() {} + + /** + * Create an {@link Entity} from the given {@link Class entity class}. + * + * @param from the entity type to select from. + * @return + */ + public static Entity entity(Class from) { + return new Entity(from.getName(), from.getSimpleName(), + getAlias(from.getSimpleName(), Predicates.isTrue(), () -> "r")); + } + + /** + * Create a {@link Join INNER JOIN}. + * + * @param origin the selection origin (a join or the entity itself) to select from. + * @param path + * @return + */ + public static Join innerJoin(Origin origin, String path) { + return new Join(origin, "INNER JOIN", path); + } + + /** + * Create a {@link Join LEFT JOIN}. + * + * @param origin the selection origin (a join or the entity itself) to select from. + * @param path + * @return + */ + public static Join leftJoin(Origin origin, String path) { + return new Join(origin, "LEFT JOIN", path); + } + + /** + * Start building a {@link Select} statement by selecting {@link Class from}. This is a short form for + * {@code selectFrom(entity(from))}. + * + * @param from the entity type to select from. + * @return + */ + public static SelectStep selectFrom(Class from) { + return selectFrom(entity(from)); + } + + /** + * Start building a {@link Select} statement by selecting {@link Entity from}. + * + * @param from the entity source to select from. + * @return a new select builder. + */ + public static SelectStep selectFrom(Entity from) { + + return new SelectStep() { + + boolean distinct = false; + + @Override + public SelectStep distinct() { + + distinct = true; + return this; + } + + @Override + public Select entity() { + return new Select(postProcess(new EntitySelection(from)), from); + } + + @Override + public Select count() { + return new Select(new CountSelection(from, distinct), from); + } + + @Override + public Select instantiate(String resultType, Collection paths) { + return new Select(postProcess(new ConstructorExpression(resultType, new Multiselect(from, paths))), from); + } + + @Override + public Select select(Collection paths) { + return new Select(postProcess(new Multiselect(from, paths)), from); + } + + Selection postProcess(Selection selection) { + return distinct ? new DistinctSelection(selection) : selection; + } + }; + } + + private static String getAlias(String from, java.util.function.Predicate predicate, + Supplier fallback) { + + char c = from.toLowerCase(Locale.ROOT).charAt(0); + String string = Character.toString(c); + if (Character.isJavaIdentifierPart(c) && predicate.test(string)) { + return string; + } + + return fallback.get(); + } + + /** + * Invoke a {@literal function} with the given {@code arguments}. + * + * @param function function name. + * @param arguments function arguments. + * @return an expression representing a function call. + */ + public static Expression function(String function, Expression... arguments) { + return new FunctionExpression(function, Arrays.asList(arguments)); + } + + /** + * Nest the given {@link Predicate}. + * + * @param predicate + * @return + */ + public static Predicate nested(Predicate predicate) { + return new NestedPredicate(predicate); + } + + /** + * Create a qualified expression for a {@link PropertyPath}. + * + * @param source + * @param path + * @return + */ + public static Expression expression(Origin source, PropertyPath path) { + return expression(new PathAndOrigin(path, source, false)); + } + + /** + * Create a qualified expression for a {@link PropertyPath}. + * + * @param source + * @param path + * @return + */ + public static Expression expression(PathAndOrigin pas) { + return new PathExpression(pas); + } + + /** + * Create a simple expression from a string. + * + * @param expression + * @return + */ + public static Expression expression(String expression) { + + Assert.hasText(expression, "Expression must not be empty or null"); + + return new LiteralExpression(expression); + } + + public static Expression parameter(String parameter) { + + Assert.hasText(parameter, "Parameter must not be empty or null"); + + return new ParameterExpression(parameter); + } + + public static Expression orderBy(Expression sortExpression, Sort.Order order) { + return new OrderExpression(sortExpression, order); + } + + /** + * Start building a {@link Predicate WHERE predicate} by providing the right-hand side. + * + * @param source + * @param path + * @return + */ + public static WhereStep where(Origin source, PropertyPath path) { + return where(expression(source, path)); + } + + /** + * Start building a {@link Predicate WHERE predicate} by providing the right-hand side. + * + * @param rhs + * @return + */ + public static WhereStep where(PathAndOrigin rhs) { + return where(expression(rhs)); + } + + /** + * Start building a {@link Predicate WHERE predicate} by providing the right-hand side. + * + * @param rhs + * @return + */ + public static WhereStep where(Expression rhs) { + + return new WhereStep() { + @Override + public Predicate between(Expression lower, Expression upper) { + return new BetweenPredicate(rhs, lower, upper); + } + + @Override + public Predicate gt(Expression value) { + return new OperatorPredicate(rhs, ">", value); + } + + @Override + public Predicate gte(Expression value) { + return new OperatorPredicate(rhs, ">=", value); + } + + @Override + public Predicate lt(Expression value) { + return new OperatorPredicate(rhs, "<", value); + } + + @Override + public Predicate lte(Expression value) { + return new OperatorPredicate(rhs, "<=", value); + } + + @Override + public Predicate isNull() { + return new LhsPredicate(rhs, "IS NULL"); + } + + @Override + public Predicate isNotNull() { + return new LhsPredicate(rhs, "IS NOT NULL"); + } + + @Override + public Predicate isTrue() { + return new LhsPredicate(rhs, "IS TRUE"); + } + + @Override + public Predicate isFalse() { + return new LhsPredicate(rhs, "IS FALSE"); + } + + @Override + public Predicate isEmpty() { + return new LhsPredicate(rhs, "IS EMPTY"); + } + + @Override + public Predicate isNotEmpty() { + return new LhsPredicate(rhs, "IS NOT EMPTY"); + } + + @Override + public Predicate in(Expression value) { + return new InPredicate(rhs, "IN", value); + } + + @Override + public Predicate notIn(Expression value) { + return new InPredicate(rhs, "NOT IN", value); + } + + @Override + public Predicate inMultivalued(Expression value) { + return new MemberOfPredicate(rhs, "IN", value); + } + + @Override + public Predicate notInMultivalued(Expression value) { + return new MemberOfPredicate(rhs, "NOT IN", value); + } + + @Override + public Predicate memberOf(Expression value) { + return new MemberOfPredicate(rhs, "MEMBER OF", value); + } + + @Override + public Predicate notMemberOf(Expression value) { + return new MemberOfPredicate(rhs, "NOT MEMBER OF", value); + } + + @Override + public Predicate like(Expression value, String escape) { + return new LikePredicate(rhs, "LIKE", value, escape); + } + + @Override + public Predicate notLike(Expression value, String escape) { + return new LikePredicate(rhs, "NOT LIKE", value, escape); + } + + @Override + public Predicate eq(Expression value) { + return new OperatorPredicate(rhs, "=", value); + } + + @Override + public Predicate neq(Expression value) { + return new OperatorPredicate(rhs, "!=", value); + } + }; + } + + @Nullable + public static Predicate and(List intermediate) { + + Predicate predicate = null; + + for (Predicate other : intermediate) { + + if (predicate == null) { + predicate = other; + } else { + predicate = predicate.and(other); + } + } + + return predicate; + } + + @Nullable + public static Predicate or(List intermediate) { + + Predicate predicate = null; + + for (Predicate other : intermediate) { + + if (predicate == null) { + predicate = other; + } else { + predicate = predicate.or(other); + } + } + + return predicate; + } + + /** + * Fluent interface to build a {@link Select}. + */ + public interface SelectStep { + + /** + * Apply {@code DISTINCT}. + */ + SelectStep distinct(); + + /** + * Select the entity. + */ + Select entity(); + + /** + * Select the count. + */ + Select count(); + + /** + * Provide a constructor expression to instantiate {@code resultType}. Operates on the underlying {@link Entity + * FROM}. + * + * @param resultType + * @param paths + * @return + */ + default Select instantiate(Class resultType, Collection paths) { + return instantiate(resultType.getName(), paths); + } + + /** + * Provide a constructor expression to instantiate {@code resultType}. + * + * @param resultType + * @param paths + * @return + */ + Select instantiate(String resultType, Collection paths); + + /** + * Specify a multi-select. + * + * @param paths + * @return + */ + Select select(Collection paths); + + /** + * Select a single attribute. + * + * @param name + * @return + */ + default Select select(PathAndOrigin path) { + return select(List.of(path)); + } + + } + + interface Selection { + String render(RenderContext context); + } + + /** + * {@code DISTINCT} wrapper. + * + * @param selection + */ + record DistinctSelection(Selection selection) implements Selection { + + @Override + public String render(RenderContext context) { + return "DISTINCT %s".formatted(selection.render(context)); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + /** + * Entity selection. + * + * @param source + */ + record EntitySelection(Entity source) implements Selection { + + @Override + public String render(RenderContext context) { + return context.getAlias(source); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + /** + * {@code COUNT(…)} selection. + * + * @param source + * @param distinct + */ + record CountSelection(Entity source, boolean distinct) implements Selection { + + @Override + public String render(RenderContext context) { + return "COUNT(%s%s)".formatted(distinct ? "DISTINCT " : "", context.getAlias(source)); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + /** + * Expression selection. + * + * @param resultType + * @param multiselect + */ + record ConstructorExpression(String resultType, Multiselect multiselect) implements Selection { + + @Override + public String render(RenderContext context) { + return "new %s(%s)".formatted(resultType, multiselect.render(context)); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + /** + * Multi-select selecting one or many property paths. + * + * @param source + * @param paths + */ + record Multiselect(Origin source, Collection paths) implements Selection { + + @Override + public String render(RenderContext context) { + + StringBuilder builder = new StringBuilder(); + + for (PathAndOrigin path : paths) { + + if (!builder.isEmpty()) { + builder.append(", "); + } + + builder.append(PathExpression.render(path, context)); + builder.append(" ").append(path.path().getSegment()); + } + + return builder.toString(); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + /** + * {@code WHERE} predicate. + */ + public interface Predicate { + + /** + * Render the predicate given {@link RenderContext}. + * + * @param context + * @return + */ + String render(RenderContext context); + + /** + * {@code OR}-concatenate this predicate with {@code other}. + * + * @param other + * @return a composed predicate combining this and {@code other} using the OR operator. + */ + default Predicate or(Predicate other) { + return new OrPredicate(this, other); + } + + /** + * {@code AND}-concatenate this predicate with {@code other}. + * + * @param other + * @return a composed predicate combining this and {@code other} using the AND operator. + */ + default Predicate and(Predicate other) { + return new AndPredicate(this, other); + } + + /** + * Wrap this predicate with parenthesis {@code (…)} to nest it without affecting AND/OR concatenation precedence. + * + * @return a nested variant of this predicate. + */ + default Predicate nest() { + return new NestedPredicate(this); + } + } + + /** + * Interface specifying an expression that can be rendered to {@code String}. + */ + public interface Expression { + + /** + * Render the expression given {@link RenderContext}. + * + * @param context + * @return + */ + String render(RenderContext context); + } + + /** + * {@code SELECT} statement. + */ + public static class Select extends AbstractJpqlQuery { + + private final Selection selection; + + private final Entity entity; + + private final Map joins = new LinkedHashMap<>(); + + private final List orderBy = new ArrayList<>(); + + private Select(Selection selection, Entity entity) { + this.selection = selection; + this.entity = entity; + } + + /** + * Append a join to this select. + * + * @param join + * @return + */ + public Select join(Join join) { + + if (join.source() instanceof Join parent) { + join(parent); + } + + this.joins.put(join.joinType() + "_" + join.getName() + "_" + join.path(), join); + return this; + } + + /** + * Append an order-by expression to this select. + * + * @param orderBy + * @return + */ + public Select orderBy(Expression orderBy) { + this.orderBy.add(orderBy); + return this; + } + + @Override + String render() { + + Map aliases = new LinkedHashMap<>(); + aliases.put(entity, entity.alias); + + RenderContext renderContext = new RenderContext(aliases); + + StringBuilder where = new StringBuilder(); + StringBuilder orderby = new StringBuilder(); + StringBuilder result = new StringBuilder( + "SELECT %s FROM %s %s".formatted(selection.render(renderContext), entity.entity(), entity.alias())); + + if (getWhere() != null) { + where.append(" WHERE ").append(getWhere().render(renderContext)); + } + + if (!orderBy.isEmpty()) { + + StringBuilder builder = new StringBuilder(); + + for (Expression order : orderBy) { + if (!builder.isEmpty()) { + builder.append(", "); + } + + builder.append(order.render(renderContext)); + } + + orderby.append(" ORDER BY ").append(builder); + } + + aliases.keySet().forEach(key -> { + + if (key instanceof Join js) { + join(js); + } + }); + + for (Join join : joins.values()) { + result.append(" ").append(join.joinType()).append(" ").append(renderContext.getAlias(join.source())).append(".") + .append(join.path()).append(" ").append(renderContext.getAlias(join)); + } + + result.append(where).append(orderby); + + return result.toString(); + } + } + + /** + * Abstract base class for JPQL queries. + */ + public static abstract class AbstractJpqlQuery { + + private @Nullable Predicate where; + + public AbstractJpqlQuery where(Predicate predicate) { + this.where = predicate; + return this; + } + + @Nullable + public Predicate getWhere() { + return where; + } + + abstract String render(); + + @Override + public String toString() { + return render(); + } + } + + record OrderExpression(Expression sortExpression, Sort.Order order) implements Expression { + + @Override + public String render(RenderContext context) { + + StringBuilder builder = new StringBuilder(); + + builder.append(sortExpression.render(context)); + builder.append(" "); + + builder.append(order.isDescending() ? TOKEN_DESC : TOKEN_ASC); + + if (order.getNullHandling() == Sort.NullHandling.NULLS_FIRST) { + builder.append(" NULLS FIRST"); + } else if (order.getNullHandling() == Sort.NullHandling.NULLS_LAST) { + builder.append(" NULLS LAST"); + } + + return builder.toString(); + } + } + + /** + * Context used during rendering. + */ + public static class RenderContext { + + public static final RenderContext EMPTY = new RenderContext(Collections.emptyMap()) { + + @Override + public String getAlias(Origin source) { + return ""; + } + }; + + private final Map aliases; + private int counter; + + RenderContext(Map aliases) { + this.aliases = aliases; + } + + /** + * Obtain an alias for {@link Origin}. Unknown selection origins are associated with the enclosing statement if they + * are used for the first time. + * + * @param source + * @return + */ + public String getAlias(Origin source) { + + return aliases.computeIfAbsent(source, it -> JpqlQueryBuilder.getAlias(source.getName(), s -> { + return !aliases.containsValue(s); + }, () -> "join_" + (counter++))); + } + + /** + * Prefix {@code fragment} with the alias for {@link Origin}. Unknown selection origins are associated with the + * enclosing statement if they are used for the first time. + * + * @param source + * @return + */ + public String prefixWithAlias(Origin source, String fragment) { + + String alias = getAlias(source); + return ObjectUtils.isEmpty(source) ? fragment : alias + "." + fragment; + } + } + + /** + * An origin that is used to select data from. selection origins are used with paths to define where a path is + * anchored. + */ + public interface Origin { + + String getName(); + } + + /** + * The root entity. + * + * @param entity + * @param simpleName + * @param alias + */ + public record Entity(String entity, String simpleName, String alias) implements Origin { + + @Override + public String getName() { + return simpleName; + } + } + + /** + * A joined entity or element collection. + * + * @param source + * @param joinType + * @param path + */ + public record Join(Origin source, String joinType, String path) implements Origin, Expression { + + @Override + public String getName() { + return path; + } + + @Override + public String render(RenderContext context) { + return ""; + } + } + + /** + * Fluent interface to build a {@link Predicate}. + */ + public interface WhereStep { + + /** + * Create a {@code BETWEEN … AND …} predicate. + * + * @param lower lower boundary. + * @param upper upper boundary. + * @return + */ + default Predicate between(String lower, String upper) { + return between(expression(lower), expression(upper)); + } + + /** + * Create a {@code BETWEEN … AND …} predicate. + * + * @param lower lower boundary. + * @param upper upper boundary. + * @return + */ + Predicate between(Expression lower, Expression upper); + + /** + * Create a greater {@code > …} predicate. + * + * @param value the comparison value. + * @return + */ + default Predicate gt(String value) { + return gt(expression(value)); + } + + /** + * Create a greater {@code > …} predicate. + * + * @param value the comparison value. + * @return + */ + Predicate gt(Expression value); + + /** + * Create a greater-or-equals {@code >= …} predicate. + * + * @param value the comparison value. + * @return + */ + default Predicate gte(String value) { + return gte(expression(value)); + } + + /** + * Create a greater-or-equals {@code >= …} predicate. + * + * @param value the comparison value. + * @return + */ + Predicate gte(Expression value); + + /** + * Create a less {@code < …} predicate. + * + * @param value the comparison value. + * @return + */ + default Predicate lt(String value) { + return lt(expression(value)); + } + + /** + * Create a less {@code < …} predicate. + * + * @param value the comparison value. + * @return + */ + Predicate lt(Expression value); + + /** + * Create a less-or-equals {@code <= …} predicate. + * + * @param value the comparison value. + * @return + */ + default Predicate lte(String value) { + return lte(expression(value)); + } + + /** + * Create a less-or-equals {@code <= …} predicate. + * + * @param value the comparison value. + * @return + */ + Predicate lte(Expression value); + + Predicate isNull(); + + Predicate isNotNull(); + + Predicate isTrue(); + + Predicate isFalse(); + + Predicate isEmpty(); + + Predicate isNotEmpty(); + + default Predicate in(String value) { + return in(expression(value)); + } + + Predicate in(Expression value); + + default Predicate notIn(String value) { + return notIn(expression(value)); + } + + Predicate notIn(Expression value); + + default Predicate inMultivalued(String value) { + return inMultivalued(expression(value)); + } + + Predicate inMultivalued(Expression value); + + default Predicate notInMultivalued(String value) { + return notInMultivalued(expression(value)); + } + + Predicate notInMultivalued(Expression value); + + default Predicate memberOf(String value) { + return memberOf(expression(value)); + } + + Predicate memberOf(Expression value); + + default Predicate notMemberOf(String value) { + return notMemberOf(expression(value)); + } + + Predicate notMemberOf(Expression value); + + default Predicate like(String value, String escape) { + return like(expression(value), escape); + } + + Predicate like(Expression value, String escape); + + default Predicate notLike(String value, String escape) { + return notLike(expression(value), escape); + } + + Predicate notLike(Expression value, String escape); + + default Predicate eq(String value) { + return eq(expression(value)); + } + + Predicate eq(Expression value); + + default Predicate neq(String value) { + return neq(expression(value)); + } + + Predicate neq(Expression value); + } + + record PathExpression(PathAndOrigin pas) implements Expression { + + @Override + public String render(RenderContext context) { + return render(pas, context); + + } + + public static String render(PathAndOrigin pas, RenderContext context) { + + if (pas.path().hasNext() || !pas.onTheJoin()) { + return context.prefixWithAlias(pas.origin(), pas.path().toDotPath()); + } else { + return context.getAlias(pas.origin()); + } + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + record LiteralExpression(String expression) implements Expression { + + @Override + public String render(RenderContext context) { + return expression; + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + record ParameterExpression(String parameter) implements Expression { + + @Override + public String render(RenderContext context) { + return parameter; + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + record FunctionExpression(String function, List arguments) implements Expression { + + @Override + public String render(RenderContext context) { + + StringBuilder builder = new StringBuilder(); + + for (Expression argument : arguments) { + + if (!builder.isEmpty()) { + builder.append(", "); + } + + builder.append(argument.render(context)); + } + + return "%s(%s)".formatted(function, builder); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + record OperatorPredicate(Expression path, String operator, Expression predicate) implements Predicate { + + @Override + public String render(RenderContext context) { + return "%s %s %s".formatted(path.render(context), operator, predicate.render(context)); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + record MemberOfPredicate(Expression path, String operator, Expression predicate) implements Predicate { + + @Override + public String render(RenderContext context) { + return "%s %s %s".formatted(predicate.render(context), operator, path.render(context)); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + record LhsPredicate(Expression path, String predicate) implements Predicate { + + @Override + public String render(RenderContext context) { + return "%s %s".formatted(path.render(context), predicate); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + record BetweenPredicate(Expression path, Expression lower, Expression upper) implements Predicate { + + @Override + public String render(RenderContext context) { + return "%s BETWEEN %s AND %s".formatted(path.render(context), lower.render(context), upper.render(context)); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + record LikePredicate(Expression left, String operator, Expression right, String escape) implements Predicate { + + @Override + public String render(RenderContext context) { + return "%s %s %s ESCAPE '%s'".formatted(left.render(context), operator, right.render(context), escape); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + record InPredicate(Expression path, String operator, Expression predicate) implements Predicate { + + @Override + public String render(RenderContext context) { + return "%s %s (%s)".formatted(path.render(context), operator, predicate.render(context)); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + record AndPredicate(Predicate left, Predicate right) implements Predicate { + + @Override + public String render(RenderContext context) { + return "%s AND %s".formatted(left.render(context), right.render(context)); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + record OrPredicate(Predicate left, Predicate right) implements Predicate { + + @Override + public String render(RenderContext context) { + return "%s OR %s".formatted(left.render(context), right.render(context)); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + record NestedPredicate(Predicate delegate) implements Predicate { + + @Override + public String render(RenderContext context) { + return "(%s)".formatted(delegate.render(context)); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + /** + * Value object capturing a property path and its origin. + * + * @param path + * @param origin + * @param onTheJoin whether the path should target the join itself instead of matching {@link PropertyPath}. + */ + public record PathAndOrigin(PropertyPath path, Origin origin, boolean onTheJoin) { + + } +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryCreator.java new file mode 100644 index 0000000000..bbffd7c8a6 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryCreator.java @@ -0,0 +1,34 @@ +/* + * Copyright 2024 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.query; + +import java.util.List; + +import org.springframework.data.domain.Sort; + +/** + * @author Mark Paluch + */ +interface JpqlQueryCreator { + + boolean useTupleQuery(); + + String createQuery(Sort sort); + + List getBindings(); + + ParameterBinder getBinder(); +} 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 new file mode 100644 index 0000000000..50da5558bb --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java @@ -0,0 +1,82 @@ +/* + * Copyright 2024 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.query; + +import jakarta.persistence.criteria.From; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.JoinType; + +import java.util.Objects; + +import org.springframework.data.mapping.PropertyPath; + +/** + * @author Mark Paluch + */ +class JpqlUtils { + + static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(JpqlQueryBuilder.Origin source, From from, + PropertyPath property) { + return toExpressionRecursively(source, from, property, false); + } + + static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(JpqlQueryBuilder.Origin source, From from, + PropertyPath property, boolean isForSelection) { + return toExpressionRecursively(source, from, property, isForSelection, false); + } + + /** + * Creates an expression with proper inner and left joins by recursively navigating the path + * + * @param from the {@link From} + * @param property the property path + * @param isForSelection is the property navigated for the selection or ordering part of the query? + * @param hasRequiredOuterJoin has a parent already required an outer join? + * @param the type of the expression + * @return the expression + */ + @SuppressWarnings("unchecked") + static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(JpqlQueryBuilder.Origin source, From from, + PropertyPath property, boolean isForSelection, boolean hasRequiredOuterJoin) { + + String segment = property.getSegment(); + + boolean isLeafProperty = !property.hasNext(); + + boolean requiresOuterJoin = QueryUtils.requiresOuterJoin(from, property, isForSelection, hasRequiredOuterJoin); + + // if it does not require an outer join and is a leaf, simply get the segment + if (!requiresOuterJoin && isLeafProperty) { + return new JpqlQueryBuilder.PathAndOrigin(property, source, false); + } + + // get or create the join + JpqlQueryBuilder.Join joinSource = requiresOuterJoin ? JpqlQueryBuilder.leftJoin(source, segment) + : JpqlQueryBuilder.innerJoin(source, segment); + JoinType joinType = requiresOuterJoin ? JoinType.LEFT : JoinType.INNER; + Join join = QueryUtils.getOrCreateJoin(from, segment, joinType); + + // if it's a leaf, return the join + if (isLeafProperty) { + return new JpqlQueryBuilder.PathAndOrigin(property, joinSource, true); + } + + PropertyPath nextProperty = Objects.requireNonNull(property.next(), "An element of the property path is null"); + + // recurse with the next property + return toExpressionRecursively(joinSource, join, nextProperty, isForSelection, requiresOuterJoin); + } +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java index 2942fa0bce..5d4d8acb5f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java @@ -16,6 +16,7 @@ package org.springframework.data.jpa.repository.query; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; @@ -24,6 +25,7 @@ import org.springframework.data.domain.ScrollPosition.Direction; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; +import org.springframework.data.jpa.repository.support.JpaEntityInformation; import org.springframework.lang.Nullable; /** @@ -112,6 +114,29 @@ protected List getResultWindow(List list, int limit) { return CollectionUtils.getFirst(limit, list); } + public Sort createSort(Sort sort, JpaEntityInformation entity) { + + Collection sortById; + Sort sortToUse; + if (entity.hasCompositeId()) { + sortById = new ArrayList<>(entity.getIdAttributeNames()); + } else { + sortById = new ArrayList<>(1); + sortById.add(entity.getRequiredIdAttribute().getName()); + } + + sort.forEach(it -> sortById.remove(it.getProperty())); + + if (sortById.isEmpty()) { + sortToUse = sort; + } else { + sortToUse = sort.and(Sort.by(sortById.toArray(new String[0]))); + } + + return getSortOrders(sortToUse); + + } + /** * Reverse scrolling variant applying {@link Direction#Backward}. In reverse scrolling, we need to flip directions for * the actual query so that we do not get everything from the top position and apply the limit but rather flip the diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java index 6047c164ca..ede9516b05 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java @@ -22,8 +22,6 @@ import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; -import java.util.ArrayList; -import java.util.Collection; import java.util.List; import org.springframework.data.domain.KeysetScrollPosition; @@ -42,7 +40,7 @@ * @author Christoph Strobl * @since 3.1 */ -public record KeysetScrollSpecification (KeysetScrollPosition position, Sort sort, +public record KeysetScrollSpecification(KeysetScrollPosition position, Sort sort, JpaEntityInformation entity) implements Specification { public KeysetScrollSpecification(KeysetScrollPosition position, Sort sort, JpaEntityInformation entity) { @@ -63,24 +61,7 @@ public static Sort createSort(KeysetScrollPosition position, Sort sort, JpaEntit KeysetScrollDelegate delegate = KeysetScrollDelegate.of(position.getDirection()); - Collection sortById; - Sort sortToUse; - if (entity.hasCompositeId()) { - sortById = new ArrayList<>(entity.getIdAttributeNames()); - } else { - sortById = new ArrayList<>(1); - sortById.add(entity.getRequiredIdAttribute().getName()); - } - - sort.forEach(it -> sortById.remove(it.getProperty())); - - if (sortById.isEmpty()) { - sortToUse = sort; - } else { - sortToUse = sort.and(Sort.by(sortById.toArray(new String[0]))); - } - - return delegate.getSortOrders(sortToUse); + return delegate.createSort(sort, entity); } @Override @@ -92,16 +73,24 @@ public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuild public Predicate createPredicate(Root root, CriteriaBuilder criteriaBuilder) { KeysetScrollDelegate delegate = KeysetScrollDelegate.of(position.getDirection()); - return delegate.createPredicate(position, sort, new JpaQueryStrategy(root, criteriaBuilder)); + return delegate.createPredicate(position, sort, new CriteriaBuilderStrategy(root, criteriaBuilder)); + } + + @Nullable + public JpqlQueryBuilder.Predicate createJpqlPredicate(From from, JpqlQueryBuilder.Entity entity, + ParameterFactory factory) { + + KeysetScrollDelegate delegate = KeysetScrollDelegate.of(position.getDirection()); + return delegate.createPredicate(position, sort, new JpqlStrategy(from, entity, factory)); } @SuppressWarnings("rawtypes") - private static class JpaQueryStrategy implements QueryStrategy, Predicate> { + private static class CriteriaBuilderStrategy implements QueryStrategy, Predicate> { private final From from; private final CriteriaBuilder cb; - public JpaQueryStrategy(From from, CriteriaBuilder cb) { + public CriteriaBuilderStrategy(From from, CriteriaBuilder cb) { this.from = from; this.cb = cb; @@ -136,4 +125,55 @@ public Predicate or(List intermediate) { return cb.or(intermediate.toArray(new Predicate[0])); } } + + private static class JpqlStrategy implements QueryStrategy { + + private final From from; + private final JpqlQueryBuilder.Entity entity; + private final ParameterFactory factory; + + public JpqlStrategy(From from, JpqlQueryBuilder.Entity entity, ParameterFactory factory) { + + this.from = from; + this.entity = entity; + this.factory = factory; + } + + @Override + public JpqlQueryBuilder.Expression createExpression(String property) { + + PropertyPath path = PropertyPath.from(property, from.getJavaType()); + return JpqlQueryBuilder.expression(JpqlUtils.toExpressionRecursively(entity, from, path)); + } + + @Override + public JpqlQueryBuilder.Predicate compare(Order order, JpqlQueryBuilder.Expression propertyExpression, + Object value) { + + JpqlQueryBuilder.WhereStep where = JpqlQueryBuilder.where(propertyExpression); + return order.isAscending() ? where.gt(factory.capture(value)) : where.lt(factory.capture(value)); + } + + @Override + public JpqlQueryBuilder.Predicate compare(JpqlQueryBuilder.Expression propertyExpression, @Nullable Object value) { + + JpqlQueryBuilder.WhereStep where = JpqlQueryBuilder.where(propertyExpression); + + return value == null ? where.isNull() : where.eq(factory.capture(value)); + } + + @Override + public JpqlQueryBuilder.Predicate and(List intermediate) { + return JpqlQueryBuilder.and(intermediate); + } + + @Override + public JpqlQueryBuilder.Predicate or(List intermediate) { + return JpqlQueryBuilder.or(intermediate); + } + } + + public interface ParameterFactory { + JpqlQueryBuilder.Expression capture(Object value); + } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java index 659c04c7de..7ef6a112dd 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java @@ -52,7 +52,6 @@ final class NamedQuery extends AbstractJpaQuery { private final @Nullable String countProjection; private final boolean namedCountQueryIsPresent; private final Lazy declaredQuery; - private final QueryParameterSetter.QueryMetadataCache metadataCache; /** * Creates a new {@link NamedQuery}. @@ -93,7 +92,6 @@ private NamedQuery(JpaQueryMethod method, EntityManager em) { // TODO: Detect whether a named query is a native one. this.declaredQuery = Lazy.of(() -> DeclaredQuery.of(queryString, query.toString().contains("NativeQuery"))); - this.metadataCache = new QueryParameterSetter.QueryMetadataCache(); } /** @@ -165,9 +163,7 @@ protected Query doCreateQuery(JpaParametersParameterAccessor accessor) { ? em.createNamedQuery(queryName) // : em.createNamedQuery(queryName, typeToRead); - QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(queryName, query); - - return parameterBinder.get().bindAndPrepare(query, metadata, accessor); + return parameterBinder.get().bindAndPrepare(query, accessor); } @Override @@ -188,9 +184,7 @@ protected TypedQuery doCreateCountQuery(JpaParametersParameterAccessor acc countQuery = em.createQuery(countQueryString, Long.class); } - QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(cacheKey, countQuery); - - return parameterBinder.get().bind(countQuery, metadata, accessor); + return parameterBinder.get().bind(countQuery, accessor); } @Override diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinder.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinder.java index 78fc9531ee..7ed5006f67 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinder.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinder.java @@ -21,6 +21,7 @@ import org.springframework.data.jpa.repository.query.QueryParameterSetter.ErrorHandling; import org.springframework.data.jpa.support.PageableUtils; import org.springframework.util.Assert; +import org.springframework.util.ErrorHandler; /** * {@link ParameterBinder} is used to bind method parameters to a {@link Query}. This is usually done whenever an @@ -33,7 +34,7 @@ * @author Jens Schauder * @author Yanming Zhou */ -public class ParameterBinder { +class ParameterBinder { static final String PARAMETER_NEEDS_TO_BE_NAMED = "For queries with named parameters you need to provide names for method parameters; Use @Param for query method parameters, or when on Java 8+ use the javac flag -parameters"; @@ -72,18 +73,18 @@ public ParameterBinder(JpaParameters parameters, Iterable this.useJpaForPaging = useJpaForPaging; } - public T bind(T jpaQuery, QueryParameterSetter.QueryMetadata metadata, + public T bind(T jpaQuery, JpaParametersParameterAccessor accessor) { - bind(metadata.withQuery(jpaQuery), accessor, ErrorHandling.STRICT); + bind(new QueryParameterSetter.BindableQuery(jpaQuery), accessor, ErrorHandling.STRICT); return jpaQuery; } public void bind(QueryParameterSetter.BindableQuery query, JpaParametersParameterAccessor accessor, - ErrorHandling errorHandling) { + ErrorHandler errorHandler) { for (QueryParameterSetter setter : parameterSetters) { - setter.setParameter(query, accessor, errorHandling); + setter.setParameter(query, accessor, errorHandler); } } @@ -91,13 +92,12 @@ public void bind(QueryParameterSetter.BindableQuery query, JpaParametersParamete * Binds the parameters to the given query and applies special parameter types (e.g. pagination). * * @param query must not be {@literal null}. - * @param metadata must not be {@literal null}. * @param accessor must not be {@literal null}. */ - Query bindAndPrepare(Query query, QueryParameterSetter.QueryMetadata metadata, + Query bindAndPrepare(Query query, JpaParametersParameterAccessor accessor) { - bind(query, metadata, accessor); + bind(query, accessor); Pageable pageable = accessor.getPageable(); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinderFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinderFactory.java index e5122e93d3..8e9b5f94a6 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinderFactory.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinderFactory.java @@ -23,7 +23,6 @@ import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter; import org.springframework.data.jpa.repository.query.ParameterBinding.BindingIdentifier; import org.springframework.data.jpa.repository.query.ParameterBinding.ParameterOrigin; -import org.springframework.data.jpa.repository.query.ParameterMetadataProvider.ParameterMetadata; import org.springframework.util.Assert; /** @@ -40,37 +39,37 @@ class ParameterBinderFactory { * otherwise. * * @param parameters method parameters that are available for binding, must not be {@literal null}. + * @param preferNamedParameters * @return a {@link ParameterBinder} that can assign values for the method parameters to query parameters of a * {@link jakarta.persistence.Query} */ - static ParameterBinder createBinder(JpaParameters parameters) { + static ParameterBinder createBinder(JpaParameters parameters, boolean preferNamedParameters) { Assert.notNull(parameters, "JpaParameters must not be null"); - QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.basic(parameters); + QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.basic(parameters, preferNamedParameters); List bindings = getBindings(parameters); return new ParameterBinder(parameters, createSetters(bindings, setterFactory)); } /** - * Creates a {@link ParameterBinder} that just matches method parameter to parameters of a - * {@link jakarta.persistence.criteria.CriteriaQuery}. + * Creates a {@link ParameterBinder} that matches method parameter to parameters of a + * {@link jakarta.persistence.Query} and that can bind synthetic parameters. * * @param parameters method parameters that are available for binding, must not be {@literal null}. - * @param metadata must not be {@literal null}. + * @param bindings parameter bindings for method argument and synthetic parameters, must not be {@literal null}. * @return a {@link ParameterBinder} that can assign values for the method parameters to query parameters of a - * {@link jakarta.persistence.criteria.CriteriaQuery} + * {@link jakarta.persistence.Query} */ - static ParameterBinder createCriteriaBinder(JpaParameters parameters, List> metadata) { + static ParameterBinder createBinder(JpaParameters parameters, List bindings) { Assert.notNull(parameters, "JpaParameters must not be null"); - Assert.notNull(metadata, "Parameter metadata must not be null"); - - QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.forCriteriaQuery(parameters, metadata); - List bindings = getBindings(parameters); + Assert.notNull(bindings, "Parameter bindings must not be null"); - return new ParameterBinder(parameters, createSetters(bindings, setterFactory)); + return new ParameterBinder(parameters, + createSetters(bindings, QueryParameterSetterFactory.forPartTreeQuery(parameters), + QueryParameterSetterFactory.forSynthetic())); } /** @@ -97,15 +96,16 @@ static ParameterBinder createQueryAwareBinder(JpaParameters parameters, Declared QueryParameterSetterFactory expressionSetterFactory = QueryParameterSetterFactory.parsing(parser, evaluationContextProvider); - QueryParameterSetterFactory basicSetterFactory = QueryParameterSetterFactory.basic(parameters); + QueryParameterSetterFactory basicSetterFactory = QueryParameterSetterFactory.basic(parameters, + query.hasNamedParameter()); return new ParameterBinder(parameters, createSetters(bindings, query, expressionSetterFactory, basicSetterFactory), !query.usesPaging()); } - private static List getBindings(JpaParameters parameters) { + static List getBindings(JpaParameters parameters) { - List result = new ArrayList<>(); + List result = new ArrayList<>(parameters.getNumberOfParameters()); int bindableParameterIndex = 0; for (JpaParameter parameter : parameters) { @@ -143,7 +143,7 @@ private static QueryParameterSetter createQueryParameterSetter(ParameterBinding for (QueryParameterSetterFactory strategy : strategies) { - QueryParameterSetter setter = strategy.create(binding, declaredQuery); + QueryParameterSetter setter = strategy.create(binding); if (setter != null) { return setter; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java index 493f474f6f..c03384cb48 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java @@ -21,13 +21,19 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; import org.springframework.data.expression.ValueExpression; import org.springframework.data.jpa.provider.PersistenceProvider; +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; +import org.springframework.data.repository.query.Parameter; +import org.springframework.data.repository.query.parser.Part; import org.springframework.data.repository.query.parser.Part.Type; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; @@ -182,6 +188,115 @@ public boolean isCompatibleWith(ParameterBinding other) { return other.getClass() == getClass() && other.getOrigin().equals(getOrigin()); } + /** + * Represents a {@link ParameterBinding} in a JPQL query augmented with instructions of how to apply a parameter as an + * {@code IN} parameter. + * + * @author Thomas Darimont + * @author Mark Paluch + */ + static class PartTreeParameterBinding extends ParameterBinding { + + private final Class parameterType; + private final JpqlQueryTemplates templates; + private final EscapeCharacter escape; + private final Type type; + private final boolean ignoreCase; + private final boolean noWildcards; + + public PartTreeParameterBinding(BindingIdentifier identifier, ParameterOrigin origin, Class parameterType, + Part part, @Nullable Object value, JpqlQueryTemplates templates, EscapeCharacter escape) { + + super(identifier, origin); + + this.parameterType = parameterType; + this.templates = templates; + this.escape = escape; + + this.type = value == null && Type.SIMPLE_PROPERTY.equals(part.getType()) ? Type.IS_NULL : part.getType(); + this.ignoreCase = Part.IgnoreCaseType.ALWAYS.equals(part.shouldIgnoreCase()); + this.noWildcards = part.getProperty().getLeafProperty().isCollection(); + } + + /** + * Returns whether the parameter shall be considered an {@literal IS NULL} parameter. + */ + public boolean isIsNullParameter() { + return Type.IS_NULL.equals(type); + } + + @Override + public Object prepare(@Nullable Object value) { + + if (value == null || parameterType == null) { + return value; + } + + if (String.class.equals(parameterType) && !noWildcards) { + + switch (type) { + case STARTING_WITH: + return String.format("%s%%", escape.escape(value.toString())); + case ENDING_WITH: + return String.format("%%%s", escape.escape(value.toString())); + case CONTAINING: + case NOT_CONTAINING: + return String.format("%%%s%%", escape.escape(value.toString())); + default: + return value; + } + } + + return Collection.class.isAssignableFrom(parameterType) // + ? potentiallyIgnoreCase(ignoreCase, toCollection(value)) // + : value; + } + + @Nullable + @SuppressWarnings("unchecked") + private Collection potentiallyIgnoreCase(boolean ignoreCase, @Nullable Collection collection) { + + if (!ignoreCase || CollectionUtils.isEmpty(collection)) { + return collection; + } + + return ((Collection) collection).stream() // + .map(it -> it == null // + ? null // + : templates.ignoreCase(it)) // + .collect(Collectors.toList()); + } + + /** + * Returns the given argument as {@link Collection} which means it will return it as is if it's a + * {@link Collections}, turn an array into an {@link ArrayList} or simply wrap any other value into a single element + * {@link Collections}. + * + * @param value the value to be converted to a {@link Collection}. + * @return the object itself as a {@link Collection} or a {@link Collection} constructed from the value. + */ + @Nullable + private static Collection toCollection(@Nullable Object value) { + + if (value == null) { + return null; + } + + if (value instanceof Collection collection) { + return collection.isEmpty() ? null : collection; + } + + if (ObjectUtils.isArray(value)) { + + List collection = Arrays.asList(ObjectUtils.toObjectArray(value)); + return collection.isEmpty() ? null : collection; + } + + return Collections.singleton(value); + } + + } + /** * Represents a {@link ParameterBinding} in a JPQL query augmented with instructions of how to apply a parameter as an * {@code IN} parameter. @@ -345,7 +460,7 @@ static Type getLikeTypeFrom(String expression) { * @author Mark Paluch * @since 3.1.2 */ - sealed interface BindingIdentifier permits Named,Indexed,NamedAndIndexed { + sealed interface BindingIdentifier permits Named, Indexed, NamedAndIndexed { /** * Creates an identifier for the given {@code name}. @@ -491,7 +606,7 @@ public String toString() { * @author Mark Paluch * @since 3.1.2 */ - sealed interface ParameterOrigin permits Expression,MethodInvocationArgument { + sealed interface ParameterOrigin permits Expression, MethodInvocationArgument, Synthetic { /** * Creates a {@link Expression} for the given {@code expression}. @@ -503,6 +618,17 @@ static Expression ofExpression(ValueExpression expression) { return new Expression(expression); } + /** + * Creates a {@link Expression} for the given {@code expression} string. + * + * @param value the captured value. + * @param source source from which this value is derived. + * @return {@link Synthetic} for the given {@code value}. + */ + static Synthetic synthetic(@Nullable Object value, Object source) { + return new Synthetic(value, source); + } + /** * Creates a {@link MethodInvocationArgument} object for {@code name} and {@code position}. Either the name or the * position must be given. @@ -525,6 +651,16 @@ static MethodInvocationArgument ofParameter(@Nullable String name, @Nullable Int return ofParameter(identifier); } + /** + * Creates a {@link MethodInvocationArgument} object for {@code position}. + * + * @param position the parameter position (1-based) from the method invocation. + * @return {@link MethodInvocationArgument} object for {@code position}. + */ + static MethodInvocationArgument ofParameter(Parameter parameter) { + return ofParameter(parameter.getIndex() + 1); + } + /** * Creates a {@link MethodInvocationArgument} object for {@code position}. * @@ -554,6 +690,11 @@ static MethodInvocationArgument ofParameter(BindingIdentifier identifier) { * @return {@code true} if the origin is an expression. */ boolean isExpression(); + + /** + * @return {@code true} if the origin is an expression. + */ + boolean isSynthetic(); } /** @@ -574,6 +715,36 @@ public boolean isMethodArgument() { public boolean isExpression() { return true; } + + @Override + public boolean isSynthetic() { + return true; + } + } + + /** + * Value object capturing the expression of which a binding parameter originates. + * + * @param value + * @param source + * @author Mark Paluch + */ + public record Synthetic(@Nullable Object value, Object source) implements ParameterOrigin { + + @Override + public boolean isMethodArgument() { + return false; + } + + @Override + public boolean isExpression() { + return false; + } + + @Override + public boolean isSynthetic() { + return true; + } } /** @@ -594,5 +765,10 @@ public boolean isMethodArgument() { public boolean isExpression() { return false; } + + @Override + public boolean isSynthetic() { + return false; + } } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java index 213e641a5c..52daad27c2 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java @@ -15,8 +15,9 @@ */ package org.springframework.data.jpa.repository.query; +import static org.springframework.data.jpa.repository.query.ParameterBinding.*; + import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.ParameterExpression; import java.util.ArrayList; import java.util.Arrays; @@ -24,10 +25,10 @@ import java.util.Collections; import java.util.Iterator; import java.util.List; -import java.util.function.Supplier; import java.util.stream.Collectors; import org.springframework.data.jpa.provider.PersistenceProvider; +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; import org.springframework.data.repository.query.Parameter; import org.springframework.data.repository.query.Parameters; import org.springframework.data.repository.query.ParametersParameterAccessor; @@ -56,83 +57,87 @@ */ class ParameterMetadataProvider { - private final CriteriaBuilder builder; private final Iterator parameters; - private final List> expressions; + private final List bindings; private final @Nullable Iterator bindableParameterValues; private final EscapeCharacter escape; + private final JpqlQueryTemplates templates; + private final JpaParameters jpaParameters; + private int position; /** * Creates a new {@link ParameterMetadataProvider} from the given {@link CriteriaBuilder} and * {@link ParametersParameterAccessor}. * - * @param builder must not be {@literal null}. * @param accessor must not be {@literal null}. * @param escape must not be {@literal null}. + * @param templates must not be {@literal null}. */ - public ParameterMetadataProvider(CriteriaBuilder builder, ParametersParameterAccessor accessor, - EscapeCharacter escape) { - this(builder, accessor.iterator(), accessor.getParameters(), escape); + public ParameterMetadataProvider(JpaParametersParameterAccessor accessor, + EscapeCharacter escape, JpqlQueryTemplates templates) { + this(accessor.iterator(), accessor.getParameters(), escape, templates); } /** * Creates a new {@link ParameterMetadataProvider} from the given {@link CriteriaBuilder} and {@link Parameters} with * support for parameter value customizations via {@link PersistenceProvider}. * - * @param builder must not be {@literal null}. * @param parameters must not be {@literal null}. * @param escape must not be {@literal null}. + * @param templates must not be {@literal null}. */ - public ParameterMetadataProvider(CriteriaBuilder builder, Parameters parameters, EscapeCharacter escape) { - this(builder, null, parameters, escape); + public ParameterMetadataProvider(JpaParameters parameters, EscapeCharacter escape, + JpqlQueryTemplates templates) { + this(null, parameters, escape, templates); } /** * Creates a new {@link ParameterMetadataProvider} from the given {@link CriteriaBuilder} an {@link Iterable} of all * bindable parameter values, and {@link Parameters}. * - * @param builder must not be {@literal null}. * @param bindableParameterValues may be {@literal null}. * @param parameters must not be {@literal null}. * @param escape must not be {@literal null}. + * @param templates must not be {@literal null}. */ - private ParameterMetadataProvider(CriteriaBuilder builder, @Nullable Iterator bindableParameterValues, - Parameters parameters, EscapeCharacter escape) { + private ParameterMetadataProvider(@Nullable Iterator bindableParameterValues, JpaParameters parameters, + EscapeCharacter escape, JpqlQueryTemplates templates) { - Assert.notNull(builder, "CriteriaBuilder must not be null"); Assert.notNull(parameters, "Parameters must not be null"); Assert.notNull(escape, "EscapeCharacter must not be null"); + Assert.notNull(templates, "JpqlQueryTemplates must not be null"); - this.builder = builder; + this.jpaParameters = parameters; this.parameters = parameters.getBindableParameters().iterator(); - this.expressions = new ArrayList<>(); + this.bindings = new ArrayList<>(); this.bindableParameterValues = bindableParameterValues; this.escape = escape; + this.templates = templates; } /** - * Returns all {@link ParameterMetadata}s built. + * Returns all {@link ParameterBinding}s built. * - * @return the expressions + * @return the bindings. */ - public List> getExpressions() { - return expressions; + public List getBindings() { + return bindings; } /** - * Builds a new {@link ParameterMetadata} for given {@link Part} and the next {@link Parameter}. + * Builds a new {@link PartTreeParameterBinding} for given {@link Part} and the next {@link Parameter}. */ @SuppressWarnings("unchecked") - public ParameterMetadata next(Part part) { + public PartTreeParameterBinding next(Part part) { Assert.isTrue(parameters.hasNext(), () -> String.format("No parameter available for part %s", part)); Parameter parameter = parameters.next(); - return (ParameterMetadata) next(part, parameter.getType(), parameter); + return next(part, parameter.getType(), parameter); } /** - * Builds a new {@link ParameterMetadata} of the given {@link Part} and type. Forwards the underlying + * Builds a new {@link PartTreeParameterBinding} of the given {@link Part} and type. Forwards the underlying * {@link Parameters} as well. * * @param is the type parameter of the returned {@link ParameterMetadata}. @@ -140,15 +145,15 @@ public ParameterMetadata next(Part part) { * @return ParameterMetadata for the next parameter. */ @SuppressWarnings("unchecked") - public ParameterMetadata next(Part part, Class type) { + public PartTreeParameterBinding next(Part part, Class type) { Parameter parameter = parameters.next(); Class typeToUse = ClassUtils.isAssignable(type, parameter.getType()) ? parameter.getType() : type; - return (ParameterMetadata) next(part, typeToUse, parameter); + return next(part, typeToUse, parameter); } /** - * Builds a new {@link ParameterMetadata} for the given type and name. + * Builds a new {@link PartTreeParameterBinding} for the given type and name. * * @param type parameter for the returned {@link ParameterMetadata}. * @param part must not be {@literal null}. @@ -156,7 +161,7 @@ public ParameterMetadata next(Part part, Class type) { * @param parameter providing the name for the returned {@link ParameterMetadata}. * @return a new {@link ParameterMetadata} for the given type and name. */ - private ParameterMetadata next(Part part, Class type, Parameter parameter) { + private PartTreeParameterBinding next(Part part, Class type, Parameter parameter) { Assert.notNull(type, "Type must not be null"); @@ -166,37 +171,57 @@ private ParameterMetadata next(Part part, Class type, Parameter parame @SuppressWarnings("unchecked") Class reifiedType = Expression.class.equals(type) ? (Class) Object.class : type; - Supplier name = () -> parameter.getName() - .orElseThrow(() -> new IllegalArgumentException("o_O Parameter needs to be named")); + Object value = bindableParameterValues == null ? ParameterMetadata.PLACEHOLDER : bindableParameterValues.next(); - ParameterExpression expression = parameter.isExplicitlyNamed() // - ? builder.parameter(reifiedType, name.get()) // - : builder.parameter(reifiedType); + int currentPosition = ++position; - Object value = bindableParameterValues == null ? ParameterMetadata.PLACEHOLDER : bindableParameterValues.next(); + BindingIdentifier bindingIdentifier = BindingIdentifier.of(currentPosition); - ParameterMetadata metadata = new ParameterMetadata<>(expression, part, value, escape); - expressions.add(metadata); + /* identifier refers to bindable parameters, not _all_ parameters index */ + MethodInvocationArgument methodParameter = ParameterOrigin.ofParameter(bindingIdentifier); + PartTreeParameterBinding binding = new PartTreeParameterBinding(bindingIdentifier, methodParameter, reifiedType, + part, value, templates, escape); - return metadata; + bindings.add(binding); + + return binding; } EscapeCharacter getEscape() { return escape; } + /** + * Builds a new synthetic {@link ParameterBinding} for the given value. + * + * @param value + * @param source + * @return a new {@link ParameterBinding} for the given value and source. + */ + public ParameterBinding nextSynthetic(Object value, Object source) { + + int currentPosition = ++position; + + return new ParameterBinding(BindingIdentifier.of(currentPosition), ParameterOrigin.synthetic(value, source)); + } + + public JpaParameters getParameters() { + return this.jpaParameters; + } + /** * @author Oliver Gierke * @author Thomas Darimont * @author Andrey Kovalev - * @param */ - static class ParameterMetadata { + public static class ParameterMetadata { static final Object PLACEHOLDER = new Object(); + private final Class parameterType; private final Type type; - private final ParameterExpression expression; + private final int position; + private final JpqlQueryTemplates templates; private final EscapeCharacter escape; private final boolean ignoreCase; private final boolean noWildcards; @@ -204,23 +229,24 @@ static class ParameterMetadata { /** * Creates a new {@link ParameterMetadata}. */ - public ParameterMetadata(ParameterExpression expression, Part part, @Nullable Object value, - EscapeCharacter escape) { + public ParameterMetadata(Class parameterType, Part part, @Nullable Object value, EscapeCharacter escape, + int position, JpqlQueryTemplates templates) { - this.expression = expression; + this.parameterType = parameterType; + this.position = position; + this.templates = templates; this.type = value == null && Type.SIMPLE_PROPERTY.equals(part.getType()) ? Type.IS_NULL : part.getType(); this.ignoreCase = IgnoreCaseType.ALWAYS.equals(part.shouldIgnoreCase()); this.noWildcards = part.getProperty().getLeafProperty().isCollection(); this.escape = escape; } - /** - * Returns the {@link ParameterExpression}. - * - * @return the expression - */ - public ParameterExpression getExpression() { - return expression; + public int getPosition() { + return position; + } + + public Class getParameterType() { + return parameterType; } /** @@ -238,11 +264,11 @@ public boolean isIsNullParameter() { @Nullable public Object prepare(@Nullable Object value) { - if (value == null || expression.getJavaType() == null) { + if (value == null || parameterType == null) { return value; } - if (String.class.equals(expression.getJavaType()) && !noWildcards) { + if (String.class.equals(parameterType) && !noWildcards) { switch (type) { case STARTING_WITH: @@ -257,8 +283,8 @@ public Object prepare(@Nullable Object value) { } } - return Collection.class.isAssignableFrom(expression.getJavaType()) // - ? upperIfIgnoreCase(ignoreCase, toCollection(value)) // + return Collection.class.isAssignableFrom(parameterType) // + ? potentiallyIgnoreCase(ignoreCase, toCollection(value)) // : value; } @@ -292,7 +318,7 @@ private static Collection toCollection(@Nullable Object value) { @Nullable @SuppressWarnings("unchecked") - private static Collection upperIfIgnoreCase(boolean ignoreCase, @Nullable Collection collection) { + private Collection potentiallyIgnoreCase(boolean ignoreCase, @Nullable Collection collection) { if (!ignoreCase || CollectionUtils.isEmpty(collection)) { return collection; @@ -301,8 +327,9 @@ private static Collection upperIfIgnoreCase(boolean ignoreCase, @Nullable Col return ((Collection) collection).stream() // .map(it -> it == null // ? null // - : it.toUpperCase()) // + : templates.ignoreCase(it)) // .collect(Collectors.toList()); } + } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java index 1d0923f26d..2aa982f174 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java @@ -18,12 +18,13 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceUnitUtil; import jakarta.persistence.Query; +import jakarta.persistence.Tuple; import jakarta.persistence.TypedQuery; -import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; +import java.util.LinkedHashMap; import java.util.List; -import java.util.concurrent.locks.ReentrantLock; +import java.util.Map; import org.springframework.data.domain.KeysetScrollPosition; import org.springframework.data.domain.OffsetScrollPosition; @@ -33,8 +34,8 @@ import org.springframework.data.jpa.repository.query.JpaQueryExecution.DeleteExecution; import org.springframework.data.jpa.repository.query.JpaQueryExecution.ExistsExecution; import org.springframework.data.jpa.repository.query.JpaQueryExecution.ScrollExecution; -import org.springframework.data.jpa.repository.query.ParameterMetadataProvider.ParameterMetadata; import org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformation; +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; import org.springframework.data.repository.query.ResultProcessor; import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.repository.query.parser.Part; @@ -42,6 +43,7 @@ import org.springframework.data.repository.query.parser.PartTree; import org.springframework.data.util.Streamable; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; /** * A {@link AbstractJpaQuery} implementation based on a {@link PartTree}. @@ -55,6 +57,8 @@ */ public class PartTreeJpaQuery extends AbstractJpaQuery { + private final JpqlQueryTemplates templates = JpqlQueryTemplates.UPPER; + private final PartTree tree; private final JpaParameters parameters; @@ -93,15 +97,12 @@ public class PartTreeJpaQuery extends AbstractJpaQuery { PersistenceUnitUtil persistenceUnitUtil = em.getEntityManagerFactory().getPersistenceUnitUtil(); this.entityInformation = new JpaMetamodelEntityInformation<>(domainClass, em.getMetamodel(), persistenceUnitUtil); - boolean recreationRequired = parameters.hasDynamicProjection() || parameters.potentiallySortsDynamically() - || method.isScrollQuery(); - try { this.tree = new PartTree(method.getName(), domainClass); validate(tree, parameters, method.toString()); - this.countQuery = new CountQueryPreparer(recreationRequired); - this.query = tree.isCountProjection() ? countQuery : new QueryPreparer(recreationRequired); + this.countQuery = new CountQueryPreparer(); + this.query = tree.isCountProjection() ? countQuery : new QueryPreparer(); } catch (Exception o_O) { throw new IllegalArgumentException( @@ -200,6 +201,7 @@ private static boolean expectsCollection(Type type) { return type == Type.IN || type == Type.NOT_IN; } + /** * Query preparer to create {@link CriteriaQuery} instances and potentially cache them. * @@ -208,50 +210,35 @@ private static boolean expectsCollection(Type type) { */ private class QueryPreparer { - private final @Nullable CriteriaQuery cachedCriteriaQuery; - private final ReentrantLock lock = new ReentrantLock(); - private final @Nullable ParameterBinder cachedParameterBinder; - private final QueryParameterSetter.QueryMetadataCache metadataCache = new QueryParameterSetter.QueryMetadataCache(); - - QueryPreparer(boolean recreateQueries) { - - JpaQueryCreator creator = createCreator(null); - - if (recreateQueries) { - this.cachedCriteriaQuery = null; - this.cachedParameterBinder = null; - } else { - this.cachedCriteriaQuery = creator.createQuery(); - this.cachedParameterBinder = getBinder(creator.getParameterExpressions()); + private final Map cache = new LinkedHashMap() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > 256; } - } + }; /** * Creates a new {@link Query} for the given parameter values. */ public Query createQuery(JpaParametersParameterAccessor accessor) { - CriteriaQuery criteriaQuery = cachedCriteriaQuery; - ParameterBinder parameterBinder = cachedParameterBinder; + Sort sort = getDynamicSort(accessor); + JpqlQueryCreator creator = createCreator(sort, accessor); + String jpql = creator.createQuery(sort); + Query query; - if (cachedCriteriaQuery == null || accessor.hasBindableNullValue()) { - JpaQueryCreator creator = createCreator(accessor); - criteriaQuery = creator.createQuery(getDynamicSort(accessor)); - List> expressions = creator.getParameterExpressions(); - parameterBinder = getBinder(expressions); + try { + query = creator.useTupleQuery() ? em.createQuery(jpql, Tuple.class) : em.createQuery(jpql); + } catch (Exception e) { + throw new BadJpqlGrammarException(e.getMessage(), jpql, e); } - if (parameterBinder == null) { - throw new IllegalStateException("ParameterBinder is null"); - } - - TypedQuery query = createQuery(criteriaQuery); + ParameterBinder binder = creator.getBinder(); ScrollPosition scrollPosition = accessor.getParameters().hasScrollPositionParameter() ? accessor.getScrollPosition() : null; - return restrictMaxResultsIfNecessary(invokeBinding(parameterBinder, query, accessor, this.metadataCache), - scrollPosition); + return restrictMaxResultsIfNecessary(invokeBinding(binder, query, accessor), scrollPosition); } /** @@ -289,65 +276,85 @@ private Query restrictMaxResultsIfNecessary(Query query, @Nullable ScrollPositio return query; } - /** - * Checks whether we are working with a cached {@link CriteriaQuery} and synchronizes the creation of a - * {@link TypedQuery} instance from it. This is due to non-thread-safety in the {@link CriteriaQuery} implementation - * of some persistence providers (i.e. Hibernate in this case), see DATAJPA-396. - * - * @param criteriaQuery must not be {@literal null}. - */ - private TypedQuery createQuery(CriteriaQuery criteriaQuery) { - - if (this.cachedCriteriaQuery != null) { - lock.lock(); - try { - return getEntityManager().createQuery(criteriaQuery); - } finally { - lock.unlock(); + protected JpqlQueryCreator createCreator(Sort sort, JpaParametersParameterAccessor accessor) { + + synchronized (cache) { + JpqlQueryCreator jpqlQueryCreator = cache.get(sort); + if (jpqlQueryCreator != null) { + return jpqlQueryCreator; } } - return getEntityManager().createQuery(criteriaQuery); + EntityManager entityManager = getEntityManager(); + ResultProcessor processor = getQueryMethod().getResultProcessor(); + + ParameterMetadataProvider provider = new ParameterMetadataProvider(accessor, escape, templates); + ReturnedType returnedType = processor.withDynamicProjection(accessor).getReturnedType(); + + if (accessor.getScrollPosition() instanceof KeysetScrollPosition keyset) { + return new JpaKeysetScrollQueryCreator(tree, returnedType, provider, templates, entityInformation, keyset, + entityManager); + } + + JpqlQueryCreator creator = new CacheableJpqlQueryCreator(sort, + new JpaQueryCreator(tree, returnedType, provider, templates, em)); + + if (accessor.getParameters().hasDynamicProjection()) { + return creator; + } + + synchronized (cache) { + cache.put(sort, creator); + } + + return creator; } - protected JpaQueryCreator createCreator(@Nullable JpaParametersParameterAccessor accessor) { + static class CacheableJpqlQueryCreator implements JpqlQueryCreator { - EntityManager entityManager = getEntityManager(); + private final Sort expectedSort; + private final String query; + private final boolean useTupleQuery; + private final List parameterBindings; + private final ParameterBinder binder; - CriteriaBuilder builder = entityManager.getCriteriaBuilder(); - ResultProcessor processor = getQueryMethod().getResultProcessor(); + public CacheableJpqlQueryCreator(Sort expectedSort, JpqlQueryCreator delegate) { - ParameterMetadataProvider provider; - ReturnedType returnedType; + this.expectedSort = expectedSort; + this.query = delegate.createQuery(expectedSort); + this.useTupleQuery = delegate.useTupleQuery(); + this.parameterBindings = delegate.getBindings(); + this.binder = delegate.getBinder(); + } + + @Override + public boolean useTupleQuery() { + return useTupleQuery; + } + + @Override + public String createQuery(Sort sort) { - if (accessor != null) { - provider = new ParameterMetadataProvider(builder, accessor, escape); - returnedType = processor.withDynamicProjection(accessor).getReturnedType(); - } else { - provider = new ParameterMetadataProvider(builder, parameters, escape); - returnedType = processor.getReturnedType(); + Assert.isTrue(sort.equals(expectedSort), "Expected sort does not match"); + return query; } - if (accessor != null && accessor.getScrollPosition() instanceof KeysetScrollPosition keyset) { - return new JpaKeysetScrollQueryCreator(tree, returnedType, builder, provider, entityInformation, keyset); + @Override + public List getBindings() { + return parameterBindings; } - return new JpaQueryCreator(tree, returnedType, builder, provider); + @Override + public ParameterBinder getBinder() { + return binder; + } } /** * Invokes parameter binding on the given {@link TypedQuery}. */ - protected Query invokeBinding(ParameterBinder binder, TypedQuery query, JpaParametersParameterAccessor accessor, - QueryParameterSetter.QueryMetadataCache metadataCache) { - - QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata("query", query); - - return binder.bindAndPrepare(query, metadata, accessor); - } - - private ParameterBinder getBinder(List> expressions) { - return ParameterBinderFactory.createCriteriaBinder(parameters, expressions); + protected Query invokeBinding(ParameterBinder binder, Query query, JpaParametersParameterAccessor accessor) { + return binder.bindAndPrepare(query, accessor); } private Sort getDynamicSort(JpaParametersParameterAccessor accessor) { @@ -366,37 +373,70 @@ private Sort getDynamicSort(JpaParametersParameterAccessor accessor) { */ private class CountQueryPreparer extends QueryPreparer { - CountQueryPreparer(boolean recreateQueries) { - super(recreateQueries); - } + private volatile JpqlQueryCreator cached; @Override - protected JpaQueryCreator createCreator(@Nullable JpaParametersParameterAccessor accessor) { + protected JpqlQueryCreator createCreator(Sort sort, JpaParametersParameterAccessor accessor) { - EntityManager entityManager = getEntityManager(); - CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + JpqlQueryCreator cached = this.cached; + + if (cached != null) { + return cached; + } - ParameterMetadataProvider provider; + ParameterMetadataProvider provider = new ParameterMetadataProvider(accessor, escape, templates); + JpaCountQueryCreator creator = new JpaCountQueryCreator(tree, + getQueryMethod().getResultProcessor().getReturnedType(), provider, templates, em); - if (accessor != null) { - provider = new ParameterMetadataProvider(builder, accessor, escape); - } else { - provider = new ParameterMetadataProvider(builder, parameters, escape); + if (!accessor.getParameters().hasDynamicProjection()) { + return this.cached = new CacheableJpqlCountQueryCreator(creator); } - return new JpaCountQueryCreator(tree, getQueryMethod().getResultProcessor().getReturnedType(), builder, provider); + return creator; } /** * Customizes binding by skipping the pagination. */ @Override - protected Query invokeBinding(ParameterBinder binder, TypedQuery query, JpaParametersParameterAccessor accessor, - QueryParameterSetter.QueryMetadataCache metadataCache) { + protected Query invokeBinding(ParameterBinder binder, Query query, JpaParametersParameterAccessor accessor) { + return binder.bind(query, accessor); + } + + static class CacheableJpqlCountQueryCreator implements JpqlQueryCreator { + + private final String query; + private final boolean useTupleQuery; + private final List parameterBindings; + private final ParameterBinder binder; + + public CacheableJpqlCountQueryCreator(JpqlQueryCreator delegate) { + + this.query = delegate.createQuery(Sort.unsorted()); + this.useTupleQuery = delegate.useTupleQuery(); + this.parameterBindings = delegate.getBindings(); + this.binder = delegate.getBinder(); + } - QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata("countquery", query); + @Override + public boolean useTupleQuery() { + return useTupleQuery; + } + + @Override + public String createQuery(Sort sort) { + return query; + } - return binder.bind(query, metadata, accessor); + @Override + public List getBindings() { + return parameterBindings; + } + + @Override + public ParameterBinder getBinder() { + return binder; + } } } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java index a05a34052b..8d24ffab7f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java @@ -23,17 +23,16 @@ import jakarta.persistence.criteria.ParameterExpression; import java.lang.reflect.Proxy; -import java.util.Collections; import java.util.Date; -import java.util.HashMap; -import java.util.Map; import java.util.Set; import java.util.function.Function; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; + import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ErrorHandler; /** * The interface encapsulates the setting of query parameters which might use a significant number of variations of @@ -45,158 +44,159 @@ */ interface QueryParameterSetter { - void setParameter(BindableQuery query, JpaParametersParameterAccessor accessor, ErrorHandling errorHandling); - /** Noop implementation */ - QueryParameterSetter NOOP = (query, values, errorHandling) -> {}; + QueryParameterSetter NOOP = (query, values, errorHandler) -> {}; + + /** + * Creates a new {@link QueryParameterSetter} for the given value extractor, JPA parameter and potentially the + * temporal type. + * + * @param valueExtractor + * @param parameter + * @param temporalType + * @return + */ + static QueryParameterSetter create(Function valueExtractor, + Parameter parameter, @Nullable TemporalType temporalType) { + + return temporalType == null ? new NamedOrIndexedQueryParameterSetter(valueExtractor, parameter) + : new TemporalParameterSetter(valueExtractor, parameter, temporalType); + } + + void setParameter(BindableQuery query, JpaParametersParameterAccessor accessor, ErrorHandler errorHandler); /** - * {@link QueryParameterSetter} for named or indexed parameters that might have a {@link TemporalType} specified. + * {@link QueryParameterSetter} for named or indexed parameters. */ class NamedOrIndexedQueryParameterSetter implements QueryParameterSetter { private final Function valueExtractor; private final Parameter parameter; - private final @Nullable TemporalType temporalType; /** * @param valueExtractor must not be {@literal null}. * @param parameter must not be {@literal null}. - * @param temporalType may be {@literal null}. */ - NamedOrIndexedQueryParameterSetter(Function valueExtractor, - Parameter parameter, @Nullable TemporalType temporalType) { + private NamedOrIndexedQueryParameterSetter(Function valueExtractor, + Parameter parameter) { Assert.notNull(valueExtractor, "ValueExtractor must not be null"); this.valueExtractor = valueExtractor; this.parameter = parameter; - this.temporalType = temporalType; } - @SuppressWarnings("unchecked") @Override - public void setParameter(BindableQuery query, JpaParametersParameterAccessor accessor, - ErrorHandling errorHandling) { + public void setParameter(BindableQuery query, JpaParametersParameterAccessor accessor, ErrorHandler errorHandler) { - if (temporalType != null) { + Object value = valueExtractor.apply(accessor); - Object extractedValue = valueExtractor.apply(accessor); - - Date value = (Date) accessor.potentiallyUnwrap(extractedValue); + try { + setParameter(query, value, errorHandler); + } catch (RuntimeException e) { + errorHandler.handleError(e); + } + } - // One would think we can simply use parameter to identify the parameter we want to set. - // But that does not work with list valued parameters. At least Hibernate tries to bind them by name. - // TODO: move to using setParameter(Parameter, value) when https://hibernate.atlassian.net/browse/HHH-11870 is - // fixed. + @SuppressWarnings("unchecked") + private void setParameter(BindableQuery query, Object value, ErrorHandler errorHandler) { - if (parameter instanceof ParameterExpression) { - errorHandling.execute(() -> query.setParameter((Parameter) parameter, value, temporalType)); - } else if (query.hasNamedParameters() && parameter.getName() != null) { - errorHandling.execute(() -> query.setParameter(parameter.getName(), value, temporalType)); - } else { + if (parameter instanceof ParameterExpression) { + query.setParameter((Parameter) parameter, value); + } else if (query.hasNamedParameters() && parameter.getName() != null) { + query.setParameter(parameter.getName(), value); - Integer position = parameter.getPosition(); + } else { - if (position != null // - && (query.getParameters().size() >= parameter.getPosition() // - || query.registerExcessParameters() // - || errorHandling == LENIENT)) { + Integer position = parameter.getPosition(); - errorHandling.execute(() -> query.setParameter(parameter.getPosition(), value, temporalType)); - } + if (position != null // + && (query.getParameters().size() >= position // + || errorHandler == LENIENT // + || query.registerExcessParameters())) { + query.setParameter(position, value); } + } + } + } - } else { + /** + * {@link QueryParameterSetter} for named or indexed parameters that have a {@link TemporalType} specified. + */ + class TemporalParameterSetter implements QueryParameterSetter { + + private final Function valueExtractor; + private final Parameter parameter; + private final TemporalType temporalType; + + private TemporalParameterSetter(Function valueExtractor, + Parameter parameter, TemporalType temporalType) { + this.valueExtractor = valueExtractor; + this.parameter = parameter; + this.temporalType = temporalType; + } + + @Override + public void setParameter(BindableQuery query, JpaParametersParameterAccessor accessor, ErrorHandler errorHandler) { + + Date value = (Date) accessor.potentiallyUnwrap(valueExtractor.apply(accessor)); + + try { + setParameter(query, value, errorHandler); + } catch (RuntimeException e) { + errorHandler.handleError(e); + } + } + + @SuppressWarnings("unchecked") + private void setParameter(BindableQuery query, Date date, ErrorHandler errorHandler) { - Object value = valueExtractor.apply(accessor); + // One would think we can simply use parameter to identify the parameter we want to set. + // But that does not work with list valued parameters. At least Hibernate tries to bind them by name. + // TODO: move to using setParameter(Parameter, value) when https://hibernate.atlassian.net/browse/HHH-11870 is + // fixed. - if (parameter instanceof ParameterExpression) { - errorHandling.execute(() -> query.setParameter((Parameter) parameter, value)); - } else if (query.hasNamedParameters() && parameter.getName() != null) { - errorHandling.execute(() -> query.setParameter(parameter.getName(), value)); + if (parameter instanceof ParameterExpression) { + query.setParameter((Parameter) parameter, date, temporalType); + } else if (query.hasNamedParameters() && parameter.getName() != null) { + query.setParameter(parameter.getName(), date, temporalType); + } else { - } else { + Integer position = parameter.getPosition(); - Integer position = parameter.getPosition(); + if (position != null // + && (query.getParameters().size() >= parameter.getPosition() // + || query.registerExcessParameters() // + || errorHandler == LENIENT)) { - if (position != null // - && (query.getParameters().size() >= position // - || errorHandling == LENIENT // - || query.registerExcessParameters())) { - errorHandling.execute(() -> query.setParameter(position, value)); - } + query.setParameter(parameter.getPosition(), date, temporalType); } } } } - enum ErrorHandling { + enum ErrorHandling implements ErrorHandler { STRICT { @Override - public void execute(Runnable block) { - block.run(); + public void handleError(Throwable t) { + if (t instanceof RuntimeException rx) { + throw rx; + } + throw new RuntimeException(t); } }, LENIENT { @Override - public void execute(Runnable block) { - - try { - block.run(); - } catch (RuntimeException rex) { - LOG.info("Silently ignoring", rex); - } + public void handleError(Throwable t) { + LOG.info("Silently ignoring", t); } }; private static final Log LOG = LogFactory.getLog(ErrorHandling.class); - - abstract void execute(Runnable block); - } - - /** - * Cache for {@link QueryMetadata}. Optimizes for small cache sizes on a best-effort basis. - */ - class QueryMetadataCache { - - private Map cache = Collections.emptyMap(); - - /** - * Retrieve the {@link QueryMetadata} for a given {@code cacheKey}. - * - * @param cacheKey - * @param query - * @return - */ - public QueryMetadata getMetadata(String cacheKey, Query query) { - - QueryMetadata queryMetadata = cache.get(cacheKey); - - if (queryMetadata == null) { - - queryMetadata = new QueryMetadata(query); - - Map cache; - - if (this.cache.isEmpty()) { - cache = Collections.singletonMap(cacheKey, queryMetadata); - } else { - cache = new HashMap<>(this.cache); - cache.put(cacheKey, queryMetadata); - } - - synchronized (this) { - this.cache = cache; - } - } - - return queryMetadata; - } } /** @@ -224,23 +224,6 @@ class QueryMetadata { && unwrapClass(query).getName().startsWith("org.eclipse"); } - QueryMetadata(QueryMetadata metadata) { - - this.namedParameters = metadata.namedParameters; - this.parameters = metadata.parameters; - this.registerExcessParameters = metadata.registerExcessParameters; - } - - /** - * Create a {@link BindableQuery} for a {@link Query}. - * - * @param query - * @return - */ - public BindableQuery withQuery(Query query) { - return new BindableQuery(this, query); - } - /** * @return */ @@ -294,13 +277,7 @@ class BindableQuery extends QueryMetadata { private final Query query; private final Query unwrapped; - BindableQuery(QueryMetadata metadata, Query query) { - super(metadata); - this.query = query; - this.unwrapped = Proxy.isProxyClass(query.getClass()) ? query.unwrap(null) : query; - } - - private BindableQuery(Query query) { + BindableQuery(Query query) { super(query); this.query = query; this.unwrapped = Proxy.isProxyClass(query.getClass()) ? query.unwrap(null) : query; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactory.java index 38247f92db..4844060aa3 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactory.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactory.java @@ -18,7 +18,6 @@ import jakarta.persistence.Query; import jakarta.persistence.TemporalType; -import java.util.List; import java.util.function.Function; import org.springframework.data.expression.ValueEvaluationContext; @@ -28,8 +27,6 @@ import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter; import org.springframework.data.jpa.repository.query.ParameterBinding.BindingIdentifier; import org.springframework.data.jpa.repository.query.ParameterBinding.MethodInvocationArgument; -import org.springframework.data.jpa.repository.query.ParameterMetadataProvider.ParameterMetadata; -import org.springframework.data.jpa.repository.query.QueryParameterSetter.NamedOrIndexedQueryParameterSetter; import org.springframework.data.repository.query.Parameter; import org.springframework.data.repository.query.Parameters; import org.springframework.data.spel.EvaluationContextProvider; @@ -49,36 +46,45 @@ */ abstract class QueryParameterSetterFactory { + /** + * Creates a {@link QueryParameterSetter} for the given {@link ParameterBinding}. This factory may return + * {@literal null} if it doesn't support the given {@link ParameterBinding}. + * + * @param binding the parameter binding to create a {@link QueryParameterSetter} for. + * @return + */ @Nullable - abstract QueryParameterSetter create(ParameterBinding binding, DeclaredQuery declaredQuery); + abstract QueryParameterSetter create(ParameterBinding binding); /** * Creates a new {@link QueryParameterSetterFactory} for the given {@link JpaParameters}. * * @param parameters must not be {@literal null}. + * @param preferNamedParameters whether to prefer named parameters. * @return a basic {@link QueryParameterSetterFactory} that can handle named and index parameters. */ - static QueryParameterSetterFactory basic(JpaParameters parameters) { - - Assert.notNull(parameters, "JpaParameters must not be null"); - - return new BasicQueryParameterSetterFactory(parameters); + static QueryParameterSetterFactory basic(JpaParameters parameters, boolean preferNamedParameters) { + return new BasicQueryParameterSetterFactory(parameters, preferNamedParameters); } /** - * Creates a new {@link QueryParameterSetterFactory} using the given {@link JpaParameters} and - * {@link ParameterMetadata}. + * Creates a new {@link QueryParameterSetterFactory} using the given {@link JpaParameters}. * * @param parameters must not be {@literal null}. - * @param metadata must not be {@literal null}. - * @return a {@link QueryParameterSetterFactory} for criteria Queries. + * @return a {@link QueryParameterSetterFactory} for Part-Tree Queries. */ - static QueryParameterSetterFactory forCriteriaQuery(JpaParameters parameters, List> metadata) { - - Assert.notNull(parameters, "JpaParameters must not be null"); - Assert.notNull(metadata, "ParameterMetadata must not be null"); + static QueryParameterSetterFactory forPartTreeQuery(JpaParameters parameters) { + return new PartTreeQueryParameterSetterFactory(parameters); + } - return new CriteriaQueryParameterSetterFactory(parameters, metadata); + /** + * Creates a new {@link QueryParameterSetterFactory} to bind + * {@link org.springframework.data.jpa.repository.query.ParameterBinding.Synthetic} parameters. + * + * @return a {@link QueryParameterSetterFactory} for JPQL Queries. + */ + static QueryParameterSetterFactory forSynthetic() { + return new SyntheticParameterSetterFactory(); } /** @@ -93,10 +99,6 @@ static QueryParameterSetterFactory forCriteriaQuery(JpaParameters parameters, Li */ static QueryParameterSetterFactory parsing(ValueExpressionParser parser, ValueEvaluationContextProvider evaluationContextProvider) { - - Assert.notNull(parser, "ValueExpressionParser must not be null"); - Assert.notNull(evaluationContextProvider, "ValueEvaluationContextProvider must not be null"); - return new ExpressionBasedQueryParameterSetterFactory(parser, evaluationContextProvider); } @@ -115,7 +117,7 @@ private static QueryParameterSetter createSetter(Function s.value(), binding, null); + } + } + /** * Extracts values for parameter bindings from method parameters. It handles named as well as indexed parameters. * @@ -217,30 +238,33 @@ private Object evaluateExpression(ValueExpression expression, JpaParametersParam private static class BasicQueryParameterSetterFactory extends QueryParameterSetterFactory { private final JpaParameters parameters; + private final boolean preferNamedParameters; /** * @param parameters must not be {@literal null}. + * @param preferNamedParameters whether to use named parameters. */ - BasicQueryParameterSetterFactory(JpaParameters parameters) { + BasicQueryParameterSetterFactory(JpaParameters parameters, boolean preferNamedParameters) { Assert.notNull(parameters, "JpaParameters must not be null"); this.parameters = parameters; + this.preferNamedParameters = preferNamedParameters; } @Override - public QueryParameterSetter create(ParameterBinding binding, DeclaredQuery declaredQuery) { + public QueryParameterSetter create(ParameterBinding binding) { Assert.notNull(binding, "Binding must not be null"); - JpaParameter parameter; if (!(binding.getOrigin() instanceof MethodInvocationArgument mia)) { - return QueryParameterSetter.NOOP; + return null; } BindingIdentifier identifier = mia.identifier(); + JpaParameter parameter; - if (declaredQuery.hasNamedParameter()) { + if (preferNamedParameters) { parameter = findParameterForBinding(parameters, identifier.getName()); } else { parameter = findParameterForBinding(parameters, identifier.getPosition() - 1); @@ -252,7 +276,7 @@ public QueryParameterSetter create(ParameterBinding binding, DeclaredQuery decla } @Nullable - private Object getValue(JpaParametersParameterAccessor accessor, Parameter parameter) { + protected Object getValue(JpaParametersParameterAccessor accessor, Parameter parameter) { return accessor.getValue(parameter); } } @@ -260,60 +284,46 @@ private Object getValue(JpaParametersParameterAccessor accessor, Parameter param /** * @author Jens Schauder * @author Oliver Gierke + * @author Mark Paluch * @see QueryParameterSetterFactory */ - private static class CriteriaQueryParameterSetterFactory extends QueryParameterSetterFactory { + private static class PartTreeQueryParameterSetterFactory extends BasicQueryParameterSetterFactory { private final JpaParameters parameters; - private final List> parameterMetadata; - /** - * Creates a new {@link QueryParameterSetterFactory} from the given {@link JpaParameters} and - * {@link ParameterMetadata}. - * - * @param parameters must not be {@literal null}. - * @param metadata must not be {@literal null}. - */ - CriteriaQueryParameterSetterFactory(JpaParameters parameters, List> metadata) { - - Assert.notNull(parameters, "JpaParameters must not be null"); - Assert.notNull(metadata, "Expressions must not be null"); - - this.parameters = parameters; - this.parameterMetadata = metadata; + private PartTreeQueryParameterSetterFactory(JpaParameters parameters) { + super(parameters, false); + this.parameters = parameters.getBindableParameters(); } @Override - public QueryParameterSetter create(ParameterBinding binding, DeclaredQuery declaredQuery) { + public QueryParameterSetter create(ParameterBinding binding) { + + if (!binding.getOrigin().isMethodArgument()) { + return null; + } int parameterIndex = binding.getRequiredPosition() - 1; Assert.isTrue( // - parameterIndex < parameterMetadata.size(), // + parameterIndex < parameters.getNumberOfParameters(), // () -> String.format( // "At least %s parameter(s) provided but only %s parameter(s) present in query", // binding.getRequiredPosition(), // - parameterMetadata.size() // + parameters.getNumberOfParameters() // ) // ); - ParameterMetadata metadata = parameterMetadata.get(parameterIndex); + if (binding instanceof ParameterBinding.PartTreeParameterBinding ptb) { - if (metadata.isIsNullParameter()) { - return QueryParameterSetter.NOOP; - } + if (ptb.isIsNullParameter()) { + return QueryParameterSetter.NOOP; + } - JpaParameter parameter = parameters.getBindableParameter(parameterIndex); - TemporalType temporalType = parameter.isTemporalParameter() ? parameter.getRequiredTemporalType() : null; + return super.create(binding); + } - return new NamedOrIndexedQueryParameterSetter(values -> getAndPrepare(parameter, metadata, values), - metadata.getExpression(), temporalType); - } - - @Nullable - private Object getAndPrepare(JpaParameter parameter, ParameterMetadata metadata, - JpaParametersParameterAccessor accessor) { - return metadata.prepare(accessor.getValue(parameter)); + return null; } } @@ -357,7 +367,6 @@ public Integer getPosition() { public Class getParameterType() { return parameterType; } - } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java index 3c06c7079b..11010a7aca 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java @@ -826,7 +826,7 @@ static Expression toExpressionRecursively(From from, PropertyPath p * @param hasRequiredOuterJoin has a parent already required an outer join? * @return whether an outer join is to be used for integrating this attribute in a query. */ - private static boolean requiresOuterJoin(From from, PropertyPath property, boolean isForSelection, + static boolean requiresOuterJoin(From from, PropertyPath property, boolean isForSelection, boolean hasRequiredOuterJoin) { // already inner joined so outer join is useless @@ -896,7 +896,7 @@ private static T getAnnotationProperty(Attribute attribute, String pro * @param joinType the join type to create if none was found * @return will never be {@literal null}. */ - private static Join getOrCreateJoin(From from, String attribute, JoinType joinType) { + static Join getOrCreateJoin(From from, String attribute, JoinType joinType) { for (Join join : from.getJoins()) { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureJpaQuery.java index 54d6b0b24a..0c6ddb9461 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureJpaQuery.java @@ -50,7 +50,6 @@ class StoredProcedureJpaQuery extends AbstractJpaQuery { private final StoredProcedureAttributes procedureAttributes; private final boolean useNamedParameters; - private final QueryParameterSetter.QueryMetadataCache metadataCache = new QueryParameterSetter.QueryMetadataCache(); /** * Creates a new {@link StoredProcedureJpaQuery}. @@ -90,9 +89,7 @@ protected StoredProcedureQuery createQuery(JpaParametersParameterAccessor access protected StoredProcedureQuery doCreateQuery(JpaParametersParameterAccessor accessor) { StoredProcedureQuery storedProcedure = createStoredProcedure(); - QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata("singleton", storedProcedure); - - return parameterBinder.get().bind(storedProcedure, metadata, accessor); + return parameterBinder.get().bind(storedProcedure, accessor); } @Override diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpqlQueryTemplates.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpqlQueryTemplates.java new file mode 100644 index 0000000000..24180ae6fc --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpqlQueryTemplates.java @@ -0,0 +1,49 @@ +/* + * Copyright 2024 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 java.util.function.Function; + +/** + * @author Mark Paluch + */ +public class JpqlQueryTemplates { + + public static final JpqlQueryTemplates UPPER = new JpqlQueryTemplates("UPPER", String::toUpperCase); + + public static final JpqlQueryTemplates LOWER = new JpqlQueryTemplates("LOWER", String::toLowerCase); + + private final String ignoreCaseOperator; + + private final Function ignoreCaseFunction; + + JpqlQueryTemplates(String ignoreCaseOperator, Function ignoreCaseFunction) { + this.ignoreCaseOperator = ignoreCaseOperator; + this.ignoreCaseFunction = ignoreCaseFunction; + } + + public static JpqlQueryTemplates of(String ignoreCaseOperator, Function ignoreCaseFunction) { + return new JpqlQueryTemplates(ignoreCaseOperator, ignoreCaseFunction); + } + + public String ignoreCase(String value) { + return ignoreCaseFunction.apply(value); + } + + public String getIgnoreCaseOperator() { + return ignoreCaseOperator; + } +} 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 99343adb99..eac0156397 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 @@ -36,4 +36,10 @@ void executesNotInQueryCorrectly() {} @Override void executesInKeywordForPageCorrectly() {} + @Disabled + @Override + void shouldProjectWithKeysetScrolling() { + + } + } 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 225336983e..dab540e8f3 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 @@ -2450,13 +2450,13 @@ void findByFluentExampleWithInterfaceBasedProjectionUsingSpEL() { prototype.setFirstname("v"); List users = repository.findBy( - of(prototype, - matching().withIgnorePaths("age", "createdAt", "active").withMatcher("firstname", - GenericPropertyMatcher::contains)), // - q -> q.as(UserProjectionUsingSpEL.class).all()); + of(prototype, + matching().withIgnorePaths("age", "createdAt", "active").withMatcher("firstname", + GenericPropertyMatcher::contains)), // + q -> q.as(UserProjectionUsingSpEL.class).all()); assertThat(users).extracting(UserProjectionUsingSpEL::hello) - .contains(new GreetingsFrom().groot(firstUser.getFirstname())); + .contains(new GreetingsFrom().groot(firstUser.getFirstname())); } @Test // GH-2294 @@ -3179,6 +3179,15 @@ void findByElementCollectionInAttributeIgnoreCase() { flushTestUsers(); + /* + TODO: Hibernate-generated HQL for the CriteriaBuilder-based API. Yields only one result in contrast to the CriteriaBuilder one. + Query query = em.createQuery("select alias_544097980 from org.springframework.data.jpa.domain.sample.User alias_544097980 left join alias_544097980.attributes alias_975381534 where alias_975381534 in (?1)") + .setParameter(1, asList("cOOl", "hIP")); + + List resultList = query.getResultList(); + + */ + List result = repository.findByAttributesIgnoreCaseIn(new HashSet<>(asList("cOOl", "hIP"))); assertThat(result).containsOnly(firstUser, secondUser); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreatorIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreatorIntegrationTests.java index 3835426aba..91694ecb2d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreatorIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreatorIntegrationTests.java @@ -19,21 +19,17 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; -import jakarta.persistence.TypedQuery; import java.lang.reflect.Method; import java.util.List; -import org.hibernate.query.spi.SqmQuery; -import org.hibernate.query.sqm.tree.expression.SqmDistinct; -import org.hibernate.query.sqm.tree.expression.SqmFunction; -import org.hibernate.query.sqm.tree.select.SqmSelectClause; -import org.hibernate.query.sqm.tree.select.SqmSelectStatement; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; + import org.springframework.data.jpa.domain.sample.Role; import org.springframework.data.jpa.domain.sample.User; import org.springframework.data.jpa.provider.PersistenceProvider; +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.repository.Repository; import org.springframework.data.repository.core.support.AbstractRepositoryMetadata; @@ -63,25 +59,15 @@ void distinctFlagOnCountQueryIssuesCountDistinct() throws Exception { AbstractRepositoryMetadata.getMetadata(SomeRepository.class), new SpelAwareProxyProjectionFactory(), provider); PartTree tree = new PartTree("findDistinctByRolesIn", User.class); - ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(entityManager.getCriteriaBuilder(), - queryMethod.getParameters(), EscapeCharacter.DEFAULT); + ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider( + queryMethod.getParameters(), EscapeCharacter.DEFAULT, JpqlQueryTemplates.UPPER); JpaCountQueryCreator creator = new JpaCountQueryCreator(tree, queryMethod.getResultProcessor().getReturnedType(), - entityManager.getCriteriaBuilder(), metadataProvider); - - TypedQuery query = entityManager.createQuery(creator.createQuery()); - - SqmQuery sqmQuery = ((SqmQuery) query); - SqmSelectStatement select = (SqmSelectStatement) sqmQuery.getSqmStatement(); + metadataProvider, JpqlQueryTemplates.UPPER, entityManager); - // Verify distinct (should this even be there for a count query?) - SqmSelectClause clause = select.getQuerySpec().getSelectClause(); - assertThat(clause.isDistinct()).isTrue(); + String query = creator.createQuery(); - // Verify count(distinct(…)) - SqmFunction function = ((SqmFunction) clause.getSelectionItems().get(0)); - assertThat(function.getFunctionName()).isEqualTo("count"); - assertThat(function.getArguments().get(0)).isInstanceOf(SqmDistinct.class); + assertThat(query).startsWith("SELECT COUNT(DISTINCT u)"); } interface SomeRepository extends Repository { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreatorTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreatorTests.java new file mode 100644 index 0000000000..2221d3a87a --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreatorTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2024 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.query; + +import static org.assertj.core.api.Assertions.*; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +import java.lang.reflect.Method; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Window; +import org.springframework.data.jpa.domain.sample.User; +import org.springframework.data.jpa.provider.PersistenceProvider; +import org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformation; +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.support.AbstractRepositoryMetadata; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** + * Unit tests for {@link JpaKeysetScrollQueryCreator}. + * + * @author Mark Paluch + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration("classpath:infrastructure.xml") +class JpaKeysetScrollQueryCreatorTests { + + @PersistenceContext EntityManager entityManager; + + @Test // GH-3588 + void shouldCreateContinuationQuery() throws Exception { + + Map keys = Map.of("id", "10", "firstname", "John", "emailAddress", "john@example.com"); + KeysetScrollPosition position = ScrollPosition.of(keys, ScrollPosition.Direction.BACKWARD); + + Method method = MyRepo.class.getMethod("findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc", + String.class, ScrollPosition.class); + + PersistenceProvider provider = PersistenceProvider.fromEntityManager(entityManager); + JpaQueryMethod queryMethod = new JpaQueryMethod(method, AbstractRepositoryMetadata.getMetadata(MyRepo.class), + new SpelAwareProxyProjectionFactory(), provider); + + PartTree tree = new PartTree("findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc", User.class); + ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider( + queryMethod.getParameters(), EscapeCharacter.DEFAULT, JpqlQueryTemplates.UPPER); + + JpaMetamodelEntityInformation entityInformation = new JpaMetamodelEntityInformation<>(User.class, + entityManager.getMetamodel(), entityManager.getEntityManagerFactory().getPersistenceUnitUtil()); + JpaKeysetScrollQueryCreator creator = new JpaKeysetScrollQueryCreator(tree, + queryMethod.getResultProcessor().getReturnedType(), metadataProvider, JpqlQueryTemplates.UPPER, + entityInformation, position, entityManager); + + String query = creator.createQuery(); + + assertThat(query).containsIgnoringWhitespaces(""" + SELECT u FROM org.springframework.data.jpa.domain.sample.User u WHERE (u.firstname LIKE ?1 ESCAPE '\\') + AND (u.firstname < ?2 + OR u.firstname = ?3 AND u.emailAddress < ?4 + OR u.firstname = ?5 AND u.emailAddress = ?6 AND u.id < ?7) + ORDER BY u.firstname desc, u.emailAddress desc, u.id desc + """); + } + + interface MyRepo extends Repository { + + Window findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc(String firstname, + ScrollPosition position); + + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessorTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessorTests.java index 6d7b55dbf1..0c2727ece4 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessorTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessorTests.java @@ -69,7 +69,7 @@ void createsHibernateParametersParameterAccessor() throws Exception { private void bind(JpaParameters parameters, JpaParametersParameterAccessor accessor) { - ParameterBinderFactory.createBinder(parameters) + ParameterBinderFactory.createBinder(parameters, true) .bind( // QueryParameterSetter.BindableQuery.from(query), // accessor, // diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedOrIndexedQueryParameterSetterUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedOrIndexedQueryParameterSetterUnitTests.java index 844ae69e01..7e40aa8443 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedOrIndexedQueryParameterSetterUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedOrIndexedQueryParameterSetterUnitTests.java @@ -18,6 +18,7 @@ import static jakarta.persistence.TemporalType.*; import static java.util.Arrays.*; import static org.mockito.Mockito.*; +import static org.springframework.data.jpa.repository.query.QueryParameterSetter.*; import static org.springframework.data.jpa.repository.query.QueryParameterSetter.ErrorHandling.*; import jakarta.persistence.Parameter; @@ -34,7 +35,8 @@ import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.data.jpa.repository.query.QueryParameterSetter.NamedOrIndexedQueryParameterSetter; + +import org.springframework.data.jpa.repository.query.QueryParameterSetter.*; /** * Unit tests fir {@link NamedOrIndexedQueryParameterSetter}. @@ -79,7 +81,7 @@ void strictErrorHandlingThrowsExceptionForAllVariationsOfParameters() { for (Parameter parameter : parameters) { for (TemporalType temporalType : temporalTypes) { - NamedOrIndexedQueryParameterSetter setter = new NamedOrIndexedQueryParameterSetter( // + QueryParameterSetter setter = QueryParameterSetter.create( // firstValueExtractor, // parameter, // temporalType // @@ -87,7 +89,7 @@ void strictErrorHandlingThrowsExceptionForAllVariationsOfParameters() { softly .assertThatThrownBy( - () -> setter.setParameter(QueryParameterSetter.BindableQuery.from(query), methodArguments, STRICT)) // + () -> setter.setParameter(BindableQuery.from(query), methodArguments, STRICT)) // .describedAs("p-type: %s, p-name: %s, p-position: %s, temporal: %s", // parameter.getClass(), // parameter.getName(), // @@ -108,7 +110,7 @@ void lenientErrorHandlingThrowsNoExceptionForAllVariationsOfParameters() { for (Parameter parameter : parameters) { for (TemporalType temporalType : temporalTypes) { - NamedOrIndexedQueryParameterSetter setter = new NamedOrIndexedQueryParameterSetter( // + QueryParameterSetter setter = QueryParameterSetter.create( // firstValueExtractor, // parameter, // temporalType // @@ -116,7 +118,7 @@ void lenientErrorHandlingThrowsNoExceptionForAllVariationsOfParameters() { softly .assertThatCode( - () -> setter.setParameter(QueryParameterSetter.BindableQuery.from(query), methodArguments, LENIENT)) // + () -> setter.setParameter(BindableQuery.from(query), methodArguments, LENIENT)) // .describedAs("p-type: %s, p-name: %s, p-position: %s, temporal: %s", // parameter.getClass(), // parameter.getName(), // @@ -141,13 +143,13 @@ void lenientSetsParameterWhenSuccessIsUnsure() { for (TemporalType temporalType : temporalTypes) { - NamedOrIndexedQueryParameterSetter setter = new NamedOrIndexedQueryParameterSetter( // + QueryParameterSetter setter = QueryParameterSetter.create( // firstValueExtractor, // new ParameterImpl(null, 11), // parameter position is beyond number of parametes in query (0) temporalType // ); - setter.setParameter(QueryParameterSetter.BindableQuery.from(query), methodArguments, LENIENT); + setter.setParameter(BindableQuery.from(query), methodArguments, LENIENT); if (temporalType == null) { verify(query).setParameter(eq(11), any(Date.class)); @@ -171,13 +173,13 @@ void parameterNotSetWhenSuccessImpossible() { for (TemporalType temporalType : temporalTypes) { - NamedOrIndexedQueryParameterSetter setter = new NamedOrIndexedQueryParameterSetter( // + QueryParameterSetter setter = QueryParameterSetter.create( // firstValueExtractor, // new ParameterImpl(null, null), // no position (and no name) makes a success of a setParameter impossible temporalType // ); - setter.setParameter(QueryParameterSetter.BindableQuery.from(query), methodArguments, LENIENT); + setter.setParameter(BindableQuery.from(query), methodArguments, LENIENT); if (temporalType == null) { verify(query, never()).setParameter(anyInt(), any(Date.class)); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBinderUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBinderUnitTests.java index 4f90c40c71..48f20cff5b 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBinderUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBinderUnitTests.java @@ -274,13 +274,13 @@ private void bind(Method method, Object[] values) { } private void bind(Method method, JpaParameters parameters, Object[] values) { - ParameterBinderFactory.createBinder(parameters).bind(QueryParameterSetter.BindableQuery.from(query), + ParameterBinderFactory.createBinder(parameters, false).bind(QueryParameterSetter.BindableQuery.from(query), getAccessor(method, values), QueryParameterSetter.ErrorHandling.STRICT); } private void bindAndPrepare(Method method, Object[] values) { - ParameterBinderFactory.createBinder(createParameters(method)).bindAndPrepare(query, - new QueryParameterSetter.QueryMetadata(query), getAccessor(method, values)); + ParameterBinderFactory.createBinder(createParameters(method), false).bindAndPrepare(query, + getAccessor(method, values)); } private JpaParametersParameterAccessor getAccessor(Method method, Object... values) { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterExpressionProviderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterExpressionProviderTests.java deleted file mode 100644 index 6d1d5393b9..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterExpressionProviderTests.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2017-2024 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.query; - -import static org.assertj.core.api.Assertions.*; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.ParameterExpression; - -import java.lang.reflect.Method; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.data.jpa.domain.sample.User; -import org.springframework.data.repository.query.DefaultParameters; -import org.springframework.data.repository.query.Parameters; -import org.springframework.data.repository.query.ParametersParameterAccessor; -import org.springframework.data.repository.query.ParametersSource; -import org.springframework.data.repository.query.parser.Part; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -/** - * Integration tests for {@link ParameterMetadataProvider}. - * - * @author Oliver Gierke - * @author Jens Schauder - */ -@ExtendWith(SpringExtension.class) -@ContextConfiguration("classpath:infrastructure.xml") -class ParameterExpressionProviderTests { - - @PersistenceContext EntityManager em; - - @Test // DATADOC-99 - @SuppressWarnings("rawtypes") - void createsParameterExpressionWithMostConcreteType() throws Exception { - - Method method = SampleRepository.class.getMethod("findByIdGreaterThan", int.class); - Parameters parameters = new DefaultParameters(ParametersSource.of(method)); - ParametersParameterAccessor accessor = new ParametersParameterAccessor(parameters, new Object[] { 1 }); - Part part = new Part("IdGreaterThan", User.class); - - CriteriaBuilder builder = em.getCriteriaBuilder(); - ParameterMetadataProvider provider = new ParameterMetadataProvider(builder, accessor, EscapeCharacter.DEFAULT); - ParameterExpression expression = provider.next(part, Comparable.class).getExpression(); - - assertThat(expression.getParameterType()).isEqualTo(Integer.class); - } - - interface SampleRepository { - - User findByIdGreaterThan(int id); - } -} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderIntegrationTests.java index 6dc7b84b1c..12b0d55b60 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderIntegrationTests.java @@ -27,7 +27,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.data.jpa.domain.sample.User; -import org.springframework.data.jpa.repository.query.ParameterMetadataProvider.ParameterMetadata; +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Parameters; import org.springframework.data.repository.query.ParametersSource; @@ -48,14 +48,14 @@ class ParameterMetadataProviderIntegrationTests { @PersistenceContext EntityManager em; - + /* TODO @Test // DATAJPA-758 void forwardsParameterNameIfTransparentlyNamed() throws Exception { ParameterMetadataProvider provider = createProvider(Sample.class.getMethod("findByFirstname", String.class)); ParameterMetadata metadata = provider.next(new Part("firstname", User.class)); - assertThat(metadata.getExpression().getName()).isEqualTo("name"); + assertThat(metadata.getName()).isEqualTo("name"); } @Test // DATAJPA-758 @@ -65,15 +65,15 @@ void forwardsParameterNameIfExplicitlyAnnotated() throws Exception { ParameterMetadata metadata = provider.next(new Part("lastname", User.class)); assertThat(metadata.getExpression().getName()).isNull(); - } + } */ @Test // DATAJPA-772 void doesNotApplyLikeExpansionOnNonStringProperties() throws Exception { ParameterMetadataProvider provider = createProvider(Sample.class.getMethod("findByAgeContaining", Integer.class)); - ParameterMetadata metadata = provider.next(new Part("ageContaining", User.class)); + ParameterBinding.PartTreeParameterBinding binding = provider.next(new Part("ageContaining", User.class)); - assertThat(metadata.prepare(1)).isEqualTo(1); + assertThat(binding.prepare(1)).isEqualTo(1); } private ParameterMetadataProvider createProvider(Method method) { @@ -81,7 +81,8 @@ private ParameterMetadataProvider createProvider(Method method) { JpaParameters parameters = new JpaParameters(ParametersSource.of(method)); simulateDiscoveredParametername(parameters); - return new ParameterMetadataProvider(em.getCriteriaBuilder(), parameters, EscapeCharacter.DEFAULT); + return new ParameterMetadataProvider(parameters, EscapeCharacter.DEFAULT, + JpqlQueryTemplates.UPPER); } @SuppressWarnings({ "unchecked", "ConstantConditions" }) diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderUnitTests.java index c62b6e8b09..d9233a92a9 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderUnitTests.java @@ -18,8 +18,6 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; -import jakarta.persistence.criteria.CriteriaBuilder; - import java.util.Collections; import org.eclipse.persistence.internal.jpa.querydef.ParameterExpressionImpl; @@ -30,7 +28,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; -import org.springframework.data.repository.query.Parameters; + +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; import org.springframework.data.repository.query.parser.Part; /** @@ -51,13 +50,11 @@ class ParameterMetadataProviderUnitTests { @Test // DATAJPA-863 void errorMessageMentionsParametersWhenParametersAreExhausted() { - CriteriaBuilder builder = mock(CriteriaBuilder.class); - - Parameters parameters = mock(Parameters.class, RETURNS_DEEP_STUBS); + JpaParameters parameters = mock(JpaParameters.class, RETURNS_DEEP_STUBS); when(parameters.getBindableParameters().iterator()).thenReturn(Collections.emptyListIterator()); - ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(builder, parameters, - EscapeCharacter.DEFAULT); + ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(parameters, + EscapeCharacter.DEFAULT, JpqlQueryTemplates.UPPER); assertThatExceptionOfType(RuntimeException.class) // .isThrownBy(() -> metadataProvider.next(mock(Part.class))) // @@ -68,6 +65,7 @@ void errorMessageMentionsParametersWhenParametersAreExhausted() { void returnAugmentedValueForStringExpressions() { when(part.getProperty().getLeafProperty().isCollection()).thenReturn(false); + when(part.getProperty().getType()).thenReturn((Class) String.class); assertThat(createParameterMetadata(Part.Type.STARTING_WITH).prepare("starting with")).isEqualTo("starting with%"); assertThat(createParameterMetadata(Part.Type.ENDING_WITH).prepare("ending with")).isEqualTo("%ending with"); @@ -82,6 +80,6 @@ void returnAugmentedValueForStringExpressions() { private ParameterMetadataProvider.ParameterMetadata createParameterMetadata(Part.Type partType) { when(part.getType()).thenReturn(partType); - return new ParameterMetadataProvider.ParameterMetadata<>(parameterExpression, part, null, EscapeCharacter.DEFAULT); + return new ParameterMetadataProvider.ParameterMetadata(part.getProperty().getType(), part, null, EscapeCharacter.DEFAULT, 1, JpqlQueryTemplates.LOWER); } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeJpaQueryIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeJpaQueryIntegrationTests.java index ddd71dbfa7..6ebe50b2f0 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeJpaQueryIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeJpaQueryIntegrationTests.java @@ -17,9 +17,7 @@ */ package org.springframework.data.jpa.repository.query; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.*; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -37,9 +35,11 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; + import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.sample.Role; import org.springframework.data.jpa.domain.sample.User; import org.springframework.data.jpa.provider.HibernateUtils; import org.springframework.data.jpa.provider.PersistenceProvider; @@ -147,7 +147,7 @@ void isEmptyCollection() throws Exception { Query query = jpaQuery.createQuery(getAccessor(queryMethod, new Object[] {})); - assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))).endsWith("roles is empty"); + assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))).endsWith("roles IS EMPTY"); } @Test // DATAJPA-1074, HHH-15432 @@ -158,7 +158,18 @@ void isNotEmptyCollection() throws Exception { Query query = jpaQuery.createQuery(getAccessor(queryMethod, new Object[] {})); - assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))).endsWith("roles is not empty"); + assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))).endsWith("roles IS NOT EMPTY"); + } + + @Test // + void containingCollection() throws Exception { + + JpaQueryMethod queryMethod = getQueryMethod("findByRolesContaining", Role.class); + PartTreeJpaQuery jpaQuery = new PartTreeJpaQuery(queryMethod, entityManager); + + Query query = jpaQuery.createQuery(getAccessor(queryMethod, new Object[] { new Role() })); + + assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))).endsWith("MEMBER OF u.roles"); } @Test // DATAJPA-1074 @@ -166,7 +177,8 @@ void rejectsIsEmptyOnNonCollectionProperty() throws Exception { JpaQueryMethod method = getQueryMethod("findByFirstnameIsEmpty"); - assertThatIllegalArgumentException().isThrownBy(() -> new PartTreeJpaQuery(method, entityManager)); + assertThatIllegalArgumentException().isThrownBy( + () -> new PartTreeJpaQuery(method, entityManager).createQuery(getAccessor(method, new Object[] {}))); } @Test // DATAJPA-1182 @@ -291,6 +303,8 @@ interface UserRepository extends Repository { List findByFirstnameIsEmpty(); + List findByRolesContaining(Role role); + // should fail, since we can't compare scalar values to collections List findById(Collection ids); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java index 51fb6d8d37..ae40f69801 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java @@ -18,13 +18,12 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; -import java.util.Collections; -import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; + import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter; import org.springframework.data.jpa.repository.query.ParameterBinding.ParameterOrigin; @@ -48,12 +47,12 @@ void before() { // we have one bindable parameter when(parameters.getBindableParameters().iterator()).thenReturn(Stream.of(mock(JpaParameter.class)).iterator()); - setterFactory = QueryParameterSetterFactory.basic(parameters); + setterFactory = QueryParameterSetterFactory.basic(parameters, true); } @Test // DATAJPA-1058 void noExceptionWhenQueryDoesNotContainNamedParameters() { - setterFactory.create(binding, DeclaredQuery.of("from Employee e", false)); + setterFactory.create(binding); } @Test // DATAJPA-1058 @@ -62,8 +61,8 @@ void exceptionWhenQueryContainNamedParametersAndMethodParametersAreNotNamed() { when(binding.getOrigin()).thenReturn(ParameterOrigin.ofParameter("NamedParameter", 1)); assertThatExceptionOfType(IllegalStateException.class) // - .isThrownBy(() -> setterFactory.create(binding, - DeclaredQuery.of("from Employee e where e.name = :NamedParameter", false))) // + .isThrownBy(() -> setterFactory.create(binding + )) // .withMessageContaining("Java 8") // .withMessageContaining("@Param") // .withMessageContaining("-parameters"); @@ -73,16 +72,14 @@ void exceptionWhenQueryContainNamedParametersAndMethodParametersAreNotNamed() { void exceptionWhenCriteriaQueryContainsInsufficientAmountOfParameters() { // no parameter present in the criteria query - List> metadata = Collections.emptyList(); - QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.forCriteriaQuery(parameters, metadata); + QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.forPartTreeQuery(parameters); // one argument present in the method signature when(binding.getRequiredPosition()).thenReturn(1); when(binding.getOrigin()).thenReturn(ParameterOrigin.ofParameter(null, 1)); assertThatExceptionOfType(IllegalArgumentException.class) // - .isThrownBy(() -> setterFactory.create(binding, - DeclaredQuery.of("from Employee e where e.name = :NamedParameter", false))) // + .isThrownBy(() -> setterFactory.create(binding)) // .withMessage("At least 1 parameter(s) provided but only 0 parameter(s) present in query"); } @@ -90,14 +87,14 @@ void exceptionWhenCriteriaQueryContainsInsufficientAmountOfParameters() { void exceptionWhenBasicQueryContainsInsufficientAmountOfParameters() { // no parameter present in the criteria query - QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.basic(parameters); + QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.basic(parameters, false); // one argument present in the method signature when(binding.getRequiredPosition()).thenReturn(1); when(binding.getOrigin()).thenReturn(ParameterOrigin.ofParameter(null, 1)); assertThatExceptionOfType(IllegalArgumentException.class) // - .isThrownBy(() -> setterFactory.create(binding, DeclaredQuery.of("from Employee e where e.name = ?1", false))) // + .isThrownBy(() -> setterFactory.create(binding)) // .withMessage("At least 1 parameter(s) provided but only 0 parameter(s) present in query"); } } From 423b8b0b636206e291f8925463a113e0ac7909ad Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Tue, 5 Nov 2024 16:17:01 +0100 Subject: [PATCH 3/5] Polishing. Make usage of ParameterExpression more explicit. Add JPQL rendering tests. Favor Metamodel over From for building jpql queries. Align IsNull and IsNotNull handling. Support Derived Delete and Exists, consider null values when caching queries. --- .../query/JpaKeysetScrollQueryCreator.java | 2 +- .../jpa/repository/query/JpaParameters.java | 2 +- .../jpa/repository/query/JpaQueryCreator.java | 87 +- .../repository/query/JpqlQueryBuilder.java | 127 +- .../data/jpa/repository/query/JpqlUtils.java | 167 ++- .../query/KeysetScrollSpecification.java | 16 +- .../repository/query/ParameterBinding.java | 6 +- .../repository/query/PartTreeJpaQuery.java | 17 +- .../repository/query/PartTreeQueryCache.java | 100 ++ .../data/jpa/repository/query/QueryUtils.java | 2 +- .../repository/UserRepositoryFinderTests.java | 9 + .../query/JpaQueryCreatorTests.java | 1039 +++++++++++++++++ .../query/JpqlQueryBuilderUnitTests.java | 265 +++++ .../query/PartTreeQueryCacheUnitTests.java | 116 ++ .../StubJpaParameterParameterAccessor.java | 93 ++ .../jpa/repository/sample/UserRepository.java | 2 + .../data/jpa/util/TestMetaModel.java | 119 ++ .../test/resources/META-INF/persistence.xml | 8 + 18 files changed, 2095 insertions(+), 82 deletions(-) create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java create mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java create mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java create mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeQueryCacheUnitTests.java create mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StubJpaParameterParameterAccessor.java create mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/util/TestMetaModel.java diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java index 47d093deff..bf97edc7d2 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java @@ -79,7 +79,7 @@ protected JpqlQueryBuilder.AbstractJpqlQuery createQuery(@Nullable JpqlQueryBuil JpqlQueryBuilder.Predicate keysetPredicate = keysetSpec.createJpqlPredicate(getFrom(), getEntity(), value -> { syntheticBindings.add(provider.nextSynthetic(value, scrollPosition)); - return JpqlQueryBuilder.expression(render(counter.incrementAndGet())); + return placeholder(counter.incrementAndGet()); }); JpqlQueryBuilder.Predicate predicateToUse = getPredicate(predicate, keysetPredicate); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParameters.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParameters.java index 0430bb4283..d40895ab78 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParameters.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParameters.java @@ -63,7 +63,7 @@ protected JpaParameters(ParametersSource parametersSource, super(parametersSource, parameterFactory); } - private JpaParameters(List parameters) { + JpaParameters(List parameters) { super(parameters); } 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 a7f9fc3861..4aaacbecd6 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 @@ -15,23 +15,30 @@ */ package org.springframework.data.jpa.repository.query; -import static org.springframework.data.repository.query.parser.Part.Type.*; +import static org.springframework.data.repository.query.parser.Part.Type.IS_NOT_EMPTY; +import static org.springframework.data.repository.query.parser.Part.Type.NOT_CONTAINING; +import static org.springframework.data.repository.query.parser.Part.Type.NOT_LIKE; +import static org.springframework.data.repository.query.parser.Part.Type.SIMPLE_PROPERTY; import jakarta.persistence.EntityManager; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Expression; -import jakarta.persistence.criteria.From; import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.metamodel.Attribute; +import jakarta.persistence.metamodel.Bindable; import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.Metamodel; import jakarta.persistence.metamodel.SingularAttribute; import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; import java.util.List; +import java.util.stream.Collectors; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.JpaSort; +import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.ParameterPlaceholder; import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.PathAndOrigin; import org.springframework.data.jpa.repository.query.ParameterBinding.PartTreeParameterBinding; import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; @@ -56,6 +63,7 @@ * @author Moritz Becker * @author Andrey Kovalev * @author Greg Turnquist + * @author Christoph Strobl */ class JpaQueryCreator extends AbstractQueryCreator implements JpqlQueryCreator { @@ -65,8 +73,8 @@ class JpaQueryCreator extends AbstractQueryCreator entityType; - private final From from; private final JpqlQueryBuilder.Entity entity; + private final Metamodel metamodel; /** * Create a new {@link JpaQueryCreator}. @@ -87,12 +95,12 @@ public JpaQueryCreator(PartTree tree, ReturnedType type, ParameterMetadataProvid this.templates = templates; this.escape = provider.getEscape(); this.entityType = em.getMetamodel().entity(type.getDomainType()); - this.from = em.getCriteriaBuilder().createQuery().from(type.getDomainType()); this.entity = JpqlQueryBuilder.entity(returnedType.getDomainType()); + this.metamodel = em.getMetamodel(); } - From getFrom() { - return from; + Bindable getFrom() { + return entityType; } JpqlQueryBuilder.Entity getEntity() { @@ -174,7 +182,7 @@ protected JpqlQueryBuilder.Select buildQuery(Sort sort) { QueryUtils.checkSortExpression(order); try { - expression = JpqlQueryBuilder.expression(JpqlUtils.toExpressionRecursively(entity, from, + expression = JpqlQueryBuilder.expression(JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, PropertyPath.from(order.getProperty(), entityType.getJavaType()))); } catch (PropertyReferenceException e) { @@ -209,12 +217,19 @@ private JpqlQueryBuilder.Select doSelect(Sort sort) { if (returnedType.needsCustomConstruction()) { - Collection requiredSelection = getRequiredSelection(sort, returnedType); + Collection requiredSelection = null; + if (returnedType.getReturnedType().getPackageName().startsWith("java.util") + || returnedType.getReturnedType().getPackageName().startsWith("jakarta.persistence")) { + requiredSelection = metamodel.managedType(returnedType.getDomainType()).getAttributes().stream() + .map(Attribute::getName).collect(Collectors.toList()); + } else { + requiredSelection = getRequiredSelection(sort, returnedType); + } List paths = new ArrayList<>(requiredSelection.size()); for (String selection : requiredSelection) { - paths.add( - JpqlUtils.toExpressionRecursively(entity, from, PropertyPath.from(selection, from.getJavaType()), true)); + paths.add(JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, + PropertyPath.from(selection, returnedType.getDomainType()), true)); } if (useTupleQuery()) { @@ -230,14 +245,14 @@ private JpqlQueryBuilder.Select doSelect(Sort sort) { if (entityType.hasSingleIdAttribute()) { SingularAttribute id = entityType.getId(entityType.getIdType().getJavaType()); - return selectStep.select( - JpqlUtils.toExpressionRecursively(entity, from, PropertyPath.from(id.getName(), from.getJavaType()), true)); + return selectStep.select(JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, + PropertyPath.from(id.getName(), returnedType.getDomainType()), true)); } else { List paths = entityType.getIdClassAttributes().stream()// - .map(it -> JpqlUtils.toExpressionRecursively(entity, from, - PropertyPath.from(it.getName(), from.getJavaType()), true)) + .map(it -> JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, + PropertyPath.from(it.getName(), returnedType.getDomainType()), true)) .toList(); return selectStep.select(paths); } @@ -254,12 +269,12 @@ Collection getRequiredSelection(Sort sort, ReturnedType returnedType) { return returnedType.getInputProperties(); } - String render(ParameterBinding binding) { - return render(binding.getRequiredPosition()); + JpqlQueryBuilder.Expression placeholder(ParameterBinding binding) { + return placeholder(binding.getRequiredPosition()); } - String render(int position) { - return "?" + position; + JpqlQueryBuilder.Expression placeholder(int position) { + return JpqlQueryBuilder.parameter(ParameterPlaceholder.indexed(position)); } /** @@ -304,7 +319,7 @@ public JpqlQueryBuilder.Predicate build() { PropertyPath property = part.getProperty(); Type type = part.getType(); - PathAndOrigin pas = JpqlUtils.toExpressionRecursively(entity, from, property); + PathAndOrigin pas = JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, property); JpqlQueryBuilder.WhereStep where = JpqlQueryBuilder.where(pas); JpqlQueryBuilder.WhereStep whereIgnoreCase = JpqlQueryBuilder.where(potentiallyIgnoreCase(pas)); @@ -312,25 +327,25 @@ public JpqlQueryBuilder.Predicate build() { case BETWEEN: PartTreeParameterBinding first = provider.next(part); ParameterBinding second = provider.next(part); - return where.between(render(first), render(second)); + return where.between(placeholder(first), placeholder(second)); case AFTER: case GREATER_THAN: - return where.gt(render(provider.next(part))); + return where.gt(placeholder(provider.next(part))); case GREATER_THAN_EQUAL: - return where.gte(render(provider.next(part))); + return where.gte(placeholder(provider.next(part))); case BEFORE: case LESS_THAN: - return where.lt(render(provider.next(part))); + return where.lt(placeholder(provider.next(part))); case LESS_THAN_EQUAL: - return where.lte(render(provider.next(part))); + return where.lte(placeholder(provider.next(part))); case IS_NULL: return where.isNull(); case IS_NOT_NULL: return where.isNotNull(); case NOT_IN: - return whereIgnoreCase.notIn(render(provider.next(part, Collection.class))); + return whereIgnoreCase.notIn(placeholder(provider.next(part, Collection.class))); case IN: - return whereIgnoreCase.in(render(provider.next(part, Collection.class))); + return whereIgnoreCase.in(placeholder(provider.next(part, Collection.class))); case STARTING_WITH: case ENDING_WITH: case CONTAINING: @@ -339,8 +354,8 @@ public JpqlQueryBuilder.Predicate build() { if (property.getLeafProperty().isCollection()) { where = JpqlQueryBuilder.where(entity, property); - return type.equals(NOT_CONTAINING) ? where.notMemberOf(render(provider.next(part))) - : where.memberOf(render(provider.next(part))); + return type.equals(NOT_CONTAINING) ? where.notMemberOf(placeholder(provider.next(part))) + : where.memberOf(placeholder(provider.next(part))); } case LIKE: @@ -348,7 +363,7 @@ public JpqlQueryBuilder.Predicate build() { PartTreeParameterBinding parameter = provider.next(part, String.class); JpqlQueryBuilder.Expression parameterExpression = potentiallyIgnoreCase(part.getProperty(), - JpqlQueryBuilder.parameter(render(parameter))); + placeholder(parameter)); // Predicate like = builder.like(propertyExpression, parameterExpression, escape.getEscapeCharacter()); String escapeChar = Character.toString(escape.getEscapeCharacter()); return @@ -361,16 +376,16 @@ public JpqlQueryBuilder.Predicate build() { case FALSE: return where.isFalse(); case SIMPLE_PROPERTY: + case NEGATING_SIMPLE_PROPERTY: + PartTreeParameterBinding metadata = provider.next(part); if (metadata.isIsNullParameter()) { - return where.isNull(); + return type.equals(SIMPLE_PROPERTY) ? where.isNull() : where.isNotNull(); } - return whereIgnoreCase.eq(potentiallyIgnoreCase(property, JpqlQueryBuilder.expression(render(metadata)))); - case NEGATING_SIMPLE_PROPERTY: - return whereIgnoreCase - .neq(potentiallyIgnoreCase(property, JpqlQueryBuilder.expression(render(provider.next(part))))); + JpqlQueryBuilder.Expression expression = potentiallyIgnoreCase(property, placeholder(metadata)); + return type.equals(SIMPLE_PROPERTY) ? whereIgnoreCase.eq(expression) : whereIgnoreCase.neq(expression); case IS_EMPTY: case IS_NOT_EMPTY: @@ -404,8 +419,8 @@ private JpqlQueryBuilder.Expression potentiallyIgnoreCase(JpqlQueryBuilder.O * @param path must not be {@literal null}. * @return */ - private JpqlQueryBuilder.Expression potentiallyIgnoreCase(PathAndOrigin pas) { - return potentiallyIgnoreCase(pas.path(), JpqlQueryBuilder.expression(pas)); + private JpqlQueryBuilder.Expression potentiallyIgnoreCase(PathAndOrigin path) { + return potentiallyIgnoreCase(path.path(), JpqlQueryBuilder.expression(path)); } /** diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java index 42c8ee95d7..cb53998c3f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java @@ -15,7 +15,8 @@ */ package org.springframework.data.jpa.repository.query; -import static org.springframework.data.jpa.repository.query.QueryTokens.*; +import static org.springframework.data.jpa.repository.query.QueryTokens.TOKEN_ASC; +import static org.springframework.data.jpa.repository.query.QueryTokens.TOKEN_DESC; import java.util.ArrayList; import java.util.Arrays; @@ -32,7 +33,9 @@ import org.springframework.data.util.Predicates; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; /** * A Domain-Specific Language to build JPQL queries using Java code. @@ -189,7 +192,7 @@ public static Expression expression(PathAndOrigin pas) { } /** - * Create a simple expression from a string. + * Create a simple expression from a string as is. * * @param expression * @return @@ -201,11 +204,19 @@ public static Expression expression(String expression) { return new LiteralExpression(expression); } + public static Expression stringLiteral(String literal) { + return new StringLiteralExpression(literal); + } + public static Expression parameter(String parameter) { Assert.hasText(parameter, "Parameter must not be empty or null"); - return new ParameterExpression(parameter); + return new ParameterExpression(new ParameterPlaceholder(parameter)); + } + + public static Expression parameter(ParameterPlaceholder placeholder) { + return new ParameterExpression(placeholder); } public static Expression orderBy(Expression sortExpression, Sort.Order order) { @@ -279,12 +290,12 @@ public Predicate isNotNull() { @Override public Predicate isTrue() { - return new LhsPredicate(rhs, "IS TRUE"); + return new LhsPredicate(rhs, "= TRUE"); } @Override public Predicate isFalse() { - return new LhsPredicate(rhs, "IS FALSE"); + return new LhsPredicate(rhs, "= FALSE"); } @Override @@ -309,7 +320,7 @@ public Predicate notIn(Expression value) { @Override public Predicate inMultivalued(Expression value) { - return new MemberOfPredicate(rhs, "IN", value); + return new MemberOfPredicate(rhs, "IN", value); // TODO: that does not line up in my head - ahahah } @Override @@ -466,6 +477,42 @@ public String toString() { } } + static PathAndOrigin path(Origin origin, String path) { + + if(origin instanceof Entity entity) { + + try { + PropertyPath from = PropertyPath.from(path, ClassUtils.forName(entity.entity, Entity.class.getClassLoader())); + return new PathAndOrigin(from, entity, false); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + if(origin instanceof Join join) { + + Origin parent = join.source; + List segments = new ArrayList<>(); + segments.add(join.path); + while(!(parent instanceof Entity)) { + if(parent instanceof Join pj) { + parent = pj.source; + segments.add(pj.path); + } else { + parent = null; + } + } + + if(parent instanceof Entity entity) { + Collections.reverse(segments); + segments.add(path); + PathAndOrigin path1 = path(parent, StringUtils.collectionToDelimitedString(segments, ".")); + return new PathAndOrigin(path1.path().getLeafProperty(), origin, false); + } + } + throw new IllegalStateException(" oh no "); + + } + /** * Entity selection. * @@ -513,7 +560,9 @@ record ConstructorExpression(String resultType, Multiselect multiselect) impleme @Override public String render(RenderContext context) { - return "new %s(%s)".formatted(resultType, multiselect.render(context)); + + + return "new %s(%s)".formatted(resultType, multiselect.render(new ConstructorContext(context))); } @Override @@ -542,7 +591,9 @@ public String render(RenderContext context) { } builder.append(PathExpression.render(path, context)); - builder.append(" ").append(path.path().getSegment()); + if(!context.isConstructorContext()) { + builder.append(" ").append(path.path().getSegment()); + } } return builder.toString(); @@ -583,7 +634,7 @@ default Predicate or(Predicate other) { * @param other * @return a composed predicate combining this and {@code other} using the AND operator. */ - default Predicate and(Predicate other) { + default Predicate and(Predicate other) { // don't like the structuring of this and the nest() thing return new AndPredicate(this, other); } @@ -799,6 +850,22 @@ public String prefixWithAlias(Origin source, String fragment) { String alias = getAlias(source); return ObjectUtils.isEmpty(source) ? fragment : alias + "." + fragment; } + + public boolean isConstructorContext() { + return false; + } + } + + static class ConstructorContext extends RenderContext { + + ConstructorContext(RenderContext rootContext) { + super(rootContext.aliases); + } + + @Override + public boolean isConstructorContext() { + return true; + } } /** @@ -807,7 +874,7 @@ public String prefixWithAlias(Origin source, String fragment) { */ public interface Origin { - String getName(); + String getName(); // TODO: mainly used along records - shoule we call this just name()? } /** @@ -1051,11 +1118,28 @@ public String toString() { } } - record ParameterExpression(String parameter) implements Expression { + record StringLiteralExpression(String literal) implements Expression { @Override public String render(RenderContext context) { - return parameter; + return "'%s'".formatted(literal.replaceAll("'", "''")); + } + + public String raw() { + return literal; + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + } + + record ParameterExpression(ParameterPlaceholder parameter) implements Expression { + + @Override + public String render(RenderContext context) { + return parameter.placeholder; } @Override @@ -1158,6 +1242,8 @@ record InPredicate(Expression path, String operator, Expression predicate) imple @Override public String render(RenderContext context) { + + //TODO: should we rather wrap it with nested or check if its a nested predicate before we call render return "%s %s (%s)".formatted(path.render(context), operator, predicate.render(context)); } @@ -1216,4 +1302,21 @@ public String toString() { public record PathAndOrigin(PropertyPath path, Origin origin, boolean onTheJoin) { } + + public record ParameterPlaceholder(String placeholder) { + + public ParameterPlaceholder { + Assert.hasText(placeholder, "Placeholder must not be null nor empty"); + } + + public static ParameterPlaceholder indexed(int index) { + return new ParameterPlaceholder("?%s".formatted(index)); + } + + public static ParameterPlaceholder named(String name) { + + Assert.hasText(name, "Placeholder name must not be empty"); + return new ParameterPlaceholder(":%s".formatted(name)); + } + } } 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 50da5558bb..d3b32380cd 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 @@ -15,27 +15,64 @@ */ package org.springframework.data.jpa.repository.query; +import static jakarta.persistence.metamodel.Attribute.PersistentAttributeType.ELEMENT_COLLECTION; +import static jakarta.persistence.metamodel.Attribute.PersistentAttributeType.MANY_TO_MANY; +import static jakarta.persistence.metamodel.Attribute.PersistentAttributeType.MANY_TO_ONE; +import static jakarta.persistence.metamodel.Attribute.PersistentAttributeType.ONE_TO_MANY; +import static jakarta.persistence.metamodel.Attribute.PersistentAttributeType.ONE_TO_ONE; + +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; import jakarta.persistence.criteria.From; import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.JoinType; +import jakarta.persistence.metamodel.Attribute; +import jakarta.persistence.metamodel.Attribute.PersistentAttributeType; +import jakarta.persistence.metamodel.Bindable; +import jakarta.persistence.metamodel.ManagedType; +import jakarta.persistence.metamodel.Metamodel; +import jakarta.persistence.metamodel.PluralAttribute; +import jakarta.persistence.metamodel.SingularAttribute; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Member; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; +import org.springframework.core.annotation.AnnotationUtils; import org.springframework.data.mapping.PropertyPath; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; /** * @author Mark Paluch */ class JpqlUtils { - static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(JpqlQueryBuilder.Origin source, From from, - PropertyPath property) { - return toExpressionRecursively(source, from, property, false); + private static final Map> ASSOCIATION_TYPES; + + static { + Map> persistentAttributeTypes = new HashMap<>(); + persistentAttributeTypes.put(ONE_TO_ONE, OneToOne.class); + persistentAttributeTypes.put(ONE_TO_MANY, null); + persistentAttributeTypes.put(MANY_TO_ONE, ManyToOne.class); + persistentAttributeTypes.put(MANY_TO_MANY, null); + persistentAttributeTypes.put(ELEMENT_COLLECTION, null); + + ASSOCIATION_TYPES = Collections.unmodifiableMap(persistentAttributeTypes); } - static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(JpqlQueryBuilder.Origin source, From from, - PropertyPath property, boolean isForSelection) { - return toExpressionRecursively(source, from, property, isForSelection, false); + static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(Metamodel metamodel, JpqlQueryBuilder.Origin source, + Bindable from, PropertyPath property) { + return toExpressionRecursively(metamodel, source, from, property, false); + } + + static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(Metamodel metamodel, JpqlQueryBuilder.Origin source, + Bindable from, PropertyPath property, boolean isForSelection) { + return toExpressionRecursively(metamodel, source, from, property, isForSelection, false); } /** @@ -45,18 +82,18 @@ static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(JpqlQueryBuilder.O * @param property the property path * @param isForSelection is the property navigated for the selection or ordering part of the query? * @param hasRequiredOuterJoin has a parent already required an outer join? - * @param the type of the expression * @return the expression */ @SuppressWarnings("unchecked") - static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(JpqlQueryBuilder.Origin source, From from, - PropertyPath property, boolean isForSelection, boolean hasRequiredOuterJoin) { + static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(Metamodel metamodel, JpqlQueryBuilder.Origin source, + Bindable from, PropertyPath property, boolean isForSelection, boolean hasRequiredOuterJoin) { String segment = property.getSegment(); boolean isLeafProperty = !property.hasNext(); - boolean requiresOuterJoin = QueryUtils.requiresOuterJoin(from, property, isForSelection, hasRequiredOuterJoin); + boolean requiresOuterJoin = requiresOuterJoin(metamodel, source, from, property, isForSelection, + hasRequiredOuterJoin); // if it does not require an outer join and is a leaf, simply get the segment if (!requiresOuterJoin && isLeafProperty) { @@ -66,9 +103,10 @@ static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(JpqlQueryBuilder.O // get or create the join JpqlQueryBuilder.Join joinSource = requiresOuterJoin ? JpqlQueryBuilder.leftJoin(source, segment) : JpqlQueryBuilder.innerJoin(source, segment); - JoinType joinType = requiresOuterJoin ? JoinType.LEFT : JoinType.INNER; - Join join = QueryUtils.getOrCreateJoin(from, segment, joinType); +// JoinType joinType = requiresOuterJoin ? JoinType.LEFT : JoinType.INNER; +// Join join = QueryUtils.getOrCreateJoin(from, segment, joinType); +// // if it's a leaf, return the join if (isLeafProperty) { return new JpqlQueryBuilder.PathAndOrigin(property, joinSource, true); @@ -76,7 +114,110 @@ static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(JpqlQueryBuilder.O PropertyPath nextProperty = Objects.requireNonNull(property.next(), "An element of the property path is null"); +// ManagedType managedType = ; + Bindable managedTypeForModel = (Bindable) getManagedTypeForModel(from); +// Attribute joinAttribute = getModelForPath(metamodel, property, getManagedTypeForModel(from), null); // recurse with the next property - return toExpressionRecursively(joinSource, join, nextProperty, isForSelection, requiresOuterJoin); + return toExpressionRecursively(metamodel, joinSource, managedTypeForModel, nextProperty, isForSelection, requiresOuterJoin); + } + + /** + * Checks if this attribute requires an outer join. This is the case e.g. if it hadn't already been fetched with an + * inner join and if it's an optional association, and if previous paths has already required outer joins. It also + * ensures outer joins are used even when Hibernate defaults to inner joins (HHH-12712 and HHH-12999) + * + * @param metamodel + * @param source + * @param bindable + * @param propertyPath + * @param isForSelection + * @param hasRequiredOuterJoin + * @return + */ + static boolean requiresOuterJoin(Metamodel metamodel, JpqlQueryBuilder.Origin source, Bindable bindable, + PropertyPath propertyPath, boolean isForSelection, boolean hasRequiredOuterJoin) { + + ManagedType managedType = getManagedTypeForModel(bindable); + Attribute attribute = getModelForPath(metamodel, propertyPath, managedType, bindable); + + boolean isPluralAttribute = bindable instanceof PluralAttribute; + if (attribute == null) { + return isPluralAttribute; + } + + if (!ASSOCIATION_TYPES.containsKey(attribute.getPersistentAttributeType())) { + return false; + } + + boolean isCollection = attribute.isCollection(); + + // if this path is an optional one to one attribute navigated from the not owning side we also need an + // explicit outer join to avoid https://hibernate.atlassian.net/browse/HHH-12712 + // and https://github.com/eclipse-ee4j/jpa-api/issues/170 + boolean isInverseOptionalOneToOne = PersistentAttributeType.ONE_TO_ONE == attribute.getPersistentAttributeType() + && StringUtils.hasText(getAnnotationProperty(attribute, "mappedBy", "")); + + boolean isLeafProperty = !propertyPath.hasNext(); + if (isLeafProperty && !isForSelection && !isCollection && !isInverseOptionalOneToOne && !hasRequiredOuterJoin) { + return false; + } + + return hasRequiredOuterJoin || getAnnotationProperty(attribute, "optional", true); + } + + @Nullable + private static T getAnnotationProperty(Attribute attribute, String propertyName, T defaultValue) { + + Class associationAnnotation = ASSOCIATION_TYPES.get(attribute.getPersistentAttributeType()); + + if (associationAnnotation == null) { + return defaultValue; + } + + Member member = attribute.getJavaMember(); + + if (!(member instanceof AnnotatedElement annotatedMember)) { + return defaultValue; + } + + Annotation annotation = AnnotationUtils.getAnnotation(annotatedMember, associationAnnotation); + return annotation == null ? defaultValue : (T) AnnotationUtils.getValue(annotation, propertyName); + } + + @Nullable + private static ManagedType getManagedTypeForModel(Bindable model) { + + if (model instanceof ManagedType managedType) { + return managedType; + } + + if (!(model instanceof SingularAttribute singularAttribute)) { + return null; + } + + return singularAttribute.getType() instanceof ManagedType managedType ? managedType : null; + } + + @Nullable + private static Attribute getModelForPath(Metamodel metamodel, PropertyPath path, + @Nullable ManagedType managedType, Bindable fallback) { + + String segment = path.getSegment(); + if (managedType != null) { + try { + return managedType.getAttribute(segment); + } catch (IllegalArgumentException ex) { + // ManagedType may be erased for some vendor if the attribute is declared as generic + } + } + + Class fallbackType = fallback.getBindableJavaType(); + try { + return metamodel.managedType(fallbackType).getAttribute(segment); + } catch (IllegalArgumentException e) { + + } + + return null; } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java index ede9516b05..df4106f35e 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java @@ -24,6 +24,8 @@ import java.util.List; +import jakarta.persistence.metamodel.Bindable; +import jakarta.persistence.metamodel.Metamodel; import org.springframework.data.domain.KeysetScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; @@ -77,11 +79,11 @@ public Predicate createPredicate(Root root, CriteriaBuilder criteriaBuilder) } @Nullable - public JpqlQueryBuilder.Predicate createJpqlPredicate(From from, JpqlQueryBuilder.Entity entity, + public JpqlQueryBuilder.Predicate createJpqlPredicate(Bindable from, JpqlQueryBuilder.Entity entity, ParameterFactory factory) { KeysetScrollDelegate delegate = KeysetScrollDelegate.of(position.getDirection()); - return delegate.createPredicate(position, sort, new JpqlStrategy(from, entity, factory)); + return delegate.createPredicate(position, sort, new JpqlStrategy(null, from, entity, factory)); } @SuppressWarnings("rawtypes") @@ -128,22 +130,24 @@ public Predicate or(List intermediate) { private static class JpqlStrategy implements QueryStrategy { - private final From from; + private final Bindable from; private final JpqlQueryBuilder.Entity entity; private final ParameterFactory factory; + private final Metamodel metamodel; - public JpqlStrategy(From from, JpqlQueryBuilder.Entity entity, ParameterFactory factory) { + public JpqlStrategy(Metamodel metamodel, Bindable from, JpqlQueryBuilder.Entity entity, ParameterFactory factory) { this.from = from; this.entity = entity; this.factory = factory; + this.metamodel = metamodel; } @Override public JpqlQueryBuilder.Expression createExpression(String property) { - PropertyPath path = PropertyPath.from(property, from.getJavaType()); - return JpqlQueryBuilder.expression(JpqlUtils.toExpressionRecursively(entity, from, path)); + PropertyPath path = PropertyPath.from(property, from.getBindableJavaType()); + return JpqlQueryBuilder.expression(JpqlUtils.toExpressionRecursively(metamodel, entity, from, path)); } @Override diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java index c03384cb48..e6eedeede5 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java @@ -42,6 +42,7 @@ * * @author Thomas Darimont * @author Mark Paluch + * @author Christoph Strobl */ class ParameterBinding { @@ -213,7 +214,10 @@ public PartTreeParameterBinding(BindingIdentifier identifier, ParameterOrigin or this.templates = templates; this.escape = escape; - this.type = value == null && Type.SIMPLE_PROPERTY.equals(part.getType()) ? Type.IS_NULL : part.getType(); + this.type = value == null + && (Type.SIMPLE_PROPERTY.equals(part.getType()) || Type.NEGATING_SIMPLE_PROPERTY.equals(part.getType())) + ? Type.IS_NULL + : part.getType(); this.ignoreCase = Part.IgnoreCaseType.ALWAYS.equals(part.shouldIgnoreCase()); this.noWildcards = part.getProperty().getLeafProperty().isCollection(); } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java index 2aa982f174..725a97a7d9 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java @@ -62,7 +62,7 @@ public class PartTreeJpaQuery extends AbstractJpaQuery { private final PartTree tree; private final JpaParameters parameters; - private final QueryPreparer query; + private final QueryPreparer queryPreparer; private final QueryPreparer countQuery; private final EntityManager em; private final EscapeCharacter escape; @@ -102,7 +102,7 @@ public class PartTreeJpaQuery extends AbstractJpaQuery { this.tree = new PartTree(method.getName(), domainClass); validate(tree, parameters, method.toString()); this.countQuery = new CountQueryPreparer(); - this.query = tree.isCountProjection() ? countQuery : new QueryPreparer(); + this.queryPreparer = tree.isCountProjection() ? countQuery : new QueryPreparer(); } catch (Exception o_O) { throw new IllegalArgumentException( @@ -112,7 +112,7 @@ public class PartTreeJpaQuery extends AbstractJpaQuery { @Override public Query doCreateQuery(JpaParametersParameterAccessor accessor) { - return query.createQuery(accessor); + return queryPreparer.createQuery(accessor); } @Override @@ -210,12 +210,7 @@ private static boolean expectsCollection(Type type) { */ private class QueryPreparer { - private final Map cache = new LinkedHashMap() { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > 256; - } - }; + private final PartTreeQueryCache cache = new PartTreeQueryCache(); /** * Creates a new {@link Query} for the given parameter values. @@ -279,7 +274,7 @@ private Query restrictMaxResultsIfNecessary(Query query, @Nullable ScrollPositio protected JpqlQueryCreator createCreator(Sort sort, JpaParametersParameterAccessor accessor) { synchronized (cache) { - JpqlQueryCreator jpqlQueryCreator = cache.get(sort); + JpqlQueryCreator jpqlQueryCreator = cache.get(sort, accessor); // this caching thingy is broken due to IS NULL rendering for simple properties if (jpqlQueryCreator != null) { return jpqlQueryCreator; } @@ -304,7 +299,7 @@ protected JpqlQueryCreator createCreator(Sort sort, JpaParametersParameterAccess } synchronized (cache) { - cache.put(sort, creator); + cache.put(sort, accessor, creator); } return creator; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java new file mode 100644 index 0000000000..71f952c2c8 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java @@ -0,0 +1,100 @@ +/* + * Copyright 2024 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.query; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +import org.springframework.data.domain.Sort; +import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; + +/** + * @author Christoph Strobl + */ +class PartTreeQueryCache { + + private final Map cache = new LinkedHashMap() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > 256; + } + }; + + @Nullable + JpqlQueryCreator get(Sort sort, JpaParametersParameterAccessor accessor) { + return cache.get(CacheKey.of(sort, accessor)); + } + + @Nullable + JpqlQueryCreator put(Sort sort, JpaParametersParameterAccessor accessor, JpqlQueryCreator creator) { + return cache.put(CacheKey.of(sort, accessor), creator); + } + + static class CacheKey { + + private final Sort sort; + private final Map params; + + public CacheKey(Sort sort, Map params) { + this.sort = sort; + this.params = params; + } + + static CacheKey of(Sort sort, JpaParametersParameterAccessor accessor) { + + Object[] values = accessor.getValues(); + if (ObjectUtils.isEmpty(values)) { + return new CacheKey(sort, Map.of()); + } + + return new CacheKey(sort, toNullableMap(values)); + } + + static Map toNullableMap(Object[] args) { + + Map paramMap = new HashMap<>(args.length); + for (int i = 0; i < args.length; i++) { + paramMap.put(i, args[i] != null ? Nulled.NO : Nulled.YES); + } + return paramMap; + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CacheKey cacheKey = (CacheKey) o; + return sort.equals(cacheKey.sort) && params.equals(cacheKey.params); + } + + @Override + public int hashCode() { + return Objects.hash(sort, params); + } + } + + enum Nulled { + YES, NO + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java index 11010a7aca..4b05507bc5 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java @@ -914,7 +914,7 @@ private static T getAnnotationProperty(Attribute attribute, String pro * @param attribute the attribute name to check. * @return true if the attribute has already been inner joined */ - private static boolean isAlreadyInnerJoined(From from, String attribute) { + static boolean isAlreadyInnerJoined(From from, String attribute) { for (Fetch fetch : from.getFetches()) { 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 259420a09a..b683b89f47 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 @@ -386,4 +386,13 @@ public void selectProjectionWithSubselect() { assertThat(dtos).flatExtracting(UserRepository.NameOnly::getLastname) // .containsExactly("Matthews", "Beauford", "Matthews"); } + + @Test + void findBySimplePropertyUsingMixedNullNonNullArgument() { + + List result = userRepository.findUserByLastname(null); + assertThat(result).isEmpty(); + result = userRepository.findUserByLastname(carter.getLastname()); + assertThat(result).containsExactly(carter); + } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java new file mode 100644 index 0000000000..dc2866fa8b --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java @@ -0,0 +1,1039 @@ +/* + * Copyright 2024 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.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import jakarta.persistence.ElementCollection; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Tuple; +import jakarta.persistence.metamodel.Metamodel; + +import java.util.Date; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; +import org.springframework.data.jpa.util.TestMetaModel; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.query.ParameterAccessor; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.data.util.Lazy; +import org.springframework.lang.Nullable; + +/** + * @author Christoph Strobl + */ +class JpaQueryCreatorTests { + + private static final TestMetaModel ORDER = TestMetaModel.hibernateModel(Order.class, LineItem.class, Product.class); + private static final TestMetaModel PERSON = TestMetaModel.hibernateModel(Person.class); + + static List ignoreCaseTemplates = List.of(JpqlQueryTemplates.LOWER, JpqlQueryTemplates.UPPER); + + @Test + void simpleProperty() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByCountry") // + .withParameters("AT") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.country = ?1", Order.class.getName()) // + .validateQuery(); + } + + @Test + void simpleNullProperty() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByCountry") // + .withParameterTypes(String.class) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.country IS NULL", Order.class.getName()) // + .validateQuery(); + } + + @Test + void negatingSimpleProperty() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByCountryNot") // + .withParameters("US") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.country != ?1", Order.class.getName()) // + .validateQuery(); + } + + @Test + void negatingSimpleNullProperty() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByCountryIsNot") // + .withParameterTypes(String.class) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.country IS NOT NULL", Order.class.getName()) // + .validateQuery(); + } + + @Test + void simpleAnd() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByCountryAndDate") // + .withParameters("GB", new Date()) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.country = ?1 AND o.date = ?2", Order.class.getName()) // + .validateQuery(); + } + + @Test + void simpleOr() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByCountryOrDate") // + .withParameters("BE", new Date()) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.country = ?1 OR o.date = ?2", Order.class.getName()) // + .validateQuery(); + } + + @Test + void simpleAndOr() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByCountryAndDateOrCompleted") // + .withParameters("IT", new Date(), Boolean.FALSE) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.country = ?1 AND o.date = ?2 OR o.completed = ?3", + Order.class.getName()) // + .validateQuery(); + } + + @Test + void distinct() { + + queryCreator(ORDER) // + .forTree(Order.class, "findDistinctOrderByCountry") // + .withParameters("AU") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT DISTINCT o FROM %s o WHERE o.country = ?1", Order.class.getName()) // + .validateQuery(); + } + + @Test + void count() { + + queryCreator(ORDER) // + .forTree(Order.class, "countOrderByCountry") // + .returing(Long.class) // + .withParameters("AU") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT COUNT(o) FROM %s o WHERE o.country = ?1", Order.class.getName()) // + .validateQuery(); + } + + @Test + void countWithJoins() { + + queryCreator(ORDER) // + .forTree(Order.class, "countOrderByLineItemsQuantityGreaterThan") // + .returing(Long.class) // + .withParameterTypes(Integer.class) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT COUNT(o) FROM %s o LEFT JOIN o.lineItems l WHERE l.quantity > ?1", Order.class.getName()) // + .validateQuery(); + } + + @Test + void countDistinct() { + + queryCreator(ORDER) // + .forTree(Order.class, "countDistinctOrderByCountry") // + .returing(Long.class) // + .withParameters("AU") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT COUNT(DISTINCT o) FROM %s o WHERE o.country = ?1", Order.class.getName()) // + .validateQuery(); + } + + @ParameterizedTest + @FieldSource("ignoreCaseTemplates") + void simplePropertyIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByCountryIgnoreCase") // + .ingnoreCaseAs(ingnoreCaseTemplate) // + .withParameters("BB") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE %s(o.country) = %s(?1)", Order.class.getName(), + ingnoreCaseTemplate.getIgnoreCaseOperator(), ingnoreCaseTemplate.getIgnoreCaseOperator()) // + .validateQuery(); + } + + @ParameterizedTest + @FieldSource("ignoreCaseTemplates") + void simplePropertyAllIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByNameAndProductTypeAllIgnoreCase") // + .ingnoreCaseAs(ingnoreCaseTemplate) // + .withParameters("spring", "data") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE %s(p.name) = %s(?1) AND %s(p.productType) = %s(?2)", + Product.class.getName(), ingnoreCaseTemplate.getIgnoreCaseOperator(), + ingnoreCaseTemplate.getIgnoreCaseOperator(), ingnoreCaseTemplate.getIgnoreCaseOperator(), + ingnoreCaseTemplate.getIgnoreCaseOperator()) // + .validateQuery(); + } + + @ParameterizedTest + @FieldSource("ignoreCaseTemplates") + void simplePropertyMixedCase(JpqlQueryTemplates ingnoreCaseTemplate) { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByNameAndProductTypeIgnoreCase") // + .ingnoreCaseAs(ingnoreCaseTemplate) // + .withParameters("spring", "data") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE p.name = ?1 AND %s(p.productType) = %s(?2)", Product.class.getName(), + ingnoreCaseTemplate.getIgnoreCaseOperator(), ingnoreCaseTemplate.getIgnoreCaseOperator(), + ingnoreCaseTemplate.getIgnoreCaseOperator()) // + .validateQuery(); + } + + @Test + void lessThan() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByDateLessThan") // + .withParameterTypes(Date.class) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.date < ?1", Order.class.getName()) // + .validateQuery(); + } + + @Test + void lessThanEqual() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByDateLessThanEqual") // + .withParameterTypes(Date.class) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.date <= ?1", Order.class.getName()) // + .validateQuery(); + } + + @Test + void greaterThan() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByDateGreaterThan") // + .withParameterTypes(Date.class) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.date > ?1", Order.class.getName()) // + .validateQuery(); + } + + @Test + void before() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByDateBefore") // + .withParameterTypes(Date.class) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.date < ?1", Order.class.getName()) // + .validateQuery(); + } + + @Test + void after() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByDateAfter") // + .withParameterTypes(Date.class) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.date > ?1", Order.class.getName()) // + .validateQuery(); + } + + @Test + void between() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByDateBetween") // + .withParameterTypes(Date.class, Date.class) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.date BETWEEN ?1 AND ?2", Order.class.getName()) // + .validateQuery(); + } + + @Test + void isNull() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByDateIsNull") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.date IS NULL", Order.class.getName()) // + .validateQuery(); + } + + @Test + void isNotNull() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByDateIsNotNull") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.date IS NOT NULL", Order.class.getName()) // + .validateQuery(); + } + + @ParameterizedTest + @ValueSource(strings = { "", "spring", "%spring", "spring%", "%spring%" }) + void like(String parameterValue) { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByNameLike") // + .withParameters(parameterValue) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE p.name LIKE ?1 ESCAPE '\\'", Product.class.getName()) // + .expectPlaceholderValue("?1", parameterValue) // + .validateQuery(); + } + + @Test + void containingString() { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByNameContaining") // + .withParameters("spring") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE p.name LIKE ?1 ESCAPE '\\'", Product.class.getName()) // + .expectPlaceholderValue("?1", "%spring%") // + .validateQuery(); + } + + @Test + void notContainingString() { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByNameNotContaining") // + .withParameters("spring") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE p.name NOT LIKE ?1 ESCAPE '\\'", Product.class.getName()) // + .expectPlaceholderValue("?1", "%spring%") // + .validateQuery(); + } + + @Test + void in() { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByNameIn") // + .withParameters(List.of("spring", "data")) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE p.name IN (?1)", Product.class.getName()) // + .expectPlaceholderValue("?1", List.of("spring", "data")) // + .validateQuery(); + } + + @Test + void notIn() { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByNameNotIn") // + .withParameters(List.of("spring", "data")) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE p.name NOT IN (?1)", Product.class.getName()) // + .expectPlaceholderValue("?1", List.of("spring", "data")) // + .validateQuery(); + } + + @Test + void containingSingleEntryElementCollection() { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByCategoriesContaining") // + .withParameterTypes(String.class) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE ?1 MEMBER OF p.categories", Product.class.getName()) // + .validateQuery(); + } + + @Test + void notContainingSingleEntryElementCollection() { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByCategoriesNotContaining") // + .withParameterTypes(String.class) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE ?1 NOT MEMBER OF p.categories", Product.class.getName()) // + .validateQuery(); + } + + @ParameterizedTest + @FieldSource("ignoreCaseTemplates") + void likeWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByNameLikeIgnoreCase") // + .ingnoreCaseAs(ingnoreCaseTemplate) // + .withParameters("%spring%") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE %s(p.name) LIKE %s(?1) ESCAPE '\\'", Product.class.getName(), + ingnoreCaseTemplate.getIgnoreCaseOperator(), ingnoreCaseTemplate.getIgnoreCaseOperator()) // + .expectPlaceholderValue("?1", "%spring%") // + .validateQuery(); + } + + @ParameterizedTest + @ValueSource(strings = { "", "spring", "%spring", "spring%", "%spring%" }) + void notLike(String parameterValue) { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByNameNotLike") // + .withParameters(parameterValue) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE p.name NOT LIKE ?1 ESCAPE '\\'", Product.class.getName()) // + .expectPlaceholderValue("?1", parameterValue) // + .validateQuery(); + } + + @ParameterizedTest + @FieldSource("ignoreCaseTemplates") + void notLikeWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByNameNotLikeIgnoreCase") // + .ingnoreCaseAs(ingnoreCaseTemplate) // + .withParameters("%spring%") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE %s(p.name) NOT LIKE %s(?1) ESCAPE '\\'", Product.class.getName(), + ingnoreCaseTemplate.getIgnoreCaseOperator(), ingnoreCaseTemplate.getIgnoreCaseOperator()) // + .expectPlaceholderValue("?1", "%spring%") // + .validateQuery(); + } + + @Test + void startingWith() { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByNameStartingWith") // + .withParameters("spring") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE p.name LIKE ?1 ESCAPE '\\'", Product.class.getName()) // + .expectPlaceholderValue("?1", "spring%") // + .validateQuery(); + } + + @ParameterizedTest + @FieldSource("ignoreCaseTemplates") + void startingWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByNameStartingWithIgnoreCase") // + .ingnoreCaseAs(ingnoreCaseTemplate) // + .withParameters("spring") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE %s(p.name) LIKE %s(?1) ESCAPE '\\'", Product.class.getName(), + ingnoreCaseTemplate.getIgnoreCaseOperator(), ingnoreCaseTemplate.getIgnoreCaseOperator()) // + .expectPlaceholderValue("?1", "spring%") // + .validateQuery(); + } + + @Test + void endingWith() { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByNameEndingWith") // + .withParameters("spring") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE p.name LIKE ?1 ESCAPE '\\'", Product.class.getName()) // + .expectPlaceholderValue("?1", "%spring") // + .validateQuery(); + } + + @ParameterizedTest + @FieldSource("ignoreCaseTemplates") + void endingWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { + + queryCreator(ORDER) // + .forTree(Product.class, "findProductByNameEndingWithIgnoreCase") // + .ingnoreCaseAs(ingnoreCaseTemplate) // + .withParameters("spring") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE %s(p.name) LIKE %s(?1) ESCAPE '\\'", Product.class.getName(), + ingnoreCaseTemplate.getIgnoreCaseOperator(), ingnoreCaseTemplate.getIgnoreCaseOperator()) // + .expectPlaceholderValue("?1", "%spring") // + .validateQuery(); + } + + @Test + void greaterThanEqual() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByDateGreaterThanEqual") // + .withParameterTypes(Date.class) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.date >= ?1", Order.class.getName()) // + .validateQuery(); + } + + @Test + void isTrue() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByCompletedIsTrue") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.completed = TRUE", Order.class.getName()) // + .validateQuery(); + } + + @Test + void isFalse() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByCompletedIsFalse") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.completed = FALSE", Order.class.getName()) // + .validateQuery(); + } + + @Test + void empty() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByLineItemsEmpty") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.lineItems IS EMPTY", Order.class.getName()) // + .validateQuery(); + } + + @Test + void notEmpty() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByLineItemsNotEmpty") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.lineItems IS NOT EMPTY", Order.class.getName()) // + .validateQuery(); + } + + @Test + void sortBySingle() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByCountryOrderByDate") // + .withParameters("CA") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o WHERE o.country = ?1 ORDER BY o.date asc", Order.class.getName()) // + .validateQuery(); + } + + @Test + void sortByMulti() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByOrderByCountryAscDateDesc") // + .withParameters() // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o ORDER BY o.country asc, o.date desc", Order.class.getName()) // + .validateQuery(); + } + + @Disabled("should we support this?") + @ParameterizedTest + @FieldSource("ignoreCaseTemplates") + void sortBySingleIngoreCase(JpqlQueryTemplates ingoreCase) { + + String jpql = queryCreator(ORDER) // + .forTree(Order.class, "findOrderByOrderByCountryAscAllIgnoreCase") // + .render(); + + assertThat(jpql).isEqualTo("SELECT o FROM %s o ORDER BY %s(o.date) asc", Order.class.getName(), + ingoreCase.getIgnoreCaseOperator()); + } + + @Test + void matchSimpleJoin() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByLineItemsQuantityGreaterThan") // + .withParameterTypes(Integer.class) // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o LEFT JOIN o.lineItems l WHERE l.quantity > ?1", Order.class.getName()) // + .validateQuery(); + } + + @Test + void matchSimpleNestedJoin() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByLineItemsProductNameIs") // + .withParameters("spring") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT o FROM %s o LEFT JOIN o.lineItems l INNER JOIN l.product p WHERE p.name = ?1", + Order.class.getName()) // + .validateQuery(); + } + + @Test + void matchMultiOnNestedJoin() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByLineItemsQuantityGreaterThanAndLineItemsProductNameIs") // + .withParameters(10, "spring") // + .as(QueryCreatorTester::create) // + .expectJpql( + "SELECT o FROM %s o LEFT JOIN o.lineItems l INNER JOIN l.product p WHERE l.quantity > ?1 AND p.name = ?2", + Order.class.getName()) // + .validateQuery(); + } + + @Test + void matchSameEntityMultipleTimes() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByLineItemsProductNameIsAndLineItemsProductNameIsNot") // + .withParameters("spring", "sukrauq") // + .as(QueryCreatorTester::create) // + .expectJpql( + "SELECT o FROM %s o LEFT JOIN o.lineItems l INNER JOIN l.product p WHERE p.name = ?1 AND p.name != ?2", + Order.class.getName()) // + .validateQuery(); + } + + @Test + void matchSameEntityMultipleTimesViaDifferentProperties() { + + queryCreator(ORDER) // + .forTree(Order.class, "findOrderByLineItemsProductNameIsAndLineItemsProduct2NameIs") // + .withParameters(10, "spring") // + .as(QueryCreatorTester::create) // + .expectJpql( + "SELECT o FROM %s o LEFT JOIN o.lineItems l INNER JOIN l.product p INNER JOIN l.product2 join_0 WHERE p.name = ?1 AND join_0.name = ?2", + Order.class.getName()) // + .validateQuery(); + } + + @Test + void dtoProjection() { + + queryCreator(ORDER) // + .forTree(Product.class, "findProjectionByNameIs") // + .returing(DtoProductProjection.class) // + .withParameters("spring") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT new %s(p.name, p.productType) FROM %s p WHERE p.name = ?1", + DtoProductProjection.class.getName(), Product.class.getName()) // + .validateQuery(); + } + + @Test + void interfaceProjection() { + + queryCreator(ORDER) // + .forTree(Product.class, "findProjectionByNameIs") // + .returing(InterfaceProductProjection.class) // + .withParameters("spring") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p.name name, p.productType productType FROM %s p WHERE p.name = ?1", + Product.class.getName()) // + .validateQuery(); + } + + @ParameterizedTest + @ValueSource(classes = { Tuple.class, Map.class }) + void tupleProjection(Class resultType) { + + queryCreator(PERSON) // + .forTree(Person.class, "findProjectionByFirstnameIs") // + .returing(resultType) // + .withParameters("chris") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p.id id, p.firstname firstname, p.lastname lastname FROM %s p WHERE p.firstname = ?1", + Person.class.getName()) // + .validateQuery(); + } + + @ParameterizedTest + @ValueSource(classes = { Long.class, List.class, Person.class }) + void delete(Class resultType) { + + queryCreator(PERSON) // + .forTree(Person.class, "deletePersonByFirstname") // + .returing(resultType) // + .withParameters("chris") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p FROM %s p WHERE p.firstname = ?1", Person.class.getName()) // + .validateQuery(); + } + + @Test + void exists() { + + queryCreator(PERSON) // + .forTree(Person.class, "existsPersonByFirstname") // + .returing(Long.class).withParameters("chris") // + .as(QueryCreatorTester::create) // + .expectJpql("SELECT p.id id FROM %s p WHERE p.firstname = ?1", Person.class.getName()) // + .validateQuery(); + } + + QueryCreatorBuilder queryCreator(Metamodel metamodel) { + return new DefaultCreatorBuilder(metamodel); + } + + JpaQueryCreator queryCreator(PartTree tree, ReturnedType returnedType, Metamodel metamodel, Object... arguments) { + return queryCreator(tree, returnedType, metamodel, JpqlQueryTemplates.UPPER, arguments); + } + + JpaQueryCreator queryCreator(PartTree tree, ReturnedType returnedType, Metamodel metamodel, + JpqlQueryTemplates templates, Object... arguments) { + + ParameterMetadataProvider parameterMetadataProvider = new ParameterMetadataProvider( + StubJpaParameterParameterAccessor.accessor(arguments), EscapeCharacter.DEFAULT, templates); + return queryCreator(tree, returnedType, metamodel, templates, parameterMetadataProvider); + } + + JpaQueryCreator queryCreator(PartTree tree, ReturnedType returnedType, Metamodel metamodel, + JpqlQueryTemplates templates, Class... argumentTypes) { + + ParameterMetadataProvider parameterMetadataProvider = new ParameterMetadataProvider( + StubJpaParameterParameterAccessor.accessor(argumentTypes), EscapeCharacter.DEFAULT, templates); + return queryCreator(tree, returnedType, metamodel, templates, parameterMetadataProvider); + } + + JpaQueryCreator queryCreator(PartTree tree, ReturnedType returnedType, Metamodel metamodel, + JpqlQueryTemplates templates, JpaParametersParameterAccessor parameterAccessor) { + + EntityManager entityManager = mock(EntityManager.class); + when(entityManager.getMetamodel()).thenReturn(metamodel); + + ParameterMetadataProvider parameterMetadataProvider = new ParameterMetadataProvider(parameterAccessor, + EscapeCharacter.DEFAULT, templates); + return new JpaQueryCreator(tree, returnedType, parameterMetadataProvider, templates, entityManager); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private JpaParametersParameterAccessor accessor(Class... argumentTypes) { + + return StubJpaParameterParameterAccessor.accessor(argumentTypes); + } + + @jakarta.persistence.Entity + static class Order { + + @Id Long id; + Date date; + String country; + Boolean completed; + + @OneToMany List lineItems; + } + + @jakarta.persistence.Entity + static class LineItem { + + @Id Long id; + + @ManyToOne Product product; + @ManyToOne Product product2; + int quantity; + + } + + @jakarta.persistence.Entity + static class Person { + @Id Long id; + String firstname; + String lastname; + } + + @jakarta.persistence.Entity + static class Product { + + @Id Long id; + + String name; + String productType; + + @ElementCollection List categories; + } + + static class DtoProductProjection { + + String name; + String productType; + + DtoProductProjection(String name, String productType) { + this.name = name; + this.productType = productType; + } + } + + interface InterfaceProductProjection { + String getName(); + + String getProductType(); + } + + static class QueryCreatorTester { + + QueryCreatorBuilder builder; + Lazy jpql; + + private QueryCreatorTester(QueryCreatorBuilder builder) { + this.builder = builder; + this.jpql = Lazy.of(builder::render); + } + + static QueryCreatorTester create(QueryCreatorBuilder builder) { + return new QueryCreatorTester(builder); + } + + QueryCreatorTester expectJpql(String jpql, Object... args) { + + assertThat(this.jpql.get()).isEqualTo(jpql, args); + return this; + } + + QueryCreatorTester expectPlaceholderValue(String placeholder, Object value) { + return expectBindingAt(builder.bindingIndexFor(placeholder), value); + } + + QueryCreatorTester expectBindingAt(int position, Object value) { + + Object current = builder.bindableParameters().getBindableValue(position - 1); + assertThat(current).isEqualTo(value); + return this; + } + + QueryCreatorTester validateQuery() { + + if (builder instanceof DefaultCreatorBuilder dcb && dcb.metamodel instanceof TestMetaModel tmm) { + return validateQuery(tmm.entityManager()); + } + + throw new IllegalStateException("No EntityManager found, plase provide one via [verify(EntityManager)]"); + } + + QueryCreatorTester validateQuery(EntityManager entityManager) { + + if (builder instanceof DefaultCreatorBuilder dcb) { + entityManager.createQuery(this.jpql.get(), dcb.returnedType.getReturnedType()); + } else { + entityManager.createQuery(this.jpql.get()); + } + return this; + } + + } + + interface QueryCreatorBuilder { + + QueryCreatorBuilder returing(ReturnedType returnedType); + + QueryCreatorBuilder forTree(Class root, String querySource); + + QueryCreatorBuilder withParameters(Object... arguments); + + QueryCreatorBuilder withParameterTypes(Class... argumentTypes); + + QueryCreatorBuilder ingnoreCaseAs(JpqlQueryTemplates queryTemplate); + + default T as(Function transformer) { + return transformer.apply(this); + } + + default String render() { + return render(null); + } + + ParameterAccessor bindableParameters(); + + int bindingIndexFor(String placeholder); + + String render(@Nullable Sort sort); + + QueryCreatorBuilder returing(Class type); + } + + private class DefaultCreatorBuilder implements QueryCreatorBuilder { + + private static final ProjectionFactory PROJECTION_FACTORY = new SpelAwareProxyProjectionFactory(); + + private final Metamodel metamodel; + private ReturnedType returnedType; + private PartTree partTree; + private Object[] arguments; + private Class[] argumentTypes; + private JpqlQueryTemplates queryTemplates; + private Lazy queryCreator = Lazy.of(this::initJpaQueryCreator); + private Lazy parameterAccessor = Lazy.of(this::initParameterAccessor); + + public DefaultCreatorBuilder(Metamodel metamodel) { + this.metamodel = metamodel; + arguments = new Object[0]; + queryTemplates = JpqlQueryTemplates.UPPER; + } + + @Override + public QueryCreatorBuilder returing(ReturnedType returnedType) { + this.returnedType = returnedType; + return this; + } + + @Override + public QueryCreatorBuilder returing(Class type) { + + if (this.returnedType != null) { + return returing(ReturnedType.of(type, returnedType.getDomainType(), PROJECTION_FACTORY)); + } + + return returing(ReturnedType.of(type, type, PROJECTION_FACTORY)); + } + + @Override + public QueryCreatorBuilder forTree(Class root, String querySource) { + + this.partTree = new PartTree(querySource, root); + if (returnedType == null) { + returnedType = ReturnedType.of(root, root, PROJECTION_FACTORY); + } + return this; + } + + @Override + public QueryCreatorBuilder withParameters(Object... arguments) { + this.arguments = arguments; + return this; + } + + @Override + public QueryCreatorBuilder withParameterTypes(Class... argumentTypes) { + this.argumentTypes = argumentTypes; + return this; + } + + @Override + public QueryCreatorBuilder ingnoreCaseAs(JpqlQueryTemplates queryTemplate) { + this.queryTemplates = queryTemplate; + return this; + } + + @Override + public String render(@Nullable Sort sort) { + return queryCreator.get().createQuery(sort != null ? sort : Sort.unsorted()); + } + + @Override + public int bindingIndexFor(String placeholder) { + + return queryCreator.get().getBindings().stream().filter(binding -> { + + if (binding.getIdentifier().hasPosition() && placeholder.startsWith("?")) { + return binding.getPosition() == Integer.parseInt(placeholder.substring(1)); + } + + if (!binding.getIdentifier().hasName()) { + return false; + } + + return binding.getIdentifier().getName().equals(placeholder); + }).findFirst().map(ParameterBinding::getPosition).orElse(-1); + } + + @Override + public ParameterAccessor bindableParameters() { + + return new ParameterAccessor() { + @Nullable + @Override + public ScrollPosition getScrollPosition() { + return null; + } + + @Override + public Pageable getPageable() { + return null; + } + + @Override + public Sort getSort() { + return null; + } + + @Nullable + @Override + public Class findDynamicProjection() { + return null; + } + + @Nullable + @Override + public Object getBindableValue(int index) { + + ParameterBinding parameterBinding = queryCreator.get().getBindings().get(index); + return parameterBinding.prepare(parameterAccessor.get().getBindableValue(index)); + } + + @Override + public boolean hasBindableNullValue() { + return false; + } + + @Override + public Iterator iterator() { + return null; + } + }; + + } + + JpaParametersParameterAccessor initParameterAccessor() { + + if (arguments.length > 0 || argumentTypes == null) { + return StubJpaParameterParameterAccessor.accessor(arguments); + } + return StubJpaParameterParameterAccessor.accessor(argumentTypes); + } + + JpaQueryCreator initJpaQueryCreator() { + + if (arguments.length > 0 || argumentTypes == null) { + return queryCreator(partTree, returnedType, metamodel, queryTemplates, parameterAccessor.get()); + } + return queryCreator(partTree, returnedType, metamodel, queryTemplates, parameterAccessor.get()); + } + } +} 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 new file mode 100644 index 0000000000..04fb7079de --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java @@ -0,0 +1,265 @@ +/* + * Copyright 2024 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.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; + +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.AbstractJpqlQuery; +import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.Entity; +import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.Expression; +import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.Join; +import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.OrderExpression; +import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.Origin; +import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.ParameterPlaceholder; +import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.PathAndOrigin; +import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.Predicate; +import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.RenderContext; +import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.SelectStep; +import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.WhereStep; + +/** + * @author Christoph Strobl + */ +class JpqlQueryBuilderUnitTests { + + @Test + void placeholdersRenderCorrectly() { + + assertThat(JpqlQueryBuilder.parameter(ParameterPlaceholder.indexed(1)).render(RenderContext.EMPTY)).isEqualTo("?1"); + assertThat(JpqlQueryBuilder.parameter(ParameterPlaceholder.named("arg1")).render(RenderContext.EMPTY)) + .isEqualTo(":arg1"); + assertThat(JpqlQueryBuilder.parameter("?1").render(RenderContext.EMPTY)).isEqualTo("?1"); + } + + @Test + void placeholdersErrorOnInvaludInput() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> JpqlQueryBuilder.parameter((String) null)); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> JpqlQueryBuilder.parameter("")); + } + + @Test + void stringLiteralRendersAsQuotedString() { + + assertThat(JpqlQueryBuilder.stringLiteral("literal").render(RenderContext.EMPTY)).isEqualTo("'literal'"); + + /* JPA Spec - 4.6.1 Literals: + > A string literal that includes a single quote is represented by two single quotes--for example: 'literal''s'. */ + assertThat(JpqlQueryBuilder.stringLiteral("literal's").render(RenderContext.EMPTY)).isEqualTo("'literal''s'"); + } + + @Test + void entity() { + + Entity entity = JpqlQueryBuilder.entity(Order.class); + assertThat(entity.alias()).isEqualTo("o"); + assertThat(entity.entity()).isEqualTo(Order.class.getName()); + assertThat(entity.getName()).isEqualTo(Order.class.getSimpleName()); // TODO: this really confusing + assertThat(entity.simpleName()).isEqualTo(Order.class.getSimpleName()); + } + + @Test + void literalExpressionRendersAsIs() { + Expression expression = JpqlQueryBuilder.expression("CONCAT(person.lastName, ‘, ’, person.firstName))"); + assertThat(expression.render(RenderContext.EMPTY)).isEqualTo("CONCAT(person.lastName, ‘, ’, person.firstName))"); + } + + @Test + void xxx() { + + Entity entity = JpqlQueryBuilder.entity(Order.class); + PathAndOrigin orderDate = JpqlQueryBuilder.path(entity, "date"); + + String fragment = JpqlQueryBuilder.where(orderDate).eq("{d '2024-11-05'}").render(ctx(entity)); + + assertThat(fragment).isEqualTo("o.date = {d '2024-11-05'}"); + + // JpqlQueryBuilder.where(PathAndOrigin) + } + + @Test + void predicateRendering() { + + + Entity entity = JpqlQueryBuilder.entity(Order.class); + WhereStep where = JpqlQueryBuilder.where(JpqlQueryBuilder.path(entity, "country")); + + assertThat(where.between("'AT'", "'DE'").render(ctx(entity))).isEqualTo("o.country BETWEEN 'AT' AND 'DE'"); + assertThat(where.eq("'AT'").render(ctx(entity))).isEqualTo("o.country = 'AT'"); + assertThat(where.eq(JpqlQueryBuilder.stringLiteral("AT")).render(ctx(entity))).isEqualTo("o.country = 'AT'"); + assertThat(where.gt("'AT'").render(ctx(entity))).isEqualTo("o.country > 'AT'"); + assertThat(where.gte("'AT'").render(ctx(entity))).isEqualTo("o.country >= 'AT'"); + // TODO: that is really really bad + // lange namen + assertThat(where.in("'AT', 'DE'").render(ctx(entity))).isEqualTo("o.country IN ('AT', 'DE')"); + + // 1 in age - cleanup what is not used - remove everything eles + // assertThat(where.inMultivalued("'AT', 'DE'").render(ctx(entity))).isEqualTo("o.country IN ('AT', 'DE')"); // + assertThat(where.isEmpty().render(ctx(entity))).isEqualTo("o.country IS EMPTY"); + assertThat(where.isNotEmpty().render(ctx(entity))).isEqualTo("o.country IS NOT EMPTY"); + assertThat(where.isTrue().render(ctx(entity))).isEqualTo("o.country = TRUE"); + assertThat(where.isFalse().render(ctx(entity))).isEqualTo("o.country = FALSE"); + assertThat(where.isNull().render(ctx(entity))).isEqualTo("o.country IS NULL"); + assertThat(where.isNotNull().render(ctx(entity))).isEqualTo("o.country IS NOT NULL"); + assertThat(where.like("'\\_%'", "" + EscapeCharacter.DEFAULT.getEscapeCharacter()).render(ctx(entity))) + .isEqualTo("o.country LIKE '\\_%' ESCAPE '\\'"); + assertThat(where.notLike("'\\_%'", "" + EscapeCharacter.DEFAULT.getEscapeCharacter()).render(ctx(entity))) + .isEqualTo("o.country NOT LIKE '\\_%' ESCAPE '\\'"); + assertThat(where.lt("'AT'").render(ctx(entity))).isEqualTo("o.country < 'AT'"); + assertThat(where.lte("'AT'").render(ctx(entity))).isEqualTo("o.country <= 'AT'"); + assertThat(where.memberOf("'AT'").render(ctx(entity))).isEqualTo("'AT' MEMBER OF o.country"); + // TODO: can we have this where.value(foo).memberOf(pathAndOrigin); + assertThat(where.notMemberOf("'AT'").render(ctx(entity))).isEqualTo("'AT' NOT MEMBER OF o.country"); + assertThat(where.neq("'AT'").render(ctx(entity))).isEqualTo("o.country != 'AT'"); + } + + @Test + void selectRendering() { + + // make sure things are immutable + SelectStep select = JpqlQueryBuilder.selectFrom(Order.class); // the select step is mutable - not sure i like it + // hm, I somehow exepect this to render only the selection part + assertThat(select.count().render()).startsWith("SELECT COUNT(o)"); + assertThat(select.distinct().entity().render()).startsWith("SELECT DISTINCT o "); + assertThat(select.distinct().count().render()).startsWith("SELECT COUNT(DISTINCT o) "); + assertThat(JpqlQueryBuilder.selectFrom(Order.class).select(JpqlQueryBuilder.path(JpqlQueryBuilder.entity(Order.class), "country")).render()) + .startsWith("SELECT o.country "); + } + +// @Test +// void sorting() { +// +// JpqlQueryBuilder.orderBy(new OrderExpression() , Sort.Order.asc("country")); +// +// Entity entity = JpqlQueryBuilder.entity(Order.class); +// +// AbstractJpqlQuery query = JpqlQueryBuilder.selectFrom(Order.class) +// .entity() +// .orderBy() +// .where(context -> "1 = 1"); +// +// } + + @Test + void joins() { + + Entity entity = JpqlQueryBuilder.entity(LineItem.class); + Join li_pr = JpqlQueryBuilder.innerJoin(entity, "product"); + Join li_pr2 = JpqlQueryBuilder.innerJoin(entity, "product2"); + + PathAndOrigin productName = JpqlQueryBuilder.path(li_pr, "name"); + PathAndOrigin personName = JpqlQueryBuilder.path(li_pr2, "name"); + + String fragment = JpqlQueryBuilder.where(productName).eq(JpqlQueryBuilder.stringLiteral("ex30")) + .and(JpqlQueryBuilder.where(personName).eq(JpqlQueryBuilder.stringLiteral("ex40"))).render(ctx(entity)); + + assertThat(fragment).isEqualTo("p.name = 'ex30' AND join_0.name = 'ex40'"); + } + + @Test + void x2() { + + Entity entity = JpqlQueryBuilder.entity(LineItem.class); + Join li_pr = JpqlQueryBuilder.innerJoin(entity, "product"); + Join li_pe = JpqlQueryBuilder.innerJoin(entity, "person"); + + PathAndOrigin productName = JpqlQueryBuilder.path(li_pr, "name"); + PathAndOrigin personName = JpqlQueryBuilder.path(li_pe, "name"); + + String fragment = JpqlQueryBuilder.where(productName).eq(JpqlQueryBuilder.stringLiteral("ex30")) + .and(JpqlQueryBuilder.where(personName).eq(JpqlQueryBuilder.stringLiteral("cstrobl"))).render(ctx(entity)); + + assertThat(fragment).isEqualTo("p.name = 'ex30' AND join_0.name = 'cstrobl'"); + } + + @Test + void x3() { + + Entity entity = JpqlQueryBuilder.entity(LineItem.class); + Join li_pr = JpqlQueryBuilder.innerJoin(entity, "product"); + Join li_pe = JpqlQueryBuilder.innerJoin(entity, "person"); + + PathAndOrigin productName = JpqlQueryBuilder.path(li_pr, "name"); + PathAndOrigin personName = JpqlQueryBuilder.path(li_pe, "name"); + + // JpqlQueryBuilder.and("x = y", "a = b"); -> x = y AND a = b + + // JpqlQueryBuilder.nested(JpqlQueryBuilder.and("x = y", "a = b")) (x = y AND a = b) + + String fragment = JpqlQueryBuilder.where(productName).eq(JpqlQueryBuilder.stringLiteral("ex30")) + .and(JpqlQueryBuilder.where(personName).eq(JpqlQueryBuilder.stringLiteral("cstrobl"))).render(ctx(entity)); + + assertThat(fragment).isEqualTo("p.name = 'ex30' AND join_0.name = 'cstrobl'"); + } + + static RenderContext ctx(Entity... entities) { + Map aliases = new LinkedHashMap<>(entities.length); + for (Entity entity : entities) { + aliases.put(entity, entity.alias()); + } + + return new RenderContext(aliases); + } + + @jakarta.persistence.Entity + static class Order { + + @Id Long id; + Date date; + String country; + + @OneToMany List lineItems; + } + + @jakarta.persistence.Entity + static class LineItem { + + @Id Long id; + + @ManyToOne Product product; + @ManyToOne Product product2; + @ManyToOne Product person; + + } + + @jakarta.persistence.Entity + static class Person { + @Id Long id; + String name; + } + + @jakarta.persistence.Entity + static class Product { + + @Id Long id; + + String name; + String productType; + + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeQueryCacheUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeQueryCacheUnitTests.java new file mode 100644 index 0000000000..aa3911473f --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeQueryCacheUnitTests.java @@ -0,0 +1,116 @@ +/* + * Copyright 2024 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.query; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.FieldSource; +import org.mockito.Mockito; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; + +/** + * @author Christoph Strobl + */ +public class PartTreeQueryCacheUnitTests { + + PartTreeQueryCache cache; + + static Supplier> cacheInput = () -> Stream.of( + Arguments.arguments(Sort.unsorted(), StubJpaParameterParameterAccessor.accessor()), // + Arguments.arguments(Sort.by(Direction.ASC, "one"), StubJpaParameterParameterAccessor.accessor()), // + Arguments.arguments(Sort.by(Direction.DESC, "one"), StubJpaParameterParameterAccessor.accessor()), // + Arguments.arguments(Sort.unsorted(), + StubJpaParameterParameterAccessor.accessorFor(String.class).withValues("value")), // + Arguments.arguments(Sort.unsorted(), + StubJpaParameterParameterAccessor.accessorFor(String.class).withValues(new Object[] { null })), // + Arguments.arguments(Sort.by(Direction.ASC, "one"), + StubJpaParameterParameterAccessor.accessorFor(String.class).withValues("value")), // + Arguments.arguments(Sort.by(Direction.ASC, "one"), + StubJpaParameterParameterAccessor.accessorFor(String.class).withValues(new Object[] { null }))); + + @BeforeEach + void beforeEach() { + cache = new PartTreeQueryCache(); + } + + @ParameterizedTest + @FieldSource("cacheInput") + void getReturnsNullForEmptyCache(Sort sort, JpaParametersParameterAccessor accessor) { + assertThat(cache.get(sort, accessor)).isNull(); + } + + @ParameterizedTest + @FieldSource("cacheInput") + void getReturnsCachedInstance(Sort sort, JpaParametersParameterAccessor accessor) { + + JpaQueryCreator queryCreator = Mockito.mock(JpaQueryCreator.class); + + assertThat(cache.put(sort, accessor, queryCreator)).isNull(); + assertThat(cache.get(sort, accessor)).isSameAs(queryCreator); + } + + @ParameterizedTest + @FieldSource("cacheInput") + void cacheGetWithSort(Sort sort, JpaParametersParameterAccessor accessor) { + + JpaQueryCreator queryCreator = Mockito.mock(JpaQueryCreator.class); + assertThat(cache.put(Sort.by("not-in-cache"), accessor, queryCreator)).isNull(); + + assertThat(cache.get(sort, accessor)).isNull(); + } + + @ParameterizedTest + @FieldSource("cacheInput") + void cacheGetWithccessor(Sort sort, JpaParametersParameterAccessor accessor) { + + JpaQueryCreator queryCreator = Mockito.mock(JpaQueryCreator.class); + assertThat(cache.put(sort, StubJpaParameterParameterAccessor.accessor("spring", "data"), queryCreator)).isNull(); + + assertThat(cache.get(sort, accessor)).isNull(); + } + + @Test + void cachesOnNullableNotArgumentType() { + + JpaQueryCreator queryCreator = Mockito.mock(JpaQueryCreator.class); + Sort sort = Sort.unsorted(); + assertThat(cache.put(sort, StubJpaParameterParameterAccessor.accessor("spring", "data"), queryCreator)).isNull(); + + assertThat(cache.get(sort, + StubJpaParameterParameterAccessor.accessor(new Class[] { String.class, String.class }, "spring", null))) + .isNull(); + + assertThat(cache.get(sort, + StubJpaParameterParameterAccessor.accessor(new Class[] { String.class, String.class }, null, "data"))).isNull(); + + assertThat(cache.get(sort, + StubJpaParameterParameterAccessor.accessor(new Class[] { String.class, String.class }, "data", "spring"))) + .isSameAs(queryCreator); + + assertThat(cache.get(Sort.by("not-in-cache"), + StubJpaParameterParameterAccessor.accessor(new Class[] { String.class, String.class }, "data", "spring"))) + .isNull(); + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StubJpaParameterParameterAccessor.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StubJpaParameterParameterAccessor.java new file mode 100644 index 0000000000..c5794c9644 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StubJpaParameterParameterAccessor.java @@ -0,0 +1,93 @@ +/* + * Copyright 2024 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.query; + +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.mockito.Mockito; +import org.springframework.core.MethodParameter; +import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter; +import org.springframework.data.util.TypeInformation; + +/** + * @author Christoph Strobl + */ +public class StubJpaParameterParameterAccessor extends JpaParametersParameterAccessor { + + private StubJpaParameterParameterAccessor(JpaParameters parameters, Object[] values) { + super(parameters, values); + } + + static JpaParametersParameterAccessor accessor(Object... values) { + + Class[] parameterTypes = Arrays.stream(values).map(it -> it != null ? it.getClass() : Object.class) + .toArray(Class[]::new); + return accessor(parameterTypes, values); + } + + static JpaParametersParameterAccessor accessor(Class... parameterTypes) { + return accessor(parameterTypes, new Object[parameterTypes.length]); + } + + static AccessorBuilder accessorFor(Class... parameterTypes) { + return arguments -> accessor(parameterTypes, arguments); + + } + + interface AccessorBuilder { + JpaParametersParameterAccessor withValues(Object... arguments); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + static JpaParametersParameterAccessor accessor(Class[] parameterTypes, Object... parameters) { + + List parametersList = new ArrayList<>(parameterTypes.length); + List valueList = new ArrayList<>(parameterTypes.length); + + for (int i = 0; i < parameterTypes.length; i++) { + + if (i < parameters.length) { + valueList.add(parameters[i]); + } + + Class parameterType = parameterTypes[i]; + MethodParameter mock = Mockito.mock(MethodParameter.class); + when(mock.getParameterType()).thenReturn((Class) parameterType); + JpaParameter parameter = new JpaParameter(mock, TypeInformation.of(parameterType)); + parametersList.add(parameter); + } + + return new StubJpaParameterParameterAccessor(new JpaParameters(parametersList), valueList.toArray()); + } + + @Override + public String toString() { + List parameters = new ArrayList<>(getParameters().getNumberOfParameters()); + + for (int i = 0; i < getParameters().getNumberOfParameters(); i++) { + Object value = getValue(i); + if (value == null) { + value = "null"; + } + parameters.add("%s: %s (%s)".formatted(i, value, getParameters().getParameter(i).getType().getSimpleName())); + } + return "%s".formatted(parameters); + } +} 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 01526cd0bf..ad551d2465 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 @@ -75,6 +75,8 @@ public interface UserRepository extends JpaRepository, JpaSpecifi @QueryHints({ @QueryHint(name = "foo", value = "bar") }) List findByLastname(String lastname); + List findUserByLastname(String lastname); + /** * Redeclaration of {@link CrudRepository#findById(java.lang.Object)} to change transaction configuration. */ diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/TestMetaModel.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/TestMetaModel.java new file mode 100644 index 0000000000..a755ba222b --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/TestMetaModel.java @@ -0,0 +1,119 @@ +/* + * Copyright 2024 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.util; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.metamodel.EmbeddableType; +import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.ManagedType; +import jakarta.persistence.metamodel.Metamodel; +import jakarta.persistence.spi.ClassTransformer; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.hibernate.jpa.HibernatePersistenceProvider; +import org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl; +import org.hibernate.jpa.boot.internal.PersistenceUnitInfoDescriptor; +import org.springframework.data.util.Lazy; +import org.springframework.instrument.classloading.SimpleThrowawayClassLoader; +import org.springframework.orm.jpa.persistenceunit.MutablePersistenceUnitInfo; + +/** + * @author Christoph Strobl + */ +public class TestMetaModel implements Metamodel { + + private final String persistenceUnit; + private final Set> managedTypes; + private final Lazy entityManagerFactory = Lazy.of(this::init); + private final Lazy metamodel = Lazy.of(() -> entityManagerFactory.get().getMetamodel()); + private Lazy enityManager = Lazy.of(() -> entityManagerFactory.get().createEntityManager()); + + TestMetaModel(Set> managedTypes) { + this("dynamic-tests", managedTypes); + } + + TestMetaModel(String persistenceUnit, Set> managedTypes) { + this.persistenceUnit = persistenceUnit; + this.managedTypes = managedTypes; + } + + public static TestMetaModel hibernateModel(Class... types) { + return new TestMetaModel(Set.of(types)); + } + + public static TestMetaModel hibernateModel(String persistenceUnit, Class... types) { + return new TestMetaModel(persistenceUnit, Set.of(types)); + } + + public EntityType entity(Class cls) { + return metamodel.get().entity(cls); + } + + public ManagedType managedType(Class cls) { + return metamodel.get().managedType(cls); + } + + public EmbeddableType embeddable(Class cls) { + return metamodel.get().embeddable(cls); + } + + public Set> getManagedTypes() { + return metamodel.get().getManagedTypes(); + } + + public Set> getEntities() { + return metamodel.get().getEntities(); + } + + public Set> getEmbeddables() { + return metamodel.get().getEmbeddables(); + } + + public EntityManager entityManager() { + return enityManager.get(); + } + + EntityManagerFactory init() { + + MutablePersistenceUnitInfo persistenceUnitInfo = new MutablePersistenceUnitInfo() { + @Override + public ClassLoader getNewTempClassLoader() { + return new SimpleThrowawayClassLoader(this.getClass().getClassLoader()); + } + + @Override + public void addTransformer(ClassTransformer classTransformer) { + // just ingnore it + } + }; + + persistenceUnitInfo.setPersistenceUnitName(persistenceUnit); + this.managedTypes.stream().map(Class::getName).forEach(persistenceUnitInfo::addManagedClassName); + + persistenceUnitInfo.setPersistenceProviderClassName(HibernatePersistenceProvider.class.getName()); + + return new EntityManagerFactoryBuilderImpl(new PersistenceUnitInfoDescriptor(persistenceUnitInfo) { + @Override + public List getManagedClassNames() { + return persistenceUnitInfo.getManagedClassNames(); + } + }, Map.of("hibernate.dialect", "org.hibernate.dialect.H2Dialect")).build(); + } +} diff --git a/spring-data-jpa/src/test/resources/META-INF/persistence.xml b/spring-data-jpa/src/test/resources/META-INF/persistence.xml index 1c3be472e0..4f904373c3 100644 --- a/spring-data-jpa/src/test/resources/META-INF/persistence.xml +++ b/spring-data-jpa/src/test/resources/META-INF/persistence.xml @@ -102,6 +102,14 @@ + + org.hibernate.jpa.HibernatePersistenceProvider + true + + + + + From 801e007fb10da59c74baeaa0d1b8beec360c8901 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 18 Nov 2024 14:50:07 +0100 Subject: [PATCH 4/5] Polishing. Remove method overloads accepting pure strings. Use switch-expressions. Correctly navigate nested joins. Introduce PathExpression interface, refine naming. --- .../jpa/repository/query/JpaQueryCreator.java | 20 +- .../repository/query/JpqlQueryBuilder.java | 474 +++++++++++------- .../data/jpa/repository/query/JpqlUtils.java | 103 +--- .../query/KeysetScrollSpecification.java | 6 +- .../repository/query/ParameterBinding.java | 19 +- .../repository/query/PartTreeJpaQuery.java | 23 +- .../repository/query/PartTreeQueryCache.java | 31 +- .../data/jpa/repository/query/QueryUtils.java | 7 +- .../query/JpaQueryCreatorTests.java | 124 ++--- .../query/JpqlQueryBuilderUnitTests.java | 152 ++---- ...meterMetadataProviderIntegrationTests.java | 18 +- 11 files changed, 492 insertions(+), 485 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 4aaacbecd6..68dbd4247d 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 @@ -15,10 +15,7 @@ */ package org.springframework.data.jpa.repository.query; -import static org.springframework.data.repository.query.parser.Part.Type.IS_NOT_EMPTY; -import static org.springframework.data.repository.query.parser.Part.Type.NOT_CONTAINING; -import static org.springframework.data.repository.query.parser.Part.Type.NOT_LIKE; -import static org.springframework.data.repository.query.parser.Part.Type.SIMPLE_PROPERTY; +import static org.springframework.data.repository.query.parser.Part.Type.*; import jakarta.persistence.EntityManager; import jakarta.persistence.criteria.CriteriaQuery; @@ -39,7 +36,6 @@ import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.JpaSort; import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.ParameterPlaceholder; -import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.PathAndOrigin; import org.springframework.data.jpa.repository.query.ParameterBinding.PartTreeParameterBinding; import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; import org.springframework.data.mapping.PropertyPath; @@ -182,8 +178,8 @@ protected JpqlQueryBuilder.Select buildQuery(Sort sort) { QueryUtils.checkSortExpression(order); try { - expression = JpqlQueryBuilder.expression(JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, - PropertyPath.from(order.getProperty(), entityType.getJavaType()))); + expression = JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, + PropertyPath.from(order.getProperty(), entityType.getJavaType())); } catch (PropertyReferenceException e) { if (order instanceof JpaSort.JpaOrder jpaOrder && jpaOrder.isUnsafe()) { @@ -226,7 +222,7 @@ private JpqlQueryBuilder.Select doSelect(Sort sort) { requiredSelection = getRequiredSelection(sort, returnedType); } - List paths = new ArrayList<>(requiredSelection.size()); + List paths = new ArrayList<>(requiredSelection.size()); for (String selection : requiredSelection) { paths.add(JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, PropertyPath.from(selection, returnedType.getDomainType()), true)); @@ -250,7 +246,7 @@ private JpqlQueryBuilder.Select doSelect(Sort sort) { } else { - List paths = entityType.getIdClassAttributes().stream()// + List paths = entityType.getIdClassAttributes().stream()// .map(it -> JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, PropertyPath.from(it.getName(), returnedType.getDomainType()), true)) .toList(); @@ -319,7 +315,7 @@ public JpqlQueryBuilder.Predicate build() { PropertyPath property = part.getProperty(); Type type = part.getType(); - PathAndOrigin pas = JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, property); + JpqlQueryBuilder.PathExpression pas = JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, property); JpqlQueryBuilder.WhereStep where = JpqlQueryBuilder.where(pas); JpqlQueryBuilder.WhereStep whereIgnoreCase = JpqlQueryBuilder.where(potentiallyIgnoreCase(pas)); @@ -419,8 +415,8 @@ private JpqlQueryBuilder.Expression potentiallyIgnoreCase(JpqlQueryBuilder.O * @param path must not be {@literal null}. * @return */ - private JpqlQueryBuilder.Expression potentiallyIgnoreCase(PathAndOrigin path) { - return potentiallyIgnoreCase(path.path(), JpqlQueryBuilder.expression(path)); + private JpqlQueryBuilder.Expression potentiallyIgnoreCase(JpqlQueryBuilder.PathExpression path) { + return potentiallyIgnoreCase(path.getPropertyPath(), path); } /** diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java index cb53998c3f..db6697a9d5 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java @@ -15,8 +15,7 @@ */ package org.springframework.data.jpa.repository.query; -import static org.springframework.data.jpa.repository.query.QueryTokens.TOKEN_ASC; -import static org.springframework.data.jpa.repository.query.QueryTokens.TOKEN_DESC; +import static org.springframework.data.jpa.repository.query.QueryTokens.*; import java.util.ArrayList; import java.util.Arrays; @@ -26,6 +25,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.function.Supplier; import org.springframework.data.domain.Sort; @@ -121,12 +121,12 @@ public Select count() { } @Override - public Select instantiate(String resultType, Collection paths) { + public Select instantiate(String resultType, Collection paths) { return new Select(postProcess(new ConstructorExpression(resultType, new Multiselect(from, paths))), from); } @Override - public Select select(Collection paths) { + public Select select(Collection paths) { return new Select(postProcess(new Multiselect(from, paths)), from); } @@ -177,22 +177,11 @@ public static Predicate nested(Predicate predicate) { * @return */ public static Expression expression(Origin source, PropertyPath path) { - return expression(new PathAndOrigin(path, source, false)); + return new PathAndOrigin(path, source, false); } /** - * Create a qualified expression for a {@link PropertyPath}. - * - * @param source - * @param path - * @return - */ - public static Expression expression(PathAndOrigin pas) { - return new PathExpression(pas); - } - - /** - * Create a simple expression from a string as is. + * Create a simple expression from a string as-is. * * @param expression * @return @@ -204,10 +193,32 @@ public static Expression expression(String expression) { return new LiteralExpression(expression); } - public static Expression stringLiteral(String literal) { + /** + * Create a simple numeric literal. + * + * @param literal + * @return + */ + public static Expression literal(Number literal) { + return new LiteralExpression(literal.toString()); + } + + /** + * Create a simple literal from a string by quoting it. + * + * @param literal + * @return + */ + public static Expression literal(String literal) { return new StringLiteralExpression(literal); } + /** + * A parameter placeholder. + * + * @param parameter + * @return + */ public static Expression parameter(String parameter) { Assert.hasText(parameter, "Parameter must not be empty or null"); @@ -215,10 +226,23 @@ public static Expression parameter(String parameter) { return new ParameterExpression(new ParameterPlaceholder(parameter)); } + /** + * A parameter placeholder. + * + * @param placeholder the placeholder to use. + * @return + */ public static Expression parameter(ParameterPlaceholder placeholder) { return new ParameterExpression(placeholder); } + /** + * Create a new ordering expression. + * + * @param sortExpression + * @param order + * @return + */ public static Expression orderBy(Expression sortExpression, Sort.Order order) { return new OrderExpression(sortExpression, order); } @@ -234,16 +258,6 @@ public static WhereStep where(Origin source, PropertyPath path) { return where(expression(source, path)); } - /** - * Start building a {@link Predicate WHERE predicate} by providing the right-hand side. - * - * @param rhs - * @return - */ - public static WhereStep where(PathAndOrigin rhs) { - return where(expression(rhs)); - } - /** * Start building a {@link Predicate WHERE predicate} by providing the right-hand side. * @@ -318,16 +332,6 @@ public Predicate notIn(Expression value) { return new InPredicate(rhs, "NOT IN", value); } - @Override - public Predicate inMultivalued(Expression value) { - return new MemberOfPredicate(rhs, "IN", value); // TODO: that does not line up in my head - ahahah - } - - @Override - public Predicate notInMultivalued(Expression value) { - return new MemberOfPredicate(rhs, "NOT IN", value); - } - @Override public Predicate memberOf(Expression value) { return new MemberOfPredicate(rhs, "MEMBER OF", value); @@ -422,7 +426,7 @@ public interface SelectStep { * @param paths * @return */ - default Select instantiate(Class resultType, Collection paths) { + default Select instantiate(Class resultType, Collection paths) { return instantiate(resultType.getName(), paths); } @@ -433,7 +437,7 @@ default Select instantiate(Class resultType, Collection paths) * @param paths * @return */ - Select instantiate(String resultType, Collection paths); + Select instantiate(String resultType, Collection paths); /** * Specify a multi-select. @@ -441,7 +445,7 @@ default Select instantiate(Class resultType, Collection paths) * @param paths * @return */ - Select select(Collection paths); + Select select(Collection paths); /** * Select a single attribute. @@ -449,7 +453,7 @@ default Select instantiate(Class resultType, Collection paths) * @param name * @return */ - default Select select(PathAndOrigin path) { + default Select select(JpqlQueryBuilder.PathExpression path) { return select(List.of(path)); } @@ -479,22 +483,22 @@ public String toString() { static PathAndOrigin path(Origin origin, String path) { - if(origin instanceof Entity entity) { + if (origin instanceof Entity entity) { - try { + try { PropertyPath from = PropertyPath.from(path, ClassUtils.forName(entity.entity, Entity.class.getClassLoader())); return new PathAndOrigin(from, entity, false); } catch (ClassNotFoundException e) { - throw new RuntimeException(e); - } - } - if(origin instanceof Join join) { + throw new RuntimeException(e); + } + } + if (origin instanceof Join join) { Origin parent = join.source; List segments = new ArrayList<>(); segments.add(join.path); - while(!(parent instanceof Entity)) { - if(parent instanceof Join pj) { + while (!(parent instanceof Entity)) { + if (parent instanceof Join pj) { parent = pj.source; segments.add(pj.path); } else { @@ -502,7 +506,7 @@ static PathAndOrigin path(Origin origin, String path) { } } - if(parent instanceof Entity entity) { + if (parent instanceof Entity) { Collections.reverse(segments); segments.add(path); PathAndOrigin path1 = path(parent, StringUtils.collectionToDelimitedString(segments, ".")); @@ -561,7 +565,6 @@ record ConstructorExpression(String resultType, Multiselect multiselect) impleme @Override public String render(RenderContext context) { - return "new %s(%s)".formatted(resultType, multiselect.render(new ConstructorContext(context))); } @@ -577,22 +580,22 @@ public String toString() { * @param source * @param paths */ - record Multiselect(Origin source, Collection paths) implements Selection { + record Multiselect(Origin source, Collection paths) implements Selection { @Override public String render(RenderContext context) { StringBuilder builder = new StringBuilder(); - for (PathAndOrigin path : paths) { + for (PathExpression path : paths) { if (!builder.isEmpty()) { builder.append(", "); } - builder.append(PathExpression.render(path, context)); - if(!context.isConstructorContext()) { - builder.append(" ").append(path.path().getSegment()); + builder.append(path.render(context)); + if (!context.isConstructorContext()) { + builder.append(" ").append(path.getPropertyPath().getSegment()); } } @@ -662,6 +665,18 @@ public interface Expression { String render(RenderContext context); } + /** + * Extension to {@link Expression} that contains a {@link PropertyPath}. Typically used to represent a selection + * expression or an expression used within sorting or {@code WHERE} clauses. + */ + public interface PathExpression extends Expression { + + /** + * @return the associated {@link PropertyPath}. + */ + PropertyPath getPropertyPath(); + } + /** * {@code SELECT} statement. */ @@ -718,7 +733,7 @@ String render() { StringBuilder where = new StringBuilder(); StringBuilder orderby = new StringBuilder(); StringBuilder result = new StringBuilder( - "SELECT %s FROM %s %s".formatted(selection.render(renderContext), entity.entity(), entity.alias())); + "SELECT %s FROM %s %s".formatted(selection.render(renderContext), entity.getEntity(), entity.getAlias())); if (getWhere() != null) { where.append(" WHERE ").append(getWhere().render(renderContext)); @@ -874,32 +889,100 @@ public boolean isConstructorContext() { */ public interface Origin { - String getName(); // TODO: mainly used along records - shoule we call this just name()? + /** + * Returns the simple name of the origin (e.g. {@link Class#getSimpleName()} or JOIN path name). + * + * @return the simple name of the origin (e.g. {@link Class#getSimpleName()}) + */ + String getName(); + } + + /** + * An origin that is used to select data from. selection origins are used with paths to define where a path is + * anchored. + */ + public interface Bindable { + + boolean isRoot(); } /** * The root entity. - * - * @param entity - * @param simpleName - * @param alias */ - public record Entity(String entity, String simpleName, String alias) implements Origin { + public static final class Entity implements Origin { + + private final String entity; + private final String simpleName; + private final String alias; + + /** + * @param entity fully-qualified entity name. + * @param simpleName simple class name. + * @param alias alias to use. + */ + Entity(String entity, String simpleName, String alias) { + this.entity = entity; + this.simpleName = simpleName; + this.alias = alias; + } + + public String getEntity() { + return entity; + } @Override public String getName() { return simpleName; } + + public String getAlias() { + return alias; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null || obj.getClass() != this.getClass()) { + return false; + } + var that = (Entity) obj; + return Objects.equals(this.entity, that.entity) && Objects.equals(this.simpleName, that.simpleName) + && Objects.equals(this.alias, that.alias); + } + + @Override + public int hashCode() { + return Objects.hash(entity, simpleName, alias); + } + + @Override + public String toString() { + return "Entity[" + "entity=" + entity + ", " + "simpleName=" + simpleName + ", " + "alias=" + alias + ']'; + } + } /** * A joined entity or element collection. - * - * @param source - * @param joinType - * @param path */ - public record Join(Origin source, String joinType, String path) implements Origin, Expression { + public static final class Join implements Origin, Expression { + + private final Origin source; + private final String joinType; + private final String path; + + /** + * @param source + * @param joinType + * @param path + */ + Join(Origin source, String joinType, String path) { + this.source = source; + this.joinType = joinType; + this.path = path; + } @Override public String getName() { @@ -908,8 +991,44 @@ public String getName() { @Override public String render(RenderContext context) { - return ""; + return "%s %s %s".formatted(joinType, context.getAlias(source), path); } + + public Origin source() { + return source; + } + + public String joinType() { + return joinType; + } + + public String path() { + return path; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null || obj.getClass() != this.getClass()) { + return false; + } + var that = (Join) obj; + return Objects.equals(this.source, that.source) && Objects.equals(this.joinType, that.joinType) + && Objects.equals(this.path, that.path); + } + + @Override + public int hashCode() { + return Objects.hash(source, joinType, path); + } + + @Override + public String toString() { + return "Join[" + "source=" + source + ", " + "joinType=" + joinType + ", " + "path=" + path + ']'; + } + } /** @@ -917,17 +1036,6 @@ public String render(RenderContext context) { */ public interface WhereStep { - /** - * Create a {@code BETWEEN … AND …} predicate. - * - * @param lower lower boundary. - * @param upper upper boundary. - * @return - */ - default Predicate between(String lower, String upper) { - return between(expression(lower), expression(upper)); - } - /** * Create a {@code BETWEEN … AND …} predicate. * @@ -943,168 +1051,143 @@ default Predicate between(String lower, String upper) { * @param value the comparison value. * @return */ - default Predicate gt(String value) { - return gt(expression(value)); - } + Predicate gt(Expression value); /** - * Create a greater {@code > …} predicate. + * Create a greater-or-equals {@code >= …} predicate. * * @param value the comparison value. * @return */ - Predicate gt(Expression value); + Predicate gte(Expression value); /** - * Create a greater-or-equals {@code >= …} predicate. + * Create a less {@code < …} predicate. * * @param value the comparison value. * @return */ - default Predicate gte(String value) { - return gte(expression(value)); - } + Predicate lt(Expression value); /** - * Create a greater-or-equals {@code >= …} predicate. + * Create a less-or-equals {@code <= …} predicate. * * @param value the comparison value. * @return */ - Predicate gte(Expression value); + Predicate lte(Expression value); /** - * Create a less {@code < …} predicate. + * Create a {@code IS NULL} predicate. * - * @param value the comparison value. * @return */ - default Predicate lt(String value) { - return lt(expression(value)); - } + Predicate isNull(); /** - * Create a less {@code < …} predicate. + * Create a {@code IS NOT NULL} predicate. * - * @param value the comparison value. * @return */ - Predicate lt(Expression value); + Predicate isNotNull(); /** - * Create a less-or-equals {@code <= …} predicate. + * Create a {@code IS TRUE} predicate. * - * @param value the comparison value. * @return */ - default Predicate lte(String value) { - return lte(expression(value)); - } + Predicate isTrue(); /** - * Create a less-or-equals {@code <= …} predicate. + * Create a {@code IS FALSE} predicate. * - * @param value the comparison value. * @return */ - Predicate lte(Expression value); - - Predicate isNull(); - - Predicate isNotNull(); - - Predicate isTrue(); - Predicate isFalse(); + /** + * Create a {@code IS EMPTY} predicate. + * + * @return + */ Predicate isEmpty(); + /** + * Create a {@code IS NOT EMPTY} predicate. + * + * @return + */ Predicate isNotEmpty(); - default Predicate in(String value) { - return in(expression(value)); - } - + /** + * Create a {@code IN} predicate. + * + * @param value + * @return + */ Predicate in(Expression value); - default Predicate notIn(String value) { - return notIn(expression(value)); - } - + /** + * Create a {@code NOT IN} predicate. + * + * @param value + * @return + */ Predicate notIn(Expression value); - default Predicate inMultivalued(String value) { - return inMultivalued(expression(value)); - } - - Predicate inMultivalued(Expression value); - - default Predicate notInMultivalued(String value) { - return notInMultivalued(expression(value)); - } - - Predicate notInMultivalued(Expression value); - - default Predicate memberOf(String value) { - return memberOf(expression(value)); - } - + /** + * Create a {@code MEMBER OF <collection>} predicate. + * + * @param value + * @return + */ Predicate memberOf(Expression value); - default Predicate notMemberOf(String value) { - return notMemberOf(expression(value)); - } - + /** + * Create a {@code NOT MEMBER OF <collection>} predicate. + * + * @param value + * @return + */ Predicate notMemberOf(Expression value); default Predicate like(String value, String escape) { return like(expression(value), escape); } + /** + * Create a {@code LIKE … ESCAPE} predicate. + * + * @param value + * @return + */ Predicate like(Expression value, String escape); - default Predicate notLike(String value, String escape) { - return notLike(expression(value), escape); - } - + /** + * Create a {@code NOT LIKE … ESCAPE} predicate. + * + * @param value + * @return + */ Predicate notLike(Expression value, String escape); - default Predicate eq(String value) { - return eq(expression(value)); - } - + /** + * Create a {@code =} (equals) predicate. + * + * @param value + * @return + */ Predicate eq(Expression value); - default Predicate neq(String value) { - return neq(expression(value)); - } - + /** + * Create a {@code <>} (not equals) predicate. + * + * @param value + * @return + */ Predicate neq(Expression value); } - record PathExpression(PathAndOrigin pas) implements Expression { - - @Override - public String render(RenderContext context) { - return render(pas, context); - - } - - public static String render(PathAndOrigin pas, RenderContext context) { - - if (pas.path().hasNext() || !pas.onTheJoin()) { - return context.prefixWithAlias(pas.origin(), pas.path().toDotPath()); - } else { - return context.getAlias(pas.origin()); - } - } - - @Override - public String toString() { - return render(RenderContext.EMPTY); - } - } - record LiteralExpression(String expression) implements Expression { @Override @@ -1243,7 +1326,7 @@ record InPredicate(Expression path, String operator, Expression predicate) imple @Override public String render(RenderContext context) { - //TODO: should we rather wrap it with nested or check if its a nested predicate before we call render + // TODO: should we rather wrap it with nested or check if its a nested predicate before we call render return "%s %s (%s)".formatted(path.render(context), operator, predicate.render(context)); } @@ -1299,20 +1382,51 @@ public String toString() { * @param origin * @param onTheJoin whether the path should target the join itself instead of matching {@link PropertyPath}. */ - public record PathAndOrigin(PropertyPath path, Origin origin, boolean onTheJoin) { + record PathAndOrigin(PropertyPath path, Origin origin, boolean onTheJoin) implements PathExpression { + + @Override + public PropertyPath getPropertyPath() { + return path; + } + + @Override + public String render(RenderContext context) { + if (path().hasNext() || !onTheJoin()) { + return context.prefixWithAlias(origin(), path().toDotPath()); + } else { + return context.getAlias(origin()); + } + } } + /** + * Value object capturing parameter placeholder. + * + * @param placeholder + */ public record ParameterPlaceholder(String placeholder) { public ParameterPlaceholder { Assert.hasText(placeholder, "Placeholder must not be null nor empty"); } + /** + * Factory method to create a parameter placeholder using a parameter {@code index}. + * + * @param index the parameter index. + * @return an indexed parameter placeholder. + */ public static ParameterPlaceholder indexed(int index) { return new ParameterPlaceholder("?%s".formatted(index)); } + /** + * Factory method to create a parameter placeholder using a parameter {@code name}. + * + * @param name the parameter name. + * @return a named parameter placeholder. + */ public static ParameterPlaceholder named(String name) { Assert.hasText(name, "Placeholder name must not be empty"); 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 d3b32380cd..500a7d4e84 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 @@ -15,34 +15,16 @@ */ package org.springframework.data.jpa.repository.query; -import static jakarta.persistence.metamodel.Attribute.PersistentAttributeType.ELEMENT_COLLECTION; -import static jakarta.persistence.metamodel.Attribute.PersistentAttributeType.MANY_TO_MANY; -import static jakarta.persistence.metamodel.Attribute.PersistentAttributeType.MANY_TO_ONE; -import static jakarta.persistence.metamodel.Attribute.PersistentAttributeType.ONE_TO_MANY; -import static jakarta.persistence.metamodel.Attribute.PersistentAttributeType.ONE_TO_ONE; - -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToOne; import jakarta.persistence.criteria.From; -import jakarta.persistence.criteria.Join; -import jakarta.persistence.criteria.JoinType; import jakarta.persistence.metamodel.Attribute; import jakarta.persistence.metamodel.Attribute.PersistentAttributeType; import jakarta.persistence.metamodel.Bindable; import jakarta.persistence.metamodel.ManagedType; import jakarta.persistence.metamodel.Metamodel; import jakarta.persistence.metamodel.PluralAttribute; -import jakarta.persistence.metamodel.SingularAttribute; - -import java.lang.annotation.Annotation; -import java.lang.reflect.AnnotatedElement; -import java.lang.reflect.Member; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; + import java.util.Objects; -import org.springframework.core.annotation.AnnotationUtils; import org.springframework.data.mapping.PropertyPath; import org.springframework.lang.Nullable; import org.springframework.util.StringUtils; @@ -52,25 +34,12 @@ */ class JpqlUtils { - private static final Map> ASSOCIATION_TYPES; - - static { - Map> persistentAttributeTypes = new HashMap<>(); - persistentAttributeTypes.put(ONE_TO_ONE, OneToOne.class); - persistentAttributeTypes.put(ONE_TO_MANY, null); - persistentAttributeTypes.put(MANY_TO_ONE, ManyToOne.class); - persistentAttributeTypes.put(MANY_TO_MANY, null); - persistentAttributeTypes.put(ELEMENT_COLLECTION, null); - - ASSOCIATION_TYPES = Collections.unmodifiableMap(persistentAttributeTypes); - } - - static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(Metamodel metamodel, JpqlQueryBuilder.Origin source, + static JpqlQueryBuilder.PathExpression toExpressionRecursively(Metamodel metamodel, JpqlQueryBuilder.Origin source, Bindable from, PropertyPath property) { return toExpressionRecursively(metamodel, source, from, property, false); } - static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(Metamodel metamodel, JpqlQueryBuilder.Origin source, + static JpqlQueryBuilder.PathExpression toExpressionRecursively(Metamodel metamodel, JpqlQueryBuilder.Origin source, Bindable from, PropertyPath property, boolean isForSelection) { return toExpressionRecursively(metamodel, source, from, property, isForSelection, false); } @@ -84,16 +53,13 @@ static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(Metamodel metamode * @param hasRequiredOuterJoin has a parent already required an outer join? * @return the expression */ - @SuppressWarnings("unchecked") - static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(Metamodel metamodel, JpqlQueryBuilder.Origin source, + static JpqlQueryBuilder.PathExpression toExpressionRecursively(Metamodel metamodel, JpqlQueryBuilder.Origin source, Bindable from, PropertyPath property, boolean isForSelection, boolean hasRequiredOuterJoin) { String segment = property.getSegment(); boolean isLeafProperty = !property.hasNext(); - - boolean requiresOuterJoin = requiresOuterJoin(metamodel, source, from, property, isForSelection, - hasRequiredOuterJoin); + boolean requiresOuterJoin = requiresOuterJoin(metamodel, from, property, isForSelection, hasRequiredOuterJoin); // if it does not require an outer join and is a leaf, simply get the segment if (!requiresOuterJoin && isLeafProperty) { @@ -103,10 +69,7 @@ static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(Metamodel metamode // get or create the join JpqlQueryBuilder.Join joinSource = requiresOuterJoin ? JpqlQueryBuilder.leftJoin(source, segment) : JpqlQueryBuilder.innerJoin(source, segment); -// JoinType joinType = requiresOuterJoin ? JoinType.LEFT : JoinType.INNER; -// Join join = QueryUtils.getOrCreateJoin(from, segment, joinType); -// // if it's a leaf, return the join if (isLeafProperty) { return new JpqlQueryBuilder.PathAndOrigin(property, joinSource, true); @@ -114,11 +77,11 @@ static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(Metamodel metamode PropertyPath nextProperty = Objects.requireNonNull(property.next(), "An element of the property path is null"); -// ManagedType managedType = ; - Bindable managedTypeForModel = (Bindable) getManagedTypeForModel(from); -// Attribute joinAttribute = getModelForPath(metamodel, property, getManagedTypeForModel(from), null); - // recurse with the next property - return toExpressionRecursively(metamodel, joinSource, managedTypeForModel, nextProperty, isForSelection, requiresOuterJoin); + ManagedType managedTypeForModel = QueryUtils.getManagedTypeForModel(from); + Attribute nextAttribute = getModelForPath(metamodel, property, managedTypeForModel, from); + + return toExpressionRecursively(metamodel, joinSource, (Bindable) nextAttribute, nextProperty, isForSelection, + requiresOuterJoin); } /** @@ -127,17 +90,16 @@ static JpqlQueryBuilder.PathAndOrigin toExpressionRecursively(Metamodel metamode * ensures outer joins are used even when Hibernate defaults to inner joins (HHH-12712 and HHH-12999) * * @param metamodel - * @param source * @param bindable * @param propertyPath * @param isForSelection * @param hasRequiredOuterJoin * @return */ - static boolean requiresOuterJoin(Metamodel metamodel, JpqlQueryBuilder.Origin source, Bindable bindable, - PropertyPath propertyPath, boolean isForSelection, boolean hasRequiredOuterJoin) { + static boolean requiresOuterJoin(Metamodel metamodel, Bindable bindable, PropertyPath propertyPath, + boolean isForSelection, boolean hasRequiredOuterJoin) { - ManagedType managedType = getManagedTypeForModel(bindable); + ManagedType managedType = QueryUtils.getManagedTypeForModel(bindable); Attribute attribute = getModelForPath(metamodel, propertyPath, managedType, bindable); boolean isPluralAttribute = bindable instanceof PluralAttribute; @@ -145,7 +107,7 @@ static boolean requiresOuterJoin(Metamodel metamodel, JpqlQueryBuilder.Origin so return isPluralAttribute; } - if (!ASSOCIATION_TYPES.containsKey(attribute.getPersistentAttributeType())) { + if (!QueryUtils.ASSOCIATION_TYPES.containsKey(attribute.getPersistentAttributeType())) { return false; } @@ -155,47 +117,14 @@ static boolean requiresOuterJoin(Metamodel metamodel, JpqlQueryBuilder.Origin so // explicit outer join to avoid https://hibernate.atlassian.net/browse/HHH-12712 // and https://github.com/eclipse-ee4j/jpa-api/issues/170 boolean isInverseOptionalOneToOne = PersistentAttributeType.ONE_TO_ONE == attribute.getPersistentAttributeType() - && StringUtils.hasText(getAnnotationProperty(attribute, "mappedBy", "")); + && StringUtils.hasText(QueryUtils.getAnnotationProperty(attribute, "mappedBy", "")); boolean isLeafProperty = !propertyPath.hasNext(); if (isLeafProperty && !isForSelection && !isCollection && !isInverseOptionalOneToOne && !hasRequiredOuterJoin) { return false; } - return hasRequiredOuterJoin || getAnnotationProperty(attribute, "optional", true); - } - - @Nullable - private static T getAnnotationProperty(Attribute attribute, String propertyName, T defaultValue) { - - Class associationAnnotation = ASSOCIATION_TYPES.get(attribute.getPersistentAttributeType()); - - if (associationAnnotation == null) { - return defaultValue; - } - - Member member = attribute.getJavaMember(); - - if (!(member instanceof AnnotatedElement annotatedMember)) { - return defaultValue; - } - - Annotation annotation = AnnotationUtils.getAnnotation(annotatedMember, associationAnnotation); - return annotation == null ? defaultValue : (T) AnnotationUtils.getValue(annotation, propertyName); - } - - @Nullable - private static ManagedType getManagedTypeForModel(Bindable model) { - - if (model instanceof ManagedType managedType) { - return managedType; - } - - if (!(model instanceof SingularAttribute singularAttribute)) { - return null; - } - - return singularAttribute.getType() instanceof ManagedType managedType ? managedType : null; + return hasRequiredOuterJoin || QueryUtils.getAnnotationProperty(attribute, "optional", true); } @Nullable diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java index df4106f35e..e36976ad63 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java @@ -21,11 +21,11 @@ import jakarta.persistence.criteria.From; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; +import jakarta.persistence.metamodel.Bindable; +import jakarta.persistence.metamodel.Metamodel; import java.util.List; -import jakarta.persistence.metamodel.Bindable; -import jakarta.persistence.metamodel.Metamodel; import org.springframework.data.domain.KeysetScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; @@ -147,7 +147,7 @@ public JpqlStrategy(Metamodel metamodel, Bindable from, JpqlQueryBuilder.Enti public JpqlQueryBuilder.Expression createExpression(String property) { PropertyPath path = PropertyPath.from(property, from.getBindableJavaType()); - return JpqlQueryBuilder.expression(JpqlUtils.toExpressionRecursively(metamodel, entity, from, path)); + return JpqlUtils.toExpressionRecursively(metamodel, entity, from, path); } @Override diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java index e6eedeede5..259343e9c7 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java @@ -238,17 +238,12 @@ public Object prepare(@Nullable Object value) { if (String.class.equals(parameterType) && !noWildcards) { - switch (type) { - case STARTING_WITH: - return String.format("%s%%", escape.escape(value.toString())); - case ENDING_WITH: - return String.format("%%%s", escape.escape(value.toString())); - case CONTAINING: - case NOT_CONTAINING: - return String.format("%%%s%%", escape.escape(value.toString())); - default: - return value; - } + return switch (type) { + case STARTING_WITH -> String.format("%s%%", escape.escape(value.toString())); + case ENDING_WITH -> String.format("%%%s", escape.escape(value.toString())); + case CONTAINING, NOT_CONTAINING -> String.format("%%%s%%", escape.escape(value.toString())); + default -> value; + }; } return Collection.class.isAssignableFrom(parameterType) // @@ -696,7 +691,7 @@ static MethodInvocationArgument ofParameter(BindingIdentifier identifier) { boolean isExpression(); /** - * @return {@code true} if the origin is an expression. + * @return {@code true} if the origin is synthetic (contributed by e.g. KeysetPagination) */ boolean isSynthetic(); } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java index 725a97a7d9..ba36c7f3e9 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java @@ -22,9 +22,10 @@ import jakarta.persistence.TypedQuery; import jakarta.persistence.criteria.CriteriaQuery; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.data.domain.KeysetScrollPosition; import org.springframework.data.domain.OffsetScrollPosition; @@ -57,6 +58,7 @@ */ public class PartTreeJpaQuery extends AbstractJpaQuery { + private static final Logger log = LoggerFactory.getLogger(PartTreeJpaQuery.class); private final JpqlQueryTemplates templates = JpqlQueryTemplates.UPPER; private final PartTree tree; @@ -201,7 +203,6 @@ private static boolean expectsCollection(Type type) { return type == Type.IN || type == Type.NOT_IN; } - /** * Query preparer to create {@link CriteriaQuery} instances and potentially cache them. * @@ -222,6 +223,11 @@ public Query createQuery(JpaParametersParameterAccessor accessor) { String jpql = creator.createQuery(sort); Query query; + if (log.isDebugEnabled()) { + log.debug(String.format("%s: Derived query for query method [%s]: '%s'", getClass().getSimpleName(), + getQueryMethod(), jpql)); + } + try { query = creator.useTupleQuery() ? em.createQuery(jpql, Tuple.class) : em.createQuery(jpql); } catch (Exception e) { @@ -273,11 +279,14 @@ private Query restrictMaxResultsIfNecessary(Query query, @Nullable ScrollPositio protected JpqlQueryCreator createCreator(Sort sort, JpaParametersParameterAccessor accessor) { + JpqlQueryCreator jpqlQueryCreator; synchronized (cache) { - JpqlQueryCreator jpqlQueryCreator = cache.get(sort, accessor); // this caching thingy is broken due to IS NULL rendering for simple properties - if (jpqlQueryCreator != null) { - return jpqlQueryCreator; - } + jpqlQueryCreator = cache.get(sort, accessor); // this caching thingy is broken due to IS NULL rendering for + // simple properties + } + + if (jpqlQueryCreator != null) { + return jpqlQueryCreator; } EntityManager entityManager = getEntityManager(); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java index 71f952c2c8..51183f4c6c 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java @@ -15,7 +15,7 @@ */ package org.springframework.data.jpa.repository.query; -import java.util.HashMap; +import java.util.BitSet; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; @@ -25,11 +25,13 @@ import org.springframework.util.ObjectUtils; /** + * Cache for PartTree queries. + * * @author Christoph Strobl */ class PartTreeQueryCache { - private final Map cache = new LinkedHashMap() { + private final Map cache = new LinkedHashMap<>() { @Override protected boolean removeEldestEntry(Map.Entry eldest) { return size() > 256; @@ -49,9 +51,14 @@ JpqlQueryCreator put(Sort sort, JpaParametersParameterAccessor accessor, JpqlQue static class CacheKey { private final Sort sort; - private final Map params; - public CacheKey(Sort sort, Map params) { + /** + * Bitset of null/non-null parameter values. A 0 bit means the parameter value is {@code null}, a 1 bit means the + * parameter is not {@code null}. + */ + private final BitSet params; + + public CacheKey(Sort sort, BitSet params) { this.sort = sort; this.params = params; } @@ -59,20 +66,22 @@ public CacheKey(Sort sort, Map params) { static CacheKey of(Sort sort, JpaParametersParameterAccessor accessor) { Object[] values = accessor.getValues(); + if (ObjectUtils.isEmpty(values)) { - return new CacheKey(sort, Map.of()); + return new CacheKey(sort, new BitSet()); } return new CacheKey(sort, toNullableMap(values)); } - static Map toNullableMap(Object[] args) { + static BitSet toNullableMap(Object[] args) { - Map paramMap = new HashMap<>(args.length); + BitSet bitSet = new BitSet(args.length); for (int i = 0; i < args.length; i++) { - paramMap.put(i, args[i] != null ? Nulled.NO : Nulled.YES); + bitSet.set(i, args[i] != null); } - return paramMap; + + return bitSet; } @Override @@ -93,8 +102,4 @@ public int hashCode() { } } - enum Nulled { - YES, NO - } - } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java index 4b05507bc5..e625c9460f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java @@ -139,7 +139,7 @@ public abstract class QueryUtils { private static final Pattern CONSTRUCTOR_EXPRESSION; - private static final Map> ASSOCIATION_TYPES; + static final Map> ASSOCIATION_TYPES; private static final int QUERY_JOIN_ALIAS_GROUP_INDEX = 3; private static final int VARIABLE_NAME_GROUP_INDEX = 4; @@ -869,8 +869,7 @@ static boolean requiresOuterJoin(From from, PropertyPath property, boolean return hasRequiredOuterJoin || getAnnotationProperty(attribute, "optional", true); } - @Nullable - private static T getAnnotationProperty(Attribute attribute, String propertyName, T defaultValue) { + static T getAnnotationProperty(Attribute attribute, String propertyName, T defaultValue) { Class associationAnnotation = ASSOCIATION_TYPES.get(attribute.getPersistentAttributeType()); @@ -992,7 +991,7 @@ private static Bindable getModelForPath(PropertyPath path, @Nullable ManagedT * @return */ @Nullable - private static ManagedType getManagedTypeForModel(Bindable model) { + static ManagedType getManagedTypeForModel(Bindable model) { if (model instanceof ManagedType managedType) { return managedType; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java index dc2866fa8b..9073848ff2 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java @@ -15,9 +15,8 @@ */ package org.springframework.data.jpa.repository.query; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; import jakarta.persistence.ElementCollection; import jakarta.persistence.EntityManager; @@ -38,6 +37,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.FieldSource; import org.junit.jupiter.params.provider.ValueSource; + import org.springframework.data.domain.Pageable; import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; @@ -52,6 +52,8 @@ import org.springframework.lang.Nullable; /** + * Unit tests for {@link JpaQueryCreator}. + * * @author Christoph Strobl */ class JpaQueryCreatorTests { @@ -61,7 +63,7 @@ class JpaQueryCreatorTests { static List ignoreCaseTemplates = List.of(JpqlQueryTemplates.LOWER, JpqlQueryTemplates.UPPER); - @Test + @Test // GH-3588 void simpleProperty() { queryCreator(ORDER) // @@ -72,7 +74,7 @@ void simpleProperty() { .validateQuery(); } - @Test + @Test // GH-3588 void simpleNullProperty() { queryCreator(ORDER) // @@ -83,7 +85,7 @@ void simpleNullProperty() { .validateQuery(); } - @Test + @Test // GH-3588 void negatingSimpleProperty() { queryCreator(ORDER) // @@ -94,7 +96,7 @@ void negatingSimpleProperty() { .validateQuery(); } - @Test + @Test // GH-3588 void negatingSimpleNullProperty() { queryCreator(ORDER) // @@ -105,7 +107,7 @@ void negatingSimpleNullProperty() { .validateQuery(); } - @Test + @Test // GH-3588 void simpleAnd() { queryCreator(ORDER) // @@ -116,7 +118,7 @@ void simpleAnd() { .validateQuery(); } - @Test + @Test // GH-3588 void simpleOr() { queryCreator(ORDER) // @@ -127,7 +129,7 @@ void simpleOr() { .validateQuery(); } - @Test + @Test // GH-3588 void simpleAndOr() { queryCreator(ORDER) // @@ -139,7 +141,7 @@ void simpleAndOr() { .validateQuery(); } - @Test + @Test // GH-3588 void distinct() { queryCreator(ORDER) // @@ -150,7 +152,7 @@ void distinct() { .validateQuery(); } - @Test + @Test // GH-3588 void count() { queryCreator(ORDER) // @@ -162,7 +164,7 @@ void count() { .validateQuery(); } - @Test + @Test // GH-3588 void countWithJoins() { queryCreator(ORDER) // @@ -174,7 +176,7 @@ void countWithJoins() { .validateQuery(); } - @Test + @Test // GH-3588 void countDistinct() { queryCreator(ORDER) // @@ -186,7 +188,7 @@ void countDistinct() { .validateQuery(); } - @ParameterizedTest + @ParameterizedTest // GH-3588 @FieldSource("ignoreCaseTemplates") void simplePropertyIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { @@ -200,7 +202,7 @@ void simplePropertyIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { .validateQuery(); } - @ParameterizedTest + @ParameterizedTest // GH-3588 @FieldSource("ignoreCaseTemplates") void simplePropertyAllIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { @@ -216,7 +218,7 @@ void simplePropertyAllIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { .validateQuery(); } - @ParameterizedTest + @ParameterizedTest // GH-3588 @FieldSource("ignoreCaseTemplates") void simplePropertyMixedCase(JpqlQueryTemplates ingnoreCaseTemplate) { @@ -231,7 +233,7 @@ void simplePropertyMixedCase(JpqlQueryTemplates ingnoreCaseTemplate) { .validateQuery(); } - @Test + @Test // GH-3588 void lessThan() { queryCreator(ORDER) // @@ -242,7 +244,7 @@ void lessThan() { .validateQuery(); } - @Test + @Test // GH-3588 void lessThanEqual() { queryCreator(ORDER) // @@ -253,7 +255,7 @@ void lessThanEqual() { .validateQuery(); } - @Test + @Test // GH-3588 void greaterThan() { queryCreator(ORDER) // @@ -264,7 +266,7 @@ void greaterThan() { .validateQuery(); } - @Test + @Test // GH-3588 void before() { queryCreator(ORDER) // @@ -275,7 +277,7 @@ void before() { .validateQuery(); } - @Test + @Test // GH-3588 void after() { queryCreator(ORDER) // @@ -286,7 +288,7 @@ void after() { .validateQuery(); } - @Test + @Test // GH-3588 void between() { queryCreator(ORDER) // @@ -297,7 +299,7 @@ void between() { .validateQuery(); } - @Test + @Test // GH-3588 void isNull() { queryCreator(ORDER) // @@ -307,7 +309,7 @@ void isNull() { .validateQuery(); } - @Test + @Test // GH-3588 void isNotNull() { queryCreator(ORDER) // @@ -317,7 +319,7 @@ void isNotNull() { .validateQuery(); } - @ParameterizedTest + @ParameterizedTest // GH-3588 @ValueSource(strings = { "", "spring", "%spring", "spring%", "%spring%" }) void like(String parameterValue) { @@ -330,7 +332,7 @@ void like(String parameterValue) { .validateQuery(); } - @Test + @Test // GH-3588 void containingString() { queryCreator(ORDER) // @@ -342,7 +344,7 @@ void containingString() { .validateQuery(); } - @Test + @Test // GH-3588 void notContainingString() { queryCreator(ORDER) // @@ -354,7 +356,7 @@ void notContainingString() { .validateQuery(); } - @Test + @Test // GH-3588 void in() { queryCreator(ORDER) // @@ -366,7 +368,7 @@ void in() { .validateQuery(); } - @Test + @Test // GH-3588 void notIn() { queryCreator(ORDER) // @@ -378,7 +380,7 @@ void notIn() { .validateQuery(); } - @Test + @Test // GH-3588 void containingSingleEntryElementCollection() { queryCreator(ORDER) // @@ -389,7 +391,7 @@ void containingSingleEntryElementCollection() { .validateQuery(); } - @Test + @Test // GH-3588 void notContainingSingleEntryElementCollection() { queryCreator(ORDER) // @@ -400,7 +402,7 @@ void notContainingSingleEntryElementCollection() { .validateQuery(); } - @ParameterizedTest + @ParameterizedTest // GH-3588 @FieldSource("ignoreCaseTemplates") void likeWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { @@ -415,7 +417,7 @@ void likeWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { .validateQuery(); } - @ParameterizedTest + @ParameterizedTest // GH-3588 @ValueSource(strings = { "", "spring", "%spring", "spring%", "%spring%" }) void notLike(String parameterValue) { @@ -428,7 +430,7 @@ void notLike(String parameterValue) { .validateQuery(); } - @ParameterizedTest + @ParameterizedTest // GH-3588 @FieldSource("ignoreCaseTemplates") void notLikeWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { @@ -443,7 +445,7 @@ void notLikeWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { .validateQuery(); } - @Test + @Test // GH-3588 void startingWith() { queryCreator(ORDER) // @@ -455,7 +457,7 @@ void startingWith() { .validateQuery(); } - @ParameterizedTest + @ParameterizedTest // GH-3588 @FieldSource("ignoreCaseTemplates") void startingWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { @@ -470,7 +472,7 @@ void startingWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { .validateQuery(); } - @Test + @Test // GH-3588 void endingWith() { queryCreator(ORDER) // @@ -482,7 +484,7 @@ void endingWith() { .validateQuery(); } - @ParameterizedTest + @ParameterizedTest // GH-3588 @FieldSource("ignoreCaseTemplates") void endingWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { @@ -497,7 +499,7 @@ void endingWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) { .validateQuery(); } - @Test + @Test // GH-3588 void greaterThanEqual() { queryCreator(ORDER) // @@ -508,7 +510,7 @@ void greaterThanEqual() { .validateQuery(); } - @Test + @Test // GH-3588 void isTrue() { queryCreator(ORDER) // @@ -518,7 +520,7 @@ void isTrue() { .validateQuery(); } - @Test + @Test // GH-3588 void isFalse() { queryCreator(ORDER) // @@ -528,7 +530,7 @@ void isFalse() { .validateQuery(); } - @Test + @Test // GH-3588 void empty() { queryCreator(ORDER) // @@ -538,7 +540,7 @@ void empty() { .validateQuery(); } - @Test + @Test // GH-3588 void notEmpty() { queryCreator(ORDER) // @@ -548,7 +550,7 @@ void notEmpty() { .validateQuery(); } - @Test + @Test // GH-3588 void sortBySingle() { queryCreator(ORDER) // @@ -559,7 +561,7 @@ void sortBySingle() { .validateQuery(); } - @Test + @Test // GH-3588 void sortByMulti() { queryCreator(ORDER) // @@ -571,7 +573,7 @@ void sortByMulti() { } @Disabled("should we support this?") - @ParameterizedTest + @ParameterizedTest // GH-3588 @FieldSource("ignoreCaseTemplates") void sortBySingleIngoreCase(JpqlQueryTemplates ingoreCase) { @@ -583,7 +585,7 @@ void sortBySingleIngoreCase(JpqlQueryTemplates ingoreCase) { ingoreCase.getIgnoreCaseOperator()); } - @Test + @Test // GH-3588 void matchSimpleJoin() { queryCreator(ORDER) // @@ -594,19 +596,19 @@ void matchSimpleJoin() { .validateQuery(); } - @Test + @Test // GH-3588 void matchSimpleNestedJoin() { queryCreator(ORDER) // .forTree(Order.class, "findOrderByLineItemsProductNameIs") // .withParameters("spring") // .as(QueryCreatorTester::create) // - .expectJpql("SELECT o FROM %s o LEFT JOIN o.lineItems l INNER JOIN l.product p WHERE p.name = ?1", + .expectJpql("SELECT o FROM %s o LEFT JOIN o.lineItems l LEFT JOIN l.product p WHERE p.name = ?1", Order.class.getName()) // .validateQuery(); } - @Test + @Test // GH-3588 void matchMultiOnNestedJoin() { queryCreator(ORDER) // @@ -614,12 +616,12 @@ void matchMultiOnNestedJoin() { .withParameters(10, "spring") // .as(QueryCreatorTester::create) // .expectJpql( - "SELECT o FROM %s o LEFT JOIN o.lineItems l INNER JOIN l.product p WHERE l.quantity > ?1 AND p.name = ?2", + "SELECT o FROM %s o LEFT JOIN o.lineItems l LEFT JOIN l.product p WHERE l.quantity > ?1 AND p.name = ?2", Order.class.getName()) // .validateQuery(); } - @Test + @Test // GH-3588 void matchSameEntityMultipleTimes() { queryCreator(ORDER) // @@ -627,12 +629,12 @@ void matchSameEntityMultipleTimes() { .withParameters("spring", "sukrauq") // .as(QueryCreatorTester::create) // .expectJpql( - "SELECT o FROM %s o LEFT JOIN o.lineItems l INNER JOIN l.product p WHERE p.name = ?1 AND p.name != ?2", + "SELECT o FROM %s o LEFT JOIN o.lineItems l LEFT JOIN l.product p WHERE p.name = ?1 AND p.name != ?2", Order.class.getName()) // .validateQuery(); } - @Test + @Test // GH-3588 void matchSameEntityMultipleTimesViaDifferentProperties() { queryCreator(ORDER) // @@ -640,12 +642,12 @@ void matchSameEntityMultipleTimesViaDifferentProperties() { .withParameters(10, "spring") // .as(QueryCreatorTester::create) // .expectJpql( - "SELECT o FROM %s o LEFT JOIN o.lineItems l INNER JOIN l.product p INNER JOIN l.product2 join_0 WHERE p.name = ?1 AND join_0.name = ?2", + "SELECT o FROM %s o LEFT JOIN o.lineItems l LEFT JOIN l.product p LEFT JOIN l.product2 join_0 WHERE p.name = ?1 AND join_0.name = ?2", Order.class.getName()) // .validateQuery(); } - @Test + @Test // GH-3588 void dtoProjection() { queryCreator(ORDER) // @@ -658,7 +660,7 @@ void dtoProjection() { .validateQuery(); } - @Test + @Test // GH-3588 void interfaceProjection() { queryCreator(ORDER) // @@ -671,7 +673,7 @@ void interfaceProjection() { .validateQuery(); } - @ParameterizedTest + @ParameterizedTest // GH-3588 @ValueSource(classes = { Tuple.class, Map.class }) void tupleProjection(Class resultType) { @@ -685,7 +687,7 @@ void tupleProjection(Class resultType) { .validateQuery(); } - @ParameterizedTest + @ParameterizedTest // GH-3588 @ValueSource(classes = { Long.class, List.class, Person.class }) void delete(Class resultType) { @@ -698,7 +700,7 @@ void delete(Class resultType) { .validateQuery(); } - @Test + @Test // GH-3588 void exists() { queryCreator(PERSON) // 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 04fb7079de..1146713058 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 @@ -15,8 +15,8 @@ */ package org.springframework.data.jpa.repository.query; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.jpa.repository.query.JpqlQueryBuilder.*; import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; @@ -28,26 +28,15 @@ import java.util.Map; import org.junit.jupiter.api.Test; -import org.springframework.data.domain.Sort; -import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.AbstractJpqlQuery; -import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.Entity; -import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.Expression; -import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.Join; -import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.OrderExpression; -import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.Origin; -import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.ParameterPlaceholder; -import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.PathAndOrigin; -import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.Predicate; -import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.RenderContext; -import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.SelectStep; -import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.WhereStep; /** + * Unit tests for {@link JpqlQueryBuilder}. + * * @author Christoph Strobl */ class JpqlQueryBuilderUnitTests { - @Test + @Test // GH-3588 void placeholdersRenderCorrectly() { assertThat(JpqlQueryBuilder.parameter(ParameterPlaceholder.indexed(1)).render(RenderContext.EMPTY)).isEqualTo("?1"); @@ -56,89 +45,88 @@ void placeholdersRenderCorrectly() { assertThat(JpqlQueryBuilder.parameter("?1").render(RenderContext.EMPTY)).isEqualTo("?1"); } - @Test - void placeholdersErrorOnInvaludInput() { + @Test // GH-3588 + void placeholdersErrorOnInvalidInput() { assertThatExceptionOfType(IllegalArgumentException.class) .isThrownBy(() -> JpqlQueryBuilder.parameter((String) null)); assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> JpqlQueryBuilder.parameter("")); } - @Test + @Test // GH-3588 void stringLiteralRendersAsQuotedString() { - assertThat(JpqlQueryBuilder.stringLiteral("literal").render(RenderContext.EMPTY)).isEqualTo("'literal'"); + assertThat(literal("literal").render(RenderContext.EMPTY)).isEqualTo("'literal'"); /* JPA Spec - 4.6.1 Literals: > A string literal that includes a single quote is represented by two single quotes--for example: 'literal''s'. */ - assertThat(JpqlQueryBuilder.stringLiteral("literal's").render(RenderContext.EMPTY)).isEqualTo("'literal''s'"); + assertThat(literal("literal's").render(RenderContext.EMPTY)).isEqualTo("'literal''s'"); } - @Test + @Test // GH-3588 void entity() { Entity entity = JpqlQueryBuilder.entity(Order.class); - assertThat(entity.alias()).isEqualTo("o"); - assertThat(entity.entity()).isEqualTo(Order.class.getName()); - assertThat(entity.getName()).isEqualTo(Order.class.getSimpleName()); // TODO: this really confusing - assertThat(entity.simpleName()).isEqualTo(Order.class.getSimpleName()); + assertThat(entity.getAlias()).isEqualTo("o"); + assertThat(entity.getEntity()).isEqualTo(Order.class.getName()); + assertThat(entity.getName()).isEqualTo(Order.class.getSimpleName()); } - @Test + @Test // GH-3588 void literalExpressionRendersAsIs() { - Expression expression = JpqlQueryBuilder.expression("CONCAT(person.lastName, ‘, ’, person.firstName))"); + Expression expression = expression("CONCAT(person.lastName, ‘, ’, person.firstName))"); assertThat(expression.render(RenderContext.EMPTY)).isEqualTo("CONCAT(person.lastName, ‘, ’, person.firstName))"); } - @Test + @Test // GH-3588 void xxx() { Entity entity = JpqlQueryBuilder.entity(Order.class); PathAndOrigin orderDate = JpqlQueryBuilder.path(entity, "date"); - String fragment = JpqlQueryBuilder.where(orderDate).eq("{d '2024-11-05'}").render(ctx(entity)); + String fragment = JpqlQueryBuilder.where(orderDate).eq(expression("{d '2024-11-05'}")).render(ctx(entity)); assertThat(fragment).isEqualTo("o.date = {d '2024-11-05'}"); - - // JpqlQueryBuilder.where(PathAndOrigin) } - @Test + @Test // GH-3588 void predicateRendering() { - Entity entity = JpqlQueryBuilder.entity(Order.class); WhereStep where = JpqlQueryBuilder.where(JpqlQueryBuilder.path(entity, "country")); + RenderContext context = ctx(entity); + + assertThat(where.between(expression("'AT'"), expression("'DE'")).render(context)) + .isEqualTo("o.country BETWEEN 'AT' AND 'DE'"); + assertThat(where.eq(expression("'AT'")).render(context)).isEqualTo("o.country = 'AT'"); + assertThat(where.eq(literal("AT")).render(context)).isEqualTo("o.country = 'AT'"); + assertThat(where.gt(expression("'AT'")).render(context)).isEqualTo("o.country > 'AT'"); + assertThat(where.gte(expression("'AT'")).render(context)).isEqualTo("o.country >= 'AT'"); - assertThat(where.between("'AT'", "'DE'").render(ctx(entity))).isEqualTo("o.country BETWEEN 'AT' AND 'DE'"); - assertThat(where.eq("'AT'").render(ctx(entity))).isEqualTo("o.country = 'AT'"); - assertThat(where.eq(JpqlQueryBuilder.stringLiteral("AT")).render(ctx(entity))).isEqualTo("o.country = 'AT'"); - assertThat(where.gt("'AT'").render(ctx(entity))).isEqualTo("o.country > 'AT'"); - assertThat(where.gte("'AT'").render(ctx(entity))).isEqualTo("o.country >= 'AT'"); // TODO: that is really really bad // lange namen - assertThat(where.in("'AT', 'DE'").render(ctx(entity))).isEqualTo("o.country IN ('AT', 'DE')"); + assertThat(where.in(expression("'AT', 'DE'")).render(context)).isEqualTo("o.country IN ('AT', 'DE')"); // 1 in age - cleanup what is not used - remove everything eles // assertThat(where.inMultivalued("'AT', 'DE'").render(ctx(entity))).isEqualTo("o.country IN ('AT', 'DE')"); // - assertThat(where.isEmpty().render(ctx(entity))).isEqualTo("o.country IS EMPTY"); - assertThat(where.isNotEmpty().render(ctx(entity))).isEqualTo("o.country IS NOT EMPTY"); - assertThat(where.isTrue().render(ctx(entity))).isEqualTo("o.country = TRUE"); - assertThat(where.isFalse().render(ctx(entity))).isEqualTo("o.country = FALSE"); - assertThat(where.isNull().render(ctx(entity))).isEqualTo("o.country IS NULL"); - assertThat(where.isNotNull().render(ctx(entity))).isEqualTo("o.country IS NOT NULL"); - assertThat(where.like("'\\_%'", "" + EscapeCharacter.DEFAULT.getEscapeCharacter()).render(ctx(entity))) + assertThat(where.isEmpty().render(context)).isEqualTo("o.country IS EMPTY"); + assertThat(where.isNotEmpty().render(context)).isEqualTo("o.country IS NOT EMPTY"); + assertThat(where.isTrue().render(context)).isEqualTo("o.country = TRUE"); + assertThat(where.isFalse().render(context)).isEqualTo("o.country = FALSE"); + assertThat(where.isNull().render(context)).isEqualTo("o.country IS NULL"); + assertThat(where.isNotNull().render(context)).isEqualTo("o.country IS NOT NULL"); + assertThat(where.like("'\\_%'", "" + EscapeCharacter.DEFAULT.getEscapeCharacter()).render(context)) .isEqualTo("o.country LIKE '\\_%' ESCAPE '\\'"); - assertThat(where.notLike("'\\_%'", "" + EscapeCharacter.DEFAULT.getEscapeCharacter()).render(ctx(entity))) + assertThat(where.notLike(expression("'\\_%'"), "" + EscapeCharacter.DEFAULT.getEscapeCharacter()).render(context)) .isEqualTo("o.country NOT LIKE '\\_%' ESCAPE '\\'"); - assertThat(where.lt("'AT'").render(ctx(entity))).isEqualTo("o.country < 'AT'"); - assertThat(where.lte("'AT'").render(ctx(entity))).isEqualTo("o.country <= 'AT'"); - assertThat(where.memberOf("'AT'").render(ctx(entity))).isEqualTo("'AT' MEMBER OF o.country"); + assertThat(where.lt(expression("'AT'")).render(context)).isEqualTo("o.country < 'AT'"); + assertThat(where.lte(expression("'AT'")).render(context)).isEqualTo("o.country <= 'AT'"); + assertThat(where.memberOf(expression("'AT'")).render(context)).isEqualTo("'AT' MEMBER OF o.country"); // TODO: can we have this where.value(foo).memberOf(pathAndOrigin); - assertThat(where.notMemberOf("'AT'").render(ctx(entity))).isEqualTo("'AT' NOT MEMBER OF o.country"); - assertThat(where.neq("'AT'").render(ctx(entity))).isEqualTo("o.country != 'AT'"); + assertThat(where.notMemberOf(expression("'AT'")).render(context)).isEqualTo("'AT' NOT MEMBER OF o.country"); + assertThat(where.neq(expression("'AT'")).render(context)).isEqualTo("o.country != 'AT'"); } - @Test + @Test // GH-3588 void selectRendering() { // make sure things are immutable @@ -147,25 +135,12 @@ void selectRendering() { assertThat(select.count().render()).startsWith("SELECT COUNT(o)"); assertThat(select.distinct().entity().render()).startsWith("SELECT DISTINCT o "); assertThat(select.distinct().count().render()).startsWith("SELECT COUNT(DISTINCT o) "); - assertThat(JpqlQueryBuilder.selectFrom(Order.class).select(JpqlQueryBuilder.path(JpqlQueryBuilder.entity(Order.class), "country")).render()) - .startsWith("SELECT o.country "); + assertThat(JpqlQueryBuilder.selectFrom(Order.class) + .select(JpqlQueryBuilder.path(JpqlQueryBuilder.entity(Order.class), "country")).render()) + .startsWith("SELECT o.country "); } -// @Test -// void sorting() { -// -// JpqlQueryBuilder.orderBy(new OrderExpression() , Sort.Order.asc("country")); -// -// Entity entity = JpqlQueryBuilder.entity(Order.class); -// -// AbstractJpqlQuery query = JpqlQueryBuilder.selectFrom(Order.class) -// .entity() -// .orderBy() -// .where(context -> "1 = 1"); -// -// } - - @Test + @Test // GH-3588 void joins() { Entity entity = JpqlQueryBuilder.entity(LineItem.class); @@ -175,14 +150,14 @@ void joins() { PathAndOrigin productName = JpqlQueryBuilder.path(li_pr, "name"); PathAndOrigin personName = JpqlQueryBuilder.path(li_pr2, "name"); - String fragment = JpqlQueryBuilder.where(productName).eq(JpqlQueryBuilder.stringLiteral("ex30")) - .and(JpqlQueryBuilder.where(personName).eq(JpqlQueryBuilder.stringLiteral("ex40"))).render(ctx(entity)); + String fragment = JpqlQueryBuilder.where(productName).eq(literal("ex30")) + .and(JpqlQueryBuilder.where(personName).eq(literal("ex40"))).render(ctx(entity)); assertThat(fragment).isEqualTo("p.name = 'ex30' AND join_0.name = 'ex40'"); } - @Test - void x2() { + @Test // GH-3588 + void joinOnPaths() { Entity entity = JpqlQueryBuilder.entity(LineItem.class); Join li_pr = JpqlQueryBuilder.innerJoin(entity, "product"); @@ -191,36 +166,17 @@ void x2() { PathAndOrigin productName = JpqlQueryBuilder.path(li_pr, "name"); PathAndOrigin personName = JpqlQueryBuilder.path(li_pe, "name"); - String fragment = JpqlQueryBuilder.where(productName).eq(JpqlQueryBuilder.stringLiteral("ex30")) - .and(JpqlQueryBuilder.where(personName).eq(JpqlQueryBuilder.stringLiteral("cstrobl"))).render(ctx(entity)); - - assertThat(fragment).isEqualTo("p.name = 'ex30' AND join_0.name = 'cstrobl'"); - } - - @Test - void x3() { - - Entity entity = JpqlQueryBuilder.entity(LineItem.class); - Join li_pr = JpqlQueryBuilder.innerJoin(entity, "product"); - Join li_pe = JpqlQueryBuilder.innerJoin(entity, "person"); - - PathAndOrigin productName = JpqlQueryBuilder.path(li_pr, "name"); - PathAndOrigin personName = JpqlQueryBuilder.path(li_pe, "name"); - - // JpqlQueryBuilder.and("x = y", "a = b"); -> x = y AND a = b - - // JpqlQueryBuilder.nested(JpqlQueryBuilder.and("x = y", "a = b")) (x = y AND a = b) - - String fragment = JpqlQueryBuilder.where(productName).eq(JpqlQueryBuilder.stringLiteral("ex30")) - .and(JpqlQueryBuilder.where(personName).eq(JpqlQueryBuilder.stringLiteral("cstrobl"))).render(ctx(entity)); + String fragment = JpqlQueryBuilder.where(productName).eq(literal("ex30")) + .and(JpqlQueryBuilder.where(personName).eq(literal("cstrobl"))).render(ctx(entity)); assertThat(fragment).isEqualTo("p.name = 'ex30' AND join_0.name = 'cstrobl'"); } static RenderContext ctx(Entity... entities) { + Map aliases = new LinkedHashMap<>(entities.length); for (Entity entity : entities) { - aliases.put(entity, entity.alias()); + aliases.put(entity, entity.getAlias()); } return new RenderContext(aliases); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderIntegrationTests.java index 12b0d55b60..bbda2e5381 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderIntegrationTests.java @@ -48,24 +48,26 @@ class ParameterMetadataProviderIntegrationTests { @PersistenceContext EntityManager em; - /* TODO + @Test // DATAJPA-758 - void forwardsParameterNameIfTransparentlyNamed() throws Exception { + void usesIndexedParametersForExplicityNamedParameters() throws Exception { ParameterMetadataProvider provider = createProvider(Sample.class.getMethod("findByFirstname", String.class)); - ParameterMetadata metadata = provider.next(new Part("firstname", User.class)); + ParameterBinding.PartTreeParameterBinding metadata = provider.next(new Part("firstname", User.class)); - assertThat(metadata.getName()).isEqualTo("name"); + assertThat(metadata.getName()).isNull(); + assertThat(metadata.getPosition()).isEqualTo(1); } @Test // DATAJPA-758 - void forwardsParameterNameIfExplicitlyAnnotated() throws Exception { + void usesIndexedParameters() throws Exception { ParameterMetadataProvider provider = createProvider(Sample.class.getMethod("findByLastname", String.class)); - ParameterMetadata metadata = provider.next(new Part("lastname", User.class)); + ParameterBinding.PartTreeParameterBinding metadata = provider.next(new Part("lastname", User.class)); - assertThat(metadata.getExpression().getName()).isNull(); - } */ + assertThat(metadata.getName()).isNull(); + assertThat(metadata.getPosition()).isEqualTo(1); + } @Test // DATAJPA-772 void doesNotApplyLikeExpansionOnNonStringProperties() throws Exception { From 43b7fb5de62420ff2531b7d52c06b61454fba0aa Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 21 Nov 2024 10:44:50 +0100 Subject: [PATCH 5/5] Revise PartTree query caching. --- .../repository/query/PartTreeJpaQuery.java | 21 +++++++------------ .../repository/query/PartTreeQueryCache.java | 5 +++-- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java index ba36c7f3e9..ed244bdb61 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java @@ -279,12 +279,8 @@ private Query restrictMaxResultsIfNecessary(Query query, @Nullable ScrollPositio protected JpqlQueryCreator createCreator(Sort sort, JpaParametersParameterAccessor accessor) { - JpqlQueryCreator jpqlQueryCreator; - synchronized (cache) { - jpqlQueryCreator = cache.get(sort, accessor); // this caching thingy is broken due to IS NULL rendering for - // simple properties - } - + JpqlQueryCreator jpqlQueryCreator = cache.get(sort, accessor); // this caching thingy is broken due to IS NULL + // rendering for if (jpqlQueryCreator != null) { return jpqlQueryCreator; } @@ -307,9 +303,7 @@ protected JpqlQueryCreator createCreator(Sort sort, JpaParametersParameterAccess return creator; } - synchronized (cache) { - cache.put(sort, accessor, creator); - } + cache.put(sort, accessor, creator); return creator; } @@ -377,13 +371,12 @@ private Sort getDynamicSort(JpaParametersParameterAccessor accessor) { */ private class CountQueryPreparer extends QueryPreparer { - private volatile JpqlQueryCreator cached; + private final PartTreeQueryCache cache = new PartTreeQueryCache(); @Override protected JpqlQueryCreator createCreator(Sort sort, JpaParametersParameterAccessor accessor) { - JpqlQueryCreator cached = this.cached; - + JpqlQueryCreator cached = cache.get(Sort.unsorted(), accessor); if (cached != null) { return cached; } @@ -393,7 +386,9 @@ protected JpqlQueryCreator createCreator(Sort sort, JpaParametersParameterAccess getQueryMethod().getResultProcessor().getReturnedType(), provider, templates, em); if (!accessor.getParameters().hasDynamicProjection()) { - return this.cached = new CacheableJpqlCountQueryCreator(creator); + cached = new CacheableJpqlCountQueryCreator(creator); + cache.put(Sort.unsorted(), accessor, cached); + return cached; } return creator; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java index 51183f4c6c..59d30c915f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java @@ -16,6 +16,7 @@ package org.springframework.data.jpa.repository.query; import java.util.BitSet; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; @@ -31,12 +32,12 @@ */ class PartTreeQueryCache { - private final Map cache = new LinkedHashMap<>() { + private final Map cache = Collections.synchronizedMap(new LinkedHashMap<>() { @Override protected boolean removeEldestEntry(Map.Entry eldest) { return size() > 256; } - }; + }); @Nullable JpqlQueryCreator get(Sort sort, JpaParametersParameterAccessor accessor) {