Skip to content

Commit 811b085

Browse files
committed
Align date branch patterns with geo branch
- Add @ prefix to LOAD fields in FT.AGGREGATE (bug fix) - Extract duplicate date mappings into module-level constants: - SQLGLOT_TO_SQL_DATE_FUNCTIONS (string-based) - SQLGLOT_DATE_EXPR_TYPES (type-based) - Add validation to reject negated date function conditions - Add test for negated date function error - Fix test expecting @category in LOAD args - Remove duplicate test_parse_select_without_from - Clean up unused variables in _build_command
1 parent 71d2acd commit 811b085

12 files changed

Lines changed: 489 additions & 61 deletions

.ipynb_checkpoints/geo_examples_consolidated-checkpoint.ipynb

Lines changed: 427 additions & 0 deletions
Large diffs are not rendered by default.

sql_redis/parser.py

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,30 @@ class DateFunctionSpec:
7272
"DATE_FORMAT": "timefmt",
7373
}
7474

75+
# Mapping from sqlglot expression type names to SQL function names
76+
SQLGLOT_TO_SQL_DATE_FUNCTIONS = {
77+
"Year": "YEAR",
78+
"Month": "MONTH",
79+
"Day": "DAY",
80+
"DayOfWeek": "DAYOFWEEK",
81+
"DayOfYear": "DAYOFYEAR",
82+
"DayOfMonth": "DAY", # DAY and DayOfMonth are equivalent
83+
"Hour": "HOUR",
84+
"Minute": "MINUTE",
85+
}
86+
87+
# Mapping from sqlglot expression types to SQL function names (for type checking)
88+
SQLGLOT_DATE_EXPR_TYPES = {
89+
exp.Year: "YEAR",
90+
exp.Month: "MONTH",
91+
exp.Day: "DAY",
92+
exp.DayOfWeek: "DAYOFWEEK",
93+
exp.DayOfYear: "DAYOFYEAR",
94+
exp.DayOfMonth: "DAY",
95+
exp.Hour: "HOUR",
96+
exp.Minute: "MINUTE",
97+
}
98+
7599

