Prisma without a plugin
Back GraphQL objects with Prisma models using plain objectRef, and tame the resulting N+1 queries.
You don't need the Prisma plugin to put Prisma models behind a GraphQL schema. builder.objectRef takes any TypeScript shape as its backing model, and a Prisma row is just a shape, so you can point a ref at Team or Player from @prisma/client and resolve relations with ordinary client calls. You give up the plugin's automatic query-planning, but the code stays plain Pothos.
import { Player, PrismaClient, Team } from '@prisma/client';
const db = new PrismaClient();
const TeamObject = builder.objectRef<Team>('Team');
const PlayerObject = builder.objectRef<Player>('Player');
TeamObject.implement({
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
players: t.field({
type: [PlayerObject],
resolve: (team) => db.player.findMany({ where: { teamId: team.id } }),
}),
}),
});
PlayerObject.implement({
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
team: t.field({
type: TeamObject,
resolve: (player) => db.team.findUniqueOrThrow({ where: { id: player.teamId } }),
}),
}),
});
builder.queryType({
fields: (t) => ({
myTeam: t.field({
type: TeamObject,
resolve: (_root, _args, ctx) => db.team.findUniqueOrThrow({ where: { id: ctx.teamId } }),
}),
}),
});This defines Team and Player objects with a relation each, plus a myTeam query for the viewer's team. Three details make it work:
- Split the ref from
implement. DeclaringTeamObject/PlayerObjectup front and callingimplementafterwards, rather thanbuilder.objectRef(...).implement(...)in one expression, keeps TypeScript from choking on the circular reference between teams and players. findUniqueOrThrowfor non-null fields.teamandmyTeamare non-nullable, so they must never resolve tonull.findUniquereturnsnullwhen nothing matches;findUniqueOrThrowthrows instead. UsefindUniqueonly when the field is markednullable.- Ref names vs. type names. The refs are
TeamObject/PlayerObjectbecauseTeamandPlayerare already taken by the imports from@prisma/client. Alias the imports instead (import { Team as TeamModel }) if you'd rather name the refs after the GraphQL types.
Cutting down N+1 queries
The schema above issues one query per relation edge. Fetch a team, then its players, then each player's team, and the round-trips multiply. Prisma batches some of this for you, but you can also shape the backing model to avoid the round-trip entirely.
If you almost always load a player's team alongside the player, fold the team into the backing shape and have the parent resolver include it:
const TeamObject = builder.objectRef<Team>('Team');
// Widen the backing model so a Player always carries its Team.
const PlayerObject = builder.objectRef<Player & { team: Team }>('Player');
TeamObject.implement({
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
players: t.field({
type: [PlayerObject],
resolve: (team) =>
db.player.findMany({
// Include the team so the child resolver has it already.
include: { team: true },
where: { teamId: team.id },
}),
}),
}),
});
PlayerObject.implement({
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
team: t.field({
type: TeamObject,
// No query — the team came along with the player.
resolve: (player) => player.team,
}),
}),
});Requiring team on every Player is a strong claim: every resolver that produces a player now owes you the include. When only some paths can supply it, make the field optional and fall back to a query:
const PlayerObject = builder.objectRef<Player & { team?: Team }>('Player');
PlayerObject.implement({
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
team: t.field({
type: TeamObject,
resolve: (player) =>
player.team ?? db.team.findUniqueOrThrow({ where: { id: player.teamId } }),
}),
}),
});Now a parent resolver may pre-load the team, and the field still resolves correctly when it doesn't.
A dataloader is the other lever for N+1: batch the per-player team lookups into one query. Or let the Prisma plugin plan these selections for you: t.relation and t.prismaField read the GraphQL selection set and build a single nested query.