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