Architecture review — ferro-orm

2026-07-08 · Python ORM, Rust core · models → SchemaIR → runtime DDL + Alembic bridge

module seam leakage deep module

1 · Compile a column fact once — the ColumnSpec module

Strong in-process

fields.py:482–523 · metaclass.py:440–495 · schema_metadata.py:112–183 · ir/compiler.py:143–188 · base.py:60–129

Before — four representations of one fact

kwargs fields.Field() packs → json_schema_extra
↓ unpack (hand-kept key whitelist)
FerroField metaclass._parse_ferro_field_metadata
↓ re-serialize (loose dict keys)
JSON-schema dict schema_metadata.build_model_schema
↓ re-parse + re-derive with different defaults
SchemaIR column ir/compiler._column_ir

autoincrement: default pk ∧ integer upstream, pk downstream. nullable: derived twice. The #153 regression lives in a compiler comment.

After — one deep value object

ColumnSpec

built once at class-definition time

pk · autoincrement · nullable · db_type · fk — each derived exactly once

↓ consumed by
SchemaIR compiler
Alembic bridge

Problem. One field declaration is packed, unpacked, re-serialized, and re-parsed across five files; two modules compute autoincrement and nullable independently with different defaulting rules.

Solution. A ColumnSpec value object produced once when the class body executes, consumed directly by the SchemaIR compiler and the Alembic bridge — the JSON-schema dict stops doubling as an untyped IR.

  • locality: one defaulting rule per fact
  • new column option = one edit, not five
  • ColumnSpec unit-tests need no Model compile

2 · Put resolved registration behind a Registry module

Strong in-process

ferro/state.py:56–139 · src/state.rs:137–224 · tests/conftest.py:213–277 · 25 test files

Before — tests reach past the interface

flowchart TD
  T1[25 test files] --> G1[_MODEL_REGISTRY_PY]
  T1 --> G2[_PENDING_RELATIONS]
  T1 --> G3[_JOIN_TABLE_REGISTRY]
  F[clean_registry fixture] --> G1
  F --> G4[4 envelope + modelset stores]
  F --> G5[generation counters]
  F --> R[Rust clear_registry]
  Q[am I synced?] -.-> G5
  Q -.-> G6[Py fingerprint]
  Q -.-> G7[Rust INSTALLED_FINGERPRINT]
  classDef leak stroke:#dc2626,stroke-width:2px;
  class T1,F,Q leak
      

The clear ritual is hand-rolled ~50×; the fixture wipes 8 globals; the synced invariant lives in 3 representations across 2 languages.

After — one interface, one reset

Registry

is_current() · reset() · isolated()

7 stores · generation counters · modelset fingerprint

provisional → resolved epoch owned internally

metaclass writes through it
one isolated_registry fixture
push seam unchanged

Problem. The registry's seven stores are module globals, so provisional and resolved registration have no interface for tests to cross — 25 files import internals, and "is the runtime current?" must be answered by reading counters in state.py, a fingerprint in state.py, and a fingerprint in state.rs.

Solution. A Registry module owning stores, counters, and fingerprint behind is_current() / reset() / isolated(); the metaclass writes through an injectable current-registry handle.

Extends ADR-0001, doesn't contradict it — install_registration stays the single atomic push; this deepens the Python side the ADR already centralized.
  • leverage: one fixture replaces ~50 hand-rolled clears
  • locality: sync invariant readable in one module
  • tests stop importing ferro.state internals
  • test-only FFI counters become ordinary assertions

3 · Split operations.rs along its horizontal seams

Strong in-process

src/operations.rs (2,640 non-test lines; 1,200 more are 8 inline test modules)

Before — five concerns interleaved in every op

route_engine preamble — repeated ×16
inline SQL build (48 lines inside fetch_filtered)
execute + engine-bind marshalling
hydration loop — triplicated verbatim ×3
identity-map store manager (lines 109–246)

fetch_all / fetch_one / fetch_filtered each re-implement the same identity-map hit → refresh, miss → hydrate loop.

After — three deep bands, ops become glue

sql_build (plan, schema, dialect) → (sql, binds) pure — no DB
exec Executor — already half-formed
read_assembly assemble_rows(rows, ReadCtx) → PyList identity-map hidden inside
each #[pyfunction]: route → build → execute → assemble

Problem. Five modules share one file — routing, identity-map store, SQL building, execution, hydration — interleaved inside each async closure; the read-path loop is paid three times and the routing preamble sixteen.

