Skip to content

Commit 175e7b5

Browse files
authored
Add repository search for nullable or empty properties.
Original Pull Request #1946 Closes #1909
1 parent b8ae9b4 commit 175e7b5

File tree

7 files changed

+417
-22
lines changed

7 files changed

+417
-22
lines changed

Diff for: src/main/asciidoc/reference/elasticsearch-repository-queries.adoc

+20-4
Original file line numberDiff line numberDiff line change
@@ -242,10 +242,6 @@ A list of supported keywords for Elasticsearch is shown below.
242242
| `findByNameNotIn(Collection<String>names)`
243243
| `{"query": {"bool": {"must": [{"query_string": {"query": "NOT(\"?\" \"?\")", "fields": ["name"]}}]}}}`
244244

245-
| `Near`
246-
| `findByStoreNear`
247-
| `Not Supported Yet !`
248-
249245
| `True`
250246
| `findByAvailableTrue`
251247
| `{ "query" : {
@@ -277,6 +273,26 @@ A list of supported keywords for Elasticsearch is shown below.
277273
}, "sort":[{"name":{"order":"desc"}}]
278274
}`
279275

276+
| `Exists`
277+
| `findByNameExists`
278+
| `{"query":{"bool":{"must":[{"exists":{"field":"name"}}]}}}`
279+
280+
| `IsNull`
281+
| `findByNameIsNull`
282+
| `{"query":{"bool":{"must_not":[{"exists":{"field":"name"}}]}}}`
283+
284+
| `IsNotNull`
285+
| `findByNameIsNotNull`
286+
| `{"query":{"bool":{"must":[{"exists":{"field":"name"}}]}}}`
287+
288+
| `IsEmpty`
289+
| `findByNameIsEmpty`
290+
| `{"query":{"bool":{"must":[{"bool":{"must":[{"exists":{"field":"name"}}],"must_not":[{"wildcard":{"name":{"wildcard":"*"}}}]}}]}}}`
291+
292+
| `IsNotEmpty`
293+
| `findByNameIsNotEmpty`
294+
| `{"query":{"bool":{"must":[{"wildcard":{"name":{"wildcard":"*"}}}]}}}`
295+
280296
|===
281297

282298
NOTE: Methods names to build Geo-shape queries taking `GeoJson` parameters are not supported.

Diff for: src/main/java/org/springframework/data/elasticsearch/core/CriteriaQueryProcessor.java

