Add @CountQuery annotation.

Original Pull Request #1682 
Closes #1156
This commit is contained in:
Peter-Josef Meisch 2021-02-07 20:21:02 +01:00 committed by GitHub
parent 910ca7b665
commit fe8c4f12ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 308 additions and 23 deletions

View File

@ -0,0 +1,39 @@
/*
* Copyright 2021 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;
import org.springframework.core.annotation.AliasFor;
/**
* Alias for a @Query annotation with the count parameter set to true.
*
* @author Peter-Josef Meisch
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE })
@Documented
@Query(count = true)
public @interface CountQuery {
@AliasFor(annotation = Query.class)
String value() default "";
}

View File

@ -22,24 +22,32 @@ import java.lang.annotation.*;
*
* @author Rizwan Idrees
* @author Mohsin Husen
* @author Peter-Josef Meisch
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Documented
public @interface Query {
/**
* Elasticsearch query to be used when executing query. May contain placeholders eg. ?0
*
* @return
* @return Elasticsearch query to be used when executing query. May contain placeholders eg. ?0
*/
String value() default "";
/**
* Named Query Named looked up by repository.
*
* @return
* @deprecated since 4.2, not implemented and used anywhere
*/
String name() default "";
/**
* Returns whether the query defined should be executed as count projection.
*
* @return {@literal false} by default.
* @since 4.2
*/
boolean count() default false;
}

View File

@ -24,6 +24,7 @@ import org.springframework.data.repository.query.RepositoryQuery;
*
* @author Rizwan Idrees
* @author Mohsin Husen
* @author Peter-Josef Meisch
*/
public abstract class AbstractElasticsearchRepositoryQuery implements RepositoryQuery {
@ -42,4 +43,10 @@ public abstract class AbstractElasticsearchRepositoryQuery implements Repository
public QueryMethod getQueryMethod() {
return queryMethod;
}
/**
* @return {@literal true} if this is a count query
* @since 4.2
*/
public abstract boolean isCountQuery();
}

View File

