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.
Files changed (267) hide show
  1. pyoq/__init__.py +10 -0
  2. pyoq/__main__.py +5 -0
  3. pyoq/_native.pyi +5 -0
  4. pyoq/cli/__init__.py +5 -0
  5. pyoq/cli/commands.py +270 -0
  6. pyoq/cli/defaults.py +98 -0
  7. pyoq/cli/services.py +97 -0
  8. pyoq/config/__init__.py +31 -0
  9. pyoq/config/connection.py +161 -0
  10. pyoq/config/loader.py +289 -0
  11. pyoq/config/models.py +245 -0
  12. pyoq/config/values.py +142 -0
  13. pyoq/descriptors.py +165 -0
  14. pyoq/diagnostics/__init__.py +68 -0
  15. pyoq/diagnostics/budget.py +136 -0
  16. pyoq/diagnostics/events.py +137 -0
  17. pyoq/diagnostics/fingerprint.py +267 -0
  18. pyoq/diagnostics/instrumented.py +237 -0
  19. pyoq/diagnostics/metrics.py +61 -0
  20. pyoq/diagnostics/observation.py +227 -0
  21. pyoq/diagnostics/scoped.py +103 -0
  22. pyoq/django/__init__.py +15 -0
  23. pyoq/django/apps.py +17 -0
  24. pyoq/django/execution.py +317 -0
  25. pyoq/django/generation.py +59 -0
  26. pyoq/django/management/__init__.py +0 -0
  27. pyoq/django/management/commands/__init__.py +0 -0
  28. pyoq/django/management/commands/makemigrations.py +53 -0
  29. pyoq/django/management/commands/pyoq_codegen.py +75 -0
  30. pyoq/django/parameters.py +101 -0
  31. pyoq/django/schema.py +379 -0
  32. pyoq/django/settings.py +87 -0
  33. pyoq/django/timeouts.py +105 -0
  34. pyoq/dsl/__init__.py +64 -0
  35. pyoq/dsl/aio/__init__.py +31 -0
  36. pyoq/dsl/aio/context.py +295 -0
  37. pyoq/dsl/aio/queries.py +335 -0
  38. pyoq/dsl/aio/writes.py +368 -0
  39. pyoq/dsl/context.py +326 -0
  40. pyoq/dsl/entry.py +37 -0
  41. pyoq/dsl/labels.py +36 -0
  42. pyoq/dsl/queries.py +339 -0
  43. pyoq/dsl/result.py +164 -0
  44. pyoq/dsl/writes.py +360 -0
  45. pyoq/errors.py +317 -0
  46. pyoq/fastapi/__init__.py +32 -0
  47. pyoq/fastapi/dependencies.py +167 -0
  48. pyoq/fastapi/lifespan.py +119 -0
  49. pyoq/fetching/__init__.py +55 -0
  50. pyoq/fetching/collections.py +136 -0
  51. pyoq/fetching/execution.py +587 -0
  52. pyoq/fetching/joined.py +79 -0
  53. pyoq/fetching/nesting.py +183 -0
  54. pyoq/fetching/plans.py +541 -0
  55. pyoq/fetching/select_in.py +149 -0
  56. pyoq/fetching/tables.py +110 -0
  57. pyoq/generation/__init__.py +54 -0
  58. pyoq/generation/cleanup.py +44 -0
  59. pyoq/generation/contracts.py +248 -0
  60. pyoq/generation/drift.py +169 -0
  61. pyoq/generation/lock.py +33 -0
  62. pyoq/generation/manifest.py +114 -0
  63. pyoq/generation/model.py +1001 -0
  64. pyoq/generation/pipeline.py +119 -0
  65. pyoq/generation/rendering/__init__.py +5 -0
  66. pyoq/generation/rendering/domains.py +51 -0
  67. pyoq/generation/rendering/enums.py +29 -0
  68. pyoq/generation/rendering/exports.py +70 -0
  69. pyoq/generation/rendering/imports.py +63 -0
  70. pyoq/generation/rendering/package.py +56 -0
  71. pyoq/generation/rendering/relations.py +133 -0
  72. pyoq/generation/rendering/routines.py +396 -0
  73. pyoq/generation/rendering/rows.py +79 -0
  74. pyoq/generation/rendering/source.py +121 -0
  75. pyoq/generation/rendering/tables.py +300 -0
  76. pyoq/generation/rendering/writes.py +514 -0
  77. pyoq/generation/validation.py +27 -0
  78. pyoq/generation/writer.py +184 -0
  79. pyoq/hydration/__init__.py +24 -0
  80. pyoq/hydration/engine.py +155 -0
  81. pyoq/hydration/identity.py +194 -0
  82. pyoq/hydration/plan.py +116 -0
  83. pyoq/migrations/__init__.py +9 -0
  84. pyoq/migrations/alembic.py +106 -0
  85. pyoq/migrations/hooks.py +75 -0
  86. pyoq/naming.py +261 -0
  87. pyoq/policies/__init__.py +47 -0
  88. pyoq/policies/bypass.py +122 -0
  89. pyoq/policies/governed.py +430 -0
  90. pyoq/policies/model.py +242 -0
  91. pyoq/policies/rewriting.py +263 -0
  92. pyoq/py.typed +1 -0
  93. pyoq/query/__init__.py +312 -0
  94. pyoq/query/aggregates.py +172 -0
  95. pyoq/query/arrays.py +65 -0
  96. pyoq/query/binding.py +52 -0
  97. pyoq/query/capabilities.py +317 -0
  98. pyoq/query/casts.py +73 -0
  99. pyoq/query/choices.py +185 -0
  100. pyoq/query/decoding.py +360 -0
  101. pyoq/query/documents.py +56 -0
  102. pyoq/query/execution/__init__.py +63 -0
  103. pyoq/query/execution/aio/__init__.py +31 -0
  104. pyoq/query/execution/aio/operations.py +228 -0
  105. pyoq/query/execution/aio/pooling.py +233 -0
  106. pyoq/query/execution/aio/streaming.py +161 -0
  107. pyoq/query/execution/aio/transactions.py +105 -0
  108. pyoq/query/execution/batch.py +96 -0
  109. pyoq/query/execution/binding_style.py +30 -0
  110. pyoq/query/execution/compilation.py +48 -0
  111. pyoq/query/execution/context.py +61 -0
  112. pyoq/query/execution/control.py +50 -0
  113. pyoq/query/execution/operations.py +224 -0
  114. pyoq/query/execution/planning.py +107 -0
  115. pyoq/query/execution/pooling.py +279 -0
  116. pyoq/query/execution/results.py +36 -0
  117. pyoq/query/execution/streaming.py +178 -0
  118. pyoq/query/execution/transactions.py +95 -0
  119. pyoq/query/expressions.py +1200 -0
  120. pyoq/query/fields.py +60 -0
  121. pyoq/query/mysql/__init__.py +59 -0
  122. pyoq/query/mysql/aio/__init__.py +38 -0
  123. pyoq/query/mysql/aio/commands.py +389 -0
  124. pyoq/query/mysql/aio/driver.py +196 -0
  125. pyoq/query/mysql/aio/executor.py +123 -0
  126. pyoq/query/mysql/aio/factory.py +26 -0
  127. pyoq/query/mysql/aio/operations.py +38 -0
  128. pyoq/query/mysql/aio/pool.py +53 -0
  129. pyoq/query/mysql/aio/transactions.py +313 -0
  130. pyoq/query/mysql/commands.py +354 -0
  131. pyoq/query/mysql/compiler.py +134 -0
  132. pyoq/query/mysql/context.py +20 -0
  133. pyoq/query/mysql/executor.py +126 -0
  134. pyoq/query/mysql/expressions.py +244 -0
  135. pyoq/query/mysql/factory.py +46 -0
  136. pyoq/query/mysql/health.py +66 -0
  137. pyoq/query/mysql/identifiers.py +9 -0
  138. pyoq/query/mysql/model.py +79 -0
  139. pyoq/query/mysql/operations.py +43 -0
  140. pyoq/query/mysql/parameters.py +69 -0
  141. pyoq/query/mysql/planning.py +20 -0
  142. pyoq/query/mysql/pool.py +67 -0
  143. pyoq/query/mysql/transactions.py +331 -0
  144. pyoq/query/mysql/writes.py +73 -0
  145. pyoq/query/nodes.py +750 -0
  146. pyoq/query/postgres/__init__.py +48 -0
  147. pyoq/query/postgres/aio/__init__.py +25 -0
  148. pyoq/query/postgres/aio/bulk.py +56 -0
  149. pyoq/query/postgres/aio/commands.py +264 -0
  150. pyoq/query/postgres/aio/executor.py +152 -0
  151. pyoq/query/postgres/aio/factory.py +26 -0
  152. pyoq/query/postgres/aio/operations.py +26 -0
  153. pyoq/query/postgres/aio/pool.py +40 -0
  154. pyoq/query/postgres/aio/transactions.py +295 -0
  155. pyoq/query/postgres/bulk.py +62 -0
  156. pyoq/query/postgres/commands.py +238 -0
  157. pyoq/query/postgres/compiler.py +114 -0
  158. pyoq/query/postgres/context.py +20 -0
  159. pyoq/query/postgres/executor.py +147 -0
  160. pyoq/query/postgres/expressions.py +311 -0
  161. pyoq/query/postgres/factory.py +24 -0
  162. pyoq/query/postgres/health.py +24 -0
  163. pyoq/query/postgres/identifiers.py +9 -0
  164. pyoq/query/postgres/model.py +81 -0
  165. pyoq/query/postgres/operations.py +25 -0
  166. pyoq/query/postgres/parameters.py +71 -0
  167. pyoq/query/postgres/planning.py +20 -0
  168. pyoq/query/postgres/pool.py +52 -0
  169. pyoq/query/postgres/transactions.py +295 -0
  170. pyoq/query/postgres/writes.py +37 -0
  171. pyoq/query/projections.py +105 -0
  172. pyoq/query/raw.py +90 -0
  173. pyoq/query/recursion.py +265 -0
  174. pyoq/query/rendering/__init__.py +1 -0
  175. pyoq/query/rendering/expressions.py +913 -0
  176. pyoq/query/rendering/identifiers.py +40 -0
  177. pyoq/query/rendering/projections.py +63 -0
  178. pyoq/query/rendering/queries.py +334 -0
  179. pyoq/query/rendering/sources.py +66 -0
  180. pyoq/query/rendering/writes.py +176 -0
  181. pyoq/query/results.py +459 -0
  182. pyoq/query/routines.py +196 -0
  183. pyoq/query/rows.py +156 -0
  184. pyoq/query/select.py +793 -0
  185. pyoq/query/select_nodes.py +277 -0
  186. pyoq/query/sources.py +236 -0
  187. pyoq/query/sqlite/__init__.py +43 -0
  188. pyoq/query/sqlite/commands.py +201 -0
  189. pyoq/query/sqlite/compiler.py +139 -0
  190. pyoq/query/sqlite/context.py +20 -0
  191. pyoq/query/sqlite/executor.py +119 -0
  192. pyoq/query/sqlite/expressions.py +224 -0
  193. pyoq/query/sqlite/factory.py +32 -0
  194. pyoq/query/sqlite/health.py +28 -0
  195. pyoq/query/sqlite/identifiers.py +9 -0
  196. pyoq/query/sqlite/model.py +73 -0
  197. pyoq/query/sqlite/operations.py +36 -0
  198. pyoq/query/sqlite/parameters.py +50 -0
  199. pyoq/query/sqlite/planning.py +20 -0
  200. pyoq/query/sqlite/pool.py +50 -0
  201. pyoq/query/sqlite/streaming.py +13 -0
  202. pyoq/query/sqlite/transactions.py +274 -0
  203. pyoq/query/sqlite/writes.py +35 -0
  204. pyoq/query/statements.py +27 -0
  205. pyoq/query/values.py +23 -0
  206. pyoq/query/vendor.py +162 -0
  207. pyoq/query/windows.py +424 -0
  208. pyoq/query/write_nodes.py +174 -0
  209. pyoq/query/writes.py +628 -0
  210. pyoq/relations/__init__.py +66 -0
  211. pyoq/relations/batching.py +219 -0
  212. pyoq/relations/derivation.py +111 -0
  213. pyoq/relations/fetching.py +355 -0
  214. pyoq/relations/graph.py +245 -0
  215. pyoq/relations/loading.py +74 -0
  216. pyoq/relations/model.py +75 -0
  217. pyoq/relations/planning.py +206 -0
  218. pyoq/runtime/__init__.py +9 -0
  219. pyoq/runtime/kernels.py +25 -0
  220. pyoq/runtime/python.py +43 -0
  221. pyoq/runtime/selection.py +73 -0
  222. pyoq/sanic/__init__.py +32 -0
  223. pyoq/sanic/scope.py +197 -0
  224. pyoq/sanic/workers.py +129 -0
  225. pyoq/schema/__init__.py +108 -0
  226. pyoq/schema/codec.py +711 -0
  227. pyoq/schema/models.py +604 -0
  228. pyoq/schema/mysql/__init__.py +16 -0
  229. pyoq/schema/mysql/connection.py +73 -0
  230. pyoq/schema/mysql/dsn.py +72 -0
  231. pyoq/schema/mysql/records.py +354 -0
  232. pyoq/schema/mysql/reflection.py +309 -0
  233. pyoq/schema/mysql/source.py +30 -0
  234. pyoq/schema/mysql/sql.py +128 -0
  235. pyoq/schema/mysql/types.py +105 -0
  236. pyoq/schema/postgres/__init__.py +13 -0
  237. pyoq/schema/postgres/connection.py +63 -0
  238. pyoq/schema/postgres/records.py +384 -0
  239. pyoq/schema/postgres/reflection.py +466 -0
  240. pyoq/schema/postgres/source.py +30 -0
  241. pyoq/schema/postgres/sql.py +246 -0
  242. pyoq/schema/postgres/types.py +98 -0
  243. pyoq/schema/registry.py +45 -0
  244. pyoq/schema/source.py +15 -0
  245. pyoq/schema/sqlite/__init__.py +6 -0
  246. pyoq/schema/sqlite/connection.py +54 -0
  247. pyoq/schema/sqlite/records.py +167 -0
  248. pyoq/schema/sqlite/reflection.py +393 -0
  249. pyoq/schema/sqlite/source.py +30 -0
  250. pyoq/schema/sqlite/sql.py +254 -0
  251. pyoq/schema/sqlite/types.py +74 -0
  252. pyoq/serving/__init__.py +23 -0
  253. pyoq/serving/databases.py +107 -0
  254. pyoq/serving/opening.py +331 -0
  255. pyoq/snapshots/__init__.py +20 -0
  256. pyoq/snapshots/drift.py +312 -0
  257. pyoq/snapshots/files.py +96 -0
  258. pyoq/snapshots/routing.py +40 -0
  259. pyoq/snapshots/source.py +33 -0
  260. pyoq/tracing/__init__.py +5 -0
  261. pyoq/tracing/spans.py +89 -0
  262. pyoq/unset.py +14 -0
  263. pyoq_sql-1.0.2.dist-info/METADATA +3050 -0
  264. pyoq_sql-1.0.2.dist-info/RECORD +267 -0
  265. pyoq_sql-1.0.2.dist-info/WHEEL +4 -0
  266. pyoq_sql-1.0.2.dist-info/entry_points.txt +3 -0
  267. pyoq_sql-1.0.2.dist-info/licenses/LICENSE +373 -0
@@ -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
+ )
@@ -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",)
@@ -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",)
@@ -0,0 +1,5 @@
1
+ """Reporting PyOQ's statements to OpenTelemetry."""
2
+
3
+ from pyoq.tracing.spans import INSTRUMENTATION_NAME, TracingSink
4
+
5
+ __all__ = ("INSTRUMENTATION_NAME", "TracingSink")
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")