Skip to content

Commit 3755822

Browse files
committed
SpEL expressions with tablename allowed in JDBC queries.
Closes #1856
1 parent ceb773e commit 3755822

File tree

6 files changed

+310
-3
lines changed

6 files changed

+310
-3
lines changed

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

+22-2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.springframework.data.jdbc.core.mapping.JdbcValue;
3636
import org.springframework.data.jdbc.support.JdbcUtil;
3737
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
38+
import org.springframework.data.relational.repository.query.QueryPreprocessor;
3839
import org.springframework.data.relational.repository.query.RelationalParameterAccessor;
3940
import org.springframework.data.relational.repository.query.RelationalParametersParameterAccessor;
4041
import org.springframework.data.repository.query.Parameter;
@@ -104,10 +105,27 @@ public StringBasedJdbcQuery(JdbcQueryMethod queryMethod, NamedParameterJdbcOpera
104105
* @param operations must not be {@literal null}.
105106
* @param rowMapperFactory must not be {@literal null}.
106107
* @since 2.3
108+
* @deprecated use alternative constructor
107109
*/
110+
@Deprecated(since = "3.4")
108111
public StringBasedJdbcQuery(JdbcQueryMethod queryMethod, NamedParameterJdbcOperations operations,
109112
RowMapperFactory rowMapperFactory, JdbcConverter converter,
110113
QueryMethodEvaluationContextProvider evaluationContextProvider) {
114+
this(queryMethod, operations, rowMapperFactory, converter, evaluationContextProvider, QueryPreprocessor.NOOP);
115+
}
116+
117+
/**
118+
* Creates a new {@link StringBasedJdbcQuery} for the given {@link JdbcQueryMethod}, {@link RelationalMappingContext}
119+
* and {@link RowMapperFactory}.
120+
*
121+
* @param queryMethod must not be {@literal null}.
122+
* @param operations must not be {@literal null}.
123+
* @param rowMapperFactory must not be {@literal null}.
124+
* @since 3.4
125+
*/
126+
public StringBasedJdbcQuery(JdbcQueryMethod queryMethod, NamedParameterJdbcOperations operations,
127+
RowMapperFactory rowMapperFactory, JdbcConverter converter,
128+
QueryMethodEvaluationContextProvider evaluationContextProvider, QueryPreprocessor queryPreprocessor) {
111129

112130
super(queryMethod, operations);
113131

@@ -116,6 +134,7 @@ public StringBasedJdbcQuery(JdbcQueryMethod queryMethod, NamedParameterJdbcOpera
116134
this.converter = converter;
117135
this.rowMapperFactory = rowMapperFactory;
118136

137+
119138
if (queryMethod.isSliceQuery()) {
120139
throw new UnsupportedOperationException(
121140
"Slice queries are not supported using string-based queries; Offending method: " + queryMethod);
@@ -140,7 +159,8 @@ public StringBasedJdbcQuery(JdbcQueryMethod queryMethod, NamedParameterJdbcOpera
140159
.of((counter, expression) -> String.format("__$synthetic$__%d", counter + 1), String::concat)
141160
.withEvaluationContextProvider(evaluationContextProvider);
142161

143-
this.query = queryMethod.getRequiredQuery();
162+
163+
this.query = queryPreprocessor.transform(queryMethod.getRequiredQuery());
144164
this.spelEvaluator = queryContext.parse(query, getQueryMethod().getParameters());
145165
this.containsSpelExpressions = !this.spelEvaluator.getQueryString().equals(query);
146166
}
@@ -160,7 +180,7 @@ public Object execute(Object[] objects) {
160180
private String processSpelExpressions(Object[] objects, MapSqlParameterSource parameterMap) {
161181

162182
if (containsSpelExpressions) {
163-
183+
// TODO: Make code changes here
164184
spelEvaluator.evaluate(objects).forEach(parameterMap::addValue);
165185
return spelEvaluator.getQueryString();
166186
}

spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcQueryLookupStrategy.java

+6-1
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,15 @@
2929
import org.springframework.data.jdbc.repository.query.JdbcQueryMethod;
3030
import org.springframework.data.jdbc.repository.query.PartTreeJdbcQuery;
3131
import org.springframework.data.jdbc.repository.query.StringBasedJdbcQuery;
32+
import org.springframework.data.relational.repository.query.TableNameQueryPreprocessor;
3233
import org.springframework.data.mapping.callback.EntityCallbacks;
3334
import org.springframework.data.projection.ProjectionFactory;
3435
import org.springframework.data.relational.core.dialect.Dialect;
3536
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
3637
import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
3738
import org.springframework.data.relational.core.mapping.event.AfterConvertCallback;
3839
import org.springframework.data.relational.core.mapping.event.AfterConvertEvent;
40+
import org.springframework.data.relational.core.sql.SqlIdentifier;
3941
import org.springframework.data.repository.core.NamedQueries;
4042
import org.springframework.data.repository.core.RepositoryMetadata;
4143
import org.springframework.data.repository.query.QueryLookupStrategy;
@@ -156,8 +158,11 @@ public RepositoryQuery resolveQuery(Method method, RepositoryMetadata repository
156158
"Query method %s is annotated with both, a query and a query name; Using the declared query", method));
157159
}
158160

161+
SqlIdentifier tableName = getContext().getPersistentEntity(repositoryMetadata.getDomainType()).getTableName();
162+
SqlIdentifier qualifiedTableName = getContext().getPersistentEntity(repositoryMetadata.getDomainType()).getQualifiedTableName();
159163
StringBasedJdbcQuery query = new StringBasedJdbcQuery(queryMethod, getOperations(), this::createMapper,
160-
getConverter(), evaluationContextProvider);
164+
getConverter(), evaluationContextProvider,
165+
new TableNameQueryPreprocessor(tableName, qualifiedTableName, getDialect()));
161166
query.setBeanFactory(getBeanFactory());
162167
return query;
163168
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* Copyright 2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.data.jdbc.repository;
18+
19+
import static org.assertj.core.api.Assertions.*;
20+
import static org.mockito.Mockito.*;
21+
22+
import org.jetbrains.annotations.NotNull;
23+
import org.junit.jupiter.api.Test;
24+
import org.mockito.ArgumentCaptor;
25+
import org.springframework.context.ApplicationEventPublisher;
26+
import org.springframework.data.annotation.Id;
27+
import org.springframework.data.jdbc.core.convert.DataAccessStrategy;
28+
import org.springframework.data.jdbc.core.convert.DefaultJdbcTypeFactory;
29+
import org.springframework.data.jdbc.core.convert.DelegatingDataAccessStrategy;
30+
import org.springframework.data.jdbc.core.convert.JdbcConverter;
31+
import org.springframework.data.jdbc.core.convert.JdbcCustomConversions;
32+
import org.springframework.data.jdbc.core.convert.MappingJdbcConverter;
33+
import org.springframework.data.jdbc.core.mapping.JdbcMappingContext;
34+
import org.springframework.data.jdbc.repository.query.Query;
35+
import org.springframework.data.jdbc.repository.support.JdbcRepositoryFactory;
36+
import org.springframework.data.relational.core.dialect.Dialect;
37+
import org.springframework.data.relational.core.dialect.HsqlDbDialect;
38+
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
39+
import org.springframework.data.relational.core.mapping.Table;
40+
import org.springframework.data.repository.CrudRepository;
41+
import org.springframework.jdbc.core.RowMapper;
42+
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations;
43+
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
44+
import org.springframework.lang.Nullable;
45+
46+
/**
47+
* Tests that extract the SQL statement that results from declared queries of a repository and perform assertions on it.
48+
*
49+
* @author Jens Schauder
50+
*/
51+
public class DeclaredQueryRepositoryUnitTests {
52+
53+
private NamedParameterJdbcOperations operations = mock(NamedParameterJdbcOperations.class, RETURNS_DEEP_STUBS);
54+
55+
@Test
56+
void plainSql() {
57+
58+
repository(DummyEntityRepository.class).plainQuery();
59+
60+
assertThat(query()).isEqualTo("select * from someTable");
61+
}
62+
63+
@Test
64+
void tableNameQuery() {
65+
66+
repository(DummyEntityRepository.class).tableNameQuery();
67+
68+
assertThat(query()).isEqualTo("select * from \"DUMMY_ENTITY\"");
69+
}
70+
71+
@Test
72+
void renamedTableNameQuery() {
73+
74+
repository(RenamedEntityRepository.class).tableNameQuery();
75+
76+
assertThat(query()).isEqualTo("select * from \"ReNamed\"");
77+
}
78+
79+
@Test
80+
void fullyQualifiedTableNameQuery() {
81+
82+
repository(RenamedEntityRepository.class).qualifiedTableNameQuery();
83+
84+
assertThat(query()).isEqualTo("select * from \"someSchema\".\"ReNamed\"");
85+
}
86+
87+
private String query() {
88+
89+
ArgumentCaptor<String> queryCaptor = ArgumentCaptor.forClass(String.class);
90+
verify(operations).queryForObject(queryCaptor.capture(), any(SqlParameterSource.class), any(RowMapper.class));
91+
return queryCaptor.getValue();
92+
}
93+
94+
private @NotNull <T extends CrudRepository> T repository(Class<T> repositoryInterface) {
95+
96+
Dialect dialect = HsqlDbDialect.INSTANCE;
97+
98+
RelationalMappingContext context = new JdbcMappingContext();
99+
100+
DelegatingDataAccessStrategy delegatingDataAccessStrategy = new DelegatingDataAccessStrategy();
101+
JdbcConverter converter = new MappingJdbcConverter(context, delegatingDataAccessStrategy,
102+
new JdbcCustomConversions(), new DefaultJdbcTypeFactory(operations.getJdbcOperations()));
103+
104+
DataAccessStrategy dataAccessStrategy = mock(DataAccessStrategy.class);
105+
ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class);
106+
107+
JdbcRepositoryFactory factory = new JdbcRepositoryFactory(dataAccessStrategy, context, converter, dialect,
108+
publisher, operations);
109+
110+
return factory.getRepository(repositoryInterface);
111+
}
112+
113+
@Table
114+
record DummyEntity(@Id Long id, String name) {
115+
}
116+
117+
interface DummyEntityRepository extends CrudRepository<DummyEntity, Long> {
118+
119+
@Nullable
120+
@Query("select * from someTable")
121+
DummyEntity plainQuery();
122+
123+
@Nullable
124+
@Query("select * from #{#tableName}")
125+
DummyEntity tableNameQuery();
126+
}
127+
128+
@Table(name = "ReNamed", schema = "someSchema")
129+
record RenamedEntity(@Id Long id, String name) {
130+
}
131+
132+
interface RenamedEntityRepository extends CrudRepository<RenamedEntity, Long> {
133+
134+
@Nullable
135+
@Query("select * from #{#tableName}")
136+
DummyEntity tableNameQuery();
137+
138+
@Nullable
139+
@Query("select * from #{#qualifiedTableName}")
140+
DummyEntity qualifiedTableNameQuery();
141+
}
142+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright 2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.data.jdbc.repository.query;
18+
19+
import org.assertj.core.api.SoftAssertions;
20+
import org.junit.jupiter.api.Test;
21+
import org.springframework.data.relational.core.dialect.AnsiDialect;
22+
import org.springframework.data.relational.core.sql.SqlIdentifier;
23+
import org.springframework.data.relational.repository.query.TableNameQueryPreprocessor;
24+
25+
class TableNameQueryPreprocessorUnitTests {
26+
27+
@Test // GH-1856
28+
void transform() {
29+
30+
TableNameQueryPreprocessor preprocessor = new TableNameQueryPreprocessor(SqlIdentifier.quoted("some_table_name"), SqlIdentifier.quoted("qualified_table_name"), AnsiDialect.INSTANCE);
31+
SoftAssertions.assertSoftly(softly -> {
32+
33+
softly.assertThat(preprocessor.transform("someString")).isEqualTo("someString");
34+
softly.assertThat(preprocessor.transform("someString#{#tableName}restOfString"))
35+
.isEqualTo("someString\"some_table_name\"restOfString");
36+
softly.assertThat(preprocessor.transform("select from #{#tableName} where x = :#{#some other spel}"))
37+
.isEqualTo("select from \"some_table_name\" where x = :#{#some other spel}");
38+
softly.assertThat(preprocessor.transform("select from #{#qualifiedTableName}"))
39+
.isEqualTo("select from \"qualified_table_name\"");
40+
});
41+
}
42+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright 2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.data.relational.repository.query;
18+
19+
public interface QueryPreprocessor {
20+
21+
QueryPreprocessor NOOP = new QueryPreprocessor() {
22+
23+
@Override
24+
public String transform(String query) {
25+
return query;
26+
}
27+
};
28+
String transform(String query);
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright 2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.data.relational.repository.query;
18+
19+
import org.springframework.data.relational.core.dialect.Dialect;
20+
import org.springframework.data.relational.core.sql.SqlIdentifier;
21+
import org.springframework.expression.Expression;
22+
import org.springframework.expression.ParserContext;
23+
import org.springframework.expression.spel.standard.SpelExpressionParser;
24+
import org.springframework.expression.spel.support.StandardEvaluationContext;
25+
26+
import java.util.regex.Pattern;
27+
28+
public class TableNameQueryPreprocessor implements QueryPreprocessor {
29+
30+
private static final String EXPRESSION_PARAMETER = "$1#{";
31+
private static final String QUOTED_EXPRESSION_PARAMETER = "$1__HASH__{";
32+
33+
private static final Pattern EXPRESSION_PARAMETER_QUOTING = Pattern.compile("([:?])#\\{");
34+
private static final Pattern EXPRESSION_PARAMETER_UNQUOTING = Pattern.compile("([:?])__HASH__\\{");
35+
36+
private final SqlIdentifier tableName;
37+
private final SqlIdentifier qualifiedTableName;
38+
private final Dialect dialect;
39+
40+
public TableNameQueryPreprocessor(SqlIdentifier tableName, SqlIdentifier qualifiedTableName, Dialect dialect) {
41+
42+
this.tableName = tableName;
43+
this.qualifiedTableName = qualifiedTableName;
44+
this.dialect = dialect;
45+
}
46+
47+
@Override
48+
public String transform(String query) {
49+
50+
StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
51+
evaluationContext.setVariable("tableName", tableName.toSql(dialect.getIdentifierProcessing()));
52+
evaluationContext.setVariable("qualifiedTableName", qualifiedTableName.toSql(dialect.getIdentifierProcessing()));
53+
54+
SpelExpressionParser parser = new SpelExpressionParser();
55+
56+
query = quoteExpressionsParameter(query);
57+
Expression expression = parser.parseExpression(query, ParserContext.TEMPLATE_EXPRESSION);
58+
59+
return unquoteParameterExpressions(expression.getValue(evaluationContext, String.class));
60+
}
61+
62+
private static String unquoteParameterExpressions(String result) {
63+
return EXPRESSION_PARAMETER_UNQUOTING.matcher(result).replaceAll(EXPRESSION_PARAMETER);
64+
}
65+
66+
private static String quoteExpressionsParameter(String query) {
67+
return EXPRESSION_PARAMETER_QUOTING.matcher(query).replaceAll(QUOTED_EXPRESSION_PARAMETER);
68+
}
69+
}

0 commit comments

Comments
 (0)