メインコンテンツへスキップ

Capacitor と SQLite で Drizzle ORM を使う方法

Capacitor と SQLite で Drizzle ORM をセットアップ:動作する sqlite-proxy ドライバー、型付きスキーマ、リレーショナルクエリ、トランザクション、アプリ内 Drizzle Kit マイグレーションまで解説します。

Capacitor ロゴ。Capacitor と SQLite を Drizzle ORM と組み合わせて使うガイド

Drizzle ORM は Capacitor アプリ内で、その sqlite-proxy ドライバーを通じて動作します。ネイティブ SQLite プラグインで SQL を実行し、行を配列として返す async 関数を Drizzle に 1 つ渡すだけで、Drizzle が型付きクエリビルダー、リレーショナルクエリ、トランザクションをその上で処理します。本ガイドでは、その関数を次のように構築します。 @capacitor-community/sqlite、Drizzle Kitのmigrationをアプリのbundle内に含めて配布する方法を紹介し、見落とすと気づかないうちに結果が壊れるエッジケースも解説します。

以下はすべて次でテスト済みです: drizzle-orm 0.45 および drizzle-kit Capacitor 8 での 0.31。

モバイルデータベースでDrizzleを使う理由

  • TypeScriptによるSchema定義。 テーブルは純粋なTypeScriptオブジェクトです。selectやinsertの型は推論されるため、code の生成ステップは不要です。
  • SQL形式の API。 db.select().from(users).where(eq(users.id, 1)) SQLと1対1で対応しているため、デバイス上で何が実行されるかを正確に把握できます。
  • Schemaから生成されるmigration。 Drizzle KitがSchemaの差分を検出し、SQLのmigrationファイルを書き出します。
  • デコレータは不要です。 Viteとesbuildで、追加のTypeScriptフラグなしに動作します。

トレードオフは、Drizzleのパッケージに Capacitor ドライバーが含まれていないため、ブリッジを自分で書く必要があることです。約30行です。

SQLiteプラグインの選び方

Drizzleの sqlite-proxy ドライバーは結果行を 位置に基づいてマッピングします: all と values 配列の配列を期待する場合もあれば、 get 生成されたSQLのカラムと同じ順序の単一の配列を期待する場合もあります SELECT。Capacitor SQLiteプラグインはカラム名をキーにしたオブジェクトを返すため、ドライバーは各オブジェクトを配列に戻す必要があります。これはオブジェクトのキーがカラムの順序を維持している場合にのみ機能します。

プラグイン 行フォーマット カラム順序が保持される Drizzleプロキシに適合
@capacitor-community/sqlite オブジェクト はい。iOSではプラグインが ios_columns リストを送信し、JSレイヤーがカラム順序で各行を再構築します 良好
@capgo/capacitor-fast-sql オブジェクト iOSでは保証されません。行はSwiftの辞書からシリアライズされます 代わりにKyselyを使用してください

そのため、このガイドではコミュニティプラグインを使用しています。すでにFast SQLを使用している場合、またはより高速なデータパスを望む場合は、 Kysely 列名で行を読み取り、そのまま直接扱えます。両方のプラグインの概要については、次を参照してください: Capacitor アプリでSQLiteを使う方法.

インストール

bun add drizzle-orm @capacitor-community/sqlite
bun add -d drizzle-kit
bunx cap sync

@capacitor-community/sqlite iOSとAndroidでSQLCipherをbundleするため、何も暗号化していなくてもアプリには暗号化機能 code が含まれます。App Storeの輸出コンプライアンスに関する質問では、この点を覚えておいてください。

スキーマを定義する

// src/db/schema.ts
import { relations } from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  createdAt: integer('created_at', { mode: 'timestamp' })
    .notNull()
    .$defaultFn(() => new Date()),
});

export const posts = sqliteTable(
  'posts',
  {
    id: integer('id').primaryKey({ autoIncrement: true }),
    authorId: integer('author_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    title: text('title').notNull(),
    published: integer('published', { mode: 'boolean' }).notNull().default(false),
  },
  (t) => [index('idx_posts_author').on(t.authorId)],
);

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));

モバイルでは、いくつかの点が重要です:

  • mode: 'timestamp' エポック秒を整数として保存し、次を返します: Date TypeScriptのオブジェクト。
  • $defaultFn(() => new Date()) JavaScriptでデフォルトを設定します。次のようなSQLのデフォルトは、 unixepoch() SQLite 3.38以降が必要ですが、古いAndroidのシステムSQLiteにはありません。
  • mode: 'boolean' 保存します 1/0。Drizzleは双方向に変換します。
  • relations() を支えているのは db.query.* を使用して with: {...}。外部キーだけでは、リレーショナルクエリには不十分です。

