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 bbde6d9414..c65cab53df 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 16ef4cff08..edac9f12d2 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; @@ -58,54 +58,50 @@ * @author Greg Turnquist * @author Jinmyeong Kim */ -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(); } /** @@ -113,102 +109,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(); } /** @@ -217,24 +279,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; } /** @@ -242,83 +300,85 @@ 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: + PartTreeParameterBinding simple = provider.next(part); + + if (simple.isIsNullParameter()) { + return where.isNull(); + } + + return whereIgnoreCase.eq(potentiallyIgnoreCase(property, JpqlQueryBuilder.expression(render(simple)))); case NEGATING_SIMPLE_PROPERTY: - ParameterMetadata expression = provider.next(part); - Expression path = getTypedPath(root, part); + PartTreeParameterBinding negating = provider.next(part); - if (expression.isIsNullParameter()) { - return type.equals(SIMPLE_PROPERTY) ? path.isNull() : path.isNotNull(); - } else { - return type.equals(SIMPLE_PROPERTY) - ? builder.equal(upperIfIgnoreCase(path), upperIfIgnoreCase(expression.getExpression())) - : builder.notEqual(upperIfIgnoreCase(path), upperIfIgnoreCase(expression.getExpression())); + if (negating.isIsNullParameter()) { + return where.isNotNull(); } + + return whereIgnoreCase + .neq(potentiallyIgnoreCase(property, JpqlQueryBuilder.expression(render(negating)))); case IS_EMPTY: case IS_NOT_EMPTY: @@ -326,77 +386,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 bfa635b413..5f55a8764c 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}. @@ -94,7 +93,6 @@ private NamedQuery(JpaQueryMethod method, EntityManager em) { this.declaredQuery = Lazy .of(() -> DeclaredQuery.of(queryString, method.isNativeQuery() || query.toString().contains("NativeQuery"))); - this.metadataCache = new QueryParameterSetter.QueryMetadataCache(); } /** @@ -168,9 +166,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 @@ -191,9 +187,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 3c858dd814..de3256baa3 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,10 +229,12 @@ 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.NEGATING_SIMPLE_PROPERTY.equals(part.getType())) ? Type.IS_NULL @@ -217,13 +244,12 @@ public ParameterMetadata(ParameterExpression expression, Part part, @Nullable 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; } /** @@ -241,11 +267,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: @@ -260,8 +286,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; } @@ -295,7 +321,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; @@ -304,8 +330,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 02a89c3677..ef57b21964 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 @@ -2452,13 +2452,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 @@ -3195,6 +3195,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 4b34c8b706..fe8f239a60 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; @@ -39,9 +37,11 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; + 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; @@ -151,7 +151,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 @@ -162,7 +162,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 @@ -170,7 +181,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 @@ -297,6 +309,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"); } } diff --git a/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc b/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc index f96e27eeed..62992269da 100644 --- a/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc +++ b/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc @@ -32,7 +32,7 @@ public interface UserRepository extends Repository { List findByEmailAddressAndLastname(String emailAddress, String lastname); } ---- -We create a query using the JPA criteria API from this, but, essentially, this translates into the following query: `select u from User u where u.emailAddress = ?1 and u.lastname = ?2`. Spring Data JPA does a property check and traverses nested properties, as described in xref:repositories/query-methods-details.adoc#repositories.query-methods.query-property-expressions[Property Expressions]. +We create a query using JPQL translating into the following query: `select u from User u where u.emailAddress = ?1 and u.lastname = ?2`. Spring Data JPA does a property check and traverses nested properties, as described in xref:repositories/query-methods-details.adoc#repositories.query-methods.query-property-expressions[Property Expressions]. ==== The following table describes the keywords supported for JPA and what a method containing that keyword translates to: