Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ jobs:

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

- name: Install dependencies
run: |
Expand Down Expand Up @@ -278,8 +278,8 @@ jobs:

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

- name: Install dependencies
run: |
Expand Down Expand Up @@ -385,8 +385,8 @@ jobs:

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

- name: Install dependencies
run: |
Expand Down Expand Up @@ -488,8 +488,8 @@ jobs:

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

- name: Install dependencies
run: |
Expand Down
2 changes: 2 additions & 0 deletions changelog.d/65.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Render `seconds_between` as `EXTRACT(EPOCH FROM (end - start))`.

54 changes: 54 additions & 0 deletions docs/en_US/postgres_specific_features/dialect.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,57 @@ Product.query().where("attributes ? 'brand'", ())
```

💡 *AI Prompt:* "How do PostgreSQL's ILIKE and standard LIKE differ in performance?"

## Date/Time Interval Expressions

PostgreSQL renders the temporal expression nodes with the standard spellings
(`EXTRACT`, `DATE_TRUNC` and `INTERVAL`):

```python
from rhosocial.activerecord.backend.expression import Column
from rhosocial.activerecord.backend.expression.functions import (
date_add, date_diff, date_trunc, extract,
)

expr = extract(dialect, "year", Column(dialect, "created_at"))
# Generates: EXTRACT(YEAR FROM "created_at")

expr = date_trunc(dialect, "month", Column(dialect, "created_at"))
# Generates: DATE_TRUNC('month', "created_at")

expr = date_add(dialect, Column(dialect, "created_at"), 30, "day")
# Generates: "created_at" + INTERVAL '30' DAY

expr = date_diff(
dialect, "day", Column(dialect, "started_at"), Column(dialect, "ended_at")
)
# Generates: (EXTRACT(EPOCH FROM ("ended_at" - "started_at")) / 86400)
```

`date_diff` counts whole units crossed. To measure elapsed time as a signed,
fractional number of seconds, use `seconds_between`:

```python
from rhosocial.activerecord.backend.expression import Column, seconds_between

elapsed = seconds_between(
dialect,
Column(dialect, "queued_at"),
Column(dialect, "started_at"),
).as_("queue_seconds")
# Generates: EXTRACT(EPOCH FROM ("started_at" - "queued_at")) AS "queue_seconds"
```

The result is always `end - start` in seconds:

| Case | Result |
|------|--------|
| `end` later than `start` | positive |
| `end` earlier than `start` | negative |
| same instant | `0` |
| a sub-second difference | the fraction is kept |

`EXTRACT(EPOCH FROM interval)` returns a PostgreSQL `numeric`, so the value
keeps the microseconds stored in the timestamp; round it explicitly if whole
seconds are wanted. `started_at.seconds_between(ended_at)` asks the same
question from a timestamp column.
21 changes: 20 additions & 1 deletion docs/zh_CN/postgres_specific_features/dialect.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,12 +324,31 @@ expr = DatetimeAddExpression(dialect, Column(dialect, "created_at"),
IntervalExpression(dialect, 30, IntervalUnit.DAY))
# 生成: "created_at" + INTERVAL '30' DAY

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

`date_diff` 按指定的整数单位计数。如果需要带符号、保留小数的秒数,使用
`seconds_between`:

```python
from rhosocial.activerecord.backend.expression import Column, seconds_between

