Browse Source

Support for table names in SpEL expressions.

SpEL expressions in queries get processed in two steps:

1. First SpEL expressions outside parameters are detected and processed.
This is done with a `StandardEvaluationContext` with the variables `tableName` and `qualifiedTableName` added.
This step is introduced by this commit.

2. Parameters made up by SpEL expressions are processed as usual.

Closes #1856
Original pull request #1863
pull/1875/head
Jens Schauder 1 year ago committed by Mark Paluch
parent
commit
f937738038
No known key found for this signature in database
GPG Key ID: 55BC6374BAA9D973
  1. 2
      spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryMethod.java
  2. 30
      spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StringBasedJdbcQuery.java
  3. 11
      spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcQueryLookupStrategy.java
  4. 142
      spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/DeclaredQueryRepositoryUnitTests.java
  5. 9
      spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/DefaultReactiveDataAccessStrategy.java
  6. 10
      spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/ReactiveDataAccessStrategy.java
  7. 27
      spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactory.java
  8. 7
      spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/documentation/PersonRepository.java
  9. 5
      spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactoryUnitTests.java
  10. 95
      spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/SqlInspectingR2dbcRepositoryUnitTests.java
  11. 29
      spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/QueryPreprocessor.java
  12. 64
      spring-data-relational/src/main/java/org/springframework/data/relational/repository/support/RelationalQueryLookupStrategy.java
  13. 80
      spring-data-relational/src/main/java/org/springframework/data/relational/repository/support/TableNameQueryPreprocessor.java
  14. 46
      spring-data-relational/src/test/java/org/springframework/data/relational/repository/support/TableNameQueryPreprocessorUnitTests.java
  15. 17
      src/main/antora/modules/ROOT/pages/jdbc/query-methods.adoc
  16. 24
      src/main/antora/modules/ROOT/pages/r2dbc/query-methods.adoc

2
spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryMethod.java

