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 UserSchema(fr.IDSchema):
name: str
class ArticleSchema(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 = ArticleSchema
On create and update, Restly looks up the User with id=1. If it does not
exist, or falls outside the model’s default_scope, the request returns
404; 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 OrderSchema(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 ArticleSchema(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 passes that constructor the values it requires, whether the schema
writes the foreign key, the relationship, or both. You can declare the foreign
key and the relationship with any init and default options:
author_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
author: Mapped["User"] = relationship(default=None)
A relationship the request does not send never overrides a foreign key it
does send. If such a relationship is None after construction, for example
from default=None, Restly removes the None. Otherwise SQLAlchemy’s flush
would write NULL over the foreign key.
Optional references work the same way. A field typed
author: fr.IDRef[User] | None accepts an explicit null, and may be omitted
when it defaults to None. Unless another field sends the foreign key, the
row gets a NULL foreign key. A null for a NOT NULL column fails in the
database, and the request returns 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. When a
request sends it together with a different relationship value, including
null, Restly saves the relationship 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#
The default 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 = TaskLabelSchema.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 = TaskLabelSchema.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_single_response(obj) before returning it:
class TaskLabelNestedSchema(fr.IDSchema):
task: fr.IDSchema[Task]
label: fr.IDSchema[Label]
@fr.post("/attach", response_model=TaskLabelNestedSchema, status_code=201)
async def attach(self, request: AttachRequest):
obj = await create_task_label(...)
return self.to_single_response(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 applies the referenced model’s default_scope, the
clause declared on its namespace (Scopes): a reference to a
row the scope hides returns 404. A session-level tenant rule also applies to
the reference lookup because it is an ORM SELECT. See
tenant row scoping for that invariant. A per-field
override (check against another clause, or none) is spelled with
RefExists; see
References: overriding per field.
A model without a default_scope resolves by bare primary key
(session.get(User, id)): tenant, soft-delete, and row-level visibility
checks are then your responsibility. Gate them in
authorize /
before_action_commit like any
other write-path authorization; both hooks are described in
Customizing 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 = ArticleSchema
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_action_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.Build a Blog API: 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.