Solution. Split horizontally (not per-operation): a pure sql_build module, the existing Executor as exec, and assemble_rows hiding the identity-map fast-path behind a ReadCtx.

  • SQL building unit-tests need no DB
  • identity-map branch matrix tested over synthetic rows
  • delete two of three hydration loops
  • the 8 inline test modules already test the extractable seams — evidence they want to exist

4 · Delete the dead DDL and registration paths

Strong quick win

ferro-migrate/lib.rs:120–172 · src/schema.rs:163–199 · src/lib.rs:93 · src/migrate.rs:305 · emit.rs:85

Before — dead paths still on the interface

flowchart LR
  P[migrate.rs] --> E2[emit_sql_with_ir]
  E2 --> DDL[ferro-ddl-lowering]
  T1[its own tests only] -.-> E1[emit_sql — 7/10 ops are placeholder comments]
  T2[test_provisional_import only] -.-> RMS[register_model_schema FFI]
  RMS -- writes MODEL_REGISTRY directly --> REG[(Rust registry)]
  INST[install_registration
build-then-swap] --> REG classDef dead stroke:#dc2626,stroke-width:2px,stroke-dasharray:4 4,color:#dc2626; classDef good fill:#0f172a,color:#fff; class E1,RMS,T1,T2 dead class INST,E2 good

After — one emit path, one install path

flowchart LR
  P[migrate.rs] --> E2[emit_sql_with_ir
+ DropColumn now executable] E2 --> DDL[ferro-ddl-lowering] INST[install_registration] --> REG[(Rust registry)] classDef good fill:#0f172a,color:#fff; class INST,E2 good

ADR-0001's single-install invariant becomes structural: you can't call what isn't exported.

Problem. emit_sql is production-dead (kept alive by its own tests); register_model_schema has zero production callers and writes MODEL_REGISTRY directly, bypassing the atomic build-then-swap; DROP COLUMN is rendered in two places with two quoting strategies; index_models is copied verbatim across two files.

Solution. Delete both dead entries, make DropColumn executable through emit_sql_with_ir, hoist index_models.

Deletion test: complexity vanishes — these are pass-throughs and stubs, not load-bearing modules.

  • interface shrinks: one FFI entry, one emit path
  • the non-atomic registry back door closes
  • "is it not called" tests become moot

5 · Make the QueryIR envelope a real contract

Worth exploring cross-language seam

query/builder.py:32–42 · query/nodes.py:97–151 · src/query.rs:64–140 · src/migrate.rs:69–74

Before — versioned in name only

sequenceDiagram
  participant Py as nodes.py / builder.py
  participant Rs as query.rs
  Py->>Rs: {ir_kind, ir_version: 1, payload} as JSON string
  Note over Rs: deserializes payload directly —
ir_kind and ir_version never read Py->>Rs: operator "LIKE" (string) Note over Rs: match operator.as_str()
unknown → runtime Err mid-query

After — gate on ingest, one vocabulary

sequenceDiagram
  participant Py as nodes.py / builder.py
  participant Rs as query.rs
  Py->>Rs: envelope
  Note over Rs: validate ir_kind + ir_version
on every ingest (queries too) Note over Py,Rs: operator vocabulary generated
from one shared source —
unknown operator fails at build

Problem. The interface between the query builder and the Rust planner is a stringly contract living in two languages: the version field is written and silently dropped, and the operator vocabulary is duplicated — golden IR fixtures are the only enforcement.

Solution. Validate kind + version on every ingest as the schema push already does; source the operator vocabulary from one shared table so both sides fail fast.

  • version mismatch fails loudly, not silently
  • new operator = one edit, build-time failure
  • IR vectors become conformance suite, not sole safety net

6 · Collapse the naming FFI band into one deep call

Worth exploring FFI seam

src/naming_ffi.rs · ir/compiler.py:199–245 · migrations/alembic.py:104–201

Before — 10 shallow hops per model

Python
re-assembles
name-by-name
ferro-ddl-
lowering

8 _ddl_* fns + _resolve_storage_type + _render_check_body — interface ≈ implementation.

After — one deep hop

Python
consumes
whole set
compute_ddl_names
(model_ir)

fk_names · index_names · unique_names · check_names · storage_types — one payload.

Problem. A wide shallow band: one FFI hop per DDL identifier, with Python orchestrating name-by-name what Rust could return whole.

Solution. One compute_ddl_names(model_ir) returning the full naming set per model.

  • leverage: 10 shallow calls → 1 deep call
  • cross-emitter parity asserts one blob per model
  • hand-maintained _core.pyi drift surface shrinks