+19-4
Original file line numberDiff line numberDiff line change
@@ -165,20 +165,35 @@ private QueryBuilder queryForEntries(Criteria criteria) {
165165
@Nullable
166166
private QueryBuilder queryFor(Criteria.CriteriaEntry entry, Field field) {
167167

168+
QueryBuilder query = null;
168169
String fieldName = field.getName();
169170
boolean isKeywordField = FieldType.Keyword == field.getFieldType();
170171

171172
OperationKey key = entry.getKey();
172173

173-
if (key == OperationKey.EXISTS) {
174-
return existsQuery(fieldName);
174+
// operations without a value
175+
switch (key) {
176+
case EXISTS:
177+
query = existsQuery(fieldName);
178+
break;
179+
case EMPTY:
180+
query = boolQuery().must(existsQuery(fieldName)).mustNot(wildcardQuery(fieldName, "*"));
181+
break;
182+
case NOT_EMPTY:
183+
query = wildcardQuery(fieldName, "*");
184+
break;
185+
default:
186+
break;
187+
}
188+
189+
if (query != null) {
190+
return query;
175191
}
176192

193+
// now operation keys with a value
177194
Object value = entry.getValue();
178195
String searchText = QueryParserUtil.escape(value.toString());
179196

180-
QueryBuilder query = null;
181-
182197
switch (key) {
183198
case EQUALS:
184199
query = queryStringQuery(searchText).field(fieldName).defaultOperator(AND);

Diff for: src/main/java/org/springframework/data/elasticsearch/core/query/Criteria.java

+37-2
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,31 @@ public Criteria matchesAll(Object value) {
586586
queryCriteriaEntries.add(new CriteriaEntry(OperationKey.MATCHES_ALL, value));
587587
return this;
588588
}
589+
590+
/**
591+
* Add a {@link OperationKey#EMPTY} entry to the {@link #queryCriteriaEntries}.
592+
*
593+
* @return this object
594+
* @since 4.3
595+
*/
596+
public Criteria empty() {
597+
598+
queryCriteriaEntries.add(new CriteriaEntry(OperationKey.EMPTY));
599+
return this;
600+
}
601+
602+
/**
603+
* Add a {@link OperationKey#NOT_EMPTY} entry to the {@link #queryCriteriaEntries}.
604+
*
605+
* @return this object
606+
* @since 4.3
607+
*/
608+
public Criteria notEmpty() {
609+
610+
queryCriteriaEntries.add(new CriteriaEntry(OperationKey.NOT_EMPTY));
611+
return this;
612+
}
613+
589614
// endregion
590615

591616
// region criteria entries - filter
@@ -921,7 +946,15 @@ public enum OperationKey { //
921946
/**
922947
* @since 4.1
923948
*/
924-
GEO_CONTAINS
949+
GEO_CONTAINS, //
950+
/**
951+
* @since 4.3
952+
*/
953+
EMPTY, //
954+
/**
955+
* @since 4.3
956+
*/
957+
NOT_EMPTY
925958
}
926959

927960
/**
@@ -934,7 +967,9 @@ public static class CriteriaEntry {
934967

935968
protected CriteriaEntry(OperationKey key) {
936969

937-
Assert.isTrue(key == OperationKey.EXISTS, "key must be OperationKey.EXISTS for this call");
970+
boolean keyIsValid = key == OperationKey.EXISTS || key == OperationKey.EMPTY || key == OperationKey.NOT_EMPTY;
971+
Assert.isTrue(keyIsValid,
972+
"key must be OperationKey.EXISTS, OperationKey.EMPTY or OperationKey.EMPTY for this call");
938973

939974
this.key = key;
940975
}

Diff for: src/main/java/org/springframework/data/elasticsearch/repository/query/parser/ElasticsearchQueryCreator.java

+9-1
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,15 @@ private Criteria from(Part part, Criteria criteria, Iterator<?> parameters) {
186186
if (firstParameter instanceof String && secondParameter instanceof String)
187187
return criteria.within((String) firstParameter, (String) secondParameter);
188188
}
189-
189+
case EXISTS:
190+
case IS_NOT_NULL:
191+
return criteria.exists();
192+
case IS_NULL:
193+
return criteria.not().exists();
194+
case IS_EMPTY:
195+
return criteria.empty();
196+
case IS_NOT_EMPTY:
197+
return criteria.notEmpty();
190198
default:
191199
throw new InvalidDataAccessApiUsageException("Illegal criteria found '" + type + "'.");
192200
}

Diff for: src/test/java/org/springframework/data/elasticsearch/core/CriteriaQueryProcessorUnitTests.java

+64
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
/**
2626
* @author Peter-Josef Meisch
2727
*/
28+
@SuppressWarnings("ConstantConditions")
2829
class CriteriaQueryProcessorUnitTests {
2930

3031
private final CriteriaQueryProcessor queryProcessor = new CriteriaQueryProcessor();
@@ -371,4 +372,67 @@ void shouldBuildNestedQuery() throws JSONException {
371372

372373
assertEquals(expected, query, false);
373374
}
375+
376+
@Test // #1909
377+
@DisplayName("should build query for empty property")
378+
void shouldBuildQueryForEmptyProperty() throws JSONException {
379+
380+
String expected = "{\n" + //
381+
" \"bool\" : {\n" + //
382+
" \"must\" : [\n" + //
383+
" {\n" + //
384+
" \"bool\" : {\n" + //
385+
" \"must\" : [\n" + //
386+
" {\n" + //
387+
" \"exists\" : {\n" + //
388+
" \"field\" : \"lastName\"" + //
389+
" }\n" + //
390+
" }\n" + //
391+
" ],\n" + //
392+
" \"must_not\" : [\n" + //
393+
" {\n" + //
394+
" \"wildcard\" : {\n" + //
395+
" \"lastName\" : {\n" + //
396+
" \"wildcard\" : \"*\"" + //
397+
" }\n" + //
398+
" }\n" + //
399+
" }\n" + //
400+
" ]\n" + //
401+
" }\n" + //
402+
" }\n" + //
403+
" ]\n" + //
404+
" }\n" + //
405+
"}"; //
406+
407+
Criteria criteria = new Criteria("lastName").empty();
408+
409+
String query = queryProcessor.createQuery(criteria).toString();
410+
411+
assertEquals(expected, query, false);
412+
}
413+
414+
@Test // #1909
415+
@DisplayName("should build query for non-empty property")
416+
void shouldBuildQueryForNonEmptyProperty() throws JSONException {
417+
418+
String expected = "{\n" + //
419+
" \"bool\" : {\n" + //
420+
" \"must\" : [\n" + //
421+
" {\n" + //
422+
" \"wildcard\" : {\n" + //
423+
" \"lastName\" : {\n" + //
424+
" \"wildcard\" : \"*\"\n" + //
425+
" }\n" + //
426+
" }\n" + //
427+
" }\n" + //
428+
" ]\n" + //
429+
" }\n" + //
430+
"}\n"; //
431+
432+
Criteria criteria = new Criteria("lastName").notEmpty();
433+
434+
String query = queryProcessor.createQuery(criteria).toString();
435+
436+
assertEquals(expected, query, false);
437+
}
374438
}

Diff for: src/test/java/org/springframework/data/elasticsearch/repository/query/keywords/QueryKeywordsTests.java

+71-11
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.springframework.data.elasticsearch.annotations.FieldType;
3636
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
3737
import org.springframework.data.elasticsearch.core.IndexOperations;
38+
import org.springframework.data.elasticsearch.core.SearchHits;
3839
import org.springframework.data.elasticsearch.junit.jupiter.ElasticsearchRestTemplateConfiguration;
3940
import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest;
4041
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
@@ -75,9 +76,10 @@ public void before() {
7576
Product product3 = new Product("3", "Sugar", "Beet sugar", 1.1f, true, "sort3");
7677
Product product4 = new Product("4", "Salt", "Rock salt", 1.9f, true, "sort2");
7778
Product product5 = new Product("5", "Salt", "Sea salt", 2.1f, false, "sort1");
78-
Product product6 = new Product("6", null, "no name", 3.4f, false, "sort0");
79+
Product product6 = new Product("6", null, "no name", 3.4f, false, "sort6");
80+
Product product7 = new Product("7", "", "empty name", 3.4f, false, "sort7");
7981

80-
repository.saveAll(Arrays.asList(product1, product2, product3, product4, product5, product6));
82+
repository.saveAll(Arrays.asList(product1, product2, product3, product4, product5, product6, product7));
8183
}
8284

8385
@AfterEach
@@ -118,7 +120,7 @@ public void shouldSupportTrueAndFalse() {
118120

119121
// then
120122
assertThat(repository.findByAvailableTrue()).hasSize(3);
121-
assertThat(repository.findByAvailableFalse()).hasSize(3);
123+
assertThat(repository.findByAvailableFalse()).hasSize(4);
122124
}
123125

124126
@Test
@@ -130,8 +132,8 @@ public void shouldSupportInAndNotInAndNot() {
130132

131133
// then
132134
assertThat(repository.findByPriceIn(Arrays.asList(1.2f, 1.1f))).hasSize(2);
133-
assertThat(repository.findByPriceNotIn(Arrays.asList(1.2f, 1.1f))).hasSize(4);
134-
assertThat(repository.findByPriceNot(1.2f)).hasSize(5);
135+
assertThat(repository.findByPriceNotIn(Arrays.asList(1.2f, 1.1f))).hasSize(5);
136+
assertThat(repository.findByPriceNot(1.2f)).hasSize(6);
135137
}
136138

137139
@Test // DATAES-171
@@ -142,7 +144,7 @@ public void shouldWorkWithNotIn() {
142144
// when
143145

144146
// then
145-
assertThat(repository.findByIdNotIn(Arrays.asList("2", "3"))).hasSize(4);
147+
assertThat(repository.findByIdNotIn(Arrays.asList("2", "3"))).hasSize(5);
146148
}
147149

148150
@Test
@@ -167,8 +169,8 @@ public void shouldSupportLessThanAndGreaterThan() {
167169
assertThat(repository.findByPriceLessThan(1.1f)).hasSize(1);
168170
assertThat(repository.findByPriceLessThanEqual(1.1f)).hasSize(2);
169171

170-
assertThat(repository.findByPriceGreaterThan(1.9f)).hasSize(2);
171-
assertThat(repository.findByPriceGreaterThanEqual(1.9f)).hasSize(3);
172+
assertThat(repository.findByPriceGreaterThan(1.9f)).hasSize(3);
173+
assertThat(repository.findByPriceGreaterThanEqual(1.9f)).hasSize(4);
172174
}
173175

174176
@Test // DATAES-615
@@ -193,7 +195,8 @@ public void shouldSupportSortOnStandardFieldWithoutCriteria() {
193195
List<String> sortedIds = repository.findAllByOrderByText().stream() //
194196
.map(it -> it.text).collect(Collectors.toList());
195197

196-
assertThat(sortedIds).containsExactly("Beet sugar", "Cane sugar", "Cane sugar", "Rock salt", "Sea salt", "no name");
198+
assertThat(sortedIds).containsExactly("Beet sugar", "Cane sugar", "Cane sugar", "Rock salt", "Sea salt",
199+
"empty name", "no name");
197200
}
198201

199202
@Test // DATAES-615
@@ -202,7 +205,7 @@ public void shouldSupportSortOnFieldWithCustomFieldNameWithoutCriteria() {
202205
List<String> sortedIds = repository.findAllByOrderBySortName().stream() //
203206
.map(it -> it.id).collect(Collectors.toList());
204207

205-
assertThat(sortedIds).containsExactly("6", "5", "4", "3", "2", "1");
208+
assertThat(sortedIds).containsExactly("5", "4", "3", "2", "1", "6", "7");
206209
}
207210

208211
@Test // DATAES-178
@@ -252,7 +255,7 @@ void shouldDeleteWithNullValues() {
252255
repository.deleteByName(null);
253256

254257
long count = repository.count();
255-
assertThat(count).isEqualTo(5);
258+
assertThat(count).isEqualTo(6);
256259
}
257260

258261
@Test // DATAES-937
@@ -273,6 +276,52 @@ void shouldReturnEmptyListOnDerivedMethodWithEmptyInputList() {
273276
assertThat(products).isEmpty();
274277
}
275278

279+
@Test // #1909
280+
@DisplayName("should find by property exists")
281+
void shouldFindByPropertyExists() {
282+
283+
SearchHits<Product> searchHits = repository.findByNameExists();
284+
285+
assertThat(searchHits.getTotalHits()).isEqualTo(6);
286+
}
287+
288+
@Test // #1909
289+
@DisplayName("should find by property is not null")
290+
void shouldFindByPropertyIsNotNull() {
291+
292+
SearchHits<Product> searchHits = repository.findByNameIsNotNull();
293+
294+
assertThat(searchHits.getTotalHits()).isEqualTo(6);
295+
}
296+
297+
@Test // #1909
298+
@DisplayName("should find by property is null")
299+
void shouldFindByPropertyIsNull() {
300+
301+
SearchHits<Product> searchHits = repository.findByNameIsNull();
302+
303+
assertThat(searchHits.getTotalHits()).isEqualTo(1);
304+
}
305+
306+
@Test // #1909
307+
@DisplayName("should find by empty property")
308+
void shouldFindByEmptyProperty() {
309+
310+
SearchHits<Product> searchHits = repository.findByNameEmpty();
311+
312+
assertThat(searchHits.getTotalHits()).isEqualTo(1);
313+
}
314+
315+
@Test // #1909
316+
@DisplayName("should find by non-empty property")
317+
void shouldFindByNonEmptyProperty() {
318+
319+
SearchHits<Product> searchHits = repository.findByNameNotEmpty();
320+
321+
assertThat(searchHits.getTotalHits()).isEqualTo(5);
322+
}
323+
324+
@SuppressWarnings("unused")
276325
@Document(indexName = "test-index-product-query-keywords")
277326
static class Product {
278327
@Nullable @Id private String id;
@@ -346,6 +395,7 @@ public void setSortName(@Nullable String sortName) {
346395
}
347396
}
348397

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

351401
List<Product> findByName(@Nullable String name);
@@ -399,6 +449,16 @@ interface ProductRepository extends ElasticsearchRepository<Product, String> {
399449
void deleteByName(@Nullable String name);
400450

401451
List<Product> findAllByNameIn(List<String> names);
452+
453+
SearchHits<Product> findByNameExists();
454+
455+
SearchHits<Product> findByNameIsNull();
456+
457+
SearchHits<Product> findByNameIsNotNull();
458+
459+
SearchHits<Product> findByNameEmpty();
460+
461+
SearchHits<Product> findByNameNotEmpty();
402462
}
403463

404464
}

0 commit comments

Comments
 (0)