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 (Authorauthor, Articlearticle). 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

author_id

Article.author_id

Article.author

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 _id field 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_id and author are explicitly provided, they must refer to the same row or the request returns 422.

  • IDSchema as a base class adds the resource’s own read-only id field.