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
8 changes: 4 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@

- 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"

Check warning on line 70 in .github/workflows/test.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=rhosocial_python-activerecord-mariadb&issues=AaG7ee75hCsovMxdKs2I&open=AaG7ee75hCsovMxdKs2I&pullRequest=13

Check warning on line 70 in .github/workflows/test.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--only-binary :all:" can lead to the execution of setup scripts. Make sure it is safe here.

See more on https://sonarcloud.io/project/issues?id=rhosocial_python-activerecord-mariadb&issues=AaG7ee75hCsovMxdKs2H&open=AaG7ee75hCsovMxdKs2H&pullRequest=13

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

- 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"

Check warning on line 157 in .github/workflows/test.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=rhosocial_python-activerecord-mariadb&issues=AaG7ee75hCsovMxdKs2K&open=AaG7ee75hCsovMxdKs2K&pullRequest=13

Check warning on line 157 in .github/workflows/test.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--only-binary :all:" can lead to the execution of setup scripts. Make sure it is safe here.

See more on https://sonarcloud.io/project/issues?id=rhosocial_python-activerecord-mariadb&issues=AaG7ee75hCsovMxdKs2J&open=AaG7ee75hCsovMxdKs2J&pullRequest=13

- name: Install dependencies
run: |
Expand Down
2 changes: 2 additions & 0 deletions changelog.d/13.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Render `seconds_between` as `TIMESTAMPDIFF(MICROSECOND, ...) / 1000000`.

79 changes: 79 additions & 0 deletions docs/datetime-expressions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# MariaDB Datetime Expressions

Core's expression layer states the temporal questions and this backend spells
them. `seconds_between` is the one that needed a MariaDB spelling of its own:
the distance between two instants, signed, and fractional where the column
keeps a fraction.

## `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")
sql, params = elapsed.to_sql()
# sql: (
# TIMESTAMPDIFF(MICROSECOND, `queued_at`, `started_at`) / 1000000
# ) AS `queue_seconds`
# params: ()
```

The node answers with `end - start`, so the direction is part of the value:

| Case | Result |
|------|--------|
| `end` later than `start` | positive |
| `end` earlier than `start` | negative |
| same instant | `0` |
| a `DATETIME(6)` fraction present | kept, subject to the scale below |

`TIMESTAMPDIFF(SECOND, start, end)` is not used: it counts whole seconds and
drops the fraction. Counting in `MICROSECOND` and dividing by `1000000`
keeps the sub-second part that a `DATETIME(6)` column stores.

The quotient is MariaDB's exact division of two integers, so its scale is the
integer's scale plus `div_precision_increment` (4 by default). That is 0.1 ms
resolution out of the box. A caller that needs the full microsecond can raise
the session's `div_precision_increment` or cast the result to a wider decimal;
a caller that wants whole seconds rounds explicitly, because the node never
does.

## Operand order and binding

The operands are rendered in the SQL's order -- `start` first, `end` second --
so the bind parameters follow the placeholders rather than the constructor's
argument order:

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

expr = seconds_between(
dialect,
Literal(dialect, "2026-01-01 00:00:00"),
Literal(dialect, "2026-01-02 00:00:00"),
)
sql, params = expr.to_sql()
# sql: "(TIMESTAMPDIFF(MICROSECOND, %s, %s) / 1000000)"
# params: ("2026-01-01 00:00:00", "2026-01-02 00:00:00")
```

The division is parenthesised, so the node composes as an operand and
`.as_("...")` applies to the whole value rather than to the second argument of
`TIMESTAMPDIFF`.

## Contract

This backend implements core's one dialect hook:

```python
def format_seconds_between_expression(self, expr) -> Tuple[str, Tuple]:
```

