Documentation Drizzle

概要

この記事では、Drizzle ORMとop-sqliteを使用してReact Nativeアプリで高性能なSQLiteデータベースを構築する方法を解説します。op-sqliteは、React Native向けの最速SQLiteライブラリの一つで、JSI(JavaScript Interface)を使用した同期APIによって、従来の非同期ブリッジよりも大幅に高速なデータベース操作を実現します。

op-sqliteとDrizzle ORMの概要

op-sqliteとは

op-sqliteは、React Native用の高性能SQLiteドライバーです。主な特徴は以下の通りです:

  • JSIベースの同期API: JavaScript Interfaceを使用し、ネイティブブリッジのオーバーヘッドを排除
  • 高速性: expo-sqliteの約2〜3倍の速度でクエリを実行
  • 同期・非同期両対応: アプリのニーズに応じて柔軟に選択可能
  • トランザクションサポート: 完全なACID準拠のトランザクション
  • React NativeとExpoの両方に対応: Config Pluginを使用してExpoでも利用可能

expo-sqliteとの比較

項目op-sqliteexpo-sqlite
パフォーマンス非常に高速(JSIベース)標準的(非同期ブリッジ)
API同期・非同期両対応非同期のみ
ネイティブモジュールカスタムネイティブコードExpo管理
Expo対応Config Plugin必要ネイティブサポート
大量データ処理最適可能だが遅い

Drizzle ORMとの組み合わせ

Drizzle ORMは、TypeScriptの型安全性を提供し、op-sqliteの高性能と組み合わせることで、安全かつ高速なモバイルデータベース操作が実現できます。

React Nativeプロジェクトでのセットアップ

ステップ1:React Nativeプロジェクトの作成

新しいReact Nativeプロジェクトを作成します。

npx react-native init DrizzleOpSQLite --template react-native-template-typescript
cd DrizzleOpSQLite

Expoプロジェクトの場合:

npx create-expo-app DrizzleOpSQLite --template blank-typescript
cd DrizzleOpSQLite

ステップ2:必要なパッケージのインストール

Drizzle ORMと@op-engineering/op-sqliteをインストールします。

React Nativeの場合:

npm install drizzle-orm @op-engineering/op-sqlite
npm install --save-dev drizzle-kit

Expoの場合:

npx expo install @op-engineering/op-sqlite
npm install drizzle-orm
npm install --save-dev drizzle-kit

Expoでは、Config Pluginの設定が必要です。app.jsonに以下を追加:

{
  "expo": {
    "plugins": [
      [
        "@op-engineering/op-sqlite",
        {
          "iosDatabaseLocation": "Documents"
        }
      ]
    ]
  }
}

設定後、開発ビルドを再作成:

npx expo prebuild
npx expo run:ios
# または
npx expo run:android

ステップ3:Drizzle ORMとop-sqliteの接続設定

op-sqliteを使用してSQLiteデータベースに接続し、Drizzle ORMを初期化します。

// database.ts
import { drizzle } from 'drizzle-orm/op-sqlite';
import { open } from '@op-engineering/op-sqlite';

// op-sqliteでデータベースを開く
const opsqliteDb = open({
  name: 'app.db',
  location: '../databases' // iOSの場合は 'Documents'
});

// Drizzle ORMを初期化
export const db = drizzle(opsqliteDb);

この設定により、app.dbというSQLiteデータベースファイルが作成され、Drizzleを通してアクセスできるようになります。

ステップ4:テーブルのスキーマ定義

Drizzle ORMを使って、SQLiteデータベース内のテーブル構造を定義します。

// schema.ts
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  age: integer('age'),
  bio: text('bio'),
  is_active: integer('is_active', { mode: 'boolean' }).notNull().default(true),
  created_at: integer('created_at', { mode: 'timestamp' })
    .notNull()
    .default(sql`(unixepoch())`),
  updated_at: integer('updated_at', { mode: 'timestamp' })
    .notNull()
    .default(sql`(unixepoch())`)
    .$onUpdate(() => new Date())
});

export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  user_id: integer('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  title: text('title').notNull(),
  content: text('content'),
  published: integer('published', { mode: 'boolean' }).notNull().default(false),
  created_at: integer('created_at', { mode: 'timestamp' })
    .notNull()
    .default(sql`(unixepoch())`)
});

// 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;

このスキーマでは、userspostsの2つのテーブルを定義し、外部キー制約とタイムスタンプの自動管理を含めています。

ステップ5:データベースの初期化とマイグレーション

アプリ起動時にテーブルを作成します。

// migrate.ts
import { db } from './database';
import { sql } from 'drizzle-orm';

