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

Capacitor と SQLite で Kysely を使う方法

Capacitor と SQLite で Kysely を活用:高速な SQL を実現する Kysely dialect の作成、テーブルの型定義、クエリの実行、トランザクション、およびアプリ内マイグレーションについて解説します。

Capacitor と SQLite で Kysely を使う方法

Kysely が Capacitor と SQLite と相性が良い理由は、プラットフォームから必要なものが一つだけだからです。それは、パラメータ付きの SQL 文字列を送信し、結果をオブジェクトとして返すドライバーです。本ガイドでは、そのドライバーを構築し、 @capgo/capacitor-fast-sql型定義されたテーブル、CRUD、join、トランザクション、そして iOS、Android、Web 上でアプリ内で実行されるマイグレーションについて説明します。

codeの生成も重いランタイムも不要で、コンパイル時にチェック可能なSQLを利用できます。

KyselyがモバイルのSQLiteに適している理由

KyselyはORMではなくクエリビルダーです。SQLのように読めるコードを書き、TypeScriptがすべてのテーブル名やカラム名をチェックし、結果の型を推論します。

  • デコレータやリフレクションを使用しないため、Viteやesbuildでそのまま動作します。
  • codeの生成ステップもありません。あなたの Database interfaceがスキーマの定義となります。
  • ランタイムが軽量でツリーシェイキングもしやすく、アプリのbundleサイズや live updateの ダウンロードサイズに影響します。
  • ドライバーのインターフェースは非常にシンプルです。KyselyにはすでにSQLiteのクエリコンパイラ、アダプター、イントロスペクターが含まれているため、トランスポート層を書くだけで済みます。

アプリにまだSQLiteをセットアップしていない場合は、こちらから始めてください: CapacitorアプリでSQLiteを使用する方法. Fast SQL が必要とするネイティブ設定(NSAllowsLocalNetworking iOS の設定や Android の localhost cleartext ルールなど)をカバーしています。

インストール

bun add kysely @capgo/capacitor-fast-sql
bunx cap sync

このガイドでは Kysely 0.29 と Fast SQL 8.2 を使用します。Kysely 0.29 では、マイグレーションクラスが kysely/migration エントリポイントに移動しました。

Fast SQL dialect を記述する

Kysely Dialect には 4 つのファクトリがあります。そのうち 3 つは Kysely の SQLite サポートから直接提供されるもので、4 つ目が当社のドライバーです。

// src/db/fast-sql-dialect.ts
import {
  CompiledQuery,
  SqliteAdapter,
  SqliteIntrospector,
  SqliteQueryCompiler,
  type DatabaseConnection,
  type DatabaseIntrospector,
  type Dialect,
  type DialectAdapter,
  type Driver,
  type Kysely,
  type QueryCompiler,
  type QueryResult,
} from 'kysely';
import {
  FastSQL,
  type SQLConnection,
  type SQLConnectionOptions,
  type SQLValue,
} from '@capgo/capacitor-fast-sql';

// Kysely passes JS values as-is. Normalize them to what SQLite stores.
function toSqlValue(value: unknown): SQLValue {
  if (value === undefined || value === null) return null;
  if (typeof value === 'boolean') return value ? 1 : 0;
  if (value instanceof Date) return value.toISOString();
  if (typeof value === 'bigint') return Number(value);
  if (value instanceof Uint8Array) return value;
  if (typeof value === 'object') return JSON.stringify(value);
  return value as SQLValue;
}

class FastSqlConnection implements DatabaseConnection {
  constructor(readonly db: SQLConnection) {}

  async executeQuery<R>(compiled: CompiledQuery): Promise<QueryResult<R>> {
    const result = await this.db.execute(compiled.sql, compiled.parameters.map(toSqlValue));
    return {
      rows: result.rows as R[],
      numAffectedRows: result.rowsAffected != null ? BigInt(result.rowsAffected) : undefined,
      insertId: result.insertId != null ? BigInt(result.insertId) : undefined,
    };
  }

