Skip to content

Commit a1a8d7f

Browse files
committed
fix: handle predicate negation and TAG field notEq operations in EntityStream
- Add NegatedPredicate class to properly handle SearchFieldPredicate negation - Override negate() in BaseAbstractPredicate to preserve SearchFieldPredicate type - Fix ClassCastException in NotEqualPredicate.getValues() for single values - Add comprehensive tests for isMissing().negate() and notEq("") operations - Document missing field queries in EntityStream documentation The issue occurred when isMissing().negate() was called, as it used Java's default Predicate.negate() which wrapped the predicate in a generic negation, losing the SearchFieldPredicate type needed for proper query generation. This caused the EntityStream to generate wildcard "*" queries instead of proper "-ismissing(@field)" queries. Additionally fixed a ClassCastException in NotEqualPredicate when using notEq("") with single string values by properly checking if the value is already iterable before casting.
1 parent 6ecaa14 commit a1a8d7f

File tree

7 files changed

+338
-1
lines changed

7 files changed

+338
-1
lines changed

docs/content/modules/ROOT/pages/entity-streams.adoc

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,52 @@ List<String> exactLocation = entityStream.of(Company.class)
227227
.collect(Collectors.toList());
228228
----
229229

230+
=== Missing Field Queries
231+
232+
For fields with `indexMissing = true` and `indexEmpty = true` (typically TAG fields), you can filter for missing or present values:
233+
234+
[source,java]
235+
----
236+
@Document
237+
public class RxDocument {
238+
@Id
239+
private String id;
240+
241+
@Indexed
242+
private String rxNumber;
243+
244+
@Indexed(indexEmpty = true, indexMissing = true)
245+
private String lock;
246+
247+
@Indexed
248+
private String status;
249+
}
250+
251+
// Find documents where lock field is missing (null or not set)
252+
List<RxDocument> unlockedDocs = entityStream.of(RxDocument.class)
253+
.filter(RxDocument$.LOCK.isMissing())
254+
.collect(Collectors.toList());
255+
256+
// Find documents where lock field exists (not null, may be empty string)
257+
List<RxDocument> lockedDocs = entityStream.of(RxDocument.class)
258+
.filter(RxDocument$.LOCK.isMissing().negate())
259+
.collect(Collectors.toList());
260+
261+
// Combine with other conditions
262+
List<RxDocument> activeAndLocked = entityStream.of(RxDocument.class)
263+
.filter(RxDocument$.STATUS.eq("ACTIVE"))
264+
.filter(RxDocument$.LOCK.isMissing().negate())
265+
.collect(Collectors.toList());
266+
267+
// Find non-empty and non-missing values
268+
List<RxDocument> hasContent = entityStream.of(RxDocument.class)
269+
.filter(RxDocument$.LOCK.isMissing().negate()) // Not missing
270+
.filter(RxDocument$.LOCK.notEq("")) // Not empty string
271+
.collect(Collectors.toList());
272+
----
273+
274+
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.
275+
230276
=== Tag and Collection Queries
231277

232278
[source,java]

redis-om-spring/src/main/java/com/redis/om/spring/search/stream/predicates/BaseAbstractPredicate.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,4 +162,9 @@ public SearchFieldAccessor getSearchFieldAccessor() {
162162
return field;
163163
}
164164

165+
@Override
166+
public SearchFieldPredicate<E, T> negate() {
167+
return new NegatedPredicate<>(this);
168+
}
169+
165170
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.redis.om.spring.search.stream.predicates;
2+
3+
import java.lang.reflect.Field;
4+
5+
import redis.clients.jedis.search.Schema.FieldType;
6+
import redis.clients.jedis.search.querybuilder.Node;
7+
8+
/**
9+
* A predicate that negates another search field predicate.
10+
* This class wraps a {@link SearchFieldPredicate} and applies logical negation to it
11+
* in Redis search queries.
12+
*
13+
* <p>This predicate is used when calling {@code negate()} on a search predicate,
14+
* ensuring that the negation is properly handled in the Redis search query.</p>
15+
*
16+
* @param <E> the entity type being filtered
17+
* @param <T> the field type of the predicate
18+
*/
19+
public class NegatedPredicate<E, T> implements SearchFieldPredicate<E, T> {
20+
21+
private final SearchFieldPredicate<E, T> predicate;
22+
23+
/**
24+
* Creates a new negated predicate.
25+
*
26+
* @param predicate the predicate to negate
27+
*/
28+
public NegatedPredicate(SearchFieldPredicate<E, T> predicate) {
29+
this.predicate = predicate;
30+
}
31+
32+
@Override
33+
public boolean test(T t) {
34+
return !predicate.test(t);
35+
}
36+
37+
@Override
38+
public FieldType getSearchFieldType() {
39+
return predicate.getSearchFieldType();
40+
}
41+
42+
@Override
43+
public Field getField() {
44+
return predicate.getField();
45+
}
46+
47+
@Override
48+
public String getSearchAlias() {
49+
return predicate.getSearchAlias();
50+
}
51+
52+
@Override
53+
public Node apply(Node root) {
54+
// Get the node from the wrapped predicate
55+
Node predicateNode = predicate.apply(root);
56+
57+
// If the predicate generates a custom query string, negate it
58+
String query = predicateNode.toString();
59+
60+
// For special queries like "ismissing", add the negation operator
61+
String negatedQuery = "-" + query;
62+
63+
return new Node() {
64+
@Override
65+
public String toString() {
66+
return negatedQuery;
67+
}
68+
69+
@Override
70+
public String toString(Parenthesize mode) {
71+
return negatedQuery;
72+
}
73+
};
74+
}
75+
76+
@Override
77+
public SearchFieldPredicate<E, T> negate() {
78+
// Double negation returns the original predicate
79+
return predicate;
80+
}
81+
}

