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 @@ private QueryBuilder queryForEntries(Criteria criteria) { @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 Criteria matchesAll(Object value) { 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 enum OperationKey { // /** * @since 4.1 */ - GEO_CONTAINS + GEO_CONTAINS, // + /** + * @since 4.3 + */ + EMPTY, // + /** + * @since 4.3 + */ + NOT_EMPTY } /** @@ -934,7 +967,9 @@ public static class CriteriaEntry { 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 @@ private Criteria from(Part part, Criteria criteria, Iterator parameters) { if (firstParameter instanceof String && secondParameter instanceof String) return criteria.within((String) firstParameter, (String) secondParameter); } - + case EXISTS: + case IS_NOT_NULL: + return criteria.exists(); + case IS_NULL: + return criteria.not().exists(); + case IS_EMPTY: + return criteria.empty(); + case IS_NOT_EMPTY: + return criteria.notEmpty(); default: throw new InvalidDataAccessApiUsageException("Illegal criteria found '" + type + "'."); } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/CriteriaQueryProcessorUnitTests.java b/src/test/java/org/springframework/data/elasticsearch/core/CriteriaQueryProcessorUnitTests.java index 222b18376..270c67c63 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/CriteriaQueryProcessorUnitTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/CriteriaQueryProcessorUnitTests.java @@ -25,6 +25,7 @@ /** * @author Peter-Josef Meisch */ +@SuppressWarnings("ConstantConditions") class CriteriaQueryProcessorUnitTests { private final CriteriaQueryProcessor queryProcessor = new CriteriaQueryProcessor(); @@ -371,4 +372,67 @@ void shouldBuildNestedQuery() throws JSONException { assertEquals(expected, query, false); } + + @Test // #1909 + @DisplayName("should build query for empty property") + void shouldBuildQueryForEmptyProperty() throws JSONException { + + String expected = "{\n" + // + " \"bool\" : {\n" + // + " \"must\" : [\n" + // + " {\n" + // + " \"bool\" : {\n" + // + " \"must\" : [\n" + // + " {\n" + // + " \"exists\" : {\n" + // + " \"field\" : \"lastName\"" + // + " }\n" + // + " }\n" + // + " ],\n" + // + " \"must_not\" : [\n" + // + " {\n" + // + " \"wildcard\" : {\n" + // + " \"lastName\" : {\n" + // + " \"wildcard\" : \"*\"" + // + " }\n" + // + " }\n" + // + " }\n" + // + " ]\n" + // + " }\n" + // + " }\n" + // + " ]\n" + // + " }\n" + // + "}"; // + + Criteria criteria = new Criteria("lastName").empty(); + + String query = queryProcessor.createQuery(criteria).toString(); + + assertEquals(expected, query, false); + } + + @Test // #1909 + @DisplayName("should build query for non-empty property") + void shouldBuildQueryForNonEmptyProperty() throws JSONException { + + String expected = "{\n" + // + " \"bool\" : {\n" + // + " \"must\" : [\n" + // + " {\n" + // + " \"wildcard\" : {\n" + // + " \"lastName\" : {\n" + // + " \"wildcard\" : \"*\"\n" + // + " }\n" + // + " }\n" + // + " }\n" + // + " ]\n" + // + " }\n" + // + "}\n"; // + + Criteria criteria = new Criteria("lastName").notEmpty(); + + String query = queryProcessor.createQuery(criteria).toString(); + + assertEquals(expected, query, false); + } } diff --git a/src/test/java/org/springframework/data/elasticsearch/repository/query/keywords/QueryKeywordsTests.java b/src/test/java/org/springframework/data/elasticsearch/repository/query/keywords/QueryKeywordsTests.java index 67c1c5b1e..7da426757 100644 --- a/src/test/java/org/springframework/data/elasticsearch/repository/query/keywords/QueryKeywordsTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/repository/query/keywords/QueryKeywordsTests.java @@ -35,6 +35,7 @@ import org.springframework.data.elasticsearch.annotations.FieldType; import org.springframework.data.elasticsearch.core.ElasticsearchOperations; import org.springframework.data.elasticsearch.core.IndexOperations; +import org.springframework.data.elasticsearch.core.SearchHits; import org.springframework.data.elasticsearch.junit.jupiter.ElasticsearchRestTemplateConfiguration; import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest; import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; @@ -75,9 +76,10 @@ public void before() { Product product3 = new Product("3", "Sugar", "Beet sugar", 1.1f, true, "sort3"); Product product4 = new Product("4", "Salt", "Rock salt", 1.9f, true, "sort2"); Product product5 = new Product("5", "Salt", "Sea salt", 2.1f, false, "sort1"); - Product product6 = new Product("6", null, "no name", 3.4f, false, "sort0"); + Product product6 = new Product("6", null, "no name", 3.4f, false, "sort6"); + Product product7 = new Product("7", "", "empty name", 3.4f, false, "sort7"); - repository.saveAll(Arrays.asList(product1, product2, product3, product4, product5, product6)); + repository.saveAll(Arrays.asList(product1, product2, product3, product4, product5, product6, product7)); } @AfterEach @@ -118,7 +120,7 @@ public void shouldSupportTrueAndFalse() { // then assertThat(repository.findByAvailableTrue()).hasSize(3); - assertThat(repository.findByAvailableFalse()).hasSize(3); + assertThat(repository.findByAvailableFalse()).hasSize(4); } @Test @@ -130,8 +132,8 @@ public void shouldSupportInAndNotInAndNot() { // then assertThat(repository.findByPriceIn(Arrays.asList(1.2f, 1.1f))).hasSize(2); - assertThat(repository.findByPriceNotIn(Arrays.asList(1.2f, 1.1f))).hasSize(4); - assertThat(repository.findByPriceNot(1.2f)).hasSize(5); + assertThat(repository.findByPriceNotIn(Arrays.asList(1.2f, 1.1f))).hasSize(5); + assertThat(repository.findByPriceNot(1.2f)).hasSize(6); } @Test // DATAES-171 @@ -142,7 +144,7 @@ public void shouldWorkWithNotIn() { // when // then - assertThat(repository.findByIdNotIn(Arrays.asList("2", "3"))).hasSize(4); + assertThat(repository.findByIdNotIn(Arrays.asList("2", "3"))).hasSize(5); } @Test @@ -167,8 +169,8 @@ public void shouldSupportLessThanAndGreaterThan() { assertThat(repository.findByPriceLessThan(1.1f)).hasSize(1); assertThat(repository.findByPriceLessThanEqual(1.1f)).hasSize(2); - assertThat(repository.findByPriceGreaterThan(1.9f)).hasSize(2); - assertThat(repository.findByPriceGreaterThanEqual(1.9f)).hasSize(3); + assertThat(repository.findByPriceGreaterThan(1.9f)).hasSize(3); + assertThat(repository.findByPriceGreaterThanEqual(1.9f)).hasSize(4); } @Test // DATAES-615 @@ -193,7 +195,8 @@ public void shouldSupportSortOnStandardFieldWithoutCriteria() { List 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 @@ public void shouldSupportSortOnFieldWithCustomFieldNameWithoutCriteria() { 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 @@ void shouldDeleteWithNullValues() { repository.deleteByName(null); long count = repository.count(); - assertThat(count).isEqualTo(5); + assertThat(count).isEqualTo(6); } @Test // DATAES-937 @@ -273,6 +276,52 @@ void shouldReturnEmptyListOnDerivedMethodWithEmptyInputList() { 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 @@ public void setSortName(@Nullable String sortName) { } } + @SuppressWarnings({ "SpringDataRepositoryMethodParametersInspection", "SpringDataMethodInconsistencyInspection" }) interface ProductRepository extends ElasticsearchRepository { List findByName(@Nullable String name); @@ -399,6 +449,16 @@ interface ProductRepository extends ElasticsearchRepository { 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 +}