44
55import re
66from dataclasses import dataclass
7- from typing import Any
7+ from typing import TYPE_CHECKING , Any
88
99import redis
1010
11- from sql_redis .schema import SchemaRegistry
11+ from sql_redis .schema import AsyncSchemaRegistry , SchemaRegistry
1212from sql_redis .translator import Translator
1313
14+ if TYPE_CHECKING :
15+ import redis .asyncio as async_redis
16+
17+
18+ def _substitute_params (sql : str , params : dict [str , Any ]) -> str :
19+ """Substitute parameter placeholders in SQL with actual values.
20+
21+ This is a pure function with no I/O operations, shared by both
22+ sync and async executors.
23+
24+ Uses token-based approach: splits SQL on :param patterns, then rebuilds
25+ with substituted values. This approach solves two critical bugs:
26+
27+ 1. PARTIAL MATCHING BUG: Prevents :id from matching inside :product_id
28+ by treating each :identifier as a complete token
29+
30+ 2. QUOTE ESCAPING BUG: Properly escapes single quotes in string values
31+ using SQL standard (single quote -> double single quote)
32+
33+ Args:
34+ sql: The SQL string with :param placeholders.
35+ params: Dictionary mapping parameter names to values.
36+
37+ Returns:
38+ SQL string with parameters substituted.
39+
40+ Implementation Details:
41+ - Uses regex to split on parameter patterns: :[a-zA-Z_][a-zA-Z0-9_]*
42+ - Keeps delimiters (the :param tokens) in the split result
43+ - Iterates through tokens, substituting matched parameters
44+ - String values are wrapped in single quotes with proper escaping
45+ - Numeric values are converted to strings
46+ - Bytes values (e.g., vectors) are NOT substituted here
47+
48+ Known Limitations:
49+ - Colons in string literals: SQL like "WHERE x = 'test:value'" would
50+ theoretically match :value as a parameter. However, this is not a
51+ practical issue because:
52+ 1. Users pass values via parameters, not hardcoded in SQL
53+ 2. The translator has its own handling of string literals
54+ 3. No real-world use cases have been identified
55+ - Parameter names are case-sensitive (:id != :ID)
56+ - Only handles int, float, str types; other types keep placeholder
57+ """
58+ if not params :
59+ return sql
60+
61+ # Split SQL on :param patterns, keeping the delimiters
62+ # Pattern matches : followed by valid identifier:
63+ # [a-zA-Z_] - First char must be letter or underscore
64+ # [a-zA-Z0-9_]* - Subsequent chars can be alphanumeric or underscore
65+ # This prevents partial matching: :id and :product_id are separate tokens
66+ tokens = re .split (r"(:[a-zA-Z_][a-zA-Z0-9_]*)" , sql )
67+
68+ result = []
69+ for token in tokens :
70+ if token .startswith (":" ):
71+ # This is a parameter placeholder
72+ key = token [1 :] # Remove leading :
73+ if key in params :
74+ value = params [key ]
75+ if isinstance (value , (int , float )):
76+ # Numeric values: convert to string
77+ result .append (str (value ))
78+ elif isinstance (value , str ):
79+ # String values: wrap in quotes and escape single quotes
80+ # SQL standard: ' -> '' (double single quote)
81+ # This fixes the quote escaping bug
82+ escaped = value .replace ("'" , "''" )
83+ result .append (f"'{ escaped } '" )
84+ else :
85+ # Other types (bytes, None, bool, list, etc.):
86+ # Keep placeholder as-is (handled elsewhere or unsupported)
87+ result .append (token )
88+ else :
89+ # Parameter not provided: keep placeholder as-is
90+ result .append (token )
91+ else :
92+ # Not a parameter: keep as-is
93+ result .append (token )
94+
95+ return "" .join (result )
96+
1497
1598@dataclass
1699class QueryResult :
@@ -23,94 +106,18 @@ class QueryResult:
23106class Executor :
24107 """Executes SQL queries against Redis."""
25108
26- def __init__ (self , client : redis .Redis , schema_registry : SchemaRegistry ):
109+ def __init__ (self , client : redis .Redis , schema_registry : SchemaRegistry ) -> None :
27110 """Initialize executor with Redis client and schema registry."""
28111 self ._client = client
29112 self ._schema_registry = schema_registry
30113 self ._translator = Translator (schema_registry )
31114
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-
108115 def execute (self , sql : str , * , params : dict | None = None ) -> QueryResult :
109116 """Execute a SQL query and return results."""
110117 params = params or {}
111118
112119 # Substitute non-bytes params in SQL using token-based approach
113- sql = self . _substitute_params (sql , params )
120+ sql = _substitute_params (sql , params )
114121
115122 # Translate SQL to Redis command
116123 translated = self ._translator .translate (sql )
@@ -153,3 +160,69 @@ def execute(self, sql: str, *, params: dict | None = None) -> QueryResult:
153160 rows .append (row )
154161
155162 return QueryResult (rows = rows , count = count )
163+
164+
165+ class AsyncExecutor :
166+ """Async version of Executor for use with redis.asyncio clients."""
167+
168+ def __init__ (
169+ self ,
170+ client : "async_redis.Redis" ,
171+ schema_registry : AsyncSchemaRegistry ,
172+ ) -> None :
173+ """Initialize async executor with Redis client and schema registry.
174+
175+ Args:
176+ client: An async Redis client (redis.asyncio.Redis).
177+ schema_registry: An AsyncSchemaRegistry instance.
178+ """
179+ self ._client = client
180+ self ._schema_registry = schema_registry
181+ self ._translator = Translator (schema_registry )
182+
183+ async def execute (self , sql : str , * , params : dict | None = None ) -> QueryResult :
184+ """Execute a SQL query asynchronously and return results."""
185+ params = params or {}
186+
187+ # Substitute non-bytes params in SQL
188+ sql = _substitute_params (sql , params )
189+
190+ # Translate SQL to Redis command (sync - no Redis calls)
191+ translated = self ._translator .translate (sql )
192+
193+ # Build command list and substitute vector params
194+ cmd : list [str | bytes ] = list (translated .to_command_list ())
195+
196+ # Find any bytes params (vectors) to substitute
197+ vector_param : bytes | None = None
198+ for value in params .values ():
199+ if isinstance (value , bytes ):
200+ vector_param = value
201+ break
202+
203+ # Replace $vector placeholder with actual bytes
204+ if vector_param :
205+ for i , arg in enumerate (cmd ):
206+ if arg == "$vector" :
207+ cmd [i ] = vector_param
208+
209+ # Execute command asynchronously
210+ raw_result = await self ._client .execute_command (* cmd )
211+
212+ # Parse result based on command type
213+ count = raw_result [0 ] if raw_result else 0
214+ rows = []
215+
216+ if translated .command == "FT.SEARCH" :
217+ # FT.SEARCH format: [count, key1, [fields1], key2, [fields2], ...]
218+ for i in range (2 , len (raw_result ), 2 ):
219+ row_data = raw_result [i ]
220+ row = dict (zip (row_data [::2 ], row_data [1 ::2 ]))
221+ rows .append (row )
222+ else :
223+ # FT.AGGREGATE format: [count, [fields1], [fields2], ...]
224+ for row_data in raw_result [1 :]:
225+ row = dict (zip (row_data [::2 ], row_data [1 ::2 ]))
226+ rows .append (row )
227+
228+ return QueryResult (rows = rows , count = count )
0 commit comments