pyoq-sql 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. pyoq/__init__.py +7 -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 +19 -0
  9. pyoq/config/loader.py +204 -0
  10. pyoq/config/models.py +243 -0
  11. pyoq/descriptors.py +165 -0
  12. pyoq/diagnostics/__init__.py +68 -0
  13. pyoq/diagnostics/budget.py +136 -0
  14. pyoq/diagnostics/events.py +137 -0
  15. pyoq/diagnostics/fingerprint.py +267 -0
  16. pyoq/diagnostics/instrumented.py +237 -0
  17. pyoq/diagnostics/metrics.py +61 -0
  18. pyoq/diagnostics/observation.py +227 -0
  19. pyoq/diagnostics/scoped.py +103 -0
  20. pyoq/django/__init__.py +15 -0
  21. pyoq/django/apps.py +17 -0
  22. pyoq/django/execution.py +317 -0
  23. pyoq/django/generation.py +59 -0
  24. pyoq/django/management/__init__.py +0 -0
  25. pyoq/django/management/commands/__init__.py +0 -0
  26. pyoq/django/management/commands/makemigrations.py +53 -0
  27. pyoq/django/management/commands/pyoq_codegen.py +75 -0
  28. pyoq/django/parameters.py +101 -0
  29. pyoq/django/schema.py +379 -0
  30. pyoq/django/settings.py +87 -0
  31. pyoq/django/timeouts.py +105 -0
  32. pyoq/dsl/__init__.py +64 -0
  33. pyoq/dsl/aio/__init__.py +31 -0
  34. pyoq/dsl/aio/context.py +295 -0
  35. pyoq/dsl/aio/queries.py +335 -0
  36. pyoq/dsl/aio/writes.py +368 -0
  37. pyoq/dsl/context.py +326 -0
  38. pyoq/dsl/entry.py +37 -0
  39. pyoq/dsl/labels.py +36 -0
  40. pyoq/dsl/queries.py +339 -0
  41. pyoq/dsl/result.py +164 -0
  42. pyoq/dsl/writes.py +360 -0
  43. pyoq/errors.py +317 -0
  44. pyoq/fastapi/__init__.py +32 -0
  45. pyoq/fastapi/dependencies.py +167 -0
  46. pyoq/fastapi/lifespan.py +119 -0
  47. pyoq/fetching/__init__.py +55 -0
  48. pyoq/fetching/collections.py +136 -0
  49. pyoq/fetching/execution.py +587 -0
  50. pyoq/fetching/joined.py +79 -0
  51. pyoq/fetching/nesting.py +183 -0
  52. pyoq/fetching/plans.py +541 -0
  53. pyoq/fetching/select_in.py +149 -0
  54. pyoq/fetching/tables.py +110 -0
  55. pyoq/generation/__init__.py +54 -0
  56. pyoq/generation/cleanup.py +44 -0
  57. pyoq/generation/contracts.py +248 -0
  58. pyoq/generation/drift.py +169 -0
  59. pyoq/generation/lock.py +33 -0
  60. pyoq/generation/manifest.py +114 -0
  61. pyoq/generation/model.py +1001 -0
  62. pyoq/generation/pipeline.py +119 -0
  63. pyoq/generation/rendering/__init__.py +5 -0
  64. pyoq/generation/rendering/domains.py +51 -0
  65. pyoq/generation/rendering/enums.py +29 -0
  66. pyoq/generation/rendering/exports.py +70 -0
  67. pyoq/generation/rendering/imports.py +63 -0
  68. pyoq/generation/rendering/package.py +56 -0
  69. pyoq/generation/rendering/relations.py +133 -0
  70. pyoq/generation/rendering/routines.py +396 -0
  71. pyoq/generation/rendering/rows.py +79 -0
  72. pyoq/generation/rendering/source.py +121 -0
  73. pyoq/generation/rendering/tables.py +300 -0
  74. pyoq/generation/rendering/writes.py +514 -0
  75. pyoq/generation/validation.py +27 -0
  76. pyoq/generation/writer.py +184 -0
  77. pyoq/hydration/__init__.py +24 -0
  78. pyoq/hydration/engine.py +155 -0
  79. pyoq/hydration/identity.py +194 -0
  80. pyoq/hydration/plan.py +116 -0
  81. pyoq/migrations/__init__.py +9 -0
  82. pyoq/migrations/alembic.py +106 -0
  83. pyoq/migrations/hooks.py +75 -0
  84. pyoq/naming.py +261 -0
  85. pyoq/policies/__init__.py +47 -0
  86. pyoq/policies/bypass.py +122 -0
  87. pyoq/policies/governed.py +430 -0
  88. pyoq/policies/model.py +242 -0
  89. pyoq/policies/rewriting.py +263 -0
  90. pyoq/py.typed +1 -0
  91. pyoq/query/__init__.py +312 -0
  92. pyoq/query/aggregates.py +172 -0
  93. pyoq/query/arrays.py +65 -0
  94. pyoq/query/binding.py +52 -0
  95. pyoq/query/capabilities.py +317 -0
  96. pyoq/query/casts.py +73 -0
  97. pyoq/query/choices.py +185 -0
  98. pyoq/query/decoding.py +360 -0
  99. pyoq/query/documents.py +56 -0
  100. pyoq/query/execution/__init__.py +63 -0
  101. pyoq/query/execution/aio/__init__.py +31 -0
  102. pyoq/query/execution/aio/operations.py +228 -0
  103. pyoq/query/execution/aio/pooling.py +233 -0
  104. pyoq/query/execution/aio/streaming.py +161 -0
  105. pyoq/query/execution/aio/transactions.py +105 -0
  106. pyoq/query/execution/batch.py +96 -0
  107. pyoq/query/execution/binding_style.py +30 -0
  108. pyoq/query/execution/compilation.py +48 -0
  109. pyoq/query/execution/context.py +61 -0
  110. pyoq/query/execution/control.py +50 -0
  111. pyoq/query/execution/operations.py +224 -0
  112. pyoq/query/execution/planning.py +107 -0
  113. pyoq/query/execution/pooling.py +279 -0
  114. pyoq/query/execution/results.py +36 -0
  115. pyoq/query/execution/streaming.py +178 -0
  116. pyoq/query/execution/transactions.py +95 -0
  117. pyoq/query/expressions.py +1200 -0
  118. pyoq/query/fields.py +60 -0
  119. pyoq/query/mysql/__init__.py +59 -0
  120. pyoq/query/mysql/aio/__init__.py +38 -0
  121. pyoq/query/mysql/aio/commands.py +389 -0
  122. pyoq/query/mysql/aio/driver.py +196 -0
  123. pyoq/query/mysql/aio/executor.py +123 -0
  124. pyoq/query/mysql/aio/factory.py +26 -0
  125. pyoq/query/mysql/aio/operations.py +38 -0
  126. pyoq/query/mysql/aio/pool.py +53 -0
  127. pyoq/query/mysql/aio/transactions.py +313 -0
  128. pyoq/query/mysql/commands.py +354 -0
  129. pyoq/query/mysql/compiler.py +134 -0
  130. pyoq/query/mysql/context.py +20 -0
  131. pyoq/query/mysql/executor.py +126 -0
  132. pyoq/query/mysql/expressions.py +244 -0
  133. pyoq/query/mysql/factory.py +46 -0
  134. pyoq/query/mysql/health.py +66 -0
  135. pyoq/query/mysql/identifiers.py +9 -0
  136. pyoq/query/mysql/model.py +79 -0
  137. pyoq/query/mysql/operations.py +43 -0
  138. pyoq/query/mysql/parameters.py +69 -0
  139. pyoq/query/mysql/planning.py +20 -0
  140. pyoq/query/mysql/pool.py +67 -0
  141. pyoq/query/mysql/transactions.py +331 -0
  142. pyoq/query/mysql/writes.py +73 -0
  143. pyoq/query/nodes.py +750 -0
  144. pyoq/query/postgres/__init__.py +48 -0
  145. pyoq/query/postgres/aio/__init__.py +25 -0
  146. pyoq/query/postgres/aio/bulk.py +56 -0
  147. pyoq/query/postgres/aio/commands.py +264 -0
  148. pyoq/query/postgres/aio/executor.py +152 -0
  149. pyoq/query/postgres/aio/factory.py +26 -0
  150. pyoq/query/postgres/aio/operations.py +26 -0
  151. pyoq/query/postgres/aio/pool.py +40 -0
  152. pyoq/query/postgres/aio/transactions.py +295 -0
  153. pyoq/query/postgres/bulk.py +62 -0
  154. pyoq/query/postgres/commands.py +238 -0
  155. pyoq/query/postgres/compiler.py +114 -0
  156. pyoq/query/postgres/context.py +20 -0
  157. pyoq/query/postgres/executor.py +147 -0
  158. pyoq/query/postgres/expressions.py +311 -0
  159. pyoq/query/postgres/factory.py +24 -0
  160. pyoq/query/postgres/health.py +24 -0
  161. pyoq/query/postgres/identifiers.py +9 -0
  162. pyoq/query/postgres/model.py +81 -0
  163. pyoq/query/postgres/operations.py +25 -0
  164. pyoq/query/postgres/parameters.py +71 -0
  165. pyoq/query/postgres/planning.py +20 -0
  166. pyoq/query/postgres/pool.py +52 -0
  167. pyoq/query/postgres/transactions.py +295 -0
  168. pyoq/query/postgres/writes.py +37 -0
  169. pyoq/query/projections.py +105 -0
  170. pyoq/query/raw.py +90 -0
  171. pyoq/query/recursion.py +265 -0
  172. pyoq/query/rendering/__init__.py +1 -0
  173. pyoq/query/rendering/expressions.py +913 -0
  174. pyoq/query/rendering/identifiers.py +40 -0
  175. pyoq/query/rendering/projections.py +63 -0
  176. pyoq/query/rendering/queries.py +334 -0
  177. pyoq/query/rendering/sources.py +66 -0
  178. pyoq/query/rendering/writes.py +176 -0
  179. pyoq/query/results.py +459 -0
  180. pyoq/query/routines.py +196 -0
  181. pyoq/query/rows.py +156 -0
  182. pyoq/query/select.py +793 -0
  183. pyoq/query/select_nodes.py +277 -0
  184. pyoq/query/sources.py +236 -0
  185. pyoq/query/sqlite/__init__.py +43 -0
  186. pyoq/query/sqlite/commands.py +201 -0
  187. pyoq/query/sqlite/compiler.py +139 -0
  188. pyoq/query/sqlite/context.py +20 -0
  189. pyoq/query/sqlite/executor.py +119 -0
  190. pyoq/query/sqlite/expressions.py +224 -0
  191. pyoq/query/sqlite/factory.py +32 -0
  192. pyoq/query/sqlite/health.py +28 -0
  193. pyoq/query/sqlite/identifiers.py +9 -0
  194. pyoq/query/sqlite/model.py +73 -0
  195. pyoq/query/sqlite/operations.py +36 -0
  196. pyoq/query/sqlite/parameters.py +50 -0
  197. pyoq/query/sqlite/planning.py +20 -0
  198. pyoq/query/sqlite/pool.py +50 -0
  199. pyoq/query/sqlite/streaming.py +13 -0
  200. pyoq/query/sqlite/transactions.py +274 -0
  201. pyoq/query/sqlite/writes.py +35 -0
  202. pyoq/query/statements.py +27 -0
  203. pyoq/query/values.py +23 -0
  204. pyoq/query/vendor.py +162 -0
  205. pyoq/query/windows.py +424 -0
  206. pyoq/query/write_nodes.py +174 -0
  207. pyoq/query/writes.py +628 -0
  208. pyoq/relations/__init__.py +66 -0
  209. pyoq/relations/batching.py +219 -0
  210. pyoq/relations/derivation.py +111 -0
  211. pyoq/relations/fetching.py +355 -0
  212. pyoq/relations/graph.py +245 -0
  213. pyoq/relations/loading.py +74 -0
  214. pyoq/relations/model.py +75 -0
  215. pyoq/relations/planning.py +206 -0
  216. pyoq/runtime/__init__.py +9 -0
  217. pyoq/runtime/kernels.py +25 -0
  218. pyoq/runtime/python.py +43 -0
  219. pyoq/runtime/selection.py +73 -0
  220. pyoq/sanic/__init__.py +32 -0
  221. pyoq/sanic/scope.py +197 -0
  222. pyoq/sanic/workers.py +129 -0
  223. pyoq/schema/__init__.py +108 -0
  224. pyoq/schema/codec.py +711 -0
  225. pyoq/schema/models.py +604 -0
  226. pyoq/schema/mysql/__init__.py +16 -0
  227. pyoq/schema/mysql/connection.py +72 -0
  228. pyoq/schema/mysql/dsn.py +72 -0
  229. pyoq/schema/mysql/records.py +354 -0
  230. pyoq/schema/mysql/reflection.py +309 -0
  231. pyoq/schema/mysql/source.py +30 -0
  232. pyoq/schema/mysql/sql.py +128 -0
  233. pyoq/schema/mysql/types.py +105 -0
  234. pyoq/schema/postgres/__init__.py +13 -0
  235. pyoq/schema/postgres/connection.py +62 -0
  236. pyoq/schema/postgres/records.py +384 -0
  237. pyoq/schema/postgres/reflection.py +466 -0
  238. pyoq/schema/postgres/source.py +30 -0
  239. pyoq/schema/postgres/sql.py +246 -0
  240. pyoq/schema/postgres/types.py +98 -0
  241. pyoq/schema/registry.py +45 -0
  242. pyoq/schema/source.py +15 -0
  243. pyoq/schema/sqlite/__init__.py +6 -0
  244. pyoq/schema/sqlite/connection.py +54 -0
  245. pyoq/schema/sqlite/records.py +167 -0
  246. pyoq/schema/sqlite/reflection.py +393 -0
  247. pyoq/schema/sqlite/source.py +30 -0
  248. pyoq/schema/sqlite/sql.py +254 -0
  249. pyoq/schema/sqlite/types.py +74 -0
  250. pyoq/serving/__init__.py +19 -0
  251. pyoq/serving/databases.py +107 -0
  252. pyoq/snapshots/__init__.py +20 -0
  253. pyoq/snapshots/drift.py +312 -0
  254. pyoq/snapshots/files.py +96 -0
  255. pyoq/snapshots/routing.py +40 -0
  256. pyoq/snapshots/source.py +33 -0
  257. pyoq/tracing/__init__.py +5 -0
  258. pyoq/tracing/spans.py +89 -0
  259. pyoq/unset.py +14 -0
  260. pyoq_sql-1.0.0.dist-info/METADATA +3034 -0
  261. pyoq_sql-1.0.0.dist-info/RECORD +264 -0
  262. pyoq_sql-1.0.0.dist-info/WHEEL +4 -0
  263. pyoq_sql-1.0.0.dist-info/entry_points.txt +3 -0
  264. pyoq_sql-1.0.0.dist-info/licenses/LICENSE +373 -0
