PluginsPrisma Next

Relations

Adding relations to Prisma-backed types

Use t.relation to expose a relation from your contract as a field. The plugin loads it with the parent's query, so a user's posts come back with the user:

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'),
  }),
});

Relation fields follow the builder's default field nullability, whatever the contract says, so a database change never changes the schema. Pass nullable to override.

Nullable relations and onNull

A to-one relation can resolve to null when the contract makes it optional, or when a variant type filters the row out. To expose one as non-null, t.relation requires onNull. Setting it to 'error' lets GraphQL raise the non-null error for the field. A function can return a replacement row from collectionFromInfo instead, or an Error to raise. This example uses the optional Bug.reviewer relation from Model inheritance:

builder.prismaObject('Bug', {
  fields: (t) => ({
    reviewer: t.relation('reviewer', { nullable: true }),
    // An error when the bug has no reviewer
    requiredReviewer: t.relation('reviewer', { nullable: false, onNull: 'error' }),
    // A different error
    assignedReviewer: t.relation('reviewer', {
      nullable: false,
      onNull: () => new Error('Bug has no reviewer'),
    }),
  }),
});

Filtering and sorting

Pass query to filter, sort, or limit the related rows. It takes { where, orderBy, limit, offset }, or a function of the field's arguments and context that returns one. where takes anything the Collection's where() does. orderBy takes columns, each with a direction, and sorts nulls last unless you set nulls, as in a connection. Here clients choose the sort order:

posts: t.relation('posts', {
  args: { oldestFirst: t.arg.boolean() },
  query: (args) => ({
    orderBy: { createdAt: args.oldestFirst ? 'asc' : 'desc' },
  }),
}),

With AsyncSelections: true in the builder's types, the function can be async.

To sort by an expression, pass orderBy what the Collection's orderBy() takes, one selector or an array of them:

posts: t.relation('posts', {
  query: {
    orderBy: (post, { fns }) => fns.raw`abs(${post.score} - 2.5)`.returns('sqlite/real@1').asc(),
  },
}),

A to-one relation takes no query. To filter one, select it in a field's select and return null from the resolver.

Paginating a list

The offset pattern for root fields works for relations too, through query:

posts: t.relation('posts', {
  args: { limit: t.arg.int(), offset: t.arg.int() },
  query: (args) => ({
    orderBy: { createdAt: 'desc' },
    limit: args.limit ?? 10,
    offset: args.offset ?? 0,
  }),
}),

For cursor-based pagination, use t.relatedConnection.

Aliases

A query can select one relation field several times under aliases with different arguments:

{
  author(id: "u-alice") {
    newest: posts(oldestFirst: false) { title }
    oldest: posts(oldestFirst: true) { title }
  }
}

Each alias loads its own rows in the parent's query. Aliases with the same arguments share one load.

Counts and other aggregates

t.relationCount adds an Int field that counts a to-many relation. Pass a query with a where to count only some rows:

builder.prismaObject('User', {
  fields: (t) => ({
    postCount: t.relationCount('posts'),
    publishedPostCount: t.relationCount('posts', { query: { where: { published: 1 } } }),
    postCountByStatus: t.relationCount('posts', {
      args: { published: t.arg.int({ required: true }) },
      query: (args) => ({ where: { published: args.published } }),
    }),
  }),
});

query is an object or a function of the arguments and context, as on t.relation, but takes only where. where takes anything a relation's query.where takes, including a predicate: query: { where: (post) => post.published.eq(1) }.

Count fields are always non-null, so if an auth check denies one, GraphQL nulls the parent instead. To hide a count from some users, use a nullable field with a select, as below.

For sums and other aggregates, give the relation a function in a field's select. It takes the relation's Collection and returns reducers by key, and each key is added to the parent row:

builder.prismaObject('User', {
  fields: (t) => ({
    totalPostScore: t.float({
      nullable: true,
      select: { posts: (posts) => ({ totalPostScore: posts.sum('score') }) },
      resolve: (user) => user.totalPostScore,
    }),
  }),
});

Keep aggregate callbacks free of side effects, and return the same keys for the same arguments. A field's select can load at most 61 aggregate keys at one level, across its relations; split larger summaries across fields.

Many-to-many

Relations defined with rel.manyToMany({ through, from, to }) work with t.relation. To expose columns from the junction table, define the junction as its own model and use two relations.

Returning rows from other fields

If a plain t.field returns a model's type, instead of t.prismaField or t.relation, it must return rows the plugin planned: the parent, the relation rows the parent's select loads, or rows from a query built with collectionFromInfo. These are typed PrismaPlanned<'User'>. A row built by hand or loaded with a plain ORM query is a type error naming PrismaPlanned<'User'>.

builder.prismaObject('Post', {
  select: { author: true },
  fields: (t) => ({
    writer: t.field({ type: User, resolve: (post) => post.author }),
  }),
});

A relation entry's type can be a ref or model name of one of the relation's variants. The rows load as that variant only, typed as its rows:

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

builder.prismaObject('User', {
  fields: (t) => ({
    bugs: t.field({
      type: [Bug],
      select: { tasks: { type: Bug } },
      resolve: (user) => user.tasks,
    }),
  }),
});

collectionFromInfo loads everything the query selects beneath the field, relations and counts included, so the field loads in one query, like a t.prismaField. See Mutations for its path and typeName options:

import { collectionFromInfo } from '@pothos/plugin-prisma-next';

builder.queryField('me', (t) =>
  t.field({
    type: User,
    nullable: true,
    resolve: async (_root, _args, ctx, info) => {
      const users = await collectionFromInfo({
        collection: db.orm.User.where({ id: ctx.userId }),
        context: ctx,
        info,
      });
      return users.first();
    },
  }),
);

In a subscription, call it in resolve, which runs per event; rows from subscribe load again.

To return a row through Node, or through an interface from builder.interfaceRef():

  • Brand it with the type's addBrand, such as User.addBrand(row). Unbranded rows fail with Abstract type ... must resolve.
  • If you route a row yourself, with a __typename or a resolveType that returns a type name, TypeScript doesn't check that the row is planned.