Drizzle ORM実践ガイド - 型安全でSQLライクなORMの活用法

中級 | 45分 で読める | 2025.12.02

公式ドキュメント

今回やること

この記事では、Drizzle ORMのスキーマ定義、クエリ、マイグレーション、Prismaとの違いを確認します。

DrizzleはSQLに近い書き方が強みです。SQLの考え方を残したままTypeScriptの型安全性を足したい場合に向いています。

Drizzle ORMとは

Drizzle ORMは、TypeScriptファーストで設計された軽量なORMです。SQLに近い構文で、型安全なクエリを実現しながら、ゼロランタイムオーバーヘッドを目指しています

flowchart TB
 subgraph App["TypeScript Application"]
 AppCode["アプリケーションコード"]
 end

 subgraph DrizzleORM["Drizzle ORM Layer"]
 Schema["Schema Definition"]
 QueryBuilder["Query Builder<br/>(SQL-like)"]
 Relations["Relations API"]
 end

 subgraph Databases["Databases"]
 PostgreSQL["PostgreSQL"]
 MySQL["MySQL"]
 SQLite["SQLite"]
 end

 App --> DrizzleORM
 Schema & QueryBuilder & Relations --> PostgreSQL & MySQL & SQLite

セットアップ

# PostgreSQLの場合
npm install drizzle-orm postgres
npm install -D drizzle-kit

# MySQLの場合
npm install drizzle-orm mysql2

# SQLiteの場合
npm install drizzle-orm better-sqlite3

設定ファイル

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
 schema: './src/db/schema.ts',
 out: './drizzle',
 dialect: 'postgresql',
 dbCredentials: {
 url: process.env.DATABASE_URL!,
 },
 verbose: true,
 strict: true,
});

実践メモ: drizzle.config.tsでstrict: trueを有効にすると、マイグレーション時に破壊的変更がある場合に警告が出ます。

スキーマ定義

// src/db/schema.ts
import {
 pgTable,
 serial,
 varchar,
 text,
 timestamp,
 integer,
 boolean,
 pgEnum,
 uuid,
 jsonb,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

// Enum定義
export const userRoleEnum = pgEnum('user_role', ['admin', 'user', 'guest']);
export const postStatusEnum = pgEnum('post_status', ['draft', 'published', 'archived']);

// ユーザーテーブル
export const users = pgTable('users', {
 id: uuid('id').primaryKey().defaultRandom(),
 email: varchar('email', { length: 255 }).notNull().unique(),
 name: varchar('name', { length: 100 }).notNull(),
 role: userRoleEnum('role').default('user').notNull(),
 profile: jsonb('profile').$type<{
 bio?: string;
 website?: string;
 social?: Record<string, string>;
 }>(),
 createdAt: timestamp('created_at').defaultNow().notNull(),
 updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

// 投稿テーブル
export const posts = pgTable('posts', {
 id: serial('id').primaryKey(),
 title: varchar('title', { length: 255 }).notNull(),
 content: text('content').notNull(),
 status: postStatusEnum('status').default('draft').notNull(),
 authorId: uuid('author_id')
 .notNull()
 .references(() => users.id, { onDelete: 'cascade' }),
 viewCount: integer('view_count').default(0).notNull(),
 publishedAt: timestamp('published_at'),
 createdAt: timestamp('created_at').defaultNow().notNull(),
});

// タグテーブル
export const tags = pgTable('tags', {
 id: serial('id').primaryKey(),
 name: varchar('name', { length: 50 }).notNull().unique(),
 slug: varchar('slug', { length: 50 }).notNull().unique(),
});

// 投稿とタグの中間テーブル
export const postsToTags = pgTable('posts_to_tags', {
 postId: integer('post_id')
 .notNull()
 .references(() => posts.id, { onDelete: 'cascade' }),
 tagId: integer('tag_id')
 .notNull()
 .references(() => tags.id, { onDelete: 'cascade' }),
});

// コメントテーブル
export const comments = pgTable('comments', {
 id: serial('id').primaryKey(),
 content: text('content').notNull(),
 postId: integer('post_id')
 .notNull()
 .references(() => posts.id, { onDelete: 'cascade' }),
 authorId: uuid('author_id')
 .notNull()
 .references(() => users.id, { onDelete: 'cascade' }),
 parentId: integer('parent_id'), // 自己参照
 createdAt: timestamp('created_at').defaultNow().notNull(),
});

ポイント: pgEnumでEnumを定義し、references()で外部キー制約を設定すると、DB層でデータ整合性を検証できます

リレーション定義

// src/db/relations.ts
import { relations } from 'drizzle-orm';
import { users, posts, comments, tags, postsToTags } from './schema';

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),
 postsToTags: many(postsToTags),
}));

