跳过主要内容

如何使用 Kysely 与 Capacitor 和 SQLite

使用 Kysely 与 Capacitor 和 SQLite: 为 Fast SQL 编写一个小的 Kysely 方言,类型化表格,运行查询,事务和在-app 迁移。

如何使用 Kysely 与 Capacitor 和 SQLite

Kysely 与 Capacitor 和 SQLite 的结合非常好,因为它只需要从平台中获取一个东西:一个驱动程序,它发送一个带参数的 SQL 字符串并返回行作为对象。这篇指南为该驱动程序建立了一个构建块, @capgo/capacitor-fast-sql然后涵盖了类型化表格、CRUD、连接、事务和在-app 迁移,这些迁移在 iOS、Android 和 web 上运行。

您可以在编译时检查 SQL,而无需 code 生成,并且无需使用重量级的运行时。

为什么 Kysely 适合移动 SQLite

Kysely 是一个查询构建器,而不是 ORM。您编写的内容类似于 SQL,TypeScript 检查每个表和列名称并推断结果类型。

  • 没有装饰器或反射,因此它可以与 Vite 和 esbuild 一起工作。
  • 没有 code 生成步骤。您的接口是 schema 协议。 Database 小的运行时可以很好地 tree-shake,这对于应用程序包大小和下载大小都很重要。
  • 驱动程序接口很小。Kysely 已经包含 SQLite 查询编译器、适配器和introspector,因此您只需要编写传输代码。 live update 如何在 __CAPGO_KEEP_0__ 应用程序中使用 SQLite
  • 开始。它涵盖了 Fast SQL 需要的原生配置。

如果您有 SQLite 配置,请继续阅读本文。 如何在Capacitor应用中使用SQLite本文将介绍 Kysely 如何与 Capacitor SQLite 一起工作。NSAllowsLocalNetworking on iOS 和一个本地主机的明文规则在 Android 上)

安装

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

本指南使用 Kysely 0.29 和 Fast SQL 8.2。 在 Kysely 0.29 中,迁移类已移动到 kysely/migration 入口点。

写入 Fast SQL 方言

一个 Kysely Dialect 有四个工厂。 三个来自 Kysely 的 SQLite 支持。 第四个是我们的驱动程序。

// 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 将布尔值转换为 1/0日期转换为 ISO 字符串,和普通对象转换为 JSON。 SQLite 没有布尔值或日期类型,且在 Android 上的插件将读取查询参数绑定为文本,因此 true 将到达作为字符串 'true' 并且永远不会匹配一个 1.
  • FastSqlConnection.executeQuery calls execute() 在 Fast SQL 连接上,Fast SQL 使用插件的本地 HTTP 通道在 iOS 和 Android 上,通过 SQLite Wasm 在 Web 上。结果,行已经以列名为键的对象形式返回,这正是 Kysely 期望的。
  • ConnectionMutex Fast SQL 的一个关键特性是它保持一个本地连接。没有锁定,两个并发 Kysely 事务将在一个连接上交替执行语句。Kysely 的 SQLite 驱动程序使用相同的模式。
  • Transactions 通过插件的本地 beginTransaction, commit 和 rollback context: HTML 文本片段来自更长的 Capgo UI 字符串(父键 `compare_step_capacitor_text`)。页面/区域:Capgo 营销网站。角色:网站副本句子。见于:页面 alternatives/expo.astro,页面 alternatives/median.astro。消息键 `compare_step_capacitor_text`(Compare Step Capacitor Text)。| 页面/区域:Capgo 营销网站。角色:短 UI 标签或导航项。见于:页面 trust.astro。消息键 `and`(And)。 begin 而不是原始
  • Savepoints Savepoints savepoint() 没有实现。如果您在一个受控事务上调用 The savepoint method is not supported by this driver,Kysely 将抛出 savepoint, rollbackToSavepoint 和 releaseSavepoint 如果需要的话,编译后的保存点SQL将运行这些方法。

描述您的表

The Database 接口将表名映射到行类型。使用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 在插入时可选。 ColumnType<Select, Insert, Update> 让一个列读取一个类型并接受另一个类型,例如 done 读取为数字,但在写入时接受布尔值,驱动程序将其转换为 1/0. never 在更新时意味着列不能被更改。

创建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 中提供迁移。 The 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 Kysely的
  • 每次启动时调用它是便宜的。如果没有任何待处理的事务,它会读取迁移表并返回。
  • SQLite 支持事务性 DDL,因此迁移器将运行包裹在事务中。与上述驱动程序一起工作。
  • 写迁移以 Kysely<any>而不是当前 Database 类型。旧的迁移必须在模式演变后仍然编译。

查询

插入

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 是個 bigint 转换为 Number() 以正常行 ID 进行

避免 .returning() 如果您使用当前的 Fast SQL 版本目标 Android,则不要使用:Android 只返回以该语句开头的行 SELECT, PRAGMA 或 EXPLAIN,所以 INSERT ... RETURNING 运行但返回空结果。 iOS 和 web 返回行。 读取 insertId 无处不在。

选择

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

连接和聚合

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 知道连接后存在哪些列,并且别名((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 等待互斥体,互斥体由事务持有,所以它们会死锁直到事务结束。

原始 SQL 当您需要它时

import { sql } from 'kysely';

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

The sql 标签将插值值绑定为参数,因此它是安全的。使用 sql.raw() 仅限于信任的片段。

使用社区插件代替

如果您正在使用 @capacitor-community/sqlite保留方言、适配器、编译器和互斥锁,替换连接:

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');
  }
}

Pass false 作为第三个参数传递给 run(). 其默认情况下会将每个语句包裹在自己的事务中,这会打破 Kysely 事务。使用插件的 beginTransaction(), commitTransaction() and rollbackTransaction() 在驱动器中。

Troubleshooting

Cannot find module 'kysely/migration'. 您正在使用 Kysely 的旧版本(0.29 之前)。 Migrator 是从 kysely 直接更新或修改导入。

查询永远卡住。 您使用 db 内部 db.transaction().execute()。使用 trx 参数。

done = true 在 Android 上 一个布尔值到插件中没有转换。确保每个值都经过 toSqlValue,包括 sql 模板文字中的值。

no such table: kysely_migration 在首次启动时出现错误。 迁移器创建了自己的表格,所以这通常意味着 init() 之前失败了。检查本机设置(iOS 本地网络,Android 清除本地主机规则)并记录第一个错误。

类型说一个列存在,但 SQLite 说它不存在。 The Database 接口和迁移发生了漂移。接口不是从数据库生成的,所以在同一个提交中更新它们。

Summary

Kysely需要大约80行胶水才能在Capacitor上运行:一个调用 execute(),一个互斥体,以及事务钩子。在交换中,您将获得在iOS和Android上的Fast SQL的本机SQLite以及在Web上的SQLite Wasm上面完全类型化的SQL。

相关阅读: Drizzle ORM与Capacitor 如果您更喜欢schema-first定义 TypeORM与Capacitor 用于装饰器和仓库的 Fast SQL插件文档 用于每个连接选项

Capacitor 的即时更新

当 web 层面的 bug 活跃时,通过 Capgo 将修复推送给用户,而不是等待几天的 app 商店审批。用户在后台接收更新,而原生变化仍然在正常审批路径中。

__CAPGO_KEEP_0__ 为您提供创建真正专业的移动应用所需的最佳见解

Capgo 应用中的双向通信