Skip to content

Commit dc83e74

Browse files
authored
Merge pull request #9 from redis-developer/feature/async-executor
Feature/async executor
2 parents 733bef6 + ca5e2fa commit dc83e74

7 files changed

Lines changed: 626 additions & 127 deletions

File tree

sql_redis/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
"""SQL to Redis command translation utility."""
22

3+
from sql_redis.executor import AsyncExecutor, Executor, QueryResult
4+
from sql_redis.schema import AsyncSchemaRegistry, SchemaRegistry
35
from sql_redis.translator import TranslatedQuery, Translator
46
from sql_redis.version import __version__
57

6-
__all__ = ["Translator", "TranslatedQuery", "__version__"]
8+
__all__ = [
9+
"Translator",
10+
"TranslatedQuery",
11+
"SchemaRegistry",
12+
"AsyncSchemaRegistry",
13+
"Executor",
14+
"AsyncExecutor",
15+
"QueryResult",
16+
"__version__",
17+
]

sql_redis/executor.py

Lines changed: 153 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,96 @@
44

55
import re
66
from dataclasses import dataclass
7-
from typing import Any
7+
from typing import TYPE_CHECKING, Any
88

99
import redis
1010

11-
from sql_redis.schema import SchemaRegistry
11+
from sql_redis.schema import AsyncSchemaRegistry, SchemaRegistry
1212
from 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
1699
class QueryResult:
@@ -23,94 +106,18 @@ class QueryResult:
23106
class 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

Comments
 (0)