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.
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/Cargo.lock +6 -6
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/Cargo.toml +1 -1
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/PKG-INFO +54 -1
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/README.md +53 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/pyproject.toml +1 -1
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/__init__.py +1 -1
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/makemigrations_cmd.py +64 -15
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/__init__.py +12 -1
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/orchestrator.py +196 -13
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/models.py +45 -4
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/queryset.py +220 -11
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/LICENSE +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/Cargo.toml +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/benches/hydrate.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/src/compile/mod.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/src/error.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/src/ir/metadata.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/src/ir/mod.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/src/lib.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-core/src/migrate/mod.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-pyo3/Cargo.toml +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-pyo3/src/lib.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/Cargo.toml +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/benches/compile.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/dialect.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/emit.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/mod.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/crates/ferrum-sql/src/lib.rs +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/_native.pyi +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/__init__.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/app.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/bootstrap.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/init.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/inspectdb_cmd.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/migrate_cmd.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/migrations_cmd.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/resetdb_cmd.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/revert_cmd.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/showmigrations_cmd.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/config.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/connection.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/contrib/__init__.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/contrib/fastapi.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/drivers/__init__.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/drivers/mssql.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/drivers/mysql.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/drivers/postgres.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/drivers/protocol.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/drivers/sqlite.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/errors.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/expressions.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/ext/__init__.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/ext/fts.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/ext/pgvector.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/hooks.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/base.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/drift.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/__init__.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/mssql.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/mysql.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/postgres.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/fts/sqlite.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/gates.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/introspect.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/ledger.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/loader.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/operations.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/migrations/tokens.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/observability.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/py.typed +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/registry.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/relations.py +0 -0
- {ferrum_orm-0.1.7 → ferrum_orm-0.1.9}/python/ferrum/runtime.py +0 -0
- {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.
|
|
177
|
+
version = "0.9.20"
|
|
178
178
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
179
|
-
checksum = "
|
|
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.
|
|
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
|
|
@@ -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
|
|
|
@@ -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}",
|