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/snapshots/drift.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Comparing what a snapshot recorded against what a database now holds.
|
|
2
|
+
|
|
3
|
+
Generated code drifts when the schema moves under it. Comparing two snapshots
|
|
4
|
+
says what moved in terms of the schema rather than of the files, so the answer
|
|
5
|
+
names the table or the column that changed rather than the line that differs.
|
|
6
|
+
|
|
7
|
+
Named things are matched by name and then by value, so a renamed table reads
|
|
8
|
+
as one removed and one added rather than as a change to something that is no
|
|
9
|
+
longer there.
|
|
10
|
+
|
|
11
|
+
Everything else a container holds is compared by standing its named children
|
|
12
|
+
aside and comparing what is left. A comment, a capability, a format version
|
|
13
|
+
and any fact the schema model gains later are all compared that way, so a new
|
|
14
|
+
fact is compared from the day it exists rather than from the day someone
|
|
15
|
+
remembers to add it here.
|
|
16
|
+
|
|
17
|
+
The children are stood aside rather than emptied, because a snapshot without
|
|
18
|
+
a catalog and a table without a column are both refused by the model. One
|
|
19
|
+
fixed stand-in is put in their place, the same one on both sides, so what
|
|
20
|
+
remains is the container's own facts and nothing else.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
26
|
+
from dataclasses import dataclass, replace
|
|
27
|
+
from enum import StrEnum
|
|
28
|
+
from typing import TYPE_CHECKING, Any, Protocol, TypeVar
|
|
29
|
+
|
|
30
|
+
from pyoq.schema.models import (
|
|
31
|
+
Catalog,
|
|
32
|
+
Column,
|
|
33
|
+
Identifier,
|
|
34
|
+
Schema,
|
|
35
|
+
SqlType,
|
|
36
|
+
TypeKind,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from pyoq.schema.models import SchemaSnapshot, Table
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HasName(Protocol):
|
|
44
|
+
"""Anything a schema names, which is everything compared here."""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def name(self) -> Identifier | None: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
Named = TypeVar("Named", bound=HasName)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SchemaChange(StrEnum):
|
|
54
|
+
"""What happened to one named thing between two snapshots."""
|
|
55
|
+
|
|
56
|
+
ADDED = "added"
|
|
57
|
+
REMOVED = "removed"
|
|
58
|
+
CHANGED = "changed"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class SchemaDifference:
|
|
63
|
+
"""One named thing that is not the same in both snapshots."""
|
|
64
|
+
|
|
65
|
+
change: SchemaChange
|
|
66
|
+
kind: str
|
|
67
|
+
name: str
|
|
68
|
+
|
|
69
|
+
def describe(self) -> str:
|
|
70
|
+
"""What changed, and which one, where there is a which one to name.
|
|
71
|
+
|
|
72
|
+
The snapshot itself has no name to give, so it is described by what it
|
|
73
|
+
is rather than followed by nothing.
|
|
74
|
+
"""
|
|
75
|
+
if not self.name:
|
|
76
|
+
return f"{self.change.value} {self.kind}"
|
|
77
|
+
return f"{self.change.value} {self.kind} {self.name}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class SchemaDriftReport:
|
|
82
|
+
"""Everything that moved between two snapshots, and whether anything did."""
|
|
83
|
+
|
|
84
|
+
differences: tuple[SchemaDifference, ...]
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def is_current(self) -> bool:
|
|
88
|
+
return not self.differences
|
|
89
|
+
|
|
90
|
+
def summary(self) -> str:
|
|
91
|
+
if self.is_current:
|
|
92
|
+
return "the recorded schema matches the database"
|
|
93
|
+
written = ", ".join(item.describe() for item in self.differences)
|
|
94
|
+
return f"{len(self.differences)} schema difference(s): {written}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def compare_snapshots(
|
|
98
|
+
recorded: SchemaSnapshot,
|
|
99
|
+
observed: SchemaSnapshot,
|
|
100
|
+
/,
|
|
101
|
+
) -> SchemaDriftReport:
|
|
102
|
+
"""What changed between the snapshot on file and the one just read."""
|
|
103
|
+
differences: list[SchemaDifference] = []
|
|
104
|
+
if recorded.dialect is not observed.dialect:
|
|
105
|
+
differences.append(
|
|
106
|
+
SchemaDifference(SchemaChange.CHANGED, "dialect", observed.dialect.value)
|
|
107
|
+
)
|
|
108
|
+
_compare_own(
|
|
109
|
+
differences,
|
|
110
|
+
"snapshot",
|
|
111
|
+
"",
|
|
112
|
+
replace(recorded, catalogs=_STAND_IN_CATALOGS, dialect=observed.dialect),
|
|
113
|
+
replace(observed, catalogs=_STAND_IN_CATALOGS),
|
|
114
|
+
)
|
|
115
|
+
left = _by_name(recorded.catalogs, "")
|
|
116
|
+
right = _by_name(observed.catalogs, "")
|
|
117
|
+
_record_missing(differences, "catalog", left, right)
|
|
118
|
+
for name in _shared(left, right):
|
|
119
|
+
_compare_catalog(differences, name, left[name], right[name])
|
|
120
|
+
return SchemaDriftReport(tuple(differences))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _compare_catalog(
|
|
124
|
+
differences: list[SchemaDifference],
|
|
125
|
+
prefix: str,
|
|
126
|
+
recorded: Catalog,
|
|
127
|
+
observed: Catalog,
|
|
128
|
+
) -> None:
|
|
129
|
+
if recorded == observed:
|
|
130
|
+
return
|
|
131
|
+
_compare_own(
|
|
132
|
+
differences,
|
|
133
|
+
"catalog",
|
|
134
|
+
prefix,
|
|
135
|
+
replace(recorded, schemas=_STAND_IN_SCHEMAS),
|
|
136
|
+
replace(observed, schemas=_STAND_IN_SCHEMAS),
|
|
137
|
+
)
|
|
138
|
+
left = _by_name(recorded.schemas, prefix)
|
|
139
|
+
right = _by_name(observed.schemas, prefix)
|
|
140
|
+
_record_missing(differences, "schema", left, right)
|
|
141
|
+
for name in _shared(left, right):
|
|
142
|
+
_compare_schema(differences, name, left[name], right[name])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _compare_schema(
|
|
146
|
+
differences: list[SchemaDifference],
|
|
147
|
+
prefix: str,
|
|
148
|
+
recorded: Schema,
|
|
149
|
+
observed: Schema,
|
|
150
|
+
) -> None:
|
|
151
|
+
if recorded == observed:
|
|
152
|
+
return
|
|
153
|
+
_compare_own(
|
|
154
|
+
differences,
|
|
155
|
+
"schema",
|
|
156
|
+
prefix,
|
|
157
|
+
replace(recorded, **_NO_SCHEMA_CHILDREN),
|
|
158
|
+
replace(observed, **_NO_SCHEMA_CHILDREN),
|
|
159
|
+
)
|
|
160
|
+
_compare_tables(differences, prefix, recorded.tables, observed.tables)
|
|
161
|
+
_compare_leaves(differences, "view", prefix, recorded.views, observed.views)
|
|
162
|
+
_compare_leaves(differences, "enum", prefix, recorded.enums, observed.enums)
|
|
163
|
+
_compare_leaves(differences, "domain", prefix, recorded.domains, observed.domains)
|
|
164
|
+
_compare_leaves(
|
|
165
|
+
differences, "routine", prefix, recorded.routines, observed.routines
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _compare_tables(
|
|
170
|
+
differences: list[SchemaDifference],
|
|
171
|
+
prefix: str,
|
|
172
|
+
recorded: Sequence[Table],
|
|
173
|
+
observed: Sequence[Table],
|
|
174
|
+
) -> None:
|
|
175
|
+
left = _by_name(recorded, prefix)
|
|
176
|
+
right = _by_name(observed, prefix)
|
|
177
|
+
_record_missing(differences, "table", left, right)
|
|
178
|
+
for name in _shared(left, right):
|
|
179
|
+
if left[name] == right[name]:
|
|
180
|
+
continue
|
|
181
|
+
_compare_table(differences, name, left[name], right[name])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _compare_table(
|
|
185
|
+
differences: list[SchemaDifference],
|
|
186
|
+
prefix: str,
|
|
187
|
+
recorded: Table,
|
|
188
|
+
observed: Table,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""A table's named parts by name, and everything else as the table itself."""
|
|
191
|
+
_compare_own(
|
|
192
|
+
differences,
|
|
193
|
+
"table",
|
|
194
|
+
prefix,
|
|
195
|
+
replace(recorded, **_NO_TABLE_CHILDREN),
|
|
196
|
+
replace(observed, **_NO_TABLE_CHILDREN),
|
|
197
|
+
)
|
|
198
|
+
for kind, held in _TABLE_CHILDREN.items():
|
|
199
|
+
_compare_leaves(differences, kind, prefix, held(recorded), held(observed))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _compare_own(
|
|
203
|
+
differences: list[SchemaDifference],
|
|
204
|
+
kind: str,
|
|
205
|
+
name: str,
|
|
206
|
+
recorded: object,
|
|
207
|
+
observed: object,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""What a container holds itself, once its named children are emptied.
|
|
210
|
+
|
|
211
|
+
Comparing the remainder rather than a written list of attributes is what
|
|
212
|
+
keeps this exhaustive: a fact added to the schema model is compared
|
|
213
|
+
without being named here.
|
|
214
|
+
"""
|
|
215
|
+
if recorded == observed:
|
|
216
|
+
return
|
|
217
|
+
differences.append(SchemaDifference(SchemaChange.CHANGED, kind, name))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _compare_leaves(
|
|
221
|
+
differences: list[SchemaDifference],
|
|
222
|
+
kind: str,
|
|
223
|
+
prefix: str,
|
|
224
|
+
recorded: Sequence[Named],
|
|
225
|
+
observed: Sequence[Named],
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Things compared whole, because nothing inside them is named separately."""
|
|
228
|
+
left = _by_name(recorded, prefix)
|
|
229
|
+
right = _by_name(observed, prefix)
|
|
230
|
+
_record_missing(differences, kind, left, right)
|
|
231
|
+
for name in _shared(left, right):
|
|
232
|
+
if left[name] != right[name]:
|
|
233
|
+
differences.append(SchemaDifference(SchemaChange.CHANGED, kind, name))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
_STAND_IN_COLUMNS: tuple[Column, ...] = (
|
|
237
|
+
Column(Identifier("pyoq_stand_in"), SqlType(TypeKind.OTHER, "pyoq"), nullable=True),
|
|
238
|
+
)
|
|
239
|
+
_STAND_IN_SCHEMAS: tuple[Schema, ...] = (Schema(Identifier("pyoq_stand_in")),)
|
|
240
|
+
_STAND_IN_CATALOGS: tuple[Catalog, ...] = (
|
|
241
|
+
Catalog(Identifier("pyoq_stand_in"), _STAND_IN_SCHEMAS),
|
|
242
|
+
)
|
|
243
|
+
"""What stands in for a container's children while its own facts are compared.
|
|
244
|
+
|
|
245
|
+
The same value on both sides, so it cancels out and only the container's own
|
|
246
|
+
facts remain.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
_NO_SCHEMA_CHILDREN: dict[str, Any] = {
|
|
250
|
+
"tables": (),
|
|
251
|
+
"views": (),
|
|
252
|
+
"enums": (),
|
|
253
|
+
"domains": (),
|
|
254
|
+
"routines": (),
|
|
255
|
+
}
|
|
256
|
+
_NO_TABLE_CHILDREN: dict[str, Any] = {
|
|
257
|
+
"columns": _STAND_IN_COLUMNS,
|
|
258
|
+
"keys": (),
|
|
259
|
+
"relations": (),
|
|
260
|
+
"indexes": (),
|
|
261
|
+
"checks": (),
|
|
262
|
+
}
|
|
263
|
+
_TABLE_CHILDREN: dict[str, Callable[[Table], Sequence[HasName]]] = {
|
|
264
|
+
"column": lambda table: table.columns,
|
|
265
|
+
"key": lambda table: table.keys,
|
|
266
|
+
"relation": lambda table: table.relations,
|
|
267
|
+
"index": lambda table: table.indexes,
|
|
268
|
+
"check": lambda table: table.checks,
|
|
269
|
+
}
|
|
270
|
+
"""Everything a table names, so each is reported as itself rather than as the
|
|
271
|
+
table it sits in."""
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _record_missing(
|
|
275
|
+
differences: list[SchemaDifference],
|
|
276
|
+
kind: str,
|
|
277
|
+
recorded: Mapping[str, Named],
|
|
278
|
+
observed: Mapping[str, Named],
|
|
279
|
+
) -> None:
|
|
280
|
+
for name in sorted(set(recorded) - set(observed)):
|
|
281
|
+
differences.append(SchemaDifference(SchemaChange.REMOVED, kind, name))
|
|
282
|
+
for name in sorted(set(observed) - set(recorded)):
|
|
283
|
+
differences.append(SchemaDifference(SchemaChange.ADDED, kind, name))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _shared(
|
|
287
|
+
recorded: Mapping[str, Named],
|
|
288
|
+
observed: Mapping[str, Named],
|
|
289
|
+
) -> tuple[str, ...]:
|
|
290
|
+
return tuple(sorted(set(recorded) & set(observed)))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _by_name(items: Sequence[Named], prefix: str) -> dict[str, Named]:
|
|
294
|
+
"""Named things by their written name, qualified by whatever holds them.
|
|
295
|
+
|
|
296
|
+
A catalog or a schema may be unnamed, and an unnamed one is still a
|
|
297
|
+
position, so it is written as the position it holds.
|
|
298
|
+
"""
|
|
299
|
+
named: dict[str, Named] = {}
|
|
300
|
+
for index, item in enumerate(items):
|
|
301
|
+
written = f"[{index}]" if item.name is None else item.name.value
|
|
302
|
+
named[f"{prefix}.{written}" if prefix else written] = item
|
|
303
|
+
return named
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
__all__ = (
|
|
307
|
+
"HasName",
|
|
308
|
+
"SchemaChange",
|
|
309
|
+
"SchemaDifference",
|
|
310
|
+
"SchemaDriftReport",
|
|
311
|
+
"compare_snapshots",
|
|
312
|
+
)
|
pyoq/snapshots/files.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Reading and writing a schema snapshot as a file.
|
|
2
|
+
|
|
3
|
+
A snapshot is the one canonical record of what a database holds. Written to a
|
|
4
|
+
file it becomes reviewable in a diff and usable without a database, which is
|
|
5
|
+
what lets generation and drift checks run where no credentials exist.
|
|
6
|
+
|
|
7
|
+
The bytes are the codec's, so a snapshot written twice from one database is
|
|
8
|
+
byte for byte the same file and an unrelated change shows as no change.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from contextlib import suppress
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from tempfile import NamedTemporaryFile
|
|
17
|
+
|
|
18
|
+
from pyoq.errors import SchemaSerializationError
|
|
19
|
+
from pyoq.schema.codec import decode_snapshot, encode_snapshot
|
|
20
|
+
from pyoq.schema.models import SchemaSnapshot
|
|
21
|
+
|
|
22
|
+
_ENCODING = "utf-8"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_snapshot(snapshot: SchemaSnapshot, path: Path, /) -> Path:
|
|
26
|
+
"""Write a snapshot where a reader and a reviewer can both reach it.
|
|
27
|
+
|
|
28
|
+
Written beside its destination and moved into place, so a reader never
|
|
29
|
+
sees half a snapshot and a failed write leaves the previous one intact.
|
|
30
|
+
|
|
31
|
+
A write can fail while the staged file is being written, while it is being
|
|
32
|
+
closed, or as it is moved. Whichever it is, the staged file goes, so a
|
|
33
|
+
failed recording leaves the directory as it found it rather than beside a
|
|
34
|
+
partial file nobody will claim.
|
|
35
|
+
"""
|
|
36
|
+
destination = path.resolve()
|
|
37
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
staged = _staged(destination, encode_snapshot(snapshot))
|
|
39
|
+
try:
|
|
40
|
+
os.replace(staged, destination)
|
|
41
|
+
except BaseException:
|
|
42
|
+
_discard(staged)
|
|
43
|
+
raise
|
|
44
|
+
return destination
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _staged(destination: Path, written: str, /) -> Path:
|
|
48
|
+
"""The snapshot written beside where it is going, ready to be moved.
|
|
49
|
+
|
|
50
|
+
Closed inside the guard rather than after it, because a write is only
|
|
51
|
+
known to have reached the file once the file is closed, and a failure
|
|
52
|
+
there leaves as much behind as a failure while writing.
|
|
53
|
+
"""
|
|
54
|
+
handle = NamedTemporaryFile( # noqa: SIM115
|
|
55
|
+
"w",
|
|
56
|
+
encoding=_ENCODING,
|
|
57
|
+
newline="\n",
|
|
58
|
+
dir=destination.parent,
|
|
59
|
+
prefix=f".{destination.name}.",
|
|
60
|
+
suffix=".partial",
|
|
61
|
+
delete=False,
|
|
62
|
+
)
|
|
63
|
+
staged = Path(handle.name)
|
|
64
|
+
try:
|
|
65
|
+
with handle:
|
|
66
|
+
handle.write(written)
|
|
67
|
+
handle.write("\n")
|
|
68
|
+
except BaseException:
|
|
69
|
+
_discard(staged)
|
|
70
|
+
raise
|
|
71
|
+
return staged
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _discard(staged: Path, /) -> None:
|
|
75
|
+
"""Take the staged file away, and say nothing about having done so.
|
|
76
|
+
|
|
77
|
+
What went wrong is what the caller is told. A failure to clean up after a
|
|
78
|
+
failure would replace the reason with a second one, so it is not allowed
|
|
79
|
+
to.
|
|
80
|
+
"""
|
|
81
|
+
with suppress(OSError):
|
|
82
|
+
staged.unlink(missing_ok=True)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def read_snapshot(path: Path, /) -> SchemaSnapshot:
|
|
86
|
+
"""The snapshot a file holds, or a precise error saying why it does not."""
|
|
87
|
+
location = path.resolve()
|
|
88
|
+
try:
|
|
89
|
+
written = location.read_text(encoding=_ENCODING)
|
|
90
|
+
except OSError as error:
|
|
91
|
+
message = f"cannot read the schema snapshot at {location}: {error}"
|
|
92
|
+
raise SchemaSerializationError(message) from error
|
|
93
|
+
return decode_snapshot(written)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = ("read_snapshot", "write_snapshot")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Choosing where a schema is read from.
|
|
2
|
+
|
|
3
|
+
Generation asks for a snapshot and does not care where it came from. A project
|
|
4
|
+
that records its schema in a file is read from that file, and one that does not
|
|
5
|
+
is read from its database, so the same pipeline serves both and neither knows
|
|
6
|
+
which it got.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from pyoq.schema.registry import DialectRoutingSchemaSource
|
|
15
|
+
from pyoq.snapshots.source import SnapshotSchemaSource
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from pyoq.config import Configuration
|
|
19
|
+
from pyoq.schema.models import SchemaSnapshot
|
|
20
|
+
from pyoq.schema.source import SchemaSource
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_LIVE = DialectRoutingSchemaSource()
|
|
24
|
+
"""The database itself, which is what a project without a snapshot reads."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class ConfiguredSchemaSource:
|
|
29
|
+
"""The recorded schema where a project keeps one, the database otherwise."""
|
|
30
|
+
|
|
31
|
+
live: SchemaSource = _LIVE
|
|
32
|
+
|
|
33
|
+
def load(self, configuration: Configuration) -> SchemaSnapshot:
|
|
34
|
+
recorded = configuration.schema_snapshot_path
|
|
35
|
+
if recorded is None:
|
|
36
|
+
return self.live.load(configuration)
|
|
37
|
+
return SnapshotSchemaSource(recorded).load(configuration)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ("ConfiguredSchemaSource",)
|
pyoq/snapshots/source.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Reading a schema from a file rather than from a database.
|
|
2
|
+
|
|
3
|
+
Generation asks a source for a snapshot and does not care where it came from.
|
|
4
|
+
A snapshot on disk is therefore all that generation needs, which is what lets
|
|
5
|
+
a check run where there are no credentials and nothing to connect to.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from pyoq.snapshots.files import read_snapshot
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from pyoq.config import Configuration
|
|
19
|
+
from pyoq.schema.models import SchemaSnapshot
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class SnapshotSchemaSource:
|
|
24
|
+
"""A schema source that opens a file and never opens a connection."""
|
|
25
|
+
|
|
26
|
+
path: Path
|
|
27
|
+
|
|
28
|
+
def load(self, configuration: Configuration) -> SchemaSnapshot:
|
|
29
|
+
del configuration
|
|
30
|
+
return read_snapshot(self.path)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = ("SnapshotSchemaSource",)
|
pyoq/tracing/__init__.py
ADDED
pyoq/tracing/spans.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Turning statement events into spans.
|
|
2
|
+
|
|
3
|
+
The span carries the shape of a statement and its counts, which is what the
|
|
4
|
+
event carries. Nothing a query was asked about reaches a trace unless the policy
|
|
5
|
+
that produced the event already allowed it, so the decision stays in one place.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from opentelemetry.trace import SpanKind, Status, StatusCode, get_tracer
|
|
13
|
+
|
|
14
|
+
from pyoq.diagnostics.events import (
|
|
15
|
+
StatementFailed,
|
|
16
|
+
StatementFinished,
|
|
17
|
+
StatementStarted,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from opentelemetry.trace import Span, Tracer
|
|
22
|
+
|
|
23
|
+
from pyoq.diagnostics.events import StatementEvent
|
|
24
|
+
|
|
25
|
+
INSTRUMENTATION_NAME = "pyoq"
|
|
26
|
+
|
|
27
|
+
_NANOSECONDS = 1_000_000_000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TracingSink:
|
|
31
|
+
"""Records each finished statement as a span of its own.
|
|
32
|
+
|
|
33
|
+
A span is opened and closed when the statement is answered rather than when
|
|
34
|
+
it starts, because the event that says how long it took is the one that can
|
|
35
|
+
give the span its duration. A statement that never finished has no duration
|
|
36
|
+
to report and would leave a span open for as long as the process runs.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
__slots__ = ("_tracer",)
|
|
40
|
+
|
|
41
|
+
def __init__(self, tracer: Tracer | None = None) -> None:
|
|
42
|
+
self._tracer = tracer or get_tracer(INSTRUMENTATION_NAME)
|
|
43
|
+
|
|
44
|
+
def record(self, event: StatementEvent, /) -> None:
|
|
45
|
+
if isinstance(event, StatementStarted):
|
|
46
|
+
return
|
|
47
|
+
span = self._tracer.start_span(
|
|
48
|
+
_name(event),
|
|
49
|
+
kind=SpanKind.CLIENT,
|
|
50
|
+
start_time=_started(event),
|
|
51
|
+
)
|
|
52
|
+
_describe(span, event)
|
|
53
|
+
span.end()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _name(event: StatementFinished | StatementFailed) -> str:
|
|
57
|
+
return f"pyoq {event.kind.value}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _started(event: StatementFinished | StatementFailed) -> int:
|
|
61
|
+
from time import time_ns
|
|
62
|
+
|
|
63
|
+
return time_ns() - int(event.duration * _NANOSECONDS)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _describe(span: Span, event: StatementFinished | StatementFailed) -> None:
|
|
67
|
+
span.set_attribute("db.system", "sql")
|
|
68
|
+
span.set_attribute("db.statement", event.shape.sql)
|
|
69
|
+
span.set_attribute("pyoq.shape", event.shape.digest)
|
|
70
|
+
span.set_attribute("pyoq.parameters", event.parameters)
|
|
71
|
+
span.set_attribute("pyoq.sensitive_parameters", event.sensitive_parameters)
|
|
72
|
+
if isinstance(event, StatementFinished):
|
|
73
|
+
_describe_finished(span, event)
|
|
74
|
+
return
|
|
75
|
+
span.set_attribute("pyoq.failure", event.failure)
|
|
76
|
+
if event.detail is not None:
|
|
77
|
+
span.set_attribute("pyoq.failure_detail", event.detail)
|
|
78
|
+
span.set_status(Status(StatusCode.ERROR, event.failure))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _describe_finished(span: Span, event: StatementFinished) -> None:
|
|
82
|
+
if event.rows is not None:
|
|
83
|
+
span.set_attribute("pyoq.rows", event.rows)
|
|
84
|
+
if event.slow:
|
|
85
|
+
span.set_attribute("pyoq.slow", value=True)
|
|
86
|
+
span.set_status(Status(StatusCode.OK))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = ("INSTRUMENTATION_NAME", "TracingSink")
|
pyoq/unset.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Explicit unset marker shared by write values and generated builders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnsetType(Enum):
|
|
9
|
+
UNSET = "unset"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
UNSET = UnsetType.UNSET
|
|
13
|
+
|
|
14
|
+
__all__ = ("UNSET", "UnsetType")
|