Query planning

How the plugin turns a GraphQL query into prisma queries

This page describes how the plugin turns a GraphQL query into prisma queries, which is worth knowing when a schema issues more queries than you expect.

How fields get their data

A field either reads its data from a row that has already been loaded, or runs a query of its own.

A field's select is planned into the query of the nearest ancestor that runs one: a t.prismaField, a t.relation, a connection, or a fallback query. The field then reads what it needs off the loaded row, without a query of its own. A field runs its own query when its resolve queries prisma directly, and when the plugin issues a fallback query for a t.relation that is missing from the row.

A field can do both. A t.prismaField, or any other field with a select, nested under one of those ancestors has its select planned into the parent's row, and still runs its own query when it resolves.

A field-level select is merged into the same query as its siblings and the type-level selection, rather than getting a copy of the row for that field alone. Two selections of the same relation share a place in that query only when their arguments (where, orderBy, take, ...) match. When they differ, the first one planned wins, and the other is loaded with a query of its own. A type-level select or include is planned before any field's selection, no matter where they appear in the document, and fields are planned in the order they are selected.

Async selections

Selections are synchronous unless the schema opts in with AsyncSelections: true. This example uses request context methods that asynchronously return a user ID and a preview limit:

const builder = new SchemaBuilder<{
  PrismaTypes: PrismaTypes;
  AsyncSelections: true;
  Context: {
    currentUserId: () => Promise<number>;
    previewSize: () => Promise<number>;
  };
}>({
  plugins: [PrismaPlugin],
  prisma: {
    client: prisma,
    dmmf: getDatamodel(),
  },
});

With the opt-in, select functions, relation query callbacks, relationCount where callbacks, and the select and query callbacks of prismaConnectionHelpers may be async. Without it they are typed as synchronous, and an async callback is a type error.

The plugin still builds a single query. It waits for the callbacks, and merges what they return after every synchronous selection, in document order. t.relation, t.relationCount, t.prismaField, t.prismaConnection and t.relatedConnection settle their plan before the resolver runs, and need no changes. The following example uses a registered Comment Prisma object ref and its Post.comments relation:

builder.prismaObject('Post', {
  fields: (t) => ({
    comments: t.relation('comments', {
      query: async (args, ctx) => ({ where: { authorId: await ctx.currentUserId() } }),
    }),
    previewComments: t.field({
      type: [Comment],
      select: async (args, ctx, nestedSelection) => ({
        comments: await nestedSelection({ take: await ctx.previewSize() }),
      }),
      resolve: (post) => post.comments,
    }),
  }),
});

await what nestedSelection returns before putting it in the selection. A selection that contains the promise itself will throw, and so will a select that returns while a nested selection it started is still pending. Calling nestedSelection and discarding a synchronous result is not detected, and the nested selection will not be loaded with the parent, so the field falls back to its own query.

Pass awaitSelections: true to queryFromInfo and prismaConnectionHelpers(...).getQuery, and await the query they return. Without it, an async selection beneath the field throws, and whether there is one depends on the incoming document rather than on the callback you wrote. A connection helper also throws when its own select or query is async, whatever the document asked for:

const post = await prisma.post.findUniqueOrThrow({
  where: { id: args.id },
  ...(await queryFromInfo({ context, info, awaitSelections: true })),
});

awaitSelections is a per-call option, and is available whether or not the schema sets AsyncSelections.

Optimized queries without t.prismaField

In some cases, it may be useful to get an optimized query for fields where you can't use t.prismaField.

This may be required for combining with other plugins, or because your query does not directly return a PrismaObject. In these cases, you can use the queryFromInfo helper. An example of this might be a mutation that wraps the prisma object in a result type.

The example assumes a Post model with id, title, and authorId columns, an existing User record for context.userId, and the builder setup with that context type.

import type { Post as PostRow } from '../lib/prisma/client';
import { queryFromInfo } from '@pothos/plugin-prisma';

const Post = builder.prismaObject('Post', {
  fields: (t) => ({
    title: t.exposeString('title'),
  }),
});

const CreatePostResult = builder.objectRef<{
  success: boolean;
  post: PostRow | null;
}>('CreatePostResult').implement({
  fields: (t) => ({
    success: t.exposeBoolean('success'),
    post: t.field({
      type: Post,
      nullable: true,
      resolve: (result) => result.post,
    }),
  }),
});

builder.mutationType({
  fields: (t) => ({
    createPost: t.field({
      type: CreatePostResult,
      args: {
        title: t.arg.string({ required: true }),
      },
      resolve: async (parent, args, context, info) => {
        if (!args.title.trim()) {
          return { success: false, post: null };
        }

        const post = await prisma.post.create({
          ...(await queryFromInfo({
            context,
            info,
            path: ['post'],
            awaitSelections: true,
          })),
          data: {
            title: args.title,
            authorId: context.userId,
          },
        });

        return { success: true, post };
      },
    }),
  }),
});

The columns and relations the query selected come back on the rows, along with anything you passed in as select, and the rows are typed to match.

To require data even when the client does not request it, pass an initial select (or include) to queryFromInfo. For example, add select: { id: true } beside path in the call above to ensure the returned post includes its ID. When nothing is selected at path, the helper returns that initial selection unchanged, or an empty query object if no initial selection was supplied.

The path is followed through fragments in the query, including inline fragments and fragment spreads that narrow an interface or union to one of its implementations. Every selection of the field that is found is merged into the query. If several implementations share a field name, matches whose field returns a different Prisma model are ignored. When you need to target a specific implementation, a segment can be written as { name, type }. The field then only matches when it is selected directly, or under a fragment on that type or one of its subtypes:

const user = await prisma.user.findUniqueOrThrow({
  where: { id: args.id },
  ...queryFromInfo({
    context,
    info,
    typeName: 'User',
    // only match `appointment` when selected inside `... on AppointmentEntry`
    path: [{ name: 'appointment', type: 'AppointmentEntry' }],
  }),
});

// user is loaded with the selections from `... on AppointmentEntry`,
// and nothing from an `appointment` field on another implementation

Conflicting selections between variants

When a query selects two variants of one model for the same row, either with a fragment on each under one field, or through a t.variant field, the plugin will throw if their type-level select/include ask for the same relation with different arguments:

PothosValidationError: Type-level selections of Viewer and Admin conflict on relation "posts".
Move the relation arguments to a field-level select on one of the types.

Both variants describe one row, so their type-level selections are merged into a single query.

To fix this, keep the relation with its arguments in the select of the field that needs it, on one of the variants. A field-level selection that conflicts with what the row already holds falls back to a query of its own, rather than failing the request.