ferrum-orm 0.1.20__tar.gz → 0.1.22__tar.gz
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.
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/Cargo.lock +4 -4
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/Cargo.toml +1 -1
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/PKG-INFO +17 -1
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/README.md +16 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/pyproject.toml +1 -1
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/__init__.py +1 -1
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/makemigrations_cmd.py +15 -4
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/autodiff.py +4 -4
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/drift.py +56 -9
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/operations.py +20 -2
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/orchestrator.py +120 -37
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/LICENSE +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/Cargo.toml +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/benches/hydrate.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/src/compile/mod.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/src/error.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/src/ir/metadata.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/src/ir/mod.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/src/lib.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/src/migrate/mod.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-pyo3/Cargo.toml +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-pyo3/src/lib.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/Cargo.toml +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/benches/compile.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/dialect.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/emit.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/fts/mod.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/lib.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/_native.pyi +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/__init__.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/app.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/bootstrap.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/check_schema_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/init.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/inspectdb_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/migrate_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/migrations_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/resetdb_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/revert_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/showmigrations_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/config.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/connection.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/contrib/__init__.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/contrib/fastapi.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/drivers/__init__.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/drivers/mssql.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/drivers/mysql.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/drivers/postgres.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/drivers/protocol.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/drivers/sqlite.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/drivers/streaming.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/echo.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/errors.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/expressions.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/ext/__init__.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/ext/fts.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/ext/pgvector.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/hooks.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/__init__.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/base.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/coordinator.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/fts/__init__.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/fts/mssql.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/fts/mysql.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/fts/postgres.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/fts/sqlite.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/gates.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/introspect.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/ledger.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/loader.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/tokens.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/models.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/observability.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/py.typed +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/queryset.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/registry.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/relations.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/routing.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/runtime.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/session.py +0 -0
|
@@ -223,7 +223,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
|
|
223
223
|
|
|
224
224
|
[[package]]
|
|
225
225
|
name = "ferrum-core"
|
|
226
|
-
version = "0.1.
|
|
226
|
+
version = "0.1.22"
|
|
227
227
|
dependencies = [
|
|
228
228
|
"criterion",
|
|
229
229
|
"proptest",
|
|
@@ -234,7 +234,7 @@ dependencies = [
|
|
|
234
234
|
|
|
235
235
|
[[package]]
|
|
236
236
|
name = "ferrum-migrate"
|
|
237
|
-
version = "0.1.
|
|
237
|
+
version = "0.1.22"
|
|
238
238
|
dependencies = [
|
|
239
239
|
"ferrum-core",
|
|
240
240
|
"serde",
|
|
@@ -243,7 +243,7 @@ dependencies = [
|
|
|
243
243
|
|
|
244
244
|
[[package]]
|
|
245
245
|
name = "ferrum-pyo3"
|
|
246
|
-
version = "0.1.
|
|
246
|
+
version = "0.1.22"
|
|
247
247
|
dependencies = [
|
|
248
248
|
"ferrum-core",
|
|
249
249
|
"ferrum-sql",
|
|
@@ -255,7 +255,7 @@ dependencies = [
|
|
|
255
255
|
|
|
256
256
|
[[package]]
|
|
257
257
|
name = "ferrum-sql"
|
|
258
|
-
version = "0.1.
|
|
258
|
+
version = "0.1.22"
|
|
259
259
|
dependencies = [
|
|
260
260
|
"criterion",
|
|
261
261
|
"ferrum-core",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ferrum-orm
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.22
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Framework :: AsyncIO
|
|
6
6
|
Classifier: Intended Audience :: Developers
|
|
@@ -216,7 +216,23 @@ class Event(Model):
|
|
|
216
216
|
|
|
217
217
|
`makemigrations` honours these field attributes:
|
|
218
218
|
- `db_default` / `nullable` are written into `CREATE TABLE` and `ADD COLUMN` DDL.
|
|
219
|
+
- A plain Python default also becomes the column `DEFAULT` when it has a safe SQL
|
|
220
|
+
literal form: `bool`, `""`, and finite `int` / `float` / `Decimal` numbers (e.g.
|
|
221
|
+
`int = 14` → `DEFAULT 14`, `float = 1e-06` → `DEFAULT 1e-06`). Other defaults
|
|
222
|
+
(non-empty strings, dates, objects) are applied by Python on insert only. A
|
|
223
|
+
non-finite number (`inf`, `nan`) fails `makemigrations` with `FERR-M001`.
|
|
219
224
|
- `db_index=True` emits `CREATE INDEX` after the table op.
|
|
225
|
+
- A `ForeignKey` / `OneToOne` references the target model's primary-key column, so
|
|
226
|
+
natural keys work (e.g. `REFERENCES catalog_version(version)`). A target with a
|
|
227
|
+
composite primary key raises `FerrumSchemaError`, because a single-column foreign
|
|
228
|
+
key cannot reference it.
|
|
229
|
+
- Every `AddForeignKey` comes after all `CreateTable` ops in the same migration, so
|
|
230
|
+
model order, self-references and cycles do not matter.
|
|
231
|
+
- Several `primary_key=True` fields produce
|
|
232
|
+
`CreateTable(..., composite_pk_columns=[...])` → `PRIMARY KEY (a, b, ...)`.
|
|
233
|
+
- A `Literal[...]` field produces `ops.Column(..., enum_values=[...])` →
|
|
234
|
+
`CHECK (col IN (...))`, with each value escaped.
|
|
235
|
+
- `reverse_operations` undo in reverse order, so foreign keys drop before tables.
|
|
220
236
|
- For **already-existing tables**, `makemigrations` now **autodiffs** index and column
|
|
221
237
|
attribute changes against the state projected from prior migration files:
|
|
222
238
|
- Adding `db_index=True` to an existing field → `AddIndex` op.
|
|
@@ -144,7 +144,23 @@ class Event(Model):
|
|
|
144
144
|
|
|
145
145
|
`makemigrations` honours these field attributes:
|
|
146
146
|
- `db_default` / `nullable` are written into `CREATE TABLE` and `ADD COLUMN` DDL.
|
|
147
|
+
- A plain Python default also becomes the column `DEFAULT` when it has a safe SQL
|
|
148
|
+
literal form: `bool`, `""`, and finite `int` / `float` / `Decimal` numbers (e.g.
|
|
149
|
+
`int = 14` → `DEFAULT 14`, `float = 1e-06` → `DEFAULT 1e-06`). Other defaults
|
|
150
|
+
(non-empty strings, dates, objects) are applied by Python on insert only. A
|
|
151
|
+
non-finite number (`inf`, `nan`) fails `makemigrations` with `FERR-M001`.
|
|
147
152
|
- `db_index=True` emits `CREATE INDEX` after the table op.
|
|
153
|
+
- A `ForeignKey` / `OneToOne` references the target model's primary-key column, so
|
|
154
|
+
natural keys work (e.g. `REFERENCES catalog_version(version)`). A target with a
|
|
155
|
+
composite primary key raises `FerrumSchemaError`, because a single-column foreign
|
|
156
|
+
key cannot reference it.
|
|
157
|
+
- Every `AddForeignKey` comes after all `CreateTable` ops in the same migration, so
|
|
158
|
+
model order, self-references and cycles do not matter.
|
|
159
|
+
- Several `primary_key=True` fields produce
|
|
160
|
+
`CreateTable(..., composite_pk_columns=[...])` → `PRIMARY KEY (a, b, ...)`.
|
|
161
|
+
- A `Literal[...]` field produces `ops.Column(..., enum_values=[...])` →
|
|
162
|
+
`CHECK (col IN (...))`, with each value escaped.
|
|
163
|
+
- `reverse_operations` undo in reverse order, so foreign keys drop before tables.
|
|
148
164
|
- For **already-existing tables**, `makemigrations` now **autodiffs** index and column
|
|
149
165
|
attribute changes against the state projected from prior migration files:
|
|
150
166
|
- Adding `db_index=True` to an existing field → `AddIndex` op.
|
|
@@ -22,6 +22,7 @@ from rich import print as rprint
|
|
|
22
22
|
if TYPE_CHECKING:
|
|
23
23
|
from ferrum.models import Model
|
|
24
24
|
|
|
25
|
+
from ferrum.errors import FerrumError
|
|
25
26
|
from ferrum.migrations import loader
|
|
26
27
|
from ferrum.migrations.orchestrator import ColumnState, IndexState, SchemaState, compute_plan
|
|
27
28
|
|
|
@@ -142,6 +143,8 @@ def _col_source(col: dict[str, Any]) -> str:
|
|
|
142
143
|
flags.append("primary_key=True")
|
|
143
144
|
if col.get("default") is not None:
|
|
144
145
|
flags.append(f"default={col['default']!r}")
|
|
146
|
+
if col.get("enum_values"):
|
|
147
|
+
flags.append(f"enum_values={list(col['enum_values'])!r}")
|
|
145
148
|
extra = (", " + ", ".join(flags)) if flags else ""
|
|
146
149
|
return f"ops.Column({col['name']!r}, {col['sql_type']!r}{extra})"
|
|
147
150
|
|
|
@@ -179,10 +182,12 @@ def _op_to_source(op: dict[str, Any]) -> str:
|
|
|
179
182
|
if kind == "create_table":
|
|
180
183
|
table = op["table"]
|
|
181
184
|
col_lines = [f" {_col_source(c)}," for c in op.get("columns", [])]
|
|
185
|
+
composite = op.get("composite_pk_columns")
|
|
186
|
+
pk_arg = f", composite_pk_columns={list(composite)!r}" if composite else ""
|
|
182
187
|
if col_lines:
|
|
183
188
|
cols_block = "\n".join(col_lines)
|
|
184
|
-
return f" ops.CreateTable({table!r}, [\n{cols_block}\n ]),"
|
|
185
|
-
return f" ops.CreateTable({table!r}, []),"
|
|
189
|
+
return f" ops.CreateTable({table!r}, [\n{cols_block}\n ]{pk_arg}),"
|
|
190
|
+
return f" ops.CreateTable({table!r}, []{pk_arg}),"
|
|
186
191
|
|
|
187
192
|
if kind == "add_column":
|
|
188
193
|
table = op["table"]
|
|
@@ -192,6 +197,7 @@ def _op_to_source(op: dict[str, Any]) -> str:
|
|
|
192
197
|
"not_null": op.get("not_null", False),
|
|
193
198
|
"default": op.get("default"),
|
|
194
199
|
"primary_key": op.get("primary_key", False),
|
|
200
|
+
"enum_values": op.get("enum_values"),
|
|
195
201
|
}
|
|
196
202
|
return f" ops.AddColumn({table!r}, {_col_source(col)}),"
|
|
197
203
|
|
|
@@ -260,7 +266,8 @@ def _build_reverse_block(ops: list[dict[str, Any]]) -> str:
|
|
|
260
266
|
If any op has no known reverse, the block uses the irreversible-comment form
|
|
261
267
|
with an empty list so the developer can fill it in manually.
|
|
262
268
|
"""
|
|
263
|
-
|
|
269
|
+
# Undo in reverse order: FKs drop before the tables they reference.
|
|
270
|
+
rev_lines = [_reverse_op_to_source(op) for op in reversed(ops)]
|
|
264
271
|
has_irreversible = any(line is None for line in rev_lines)
|
|
265
272
|
if has_irreversible:
|
|
266
273
|
return (
|
|
@@ -302,7 +309,11 @@ async def run_makemigrations(
|
|
|
302
309
|
existing_state = _build_existing_state(existing_migrations)
|
|
303
310
|
model_classes = _get_all_model_subclasses()
|
|
304
311
|
|
|
305
|
-
|
|
312
|
+
try:
|
|
313
|
+
plan = compute_plan(model_classes, existing_state)
|
|
314
|
+
except FerrumError as exc:
|
|
315
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
316
|
+
return 2
|
|
306
317
|
ops = plan.get("ops", [])
|
|
307
318
|
|
|
308
319
|
if not ops:
|
|
@@ -52,6 +52,7 @@ from ferrum.migrations.orchestrator import (
|
|
|
52
52
|
IndexState,
|
|
53
53
|
SchemaState,
|
|
54
54
|
_is_op_destructive,
|
|
55
|
+
_resolve_fk_target,
|
|
55
56
|
compute_plan,
|
|
56
57
|
)
|
|
57
58
|
|
|
@@ -516,13 +517,12 @@ def compute_autodiff_plan(
|
|
|
516
517
|
for rel in metadata.relations:
|
|
517
518
|
if rel.kind in ("fk", "one_to_one") and rel.db_column:
|
|
518
519
|
constraint_name = f"fk_{table}_{rel.db_column}"
|
|
519
|
-
|
|
520
|
-
target_table = target_meta.table_name if target_meta else rel.to_model.lower()
|
|
520
|
+
ref_table, ref_column = _resolve_fk_target(rel, table, meta_by_name)
|
|
521
521
|
desired_fks[constraint_name] = ForeignKeyState(
|
|
522
522
|
table=table,
|
|
523
523
|
column=rel.db_column,
|
|
524
|
-
ref_table=
|
|
525
|
-
ref_column=
|
|
524
|
+
ref_table=ref_table,
|
|
525
|
+
ref_column=ref_column,
|
|
526
526
|
on_delete=rel.on_delete or "CASCADE",
|
|
527
527
|
)
|
|
528
528
|
existing_fks = {
|
|
@@ -17,10 +17,13 @@ Security invariants:
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import json
|
|
20
|
+
import re
|
|
20
21
|
from collections.abc import Iterable, Mapping
|
|
21
22
|
from dataclasses import dataclass, field
|
|
22
23
|
from typing import TYPE_CHECKING, Any
|
|
23
24
|
|
|
25
|
+
from ferrum.errors import FerrumSchemaError
|
|
26
|
+
from ferrum.migrations.orchestrator import _resolve_fk_target
|
|
24
27
|
from ferrum.registry import all_models
|
|
25
28
|
|
|
26
29
|
if TYPE_CHECKING:
|
|
@@ -1068,21 +1071,29 @@ def _expected_constraints(metadata: ModelMetadata) -> list[dict[str, Any]]:
|
|
|
1068
1071
|
"definition": f"UNIQUE ({field_meta.column_name})",
|
|
1069
1072
|
}
|
|
1070
1073
|
)
|
|
1071
|
-
# FK constraints from declared relations.
|
|
1074
|
+
# FK constraints from declared relations. The name and target mirror what
|
|
1075
|
+
# makemigrations emits (``fk_<table>_<column>``, target primary key).
|
|
1076
|
+
meta_by_name = {name: model.get_metadata() for name, model in all_models().items()}
|
|
1072
1077
|
for relation in metadata.relations:
|
|
1073
1078
|
if relation.kind in ("fk", "one_to_one") and relation.db_column:
|
|
1079
|
+
try:
|
|
1080
|
+
ref_table, ref_column = _resolve_fk_target(
|
|
1081
|
+
relation, metadata.table_name, meta_by_name
|
|
1082
|
+
)
|
|
1083
|
+
except FerrumSchemaError:
|
|
1084
|
+
ref_table, ref_column = relation.to_model, "?"
|
|
1085
|
+
delete_rule = (relation.on_delete or "NO ACTION").upper()
|
|
1074
1086
|
expected.append(
|
|
1075
1087
|
{
|
|
1076
|
-
"name": f"{metadata.table_name}_{relation.db_column}
|
|
1088
|
+
"name": f"fk_{metadata.table_name}_{relation.db_column}",
|
|
1077
1089
|
"type": "FOREIGN KEY",
|
|
1078
1090
|
"column": relation.db_column,
|
|
1079
|
-
"foreign_table":
|
|
1080
|
-
"foreign_column":
|
|
1081
|
-
"delete_rule":
|
|
1091
|
+
"foreign_table": ref_table,
|
|
1092
|
+
"foreign_column": ref_column,
|
|
1093
|
+
"delete_rule": delete_rule,
|
|
1082
1094
|
"definition": (
|
|
1083
1095
|
f"FOREIGN KEY ({relation.db_column}) REFERENCES "
|
|
1084
|
-
f"{
|
|
1085
|
-
f"{(relation.on_delete or 'NO ACTION').upper()}"
|
|
1096
|
+
f"{ref_table}({ref_column}) ON DELETE {delete_rule}"
|
|
1086
1097
|
),
|
|
1087
1098
|
}
|
|
1088
1099
|
)
|
|
@@ -1101,6 +1112,31 @@ def _expected_constraints(metadata: ModelMetadata) -> list[dict[str, Any]]:
|
|
|
1101
1112
|
return expected
|
|
1102
1113
|
|
|
1103
1114
|
|
|
1115
|
+
_CHECK_IN_RE = re.compile(
|
|
1116
|
+
r"^CHECK\s*\(\s*\"?(\w+)\"?\s+IN\s*\((.*)\)\s*\)$", re.IGNORECASE | re.DOTALL
|
|
1117
|
+
)
|
|
1118
|
+
_CHECK_ANY_RE = re.compile(
|
|
1119
|
+
r"^CHECK\s*\(\s*\(\s*\"?(\w+)\"?\s*=\s*ANY\s*\(\s*ARRAY\s*\[(.*)\]\s*\)\s*\)\s*\)$",
|
|
1120
|
+
re.IGNORECASE | re.DOTALL,
|
|
1121
|
+
)
|
|
1122
|
+
_CHECK_LITERAL_RE = re.compile(r"'((?:[^']|'')*)'(?:::[\w ]+)?")
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def _canonical_check(definition: str) -> str:
|
|
1126
|
+
"""Normalize an enum CHECK so model and live forms compare equal.
|
|
1127
|
+
|
|
1128
|
+
PostgreSQL stores ``col IN ('a', 'b')`` as
|
|
1129
|
+
``((col = ANY (ARRAY['a'::text, 'b'::text])))``. Both reduce to
|
|
1130
|
+
``CHECK (COL IN ('a', 'b'))``; other definitions are only uppercased.
|
|
1131
|
+
"""
|
|
1132
|
+
text = definition.strip()
|
|
1133
|
+
match = _CHECK_IN_RE.match(text) or _CHECK_ANY_RE.match(text)
|
|
1134
|
+
if match is None:
|
|
1135
|
+
return text.upper()
|
|
1136
|
+
values = _CHECK_LITERAL_RE.findall(match.group(2))
|
|
1137
|
+
return f"CHECK ({match.group(1).upper()} IN ({', '.join(repr(v) for v in values)}))"
|
|
1138
|
+
|
|
1139
|
+
|
|
1104
1140
|
def _compare_constraints(
|
|
1105
1141
|
table: str,
|
|
1106
1142
|
expected: list[dict[str, Any]],
|
|
@@ -1110,6 +1146,17 @@ def _compare_constraints(
|
|
|
1110
1146
|
actual_by_name = {str(a["name"]): a for a in actual}
|
|
1111
1147
|
expected_by_name = {str(e["name"]): e for e in expected}
|
|
1112
1148
|
|
|
1149
|
+
# A live FK on the expected column satisfies the expectation under any
|
|
1150
|
+
# name (e.g. PostgreSQL's default ``<table>_<column>_fkey``).
|
|
1151
|
+
actual_fk_by_column = {
|
|
1152
|
+
str(a.get("column")): str(a["name"]) for a in actual if a["type"] == "FOREIGN KEY"
|
|
1153
|
+
}
|
|
1154
|
+
for name, expected_c in list(expected_by_name.items()):
|
|
1155
|
+
if expected_c["type"] == "FOREIGN KEY" and name not in actual_by_name:
|
|
1156
|
+
live_name = actual_fk_by_column.get(str(expected_c["column"]))
|
|
1157
|
+
if live_name is not None and live_name not in expected_by_name:
|
|
1158
|
+
expected_by_name[live_name] = expected_by_name.pop(name)
|
|
1159
|
+
|
|
1113
1160
|
for name, expected_c in expected_by_name.items():
|
|
1114
1161
|
if name not in actual_by_name:
|
|
1115
1162
|
diffs.append(
|
|
@@ -1142,8 +1189,8 @@ def _compare_constraints(
|
|
|
1142
1189
|
)
|
|
1143
1190
|
# For CHECK constraints, compare the normalized definition.
|
|
1144
1191
|
if expected_c["type"] == "CHECK":
|
|
1145
|
-
expected_def = str(expected_c.get("definition") or "")
|
|
1146
|
-
actual_def = str(actual_c.get("definition") or "")
|
|
1192
|
+
expected_def = _canonical_check(str(expected_c.get("definition") or ""))
|
|
1193
|
+
actual_def = _canonical_check(str(actual_c.get("definition") or ""))
|
|
1147
1194
|
if expected_def and actual_def and expected_def != actual_def:
|
|
1148
1195
|
diffs.append(
|
|
1149
1196
|
ConstraintDifference(
|
|
@@ -41,22 +41,33 @@ class Column:
|
|
|
41
41
|
not_null: bool = False,
|
|
42
42
|
default: str | None = None,
|
|
43
43
|
primary_key: bool = False,
|
|
44
|
+
enum_values: list[str] | None = None,
|
|
44
45
|
) -> None:
|
|
46
|
+
"""Describe one column.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
enum_values: Allowed values of a ``Literal[...]`` field. The DDL
|
|
50
|
+
adds ``CHECK (<column> IN (...))`` with each value escaped.
|
|
51
|
+
"""
|
|
45
52
|
self.name = name
|
|
46
53
|
self.sql_type = sql_type
|
|
47
54
|
self.not_null = not_null
|
|
48
55
|
self.default = default
|
|
49
56
|
self.primary_key = primary_key
|
|
57
|
+
self.enum_values = list(enum_values) if enum_values is not None else None
|
|
50
58
|
|
|
51
59
|
def to_col_dict(self) -> dict[str, Any]:
|
|
52
60
|
"""Return a column-def dict consumed by ``_col_def`` / ``_op_to_sql``."""
|
|
53
|
-
|
|
61
|
+
col: dict[str, Any] = {
|
|
54
62
|
"name": self.name,
|
|
55
63
|
"sql_type": self.sql_type,
|
|
56
64
|
"not_null": self.not_null,
|
|
57
65
|
"default": self.default,
|
|
58
66
|
"primary_key": self.primary_key,
|
|
59
67
|
}
|
|
68
|
+
if self.enum_values:
|
|
69
|
+
col["enum_values"] = list(self.enum_values)
|
|
70
|
+
return col
|
|
60
71
|
|
|
61
72
|
def __repr__(self) -> str:
|
|
62
73
|
flags = []
|
|
@@ -66,6 +77,8 @@ class Column:
|
|
|
66
77
|
flags.append("primary_key=True")
|
|
67
78
|
if self.default is not None:
|
|
68
79
|
flags.append(f"default={self.default!r}")
|
|
80
|
+
if self.enum_values:
|
|
81
|
+
flags.append(f"enum_values={self.enum_values!r}")
|
|
69
82
|
extra = (", " + ", ".join(flags)) if flags else ""
|
|
70
83
|
return f"Column({self.name!r}, {self.sql_type!r}{extra})"
|
|
71
84
|
|
|
@@ -101,7 +114,12 @@ class CreateTable(Operation):
|
|
|
101
114
|
return "safe"
|
|
102
115
|
|
|
103
116
|
def __repr__(self) -> str:
|
|
104
|
-
|
|
117
|
+
extra = (
|
|
118
|
+
f", composite_pk_columns={self.composite_pk_columns!r}"
|
|
119
|
+
if self.composite_pk_columns
|
|
120
|
+
else ""
|
|
121
|
+
)
|
|
122
|
+
return f"CreateTable({self.table_name!r}, {self.columns!r}{extra})"
|
|
105
123
|
|
|
106
124
|
|
|
107
125
|
class DropTable(Operation):
|
|
@@ -26,10 +26,11 @@ import hashlib
|
|
|
26
26
|
import json
|
|
27
27
|
import re
|
|
28
28
|
from dataclasses import dataclass, field
|
|
29
|
+
from decimal import Decimal
|
|
29
30
|
from enum import Enum
|
|
30
31
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
31
32
|
|
|
32
|
-
from ferrum.errors import FerrumMigrationError
|
|
33
|
+
from ferrum.errors import FerrumMigrationError, FerrumSchemaError
|
|
33
34
|
from ferrum.migrations.ledger import (
|
|
34
35
|
ADVISORY_LOCK_KEY_1,
|
|
35
36
|
ADVISORY_LOCK_KEY_2,
|
|
@@ -44,7 +45,7 @@ from ferrum.migrations.tokens import verify_token
|
|
|
44
45
|
if TYPE_CHECKING:
|
|
45
46
|
from ferrum.connection import Connection, ConnectionLike
|
|
46
47
|
from ferrum.migrations.loader import MigrationModule
|
|
47
|
-
from ferrum.models import FieldMeta, Model, ModelMetadata
|
|
48
|
+
from ferrum.models import FieldMeta, Model, ModelMetadata, RelationMeta
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
# ---------------------------------------------------------------------------
|
|
@@ -274,6 +275,9 @@ _DEFAULT_VALUE_ALLOWLIST: frozenset[str] = frozenset(
|
|
|
274
275
|
)
|
|
275
276
|
|
|
276
277
|
|
|
278
|
+
_NUMERIC_LITERAL_RE = re.compile(r"-?\d+(\.\d+)?([eE][-+]?\d+)?")
|
|
279
|
+
|
|
280
|
+
|
|
277
281
|
def _normalize_column_default(default: Any) -> str: # noqa: ANN401
|
|
278
282
|
"""Normalize a migration default value to its SQL literal token."""
|
|
279
283
|
text = str(default)
|
|
@@ -282,8 +286,31 @@ def _normalize_column_default(default: Any) -> str: # noqa: ANN401
|
|
|
282
286
|
return text
|
|
283
287
|
|
|
284
288
|
|
|
285
|
-
def
|
|
286
|
-
"""
|
|
289
|
+
def _is_allowed_default(token: str) -> bool:
|
|
290
|
+
"""Return whether *token* may appear unquoted after ``DEFAULT`` in DDL.
|
|
291
|
+
|
|
292
|
+
The single rule shared by the generator and the DDL check: an allowlisted
|
|
293
|
+
token, or a plain numeric literal (sign, digits, fraction, exponent only).
|
|
294
|
+
"""
|
|
295
|
+
return token.upper() in _DEFAULT_VALUE_ALLOWLIST or bool(_NUMERIC_LITERAL_RE.fullmatch(token))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _python_default_to_sql(
|
|
299
|
+
*,
|
|
300
|
+
value: Any, # noqa: ANN401
|
|
301
|
+
field_meta: FieldMeta,
|
|
302
|
+
model_name: str,
|
|
303
|
+
) -> str | None:
|
|
304
|
+
"""Map a Python-side field default to an allowed SQL DEFAULT literal, if possible.
|
|
305
|
+
|
|
306
|
+
Returns ``None`` when the default has no DB-side form (the value is then
|
|
307
|
+
applied by Python on insert only).
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
FerrumMigrationError: a numeric default that cannot be written as a SQL
|
|
311
|
+
literal (``inf``, ``nan``), so ``makemigrations`` fails instead of
|
|
312
|
+
``migrate``.
|
|
313
|
+
"""
|
|
287
314
|
if value is None:
|
|
288
315
|
return None
|
|
289
316
|
field_type = field_meta.field_type
|
|
@@ -293,11 +320,24 @@ def _python_default_to_sql(*, value: Any, field_meta: FieldMeta) -> str | None:
|
|
|
293
320
|
return None
|
|
294
321
|
if field_type == "bool" and isinstance(value, bool):
|
|
295
322
|
return "TRUE" if value else "FALSE"
|
|
296
|
-
if
|
|
297
|
-
return
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
323
|
+
if isinstance(value, bool):
|
|
324
|
+
return None
|
|
325
|
+
token: str | None = None
|
|
326
|
+
if (field_type in ("int", "big_int") and isinstance(value, int)) or (
|
|
327
|
+
field_type == "float" and isinstance(value, (int, float))
|
|
328
|
+
):
|
|
329
|
+
token = str(value)
|
|
330
|
+
elif field_type == "decimal" and isinstance(value, (int, Decimal)):
|
|
331
|
+
token = format(value, "f") if isinstance(value, Decimal) else str(value)
|
|
332
|
+
if token is None:
|
|
333
|
+
return None
|
|
334
|
+
if not _is_allowed_default(token):
|
|
335
|
+
raise FerrumMigrationError(
|
|
336
|
+
f"Model {model_name!r} field {field_meta.name!r}: the default is not a "
|
|
337
|
+
"finite number, so it cannot be a SQL DEFAULT. Use a finite default or "
|
|
338
|
+
"set db_default=. [FERR-M001]"
|
|
339
|
+
)
|
|
340
|
+
return token
|
|
301
341
|
|
|
302
342
|
|
|
303
343
|
def _quote_ident(name: str, dialect: str) -> str:
|
|
@@ -472,7 +512,7 @@ def _col_def(col: dict[str, Any], *, dialect: str = "postgres") -> str:
|
|
|
472
512
|
parts.append("NOT NULL")
|
|
473
513
|
if default is not None:
|
|
474
514
|
normalized = _normalize_column_default(default)
|
|
475
|
-
if normalized
|
|
515
|
+
if not _is_allowed_default(normalized):
|
|
476
516
|
raise FerrumMigrationError(
|
|
477
517
|
f"Unsupported DEFAULT value {default!r}. "
|
|
478
518
|
f"Only simple literals are allowed. [FERR-M001]"
|
|
@@ -484,11 +524,22 @@ def _col_def(col: dict[str, Any], *, dialect: str = "postgres") -> str:
|
|
|
484
524
|
parts.append("PRIMARY KEY")
|
|
485
525
|
if col.get("unique"):
|
|
486
526
|
parts.append("UNIQUE")
|
|
487
|
-
|
|
488
|
-
|
|
527
|
+
enum_values = col.get("enum_values")
|
|
528
|
+
if enum_values:
|
|
529
|
+
parts.append(_enum_check_sql(col["name"], enum_values, dialect))
|
|
489
530
|
return " ".join(parts)
|
|
490
531
|
|
|
491
532
|
|
|
533
|
+
def _enum_check_sql(column: str, values: list[str], dialect: str) -> str:
|
|
534
|
+
"""Build ``CHECK (col IN ('a', 'b'))`` for a ``Literal`` field.
|
|
535
|
+
|
|
536
|
+
Values come from the model's ``Literal[...]`` annotation. Each one is
|
|
537
|
+
emitted as an escaped string literal, never interpolated raw.
|
|
538
|
+
"""
|
|
539
|
+
literals = ", ".join("'" + str(v).replace("'", "''") + "'" for v in values)
|
|
540
|
+
return f"CHECK ({_quote_ident(column, dialect)} IN ({literals}))"
|
|
541
|
+
|
|
542
|
+
|
|
492
543
|
def _op_to_sql(op: dict[str, Any], *, dialect: str = "postgres") -> str:
|
|
493
544
|
"""Generate DDL SQL from a MigrationOp dict.
|
|
494
545
|
|
|
@@ -583,7 +634,7 @@ def _op_to_sql(op: dict[str, Any], *, dialect: str = "postgres") -> str:
|
|
|
583
634
|
default = op.get("default")
|
|
584
635
|
if default is not None:
|
|
585
636
|
default_token = _normalize_column_default(default)
|
|
586
|
-
if default_token
|
|
637
|
+
if not _is_allowed_default(default_token):
|
|
587
638
|
raise FerrumMigrationError(
|
|
588
639
|
f"Default value {default!r} is not in the migration allowlist. [FERR-M001]"
|
|
589
640
|
)
|
|
@@ -842,7 +893,7 @@ def _index_columns_sql(
|
|
|
842
893
|
return ", ".join(parts)
|
|
843
894
|
|
|
844
895
|
|
|
845
|
-
def _field_to_col_def(field_meta: FieldMeta, *, is_pk: bool) -> dict[str, Any]:
|
|
896
|
+
def _field_to_col_def(field_meta: FieldMeta, *, is_pk: bool, model_name: str) -> dict[str, Any]:
|
|
846
897
|
"""Convert a ``FieldMeta`` to a column-def dict for the plan JSON.
|
|
847
898
|
|
|
848
899
|
Security: all names come from model-metadata allowlists — never user input.
|
|
@@ -859,8 +910,10 @@ def _field_to_col_def(field_meta: FieldMeta, *, is_pk: bool) -> dict[str, Any]:
|
|
|
859
910
|
# A Python-side default on a serial PK (e.g. ``id: int = 0``) is an
|
|
860
911
|
# insert-time value, not a DB default: the sequence owns the DB default.
|
|
861
912
|
if default is None and sql_type.upper() not in _SERIAL_SQL_TYPES:
|
|
862
|
-
default = _python_default_to_sql(
|
|
863
|
-
|
|
913
|
+
default = _python_default_to_sql(
|
|
914
|
+
value=field_meta.python_default, field_meta=field_meta, model_name=model_name
|
|
915
|
+
)
|
|
916
|
+
col_def: dict[str, Any] = {
|
|
864
917
|
"name": field_meta.column_name,
|
|
865
918
|
"sql_type": sql_type,
|
|
866
919
|
"not_null": not field_meta.nullable,
|
|
@@ -868,6 +921,37 @@ def _field_to_col_def(field_meta: FieldMeta, *, is_pk: bool) -> dict[str, Any]:
|
|
|
868
921
|
"primary_key": is_pk,
|
|
869
922
|
"unique": field_meta.unique,
|
|
870
923
|
}
|
|
924
|
+
if field_meta.field_type == "enum" and field_meta.enum_values:
|
|
925
|
+
col_def["enum_values"] = list(field_meta.enum_values)
|
|
926
|
+
return col_def
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _resolve_fk_target(
|
|
930
|
+
rel: RelationMeta,
|
|
931
|
+
owner_table: str,
|
|
932
|
+
meta_by_name: dict[str, ModelMetadata],
|
|
933
|
+
) -> tuple[str, str]:
|
|
934
|
+
"""Return ``(ref_table, ref_column)`` for an FK / one-to-one relation.
|
|
935
|
+
|
|
936
|
+
The referenced column is the target model's primary-key column. A target
|
|
937
|
+
outside the planned model set keeps the legacy ``<model>.id`` fallback.
|
|
938
|
+
|
|
939
|
+
Raises:
|
|
940
|
+
FerrumSchemaError: the target has a composite primary key, which a
|
|
941
|
+
single-column foreign key cannot reference.
|
|
942
|
+
"""
|
|
943
|
+
target_meta = meta_by_name.get(rel.to_model)
|
|
944
|
+
if target_meta is None:
|
|
945
|
+
return rel.to_model.lower(), "id"
|
|
946
|
+
pk_columns = [target_meta.fields[i].column_name for i in target_meta.pk_fields]
|
|
947
|
+
if len(pk_columns) != 1:
|
|
948
|
+
raise FerrumSchemaError(
|
|
949
|
+
f"Foreign key {owner_table}.{rel.db_column} references {rel.to_model}, "
|
|
950
|
+
f"which has a composite primary key ({', '.join(pk_columns)}). A "
|
|
951
|
+
"single-column foreign key cannot reference it. [FERR-S001]",
|
|
952
|
+
model=rel.to_model,
|
|
953
|
+
)
|
|
954
|
+
return target_meta.table_name, pk_columns[0]
|
|
871
955
|
|
|
872
956
|
|
|
873
957
|
def _autodiff_existing_table(
|
|
@@ -885,7 +969,7 @@ def _autodiff_existing_table(
|
|
|
885
969
|
|
|
886
970
|
Security: all identifiers come from model metadata allowlists or
|
|
887
971
|
``SchemaState`` that was itself built from prior migration ops — never user
|
|
888
|
-
input. Default values are checked
|
|
972
|
+
input. Default values are checked by ``_is_allowed_default`` in
|
|
889
973
|
``_op_to_sql`` when the plan is applied.
|
|
890
974
|
|
|
891
975
|
Out of scope: sql_type changes, renames, drops (unchanged v0.1 rule).
|
|
@@ -947,7 +1031,11 @@ def _autodiff_existing_table(
|
|
|
947
1031
|
col_state = existing_col_attrs[f.column_name]
|
|
948
1032
|
|
|
949
1033
|
desired_not_null = not f.nullable
|
|
950
|
-
|
|
1034
|
+
# Same default the generator writes for a new column (db_default, else
|
|
1035
|
+
# the Python default when it has a SQL literal form).
|
|
1036
|
+
desired_default = _field_to_col_def(f, is_pk=f.pk, model_name=metadata.model_name)[
|
|
1037
|
+
"default"
|
|
1038
|
+
]
|
|
951
1039
|
|
|
952
1040
|
# Compare case-insensitively to handle legacy lowercase defaults in state.
|
|
953
1041
|
state_default_upper = col_state.default.upper() if col_state.default else None
|
|
@@ -1051,6 +1139,7 @@ def compute_plan(
|
|
|
1051
1139
|
# Track M2M join tables already emitted to avoid duplicate CREATE TABLE ops
|
|
1052
1140
|
# when both sides of the relationship appear in model_classes.
|
|
1053
1141
|
emitted_m2m_tables: set[str] = set()
|
|
1142
|
+
fk_ops: list[dict[str, Any]] = []
|
|
1054
1143
|
|
|
1055
1144
|
for cls in model_classes:
|
|
1056
1145
|
# ModelMetadata is built once at class-definition time and is read-only.
|
|
@@ -1066,16 +1155,9 @@ def compute_plan(
|
|
|
1066
1155
|
# _col_def skips the inline PRIMARY KEY (the constraint is table-level).
|
|
1067
1156
|
col_defs: list[dict[str, Any]] = []
|
|
1068
1157
|
for f in metadata.fields:
|
|
1069
|
-
cd = _field_to_col_def(f, is_pk=f.pk)
|
|
1158
|
+
cd = _field_to_col_def(f, is_pk=f.pk, model_name=metadata.model_name)
|
|
1070
1159
|
if is_composite_pk and f.pk:
|
|
1071
1160
|
cd = {**cd, "primary_key": False, "composite_pk": True}
|
|
1072
|
-
# Enum columns get an inline CHECK constraint in the DDL.
|
|
1073
|
-
if f.field_type == "enum" and f.enum_values:
|
|
1074
|
-
quoted_vals = ", ".join(f"'{v}'" for v in f.enum_values)
|
|
1075
|
-
# Store check expression; _col_def doesn't handle it, so we embed
|
|
1076
|
-
# it as a suffix directly on the column entry via a post-pass.
|
|
1077
|
-
check = f"CHECK ({_quote_ident(f.column_name, 'postgres')} IN ({quoted_vals}))"
|
|
1078
|
-
cd = {**cd, "enum_check": check}
|
|
1079
1161
|
col_defs.append(cd)
|
|
1080
1162
|
|
|
1081
1163
|
create_op: dict[str, Any] = {
|
|
@@ -1118,20 +1200,20 @@ def compute_plan(
|
|
|
1118
1200
|
index_op["opclasses"] = opclasses
|
|
1119
1201
|
ops.append(index_op)
|
|
1120
1202
|
|
|
1121
|
-
#
|
|
1203
|
+
# AddForeignKey ops for new tables are deferred until every table
|
|
1204
|
+
# exists, so the target may be declared later, be the table itself,
|
|
1205
|
+
# or form a cycle.
|
|
1122
1206
|
for rel in metadata.relations:
|
|
1123
1207
|
if rel.kind in ("fk", "one_to_one"):
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
constraint_name = f"fk_{table}_{rel.db_column}"
|
|
1127
|
-
ops.append(
|
|
1208
|
+
ref_table, ref_column = _resolve_fk_target(rel, table, meta_by_name)
|
|
1209
|
+
fk_ops.append(
|
|
1128
1210
|
{
|
|
1129
1211
|
"kind": "add_fk",
|
|
1130
1212
|
"table": table,
|
|
1131
|
-
"name":
|
|
1213
|
+
"name": f"fk_{table}_{rel.db_column}",
|
|
1132
1214
|
"column": rel.db_column,
|
|
1133
|
-
"ref_table":
|
|
1134
|
-
"ref_column":
|
|
1215
|
+
"ref_table": ref_table,
|
|
1216
|
+
"ref_column": ref_column,
|
|
1135
1217
|
"on_delete": rel.on_delete or "CASCADE",
|
|
1136
1218
|
}
|
|
1137
1219
|
)
|
|
@@ -1174,7 +1256,7 @@ def compute_plan(
|
|
|
1174
1256
|
],
|
|
1175
1257
|
}
|
|
1176
1258
|
)
|
|
1177
|
-
|
|
1259
|
+
fk_ops.append(
|
|
1178
1260
|
{
|
|
1179
1261
|
"kind": "add_fk",
|
|
1180
1262
|
"table": through,
|
|
@@ -1185,7 +1267,7 @@ def compute_plan(
|
|
|
1185
1267
|
"on_delete": "CASCADE",
|
|
1186
1268
|
}
|
|
1187
1269
|
)
|
|
1188
|
-
|
|
1270
|
+
fk_ops.append(
|
|
1189
1271
|
{
|
|
1190
1272
|
"kind": "add_fk",
|
|
1191
1273
|
"table": through,
|
|
@@ -1204,7 +1286,7 @@ def compute_plan(
|
|
|
1204
1286
|
|
|
1205
1287
|
for f in metadata.fields:
|
|
1206
1288
|
if f.column_name not in existing_col_names:
|
|
1207
|
-
col_def = _field_to_col_def(f, is_pk=False)
|
|
1289
|
+
col_def = _field_to_col_def(f, is_pk=False, model_name=metadata.model_name)
|
|
1208
1290
|
ops.append(
|
|
1209
1291
|
{
|
|
1210
1292
|
"kind": "add_column",
|
|
@@ -1235,6 +1317,7 @@ def compute_plan(
|
|
|
1235
1317
|
schema_state.indexes,
|
|
1236
1318
|
)
|
|
1237
1319
|
|
|
1320
|
+
ops.extend(fk_ops)
|
|
1238
1321
|
return {
|
|
1239
1322
|
"version": 1,
|
|
1240
1323
|
"name": f"auto_{timestamp}",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|