Prisma 对比 Drizzle 有哪些优缺点,上代码举例一下
Prisma vs Drizzle comparison
Cursor
6 条消息
先看一下项目里现在用的是 Prisma 还是 Drizzle,再对照代码举例说明两者的优缺点。
保罗小店已经拍板用 Drizzle。两者不是「谁全面碾压谁」,而是:Prisma 更像带客户端的 ORM,Drizzle 更像带类型的 SQL 层。
一句话定位
| Prisma | Drizzle | |
|---|---|---|
| 本质 | Schema DSL + 生成式 Client | TypeScript schema + SQL 风格查询 |
| 你写的是 | 「给我带 SKU 的商品」 | 「FROM products LEFT JOIN product_skus」 |
| 适合 | CRUD 快、团队 SQL 弱、要 Studio | 要控 SQL、行锁、部分唯一索引、包体积 |
| 运行时 | 历史上有 Query Engine;现在可走 driver adapter,仍偏厚 | 几乎就是 SQL 生成器,无独立引擎 |
1. Schema:DSL vs TypeScript
Prisma 用独立语言,列名映射靠 @map:
enum UserRole {
USER
ADMIN
}
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique @db.VarChar(254)
passwordHash String @map("password_hash") @db.VarChar(255)
role UserRole @default(USER)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
orders Order[]
@@map("users")
@@index([role, status])
}
改完必须 prisma generate,类型才更新。
Drizzle 就是 TS,列名写在第二个参数里,类型立刻跟着变:
import { pgTable, uuid, varchar, timestamp, pgEnum, index } from "drizzle-orm/pg-core";
export const userRole = pgEnum("user_role", ["USER", "ADMIN"]);
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
email: varchar("email", { length: 254 }).notNull().unique(),
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true, precision: 6 })
.notNull()
.defaultNow(),
},
(t) => [index("users_role_status_idx").on(t.role, t.status)],
);
这正是后端文档里选 Drizzle 的原因:password_hash 显式写出,不靠 @map。
Prisma 赢:Studio、校验、关系图、文档都围绕一份 .prisma。
Drizzle 赢:schema 可拆文件、可复用、可跟 Zod 共用同一套字段定义。
2. CRUD:Client API vs SQL 形状
查已上架商品 + 最低价 SKU。
Prisma — 嵌套 include,写起来短:
const products = await prisma.product.findMany({
where: { status: "PUBLISHED" },
include: {
skus: {
where: { isListed: true },
orderBy: { price: "asc" },
},
coverFile: true,
},
orderBy: { publishedAt: "desc" },
});
生成的 SQL 往往是多条查询或较重的 JOIN;你不太容易一眼看出最终语句。
Drizzle — 查询长得像 SQL:
const rows = await db
.select({
id: products.id,
slug: products.slug,
title: products.title,
skuName: productSkus.name,
price: productSkus.price,
})
.from(products)
.leftJoin(productSkus, eq(productSkus.productId, products.id))
.where(and(eq(products.status, "PUBLISHED"), eq(productSkus.isListed, true)))
.orderBy(desc(products.publishedAt), asc(productSkus.price));
db.query.products.findMany({ with: { skus: true } }) 也能走关系 API,但复杂过滤、投影、锁,最终还是 select().from() 更稳。
Prisma 赢:嵌套读写、关系图查询、CRUD 样板少。
Drizzle 赢:SELECT 列、JOIN、WHERE 完全可控,没有 N+1 黑盒。
3. 嵌套写入
创建商品 + 一个 SKU。
Prisma 一条 create 带 skus.create:
await prisma.product.create({
data: {
slug: "paul-kit",
title: "保罗工具箱",
status: "DRAFT",
skus: {
create: { name: "标准版", price: 5800, isListed: true, sortOrder: 0 },
},
},
});
Drizzle 没有等价的「嵌套 create」,事务里自己插:
await db.transaction(async (tx) => {
const [product] = await tx
.insert(products)
.values({ slug: "paul-kit", title: "保罗工具箱", status: "DRAFT" })
.returning({ id: products.id });
await tx.insert(productSkus).values({
productId: product.id,
name: "标准版",
price: 5800,
isListed: true,
sortOrder: 0,
});
});
样板更多,但插入顺序、返回列、失败回滚都看得见。
4. 事务与行锁(本店真正会痛的地方)
优惠券核销:SELECT … FOR UPDATE,再判断 HELD + COMMITTED < max_redemptions。这是文档里的硬约束。
Prisma 交互式事务有,行锁要自己拼 SQL:
await prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<Coupon[]>`
SELECT * FROM coupons WHERE id = ${couponId}::uuid FOR UPDATE
`;
const coupon = rows[0];
if (!coupon) throw new Error("COUPON_NOT_FOUND");
const held = await tx.couponRedemption.count({
where: { couponId, status: { in: ["HELD", "COMMITTED"] } },
});
if (held >= coupon.maxRedemptions) throw new Error("COUPON_EXHAUSTED");
await tx.couponRedemption.create({
data: { orderId, couponId, userId, discount, status: "HELD" },
});
});
Client API 和 $queryRaw 混用,锁语义容易漏。
Drizzle 的 for("update") 就是 SQL:
await db.transaction(async (tx) => {
const [coupon] = await tx
.select()
.from(coupons)
.where(eq(coupons.id, couponId))
.for("update");
if (!coupon) throw new Error("COUPON_NOT_FOUND");
const [{ used }] = await tx
.select({ used: sql<number>`count(*)::int` })
.from(couponRedemptions)
.where(
and(
eq(couponRedemptions.couponId, couponId),
inArray(couponRedemptions.status, ["HELD", "COMMITTED"]),
),
);
if (used >= coupon.maxRedemptions) throw new Error("COUPON_EXHAUSTED");
await tx.insert(couponRedemptions).values({
orderId,
couponId,
userId,
discount,
status: "HELD",
});
});
支付回调、SKU 锁价、订单金额快照,都属于「必须看见 SQL」的路径。这里 Drizzle 明显更合适。
5. 部分唯一索引、CHECK
「每个商品最多一个 is_current = true 版本」—— Postgres 部分唯一索引。
Prisma 长期表达不好,常要手写 SQL 进 migration:
CREATE UNIQUE INDEX product_versions_one_current
ON product_versions (product_id)
WHERE is_current = true;
schema 里看不到这条约束,下次 migrate 还可能对不齐。
Drizzle 写在 schema 里,生成迁移时带着走:
import { uniqueIndex } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
export const productVersions = pgTable(
"product_versions",
{ /* ... */ isCurrent: boolean("is_current").notNull() },
(t) => [
uniqueIndex("product_versions_one_current")
.on(t.productId)
.where(sql`${t.isCurrent} = true`),
],
);
金额 CHECK(payable = subtotal - discount)同理:Drizzle 用 check(),Prisma 多半落在 raw SQL。
6. 类型从哪来
Prisma
import type { Product, Prisma } from "@prisma/client";
type CreateProduct = Prisma.ProductCreateInput;
类型完整、嵌套 payload 自动生成,但必须跑 generate;CI 漏了就会和 schema 脱节。
Drizzle
import type { InferSelectModel, InferInsertModel } from "drizzle-orm";
type Product = InferSelectModel<typeof products>;
type NewProduct = InferInsertModel<typeof products>;
改 schema 立刻反映到类型,没有 generate 步骤。复杂 JOIN 的返回类型要靠 select({ ... }) 推出来,写漏列就会变成宽 any 感——这是 Drizzle 最常见的 DX 坑。
7. 迁移与运行时
| Prisma | Drizzle | |
|---|---|---|
| 生成迁移 | prisma migrate dev | drizzle-kit generate |
| 应用迁移 | prisma migrate deploy | drizzle-kit migrate |
| 开发偷懒 | db push(两边都有,生产都禁止) | 同左 |
| 包体 / 部署 | 曾必须带 engine 二进制;adapter 模式好转,仍重 | 几乎无额外二进制 |
| 调试 SQL | log: ["query"],语句常经过引擎改写 | logger: true,基本就是你写的那句 |
| Studio | 成熟 | 有 drizzle-studio,弱一档 |
| 生态 | 文档、StackOverflow、Nest 示例更多 | 2024 后增长很快,边角案例仍少 |
8. 优缺点对照(按实际踩坑排)
Prisma 优点
- 嵌套 CRUD、
include/select对业务表关系很省事 - Client 类型、Studio、校验一体,上手快
- 中间件 / 扩展(软删、审计)现成套路多
- 团队 SQL 不熟时,能把正确性托付给 Client
Prisma 缺点
- SQL 不透明:N+1、多余 JOIN、难写
FOR UPDATE/ CTE - schema 是另一门语言;
snake_case靠@map,和库表对账累 - 部分索引、CHECK、复杂约束经常逃到 raw SQL
- 运行时更重;
generate漏跑会类型过期 - 复杂报表最后还是
$queryRaw,两套风格并存
Drizzle 优点
- Schema = TypeScript,列名、枚举、索引都在代码里
- 查询接近 SQL,行锁、事务、投影好控
- 轻:适合 Nest + postgres.js 这种「自己管连接」的栈
- 迁移和 schema 同源,部分唯一索引能进 TS
- 和 Zod 组合自然(
createInsertSchema一类)
Drizzle 缺点
- 关联查询、嵌套写入要手写 JOIN / 事务,样板多
- 关系 API(
db.query)一复杂就不够用,团队必须会 SQL - 推类型有时难看,JOIN 漏选列要自己兜
- Studio、文档、Nest 官方示例不如 Prisma
timestamp漏写withTimezone: true会变成无时区列——后端文档已经点名这个坑
9. 对本项目意味着什么
保罗小店这几条,Prisma 会别扭、Drizzle 刚好:
- 金额整数 + CHECK(
payable = subtotal - discount) - 优惠券 / SKU 行锁(
FOR UPDATE) - 部分唯一索引(当前版本、用户对同一券的占用)
- snake_case 列 + camelCase TS 字段,不要满屏
@map - 订单快照:插入时显式列出
unit_price、snapshot,不允许 Client 隐式带关系改历史价
Prisma 更适合「后台 CMS、关系全是 include」的阶段。这个店的一致性边界在支付和库存式核销,SQL 必须可见,所以文档里用 Drizzle 换掉 Prisma 是合理选择,不是跟风。
如果以后加一块纯管理端列表(用户、审计、设置),可以在 Drizzle 里用 db.query.*.findMany({ with }) 减轻样板,不必为此切回 Prisma。
- include: { skus } 本质上就是 join 了 sku 表是吧,这个属于是 prisma 自己的映射规则,sku 表叫什么名字是 schema 文件 Model xxx 决定,而 drizzle 是需要变量实体引用
- 嵌套写入是指一次执行两个 sql 插入指令吗,反正我一眼看到 prisma 的写法,其实你根本看不出来执行了几条 sql,但 drizzle 一眼能看出来
- 事务与行锁我需要你能给出更好理解的说明,不仅仅只是描述这两个 orm 的使用差异
- 迁移和运行时:我只记得 prisma 会在数据库里插入一个
_prisma_migrations,drizzle 是怎么知道需要迁移到那个版本的呢
先
把四个点对准:include 是不是 JOIN、嵌套写入实际跑几条 SQL、事务/行锁在业务里干什么,以及 Drizzle 怎么记迁移版本。我会对照 Prisma / Drizzle 现在的迁移机制核对一下。
你的 1、2 方向对,但有两处需要拧紧:include 不等于你写了一句 JOIN;嵌套写入也不只是「两条 INSERT」。3 和 4 按机制讲。
1. include: { skus } 不是「你写了 JOIN」
它是 Prisma 的关系加载约定:拿到商品时,把关联的 SKU 一并填进 JS 对象。底层怎么取数,是 Prisma 自己决定的,API 不保证是一条 JOIN。
历史上默认是两次查询,应用层拼:
SELECT * FROM products WHERE status = 'PUBLISHED';
SELECT * FROM product_skus WHERE product_id IN (...);
较新版本可以改成库内 LATERAL JOIN + json_agg,一次返回嵌套 JSON。所以:
- 语义:加载关系
- 实现:可能是 JOIN,也可能是
IN (...)再内存合并 - 你在代码里看不出来走哪条
名字分三层,别混:
| 层 | Prisma | Drizzle |
|---|---|---|
| JS 字段 | include: { skus } 里的 skus | productSkus 这个变量 |
| Model / schema 对象 | model ProductSku { ... } | export const productSkus = pgTable(...) |
| 真实表名 | @@map("product_skus"),没写就按规则从 Model 名推 | pgTable("product_skus", ...) 第一个参数 |
Prisma 里 skus 不是表名,是 Model 上声明的关系字段:
model Product {
id String @id
skus ProductSku[] // ← include 用这个名字
@@map("products")
}
model ProductSku {
id String @id
productId String @map("product_id")
product Product @relation(fields: [productId], references: [id])
@@map("product_skus") // ← 真正的表名
}
Client 是 prisma.product.findMany({ include: { skus: true } })。表叫什么,由 @@map 决定;关系叫什么,由字段 skus 决定。这就是 Prisma 的映射规则。
Drizzle 没有「按名字找关系」这一层。JOIN 必须引用你 import 进来的表对象:
import { products, productSkus } from "./schema";
db.select().from(products).leftJoin(productSkus, eq(productSkus.productId, products.id));
db.query.products.findMany({ with: { skus: true } }) 看起来像 include,但 skus 仍然是你在 relations() 里注册过的名字,底层还是那张 productSkus 表对象。少一层「Model 名 → Client → 表名」的生成映射。
2. 嵌套写入:一次 API,多条 SQL,外面包事务
对。create 带 skus.create 不是一条能插入两张表的 SQL。Postgres 一次 INSERT 只能进一张表。Prisma 会在一个事务里拆开执行,大致是:
BEGIN;
INSERT INTO products (...) VALUES (...) RETURNING *;
INSERT INTO product_skus (product_id, ...) VALUES (...);
-- 常常还有额外 SELECT,用来拼你要 return 的嵌套对象
COMMIT;
失败则 ROLLBACK,不会留下「有商品没 SKU」。
你的判断对:从 Prisma 那一行看不出几条 SQL、有没有多打 SELECT、JOIN 还是二次查询。 要开 query log 才知道。Drizzle 每个 insert() 对应你看见的那条语句,条数就是代码行数(外加你自己写的 transaction)。
补充两点:
- 嵌套写入还可以是
update里skus: { create / update / deleteMany },SQL 数量随嵌套变,更难目测。 - Prisma 为了返回完整对象,经常在 INSERT 后再 SELECT。Drizzle 的
.returning({ id: products.id })写了才查,没写就没有。
3. 事务和行锁:先不管 ORM
这两件事解决的是:两个人同时改同一份数据时,数据库保证什么。 ORM 只是把 SQL 发出去。
事务:一组语句要么全成,要么全不成
下单在本店是一个一致性边界,文档里写了四步要在同一事务里:
- 读并锁住 SKU 现价
- 可选:锁优惠券,插入
HELD核销 - 写
orders+order_items - 写
payments
假设券插进去了,订单 INSERT 因 check 失败。没有事务:库里会留下一张废核销,额度被占,用户却没订单。有事务:第四步失败,1–3 全部撤回,像没发生过。
事务保证的是这一组语句的原子性,不保证「两个事务不会同时读到同一行」。后一个要靠锁。
为什么会超卖:时间窗口
券 max_redemptions = 1。用户 A、B 同时用这张券,各开一个事务,默认 Postgres 是 READ COMMITTED:
时刻 A B
t1 读到已用 0,限额 1
t2 读到已用 0,限额 1 ← B 也觉得还能用
t3 INSERT HELD,COMMIT
t4 INSERT HELD,COMMIT ← 超卖
两人读的都是「当时已提交的数据」。A 提交后,B 的事务若不再读一次,仍拿着自己的旧结论往下写。这不是 ORM 的 bug,是读和写之间没有互斥。
唯一约束能挡一部分(例如「同一用户同一张券不能占两笔」),挡不住「全局限额 1 张、两个不同用户同时抢」。那是计数问题,需要锁住这张券这一行。
行锁:FOR UPDATE 在锁哪一行
SELECT * FROM coupons WHERE id = $1 FOR UPDATE;
含义:
- 锁的是这一行,不是整张
coupons表。别的券照样卖。 - 第一个事务拿到锁;第二个事务执行到同一句时堵住,等到第一个
COMMIT或ROLLBACK。 - 第一个提交后,第二个再读,看到的是新数据(已用 1),于是拒绝。
接上时间线:
时刻 A B
t1 SELECT ... FOR UPDATE (B 也执行同一句,进入等待)
t2 已用 0 < 1,INSERT HELD
t3 COMMIT,释放锁
t4 被唤醒,再读:已用 1,拒绝
没有 FOR UPDATE,SELECT 只是快照,读完锁就没了,窗口还在。
FOR UPDATE 是写锁:别人不能再 FOR UPDATE 或 UPDATE 这行,普通 SELECT 默认仍能读(不会看到你未提交的改动)。所以列表页看券不会被下单堵住;真正抢额度的下单路径会串行化在这一行上。
SKU 锁价同理:不锁的话,A 读到 5800,管理员同时改成 6800,A 仍按 5800 下单。锁住 SKU 行,读到的价就是本事务里要写入 order_items.unit_price 的价。
和「唯一索引」的分工
| 手段 | 挡住什么 | 挡不住什么 |
|---|---|---|
order_id unique | 一单两张券 | 两单抢同一张全局限额券 |
部分唯一 (coupon_id, user_id) WHERE status IN ('HELD','COMMITTED') | 同一人同时占两笔 | 两个人各占一次、打满限额 |
SELECT ... FOR UPDATE 再 count | 全局限额超卖 | —(代价:抢同一张券的下单排队) |
锁解决竞态,约束解决「即使锁漏了也不能脏」。两边都要,不是二选一。
事务结束锁才放
锁跟事务走,不跟某句 SQL 走。SELECT FOR UPDATE 之后无论再 INSERT 几张表,锁一直持有到 COMMIT/ROLLBACK。所以必须把「锁券 → 检查额度 → 写核销 → 写订单」放进同一个事务,不能锁完就提交再去写订单——提交的瞬间锁没了,窗口又打开。
这就是文档把下单四步写进同一事务的原因。支付回调是另一笔事务:此时待支付订单已存在,做的是验签、改 PAID、核销改 COMMITTED、写拥有权。两笔事务靠订单状态机衔接,不是一笔从头锁到支付成功。
ORM 在这里只做一件事
概念上你要发出的就是:
BEGIN;
SELECT * FROM coupons WHERE id = $1 FOR UPDATE;
SELECT count(*) FROM coupon_redemptions WHERE ...;
INSERT INTO coupon_redemptions ...;
INSERT INTO orders ...;
COMMIT;
Prisma 的 prisma.coupon.findUnique 不会带 FOR UPDATE,所以要 $queryRaw。Drizzle 的 .for("update") 就是把这四个字拼进 SQL。差异只在这句锁能不能用类型安全的 API 写出来,不是锁的含义不同。
4. Drizzle 怎么知道迁到哪:和 Prisma 一样是「已应用日志」,不是版本号
两边都没有「当前是 v17,目标是 v19」这种单一版本号。都是:仓库里有一份有序列表,库里有一张已应用记录,差集就是待跑的文件。
Prisma
- 仓库:
prisma/migrations/20240910_xxx/migration.sql一堆目录 - 数据库:
public._prisma_migrations - 每行大约是:迁移名、checksum、applied 时间、成功/失败
migrate deploy:读文件夹顺序 → 查 _prisma_migrations → 没出现过的按序执行 → 插入一行。
Drizzle
两份账要对上:
1. 仓库里的账(源码,跟着 git 走)
drizzle/
├── 0000_initial.sql
├── 0001_add_coupons.sql
└── meta/
├── _journal.json ← 有序列表
├── 0000_snapshot.json
└── 0001_snapshot.json
_journal.json 类似:
{
"version": "7",
"dialect": "postgresql",
"entries": [
{ "idx": 0, "tag": "0000_initial", "when": 1709553600000 },
{ "idx": 1, "tag": "0001_add_coupons", "when": 1709640000000 }
]
}
idx / tag 决定顺序和文件名。snapshot 是当时 schema 的 JSON 快照,下次 generate 拿来 diff,运行迁移时不看 snapshot。
2. 数据库里的账(这台库已经跑过哪些)
默认不在 public,而在独立 schema:
drizzle.__drizzle_migrations
列大致是:id、hash(那份 .sql 的哈希)、created_at。
drizzle-kit migrate 实际步骤:
- 连库,没有表就建
drizzle.__drizzle_migrations - 读
_journal.json,按idx得到应存在的迁移列表 SELECT hash FROM drizzle.__drizzle_migrations- journal 里有、表里没有的,按序执行对应
.sql - 成功则插入一行 hash
所以它知道「迁到哪」的方式是:journal 的最后一条,且这条的 hash 已在 __drizzle_migrations 里。 不是比较一个整数版本。
对照
| Prisma | Drizzle | |
|---|---|---|
| 库内日志表 | public._prisma_migrations | drizzle.__drizzle_migrations(可改) |
| 仓库清单 | 迁移文件夹名 | meta/_journal.json + 000x_*.sql |
| 如何认「同一条」 | 文件夹名 + SQL checksum | SQL 文件 hash |
| 待跑集合 | 文件夹有、表里没有 | journal 有、表里没有 |
| 生产 | migrate deploy | drizzle-kit migrate |
本地库跑过 0000、0001,同事新提交 0002,你 pull 后再 migrate:表里只有两条 hash,journal 有三条,于是只跑 0002。另一台空库:表是空的,三条全跑。和 Prisma 一样。
注意:改已经应用过的 .sql 会让 hash 对不上,两边都会报历史被改。正确做法是再 generate 一条新迁移,不要改旧文件。push 不走这张日志表,所以生产禁用。
"Prisma 里 skus 不是表名,是 Model 上声明的关系字段"
model Product {
id String @id
skus ProductSku[] // ← include 用这个名字
@@map("products")
}
model ProductSku {
id String @id
productId String @map("product_id")
product Product @relation(fields: [productId], references: [id])
@@map("product_skus") // ← 真正的表名
}
也就是说 model 是 Prisma 自己虚构的一层东西,本身和数据库结构是没关联的吗,你 products 表怎么可能物理上和 product_skus 关联起来呢
不是。Model 对应的是表;和库没关系的,只有那些没有列、只给 Client 用的关系字段。
物理关联靠的是外键,不是 skus 这个名字。
库里实际长什么样
Postgres 里只有表和列,没有 skus 这个东西:
products
id uuid PK
product_skus
id uuid PK
product_id uuid NOT NULL → REFERENCES products(id)
两张表能「连上」,是因为 product_skus.product_id 存的就是 products.id。这是数据库自己的约束,跟 Prisma、Drizzle 都无关。没有这列,ORM 再怎么写关系也关联不起来。
Prisma 在这上面叠了两层
model Product {
id String @id
skus ProductSku[] // ③ 虚拟字段:不是列
@@map("products") // ① 这张表
}
model ProductSku {
id String @id
productId String @map("product_id") // ② 真列
product Product @relation(fields: [productId], references: [id]) // ③ 虚拟 + 外键声明
@@map("product_skus") // ① 那张表
}
| 编号 | 写在 schema 里 | 库里有没有 | 作用 |
|---|---|---|---|
① model + @@map | Product → products | 有,就是表 | Model 不是虚构表,是表的 ORM 名字 |
② 标了 @map 的标量字段 | productId → product_id | 有,就是列 | 真正存关联值的地方 |
| ③ 关系字段 | skus、product | 没有对应列 | 只给 Client 用:include: { skus }、product.skus |
所以:
model Product↔ 表products(有关联)skus ProductSku[]↔ 不是列(这一层才是 Prisma 虚构的)- 物理关联 =
product_skus.product_id指向products.id
@relation(fields: [productId], references: [id]) 的意思是:用 SKU 表上已有的 product_id 列,去对 商品表的 id。migrate 时 Prisma 会据此建外键,例如:
ALTER TABLE product_skus
ADD CONSTRAINT product_skus_product_id_fkey
FOREIGN KEY (product_id) REFERENCES products(id);
include: { skus } 只是告诉 Client:「用这条外键,把 SKU 查回来,塞进 JS 的 skus 数组」。查询时 Prisma 知道去 product_skus 里 WHERE product_id = products.id。数组名 skus 可以改成 variants,表结构和外键不变。
一对多为什么外键在「多」的那边
一张商品对应多条 SKU,所以 product_id 只能放在 product_skus 上。products 表不能有一列叫 skus——一列存不下多行。
products.id = "aaa"
↑
product_skus: { id: 1, product_id: "aaa", name: "标准版" }
{ id: 2, product_id: "aaa", name: "专业版" }
Prisma 在 Product 上写 skus ProductSku[],只是从「一」的这边反向导航到「多」。库里的箭头始终是 SKU → Product。
Drizzle 一样:表是真的,外键是真的;relations() 里的 skus 同样只是 TS 里的导航名,不会多出一列。