Skip to content

Commit a1a74c7

Browse files
committed
replace str.replace() with token-based parameter substitution
- Fix quote escaping bug (single quotes now properly escaped) - Fix partial matching bug (:id no longer matches inside :product_id) - Remove sqlglot dependency, use stdlib re module - Add tests
1 parent e0eacdb commit a1a74c7

2 files changed

Lines changed: 396 additions & 8 deletions

File tree

sql_redis/executor.py

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
from __future__ import annotations
44

5+
import re
56
from dataclasses import dataclass
7+
from typing import Any
68

79
import redis
810

@@ -27,18 +29,88 @@ def __init__(self, client: redis.Redis, schema_registry: SchemaRegistry):
2729
self._schema_registry = schema_registry
2830
self._translator = Translator(schema_registry)
2931

32+
def _substitute_params(self, sql: str, params: dict[str, Any]) -> str:
33+
"""Substitute parameter placeholders in SQL with actual values.
34+
35+
Uses token-based approach: splits SQL on :param patterns, then rebuilds
36+
with substituted values. This approach solves two critical bugs:
37+
38+
1. PARTIAL MATCHING BUG: Prevents :id from matching inside :product_id
39+
by treating each :identifier as a complete token
40+
41+
2. QUOTE ESCAPING BUG: Properly escapes single quotes in string values
42+
using SQL standard (single quote -> double single quote)
43+
44+
Args:
45+
sql: The SQL string with :param placeholders.
46+
params: Dictionary mapping parameter names to values.
47+
48+
Returns:
49+
SQL string with parameters substituted.
50+
51+
Implementation Details:
52+
- Uses regex to split on parameter patterns: :[a-zA-Z_][a-zA-Z0-9_]*
53+
- Keeps delimiters (the :param tokens) in the split result
54+
- Iterates through tokens, substituting matched parameters
55+
- String values are wrapped in single quotes with proper escaping
56+
- Numeric values are converted to strings
57+
- Bytes values (e.g., vectors) are NOT substituted here
58+
59+
Known Limitations:
60+
- Colons in string literals: SQL like "WHERE x = 'test:value'" would
61+
theoretically match :value as a parameter. However, this is not a
62+
practical issue because:
63+
1. Users pass values via parameters, not hardcoded in SQL
64+
2. The translator has its own handling of string literals
65+
3. No real-world use cases have been identified
66+
- Parameter names are case-sensitive (:id != :ID)
67+
- Only handles int, float, str types; other types keep placeholder
68+
"""
69+
if not params:
70+
return sql
71+
72+
# Split SQL on :param patterns, keeping the delimiters
73+
# Pattern matches : followed by valid identifier:
74+
# [a-zA-Z_] - First char must be letter or underscore
75+
# [a-zA-Z0-9_]* - Subsequent chars can be alphanumeric or underscore
76+
# This prevents partial matching: :id and :product_id are separate tokens
77+
tokens = re.split(r"(:[a-zA-Z_][a-zA-Z0-9_]*)", sql)
78+
79+
result = []
80+
for token in tokens:
81+
if token.startswith(":"):
82+
# This is a parameter placeholder
83+
key = token[1:] # Remove leading :
84+
if key in params:
85+
value = params[key]
86+
if isinstance(value, (int, float)):
87+
# Numeric values: convert to string
88+
result.append(str(value))
89+
elif isinstance(value, str):
90+
# String values: wrap in quotes and escape single quotes
91+
# SQL standard: ' -> '' (double single quote)
92+
# This fixes the quote escaping bug
93+
escaped = value.replace("'", "''")
94+
result.append(f"'{escaped}'")
95+
else:
96+
# Other types (bytes, None, bool, list, etc.):
97+
# Keep placeholder as-is (handled elsewhere or unsupported)
98+
result.append(token)
99+
else:
100+
# Parameter not provided: keep placeholder as-is
101+
result.append(token)
102+
else:
103+
# Not a parameter: keep as-is
104+
result.append(token)
105+
106+
return "".join(result)
107+
30108
def execute(self, sql: str, *, params: dict | None = None) -> QueryResult:
31109
"""Execute a SQL query and return results."""
32110
params = params or {}
33111

34-
# Substitute non-bytes params in SQL
35-
for key, value in params.items():
36-
placeholder = f":{key}"
37-
if isinstance(value, (int, float)):
38-
sql = sql.replace(placeholder, str(value))
39-
elif isinstance(value, str):
40-
sql = sql.replace(placeholder, f"'{value}'")
41-
# bytes (vectors) are handled via Redis PARAMS
112+
# Substitute non-bytes params in SQL using token-based approach
113+
sql = self._substitute_params(sql, params)
42114

43115
# Translate SQL to Redis command
44116
translated = self._translator.translate(sql)

0 commit comments

Comments
 (0)