How-To: Work with Foreign Keys Using IDSchema#
Use fr.IDSchema[Model] as a schema field type when you want clients to reference a related object by ID, and have FastAPI-Restly resolve it to a real SQLAlchemy instance automatically.
Naming Convention#
Two requirements must be met for automatic resolution to work:
The schema field name must end in
_id(for example,author_id).The SQLAlchemy model must have a relationship attribute with the same name minus the
_idsuffix (for example,author).
When both conditions are met, the view sets both the FK column (author_id) and the relationship attribute (author) on the new or updated object. If the relationship attribute is absent, only the FK column is set.
Model Setup#
import fastapi_restly as fr
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import ForeignKey
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 is the convenience alias built on fr.DataclassBase, so it still auto-generates the table name from the class name (Author → author, Article → article). That is why ForeignKey("author.id") is correct here.
Why init=False and default=None are required#
fr.IDBase uses SQLAlchemy’s MappedAsDataclass, which auto-generates an __init__ from the model’s field declarations. Any attribute that is not marked init=False becomes a constructor parameter.
Relationship attributes should not be constructor parameters — SQLAlchemy loads them lazily from the database via the foreign key column. If you omit init=False, SQLAlchemy will expect the related object to be passed directly to Article(...), which is not how FK-based construction works.
default=None is the companion requirement: without a default value, the generated __init__ would require the relationship as a positional argument, making it impossible to construct the object at all.
The correct declaration is always:
author: Mapped["Author"] = relationship(default=None, init=False)
Plain (non-dataclass) models#
If you use fr.PlainBase / fr.PlainIDBase instead of fr.IDBase, the dataclass constraint does not apply. Plain models use SQLAlchemy’s traditional declarative style and accept any keyword arguments in __init__, so init=False is not needed:
class Article(fr.PlainIDBase):
__tablename__ = "article"
title: Mapped[str]
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
author: Mapped["Author"] = relationship() # no init=False needed
Schema Setup#
import fastapi_restly as fr
class AuthorSchema(fr.IDSchema):
name: str
class ArticleSchema(fr.IDSchema):
title: str
author_id: fr.IDSchema[Author]
View Setup#
@fr.include_view(app)
class ArticleView(fr.AsyncRestView):
prefix = "/articles"
model = Article
schema = ArticleSchema
Request Format#
The client sends the related object’s primary key wrapped in an object:
{
"title": "Intro",
"author_id": {"id": 1}
}
The view looks up the Author with id=1 and raises 404 if it does not exist.
Behavior#
The
idinside the{"id": 1}payload is the foreign key value provided by the client, not the article’s own primary key.FastAPI-Restly resolves
author_idto anAuthorORM instance before creating or updating the object.Both the FK column (
author_id) and the relationship (author) are kept in sync on write, provided theauthorrelationship exists on the model.On dataclass-based models, the framework detects
init=Falseon the relationship attribute and skips passing it to__init__. The FK column is still set, so SQLAlchemy will populate the relationship on the next access.Missing related IDs return
404.