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,393 @@
1
+ """SQLite metadata reflection into the immutable schema model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass
8
+
9
+ from pyoq.config import DatabaseDialect
10
+ from pyoq.errors import SchemaInspectionError
11
+ from pyoq.schema import (
12
+ Capability,
13
+ CapabilitySet,
14
+ Catalog,
15
+ CheckConstraint,
16
+ Column,
17
+ GeneratedKind,
18
+ GeneratedValue,
19
+ Identifier,
20
+ Index,
21
+ IndexTerm,
22
+ Key,
23
+ KeyKind,
24
+ MetadataEntry,
25
+ ObjectReference,
26
+ ReferentialAction,
27
+ Relation,
28
+ Schema,
29
+ SchemaSnapshot,
30
+ SqlExpression,
31
+ Table,
32
+ View,
33
+ )
34
+ from pyoq.schema.sqlite.records import (
35
+ ColumnRecord,
36
+ ForeignKeyRecord,
37
+ IndexRecord,
38
+ IndexTermRecord,
39
+ SchemaObjectRecord,
40
+ column,
41
+ foreign_key,
42
+ index,
43
+ index_term,
44
+ schema_object,
45
+ )
46
+ from pyoq.schema.sqlite.sql import (
47
+ ParsedIndexTerm,
48
+ check_constraints,
49
+ descending_primary_columns,
50
+ generated_columns,
51
+ index_parts,
52
+ table_options,
53
+ view_query,
54
+ )
55
+ from pyoq.schema.sqlite.types import sqlite_type
56
+
57
+ _CAPABILITIES = CapabilitySet(
58
+ frozenset(
59
+ {
60
+ Capability.CHECK_CONSTRAINTS,
61
+ Capability.EXPRESSION_INDEXES,
62
+ Capability.GENERATED_COLUMNS,
63
+ Capability.PARTIAL_INDEXES,
64
+ Capability.VIEWS,
65
+ }
66
+ )
67
+ )
68
+ _SCHEMA_OBJECTS = """
69
+ SELECT type, name, sql
70
+ FROM sqlite_schema
71
+ WHERE type IN ('index', 'table', 'view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'
72
+ ORDER BY type, name
73
+ """
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class SQLiteSchemaInspector:
78
+ def inspect(self, connection: sqlite3.Connection) -> SchemaSnapshot:
79
+ try:
80
+ objects = tuple(
81
+ schema_object(row) for row in connection.execute(_SCHEMA_OBJECTS)
82
+ )
83
+ object_map = {record.name: record for record in objects}
84
+ table_records = tuple(
85
+ record for record in objects if record.kind == "table"
86
+ )
87
+ tables = tuple(
88
+ self._table(connection, record, object_map) for record in table_records
89
+ )
90
+ views = tuple(
91
+ self._view(connection, record)
92
+ for record in objects
93
+ if record.kind == "view"
94
+ )
95
+ except sqlite3.Error as error:
96
+ message = f"unable to inspect SQLite schema: {error}"
97
+ raise SchemaInspectionError(message) from error
98
+ return SchemaSnapshot(
99
+ DatabaseDialect.SQLITE,
100
+ (Catalog(None, (Schema(None, tables, views),)),),
101
+ _CAPABILITIES,
102
+ )
103
+
104
+ def _table(
105
+ self,
106
+ connection: sqlite3.Connection,
107
+ record: SchemaObjectRecord,
108
+ objects: dict[str, SchemaObjectRecord],
109
+ ) -> Table:
110
+ column_records = _columns(connection, record.name)
111
+ columns = _model_columns(column_records, record.sql)
112
+ index_records = _indexes(connection, record.name)
113
+ keys = _keys(connection, column_records, index_records)
114
+ relations = _relations(connection, record.name, objects)
115
+ indexes = _model_indexes(connection, index_records, objects)
116
+ return Table(
117
+ Identifier(record.name),
118
+ columns,
119
+ keys=keys,
120
+ relations=relations,
121
+ indexes=indexes,
122
+ checks=_model_checks(record.sql),
123
+ metadata=_table_metadata(record.sql),
124
+ )
125
+
126
+ def _view(
127
+ self,
128
+ connection: sqlite3.Connection,
129
+ record: SchemaObjectRecord,
130
+ ) -> View:
131
+ columns = _model_columns(_columns(connection, record.name), None)
132
+ query = view_query(record.sql)
133
+ return View(
134
+ Identifier(record.name),
135
+ columns,
136
+ None if query is None else SqlExpression(query),
137
+ )
138
+
139
+
140
+ def _columns(connection: sqlite3.Connection, name: str) -> tuple[ColumnRecord, ...]:
141
+ rows = connection.execute(
142
+ 'SELECT cid, name, type, "notnull", dflt_value, pk, hidden '
143
+ "FROM pragma_table_xinfo(?) ORDER BY cid",
144
+ (name,),
145
+ )
146
+ return tuple(record for row in rows if (record := column(row)).hidden != 1)
147
+
148
+
149
+ def _indexes(connection: sqlite3.Connection, name: str) -> tuple[IndexRecord, ...]:
150
+ rows = connection.execute(
151
+ 'SELECT seq, name, "unique", origin, partial '
152
+ "FROM pragma_index_list(?) ORDER BY seq",
153
+ (name,),
154
+ )
155
+ return tuple(index(row) for row in rows)
156
+
157
+
158
+ def _index_terms(
159
+ connection: sqlite3.Connection,
160
+ name: str,
161
+ ) -> tuple[IndexTermRecord, ...]:
162
+ rows = connection.execute(
163
+ "SELECT seqno, cid, name, desc, coll, key "
164
+ "FROM pragma_index_xinfo(?) ORDER BY seqno",
165
+ (name,),
166
+ )
167
+ return tuple(term for row in rows if (term := index_term(row)).key_term)
168
+
169
+
170
+ def _model_columns(
171
+ records: tuple[ColumnRecord, ...],
172
+ create_sql: str | None,
173
+ ) -> tuple[Column, ...]:
174
+ generated = generated_columns(create_sql)
175
+ descending_primary = descending_primary_columns(create_sql)
176
+ single_primary = sum(record.primary_ordinal > 0 for record in records) == 1
177
+ without_rowid, _ = table_options(create_sql)
178
+ return tuple(
179
+ _model_column(
180
+ record,
181
+ generated.get(record.name),
182
+ single_primary,
183
+ without_rowid,
184
+ record.name in descending_primary,
185
+ )
186
+ for record in records
187
+ )
188
+
189
+
190
+ def _model_column(
191
+ record: ColumnRecord,
192
+ generated: tuple[str, bool] | None,
193
+ single_primary: bool,
194
+ without_rowid: bool,
195
+ descending_primary: bool,
196
+ ) -> Column:
197
+ identity = (
198
+ record.primary_ordinal > 0
199
+ and single_primary
200
+ and not without_rowid
201
+ and not descending_primary
202
+ and record.declared_type.strip().upper() == "INTEGER"
203
+ )
204
+ generated_value = _generated_value(generated, identity)
205
+ return Column(
206
+ Identifier(record.name),
207
+ sqlite_type(record.declared_type),
208
+ nullable=not record.not_null and not identity,
209
+ default=None if record.default is None else SqlExpression(record.default),
210
+ generated=generated_value,
211
+ )
212
+
213
+
214
+ def _generated_value(
215
+ generated: tuple[str, bool] | None,
216
+ identity: bool,
217
+ ) -> GeneratedValue | None:
218
+ if generated is not None:
219
+ expression, stored = generated
220
+ kind = (
221
+ GeneratedKind.COMPUTED_STORED if stored else GeneratedKind.COMPUTED_VIRTUAL
222
+ )
223
+ return GeneratedValue(kind, SqlExpression(expression))
224
+ if identity:
225
+ return GeneratedValue(GeneratedKind.IDENTITY_BY_DEFAULT)
226
+ return None
227
+
228
+
229
+ def _keys(
230
+ connection: sqlite3.Connection,
231
+ columns: tuple[ColumnRecord, ...],
232
+ indexes: tuple[IndexRecord, ...],
233
+ ) -> tuple[Key, ...]:
234
+ primary_columns = tuple(
235
+ Identifier(record.name)
236
+ for record in sorted(columns, key=lambda candidate: candidate.primary_ordinal)
237
+ if record.primary_ordinal > 0
238
+ )
239
+ keys: list[Key] = []
240
+ if primary_columns:
241
+ keys.append(Key(KeyKind.PRIMARY, primary_columns))
242
+ for record in indexes:
243
+ if record.origin != "u":
244
+ continue
245
+ terms = _index_terms(connection, record.name)
246
+ names = tuple(term.column_name for term in terms)
247
+ if all(name is not None for name in names):
248
+ keys.append(
249
+ Key(
250
+ KeyKind.UNIQUE,
251
+ tuple(Identifier(name) for name in names if name is not None),
252
+ )
253
+ )
254
+ return tuple(keys)
255
+
256
+
257
+ def _relations(
258
+ connection: sqlite3.Connection,
259
+ table_name: str,
260
+ objects: dict[str, SchemaObjectRecord],
261
+ ) -> tuple[Relation, ...]:
262
+ rows = connection.execute(
263
+ 'SELECT id, seq, "table", "from", "to", on_update, on_delete, match '
264
+ "FROM pragma_foreign_key_list(?) ORDER BY id, seq",
265
+ (table_name,),
266
+ )
267
+ grouped: dict[int, list[ForeignKeyRecord]] = defaultdict(list)
268
+ for row in rows:
269
+ record = foreign_key(row)
270
+ grouped[record.relation_id].append(record)
271
+ return tuple(
272
+ _relation(connection, tuple(grouped[relation_id]), objects)
273
+ for relation_id in sorted(grouped)
274
+ )
275
+
276
+
277
+ def _relation(
278
+ connection: sqlite3.Connection,
279
+ records: tuple[ForeignKeyRecord, ...],
280
+ objects: dict[str, SchemaObjectRecord],
281
+ ) -> Relation:
282
+ first = records[0]
283
+ target_columns = tuple(record.target_column for record in records)
284
+ if any(name is None for name in target_columns):
285
+ target_columns = _target_primary_columns(
286
+ connection, first.target_table, objects
287
+ )
288
+ return Relation(
289
+ tuple(Identifier(record.source_column) for record in records),
290
+ ObjectReference(Identifier(first.target_table)),
291
+ tuple(Identifier(name) for name in target_columns if name is not None),
292
+ on_update=_referential_action(first.on_update),
293
+ on_delete=_referential_action(first.on_delete),
294
+ )
295
+
296
+
297
+ def _target_primary_columns(
298
+ connection: sqlite3.Connection,
299
+ table_name: str,
300
+ objects: dict[str, SchemaObjectRecord],
301
+ ) -> tuple[str, ...]:
302
+ if table_name not in objects:
303
+ message = f"foreign key references unavailable table: {table_name}"
304
+ raise SchemaInspectionError(message)
305
+ records = sorted(
306
+ _columns(connection, table_name), key=lambda item: item.primary_ordinal
307
+ )
308
+ names = tuple(record.name for record in records if record.primary_ordinal > 0)
309
+ if not names:
310
+ message = f"foreign key target has no primary key: {table_name}"
311
+ raise SchemaInspectionError(message)
312
+ return names
313
+
314
+
315
+ def _model_indexes(
316
+ connection: sqlite3.Connection,
317
+ records: tuple[IndexRecord, ...],
318
+ objects: dict[str, SchemaObjectRecord],
319
+ ) -> tuple[Index, ...]:
320
+ indexes: list[Index] = []
321
+ for record in records:
322
+ if record.origin != "c":
323
+ continue
324
+ schema_record = objects.get(record.name)
325
+ indexes.append(_model_index(connection, record, schema_record))
326
+ return tuple(indexes)
327
+
328
+
329
+ def _model_index(
330
+ connection: sqlite3.Connection,
331
+ record: IndexRecord,
332
+ schema_record: SchemaObjectRecord | None,
333
+ ) -> Index:
334
+ metadata_terms = _index_terms(connection, record.name)
335
+ sql = None if schema_record is None else schema_record.sql
336
+ parsed_terms, predicate = ((), None) if sql is None else index_parts(sql)
337
+ terms = tuple(
338
+ _model_index_term(
339
+ term, parsed_terms[index] if index < len(parsed_terms) else None
340
+ )
341
+ for index, term in enumerate(metadata_terms)
342
+ )
343
+ return Index(
344
+ Identifier(record.name),
345
+ terms,
346
+ unique=record.unique,
347
+ predicate=None if predicate is None else SqlExpression(predicate),
348
+ )
349
+
350
+
351
+ def _model_index_term(
352
+ record: IndexTermRecord,
353
+ parsed: ParsedIndexTerm | None,
354
+ ) -> IndexTerm:
355
+ if record.column_name is not None:
356
+ return IndexTerm(Identifier(record.column_name), descending=record.descending)
357
+ if parsed is None:
358
+ message = "unable to recover SQLite expression index term"
359
+ raise SchemaInspectionError(message)
360
+ return IndexTerm(
361
+ expression=SqlExpression(parsed.expression),
362
+ descending=parsed.descending,
363
+ )
364
+
365
+
366
+ def _referential_action(value: str) -> ReferentialAction:
367
+ normalized = value.lower().replace(" ", "-")
368
+ try:
369
+ return ReferentialAction(normalized)
370
+ except ValueError as error:
371
+ message = f"unsupported SQLite referential action: {value}"
372
+ raise SchemaInspectionError(message) from error
373
+
374
+
375
+ def _model_checks(create_sql: str | None) -> tuple[CheckConstraint, ...]:
376
+ return tuple(
377
+ CheckConstraint(
378
+ SqlExpression(check.expression),
379
+ None if check.name is None else Identifier(check.name),
380
+ )
381
+ for check in check_constraints(create_sql)
382
+ )
383
+
384
+
385
+ def _table_metadata(create_sql: str | None) -> tuple[MetadataEntry, ...]:
386
+ without_rowid, strict = table_options(create_sql)
387
+ return (
388
+ MetadataEntry("strict", str(strict).lower()),
389
+ MetadataEntry("without-rowid", str(without_rowid).lower()),
390
+ )
391
+
392
+
393
+ __all__ = ("SQLiteSchemaInspector",)
@@ -0,0 +1,30 @@
1
+ """Configuration-backed SQLite 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 import SchemaSnapshot
10
+ from pyoq.schema.sqlite.connection import SQLiteConnectionProvider
11
+ from pyoq.schema.sqlite.reflection import SQLiteSchemaInspector
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class SQLiteSchemaSource:
16
+ connections: SQLiteConnectionProvider = field(
17
+ default_factory=SQLiteConnectionProvider
18
+ )
19
+ inspector: SQLiteSchemaInspector = field(default_factory=SQLiteSchemaInspector)
20
+
21
+ def load(self, configuration: Configuration) -> SchemaSnapshot:
22
+ dialect = configuration.active_profile.dialect
23
+ if dialect is not DatabaseDialect.SQLITE:
24
+ message = f"schema 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__ = ("SQLiteSchemaSource",)
@@ -0,0 +1,254 @@
1
+ """Small SQLite DDL extraction helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from pyoq.errors import SchemaInspectionError
9
+
10
+ _IDENTIFIER = re.compile(
11
+ r"\s*(?:\"((?:\"\"|[^\"])*)\"|`((?:``|[^`])*)`|\[([^]]*)\]|([^\s,()]+))"
12
+ )
13
+ _GENERATED = re.compile(r"\b(?:GENERATED\s+ALWAYS\s+)?AS\s*\(", re.IGNORECASE)
14
+ _CHECK = re.compile(r"\bCHECK\s*\(", re.IGNORECASE)
15
+ _CONSTRAINT_NAME = re.compile(
16
+ r"\bCONSTRAINT\s+(?:\"((?:\"\"|[^\"])*)\"|`((?:``|[^`])*)`|"
17
+ r"\[([^]]*)\]|([^\s,()]+))\s*$",
18
+ re.IGNORECASE,
19
+ )
20
+ _ORDERING = re.compile(r"\s+(ASC|DESC)\s*$", re.IGNORECASE)
21
+ _COLLATION = re.compile(r"\s+COLLATE\s+\S+\s*$", re.IGNORECASE)
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class ParsedIndexTerm:
26
+ expression: str
27
+ descending: bool
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ParsedCheck:
32
+ name: str | None
33
+ expression: str
34
+
35
+
36
+ def check_constraints(create_sql: str | None) -> tuple[ParsedCheck, ...]:
37
+ if create_sql is None:
38
+ return ()
39
+ body, _ = _first_parenthesized(create_sql)
40
+ checks: list[ParsedCheck] = []
41
+ for definition in split_top_level(body):
42
+ for match in _CHECK.finditer(definition):
43
+ if not _is_top_level(definition, match.start()):
44
+ continue
45
+ expression, _ = _balanced_from(definition, match.end() - 1)
46
+ checks.append(
47
+ ParsedCheck(
48
+ _constraint_name(definition[: match.start()]), expression.strip()
49
+ )
50
+ )
51
+ return tuple(checks)
52
+
53
+
54
+ def generated_columns(create_sql: str | None) -> dict[str, tuple[str, bool]]:
55
+ if create_sql is None:
56
+ return {}
57
+ body, _ = _first_parenthesized(create_sql)
58
+ generated: dict[str, tuple[str, bool]] = {}
59
+ for definition in split_top_level(body):
60
+ name, remainder = _column_definition(definition)
61
+ if name is None:
62
+ continue
63
+ match = _GENERATED.search(remainder)
64
+ if match is None or not _is_top_level(remainder, match.start()):
65
+ continue
66
+ expression, closing = _balanced_from(remainder, match.end() - 1)
67
+ storage = remainder[closing + 1 :].upper()
68
+ generated[name] = (expression.strip(), "STORED" in storage)
69
+ return generated
70
+
71
+
72
+ def view_query(create_sql: str | None) -> str | None:
73
+ if create_sql is None:
74
+ return None
75
+ position = _top_level_keyword(create_sql, "AS")
76
+ if position is None:
77
+ return None
78
+ query = create_sql[position + 2 :].strip()
79
+ return query or None
80
+
81
+
82
+ def index_parts(create_sql: str) -> tuple[tuple[ParsedIndexTerm, ...], str | None]:
83
+ body, closing = _first_parenthesized(create_sql)
84
+ terms = tuple(_index_term(term) for term in split_top_level(body))
85
+ suffix = create_sql[closing + 1 :]
86
+ where_position = _top_level_keyword(suffix, "WHERE")
87
+ predicate = None
88
+ if where_position is not None:
89
+ candidate = suffix[where_position + 5 :].strip()
90
+ predicate = candidate or None
91
+ return terms, predicate
92
+
93
+
94
+ def table_options(create_sql: str | None) -> tuple[bool, bool]:
95
+ if create_sql is None:
96
+ return False, False
97
+ _, closing = _first_parenthesized(create_sql)
98
+ suffix = create_sql[closing + 1 :]
99
+ without_rowid = re.search(r"\bWITHOUT\s+ROWID\b", suffix, re.IGNORECASE)
100
+ strict = re.search(r"\bSTRICT\b", suffix, re.IGNORECASE)
101
+ return without_rowid is not None, strict is not None
102
+
103
+
104
+ def descending_primary_columns(create_sql: str | None) -> frozenset[str]:
105
+ if create_sql is None:
106
+ return frozenset()
107
+ body, _ = _first_parenthesized(create_sql)
108
+ names: set[str] = set()
109
+ for definition in split_top_level(body):
110
+ name, remainder = _column_definition(definition)
111
+ if name is not None and re.search(
112
+ r"\bPRIMARY\s+KEY\s+DESC\b", remainder, re.IGNORECASE
113
+ ):
114
+ names.add(name)
115
+ return frozenset(names)
116
+
117
+
118
+ def split_top_level(value: str) -> tuple[str, ...]:
119
+ parts: list[str] = []
120
+ start = 0
121
+ depth = 0
122
+ quote: str | None = None
123
+ index = 0
124
+ while index < len(value):
125
+ character = value[index]
126
+ if quote is not None:
127
+ index, quote = _advance_quote(value, index, quote)
128
+ continue
129
+ if character in {'"', "'", "`", "["}:
130
+ quote = "]" if character == "[" else character
131
+ elif character == "(":
132
+ depth += 1
133
+ elif character == ")":
134
+ depth -= 1
135
+ elif character == "," and depth == 0:
136
+ parts.append(value[start:index].strip())
137
+ start = index + 1
138
+ index += 1
139
+ parts.append(value[start:].strip())
140
+ return tuple(part for part in parts if part)
141
+
142
+
143
+ def _column_definition(definition: str) -> tuple[str | None, str]:
144
+ match = _IDENTIFIER.match(definition)
145
+ if match is None:
146
+ return None, definition
147
+ raw_name = next(group for group in match.groups() if group is not None)
148
+ name = raw_name.replace('""', '"').replace("``", "`")
149
+ if name.upper() in {"CHECK", "CONSTRAINT", "FOREIGN", "PRIMARY", "UNIQUE"}:
150
+ return None, definition
151
+ return name, definition[match.end() :]
152
+
153
+
154
+ def _index_term(value: str) -> ParsedIndexTerm:
155
+ order = _ORDERING.search(value)
156
+ descending = order is not None and order.group(1).upper() == "DESC"
157
+ expression = value if order is None else value[: order.start()]
158
+ expression = _COLLATION.sub("", expression).strip()
159
+ return ParsedIndexTerm(expression, descending)
160
+
161
+
162
+ def _constraint_name(prefix: str) -> str | None:
163
+ match = _CONSTRAINT_NAME.search(prefix)
164
+ if match is None:
165
+ return None
166
+ raw_name = next(group for group in match.groups() if group is not None)
167
+ return raw_name.replace('""', '"').replace("``", "`")
168
+
169
+
170
+ def _first_parenthesized(value: str) -> tuple[str, int]:
171
+ quote: str | None = None
172
+ index = 0
173
+ while index < len(value):
174
+ character = value[index]
175
+ if quote is not None:
176
+ index, quote = _advance_quote(value, index, quote)
177
+ continue
178
+ if character in {'"', "'", "`", "["}:
179
+ quote = "]" if character == "[" else character
180
+ elif character == "(":
181
+ return _balanced_from(value, index)
182
+ index += 1
183
+ message = "SQLite DDL is missing a parenthesized definition"
184
+ raise SchemaInspectionError(message)
185
+
186
+
187
+ def _balanced_from(value: str, opening: int) -> tuple[str, int]:
188
+ depth = 0
189
+ quote: str | None = None
190
+ index = opening
191
+ while index < len(value):
192
+ character = value[index]
193
+ if quote is not None:
194
+ index, quote = _advance_quote(value, index, quote)
195
+ continue
196
+ if character in {'"', "'", "`", "["}:
197
+ quote = "]" if character == "[" else character
198
+ elif character == "(":
199
+ depth += 1
200
+ elif character == ")":
201
+ depth -= 1
202
+ if depth == 0:
203
+ return value[opening + 1 : index], index
204
+ index += 1
205
+ message = "SQLite DDL contains an unclosed parenthesis"
206
+ raise SchemaInspectionError(message)
207
+
208
+
209
+ def _advance_quote(value: str, index: int, quote: str) -> tuple[int, str | None]:
210
+ if value[index] != quote:
211
+ return index + 1, quote
212
+ if quote != "]" and index + 1 < len(value) and value[index + 1] == quote:
213
+ return index + 2, quote
214
+ return index + 1, None
215
+
216
+
217
+ def _top_level_keyword(value: str, keyword: str) -> int | None:
218
+ pattern = re.compile(rf"\b{keyword}\b", re.IGNORECASE)
219
+ for match in pattern.finditer(value):
220
+ if _is_top_level(value, match.start()):
221
+ return match.start()
222
+ return None
223
+
224
+
225
+ def _is_top_level(value: str, stop: int) -> bool:
226
+ depth = 0
227
+ quote: str | None = None
228
+ index = 0
229
+ while index < stop:
230
+ character = value[index]
231
+ if quote is not None:
232
+ index, quote = _advance_quote(value, index, quote)
233
+ continue
234
+ if character in {'"', "'", "`", "["}:
235
+ quote = "]" if character == "[" else character
236
+ elif character == "(":
237
+ depth += 1
238
+ elif character == ")":
239
+ depth -= 1
240
+ index += 1
241
+ return depth == 0 and quote is None
242
+
243
+
244
+ __all__ = (
245
+ "ParsedCheck",
246
+ "ParsedIndexTerm",
247
+ "check_constraints",
248
+ "descending_primary_columns",
249
+ "generated_columns",
250
+ "index_parts",
251
+ "split_top_level",
252
+ "table_options",
253
+ "view_query",
254
+ )