ferrum-orm 0.1.20__tar.gz → 0.1.21__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.21}/Cargo.lock +4 -4
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/Cargo.toml +1 -1
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/PKG-INFO +12 -1
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/README.md +11 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/pyproject.toml +1 -1
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/__init__.py +1 -1
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/makemigrations_cmd.py +9 -3
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/autodiff.py +4 -4
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/drift.py +56 -9
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/operations.py +20 -2
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/orchestrator.py +59 -22
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/LICENSE +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/Cargo.toml +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/benches/hydrate.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/src/compile/mod.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/src/error.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/src/ir/metadata.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/src/ir/mod.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/src/lib.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/src/migrate/mod.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-pyo3/Cargo.toml +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-pyo3/src/lib.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/Cargo.toml +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/benches/compile.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/dialect.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/emit.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/fts/mod.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/lib.rs +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/_native.pyi +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/__init__.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/app.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/bootstrap.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/check_schema_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/init.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/inspectdb_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/migrate_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/migrations_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/resetdb_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/revert_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/showmigrations_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/config.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/connection.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/contrib/__init__.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/contrib/fastapi.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/drivers/__init__.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/drivers/mssql.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/drivers/mysql.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/drivers/postgres.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/drivers/protocol.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/drivers/sqlite.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/drivers/streaming.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/echo.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/errors.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/expressions.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/ext/__init__.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/ext/fts.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/ext/pgvector.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/hooks.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/__init__.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/base.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/coordinator.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/fts/__init__.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/fts/mssql.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/fts/mysql.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/fts/postgres.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/fts/sqlite.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/gates.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/introspect.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/ledger.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/loader.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/tokens.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/models.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/observability.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/py.typed +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/queryset.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/registry.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/relations.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/routing.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/runtime.py +0 -0
- {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/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.21"
|
|
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.21"
|
|
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.21"
|
|
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.21"
|
|
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.21
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Framework :: AsyncIO
|
|
6
6
|
Classifier: Intended Audience :: Developers
|
|
@@ -217,6 +217,17 @@ class Event(Model):
|
|
|
217
217
|
`makemigrations` honours these field attributes:
|
|
218
218
|
- `db_default` / `nullable` are written into `CREATE TABLE` and `ADD COLUMN` DDL.
|
|
219
219
|
- `db_index=True` emits `CREATE INDEX` after the table op.
|
|
220
|
+
- A `ForeignKey` / `OneToOne` references the target model's primary-key column, so
|
|
221
|
+
natural keys work (e.g. `REFERENCES catalog_version(version)`). A target with a
|
|
222
|
+
composite primary key raises `FerrumSchemaError`, because a single-column foreign
|
|
223
|
+
key cannot reference it.
|
|
224
|
+
- Every `AddForeignKey` comes after all `CreateTable` ops in the same migration, so
|
|
225
|
+
model order, self-references and cycles do not matter.
|
|
226
|
+
- Several `primary_key=True` fields produce
|
|
227
|
+
`CreateTable(..., composite_pk_columns=[...])` → `PRIMARY KEY (a, b, ...)`.
|
|
228
|
+
- A `Literal[...]` field produces `ops.Column(..., enum_values=[...])` →
|
|
229
|
+
`CHECK (col IN (...))`, with each value escaped.
|
|
230
|
+
- `reverse_operations` undo in reverse order, so foreign keys drop before tables.
|
|
220
231
|
- For **already-existing tables**, `makemigrations` now **autodiffs** index and column
|
|
221
232
|
attribute changes against the state projected from prior migration files:
|
|
222
233
|
- Adding `db_index=True` to an existing field → `AddIndex` op.
|
|
@@ -145,6 +145,17 @@ class Event(Model):
|
|
|
145
145
|
`makemigrations` honours these field attributes:
|
|
146
146
|
- `db_default` / `nullable` are written into `CREATE TABLE` and `ADD COLUMN` DDL.
|
|
147
147
|
- `db_index=True` emits `CREATE INDEX` after the table op.
|
|
148
|
+
- A `ForeignKey` / `OneToOne` references the target model's primary-key column, so
|
|
149
|
+
natural keys work (e.g. `REFERENCES catalog_version(version)`). A target with a
|
|
150
|
+
composite primary key raises `FerrumSchemaError`, because a single-column foreign
|
|
151
|
+
key cannot reference it.
|
|
152
|
+
- Every `AddForeignKey` comes after all `CreateTable` ops in the same migration, so
|
|
153
|
+
model order, self-references and cycles do not matter.
|
|
154
|
+
- Several `primary_key=True` fields produce
|
|
155
|
+
`CreateTable(..., composite_pk_columns=[...])` → `PRIMARY KEY (a, b, ...)`.
|
|
156
|
+
- A `Literal[...]` field produces `ops.Column(..., enum_values=[...])` →
|
|
157
|
+
`CHECK (col IN (...))`, with each value escaped.
|
|
158
|
+
- `reverse_operations` undo in reverse order, so foreign keys drop before tables.
|
|
148
159
|
- For **already-existing tables**, `makemigrations` now **autodiffs** index and column
|
|
149
160
|
attribute changes against the state projected from prior migration files:
|
|
150
161
|
- Adding `db_index=True` to an existing field → `AddIndex` op.
|
|
@@ -142,6 +142,8 @@ def _col_source(col: dict[str, Any]) -> str:
|
|
|
142
142
|
flags.append("primary_key=True")
|
|
143
143
|
if col.get("default") is not None:
|
|
144
144
|
flags.append(f"default={col['default']!r}")
|
|
145
|
+
if col.get("enum_values"):
|
|
146
|
+
flags.append(f"enum_values={list(col['enum_values'])!r}")
|
|
145
147
|
extra = (", " + ", ".join(flags)) if flags else ""
|
|
146
148
|
return f"ops.Column({col['name']!r}, {col['sql_type']!r}{extra})"
|
|
147
149
|
|
|
@@ -179,10 +181,12 @@ def _op_to_source(op: dict[str, Any]) -> str:
|
|
|
179
181
|
if kind == "create_table":
|
|
180
182
|
table = op["table"]
|
|
181
183
|
col_lines = [f" {_col_source(c)}," for c in op.get("columns", [])]
|
|
184
|
+
composite = op.get("composite_pk_columns")
|
|
185
|
+
pk_arg = f", composite_pk_columns={list(composite)!r}" if composite else ""
|
|
182
186
|
if col_lines:
|
|
183
187
|
cols_block = "\n".join(col_lines)
|
|
184
|
-
return f" ops.CreateTable({table!r}, [\n{cols_block}\n ]),"
|
|
185
|
-
return f" ops.CreateTable({table!r}, []),"
|
|
188
|
+
return f" ops.CreateTable({table!r}, [\n{cols_block}\n ]{pk_arg}),"
|
|
189
|
+
return f" ops.CreateTable({table!r}, []{pk_arg}),"
|
|
186
190
|
|
|
187
191
|
if kind == "add_column":
|
|
188
192
|
table = op["table"]
|
|
@@ -192,6 +196,7 @@ def _op_to_source(op: dict[str, Any]) -> str:
|
|
|
192
196
|
"not_null": op.get("not_null", False),
|
|
193
197
|
"default": op.get("default"),
|
|
194
198
|
"primary_key": op.get("primary_key", False),
|
|
199
|
+
"enum_values": op.get("enum_values"),
|
|
195
200
|
}
|
|
196
201
|
return f" ops.AddColumn({table!r}, {_col_source(col)}),"
|
|
197
202
|
|
|
@@ -260,7 +265,8 @@ def _build_reverse_block(ops: list[dict[str, Any]]) -> str:
|
|
|
260
265
|
If any op has no known reverse, the block uses the irreversible-comment form
|
|
261
266
|
with an empty list so the developer can fill it in manually.
|
|
262
267
|
"""
|
|
263
|
-
|
|
268
|
+
# Undo in reverse order: FKs drop before the tables they reference.
|
|
269
|
+
rev_lines = [_reverse_op_to_source(op) for op in reversed(ops)]
|
|
264
270
|
has_irreversible = any(line is None for line in rev_lines)
|
|
265
271
|
if has_irreversible:
|
|
266
272
|
return (
|
|
@@ -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):
|
|
@@ -29,7 +29,7 @@ from dataclasses import dataclass, field
|
|
|
29
29
|
from enum import Enum
|
|
30
30
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
31
31
|
|
|
32
|
-
from ferrum.errors import FerrumMigrationError
|
|
32
|
+
from ferrum.errors import FerrumMigrationError, FerrumSchemaError
|
|
33
33
|
from ferrum.migrations.ledger import (
|
|
34
34
|
ADVISORY_LOCK_KEY_1,
|
|
35
35
|
ADVISORY_LOCK_KEY_2,
|
|
@@ -44,7 +44,7 @@ from ferrum.migrations.tokens import verify_token
|
|
|
44
44
|
if TYPE_CHECKING:
|
|
45
45
|
from ferrum.connection import Connection, ConnectionLike
|
|
46
46
|
from ferrum.migrations.loader import MigrationModule
|
|
47
|
-
from ferrum.models import FieldMeta, Model, ModelMetadata
|
|
47
|
+
from ferrum.models import FieldMeta, Model, ModelMetadata, RelationMeta
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
# ---------------------------------------------------------------------------
|
|
@@ -484,11 +484,22 @@ def _col_def(col: dict[str, Any], *, dialect: str = "postgres") -> str:
|
|
|
484
484
|
parts.append("PRIMARY KEY")
|
|
485
485
|
if col.get("unique"):
|
|
486
486
|
parts.append("UNIQUE")
|
|
487
|
-
|
|
488
|
-
|
|
487
|
+
enum_values = col.get("enum_values")
|
|
488
|
+
if enum_values:
|
|
489
|
+
parts.append(_enum_check_sql(col["name"], enum_values, dialect))
|
|
489
490
|
return " ".join(parts)
|
|
490
491
|
|
|
491
492
|
|
|
493
|
+
def _enum_check_sql(column: str, values: list[str], dialect: str) -> str:
|
|
494
|
+
"""Build ``CHECK (col IN ('a', 'b'))`` for a ``Literal`` field.
|
|
495
|
+
|
|
496
|
+
Values come from the model's ``Literal[...]`` annotation. Each one is
|
|
497
|
+
emitted as an escaped string literal, never interpolated raw.
|
|
498
|
+
"""
|
|
499
|
+
literals = ", ".join("'" + str(v).replace("'", "''") + "'" for v in values)
|
|
500
|
+
return f"CHECK ({_quote_ident(column, dialect)} IN ({literals}))"
|
|
501
|
+
|
|
502
|
+
|
|
492
503
|
def _op_to_sql(op: dict[str, Any], *, dialect: str = "postgres") -> str:
|
|
493
504
|
"""Generate DDL SQL from a MigrationOp dict.
|
|
494
505
|
|
|
@@ -860,7 +871,7 @@ def _field_to_col_def(field_meta: FieldMeta, *, is_pk: bool) -> dict[str, Any]:
|
|
|
860
871
|
# insert-time value, not a DB default: the sequence owns the DB default.
|
|
861
872
|
if default is None and sql_type.upper() not in _SERIAL_SQL_TYPES:
|
|
862
873
|
default = _python_default_to_sql(value=field_meta.python_default, field_meta=field_meta)
|
|
863
|
-
|
|
874
|
+
col_def: dict[str, Any] = {
|
|
864
875
|
"name": field_meta.column_name,
|
|
865
876
|
"sql_type": sql_type,
|
|
866
877
|
"not_null": not field_meta.nullable,
|
|
@@ -868,6 +879,37 @@ def _field_to_col_def(field_meta: FieldMeta, *, is_pk: bool) -> dict[str, Any]:
|
|
|
868
879
|
"primary_key": is_pk,
|
|
869
880
|
"unique": field_meta.unique,
|
|
870
881
|
}
|
|
882
|
+
if field_meta.field_type == "enum" and field_meta.enum_values:
|
|
883
|
+
col_def["enum_values"] = list(field_meta.enum_values)
|
|
884
|
+
return col_def
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _resolve_fk_target(
|
|
888
|
+
rel: RelationMeta,
|
|
889
|
+
owner_table: str,
|
|
890
|
+
meta_by_name: dict[str, ModelMetadata],
|
|
891
|
+
) -> tuple[str, str]:
|
|
892
|
+
"""Return ``(ref_table, ref_column)`` for an FK / one-to-one relation.
|
|
893
|
+
|
|
894
|
+
The referenced column is the target model's primary-key column. A target
|
|
895
|
+
outside the planned model set keeps the legacy ``<model>.id`` fallback.
|
|
896
|
+
|
|
897
|
+
Raises:
|
|
898
|
+
FerrumSchemaError: the target has a composite primary key, which a
|
|
899
|
+
single-column foreign key cannot reference.
|
|
900
|
+
"""
|
|
901
|
+
target_meta = meta_by_name.get(rel.to_model)
|
|
902
|
+
if target_meta is None:
|
|
903
|
+
return rel.to_model.lower(), "id"
|
|
904
|
+
pk_columns = [target_meta.fields[i].column_name for i in target_meta.pk_fields]
|
|
905
|
+
if len(pk_columns) != 1:
|
|
906
|
+
raise FerrumSchemaError(
|
|
907
|
+
f"Foreign key {owner_table}.{rel.db_column} references {rel.to_model}, "
|
|
908
|
+
f"which has a composite primary key ({', '.join(pk_columns)}). A "
|
|
909
|
+
"single-column foreign key cannot reference it. [FERR-S001]",
|
|
910
|
+
model=rel.to_model,
|
|
911
|
+
)
|
|
912
|
+
return target_meta.table_name, pk_columns[0]
|
|
871
913
|
|
|
872
914
|
|
|
873
915
|
def _autodiff_existing_table(
|
|
@@ -1051,6 +1093,7 @@ def compute_plan(
|
|
|
1051
1093
|
# Track M2M join tables already emitted to avoid duplicate CREATE TABLE ops
|
|
1052
1094
|
# when both sides of the relationship appear in model_classes.
|
|
1053
1095
|
emitted_m2m_tables: set[str] = set()
|
|
1096
|
+
fk_ops: list[dict[str, Any]] = []
|
|
1054
1097
|
|
|
1055
1098
|
for cls in model_classes:
|
|
1056
1099
|
# ModelMetadata is built once at class-definition time and is read-only.
|
|
@@ -1069,13 +1112,6 @@ def compute_plan(
|
|
|
1069
1112
|
cd = _field_to_col_def(f, is_pk=f.pk)
|
|
1070
1113
|
if is_composite_pk and f.pk:
|
|
1071
1114
|
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
1115
|
col_defs.append(cd)
|
|
1080
1116
|
|
|
1081
1117
|
create_op: dict[str, Any] = {
|
|
@@ -1118,20 +1154,20 @@ def compute_plan(
|
|
|
1118
1154
|
index_op["opclasses"] = opclasses
|
|
1119
1155
|
ops.append(index_op)
|
|
1120
1156
|
|
|
1121
|
-
#
|
|
1157
|
+
# AddForeignKey ops for new tables are deferred until every table
|
|
1158
|
+
# exists, so the target may be declared later, be the table itself,
|
|
1159
|
+
# or form a cycle.
|
|
1122
1160
|
for rel in metadata.relations:
|
|
1123
1161
|
if rel.kind in ("fk", "one_to_one"):
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
constraint_name = f"fk_{table}_{rel.db_column}"
|
|
1127
|
-
ops.append(
|
|
1162
|
+
ref_table, ref_column = _resolve_fk_target(rel, table, meta_by_name)
|
|
1163
|
+
fk_ops.append(
|
|
1128
1164
|
{
|
|
1129
1165
|
"kind": "add_fk",
|
|
1130
1166
|
"table": table,
|
|
1131
|
-
"name":
|
|
1167
|
+
"name": f"fk_{table}_{rel.db_column}",
|
|
1132
1168
|
"column": rel.db_column,
|
|
1133
|
-
"ref_table":
|
|
1134
|
-
"ref_column":
|
|
1169
|
+
"ref_table": ref_table,
|
|
1170
|
+
"ref_column": ref_column,
|
|
1135
1171
|
"on_delete": rel.on_delete or "CASCADE",
|
|
1136
1172
|
}
|
|
1137
1173
|
)
|
|
@@ -1174,7 +1210,7 @@ def compute_plan(
|
|
|
1174
1210
|
],
|
|
1175
1211
|
}
|
|
1176
1212
|
)
|
|
1177
|
-
|
|
1213
|
+
fk_ops.append(
|
|
1178
1214
|
{
|
|
1179
1215
|
"kind": "add_fk",
|
|
1180
1216
|
"table": through,
|
|
@@ -1185,7 +1221,7 @@ def compute_plan(
|
|
|
1185
1221
|
"on_delete": "CASCADE",
|
|
1186
1222
|
}
|
|
1187
1223
|
)
|
|
1188
|
-
|
|
1224
|
+
fk_ops.append(
|
|
1189
1225
|
{
|
|
1190
1226
|
"kind": "add_fk",
|
|
1191
1227
|
"table": through,
|
|
@@ -1235,6 +1271,7 @@ def compute_plan(
|
|
|
1235
1271
|
schema_state.indexes,
|
|
1236
1272
|
)
|
|
1237
1273
|
|
|
1274
|
+
ops.extend(fk_ops)
|
|
1238
1275
|
return {
|
|
1239
1276
|
"version": 1,
|
|
1240
1277
|
"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
|