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