From c3f95ed8e36cc225aec72cad4a781e75c0016c84 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 5 Jun 2026 01:49:06 +0800 Subject: [PATCH 1/3] feat(postgres): add datetime interval expression formatting Co-Authored-By: Claude Opus 4.7 --- .../backend/impl/postgres/dialect.py | 31 +++ ..._postgres_datetime_interval_expressions.py | 214 ++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_datetime_interval_expressions.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 2863d622..9b4ea6bc 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -13,6 +13,7 @@ from .function_versions import FunctionSupportInfo, FunctionVersionRequirement from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.dialect.mixins import ( SQLXMLMixin, CollationMixin, @@ -485,6 +486,36 @@ def get_server_version(self) -> Tuple[int, int, int]: """Return the PostgreSQL version this dialect is configured for.""" return self.version + def format_datetime_diff_expression(self, expr: "Any") -> Tuple[str, Tuple]: + start_sql, start_params = expr.start.to_sql() + end_sql, end_params = expr.end.to_sql() + seconds_sql = f"EXTRACT(EPOCH FROM ({end_sql} - {start_sql}))" + factors = { + "second": "1", + "minute": "60", + "hour": "3600", + "day": "86400", + "week": "604800", + } + if expr.unit.value in factors: + sql = f"({seconds_sql} / {factors[expr.unit.value]})" + params = end_params + start_params + elif expr.unit.value == "month": + sql = ( + f"((EXTRACT(YEAR FROM {end_sql}) - " + f"EXTRACT(YEAR FROM {start_sql})) * 12 + " + f"(EXTRACT(MONTH FROM {end_sql}) - " + f"EXTRACT(MONTH FROM {start_sql})))" + ) + params = end_params + start_params + end_params + start_params + else: + sql = ( + f"(EXTRACT(YEAR FROM {end_sql}) - " + f"EXTRACT(YEAR FROM {start_sql}))" + ) + params = end_params + start_params + return self._apply_value_expression_modifiers(sql, params, expr) + def supports_xmlparse(self) -> bool: """XMLPARSE is supported since PostgreSQL 8.3.""" return self.version >= (8, 3, 0) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_datetime_interval_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_datetime_interval_expressions.py new file mode 100644 index 00000000..8acd4887 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_datetime_interval_expressions.py @@ -0,0 +1,214 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/ +# test_postgres_datetime_interval_expressions.py +"""Tests for PostgreSQL datetime interval expressions.""" + +import pytest + +from rhosocial.activerecord.backend.expression import Column, Literal, QueryExpression +from rhosocial.activerecord.backend.expression.functions import ( + date_add, + date_diff, + date_part, + date_sub, + date_trunc, + extract, + interval, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +class TestPostgresDateTimeIntervalExpressions: + @pytest.mark.parametrize( + "field", + ["year", "month", "day", "hour", "minute", "second"], + ) + def test_extract_datetime_fields( + self, postgres_dialect: PostgresDialect, field: str + ): + expr = extract(postgres_dialect, field, Column(postgres_dialect, "created_at")) + + sql, params = expr.to_sql() + + assert sql == f'EXTRACT({field.upper()} FROM "created_at")' + assert params == () + + def test_date_part_uses_extract_mapping(self, postgres_dialect: PostgresDialect): + expr = date_part( + postgres_dialect, "day", Column(postgres_dialect, "created_at") + ) + + sql, params = expr.to_sql() + + assert sql == 'EXTRACT(DAY FROM "created_at")' + assert params == () + + def test_date_trunc_expression(self, postgres_dialect: PostgresDialect): + expr = date_trunc( + postgres_dialect, "month", Column(postgres_dialect, "created_at") + ) + + sql, params = expr.to_sql() + + assert sql == "DATE_TRUNC('month', \"created_at\")" + assert params == () + + def test_interval_expression(self, postgres_dialect: PostgresDialect): + expr = interval(postgres_dialect, 2, "hour") + + sql, params = expr.to_sql() + + assert sql == "INTERVAL '2' HOUR" + assert params == () + + def test_date_add_column_source(self, postgres_dialect: PostgresDialect): + expr = date_add( + postgres_dialect, Column(postgres_dialect, "created_at"), 1, "day" + ) + + sql, params = expr.to_sql() + + assert sql == '"created_at" + INTERVAL \'1\' DAY' + assert params == () + + def test_date_sub_interval_expression(self, postgres_dialect: PostgresDialect): + expr = date_sub( + postgres_dialect, + Column(postgres_dialect, "created_at"), + interval(postgres_dialect, 2, "hour"), + ) + + sql, params = expr.to_sql() + + assert sql == '"created_at" - INTERVAL \'2\' HOUR' + assert params == () + + def test_date_add_literal_source_params_order( + self, postgres_dialect: PostgresDialect + ): + expr = date_add( + postgres_dialect, + Literal(postgres_dialect, "2026-06-04 10:00:00"), + 30, + "minute", + ) + + sql, params = expr.to_sql() + + assert sql == "%s + INTERVAL '30' MINUTE" + assert params == ("2026-06-04 10:00:00",) + + @pytest.mark.parametrize( + "unit,factor", + [ + ("second", "1"), + ("minute", "60"), + ("hour", "3600"), + ("day", "86400"), + ("week", "604800"), + ], + ) + def test_date_diff_epoch_units( + self, postgres_dialect: PostgresDialect, unit: str, factor: str + ): + expr = date_diff( + postgres_dialect, + unit, + Column(postgres_dialect, "started_at"), + Column(postgres_dialect, "ended_at"), + ) + + sql, params = expr.to_sql() + + assert sql == ( + '(EXTRACT(EPOCH FROM ("ended_at" - "started_at"))' + f" / {factor})" + ) + assert params == () + + def test_date_diff_month(self, postgres_dialect: PostgresDialect): + expr = date_diff( + postgres_dialect, + "month", + Column(postgres_dialect, "started_at"), + Column(postgres_dialect, "ended_at"), + ) + + sql, params = expr.to_sql() + + assert sql == ( + '((EXTRACT(YEAR FROM "ended_at") - EXTRACT(YEAR FROM "started_at")) ' + '* 12 + (EXTRACT(MONTH FROM "ended_at") - ' + 'EXTRACT(MONTH FROM "started_at")))' + ) + assert params == () + + def test_date_diff_month_literal_params(self, postgres_dialect: PostgresDialect): + expr = date_diff( + postgres_dialect, + "month", + Literal(postgres_dialect, "2026-01-01"), + Literal(postgres_dialect, "2026-06-01"), + ) + + sql, params = expr.to_sql() + + assert sql == ( + "((EXTRACT(YEAR FROM %s) - EXTRACT(YEAR FROM %s)) " + "* 12 + (EXTRACT(MONTH FROM %s) - EXTRACT(MONTH FROM %s)))" + ) + assert params == ( + "2026-06-01", + "2026-01-01", + "2026-06-01", + "2026-01-01", + ) + + def test_date_diff_year(self, postgres_dialect: PostgresDialect): + expr = date_diff( + postgres_dialect, + "year", + Column(postgres_dialect, "started_at"), + Column(postgres_dialect, "ended_at"), + ) + + sql, params = expr.to_sql() + + assert sql == ( + '(EXTRACT(YEAR FROM "ended_at") - EXTRACT(YEAR FROM "started_at"))' + ) + assert params == () + + def test_alias_and_cast(self, postgres_dialect: PostgresDialect): + expr = date_diff( + postgres_dialect, + "day", + Column(postgres_dialect, "started_at"), + Column(postgres_dialect, "ended_at"), + ).cast("INTEGER").as_("elapsed_days") + + sql, params = expr.to_sql() + + assert sql == ( + '(EXTRACT(EPOCH FROM ("ended_at" - "started_at")) / 86400)' + '::INTEGER AS "elapsed_days"' + ) + assert params == () + + def test_query_expression_integration(self, postgres_dialect: PostgresDialect): + shifted = date_add( + postgres_dialect, Column(postgres_dialect, "created_at"), 1, "day" + ) + query = QueryExpression( + postgres_dialect, + select=[ + extract(postgres_dialect, "year", Column(postgres_dialect, "created_at")) + ], + from_="events", + where=shifted > Literal(postgres_dialect, "2026-01-01"), + ) + + sql, params = query.to_sql() + + assert 'EXTRACT(YEAR FROM "created_at")' in sql + assert '"created_at" + INTERVAL \'1\' DAY > %s' in sql + assert params == ("2026-01-01",) From a5c2dcbbf2c98f7aeaf8f7503da6a917836c9ffe Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 5 Jun 2026 14:37:15 +0800 Subject: [PATCH 2/3] 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 --- ...gres_datetime_interval_explain_examples.py | 296 ++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_datetime_interval_explain_examples.py diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_datetime_interval_explain_examples.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_datetime_interval_explain_examples.py new file mode 100644 index 00000000..9f16564c --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_datetime_interval_explain_examples.py @@ -0,0 +1,296 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/ +# test_postgres_datetime_interval_explain_examples.py +"""PostgreSQL EXPLAIN examples for datetime interval expressions and indexes.""" + +from decimal import Decimal + +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + Literal, + LogicalPredicate, + QueryExpression, + TableExpression, +) +from rhosocial.activerecord.backend.expression.functions import ( + date_add, + date_diff, + date_sub, + date_trunc, + extract, +) +from rhosocial.activerecord.backend.expression.query_parts import OrderByClause +from rhosocial.activerecord.backend.impl.postgres import PostgresExplainResult +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + + +_SETUP_STMTS = [ + "DROP TABLE IF EXISTS explain_temporal_events CASCADE", + """CREATE TABLE explain_temporal_events ( + id SERIAL PRIMARY KEY, + category VARCHAR(32) NOT NULL, + created_at TIMESTAMP NOT NULL, + started_at TIMESTAMP NOT NULL, + ended_at TIMESTAMP NOT NULL + )""", + "CREATE INDEX idx_temporal_created_at ON explain_temporal_events(created_at)", + """CREATE INDEX idx_temporal_started_ended + ON explain_temporal_events(started_at, ended_at)""", + """CREATE INDEX idx_temporal_category_created + ON explain_temporal_events(category, created_at)""", + """INSERT INTO explain_temporal_events + (category, created_at, started_at, ended_at) + SELECT + CASE + WHEN n IN (55, 58, 62) THEN 'deploy' + WHEN n % 3 = 0 THEN 'billing' + WHEN n % 3 = 1 THEN 'report' + ELSE 'maintenance' + END, + TIMESTAMP '2026-01-01 00:00:00' + n * INTERVAL '1 day', + TIMESTAMP '2026-01-01 00:00:00' + n * INTERVAL '1 day' + + INTERVAL '10 minutes', + TIMESTAMP '2026-01-01 00:00:00' + n * INTERVAL '1 day' + + INTERVAL '40 minutes' + FROM generate_series(0, 199) AS seq(n)""", + "ANALYZE explain_temporal_events", +] + +_CLEANUP_STMTS = ["DROP TABLE IF EXISTS explain_temporal_events CASCADE"] +_DQL_OPTIONS = ExecutionOptions(stmt_type=StatementType.DQL) +_INDEX_SCAN_TERMS = ("INDEX SCAN", "BITMAP INDEX SCAN", "INDEX ONLY SCAN") +_DATETIME_INDEX_NAMES = ( + "IDX_TEMPORAL_CATEGORY_CREATED", + "IDX_TEMPORAL_CREATED_AT", +) + + +@pytest.fixture(scope="function") +def temporal_indexed_backend(postgres_backend): + for stmt in _SETUP_STMTS: + postgres_backend.execute(stmt) + yield postgres_backend + try: + for stmt in _CLEANUP_STMTS: + postgres_backend.execute(stmt) + except Exception: + pass + + +@pytest_asyncio.fixture(scope="function") +async def async_temporal_indexed_backend(async_postgres_backend): + for stmt in _SETUP_STMTS: + await async_postgres_backend.execute(stmt) + yield async_postgres_backend + try: + for stmt in _CLEANUP_STMTS: + await async_postgres_backend.execute(stmt) + except Exception: + pass + + +def _combined_plan(result: PostgresExplainResult) -> str: + return " ".join(row.line.upper() for row in result.rows) + + +def _assert_datetime_index_used(plan: str): + assert any(term in plan for term in _INDEX_SCAN_TERMS) + assert any(index_name in plan for index_name in _DATETIME_INDEX_NAMES) + + +def _range_filter(dialect, column_name: str, start: str, end: str): + return LogicalPredicate( + dialect, + "AND", + ComparisonPredicate( + dialect, ">=", Column(dialect, column_name), Literal(dialect, start) + ), + ComparisonPredicate(dialect, "<", Column(dialect, column_name), Literal(dialect, end)), + ) + + +def _category_created_filter(dialect): + return LogicalPredicate( + dialect, + "AND", + ComparisonPredicate( + dialect, "=", Column(dialect, "category"), Literal(dialect, "deploy") + ), + _range_filter( + dialect, + "created_at", + "2026-02-20 00:00:00", + "2026-03-10 00:00:00", + ), + ) + + +def _datetime_expression_query(dialect): + return QueryExpression( + dialect, + select=[ + Column(dialect, "id"), + extract(dialect, "year", Column(dialect, "created_at")).as_( + "created_year" + ), + date_trunc(dialect, "month", Column(dialect, "created_at")).as_( + "created_month" + ), + date_add(dialect, Column(dialect, "started_at"), 30, "minute").as_( + "starts_plus_30m" + ), + date_sub(dialect, Column(dialect, "ended_at"), 1, "hour").as_( + "ended_minus_1h" + ), + date_diff( + dialect, "minute", Column(dialect, "started_at"), Column(dialect, "ended_at") + ).as_("duration_minutes"), + ], + from_=TableExpression(dialect, "explain_temporal_events"), + where=_category_created_filter(dialect), + order_by=OrderByClause( + dialect, + [(Column(dialect, "category"), "ASC"), (Column(dialect, "created_at"), "ASC")], + ), + ) + + +def _assert_temporal_rows(rows): + assert rows is not None + assert len(rows) == 3 + assert rows[0]["created_year"] in (2026, Decimal("2026")) + assert rows[0]["created_month"] is not None + assert rows[0]["starts_plus_30m"] is not None + assert rows[0]["ended_minus_1h"] is not None + assert round(float(rows[0]["duration_minutes"])) == 30 + + +class TestSyncPostgresDateTimeIntervalExplainExamples: + def test_created_at_range_explain_returns_temporal_plan( + self, temporal_indexed_backend + ): + dialect = temporal_indexed_backend.dialect + result = temporal_indexed_backend.explain( + QueryExpression( + dialect, + select=[Column(dialect, "id"), Column(dialect, "created_at")], + from_=TableExpression(dialect, "explain_temporal_events"), + where=_range_filter( + dialect, + "created_at", + "2026-02-20 00:00:00", + "2026-03-10 00:00:00", + ), + ) + ) + + assert isinstance(result, PostgresExplainResult) + assert len(result.rows) > 0 + plan = _combined_plan(result) + assert "EXPLAIN_TEMPORAL_EVENTS" in plan + assert "SCAN" in plan + + def test_datetime_range_index_is_available_when_seqscan_disabled( + self, temporal_indexed_backend + ): + dialect = temporal_indexed_backend.dialect + temporal_indexed_backend.execute("SET enable_seqscan = off") + try: + result = temporal_indexed_backend.explain( + QueryExpression( + dialect, + select=[ + Column(dialect, "id"), + Column(dialect, "category"), + Column(dialect, "created_at"), + ], + from_=TableExpression(dialect, "explain_temporal_events"), + where=_category_created_filter(dialect), + ) + ) + finally: + temporal_indexed_backend.execute("RESET enable_seqscan") + + plan = _combined_plan(result) + _assert_datetime_index_used(plan) + assert result.is_index_used is True + assert result.is_full_scan is False + + def test_datetime_interval_expressions_work_with_indexed_filter( + self, temporal_indexed_backend + ): + dialect = temporal_indexed_backend.dialect + query = _datetime_expression_query(dialect) + temporal_indexed_backend.execute("SET enable_seqscan = off") + try: + explain_result = temporal_indexed_backend.explain( + query + ) + finally: + temporal_indexed_backend.execute("RESET enable_seqscan") + + plan = _combined_plan(explain_result) + _assert_datetime_index_used(plan) + assert explain_result.is_index_used is True + + query_result = temporal_indexed_backend.execute( + *query.to_sql(), + options=_DQL_OPTIONS, + ) + _assert_temporal_rows(query_result.data) + + +class TestAsyncPostgresDateTimeIntervalExplainExamples: + @pytest.mark.asyncio + async def test_datetime_range_index_is_available_when_seqscan_disabled( + self, async_temporal_indexed_backend + ): + dialect = async_temporal_indexed_backend.dialect + await async_temporal_indexed_backend.execute("SET enable_seqscan = off") + try: + result = await async_temporal_indexed_backend.explain( + QueryExpression( + dialect, + select=[ + Column(dialect, "id"), + Column(dialect, "category"), + Column(dialect, "created_at"), + ], + from_=TableExpression(dialect, "explain_temporal_events"), + where=_category_created_filter(dialect), + ) + ) + finally: + await async_temporal_indexed_backend.execute("RESET enable_seqscan") + + plan = _combined_plan(result) + _assert_datetime_index_used(plan) + assert result.is_index_used is True + + @pytest.mark.asyncio + async def test_datetime_interval_expressions_work_with_indexed_filter( + self, async_temporal_indexed_backend + ): + dialect = async_temporal_indexed_backend.dialect + query = _datetime_expression_query(dialect) + await async_temporal_indexed_backend.execute("SET enable_seqscan = off") + try: + explain_result = await async_temporal_indexed_backend.explain( + query + ) + finally: + await async_temporal_indexed_backend.execute("RESET enable_seqscan") + + plan = _combined_plan(explain_result) + _assert_datetime_index_used(plan) + assert explain_result.is_index_used is True + + query_result = await async_temporal_indexed_backend.execute( + *query.to_sql(), + options=_DQL_OPTIONS, + ) + _assert_temporal_rows(query_result.data) From 7be4627a5395d2cde29a82aa7ab306b4468710b8 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 5 Jun 2026 19:09:22 +0800 Subject: [PATCH 3/3] chore: add changelog fragment for PR #42 --- changelog.d/42.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/42.added.md diff --git a/changelog.d/42.added.md b/changelog.d/42.added.md new file mode 100644 index 00000000..36bca375 --- /dev/null +++ b/changelog.d/42.added.md @@ -0,0 +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.