Skip to content
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
46 changes: 46 additions & 0 deletions docs/content/modules/ROOT/pages/entity-streams.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,52 @@ List<String> exactLocation = entityStream.of(Company.class)
.collect(Collectors.toList());
----

=== Missing Field Queries

For fields with `indexMissing = true` and `indexEmpty = true` (typically TAG fields), you can filter for missing or present values:

[source,java]
----
@Document
public class RxDocument {
@Id
private String id;

@Indexed
private String rxNumber;

@Indexed(indexEmpty = true, indexMissing = true)
private String lock;

@Indexed
private String status;
}

// Find documents where lock field is missing (null or not set)
List<RxDocument> unlockedDocs = entityStream.of(RxDocument.class)
.filter(RxDocument$.LOCK.isMissing())
.collect(Collectors.toList());

// Find documents where lock field exists (not null, may be empty string)
List<RxDocument> lockedDocs = entityStream.of(RxDocument.class)
.filter(RxDocument$.LOCK.isMissing().negate())
.collect(Collectors.toList());

// Combine with other conditions
List<RxDocument> activeAndLocked = entityStream.of(RxDocument.class)
.filter(RxDocument$.STATUS.eq("ACTIVE"))
.filter(RxDocument$.LOCK.isMissing().negate())
.collect(Collectors.toList());

// Find non-empty and non-missing values
List<RxDocument> hasContent = entityStream.of(RxDocument.class)
.filter(RxDocument$.LOCK.isMissing().negate()) // Not missing
.filter(RxDocument$.LOCK.notEq("")) // Not empty string
.collect(Collectors.toList());
----

NOTE: The `isMissing()` predicate checks if a field value is null or not set in Redis. The `.negate()` method inverts this check to find documents where the field exists. This is particularly useful for TAG fields with `indexEmpty = true` and `indexMissing = true` settings.

=== Tag and Collection Queries

[source,java]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,9 @@ public SearchFieldAccessor getSearchFieldAccessor() {
return field;
}

