22
33from __future__ import annotations
44
5+ import re
56from dataclasses import dataclass
7+ from typing import Any
68
79import 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