跳转至正文

如何在 Capacitor 和 SQLite 中使用 TypeORM

在 iOS 和 Android 上结合 Capacitor 与 SQLite 使用 TypeORM:涵盖 DataSource 配置、适配 Vite 的 entities、repositories、transactions 以及自动生成的 migrations。

如何在 Capacitor 和 SQLite 中使用 TypeORM

TypeORM 通过其内置的 Capacitor 应用 capacitor driver,在以下环境运行每个查询: @capacitor-community/sqlite。你可以配置一个 DataSource 配合该插件的 SQLiteConnection,使用装饰器定义实体,并像在服务器端一样使用 repository 和 transaction。本指南涵盖了 TypeORM 1.x 和 Capacitor 8 的完整配置,包括大多数教程都会忽略的两点:为 Vite 配置显式的列类型,以及在 CLI 无法直接连接手机的情况下如何生成 migration。

何时适合使用 TypeORM

TypeORM 是一个包含实体 (entities)、关系 (relations)、repository 和 migration 系统的 data-mapper ORM。在移动端,如果满足以下情况,使用它会非常合适:

  • 你已经在后端使用了 TypeORM,并希望在 App 中保持相同的实体风格,
  • 你的数据模型包含大量关系,并且你希望 relations: { posts: true } 而不是手动编写 join 查询,
  • 你的团队更倾向于使用 class 和 decorator,而不是 schema 对象。

它比 query builder 更重。如果 bundle size 和启动时间至关重要,或者你想使用 @capgo/capacitor-fast-sql,请查看 Kysely 或 Drizzle 作为替代。

安装

bun add typeorm @capacitor-community/sqlite
bunx cap sync

TypeORM 1.x 会自动导入 reflect-metadata 自身,因此你不需要像旧指南那样在顶部 main.ts 进行单独导入。无论你是否使用加密,该社区插件都会在 iOS 和 Android 上打包 SQLCipher,这在回答 App Store 出口合规性问题时非常重要。

TypeScript 设置

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "useDefineForClassFields": false
  }
}

experimentalDecorators 可以启用 TypeORM 的旧版装饰器。 useDefineForClassFields: false 防止类字段初始化程序覆盖 TypeORM 设置的值,这在你的 target 是 ES2022 或更高版本时非常重要。

兼容 Vite 和 esbuild 的 Entity

大多数 Capacitor 应用都使用 Vite 构建,它通过 esbuild 编译 TypeScript。esbuild 虽然支持装饰器,但并未实现 emitDecoratorMetadata,导致 TypeORM 无法在运行时读取属性类型。如果没有显式类型,你会遇到 ColumnTypeUndefinedError。请为每个列指定类型:

// src/db/entities/author.ts
import {
  Column,
  CreateDateColumn,
  Entity,
  OneToMany,
  PrimaryGeneratedColumn,
  type Relation,
} from 'typeorm';
import { Post } from './post';

@Entity('authors')
export class Author {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column('text')
  name!: string;

  @Column('text', { unique: true })
  email!: string;

  @CreateDateColumn()
  createdAt!: Date;

  @OneToMany(() => Post, (post) => post.author)
  posts!: Relation<Post[]>;
}
// src/db/entities/post.ts
import {
  Column,
  Entity,
  Index,
  JoinColumn,
  ManyToOne,
  PrimaryGeneratedColumn,
  type Relation,
} from 'typeorm';
import { Author } from './author';

@Entity('posts')
export class Post {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column('text')
  title!: string;

  @Column('boolean', { default: false })
  published!: boolean;

  @Index()
  @Column('integer')
  authorId!: number;

  @ManyToOne(() => Author, (author) => author.posts, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'authorId' })
  author!: Relation<Author>;
}

注意:

  • Relation<T> 是一个仅限类型的包装器。它可以避免 author.ts 和 post.ts 之间的循环导入问题(例如在 CLI 或测试中进行编译时)。
  • 显式的 authorId 列允许你在不加载作者的情况下,通过 id 进行过滤和插入。
  • SQLite 没有布尔类型。TypeORM 会将 boolean 列存储为 0/1 并在读取时将其转换回来。