export async function migrateDb() {
  try {
    // usersテーブルを作成
    db.run(sql`
      CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        email TEXT NOT NULL UNIQUE,
        age INTEGER,
        bio TEXT,
        is_active INTEGER NOT NULL DEFAULT 1,
        created_at INTEGER NOT NULL DEFAULT (unixepoch()),
        updated_at INTEGER NOT NULL DEFAULT (unixepoch())
      )
    `);

    // postsテーブルを作成
    db.run(sql`
      CREATE TABLE IF NOT EXISTS posts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id INTEGER NOT NULL,
        title TEXT NOT NULL,
        content TEXT,
        published INTEGER NOT NULL DEFAULT 0,
        created_at INTEGER NOT NULL DEFAULT (unixepoch()),
        FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
      )
    `);

    console.log('Database migrated successfully');
  } catch (error) {
    console.error('Migration failed:', error);
    throw error;
  }
}

ステップ6:CRUD操作の実装

Drizzle ORMとop-sqliteを使用した完全な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 function createUser(newUser: NewUser) {
  try {
    const result = db.insert(users).values(newUser).returning().get();
    return { success: true, user: result };
  } 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 function getAllUsers() {
  try {
    return db.select().from(users).orderBy(desc(users.created_at)).all();
  } catch (error) {
    console.error('Failed to get users:', error);
    throw error;
  }
}

// IDでユーザーを取得
export function getUserById(id: number) {
  try {
    return db.select().from(users).where(eq(users.id, id)).get();
  } catch (error) {
    console.error('Failed to get user:', error);
    throw error;
  }
}

// ユーザーとその投稿を取得
export function getUserWithPosts(userId: number) {
  try {
    const user = db.select().from(users).where(eq(users.id, userId)).get();

    if (!user) {
      return null;
    }

    const userPosts = db
      .select()
      .from(posts)
      .where(eq(posts.user_id, userId))
      .orderBy(desc(posts.created_at))
      .all();

    return { ...user, posts: userPosts };
  } catch (error) {
    console.error('Failed to get user with posts:', error);
    throw error;
  }
}

// ユーザーを更新
export function updateUser(id: number, updates: Partial<NewUser>) {
  try {
    const result = db
      .update(users)
      .set(updates)
      .where(eq(users.id, id))
      .returning()
      .get();
    return { success: true, user: result };
  } catch (error) {
    console.error('Failed to update user:', error);
    return { success: false, error: 'Failed to update user' };
  }
}

// ユーザーを削除(関連する投稿も削除)
export function deleteUser(id: number) {
  try {
    db.delete(users).where(eq(users.id, id)).run();
    return { success: true };
  } catch (error) {
    console.error('Failed to delete user:', error);
    return { success: false, error: 'Failed to delete user' };
  }
}

// 投稿を作成
export function createPost(newPost: NewPost) {
  try {
    const result = db.insert(posts).values(newPost).returning().get();
    return { success: true, post: result };
  } catch (error) {
    console.error('Failed to create post:', error);
    return { success: false, error: 'Failed to create post' };
  }
}

// ユーザーの投稿をすべて取得
export function getPostsByUser(userId: number) {
  try {
    return db
      .select()
      .from(posts)
      .where(eq(posts.user_id, userId))
      .orderBy(desc(posts.created_at))
      .all();
  } catch (error) {
    console.error('Failed to get posts:', error);
    throw error;
  }
}

// トランザクションの例
export function createUserWithPost(
  newUser: NewUser,
  postTitle: string,
  postContent: string
) {
  try {
    return db.transaction((tx) => {
      // ユーザーを作成
      const user = tx.insert(users).values(newUser).returning().get();

      // 投稿を作成
      const post = tx
        .insert(posts)
        .values({
          user_id: user.id,
          title: postTitle,
          content: postContent,
          published: false
        })
        .returning()
        .get();

      return { user, post };
    });
  } catch (error) {
    console.error('Transaction failed:', error);
    throw error;
  }
}

// バッチ挿入
export function createMultipleUsers(newUsers: NewUser[]) {
  try {
    const results = db.insert(users).values(newUsers).returning().all();
    return { success: true, users: results };
  } catch (error) {
    console.error('Batch insert failed:', error);
    return { success: false, error: 'Failed to create users' };
  }
}

op-sqliteの同期APIの活用

op-sqliteの最大の利点は、同期APIによる高速なデータアクセスです。

// 同期APIの例(.get(), .all(), .run()を使用)
// 非同期ブリッジのオーバーヘッドがないため、非常に高速

// 単一レコード取得(同期)
const user = db.select().from(users).where(eq(users.id, 1)).get();

// 複数レコード取得(同期)
const allUsers = db.select().from(users).all();

// 更新・削除(同期)
db.update(users).set({ name: 'Updated Name' }).where(eq(users.id, 1)).run();

// パフォーマンス比較の例
console.time('op-sqlite sync');
for (let i = 0; i < 1000; i++) {
  db.insert(users).values({ name: `User ${i}`, email: `user${i}@example.com` }).run();
}
console.timeEnd('op-sqlite sync'); // expo-sqliteの約2〜3倍高速

React Nativeコンポーネントでの使用例

実際のReact Nativeアプリでデータベース操作を使用する例です。

// App.tsx
import React, { useEffect, useState } from 'react';
import {
  View,
  Text,
  TextInput,
  Button,
  FlatList,
  StyleSheet,
  TouchableOpacity,
  Alert
} from 'react-native';
import { migrateDb } from './migrate';
import {
  getAllUsers,
  createUser,
  updateUser,
  deleteUser,
  getUserWithPosts,
  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();
  }, []);

  const initializeDatabase = () => {
    try {
      migrateDb();
      loadUsers();
      setIsLoading(false);
    } catch (error) {
      console.error('Database initialization failed:', error);
      Alert.alert('エラー', 'データベースの初期化に失敗しました');
    }
  };

  const loadUsers = () => {
    try {
      const allUsers = getAllUsers();
      setUsers(allUsers);
    } catch (error) {
      console.error('Failed to load users:', error);
      Alert.alert('エラー', 'ユーザーの読み込みに失敗しました');
    }
  };

  const handleCreateUser = () => {
    if (!name.trim() || !email.trim()) {
      Alert.alert('エラー', '名前とメールアドレスを入力してください');
      return;
    }

    const result = createUser({ name, email, is_active: true });

    if (result.success) {
      setName('');
      setEmail('');
      loadUsers();
      Alert.alert('成功', 'ユーザーを作成しました');
    } else {
      Alert.alert('エラー', result.error);
    }
  };

  const handleDeleteUser = (id: number) => {
    Alert.alert(
      '削除確認',
      'このユーザーを削除しますか?',
      [
        { text: 'キャンセル', style: 'cancel' },
        {
          text: '削除',
          style: 'destructive',
          onPress: () => {
            const result = deleteUser(id);
            if (result.success) {
              loadUsers();
            } else {
              Alert.alert('エラー', result.error);
            }
          }
        }
      ]
    );
  };

  const handleViewUserPosts = (userId: number) => {
    const userWithPosts = getUserWithPosts(userId);
    if (userWithPosts) {
      Alert.alert(
        'ユーザー情報',
        `名前: ${userWithPosts.name}\n投稿数: ${userWithPosts.posts.length}`
      );
    }
  };

  if (isLoading) {
    return (
      <View style={styles.container}>
        <Text>データベースを初期化中...</Text>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Drizzle + op-sqlite</Text>

      <View style={styles.inputContainer}>
        <TextInput
          placeholder="名前"
          value={name}
          onChangeText={setName}
          style={styles.input}
        />
        <TextInput
          placeholder="メールアドレス"
          value={email}
          onChangeText={setEmail}
          style={styles.input}
          keyboardType="email-address"
          autoCapitalize="none"
        />
        <Button title="ユーザーを追加" onPress={handleCreateUser} />
      </View>

      <Text style={styles.subtitle}>ユーザー一覧 ({users.length})</Text>

      <FlatList
        data={users}
        keyExtractor={(item) => item.id.toString()}
        renderItem={({ item }) => (
          <View style={styles.userItem}>
            <View style={styles.userInfo}>
              <Text style={styles.userName}>{item.name}</Text>
              <Text style={styles.userEmail}>{item.email}</Text>
            </View>
            <View style={styles.userActions}>
              <TouchableOpacity onPress={() => handleViewUserPosts(item.id)}>
                <Text style={styles.viewButton}>表示</Text>
              </TouchableOpacity>
              <TouchableOpacity onPress={() => handleDeleteUser(item.id)}>
                <Text style={styles.deleteButton}>削除</Text>
              </TouchableOpacity>
            </View>
          </View>
        )}
        ListEmptyComponent={
          <Text style={styles.emptyText}>ユーザーがいません</Text>
        }
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
    paddingTop: 60,
    backgroundColor: '#f5f5f5'
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 20
  },
  subtitle: {
    fontSize: 18,
    fontWeight: '600',
    marginTop: 20,
    marginBottom: 10
  },
  inputContainer: {
    backgroundColor: 'white',
    padding: 15,
    borderRadius: 10
  },
  input: {
    borderBottomWidth: 1,
    borderBottomColor: '#ddd',
    marginBottom: 10,
    padding: 10,
    fontSize: 16
  },
  userItem: {
    backgroundColor: 'white',
    padding: 15,
    borderRadius: 10,
    marginBottom: 10,
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center'
  },
  userInfo: {
    flex: 1
  },
  userName: {
    fontSize: 16,
    fontWeight: '600',
    marginBottom: 5
  },
  userEmail: {
    fontSize: 14,
    color: '#666'
  },
  userActions: {
    flexDirection: 'row',
    gap: 10
  },
  viewButton: {
    color: '#007AFF',
    padding: 5
  },
  deleteButton: {
    color: '#ff6b6b',
    padding: 5
  },
  emptyText: {
    textAlign: 'center',
    color: '#999',
    marginTop: 50,
    fontSize: 16
  }
});