  // eslint-disable-next-line require-yield
  async *streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
    throw new Error('Streaming is not supported by the Fast SQL driver');
  }
}

// One native connection per database, so serialize access to it.
class ConnectionMutex {
  #pending: Promise<void> = Promise.resolve();
  #release?: () => void;

  async lock(): Promise<void> {
    const previous = this.#pending;
    let release!: () => void;
    this.#pending = new Promise<void>((resolve) => (release = resolve));
    await previous;
    this.#release = release;
  }

  unlock(): void {
    const release = this.#release;
    this.#release = undefined;
    release?.();
  }
}

class FastSqlDriver implements Driver {
  #connection?: FastSqlConnection;
  readonly #mutex = new ConnectionMutex();

  constructor(private readonly options: SQLConnectionOptions) {}

  async init(): Promise<void> {
    const db = await FastSQL.connect(this.options);
    this.#connection = new FastSqlConnection(db);
  }

  async acquireConnection(): Promise<DatabaseConnection> {
    await this.#mutex.lock();
    return this.#connection!;
  }

  async releaseConnection(): Promise<void> {
    this.#mutex.unlock();
  }

  async beginTransaction(connection: DatabaseConnection): Promise<void> {
    await (connection as FastSqlConnection).db.beginTransaction();
  }

  async commitTransaction(connection: DatabaseConnection): Promise<void> {
    await (connection as FastSqlConnection).db.commit();
  }

  async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
    await (connection as FastSqlConnection).db.rollback();
  }

  async destroy(): Promise<void> {
    if (this.#connection) {
      await FastSQL.disconnect(this.options.database);
      this.#connection = undefined;
    }
  }
}

export class FastSqlDialect implements Dialect {
  constructor(private readonly options: SQLConnectionOptions) {}

  createDriver(): Driver {
    return new FastSqlDriver(this.options);
  }

  createQueryCompiler(): QueryCompiler {
    return new SqliteQueryCompiler();
  }

  createAdapter(): DialectAdapter {
    return new SqliteAdapter();
  }

  createIntrospector(db: Kysely<any>): DatabaseIntrospector {
    return new SqliteIntrospector(db);
  }
}

各パーツの役割:

  • toSqlValue boolean を 1/0、日付を ISO 文字列に、プレーンオブジェクトを JSON に変換します。SQLite には boolean 型や date 型が存在せず、Android ではプラグインが読み取りクエリのパラメータをテキストとしてバインドするため、 true 文字列として届くことになります。 'true' そして、決して一致しません。 1.
  • FastSqlConnection.executeQuery 呼び出し execute() Fast SQL接続上で行われます。これは、iOSとAndroidではプラグインのローカルHTTPチャネルを介し、WebではSQLite Wasmを介して動作します。取得される行はすでにカラム名をキーとしたオブジェクト形式になっており、これはまさにKyselyが期待する形式です。
  • ConnectionMutex 重要です。Fast SQLはデータベースごとに1つのネイティブ接続を維持します。ロックがないと、2つのKyselyトランザクションが同時に実行された際、単一の接続上でステートメントが混在してしまいます。Kysely自身のSQLiteドライバーも同様のパターンを採用しています。
  • トランザクションは プラグインのネイティブ beginTransaction, commit と rollback 生の begin SQLではなく、プラグインを経由して実行されるため、両プラットフォームでトランザクションの状態が正しく追跡されます。
  • Savepointは 実装されていません。制御されたトランザクション内で savepoint() を呼び出すと、Kyselyはエラーをスローします。 The savepoint method is not supported by this driver. 追加 savepoint, rollbackToSavepoint と releaseSavepoint 必要に応じて、コンパイルされたsavepoint SQLを実行するメソッドを追加します。

テーブルの定義

この Database interface はテーブル名を型にマッピングします。データベースが補完する値については、Kyselyのヘルパー型を使用して定義してください。

// src/db/schema.ts
import type { ColumnType, Generated, Insertable, Selectable, Updateable } from 'kysely';

