PluginsPrisma

Indirect relations

Pre-load data through wrapper types and join tables with the nestedSelection helper.

t.relation pre-loads a direct relation, but some fields don't map to one edge. A field might wrap a Prisma object in a non-Prisma type, or reach a model through a join table two hops away. For those, the select function on a field gives you nestedSelection, a helper that reads the GraphQL selection set at a path you choose and returns the matching Prisma select, so you can still load everything in one query.

This page assumes the generated types and builder are already wired up.

Selecting through a wrapper type

By default nestedSelection returns selections for the current field's own type. Pass it a path and it looks deeper, into a field nested inside the returned type. That's what you need when a field returns a plain objectRef whose inner field is the actual prismaObject.

Here a Player exposes statCards, a list of a plain wrapper type. Each StatCard carries a computed summary plus the full PlayerStat behind its stat field. nestedSelection reaches into statCards.stat to figure out what to load:

import { PlayerStat } from '@prisma/client';

const PlayerStatRef = builder.prismaObject('PlayerStat', {
  fields: (t) => ({
    goals: t.exposeInt('goals'),
    assists: t.exposeInt('assists'),
    game: t.relation('game'),
  }),
});

const StatCard = builder.objectRef<PlayerStat>('StatCard').implement({
  fields: (t) => ({
    stat: t.field({
      type: PlayerStatRef,
      resolve: (stat) => stat,
    }),
    summary: t.string({
      resolve: (stat) => `${stat.goals}G ${stat.assists}A`,
    }),
  }),
});

builder.prismaObject('Player', {
  fields: (t) => ({
    id: t.exposeID('id'),
    statCards: t.field({
      select: (args, ctx, nestedSelection) => ({
        stats: nestedSelection(
          // Default query for the stats relation — cap how many we load.
          { take: 2 },
          // Look at selections under statCards.stat to decide what to select.
          ['stat'],
          // Optional: if the field returned a union or interface, name the
          // concrete type whose selections you want.
          'PlayerStat',
        ),
      }),
      type: [StatCard],
      resolve: (player) => player.stats,
    }),
  }),
});

The third argument is only needed when the nested field returns a union or interface; pass the object type name whose selections you want. For a plain object type it can be omitted.

Reaching through a join table

Many-to-many relations modeled with an explicit join table are the common case for the two-hop reach. In the Ultimate League schema, Player and Game connect through PlayerStat:

model Game {
  id       Int          @id @default(autoincrement())
  playedAt DateTime
  stats    PlayerStat[]
}

model Player {
  id     Int          @id @default(autoincrement())
  name   String
  stats  PlayerStat[]
}

model PlayerStat {
  id       Int    @id @default(autoincrement())
  goals    Int
  assists  Int
  player   Player @relation(fields: [playerId], references: [id])
  playerId Int
  game     Game   @relation(fields: [gameId], references: [id])
  gameId   Int
}

To expose the Players who featured in a Game as a flat list, hiding the PlayerStat join entirely, nest nestedSelection inside the join relation's select. It reads what the query asks of Player and pre-loads exactly those columns and relations:

const Game = builder.prismaObject('Game', {
  fields: (t) => ({
    id: t.exposeID('id'),
    players: t.field({
      select: (args, ctx, nestedSelection) => ({
        stats: {
          select: {
            // Inspects the fields queried on Player and selects them —
            // automatically pulling in a relation like `team` if requested.
            player: nestedSelection(
              // Default query for the player relation; could also be
              // something like `{ select: { id: true } }`.
              true,
            ),
          },
        },
      }),
      type: [Player],
      resolve: (game) => game.stats.map((stat) => stat.player),
    }),
  }),
});

const Player = builder.prismaObject('Player', {
  select: {
    id: true,
  },
  fields: (t) => ({
    name: t.exposeString('name'),
    team: t.relation('team'),
  }),
});

The resolve maps the join rows back to their Player, so clients see game.players with no sign of PlayerStat, while the query still loads a requested team relation in the same round-trip.