资讯详情

资讯详情

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

同一个需求两种写法直接 import SQL 与传统 ORM 对比【免费下载链接】reyloImport .sql files into TypeScript with full type inference and zero codegen.项目地址: https://gitcode.com/gh_mirrors/re/reylo2026 年 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 );写法 AReylo查询就是文件模型不在 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: QueryParams, 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、returningmaybeOne/one/many/none枚举数据库枚举标签、check 约束列出的值admin \| memberJSONjsonb_build_object、jsonb_agg、JSON Schema check嵌套对象类型基数推断不是装饰。src/index.ts 里Materialize直接把基数映射成返回类型export type MaterializeRow, 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 位整数是bigintdecimal 是精确文本时间戳是 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)、以及事务内的 savepointconst 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-orm0.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),仅供参考
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →