Skip to content

Commit 576a588

Browse files
committed
Fix Copilot review issues for date support
- Reject DATE_FORMAT in WHERE conditions (format string not representable) - Fix datetime parsing for fractional seconds (2024-01-01T12:00:00.123Z) - Fix timezone offset normalization (+0000 -> +00:00 for fromisoformat) - Fix date function alias mismatch: always compute canonical alias for FILTER even when SELECT uses custom alias - Remove DATE_FORMAT from _is_date_function_condition (rejected at parse time) - Add 4 new tests for edge cases
1 parent 811b085 commit 576a588

4 files changed

Lines changed: 75 additions & 30 deletions

File tree

sql_redis/parser.py

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,13 @@ def _add_condition(
506506
elif isinstance(expression.this, exp.Anonymous):
507507
# Function call like DISTANCE(location, POINT(...)) or DATE_FORMAT
508508
func_name = expression.this.name.upper()
509+
# DATE_FORMAT in WHERE is not supported - format string can't be
510+
# represented in the Condition model. Use DATE_FORMAT in SELECT instead.
511+
if func_name == "DATE_FORMAT":
512+
raise ValueError(
513+
"DATE_FORMAT in WHERE conditions is not supported. "
514+
"Use DATE_FORMAT in the SELECT clause instead."
515+
)
509516
if expression.this.expressions:
510517
first_arg = expression.this.expressions[0]
511518
if isinstance(first_arg, exp.Column):
@@ -645,32 +652,20 @@ def _parse_date_to_timestamp(self, value: str) -> int | None:
645652
# Normalize: replace space with T for parsing
646653
normalized = value.replace(" ", "T")
647654

648-
# Handle timezone variations
655+
# Normalize 'Z' (UTC designator) to '+00:00' for fromisoformat
656+
if normalized.endswith("Z"):
657+
normalized = normalized[:-1] + "+00:00"
658+
659+
# Normalize timezone offsets without colon (+0000 -> +00:00)
660+
# This ensures compatibility with datetime.fromisoformat
661+
normalized = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", normalized)
662+
649663
try:
650-
# Try with Z suffix (UTC)
651-
if normalized.endswith("Z"):
652-
dt = datetime.strptime(normalized, "%Y-%m-%dT%H:%M:%SZ")
664+
# Use fromisoformat for robust parsing (handles fractional seconds)
665+
dt = datetime.fromisoformat(normalized)
666+
# If no timezone info, treat as UTC
667+
if dt.tzinfo is None:
653668
dt = dt.replace(tzinfo=timezone.utc)
654-
return int(dt.timestamp())
655-
656-
# Try with timezone offset (+00:00 or +0000)
657-
if "+" in normalized or normalized.count("-") > 2:
658-
# Has timezone offset
659-
try:
660-
dt = datetime.fromisoformat(normalized)
661-
return int(dt.timestamp())
662-
except ValueError:
663-
pass
664-
665-
# No timezone - treat as UTC
666-
# Handle optional milliseconds
667-
if "." in normalized:
668-
dt = datetime.strptime(
669-
normalized.split(".")[0], "%Y-%m-%dT%H:%M:%S"
670-
)
671-
else:
672-
dt = datetime.strptime(normalized, "%Y-%m-%dT%H:%M:%S")
673-
dt = dt.replace(tzinfo=timezone.utc)
674669
return int(dt.timestamp())
675670
except ValueError:
676671
return None

sql_redis/translator.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -296,19 +296,27 @@ def _build_aggregate(
296296
"are not supported"
297297
)
298298

299-
# Track which date functions we've already computed (avoid duplicates)
300-
computed_date_funcs = {
301-
(df.function, df.field) for df in analyzed.date_functions
299+
# Track which date functions we've already computed with canonical alias.
300+
# Only skip if SELECT used the canonical alias (e.g., year_created_at).
301+
# If SELECT used a custom alias (e.g., YEAR(created_at) AS year),
302+
# we still need to compute the canonical alias for FILTER.
303+
computed_canonical_aliases = {
304+
(df.function, df.field)
305+
for df in analyzed.date_functions
306+
if df.alias == f"{df.function.lower()}_{df.field}"
302307
}
303308
for condition in date_func_conditions:
304309
parts = condition.operator.rsplit("_", 1)
305310
func_name = parts[0]
306311
redis_func = SQL_TO_REDIS_DATE_FUNCTIONS.get(func_name)
307-
if redis_func and (func_name, condition.field) not in computed_date_funcs:
312+
if (
313+
redis_func
314+
and (func_name, condition.field) not in computed_canonical_aliases
315+
):
308316
expression = f"{redis_func}(@{condition.field})"
309317
alias = f"{func_name.lower()}_{condition.field}"
310318
args.extend(["APPLY", expression, "AS", alias])
311-
computed_date_funcs.add((func_name, condition.field))
319+
computed_canonical_aliases.add((func_name, condition.field))
312320

313321
# FILTER for date function conditions
314322
for condition in date_func_conditions:
@@ -395,6 +403,8 @@ def _is_date_function_condition(self, condition) -> bool:
395403
"""Check if a condition involves a date function.
396404
397405
Date function conditions have operators like YEAR_=, MONTH_>, etc.
406+
Note: DATE_FORMAT is excluded - it's rejected at parse time because
407+
the format string can't be represented in the Condition model.
398408
"""
399409
date_prefixes = (
400410
"YEAR_",
@@ -404,7 +414,6 @@ def _is_date_function_condition(self, condition) -> bool:
404414
"DAYOFYEAR_",
405415
"HOUR_",
406416
"MINUTE_",
407-
"DATE_FORMAT_",
408417
)
409418
return condition.operator.startswith(date_prefixes)
410419

tests/test_date_fields.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,25 @@ def test_datetime_literal_with_timezone_offset(self, parser):
6161
assert len(result.conditions) == 1
6262
assert result.conditions[0].value == 1704110400
6363

64+
def test_datetime_literal_with_fractional_seconds(self, parser):
65+
"""Datetime literal with fractional seconds should be converted."""
66+
result = parser.parse(
67+
"SELECT * FROM events WHERE created_at = '2024-01-01T12:00:00.123Z'"
68+
)
69+
70+
assert len(result.conditions) == 1
71+
# Should parse successfully (fractional seconds truncated to int timestamp)
72+
assert result.conditions[0].value == 1704110400
73+
74+
def test_datetime_literal_with_timezone_no_colon(self, parser):
75+
"""Datetime literal with +0000 format should be converted."""
76+
result = parser.parse(
77+
"SELECT * FROM events WHERE created_at = '2024-01-01T12:00:00+0000'"
78+
)
79+
80+
assert len(result.conditions) == 1
81+
assert result.conditions[0].value == 1704110400
82+
6483
def test_date_between_range(self, parser):
6584
"""BETWEEN with date literals should convert both bounds."""
6685
result = parser.parse(

tests/test_date_functions.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,25 @@ def test_negated_date_function_raises_error(self, date_translator, date_index):
210210
sql = f"SELECT * FROM {date_index} WHERE NOT YEAR(created_at) = 2024"
211211
with pytest.raises(ValueError, match="Negated date function"):
212212
date_translator.translate(sql)
213+
214+
def test_date_format_in_where_raises_error(self, date_translator, date_index):
215+
"""DATE_FORMAT in WHERE should raise a clear error."""
216+
sql = f"SELECT * FROM {date_index} WHERE DATE_FORMAT(created_at, '%Y-%m') = '2024-01'"
217+
with pytest.raises(ValueError, match="DATE_FORMAT in WHERE"):
218+
date_translator.translate(sql)
219+
220+
def test_custom_alias_with_where_filter(self, date_translator, date_index):
221+
"""SELECT with custom alias + WHERE should compute canonical alias for FILTER."""
222+
result = date_translator.translate(
223+
f"SELECT YEAR(created_at) AS year FROM {date_index} "
224+
f"WHERE YEAR(created_at) = 2024"
225+
)
226+
# Should have APPLY for both: custom alias 'year' AND canonical 'year_created_at'
227+
assert result.command == "FT.AGGREGATE"
228+
apply_indices = [i for i, arg in enumerate(result.args) if arg == "APPLY"]
229+
# Should have at least 2 APPLYs (one for SELECT, one for FILTER)
230+
assert len(apply_indices) >= 2
231+
# FILTER should reference the canonical alias
232+
assert "FILTER" in result.args
233+
filter_idx = result.args.index("FILTER")
234+
assert "year_created_at" in result.args[filter_idx + 1]

0 commit comments

Comments
 (0)