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: 每一行,作为数组 (用于 rawvalues()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() 插入。
何时选择其他选项
- 您想要使用 Fast SQL 的最快数据路径,或者以名称为键的行对象: Kysely 与 Capacitor.
- 您更喜欢装饰器、实体和仓库,或者您正在将 TypeORM 后端模型移植: TypeORM 与 Capacitor.
- 您需要在休眠状态下进行加密: 在 Capacitor 中加密 SQLite 数据库.
添加一个本机插件意味着需要重建 iOS 和 Android 应用。如果您没有 Mac 可用,请 Capgo 构建 在云中构建和签名应用