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 |
|---|---|---|
|
scalar foreign key |
|
|
resolves to |
|
|
resolves to |
|
|
the full object |
read-only relationship embed |
|
|
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
MustExistfor the common case: a*_idcolumn you want validated. Name the primary-key type first, then the target model, as infr.MustExist[int, User](fr.MustExist[UUID, Account]for a UUID key). When the column has a singleForeignKey, you can drop the model and let Restly infer it, writing justfr.MustExist[int]. A plainint(orReadOnly[int]for a server-stamped column) is the unchecked alternative with no existence check.Use
IDRef/IDSchemawhen 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 forMustExistandint; forIDRef/IDSchemait 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 |
|---|---|---|
|
|
|
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#
Relationship Loading and Async: how these relationship fields load for responses, and
MissingGreenletfixes on async.Tutorial: reference fields in the blog build, in context.
Custom Schemas and Field Types: the schema bases and field markers these compose with.
API Reference:
MustExist/IDRef/IDSchemasignatures and the full symbol tables.