Skip to content

Commit 2288d97

Browse files
committed
feat: add official null support with indexMissing and indexEmpty options (#527)
Implement INDEXMISSING and INDEXEMPTY support for enhanced null/empty value queries: - Add indexMissing and indexEmpty parameters to @indexed and @searchable annotations - Update repository queries to use ismissing() when indexMissing=true, with fallback to exists() - Support TAG and TEXT fields for both options, NUMERIC fields for indexMissing only - Requires Redis Stack 2.10+ for full functionality with automatic version detection - Add comprehensive documentation with examples and version requirements
1 parent bf849e1 commit 2288d97

File tree

7 files changed

+342
-18
lines changed

7 files changed

+342
-18
lines changed

docs/content/modules/ROOT/pages/index-annotations.adoc

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,72 @@ public class Company {
259259
* `maxTextFields` - Whether to index all text fields (default: false)
260260
* `temporaryIndex` - Create temporary index (default: false)
261261

262+
== Advanced Null and Empty Value Indexing
263+
264+
=== IndexMissing and IndexEmpty Support
265+
266+
Redis OM Spring supports advanced null and empty value indexing using the `indexMissing` and `indexEmpty` parameters, which leverage Redis Query Engine's INDEXMISSING and INDEXEMPTY features:
267+
268+
[source,java]
269+
----
270+
@Document
271+
public class Product {
272+
@Id
273+
private String id;
274+
275+
@Indexed(indexMissing = true) // Enhanced null queries
276+
private String title;
277+
278+
@Searchable(indexMissing = true, indexEmpty = true) // Full-text with null/empty
279+
private String description;
280+
281+
@Indexed(indexMissing = true) // Numeric fields support indexMissing
282+
private Integer price;
283+
284+
@Indexed(indexEmpty = true) // Tag fields support both options
285+
private String category;
286+
}
287+
----
288+
289+
==== Requirements:
290+
291+
* **Redis Stack 2.10+** required for `indexMissing = true`
292+
* **Redis Stack 2.10+** required for `indexEmpty = true`
293+
* **Automatic fallback** to legacy `exists()` queries on older Redis versions
294+
295+
==== Query Behavior:
296+
297+
When `indexMissing = true` is used:
298+
299+
* `findByTitleIsNull()` uses `ismissing(@title)` (Redis Stack 2.10+)
300+
* `findByTitleIsNotNull()` uses `!ismissing(@title)` (Redis Stack 2.10+)
301+
* Automatically falls back to `!exists(@title)` on older Redis versions
302+
303+
==== Supported Field Types:
304+
305+
* **TAG fields** (`@Indexed`, `@TagIndexed`) - supports both `indexMissing` and `indexEmpty`
306+
* **TEXT fields** (`@Searchable`, `@TextIndexed`) - supports both `indexMissing` and `indexEmpty`
307+
* **NUMERIC fields** (`@Indexed`, `@NumericIndexed`) - supports `indexMissing` only
308+
309+
==== Repository Query Examples:
310+
311+
[source,java]
312+
----
313+
public interface ProductRepository extends RedisDocumentRepository<Product, String> {
314+
// Enhanced null queries (uses ismissing() when indexMissing = true)
315+
List<Product> findByTitleIsNull();
316+
List<Product> findByTitleIsNotNull();
317+
318+
// Works with complex queries
319+
List<Product> findByTitleIsNullAndPriceGreaterThan(Double price);
320+
321+
// Empty string queries (requires indexEmpty = true)
322+
List<Product> findByCategoryIsNull(); // Matches both null and empty strings
323+
}
324+
----
325+
326+
NOTE: The `indexMissing` and `indexEmpty` options provide more accurate null/empty value queries compared to the legacy `exists()` approach, especially for distinguishing between null values and missing fields.
327+
262328
== Best Practices
263329

264330
* **Use `@Indexed` for most cases** - It auto-detects the appropriate index type

redis-om-spring/src/main/java/com/redis/om/spring/indexing/RediSearchIndexer.java

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -463,15 +463,15 @@ private List<SearchField> findIndexFields(java.lang.reflect.Field field, String
463463
if (CharSequence.class.isAssignableFrom(fieldType) || //
464464
(fieldType == Boolean.class) || (fieldType == UUID.class) || (fieldType == Ulid.class)) {
465465
fields.add(SearchField.of(field, indexAsTagFieldFor(field, isDocument, prefix, indexed.sortable(), indexed
466-
.separator(), indexed.arrayIndex(), indexed.alias())));
466+
.separator(), indexed.arrayIndex(), indexed.alias(), indexed.indexMissing(), indexed.indexEmpty())));
467467
} else if (fieldType.isEnum()) {
468468
if (Objects.requireNonNull(indexed.serializationHint()) == SerializationHint.ORDINAL) {
469469
fields.add(SearchField.of(field, indexAsNumericFieldFor(field, isDocument, prefix, indexed.sortable(),
470-
indexed.noindex(), indexed.alias())));
470+
indexed.noindex(), indexed.alias(), indexed.indexMissing(), indexed.indexEmpty())));
471471
gsonBuilder.registerTypeAdapter(fieldType, EnumTypeAdapter.of(fieldType));
472472
} else {
473473
fields.add(SearchField.of(field, indexAsTagFieldFor(field, isDocument, prefix, indexed.sortable(), indexed
474-
.separator(), indexed.arrayIndex(), indexed.alias())));
474+
.separator(), indexed.arrayIndex(), indexed.alias(), indexed.indexMissing(), indexed.indexEmpty())));
475475
}
476476
}
477477
//
@@ -486,7 +486,7 @@ else if ( //
486486
(field.getType() == OffsetDateTime.class) //
487487
) {
488488
fields.add(SearchField.of(field, indexAsNumericFieldFor(field, isDocument, prefix, indexed.sortable(), indexed
489-
.noindex(), indexed.alias())));
489+
.noindex(), indexed.alias(), indexed.indexMissing(), indexed.indexEmpty())));
490490
}
491491
//
492492
// Set / List
@@ -505,16 +505,16 @@ else if (Set.class.isAssignableFrom(fieldType) || List.class.isAssignableFrom(fi
505505

506506
if (CharSequence.class.isAssignableFrom(collectionType) || (collectionType == Boolean.class)) {
507507
fields.add(SearchField.of(field, indexAsTagFieldFor(field, isDocument, prefix, indexed.sortable(), indexed
508-
.separator(), indexed.arrayIndex(), indexed.alias())));
508+
.separator(), indexed.arrayIndex(), indexed.alias(), indexed.indexMissing(), indexed.indexEmpty())));
509509
} else if (isDocument) {
510510
if (Number.class.isAssignableFrom(collectionType)) {
511511
fields.add(SearchField.of(field, indexAsNumericFieldFor(field, true, prefix, indexed.sortable(), indexed
512-
.noindex(), indexed.alias())));
512+
.noindex(), indexed.alias(), indexed.indexMissing(), indexed.indexEmpty())));
513513
} else if (collectionType == Point.class) {
514514
fields.add(SearchField.of(field, indexAsGeoFieldFor(field, true, prefix, indexed.alias())));
515515
} else if (collectionType == UUID.class || collectionType == Ulid.class) {
516516
fields.add(SearchField.of(field, indexAsTagFieldFor(field, true, prefix, indexed.sortable(), indexed
517-
.separator(), 0, indexed.alias())));
517+
.separator(), 0, indexed.alias(), indexed.indexMissing(), indexed.indexEmpty())));
518518
} else {
519519
// Index nested JSON fields
520520
logger.debug(String.format("Found nested field on field of type: %s", field.getType()));
@@ -546,9 +546,10 @@ else if (fieldType == Point.class) {
546546
} else { // Schema field type hardcoded/set in @Indexed
547547
switch (indexed.schemaFieldType()) {
548548
case TAG -> fields.add(SearchField.of(field, indexAsTagFieldFor(field, isDocument, prefix, indexed.sortable(),
549-
indexed.separator(), indexed.arrayIndex(), indexed.alias())));
549+
indexed.separator(), indexed.arrayIndex(), indexed.alias(), indexed.indexMissing(), indexed
550+
.indexEmpty())));
550551
case NUMERIC -> fields.add(SearchField.of(field, indexAsNumericFieldFor(field, isDocument, prefix, indexed
551-
.sortable(), indexed.noindex(), indexed.alias())));
552+
.sortable(), indexed.noindex(), indexed.alias(), indexed.indexMissing(), indexed.indexEmpty())));
552553
case GEO -> fields.add(SearchField.of(field, indexAsGeoFieldFor(field, true, prefix, indexed.alias())));
553554
case VECTOR -> fields.add(SearchField.of(field, indexAsVectorFieldFor(field, isDocument, prefix, indexed)));
554555
case NESTED -> {
@@ -676,9 +677,16 @@ private VectorField indexAsVectorFieldFor(java.lang.reflect.Field field, boolean
676677

677678
private SchemaField indexAsTagFieldFor(java.lang.reflect.Field field, boolean isDocument, String prefix,
678679
boolean sortable, String separator, int arrayIndex, String annotationAlias) {
680+
return indexAsTagFieldFor(field, isDocument, prefix, sortable, separator, arrayIndex, annotationAlias, false,
681+
false);
682+
}
683+
684+
private SchemaField indexAsTagFieldFor(java.lang.reflect.Field field, boolean isDocument, String prefix,
685+
boolean sortable, String separator, int arrayIndex, String annotationAlias, boolean indexMissing,
686+
boolean indexEmpty) {
679687
FieldName fieldName = buildFieldName(field, prefix, isDocument, Optional.ofNullable(annotationAlias), Optional.of(
680688
arrayIndex));
681-
return getTagField(fieldName, separator, sortable);
689+
return getTagField(fieldName, separator, sortable, indexMissing, indexEmpty);
682690
}
683691

684692
private TextField indexAsTextFieldFor(java.lang.reflect.Field field, boolean isDocument, String prefix,
@@ -710,13 +718,23 @@ private NumericField indexAsNumericFieldFor(java.lang.reflect.Field field, boole
710718

711719
private NumericField indexAsNumericFieldFor(java.lang.reflect.Field field, boolean isDocument, String prefix,
712720
boolean sortable, boolean noIndex, String annotationAlias) {
721+
return indexAsNumericFieldFor(field, isDocument, prefix, sortable, noIndex, annotationAlias, false, false);
722+
}
723+
724+
private NumericField indexAsNumericFieldFor(java.lang.reflect.Field field, boolean isDocument, String prefix,
725+
boolean sortable, boolean noIndex, String annotationAlias, boolean indexMissing, boolean indexEmpty) {
713726
var fieldName = buildFieldName(field, prefix, isDocument, Optional.ofNullable(annotationAlias), Optional.empty());
714727

715728
NumericField num = NumericField.of(fieldName);
716729
if (sortable)
717730
num.sortable();
718731
if (noIndex)
719732
num.noIndex();
733+
if (indexMissing)
734+
num.indexMissing();
735+
// Note: NumericField doesn't support indexEmpty() in current Jedis version
736+
// if (indexEmpty)
737+
// num.indexEmpty();
720738
return num;
721739
}
722740

@@ -827,6 +845,11 @@ private List<SearchField> getNestedField(String fieldPrefix, java.lang.reflect.F
827845
}
828846

829847
private TagField getTagField(FieldName fieldName, String separator, boolean sortable) {
848+
return getTagField(fieldName, separator, sortable, false, false);
849+
}
850+
851+
private TagField getTagField(FieldName fieldName, String separator, boolean sortable, boolean indexMissing,
852+
boolean indexEmpty) {
830853
TagField tag = TagField.of(fieldName);
831854
if (separator != null) {
832855
if (separator.length() != 1) {
@@ -836,6 +859,10 @@ private TagField getTagField(FieldName fieldName, String separator, boolean sort
836859
}
837860
if (sortable)
838861
tag.sortable();
862+
if (indexMissing)
863+
tag.indexMissing();
864+
if (indexEmpty)
865+
tag.indexEmpty();
839866
return tag;
840867
}
841868

redis-om-spring/src/main/java/com/redis/om/spring/repository/query/RediSearchQuery.java

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -620,9 +620,17 @@ private Object executeDeleteQuery(Object[] parameters) {
620620
for (List<Pair<String, QueryClause>> orPartParts : queryOrParts) {
621621
for (Pair<String, QueryClause> pair : orPartParts) {
622622
if (pair.getSecond() == QueryClause.IS_NULL) {
623-
aggregation.filter("!exists(@" + pair.getFirst() + ")");
623+
if (hasIndexMissing(pair.getFirst())) {
624+
aggregation.filter("ismissing(@" + pair.getFirst() + ")");
625+
} else {
626+
aggregation.filter("!exists(@" + pair.getFirst() + ")");
627+
}
624628
} else if (pair.getSecond() == QueryClause.IS_NOT_NULL) {
625-
aggregation.filter("exists(@" + pair.getFirst() + ")");
629+
if (hasIndexMissing(pair.getFirst())) {
630+
aggregation.filter("!ismissing(@" + pair.getFirst() + ")");
631+
} else {
632+
aggregation.filter("exists(@" + pair.getFirst() + ")");
633+
}
626634
}
627635
}
628636
}
@@ -884,9 +892,17 @@ private Object executeNullQuery(Object[] parameters) {
884892
for (List<Pair<String, QueryClause>> orPartParts : queryOrParts) {
885893
for (Pair<String, QueryClause> pair : orPartParts) {
886894
if (pair.getSecond() == QueryClause.IS_NULL) {
887-
aggregation.filter("!exists(@" + pair.getFirst() + ")");
895+
if (hasIndexMissing(pair.getFirst())) {
896+
aggregation.filter("ismissing(@" + pair.getFirst() + ")");
897+
} else {
898+
aggregation.filter("!exists(@" + pair.getFirst() + ")");
899+
}
888900
} else if (pair.getSecond() == QueryClause.IS_NOT_NULL) {
889-
aggregation.filter("exists(@" + pair.getFirst() + ")");
901+
if (hasIndexMissing(pair.getFirst())) {
902+
aggregation.filter("!ismissing(@" + pair.getFirst() + ")");
903+
} else {
904+
aggregation.filter("exists(@" + pair.getFirst() + ")");
905+
}
890906
}
891907
}
892908
}
@@ -957,4 +973,38 @@ private Object executeNullQuery(Object[] parameters) {
957973
return entities;
958974
}
959975
}
976+
977+
/**
978+
* Checks if a field has indexMissing enabled by examining its annotations.
979+
*
980+
* @param fieldName the name of the field to check
981+
* @return true if the field has indexMissing = true, false otherwise
982+
*/
983+
private boolean hasIndexMissing(String fieldName) {
984+
try {
985+
Field field = ReflectionUtils.findField(domainType, fieldName);
986+
if (field == null) {
987+
return false;
988+
}
989+
990+
// Check @Indexed annotation
991+
if (field.isAnnotationPresent(com.redis.om.spring.annotations.Indexed.class)) {
992+
com.redis.om.spring.annotations.Indexed indexed = field.getAnnotation(
993+
com.redis.om.spring.annotations.Indexed.class);
994+
return indexed.indexMissing();
995+
}
996+
997+
// Check @Searchable annotation
998+
if (field.isAnnotationPresent(com.redis.om.spring.annotations.Searchable.class)) {
999+
com.redis.om.spring.annotations.Searchable searchable = field.getAnnotation(
1000+
com.redis.om.spring.annotations.Searchable.class);
1001+
return searchable.indexMissing();
1002+
}
1003+
1004+
return false;
1005+
} catch (Exception e) {
1006+
logger.debug("Failed to check indexMissing for field: " + fieldName, e);
1007+
return false;
1008+
}
1009+
}
9601010
}

redis-om-spring/src/main/java/com/redis/om/spring/repository/query/RedisEnhancedQuery.java

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -622,9 +622,17 @@ private Object executeDeleteQuery(Object[] parameters) {
622622
for (List<Pair<String, QueryClause>> orPartParts : queryOrParts) {
623623
for (Pair<String, QueryClause> pair : orPartParts) {
624624
if (pair.getSecond() == QueryClause.IS_NULL) {
625-
aggregation.filter("!exists(@" + pair.getFirst() + ")");
625+
if (hasIndexMissing(pair.getFirst())) {
626+
aggregation.filter("ismissing(@" + pair.getFirst() + ")");
627+
} else {
628+
aggregation.filter("!exists(@" + pair.getFirst() + ")");
629+
}
626630
} else if (pair.getSecond() == QueryClause.IS_NOT_NULL) {
627-
aggregation.filter("exists(@" + pair.getFirst() + ")");
631+
if (hasIndexMissing(pair.getFirst())) {
632+
aggregation.filter("!ismissing(@" + pair.getFirst() + ")");
633+
} else {
634+
aggregation.filter("exists(@" + pair.getFirst() + ")");
635+
}
628636
}
629637
}
630638
}
@@ -898,9 +906,17 @@ private Object executeNullQuery(Object[] parameters) {
898906
for (List<Pair<String, QueryClause>> orPartParts : queryOrParts) {
899907
for (Pair<String, QueryClause> pair : orPartParts) {
900908
if (pair.getSecond() == QueryClause.IS_NULL) {
901-
aggregation.filter("!exists(@" + pair.getFirst() + ")");
909+
if (hasIndexMissing(pair.getFirst())) {
910+
aggregation.filter("ismissing(@" + pair.getFirst() + ")");
911+
} else {
912+
aggregation.filter("!exists(@" + pair.getFirst() + ")");
913+
}
902914
} else if (pair.getSecond() == QueryClause.IS_NOT_NULL) {
903-
aggregation.filter("exists(@" + pair.getFirst() + ")");
915+
if (hasIndexMissing(pair.getFirst())) {
916+
aggregation.filter("!ismissing(@" + pair.getFirst() + ")");
917+
} else {
918+
aggregation.filter("exists(@" + pair.getFirst() + ")");
919+
}
904920
}
905921
}
906922
}
@@ -971,4 +987,38 @@ private Object executeNullQuery(Object[] parameters) {
971987
return entities;
972988
}
973989
}
990+
991+
/**
992+
* Checks if a field has indexMissing enabled by examining its annotations.
993+
*
994+
* @param fieldName the name of the field to check
995+
* @return true if the field has indexMissing = true, false otherwise
996+
*/
997+
private boolean hasIndexMissing(String fieldName) {
998+
try {
999+
Field field = ReflectionUtils.findField(domainType, fieldName);
1000+
if (field == null) {
1001+
return false;
1002+
}
1003+
1004+
// Check @Indexed annotation
1005+
if (field.isAnnotationPresent(com.redis.om.spring.annotations.Indexed.class)) {
1006+
com.redis.om.spring.annotations.Indexed indexed = field.getAnnotation(
1007+
com.redis.om.spring.annotations.Indexed.class);
1008+
return indexed.indexMissing();
1009+
}
1010+
1011+
// Check @Searchable annotation
1012+
if (field.isAnnotationPresent(com.redis.om.spring.annotations.Searchable.class)) {
1013+
com.redis.om.spring.annotations.Searchable searchable = field.getAnnotation(
1014+
com.redis.om.spring.annotations.Searchable.class);
1015+
return searchable.indexMissing();
1016+
}
1017+
1018+
return false;
1019+
} catch (Exception e) {
1020+
logger.debug("Failed to check indexMissing for field: " + fieldName, e);
1021+
return false;
1022+
}
1023+
}
9741024
}

0 commit comments

Comments
 (0)