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
pyoq/schema/models.py ADDED
@@ -0,0 +1,604 @@
1
+ """Immutable database schema model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+ from typing import Protocol, TypeAlias, TypeVar
9
+
10
+ from pyoq.config import DatabaseDialect
11
+ from pyoq.errors import SchemaValidationError
12
+
13
+ SCHEMA_FORMAT_VERSION = 1
14
+ UniqueValue = TypeVar("UniqueValue", bound=Hashable)
15
+
16
+
17
+ class NamedSchemaItem(Protocol):
18
+ @property
19
+ def name(self) -> Identifier | None: ...
20
+
21
+
22
+ JsonScalar: TypeAlias = bool | int | float | str | None
23
+ JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
24
+ """What JSON can hold, said once for everything that needs to know.
25
+
26
+ A JSON number is not always whole, so a float is one of them.
27
+ """
28
+
29
+
30
+ class TypeKind(StrEnum):
31
+ BOOLEAN = "boolean"
32
+ SMALL_INTEGER = "small-integer"
33
+ INTEGER = "integer"
34
+ BIG_INTEGER = "big-integer"
35
+ DECIMAL = "decimal"
36
+ REAL = "real"
37
+ DOUBLE = "double"
38
+ STRING = "string"
39
+ BINARY = "binary"
40
+ DATE = "date"
41
+ TIME = "time"
42
+ DATETIME = "datetime"
43
+ INTERVAL = "interval"
44
+ UUID = "uuid"
45
+ JSON = "json"
46
+ ENUM = "enum"
47
+ ARRAY = "array"
48
+ DOMAIN = "domain"
49
+ OTHER = "other"
50
+
51
+
52
+ class GeneratedKind(StrEnum):
53
+ IDENTITY_ALWAYS = "identity-always"
54
+ IDENTITY_BY_DEFAULT = "identity-by-default"
55
+ COMPUTED_STORED = "computed-stored"
56
+ COMPUTED_VIRTUAL = "computed-virtual"
57
+
58
+
59
+ class KeyKind(StrEnum):
60
+ PRIMARY = "primary"
61
+ UNIQUE = "unique"
62
+
63
+
64
+ class ReferentialAction(StrEnum):
65
+ NO_ACTION = "no-action"
66
+ RESTRICT = "restrict"
67
+ CASCADE = "cascade"
68
+ SET_NULL = "set-null"
69
+ SET_DEFAULT = "set-default"
70
+
71
+
72
+ class Capability(StrEnum):
73
+ CATALOGS = "catalogs"
74
+ SCHEMAS = "schemas"
75
+ VIEWS = "views"
76
+ MATERIALIZED_VIEWS = "materialized-views"
77
+ ENUMS = "enums"
78
+ ARRAYS = "arrays"
79
+ GENERATED_COLUMNS = "generated-columns"
80
+ IDENTITY_COLUMNS = "identity-columns"
81
+ EXPRESSION_INDEXES = "expression-indexes"
82
+ PARTIAL_INDEXES = "partial-indexes"
83
+ CHECK_CONSTRAINTS = "check-constraints"
84
+
85
+
86
+ @dataclass(frozen=True, slots=True, order=True)
87
+ class Identifier:
88
+ value: str
89
+
90
+ def __post_init__(self) -> None:
91
+ _require_text(self.value, "identifier")
92
+ if "\x00" in self.value:
93
+ message = "identifier cannot contain a null character"
94
+ raise SchemaValidationError(message)
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class ObjectReference:
99
+ name: Identifier
100
+ schema: Identifier | None = None
101
+ catalog: Identifier | None = None
102
+
103
+
104
+ @dataclass(frozen=True, slots=True, order=True)
105
+ class MetadataEntry:
106
+ key: str
107
+ value: str
108
+
109
+ def __post_init__(self) -> None:
110
+ _require_text(self.key, "metadata key")
111
+ _require_text(self.value, "metadata value")
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class SqlExpression:
116
+ text: str
117
+
118
+ def __post_init__(self) -> None:
119
+ _require_text(self.text, "SQL expression")
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class SqlType:
124
+ kind: TypeKind
125
+ source_name: str
126
+ length: int | None = None
127
+ precision: int | None = None
128
+ scale: int | None = None
129
+ element_type: SqlType | None = None
130
+ named_type: ObjectReference | None = None
131
+ metadata: tuple[MetadataEntry, ...] = ()
132
+
133
+ def __post_init__(self) -> None:
134
+ _require_text(self.source_name, "source type name")
135
+ _require_positive(self.length, "type length")
136
+ _require_positive(self.precision, "type precision")
137
+ _validate_scale(self.precision, self.scale)
138
+ _validate_nested_type(self.kind, self.element_type, self.named_type)
139
+ object.__setattr__(self, "metadata", _canonical_metadata(self.metadata))
140
+
141
+
142
+ @dataclass(frozen=True, slots=True)
143
+ class GeneratedValue:
144
+ kind: GeneratedKind
145
+ expression: SqlExpression | None = None
146
+
147
+ def __post_init__(self) -> None:
148
+ computed = self.kind in {
149
+ GeneratedKind.COMPUTED_STORED,
150
+ GeneratedKind.COMPUTED_VIRTUAL,
151
+ }
152
+ if computed != (self.expression is not None):
153
+ message = "computed generation requires an expression; identity does not"
154
+ raise SchemaValidationError(message)
155
+
156
+
157
+ @dataclass(frozen=True, slots=True)
158
+ class Column:
159
+ name: Identifier
160
+ data_type: SqlType
161
+ nullable: bool
162
+ default: SqlExpression | None = None
163
+ generated: GeneratedValue | None = None
164
+ comment: str | None = None
165
+ metadata: tuple[MetadataEntry, ...] = ()
166
+
167
+ def __post_init__(self) -> None:
168
+ _validate_optional_text(self.comment, "column comment")
169
+ object.__setattr__(self, "metadata", _canonical_metadata(self.metadata))
170
+
171
+
172
+ @dataclass(frozen=True, slots=True)
173
+ class Key:
174
+ kind: KeyKind
175
+ columns: tuple[Identifier, ...]
176
+ name: Identifier | None = None
177
+
178
+ def __post_init__(self) -> None:
179
+ _require_identifiers(self.columns, "key columns")
180
+
181
+
182
+ @dataclass(frozen=True, slots=True)
183
+ class Relation:
184
+ columns: tuple[Identifier, ...]
185
+ target: ObjectReference
186
+ target_columns: tuple[Identifier, ...]
187
+ name: Identifier | None = None
188
+ on_update: ReferentialAction = ReferentialAction.NO_ACTION
189
+ on_delete: ReferentialAction = ReferentialAction.NO_ACTION
190
+
191
+ def __post_init__(self) -> None:
192
+ _require_identifiers(self.columns, "relation columns")
193
+ _require_identifiers(self.target_columns, "relation target columns")
194
+ if len(self.columns) != len(self.target_columns):
195
+ message = "relation source and target column counts must match"
196
+ raise SchemaValidationError(message)
197
+
198
+
199
+ @dataclass(frozen=True, slots=True)
200
+ class IndexTerm:
201
+ column: Identifier | None = None
202
+ expression: SqlExpression | None = None
203
+ descending: bool = False
204
+
205
+ def __post_init__(self) -> None:
206
+ if (self.column is None) == (self.expression is None):
207
+ message = "index term requires exactly one column or expression"
208
+ raise SchemaValidationError(message)
209
+
210
+
211
+ @dataclass(frozen=True, slots=True)
212
+ class Index:
213
+ name: Identifier
214
+ terms: tuple[IndexTerm, ...]
215
+ unique: bool = False
216
+ predicate: SqlExpression | None = None
217
+ method: str | None = None
218
+
219
+ def __post_init__(self) -> None:
220
+ if not self.terms:
221
+ message = "index must contain at least one term"
222
+ raise SchemaValidationError(message)
223
+ _validate_optional_text(self.method, "index method")
224
+
225
+
226
+ @dataclass(frozen=True, slots=True)
227
+ class CheckConstraint:
228
+ expression: SqlExpression
229
+ name: Identifier | None = None
230
+
231
+
232
+ @dataclass(frozen=True, slots=True)
233
+ class Table:
234
+ name: Identifier
235
+ columns: tuple[Column, ...]
236
+ keys: tuple[Key, ...] = ()
237
+ relations: tuple[Relation, ...] = ()
238
+ indexes: tuple[Index, ...] = ()
239
+ checks: tuple[CheckConstraint, ...] = ()
240
+ comment: str | None = None
241
+ metadata: tuple[MetadataEntry, ...] = ()
242
+
243
+ def __post_init__(self) -> None:
244
+ if not self.columns:
245
+ message = "table must contain at least one column"
246
+ raise SchemaValidationError(message)
247
+ column_names = tuple(column.name for column in self.columns)
248
+ _require_unique(column_names, "table column names")
249
+ _validate_table_keys(self.keys, column_names)
250
+ _validate_table_relations(self.relations, column_names)
251
+ _validate_table_indexes(self.indexes, column_names)
252
+ _validate_primary_key_count(self.keys)
253
+ _validate_named_items(self.keys, "key names")
254
+ _validate_named_items(self.relations, "relation names")
255
+ _validate_named_items(self.indexes, "index names")
256
+ _validate_named_items(self.checks, "check names")
257
+ _validate_optional_text(self.comment, "table comment")
258
+ object.__setattr__(self, "metadata", _canonical_metadata(self.metadata))
259
+
260
+
261
+ @dataclass(frozen=True, slots=True)
262
+ class View:
263
+ name: Identifier
264
+ columns: tuple[Column, ...]
265
+ definition: SqlExpression | None = None
266
+ materialized: bool = False
267
+ comment: str | None = None
268
+ metadata: tuple[MetadataEntry, ...] = ()
269
+
270
+ def __post_init__(self) -> None:
271
+ if not self.columns:
272
+ message = "view must contain at least one column"
273
+ raise SchemaValidationError(message)
274
+ _require_unique(tuple(column.name for column in self.columns), "view columns")
275
+ _validate_optional_text(self.comment, "view comment")
276
+ object.__setattr__(self, "metadata", _canonical_metadata(self.metadata))
277
+
278
+
279
+ @dataclass(frozen=True, slots=True)
280
+ class EnumType:
281
+ name: Identifier
282
+ values: tuple[str, ...]
283
+ comment: str | None = None
284
+
285
+ def __post_init__(self) -> None:
286
+ if not self.values:
287
+ message = "enum must contain at least one value"
288
+ raise SchemaValidationError(message)
289
+ for value in self.values:
290
+ _require_text(value, "enum value")
291
+ _require_unique(self.values, "enum values")
292
+ _validate_optional_text(self.comment, "enum comment")
293
+
294
+
295
+ @dataclass(frozen=True, slots=True)
296
+ class Domain:
297
+ """A named type written over another, with rules of its own.
298
+
299
+ Two domains over the same base are different types, so the name is what
300
+ identifies one. Its checks and default belong to the domain rather than
301
+ to any column of it, which is why they are kept here.
302
+ """
303
+
304
+ name: Identifier
305
+ data_type: SqlType
306
+ nullable: bool = True
307
+ default: SqlExpression | None = None
308
+ checks: tuple[CheckConstraint, ...] = ()
309
+ comment: str | None = None
310
+
311
+ def __post_init__(self) -> None:
312
+ if self.data_type.kind is TypeKind.DOMAIN:
313
+ message = "a domain is written over a type, not over another domain"
314
+ raise SchemaValidationError(message)
315
+ _require_unique(tuple(check.name for check in self.checks), "domain checks")
316
+ _validate_optional_text(self.comment, "domain comment")
317
+
318
+
319
+ class RoutineKind(StrEnum):
320
+ """Whether a routine answers with a value or is run for what it does."""
321
+
322
+ FUNCTION = "function"
323
+ PROCEDURE = "procedure"
324
+
325
+
326
+ class ParameterMode(StrEnum):
327
+ """Which way a value travels through a routine parameter."""
328
+
329
+ IN = "in"
330
+ OUT = "out"
331
+ INOUT = "inout"
332
+
333
+
334
+ @dataclass(frozen=True, slots=True)
335
+ class RoutineParameter:
336
+ """One parameter of a routine, as the catalog declares it.
337
+
338
+ No catalog states a parameter's nullability on the parameter. It comes
339
+ from the parameter's own type, which a domain can declare forbids null,
340
+ so that is the only way ``nullable`` is ever false.
341
+ """
342
+
343
+ name: Identifier | None
344
+ data_type: SqlType
345
+ mode: ParameterMode = ParameterMode.IN
346
+ has_default: bool = False
347
+ nullable: bool = True
348
+
349
+
350
+ @dataclass(frozen=True, slots=True)
351
+ class Routine:
352
+ """A procedure or function the database keeps.
353
+
354
+ ``overload`` separates routines that share a name, which several databases
355
+ allow. Without it a generated name could stand for more than one routine
356
+ and a call could reach the wrong one.
357
+ """
358
+
359
+ name: Identifier
360
+ kind: RoutineKind
361
+ parameters: tuple[RoutineParameter, ...] = ()
362
+ returns: SqlType | None = None
363
+ overload: str | None = None
364
+ comment: str | None = None
365
+
366
+ def __post_init__(self) -> None:
367
+ if (self.kind is RoutineKind.FUNCTION) != (self.returns is not None):
368
+ message = "a function answers with a type and a procedure does not"
369
+ raise SchemaValidationError(message)
370
+ named = tuple(
371
+ parameter.name
372
+ for parameter in self.parameters
373
+ if parameter.name is not None
374
+ )
375
+ _require_unique(named, "routine parameter names")
376
+ _validate_optional_text(self.comment, "routine comment")
377
+
378
+
379
+ @dataclass(frozen=True, slots=True)
380
+ class Schema:
381
+ name: Identifier | None
382
+ tables: tuple[Table, ...] = ()
383
+ views: tuple[View, ...] = ()
384
+ enums: tuple[EnumType, ...] = ()
385
+ domains: tuple[Domain, ...] = ()
386
+ routines: tuple[Routine, ...] = ()
387
+ comment: str | None = None
388
+
389
+ def __post_init__(self) -> None:
390
+ table_names = tuple(table.name for table in self.tables)
391
+ view_names = tuple(view.name for view in self.views)
392
+ _require_unique(table_names + view_names, "schema relation names")
393
+ _require_unique(tuple(enum.name for enum in self.enums), "schema enum names")
394
+ _require_unique(
395
+ tuple(domain.name for domain in self.domains), "schema domain names"
396
+ )
397
+ _require_unique(
398
+ tuple((routine.name.value, routine.overload) for routine in self.routines),
399
+ "schema routine names",
400
+ )
401
+ _validate_optional_text(self.comment, "schema comment")
402
+
403
+
404
+ @dataclass(frozen=True, slots=True)
405
+ class Catalog:
406
+ name: Identifier | None
407
+ schemas: tuple[Schema, ...]
408
+ comment: str | None = None
409
+
410
+ def __post_init__(self) -> None:
411
+ if not self.schemas:
412
+ message = "catalog must contain at least one schema"
413
+ raise SchemaValidationError(message)
414
+ _require_unique(tuple(schema.name for schema in self.schemas), "schema names")
415
+ _validate_optional_text(self.comment, "catalog comment")
416
+
417
+
418
+ @dataclass(frozen=True, slots=True)
419
+ class CapabilitySet:
420
+ values: frozenset[Capability] = frozenset()
421
+
422
+ def supports(self, capability: Capability) -> bool:
423
+ return capability in self.values
424
+
425
+
426
+ @dataclass(frozen=True, slots=True)
427
+ class SchemaSnapshot:
428
+ dialect: DatabaseDialect
429
+ catalogs: tuple[Catalog, ...]
430
+ capabilities: CapabilitySet = CapabilitySet()
431
+ format_version: int = SCHEMA_FORMAT_VERSION
432
+
433
+ def __post_init__(self) -> None:
434
+ if self.format_version != SCHEMA_FORMAT_VERSION:
435
+ message = f"unsupported schema format version: {self.format_version}"
436
+ raise SchemaValidationError(message)
437
+ if not self.catalogs:
438
+ message = "schema snapshot must contain at least one catalog"
439
+ raise SchemaValidationError(message)
440
+ _require_unique(
441
+ tuple(catalog.name for catalog in self.catalogs),
442
+ "catalog names",
443
+ )
444
+
445
+ def to_json(self) -> str:
446
+ from pyoq.schema.codec import encode_snapshot
447
+
448
+ return encode_snapshot(self)
449
+
450
+ @staticmethod
451
+ def from_json(value: str) -> SchemaSnapshot:
452
+ from pyoq.schema.codec import decode_snapshot
453
+
454
+ return decode_snapshot(value)
455
+
456
+
457
+ def _require_text(value: str, label: str) -> None:
458
+ if not value:
459
+ message = f"{label} cannot be empty"
460
+ raise SchemaValidationError(message)
461
+
462
+
463
+ def _validate_optional_text(value: str | None, label: str) -> None:
464
+ if value is not None:
465
+ _require_text(value, label)
466
+
467
+
468
+ def _require_positive(value: int | None, label: str) -> None:
469
+ if value is not None and value <= 0:
470
+ message = f"{label} must be positive"
471
+ raise SchemaValidationError(message)
472
+
473
+
474
+ def _validate_scale(precision: int | None, scale: int | None) -> None:
475
+ if scale is None:
476
+ return
477
+ if scale < 0 or precision is None or scale > precision:
478
+ message = "type scale requires precision and must be within its range"
479
+ raise SchemaValidationError(message)
480
+
481
+
482
+ _NESTING_KINDS = frozenset({TypeKind.ARRAY, TypeKind.DOMAIN})
483
+ """The kinds that are written around another type."""
484
+
485
+ _NAMED_KINDS = frozenset({TypeKind.ENUM, TypeKind.DOMAIN})
486
+ """The kinds a database knows by a name of their own."""
487
+
488
+
489
+ def _validate_nested_type(
490
+ kind: TypeKind,
491
+ element_type: SqlType | None,
492
+ named_type: ObjectReference | None,
493
+ ) -> None:
494
+ if (kind in _NESTING_KINDS) != (element_type is not None):
495
+ message = (
496
+ "array and domain types require the type inside them, and other "
497
+ "types forbid one"
498
+ )
499
+ raise SchemaValidationError(message)
500
+ if (kind in _NAMED_KINDS) != (named_type is not None):
501
+ message = (
502
+ "enum and domain types require a named type and other types forbid one"
503
+ )
504
+ raise SchemaValidationError(message)
505
+
506
+
507
+ def _canonical_metadata(
508
+ metadata: tuple[MetadataEntry, ...],
509
+ ) -> tuple[MetadataEntry, ...]:
510
+ ordered = tuple(sorted(metadata))
511
+ _require_unique(tuple(entry.key for entry in ordered), "metadata keys")
512
+ return ordered
513
+
514
+
515
+ def _require_identifiers(values: tuple[Identifier, ...], label: str) -> None:
516
+ if not values:
517
+ message = f"{label} cannot be empty"
518
+ raise SchemaValidationError(message)
519
+ _require_unique(values, label)
520
+
521
+
522
+ def _require_unique(values: tuple[UniqueValue, ...], label: str) -> None:
523
+ if len(set(values)) != len(values):
524
+ message = f"{label} must be unique"
525
+ raise SchemaValidationError(message)
526
+
527
+
528
+ def _validate_primary_key_count(keys: tuple[Key, ...]) -> None:
529
+ count = sum(key.kind is KeyKind.PRIMARY for key in keys)
530
+ if count > 1:
531
+ message = "table cannot contain more than one primary key"
532
+ raise SchemaValidationError(message)
533
+
534
+
535
+ def _validate_table_keys(
536
+ keys: tuple[Key, ...],
537
+ columns: tuple[Identifier, ...],
538
+ ) -> None:
539
+ for key in keys:
540
+ _require_local_columns(key.columns, columns, "key")
541
+
542
+
543
+ def _validate_table_relations(
544
+ relations: tuple[Relation, ...],
545
+ columns: tuple[Identifier, ...],
546
+ ) -> None:
547
+ for relation in relations:
548
+ _require_local_columns(relation.columns, columns, "relation")
549
+
550
+
551
+ def _validate_table_indexes(
552
+ indexes: tuple[Index, ...],
553
+ columns: tuple[Identifier, ...],
554
+ ) -> None:
555
+ for index in indexes:
556
+ for term in index.terms:
557
+ if term.column is not None:
558
+ _require_local_columns((term.column,), columns, "index")
559
+
560
+
561
+ def _require_local_columns(
562
+ selected: tuple[Identifier, ...],
563
+ available: tuple[Identifier, ...],
564
+ label: str,
565
+ ) -> None:
566
+ missing = tuple(column for column in selected if column not in available)
567
+ if missing:
568
+ names = ", ".join(column.value for column in missing)
569
+ message = f"{label} references unknown local columns: {names}"
570
+ raise SchemaValidationError(message)
571
+
572
+
573
+ def _validate_named_items(items: tuple[NamedSchemaItem, ...], label: str) -> None:
574
+ names = tuple(item.name for item in items if item.name is not None)
575
+ _require_unique(names, label)
576
+
577
+
578
+ __all__ = (
579
+ "SCHEMA_FORMAT_VERSION",
580
+ "Capability",
581
+ "CapabilitySet",
582
+ "Catalog",
583
+ "CheckConstraint",
584
+ "Column",
585
+ "EnumType",
586
+ "GeneratedKind",
587
+ "GeneratedValue",
588
+ "Identifier",
589
+ "Index",
590
+ "IndexTerm",
591
+ "Key",
592
+ "KeyKind",
593
+ "MetadataEntry",
594
+ "ObjectReference",
595
+ "ReferentialAction",
596
+ "Relation",
597
+ "Schema",
598
+ "SchemaSnapshot",
599
+ "SqlExpression",
600
+ "SqlType",
601
+ "Table",
602
+ "TypeKind",
603
+ "View",
604
+ )
@@ -0,0 +1,16 @@
1
+ """MySQL schema support."""
2
+
3
+ from pyoq.schema.mysql.connection import MySQLConnectionProvider
4
+ from pyoq.schema.mysql.dsn import MySQLConnectionSettings, parse_data_source_name
5
+ from pyoq.schema.mysql.reflection import MySQLSchemaInspector
6
+ from pyoq.schema.mysql.source import MySQLSchemaSource
7
+ from pyoq.schema.mysql.types import mysql_type
8
+
9
+ __all__ = (
10
+ "MySQLConnectionProvider",
11
+ "MySQLConnectionSettings",
12
+ "MySQLSchemaInspector",
13
+ "MySQLSchemaSource",
14
+ "mysql_type",
15
+ "parse_data_source_name",
16
+ )
@@ -0,0 +1,73 @@
1
+ """Read-only MySQL connection ownership."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Generator
6
+ from contextlib import AbstractContextManager, contextmanager
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING
9
+
10
+ from pyoq.config import Configuration
11
+ from pyoq.errors import (
12
+ ConfigurationValidationError,
13
+ OperationUnavailableError,
14
+ SchemaInspectionError,
15
+ )
16
+ from pyoq.schema.mysql.dsn import MySQLConnectionSettings, parse_data_source_name
17
+
18
+ if TYPE_CHECKING:
19
+ from pymysql.connections import Connection
20
+ from pymysql.cursors import Cursor
21
+
22
+ _MISSING_DRIVER = "MySQL support requires the PyMySQL driver; install pyoq-sql[mysql]"
23
+ _READ_ONLY_SESSION = "SET SESSION TRANSACTION READ ONLY"
24
+ _CHARSET = "utf8mb4"
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class MySQLConnectionProvider:
29
+ def open(
30
+ self,
31
+ configuration: Configuration,
32
+ ) -> AbstractContextManager[Connection[Cursor]]:
33
+ return self._connect(parse_data_source_name(_data_source_name(configuration)))
34
+
35
+ @contextmanager
36
+ def _connect(
37
+ self,
38
+ settings: MySQLConnectionSettings,
39
+ ) -> Generator[Connection[Cursor]]:
40
+ try:
41
+ import pymysql
42
+ except ImportError as error:
43
+ raise OperationUnavailableError(_MISSING_DRIVER) from error
44
+ try:
45
+ connection = pymysql.connect(
46
+ host=settings.host,
47
+ port=settings.port,
48
+ user=settings.user,
49
+ password=settings.password,
50
+ database=settings.database,
51
+ charset=_CHARSET,
52
+ autocommit=True,
53
+ init_command=_READ_ONLY_SESSION,
54
+ )
55
+ except pymysql.Error as error:
56
+ message = f"unable to open MySQL connection: {error}"
57
+ raise SchemaInspectionError(message) from error
58
+ try:
59
+ yield connection
60
+ finally:
61
+ connection.close()
62
+
63
+
64
+ def _data_source_name(configuration: Configuration) -> str:
65
+ profile = configuration.active_profile
66
+ resolved = profile.resolve_dsn()
67
+ if resolved is None:
68
+ message = "MySQL profiles require a dsn setting"
69
+ raise ConfigurationValidationError(message)
70
+ return resolved.reveal()
71
+
72
+
73
+ __all__ = ("MySQLConnectionProvider",)