From f2541b376ff88237575acdc28ae884ec6c4594a2 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 4 Nov 2025 10:11:32 +0100 Subject: [PATCH] Skip conversion of placeholders during AOT processing of derived queries. We now bypass the converter used for AOT query mapping in derived queries to avoid conversion of placeholders in the AOT query. Closes #2174 --- .../jdbc/repository/aot/QueriesFactory.java | 105 +++++++++++++++++- ...RepositoryContributorIntegrationTests.java | 43 ++++++- ...dbcRepositoryMetadataIntegrationTests.java | 14 ++- .../data/jdbc/repository/aot/User.java | 24 ++++ .../jdbc/repository/aot/UserRepository.java | 11 ++ ...positoryContributorIntegrationTests-h2.sql | 4 +- 6 files changed, 191 insertions(+), 10 deletions(-) diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/QueriesFactory.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/QueriesFactory.java index 877d6f8bf..1115d8302 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/QueriesFactory.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/QueriesFactory.java @@ -16,6 +16,7 @@ package org.springframework.data.jdbc.repository.aot; import java.io.IOException; +import java.sql.SQLType; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -24,13 +25,17 @@ import java.util.Properties; import org.jspecify.annotations.Nullable; import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.convert.ConversionService; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.data.core.TypeInformation; import org.springframework.data.domain.Sort; +import org.springframework.data.jdbc.core.convert.Identifier; import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.jdbc.core.convert.JdbcCustomConversions; import org.springframework.data.jdbc.core.convert.JdbcTypeFactory; import org.springframework.data.jdbc.core.convert.MappingJdbcConverter; import org.springframework.data.jdbc.core.dialect.JdbcDialect; +import org.springframework.data.jdbc.core.mapping.JdbcValue; import org.springframework.data.jdbc.repository.config.JdbcRepositoryConfigExtension; import org.springframework.data.jdbc.repository.query.JdbcCountQueryCreator; import org.springframework.data.jdbc.repository.query.JdbcParameters; @@ -39,7 +44,13 @@ import org.springframework.data.jdbc.repository.query.JdbcQueryMethod; import org.springframework.data.jdbc.repository.query.ParameterBinding; import org.springframework.data.jdbc.repository.query.ParametrizedQuery; import org.springframework.data.jdbc.repository.query.Query; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentPropertyPathAccessor; +import org.springframework.data.mapping.model.EntityInstantiators; +import org.springframework.data.projection.EntityProjection; import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.domain.RowDocument; import org.springframework.data.relational.repository.query.ParameterMetadataProvider; import org.springframework.data.relational.repository.query.RelationalParameterAccessor; import org.springframework.data.relational.repository.query.RelationalParameters; @@ -161,7 +172,8 @@ class QueriesFactory { PartTree partTree = new PartTree(queryMethod.getName(), repositoryInformation.getDomainType()); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod); - JdbcQueryCreator queryCreator = new JdbcQueryCreator(partTree, converter, dialect, queryMethod, accessor, + JdbcQueryCreator queryCreator = new JdbcQueryCreator(partTree, new AotPassThruJdbcConverter(converter), dialect, + queryMethod, accessor, returnedType) { @Override @@ -226,4 +238,95 @@ class QueriesFactory { return bindings; } + /** + * Pass-thru implementation for {@link JdbcValue} objects to allow capturing parameter placeholders without applying + * conversion. + * + * @param delegate + */ + record AotPassThruJdbcConverter(JdbcConverter delegate) implements JdbcConverter { + + @Override + public Class getColumnType(RelationalPersistentProperty property) { + return delegate.getColumnType(property); + } + + @Override + public SQLType getTargetSqlType(RelationalPersistentProperty property) { + return delegate.getTargetSqlType(property); + } + + @Override + public RelationalMappingContext getMappingContext() { + return delegate.getMappingContext(); + } + + @Override + public ConversionService getConversionService() { + return delegate.getConversionService(); + } + + @Override + public EntityInstantiators getEntityInstantiators() { + return delegate.getEntityInstantiators(); + } + + @Override + public PersistentPropertyPathAccessor getPropertyAccessor(PersistentEntity persistentEntity, + T instance) { + return delegate.getPropertyAccessor(persistentEntity, instance); + } + + @Override + public JdbcValue writeJdbcValue(@Nullable Object value, Class type, SQLType sqlType) { + return value instanceof JdbcValue jdbcValue ? jdbcValue : delegate.writeJdbcValue(value, type, sqlType); + } + + @Override + public JdbcValue writeJdbcValue(@Nullable Object value, TypeInformation type, SQLType sqlType) { + return value instanceof JdbcValue jdbcValue ? jdbcValue : delegate.writeJdbcValue(value, type, sqlType); + } + + @Override + public @Nullable Object writeValue(@Nullable Object value, TypeInformation type) { + return value; + } + + @Override + public R readAndResolve(Class type, RowDocument source) { + throw new UnsupportedOperationException(); + } + + @Override + public R readAndResolve(Class type, RowDocument source, Identifier identifier) { + throw new UnsupportedOperationException(); + } + + @Override + public R readAndResolve(TypeInformation type, RowDocument source, Identifier identifier) { + throw new UnsupportedOperationException(); + } + + @Override + public EntityProjection introspectProjection(Class resultType, Class entityType) { + throw new UnsupportedOperationException(); + } + + @Override + public R project(EntityProjection descriptor, RowDocument document) { + throw new UnsupportedOperationException(); + } + + @Override + public R read(Class type, RowDocument source) { + throw new UnsupportedOperationException(); + } + + @Override + public @Nullable Object readValue(@Nullable Object value, TypeInformation type) { + throw new UnsupportedOperationException(); + } + + } + } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryContributorIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryContributorIntegrationTests.java index 9d874e973..b05dab742 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryContributorIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryContributorIntegrationTests.java @@ -17,6 +17,7 @@ package org.springframework.data.jdbc.repository.aot; import static org.assertj.core.api.Assertions.*; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -38,6 +39,7 @@ import org.springframework.data.domain.Sort; import org.springframework.data.jdbc.core.JdbcAggregateOperations; import org.springframework.data.jdbc.core.convert.QueryMappingConfiguration; import org.springframework.data.jdbc.core.dialect.JdbcH2Dialect; +import org.springframework.data.jdbc.core.mapping.AggregateReference; import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories; import org.springframework.data.jdbc.repository.support.BeanFactoryAwareRowMapperFactory; import org.springframework.data.jdbc.testing.DatabaseType; @@ -102,8 +104,12 @@ class JdbcRepositoryContributorIntegrationTests { operations.insert(new User("Walter", 52)); operations.insert(new User("Skyler", 40)); operations.insert(new User("Flynn", 16)); - operations.insert(new User("Mike", 62)); - operations.insert(new User("Gustavo", 51)); + User mike = operations.insert(new User("Mike", 62)); + User gus = operations.insert(new User("Gustavo", 51)); + + mike.setFriend(AggregateReference.to(gus.getId())); + operations.save(mike); + operations.insert(new User("Hector", 83)); } @@ -158,6 +164,22 @@ class JdbcRepositoryContributorIntegrationTests { assertThat(walter).isNull(); // % is escaped } + @Test // GH-2174 + void shouldSupportDerivedQueryWithConverter() { + + List users = fragment.findByCreatedBefore(Instant.now().plusSeconds(180)); + + assertThat(users).hasSize(6); + } + + @Test // GH-2174 + void shouldSupportDerivedQueryBetweenWithConverter() { + + List users = fragment.findByCreatedBetween(Instant.now().minusSeconds(180), Instant.now().plusSeconds(180)); + + assertThat(users).hasSize(6); + } + @Test // GH-2121 void shouldFindBetween() { @@ -283,6 +305,23 @@ class JdbcRepositoryContributorIntegrationTests { assertThat(result).isOne(); } + @Test // GH-2174 + void shouldSupportDeclaredQueryWithConverter() { + + List users = fragment.findCreatedBefore(Instant.now().plusSeconds(180)); + + assertThat(users).hasSize(6); + } + + @Test // GH-2174 + void shouldSupportDeclaredQueryWithAggregateReference() { + + User gus = fragment.findByFirstname("Gustavo"); + List users = fragment.findByFriend(AggregateReference.to(gus.getId())); + + assertThat(users).hasSize(1); + } + @Test // GH-2121 void shouldProjectOneToDto() { diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryMetadataIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryMetadataIntegrationTests.java index d41d22879..6c0ab4e74 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryMetadataIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryMetadataIntegrationTests.java @@ -123,8 +123,8 @@ class JdbcRepositoryMetadataIntegrationTests { String json = resource.getContentAsString(StandardCharsets.UTF_8); assertThatJson(json).inPath("$.methods[?(@.name == 'findByFirstname')].query").isArray().first().isObject() - .containsEntry("query", - "SELECT \"MY_USER\".\"ID\" AS \"ID\", \"MY_USER\".\"AGE\" AS \"AGE\", \"MY_USER\".\"FIRSTNAME\" AS \"FIRSTNAME\" FROM \"MY_USER\" WHERE \"MY_USER\".\"FIRSTNAME\" = :firstname"); + .hasEntrySatisfying("query", value -> assertThat(value).asString().contains("SELECT \"MY_USER\".\"ID\"", + "FROM \"MY_USER\" WHERE \"MY_USER\".\"FIRSTNAME\" = :firstname")); } @Test // GH-2121 @@ -139,8 +139,9 @@ class JdbcRepositoryMetadataIntegrationTests { assertThatJson(json) .inPath("$.methods[?(@.name == 'findWithParameterNameByFirstnameStartingWithOrFirstnameEndingWith')].query") - .isArray().first().isObject().containsEntry("query", - "SELECT \"MY_USER\".\"ID\" AS \"ID\", \"MY_USER\".\"AGE\" AS \"AGE\", \"MY_USER\".\"FIRSTNAME\" AS \"FIRSTNAME\" FROM \"MY_USER\" WHERE \"MY_USER\".\"FIRSTNAME\" LIKE :firstname OR (\"MY_USER\".\"FIRSTNAME\" LIKE :firstname1)"); + .isArray().first().isObject() + .hasEntrySatisfying("query", value -> assertThat(value).asString().contains("SELECT \"MY_USER\".\"ID\"", + "FROM \"MY_USER\" WHERE \"MY_USER\".\"FIRSTNAME\" LIKE :firstname OR (\"MY_USER\".\"FIRSTNAME\" LIKE :firstname1)")); } @Test // GH-2121 @@ -155,8 +156,9 @@ class JdbcRepositoryMetadataIntegrationTests { assertThatJson(json).inPath("$.methods[?(@.name == 'findPageByAgeGreaterThan')].query").isArray().element(0) .isObject() - .containsEntry("query", - "SELECT \"MY_USER\".\"ID\" AS \"ID\", \"MY_USER\".\"AGE\" AS \"AGE\", \"MY_USER\".\"FIRSTNAME\" AS \"FIRSTNAME\" FROM \"MY_USER\" WHERE \"MY_USER\".\"AGE\" > :age") + .hasEntrySatisfying("query", + value -> assertThat(value).asString().contains("SELECT \"MY_USER\".\"ID\"", + "FROM \"MY_USER\" WHERE \"MY_USER\".\"AGE\" > :age")) .containsEntry("count-query", "SELECT COUNT(*) FROM \"MY_USER\" WHERE \"MY_USER\".\"AGE\" > :age"); } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/User.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/User.java index 7497b6af2..0a985bc6c 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/User.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/User.java @@ -15,7 +15,12 @@ */ package org.springframework.data.jdbc.repository.aot; +import java.time.Instant; + +import org.jspecify.annotations.Nullable; + import org.springframework.data.annotation.Id; +import org.springframework.data.jdbc.core.mapping.AggregateReference; import org.springframework.data.relational.core.mapping.Table; /** @@ -27,6 +32,8 @@ public class User { private @Id long id; private String firstname; private int age; + private Instant created = Instant.now(); + private @Nullable AggregateReference friend; public User(String firstname, int age) { this.firstname = firstname; @@ -56,4 +63,21 @@ public class User { public void setAge(int age) { this.age = age; } + + public Instant getCreated() { + return created; + } + + public void setCreated(Instant created) { + this.created = created; + } + + public @Nullable AggregateReference getFriend() { + return friend; + } + + public void setFriend(AggregateReference friend) { + this.friend = friend; + } + } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/UserRepository.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/UserRepository.java index 9362a44d8..5d1ace2b1 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/UserRepository.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/UserRepository.java @@ -15,6 +15,7 @@ */ package org.springframework.data.jdbc.repository.aot; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.stream.Stream; @@ -22,6 +23,7 @@ import java.util.stream.Stream; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import org.springframework.data.jdbc.core.mapping.AggregateReference; import org.springframework.data.jdbc.repository.query.Modifying; import org.springframework.data.jdbc.repository.query.Query; import org.springframework.data.repository.CrudRepository; @@ -41,6 +43,12 @@ public interface UserRepository extends CrudRepository { User findByFirstnameEndingWith(String name); + List findByCreatedBefore(Instant instant); + + List findByCreatedBetween(Instant from, Instant to); + + List findByFriend(AggregateReference friend); + List findAllByAgeBetween(int start, int end); Optional findOptionalByFirstname(String name); @@ -86,6 +94,9 @@ public interface UserRepository extends CrudRepository { @Query(value = "SELECT * FROM MY_USER WHERE firstname = :name", resultSetExtractorRef = "simpleResultSetExtractor") int findUsingAndResultSetExtractorRef(String name); + @Query(value = "SELECT * FROM MY_USER WHERE created < :instant") + List findCreatedBefore(Instant instant); + // ------------------------------------------------------------------------- // Parameter naming // ------------------------------------------------------------------------- diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.aot/JdbcRepositoryContributorIntegrationTests-h2.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.aot/JdbcRepositoryContributorIntegrationTests-h2.sql index ef373c709..a71a6853c 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.aot/JdbcRepositoryContributorIntegrationTests-h2.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.aot/JdbcRepositoryContributorIntegrationTests-h2.sql @@ -2,5 +2,7 @@ CREATE TABLE MY_USER ( id BIGINT GENERATED BY DEFAULT AS IDENTITY ( START WITH 1 ), firstname VARCHAR(255), - age INT + age INT, + created TIMESTAMP, + friend BIGINT NULL );