Skip to content

Support foreign keys in forms and reserve relation names#1135

Draft
audreyfeldroy wants to merge 11 commits into
mainfrom
foreign-key-support
Draft

Support foreign keys in forms and reserve relation names#1135
audreyfeldroy wants to merge 11 commits into
mainfrom
foreign-key-support

Conversation

@audreyfeldroy

Copy link
Copy Markdown
Member

Summary

  • add foreign key metadata and relation-name collision validation in AirModel
  • make runtime choices= render arbitrary form fields as <select> elements
  • add focused model and form coverage for the new FK and form behavior

Why

The foreign key spec relied on behavior that was not fully enforced in code:

  • derived relation names could shadow inherited AirModel and BaseModel attributes
  • runtime choices= did not turn plain fields into selects, even though the spec said it would

Impact

  • foreign-key fields that derive reserved relation names now fail at model definition time with a clear error
  • AirForm(..., choices=...) can drive select rendering for plain fields, not just static Choices/Enum/Literal cases
  • legacy custom form widgets remain compatible even if they do not accept a choices keyword

Root Cause

  • AirField used the parameter name type, which shadowed Python's built-in type during foreign-key validation
  • form rendering only considered static field metadata when deciding whether a field should be rendered as a select

Validation

  • uv run python -m pytest tests/test_model.py tests/test_form.py -q

Defines the API: AirField(foreign_key=Maker) declares an integer
column that references another model's primary key. Three layers:
SQL generation emits REFERENCES constraints, forms auto-render FK
fields as <select> dropdowns with fetched options, and the metadata
follows Air's existing BasePresentation pattern.

Deliberately excludes cascading deletes, reverse relations, and
join queries — those are separate features.
The initial spec covered the happy path but left structural gaps:
forward references break when the target model isn't defined yet,
table creation order ignores FK dependencies, form rendering can't
fetch options without going async, _add_column_sql omits REFERENCES,
and ForeignKey inherits the wrong base class.

Key design decisions:
- String forward references ("Maker") resolve lazily against the
  registry at first use, not at class definition time
- Topological sort (Kahn's algorithm) in create_tables() so FK
  targets exist before dependents
- render() stays sync; views pre-fetch FK choices explicitly via
  fk_choices parameter and a new as_choices() helper on AirModel
- New BaseStructure base class separates structural metadata (DDL)
  from presentation metadata (rendering); PrimaryKey moves there too
- foreign_key and choices are mutually exclusive, validated at
  AirField() call time

Automatic option fetching in forms, cascading deletes, reverse
relations, and join queries are all explicitly out of scope.
The spec now handles self-referential FKs (Category -> Category),
multiple FKs to the same target, eager validation of non-AirModel
arguments, and misspelled choice keys. It documents what happens
when you delete a referenced row, what to do when adding a
constraint to an existing column, and why FK columns get indexes.

Key design decisions:
- Dropped BaseStructure; ForeignKey inherits BasePresentation like
  PrimaryKey, since no consumer queries the base class
- resolve(registry) takes the registry as a parameter instead of
  importing it, keeping field/types.py free of model imports
- Renamed fk_choices to a general choices parameter on AirForm so
  there's one mechanism for dynamic select options, not two
- as_choices() accepts order_by and limit to prevent unbounded
  queries on large tables
- Eager issubclass check in AirField() catches foreign_key=str
  or foreign_key=42 at definition time

FK existence validation on form submit is explicitly out of scope
(Pydantic checks the type, PostgreSQL enforces the constraint).
reverse queries, and eager loading

The spec previously deferred three features users would need
immediately after declaring foreign keys. All three are now
designed: on_delete controls what PostgreSQL does when a parent
is deleted, related() lets a parent query its children, and
select_related fetches parent records in batch to avoid N+1.

Key design decisions:
- on_delete is a string ("cascade", "set_null", "restrict") that
  maps directly to SQL ON DELETE clauses. PostgreSQL handles it,
  no Python-level logic needed. Default is "restrict".
- related() discovers FK fields on the child model automatically.
  When multiple FKs point to the same parent (created_by,
  updated_by), it raises with a message directing users to
  filter() with the specific field name.
- select_related uses batch queries (one per FK field), not JOINs.
  JOINs change the result set shape and complicate row-to-model
  mapping. Batch queries are simpler and sufficient for typical
  use. Works on all(), filter(), and get().
- Attached attributes use a naming convention: maker_id becomes
  maker_ (strip _id, add _). Fields without _id suffix get _rel
  appended. Trailing underscore avoids collisions with existing
  model fields.

Composite FKs, automatic form validation of FK existence, and
constraint migration for existing columns remain permanently out
of scope.
The spec had grown to 879 lines of pseudocode wrapped in prose,
with every decision buried inside a function body the implementer
would have to write twice. It now reads as decisions and
constraints: API contracts, SQL output examples, and design
rationale for the non-obvious calls.

Key design decisions kept or refined:
- on_delete is now Literal["cascade", "set_null", "restrict"] for
  IDE autocomplete and type-check-time validation
- select_related accepts relation names ("maker") not field names
  ("maker_id"), and attaches attributes under the same name so
  users query and read with the same word
- as_choices() detects whether a model overrides __str__ and falls
  back to "ClassName #pk". Pydantic's default __str__ produces
  "Maker(id=1, name='Kuma-san')" which is unusable in a dropdown.
- Dynamic form choices reach the renderer by growing the widget
  callable protocol with a choices parameter. Mutating
  field_info.metadata at render time is class-level shared state
  and non-reentrant under async concurrent requests. Synthesizing
  a per-render model class pollutes _table_registry. Threading a
  parameter is honest about what's happening.
- filter()'s empty-kwargs delegation to all() gets removed during
  implementation. Each method handles its own query building so
  select_related (and any future parameter) can't silently drop.

Stripped every implementation code block that prose + test cases
already describe without ambiguity: resolve() body, Kahn's
algorithm, _load_related, AirField validation logic, _column_defs
integration, render() injection flow. Kept the ForeignKey
dataclass definition, AirField signature, SQL output examples,
as_choices helper, and all user-facing API examples, since those
are the contract the implementer must meet.
Dynamic choices from AirForm(choices=...) reach the widget as
list[tuple[Any, str]] with typically-int PK values. The existing
select renderer calls html.escape(opt_val) and str(value) == opt_val,
which crashes on ints and silently fails the pre-selection
comparison. Hardening the render site (escape(str(opt_val)),
str(value) == str(opt_val)) fixes both the incoming dynamic-choices
path and a latent bug in the Enum path for int-valued enums.

Also tightens two other spec details the code review caught:
_get_options needs to grow a field_name parameter to look up
dynamic choices (not just the widget protocol), and the
DangoOptional test assertion now checks for the specific string
'"order_id" INTEGER NOT NULL' instead of a brittle split that
happened to work only because order_id was the last column.
@audreyfeldroy audreyfeldroy changed the title [codex] Support foreign keys in forms and reserve relation names Support foreign keys in forms and reserve relation names Apr 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant