#164 - Support @Query definitions with SpEL expressions.

We now support SpEL expressions in string-based queries to bind parameters for more dynamic queries. SpEL expressions are enclosed in :#{…} and rendered as synthetic named parameter so their values are substituted with bound parameters to avoid SQL injection attach vectors.

interface PersonRepository extends Repository<Person, String> {

	@Query("SELECT * FROM person WHERE lastname = :#{'hello'}")
	Mono<Person> findHello();

	@Query("SELECT * FROM person WHERE lastname = :#{[0]} and firstname = :firstname")
	Mono<Person> findByLastnameAndFirstname(@Param("value") String value, @Param("firstname") String firstname);

	@Query("SELECT * FROM person WHERE lastname = :#{#person.name}")
	Mono<Person> findByExample(@Param("person") Person person);
}
This commit is contained in:
Mark Paluch
2020-02-20 10:36:18 +01:00
parent 714dec3c94
commit 6027e29305
6 changed files with 521 additions and 59 deletions

View File

@@ -14,6 +14,7 @@
* `@Modifying` annotation for query methods to consume affected row count.
* Repository `save(…)` with an associated Id terminates with `TransientDataAccessException` if the row does not exist in the database.
* Added `SingleConnectionConnectionFactory` for testing using connection singletons.
* Support for {spring-framework-ref}/core.html#expressions[SpEL expressions] in `@Query`.
[[new-features.1-0-0-RC1]]
== What's New in Spring Data R2DBC 1.0.0 RC1

View File

