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,227 @@
1
+ """Watching what one scope executed, so a repeated query becomes visible.
2
+
3
+ A query issued once per row of a previous result is the shape of an N+1 access,
4
+ and it is invisible from inside the loop that causes it. Recording shapes for
5
+ the length of a scope makes it visible from outside.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from inspect import currentframe
12
+ from threading import Lock
13
+ from types import FrameType
14
+
15
+ from pyoq.diagnostics.fingerprint import QueryShape, query_shape
16
+ from pyoq.errors import QueryValidationError
17
+ from pyoq.query.execution import CompiledQuery
18
+ from pyoq.query.execution.results import StatementKind
19
+
20
+ DEFAULT_SHAPE_LIMIT = 256
21
+ DEFAULT_SITE_LIMIT = 5
22
+ DEFAULT_REPEAT_THRESHOLD = 2
23
+
24
+ _OWN_PACKAGE = "pyoq."
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class CallSite:
29
+ """Where in the caller's own code a query was issued."""
30
+
31
+ file: str
32
+ line: int
33
+ function: str
34
+
35
+ def __str__(self) -> str:
36
+ return f"{self.file}:{self.line} in {self.function}"
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class RepeatedQuery:
41
+ """One shape a scope executed more than once, and where from."""
42
+
43
+ shape: QueryShape
44
+ executions: int
45
+ kinds: frozenset[StatementKind]
46
+ sites: tuple[CallSite, ...]
47
+
48
+ def describe(self) -> str:
49
+ places = "; ".join(str(site) for site in self.sites)
50
+ suffix = f" from {places}" if places else ""
51
+ return f"{self.executions} executions of {self.shape.sql}{suffix}"
52
+
53
+
54
+ def _no_kinds() -> set[StatementKind]:
55
+ return set()
56
+
57
+
58
+ def _no_sites() -> dict[CallSite, None]:
59
+ """An ordered set of places, so a report reads in the order they occurred."""
60
+ return {}
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class _Observed:
65
+ shape: QueryShape
66
+ executions: int = 0
67
+ kinds: set[StatementKind] = field(default_factory=_no_kinds)
68
+ sites: dict[CallSite, None] = field(default_factory=_no_sites)
69
+
70
+
71
+ class QueryObserver:
72
+ """What one scope executed, kept within a fixed budget of memory.
73
+
74
+ Diagnostics must not become the thing that exhausts a process, so the number
75
+ of distinct shapes and the number of places recorded per shape are both
76
+ capped. Executions keep being counted after the caps are reached.
77
+
78
+ An observer is safe to share. A pool hands connections to whichever thread
79
+ asks, so an observer watching a whole application is written to from several
80
+ at once, and reading a report while that happens must not fail.
81
+ """
82
+
83
+ __slots__ = (
84
+ "_capture_sites",
85
+ "_executions",
86
+ "_lock",
87
+ "_observed",
88
+ "_shape_limit",
89
+ "_site_limit",
90
+ "_unrecorded_shapes",
91
+ )
92
+
93
+ def __init__(
94
+ self,
95
+ *,
96
+ shape_limit: int = DEFAULT_SHAPE_LIMIT,
97
+ site_limit: int = DEFAULT_SITE_LIMIT,
98
+ capture_sites: bool = True,
99
+ ) -> None:
100
+ _require_positive(shape_limit, "shape limit")
101
+ _require_positive(site_limit, "site limit")
102
+ self._shape_limit = shape_limit
103
+ self._site_limit = site_limit
104
+ self._capture_sites = capture_sites
105
+ self._observed: dict[str, _Observed] = {}
106
+ self._executions = 0
107
+ self._unrecorded_shapes = 0
108
+ self._lock = Lock()
109
+
110
+ @property
111
+ def executions(self) -> int:
112
+ """Every statement this scope executed, including ones not recorded."""
113
+ return self._executions
114
+
115
+ @property
116
+ def shapes(self) -> int:
117
+ with self._lock:
118
+ return len(self._observed)
119
+
120
+ @property
121
+ def unrecorded_shapes(self) -> int:
122
+ """Distinct shapes seen after the limit, counted but not kept."""
123
+ return self._unrecorded_shapes
124
+
125
+ def record(self, statement: CompiledQuery, /) -> int:
126
+ """Record a statement and report how often its shape has now run.
127
+
128
+ A shape seen after the cap reports nothing, because it is counted but
129
+ not kept. A query repeated is one shape repeated, so a scope with more
130
+ distinct shapes than the cap has a different problem.
131
+ """
132
+ shape = query_shape(statement.sql)
133
+ site = self._site()
134
+ with self._lock:
135
+ self._executions += 1
136
+ observed = self._observed.get(shape.digest)
137
+ if observed is None:
138
+ if len(self._observed) >= self._shape_limit:
139
+ self._unrecorded_shapes += 1
140
+ return 0
141
+ observed = _Observed(shape)
142
+ self._observed[shape.digest] = observed
143
+ observed.executions += 1
144
+ observed.kinds.add(statement.statement_kind)
145
+ if site is not None and len(observed.sites) < self._site_limit:
146
+ observed.sites[site] = None
147
+ return observed.executions
148
+
149
+ def report(self, digest: str, /) -> RepeatedQuery | None:
150
+ """One shape's report, for a caller that already knows which."""
151
+ with self._lock:
152
+ observed = self._observed.get(digest)
153
+ if observed is None:
154
+ return None
155
+ return _report(observed)
156
+
157
+ def repeated(
158
+ self,
159
+ *,
160
+ threshold: int = DEFAULT_REPEAT_THRESHOLD,
161
+ ) -> tuple[RepeatedQuery, ...]:
162
+ """Shapes executed at least ``threshold`` times, busiest first."""
163
+ _require_positive(threshold, "repeat threshold")
164
+ with self._lock:
165
+ matching = [
166
+ _report(observed)
167
+ for observed in self._observed.values()
168
+ if observed.executions >= threshold
169
+ ]
170
+ matching.sort(key=lambda entry: (-entry.executions, entry.shape.digest))
171
+ return tuple(matching)
172
+
173
+ def _site(self) -> CallSite | None:
174
+ """Read the caller's frame outside the lock, where it still is theirs."""
175
+ return calling_site() if self._capture_sites else None
176
+
177
+
178
+ def _report(observed: _Observed) -> RepeatedQuery:
179
+ return RepeatedQuery(
180
+ observed.shape,
181
+ observed.executions,
182
+ frozenset(observed.kinds),
183
+ tuple(observed.sites),
184
+ )
185
+
186
+
187
+ def calling_site() -> CallSite | None:
188
+ """The nearest frame that is not PyOQ's own.
189
+
190
+ A query is issued from inside this library, so the frame that matters is
191
+ the first one above it, which is the caller's own code. An interpreter that
192
+ does not offer frames reports no place rather than refusing to run.
193
+ """
194
+ frame = currentframe()
195
+ while frame is not None:
196
+ if not _is_own_frame(frame):
197
+ return CallSite(
198
+ frame.f_code.co_filename,
199
+ frame.f_lineno,
200
+ frame.f_code.co_qualname,
201
+ )
202
+ frame = frame.f_back
203
+ return None
204
+
205
+
206
+ def _is_own_frame(frame: FrameType) -> bool:
207
+ module = frame.f_globals.get("__name__", "")
208
+ return isinstance(module, str) and (
209
+ module == "pyoq" or module.startswith(_OWN_PACKAGE)
210
+ )
211
+
212
+
213
+ def _require_positive(value: int, label: str) -> None:
214
+ if value < 1:
215
+ message = f"{label} must be at least one"
216
+ raise QueryValidationError(message)
217
+
218
+
219
+ __all__ = (
220
+ "DEFAULT_REPEAT_THRESHOLD",
221
+ "DEFAULT_SHAPE_LIMIT",
222
+ "DEFAULT_SITE_LIMIT",
223
+ "CallSite",
224
+ "QueryObserver",
225
+ "RepeatedQuery",
226
+ "calling_site",
227
+ )
@@ -0,0 +1,103 @@
1
+ """Counting what one scope's database work actually does.
2
+
3
+ A budget is only worth holding if something records against it. Every read and
4
+ write funnels through one place before it reaches a driver, so that is where a
5
+ statement is counted, and nothing about a dialect has to know a budget exists.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, TypeVar
11
+
12
+ from pyoq.query.execution import QueryOperations
13
+ from pyoq.query.execution.aio import AsyncQueryOperations
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Awaitable, Callable
17
+
18
+ from pyoq.diagnostics.budget import QueryScope
19
+ from pyoq.query.execution import (
20
+ BulkPlan,
21
+ CompiledQuery,
22
+ DatabaseCursor,
23
+ ExecutionControl,
24
+ WriteProvider,
25
+ )
26
+ from pyoq.query.execution.aio import AsyncDatabaseCursor
27
+ from pyoq.query.statements import StatementCompiler
28
+ from pyoq.query.write_nodes import WriteNode
29
+
30
+ OperationResult = TypeVar("OperationResult")
31
+
32
+
33
+ class ScopedOperations(QueryOperations):
34
+ """A database that reports every statement to a scope before running it.
35
+
36
+ Recording happens after compilation and before the driver sees anything, so
37
+ a statement that a budget refuses never reaches the database at all.
38
+ """
39
+
40
+ __slots__ = ("_inner", "_scope")
41
+
42
+ def __init__(self, inner: QueryOperations, scope: QueryScope) -> None:
43
+ self._inner = inner
44
+ self._scope = scope
45
+
46
+ @property
47
+ def scope(self) -> QueryScope:
48
+ return self._scope
49
+
50
+ @property
51
+ def compiler(self) -> StatementCompiler:
52
+ return self._inner.compiler
53
+
54
+ def plan_bulk(self, statement: WriteProvider | WriteNode, /) -> BulkPlan:
55
+ return self._inner.plan_bulk(statement)
56
+
57
+ def last_inserted_id(self, cursor: DatabaseCursor) -> int | None:
58
+ return self._inner.last_inserted_id(cursor)
59
+
60
+ def _run(
61
+ self,
62
+ statement: CompiledQuery,
63
+ operation: Callable[[DatabaseCursor], OperationResult],
64
+ control: ExecutionControl | None,
65
+ ) -> OperationResult:
66
+ self._scope.record(statement)
67
+ return self._inner._run(statement, operation, control)
68
+
69
+
70
+ class AsyncScopedOperations(AsyncQueryOperations):
71
+ """The asynchronous counterpart, counting at the same point."""
72
+
73
+ __slots__ = ("_inner", "_scope")
74
+
75
+ def __init__(self, inner: AsyncQueryOperations, scope: QueryScope) -> None:
76
+ self._inner = inner
77
+ self._scope = scope
78
+
79
+ @property
80
+ def scope(self) -> QueryScope:
81
+ return self._scope
82
+
83
+ @property
84
+ def compiler(self) -> StatementCompiler:
85
+ return self._inner.compiler
86
+
87
+ def plan_bulk(self, statement: WriteProvider | WriteNode, /) -> BulkPlan:
88
+ return self._inner.plan_bulk(statement)
89
+
90
+ def last_inserted_id(self, cursor: AsyncDatabaseCursor) -> int | None:
91
+ return self._inner.last_inserted_id(cursor)
92
+
93
+ async def _run(
94
+ self,
95
+ statement: CompiledQuery,
96
+ operation: Callable[[AsyncDatabaseCursor], Awaitable[OperationResult]],
97
+ control: ExecutionControl | None,
98
+ ) -> OperationResult:
99
+ self._scope.record(statement)
100
+ return await self._inner._run(statement, operation, control)
101
+
102
+
103
+ __all__ = ("AsyncScopedOperations", "ScopedOperations")
@@ -0,0 +1,15 @@
1
+ """Running PyOQ inside a Django project."""
2
+
3
+ from pyoq.django.execution import Dialect, DjangoOperations, alias_for, dialect_for
4
+ from pyoq.django.generation import GenerationRequest, generate
5
+ from pyoq.django.schema import MigrationStateSchemaSource
6
+
7
+ __all__ = (
8
+ "Dialect",
9
+ "DjangoOperations",
10
+ "GenerationRequest",
11
+ "MigrationStateSchemaSource",
12
+ "alias_for",
13
+ "dialect_for",
14
+ "generate",
15
+ )
pyoq/django/apps.py ADDED
@@ -0,0 +1,17 @@
1
+ """The application entry a project adds to install PyOQ's commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django.apps import AppConfig
6
+
7
+
8
+ class PyoqConfig(AppConfig):
9
+ """Named for the toolkit rather than for the package it lives under.
10
+
11
+ The label Django would infer from `pyoq.django` is `django`, which reads as
12
+ the framework itself everywhere a label is shown.
13
+ """
14
+
15
+ name = "pyoq.django"
16
+ label = "pyoq"
17
+ verbose_name = "PyOQ"
@@ -0,0 +1,317 @@
1
+ """Running PyOQ statements on the connection Django already has open.
2
+
3
+ Django owns its connections, their aliases, and whatever transaction is in
4
+ progress. PyOQ borrows one rather than opening a pool beside it, because two
5
+ pools against one database is two views of what has been committed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Generator, Sequence
11
+ from contextlib import AbstractContextManager, contextmanager
12
+ from dataclasses import dataclass
13
+ from typing import Protocol, TypeVar, cast
14
+
15
+ from django.db import DEFAULT_DB_ALIAS, Error, connections, router, transaction
16
+ from django.db.backends.base.base import BaseDatabaseWrapper
17
+ from django.db.models import Model
18
+
19
+ from pyoq.django.parameters import DjangoValue, adapt_parameters
20
+ from pyoq.django.timeouts import (
21
+ MYSQL_TIMEOUT,
22
+ POSTGRES_TIMEOUT,
23
+ TimeoutSetting,
24
+ applied,
25
+ )
26
+ from pyoq.errors import (
27
+ OperationUnavailableError,
28
+ QueryCancelledError,
29
+ QueryExecutionError,
30
+ )
31
+ from pyoq.query.execution import (
32
+ BulkPlan,
33
+ CompiledQuery,
34
+ DatabaseCursor,
35
+ ExecutionControl,
36
+ QueryOperations,
37
+ WriteProvider,
38
+ )
39
+ from pyoq.query.statements import StatementCompiler
40
+ from pyoq.query.write_nodes import WriteNode
41
+
42
+ OperationResult = TypeVar("OperationResult")
43
+
44
+
45
+ class BulkPlanner(Protocol):
46
+ def plan(self, statement: WriteProvider | WriteNode, /) -> BulkPlan: ...
47
+
48
+
49
+ class LastRowCursor(Protocol):
50
+ @property
51
+ def lastrowid(self) -> object: ...
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class Dialect:
56
+ """What the backend Django is already speaking needs from PyOQ.
57
+
58
+ Values are adapted the same way they are on PyOQ's own connections, because
59
+ a backend does not care whose connection it is: a decimal or a date still
60
+ has to reach it in the shape its driver accepts.
61
+ """
62
+
63
+ compiler: StatementCompiler
64
+ planner: BulkPlanner
65
+ timeout: TimeoutSetting | None = None
66
+
67
+
68
+ def _sqlite() -> Dialect:
69
+ from pyoq.query.execution import ParameterStyle
70
+ from pyoq.query.sqlite import (
71
+ SQLiteBulkPlanner,
72
+ SQLiteCapabilities,
73
+ SQLiteCompiler,
74
+ )
75
+
76
+ compiler = SQLiteCompiler(SQLiteCapabilities(parameter_style=ParameterStyle.FORMAT))
77
+ return Dialect(compiler, SQLiteBulkPlanner(compiler))
78
+
79
+
80
+ def _postgres() -> Dialect:
81
+ from pyoq.query.postgres import PostgresBulkPlanner, PostgresCompiler
82
+
83
+ compiler = PostgresCompiler()
84
+ return Dialect(compiler, PostgresBulkPlanner(compiler), POSTGRES_TIMEOUT)
85
+
86
+
87
+ def _mysql() -> Dialect:
88
+ from pyoq.query.mysql import MySQLBulkPlanner, MySQLCompiler
89
+
90
+ compiler = MySQLCompiler()
91
+ return Dialect(compiler, MySQLBulkPlanner(compiler), MYSQL_TIMEOUT)
92
+
93
+
94
+ _DIALECTS: dict[str, Callable[[], Dialect]] = {
95
+ "sqlite": _sqlite,
96
+ "postgresql": _postgres,
97
+ "mysql": _mysql,
98
+ }
99
+ """How to build each dialect, called once a connection turns out to be one.
100
+
101
+ A dialect package imports the driver it speaks to, so building these eagerly
102
+ would make the Django extra need every driver PyOQ supports.
103
+ """
104
+
105
+ _DRIVER_EXTRAS: dict[str, str] = {"postgresql": "postgres", "mysql": "mysql"}
106
+ """What a project installs to reach each backend.
107
+
108
+ SQLite is absent because it needs nothing, and so can never be the one that
109
+ is missing.
110
+ """
111
+
112
+
113
+ def dialect_for(connection: BaseDatabaseWrapper, /) -> Dialect:
114
+ """Choose the dialect Django is already speaking.
115
+
116
+ The connection decides, not the caller. Speaking a different dialect than
117
+ the one on the other end of the socket is not a choice worth offering.
118
+ """
119
+ factory = _DIALECTS.get(connection.vendor)
120
+ if factory is None:
121
+ message = (
122
+ f"PyOQ has no dialect for the Django backend {connection.vendor!r}; "
123
+ f"supported backends: {', '.join(sorted(_DIALECTS))}"
124
+ )
125
+ raise OperationUnavailableError(message)
126
+ return _built(factory, connection.vendor)
127
+
128
+
129
+ def _built(factory: Callable[[], Dialect], vendor: str) -> Dialect:
130
+ """Say which extra is missing, rather than which module is.
131
+
132
+ A project installs the drivers it uses, so reaching a backend it did not
133
+ install is an ordinary mistake and worth answering with the fix.
134
+ """
135
+ try:
136
+ return factory()
137
+ except ImportError as error:
138
+ extra = _DRIVER_EXTRAS[vendor]
139
+ message = (
140
+ f"the Django backend {vendor!r} needs a driver PyOQ does not "
141
+ f"install by default; add it with pip install "
142
+ f"'pyoq-sql[django,{extra}]'"
143
+ )
144
+ raise OperationUnavailableError(message) from error
145
+
146
+
147
+ def alias_for(
148
+ model: type[Model],
149
+ /,
150
+ *,
151
+ write: bool = False,
152
+ **hints: object,
153
+ ) -> str:
154
+ """Ask Django's routers which database a model lives in.
155
+
156
+ Routing is a question about a model, because that is the only thing a
157
+ router is given to decide on. A caller with a model gets the answer its own
158
+ routers would give; a caller without one names the alias instead.
159
+ """
160
+ if write:
161
+ return str(router.db_for_write(model, **hints) or DEFAULT_DB_ALIAS)
162
+ return str(router.db_for_read(model, **hints) or DEFAULT_DB_ALIAS)
163
+
164
+
165
+ class DjangoOperations(QueryOperations):
166
+ """Typed operations against one Django database alias.
167
+
168
+ The connection is resolved for each statement rather than held, because
169
+ Django hands a different connection to each thread and closes them between
170
+ requests, so holding one would outlive what it belongs to.
171
+ """
172
+
173
+ __slots__ = ("_alias", "_dialect")
174
+
175
+ def __init__(self, alias: str = DEFAULT_DB_ALIAS) -> None:
176
+ self._alias = alias
177
+ self._dialect = dialect_for(self.connection)
178
+
179
+ @classmethod
180
+ def for_model(
181
+ cls,
182
+ model: type[Model],
183
+ /,
184
+ *,
185
+ write: bool = False,
186
+ **hints: object,
187
+ ) -> DjangoOperations:
188
+ """Run against whichever database this project routes a model to."""
189
+ return cls(alias_for(model, write=write, **hints))
190
+
191
+ @property
192
+ def alias(self) -> str:
193
+ return self._alias
194
+
195
+ @property
196
+ def connection(self) -> BaseDatabaseWrapper:
197
+ """Whatever connection Django holds for this alias right now."""
198
+ return connections[self._alias]
199
+
200
+ @property
201
+ def compiler(self) -> StatementCompiler:
202
+ return self._dialect.compiler
203
+
204
+ @property
205
+ def in_transaction(self) -> bool:
206
+ """Whether this database is inside a transaction right now."""
207
+ return self.connection.in_atomic_block
208
+
209
+ def atomic(
210
+ self,
211
+ *,
212
+ savepoint: bool = True,
213
+ durable: bool = False,
214
+ ) -> AbstractContextManager[None]:
215
+ """A transaction on this database, rather than on whichever is default.
216
+
217
+ Django's own block covers the default alias unless told otherwise, so a
218
+ caller running here against another alias would get no transaction at
219
+ all and no indication of it. Nesting one of these is a savepoint, which
220
+ is what Django already does.
221
+
222
+ PyOQ never commits or rolls back. The block that opened a transaction is
223
+ the one that ends it, and here that block is Django's.
224
+ """
225
+ return transaction.atomic(
226
+ using=self._alias, savepoint=savepoint, durable=durable
227
+ )
228
+
229
+ def on_commit(
230
+ self,
231
+ callback: Callable[[], object],
232
+ /,
233
+ *,
234
+ robust: bool = False,
235
+ ) -> None:
236
+ """Run something once this database's transaction has committed.
237
+
238
+ Outside a transaction it runs immediately, which is Django's own rule
239
+ and the right one: there is nothing left to wait for.
240
+ """
241
+ transaction.on_commit(callback, using=self._alias, robust=robust)
242
+
243
+ def plan_bulk(self, statement: WriteProvider | WriteNode, /) -> BulkPlan:
244
+ return self._dialect.planner.plan(statement)
245
+
246
+ def last_inserted_id(self, cursor: DatabaseCursor) -> int | None:
247
+ identifier = _last_row_identifier(cursor)
248
+ if not isinstance(identifier, int) or identifier <= 0:
249
+ return None
250
+ return identifier
251
+
252
+ def _run(
253
+ self,
254
+ statement: CompiledQuery,
255
+ operation: Callable[[DatabaseCursor], OperationResult],
256
+ control: ExecutionControl | None,
257
+ ) -> OperationResult:
258
+ _checkpoint(control)
259
+ connection = self.connection
260
+ adapted = adapt_parameters(connection, statement.parameters)
261
+ parameters = cast("Sequence[DjangoValue]", adapted)
262
+ with self._bounded(control), connection.cursor() as cursor:
263
+ try:
264
+ # Django's stub omits the duration values its own interval
265
+ # backends accept, so the call is wider than the stub says.
266
+ cursor.execute(statement.sql, parameters) # type: ignore[arg-type]
267
+ result = operation(cast("DatabaseCursor", cursor))
268
+ except Error as error:
269
+ message = f"Django query execution failed: {error}"
270
+ raise QueryExecutionError(message) from error
271
+ _checkpoint(control)
272
+ return result
273
+
274
+ @contextmanager
275
+ def _bounded(self, control: ExecutionControl | None) -> Generator[None]:
276
+ """Bound the statement where the backend offers a way to.
277
+
278
+ SQLite has no session setting for this, and MySQL's covers reads only,
279
+ so a caller is given what the backend can honour rather than a promise
280
+ it cannot keep.
281
+ """
282
+ setting = self._dialect.timeout
283
+ seconds = None if control is None else control.timeout
284
+ if setting is None or seconds is None:
285
+ yield
286
+ return
287
+ with applied(setting, self.connection, seconds):
288
+ yield
289
+
290
+
291
+ def _last_row_identifier(cursor: DatabaseCursor) -> object:
292
+ """Not every backend offers one, and none of them promise a type."""
293
+ return getattr(cursor, "lastrowid", None)
294
+
295
+
296
+ def _checkpoint(control: ExecutionControl | None) -> None:
297
+ """Honour a caller's own limits without touching Django's connection.
298
+
299
+ A server-side timeout is a change to session state, and the session belongs
300
+ to Django. What is left is refusing before and after a statement, which is
301
+ what a caller asking for cancellation can be given honestly.
302
+ """
303
+ if control is None:
304
+ return
305
+ token = control.cancellation_token
306
+ if token is not None and token.cancelled:
307
+ message = "Django query execution was cancelled"
308
+ raise QueryCancelledError(message)
309
+
310
+
311
+ __all__ = (
312
+ "BulkPlanner",
313
+ "Dialect",
314
+ "DjangoOperations",
315
+ "alias_for",
316
+ "dialect_for",
317
+ )