Implement SourceFilters annotation.

Original Pull Request #2254
Closes #1280
Closes #2062
Closes #2146
Closes #2147
Closes #2151
This commit is contained in:
Peter-Josef Meisch 2022-08-06 21:04:13 +02:00 committed by GitHub
parent 954d8b0f97
commit cf135f4cdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 602 additions and 137 deletions

View File

@ -15,8 +15,14 @@
*/ */
package org.springframework.data.elasticsearch.annotations; package org.springframework.data.elasticsearch.annotations;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.core.annotation.AliasFor;
import org.springframework.data.annotation.QueryAnnotation; import org.springframework.data.annotation.QueryAnnotation;
import java.lang.annotation.*;
/** /**
* Query * Query
@ -34,10 +40,18 @@ import java.lang.annotation.*;
public @interface Query { public @interface Query {
/** /**
* @return Elasticsearch query to be used when executing query. May contain placeholders eg. ?0 * @return Elasticsearch query to be used when executing query. May contain placeholders eg. ?0. Alias for query.
*/ */
@AliasFor("query")
String value() default ""; String value() default "";
/**
* @return Elasticsearch query to be used when executing query. May contain placeholders eg. ?0. Alias for value
* @since 5.0
*/
@AliasFor("value")
String query() default "";
/** /**
* Named Query Named looked up by repository. * Named Query Named looked up by repository.
* *

View File

@ -0,0 +1,73 @@
/*
* Copyright 2022 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.annotations;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* This annotation can be placed on repository methods to define the properties that should be requested from
* Elasticsearch when the method is run.
*
* @author Alexander Torres
* @author Peter-Josef Meisch
* @since 5.0
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE })
@Documented
public @interface SourceFilters {
/**
* Properties to be requested from Elasticsearch to be included in the response. These can be passed in as literals
* like
*
* <pre>
* {@code @SourceFilters(includes = {"property1", "property2"})}
* </pre>
*
* or as a parameterized value
*
* <pre>
* {@code @SourceFilters(includes = "?0")}
* </pre>
*
* when the list of properties is passed as a function parameter.
*/
String[] includes() default "";
/**
* Properties to be requested from Elasticsearch to be excluded in the response. These can be passed in as literals
* like
*
* <pre>
* {@code @SourceFilters(excludes = {"property1", "property2"})}
* </pre>
*
* or as a parameterized value
*
* <pre>
* {@code @SourceFilters(excludes = "?0")}
* </pre>
*
* when the list of properties is passed as a function parameter.
*/
String[] excludes() default "";
}

View File

@ -16,6 +16,9 @@
package org.springframework.data.elasticsearch.repository.query; package org.springframework.data.elasticsearch.repository.query;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations; import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.convert.ElasticsearchConverter;
import org.springframework.data.elasticsearch.core.query.Query;
import org.springframework.data.repository.query.ParameterAccessor;
import org.springframework.data.repository.query.QueryMethod; import org.springframework.data.repository.query.QueryMethod;
import org.springframework.data.repository.query.RepositoryQuery; import org.springframework.data.repository.query.RepositoryQuery;
@ -31,12 +34,14 @@ public abstract class AbstractElasticsearchRepositoryQuery implements Repository
protected static final int DEFAULT_STREAM_BATCH_SIZE = 500; protected static final int DEFAULT_STREAM_BATCH_SIZE = 500;
protected ElasticsearchQueryMethod queryMethod; protected ElasticsearchQueryMethod queryMethod;
protected ElasticsearchOperations elasticsearchOperations; protected final ElasticsearchOperations elasticsearchOperations;
protected final ElasticsearchConverter elasticsearchConverter;
public AbstractElasticsearchRepositoryQuery(ElasticsearchQueryMethod queryMethod, public AbstractElasticsearchRepositoryQuery(ElasticsearchQueryMethod queryMethod,
ElasticsearchOperations elasticsearchOperations) { ElasticsearchOperations elasticsearchOperations) {
this.queryMethod = queryMethod; this.queryMethod = queryMethod;
this.elasticsearchOperations = elasticsearchOperations; this.elasticsearchOperations = elasticsearchOperations;
this.elasticsearchConverter = elasticsearchOperations.getElasticsearchConverter();
} }
@Override @Override
@ -49,4 +54,19 @@ public abstract class AbstractElasticsearchRepositoryQuery implements Repository
* @since 4.2 * @since 4.2
*/ */
public abstract boolean isCountQuery(); public abstract boolean isCountQuery();
protected void prepareQuery(Query query, Class<?> clazz, ParameterAccessor parameterAccessor) {
elasticsearchConverter.updateQuery(query, clazz);
if (queryMethod.hasAnnotatedHighlight()) {
query.setHighlightQuery(queryMethod.getAnnotatedHighlightQuery());
}
var sourceFilter = queryMethod.getSourceFilter(parameterAccessor,
elasticsearchOperations.getElasticsearchConverter());
if (sourceFilter != null) {
query.addSourceFilter(sourceFilter);
}
}
} }

View File

