Agent X-Ray
RuntimeNotesAbout
Notes/代码工程/Encore/第5章

第5章:数据库 —— SQLDatabase、迁移、查询与 ORM 集成

5 分钟 · 更新于 2026-09-02

在 Encore 里"要一个数据库"是两行代码的事:一个声明 + 一个迁移目录。本章覆盖查询方法全家族、事务、迁移规则、跨服务共享、CLI 工具,以及 knex / Drizzle / Prisma 三种 ORM 集成路线。


一、声明数据库

typescript
import { SQLDatabase } from "encore.dev/storage/sqldb";

const db = new SQLDatabase("order", {
  migrations: "./migrations",
});

发生了什么:

  • 本地encore run 自动用 Docker 拉起 Postgres(encoredotdev/postgres 镜像,内置 pgvectorPostGIS 扩展),建库并执行迁移;
  • 云上:映射到 AWS RDS / GCP Cloud SQL(Encore Cloud 自动开通)或你在 infra-config.json 里指定的实例(自托管,第 16 章);
  • 数据库名在应用内全局唯一;一个服务可以声明多个数据库,通常一服务一库

二、迁移 (Migrations)

迁移文件放在声明时指定的目录,纯 SQL:

text
order/
└── migrations/
    ├── 001_create_orders.up.sql
    ├── 002_add_passengers.up.sql
    └── 003_add_indexes.up.sql

命名规则(违反即报错):

  • 数字开头(1_001_ 均可),必须严格递增
  • 下划线后接描述;
  • .up.sql 结尾。
sql
-- order/migrations/001_create_orders.up.sql
CREATE TABLE orders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_no TEXT UNIQUE NOT NULL,
    flight_no TEXT NOT NULL,
    amount_cents BIGINT NOT NULL,
    status TEXT NOT NULL DEFAULT 'CREATED',
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_orders_status ON orders(status);

迁移在应用启动时自动执行(本地与云上一致)。迁移是 schema 的唯一事实源——这对订单、支付这类审计敏感领域是重要性质:schema 变更全部进代码评审与版本历史。

三、查询方法全家族

模板字符串形式(推荐,自动参数化防注入):

typescript
// query —— 多行,返回异步迭代器
const rows = await db.query<{ orderNo: string; amountCents: number }>`
  SELECT order_no, amount_cents FROM orders WHERE status = ${status}
`;
for await (const row of rows) {
  // 逐行流式处理,适合大结果集
}

// queryAll —— 多行,一次取回数组
const orders = await db.queryAll<Order>`
  SELECT * FROM orders WHERE status = ${status}
`;

// queryRow —— 单行或 null
const order = await db.queryRow<Order>`
  SELECT * FROM orders WHERE order_no = ${orderNo}
`;
if (!order) throw APIError.notFound("order not found");

// exec —— 不返回行(INSERT / UPDATE / DELETE / DDL)
await db.exec`
  UPDATE orders SET status = ${"PAID"} WHERE order_no = ${orderNo}
`;

raw 形式(SQL 需要动态拼装时用,参数走 $1 占位):

typescript
const rows  = await db.rawQuery<Order>("SELECT * FROM orders WHERE status = $1", status);
const row   = await db.rawQueryRow<Order>("SELECT * FROM orders WHERE id = $1", id);
const all   = await db.rawQueryAll<Order>("SELECT * FROM orders WHERE status = $1", status);
await db.rawExec("INSERT INTO audit_log (msg) VALUES ($1)", msg);

防注入的边界 db.query`...${v}...` 的插值编译为参数化查询,安全db.rawQuery("..." + v) 字符串拼接危险。动态拼 SQL(如多条件搜索)时把用户值全部走 $n 参数、只拼接白名单内的 SQL 片段。本机 B2B 项目把全仓唯一的 rawQuery 使用点集中在一个文件(list-query.ts)便于审计,值得效仿。

类型标注建议

query<T> 的泛型是你对结果行的断言,编译器无法核对 SQL 与 T 是否一致。列名经 AS 改名、可空列、聚合列都容易写错——用集成测试盯住(第 15 章),或上 ORM 获得端到端类型。

四、事务

typescript
// AsyncDisposable 风格:离开作用域自动回滚未提交事务
await using tx = await db.begin();

await tx.exec`UPDATE accounts SET balance = balance - ${amount} WHERE id = ${from}`;
await tx.exec`UPDATE accounts SET balance = balance + ${amount} WHERE id = ${to}`;

await tx.commit();   // 不 commit 则自动回滚

await using 是 TypeScript 5.2+ 的显式资源管理语法:异常路径不需要手写 rollback,作用域结束未提交即回滚。

五、跨服务共享数据库

默认一服务一库是推荐架构(服务边界=数据边界)。确需共享时用 SQLDatabase.named 引用已存在的库:

typescript
// 拥有方(order 服务)
const db = new SQLDatabase("order", { migrations: "./migrations" });

// 引用方(report 服务)—— 不带迁移,只读引用
const orderDb = SQLDatabase.named("order");
const row = await orderDb.queryRow`SELECT count(*) AS n FROM orders`;