The signature, the sign rule and the alias behaviour are core's. What MariaDB
adds is the spelling and the `MICROSECOND` unit; the full cross-backend table
is in
[core's expression docs](https://github.com/rhosocial/python-activerecord/blob/main/docs/en_US/backend/expression/functions.md#seconds_between).
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,23 @@ def format_datetime_diff_expression(self, expr: "Any") -> Tuple[str, tuple]:
sql = f"TIMESTAMPDIFF({expr.unit.value.upper()}, {start_sql}, {end_sql})"
return self.apply_alias(sql, start_params + end_params, expr)

def format_seconds_between_expression(self, expr: "Any") -> Tuple[str, tuple]:
"""Format the signed seconds between two instants for MariaDB.

``TIMESTAMPDIFF`` counts ``end - start`` and keeps the direction, so an
``end`` earlier than the ``start`` is negative. Counting in
``MICROSECOND`` and dividing by ``1000000`` keeps the sub-second part
that ``TIMESTAMPDIFF(SECOND, ...)`` would drop, and the division is
parenthesised so the node composes as an operand.

The operands are rendered in the order the SQL writes them -- ``start``
then ``end`` -- so the bind parameters follow that order rather than the
constructor's argument order.
"""
start_sql, start_params = expr.start.to_sql()
end_sql, end_params = expr.end.to_sql()
sql = f"(TIMESTAMPDIFF(MICROSECOND, {start_sql}, {end_sql}) / 1000000)"
return self.apply_alias(sql, start_params + end_params, expr)


__all__ = ['MariaDBDateTimeMixin']
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
# tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_seconds_between.py
"""MariaDB's spelling for the signed seconds between two instants.

``seconds_between`` is a duration, so the cases that matter are the ones a
component sum gets wrong: the sign, the zero, the fraction and the boundaries
where a month or a year changes. MariaDB counts in ``MICROSECOND`` and divides
by ``1000000`` so both the direction and the sub-second part survive.

The render tests pin the spelling, the bind order and the alias; the live tests
run the same expression against each configured server and read the number back.
"""

import pytest

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


DQL_OPTIONS = ExecutionOptions(stmt_type=StatementType.DQL)

RENDERED = (
"(TIMESTAMPDIFF(MICROSECOND, `started_at`, `ended_at`) / 1000000)"
)


class TestMariaDBSecondsBetweenSpelling:
"""The SQL MariaDB's formatter produces, without a server."""

def test_columns_render_timestampdiff_microseconds(self, mariadb_dialect):
expr = seconds_between(
mariadb_dialect,
Column(mariadb_dialect, "started_at"),
Column(mariadb_dialect, "ended_at"),
)

sql, params = expr.to_sql()

assert sql == RENDERED
assert params == ()

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

sql, params = expr.to_sql()

assert sql == "(TIMESTAMPDIFF(MICROSECOND, %s, %s) / 1000000)"
# ``start`` is written first, so its parameter binds first.
assert params == ("2026-01-01 00:00:00", "2026-01-02 00:00:00")

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

sql, params = expr.to_sql()

assert sql == f"{RENDERED} AS `elapsed`"
assert params == ()

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

sql, params = expr.to_sql()

assert sql == RENDERED
assert params == ()

def test_column_method(self, mariadb_dialect):
started = TimestampColumn(mariadb_dialect, "started_at")
ended = TimestampColumn(mariadb_dialect, "ended_at")

expr = started.seconds_between(ended)

sql, params = expr.to_sql()

assert sql == RENDERED
assert params == ()

def test_node_is_a_fractional_number(self, mariadb_dialect):
expr = seconds_between(
mariadb_dialect,
Column(mariadb_dialect, "started_at"),
Column(mariadb_dialect, "ended_at"),
)

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

def test_select_keeps_the_authored_alias(self, mariadb_dialect):
expr = seconds_between(
mariadb_dialect,
Column(mariadb_dialect, "started_at"),
Column(mariadb_dialect, "ended_at"),
).as_("queue_seconds")
query = QueryStatement(mariadb_dialect, select=[expr])

sql, params = query.to_sql()

assert sql == (
"SELECT (TIMESTAMPDIFF(MICROSECOND, `started_at`, `ended_at`) "
"/ 1000000) AS `queue_seconds`"
)
assert params == ()


def _elapsed(backend, start: str, end: str) -> float:
"""Run ``seconds_between(start, end)`` and return the scalar."""
dialect = backend.dialect
expr = seconds_between(dialect, Literal(dialect, start), Literal(dialect, end))
query = QueryStatement(dialect, select=[expr.as_("seconds")])
sql, params = query.to_sql()
result = backend.execute(sql, params, options=DQL_OPTIONS)
return float(result.data[0]["seconds"])


async def _async_elapsed(backend, start: str, end: str) -> float:
"""Run ``seconds_between(start, end)`` asynchronously and return the scalar."""
dialect = backend.dialect
expr = seconds_between(dialect, Literal(dialect, start), Literal(dialect, end))
query = QueryStatement(dialect, select=[expr.as_("seconds")])
sql, params = query.to_sql()
result = await backend.execute(sql, params, options=DQL_OPTIONS)
return float(result.data[0]["seconds"])


class TestMariaDBSecondsBetweenExecution:
"""The value the MariaDB spelling actually produces."""

@pytest.mark.parametrize(
"start,end,expected",
[
("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),
],
)
def test_interval(self, mariadb_backend_single, start: str, end: str, expected: float):
assert _elapsed(mariadb_backend_single, start, end) == pytest.approx(expected, abs=1e-6)

def test_columns_in_a_row(self, mariadb_backend_single):
"""The same question asked of two columns, not two literals."""
table = "mariadb_seconds_between_spans"
mariadb_backend_single.executescript(
f"""
DROP TABLE IF EXISTS {table};
CREATE TABLE {table} (
id INT PRIMARY KEY,
started_at DATETIME(6),
ended_at DATETIME(6)
);
INSERT INTO {table} (id, started_at, ended_at) VALUES
(1, '2026-03-01 00:00:00', '2026-03-02 00:00:00'),
(2, '2026-03-02 00:00:00', '2026-03-01 00:00:00'),
(3, '2026-03-01 00:00:00.750', '2026-03-01 00:00:01.000');
"""
)

dialect = mariadb_backend_single.dialect
expr = seconds_between(
dialect,
Column(dialect, "started_at"),
Column(dialect, "ended_at"),
)
query = QueryStatement(
dialect,
select=[expr.as_("seconds"), Column(dialect, "id")],
from_=table,
)
sql, params = query.to_sql()

try:
result = mariadb_backend_single.execute(sql, params, options=DQL_OPTIONS)
rows = {row["id"]: float(row["seconds"]) for row in result.data}
assert rows[1] == pytest.approx(86400.0, abs=1e-6)
assert rows[2] == pytest.approx(-86400.0, abs=1e-6)
assert rows[3] == pytest.approx(0.25, abs=1e-6)
finally:
mariadb_backend_single.execute(f"DROP TABLE IF EXISTS {table}")


class TestAsyncMariaDBSecondsBetweenExecution:
"""The same values, read through the async backend."""

@pytest.mark.asyncio
@pytest.mark.parametrize(
"start,end,expected",
[
("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),
],
)
async def test_interval(
self, async_mariadb_backend_single, start: str, end: str, expected: float
):
assert await _async_elapsed(
async_mariadb_backend_single, start, end
) == pytest.approx(expected, abs=1e-6)

@pytest.mark.asyncio
async def test_columns_in_a_row(self, async_mariadb_backend_single):
table = "mariadb_seconds_between_spans_async"
await async_mariadb_backend_single.executescript(
f"""
DROP TABLE IF EXISTS {table};
CREATE TABLE {table} (
id INT PRIMARY KEY,
started_at DATETIME(6),
ended_at DATETIME(6)
);
INSERT INTO {table} (id, started_at, ended_at) VALUES
(1, '2026-03-01 00:00:00', '2026-03-02 00:00:00'),
(2, '2026-03-02 00:00:00', '2026-03-01 00:00:00'),
(3, '2026-03-01 00:00:00.750', '2026-03-01 00:00:01.000');
"""
)

dialect = async_mariadb_backend_single.dialect
expr = seconds_between(
dialect,
Column(dialect, "started_at"),
Column(dialect, "ended_at"),
)
query = QueryStatement(
dialect,
select=[expr.as_("seconds"), Column(dialect, "id")],
from_=table,
)
sql, params = query.to_sql()

try:
result = await async_mariadb_backend_single.execute(
sql, params, options=DQL_OPTIONS
)
rows = {row["id"]: float(row["seconds"]) for row in result.data}
assert rows[1] == pytest.approx(86400.0, abs=1e-6)
assert rows[2] == pytest.approx(-86400.0, abs=1e-6)
assert rows[3] == pytest.approx(0.25, abs=1e-6)
finally:
await async_mariadb_backend_single.execute(f"DROP TABLE IF EXISTS {table}")
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# tests/rhosocial/activerecord_mariadb_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