Documentation Drizzle

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データベースとして、以下のような特徴と利点を持ちます:

主な特徴

  1. WebAssemblyによる軽量性: 約3MBの軽量なサイズで、ブラウザやサーバーレス環境で迅速にセットアップ
  2. インメモリと永続化の柔軟な対応: データの永続化やインメモリの選択が可能
  3. PostgreSQL互換: 標準的なPostgreSQLのSQLクエリが使用可能
  4. クライアントサイド実行: サーバー不要でブラウザ内で完結
  5. 拡張機能サポート: 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互換のインメモリデータベースを簡単に利用できるようになります。

主なポイント:

  1. 軽量: 約3MBのWASMファイルで、ブラウザやサーバーレス環境で高速起動
  2. 柔軟なデータ永続化: インメモリ、ファイルシステム、IndexedDBから選択可能
  3. PostgreSQL互換: 標準的なPostgreSQLの機能とSQLクエリをサポート
  4. 型安全性: Drizzle ORMによるTypeScript完全サポート
  5. 開発効率: テスト、プロトタイプ、ローカル開発で迅速なセットアップ
  6. オフライン対応: ブラウザ内でデータベースが完結、サーバー不要

推奨用途:

  • 単体テスト: インメモリで高速テスト実行
  • ローカル開発: ファイルシステムで永続化、PostgreSQL本番環境と互換
  • PWA/オフラインアプリ: IndexedDBで完全オフライン対応
  • プロトタイプ: セットアップ不要でデモ環境を即座に構築

PGLiteは、従来サーバーサイドでしか実現できなかったPostgreSQLの機能をクライアントサイドで実現する画期的なソリューションです。

参考文献

円