Skip to content

Commit 72ed52f

Browse files
vistartclaude
andcommitted
feat(postgres): add datetime interval expressions with EXPLAIN examples (#42)
* feat(postgres): add datetime interval expression formatting Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(postgres): add datetime interval EXPLAIN examples Cover temporal expression queries against indexed datetime filters while allowing PostgreSQL planner cost choices between eligible datetime indexes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: add changelog fragment for PR #42 --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f0ffe09 commit 72ed52f

4 files changed

Lines changed: 542 additions & 0 deletions

File tree

‎changelog.d/42.added.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added PostgreSQL dialect support for datetime interval expressions (`EXTRACT`, `DATE_PART`, `DATE_TRUNC`, `INTERVAL` add/subtract, date diff) with EXPLAIN-backed index usage verification tests.

‎src/rhosocial/activerecord/backend/impl/postgres/dialect.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .function_versions import FunctionSupportInfo, FunctionVersionRequirement
1414

1515
from rhosocial.activerecord.backend.dialect.base import SQLDialectBase
16+
from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError
1617
from rhosocial.activerecord.backend.dialect.mixins import (
1718
SQLXMLMixin,
1819
CollationMixin,
@@ -485,6 +486,36 @@ def get_server_version(self) -> Tuple[int, int, int]:
485486
"""Return the PostgreSQL version this dialect is configured for."""
486487
return self.version
487488

489+
def format_datetime_diff_expression(self, expr: "Any") -> Tuple[str, Tuple]:
490+
start_sql, start_params = expr.start.to_sql()
491+
end_sql, end_params = expr.end.to_sql()
492+
seconds_sql = f"EXTRACT(EPOCH FROM ({end_sql} - {start_sql}))"
493+
factors = {
494+
"second": "1",
495+
"minute": "60",
496+
"hour": "3600",
497+
"day": "86400",
498+
"week": "604800",
499+
}
500+
if expr.unit.value in factors:
501+
sql = f"({seconds_sql} / {factors[expr.unit.value]})"
502+
params = end_params + start_params
503+
elif expr.unit.value == "month":
504+
sql = (
505+
f"((EXTRACT(YEAR FROM {end_sql}) - "
506+
f"EXTRACT(YEAR FROM {start_sql})) * 12 + "
507+
f"(EXTRACT(MONTH FROM {end_sql}) - "
508+
f"EXTRACT(MONTH FROM {start_sql})))"
509+
)
510+
params = end_params + start_params + end_params + start_params
511+
else:
512+
sql = (
513+
f"(EXTRACT(YEAR FROM {end_sql}) - "
514+
f"EXTRACT(YEAR FROM {start_sql}))"
515+
)
516+
params = end_params + start_params
517+
return self._apply_value_expression_modifiers(sql, params, expr)
518+
488519
def supports_xmlparse(self) -> bool:
489520
"""XMLPARSE is supported since PostgreSQL 8.3."""
490521
return self.version >= (8, 3, 0)
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/
2+
# test_postgres_datetime_interval_expressions.py
3+
"""Tests for PostgreSQL datetime interval expressions."""
4+
5+
import pytest
6+
7+
from rhosocial.activerecord.backend.expression import Column, Literal, QueryExpression
8+
from rhosocial.activerecord.backend.expression.functions import (
9+
date_add,
10+
date_diff,
11+
date_part,
12+
date_sub,
13+
date_trunc,
14+
extract,
15+
interval,
16+
)
17+
from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect
18+
19+
20+
class TestPostgresDateTimeIntervalExpressions:
21+
@pytest.mark.parametrize(
22+
"field",
23+
["year", "month", "day", "hour", "minute", "second"],
24+
)
25+
def test_extract_datetime_fields(
26+
self, postgres_dialect: PostgresDialect, field: str
27+
):
28+
expr = extract(postgres_dialect, field, Column(postgres_dialect, "created_at"))
29+
30+
sql, params = expr.to_sql()
31+
32+
assert sql == f'EXTRACT({field.upper()} FROM "created_at")'
33+
assert params == ()
34+
35+
def test_date_part_uses_extract_mapping(self, postgres_dialect: PostgresDialect):
36+
expr = date_part(
37+
postgres_dialect, "day", Column(postgres_dialect, "created_at")
38+
)
39+
40+
sql, params = expr.to_sql()
41+
42+
assert sql == 'EXTRACT(DAY FROM "created_at")'
43+
assert params == ()
44+
45+
def test_date_trunc_expression(self, postgres_dialect: PostgresDialect):
46+
expr = date_trunc(
47+
postgres_dialect, "month", Column(postgres_dialect, "created_at")
48+
)
49+
50+
sql, params = expr.to_sql()
51+
52+
assert sql == "DATE_TRUNC('month', \"created_at\")"
53+
assert params == ()
54+
55+
def test_interval_expression(self, postgres_dialect: PostgresDialect):
56+
expr = interval(postgres_dialect, 2, "hour")
57+
58+
sql, params = expr.to_sql()
59+
60+
assert sql == "INTERVAL '2' HOUR"
61+
assert params == ()
62+
63+
def test_date_add_column_source(self, postgres_dialect: PostgresDialect):
64+
expr = date_add(
65+
postgres_dialect, Column(postgres_dialect, "created_at"), 1, "day"
66+
)
67+
68+
sql, params = expr.to_sql()
69+
70+
assert sql == '"created_at" + INTERVAL \'1\' DAY'
71+
assert params == ()
72+
73+
def test_date_sub_interval_expression(self, postgres_dialect: PostgresDialect):
74+
expr = date_sub(
75+
postgres_dialect,
76+
Column(postgres_dialect, "created_at"),
77+
interval(postgres_dialect, 2, "hour"),
78+
)
79+
80+
sql, params = expr.to_sql()
81+
82+
assert sql == '"created_at" - INTERVAL \'2\' HOUR'
83+
assert params == ()
84+
85+
def test_date_add_literal_source_params_order(
86+
self, postgres_dialect: PostgresDialect
87+
):
88+
expr = date_add(
89+
postgres_dialect,
90+
Literal(postgres_dialect, "2026-06-04 10:00:00"),
91+
30,
92+
"minute",
93+
)
94+
95+
sql, params = expr.to_sql()
96+
97+
assert sql == "%s + INTERVAL '30' MINUTE"
98+
assert params == ("2026-06-04 10:00:00",)
99+
100+
@pytest.mark.parametrize(
101+
"unit,factor",
102+
[
103+
("second", "1"),
104+
("minute", "60"),
105+
("hour", "3600"),
106+
("day", "86400"),
107+
("week", "604800"),
108+
],
109+
)
110+
def test_date_diff_epoch_units(
111+
self, postgres_dialect: PostgresDialect, unit: str, factor: str
112+
):
113+
expr = date_diff(
114+
postgres_dialect,
115+
unit,
116+
Column(postgres_dialect, "started_at"),
117+
Column(postgres_dialect, "ended_at"),
118+
)
119+
120+
sql, params = expr.to_sql()
121+
122+
assert sql == (
123+
'(EXTRACT(EPOCH FROM ("ended_at" - "started_at"))'
124+
f" / {factor})"
125+
)
126+
assert params == ()
127+
128+
def test_date_diff_month(self, postgres_dialect: PostgresDialect):
129+
expr = date_diff(
130+
postgres_dialect,
131+
"month",
132+
Column(postgres_dialect, "started_at"),
133+
Column(postgres_dialect, "ended_at"),
134+
)
135+
136+
sql, params = expr.to_sql()
137+
138+
assert sql == (
139+
'((EXTRACT(YEAR FROM "ended_at") - EXTRACT(YEAR FROM "started_at")) '
140+
'* 12 + (EXTRACT(MONTH FROM "ended_at") - '
141+
'EXTRACT(MONTH FROM "started_at")))'
142+
)
143+
assert params == ()
144+
145+
def test_date_diff_month_literal_params(self, postgres_dialect: PostgresDialect):
146+
expr = date_diff(
147+
postgres_dialect,
148+
"month",
149+
Literal(postgres_dialect, "2026-01-01"),
150+
Literal(postgres_dialect, "2026-06-01"),
151+
)
152+
153+
sql, params = expr.to_sql()
154+
155+
assert sql == (
156+
"((EXTRACT(YEAR FROM %s) - EXTRACT(YEAR FROM %s)) "
157+
"* 12 + (EXTRACT(MONTH FROM %s) - EXTRACT(MONTH FROM %s)))"
158+
)
159+
assert params == (
160+
"2026-06-01",
161+
"2026-01-01",
162+
"2026-06-01",
163+
"2026-01-01",
164+
)
165+
166+
def test_date_diff_year(self, postgres_dialect: PostgresDialect):
167+
expr = date_diff(
168+
postgres_dialect,
169+
"year",
170+
Column(postgres_dialect, "started_at"),
171+
Column(postgres_dialect, "ended_at"),
172+
)
173+
174+
sql, params = expr.to_sql()
175+
176+
assert sql == (
177+
'(EXTRACT(YEAR FROM "ended_at") - EXTRACT(YEAR FROM "started_at"))'
178+
)
179+
assert params == ()
180+
181+
def test_alias_and_cast(self, postgres_dialect: PostgresDialect):
182+
expr = date_diff(
183+
postgres_dialect,
184+
"day",
185+
Column(postgres_dialect, "started_at"),
186+
Column(postgres_dialect, "ended_at"),
187+
).cast("INTEGER").as_("elapsed_days")
188+
189+
sql, params = expr.to_sql()
190+
191+
assert sql == (
192+
'(EXTRACT(EPOCH FROM ("ended_at" - "started_at")) / 86400)'
193+
'::INTEGER AS "elapsed_days"'
194+
)
195+
assert params == ()
196+
197+
def test_query_expression_integration(self, postgres_dialect: PostgresDialect):
198+
shifted = date_add(
199+
postgres_dialect, Column(postgres_dialect, "created_at"), 1, "day"
200+
)
201+
query = QueryExpression(
202+
postgres_dialect,
203+
select=[
204+
extract(postgres_dialect, "year", Column(postgres_dialect, "created_at"))
205+
],
206+
from_="events",
207+
where=shifted > Literal(postgres_dialect, "2026-01-01"),
208+
)
209+
210+
sql, params = query.to_sql()
211+
212+
assert 'EXTRACT(YEAR FROM "created_at")' in sql
213+
assert '"created_at" + INTERVAL \'1\' DAY > %s' in sql
214+
assert params == ("2026-01-01",)

0 commit comments

Comments
 (0)