export const tagsRelations = relations(tags, ({ many }) => ({
 postsToTags: many(postsToTags),
}));

export const postsToTagsRelations = relations(postsToTags, ({ one }) => ({
 post: one(posts, {
 fields: [postsToTags.postId],
 references: [posts.id],
 }),
 tag: one(tags, {
 fields: [postsToTags.tagId],
 references: [tags.id],
 }),
}));

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],
 relationName: 'replies',
 }),
 replies: many(comments, { relationName: 'replies' }),
}));

データベース接続

// src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
import * as relations from './relations';

const connectionString = process.env.DATABASE_URL!;

// クエリ用クライアント
const queryClient = postgres(connectionString);

// Drizzleインスタンス
export const db = drizzle(queryClient, {
 schema: { ...schema, ...relations },
 logger: process.env.NODE_ENV === 'development',
});

export type Database = typeof db;

注意: リレーション定義はクエリAPI(db.query)でのみ使用されます。db.select()のJOINには影響しません。

基本的なCRUD操作

Create(挿入)

import { db } from './db';
import { users, posts } from './db/schema';

// 単一レコードの挿入
const newUser = await db
 .insert(users)
 .values({
 email: 'user@example.com',
 name: 'John Doe',
 role: 'user',
 profile: { bio: 'Developer', website: 'https://example.com' },
 })
 .returning();

// 複数レコードの挿入
const newPosts = await db
 .insert(posts)
 .values([
 { title: 'First Post', content: 'Hello World', authorId: newUser[0].id },
 { title: 'Second Post', content: 'Drizzle is awesome', authorId: newUser[0].id },
 ])
 .returning();

// ON CONFLICT(Upsert)
const upsertedUser = await db
 .insert(users)
 .values({
 email: 'user@example.com',
 name: 'Updated Name',
 })
 .onConflictDoUpdate({
 target: users.email,
 set: { name: 'Updated Name', updatedAt: new Date() },
 })
 .returning();

Read(取得)

import { eq, and, or, gt, like, inArray, desc, asc, sql } from 'drizzle-orm';

// 全件取得
const allUsers = await db.select().from(users);

// 条件指定
const activeUsers = await db
 .select()
 .from(users)
 .where(eq(users.role, 'user'));

// 複合条件
const filteredPosts = await db
 .select()
 .from(posts)
 .where(
 and(
 eq(posts.status, 'published'),
 gt(posts.viewCount, 100),
 or(
 like(posts.title, '%TypeScript%'),
 like(posts.title, '%Drizzle%')
 )
 )
 );

// ソートとページネーション
const paginatedPosts = await db
 .select()
 .from(posts)
 .orderBy(desc(posts.createdAt))
 .limit(10)
 .offset(20);

// 特定カラムのみ取得
const userEmails = await db
 .select({
 id: users.id,
 email: users.email,
 })
 .from(users);

// 集計関数
const stats = await db
 .select({
 totalPosts: sql<number>`count(*)`,
 totalViews: sql<number>`sum(${posts.viewCount})`,
 avgViews: sql<number>`avg(${posts.viewCount})`,
 })
 .from(posts)
 .where(eq(posts.status, 'published'));

