KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · schema 是一份两向的契约 — keel 龙骨
这一章回答:那份"能被编译成 SQL"的东西是谁写的——它可以从数据库里读出来,也可以从手里写出来推到数据库去,而这两个方向能表达的东西不一样。
这一章回答:那份"能被编译成 SQL"的东西是谁写的——它可以从数据库里读出来,也可以从手里写出来推到数据库去,而这两个方向能表达的东西不一样。
现场:接手一个已经跑了两年的库
新项目不总是从空库开始。更常见的情形是:库里已经有 5 张表、两个枚举、几个索引,还有半年前 DBA 手动加的检查约束和一张审计表。现在你要给它加一层 ORM。
第一件事几乎所有人的第一反应都一样:"能不能直接从库里把模型生成出来?" 可以。一条命令:
npx prisma db pull --schema prisma/schema.prisma
它连接数据库,把结构读出来,写成那份 schema 文件。这是我们这个实验台里真实跑出来的输出:
Prisma schema loaded from prisma\schema.prisma.
Datasource "db": PostgreSQL database "labprisma", schema "public" at "127.0.0.1:5433"
- Introspecting based on datasource defined in prisma\schema.prisma
√ Introspected 5 models and wrote them into prisma\schema.prisma in 1.06s
5 个模型,1 秒。 看起来这个文件就是数据库的完整镜像。它不是。这一章的其余部分就是在量这个"不是"有多大——因为你以为逆向出来的是一份完整描述,实际拿到的是数据库结构的一个子集,而缺掉的那部分,恰恰是生产库里最不能丢的那些。
一、它长什么样:逆向产物与它丢掉的
先看这份 schema 本身。这是真实产物,一个字没改:
model posts {
id Int @id @default(autoincrement())
author_id Int
title String @db.VarChar(200)
slug String @unique @db.VarChar(200)
body String
status post_status @default(draft)
view_count Int @default(0)
rating Decimal? @db.Decimal(3, 1)
published_at DateTime? @db.Timestamptz(6)
created_at DateTime @default(now()) @db.Timestamptz(6)
comments comments[]
post_tags post_tags[]
users users @relation(fields: [author_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([author_id])
@@index([status, created_at(sort: Desc)], map: "posts_status_created_idx")
}
有件事值得先停一下。上面这个模型里,users 这个字段指的是作者——onDelete: Cascade 说明删掉用户时他的文章一起删。但从读代码的角度看,posts.users 是"文章的用户们",是复数,是作者列表。这是错的语义,而它是对的映射。
原因在后半段会讲透:db pull 只知道表叫什么、外键指向哪里,它不知道"那个用户在业务上叫作者"。逆向产出的是结构事实,不是意图。 结构事实必须精确,意图只能由人补。
现在看它丢了多少。为了量这件事,我们往库里加了几样真实项目里常见的东西,再逆向一次:
COMMENT ON TABLE users IS '平台注册用户,含内部测试账号';
ALTER TABLE posts ADD CONSTRAINT posts_view_count_nonneg CHECK (view_count >= 0);
CREATE UNIQUE INDEX posts_lower_slug_uidx ON posts (lower(slug)) WHERE status = 'published';
CREATE OR REPLACE VIEW published_posts AS SELECT id, title, slug FROM posts WHERE status = 'published';
ALTER TABLE posts ADD COLUMN title_len integer GENERATED ALWAYS AS (length(title)) STORED;
CREATE TRIGGER posts_audit_ins AFTER INSERT ON posts FOR EACH ROW EXECUTE FUNCTION touch_audit();
第二次逆向,把它真正说的话全部抄下来:
√ Introspected 6 models and wrote them into prisma\roundtrip.prisma
*** WARNING ***
These constraints are not supported by Prisma Client, because Prisma currently does not fully
support check constraints.
- Model: "posts", constraint: "posts_view_count_nonneg"
These objects have comments defined in the database, which is not yet fully supported.
- Type: "model", name: "users"
- Type: "field", name: "users.bio"
These indexes are not supported by Prisma Client, because Prisma currently does not fully
support expression indexes.
- Model: "posts", constraint: "posts_lower_slug_uidx"
三条告警,三样东西丢了。再加上两样连告警都没有的:
| 库里的东西 | 逆向结果 | 提示方式 |
|---|---|---|
6 张表(含新建的 audit_log) |
6 个 model,全部保住 | — |
复合主键 (post_id, tag_id) |
@@id([post_id, tag_id]) |
— |
枚举 post_status / user_role |
两个 enum 块,默认值也在 |
— |
列类型 varchar(200) / timestamptz(6) / decimal(3,1) |
@db.VarChar(200) / @db.Timestamptz(6) / @db.Decimal(3, 1) |
— |
| 带排序的索引 | @@index([status, created_at(sort: Desc)], map: "posts_status_created_idx"),连自定义索引名都用 map: 保留了 |
— |
| 外键与级联规则 | @relation(..., onDelete: Cascade) |
— |
| CHECK 约束 | 丢掉 | 告警 |
| 表注释与列注释 | 丢掉 | 告警 |
| 表达式索引 + 部分索引 | 丢掉 | 告警 |
视图 published_posts |
丢掉 | 无告警 |
| 触发器与函数 | 丢掉 | 无告警 |
后两行是最容易出事的地方。丢东西不可怕,静默地丢才可怕。 视图在 Prisma 里没有对应概念(它只建模表),触发器更是完全在它的视野之外。逆向一次之后,你的 schema 文件看起来"完整了"——因为文件里没有空格提示你少了什么。
还有一行值得单独看,它是近似映射而不是丢失。生成列被这样写回去:
title_len Int? @default(dbgenerated("length((title)::text)"))
真实的列是 GENERATED ALWAYS AS (length(title)) STORED——它的值由数据库计算,你不能赋值。而 schema 里它被表示成一个"带默认值的可空整数",@default(: dbgenerated(...)) 是 Prisma 用来装下这个表达式的容器,并不是真的默认值。语义上,"我没有提供值,所以用默认值"和"数据库强制我无法提供值"是两件事。这类近似映射在逆向里很常见,看 schema 不能替代看库。
二、反方向:从 schema 推回数据库
db pull 只是两个方向中的一个。另一个方向是把 schema 当作输入,让它去改数据库。这条路才是 Prisma 的"正门"。
先要知道 Prisma 7 把配置放到了哪里。 如果你照着两三年前的教程,会在 schema 里这样写:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") ← Prisma 7 里这行会报错
}
真实报错是:
Error code: P1012
error: The datasource property `url` is no longer supported in schema files.
Move connection URLs for Migrate to `prisma.config.ts` ...
连接串被移出去了,放进项目根的一个 TypeScript 配置文件:
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: { path: "prisma/migrations" },
datasource: { url: env("DATABASE_URL") },
});
于是 schema 文件干净成了纯结构描述:一个 datasource 块(只有 provider)、一个 generator 块、一堆 model。这个切分有它的道理——"数据长什么样"和"怎么连上它"是两件生命周期完全不同的事,前者进版本库、被评审、被迁移,后者随环境变。放在一起的时候,你没法在不暴露连接串的前提下分享 schema。
generator 块在 Prisma 7 里也变了,而且这一条会直接影响你的构建:
generator client {
provider = "prisma-client" // 旧版本是 "prisma-client-js"
output = "../generated/prisma" // 现在必须显式指定
}
output 从"可省略、默认塞进 node_modules/.prisma/client"变成了"必须写、产物落在你的项目里"。上一章看到的那 13 个 TS 文件就在这个目录。产物进了你的仓库视野,你就得对它负责:要么 .gitignore 掉并在构建时生成,要么提交进去但每次都跑 generate 防止漂移。
三个命令构成这个方向的日常循环:
npx prisma validate # schema 语法与语义检查,不连库
npx prisma generate # 从 schema 生成客户端与类型,不连库
npx prisma migrate diff # 比较两个 schema / 一个 schema 与一个库,输出 DDL
注意前两个不连数据库。这意味着 schema 本身是一个可以离线校验的工件——CI 里不需要数据库就能挡住"有人手滑写错字段类型"这类问题。这是把结构描述独立出来的直接好处。
三、两个方向什么时候用哪个
问题不在于哪个"更好",而在于谁是真相:
| 你的处境 | 真相在哪 | 用哪条路 |
|---|---|---|
| 全新项目,库还不存在 | schema 文件 | 手写 schema → 用迁移推到库 |
| 接手现成库,要长期用 ORM 迭代 | 数据库(历史包袱都在里面) | db pull 出初稿 → 人工补语义(改名、加注释)→ 之后转成迁移方向 |
| 只是临时查数、做一次分析 | 数据库 | db pull 完事,别管长期维护 |
| 多人协作、要审计每一次结构变更 | schema + 迁移文件 | 严格迁移方向,db pull 只用于一次性纠偏 |
只有一条硬规则:不要同时把两个方向当真相。 具体说,不要一边用 db pull 覆盖 schema,一边又在用迁移改库。这两件事会互相抹掉对方的成果——db pull 会把迁移文件里表达的意图(你刻意加的索引名、你选择的字段顺序)重新打散成"现状的描述";而迁移会把 db pull 里来不及补的语义差异当成需要修正的偏差。真实的团队事故基本都是从这里开始的。
db pull 之后要补的东西,按重要性排是这三样:
- 语义命名:
posts.users改成posts.author。这一步需要@relation显式命名,不然两个模型之间的多条关系无法区分。 @@map与@map:如果数据库里的表名是t_user_info这类历史命名,而你想在代码里用UserInfo,用@@map("t_user_info")把两边接起来。这是不动物理结构就能改善代码可读性的唯一手段。- 补注释:数据库注释逆向不出来,那就写在 schema 里。schema 能进版本库、能被 review,它比数据库注释更适合承载"这一列为什么长这样"。
生产边界
db pull是单向读取,它不改数据库。 但db pull会覆盖 schema 文件——如果那个文件里有人手补的语义(@relation命名、@@map、注释),一次逆向就全没了。先提交,再逆向,用 diff 看清楚丢了什么。- 告警必须读完。 上面三条告警对应的都是"Prisma 不支持,所以从 schema 里拿不到"。不读告警,你会以为 schema 是完整的。而 视图和触发器连告警都没有——这条只能靠"知道 Prisma 只建模表"来预防。
- 逆向产物是结构事实的下界,不是等号。
title_len那种近似映射说明连"保住了"的字段都可能语义失真。 prisma.config.ts是 TypeScript 文件,会被执行。 它导入dotenv/config是官方推荐的写法,也就是在 CLI 启动时读.env。生产环境的凭据不要放进.env再指望配置文件只读不泄露——它是可执行代码,不是数据。- schema 里不要写连接串。 Prisma 7 直接拒绝(P1012)。如果你的工具链或脚本还在往 schema 里注入 url,升级时会全线失败。
动手:可观察结果
| 产出 | 判断标准 |
|---|---|
一次 db pull 的完整 stdout |
能列出几条告警、每条对应库里哪样东西 |
| 一张"逆向能保住 / 会丢"的对照表 | 至少覆盖主键、枚举、列类型、索引、外键、CHECK、注释、视图、触发器九类 |
| 一份做过语义补充的 schema | 能指出哪几处是你加的(@relation 名字、@@map、注释),并说出不加会怎样 |
一次 prisma validate |
能在不连库的情况下改坏一个字段类型,看它报不报 |
一次 prisma generate |
能指出产物目录里哪几个文件对应哪几个模型 |
完成标志:给定一个现成数据库,你能先说"这次逆向会丢掉哪几类东西",再用 db pull 的告警逐条核对。反过来(先逆向再解释告警)说明你还没建立边界感——而静默丢的那两类,反向核对是发现不了的。
故障注入
| 注入方式 | 观察 |
|---|---|
往 schema 的 datasource 里加回 url = env(...) |
报什么错(P1012),提示你把配置移到哪里 |
把 generator 的 output 删掉 |
报错还是不报错;产物落到哪去了 |
在 schema 里把 posts.users 改名成 posts.author |
第二个关系出现时会不会冲突(提示:同一对模型间有多条关系时必须给 @relation 名字) |
往库里加一张视图,再 db pull |
它在 schema 里出现了吗?有告警吗? |
往库里加一个触发器,再 db pull |
同上 |
用 db pull 覆盖一份已经补过语义的 schema |
diff 里少了哪些行;这些行是谁加的 |
把两个 model 指向同一张表(其中一个写 @@map) |
validate 报什么 |
自测题
db pull出来的posts.users在业务上是"作者"。为什么逆向做不到这件事?要怎么做才对?- 六张表、两个枚举、两个索引都保住了,但 schema 依然不是数据库的完整描述。缺的那几类东西有什么共同点?
- CHECK 约束、注释、表达式索引会告警,视图和触发器不会。这个差别意味着你该怎么用
db pull? title_len Int? @default(dbgenerated("length((title)::text)"))和真实的GENERATED ALWAYS AS ... STORED差在哪?为什么说这是"近似映射"?- Prisma 7 为什么把
datasource.url从 schema 移进prisma.config.ts?这个切分解决了什么问题? validate和generate都不连数据库。这对 CI 意味着你能在没有数据库的环境里挡住哪一类改动?- 为什么"一边
db pull、一边用迁移"会互相抹掉成果?具体举一个会被抹掉的例子。
现在能解释什么
- 为什么逆向出来的模型名是表名、关联字段名是对方模型名——它描述结构,不描述意图;
- 为什么"schema 有 5 个 model"和"库里有 5 张表"不是同一句话;
- 为什么读告警是逆向的必经步骤,而光读告警还不够(有两类东西是静默丢的);
- 为什么 Prisma 7 强制要求
generator.output,以及那 410 KB 的 TS 产物为什么进了你的构建链路; - 为什么"结构描述"和"怎么连上它"必须分开存放;
- 为什么一个团队只能有一个真相——要么数据库是,要么 schema 是,不能同时。
下一步:03 章 · include 到底发几条 SQL —— 现在 schema 描述好了、调用也能编译了,接下来是本课程最实用的一章:把关联取数的真实代价量出来。