Skip to content

Commit 14a6200

Browse files
committed
fix: 4 review issues — case-sensitive OR, score ORDER BY, per-row alias collision, extra arg validation
P1 fixes: - Lowercase 'or' no longer parsed as boolean OR in FULLTEXT; only uppercase 'OR' triggers union semantics ('bank or america' stays as AND search) - ORDER BY score() alias DESC omits invalid SORTBY (RediSearch sorts by relevance by default); ORDER BY score ASC raises ValueError P2 fixes: - Score alias collision detection now checks per-row instead of first-row-only, preventing field overwrite when later rows have different field sets - fulltext() rejects >4 args, fuzzy() rejects >3 args (was silently ignoring) - Applied to both sync and async executor paths Add 8 new tests (384 total)
1 parent 29d971e commit 14a6200

7 files changed

Lines changed: 111 additions & 26 deletions

File tree

sql_redis/executor.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -228,18 +228,20 @@ def execute(self, sql: str, *, params: dict | None = None) -> QueryResult:
228228
elif with_scores:
229229
# WITHSCORES format: [count, key1, score1, [fields1], key2, score2, [fields2], ...]
230230
# Stride of 3: key, score, field_list
231-
# Resolve alias using first row's fields for SELECT * (no RETURN)
231+
# Resolve alias per-row: FT.SEARCH may omit missing attributes,
232+
# so different rows can have different field sets. We must
233+
# check each row individually to avoid overwriting a real field
234+
# with the score value.
232235
for i in range(1, len(raw_result) - 2, 3):
233236
score = raw_result[i + 1]
234237
row_data = raw_result[i + 2]
235238
row = dict(zip(row_data[::2], row_data[1::2]))
236-
if score_alias is None:
237-
score_alias = self._resolve_score_alias(
238-
translated.score_alias,
239-
translated.args,
240-
first_row_fields=set(row.keys()),
241-
)
242-
row[score_alias] = score
239+
row_score_alias = self._resolve_score_alias(
240+
translated.score_alias,
241+
translated.args,
242+
first_row_fields=set(row.keys()),
243+
)
244+
row[row_score_alias] = score
243245
rows.append(row)
244246
else:
245247
# Standard format: [count, key1, [fields1], key2, [fields2], ...]
@@ -343,17 +345,17 @@ async def execute(self, sql: str, *, params: dict | None = None) -> QueryResult:
343345
rows.append(row)
344346
elif with_scores:
345347
# WITHSCORES format: [count, key1, score1, [fields1], ...]
348+
# Resolve alias per-row to avoid field collision on later rows.
346349
for i in range(1, len(raw_result) - 2, 3):
347350
score = raw_result[i + 1]
348351
row_data = raw_result[i + 2]
349352
row = dict(zip(row_data[::2], row_data[1::2]))
350-
if score_alias is None:
351-
score_alias = self._resolve_score_alias(
352-
translated.score_alias,
353-
translated.args,
354-
first_row_fields=set(row.keys()),
355-
)
356-
row[score_alias] = score
353+
row_score_alias = self._resolve_score_alias(
354+
translated.score_alias,
355+
translated.args,
356+
first_row_fields=set(row.keys()),
357+
)
358+
row[row_score_alias] = score
357359
rows.append(row)
358360
else:
359361
# Standard format: [count, key1, [fields1], key2, [fields2], ...]

sql_redis/parser.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,16 @@ def _add_function_condition(
986986
f"{func_name.lower()}(field, value), got {len(args)}."
987987
)
988988

989+
# Validate max argument counts to catch typos / misuse early.
990+
# fulltext(field, value [, slop [, inorder]]) → max 4
991+
# fuzzy(field, value [, level]) → max 3
992+
_max_args = {"FULLTEXT": 4, "FUZZY": 3}
993+
if func_name in _max_args and len(args) > _max_args[func_name]:
994+
raise ValueError(
995+
f"{func_name.lower()}() accepts at most {_max_args[func_name]} "
996+
f"arguments, got {len(args)}."
997+
)
998+
989999
if func_name == "FULLTEXT" and len(args) >= 2:
9901000
field_name = args[0].name if isinstance(args[0], exp.Column) else None
9911001
value = self._extract_literal_value(args[1])