接続を開く

コミュニティプラグインは接続のレジストリを保持します。ライブリロードやホットモジュール置換時に code が2回実行される可能性があるため、新しい接続を作成するのではなく、既存の接続を再利用してください。

// src/db/connection.ts
import {
  CapacitorSQLite,
  SQLiteConnection,
  type SQLiteDBConnection,
} from '@capacitor-community/sqlite';

const sqlite = new SQLiteConnection(CapacitorSQLite);

export async function openConnection(name: string): Promise<SQLiteDBConnection> {
  const consistent = (await sqlite.checkConnectionsConsistency()).result;
  const exists = (await sqlite.isConnection(name, false)).result;

  const conn =
    consistent && exists
      ? await sqlite.retrieveConnection(name, false)
      : await sqlite.createConnection(name, false, 'no-encryption', 1, false);

  await conn.open();
  await conn.execute('PRAGMA foreign_keys = ON', false);
  return conn;
}

プラグインはファイル名に SQLite.db を付加するため、 app に変わります appSQLite.db ディスク上に保存されます。Web では、プラグインには jeep-sqlite カスタム要素と sqlite.initWebStore() が最初の接続前に必要です。ブラウザをターゲットにする場合は、プラグインの Web セットアップに従ってください。

sqlite-proxy ドライバー

これが統合の中核です。Drizzle は SQL、パラメータ、および method:

  • run:行は不要(INSERT、UPDATE、DELETE、DDL、トランザクション制御)。
  • all:すべての行を配列として返します。
  • get:最初の行を配列として、または undefined.
  • values:すべての行を配列として(raw values() 呼び出しや内部処理で使用)。
// src/db/client.ts
import type { SQLiteDBConnection } from '@capacitor-community/sqlite';
import { drizzle } from 'drizzle-orm/sqlite-proxy';
import * as schema from './schema';

const READ = /^\s*(select|pragma|with|explain)\b/i;
const TX_CONTROL = /^\s*(begin|commit|rollback|savepoint|release)\b/i;

export function createDb(conn: SQLiteDBConnection) {
  return drizzle(
    async (sql, params, method) => {
      // Transaction control: no params, no rows, no implicit wrapping
      if (TX_CONTROL.test(sql)) {
        await conn.execute(sql, false);
        return { rows: [] };
      }

      let rows: Record<string, unknown>[];
      if (READ.test(sql)) {
        rows = (await conn.query(sql, params)).values ?? [];
      } else {
        // 'all' returns rows for INSERT/UPDATE/DELETE ... RETURNING
        const res = await conn.run(sql, params, false, method === 'run' ? 'no' : 'all');
        rows = res.changes?.values ?? [];
      }

      if (method === 'run') return { rows: [] };

      // Drizzle maps by position. The plugin keeps column order in each object.
      const arrays = rows.map((row) => Object.values(row));
      return { rows: method === 'get' ? (arrays[0] as any) : arrays };
    },
    { schema },
  );
}

export type AppDb = ReturnType<typeof createDb>;

各分岐が存在する理由:

  • TX_CONTROL:Drizzle は実装しています db.transaction() を送信することで begin, commit, rollbackや savepoint/release ネストされたトランザクション用です。これらは execute(sql, false)、TypeORM の公式 Capacitor ドライバーが〜に使うのと同じパスを通ります BEGIN/COMMIT.
  • false を第3引数として run():プラグインのデフォルトは各ステートメントを独自のトランザクションでラップするので、Drizzle の明示的なトランザクションと衝突します begin.
  • returnMode 'all':〜を .returning() INSERT、UPDATE、DELETE で動作するようにします。
  • Object.values(row):キー付きの行を位置配列に戻します。

この関数そのものを、プラグインと同じように行を返すインメモリ SQLite に対して実行しました。〜を使った INSERT .returning()、フィルター付き SELECT、 .get()、リレーショナルクエリ、トランザクション、およびエラー発生時のロールバックはすべて正しい結果を返します。

一度だけ初期化

// src/db/index.ts
import { openConnection } from './connection';
import { createDb, type AppDb } from './client';
import { runMigrations } from './migrate';
import bundle from '../../drizzle/migrations';

let dbPromise: Promise<AppDb> | null = null;

export function getDb(): Promise<AppDb> {
  if (!dbPromise) {
    dbPromise = (async () => {
      const conn = await openConnection('app');
      await runMigrations(conn, bundle);
      return createDb(conn);
    })();
  }
  return dbPromise;
}

Drizzle Kit によるマイグレーション