@ -27,6 +27,7 @@ import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersiste
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
import org.springframework.data.elasticsearch.core.query.ByQueryResponse; import org.springframework.data.elasticsearch.core.query.ByQueryResponse;
import org.springframework.data.elasticsearch.core.query.Query; import org.springframework.data.elasticsearch.core.query.Query;
import org.springframework.data.elasticsearch.core.query.SourceFilter;
import org.springframework.data.elasticsearch.repository.query.ReactiveElasticsearchQueryExecution.ResultProcessingConverter; import org.springframework.data.elasticsearch.repository.query.ReactiveElasticsearchQueryExecution.ResultProcessingConverter;
import org.springframework.data.elasticsearch.repository.query.ReactiveElasticsearchQueryExecution.ResultProcessingExecution; import org.springframework.data.elasticsearch.repository.query.ReactiveElasticsearchQueryExecution.ResultProcessingExecution;
import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mapping.context.MappingContext;
@ -88,6 +89,12 @@ abstract class AbstractReactiveElasticsearchRepositoryQuery implements Repositor
query.setHighlightQuery(queryMethod.getAnnotatedHighlightQuery()); query.setHighlightQuery(queryMethod.getAnnotatedHighlightQuery());
} }
var sourceFilter = queryMethod.getSourceFilter(parameterAccessor,
elasticsearchOperations.getElasticsearchConverter());
if (sourceFilter != null) {
query.addSourceFilter(sourceFilter);
}
Class<?> targetType = processor.getReturnedType().getTypeToRead(); Class<?> targetType = processor.getReturnedType().getTypeToRead();
String indexName = queryMethod.getEntityInformation().getIndexName(); String indexName = queryMethod.getEntityInformation().getIndexName();
IndexCoordinates index = IndexCoordinates.of(indexName); IndexCoordinates index = IndexCoordinates.of(indexName);

View File

@ -23,7 +23,6 @@ import org.springframework.data.elasticsearch.core.SearchHitSupport;
import org.springframework.data.elasticsearch.core.SearchHits; import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.SearchHitsImpl; import org.springframework.data.elasticsearch.core.SearchHitsImpl;
import org.springframework.data.elasticsearch.core.TotalHitsRelation; import org.springframework.data.elasticsearch.core.TotalHitsRelation;
import org.springframework.data.elasticsearch.core.convert.ElasticsearchConverter;
import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty; import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty;
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
import org.springframework.data.elasticsearch.core.query.CriteriaQuery; import org.springframework.data.elasticsearch.core.query.CriteriaQuery;
@ -49,13 +48,11 @@ import org.springframework.util.ClassUtils;
public class ElasticsearchPartQuery extends AbstractElasticsearchRepositoryQuery { public class ElasticsearchPartQuery extends AbstractElasticsearchRepositoryQuery {
private final PartTree tree; private final PartTree tree;
private final ElasticsearchConverter elasticsearchConverter;
private final MappingContext<?, ElasticsearchPersistentProperty> mappingContext; private final MappingContext<?, ElasticsearchPersistentProperty> mappingContext;
public ElasticsearchPartQuery(ElasticsearchQueryMethod method, ElasticsearchOperations elasticsearchOperations) { public ElasticsearchPartQuery(ElasticsearchQueryMethod method, ElasticsearchOperations elasticsearchOperations) {
super(method, elasticsearchOperations); super(method, elasticsearchOperations);
this.tree = new PartTree(queryMethod.getName(), queryMethod.getResultProcessor().getReturnedType().getDomainType()); this.tree = new PartTree(queryMethod.getName(), queryMethod.getResultProcessor().getReturnedType().getDomainType());
this.elasticsearchConverter = elasticsearchOperations.getElasticsearchConverter();
this.mappingContext = elasticsearchConverter.getMappingContext(); this.mappingContext = elasticsearchConverter.getMappingContext();
} }
@ -66,18 +63,16 @@ public class ElasticsearchPartQuery extends AbstractElasticsearchRepositoryQuery
@Override @Override
public Object execute(Object[] parameters) { public Object execute(Object[] parameters) {
Class<?> clazz = queryMethod.getResultProcessor().getReturnedType().getDomainType();
ParametersParameterAccessor accessor = new ParametersParameterAccessor(queryMethod.getParameters(), parameters);
CriteriaQuery query = createQuery(accessor); Class<?> clazz = queryMethod.getResultProcessor().getReturnedType().getDomainType();
ParametersParameterAccessor parameterAccessor = new ParametersParameterAccessor(queryMethod.getParameters(),
parameters);
CriteriaQuery query = createQuery(parameterAccessor);
Assert.notNull(query, "unsupported query"); Assert.notNull(query, "unsupported query");
elasticsearchConverter.updateQuery(query, clazz); prepareQuery(query, clazz, parameterAccessor);
if (queryMethod.hasAnnotatedHighlight()) {
query.setHighlightQuery(queryMethod.getAnnotatedHighlightQuery());
}
IndexCoordinates index = elasticsearchOperations.getIndexCoordinatesFor(clazz); IndexCoordinates index = elasticsearchOperations.getIndexCoordinatesFor(clazz);
@ -89,11 +84,11 @@ public class ElasticsearchPartQuery extends AbstractElasticsearchRepositoryQuery
} }
if (tree.isDelete()) { if (tree.isDelete()) {
result = countOrGetDocumentsForDelete(query, accessor); result = countOrGetDocumentsForDelete(query, parameterAccessor);
elasticsearchOperations.delete(query, clazz, index); elasticsearchOperations.delete(query, clazz, index);
elasticsearchOperations.indexOps(index).refresh(); elasticsearchOperations.indexOps(index).refresh();
} else if (queryMethod.isPageQuery()) { } else if (queryMethod.isPageQuery()) {
query.setPageable(accessor.getPageable()); query.setPageable(parameterAccessor.getPageable());
SearchHits<?> searchHits = elasticsearchOperations.search(query, clazz, index); SearchHits<?> searchHits = elasticsearchOperations.search(query, clazz, index);
if (queryMethod.isSearchPageMethod()) { if (queryMethod.isSearchPageMethod()) {
result = SearchHitSupport.searchPageFor(searchHits, query.getPageable()); result = SearchHitSupport.searchPageFor(searchHits, query.getPageable());
@ -101,15 +96,15 @@ public class ElasticsearchPartQuery extends AbstractElasticsearchRepositoryQuery
result = SearchHitSupport.unwrapSearchHits(SearchHitSupport.searchPageFor(searchHits, query.getPageable())); result = SearchHitSupport.unwrapSearchHits(SearchHitSupport.searchPageFor(searchHits, query.getPageable()));
} }
} else if (queryMethod.isStreamQuery()) { } else if (queryMethod.isStreamQuery()) {
if (accessor.getPageable().isUnpaged()) { if (parameterAccessor.getPageable().isUnpaged()) {
query.setPageable(PageRequest.of(0, DEFAULT_STREAM_BATCH_SIZE)); query.setPageable(PageRequest.of(0, DEFAULT_STREAM_BATCH_SIZE));
} else { } else {
query.setPageable(accessor.getPageable()); query.setPageable(parameterAccessor.getPageable());
} }
result = StreamUtils.createStreamFromIterator(elasticsearchOperations.searchForStream(query, clazz, index)); result = StreamUtils.createStreamFromIterator(elasticsearchOperations.searchForStream(query, clazz, index));
} else if (queryMethod.isCollectionQuery()) { } else if (queryMethod.isCollectionQuery()) {
if (accessor.getPageable().isUnpaged()) { if (parameterAccessor.getPageable().isUnpaged()) {
int itemCount = (int) elasticsearchOperations.count(query, clazz, index); int itemCount = (int) elasticsearchOperations.count(query, clazz, index);
if (itemCount == 0) { if (itemCount == 0) {
@ -119,7 +114,7 @@ public class ElasticsearchPartQuery extends AbstractElasticsearchRepositoryQuery
query.setPageable(PageRequest.of(0, Math.max(1, itemCount))); query.setPageable(PageRequest.of(0, Math.max(1, itemCount)));
} }
} else { } else {
query.setPageable(accessor.getPageable()); query.setPageable(parameterAccessor.getPageable());
} }
if (result == null) { if (result == null) {

View File

@ -17,22 +17,31 @@ package org.springframework.data.elasticsearch.repository.query;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType; import java.lang.reflect.ParameterizedType;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.List;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.elasticsearch.annotations.Highlight; import org.springframework.data.elasticsearch.annotations.Highlight;
import org.springframework.data.elasticsearch.annotations.Query; import org.springframework.data.elasticsearch.annotations.Query;
import org.springframework.data.elasticsearch.annotations.SourceFilters;
import org.springframework.data.elasticsearch.core.SearchHit; import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits; import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.SearchPage; import org.springframework.data.elasticsearch.core.SearchPage;
import org.springframework.data.elasticsearch.core.convert.ElasticsearchConverter;
import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentEntity; import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentEntity;
import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty; import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty;
import org.springframework.data.elasticsearch.core.query.FetchSourceFilterBuilder;
import org.springframework.data.elasticsearch.core.query.HighlightQuery; import org.springframework.data.elasticsearch.core.query.HighlightQuery;
import org.springframework.data.elasticsearch.core.query.SourceFilter;
import org.springframework.data.elasticsearch.repository.support.StringQueryUtil;
import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.repository.core.RepositoryMetadata; import org.springframework.data.repository.core.RepositoryMetadata;
import org.springframework.data.repository.query.ParameterAccessor;
import org.springframework.data.repository.query.QueryMethod; import org.springframework.data.repository.query.QueryMethod;
import org.springframework.data.util.Lazy; import org.springframework.data.util.Lazy;
import org.springframework.data.util.TypeInformation; import org.springframework.data.util.TypeInformation;
@ -49,16 +58,19 @@ import org.springframework.util.ClassUtils;
* @author Mark Paluch * @author Mark Paluch
* @author Christoph Strobl * @author Christoph Strobl
* @author Peter-Josef Meisch * @author Peter-Josef Meisch
* @author Alexander Torres
*/ */
public class ElasticsearchQueryMethod extends QueryMethod { public class ElasticsearchQueryMethod extends QueryMethod {
private final MappingContext<? extends ElasticsearchPersistentEntity<?>, ElasticsearchPersistentProperty> mappingContext; private final MappingContext<? extends ElasticsearchPersistentEntity<?>, ElasticsearchPersistentProperty> mappingContext;
private @Nullable ElasticsearchEntityMetadata<?> metadata; @Nullable private ElasticsearchEntityMetadata<?> metadata;
protected final Method method; // private in base class, but needed here and in derived classes as well protected final Method method; // private in base class, but needed here and in derived classes as well
@Nullable private final Query queryAnnotation; @Nullable private final Query queryAnnotation;
@Nullable private final Highlight highlightAnnotation; @Nullable private final Highlight highlightAnnotation;
private final Lazy<HighlightQuery> highlightQueryLazy = Lazy.of(this::createAnnotatedHighlightQuery); private final Lazy<HighlightQuery> highlightQueryLazy = Lazy.of(this::createAnnotatedHighlightQuery);
@Nullable private final SourceFilters sourceFilters;
public ElasticsearchQueryMethod(Method method, RepositoryMetadata repositoryMetadata, ProjectionFactory factory, public ElasticsearchQueryMethod(Method method, RepositoryMetadata repositoryMetadata, ProjectionFactory factory,
MappingContext<? extends ElasticsearchPersistentEntity<?>, ElasticsearchPersistentProperty> mappingContext) { MappingContext<? extends ElasticsearchPersistentEntity<?>, ElasticsearchPersistentProperty> mappingContext) {
@ -70,6 +82,7 @@ public class ElasticsearchQueryMethod extends QueryMethod {
this.mappingContext = mappingContext; this.mappingContext = mappingContext;
this.queryAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, Query.class); this.queryAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, Query.class);
this.highlightAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, Highlight.class); this.highlightAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, Highlight.class);
this.sourceFilters = AnnotatedElementUtils.findMergedAnnotation(method, SourceFilters.class);
verifyCountQueryTypes(); verifyCountQueryTypes();
} }
@ -92,8 +105,9 @@ public class ElasticsearchQueryMethod extends QueryMethod {
/** /**
* @return the query String. Must not be {@literal null} when {@link #hasAnnotatedQuery()} returns true * @return the query String. Must not be {@literal null} when {@link #hasAnnotatedQuery()} returns true
*/ */
@Nullable
public String getAnnotatedQuery() { public String getAnnotatedQuery() {
return queryAnnotation.value(); return queryAnnotation != null ? queryAnnotation.value() : null;
} }
/** /**
@ -246,4 +260,86 @@ public class ElasticsearchQueryMethod extends QueryMethod {
return queryAnnotation != null && queryAnnotation.count(); return queryAnnotation != null && queryAnnotation.count();
} }
/**
* @return {@literal true} if the method is annotated with {@link SourceFilters}.
* @since 5.0
*/
public boolean hasSourceFilters() {
return sourceFilters != null;
}
/**
* @return the {@link SourceFilters} annotation for this method.
* @since 5.0
*/
@Nullable
public SourceFilters getSourceFilters() {
return sourceFilters;
}
/**
* Uses the sourceFilters property to create a {@link SourceFilter} to be added to a
* {@link org.springframework.data.elasticsearch.core.query.Query}
*
* @param parameterAccessor the accessor with the query method parameter details
* @param converter {@link ElasticsearchConverter} needed to convert entity property names to the Elasticsearch field
* names and for parameter conversion when the includes or excludes are defined as parameters
* @return source filter with includes and excludes for a query, {@literal null} when no {@link SourceFilters}
* annotation was set on the method.
* @since 5.0
*/
@Nullable
SourceFilter getSourceFilter(ParameterAccessor parameterAccessor, ElasticsearchConverter converter) {
if (sourceFilters == null || (sourceFilters.includes().length == 0 && sourceFilters.excludes().length == 0)) {
return null;
}
ElasticsearchPersistentEntity<?> persistentEntity = converter.getMappingContext()
.getPersistentEntity(getEntityInformation().getJavaType());
StringQueryUtil stringQueryUtil = new StringQueryUtil(converter.getConversionService());
FetchSourceFilterBuilder fetchSourceFilterBuilder = new FetchSourceFilterBuilder();
if (sourceFilters.includes().length > 0) {
fetchSourceFilterBuilder
.withIncludes(mapParameters(sourceFilters.includes(), parameterAccessor, stringQueryUtil, persistentEntity));
}
if (sourceFilters.excludes().length > 0) {
fetchSourceFilterBuilder
.withExcludes(mapParameters(sourceFilters.excludes(), parameterAccessor, stringQueryUtil, persistentEntity));
}
return fetchSourceFilterBuilder.build();
}
private String[] mapParameters(String[] source, ParameterAccessor parameterAccessor, StringQueryUtil stringQueryUtil,
@Nullable ElasticsearchPersistentEntity<?> persistentEntity) {
List<String> unmappedFieldNames = new ArrayList<>();
for (String s : source) {
if (!s.isBlank()) {
String fieldName = stringQueryUtil.replacePlaceholders(s, parameterAccessor);
// this could be "[\"foo\",\"bar\"]", must be split
if (fieldName.startsWith("[") && fieldName.endsWith("]")) {
unmappedFieldNames.addAll( //
Arrays.asList(fieldName.substring(1, fieldName.length() - 2) //
.replaceAll("\\\"", "") //
.split(","))); //
} else {
unmappedFieldNames.add(fieldName);
}
}
}
return unmappedFieldNames.stream().map(fieldName -> {
ElasticsearchPersistentProperty property = persistentEntity != null
? persistentEntity.getPersistentProperty(fieldName)
: null;
return property != null ? property.getFieldName() : fieldName;
}).toArray(String[]::new);
}
} }

View File

@ -21,6 +21,7 @@ import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHitSupport; import org.springframework.data.elasticsearch.core.SearchHitSupport;
import org.springframework.data.elasticsearch.core.SearchHits; import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
import org.springframework.data.elasticsearch.core.query.Query;
import org.springframework.data.elasticsearch.core.query.StringQuery; import org.springframework.data.elasticsearch.core.query.StringQuery;
import org.springframework.data.elasticsearch.repository.support.StringQueryUtil; import org.springframework.data.elasticsearch.repository.support.StringQueryUtil;
import org.springframework.data.repository.query.ParametersParameterAccessor; import org.springframework.data.repository.query.ParametersParameterAccessor;
@ -38,13 +39,13 @@ import org.springframework.util.Assert;
*/ */
public class ElasticsearchStringQuery extends AbstractElasticsearchRepositoryQuery { public class ElasticsearchStringQuery extends AbstractElasticsearchRepositoryQuery {
private String query; private final String queryString;
public ElasticsearchStringQuery(ElasticsearchQueryMethod queryMethod, ElasticsearchOperations elasticsearchOperations, public ElasticsearchStringQuery(ElasticsearchQueryMethod queryMethod, ElasticsearchOperations elasticsearchOperations,
String query) { String queryString) {
super(queryMethod, elasticsearchOperations); super(queryMethod, elasticsearchOperations);
Assert.notNull(query, "Query cannot be empty"); Assert.notNull(queryString, "Query cannot be empty");
this.query = query; this.queryString = queryString;
} }
@Override @Override
@ -56,40 +57,42 @@ public class ElasticsearchStringQuery extends AbstractElasticsearchRepositoryQue
public Object execute(Object[] parameters) { public Object execute(Object[] parameters) {
Class<?> clazz = queryMethod.getResultProcessor().getReturnedType().getDomainType(); Class<?> clazz = queryMethod.getResultProcessor().getReturnedType().getDomainType();
ParametersParameterAccessor accessor = new ParametersParameterAccessor(queryMethod.getParameters(), parameters); ParametersParameterAccessor parameterAccessor = new ParametersParameterAccessor(queryMethod.getParameters(),
parameters);
StringQuery stringQuery = createQuery(accessor); Query query = createQuery(parameterAccessor);
Assert.notNull(query, "unsupported query");
Assert.notNull(stringQuery, "unsupported query");
if (queryMethod.hasAnnotatedHighlight()) { if (queryMethod.hasAnnotatedHighlight()) {
stringQuery.setHighlightQuery(queryMethod.getAnnotatedHighlightQuery()); query.setHighlightQuery(queryMethod.getAnnotatedHighlightQuery());
} }
prepareQuery(query, clazz, parameterAccessor);
IndexCoordinates index = elasticsearchOperations.getIndexCoordinatesFor(clazz); IndexCoordinates index = elasticsearchOperations.getIndexCoordinatesFor(clazz);
Object result = null; Object result;
if (isCountQuery()) { if (isCountQuery()) {
result = elasticsearchOperations.count(stringQuery, clazz, index); result = elasticsearchOperations.count(query, clazz, index);
} else if (queryMethod.isPageQuery()) { } else if (queryMethod.isPageQuery()) {
stringQuery.setPageable(accessor.getPageable()); query.setPageable(parameterAccessor.getPageable());
SearchHits<?> searchHits = elasticsearchOperations.search(stringQuery, clazz, index); SearchHits<?> searchHits = elasticsearchOperations.search(query, clazz, index);
if (queryMethod.isSearchPageMethod()) { if (queryMethod.isSearchPageMethod()) {
result = SearchHitSupport.searchPageFor(searchHits, stringQuery.getPageable()); result = SearchHitSupport.searchPageFor(searchHits, query.getPageable());
} else { } else {
result = SearchHitSupport result = SearchHitSupport.unwrapSearchHits(SearchHitSupport.searchPageFor(searchHits, query.getPageable()));
.unwrapSearchHits(SearchHitSupport.searchPageFor(searchHits, stringQuery.getPageable()));
} }
} else if (queryMethod.isStreamQuery()) { } else if (queryMethod.isStreamQuery()) {
stringQuery.setPageable( query.setPageable(parameterAccessor.getPageable().isPaged() ? parameterAccessor.getPageable()
accessor.getPageable().isPaged() ? accessor.getPageable() : PageRequest.of(0, DEFAULT_STREAM_BATCH_SIZE)); : PageRequest.of(0, DEFAULT_STREAM_BATCH_SIZE));
result = StreamUtils.createStreamFromIterator(elasticsearchOperations.searchForStream(stringQuery, clazz, index)); result = StreamUtils.createStreamFromIterator(elasticsearchOperations.searchForStream(query, clazz, index));
} else if (queryMethod.isCollectionQuery()) { } else if (queryMethod.isCollectionQuery()) {
stringQuery.setPageable(accessor.getPageable().isPaged() ? accessor.getPageable() : Pageable.unpaged()); query.setPageable(
result = elasticsearchOperations.search(stringQuery, clazz, index); parameterAccessor.getPageable().isPaged() ? parameterAccessor.getPageable() : Pageable.unpaged());
result = elasticsearchOperations.search(query, clazz, index);
} else { } else {
result = elasticsearchOperations.searchOne(stringQuery, clazz, index); result = elasticsearchOperations.searchOne(query, clazz, index);
} }
return (queryMethod.isNotSearchHitMethod() && queryMethod.isNotSearchPageMethod()) return (queryMethod.isNotSearchHitMethod() && queryMethod.isNotSearchPageMethod())
@ -99,7 +102,7 @@ public class ElasticsearchStringQuery extends AbstractElasticsearchRepositoryQue
protected StringQuery createQuery(ParametersParameterAccessor parameterAccessor) { protected StringQuery createQuery(ParametersParameterAccessor parameterAccessor) {
String queryString = new StringQueryUtil(elasticsearchOperations.getElasticsearchConverter().getConversionService()) String queryString = new StringQueryUtil(elasticsearchOperations.getElasticsearchConverter().getConversionService())
.replacePlaceholders(this.query, parameterAccessor); .replacePlaceholders(this.queryString, parameterAccessor);
return new StringQuery(queryString); return new StringQuery(queryString);
} }

View File

@ -48,6 +48,7 @@ import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.Highlight; import org.springframework.data.elasticsearch.annotations.Highlight;
import org.springframework.data.elasticsearch.annotations.HighlightField; import org.springframework.data.elasticsearch.annotations.HighlightField;
import org.springframework.data.elasticsearch.annotations.Query; import org.springframework.data.elasticsearch.annotations.Query;
import org.springframework.data.elasticsearch.annotations.SourceFilters;
import org.springframework.data.elasticsearch.core.AbstractElasticsearchTemplate; import org.springframework.data.elasticsearch.core.AbstractElasticsearchTemplate;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations; import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHit; import org.springframework.data.elasticsearch.core.SearchHit;
@ -80,12 +81,11 @@ import org.springframework.lang.Nullable;
@SpringIntegrationTest @SpringIntegrationTest
public abstract class CustomMethodRepositoryIntegrationTests implements NewElasticsearchClientDevelopment { public abstract class CustomMethodRepositoryIntegrationTests implements NewElasticsearchClientDevelopment {
@Autowired ElasticsearchOperations operations;
@Autowired private IndexNameProvider indexNameProvider; @Autowired private IndexNameProvider indexNameProvider;
@Autowired private SampleCustomMethodRepository repository; @Autowired private SampleCustomMethodRepository repository;
@Autowired private SampleStreamingCustomMethodRepository streamingRepository; @Autowired private SampleStreamingCustomMethodRepository streamingRepository;
@Autowired ElasticsearchOperations operations;
boolean rhlcWithCluster8() { boolean rhlcWithCluster8() {
var clusterVersion = ((AbstractElasticsearchTemplate) operations).getClusterVersion(); var clusterVersion = ((AbstractElasticsearchTemplate) operations).getClusterVersion();
return (oldElasticsearchClient() && clusterVersion != null && clusterVersion.startsWith("8")); return (oldElasticsearchClient() && clusterVersion != null && clusterVersion.startsWith("8"));
@ -1675,6 +1675,94 @@ public abstract class CustomMethodRepositoryIntegrationTests implements NewElast
assertThat(returnedIds).containsAll(ids); assertThat(returnedIds).containsAll(ids);
} }
@Test // #2146
@DisplayName("should use sourceIncludes from annotation")
void shouldUseSourceIncludesFromAnnotation() {
SampleEntity entity = new SampleEntity();
entity.setId("42");
entity.setMessage("message");
entity.setCustomFieldNameMessage("customFieldNameMessage");
entity.setType("type");
entity.setKeyword("keyword");
repository.save(entity);
var searchHits = repository.searchWithSourceFilterIncludesAnnotation();
assertThat(searchHits.hasSearchHits()).isTrue();
var foundEntity = searchHits.getSearchHit(0).getContent();
assertThat(foundEntity.getMessage()).isEqualTo("message");
assertThat(foundEntity.getCustomFieldNameMessage()).isEqualTo("customFieldNameMessage");
assertThat(foundEntity.getType()).isNull();
assertThat(foundEntity.getKeyword()).isNull();
}
@Test // #2146
@DisplayName("should use sourceIncludes from parameter")
void shouldUseSourceIncludesFromParameter() {
SampleEntity entity = new SampleEntity();
entity.setId("42");
entity.setMessage("message");
entity.setCustomFieldNameMessage("customFieldNameMessage");
entity.setType("type");
entity.setKeyword("keyword");
repository.save(entity);
var searchHits = repository.searchBy(List.of("message", "customFieldNameMessage"));
assertThat(searchHits.hasSearchHits()).isTrue();
var foundEntity = searchHits.getSearchHit(0).getContent();
assertThat(foundEntity.getMessage()).isEqualTo("message");
assertThat(foundEntity.getCustomFieldNameMessage()).isEqualTo("customFieldNameMessage");
assertThat(foundEntity.getType()).isNull();
assertThat(foundEntity.getKeyword()).isNull();
}
@Test // #2146
@DisplayName("should use sourceExcludes from annotation")
void shouldUseSourceExcludesFromAnnotation() {
SampleEntity entity = new SampleEntity();
entity.setId("42");
entity.setMessage("message");
entity.setCustomFieldNameMessage("customFieldNameMessage");
entity.setType("type");
entity.setKeyword("keyword");
repository.save(entity);
var searchHits = repository.searchWithSourceFilterExcludesAnnotation();
assertThat(searchHits.hasSearchHits()).isTrue();
var foundEntity = searchHits.getSearchHit(0).getContent();
assertThat(foundEntity.getMessage()).isEqualTo("message");
assertThat(foundEntity.getCustomFieldNameMessage()).isEqualTo("customFieldNameMessage");
assertThat(foundEntity.getType()).isNull();
assertThat(foundEntity.getKeyword()).isNull();
}
@Test // #2146
@DisplayName("should use source excludes from parameter")
void shouldUseSourceExcludesFromParameter() {
SampleEntity entity = new SampleEntity();
entity.setId("42");
entity.setMessage("message");
entity.setCustomFieldNameMessage("customFieldNameMessage");
entity.setType("type");
entity.setKeyword("keyword");
repository.save(entity);
var searchHits = repository.findBy(List.of("type", "keyword"));
assertThat(searchHits.hasSearchHits()).isTrue();
var foundEntity = searchHits.getSearchHit(0).getContent();
assertThat(foundEntity.getMessage()).isEqualTo("message");
assertThat(foundEntity.getCustomFieldNameMessage()).isEqualTo("customFieldNameMessage");
assertThat(foundEntity.getType()).isNull();
assertThat(foundEntity.getKeyword()).isNull();
}
private List<SampleEntity> createSampleEntities(String type, int numberOfEntities) { private List<SampleEntity> createSampleEntities(String type, int numberOfEntities) {
List<SampleEntity> entities = new ArrayList<>(); List<SampleEntity> entities = new ArrayList<>();
@ -1690,93 +1778,6 @@ public abstract class CustomMethodRepositoryIntegrationTests implements NewElast
return entities; return entities;
} }
@Document(indexName = "#{@indexNameProvider.indexName()}")
static class SampleEntity {
@Nullable
@Id private String id;
@Nullable
@Field(type = Text, store = true, fielddata = true) private String type;
@Nullable
@Field(type = Text, store = true, fielddata = true) private String message;
@Nullable
@Field(type = Keyword) private String keyword;
@Nullable private int rate;
@Nullable private boolean available;
@Nullable private GeoPoint location;
@Nullable
@Version private Long version;
@Nullable
public String getId() {
return id;
}
public void setId(@Nullable String id) {
this.id = id;
}
@Nullable
public String getType() {
return type;
}
public void setType(@Nullable String type) {
this.type = type;
}
@Nullable
public String getMessage() {
return message;
}
public void setMessage(@Nullable String message) {
this.message = message;
}
@Nullable
public String getKeyword() {
return keyword;
}
public void setKeyword(@Nullable String keyword) {
this.keyword = keyword;
}
public int getRate() {
return rate;
}
public void setRate(int rate) {
this.rate = rate;
}
public boolean isAvailable() {
return available;
}
public void setAvailable(boolean available) {
this.available = available;
}
@Nullable
public GeoPoint getLocation() {
return location;
}
public void setLocation(@Nullable GeoPoint location) {
this.location = location;
}
@Nullable
public java.lang.Long getVersion() {
return version;
}
public void setVersion(@Nullable java.lang.Long version) {
this.version = version;
}
}
/** /**
* @author Rizwan Idrees * @author Rizwan Idrees
* @author Mohsin Husen * @author Mohsin Husen
@ -1911,11 +1912,30 @@ public abstract class CustomMethodRepositoryIntegrationTests implements NewElast
@Query("{\"ids\" : {\"values\" : ?0 }}") @Query("{\"ids\" : {\"values\" : ?0 }}")
List<SampleEntity> getByIds(Collection<String> ids); List<SampleEntity> getByIds(Collection<String> ids);
@Query("""
{
"match_all": {}
}
""")
@SourceFilters(includes = { "message", "customFieldNameMessage" })
SearchHits<SampleEntity> searchWithSourceFilterIncludesAnnotation();
@SourceFilters(includes = "?0")
SearchHits<SampleEntity> searchBy(Collection<String> sourceIncludes);
@Query("""
{
"match_all": {}
}
""")
@SourceFilters(excludes = { "type", "keyword" })
SearchHits<SampleEntity> searchWithSourceFilterExcludesAnnotation();
@SourceFilters(excludes = "?0")
SearchHits<SampleEntity> findBy(Collection<String> sourceExcludes);
} }
/**
* @author Rasmus Faber-Espensen
*/
public interface SampleStreamingCustomMethodRepository extends ElasticsearchRepository<SampleEntity, String> { public interface SampleStreamingCustomMethodRepository extends ElasticsearchRepository<SampleEntity, String> {
Stream<SampleEntity> findByType(String type); Stream<SampleEntity> findByType(String type);
@ -1928,4 +1948,103 @@ public abstract class CustomMethodRepositoryIntegrationTests implements NewElast
Stream<SearchHit<SampleEntity>> streamSearchHitsByType(String type); Stream<SearchHit<SampleEntity>> streamSearchHitsByType(String type);
} }
@Document(indexName = "#{@indexNameProvider.indexName()}")
static class SampleEntity {
@Nullable
@Id private String id;
@Nullable
@Field(type = Text, store = true, fielddata = true) private String type;
@Nullable
@Field(type = Text, store = true, fielddata = true) private String message;
@Nullable
@Field(type = Keyword) private String keyword;
@Nullable private int rate;
@Nullable private boolean available;
@Nullable private GeoPoint location;
@Nullable
@Version private Long version;
@Field(name = "custom_field_name", type = Text)
@Nullable private String customFieldNameMessage;
@Nullable
public String getId() {
return id;
}
public void setId(@Nullable String id) {
this.id = id;
}
@Nullable
public String getType() {
return type;
}
public void setType(@Nullable String type) {
this.type = type;
}
@Nullable
public String getMessage() {
return message;
}
public void setMessage(@Nullable String message) {
this.message = message;
}
@Nullable
public String getCustomFieldNameMessage() {
return customFieldNameMessage;
}
public void setCustomFieldNameMessage(@Nullable String customFieldNameMessage) {
this.customFieldNameMessage = customFieldNameMessage;
}
@Nullable
public String getKeyword() {
return keyword;
}
public void setKeyword(@Nullable String keyword) {
this.keyword = keyword;
}
public int getRate() {
return rate;
}
public void setRate(int rate) {
this.rate = rate;
}
public boolean isAvailable() {
return available;
}
public void setAvailable(boolean available) {
this.available = available;
}
@Nullable
public GeoPoint getLocation() {
return location;
}
public void setLocation(@Nullable GeoPoint location) {
this.location = location;
}
@Nullable
public java.lang.Long getVersion() {
return version;
}
public void setVersion(@Nullable java.lang.Long version) {
this.version = version;
}
}
} }

View File

@ -23,6 +23,7 @@ import reactor.core.publisher.Mono;
import reactor.test.StepVerifier; import reactor.test.StepVerifier;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -47,6 +48,7 @@ import org.springframework.data.elasticsearch.annotations.FieldType;
import org.springframework.data.elasticsearch.annotations.Highlight; import org.springframework.data.elasticsearch.annotations.Highlight;
import org.springframework.data.elasticsearch.annotations.HighlightField; import org.springframework.data.elasticsearch.annotations.HighlightField;
import org.springframework.data.elasticsearch.annotations.Query; import org.springframework.data.elasticsearch.annotations.Query;
import org.springframework.data.elasticsearch.annotations.SourceFilters;
import org.springframework.data.elasticsearch.core.ReactiveElasticsearchOperations; import org.springframework.data.elasticsearch.core.ReactiveElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHit; import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
@ -634,6 +636,98 @@ abstract class SimpleReactiveElasticsearchRepositoryIntegrationTests {
} }
@Test // #2146
@DisplayName("should use sourceIncludes from annotation")
void shouldUseSourceIncludesFromAnnotation() {
var entity = new SampleEntity();
entity.setId("42");
entity.setMessage("message");
entity.setCustomFieldNameMessage("customFieldNameMessage");
entity.setType("type");
entity.setKeyword("keyword");
repository.save(entity).block();
repository.searchWithSourceFilterIncludesAnnotation() //
.as(StepVerifier::create) //
.consumeNextWith(foundEntity -> { //
assertThat(foundEntity.getMessage()).isEqualTo("message"); //
assertThat(foundEntity.getCustomFieldNameMessage()).isEqualTo("customFieldNameMessage"); //
assertThat(foundEntity.getType()).isNull(); //
assertThat(foundEntity.getKeyword()).isNull(); //
}) //
.verifyComplete();
}
@Test // #2146
@DisplayName("should use sourceIncludes from parameter")
void shouldUseSourceIncludesFromParameter() {
var entity = new SampleEntity();
entity.setId("42");
entity.setMessage("message");
entity.setCustomFieldNameMessage("customFieldNameMessage");
entity.setType("type");
entity.setKeyword("keyword");
repository.save(entity).block();
repository.searchBy(List.of("message", "customFieldNameMessage")) //
.as(StepVerifier::create) //
.consumeNextWith(foundEntity -> { //
assertThat(foundEntity.getMessage()).isEqualTo("message"); //
assertThat(foundEntity.getCustomFieldNameMessage()).isEqualTo("customFieldNameMessage"); //
assertThat(foundEntity.getType()).isNull(); //
assertThat(foundEntity.getKeyword()).isNull(); //
}) //
.verifyComplete();
}
@Test // #2146
@DisplayName("should use sourceExcludes from annotation")
void shouldUseSourceExcludesFromAnnotation() {
var entity = new SampleEntity();
entity.setId("42");
entity.setMessage("message");
entity.setCustomFieldNameMessage("customFieldNameMessage");
entity.setType("type");
entity.setKeyword("keyword");
repository.save(entity).block();
repository.searchWithSourceFilterExcludesAnnotation() //
.as(StepVerifier::create) //
.consumeNextWith(foundEntity -> { //
assertThat(foundEntity.getMessage()).isEqualTo("message"); //
assertThat(foundEntity.getCustomFieldNameMessage()).isEqualTo("customFieldNameMessage"); //
assertThat(foundEntity.getType()).isNull(); //
assertThat(foundEntity.getKeyword()).isNull(); //
}) //
.verifyComplete();
}
@Test // #2146
@DisplayName("should use source excludes from parameter")
void shouldUseSourceExcludesFromParameter() {
var entity = new SampleEntity();
entity.setId("42");
entity.setMessage("message");
entity.setCustomFieldNameMessage("customFieldNameMessage");
entity.setType("type");
entity.setKeyword("keyword");
repository.save(entity).block();
repository.findBy(List.of("type", "keyword")) //
.as(StepVerifier::create) //
.consumeNextWith(foundEntity -> { //
assertThat(foundEntity.getMessage()).isEqualTo("message"); //
assertThat(foundEntity.getCustomFieldNameMessage()).isEqualTo("customFieldNameMessage"); //
assertThat(foundEntity.getType()).isNull(); //
assertThat(foundEntity.getKeyword()).isNull(); //
}) //
.verifyComplete();
}
Mono<Void> bulkIndex(SampleEntity... entities) { Mono<Void> bulkIndex(SampleEntity... entities) {
return operations.saveAll(Arrays.asList(entities), IndexCoordinates.of(indexNameProvider.indexName())).then(); return operations.saveAll(Arrays.asList(entities), IndexCoordinates.of(indexNameProvider.indexName())).then();
} }
@ -683,6 +777,27 @@ abstract class SimpleReactiveElasticsearchRepositoryIntegrationTests {
@Query("{\"bool\": {\"must\": [{ \"terms\": { \"message\": ?0 } }, { \"terms\": { \"rate\": ?1 } }] } }") @Query("{\"bool\": {\"must\": [{ \"terms\": { \"message\": ?0 } }, { \"terms\": { \"rate\": ?1 } }] } }")
Flux<SampleEntity> findAllViaAnnotatedQueryByMessageInAndRatesIn(List<String> messages, List<Integer> rates); Flux<SampleEntity> findAllViaAnnotatedQueryByMessageInAndRatesIn(List<String> messages, List<Integer> rates);
@Query(query = """
{
"match_all": {}
}
""")
@SourceFilters(includes = { "message", "customFieldNameMessage" })
Flux<SampleEntity> searchWithSourceFilterIncludesAnnotation();
@SourceFilters(includes = "?0")
Flux<SampleEntity> searchBy(Collection<String> sourceIncludes);
@Query("""
{
"match_all": {}
}
""")
@SourceFilters(excludes = { "type", "keyword" })
Flux<SampleEntity> searchWithSourceFilterExcludesAnnotation();
@SourceFilters(excludes = "?0")
Flux<SampleEntity> findBy(Collection<String> sourceExcludes);
} }
@Document(indexName = "#{@indexNameProvider.indexName()}") @Document(indexName = "#{@indexNameProvider.indexName()}")
@ -693,10 +808,15 @@ abstract class SimpleReactiveElasticsearchRepositoryIntegrationTests {
@Field(type = FieldType.Text, store = true, fielddata = true) private String type; @Field(type = FieldType.Text, store = true, fielddata = true) private String type;
@Nullable @Nullable
@Field(type = FieldType.Text, store = true, fielddata = true) private String message; @Field(type = FieldType.Text, store = true, fielddata = true) private String message;
@Nullable
@Field(type = FieldType.Keyword) private String keyword;
@Nullable private int rate; @Nullable private int rate;
@Nullable private boolean available; @Nullable private boolean available;
@Nullable @Nullable
@Version private Long version; @Version private Long version;
@Field(name = "custom_field_name", type = FieldType.Text)
@Nullable private String customFieldNameMessage;
public SampleEntity() {} public SampleEntity() {}
@ -733,6 +853,15 @@ abstract class SimpleReactiveElasticsearchRepositoryIntegrationTests {
this.type = type; this.type = type;
} }
@Nullable
public String getKeyword() {
return keyword;
}
public void setKeyword(@Nullable String keyword) {
this.keyword = keyword;
}
@Nullable @Nullable
public String getMessage() { public String getMessage() {
return message; return message;
@ -766,5 +895,14 @@ abstract class SimpleReactiveElasticsearchRepositoryIntegrationTests {
public void setVersion(@Nullable java.lang.Long version) { public void setVersion(@Nullable java.lang.Long version) {
this.version = version; this.version = version;
} }
@Nullable
public String getCustomFieldNameMessage() {
return customFieldNameMessage;
}
public void setCustomFieldNameMessage(@Nullable String customFieldNameMessage) {
this.customFieldNameMessage = customFieldNameMessage;
}
} }
} }