Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/air/field/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -38,6 +39,7 @@
"CsrfToken",
"DisplayFormat",
"Filterable",
"ForeignKey",
"Grouped",
"HelpText",
"Hidden",
Expand Down
46 changes: 42 additions & 4 deletions src/air/field/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -44,17 +48,49 @@ 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
``pydantic.Field()``; Pydantic raises on unrecognized parameters.

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:
Expand All @@ -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:
Expand Down
18 changes: 17 additions & 1 deletion src/air/field/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
69 changes: 54 additions & 15 deletions src/air/form/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from __future__ import annotations

import inspect
from enum import Enum
from html import escape
from types import UnionType
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -291,14 +305,14 @@ def default_form_widget( # noqa: C901
parts.append(f" <textarea{_attr_str(input_attrs)}>{val}</textarea>")

elif input_type == "select":
options = _get_options(annotation, meta)
options = _get_options(annotation, meta, field_name=field_name, choices=choices)
parts.extend((
f" <select{_attr_str(input_attrs)}>",
' <option value="" disabled selected hidden>Select...</option>',
))
for opt_val, opt_label in options:
sel = " selected" if value is not None and str(value) == opt_val else ""
parts.append(f' <option value="{escape(opt_val)}"{sel}>{escape(opt_label)}</option>')
sel = " selected" if value is not None and str(value) == str(opt_val) else ""
parts.append(f' <option value="{escape(str(opt_val))}"{sel}>{escape(opt_label)}</option>')
parts.append(" </select>")

else:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}")
58 changes: 55 additions & 3 deletions src/air/model/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class UnicornSighting(AirModel):

from __future__ import annotations

import inspect
import re
import tomllib
from contextlib import asynccontextmanager
Expand All @@ -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
Expand Down Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 ----------------------------------------------
Expand All @@ -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."""
Expand Down
Loading