Drizzle 組み込みの sqlite-proxy マイグレーターが読み取ります .sql Nodeの fs、WebViewにはない機能です。解決策は、Drizzle KitにSQLをJavaScriptモジュールとしてbundleさせ、自分で適用することです。

1. Drizzle Kitの設定

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

export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'sqlite',
  driver: 'expo',
});

driver: 'expo' Expoが必要という意味ではありません。Drizzle Kitに追加で書き出すよう指示します。 drizzle/migrations.js、これはジャーナルと各 .sql ファイルをインポートします。これが、Node以外のランタイムに必要なフォーマットです。

2. 生成

bunx drizzle-kit generate

次のものが生成されます drizzle/0000_<name>.sql, drizzle/meta/_journal.json と drizzle/migrations.js. 3つすべてをコミットしてください。コマンドを generate スキーマ変更のたびに再実行してください。すでにリリースしたSQLファイルは編集しないでください。

3. bundlerに.sqlをテキストとしてimportさせる

migrations.js のような行があります import m0000 from './0000_fearless_selene.sql'。Vite はこれを認識しません .sql。別の依存関係を追加する代わりに、小さなプラグインを追加します:

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    {
      name: 'sql-as-text',
      transform(code, id) {
        if (id.endsWith('.sql')) {
          return { code: `export default ${JSON.stringify(code)};`, map: null };
        }
      },
    },
  ],
});

TypeScript がインポートを受け入れるように、型宣言も追加します:

// src/sql.d.ts
declare module '*.sql' {
  const content: string;
  export default content;
}

webpack (Angular カスタムビルダー、古いセットアップ) を使用する場合、 asset/source 用の .sql ルールを代わりに使用します。

4. 起動時にマイグレーションを適用する

// src/db/migrate.ts
import type { SQLiteDBConnection } from '@capacitor-community/sqlite';

interface MigrationBundle {
  journal: { entries: { idx: number; when: number; tag: string }[] };
  migrations: Record<string, string>;
}

export async function runMigrations(conn: SQLiteDBConnection, bundle: MigrationBundle) {
  await conn.execute(
    `CREATE TABLE IF NOT EXISTS __drizzle_migrations (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       hash TEXT NOT NULL,
       created_at NUMERIC
     )`,
    false,
  );

  const res = await conn.query(
    'SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1',
  );
  const lastApplied = Number(res.values?.[0]?.created_at ?? 0);

  for (const entry of bundle.journal.entries) {
    if (entry.when <= lastApplied) continue;

    const key = `m${entry.idx.toString().padStart(4, '0')}`;
    const sqlText = bundle.migrations[key];
    if (!sqlText) throw new Error(`Missing migration ${entry.tag}`);

    const statements = sqlText
      .split('--> statement-breakpoint')
      .map((s) => s.trim())
      .filter(Boolean);

    await conn.beginTransaction();
    try {
      for (const statement of statements) {
        await conn.execute(statement, false);
      }
      await conn.run(
        'INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)',
        [entry.tag, entry.when],
        false,
      );
      await conn.commitTransaction();
    } catch (error) {
      await conn.rollbackTransaction();
      throw error;
    }
  }
}

これは Drizzle 自身のマイグレータが採用しているルールと同じです。ジャーナルのタイムスタンプが記録済みの最新のものより新しい場合、そのマイグレーションは未適用とみなされます。各マイグレーションは独立したトランザクション内で実行されるため、失敗しても前の状態は保持されます。すべての起動時に呼び出すコストは低いです。

JavaScript を以下で配布する場合 Capgo Live Updates、新しいマイグレーションはストアリリースなしでユーザーに届きます。ロールバックで古いバンドルに戻った際に新しいスキーマに対して実行されるため、追加的な変更(新しいテーブル、新しい NULL 許容カラムなど)に留めてください。

クエリ

import { and, desc, eq, sql } from 'drizzle-orm';
import { getDb } from './db';
import { posts, users } from './db/schema';

const db = await getDb();

// Insert and get the row back
const [ada] = await db
  .insert(users)
  .values({ name: 'Ada', email: 'ada@example.com' })
  .returning();

// Bulk insert
await db.insert(posts).values([
  { authorId: ada.id, title: 'Draft' },
  { authorId: ada.id, title: 'Live', published: true },
]);

// Select with filters
const live = await db
  .select()
  .from(posts)
  .where(and(eq(posts.authorId, ada.id), eq(posts.published, true)))
  .orderBy(desc(posts.id));

// Single row or undefined
const user = await db.select().from(users).where(eq(users.email, 'ada@example.com')).get();

// Update and delete
await db.update(posts).set({ published: true }).where(eq(posts.id, live[0].id));
await db.delete(posts).where(eq(posts.published, false));

