Skip to content

Relationships

Relationships are declared with annotations: Annotated[Other, ForeignKey(...)] for the owning side, BackRef() for the reverse side, and ManyToMany(...) for join-table relations. At runtime, related data is accessed through Relation — an awaitable, chainable query bound to the instance. See the Relationships guide for usage patterns.

ForeignKey

Describe a forward foreign-key relationship between models

Attributes:

to: Target model class resolved during model binding.
related_name: Name of the reverse relationship attribute on the target model.
on_delete: Referential action applied when the parent row is deleted.
unique: Treat the relation as one-to-one when True.
index: Request a non-unique index on the shadow ``*_id`` column.
nullable: Alembic nullability for the shadow ``*_id`` column (see
    :class:`FerroField` ``nullable``). When ``'infer'``, uses whether the
    **relation** annotation allows ``None``.

Examples:

>>> from typing import Annotated
>>> from ferro.models import Model
>>>
>>> class User(Model):
...     id: Annotated[int, FerroField(primary_key=True)]
>>>
>>> class Post(Model):
...     id: Annotated[int, FerroField(primary_key=True)]
...     author: Annotated[int, ForeignKey("posts", on_delete="CASCADE")]
Source code in src/ferro/base.py
class ForeignKey:
    """Describe a forward foreign-key relationship between models

    Attributes:

        to: Target model class resolved during model binding.
        related_name: Name of the reverse relationship attribute on the target model.
        on_delete: Referential action applied when the parent row is deleted.
        unique: Treat the relation as one-to-one when True.
        index: Request a non-unique index on the shadow ``*_id`` column.
        nullable: Alembic nullability for the shadow ``*_id`` column (see
            :class:`FerroField` ``nullable``). When ``'infer'``, uses whether the
            **relation** annotation allows ``None``.

    Examples:
        >>> from typing import Annotated
        >>> from ferro.models import Model
        >>>
        >>> class User(Model):
        ...     id: Annotated[int, FerroField(primary_key=True)]
        >>>
        >>> class Post(Model):
        ...     id: Annotated[int, FerroField(primary_key=True)]
        ...     author: Annotated[int, ForeignKey("posts", on_delete="CASCADE")]
    """

    def __init__(
        self,
        related_name: str,
        on_delete: str = "CASCADE",
        unique: bool = False,
        index: bool = False,
        nullable: FerroNullable = "infer",
    ):
        """Initialize foreign-key relationship metadata

        Args:

            related_name: Name for reverse access from the related model.
            on_delete: Referential action for parent deletion.
                Common values include "CASCADE", "RESTRICT", "SET NULL", "SET DEFAULT", and "NO ACTION".
            unique: Set to True to enforce one-to-one behavior. Implies an
                index, so combining ``unique=True`` with ``index=True`` is
                redundant; ``index=True`` will be ignored and a ``UserWarning``
                will be raised.
            index: Set to True to create a non-unique index on the shadow
                ``*_id`` column. Useful for tenant FKs queried on every list
                endpoint where Postgres does not auto-index the FK column.
            nullable: See :class:`ForeignKey` class docstring.

        Examples:
            >>> from typing import Annotated
            >>> from ferro import BackRef, ForeignKey, Relation
            >>> from ferro.models import Model
            >>>
            >>> class Org(Model):
            ...     id: Annotated[int, FerroField(primary_key=True)]
            ...     projects: Relation[list["Project"]] = BackRef()
            >>>
            >>> class Project(Model):
            ...     id: Annotated[int, FerroField(primary_key=True)]
            ...     org: Annotated[Org, ForeignKey("projects", index=True)]
        """
        self.to = None  # Resolved later
        self.related_name = related_name
        self.on_delete = on_delete
        self.unique = unique
        self.index = index
        self.nullable = _validate_nullable_option(nullable, "ForeignKey")
        if str(self.on_delete).upper() == "SET NULL" and self.nullable is False:
            raise ValueError(
                "ForeignKey(on_delete='SET NULL') requires nullable=True or 'infer'"
            )
        if unique and index:
            warnings.warn(
                "ForeignKey(unique=True, index=True) is redundant; unique=True "
                "already implies an index. Ignoring index=True.",
                UserWarning,
                stacklevel=2,
            )
            self.index = False
        #: First type argument of ``Annotated[..., ForeignKey]``; set by the metaclass
        #: for Alembic nullability inference (forward fields are not in ``model_fields``).
        self.relation_annotation: Any | None = None

Attributes

to = None instance-attribute

related_name = related_name instance-attribute

on_delete = on_delete instance-attribute

unique = unique instance-attribute

index = index instance-attribute

nullable = _validate_nullable_option(nullable, 'ForeignKey') instance-attribute

