Skip to content

Commit eb929ec

Browse files
committed
fix: single-term ~ prefix, multi-word OR grouping; docs: IS NULL & exists()
- Preserve ~ optional-term prefix on single-word FULLTEXT (was escaped) - Wrap multi-word OR operands in parentheses to prevent precedence issues (e.g. 'gaming laptop OR tablet' → (gaming laptop)|tablet) - Add IS NULL / IS NOT NULL (ismissing) section to README - Add exists() function section to README - Add to What's Implemented checklist - Add 3 new tests (368 total)
1 parent c76243a commit eb929ec

3 files changed

Lines changed: 81 additions & 8 deletions

File tree

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ The layered approach emerged from TDD — writing tests first revealed natural b
157157
- [x] Full-text search: exact phrase, fuzzy, proximity, OR/union, LIKE patterns, BM25 scoring (see below)
158158
- [x] GEO field queries with full operator support (see below)
159159
- [x] Date functions: `YEAR()`, `MONTH()`, `DAY()`, `DATE_FORMAT()`, etc. (see below)
160+
- [x] `IS NULL` / `IS NOT NULL` via `ismissing()` (requires Redis 7.4+, see below)
161+
- [x] `exists()` function for field presence checks (see below)
160162

161163
## What's Not Implemented (Yet...)
162164

@@ -219,6 +221,45 @@ SELECT * FROM products WHERE fulltext(title, 'laptop') OR fulltext(description,
219221
- OR is case-insensitive: `'laptop OR tablet'`, `'laptop or tablet'`, and `'laptop Or tablet'` all work
220222
- Special characters (`@`, `|`, `-`, `*`, `+`, etc.) in search terms are automatically escaped
221223

224+
### IS NULL / IS NOT NULL (ismissing)
225+
226+
Check for missing (absent) fields using standard SQL `IS NULL` / `IS NOT NULL` syntax. Requires **Redis 7.4+** (RediSearch 2.10+) with `INDEXMISSING` declared on the field.
227+
228+
| SQL | RediSearch Output |
229+
|-----|-------------------|
230+
| `WHERE email IS NULL` | `ismissing(@email)` |
231+
| `WHERE email IS NOT NULL` | `-ismissing(@email)` |
232+
233+
```sql
234+
-- Find users without an email
235+
SELECT * FROM users WHERE email IS NULL
236+
237+
-- Find users with an email
238+
SELECT * FROM users WHERE email IS NOT NULL
239+
240+
-- Combine with other filters
241+
SELECT * FROM users WHERE category = 'eng' AND email IS NULL
242+
```
243+
244+
**Note:** The field must be declared with `INDEXMISSING` in the index schema. A warning is emitted at translation time as a reminder.
245+
246+
### exists() — Field Presence Check
247+
248+
Check whether a field has a value using `exists()` in SELECT or HAVING. This uses `FT.AGGREGATE` with `APPLY exists(@field)`.
249+
250+
```sql
251+
-- Check if fields exist (returns 1 or 0)
252+
SELECT name, exists(email) AS has_email FROM users
253+
254+
-- Filter to only rows where a field exists
255+
SELECT name FROM users HAVING exists(email) = 1
256+
257+
-- Combine with other computed fields
258+
SELECT name, exists(email) AS has_email, exists(phone) AS has_phone FROM users
259+
```
260+
261+
**Note:** `exists()` is different from `IS NOT NULL` — it works via `FT.AGGREGATE APPLY` and doesn't require `INDEXMISSING` on the field, but returns `1`/`0` rather than filtering rows directly.
262+
222263
### DATE/DATETIME Handling
223264

224265
Redis does not have a native DATE field type. Dates are stored as **NUMERIC fields** with Unix timestamps.

sql_redis/query_builder.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,18 @@ def build_text_condition(
142142
search_value = f'"{escaped}"'
143143
elif re.search(r"\s+[Oo][Rr]\s+", value):
144144
# OR union within text field: split on case-insensitive OR with
145-
# flexible whitespace, escape each term, join with |
146-
or_terms = [
147-
self._escape_fulltext_term(t.strip())
148-
for t in re.split(r"\s+[Oo][Rr]\s+", value)
149-
]
150-
search_value = f"({'|'.join(or_terms)})"
145+
# flexible whitespace, escape each term, join with |.
146+
# Multi-word operands (e.g. "gaming laptop OR tablet") are wrapped
147+
# in parentheses so each side is an atomic subexpression.
148+
or_parts: list[str] = []
149+
for part in re.split(r"\s+[Oo][Rr]\s+", value):
150+
words = part.strip().split()
151+
if len(words) > 1:
152+
escaped = " ".join(self._escape_fulltext_term(w) for w in words)
153+
or_parts.append(f"({escaped})")
154+
else:
155+
or_parts.append(self._escape_fulltext_term(words[0]))
156+
search_value = f"({'|'.join(or_parts)})"
151157
elif " " in value:
152158
# FULLTEXT/MATCH with multi-word: tokenized search with stopword filtering.
153159
# Each term is escaped to prevent accidental operator injection, but a
@@ -182,8 +188,12 @@ def build_text_condition(
182188
terms = " ".join(escaped_words)
183189
search_value = f"({terms})"
184190
else:
185-
# Single-word FULLTEXT — escape to prevent accidental operator injection
186-
search_value = self._escape_fulltext_term(value)
191+
# Single-word FULLTEXT — escape to prevent accidental operator injection.
192+
# Preserve ~ optional-term prefix (same as multi-word branch).
193+
if value.startswith("~"):
194+
search_value = "~" + self._escape_fulltext_term(value[1:])
195+
else:
196+
search_value = self._escape_fulltext_term(value)
187197

188198
# Handle multi-field search — use computed search_value with multi-field syntax
189199
if isinstance(field, list):

tests/test_query_builder.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,3 +656,25 @@ def test_escape_plus_in_fulltext(self):
656656
builder = QueryBuilder()
657657
result = builder.build_text_condition("title", "FULLTEXT", "C++")
658658
assert result == r"@title:C\+\+"
659+
660+
def test_single_term_optional_prefix_preserved(self):
661+
"""Single-term FULLTEXT with ~ prefix preserves optional semantics."""
662+
builder = QueryBuilder()
663+
result = builder.build_text_condition("title", "FULLTEXT", "~gaming")
664+
assert result == "@title:~gaming"
665+
666+
def test_or_multiword_operand_grouped(self):
667+
"""OR with multi-word operand wraps it in parentheses."""
668+
builder = QueryBuilder()
669+
result = builder.build_text_condition(
670+
"title", "FULLTEXT", "gaming laptop OR tablet"
671+
)
672+
assert result == "@title:((gaming laptop)|tablet)"
673+
674+
def test_or_both_multiword_operands_grouped(self):
675+
"""OR with multi-word operands on both sides wraps each."""
676+
builder = QueryBuilder()
677+
result = builder.build_text_condition(
678+
"title", "FULLTEXT", "gaming laptop OR android tablet"
679+
)
680+
assert result == "@title:((gaming laptop)|(android tablet))"

0 commit comments

Comments
 (0)