Skip to content

Add repository search for nullable or empty properties. #1946

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions src/main/asciidoc/reference/elasticsearch-repository-queries.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -921,7 +946,15 @@ public enum OperationKey { //
/**
* @since 4.1
*/
GEO_CONTAINS
GEO_CONTAINS, //
/**
* @since 4.3
*/
EMPTY, //
/**
* @since 4.3
*/
NOT_EMPTY
}

/**
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "'.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
/**
* @author Peter-Josef Meisch
*/
@SuppressWarnings("ConstantConditions")
class CriteriaQueryProcessorUnitTests {

private final CriteriaQueryProcessor queryProcessor = new CriteriaQueryProcessor();
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -118,7 +120,7 @@ public void shouldSupportTrueAndFalse() {

// then
assertThat(repository.findByAvailableTrue()).hasSize(3);
assertThat(repository.findByAvailableFalse()).hasSize(3);
assertThat(repository.findByAvailableFalse()).hasSize(4);
}

@Test
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -193,7 +195,8 @@ public void shouldSupportSortOnStandardFieldWithoutCriteria() {
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
Expand All @@ -202,7 +205,7 @@ public void shouldSupportSortOnFieldWithCustomFieldNameWithoutCriteria() {
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
Expand Down Expand Up @@ -252,7 +255,7 @@ void shouldDeleteWithNullValues() {
repository.deleteByName(null);

long count = repository.count();
assertThat(count).isEqualTo(5);
assertThat(count).isEqualTo(6);
}

@Test // DATAES-937
Expand All @@ -273,6 +276,52 @@ void shouldReturnEmptyListOnDerivedMethodWithEmptyInputList() {
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;
Expand Down Expand Up @@ -346,6 +395,7 @@ public void setSortName(@Nullable String sortName) {
}
}

@SuppressWarnings({ "SpringDataRepositoryMethodParametersInspection", "SpringDataMethodInconsistencyInspection" })
interface ProductRepository extends ElasticsearchRepository<Product, String> {

List<Product> findByName(@Nullable String name);
Expand Down Expand Up @@ -399,6 +449,16 @@ interface ProductRepository extends ElasticsearchRepository<Product, String> {
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();
}

}
Loading