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.
- snake_orm-0.1.0b1.dist-info/METADATA +366 -0
- snake_orm-0.1.0b1.dist-info/RECORD +198 -0
- snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
- snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
- snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- snakeorm/__init__.py +214 -0
- snakeorm/advisor.py +109 -0
- snakeorm/cli/__init__.py +3 -0
- snakeorm/cli/app.py +992 -0
- snakeorm/cli/discovery.py +179 -0
- snakeorm/cli/hooks.py +86 -0
- snakeorm/compiler/__init__.py +3 -0
- snakeorm/compiler/compiler.py +330 -0
- snakeorm/connection.py +228 -0
- snakeorm/contrib/__init__.py +17 -0
- snakeorm/contrib/asgi.py +192 -0
- snakeorm/contrib/config.py +264 -0
- snakeorm/contrib/deliver.py +256 -0
- snakeorm/contrib/django.py +245 -0
- snakeorm/contrib/sidecar.py +44 -0
- snakeorm/contrib/wsgi.py +176 -0
- snakeorm/core/__init__.py +1 -0
- snakeorm/core/config.py +173 -0
- snakeorm/core/converters.py +172 -0
- snakeorm/core/exceptions.py +175 -0
- snakeorm/core/placement.py +15 -0
- snakeorm/core/sentinels.py +37 -0
- snakeorm/core/signals.py +114 -0
- snakeorm/debug/__init__.py +42 -0
- snakeorm/debug/assets/css/panel.css +616 -0
- snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snakeorm/debug/assets/img/icon.svg +5 -0
- snakeorm/debug/assets/js/history.js +425 -0
- snakeorm/debug/assets/js/language.js +283 -0
- snakeorm/debug/assets/js/panel.js +241 -0
- snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snakeorm/debug/assets/pages/cli.html +28 -0
- snakeorm/debug/assets/pages/config.html +34 -0
- snakeorm/debug/assets/pages/database_first.html +29 -0
- snakeorm/debug/assets/pages/help.html +21 -0
- snakeorm/debug/assets/pages/history.html +27 -0
- snakeorm/debug/capture.py +234 -0
- snakeorm/debug/channel.py +131 -0
- snakeorm/debug/collector.py +144 -0
- snakeorm/debug/config.py +149 -0
- snakeorm/debug/html.py +578 -0
- snakeorm/debug/matrix.py +60 -0
- snakeorm/debug/origin.py +42 -0
- snakeorm/debug/otel/__init__.py +68 -0
- snakeorm/debug/otel/context.py +43 -0
- snakeorm/debug/otel/exporter.py +277 -0
- snakeorm/debug/otel/payload.py +97 -0
- snakeorm/debug/otel/report.py +29 -0
- snakeorm/debug/otel/spans.py +393 -0
- snakeorm/debug/otel/summary.py +59 -0
- snakeorm/debug/record.py +54 -0
- snakeorm/debug/report.py +311 -0
- snakeorm/debug/testing.py +23 -0
- snakeorm/decorators/__init__.py +13 -0
- snakeorm/decorators/abstract.py +26 -0
- snakeorm/decorators/db_first.py +104 -0
- snakeorm/decorators/function.py +28 -0
- snakeorm/decorators/model.py +268 -0
- snakeorm/decorators/polymorphic.py +140 -0
- snakeorm/decorators/result.py +167 -0
- snakeorm/decorators/row.py +90 -0
- snakeorm/decorators/trigger.py +47 -0
- snakeorm/decorators/view.py +149 -0
- snakeorm/dialects/__init__.py +6 -0
- snakeorm/dialects/base.py +373 -0
- snakeorm/dialects/capabilities.py +571 -0
- snakeorm/dialects/literals.py +46 -0
- snakeorm/dialects/matrix.py +384 -0
- snakeorm/dialects/mysql.py +519 -0
- snakeorm/dialects/postgres.py +504 -0
- snakeorm/dialects/sqlite.py +417 -0
- snakeorm/drivers/__init__.py +18 -0
- snakeorm/drivers/asyncbase.py +73 -0
- snakeorm/drivers/asyncdecorators.py +244 -0
- snakeorm/drivers/asyncpool.py +228 -0
- snakeorm/drivers/asyncpsycopg.py +124 -0
- snakeorm/drivers/asyncpymysql.py +50 -0
- snakeorm/drivers/asyncsqlite.py +30 -0
- snakeorm/drivers/base.py +76 -0
- snakeorm/drivers/failures.py +181 -0
- snakeorm/drivers/logging.py +167 -0
- snakeorm/drivers/pool.py +352 -0
- snakeorm/drivers/psycopg.py +188 -0
- snakeorm/drivers/pymysql.py +176 -0
- snakeorm/drivers/savepoints.py +46 -0
- snakeorm/drivers/sqlite.py +170 -0
- snakeorm/drivers/threaded.py +143 -0
- snakeorm/drivers/timeout.py +87 -0
- snakeorm/dto/__init__.py +55 -0
- snakeorm/dto/read.py +231 -0
- snakeorm/dto/region.py +326 -0
- snakeorm/dto/resolve.py +354 -0
- snakeorm/dto/spec.py +201 -0
- snakeorm/expressions/__init__.py +88 -0
- snakeorm/expressions/conditional.py +178 -0
- snakeorm/expressions/expression.py +810 -0
- snakeorm/expressions/functions.py +71 -0
- snakeorm/expressions/keys.py +299 -0
- snakeorm/expressions/paths.py +43 -0
- snakeorm/expressions/scalar.py +293 -0
- snakeorm/expressions/window.py +248 -0
- snakeorm/fields/__init__.py +68 -0
- snakeorm/fields/check.py +79 -0
- snakeorm/fields/column.py +316 -0
- snakeorm/fields/enum.py +64 -0
- snakeorm/fields/index.py +46 -0
- snakeorm/fields/relationship.py +738 -0
- snakeorm/fields/typed.py +736 -0
- snakeorm/helpers/__init__.py +1 -0
- snakeorm/helpers/annotations.py +21 -0
- snakeorm/helpers/inheritance.py +41 -0
- snakeorm/helpers/pyliteral.py +52 -0
- snakeorm/helpers/pytype.py +50 -0
- snakeorm/introspection/__init__.py +13 -0
- snakeorm/introspection/base.py +30 -0
- snakeorm/introspection/drift.py +78 -0
- snakeorm/introspection/models.py +633 -0
- snakeorm/introspection/mysql.py +367 -0
- snakeorm/introspection/postgres.py +397 -0
- snakeorm/introspection/sqlite.py +217 -0
- snakeorm/introspection/unsupported.py +94 -0
- snakeorm/linker/__init__.py +3 -0
- snakeorm/linker/linker.py +521 -0
- snakeorm/metadata/__init__.py +41 -0
- snakeorm/metadata/check.py +37 -0
- snakeorm/metadata/column.py +201 -0
- snakeorm/metadata/enum_storage.py +33 -0
- snakeorm/metadata/fk_action.py +19 -0
- snakeorm/metadata/foreign_key.py +26 -0
- snakeorm/metadata/index.py +43 -0
- snakeorm/metadata/index_method.py +19 -0
- snakeorm/metadata/int_size.py +20 -0
- snakeorm/metadata/json_storage.py +21 -0
- snakeorm/metadata/polymorphic.py +30 -0
- snakeorm/metadata/primary_key.py +23 -0
- snakeorm/metadata/relationship.py +60 -0
- snakeorm/metadata/relationship_kind.py +43 -0
- snakeorm/metadata/routine.py +26 -0
- snakeorm/metadata/server_default.py +20 -0
- snakeorm/metadata/table.py +97 -0
- snakeorm/metadata/table_kind.py +23 -0
- snakeorm/metadata/trigger.py +62 -0
- snakeorm/metadata/type_params.py +348 -0
- snakeorm/migration/__init__.py +88 -0
- snakeorm/migration/asyncrunner.py +168 -0
- snakeorm/migration/autodetect.py +96 -0
- snakeorm/migration/ddl.py +1172 -0
- snakeorm/migration/diff.py +701 -0
- snakeorm/migration/loader.py +77 -0
- snakeorm/migration/operations.py +952 -0
- snakeorm/migration/planner.py +78 -0
- snakeorm/migration/realize.py +420 -0
- snakeorm/migration/renames.py +153 -0
- snakeorm/migration/render.py +1095 -0
- snakeorm/migration/runner.py +314 -0
- snakeorm/migration/squash.py +60 -0
- snakeorm/migration/state.py +107 -0
- snakeorm/model.py +162 -0
- snakeorm/py.typed +0 -0
- snakeorm/query/__init__.py +9 -0
- snakeorm/query/compound.py +288 -0
- snakeorm/query/join_kind.py +19 -0
- snakeorm/query/joined.py +143 -0
- snakeorm/query/query.py +1001 -0
- snakeorm/query/recursive.py +244 -0
- snakeorm/registry/__init__.py +5 -0
- snakeorm/registry/by_module.py +163 -0
- snakeorm/registry/registry.py +166 -0
- snakeorm/session/__init__.py +8 -0
- snakeorm/session/asyncsession.py +638 -0
- snakeorm/session/coercion.py +306 -0
- snakeorm/session/factory.py +29 -0
- snakeorm/session/guards.py +269 -0
- snakeorm/session/isolation.py +23 -0
- snakeorm/session/mapper.py +213 -0
- snakeorm/session/planning.py +815 -0
- snakeorm/session/retry.py +96 -0
- snakeorm/session/session.py +871 -0
- snakeorm/session/shared.py +355 -0
- snakeorm/sql/__init__.py +18 -0
- snakeorm/sql/adapt.py +104 -0
- snakeorm/sql/aggregate.py +232 -0
- snakeorm/sql/condition.py +359 -0
- snakeorm/sql/delete.py +46 -0
- snakeorm/sql/insert.py +149 -0
- snakeorm/sql/joins.py +158 -0
- snakeorm/sql/pk_subquery.py +54 -0
- snakeorm/sql/refs.py +19 -0
- snakeorm/sql/resolver.py +28 -0
- snakeorm/sql/select.py +175 -0
- snakeorm/sql/update.py +82 -0
- snakeorm/sql/value.py +552 -0
- 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)
|