Skip to content

Commit 33b3cf7

Browse files
authored
feat(postgres): render seconds_between as EXTRACT(EPOCH) (#65)
* feat(postgres): render seconds_between as EXTRACT(EPOCH) * chore(ci): test against the core extract-epoch branch * chore: add changelog fragment for PR #65 * chore(ci): annotate the paired core install as intentional * chore(ci): test against the testsuite extract-epoch branch * chore(ci): test against the merged core release
1 parent 28b001e commit 33b3cf7

7 files changed

Lines changed: 304 additions & 11 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ jobs:
6363

6464
- name: Install activerecord and testsuite
6565
run: |
66-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
67-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
66+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
67+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch"
6868
6969
- name: Install dependencies
7070
run: |
@@ -278,8 +278,8 @@ jobs:
278278

279279
- name: Install activerecord and testsuite
280280
run: |
281-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
282-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
281+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
282+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch"
283283
284284
- name: Install dependencies
285285
run: |
@@ -385,8 +385,8 @@ jobs:
385385

386386
- name: Install activerecord and testsuite
387387
run: |
388-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
389-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
388+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
389+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch"
390390
391391
- name: Install dependencies
392392
run: |
@@ -488,8 +488,8 @@ jobs:
488488

489489
- name: Install activerecord and testsuite
490490
run: |
491-
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
492-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
491+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
492+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch"
493493
494494
- name: Install dependencies
495495
run: |

‎changelog.d/65.added.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Render `seconds_between` as `EXTRACT(EPOCH FROM (end - start))`.
2+

‎docs/en_US/postgres_specific_features/dialect.md‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,57 @@ Product.query().where("attributes ? 'brand'", ())
279279
```
280280

281281
💡 *AI Prompt:* "How do PostgreSQL's ILIKE and standard LIKE differ in performance?"
282+
283+
## Date/Time Interval Expressions
284+
285+
PostgreSQL renders the temporal expression nodes with the standard spellings
286+
(`EXTRACT`, `DATE_TRUNC` and `INTERVAL`):
287+
288+
```python
289+
from rhosocial.activerecord.backend.expression import Column
290+
from rhosocial.activerecord.backend.expression.functions import (
291+
date_add, date_diff, date_trunc, extract,
292+
)
293+
294+
expr = extract(dialect, "year", Column(dialect, "created_at"))
295+
# Generates: EXTRACT(YEAR FROM "created_at")
296+
297+
expr = date_trunc(dialect, "month", Column(dialect, "created_at"))
298+
# Generates: DATE_TRUNC('month', "created_at")
299+
300+
expr = date_add(dialect, Column(dialect, "created_at"), 30, "day")
301+
# Generates: "created_at" + INTERVAL '30' DAY
302+
303+
expr = date_diff(
304+
dialect, "day", Column(dialect, "started_at"), Column(dialect, "ended_at")
305+
)
306+
# Generates: (EXTRACT(EPOCH FROM ("ended_at" - "started_at")) / 86400)
307+
```
308+
309+
`date_diff` counts whole units crossed. To measure elapsed time as a signed,
310+
fractional number of seconds, use `seconds_between`:
311+
312+
```python
313+
from rhosocial.activerecord.backend.expression import Column, seconds_between
314+
315+
elapsed = seconds_between(
316+
dialect,
317+
Column(dialect, "queued_at"),
318+
Column(dialect, "started_at"),
319+
).as_("queue_seconds")
320+
# Generates: EXTRACT(EPOCH FROM ("started_at" - "queued_at")) AS "queue_seconds"
321+
```
322+
323+
The result is always `end - start` in seconds:
324+
325+
| Case | Result |
326+
|------|--------|
327+
| `end` later than `start` | positive |
328+
| `end` earlier than `start` | negative |
329+
| same instant | `0` |
330+
| a sub-second difference | the fraction is kept |
331+
332+
`EXTRACT(EPOCH FROM interval)` returns a PostgreSQL `numeric`, so the value
333+
keeps the microseconds stored in the timestamp; round it explicitly if whole
334+
seconds are wanted. `started_at.seconds_between(ended_at)` asks the same
335+
question from a timestamp column.

‎docs/zh_CN/postgres_specific_features/dialect.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,12 +324,31 @@ expr = DatetimeAddExpression(dialect, Column(dialect, "created_at"),
324324
IntervalExpression(dialect, 30, IntervalUnit.DAY))
325325
# 生成: "created_at" + INTERVAL '30' DAY
326326

327-
# 日期差
327+
# 日期差(按整天计数)
328328
expr = DatetimeDiffExpression(dialect, DateTimeField.DAY,
329329
Column(dialect, "start"), Column(dialect, "end"))
330330
# 生成: EXTRACT(EPOCH FROM "end" - "start") / 86400
331331
```
332332

333+
`date_diff` 按指定的整数单位计数。如果需要带符号、保留小数的秒数,使用
334+
`seconds_between`:
335+
336+
```python
337+
from rhosocial.activerecord.backend.expression import Column, seconds_between
338+
339+
elapsed = seconds_between(
340+
dialect,
341+
Column(dialect, "queued_at"),
342+
Column(dialect, "started_at"),
343+
).as_("queue_seconds")
344+
# 生成: EXTRACT(EPOCH FROM ("started_at" - "queued_at")) AS "queue_seconds"
345+
```
346+
347+
结果始终是 `end - start` 的秒数:`end` 晚于 `start` 为正,早于 `start` 为负,
348+
同一时刻为 `0`。PostgreSQL 的 `EXTRACT(EPOCH FROM interval)` 返回 `numeric`,
349+
因此时间戳中的微秒部分会保留;需要整数秒时请调用方显式取整。
350+
`started_at.seconds_between(ended_at)` 在时间戳列上提出同一个问题。
351+
333352
## SQL/XML 标准表达式
334353

335354
PostgreSQL 支持 SQL/XML 标准表达式(PG 8.3+):

‎src/rhosocial/activerecord/backend/impl/postgres/mixins/datetime.py‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44
from typing import Tuple, TYPE_CHECKING
55

66
if TYPE_CHECKING:
7-
from rhosocial.activerecord.backend.expression.datetime import DatetimeDiffExpression
7+
from rhosocial.activerecord.backend.expression.datetime import (
8+
DatetimeDiffExpression,
9+
SecondsBetweenExpression,
10+
)
811

912

1013
class PostgresDateTimeMixin:
11-
"""PostgreSQL datetime diff expression formatting."""
14+
"""PostgreSQL datetime diff and elapsed-seconds formatting."""
1215

1316
def format_datetime_diff_expression(self, expr: "DatetimeDiffExpression") -> Tuple[str, tuple]:
1417
start_sql, start_params = expr.start.to_sql()
@@ -39,3 +42,21 @@ def format_datetime_diff_expression(self, expr: "DatetimeDiffExpression") -> Tup
3942
)
4043
params = end_params + start_params
4144
return self.apply_alias(sql, params, expr)
45+
46+
def format_seconds_between_expression(
47+
self, expr: "SecondsBetweenExpression"
48+
) -> Tuple[str, tuple]:
49+
"""Render signed, fractional seconds from ``start`` to ``end``.
50+
51+
``EXTRACT(EPOCH FROM interval)`` is a ``numeric``, so the subtraction
52+
keeps microseconds and the sign is whatever ``end - start`` yields:
53+
an end before the start is negative, equal instants are exactly zero.
54+
55+
The SQL writes ``end`` first, so its bind parameters are collected
56+
first as well -- bind order follows the SQL, not the constructor's
57+
argument order.
58+
"""
59+
start_sql, start_params = expr.start.to_sql()
60+
end_sql, end_params = expr.end.to_sql()
61+
sql = f"EXTRACT(EPOCH FROM ({end_sql} - {start_sql}))"
62+
return self.apply_alias(sql, end_params + start_params, expr)
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/
2+
# test_postgres_seconds_between.py
3+
"""Signed, fractional seconds between two instants on PostgreSQL.
4+
5+
What matters here is what a calendar-component sum gets wrong: the sign,
6+
the zero, the fraction, and the boundaries where a month or a year changes.
7+
``EXTRACT(EPOCH FROM (end - start))`` is a ``numeric``, so the value keeps the
8+
microseconds PostgreSQL stores and turns negative when the end is earlier.
9+
"""
10+
11+
import pytest
12+
13+
from rhosocial.activerecord.backend.expression import (
14+
Column,
15+
Literal,
16+
QueryStatement,
17+
seconds_between,
18+
)
19+
from rhosocial.activerecord.backend.expression.datetime import SecondsBetweenExpression
20+
from rhosocial.activerecord.backend.expression.value_types import NumericValue
21+
from rhosocial.activerecord.backend.options import ExecutionOptions
22+
from rhosocial.activerecord.backend.schema import StatementType
23+
24+
_SPANS = [
25+
("2026-06-04 10:00:00", "2026-06-04 10:00:00", 0.0),
26+
("2026-06-04 10:00:00", "2026-06-04 11:00:00", 3600.0),
27+
("2026-06-04 11:00:00", "2026-06-04 10:00:00", -3600.0),
28+
("2026-01-31 23:59:59", "2026-02-01 00:00:00", 1.0),
29+
("2025-12-31 12:00:00", "2026-01-01 12:00:00", 86400.0),
30+
("2026-06-04 10:00:00.000", "2026-06-04 10:00:00.500", 0.5),
31+
("2026-06-04 10:00:00.250", "2026-06-04 10:00:00.000", -0.25),
32+
]
33+
34+
_TABLE = "postgres_seconds_between_spans"
35+
36+
37+
def _setup_statements() -> list:
38+
statements = [f"DROP TABLE IF EXISTS {_TABLE} CASCADE"]
39+
statements.append(
40+
f"""
41+
CREATE TABLE {_TABLE} (
42+
id INTEGER PRIMARY KEY,
43+
started_at TIMESTAMP NOT NULL,
44+
ended_at TIMESTAMP NOT NULL
45+
)
46+
"""
47+
)
48+
for index, (start, end, _) in enumerate(_SPANS, start=1):
49+
statements.append(
50+
f"INSERT INTO {_TABLE} (id, started_at, ended_at) VALUES "
51+
f"({index}, TIMESTAMP '{start}', TIMESTAMP '{end}')"
52+
)
53+
return statements
54+
55+
56+
_CLEANUP = f"DROP TABLE IF EXISTS {_TABLE} CASCADE"
57+
_DQL_OPTIONS = ExecutionOptions(stmt_type=StatementType.DQL)
58+
59+
60+
def _seconds_query(dialect) -> QueryStatement:
61+
return QueryStatement(
62+
dialect,
63+
select=[
64+
seconds_between(
65+
dialect,
66+
Column(dialect, "started_at"),
67+
Column(dialect, "ended_at"),
68+
).as_("seconds"),
69+
Column(dialect, "id"),
70+
],
71+
from_=_TABLE,
72+
)
73+
74+
75+
class TestPostgresSecondsBetweenExpression:
76+
"""The SQL PostgreSQL renders for the node."""
77+
78+
def test_columns(self, postgres_dialect):
79+
expr = seconds_between(
80+
postgres_dialect,
81+
Column(postgres_dialect, "started_at"),
82+
Column(postgres_dialect, "ended_at"),
83+
)
84+
85+
sql, params = expr.to_sql()
86+
87+
assert sql == 'EXTRACT(EPOCH FROM ("ended_at" - "started_at"))'
88+
assert params == ()
89+
90+
def test_literal_params_follow_sql_order(self, postgres_dialect):
91+
expr = seconds_between(
92+
postgres_dialect,
93+
Literal(postgres_dialect, "2026-01-01 00:00:00"),
94+
Literal(postgres_dialect, "2026-01-02 00:00:00"),
95+
)
96+
97+
sql, params = expr.to_sql()
98+
99+
assert sql == "EXTRACT(EPOCH FROM (%s - %s))"
100+
# ``end`` is written first, so its parameter binds first.
101+
assert params == ("2026-01-02 00:00:00", "2026-01-01 00:00:00")
102+
103+
def test_factory_coerces_string_to_column(self, postgres_dialect):
104+
expr = seconds_between(postgres_dialect, "started_at", "ended_at")
105+
106+
sql, params = expr.to_sql()
107+
108+
assert sql == 'EXTRACT(EPOCH FROM ("ended_at" - "started_at"))'
109+
assert params == ()
110+
111+
def test_alias(self, postgres_dialect):
112+
expr = seconds_between(
113+
postgres_dialect,
114+
Column(postgres_dialect, "started_at"),
115+
Column(postgres_dialect, "ended_at"),
116+
).as_("elapsed")
117+
118+
sql, params = expr.to_sql()
119+
120+
assert sql == (
121+
'EXTRACT(EPOCH FROM ("ended_at" - "started_at")) AS "elapsed"'
122+
)
123+
assert params == ()
124+
125+
def test_is_a_signed_fractional_number(self, postgres_dialect):
126+
expr = seconds_between(
127+
postgres_dialect,
128+
Column(postgres_dialect, "started_at"),
129+
Column(postgres_dialect, "ended_at"),
130+
)
131+
132+
assert isinstance(expr, SecondsBetweenExpression)
133+
assert isinstance(expr, NumericValue)
134+
assert expr.format_method == "format_seconds_between_expression"
135+
136+
def test_query_integration(self, postgres_dialect):
137+
query = QueryStatement(
138+
postgres_dialect,
139+
select=[
140+
seconds_between(
141+
postgres_dialect,
142+
Column(postgres_dialect, "started_at"),
143+
Column(postgres_dialect, "ended_at"),
144+
).as_("seconds")
145+
],
146+
from_="spans",
147+
)
148+
149+
sql, params = query.to_sql()
150+
151+
assert 'EXTRACT(EPOCH FROM ("ended_at" - "started_at")) AS "seconds"' in sql
152+
assert params == ()
153+
154+
155+
class TestSyncPostgresSecondsBetweenExecution:
156+
"""The value the PostgreSQL spelling actually produces."""
157+
158+
def test_signed_fractional_semantics(self, postgres_backend_single):
159+
backend = postgres_backend_single
160+
try:
161+
for statement in _setup_statements():
162+
backend.execute(statement)
163+
164+
query = _seconds_query(backend.dialect)
165+
sql, params = query.to_sql()
166+
result = backend.execute(sql, params, options=_DQL_OPTIONS)
167+
168+
rows = {row["id"]: row["seconds"] for row in result.data}
169+
for index, (_, _, expected) in enumerate(_SPANS, start=1):
170+
assert float(rows[index]) == pytest.approx(expected, abs=1e-6)
171+
finally:
172+
backend.execute(_CLEANUP)
173+
174+
175+
class TestAsyncPostgresSecondsBetweenExecution:
176+
"""The same value asked through the async backend."""
177+
178+
@pytest.mark.asyncio
179+
async def test_signed_fractional_semantics(
180+
self, async_postgres_backend_single
181+
):
182+
backend = async_postgres_backend_single
183+
try:
184+
for statement in _setup_statements():
185+
await backend.execute(statement)
186+
187+
query = _seconds_query(backend.dialect)
188+
sql, params = query.to_sql()
189+
result = await backend.execute(sql, params, options=_DQL_OPTIONS)
190+
191+
rows = {row["id"]: row["seconds"] for row in result.data}
192+
for index, (_, _, expected) in enumerate(_SPANS, start=1):
193+
assert float(rows[index]) == pytest.approx(expected, abs=1e-6)
194+
finally:
195+
await backend.execute(_CLEANUP)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# tests/rhosocial/activerecord_postgres_test/feature/query/typed_column/test_seconds_between_contract.py
2+
from rhosocial.activerecord.testsuite.feature.query.typed_column.test_seconds_between_contract import * # noqa: F401,F403

0 commit comments

Comments
 (0)