snake-orm 0.1.0b1__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 (198) hide show
  1. snake_orm-0.1.0b1.dist-info/METADATA +366 -0
  2. snake_orm-0.1.0b1.dist-info/RECORD +198 -0
  3. snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
  4. snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
  5. snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
  6. snakeorm/__init__.py +214 -0
  7. snakeorm/advisor.py +109 -0
  8. snakeorm/cli/__init__.py +3 -0
  9. snakeorm/cli/app.py +992 -0
  10. snakeorm/cli/discovery.py +179 -0
  11. snakeorm/cli/hooks.py +86 -0
  12. snakeorm/compiler/__init__.py +3 -0
  13. snakeorm/compiler/compiler.py +330 -0
  14. snakeorm/connection.py +228 -0
  15. snakeorm/contrib/__init__.py +17 -0
  16. snakeorm/contrib/asgi.py +192 -0
  17. snakeorm/contrib/config.py +264 -0
  18. snakeorm/contrib/deliver.py +256 -0
  19. snakeorm/contrib/django.py +245 -0
  20. snakeorm/contrib/sidecar.py +44 -0
  21. snakeorm/contrib/wsgi.py +176 -0
  22. snakeorm/core/__init__.py +1 -0
  23. snakeorm/core/config.py +173 -0
  24. snakeorm/core/converters.py +172 -0
  25. snakeorm/core/exceptions.py +175 -0
  26. snakeorm/core/placement.py +15 -0
  27. snakeorm/core/sentinels.py +37 -0
  28. snakeorm/core/signals.py +114 -0
  29. snakeorm/debug/__init__.py +42 -0
  30. snakeorm/debug/assets/css/panel.css +616 -0
  31. snakeorm/debug/assets/css/snake_orm_app.css +293 -0
  32. snakeorm/debug/assets/img/icon.svg +5 -0
  33. snakeorm/debug/assets/js/history.js +425 -0
  34. snakeorm/debug/assets/js/language.js +283 -0
  35. snakeorm/debug/assets/js/panel.js +241 -0
  36. snakeorm/debug/assets/js/snake_orm_app.js +82 -0
  37. snakeorm/debug/assets/pages/cli.html +28 -0
  38. snakeorm/debug/assets/pages/config.html +34 -0
  39. snakeorm/debug/assets/pages/database_first.html +29 -0
  40. snakeorm/debug/assets/pages/help.html +21 -0
  41. snakeorm/debug/assets/pages/history.html +27 -0
  42. snakeorm/debug/capture.py +234 -0
  43. snakeorm/debug/channel.py +131 -0
  44. snakeorm/debug/collector.py +144 -0
  45. snakeorm/debug/config.py +149 -0
  46. snakeorm/debug/html.py +578 -0
  47. snakeorm/debug/matrix.py +60 -0
  48. snakeorm/debug/origin.py +42 -0
  49. snakeorm/debug/otel/__init__.py +68 -0
  50. snakeorm/debug/otel/context.py +43 -0
  51. snakeorm/debug/otel/exporter.py +277 -0
  52. snakeorm/debug/otel/payload.py +97 -0
  53. snakeorm/debug/otel/report.py +29 -0
  54. snakeorm/debug/otel/spans.py +393 -0
  55. snakeorm/debug/otel/summary.py +59 -0
  56. snakeorm/debug/record.py +54 -0
  57. snakeorm/debug/report.py +311 -0
  58. snakeorm/debug/testing.py +23 -0
  59. snakeorm/decorators/__init__.py +13 -0
  60. snakeorm/decorators/abstract.py +26 -0
  61. snakeorm/decorators/db_first.py +104 -0
  62. snakeorm/decorators/function.py +28 -0
  63. snakeorm/decorators/model.py +268 -0
  64. snakeorm/decorators/polymorphic.py +140 -0
  65. snakeorm/decorators/result.py +167 -0
  66. snakeorm/decorators/row.py +90 -0
  67. snakeorm/decorators/trigger.py +47 -0
  68. snakeorm/decorators/view.py +149 -0
  69. snakeorm/dialects/__init__.py +6 -0
  70. snakeorm/dialects/base.py +373 -0
  71. snakeorm/dialects/capabilities.py +571 -0
  72. snakeorm/dialects/literals.py +46 -0
  73. snakeorm/dialects/matrix.py +384 -0
  74. snakeorm/dialects/mysql.py +519 -0
  75. snakeorm/dialects/postgres.py +504 -0
  76. snakeorm/dialects/sqlite.py +417 -0
  77. snakeorm/drivers/__init__.py +18 -0
  78. snakeorm/drivers/asyncbase.py +73 -0
  79. snakeorm/drivers/asyncdecorators.py +244 -0
  80. snakeorm/drivers/asyncpool.py +228 -0
  81. snakeorm/drivers/asyncpsycopg.py +124 -0
  82. snakeorm/drivers/asyncpymysql.py +50 -0
  83. snakeorm/drivers/asyncsqlite.py +30 -0
  84. snakeorm/drivers/base.py +76 -0
  85. snakeorm/drivers/failures.py +181 -0
  86. snakeorm/drivers/logging.py +167 -0
  87. snakeorm/drivers/pool.py +352 -0
  88. snakeorm/drivers/psycopg.py +188 -0
  89. snakeorm/drivers/pymysql.py +176 -0
  90. snakeorm/drivers/savepoints.py +46 -0
  91. snakeorm/drivers/sqlite.py +170 -0
  92. snakeorm/drivers/threaded.py +143 -0
  93. snakeorm/drivers/timeout.py +87 -0
  94. snakeorm/dto/__init__.py +55 -0
  95. snakeorm/dto/read.py +231 -0
  96. snakeorm/dto/region.py +326 -0
  97. snakeorm/dto/resolve.py +354 -0
  98. snakeorm/dto/spec.py +201 -0
  99. snakeorm/expressions/__init__.py +88 -0
  100. snakeorm/expressions/conditional.py +178 -0
  101. snakeorm/expressions/expression.py +810 -0
  102. snakeorm/expressions/functions.py +71 -0
  103. snakeorm/expressions/keys.py +299 -0
  104. snakeorm/expressions/paths.py +43 -0
  105. snakeorm/expressions/scalar.py +293 -0
  106. snakeorm/expressions/window.py +248 -0
  107. snakeorm/fields/__init__.py +68 -0
  108. snakeorm/fields/check.py +79 -0
  109. snakeorm/fields/column.py +316 -0
  110. snakeorm/fields/enum.py +64 -0
  111. snakeorm/fields/index.py +46 -0
  112. snakeorm/fields/relationship.py +738 -0
  113. snakeorm/fields/typed.py +736 -0
  114. snakeorm/helpers/__init__.py +1 -0
  115. snakeorm/helpers/annotations.py +21 -0
  116. snakeorm/helpers/inheritance.py +41 -0
  117. snakeorm/helpers/pyliteral.py +52 -0
  118. snakeorm/helpers/pytype.py +50 -0
  119. snakeorm/introspection/__init__.py +13 -0
  120. snakeorm/introspection/base.py +30 -0
  121. snakeorm/introspection/drift.py +78 -0
  122. snakeorm/introspection/models.py +633 -0
  123. snakeorm/introspection/mysql.py +367 -0
  124. snakeorm/introspection/postgres.py +397 -0
  125. snakeorm/introspection/sqlite.py +217 -0
  126. snakeorm/introspection/unsupported.py +94 -0
  127. snakeorm/linker/__init__.py +3 -0
  128. snakeorm/linker/linker.py +521 -0
  129. snakeorm/metadata/__init__.py +41 -0
  130. snakeorm/metadata/check.py +37 -0
  131. snakeorm/metadata/column.py +201 -0
  132. snakeorm/metadata/enum_storage.py +33 -0
  133. snakeorm/metadata/fk_action.py +19 -0
  134. snakeorm/metadata/foreign_key.py +26 -0
  135. snakeorm/metadata/index.py +43 -0
  136. snakeorm/metadata/index_method.py +19 -0
  137. snakeorm/metadata/int_size.py +20 -0
  138. snakeorm/metadata/json_storage.py +21 -0
  139. snakeorm/metadata/polymorphic.py +30 -0
  140. snakeorm/metadata/primary_key.py +23 -0
  141. snakeorm/metadata/relationship.py +60 -0
  142. snakeorm/metadata/relationship_kind.py +43 -0
  143. snakeorm/metadata/routine.py +26 -0
  144. snakeorm/metadata/server_default.py +20 -0
  145. snakeorm/metadata/table.py +97 -0
  146. snakeorm/metadata/table_kind.py +23 -0
  147. snakeorm/metadata/trigger.py +62 -0
  148. snakeorm/metadata/type_params.py +348 -0
  149. snakeorm/migration/__init__.py +88 -0
  150. snakeorm/migration/asyncrunner.py +168 -0
  151. snakeorm/migration/autodetect.py +96 -0
  152. snakeorm/migration/ddl.py +1172 -0
  153. snakeorm/migration/diff.py +701 -0
  154. snakeorm/migration/loader.py +77 -0
  155. snakeorm/migration/operations.py +952 -0
  156. snakeorm/migration/planner.py +78 -0
  157. snakeorm/migration/realize.py +420 -0
  158. snakeorm/migration/renames.py +153 -0
  159. snakeorm/migration/render.py +1095 -0
  160. snakeorm/migration/runner.py +314 -0
  161. snakeorm/migration/squash.py +60 -0
  162. snakeorm/migration/state.py +107 -0
  163. snakeorm/model.py +162 -0
  164. snakeorm/py.typed +0 -0
  165. snakeorm/query/__init__.py +9 -0
  166. snakeorm/query/compound.py +288 -0
  167. snakeorm/query/join_kind.py +19 -0
  168. snakeorm/query/joined.py +143 -0
  169. snakeorm/query/query.py +1001 -0
  170. snakeorm/query/recursive.py +244 -0
  171. snakeorm/registry/__init__.py +5 -0
  172. snakeorm/registry/by_module.py +163 -0
  173. snakeorm/registry/registry.py +166 -0
  174. snakeorm/session/__init__.py +8 -0
  175. snakeorm/session/asyncsession.py +638 -0
  176. snakeorm/session/coercion.py +306 -0
  177. snakeorm/session/factory.py +29 -0
  178. snakeorm/session/guards.py +269 -0
  179. snakeorm/session/isolation.py +23 -0
  180. snakeorm/session/mapper.py +213 -0
  181. snakeorm/session/planning.py +815 -0
  182. snakeorm/session/retry.py +96 -0
  183. snakeorm/session/session.py +871 -0
  184. snakeorm/session/shared.py +355 -0
  185. snakeorm/sql/__init__.py +18 -0
  186. snakeorm/sql/adapt.py +104 -0
  187. snakeorm/sql/aggregate.py +232 -0
  188. snakeorm/sql/condition.py +359 -0
  189. snakeorm/sql/delete.py +46 -0
  190. snakeorm/sql/insert.py +149 -0
  191. snakeorm/sql/joins.py +158 -0
  192. snakeorm/sql/pk_subquery.py +54 -0
  193. snakeorm/sql/refs.py +19 -0
  194. snakeorm/sql/resolver.py +28 -0
  195. snakeorm/sql/select.py +175 -0
  196. snakeorm/sql/update.py +82 -0
  197. snakeorm/sql/value.py +552 -0
  198. snakeorm/times.py +276 -0
