How-To: Work with Foreign Keys Using IDRef#
Use fr.IDRef[Model] for foreign-key fields. The API stays in the common
scalar-id shape while FastAPI-Restly still validates that the referenced row
exists.
Note
FastAPI-Restly uses schema for Pydantic request/response models and model for SQLAlchemy ORM models.
Model Setup#
import fastapi_restly as fr
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
class Author(fr.IDBase):
name: Mapped[str]
class Article(fr.IDBase):
title: Mapped[str]
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
author: Mapped["Author"] = relationship(default=None, init=False)
fr.IDBase auto-generates the table name from the class name (Author →
author, Article → article). That is why ForeignKey("author.id") is
correct here.
Schema Setup#
class AuthorRead(fr.IDSchema):
name: str
class ArticleRead(fr.IDSchema):
title: str
author_id: fr.IDRef[Author]
fr.IDRef[Author] means “this field references an Author row by id.” 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#
@fr.include_view(app)
class ArticleView(fr.AsyncRestView):
prefix = "/articles"
model = Article
schema = ArticleRead
On create and update, Restly looks up the Author with id=1. If it does not
exist, the request returns 404.
Naming Convention#
Automatic FK resolution needs the schema field name to end in _id:
author_id: fr.IDRef[Author]
If the SQLAlchemy model also has a relationship with the same name minus
_id, Restly keeps the FK column and relationship in sync:
Schema field |
FK column |
Relationship |
|---|---|---|
|
|
|
If the relationship attribute is absent, Restly still sets the FK column.
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 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("author.id"))
author: Mapped["Author"] = relationship(default=None, init=False)
With that model and author_id: fr.IDRef[Author], Restly passes the scalar FK
where the dataclass constructor accepts 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("author.id"), init=False)
author: Mapped["Author"] = relationship(default=None)
In that shape, Restly passes the resolved Author object to the constructor and
keeps author_id in sync. More generally, Restly supplies the constructor
values your dataclass model requires. For one resolved reference, it may pass the
FK scalar, the relationship object, or both if both dataclass fields are
required. When both are supplied by Restly, they are derived from the same
database row.
If a client supplies both sides independently, 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.
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("author.id"))
author: Mapped["Author"] = relationship()
Input Compatibility#
IDRef accepts both scalar ids and {"id": ...} dictionaries on input:
{ "author_id": 1 }
{ "author_id": {"id": 1} }
The response shape stays scalar. This is useful when clients or migration code already send the dictionary form, but the public API contract should remain an identifier field.
About IDSchema#
Most examples inherit from fr.IDSchema:
class ArticleRead(fr.IDSchema):
title: str
author_id: fr.IDRef[Author]
As a base class, IDSchema is essentially BaseSchema with a read-only id
field:
class IDSchema(fr.BaseSchema):
id: fr.ReadOnly[Any]
You can inherit from fr.BaseSchema instead if you want every field, including
id, to be explicit in the schema definition:
class ArticleRead(fr.BaseSchema):
id: fr.ReadOnly[int]
title: str
author_id: fr.IDRef[Author]
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[Author]
The wire format is:
{
"title": "Intro",
"author": {"id": 1}
}
IDRef and IDSchema[Model] both validate the referenced row and use the same
resolver. The difference is the API shape.
Behavior Summary#
IDRef[Model]uses scalar id wire format on request and response.Missing related IDs return
404.The
_idfield name triggers FK resolution.A matching SQLAlchemy relationship lets Restly keep the FK column and relationship attribute in sync.
Dataclass models can be FK-first or relationship-first; Restly supplies the constructor values the model requires from the same resolved row.
If both
author_idandauthorare explicitly provided, they must refer to the same row or the request returns422.IDSchemaas a base class adds the resource’s own read-onlyidfield.