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, Mutation and GraphQL Type using strongly typed TypeScript code, automatically catching errors to ensure 100% Type Safety.
  • Pothos has a built-in plugin to connect with Drizzle (@pothos/plugin-drizzle). You only need to declare tables in Drizzle corresponding to Objects in GraphQL and Pothos will automatically understand relations, pagination, helping you avoid writing complex SELECT/JOIN statements.

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-drizzle and @pothos/plugin-zod
  • The builder.queryType({}) section allows defining query, while builder.mutationType({}) allows defining mutation

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 CRUD for user
  • In createUser and updateUser, 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, ProductVariant is defined so that when querying, it fetches complete product data
  • Query for product uses resolveCursorConnection to handle cursor pagination simply
  • Mutation createProduct
    • Also uses zod to validate the payload
    • When INSERTING product, it also INSERTS corresponding productVariants within a transaction
    • I also caught and threw an error in case of a duplicate SKU

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 thisalt text

Standard user list fetches all dataalt text

Using Product list with cursor paginationalt text

User detailalt text

Create useralt text

Check data validation as followsalt textalt text

Update useralt textalt text

Delete useralt text

Create productalt textalt textalt text

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Understanding React Server Component

Kubernetes Deployment for Zero Downtime

Sitemap

Deploying a NodeJS Server on Google Kubernetes Engine

React Practice Series

Docker Practice Series

Setting up Kubernetes Dashboard with Kind

Helm for beginer - Deploy nginx to Google Kubernetes Engine

DevOps Practice Series