ferrum-orm 0.1.8__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.8 → ferrum_orm-0.1.9}/Cargo.lock +4 -4
  2. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/Cargo.toml +1 -1
  3. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/PKG-INFO +46 -1
  4. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/README.md +45 -0
  5. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/pyproject.toml +1 -1
  6. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/__init__.py +1 -1
  7. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/makemigrations_cmd.py +64 -15
  8. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/__init__.py +12 -1
  9. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/orchestrator.py +196 -13
  10. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/models.py +45 -4
  11. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/LICENSE +0 -0
  12. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/Cargo.toml +0 -0
  13. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/benches/hydrate.rs +0 -0
  14. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/src/compile/mod.rs +0 -0
  15. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/src/error.rs +0 -0
  16. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
  17. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/src/ir/metadata.rs +0 -0
  18. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/src/ir/mod.rs +0 -0
  19. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/src/lib.rs +0 -0
  20. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/src/migrate/mod.rs +0 -0
  21. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-pyo3/Cargo.toml +0 -0
  22. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-pyo3/src/lib.rs +0 -0
  23. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/Cargo.toml +0 -0
  24. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/benches/compile.rs +0 -0
  25. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/dialect.rs +0 -0
  26. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/emit.rs +0 -0
  27. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/mod.rs +0 -0
  28. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
  29. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
  30. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
  31. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
  32. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/lib.rs +0 -0
  33. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/_native.pyi +0 -0
  34. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/__init__.py +0 -0
  35. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/app.py +0 -0
  36. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/bootstrap.py +0 -0
  37. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/init.py +0 -0
  38. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/inspectdb_cmd.py +0 -0
  39. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/migrate_cmd.py +0 -0
  40. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/migrations_cmd.py +0 -0
  41. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/resetdb_cmd.py +0 -0
  42. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/revert_cmd.py +0 -0
  43. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/showmigrations_cmd.py +0 -0
  44. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
  45. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/config.py +0 -0
  46. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/connection.py +0 -0
  47. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/contrib/__init__.py +0 -0
  48. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/contrib/fastapi.py +0 -0
  49. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/drivers/__init__.py +0 -0
  50. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/drivers/mssql.py +0 -0
  51. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/drivers/mysql.py +0 -0
  52. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/drivers/postgres.py +0 -0
  53. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/drivers/protocol.py +0 -0
  54. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/drivers/sqlite.py +0 -0
  55. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/errors.py +0 -0
  56. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/expressions.py +0 -0
  57. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/ext/__init__.py +0 -0
  58. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/ext/fts.py +0 -0
  59. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/ext/pgvector.py +0 -0
  60. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/hooks.py +0 -0
  61. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/base.py +0 -0
  62. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/drift.py +0 -0
  63. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/__init__.py +0 -0
  64. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/mssql.py +0 -0
  65. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/mysql.py +0 -0
  66. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/postgres.py +0 -0
  67. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/sqlite.py +0 -0
  68. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/gates.py +0 -0
  69. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/introspect.py +0 -0
  70. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/ledger.py +0 -0
  71. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/loader.py +0 -0
  72. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/operations.py +0 -0
  73. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/tokens.py +0 -0
  74. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/observability.py +0 -0
  75. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/py.typed +0 -0
  76. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/queryset.py +0 -0
  77. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/registry.py +0 -0
  78. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/relations.py +0 -0
  79. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/runtime.py +0 -0
  80. {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/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.8"
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.8"
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.8"
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.8"
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.8"
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.8
3
+ Version: 0.1.9
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Framework :: AsyncIO
6
6
  Classifier: Intended Audience :: Developers
@@ -168,6 +168,49 @@ class User(Model):
168
168
 
169
169
  No duplicate schema definitions.
170
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
+
171
214
  ### Django-Inspired API
172
215
 
173
216
  Familiar query interface.
@@ -309,6 +352,8 @@ and [API Reference](docs/api-reference.md) for per-dialect DDL and operator mapp
309
352
  - [x] `call_function` for allowlisted stored-procedure calls
310
353
  - [x] Migration ops for extensions, RLS policies, and function DDL
311
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
312
357
  - [ ] Query optimization (deferred fields, prefetch tuning)
313
358
  - [ ] Advanced relationship loading
314
359
 
@@ -96,6 +96,49 @@ class User(Model):
96
96
 
97
97
  No duplicate schema definitions.
98
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
+
99
142
  ### Django-Inspired API
100
143
 
101
144
  Familiar query interface.
@@ -237,6 +280,8 @@ and [API Reference](docs/api-reference.md) for per-dialect DDL and operator mapp
237
280
  - [x] `call_function` for allowlisted stored-procedure calls
238
281
  - [x] Migration ops for extensions, RLS policies, and function DDL
239
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
240
285
  - [ ] Query optimization (deferred fields, prefetch tuning)
241
286
  - [ ] Advanced relationship loading
242
287
 
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ferrum-orm"
7
- version = "0.1.8"
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.8"
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}",
@@ -570,6 +570,20 @@ def _is_db_default_expression(value: str) -> bool:
570
570
  return "(" in value and ")" in value
