Skip to content

ORMs and Query Builders

ORMs trade control for speed - and that's fine until the abstraction leaks When your ORM generates 15 JOINs for a simple findById , you'll wish you wrote raw SQL But for 90% of CRUD apps , the abstraction pays for itself in development speed

Prisma

Prisma is the modern king of Node ORMs - auto-generated client , type-safe queries , declarative schema

// schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  role      Role     @default(USER)
  posts     Post[]
  createdAt DateTime @default(now())
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
}

enum Role {
  USER
  ADMIN
}
// Generated Prisma Client
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()

// Create with relation
const user = await prisma.user.create({
  data: {
    email: 'test@example.com',
    name: 'Mahmoud',
    posts: {
      create: { title: 'My first post' }
    }
  },
  include: { posts: true }
})

// Read with filtering and pagination
const users = await prisma.user.findMany({
  where: {
    role: 'ADMIN',
    email: { contains: '@example.com' }
  },
  include: { posts: true },
  skip: 0,
  take: 10,
  orderBy: { createdAt: 'desc' }
})

// Update
await prisma.user.update({
  where: { id: 1 },
  data: { name: 'Ali' }
})

// Delete with cascade
// Defined in schema - Prisma handles cascade automatically
await prisma.user.delete({ where: { id: 1 } })

Prisma queries are fully type-safe when using TypeScript Your editor catches typos in field names before they hit the database - match that with raw SQL

Prisma Migrations

# Create a migration from schema changes
npx prisma migrate dev --name add_user_role

# Apply to production
npx prisma migrate deploy

# Generate client after schema changes
npx prisma generate

# Reset database (dev only)
npx prisma migrate reset

Prisma generates SQL migration files you can review before applying Always review generated SQL - Prisma sometimes adds unnecessary DROP statements

Sequelize

Sequelize is the old guard - bulky but battle-tested

const { Sequelize, DataTypes } = require('sequelize')
const sequelize = new Sequelize(process.env.DATABASE_URL)

const User = sequelize.define('User', {
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  email: {
    type: DataTypes.STRING,
    allowNull: false,
    unique: true,
    validate: { isEmail: true }
  },
  name: DataTypes.STRING,
  role: {
    type: DataTypes.ENUM('user', 'admin'),
    defaultValue: 'user'
  }
})

const Post = sequelize.define('Post', {
  title: { type: DataTypes.STRING, allowNull: false },
  content: DataTypes.TEXT,
  published: { type: DataTypes.BOOLEAN, defaultValue: false }
})

// Associations
User.hasMany(Post, { foreignKey: 'authorId' })
Post.belongsTo(User, { foreignKey: 'authorId' })

// Sync (dev only - use migrations in prod)
await sequelize.sync()

// Query
const users = await User.findAll({
  where: { role: 'admin' },
  include: [Post],
  order: [['createdAt', 'DESC']]
})

Sequelize sync() drops tables if you pass force: true One accidental deploy with that flag and your production data is gone - use migrations

Knex - Query Builder

Knex isn't an ORM - it's a query builder with a migration system No model classes , no magic - just SQL with a nicer syntax

const knex = require('knex')({
  client: 'pg',
  connection: process.env.DATABASE_URL,
  pool: { min: 2, max: 10 }
})

// SELECT
const users = await knex('users')
  .join('posts', 'users.id', 'posts.author_id')
  .where('users.role', 'admin')
  .select('users.*', 'posts.title')
  .limit(10)

// INSERT
const [id] = await knex('users').insert({
  email: 'test@example.com',
  name: 'Mahmoud'
}).returning('id')

// UPDATE
await knex('users')
  .where({ id: 1 })
  .update({ name: 'Ali' })

// Raw SQL when you need it
const results = await knex.raw(`
  SELECT * FROM users
  WHERE email = ?
  AND role = ?
`, [email, role])

Knex gives you full control over SQL without writing strings If you find yourself fighting an ORM , switch to Knex - it's the middle ground between raw SQL and full abstraction

Drizzle ORM

Drizzle is the TypeScript-native ORM taking over from Prisma

import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'

const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name'),
  createdAt: timestamp('created_at').defaultNow()
})

const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const db = drizzle(pool)

// Type-safe queries
const result = await db.select()
  .from(users)
  .where(eq(users.email, 'test@example.com'))

const inserted = await db.insert(users)
  .values({ email: 'test@example.com', name: 'Mahmoud' })
  .returning()

await db.update(users)
  .set({ name: 'Ali' })
  .where(eq(users.id, 1))

Drizzle is SQL-like in TypeScript - no hidden query generation What you write is what the database executes , no magic , no surprises

N+1 Query Problem

The classic ORM footgun - fetching one record triggers N additional queries

// BAD - N+1 queries
const posts = await Post.findAll()  // 1 query
for (const post of posts) {
  const author = await User.findByPk(post.authorId)  // N queries
}

// GOOD - eager loading (Sequelize)
const posts = await Post.findAll({
  include: [User]  // 1 query with JOIN
})

// GOOD - eager loading (Prisma)
const posts = await prisma.post.findMany({
  include: { author: true }  // 1 query or optimized batch
})

// GOOD - batch loading (raw)
const posts = await knex('posts').select()
const authorIds = [...new Set(posts(p => p.authorId))]
const authors = await knex('users').whereIn('id', authorIds)

N+1 queries kill API performance Always use include , JOIN , or batch loading - your database handles one big query better than 100 small ones

Migration Management

// Prisma migration
npx prisma migrate dev --name add_user_role

// Knex migration
npx knex migrate:make add_user_role

// Sequelize migration
npx sequelize migration:generate --name add-user-role

// Generated Knex migration file
exports.up = function(knex) {
  return knex.schema.table('users', (table) => {
    table.enu('role', ['user', 'admin']).defaultTo('user')
  })
}

exports.down = function(knex) {
  return knex.schema.table('users', (table) => {
    table.dropColumn('role')
  })
}

Always test down migrations - they're useless if they fail when you need to rollback

Prerequisites

  • db_03_nosql.md - understand different DB paradigms before abstracting