relation_annotation = None instance-attribute

Functions

__init__(related_name, on_delete='CASCADE', unique=False, index=False, nullable='infer')

Initialize foreign-key relationship metadata

Args:

related_name: Name for reverse access from the related model.
on_delete: Referential action for parent deletion.
    Common values include "CASCADE", "RESTRICT", "SET NULL", "SET DEFAULT", and "NO ACTION".
unique: Set to True to enforce one-to-one behavior. Implies an
    index, so combining ``unique=True`` with ``index=True`` is
    redundant; ``index=True`` will be ignored and a ``UserWarning``
    will be raised.
index: Set to True to create a non-unique index on the shadow
    ``*_id`` column. Useful for tenant FKs queried on every list
    endpoint where Postgres does not auto-index the FK column.
nullable: See :class:`ForeignKey` class docstring.

Examples:

>>> from typing import Annotated
>>> from ferro import BackRef, ForeignKey, Relation
>>> from ferro.models import Model
>>>
>>> class Org(Model):
...     id: Annotated[int, FerroField(primary_key=True)]
...     projects: Relation[list["Project"]] = BackRef()
>>>
>>> class Project(Model):
...     id: Annotated[int, FerroField(primary_key=True)]
...     org: Annotated[Org, ForeignKey("projects", index=True)]
Source code in src/ferro/base.py
def __init__(
    self,
    related_name: str,
    on_delete: str = "CASCADE",
    unique: bool = False,
    index: bool = False,
    nullable: FerroNullable = "infer",
):
    """Initialize foreign-key relationship metadata

    Args:

        related_name: Name for reverse access from the related model.
        on_delete: Referential action for parent deletion.
            Common values include "CASCADE", "RESTRICT", "SET NULL", "SET DEFAULT", and "NO ACTION".
        unique: Set to True to enforce one-to-one behavior. Implies an
            index, so combining ``unique=True`` with ``index=True`` is
            redundant; ``index=True`` will be ignored and a ``UserWarning``
            will be raised.
        index: Set to True to create a non-unique index on the shadow
            ``*_id`` column. Useful for tenant FKs queried on every list
            endpoint where Postgres does not auto-index the FK column.
        nullable: See :class:`ForeignKey` class docstring.

    Examples:
        >>> from typing import Annotated
        >>> from ferro import BackRef, ForeignKey, Relation
        >>> from ferro.models import Model
        >>>
        >>> class Org(Model):
        ...     id: Annotated[int, FerroField(primary_key=True)]
        ...     projects: Relation[list["Project"]] = BackRef()
        >>>
        >>> class Project(Model):
        ...     id: Annotated[int, FerroField(primary_key=True)]
        ...     org: Annotated[Org, ForeignKey("projects", index=True)]
    """
    self.to = None  # Resolved later
    self.related_name = related_name
    self.on_delete = on_delete
    self.unique = unique
    self.index = index
    self.nullable = _validate_nullable_option(nullable, "ForeignKey")
    if str(self.on_delete).upper() == "SET NULL" and self.nullable is False:
        raise ValueError(
            "ForeignKey(on_delete='SET NULL') requires nullable=True or 'infer'"
        )
    if unique and index:
        warnings.warn(
            "ForeignKey(unique=True, index=True) is redundant; unique=True "
            "already implies an index. Ignoring index=True.",
            UserWarning,
            stacklevel=2,
        )
        self.index = False
    #: First type argument of ``Annotated[..., ForeignKey]``; set by the metaclass
    #: for Alembic nullability inference (forward fields are not in ``model_fields``).
    self.relation_annotation: Any | None = None

BackRef

Declare a reverse relationship field.

BackRef() is a convenience wrapper around Field(back_ref=True).

Source code in src/ferro/fields.py
class BackRef:
    """Declare a reverse relationship field.

    ``BackRef()`` is a convenience wrapper around ``Field(back_ref=True)``.
    """

    def __new__(cls, **kwargs: Any) -> Any:
        if "reverse_index" in kwargs:
            raise TypeError(
                "BackRef() does not accept 'reverse_index'; this kwarg lives on the "
                "forward ManyToMany(...) declaration. Set reverse_index there instead."
            )
        return Field(back_ref=True, **kwargs)

    @classmethod
    def __class_getitem__(cls, _item: Any) -> Any:
        raise TypeError(
            "BackRef[...] is no longer a type annotation. Use "
            "Relation[list[T]] = BackRef() for collection back-references."
        )

Functions

__new__(**kwargs)

Source code in src/ferro/fields.py
def __new__(cls, **kwargs: Any) -> Any:
    if "reverse_index" in kwargs:
        raise TypeError(
            "BackRef() does not accept 'reverse_index'; this kwarg lives on the "
            "forward ManyToMany(...) declaration. Set reverse_index there instead."
        )
    return Field(back_ref=True, **kwargs)

