diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryMethod.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryMethod.java index 0bca96a88f9..6747a7d2679 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryMethod.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryMethod.java @@ -128,7 +128,7 @@ String getDeclaredQuery() { return StringUtils.hasText(annotatedValue) ? annotatedValue : getNamedQuery(); } - String getRequiredQuery() { + public String getRequiredQuery() { String query = getDeclaredQuery(); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StringBasedJdbcQuery.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StringBasedJdbcQuery.java index c41031c7a79..6949ea3681c 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StringBasedJdbcQuery.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StringBasedJdbcQuery.java @@ -35,6 +35,7 @@ import org.springframework.data.jdbc.core.mapping.JdbcValue; import org.springframework.data.jdbc.support.JdbcUtil; import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.repository.query.QueryPreprocessor; import org.springframework.data.relational.repository.query.RelationalParameterAccessor; import org.springframework.data.relational.repository.query.RelationalParametersParameterAccessor; import org.springframework.data.repository.query.Parameter; @@ -103,11 +104,33 @@ public StringBasedJdbcQuery(JdbcQueryMethod queryMethod, NamedParameterJdbcOpera * @param queryMethod must not be {@literal null}. * @param operations must not be {@literal null}. * @param rowMapperFactory must not be {@literal null}. + * @param converter must not be {@literal null}. + * @param evaluationContextProvider must not be {@literal null}. * @since 2.3 + * @deprecated use alternative constructor */ + @Deprecated(since = "3.4") public StringBasedJdbcQuery(JdbcQueryMethod queryMethod, NamedParameterJdbcOperations operations, RowMapperFactory rowMapperFactory, JdbcConverter converter, QueryMethodEvaluationContextProvider evaluationContextProvider) { + this(queryMethod, operations, rowMapperFactory, converter, evaluationContextProvider, QueryPreprocessor.NOOP.transform(queryMethod.getRequiredQuery())); + } + + /** + * Creates a new {@link StringBasedJdbcQuery} for the given {@link JdbcQueryMethod}, {@link RelationalMappingContext} + * and {@link RowMapperFactory}. + * + * @param queryMethod must not be {@literal null}. + * @param operations must not be {@literal null}. + * @param rowMapperFactory must not be {@literal null}. + * @param converter must not be {@literal null}. + * @param evaluationContextProvider must not be {@literal null}. + * @param query + * @since 3.4 + */ + public StringBasedJdbcQuery(JdbcQueryMethod queryMethod, NamedParameterJdbcOperations operations, + RowMapperFactory rowMapperFactory, JdbcConverter converter, + QueryMethodEvaluationContextProvider evaluationContextProvider, String query) { super(queryMethod, operations); @@ -116,6 +139,7 @@ public StringBasedJdbcQuery(JdbcQueryMethod queryMethod, NamedParameterJdbcOpera this.converter = converter; this.rowMapperFactory = rowMapperFactory; + if (queryMethod.isSliceQuery()) { throw new UnsupportedOperationException( "Slice queries are not supported using string-based queries; Offending method: " + queryMethod); @@ -140,9 +164,9 @@ public StringBasedJdbcQuery(JdbcQueryMethod queryMethod, NamedParameterJdbcOpera .of((counter, expression) -> String.format("__$synthetic$__%d", counter + 1), String::concat) .withEvaluationContextProvider(evaluationContextProvider); - this.query = queryMethod.getRequiredQuery(); - this.spelEvaluator = queryContext.parse(query, getQueryMethod().getParameters()); - this.containsSpelExpressions = !this.spelEvaluator.getQueryString().equals(query); + this.query = query; + this.spelEvaluator = queryContext.parse(this.query, getQueryMethod().getParameters()); + this.containsSpelExpressions = !this.spelEvaluator.getQueryString().equals(this.query); } @Override diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcQueryLookupStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcQueryLookupStrategy.java index 0a4ee147687..999481d57bb 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcQueryLookupStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcQueryLookupStrategy.java @@ -36,6 +36,7 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.event.AfterConvertCallback; import org.springframework.data.relational.core.mapping.event.AfterConvertEvent; +import org.springframework.data.relational.repository.support.RelationalQueryLookupStrategy; import org.springframework.data.repository.core.NamedQueries; import org.springframework.data.repository.core.RepositoryMetadata; import org.springframework.data.repository.query.QueryLookupStrategy; @@ -60,7 +61,7 @@ * @author Diego Krupitza * @author Christopher Klein */ -abstract class JdbcQueryLookupStrategy implements QueryLookupStrategy { +abstract class JdbcQueryLookupStrategy extends RelationalQueryLookupStrategy { private static final Log LOG = LogFactory.getLog(JdbcQueryLookupStrategy.class); @@ -79,8 +80,10 @@ abstract class JdbcQueryLookupStrategy implements QueryLookupStrategy { QueryMappingConfiguration queryMappingConfiguration, NamedParameterJdbcOperations operations, @Nullable BeanFactory beanfactory, QueryMethodEvaluationContextProvider evaluationContextProvider) { + super(context, dialect); + Assert.notNull(publisher, "ApplicationEventPublisher must not be null"); - Assert.notNull(context, "RelationalMappingContextPublisher must not be null"); + Assert.notNull(context, "RelationalMappingContext must not be null"); Assert.notNull(converter, "JdbcConverter must not be null"); Assert.notNull(dialect, "Dialect must not be null"); Assert.notNull(queryMappingConfiguration, "QueryMappingConfiguration must not be null"); @@ -156,8 +159,10 @@ public RepositoryQuery resolveQuery(Method method, RepositoryMetadata repository "Query method %s is annotated with both, a query and a query name; Using the declared query", method)); } + String queryString = evaluateTableExpressions(repositoryMetadata, queryMethod.getRequiredQuery()); + StringBasedJdbcQuery query = new StringBasedJdbcQuery(queryMethod, getOperations(), this::createMapper, - getConverter(), evaluationContextProvider); + getConverter(), evaluationContextProvider, queryString); query.setBeanFactory(getBeanFactory()); return query; } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/DeclaredQueryRepositoryUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/DeclaredQueryRepositoryUnitTests.java new file mode 100644 index 00000000000..6df361b7aa4 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/DeclaredQueryRepositoryUnitTests.java @@ -0,0 +1,142 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.jdbc.repository; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.annotation.Id; +import org.springframework.data.jdbc.core.convert.DataAccessStrategy; +import org.springframework.data.jdbc.core.convert.DefaultJdbcTypeFactory; +import org.springframework.data.jdbc.core.convert.DelegatingDataAccessStrategy; +import org.springframework.data.jdbc.core.convert.JdbcConverter; +import org.springframework.data.jdbc.core.convert.JdbcCustomConversions; +import org.springframework.data.jdbc.core.convert.MappingJdbcConverter; +import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; +import org.springframework.data.jdbc.repository.query.Query; +import org.springframework.data.jdbc.repository.support.JdbcRepositoryFactory; +import org.springframework.data.relational.core.dialect.Dialect; +import org.springframework.data.relational.core.dialect.HsqlDbDialect; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.core.mapping.Table; +import org.springframework.data.repository.CrudRepository; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.lang.Nullable; + +/** + * Extracts the SQL statement that results from declared queries of a repository and perform assertions on it. + * + * @author Jens Schauder + */ +public class DeclaredQueryRepositoryUnitTests { + + private NamedParameterJdbcOperations operations = mock(NamedParameterJdbcOperations.class, RETURNS_DEEP_STUBS); + + @Test // GH-1856 + void plainSql() { + + repository(DummyEntityRepository.class).plainQuery(); + + assertThat(query()).isEqualTo("select * from someTable"); + } + + @Test // GH-1856 + void tableNameQuery() { + + repository(DummyEntityRepository.class).tableNameQuery(); + + assertThat(query()).isEqualTo("select * from \"DUMMY_ENTITY\""); + } + + @Test // GH-1856 + void renamedTableNameQuery() { + + repository(RenamedEntityRepository.class).tableNameQuery(); + + assertThat(query()).isEqualTo("select * from \"ReNamed\""); + } + + @Test // GH-1856 + void fullyQualifiedTableNameQuery() { + + repository(RenamedEntityRepository.class).qualifiedTableNameQuery(); + + assertThat(query()).isEqualTo("select * from \"someSchema\".\"ReNamed\""); + } + + private String query() { + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + verify(operations).queryForObject(queryCaptor.capture(), any(SqlParameterSource.class), any(RowMapper.class)); + return queryCaptor.getValue(); + } + + private @NotNull T repository(Class repositoryInterface) { + + Dialect dialect = HsqlDbDialect.INSTANCE; + + RelationalMappingContext context = new JdbcMappingContext(); + + DelegatingDataAccessStrategy delegatingDataAccessStrategy = new DelegatingDataAccessStrategy(); + JdbcConverter converter = new MappingJdbcConverter(context, delegatingDataAccessStrategy, + new JdbcCustomConversions(), new DefaultJdbcTypeFactory(operations.getJdbcOperations())); + + DataAccessStrategy dataAccessStrategy = mock(DataAccessStrategy.class); + ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class); + + JdbcRepositoryFactory factory = new JdbcRepositoryFactory(dataAccessStrategy, context, converter, dialect, + publisher, operations); + + return factory.getRepository(repositoryInterface); + } + + @Table + record DummyEntity(@Id Long id, String name) { + } + + interface DummyEntityRepository extends CrudRepository { + + @Nullable + @Query("select * from someTable") + DummyEntity plainQuery(); + + @Nullable + @Query("select * from #{#tableName}") + DummyEntity tableNameQuery(); + } + + @Table(name = "ReNamed", schema = "someSchema") + record RenamedEntity(@Id Long id, String name) { + } + + interface RenamedEntityRepository extends CrudRepository { + + @Nullable + @Query("select * from #{#tableName}") + DummyEntity tableNameQuery(); + + @Nullable + @Query("select * from #{#qualifiedTableName}") + DummyEntity qualifiedTableNameQuery(); + } +} diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/DefaultReactiveDataAccessStrategy.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/DefaultReactiveDataAccessStrategy.java index c2be43faaf9..e393af023b5 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/DefaultReactiveDataAccessStrategy.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/DefaultReactiveDataAccessStrategy.java @@ -41,6 +41,7 @@ import org.springframework.data.r2dbc.query.UpdateMapper; import org.springframework.data.r2dbc.support.ArrayUtils; import org.springframework.data.relational.core.dialect.ArrayColumns; +import org.springframework.data.relational.core.dialect.Dialect; import org.springframework.data.relational.core.dialect.RenderContextFactory; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; @@ -310,6 +311,14 @@ public String renderForGeneratedValues(SqlIdentifier identifier) { return dialect.renderForGeneratedValues(identifier); } + /** + * @since 3.4 + */ + @Override + public Dialect getDialect() { + return dialect; + } + private RelationalPersistentEntity getRequiredPersistentEntity(Class typeToRead) { return this.mappingContext.getRequiredPersistentEntity(typeToRead); } diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/ReactiveDataAccessStrategy.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/ReactiveDataAccessStrategy.java index 7520c7d11e6..b36002fb901 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/ReactiveDataAccessStrategy.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/ReactiveDataAccessStrategy.java @@ -25,6 +25,8 @@ import org.springframework.data.r2dbc.convert.R2dbcConverter; import org.springframework.data.r2dbc.mapping.OutboundRow; +import org.springframework.data.relational.core.dialect.AnsiDialect; +import org.springframework.data.relational.core.dialect.Dialect; import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.domain.RowDocument; @@ -154,6 +156,14 @@ default String renderForGeneratedValues(SqlIdentifier identifier) { return identifier.toSql(IdentifierProcessing.NONE); } + /** + * @return the {@link Dialect} used by this strategy. + * @since 3.4 + */ + default Dialect getDialect() { + return AnsiDialect.INSTANCE; + } + /** * Interface to retrieve parameters for named parameter processing. */ diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactory.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactory.java index 376f6054c87..d3299a29a7d 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactory.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactory.java @@ -32,6 +32,7 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.repository.query.RelationalEntityInformation; import org.springframework.data.relational.repository.support.MappingRelationalEntityInformation; +import org.springframework.data.relational.repository.support.RelationalQueryLookupStrategy; import org.springframework.data.repository.core.NamedQueries; import org.springframework.data.repository.core.RepositoryInformation; import org.springframework.data.repository.core.RepositoryMetadata; @@ -51,6 +52,7 @@ * Factory to create {@link R2dbcRepository} instances. * * @author Mark Paluch + * @author Jens Schauder */ public class R2dbcRepositoryFactory extends ReactiveRepositoryFactorySupport { @@ -139,8 +141,9 @@ private RelationalEntityInformation getEntityInformation(Class * {@link QueryLookupStrategy} to create R2DBC queries.. * * @author Mark Paluch + * @author Jens Schauder */ - private static class R2dbcQueryLookupStrategy implements QueryLookupStrategy { + private static class R2dbcQueryLookupStrategy extends RelationalQueryLookupStrategy { private final R2dbcEntityOperations entityOperations; private final ReactiveQueryMethodEvaluationContextProvider evaluationContextProvider; @@ -151,30 +154,34 @@ private static class R2dbcQueryLookupStrategy implements QueryLookupStrategy { R2dbcQueryLookupStrategy(R2dbcEntityOperations entityOperations, ReactiveQueryMethodEvaluationContextProvider evaluationContextProvider, R2dbcConverter converter, ReactiveDataAccessStrategy dataAccessStrategy) { + + super(converter.getMappingContext(), dataAccessStrategy.getDialect()); + this.entityOperations = entityOperations; this.evaluationContextProvider = evaluationContextProvider; this.converter = converter; this.dataAccessStrategy = dataAccessStrategy; - } @Override public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory, NamedQueries namedQueries) { + MappingContext, ? extends RelationalPersistentProperty> mappingContext = this.converter.getMappingContext(); + R2dbcQueryMethod queryMethod = new R2dbcQueryMethod(method, metadata, factory, - this.converter.getMappingContext()); + mappingContext); String namedQueryName = queryMethod.getNamedQueryName(); - if (namedQueries.hasQuery(namedQueryName)) { - String namedQuery = namedQueries.getQuery(namedQueryName); - return new StringBasedR2dbcQuery(namedQuery, queryMethod, this.entityOperations, this.converter, + if (namedQueries.hasQuery(namedQueryName) || queryMethod.hasAnnotatedQuery()) { + + String query = namedQueries.hasQuery(namedQueryName) ? namedQueries.getQuery(namedQueryName) : queryMethod.getRequiredAnnotatedQuery(); + query = evaluateTableExpressions(metadata, query); + + return new StringBasedR2dbcQuery(query, queryMethod, this.entityOperations, this.converter, this.dataAccessStrategy, parser, this.evaluationContextProvider); - } else if (queryMethod.hasAnnotatedQuery()) { - return new StringBasedR2dbcQuery(queryMethod, this.entityOperations, this.converter, this.dataAccessStrategy, - this.parser, - this.evaluationContextProvider); + } else { return new PartTreeR2dbcQuery(queryMethod, this.entityOperations, this.converter, this.dataAccessStrategy); } diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/documentation/PersonRepository.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/documentation/PersonRepository.java index 815bf1f03f5..e1879f5089f 100644 --- a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/documentation/PersonRepository.java +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/documentation/PersonRepository.java @@ -34,6 +34,11 @@ public interface PersonRepository extends ReactiveCrudRepository // tag::spel[] @Query("SELECT * FROM person WHERE lastname = :#{[0]}") - Flux findByQueryWithExpression(String lastname); + Flux findByQueryWithParameterExpression(String lastname); // end::spel[] + + // tag::spel2[] + @Query("SELECT * FROM #{tableName} WHERE lastname = :lastname") + Flux findByQueryWithExpression(String lastname); + // end::spel2[] } diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactoryUnitTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactoryUnitTests.java index 990b3085bc6..380b3039b88 100644 --- a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactoryUnitTests.java +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactoryUnitTests.java @@ -29,6 +29,7 @@ import org.springframework.data.r2dbc.convert.R2dbcConverter; import org.springframework.data.r2dbc.core.ReactiveDataAccessStrategy; import org.springframework.data.r2dbc.mapping.R2dbcMappingContext; +import org.springframework.data.relational.core.dialect.AnsiDialect; import org.springframework.data.relational.repository.query.RelationalEntityInformation; import org.springframework.data.relational.repository.support.MappingRelationalEntityInformation; import org.springframework.data.repository.Repository; @@ -38,6 +39,7 @@ * Unit test for {@link R2dbcRepositoryFactory}. * * @author Mark Paluch + * @author Jens Schauder */ @ExtendWith(MockitoExtension.class) public class R2dbcRepositoryFactoryUnitTests { @@ -50,6 +52,7 @@ public class R2dbcRepositoryFactoryUnitTests { @BeforeEach @SuppressWarnings("unchecked") public void before() { + when(dataAccessStrategy.getConverter()).thenReturn(r2dbcConverter); } @@ -65,6 +68,8 @@ public void usesMappingRelationalEntityInformationIfMappingContextSet() { @Test public void createsRepositoryWithIdTypeLong() { + when(dataAccessStrategy.getDialect()).thenReturn(AnsiDialect.INSTANCE); + R2dbcRepositoryFactory factory = new R2dbcRepositoryFactory(databaseClient, dataAccessStrategy); MyPersonRepository repository = factory.getRepository(MyPersonRepository.class); diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/SqlInspectingR2dbcRepositoryUnitTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/SqlInspectingR2dbcRepositoryUnitTests.java new file mode 100644 index 00000000000..be1927a4dc8 --- /dev/null +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/SqlInspectingR2dbcRepositoryUnitTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2018-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.r2dbc.repository.support; + +import static org.assertj.core.api.Assertions.*; + +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.annotation.Id; +import org.springframework.data.r2dbc.convert.MappingR2dbcConverter; +import org.springframework.data.r2dbc.convert.R2dbcConverter; +import org.springframework.data.r2dbc.core.DefaultReactiveDataAccessStrategy; +import org.springframework.data.r2dbc.core.ReactiveDataAccessStrategy; +import org.springframework.data.r2dbc.dialect.H2Dialect; +import org.springframework.data.r2dbc.dialect.PostgresDialect; +import org.springframework.data.r2dbc.mapping.R2dbcMappingContext; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.r2dbc.testing.StatementRecorder; +import org.springframework.data.repository.Repository; +import org.springframework.r2dbc.core.DatabaseClient; + +/** + * Test extracting the SQL from a repository method call and performing assertions on it. + * + * @author Jens Schauder + */ +@ExtendWith(MockitoExtension.class) +public class SqlInspectingR2dbcRepositoryUnitTests { + + R2dbcConverter r2dbcConverter = new MappingR2dbcConverter(new R2dbcMappingContext()); + + DatabaseClient databaseClient; + StatementRecorder recorder = StatementRecorder.newInstance(); + ReactiveDataAccessStrategy dataAccessStrategy = new DefaultReactiveDataAccessStrategy(H2Dialect.INSTANCE); + + + @BeforeEach + @SuppressWarnings("unchecked") + public void before() { + + databaseClient = DatabaseClient.builder().connectionFactory(recorder) + .bindMarkers(H2Dialect.INSTANCE.getBindMarkersFactory()).build(); + + } + + @Test // GH-1856 + public void replacesSpelExpressionInQuery() { + + recorder.addStubbing(SqlInspectingR2dbcRepositoryUnitTests::isSelect, List.of()); + + R2dbcRepositoryFactory factory = new R2dbcRepositoryFactory(databaseClient, dataAccessStrategy); + MyPersonRepository repository = factory.getRepository(MyPersonRepository.class); + + assertThat(repository).isNotNull(); + + repository.findBySpel().block(Duration.ofMillis(100)); + + StatementRecorder.RecordedStatement statement = recorder.getCreatedStatement(SqlInspectingR2dbcRepositoryUnitTests::isSelect); + + assertThat(statement.getSql()).isEqualTo("select * from PERSONx"); + } + + private static boolean isSelect(String sql) { + return sql.toLowerCase().startsWith("select"); + } + + interface MyPersonRepository extends Repository { + @Query("select * from #{#tableName +'x'}") + Mono findBySpel(); + } + + static class Person { + @Id long id; + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/QueryPreprocessor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/QueryPreprocessor.java new file mode 100644 index 00000000000..4508e21d7de --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/QueryPreprocessor.java @@ -0,0 +1,29 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.relational.repository.query; + +public interface QueryPreprocessor { + + QueryPreprocessor NOOP = new QueryPreprocessor() { + + @Override + public String transform(String query) { + return query; + } + }; + String transform(String query); +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/repository/support/RelationalQueryLookupStrategy.java b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/support/RelationalQueryLookupStrategy.java new file mode 100644 index 00000000000..de78eeea58a --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/support/RelationalQueryLookupStrategy.java @@ -0,0 +1,64 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.relational.repository.support; + +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.relational.core.dialect.Dialect; +import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.repository.query.QueryPreprocessor; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.query.QueryLookupStrategy; +import org.springframework.util.Assert; + +/** + * Base class for R2DBC and JDBC {@link QueryLookupStrategy} implementations. + * + * @author Jens Schauder + * @since 3.4 + */ +public abstract class RelationalQueryLookupStrategy implements QueryLookupStrategy { + + private final MappingContext, ? extends RelationalPersistentProperty> context; + private final Dialect dialect; + + protected RelationalQueryLookupStrategy( + MappingContext, ? extends RelationalPersistentProperty> context, + Dialect dialect) { + + Assert.notNull(context, "RelationalMappingContext must not be null"); + Assert.notNull(dialect, "Dialect must not be null"); + + this.context = context; + this.dialect = dialect; + } + + protected String evaluateTableExpressions(RepositoryMetadata repositoryMetadata, String queryString) { + + return prepareQueryPreprocessor(repositoryMetadata).transform(queryString); + } + + private QueryPreprocessor prepareQueryPreprocessor(RepositoryMetadata repositoryMetadata) { + + SqlIdentifier tableName = context.getPersistentEntity(repositoryMetadata.getDomainType()).getTableName(); + SqlIdentifier qualifiedTableName = context.getPersistentEntity(repositoryMetadata.getDomainType()) + .getQualifiedTableName(); + return new TableNameQueryPreprocessor(tableName, qualifiedTableName, dialect); + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/repository/support/TableNameQueryPreprocessor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/support/TableNameQueryPreprocessor.java new file mode 100644 index 00000000000..d54999f535c --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/support/TableNameQueryPreprocessor.java @@ -0,0 +1,80 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.relational.repository.support; + +import org.springframework.data.relational.core.dialect.Dialect; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.repository.query.QueryPreprocessor; +import org.springframework.expression.Expression; +import org.springframework.expression.ParserContext; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.util.Assert; + +import java.util.regex.Pattern; + +/** + * Replaces SpEL expressions based on table names in query strings. + * + * @author Jens Schauder + */ +class TableNameQueryPreprocessor implements QueryPreprocessor { + + private static final String EXPRESSION_PARAMETER = "$1#{"; + private static final String QUOTED_EXPRESSION_PARAMETER = "$1__HASH__{"; + + private static final Pattern EXPRESSION_PARAMETER_QUOTING = Pattern.compile("([:?])#\\{"); + private static final Pattern EXPRESSION_PARAMETER_UNQUOTING = Pattern.compile("([:?])__HASH__\\{"); + + private final SqlIdentifier tableName; + private final SqlIdentifier qualifiedTableName; + private final Dialect dialect; + + public TableNameQueryPreprocessor(SqlIdentifier tableName, SqlIdentifier qualifiedTableName, Dialect dialect) { + + Assert.notNull(tableName, "TableName must not be null"); + Assert.notNull(qualifiedTableName, "QualifiedTableName must not be null"); + Assert.notNull(dialect, "Dialect must not be null"); + + this.tableName = tableName; + this.qualifiedTableName = qualifiedTableName; + this.dialect = dialect; + } + + @Override + public String transform(String query) { + + StandardEvaluationContext evaluationContext = new StandardEvaluationContext(); + evaluationContext.setVariable("tableName", tableName.toSql(dialect.getIdentifierProcessing())); + evaluationContext.setVariable("qualifiedTableName", qualifiedTableName.toSql(dialect.getIdentifierProcessing())); + + SpelExpressionParser parser = new SpelExpressionParser(); + + query = quoteExpressionsParameter(query); + Expression expression = parser.parseExpression(query, ParserContext.TEMPLATE_EXPRESSION); + + return unquoteParameterExpressions(expression.getValue(evaluationContext, String.class)); + } + + private static String unquoteParameterExpressions(String result) { + return EXPRESSION_PARAMETER_UNQUOTING.matcher(result).replaceAll(EXPRESSION_PARAMETER); + } + + private static String quoteExpressionsParameter(String query) { + return EXPRESSION_PARAMETER_QUOTING.matcher(query).replaceAll(QUOTED_EXPRESSION_PARAMETER); + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/repository/support/TableNameQueryPreprocessorUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/repository/support/TableNameQueryPreprocessorUnitTests.java new file mode 100644 index 00000000000..6e657c27f45 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/repository/support/TableNameQueryPreprocessorUnitTests.java @@ -0,0 +1,46 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.relational.repository.support; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.Test; +import org.springframework.data.relational.core.dialect.AnsiDialect; +import org.springframework.data.relational.core.sql.SqlIdentifier; + +/** + * Tests for {@link TableNameQueryPreprocessor}. + * + * @author Jens Schauder + */ +class TableNameQueryPreprocessorUnitTests { + + @Test // GH-1856 + void transform() { + + TableNameQueryPreprocessor preprocessor = new TableNameQueryPreprocessor(SqlIdentifier.quoted("some_table_name"), SqlIdentifier.quoted("qualified_table_name"), AnsiDialect.INSTANCE); + SoftAssertions.assertSoftly(softly -> { + + softly.assertThat(preprocessor.transform("someString")).isEqualTo("someString"); + softly.assertThat(preprocessor.transform("someString#{#tableName}restOfString")) + .isEqualTo("someString\"some_table_name\"restOfString"); + softly.assertThat(preprocessor.transform("select from #{#tableName} where x = :#{#some other spel}")) + .isEqualTo("select from \"some_table_name\" where x = :#{#some other spel}"); + softly.assertThat(preprocessor.transform("select from #{#qualifiedTableName}")) + .isEqualTo("select from \"qualified_table_name\""); + }); + } +} diff --git a/src/main/antora/modules/ROOT/pages/jdbc/query-methods.adoc b/src/main/antora/modules/ROOT/pages/jdbc/query-methods.adoc index a329071eba3..970a36a5403 100644 --- a/src/main/antora/modules/ROOT/pages/jdbc/query-methods.adoc +++ b/src/main/antora/modules/ROOT/pages/jdbc/query-methods.adoc @@ -176,7 +176,10 @@ For example if the `User` from the example above has an `address` with the prope WARNING: Note that String-based queries do not support pagination nor accept `Sort`, `PageRequest`, and `Limit` as a query parameter as for these queries the query would be required to be rewritten. If you want to apply limiting, please express this intent using SQL and bind the appropriate parameters to the query yourself. -Queries may contain SpEL expressions where bind variables are allowed. +Queries may contain SpEL expressions. +There are two variants that are evaluated differently. + +In the first variant a SpEL expression is prefixed with `:` and used like a bind variable. Such a SpEL expression will get replaced with a bind variable and the variable gets bound to the result of the SpEL expression. .Use a SpEL in a query @@ -189,6 +192,18 @@ Person findWithSpEL(PersonRef person); This can be used to access members of a parameter, as demonstrated in the example above. For more involved use cases an `EvaluationContextExtension` can be made available in the application context, which in turn can make any object available in to the SpEL. +The other variant can be used anywhere in the query and the result of evaluating the query will replace the expression in the query string. + +.Use a SpEL in a query +[source,java] +---- +@Query("SELECT * FROM #{tableName} WHERE id = :id") +Person findWithSpEL(PersonRef person); +---- + +It is evaluated once before the first execution and uses a `StandardEvaluationContext` with the two variables `tableName` and `qualifiedTableName` added. +This use is most useful when table names are dynamic themselves, because they use SpEL expressions as well. + NOTE: Spring fully supports Java 8’s parameter name discovery based on the `-parameters` compiler flag. By using this flag in your build as an alternative to debug information, you can omit the `@Param` annotation for named parameters. diff --git a/src/main/antora/modules/ROOT/pages/r2dbc/query-methods.adoc b/src/main/antora/modules/ROOT/pages/r2dbc/query-methods.adoc index b0f966faada..421aea88d63 100644 --- a/src/main/antora/modules/ROOT/pages/r2dbc/query-methods.adoc +++ b/src/main/antora/modules/ROOT/pages/r2dbc/query-methods.adoc @@ -207,6 +207,8 @@ By using this flag in your build as an alternative to debug information, you can === Queries with SpEL Expressions Query string definitions can be used together with SpEL expressions to create dynamic queries at runtime. +SpEL expressions can be used in two ways. + SpEL expressions can provide predicate values which are evaluated right before running the query. Expressions expose method arguments through an array that contains all the arguments. @@ -218,12 +220,24 @@ to declare the predicate value for `lastname` (which is equivalent to the `:last include::example$r2dbc/PersonRepository.java[tags=spel] ---- -SpEL in query strings can be a powerful way to enhance queries. -However, they can also accept a broad range of unwanted arguments. -You should make sure to sanitize strings before passing them to the query to avoid unwanted changes to your query. - -Expression support is extensible through the Query SPI: `org.springframework.data.spel.spi.EvaluationContextExtension`. +This Expression support is extensible through the Query SPI: `org.springframework.data.spel.spi.EvaluationContextExtension`. The Query SPI can contribute properties and functions and can customize the root object. Extensions are retrieved from the application context at the time of SpEL evaluation when the query is built. TIP: When using SpEL expressions in combination with plain parameters, use named parameter notation instead of native bind markers to ensure a proper binding order. + +The other way to use Expression is in the middle of query, independent of parameters. +The result of evaluating the query will replace the expression in the query string. + +.Use a SpEL in a query +[source,java,indent=0] +---- +include::example$r2dbc/PersonRepository.java[tags=spel2] +---- + +It is evaluated once before the first execution and uses a `StandardEvaluationContext` with the two variables `tableName` and `qualifiedTableName` added. +This use is most useful when table names are dynamic themselves, because they use SpEL expressions as well. + +SpEL in query strings can be a powerful way to enhance queries. +However, they can also accept a broad range of unwanted arguments. +You should make sure to sanitize strings before passing them to the query to avoid unwanted changes to your query.