export interface ProjectTable {
  id: Generated<number>;
  name: string;
  created_at: ColumnType<string, string | undefined, never>;
}

export interface TaskTable {
  id: Generated<number>;
  project_id: number;
  title: string;
  done: ColumnType<number, number | boolean | undefined, number | boolean>;
  due_at: string | null;
}

export interface Database {
  projects: ProjectTable;
  tasks: TaskTable;
}

export type Task = Selectable<TaskTable>;
export type NewTask = Insertable<TaskTable>;
export type TaskUpdate = Updateable<TaskTable>;

Generated<number> を id insert時に任意(optional)にします。 ColumnType<Select, Insert, Update> により、ある型として読み込みつつ別の型を受け取ることが可能になります。ここでは、 done として読み込みますが、書き込み時にはbooleanを受け入れます。ドライバーがそれを 1/0. never をupdateに指定すると、そのカラムは変更不可になります。

Kysely インスタンスの作成

// src/db/index.ts
import { Kysely } from 'kysely';
import { FastSqlDialect } from './fast-sql-dialect';
import type { Database } from './schema';
import { runMigrations } from './migrations';

let instance: Promise<Kysely<Database>> | null = null;

export function getDb(): Promise<Kysely<Database>> {
  if (!instance) {
    instance = (async () => {
      const db = new Kysely<Database>({
        dialect: new FastSqlDialect({
          database: 'tasks',
          walMode: true,
          performancePresets: true, // also enables foreign_keys
        }),
      });
      await runMigrations(db);
      return db;
    })();
  }
  return instance;
}

Kyselyはドライバーの init() を最初のクエリ時に遅延して呼び出すため、インスタンスの生成は低コストです。

アプリ内のマイグレーション

Kyselyの FileMigrationProvider はNodeの fsでフォルダを読み込みますが、これはWebViewには存在しません。代わりにcodeでマイグレーションを提供してください。また、 Migrator 適用済みの名前を kysely_migration テーブルに保存し、未適用のものを順番に実行します。

// src/db/migrations.ts
import { sql, type Kysely } from 'kysely';
import { Migrator, type Migration, type MigrationProvider } from 'kysely/migration';

const migrations: Record<string, Migration> = {
  '0001_init': {
    async up(db: Kysely<any>) {
      await db.schema
        .createTable('projects')
        .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
        .addColumn('name', 'text', (col) => col.notNull())
        .addColumn('created_at', 'text', (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))
        .execute();

      await db.schema
        .createTable('tasks')
        .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
        .addColumn('project_id', 'integer', (col) =>
          col.notNull().references('projects.id').onDelete('cascade'),
        )
        .addColumn('title', 'text', (col) => col.notNull())
        .addColumn('done', 'integer', (col) => col.notNull().defaultTo(0))
        .execute();
    },
  },
  '0002_due_date': {
    async up(db: Kysely<any>) {
      await db.schema.alterTable('tasks').addColumn('due_at', 'text').execute();
      await db.schema.createIndex('idx_tasks_project').on('tasks').column('project_id').execute();
    },
  },
};

class InCodeMigrationProvider implements MigrationProvider {
  async getMigrations(): Promise<Record<string, Migration>> {
    return migrations;
  }
}

export async function runMigrations(db: Kysely<any>): Promise<void> {
  const migrator = new Migrator({ db, provider: new InCodeMigrationProvider() });
  const { error, results } = await migrator.migrateToLatest();

  results?.forEach((r) => {
    if (r.status === 'Error') console.error(`Migration ${r.migrationName} failed`);
  });
  if (error) throw error;
}

