pyxle-db 0.2.0__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.
- pyxle_db/__init__.py +95 -0
- pyxle_db/backends/__init__.py +57 -0
- pyxle_db/backends/base.py +172 -0
- pyxle_db/backends/mysql.py +299 -0
- pyxle_db/backends/postgresql.py +403 -0
- pyxle_db/backends/sqlite.py +621 -0
- pyxle_db/contract.py +66 -0
- pyxle_db/database.py +299 -0
- pyxle_db/errors.py +70 -0
- pyxle_db/migrator.py +313 -0
- pyxle_db/plugin.py +207 -0
- pyxle_db/py.typed +0 -0
- pyxle_db/rows.py +79 -0
- pyxle_db/sql.py +298 -0
- pyxle_db/url.py +124 -0
- pyxle_db-0.2.0.dist-info/METADATA +265 -0
- pyxle_db-0.2.0.dist-info/RECORD +19 -0
- pyxle_db-0.2.0.dist-info/WHEEL +4 -0
- pyxle_db-0.2.0.dist-info/licenses/LICENSE +21 -0
pyxle_db/__init__.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""pyxle-db — the official database plugin for Pyxle.
|
|
2
|
+
|
|
3
|
+
One explicit-SQL API over SQLite, PostgreSQL, and MySQL:
|
|
4
|
+
|
|
5
|
+
* :class:`Database` — async facade over a driver backend. Accepts a bare
|
|
6
|
+
SQLite path (0.1-compatible) or any supported database URL. Queries are
|
|
7
|
+
written once in canonical qmark style (``?`` placeholders) and translated
|
|
8
|
+
per backend with a literal-aware rewriter.
|
|
9
|
+
* :class:`Migrator` — discovery + atomic application of ordered SQL
|
|
10
|
+
migrations from a filesystem directory. Keeps a ``schema_migrations``
|
|
11
|
+
table with applied-hash tracking so edits to committed migrations are
|
|
12
|
+
detected and rejected.
|
|
13
|
+
* :func:`connect` — convenience entry point that opens a :class:`Database`
|
|
14
|
+
and applies a migrations directory in one call.
|
|
15
|
+
|
|
16
|
+
Design constraints:
|
|
17
|
+
|
|
18
|
+
* Zero third-party dependencies for SQLite; PostgreSQL and MySQL drivers
|
|
19
|
+
install via extras (``pyxle-db[postgres]``, ``pyxle-db[mysql]``).
|
|
20
|
+
* Parameterised queries only — no string interpolation in SQL.
|
|
21
|
+
* Every write goes through a transaction (implicit or explicit).
|
|
22
|
+
* Driver exceptions never leak: every failure crosses the boundary as a
|
|
23
|
+
:class:`DatabaseError` subclass, so application code never imports
|
|
24
|
+
``sqlite3``/``asyncpg``/``asyncmy`` just to handle an error.
|
|
25
|
+
* Fail loudly: bad URLs, missing drivers, unknown migrations, and checksum
|
|
26
|
+
drift all raise specific, actionable error types.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from pyxle_db.backends import Dialect
|
|
32
|
+
from pyxle_db.contract import DatabaseLike, TransactionLike
|
|
33
|
+
from pyxle_db.database import Database, Transaction, connect
|
|
34
|
+
from pyxle_db.errors import (
|
|
35
|
+
ConfigurationError,
|
|
36
|
+
DatabaseError,
|
|
37
|
+
IntegrityError,
|
|
38
|
+
MigrationChecksumMismatch,
|
|
39
|
+
MigrationError,
|
|
40
|
+
NotFoundError,
|
|
41
|
+
OperationalError,
|
|
42
|
+
UnsupportedOperationError,
|
|
43
|
+
)
|
|
44
|
+
from pyxle_db.migrator import Migration, Migrator
|
|
45
|
+
from pyxle_db.rows import Row
|
|
46
|
+
from pyxle_db.url import DatabaseConfig, parse_database_url
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_database() -> Database:
|
|
50
|
+
"""Return the :class:`Database` the active ``pyxle-db`` plugin opened.
|
|
51
|
+
|
|
52
|
+
Short form for app code that wants the database without reaching
|
|
53
|
+
through ``request.app.state.pyxle_plugins``::
|
|
54
|
+
|
|
55
|
+
from pyxle_db import get_database
|
|
56
|
+
|
|
57
|
+
@server
|
|
58
|
+
async def load(request):
|
|
59
|
+
db = get_database()
|
|
60
|
+
row = await db.fetchone("SELECT ... FROM ...")
|
|
61
|
+
|
|
62
|
+
Requires ``pyxle-db`` to be listed in ``pyxle.config.json::plugins``
|
|
63
|
+
— otherwise raises :class:`pyxle.plugins.PluginServiceError`.
|
|
64
|
+
"""
|
|
65
|
+
from pyxle.plugins import plugin as _plugin # local import to
|
|
66
|
+
# avoid pyxle-db depending on pyxle at module-load time; the plugin
|
|
67
|
+
# system is only needed at *call* time.
|
|
68
|
+
|
|
69
|
+
return _plugin("db.database")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"Database",
|
|
74
|
+
"DatabaseLike",
|
|
75
|
+
"Transaction",
|
|
76
|
+
"TransactionLike",
|
|
77
|
+
"Row",
|
|
78
|
+
"connect",
|
|
79
|
+
"Migration",
|
|
80
|
+
"Migrator",
|
|
81
|
+
"Dialect",
|
|
82
|
+
"DatabaseConfig",
|
|
83
|
+
"parse_database_url",
|
|
84
|
+
"DatabaseError",
|
|
85
|
+
"IntegrityError",
|
|
86
|
+
"OperationalError",
|
|
87
|
+
"ConfigurationError",
|
|
88
|
+
"UnsupportedOperationError",
|
|
89
|
+
"NotFoundError",
|
|
90
|
+
"MigrationError",
|
|
91
|
+
"MigrationChecksumMismatch",
|
|
92
|
+
"get_database",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Backend registry — maps a parsed config to a driver adapter.
|
|
2
|
+
|
|
3
|
+
Driver imports happen lazily so the base install stays dependency-free:
|
|
4
|
+
SQLite always works; PostgreSQL needs ``pyxle-db[postgres]``; MySQL needs
|
|
5
|
+
``pyxle-db[mysql]``. A missing driver surfaces as a clear
|
|
6
|
+
:class:`pyxle_db.errors.ConfigurationError`, not an ImportError five
|
|
7
|
+
frames deep.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pyxle_db.backends.base import (
|
|
13
|
+
MYSQL_DIALECT,
|
|
14
|
+
POSTGRESQL_DIALECT,
|
|
15
|
+
SQLITE_DIALECT,
|
|
16
|
+
Backend,
|
|
17
|
+
BackendTransaction,
|
|
18
|
+
Dialect,
|
|
19
|
+
)
|
|
20
|
+
from pyxle_db.errors import ConfigurationError
|
|
21
|
+
from pyxle_db.url import DatabaseConfig
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Backend",
|
|
25
|
+
"BackendTransaction",
|
|
26
|
+
"Dialect",
|
|
27
|
+
"SQLITE_DIALECT",
|
|
28
|
+
"POSTGRESQL_DIALECT",
|
|
29
|
+
"MYSQL_DIALECT",
|
|
30
|
+
"create_backend",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_backend(config: DatabaseConfig) -> Backend:
|
|
35
|
+
if config.backend == "sqlite":
|
|
36
|
+
from pyxle_db.backends.sqlite import SqliteBackend
|
|
37
|
+
|
|
38
|
+
return SqliteBackend(config)
|
|
39
|
+
if config.backend == "postgresql":
|
|
40
|
+
try:
|
|
41
|
+
from pyxle_db.backends.postgresql import PostgresBackend
|
|
42
|
+
except ImportError as exc: # asyncpg not installed
|
|
43
|
+
raise ConfigurationError(
|
|
44
|
+
"PostgreSQL support requires the asyncpg driver — "
|
|
45
|
+
"install with: pip install 'pyxle-db[postgres]'"
|
|
46
|
+
) from exc
|
|
47
|
+
return PostgresBackend(config)
|
|
48
|
+
if config.backend == "mysql":
|
|
49
|
+
try:
|
|
50
|
+
from pyxle_db.backends.mysql import MysqlBackend
|
|
51
|
+
except ImportError as exc: # asyncmy not installed
|
|
52
|
+
raise ConfigurationError(
|
|
53
|
+
"MySQL support requires the asyncmy driver — "
|
|
54
|
+
"install with: pip install 'pyxle-db[mysql]'"
|
|
55
|
+
) from exc
|
|
56
|
+
return MysqlBackend(config)
|
|
57
|
+
raise ConfigurationError(f"Unknown backend: {config.backend!r}")
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Backend protocol — the contract every database driver adapter implements.
|
|
2
|
+
|
|
3
|
+
A backend owns connections/pools for exactly one database and speaks that
|
|
4
|
+
database's native parameter style. Everything above the backend (the
|
|
5
|
+
:class:`pyxle_db.Database` facade, the migrator, application code) speaks
|
|
6
|
+
canonical qmark SQL; the facade translates via :mod:`pyxle_db.sql` before
|
|
7
|
+
calling the backend, so every backend receives SQL already in its native
|
|
8
|
+
parameter style (for SQLite that native style happens to be qmark).
|
|
9
|
+
|
|
10
|
+
Implementations:
|
|
11
|
+
|
|
12
|
+
* :mod:`pyxle_db.backends.sqlite` — stdlib ``sqlite3``, thread-local
|
|
13
|
+
connections bridged with ``asyncio.to_thread``. Zero dependencies.
|
|
14
|
+
* :mod:`pyxle_db.backends.postgresql` — ``asyncpg`` pool
|
|
15
|
+
(install extra: ``pyxle-db[postgres]``).
|
|
16
|
+
* :mod:`pyxle_db.backends.mysql` — ``asyncmy`` pool
|
|
17
|
+
(install extra: ``pyxle-db[mysql]``).
|
|
18
|
+
|
|
19
|
+
Contract rules (enforced by the shared conformance tests):
|
|
20
|
+
|
|
21
|
+
1. Every error raised crosses the boundary as a :mod:`pyxle_db.errors`
|
|
22
|
+
type — driver exceptions never leak.
|
|
23
|
+
2. Constraint violations (unique, FK, NOT NULL, CHECK) raise
|
|
24
|
+
:class:`pyxle_db.errors.IntegrityError`.
|
|
25
|
+
3. Fetch methods return :class:`pyxle_db.rows.Row`.
|
|
26
|
+
4. Timestamp/datetime columns come back timezone-aware UTC; naive values
|
|
27
|
+
read from the database are assumed UTC and tagged as such. The
|
|
28
|
+
guarantee covers top-level column values (not datetimes nested inside
|
|
29
|
+
driver-specific composite/array types).
|
|
30
|
+
5. ``connect()`` is idempotent; ``aclose()`` is idempotent and the backend
|
|
31
|
+
must be reusable after a subsequent ``connect()``. Backends MAY also
|
|
32
|
+
reopen lazily after close (SQLite does, preserving 0.1 semantics).
|
|
33
|
+
6. A transaction is exclusive to its caller; concurrent ``transaction()``
|
|
34
|
+
calls must not interleave statements on the same underlying connection.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from abc import ABC, abstractmethod
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from datetime import datetime, timezone
|
|
42
|
+
from typing import Any, AsyncContextManager, Iterable, Sequence
|
|
43
|
+
|
|
44
|
+
from pyxle_db.rows import Row
|
|
45
|
+
|
|
46
|
+
Params = Sequence[Any]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def utc_naive_params(params: Params) -> tuple[Any, ...]:
|
|
50
|
+
"""Normalise top-level datetime parameters to naive UTC for binding.
|
|
51
|
+
|
|
52
|
+
The package-wide datetime contract is symmetric: columns store UTC wall
|
|
53
|
+
time, reads come back as aware-UTC datetimes, and callers may bind either
|
|
54
|
+
naive (assumed UTC) or aware (converted to UTC) datetimes. The SQLite
|
|
55
|
+
backend gets this from its registered adapter; PostgreSQL and MySQL
|
|
56
|
+
drivers do not — asyncpg rejects aware datetimes for ``TIMESTAMP``
|
|
57
|
+
columns outright, and asyncmy would silently serialise an aware
|
|
58
|
+
datetime's *foreign wall clock*, corrupting the stored instant. Both
|
|
59
|
+
backends therefore run every parameter sequence through this before
|
|
60
|
+
handing it to the driver. Only top-level values are touched, matching
|
|
61
|
+
the read-side rule (containers are passed through untouched).
|
|
62
|
+
"""
|
|
63
|
+
return tuple(
|
|
64
|
+
value.astimezone(timezone.utc).replace(tzinfo=None)
|
|
65
|
+
if isinstance(value, datetime) and value.tzinfo is not None
|
|
66
|
+
else value
|
|
67
|
+
for value in params
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Dialect:
|
|
73
|
+
"""What the SQL layer needs to know about a backend's flavour."""
|
|
74
|
+
|
|
75
|
+
name: str
|
|
76
|
+
"""``"sqlite"`` | ``"postgresql"`` | ``"mysql"``."""
|
|
77
|
+
|
|
78
|
+
paramstyle: str
|
|
79
|
+
"""``"qmark"`` | ``"numeric_dollar"`` | ``"format"`` (see pyxle_db.sql)."""
|
|
80
|
+
|
|
81
|
+
migrations_table_ddl: str
|
|
82
|
+
"""``CREATE TABLE IF NOT EXISTS schema_migrations`` for this dialect."""
|
|
83
|
+
|
|
84
|
+
supports_sync: bool = False
|
|
85
|
+
"""True only for SQLite — powers ``Database.sync_transaction()``."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
SQLITE_DIALECT = Dialect(
|
|
89
|
+
name="sqlite",
|
|
90
|
+
paramstyle="qmark",
|
|
91
|
+
migrations_table_ddl=(
|
|
92
|
+
"CREATE TABLE IF NOT EXISTS schema_migrations (\n"
|
|
93
|
+
" id TEXT PRIMARY KEY,\n"
|
|
94
|
+
" checksum TEXT NOT NULL,\n"
|
|
95
|
+
" applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n"
|
|
96
|
+
")"
|
|
97
|
+
),
|
|
98
|
+
supports_sync=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
POSTGRESQL_DIALECT = Dialect(
|
|
102
|
+
name="postgresql",
|
|
103
|
+
paramstyle="numeric_dollar",
|
|
104
|
+
migrations_table_ddl=(
|
|
105
|
+
"CREATE TABLE IF NOT EXISTS schema_migrations (\n"
|
|
106
|
+
" id TEXT PRIMARY KEY,\n"
|
|
107
|
+
" checksum TEXT NOT NULL,\n"
|
|
108
|
+
" applied_at TIMESTAMPTZ NOT NULL DEFAULT now()\n"
|
|
109
|
+
")"
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
MYSQL_DIALECT = Dialect(
|
|
114
|
+
name="mysql",
|
|
115
|
+
paramstyle="format",
|
|
116
|
+
migrations_table_ddl=(
|
|
117
|
+
"CREATE TABLE IF NOT EXISTS schema_migrations (\n"
|
|
118
|
+
" id VARCHAR(255) PRIMARY KEY,\n"
|
|
119
|
+
" checksum VARCHAR(64) NOT NULL,\n"
|
|
120
|
+
" applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n"
|
|
121
|
+
")"
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class BackendTransaction(ABC):
|
|
127
|
+
"""One open transaction. All methods receive backend-native SQL."""
|
|
128
|
+
|
|
129
|
+
@abstractmethod
|
|
130
|
+
async def execute(self, sql: str, params: Params = ()) -> int:
|
|
131
|
+
"""Run one statement; return affected rowcount (-1 if unknown)."""
|
|
132
|
+
|
|
133
|
+
@abstractmethod
|
|
134
|
+
async def executemany(self, sql: str, seq_params: Iterable[Params]) -> None: ...
|
|
135
|
+
|
|
136
|
+
@abstractmethod
|
|
137
|
+
async def fetchone(self, sql: str, params: Params = ()) -> Row | None: ...
|
|
138
|
+
|
|
139
|
+
@abstractmethod
|
|
140
|
+
async def fetchall(self, sql: str, params: Params = ()) -> list[Row]: ...
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Backend(ABC):
|
|
144
|
+
"""Connection owner for one configured database."""
|
|
145
|
+
|
|
146
|
+
dialect: Dialect
|
|
147
|
+
|
|
148
|
+
@abstractmethod
|
|
149
|
+
async def connect(self) -> None:
|
|
150
|
+
"""Create pools / verify connectivity. Idempotent."""
|
|
151
|
+
|
|
152
|
+
@abstractmethod
|
|
153
|
+
async def aclose(self) -> None:
|
|
154
|
+
"""Release every connection. Idempotent."""
|
|
155
|
+
|
|
156
|
+
@abstractmethod
|
|
157
|
+
async def execute(self, sql: str, params: Params = ()) -> int:
|
|
158
|
+
"""One-shot autocommitted statement; returns affected rowcount."""
|
|
159
|
+
|
|
160
|
+
@abstractmethod
|
|
161
|
+
async def executemany(self, sql: str, seq_params: Iterable[Params]) -> None:
|
|
162
|
+
"""One-shot bulk statement, atomically (single transaction)."""
|
|
163
|
+
|
|
164
|
+
@abstractmethod
|
|
165
|
+
async def fetchone(self, sql: str, params: Params = ()) -> Row | None: ...
|
|
166
|
+
|
|
167
|
+
@abstractmethod
|
|
168
|
+
async def fetchall(self, sql: str, params: Params = ()) -> list[Row]: ...
|
|
169
|
+
|
|
170
|
+
@abstractmethod
|
|
171
|
+
def transaction(self) -> AsyncContextManager[BackendTransaction]:
|
|
172
|
+
"""Open a transaction scope; commit on exit, roll back on exception."""
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""MySQL/MariaDB backend on an :mod:`asyncmy` connection pool.
|
|
2
|
+
|
|
3
|
+
Install with ``pip install 'pyxle-db[mysql]'``. The pool is created lazily on
|
|
4
|
+
first :meth:`MysqlBackend.connect` with ``charset=utf8mb4`` (always) and
|
|
5
|
+
``autocommit=False`` — every one-shot helper therefore commits explicitly,
|
|
6
|
+
including the fetch helpers: with autocommit off even a ``SELECT`` opens an
|
|
7
|
+
InnoDB read view, and the connection must not return to the pool holding one.
|
|
8
|
+
|
|
9
|
+
Pool sizing is configurable through URL options::
|
|
10
|
+
|
|
11
|
+
mysql://app:secret@db.internal:3306/appdb?pool_min=2&pool_max=20
|
|
12
|
+
|
|
13
|
+
SQL arrives already translated to ``%s`` style by the facade
|
|
14
|
+
(:func:`pyxle_db.sql.translate`), which also doubles literal percent signs
|
|
15
|
+
outside string literals. asyncmy only collapses those ``%%`` back to ``%``
|
|
16
|
+
when ``args`` is not ``None``, so every cursor call here passes a parameter
|
|
17
|
+
tuple — an empty one when the statement takes no parameters.
|
|
18
|
+
|
|
19
|
+
Contract compliance (see :mod:`pyxle_db.backends.base`):
|
|
20
|
+
|
|
21
|
+
* asyncmy exceptions are translated via :func:`_translate` and never leak.
|
|
22
|
+
* Fetches return :class:`pyxle_db.rows.Row` with datetimes normalised to
|
|
23
|
+
timezone-aware UTC (asyncmy returns naive values; they are assumed UTC).
|
|
24
|
+
* ``connect()`` / ``aclose()`` are idempotent; the backend is reusable after
|
|
25
|
+
``aclose()`` followed by another ``connect()``.
|
|
26
|
+
* Each transaction holds its own pooled connection for its whole scope, so
|
|
27
|
+
concurrent transactions can never interleave statements.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
from contextlib import asynccontextmanager
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from typing import Any, AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence, TypeVar
|
|
36
|
+
|
|
37
|
+
import asyncmy
|
|
38
|
+
import asyncmy.errors
|
|
39
|
+
|
|
40
|
+
from pyxle_db.backends.base import (
|
|
41
|
+
MYSQL_DIALECT,
|
|
42
|
+
Backend,
|
|
43
|
+
BackendTransaction,
|
|
44
|
+
Params,
|
|
45
|
+
utc_naive_params,
|
|
46
|
+
)
|
|
47
|
+
from pyxle_db.errors import (
|
|
48
|
+
ConfigurationError,
|
|
49
|
+
DatabaseError,
|
|
50
|
+
IntegrityError,
|
|
51
|
+
OperationalError,
|
|
52
|
+
)
|
|
53
|
+
from pyxle_db.rows import Row
|
|
54
|
+
from pyxle_db.url import DatabaseConfig
|
|
55
|
+
|
|
56
|
+
__all__ = ["MysqlBackend"]
|
|
57
|
+
|
|
58
|
+
T = TypeVar("T")
|
|
59
|
+
|
|
60
|
+
_DEFAULT_POOL_MIN = 1
|
|
61
|
+
_DEFAULT_POOL_MAX = 10
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _translate(exc: Exception) -> DatabaseError:
|
|
65
|
+
"""Map an asyncmy exception onto the :mod:`pyxle_db.errors` hierarchy.
|
|
66
|
+
|
|
67
|
+
* ``asyncmy.errors.IntegrityError`` → :class:`IntegrityError`
|
|
68
|
+
(duplicate key, FK violation, NOT NULL, …).
|
|
69
|
+
* ``asyncmy.errors.OperationalError`` / ``InterfaceError`` →
|
|
70
|
+
:class:`OperationalError` — the retryable family, covering the
|
|
71
|
+
connection errnos (2002/2003 can't connect, 2006 server has gone away,
|
|
72
|
+
2013 lost connection) and 1205 lock-wait timeout.
|
|
73
|
+
* Any other ``asyncmy.errors.MySQLError`` → :class:`DatabaseError`.
|
|
74
|
+
|
|
75
|
+
Callers chain the original with ``raise _translate(exc) from exc``.
|
|
76
|
+
"""
|
|
77
|
+
if isinstance(exc, asyncmy.errors.IntegrityError):
|
|
78
|
+
return IntegrityError(str(exc))
|
|
79
|
+
if isinstance(exc, (asyncmy.errors.OperationalError, asyncmy.errors.InterfaceError)):
|
|
80
|
+
return OperationalError(str(exc))
|
|
81
|
+
return DatabaseError(str(exc))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _as_utc(value: Any) -> Any:
|
|
85
|
+
"""Tag naive datetimes as UTC; convert aware ones to UTC (contract rule 4)."""
|
|
86
|
+
if isinstance(value, datetime):
|
|
87
|
+
if value.tzinfo is None:
|
|
88
|
+
return value.replace(tzinfo=timezone.utc)
|
|
89
|
+
return value.astimezone(timezone.utc)
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _build_row(description: Sequence[Sequence[Any]], values: Sequence[Any]) -> Row:
|
|
94
|
+
"""Build a :class:`Row` from a DB-API cursor description and one tuple."""
|
|
95
|
+
return Row([column[0] for column in description], [_as_utc(v) for v in values])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _pool_bounds(options: Mapping[str, str]) -> tuple[int, int]:
|
|
99
|
+
"""Read ``pool_min`` / ``pool_max`` URL options into asyncmy pool sizes."""
|
|
100
|
+
minsize = _int_option(options, "pool_min", _DEFAULT_POOL_MIN)
|
|
101
|
+
maxsize = _int_option(options, "pool_max", _DEFAULT_POOL_MAX)
|
|
102
|
+
if minsize < 0:
|
|
103
|
+
raise ConfigurationError(f"pool_min must be >= 0, got {minsize}")
|
|
104
|
+
if maxsize < 1:
|
|
105
|
+
raise ConfigurationError(f"pool_max must be >= 1, got {maxsize}")
|
|
106
|
+
if maxsize < minsize:
|
|
107
|
+
raise ConfigurationError(
|
|
108
|
+
f"pool_max ({maxsize}) must be >= pool_min ({minsize})"
|
|
109
|
+
)
|
|
110
|
+
return minsize, maxsize
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _int_option(options: Mapping[str, str], key: str, default: int) -> int:
|
|
114
|
+
raw = options.get(key)
|
|
115
|
+
if raw is None:
|
|
116
|
+
return default
|
|
117
|
+
try:
|
|
118
|
+
return int(raw)
|
|
119
|
+
except ValueError:
|
|
120
|
+
raise ConfigurationError(
|
|
121
|
+
f"Database URL option {key!r} must be an integer, got {raw!r}"
|
|
122
|
+
) from None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _rollback_quietly(conn: asyncmy.Connection) -> None:
|
|
126
|
+
"""Best-effort rollback while another exception is already in flight.
|
|
127
|
+
|
|
128
|
+
If the rollback itself fails (typically because the connection died) the
|
|
129
|
+
original error is the one worth reporting, so driver errors are dropped.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
await conn.rollback()
|
|
133
|
+
except asyncmy.errors.MySQLError:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _MysqlTransaction(BackendTransaction):
|
|
138
|
+
"""Statements on one pooled connection; commit/rollback is the scope's job."""
|
|
139
|
+
|
|
140
|
+
__slots__ = ("_conn",)
|
|
141
|
+
|
|
142
|
+
def __init__(self, conn: asyncmy.Connection) -> None:
|
|
143
|
+
self._conn = conn
|
|
144
|
+
|
|
145
|
+
async def execute(self, sql: str, params: Params = ()) -> int:
|
|
146
|
+
try:
|
|
147
|
+
async with self._conn.cursor() as cursor:
|
|
148
|
+
await cursor.execute(sql, utc_naive_params(params))
|
|
149
|
+
return int(cursor.rowcount)
|
|
150
|
+
except asyncmy.errors.MySQLError as exc:
|
|
151
|
+
raise _translate(exc) from exc
|
|
152
|
+
|
|
153
|
+
async def executemany(self, sql: str, seq_params: Iterable[Params]) -> None:
|
|
154
|
+
rows = [utc_naive_params(p) for p in seq_params]
|
|
155
|
+
if not rows:
|
|
156
|
+
return
|
|
157
|
+
try:
|
|
158
|
+
async with self._conn.cursor() as cursor:
|
|
159
|
+
await cursor.executemany(sql, rows)
|
|
160
|
+
except asyncmy.errors.MySQLError as exc:
|
|
161
|
+
raise _translate(exc) from exc
|
|
162
|
+
|
|
163
|
+
async def fetchone(self, sql: str, params: Params = ()) -> Row | None:
|
|
164
|
+
try:
|
|
165
|
+
async with self._conn.cursor() as cursor:
|
|
166
|
+
await cursor.execute(sql, utc_naive_params(params))
|
|
167
|
+
values = await cursor.fetchone()
|
|
168
|
+
if values is None:
|
|
169
|
+
return None
|
|
170
|
+
return _build_row(cursor.description, values)
|
|
171
|
+
except asyncmy.errors.MySQLError as exc:
|
|
172
|
+
raise _translate(exc) from exc
|
|
173
|
+
|
|
174
|
+
async def fetchall(self, sql: str, params: Params = ()) -> list[Row]:
|
|
175
|
+
try:
|
|
176
|
+
async with self._conn.cursor() as cursor:
|
|
177
|
+
await cursor.execute(sql, utc_naive_params(params))
|
|
178
|
+
values = await cursor.fetchall()
|
|
179
|
+
return [_build_row(cursor.description, row) for row in values]
|
|
180
|
+
except asyncmy.errors.MySQLError as exc:
|
|
181
|
+
raise _translate(exc) from exc
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class MysqlBackend(Backend):
|
|
185
|
+
"""MySQL adapter. SQL must already be in ``%s`` (format) parameter style."""
|
|
186
|
+
|
|
187
|
+
dialect = MYSQL_DIALECT
|
|
188
|
+
|
|
189
|
+
def __init__(self, config: DatabaseConfig) -> None:
|
|
190
|
+
self._config = config
|
|
191
|
+
self._pool_min, self._pool_max = _pool_bounds(config.options)
|
|
192
|
+
self._pool: asyncmy.Pool | None = None
|
|
193
|
+
self._connect_lock = asyncio.Lock()
|
|
194
|
+
|
|
195
|
+
# -- lifecycle ------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
async def connect(self) -> None:
|
|
198
|
+
await self._ensure_pool()
|
|
199
|
+
|
|
200
|
+
async def aclose(self) -> None:
|
|
201
|
+
pool, self._pool = self._pool, None
|
|
202
|
+
if pool is None:
|
|
203
|
+
return
|
|
204
|
+
pool.close()
|
|
205
|
+
await pool.wait_closed()
|
|
206
|
+
|
|
207
|
+
async def _ensure_pool(self) -> asyncmy.Pool:
|
|
208
|
+
pool = self._pool
|
|
209
|
+
if pool is not None:
|
|
210
|
+
return pool
|
|
211
|
+
async with self._connect_lock:
|
|
212
|
+
if self._pool is None:
|
|
213
|
+
try:
|
|
214
|
+
self._pool = await asyncmy.create_pool(
|
|
215
|
+
host=self._config.host,
|
|
216
|
+
port=self._config.port,
|
|
217
|
+
user=self._config.user,
|
|
218
|
+
password=self._config.password,
|
|
219
|
+
database=self._config.database,
|
|
220
|
+
charset="utf8mb4",
|
|
221
|
+
autocommit=False,
|
|
222
|
+
minsize=self._pool_min,
|
|
223
|
+
maxsize=self._pool_max,
|
|
224
|
+
# Pin the session to UTC. MySQL converts TIMESTAMP
|
|
225
|
+
# columns to the session time_zone on read (default:
|
|
226
|
+
# the server's SYSTEM zone), which would shift values
|
|
227
|
+
# that the row layer then mis-tags as UTC. With the
|
|
228
|
+
# session pinned, TIMESTAMP and NOW() speak UTC and
|
|
229
|
+
# the naive-equals-UTC contract holds everywhere.
|
|
230
|
+
init_command="SET time_zone = '+00:00'",
|
|
231
|
+
)
|
|
232
|
+
except asyncmy.errors.MySQLError as exc:
|
|
233
|
+
raise _translate(exc) from exc
|
|
234
|
+
return self._pool
|
|
235
|
+
|
|
236
|
+
# -- one-shot helpers -------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
async def _autocommit(self, op: Callable[[_MysqlTransaction], Awaitable[T]]) -> T:
|
|
239
|
+
"""Run ``op`` on a pooled connection, commit, and always release.
|
|
240
|
+
|
|
241
|
+
Rolls back on any failure so the connection re-enters the pool clean.
|
|
242
|
+
``op`` raises already-translated errors; the outer handler translates
|
|
243
|
+
what the commit itself may raise.
|
|
244
|
+
"""
|
|
245
|
+
pool = await self._ensure_pool()
|
|
246
|
+
conn = await pool.acquire()
|
|
247
|
+
try:
|
|
248
|
+
try:
|
|
249
|
+
result = await op(_MysqlTransaction(conn))
|
|
250
|
+
await conn.commit()
|
|
251
|
+
return result
|
|
252
|
+
except BaseException:
|
|
253
|
+
await _rollback_quietly(conn)
|
|
254
|
+
raise
|
|
255
|
+
except asyncmy.errors.MySQLError as exc:
|
|
256
|
+
raise _translate(exc) from exc
|
|
257
|
+
finally:
|
|
258
|
+
pool.release(conn)
|
|
259
|
+
|
|
260
|
+
async def execute(self, sql: str, params: Params = ()) -> int:
|
|
261
|
+
return await self._autocommit(lambda tx: tx.execute(sql, params))
|
|
262
|
+
|
|
263
|
+
async def executemany(self, sql: str, seq_params: Iterable[Params]) -> None:
|
|
264
|
+
rows = [utc_naive_params(p) for p in seq_params]
|
|
265
|
+
if not rows:
|
|
266
|
+
return
|
|
267
|
+
await self._autocommit(lambda tx: tx.executemany(sql, rows))
|
|
268
|
+
|
|
269
|
+
async def fetchone(self, sql: str, params: Params = ()) -> Row | None:
|
|
270
|
+
return await self._autocommit(lambda tx: tx.fetchone(sql, params))
|
|
271
|
+
|
|
272
|
+
async def fetchall(self, sql: str, params: Params = ()) -> list[Row]:
|
|
273
|
+
return await self._autocommit(lambda tx: tx.fetchall(sql, params))
|
|
274
|
+
|
|
275
|
+
# -- transactions ------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
@asynccontextmanager
|
|
278
|
+
async def transaction(self) -> AsyncIterator[BackendTransaction]:
|
|
279
|
+
"""One ``BEGIN``-scoped connection: commit on exit, roll back on error.
|
|
280
|
+
|
|
281
|
+
The connection is acquired for the whole scope and released in all
|
|
282
|
+
cases, so concurrent transactions never share a connection
|
|
283
|
+
(contract rule 6).
|
|
284
|
+
"""
|
|
285
|
+
pool = await self._ensure_pool()
|
|
286
|
+
conn = await pool.acquire()
|
|
287
|
+
try:
|
|
288
|
+
try:
|
|
289
|
+
await conn.begin()
|
|
290
|
+
yield _MysqlTransaction(conn)
|
|
291
|
+
except BaseException:
|
|
292
|
+
await _rollback_quietly(conn)
|
|
293
|
+
raise
|
|
294
|
+
else:
|
|
295
|
+
await conn.commit()
|
|
296
|
+
except asyncmy.errors.MySQLError as exc:
|
|
297
|
+
raise _translate(exc) from exc
|
|
298
|
+
finally:
|
|
299
|
+
pool.release(conn)
|