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.
Files changed (86) hide show
  1. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/Cargo.lock +4 -4
  2. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/Cargo.toml +1 -1
  3. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/PKG-INFO +12 -1
  4. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/README.md +11 -0
  5. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/pyproject.toml +1 -1
  6. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/__init__.py +1 -1
  7. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/makemigrations_cmd.py +9 -3
  8. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/autodiff.py +4 -4
  9. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/drift.py +56 -9
  10. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/operations.py +20 -2
  11. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/orchestrator.py +59 -22
  12. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/LICENSE +0 -0
  13. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/Cargo.toml +0 -0
  14. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/benches/hydrate.rs +0 -0
  15. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/src/compile/mod.rs +0 -0
  16. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/src/error.rs +0 -0
  17. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
  18. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/src/ir/metadata.rs +0 -0
  19. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/src/ir/mod.rs +0 -0
  20. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/src/lib.rs +0 -0
  21. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-core/src/migrate/mod.rs +0 -0
  22. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-pyo3/Cargo.toml +0 -0
  23. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-pyo3/src/lib.rs +0 -0
  24. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/Cargo.toml +0 -0
  25. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/benches/compile.rs +0 -0
  26. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/dialect.rs +0 -0
  27. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/emit.rs +0 -0
  28. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/fts/mod.rs +0 -0
  29. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
  30. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
  31. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
  32. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
  33. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/crates/ferrum-sql/src/lib.rs +0 -0
  34. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/_native.pyi +0 -0
  35. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/__init__.py +0 -0
  36. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/app.py +0 -0
  37. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/bootstrap.py +0 -0
  38. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/check_schema_cmd.py +0 -0
  39. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/init.py +0 -0
  40. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/inspectdb_cmd.py +0 -0
  41. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/migrate_cmd.py +0 -0
  42. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/migrations_cmd.py +0 -0
  43. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/resetdb_cmd.py +0 -0
  44. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/revert_cmd.py +0 -0
  45. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/showmigrations_cmd.py +0 -0
  46. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
  47. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/config.py +0 -0
  48. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/connection.py +0 -0
  49. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/contrib/__init__.py +0 -0
  50. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/contrib/fastapi.py +0 -0
  51. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/drivers/__init__.py +0 -0
  52. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/drivers/mssql.py +0 -0
  53. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/drivers/mysql.py +0 -0
  54. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/drivers/postgres.py +0 -0
  55. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/drivers/protocol.py +0 -0
  56. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/drivers/sqlite.py +0 -0
  57. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/drivers/streaming.py +0 -0
  58. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/echo.py +0 -0
  59. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/errors.py +0 -0
  60. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/expressions.py +0 -0
  61. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/ext/__init__.py +0 -0
  62. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/ext/fts.py +0 -0
  63. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/ext/pgvector.py +0 -0
  64. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/hooks.py +0 -0
  65. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/__init__.py +0 -0
  66. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/base.py +0 -0
  67. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/coordinator.py +0 -0
  68. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/fts/__init__.py +0 -0
  69. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/fts/mssql.py +0 -0
  70. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/fts/mysql.py +0 -0
  71. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/fts/postgres.py +0 -0
  72. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/fts/sqlite.py +0 -0
  73. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/gates.py +0 -0
  74. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/introspect.py +0 -0
  75. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/ledger.py +0 -0
  76. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/loader.py +0 -0
  77. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/migrations/tokens.py +0 -0
  78. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/models.py +0 -0
  79. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/observability.py +0 -0
  80. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/py.typed +0 -0
  81. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/queryset.py +0 -0
  82. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/registry.py +0 -0
  83. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/relations.py +0 -0
  84. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/routing.py +0 -0
  85. {ferrum_orm-0.1.20 → ferrum_orm-0.1.21}/python/ferrum/runtime.py +0 -0
  86. {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.20"
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.20"
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.20"
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.20"
258
+ version = "0.1.21"
259
259
  dependencies = [
260
260
  "criterion",
261
261
  "ferrum-core",
@@ -3,7 +3,7 @@ members = ["crates/ferrum-core", "crates/ferrum-sql", "crates/ferrum-pyo3"]
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "0.1.20"
6
+ version = "0.1.21"
7
7
  edition = "2021"
8
8
  authors = ["Ferrum Contributors"]
9
9
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferrum-orm
3
- Version: 0.1.20
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.
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ferrum-orm"
7
- version = "0.1.20"
7
+ version = "0.1.21"
8
8
  description = "Next-generation async ORM for Python with a Rust-powered core"
9
9
  license = { text = "Apache-2.0" }
10
10
  readme = "README.md"
@@ -6,7 +6,7 @@ Import paths are stable API; internal module paths are not.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- __version__ = "0.1.20"
9
+ __version__ = "0.1.21"
10
10
  __all__ = [
11
11
  "Aggregate",
12
12
  "ConnectionRegistry",
@@ -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
- rev_lines = [_reverse_op_to_source(op) for op in ops]
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
- target_meta = meta_by_name.get(rel.to_model)
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=target_table,
525
- ref_column="id",
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}_fkey",
1088
+ "name": f"fk_{metadata.table_name}_{relation.db_column}",
1077
1089
  "type": "FOREIGN KEY",
1078
1090
  "column": relation.db_column,
1079
- "foreign_table": None, # resolved by metadata caller if needed
1080
- "foreign_column": None,
1081
- "delete_rule": (relation.on_delete or "NO ACTION").upper(),
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"{relation.to_model} (id) ON DELETE "
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 "").upper()
1146
- actual_def = str(actual_c.get("definition") or "").upper()
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
- return {
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
- return f"CreateTable({self.table_name!r}, {self.columns!r})"
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
- if col.get("enum_check"):
488
- parts.append(str(col["enum_check"]))
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
- return {
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
- # Emit AddForeignKey ops for FK / OneToOne relations on new tables.
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
- target_meta = meta_by_name.get(rel.to_model)
1125
- target_table = target_meta.table_name if target_meta else rel.to_model.lower()
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": constraint_name,
1167
+ "name": f"fk_{table}_{rel.db_column}",
1132
1168
  "column": rel.db_column,
1133
- "ref_table": target_table,
1134
- "ref_column": "id",
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
- ops.append(
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
- ops.append(
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