__class_getitem__(_item) classmethod

Source code in src/ferro/fields.py
@classmethod
def __class_getitem__(cls, _item: Any) -> Any:
    raise TypeError(
        "BackRef[...] is no longer a type annotation. Use "
        "Relation[list[T]] = BackRef() for collection back-references."
    )

ManyToMany(*, related_name, through=None, reverse_index=True, **kwargs)

Declare a many-to-many relationship field.

Parameters:

Name Type Description Default
related_name str

Name for reverse access from the related model.

required
through str | None

Optional explicit join table name. When omitted, Ferro generates a join table name automatically.

None
reverse_index bool

When True (default), the synthesized join table gets a non-unique composite index on (target_col, source_col) to optimize back-ref queries. Set to False to opt out.

True
Source code in src/ferro/fields.py
def ManyToMany(
    *,
    related_name: str,
    through: str | None = None,
    reverse_index: bool = True,
    **kwargs: Any,
) -> Any:
    """Declare a many-to-many relationship field.

    Args:
        related_name: Name for reverse access from the related model.
        through: Optional explicit join table name. When omitted, Ferro
            generates a join table name automatically.
        reverse_index: When True (default), the synthesized join table gets
            a non-unique composite index on ``(target_col, source_col)`` to
            optimize back-ref queries. Set to False to opt out.
    """

    return Field(
        many_to_many=True,
        related_name=related_name,
        through=through,
        reverse_index=reverse_index,
        **kwargs,
    )

Relation

Bases: Query[T]

Represent lazy collection relationship queries with typing support

Examples:

>>> class User(Model):
...     id: Annotated[int, FerroField(primary_key=True)]
...     name: str
...     posts: Relation[list["Post"]] = BackRef()
>>> class Post(Model):
...     id: Annotated[int, FerroField(primary_key=True)]
...     title: str
...     user: Annotated[User, ForeignKey(related_name="posts")]
>>> user = await User.get(1)
>>> posts = await user.posts.all()
>>> isinstance(posts, list)
True
Source code in src/ferro/query/builder.py
class Relation(Query[T]):
    """Represent lazy collection relationship queries with typing support

    Examples:
        >>> class User(Model):
        ...     id: Annotated[int, FerroField(primary_key=True)]
        ...     name: str
        ...     posts: Relation[list["Post"]] = BackRef()

        >>> class Post(Model):
        ...     id: Annotated[int, FerroField(primary_key=True)]
        ...     title: str
        ...     user: Annotated[User, ForeignKey(related_name="posts")]

        >>> user = await User.get(1)
        >>> posts = await user.posts.all()
        >>> isinstance(posts, list)
        True
    """

    # NOTE ON TYPING:
    #
    # Users annotate collection relationships as Relation[list[Model]] to encode
    # cardinality (one-to-many / many-to-many). Since Query.all() is typed as list[T],
    # that would naively become list[list[Model]] in IDEs.
    #
    # We fix hinting by overriding Relation.{all,first} with overloads that interpret
    # Relation[T] as a query whose *rows* are model instances, regardless of whether
    # T is written as Model or list[Model] in the field annotation.
    if TYPE_CHECKING:

        @overload
        async def all(self: "Relation[list[E]]") -> list[E]: ...

        @overload
        async def all(self: "Relation[E]") -> list[E]: ...

        @overload
        async def first(self: "Relation[list[E]]") -> E | None: ...

        @overload
        async def first(self: "Relation[E]") -> E | None: ...

    async def all(self):  # type: ignore[override]
        return await super().all()

    async def first(self):  # type: ignore[override]
        return await super().first()

    @classmethod
    def __get_pydantic_core_schema__(cls, _source_type, _handler):
        """Allow pydantic-core to treat relationships as arbitrary runtime values"""
        from pydantic_core import core_schema

        return core_schema.any_schema()

Functions

all() async

all() -> list[E]
all() -> list[E]
Source code in src/ferro/query/builder.py
async def all(self):  # type: ignore[override]
    return await super().all()

first() async

first() -> E | None
first() -> E | None
Source code in src/ferro/query/builder.py
async def first(self):  # type: ignore[override]
    return await super().first()

__get_pydantic_core_schema__(_source_type, _handler) classmethod

Allow pydantic-core to treat relationships as arbitrary runtime values

Source code in src/ferro/query/builder.py
@classmethod
def __get_pydantic_core_schema__(cls, _source_type, _handler):
    """Allow pydantic-core to treat relationships as arbitrary runtime values"""
    from pydantic_core import core_schema

    return core_schema.any_schema()