@@ -149,6 +149,35 @@ The result of a modifying query can be:
The `@Modifying` annotation is only relevant in combination with the `@Query` annotation.
Derived custom methods do not require this annotation.
[[r2dbc.repositories.queries.spel]]
=== Queries with SpEL Expressions
Query string definitions can be used together with SpEL expressions to create dynamic queries at runtime.
SpEL expressions can provide predicate values which are evaluated right before executing the query.
Expressions expose method arguments through an array that contains all the arguments.
The following query uses `[0]`
to declare the predicate value for `lastname` (which is equivalent to the `:lastname` parameter binding):
[source,java]
----
public interface PersonRepository extends ReactiveCrudRepository<Person, String> {
@Query("SELECT * FROM person WHERE lastname = :#{[0]} }")
List<Person> findByQueryWithExpression(String lastname);
}
----
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`.
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.
[[r2dbc.entity-persistence.state-detection-strategies]]
=== Entity State Detection Strategies

View File

@@ -0,0 +1,194 @@
/*
* Copyright 2020 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.query;
import static org.springframework.data.r2dbc.repository.query.ExpressionQuery.*;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.springframework.data.r2dbc.core.DatabaseClient;
import org.springframework.data.r2dbc.mapping.SettableValue;
import org.springframework.data.relational.repository.query.RelationalParameterAccessor;
import org.springframework.data.repository.query.Parameter;
import org.springframework.data.repository.query.Parameters;
import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.util.Assert;
/**
* {@link ExpressionEvaluatingParameterBinder} allows to evaluate, convert and bind parameters to placeholders within a
* {@link String}.
*
* @author Mark Paluch
* @since 1.1
*/
class ExpressionEvaluatingParameterBinder {
private final SpelExpressionParser expressionParser;
private final QueryMethodEvaluationContextProvider evaluationContextProvider;
private final ExpressionQuery expressionQuery;
private final Map<String, Boolean> namedParameters = new ConcurrentHashMap<>();
/**
* Creates new {@link ExpressionEvaluatingParameterBinder}
*
* @param expressionParser must not be {@literal null}.
* @param evaluationContextProvider must not be {@literal null}.
* @param expressionQuery must not be {@literal null}.
*/
ExpressionEvaluatingParameterBinder(SpelExpressionParser expressionParser,
QueryMethodEvaluationContextProvider evaluationContextProvider, ExpressionQuery expressionQuery) {
Assert.notNull(expressionParser, "ExpressionParser must not be null");
Assert.notNull(evaluationContextProvider, "EvaluationContextProvider must not be null");
Assert.notNull(expressionQuery, "ExpressionQuery must not be null");
this.expressionParser = expressionParser;
this.evaluationContextProvider = evaluationContextProvider;
this.expressionQuery = expressionQuery;
}
/**
* Bind values provided by {@link RelationalParameterAccessor} to placeholders in {@link ExpressionQuery} while
* considering potential conversions and parameter types.
*
* @param bindSpec must not be {@literal null}.
* @param parameterAccessor must not be {@literal null}.
*/
public <T extends DatabaseClient.BindSpec<T>> T bind(T bindSpec, RelationalParameterAccessor parameterAccessor) {
Object[] values = parameterAccessor.getValues();
Parameters<?, ?> bindableParameters = parameterAccessor.getBindableParameters();
T bindSpecToUse = bindExpressions(bindSpec, values, bindableParameters);
bindSpecToUse = bindParameters(bindSpecToUse, parameterAccessor.hasBindableNullValue(), values, bindableParameters);
return bindSpecToUse;
}
private <T extends DatabaseClient.BindSpec<T>> T bindExpressions(T bindSpec, Object[] values,
Parameters<?, ?> bindableParameters) {
T bindSpecToUse = bindSpec;
for (ParameterBinding binding : expressionQuery.getBindings()) {
SettableValue valueForBinding = getParameterValueForBinding(bindableParameters, values, binding);
if (valueForBinding.isEmpty()) {
bindSpecToUse = bindSpecToUse.bindNull(binding.getParameterName(), valueForBinding.getType());
} else {
bindSpecToUse = bindSpecToUse.bind(binding.getParameterName(), valueForBinding.getValue());
}
}
return bindSpecToUse;
}
private <T extends DatabaseClient.BindSpec<T>> T bindParameters(T bindSpec, boolean bindableNull, Object[] values,
Parameters<?, ?> bindableParameters) {
T bindSpecToUse = bindSpec;
int index = 0;
int bindingIndex = 0;
for (Object value : values) {
Parameter bindableParameter = bindableParameters.getBindableParameter(index++);
Optional<String> name = bindableParameter.getName();
if ((name.isPresent() && isNamedParameterUsed(name)) || !expressionQuery.getBindings().isEmpty()) {
if (isNamedParameterUsed(name)) {
if (value == null) {
if (bindableNull) {
bindSpecToUse = bindSpecToUse.bindNull(name.get(), bindableParameter.getType());
}
} else {
bindSpecToUse = bindSpecToUse.bind(name.get(), value);
}
}
// skip unused named parameters if there is SpEL
} else {
if (value == null) {
if (bindableNull) {
bindSpecToUse = bindSpecToUse.bindNull(bindingIndex++, bindableParameter.getType());
}
} else {
bindSpecToUse = bindSpecToUse.bind(bindingIndex++, value);
}
}
}
return bindSpecToUse;
}
private boolean isNamedParameterUsed(Optional<String> name) {
if (!name.isPresent()) {
return false;
}
return namedParameters.computeIfAbsent(name.get(), it -> {
Pattern namedParameterPattern = Pattern.compile("(\\W)[:#$@]" + Pattern.quote(it) + "(\\W|$)");
return namedParameterPattern.matcher(expressionQuery.getQuery()).find();
});
}
/**
* Returns the value to be used for the given {@link ParameterBinding}.
*
* @param parameters must not be {@literal null}.
* @param binding must not be {@literal null}.
* @return the value used for the given {@link ParameterBinding}.
*/
private SettableValue getParameterValueForBinding(Parameters<?, ?> parameters, Object[] values,
ParameterBinding binding) {
return evaluateExpression(binding.getExpression(), parameters, values);
}
/**
* Evaluates the given {@code expressionString}.
*
* @param expressionString must not be {@literal null} or empty.
* @param parameters must not be {@literal null}.
* @param parameterValues must not be {@literal null}.
* @return the value of the {@code expressionString} evaluation.
*/
private SettableValue evaluateExpression(String expressionString, Parameters<?, ?> parameters,
Object[] parameterValues) {
EvaluationContext evaluationContext = evaluationContextProvider.getEvaluationContext(parameters, parameterValues);
Expression expression = expressionParser.parseExpression(expressionString);
Object value = expression.getValue(evaluationContext, Object.class);
Class<?> valueType = expression.getValueType(evaluationContext);
return SettableValue.fromOrEmpty(value, valueType != null ? valueType : Object.class);
}
}

View File

@@ -0,0 +1,168 @@
/*
* Copyright 2020 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.query;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.lang.Nullable;
/**
* Query using Spring Expression Language to indicate parameter bindings. Queries using SpEL use {@code :#{…}} to
* enclose expressions. Expressions are substituted with synthetic named parameters and require therefore enabled named
* parameter expansion for execution.
*
* @author Mark Paluch
* @since 1.1
*/
class ExpressionQuery {
private static final char CURLY_BRACE_OPEN = '{';
private static final char CURLY_BRACE_CLOSE = '}';
private static final String SYNTHETIC_PARAMETER_TEMPLATE = "__synthetic_%d__";
private static final Pattern EXPRESSION_BINDING_PATTERN = Pattern.compile("[:]#\\{(.*)}");
private final String query;
private final List<ParameterBinding> parameterBindings;
private ExpressionQuery(String query, List<ParameterBinding> parameterBindings) {
this.query = query;
this.parameterBindings = parameterBindings;
}
/**
* Create a {@link ExpressionQuery} from a {@code query}.
*
* @param query the query string to parse.
* @return the parsed {@link ExpressionQuery}.
*/
public static ExpressionQuery create(String query) {
List<ParameterBinding> parameterBindings = new ArrayList<>();
String rewritten = transformQueryAndCollectExpressionParametersIntoBindings(query, parameterBindings);
return new ExpressionQuery(rewritten, parameterBindings);
}
public String getQuery() {
return query;
}
public List<ParameterBinding> getBindings() {
return parameterBindings;
}
private static String transformQueryAndCollectExpressionParametersIntoBindings(String input,
List<ParameterBinding> bindings) {
StringBuilder result = new StringBuilder();
int startIndex = 0;
int currentPosition = 0;
int parameterIndex = 0;
while (currentPosition < input.length()) {
Matcher matcher = findNextBindingOrExpression(input, currentPosition);
// no expression parameter found
if (matcher == null) {
break;
}
int exprStart = matcher.start();
currentPosition = exprStart;
// eat parameter expression
int curlyBraceOpenCount = 1;
currentPosition += 3;
while (curlyBraceOpenCount > 0 && currentPosition < input.length()) {
switch (input.charAt(currentPosition++)) {
case CURLY_BRACE_OPEN:
curlyBraceOpenCount++;
break;
case CURLY_BRACE_CLOSE:
curlyBraceOpenCount--;
break;
default:
}
}
result.append(input.subSequence(startIndex, exprStart));
String parameterName = String.format(SYNTHETIC_PARAMETER_TEMPLATE, parameterIndex++);
result.append(':').append(parameterName);
bindings.add(ParameterBinding.named(parameterName, matcher.group(1)));
currentPosition = matcher.end();
startIndex = currentPosition;
}
return result.append(input.subSequence(currentPosition, input.length())).toString();
}
@Nullable
private static Matcher findNextBindingOrExpression(String input, int position) {
Matcher matcher = EXPRESSION_BINDING_PATTERN.matcher(input);
if (matcher.find(position)) {
return matcher;
}
return null;
}
@Override
public String toString() {
return query;
}
/**
* A SpEL parameter binding.
*
* @author Mark Paluch
*/
static class ParameterBinding {
private final String parameterName;
private final String expression;
private ParameterBinding(String parameterName, String expression) {
this.expression = expression;
this.parameterName = parameterName;
}
static ParameterBinding named(String name, String expression) {
return new ParameterBinding(name, expression);
}
String getExpression() {
return expression;
}
String getParameterName() {
return parameterName;
}
}
}

View File

@@ -15,18 +15,11 @@
*/
package org.springframework.data.r2dbc.repository.query;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.springframework.data.r2dbc.convert.R2dbcConverter;
import org.springframework.data.r2dbc.core.DatabaseClient;
import org.springframework.data.r2dbc.core.DatabaseClient.BindSpec;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.relational.repository.query.RelationalParameterAccessor;
import org.springframework.data.repository.query.Parameter;
import org.springframework.data.repository.query.Parameters;
import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.util.Assert;
@@ -34,14 +27,15 @@ import org.springframework.util.Assert;
/**
* String-based {@link StringBasedR2dbcQuery} implementation.
* <p>
* A {@link StringBasedR2dbcQuery} expects a query method to be annotated with {@link Query} with a SQL query.
* A {@link StringBasedR2dbcQuery} expects a query method to be annotated with {@link Query} with a SQL query. Supports
* named parameters (if enabled on {@link DatabaseClient}) and SpEL expressions enclosed with {@code :#{…}}.
*
* @author Mark Paluch
*/
public class StringBasedR2dbcQuery extends AbstractR2dbcQuery {
private final String sql;
private final Map<String, Boolean> namedParameters = new ConcurrentHashMap<>();
private final ExpressionQuery expressionQuery;
private final ExpressionEvaluatingParameterBinder binder;
/**
* Creates a new {@link StringBasedR2dbcQuery} for the given {@link StringBasedR2dbcQuery}, {@link DatabaseClient},
@@ -78,7 +72,8 @@ public class StringBasedR2dbcQuery extends AbstractR2dbcQuery {
Assert.hasText(query, "Query must not be empty");
this.sql = query;
this.expressionQuery = ExpressionQuery.create(query);
this.binder = new ExpressionEvaluatingParameterBinder(expressionParser, evaluationContextProvider, expressionQuery);
}
/* (non-Javadoc)
@@ -91,58 +86,14 @@ public class StringBasedR2dbcQuery extends AbstractR2dbcQuery {
@Override
public <T extends BindSpec<T>> T bind(T bindSpec) {
T bindSpecToUse = bindSpec;
Parameters<?, ?> bindableParameters = accessor.getBindableParameters();
int index = 0;
int bindingIndex = 0;
for (Object value : accessor.getValues()) {
Parameter bindableParameter = bindableParameters.getBindableParameter(index++);
Optional<String> name = bindableParameter.getName();
if (isNamedParameter(name)) {
if (value == null) {
if (accessor.hasBindableNullValue()) {
bindSpecToUse = bindSpecToUse.bindNull(name.get(), bindableParameter.getType());
}
} else {
bindSpecToUse = bindSpecToUse.bind(name.get(), value);
}
} else {
if (value == null) {
if (accessor.hasBindableNullValue()) {
bindSpecToUse = bindSpecToUse.bindNull(bindingIndex++, bindableParameter.getType());
}
} else {
bindSpecToUse = bindSpecToUse.bind(bindingIndex++, value);
}
}
}
return bindSpecToUse;
}
private boolean isNamedParameter(Optional<String> name) {
if (!name.isPresent()) {
return false;
}
return namedParameters.computeIfAbsent(name.get(), it -> {
Pattern namedParameterPattern = Pattern.compile("(\\W)" + Pattern.quote(it) + "(\\W|$)");
return namedParameterPattern.matcher(this.get()).find();
});
return binder.bind(bindSpec, accessor);
}
@Override
public String get() {
return sql;
return expressionQuery.getQuery();
}
};
}
}

View File

@@ -87,6 +87,20 @@ public class StringBasedR2dbcQueryUnitTests {
verify(bindSpec).bind(0, "White");
}
@Test // gh-164
public void bindsPositionalPropertyCorrectly() {
StringBasedR2dbcQuery query = getQueryMethod("findByLastnamePositional", String.class);
R2dbcParameterAccessor accessor = new R2dbcParameterAccessor(query.getQueryMethod(), "White");
BindableQuery stringQuery = query.createQuery(accessor);
assertThat(stringQuery.get()).isEqualTo("SELECT * FROM person WHERE lastname = $1");
assertThat(stringQuery.bind(bindSpec)).isNotNull();
verify(bindSpec).bind(0, "White");
}
@Test
public void bindsByNamedParameter() {
@@ -129,6 +143,84 @@ public class StringBasedR2dbcQueryUnitTests {
verify(bindSpec).bind(0, "White");
}
@Test // gh-164
public void bindsSimpleSpelQuery() {
StringBasedR2dbcQuery query = getQueryMethod("simpleSpel");
R2dbcParameterAccessor accessor = new R2dbcParameterAccessor(query.getQueryMethod());
BindableQuery stringQuery = query.createQuery(accessor);
assertThat(stringQuery.get()).isEqualTo("SELECT * FROM person WHERE lastname = :__synthetic_0__");
assertThat(stringQuery.bind(bindSpec)).isNotNull();
verify(bindSpec).bind("__synthetic_0__", "hello");
}
@Test // gh-164
public void bindsIndexedSpelQuery() {
StringBasedR2dbcQuery query = getQueryMethod("simpleIndexedSpel", String.class);
R2dbcParameterAccessor accessor = new R2dbcParameterAccessor(query.getQueryMethod(), "White");
BindableQuery stringQuery = query.createQuery(accessor);
assertThat(stringQuery.get()).isEqualTo("SELECT * FROM person WHERE lastname = :__synthetic_0__");
assertThat(stringQuery.bind(bindSpec)).isNotNull();
verify(bindSpec).bind("__synthetic_0__", "White");
verifyNoMoreInteractions(bindSpec);
}
@Test // gh-164
public void bindsPositionalSpelQuery() {
StringBasedR2dbcQuery query = getQueryMethod("simplePositionalSpel", String.class, String.class);
R2dbcParameterAccessor accessor = new R2dbcParameterAccessor(query.getQueryMethod(), "White", "Walter");
BindableQuery stringQuery = query.createQuery(accessor);
assertThat(stringQuery.get())
.isEqualTo("SELECT * FROM person WHERE lastname = :__synthetic_0__ and firstname = :firstname");
assertThat(stringQuery.bind(bindSpec)).isNotNull();
verify(bindSpec).bind("__synthetic_0__", "White");
verify(bindSpec).bind("firstname", "Walter");
verifyNoMoreInteractions(bindSpec);
}
@Test // gh-164
public void bindsPositionalNamedSpelQuery() {
StringBasedR2dbcQuery query = getQueryMethod("simpleNamedSpel", String.class, String.class);
R2dbcParameterAccessor accessor = new R2dbcParameterAccessor(query.getQueryMethod(), "White", "Walter");
BindableQuery stringQuery = query.createQuery(accessor);
assertThat(stringQuery.get())
.isEqualTo("SELECT * FROM person WHERE lastname = :__synthetic_0__ and firstname = :firstname");
assertThat(stringQuery.bind(bindSpec)).isNotNull();
verify(bindSpec).bind("__synthetic_0__", "White");
verify(bindSpec).bind("firstname", "Walter");
verifyNoMoreInteractions(bindSpec);
}
@Test // gh-164
public void bindsComplexSpelQuery() {
StringBasedR2dbcQuery query = getQueryMethod("queryWithSpelObject", Person.class);
R2dbcParameterAccessor accessor = new R2dbcParameterAccessor(query.getQueryMethod(), new Person("Walter"));
BindableQuery stringQuery = query.createQuery(accessor);
assertThat(stringQuery.get()).isEqualTo("SELECT * FROM person WHERE lastname = :__synthetic_0__");
assertThat(stringQuery.bind(bindSpec)).isNotNull();
verify(bindSpec).bind("__synthetic_0__", "Walter");
verifyNoMoreInteractions(bindSpec);
}
private StringBasedR2dbcQuery getQueryMethod(String name, Class<?>... args) {
Method method = ReflectionUtils.findMethod(SampleRepository.class, name, args);
@@ -143,7 +235,10 @@ public class StringBasedR2dbcQueryUnitTests {
private interface SampleRepository extends Repository<Person, String> {
@Query("SELECT * FROM person WHERE lastname = $1")
Person findByLastname(String lastname);
Person findByLastname(@Param("lastname") String lastname);
@Query("SELECT * FROM person WHERE lastname = $1")
Person findByLastnamePositional(String lastname);
@Query("SELECT * FROM person WHERE lastname = :lastname")
Person findByNamedParameter(@Param("lastname") String lastname);
@@ -153,9 +248,33 @@ public class StringBasedR2dbcQueryUnitTests {
@Query("SELECT * FROM person WHERE lastname = @lastname")
Person findByNamedBindMarker(@Param("lastname") String lastname);
@Query("SELECT * FROM person WHERE lastname = :#{'hello'}")
Person simpleSpel();
@Query("SELECT * FROM person WHERE lastname = :#{[0]}")
Person simpleIndexedSpel(String value);
@Query("SELECT * FROM person WHERE lastname = :#{[0]} and firstname = :firstname")
Person simpleNamedSpel(@Param("value") String value, @Param("firstname") String firstname);
@Query("SELECT * FROM person WHERE lastname = :#{#value} and firstname = :firstname")
Person simplePositionalSpel(@Param("value") String value, @Param("firstname") String firstname);
@Query("SELECT * FROM person WHERE lastname = :#{#person.name}")
Person queryWithSpelObject(@Param("person") Person person);
}
static class Person {
String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
}