PluginsDrizzle

Drizzle without a plugin

Back GraphQL objects with Drizzle rows using plain objectRef, and tame the resulting N+1 queries.

You don't need the Drizzle plugin to put Drizzle rows behind a GraphQL schema. builder.objectRef takes any TypeScript shape as its backing model, and a Drizzle row is just a shape; InferSelectModel gives you the type of a select from a table. Point a ref at it and resolve relations with ordinary query-builder calls. You give up the plugin's automatic query-planning, but the code stays plain Pothos.

import { InferSelectModel } from 'drizzle-orm';
import { players, teams } from './db/schema';

type TeamRow = InferSelectModel<typeof teams>;
type PlayerRow = InferSelectModel<typeof players>;

const TeamObject = builder.objectRef<TeamRow>('Team');
const PlayerObject = builder.objectRef<PlayerRow>('Player');

TeamObject.implement({
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    players: t.field({
      type: [PlayerObject],
      resolve: (team) => db.query.players.findMany({ where: { teamId: team.id } }),
    }),
  }),
});

PlayerObject.implement({
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    team: t.field({
      type: TeamObject,
      resolve: async (player) => {
        const team = await db.query.teams.findFirst({ where: { id: player.teamId } });
        if (!team) throw new Error(`Team ${player.teamId} not found`);
        return team;
      },
    }),
  }),
});

builder.queryType({
  fields: (t) => ({
    myTeam: t.field({
      type: TeamObject,
      resolve: async (_root, _args, ctx) => {
        const team = await db.query.teams.findFirst({ where: { id: ctx.teamId } });
        if (!team) throw new Error('Team not found');
        return team;
      },
    }),
  }),
});

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. Declaring TeamObject/PlayerObject up front and calling implement afterwards, rather than builder.objectRef(...).implement(...) in one expression, keeps TypeScript from choking on the circular reference between teams and players.
  • Throw for non-null fields. team and myTeam are non-nullable, so they must never resolve to null. Drizzle's findFirst returns undefined when nothing matches, so throw explicitly. Mark the field nullable instead if a missing row is a valid result.
  • Ref names vs. type names. The refs are TeamObject/PlayerObject because TeamRow/PlayerRow name the backing shapes. Give the refs the GraphQL type names directly if you'd rather.

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. You can 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 load it with with:

const TeamObject = builder.objectRef<TeamRow>('Team');
// Widen the backing model so a Player always carries its Team.
const PlayerObject = builder.objectRef<PlayerRow & { team: TeamRow }>('Player');

TeamObject.implement({
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    players: t.field({
      type: [PlayerObject],
      resolve: (team) =>
        db.query.players.findMany({
          // Load the team so the child resolver has it already.
          with: { 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 with. When only some paths can supply it, make the field optional and fall back to a query:

const PlayerObject = builder.objectRef<PlayerRow & { team?: TeamRow }>('Player');

PlayerObject.implement({
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    team: t.field({
      type: TeamObject,
      resolve: async (player) => {
        if (player.team) return player.team;
        const team = await db.query.teams.findFirst({ where: { id: player.teamId } });
        if (!team) throw new Error(`Team ${player.teamId} not found`);
        return team;
      },
    }),
  }),
});

Now a parent resolver may pre-load the team, and the field still resolves correctly when it doesn't.

A dataloader is another lever for N+1, batching the per-player team lookups into one query. The Drizzle plugin is a third option: t.relation and t.drizzleField read the GraphQL selection set and build a single nested query, so you don't shape the backing model by hand.

On this page

Edit on GitHub