Guide to using Pothos GraphQL
Introduction
Pothos GraphQL is a GraphQL Schema Builder, which is a library that helps you write/define a GraphQL Schema using TypeScript code (a Code-First API approach).
Functions
- Helps define each
Query,MutationandGraphQL Typeusing strongly typedTypeScriptcode, automatically catching errors to ensure 100%Type Safety. Pothoshas a built-in plugin to connect withDrizzle (@pothos/plugin-drizzle). You only need to declare tables inDrizzlecorresponding to Objects inGraphQLandPothoswill automatically understandrelations, pagination, helping you avoid writing complexSELECT/JOINstatements.
Detail
bun add @pothos/core @pothos/plugin-drizzle @pothos/plugin-zod
Create file src/drizzle-orm/schema/schema.ts
import {sql} from 'drizzle-orm'
import {
check,
decimal,
integer,
pgTable,
serial,
text,
timestamp,
unique,
varchar,
} from 'drizzle-orm/pg-core'
export const baseColumns = {
id: serial('id').primaryKey(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}
export const users = pgTable('users', {
...baseColumns,
username: varchar('username', {length: 255}).notNull().unique(),
email: varchar('email', {length: 255}).notNull().unique(),
password: text('password').notNull(),
firstName: varchar('first_name', {length: 100}).notNull(),
lastName: varchar('last_name', {length: 100}).notNull(),
phone: varchar('phone', {length: 20}),
})
export const products = pgTable('products', {
...baseColumns,
name: varchar('name', {length: 255}).notNull(),
description: text('description'),
})
export const productVariants = pgTable(
'product_variants',
{
...baseColumns,
productId: integer('product_id')
.notNull()
.references(() => products.id, {onDelete: 'cascade'}),
sku: varchar('sku', {length: 100}).notNull().unique(),
variationName: varchar('variation_name', {length: 100}).notNull(),
price: decimal('price', {precision: 12, scale: 2}).notNull(),
stock: integer('stock').notNull().default(0),
views: integer('views').default(0),
},
table => [check('stock_gte_zero', sql`${table.stock} >= 0`)]
)
Create file src/drizzle-orm/index.ts
import {DefaultLogger, type LogWriter} from 'drizzle-orm'
import {drizzle, type NodePgDatabase} from 'drizzle-orm/node-postgres'
import {Pool} from 'pg'
import {DRIZZLE_DB} from 'src/constant'
import * as schema from './schema/schema'
export type DrizzleDB = NodePgDatabase<typeof schema>
export const createPool = () => {
return new Pool({connectionString: process.env.DATABASE_URL})
}
export const createDrizzle = () => {
const pool = createPool()
return drizzle(pool, {
schema,
})
}
export const drizzleProvider = {
provide: DRIZZLE_DB,
useFactory: createDrizzle,
}
Create file src/pothos-graphql/builder.ts
import SchemaBuilder from '@pothos/core'
import DrizzlePlugin from '@pothos/plugin-drizzle'
import ZodPlugin from '@pothos/plugin-zod'
import {
extractTablesRelationalConfig,
type ExtractTablesWithRelations,
} from 'drizzle-orm'
import {getTableConfig} from 'drizzle-orm/pg-core'
import {GraphQLError} from 'graphql'
import type {DrizzleDB} from 'src/drizzle-orm'
import * as schema from 'src/drizzle-orm/schema/schema'
interface GraphQLContext {
db: DrizzleDB
}
type Schema = typeof schema
type DrizzleTableRelations = ExtractTablesWithRelations<Schema>
const relations = extractTablesRelationalConfig(schema, table => table)
.tables as DrizzleTableRelations
export const builder = new SchemaBuilder<{
Context: GraphQLContext
DrizzleSchema: Schema
DrizzleRelations: DrizzleTableRelations
}>({
plugins: [DrizzlePlugin, ZodPlugin],
drizzle: {
client: ((ctx: GraphQLContext) => ctx.db) as never,
getTableConfig: getTableConfig as never,
relations,
},
zod: {
validationError: (zodError, _args, _ctx, _info) => {
const formattedErrors = zodError.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
}))
return new GraphQLError('Validation failed', {
extensions: {
code: 'BAD_USER_INPUT',
errors: formattedErrors,
},
})
},
},
})
builder.queryType({})
builder.mutationType({})
- Used to configure Pothos for use with
@pothos/plugin-drizzleand@pothos/plugin-zod - The
builder.queryType({})section allows definingquery, whilebuilder.mutationType({})allows definingmutation
Create file src/pothos-graphql/module/users.ts
import {eq} from 'drizzle-orm'
import {users} from 'src/drizzle-orm/schema/schema'
import {z} from 'zod'
import {builder} from '../builder'
const User = builder.objectRef<typeof users.$inferSelect>('User')
builder.objectType(User, {
name: 'User',
fields: t => ({
id: t.exposeInt('id'),
username: t.exposeString('username'),
email: t.exposeString('email'),
firstName: t.exposeString('firstName'),
lastName: t.exposeString('lastName'),
fullName: t.string({
resolve: user => `${user.firstName}${user.lastName}`,
}),
createdAt: t.string({
resolve: user => user.createdAt.toISOString(),
}),
}),
})
builder.queryFields(t => ({
users: t.field({
type: [User],
resolve: async (_root, _args, ctx) => {
return ctx.db.select().from(users)
},
}),
user: t.field({
type: User,
nullable: true,
args: {
id: t.arg.int({required: true}),
},
resolve: async (_root, args, ctx) => {
const [foundUser] = await ctx.db
.select()
.from(users)
.where(eq(users.id, args.id))
return foundUser ?? null
},
}),
}))
builder.mutationFields(t => ({
createUser: t.field({
type: User,
args: {
username: t.arg.string({
required: true,
validate: {schema: z.string().min(3)},
}),
email: t.arg.string({
required: true,
validate: {schema: z.email()},
}),
password: t.arg.string({
required: true,
validate: {schema: z.string().min(6)},
}),
firstName: t.arg.string({required: true}),
lastName: t.arg.string({required: true}),
phone: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
const [newUser] = await ctx.db
.insert(users)
.values({
username: args.username,
email: args.email,
password: args.password,
firstName: args.firstName,
lastName: args.lastName,
})
.returning()
return newUser
},
}),
updateUser: t.field({
type: User,
nullable: true,
args: {
id: t.arg.int({required: true}),
username: t.arg.string({
validate: {schema: z.string().min(3)},
}),
email: t.arg.string({
validate: {schema: z.email()},
}),
password: t.arg.string({
validate: {schema: z.string().min(6)},
}),
firstName: t.arg.string(),
lastName: t.arg.string(),
phone: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
const {id, ...rest} = args
const updateData = Object.fromEntries(
Object.entries(rest).filter(([, value]) => value !== undefined)
)
if (Object.keys(updateData).length === 0) {
const [existing] = await ctx.db
.select()
.from(users)
.where(eq(users.id, id))
return existing ?? null
}
const [updatedUser] = await ctx.db
.update(users)
.set(updateData)
.where(eq(users.id, id))
.returning()
return updatedUser ?? null
},
}),
deleteUser: t.field({
type: User,
nullable: true,
args: {
id: t.arg.int({required: true}),
},
resolve: async (_root, args, ctx) => {
const [deletedUser] = await ctx.db
.delete(users)
.where(eq(users.id, args.id))
.returning()
return deletedUser ?? null
},
}),
}))
- As you can see, I have fully defined
CRUDfor user - In
createUserandupdateUser, Zod was additionally used to validate the payload
Create file src/pothos-graphql/module/products.ts
import {
resolveCursorConnection,
type ResolveCursorConnectionArgs,
} from '@pothos/plugin-relay'
import {and, asc, desc, eq, gt, lt} from 'drizzle-orm'
import {GraphQLError} from 'graphql'
import {products, productVariants} from 'src/drizzle-orm/schema/schema'
import z from 'zod'
import {builder} from '../builder'
const Product = builder.objectRef<typeof products.$inferSelect>('Product')
const ProductVariant =
builder.objectRef<typeof productVariants.$inferSelect>('ProductVariant')
builder.objectType(ProductVariant, {
name: 'ProductVariant',
fields: t => ({
id: t.exposeInt('id'),
productId: t.exposeInt('productId'),
sku: t.exposeString('sku'),
variationName: t.exposeString('variationName'),
price: t.float({
resolve: variant => Number(variant.price),
}),
stock: t.exposeInt('stock'),
views: t.exposeInt('views'),
createdAt: t.string({
resolve: variant => variant.createdAt.toISOString(),
}),
}),
})
builder.objectType(Product, {
name: 'Product',
fields: t => ({
id: t.exposeInt('id'),
name: t.exposeString('name'),
description: t.exposeString('description'),
productVariants: t.field({
type: [ProductVariant],
resolve: async (product, _args, ctx) => {
return ctx.db
.select()
.from(productVariants)
.where(eq(productVariants.productId, product.id))
},
}),
createdAt: t.string({
resolve: user => user.createdAt.toISOString(),
}),
}),
})
const VariantInput = builder.inputType('VariantInput', {
fields: t => ({
sku: t.string({required: true}),
variationName: t.string({required: true}),
price: t.float({required: true}),
stock: t.int({required: true}),
views: t.int(),
}),
})
const variantZodSchema = z.object({
sku: z.string().trim().min(1).max(100),
variationName: z.string().trim().min(1).max(100),
price: z
.number()
.positive({message: 'Price must be greater than 0'})
.refine(
value =>
Number.isInteger(value) || value.toString().split('.')[1]?.length <= 2,
{message: 'Price can have a maximum of 2 decimal places (e.g., 10.50)'}
),
stock: z.number().int().min(0, {message: 'Min stock is 0'}),
views: z.number().int().nonnegative().optional().default(0),
})
builder.queryFields(t => ({
products: t.connection({
type: Product,
resolve: (_root, args, ctx) =>
resolveCursorConnection(
{
args,
toCursor: product => String(product.id),
},
({before, after, limit, inverted}: ResolveCursorConnectionArgs) => {
const filters: any[] = []
if (before) {
filters.push(lt(products.id, Number(before)))
}
if (after) {
filters.push(gt(products.id, Number(after)))
}
return ctx.db
.select()
.from(products)
.where(filters.length > 0 ? and(...filters) : undefined)
.orderBy(inverted ? desc(products.id) : asc(products.id))
.limit(limit)
}
),
}),
}))
builder.mutationFields(t => ({
createProduct: t.field({
type: Product,
args: {
name: t.arg.string({
required: true,
validate: {schema: z.string().min(1).max(255)},
}),
description: t.arg.string({
validate: {schema: z.string()},
}),
variants: t.arg({
type: [VariantInput],
required: true,
validate: {
schema: z
.array(variantZodSchema)
.min(1, {message: 'Must have at least 1 variant'}),
},
}),
},
resolve: async (_root, args, ctx) => {
try {
return await ctx.db.transaction(async tx => {
const [newProduct] = await tx
.insert(products)
.values({
name: args.name,
description: args.description ?? null,
})
.returning()
const variantsToInsert = args.variants.map(variant => ({
...variant,
productId: newProduct.id,
price: String(variant.price),
}))
await tx.insert(productVariants).values(variantsToInsert)
return newProduct
})
} catch (error: any) {
const dbError = error?.cause || {}
if (
dbError?.code === '23505' &&
dbError?.constraint === 'product_variants_sku_unique'
) {
throw new GraphQLError(
'One or more SKUs already exist in the system',
{
extensions: {
code: 'BAD_USER_INPUT',
detail: dbError.detail,
},
}
)
}
}
},
}),
}))
- As you can see, in
Product,ProductVariantis defined so that when querying, it fetches complete product data - Query for product uses
resolveCursorConnectionto handle cursor pagination simply - Mutation createProduct
- Also uses
zodto validate the payload - When INSERTING
product, it also INSERTS correspondingproductVariantswithin a transaction - I also caught and threw an error in case of a duplicate
SKU
- Also uses
Create file src/controller/graphql.controller.ts with graphql-yoga, this is just a normal controller, so you can implement a project containing both GraphQL and RESTful if desired
import {All, Controller, Inject, Req, Res} from '@nestjs/common'
import type {Request, Response} from 'express'
import {createYoga, type YogaServerInstance} from 'graphql-yoga'
import {DRIZZLE_DB} from 'src/constant'
import type {DrizzleDB} from 'src/drizzle-orm'
import {executableSchema} from 'src/pothos-graphql/schema'
@Controller('graphql')
export class GraphQLController {
private yoga: YogaServerInstance<
{},
{
db: DrizzleDB
}
>
constructor(@Inject(DRIZZLE_DB) private readonly db: DrizzleDB) {
this.yoga = createYoga({
schema: executableSchema,
context: () => ({
db: this.db,
}),
})
}
@All()
async handleRequest(@Req() req: Request, @Res() res: Response) {
return this.yoga(req, res)
}
}
Modify file src/app.module.ts
import {Module} from '@nestjs/common'
import {ConfigModule} from '@nestjs/config'
import {GraphQLController} from './controller/graphql.controller'
import {drizzleProvider} from './drizzle-orm'
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
],
controllers: [
GraphQLController,
],
providers: [
drizzleProvider,
],
})
export class AppModule {}
Query and mutation results like this
Standard user list fetches all data
Using Product list with cursor pagination
Check data validation as follows
Happy coding!
Comments
Post a Comment