パフォーマンス最適化とベストプラクティス

1. インデックスの活用

大量データを扱う場合は、インデックスを追加して検索を高速化します。

// スキーマにインデックスを追加
import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').notNull().unique(),
  name: text('name').notNull()
}, (table) => ({
  emailIdx: index('email_idx').on(table.email),
  nameIdx: index('name_idx').on(table.name)
}));

2. バッチ処理の活用

複数のレコードを一度に挿入する場合は、バッチ処理を使用します。

// 効率的なバッチ挿入
const newUsers = Array.from({ length: 1000 }, (_, i) => ({
  name: `User ${i}`,
  email: `user${i}@example.com`
}));

console.time('batch insert');
db.insert(users).values(newUsers).run();
console.timeEnd('batch insert'); // 個別挿入より圧倒的に高速

3. トランザクションの使用

複数の関連操作は、トランザクションでまとめて実行します。

// トランザクションで複数操作をアトミックに実行
db.transaction((tx) => {
  const user = tx.insert(users).values({ name: 'John', email: 'john@example.com' }).returning().get();

  tx.insert(posts).values([
    { user_id: user.id, title: 'Post 1', content: 'Content 1' },
    { user_id: user.id, title: 'Post 2', content: 'Content 2' }
  ]).run();
});

4. プリペアドステートメント

