ferrum-orm 0.1.7__tar.gz → 0.1.9__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 (80) hide show
  1. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/Cargo.lock +6 -6
  2. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/Cargo.toml +1 -1
  3. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/PKG-INFO +54 -1
  4. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/README.md +53 -0
  5. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/pyproject.toml +1 -1
  6. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/__init__.py +1 -1
  7. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/makemigrations_cmd.py +64 -15
  8. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/__init__.py +12 -1
  9. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/orchestrator.py +196 -13
  10. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/models.py +45 -4
  11. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/queryset.py +220 -11
  12. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/LICENSE +0 -0
  13. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/Cargo.toml +0 -0
  14. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/benches/hydrate.rs +0 -0
  15. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/src/compile/mod.rs +0 -0
  16. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/src/error.rs +0 -0
  17. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
  18. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/src/ir/metadata.rs +0 -0
  19. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/src/ir/mod.rs +0 -0
  20. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/src/lib.rs +0 -0
  21. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/src/migrate/mod.rs +0 -0
  22. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-pyo3/Cargo.toml +0 -0
  23. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-pyo3/src/lib.rs +0 -0
  24. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/Cargo.toml +0 -0
  25. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/benches/compile.rs +0 -0
  26. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/dialect.rs +0 -0
  27. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/emit.rs +0 -0
  28. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/mod.rs +0 -0
  29. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
  30. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
  31. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
  32. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
  33. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/lib.rs +0 -0
  34. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/_native.pyi +0 -0
  35. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/__init__.py +0 -0
  36. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/app.py +0 -0
  37. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/bootstrap.py +0 -0
  38. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/init.py +0 -0
  39. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/inspectdb_cmd.py +0 -0
  40. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/migrate_cmd.py +0 -0
  41. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/migrations_cmd.py +0 -0
  42. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/resetdb_cmd.py +0 -0
  43. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/revert_cmd.py +0 -0
  44. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/showmigrations_cmd.py +0 -0
  45. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
  46. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/config.py +0 -0
  47. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/connection.py +0 -0
  48. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/contrib/__init__.py +0 -0
  49. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/contrib/fastapi.py +0 -0
  50. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/drivers/__init__.py +0 -0
  51. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/drivers/mssql.py +0 -0
  52. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/drivers/mysql.py +0 -0
  53. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/drivers/postgres.py +0 -0
  54. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/drivers/protocol.py +0 -0
  55. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/drivers/sqlite.py +0 -0
  56. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/errors.py +0 -0
  57. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/expressions.py +0 -0
  58. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/ext/__init__.py +0 -0
  59. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/ext/fts.py +0 -0
  60. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/ext/pgvector.py +0 -0
  61. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/hooks.py +0 -0
  62. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/base.py +0 -0
  63. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/drift.py +0 -0
  64. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/__init__.py +0 -0
  65. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/mssql.py +0 -0
  66. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/mysql.py +0 -0
  67. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/postgres.py +0 -0
  68. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/sqlite.py +0 -0
  69. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/gates.py +0 -0
  70. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/introspect.py +0 -0
  71. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/ledger.py +0 -0
  72. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/loader.py +0 -0
  73. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/operations.py +0 -0
  74. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/tokens.py +0 -0
  75. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/observability.py +0 -0
  76. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/py.typed +0 -0
  77. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/registry.py +0 -0
  78. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/relations.py +0 -0
  79. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/runtime.py +0 -0
  80. {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/session.py +0 -0
@@ -174,9 +174,9 @@ dependencies = [
174
174
 
175
175
  [[package]]
176
176
  name = "crossbeam-epoch"
177
- version = "0.9.18"
177
+ version = "0.9.20"
178
178
  source = "registry+https://github.com/rust-lang/crates.io-index"
179
- checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
179
+ checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
180
180
  dependencies = [
181
181
  "crossbeam-utils",
182
182
  ]
@@ -223,7 +223,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
223
223
 
224
224
  [[package]]
225
225
  name = "ferrum-core"
226
- version = "0.1.6"
226
+ version = "0.1.9"
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.6"
237
+ version = "0.1.9"
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.6"
246
+ version = "0.1.9"
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.6"
258
+ version = "0.1.9"
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.6"
6
+ version = "0.1.9"
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.7
3
+ Version: 0.1.9
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Framework :: AsyncIO
6
6
  Classifier: Intended Audience :: Developers
@@ -121,6 +121,14 @@ user = await User.objects.create(
121
121
  email="john@example.com",
122
122
  )
123
123
 
124
+ # Insert from an instance (sentinel PK is dropped so the DB default runs)
125
+ draft = User(id=0, email="dana@example.com")
126
+ created = await User.objects.create(conn, draft)
127
+
128
+ # Persist one instance's changes by primary key
129
+ created.is_active = False
130
+ await User.objects.update_instance(conn, created, fields=["is_active"])
131
+
124
132
  users = await (
125
133
  User.objects
126
134
  .filter(is_active=True)
@@ -160,6 +168,49 @@ class User(Model):
160
168
 
161
169
  No duplicate schema definitions.
162
170
 
171
+ ### Field Options
172
+
173
+ `ferrum.Field()` accepts column-level constraints that are carried through to DDL:
174
+
175
+ | Parameter | Type | Description |
176
+ | --------- | ---- | ----------- |
177
+ | `db_default` | `str \| None` | DB-side `DEFAULT` expression (e.g. `"NOW()"`, `"''"`, `"GEN_RANDOM_UUID()"`). Token casing is normalised (`now()` → `NOW()`). |
178
+ | `nullable` | `bool \| None` | Override annotation-derived nullability. Use `nullable=False` to force `NOT NULL` even on a `T \| None`-annotated field. |
179
+ | `db_index` | `bool` | Emit a `CREATE INDEX` on this column (auto-named `idx_{table}_{field}`). |
180
+ | `unique` | `bool` | Emit a `UNIQUE` constraint. |
181
+ | `db_column` | `str \| None` | Override the database column name. |
182
+ | `primary_key` | `bool` | Declare this field as the primary key. |
183
+ | `uuid_generate` | `"v4" \| "v7" \| None` | Set `GEN_RANDOM_UUID()` / `UUIDV7()` as the DB default for UUID PK columns. |
184
+ | `max_length` | `int \| None` | Emit `VARCHAR(n)` instead of `TEXT`. |
185
+ | `max_digits` / `decimal_places` | `int \| None` | Emit `NUMERIC(p,s)`. |
186
+ | `vector_dimensions` | `int \| None` | Required for `Vector` columns; emits `VECTOR(n)`. |
187
+
188
+ ```python
189
+ from datetime import datetime
190
+ from typing import Annotated
191
+ from ferrum import Model, Field
192
+
193
+ class Event(Model):
194
+ id: int
195
+ # DB-side timestamp default; Python annotation allows None but column is NOT NULL
196
+ created_at: Annotated[datetime | None, Field(db_default="now()", nullable=False)]
197
+ # Indexed text column
198
+ kind: Annotated[str, Field(db_index=True, max_length=64)]
199
+ ```
200
+
201
+ `makemigrations` honours these field attributes:
202
+ - `db_default` / `nullable` are written into `CREATE TABLE` and `ADD COLUMN` DDL.
203
+ - `db_index=True` emits `CREATE INDEX` after the table op.
204
+ - For **already-existing tables**, `makemigrations` now **autodiffs** index and column
205
+ attribute changes against the state projected from prior migration files:
206
+ - Adding `db_index=True` to an existing field → `AddIndex` op.
207
+ - Removing `db_index=True` → `DropIndex` op.
208
+ - Adding or changing `db_default` → `AlterColumn … SET DEFAULT …` op.
209
+ - Removing `db_default` → `AlterColumn … DROP DEFAULT` op.
210
+ - Changing nullability → `AlterColumn … SET / DROP NOT NULL` op (SET NOT NULL is
211
+ classified destructive and requires `--confirm`).
212
+ - Column type changes, renames, and drops remain manual (out of scope for v0.1).
213
+
163
214
  ### Django-Inspired API
164
215
 
165
216
  Familiar query interface.
@@ -301,6 +352,8 @@ and [API Reference](docs/api-reference.md) for per-dialect DDL and operator mapp
301
352
  - [x] `call_function` for allowlisted stored-procedure calls
302
353
  - [x] Migration ops for extensions, RLS policies, and function DDL
303
354
  - [x] pgvector similarity score projection (`vector_search` helper)
355
+ - [x] `Field(db_default=..., nullable=...)` — first-class DB-side defaults and nullability override
356
+ - [x] `makemigrations` autodiff for index / default / nullability changes on existing tables
304
357
  - [ ] Query optimization (deferred fields, prefetch tuning)
305
358
  - [ ] Advanced relationship loading
306
359
 
@@ -49,6 +49,14 @@ user = await User.objects.create(
49
49
  email="john@example.com",
50
50
  )
51
51
 
52
+ # Insert from an instance (sentinel PK is dropped so the DB default runs)
53
+ draft = User(id=0, email="dana@example.com")
54
+ created = await User.objects.create(conn, draft)
55
+
56
+ # Persist one instance's changes by primary key
57
+ created.is_active = False
58
+ await User.objects.update_instance(conn, created, fields=["is_active"])
59
+
52
60
  users = await (
53
61
  User.objects
54
62
  .filter(is_active=True)
@@ -88,6 +96,49 @@ class User(Model):
88
96
 
89
97
  No duplicate schema definitions.
90
98
 
99
+ ### Field Options
100
+
101
+ `ferrum.Field()` accepts column-level constraints that are carried through to DDL:
102
+
103
+ | Parameter | Type | Description |
104
+ | --------- | ---- | ----------- |
105
+ | `db_default` | `str \| None` | DB-side `DEFAULT` expression (e.g. `"NOW()"`, `"''"`, `"GEN_RANDOM_UUID()"`). Token casing is normalised (`now()` → `NOW()`). |
106
+ | `nullable` | `bool \| None` | Override annotation-derived nullability. Use `nullable=False` to force `NOT NULL` even on a `T \| None`-annotated field. |
107
+ | `db_index` | `bool` | Emit a `CREATE INDEX` on this column (auto-named `idx_{table}_{field}`). |
108
+ | `unique` | `bool` | Emit a `UNIQUE` constraint. |
109
+ | `db_column` | `str \| None` | Override the database column name. |
110
+ | `primary_key` | `bool` | Declare this field as the primary key. |
111
+ | `uuid_generate` | `"v4" \| "v7" \| None` | Set `GEN_RANDOM_UUID()` / `UUIDV7()` as the DB default for UUID PK columns. |
112
+ | `max_length` | `int \| None` | Emit `VARCHAR(n)` instead of `TEXT`. |
113
+ | `max_digits` / `decimal_places` | `int \| None` | Emit `NUMERIC(p,s)`. |
114
+ | `vector_dimensions` | `int \| None` | Required for `Vector` columns; emits `VECTOR(n)`. |
115
+
116
+ ```python
117
+ from datetime import datetime
118
+ from typing import Annotated
119
+ from ferrum import Model, Field
120
+
121
+ class Event(Model):
122
+ id: int
123
+ # DB-side timestamp default; Python annotation allows None but column is NOT NULL
124
+ created_at: Annotated[datetime | None, Field(db_default="now()", nullable=False)]
125
+ # Indexed text column
126
+ kind: Annotated[str, Field(db_index=True, max_length=64)]
127
+ ```
128
+
129
+ `makemigrations` honours these field attributes:
130
+ - `db_default` / `nullable` are written into `CREATE TABLE` and `ADD COLUMN` DDL.
131
+ - `db_index=True` emits `CREATE INDEX` after the table op.
132
+ - For **already-existing tables**, `makemigrations` now **autodiffs** index and column
133
+ attribute changes against the state projected from prior migration files:
134
+ - Adding `db_index=True` to an existing field → `AddIndex` op.
135
+ - Removing `db_index=True` → `DropIndex` op.
136
+ - Adding or changing `db_default` → `AlterColumn … SET DEFAULT …` op.
137
+ - Removing `db_default` → `AlterColumn … DROP DEFAULT` op.
138
+ - Changing nullability → `AlterColumn … SET / DROP NOT NULL` op (SET NOT NULL is
139
+ classified destructive and requires `--confirm`).
140
+ - Column type changes, renames, and drops remain manual (out of scope for v0.1).
141
+
91
142
  ### Django-Inspired API
92
143
 
93
144
  Familiar query interface.
@@ -229,6 +280,8 @@ and [API Reference](docs/api-reference.md) for per-dialect DDL and operator mapp
229
280
  - [x] `call_function` for allowlisted stored-procedure calls
230
281
  - [x] Migration ops for extensions, RLS policies, and function DDL
231
282
  - [x] pgvector similarity score projection (`vector_search` helper)
283
+ - [x] `Field(db_default=..., nullable=...)` — first-class DB-side defaults and nullability override
284
+ - [x] `makemigrations` autodiff for index / default / nullability changes on existing tables
232
285
  - [ ] Query optimization (deferred fields, prefetch tuning)
233
286
  - [ ] Advanced relationship loading
234
287
 
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ferrum-orm"
7
- version = "0.1.7"
7
+ version = "0.1.9"
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.7"
9
+ __version__ = "0.1.9"
10
10
  __all__ = [
11
11
  "CreateExtension",
12
12
  "CreateFunction",
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
23
23
  from ferrum.models import Model
24
24
 
25
25
  from ferrum.migrations import loader
26
- from ferrum.migrations.orchestrator import compute_plan
26
+ from ferrum.migrations.orchestrator import ColumnState, IndexState, SchemaState, compute_plan
27
27
 
28
28
  _MIGRATION_TEMPLATE = """\
29
29
  # Generated by ferrum makemigrations
@@ -59,34 +59,77 @@ def _get_all_model_subclasses() -> list[type[Model]]:
59
59
  return result
60
60
 
61
61
 
62
+ def _normalize_state_default(value: str | None) -> str | None:
63
+ """Normalize a state default token to uppercase for consistent comparison."""
64
+ if value is None:
65
+ return None
66
+ from ferrum.models import _normalize_db_default
67
+
68
+ return _normalize_db_default(value)
69
+
70
+
62
71
  def _build_existing_state(
63
72
  migrations: list[loader.MigrationModule],
64
- ) -> dict[str, list[str]]:
65
- """Replay prior migration operations to produce {table: [column_names]} state.
73
+ ) -> SchemaState:
74
+ """Replay prior migration operations to produce a rich ``SchemaState``.
66
75
 
67
- This is a static reconstruction — no database connection is used. Only
68
- ``create_table``, ``add_column``, ``drop_table``, and ``drop_column`` ops
69
- are handled because those are the only kinds that change the column
70
- inventory tracked by ``compute_plan``.
76
+ This is a static reconstruction — no database connection is used.
77
+ Handles: ``create_table``, ``add_column``, ``drop_table``, ``drop_column``,
78
+ ``add_index``, ``drop_index``, and ``alter_column`` (default / not_null).
71
79
  """
72
- state: dict[str, list[str]] = {}
80
+ state = SchemaState()
73
81
  for mig_mod in migrations:
74
82
  for op in mig_mod.migration.operations:
75
83
  op_dict = op.to_op_dict()
76
84
  kind = op_dict.get("kind")
77
85
  if kind == "create_table":
78
86
  table = op_dict["table"]
79
- state[table] = [c["name"] for c in op_dict.get("columns", [])]
87
+ state.tables[table] = {
88
+ c["name"]: ColumnState(
89
+ sql_type=c.get("sql_type", ""),
90
+ not_null=bool(c.get("not_null", False)),
91
+ default=_normalize_state_default(c.get("default")),
92
+ )
93
+ for c in op_dict.get("columns", [])
94
+ }
80
95
  elif kind == "add_column":
81
96
  table = op_dict["table"]
82
- state.setdefault(table, []).append(op_dict["name"])
97
+ col_name = op_dict["name"]
98
+ state.tables.setdefault(table, {})[col_name] = ColumnState(
99
+ sql_type=op_dict.get("sql_type", ""),
100
+ not_null=bool(op_dict.get("not_null", False)),
101
+ default=_normalize_state_default(op_dict.get("default")),
102
+ )
83
103
  elif kind == "drop_table":
84
- state.pop(op_dict.get("table", ""), None)
104
+ state.tables.pop(op_dict.get("table", ""), None)
85
105
  elif kind == "drop_column":
86
106
  table = op_dict.get("table", "")
87
107
  col = op_dict.get("column", "")
88
- if table in state and col in state[table]:
89
- state[table].remove(col)
108
+ if table in state.tables:
109
+ state.tables[table].pop(col, None)
110
+ elif kind == "add_index":
111
+ idx_name = op_dict.get("name", "")
112
+ if idx_name:
113
+ state.indexes[idx_name] = IndexState(
114
+ table=op_dict.get("table", ""),
115
+ columns=list(op_dict.get("columns", [])),
116
+ unique=bool(op_dict.get("unique", False)),
117
+ using=op_dict.get("using", "btree"),
118
+ where=op_dict.get("where"),
119
+ )
120
+ elif kind == "drop_index":
121
+ state.indexes.pop(op_dict.get("name", ""), None)
122
+ elif kind == "alter_column":
123
+ table = op_dict.get("table", "")
124
+ col = op_dict.get("column", "")
125
+ if table in state.tables and col in state.tables[table]:
126
+ col_state = state.tables[table][col]
127
+ if op_dict.get("not_null") is not None:
128
+ col_state.not_null = bool(op_dict["not_null"])
129
+ if op_dict.get("default") is not None:
130
+ col_state.default = _normalize_state_default(op_dict["default"])
131
+ if op_dict.get("drop_default"):
132
+ col_state.default = None
90
133
  return state
91
134
 
92
135
 
@@ -125,8 +168,8 @@ def _index_source(op: dict[str, Any]) -> str:
125
168
  def _op_to_source(op: dict[str, Any]) -> str:
126
169
  """Convert a plan op dict to an 8-space-indented Python source line.
127
170
 
128
- Handles ``create_table``, ``add_column``, ``add_index``, and ``add_fk`` — the kinds
129
- emitted by ``compute_plan`` in v0.1.
171
+ Handles ``create_table``, ``add_column``, ``add_index``, ``drop_index``,
172
+ ``add_fk``, and ``alter_column`` — the kinds emitted by ``compute_plan``.
130
173
 
131
174
  Raises:
132
175
  ValueError: if the op kind cannot be rendered to Python source.
@@ -155,6 +198,9 @@ def _op_to_source(op: dict[str, Any]) -> str:
155
198
  if kind == "add_index":
156
199
  return f" {_index_source(op)},"
157
200
 
201
+ if kind == "drop_index":
202
+ return f" ops.DropIndex({op['name']!r}),"
203
+
158
204
  if kind == "add_fk":
159
205
  return (
160
206
  f" ops.AddForeignKey({op['table']!r}, {op['name']!r}, "
@@ -198,6 +244,9 @@ def _reverse_op_to_source(op_dict: dict[str, Any]) -> str | None:
198
244
  return f" ops.DropColumn({op_dict['table']!r}, {op_dict['name']!r}),"
199
245
  if kind == "add_index":
200
246
  return f" ops.DropIndex({op_dict['name']!r}),"
247
+ if kind == "drop_index":
248
+ # Can't auto-reverse a drop_index without knowing all original parameters.
249
+ return None
201
250
  if kind == "add_fk":
202
251
  return f" ops.DropForeignKey({op_dict['table']!r}, {op_dict['name']!r}),"
203
252
  return None
@@ -36,9 +36,18 @@ from ferrum.migrations.operations import (
36
36
  DropPolicy,
37
37
  EnableRLS,
38
38
  )
39
- from ferrum.migrations.orchestrator import MigrationResult, _op_to_sql, apply, compute_plan
39
+ from ferrum.migrations.orchestrator import (
40
+ ColumnState,
41
+ IndexState,
42
+ MigrationResult,
43
+ SchemaState,
44
+ _op_to_sql,
45
+ apply,
46
+ compute_plan,
47
+ )
40
48
 
41
49
  __all__ = [
50
+ "ColumnState",
42
51
  "CreateExtension",
43
52
  "CreateFullTextCatalog",
44
53
  "CreateFullTextIndex",
@@ -50,8 +59,10 @@ __all__ = [
50
59
  "DropFunction",
51
60
  "DropPolicy",
52
61
  "EnableRLS",
62
+ "IndexState",
53
63
  "Migration",
54
64
  "MigrationResult",
65
+ "SchemaState",
55
66
  "_op_to_sql",
56
67
  "apply",
57
68
  "compute_plan",
@@ -20,6 +20,7 @@ Security invariants:
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
+ import dataclasses
23
24
  import datetime
24
25
  import hashlib
25
26
  import json
@@ -36,6 +37,43 @@ if TYPE_CHECKING:
36
37
  from ferrum.models import FieldMeta, Model, ModelMetadata
37
38
 
38
39
 
40
+ # ---------------------------------------------------------------------------
41
+ # Schema state dataclasses — projected migration state used by makemigrations
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ @dataclasses.dataclass
46
+ class ColumnState:
47
+ """Projected state of a single table column after replaying migration ops."""
48
+
49
+ sql_type: str
50
+ not_null: bool = False
51
+ default: str | None = None
52
+
53
+
54
+ @dataclasses.dataclass
55
+ class IndexState:
56
+ """Projected state of a single index after replaying migration ops."""
57
+
58
+ table: str
59
+ columns: list[str] = dataclasses.field(default_factory=list)
60
+ unique: bool = False
61
+ using: str = "btree"
62
+ where: str | None = None
63
+
64
+
65
+ @dataclasses.dataclass
66
+ class SchemaState:
67
+ """Full projected schema state derived by replaying prior migration files.
68
+
69
+ ``tables`` maps table name → column name → ``ColumnState``.
70
+ ``indexes`` maps index name → ``IndexState``.
71
+ """
72
+
73
+ tables: dict[str, dict[str, ColumnState]] = dataclasses.field(default_factory=dict)
74
+ indexes: dict[str, IndexState] = dataclasses.field(default_factory=dict)
75
+
76
+
39
77
  class OperationClass(Enum):
40
78
  """Classification of a migration operation by safety profile."""
41
79
 
@@ -744,28 +782,139 @@ def _field_to_col_def(field_meta: FieldMeta, *, is_pk: bool) -> dict[str, Any]:
744
782
  }
745
783
 
746
784
 
785
+ def _autodiff_existing_table(
786
+ ops: list[dict[str, Any]],
787
+ metadata: ModelMetadata,
788
+ table: str,
789
+ existing_col_names: set[str],
790
+ existing_col_attrs: dict[str, ColumnState],
791
+ existing_indexes: dict[str, IndexState],
792
+ ) -> None:
793
+ """Emit index and column-attribute diff ops for an existing table.
794
+
795
+ Called from ``compute_plan`` only when a rich ``SchemaState`` is available.
796
+ Appends ``add_index``, ``drop_index``, and ``alter_column`` ops to *ops*.
797
+
798
+ Security: all identifiers come from model metadata allowlists or
799
+ ``SchemaState`` that was itself built from prior migration ops — never user
800
+ input. Default values are checked against ``_DEFAULT_VALUE_ALLOWLIST`` by
801
+ ``_op_to_sql`` when the plan is applied.
802
+
803
+ Out of scope: sql_type changes, renames, drops (unchanged v0.1 rule).
804
+ ``SET NOT NULL`` is emitted but classified destructive by ``AlterColumn``.
805
+ """
806
+ # Build the set of desired indexes for this table.
807
+ # Key: index name → op dict for add_index.
808
+ desired_indexes: dict[str, dict[str, Any]] = {}
809
+
810
+ # db_index=True fields on *existing* columns (new columns are handled above).
811
+ for f in metadata.fields:
812
+ if f.column_name in existing_col_names and f.db_index:
813
+ idx_name = f"idx_{table}_{f.name}"
814
+ desired_indexes[idx_name] = {
815
+ "kind": "add_index",
816
+ "table": table,
817
+ "name": idx_name,
818
+ "columns": [f.column_name],
819
+ "unique": False,
820
+ "using": "btree",
821
+ }
822
+
823
+ # Meta.indexes entries.
824
+ for index in metadata.indexes:
825
+ # Only consider indexes whose columns all exist already.
826
+ column_names = [
827
+ next((f.column_name for f in metadata.fields if f.name == fn), fn)
828
+ for fn in index.fields
829
+ ]
830
+ idx_op: dict[str, Any] = {
831
+ "kind": "add_index",
832
+ "table": table,
833
+ "name": index.name,
834
+ "columns": column_names,
835
+ "unique": index.unique,
836
+ "using": index.using,
837
+ }
838
+ if index.where is not None:
839
+ idx_op["where"] = index.where
840
+ opclasses = _resolve_gin_opclasses(metadata, index.fields, using=index.using)
841
+ if opclasses is not None:
842
+ idx_op["opclasses"] = opclasses
843
+ desired_indexes[index.name] = idx_op
844
+
845
+ # Emit add_index for desired indexes not yet in state.
846
+ for idx_name, idx_op in desired_indexes.items():
847
+ if idx_name not in existing_indexes:
848
+ ops.append(idx_op)
849
+
850
+ # Emit drop_index for Ferrum-tracked indexes that are no longer desired.
851
+ for idx_name, idx_state in existing_indexes.items():
852
+ if idx_state.table == table and idx_name not in desired_indexes:
853
+ ops.append({"kind": "drop_index", "name": idx_name, "table": table})
854
+
855
+ # Column default / nullability autodiff.
856
+ for f in metadata.fields:
857
+ if f.column_name not in existing_col_attrs:
858
+ continue # New column — already emitted as add_column above.
859
+ col_state = existing_col_attrs[f.column_name]
860
+
861
+ desired_not_null = not f.nullable
862
+ desired_default = f.db_default # already normalized in FieldMeta
863
+
864
+ # Compare case-insensitively to handle legacy lowercase defaults in state.
865
+ state_default_upper = col_state.default.upper() if col_state.default else None
866
+ desired_default_upper = desired_default.upper() if desired_default else None
867
+
868
+ alter_kwargs: dict[str, Any] = {}
869
+
870
+ if desired_not_null != col_state.not_null:
871
+ alter_kwargs["not_null"] = desired_not_null
872
+
873
+ if desired_default_upper != state_default_upper:
874
+ if desired_default is not None:
875
+ alter_kwargs["default"] = desired_default
876
+ elif col_state.default is not None:
877
+ alter_kwargs["drop_default"] = True
878
+
879
+ if alter_kwargs:
880
+ ops.append(
881
+ {
882
+ "kind": "alter_column",
883
+ "table": table,
884
+ "column": f.column_name,
885
+ **alter_kwargs,
886
+ }
887
+ )
888
+
889
+
747
890
  def compute_plan(
748
891
  model_classes: list[type[Model]] | None = None,
749
- existing_tables: dict[str, list[str]] | None = None,
892
+ existing_tables: dict[str, list[str]] | SchemaState | None = None,
750
893
  *,
751
894
  conn: Connection | None = None,
752
895
  models: list[type[Model]] | None = None,
753
896
  ) -> dict[str, Any]:
754
897
  """Compute a migration plan from model classes against the current DB schema.
755
898
 
756
- Compares ``model_classes`` against ``existing_tables`` (table → column names)
757
- and emits ``create_table`` ops for absent tables and ``add_column`` ops for
758
- columns present in the model but absent from the DB.
899
+ Compares ``model_classes`` against ``existing_tables`` and emits:
900
+ - ``create_table`` ops for absent tables (with ``add_index`` for ``db_index`` fields
901
+ and ``Meta.indexes`` entries).
902
+ - ``add_column`` ops for columns present in the model but absent from the DB.
903
+ - When ``existing_tables`` is a :class:`SchemaState` (returned by
904
+ ``_build_existing_state``): also emits ``add_index`` / ``drop_index`` for
905
+ index changes on existing columns, and ``alter_column`` for default /
906
+ nullability changes.
759
907
 
760
- This is a v0.1 additive-only schema diff. Column type changes, renames,
761
- and drops are out of scope and will be addressed in a future release.
908
+ This is a v0.1 additive schema diff. Column type changes, renames, and
909
+ drops are out of scope.
762
910
 
763
911
  Args:
764
912
  model_classes: Ferrum ``Model`` subclasses to inspect. Their
765
913
  ``ModelMetadata`` (built at class-definition time) is the sole source
766
914
  of table/column names; no user input reaches SQL identifiers.
767
- existing_tables: Mapping of table name → list of existing column names,
768
- as returned by DB introspection. Pass ``{}`` for a fresh database.
915
+ existing_tables: Either a ``dict[str, list[str]]`` (table column names,
916
+ legacy/backward-compat), a :class:`SchemaState` (richer projected
917
+ state from ``_build_existing_state``), or ``None`` for a fresh database.
769
918
  conn: Reserved for a future DB-introspection path. ``None`` means use
770
919
  the supplied/static ``existing_tables`` mapping.
771
920
  models: Keyword alias for ``model_classes`` used by CLI/tests.
@@ -781,8 +930,28 @@ def compute_plan(
781
930
  model_classes = models
782
931
  if model_classes is None:
783
932
  raise TypeError("compute_plan() requires model_classes or models.")
933
+
934
+ # Normalize existing_tables to SchemaState for uniform handling.
935
+ # When the caller passes a legacy dict[str, list[str]], we build a SchemaState
936
+ # with column-name-only entries (no default/not_null/index info) and suppress
937
+ # the index/default autodiff so existing callers are unaffected.
938
+ has_rich_state: bool
939
+ schema_state: SchemaState
784
940
  if existing_tables is None:
785
- existing_tables = {}
941
+ schema_state = SchemaState()
942
+ has_rich_state = True
943
+ elif isinstance(existing_tables, SchemaState):
944
+ schema_state = existing_tables
945
+ has_rich_state = True
946
+ else:
947
+ # Legacy dict[str, list[str]] — columns only, no index/default state.
948
+ schema_state = SchemaState(
949
+ tables={
950
+ tbl: {col: ColumnState(sql_type="") for col in cols}
951
+ for tbl, cols in existing_tables.items()
952
+ }
953
+ )
954
+ has_rich_state = False
786
955
 
787
956
  ops: list[dict[str, Any]] = []
788
957
  timestamp = datetime.datetime.now(tz=datetime.UTC).strftime("%Y%m%d_%H%M%S")
@@ -800,7 +969,7 @@ def compute_plan(
800
969
  metadata = cls.get_metadata()
801
970
  table = metadata.table_name
802
971
 
803
- if table not in existing_tables:
972
+ if table not in schema_state.tables:
804
973
  # Detect composite PK: more than one field has pk=True.
805
974
  pk_fields_list = [f for f in metadata.fields if f.pk]
806
975
  is_composite_pk = len(pk_fields_list) > 1
@@ -892,7 +1061,7 @@ def compute_plan(
892
1061
  owner_col = f"{table}_id"
893
1062
  target_col = f"{target_table}_id"
894
1063
 
895
- if through not in existing_tables:
1064
+ if through not in schema_state.tables:
896
1065
  ops.append(
897
1066
  {
898
1067
  "kind": "create_table",
@@ -940,9 +1109,13 @@ def compute_plan(
940
1109
  }
941
1110
  )
942
1111
  else:
943
- existing_cols = set(existing_tables[table])
1112
+ # Table already exists — add new columns and (when rich state is
1113
+ # available) diff indexes and column default/nullability.
1114
+ existing_col_attrs = schema_state.tables.get(table, {})
1115
+ existing_col_names: set[str] = set(existing_col_attrs.keys())
1116
+
944
1117
  for f in metadata.fields:
945
- if f.column_name not in existing_cols:
1118
+ if f.column_name not in existing_col_names:
946
1119
  col_def = _field_to_col_def(f, is_pk=False)
947
1120
  ops.append(
948
1121
  {
@@ -964,6 +1137,16 @@ def compute_plan(
964
1137
  }
965
1138
  )
966
1139
 
1140
+ if has_rich_state:
1141
+ _autodiff_existing_table(
1142
+ ops,
1143
+ metadata,
1144
+ table,
1145
+ existing_col_names,
1146
+ existing_col_attrs,
1147
+ schema_state.indexes,
1148
+ )
1149
+
967
1150
  return {
968
1151
  "version": 1,
969
1152
  "name": f"auto_{timestamp}",