@@ -0,0 +1,701 @@
1
+ """Diff engine (autodetection): two schema states -> migration operations.
2
+
3
+ The heart of the CODE-FIRST flow. Agnostic about where the previous state came from (snapshot /
4
+ replay / reflection): it receives two collections of SnakeTableInfo. Order: tables and columns first
5
+ and the FKs LAST (so every table exists before any FK, with no topological ordering); FKs are only
6
+ diffed if `resolve_target` is passed. It also detects changes inside a column (-> AlterColumn) and
7
+ the indexes of an already existing table (a new table's are emitted by `CreateTable`).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import dataclasses
13
+ from collections.abc import Callable, Iterable
14
+ from typing import get_origin
15
+
16
+ from snakeorm.core.placement import DEFAULT_SCHEMA
17
+ from snakeorm.dialects import PostgresDialect
18
+ from snakeorm.core.exceptions import SnakeMigrationError
19
+ from snakeorm.migration.ddl import topological_view_order, view_fingerprint
20
+ from snakeorm.metadata import (
21
+ SnakeCheckInfo,
22
+ SnakeColumnInfo,
23
+ SnakeIndexInfo,
24
+ SnakeRelationshipKind,
25
+ SnakeRelationshipInfo,
26
+ SnakeRoutineInfo,
27
+ SnakeTableInfo,
28
+ SnakeTriggerInfo,
29
+ )
30
+ from snakeorm.migration.operations import (
31
+ AddCheck,
32
+ AddColumn,
33
+ AddForeignKey,
34
+ AlterColumn,
35
+ AlterFunction,
36
+ AlterTableComment,
37
+ AlterTrigger,
38
+ AlterView,
39
+ CreateFunction,
40
+ CreateIndex,
41
+ CreateSchema,
42
+ CreateTable,
43
+ CreateTrigger,
44
+ CreateView,
45
+ DropCheck,
46
+ DropColumn,
47
+ DropForeignKey,
48
+ DropFunction,
49
+ DropIndex,
50
+ DropTable,
51
+ DropTrigger,
52
+ DropView,
53
+ RebuildTable,
54
+ SnakeOperation,
55
+ )
56
+ from snakeorm.sql.condition import emit_condition_ddl
57
+
58
+ ResolveTarget = Callable[[str], SnakeTableInfo | None]
59
+
60
+
61
+ def diff_schema(
62
+ before: Iterable[SnakeTableInfo],
63
+ after: Iterable[SnakeTableInfo],
64
+ resolve_target: ResolveTarget | None = None,
65
+ resolve_qualified: ResolveTarget | None = None,
66
+ triggers: Iterable[SnakeTriggerInfo] = (),
67
+ ) -> list[SnakeOperation]:
68
+ """Derives the operations to get from schema `before` to `after`.
69
+
70
+ Order: tables (create/drop/columns) -> FKs -> views (which depend on the tables). Views are not
71
+ mixed in with columns or FKs; if their definition changes the whole thing is replaced (AlterView).
72
+
73
+ `triggers` are the ones the schema ALREADY has — the replayed state's, which is the only place
74
+ that knows they exist, since `SnakeTableInfo` has no field for one. They are not diffed here
75
+ (`diff_triggers` does that, afterwards): they are handed to any `RebuildTable` this call emits,
76
+ because on an engine with no `ALTER TABLE ADD CONSTRAINT` that rebuild DROPS the table and takes
77
+ them with it. Filling them in the same call that builds the operation is what keeps a rebuild
78
+ from coming out headless — nobody has to remember a second step.
79
+
80
+ THE VIEWS ARE NOT HANDED OVER, and the difference from the triggers is a fact against a guess.
81
+ A `SnakeTriggerInfo` has a `.table`, so "the triggers of this table" is a question the state
82
+ answers exactly. Nothing says which TABLES a view reads: `depends_on` is view->view only and is
83
+ refused for tables on purpose, and a view declared with `sql=` is raw text. So the generator
84
+ emits the rebuild bare: the engine refuses it, the migration rolls back whole, and
85
+ `explain_rebuild_failure` says to put a `DropView` before it and a `CreateView` after it.
86
+ """
87
+ before_list = list(before)
88
+ after_list = list(after)
89
+ before_tables = [table for table in before_list if not table.is_view]
90
+ after_tables = [table for table in after_list if not table.is_view]
91
+ # Keyed by (SCHEMA, name) and not by name. `@snake_model(schema=...)` makes `public.events` and
92
+ # `analytics.events` a legal pair, and a dict keyed by the bare name let the second overwrite the
93
+ # first: `diff_schema([], [public, analytics])` emitted ONE CreateTable, so the other table was
94
+ # never created and nothing said so. The mirror case is worse — moving a table between schemas
95
+ # looked like no change, so the migration came out empty and regenerated identically forever.
96
+ before_by_name = {(table.schema, table.name): table for table in before_tables}
97
+ after_by_name = {(table.schema, table.name): table for table in after_tables}
98
+ triggers_by_table: dict[tuple[str, str], list[SnakeTriggerInfo]] = {}
99
+ for trigger in triggers:
100
+ triggers_by_table.setdefault((trigger.schema, trigger.table), []).append(
101
+ trigger
102
+ )
103
+
104
+ # Schemas come FIRST: a table in `analytics` cannot be created if `analytics` does not exist.
105
+ # It is the same ordering criterion as the FKs going last, from the opposite side.
106
+ schema_ops: list[SnakeOperation] = _diff_schemas(before_list, after_list)
107
+
108
+ # The FKs are diffed FIRST, before the per-table loop, and that is new: the loop has to be able
109
+ # to tell whether a table's ONLY change is to its constraints, and half of the constraints live
110
+ # here. The order they are EMITTED in has not moved.
111
+ key_ops: list[AddForeignKey | DropForeignKey] = []
112
+ if resolve_target is not None:
113
+ key_ops = _diff_foreign_keys(
114
+ before_by_name, after_tables, resolve_target, resolve_qualified
115
+ )
116
+ keys_changed = {(op.table.schema, op.table.name) for op in key_ops}
117
+
118
+ table_ops: list[SnakeOperation] = []
119
+ rebuilds: list[SnakeOperation] = []
120
+ rebuilt: set[tuple[str, str]] = set()
121
+ for name in sorted(after_by_name):
122
+ if name in before_by_name:
123
+ before_t, after_t = before_by_name[name], after_by_name[name]
124
+ # Indexes are DROPPED before touching the columns and CREATED after: see _diff_indexes.
125
+ index_drops, index_creates = _diff_indexes(before_t, after_t)
126
+ check_drops, check_adds = _diff_checks(before_t, after_t)
127
+ column_ops = _diff_columns(before_t, after_t)
128
+ comment_changed = before_t.db_comment != after_t.db_comment
129
+ if _is_a_pure_constraint_change(
130
+ checks_changed=bool(check_drops or check_adds),
131
+ keys_changed=name in keys_changed,
132
+ other_changes=bool(index_drops or index_creates or column_ops)
133
+ or comment_changed,
134
+ ):
135
+ rebuilt.add(name)
136
+ rebuilds.append(
137
+ RebuildTable(
138
+ before_t,
139
+ _constraint_shape(
140
+ before_t, after_t, keys_diffed=resolve_target is not None
141
+ ),
142
+ triggers=tuple(triggers_by_table.get(name, ())),
143
+ )
144
+ )
145
+ continue
146
+ table_ops.extend(check_drops)
147
+ table_ops.extend(index_drops)
148
+ table_ops.extend(column_ops)
149
+ table_ops.extend(index_creates)
150
+ table_ops.extend(check_adds)
151
+ if comment_changed:
152
+ # The COLUMN comment is already diffed by `_column_changed`; the TABLE one was forgotten.
153
+ table_ops.append(
154
+ AlterTableComment(after_t, previous=before_t.db_comment)
155
+ )
156
+ else:
157
+ # New table: its indexes are emitted by CreateTable.up_sql itself, not a CreateIndex.
158
+ table_ops.append(CreateTable(after_by_name[name]))
159
+ drop_ops: list[SnakeOperation] = [
160
+ DropTable(table)
161
+ for table in drop_order(
162
+ [
163
+ before_by_name[name]
164
+ for name in sorted(before_by_name)
165
+ if name not in after_by_name
166
+ ]
167
+ )
168
+ ]
169
+
170
+ # FK DROPS go BEFORE the columns and the ADDS after, the same ordering the indexes and the
171
+ # CHECKs above already follow — and for the same reason, measured against Postgres: dropping a
172
+ # column takes its constraint with it, so a `DROP CONSTRAINT` emitted afterwards answers
173
+ # `UndefinedObject: constraint ... does not exist` and the migration aborts. Removing a
174
+ # relationship from a model used to produce a file that could not be applied.
175
+ fk_drops: list[SnakeOperation] = []
176
+ fk_adds: list[SnakeOperation] = []
177
+ for operation in key_ops:
178
+ if (operation.table.schema, operation.table.name) in rebuilt:
179
+ continue # its rebuild carries the key: emitting it too would apply it twice
180
+ target = fk_drops if isinstance(operation, DropForeignKey) else fk_adds
181
+ target.append(operation)
182
+
183
+ view_ops = _diff_views(before_list, after_list)
184
+
185
+ # The rebuilds sit BETWEEN the creations and the drops, and the slot is not decoration: a
186
+ # rebuild carries its table's foreign keys, so every table it points at has to exist already
187
+ # (hence after the CreateTables) and every table it stops pointing at must still be there
188
+ # (hence before the DropTables).
189
+ return [
190
+ *schema_ops,
191
+ *fk_drops,
192
+ *table_ops,
193
+ *rebuilds,
194
+ *drop_ops,
195
+ *fk_adds,
196
+ *view_ops,
197
+ ]
198
+
199
+
200
+ def _is_a_pure_constraint_change(
201
+ *, checks_changed: bool, keys_changed: bool, other_changes: bool
202
+ ) -> bool:
203
+ """Whether this table's whole difference is CHECKs and foreign keys, and nothing else.
204
+
205
+ THE SECOND HALF IS THE IMPORTANT ONE. A rebuild that also swallowed the column operations would
206
+ take `rename_suggestions` down with it: the warning that stops a `DropColumn` + `AddColumn` pair
207
+ from deleting a column's data reads exactly those two operations out of the diff, and so does
208
+ `narrowing_warnings` with `AlterColumn`. Going quiet there — on tables that have constraints,
209
+ which is to say the ones with rules worth keeping — is a net that fails open, and this branch
210
+ has already deleted three of those for doing precisely that.
211
+
212
+ So the collapse happens only where there is nothing else to hide: no column moved, no index
213
+ moved, no comment moved. What is left is a change SQLite can make no other way than by remaking
214
+ the table, and that is what the operation is called.
215
+ """
216
+ return (checks_changed or keys_changed) and not other_changes
217
+
218
+
219
+ def _constraint_shape(
220
+ before: SnakeTableInfo, after: SnakeTableInfo, *, keys_diffed: bool
221
+ ) -> SnakeTableInfo:
222
+ """The `after` snapshot of a rebuild: the table as it IS, wearing the constraints it SHOULD have.
223
+
224
+ Built from `before` and not from the desired table on purpose. The two agree on everything the
225
+ diff compares, but not necessarily on everything they CARRY — `_NOT_A_COLUMN_CHANGE` lists the
226
+ column fields that deliberately change nothing in SQL, and a snapshot that differed in one of
227
+ them would be refused by `RebuildTable` for a difference no engine can see. What goes into the
228
+ file is the shape the database really has, plus the constraints that are the point of the file.
229
+
230
+ Only to-one relations travel: a to-many is the inverse and its key lives on the other table, the
231
+ same filter `_diff_foreign_keys` applies. With no `resolve_target` the keys were never diffed at
232
+ all, so the previous ones are kept rather than invented.
233
+ """
234
+ relationships = (
235
+ tuple(
236
+ relationship
237
+ for relationship in after.relationships
238
+ if relationship.kind is SnakeRelationshipKind.TO_ONE
239
+ )
240
+ if keys_diffed
241
+ else before.relationships
242
+ )
243
+ return dataclasses.replace(before, checks=after.checks, relationships=relationships)
244
+
245
+
246
+ def drop_order(tables: list[SnakeTableInfo]) -> list[SnakeTableInfo]:
247
+ """Orders tables so the one HOLDING a foreign key is dropped before the one it points at.
248
+
249
+ Two of the three engines refuse `DROP TABLE` while a key points at the table — measured:
250
+ PostgreSQL says other objects depend on it, MariaDB answers error 1451, and only SQLite accepts
251
+ it and leaves the key dangling. It is not a dialect difference to translate in the emitter: the
252
+ SQL is correct and what matters is the ORDER.
253
+
254
+ It is the exact mirror of the creation order the planner already derives from the same FKs, and
255
+ of `topological_view_order`, which drops views in reverse of the order it creates them in.
256
+
257
+ Only edges INSIDE this set count: a key into a table that is not being dropped constrains
258
+ nothing here. A table pointing at ITSELF (`parent_id`, the commonest tree there is) imposes no
259
+ order either — its own edge cannot make it wait for itself. A real cycle between two tables
260
+ cannot be ordered at all, so it stops and names THE WHOLE LOOP: naming one end sends the reader
261
+ to look at half of it, and the half they cannot see is the one holding the other key.
262
+ """
263
+ by_name = {table.name: table for table in tables}
264
+ ordered: list[SnakeTableInfo] = []
265
+ visited: set[str] = set()
266
+ # A LIST and not a set, unlike the view ordering: the message quotes the loop, and a set has no
267
+ # loop in it — only the names that happen to be in flight.
268
+ path: list[str] = []
269
+
270
+ def visit(table: SnakeTableInfo) -> None:
271
+ """Emits everything that points AT this table before emitting the table itself."""
272
+ if table.name in visited:
273
+ return
274
+ if table.name in path:
275
+ loop = [*path[path.index(table.name) :], table.name]
276
+ raise SnakeMigrationError(
277
+ f"Cycle of foreign keys among the tables being dropped: "
278
+ f"{' -> '.join(loop)}. There is no order in which they can be retired — whichever "
279
+ f"goes first, a key from the next one is still pointing at it. Break the loop with "
280
+ f"an explicit `DropForeignKey` before the drops."
281
+ )
282
+ path.append(table.name)
283
+ for holder in sorted(by_name.values(), key=lambda item: item.name):
284
+ if holder.name != table.name and table.name in _referenced_tables(holder):
285
+ visit(holder)
286
+ path.pop()
287
+ visited.add(table.name)
288
+ ordered.append(table)
289
+
290
+ for table in sorted(tables, key=lambda item: item.name):
291
+ visit(table)
292
+ return ordered
293
+
294
+
295
+ def _referenced_tables(table: SnakeTableInfo) -> set[str]:
296
+ """The bare names of the tables this table's to-one keys POINT AT.
297
+
298
+ Only TO_ONE: a to-many is the inverse relation and its constraint lives on the other table, so
299
+ counting it would invent an edge in the wrong direction — the same filter `planner._referenced`
300
+ applies, and for the same reason.
301
+ """
302
+ return {
303
+ relationship.target_table.rpartition(".")[2]
304
+ for relationship in table.relationships
305
+ if relationship.kind is SnakeRelationshipKind.TO_ONE
306
+ and relationship.target_table
307
+ }
308
+
309
+
310
+ def _diff_schemas(
311
+ before: list[SnakeTableInfo], after: list[SnakeTableInfo]
312
+ ) -> list[SnakeOperation]:
313
+ """Schemas that are needed and do not exist yet -> `CreateSchema`.
314
+
315
+ `public` is never created (it always exists in Postgres). No `DropSchema` is emitted when one
316
+ empties out: it may hold things we do not govern; cleaning up is the human's call.
317
+ """
318
+ known = {table.schema for table in before} | {DEFAULT_SCHEMA}
319
+ needed = {table.schema for table in after}
320
+ return [CreateSchema(schema) for schema in sorted(needed - known)]
321
+
322
+
323
+ def _bare_view(view: SnakeTableInfo) -> SnakeTableInfo:
324
+ """A view stripped of relations for the operations: its FKs are navigation, not real constraints.
325
+
326
+ It keeps `depends_on`: the topological order (create/drop) and the render round-trip both use it.
327
+ """
328
+ return dataclasses.replace(view, relationships=())
329
+
330
+
331
+ def _diff_views(
332
+ before: list[SnakeTableInfo], after: list[SnakeTableInfo]
333
+ ) -> list[SnakeOperation]:
334
+ """Compares both states' views: new -> CreateView, dropped -> DropView, def changed -> AlterView.
335
+
336
+ The CreateViews are ordered topologically (a view after those it depends on, `depends_on`); the
337
+ DropViews in REVERSE order (the depending one first). AlterView (CREATE OR REPLACE) is not
338
+ ordered: both views already exist. A cycle between views raises `SnakeMigrationError`.
339
+ """
340
+ before_by_name = {view.name: view for view in before if view.is_view}
341
+ after_by_name = {view.name: view for view in after if view.is_view}
342
+
343
+ to_create: list[SnakeTableInfo] = []
344
+ alters: list[SnakeOperation] = []
345
+ for name in sorted(after_by_name):
346
+ after_view = after_by_name[name]
347
+ before_view = before_by_name.get(name)
348
+ if before_view is None:
349
+ to_create.append(after_view)
350
+ elif view_fingerprint(before_view) != view_fingerprint(after_view):
351
+ # By FINGERPRINT and not by `view_definition`: a view declared with `query=` no longer
352
+ # holds a string — it holds the uncompiled query, so each engine writes its own body —
353
+ # so comparing the field directly would always report "no changes".
354
+ alters.append(AlterView(_bare_view(before_view), _bare_view(after_view)))
355
+ to_drop = [
356
+ before_by_name[name]
357
+ for name in sorted(before_by_name)
358
+ if name not in after_by_name
359
+ ]
360
+
361
+ create_ops: list[SnakeOperation] = [
362
+ CreateView(_bare_view(view)) for view in topological_view_order(to_create)
363
+ ]
364
+ drop_ops: list[SnakeOperation] = [
365
+ DropView(_bare_view(view)) for view in reversed(topological_view_order(to_drop))
366
+ ]
367
+ return [*create_ops, *alters, *drop_ops]
368
+
369
+
370
+ def diff_routines(
371
+ before: Iterable[SnakeRoutineInfo], after: Iterable[SnakeRoutineInfo]
372
+ ) -> list[SnakeOperation]:
373
+ """Compares the desired routines (functions) against the history -> migration operations.
374
+
375
+ Like a table, but a routine is OPAQUE: only the `body` string is compared. New -> CreateFunction;
376
+ body changed -> AlterFunction(old, new); removed -> DropFunction. The key is the `name`
377
+ (consistent with `SchemaState`, which indexes routines by name). Functions are emitted AFTER
378
+ tables and views (they may depend on them); they are not ordered among themselves (a documented
379
+ limitation).
380
+ """
381
+ before_by_name = {routine.name: routine for routine in before}
382
+ after_by_name = {routine.name: routine for routine in after}
383
+
384
+ operations: list[SnakeOperation] = []
385
+ for name in sorted(after_by_name):
386
+ after_routine = after_by_name[name]
387
+ before_routine = before_by_name.get(name)
388
+ if before_routine is None:
389
+ operations.append(CreateFunction(after_routine))
390
+ elif before_routine.body != after_routine.body:
391
+ operations.append(AlterFunction(before_routine, after_routine))
392
+ for name in sorted(before_by_name):
393
+ if name not in after_by_name:
394
+ operations.append(DropFunction(before_by_name[name]))
395
+ return operations
396
+
397
+
398
+ def diff_triggers(
399
+ before: Iterable[SnakeTriggerInfo], after: Iterable[SnakeTriggerInfo]
400
+ ) -> list[SnakeOperation]:
401
+ """Compares the desired triggers against the history -> migration operations.
402
+
403
+ Like `diff_routines`, but the key is `(table, name)`: in Postgres two tables can each have a
404
+ trigger of the same name, and indexing by name would silently overwrite one. ALL fields are
405
+ compared (timing and events count too); with no portable `CREATE OR REPLACE TRIGGER`, any change
406
+ is a DROP + CREATE (`AlterTrigger`).
407
+ """
408
+ before_by_key = {(t.table, t.name): t for t in before}
409
+ after_by_key = {(t.table, t.name): t for t in after}
410
+
411
+ operations: list[SnakeOperation] = []
412
+ for key in sorted(after_by_key):
413
+ new = after_by_key[key]
414
+ old = before_by_key.get(key)
415
+ if old is None:
416
+ operations.append(CreateTrigger(new))
417
+ elif old != new:
418
+ operations.append(AlterTrigger(old, new))
419
+ for key in sorted(before_by_key):
420
+ if key not in after_by_key:
421
+ operations.append(DropTrigger(before_by_key[key]))
422
+ return operations
423
+
424
+
425
+ def _diff_columns(
426
+ before: SnakeTableInfo, after: SnakeTableInfo
427
+ ) -> list[SnakeOperation]:
428
+ """Compares the columns of a table existing in both states -> Add/Drop column."""
429
+ before_columns = {column.name: column for column in before.columns}
430
+ after_columns = {column.name: column for column in after.columns}
431
+
432
+ operations: list[SnakeOperation] = []
433
+ for name in sorted(after_columns):
434
+ if name not in before_columns:
435
+ operations.append(AddColumn(after, after_columns[name]))
436
+ elif _column_changed(before_columns[name], after_columns[name]):
437
+ operations.append(
438
+ AlterColumn(after, before_columns[name], after_columns[name])
439
+ )
440
+ for name in sorted(before_columns):
441
+ if name not in after_columns:
442
+ operations.append(DropColumn(after, before_columns[name]))
443
+ return operations
444
+
445
+
446
+ _NOT_A_COLUMN_CHANGE: dict[str, str] = {
447
+ "name": "the diff KEYS by it; a different name is a different column (or a RenameColumn)",
448
+ "attr_name": "Python-side only: what the attribute is called changes no SQL",
449
+ "index": "diffed apart by `_diff_indexes`, because indexes are dropped before the column "
450
+ "changes and created after",
451
+ "default_factory": "a callable, and two equivalent lambdas are never `==`: comparing it "
452
+ "would emit a migration on every single run",
453
+ "type_params": "compared through the properties it feeds (precision, scale, int_size, "
454
+ "max_length, json_storage), which NORMALISE — `None` and the explicit default mean the "
455
+ "same type, and comparing the raw params would invent changes that are not there",
456
+ "python_type": "compared through `_comparable_type`, which normalises the one case where the "
457
+ "parameters provably do not reach SQL: raw identity made `dict` -> `dict[str, object]` an "
458
+ "AlterColumn that every dialect then rendered as no statements at all",
459
+ }
460
+ """Fields of `SnakeColumnInfo` the diff deliberately does not compare, each with its reason.
461
+
462
+ An OPT-OUT with a written reason instead of an opt-in by memory. The comparison used to be a chain
463
+ of thirteen `or`s, and a field added to `SnakeColumnInfo` afterwards simply never joined it:
464
+ `autoincrement`, `enum_type` and `enum_storage` were all invisible, so `BIGINT` -> `BIGSERIAL` and
465
+ any change to an enum produced an EMPTY migration — no error, and a `makemigrations` that goes on
466
+ proposing nothing while the model and the database drift.
467
+
468
+ `test_column_diff.py` fails if a field is neither compared nor listed here, which is the same
469
+ mechanism as the `Cap` catalogue: you cannot forget to answer, only answer explicitly.
470
+ """
471
+
472
+
473
+ def _column_changed(old: SnakeColumnInfo, new: SnakeColumnInfo) -> bool:
474
+ """Whether a column changed in anything the schema can tell apart.
475
+
476
+ Derived from `SnakeColumnInfo`'s own fields minus `_NOT_A_COLUMN_CHANGE`, so a field added to
477
+ the metadata is compared the day it appears rather than the day somebody remembers.
478
+ """
479
+ for field in dataclasses.fields(SnakeColumnInfo):
480
+ if field.name in _NOT_A_COLUMN_CHANGE:
481
+ continue
482
+ if getattr(old, field.name) != getattr(new, field.name):
483
+ return True
484
+ # Fed by `type_params`, which is excluded above: these properties normalise it, so they are the
485
+ # comparable face of it. Widening an int (INTEGER->BIGINT), a VARCHAR(100)->VARCHAR(255) and a
486
+ # JSONB<->JSON all live here and all change the real column type.
487
+ return (
488
+ old.precision != new.precision
489
+ or old.scale != new.scale
490
+ or old.int_size != new.int_size
491
+ or old.max_length != new.max_length
492
+ or old.json_storage != new.json_storage
493
+ or _comparable_type(old.python_type) != _comparable_type(new.python_type)
494
+ )
495
+
496
+
497
+ def _comparable_type(python_type: type) -> object:
498
+ """The face of a `python_type` that the SCHEMA can tell apart.
499
+
500
+ A `dict` is ONE opaque JSON value in all three engines, so how it is parameterised never reaches
501
+ SQL. Compared by identity, refining `SnakeColumn[dict]` into `SnakeColumn[dict[str, object]]`
502
+ produced an `AlterColumn` that `emit_alter_column` then rendered as no statements at all — an
503
+ operation asking for `ALTER COLUMN ... TYPE JSONB` on a column that is already JSONB, which on
504
+ Postgres is a full table rewrite under a lock to change nothing. Demanding an outage because
505
+ somebody typed their `dict` properly punishes the one rule this project cares most about, and it
506
+ happened here: to these demos, the day that annotation was fixed.
507
+
508
+ It is NOT a blanket unwrap of the origin, which is the fix that looks obvious and is wrong.
509
+ Measured on Postgres, `list[int]` is `BIGINT[]` and `list[str]` is `TEXT[]`: comparing origins
510
+ would call those one column and lose a real change. This one is loud and that one would be
511
+ silent, so the normalisation stays narrow and stays measured — `test_the_diff_compares_what_
512
+ reaches_sql` asserts BOTH halves, so the day an engine spells the two dicts differently it goes
513
+ red instead of quietly under-reporting.
514
+ """
515
+ return dict if get_origin(python_type) is dict else python_type
516
+
517
+
518
+ def _diff_checks(
519
+ before: SnakeTableInfo, after: SnakeTableInfo
520
+ ) -> tuple[list[SnakeOperation], list[SnakeOperation]]:
521
+ """Compares the CHECKs of a table present in BOTH states -> (dropped, added).
522
+
523
+ Two lists because of ordering (as with indexes): a CHECK is dropped BEFORE touching the columns
524
+ and added AFTER. Identity = resolved name; if the condition changes under the same name it is
525
+ recreated (in SQL a CHECK is not altered in place). Comparison is by the emitted SQL, not by
526
+ object identity (the AST nodes use `eq=False`).
527
+ """
528
+ dialect = PostgresDialect()
529
+
530
+ def fingerprint(check: SnakeCheckInfo) -> str:
531
+ """A CHECK's comparable fingerprint: its already-emitted SQL."""
532
+ return emit_condition_ddl(check.condition, dialect)
533
+
534
+ before_by_name = {
535
+ check.resolved_name(before.name): check for check in before.checks
536
+ }
537
+ after_by_name = {check.resolved_name(after.name): check for check in after.checks}
538
+
539
+ drops: list[SnakeOperation] = [
540
+ DropCheck(before, check)
541
+ for name, check in sorted(before_by_name.items(), key=lambda item: item[0])
542
+ if name not in after_by_name
543
+ or fingerprint(after_by_name[name]) != fingerprint(check)
544
+ ]
545
+ adds: list[SnakeOperation] = [
546
+ AddCheck(after, check)
547
+ for name, check in sorted(after_by_name.items(), key=lambda item: item[0])
548
+ if name not in before_by_name
549
+ or fingerprint(before_by_name[name]) != fingerprint(check)
550
+ ]
551
+ return drops, adds
552
+
553
+
554
+ def _diff_indexes(
555
+ before: SnakeTableInfo, after: SnakeTableInfo
556
+ ) -> tuple[list[SnakeOperation], list[SnakeOperation]]:
557
+ """Compares the indexes of a table existing in BOTH states -> (dropped, created).
558
+
559
+ Two separate lists because of ordering: an index is DROPPED before touching the columns and
560
+ CREATED after. Identity = resolved name; if anything else changes (columns, unique) it is
561
+ recreated (in SQL an index is not altered in place).
562
+ """
563
+ dialect = PostgresDialect()
564
+
565
+ def fingerprint(index: SnakeIndexInfo) -> tuple[object, ...]:
566
+ """An index's comparable fingerprint.
567
+
568
+ The `where` is compared by the SQL it emits, not with `==`: the AST nodes use `eq=False` and
569
+ two equivalent conditions would be different objects, a phantom change on every pass.
570
+ """
571
+ where = (
572
+ emit_condition_ddl(index.where, dialect)
573
+ if index.where is not None
574
+ else None
575
+ )
576
+ return (index.columns, index.unique, index.method, where)
577
+
578
+ before_by_name = {
579
+ index.resolved_name(before.name): index for index in before.indexes
580
+ }
581
+ after_by_name = {index.resolved_name(after.name): index for index in after.indexes}
582
+
583
+ def changed(
584
+ name: str, index: SnakeIndexInfo, other: dict[str, SnakeIndexInfo]
585
+ ) -> bool:
586
+ """Whether the index is absent from the other state, or present with another definition."""
587
+ counterpart = other.get(name)
588
+ return counterpart is None or fingerprint(counterpart) != fingerprint(index)
589
+
590
+ drops: list[SnakeOperation] = [
591
+ DropIndex(before, index)
592
+ for name, index in sorted(before_by_name.items(), key=lambda item: item[0])
593
+ if changed(name, index, after_by_name)
594
+ ]
595
+ creates: list[SnakeOperation] = [
596
+ CreateIndex(after, index)
597
+ for name, index in sorted(after_by_name.items(), key=lambda item: item[0])
598
+ if changed(name, index, before_by_name)
599
+ ]
600
+ return drops, creates
601
+
602
+
603
+ def _diff_foreign_keys(
604
+ before_by_name: dict[tuple[str, str], SnakeTableInfo],
605
+ after: list[SnakeTableInfo],
606
+ resolve_target: ResolveTarget,
607
+ resolve_qualified: ResolveTarget | None = None,
608
+ ) -> list[AddForeignKey | DropForeignKey]:
609
+ """Compares each current table's relations (FKs) against the previous state.
610
+
611
+ The return type NAMES the two operations instead of widening to `SnakeOperation`, because
612
+ `diff_schema` now reads `.table` off them to work out which tables changed only a constraint.
613
+ Widened, that read needed a cast, and a cast is a promise the checker stops verifying.
614
+ """
615
+ operations: list[AddForeignKey | DropForeignKey] = []
616
+ for table in sorted(after, key=lambda t: t.name):
617
+ before_table = before_by_name.get((table.schema, table.name))
618
+ # Only to-one relations produce constraints: a to-many is the inverse (its FK lives on the
619
+ # child) and one to/from a view is pure navigation. They are filtered out so no incorrect
620
+ # ALTER TABLE is emitted.
621
+ before_rels = {
622
+ rel.name: rel
623
+ for rel in (before_table.relationships if before_table else ())
624
+ if rel.kind is SnakeRelationshipKind.TO_ONE
625
+ }
626
+ after_rels = {
627
+ rel.name: rel
628
+ for rel in table.relationships
629
+ if rel.kind is SnakeRelationshipKind.TO_ONE
630
+ }
631
+ # Removals first, and the FKs that CHANGED too: in SQL a constraint is not altered in
632
+ # place, it is dropped and recreated. The whole `foreign_key` is compared (not just the
633
+ # name) so a change of `on_delete` or of columns does not slip through.
634
+ for name in sorted(before_rels):
635
+ if (
636
+ name in after_rels
637
+ and before_rels[name].foreign_key == after_rels[name].foreign_key
638
+ ):
639
+ continue
640
+ target = _previous_target(before_rels[name], before_by_name, resolve_target)
641
+ if target is not None:
642
+ operations.append(DropForeignKey(table, before_rels[name], target))
643
+ for name in sorted(after_rels):
644
+ if (
645
+ name in before_rels
646
+ and before_rels[name].foreign_key == after_rels[name].foreign_key
647
+ ):
648
+ continue
649
+ # The DESIRED side uses the target already resolved and qualified by the linker:
650
+ # looking it up by class name would point at another model of the same name. The
651
+ # PREVIOUS side does go by name (it is rebuilt from a migration, where the name is all
652
+ # the information there is).
653
+ target = _desired_target(
654
+ after_rels[name], resolve_target, resolve_qualified
655
+ )
656
+ if target is not None:
657
+ operations.append(AddForeignKey(table, after_rels[name], target))
658
+ return operations
659
+
660
+
661
+ def _previous_target(
662
+ relationship: SnakeRelationshipInfo,
663
+ before_by_name: dict[tuple[str, str], SnakeTableInfo],
664
+ resolve_target: ResolveTarget,
665
+ ) -> SnakeTableInfo | None:
666
+ """Target table of a relation being DROPPED, falling back to the PREVIOUS state.
667
+
668
+ The registry is asked first, because it is the live truth. But a relation is dropped precisely
669
+ when it leaves the code, and the commonest way for that to happen is that the target MODEL was
670
+ deleted too. A deleted model is not in the registry, and answering `None` there makes the
671
+ `DropForeignKey` VANISH in silence, leaving a plan with a bare `DropColumn` that MySQL (error
672
+ 1553) and SQLite both refuse.
673
+
674
+ So it falls back to the previous state, which the migration history replays: it holds the target
675
+ table with its real schema and its real columns — exactly what the reverse (`AddForeignKey`)
676
+ needs to put the constraint back. It is the recorded table or nothing.
677
+
678
+ The one case it cannot answer is a history so old that it recorded no `target_table` — the field
679
+ is derived, and migrations written before it existed do not carry it. There the previous state
680
+ holds the table and nothing says WHICH one it is, so this returns `None`.
681
+ """
682
+ target = resolve_target(relationship.foreign_key.target)
683
+ if target is not None:
684
+ return target
685
+ if not relationship.target_table:
686
+ return None
687
+ schema, _, name = relationship.target_table.rpartition(".")
688
+ return before_by_name.get((schema, name))
689
+
690
+
691
+ def _desired_target(
692
+ relationship: SnakeRelationshipInfo,
693
+ resolve_target: ResolveTarget,
694
+ resolve_qualified: ResolveTarget | None,
695
+ ) -> SnakeTableInfo | None:
696
+ """Target table of a DESIRED relation, without going through the ambiguous name index."""
697
+ if resolve_qualified is not None and relationship.target_table:
698
+ resolved = resolve_qualified(relationship.target_table)
699
+ if resolved is not None:
700
+ return resolved
701
+ return resolve_target(relationship.foreign_key.target)