頻繁に実行するクエリは、プリペアドステートメントを使用します。

const getUserByEmail = db
  .select()
  .from(users)
  .where(eq(users.email, sql.placeholder('email')))
  .prepare();

// 再利用
const user1 = getUserByEmail.get({ email: 'user1@example.com' });
const user2 = getUserByEmail.get({ email: 'user2@example.com' });

エラー対処と注意点

1. Expoでのネイティブモジュール設定

問題: Expoでop-sqliteが動作しない

解決策:

  • Config Pluginをapp.jsonに追加
  • 開発ビルドを再作成(npx expo prebuild
  • Expo Goではなく、開発ビルドを使用
{
  "expo": {
    "plugins": [
      [
        "@op-engineering/op-sqlite",
        {
          "iosDatabaseLocation": "Documents"
        }
      ]
    ]
  }
}

2. データベースファイルの場所

iOS: Documentsディレクトリを使用(iCloudバックアップ対象) Android: ../databasesディレクトリを使用

import { Platform } from 'react-native';

const dbLocation = Platform.select({
  ios: 'Documents',
  android: '../databases'
});

const opsqliteDb = open({
  name: 'app.db',
  location: dbLocation
});

3. 型安全性の確保

問題: TypeScriptの型エラーが発生する

解決策:

  • $inferSelect$inferInsertを使用して型を推論
  • Zodなどのバリデーションライブラリを併用
import { z } from 'zod';

const userSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email()
});

function createUserSafe(data: unknown) {
  const validated = userSchema.parse(data);
  return createUser(validated);
}

4. SQLiteの制約

  • 外部キー制約はデフォルトで無効(PRAGMA foreign_keys = ONで有効化)
  • 論理型は存在しないため、integerで0/1として保存
  • 日時はunixepoch()を使用してタイムスタンプとして保存
// 外部キー制約を有効化
import { sql } from 'drizzle-orm';
db.run(sql`PRAGMA foreign_keys = ON`);

まとめ

Drizzle ORMとop-sqliteを組み合わせることで、React Nativeアプリで型安全かつ高性能なデータベース操作が実現できます。

主なポイント:

  1. 高性能: JSIベースの同期APIにより、expo-sqliteの約2〜3倍高速
  2. 型安全性: TypeScriptとDrizzle ORMによる完全な型推論
  3. Expo対応: Config Pluginを使用してExpoプロジェクトでも利用可能
  4. トランザクション: ACID準拠のトランザクションサポート
  5. バッチ処理: 大量データの効率的な処理
  6. プリペアドステートメント: 頻繁に実行するクエリの最適化

op-sqliteは、React Nativeアプリで大量のローカルデータを扱う場合や、高速なデータベース操作が求められる場合に最適な選択肢です。Drizzle ORMの型安全性と組み合わせることで、安全かつ高速なモバイルアプリ開発が可能になります。

参考文献

円