-
Notifications
You must be signed in to change notification settings - Fork 741
Expand file tree
/
Copy pathbase.py
More file actions
112 lines (86 loc) · 4.07 KB
/
Copy pathbase.py
File metadata and controls
112 lines (86 loc) · 4.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
"""Common SQLModel base for everos tables.
:class:`BaseTable` adds ``created_at`` / ``updated_at`` columns. The
``updated_at`` column auto-refreshes on UPDATE through SA's ``onupdate``
hook (no explicit assignment needed in business code).
The **two-zone storage-UTC discipline** is enforced by a SQLAlchemy
:class:`TypeDecorator` (:class:`UtcDateTimeColumn`) used as the SQL
column type for every datetime field:
* **on write** — ``process_bind_param`` converts every datetime to
aware UTC before SQLAlchemy emits the bound parameter. This covers
*every* SQLAlchemy write path uniformly:
- ORM ``session.add()`` / ``session.merge()`` (unit-of-work flush)
- Core ``session.execute(insert(...).values(...))``
- Core ``session.execute(update(...).values(...))``
- Bulk ``bulk_insert_mappings`` / ``bulk_save_objects``
- Raw SQL with bound parameters
Reaching into the column type is the only place SQLAlchemy guarantees
*every* write path passes through. Mapper events (``before_insert`` /
``before_update``) only fire on the ORM unit-of-work path and would
silently miss Core statements — which :mod:`everos.infra.persistence
.sqlite.repos.md_change_state` uses heavily.
* **on read** — ``process_result_value`` re-attaches ``tzinfo=UTC`` to
every naive datetime returned from SQLite (which has no native tz
storage and always returns naive). Callers therefore never observe a
naive datetime regardless of which read API they use.
Subclass with ``table=True`` to declare a real SQLite table::
from sqlmodel import Field
class Sender(BaseTable, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
"""
from __future__ import annotations
import datetime as _dt
from typing import Any
from sqlalchemy import DateTime
from sqlalchemy import types as sa_types
from sqlmodel import Field, SQLModel
from everos.component.utils.datetime import UtcDatetime, ensure_utc, get_utc_now
class UtcDateTimeColumn(sa_types.TypeDecorator[_dt.datetime]):
"""SQLAlchemy column type enforcing storage-UTC on every read/write.
Implementation:
* ``impl = DateTime`` — uses the dialect's standard DateTime SQL type
(TEXT ISO-8601 on SQLite; ``TIMESTAMP`` on Postgres etc.).
* ``process_bind_param`` — write hook. Awares → ``astimezone(UTC)``;
naives → assumed already UTC (storage-boundary convention; see
:func:`ensure_utc` docstring); ``None`` passes through.
* ``process_result_value`` — read hook. Naive ``datetime`` →
``replace(tzinfo=UTC)``; aware passes through unchanged.
``cache_ok = True`` — SQLAlchemy can safely cache statement
compilations using this type (no per-instance mutable state).
"""
impl = DateTime
cache_ok = True
def process_bind_param(
self, value: _dt.datetime | None, _dialect: Any
) -> _dt.datetime | None:
if value is None:
return None
if not isinstance(value, _dt.datetime):
return value
return ensure_utc(value)
def process_result_value(
self, value: _dt.datetime | None, _dialect: Any
) -> _dt.datetime | None:
if value is None:
return None
if isinstance(value, _dt.datetime) and value.tzinfo is None:
return value.replace(tzinfo=_dt.UTC)
return value
class BaseTable(SQLModel):
"""Mixin providing ``created_at`` / ``updated_at`` columns.
Both default to :func:`get_utc_now` on INSERT.
``updated_at`` is auto-refreshed by SQLAlchemy on every UPDATE via the
``onupdate`` hook — do not set it manually unless overriding intentionally.
Both columns use :class:`UtcDateTimeColumn` as the SQL column type
so storage-UTC is enforced **at the SQLAlchemy bind layer** on every
write path (ORM + Core + bulk + raw bound params).
"""
created_at: UtcDatetime = Field(
default_factory=get_utc_now,
sa_type=UtcDateTimeColumn,
)
updated_at: UtcDatetime = Field(
default_factory=get_utc_now,
sa_type=UtcDateTimeColumn,
sa_column_kwargs={"onupdate": get_utc_now},
)