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