Skip to content

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.

crates/features/src/users/entity.rs
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>,
}
crates/features/src/orgs/entity.rs
#[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:

Terminal window
$ curl -sX POST http://localhost:3000/graphql -d \
'{"query":"{ users { id org { id name } } orgs { users { edges { node { name } } } } }"}'

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_to relation, 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:

the SDL it produces
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.

Terminal window
$ 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 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.

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.

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:

Terminal window
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.

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:

from the macro expansion
#[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.

crates/features/src/orgs/entity.rs
#[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>,
}

When the unexposed relation opens the slot, write the replacement on the resolver:

crates/features/src/orgs/graphql/resolver.rs
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.

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:

crates/features/src/people/entity.rs
#[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.

  • 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-ComplexObject rule in detail.
  • Database — the service contract every loader (auto-emitted or hand-rolled) routes through.