Skip to content

Commit d6482ff

Browse files
committed
Use parameter names in derived JPQL queries.
We also use improved parameter naming for keyset queries for easier correlation of values. Closes #3857
1 parent 3827c3c commit d6482ff

16 files changed

+152
-53
lines changed

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaCodeBlocks.java

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -427,13 +427,7 @@ private CodeBlock doCreateQuery(boolean count, String queryVariableName,
427427
}
428428

429429
private Object getParameterName(ParameterBinding.BindingIdentifier identifier) {
430-
431-
if (identifier.hasPosition()) {
432-
return identifier.getPosition();
433-
}
434-
435-
return identifier.getName();
436-
430+
return identifier.hasName() ? identifier.getName() : Integer.valueOf(identifier.getPosition());
437431
}
438432

439433
private Object getParameter(ParameterBinding.ParameterOrigin origin) {

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,13 @@
1919

2020
import java.util.ArrayList;
2121
import java.util.Collection;
22-
import java.util.LinkedHashSet;
22+
import java.util.LinkedHashMap;
2323
import java.util.List;
24-
import java.util.Set;
25-
import java.util.concurrent.atomic.AtomicInteger;
26-
27-
import org.springframework.data.domain.KeysetScrollPosition;
24+
import java.util.Map;
2825

2926
import org.jspecify.annotations.Nullable;
27+
28+
import org.springframework.data.domain.KeysetScrollPosition;
3029
import org.springframework.data.domain.Sort;
3130
import org.springframework.data.jpa.repository.support.JpaEntityInformation;
3231
import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
@@ -76,12 +75,22 @@ protected JpqlQueryBuilder.AbstractJpqlQuery createQuery(JpqlQueryBuilder.@Nulla
7675

7776
JpqlQueryBuilder.Select query = buildQuery(keysetSpec.sort());
7877

79-
AtomicInteger counter = new AtomicInteger(provider.getBindings().size());
80-
JpqlQueryBuilder.Predicate keysetPredicate = keysetSpec.createJpqlPredicate(getFrom(), getEntity(), value -> {
78+
Map<String, Map<Object, ParameterBinding>> cachedBindings = new LinkedHashMap<>();
79+
JpqlQueryBuilder.Predicate keysetPredicate = keysetSpec.createJpqlPredicate(getFrom(), getEntity(),
80+
(property, value) -> {
81+
82+
Map<Object, ParameterBinding> bindings = cachedBindings.computeIfAbsent(property, k -> new LinkedHashMap<>());
83+
84+
ParameterBinding parameterBinding = bindings.computeIfAbsent(value, o -> {
85+
86+
ParameterBinding binding = provider.nextSynthetic(sanitize(property), value, scrollPosition);
87+
syntheticBindings.add(binding);
88+
return binding;
89+
});
90+
91+
return placeholder(parameterBinding);
92+
});
8193

82-
syntheticBindings.add(provider.nextSynthetic(value, scrollPosition));
83-
return placeholder(counter.incrementAndGet());
84-
});
8594
JpqlQueryBuilder.Predicate predicateToUse = getPredicate(predicate, keysetPredicate);
8695

8796
if (predicateToUse != null) {
@@ -91,6 +100,29 @@ protected JpqlQueryBuilder.AbstractJpqlQuery createQuery(JpqlQueryBuilder.@Nulla
91100
return query;
92101
}
93102

103+
private static String sanitize(String property) {
104+
105+
StringBuilder buffer = new StringBuilder(10 + property.length());
106+
107+
// max length 24
108+
buffer.append("keyset_");
109+
110+
char[] charArray = property.toCharArray();
111+
for (int i = 0; i < charArray.length; i++) {
112+
113+
if (buffer.length() > 24) {
114+
break;
115+
}
116+
117+
if (Character.isDigit(charArray[i]) || Character.isLetter(charArray[i])) {
118+
buffer.append(charArray[i]);
119+
} else if (charArray[i] == '.') {
120+
buffer.append('_');
121+
}
122+
}
123+
124+
return buffer.toString();
125+
}
94126

95127
private static JpqlQueryBuilder.@Nullable Predicate getPredicate(JpqlQueryBuilder.@Nullable Predicate predicate,
96128
JpqlQueryBuilder.@Nullable Predicate keysetPredicate) {

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@
3333
import java.util.List;
3434
import java.util.stream.Collectors;
3535

36-
import org.springframework.data.domain.Sort;
37-
3836
import org.jspecify.annotations.Nullable;
37+
38+
import org.springframework.data.domain.Sort;
3939
import org.springframework.data.jpa.domain.JpaSort;
4040
import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.ParameterPlaceholder;
4141
import org.springframework.data.jpa.repository.query.ParameterBinding.PartTreeParameterBinding;
@@ -73,6 +73,7 @@ public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuild
7373
private final EntityType<?> entityType;
7474
private final JpqlQueryBuilder.Entity entity;
7575
private final Metamodel metamodel;
76+
private final boolean useNamedParameters;
7677

7778
/**
7879
* Create a new {@link JpaQueryCreator}.
@@ -96,6 +97,23 @@ public JpaQueryCreator(PartTree tree, ReturnedType type, ParameterMetadataProvid
9697
this.tree = tree;
9798
this.returnedType = type;
9899
this.provider = provider;
100+
101+
JpaParameters bindableParameters = provider.getParameters().getBindableParameters();
102+
103+
boolean useNamedParameters = false;
104+
for (JpaParameters.JpaParameter bindableParameter : bindableParameters) {
105+
106+
if (bindableParameter.isNamedParameter()) {
107+
useNamedParameters = true;
108+
}
109+
110+
if (useNamedParameters && !bindableParameter.isNamedParameter()) {
111+
useNamedParameters = false;
112+
break;
113+
}
114+
}
115+
116+
this.useNamedParameters = useNamedParameters;
99117
this.templates = templates;
100118
this.escape = provider.getEscape();
101119
this.entityType = metamodel.entity(type.getDomainType());
@@ -274,11 +292,12 @@ Collection<String> getRequiredSelection(Sort sort, ReturnedType returnedType) {
274292
}
275293

276294
JpqlQueryBuilder.Expression placeholder(ParameterBinding binding) {
277-
return placeholder(binding.getRequiredPosition());
278-
}
279295

280-
JpqlQueryBuilder.Expression placeholder(int position) {
281-
return JpqlQueryBuilder.parameter(ParameterPlaceholder.indexed(position));
296+
if (useNamedParameters && binding.hasName()) {
297+
return JpqlQueryBuilder.parameter(ParameterPlaceholder.named(binding.getRequiredName()));
298+
}
299+
300+
return JpqlQueryBuilder.parameter(ParameterPlaceholder.indexed(binding.getRequiredPosition()));
282301
}
283302

284303
/**

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
import java.util.List;
2323
import java.util.Map;
2424

25-
import org.springframework.data.domain.KeysetScrollPosition;
26-
2725
import org.jspecify.annotations.Nullable;
26+
27+
import org.springframework.data.domain.KeysetScrollPosition;
2828
import org.springframework.data.domain.ScrollPosition.Direction;
2929
import org.springframework.data.domain.Sort;
3030
import org.springframework.data.domain.Sort.Order;
@@ -104,7 +104,7 @@ public static Collection<String> getProjectionInputProperties(JpaEntityInformati
104104
break;
105105
}
106106

107-
sortConstraint.add(strategy.compare(propertyExpression, o));
107+
sortConstraint.add(strategy.compare(inner.getProperty(), propertyExpression, o));
108108
j++;
109109
}
110110

@@ -215,11 +215,12 @@ public interface QueryStrategy<E, P> {
215215
/**
216216
* Create an equals-comparison object.
217217
*
218+
* @param property name of the property.
218219
* @param propertyExpression must not be {@literal null}.
219220
* @param value the value to compare with. Can be {@literal null}.
220221
* @return an object representing the comparison predicate.
221222
*/
222-
P compare(E propertyExpression, @Nullable Object value);
223+
P compare(String property, E propertyExpression, @Nullable Object value);
223224

224225
/**
225226
* AND-combine the {@code intermediate} predicates.

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ public Predicate compare(Order order, Expression<Comparable> propertyExpression,
117117
}
118118

119119
@Override
120-
public Predicate compare(Expression<Comparable> propertyExpression, @Nullable Object value) {
120+
public Predicate compare(String property, Expression<Comparable> propertyExpression, @Nullable Object value) {
121121
return value == null ? cb.isNull(propertyExpression) : cb.equal(propertyExpression, value);
122122
}
123123

@@ -163,15 +163,17 @@ public JpqlQueryBuilder.Predicate compare(Order order, JpqlQueryBuilder.Expressi
163163
if (value == null) {
164164
return order.isAscending() ? where.isNull() : where.isNotNull();
165165
}
166-
return order.isAscending() ? where.gt(factory.capture(value)) : where.lt(factory.capture(value));
166+
return order.isAscending() ? where.gt(factory.capture(order.getProperty(), value))
167+
: where.lt(factory.capture(order.getProperty(), value));
167168
}
168169

169170
@Override
170-
public JpqlQueryBuilder.Predicate compare(JpqlQueryBuilder.Expression propertyExpression, @Nullable Object value) {
171+
public JpqlQueryBuilder.Predicate compare(String property, JpqlQueryBuilder.Expression propertyExpression,
172+
@Nullable Object value) {
171173

172174
JpqlQueryBuilder.WhereStep where = JpqlQueryBuilder.where(propertyExpression);
173175

174-
return value == null ? where.isNull() : where.eq(factory.capture(value));
176+
return value == null ? where.isNull() : where.eq(factory.capture(property, value));
175177
}
176178

177179
@Override
@@ -186,6 +188,6 @@ public JpqlQueryBuilder.Predicate compare(JpqlQueryBuilder.Expression propertyEx
186188
}
187189

188190
public interface ParameterFactory {
189-
JpqlQueryBuilder.Expression capture(Object value);
191+
JpqlQueryBuilder.Expression capture(String name, Object value);
190192
}
191193
}

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ public ParameterOrigin getOrigin() {
8181
return identifier.hasName() ? identifier.getName() : null;
8282
}
8383

84+
/**
85+
* @return {@literal true} if the binding identifier is associated with a name.
86+
* @since 4.0
87+
*/
88+
boolean hasName() {
89+
return identifier.hasName();
90+
}
91+
8492
/**
8593
* @return the name
8694
* @throws IllegalStateException if the name is not available.

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@
2424
import java.util.Collection;
2525
import java.util.Collections;
2626
import java.util.Iterator;
27+
import java.util.LinkedHashSet;
2728
import java.util.List;
29+
import java.util.Set;
2830
import java.util.stream.Collectors;
2931

30-
import org.springframework.data.jpa.provider.PersistenceProvider;
31-
3232
import org.jspecify.annotations.Nullable;
33+
34+
import org.springframework.data.jpa.provider.PersistenceProvider;
3335
import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
3436
import org.springframework.data.repository.query.Parameter;
3537
import org.springframework.data.repository.query.Parameters;
@@ -60,6 +62,7 @@ public class ParameterMetadataProvider {
6062

6163
private final Iterator<? extends Parameter> parameters;
6264
private final List<ParameterBinding> bindings;
65+
private final Set<String> syntheticParameterNames = new LinkedHashSet<>();
6366
private final @Nullable Iterator<Object> bindableParameterValues;
6467
private final EscapeCharacter escape;
6568
private final JpqlQueryTemplates templates;
@@ -176,7 +179,8 @@ private <T> PartTreeParameterBinding next(Part part, Class<T> type, Parameter pa
176179

177180
int currentPosition = ++position;
178181

179-
BindingIdentifier bindingIdentifier = BindingIdentifier.of(currentPosition);
182+
BindingIdentifier bindingIdentifier = parameter.getName().map(it -> BindingIdentifier.of(it, currentPosition))
183+
.orElseGet(() -> BindingIdentifier.of(currentPosition));
180184

181185
/* identifier refers to bindable parameters, not _all_ parameters index */
182186
MethodInvocationArgument methodParameter = ParameterOrigin.ofParameter(bindingIdentifier);
@@ -195,15 +199,24 @@ EscapeCharacter getEscape() {
195199
/**
196200
* Builds a new synthetic {@link ParameterBinding} for the given value.
197201
*
202+
* @param nameHint
198203
* @param value
199204
* @param source
200205
* @return a new {@link ParameterBinding} for the given value and source.
201206
*/
202-
public ParameterBinding nextSynthetic(Object value, Object source) {
207+
public ParameterBinding nextSynthetic(String nameHint, Object value, Object source) {
203208

204209
int currentPosition = ++position;
210+
String bindingName = nameHint;
211+
212+
if (!syntheticParameterNames.add(bindingName)) {
213+
214+
bindingName = bindingName + "_" + currentPosition;
215+
syntheticParameterNames.add(bindingName);
216+
}
205217

206-
return new ParameterBinding(BindingIdentifier.of(currentPosition), ParameterOrigin.synthetic(value, source));
218+
return new ParameterBinding(BindingIdentifier.of(bindingName, currentPosition),
219+
ParameterOrigin.synthetic(value, source));
207220
}
208221

209222
public JpaParameters getParameters() {

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ public BooleanExpression compare(Order order, Expression<?> propertyExpression,
386386
}
387387

388388
@Override
389-
public BooleanExpression compare(Expression<?> propertyExpression, @Nullable Object value) {
389+
public BooleanExpression compare(String property, Expression<?> propertyExpression, @Nullable Object value) {
390390
return Expressions.booleanOperation(Ops.EQ, propertyExpression,
391391
value == null ? NullExpression.DEFAULT : ConstantImpl.create(value));
392392
}

spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,8 @@
1515
*/
1616
package org.springframework.data.jpa.repository;
1717

18-
import static org.assertj.core.api.Assertions.assertThat;
19-
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
20-
import static org.springframework.data.domain.Sort.Direction.ASC;
21-
import static org.springframework.data.domain.Sort.Direction.DESC;
18+
import static org.assertj.core.api.Assertions.*;
19+
import static org.springframework.data.domain.Sort.Direction.*;
2220

2321
import jakarta.persistence.EntityManager;
2422

@@ -33,6 +31,7 @@
3331
import org.junit.jupiter.api.extension.ExtendWith;
3432
import org.junit.jupiter.params.ParameterizedTest;
3533
import org.junit.jupiter.params.provider.ValueSource;
34+
3635
import org.springframework.beans.factory.annotation.Autowired;
3736
import org.springframework.dao.InvalidDataAccessApiUsageException;
3837
import org.springframework.data.domain.Limit;
@@ -495,6 +494,14 @@ void dtoProjectionWithEntityAndAggregatedValueWithPageable() {
495494
});
496495
}
497496

497+
@Test // GH-3857
498+
void shouldApplyParameterNames() {
499+
500+
assertThat(userRepository.findAnnotatedWithParameterNameQuery(oliver.getLastname())).hasSize(2);
501+
assertThat(userRepository.findWithParameterNameByLastnameStartingWithOrLastnameEndingWith(oliver.getLastname(),
502+
oliver.getLastname())).hasSize(2);
503+
}
504+
498505
@ParameterizedTest // GH-3076
499506
@ValueSource(classes = { UserRoleCountDtoProjection.class, UserRoleCountInterfaceProjection.class })
500507
<T> void dynamicProjectionWithEntityAndAggregated(Class<T> resultType) {

spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,13 @@ void testPagingAnnotatedQueryWithSort() {
320320
321321
}
322322

323+
@Test // GH-3857
324+
void appliesCustomParameterNaming() {
325+
326+
assertThat(fragment.findAnnotatedWithParameterNameQuery("S")).hasSize(4);
327+
assertThat(fragment.findWithParameterNameByLastnameStartingWithOrLastnameEndingWith("S", "S")).hasSize(4);
328+
}
329+
323330
@Test // GH-3830
324331
void testAnnotatedFinderReturningSlice() {
325332

spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryMetadataIntegrationTests.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
import org.springframework.transaction.annotation.Transactional;
3333

3434
/**
35-
* Integration tests for the {@link UserRepository} JSON metadata.
35+
* Integration tests for the {@link UserRepository} JSON metadata via {@link JpaRepositoryContributor}.
3636
*
3737
* @author Mark Paluch
3838
*/
@@ -77,7 +77,7 @@ void shouldDocumentDerivedQuery() throws IOException {
7777

7878
assertThatJson(json).inPath("$.methods[0]").isObject().containsEntry("name", "countUsersByLastname");
7979
assertThatJson(json).inPath("$.methods[0].query").isObject().containsEntry("query",
80-
"SELECT COUNT(u) FROM org.springframework.data.jpa.domain.sample.User u WHERE u.lastname = ?1");
80+
"SELECT COUNT(u) FROM org.springframework.data.jpa.domain.sample.User u WHERE u.lastname = :lastname");
8181
}
8282

8383
@Test // GH-3830

spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,16 @@ interface UserRepository extends CrudRepository<User, Integer> {
116116
@Query("select u from User u where u.lastname like ?1%")
117117
Slice<User> findAnnotatedQuerySliceOfUsersByLastname(String lastname, Pageable pageable);
118118

119+
// -------------------------------------------------------------------------
120+
// Projections: Parameter naming
121+
// -------------------------------------------------------------------------
122+
123+
@Query("select u from User u where u.lastname like %:name or u.lastname like :name% ORDER BY u.lastname")
124+
List<User> findAnnotatedWithParameterNameQuery(@Param("name") String lastname);
125+
126+
List<User> findWithParameterNameByLastnameStartingWithOrLastnameEndingWith(@Param("l1") String l1,
127+
@Param("l2") String l2);
128+
119129
// -------------------------------------------------------------------------
120130
// Value Expressions
121131
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)