Skip to content

Commit 78d2533

Browse files
authored
update for sql-redis 0.5.0 (#598)
<!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > Upgrades the optional `sql-redis` dependency, which can change SQL-to-RediSearch translation behavior; new regression tests should catch precedence and DISTINCT-count issues but downstream query semantics may still shift. > > **Overview** > Updates the optional dependency `sql-redis` from `0.4.0` to `0.5.0` (including lockfile updates). > > Adds new integration regression coverage for SQLQuery translation/execution, specifically ensuring mixed `AND`/`OR` WHERE clauses preserve parenthesized boolean precedence and that `COUNT(DISTINCT ...)` maps to RediSearch `COUNT_DISTINCT` (with validation for unsupported DISTINCT forms). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fd2061b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent ba4c869 commit 78d2533

4 files changed

Lines changed: 223 additions & 9 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ pillow = [
5757
"pillow>=11.3.0",
5858
]
5959
sql-redis = [
60-
"sql-redis>=0.4.0",
60+
"sql-redis>=0.5.0",
6161
]
6262
all = [
6363
"mistralai>=1.0.0",
@@ -72,7 +72,7 @@ all = [
7272
"boto3>=1.36.0,<2",
7373
"urllib3<2.2.0",
7474
"pillow>=11.3.0",
75-
"sql-redis>=0.4.0",
75+
"sql-redis>=0.5.0",
7676
]
7777

7878
[project.urls]

tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

tests/integration/test_sql_redis_json.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,144 @@ def test_where_numeric_range(self, sql_index):
325325
assert 25 <= price <= 50
326326

327327

328+
class TestSQLQueryBooleanLogicRegression:
329+
"""Regression tests for SQL boolean operator precedence.
330+
331+
Guards against a sql-redis bug (fixed in 0.5.0) where mixed AND/OR
332+
WHERE clauses with parentheses were collapsed into a single flat
333+
operator, e.g. ``A AND (B OR C)`` translated to ``@a|@b|@c`` and
334+
``A OR (B AND C)`` translated to ``@a @b @c`` — silently changing
335+
query semantics.
336+
"""
337+
338+
def test_and_with_or_group_preserves_precedence(self, sql_index):
339+
"""``A AND (B OR C)`` keeps the OR group parenthesized."""
340+
sql_query = SQLQuery(
341+
f"SELECT * FROM {sql_index.name} "
342+
f"WHERE category = 'electronics' "
343+
f"AND (tags = 'sale' OR tags = 'featured')"
344+
)
345+
cmd = sql_query.redis_query_string(redis_client=sql_index._redis_client)
346+
347+
assert "@category:{electronics}" in cmd
348+
assert "(@tags:{sale}|@tags:{featured})" in cmd
349+
# Bug pattern: all three predicates flattened into one OR group.
350+
assert "@category:{electronics}|@tags:" not in cmd
351+
352+
def test_or_with_and_group_preserves_precedence(self, sql_index):
353+
"""``A OR (B AND C)`` wraps the whole expression and keeps inner AND."""
354+
sql_query = SQLQuery(
355+
f"SELECT * FROM {sql_index.name} "
356+
f"WHERE category = 'electronics' "
357+
f"OR (tags = 'sale' AND price > 100)"
358+
)
359+
cmd = sql_query.redis_query_string(redis_client=sql_index._redis_client)
360+
361+
# The AND group preserves space-separation inside the surrounding OR.
362+
assert "@tags:{sale} @price:[(100 +inf]" in cmd
363+
# The category predicate is OR'd with the AND group.
364+
assert "@category:{electronics}|@tags:{sale}" in cmd
365+
# Bug pattern: tags-AND-price collapsed into the outer OR.
366+
assert "@category:{electronics}|@tags:{sale}|@price:" not in cmd
367+
368+
def test_or_group_first_then_and_preserves_precedence(self, sql_index):
369+
"""``(B OR C) AND A`` keeps the leading OR group parenthesized."""
370+
sql_query = SQLQuery(
371+
f"SELECT * FROM {sql_index.name} "
372+
f"WHERE (tags = 'sale' OR tags = 'featured') "
373+
f"AND category = 'electronics'"
374+
)
375+
cmd = sql_query.redis_query_string(redis_client=sql_index._redis_client)
376+
377+
assert "(@tags:{sale}|@tags:{featured})" in cmd
378+
assert "@category:{electronics}" in cmd
379+
# Bug pattern: leading OR group flattened with the trailing AND.
380+
assert "@tags:{sale}|@tags:{featured}|@category:" not in cmd
381+
382+
def test_chained_ands_with_trailing_or_group(self, sql_index):
383+
"""``A AND B AND C AND (D OR E)`` only parenthesizes the OR group."""
384+
sql_query = SQLQuery(
385+
f"SELECT * FROM {sql_index.name} "
386+
f"WHERE category = 'electronics' "
387+
f"AND price > 100 "
388+
f"AND rating > 4 "
389+
f"AND (tags = 'sale' OR tags = 'featured')"
390+
)
391+
cmd = sql_query.redis_query_string(redis_client=sql_index._redis_client)
392+
393+
assert "@category:{electronics}" in cmd
394+
assert "@price:[(100 +inf]" in cmd
395+
assert "@rating:[(4 +inf]" in cmd
396+
assert "(@tags:{sale}|@tags:{featured})" in cmd
397+
# Bug pattern: all five predicates flattened into a single OR.
398+
assert "@category:{electronics}|" not in cmd
399+
400+
def test_two_or_groups_anded(self, sql_index):
401+
"""``(A OR B) AND (C OR D)`` keeps both OR groups parenthesized."""
402+
sql_query = SQLQuery(
403+
f"SELECT * FROM {sql_index.name} "
404+
f"WHERE (category = 'electronics' OR category = 'books') "
405+
f"AND (tags = 'sale' OR tags = 'featured')"
406+
)
407+
cmd = sql_query.redis_query_string(redis_client=sql_index._redis_client)
408+
409+
assert "(@category:{electronics}|@category:{books})" in cmd
410+
assert "(@tags:{sale}|@tags:{featured})" in cmd
411+
412+
def test_pure_and_chain_unchanged(self, sql_index):
413+
"""``A AND B AND C`` still renders as space-joined without parens."""
414+
sql_query = SQLQuery(
415+
f"SELECT * FROM {sql_index.name} "
416+
f"WHERE category = 'electronics' "
417+
f"AND price > 100 "
418+
f"AND rating > 4"
419+
)
420+
cmd = sql_query.redis_query_string(redis_client=sql_index._redis_client)
421+
422+
assert "@category:{electronics}" in cmd
423+
assert "@price:[(100 +inf]" in cmd
424+
assert "@rating:[(4 +inf]" in cmd
425+
# No pipe operator should appear between predicates of a pure AND chain.
426+
assert "@category:{electronics}|" not in cmd
427+
assert "|@rating:" not in cmd
428+
429+
def test_pure_or_chain_unchanged(self, sql_index):
430+
"""``A OR B OR C`` still renders as a single pipe-joined OR group."""
431+
sql_query = SQLQuery(
432+
f"SELECT * FROM {sql_index.name} "
433+
f"WHERE category = 'electronics' "
434+
f"OR category = 'books' "
435+
f"OR category = 'accessories'"
436+
)
437+
cmd = sql_query.redis_query_string(redis_client=sql_index._redis_client)
438+
439+
assert (
440+
"(@category:{electronics}|@category:{books}|@category:{accessories})" in cmd
441+
)
442+
443+
def test_and_with_or_group_executes_correctly(self, sql_index):
444+
"""End-to-end: results respect ``A AND (B OR C)`` semantics, not the flattened bug.
445+
446+
13 fixture products: filtering by ``category = 'electronics' AND
447+
(tags = 'sale' OR tags = 'featured')`` should return only electronics
448+
rows that also carry one of those two tags. The flattened-bug variant
449+
(``category|tags|tags``) would return rows from non-electronics
450+
categories too.
451+
"""
452+
sql_query = SQLQuery(
453+
f"SELECT title, category, tags FROM {sql_index.name} "
454+
f"WHERE category = 'electronics' "
455+
f"AND (tags = 'sale' OR tags = 'featured')"
456+
)
457+
results = sql_index.query(sql_query)
458+
459+
assert len(results) > 0
460+
for r in results:
461+
assert r["category"] == "electronics"
462+
row_tags = set(r.get("tags", "").split(","))
463+
assert row_tags & {"sale", "featured"}
464+
465+
328466
class TestSQLQueryTagOperators:
329467
"""Tests for SQL tag field operators."""
330468

@@ -938,6 +1076,81 @@ def test_first_value(self, sql_index):
9381076
assert isinstance(result["first_title"], str)
9391077
assert len(result["first_title"]) > 0
9401078

1079+
def test_count_distinct_translates_to_count_distinct_reducer(self, sql_index):
1080+
"""Regression: ``COUNT(DISTINCT col)`` must emit ``COUNT_DISTINCT``.
1081+
1082+
Prior to sql-redis 0.5.0 the ``DISTINCT`` modifier was silently
1083+
dropped and the query degraded to ``COUNT(*)``.
1084+
"""
1085+
sql_query = SQLQuery(
1086+
f"SELECT COUNT(DISTINCT category) AS unique_cats " f"FROM {sql_index.name}"
1087+
)
1088+
cmd = sql_query.redis_query_string(redis_client=sql_index._redis_client)
1089+
1090+
assert cmd.startswith("FT.AGGREGATE")
1091+
assert "COUNT_DISTINCT" in cmd
1092+
assert "@category" in cmd
1093+
assert "unique_cats" in cmd
1094+
1095+
def test_count_distinct_global_returns_unique_count(self, sql_index):
1096+
"""End-to-end: ``COUNT(DISTINCT category)`` returns the true unique count.
1097+
1098+
The fixture has 4 unique categories: electronics, books,
1099+
accessories, stationery. The pre-fix bug would have returned
1100+
13 (the total document count) instead of 4.
1101+
"""
1102+
sql_query = SQLQuery(
1103+
f"SELECT COUNT(DISTINCT category) AS unique_cats " f"FROM {sql_index.name}"
1104+
)
1105+
results = sql_index.query(sql_query)
1106+
1107+
assert len(results) == 1
1108+
assert int(results[0]["unique_cats"]) == 4
1109+
1110+
def test_count_distinct_with_group_by_translates_correctly(self, sql_index):
1111+
"""``COUNT(DISTINCT field)`` with GROUP BY emits COUNT_DISTINCT per group."""
1112+
sql_query = SQLQuery(
1113+
f"SELECT category, COUNT(DISTINCT title) AS unique_titles "
1114+
f"FROM {sql_index.name} GROUP BY category"
1115+
)
1116+
cmd = sql_query.redis_query_string(redis_client=sql_index._redis_client)
1117+
1118+
assert cmd.startswith("FT.AGGREGATE")
1119+
assert "GROUPBY" in cmd
1120+
assert "COUNT_DISTINCT" in cmd
1121+
assert "@title" in cmd
1122+
assert "unique_titles" in cmd
1123+
1124+
def test_count_distinct_equivalent_to_count_distinct_alias(self, sql_index):
1125+
"""``COUNT(DISTINCT x)`` and ``COUNT_DISTINCT(x)`` produce equivalent commands."""
1126+
sql_distinct = SQLQuery(
1127+
f"SELECT category, COUNT(DISTINCT title) AS n "
1128+
f"FROM {sql_index.name} GROUP BY category"
1129+
)
1130+
redis_distinct = SQLQuery(
1131+
f"SELECT category, COUNT_DISTINCT(title) AS n "
1132+
f"FROM {sql_index.name} GROUP BY category"
1133+
)
1134+
1135+
cmd_a = sql_distinct.redis_query_string(redis_client=sql_index._redis_client)
1136+
cmd_b = redis_distinct.redis_query_string(redis_client=sql_index._redis_client)
1137+
1138+
assert cmd_a == cmd_b
1139+
1140+
def test_sum_distinct_raises_value_error(self, sql_index):
1141+
"""``SUM(DISTINCT x)`` is rejected — RediSearch has no SUM_DISTINCT."""
1142+
sql_query = SQLQuery(f"SELECT SUM(DISTINCT price) FROM {sql_index.name}")
1143+
with pytest.raises(ValueError, match="DISTINCT"):
1144+
sql_query.redis_query_string(redis_client=sql_index._redis_client)
1145+
1146+
def test_count_distinct_multi_column_raises_value_error(self, sql_index):
1147+
"""``COUNT(DISTINCT a, b)`` is rejected — multi-column DISTINCT unsupported."""
1148+
sql_query = SQLQuery(
1149+
f"SELECT COUNT(DISTINCT title, category) FROM {sql_index.name}"
1150+
)
1151+
with pytest.raises(ValueError, match="single column"):
1152+
sql_query.redis_query_string(redis_client=sql_index._redis_client)
1153+
9411154

9421155
class TestSQLQueryIntegration:
9431156
"""End-to-end integration tests matching proposal examples."""

uv.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)