7 · Concentrate the duplicated derivations

Worth exploring in-process

_annotation_utils.py · metaclass.py:136–146 · schema_metadata.py:33–43 · _shadow_fk_types.py:21–68 · models.py:348–416 · relations/descriptors.py:42–66 · composite_indexes.py / composite_uniques.py

Before — the same rule, hand-copied

annotation unwrap × 5 (only 1 copy handles PEP-695 __value__)

primary-key lookup × 4 (one copy dead)

composite indexes / uniques — shallow twins

After — one home each

_annotation_utils — the one unwrap module; other four call in
__ferro_pk__ — computed once at class definition; everyone reads it
composite groups — one parametrized module, two thin declarations

Problem. Three rules — peel Annotated/T | None, find the primary key, validate composite groups — are each re-implemented in several modules with subtly different scope, so a fix in one copy doesn't propagate.

Solution. One home per rule; the copies become calls.

Deletion test: the duplicates vanish into the shared module — they are copied logic, not independent concerns.

  • locality: unwrap bug fixed once, fixed everywhere
  • delete ~150 duplicate lines and one dead loop

8 · Test the codec belt through its existing pure seams

Worth exploring tests only — no restructure

src/codec.rs (523 lines, 0 tests) · src/hydration.rs (0) · src/migrate.rs:334–386 (0) — vs codec_plan.rs (11 tests)

Before — the plan is tested, its application isn't

codec_plan — decide once per epoch 11 Rust tests
codec — apply: binds + decode_engine_value 0 tests
hydration — RustValue → Python 0 tests
migrate.rs SQLite drop-column autoindex logic 0 tests

OID-correct Postgres binds — the code most likely to regress silently — is verified only via built extension + live Postgres.

After — the interface is the test surface

decode_engine_value(EngineValue, plan) → RustValue — already pure, just lacks a test module
schema_bind_expr(...).to_string() — assertable without a DB (pattern operations.rs already uses)
drop-column decision(index rows) → plan — extract as pure function over introspected rows

Problem. RustValue is a clean seam between plan, application, and hydration — but only the plan side has Rust tests; bind/decode correctness rides entirely on end-to-end Python suites.

Solution. No restructure — the pure seams exist; give them #[cfg(test)] modules, and extract the one DB-coupled decision (SQLite autoindex blocking) into a pure function.

  • bind regressions caught in cargo test, no Postgres needed
  • RustValue earns its role as the test surface

9 · A second adapter behind the operation seam

Speculative ports & adapters

query/builder.py:97–102 · ferro/state.py:239–341 · tests/conftest.py:125–132

Before — one adapter, hypothetical seam

Python ORM
routing · query building · sessions
Rust engine (real DB)
— empty slot —

conftest hard-fails the whole session if _core won't import; ~53 of 80 test files boot a real engine.

After — two adapters, real seam

Python ORM
Rust engine (prod + e2e)
in-memory fake (unit)

Routing, precedence, and query-immutability tests run without the .so or a DB.

Problem. The FFI seam has exactly one adapter — the real engine — so pure-Python behaviour (route resolution, operation-scope precedence, query immutability) is only exercisable end-to-end.

Solution. A narrow Python-level backend interface (fetch_filtered / save_record / …) with an in-memory fake as the second adapter.

One adapter means a hypothetical seam — the fake must earn its keep or it tests a fiction. Whether the Python-only logic is rich enough to justify a second adapter is the design question to grill.

Genuinely deep — leave alone

state::install_registration — build-then-swap, fingerprint gate, retained-last-good; ADR-0001's payoff working.

src/errors.rs — pure classifier, typed exceptions, reused at ~44 sites; the model to imitate.

RouteHandle — resolved once in Python, threaded by value, frozen; structurally enforced.

EngineHandle pool-swap — stale-statement-cache hazard owned internally; real two-adapter dialect seam.

codec trio responsibility split — RustValue is a clean seam (only its tests are missing, card 8).

alembic.get_metadata — a true second consumer of SchemaIR; proves the IR is a real seam.

Top recommendation

2 · The Registry module

It deepens the seam ADR-0001 already built, exactly where the friction is measured: 25 test files reaching past the interface, a fixture wiping eight globals, and a sync invariant split across three representations in two languages. And it compounds — once tests cross one Registry interface, every other candidate here (ColumnSpec, the operations.rs split, the dead-path deletions) becomes safer to verify. Do the seam that makes the rest of the work testable first.

Runner-up: 4 · dead-path deletion — an afternoon's work that closes a live violation channel around the atomic install.