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:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
""");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }));
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user