Drizzle ORM高度な使い方ガイド - リレーション、トランザクション、パフォーマンス最適化
Drizzle ORM高度な使い方ガイド
Drizzle ORMは、TypeScriptのための型安全なORMライブラリです。この記事では、基本的な使い方を超えて、複雑なリレーション、トランザクション管理、パフォーマンス最適化など、実践的な高度なテクニックを詳しく解説します。
複雑なリレーションの設計
One-to-Many(一対多)
// schema.ts
import { pgTable, serial, text, integer, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content'),
authorId: integer('author_id').notNull().references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// リレーション定義
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
Many-to-Many(多対多)
export const categories = pgTable('categories', {
id: serial('id').primaryKey(),
name: text('name').notNull().unique(),
slug: text('slug').notNull().unique(),
});
// 中間テーブル
export const postCategories = pgTable('post_categories', {
postId: integer('post_id').notNull().references(() => posts.id),
categoryId: integer('category_id').notNull().references(() => categories.id),
});
export const categoriesRelations = relations(categories, ({ many }) => ({
postCategories: many(postCategories),
}));
export const postCategoriesRelations = relations(postCategories, ({ one }) => ({
post: one(posts, {
fields: [postCategories.postId],
references: [posts.id],
}),
category: one(categories, {
fields: [postCategories.categoryId],
references: [categories.id],
}),
}));
export const postsWithCategoriesRelations = relations(posts, ({ many }) => ({
postCategories: many(postCategories),
}));
Self-Referencing(自己参照)
export const comments = pgTable('comments', {
id: serial('id').primaryKey(),
content: text('content').notNull(),
postId: integer('post_id').notNull().references(() => posts.id),
parentId: integer('parent_id'),
authorId: integer('author_id').notNull().references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const commentsRelations = relations(comments, ({ one, many }) => ({
post: one(posts, {
fields: [comments.postId],
references: [posts.id],
}),
author: one(users, {
fields: [comments.authorId],
references: [users.id],
}),
parent: one(comments, {
fields: [comments.parentId],
references: [comments.id],
}),
replies: many(comments),
}));
高度なクエリテクニック
ネストしたリレーションのクエリ
import { db } from './db';
import { users, posts, comments } from './schema';
// ユーザーと投稿、コメントを一度に取得
const usersWithPostsAndComments = await db.query.users.findMany({
with: {
posts: {
with: {
postCategories: {
with: {
category: true,
},
},
},
},
},
});
// 特定の投稿とすべてのネストしたコメント
const postWithNestedComments = await db.query.posts.findFirst({
where: (posts, { eq }) => eq(posts.id, 1),
with: {
author: true,
postCategories: {
with: {
category: true,
},
},
},
});
集計とグループ化
import { count, avg, sum, min, max } from 'drizzle-orm';
import { sql } from 'drizzle-orm';
// 投稿数をカウント
const postCounts = await db
.select({
authorId: posts.authorId,
postCount: count(posts.id),
})
.from(posts)
.groupBy(posts.authorId);
// 複雑な集計
const userStats = await db
.select({
userId: users.id,
userName: users.name,
totalPosts: count(posts.id),
totalComments: count(comments.id),
})
.from(users)
.leftJoin(posts, eq(users.id, posts.authorId))
.leftJoin(comments, eq(users.id, comments.authorId))
.groupBy(users.id, users.name);
// SQLテンプレート使用
const avgPostLength = await db
.select({
avgLength: sql<number>`AVG(LENGTH(${posts.content}))`,
})
.from(posts);
サブクエリ
import { eq, inArray } from 'drizzle-orm';
// サブクエリで活発なユーザーを取得
const activeUsers = db
.select({ id: users.id })
.from(users)
.leftJoin(posts, eq(users.id, posts.authorId))
.groupBy(users.id)
.having(sql`COUNT(${posts.id}) > 10`);
// メインクエリでサブクエリを使用
const popularPosts = await db
.select()
.from(posts)
.where(inArray(posts.authorId, activeUsers));
// 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({
id: posts.id,
title: posts.title,
rank: sql<number>`ROW_NUMBER() OVER (
PARTITION BY ${posts.authorId}
ORDER BY ${posts.createdAt} DESC
)`,
})
.from(posts);
// 累積カウント
const cumulativePosts = await db
.select({
date: sql<Date>`DATE(${posts.createdAt})`,
dailyCount: count(posts.id),
cumulativeCount: sql<number>`SUM(COUNT(*)) OVER (
ORDER BY DATE(${posts.createdAt})
)`,
})
.from(posts)
.groupBy(sql`DATE(${posts.createdAt})`);
トランザクション管理
基本的なトランザクション
import { db } from './db';
// トランザクション実行
await db.transaction(async (tx) => {
// ユーザー作成
const [user] = await tx
.insert(users)
.values({
name: 'John Doe',
email: 'john@example.com',
})
.returning();
// 投稿作成
await tx.insert(posts).values({
title: 'First Post',
content: 'Hello, World!',
authorId: user.id,
});
});
ネストしたトランザクション
async function createUserWithProfile(
userData: any,
profileData: any
) {
return await db.transaction(async (tx) => {
// ユーザー作成
const [user] = await tx
.insert(users)
.values(userData)
.returning();
// プロフィール作成(別のトランザクション)
await tx.transaction(async (innerTx) => {
await innerTx
.insert(profiles)
.values({
...profileData,
userId: user.id,
});
});
return user;
});
}
エラーハンドリング
import { DatabaseError } from 'pg';
async function safeCreatePost(postData: any) {
try {
return await db.transaction(async (tx) => {
const [post] = await tx
.insert(posts)
.values(postData)
.returning();
// 何か問題があれば例外を投げる
if (!post.title) {
throw new Error('Title is required');
}
return post;
});
} catch (error) {
if (error instanceof DatabaseError) {
if (error.code === '23505') {
// Unique constraint violation
throw new Error('Duplicate entry');
}
}
throw error;
}
}
トランザクション分離レベル
import { sql } from 'drizzle-orm';
// READ COMMITTED(デフォルト)
await db.transaction(async (tx) => {
await tx.execute(
sql`SET TRANSACTION ISOLATION LEVEL READ COMMITTED`
);
// トランザクション処理
});
// SERIALIZABLE(最も厳密)
await db.transaction(async (tx) => {
await tx.execute(
sql`SET TRANSACTION ISOLATION LEVEL SERIALIZABLE`
);
const [user] = await tx
.select()
.from(users)
.where(eq(users.id, 1))
.for('update'); // 行ロック
await tx
.update(users)
.set({ balance: user.balance - 100 })
.where(eq(users.id, 1));
});
パフォーマンス最適化
インデックスの作成
import { index, uniqueIndex } from 'drizzle-orm/pg-core';
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content'),
authorId: integer('author_id').notNull(),
published: boolean('published').default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => ({
authorIdx: index('author_idx').on(table.authorId),
publishedIdx: index('published_idx').on(table.published),
createdAtIdx: index('created_at_idx').on(table.createdAt),
// 複合インデックス
authorPublishedIdx: index('author_published_idx')
.on(table.authorId, table.published),
// ユニークインデックス
slugIdx: uniqueIndex('slug_idx').on(table.slug),
}));
バッチ処理
// バッチインサート
const newUsers = [
{ name: 'User 1', email: 'user1@example.com' },
{ name: 'User 2', email: 'user2@example.com' },
{ name: 'User 3', email: 'user3@example.com' },
];
await db.insert(users).values(newUsers);
// バッチ更新
await db
.update(posts)
.set({ published: true })
.where(inArray(posts.id, [1, 2, 3, 4, 5]));
// 大量データのバッチ処理
async function batchInsert<T>(
data: T[],
batchSize: number = 1000
) {
for (let i = 0; i < data.length; i += batchSize) {
const batch = data.slice(i, i + batchSize);
await db.insert(users).values(batch);
}
}
クエリ最適化
// ❌ N+1問題
const usersData = await db.select().from(users);
for (const user of usersData) {
const userPosts = await db
.select()
.from(posts)
.where(eq(posts.authorId, user.id));
}
// ✅ 1回のクエリで取得
const usersWithPosts = await db.query.users.findMany({
with: {
posts: true,
},
});
// ✅ JOINを使用
const usersWithPostCount = await db
.select({
userId: users.id,
userName: users.name,
postCount: count(posts.id),
})
.from(users)
.leftJoin(posts, eq(users.id, posts.authorId))
.groupBy(users.id, users.name);
接続プーリング
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
// 接続プール設定
const pool = new Pool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20, // 最大接続数
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
export const db = drizzle(pool);
// アプリケーション終了時にプールをクローズ
process.on('SIGTERM', async () => {
await pool.end();
});
ページネーション
// オフセットベースのページネーション
async function getPosts(page: number = 1, pageSize: number = 20) {
const offset = (page - 1) * pageSize;
const [data, total] = await Promise.all([
db
.select()
.from(posts)
.limit(pageSize)
.offset(offset)
.orderBy(desc(posts.createdAt)),
db
.select({ count: count() })
.from(posts)
.then(([{ count }]) => count),
]);
return {
data,
pagination: {
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
total,
},
};
}
// カーソルベースのページネーション
async function getPostsCursor(
cursor?: number,
limit: number = 20
) {
const conditions = cursor
? lt(posts.id, cursor)
: undefined;
const data = await db
.select()
.from(posts)
.where(conditions)
.limit(limit + 1)
.orderBy(desc(posts.id));
const hasMore = data.length > limit;
const items = hasMore ? data.slice(0, -1) : data;
const nextCursor = hasMore ? items[items.length - 1].id : null;
return {
data: items,
nextCursor,
hasMore,
};
}
マイグレーション戦略
マイグレーションの生成
# スキーマからマイグレーションを生成
npx drizzle-kit generate:pg
# カスタム名でマイグレーション生成
npx drizzle-kit generate:pg --name add_users_table
マイグレーションの実行
// migrate.ts
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { db } from './db';
async function runMigrations() {
console.log('Running migrations...');
await migrate(db, {
migrationsFolder: './drizzle',
});
console.log('Migrations completed!');
process.exit(0);
}
runMigrations().catch((err) => {
console.error('Migration failed!', err);
process.exit(1);
});
ゼロダウンタイムマイグレーション
-- ステップ1: 新しいカラムを追加(NULL許可)
ALTER TABLE users ADD COLUMN full_name TEXT;
-- ステップ2: データを移行(アプリケーション側で実行)
UPDATE users SET full_name = name WHERE full_name IS NULL;
-- ステップ3: NOT NULL制約を追加
ALTER TABLE users ALTER COLUMN full_name SET NOT NULL;
-- ステップ4: 古いカラムを削除
ALTER TABLE users DROP COLUMN name;
マイグレーションのロールバック
// rollback.ts
import { sql } from 'drizzle-orm';
import { db } from './db';
async function rollback() {
// 最新のマイグレーションを取得
const [latest] = await db.execute(
sql`SELECT version FROM migrations ORDER BY version DESC LIMIT 1`
);
if (!latest) {
console.log('No migrations to rollback');
return;
}
// ロールバック実行
console.log(`Rolling back migration: ${latest.version}`);
// マイグレーションテーブルから削除
await db.execute(
sql`DELETE FROM migrations WHERE version = ${latest.version}`
);
console.log('Rollback completed!');
}
rollback().catch((err) => {
console.error('Rollback failed!', err);
process.exit(1);
});
テスト戦略
テスト用データベースのセットアップ
// test/setup.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
export async function setupTestDb() {
const pool = new Pool({
connectionString: process.env.TEST_DATABASE_URL,
});
const db = drizzle(pool);
// マイグレーション実行
await migrate(db, {
migrationsFolder: './drizzle',
});
return { db, pool };
}
export async function teardownTestDb(pool: Pool) {
await pool.end();
}
テストの例
// test/users.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { setupTestDb, teardownTestDb } from './setup';
import { users } from '../schema';
describe('Users', () => {
let db: any;
let pool: any;
beforeAll(async () => {
({ db, pool } = await setupTestDb());
});
afterAll(async () => {
await teardownTestDb(pool);
});
it('should create a user', async () => {
const [user] = await db
.insert(users)
.values({
name: 'Test User',
email: 'test@example.com',
})
.returning();
expect(user).toBeDefined();
expect(user.name).toBe('Test User');
expect(user.email).toBe('test@example.com');
});
it('should find a user by email', async () => {
const user = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.email, 'test@example.com'),
});
expect(user).toBeDefined();
expect(user?.name).toBe('Test User');
});
});
まとめ
Drizzle ORMは、TypeScriptの型安全性を最大限に活用しながら、柔軟で高性能なデータベース操作を可能にします。
主な利点:
- 完全な型安全性
- 高度なリレーション管理
- 強力なクエリビルダー
- トランザクションサポート
- 優れたパフォーマンス
ベストプラクティス:
- 適切なインデックスの使用
- バッチ処理の活用
- N+1問題の回避
- トランザクションの適切な使用
- マイグレーション戦略の計画
Drizzle ORMを使いこなすことで、型安全で保守性の高いデータベース層を構築できます。