Skip to content

Commit 4fd66d9

Browse files
committed
fix: score alias collision for SELECT *, reject float fuzzy levels
- When no RETURN clause exists (SELECT *), use first row's field names to detect score alias collisions instead of skipping the check - Reject non-integer fuzzy levels (e.g. 2.9) with ValueError, matching the slop validation behavior - Add test for fuzzy float rejection (351 total)
1 parent 2e27246 commit 4fd66d9

3 files changed

Lines changed: 51 additions & 18 deletions

File tree

sql_redis/executor.py

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,28 @@ def _has_return_0(args: list[str]) -> bool:
116116
return False
117117

118118
@staticmethod
119-
def _resolve_score_alias(score_alias: str | None, args: list[str]) -> str:
119+
def _resolve_score_alias(
120+
score_alias: str | None,
121+
args: list[str],
122+
first_row_fields: set[str] | None = None,
123+
) -> str:
120124
"""Determine a stable score column name that won't collide with
121125
document fields. The alias is decided once before iterating rows
122-
so every row uses the same column name."""
126+
so every row uses the same column name.
127+
128+
When a RETURN clause is present, the returned field names are used
129+
for collision detection. When RETURN is absent (SELECT *), the
130+
caller should pass ``first_row_fields`` — the field names from the
131+
first result row — so we can detect collisions even when all
132+
document attributes are returned."""
123133
alias = score_alias or "__score"
124134
# Extract RETURN field names from args to detect collision
125135
try:
126136
idx = args.index("RETURN")
127137
count = int(args[idx + 1])
128138
return_fields = set(args[idx + 2 : idx + 2 + count])
129139
except (ValueError, IndexError):
130-
return_fields = set()
140+
return_fields = first_row_fields or set()
131141
if alias in return_fields:
132142
alias = f"__score_{alias}"
133143
return alias
@@ -201,28 +211,35 @@ def execute(self, sql: str, *, params: dict | None = None) -> QueryResult:
201211
# RETURN 0 suppresses document fields (like NOCONTENT);
202212
# with WITHSCORES the reply is [count, id, score, id, score, ...]
203213
no_content = self._has_return_0(translated.args)
204-
# Determine score column name once so every row is consistent
205-
alias = (
206-
self._resolve_score_alias(translated.score_alias, translated.args)
207-
if with_scores
208-
else ""
209-
)
214+
215+
# Pre-resolve score alias; may be deferred for SELECT *
216+
score_alias: str | None = None
210217

211218
if with_scores and no_content:
212219
# WITHSCORES + RETURN 0: [count, id1, score1, id2, score2, ...]
213220
# Stride of 2: key, score (no field array)
221+
score_alias = self._resolve_score_alias(
222+
translated.score_alias, translated.args
223+
)
214224
for i in range(1, len(raw_result) - 1, 2):
215225
score = raw_result[i + 1]
216-
row = {alias: score}
226+
row = {score_alias: score}
217227
rows.append(row)
218228
elif with_scores:
219229
# WITHSCORES format: [count, key1, score1, [fields1], key2, score2, [fields2], ...]
220230
# Stride of 3: key, score, field_list
231+
# Resolve alias using first row's fields for SELECT * (no RETURN)
221232
for i in range(1, len(raw_result) - 2, 3):
222233
score = raw_result[i + 1]
223234
row_data = raw_result[i + 2]
224235
row = dict(zip(row_data[::2], row_data[1::2]))
225-
row[alias] = score
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
226243
rows.append(row)
227244
else:
228245
# Standard format: [count, key1, [fields1], key2, [fields2], ...]
@@ -312,25 +329,31 @@ async def execute(self, sql: str, *, params: dict | None = None) -> QueryResult:
312329
if translated.command == "FT.SEARCH":
313330
with_scores = "WITHSCORES" in translated.args
314331
no_content = self._has_return_0(translated.args)
315-
alias = (
316-
self._resolve_score_alias(translated.score_alias, translated.args)
317-
if with_scores
318-
else ""
319-
)
332+
333+
score_alias: str | None = None
320334

321335
if with_scores and no_content:
322336
# WITHSCORES + RETURN 0: [count, id1, score1, id2, score2, ...]
337+
score_alias = self._resolve_score_alias(
338+
translated.score_alias, translated.args
339+
)
323340
for i in range(1, len(raw_result) - 1, 2):
324341
score = raw_result[i + 1]
325-
row = {alias: score}
342+
row = {score_alias: score}
326343
rows.append(row)
327344
elif with_scores:
328345
# WITHSCORES format: [count, key1, score1, [fields1], ...]
329346
for i in range(1, len(raw_result) - 2, 3):
330347
score = raw_result[i + 1]
331348
row_data = raw_result[i + 2]
332349
row = dict(zip(row_data[::2], row_data[1::2]))
333-
row[alias] = score
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
334357
rows.append(row)
335358
else:
336359
# Standard format: [count, key1, [fields1], key2, [fields2], ...]

sql_redis/parser.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,10 @@ def _add_function_condition(
10201020
if len(args) >= 3:
10211021
level_val = self._extract_literal_value(args[2])
10221022
if level_val is not None:
1023+
if isinstance(level_val, float) and level_val != int(level_val):
1024+
raise ValueError(
1025+
f"FUZZY level argument must be an integer (got {level_val})"
1026+
)
10231027
fuzzy_level = int(level_val)
10241028

10251029
if field_name is not None:

tests/test_sql_parser.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,3 +871,9 @@ def test_fulltext_float_slop_raises(self):
871871
parser = SQLParser()
872872
with pytest.raises(ValueError, match="must be an integer"):
873873
parser.parse("SELECT * FROM idx WHERE fulltext(title, 'hello world', 2.9)")
874+
875+
def test_fuzzy_float_level_raises(self):
876+
"""Float fuzzy level raises ValueError instead of silently truncating."""
877+
parser = SQLParser()
878+
with pytest.raises(ValueError, match="must be an integer"):
879+
parser.parse("SELECT * FROM idx WHERE fuzzy(title, 'laptap', 2.9)")

0 commit comments

Comments
 (0)