Marks a list field as a Relay cursor connection. The schema-transform stage rewrites the field’s return type from [T!]! to a synthesized TConnection carrying edges, nodes, pageInfo, and totalCount; the generator emits keyset-based seek/limit SQL with ROW_NUMBER() OVER (PARTITION BY …) for batched per-parent pagination. If the field doesn’t already declare pagination arguments, forward-pagination args (first: Int, after: String) are added automatically; backward (last/before) and dynamic @orderBy arguments coexist when present.
@asConnection works on three field shapes that the rewrite recognises distinctly: bare list fields on @table parents (inline pagination), @reference-driven child lists (per-parent paginated children), and union/interface lists (multi-table polymorphic pagination). It also composes with @splitQuery to dispatch each parent’s connection through a DataLoader.
SDL signature
directive @asConnection(
defaultFirstValue: Int = 100
connectionName: String
) on FIELD_DEFINITION
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
|
|
|
Page size used when the client omits |
|
|
|
Deprecated. Override the synthesized connection type’s name. Each connection field should own its own type; sharing a Connection across fields conflates distinct semantic contexts (different parents, filters, orders) at the type level. The override remains supported as a transition mechanism for legacy schemas but is permanently discouraged. See How-to: Cursor-paginated connections. |
Canonical examples
A bare list on a @table parent (Query.stores): @asConnection rewrites the return type to StoreConnection, adds first:/after: arguments, and emits keyset pagination using @defaultOrder for the cursor:
type Query {
stores: [Store!]! @asConnection @defaultOrder(primaryKey: true)
}
A union-typed list (Address.occupantsConnection): the multi-table polymorphic emitter wraps the per-branch UNION ALL in a derived table so seek/limit and cursor pagination apply across the union; totalCount counts only this parent’s occupants:
type Address @table(name: "address") {
occupants: [AddressOccupant!]!
occupantsConnection: [AddressOccupant!]! @asConnection(defaultFirstValue: 5)
}
union AddressOccupant = Customer | Staff
Per-parent paginated children with @splitQuery (Film.actorsConnection): a ROW_NUMBER() OVER (PARTITION BY parentInput.idx) envelope gives each parent its own paginated slice, and a DataLoader batches the dispatch:
type Film @table(name: "film") {
actorsConnection(
first: Int, last: Int, after: String, before: String
): ActorsConnection! @splitQuery @reference(path: [
{key: "film_actor_film_id_fkey"},
{key: "film_actor_actor_id_fkey"}
]) @defaultOrder(primaryKey: true)
}
When the schema declares the connection type explicitly (the ActorsConnection! shape above), the rewrite reuses it instead of synthesising a fresh one. Otherwise, a *Connection, *Edge, and PageInfo are generated alongside the field.
Dynamic order via @orderBy composes naturally:
actorsOrderedConnection(
order: [ActorOrderBy] @orderBy,
first: Int, last: Int, after: String, before: String
): ActorsConnection! @splitQuery @reference(path: [
{key: "film_actor_film_id_fkey"},
{key: "film_actor_actor_id_fkey"}
]) @defaultOrder(primaryKey: true)
The cursor encodes the active order columns (the @orderBy selection if the client supplied one, otherwise @defaultOrder); decoding rejects cursors whose embedded sort doesn’t match the request’s effective order.
Constraints
-
Applies to list-typed fields. The runtime cardinality must be a list; single-cardinality
@asConnectionis rejected at classify time. -
The field must have a stable order. Either
@defaultOrderis set on the field, or@orderByis required at runtime; without one of these, keyset pagination has no anchor and the build fails. -
The synthesized connection type’s name must not collide with another type already declared in the schema. The default
<ParentType><FieldName>Connectionshape rarely collides because parent and field names jointly disambiguate; collisions usually mean two carriers should be split into distinct fields rather than coalesced. The deprecatedconnectionName:override exists as a transition escape hatch. -
defaultFirstValue:is the page size used when the client omitsfirst:; it is not a cap. The rewrite plugin does not ship a server-side maximum onfirst:/last:(the legacy generator’s<maxAllowedPageSize>setting was excised); enforce caps at the API layer if needed. See How-to: Cursor-paginated connections. -
Composes with
@splitQuery: when both are present, the connection is materialized per-parent through aDataLoaderkeyed on the parent’s PK or FK. Without@splitQueryon a parent-keyed list, all parents' rows are paginated in a single SQL pass (thePARTITION BYenvelope is omitted). -
Inline
TableFieldplacement is not allowed under interface-or-union return types unless the surrounding type axis supports the multi-table emitter; the generator rejects mismatched placements at classify time. (Seerewrite-design-principles.adocfor the "no generator-side invariant goes unchecked at validate time" rule that backs this.)
See also
-
@splitQuerydispatches per-parent connections through aDataLoaderfor nested-depth pagination. -
@defaultOrderanchors the cursor when the client omits@orderBy. -
@orderBysupplies dynamic order; the cursor encodes the active selection. -
How-to: Cursor-paginated connections covers the one-field-one-connection rule,
totalCountperformance, and what the rewrite does about page-size caps.