@ -128,7 +128,7 @@ public class JdbcQueryMethod extends QueryMethod { @@ -128,7 +128,7 @@ public class JdbcQueryMethod extends QueryMethod {
return StringUtils.hasText(annotatedValue) ? annotatedValue : getNamedQuery();
}
String getRequiredQuery() {
public String getRequiredQuery() {
String query = getDeclaredQuery();

30
spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StringBasedJdbcQuery.java

@ -35,6 +35,7 @@ import org.springframework.data.jdbc.core.convert.JdbcConverter; @@ -35,6 +35,7 @@ import org.springframework.data.jdbc.core.convert.JdbcConverter;
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 class StringBasedJdbcQuery extends AbstractJdbcQuery { @@ -103,11 +104,33 @@ public class StringBasedJdbcQuery extends AbstractJdbcQuery {
* @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 class StringBasedJdbcQuery extends AbstractJdbcQuery { @@ -116,6 +139,7 @@ public class StringBasedJdbcQuery extends AbstractJdbcQuery {
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 class StringBasedJdbcQuery extends AbstractJdbcQuery { @@ -140,9 +164,9 @@ public class StringBasedJdbcQuery extends AbstractJdbcQuery {
.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

11
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.RelationalMappingContext @@ -36,6 +36,7 @@ import org.springframework.data.relational.core.mapping.RelationalMappingContext
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 @@ import org.springframework.util.Assert; @@ -60,7 +61,7 @@ import org.springframework.util.Assert;
* @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 { @@ -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 @@ abstract class JdbcQueryLookupStrategy implements QueryLookupStrategy { @@ -156,8 +159,10 @@ abstract class JdbcQueryLookupStrategy implements QueryLookupStrategy {
"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;
}

142
spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/DeclaredQueryRepositoryUnitTests.java

@ -0,0 +1,142 @@ @@ -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<String> queryCaptor = ArgumentCaptor.forClass(String.class);
verify(operations).queryForObject(queryCaptor.capture(), any(SqlParameterSource.class), any(RowMapper.class));
return queryCaptor.getValue();
}
private @NotNull <T extends CrudRepository> T repository(Class<T> 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<DummyEntity, Long> {
@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<RenamedEntity, Long> {
@Nullable
@Query("select * from #{#tableName}")
DummyEntity tableNameQuery();
@Nullable
@Query("select * from #{#qualifiedTableName}")
DummyEntity qualifiedTableNameQuery();
}
}

9
spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/DefaultReactiveDataAccessStrategy.java

@ -41,6 +41,7 @@ import org.springframework.data.r2dbc.mapping.R2dbcMappingContext; @@ -41,6 +41,7 @@ import org.springframework.data.r2dbc.mapping.R2dbcMappingContext;
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 class DefaultReactiveDataAccessStrategy implements ReactiveDataAccessStra @@ -310,6 +311,14 @@ public class DefaultReactiveDataAccessStrategy implements ReactiveDataAccessStra
return dialect.renderForGeneratedValues(identifier);
}
/**
* @since 3.4
*/
@Override
public Dialect getDialect() {
return dialect;
}
private RelationalPersistentEntity<?> getRequiredPersistentEntity(Class<?> typeToRead) {
return this.mappingContext.getRequiredPersistentEntity(typeToRead);
}

10
spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/ReactiveDataAccessStrategy.java

@ -25,6 +25,8 @@ import java.util.function.BiFunction; @@ -25,6 +25,8 @@ import java.util.function.BiFunction;
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 @@ public interface ReactiveDataAccessStrategy { @@ -154,6 +156,14 @@ public interface ReactiveDataAccessStrategy {
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.
*/

27
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.RelationalPersistentEnti @@ -32,6 +32,7 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentEnti
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 @@ import org.springframework.util.Assert; @@ -51,6 +52,7 @@ import org.springframework.util.Assert;
* Factory to create {@link R2dbcRepository} instances.
*
* @author Mark Paluch
* @author Jens Schauder
*/
public class R2dbcRepositoryFactory extends ReactiveRepositoryFactorySupport {
@ -139,8 +141,9 @@ public class R2dbcRepositoryFactory extends ReactiveRepositoryFactorySupport { @@ -139,8 +141,9 @@ public class R2dbcRepositoryFactory extends ReactiveRepositoryFactorySupport {
* {@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 @@ public class R2dbcRepositoryFactory extends ReactiveRepositoryFactorySupport { @@ -151,30 +154,34 @@ public class R2dbcRepositoryFactory extends ReactiveRepositoryFactorySupport {
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 RelationalPersistentEntity<?>, ? 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);
}

7
spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/documentation/PersonRepository.java

@ -34,6 +34,11 @@ public interface PersonRepository extends ReactiveCrudRepository<Person, String> @@ -34,6 +34,11 @@ public interface PersonRepository extends ReactiveCrudRepository<Person, String>
// tag::spel[]
@Query("SELECT * FROM person WHERE lastname = :#{[0]}")
Flux<Person> findByQueryWithExpression(String lastname);
Flux<Person> findByQueryWithParameterExpression(String lastname);
// end::spel[]
// tag::spel2[]
@Query("SELECT * FROM #{tableName} WHERE lastname = :lastname")
Flux<Person> findByQueryWithExpression(String lastname);
// end::spel2[]
}

5
spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactoryUnitTests.java

@ -29,6 +29,7 @@ import org.springframework.data.r2dbc.convert.MappingR2dbcConverter; @@ -29,6 +29,7 @@ import org.springframework.data.r2dbc.convert.MappingR2dbcConverter;
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 @@ import org.springframework.r2dbc.core.DatabaseClient; @@ -38,6 +39,7 @@ import org.springframework.r2dbc.core.DatabaseClient;
* Unit test for {@link R2dbcRepositoryFactory}.
*
* @author Mark Paluch
* @author Jens Schauder
*/
@ExtendWith(MockitoExtension.class)
public class R2dbcRepositoryFactoryUnitTests {
@ -50,6 +52,7 @@ 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 class R2dbcRepositoryFactoryUnitTests { @@ -65,6 +68,8 @@ public class R2dbcRepositoryFactoryUnitTests {
@Test
public void createsRepositoryWithIdTypeLong() {
when(dataAccessStrategy.getDialect()).thenReturn(AnsiDialect.INSTANCE);
R2dbcRepositoryFactory factory = new R2dbcRepositoryFactory(databaseClient, dataAccessStrategy);
MyPersonRepository repository = factory.getRepository(MyPersonRepository.class);

95
spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/SqlInspectingR2dbcRepositoryUnitTests.java

@ -0,0 +1,95 @@ @@ -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<Person, Long> {
@Query("select * from #{#tableName +'x'}")
Mono<Person> findBySpel();
}
static class Person {
@Id long id;
}
}

29
spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/QueryPreprocessor.java

@ -0,0 +1,29 @@ @@ -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);
}

64
spring-data-relational/src/main/java/org/springframework/data/relational/repository/support/RelationalQueryLookupStrategy.java

@ -0,0 +1,64 @@ @@ -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 RelationalPersistentEntity<?>, ? extends RelationalPersistentProperty> context;
private final Dialect dialect;
protected RelationalQueryLookupStrategy(
MappingContext<? extends RelationalPersistentEntity<?>, ? 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);
}
}

80
spring-data-relational/src/main/java/org/springframework/data/relational/repository/support/TableNameQueryPreprocessor.java

@ -0,0 +1,80 @@ @@ -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);
}
}

46
spring-data-relational/src/test/java/org/springframework/data/relational/repository/support/TableNameQueryPreprocessorUnitTests.java

@ -0,0 +1,46 @@ @@ -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\"");
});
}
}

17
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 @@ -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); @@ -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.

24
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 @@ -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 @@ -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.

Loading…
Cancel
Save