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.
- pyoq/__init__.py +7 -0
- pyoq/__main__.py +5 -0
- pyoq/_native.pyi +5 -0
- pyoq/cli/__init__.py +5 -0
- pyoq/cli/commands.py +270 -0
- pyoq/cli/defaults.py +98 -0
- pyoq/cli/services.py +97 -0
- pyoq/config/__init__.py +19 -0
- pyoq/config/loader.py +204 -0
- pyoq/config/models.py +243 -0
- pyoq/descriptors.py +165 -0
- pyoq/diagnostics/__init__.py +68 -0
- pyoq/diagnostics/budget.py +136 -0
- pyoq/diagnostics/events.py +137 -0
- pyoq/diagnostics/fingerprint.py +267 -0
- pyoq/diagnostics/instrumented.py +237 -0
- pyoq/diagnostics/metrics.py +61 -0
- pyoq/diagnostics/observation.py +227 -0
- pyoq/diagnostics/scoped.py +103 -0
- pyoq/django/__init__.py +15 -0
- pyoq/django/apps.py +17 -0
- pyoq/django/execution.py +317 -0
- pyoq/django/generation.py +59 -0
- pyoq/django/management/__init__.py +0 -0
- pyoq/django/management/commands/__init__.py +0 -0
- pyoq/django/management/commands/makemigrations.py +53 -0
- pyoq/django/management/commands/pyoq_codegen.py +75 -0
- pyoq/django/parameters.py +101 -0
- pyoq/django/schema.py +379 -0
- pyoq/django/settings.py +87 -0
- pyoq/django/timeouts.py +105 -0
- pyoq/dsl/__init__.py +64 -0
- pyoq/dsl/aio/__init__.py +31 -0
- pyoq/dsl/aio/context.py +295 -0
- pyoq/dsl/aio/queries.py +335 -0
- pyoq/dsl/aio/writes.py +368 -0
- pyoq/dsl/context.py +326 -0
- pyoq/dsl/entry.py +37 -0
- pyoq/dsl/labels.py +36 -0
- pyoq/dsl/queries.py +339 -0
- pyoq/dsl/result.py +164 -0
- pyoq/dsl/writes.py +360 -0
- pyoq/errors.py +317 -0
- pyoq/fastapi/__init__.py +32 -0
- pyoq/fastapi/dependencies.py +167 -0
- pyoq/fastapi/lifespan.py +119 -0
- pyoq/fetching/__init__.py +55 -0
- pyoq/fetching/collections.py +136 -0
- pyoq/fetching/execution.py +587 -0
- pyoq/fetching/joined.py +79 -0
- pyoq/fetching/nesting.py +183 -0
- pyoq/fetching/plans.py +541 -0
- pyoq/fetching/select_in.py +149 -0
- pyoq/fetching/tables.py +110 -0
- pyoq/generation/__init__.py +54 -0
- pyoq/generation/cleanup.py +44 -0
- pyoq/generation/contracts.py +248 -0
- pyoq/generation/drift.py +169 -0
- pyoq/generation/lock.py +33 -0
- pyoq/generation/manifest.py +114 -0
- pyoq/generation/model.py +1001 -0
- pyoq/generation/pipeline.py +119 -0
- pyoq/generation/rendering/__init__.py +5 -0
- pyoq/generation/rendering/domains.py +51 -0
- pyoq/generation/rendering/enums.py +29 -0
- pyoq/generation/rendering/exports.py +70 -0
- pyoq/generation/rendering/imports.py +63 -0
- pyoq/generation/rendering/package.py +56 -0
- pyoq/generation/rendering/relations.py +133 -0
- pyoq/generation/rendering/routines.py +396 -0
- pyoq/generation/rendering/rows.py +79 -0
- pyoq/generation/rendering/source.py +121 -0
- pyoq/generation/rendering/tables.py +300 -0
- pyoq/generation/rendering/writes.py +514 -0
- pyoq/generation/validation.py +27 -0
- pyoq/generation/writer.py +184 -0
- pyoq/hydration/__init__.py +24 -0
- pyoq/hydration/engine.py +155 -0
- pyoq/hydration/identity.py +194 -0
- pyoq/hydration/plan.py +116 -0
- pyoq/migrations/__init__.py +9 -0
- pyoq/migrations/alembic.py +106 -0
- pyoq/migrations/hooks.py +75 -0
- pyoq/naming.py +261 -0
- pyoq/policies/__init__.py +47 -0
- pyoq/policies/bypass.py +122 -0
- pyoq/policies/governed.py +430 -0
- pyoq/policies/model.py +242 -0
- pyoq/policies/rewriting.py +263 -0
- pyoq/py.typed +1 -0
- pyoq/query/__init__.py +312 -0
- pyoq/query/aggregates.py +172 -0
- pyoq/query/arrays.py +65 -0
- pyoq/query/binding.py +52 -0
- pyoq/query/capabilities.py +317 -0
- pyoq/query/casts.py +73 -0
- pyoq/query/choices.py +185 -0
- pyoq/query/decoding.py +360 -0
- pyoq/query/documents.py +56 -0
- pyoq/query/execution/__init__.py +63 -0
- pyoq/query/execution/aio/__init__.py +31 -0
- pyoq/query/execution/aio/operations.py +228 -0
- pyoq/query/execution/aio/pooling.py +233 -0
- pyoq/query/execution/aio/streaming.py +161 -0
- pyoq/query/execution/aio/transactions.py +105 -0
- pyoq/query/execution/batch.py +96 -0
- pyoq/query/execution/binding_style.py +30 -0
- pyoq/query/execution/compilation.py +48 -0
- pyoq/query/execution/context.py +61 -0
- pyoq/query/execution/control.py +50 -0
- pyoq/query/execution/operations.py +224 -0
- pyoq/query/execution/planning.py +107 -0
- pyoq/query/execution/pooling.py +279 -0
- pyoq/query/execution/results.py +36 -0
- pyoq/query/execution/streaming.py +178 -0
- pyoq/query/execution/transactions.py +95 -0
- pyoq/query/expressions.py +1200 -0
- pyoq/query/fields.py +60 -0
- pyoq/query/mysql/__init__.py +59 -0
- pyoq/query/mysql/aio/__init__.py +38 -0
- pyoq/query/mysql/aio/commands.py +389 -0
- pyoq/query/mysql/aio/driver.py +196 -0
- pyoq/query/mysql/aio/executor.py +123 -0
- pyoq/query/mysql/aio/factory.py +26 -0
- pyoq/query/mysql/aio/operations.py +38 -0
- pyoq/query/mysql/aio/pool.py +53 -0
- pyoq/query/mysql/aio/transactions.py +313 -0
- pyoq/query/mysql/commands.py +354 -0
- pyoq/query/mysql/compiler.py +134 -0
- pyoq/query/mysql/context.py +20 -0
- pyoq/query/mysql/executor.py +126 -0
- pyoq/query/mysql/expressions.py +244 -0
- pyoq/query/mysql/factory.py +46 -0
- pyoq/query/mysql/health.py +66 -0
- pyoq/query/mysql/identifiers.py +9 -0
- pyoq/query/mysql/model.py +79 -0
- pyoq/query/mysql/operations.py +43 -0
- pyoq/query/mysql/parameters.py +69 -0
- pyoq/query/mysql/planning.py +20 -0
- pyoq/query/mysql/pool.py +67 -0
- pyoq/query/mysql/transactions.py +331 -0
- pyoq/query/mysql/writes.py +73 -0
- pyoq/query/nodes.py +750 -0
- pyoq/query/postgres/__init__.py +48 -0
- pyoq/query/postgres/aio/__init__.py +25 -0
- pyoq/query/postgres/aio/bulk.py +56 -0
- pyoq/query/postgres/aio/commands.py +264 -0
- pyoq/query/postgres/aio/executor.py +152 -0
- pyoq/query/postgres/aio/factory.py +26 -0
- pyoq/query/postgres/aio/operations.py +26 -0
- pyoq/query/postgres/aio/pool.py +40 -0
- pyoq/query/postgres/aio/transactions.py +295 -0
- pyoq/query/postgres/bulk.py +62 -0
- pyoq/query/postgres/commands.py +238 -0
- pyoq/query/postgres/compiler.py +114 -0
- pyoq/query/postgres/context.py +20 -0
- pyoq/query/postgres/executor.py +147 -0
- pyoq/query/postgres/expressions.py +311 -0
- pyoq/query/postgres/factory.py +24 -0
- pyoq/query/postgres/health.py +24 -0
- pyoq/query/postgres/identifiers.py +9 -0
- pyoq/query/postgres/model.py +81 -0
- pyoq/query/postgres/operations.py +25 -0
- pyoq/query/postgres/parameters.py +71 -0
- pyoq/query/postgres/planning.py +20 -0
- pyoq/query/postgres/pool.py +52 -0
- pyoq/query/postgres/transactions.py +295 -0
- pyoq/query/postgres/writes.py +37 -0
- pyoq/query/projections.py +105 -0
- pyoq/query/raw.py +90 -0
- pyoq/query/recursion.py +265 -0
- pyoq/query/rendering/__init__.py +1 -0
- pyoq/query/rendering/expressions.py +913 -0
- pyoq/query/rendering/identifiers.py +40 -0
- pyoq/query/rendering/projections.py +63 -0
- pyoq/query/rendering/queries.py +334 -0
- pyoq/query/rendering/sources.py +66 -0
- pyoq/query/rendering/writes.py +176 -0
- pyoq/query/results.py +459 -0
- pyoq/query/routines.py +196 -0
- pyoq/query/rows.py +156 -0
- pyoq/query/select.py +793 -0
- pyoq/query/select_nodes.py +277 -0
- pyoq/query/sources.py +236 -0
- pyoq/query/sqlite/__init__.py +43 -0
- pyoq/query/sqlite/commands.py +201 -0
- pyoq/query/sqlite/compiler.py +139 -0
- pyoq/query/sqlite/context.py +20 -0
- pyoq/query/sqlite/executor.py +119 -0
- pyoq/query/sqlite/expressions.py +224 -0
- pyoq/query/sqlite/factory.py +32 -0
- pyoq/query/sqlite/health.py +28 -0
- pyoq/query/sqlite/identifiers.py +9 -0
- pyoq/query/sqlite/model.py +73 -0
- pyoq/query/sqlite/operations.py +36 -0
- pyoq/query/sqlite/parameters.py +50 -0
- pyoq/query/sqlite/planning.py +20 -0
- pyoq/query/sqlite/pool.py +50 -0
- pyoq/query/sqlite/streaming.py +13 -0
- pyoq/query/sqlite/transactions.py +274 -0
- pyoq/query/sqlite/writes.py +35 -0
- pyoq/query/statements.py +27 -0
- pyoq/query/values.py +23 -0
- pyoq/query/vendor.py +162 -0
- pyoq/query/windows.py +424 -0
- pyoq/query/write_nodes.py +174 -0
- pyoq/query/writes.py +628 -0
- pyoq/relations/__init__.py +66 -0
- pyoq/relations/batching.py +219 -0
- pyoq/relations/derivation.py +111 -0
- pyoq/relations/fetching.py +355 -0
- pyoq/relations/graph.py +245 -0
- pyoq/relations/loading.py +74 -0
- pyoq/relations/model.py +75 -0
- pyoq/relations/planning.py +206 -0
- pyoq/runtime/__init__.py +9 -0
- pyoq/runtime/kernels.py +25 -0
- pyoq/runtime/python.py +43 -0
- pyoq/runtime/selection.py +73 -0
- pyoq/sanic/__init__.py +32 -0
- pyoq/sanic/scope.py +197 -0
- pyoq/sanic/workers.py +129 -0
- pyoq/schema/__init__.py +108 -0
- pyoq/schema/codec.py +711 -0
- pyoq/schema/models.py +604 -0
- pyoq/schema/mysql/__init__.py +16 -0
- pyoq/schema/mysql/connection.py +72 -0
- pyoq/schema/mysql/dsn.py +72 -0
- pyoq/schema/mysql/records.py +354 -0
- pyoq/schema/mysql/reflection.py +309 -0
- pyoq/schema/mysql/source.py +30 -0
- pyoq/schema/mysql/sql.py +128 -0
- pyoq/schema/mysql/types.py +105 -0
- pyoq/schema/postgres/__init__.py +13 -0
- pyoq/schema/postgres/connection.py +62 -0
- pyoq/schema/postgres/records.py +384 -0
- pyoq/schema/postgres/reflection.py +466 -0
- pyoq/schema/postgres/source.py +30 -0
- pyoq/schema/postgres/sql.py +246 -0
- pyoq/schema/postgres/types.py +98 -0
- pyoq/schema/registry.py +45 -0
- pyoq/schema/source.py +15 -0
- pyoq/schema/sqlite/__init__.py +6 -0
- pyoq/schema/sqlite/connection.py +54 -0
- pyoq/schema/sqlite/records.py +167 -0
- pyoq/schema/sqlite/reflection.py +393 -0
- pyoq/schema/sqlite/source.py +30 -0
- pyoq/schema/sqlite/sql.py +254 -0
- pyoq/schema/sqlite/types.py +74 -0
- pyoq/serving/__init__.py +19 -0
- pyoq/serving/databases.py +107 -0
- pyoq/snapshots/__init__.py +20 -0
- pyoq/snapshots/drift.py +312 -0
- pyoq/snapshots/files.py +96 -0
- pyoq/snapshots/routing.py +40 -0
- pyoq/snapshots/source.py +33 -0
- pyoq/tracing/__init__.py +5 -0
- pyoq/tracing/spans.py +89 -0
- pyoq/unset.py +14 -0
- pyoq_sql-1.0.0.dist-info/METADATA +3034 -0
- pyoq_sql-1.0.0.dist-info/RECORD +264 -0
- pyoq_sql-1.0.0.dist-info/WHEEL +4 -0
- pyoq_sql-1.0.0.dist-info/entry_points.txt +3 -0
- 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")
|
pyoq/django/__init__.py
ADDED
|
@@ -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"
|