JOINクエリ

// INNER JOIN
const postsWithAuthors = await db
 .select({
 post: posts,
 author: users,
 })
 .from(posts)
 .innerJoin(users, eq(posts.authorId, users.id));

// LEFT JOIN
const usersWithPosts = await db
 .select({
 user: users,
 postCount: sql<number>`count(${posts.id})`,
 })
 .from(users)
 .leftJoin(posts, eq(users.id, posts.authorId))
 .groupBy(users.id);

// 複数テーブルJOIN
const fullPostData = await db
 .select({
 postId: posts.id,
 postTitle: posts.title,
 authorName: users.name,
 commentCount: sql<number>`count(distinct ${comments.id})`,
 })
 .from(posts)
 .innerJoin(users, eq(posts.authorId, users.id))
 .leftJoin(comments, eq(posts.id, comments.postId))
 .groupBy(posts.id, users.name);

リレーションクエリ(Query API)

// ネストしたデータ取得
const postsWithRelations = await db.query.posts.findMany({
 with: {
 author: true,
 comments: {
 with: {
 author: true,
 },
 limit: 5,
 orderBy: (comments, { desc }) => [desc(comments.createdAt)],
 },
 postsToTags: {
 with: {
 tag: true,
 },
 },
 },
 where: eq(posts.status, 'published'),
 orderBy: (posts, { desc }) => [desc(posts.publishedAt)],
 limit: 10,
});

// 単一レコード取得
const post = await db.query.posts.findFirst({
 where: eq(posts.id, 1),
 with: {
 author: {
 columns: {
 id: true,
 name: true,
 profile: true,
 },
 },
 },
});

Update(更新)

// 条件指定で更新
const updatedPosts = await db
 .update(posts)
 .set({
 status: 'published',
 publishedAt: new Date(),
 })
 .where(eq(posts.id, 1))
 .returning();

// 複数条件で更新
await db
 .update(posts)
 .set({ viewCount: sql`${posts.viewCount} + 1` })
 .where(
 and(
 eq(posts.status, 'published'),
 gt(posts.publishedAt, new Date('2024-01-01'))
 )
 );

Delete(削除)

// 条件指定で削除
const deletedPosts = await db
 .delete(posts)
 .where(eq(posts.status, 'archived'))
 .returning();

// 複数条件で削除
await db
 .delete(comments)
 .where(
 and(
 eq(comments.authorId, userId),
 inArray(comments.postId, [1, 2, 3])
 )
 );

実践メモ: .returning()を付けると、INSERT/UPDATE/DELETEの結果を即座に取得できます。追加のSELECTが不要になります。

トランザクション

// 基本的なトランザクション
const result = await db.transaction(async (tx) => {
 const [newPost] = await tx
 .insert(posts)
 .values({
 title: 'New Post',
 content: 'Content',
 authorId: userId,
 })
 .returning();

 await tx.insert(postsToTags).values([
 { postId: newPost.id, tagId: 1 },
 { postId: newPost.id, tagId: 2 },
 ]);

 return newPost;
});

// ネストしたトランザクション(セーブポイント)
await db.transaction(async (tx) => {
 await tx.insert(users).values({ email: 'a@example.com', name: 'A' });

 await tx.transaction(async (nested) => {
 await nested.insert(users).values({ email: 'b@example.com', name: 'B' });
 // エラー時はこのセーブポイントまでロールバック
 });
});

// 明示的なロールバック
await db.transaction(async (tx) => {
 const [user] = await tx.insert(users).values({ ... }).returning();

 if (someCondition) {
 tx.rollback(); // トランザクション全体をロールバック
 }
});

マイグレーション

本番環境では必ずgenerate→migrateのフローを使い、pushは開発用に限定してください。

