Drizzle ORM v1完全ガイド - TypeScript型安全とパフォーマンスを両立する次世代ORM
Drizzle ORMは、TypeScriptの型安全性とSQLのパフォーマンスを両立させた次世代のORMです。v1リリースにより、本番環境での使用に完全対応し、エンタープライズグレードのアプリケーション開発が可能になりました。
この記事では、Drizzle ORM v1の全機能を実践的に解説し、効率的なデータベース設計とクエリ最適化の手法を紹介します。
Drizzle ORM v1の特徴
なぜDrizzle ORMなのか
// Prismaとの比較: より直接的なSQL制御
// Prisma
const users = await prisma.user.findMany({
where: { age: { gte: 18 } },
include: { posts: true }
});
// Drizzle - SQLライクな記述で型安全
import { eq, gte } from 'drizzle-orm';
const users = await db.select()
.from(usersTable)
.where(gte(usersTable.age, 18))
.leftJoin(postsTable, eq(usersTable.id, postsTable.userId));
主な利点:
- ゼロオーバーヘッド: 生SQLと同等のパフォーマンス
- 完全な型推論: スキーマからクエリ結果まで自動型付け
- SQL互換性: 既存のSQLスキーマとの統合が容易
- 軽量: バンドルサイズがPrismaの1/10以下
- エッジランタイム対応: Cloudflare Workers、Vercel Edge対応
スキーマ定義の完全ガイド
基本的なテーブル定義
// schema.ts
import { pgTable, serial, text, timestamp, integer, boolean } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
age: integer('age'),
isActive: boolean('is_active').default(true),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content'),
authorId: integer('author_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
publishedAt: timestamp('published_at'),
createdAt: timestamp('created_at').defaultNow(),
});
高度なスキーマパターン
import { pgTable, varchar, jsonb, index, uniqueIndex, check } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const products = pgTable('products', {
id: serial('id').primaryKey(),
slug: varchar('slug', { length: 255 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
price: integer('price').notNull(),
metadata: jsonb('metadata').$type<{
tags: string[];
features: Record<string, any>;
}>(),
// 計算カラム
discountedPrice: integer('discounted_price')
.generatedAlwaysAs(sql`price * 0.9`),
}, (table) => ({
// インデックス定義
slugIdx: uniqueIndex('slug_idx').on(table.slug),
priceIdx: index('price_idx').on(table.price),
// CHECK制約
priceCheck: check('price_check', sql`${table.price} >= 0`),
}));
// Enum型の定義
import { pgEnum } from 'drizzle-orm/pg-core';
export const roleEnum = pgEnum('role', ['admin', 'user', 'guest']);
export const userRoles = pgTable('user_roles', {
id: serial('id').primaryKey(),
userId: integer('user_id').references(() => users.id),
role: roleEnum('role').notNull(),
});
複合キーとマルチテナント設計
import { primaryKey } from 'drizzle-orm/pg-core';
export const tenants = pgTable('tenants', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
});
export const tenantUsers = pgTable('tenant_users', {
tenantId: integer('tenant_id')
.notNull()
.references(() => tenants.id),
userId: integer('user_id')
.notNull()
.references(() => users.id),
role: text('role').notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.tenantId, table.userId] }),
}));
マイグレーション戦略
自動マイグレーション生成
// drizzle.config.ts
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './drizzle/migrations',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
} satisfies Config;
# マイグレーション生成
pnpm drizzle-kit generate:pg
# マイグレーション実行
pnpm drizzle-kit push:pg
# スキーマ検証
pnpm drizzle-kit check:pg
プログラマティックマイグレーション
// migrate.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
const runMigration = async () => {
const migrationClient = postgres(process.env.DATABASE_URL!, { max: 1 });
const db = drizzle(migrationClient);
await migrate(db, { migrationsFolder: './drizzle/migrations' });
await migrationClient.end();
console.log('Migration completed');
};
runMigration();
カスタムマイグレーションスクリプト
-- drizzle/migrations/0001_add_full_text_search.sql
-- ⬆️ Drizzle生成のマイグレーションに手動でSQLを追加可能
-- 全文検索用のGINインデックス追加
CREATE INDEX posts_content_gin_idx ON posts USING GIN(to_tsvector('english', content));
-- トリガー追加
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER posts_updated_at
BEFORE UPDATE ON posts
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
リレーションと JOIN クエリ
リレーション定義
// schema.ts
import { relations } from 'drizzle-orm';
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
comments: many(comments),
profile: one(profiles, {
fields: [users.id],
references: [profiles.userId],
}),
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
comments: many(comments),
tags: many(postTags),
}));
export const commentsRelations = relations(comments, ({ one }) => ({
post: one(posts, {
fields: [comments.postId],
references: [posts.id],
}),
author: one(users, {
fields: [comments.authorId],
references: [users.id],
}),
}));
リレーショナルクエリ(Query API)
// リレーションを使った簡潔なクエリ
const usersWithPosts = await db.query.users.findMany({
with: {
posts: {
with: {
comments: {
with: {
author: true,
},
},
},
},
},
});
// 型推論が完璧に効く
usersWithPosts[0].posts[0].comments[0].author.name; // ✅ 型安全
// 条件付きリレーション読み込み
const activeUsers = await db.query.users.findMany({
where: (users, { eq }) => eq(users.isActive, true),
with: {
posts: {
where: (posts, { isNotNull }) => isNotNull(posts.publishedAt),
limit: 5,
orderBy: (posts, { desc }) => desc(posts.publishedAt),
},
},
});
手動JOIN(Core API)
import { eq, and, desc } from 'drizzle-orm';
// 複雑なJOINクエリ
const postsWithAuthors = await db
.select({
postId: posts.id,
postTitle: posts.title,
authorName: users.name,
authorEmail: users.email,
commentCount: sql<number>`count(${comments.id})`,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.leftJoin(comments, eq(comments.postId, posts.id))
.where(and(
eq(users.isActive, true),
isNotNull(posts.publishedAt)
))
.groupBy(posts.id, users.id)
.orderBy(desc(posts.createdAt))
.limit(10);
// サブクエリ
const avgPostCount = db
.select({ count: sql<number>`count(*)` })
.from(posts)
.where(eq(posts.authorId, users.id))
.as('avg_post_count');
const prolificAuthors = await db
.select({
name: users.name,
postCount: avgPostCount.count,
})
.from(users)
.leftJoin(avgPostCount, eq(users.id, avgPostCount.authorId))
.where(gt(avgPostCount.count, 10));
クエリビルダーの完全活用
CRUD操作
// INSERT
const newUser = await db.insert(users)
.values({
email: 'user@example.com',
name: 'John Doe',
age: 25,
})
.returning(); // PostgreSQLのみ
// 複数行INSERT
await db.insert(users)
.values([
{ email: 'user1@example.com', name: 'User 1' },
{ email: 'user2@example.com', name: 'User 2' },
]);
// UPSERT (ON CONFLICT)
await db.insert(users)
.values({ email: 'user@example.com', name: 'John' })
.onConflictDoUpdate({
target: users.email,
set: { name: 'John Updated', updatedAt: new Date() },
});
// UPDATE
await db.update(users)
.set({ isActive: false })
.where(eq(users.id, 1));
// 条件付きUPDATE
import { gt } from 'drizzle-orm';
await db.update(users)
.set({ role: 'premium' })
.where(gt(users.postCount, 100));
// DELETE
await db.delete(users)
.where(eq(users.id, 1));
高度なクエリパターン
// トランザクション
await db.transaction(async (tx) => {
const user = await tx.insert(users)
.values({ email: 'new@example.com', name: 'New User' })
.returning();
await tx.insert(posts)
.values({
title: 'First Post',
authorId: user[0].id,
});
// エラー時は自動ロールバック
});
// ページネーション
const page = 2;
const pageSize = 20;
const paginatedPosts = await db.select()
.from(posts)
.orderBy(desc(posts.createdAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
// カーソルベースページネーション
const lastSeenId = 100;
const nextPage = await db.select()
.from(posts)
.where(lt(posts.id, lastSeenId))
.orderBy(desc(posts.id))
.limit(20);
// 集計クエリ
const stats = await db.select({
totalUsers: sql<number>`count(distinct ${users.id})`,
totalPosts: sql<number>`count(distinct ${posts.id})`,
avgPostsPerUser: sql<number>`count(${posts.id})::float / count(distinct ${users.id})`,
})
.from(users)
.leftJoin(posts, eq(posts.authorId, users.id));
Prepared Statements(パフォーマンス最適化)
// プリペアドステートメント作成
const prepared = db.select()
.from(users)
.where(eq(users.id, placeholder('id')))
.prepare('get_user_by_id');
// 高速実行(パース・プラン再利用)
const user1 = await prepared.execute({ id: 1 });
const user2 = await prepared.execute({ id: 2 });
// 複雑なクエリもプリペア可能
const searchPosts = db.select()
.from(posts)
.where(
and(
like(posts.title, placeholder('query')),
gte(posts.createdAt, placeholder('startDate'))
)
)
.prepare('search_posts');
await searchPosts.execute({
query: '%drizzle%',
startDate: new Date('2025-01-01')
});
データベース別の最適化
PostgreSQL固有機能
import { jsonb, vector } from 'drizzle-orm/pg-core';
export const documents = pgTable('documents', {
id: serial('id').primaryKey(),
content: text('content'),
// JSONB型
metadata: jsonb('metadata').$type<{
tags: string[];
views: number;
}>(),
// ベクトル型(pgvector拡張)
embedding: vector('embedding', { dimensions: 1536 }),
});
// JSONB操作
import { jsonb } from 'drizzle-orm/pg-core';
const docsWithTag = await db.select()
.from(documents)
.where(sql`${documents.metadata}->>'tags' ? 'typescript'`);
// ベクトル類似検索
const similar = await db.select()
.from(documents)
.orderBy(sql`${documents.embedding} <-> ${searchVector}`)
.limit(10);
MySQL固有機能
import { mysqlTable, int, varchar } from 'drizzle-orm/mysql-core';
export const products = mysqlTable('products', {
id: int('id').primaryKey().autoincrement(),
name: varchar('name', { length: 255 }),
// 全文検索
description: text('description'),
}, (table) => ({
fullTextIdx: index('ft_idx').on(table.description).using('FULLTEXT'),
}));
// 全文検索クエリ
const searchResults = await db.select()
.from(products)
.where(sql`MATCH(${products.description}) AGAINST('search term' IN NATURAL LANGUAGE MODE)`);
Drizzle StudioとDX向上
# Drizzle Studio起動(GUIデータベースブラウザ)
pnpm drizzle-kit studio
# ブラウザで https://local.drizzle.studio にアクセス
Drizzle Studioの機能:
- リアルタイムデータブラウジング
- CRUD操作のGUIインターフェース
- リレーション視覚化
- スキーマ図生成
- クエリエディタ
実践パターン: Repository層の実装
// repositories/user.repository.ts
import { eq, and, or, like, desc } from 'drizzle-orm';
import type { DB } from '../db';
import { users } from '../db/schema';
export class UserRepository {
constructor(private db: DB) {}
async findById(id: number) {
return this.db.query.users.findFirst({
where: (users, { eq }) => eq(users.id, id),
with: {
posts: { limit: 5, orderBy: (posts, { desc }) => desc(posts.createdAt) },
},
});
}
async search(query: string, filters: { isActive?: boolean } = {}) {
const conditions = [
or(
like(users.name, `%${query}%`),
like(users.email, `%${query}%`)
),
];
if (filters.isActive !== undefined) {
conditions.push(eq(users.isActive, filters.isActive));
}
return this.db.select()
.from(users)
.where(and(...conditions))
.orderBy(desc(users.createdAt));
}
async create(data: { email: string; name: string; age?: number }) {
const result = await this.db.insert(users)
.values(data)
.returning();
return result[0];
}
async update(id: number, data: Partial<typeof users.$inferInsert>) {
await this.db.update(users)
.set({ ...data, updatedAt: new Date() })
.where(eq(users.id, id));
}
}
パフォーマンスベストプラクティス
N+1問題の回避
// ❌ N+1問題(各postごとにクエリ発行)
const posts = await db.select().from(postsTable);
for (const post of posts) {
const author = await db.select()
.from(usersTable)
.where(eq(usersTable.id, post.authorId));
}
// ✅ 1クエリで解決
const postsWithAuthors = await db.query.posts.findMany({
with: { author: true },
});
// または手動JOIN
const postsWithAuthors = await db.select()
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id));
インデックス活用
// スキーマにインデックス定義
export const posts = pgTable('posts', {
// ...
}, (table) => ({
authorIdx: index('author_idx').on(table.authorId),
publishedIdx: index('published_idx').on(table.publishedAt),
// 複合インデックス
authorPublishedIdx: index('author_published_idx')
.on(table.authorId, table.publishedAt),
}));
// クエリがインデックスを活用
const recentPosts = await db.select()
.from(posts)
.where(and(
eq(posts.authorId, 1),
gte(posts.publishedAt, new Date('2025-01-01'))
));
コネクションプーリング
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
// 本番環境
const client = postgres(process.env.DATABASE_URL!, {
max: 10, // 最大接続数
idle_timeout: 20,
connect_timeout: 10,
});
// サーバーレス環境(Vercel等)
import { Pool } from '@vercel/postgres';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);
まとめ
Drizzle ORM v1は以下のシナリオで特に優れています:
最適なユースケース:
- TypeScript型安全性を最大限活用したい
- SQLの完全な制御が必要
- エッジランタイム(Cloudflare Workers等)で動作させたい
- 軽量なバンドルサイズが重要
- 既存のSQLスキーマと統合したい
開発のポイント:
- スキーマはTypeScriptで定義し、マイグレーションは自動生成
- Query APIとCore APIを使い分ける
- Prepared Statementsで頻繁なクエリを高速化
- Drizzle Studioで開発体験を向上
- リレーション定義でN+1問題を回避
Drizzle ORMのエコシステムは急速に成長しており、v1リリースにより本番環境での使用が推奨されるレベルに到達しました。SQLを理解している開発者にとって、最も生産的なORMの選択肢となるでしょう。