diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaCodeBlocks.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaCodeBlocks.java index f9c9b45e6..fe0a84eaf 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaCodeBlocks.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaCodeBlocks.java @@ -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) { 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 1acb62d76..776657b2a 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 @@ -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> cachedBindings = new LinkedHashMap<>(); + JpqlQueryBuilder.Predicate keysetPredicate = keysetSpec.createJpqlPredicate(getFrom(), getEntity(), + (property, value) -> { + + Map 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) { 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 3eec07e41..c49baf6ff 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 @@ -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 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(KeysetScrollPosition position, Sort s } @Override - public Predicate compare(Expression propertyExpression, @Nullable Object value) { + public Predicate compare(String property, Expression propertyExpression, @Nullable Object value) { return value == null ? cb.isNull(propertyExpression) : cb.equal(propertyExpression, value); } @@ -163,15 +163,17 @@ public record KeysetScrollSpecification(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(KeysetScrollPosition position, Sort s } public interface ParameterFactory { - JpqlQueryBuilder.Expression capture(Object value); + JpqlQueryBuilder.Expression capture(String name, Object value); } } 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 b06b0f971..040e84a8e 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 @@ -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. 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 5071e23ff..72d43ab5b 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 @@ -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 parameters; private final List bindings; + private final Set syntheticParameterNames = new LinkedHashSet<>(); private final @Nullable Iterator 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() { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java index 8881ab84c..0bbcee84b 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java @@ -386,7 +386,7 @@ public class QuerydslJpaPredicateExecutor 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)); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java index 7eb4e4cc6..46721b1df 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java @@ -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 }) void dynamicProjectionWithEntityAndAggregated(Class resultType) { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java index 566a08cb7..0d649778e 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java @@ -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() { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryMetadataIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryMetadataIntegrationTests.java index 0cdde1ef0..3450bcf1a 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryMetadataIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryMetadataIntegrationTests.java @@ -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 diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java index b95cd8837..7279abf7d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java @@ -116,6 +116,16 @@ interface UserRepository extends CrudRepository { @Query("select u from User u where u.lastname like ?1%") Slice 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 findAnnotatedWithParameterNameQuery(@Param("name") String lastname); + + List findWithParameterNameByLastnameStartingWithOrLastnameEndingWith(@Param("l1") String l1, + @Param("l2") String l2); + // ------------------------------------------------------------------------- // Value Expressions // ------------------------------------------------------------------------- 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 index d8bfd1fdb..dd180bab5 100644 --- 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 @@ -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 """); } 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 beb8e68a7..81e454c79 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 @@ -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); } 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 b99e50071..02d63e677 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 @@ -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) })); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java index 1833a1035..2fc34657f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java @@ -768,6 +768,12 @@ public interface UserRepository extends JpaRepository, JpaSpecifi Window findBy(OffsetScrollPosition position); + @Query("select u from User u where u.lastname like %:name or u.lastname like :name% ORDER BY u.lastname") + List findAnnotatedWithParameterNameQuery(@Param("name") String lastname); + + List 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 {