Drizzle ORM実践ガイド - SQL-likeな型安全ORMの決定版
Drizzle ORM実践ガイド - SQL-likeな型安全ORMの決定版
データベースとのやり取りは、あらゆるアプリケーション開発において避けて通れない重要な要素です。従来、TypeScript環境ではPrismaが主流でしたが、独自のスキーマ言語やマイグレーション方法、パフォーマンスの課題などが指摘されてきました。
Drizzle ORMは、SQLに近い直感的なAPI、完全な型安全性、優れたパフォーマンス、軽量なランタイムを実現した次世代ORMです。SQLの知識を活かしながら、TypeScriptの恩恵を最大限に受けられる設計になっています。
本記事では、Drizzle ORMの導入から実践的な使い方、リレーション管理、マイグレーション戦略、パフォーマンス最適化まで、実際のプロジェクトで使えるノウハウを徹底解説します。
Drizzle ORMの特徴
SQL-likeなAPI設計
Drizzle ORMは、SQLに近い文法を採用しており、SQLの知識があればすぐに使いこなせます。
// Prisma
const users = await prisma.user.findMany({
where: { age: { gte: 18 } },
orderBy: { createdAt: 'desc' },
take: 10,
})
// Drizzle ORM
const users = await db
.select()
.from(usersTable)
.where(gte(usersTable.age, 18))
.orderBy(desc(usersTable.createdAt))
.limit(10)
完全な型安全性
スキーマから自動的に型が推論され、クエリ全体が型安全になります。
import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
age: integer('age').notNull(),
email: text('email').notNull().unique(),
})
// 型推論が効く
const user = await db.select().from(users).where(eq(users.id, 1))
// ^? { id: number; name: string; age: number; email: string }[]
軽量で高速
ランタイムオーバーヘッドが少なく、生成されるSQLも最適化されています。
// バンドルサイズ比較(gzip圧縮後)
// Prisma Client: ~300KB
// TypeORM: ~200KB
// Drizzle ORM: ~30KB
複数データベース対応
PostgreSQL、MySQL、SQLite、その他多数のデータベースをサポートしています。
import { drizzle } from 'drizzle-orm/postgres-js'
// import { drizzle } from 'drizzle-orm/mysql2'
// import { drizzle } from 'drizzle-orm/better-sqlite3'
プロジェクトセットアップ
インストール
# PostgreSQLの場合
npm install drizzle-orm postgres
npm install -D drizzle-kit
# MySQLの場合
npm install drizzle-orm mysql2
npm install -D drizzle-kit
# SQLiteの場合
npm install drizzle-orm better-sqlite3
npm install -D drizzle-kit @types/better-sqlite3
データベース接続
// src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'
// 接続プールの作成
const connectionString = process.env.DATABASE_URL!
const client = postgres(connectionString, {
max: 10, // 最大接続数
idle_timeout: 20, // アイドルタイムアウト(秒)
connect_timeout: 10, // 接続タイムアウト(秒)
})
// Drizzleインスタンス
export const db = drizzle(client, { schema })
Drizzle Kit設定
// drizzle.config.ts
import type { Config } from 'drizzle-kit'
export default {
schema: './src/db/schema.ts',
out: './drizzle',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
} satisfies Config
スキーマ定義
基本的なテーブル定義
// src/db/schema.ts
import { pgTable, serial, text, integer, timestamp, boolean } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
age: integer('age').notNull(),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
// 型を自動生成
export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsert
カラムタイプとオプション
import {
pgTable,
serial,
text,
varchar,
integer,
real,
boolean,
timestamp,
date,
json,
jsonb,
uuid,
pgEnum,
} from 'drizzle-orm/pg-core'
// Enumの定義
export const roleEnum = pgEnum('role', ['admin', 'user', 'guest'])
export const statusEnum = pgEnum('status', ['active', 'inactive', 'suspended'])
export const users = pgTable('users', {
// 主キー
id: uuid('id').primaryKey().defaultRandom(),
// テキスト系
name: text('name').notNull(),
username: varchar('username', { length: 50 }).notNull().unique(),
bio: text('bio'),
// 数値系
age: integer('age').notNull(),
height: real('height'), // float
// 真偽値
isActive: boolean('is_active').notNull().default(true),
isVerified: boolean('is_verified').notNull().default(false),
// 日時
birthDate: date('birth_date'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()),
lastLoginAt: timestamp('last_login_at'),
// Enum
role: roleEnum('role').notNull().default('user'),
status: statusEnum('status').notNull().default('active'),
// JSON
metadata: json('metadata').$type<{ theme: string; language: string }>(),
settings: jsonb('settings').$type<UserSettings>(),
})
interface UserSettings {
notifications: {
email: boolean
push: boolean
}
privacy: {
profileVisible: boolean
showEmail: boolean
}
}
リレーション定義
// src/db/schema.ts
import { relations } from 'drizzle-orm'
import { pgTable, serial, text, integer, timestamp } from 'drizzle-orm/pg-core'
// ユーザーテーブル
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
// 投稿テーブル
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content').notNull(),
authorId: integer('author_id').notNull().references(() => users.id),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
// コメントテーブル
export const comments = pgTable('comments', {
id: serial('id').primaryKey(),
content: text('content').notNull(),
postId: integer('post_id').notNull().references(() => posts.id),
authorId: integer('author_id').notNull().references(() => users.id),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
// リレーション定義
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
comments: many(comments),
}))
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
comments: many(comments),
}))
export const commentsRelations = relations(comments, ({ one }) => ({
post: one(posts, {
fields: [comments.postId],
references: [posts.id],
}),
author: one(users, {
fields: [comments.authorId],
references: [users.id],
}),
}))
インデックスと制約
import { pgTable, serial, text, integer, index, uniqueIndex, check } from 'drizzle-orm/pg-core'
import { sql } from 'drizzle-orm'
export const users = pgTable(
'users',
{
id: serial('id').primaryKey(),
email: text('email').notNull(),
username: text('username').notNull(),
age: integer('age').notNull(),
},
(table) => ({
// 通常のインデックス
emailIdx: index('email_idx').on(table.email),
// ユニークインデックス
usernameIdx: uniqueIndex('username_idx').on(table.username),
// 複合インデックス
emailUsernameIdx: index('email_username_idx').on(table.email, table.username),
// 部分インデックス
activeUsersIdx: index('active_users_idx')
.on(table.email)
.where(sql`${table.age} >= 18`),
// チェック制約
ageCheck: check('age_check', sql`${table.age} >= 0 AND ${table.age} <= 150`),
})
)
クエリ操作
SELECT(読み取り)
import { eq, and, or, gt, gte, lt, lte, like, between, inArray } from 'drizzle-orm'
// 基本的なSELECT
const allUsers = await db.select().from(users)
// 特定のカラムのみ取得
const userNames = await db
.select({
id: users.id,
name: users.name,
})
.from(users)
// WHERE条件
const user = await db
.select()
.from(users)
.where(eq(users.id, 1))
// 複数条件(AND)
const activeAdults = await db
.select()
.from(users)
.where(
and(
eq(users.isActive, true),
gte(users.age, 18)
)
)
// 複数条件(OR)
const youngOrOld = await db
.select()
.from(users)
.where(
or(
lt(users.age, 20),
gt(users.age, 60)
)
)
// LIKE検索
const searchUsers = await db
.select()
.from(users)
.where(like(users.name, '%John%'))
// BETWEEN
const ageRangeUsers = await db
.select()
.from(users)
.where(between(users.age, 20, 30))
// IN句
const specificUsers = await db
.select()
.from(users)
.where(inArray(users.id, [1, 2, 3, 4, 5]))
// ORDER BY
const sortedUsers = await db
.select()
.from(users)
.orderBy(desc(users.createdAt))
// LIMIT & OFFSET
const paginatedUsers = await db
.select()
.from(users)
.limit(10)
.offset(20)
INSERT(作成)
// 単一レコード挿入
const newUser = await db
.insert(users)
.values({
name: 'John Doe',
email: 'john@example.com',
age: 30,
})
.returning()
// 複数レコード挿入
const newUsers = await db
.insert(users)
.values([
{ name: 'Alice', email: 'alice@example.com', age: 25 },
{ name: 'Bob', email: 'bob@example.com', age: 35 },
])
.returning()
// ON CONFLICT(upsert)
const upsertedUser = await db
.insert(users)
.values({
email: 'john@example.com',
name: 'John Updated',
age: 31,
})
.onConflictDoUpdate({
target: users.email,
set: {
name: sql`excluded.name`,
age: sql`excluded.age`,
},
})
.returning()
// ON CONFLICT DO NOTHING
await db
.insert(users)
.values({
email: 'john@example.com',
name: 'John',
age: 30,
})
.onConflictDoNothing()
UPDATE(更新)
// 基本的なUPDATE
const updatedUser = await db
.update(users)
.set({
name: 'John Updated',
age: 31,
})
.where(eq(users.id, 1))
.returning()
// 複数レコード更新
await db
.update(users)
.set({ isActive: false })
.where(lt(users.lastLoginAt, new Date('2024-01-01')))
// 計算による更新
await db
.update(users)
.set({
age: sql`${users.age} + 1`,
})
.where(eq(users.id, 1))
DELETE(削除)
// 基本的なDELETE
const deletedUser = await db
.delete(users)
.where(eq(users.id, 1))
.returning()
// 複数レコード削除
await db
.delete(users)
.where(eq(users.isActive, false))
// 全件削除(危険!)
await db.delete(users)
リレーショナルクエリ
基本的なJOIN
// INNER JOIN
const postsWithAuthors = await db
.select({
post: posts,
author: users,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
// LEFT JOIN
const allPostsWithAuthors = await db
.select({
post: posts,
author: users,
})
.from(posts)
.leftJoin(users, eq(posts.authorId, users.id))
リレーショナルクエリAPI
リレーション定義を使用すると、より直感的にクエリを書けます。
// ユーザーと投稿を一緒に取得
const usersWithPosts = await db.query.users.findMany({
with: {
posts: true,
},
})
// ネストしたリレーション
const usersWithPostsAndComments = await db.query.users.findMany({
with: {
posts: {
with: {
comments: true,
},
},
},
})
// フィルタリングと制限
const activeUsersWithRecentPosts = await db.query.users.findMany({
where: eq(users.isActive, true),
with: {
posts: {
where: gte(posts.createdAt, new Date('2025-01-01')),
orderBy: [desc(posts.createdAt)],
limit: 5,
},
},
})
// 特定のカラムのみ取得
const usersWithPostTitles = await db.query.users.findMany({
columns: {
id: true,
name: true,
},
with: {
posts: {
columns: {
id: true,
title: true,
},
},
},
})
複雑なリレーショナルクエリ
// 投稿、著者、コメント、コメント著者を全て取得
const postsWithFullDetails = await db.query.posts.findMany({
with: {
author: {
columns: {
id: true,
name: true,
},
},
comments: {
with: {
author: {
columns: {
id: true,
name: true,
},
},
},
orderBy: [desc(comments.createdAt)],
},
},
orderBy: [desc(posts.createdAt)],
limit: 10,
})
// 条件付きリレーション読み込み
const usersWithPublishedPosts = await db.query.users.findMany({
with: {
posts: {
where: eq(posts.status, 'published'),
extras: {
commentCount: sql<number>`(
SELECT COUNT(*)
FROM ${comments}
WHERE ${comments.postId} = ${posts.id}
)`.as('comment_count'),
},
},
},
})
トランザクション
基本的なトランザクション
import { db } from './db'
// トランザクション
await db.transaction(async (tx) => {
// 新規ユーザー作成
const [user] = await tx
.insert(users)
.values({
name: 'John Doe',
email: 'john@example.com',
age: 30,
})
.returning()
// 関連する投稿を作成
await tx.insert(posts).values({
title: 'First Post',
content: 'Hello World',
authorId: user.id,
})
})
ネストしたトランザクション
await db.transaction(async (tx) => {
const [user] = await tx
.insert(users)
.values({ name: 'Alice', email: 'alice@example.com', age: 25 })
.returning()
// ネストしたトランザクション
await tx.transaction(async (tx2) => {
await tx2.insert(posts).values({
title: 'Post 1',
content: 'Content',
authorId: user.id,
})
await tx2.insert(posts).values({
title: 'Post 2',
content: 'Content',
authorId: user.id,
})
})
})
トランザクション分離レベル
import { sql } from 'drizzle-orm'
await db.transaction(
async (tx) => {
// トランザクション処理
},
{
isolationLevel: 'read committed',
// 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable'
}
)
エラーハンドリングとロールバック
try {
await db.transaction(async (tx) => {
await tx.insert(users).values({
name: 'John',
email: 'john@example.com',
age: 30,
})
// エラーが発生すると自動的にロールバック
throw new Error('Something went wrong')
await tx.insert(posts).values({
title: 'Post',
content: 'Content',
authorId: 1,
})
})
} catch (error) {
console.error('Transaction failed:', error)
// トランザクション全体がロールバックされる
}
マイグレーション
マイグレーションファイルの生成
# スキーマからマイグレーションを自動生成
npx drizzle-kit generate:pg
# カスタム名でマイグレーション生成
npx drizzle-kit generate:pg --name add_user_roles
マイグレーションの実行
# マイグレーション実行
npx drizzle-kit push:pg
# または、プログラマティックに実行
// src/db/migrate.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import { migrate } from 'drizzle-orm/postgres-js/migrator'
import postgres from 'postgres'
const sql = postgres(process.env.DATABASE_URL!, { max: 1 })
const db = drizzle(sql)
async function main() {
console.log('Running migrations...')
await migrate(db, { migrationsFolder: './drizzle' })
console.log('Migrations complete!')
process.exit(0)
}
main().catch((err) => {
console.error('Migration failed!')
console.error(err)
process.exit(1)
})
マイグレーション戦略
// package.json
{
"scripts": {
"db:generate": "drizzle-kit generate:pg",
"db:push": "drizzle-kit push:pg",
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio"
}
}
Drizzle Studio
視覚的なデータベース管理ツール。
# Drizzle Studioを起動
npx drizzle-kit studio
# https://local.drizzle.studio でアクセス可能
高度なクエリパターン
サブクエリ
import { sql } from 'drizzle-orm'
// サブクエリを使った集計
const usersWithPostCount = await db
.select({
user: users,
postCount: sql<number>`(
SELECT COUNT(*)
FROM ${posts}
WHERE ${posts.authorId} = ${users.id}
)`,
})
.from(users)
// EXISTS句
const usersWithPosts = await db
.select()
.from(users)
.where(
sql`EXISTS (
SELECT 1
FROM ${posts}
WHERE ${posts.authorId} = ${users.id}
)`
)
ウィンドウ関数
// ランキング
const rankedPosts = await db
.select({
post: posts,
rank: sql<number>`RANK() OVER (
PARTITION BY ${posts.authorId}
ORDER BY ${posts.createdAt} DESC
)`,
})
.from(posts)
// 累積集計
const cumulativeViews = await db
.select({
post: posts,
cumulativeViews: sql<number>`SUM(${posts.views}) OVER (
PARTITION BY ${posts.authorId}
ORDER BY ${posts.createdAt}
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)`,
})
.from(posts)
CTE(Common Table Expressions)
import { sql } from 'drizzle-orm'
const result = await db.execute(sql`
WITH active_users AS (
SELECT * FROM ${users}
WHERE ${users.isActive} = true
),
user_post_counts AS (
SELECT
${users.id},
COUNT(${posts.id}) as post_count
FROM active_users
LEFT JOIN ${posts} ON ${posts.authorId} = ${users.id}
GROUP BY ${users.id}
)
SELECT * FROM user_post_counts
WHERE post_count > 10
`)
集計クエリ
import { count, sum, avg, min, max } from 'drizzle-orm'
// COUNT
const userCount = await db
.select({ count: count() })
.from(users)
// GROUP BY
const postsByAuthor = await db
.select({
authorId: posts.authorId,
postCount: count(posts.id),
totalViews: sum(posts.views),
avgViews: avg(posts.views),
})
.from(posts)
.groupBy(posts.authorId)
// HAVING
const activeAuthors = await db
.select({
authorId: posts.authorId,
postCount: count(posts.id),
})
.from(posts)
.groupBy(posts.authorId)
.having(({ postCount }) => gte(postCount, 10))
パフォーマンス最適化
コネクションプーリング
// src/db/index.ts
import postgres from 'postgres'
import { drizzle } from 'drizzle-orm/postgres-js'
const client = postgres(process.env.DATABASE_URL!, {
max: 10, // 最大接続数
idle_timeout: 20, // アイドルタイムアウト(秒)
connect_timeout: 10, // 接続タイムアウト(秒)
max_lifetime: 60 * 30, // 接続の最大生存時間(秒)
})
export const db = drizzle(client)
プリペアドステートメント
import { sql } from 'drizzle-orm'
// プリペアドステートメント
const statement = db
.select()
.from(users)
.where(eq(users.id, sql.placeholder('id')))
.prepare('get_user_by_id')
// 実行
const user = await statement.execute({ id: 1 })
const user2 = await statement.execute({ id: 2 })
バッチ処理
// バッチインサート(一度に複数レコード挿入)
const batchSize = 1000
const usersToInsert = [...] // 大量のユーザーデータ
for (let i = 0; i < usersToInsert.length; i += batchSize) {
const batch = usersToInsert.slice(i, i + batchSize)
await db.insert(users).values(batch)
}
インデックスの活用
// スキーマにインデックスを追加
export const posts = pgTable(
'posts',
{
id: serial('id').primaryKey(),
authorId: integer('author_id').notNull(),
title: text('title').notNull(),
status: text('status').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
authorIdx: index('author_idx').on(table.authorId),
statusIdx: index('status_idx').on(table.status),
createdAtIdx: index('created_at_idx').on(table.createdAt),
// 複合インデックス
authorStatusIdx: index('author_status_idx').on(table.authorId, table.status),
})
)
クエリの最適化
// N+1問題の回避(リレーショナルクエリを使用)
// ❌ N+1問題
const users = await db.select().from(usersTable)
for (const user of users) {
const posts = await db.select().from(postsTable).where(eq(postsTable.authorId, user.id))
// ...
}
// ✅ 最適化
const usersWithPosts = await db.query.users.findMany({
with: {
posts: true,
},
})
// 必要なカラムのみ取得
const users = await db
.select({
id: usersTable.id,
name: usersTable.name,
})
.from(usersTable)
実践的なパターン
リポジトリパターン
// src/repositories/userRepository.ts
import { db } from '@/db'
import { users, type User, type NewUser } from '@/db/schema'
import { eq } from 'drizzle-orm'
export class UserRepository {
async findById(id: number): Promise<User | undefined> {
const [user] = await db.select().from(users).where(eq(users.id, id))
return user
}
async findByEmail(email: string): Promise<User | undefined> {
const [user] = await db.select().from(users).where(eq(users.email, email))
return user
}
async create(data: NewUser): Promise<User> {
const [user] = await db.insert(users).values(data).returning()
return user
}
async update(id: number, data: Partial<NewUser>): Promise<User | undefined> {
const [user] = await db
.update(users)
.set(data)
.where(eq(users.id, id))
.returning()
return user
}
async delete(id: number): Promise<void> {
await db.delete(users).where(eq(users.id, id))
}
async findAll(limit = 100, offset = 0): Promise<User[]> {
return db.select().from(users).limit(limit).offset(offset)
}
}
export const userRepository = new UserRepository()
ソフトデリート
// スキーマ
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull(),
deletedAt: timestamp('deleted_at'),
})
// リポジトリ
export class UserRepository {
async findActive(): Promise<User[]> {
return db
.select()
.from(users)
.where(isNull(users.deletedAt))
}
async softDelete(id: number): Promise<void> {
await db
.update(users)
.set({ deletedAt: new Date() })
.where(eq(users.id, id))
}
async restore(id: number): Promise<void> {
await db
.update(users)
.set({ deletedAt: null })
.where(eq(users.id, id))
}
}
ページネーション
interface PaginationParams {
page: number
pageSize: number
}
interface PaginatedResult<T> {
data: T[]
pagination: {
page: number
pageSize: number
totalItems: number
totalPages: number
}
}
async function getPaginatedUsers(
params: PaginationParams
): Promise<PaginatedResult<User>> {
const { page, pageSize } = params
const offset = (page - 1) * pageSize
// データ取得
const data = await db
.select()
.from(users)
.limit(pageSize)
.offset(offset)
// 総数取得
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(users)
return {
data,
pagination: {
page,
pageSize,
totalItems: count,
totalPages: Math.ceil(count / pageSize),
},
}
}
まとめ
Drizzle ORMは、SQL-likeなAPI設計と完全な型安全性を備えた次世代ORMとして、以下の特徴を提供します。
- SQL-likeなAPI: SQLの知識を活かせる直感的な文法
- 完全な型安全性: スキーマから自動的に型が推論される
- 軽量で高速: 最小限のランタイムオーバーヘッド
- リレーショナルクエリ: N+1問題を回避する効率的なクエリ
- マイグレーション: 自動生成とバージョン管理
PrismaやTypeORMと比較して、よりSQLに近い操作感、軽量なランタイム、優れたパフォーマンスを実現しています。SQLの知識があるチーム、パフォーマンスを重視するプロジェクト、型安全性を求める開発において、Drizzle ORMは最適な選択肢となるでしょう。