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
pyoq/descriptors.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Runtime contracts used by generated database types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import ClassVar, Generic, TypeAlias, TypeVar
|
|
7
|
+
|
|
8
|
+
from pyoq.query import Expression, FieldNode, ScalarFamily
|
|
9
|
+
from pyoq.relations import RelationCardinality, RelationDirection
|
|
10
|
+
from pyoq.schema import JsonScalar as SchemaJsonScalar
|
|
11
|
+
from pyoq.schema import JsonValue as SchemaJsonValue
|
|
12
|
+
from pyoq.schema import KeyKind, ReferentialAction
|
|
13
|
+
from pyoq.unset import UNSET, UnsetType
|
|
14
|
+
|
|
15
|
+
Value = TypeVar("Value")
|
|
16
|
+
Row = TypeVar("Row", covariant=True)
|
|
17
|
+
Insert = TypeVar("Insert", covariant=True)
|
|
18
|
+
Update = TypeVar("Update", covariant=True)
|
|
19
|
+
SourceRow = TypeVar("SourceRow", covariant=True)
|
|
20
|
+
TargetRow = TypeVar("TargetRow", covariant=True)
|
|
21
|
+
JsonScalar: TypeAlias = SchemaJsonScalar
|
|
22
|
+
JsonValue: TypeAlias = SchemaJsonValue
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Missing:
|
|
26
|
+
__slots__ = ()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Present:
|
|
30
|
+
__slots__ = ()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class ColumnDescriptor(Expression[Value], Generic[Value]):
|
|
35
|
+
database_name: str
|
|
36
|
+
nullable: bool
|
|
37
|
+
writable: bool
|
|
38
|
+
has_default: bool
|
|
39
|
+
generated: bool
|
|
40
|
+
scalar_family: ScalarFamily = ScalarFamily.OTHER
|
|
41
|
+
table_name: str | None = None
|
|
42
|
+
schema_name: str | None = None
|
|
43
|
+
catalog_name: str | None = None
|
|
44
|
+
field_name: str | None = None
|
|
45
|
+
value_type: type[object] | None = None
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
_require_name(self.database_name, "column")
|
|
49
|
+
self._initialize_expression(
|
|
50
|
+
FieldNode(
|
|
51
|
+
self.database_name,
|
|
52
|
+
self.table_name,
|
|
53
|
+
self.schema_name,
|
|
54
|
+
self.catalog_name,
|
|
55
|
+
self.scalar_family,
|
|
56
|
+
self.value_type,
|
|
57
|
+
self.nullable,
|
|
58
|
+
),
|
|
59
|
+
self.scalar_family,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class TableDescriptor(Generic[Row, Insert, Update]):
|
|
65
|
+
COLUMNS: ClassVar[tuple[object, ...]] = ()
|
|
66
|
+
"""Every column this table has, in the order the table declares them.
|
|
67
|
+
|
|
68
|
+
Generation fills this in. A table written by hand names no columns, and
|
|
69
|
+
so cannot be selected from without saying which.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
database_name: str
|
|
73
|
+
schema_name: str | None = None
|
|
74
|
+
catalog_name: str | None = None
|
|
75
|
+
row_type: type[Row] | None = None
|
|
76
|
+
"""The class one row of this table reads back as.
|
|
77
|
+
|
|
78
|
+
Generation fills this in, so a table constant carries the type of what it
|
|
79
|
+
holds as well as its name. A table written by hand names no row class, and
|
|
80
|
+
so a row of it can only be read as the values it came back with.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __post_init__(self) -> None:
|
|
84
|
+
_require_name(self.database_name, "table")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class KeyDescriptor(Generic[Value]):
|
|
89
|
+
kind: KeyKind
|
|
90
|
+
column_names: tuple[str, ...]
|
|
91
|
+
database_name: str | None = None
|
|
92
|
+
|
|
93
|
+
def __post_init__(self) -> None:
|
|
94
|
+
if not self.column_names:
|
|
95
|
+
message = "key descriptor requires at least one column"
|
|
96
|
+
raise ValueError(message)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class RelationshipDescriptor(Generic[SourceRow, TargetRow]):
|
|
101
|
+
source_table: str
|
|
102
|
+
source_columns: tuple[str, ...]
|
|
103
|
+
target_table: str
|
|
104
|
+
target_columns: tuple[str, ...]
|
|
105
|
+
source_schema: str | None = None
|
|
106
|
+
source_catalog: str | None = None
|
|
107
|
+
target_schema: str | None = None
|
|
108
|
+
target_catalog: str | None = None
|
|
109
|
+
"""Where each side lives, because a table name alone names more than one.
|
|
110
|
+
|
|
111
|
+
Two schemas may each hold a `parent` and a `child` joined on the same
|
|
112
|
+
columns. Without these, a constant generated for one of them describes
|
|
113
|
+
both, and reading through it answers with whichever was found first.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
database_name: str | None = None
|
|
117
|
+
on_update: ReferentialAction = ReferentialAction.NO_ACTION
|
|
118
|
+
on_delete: ReferentialAction = ReferentialAction.NO_ACTION
|
|
119
|
+
direction: RelationDirection = RelationDirection.FORWARD
|
|
120
|
+
cardinality: RelationCardinality = RelationCardinality.TO_ONE
|
|
121
|
+
optional: bool = False
|
|
122
|
+
target_row_type: type[TargetRow] | None = None
|
|
123
|
+
"""The class one row of the table this reaches reads back as.
|
|
124
|
+
|
|
125
|
+
The same fact as a table constant's own, carried here so that following a
|
|
126
|
+
relation says what it arrives at without the caller naming it again.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __post_init__(self) -> None:
|
|
130
|
+
_require_name(self.source_table, "relationship source table")
|
|
131
|
+
_require_name(self.target_table, "relationship target table")
|
|
132
|
+
if not self.source_columns or len(self.source_columns) != len(
|
|
133
|
+
self.target_columns
|
|
134
|
+
):
|
|
135
|
+
message = "relationship descriptor columns must be non-empty and aligned"
|
|
136
|
+
raise ValueError(message)
|
|
137
|
+
if self.cardinality is RelationCardinality.TO_MANY and self.optional:
|
|
138
|
+
message = "a to-many relationship cannot be optional"
|
|
139
|
+
raise ValueError(message)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def to_one(self) -> bool:
|
|
143
|
+
return self.cardinality is RelationCardinality.TO_ONE
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _require_name(value: str, label: str) -> None:
|
|
147
|
+
if not value:
|
|
148
|
+
message = f"{label} name cannot be empty"
|
|
149
|
+
raise ValueError(message)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
__all__ = (
|
|
153
|
+
"UNSET",
|
|
154
|
+
"ColumnDescriptor",
|
|
155
|
+
"JsonScalar",
|
|
156
|
+
"JsonValue",
|
|
157
|
+
"KeyDescriptor",
|
|
158
|
+
"Missing",
|
|
159
|
+
"Present",
|
|
160
|
+
"RelationCardinality",
|
|
161
|
+
"RelationDirection",
|
|
162
|
+
"RelationshipDescriptor",
|
|
163
|
+
"TableDescriptor",
|
|
164
|
+
"UnsetType",
|
|
165
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Seeing what a scope asked a database to do."""
|
|
2
|
+
|
|
3
|
+
from pyoq.diagnostics.budget import QueryBudget, QueryScope
|
|
4
|
+
from pyoq.diagnostics.events import (
|
|
5
|
+
CollectingSink,
|
|
6
|
+
EventPolicy,
|
|
7
|
+
EventSink,
|
|
8
|
+
StatementEvent,
|
|
9
|
+
StatementFailed,
|
|
10
|
+
StatementFinished,
|
|
11
|
+
StatementStarted,
|
|
12
|
+
reportable_values,
|
|
13
|
+
)
|
|
14
|
+
from pyoq.diagnostics.fingerprint import (
|
|
15
|
+
CacheMetrics,
|
|
16
|
+
QueryShape,
|
|
17
|
+
forget_shapes,
|
|
18
|
+
normalize_sql,
|
|
19
|
+
query_shape,
|
|
20
|
+
shape_cache_metrics,
|
|
21
|
+
)
|
|
22
|
+
from pyoq.diagnostics.instrumented import (
|
|
23
|
+
AsyncInstrumentedOperations,
|
|
24
|
+
InstrumentedOperations,
|
|
25
|
+
)
|
|
26
|
+
from pyoq.diagnostics.metrics import DatabaseMetrics, metrics_of
|
|
27
|
+
from pyoq.diagnostics.observation import (
|
|
28
|
+
DEFAULT_REPEAT_THRESHOLD,
|
|
29
|
+
DEFAULT_SHAPE_LIMIT,
|
|
30
|
+
DEFAULT_SITE_LIMIT,
|
|
31
|
+
CallSite,
|
|
32
|
+
QueryObserver,
|
|
33
|
+
RepeatedQuery,
|
|
34
|
+
calling_site,
|
|
35
|
+
)
|
|
36
|
+
from pyoq.diagnostics.scoped import AsyncScopedOperations, ScopedOperations
|
|
37
|
+
|
|
38
|
+
__all__ = (
|
|
39
|
+
"DEFAULT_REPEAT_THRESHOLD",
|
|
40
|
+
"DEFAULT_SHAPE_LIMIT",
|
|
41
|
+
"DEFAULT_SITE_LIMIT",
|
|
42
|
+
"AsyncInstrumentedOperations",
|
|
43
|
+
"AsyncScopedOperations",
|
|
44
|
+
"CacheMetrics",
|
|
45
|
+
"CallSite",
|
|
46
|
+
"CollectingSink",
|
|
47
|
+
"DatabaseMetrics",
|
|
48
|
+
"EventPolicy",
|
|
49
|
+
"EventSink",
|
|
50
|
+
"InstrumentedOperations",
|
|
51
|
+
"QueryBudget",
|
|
52
|
+
"QueryObserver",
|
|
53
|
+
"QueryScope",
|
|
54
|
+
"QueryShape",
|
|
55
|
+
"RepeatedQuery",
|
|
56
|
+
"ScopedOperations",
|
|
57
|
+
"StatementEvent",
|
|
58
|
+
"StatementFailed",
|
|
59
|
+
"StatementFinished",
|
|
60
|
+
"StatementStarted",
|
|
61
|
+
"calling_site",
|
|
62
|
+
"forget_shapes",
|
|
63
|
+
"metrics_of",
|
|
64
|
+
"normalize_sql",
|
|
65
|
+
"query_shape",
|
|
66
|
+
"reportable_values",
|
|
67
|
+
"shape_cache_metrics",
|
|
68
|
+
)
|
|
@@ -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
|
+
)
|