Use parameter names in derived JPQL queries.

We also use improved parameter naming for keyset queries for easier correlation of values.

Closes #3857
This commit is contained in:
Mark Paluch
2025-04-24 16:12:27 +02:00
parent ddeac0cacd
commit 403e1b4d01
16 changed files with 149 additions and 49 deletions

View File

@@ -427,13 +427,7 @@ class JpaCodeBlocks {
}
private Object getParameterName(ParameterBinding.BindingIdentifier identifier) {
if (identifier.hasPosition()) {
return identifier.getPosition();
}
return identifier.getName();
return identifier.hasName() ? identifier.getName() : Integer.valueOf(identifier.getPosition());
}
private Object getParameter(ParameterBinding.ParameterOrigin origin) {

View File

@@ -19,14 +19,13 @@ import jakarta.persistence.EntityManager;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.data.domain.KeysetScrollPosition;
import java.util.Map;
import org.jspecify.annotations.Nullable;
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;
@@ -76,12 +75,22 @@ class JpaKeysetScrollQueryCreator extends JpaQueryCreator {
JpqlQueryBuilder.Select query = buildQuery(keysetSpec.sort());
AtomicInteger counter = new AtomicInteger(provider.getBindings().size());
JpqlQueryBuilder.Predicate keysetPredicate = keysetSpec.createJpqlPredicate(getFrom(), getEntity(), value -> {
Map<String, Map<Object, ParameterBinding>> cachedBindings = new LinkedHashMap<>();
JpqlQueryBuilder.Predicate keysetPredicate = keysetSpec.createJpqlPredicate(getFrom(), getEntity(),
(property, value) -> {
Map<Object, ParameterBinding> bindings = cachedBindings.computeIfAbsent(property, k -> new LinkedHashMap<>());
ParameterBinding parameterBinding = bindings.computeIfAbsent(value, o -> {
ParameterBinding binding = provider.nextSynthetic(sanitize(property), value, scrollPosition);
syntheticBindings.add(binding);
return binding;
});
return placeholder(parameterBinding);
});
syntheticBindings.add(provider.nextSynthetic(value, scrollPosition));
return placeholder(counter.incrementAndGet());
});
JpqlQueryBuilder.Predicate predicateToUse = getPredicate(predicate, keysetPredicate);
if (predicateToUse != null) {
@@ -91,6 +100,29 @@ class JpaKeysetScrollQueryCreator extends JpaQueryCreator {
return query;
}
private static String sanitize(String property) {
StringBuilder buffer = new StringBuilder(10 + property.length());
// max length 24
buffer.append("keyset_");
char[] charArray = property.toCharArray();
for (int i = 0; i < charArray.length; i++) {
if (buffer.length() > 24) {
break;
}
if (Character.isDigit(charArray[i]) || Character.isLetter(charArray[i])) {
buffer.append(charArray[i]);
} else if (charArray[i] == '.') {
buffer.append('_');
}
}
return buffer.toString();
}
private static JpqlQueryBuilder.@Nullable Predicate getPredicate(JpqlQueryBuilder.@Nullable Predicate predicate,
JpqlQueryBuilder.@Nullable Predicate keysetPredicate) {

View File

@@ -33,9 +33,9 @@ import java.util.Iterator;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.data.domain.Sort;
import org.jspecify.annotations.Nullable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.JpaSort;
import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.ParameterPlaceholder;
import org.springframework.data.jpa.repository.query.ParameterBinding.PartTreeParameterBinding;
@@ -73,6 +73,7 @@ public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuild
private final EntityType<?> entityType;
private final JpqlQueryBuilder.Entity entity;
private final Metamodel metamodel;
private final boolean useNamedParameters;
/**
* Create a new {@link JpaQueryCreator}.
@@ -96,6 +97,23 @@ public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuild
this.tree = tree;
this.returnedType = type;
this.provider = provider;
JpaParameters bindableParameters = provider.getParameters().getBindableParameters();
boolean useNamedParameters = false;
for (JpaParameters.JpaParameter bindableParameter : bindableParameters) {
if (bindableParameter.isNamedParameter()) {
useNamedParameters = true;
}
if (useNamedParameters && !bindableParameter.isNamedParameter()) {
useNamedParameters = false;
break;
}
}
this.useNamedParameters = useNamedParameters;
this.templates = templates;
this.escape = provider.getEscape();
this.entityType = metamodel.entity(type.getDomainType());
@@ -274,11 +292,12 @@ public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuild
}
JpqlQueryBuilder.Expression placeholder(ParameterBinding binding) {
return placeholder(binding.getRequiredPosition());
}
JpqlQueryBuilder.Expression placeholder(int position) {
return JpqlQueryBuilder.parameter(ParameterPlaceholder.indexed(position));
if (useNamedParameters && binding.hasName()) {
return JpqlQueryBuilder.parameter(ParameterPlaceholder.named(binding.getRequiredName()));
}
return JpqlQueryBuilder.parameter(ParameterPlaceholder.indexed(binding.getRequiredPosition()));
}
/**

View File

@@ -22,9 +22,9 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import org.springframework.data.domain.KeysetScrollPosition;
import org.jspecify.annotations.Nullable;
import org.springframework.data.domain.KeysetScrollPosition;
import org.springframework.data.domain.ScrollPosition.Direction;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Order;
@@ -104,7 +104,7 @@ public class KeysetScrollDelegate {
break;
}
sortConstraint.add(strategy.compare(propertyExpression, o));
sortConstraint.add(strategy.compare(inner.getProperty(), propertyExpression, o));
j++;
}
@@ -215,11 +215,12 @@ public class KeysetScrollDelegate {
/**
* Create an equals-comparison object.
*
* @param property name of the property.
* @param propertyExpression must not be {@literal null}.
* @param value the value to compare with. Can be {@literal null}.
* @return an object representing the comparison predicate.
*/
P compare(E propertyExpression, @Nullable Object value);
P compare(String property, E propertyExpression, @Nullable Object value);
/**
* AND-combine the {@code intermediate} predicates.

View File

@@ -117,7 +117,7 @@ public record KeysetScrollSpecification<T>(KeysetScrollPosition position, Sort s
}
@Override
public Predicate compare(Expression<Comparable> propertyExpression, @Nullable Object value) {
public Predicate compare(String property, Expression<Comparable> propertyExpression, @Nullable Object value) {
return value == null ? cb.isNull(propertyExpression) : cb.equal(propertyExpression, value);
}
@@ -163,15 +163,17 @@ public record KeysetScrollSpecification<T>(KeysetScrollPosition position, Sort s
if (value == null) {
return order.isAscending() ? where.isNull() : where.isNotNull();
}
return order.isAscending() ? where.gt(factory.capture(value)) : where.lt(factory.capture(value));
return order.isAscending() ? where.gt(factory.capture(order.getProperty(), value))
: where.lt(factory.capture(order.getProperty(), value));
}
@Override
public JpqlQueryBuilder.Predicate compare(JpqlQueryBuilder.Expression propertyExpression, @Nullable Object value) {
public JpqlQueryBuilder.Predicate compare(String property, JpqlQueryBuilder.Expression propertyExpression,
@Nullable Object value) {
JpqlQueryBuilder.WhereStep where = JpqlQueryBuilder.where(propertyExpression);
return value == null ? where.isNull() : where.eq(factory.capture(value));
return value == null ? where.isNull() : where.eq(factory.capture(property, value));
}
@Override
@@ -186,6 +188,6 @@ public record KeysetScrollSpecification<T>(KeysetScrollPosition position, Sort s
}
public interface ParameterFactory {
JpqlQueryBuilder.Expression capture(Object value);
JpqlQueryBuilder.Expression capture(String name, Object value);
}
}

View File

@@ -81,6 +81,14 @@ public class ParameterBinding {
return identifier.hasName() ? identifier.getName() : null;
}
/**
* @return {@literal true} if the binding identifier is associated with a name.
* @since 4.0
*/
boolean hasName() {
return identifier.hasName();
}
/**
* @return the name
* @throws IllegalStateException if the name is not available.

View File

@@ -24,12 +24,14 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.data.jpa.provider.PersistenceProvider;
import org.jspecify.annotations.Nullable;
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;
@@ -60,6 +62,7 @@ public class ParameterMetadataProvider {
private final Iterator<? extends Parameter> parameters;
private final List<ParameterBinding> bindings;
private final Set<String> syntheticParameterNames = new LinkedHashSet<>();
private final @Nullable Iterator<Object> bindableParameterValues;
private final EscapeCharacter escape;
private final JpqlQueryTemplates templates;
@@ -176,7 +179,8 @@ public class ParameterMetadataProvider {
int currentPosition = ++position;
BindingIdentifier bindingIdentifier = BindingIdentifier.of(currentPosition);
BindingIdentifier bindingIdentifier = parameter.getName().map(it -> BindingIdentifier.of(it, currentPosition))
.orElseGet(() -> BindingIdentifier.of(currentPosition));
/* identifier refers to bindable parameters, not _all_ parameters index */
MethodInvocationArgument methodParameter = ParameterOrigin.ofParameter(bindingIdentifier);
@@ -195,15 +199,24 @@ public class ParameterMetadataProvider {
/**
* Builds a new synthetic {@link ParameterBinding} for the given value.
*
* @param nameHint
* @param value
* @param source
* @return a new {@link ParameterBinding} for the given value and source.
*/
public ParameterBinding nextSynthetic(Object value, Object source) {
public ParameterBinding nextSynthetic(String nameHint, Object value, Object source) {
int currentPosition = ++position;
String bindingName = nameHint;
return new ParameterBinding(BindingIdentifier.of(currentPosition), ParameterOrigin.synthetic(value, source));
if (!syntheticParameterNames.add(bindingName)) {
bindingName = bindingName + "_" + currentPosition;
syntheticParameterNames.add(bindingName);
}
return new ParameterBinding(BindingIdentifier.of(bindingName, currentPosition),
ParameterOrigin.synthetic(value, source));
}
public JpaParameters getParameters() {

View File

@@ -386,7 +386,7 @@ public class QuerydslJpaPredicateExecutor<T> implements QuerydslPredicateExecuto
}
@Override
public BooleanExpression compare(Expression<?> propertyExpression, @Nullable Object value) {
public BooleanExpression compare(String property, Expression<?> propertyExpression, @Nullable Object value) {
return Expressions.booleanOperation(Ops.EQ, propertyExpression,
value == null ? NullExpression.DEFAULT : ConstantImpl.create(value));
}

View File

@@ -523,6 +523,14 @@ class UserRepositoryFinderTests {
});
}
@Test // GH-3857
void shouldApplyParameterNames() {
assertThat(userRepository.findAnnotatedWithParameterNameQuery(oliver.getLastname())).hasSize(2);
assertThat(userRepository.findWithParameterNameByLastnameStartingWithOrLastnameEndingWith(oliver.getLastname(),
oliver.getLastname())).hasSize(2);
}
@ParameterizedTest // GH-3076
@ValueSource(classes = { UserRoleCountDtoProjection.class, UserRoleCountInterfaceProjection.class })
<T> void dynamicProjectionWithEntityAndAggregated(Class<T> resultType) {

View File

@@ -320,6 +320,13 @@ class JpaRepositoryContributorIntegrationTests {
"vader@empire.com");
}
@Test // GH-3857
void appliesCustomParameterNaming() {
assertThat(fragment.findAnnotatedWithParameterNameQuery("S")).hasSize(4);
assertThat(fragment.findWithParameterNameByLastnameStartingWithOrLastnameEndingWith("S", "S")).hasSize(4);
}
@Test // GH-3830
void testAnnotatedFinderReturningSlice() {

View File

@@ -32,7 +32,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.annotation.Transactional;
/**
* Integration tests for the {@link UserRepository} JSON metadata.
* Integration tests for the {@link UserRepository} JSON metadata via {@link JpaRepositoryContributor}.
*
* @author Mark Paluch
*/
@@ -77,7 +77,7 @@ class JpaRepositoryMetadataIntegrationTests {
assertThatJson(json).inPath("$.methods[0]").isObject().containsEntry("name", "countUsersByLastname");
assertThatJson(json).inPath("$.methods[0].query").isObject().containsEntry("query",
"SELECT COUNT(u) FROM org.springframework.data.jpa.domain.sample.User u WHERE u.lastname = ?1");
"SELECT COUNT(u) FROM org.springframework.data.jpa.domain.sample.User u WHERE u.lastname = :lastname");
}
@Test // GH-3830

View File

@@ -116,6 +116,16 @@ interface UserRepository extends CrudRepository<User, Integer> {
@Query("select u from User u where u.lastname like ?1%")
Slice<User> findAnnotatedQuerySliceOfUsersByLastname(String lastname, Pageable pageable);
// -------------------------------------------------------------------------
// Projections: Parameter naming
// -------------------------------------------------------------------------
@Query("select u from User u where u.lastname like %:name or u.lastname like :name% ORDER BY u.lastname")
List<User> findAnnotatedWithParameterNameQuery(@Param("name") String lastname);
List<User> findWithParameterNameByLastnameStartingWithOrLastnameEndingWith(@Param("l1") String l1,
@Param("l2") String l2);
// -------------------------------------------------------------------------
// Value Expressions
// -------------------------------------------------------------------------

View File

@@ -77,10 +77,10 @@ class JpaKeysetScrollQueryCreatorTests {
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)
SELECT u FROM org.springframework.data.jpa.domain.sample.User u WHERE (u.firstname LIKE :firstname ESCAPE '\\')
AND (u.firstname < :keyset_firstname
OR u.firstname = :keyset_firstname AND u.emailAddress < :keyset_emailAddress
OR u.firstname = :keyset_firstname AND u.emailAddress = :keyset_emailAddress AND u.id < :keyset_id)
ORDER BY u.firstname desc, u.emailAddress desc, u.id desc
""");
}

View File

@@ -50,22 +50,22 @@ class ParameterMetadataProviderIntegrationTests {
@PersistenceContext EntityManager em;
@Test // DATAJPA-758
void usesIndexedParametersForExplicityNamedParameters() throws Exception {
void usesNamedParametersForExplicitlyNamedParameters() throws Exception {
ParameterMetadataProvider provider = createProvider(Sample.class.getMethod("findByFirstname", String.class));
ParameterBinding.PartTreeParameterBinding metadata = provider.next(new Part("firstname", User.class));
assertThat(metadata.getName()).isNull();
assertThat(metadata.getName()).isEqualTo("name");
assertThat(metadata.getPosition()).isEqualTo(1);
}
@Test // DATAJPA-758
void usesIndexedParameters() throws Exception {
void usesNamedParameters() throws Exception {
ParameterMetadataProvider provider = createProvider(Sample.class.getMethod("findByLastname", String.class));
ParameterBinding.PartTreeParameterBinding metadata = provider.next(new Part("lastname", User.class));
assertThat(metadata.getName()).isNull();
assertThat(metadata.getName()).isEqualTo("lastname");
assertThat(metadata.getPosition()).isEqualTo(1);
}

View File

@@ -112,7 +112,7 @@ class PartTreeJpaQueryIntegrationTests {
Query query = jpaQuery.createQuery(getAccessor(queryMethod, new Object[] { "Matthews", PageRequest.of(0, 1) }));
assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY)))
.contains("firstname %s ?".formatted(criteria.endsWith("Not") ? "!=" : "="));
.contains("firstname %s :".formatted(criteria.endsWith("Not") ? "!=" : "="));
query = jpaQuery.createQuery(getAccessor(queryMethod, new Object[] { null, PageRequest.of(0, 1) }));

View File

@@ -768,6 +768,12 @@ public interface UserRepository extends JpaRepository<User, Integer>, JpaSpecifi
Window<User> findBy(OffsetScrollPosition position);
@Query("select u from User u where u.lastname like %:name or u.lastname like :name% ORDER BY u.lastname")
List<User> findAnnotatedWithParameterNameQuery(@Param("name") String lastname);
List<User> findWithParameterNameByLastnameStartingWithOrLastnameEndingWith(@Param("l1") String l1,
@Param("l2") String l2);
@Retention(RetentionPolicy.RUNTIME)
@Query("select u, count(r) from User u left outer join u.roles r group by u")
@interface UserRoleCountProjectingQuery {