elapsed = seconds_between(
dialect,
Column(dialect, "queued_at"),
Column(dialect, "started_at"),
).as_("queue_seconds")
# 生成: EXTRACT(EPOCH FROM ("started_at" - "queued_at")) AS "queue_seconds"
```

结果始终是 `end - start` 的秒数:`end` 晚于 `start` 为正,早于 `start` 为负,
同一时刻为 `0`。PostgreSQL 的 `EXTRACT(EPOCH FROM interval)` 返回 `numeric`,
因此时间戳中的微秒部分会保留;需要整数秒时请调用方显式取整。
`started_at.seconds_between(ended_at)` 在时间戳列上提出同一个问题。

## SQL/XML 标准表达式

PostgreSQL 支持 SQL/XML 标准表达式(PG 8.3+):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
from typing import Tuple, TYPE_CHECKING

if TYPE_CHECKING:
from rhosocial.activerecord.backend.expression.datetime import DatetimeDiffExpression
from rhosocial.activerecord.backend.expression.datetime import (
DatetimeDiffExpression,
SecondsBetweenExpression,
)


class PostgresDateTimeMixin:
"""PostgreSQL datetime diff expression formatting."""
"""PostgreSQL datetime diff and elapsed-seconds formatting."""

def format_datetime_diff_expression(self, expr: "DatetimeDiffExpression") -> Tuple[str, tuple]:
start_sql, start_params = expr.start.to_sql()
Expand Down Expand Up @@ -39,3 +42,21 @@ def format_datetime_diff_expression(self, expr: "DatetimeDiffExpression") -> Tup
)
params = end_params + start_params
return self.apply_alias(sql, params, expr)

def format_seconds_between_expression(
self, expr: "SecondsBetweenExpression"
) -> Tuple[str, tuple]:
"""Render signed, fractional seconds from ``start`` to ``end``.

``EXTRACT(EPOCH FROM interval)`` is a ``numeric``, so the subtraction
keeps microseconds and the sign is whatever ``end - start`` yields:
an end before the start is negative, equal instants are exactly zero.

The SQL writes ``end`` first, so its bind parameters are collected
first as well -- bind order follows the SQL, not the constructor's
argument order.
"""
start_sql, start_params = expr.start.to_sql()
end_sql, end_params = expr.end.to_sql()
sql = f"EXTRACT(EPOCH FROM ({end_sql} - {start_sql}))"
return self.apply_alias(sql, end_params + start_params, expr)
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/
# test_postgres_seconds_between.py
"""Signed, fractional seconds between two instants on PostgreSQL.

What matters here is what a calendar-component sum gets wrong: the sign,
the zero, the fraction, and the boundaries where a month or a year changes.
``EXTRACT(EPOCH FROM (end - start))`` is a ``numeric``, so the value keeps the
microseconds PostgreSQL stores and turns negative when the end is earlier.
"""

import pytest

from rhosocial.activerecord.backend.expression import (
Column,
Literal,
QueryStatement,
seconds_between,
)
from rhosocial.activerecord.backend.expression.datetime import SecondsBetweenExpression
from rhosocial.activerecord.backend.expression.value_types import NumericValue
from rhosocial.activerecord.backend.options import ExecutionOptions
from rhosocial.activerecord.backend.schema import StatementType

_SPANS = [
("2026-06-04 10:00:00", "2026-06-04 10:00:00", 0.0),
("2026-06-04 10:00:00", "2026-06-04 11:00:00", 3600.0),
("2026-06-04 11:00:00", "2026-06-04 10:00:00", -3600.0),
("2026-01-31 23:59:59", "2026-02-01 00:00:00", 1.0),
("2025-12-31 12:00:00", "2026-01-01 12:00:00", 86400.0),
("2026-06-04 10:00:00.000", "2026-06-04 10:00:00.500", 0.5),
("2026-06-04 10:00:00.250", "2026-06-04 10:00:00.000", -0.25),
]

_TABLE = "postgres_seconds_between_spans"


def _setup_statements() -> list:
statements = [f"DROP TABLE IF EXISTS {_TABLE} CASCADE"]
statements.append(
f"""
CREATE TABLE {_TABLE} (
id INTEGER PRIMARY KEY,
started_at TIMESTAMP NOT NULL,
ended_at TIMESTAMP NOT NULL
)
"""
)
for index, (start, end, _) in enumerate(_SPANS, start=1):
statements.append(
f"INSERT INTO {_TABLE} (id, started_at, ended_at) VALUES "
f"({index}, TIMESTAMP '{start}', TIMESTAMP '{end}')"
)
return statements


_CLEANUP = f"DROP TABLE IF EXISTS {_TABLE} CASCADE"
_DQL_OPTIONS = ExecutionOptions(stmt_type=StatementType.DQL)


def _seconds_query(dialect) -> QueryStatement:
return QueryStatement(
dialect,
select=[
seconds_between(
dialect,
Column(dialect, "started_at"),
Column(dialect, "ended_at"),
).as_("seconds"),
Column(dialect, "id"),
],
from_=_TABLE,
)


class TestPostgresSecondsBetweenExpression:
"""The SQL PostgreSQL renders for the node."""

def test_columns(self, postgres_dialect):
expr = seconds_between(
postgres_dialect,
Column(postgres_dialect, "started_at"),
Column(postgres_dialect, "ended_at"),
)

sql, params = expr.to_sql()

assert sql == 'EXTRACT(EPOCH FROM ("ended_at" - "started_at"))'
assert params == ()

def test_literal_params_follow_sql_order(self, postgres_dialect):
expr = seconds_between(
postgres_dialect,
Literal(postgres_dialect, "2026-01-01 00:00:00"),
Literal(postgres_dialect, "2026-01-02 00:00:00"),
)

sql, params = expr.to_sql()

assert sql == "EXTRACT(EPOCH FROM (%s - %s))"
# ``end`` is written first, so its parameter binds first.
assert params == ("2026-01-02 00:00:00", "2026-01-01 00:00:00")

def test_factory_coerces_string_to_column(self, postgres_dialect):
expr = seconds_between(postgres_dialect, "started_at", "ended_at")

sql, params = expr.to_sql()

assert sql == 'EXTRACT(EPOCH FROM ("ended_at" - "started_at"))'
assert params == ()

def test_alias(self, postgres_dialect):
expr = seconds_between(
postgres_dialect,
Column(postgres_dialect, "started_at"),
Column(postgres_dialect, "ended_at"),
).as_("elapsed")

sql, params = expr.to_sql()

assert sql == (
'EXTRACT(EPOCH FROM ("ended_at" - "started_at")) AS "elapsed"'
)
assert params == ()

def test_is_a_signed_fractional_number(self, postgres_dialect):
expr = seconds_between(
postgres_dialect,
Column(postgres_dialect, "started_at"),
Column(postgres_dialect, "ended_at"),
)

assert isinstance(expr, SecondsBetweenExpression)
assert isinstance(expr, NumericValue)
assert expr.format_method == "format_seconds_between_expression"

def test_query_integration(self, postgres_dialect):
query = QueryStatement(
postgres_dialect,
select=[
seconds_between(
postgres_dialect,
Column(postgres_dialect, "started_at"),
Column(postgres_dialect, "ended_at"),
).as_("seconds")
],
from_="spans",
)

sql, params = query.to_sql()

assert 'EXTRACT(EPOCH FROM ("ended_at" - "started_at")) AS "seconds"' in sql
assert params == ()


class TestSyncPostgresSecondsBetweenExecution:
"""The value the PostgreSQL spelling actually produces."""

def test_signed_fractional_semantics(self, postgres_backend_single):
backend = postgres_backend_single
try:
for statement in _setup_statements():
backend.execute(statement)

query = _seconds_query(backend.dialect)
sql, params = query.to_sql()
result = backend.execute(sql, params, options=_DQL_OPTIONS)

rows = {row["id"]: row["seconds"] for row in result.data}
for index, (_, _, expected) in enumerate(_SPANS, start=1):
assert float(rows[index]) == pytest.approx(expected, abs=1e-6)
finally:
backend.execute(_CLEANUP)


class TestAsyncPostgresSecondsBetweenExecution:
"""The same value asked through the async backend."""

@pytest.mark.asyncio
async def test_signed_fractional_semantics(
self, async_postgres_backend_single
):
backend = async_postgres_backend_single
try:
for statement in _setup_statements():
await backend.execute(statement)

query = _seconds_query(backend.dialect)
sql, params = query.to_sql()
result = await backend.execute(sql, params, options=_DQL_OPTIONS)

rows = {row["id"]: row["seconds"] for row in result.data}
for index, (_, _, expected) in enumerate(_SPANS, start=1):
assert float(rows[index]) == pytest.approx(expected, abs=1e-6)
finally:
await backend.execute(_CLEANUP)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# tests/rhosocial/activerecord_postgres_test/feature/query/typed_column/test_seconds_between_contract.py
from rhosocial.activerecord.testsuite.feature.query.typed_column.test_seconds_between_contract import * # noqa: F401,F403
Loading