跳过主要内容

如何使用 Drizzle ORM 与 Capacitor 和 SQLite

设置 Drizzle ORM 与 Capacitor 和 SQLite:一个工作的 sqlite-proxy 驱动程序、类型化的模式、关系查询、事务和在应用程序内的 Drizzle Kit 迁移。

文章来源

马丁·多纳迪厄

作者

瓦莱里娅

审阅者

乔丹

编辑

如何使用 Drizzle ORM 与 Capacitor 和 SQLite

Drizzle ORM 在一个 Capacitor 应用程序中运行,通过其 sqlite-proxy 驱动程序:您给 Drizzle 提供一个异步函数,该函数在本机 SQLite 插件上执行 SQL,并以数组形式返回行,Drizzle 处理类型化的查询构建器、关系查询和事务。该指南为 @capacitor-community/sqlite,展示如何将 Drizzle Kit 迁移内置到应用程序包中,并列出了如果您忽略它们会导致结果静默损坏的边缘案例。

Everything below was tested with 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 生成的迁移。 Drizzle Kit 会根据你的 Schema diff 并生成 SQL 迁移文件。
  • 不需要装饰器。 与 Vite 和 esbuild 一起工作,且无需额外的 TypeScript 标志。

Drizzle 的权衡: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 通过列名读取行并直接工作。有关两种插件的概述,请参见 How to use SQLite in a Capacitor app.

安装

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

@capacitor-community/sqlite 在 iOS 和 Android 上的 bundles SQLCipher,确保您的应用程序即使您没有加密任何内容,也会带有加密 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 对象的类型脚本。
  • $defaultFn(() => new Date()) 设置 JavaScript 的默认值。一个 SQL 默认值 unixepoch() 需要 SQLite 3.38 或更高版本,这些旧的 Android 系统 SQLite 不支持。
  • mode: 'boolean' 存储 1/0。Drizzle 可以双向转换。
  • relations() 是 SQLite 的驱动程序 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驱动程序 method:

  • run: 无需任何行 (INSERT, UPDATE, DELETE, DDL, 事务控制).
  • all: 每一行,作为数组.
  • get: 第一行,作为数组,或者 undefined.
  • values: 每一行,作为数组 (用于 raw values() calls 和内部).
// 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>;

每个 branch 为何存在:

  • TX_CONTROL: Drizzle 实现 db.transaction() 通过发送 begin, commit, rollback, 和 savepoint/release 用于嵌套事务。它们通过 execute(sql, false)使用 TypeORM 的官方 Capacitor 驱动器的相同路径 BEGIN/COMMIT.
  • false __CAPGO_KEEP_0__ run(): 这个插件的默认行为是将每个语句包裹在自己的事务中,这与Drizzle的显式事务冲突。 begin.
  • returnMode 'all': 使 .returning() : 使插入、更新和删除语句
  • Object.values(row): 将带键的行转换回位置数组。

我在内存中的SQLite上运行了这个函数,返回结果与插件相同。使用 .returning(), 过滤选择 .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模块,然后手动应用它。

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.jscontext generate 页面/区域:Capgo营销网站。角色:短UI标签或导航项。见于:页面trust.astro。消息键`and` (And)。

。提交所有三个。每次更改schema后都重新运行

migrations.js 不要编辑已经发布的SQL文件。让打包器导入.sql作为文本文件, 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 实时更新一起发布,新迁移将在没有存储更新的情况下到达用户。保持它们为增量(新表,新可空列),因为回滚到一个旧的捆绑包将在新模式下运行。

查询

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

连接:别名重复列名

这是任何基于对象行的代理驱动的锐利边缘。 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));

的列。它们在行对象中折叠成一个键,数组少了一个值,Drizzle将值分配到错误的字段。 在我的测试中 id返回了用户ID和 postId 是 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' });
  });
});

事务 rollback. Calling tx.rollback() 。调用

也会中止事务,通过抛出异常。 await 每个数据库都有一个连接,因此请保持事务短并且不要在其中进行网络调用。其他写入同一连接的写入将在您的打开事务中运行。

解决问题

启动时出现“连接已存在”错误。 您的初始化已运行两次,通常是由于热重载。使用上面的 checkConnectionsConsistency 和 retrieveConnection 模式。

cannot start a transaction within a transaction. 一个 run() 调用没有 false 作为第三个参数,因此插件在Drizzle内部打开了自己的事务。

每次启动都会运行迁移,且会因缺失或回滚的插入语句而失败。检查您的捆绑包中的日志 table already exists. 值与表中的行是否匹配。 __drizzle_migrations __CAPGO_KEEP_0__ when __CAPGO_KEEP_0__

编译错误 Failed to parse source for import analysis 在 .sql. 缺少或在数组中排列的 Vite 转换插件出现在尝试解析文件的另一个插件之后。将其放在数组的第一位。 plugins 返回空数组。

.returning() 写入成功 使用返回模式 run . 确保驱动程序在 no时 'all' 不是 method 日期偏差为 1000 倍。 run.

存储秒, mode: 'timestamp' items mode: 'timestamp_ms' 存储毫秒。每列选择一个,不要混淆原始数据 Date.now() 插入。

何时选择其他选项

添加一个本机插件意味着需要重建 iOS 和 Android 应用。如果您没有 Mac 可用,请 Capgo 构建 在云中构建和签名应用

Live updates for Capacitor apps

实时更新Capgo应用

来自马丁的人性化支持

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