diff --git a/src/main/asciidoc/reference/elasticsearch-repository-queries.adoc b/src/main/asciidoc/reference/elasticsearch-repository-queries.adoc index 616358f58..2865c471b 100644 --- a/src/main/asciidoc/reference/elasticsearch-repository-queries.adoc +++ b/src/main/asciidoc/reference/elasticsearch-repository-queries.adoc @@ -242,10 +242,6 @@ A list of supported keywords for Elasticsearch is shown below. | `findByNameNotIn(Collectionnames)` | `{"query": {"bool": {"must": [{"query_string": {"query": "NOT(\"?\" \"?\")", "fields": ["name"]}}]}}}` -| `Near` -| `findByStoreNear` -| `Not Supported Yet !` - | `True` | `findByAvailableTrue` | `{ "query" : { @@ -277,6 +273,26 @@ A list of supported keywords for Elasticsearch is shown below. }, "sort":[{"name":{"order":"desc"}}] }` +| `Exists` +| `findByNameExists` +| `{"query":{"bool":{"must":[{"exists":{"field":"name"}}]}}}` + +| `IsNull` +| `findByNameIsNull` +| `{"query":{"bool":{"must_not":[{"exists":{"field":"name"}}]}}}` + +| `IsNotNull` +| `findByNameIsNotNull` +| `{"query":{"bool":{"must":[{"exists":{"field":"name"}}]}}}` + +| `IsEmpty` +| `findByNameIsEmpty` +| `{"query":{"bool":{"must":[{"bool":{"must":[{"exists":{"field":"name"}}],"must_not":[{"wildcard":{"name":{"wildcard":"*"}}}]}}]}}}` + +| `IsNotEmpty` +| `findByNameIsNotEmpty` +| `{"query":{"bool":{"must":[{"wildcard":{"name":{"wildcard":"*"}}}]}}}` + |=== NOTE: Methods names to build Geo-shape queries taking `GeoJson` parameters are not supported. diff --git a/src/main/java/org/springframework/data/elasticsearch/core/CriteriaQueryProcessor.java b/src/main/java/org/springframework/data/elasticsearch/core/CriteriaQueryProcessor.java index 02988ced4..a6ff7a6fd 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/CriteriaQueryProcessor.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/CriteriaQueryProcessor.java @@ -165,20 +165,35 @@ class CriteriaQueryProcessor { @Nullable private QueryBuilder queryFor(Criteria.CriteriaEntry entry, Field field) { + QueryBuilder query = null; String fieldName = field.getName(); boolean isKeywordField = FieldType.Keyword == field.getFieldType(); OperationKey key = entry.getKey(); - if (key == OperationKey.EXISTS) { - return existsQuery(fieldName); + // operations without a value + switch (key) { + case EXISTS: + query = existsQuery(fieldName); + break; + case EMPTY: + query = boolQuery().must(existsQuery(fieldName)).mustNot(wildcardQuery(fieldName, "*")); + break; + case NOT_EMPTY: + query = wildcardQuery(fieldName, "*"); + break; + default: + break; } + if (query != null) { + return query; + } + + // now operation keys with a value Object value = entry.getValue(); String searchText = QueryParserUtil.escape(value.toString()); - QueryBuilder query = null; - switch (key) { case EQUALS: query = queryStringQuery(searchText).field(fieldName).defaultOperator(AND); diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/Criteria.java b/src/main/java/org/springframework/data/elasticsearch/core/query/Criteria.java index 63b2848d5..145c5d070 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/Criteria.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/Criteria.java @@ -586,6 +586,31 @@ public class Criteria { queryCriteriaEntries.add(new CriteriaEntry(OperationKey.MATCHES_ALL, value)); return this; } + + /** + * Add a {@link OperationKey#EMPTY} entry to the {@link #queryCriteriaEntries}. + * + * @return this object + * @since 4.3 + */ + public Criteria empty() { + + queryCriteriaEntries.add(new CriteriaEntry(OperationKey.EMPTY)); + return this; + } + + /** + * Add a {@link OperationKey#NOT_EMPTY} entry to the {@link #queryCriteriaEntries}. + * + * @return this object + * @since 4.3 + */ + public Criteria notEmpty() { + + queryCriteriaEntries.add(new CriteriaEntry(OperationKey.NOT_EMPTY)); + return this; + } + // endregion // region criteria entries - filter @@ -921,7 +946,15 @@ public class Criteria { /** * @since 4.1 */ - GEO_CONTAINS + GEO_CONTAINS, // + /** + * @since 4.3 + */ + EMPTY, // + /** + * @since 4.3 + */ + NOT_EMPTY } /** @@ -934,7 +967,9 @@ public class Criteria { protected CriteriaEntry(OperationKey key) { - Assert.isTrue(key == OperationKey.EXISTS, "key must be OperationKey.EXISTS for this call"); + boolean keyIsValid = key == OperationKey.EXISTS || key == OperationKey.EMPTY || key == OperationKey.NOT_EMPTY; + Assert.isTrue(keyIsValid, + "key must be OperationKey.EXISTS, OperationKey.EMPTY or OperationKey.EMPTY for this call"); this.key = key; } diff --git a/src/main/java/org/springframework/data/elasticsearch/repository/query/parser/ElasticsearchQueryCreator.java b/src/main/java/org/springframework/data/elasticsearch/repository/query/parser/ElasticsearchQueryCreator.java index c7804aeb8..e23120388 100644 --- a/src/main/java/org/springframework/data/elasticsearch/repository/query/parser/ElasticsearchQueryCreator.java +++ b/src/main/java/org/springframework/data/elasticsearch/repository/query/parser/ElasticsearchQueryCreator.java @@ -186,7 +186,15 @@ public class ElasticsearchQueryCreator extends AbstractQueryCreator sortedIds = repository.findAllByOrderByText().stream() // .map(it -> it.text).collect(Collectors.toList()); - assertThat(sortedIds).containsExactly("Beet sugar", "Cane sugar", "Cane sugar", "Rock salt", "Sea salt", "no name"); + assertThat(sortedIds).containsExactly("Beet sugar", "Cane sugar", "Cane sugar", "Rock salt", "Sea salt", + "empty name", "no name"); } @Test // DATAES-615 @@ -202,7 +205,7 @@ class QueryKeywordsTests { List sortedIds = repository.findAllByOrderBySortName().stream() // .map(it -> it.id).collect(Collectors.toList()); - assertThat(sortedIds).containsExactly("6", "5", "4", "3", "2", "1"); + assertThat(sortedIds).containsExactly("5", "4", "3", "2", "1", "6", "7"); } @Test // DATAES-178 @@ -252,7 +255,7 @@ class QueryKeywordsTests { repository.deleteByName(null); long count = repository.count(); - assertThat(count).isEqualTo(5); + assertThat(count).isEqualTo(6); } @Test // DATAES-937 @@ -273,6 +276,52 @@ class QueryKeywordsTests { assertThat(products).isEmpty(); } + @Test // #1909 + @DisplayName("should find by property exists") + void shouldFindByPropertyExists() { + + SearchHits searchHits = repository.findByNameExists(); + + assertThat(searchHits.getTotalHits()).isEqualTo(6); + } + + @Test // #1909 + @DisplayName("should find by property is not null") + void shouldFindByPropertyIsNotNull() { + + SearchHits searchHits = repository.findByNameIsNotNull(); + + assertThat(searchHits.getTotalHits()).isEqualTo(6); + } + + @Test // #1909 + @DisplayName("should find by property is null") + void shouldFindByPropertyIsNull() { + + SearchHits searchHits = repository.findByNameIsNull(); + + assertThat(searchHits.getTotalHits()).isEqualTo(1); + } + + @Test // #1909 + @DisplayName("should find by empty property") + void shouldFindByEmptyProperty() { + + SearchHits searchHits = repository.findByNameEmpty(); + + assertThat(searchHits.getTotalHits()).isEqualTo(1); + } + + @Test // #1909 + @DisplayName("should find by non-empty property") + void shouldFindByNonEmptyProperty() { + + SearchHits searchHits = repository.findByNameNotEmpty(); + + assertThat(searchHits.getTotalHits()).isEqualTo(5); + } + + @SuppressWarnings("unused") @Document(indexName = "test-index-product-query-keywords") static class Product { @Nullable @Id private String id; @@ -346,6 +395,7 @@ class QueryKeywordsTests { } } + @SuppressWarnings({ "SpringDataRepositoryMethodParametersInspection", "SpringDataMethodInconsistencyInspection" }) interface ProductRepository extends ElasticsearchRepository { List findByName(@Nullable String name); @@ -399,6 +449,16 @@ class QueryKeywordsTests { void deleteByName(@Nullable String name); List findAllByNameIn(List names); + + SearchHits findByNameExists(); + + SearchHits findByNameIsNull(); + + SearchHits findByNameIsNotNull(); + + SearchHits findByNameEmpty(); + + SearchHits findByNameNotEmpty(); } } diff --git a/src/test/java/org/springframework/data/elasticsearch/repository/query/keywords/ReactiveQueryKeywordsIntegrationTests.java b/src/test/java/org/springframework/data/elasticsearch/repository/query/keywords/ReactiveQueryKeywordsIntegrationTests.java new file mode 100644 index 000000000..dc0f7a1ff --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/repository/query/keywords/ReactiveQueryKeywordsIntegrationTests.java @@ -0,0 +1,197 @@ +/* + * 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.keywords; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.elasticsearch.annotations.FieldType.*; + +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.core.ReactiveElasticsearchOperations; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; +import org.springframework.data.elasticsearch.junit.jupiter.ReactiveElasticsearchRestTemplateConfiguration; +import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest; +import org.springframework.data.elasticsearch.repository.ReactiveElasticsearchRepository; +import org.springframework.data.elasticsearch.repository.config.EnableReactiveElasticsearchRepositories; +import org.springframework.data.elasticsearch.utils.IndexNameProvider; +import org.springframework.lang.Nullable; +import org.springframework.test.context.ContextConfiguration; + +/** + * @author Peter-Josef Meisch + */ +@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") +@SpringIntegrationTest +@ContextConfiguration(classes = { ReactiveQueryKeywordsIntegrationTests.Config.class }) +public class ReactiveQueryKeywordsIntegrationTests { + + @Configuration + @Import({ ReactiveElasticsearchRestTemplateConfiguration.class }) + @EnableReactiveElasticsearchRepositories(considerNestedRepositories = true) + static class Config { + @Bean + IndexNameProvider indexNameProvider() { + return new IndexNameProvider("reactive-template"); + } + } + + @Autowired private IndexNameProvider indexNameProvider; + @Autowired private ReactiveElasticsearchOperations operations; + @Autowired private SampleRepository repository; + + // region setup + @BeforeEach + void setUp() { + indexNameProvider.increment(); + operations.indexOps(SampleEntity.class).createWithMapping().block(); + } + + @Test + @Order(java.lang.Integer.MAX_VALUE) + void cleanup() { + operations.indexOps(IndexCoordinates.of("*")).delete().block(); + } + // endregion + + @Test // #1909 + @DisplayName("should find by property exists") + void shouldFindByPropertyExists() { + + loadEntities(); + repository.findByMessageExists().mapNotNull(SearchHit::getId).collectList() // + .as(StepVerifier::create) // + .assertNext(ids -> { // + assertThat(ids).containsExactlyInAnyOrder("empty-message", "with-message"); // + }).verifyComplete(); + } + + @Test // #1909 + @DisplayName("should find by property is not null") + void shouldFindByPropertyIsNotNull() { + + loadEntities(); + repository.findByMessageIsNotNull().mapNotNull(SearchHit::getId).collectList() // + .as(StepVerifier::create) // + .assertNext(ids -> { // + assertThat(ids).containsExactlyInAnyOrder("empty-message", "with-message"); // + }).verifyComplete(); + } + + @Test // #1909 + @DisplayName("should find by property is null") + void shouldFindByPropertyIsNull() { + + loadEntities(); + repository.findByMessageIsNull().mapNotNull(SearchHit::getId).collectList() // + .as(StepVerifier::create) // + .assertNext(ids -> { // + assertThat(ids).containsExactlyInAnyOrder("null-message"); // + }).verifyComplete(); + } + + @Test // #1909 + @DisplayName("should find by empty property ") + void shouldFindByEmptyProperty() { + + loadEntities(); + repository.findByMessageIsEmpty().mapNotNull(SearchHit::getId).collectList() // + .as(StepVerifier::create) // + .assertNext(ids -> { // + assertThat(ids).containsExactlyInAnyOrder("empty-message"); // + }).verifyComplete(); + } + + @Test // #1909 + @DisplayName("should find by not empty property ") + void shouldFindByNotEmptyProperty() { + + loadEntities(); + repository.findByMessageIsNotEmpty().mapNotNull(SearchHit::getId).collectList() // + .as(StepVerifier::create) // + .assertNext(ids -> { // + assertThat(ids).containsExactlyInAnyOrder("with-message"); // + }).verifyComplete(); + } + + @SuppressWarnings("SpringDataMethodInconsistencyInspection") + interface SampleRepository extends ReactiveElasticsearchRepository { + Flux> findByMessageExists(); + + Flux> findByMessageIsNotNull(); + + Flux> findByMessageIsNull(); + + Flux> findByMessageIsNotEmpty(); + + Flux> findByMessageIsEmpty(); + } + + private void loadEntities() { + repository.saveAll(Flux.just( // + new SampleEntity("with-message", "message"), // + new SampleEntity("empty-message", ""), // + new SampleEntity("null-message", null)) // + ).blockLast(); // + } + + // region entities + @SuppressWarnings("unused") + @Document(indexName = "#{@indexNameProvider.indexName()}") + static class SampleEntity { + @Nullable @Id private String id; + + @Nullable @Field(type = Text) private String message; + + public SampleEntity() {} + + public SampleEntity(@Nullable String id, @Nullable String message) { + this.id = id; + this.message = message; + } + + @Nullable + public String getId() { + return id; + } + + public void setId(@Nullable String id) { + this.id = id; + } + + @Nullable + public String getMessage() { + return message; + } + + public void setMessage(@Nullable String message) { + this.message = message; + } + } + // endregion +}