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" {val}") elif input_type == "select": - options = _get_options(annotation, meta) + options = _get_options(annotation, meta, field_name=field_name, choices=choices) parts.extend(( f" ", ' ', )) for opt_val, opt_label in options: - sel = " selected" if value is not None and str(value) == opt_val else "" - parts.append(f' ') + sel = " selected" if value is not None and str(value) == str(opt_val) else "" + parts.append(f' ') parts.append(" ") 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 "Brown Sugar' in html + + +def test_render_dynamic_choices_preselects_non_string_values() -> None: + class MakerModel(BaseModel): + maker_id: int + + class MakerForm(AirForm[MakerModel]): + pass + + html = MakerForm( + {"maker_id": 2}, + choices={"maker_id": [(1, "Kuma-san"), (2, "Tanuki")]}, + ).render() + assert 'value="2" selected' in html + + +def test_airform_choices_unknown_field_raises() -> None: + class TeaOrderModel(BaseModel): + syrup: str + + class TeaOrderForm(AirForm[TeaOrderModel]): + pass + + with pytest.raises(ValueError, match="sryup"): + TeaOrderForm(choices={"sryup": [("brown_sugar", "Brown Sugar")]}) + + def test_render_airfield_autofocus() -> None: class CompanionModel(BaseModel): name: str = AirField(label="Name", autofocus=True) @@ -520,6 +558,48 @@ class CompanionForm(AirForm[CompanionModel]): assert 'name="csrf_token"' in html # CSRF token still included +def test_custom_widget_without_choices_keyword_still_renders() -> None: + class CompanionModel(BaseModel): + name: str + + def tavern_widget( + *, model: type, data: dict | None = None, errors: list | None = None, excludes: set | None = None + ) -> str: + return "

legacy widget

" + + class CompanionForm(AirForm[CompanionModel]): + widget = staticmethod(tavern_widget) + + html = CompanionForm(choices={"name": [("k", "Konstantina")]}).render() + assert "

legacy widget

" in html + + +def test_custom_widget_with_choices_keyword_receives_dynamic_choices() -> None: + class CompanionModel(BaseModel): + role: str + + seen: dict[str, list[tuple[str, str]]] | None = None + + def tavern_widget( + *, + model: type, + data: dict | None = None, + errors: list | None = None, + excludes: set | None = None, + choices: dict[str, list[tuple[str, str]]] | None = None, + ) -> str: + nonlocal seen + seen = choices + return default_form_widget(model=model, data=data, errors=errors, excludes=excludes, choices=choices) + + class CompanionForm(AirForm[CompanionModel]): + widget = staticmethod(tavern_widget) + + html = CompanionForm(choices={"role": [("tank", "Tank")]}).render() + assert seen == {"role": [("tank", "Tank")]} + assert '' in html + + # ── Empty string vs. too-short error message tests (pottery/ceramics) ── diff --git a/tests/test_model.py b/tests/test_model.py index e35797b41..a50ffed54 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -13,7 +13,7 @@ import pytest -from air.field import AirField, PrimaryKey +from air.field import AirField, ForeignKey, PrimaryKey from air.model import AirDB, AirModel, MultipleObjectsReturned from air.model.main import _PY_TO_PG, _pg_type, _table_registry # noqa: PLC2701 @@ -142,6 +142,105 @@ def test_non_pk_fields_excludes_pk(self) -> None: assert "color" in non_pk +class TestForeignKeyRelationNames: + def test_foreign_key_metadata_is_stored(self) -> None: + class Maker(AirModel): + id: int | None = AirField(default=None, primary_key=True) + name: str + + class Dango(AirModel): + id: int | None = AirField(default=None, primary_key=True) + maker_id: int = AirField(foreign_key=Maker) + + field_info = Dango.model_fields["maker_id"] + foreign_key = next((m for m in field_info.metadata if isinstance(m, ForeignKey)), None) + assert foreign_key is not None + assert foreign_key.to is Maker + assert Dango._relation_attr_name("maker_id") == "maker" + assert Dango._relation_field_map() == {"maker": "maker_id"} + + def test_forward_reference_foreign_key_validates_relation_name_early(self) -> None: + class LocalChild(AirModel): + id: int | None = AirField(default=None, primary_key=True) + parent_id: int = AirField(foreign_key="LocalParent") + + class LocalParent(AirModel): + id: int | None = AirField(default=None, primary_key=True) + name: str + + field_info = LocalChild.model_fields["parent_id"] + foreign_key = next((m for m in field_info.metadata if isinstance(m, ForeignKey)), None) + assert foreign_key is not None + assert foreign_key.to == "LocalParent" + assert LocalChild._relation_field_map() == {"parent": "parent_id"} + + def test_declared_field_collision_raises_at_definition_time(self) -> None: + before = {table.__name__ for table in _table_registry} + + class Maker(AirModel): + id: int | None = AirField(default=None, primary_key=True) + + with pytest.raises(ValueError, match=r'maker_id.*"maker".*existing field'): + + class BadDango(AirModel): + id: int | None = AirField(default=None, primary_key=True) + maker: str + maker_id: int = AirField(foreign_key=Maker) + + after = {table.__name__ for table in _table_registry} + assert "BadDango" not in after + assert before <= after + + def test_inherited_save_collision_raises_at_definition_time(self) -> None: + class User(AirModel): + id: int | None = AirField(default=None, primary_key=True) + + with pytest.raises(ValueError, match=r'save_id.*"save".*existing model attribute'): + + class BadSaveRelation(AirModel): + id: int | None = AirField(default=None, primary_key=True) + save_id: int = AirField(foreign_key=User) + + def test_inherited_delete_collision_raises_at_definition_time(self) -> None: + class User(AirModel): + id: int | None = AirField(default=None, primary_key=True) + + with pytest.raises(ValueError, match=r'delete_id.*"delete".*existing model attribute'): + + class BadDeleteRelation(AirModel): + id: int | None = AirField(default=None, primary_key=True) + delete_id: int = AirField(foreign_key=User) + + def test_basemodel_attribute_collision_raises_at_definition_time(self) -> None: + class User(AirModel): + id: int | None = AirField(default=None, primary_key=True) + + with ( + pytest.warns(UserWarning, match="protected namespace 'model_dump'"), + pytest.raises( + ValueError, + match=r'model_dump_id.*"model_dump".*existing model attribute', + ), + ): + + class BadDumpRelation(AirModel): + id: int | None = AirField(default=None, primary_key=True) + model_dump_id: int = AirField(foreign_key=User) + + def test_user_defined_attribute_collision_raises_at_definition_time(self) -> None: + class User(AirModel): + id: int | None = AirField(default=None, primary_key=True) + + with pytest.raises(ValueError, match=r'author_id.*"author".*existing model attribute'): + + class BadAuthorRelation(AirModel): + id: int | None = AirField(default=None, primary_key=True) + author_id: int = AirField(foreign_key=User) + + def author(self) -> str: + return "collision" + + # --------------------------------------------------------------------------- # SQL generation: column definitions # ---------------------------------------------------------------------------