sql_redis/query_builder.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,16 @@ def build_text_condition(
146146
# Exact phrase match — always wrap in quotes, preserve stopwords.
147147
escaped = self._escape_text_value(value)
148148
search_value = f'"{escaped}"'
149-
elif re.search(r"\s+[Oo][Rr]\s+", value):
150-
# OR union within text field: split on case-insensitive OR with
149+
elif re.search(r"\s+OR\s+", value):
150+
# OR union within text field: split on uppercase-only OR with
151151
# flexible whitespace, escape each term, join with |.
152+
# Only uppercase OR is treated as a boolean operator; lowercase
153+
# "or" is treated as a regular search term (e.g. "bank or america"
154+
# stays as a multi-word AND search, not bank|america).
152155
# Multi-word operands (e.g. "gaming laptop OR tablet") are wrapped
153156
# in parentheses so each side is an atomic subexpression.
154157
or_parts: list[str] = []
155-
for part in re.split(r"\s+[Oo][Rr]\s+", value):
158+
for part in re.split(r"\s+OR\s+", value):
156159
words = part.strip().split()
157160
if not words:
158161
raise ValueError(

sql_redis/translator.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,10 +328,25 @@ def _build_search(
328328
args.append(str(len(return_fields)))
329329
args.extend(return_fields)
330330

331-
# SORTBY
331+
# SORTBY — skip if the ORDER BY field is a score() alias, because
332+
# WITHSCORES already returns results in relevance order and the alias
333+
# is not a sortable indexed field.
334+
score_alias_name = parsed.scoring.alias if parsed.scoring else None
332335
if parsed.orderby_fields:
333336
field_name, direction = parsed.orderby_fields[0]
334-
args.extend(["SORTBY", field_name, direction])
337+
if field_name == score_alias_name:
338+
# score() alias — not a real field; RediSearch sorts by
339+
# relevance by default when no SORTBY is specified.
340+
if direction == "ASC":
341+
raise ValueError(
342+
f"ORDER BY {field_name} ASC is not supported: "
343+
"RediSearch returns results in descending relevance "
344+
"order by default and does not support ascending "
345+
"score sorting via FT.SEARCH."
346+
)
347+
# DESC is the default — omit SORTBY entirely
348+
else:
349+
args.extend(["SORTBY", field_name, direction])
335350

336351
# LIMIT
337352
if parsed.limit is not None:

tests/test_query_builder.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -631,17 +631,27 @@ def test_multi_field_fuzzy(self):
631631
)
632632
assert result == "(@title|description:%%laptap%%)"
633633

634-
def test_or_case_insensitive_lowercase(self):
635-
"""OR parsing is case-insensitive: 'laptop or tablet' works."""
634+
def test_lowercase_or_is_not_boolean(self):
635+
"""Lowercase 'or' is treated as a regular search term, not a boolean operator.
636+
637+
'bank or america' should NOT become bank|america — it should be a
638+
multi-word AND-style search with stopword filtering applied to 'or'.
639+
"""
636640
builder = QueryBuilder()
637641
result = builder.build_text_condition("title", "FULLTEXT", "laptop or tablet")
638-
assert result == "@title:(laptop|tablet)"
642+
# "or" is a stopword; remaining terms are "laptop" and "tablet"
643+
assert result == "@title:(laptop tablet)"
639644

640-
def test_or_case_insensitive_mixed(self):
641-
"""OR parsing is case-insensitive: 'laptop Or tablet' works."""
645+
def test_mixed_case_or_is_not_boolean(self):
646+
"""Mixed case 'Or' / 'oR' is treated as a regular term, not boolean OR.
647+
648+
Only uppercase 'OR' triggers the union operator.
649+
'Or' is a stopword (or → stopword list), so it gets removed.
650+
"""
642651
builder = QueryBuilder()
643652
result = builder.build_text_condition("title", "FULLTEXT", "laptop Or tablet")
644-
assert result == "@title:(laptop|tablet)"
653+
# "Or" lowercases to "or" which is a stopword; remaining: laptop tablet
654+
assert result == "@title:(laptop tablet)"
645655

646656
def test_or_extra_whitespace(self):
647657
"""OR parsing tolerates extra whitespace."""

tests/test_sql_parser.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,3 +907,17 @@ def test_fuzzy_no_value_raises(self):
907907
parser = SQLParser()
908908
with pytest.raises(ValueError, match="requires at least 2 arguments"):
909909
parser.parse("SELECT * FROM idx WHERE fuzzy(title)")
910+
911+
def test_fulltext_too_many_args_raises(self):
912+
"""fulltext() with more than 4 arguments raises ValueError."""
913+
parser = SQLParser()
914+
with pytest.raises(ValueError, match="at most 4 arguments"):
915+
parser.parse(
916+
"SELECT * FROM idx WHERE fulltext(title, 'hello world', 2, true, 'extra')"
917+
)
918+
919+
def test_fuzzy_too_many_args_raises(self):
920+
"""fuzzy() with more than 3 arguments raises ValueError."""
921+
parser = SQLParser()
922+
with pytest.raises(ValueError, match="at most 3 arguments"):
923+
parser.parse("SELECT * FROM idx WHERE fuzzy(title, 'laptap', 2, 'extra')")

tests/test_translator.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,3 +840,34 @@ def test_score_too_many_args_raises(self, translator: Translator, basic_index: s
840840
f"SELECT score('BM25', 'extra') AS relevance FROM {basic_index} "
841841
"WHERE fulltext(title, 'laptop')"
842842
)
843+
844+
def test_order_by_score_desc_omits_sortby(
845+
self, translator: Translator, basic_index: str
846+
):
847+
"""ORDER BY score_alias DESC omits SORTBY (RediSearch sorts by relevance by default)."""
848+
result = translator.translate(
849+
f"SELECT title, score() AS relevance FROM {basic_index} "
850+
"WHERE fulltext(title, 'laptop') ORDER BY relevance DESC"
851+
)
852+
assert "WITHSCORES" in result.args
853+
assert "SORTBY" not in result.args
854+
855+
def test_order_by_score_asc_raises(self, translator: Translator, basic_index: str):
856+
"""ORDER BY score_alias ASC raises ValueError (not supported by RediSearch)."""
857+
with pytest.raises(ValueError, match="ASC is not supported"):
858+
translator.translate(
859+
f"SELECT title, score() AS relevance FROM {basic_index} "
860+
"WHERE fulltext(title, 'laptop') ORDER BY relevance ASC"
861+
)
862+
863+
def test_order_by_real_field_with_score_still_works(
864+
self, translator: Translator, basic_index: str
865+
):
866+
"""ORDER BY a real field (not score alias) still emits SORTBY."""
867+
result = translator.translate(
868+
f"SELECT title, score() AS relevance FROM {basic_index} "
869+
"WHERE fulltext(title, 'laptop') ORDER BY price DESC"
870+
)
871+
assert "SORTBY" in result.args
872+
idx = result.args.index("SORTBY")
873+
assert result.args[idx + 1] == "price"

0 commit comments

Comments
 (0)