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 ofParentType.fieldNamestrings, from the root field down to the field being resolved (eg.['Query.user', 'User.posts']).segments: one object per entry inpath, withfield(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), andisList(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
drizzleFieldresolver did not pass the result ofquery()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.
Related count
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.
Related field
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.