Drizzle ORMのPGLite接続概要
PGLiteは、PostgresデータベースをWebAssembly(WASM)として実行し、Node.jsやブラウザで利用できるインメモリPostgresの軽量データベースソリューションです。インメモリデータベースとして動作するほか、ファイルシステム(Node.js、Bun)やIndexedDB(ブラウザ)を使って永続的にデータを保持することもできます。Drizzle ORMは、このPGLiteとの接続をサポートしており、サーバーレスアプリやフロントエンドでのデータベース操作に便利です。
PGLiteとDrizzle ORMの接続設定
ステップ1:パッケージのインストール
まず、PGLiteとDrizzle ORMをインストールします。Drizzleのマイグレーションツールとしてdrizzle-kitも併せてインストールします。
npm install drizzle-orm @electric-sql/pglite
npm install -D drizzle-kit
ステップ2:スキーマの定義
PostgreSQL互換のスキーマを定義します。
// schema.ts
import { pgTable, serial, varchar, text, timestamp, boolean } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 100 }).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
bio: text('bio'),
is_active: boolean('is_active').notNull().default(true),
created_at: timestamp('created_at', { withTimezone: true })
.notNull()
.default(sql`now()`)
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
user_id: serial('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
title: varchar('title', { length: 200 }).notNull(),
content: text('content'),
published: boolean('published').notNull().default(false),
created_at: timestamp('created_at', { withTimezone: true })
.notNull()
.default(sql`now()`)
});
// TypeScript型推論
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
ステップ3:PGLiteドライバーの初期化とクエリの実行
インメモリデータベースの設定(Node.js / Bun)
インメモリデータベースとしてPGLiteを使用する場合の設定です。
// database.ts
import { PGlite } from '@electric-sql/pglite';
import { drizzle } from 'drizzle-orm/pglite';
import * as schema from './schema';
// インメモリPGliteインスタンスを作成
const client = new PGlite();
// Drizzle ORMを初期化
export const db = drizzle(client, { schema });
// テーブルを作成
async function initDb() {
await client.exec(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
bio TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS posts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
content TEXT,
published BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
`);
}
// 初期化を実行
await initDb();
このように、インメモリでの簡単なテストや一時的なデータ操作に最適です。アプリ終了時にデータは消失します。
データ永続化の設定(Node.js / Bun - ファイルシステム)
データを永続化したい場合は、ファイルシステムにデータを保存できます。
// database-persistent.ts
import { PGlite } from '@electric-sql/pglite';
import { drizzle } from 'drizzle-orm/pglite';
import * as schema from './schema';
import path from 'path';
// ファイルシステムに永続化
const client = new PGlite(path.join(process.cwd(), 'data', 'pglite'));
// Drizzle ORMを初期化
export const db = drizzle(client, { schema });
// 初回起動時にテーブルを作成
async function initDb() {
try {
await client.exec(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
bio TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
`);
console.log('Database initialized successfully');
} catch (error) {
console.error('Failed to initialize database:', error);
}
}
await initDb();
ブラウザでの設定(IndexedDB)
ブラウザ環境では、IndexedDBを使用してデータを永続化します。
// database-browser.ts
import { PGlite } from '@electric-sql/pglite';
import { drizzle } from 'drizzle-orm/pglite';
import * as schema from './schema';
// IndexedDBに永続化(ブラウザ環境)
const client = new PGlite('idb://my-database');
// Drizzle ORMを初期化
export const db = drizzle(client, { schema });
// テーブル作成
export async function initDb() {
try {
await client.exec(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
bio TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS posts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
content TEXT,
published BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
`);
console.log('Browser database initialized');
} catch (error) {
console.error('Failed to initialize database:', error);
throw error;
}
}
ブラウザではidb://プレフィックスを使用することで、IndexedDBにデータが保存されます。ページをリロードしてもデータは保持されます。
ステップ4:CRUD操作の実装
PGliteとDrizzle ORMを使用した完全なCRUD操作を実装します。
// operations.ts
import { db } from './database';
import { users, posts, type NewUser, type NewPost } from './schema';
import { eq, like, and, desc } from 'drizzle-orm';
// ユーザーを作成
export async function createUser(newUser: NewUser) {
try {
const result = await db.insert(users).values(newUser).returning();
return { success: true, user: result[0] };
} catch (error: any) {
console.error('Failed to create user:', error);
if (error.message.includes('unique constraint')) {
return { success: false, error: 'Email already exists' };
}
return { success: false, error: 'Failed to create user' };
}
}
// すべてのユーザーを取得
export async function getAllUsers() {
try {
return await db.select().from(users).orderBy(desc(users.created_at));
} catch (error) {
console.error('Failed to get users:', error);
throw error;
}
}
// IDでユーザーを取得
export async function getUserById(id: number) {
try {
const result = await db.select().from(users).where(eq(users.id, id));
return result[0];
} catch (error) {
console.error('Failed to get user:', error);
throw error;
}
}
// ユーザーとその投稿を取得
export async function getUserWithPosts(userId: number) {
try {
const user = await getUserById(userId);
if (!user) return null;
const userPosts = await db
.select()
.from(posts)
.where(eq(posts.user_id, userId))
.orderBy(desc(posts.created_at));
return { ...user, posts: userPosts };
} catch (error) {
console.error('Failed to get user with posts:', error);
throw error;
}
}
// ユーザーを更新
export async function updateUser(id: number, updates: Partial<NewUser>) {
try {
const result = await db
.update(users)
.set(updates)
.where(eq(users.id, id))
.returning();
return { success: true, user: result[0] };
} catch (error) {
console.error('Failed to update user:', error);
return { success: false, error: 'Failed to update user' };
}
}
// ユーザーを削除(関連する投稿も削除)
export async function deleteUser(id: number) {
try {
await db.delete(users).where(eq(users.id, id));
return { success: true };
} catch (error) {
console.error('Failed to delete user:', error);
return { success: false, error: 'Failed to delete user' };
}
}
// 投稿を作成
export async function createPost(newPost: NewPost) {
try {
const result = await db.insert(posts).values(newPost).returning();
return { success: true, post: result[0] };
} catch (error) {
console.error('Failed to create post:', error);
return { success: false, error: 'Failed to create post' };
}
}
// メールで検索
export async function searchUsersByEmail(query: string) {
try {
return await db
.select()
.from(users)
.where(like(users.email, `%${query}%`));
} catch (error) {
console.error('Failed to search users:', error);
throw error;
}
}
// トランザクションの例
export async function createUserWithPost(
newUser: NewUser,
postTitle: string,
postContent: string
) {
try {
return await db.transaction(async (tx) => {
// ユーザーを作成
const [user] = await tx.insert(users).values(newUser).returning();
// 投稿を作成
const [post] = await tx
.insert(posts)
.values({
user_id: user.id,
title: postTitle,
content: postContent,
published: false
})
.returning();
return { user, post };
});
} catch (error) {
console.error('Transaction failed:', error);
throw error;
}
}
React / Viteアプリでの使用例
ブラウザ環境でReactと組み合わせて使用する実践例です。
// App.tsx
import React, { useEffect, useState } from 'react';
import { db, initDb } from './database-browser';
import {
getAllUsers,
createUser,
deleteUser,
type User
} from './operations';
export default function App() {
const [users, setUsers] = useState<User[]>([]);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
initializeDatabase();
}, []);
async function initializeDatabase() {
try {
await initDb();
await loadUsers();
setIsLoading(false);
} catch (error) {
console.error('Database initialization failed:', error);
alert('データベースの初期化に失敗しました');
}
}
async function loadUsers() {
try {
const allUsers = await getAllUsers();
setUsers(allUsers);
} catch (error) {
console.error('Failed to load users:', error);
alert('ユーザーの読み込みに失敗しました');
}
}
async function handleCreateUser() {
if (!name.trim() || !email.trim()) {
alert('名前とメールアドレスを入力してください');
return;
}
const result = await createUser({ name, email, is_active: true });
if (result.success) {
setName('');
setEmail('');
await loadUsers();
alert('ユーザーを作成しました');
} else {
alert(result.error);
}
}
async function handleDeleteUser(id: number) {
if (!confirm('このユーザーを削除しますか?')) return;
const result = await deleteUser(id);
if (result.success) {
await loadUsers();
} else {
alert(result.error);
}
}
if (isLoading) {
return <div>データベースを初期化中...</div>;
}
return (
<div style={{ padding: '20px' }}>
<h1>PGlite + Drizzle in Browser</h1>
<div style={{ marginBottom: '20px', padding: '15px', background: '#f5f5f5' }}>
<input
type="text"
placeholder="名前"
value={name}
onChange={(e) => setName(e.target.value)}
style={{ marginRight: '10px', padding: '8px' }}
/>
<input
type="email"
placeholder="メールアドレス"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{ marginRight: '10px', padding: '8px' }}
/>
<button onClick={handleCreateUser} style={{ padding: '8px 16px' }}>
ユーザーを追加
</button>
</div>
<h2>ユーザー一覧 ({users.length})</h2>
<div>
{users.map((user) => (
<div
key={user.id}
style={{
padding: '10px',
marginBottom: '10px',
background: 'white',
border: '1px solid #ddd',
borderRadius: '5px'
}}
>
<strong>{user.name}</strong> - {user.email}
<button
onClick={() => handleDeleteUser(user.id)}
style={{ marginLeft: '10px', color: 'red' }}
>
削除
</button>
</div>
))}
</div>
{users.length === 0 && (
<p style={{ color: '#999' }}>ユーザーがいません</p>
)}
</div>
);
}
PGLiteの特徴と活用シーン
PGLiteは、軽量なPostgresデータベースとして、以下のような特徴と利点を持ちます:
主な特徴
- WebAssemblyによる軽量性: 約3MBの軽量なサイズで、ブラウザやサーバーレス環境で迅速にセットアップ
- インメモリと永続化の柔軟な対応: データの永続化やインメモリの選択が可能
- PostgreSQL互換: 標準的なPostgreSQLのSQLクエリが使用可能
- クライアントサイド実行: サーバー不要でブラウザ内で完結
- 拡張機能サポート: pgvector、PostGISなどのPostgreSQL拡張をサポート
推奨ユースケース
| ユースケース | 設定 | メリット |
|---|---|---|
| 単体テスト | インメモリ | 高速、クリーンな状態、並列実行可能 |
| ローカル開発 | ファイルシステム | 簡単セットアップ、PostgreSQL本番環境との互換性 |
| PWA/オフラインアプリ | IndexedDB | オフライン対応、同期不要 |
| プロトタイプ/デモ | インメモリorブラウザ | セットアップ不要、共有が簡単 |
| エッジコンピューティング | インメモリ | 低レイテンシ、サーバーレス |
Node.jsでの実践例(単体テスト)
// user.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { PGlite } from '@electric-sql/pglite';
import { drizzle } from 'drizzle-orm/pglite';
import { users } from './schema';
import { createUser, getAllUsers } from './operations';
describe('User operations', () => {
let db: ReturnType<typeof drizzle>;
beforeEach(async () => {
// 各テストで新しいインメモリDBを作成
const client = new PGlite();
db = drizzle(client);
// テーブル作成
await client.exec(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
bio TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
`);
});
it('should create a user', async () => {
const result = await createUser({
name: 'Test User',
email: 'test@example.com'
});
expect(result.success).toBe(true);
expect(result.user?.name).toBe('Test User');
});
it('should get all users', async () => {
await createUser({ name: 'User 1', email: 'user1@example.com' });
await createUser({ name: 'User 2', email: 'user2@example.com' });
const allUsers = await getAllUsers();
expect(allUsers).toHaveLength(2);
});
it('should enforce unique email constraint', async () => {
await createUser({ name: 'User 1', email: 'same@example.com' });
const result = await createUser({ name: 'User 2', email: 'same@example.com' });
expect(result.success).toBe(false);
expect(result.error).toContain('Email already exists');
});
});
パフォーマンスとベストプラクティス
1. インデックスの活用
大量データを扱う場合は、インデックスを作成して検索を高速化します。
await client.exec(`
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_posts_user_id ON posts(user_id);
`);
2. トランザクションの使用
複数の関連操作は、トランザクションでまとめて実行します。
await db.transaction(async (tx) => {
await tx.insert(users).values({ name: 'User', email: 'user@example.com' });
await tx.insert(posts).values({ user_id: 1, title: 'Post' });
});
3. 接続の再利用
PGliteインスタンスは再利用可能です。複数のDrizzleインスタンスで同じPGliteクライアントを共有できます。
const client = new PGlite('idb://shared-db');
const db1 = drizzle(client);
const db2 = drizzle(client); // 同じクライアントを再利用
4. エラーハンドリング
データベース操作は常にエラーハンドリングを行います。
try {
await db.insert(users).values({ name, email });
} catch (error: any) {
if (error.message.includes('unique constraint')) {
console.error('Email already exists');
} else if (error.message.includes('foreign key')) {
console.error('Referenced record does not exist');
} else {
console.error('Database error:', error);
}
}
まとめ
Drizzle ORMとPGLiteの組み合わせにより、Node.jsやブラウザでPostgres互換のインメモリデータベースを簡単に利用できるようになります。
主なポイント:
- 軽量: 約3MBのWASMファイルで、ブラウザやサーバーレス環境で高速起動
- 柔軟なデータ永続化: インメモリ、ファイルシステム、IndexedDBから選択可能
- PostgreSQL互換: 標準的なPostgreSQLの機能とSQLクエリをサポート
- 型安全性: Drizzle ORMによるTypeScript完全サポート
- 開発効率: テスト、プロトタイプ、ローカル開発で迅速なセットアップ
- オフライン対応: ブラウザ内でデータベースが完結、サーバー不要
推奨用途:
- 単体テスト: インメモリで高速テスト実行
- ローカル開発: ファイルシステムで永続化、PostgreSQL本番環境と互換
- PWA/オフラインアプリ: IndexedDBで完全オフライン対応
- プロトタイプ: セットアップ不要でデモ環境を即座に構築
PGLiteは、従来サーバーサイドでしか実現できなかったPostgreSQLの機能をクライアントサイドで実現する画期的なソリューションです。