Work with Foreign Keys and Relationships#

Use fr.MustExist[int, Model] for foreign-key columns. The API will have the common scalar-id shape while FastAPI-Restly validates that the referenced row exists. Use IDRef or IDSchema when the schema field names a relationship instead of a foreign key column.

Note

FastAPI-Restly uses the term “schema” for Pydantic request/response models and “model” for SQLAlchemy ORM models.

Choosing a reference style#

Most APIs communicate through ids. They are what forms and dropdowns submit, they stay cacheable, and clients like React Admin can dereference them for display. Embed the target object only when you deliberately want one larger response instead of a separate fetch; see Nested Response Schemas vs Write Payloads for what embedding supports.

Declaration

JSON value

SQLAlchemy type

author_id: fr.MustExist[int, User]

1

scalar foreign key

author: fr.IDRef[User]

1

resolves to User

author: fr.IDSchema[User]

{"id": 1}

resolves to User

author: fr.ReadOnly[UserRead]

the full object

read-only relationship embed

author_id: int

1

scalar foreign key

The three checked forms (MustExist, IDRef, IDSchema) return 404 when the referenced id does not exist. A schema that exposes the same link through two relationship references must receive matching ids; conflicting ids return 422 (see Dataclass relationship setup).

  • Use MustExist for the common case: a *_id column you want validated. Name the primary-key type first, then the target model, as in fr.MustExist[int, User] (fr.MustExist[UUID, Account] for a UUID key). When the column has a single ForeignKey, you can drop the model and let Restly infer it, writing just fr.MustExist[int]. A plain int (or ReadOnly[int] for a server-stamped column) is the unchecked alternative with no existence check.

  • Use IDRef / IDSchema when the field names a relationship. Restly resolves the id to the related object; the difference is whether the wire format is flat (IDRef) or nested (IDSchema).

  • In hooks, data.<field> is the plain id for MustExist and int; for IDRef / IDSchema it is an unresolved reference (read .id) that Restly resolves on write.

Model setup#

The examples on this page use two models joined by one foreign key: each Article references its author, a User:

import fastapi_restly as fr
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column


class User(fr.IDBase):
    name: Mapped[str]


class Article(fr.IDBase):
    title: Mapped[str]
    author_id: Mapped[int] = mapped_column(ForeignKey("user.id"))

fr.IDBase auto-generates the table name from the class name (User becomes user, Article becomes article). That is why ForeignKey("user.id") is correct here.

Schema setup#

With the models in place, declare the foreign-key column on the schema as a checked reference:

class UserRead(fr.IDSchema):
    name: str


class ArticleRead(fr.IDSchema):
    title: str
    author_id: fr.MustExist[int, User]

fr.MustExist[int, User] marks an integer foreign key to User that must exist. The wire format is a plain scalar:

{
  "title": "Intro",
  "author_id": 1
}

Responses use the same shape:

{
  "id": 10,
  "title": "Intro",
  "author_id": 1
}

View setup#

The view is registered as usual; the reference behavior comes entirely from the schema:

@fr.include_view(app)
class ArticleView(fr.AsyncRestView):
    prefix = "/articles"
    model = Article
    schema = ArticleRead

On create and update, Restly looks up the User with id=1. If it does not exist, the request returns 404. That lookup is an unscoped existence check; see Visibility and multi-tenancy below. In hooks, data.author_id is the plain integer.

List filtering#

The FK field is filterable on the list endpoint by its own public name, as in GET /articles/?author_id=1. The suffixed operators author_id__in, author_id__ne, and author_id__isnull are available as well, and because a MustExist[int, ...] id is a plain integer, so is the range family author_id__gte / __lte / __gt / __lt. See Foreign-key filtering for the full behavior.

Field naming#

Name the schema field after a mapped attribute on the model: a foreign-key column for MustExist, or a relationship for IDRef / IDSchema. Restly inspects the SQLAlchemy mapper to decide how to apply the value, so the FK column can be named anything; the _id suffix is a common convention, not a requirement:

author_id: fr.MustExist[int, User]  # the Article.author_id FK column
post_fk: fr.MustExist[int, Post]    # a non-_id column name works the same way
author: fr.IDRef[User]              # the Article.author relationship

The Python field name is what Restly matches against the mapped attribute: it builds the model with that name as a keyword argument, so the two must match. To expose a different name only on the wire (a camelCase API, say), keep the field named after the attribute and add a Pydantic alias:

from pydantic import Field

author_id: fr.MustExist[int, User] = Field(alias="authorId")  # wire: "authorId"

When the field names a relationship, Restly resolves the id to an ORM object and keeps the relationship and its backing FK column in sync:

Schema field

FK column

Relationship

author

Article.author_id

Article.author

The relationship is found through the mapper, so this pairing holds whatever the column is called, including a column with an explicit DB name (mapped_column("db_name", ...)). If an FK-named reference has no partner relationship, or more than one relationship shares the FK column (ambiguous), Restly sets the FK column and leaves the relationship to you.

Lists of references#

A to-many reference serializes as a plain id array with list[fr.IDRef[Model]]:

class OrderRead(fr.IDSchema):
    customer_name: str
    products: list[fr.IDRef[Product]]  # serializes as [1, 2, 3]

On input, each element accepts both raw scalars and {"id": ...} shapes, so the same field doubles as a permissive write-side type when paired with a custom create / update business method that resolves the list. For relationship objects that must stay nested on the wire, use fr.IDSchema[Model] (see Nested relationship objects). MustExist is for a single scalar FK column; a to-many field is a relationship, so use list[fr.IDRef[Model]].

Input compatibility#

IDRef and IDSchema[Model] accept both scalar ids and {"id": ...} dictionaries on input. The flat form passes the id directly:

{ "author": 1 }

The nested form wraps it in an object:

{ "author": {"id": 1} }

The response shape stays with the declared type: IDRef serializes as a scalar, and IDSchema serializes as {"id": ...}. This is useful when clients or migration code already send one form, but the public API contract should keep the other.

About IDSchema#

Most examples inherit from fr.IDSchema, which is BaseSchema plus a read-only id field. The schema bases, ReadOnly / WriteOnly markers, and aliases are owned by Custom Schemas and Field Types; inherit from fr.BaseSchema instead if you want every field, including id, explicit. When used as a field type (author: fr.IDSchema[User]), it is a nested relationship reference (covered next), separate from its use as a base class.

Nested relationship objects#

Some clients model relationships as objects. For that shape, annotate the relationship field with fr.IDSchema[Model]:

class ArticleRead(fr.IDSchema):
    title: str
    author: fr.IDSchema[User]

The wire format is:

{
  "title": "Intro",
  "author": {"id": 1}
}

IDRef and IDSchema[Model] both name a relationship, validate the referenced row, and use the same resolver. The difference is the API shape: flat id versus nested object.

Dataclass relationship setup#

fr.IDBase uses SQLAlchemy’s MappedAsDataclass, which generates an __init__ from the model fields. Restly’s create/update helpers are aware of that constructor shape when an IDRef / IDSchema relationship field has been resolved to an ORM object.

The common FK-first declaration is still the clearest default:

author_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
author: Mapped["User"] = relationship(default=None, init=False)

With that model and author: fr.IDRef[User], Restly passes the scalar FK when the constructor needs it and keeps author in sync after construction.

If your model is relationship-first, Restly adapts there too:

author_id: Mapped[int] = mapped_column(ForeignKey("user.id"), init=False)
author: Mapped["User"] = relationship(default=None)

In that shape, Restly passes the resolved User object to the constructor and keeps author_id in sync. More generally, Restly supplies the constructor values your dataclass model requires: FK scalar, relationship object, or both.

Optional references follow the same contract. A field typed author: fr.IDRef[User] | None accepts an explicit null, and may simply be omitted when it defaults to None; the row is created with a NULL foreign key, with the constructor again receiving whatever the dataclass requires. A null against a NOT NULL column fails at the database and surfaces as the standard 409, not a server error.

