跳转至正文

如何在 Capacitor 和 SQLite 中使用 Drizzle ORM

用 Capacitor 和 SQLite 搭建 Drizzle ORM:可用的 sqlite-proxy 驱动、类型化 schema、关系查询、事务,以及应用内 Drizzle Kit 迁移。

Capacitor 标志,用于介绍如何在 Capacitor 和 SQLite 中使用 Drizzle ORM 的指南

Drizzle ORM 通过其 Capacitor 应用中的 sqlite-proxy driver 运行:你只需向 Drizzle 提供一个异步函数,该函数在原生 SQLite 插件上执行 SQL 并以数组形式返回行数据,Drizzle 则在其上层处理类型化查询构建器、关系查询和事务。本指南将为以下场景构建该函数: @capacitor-community/sqlite,展示了如何将 Drizzle Kit 迁移打包进 app bundle,并列出如果忽略就会悄悄破坏结果的边缘情况。

以下所有内容均使用以下版本测试: drizzle-orm 0.45 和 drizzle-kit 0.31,在 Capacitor 8 上。

为什么在移动端数据库中使用 Drizzle

  • TypeScript 中的 Schema。 表是纯 TypeScript 对象。select 和 insert 的类型会自动推断,无需 code 生成步骤。
  • SQL 风格的 API。 db.select().from(users).where(eq(users.id, 1)) 与 SQL 一一对应,因此你能推断设备上实际运行的内容。
  • 基于 Schema 生成的 Migrations。 Drizzle Kit 会对比你的 Schema 并写入 SQL 迁移文件。
  • 无需装饰器。 兼容 Vite 和 esbuild,无需额外 TypeScript 配置。

权衡在于:Drizzle 的包中没有 Capacitor 驱动,因此需要自己编写桥接代码。大约 30 行。

选择合适的 SQLite 插件

Drizzle 的 sqlite-proxy 驱动按位置映射结果行 按位置:对于 all 以及 values 它期望一个数组的数组,用于 get 单个数组,顺序与生成的 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,因此即使你不加密任何内容,应用也会自带加密 code。在回答 App Store 出口合规问题时请留意这一点。

定义 schema

// 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' 将自 epoch 以来的秒数存储为整数,并返回 Date TypeScript 中的对象。
  • $defaultFn(() => new Date()) 在 JavaScript 中设置默认值。类似 unixepoch() 需要 SQLite 3.38 或更高版本,而旧版 Android 系统自带的 SQLite 并不支持。
  • mode: 'boolean' 存储 1/0。Drizzle 支持双向转换。
  • relations() 正是其动力所在 db.query.* 配合 with: {...}。仅靠外键不足以支持关系查询。

打开连接

社区插件维护着一个连接注册表。在 live reload 或 hot module replacement 期间,你的 code 可能会运行两次,因此请复用现有连接,而不是创建第二个连接。

// 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 作为第三个参数传给 run():该插件默认会把每条语句包在各自的事务中,这与 Drizzle 的显式 begin.
  • returnMode 'all':让 .returning() 适用于 insert、update 和 delete。
  • Object.values(row):将带键的行转回位置数组。

我把这个函数原样在内存 SQLite 上跑了一遍,它返回行的方式与插件一致。使用 .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 migrator 会读取 .sql 从磁盘读取文件,而 Node 的 fs,WebView 并不具备此功能。解决方案是让 Drizzle Kit 将 SQL 打包成 JavaScript 模块,然后由你手动应用。

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,该文件会导入 journal 以及每个 .sql 文件。这正是任何非 Node 运行时所需的格式。

2. 生成

bunx drizzle-kit generate

你会得到 drizzle/0000_<name>.sql, drizzle/meta/_journal.json 以及 drizzle/migrations.js。提交这三个文件。运行 generate 每次 schema 变更后都要重新执行。不要编辑已发布的 SQL 文件。

3. 让 bundler 将 .sql 作为文本导入

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 自身迁移器使用的规则:当迁移的 journal 时间戳比已记录的最新时间戳更新时,该迁移即为待处理状态。每个迁移都在独立事务中运行,因此失败会保留之前的状态。每次启动时调用它的开销很小。

如果你使用 Capgo 实时更新发布 JavaScript,新的迁移无需商店发布即可触达用户。请保持迁移为增量式(新表、新的可空列),因为回滚到旧 bundle 时,迁移将针对较新的 schema 运行。

查询

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('{}') 在你最旧的测试设备上。

Joins:为重复的列名设置别名

这是任何基于对象行的代理驱动器的一个尖锐边缘。Drizzle 不会添加 AS 别名指向连接后的列,因此该查询:

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

会生成两个名为 id的列。它们在行对象中合并为一个键,数组少了一个值,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() 也会抛出异常并中止事务。

每个数据库只有一个连接,因此请保持事务简短,不要 await 网络调用在其中执行。同一连接上的其他写入操作将在你打开的事务内运行。

故障排除

启动时出现“Connection already exists”错误。 初始化运行了两次,通常是因为热重载。请使用 checkConnectionsConsistency 以及 retrieveConnection 上述模式。

cannot start a transaction within a transaction. 一套 run() 调用时未提供 false 作为第三个参数,因此插件在 Drizzle 的事务内部开启了独立事务。

迁移在每次启动时运行,并因以下错误失败: table already exists. 本指南中介绍的 __drizzle_migrations 插入缺失或已回滚。请检查 bundle 中的 journal when 值是否与表中的行匹配。

构建错误 Failed to parse source for import analysis 开启 .sql. Vite 转换插件缺失,或在尝试解析文件的另一个插件之后运行。请将其放在 plugins 数组的首位。

.returning() 返回空数组。 写入成功 run 使用 return 模式 no. 确保 driver 传递 'all' 何时 method 不是 run.

日期相差 1000 倍。 mode: 'timestamp' 存储秒, mode: 'timestamp_ms' 存储毫秒。每列选一种,不要与原始 Date.now() inserts 混用。

何时该选择其他方案

添加原生插件意味着需要重新构建 iOS 和 Android 应用。如果你手边没有 Mac, Capgo 构建 构建和签名都在云端完成。

为 Capacitor 应用提供 Live Updates

当 Web 层出现线上 Bug 时,通过 Capgo 发布修复,而不用等 App Store 审核好几天。用户会在后台收到更新,原生变更则仍走正常审核流程。

博客最新动态

Capgo 为你提供打造真正专业的移动应用所需的最有价值的洞察。