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

author_id: fr.MustExist[int, User]

1

scalar foreign key

author: fr.IDRef[User]

1

resolves to User

author: fr.IDSchema[User]

{"id": 1}

resolves to User

author: fr.ReadOnly[UserSchema]

the full object

read-only relationship embed

author_id: int

1

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 MustExist for the common case: a *_id column you want validated. Name the primary-key type first, then the target model, as in fr.MustExist[int, User] (fr.MustExist[UUID, Account] for a UUID key). When the column has a single ForeignKey, you can drop the model and let Restly infer it, writing just fr.MustExist[int]. A plain int (or ReadOnly[int] for a server-stamped column) is the unchecked alternative with no existence check.

  • Use IDRef / IDSchema when 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 for MustExist and int; for IDRef / IDSchema it 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

author

Article.author_id

Article.author

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#