@@ -0,0 +1,237 @@
1
+ """Reporting what every statement did, at the point they all pass.
2
+
3
+ Instrumentation is a database that wraps another one, so a project that wants
4
+ none holds the plain database and pays nothing at all. There is no flag to read
5
+ on the way to the driver because there is nothing in the way.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from time import perf_counter
11
+ from typing import TYPE_CHECKING, TypeVar
12
+
13
+ from pyoq.diagnostics.events import (
14
+ EventPolicy,
15
+ StatementFailed,
16
+ StatementFinished,
17
+ StatementStarted,
18
+ reportable_values,
19
+ )
20
+ from pyoq.diagnostics.fingerprint import query_shape
21
+ from pyoq.query.execution import QueryOperations
22
+ from pyoq.query.execution.aio import AsyncQueryOperations
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Awaitable, Callable
26
+
27
+ from pyoq.diagnostics.events import EventSink
28
+ from pyoq.query.execution import (
29
+ BulkPlan,
30
+ CompiledQuery,
31
+ DatabaseCursor,
32
+ ExecutionControl,
33
+ WriteProvider,
34
+ )
35
+ from pyoq.query.execution.aio import AsyncDatabaseCursor
36
+ from pyoq.query.statements import StatementCompiler
37
+ from pyoq.query.write_nodes import WriteNode
38
+
39
+ OperationResult = TypeVar("OperationResult")
40
+
41
+
42
+ class _Rows:
43
+ """How many rows the cursor reported, which only it can say."""
44
+
45
+ __slots__ = ("count",)
46
+
47
+ def __init__(self) -> None:
48
+ self.count: int | None = None
49
+
50
+
51
+ class InstrumentedOperations(QueryOperations):
52
+ """A database that reports every statement it runs."""
53
+
54
+ __slots__ = ("_inner", "_policy", "_sink")
55
+
56
+ def __init__(
57
+ self,
58
+ inner: QueryOperations,
59
+ sink: EventSink,
60
+ policy: EventPolicy | None = None,
61
+ ) -> None:
62
+ self._inner = inner
63
+ self._sink = sink
64
+ self._policy = policy or EventPolicy()
65
+
66
+ @property
67
+ def policy(self) -> EventPolicy:
68
+ return self._policy
69
+
70
+ @property
71
+ def compiler(self) -> StatementCompiler:
72
+ return self._inner.compiler
73
+
74
+ def plan_bulk(self, statement: WriteProvider | WriteNode, /) -> BulkPlan:
75
+ return self._inner.plan_bulk(statement)
76
+
77
+ def last_inserted_id(self, cursor: DatabaseCursor) -> int | None:
78
+ return self._inner.last_inserted_id(cursor)
79
+
80
+ def _run(
81
+ self,
82
+ statement: CompiledQuery,
83
+ operation: Callable[[DatabaseCursor], OperationResult],
84
+ control: ExecutionControl | None,
85
+ ) -> OperationResult:
86
+ report = _Report(statement, self._sink, self._policy)
87
+ rows = _Rows()
88
+ report.started()
89
+ try:
90
+ outcome = self._inner._run(statement, _counting(operation, rows), control)
91
+ except BaseException as error:
92
+ report.failed(error)
93
+ raise
94
+ report.finished(rows.count)
95
+ return outcome
96
+
97
+
98
+ class AsyncInstrumentedOperations(AsyncQueryOperations):
99
+ """The asynchronous counterpart, reporting at the same point."""
100
+
101
+ __slots__ = ("_inner", "_policy", "_sink")
102
+
103
+ def __init__(
104
+ self,
105
+ inner: AsyncQueryOperations,
106
+ sink: EventSink,
107
+ policy: EventPolicy | None = None,
108
+ ) -> None:
109
+ self._inner = inner
110
+ self._sink = sink
111
+ self._policy = policy or EventPolicy()
112
+
113
+ @property
114
+ def policy(self) -> EventPolicy:
115
+ return self._policy
116
+
117
+ @property
118
+ def compiler(self) -> StatementCompiler:
119
+ return self._inner.compiler
120
+
121
+ def plan_bulk(self, statement: WriteProvider | WriteNode, /) -> BulkPlan:
122
+ return self._inner.plan_bulk(statement)
123
+
124
+ def last_inserted_id(self, cursor: AsyncDatabaseCursor) -> int | None:
125
+ return self._inner.last_inserted_id(cursor)
126
+
127
+ async def _run(
128
+ self,
129
+ statement: CompiledQuery,
130
+ operation: Callable[[AsyncDatabaseCursor], Awaitable[OperationResult]],
131
+ control: ExecutionControl | None,
132
+ ) -> OperationResult:
133
+ report = _Report(statement, self._sink, self._policy)
134
+ rows = _Rows()
135
+ report.started()
136
+ try:
137
+ outcome = await self._inner._run(
138
+ statement, _counting_async(operation, rows), control
139
+ )
140
+ except BaseException as error:
141
+ report.failed(error)
142
+ raise
143
+ report.finished(rows.count)
144
+ return outcome
145
+
146
+
147
+ class _Report:
148
+ """One statement's events, timed from the moment it was described."""
149
+
150
+ __slots__ = ("_policy", "_shape", "_sink", "_started", "_statement")
151
+
152
+ def __init__(
153
+ self,
154
+ statement: CompiledQuery,
155
+ sink: EventSink,
156
+ policy: EventPolicy,
157
+ ) -> None:
158
+ self._statement = statement
159
+ self._sink = sink
160
+ self._policy = policy
161
+ self._shape = query_shape(statement.sql)
162
+ self._started = perf_counter()
163
+
164
+ def started(self) -> None:
165
+ self._sink.record(
166
+ StatementStarted(
167
+ shape=self._shape,
168
+ kind=self._statement.statement_kind,
169
+ parameters=len(self._statement.parameters),
170
+ sensitive_parameters=len(self._statement.sensitive_parameter_indexes),
171
+ values=self._values(),
172
+ )
173
+ )
174
+
175
+ def finished(self, rows: int | None) -> None:
176
+ elapsed = perf_counter() - self._started
177
+ threshold = self._policy.slow_after
178
+ self._sink.record(
179
+ StatementFinished(
180
+ shape=self._shape,
181
+ kind=self._statement.statement_kind,
182
+ parameters=len(self._statement.parameters),
183
+ sensitive_parameters=len(self._statement.sensitive_parameter_indexes),
184
+ duration=elapsed,
185
+ rows=rows,
186
+ slow=threshold is not None and elapsed >= threshold,
187
+ values=self._values(),
188
+ )
189
+ )
190
+
191
+ def failed(self, error: BaseException) -> None:
192
+ self._sink.record(
193
+ StatementFailed(
194
+ shape=self._shape,
195
+ kind=self._statement.statement_kind,
196
+ parameters=len(self._statement.parameters),
197
+ sensitive_parameters=len(self._statement.sensitive_parameter_indexes),
198
+ duration=perf_counter() - self._started,
199
+ failure=type(error).__name__,
200
+ detail=self._detail(error),
201
+ values=self._values(),
202
+ )
203
+ )
204
+
205
+ def _values(self) -> tuple[object, ...]:
206
+ return reportable_values(self._statement, self._policy)
207
+
208
+ def _detail(self, error: BaseException) -> str | None:
209
+ """A driver names the offending value in its message, so this is asked."""
210
+ return str(error) if self._policy.include_failure_detail else None
211
+
212
+
213
+ def _counting(
214
+ operation: Callable[[DatabaseCursor], OperationResult],
215
+ rows: _Rows,
216
+ ) -> Callable[[DatabaseCursor], OperationResult]:
217
+ def counted(cursor: DatabaseCursor) -> OperationResult:
218
+ outcome = operation(cursor)
219
+ rows.count = cursor.rowcount
220
+ return outcome
221
+
222
+ return counted
223
+
224
+
225
+ def _counting_async(
226
+ operation: Callable[[AsyncDatabaseCursor], Awaitable[OperationResult]],
227
+ rows: _Rows,
228
+ ) -> Callable[[AsyncDatabaseCursor], Awaitable[OperationResult]]:
229
+ async def counted(cursor: AsyncDatabaseCursor) -> OperationResult:
230
+ outcome = await operation(cursor)
231
+ rows.count = cursor.rowcount
232
+ return outcome
233
+
234
+ return counted
235
+
236
+
237
+ __all__ = ("AsyncInstrumentedOperations", "InstrumentedOperations")
@@ -0,0 +1,61 @@
1
+ """What a database is doing right now, as numbers.
2
+
3
+ A pool already knows its own counts and an observer already knows what has run.
4
+ This puts the two together so one reading describes both, and adds nothing that
5
+ would have to be kept up to date separately.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING, Protocol
12
+
13
+ if TYPE_CHECKING:
14
+ from pyoq.diagnostics.observation import QueryObserver
15
+ from pyoq.query.execution import PoolStats
16
+
17
+
18
+ class ReportsPoolStats(Protocol):
19
+ """Anything that can say how its connections are being used."""
20
+
21
+ @property
22
+ def stats(self) -> PoolStats: ...
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class DatabaseMetrics:
27
+ """One reading, carrying counts and nothing a query was asked about."""
28
+
29
+ open_connections: int
30
+ idle_connections: int
31
+ checked_out_connections: int
32
+ closed: bool
33
+ statements: int
34
+ shapes: int
35
+
36
+ @property
37
+ def in_use(self) -> float:
38
+ """How much of what is open is checked out, between zero and one."""
39
+ if self.open_connections == 0:
40
+ return 0.0
41
+ return self.checked_out_connections / self.open_connections
42
+
43
+
44
+ def metrics_of(
45
+ pool: ReportsPoolStats,
46
+ /,
47
+ observer: QueryObserver | None = None,
48
+ ) -> DatabaseMetrics:
49
+ """A reading taken now, from a pool and optionally from what it has run."""
50
+ stats = pool.stats
51
+ return DatabaseMetrics(
52
+ open_connections=stats.open_connections,
53
+ idle_connections=stats.idle_connections,
54
+ checked_out_connections=stats.checked_out_connections,
55
+ closed=stats.closed,
56
+ statements=0 if observer is None else observer.executions,
57
+ shapes=0 if observer is None else len(observer.repeated(threshold=1)),
58
+ )
59
+
60
+
61
+ __all__ = ("DatabaseMetrics", "ReportsPoolStats", "metrics_of")
@@ -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"