// Aggregate
const [{ count }] = await db
  .select({ count: sql<number>`count(*)` })
  .from(posts);

リレーショナルクエリ

const authors = await db.query.users.findMany({
  with: { posts: { where: (p, { eq }) => eq(p.published, true) } },
});

const one = await db.query.users.findFirst({
  where: (u, { eq }) => eq(u.id, ada.id),
  with: { posts: true },
});

DrizzleはSQLiteのJSON関数を使ってこれらを組み立て、結果をパースします。iOSと最近のAndroidバージョンで動作します。もしお使いの minSdkVersion が古い場合は、 SELECT json('{}') 最も古いテストデバイスで確認してください。

Join:重複するカラム名をエイリアスする

これは、オブジェクト行を使うプロキシドライバに共通する唯一の落とし穴です。Drizzleは AS JOINしたカラムにエイリアスを付けないため、次のクエリでは:

// Wrong: both columns are named "id" in the result set
await db
  .select({ postId: posts.id, userId: users.id })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id));

という2つのカラムが生成されます。 idこれらは行オブジェクト内で1つのキーにまとまり、配列の値が1つ足りなくなり、Drizzleは誤ったフィールドに値を割り当ててしまいます。私のテストでは、 postId ユーザーIDが返ってきて、 userId は undefined. エラーはスローされません。

明示的にエイリアスを付けます:

await db
  .select({
    postId: sql<number>`${posts.id}`.as('post_id'),
    userId: sql<number>`${users.id}`.as('user_id'),
    title: posts.title,
    author: users.name,
  })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id));

異なる名前の列を選択する場合 (posts.title, users.name) は問題ありません。避けるべきなのは、 db.select().from(a).innerJoin(b, ...) を、両方のテーブルで id または created_atのような列名が重複している場合に、フィールドリストを指定せずに使用することです。リレーショナルクエリではこの問題は発生しません。

トランザクション

await db.transaction(async (tx) => {
  const [user] = await tx
    .insert(users)
    .values({ name: 'Grace', email: 'grace@example.com' })
    .returning();

  await tx.insert(posts).values({ authorId: user.id, title: 'Hello' });

  // Nested transactions become savepoints
  await tx.transaction(async (inner) => {
    await inner.insert(posts).values({ authorId: user.id, title: 'Maybe' });
  });
});

コールバックがスローすると、Drizzle は rollbackを送信します。また、 tx.rollback() を呼び出すと、例外をスローしてトランザクションも中断されます。

データベースごとに接続は1つしかないため、トランザクションは短く保ち、~しないでください。 await その内部でネットワーク呼び出しが行われます。同じ接続での他の書き込みは、開いているトランザクション内で実行されます。

トラブルシューティング

起動時に「Connection already exists」エラーが発生します。 初期化が2回実行されています。通常はホットリロードが原因です。上に示した checkConnectionsConsistency と retrieveConnection パターンを使用してください。

cannot start a transaction within a transaction. ある run() 呼び出しが行われましたが、 false 第3引数として指定されなかったため、プラグインがDrizzleのトランザクション内で独自のトランザクションを開始しました。

起動のたびにマイグレーションが実行され、次のエラーで失敗します。 table already exists. この __drizzle_migrations insert が欠落しているか、ロールバックされています。journal ファイルを確認してください。 when bundle 内の値がテーブルの行と一致しています。

ビルドエラー Failed to parse source for import analysis で .sql. Vite の transform プラグインが見つからないか、ファイルを解析しようとする別のプラグインより後に実行されています。まずは plugins 配列の先頭に置いてください。

.returning() 空の配列を返します。 書き込みが完了しました run return モードで no。ドライバーが渡すことを確認してください。 'all' いつ method ではない run.

日付が 1000 倍ずれています。 mode: 'timestamp' 秒単位で保存、 mode: 'timestamp_ms' ミリ秒単位で保存。カラムごとに1つ選び、生の Date.now() insertと混在させないこと。

別の選択肢を選ぶべきとき

ネイティブプラグインを追加すると、iOS と Android アプリの再ビルドが必要です。Mac が手元にない場合、 Capgo のビルド クラウド上でビルドと署名の両方を行います。

Capacitor アプリ向けの Live Updates

Web レイヤーのバグが本番で発生したら、App Store の審査を何日も待たずに Capgo で修正を配信しましょう。ネイティブの変更は通常の審査フローに残したまま、ユーザーはバックグラウンドでアップデートを受け取れます。

最新のブログ記事

Capgoは、真にプロフェッショナルなモバイルアプリを作るために必要な最高のインサイトを提供します。