Relations resolve themselves
Declare a SeaORM relation on an exposed entity and the GraphQL field, the loader, and the auth scope appear with it.
Declaring a SeaORM relation on an exposed entity — and marking the
relation field #[expose] — is the whole
field-resolver story for entity-to-entity links — no
#[field_resolver], no #[dataloader] to write by hand. Pass
service = … to #[expose] and the macro emits the PK loader on the
service, the trait bridges that let other entities reach it, and a
#[ComplexObject] field resolver on the wire DTO. The ambient
Ability filters every batch under Repo::scoped(Action::Read), so a
relation cannot leak across orgs.
The declaration
Section titled “The declaration”use nest_rs::resource::expose;use sea_orm::entity::prelude::*;
#[expose(name = "User", service = super::service::UsersService, graphql)]#[sea_orm::model]#[derive(Clone, Debug, DeriveEntityModel)]#[sea_orm(table_name = "user")]pub struct Model { #[sea_orm(primary_key, auto_increment = false)] #[expose] pub id: Uuid, #[expose] pub org_id: Uuid, #[expose] pub name: String,
#[sea_orm(belongs_to, from = "org_id", to = "id")] #[expose] pub org: HasOne<crate::orgs::Entity>,}#[expose(name = "Org", service = super::service::OrgsService, graphql)]#[sea_orm::model]#[derive(Clone, Debug, DeriveEntityModel)]#[sea_orm(table_name = "org")]pub struct Model { #[sea_orm(primary_key, auto_increment = false)] #[expose] pub id: Uuid, #[expose] pub name: String,
#[sea_orm(has_many)] #[expose] pub users: HasMany<crate::users::Entity>,}That is the whole declaration — both directions now query:
$ curl -sX POST http://localhost:3000/graphql -d \ '{"query":"{ users { id org { id name } } orgs { users { edges { node { name } } } } }"}'What the macro emits
Section titled “What the macro emits”Per #[expose(service = …)]:
- A PK loader on the service:
<Service>ById(e.g.UsersServiceById,OrgsServiceById). Reads the parent’s primary key, returns the wire DTO. - For every
belongs_torelation, an FK loader on the FK-owning service:<Service>By<FkCol>(e.g.UsersServiceByOrgId). Its key carries the page window as well as the parent id, so two selections asking for different pages of one relation cannot be served the same batch entry. - The trait impls (
PkLoadable,RelatedTo<Parent, Via>) that let the inverse side reach the loader without naming the other service directly. - A
#[ComplexObject]field resolver on the wire DTO that calls into the loader and shapes the result.
The schema you get:
type User { id: ID! orgId: ID! name: String! org: Org # belongs_to side — PK loader on OrgsService}
type Org { id: ID! name: String! # has_many side — FK loader on UsersService, paged users(first: Int, after: String): UserConnection!}The has_many side is a Relay connection, not a list: edges { cursor node }
plus pageInfo, with the child’s own primary key as the cursor — the same keyset
Repo::page uses everywhere else. first defaults to 20 and is clamped to
1..=100 by clamp_page_size, so a relation over millions of rows is walked a
page at a time.
$ curl -sX POST http://localhost:3000/graphql -d \ '{"query":"{ users { id posts(first: 1) { edges { cursor node { id } } pageInfo { hasNextPage endCursor } } } }"}'{"data":{"users":[ {"id":"…ac00","posts":{"edges":[{"cursor":"019ff…","node":{"id":"019ff…"}}], "pageInfo":{"hasNextPage":false,"endCursor":"019ff…"}}}, {"id":"…ac01","posts":{"edges":[{"cursor":"…b001","node":{"id":"…b001"}}], "pageInfo":{"hasNextPage":true,"endCursor":"…b001"}}}]}}Every parent gets its own page. That is a property of the query, not of the
buffer: the batch ranks rows within each parent
(ROW_NUMBER() OVER (PARTITION BY fk ORDER BY pk)) and keeps the first
first + 1 of each, in one round trip. A single LIMIT over
WHERE fk IN (…) cannot do this — it bounds the result set, so the parents that
sort first consume the budget and the rest come back empty, which reads exactly
like having no children.
The cross-entity rule
Section titled “The cross-entity rule”The FK loader belongs to the owner of the FK column — never the
consumer. Users.org_id lives on the users table, so
UsersServiceByOrgId is on UsersService. The orgs resolver reaches
it through a trait bridge, not by depending on UsersService directly.
This is the same hexagonal rule the rest of the framework follows:
when one service needs to touch another entity, it goes through the
owner’s service — never the ORM. Within a feature’s graphql/
adapter, the resolver only injects its own service; the auto-emitted
relation glue handles the rest.
Auth-scoped batches
Section titled “Auth-scoped batches”Every relation batch runs through Repo::<Entity>::scoped(Action::Read)
against the ambient Ability. A user without read access to Org
asking for user.org { name } gets null for that field, not an
error — the row does not pass the filter. A guarded GraphQL
request always carries an Ability (the guard seeds it per operation);
the ability-less unscoped path is the worker/queue side, not a request
transport — see Repo and executor.
This is the killer property: turning a per-row leak into a wire-level
omission needs no controller code and no manual if in the resolver.
What fails if you get it wrong
Section titled “What fails if you get it wrong”A batch runs on its own task — that is what a DataLoader is — and a fresh
task starts with empty task-locals, so the ambient executor and ability the
request installed are gone. LoaderScope is what carries them across, bound as
LoaderScope as dyn GraphqlBatchContext in a reachable module (nestrs g graphql writes it into authz/graphql/).
Without it, Repo finds no ambient executor and every relation field fails
before a single statement reaches the database — the operation answers
database error on each relation path, scalars resolve normally, and the query
log stays empty. Schema build names the gap up front so the symptom is not the
only clue:
WARN nest_rs::graphql: dataloaders seeded with no batch context loaders=4 hint="list `LoaderScope as dyn GraphqlBatchContext` in a reachable module …"The failure itself is logged too — error on nest_rs::orm, naming the entity
and every binding that installs an executor — because the wire form of
ServiceError::Db is the constant database error and carries nothing an
operator can act on.
Fanout-aware complexity
Section titled “Fanout-aware complexity”With async-graphql’s default 1 + child_complexity formula, a relation
field barely moves the score even when it resolves a hundred rows. The
macro adds the honest estimate for you — the page the client actually
asked for, times the cost of each row:
#[graphql(complexity = "first.unwrap_or(20) as usize * child_complexity")]So posts(first: 5) costs a twentieth of posts(first: 100), and a
three-level chain left at the default page size scores 20³. That is a
direct consequence of the field taking its page size: while the relation
returned an unparameterised list there was nothing to multiply by but a
guess.
BelongsTo keeps the additive default (one parent row, dropping to
zero when the ambient Ability denies it). The two relation kinds
therefore use asymmetric base scoring — multiplicative for
HasMany, additive for BelongsTo — which is the point: pages are
the cost driver. Override per field with #[expose(complexity = …)],
which may now name first — full details in
Query limits.
Opt out: leave a single relation unexposed
Section titled “Opt out: leave a single relation unexposed”Leaving a relation field unexposed (no #[expose]) keeps auto-emission
off for that field only. The relation stays declared at the ORM level
(SeaORM still knows about it), and you can write a hand-rolled
#[field_resolver] if you need a shape the auto-emitted one does not
give you — a filtered or sorted subset, extra edge fields, a projection.
Cursor pagination is not on that list any more: the auto-emitted
field already is a connection.
#[expose(name = "Org", service = …, graphql)]#[sea_orm::model]pub struct Model { #[expose] pub id: Uuid, #[expose] pub name: String,
// No #[expose] => not auto-resolved; own it with a #[field_resolver]. #[sea_orm(has_many)] pub users: HasMany<crate::users::Entity>,}A custom shape: a filtered relation
Section titled “A custom shape: a filtered relation”When the unexposed relation opens the slot, write the replacement on the resolver:
use async_graphql::{Context, Result};use nest_rs::graphql::{operations, resolver};
use crate::orgs::Org;use crate::users::User;
#[resolver]pub struct OrgsResolver { #[inject] users: Arc<crate::users::UsersService>,}
#[operations]impl OrgsResolver { #[field_resolver] async fn admins(&self, _ctx: &Context<'_>, parent: &Org) -> Result<Vec<User>> { Ok(self.users.admins_in_org(parent.id).await?) }}The filter, the service method, and the User wire DTO all stay where
they belong — the resolver only translates between the two.
Two foreign keys to one parent
Section titled “Two foreign keys to one parent”A child can point at the same parent twice — a ticket with a reporter_id
and an assignee_id. Nothing in the type of that relation tells the
parent’s side which column to follow, so it says so:
#[sea_orm(has_many, relation_enum = "Reported", via_rel = "Reporter")]#[expose(via = "reporter_id")]pub reported: HasMany<crate::tickets::Entity>,
#[sea_orm(has_many, relation_enum = "Assigned", via_rel = "Assignee")]#[expose(via = "assignee_id")]pub assigned: HasMany<crate::tickets::Entity>,via names the child’s column; relation_enum / via_rel are SeaORM’s own
way of telling two relations to one entity apart, and belong to the entity
file either way. The child side needs nothing new — it already names its
column in #[sea_orm(belongs_to, from = "…")], so writing via there is a
compile error pointing back at from.
Leaving it out when it is ambiguous does not compile. A child that reaches
one parent through exactly one key gets a default; a child with two gets none,
and the inverse relation fails with the note telling it to name a column. The
one thing that never happens is the framework picking whichever belongs_to
came first.
Advanced: bidirectional and multi-hop graphs
Section titled “Advanced: bidirectional and multi-hop graphs”You can skip this on a first read — it only matters once a schema grows bidirectional or multi-hop relations.
Bidirectional and multi-hop graphs (org ↔ membership ↔ user) are resolved
explicitly rather than guessed. Composite primary keys are refused at compile
time (not a cryptic E0119). See
crates/nest-rs-resource-macros/src/relations.rs for the diagnostics.
Going further
Section titled “Going further”- Dataloaders — write a custom batch loader
(
by_name,by_tag, denormalized lookups) when the auto-emitted PK + FK loaders don’t fit. - Field resolvers — the
leave-it-unexposed escape hatch, and the single-
ComplexObjectrule in detail. - Database — the service contract every loader (auto-emitted or hand-rolled) routes through.