Relations

Adding relations to prisma objects

Use t.relation to expose relations between models. This example uses the client and authenticated userId context from Setup:

builder.queryType({
  fields: (t) => ({
    me: t.prismaField({
      type: 'User',
      resolve: async (query, root, args, ctx, info) => {
        return prisma.user.findUniqueOrThrow({
          ...query,
          where: { id: ctx.userId },
        });
      },
    }),
  }),
});

builder.prismaObject('User', {
  fields: (t) => ({
    id: t.exposeID('id'),
    email: t.exposeString('email'),
    posts: t.relation('posts'),
  }),
});

builder.prismaObject('Post', {
  fields: (t) => ({
    id: t.exposeID('id'),
    title: t.exposeString('title'),
    author: t.relation('author'),
  }),
});

t.relation defines a field that can be pre-loaded by a parent resolver. This will create something like { include: { author: true }} that will be passed as part of the query argument of a prismaField resolver. If the parent is another relation field, the includes will become nested, and the full relation chain will be passed to the prismaField that started the chain.

For example the query:

query {
  me {
    posts {
      author {
        id
      }
    }
  }
}

the me prismaField would receive something like the following as its query parameter:

{
  include: {
    posts: {
      include: {
        author: true;
      }
    }
  }
}

When selections cannot share a Prisma query, Pothos loads the missing data with fallback queries.

Fallback queries

There are some cases where data can not be pre-loaded by a prisma field. In these cases, pothos will issue a findUnique query for the parent of any fields that were not pre-loaded, and select the missing relations so those fields can be resolved with the correct data. These queries should be very efficient, are batched by pothos to combine requirements for multiple fields into one query, and batched by Prisma to combine multiple queries (in an n+1 situation) to a single sql query.

The following are some edge cases that could cause an additional query to be necessary:

  • The parent object was not loaded through a field defined with t.prismaField, or t.relation
  • The root prismaField did not correctly spread the query arguments in its prisma call.
  • The query selects multiple fields that use the same relation with different filters, sorting, or limits
  • The query contains multiple aliases for the same relation field with different arguments in a way that results in different query options for the relation.
  • A relation field has a query that is incompatible with the default includes of the parent object

A fallback query loads the parent row again by its primary key, or by the first required unique field or index when the model has no primary key, selecting what the missing fields need. To load it some other way, add a findUnique option to the type that returns the where for prisma.<model>.findUnique:

builder.prismaObject('User', {
  findUnique: (user, ctx) => ({ email: user.email }),
  fields: (t) => ({
    id: t.exposeID('id'),
    posts: t.relation('posts'),
  }),
});

A type in include mode can also opt out of fallback queries with findUnique: null. A field that would need one will throw Missing findUnique for User instead of querying.

Filters, Sorting, and arguments

So far we have been describing very simple queries without any arguments, filtering, or sorting. For t.prismaField definitions, you can add arguments to your field like normal, and pass them into your prisma query as needed. For t.relation the flow is slightly different because we are not making a prisma query directly. We do this by adding a query option to our field options. Query can either be a query object, or a method that returns a query object based on the field arguments.

builder.prismaObject('User', {
  fields: (t) => ({
    id: t.exposeID('id'),
    posts: t.relation('posts', {
      // We can define arguments like any other field
      args: {
        oldestFirst: t.arg.boolean(),
      },
      // Then we can generate our query conditions based on the arguments
      query: (args, context) => ({
        orderBy: {
          createdAt: args.oldestFirst ? 'asc' : 'desc',
        },
      }),
    }),
  }),
});

The returned query object will be added to the include section of the query argument that gets passed into the first argument of the parent t.prismaField, and can include things like where, skip, take, and orderBy. The query function will be passed the arguments for the field, and the context for the current request. Because it is used for pre-loading data, and solving n+1 issues, it can not be passed the parent object because it may not be loaded yet.

Nullable relations and onNull

A relation that is optional in the prisma schema (profile Profile?) can be exposed as a nullable field with nullable: true. To expose it as non-nullable, t.relation requires an onNull option describing what should happen when the related row is missing. Setting it to 'error' lets GraphQL raise the non-null error for the field. A function can return a replacement value instead, or an Error to raise:

builder.prismaObject('User', {
  fields: (t) => ({
    profile: t.relation('profile', { nullable: true }),
    // An error when the user has no profile
    requiredProfile: t.relation('profile', { nullable: false, onNull: 'error' }),
    // A default when the user has no profile
    profileOrDefault: t.relation('profile', {
      nullable: false,
      onNull: (user, args, ctx, info) => ({ id: 0, userId: user.id, bio: null }),
    }),
  }),
});

relationCount

t.relationCount adds a field that counts related rows without loading them. Its where option filters the rows included in the count:

builder.prismaObject('User', {
  fields: (t) => ({
    id: t.exposeID('id'),
    postCount: t.relationCount('posts', {
      where: {
        published: true,
      },
    }),
  }),
});

Published posts and profiles

In a publishing API, a public author page exposes published posts and an optional profile. Drafts belong on the private viewer. Filtering the list and its count consistently prevents the count from revealing unpublished posts. Sorting by both the timestamp and ID makes the order deterministic when two posts share a timestamp.

The following type is used by the nullable author lookup in Objects:

builder.prismaNode('User', {
  id: { field: 'id' },
  select: { id: true },
  fields: (t) => ({
    name: t.exposeString('name'),
    bio: t.string({
      nullable: true,
      select: { profile: { select: { bio: true } } },
      resolve: (user) => user.profile?.bio,
    }),
    posts: t.relation('posts', {
      args: { oldestFirst: t.arg.boolean() },
      query: (args) => ({
        where: { published: true },
        orderBy: [
          { createdAt: args.oldestFirst ? 'asc' : 'desc' },
          { id: args.oldestFirst ? 'asc' : 'desc' },
        ],
      }),
    }),
    postCount: t.relationCount('posts', { where: { published: true } }),
    postsConnection: t.relatedConnection('posts', {
      cursor: 'id',
      query: { where: { published: true }, orderBy: { id: 'asc' } },
      totalCount: true,
    }),
  }),
});

author(id: 1) { name bio postCount posts { title } } returns Maya's profile and two published posts. Nora (id: 3) has no profile or posts, so bio is null, posts is empty, and postCount is zero. A missing relation is represented as missing data, without manufacturing a profile record.