snake-orm 0.1.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- snake_orm-0.1.0b1.dist-info/METADATA +366 -0
- snake_orm-0.1.0b1.dist-info/RECORD +198 -0
- snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
- snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
- snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- snakeorm/__init__.py +214 -0
- snakeorm/advisor.py +109 -0
- snakeorm/cli/__init__.py +3 -0
- snakeorm/cli/app.py +992 -0
- snakeorm/cli/discovery.py +179 -0
- snakeorm/cli/hooks.py +86 -0
- snakeorm/compiler/__init__.py +3 -0
- snakeorm/compiler/compiler.py +330 -0
- snakeorm/connection.py +228 -0
- snakeorm/contrib/__init__.py +17 -0
- snakeorm/contrib/asgi.py +192 -0
- snakeorm/contrib/config.py +264 -0
- snakeorm/contrib/deliver.py +256 -0
- snakeorm/contrib/django.py +245 -0
- snakeorm/contrib/sidecar.py +44 -0
- snakeorm/contrib/wsgi.py +176 -0
- snakeorm/core/__init__.py +1 -0
- snakeorm/core/config.py +173 -0
- snakeorm/core/converters.py +172 -0
- snakeorm/core/exceptions.py +175 -0
- snakeorm/core/placement.py +15 -0
- snakeorm/core/sentinels.py +37 -0
- snakeorm/core/signals.py +114 -0
- snakeorm/debug/__init__.py +42 -0
- snakeorm/debug/assets/css/panel.css +616 -0
- snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snakeorm/debug/assets/img/icon.svg +5 -0
- snakeorm/debug/assets/js/history.js +425 -0
- snakeorm/debug/assets/js/language.js +283 -0
- snakeorm/debug/assets/js/panel.js +241 -0
- snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snakeorm/debug/assets/pages/cli.html +28 -0
- snakeorm/debug/assets/pages/config.html +34 -0
- snakeorm/debug/assets/pages/database_first.html +29 -0
- snakeorm/debug/assets/pages/help.html +21 -0
- snakeorm/debug/assets/pages/history.html +27 -0
- snakeorm/debug/capture.py +234 -0
- snakeorm/debug/channel.py +131 -0
- snakeorm/debug/collector.py +144 -0
- snakeorm/debug/config.py +149 -0
- snakeorm/debug/html.py +578 -0
- snakeorm/debug/matrix.py +60 -0
- snakeorm/debug/origin.py +42 -0
- snakeorm/debug/otel/__init__.py +68 -0
- snakeorm/debug/otel/context.py +43 -0
- snakeorm/debug/otel/exporter.py +277 -0
- snakeorm/debug/otel/payload.py +97 -0
- snakeorm/debug/otel/report.py +29 -0
- snakeorm/debug/otel/spans.py +393 -0
- snakeorm/debug/otel/summary.py +59 -0
- snakeorm/debug/record.py +54 -0
- snakeorm/debug/report.py +311 -0
- snakeorm/debug/testing.py +23 -0
- snakeorm/decorators/__init__.py +13 -0
- snakeorm/decorators/abstract.py +26 -0
- snakeorm/decorators/db_first.py +104 -0
- snakeorm/decorators/function.py +28 -0
- snakeorm/decorators/model.py +268 -0
- snakeorm/decorators/polymorphic.py +140 -0
- snakeorm/decorators/result.py +167 -0
- snakeorm/decorators/row.py +90 -0
- snakeorm/decorators/trigger.py +47 -0
- snakeorm/decorators/view.py +149 -0
- snakeorm/dialects/__init__.py +6 -0
- snakeorm/dialects/base.py +373 -0
- snakeorm/dialects/capabilities.py +571 -0
- snakeorm/dialects/literals.py +46 -0
- snakeorm/dialects/matrix.py +384 -0
- snakeorm/dialects/mysql.py +519 -0
- snakeorm/dialects/postgres.py +504 -0
- snakeorm/dialects/sqlite.py +417 -0
- snakeorm/drivers/__init__.py +18 -0
- snakeorm/drivers/asyncbase.py +73 -0
- snakeorm/drivers/asyncdecorators.py +244 -0
- snakeorm/drivers/asyncpool.py +228 -0
- snakeorm/drivers/asyncpsycopg.py +124 -0
- snakeorm/drivers/asyncpymysql.py +50 -0
- snakeorm/drivers/asyncsqlite.py +30 -0
- snakeorm/drivers/base.py +76 -0
- snakeorm/drivers/failures.py +181 -0
- snakeorm/drivers/logging.py +167 -0
- snakeorm/drivers/pool.py +352 -0
- snakeorm/drivers/psycopg.py +188 -0
- snakeorm/drivers/pymysql.py +176 -0
- snakeorm/drivers/savepoints.py +46 -0
- snakeorm/drivers/sqlite.py +170 -0
- snakeorm/drivers/threaded.py +143 -0
- snakeorm/drivers/timeout.py +87 -0
- snakeorm/dto/__init__.py +55 -0
- snakeorm/dto/read.py +231 -0
- snakeorm/dto/region.py +326 -0
- snakeorm/dto/resolve.py +354 -0
- snakeorm/dto/spec.py +201 -0
- snakeorm/expressions/__init__.py +88 -0
- snakeorm/expressions/conditional.py +178 -0
- snakeorm/expressions/expression.py +810 -0
- snakeorm/expressions/functions.py +71 -0
- snakeorm/expressions/keys.py +299 -0
- snakeorm/expressions/paths.py +43 -0
- snakeorm/expressions/scalar.py +293 -0
- snakeorm/expressions/window.py +248 -0
- snakeorm/fields/__init__.py +68 -0
- snakeorm/fields/check.py +79 -0
- snakeorm/fields/column.py +316 -0
- snakeorm/fields/enum.py +64 -0
- snakeorm/fields/index.py +46 -0
- snakeorm/fields/relationship.py +738 -0
- snakeorm/fields/typed.py +736 -0
- snakeorm/helpers/__init__.py +1 -0
- snakeorm/helpers/annotations.py +21 -0
- snakeorm/helpers/inheritance.py +41 -0
- snakeorm/helpers/pyliteral.py +52 -0
- snakeorm/helpers/pytype.py +50 -0
- snakeorm/introspection/__init__.py +13 -0
- snakeorm/introspection/base.py +30 -0
- snakeorm/introspection/drift.py +78 -0
- snakeorm/introspection/models.py +633 -0
- snakeorm/introspection/mysql.py +367 -0
- snakeorm/introspection/postgres.py +397 -0
- snakeorm/introspection/sqlite.py +217 -0
- snakeorm/introspection/unsupported.py +94 -0
- snakeorm/linker/__init__.py +3 -0
- snakeorm/linker/linker.py +521 -0
- snakeorm/metadata/__init__.py +41 -0
- snakeorm/metadata/check.py +37 -0
- snakeorm/metadata/column.py +201 -0
- snakeorm/metadata/enum_storage.py +33 -0
- snakeorm/metadata/fk_action.py +19 -0
- snakeorm/metadata/foreign_key.py +26 -0
- snakeorm/metadata/index.py +43 -0
- snakeorm/metadata/index_method.py +19 -0
- snakeorm/metadata/int_size.py +20 -0
- snakeorm/metadata/json_storage.py +21 -0
- snakeorm/metadata/polymorphic.py +30 -0
- snakeorm/metadata/primary_key.py +23 -0
- snakeorm/metadata/relationship.py +60 -0
- snakeorm/metadata/relationship_kind.py +43 -0
- snakeorm/metadata/routine.py +26 -0
- snakeorm/metadata/server_default.py +20 -0
- snakeorm/metadata/table.py +97 -0
- snakeorm/metadata/table_kind.py +23 -0
- snakeorm/metadata/trigger.py +62 -0
- snakeorm/metadata/type_params.py +348 -0
- snakeorm/migration/__init__.py +88 -0
- snakeorm/migration/asyncrunner.py +168 -0
- snakeorm/migration/autodetect.py +96 -0
- snakeorm/migration/ddl.py +1172 -0
- snakeorm/migration/diff.py +701 -0
- snakeorm/migration/loader.py +77 -0
- snakeorm/migration/operations.py +952 -0
- snakeorm/migration/planner.py +78 -0
- snakeorm/migration/realize.py +420 -0
- snakeorm/migration/renames.py +153 -0
- snakeorm/migration/render.py +1095 -0
- snakeorm/migration/runner.py +314 -0
- snakeorm/migration/squash.py +60 -0
- snakeorm/migration/state.py +107 -0
- snakeorm/model.py +162 -0
- snakeorm/py.typed +0 -0
- snakeorm/query/__init__.py +9 -0
- snakeorm/query/compound.py +288 -0
- snakeorm/query/join_kind.py +19 -0
- snakeorm/query/joined.py +143 -0
- snakeorm/query/query.py +1001 -0
- snakeorm/query/recursive.py +244 -0
- snakeorm/registry/__init__.py +5 -0
- snakeorm/registry/by_module.py +163 -0
- snakeorm/registry/registry.py +166 -0
- snakeorm/session/__init__.py +8 -0
- snakeorm/session/asyncsession.py +638 -0
- snakeorm/session/coercion.py +306 -0
- snakeorm/session/factory.py +29 -0
- snakeorm/session/guards.py +269 -0
- snakeorm/session/isolation.py +23 -0
- snakeorm/session/mapper.py +213 -0
- snakeorm/session/planning.py +815 -0
- snakeorm/session/retry.py +96 -0
- snakeorm/session/session.py +871 -0
- snakeorm/session/shared.py +355 -0
- snakeorm/sql/__init__.py +18 -0
- snakeorm/sql/adapt.py +104 -0
- snakeorm/sql/aggregate.py +232 -0
- snakeorm/sql/condition.py +359 -0
- snakeorm/sql/delete.py +46 -0
- snakeorm/sql/insert.py +149 -0
- snakeorm/sql/joins.py +158 -0
- snakeorm/sql/pk_subquery.py +54 -0
- snakeorm/sql/refs.py +19 -0
- snakeorm/sql/resolver.py +28 -0
- snakeorm/sql/select.py +175 -0
- snakeorm/sql/update.py +82 -0
- snakeorm/sql/value.py +552 -0
- snakeorm/times.py +276 -0
snakeorm/drivers/base.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The Protocol that defines a driver: how the SQL is EXECUTED (synchronous)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Sequence
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class SnakeDriver(Protocol):
|
|
11
|
+
"""How the SQL is executed: wraps the DBAPI (connection, cursor, transaction).
|
|
12
|
+
|
|
13
|
+
SYNCHRONOUS. The SQL arrives already compiled (colourless) from `sql/`; the driver only
|
|
14
|
+
runs it. The day async is needed, an AsyncDriver is added WITHOUT touching the compiler,
|
|
15
|
+
the dialect or the AST. Adding a new engine = one implementation of this Protocol, with
|
|
16
|
+
no refactor.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def fetch_all(self, sql: str, params: Sequence[object]) -> list[tuple[object, ...]]:
|
|
20
|
+
"""Runs a query and returns every row (for SELECT / RETURNING)."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def fetch_iter(
|
|
24
|
+
self, sql: str, params: Sequence[object], *, chunk: int = 1000
|
|
25
|
+
) -> Iterator[tuple[object, ...]]:
|
|
26
|
+
"""Runs a query and yields the rows WITHOUT materialising them all.
|
|
27
|
+
|
|
28
|
+
This is the streaming seam, and that is why it lives in the Protocol and not in some
|
|
29
|
+
loose method on one driver: with only `fetch_all`, a ten-million-row query built a
|
|
30
|
+
Python list of ten million tuples before returning the first one, and there was no way
|
|
31
|
+
to fix that without touching this contract.
|
|
32
|
+
|
|
33
|
+
`chunk` is how many rows the engine brings back per round trip: the knob that decides
|
|
34
|
+
the memory. Whoever can, uses a SERVER-SIDE CURSOR (the result stays over there);
|
|
35
|
+
whoever cannot, a `fetchmany`, which at least bounds the peak.
|
|
36
|
+
|
|
37
|
+
The cursor lives for as long as the iteration does, so the consumer must exhaust it or
|
|
38
|
+
close it.
|
|
39
|
+
"""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
def execute(self, sql: str, params: Sequence[object]) -> int:
|
|
43
|
+
"""Runs a statement fetching no rows and returns the rowcount (used by bulk writes)."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def last_insert_id(self) -> int:
|
|
48
|
+
"""The autoincrement id of the last INSERT. The session only uses it on engines WITHOUT
|
|
49
|
+
`RETURNING` (MySQL); on Postgres/SQLite the PK comes back through RETURNING and this is
|
|
50
|
+
irrelevant (it may be 0).
|
|
51
|
+
"""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
def commit(self) -> None:
|
|
55
|
+
"""Commits the transaction in progress."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
def rollback(self) -> None:
|
|
59
|
+
"""Rolls back the transaction in progress."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
def savepoint(self, name: str) -> None:
|
|
63
|
+
"""Marks a SAVEPOINT: lets you roll back ONLY a part (via `rollback_to_savepoint`) without aborting the transaction."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
def release_savepoint(self, name: str) -> None:
|
|
67
|
+
"""Releases (RELEASE) a savepoint: its work is folded into the transaction in progress."""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
def rollback_to_savepoint(self, name: str) -> None:
|
|
71
|
+
"""Rolls back to a savepoint: discards what was done since it, without aborting the transaction."""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
"""Closes the connection."""
|
|
76
|
+
...
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Turning a driver's exception into the ORM's, by CODE and never by message.
|
|
2
|
+
|
|
3
|
+
A violated constraint came back as `psycopg2.errors.UniqueViolation`, `pymysql.err.IntegrityError`
|
|
4
|
+
and `sqlite3.IntegrityError`: three types for one condition, so the `except` that handled it was the
|
|
5
|
+
one part of an application this ORM could not carry between engines.
|
|
6
|
+
|
|
7
|
+
WHY BY CODE. Reading the message is how a detector fails open — it finds only what somebody already
|
|
8
|
+
thought to look for. It is also not needed: all three engines say precisely which constraint broke,
|
|
9
|
+
each in its own place.
|
|
10
|
+
|
|
11
|
+
postgres SQLSTATE 23505 / 23503 / 23502 / 23514
|
|
12
|
+
mysql errno 1062 / 1452 / 1048 / 4025
|
|
13
|
+
(its SQLSTATE is 23000 for all four and says nothing)
|
|
14
|
+
sqlite sqlite_errorname SQLITE_CONSTRAINT_UNIQUE / _FOREIGNKEY / _NOTNULL / _CHECK
|
|
15
|
+
(Python 3.11+, which is this project's declared floor)
|
|
16
|
+
|
|
17
|
+
AND NEVER BY THE DRIVER'S CLASS, which is the trap this module exists to keep shut: on MySQL a CHECK
|
|
18
|
+
violation arrives as `OperationalError` while the other three arrive as `IntegrityError`. Keyed on
|
|
19
|
+
the class, that one lands in the wrong bucket on one engine only.
|
|
20
|
+
|
|
21
|
+
One function for the three engines, the same way `session/retry.py::is_transient` reads them: it
|
|
22
|
+
looks for each spelling in turn and does not need to know who raised.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import functools
|
|
28
|
+
from collections.abc import Awaitable, Callable, Iterator
|
|
29
|
+
from contextlib import contextmanager
|
|
30
|
+
from typing import ParamSpec, TypeVar, cast
|
|
31
|
+
|
|
32
|
+
from snakeorm.core.exceptions import (
|
|
33
|
+
SnakeCheckViolation,
|
|
34
|
+
SnakeForeignKeyViolation,
|
|
35
|
+
SnakeIntegrityError,
|
|
36
|
+
SnakeNotNullViolation,
|
|
37
|
+
SnakeUniqueViolation,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
P = ParamSpec("P")
|
|
41
|
+
T = TypeVar("T")
|
|
42
|
+
AsyncMethod = TypeVar("AsyncMethod", bound=Callable[..., Awaitable[object]])
|
|
43
|
+
"""An asynchronous driver method, PRESERVED. Bound and not `ParamSpec` + return, because what
|
|
44
|
+
has to survive the decorator is the exact signature the Protocol declares, `CoroutineType`
|
|
45
|
+
included."""
|
|
46
|
+
|
|
47
|
+
_BY_SQLSTATE: dict[str, type[SnakeIntegrityError]] = {
|
|
48
|
+
"23505": SnakeUniqueViolation,
|
|
49
|
+
"23503": SnakeForeignKeyViolation,
|
|
50
|
+
"23502": SnakeNotNullViolation,
|
|
51
|
+
"23514": SnakeCheckViolation,
|
|
52
|
+
}
|
|
53
|
+
"""PostgreSQL, which is the only one whose SQLSTATE distinguishes the four."""
|
|
54
|
+
|
|
55
|
+
_BY_MYSQL_ERRNO: dict[int, type[SnakeIntegrityError]] = {
|
|
56
|
+
1062: SnakeUniqueViolation, # ER_DUP_ENTRY
|
|
57
|
+
1586: SnakeUniqueViolation, # ER_DUP_ENTRY_WITH_KEY_NAME
|
|
58
|
+
1452: SnakeForeignKeyViolation, # ER_NO_REFERENCED_ROW_2
|
|
59
|
+
1451: SnakeForeignKeyViolation, # ER_ROW_IS_REFERENCED_2 (the delete side)
|
|
60
|
+
1048: SnakeNotNullViolation, # ER_BAD_NULL_ERROR
|
|
61
|
+
3819: SnakeCheckViolation, # ER_CHECK_CONSTRAINT_VIOLATED (MySQL 8)
|
|
62
|
+
4025: SnakeCheckViolation, # ER_CONSTRAINT_FAILED (MariaDB)
|
|
63
|
+
}
|
|
64
|
+
"""MySQL and MariaDB. Two errnos per family where the engines disagree, and both are listed rather
|
|
65
|
+
than one guessed: MariaDB reports a CHECK as 4025 and MySQL 8 as 3819, and a foreign key breaks from
|
|
66
|
+
either side."""
|
|
67
|
+
|
|
68
|
+
_BY_SQLITE_NAME: dict[str, type[SnakeIntegrityError]] = {
|
|
69
|
+
"SQLITE_CONSTRAINT_UNIQUE": SnakeUniqueViolation,
|
|
70
|
+
"SQLITE_CONSTRAINT_PRIMARYKEY": SnakeUniqueViolation,
|
|
71
|
+
"SQLITE_CONSTRAINT_FOREIGNKEY": SnakeForeignKeyViolation,
|
|
72
|
+
"SQLITE_CONSTRAINT_NOTNULL": SnakeNotNullViolation,
|
|
73
|
+
"SQLITE_CONSTRAINT_CHECK": SnakeCheckViolation,
|
|
74
|
+
}
|
|
75
|
+
"""SQLite's EXTENDED result codes. `PRIMARYKEY` is its own name for what the other two call a unique
|
|
76
|
+
violation, so it maps to the same exception rather than falling through to the generic family."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_SAYS: dict[type[SnakeIntegrityError], str] = {
|
|
80
|
+
SnakeUniqueViolation: "a UNIQUE constraint refused the write: that value is already there",
|
|
81
|
+
SnakeForeignKeyViolation: (
|
|
82
|
+
"a FOREIGN KEY refused the write: the row it points at is not there, or it is still "
|
|
83
|
+
"pointed at"
|
|
84
|
+
),
|
|
85
|
+
SnakeNotNullViolation: "a NOT NULL column was given nothing",
|
|
86
|
+
SnakeCheckViolation: "a CHECK constraint refused the write",
|
|
87
|
+
}
|
|
88
|
+
"""What the ORM says, in its OWN words, before quoting the engine.
|
|
89
|
+
|
|
90
|
+
Passing the driver's text through unchanged was half a translation: the exception was portable and
|
|
91
|
+
the sentence it carried still was not, so anything reading the message was back to three engines.
|
|
92
|
+
In this ORM the message is the product.
|
|
93
|
+
|
|
94
|
+
The engine's own words follow, because they carry what these cannot — WHICH constraint, and the
|
|
95
|
+
value that collided. Two texts for one fact, in the right order: what happened, then who said so.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def translate(error: BaseException) -> SnakeIntegrityError | None:
|
|
100
|
+
"""The ORM exception this driver error corresponds to, or `None` if it is not a constraint.
|
|
101
|
+
|
|
102
|
+
`None` is the answer for everything else — a syntax error, a dropped connection, a timeout — and
|
|
103
|
+
those keep flying untouched. Wrapping what is not understood would put an ORM name on a problem
|
|
104
|
+
the ORM did not diagnose, which is worse than the raw exception it replaced.
|
|
105
|
+
"""
|
|
106
|
+
for attribute in ("sqlstate", "pgcode"):
|
|
107
|
+
sqlstate = getattr(error, attribute, None)
|
|
108
|
+
if isinstance(sqlstate, str) and sqlstate in _BY_SQLSTATE:
|
|
109
|
+
return _built(_BY_SQLSTATE[sqlstate], error, sqlstate)
|
|
110
|
+
name = getattr(error, "sqlite_errorname", None)
|
|
111
|
+
if isinstance(name, str) and name in _BY_SQLITE_NAME:
|
|
112
|
+
return _built(_BY_SQLITE_NAME[name], error, name)
|
|
113
|
+
errno = error.args[0] if error.args else None
|
|
114
|
+
if isinstance(errno, int) and errno in _BY_MYSQL_ERRNO:
|
|
115
|
+
return _built(_BY_MYSQL_ERRNO[errno], error, errno)
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _built(
|
|
120
|
+
kind: type[SnakeIntegrityError], error: BaseException, code: str | int
|
|
121
|
+
) -> SnakeIntegrityError:
|
|
122
|
+
"""The exception, its message and what it was built from, in one place."""
|
|
123
|
+
return kind(
|
|
124
|
+
f"{_SAYS[kind]}. The engine said: {error}", driver_error=error, code=code
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@contextmanager
|
|
129
|
+
def translating_failures() -> Iterator[None]:
|
|
130
|
+
"""Runs a driver call and re-raises a constraint failure as the ORM's own, CHAINED.
|
|
131
|
+
|
|
132
|
+
`from error` is the whole difference between wrapping and hiding: the original stays in
|
|
133
|
+
`__cause__`, the traceback prints both, and whoever debugs still gets the server's own words
|
|
134
|
+
about what it refused.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
yield
|
|
138
|
+
except Exception as error:
|
|
139
|
+
translated = translate(error)
|
|
140
|
+
if translated is None:
|
|
141
|
+
raise
|
|
142
|
+
raise translated from error
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def translating(method: Callable[P, T]) -> Callable[P, T]:
|
|
146
|
+
"""Applies the translation to one synchronous driver method.
|
|
147
|
+
|
|
148
|
+
A decorator and not a `with` inside each body, because it is the same line twenty times and a
|
|
149
|
+
decorator is the version somebody can see is MISSING from a method.
|
|
150
|
+
|
|
151
|
+
Not for `fetch_iter`: it is a generator, so this wrapper would return it without running a
|
|
152
|
+
statement and the exception would escape untranslated during iteration. It is also not needed —
|
|
153
|
+
it walks a SELECT, and a SELECT breaks no constraint.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
@functools.wraps(method)
|
|
157
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
158
|
+
with translating_failures():
|
|
159
|
+
return method(*args, **kwargs)
|
|
160
|
+
|
|
161
|
+
return wrapper
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def async_translating(method: AsyncMethod) -> AsyncMethod:
|
|
165
|
+
"""The same for an asynchronous driver method. The colour changes; the classification does not.
|
|
166
|
+
|
|
167
|
+
It gives BACK the type it was handed, instead of describing the wrapper: a decorator that
|
|
168
|
+
translates exceptions must not change what the method IS. Spelling the return as
|
|
169
|
+
`Callable[P, Coroutine[object, object, T]]` looked equivalent and was not — an `async def`
|
|
170
|
+
declared in a Protocol is a `CoroutineType`, which a plain `Coroutine` does not satisfy, so
|
|
171
|
+
every driver wrapped by this stopped being an `AsyncDriver` for pyright while mypy said
|
|
172
|
+
nothing. The one function that hands the pair out (`connection.py::driver_and_dialect`) was
|
|
173
|
+
the visible casualty.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
@functools.wraps(method)
|
|
177
|
+
async def wrapper(*args: object, **kwargs: object) -> object:
|
|
178
|
+
with translating_failures():
|
|
179
|
+
return await method(*args, **kwargs)
|
|
180
|
+
|
|
181
|
+
return cast("AsyncMethod", wrapper)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""A driver that LOGS the SQL passing through it, by wrapping another one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
|
|
8
|
+
from snakeorm.drivers.base import SnakeDriver
|
|
9
|
+
|
|
10
|
+
# Where each line gets written. By default it writes nothing (leave it wired up without making a mess); inject `print`/a logger.
|
|
11
|
+
Writer = Callable[[str], None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _silent(line: str) -> None:
|
|
15
|
+
"""Throws the line away (the default behaviour)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_params(params: Sequence[object], keys: frozenset[str]) -> str:
|
|
19
|
+
"""The named parameters, the rest hidden, and the count always.
|
|
20
|
+
|
|
21
|
+
The guide wires these drivers with `write=print`, whose output is the container's stdout and
|
|
22
|
+
from there the log aggregator. So `params=('ana@x.com', '$2b$12$...')` was an email and a
|
|
23
|
+
password hash sitting in a searchable index, with no flag to turn it off.
|
|
24
|
+
|
|
25
|
+
Written ONCE for both colours on purpose: the asynchronous driver went on printing raw
|
|
26
|
+
values after the synchronous one stopped, which is the drift this seam keeps suffering.
|
|
27
|
+
"""
|
|
28
|
+
if not keys:
|
|
29
|
+
return f"<{len(params)} hidden>"
|
|
30
|
+
shown = [
|
|
31
|
+
repr(value) if str(index) in keys else "<hidden>"
|
|
32
|
+
for index, value in enumerate(params)
|
|
33
|
+
]
|
|
34
|
+
return f"({', '.join(shown)})"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def _logged(write: Writer, head: str) -> Iterator[list[str]]:
|
|
39
|
+
"""Writes ONE line for the statement, whether it worked or not.
|
|
40
|
+
|
|
41
|
+
Every write point used to sit AFTER the inner call, so a statement that raised left no line
|
|
42
|
+
at all: you opened the log at 3:14, found the ten statements before it and a ROLLBACK —that
|
|
43
|
+
one wrote, because `rollback()` follows a call that does not fail— and no trace of what
|
|
44
|
+
caused the rollback. The log showed you everything except the thing you came for.
|
|
45
|
+
|
|
46
|
+
And the exception does not carry it either: `drivers/failures.py` builds its message from
|
|
47
|
+
the engine's words, with no SQL and no params in it. The log WAS the only place the failed
|
|
48
|
+
statement existed.
|
|
49
|
+
|
|
50
|
+
The caller appends its outcome to the yielded list; if it never gets there, the failure is
|
|
51
|
+
written instead. `except BaseException` and re-raise: a timeout or a cancellation is exactly
|
|
52
|
+
the kind of ending worth having in the log.
|
|
53
|
+
|
|
54
|
+
`fetch_iter` does NOT use this, deliberately — see its own docstring.
|
|
55
|
+
"""
|
|
56
|
+
outcome: list[str] = []
|
|
57
|
+
try:
|
|
58
|
+
yield outcome
|
|
59
|
+
except BaseException as error:
|
|
60
|
+
write(f"{head} -> FAILED: {type(error).__name__}: {error}")
|
|
61
|
+
raise
|
|
62
|
+
# A boundary counts no rows: `COMMIT` stays `COMMIT`, byte for byte.
|
|
63
|
+
write(f"{head} -> {outcome[0]}" if outcome else head)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LoggingDriver:
|
|
67
|
+
"""Wraps a `SnakeDriver` and logs SQL, params and how much it affected. A pure decorator: it changes nothing.
|
|
68
|
+
It also logs the transaction boundaries (commit/rollback/savepoints); without them the log lies by omission.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
__slots__ = ("_inner", "_parameter_keys", "_write")
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
inner: SnakeDriver,
|
|
76
|
+
write: Writer = _silent,
|
|
77
|
+
*,
|
|
78
|
+
parameter_keys: frozenset[str] = frozenset(),
|
|
79
|
+
) -> None:
|
|
80
|
+
"""`parameter_keys` names the parameter positions to WRITE OUT, and there is no environment
|
|
81
|
+
variable for it.
|
|
82
|
+
|
|
83
|
+
That omission is the decision, and it is the same one `debug/otel/exporter.py` already made
|
|
84
|
+
for the same data: an environment variable is precisely the switch somebody flips in
|
|
85
|
+
production by accident, and this one would put user values into the log aggregator. It takes
|
|
86
|
+
an explicit line of code, key by key — the key of a positional parameter is its 0-based
|
|
87
|
+
index, as the OpenTelemetry convention spells it.
|
|
88
|
+
|
|
89
|
+
By default the values are hidden and the COUNT is written instead. The count leaks nothing
|
|
90
|
+
and it is half of what makes a log line readable.
|
|
91
|
+
"""
|
|
92
|
+
self._inner = inner
|
|
93
|
+
self._write = write
|
|
94
|
+
self._parameter_keys = parameter_keys
|
|
95
|
+
|
|
96
|
+
def _params(self, params: Sequence[object]) -> str:
|
|
97
|
+
"""The parameters as this driver writes them. Delegates so both colours render alike."""
|
|
98
|
+
return render_params(params, self._parameter_keys)
|
|
99
|
+
|
|
100
|
+
def fetch_all(self, sql: str, params: Sequence[object]) -> list[tuple[object, ...]]:
|
|
101
|
+
"""Runs the query, logs the SQL and how many rows it returned."""
|
|
102
|
+
with _logged(self._write, f"{sql} -- params={self._params(params)}") as outcome:
|
|
103
|
+
rows = self._inner.fetch_all(sql, params)
|
|
104
|
+
outcome.append(f"{len(rows)} row(s)")
|
|
105
|
+
return rows
|
|
106
|
+
|
|
107
|
+
def fetch_iter(
|
|
108
|
+
self, sql: str, params: Sequence[object], *, chunk: int = 1000
|
|
109
|
+
) -> Iterator[tuple[object, ...]]:
|
|
110
|
+
"""Logs the SQL and yields the rows, counting them WHEN IT IS DONE.
|
|
111
|
+
|
|
112
|
+
The counter goes at the end and not at the start on purpose: writing "-> N rows" before
|
|
113
|
+
walking them would require materialising them, which is exactly what this path avoids.
|
|
114
|
+
What actually got consumed is what gets logged, and that is also the interesting figure
|
|
115
|
+
when somebody cuts out with a `break`.
|
|
116
|
+
"""
|
|
117
|
+
counter = 0
|
|
118
|
+
try:
|
|
119
|
+
for row in self._inner.fetch_iter(sql, params, chunk=chunk):
|
|
120
|
+
counter += 1
|
|
121
|
+
yield row
|
|
122
|
+
finally:
|
|
123
|
+
self._write(
|
|
124
|
+
f"{sql} -- params={self._params(params)} -> {counter} row(s) streamed"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def execute(self, sql: str, params: Sequence[object]) -> int:
|
|
128
|
+
"""Runs the statement, logs the SQL and how many rows it affected."""
|
|
129
|
+
with _logged(self._write, f"{sql} -- params={self._params(params)}") as outcome:
|
|
130
|
+
affected = self._inner.execute(sql, params)
|
|
131
|
+
outcome.append(f"{affected} row(s)")
|
|
132
|
+
return affected
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def last_insert_id(self) -> int:
|
|
136
|
+
"""The id of the last INSERT (see the Protocol). Forwarded to the wrapped driver."""
|
|
137
|
+
return self._inner.last_insert_id
|
|
138
|
+
|
|
139
|
+
def commit(self) -> None:
|
|
140
|
+
"""Commits the transaction and writes it down."""
|
|
141
|
+
with _logged(self._write, "COMMIT"):
|
|
142
|
+
self._inner.commit()
|
|
143
|
+
|
|
144
|
+
def rollback(self) -> None:
|
|
145
|
+
"""Rolls the transaction back and writes it down."""
|
|
146
|
+
with _logged(self._write, "ROLLBACK"):
|
|
147
|
+
self._inner.rollback()
|
|
148
|
+
|
|
149
|
+
def savepoint(self, name: str) -> None:
|
|
150
|
+
"""Marks a savepoint and writes it down."""
|
|
151
|
+
with _logged(self._write, f"SAVEPOINT {name}"):
|
|
152
|
+
self._inner.savepoint(name)
|
|
153
|
+
|
|
154
|
+
def release_savepoint(self, name: str) -> None:
|
|
155
|
+
"""Releases a savepoint and writes it down."""
|
|
156
|
+
with _logged(self._write, f"RELEASE SAVEPOINT {name}"):
|
|
157
|
+
self._inner.release_savepoint(name)
|
|
158
|
+
|
|
159
|
+
def rollback_to_savepoint(self, name: str) -> None:
|
|
160
|
+
"""Rolls back to a savepoint and writes it down."""
|
|
161
|
+
with _logged(self._write, f"ROLLBACK TO SAVEPOINT {name}"):
|
|
162
|
+
self._inner.rollback_to_savepoint(name)
|
|
163
|
+
|
|
164
|
+
def close(self) -> None:
|
|
165
|
+
"""Closes the INNER driver: the decorator does not keep the connection."""
|
|
166
|
+
with _logged(self._write, "CLOSE"):
|
|
167
|
+
self._inner.close()
|