@@ -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+
328466class 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
9421155class TestSQLQueryIntegration :
9431156 """End-to-end integration tests matching proposal examples."""
0 commit comments