同一个需求两种写法:直接 import SQL 与传统 ORM 对比

发布时间:2026/10/24 9:46:12
同一个需求两种写法:直接 import SQL 与传统 ORM 对比

同一个需求两种写法:直接 import SQL 与传统 ORM 对比

【免费下载链接】reyloImport .sql files into TypeScript with full type inference and zero codegen.项目地址: https://gitcode.com/gh_mirrors/re/reylo

2026 年 10 月 20 日,TypeScript 7.1 Beta 正式引入content mappers(内容映射器):编译器可以为一个非 TypeScript 后缀的文件注册一个可执行插件,由插件在编译期返回该文件对应的模块声明。tsc只要看到.sql导入,就会把文件交给插件,插件返回Params、Row和返回值形状。Reylo 就是长在这条新接口上的项目,它把这个能力接到了真实数据库上:让数据库prepare语句,读回列与参数的类型信息,再翻译成 TypeScript 类型。

这让一个老问题重新变得具体:同一个需求,为什么有人坚持写 SQL,有人坚持用 ORM?本文不讨论偏好,而是拿本仓库里同一组表、同一组查询,把两种写法并排放在一起,从"类型从哪来""谁和谁耦合""什么样的团队节奏更合适"三个角度做对照,所有结论都能在源码里找到对应实现。

先看同一张表:两种建模的落点

仓库里examples/下的示例项目共用一份建表脚本 setup.sql:一张users、一张posts,以及一个 Postgres 枚举类型user_role。

create type user_role as enum ('admin', 'member'); create table users ( id serial primary key, name text not null, email text, role user_role not null default 'member', created_at timestamptz not null default now() ); create table posts ( id serial primary key, user_id int not null references users(id), title text not null, body text );

写法 A(Reylo):查询就是文件,模型不在 TypeScript 里。

-- examples/node/queries/get_user.sql select id, name, email, role, created_at from users where id = :id
// examples/node/src/main.ts import postgres from "postgres"; import getUser from "../queries/get_user.sql"; const sql = postgres(process.env["DATABASE_URL"]!); const user = await getUser.run(sql, { id: 1 }); // ^? { id: number; name: string; email: string | null; // role: "admin" | "member"; created_at: string } | null

注意三个细节:email自动带上| null,因为列可空;role是字面量联合而不是string,因为数据库里它是枚举;created_at是string而不是Date,这是 Reylo 刻意的取值表示(后文再说)。返回类型是Row | null而不是Row[],因为where id = :id命中了主键——这是基数推断,不是手写标注。

写法 B(传统 ORM):模型先被声明一次,查询再基于模型构造。以 Drizzle 为例,等价需求大致是这样(示意写法,非本仓库文件):

// schema.ts export const users = pgTable("users", { id: serial("id").primaryKey(), name: text("name").notNull(), email: text("email"), role: userRole("role").notNull().default("member"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); // 查询 const rows = await db .select({ id: users.id, name: users.name, email: users.email, role: users.role }) .from(users) .where(eq(users.id, id));

两边都能得到类型安全的user。差别不在"有没有类型",而在于类型这份事实被放在了几处。

写 Reylo 时只有一处:.sql文件本身。改一个列名,改 SQL 就行;类型的来源是数据库的 catalog,而不是另一份声明。写 ORM 时至少有两处:数据库里的真实结构(由迁移脚本产生),以及 TS 里的 schema 声明。两者必须靠drizzle-kit generate/prisma migrate这类工具保持同步,否则类型是对的、数据库是错的,或者反过来。

类型究竟从哪里长出来

Reylo 的类型链路可以完整追踪。第一步是把.sql文件变成 TypeScript 模块,在 src/mapper.ts 中,transform()拿到文件内容后做三件事:词法分析取参数名、session.analyze(sql)取分析结果、moduleTypes()生成声明文本。最终交给 TypeScript 的模块长这样:

import type { Query } from "reylo"; export type Params = { id: number }; export type Row = { id: number; name: string; ... }; export declare const sql: "select id, name, ..."; export declare const raw: "..."; export declare const params: readonly ["id"]; declare const query: Query<Params, Row, "maybeOne", "postgres"> & { ... }; export default query;

第二步是分析本身。src/analyze.ts 里的Session把语句发给 Rust 引擎(编译成 WASI 的engine.wasm,每种方言一个包),引擎再通过宿主提供的describe和 catalog 查询与真实数据库交互。分析结果按"语句 + 引擎指纹 + 方言"哈希缓存进.reylo/queries/*.json,不与 schema 指纹匹配就重新分析;schema 指纹的缓存时间只有 5 秒(SCHEMA_TTL = 5_000),数据库连不上时降级为 60 秒(OFFLINE_TTL = 60_000)。这意味着一次迁移落地后,几秒内编辑器就会看到新结构。

第三步是"多出来的那部分类型",也就是 ORM 通常不会替你算的东西:

维度Reylo 的来源结果
空值NOT NULL、外连接、where上的判空email: string \| null
基数主键/唯一键匹配、聚合、limit 1、returning"maybeOne"/"one"/"many"/"none"
枚举数据库枚举标签、check 约束列出的值"admin" \| "member"
JSONjsonb_build_object、jsonb_agg、JSON Schema check嵌套对象类型

基数推断不是装饰。src/index.ts 里Materialize直接把基数映射成返回类型:

export type Materialize<Row, C extends Cardinality> = C extends "many" ? Row[] : C extends "one" ? Row : C extends "none" ? void : Row | null;

而运行时会兑现这个承诺:materialize()在"one"上拿到非 1 行就抛Expected exactly one row,在"maybeOne"上拿到多于 1 行就抛Expected at most one row。类型不是注释,而是运行期契约。

仓库里 lab/basic/main.ts 用双向赋值把这条链路钉死了:它先声明期望类型,再让推断结果与期望类型互相可赋值,以此证明"完全相等"而不是"恰好兼容"。同一份文件里还有一行断言,把四个查询的基数固定为["maybeOne", "maybeOne", "one", "many"]——主键匹配、聚合、普通连接各自的结果都在里面。

反过来说 ORM 一侧:它的类型来自 schema DSL 的静态声明,查询构造器再把这些声明组合成结果类型。这套机制很强,尤其在需要跨数据库、需要模型层钩子、需要把业务实体做成一等公民时。但它的类型是声明驱动的:email是否可空,取决于你写没写.notNull();左连接后某一列是否可能为 null,取决于 ORM 有没有为这个连接形态建模。数据库里真实的约束,只能通过"声明得对不对"间接影响类型。这是两套不同的正确性来源,不是谁更先进。

耦合度:被绑住的是什么

标题里的"耦合"容易被误解。Reylo 其实把类型更紧地绑在了数据库和 SQL 上,但在另一个方向上解耦得更彻底。

被绑住的是语句。一个.sql文件就是一条语句,这是硬约束。仓库给 agent 的技能文档 SKILL.md 明确写了:一个.sql文件一条语句、用相对路径导入、迁移文件或多语句文件要在tsconfig.json里exclude掉。这带来一个直接好处:类型的粒度等于 SQL 的粒度。你改了一条查询,受影响的调用点在tsc里全部现形,不存在"生成物没重跑"的中间态——因为根本没有生成物。package.json里的typescript.contentMapper只声明了一个可执行入口:

"typescript": { "contentMapper": { "exec": ["node", "src/mapper.ts"], "compilerOptions": [], "dynamicConfig": true } }

tsc --noEmit --runExternalCode就是全部。没有 watch 进程,没有generated/目录,没有"改完 schema 忘了 generate"这种事。

被解开的是声明。因为类型从数据库读,业务代码里不再出现第二份行类型。README 把这称为 agent 友好:每个事实只存在一处,模型不可能留下一个过期类型,而类型检查会找出它漏掉的每一个调用点。这个说法在代码里是成立的:moduleTypes()生成的Row只用于声明,任何一处消费它的代码都在同一次tsc里被重新检查。

代价出现在构建环境上。类型检查需要连得上开发库。仓库对此并不回避:09-faq.mdx 直说 "Can I build without a database? No, not yet"。缓解手段有两个:.reylo/缓存可以在数据库不可达时被复用(#offlineEntry会退回到任意服务器写入的、最新的同语句条目),以及reylo generate在能连库的地方预先分析整个项目(src/cli.ts)。换句话说,CI 里做不到"零数据库",只能做到"数据库只在一个步骤出现"。

还有一层运行期耦合,值得单独说。src/fresh.ts 会在查询第一次运行时,以及此后每隔 60 秒(FRESH_FOR = 60_000),通过调用方自己的客户端重跑一次 catalog 查询,把列的"是否存在、类型、是否非空、是否有默认值"与构建时记录的事实逐项比对。不一致就直接失败,并且报错里点名表和列:

users.email was text and no longer exists

这个设计的意义在于:类型检查和线上运行时之间不再有盲区。长驻服务里凌晨落地的迁移,最迟一分钟后就会以一条精确的错误暴露,而不是让一条按旧结构生成、按新结构执行的语句悄悄跑出错误结果。ORM 通常做不到这一点——它的运行期假设来自内存中的 schema 对象,与数据库实际结构之间没有这道校验。当然,这道校验也意味着每次冷启动多一次 catalog 往返,这是明确的取舍。

代价清单:Reylo 不会替你做的事

把对比做得诚实,必须列出它不提供的东西:

  • 不跑迁移。官方答案是"自带迁移工具",Prisma Migrate、Drizzle Kit、Atlas、dbmate 或纯 SQL 文件都行,Reylo 只读取迁移后的结果。

  • 不做模型层抽象。没有实体、没有关联加载、没有钩子、没有软删除、没有多态。posts与users的关系只存在于 SQL 的join里。

  • 工具链门槛不低。需要 TypeScript 7.1(当时为 beta)、Node 24+,以及一个可连的开发库。MySQL 侧 README 要求 8.0.22 及以上;类型检查还会识别并拒绝 MariaDB,错误信息明确要求把 URL 指向 MySQL 8.4 或更新版本的服务器。

  • 参数有严格规则。参数统一写成:name,同一语句内不能重复使用(要用两次就先在with里选一次),无法从上下文推断类型的参数会直接报错:

    Nothing in the statement gives the parameter :q a type; cast it, as in :q::text, or set strict_params: false in .reylo.yaml.

    判空过滤必须写成where user_id = coalesce(:user_id::int, user_id),而不是(:q is null or ...)这种常见写法,原因正是后者会让参数出现两次。

  • 取值表示是统一且非默认的。64 位整数是bigint,decimal 是精确文本,时间戳是 UTC 的 ISO 8601 字符串,没有值是Date。这是为了让所有驱动给出一致结果,避免驱动按各自时区把时间读成Date;但它和 ORM 的默认行为不同,迁移代码时要留意。

对应地,ORM 的代价清单同样具体:多一份需要同步的 schema、生成物与源码的时序问题、复杂 SQL(窗口函数、递归 CTE、JSON 聚合、方言特有能力)常常要退回sql模板逃生舱——而一旦用了逃生舱,类型保障也就随之失效。

团队规模与迭代节奏的分界

把这些差异折算成团队决策,分界线其实相当清晰。

Reylo 更舒服的场景:团队里有能直接写 SQL 的人,查询以读为主、分析性强,重度使用 JSON 聚合、窗口函数、returning、方言特有语法;schema 演进快,且已有成熟的迁移流程;构建环境能提供开发库或预先跑reylo generate。这类团队最痛的不是"写 SQL 累",而是"SQL 改了、类型没跟上"。Reylo 正面消掉的就是这个延迟。

ORM 更舒服的场景:团队规模大到需要把数据访问收敛成统一 API,业务实体要被多处复用,需要把迁移、种子、模型校验、审计钩子打包成一套约定;或者需要跨多种数据库保持一致(Reylo 的方言差异是显式的,MySQL 的键匹配规则就比 Postgres 严格:03-inference.mdx 里明确写了 MySQL 只认单表自身的主键/唯一键匹配,连接表的键不算);又或者团队暂时无法升级到 TypeScript 7.1。工具链的稳定性本身就是一种生产力。

一个可参考的判断标准是改动的半径。如果你的一次需求变更,通常涉及"一条 SQL + 它的调用点",Reylo 的 1:1 映射几乎零摩擦。如果一次变更涉及"实体 + 关联 + 序列化 + 校验 + 迁移 + 多个上层服务",ORM 提供的抽象层会显著降低沟通成本,这时手写 SQL 反而是在重复造轮子。

不是二选一:把两者接在同一根连接上

最有意思的证据来自仓库自己的测试矩阵。Reylo 的.run()第一个参数是驱动客户端,不是它自己的连接池;而 Drizzle 的 database 对象恰好把底层客户端暴露在session.client上。于是 lab/driver-matrix/drivers/drizzle.ts 直接把 Reylo 跑在 Drizzle 之上,覆盖drizzle(pool)、db.transaction(tx)、以及事务内的 savepoint:

const pool = new lib.Pool({ connectionString: pgUrl, max: 10 }); const db = drizzle(pool); // ... { name: "db.transaction(tx)", scope: (fn) => db.transaction((tx) => fn(tx)) }

这不是概念演示,而是有数字的实测。lab/driver-matrix/RESULTS.md 记录了一次完整运行:4115 行断言,4047 通过,2 失败,51 按设计拒绝,12 跳过,3 未运行;其中drizzle-orm@0.45.4在 node-postgres、postgres.js、mysql2、better-sqlite3、libsql 五个驱动上分别通过 80 / 80 / 65 / 20 / 65 行。类型层面还有一份独立矩阵:typecheck/RESULTS.md 用仓库自带的 TypeScript 7.1 跑了666 个用例、113 种 client 类型,666 全部符合预期,0 误接受、0 误拒绝——包括"用 Postgres 查询跑在 MySQL 客户端上必须被拒绝"这类负向断言。

这意味着实际落地时常见的组合是:ORM 继续负责迁移与写路径的模型抽象,Reylo 负责那些 ORM 表达不好、却又必须类型安全的复杂读查询。两者共享同一个连接池、同一个事务对象,事务语义不被打断(矩阵里的 S7 系列场景就是验证提交、回滚、savepoint 回滚的行为)。Reylo 甚至不要求你放弃 Drizzle 的 schema——它压根不读那份 schema。

真正需要警惕的只有一点:一旦同时存在两套类型来源,就要明确"谁是权威"。在这个组合里答案是清楚的——数据库是唯一权威,ORM 的 schema 是它的投影,Reylo 的.sql是对它的直接提问。只要这个前提成立,两种写法就不冲突;一旦有人开始手写行类型来"补"ORM 推断不到的部分,第二份事实就出现了,而这恰恰是 Reylo 从设计上想消除的东西。

从这个角度看,"直接 import SQL 还是用 ORM"并不是框架之争,而是你把类型的可信来源放在哪里的问题。放在声明里,就要承担声明与现实漂移的成本;放在数据库里,就要承担构建期必须连库的成本。两条路的账都很清楚,剩下的是你的团队更付得起哪一笔。

【免费下载链接】reyloImport .sql files into TypeScript with full type inference and zero codegen.项目地址: https://gitcode.com/gh_mirrors/re/reylo

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考