redis-om-spring/src/main/java/com/redis/om/spring/search/stream/predicates/tag/NotEqualPredicate.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,16 @@ public NotEqualPredicate(SearchFieldAccessor field, List<String> list) {
5555
* @return the tag values to exclude from matches, either as a single value or collection
5656
*/
5757
public Iterable<?> getValues() {
58-
return value != null ? (Iterable<?>) value : values;
58+
if (value != null) {
59+
// Check if value is already iterable (e.g., a List or Collection)
60+
if (value instanceof Iterable) {
61+
return (Iterable<?>) value;
62+
} else {
63+
// Wrap single values in a List
64+
return List.of(value);
65+
}
66+
}
67+
return values;
5968
}
6069

6170
/**
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.redis.om.spring.fixtures.document.model;
2+
3+
import com.redis.om.spring.annotations.Document;
4+
import com.redis.om.spring.annotations.Indexed;
5+
import org.springframework.data.annotation.Id;
6+
7+
import lombok.Data;
8+
import lombok.NoArgsConstructor;
9+
import lombok.AllArgsConstructor;
10+
import lombok.Builder;
11+
12+
@Data
13+
@NoArgsConstructor
14+
@AllArgsConstructor
15+
@Builder
16+
@Document
17+
public class RxDocument {
18+
@Id
19+
private String id;
20+
21+
@Indexed
22+
private String rxNumber;
23+
24+
@Indexed(indexEmpty = true, indexMissing = true)
25+
private String lock;
26+
27+
@Indexed
28+
private String status;
29+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.redis.om.spring.fixtures.document.repository;
2+
3+
import com.redis.om.spring.fixtures.document.model.RxDocument;
4+
import com.redis.om.spring.repository.RedisDocumentRepository;
5+
import org.springframework.stereotype.Repository;
6+
7+
@Repository
8+
public interface RxDocumentRepository extends RedisDocumentRepository<RxDocument, String> {
9+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package com.redis.om.spring.search.stream;
2+
3+
import com.redis.om.spring.AbstractBaseDocumentTest;
4+
import com.redis.om.spring.fixtures.document.model.RxDocument;
5+
import com.redis.om.spring.fixtures.document.model.RxDocument$;
6+
import com.redis.om.spring.fixtures.document.repository.RxDocumentRepository;
7+
import org.junit.jupiter.api.BeforeEach;
8+
import org.junit.jupiter.api.Test;
9+
import org.springframework.beans.factory.annotation.Autowired;
10+
11+
import java.util.List;
12+
import java.util.stream.Collectors;
13+
14+
import static org.assertj.core.api.Assertions.assertThat;
15+
import static org.junit.jupiter.api.Assertions.assertAll;
16+
17+
class EntityStreamMissingFieldTest extends AbstractBaseDocumentTest {
18+
19+
@Autowired
20+
private RxDocumentRepository rxDocumentRepository;
21+
22+
@Autowired
23+
private EntityStream entityStream;
24+
25+
@BeforeEach
26+
void beforeEach() {
27+
rxDocumentRepository.deleteAll();
28+
29+
// Create test data with various lock states
30+
RxDocument rx1 = RxDocument.builder()
31+
.rxNumber("RX001")
32+
.lock("LOCKED")
33+
.status("ACTIVE")
34+
.build();
35+
36+
RxDocument rx2 = RxDocument.builder()
37+
.rxNumber("RX002")
38+
.lock("") // Empty string
39+
.status("ACTIVE")
40+
.build();
41+
42+
RxDocument rx3 = RxDocument.builder()
43+
.rxNumber("RX003")
44+
.lock(null) // Null value (will be missing in Redis)
45+
.status("ACTIVE")
46+
.build();
47+
48+
RxDocument rx4 = RxDocument.builder()
49+
.rxNumber("RX004")
50+
.lock("PROCESSING")
51+
.status("ACTIVE")
52+
.build();
53+
54+
RxDocument rx5 = RxDocument.builder()
55+
.rxNumber("RX005")
56+
// lock field not set at all (will be missing)
57+
.status("INACTIVE")
58+
.build();
59+
60+
rxDocumentRepository.saveAll(List.of(rx1, rx2, rx3, rx4, rx5));
61+
}
62+
63+
@Test
64+
void testIsMissingNegateProducesWildcardQuery_ReproducesIssue() {
65+
// This test reproduces the issue where isMissing().negate() produces "*" query
66+
// instead of the proper filter "-ismissing(@lock)"
67+
// Capture the actual query that gets executed
68+
String indexName = RxDocument.class.getName() + "Idx";
69+
70+
// Try using isMissing().negate() as user reported
71+
List<String> results = entityStream.of(RxDocument.class)
72+
.filter(RxDocument$.LOCK.isMissing().negate())
73+
.map(RxDocument$.RX_NUMBER)
74+
.collect(Collectors.toList());
75+
76+
System.out.println("Results from isMissing().negate(): " + results);
77+
78+
// After fix: should return RX001, RX002, RX004
79+
// RX001: lock = "LOCKED" (not missing)
80+
// RX002: lock = "" (not missing, just empty)
81+
// RX004: lock = "PROCESSING" (not missing)
82+
// RX003 and RX005 have null/missing lock fields and should be filtered out
83+
84+
assertThat(results).as("isMissing().negate() should filter out documents with missing lock field")
85+
.containsExactlyInAnyOrder("RX001", "RX002", "RX004");
86+
}
87+
88+
@Test
89+
void testIsMissingAlone() {
90+
// Test that isMissing() works correctly on its own
91+
List<String> results = entityStream.of(RxDocument.class)
92+
.filter(RxDocument$.LOCK.isMissing())
93+
.map(RxDocument$.RX_NUMBER)
94+
.collect(Collectors.toList());
95+
96+
// Should return RX003 and RX005 (where lock is null/missing)
97+
assertThat(results).as("isMissing() should find documents with missing lock field")
98+
.containsExactlyInAnyOrder("RX003", "RX005");
99+
}
100+
101+
@Test
102+
void testNotEmptyQuery() {
103+
// Test filtering for non-empty lock values
104+
List<String> results = entityStream.of(RxDocument.class)
105+
.filter(RxDocument$.LOCK.notEq(""))
106+
.map(RxDocument$.RX_NUMBER)
107+
.collect(Collectors.toList());
108+
109+
// Should return documents where lock is not an empty string
110+
// This includes RX001, RX003, RX004, RX005 (excludes only RX002 with empty string)
111+
assertThat(results).as("notEq('') should filter out only empty strings")
112+
.containsExactlyInAnyOrder("RX001", "RX003", "RX004", "RX005");
113+
}
114+
115+
@Test
116+
void testCombinedFiltersWithNegatedMissing() {
117+
// Test combining multiple filters with negated missing
118+
List<String> results = entityStream.of(RxDocument.class)
119+
.filter(RxDocument$.STATUS.eq("ACTIVE"))
120+
.filter(RxDocument$.LOCK.isMissing().negate())
121+
.map(RxDocument$.RX_NUMBER)
122+
.collect(Collectors.toList());
123+
124+
// Should return only ACTIVE documents with non-missing lock
125+
// RX001, RX002, RX004 are ACTIVE and have non-missing lock
126+
assertThat(results).as("Combined filter should work correctly")
127+
.containsExactlyInAnyOrder("RX001", "RX002", "RX004");
128+
}
129+
130+
@Test
131+
void testDoubleNegation() {
132+
// Test that double negation returns to original
133+
List<String> results = entityStream.of(RxDocument.class)
134+
.filter(RxDocument$.LOCK.isMissing().negate().negate())
135+
.map(RxDocument$.RX_NUMBER)
136+
.collect(Collectors.toList());
137+
138+
// Double negation should return to original isMissing()
139+
assertThat(results).as("Double negation should return to original predicate")
140+
.containsExactlyInAnyOrder("RX003", "RX005");
141+
}
142+
143+
@Test
144+
void testFindNonEmptyAndNonMissingValues() {
145+
// Test finding values that are both not empty AND not missing
146+
147+
// This combines two conditions: not missing AND not empty
148+
List<String> results = entityStream.of(RxDocument.class)
149+
.filter(RxDocument$.LOCK.isMissing().negate())
150+
.filter(RxDocument$.LOCK.notEq(""))
151+
.map(RxDocument$.RX_NUMBER)
152+
.collect(Collectors.toList());
153+
154+
// Should return only RX001 and RX004 (not missing AND not empty)
155+
assertThat(results).as("Should find only non-empty and non-missing values")
156+
.containsExactlyInAnyOrder("RX001", "RX004");
157+
}
158+
}

0 commit comments

Comments
 (0)