571
571
 
572
572
 
573
+ def _normalize_db_default(value: str) -> str:
574
+ """Normalize a DB default token to its canonical uppercase form.
575
+
576
+ Maps well-known SQL function tokens to their uppercase canonical form
577
+ (``now()`` → ``NOW()``, ``gen_random_uuid()`` → ``GEN_RANDOM_UUID()``) so
578
+ that the default allowlist check and equality comparisons in ``compute_plan``
579
+ are case-insensitive. Unknown expressions pass through unchanged.
580
+ """
581
+ upper = value.upper()
582
+ if upper in _DB_DEFAULT_STRINGS:
583
+ return upper
584
+ return value
585
+
586
+
573
587
  def Field( # noqa: N802
574
588
  *,
575
589
  max_length: int | None = None,
@@ -579,6 +593,8 @@ def Field( # noqa: N802
579
593
  unique: bool = False,
580
594
  db_index: bool = False,
581
595
  default: Any = ..., # noqa: ANN401
596
+ db_default: str | None = None,
597
+ nullable: bool | None = None,
582
598
  primary_key: bool = False,
583
599
  uuid_generate: Literal["v4", "v7"] | None = None,
584
600
  vector_dimensions: int | None = None,
@@ -599,6 +615,16 @@ def Field( # noqa: N802
599
615
  - Plain ``str`` literals (including ``""``): Python-side default; migrations may
600
616
  emit a matching SQL default for ``NOT NULL`` text columns.
601
617
  - Any other value: passed to Pydantic as ``default``.
618
+
619
+ ``db_default`` (explicit):
620
+ - Accepts a SQL default expression string directly (e.g. ``"now()"``, ``"NOW()"``).
621
+ - Normalized to canonical uppercase form before storage.
622
+ - Takes precedence over the ``default=`` string-expression path.
623
+
624
+ ``nullable``:
625
+ - When provided, overrides the annotation-derived nullability in
626
+ ``_build_metadata``. Use to mark a ``T | None``-annotated field as
627
+ ``NOT NULL`` (e.g. ``Field(nullable=False)``).
602
628
  """
603
629
  ferrum_extras: dict[str, Any] = {
604
630
  "max_length": max_length,
@@ -613,17 +639,25 @@ def Field( # noqa: N802
613
639
  "fts_source_columns": fts_source_columns,
614
640
  }
615
641
 
642
+ # Store explicit nullable override for _build_metadata to consume.
643
+ if nullable is not None:
644
+ ferrum_extras["nullable"] = nullable
645
+
616
646
  if uuid_generate == "v4":
617
- ferrum_extras["db_default"] = "gen_random_uuid()"
647
+ ferrum_extras["db_default"] = "GEN_RANDOM_UUID()"
618
648
  elif uuid_generate == "v7":
619
- ferrum_extras["db_default"] = "uuidv7()"
649
+ ferrum_extras["db_default"] = "UUIDV7()"
620
650
 
621
651
  if isinstance(default, str) and _is_db_default_expression(default):
622
- ferrum_extras["db_default"] = default
652
+ ferrum_extras["db_default"] = _normalize_db_default(default)
623
653
  kwargs["default"] = None
624
654
  elif default is not ...:
625
655
  kwargs["default"] = default
626
656
 
657
+ # Explicit db_default= param takes highest priority over all other paths.
658
+ if db_default is not None:
659
+ ferrum_extras["db_default"] = _normalize_db_default(db_default)
660
+
627
661
  if max_length is not None:
628
662
  kwargs["max_length"] = max_length
629
663
 
@@ -712,6 +746,11 @@ def _build_metadata(cls: type[_PydanticBaseModel]) -> ModelMetadata:
712
746
  if isinstance(finfo_jse, dict) and "__ferrum__" in finfo_jse:
713
747
  ferrum_extras = finfo_jse["__ferrum__"]
714
748
 
749
+ # Honor explicit nullable= override from Field() — wins over annotation-derived value.
750
+ nullable_override = ferrum_extras.get("nullable")
751
+ if nullable_override is not None:
752
+ nullable = bool(nullable_override)
753
+
715
754
  is_pk = bool(ferrum_extras.get("primary_key", False))
716
755
 
717
756
  # Implicit PK: first int field named "id" when no explicit PK is declared.
@@ -743,8 +782,10 @@ def _build_metadata(cls: type[_PydanticBaseModel]) -> ModelMetadata:
743
782
  column_name = ferrum_extras.get("db_column") or name
744
783
 
745
784
  db_default = ferrum_extras.get("db_default")
785
+ if db_default is not None and isinstance(db_default, str):
786
+ db_default = _normalize_db_default(db_default)
746
787
  if is_pk and db_type == "uuid" and db_default is None:
747
- db_default = "gen_random_uuid()"
788
+ db_default = "GEN_RANDOM_UUID()"
748
789
 
749
790
  python_default: Any | None = None
750
791
  if field_info.default is not ...:
File without changes