概要
この記事では、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-sqlite | expo-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;
このスキーマでは、usersとpostsの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アプリで型安全かつ高性能なデータベース操作が実現できます。
主なポイント:
- 高性能: JSIベースの同期APIにより、expo-sqliteの約2〜3倍高速
- 型安全性: TypeScriptとDrizzle ORMによる完全な型推論
- Expo対応: Config Pluginを使用してExpoプロジェクトでも利用可能
- トランザクション: ACID準拠のトランザクションサポート
- バッチ処理: 大量データの効率的な処理
- プリペアドステートメント: 頻繁に実行するクエリの最適化
op-sqliteは、React Nativeアプリで大量のローカルデータを扱う場合や、高速なデータベース操作が求められる場合に最適な選択肢です。Drizzle ORMの型安全性と組み合わせることで、安全かつ高速なモバイルアプリ開発が可能になります。