diff --git a/src/air/field/__init__.py b/src/air/field/__init__.py
index 970c3971e..db61ae14c 100644
--- a/src/air/field/__init__.py
+++ b/src/air/field/__init__.py
@@ -15,6 +15,7 @@
CsrfToken as CsrfToken,
DisplayFormat as DisplayFormat,
Filterable as Filterable,
+ ForeignKey as ForeignKey,
Grouped as Grouped,
HelpText as HelpText,
Hidden as Hidden,
@@ -38,6 +39,7 @@
"CsrfToken",
"DisplayFormat",
"Filterable",
+ "ForeignKey",
"Grouped",
"HelpText",
"Hidden",
diff --git a/src/air/field/main.py b/src/air/field/main.py
index 9154c83f2..859abeba8 100644
--- a/src/air/field/main.py
+++ b/src/air/field/main.py
@@ -8,13 +8,15 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Any
+import builtins
+from typing import TYPE_CHECKING, Any, Literal
from pydantic import Field as PydanticField
from air.field.types import (
Autofocus,
Choices,
+ ForeignKey,
HelpText,
Label,
Placeholder,
@@ -31,6 +33,8 @@ def AirField( # noqa: C901, N802
*,
# Presentation
primary_key: bool = False,
+ foreign_key: type | str | None = None,
+ on_delete: Literal["cascade", "set_null", "restrict"] | None = None,
type: str | None = None, # noqa: A002
label: str | None = None,
widget: str | None = None,
@@ -44,9 +48,10 @@ def AirField( # noqa: C901, N802
) -> Any:
"""Unified field descriptor for Pydantic models.
- Accepts presentation metadata (``primary_key``, ``type``, ``label``,
- ``widget``, ``choices``, ``placeholder``, ``help_text``,
- ``autofocus``) and all standard ``pydantic.Field`` parameters.
+ Accepts presentation metadata (``primary_key``, ``foreign_key``,
+ ``on_delete``, ``type``, ``label``, ``widget``, ``choices``,
+ ``placeholder``, ``help_text``, ``autofocus``) and all standard
+ ``pydantic.Field`` parameters.
All AirField-specific parameters become typed metadata objects in
``field_info.metadata``. Remaining ``**kwargs`` pass through to
@@ -54,7 +59,38 @@ def AirField( # noqa: C901, N802
Returns:
A Pydantic FieldInfo configured with all specified parameters.
+
+ Raises:
+ TypeError: If ``foreign_key`` is neither an AirModel subclass nor a string reference.
+ ValueError: If AirField-specific options are combined in an invalid way.
"""
+ if foreign_key is not None and choices is not None:
+ msg = "foreign_key and choices are mutually exclusive"
+ raise ValueError(msg)
+ if foreign_key is not None and primary_key:
+ msg = "foreign_key and primary_key are mutually exclusive"
+ raise ValueError(msg)
+ if on_delete is not None and foreign_key is None:
+ msg = "on_delete requires foreign_key"
+ raise ValueError(msg)
+ if on_delete is not None and on_delete not in {"cascade", "set_null", "restrict"}:
+ msg = "on_delete must be one of: cascade, set_null, restrict"
+ raise ValueError(msg)
+ if foreign_key is not None:
+ if isinstance(foreign_key, str):
+ pass
+ elif isinstance(foreign_key, builtins.type):
+ from air.model import AirModel # noqa: PLC0415
+
+ if not issubclass(foreign_key, AirModel):
+ msg = "foreign_key must be an AirModel subclass or string reference"
+ raise TypeError(msg)
+ else:
+ msg = "foreign_key must be an AirModel subclass or string reference"
+ raise TypeError(msg)
+ if on_delete is None:
+ on_delete = "restrict"
+
if default is not ...:
kwargs["default"] = default
if default_factory is not None:
@@ -65,6 +101,8 @@ def AirField( # noqa: C901, N802
# Typed presentation metadata
if primary_key:
field_info.metadata.append(PrimaryKey())
+ if foreign_key is not None:
+ field_info.metadata.append(ForeignKey(to=foreign_key, on_delete=on_delete or "restrict"))
if type:
field_info.metadata.append(Widget(kind=type))
if widget:
diff --git a/src/air/field/types.py b/src/air/field/types.py
index 58c94fdf1..8ee421dea 100644
--- a/src/air/field/types.py
+++ b/src/air/field/types.py
@@ -11,7 +11,10 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Any, Literal
+from typing import TYPE_CHECKING, Any, Literal
+
+if TYPE_CHECKING:
+ from air.model import AirModel
class BasePresentation:
@@ -39,6 +42,19 @@ class PrimaryKey(BasePresentation):
"""
+@dataclass(frozen=True, slots=True)
+class ForeignKey(BasePresentation):
+ """Marks this field as a foreign key to another AirModel.
+
+ This metadata is structural rather than presentational. Consumers in
+ ``air.model`` use it to derive relation attribute names, validate
+ collisions, and eventually build relationship-aware query helpers.
+ """
+
+ to: type["AirModel"] | str
+ on_delete: Literal["cascade", "set_null", "restrict"] = "restrict"
+
+
@dataclass(frozen=True, slots=True)
class CsrfToken(BasePresentation):
"""Marks this field as a CSRF protection token.
diff --git a/src/air/form/main.py b/src/air/form/main.py
index 030f462a4..9febf0840 100644
--- a/src/air/form/main.py
+++ b/src/air/form/main.py
@@ -10,6 +10,7 @@
from __future__ import annotations
+import inspect
from enum import Enum
from html import escape
from types import UnionType
@@ -18,7 +19,7 @@
import annotated_types
from pydantic import BaseModel, ValidationError
-from air.field import Autofocus, Label, Widget
+from air.field import Autofocus, ForeignKey, Label, Widget
from air.field.types import (
BasePresentation,
Choices,
@@ -101,6 +102,8 @@ def pydantic_type_to_html_type(field_info: Any) -> str:
return widget.kind
if Choices in meta:
return "select"
+ if ForeignKey in meta:
+ return "select"
annotation = field_info.annotation
if annotation is bool:
@@ -115,11 +118,19 @@ def pydantic_type_to_html_type(field_info: Any) -> str:
return "text"
-def _get_options(annotation: Any, meta: dict[type, BasePresentation]) -> list[tuple[str, str]]:
+def _get_options(
+ annotation: Any,
+ meta: dict[type, BasePresentation],
+ *,
+ field_name: str | None = None,
+ choices: dict[str, list[tuple[Any, str]]] | None = None,
+) -> list[tuple[str, str]]:
"""Get select/dropdown options from metadata or type."""
- choices = _get_meta(meta, Choices)
- if choices:
- return [(str(v), lbl) for v, lbl in choices.options]
+ if field_name is not None and choices is not None and field_name in choices:
+ return [(str(v), lbl) for v, lbl in choices[field_name]]
+ field_choices = _get_meta(meta, Choices)
+ if field_choices:
+ return [(str(v), lbl) for v, lbl in field_choices.options]
if isinstance(annotation, type) and issubclass(annotation, Enum):
return [(m.value, m.name.replace("_", " ").title()) for m in annotation]
if get_origin(annotation) is Literal:
@@ -197,6 +208,7 @@ def default_form_widget( # noqa: C901
data: dict | None = None,
errors: list | None = None,
excludes: set[str] | None = None,
+ choices: dict[str, list[tuple[Any, str]]] | None = None,
) -> str:
"""Render form fields for a Pydantic model as HTML.
@@ -231,7 +243,9 @@ def default_form_widget( # noqa: C901
if readonly and readonly.in_context("form"):
continue
- input_type = pydantic_type_to_html_type(field_info)
+ input_type = (
+ "select" if choices is not None and field_name in choices else pydantic_type_to_html_type(field_info)
+ )
label_text = label_for_field(field_name, field_info)
error = error_dict.get(field_name)
value = data.get(field_name) if data is not None else None
@@ -291,14 +305,14 @@ def default_form_widget( # noqa: C901
parts.append(f" ")
elif input_type == "select":
- options = _get_options(annotation, meta)
+ options = _get_options(annotation, meta, field_name=field_name, choices=choices)
parts.extend((
f" ")
else:
@@ -436,11 +450,23 @@ def __init_subclass__(cls, **kwargs: Any) -> None:
if cls.model is not None:
cls._display_excludes, cls._save_excludes = _build_excludes(cls.model, cls.excludes)
- def __init__(self, initial_data: dict | None = None) -> None:
+ def __init__(
+ self,
+ initial_data: dict | None = None,
+ *,
+ choices: dict[str, list[tuple[Any, str]]] | None = None,
+ ) -> None:
if self.model is None:
msg = "model"
raise NotImplementedError(msg)
+ if choices is not None:
+ unknown_fields = set(choices) - set(self.model.model_fields)
+ if unknown_fields:
+ unknown = ", ".join(sorted(unknown_fields))
+ msg = f"Unknown choices field(s): {unknown}"
+ raise ValueError(msg)
self.initial_data = initial_data
+ self._choices = choices
self.submitted_data: dict | None = None
self._csrf_token: str | None = None
@@ -551,10 +577,23 @@ def render(self) -> str:
csrf_html, self._csrf_token = csrf_hidden_input()
render_data = self.submitted_data or self.initial_data
- fields_html = self.widget(
- model=self.model,
- data=render_data,
- errors=self.errors,
- excludes=self._display_excludes or None,
+ widget_signature = inspect.signature(self.widget)
+ accepts_var_kwargs = any(
+ param.kind == inspect.Parameter.VAR_KEYWORD for param in widget_signature.parameters.values()
)
+ if "choices" in widget_signature.parameters or accepts_var_kwargs:
+ fields_html = self.widget(
+ model=self.model,
+ data=render_data,
+ errors=self.errors,
+ excludes=self._display_excludes or None,
+ choices=self._choices,
+ )
+ else:
+ fields_html = self.widget(
+ model=self.model,
+ data=render_data,
+ errors=self.errors,
+ excludes=self._display_excludes or None,
+ )
return SafeHTML(f"{csrf_html}\n{fields_html}")
diff --git a/src/air/model/main.py b/src/air/model/main.py
index 7da774d20..a5974908f 100644
--- a/src/air/model/main.py
+++ b/src/air/model/main.py
@@ -30,6 +30,7 @@ class UnicornSighting(AirModel):
from __future__ import annotations
+import inspect
import re
import tomllib
from contextlib import asynccontextmanager
@@ -46,7 +47,7 @@ class UnicornSighting(AirModel):
ConfigDict,
)
-from air.field import PrimaryKey
+from air.field import ForeignKey, PrimaryKey
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -160,6 +161,13 @@ def _is_primary_key(field_info: FieldInfo) -> bool:
return any(isinstance(m, PrimaryKey) for m in field_info.metadata)
+def _get_foreign_key(field_info: FieldInfo) -> ForeignKey | None:
+ for metadata in field_info.metadata:
+ if isinstance(metadata, ForeignKey):
+ return metadata
+ return None
+
+
# ---------------------------------------------------------------------------
# Lookup operators (Django-style double-underscore)
# ---------------------------------------------------------------------------
@@ -295,8 +303,10 @@ class User(AirModel):
model_config = ConfigDict(from_attributes=True)
- def __init_subclass__(cls, **kwargs: Any) -> None:
- super().__init_subclass__(**kwargs)
+ @classmethod
+ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
+ super().__pydantic_init_subclass__(**kwargs)
+ cls._validate_fk_relation_names()
_table_registry.append(cls)
# -- SQL generation helpers ----------------------------------------------
@@ -315,6 +325,48 @@ def _pk_field(cls) -> str | None:
return name
return None
+ @staticmethod
+ def _relation_attr_name(field_name: str) -> str:
+ """Derive the eager-loading relation attribute from an FK field name."""
+ if field_name.endswith("_id"):
+ return field_name.removesuffix("_id")
+ return f"{field_name}_obj"
+
+ @classmethod
+ def _relation_field_map(cls) -> dict[str, str]:
+ """Map derived relation attribute names to their FK field names.
+
+ Raises:
+ ValueError: If a derived relation attribute would shadow an existing field or attribute.
+ """
+ relation_fields: dict[str, str] = {}
+ for field_name, field_info in cls.model_fields.items():
+ if _get_foreign_key(field_info) is None:
+ continue
+ relation_name = cls._relation_attr_name(field_name)
+ if relation_name in cls.model_fields:
+ msg = (
+ f'Foreign key field "{field_name}" derives relation attribute '
+ f'"{relation_name}", which collides with existing field "{relation_name}".'
+ )
+ raise ValueError(msg)
+ marker = object()
+ existing = inspect.getattr_static(cls, relation_name, marker)
+ if existing is not marker:
+ msg = (
+ f'Foreign key field "{field_name}" derives relation attribute '
+ f'"{relation_name}", which collides with existing model attribute '
+ f'"{relation_name}".'
+ )
+ raise ValueError(msg)
+ relation_fields[relation_name] = field_name
+ return relation_fields
+
+ @classmethod
+ def _validate_fk_relation_names(cls) -> None:
+ """Reject FK relation names that would shadow model attributes."""
+ cls._relation_field_map()
+
@classmethod
def _column_defs(cls) -> list[str]:
"""Return a list of ``"column_name TYPE [constraints]"`` strings."""
diff --git a/tests/test_form.py b/tests/test_form.py
index deb3291f9..9414facf7 100644
--- a/tests/test_form.py
+++ b/tests/test_form.py
@@ -394,6 +394,44 @@ class CompanionModel(BaseModel):
assert 'value="healer" selected' in html
+def test_render_dynamic_choices_turn_plain_field_into_select() -> None:
+ class TeaOrderModel(BaseModel):
+ syrup: str
+
+ class TeaOrderForm(AirForm[TeaOrderModel]):
+ pass
+
+ html = TeaOrderForm(choices={"syrup": [("brown_sugar", "Brown Sugar"), ("honey", "Honey")]}).render()
+ assert "