@ -43,7 +43,7 @@ import org.springframework.data.repository.query.ResultProcessor;
*/
abstract class AbstractReactiveElasticsearchRepositoryQuery implements RepositoryQuery {
private final ReactiveElasticsearchQueryMethod queryMethod;
protected final ReactiveElasticsearchQueryMethod queryMethod;
private final ReactiveElasticsearchOperations elasticsearchOperations;
AbstractReactiveElasticsearchRepositoryQuery(ReactiveElasticsearchQueryMethod queryMethod,

View File

@ -59,6 +59,11 @@ public class ElasticsearchPartQuery extends AbstractElasticsearchRepositoryQuery
this.mappingContext = elasticsearchConverter.getMappingContext();
}
@Override
public boolean isCountQuery() {
return tree.isCountProjection();
}
@Override
public Object execute(Object[] parameters) {
Class<?> clazz = queryMethod.getResultProcessor().getReturnedType().getDomainType();

View File

@ -20,7 +20,8 @@ import java.lang.reflect.ParameterizedType;
import java.util.Collection;
import java.util.stream.Stream;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.elasticsearch.annotations.Highlight;
import org.springframework.data.elasticsearch.annotations.Query;
import org.springframework.data.elasticsearch.core.SearchHit;
@ -34,7 +35,9 @@ import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.repository.core.RepositoryMetadata;
import org.springframework.data.repository.query.QueryMethod;
import org.springframework.data.util.ClassTypeInformation;
import org.springframework.data.util.Lazy;
import org.springframework.data.util.TypeInformation;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
@ -53,9 +56,9 @@ public class ElasticsearchQueryMethod extends QueryMethod {
private final MappingContext<? extends ElasticsearchPersistentEntity<?>, ElasticsearchPersistentProperty> mappingContext;
private @Nullable ElasticsearchEntityMetadata<?> metadata;
private final Method method; // private in base class, but needed here as well
private final Query queryAnnotation;
private final Highlight highlightAnnotation;
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 Highlight highlightAnnotation;
private final Lazy<HighlightQuery> highlightQueryLazy = Lazy.of(this::createAnnotatedHighlightQuery);
public ElasticsearchQueryMethod(Method method, RepositoryMetadata repositoryMetadata, ProjectionFactory factory,
@ -67,16 +70,32 @@ public class ElasticsearchQueryMethod extends QueryMethod {
this.method = method;
this.mappingContext = mappingContext;
this.queryAnnotation = method.getAnnotation(Query.class);
this.highlightAnnotation = method.getAnnotation(Highlight.class);
this.queryAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, Query.class);
this.highlightAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, Highlight.class);
verifyCountQueryTypes();
}
protected void verifyCountQueryTypes() {
if (hasCountQueryAnnotation()) {
TypeInformation<?> returnType = ClassTypeInformation.fromReturnTypeOf(method);
if (returnType.getType() != long.class && !Long.class.isAssignableFrom(returnType.getType())) {
throw new InvalidDataAccessApiUsageException("count query methods must return a Long");
}
}
}
public boolean hasAnnotatedQuery() {
return this.queryAnnotation != null;
}
/**
* @return the query String. Must not be {@literal null} when {@link #hasAnnotatedQuery()} returns true
*/
public String getAnnotatedQuery() {
return (String) AnnotationUtils.getValue(queryAnnotation, "value");
return queryAnnotation.value();
}
/**
@ -217,4 +236,14 @@ public class ElasticsearchQueryMethod extends QueryMethod {
public boolean isNotSearchPageMethod() {
return !isSearchPageMethod();
}
/**
* @return {@literal true} if the method is annotated with
* {@link org.springframework.data.elasticsearch.annotations.CountQuery} or with {@link Query}(count =true)
* @since 4.2
*/
public boolean hasCountQueryAnnotation() {
return queryAnnotation != null && queryAnnotation.count();
}
}

View File

@ -69,8 +69,14 @@ public class ElasticsearchStringQuery extends AbstractElasticsearchRepositoryQue
this.query = query;
}
@Override
public boolean isCountQuery() {
return queryMethod.hasCountQueryAnnotation();
}
@Override
public Object execute(Object[] parameters) {
Class<?> clazz = queryMethod.getResultProcessor().getReturnedType().getDomainType();
ParametersParameterAccessor accessor = new ParametersParameterAccessor(queryMethod.getParameters(), parameters);
@ -86,7 +92,9 @@ public class ElasticsearchStringQuery extends AbstractElasticsearchRepositoryQue
Object result = null;
if (queryMethod.isPageQuery()) {
if (isCountQuery()) {
result = elasticsearchOperations.count(stringQuery, clazz, index);
} else if (queryMethod.isPageQuery()) {
stringQuery.setPageable(accessor.getPageable());
SearchHits<?> searchHits = elasticsearchOperations.search(stringQuery, clazz, index);
result = SearchHitSupport.searchPageFor(searchHits, stringQuery.getPageable());

View File

@ -18,9 +18,11 @@ package org.springframework.data.elasticsearch.repository.query;
import static org.springframework.data.repository.util.ClassUtils.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.util.List;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.domain.Page;
@ -59,7 +61,6 @@ public class ReactiveElasticsearchQueryMethod extends ElasticsearchQueryMethod {
if (hasParameterOfType(method, Pageable.class)) {
TypeInformation<?> returnType = ClassTypeInformation.fromReturnTypeOf(method);
boolean multiWrapper = ReactiveWrappers.isMultiValueType(returnType.getType());
boolean singleWrapperWithWrappedPageableResult = ReactiveWrappers.isSingleValueType(returnType.getType())
&& (PAGE_TYPE.isAssignableFrom(returnType.getRequiredComponentType())
@ -87,6 +88,20 @@ public class ReactiveElasticsearchQueryMethod extends ElasticsearchQueryMethod {
&& ReactiveWrappers.isMultiValueType(metadata.getReturnType(method).getType()) || super.isCollectionQuery()));
}
@Override
protected void verifyCountQueryTypes() {
if (hasCountQueryAnnotation()) {
TypeInformation<?> returnType = ClassTypeInformation.fromReturnTypeOf(method);
List<TypeInformation<?>> typeArguments = returnType.getTypeArguments();
if (!Mono.class.isAssignableFrom(returnType.getType()) || typeArguments.size() != 1
|| (typeArguments.get(0).getType() != long.class
&& !Long.class.isAssignableFrom(typeArguments.get(0).getType()))) {
throw new InvalidDataAccessApiUsageException("count query methods must return a Mono<Long>");
}
}
}
@Override
protected ElasticsearchParameters createParameters(Method method) {
return new ElasticsearchParameters(method);

View File

@ -75,7 +75,7 @@ public class ReactiveElasticsearchStringQuery extends AbstractReactiveElasticsea
@Override
boolean isCountQuery() {
return false;
return queryMethod.hasCountQueryAnnotation();
}
@Override

View File

@ -1,5 +1,17 @@
/*
* (c) Copyright 2021 sothawo
* Copyright 2021 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.core.mapping;
@ -10,7 +22,7 @@ import org.springframework.data.mapping.model.SnakeCaseFieldNamingStrategy;
import org.springframework.test.context.ContextConfiguration;
/**
* @author P.J. Meisch (pj.meisch@sothawo.com)
* @author Peter-Josef Meisch
*/
@ContextConfiguration(classes = { FieldNamingStrategyIntegrationTemplateTest.Config.class })
public class FieldNamingStrategyIntegrationTemplateTest extends FieldNamingStrategyIntegrationTest {

View File

@ -35,6 +35,7 @@ import java.util.stream.Stream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.annotation.Id;
@ -44,6 +45,7 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Order;
import org.springframework.data.elasticsearch.annotations.CountQuery;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.Highlight;
@ -911,6 +913,31 @@ public abstract class CustomMethodRepositoryBaseTests {
assertThat(count).isEqualTo(1L);
}
@Test // #1156
@DisplayName("should count with query by type")
void shouldCountWithQueryByType() {
String documentId = nextIdAsString();
SampleEntity sampleEntity = new SampleEntity();
sampleEntity.setId(documentId);
sampleEntity.setType("test");
sampleEntity.setMessage("some message");
repository.save(sampleEntity);
documentId = nextIdAsString();
SampleEntity sampleEntity2 = new SampleEntity();
sampleEntity2.setId(documentId);
sampleEntity2.setType("test2");
sampleEntity2.setMessage("some message");
repository.save(sampleEntity2);
long count = repository.countWithQueryByType("test");
assertThat(count).isEqualTo(1L);
}
@Test // DATAES-106
public void shouldCountCustomMethodForNot() {
@ -1746,6 +1773,9 @@ public abstract class CustomMethodRepositoryBaseTests {
SearchHits<SampleEntity> searchBy(Sort sort);
SearchPage<SampleEntity> searchByMessage(String message, Pageable pageable);
@CountQuery("{\"bool\" : {\"must\" : {\"term\" : {\"type\" : \"?0\"}}}}")
long countWithQueryByType(String type);
}
/**

View File

@ -0,0 +1,92 @@
/*
* Copyright 2021 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 static org.assertj.core.api.Assertions.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.lang.reflect.Method;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.CountQuery;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.core.mapping.SimpleElasticsearchMappingContext;
import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.core.support.DefaultRepositoryMetadata;
import org.springframework.lang.Nullable;
/**
* @author Peter-Josef Meisch
*/
public class ElasticsearchQueryMethodUnitTests {
private SimpleElasticsearchMappingContext mappingContext;
@BeforeEach
public void setUp() {
mappingContext = new SimpleElasticsearchMappingContext();
}
@Test // #1156
@DisplayName("should reject count query method not returning a Long")
void shouldRejectCountQueryMethodNotReturningLong() {
assertThatThrownBy(() -> queryMethod(PersonRepository.class, "invalidCountQueryResult", String.class))
.isInstanceOf(InvalidDataAccessApiUsageException.class);
}
@Test // #1156
@DisplayName("should accept count query method returning a Long")
void shouldAcceptCountQueryMethodReturningALong() throws Exception {
queryMethod(PersonRepository.class, "validCountQueryResult", String.class);
}
private ElasticsearchQueryMethod queryMethod(Class<?> repository, String name, Class<?>... parameters)
throws Exception {
Method method = repository.getMethod(name, parameters);
ProjectionFactory factory = new SpelAwareProxyProjectionFactory();
return new ElasticsearchQueryMethod(method, new DefaultRepositoryMetadata(repository), factory, mappingContext);
}
interface PersonRepository extends Repository<ReactiveElasticsearchQueryMethodUnitTests.Person, String> {
@CountQuery("{}")
List<Person> invalidCountQueryResult(String name); // invalid return type here
@CountQuery("{}")
Long validCountQueryResult(String name);
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@Document(indexName = "query-method-unit-tests")
private static class Person {
@Id private String id;
@Nullable private String name;
@Nullable private String firstName;
}
}

View File

@ -32,12 +32,14 @@ import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.annotation.Id;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.elasticsearch.annotations.CountQuery;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
@ -52,7 +54,6 @@ import org.springframework.lang.Nullable;
/**
* @author Christoph Strobl
* @currentRead Fool's Fate - Robin Hobb
*/
public class ReactiveElasticsearchQueryMethodUnitTests {
@ -115,6 +116,20 @@ public class ReactiveElasticsearchQueryMethodUnitTests {
assertThat(method.getEntityInformation().getJavaType()).isAssignableFrom(Person.class);
}
@Test // #1156
@DisplayName("should reject count query method not returning a Mono of Long")
void shouldRejectCountQueryMethodNotReturningAMonoOfLong() {
assertThatThrownBy(() -> queryMethod(PersonRepository.class, "invalidCountQueryResult", String.class))
.isInstanceOf(InvalidDataAccessApiUsageException.class);
}
@Test // #1156
@DisplayName("should accept count query method returning a Mono of Long")
void shouldAcceptCountQueryMethodReturningAMonoOfLong() throws Exception {
queryMethod(PersonRepository.class, "validCountQueryResult", String.class);
}
private ReactiveElasticsearchQueryMethod queryMethod(Class<?> repository, String name, Class<?>... parameters)
throws Exception {
@ -137,6 +152,12 @@ public class ReactiveElasticsearchQueryMethodUnitTests {
Flux<Person> findByName(String name, Pageable pageRequest);
void deleteByName(String name);
@CountQuery("{}")
Flux<Person> invalidCountQueryResult(String name); // invalid return type here
@CountQuery("{}")
Mono<Long> validCountQueryResult(String name);
}
interface NonReactiveRepository extends Repository<Person, Long> {
@ -156,6 +177,7 @@ public class ReactiveElasticsearchQueryMethodUnitTests {
@Nullable @Id private String id;
@Nullable private String name;
@Nullable private String firstName;
@Nullable @Field(type = FieldType.Nested) private List<Car> car;

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.elasticsearch.repository.support.simple;
package org.springframework.data.elasticsearch.repository.support;
import static org.assertj.core.api.Assertions.*;
import static org.elasticsearch.index.query.QueryBuilders.*;
@ -77,7 +77,7 @@ class SimpleElasticsearchRepositoryIntegrationTests {
@Configuration
@Import({ ElasticsearchRestTemplateConfiguration.class })
@EnableElasticsearchRepositories(
basePackages = { "org.springframework.data.elasticsearch.repository.support.simple" },
basePackages = { "org.springframework.data.elasticsearch.repository.support" },
considerNestedRepositories = true)
static class Config {}

View File

@ -37,6 +37,7 @@ import java.util.stream.IntStream;
import org.elasticsearch.ElasticsearchStatusException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.reactivestreams.Publisher;
import org.springframework.beans.factory.annotation.Autowired;
@ -48,6 +49,7 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Order;
import org.springframework.data.elasticsearch.annotations.CountQuery;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.Highlight;
@ -316,8 +318,7 @@ class SimpleReactiveElasticsearchRepositoryTests {
bulkIndex(SampleEntity.builder().id("id-one").message("message").build(), //
SampleEntity.builder().id("id-two").message("test message").build(), //
SampleEntity.builder().id("id-three").message("test test").build()) //
.block();
SampleEntity.builder().id("id-three").message("test test").build()).block();
repository.countAllByMessage("test") //
.as(StepVerifier::create) //
@ -325,6 +326,20 @@ class SimpleReactiveElasticsearchRepositoryTests {
.verifyComplete();
}
@Test // #1156
@DisplayName("should count with string query")
void shouldCountWithStringQuery() {
bulkIndex(SampleEntity.builder().id("id-one").message("message").build(), //
SampleEntity.builder().id("id-two").message("test message").build(), //
SampleEntity.builder().id("id-three").message("test test").build()).block();
repository.retrieveCountByText("test") //
.as(StepVerifier::create) //
.expectNext(2L) //
.verifyComplete();
}
@Test // DATAES-519
void existsShouldReturnTrueIfExists() {
@ -593,6 +608,9 @@ class SimpleReactiveElasticsearchRepositoryTests {
Mono<Boolean> existsAllByMessage(String message);
Mono<Long> deleteAllByMessage(String message);
@CountQuery(value = "{\"bool\": {\"must\": [{\"term\": {\"message\": \"?0\"}}]}}")
Mono<Long> retrieveCountByText(String message);
}
/**