If a schema exposes the same link as two reference fields, for example a FK-named IDRef field alongside the relationship, Restly validates that they match:

{
  "author_id": 1,
  "author": {"id": 1}
}

Conflicting references, such as "author_id": 1 with "author": {"id": 2}, return 422. Explicit null also participates in this check: author_id: 1 with author: null is a conflict, while omitting author entirely is not. A plain MustExist scalar does not take part in this reference-pair check; it is a checked column value.

Standard SQLAlchemy declarative models#

If you use a normal SQLAlchemy DeclarativeBase, the dataclass constructor rules do not apply:

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


class Article(Base):
    __tablename__ = "article"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    author_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
    author: Mapped["User"] = relationship()

There is no generated __init__ contract to satisfy: Restly constructs the object and applies the resolved reference to the FK column (and the matching relationship attribute, when one is declared) directly. Registering such models with Restly is covered in Use Your Own DeclarativeBase Models.

Reference fields in custom routes#

Generated POST and PATCH routes validate the body before Restly calls make_new_object() or update_object(), so reference fields are already in the right shape. A MustExist field is a plain id; IDRef[Model] and IDSchema[Model] fields are reference instances.

In a custom route, be careful when you construct a schema yourself. Pydantic’s model_construct() skips validation. For a MustExist field, pass the plain id:

from fastapi_restly.objects import async_make_new_object


link_schema = TaskLabelRead.model_construct(
    task_id=request.task_id,
    label_id=label.id,
)

task_label = await async_make_new_object(
    self.session,
    TaskLabel,
    link_schema,
)

This keeps the existence-check path active: Restly verifies the referenced rows exist and writes the FK columns. It helps when validated construction would require response-only fields such as id or timestamps.

If those fields were IDRef / IDSchema relationship references instead, wrap them explicitly before calling the object helper:

link_schema = TaskLabelRead.model_construct(
    task=fr.IDRef[Task](id=request.task_id),
    label=fr.IDRef[Label](id=label.id),
)

If you instead use IDSchema[Model] as a nested relationship-object field in a custom response schema, serialize the ORM object through self.to_response_schema(obj) before returning it:

class TaskLabelNestedRead(fr.IDSchema):
    task: fr.IDSchema[Task]
    label: fr.IDSchema[Label]


@fr.post("/attach", response_model=TaskLabelNestedRead, status_code=201)
async def attach(self, request: AttachRequest):
    obj = await create_task_label(...)
    return self.to_response_schema(obj)

The raw ORM object usually has scalar FK columns, while a nested schema expects relationship-shaped data. Scalar fields (MustExist, IDRef) do not need this step because their wire format is already scalar.

Visibility and multi-tenancy#

Reference resolution is an unscoped existence check. Restly fetches the referenced row by primary key only (session.get(User, id)). View build_query scoping is not applied, so tenant, soft-delete, and row-level visibility checks are your responsibility.

The resolver only knows the referenced model from the field type, not which view governs it. References are a policy concern, and they are gated in authorize / before_commit like any other write-path authorization; both hooks are described in Customize RestView.

Gate in authorize, where data carries the write-side value before resolution. For a MustExist field, data.author_id is the requested id. For IDRef / IDSchema, data.<field>.id is the requested id (and a list field is a list of references):

@fr.include_view(app)
class ArticleView(fr.AsyncRestView):
    prefix = "/articles"
    model = Article
    schema = ArticleRead

    async def authorize(self, action, obj=None, data=None):
        if data is not None and data.author_id is not None:
            if not await self.author_visible(data.author_id):
                # 404 (not 403) so you don't leak that the id exists elsewhere.
                raise fr.exc.NotFound("author not found")

The resolved ORM object is not available in authorize; resolution runs later in the business method. If you need the resolved row, check in before_commit, where the built object carries it (for example new.author.org_id). Prefer authorize when the requested id is enough: it rejects before the unscoped fetch and is the standard policy seam.

See also#