Relations

Adding relations to drizzle objects

t.relation turns a Drizzle relation into a GraphQL field with its target type and cardinality. Register the target GraphQL type as well as the relation.

Published posts and profiles

The public author page returns published posts. Drafts belong on the private Viewer, so the filter belongs on the relation wherever User can be reached:

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

The filter applies whenever this User type is queried. An author with no matching posts returns an empty list. The oldestFirst argument selects the ordering.

To return the Profile object, register its GraphQL type and use t.relation('profile'). To return only its nullable biography, use a field selection.

Relation queries

query accepts a static object or a callback that converts field arguments into Drizzle query options. Alongside filters and ordering, a relation may use limit and offset. For example, this alternative adds offset pagination to the public posts field:

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

The query API enables you to define args and convert them into parameters that will be passed into the relational query builder. The query callback receives (args, ctx, pathInfo) where pathInfo describes where in the GraphQL query the relation is being loaded:

  • path: a list of ParentType.fieldName strings, from the root field down to the field being resolved (eg. ['Query.user', 'User.posts']).
  • segments: one object per entry in path, with field (the field name), alias (the alias used in the query, or the field name if none), parentType (the name of the type the field is defined on), and isList (whether the field returns a list).

You can read more about the relation query builder api here

Fallback queries

A field whose data is not on the row it resolves from is loaded with a fallback query. This happens when:

  • The parent row was not loaded through a t.drizzleField, t.relation, or connection. This covers rows a resolver queried itself, and rows that came from somewhere else entirely.
  • A drizzleField resolver did not pass the result of query() to drizzle.
  • A relation's arguments conflict with a sibling selection of the same relation that was planned first.

Fallback queries are batched. Every row of a table that needs the same selection in the same tick is loaded with one findMany filtered on the primary key, or on the first unique column for a table that has no primary key, and the rows are matched back to their parents. If the query does not return a row for a parent, because it was deleted since it was loaded, or never came from the table, that field rejects with Model users(1) not found, where the value in parentheses is the key that was looked up.

The alias query demonstrates this fallback: newest and oldest cannot share one loaded posts list.

An author’s count must use the same publication filter as the list. Otherwise the number can reveal that unpublished posts exist:

postCount: t.relatedCount('posts', { where: eq(posts.published, true) }),

t.relatedCount returns the number of related rows without loading them. Without a where, it counts all rows in the relation.

The where option accepts either a static SQL filter or a function that receives the field arguments and context:

import { and, eq } from 'drizzle-orm';
import { posts } from './tables';

// In the User fields callback:
publishedPostsCount: t.relatedCount('posts', {
  args: {
    title: t.arg.string(),
  },
  where: (args, ctx) => {
    if (args.title) {
      return and(eq(posts.published, true), eq(posts.title, args.title));
    }

    return eq(posts.published, true);
  },
});

For a many-to-many relation (one defined with .through(...)), t.relatedCount counts distinct related rows, so a row reachable through two junction rows counts once. A t.relatedConnection's totalCount counts the rows the connection pages over instead, which is one per junction row, since that is what the relational query builder returns for the relation.

Many-to-many relations

A post exposes attached Media objects even though the database stores an attachment row. The through relation handles that join; Pothos exposes its target like any other relation:

builder.drizzleObject('posts', {
  name: 'Post',
  select: {},
  fields: (t) => ({
    id: t.exposeID('id'),
    title: t.exposeString('title'),
    author: t.relation('author'),
    media: t.relation('media'),
    mediaConnection: t.relatedConnection('media', {
      query: { orderBy: { id: 'asc' } },
      totalCount: true,
    }),
  }),
});
export const Media = builder.drizzleObject('media', {
  name: 'Media',
  fields: (t) => ({ url: t.exposeString('url'), uploadedBy: t.relation('uploadedBy') }),
});

t.relation and t.relatedConnection expose the Media type directly; no GraphQL type for the junction table is needed.

The t.relatedField method allows you to define a field based on a relation that uses custom selections, including aggregations like counts. This is useful when you want to expose derived data from a relation without loading the full related records.

For a simple count, prefer t.relatedCount. Its equivalent using t.relatedField illustrates how buildFilter restricts an expression to the parent's related rows:

import { and, eq } from 'drizzle-orm';
import { posts } from './tables';

builder.drizzleNode('users', {
  name: 'User',
  id: { column: (user) => user.id },
  fields: (t) => ({
    firstName: t.exposeString('firstName'),
    // Count only this author’s published posts
    postsCount: t.relatedField('posts', {
      type: 'Int',
      // buildFilter creates the correct WHERE clause for the relation
      select: (buildFilter) => {
        return {
          extras: {
            postsCount: (parent) => {
              return db.$count(
                posts,
                and(buildFilter(parent), eq(posts.published, true)),
              );
            },
          },
        };
      },
      resolve: (user) => user.postsCount,
    }),
  }),
});

buildFilter(parent) includes the relation's join conditions and any filter defined on the relation. Combine it with the conditions your field needs, then return the selected value from resolve. Use this helper for custom expressions that t.relatedCount does not cover.

t.relatedField also accepts the normal field options (description, deprecationReason, extensions, and options added by other plugins like authScopes). Its resolve may be async, and receives the resolve info as its fourth argument.