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.
Files changed (86) hide show
  1. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/Cargo.lock +4 -4
  2. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/Cargo.toml +1 -1
  3. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/PKG-INFO +17 -1
  4. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/README.md +16 -0
  5. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/pyproject.toml +1 -1
  6. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/__init__.py +1 -1
  7. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/makemigrations_cmd.py +15 -4
  8. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/autodiff.py +4 -4
  9. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/drift.py +56 -9
  10. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/operations.py +20 -2
  11. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/orchestrator.py +120 -37
  12. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/LICENSE +0 -0
  13. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/Cargo.toml +0 -0
  14. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/benches/hydrate.rs +0 -0
  15. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/src/compile/mod.rs +0 -0
  16. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/src/error.rs +0 -0
  17. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
  18. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/src/ir/metadata.rs +0 -0
  19. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/src/ir/mod.rs +0 -0
  20. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/src/lib.rs +0 -0
  21. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-core/src/migrate/mod.rs +0 -0
  22. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-pyo3/Cargo.toml +0 -0
  23. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-pyo3/src/lib.rs +0 -0
  24. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/Cargo.toml +0 -0
  25. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/benches/compile.rs +0 -0
  26. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/dialect.rs +0 -0
  27. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/emit.rs +0 -0
  28. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/fts/mod.rs +0 -0
  29. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
  30. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
  31. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
  32. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
  33. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/crates/ferrum-sql/src/lib.rs +0 -0
  34. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/_native.pyi +0 -0
  35. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/__init__.py +0 -0
  36. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/app.py +0 -0
  37. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/bootstrap.py +0 -0
  38. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/check_schema_cmd.py +0 -0
  39. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/init.py +0 -0
  40. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/inspectdb_cmd.py +0 -0
  41. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/migrate_cmd.py +0 -0
  42. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/migrations_cmd.py +0 -0
  43. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/resetdb_cmd.py +0 -0
  44. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/revert_cmd.py +0 -0
  45. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/showmigrations_cmd.py +0 -0
  46. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
  47. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/config.py +0 -0
  48. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/connection.py +0 -0
  49. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/contrib/__init__.py +0 -0
  50. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/contrib/fastapi.py +0 -0
  51. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/drivers/__init__.py +0 -0
  52. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/drivers/mssql.py +0 -0
  53. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/drivers/mysql.py +0 -0
  54. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/drivers/postgres.py +0 -0
  55. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/drivers/protocol.py +0 -0
  56. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/drivers/sqlite.py +0 -0
  57. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/drivers/streaming.py +0 -0
  58. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/echo.py +0 -0
  59. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/errors.py +0 -0
  60. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/expressions.py +0 -0
  61. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/ext/__init__.py +0 -0
  62. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/ext/fts.py +0 -0
  63. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/ext/pgvector.py +0 -0
  64. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/hooks.py +0 -0
  65. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/__init__.py +0 -0
  66. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/base.py +0 -0
  67. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/coordinator.py +0 -0
  68. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/fts/__init__.py +0 -0
  69. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/fts/mssql.py +0 -0
  70. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/fts/mysql.py +0 -0
  71. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/fts/postgres.py +0 -0
  72. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/fts/sqlite.py +0 -0
  73. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/gates.py +0 -0
  74. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/introspect.py +0 -0
  75. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/ledger.py +0 -0
  76. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/loader.py +0 -0
  77. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/migrations/tokens.py +0 -0
  78. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/models.py +0 -0
  79. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/observability.py +0 -0
  80. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/py.typed +0 -0
  81. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/queryset.py +0 -0
  82. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/registry.py +0 -0
  83. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/relations.py +0 -0
  84. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/routing.py +0 -0
  85. {ferrum_orm-0.1.20 → ferrum_orm-0.1.22}/python/ferrum/runtime.py +0 -0
  86. {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.20"
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.20"
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.20"
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.20"
258
+ version = "0.1.22"
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.22"
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.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.
@@ -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.22"
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.22"
10
10
  __all__ = [
11
11
  "Aggregate",
12
12
  "ConnectionRegistry",
@@ -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
- rev_lines = [_reverse_op_to_source(op) for op in ops]
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
- plan = compute_plan(model_classes, existing_state)
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
- 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):
@@ -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 _python_default_to_sql(*, value: Any, field_meta: FieldMeta) -> str | None: # noqa: ANN401
286
- """Map a Python-side field default to an allowed SQL DEFAULT literal, if possible."""
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 field_type in ("int", "big_int") and isinstance(value, int) and not isinstance(value, bool):
297
- return str(value)
298
- if field_type == "float" and isinstance(value, (int, float)) and not isinstance(value, bool):
299
- return str(value)
300
- return None
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.upper() not in _DEFAULT_VALUE_ALLOWLIST:
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
- if col.get("enum_check"):
488
- parts.append(str(col["enum_check"]))
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.upper() not in _DEFAULT_VALUE_ALLOWLIST:
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(value=field_meta.python_default, field_meta=field_meta)
863
- return {
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 against ``_DEFAULT_VALUE_ALLOWLIST`` by
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
- desired_default = f.db_default # already normalized in FieldMeta
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
- # Emit AddForeignKey ops for FK / OneToOne relations on new tables.
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
- 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(
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": constraint_name,
1213
+ "name": f"fk_{table}_{rel.db_column}",
1132
1214
  "column": rel.db_column,
1133
- "ref_table": target_table,
1134
- "ref_column": "id",
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
- ops.append(
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
- ops.append(
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