Repository navigation
feat: retired columns, Field(retired=True) (#625) - #631
Conversation
…egistration (#625) `SchemaColumn.retired` (serde default false, skipped when false, so every existing envelope is byte-identical). `RegisteredModel::new` leaves retired columns out of `column_names` and the codec plan, once; the root fetch names the registration's columns instead of `"t".*`, so a column the table holds beyond the model's never reaches an instance (ADR-0053, ADR-0008). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
…t the schema (#625) `Field(retired=True)`, `FerroField(retired=True)` and `ForeignKey(retired=True)` retire a column (ADR-0053): the metaclass derives its column spec once, then strips the field from the Pydantic model, so the model has no attribute, keyword or dump key for it, and no statement it issues names the column. The IR compiler emits the column beside the mapped ones, flagged and nullable, so its index, unique, check, foreign key and every table check, composite or policy naming it stay as declared. `Author(bio=...)` is a TypeError naming the field; a query lambda, `order_by`, `update()` and `save(only=/exclude=)` naming it carry "retired in this release". Keyword `update()` now validates its columns the way a recipe's keys always were. Retiring a primary key, a non-optional field, a renamed field, a BackRef or a ManyToMany, or keeping a BackRef over a retired ForeignKey, is a TypeError naming the fix. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
…oor (#625) Two casebook cases, "A6-retire-a-required-enum-column" (before -> retired) and "A6-delete-a-retired-enum-column" (retired -> gone), carry retirement through pins (a)-(g) on both dialects with no new FINDINGS entry; the retire case is a SQLite rebuild, so it joins REBUILD_CASES. Generator tests pin `ferro migrate new`: retiring a required column writes `DROP NOT NULL` (Postgres) or the rebuild (SQLite) and nothing else; retiring an optional one is "no schema change" with `check` clean; deleting the declaration is the destructive `DROP COLUMN` and the orphaned type's `DROP TYPE`. The pass, Alembic autogenerate, historical models, the IR vector `schema_retired_column_v2`, the constraint names and hydration each get their pin. Two existing tests change with the behaviour: the pool-refresh test caches a raw `SELECT *` (a model's fetch now names its columns, so it cannot run against a table missing one), and the SET-emission vectors write to a model that declares their columns (keyword `update()` now validates them). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
A "Retiring a column" section beside "Renames": both declaration styles,
backed by runnable docs/examples/migrations_retire{,_annotated}.py (three
releases: create, retire, drop), the two-release timeline, and the online
rename recipe beside `renamed_from`. A `retired` row in the Field options
table.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
`Field(retired=True, nullable=False)` (and `ForeignKey(retired=True, nullable=False)`) contradicts "a retired column stays nullable" the way a non-optional annotation does, so it is a TypeError at class creation naming the fix instead of being overridden silently (I-6). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
0x054
left a comment
There was a problem hiding this comment.
Spec: PASS
- Minor · tests/test_retired_columns.py:346 at 57fc78b · Missing (test shape) · the happy-path scenario asks for "the captured SQL names no
bio"; no statement is captured.test_a_retired_column_dropped_by_hand_changes_nothing(save, bulk_create, update, save(only), select, get against a table withoutbio, and withoutteam_idon Postgres) proves the same thing behaviourally, so this does not block. - Minor · tests/test_historical_models.py (new test at end of file) at 57fc78b · Missing (test shape) · the scenario says "a data step can read it"; the test builds the historical model and constructs an instance, but runs no data step.
- Minor · tests/test_retired_columns.py:180 at 57fc78b · Missing · "What to build" lists a projection naming the retired column among the refused surfaces; lambda,
order_by,update()andsave(only=)are tested, a projection is not. It goes through the samevalidate_query_column. - Minor · src/ferro/metaclass.py:512 at 57fc78b · Misunderstood (narrow) · the ticket refuses
retired"together with a liverenamed_from"; this refuses anyrenamed_from, including an inert one. The class cannot know liveness without the snapshot, and the message names the fix (droprenamed_from), so I accept it.
Checked: the four ticket refusals, with the ticket's exact texts. BackRef/ManyToMany with retired=True. The ruling's nullable=False refusal, tested in both styles (tests/test_retired_columns.py:231) and on ForeignKey (:247), and named in the docs refusal sentence (migrations.md "Refused at class definition…"). I-1: no new op. The relaxation is the planner's existing AlterColumnNullability, and the IR column is ordinary apart from the flag. A6-retire-a-required-enum-column (in REBUILD_CASES) and A6-delete-a-retired-enum-column were added to the casebook, FINDINGS is untouched, and test_cross_emitter_parity is green on both dialects. I-2: only the SELECT list changed (select_root_columns). No __init__ call was added on the Rust side, and test_hydration_holds_the_mapped_columns_only asserts every Pydantic slot. I-7: both tabs are backed by migrations_retire.py and _annotated.py, and test_docs_migrations runs both and compares the migrations byte for byte. IR vector contract: schema_retired_column_v2.json is added, no existing vector file changed, and retired is skip_serializing_if false, so the existing-vector tests stay green. Outside Files owned: every declared file and observation is accepted. (1) Naming the root fetch's columns is the acceptance criterion. The test_auto_migrate.py change keeps the pool-refresh coverage, because a raw SELECT * is the statement whose result shape the DDL changes, and the model fetch is still re-run after the migration. (2) Keyword update() validation changes only an unknown keyword, which now fails as an AttributeError before reaching the database. The only other caller-visible change is in two wire tests that wrote undeclared columns. (3) related_name is optional only with retired=True; it is tested. No undeclared file.
Cannot verify: whether #627 (the metaclass seam) can consume _retire_fields/__ferro_retired_names__ as built, and whether #628 rebases cleanly over the one-line retired: false in src/live_ir.rs.
Quality: PASS
- Minor · src/ferro/columns.py:176 at 57fc78b · the function-level import of
_shadow_fk_typesruns inside the per-relation loop. Hoist it above the loop (it is still a lazy import).
Verify: passed on 57fc78b. maturin develop ok. cargo: schema-ir 46, ferro-migrate 387, crate 352 passed. IR vectors: 14 passed. Listed pytest files: 1132 passed, 5 skipped, 1 xfailed (sqlite and postgres). prek run --all-files: clean. The full suite was also run on both backends: 4016 passed, 60 skipped, 2 xfailed. Worker's sdd-verify note is present on HEAD.
…nd stays in the snapshot (#625) (#631) `Field(retired=True)`, `FerroField(retired=True)` and `ForeignKey(retired=True)` take a column out of the running model while it stays in the schema snapshot, nullable, with every artifact that names it. No statement the model issues names the column: the root fetch names the registration's mapped columns instead of `*`. Retiring a required column generates the planner's `DROP NOT NULL`; deleting the declaration next release generates the destructive `DROP COLUMN` and the orphaned enum type's `DROP TYPE`. Contradictions are refused at class creation with a `TypeError` naming the fix. Closes #625 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
Closes #625
What
A column can now leave in two releases, so the release still serving during a rolling deploy keeps working. Release B declares the field retired:
ferro migrate newwrites one statement, the relaxation release A needs to keep writingbio:From B on,
author.bioraisesAttributeError,Author(bio="x")raisesTypeError, and a query,order_by,update()orsave(only=...)naming it fails with "retired in this release". NoSELECT,INSERTorUPDATEthe model issues namesbio, so B works whether the column is still there or C has already dropped it. The column stays in the snapshot, nullable, with its index, unique, check, foreign key and every table-level artifact that names it. Release C deletes the declaration: the destructiveDROP COLUMN, and theDROP TYPEwhen it was a native enum type's last column.ForeignKey(retired=True)does the same for a relation's*_idcolumn.How
SchemaColumn.retired(serde default false, skipped when false, so every existing envelope is byte-identical). The planner sees an ordinary column, so the relaxation is the existingAlterColumnNullabilityop and every door agrees (I-1): the create pass,migrate new, the reconciliation pass, drift and the Alembic bridge.__ferro_columns__/__ferro_query_columns__hold the mapped columns only. The IR compiler emits the retired columns next to them (retired_column_specs), and table checks, composites and policies compile against both.RegisteredModel::newleaves retired columns out ofcolumn_namesand the codec plan. The root fetch now namescolumn_namesinstead of"t".*, so hydration only ever sees the row the model owns (the complete-instance invariant, ADR-0008).renamed_fromtogether withretired, aBackRefover a retiredForeignKey, andBackRef/ManyToManywithretired=True. Alsonullable=Falseon a retired field orForeignKey(the same contradiction spelled through the option): ". is retired but declarednullable=False: a retired column stays nullable; dropnullable=False".Verify
Ran the ticket's Verify block on
57fc78b: passed (cargo: schema-ir 46, ferro-migrate 387, crate 352; vectors 14; listed pytest files 1132 passed, 5 skipped, 1 xfailed, sqlite + postgres; prek clean).TDD: RED
cargo test --no-default-features --features testing a_retired_column_is_outsidefailed (left: ["id", "value", "bio"],right: ["id", "value"]); REDpytest tests/test_retired_columns.py -xfailed ("bio"still inAuthor.model_fields); GREEN on both after the change, with the full suite (sqlite + postgres) green.Verify block corrected: no
Notes for the reviewer
test_auto_migrate.py::test_manual_migrate_on_live_pool_refreshes_cached_statementscached a model fetch against a table missing a declared column; it now caches a rawSELECT *, which still exercises the pool refresh.update()now validates its column names, as recipe keys always have (_literal_setinquery/wire.py). Before, an unknown keyword reached the database. Two SET-emission tests wrote to columns their model did not declare, so they now use a dedicatedSetKindsmodel.ForeignKey(related_name=...)is optional only whenretired=True. Leaving it out otherwise is aTypeErrornaming the fix.nullable=False: refused at class creation (coordinator ruling, I-6), beside the other refusals, in both declaration styles and onForeignKey.src/ferro/migrations/historical.pyneeded no change: theretiredkey is ignored there, so a historical model exposes the column as an ordinary nullable one, which a new test pins.REBUILD_CASES) and "A6-delete-a-retired-enum-column" pass pins (a)–(g) on both dialects. NoFINDINGSentry.Outside Files owned
Observations for the reviewer (behaviour, as built):
*is the ticket's acceptance criterion; it changes every model's root SELECT.update()checking its column names is what the ticket'supdate(bio="x")scenario requires.ForeignKeywithoutrelated_nameonly whenretired=Trueis consistent with "a BackRef over a retired relation is refused".Files, each with its reason:
src/ferro/fields.py:Field(retired=...)lives here, not inbase.py.src/ferro/models.py:Model.__init__(the constructionTypeError) and__getattr__(theAttributeErrortext) live here;base.pyhas no model class.src/ferro/relations/__init__.py: a retired relation's target is resolved and itsBackRefrefused during relationship resolution.src/ferro/query/wire.py: keywordupdate()column validation.src/live_ir.rs,src/codec_plan.rs,src/schema.rs,crates/ferro-ddl-lowering/src/lib.rs,crates/ferro-migrate/src/{plan,tests,revision_tests}.rs,crates/ferro-migrate/src/generate/mod.rs: eachSchemaColumnliteral gainsretired: false(a one-line edit per literal;live_ir.rsis Foreign defaults are drift: the live read carries defaults, DropDefault on every door (#622) #628's file, touched only for this).tests/test_auto_migrate.py,tests/test_query_wire_vectors.py,tests/test_docs_migrations.py: see above; the last lists the new example.🤖 Generated with Claude Code
https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR