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.
Files changed (267) hide show
  1. pyoq/__init__.py +10 -0
  2. pyoq/__main__.py +5 -0
  3. pyoq/_native.pyi +5 -0
  4. pyoq/cli/__init__.py +5 -0
  5. pyoq/cli/commands.py +270 -0
  6. pyoq/cli/defaults.py +98 -0
  7. pyoq/cli/services.py +97 -0
  8. pyoq/config/__init__.py +31 -0
  9. pyoq/config/connection.py +161 -0
  10. pyoq/config/loader.py +289 -0
  11. pyoq/config/models.py +245 -0
  12. pyoq/config/values.py +142 -0
  13. pyoq/descriptors.py +165 -0
  14. pyoq/diagnostics/__init__.py +68 -0
  15. pyoq/diagnostics/budget.py +136 -0
  16. pyoq/diagnostics/events.py +137 -0
  17. pyoq/diagnostics/fingerprint.py +267 -0
  18. pyoq/diagnostics/instrumented.py +237 -0
  19. pyoq/diagnostics/metrics.py +61 -0
  20. pyoq/diagnostics/observation.py +227 -0
  21. pyoq/diagnostics/scoped.py +103 -0
  22. pyoq/django/__init__.py +15 -0
  23. pyoq/django/apps.py +17 -0
  24. pyoq/django/execution.py +317 -0
  25. pyoq/django/generation.py +59 -0
  26. pyoq/django/management/__init__.py +0 -0
  27. pyoq/django/management/commands/__init__.py +0 -0
  28. pyoq/django/management/commands/makemigrations.py +53 -0
  29. pyoq/django/management/commands/pyoq_codegen.py +75 -0
  30. pyoq/django/parameters.py +101 -0
  31. pyoq/django/schema.py +379 -0
  32. pyoq/django/settings.py +87 -0
  33. pyoq/django/timeouts.py +105 -0
  34. pyoq/dsl/__init__.py +64 -0
  35. pyoq/dsl/aio/__init__.py +31 -0
  36. pyoq/dsl/aio/context.py +295 -0
  37. pyoq/dsl/aio/queries.py +335 -0
  38. pyoq/dsl/aio/writes.py +368 -0
  39. pyoq/dsl/context.py +326 -0
  40. pyoq/dsl/entry.py +37 -0
  41. pyoq/dsl/labels.py +36 -0
  42. pyoq/dsl/queries.py +339 -0
  43. pyoq/dsl/result.py +164 -0
  44. pyoq/dsl/writes.py +360 -0
  45. pyoq/errors.py +317 -0
  46. pyoq/fastapi/__init__.py +32 -0
  47. pyoq/fastapi/dependencies.py +167 -0
  48. pyoq/fastapi/lifespan.py +119 -0
  49. pyoq/fetching/__init__.py +55 -0
  50. pyoq/fetching/collections.py +136 -0
  51. pyoq/fetching/execution.py +587 -0
  52. pyoq/fetching/joined.py +79 -0
  53. pyoq/fetching/nesting.py +183 -0
  54. pyoq/fetching/plans.py +541 -0
  55. pyoq/fetching/select_in.py +149 -0
  56. pyoq/fetching/tables.py +110 -0
  57. pyoq/generation/__init__.py +54 -0
  58. pyoq/generation/cleanup.py +44 -0
  59. pyoq/generation/contracts.py +248 -0
  60. pyoq/generation/drift.py +169 -0
  61. pyoq/generation/lock.py +33 -0
  62. pyoq/generation/manifest.py +114 -0
  63. pyoq/generation/model.py +1001 -0
  64. pyoq/generation/pipeline.py +119 -0
  65. pyoq/generation/rendering/__init__.py +5 -0
  66. pyoq/generation/rendering/domains.py +51 -0
  67. pyoq/generation/rendering/enums.py +29 -0
  68. pyoq/generation/rendering/exports.py +70 -0
  69. pyoq/generation/rendering/imports.py +63 -0
  70. pyoq/generation/rendering/package.py +56 -0
  71. pyoq/generation/rendering/relations.py +133 -0
  72. pyoq/generation/rendering/routines.py +396 -0
  73. pyoq/generation/rendering/rows.py +79 -0
  74. pyoq/generation/rendering/source.py +121 -0
  75. pyoq/generation/rendering/tables.py +300 -0
  76. pyoq/generation/rendering/writes.py +514 -0
  77. pyoq/generation/validation.py +27 -0
  78. pyoq/generation/writer.py +184 -0
  79. pyoq/hydration/__init__.py +24 -0
  80. pyoq/hydration/engine.py +155 -0
  81. pyoq/hydration/identity.py +194 -0
  82. pyoq/hydration/plan.py +116 -0
  83. pyoq/migrations/__init__.py +9 -0
  84. pyoq/migrations/alembic.py +106 -0
  85. pyoq/migrations/hooks.py +75 -0
  86. pyoq/naming.py +261 -0
  87. pyoq/policies/__init__.py +47 -0
  88. pyoq/policies/bypass.py +122 -0
  89. pyoq/policies/governed.py +430 -0
  90. pyoq/policies/model.py +242 -0
  91. pyoq/policies/rewriting.py +263 -0
  92. pyoq/py.typed +1 -0
  93. pyoq/query/__init__.py +312 -0
  94. pyoq/query/aggregates.py +172 -0
  95. pyoq/query/arrays.py +65 -0
  96. pyoq/query/binding.py +52 -0
  97. pyoq/query/capabilities.py +317 -0
  98. pyoq/query/casts.py +73 -0
  99. pyoq/query/choices.py +185 -0
  100. pyoq/query/decoding.py +360 -0
  101. pyoq/query/documents.py +56 -0
  102. pyoq/query/execution/__init__.py +63 -0
  103. pyoq/query/execution/aio/__init__.py +31 -0
  104. pyoq/query/execution/aio/operations.py +228 -0
  105. pyoq/query/execution/aio/pooling.py +233 -0
  106. pyoq/query/execution/aio/streaming.py +161 -0
  107. pyoq/query/execution/aio/transactions.py +105 -0
  108. pyoq/query/execution/batch.py +96 -0
  109. pyoq/query/execution/binding_style.py +30 -0
  110. pyoq/query/execution/compilation.py +48 -0
  111. pyoq/query/execution/context.py +61 -0
  112. pyoq/query/execution/control.py +50 -0
  113. pyoq/query/execution/operations.py +224 -0
  114. pyoq/query/execution/planning.py +107 -0
  115. pyoq/query/execution/pooling.py +279 -0
  116. pyoq/query/execution/results.py +36 -0
  117. pyoq/query/execution/streaming.py +178 -0
  118. pyoq/query/execution/transactions.py +95 -0
  119. pyoq/query/expressions.py +1200 -0
  120. pyoq/query/fields.py +60 -0
  121. pyoq/query/mysql/__init__.py +59 -0
  122. pyoq/query/mysql/aio/__init__.py +38 -0
  123. pyoq/query/mysql/aio/commands.py +389 -0
  124. pyoq/query/mysql/aio/driver.py +196 -0
  125. pyoq/query/mysql/aio/executor.py +123 -0
  126. pyoq/query/mysql/aio/factory.py +26 -0
  127. pyoq/query/mysql/aio/operations.py +38 -0
  128. pyoq/query/mysql/aio/pool.py +53 -0
  129. pyoq/query/mysql/aio/transactions.py +313 -0
  130. pyoq/query/mysql/commands.py +354 -0
  131. pyoq/query/mysql/compiler.py +134 -0
  132. pyoq/query/mysql/context.py +20 -0
  133. pyoq/query/mysql/executor.py +126 -0
  134. pyoq/query/mysql/expressions.py +244 -0
  135. pyoq/query/mysql/factory.py +46 -0
  136. pyoq/query/mysql/health.py +66 -0
  137. pyoq/query/mysql/identifiers.py +9 -0
  138. pyoq/query/mysql/model.py +79 -0
  139. pyoq/query/mysql/operations.py +43 -0
  140. pyoq/query/mysql/parameters.py +69 -0
  141. pyoq/query/mysql/planning.py +20 -0
  142. pyoq/query/mysql/pool.py +67 -0
  143. pyoq/query/mysql/transactions.py +331 -0
  144. pyoq/query/mysql/writes.py +73 -0
  145. pyoq/query/nodes.py +750 -0
  146. pyoq/query/postgres/__init__.py +48 -0
  147. pyoq/query/postgres/aio/__init__.py +25 -0
  148. pyoq/query/postgres/aio/bulk.py +56 -0
  149. pyoq/query/postgres/aio/commands.py +264 -0
  150. pyoq/query/postgres/aio/executor.py +152 -0
  151. pyoq/query/postgres/aio/factory.py +26 -0
  152. pyoq/query/postgres/aio/operations.py +26 -0
  153. pyoq/query/postgres/aio/pool.py +40 -0
  154. pyoq/query/postgres/aio/transactions.py +295 -0
  155. pyoq/query/postgres/bulk.py +62 -0
  156. pyoq/query/postgres/commands.py +238 -0
  157. pyoq/query/postgres/compiler.py +114 -0
  158. pyoq/query/postgres/context.py +20 -0
  159. pyoq/query/postgres/executor.py +147 -0
  160. pyoq/query/postgres/expressions.py +311 -0
  161. pyoq/query/postgres/factory.py +24 -0
  162. pyoq/query/postgres/health.py +24 -0
  163. pyoq/query/postgres/identifiers.py +9 -0
  164. pyoq/query/postgres/model.py +81 -0
  165. pyoq/query/postgres/operations.py +25 -0
  166. pyoq/query/postgres/parameters.py +71 -0
  167. pyoq/query/postgres/planning.py +20 -0
  168. pyoq/query/postgres/pool.py +52 -0
  169. pyoq/query/postgres/transactions.py +295 -0
  170. pyoq/query/postgres/writes.py +37 -0
  171. pyoq/query/projections.py +105 -0
  172. pyoq/query/raw.py +90 -0
  173. pyoq/query/recursion.py +265 -0
  174. pyoq/query/rendering/__init__.py +1 -0
  175. pyoq/query/rendering/expressions.py +913 -0
  176. pyoq/query/rendering/identifiers.py +40 -0
  177. pyoq/query/rendering/projections.py +63 -0
  178. pyoq/query/rendering/queries.py +334 -0
  179. pyoq/query/rendering/sources.py +66 -0
  180. pyoq/query/rendering/writes.py +176 -0
  181. pyoq/query/results.py +459 -0
  182. pyoq/query/routines.py +196 -0
  183. pyoq/query/rows.py +156 -0
  184. pyoq/query/select.py +793 -0
  185. pyoq/query/select_nodes.py +277 -0
  186. pyoq/query/sources.py +236 -0
  187. pyoq/query/sqlite/__init__.py +43 -0
  188. pyoq/query/sqlite/commands.py +201 -0
  189. pyoq/query/sqlite/compiler.py +139 -0
  190. pyoq/query/sqlite/context.py +20 -0
  191. pyoq/query/sqlite/executor.py +119 -0
  192. pyoq/query/sqlite/expressions.py +224 -0
  193. pyoq/query/sqlite/factory.py +32 -0
  194. pyoq/query/sqlite/health.py +28 -0
  195. pyoq/query/sqlite/identifiers.py +9 -0
  196. pyoq/query/sqlite/model.py +73 -0
  197. pyoq/query/sqlite/operations.py +36 -0
  198. pyoq/query/sqlite/parameters.py +50 -0
  199. pyoq/query/sqlite/planning.py +20 -0
  200. pyoq/query/sqlite/pool.py +50 -0
  201. pyoq/query/sqlite/streaming.py +13 -0
  202. pyoq/query/sqlite/transactions.py +274 -0
  203. pyoq/query/sqlite/writes.py +35 -0
  204. pyoq/query/statements.py +27 -0
  205. pyoq/query/values.py +23 -0
  206. pyoq/query/vendor.py +162 -0
  207. pyoq/query/windows.py +424 -0
  208. pyoq/query/write_nodes.py +174 -0
  209. pyoq/query/writes.py +628 -0
  210. pyoq/relations/__init__.py +66 -0
  211. pyoq/relations/batching.py +219 -0
  212. pyoq/relations/derivation.py +111 -0
  213. pyoq/relations/fetching.py +355 -0
  214. pyoq/relations/graph.py +245 -0
  215. pyoq/relations/loading.py +74 -0
  216. pyoq/relations/model.py +75 -0
  217. pyoq/relations/planning.py +206 -0
  218. pyoq/runtime/__init__.py +9 -0
  219. pyoq/runtime/kernels.py +25 -0
  220. pyoq/runtime/python.py +43 -0
  221. pyoq/runtime/selection.py +73 -0
  222. pyoq/sanic/__init__.py +32 -0
  223. pyoq/sanic/scope.py +197 -0
  224. pyoq/sanic/workers.py +129 -0
  225. pyoq/schema/__init__.py +108 -0
  226. pyoq/schema/codec.py +711 -0
  227. pyoq/schema/models.py +604 -0
  228. pyoq/schema/mysql/__init__.py +16 -0
  229. pyoq/schema/mysql/connection.py +73 -0
  230. pyoq/schema/mysql/dsn.py +72 -0
  231. pyoq/schema/mysql/records.py +354 -0
  232. pyoq/schema/mysql/reflection.py +309 -0
  233. pyoq/schema/mysql/source.py +30 -0
  234. pyoq/schema/mysql/sql.py +128 -0
  235. pyoq/schema/mysql/types.py +105 -0
  236. pyoq/schema/postgres/__init__.py +13 -0
  237. pyoq/schema/postgres/connection.py +63 -0
  238. pyoq/schema/postgres/records.py +384 -0
  239. pyoq/schema/postgres/reflection.py +466 -0
  240. pyoq/schema/postgres/source.py +30 -0
  241. pyoq/schema/postgres/sql.py +246 -0
  242. pyoq/schema/postgres/types.py +98 -0
  243. pyoq/schema/registry.py +45 -0
  244. pyoq/schema/source.py +15 -0
  245. pyoq/schema/sqlite/__init__.py +6 -0
  246. pyoq/schema/sqlite/connection.py +54 -0
  247. pyoq/schema/sqlite/records.py +167 -0
  248. pyoq/schema/sqlite/reflection.py +393 -0
  249. pyoq/schema/sqlite/source.py +30 -0
  250. pyoq/schema/sqlite/sql.py +254 -0
  251. pyoq/schema/sqlite/types.py +74 -0
  252. pyoq/serving/__init__.py +23 -0
  253. pyoq/serving/databases.py +107 -0
  254. pyoq/serving/opening.py +331 -0
  255. pyoq/snapshots/__init__.py +20 -0
  256. pyoq/snapshots/drift.py +312 -0
  257. pyoq/snapshots/files.py +96 -0
  258. pyoq/snapshots/routing.py +40 -0
  259. pyoq/snapshots/source.py +33 -0
  260. pyoq/tracing/__init__.py +5 -0
  261. pyoq/tracing/spans.py +89 -0
  262. pyoq/unset.py +14 -0
  263. pyoq_sql-1.0.2.dist-info/METADATA +3050 -0
  264. pyoq_sql-1.0.2.dist-info/RECORD +267 -0
  265. pyoq_sql-1.0.2.dist-info/WHEEL +4 -0
  266. pyoq_sql-1.0.2.dist-info/entry_points.txt +3 -0
  267. 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
+ )