Skip to content

Commit 795c970

Browse files
committed
fix: scorer case preservation, duplicate score guard, OR ~ prefix, stopword warning
- Preserve caller-provided scorer casing (score('MyScorer') no longer uppercased to MYSCORER) - Raise ValueError when multiple score() expressions in same query - Preserve ~ optional-term prefix in OR operands - Fix misleading stopword warning when all tokens are stopwords - Remove redundant inner pytest import in test_translator.py - Add 3 new tests (373 total)
1 parent 8ccbf4f commit 795c970

4 files changed

Lines changed: 49 additions & 7 deletions

File tree

sql_redis/parser.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,11 @@ def _process_select_expression_inner(
462462
if expression.expressions:
463463
scorer_val = self._extract_literal_value(expression.expressions[0])
464464
if scorer_val is not None:
465-
scorer = str(scorer_val).upper()
465+
scorer = str(scorer_val)
466+
if result.scoring is not None:
467+
raise ValueError(
468+
"Only one score() expression is allowed per query."
469+
)
466470
result.scoring = ScoringSpec(
467471
alias=alias or "score",
468472
scorer=scorer,

sql_redis/query_builder.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,21 @@ def build_text_condition(
154154
"must contain at least one search term."
155155
)
156156
if len(words) > 1:
157-
escaped = " ".join(self._escape_fulltext_term(w) for w in words)
158-
or_parts.append(f"({escaped})")
157+
escaped_tokens = []
158+
for w in words:
159+
if w.startswith("~"):
160+
escaped_tokens.append(
161+
"~" + self._escape_fulltext_term(w[1:])
162+
)
163+
else:
164+
escaped_tokens.append(self._escape_fulltext_term(w))
165+
or_parts.append(f"({' '.join(escaped_tokens)})")
159166
else:
160-
or_parts.append(self._escape_fulltext_term(words[0]))
167+
token = words[0]
168+
if token.startswith("~"):
169+
or_parts.append("~" + self._escape_fulltext_term(token[1:]))
170+
else:
171+
or_parts.append(self._escape_fulltext_term(token))
161172
search_value = f"({'|'.join(or_parts)})"
162173
elif " " in value:
163174
# FULLTEXT/MATCH with multi-word: tokenized search with stopword filtering.
@@ -173,8 +184,12 @@ def build_text_condition(
173184
]
174185

175186
if removed_stopwords:
187+
if filtered_words:
188+
sw_action = f"Stopwords {removed_stopwords} were removed from"
189+
else:
190+
sw_action = f"All tokens in '{value}' are stopwords and may not be indexed by"
176191
warnings.warn(
177-
f"Stopwords {removed_stopwords} were removed from text search '{value}'. "
192+
f"{sw_action} text search '{value}'. "
178193
"By default, Redis does not index stopwords. "
179194
"To include stopwords in your index, create it with STOPWORDS 0. "
180195
"Use = operator for exact phrase matching that preserves stopwords.",

tests/test_query_builder.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,3 +690,9 @@ def test_or_leading_empty_operand_raises(self):
690690
builder = QueryBuilder()
691691
with pytest.raises(ValueError, match="Empty operand in OR expression"):
692692
builder.build_text_condition("title", "FULLTEXT", " OR tablet")
693+
694+
def test_or_preserves_optional_prefix(self):
695+
"""OR operand with ~ prefix preserves optional-term semantics."""
696+
builder = QueryBuilder()
697+
result = builder.build_text_condition("title", "FULLTEXT", "laptop OR ~gaming")
698+
assert result == "@title:(laptop|~gaming)"

tests/test_translator.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,25 @@ def test_score_custom_scorer(self, translator: Translator, basic_index: str):
785785
scorer_idx = result.args.index("SCORER")
786786
assert result.args[scorer_idx + 1] == "TFIDF"
787787

788+
def test_score_custom_scorer_preserves_case(
789+
self, translator: Translator, basic_index: str
790+
):
791+
"""score('MyScorer') preserves caller-provided casing."""
792+
result = translator.translate(
793+
f"SELECT title, score('MyScorer') AS relevance FROM {basic_index} "
794+
"WHERE fulltext(title, 'laptop')"
795+
)
796+
scorer_idx = result.args.index("SCORER")
797+
assert result.args[scorer_idx + 1] == "MyScorer"
798+
799+
def test_duplicate_score_raises(self, translator: Translator, basic_index: str):
800+
"""Multiple score() expressions in the same query raise ValueError."""
801+
with pytest.raises(ValueError, match="Only one score"):
802+
translator.translate(
803+
f"SELECT score() AS s1, score('TFIDF') AS s2 FROM {basic_index} "
804+
"WHERE fulltext(title, 'laptop')"
805+
)
806+
788807
def test_no_score_no_withscores(self, translator: Translator, basic_index: str):
789808
"""Without score() → no WITHSCORES flag."""
790809
result = translator.translate(
@@ -808,8 +827,6 @@ def test_score_with_aggregate_raises(
808827
self, translator: Translator, basic_index: str
809828
):
810829
"""score() combined with GROUP BY (forces FT.AGGREGATE) raises ValueError."""
811-
import pytest
812-
813830
with pytest.raises(ValueError, match="score.*not supported.*FT.AGGREGATE"):
814831
translator.translate(
815832
f"SELECT COUNT(*), score() AS relevance FROM {basic_index} "

0 commit comments

Comments
 (0)