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.
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/Cargo.lock +4 -4
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/Cargo.toml +1 -1
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/PKG-INFO +46 -1
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/README.md +45 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/pyproject.toml +1 -1
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/__init__.py +1 -1
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/makemigrations_cmd.py +64 -15
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/__init__.py +12 -1
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/orchestrator.py +196 -13
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/models.py +45 -4
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/LICENSE +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/Cargo.toml +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/benches/hydrate.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/src/compile/mod.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/src/error.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/src/ir/metadata.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/src/ir/mod.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/src/lib.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-core/src/migrate/mod.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-pyo3/Cargo.toml +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-pyo3/src/lib.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/Cargo.toml +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/benches/compile.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/dialect.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/emit.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/mod.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/lib.rs +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/_native.pyi +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/__init__.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/app.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/bootstrap.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/init.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/inspectdb_cmd.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/migrate_cmd.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/migrations_cmd.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/resetdb_cmd.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/revert_cmd.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/showmigrations_cmd.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/config.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/connection.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/contrib/__init__.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/contrib/fastapi.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/drivers/__init__.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/drivers/mssql.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/drivers/mysql.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/drivers/postgres.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/drivers/protocol.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/drivers/sqlite.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/errors.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/expressions.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/ext/__init__.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/ext/fts.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/ext/pgvector.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/hooks.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/base.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/drift.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/__init__.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/mssql.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/mysql.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/postgres.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/sqlite.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/gates.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/introspect.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/ledger.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/loader.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/operations.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/migrations/tokens.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/observability.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/py.typed +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/queryset.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/registry.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/relations.py +0 -0
- {ferrum_orm-0.1.8 → ferrum_orm-0.1.9}/python/ferrum/runtime.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
258
|
+
version = "0.1.9"
|
|
259
259
|
dependencies = [
|
|
260
260
|
"criterion",
|
|
261
261
|
"ferrum-core",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ferrum-orm
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.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
|
|
|
@@ -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
|
-
) ->
|
|
65
|
-
"""Replay prior migration operations to produce
|
|
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.
|
|
68
|
-
``create_table``, ``add_column``, ``drop_table``,
|
|
69
|
-
|
|
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
|
|
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] =
|
|
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
|
-
|
|
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
|
|
89
|
-
state[table].
|
|
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``,
|
|
129
|
-
emitted by ``compute_plan
|
|
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
|
|
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``
|
|
757
|
-
|
|
758
|
-
|
|
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
|
|
761
|
-
|
|
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:
|
|
768
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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"] = "
|
|
647
|
+
ferrum_extras["db_default"] = "GEN_RANDOM_UUID()"
|
|
618
648
|
elif uuid_generate == "v7":
|
|
619
|
-
ferrum_extras["db_default"] = "
|
|
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 = "
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|