@Override
public SearchFieldPredicate<E, T> negate() {
return new NegatedPredicate<>(this);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.redis.om.spring.search.stream.predicates;

import java.lang.reflect.Field;

import redis.clients.jedis.search.Schema.FieldType;
import redis.clients.jedis.search.querybuilder.Node;

/**
* A predicate that negates another search field predicate.
* This class wraps a {@link SearchFieldPredicate} and applies logical negation to it
* in Redis search queries.
*
* <p>This predicate is used when calling {@code negate()} on a search predicate,
* ensuring that the negation is properly handled in the Redis search query.</p>
*
* @param <E> the entity type being filtered
* @param <T> the field type of the predicate
*/
public class NegatedPredicate<E, T> implements SearchFieldPredicate<E, T> {

private final SearchFieldPredicate<E, T> predicate;

/**
* Creates a new negated predicate.
*
* @param predicate the predicate to negate
*/
public NegatedPredicate(SearchFieldPredicate<E, T> predicate) {
this.predicate = predicate;
}

@Override
public boolean test(T t) {
return !predicate.test(t);
}

@Override
public FieldType getSearchFieldType() {
return predicate.getSearchFieldType();
}

@Override
public Field getField() {
return predicate.getField();
}

@Override
public String getSearchAlias() {
return predicate.getSearchAlias();
}

@Override
public Node apply(Node root) {
// Get the node from the wrapped predicate
Node predicateNode = predicate.apply(root);

// If the predicate generates a custom query string, negate it
String query = predicateNode.toString();

// For special queries like "ismissing", add the negation operator
String negatedQuery = "-" + query;

return new Node() {
@Override
public String toString() {
return negatedQuery;
}

@Override
public String toString(Parenthesize mode) {
return negatedQuery;
}
};
}

@Override
public SearchFieldPredicate<E, T> negate() {
// Double negation returns the original predicate
return predicate;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,16 @@ public NotEqualPredicate(SearchFieldAccessor field, List<String> list) {
* @return the tag values to exclude from matches, either as a single value or collection
*/
public Iterable<?> getValues() {
return value != null ? (Iterable<?>) value : values;
if (value != null) {
// Check if value is already iterable (e.g., a List or Collection)
if (value instanceof Iterable) {
return (Iterable<?>) value;
} else {
// Wrap single values in a List
return List.of(value);
}
}
return values;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.redis.om.spring.fixtures.document.model;

import com.redis.om.spring.annotations.Document;
import com.redis.om.spring.annotations.Indexed;
import org.springframework.data.annotation.Id;

import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Document
public class RxDocument {
@Id
private String id;

@Indexed
private String rxNumber;

@Indexed(indexEmpty = true, indexMissing = true)
private String lock;

@Indexed
private String status;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.redis.om.spring.fixtures.document.repository;

import com.redis.om.spring.fixtures.document.model.RxDocument;
import com.redis.om.spring.repository.RedisDocumentRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface RxDocumentRepository extends RedisDocumentRepository<RxDocument, String> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package com.redis.om.spring.search.stream;

import com.redis.om.spring.AbstractBaseDocumentTest;
import com.redis.om.spring.fixtures.document.model.RxDocument;
import com.redis.om.spring.fixtures.document.model.RxDocument$;
import com.redis.om.spring.fixtures.document.repository.RxDocumentRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.List;
import java.util.stream.Collectors;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;

class EntityStreamMissingFieldTest extends AbstractBaseDocumentTest {

@Autowired
private RxDocumentRepository rxDocumentRepository;

@Autowired
private EntityStream entityStream;

@BeforeEach
void beforeEach() {
rxDocumentRepository.deleteAll();

// Create test data with various lock states
RxDocument rx1 = RxDocument.builder()
.rxNumber("RX001")
.lock("LOCKED")
.status("ACTIVE")
.build();

RxDocument rx2 = RxDocument.builder()
.rxNumber("RX002")
.lock("") // Empty string
.status("ACTIVE")
.build();

RxDocument rx3 = RxDocument.builder()
.rxNumber("RX003")
.lock(null) // Null value (will be missing in Redis)
.status("ACTIVE")
.build();

RxDocument rx4 = RxDocument.builder()
.rxNumber("RX004")
.lock("PROCESSING")
.status("ACTIVE")
.build();

RxDocument rx5 = RxDocument.builder()
.rxNumber("RX005")
// lock field not set at all (will be missing)
.status("INACTIVE")
.build();

rxDocumentRepository.saveAll(List.of(rx1, rx2, rx3, rx4, rx5));
}

@Test
void testIsMissingNegateProducesWildcardQuery_ReproducesIssue() {
// This test reproduces the issue where isMissing().negate() produces "*" query
// instead of the proper filter "-ismissing(@lock)"
// Capture the actual query that gets executed
String indexName = RxDocument.class.getName() + "Idx";

// Try using isMissing().negate() as user reported
List<String> results = entityStream.of(RxDocument.class)
.filter(RxDocument$.LOCK.isMissing().negate())
.map(RxDocument$.RX_NUMBER)
.collect(Collectors.toList());

System.out.println("Results from isMissing().negate(): " + results);

// After fix: should return RX001, RX002, RX004
// RX001: lock = "LOCKED" (not missing)
// RX002: lock = "" (not missing, just empty)
// RX004: lock = "PROCESSING" (not missing)
// RX003 and RX005 have null/missing lock fields and should be filtered out

assertThat(results).as("isMissing().negate() should filter out documents with missing lock field")
.containsExactlyInAnyOrder("RX001", "RX002", "RX004");
}

@Test
void testIsMissingAlone() {
// Test that isMissing() works correctly on its own
List<String> results = entityStream.of(RxDocument.class)
.filter(RxDocument$.LOCK.isMissing())
.map(RxDocument$.RX_NUMBER)
.collect(Collectors.toList());

// Should return RX003 and RX005 (where lock is null/missing)
assertThat(results).as("isMissing() should find documents with missing lock field")
.containsExactlyInAnyOrder("RX003", "RX005");
}

@Test
void testNotEmptyQuery() {
// Test filtering for non-empty lock values
List<String> results = entityStream.of(RxDocument.class)
.filter(RxDocument$.LOCK.notEq(""))
.map(RxDocument$.RX_NUMBER)
.collect(Collectors.toList());

// Should return documents where lock is not an empty string
// This includes RX001, RX003, RX004, RX005 (excludes only RX002 with empty string)
assertThat(results).as("notEq('') should filter out only empty strings")
.containsExactlyInAnyOrder("RX001", "RX003", "RX004", "RX005");
}

@Test
void testCombinedFiltersWithNegatedMissing() {
// Test combining multiple filters with negated missing
List<String> results = entityStream.of(RxDocument.class)
.filter(RxDocument$.STATUS.eq("ACTIVE"))
.filter(RxDocument$.LOCK.isMissing().negate())
.map(RxDocument$.RX_NUMBER)
.collect(Collectors.toList());

// Should return only ACTIVE documents with non-missing lock
// RX001, RX002, RX004 are ACTIVE and have non-missing lock
assertThat(results).as("Combined filter should work correctly")
.containsExactlyInAnyOrder("RX001", "RX002", "RX004");
}

@Test
void testDoubleNegation() {
// Test that double negation returns to original
List<String> results = entityStream.of(RxDocument.class)
.filter(RxDocument$.LOCK.isMissing().negate().negate())
.map(RxDocument$.RX_NUMBER)
.collect(Collectors.toList());

// Double negation should return to original isMissing()
assertThat(results).as("Double negation should return to original predicate")
.containsExactlyInAnyOrder("RX003", "RX005");
}

@Test
void testFindNonEmptyAndNonMissingValues() {
// Test finding values that are both not empty AND not missing

// This combines two conditions: not missing AND not empty
List<String> results = entityStream.of(RxDocument.class)
.filter(RxDocument$.LOCK.isMissing().negate())
.filter(RxDocument$.LOCK.notEq(""))
.map(RxDocument$.RX_NUMBER)
.collect(Collectors.toList());

// Should return only RX001 and RX004 (not missing AND not empty)
assertThat(results).as("Should find only non-empty and non-missing values")
.containsExactlyInAnyOrder("RX001", "RX004");
}
}