Skip to content

Commit 467b416

Browse files
authored
Feat/RAAE-1377/add-is-null-is-not-null-support (#13)
* feat: add IS NULL / IS NOT NULL support via RediSearch ismissing() Translate SQL IS NULL and IS NOT NULL to RediSearch ismissing(@field) and -ismissing(@field) respectively. Requires Redis 7.4+ with the INDEXMISSING attribute declared on the target field. Changes: - parser: handle sqlglot exp.Is nodes, emit IS_NULL / IS_NOT_NULL operators - query_builder: add build_missing_condition() for ismissing() syntax - translator: short-circuit IS_NULL/IS_NOT_NULL before field-type dispatch - translator: make DIALECT 2 the default for all FT.SEARCH and FT.AGGREGATE - executor: catch and re-raise ResponseError with clear version guidance when ismissing() fails (both sync and async paths) - translator: emit UserWarning on IS NULL/IS NOT NULL noting Redis 7.4+ and INDEXMISSING requirements Tests: - 18 integration tests against Redis 8 (TAG, TEXT, NUMERIC field types, combined conditions, edge cases, raw command verification) - Unit tests for parser, query_builder, and translator - Warning and error message verification tests * chore: linter * fix: changes based on pr review - Changed to "ismissing(@" in both sync and async executor - Changed to negated=not negated to support double negation - Parameter test Now asserts on _substitute_params() output deterministically + verifies at least one value executes - Renamed to test_is_not_null_on_indexmissing_field_returns_present_docs with accurate docstring - AsyncExecutor catches broad Exception, Changed to catch redis.ResponseError directly (confirmed it's the same class in both sync/async) * fix: changes based on pr review - Changed to "ismissing(@" in both sync and async executor - Changed to negated=not negated to support double negation - Parameter test Now asserts on _substitute_params() output deterministically + verifies at least one value executes - Renamed to test_is_not_null_on_indexmissing_field_returns_present_docs with accurate docstring - AsyncExecutor catches broad Exception, Changed to catch redis.ResponseError directly (confirmed it's the same class in both sync/async) * fix: pr comments Executor error re-wrap narrowed (sync + async) — now only augments the error when the server message contains known signatures ("Unknown function", "No such function", "Syntax error", "INDEXMISSING"). Unrelated errors (auth, OOM, unknown index) pass through untouched. Parser exp.Is else branch — raises ValueError for unsupported IS variants (e.g., IS TRUE, IS DISTINCT FROM) instead of silently dropping the condition.
1 parent e33c29a commit 467b416

9 files changed

Lines changed: 702 additions & 18 deletions

sql_redis/executor.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,26 @@ def execute(self, sql: str, *, params: dict | None = None) -> QueryResult:
140140
cmd[i] = vector_param
141141

142142
# Execute command
143-
raw_result = self._client.execute_command(*cmd)
143+
try:
144+
raw_result = self._client.execute_command(*cmd)
145+
except redis.ResponseError as e:
146+
error_msg = str(e)
147+
_ismissing_signatures = (
148+
"Unknown function",
149+
"No such function",
150+
"Syntax error",
151+
"INDEXMISSING",
152+
)
153+
if "ismissing(@" in translated.query_string and any(
154+
sig in error_msg for sig in _ismissing_signatures
155+
):
156+
raise redis.ResponseError(
157+
f"{error_msg}. This error may be caused by use of the "
158+
"ismissing() function. ismissing() requires Redis 7.4+ "
159+
"(RediSearch 2.10+) and the field must have INDEXMISSING "
160+
"declared in the schema."
161+
) from e
162+
raise
144163

145164
# Parse result based on command type
146165
count = raw_result[0] if raw_result else 0
@@ -207,7 +226,26 @@ async def execute(self, sql: str, *, params: dict | None = None) -> QueryResult:
207226
cmd[i] = vector_param
208227

209228
# Execute command asynchronously
210-
raw_result = await self._client.execute_command(*cmd)
229+
try:
230+
raw_result = await self._client.execute_command(*cmd)
231+
except redis.ResponseError as e:
232+
error_msg = str(e)
233+
_ismissing_signatures = (
234+
"Unknown function",
235+
"No such function",
236+
"Syntax error",
237+
"INDEXMISSING",
238+
)
239+
if "ismissing(@" in translated.query_string and any(
240+
sig in error_msg for sig in _ismissing_signatures
241+
):
242+
raise redis.ResponseError(
243+
f"{error_msg}. This error may be caused by use of the "
244+
"ismissing() function. ismissing() requires Redis 7.4+ "
245+
"(RediSearch 2.10+) and the field must have INDEXMISSING "
246+
"declared in the schema."
247+
) from e
248+
raise
211249

212250
# Parse result based on command type
213251
count = raw_result[0] if raw_result else 0

sql_redis/parser.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,29 @@ def _process_where_clause(
641641
self._process_where_clause(expression.this, result, negated)
642642
self._process_where_clause(expression.expression, result, negated)
643643
elif isinstance(expression, exp.Not):
644-
self._process_where_clause(expression.this, result, negated=True)
644+
self._process_where_clause(expression.this, result, negated=not negated)
645+
elif isinstance(expression, exp.Paren):
646+
self._process_where_clause(expression.this, result, negated=negated)
647+
elif isinstance(expression, exp.Is):
648+
# IS NULL: exp.Is(this=Column, expression=Null())
649+
# IS NOT NULL arrives here with negated=True via the exp.Not handler above
650+
if isinstance(expression.this, exp.Column) and isinstance(
651+
expression.expression, exp.Null
652+
):
653+
operator = "IS_NOT_NULL" if negated else "IS_NULL"
654+
result.conditions.append(
655+
Condition(
656+
field=expression.this.name,
657+
operator=operator,
658+
value=None,
659+
negated=False,
660+
)
661+
)
662+
else:
663+
raise ValueError(
664+
"Unsupported IS expression in WHERE clause; only "
665+
"`column IS NULL` and `column IS NOT NULL` are supported."
666+
)
645667
elif isinstance(expression, exp.Anonymous):
646668
# Custom function like MATCH(field, value)
647669
self._add_function_condition(expression, result, negated)

sql_redis/query_builder.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,3 +332,17 @@ def build_query_string(
332332
parts.append(self.build_tag_condition(field, operator, value))
333333

334334
return self.combine_conditions(parts, "AND")
335+
336+
def build_missing_condition(self, field: str, *, is_missing: bool) -> str:
337+
"""Build ismissing() query fragment for IS NULL / IS NOT NULL.
338+
339+
Args:
340+
field: Field name (without @ prefix).
341+
is_missing: True for IS NULL (ismissing), False for IS NOT NULL (-ismissing).
342+
343+
Returns:
344+
Query fragment like "ismissing(@field)" or "-ismissing(@field)".
345+
"""
346+
if is_missing:
347+
return f"ismissing(@{field})"
348+
return f"-ismissing(@{field})"

sql_redis/translator.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import warnings
56
from dataclasses import dataclass, field
67

78
from sql_redis.analyzer import AnalyzedQuery, Analyzer
@@ -170,6 +171,18 @@ def _build_query_string(self, analyzed: AnalyzedQuery) -> str:
170171

171172
def _build_condition(self, condition: Condition, field_type: str | None) -> str:
172173
"""Build a single condition string based on field type."""
174+
# Short-circuit for IS NULL / IS NOT NULL → ismissing()
175+
if condition.operator in ("IS_NULL", "IS_NOT_NULL"):
176+
warnings.warn(
177+
f"IS NULL / IS NOT NULL on field '{condition.field}' requires "
178+
"Redis 7.4+ (RediSearch 2.10+) with INDEXMISSING declared on "
179+
"the field. Older versions will return a server error.",
180+
stacklevel=4,
181+
)
182+
return self._query_builder.build_missing_condition(
183+
condition.field, is_missing=(condition.operator == "IS_NULL")
184+
)
185+
173186
# Determine if this is a negation (either explicit or via != operator)
174187
operator = condition.operator
175188
is_negated = condition.negated or operator == "!="
@@ -257,8 +270,6 @@ def _build_search(
257270
# Handle vector search parameters
258271
if analyzed.vector_search:
259272
args.extend(["PARAMS", "2", "vector", "$vector"])
260-
args.append("DIALECT")
261-
args.append("2")
262273
params["vector"] = None # Placeholder for vector bytes
263274

264275
# GEOFILTER clause for geo_distance conditions (only < and <= operators)
@@ -288,6 +299,9 @@ def _build_search(
288299
offset = parsed.offset or 0
289300
args.extend(["LIMIT", str(offset), str(parsed.limit)])
290301

302+
# DIALECT 2 — unconditionally appended as the last arguments
303+
args.extend(["DIALECT", "2"])
304+
291305
return TranslatedQuery(
292306
command="FT.SEARCH",
293307
index=parsed.index,
@@ -496,6 +510,9 @@ def _build_aggregate(
496510
offset = parsed.offset or 0
497511
args.extend(["LIMIT", str(offset), str(parsed.limit)])
498512

513+
# DIALECT 2 — unconditionally appended as the last arguments
514+
args.extend(["DIALECT", "2"])
515+
499516
return TranslatedQuery(
500517
command="FT.AGGREGATE",
501518
index=parsed.index,

0 commit comments

Comments
 (0)