Prisma without a plugin
Using Prisma without a plugin
Use builder.objectRef with the generated Prisma model types and write resolvers that query your client.
This example uses a generated Prisma client with User and Post models, backed by SQLite.
Use the driver adapter for your database, and adjust the client import to your generated output.
Create a builder with an authenticated userId context:
import SchemaBuilder from '@pothos/core';
import { PrismaClient, type Post, type User } from '../lib/prisma/client';
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
const builder = new SchemaBuilder<{ Context: { userId: number } }>({});
const db = new PrismaClient({
adapter: new PrismaBetterSqlite3({ url: 'file:./dev.db' }),
});
const UserObject = builder.objectRef<User>('User');
const PostObject = builder.objectRef<Post>('Post');
UserObject.implement({
fields: (t) => ({
id: t.exposeID('id'),
email: t.exposeString('email'),
posts: t.field({
type: [PostObject],
resolve: (user) => {
return db.post.findMany({
where: { authorId: user.id },
});
},
}),
}),
});
PostObject.implement({
fields: (t) => ({
id: t.exposeID('id'),
title: t.exposeString('title'),
author: t.field({
type: UserObject,
resolve: (post) => {
return db.user.findUniqueOrThrow({
where: { id: post.authorId },
});
},
}),
}),
});
builder.queryType({
fields: (t) => ({
me: t.field({
type: UserObject,
resolve: (root, args, ctx) => {
return db.user.findUniqueOrThrow({
where: { id: ctx.userId },
});
},
}),
}),
});This sets up User, and Post objects with a few fields, and a me query that returns the current
user. There are a few things to note in this setup:
- We split up the
builder.objectRefand theimplementcalls, rather than callingbuilder.objectRef(...).implement(...). This prevents typescript from getting tripped up by the circular references between posts and users. - We use
findUniqueOrThrowbecause those fields are not nullable. UsingfindUnique, prisma will return a null if the object is not found. An alternative is to mark these fields as nullable. - The refs to our object types are called
UserObjectandPostObject, this is becauseUserandPostare the names of the types imported from prisma. We could instead alias the types when we import them so we can name the refs to our GraphQL types after the models.
This setup is fairly simple, but it is easy to see the n+1 issues we might run into. Prisma helps with this by batching queries together, but there are also things we can do in our implementation to improve things.
One thing we could do if we know we will usually be loading the author any time we load a post is to include the author in the backing type for a post. Replace the refs and implementations above with:
const UserObject = builder.objectRef<User>('User');
// We add the author here in the objectRef
const PostObject = builder.objectRef<Post & { author: User }>('Post');
UserObject.implement({
fields: (t) => ({
id: t.exposeID('id'),
email: t.exposeString('email'),
posts: t.field({
type: [PostObject],
resolve: (user) => {
return db.post.findMany({
// We now need to include the author when we query for posts
include: {
author: true,
},
where: { authorId: user.id },
});
},
}),
}),
});
PostObject.implement({
fields: (t) => ({
id: t.exposeID('id'),
title: t.exposeString('title'),
author: t.field({
type: UserObject,
// Now we can just return the author from the post instead of querying for it
resolve: (post) => post.author,
}),
}),
});We may not always want to query for the author though, so we could make the author optional and fall
back to a query if the parent resolver did not include it. Replace PostObject and its implementation with:
const PostObject = builder.objectRef<Post & { author?: User }>('Post');
PostObject.implement({
fields: (t) => ({
id: t.exposeID('id'),
title: t.exposeString('title'),
author: t.field({
type: UserObject,
resolve: (post) =>
post.author ?? db.user.findUniqueOrThrow({ where: { id: post.authorId } }),
}),
}),
});With this setup, a parent resolver has the option to include the author, but we have a fallback in case it does not.
The Dataloader plugin provides another way to batch loads across resolvers.
Compare the same author page
The publishing schema also defines the author lookup with ordinary object refs, using the same Prisma models and seeded database. The refs describe the backing rows; each relation resolver queries Prisma explicitly:
const builder = new SchemaBuilder({});
const Author = builder.objectRef<User>('Author');
const Article = builder.objectRef<Post>('Article');
Author.implement({
fields: (t) => ({
name: t.exposeString('name'),
posts: t.field({
type: [Article],
resolve: (author) => {
return prisma.post.findMany({
where: { authorId: author.id, published: true },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
},
}),
}),
});
Article.implement({
fields: (t) => ({
title: t.exposeString('title'),
author: t.field({
type: Author,
resolve: (post) => {
return prisma.user.findUniqueOrThrow({
where: { id: post.authorId },
});
},
}),
}),
});
builder.queryType({
fields: (t) => ({
author: t.field({
type: Author,
nullable: true,
args: { id: t.arg.int({ required: true }) },
resolve: (_root, args) => {
return prisma.user.findUnique({
where: { id: args.id },
});
},
}),
}),
});author(id: 1) { name posts { title author { name } } } returns the same published posts as the
plugin-backed lookup. The plugin version plans relation selections into the root query; this
version makes those calls in its field resolvers. The selection, eager-loading, and fallback
alternatives above remain useful when choosing how to manage those calls yourself.