静态分析器会把这条依赖画进架构图。注意:共享库让两个服务在 schema 上耦合,报表/后台类只读场景可以接受,写路径共享是拆分失败的信号(见第 6 章决策表)。

六、CLI 数据库工具

bash
encore db shell order            # 打开 psql(默认只读;--write --admin --superuser 提权)
encore db conn-uri order         # 输出连接串(喂给 GUI 客户端 / 脚本)
encore db proxy [--env=name]     # 本地代理到指定环境的数据库
encore db reset order            # 重置该服务数据库(重跑全部迁移)

--env 参数让这套命令同样适用于云上环境(如 encore db shell order --env=staging),权限受 Encore Cloud 的 IAM 管控。

七、ORM 集成

Encore 的数据库是标准 Postgres,任何"支持标准 SQL 驱动 + 迁移产物是纯 SQL"的 ORM 都能接。桥梁是 db.connectionString

路线 A:knex / kysely 等查询构造器

typescript
import { SQLDatabase } from "encore.dev/storage/sqldb";
import knex from "knex";

const SiteDB = new SQLDatabase("site", { migrations: "./migrations" });

const orm = knex({
  client: "pg",
  connection: SiteDB.connectionString,
});

const Sites = () => orm<Site>("site");
// 用法:await Sites().where("id", id).first();

迁移仍由 Encore 管(手写 SQL),knex 只负责查询——第 14 章实战二采用这条路线。

路线 B:Drizzle(迁移也交给 ORM 生成)

typescript
// database.ts
import { SQLDatabase } from "encore.dev/storage/sqldb";
import { drizzle } from "drizzle-orm/node-postgres";

const db = new SQLDatabase("app", {
  migrations: { path: "migrations", source: "drizzle" },  // 声明迁移来源为 drizzle
});

export const orm = drizzle(db.connectionString);
typescript
// schema.ts
import * as p from "drizzle-orm/pg-core";

export const users = p.pgTable("users", {
  id: p.uuid().primaryKey().defaultRandom(),
  email: p.text().unique().notNull(),
  name: p.text().notNull(),
});
typescript
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
  out: "migrations",
  schema: "schema.ts",
  dialect: "postgresql",
});

drizzle-kit generate 生成 SQL 迁移文件 → Encore 启动时自动执行。查询获得端到端类型:

typescript
import { eq } from "drizzle-orm";
const user = await orm.select().from(users).where(eq(users.id, id));
await orm.insert(users).values({ email, name });

路线 C:Prisma

同理:Prisma 的迁移产物是纯 SQL,connectionString 接上即可。三条路线选型:

场景建议
SQL 熟练、想要最少抽象原生模板字符串
复杂动态查询多knex / kysely
想要 schema→类型全链路Drizzle(与 Encore 集成最深,迁移源可声明)

八、常见误区

  1. 迁移编号跳号或修改已执行过的迁移文件——前者报错,后者导致环境间 schema 漂移;改 schema 永远新增迁移。
  2. 在 handler 里 new SQLDatabase——原语必须包级声明。
  3. 用字符串拼接构造 SQL——注入风险;动态 SQL 走 rawQuery + $n 参数。
  4. query 结果忘了 for await(当成数组用)——它是异步迭代器,要数组用 queryAll
  5. 事务用 db.begin() 后在事务内误用 db.exec(而不是 tx.exec)——绕过了事务。
  6. 把泛型 query<T> 当成类型安全保证——它只是断言,需测试或 ORM 兜底。
  7. 图方便到处 SQLDatabase.named 共享库——数据边界溶解,拆服务白拆。

九、实践练习

  1. order 服务与订单表(含 order_no 唯一约束、status 默认值、created_at),写入/查询/更新三个端点全部走模板字符串。
  2. 故意插入重复 order_no,观察 Postgres 唯一约束错误如何浮出,为它包一层 APIError.alreadyExists
  3. 写一个转账式双写事务(扣减库存 + 创建订单),在两写之间抛异常,用 encore db shell 验证自动回滚。
  4. 新增迁移 002_add_contact.up.sql 给订单表加联系人列,观察 encore run 自动执行;再试着把 002 改名 004,看报错。
  5. 用 Drizzle 重做练习 1,对比模板字符串与 ORM 的类型体验。

十、总结

  1. 数据库 = new SQLDatabase(名, {migrations}) + SQL 迁移目录;本地 Docker 自动开通、启动自动迁移。
  2. 查询四方法(query/queryAll/queryRow/exec)+ raw 四兄弟;模板字符串自动参数化防注入。
  3. 事务用 await using tx = await db.begin(),未提交自动回滚。
  4. 跨服务共享用 SQLDatabase.named,但一服务一库是默认正道。
  5. CLI 四件套:shell / conn-uri / proxy / reset,均支持 --env
  6. ORM 三路线:模板字符串、knex、Drizzle/Prisma;connectionString 是通用接口。

请继续阅读:第6章:服务架构


原始资料引用



本章目录
一、声明数据库二、迁移 (Migrations)三、查询方法全家族四、事务五、跨服务共享数据库六、CLI 数据库工具七、ORM 集成八、常见误区九、实践练习十、总结原始资料引用Related Documents
苏ICP备2025204887号-2