# マイグレーションファイル生成
npx drizzle-kit generate

# マイグレーション実行
npx drizzle-kit migrate

# スキーマをDBに直接プッシュ(開発用)
npx drizzle-kit push

# スキーマの差分確認
npx drizzle-kit check

# Drizzle Studioでデータ確認
npx drizzle-kit studio
flowchart TB
 subgraph DevFlow["開発フロー"]
 direction LR
 Schema1["Schema 変更"] --> Push["push<br/>(直接)"] --> DB1["Database 更新"]
 end

 subgraph ProdFlow["本番フロー"]
 direction TB
 Schema2["Schema 変更"] --> Generate["generate<br/>(SQL生成)"] --> Migrate["migrate<br/>(適用)"]
 Generate --> SQLFile["drizzle/0001_xxx.sql<br/>(レビュー可能なSQL)"]
 end

ポイント: $inferSelectと$inferInsertでスキーマから型を自動推論でき、手動で型定義を書く必要がありません。

型安全なヘルパー関数

// src/db/helpers.ts
import { db } from './index';
import { posts, users } from './schema';
import { eq, and, desc, sql, type SQL } from 'drizzle-orm';

// 型安全なページネーション
export async function getPaginatedPosts(options: {
 page: number;
 limit: number;
 status?: 'draft' | 'published' | 'archived';
}) {
 const { page, limit, status } = options;
 const offset = (page - 1) * limit;

 const conditions: SQL[] = [];
 if (status) {
 conditions.push(eq(posts.status, status));
 }

 const [data, countResult] = await Promise.all([
 db
 .select()
 .from(posts)
 .where(conditions.length > 0 ? and(...conditions) : undefined)
 .orderBy(desc(posts.createdAt))
 .limit(limit)
 .offset(offset),
 db
 .select({ count: sql<number>`count(*)` })
 .from(posts)
 .where(conditions.length > 0 ? and(...conditions) : undefined),
 ]);

 return {
 data,
 pagination: {
 page,
 limit,
 total: countResult[0].count,
 totalPages: Math.ceil(countResult[0].count / limit),
 },
 };
}

// 型推論を活用した汎用関数
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;

注意: tx.rollback()を呼ぶとトランザクション全体がロールバックされます。部分的に保持したい場合はネストトランザクション(セーブポイント)を使いましょう

Prismaとの比較

観点Drizzle ORMPrisma
クエリ構文SQL-like独自DSL
型安全性TypeScript native生成された型
バンドルサイズ軽量 (~7.4KB)重い (~2MB)
マイグレーションSQLファイル独自形式
学習曲線SQLの知識が活きる独自構文を学習
エッジランタイム対応一部制限あり

動作確認

Drizzleを導入したら、次を確認します。

確認項目コマンド・場所見ること
スキーマ生成Drizzle KitSQLが意図通り生成される
マイグレーションmigrationファイル破壊的変更が混ざっていない
クエリ実行開発DBselect/insert/updateが動く
型推論エディタ$inferSelect$inferInsert が期待通り

DB変更前には、生成されたSQLを必ず読んでください。

練習

学習用DBで次を試してください。

  1. usersposts テーブルを定義する
  2. posts.authorId から users.id への関連を作る
  3. マイグレーションSQLを生成する
  4. 投稿一覧を作成日時の降順で取得する
  5. Prisma版ならどう書くかを比較する

次のステップ

ORM選定で迷う場合は Prisma ORM実践ガイド と比較してください。SQLの基礎が不安な場合は ユーザーと注文テーブルでJOINを練習する が先に役立ちます。

まとめ

この記事では、手順を追って動かしながら重要な確認ポイントを押さえることが大切です。まずは学習用の最小構成で動作確認し、本番利用する前にセキュリティ、権限、エラー処理、運用手順を見直してください。

参考リンク

参考リソース

← 一覧に戻る
PR
PR
PR
PR