Add support for SpEL in @Query.

Original Pull Request #2826
Closes #2083
This commit is contained in:
puppylpg 2024-01-20 02:19:03 +08:00 committed by GitHub
parent c6041fb659
commit e1a2412651
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1645 additions and 143 deletions

View File

@ -316,7 +316,7 @@ Repository methods can be defined to have the following return types for returni
.Declare query on the method using the `@Query` annotation.
====
The arguments passed to the method can be inserted into placeholders in the query string. the placeholders are of the form `?0`, `?1`, `?2` etc. for the first, second, third parameter and so on.
The arguments passed to the method can be inserted into placeholders in the query string. The placeholders are of the form `?0`, `?1`, `?2` etc. for the first, second, third parameter and so on.
[source,java]
----
interface BookRepository extends ElasticsearchRepository<Book, String> {
@ -361,3 +361,202 @@ would make an https://www.elastic.co/guide/en/elasticsearch/reference/current/qu
}
----
====
[[elasticsearch.query-methods.at-query.spel]]
=== Using SpEL Expressions
.Declare query on the method using the `@Query` annotation with SpEL expression.
====
https://docs.spring.io/spring-framework/reference/core/expressions.html[SpEL expression] is also supported when defining query in `@Query`.
[source,java]
----
interface BookRepository extends ElasticsearchRepository<Book, String> {
@Query("""
{
"bool":{
"must":[
{
"term":{
"name": "#{#name}"
}
}
]
}
}
""")
Page<Book> findByName(String name, Pageable pageable);
}
----
If for example the function is called with the parameter _John_, it would produce the following query body:
[source,json]
----
{
"bool":{
"must":[
{
"term":{
"name": "John"
}
}
]
}
}
----
====
.accessing parameter property.
====
Supposing that we have the following class as query parameter type:
[source,java]
----
public record QueryParameter(String value) {
}
----
It's easy to access the parameter by `#` symbol, then reference the property `value` with a simple `.`:
[source,java]
----
interface BookRepository extends ElasticsearchRepository<Book, String> {
@Query("""
{
"bool":{
"must":[
{
"term":{
"name": "#{#parameter.value}"
}
}
]
}
}
""")
Page<Book> findByName(QueryParameter parameter, Pageable pageable);
}
----
We can pass `new QueryParameter("John")` as the parameter now, and it will produce the same query string as above.
====
.accessing bean property.
====
https://docs.spring.io/spring-framework/reference/core/expressions/language-ref/bean-references.html[Bean property] is also supported to access. Given that there is a bean named `queryParameter` of type `QueryParameter`, we can access the bean with symbol `@` rather than `#`, and there is no need to declare a parameter of type `QueryParameter` in the query method:
[source,java]
----
interface BookRepository extends ElasticsearchRepository<Book, String> {
@Query("""
{
"bool":{
"must":[
{
"term":{
"name": "#{@queryParameter.value}"
}
}
]
}
}
""")
Page<Book> findByName(Pageable pageable);
}
----
====
.SpEL and `Collection` param.
====
`Collection` parameter is also supported and is as easy to use as normal `String`, such as the following `terms` query:
[source,java]
----
interface BookRepository extends ElasticsearchRepository<Book, String> {
@Query("""
{
"bool":{
"must":[
{
"terms":{
"name": #{#names}
}
}
]
}
}
""")
Page<Book> findByName(Collection<String> names, Pageable pageable);
}
----
NOTE: collection values should not be quoted when declaring the elasticsearch json query.
A collection of `names` like `List.of("name1", "name2")` will produce the following terms query:
[source,json]
----
{
"bool":{
"must":[
{
"terms":{
"name": ["name1", "name2"]
}
}
]
}
}
----
====
.access property in the `Collection` param.
====
https://docs.spring.io/spring-framework/reference/core/expressions/language-ref/collection-projection.html[SpEL Collection Projection] is convenient to use when values in the `Collection` parameter is not plain `String`:
[source,java]
----
interface BookRepository extends ElasticsearchRepository<Book, String> {
@Query("""
{
"bool":{
"must":[
{
"terms":{
"name": #{#parameters.![value]}
}
}
]
}
}
""")
Page<Book> findByName(Collection<QueryParameter> parameters, Pageable pageable);
}
----
This will extract all the `value` property values as a new `Collection` from `QueryParameter` collection, thus takes the same effect as above.
====
.alter parameter name by using `@Param`
====
When accessing the parameter by SpEL, it's also useful to alter the parameter name to another one by `@Param` annotation in Sping Data:
[source,java]
----
interface BookRepository extends ElasticsearchRepository<Book, String> {
@Query("""
{
"bool":{
"must":[
{
"terms":{
"name": #{#another.![value]}
}
}
]
}
}
""")
Page<Book> findByName(@Param("another") Collection<QueryParameter> parameters, Pageable pageable);
}
----
====

View File