76100
@dataclass
77101
class VectorSearchSpec:
@@ -413,20 +437,8 @@ def _process_date_expression(
413437
result: The ParsedQuery to update.
414438
alias: Optional alias for the result.
415439
"""
416-
# Map sqlglot expression types to SQL function names
417-
sqlglot_to_sql = {
418-
"Year": "YEAR",
419-
"Month": "MONTH",
420-
"Day": "DAY",
421-
"DayOfWeek": "DAYOFWEEK",
422-
"DayOfYear": "DAYOFYEAR",
423-
"DayOfMonth": "DAY", # DAY and DayOfMonth are equivalent
424-
"Hour": "HOUR",
425-
"Minute": "MINUTE",
426-
}
427-
428440
expr_type = type(expression).__name__
429-
func_name = sqlglot_to_sql.get(expr_type)
441+
func_name = SQLGLOT_TO_SQL_DATE_FUNCTIONS.get(expr_type)
430442

431443
if func_name and expression.this:
432444
field_name = None
@@ -488,18 +500,6 @@ def _add_condition(
488500
field_name = None
489501
value = None
490502

491-
# Map sqlglot date expression types to function names
492-
date_expr_types = {
493-
exp.Year: "YEAR",
494-
exp.Month: "MONTH",
495-
exp.Day: "DAY",
496-
exp.DayOfWeek: "DAYOFWEEK",
497-
exp.DayOfYear: "DAYOFYEAR",
498-
exp.DayOfMonth: "DAY",
499-
exp.Hour: "HOUR",
500-
exp.Minute: "MINUTE",
501-
}
502-
503503
# Get field name from left side
504504
if isinstance(expression.this, exp.Column):
505505
field_name = expression.this.name
@@ -512,9 +512,9 @@ def _add_condition(
512512
field_name = first_arg.name
513513
# Use function name as operator prefix
514514
operator = f"{func_name}_{operator}"
515-
elif type(expression.this) in date_expr_types:
515+
elif type(expression.this) in SQLGLOT_DATE_EXPR_TYPES:
516516
# Built-in date expression like YEAR(field), MONTH(field), etc.
517-
func_name = date_expr_types[type(expression.this)]
517+
func_name = SQLGLOT_DATE_EXPR_TYPES[type(expression.this)]
518518
if expression.this.this and isinstance(expression.this.this, exp.Column):
519519
field_name = expression.this.this.name
520520
# Use function name as operator prefix

sql_redis/translator.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from sql_redis.parser import (
99
SQL_TO_REDIS_DATE_FUNCTIONS,
1010
Condition,
11-
ParsedQuery,
1211
SQLParser,
1312
)
1413
from sql_redis.query_builder import QueryBuilder
@@ -95,10 +94,6 @@ def _build_command(self, analyzed: AnalyzedQuery) -> TranslatedQuery:
9594
# Build query string from conditions
9695
query_string = self._build_query_string(analyzed)
9796

98-
# Build arguments
99-
args: list[str] = []
100-
params: dict[str, object] = {}
101-
10297
if use_aggregate:
10398
return self._build_aggregate(analyzed, query_string)
10499
else:
@@ -260,7 +255,11 @@ def _build_aggregate(
260255
if load_fields:
261256
args.append("LOAD")
262257
args.append(str(len(load_fields)))
263-
args.extend(sorted(load_fields))
258+
# Redis expects property names prefixed with '@' in LOAD
259+
args.extend(
260+
f"@{field}" if not field.startswith("@") else field
261+
for field in sorted(load_fields)
262+
)
264263

265264
# APPLY for computed fields
266265
for computed in analyzed.computed_fields:
@@ -288,6 +287,15 @@ def _build_aggregate(
288287
date_func_conditions = [
289288
c for c in parsed.conditions if self._is_date_function_condition(c)
290289
]
290+
291+
# Validate: negated date function conditions are not supported
292+
for condition in date_func_conditions:
293+
if condition.negated:
294+
raise ValueError(
295+
"Negated date function conditions (NOT YEAR(...), etc.) "
296+
"are not supported"
297+
)
298+
291299
# Track which date functions we've already computed (avoid duplicates)
292300
computed_date_funcs = {
293301
(df.function, df.field) for df in analyzed.date_functions

tests/conftest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ def products_data(redis_client: redis.Redis, products_index: str):
276276
]
277277

278278
for i, product in enumerate(products):
279-
key = f"product:{i+1}"
279+
key = f"product:{i + 1}"
280280
redis_client.hset(key, mapping=product)
281281

282282
return products_index
@@ -300,7 +300,7 @@ def vec_data(redis_client: redis.Redis, vec_index: str):
300300
binary_client = redis.Redis(host=host, port=port, decode_responses=False)
301301

302302
for i, vec in enumerate(vectors):
303-
key = f"vec:{i+1}"
303+
key = f"vec:{i + 1}"
304304
binary_client.hset(key, mapping=vec)
305305

306306
binary_client.close()
@@ -349,7 +349,7 @@ def items_data(redis_client: redis.Redis, items_index: str):
349349
]
350350

351351
for i, item in enumerate(items):
352-
key = f"item:{i+1}"
352+
key = f"item:{i + 1}"
353353
binary_client.hset(key, mapping=item)
354354

355355
binary_client.close()

tests/test_analyzer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from sql_redis.analyzer import AnalyzedQuery, Analyzer
5+
from sql_redis.analyzer import Analyzer
66
from sql_redis.parser import SQLParser
77

88

tests/test_date_fields.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
"""Tests for DATE/DATETIME literal parsing and conversion."""
22

3-
from datetime import datetime, timezone
4-
53
import pytest
64

75
from sql_redis.parser import SQLParser

tests/test_date_functions.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from sql_redis.parser import DateFunctionSpec, SQLParser
5+
from sql_redis.parser import SQLParser
66

77

88
class TestDateFunctionParsing:
@@ -204,3 +204,9 @@ def test_group_by_year_month(self, date_translator, date_index):
204204
assert "GROUPBY" in result.args
205205
assert "REDUCE" in result.args
206206
assert "COUNT" in result.args
207+
208+
def test_negated_date_function_raises_error(self, date_translator, date_index):
209+
"""NOT YEAR(...) should raise a clear error."""
210+
sql = f"SELECT * FROM {date_index} WHERE NOT YEAR(created_at) = 2024"
211+
with pytest.raises(ValueError, match="Negated date function"):
212+
date_translator.translate(sql)

tests/test_executor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import redis
77
from testcontainers.redis import RedisContainer
88

9-
from sql_redis.executor import Executor, QueryResult
9+
from sql_redis.executor import Executor
1010
from sql_redis.schema import SchemaRegistry
1111

1212

tests/test_redis_queries.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import struct
44

5-
import pytest
65
import redis
76

87

@@ -279,9 +278,9 @@ def test_between_and_greater_than(
279278
row_dict = dict(zip(row[::2], row[1::2]))
280279
if "price" in row_dict:
281280
price = float(row_dict["price"])
282-
assert (
283-
100 <= price <= 500
284-
), f"Price {price} should be between 100 and 500"
281+
assert 100 <= price <= 500, (
282+
f"Price {price} should be between 100 and 500"
283+
)
285284

286285

287286
class TestTagFieldMultiValueSearch:

tests/test_sql_parser.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
"""Tests for the SQL parser component."""
22

3-
import pytest
4-
5-
from sql_redis.parser import ParsedQuery, SQLParser
3+
from sql_redis.parser import SQLParser
64

75

86
class TestSQLParserSelectClause:
@@ -664,14 +662,6 @@ def test_parse_from_values_clause(self):
664662
# FROM exists but has no Table - index stays empty
665663
assert result.index == ""
666664

667-
def test_parse_select_without_from(self):
668-
"""Parse SELECT without FROM clause."""
669-
parser = SQLParser()
670-
result = parser.parse("SELECT 1 + 1")
671-
672-
# No FROM clause at all
673-
assert result.index == ""
674-
675665
def test_parse_insert_statement(self):
676666
"""Parse INSERT statement (no SELECT clause)."""
677667
parser = SQLParser()

0 commit comments

Comments
 (0)