pyoq-sql 1.0.2__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.
- pyoq/__init__.py +10 -0
- pyoq/__main__.py +5 -0
- pyoq/_native.pyi +5 -0
- pyoq/cli/__init__.py +5 -0
- pyoq/cli/commands.py +270 -0
- pyoq/cli/defaults.py +98 -0
- pyoq/cli/services.py +97 -0
- pyoq/config/__init__.py +31 -0
- pyoq/config/connection.py +161 -0
- pyoq/config/loader.py +289 -0
- pyoq/config/models.py +245 -0
- pyoq/config/values.py +142 -0
- pyoq/descriptors.py +165 -0
- pyoq/diagnostics/__init__.py +68 -0
- pyoq/diagnostics/budget.py +136 -0
- pyoq/diagnostics/events.py +137 -0
- pyoq/diagnostics/fingerprint.py +267 -0
- pyoq/diagnostics/instrumented.py +237 -0
- pyoq/diagnostics/metrics.py +61 -0
- pyoq/diagnostics/observation.py +227 -0
- pyoq/diagnostics/scoped.py +103 -0
- pyoq/django/__init__.py +15 -0
- pyoq/django/apps.py +17 -0
- pyoq/django/execution.py +317 -0
- pyoq/django/generation.py +59 -0
- pyoq/django/management/__init__.py +0 -0
- pyoq/django/management/commands/__init__.py +0 -0
- pyoq/django/management/commands/makemigrations.py +53 -0
- pyoq/django/management/commands/pyoq_codegen.py +75 -0
- pyoq/django/parameters.py +101 -0
- pyoq/django/schema.py +379 -0
- pyoq/django/settings.py +87 -0
- pyoq/django/timeouts.py +105 -0
- pyoq/dsl/__init__.py +64 -0
- pyoq/dsl/aio/__init__.py +31 -0
- pyoq/dsl/aio/context.py +295 -0
- pyoq/dsl/aio/queries.py +335 -0
- pyoq/dsl/aio/writes.py +368 -0
- pyoq/dsl/context.py +326 -0
- pyoq/dsl/entry.py +37 -0
- pyoq/dsl/labels.py +36 -0
- pyoq/dsl/queries.py +339 -0
- pyoq/dsl/result.py +164 -0
- pyoq/dsl/writes.py +360 -0
- pyoq/errors.py +317 -0
- pyoq/fastapi/__init__.py +32 -0
- pyoq/fastapi/dependencies.py +167 -0
- pyoq/fastapi/lifespan.py +119 -0
- pyoq/fetching/__init__.py +55 -0
- pyoq/fetching/collections.py +136 -0
- pyoq/fetching/execution.py +587 -0
- pyoq/fetching/joined.py +79 -0
- pyoq/fetching/nesting.py +183 -0
- pyoq/fetching/plans.py +541 -0
- pyoq/fetching/select_in.py +149 -0
- pyoq/fetching/tables.py +110 -0
- pyoq/generation/__init__.py +54 -0
- pyoq/generation/cleanup.py +44 -0
- pyoq/generation/contracts.py +248 -0
- pyoq/generation/drift.py +169 -0
- pyoq/generation/lock.py +33 -0
- pyoq/generation/manifest.py +114 -0
- pyoq/generation/model.py +1001 -0
- pyoq/generation/pipeline.py +119 -0
- pyoq/generation/rendering/__init__.py +5 -0
- pyoq/generation/rendering/domains.py +51 -0
- pyoq/generation/rendering/enums.py +29 -0
- pyoq/generation/rendering/exports.py +70 -0
- pyoq/generation/rendering/imports.py +63 -0
- pyoq/generation/rendering/package.py +56 -0
- pyoq/generation/rendering/relations.py +133 -0
- pyoq/generation/rendering/routines.py +396 -0
- pyoq/generation/rendering/rows.py +79 -0
- pyoq/generation/rendering/source.py +121 -0
- pyoq/generation/rendering/tables.py +300 -0
- pyoq/generation/rendering/writes.py +514 -0
- pyoq/generation/validation.py +27 -0
- pyoq/generation/writer.py +184 -0
- pyoq/hydration/__init__.py +24 -0
- pyoq/hydration/engine.py +155 -0
- pyoq/hydration/identity.py +194 -0
- pyoq/hydration/plan.py +116 -0
- pyoq/migrations/__init__.py +9 -0
- pyoq/migrations/alembic.py +106 -0
- pyoq/migrations/hooks.py +75 -0
- pyoq/naming.py +261 -0
- pyoq/policies/__init__.py +47 -0
- pyoq/policies/bypass.py +122 -0
- pyoq/policies/governed.py +430 -0
- pyoq/policies/model.py +242 -0
- pyoq/policies/rewriting.py +263 -0
- pyoq/py.typed +1 -0
- pyoq/query/__init__.py +312 -0
- pyoq/query/aggregates.py +172 -0
- pyoq/query/arrays.py +65 -0
- pyoq/query/binding.py +52 -0
- pyoq/query/capabilities.py +317 -0
- pyoq/query/casts.py +73 -0
- pyoq/query/choices.py +185 -0
- pyoq/query/decoding.py +360 -0
- pyoq/query/documents.py +56 -0
- pyoq/query/execution/__init__.py +63 -0
- pyoq/query/execution/aio/__init__.py +31 -0
- pyoq/query/execution/aio/operations.py +228 -0
- pyoq/query/execution/aio/pooling.py +233 -0
- pyoq/query/execution/aio/streaming.py +161 -0
- pyoq/query/execution/aio/transactions.py +105 -0
- pyoq/query/execution/batch.py +96 -0
- pyoq/query/execution/binding_style.py +30 -0
- pyoq/query/execution/compilation.py +48 -0
- pyoq/query/execution/context.py +61 -0
- pyoq/query/execution/control.py +50 -0
- pyoq/query/execution/operations.py +224 -0
- pyoq/query/execution/planning.py +107 -0
- pyoq/query/execution/pooling.py +279 -0
- pyoq/query/execution/results.py +36 -0
- pyoq/query/execution/streaming.py +178 -0
- pyoq/query/execution/transactions.py +95 -0
- pyoq/query/expressions.py +1200 -0
- pyoq/query/fields.py +60 -0
- pyoq/query/mysql/__init__.py +59 -0
- pyoq/query/mysql/aio/__init__.py +38 -0
- pyoq/query/mysql/aio/commands.py +389 -0
- pyoq/query/mysql/aio/driver.py +196 -0
- pyoq/query/mysql/aio/executor.py +123 -0
- pyoq/query/mysql/aio/factory.py +26 -0
- pyoq/query/mysql/aio/operations.py +38 -0
- pyoq/query/mysql/aio/pool.py +53 -0
- pyoq/query/mysql/aio/transactions.py +313 -0
- pyoq/query/mysql/commands.py +354 -0
- pyoq/query/mysql/compiler.py +134 -0
- pyoq/query/mysql/context.py +20 -0
- pyoq/query/mysql/executor.py +126 -0
- pyoq/query/mysql/expressions.py +244 -0
- pyoq/query/mysql/factory.py +46 -0
- pyoq/query/mysql/health.py +66 -0
- pyoq/query/mysql/identifiers.py +9 -0
- pyoq/query/mysql/model.py +79 -0
- pyoq/query/mysql/operations.py +43 -0
- pyoq/query/mysql/parameters.py +69 -0
- pyoq/query/mysql/planning.py +20 -0
- pyoq/query/mysql/pool.py +67 -0
- pyoq/query/mysql/transactions.py +331 -0
- pyoq/query/mysql/writes.py +73 -0
- pyoq/query/nodes.py +750 -0
- pyoq/query/postgres/__init__.py +48 -0
- pyoq/query/postgres/aio/__init__.py +25 -0
- pyoq/query/postgres/aio/bulk.py +56 -0
- pyoq/query/postgres/aio/commands.py +264 -0
- pyoq/query/postgres/aio/executor.py +152 -0
- pyoq/query/postgres/aio/factory.py +26 -0
- pyoq/query/postgres/aio/operations.py +26 -0
- pyoq/query/postgres/aio/pool.py +40 -0
- pyoq/query/postgres/aio/transactions.py +295 -0
- pyoq/query/postgres/bulk.py +62 -0
- pyoq/query/postgres/commands.py +238 -0
- pyoq/query/postgres/compiler.py +114 -0
- pyoq/query/postgres/context.py +20 -0
- pyoq/query/postgres/executor.py +147 -0
- pyoq/query/postgres/expressions.py +311 -0
- pyoq/query/postgres/factory.py +24 -0
- pyoq/query/postgres/health.py +24 -0
- pyoq/query/postgres/identifiers.py +9 -0
- pyoq/query/postgres/model.py +81 -0
- pyoq/query/postgres/operations.py +25 -0
- pyoq/query/postgres/parameters.py +71 -0
- pyoq/query/postgres/planning.py +20 -0
- pyoq/query/postgres/pool.py +52 -0
- pyoq/query/postgres/transactions.py +295 -0
- pyoq/query/postgres/writes.py +37 -0
- pyoq/query/projections.py +105 -0
- pyoq/query/raw.py +90 -0
- pyoq/query/recursion.py +265 -0
- pyoq/query/rendering/__init__.py +1 -0
- pyoq/query/rendering/expressions.py +913 -0
- pyoq/query/rendering/identifiers.py +40 -0
- pyoq/query/rendering/projections.py +63 -0
- pyoq/query/rendering/queries.py +334 -0
- pyoq/query/rendering/sources.py +66 -0
- pyoq/query/rendering/writes.py +176 -0
- pyoq/query/results.py +459 -0
- pyoq/query/routines.py +196 -0
- pyoq/query/rows.py +156 -0
- pyoq/query/select.py +793 -0
- pyoq/query/select_nodes.py +277 -0
- pyoq/query/sources.py +236 -0
- pyoq/query/sqlite/__init__.py +43 -0
- pyoq/query/sqlite/commands.py +201 -0
- pyoq/query/sqlite/compiler.py +139 -0
- pyoq/query/sqlite/context.py +20 -0
- pyoq/query/sqlite/executor.py +119 -0
- pyoq/query/sqlite/expressions.py +224 -0
- pyoq/query/sqlite/factory.py +32 -0
- pyoq/query/sqlite/health.py +28 -0
- pyoq/query/sqlite/identifiers.py +9 -0
- pyoq/query/sqlite/model.py +73 -0
- pyoq/query/sqlite/operations.py +36 -0
- pyoq/query/sqlite/parameters.py +50 -0
- pyoq/query/sqlite/planning.py +20 -0
- pyoq/query/sqlite/pool.py +50 -0
- pyoq/query/sqlite/streaming.py +13 -0
- pyoq/query/sqlite/transactions.py +274 -0
- pyoq/query/sqlite/writes.py +35 -0
- pyoq/query/statements.py +27 -0
- pyoq/query/values.py +23 -0
- pyoq/query/vendor.py +162 -0
- pyoq/query/windows.py +424 -0
- pyoq/query/write_nodes.py +174 -0
- pyoq/query/writes.py +628 -0
- pyoq/relations/__init__.py +66 -0
- pyoq/relations/batching.py +219 -0
- pyoq/relations/derivation.py +111 -0
- pyoq/relations/fetching.py +355 -0
- pyoq/relations/graph.py +245 -0
- pyoq/relations/loading.py +74 -0
- pyoq/relations/model.py +75 -0
- pyoq/relations/planning.py +206 -0
- pyoq/runtime/__init__.py +9 -0
- pyoq/runtime/kernels.py +25 -0
- pyoq/runtime/python.py +43 -0
- pyoq/runtime/selection.py +73 -0
- pyoq/sanic/__init__.py +32 -0
- pyoq/sanic/scope.py +197 -0
- pyoq/sanic/workers.py +129 -0
- pyoq/schema/__init__.py +108 -0
- pyoq/schema/codec.py +711 -0
- pyoq/schema/models.py +604 -0
- pyoq/schema/mysql/__init__.py +16 -0
- pyoq/schema/mysql/connection.py +73 -0
- pyoq/schema/mysql/dsn.py +72 -0
- pyoq/schema/mysql/records.py +354 -0
- pyoq/schema/mysql/reflection.py +309 -0
- pyoq/schema/mysql/source.py +30 -0
- pyoq/schema/mysql/sql.py +128 -0
- pyoq/schema/mysql/types.py +105 -0
- pyoq/schema/postgres/__init__.py +13 -0
- pyoq/schema/postgres/connection.py +63 -0
- pyoq/schema/postgres/records.py +384 -0
- pyoq/schema/postgres/reflection.py +466 -0
- pyoq/schema/postgres/source.py +30 -0
- pyoq/schema/postgres/sql.py +246 -0
- pyoq/schema/postgres/types.py +98 -0
- pyoq/schema/registry.py +45 -0
- pyoq/schema/source.py +15 -0
- pyoq/schema/sqlite/__init__.py +6 -0
- pyoq/schema/sqlite/connection.py +54 -0
- pyoq/schema/sqlite/records.py +167 -0
- pyoq/schema/sqlite/reflection.py +393 -0
- pyoq/schema/sqlite/source.py +30 -0
- pyoq/schema/sqlite/sql.py +254 -0
- pyoq/schema/sqlite/types.py +74 -0
- pyoq/serving/__init__.py +23 -0
- pyoq/serving/databases.py +107 -0
- pyoq/serving/opening.py +331 -0
- pyoq/snapshots/__init__.py +20 -0
- pyoq/snapshots/drift.py +312 -0
- pyoq/snapshots/files.py +96 -0
- pyoq/snapshots/routing.py +40 -0
- pyoq/snapshots/source.py +33 -0
- pyoq/tracing/__init__.py +5 -0
- pyoq/tracing/spans.py +89 -0
- pyoq/unset.py +14 -0
- pyoq_sql-1.0.2.dist-info/METADATA +3050 -0
- pyoq_sql-1.0.2.dist-info/RECORD +267 -0
- pyoq_sql-1.0.2.dist-info/WHEEL +4 -0
- pyoq_sql-1.0.2.dist-info/entry_points.txt +3 -0
- pyoq_sql-1.0.2.dist-info/licenses/LICENSE +373 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Owned asynchronous MySQL transactions and nested savepoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from typing import TypeVar, cast
|
|
7
|
+
|
|
8
|
+
from pyoq.errors import ExecutionError, QueryValidationError, TransactionStateError
|
|
9
|
+
from pyoq.query.execution import (
|
|
10
|
+
CompiledQuery,
|
|
11
|
+
ExecutionControl,
|
|
12
|
+
StatementKind,
|
|
13
|
+
StreamPolicy,
|
|
14
|
+
)
|
|
15
|
+
from pyoq.query.execution.aio import (
|
|
16
|
+
AsyncRowStream,
|
|
17
|
+
AsyncStreamResource,
|
|
18
|
+
AsyncTransactionState,
|
|
19
|
+
)
|
|
20
|
+
from pyoq.query.mysql.aio.commands import (
|
|
21
|
+
AsyncExecutionEnvironment,
|
|
22
|
+
AsyncFailureHandler,
|
|
23
|
+
open_cursor,
|
|
24
|
+
run_sql,
|
|
25
|
+
)
|
|
26
|
+
from pyoq.query.mysql.aio.driver import AsyncMySQLConnection, AsyncMySQLCursor
|
|
27
|
+
from pyoq.query.mysql.aio.operations import MySQLAsyncQueryOperations
|
|
28
|
+
from pyoq.query.mysql.aio.pool import MySQLAsyncPool
|
|
29
|
+
from pyoq.query.mysql.compiler import MySQLCompiler
|
|
30
|
+
from pyoq.query.mysql.parameters import adapt_parameters
|
|
31
|
+
from pyoq.query.mysql.transactions import TransactionMode
|
|
32
|
+
from pyoq.query.select import Query
|
|
33
|
+
|
|
34
|
+
OperationResult = TypeVar("OperationResult")
|
|
35
|
+
ResultRow = TypeVar("ResultRow")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MySQLAsyncTransactionState(
|
|
39
|
+
AsyncTransactionState[AsyncMySQLConnection, "MySQLAsyncTransaction"]
|
|
40
|
+
):
|
|
41
|
+
"""Adds the one transaction fact MySQL reports that other dialects do not.
|
|
42
|
+
|
|
43
|
+
A deadlock rolls the whole transaction back on the server while leaving the
|
|
44
|
+
connection healthy, and a later ``COMMIT`` then succeeds having committed
|
|
45
|
+
nothing.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
__slots__ = ("server_rolled_back",)
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
super().__init__()
|
|
52
|
+
self.server_rolled_back = False
|
|
53
|
+
|
|
54
|
+
def mark_server_rollback(self) -> None:
|
|
55
|
+
self.server_rolled_back = True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MySQLAsyncTransaction(MySQLAsyncQueryOperations):
|
|
59
|
+
__slots__ = (
|
|
60
|
+
"_control",
|
|
61
|
+
"_entered",
|
|
62
|
+
"_finished",
|
|
63
|
+
"_mode",
|
|
64
|
+
"_parent",
|
|
65
|
+
"_pool",
|
|
66
|
+
"_savepoint_name",
|
|
67
|
+
"_state",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
pool: MySQLAsyncPool,
|
|
73
|
+
compiler: MySQLCompiler,
|
|
74
|
+
mode: TransactionMode = TransactionMode.REPEATABLE_READ,
|
|
75
|
+
control: ExecutionControl | None = None,
|
|
76
|
+
*,
|
|
77
|
+
parent: MySQLAsyncTransaction | None = None,
|
|
78
|
+
state: MySQLAsyncTransactionState | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
super().__init__(compiler)
|
|
81
|
+
if not _is_transaction_mode(mode):
|
|
82
|
+
raise QueryValidationError("MySQL transaction mode is invalid")
|
|
83
|
+
self._pool = pool
|
|
84
|
+
self._mode = mode
|
|
85
|
+
self._control = control
|
|
86
|
+
self._parent = parent
|
|
87
|
+
self._state = state or MySQLAsyncTransactionState()
|
|
88
|
+
self._savepoint_name: str | None = None
|
|
89
|
+
self._entered = False
|
|
90
|
+
self._finished = False
|
|
91
|
+
|
|
92
|
+
async def __aenter__(self) -> MySQLAsyncTransaction:
|
|
93
|
+
if self._entered or self._finished:
|
|
94
|
+
raise TransactionStateError("transaction cannot be entered twice")
|
|
95
|
+
if self._parent is None:
|
|
96
|
+
await self._enter_outer()
|
|
97
|
+
else:
|
|
98
|
+
await self._enter_savepoint()
|
|
99
|
+
self._entered = True
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
async def __aexit__(
|
|
103
|
+
self,
|
|
104
|
+
exception_type: type[BaseException] | None,
|
|
105
|
+
exception: BaseException | None,
|
|
106
|
+
traceback: object,
|
|
107
|
+
) -> None:
|
|
108
|
+
if not self._entered or self._finished:
|
|
109
|
+
return
|
|
110
|
+
if self._parent is None:
|
|
111
|
+
await self._exit_outer(exception_type)
|
|
112
|
+
else:
|
|
113
|
+
await self._exit_savepoint(exception_type)
|
|
114
|
+
|
|
115
|
+
def savepoint(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
control: ExecutionControl | None = None,
|
|
119
|
+
) -> MySQLAsyncTransaction:
|
|
120
|
+
self._state.require_current(self)
|
|
121
|
+
return MySQLAsyncTransaction(
|
|
122
|
+
self._pool,
|
|
123
|
+
self.compiler,
|
|
124
|
+
self._mode,
|
|
125
|
+
control or self._control,
|
|
126
|
+
parent=self,
|
|
127
|
+
state=self._state,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def stream(
|
|
131
|
+
self,
|
|
132
|
+
query: Query[ResultRow],
|
|
133
|
+
/,
|
|
134
|
+
*,
|
|
135
|
+
policy: StreamPolicy | None = None,
|
|
136
|
+
) -> AsyncRowStream[ResultRow]:
|
|
137
|
+
self._state.require_current(self)
|
|
138
|
+
selected = policy or StreamPolicy(control=self._control)
|
|
139
|
+
if selected.control is None and self._control is not None:
|
|
140
|
+
selected = StreamPolicy(selected.batch_size, self._control)
|
|
141
|
+
statement = self.compiler.compile(query)
|
|
142
|
+
stream: AsyncRowStream[ResultRow]
|
|
143
|
+
stream = AsyncRowStream(
|
|
144
|
+
lambda: self._open_stream(stream, statement, selected),
|
|
145
|
+
selected,
|
|
146
|
+
)
|
|
147
|
+
return stream
|
|
148
|
+
|
|
149
|
+
def _environment(
|
|
150
|
+
self,
|
|
151
|
+
connection: AsyncMySQLConnection,
|
|
152
|
+
) -> AsyncExecutionEnvironment:
|
|
153
|
+
return AsyncExecutionEnvironment(
|
|
154
|
+
connection,
|
|
155
|
+
self._pool.connection_factory,
|
|
156
|
+
AsyncFailureHandler(
|
|
157
|
+
self._state.invalidate,
|
|
158
|
+
self._state.mark_server_rollback,
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def _run(
|
|
163
|
+
self,
|
|
164
|
+
statement: CompiledQuery,
|
|
165
|
+
operation: Callable[[AsyncMySQLCursor], Awaitable[OperationResult]],
|
|
166
|
+
control: ExecutionControl | None,
|
|
167
|
+
) -> OperationResult:
|
|
168
|
+
parameters = adapt_parameters(statement.parameters)
|
|
169
|
+
connection = self._state.require_current(self)
|
|
170
|
+
return await run_sql(
|
|
171
|
+
self._environment(connection),
|
|
172
|
+
statement.sql,
|
|
173
|
+
parameters,
|
|
174
|
+
operation,
|
|
175
|
+
control or self._control,
|
|
176
|
+
one_result_set=statement.statement_kind is StatementKind.CALL,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
async def _enter_outer(self) -> None:
|
|
180
|
+
await self._state.adopt(self._pool.checkout(), self)
|
|
181
|
+
try:
|
|
182
|
+
await self._run_transaction_sql(self._mode.isolation_sql)
|
|
183
|
+
await self._run_transaction_sql(self._mode.start_sql)
|
|
184
|
+
except BaseException:
|
|
185
|
+
await self._release_outer()
|
|
186
|
+
raise
|
|
187
|
+
|
|
188
|
+
async def _enter_savepoint(self) -> None:
|
|
189
|
+
self._state.require_current(cast("MySQLAsyncTransaction", self._parent))
|
|
190
|
+
name = self._state.next_savepoint()
|
|
191
|
+
await self._run_transaction_sql(f"SAVEPOINT `{name}`", scope=self._parent)
|
|
192
|
+
self._savepoint_name = name
|
|
193
|
+
self._state.current_scope = self
|
|
194
|
+
|
|
195
|
+
async def _exit_outer(self, exception_type: type[BaseException] | None) -> None:
|
|
196
|
+
try:
|
|
197
|
+
self._state.require_scope(self)
|
|
198
|
+
except TransactionStateError:
|
|
199
|
+
await self._abort_misnested_outer(exception_type)
|
|
200
|
+
return
|
|
201
|
+
await self._state.close_stream()
|
|
202
|
+
try:
|
|
203
|
+
await self._finish_outer(exception_type)
|
|
204
|
+
except ExecutionError:
|
|
205
|
+
self._state.invalidate()
|
|
206
|
+
if exception_type is None:
|
|
207
|
+
raise
|
|
208
|
+
finally:
|
|
209
|
+
await self._release_outer()
|
|
210
|
+
|
|
211
|
+
async def _finish_outer(self, exception_type: type[BaseException] | None) -> None:
|
|
212
|
+
if self._state.server_rolled_back:
|
|
213
|
+
if exception_type is None:
|
|
214
|
+
message = (
|
|
215
|
+
"MySQL rolled this transaction back on the server; its work "
|
|
216
|
+
"was discarded and it cannot be committed"
|
|
217
|
+
)
|
|
218
|
+
raise TransactionStateError(message)
|
|
219
|
+
return
|
|
220
|
+
if self._state.broken:
|
|
221
|
+
return
|
|
222
|
+
await self._run_transaction_sql("ROLLBACK" if exception_type else "COMMIT")
|
|
223
|
+
|
|
224
|
+
async def _exit_savepoint(self, exception_type: type[BaseException] | None) -> None:
|
|
225
|
+
self._state.require_scope(self)
|
|
226
|
+
await self._state.close_stream()
|
|
227
|
+
name = cast("str", self._savepoint_name)
|
|
228
|
+
try:
|
|
229
|
+
await self._finish_savepoint(name, exception_type)
|
|
230
|
+
except ExecutionError:
|
|
231
|
+
self._state.invalidate()
|
|
232
|
+
if exception_type is None:
|
|
233
|
+
raise
|
|
234
|
+
finally:
|
|
235
|
+
self._state.current_scope = self._parent
|
|
236
|
+
self._finished = True
|
|
237
|
+
|
|
238
|
+
async def _finish_savepoint(
|
|
239
|
+
self,
|
|
240
|
+
name: str,
|
|
241
|
+
exception_type: type[BaseException] | None,
|
|
242
|
+
) -> None:
|
|
243
|
+
if self._state.broken or self._state.server_rolled_back:
|
|
244
|
+
return
|
|
245
|
+
if exception_type is not None:
|
|
246
|
+
await self._run_transaction_sql(f"ROLLBACK TO SAVEPOINT `{name}`")
|
|
247
|
+
await self._run_transaction_sql(f"RELEASE SAVEPOINT `{name}`")
|
|
248
|
+
|
|
249
|
+
async def _run_transaction_sql(
|
|
250
|
+
self,
|
|
251
|
+
sql: str,
|
|
252
|
+
*,
|
|
253
|
+
scope: MySQLAsyncTransaction | None = None,
|
|
254
|
+
) -> None:
|
|
255
|
+
connection = self._state.require_current(scope or self, allow_stream=True)
|
|
256
|
+
await run_sql(
|
|
257
|
+
self._environment(connection),
|
|
258
|
+
sql,
|
|
259
|
+
(),
|
|
260
|
+
_discard_cursor,
|
|
261
|
+
self._control,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
async def _open_stream(
|
|
265
|
+
self,
|
|
266
|
+
stream: AsyncRowStream[ResultRow],
|
|
267
|
+
statement: CompiledQuery,
|
|
268
|
+
policy: StreamPolicy,
|
|
269
|
+
) -> AsyncStreamResource:
|
|
270
|
+
parameters = adapt_parameters(statement.parameters)
|
|
271
|
+
connection = self._state.require_current(self)
|
|
272
|
+
session = await open_cursor(
|
|
273
|
+
self._environment(connection),
|
|
274
|
+
statement.sql,
|
|
275
|
+
parameters,
|
|
276
|
+
policy.control,
|
|
277
|
+
unbuffered=True,
|
|
278
|
+
)
|
|
279
|
+
self._state.active_stream = cast("AsyncRowStream[object]", stream)
|
|
280
|
+
return AsyncStreamResource(session, self._state.release_stream)
|
|
281
|
+
|
|
282
|
+
async def _release_outer(self) -> None:
|
|
283
|
+
self._finished = True
|
|
284
|
+
await self._state.release()
|
|
285
|
+
|
|
286
|
+
async def _abort_misnested_outer(
|
|
287
|
+
self,
|
|
288
|
+
exception_type: type[BaseException] | None,
|
|
289
|
+
) -> None:
|
|
290
|
+
connection = self._state.require_owner()
|
|
291
|
+
await self._state.close_stream()
|
|
292
|
+
try:
|
|
293
|
+
cursor = await connection.cursor()
|
|
294
|
+
await cursor.execute("ROLLBACK", ())
|
|
295
|
+
await cursor.close()
|
|
296
|
+
except Exception:
|
|
297
|
+
self._state.invalidate()
|
|
298
|
+
finally:
|
|
299
|
+
await self._release_outer()
|
|
300
|
+
if exception_type is None:
|
|
301
|
+
message = "transaction exited with an active nested savepoint"
|
|
302
|
+
raise TransactionStateError(message)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _is_transaction_mode(value: object) -> bool:
|
|
306
|
+
return isinstance(value, TransactionMode)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
async def _discard_cursor(cursor: AsyncMySQLCursor) -> None:
|
|
310
|
+
del cursor
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
__all__ = ("MySQLAsyncTransaction", "MySQLAsyncTransactionState", "TransactionMode")
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""MySQL cursor execution, timeouts, and cancellation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from threading import Event, Thread
|
|
8
|
+
from time import monotonic
|
|
9
|
+
from typing import TYPE_CHECKING, NoReturn, TypeAlias, TypeVar, cast
|
|
10
|
+
|
|
11
|
+
import pymysql
|
|
12
|
+
from pymysql.cursors import Cursor, SSCursor
|
|
13
|
+
|
|
14
|
+
from pyoq.errors import QueryCancelledError, QueryExecutionError, QueryTimeoutError
|
|
15
|
+
from pyoq.query.execution import ExecutionControl
|
|
16
|
+
from pyoq.query.mysql.health import (
|
|
17
|
+
is_cancellation,
|
|
18
|
+
is_connection_failure,
|
|
19
|
+
is_timeout,
|
|
20
|
+
is_transaction_abort,
|
|
21
|
+
)
|
|
22
|
+
from pyoq.query.mysql.parameters import MySQLParameter
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from pymysql.connections import Connection as DriverConnection
|
|
26
|
+
|
|
27
|
+
OperationResult = TypeVar("OperationResult")
|
|
28
|
+
|
|
29
|
+
Connection: TypeAlias = "DriverConnection[Cursor]"
|
|
30
|
+
ConnectionOpener: TypeAlias = "Callable[[], Connection]"
|
|
31
|
+
|
|
32
|
+
_WATCHDOG_INTERVAL = 0.025
|
|
33
|
+
_MILLISECONDS = 1000
|
|
34
|
+
_CONNECTION_ID = "SELECT CONNECTION_ID()"
|
|
35
|
+
_CLEAR_TIMEOUT = "SET @@max_execution_time = 0"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_connection_failure(error: pymysql.Error) -> bool:
|
|
39
|
+
"""PyMySQL reports a locally closed connection as an interface error."""
|
|
40
|
+
return isinstance(error, pymysql.InterfaceError) or is_connection_failure(error)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _ignore_transaction_abort() -> None:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class FailureHandler:
|
|
49
|
+
"""Where a driver failure is reported once it has been classified."""
|
|
50
|
+
|
|
51
|
+
invalidate: Callable[[], None]
|
|
52
|
+
abort_transaction: Callable[[], None] = field(default=_ignore_transaction_abort)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class ExecutionEnvironment:
|
|
57
|
+
connection: Connection
|
|
58
|
+
opener: ConnectionOpener
|
|
59
|
+
handler: FailureHandler
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MySQLExecutionMonitor:
|
|
63
|
+
"""Server-side SELECT timeout plus a watchdog that kills the running query.
|
|
64
|
+
|
|
65
|
+
MySQL applies ``max_execution_time`` to read-only SELECT statements only, so
|
|
66
|
+
the watchdog enforces both cancellation and the deadline for every other
|
|
67
|
+
statement by issuing ``KILL QUERY`` from a separate connection.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
__slots__ = ("_control", "_started", "_stop", "_timeout_applied", "_watchdog")
|
|
71
|
+
|
|
72
|
+
def __init__(self, control: ExecutionControl | None) -> None:
|
|
73
|
+
self._control = control or ExecutionControl()
|
|
74
|
+
self._started = monotonic() if self._control.timeout is not None else 0.0
|
|
75
|
+
self._stop = Event()
|
|
76
|
+
self._watchdog: Thread | None = None
|
|
77
|
+
self._timeout_applied = False
|
|
78
|
+
|
|
79
|
+
def install(self, environment: ExecutionEnvironment) -> None:
|
|
80
|
+
self.checkpoint()
|
|
81
|
+
self._apply_timeout(environment)
|
|
82
|
+
if not self._watchdog_required():
|
|
83
|
+
return
|
|
84
|
+
watchdog = Thread(
|
|
85
|
+
target=self._watch,
|
|
86
|
+
args=(environment, _read_connection_id(environment.connection)),
|
|
87
|
+
daemon=True,
|
|
88
|
+
name="pyoq-mysql-cancel",
|
|
89
|
+
)
|
|
90
|
+
self._watchdog = watchdog
|
|
91
|
+
watchdog.start()
|
|
92
|
+
|
|
93
|
+
def checkpoint(self) -> None:
|
|
94
|
+
if self._cancel_requested():
|
|
95
|
+
raise QueryCancelledError("MySQL query execution was cancelled")
|
|
96
|
+
if self._expired():
|
|
97
|
+
raise QueryTimeoutError("MySQL query execution exceeded its timeout")
|
|
98
|
+
|
|
99
|
+
def translate_driver_error(
|
|
100
|
+
self,
|
|
101
|
+
error: pymysql.Error,
|
|
102
|
+
environment: ExecutionEnvironment,
|
|
103
|
+
) -> NoReturn:
|
|
104
|
+
if _is_connection_failure(error):
|
|
105
|
+
environment.handler.invalidate()
|
|
106
|
+
elif is_transaction_abort(error):
|
|
107
|
+
environment.handler.abort_transaction()
|
|
108
|
+
self.checkpoint()
|
|
109
|
+
if is_timeout(error):
|
|
110
|
+
raise QueryTimeoutError(
|
|
111
|
+
"MySQL query execution exceeded its timeout"
|
|
112
|
+
) from error
|
|
113
|
+
if is_cancellation(error):
|
|
114
|
+
raise QueryCancelledError("MySQL query execution was cancelled") from error
|
|
115
|
+
message = f"MySQL query execution failed: {error}"
|
|
116
|
+
raise QueryExecutionError(message) from error
|
|
117
|
+
|
|
118
|
+
def close(self, environment: ExecutionEnvironment) -> None:
|
|
119
|
+
self._stop.set()
|
|
120
|
+
watchdog = self._watchdog
|
|
121
|
+
self._watchdog = None
|
|
122
|
+
if watchdog is not None:
|
|
123
|
+
watchdog.join()
|
|
124
|
+
self._clear_timeout(environment)
|
|
125
|
+
|
|
126
|
+
def _watch(self, environment: ExecutionEnvironment, victim: int) -> None:
|
|
127
|
+
while not self._stop.wait(_WATCHDOG_INTERVAL):
|
|
128
|
+
if self._cancel_requested() or self._expired():
|
|
129
|
+
kill_query(environment.opener, victim)
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
def _watchdog_required(self) -> bool:
|
|
133
|
+
return (
|
|
134
|
+
self._control.cancellation_token is not None
|
|
135
|
+
or self._control.timeout is not None
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _apply_timeout(self, environment: ExecutionEnvironment) -> None:
|
|
139
|
+
timeout = self._control.timeout
|
|
140
|
+
if timeout is None:
|
|
141
|
+
return
|
|
142
|
+
milliseconds = max(int(timeout * _MILLISECONDS), 1)
|
|
143
|
+
with environment.connection.cursor() as cursor:
|
|
144
|
+
cursor.execute(f"SET @@max_execution_time = {milliseconds:d}", ())
|
|
145
|
+
self._timeout_applied = True
|
|
146
|
+
|
|
147
|
+
def _clear_timeout(self, environment: ExecutionEnvironment) -> None:
|
|
148
|
+
if not self._timeout_applied:
|
|
149
|
+
return
|
|
150
|
+
self._timeout_applied = False
|
|
151
|
+
try:
|
|
152
|
+
with environment.connection.cursor() as cursor:
|
|
153
|
+
cursor.execute(_CLEAR_TIMEOUT, ())
|
|
154
|
+
except pymysql.Error:
|
|
155
|
+
environment.handler.invalidate()
|
|
156
|
+
|
|
157
|
+
def _cancel_requested(self) -> bool:
|
|
158
|
+
token = self._control.cancellation_token
|
|
159
|
+
return token is not None and token.cancelled
|
|
160
|
+
|
|
161
|
+
def _expired(self) -> bool:
|
|
162
|
+
timeout = self._control.timeout
|
|
163
|
+
return timeout is not None and monotonic() - self._started >= timeout
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class MySQLCursorSession:
|
|
167
|
+
__slots__ = ("_closed", "_cursor", "_environment", "_monitor")
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
environment: ExecutionEnvironment,
|
|
172
|
+
cursor: Cursor,
|
|
173
|
+
monitor: MySQLExecutionMonitor,
|
|
174
|
+
) -> None:
|
|
175
|
+
self._environment = environment
|
|
176
|
+
self._cursor = cursor
|
|
177
|
+
self._monitor = monitor
|
|
178
|
+
self._closed = False
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def cursor(self) -> Cursor:
|
|
182
|
+
return self._cursor
|
|
183
|
+
|
|
184
|
+
def checkpoint(self) -> None:
|
|
185
|
+
self._monitor.checkpoint()
|
|
186
|
+
|
|
187
|
+
def translate_driver_error(self, error: pymysql.Error) -> NoReturn:
|
|
188
|
+
self._monitor.translate_driver_error(error, self._environment)
|
|
189
|
+
|
|
190
|
+
def fetch_batch(self, size: int) -> list[tuple[object, ...]]:
|
|
191
|
+
_require_live_connection(self._environment)
|
|
192
|
+
try:
|
|
193
|
+
self._monitor.checkpoint()
|
|
194
|
+
rows = cast("tuple[tuple[object, ...], ...]", self._cursor.fetchmany(size))
|
|
195
|
+
self._monitor.checkpoint()
|
|
196
|
+
except pymysql.Error as error:
|
|
197
|
+
self._monitor.translate_driver_error(error, self._environment)
|
|
198
|
+
return list(rows)
|
|
199
|
+
|
|
200
|
+
def answered_with_more_than_one_result_set(self) -> bool:
|
|
201
|
+
"""Whether the procedure left result sets behind that nothing would read.
|
|
202
|
+
|
|
203
|
+
MySQL follows the sets of a procedure with a packet that carries no
|
|
204
|
+
columns, so a set that has columns is one that holds rows.
|
|
205
|
+
"""
|
|
206
|
+
try:
|
|
207
|
+
while self._cursor.nextset():
|
|
208
|
+
if self._cursor.description:
|
|
209
|
+
return True
|
|
210
|
+
except pymysql.Error as error:
|
|
211
|
+
self._monitor.translate_driver_error(error, self._environment)
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
def close(self) -> None:
|
|
215
|
+
if self._closed:
|
|
216
|
+
return
|
|
217
|
+
self._closed = True
|
|
218
|
+
_close_cursor(self._cursor, self._environment)
|
|
219
|
+
self._monitor.close(self._environment)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def run_sql(
|
|
223
|
+
environment: ExecutionEnvironment,
|
|
224
|
+
sql: str,
|
|
225
|
+
parameters: tuple[MySQLParameter, ...],
|
|
226
|
+
operation: Callable[[Cursor], OperationResult],
|
|
227
|
+
control: ExecutionControl | None,
|
|
228
|
+
*,
|
|
229
|
+
one_result_set: bool = False,
|
|
230
|
+
) -> OperationResult:
|
|
231
|
+
session = open_cursor(environment, sql, parameters, control)
|
|
232
|
+
try:
|
|
233
|
+
result = operation(session.cursor)
|
|
234
|
+
if one_result_set and session.answered_with_more_than_one_result_set():
|
|
235
|
+
raise QueryExecutionError(MANY_RESULT_SETS)
|
|
236
|
+
session.checkpoint()
|
|
237
|
+
return result
|
|
238
|
+
except pymysql.Error as error:
|
|
239
|
+
session.translate_driver_error(error)
|
|
240
|
+
finally:
|
|
241
|
+
session.close()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
MANY_RESULT_SETS = (
|
|
245
|
+
"the procedure answered with more than one result set, and only the first "
|
|
246
|
+
"would be read; have it answer with one, or read the rest in statements of "
|
|
247
|
+
"their own"
|
|
248
|
+
)
|
|
249
|
+
"""Refused rather than dropped, because a dropped result set is silent."""
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def open_cursor(
|
|
253
|
+
environment: ExecutionEnvironment,
|
|
254
|
+
sql: str,
|
|
255
|
+
parameters: tuple[MySQLParameter, ...],
|
|
256
|
+
control: ExecutionControl | None,
|
|
257
|
+
*,
|
|
258
|
+
unbuffered: bool = False,
|
|
259
|
+
) -> MySQLCursorSession:
|
|
260
|
+
monitor = MySQLExecutionMonitor(control)
|
|
261
|
+
cursor: Cursor | None = None
|
|
262
|
+
try:
|
|
263
|
+
monitor.install(environment)
|
|
264
|
+
cursor = _open_driver_cursor(environment.connection, unbuffered=unbuffered)
|
|
265
|
+
cursor.execute(sql, parameters)
|
|
266
|
+
monitor.checkpoint()
|
|
267
|
+
return MySQLCursorSession(environment, cursor, monitor)
|
|
268
|
+
except pymysql.Error as error:
|
|
269
|
+
_release(cursor, monitor, environment)
|
|
270
|
+
monitor.translate_driver_error(error, environment)
|
|
271
|
+
except BaseException:
|
|
272
|
+
_release(cursor, monitor, environment)
|
|
273
|
+
raise
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def kill_query(opener: ConnectionOpener, victim: int) -> None:
|
|
277
|
+
"""Interrupt a running statement from a second connection.
|
|
278
|
+
|
|
279
|
+
MySQL has no client-side cancel, so the only way to stop a statement is to
|
|
280
|
+
ask the server to kill it from somewhere else. A kill that cannot be
|
|
281
|
+
delivered leaves the statement running, which the caller detects at its own
|
|
282
|
+
checkpoint.
|
|
283
|
+
"""
|
|
284
|
+
try:
|
|
285
|
+
killer = opener()
|
|
286
|
+
except Exception:
|
|
287
|
+
return
|
|
288
|
+
try:
|
|
289
|
+
with killer.cursor() as cursor:
|
|
290
|
+
cursor.execute(f"KILL QUERY {victim:d}", ())
|
|
291
|
+
except pymysql.Error:
|
|
292
|
+
return
|
|
293
|
+
finally:
|
|
294
|
+
killer.close()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _open_driver_cursor(connection: Connection, *, unbuffered: bool) -> Cursor:
|
|
298
|
+
return connection.cursor(SSCursor) if unbuffered else connection.cursor()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _read_connection_id(connection: Connection) -> int:
|
|
302
|
+
"""Read the identifier a kill has to name.
|
|
303
|
+
|
|
304
|
+
``SELECT CONNECTION_ID()`` always answers with one integer row, so a reply
|
|
305
|
+
of any other shape means the peer is not a MySQL server and the resulting
|
|
306
|
+
error belongs to the caller.
|
|
307
|
+
"""
|
|
308
|
+
with connection.cursor() as cursor:
|
|
309
|
+
cursor.execute(_CONNECTION_ID, ())
|
|
310
|
+
row = cast("tuple[int, ...]", cursor.fetchone())
|
|
311
|
+
return row[0]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _release(
|
|
315
|
+
cursor: Cursor | None,
|
|
316
|
+
monitor: MySQLExecutionMonitor,
|
|
317
|
+
environment: ExecutionEnvironment,
|
|
318
|
+
) -> None:
|
|
319
|
+
if cursor is not None:
|
|
320
|
+
_close_cursor(cursor, environment)
|
|
321
|
+
monitor.close(environment)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _close_cursor(cursor: Cursor, environment: ExecutionEnvironment) -> None:
|
|
325
|
+
if not environment.connection.open:
|
|
326
|
+
environment.handler.invalidate()
|
|
327
|
+
return
|
|
328
|
+
cursor.close()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _require_live_connection(environment: ExecutionEnvironment) -> None:
|
|
332
|
+
"""Refuse to read through a connection whose socket is already gone.
|
|
333
|
+
|
|
334
|
+
PyMySQL reads an unbuffered result straight from the socket, so a closed
|
|
335
|
+
connection fails inside the driver with an attribute error rather than a
|
|
336
|
+
driver error that could be classified.
|
|
337
|
+
"""
|
|
338
|
+
if environment.connection.open:
|
|
339
|
+
return
|
|
340
|
+
environment.handler.invalidate()
|
|
341
|
+
message = "MySQL connection was closed while a result was open"
|
|
342
|
+
raise QueryExecutionError(message)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
__all__ = (
|
|
346
|
+
"ConnectionOpener",
|
|
347
|
+
"ExecutionEnvironment",
|
|
348
|
+
"FailureHandler",
|
|
349
|
+
"MySQLCursorSession",
|
|
350
|
+
"MySQLExecutionMonitor",
|
|
351
|
+
"kill_query",
|
|
352
|
+
"open_cursor",
|
|
353
|
+
"run_sql",
|
|
354
|
+
)
|