注意:

  • 名前はアルファベット順にソートされるため、ゼロ埋めの接頭辞を使用してください。
  • migrateToLatest() はエラーを投げません。返り値は { error, results }なので、確認してください error 自分自身やアプリが中途半端にマイグレーションされたスキーマで起動してしまうことがあります。
  • 起動のたびに呼び出してもコストは低いです。保留中のマイグレーションがなければ、マイグレーションテーブルを読んで戻ります。
  • SQLiteはトランザクションDDLをサポートしているため、Migratorは実行をトランザクションでラップします。これは上記のドライバーで動作します。
  • マイグレーションは次の対象に対して書いてください: Kysely<any>、現在の Database 型ではなく。スキーマが進化しても、古いマイグレーションはコンパイルできる状態を保つ必要があります。

クエリ

Insert

const db = await getDb();

const result = await db
  .insertInto('projects')
  .values({ name: 'Groceries' })
  .executeTakeFirst();

const projectId = Number(result.insertId);

await db
  .insertInto('tasks')
  .values([
    { project_id: projectId, title: 'Milk' },
    { project_id: projectId, title: 'Bread', done: true },
  ])
  .execute();

insertId は、Kyselyの bigint です。変換するには Number() 通常の行ID用です。

避けてください .returning() 現在のFast SQLバージョンでAndroidをターゲットにする場合、Androidは以下の文で始まるステートメントの行のみを返します: SELECT, PRAGMA または EXPLAIN、そのため、 INSERT ... RETURNING は実行されますが、何も返されません。iOSとWebでは行が返されます。 insertId はどこでも動作します。

Select

const openTasks = await db
  .selectFrom('tasks')
  .select(['id', 'title', 'due_at'])
  .where('project_id', '=', projectId)
  .where('done', '=', 0)
  .orderBy('id')
  .execute();
// openTasks: { id: number; title: string; due_at: string | null }[]

const project = await db
  .selectFrom('projects')
  .selectAll()
  .where('id', '=', projectId)
  .executeTakeFirst(); // undefined if not found

Joinと集計

const summary = await db
  .selectFrom('projects as p')
  .leftJoin('tasks as t', 't.project_id', 'p.id')
  .select((eb) => [
    'p.id',
    'p.name',
    eb.fn.count<number>('t.id').as('task_count'),
    eb.fn.sum<number>('t.done').as('done_count'),
  ])
  .groupBy('p.id')
  .execute();

Kyselyはjoin後の存在するカラムを把握しており、エイリアス(task_count, done_count)が結果の型に反映されます。行はカラム名をキーとしたオブジェクトとして返されるため、選択する各カラムには一意の名前を付けてください。両方のカラムが id という名前だと、行オブジェクト内で上書きされてしまいます。これはオブジェクトを返すすべてのSQLiteドライバに共通する挙動です。

JSONヘルパーによるネストされた結果

import { jsonArrayFrom } from 'kysely/helpers/sqlite';

const projects = await db
  .selectFrom('projects')
  .select((eb) => [
    'projects.id',
    'projects.name',
    jsonArrayFrom(
      eb.selectFrom('tasks')
        .select(['tasks.id', 'tasks.title'])
        .whereRef('tasks.project_id', '=', 'projects.id'),
    ).as('tasks'),
  ])
  .execute();

これはSQLiteのJSON関数に依存しています。これらはiOS、Webビルド、および最近のAndroidリリースでは組み込まれていますが、もし minSdkVersion が古い場合は、まず SELECT json('{}') 最も古いテストデバイスで実行してください。

更新と削除

await db.updateTable('tasks').set({ done: true }).where('id', '=', taskId).execute();

const deleted = await db.deleteFrom('tasks').where('done', '=', 1).executeTakeFirst();
console.log(Number(deleted.numDeletedRows));

トランザクション

await db.transaction().execute(async (trx) => {
  const { insertId } = await trx
    .insertInto('projects')
    .values({ name: 'Trip' })
    .executeTakeFirstOrThrow();

  await trx
    .insertInto('tasks')
    .values(['Passport', 'Tickets', 'Charger'].map((title) => ({
      project_id: Number(insertId),
      title,
    })))
    .execute();
});

コールバックが例外をスローすると、ドライバーはロールバックします。コールバック内では trxではなく dbを使用してください。また、 db へのクエリは、トランザクションが保持するミューテックスを待機するため、トランザクションが終了するまでデッドロックします。

