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,136 @@
|
|
|
1
|
+
"""What one scope is allowed to ask a database to do.
|
|
2
|
+
|
|
3
|
+
A repeated query that is only reported is a repeated query that still ships. A
|
|
4
|
+
budget turns the same observation into a refusal, at the point where the scope
|
|
5
|
+
exceeds what it was allowed rather than after the fact.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
from pyoq.diagnostics.fingerprint import QueryShape, query_shape
|
|
14
|
+
from pyoq.diagnostics.observation import QueryObserver, RepeatedQuery
|
|
15
|
+
from pyoq.errors import QueryBudgetExceededError, QueryValidationError
|
|
16
|
+
from pyoq.query.execution import CompiledQuery
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class QueryBudget:
|
|
21
|
+
"""A ceiling on one scope's database work.
|
|
22
|
+
|
|
23
|
+
``maximum_repeats`` is the one that catches an N+1 access, because the shape
|
|
24
|
+
executed once per row of an earlier result is the shape that repeats.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
maximum_queries: int | None = None
|
|
28
|
+
maximum_repeats: int | None = None
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
_require_optional_positive(self.maximum_queries, "maximum queries")
|
|
32
|
+
_require_optional_positive(self.maximum_repeats, "maximum repeats")
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def unlimited(self) -> bool:
|
|
36
|
+
return self.maximum_queries is None and self.maximum_repeats is None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class QueryScope:
|
|
40
|
+
"""One request's worth of database work, watched and bounded.
|
|
41
|
+
|
|
42
|
+
The scope holds an observer so that a refusal can say which shape ran too
|
|
43
|
+
often and where from, which is what makes the refusal actionable rather than
|
|
44
|
+
merely correct.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("_budget", "_observer")
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
budget: QueryBudget | None = None,
|
|
52
|
+
*,
|
|
53
|
+
observer: QueryObserver | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._budget = budget or QueryBudget()
|
|
56
|
+
self._observer = observer or QueryObserver()
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def budget(self) -> QueryBudget:
|
|
60
|
+
return self._budget
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def observer(self) -> QueryObserver:
|
|
64
|
+
return self._observer
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def executions(self) -> int:
|
|
68
|
+
return self._observer.executions
|
|
69
|
+
|
|
70
|
+
def record(self, statement: CompiledQuery, /) -> None:
|
|
71
|
+
"""Record a statement, refusing the one that exceeds the budget.
|
|
72
|
+
|
|
73
|
+
The statement is recorded before it is judged, so a report taken after
|
|
74
|
+
a refusal includes the execution that caused it.
|
|
75
|
+
"""
|
|
76
|
+
executions = self._observer.record(statement)
|
|
77
|
+
if self._budget.unlimited:
|
|
78
|
+
return
|
|
79
|
+
self._require_within_total()
|
|
80
|
+
self._require_within_repeats(statement, executions)
|
|
81
|
+
|
|
82
|
+
def repeated(self, *, threshold: int = 2) -> tuple[RepeatedQuery, ...]:
|
|
83
|
+
return self._observer.repeated(threshold=threshold)
|
|
84
|
+
|
|
85
|
+
def _require_within_total(self) -> None:
|
|
86
|
+
limit = self._budget.maximum_queries
|
|
87
|
+
if limit is None or self._observer.executions <= limit:
|
|
88
|
+
return
|
|
89
|
+
message = (
|
|
90
|
+
f"this scope executed {self._observer.executions} statements, beyond "
|
|
91
|
+
f"the {limit} it was allowed"
|
|
92
|
+
)
|
|
93
|
+
raise QueryBudgetExceededError(message)
|
|
94
|
+
|
|
95
|
+
def _require_within_repeats(
|
|
96
|
+
self,
|
|
97
|
+
statement: CompiledQuery,
|
|
98
|
+
executions: int,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Judge only the shape just recorded, which is the only one that moved.
|
|
101
|
+
|
|
102
|
+
Scanning every shape on every statement would make the cost of holding a
|
|
103
|
+
budget grow with the variety of a scope's queries.
|
|
104
|
+
"""
|
|
105
|
+
limit = self._budget.maximum_repeats
|
|
106
|
+
if limit is None or executions <= limit:
|
|
107
|
+
return
|
|
108
|
+
digest = query_shape(statement.sql).digest
|
|
109
|
+
# A count above the limit means the observer kept this shape, and it
|
|
110
|
+
# never lets one go, so the report is there to be read.
|
|
111
|
+
repeated = cast("RepeatedQuery", self._observer.report(digest))
|
|
112
|
+
raise QueryBudgetExceededError(_repeat_message(repeated, limit))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _repeat_message(repeated: RepeatedQuery, limit: int) -> str:
|
|
116
|
+
places = "; ".join(str(site) for site in repeated.sites)
|
|
117
|
+
origin = f" from {places}" if places else ""
|
|
118
|
+
return (
|
|
119
|
+
f"one statement ran {repeated.executions} times in this scope, beyond "
|
|
120
|
+
f"the {limit} it was allowed{origin}: {_shape_text(repeated.shape)}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _shape_text(shape: QueryShape) -> str:
|
|
125
|
+
return shape.sql
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _require_optional_positive(value: int | None, label: str) -> None:
|
|
129
|
+
if value is None:
|
|
130
|
+
return
|
|
131
|
+
if isinstance(value, bool) or value < 1:
|
|
132
|
+
message = f"{label} must be a positive integer or None"
|
|
133
|
+
raise QueryValidationError(message)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
__all__ = ("QueryBudget", "QueryScope")
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""What a statement did, described without what it was asked about.
|
|
2
|
+
|
|
3
|
+
A shape carries the SQL with its values taken out, which is safe to log. A bound
|
|
4
|
+
value is not, and neither is the message a driver raises: PostgreSQL states the
|
|
5
|
+
offending value inside it, so a failure that says only its type is the one that
|
|
6
|
+
can be reported anywhere.
|
|
7
|
+
|
|
8
|
+
A project that has decided otherwise says so through a policy, one field at a
|
|
9
|
+
time. A value marked sensitive when the statement was compiled is never included
|
|
10
|
+
whatever the policy says.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING, Protocol, TypeAlias
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from pyoq.diagnostics.fingerprint import QueryShape
|
|
20
|
+
from pyoq.query.execution import CompiledQuery, StatementKind
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class EventPolicy:
|
|
25
|
+
"""What an event may carry beyond the shape of a statement.
|
|
26
|
+
|
|
27
|
+
Every field is off, so instrumentation added without a decision reports
|
|
28
|
+
nothing a query was asked about.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
include_values: bool = False
|
|
32
|
+
include_failure_detail: bool = False
|
|
33
|
+
slow_after: float | None = None
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if self.slow_after is not None and self.slow_after <= 0:
|
|
37
|
+
message = "a slow statement threshold must be positive"
|
|
38
|
+
raise ValueError(message)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class StatementStarted:
|
|
43
|
+
"""A statement about to reach a driver."""
|
|
44
|
+
|
|
45
|
+
shape: QueryShape
|
|
46
|
+
kind: StatementKind
|
|
47
|
+
parameters: int
|
|
48
|
+
sensitive_parameters: int
|
|
49
|
+
values: tuple[object, ...] = ()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class StatementFinished:
|
|
54
|
+
"""A statement the database answered."""
|
|
55
|
+
|
|
56
|
+
shape: QueryShape
|
|
57
|
+
kind: StatementKind
|
|
58
|
+
parameters: int
|
|
59
|
+
sensitive_parameters: int
|
|
60
|
+
duration: float
|
|
61
|
+
rows: int | None = None
|
|
62
|
+
slow: bool = False
|
|
63
|
+
values: tuple[object, ...] = ()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class StatementFailed:
|
|
68
|
+
"""A statement the database refused.
|
|
69
|
+
|
|
70
|
+
`failure` names the error's type. Its message is only carried when a policy
|
|
71
|
+
says so, because a driver states the value that caused the failure in it.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
shape: QueryShape
|
|
75
|
+
kind: StatementKind
|
|
76
|
+
parameters: int
|
|
77
|
+
sensitive_parameters: int
|
|
78
|
+
duration: float
|
|
79
|
+
failure: str
|
|
80
|
+
detail: str | None = None
|
|
81
|
+
values: tuple[object, ...] = ()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
StatementEvent: TypeAlias = StatementStarted | StatementFinished | StatementFailed
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class EventSink(Protocol):
|
|
88
|
+
"""Somewhere for events to go."""
|
|
89
|
+
|
|
90
|
+
def record(self, event: StatementEvent, /) -> None: ...
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CollectingSink:
|
|
94
|
+
"""Keeps what it is given, for a caller that wants to look afterwards."""
|
|
95
|
+
|
|
96
|
+
__slots__ = ("events",)
|
|
97
|
+
events: list[StatementEvent]
|
|
98
|
+
|
|
99
|
+
def __init__(self) -> None:
|
|
100
|
+
self.events = []
|
|
101
|
+
|
|
102
|
+
def record(self, event: StatementEvent, /) -> None:
|
|
103
|
+
self.events.append(event)
|
|
104
|
+
|
|
105
|
+
def clear(self) -> None:
|
|
106
|
+
self.events.clear()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def reportable_values(
|
|
110
|
+
statement: CompiledQuery,
|
|
111
|
+
policy: EventPolicy,
|
|
112
|
+
/,
|
|
113
|
+
) -> tuple[object, ...]:
|
|
114
|
+
"""The values an event may carry, which is none of them by default.
|
|
115
|
+
|
|
116
|
+
A value the compiler marked sensitive is left out even when a policy asks
|
|
117
|
+
for values, because marking it was the decision that it must not be shown.
|
|
118
|
+
"""
|
|
119
|
+
if not policy.include_values:
|
|
120
|
+
return ()
|
|
121
|
+
return tuple(
|
|
122
|
+
value
|
|
123
|
+
for index, value in enumerate(statement.parameters)
|
|
124
|
+
if index not in statement.sensitive_parameter_indexes
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = (
|
|
129
|
+
"CollectingSink",
|
|
130
|
+
"EventPolicy",
|
|
131
|
+
"EventSink",
|
|
132
|
+
"StatementEvent",
|
|
133
|
+
"StatementFailed",
|
|
134
|
+
"StatementFinished",
|
|
135
|
+
"StatementStarted",
|
|
136
|
+
"reportable_values",
|
|
137
|
+
)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Recognizing one query shape across the many times it is executed.
|
|
2
|
+
|
|
3
|
+
A query executed in a loop differs only in its bound values, a query executed
|
|
4
|
+
against a growing list of keys differs only in how many placeholders it carries,
|
|
5
|
+
and a write chunked to fit a parameter limit differs only in how many groups of
|
|
6
|
+
them it carries. All three are one shape, and seeing them as one is what makes a
|
|
7
|
+
repeated query visible.
|
|
8
|
+
|
|
9
|
+
A literal written into the statement itself is taken out for the same reason,
|
|
10
|
+
and for one more: a shape is reported and logged, so anything left in it is
|
|
11
|
+
reported and logged too. PyOQ binds every value it is given, but raw SQL can
|
|
12
|
+
carry one, and a shape that kept it would put it in a log.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from functools import lru_cache
|
|
20
|
+
from hashlib import blake2b
|
|
21
|
+
|
|
22
|
+
_ESCAPED_PERCENT = "\x00escaped-percent\x00"
|
|
23
|
+
# PostgreSQL opens a string with a tag it chooses, and closes it with the same.
|
|
24
|
+
_DOLLAR_TAG = re.compile(r"\$[A-Za-z_]\w*\$|\$\$")
|
|
25
|
+
# What quotes a name rather than a value, in the dialects PyOQ speaks.
|
|
26
|
+
# What quotes a name rather than a value. A single quote is absent because a
|
|
27
|
+
# single quoted string is a value, and is read as one before this is asked.
|
|
28
|
+
_NAME_QUOTES = '"`'
|
|
29
|
+
# What begins a comment that runs to the end of its line.
|
|
30
|
+
_LINE_COMMENTS = ("--", "#")
|
|
31
|
+
# A number written in another radix, and one written in the usual way.
|
|
32
|
+
_RADIX_NUMBER = re.compile(r"0[xX][0-9a-fA-F]+|0[bB][01]+")
|
|
33
|
+
_DECIMAL_NUMBER = re.compile(r"\d+(?:\.\d+)?(?:[eE][+-]?\d+)?")
|
|
34
|
+
_PLACEHOLDER = re.compile(r"\$\d+|%s|\?")
|
|
35
|
+
_PLACEHOLDER_RUN = re.compile(r"\?(?:\s*,\s*\?)+")
|
|
36
|
+
_GROUP_RUN = re.compile(r"\(\?\)(?:\s*,\s*\(\?\))+")
|
|
37
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
38
|
+
_DIGEST_BYTES = 16
|
|
39
|
+
# What a shape may carry into a log line or a span attribute.
|
|
40
|
+
_MAXIMUM_SHAPE = 4096
|
|
41
|
+
_TRUNCATED = " ..."
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class QueryShape:
|
|
46
|
+
"""One query with its values and their number taken out.
|
|
47
|
+
|
|
48
|
+
The shape carries no bound value, so it can be reported and logged without
|
|
49
|
+
revealing anything a query was asked about.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
sql: str
|
|
53
|
+
digest: str
|
|
54
|
+
|
|
55
|
+
def __str__(self) -> str:
|
|
56
|
+
return self.sql
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# An application runs the same statements over and over, so the shape of one is
|
|
60
|
+
# asked for far more often than it changes. The cache is bounded, because the
|
|
61
|
+
# statements a process runs are not.
|
|
62
|
+
_SHAPE_CACHE_SIZE = 2048
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@lru_cache(maxsize=_SHAPE_CACHE_SIZE)
|
|
66
|
+
def query_shape(sql: str, /) -> QueryShape:
|
|
67
|
+
"""The shape of a statement, remembered while it keeps being asked for.
|
|
68
|
+
|
|
69
|
+
A shape is written to a log and set on a span, so what it carries is
|
|
70
|
+
bounded. The digest is taken from the whole statement, so two that differ
|
|
71
|
+
only past the bound are still told apart.
|
|
72
|
+
"""
|
|
73
|
+
normalized = normalize_sql(sql)
|
|
74
|
+
return QueryShape(_within_bounds(normalized), _digest(normalized))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _within_bounds(normalized: str) -> str:
|
|
78
|
+
"""A generated statement can be enormous, and a log line cannot."""
|
|
79
|
+
if len(normalized) <= _MAXIMUM_SHAPE:
|
|
80
|
+
return normalized
|
|
81
|
+
return normalized[:_MAXIMUM_SHAPE] + _TRUNCATED
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def shape_cache_metrics() -> CacheMetrics:
|
|
85
|
+
"""How well the shapes being asked for are already known."""
|
|
86
|
+
info = query_shape.cache_info()
|
|
87
|
+
return CacheMetrics(
|
|
88
|
+
hits=info.hits,
|
|
89
|
+
misses=info.misses,
|
|
90
|
+
held=info.currsize,
|
|
91
|
+
capacity=_SHAPE_CACHE_SIZE,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def forget_shapes() -> None:
|
|
96
|
+
"""Empty the cache, which only a test measuring it should need."""
|
|
97
|
+
query_shape.cache_clear()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class CacheMetrics:
|
|
102
|
+
"""What a cache has been asked for and how much of it it kept."""
|
|
103
|
+
|
|
104
|
+
hits: int
|
|
105
|
+
misses: int
|
|
106
|
+
held: int
|
|
107
|
+
capacity: int
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def hit_rate(self) -> float:
|
|
111
|
+
asked = self.hits + self.misses
|
|
112
|
+
return 0.0 if asked == 0 else self.hits / asked
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def normalize_sql(sql: str, /) -> str:
|
|
116
|
+
"""Reduce a statement to the shape it shares with its repetitions.
|
|
117
|
+
|
|
118
|
+
The statement is read once, left to right, because what a character means
|
|
119
|
+
depends on what it is inside. A quote inside a comment starts nothing, and
|
|
120
|
+
two dashes inside a string are not a comment, so deciding either one without
|
|
121
|
+
tracking the other gets both wrong.
|
|
122
|
+
"""
|
|
123
|
+
pieces: list[str] = []
|
|
124
|
+
position = 0
|
|
125
|
+
length = len(sql)
|
|
126
|
+
while position < length:
|
|
127
|
+
step = (
|
|
128
|
+
_skip_comment(sql, position)
|
|
129
|
+
or _take_literal(sql, position)
|
|
130
|
+
or _take_quoted_name(sql, position)
|
|
131
|
+
)
|
|
132
|
+
if step is None:
|
|
133
|
+
pieces.append(sql[position])
|
|
134
|
+
position += 1
|
|
135
|
+
continue
|
|
136
|
+
text, position = step
|
|
137
|
+
pieces.append(text)
|
|
138
|
+
return _collapse("".join(pieces))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _skip_comment(sql: str, position: int) -> tuple[str, int] | None:
|
|
142
|
+
"""A comment is dropped, because it says nothing about the shape."""
|
|
143
|
+
if sql.startswith(_LINE_COMMENTS, position):
|
|
144
|
+
ending = sql.find("\n", position)
|
|
145
|
+
return (" ", len(sql) if ending == -1 else ending)
|
|
146
|
+
if sql.startswith("/*", position):
|
|
147
|
+
return (" ", _end_of_block_comment(sql, position))
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _end_of_block_comment(sql: str, position: int) -> int:
|
|
152
|
+
"""Past the closing marker, counting the nesting PostgreSQL allows.
|
|
153
|
+
|
|
154
|
+
A dialect that does not nest would have ended at the first marker, so this
|
|
155
|
+
can drop more than that dialect needed. Dropping too much loses a little of
|
|
156
|
+
a shape; dropping too little puts whatever was written there in a log.
|
|
157
|
+
"""
|
|
158
|
+
depth = 0
|
|
159
|
+
index = position
|
|
160
|
+
while index < len(sql):
|
|
161
|
+
if sql.startswith("/*", index):
|
|
162
|
+
depth += 1
|
|
163
|
+
index += 2
|
|
164
|
+
continue
|
|
165
|
+
if sql.startswith("*/", index):
|
|
166
|
+
depth -= 1
|
|
167
|
+
index += 2
|
|
168
|
+
if depth == 0:
|
|
169
|
+
return index
|
|
170
|
+
continue
|
|
171
|
+
index += 1
|
|
172
|
+
return len(sql)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _take_literal(sql: str, position: int) -> tuple[str, int] | None:
|
|
176
|
+
"""Every kind of literal becomes one placeholder."""
|
|
177
|
+
dollar = _take_dollar_quoted(sql, position)
|
|
178
|
+
if dollar is not None:
|
|
179
|
+
return dollar
|
|
180
|
+
if sql[position] == "'":
|
|
181
|
+
return ("?", _end_of_quoted(sql, position, "'"))
|
|
182
|
+
if _starts_number(sql, position):
|
|
183
|
+
return ("?", _end_of_number(sql, position))
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _take_dollar_quoted(sql: str, position: int) -> tuple[str, int] | None:
|
|
188
|
+
"""PostgreSQL quotes with a tag of its own choosing, so it is read out."""
|
|
189
|
+
if sql[position] != "$":
|
|
190
|
+
return None
|
|
191
|
+
opening = _DOLLAR_TAG.match(sql, position)
|
|
192
|
+
if opening is None:
|
|
193
|
+
return None
|
|
194
|
+
tag = opening.group(0)
|
|
195
|
+
ending = sql.find(tag, opening.end())
|
|
196
|
+
return ("?", len(sql) if ending == -1 else ending + len(tag))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _end_of_quoted(sql: str, position: int, quote: str) -> int:
|
|
200
|
+
"""Past the closing quote, counting a doubled or escaped one as inside."""
|
|
201
|
+
index = position + 1
|
|
202
|
+
while index < len(sql):
|
|
203
|
+
character = sql[index]
|
|
204
|
+
if character == "\\":
|
|
205
|
+
index += 2
|
|
206
|
+
continue
|
|
207
|
+
if character == quote:
|
|
208
|
+
if sql.startswith(quote * 2, index):
|
|
209
|
+
index += 2
|
|
210
|
+
continue
|
|
211
|
+
return index + 1
|
|
212
|
+
index += 1
|
|
213
|
+
return len(sql)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _take_quoted_name(sql: str, position: int) -> tuple[str, int] | None:
|
|
217
|
+
"""A quoted name is a name, so it is kept as it was written."""
|
|
218
|
+
quote = sql[position]
|
|
219
|
+
if quote not in _NAME_QUOTES:
|
|
220
|
+
return None
|
|
221
|
+
ending = _end_of_quoted(sql, position, quote)
|
|
222
|
+
return (sql[position:ending], ending)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _starts_number(sql: str, position: int) -> bool:
|
|
226
|
+
"""A decimal digit, which is narrower than what `isdigit` accepts.
|
|
227
|
+
|
|
228
|
+
A superscript is a digit to `isdigit` and not to the pattern that reads the
|
|
229
|
+
rest of the number, and taking one out a character at a time would mean a
|
|
230
|
+
shape that changes every time it is taken.
|
|
231
|
+
"""
|
|
232
|
+
if not sql[position].isdecimal():
|
|
233
|
+
return False
|
|
234
|
+
before = sql[position - 1] if position else ""
|
|
235
|
+
return not (before.isalnum() or before in "_.$")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _end_of_number(sql: str, position: int) -> int:
|
|
239
|
+
"""Past the whole number, in any of the ways one can be written."""
|
|
240
|
+
radix = _RADIX_NUMBER.match(sql, position)
|
|
241
|
+
if radix is not None:
|
|
242
|
+
return radix.end()
|
|
243
|
+
decimal = _DECIMAL_NUMBER.match(sql, position)
|
|
244
|
+
return position + 1 if decimal is None else decimal.end()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _collapse(text: str) -> str:
|
|
248
|
+
text = text.replace("%%", _ESCAPED_PERCENT)
|
|
249
|
+
text = _PLACEHOLDER.sub("?", text)
|
|
250
|
+
text = _PLACEHOLDER_RUN.sub("?", text)
|
|
251
|
+
text = _GROUP_RUN.sub("(?)", text)
|
|
252
|
+
text = _WHITESPACE.sub(" ", text).strip()
|
|
253
|
+
return text.replace(_ESCAPED_PERCENT, "%%")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _digest(normalized: str) -> str:
|
|
257
|
+
return blake2b(normalized.encode(), digest_size=_DIGEST_BYTES).hexdigest()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
__all__ = (
|
|
261
|
+
"CacheMetrics",
|
|
262
|
+
"QueryShape",
|
|
263
|
+
"forget_shapes",
|
|
264
|
+
"normalize_sql",
|
|
265
|
+
"query_shape",
|
|
266
|
+
"shape_cache_metrics",
|
|
267
|
+
)
|