Browse Source
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 #1863pull/1875/head
16 changed files with 574 additions and 24 deletions
@ -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(); |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
@ -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); |
||||
} |
||||
|
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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\""); |
||||
}); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue