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,309 @@
1
+ """MySQL metadata reflection into the immutable schema model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, Protocol, TypeVar, cast
9
+
10
+ from pyoq.config import DatabaseDialect
11
+ from pyoq.errors import SchemaInspectionError
12
+ from pyoq.schema.models import (
13
+ Capability,
14
+ CapabilitySet,
15
+ Catalog,
16
+ CheckConstraint,
17
+ Column,
18
+ EnumType,
19
+ Identifier,
20
+ Index,
21
+ Key,
22
+ KeyKind,
23
+ ObjectReference,
24
+ ParameterMode,
25
+ Relation,
26
+ Routine,
27
+ RoutineKind,
28
+ RoutineParameter,
29
+ Schema,
30
+ SchemaSnapshot,
31
+ SqlExpression,
32
+ Table,
33
+ View,
34
+ )
35
+ from pyoq.schema.mysql import sql
36
+ from pyoq.schema.mysql.records import (
37
+ CheckRecord,
38
+ ColumnRecord,
39
+ ConstraintRecord,
40
+ IndexRecord,
41
+ ObjectRecord,
42
+ RoutineRecord,
43
+ read_check,
44
+ read_column,
45
+ read_constraint,
46
+ read_index,
47
+ read_object,
48
+ read_routine,
49
+ )
50
+ from pyoq.schema.mysql.types import mysql_type
51
+
52
+ if TYPE_CHECKING:
53
+ from pymysql.connections import Connection
54
+ from pymysql.cursors import Cursor
55
+
56
+
57
+ class NamedRecord(Protocol):
58
+ @property
59
+ def name(self) -> str: ...
60
+
61
+
62
+ Record = TypeVar("Record")
63
+ Named = TypeVar("Named", bound=NamedRecord)
64
+
65
+ _CAPABILITIES = CapabilitySet(
66
+ frozenset(
67
+ {
68
+ Capability.CHECK_CONSTRAINTS,
69
+ Capability.EXPRESSION_INDEXES,
70
+ Capability.GENERATED_COLUMNS,
71
+ Capability.IDENTITY_COLUMNS,
72
+ Capability.SCHEMAS,
73
+ Capability.VIEWS,
74
+ }
75
+ )
76
+ )
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class MySQLSchemaInspector:
81
+ def inspect(self, connection: Connection[Cursor]) -> SchemaSnapshot:
82
+ catalog = _read(connection)
83
+ return SchemaSnapshot(
84
+ DatabaseDialect.MYSQL,
85
+ (Catalog(None, (_schema(catalog),)),),
86
+ _CAPABILITIES,
87
+ )
88
+
89
+
90
+ @dataclass(frozen=True, slots=True, eq=False)
91
+ class _Catalog:
92
+ """Grouped catalog rows. An index, not a value, so it does not compare."""
93
+
94
+ database: str
95
+ objects: tuple[ObjectRecord, ...]
96
+ columns: dict[str, list[ColumnRecord]]
97
+ constraints: dict[str, list[ConstraintRecord]]
98
+ checks: dict[str, list[CheckRecord]]
99
+ indexes: dict[str, list[IndexRecord]]
100
+ routines: dict[str, list[RoutineRecord]]
101
+
102
+
103
+ def _read(connection: Connection[Cursor]) -> _Catalog:
104
+ try:
105
+ database = _database_name(connection)
106
+ objects = tuple(map(read_object, _rows(connection, sql.SCHEMA_OBJECTS)))
107
+ columns = tuple(map(read_column, _rows(connection, sql.COLUMNS)))
108
+ constraints = tuple(map(read_constraint, _rows(connection, sql.CONSTRAINTS)))
109
+ checks = tuple(map(read_check, _rows(connection, sql.CHECKS)))
110
+ indexes = tuple(map(read_index, _rows(connection, sql.INDEXES)))
111
+ routines = tuple(map(read_routine, _rows(connection, sql.ROUTINES)))
112
+ except SchemaInspectionError:
113
+ raise
114
+ except Exception as error:
115
+ message = f"unable to inspect MySQL schema: {error}"
116
+ raise SchemaInspectionError(message) from error
117
+ return _Catalog(
118
+ database,
119
+ objects,
120
+ _grouped(columns, lambda record: record.table),
121
+ _grouped(constraints, lambda record: record.table),
122
+ _grouped(checks, lambda record: record.table),
123
+ _grouped(indexes, lambda record: record.table),
124
+ _grouped(routines, lambda record: record.name),
125
+ )
126
+
127
+
128
+ def _database_name(connection: Connection[Cursor]) -> str:
129
+ rows = _rows(connection, sql.DATABASE_NAME)
130
+ name = rows[0][0] if rows and rows[0] else None
131
+ if not isinstance(name, str) or name == "":
132
+ message = "MySQL inspection requires a connection with a selected database"
133
+ raise SchemaInspectionError(message)
134
+ return name
135
+
136
+
137
+ def _rows(
138
+ connection: Connection[Cursor],
139
+ query: str,
140
+ ) -> tuple[tuple[object, ...], ...]:
141
+ with connection.cursor() as cursor:
142
+ cursor.execute(query)
143
+ return cast("tuple[tuple[object, ...], ...]", cursor.fetchall())
144
+
145
+
146
+ def _schema(catalog: _Catalog) -> Schema:
147
+ objects = catalog.objects
148
+ tables = tuple(_table(item, catalog) for item in objects if item.is_table)
149
+ views = tuple(_view(item, catalog) for item in objects if not item.is_table)
150
+ return Schema(
151
+ Identifier(catalog.database),
152
+ tables,
153
+ views,
154
+ _enums(catalog),
155
+ routines=_routines(catalog),
156
+ )
157
+
158
+
159
+ _PARAMETER_MODES = {
160
+ "IN": ParameterMode.IN,
161
+ "OUT": ParameterMode.OUT,
162
+ "INOUT": ParameterMode.INOUT,
163
+ }
164
+
165
+
166
+ def _routines(catalog: _Catalog) -> tuple[Routine, ...]:
167
+ """Every procedure and function this database keeps, with its parameters.
168
+
169
+ MySQL refuses a second routine of one name, so a name identifies a routine
170
+ on its own and nothing here separates overloads.
171
+ """
172
+ return tuple(
173
+ _routine(records) for _, records in sorted(catalog.routines.items()) if records
174
+ )
175
+
176
+
177
+ def _routine(records: list[RoutineRecord]) -> Routine:
178
+ first = records[0]
179
+ function = first.kind.upper() == "FUNCTION"
180
+ kind = RoutineKind.FUNCTION if function else RoutineKind.PROCEDURE
181
+ returns = mysql_type(first.returns) if function and first.returns else None
182
+ return Routine(
183
+ Identifier(first.name),
184
+ kind,
185
+ tuple(
186
+ _routine_parameter(record)
187
+ for record in records
188
+ if record.position is not None
189
+ ),
190
+ returns,
191
+ )
192
+
193
+
194
+ def _routine_parameter(record: RoutineRecord) -> RoutineParameter:
195
+ """MySQL states no nullability for a parameter, so one may hold nothing."""
196
+ return RoutineParameter(
197
+ None if record.parameter_name is None else Identifier(record.parameter_name),
198
+ mysql_type(record.parameter_type or "text"),
199
+ _PARAMETER_MODES.get((record.mode or "IN").upper(), ParameterMode.IN),
200
+ )
201
+
202
+
203
+ def _enums(catalog: _Catalog) -> tuple[EnumType, ...]:
204
+ """Every column-level enum, named for the column that carries it."""
205
+ enums = [
206
+ EnumType(record.enum_name, record.enum_values, record.comment)
207
+ for records in catalog.columns.values()
208
+ for record in records
209
+ if record.enum_values
210
+ ]
211
+ return tuple(sorted(enums, key=lambda enum: enum.name.value))
212
+
213
+
214
+ def _table(record: ObjectRecord, catalog: _Catalog) -> Table:
215
+ constraints = _by_name(catalog.constraints.get(record.name, []))
216
+ indexes = _by_name(catalog.indexes.get(record.name, []))
217
+ return Table(
218
+ Identifier(record.name),
219
+ _columns(catalog, record.name),
220
+ tuple(
221
+ _key(rows, kind)
222
+ for rows in constraints.values()
223
+ if (kind := rows[0].key_kind) is not None
224
+ ),
225
+ tuple(
226
+ _relation(rows, target)
227
+ for rows in constraints.values()
228
+ if rows[0].is_foreign_key
229
+ and (target := rows[0].target_reference) is not None
230
+ ),
231
+ tuple(_index(rows) for rows in indexes.values()),
232
+ tuple(_check(item) for item in catalog.checks.get(record.name, [])),
233
+ record.comment,
234
+ )
235
+
236
+
237
+ def _view(record: ObjectRecord, catalog: _Catalog) -> View:
238
+ return View(
239
+ Identifier(record.name),
240
+ _columns(catalog, record.name),
241
+ record.definition,
242
+ materialized=False,
243
+ )
244
+
245
+
246
+ def _columns(catalog: _Catalog, table: str) -> tuple[Column, ...]:
247
+ return tuple(_column(record) for record in catalog.columns.get(table, []))
248
+
249
+
250
+ def _column(record: ColumnRecord) -> Column:
251
+ return Column(
252
+ Identifier(record.name),
253
+ record.data_type,
254
+ record.nullable,
255
+ record.default,
256
+ record.generated,
257
+ record.comment,
258
+ )
259
+
260
+
261
+ def _key(rows: list[ConstraintRecord], kind: KeyKind) -> Key:
262
+ return Key(
263
+ kind,
264
+ tuple(Identifier(row.column) for row in rows),
265
+ Identifier(rows[0].name),
266
+ )
267
+
268
+
269
+ def _relation(rows: list[ConstraintRecord], target: ObjectReference) -> Relation:
270
+ return Relation(
271
+ tuple(Identifier(row.column) for row in rows),
272
+ target,
273
+ tuple(row.target_identifier for row in rows),
274
+ Identifier(rows[0].name),
275
+ rows[0].update_action,
276
+ rows[0].delete_action,
277
+ )
278
+
279
+
280
+ def _check(record: CheckRecord) -> CheckConstraint:
281
+ return CheckConstraint(SqlExpression(record.clause), Identifier(record.name))
282
+
283
+
284
+ def _index(rows: list[IndexRecord]) -> Index:
285
+ first = rows[0]
286
+ return Index(
287
+ Identifier(first.name),
288
+ tuple(row.term for row in rows),
289
+ first.unique,
290
+ None,
291
+ first.method,
292
+ )
293
+
294
+
295
+ def _by_name(records: list[Named]) -> dict[str, list[Named]]:
296
+ return _grouped(tuple(records), lambda record: record.name)
297
+
298
+
299
+ def _grouped(
300
+ records: tuple[Record, ...],
301
+ key: Callable[[Record], str],
302
+ ) -> dict[str, list[Record]]:
303
+ grouped: dict[str, list[Record]] = defaultdict(list)
304
+ for record in records:
305
+ grouped[key(record)].append(record)
306
+ return grouped
307
+
308
+
309
+ __all__ = ("MySQLSchemaInspector",)
@@ -0,0 +1,30 @@
1
+ """Configuration-backed MySQL schema source."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from pyoq.config import Configuration, DatabaseDialect
8
+ from pyoq.errors import OperationUnavailableError
9
+ from pyoq.schema.models import SchemaSnapshot
10
+ from pyoq.schema.mysql.connection import MySQLConnectionProvider
11
+ from pyoq.schema.mysql.reflection import MySQLSchemaInspector
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class MySQLSchemaSource:
16
+ connections: MySQLConnectionProvider = field(
17
+ default_factory=MySQLConnectionProvider
18
+ )
19
+ inspector: MySQLSchemaInspector = field(default_factory=MySQLSchemaInspector)
20
+
21
+ def load(self, configuration: Configuration) -> SchemaSnapshot:
22
+ dialect = configuration.active_profile.dialect
23
+ if dialect is not DatabaseDialect.MYSQL:
24
+ message = f"MySQL inspection is not available for {dialect.value}"
25
+ raise OperationUnavailableError(message)
26
+ with self.connections.open(configuration) as connection:
27
+ return self.inspector.inspect(connection)
28
+
29
+
30
+ __all__ = ("MySQLSchemaSource",)
@@ -0,0 +1,128 @@
1
+ """MySQL information schema queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ DATABASE_NAME = "SELECT DATABASE()"
6
+
7
+ SCHEMA_OBJECTS = """
8
+ SELECT
9
+ t.TABLE_NAME,
10
+ t.TABLE_TYPE,
11
+ t.TABLE_COMMENT,
12
+ v.VIEW_DEFINITION
13
+ FROM information_schema.TABLES AS t
14
+ LEFT JOIN information_schema.VIEWS AS v
15
+ ON v.TABLE_SCHEMA = t.TABLE_SCHEMA
16
+ AND v.TABLE_NAME = t.TABLE_NAME
17
+ WHERE t.TABLE_SCHEMA = DATABASE()
18
+ AND t.TABLE_TYPE IN ('BASE TABLE', 'VIEW')
19
+ ORDER BY t.TABLE_NAME
20
+ """
21
+
22
+ COLUMNS = """
23
+ SELECT
24
+ TABLE_NAME,
25
+ COLUMN_NAME,
26
+ COLUMN_TYPE,
27
+ IS_NULLABLE,
28
+ COLUMN_DEFAULT,
29
+ EXTRA,
30
+ GENERATION_EXPRESSION,
31
+ COLUMN_COMMENT
32
+ FROM information_schema.COLUMNS
33
+ WHERE TABLE_SCHEMA = DATABASE()
34
+ ORDER BY TABLE_NAME, ORDINAL_POSITION
35
+ """
36
+
37
+ CONSTRAINTS = """
38
+ SELECT
39
+ tc.TABLE_NAME,
40
+ tc.CONSTRAINT_NAME,
41
+ tc.CONSTRAINT_TYPE,
42
+ k.COLUMN_NAME,
43
+ k.REFERENCED_TABLE_SCHEMA,
44
+ k.REFERENCED_TABLE_NAME,
45
+ k.REFERENCED_COLUMN_NAME,
46
+ rc.UPDATE_RULE,
47
+ rc.DELETE_RULE
48
+ FROM information_schema.TABLE_CONSTRAINTS AS tc
49
+ JOIN information_schema.KEY_COLUMN_USAGE AS k
50
+ ON k.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA
51
+ AND k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
52
+ AND k.TABLE_NAME = tc.TABLE_NAME
53
+ LEFT JOIN information_schema.REFERENTIAL_CONSTRAINTS AS rc
54
+ ON rc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA
55
+ AND rc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
56
+ AND rc.TABLE_NAME = tc.TABLE_NAME
57
+ WHERE tc.TABLE_SCHEMA = DATABASE()
58
+ AND tc.CONSTRAINT_TYPE IN ('PRIMARY KEY', 'UNIQUE', 'FOREIGN KEY')
59
+ ORDER BY
60
+ tc.TABLE_NAME,
61
+ tc.CONSTRAINT_TYPE,
62
+ tc.CONSTRAINT_NAME,
63
+ k.ORDINAL_POSITION
64
+ """
65
+
66
+ CHECKS = """
67
+ SELECT
68
+ tc.TABLE_NAME,
69
+ cc.CONSTRAINT_NAME,
70
+ cc.CHECK_CLAUSE
71
+ FROM information_schema.CHECK_CONSTRAINTS AS cc
72
+ JOIN information_schema.TABLE_CONSTRAINTS AS tc
73
+ ON tc.CONSTRAINT_SCHEMA = cc.CONSTRAINT_SCHEMA
74
+ AND tc.CONSTRAINT_NAME = cc.CONSTRAINT_NAME
75
+ WHERE tc.TABLE_SCHEMA = DATABASE()
76
+ AND tc.CONSTRAINT_TYPE = 'CHECK'
77
+ ORDER BY tc.TABLE_NAME, cc.CONSTRAINT_NAME
78
+ """
79
+
80
+ INDEXES = """
81
+ SELECT
82
+ TABLE_NAME,
83
+ INDEX_NAME,
84
+ NON_UNIQUE,
85
+ COLUMN_NAME,
86
+ COLLATION,
87
+ EXPRESSION,
88
+ INDEX_TYPE
89
+ FROM information_schema.STATISTICS
90
+ WHERE TABLE_SCHEMA = DATABASE()
91
+ AND INDEX_NAME <> 'PRIMARY'
92
+ ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
93
+ """
94
+
95
+ __all__ = (
96
+ "CHECKS",
97
+ "COLUMNS",
98
+ "CONSTRAINTS",
99
+ "DATABASE_NAME",
100
+ "INDEXES",
101
+ "SCHEMA_OBJECTS",
102
+ )
103
+
104
+
105
+ ROUTINES = """
106
+ SELECT
107
+ r.SPECIFIC_NAME,
108
+ r.ROUTINE_TYPE,
109
+ r.DTD_IDENTIFIER,
110
+ p.ORDINAL_POSITION,
111
+ p.PARAMETER_NAME,
112
+ p.PARAMETER_MODE,
113
+ p.DTD_IDENTIFIER
114
+ FROM information_schema.ROUTINES AS r
115
+ LEFT JOIN information_schema.PARAMETERS AS p
116
+ ON p.SPECIFIC_SCHEMA = r.ROUTINE_SCHEMA
117
+ AND p.SPECIFIC_NAME = r.SPECIFIC_NAME
118
+ AND p.ROUTINE_TYPE = r.ROUTINE_TYPE
119
+ AND p.ORDINAL_POSITION > 0
120
+ WHERE r.ROUTINE_SCHEMA = DATABASE()
121
+ ORDER BY r.SPECIFIC_NAME, p.ORDINAL_POSITION
122
+ """
123
+ """One row per parameter, so a routine of no parameters is still one row.
124
+
125
+ A function's own result is ordinal zero in this catalog, and it is left out
126
+ here because the routine already carries it. MySQL refuses two routines of one
127
+ name, so a name is its own identity and no overload marker is needed.
128
+ """
@@ -0,0 +1,105 @@
1
+ """MySQL declared-type normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from pyoq.schema.models import SqlType, TypeKind
8
+
9
+ _TYPE_ARGUMENTS = re.compile(
10
+ r"^\s*([A-Za-z][A-Za-z ]*?)\s*(?:\(([^()]*)\))?"
11
+ r"((?:\s+(?:unsigned|signed|zerofill))*)\s*$",
12
+ re.IGNORECASE,
13
+ )
14
+ _MODIFIERS = frozenset({"UNSIGNED", "SIGNED", "ZEROFILL"})
15
+
16
+ _EXACT_KINDS = {
17
+ "BOOL": TypeKind.BOOLEAN,
18
+ "BOOLEAN": TypeKind.BOOLEAN,
19
+ "TINYINT": TypeKind.SMALL_INTEGER,
20
+ "SMALLINT": TypeKind.SMALL_INTEGER,
21
+ "MEDIUMINT": TypeKind.INTEGER,
22
+ "INT": TypeKind.INTEGER,
23
+ "INTEGER": TypeKind.INTEGER,
24
+ "BIGINT": TypeKind.BIG_INTEGER,
25
+ "DECIMAL": TypeKind.DECIMAL,
26
+ "NUMERIC": TypeKind.DECIMAL,
27
+ "FLOAT": TypeKind.REAL,
28
+ "DOUBLE": TypeKind.DOUBLE,
29
+ "DOUBLE PRECISION": TypeKind.DOUBLE,
30
+ "REAL": TypeKind.DOUBLE,
31
+ "CHAR": TypeKind.STRING,
32
+ "VARCHAR": TypeKind.STRING,
33
+ "TINYTEXT": TypeKind.STRING,
34
+ "TEXT": TypeKind.STRING,
35
+ "MEDIUMTEXT": TypeKind.STRING,
36
+ "LONGTEXT": TypeKind.STRING,
37
+ "BIT": TypeKind.BINARY,
38
+ "BINARY": TypeKind.BINARY,
39
+ "VARBINARY": TypeKind.BINARY,
40
+ "TINYBLOB": TypeKind.BINARY,
41
+ "BLOB": TypeKind.BINARY,
42
+ "MEDIUMBLOB": TypeKind.BINARY,
43
+ "LONGBLOB": TypeKind.BINARY,
44
+ "DATE": TypeKind.DATE,
45
+ "TIME": TypeKind.TIME,
46
+ "DATETIME": TypeKind.DATETIME,
47
+ "TIMESTAMP": TypeKind.DATETIME,
48
+ "YEAR": TypeKind.SMALL_INTEGER,
49
+ "JSON": TypeKind.JSON,
50
+ "SET": TypeKind.STRING,
51
+ }
52
+
53
+ _ENUM_VALUES = re.compile(r"(?i)^enum\((.*)\)$", re.S)
54
+ _ENUM_LABEL = re.compile(r"'((?:[^']|'')*)'")
55
+
56
+ _SIZED_KINDS = frozenset({TypeKind.STRING, TypeKind.BINARY})
57
+ _PRECISION_KINDS = frozenset({TypeKind.DECIMAL})
58
+
59
+
60
+ def mysql_enum_values(declared_type: str) -> tuple[str, ...]:
61
+ """The labels a column-level enum lists, in the order it lists them.
62
+
63
+ MySQL keeps an enum on the column rather than as a type of its own, and
64
+ reports it as the whole declaration. The labels are its own order, which
65
+ is what it sorts and compares by.
66
+ """
67
+ match = _ENUM_VALUES.fullmatch(declared_type.strip())
68
+ if match is None:
69
+ return ()
70
+ return tuple(
71
+ label.replace("''", "'") for label in _ENUM_LABEL.findall(match.group(1))
72
+ )
73
+
74
+
75
+ def mysql_type(declared_type: str) -> SqlType:
76
+ source_name = declared_type.strip() or "text"
77
+ match = _TYPE_ARGUMENTS.fullmatch(source_name)
78
+ if match is None:
79
+ return SqlType(TypeKind.OTHER, source_name)
80
+ base_name, argument_text, trailing = match.groups()
81
+ kind = _EXACT_KINDS.get(_base_name(base_name, trailing), TypeKind.OTHER)
82
+ arguments = _numeric_arguments(argument_text)
83
+ if kind in _SIZED_KINDS and len(arguments) == 1:
84
+ return SqlType(kind, source_name, length=arguments[0])
85
+ if kind in _PRECISION_KINDS and arguments:
86
+ scale = arguments[1] if len(arguments) == 2 else None
87
+ return SqlType(kind, source_name, precision=arguments[0], scale=scale)
88
+ return SqlType(kind, source_name)
89
+
90
+
91
+ def _base_name(base: str, trailing: str) -> str:
92
+ words = f"{base} {trailing}".upper().split()
93
+ return " ".join(word for word in words if word not in _MODIFIERS)
94
+
95
+
96
+ def _numeric_arguments(value: str | None) -> tuple[int, ...]:
97
+ if value is None:
98
+ return ()
99
+ parts = tuple(part.strip() for part in value.split(","))
100
+ if not 1 <= len(parts) <= 2 or any(not part.isdecimal() for part in parts):
101
+ return ()
102
+ return tuple(int(part) for part in parts)
103
+
104
+
105
+ __all__ = ("mysql_type",)
@@ -0,0 +1,13 @@
1
+ """PostgreSQL schema support."""
2
+
3
+ from pyoq.schema.postgres.connection import PostgresConnectionProvider
4
+ from pyoq.schema.postgres.reflection import PostgresSchemaInspector
5
+ from pyoq.schema.postgres.source import PostgresSchemaSource
6
+ from pyoq.schema.postgres.types import postgres_type
7
+
8
+ __all__ = (
9
+ "PostgresConnectionProvider",
10
+ "PostgresSchemaInspector",
11
+ "PostgresSchemaSource",
12
+ "postgres_type",
13
+ )
@@ -0,0 +1,63 @@
1
+ """Read-only PostgreSQL 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
+
17
+ if TYPE_CHECKING:
18
+ from psycopg import Connection
19
+
20
+ _MISSING_DRIVER = (
21
+ "PostgreSQL support requires the psycopg driver; install pyoq-sql[postgres]"
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class PostgresConnectionProvider:
27
+ def open(
28
+ self,
29
+ configuration: Configuration,
30
+ ) -> AbstractContextManager[Connection[tuple[object, ...]]]:
31
+ return self._connect(_data_source_name(configuration))
32
+
33
+ @contextmanager
34
+ def _connect(
35
+ self,
36
+ dsn: str,
37
+ ) -> Generator[Connection[tuple[object, ...]]]:
38
+ try:
39
+ import psycopg
40
+ except ImportError as error:
41
+ raise OperationUnavailableError(_MISSING_DRIVER) from error
42
+ try:
43
+ connection = psycopg.connect(dsn, autocommit=True)
44
+ except psycopg.Error as error:
45
+ message = f"unable to open PostgreSQL connection: {error}"
46
+ raise SchemaInspectionError(message) from error
47
+ try:
48
+ connection.execute("SET default_transaction_read_only = on")
49
+ yield connection
50
+ finally:
51
+ connection.close()
52
+
53
+
54
+ def _data_source_name(configuration: Configuration) -> str:
55
+ profile = configuration.active_profile
56
+ resolved = profile.resolve_dsn()
57
+ if resolved is None:
58
+ message = "PostgreSQL profiles require a dsn setting"
59
+ raise ConfigurationValidationError(message)
60
+ return resolved.reveal()
61
+
62
+
63
+ __all__ = ("PostgresConnectionProvider",)