Add repository search for nullable or empty properties.

Original Pull Request #1946 
Closes #1909
This commit is contained in:
Peter-Josef Meisch 2021-09-25 14:51:40 +02:00 committed by GitHub
parent b8ae9b4a83
commit 175e7b51ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 417 additions and 22 deletions

View File

@ -242,10 +242,6 @@ A list of supported keywords for Elasticsearch is shown below.
| `findByNameNotIn(Collection<String>names)`
| `{"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.

View File

@ -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);

View File

@ -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;
}

View File

@ -186,7 +186,15 @@ public class ElasticsearchQueryCreator extends AbstractQueryCreator<CriteriaQuer
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 + "'.");
}

View File

@ -25,6 +25,7 @@ import org.springframework.data.elasticsearch.core.query.Criteria;
/**
* @author Peter-Josef Meisch
*/
@SuppressWarnings("ConstantConditions")
class CriteriaQueryProcessorUnitTests {
private final CriteriaQueryProcessor queryProcessor = new CriteriaQueryProcessor();
@ -371,4 +372,67 @@ class CriteriaQueryProcessorUnitTests {
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);
}
}

View File

@ -35,6 +35,7 @@ import org.springframework.data.elasticsearch.annotations.Field;
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 @@ class QueryKeywordsTests {
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 @@ class QueryKeywordsTests {
// then
assertThat(repository.findByAvailableTrue()).hasSize(3);
assertThat(repository.findByAvailableFalse()).hasSize(3);
assertThat(repository.findByAvailableFalse()).hasSize(4);
}
@Test
@ -130,8 +132,8 @@ class QueryKeywordsTests {
// 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 @@ class QueryKeywordsTests {
// 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 @@ class QueryKeywordsTests {
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 @@ class QueryKeywordsTests {
List<String> 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<String> 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<Product> searchHits = repository.findByNameExists();
assertThat(searchHits.getTotalHits()).isEqualTo(6);
}
@Test // #1909
@DisplayName("should find by property is not null")
void shouldFindByPropertyIsNotNull() {
SearchHits<Product> searchHits = repository.findByNameIsNotNull();
assertThat(searchHits.getTotalHits()).isEqualTo(6);
}
@Test // #1909
@DisplayName("should find by property is null")
void shouldFindByPropertyIsNull() {
SearchHits<Product> searchHits = repository.findByNameIsNull();
assertThat(searchHits.getTotalHits()).isEqualTo(1);
}
@Test // #1909
@DisplayName("should find by empty property")
void shouldFindByEmptyProperty() {
SearchHits<Product> searchHits = repository.findByNameEmpty();
assertThat(searchHits.getTotalHits()).isEqualTo(1);
}
@Test // #1909
@DisplayName("should find by non-empty property")
void shouldFindByNonEmptyProperty() {
SearchHits<Product> 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<Product, String> {
List<Product> findByName(@Nullable String name);
@ -399,6 +449,16 @@ class QueryKeywordsTests {
void deleteByName(@Nullable String name);
List<Product> findAllByNameIn(List<String> names);
SearchHits<Product> findByNameExists();
SearchHits<Product> findByNameIsNull();
SearchHits<Product> findByNameIsNotNull();
SearchHits<Product> findByNameEmpty();
SearchHits<Product> findByNameNotEmpty();
}
}

View File

@ -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<SampleEntity, String> {
Flux<SearchHit<SampleEntity>> findByMessageExists();
Flux<SearchHit<SampleEntity>> findByMessageIsNotNull();
Flux<SearchHit<SampleEntity>> findByMessageIsNull();
Flux<SearchHit<SampleEntity>> findByMessageIsNotEmpty();
Flux<SearchHit<SampleEntity>> 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
}