配置 DataSource

// src/db/data-source.ts
import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite';
import { DataSource } from 'typeorm';
import { Author } from './entities/author';
import { Post } from './entities/post';
import { Init1790000000000 } from './migrations/1790000000000-Init';

export const sqliteConnection = new SQLiteConnection(CapacitorSQLite);

export const AppDataSource = new DataSource({
  type: 'capacitor',
  driver: sqliteConnection,
  database: 'app',
  mode: 'no-encryption',
  entities: [Author, Post],
  migrations: [Init1790000000000],
  synchronize: false,
  migrationsRun: false,
  journalMode: 'WAL',
  logging: ['error'],
});

逐项配置:

  • driver:插件的 SQLiteConnection. TypeORM 的驱动程序使用它来创建并打开连接,然后启用 PRAGMA foreign_keys 所以 onDelete: 'CASCADE' 可以正常工作。
  • database: 插件会添加一个 SQLite.db 后缀,因此文件名为 appSQLite.db.
  • mode: no-encryption,或者是插件的加密模式之一 ("secret, encryption)。请参阅 在 Capacitor 中对 SQLite 进行加密.
  • synchronize: false: 在生产环境中务必开启。Sync 可能会为了匹配 entity 而删除列,从而导致设备上的用户数据丢失。
  • migrationsRun: false:插件文档要求使用 capacitor 类型。请在之后立即手动运行迁移 initialize(),如下所示。
  • journalMode: 'WAL':可选。可提高读写并发性能。
  • migrations:引用类而非 glob 模式。WebView 中无法进行文件系统扫描。

像以下的 Glob 模式 entities: ['src/**/*.entity.ts'] 由于同样的原因在 App 中无法工作。请直接导入类。

在启动时初始化

// src/db/index.ts
import { Capacitor } from '@capacitor/core';
import { AppDataSource, sqliteConnection } from './data-source';

let ready: Promise<typeof AppDataSource> | null = null;

export function initDatabase() {
  if (!ready) {
    ready = (async () => {
      if (Capacitor.getPlatform() === 'web') {
        // Needs the jeep-sqlite element in the DOM, see "Web support"
        await sqliteConnection.initWebStore();
      }

      // Clean up connections left over from a hot reload
      await sqliteConnection.checkConnectionsConsistency();

      if (!AppDataSource.isInitialized) {
        await AppDataSource.initialize();
      }
      await AppDataSource.runMigrations({ transaction: 'each' });

      if (Capacitor.getPlatform() === 'web') {
        await sqliteConnection.saveToStore('app');
      }
      return AppDataSource;
    })();
  }
  return ready;
}

调用 await initDatabase() 在 App 渲染任何读取数据的组件之前。 transaction: 'each' 它将每次迁移都封装在独立的事务中,因此如果发生失败,之前的迁移会保留,而失败的迁移会回滚。

Repository:insert, select, update, delete

import { initDatabase } from './db';
import { Author } from './db/entities/author';
import { Post } from './db/entities/post';

const ds = await initDatabase();
const authors = ds.getRepository(Author);
const posts = ds.getRepository(Post);

// Insert
const ada = await authors.save(authors.create({ name: 'Ada', email: 'ada@example.com' }));
await posts.save([
  posts.create({ title: 'Draft', authorId: ada.id }),
  posts.create({ title: 'Live', authorId: ada.id, published: true }),
]);

// Select
const published = await posts.find({
  where: { authorId: ada.id, published: true },
  order: { id: 'DESC' },
});
const one = await authors.findOneBy({ email: 'ada@example.com' });

// Load a relation
const withPosts = await authors.findOne({
  where: { id: ada.id },
  relations: { posts: true },
});

// Update without loading
await posts.update({ authorId: ada.id }, { published: true });

// Delete
await posts.delete({ published: false });

save() 根据主键是否已设置来执行插入或更新,并返回带有生成 ID 的实体。对于不需要返回实体的批量写入操作, insert() 由于跳过了额外的查询,速度更快。

适用于处理复杂逻辑的 Query builder

const stats = await ds
  .getRepository(Author)
  .createQueryBuilder('a')
  .leftJoin('a.posts', 'p')
  .select('a.id', 'id')
  .addSelect('a.name', 'name')
  .addSelect('COUNT(p.id)', 'postCount')
  .groupBy('a.id')
  .getRawMany<{ id: number; name: string; postCount: number }>();

在移动端,Join 和聚合操作是 ORM 节省时间的核心。可以通过以下方式检查生成的 SQL: .getSql() 在开发过程中,并为用于以下内容的列添加索引: WHERE 和 JOIN.

Transactions

await ds.transaction(async (manager) => {
  const grace = await manager.save(Author, { name: 'Grace', email: 'grace@example.com' });
  await manager.save(Post, { title: 'Hello', authorId: grace.id });
});

使用 manager 在事务内的每个查询中都会传递给回调函数。如果回调抛出异常,TypeORM 会执行回滚。

由于 Capacitor 驱动程序使用单一连接,因此事务与其他操作之间不存在隔离:在事务开启期间,通过 ds 或其他 repository 发起的查询将在同一连接上运行,并随事务一起提交或回滚。请勿在事务完成前启动无关的数据库操作,保持事务简短,且绝不要在事务内部等待网络请求。

Migrations:使用本地 sqljs DataSource 生成它们

TypeORM CLI 运行在你的电脑上而非手机上,因此无法检查设备数据库。插件文档指出,capacitor 类型的 migrations 必须手动编写。你仍然可以获取生成的 migrations:通过 TypeORM 的 sqljs 驱动程序使用相同的 SQLite 方言,因此生成的 SQL 代码可以在设备上直接运行。

1. 添加一个仅限 CLI 的 DataSource

bun add -d sql.js
// typeorm.cli.ts (project root, not bundled into the app)
import { DataSource } from 'typeorm';
import { Author } from './src/db/entities/author';
import { Post } from './src/db/entities/post';

export default new DataSource({
  type: 'sqljs',
  location: '.typeorm/schema.sqlite',
  autoSave: true,
  entities: [Author, Post],
  migrations: ['src/db/migrations/*.ts'],
});

添加 .typeorm/ 至 .gitignore。该文件是“所有现有迁移完成后模式(schema)状态”的一个临时副本。

2. 更新临时数据库,然后进行 diff

mkdir -p .typeorm
bun ./node_modules/typeorm/cli.js migration:run -d ./typeorm.cli.ts
bun ./node_modules/typeorm/cli.js migration:generate -d ./typeorm.cli.ts src/db/migrations/AddTags

Bun 直接运行 TypeScript 数据源和实体。第一个命令将现有迁移应用于临时数据库。第二个命令将其与您的实体进行比较并写入新的 migration 类,例如 src/db/migrations/1790000123456-AddTags.ts。然后将该类添加到 migrations 中的 data-source.ts.

数组中

import type { MigrationInterface, QueryRunner } from 'typeorm';

export class Init1790000000000 implements MigrationInterface {
  name = 'Init1790000000000';

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `CREATE TABLE "authors" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" text NOT NULL, "email" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_authors_email" UNIQUE ("email"))`,
    );
    await queryRunner.query(
      `CREATE TABLE "posts" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" text NOT NULL, "published" boolean NOT NULL DEFAULT (0), "authorId" integer NOT NULL, CONSTRAINT "FK_posts_author" FOREIGN KEY ("authorId") REFERENCES "authors" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`,
    );
    await queryRunner.query(`CREATE INDEX "IDX_posts_authorId" ON "posts" ("authorId")`);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP INDEX "IDX_posts_authorId"`);
    await queryRunner.query(`DROP TABLE "posts"`);
    await queryRunner.query(`DROP TABLE "authors"`);
  }
}

生成的初始 migration 如下所示: temporary_<table>在发布之前请务必检查每一份生成的 migration。对于 SQLite,TypeORM 通常通过创建新表、复制行、删除原表并重命名的方式来修改表。这种操作是正确的,但在处理大表时在手机上会很耗时,因此请配合 loading 状态使用。

手动编写迁移文件

如果你手动编写迁移,请确保每个语句都以大写关键字开头。capacitor query runner 会通过区分大小写的首词来决定如何调用插件: BEGIN, COMMIT, ROLLBACK, CREATE, ALTER 和 DROP 转交给 execute, INSERT, UPDATE 和 DELETE 转交给 run,而其他所有内容都转交给 query。如果使用小写 create table ,它会被当作普通查询发送,这不符合 DDL 的预期。

迁移与 Live Update

迁移类是 JavaScript bundle 的一部分。通过 Capgo Live Update Schema 变更无需经过应用商店审核即可触达用户。通过回滚,可以将旧版本的 code 应用于新 Schema,因此建议优先采用增量迁移(如新增表、新增可为空的列),并在后续版本中再删除旧列。

Web 支持

该社区插件通过以下方式在 Web 端运行: jeep-sqlite Web 组件(将 sql.js 持久化到 IndexedDB)。这在浏览器开发时非常有用:

bun add jeep-sqlite
cp node_modules/sql.js/dist/sql-wasm.wasm public/assets/
// main.ts, before initDatabase()
import { defineCustomElements as jeepSqlite } from 'jeep-sqlite/loader';
import { Capacitor } from '@capacitor/core';

if (Capacitor.getPlatform() === 'web') {
  jeepSqlite(window);
  const el = document.createElement('jeep-sqlite');
  document.body.appendChild(el);
  await customElements.whenDefined('jeep-sqlite');
}

在 Web 端,数据库存储在内存中,当你调用 sqliteConnection.saveToStore('app')时会写入 IndexedDB。请在执行完需要保留的写入操作后调用它,否则数据会在页面刷新时丢失。

故障排除

Column type for Post#title is not defined and cannot be guessed. esbuild 未生成元数据。请为 @Column 装饰器添加显式类型。

Capacitor package has not been found installed. 该 driver 选项缺失或未定义。请传入 new SQLiteConnection(CapacitorSQLite).

页面刷新时出现 “connection already exists” 错误。 初始化运行了两次。请保留单个 ready promise 并调用 checkConnectionsConsistency() 在 initialize().

No metadata for "Post" was found. 实体类不在 entities 数组中,或者你使用了 glob 字符串。请导入该类。

开发期间,每次应用更新后数据都会重置。 synchronize: true 已开启,或者迁移操作会删除并重建表。请关闭 sync 并检查生成的 SQL。

Migrations 已在 CLI 中运行,但未在设备上运行。 新类不在 migrations 中的 data-source.ts数组里。应用不会扫描文件夹。

总结

在 Capacitor 上使用 TypeORM 需要三个部分:作为驱动的社区 SQLite 插件、具有明确列类型的实体,以及针对本地数据库生成的 sqljs 并作为类导入的迁移文件。配置完成后,Repository、关联关系和事务的处理方式与在后端时完全一致。

若想在以下环境进行更轻量级的配置, @capgo/capacitor-fast-sql请参阅 Capacitor 配合 Kysely。关于插件的基础用法,请从 如何在 Capacitor 应用中使用 SQLite 开始学习。当原生端发生变化时, Capgo 构建 可以在云端重新构建并签名 iOS 和 Android 应用。

为 Capacitor 应用提供实时更新

当 Web 层 Bug 已上线时,通过 Capgo 推送修复,不必等 App Store 审核数天。用户会在后台获取更新,而原生变更仍走常规审核流程。

博客最新文章

Capgo 为你提供打造真正专业移动应用所需的深度洞察。