必要なときにRaw SQL

import { sql } from 'kysely';

const { rows } = await sql<{ journal_mode: string }>`PRAGMA journal_mode`.execute(db);

この sql タグは補間された値をパラメータとしてバインドするため、ユーザー入力に対しても安全です。 sql.raw() は、信頼できるフラグメントに対してのみ使用してください。

代わりにコミュニティプラグインを使用する場合

もし〜を使用している場合は、 @capacitor-community/sqlitedialect、adapter、compiler、mutexはそのまま保持し、connectionのみを以下のように置き換えてください:

import type { SQLiteDBConnection } from '@capacitor-community/sqlite';

class CommunityConnection implements DatabaseConnection {
  constructor(readonly db: SQLiteDBConnection) {}

  async executeQuery<R>(compiled: CompiledQuery): Promise<QueryResult<R>> {
    const params = compiled.parameters.map(toSqlValue);
    if (/^\s*(select|pragma|with)\b/i.test(compiled.sql)) {
      const res = await this.db.query(compiled.sql, params);
      return { rows: (res.values ?? []) as R[] };
    }
    const res = await this.db.run(compiled.sql, params, false, 'all');
    return {
      rows: (res.changes?.values ?? []) as R[],
      numAffectedRows: BigInt(res.changes?.changes ?? 0),
      insertId: res.changes?.lastId != null ? BigInt(res.changes.lastId) : undefined,
    };
  }

  async *streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
    throw new Error('Streaming is not supported');
  }
}

を false の第3引数として渡します。 run()のデフォルト設定では、各ステートメントが個別のトランザクションでラップされるため、Kyselyのトランザクションが機能しません。プラグインの beginTransaction(), commitTransaction() と rollbackTransaction() をドライバー内で使用してください。

トラブルシューティング

Cannot find module 'kysely/migration'. Kyselyのバージョンが0.29より古い場合は、 Migrator が kysely から直接エクスポートされています。インポート文を更新するか、変更してください。

クエリがタイムアウトします。 使用した db 内側で db.transaction().execute(). 次のを使用します。 trx 引数を使用します。

done = true Androidでは一致しません。 変換されずにboolean値がプラグインに渡されました。すべての値が適切に処理されているか確認してください。 toSqlValue、および〜内の値を含む sql テンプレートリテラルも対象です。

no such table: kysely_migration 初回起動時にエラーが発生します。 Migratorがテーブルを自動作成するため、これは通常、 init() 以前の処理に失敗したことを意味します。ネイティブ設定(iOSのローカルネットワーク、Androidのcleartext localhostルール)を確認し、最初のエラーをログに出力してください。

型定義ではカラムが存在することになっているのに、SQLiteでは存在しないと言われる。 この Database interfaceとマイグレーションに乖離が生じたためです。interfaceはデータベースから自動生成されるわけではないので、同じコミットで両方を更新してください。

概要

Capacitor で動作させるには、Kyselyには接続処理、 execute()ミューテックス、トランザクションフックなど、約80行のブリッジコードが必要です。その代わり、iOS/Android上のFast SQLネイティブSQLite、およびWeb上のSQLite Wasmに対して、完全に型付けされたSQLを利用できます。

関連記事: Capacitor と Drizzle ORM スキーマファーストな定義を好む場合はこちら、 Capacitor と TypeORM デコレータやリポジトリを使用する場合はこちら、および Fast SQL プラグインのドキュメント すべての接続オプションに対して。

Capacitor アプリ向けの Live Update

Webレイヤーのバグが本番に出たときは、アプリストアの審査を何日も待たずに Capgo で修正を配信できます。ネイティブの変更は通常の審査フローに乗せたまま、ユーザーにはバックグラウンドでアップデートが届きます。

最新のブログ記事

Capgo は、本格的なプロフェッショナルモバイルアプリの開発に必要な最高のインサイトを提供します。