@ -43,6 +43,7 @@ import org.springframework.util.Assert;
*
* @author Christoph Strobl
* @author Peter-Josef Meisch
* @author Haibo Liu
* @since 3.2
*/
abstract class AbstractReactiveElasticsearchRepositoryQuery implements RepositoryQuery {
@ -112,7 +113,7 @@ abstract class AbstractReactiveElasticsearchRepositoryQuery implements Repositor
* @param accessor must not be {@literal null}.
* @return
*/
protected abstract BaseQuery createQuery(ElasticsearchParameterAccessor accessor);
protected abstract BaseQuery createQuery(ElasticsearchParametersParameterAccessor accessor);
private ReactiveElasticsearchQueryExecution getExecution(ElasticsearchParameterAccessor accessor,
Converter<Object, Object> resultProcessing) {

View File

@ -21,7 +21,7 @@ import org.springframework.data.repository.query.ParametersParameterAccessor;
* @author Christoph Strobl
* @since 3.2
*/
class ElasticsearchParametersParameterAccessor extends ParametersParameterAccessor
public class ElasticsearchParametersParameterAccessor extends ParametersParameterAccessor
implements ElasticsearchParameterAccessor {
private final Object[] values;

View File

@ -69,9 +69,9 @@ import org.springframework.util.ClassUtils;
*/
public class ElasticsearchQueryMethod extends QueryMethod {
// the following 2 variables exits in the base class, but are private. We need them for
// the following 2 variables exist in the base class, but are private. We need them for
// correct handling of return types (SearchHits), so we have our own values here.
// Alas this means that we have to copy code that initializes these variables and in the
// This means that we have to copy code that initializes these variables and in the
// base class uses them in order to use our variables
protected final Method method;
protected final Class<?> unwrappedReturnType;

View File

@ -17,9 +17,10 @@ package org.springframework.data.elasticsearch.repository.query;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.query.BaseQuery;
import org.springframework.data.elasticsearch.core.query.Query;
import org.springframework.data.elasticsearch.core.query.StringQuery;
import org.springframework.data.elasticsearch.repository.support.StringQueryUtil;
import org.springframework.data.elasticsearch.repository.support.spel.QueryStringSpELEvaluator;
import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
import org.springframework.util.Assert;
/**
@ -30,16 +31,21 @@ import org.springframework.util.Assert;
* @author Mark Paluch
* @author Taylor Ono
* @author Peter-Josef Meisch
* @author Haibo Liu
*/
public class ElasticsearchStringQuery extends AbstractElasticsearchRepositoryQuery {
private final String queryString;
private final QueryMethodEvaluationContextProvider evaluationContextProvider;
public ElasticsearchStringQuery(ElasticsearchQueryMethod queryMethod, ElasticsearchOperations elasticsearchOperations,
String queryString) {
String queryString, QueryMethodEvaluationContextProvider evaluationContextProvider) {
super(queryMethod, elasticsearchOperations);
Assert.notNull(queryString, "Query cannot be empty");
Assert.notNull(evaluationContextProvider, "ExpressionEvaluationContextProvider must not be null");
this.queryString = queryString;
this.evaluationContextProvider = evaluationContextProvider;
}
@Override
@ -58,12 +64,14 @@ public class ElasticsearchStringQuery extends AbstractElasticsearchRepositoryQue
}
protected BaseQuery createQuery(ElasticsearchParametersParameterAccessor parameterAccessor) {
String queryString = new StringQueryUtil(elasticsearchOperations.getElasticsearchConverter().getConversionService())
.replacePlaceholders(this.queryString, parameterAccessor);
var query = new StringQuery(queryString);
QueryStringSpELEvaluator evaluator = new QueryStringSpELEvaluator(queryString, parameterAccessor, queryMethod,
evaluationContextProvider);
var query = new StringQuery(evaluator.evaluate());
query.addSort(parameterAccessor.getSort());
return query;
}
}

View File

@ -16,19 +16,23 @@
package org.springframework.data.elasticsearch.repository.query;
import org.springframework.data.elasticsearch.core.ReactiveElasticsearchOperations;
import org.springframework.data.elasticsearch.core.query.BaseQuery;
import org.springframework.data.elasticsearch.core.query.StringQuery;
import org.springframework.data.elasticsearch.repository.support.StringQueryUtil;
import org.springframework.data.elasticsearch.repository.support.spel.QueryStringSpELEvaluator;
import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
import org.springframework.expression.spel.standard.SpelExpressionParser;
/**
* @author Christoph Strobl
* @author Taylor Ono
* @author Haibo Liu
* @since 3.2
*/
public class ReactiveElasticsearchStringQuery extends AbstractReactiveElasticsearchRepositoryQuery {
private final String query;
private final QueryMethodEvaluationContextProvider evaluationContextProvider;
public ReactiveElasticsearchStringQuery(ReactiveElasticsearchQueryMethod queryMethod,
ReactiveElasticsearchOperations operations, SpelExpressionParser expressionParser,
@ -43,14 +47,17 @@ public class ReactiveElasticsearchStringQuery extends AbstractReactiveElasticsea
super(queryMethod, operations);
this.query = query;
this.evaluationContextProvider = evaluationContextProvider;
}
@Override
protected StringQuery createQuery(ElasticsearchParameterAccessor parameterAccessor) {
String queryString = new StringQueryUtil(
getElasticsearchOperations().getElasticsearchConverter().getConversionService()).replacePlaceholders(this.query,
parameterAccessor);
return new StringQuery(queryString);
protected BaseQuery createQuery(ElasticsearchParametersParameterAccessor parameterAccessor) {
String queryString = new StringQueryUtil(getElasticsearchOperations().getElasticsearchConverter().getConversionService())
.replacePlaceholders(this.query, parameterAccessor);
QueryStringSpELEvaluator evaluator = new QueryStringSpELEvaluator(queryString, parameterAccessor, queryMethod,
evaluationContextProvider);
return new StringQuery(evaluator.evaluate());
}
@Override

View File

@ -40,7 +40,7 @@ public class ReactivePartTreeElasticsearchQuery extends AbstractReactiveElastics
}
@Override
protected BaseQuery createQuery(ElasticsearchParameterAccessor accessor) {
protected BaseQuery createQuery(ElasticsearchParametersParameterAccessor accessor) {
CriteriaQuery query = new ElasticsearchQueryCreator(tree, accessor, getMappingContext()).createQuery();
if (tree.isLimiting()) {

View File

@ -34,6 +34,8 @@ import org.springframework.data.repository.query.QueryLookupStrategy;
import org.springframework.data.repository.query.QueryLookupStrategy.Key;
import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
import org.springframework.data.repository.query.RepositoryQuery;
import org.springframework.expression.TypeConverter;
import org.springframework.expression.spel.support.StandardTypeConverter;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
@ -54,6 +56,7 @@ import static org.springframework.data.querydsl.QuerydslUtils.QUERY_DSL_PRESENT;
* @author Sascha Woo
* @author Peter-Josef Meisch
* @author Ezequiel Antúnez Camacho
* @author Haibo Liu
*/
public class ElasticsearchRepositoryFactory extends RepositoryFactorySupport {
@ -96,11 +99,17 @@ public class ElasticsearchRepositoryFactory extends RepositoryFactorySupport {
@Override
protected Optional<QueryLookupStrategy> getQueryLookupStrategy(@Nullable Key key,
QueryMethodEvaluationContextProvider evaluationContextProvider) {
return Optional.of(new ElasticsearchQueryLookupStrategy());
return Optional.of(new ElasticsearchQueryLookupStrategy(evaluationContextProvider));
}
private class ElasticsearchQueryLookupStrategy implements QueryLookupStrategy {
private final QueryMethodEvaluationContextProvider evaluationContextProvider;
ElasticsearchQueryLookupStrategy(QueryMethodEvaluationContextProvider evaluationContextProvider) {
this.evaluationContextProvider = evaluationContextProvider;
}
/*
* (non-Javadoc)
* @see org.springframework.data.repository.query.QueryLookupStrategy#resolveQuery(java.lang.reflect.Method, org.springframework.data.repository.core.RepositoryMetadata, org.springframework.data.projection.ProjectionFactory, org.springframework.data.repository.core.NamedQueries)
@ -115,9 +124,11 @@ public class ElasticsearchRepositoryFactory extends RepositoryFactorySupport {
if (namedQueries.hasQuery(namedQueryName)) {
String namedQuery = namedQueries.getQuery(namedQueryName);
return new ElasticsearchStringQuery(queryMethod, elasticsearchOperations, namedQuery);
return new ElasticsearchStringQuery(queryMethod, elasticsearchOperations, namedQuery,
evaluationContextProvider);
} else if (queryMethod.hasAnnotatedQuery()) {
return new ElasticsearchStringQuery(queryMethod, elasticsearchOperations, queryMethod.getAnnotatedQuery());
return new ElasticsearchStringQuery(queryMethod, elasticsearchOperations, queryMethod.getAnnotatedQuery(),
evaluationContextProvider);
}
return new ElasticsearchPartQuery(queryMethod, elasticsearchOperations);
}

View File

@ -0,0 +1,92 @@
/*
* 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.elasticsearch.repository.support.spel;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.core.convert.converter.GenericConverter;
import org.springframework.lang.Nullable;
import java.util.*;
/**
* Convert a collection into string for value part of the elasticsearch query.
* <p>
* If the value is type {@link String}, it should be wrapped with square brackets, with each element quoted therefore
* escaped(by {@link ElasticsearchStringValueToStringConverter}) if quotations exist in the original element.
* <p>
* eg: The value part of an elasticsearch terms query should looks like {@code ["hello \"Stranger\"","Another string"]}
* for query
* <pre>
* {@code
* {
* "bool":{
* "must":{
* "terms":{
* "name": ["hello \"Stranger\"", "Another string"]
* }
* }
* }
* }
* }
* </pre>
*
* @since 5.3
* @author Haibo Liu
*/
public class ElasticsearchCollectionValueToStringConverter implements GenericConverter {
private static final String DELIMITER = ",";
private final ConversionService conversionService;
public ElasticsearchCollectionValueToStringConverter(ConversionService conversionService) {
this.conversionService = conversionService;
}
@Override
public Set<ConvertiblePair> getConvertibleTypes() {
return Collections.singleton(new ConvertiblePair(Collection.class, String.class));
}
@Override
@Nullable
public Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
if (source == null) {
return "[]";
}
Collection<?> sourceCollection = (Collection<?>) source;
if (sourceCollection.isEmpty()) {
return "[]";
}
StringJoiner sb = new StringJoiner(DELIMITER, "[", "]");
for (Object sourceElement : sourceCollection) {
// ignore the null value in collection
if (Objects.isNull(sourceElement)) {
continue;
}
Object targetElement = this.conversionService.convert(
sourceElement, sourceType.elementTypeDescriptor(sourceElement), targetType);
if (sourceElement instanceof String) {
sb.add("\"" + targetElement + "\"");
} else {
sb.add(String.valueOf(targetElement));
}
}
return sb.toString();
}
}

View File

@ -0,0 +1,53 @@
/*
* 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.elasticsearch.repository.support.spel;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.core.convert.converter.GenericConverter;
import org.springframework.lang.Nullable;
import java.util.Collections;
import java.util.Set;
import java.util.regex.Matcher;
/**
* Values in elasticsearch query may contain quotations and should be escaped when converting.
* Note that the converter should only be used in this situation, rather than common string to string conversions.
*
* @since 5.3
* @author Haibo Liu
*/
public class ElasticsearchStringValueToStringConverter implements GenericConverter {
@Override
public Set<ConvertiblePair> getConvertibleTypes() {
return Collections.singleton(new ConvertiblePair(String.class, String.class));
}
@Override
@Nullable
public Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
if (source == null) {
return null;
}
return escape(source);
}
private String escape(@Nullable Object source) {
// escape the quotes in the string, because the string should already be quoted manually
return String.valueOf(source).replaceAll("\"", Matcher.quoteReplacement("\\\""));
}
}

View File

@ -0,0 +1,42 @@
/*
* 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.elasticsearch.repository.support.spel;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.converter.ConverterRegistry;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.data.util.Lazy;
/**
* To supply a {@link ConversionService} with custom converters to handle SpEL values in elasticsearch query.
*
* @since 5.3
* @author Haibo Liu
*/
public class ElasticsearchValueSpELConversionService {
public static final Lazy<ConversionService> CONVERSION_SERVICE_LAZY = Lazy.of(
ElasticsearchValueSpELConversionService::buildSpELConversionService);
private static ConversionService buildSpELConversionService() {
// register elasticsearch custom type converter for conversion service
ConversionService conversionService = new DefaultConversionService();
ConverterRegistry converterRegistry = (ConverterRegistry) conversionService;
converterRegistry.addConverter(new ElasticsearchCollectionValueToStringConverter(conversionService));
converterRegistry.addConverter(new ElasticsearchStringValueToStringConverter());
return conversionService;
}
}

View File

@ -0,0 +1,128 @@
/*
* 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.elasticsearch.repository.support.spel;
import org.springframework.data.elasticsearch.core.convert.ConversionException;
import org.springframework.data.elasticsearch.repository.query.ElasticsearchParametersParameterAccessor;
import org.springframework.data.repository.query.QueryMethod;
import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ParserContext;
import org.springframework.expression.TypeConverter;
import org.springframework.expression.common.CompositeStringExpression;
import org.springframework.expression.common.LiteralExpression;
import org.springframework.expression.spel.standard.SpelExpression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.expression.spel.support.StandardTypeConverter;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* To evaluate the SpEL expressions of the query string.
*
* @author Haibo Liu
* @since 5.3
*/
public class QueryStringSpELEvaluator {
private static final SpelExpressionParser PARSER = new SpelExpressionParser();
private static final Map<String, Expression> QUERY_EXPRESSIONS = new ConcurrentHashMap<>();
private final String queryString;
private final ElasticsearchParametersParameterAccessor parameterAccessor;
private final QueryMethod queryMethod;
private final QueryMethodEvaluationContextProvider evaluationContextProvider;
private final TypeConverter elasticsearchSpELTypeConverter;
public QueryStringSpELEvaluator(String queryString, ElasticsearchParametersParameterAccessor parameterAccessor,
QueryMethod queryMethod, QueryMethodEvaluationContextProvider evaluationContextProvider) {
this.queryString = queryString;
this.parameterAccessor = parameterAccessor;
this.queryMethod = queryMethod;
this.evaluationContextProvider = evaluationContextProvider;
this.elasticsearchSpELTypeConverter = new StandardTypeConverter(ElasticsearchValueSpELConversionService.CONVERSION_SERVICE_LAZY);
}
/**
* Evaluate the SpEL parts of the query string.
*
* @return a plain string with values evaluated
*/
public String evaluate() {
Expression expr = getQueryExpression(queryString);
if (expr != null) {
EvaluationContext context = evaluationContextProvider.getEvaluationContext(parameterAccessor.getParameters(),
parameterAccessor.getValues());
if (context instanceof StandardEvaluationContext standardEvaluationContext) {
standardEvaluationContext.setTypeConverter(elasticsearchSpELTypeConverter);
}
String parsed = parseExpressions(expr, context);
Assert.notNull(parsed, "Query parsed by SpEL should not be null");
return parsed;
}
return queryString;
}
/**
* {@link Expression#getValue(EvaluationContext, Class)} is not used because the value part in SpEL should be converted
* by {@link ElasticsearchStringValueToStringConverter} or
* {@link ElasticsearchCollectionValueToStringConverter} to
* escape the quotations, but other literal parts in SpEL expression should not be processed with these converters.
* So we just get the string value from {@link LiteralExpression} directly rather than
* {@link LiteralExpression#getValue(EvaluationContext, Class)}.
*/
private String parseExpressions(Expression rootExpr, EvaluationContext context) {
StringBuilder parsed = new StringBuilder();
if (rootExpr instanceof LiteralExpression literalExpression) {
// get the string literal directly
parsed.append(literalExpression.getExpressionString());
} else if (rootExpr instanceof SpelExpression spelExpression) {
// evaluate the value
String value = spelExpression.getValue(context, String.class);
if (value == null) {
throw new ConversionException(String.format(
"Parameter value can't be null for SpEL expression '%s' in method '%s' when querying elasticsearch",
spelExpression.getExpressionString(), queryMethod.getName()));
}
parsed.append(value);
} else if (rootExpr instanceof CompositeStringExpression compositeStringExpression) {
// parse one by one for composite expression
Expression[] expressions = compositeStringExpression.getExpressions();
for (Expression exp : expressions) {
parsed.append(parseExpressions(exp, context));
}
} else {
// no more
parsed.append(rootExpr.getValue(context, String.class));
}
return parsed.toString();
}
@Nullable
private Expression getQueryExpression(String queryString) {
return QUERY_EXPRESSIONS.computeIfAbsent(queryString, f -> {
Expression expr = PARSER.parseExpression(queryString, ParserContext.TEMPLATE_EXPRESSION);
return expr instanceof LiteralExpression ? null : expr;
});
}
}

View File

@ -323,9 +323,9 @@ public abstract class SearchTemplateIntegrationTests {
@Document(indexName = "#{@indexNameProvider.indexName()}-student")
record Student( //
@Nullable @Id String id, //
@Field(type = FieldType.Text) String firstName, //
@Field(type = FieldType.Text) String lastName //
@Nullable @Id String id, //
@Field(type = FieldType.Text) String firstName, //
@Field(type = FieldType.Text) String lastName //
) {
}
}

View File

@ -25,6 +25,7 @@ import org.springframework.test.context.ContextConfiguration;
/**
* @author Peter-Josef Meisch
* @author Haibo Liu
* @since 4.4
*/
@ContextConfiguration(classes = { CustomMethodRepositoryELCIntegrationTests.Config.class })
@ -40,5 +41,13 @@ public class CustomMethodRepositoryELCIntegrationTests extends CustomMethodRepos
IndexNameProvider indexNameProvider() {
return new IndexNameProvider("custom-method-repository");
}
/**
* a normal bean referenced by SpEL in query
*/
@Bean
QueryParameter queryParameter() {
return new QueryParameter("abc");
}
}
}

View File

@ -16,6 +16,7 @@
package org.springframework.data.elasticsearch.repositories.custommethod;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.data.elasticsearch.annotations.FieldType.*;
import static org.springframework.data.elasticsearch.utils.IdGenerator.*;
@ -52,6 +53,7 @@ import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.SearchPage;
import org.springframework.data.elasticsearch.core.convert.ConversionException;
import org.springframework.data.elasticsearch.core.geo.GeoBox;
import org.springframework.data.elasticsearch.core.geo.GeoPoint;
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
@ -63,6 +65,7 @@ import org.springframework.data.geo.Box;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.Metrics;
import org.springframework.data.geo.Point;
import org.springframework.data.repository.query.Param;
import org.springframework.lang.Nullable;
/**
@ -1522,6 +1525,135 @@ public abstract class CustomMethodRepositoryIntegrationTests {
assertThat(searchHits.getTotalHits()).isEqualTo(20);
}
@Test
void shouldReturnSearchHitsForStringQuerySpEL() {
List<SampleEntity> entities = createSampleEntities("abc", 20);
repository.saveAll(entities);
// when
SearchHits<SampleEntity> searchHits = repository.queryByStringSpEL("abc");
assertThat(searchHits.getTotalHits()).isEqualTo(20);
}
@Test
void shouldRaiseExceptionForNullStringQuerySpEL() {
List<SampleEntity> entities = createSampleEntities("abc", 20);
repository.saveAll(entities);
ConversionException thrown = assertThrows(ConversionException.class, () -> repository.queryByStringSpEL(null));
assertThat(thrown.getMessage())
.isEqualTo("Parameter value can't be null for SpEL expression '#type' in method 'queryByStringSpEL'" +
" when querying elasticsearch");
}
@Test
void shouldReturnSearchHitsForParameterPropertyQuerySpEL() {
List<SampleEntity> entities = createSampleEntities("abc", 20);
repository.saveAll(entities);
QueryParameter param = new QueryParameter("abc");
// when
SearchHits<SampleEntity> searchHits = repository.queryByParameterPropertySpEL(param);
assertThat(searchHits.getTotalHits()).isEqualTo(20);
}
@Test
void shouldReturnSearchHitsForBeanQuerySpEL() {
List<SampleEntity> entities = createSampleEntities("abc", 20);
repository.saveAll(entities);
// when
SearchHits<SampleEntity> searchHits = repository.queryByBeanPropertySpEL();
assertThat(searchHits.getTotalHits()).isEqualTo(20);
}
@Test
void shouldReturnSearchHitsForCollectionQuerySpEL() {
List<SampleEntity> entities = createSampleEntities("abc", 20);
repository.saveAll(entities);
// when
SearchHits<SampleEntity> searchHits = repository.queryByCollectionSpEL(List.of("abc"));
assertThat(searchHits.getTotalHits()).isEqualTo(20);
}
@Test
void shouldRaiseExceptionForNullCollectionQuerySpEL() {
List<SampleEntity> entities = createSampleEntities("abc", 20);
repository.saveAll(entities);
ConversionException thrown = assertThrows(ConversionException.class, () -> repository.queryByCollectionSpEL(null));
assertThat(thrown.getMessage())
.isEqualTo("Parameter value can't be null for SpEL expression '#types' in method 'queryByCollectionSpEL'" +
" when querying elasticsearch");
}
@Test
void shouldNotReturnSearchHitsForEmptyCollectionQuerySpEL() {
List<SampleEntity> entities = createSampleEntities("abc", 20);
repository.saveAll(entities);
// when
SearchHits<SampleEntity> searchHits = repository.queryByCollectionSpEL(List.of());
assertThat(searchHits.getTotalHits()).isEqualTo(0);
}
@Test
void shouldNotReturnSearchHitsForCollectionQueryWithOnlyNullValuesSpEL() {
List<SampleEntity> entities = createSampleEntities("abc", 20);
repository.saveAll(entities);
List<String> params = new ArrayList<>();
params.add(null);
// when
SearchHits<SampleEntity> searchHits = repository.queryByCollectionSpEL(params);
assertThat(searchHits.getTotalHits()).isEqualTo(0);
}
@Test
void shouldIgnoreNullValuesInCollectionQuerySpEL() {
List<SampleEntity> entities = createSampleEntities("abc", 20);
repository.saveAll(entities);
// when
SearchHits<SampleEntity> searchHits = repository.queryByCollectionSpEL(Arrays.asList("abc", null));
assertThat(searchHits.getTotalHits()).isEqualTo(20);
}
@Test
void shouldReturnSearchHitsForParameterPropertyCollectionQuerySpEL() {
List<SampleEntity> entities = createSampleEntities("abc", 20);
repository.saveAll(entities);
QueryParameter param = new QueryParameter("abc");
// when
SearchHits<SampleEntity> searchHits = repository.queryByParameterPropertyCollectionSpEL(List.of(param));
assertThat(searchHits.getTotalHits()).isEqualTo(20);
}
@Test
void shouldReturnSearchHitsForParameterPropertyCollectionQuerySpELWithParamAnnotation() {
List<SampleEntity> entities = createSampleEntities("abc", 20);
repository.saveAll(entities);
QueryParameter param = new QueryParameter("abc");
// when
SearchHits<SampleEntity> searchHits = repository.queryByParameterPropertyCollectionSpELWithParamAnnotation(
List.of(param));
assertThat(searchHits.getTotalHits()).isEqualTo(20);
}
@Test // DATAES-372
void shouldReturnHighlightsOnAnnotatedMethod() {
List<SampleEntity> entities = createSampleEntities("abc", 2);
@ -1940,6 +2072,105 @@ public abstract class CustomMethodRepositoryIntegrationTests {
@Highlight(fields = { @HighlightField(name = "type") })
SearchHits<SampleEntity> queryByString(String type);
/**
* The parameter is annotated with {@link Nullable} deliberately to test that our elasticsearch SpEL converters
* will not accept a null parameter as query value.
*/
@Query("""
{
"bool":{
"must":[
{
"term":{
"type": "#{#type}"
}
}
]
}
}
""")
SearchHits<SampleEntity> queryByStringSpEL(@Nullable String type);
@Query("""
{
"bool":{
"must":[
{
"term":{
"type": "#{#parameter.value}"
}
}
]
}
}
""")
SearchHits<SampleEntity> queryByParameterPropertySpEL(QueryParameter parameter);
@Query("""
{
"bool":{
"must":[
{
"term":{
"type": "#{@queryParameter.value}"
}
}
]
}
}
""")
SearchHits<SampleEntity> queryByBeanPropertySpEL();
/**
* The parameter is annotated with {@link Nullable} deliberately to test that our elasticsearch SpEL converters
* will not accept a null parameter as query value.
*/
@Query("""
{
"bool":{
"must":[
{
"terms":{
"type": #{#types}
}
}
]
}
}
""")
SearchHits<SampleEntity> queryByCollectionSpEL(@Nullable Collection<String> types);
@Query("""
{
"bool":{
"must":[
{
"terms":{
"type": #{#parameters.![value]}
}
}
]
}
}
""")
SearchHits<SampleEntity> queryByParameterPropertyCollectionSpEL(Collection<QueryParameter> parameters);
@Query("""
{
"bool":{
"must":[
{
"terms":{
"type": #{#e.![value]}
}
}
]
}
}
""")
SearchHits<SampleEntity> queryByParameterPropertyCollectionSpELWithParamAnnotation(
@Param("e") Collection<QueryParameter> parameters);
@Query("""
{
"bool":{

View File

@ -0,0 +1,25 @@
/*
* 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.elasticsearch.repositories.custommethod;
/**
* Used as a parameter referenced by SpEL in query method tests.
*
* @param value content
* @author Haibo Liu
*/
public record QueryParameter(String value) {
}

View File

@ -32,6 +32,8 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert;
import org.skyscreamer.jsonassert.JSONCompareMode;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
@ -42,15 +44,18 @@ import org.springframework.data.elasticsearch.annotations.Query;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.query.StringQuery;
import org.springframework.data.elasticsearch.repositories.custommethod.QueryParameter;
import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.core.support.DefaultRepositoryMetadata;
import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
import org.springframework.lang.Nullable;
/**
* @author Christoph Strobl
* @author Peter-Josef Meisch
* @author Niklas Herder
* @author Haibo Liu
*/
@ExtendWith(MockitoExtension.class)
public class ElasticsearchStringQueryUnitTests extends ElasticsearchStringQueryUnitTestBase {
@ -83,6 +88,205 @@ public class ElasticsearchStringQueryUnitTests extends ElasticsearchStringQueryU
.isEqualTo("name:(zero, eleven, one, two, three, four, five, six, seven, eight, nine, ten, eleven, zero, one)");
}
@Test
public void shouldReplaceParametersSpEL() throws Exception {
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNameSpEL", "Luke");
String expected = """
{
"bool":{
"must":{
"term":{
"name": "Luke"
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldReplaceParametersSpELWithQuotes() throws Exception {
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNameSpEL",
"hello \"world\"");
String expected = """
{
"bool":{
"must":{
"term":{
"name": "hello \\"world\\""
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldUseParameterPropertySpEL() throws Exception {
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByParameterPropertySpEL",
new QueryParameter("Luke"));
String expected = """
{
"bool":{
"must":{
"term":{
"name": "Luke"
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldReplaceCollectionSpEL() throws Exception {
final List<String> anotherString = List.of("hello \"Stranger\"", "Another string");
List<String> params = new ArrayList<>(anotherString);
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNamesSpEL", params);
String expected = """
{
"bool":{
"must":{
"terms":{
"name": ["hello \\"Stranger\\"", "Another string"]
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldReplaceNonStringCollectionSpEL() throws Exception {
final List<Integer> ages = List.of(1, 2, 3);
List<Integer> params = new ArrayList<>(ages);
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByAgesSpEL", params);
String expected = """
{
"bool":{
"must":{
"terms":{
"age": [1, 2, 3]
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldReplaceEmptyCollectionSpEL() throws Exception {
final List<String> anotherString = List.of();
List<String> params = new ArrayList<>(anotherString);
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNamesSpEL", params);
String expected = """
{
"bool":{
"must":{
"terms":{
"name": []
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldBeEmptyWithNullValuesInCollectionSpEL() throws Exception {
final List<String> anotherString = List.of();
List<String> params = new ArrayList<>(anotherString);
// add a null value
params.add(null);
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNamesSpEL", params);
String expected = """
{
"bool":{
"must":{
"terms":{
"name": []
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldIgnoreNullValuesInCollectionSpEL() throws Exception {
final List<String> anotherString = List.of("abc");
List<String> params = new ArrayList<>(anotherString);
// add a null value
params.add(null);
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNamesSpEL", params);
String expected = """
{
"bool":{
"must":{
"terms":{
"name": ["abc"]
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldReplaceCollectionParametersSpEL() throws Exception {
final List<QueryParameter> anotherString = List.of(new QueryParameter("hello \"Stranger\""),
new QueryParameter("Another string"));
List<QueryParameter> params = new ArrayList<>(anotherString);
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNamesParameterSpEL", params);
String expected = """
{
"bool":{
"must":{
"terms":{
"name": ["hello \\"Stranger\\"", "Another string"]
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test // #1790
@DisplayName("should escape Strings in query parameters")
void shouldEscapeStringsInQueryParameters() throws Exception {
@ -166,7 +370,8 @@ public class ElasticsearchStringQueryUnitTests extends ElasticsearchStringQueryU
}
private ElasticsearchStringQuery queryForMethod(ElasticsearchQueryMethod queryMethod) {
return new ElasticsearchStringQuery(queryMethod, operations, queryMethod.getAnnotatedQuery());
return new ElasticsearchStringQuery(queryMethod, operations, queryMethod.getAnnotatedQuery(),
QueryMethodEvaluationContextProvider.DEFAULT);
}
private ElasticsearchQueryMethod getQueryMethod(String name, Class<?>... parameters) throws NoSuchMethodException {
@ -187,9 +392,74 @@ public class ElasticsearchStringQueryUnitTests extends ElasticsearchStringQueryU
@Query("{ 'bool' : { 'must' : { 'term' : { 'name' : '?0' } } } }")
Person findByName(String name);
@Query("""
{
"bool":{
"must":{
"term":{
"name": "#{#name}"
}
}
}
}
""")
Person findByNameSpEL(String name);
@Query("""
{
"bool":{
"must":{
"term":{
"name": "#{#param.value}"
}
}
}
}
""")
Person findByParameterPropertySpEL(QueryParameter param);
@Query("{ 'bool' : { 'must' : { 'terms' : { 'name' : ?0 } } } }")
Person findByNameIn(ArrayList<String> names);
@Query("""
{
"bool":{
"must":{
"terms":{
"name": #{#names}
}
}
}
}
""")
Person findByNamesSpEL(ArrayList<String> names);
@Query("""
{
"bool":{
"must":{
"terms":{
"age": #{#ages}
}
}
}
}
""")
Person findByAgesSpEL(ArrayList<Integer> ages);
@Query("""
{
"bool":{
"must":{
"terms":{
"name": #{#names.![value]}
}
}
}
}
""")
Person findByNamesParameterSpEL(ArrayList<QueryParameter> names);
@Query(value = "name:(?0, ?11, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?0, ?1)")
Person findWithRepeatedPlaceholder(String arg0, String arg1, String arg2, String arg3, String arg4, String arg5,
String arg6, String arg7, String arg8, String arg9, String arg10, String arg11);

View File

@ -22,6 +22,7 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
@ -29,12 +30,13 @@ import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert;
import org.skyscreamer.jsonassert.JSONCompareMode;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
@ -45,6 +47,7 @@ import org.springframework.data.elasticsearch.annotations.Query;
import org.springframework.data.elasticsearch.core.ReactiveElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.query.StringQuery;
import org.springframework.data.elasticsearch.repositories.custommethod.QueryParameter;
import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.core.support.DefaultRepositoryMetadata;
@ -55,6 +58,7 @@ import org.springframework.lang.Nullable;
/**
* @author Christoph Strobl
* @author Peter-Josef Meisch
* @author Haibo Liu
*/
@ExtendWith(MockitoExtension.class)
public class ReactiveElasticsearchStringQueryUnitTests extends ElasticsearchStringQueryUnitTestBase {
@ -71,10 +75,7 @@ public class ReactiveElasticsearchStringQueryUnitTests extends ElasticsearchStri
@Test // DATAES-519
public void bindsSimplePropertyCorrectly() throws Exception {
ReactiveElasticsearchStringQuery elasticsearchStringQuery = createQueryForMethod("findByName", String.class);
StubParameterAccessor accessor = new StubParameterAccessor("Luke");
org.springframework.data.elasticsearch.core.query.Query query = elasticsearchStringQuery.createQuery(accessor);
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByName", "Luke");
StringQuery reference = new StringQuery("{ 'bool' : { 'must' : { 'term' : { 'name' : 'Luke' } } } }");
assertThat(query).isInstanceOf(StringQuery.class);
@ -82,20 +83,214 @@ public class ReactiveElasticsearchStringQueryUnitTests extends ElasticsearchStri
}
@Test // DATAES-519
@Disabled("TODO: fix spel query integration")
public void bindsExpressionPropertyCorrectly() throws Exception {
ReactiveElasticsearchStringQuery elasticsearchStringQuery = createQueryForMethod("findByNameWithExpression",
String.class);
StubParameterAccessor accessor = new StubParameterAccessor("Luke");
org.springframework.data.elasticsearch.core.query.Query query = elasticsearchStringQuery.createQuery(accessor);
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNameWithExpression", "Luke");
StringQuery reference = new StringQuery("{ 'bool' : { 'must' : { 'term' : { 'name' : 'Luke' } } } }");
assertThat(query).isInstanceOf(StringQuery.class);
assertThat(((StringQuery) query).getSource()).isEqualTo(reference.getSource());
}
@Test
public void shouldReplaceParametersSpEL() throws Exception {
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNameSpEL", "Luke");
String expected = """
{
"bool":{
"must":{
"term":{
"name": "Luke"
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldReplaceParametersSpELWithQuotes() throws Exception {
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNameSpEL",
"hello \"world\"");
String expected = """
{
"bool":{
"must":{
"term":{
"name": "hello \\"world\\""
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldUseParameterPropertySpEL() throws Exception {
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByParameterPropertySpEL",
new QueryParameter("Luke"));
String expected = """
{
"bool":{
"must":{
"term":{
"name": "Luke"
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldReplaceCollectionSpEL() throws Exception {
final List<String> anotherString = List.of("hello \"Stranger\"", "Another string");
List<String> params = new ArrayList<>(anotherString);
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNamesSpEL", params);
String expected = """
{
"bool":{
"must":{
"terms":{
"name": ["hello \\"Stranger\\"", "Another string"]
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldReplaceNonStringCollectionSpEL() throws Exception {
final List<Integer> ages = List.of(1, 2, 3);
List<Integer> params = new ArrayList<>(ages);
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByAgesSpEL", params);
String expected = """
{
"bool":{
"must":{
"terms":{
"age": [1, 2, 3]
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldReplaceEmptyCollectionSpEL() throws Exception {
final List<String> anotherString = List.of();
List<String> params = new ArrayList<>(anotherString);
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNamesSpEL", params);
String expected = """
{
"bool":{
"must":{
"terms":{
"name": []
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldBeEmptyWithNullValuesInCollectionSpEL() throws Exception {
final List<String> anotherString = List.of();
List<String> params = new ArrayList<>(anotherString);
// add a null value
params.add(null);
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNamesSpEL", params);
String expected = """
{
"bool":{
"must":{
"terms":{
"name": []
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldIgnoreNullValuesInCollectionSpEL() throws Exception {
final List<String> anotherString = List.of("abc");
List<String> params = new ArrayList<>(anotherString);
// add a null value
params.add(null);
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNamesSpEL", params);
String expected = """
{
"bool":{
"must":{
"terms":{
"name": ["abc"]
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test
public void shouldReplaceCollectionParametersSpEL() throws Exception {
final List<QueryParameter> anotherString = List.of(new QueryParameter("hello \"Stranger\""),
new QueryParameter("Another string"));
List<QueryParameter> params = new ArrayList<>(anotherString);
org.springframework.data.elasticsearch.core.query.Query query = createQuery("findByNamesParameterSpEL", params);
String expected = """
{
"bool":{
"must":{
"terms":{
"name": ["hello \\"Stranger\\"", "Another string"]
}
}
}
}
""";
assertThat(query).isInstanceOf(StringQuery.class);
JSONAssert.assertEquals(((StringQuery) query).getSource(), expected, JSONCompareMode.NON_EXTENSIBLE);
}
@Test // DATAES-552
public void shouldReplaceLotsOfParametersCorrectly() throws Exception {
@ -205,7 +400,59 @@ public class ReactiveElasticsearchStringQueryUnitTests extends ElasticsearchStri
@Query("{ 'bool' : { 'must' : { 'term' : { 'name' : '?0' } } } }")
Mono<Person> findByName(String name);
@Query("{ 'bool' : { 'must' : { 'term' : { 'name' : '?#{[0]}' } } } }")
@Query("""
{
"bool":{
"must":{
"term":{
"name": "#{#name}"
}
}
}
}
""")
Mono<Person> findByNameSpEL(String name);
@Query("""
{
"bool":{
"must":{
"terms":{
"name": #{#names}
}
}
}
}
""")
Flux<Person> findByNamesSpEL(List<String> names);
@Query("""
{
"bool":{
"must":{
"term":{
"name": "#{#param.value}"
}
}
}
}
""")
Flux<Person> findByParameterPropertySpEL(QueryParameter param);
@Query("""
{
"bool":{
"must":{
"terms":{
"name": #{#names.![value]}
}
}
}
}
""")
Flux<Person> findByNamesParameterSpEL(List<QueryParameter> names);
@Query("{ 'bool' : { 'must' : { 'term' : { 'name' : '#{[0]}' } } } }")
Flux<Person> findByNameWithExpression(String param0);
@Query(value = "name:(?0, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)")
@ -228,6 +475,18 @@ public class ReactiveElasticsearchStringQueryUnitTests extends ElasticsearchStri
@Query("{ 'bool' : { 'must' : { 'terms' : { 'ages' : ?0 } } } }")
Flux<Person> findByAges(List<Integer> ages);
@Query("""
{
"bool":{
"must":{
"terms":{
"age": #{#ages}
}
}
}
}
""")
Flux<Person> findByAgesSpEL(List<Integer> ages);
}
/**

View File

@ -1,108 +0,0 @@
/*
* Copyright 2019-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.elasticsearch.repository.query;
import java.util.Arrays;
import java.util.Iterator;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Sort;
import org.springframework.data.repository.query.ParameterAccessor;
/**
* Simple {@link ParameterAccessor} that returns the given parameters unfiltered.
*
* @author Christoph Strobl
* @author Peter-Josef Meisch
*/
class StubParameterAccessor implements ElasticsearchParameterAccessor {
private final Object[] values;
StubParameterAccessor(Object... values) {
this.values = values;
}
@Override
public ScrollPosition getScrollPosition() {
return null;
}
/*
* (non-Javadoc)
* @see org.springframework.data.repository.query.ParameterAccessor#getPageable()
*/
@Override
public Pageable getPageable() {
return Pageable.unpaged();
}
/*
* (non-Javadoc)
* @see org.springframework.data.repository.query.ParameterAccessor#getBindableValue(int)
*/
@Override
public Object getBindableValue(int index) {
return values[index];
}
/*
* (non-Javadoc)
* @see org.springframework.data.repository.query.ParameterAccessor#hasBindableNullValue()
*/
@Override
public boolean hasBindableNullValue() {
return false;
}
/*
* (non-Javadoc)
* @see org.springframework.data.repository.query.ParameterAccessor#getSort()
*/
@Override
public Sort getSort() {
return Sort.unsorted();
}
/*
* (non-Javadoc)
* @see org.springframework.data.repository.query.ParameterAccessor#iterator()
*/
@Override
public Iterator<Object> iterator() {
return Arrays.asList(values).iterator();
}
/*
* (non-Javadoc)
* @see org.springframework.data.elasticsearch.repository.query.ElasticsearchParameterAccessor#getValues()
*/
@Override
public Object[] getValues() {
return this.values;
}
/*
* (non-Javadoc)
* @see org.springframework.data.repository.query.ParameterAccessor#findDynamicProjection()
*/
@Override
public Class<?> findDynamicProjection() {
return null;
}
}

View File

@ -19,12 +19,14 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.data.elasticsearch.junit.jupiter.ReactiveElasticsearchTemplateConfiguration;
import org.springframework.data.elasticsearch.repositories.custommethod.QueryParameter;
import org.springframework.data.elasticsearch.repository.config.EnableReactiveElasticsearchRepositories;
import org.springframework.data.elasticsearch.utils.IndexNameProvider;
import org.springframework.test.context.ContextConfiguration;
/**
* @author Peter-Josef Meisch
* @author Haibo Liu
* @since 4.4
*/
@ContextConfiguration(classes = { SimpleReactiveElasticsearchRepositoryELCIntegrationTests.Config.class })
@ -39,6 +41,14 @@ public class SimpleReactiveElasticsearchRepositoryELCIntegrationTests
IndexNameProvider indexNameProvider() {
return new IndexNameProvider("simple-reactive-repository");
}
/**
* a normal bean referenced by SpEL in query
*/
@Bean
QueryParameter queryParameter() {
return new QueryParameter("message");
}
}
}

View File

@ -16,10 +16,14 @@
package org.springframework.data.elasticsearch.repository.support;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.data.elasticsearch.core.IndexOperationsAdapter.*;
import static org.springframework.data.elasticsearch.core.query.Query.*;
import static org.springframework.data.elasticsearch.utils.IdGenerator.*;
import org.springframework.data.elasticsearch.core.convert.ConversionException;
import org.springframework.data.elasticsearch.repositories.custommethod.QueryParameter;
import org.springframework.data.repository.query.Param;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@ -64,6 +68,7 @@ import org.springframework.lang.Nullable;
* @author Christoph Strobl
* @author Peter-Josef Meisch
* @author Jens Schauder
* @author Haibo Liu
*/
@SpringIntegrationTest
abstract class SimpleReactiveElasticsearchRepositoryIntegrationTests {
@ -234,6 +239,167 @@ abstract class SimpleReactiveElasticsearchRepositoryIntegrationTests {
.verifyComplete();
}
@Test
void shouldReturnSearchHitsForStringQuerySpEL() {
bulkIndex(new SampleEntity("id-one", "message"), //
new SampleEntity("id-two", "message"), //
new SampleEntity("id-three", "message")) //
.block();
repository.queryByStringSpEL("message")
.as(StepVerifier::create) //
.expectNextMatches(searchHit -> SearchHit.class.isAssignableFrom(searchHit.getClass()))//
.expectNextCount(2) //
.verifyComplete();
}
@Test
void shouldRaiseExceptionForNullStringQuerySpEL() {
bulkIndex(new SampleEntity("id-one", "message"), //
new SampleEntity("id-two", "message"), //
new SampleEntity("id-three", "message")) //
.block();
ConversionException thrown = assertThrows(ConversionException.class, () -> repository.queryByStringSpEL(null));
assertThat(thrown.getMessage())
.isEqualTo("Parameter value can't be null for SpEL expression '#message' in method 'queryByStringSpEL'" +
" when querying elasticsearch");
}
@Test
void shouldReturnSearchHitsForParameterPropertyQuerySpEL() {
bulkIndex(new SampleEntity("id-one", "message"), //
new SampleEntity("id-two", "message"), //
new SampleEntity("id-three", "message")) //
.block();
QueryParameter param = new QueryParameter("message");
repository.queryByParameterPropertySpEL(param)
.as(StepVerifier::create) //
.expectNextMatches(searchHit -> SearchHit.class.isAssignableFrom(searchHit.getClass()))//
.expectNextCount(2) //
.verifyComplete();
}
@Test
void shouldReturnSearchHitsForBeanQuerySpEL() {
bulkIndex(new SampleEntity("id-one", "message"), //
new SampleEntity("id-two", "message"), //
new SampleEntity("id-three", "message")) //
.block();
repository.queryByBeanPropertySpEL()
.as(StepVerifier::create) //
.expectNextMatches(searchHit -> SearchHit.class.isAssignableFrom(searchHit.getClass()))//
.expectNextCount(2) //
.verifyComplete();
}
@Test
void shouldReturnSearchHitsForCollectionQuerySpEL() {
bulkIndex(new SampleEntity("id-one", "message"), //
new SampleEntity("id-two", "message"), //
new SampleEntity("id-three", "message")) //
.block();
repository.queryByCollectionSpEL(List.of("message"))
.as(StepVerifier::create) //
.expectNextMatches(searchHit -> SearchHit.class.isAssignableFrom(searchHit.getClass()))//
.expectNextCount(2) //
.verifyComplete();
}
@Test
void shouldRaiseExceptionForNullCollectionQuerySpEL() {
bulkIndex(new SampleEntity("id-one", "message"), //
new SampleEntity("id-two", "message"), //
new SampleEntity("id-three", "message")) //
.block();
ConversionException thrown = assertThrows(ConversionException.class, () -> repository.queryByCollectionSpEL(null));
assertThat(thrown.getMessage())
.isEqualTo("Parameter value can't be null for SpEL expression '#messages' in method 'queryByCollectionSpEL'" +
" when querying elasticsearch");
}
@Test
void shouldNotReturnSearchHitsForEmptyCollectionQuerySpEL() {
bulkIndex(new SampleEntity("id-one", "message"), //
new SampleEntity("id-two", "message"), //
new SampleEntity("id-three", "message")) //
.block();
repository.queryByCollectionSpEL(List.of())
.as(StepVerifier::create) //
.expectNextCount(0) //
.verifyComplete();
}
@Test
void shouldNotReturnSearchHitsForCollectionQueryWithOnlyNullValuesSpEL() {
bulkIndex(new SampleEntity("id-one", "message"), //
new SampleEntity("id-two", "message"), //
new SampleEntity("id-three", "message")) //
.block();
List<String> params = new ArrayList<>();
params.add(null);
repository.queryByCollectionSpEL(params)
.as(StepVerifier::create) //
.expectNextCount(0) //
.verifyComplete();
}
@Test
void shouldIgnoreNullValuesInCollectionQuerySpEL() {
bulkIndex(new SampleEntity("id-one", "message"), //
new SampleEntity("id-two", "message"), //
new SampleEntity("id-three", "message")) //
.block();
repository.queryByCollectionSpEL(Arrays.asList("message", null))
.as(StepVerifier::create) //
.expectNextMatches(searchHit -> SearchHit.class.isAssignableFrom(searchHit.getClass()))//
.expectNextCount(2) //
.verifyComplete();
}
@Test
void shouldReturnSearchHitsForParameterPropertyCollectionQuerySpEL() {
bulkIndex(new SampleEntity("id-one", "message"), //
new SampleEntity("id-two", "message"), //
new SampleEntity("id-three", "message")) //
.block();
QueryParameter param = new QueryParameter("message");
repository.queryByParameterPropertyCollectionSpEL(List.of(param))
.as(StepVerifier::create) //
.expectNextMatches(searchHit -> SearchHit.class.isAssignableFrom(searchHit.getClass()))//
.expectNextCount(2) //
.verifyComplete();
}
@Test
void shouldReturnSearchHitsForParameterPropertyCollectionQuerySpELWithParamAnnotation() {
bulkIndex(new SampleEntity("id-one", "message"), //
new SampleEntity("id-two", "message"), //
new SampleEntity("id-three", "message")) //
.block();
QueryParameter param = new QueryParameter("message");
repository.queryByParameterPropertyCollectionSpELWithParamAnnotation(List.of(param))
.as(StepVerifier::create) //
.expectNextMatches(searchHit -> SearchHit.class.isAssignableFrom(searchHit.getClass()))//
.expectNextCount(2) //
.verifyComplete();
}
@Test // DATAES-372
void shouldReturnHighlightsOnAnnotatedMethod() {
@ -787,6 +953,105 @@ abstract class SimpleReactiveElasticsearchRepositoryIntegrationTests {
@Query("{ \"bool\" : { \"must\" : { \"term\" : { \"message\" : \"?0\" } } } }")
Flux<SampleEntity> findAllViaAnnotatedQueryByMessageLikePaged(String message, Pageable pageable);
/**
* The parameter is annotated with {@link Nullable} deliberately to test that our elasticsearch SpEL converters
* will not accept a null parameter as query value.
*/
@Query("""
{
"bool":{
"must":[
{
"term":{
"message": "#{#message}"
}
}
]
}
}
""")
Flux<SearchHit<SampleEntity>> queryByStringSpEL(@Nullable String message);
@Query("""
{
"bool":{
"must":[
{
"term":{
"message": "#{#parameter.value}"
}
}
]
}
}
""")
Flux<SearchHit<SampleEntity>> queryByParameterPropertySpEL(QueryParameter parameter);
@Query("""
{
"bool":{
"must":[
{
"term":{
"message": "#{@queryParameter.value}"
}
}
]
}
}
""")
Flux<SearchHit<SampleEntity>> queryByBeanPropertySpEL();
/**
* The parameter is annotated with {@link Nullable} deliberately to test that our elasticsearch SpEL converters
* will not accept a null parameter as query value.
*/
@Query("""
{
"bool":{
"must":[
{
"terms":{
"message": #{#messages}
}
}
]
}
}
""")
Flux<SearchHit<SampleEntity>> queryByCollectionSpEL(@Nullable Collection<String> messages);
@Query("""
{
"bool":{
"must":[
{
"terms":{
"message": #{#parameters.![value]}
}
}
]
}
}
""")
Flux<SearchHit<SampleEntity>> queryByParameterPropertyCollectionSpEL(Collection<QueryParameter> parameters);
@Query("""
{
"bool":{
"must":[
{
"terms":{
"message": #{#e.![value]}
}
}
]
}
}
""")
Flux<SearchHit<SampleEntity>> queryByParameterPropertyCollectionSpELWithParamAnnotation(
@Param("e") Collection<QueryParameter> parameters);
Mono<SampleEntity> findFirstByMessageLike(String message);
Mono<Long> countAllByMessage(String message);

View File

@ -16,7 +16,7 @@
package org.springframework.data.elasticsearch.utils;
/**
* Class providing an index name with a prefix and a index number
* Class providing an index name with a prefix and an index number
*
* @author Peter-Josef Meisch
*/