
最近在项目里折腾 Prisma,关系配置这块被绕了好几圈才搞清楚。这篇把几种常见的表关系讲清楚,每种都配上 MySQL 的写法和平面图,两边对照着看,理解起来快得多。
你本来就会 MySQL 和 ER 图,这篇的目标不是教你怎么写外键,而是让 Prisma 的语法真正"对得上号",不再靠猜。
大多数人卡在这里,是因为 Prisma 要求在两个模型上都声明关系。看起来像是在说两遍一样,但其实不是。
记住这条规则,所有东西都通了:
外键列只存在于其中一张表。
两张模型互相命名,这样 Prisma 从两个方向都能找到关联。
MySQL 里外键只写一次,写在持有外键的那张表上。Prisma 仍然这样处理,但还会问另一张表从自己的角度命名这个关系——纯粹是为了 JavaScript 代码里能写 user.jobPostings。第二个声明不会产生任何额外的列。
记住这个,后面看例子就清晰了。
一个简单的招聘平台,追踪三样东西:
User(使用平台的用户)Profile(用户的扩展信息,比如简介和头像)JobPosting(用户发布的职位)SavedJob(用户收藏的职位)这覆盖了四种常见的关系形态:
| 形态 | 在我们应用中的例子 |
|---|---|
| 一对一 | 一个 User 对应一个 Profile |
| 一对多 | 一个 User 发布多个 JobPosting |
| 多对一 | 一个 JobPosting 属于一个 User(同一个东西) |
| 多对多 | 用户收藏多个职位,职位被多个用户收藏 |
逐个来看。
这是最常见的形态。一个用户发多个职位,每个职位属于一个用户。
┌────────┐ 1 N ┌─────────────┐
│ User │───────────>│ JobPosting │
└────────┘ └─────────────┘
箭头从 User("一"端)指向 JobPosting("多"端)。
CREATE TABLE User ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL UNIQUE
); CREATE TABLE JobPosting ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(255) NOT NULL, salary INT, userId INT NOT NULL, FOREIGN KEY (userId) REFERENCES User(id)
);
外键在 JobPosting 上——这是"多"的那一端。User 上没有任何指向 JobPosting 的列。用户不需要知道自己发了哪些职位,职位知道自己属于哪个用户就行了。
model User { id Int @id @default(autoincrement()) name String email String @unique jobPostings JobPosting[]
} model JobPosting { id Int @id @default(autoincrement()) title String salary Int? user User @relation(fields: [userId], references: [id]) userId Int
}
注意三件事:
JobPosting 上的 userId。这就是外键,和你在 MySQL 里写的完全一样。user User @relation(...) 不创建列。它叫"关系字段",告诉 Prisma:"我的 userId 列指向 User 的 id 列,代码里叫它 .user"。User 上的 jobPostings JobPosting[] 也不创建列。这是反向引用,让你能写 user.jobPostings 来查询。数据库里一个外键,schema 里两个关系字段。每个模型一个。
@relation(fields: [userId], references: [id]) 到底在说啥把它读成一句话:
"用我的
userId列(fields)指向User的id列(references)。"
两个参数,对应两边:
| 参数 | 谁的列? | 我们例子里的值 |
|---|---|---|
fields |
当前模型(JobPosting) | userId |
references |
另一个模型(User) | id |
对应的 MySQL 语句你本来就认识:
FOREIGN KEY (userId) REFERENCES User(id)
-- ^^^^^ ^^^^^^^^^^
-- fields references
用数组是因为支持复合外键(两端各多个列),但 99% 的情况下你会看到单个元素的数组,跟我们这个例子一样。
@relation(fields, references) 只放在有外键的那一端。另一端只写个裸的 JobPosting[](一对一是 JobPosting),不需要 @relation 属性,因为没什么要声明的。
// 查询用户1发布的所有职位
const jobs = await prisma.jobPosting.findMany({ where: { userId: 1 },
}); // 查一个用户,同时带上他发布的职位
const user = await prisma.user.findUnique({ where: { id: 1 }, include: { jobPostings: true },
}); // 给已有用户创建一个职位
await prisma.jobPosting.create({ data: { title: "前端开发工程师", salary: 40000, user: { connect: { id: 1 } }, },
});
connect 的意思是"关联到已有用户,不要创建新的"。这是 Prisma 语法里最实用的操作之一,一旦掌握就离不开了。
一个用户有一个扩展 profile,profile 只属于这一个用户。
┌────────┐ 1 1 ┌─────────┐
│ User │────────────│ Profile │
└────────┘ └─────────┘
一对一画出来是条直线,没有箭头,因为两端都是"1",没有"多"端可指。基数标签承载了全部含义。
CREATE TABLE Profile ( id INT PRIMARY KEY AUTO_INCREMENT, bio TEXT, avatar VARCHAR(255), userId INT NOT NULL UNIQUE, FOREIGN KEY (userId) REFERENCES User(id)
);
和一对多一样,只多了一个技巧:userId 上加 UNIQUE。这个约束把"多对一"变成了"一对一"。不加的话,同一个用户就可以有多条 profile 了。
model User { id Int @id @default(autoincrement()) name String email String @unique profile Profile?
} model Profile { id Int @id @default(autoincrement()) bio String? avatar String? user User @relation(fields: [userId], references: [id]) userId Int @unique
}
和一对多比有两处变化:
profile Profile? 而不是 Profile[]。单数,不是列表。? 表示"可选",符合现实:用户可能还没创建 profile。userId 上加 @unique。和 MySQL 一样,约束"每个用户最多一个 profile"。不加 @unique 的话,Prisma 会把它当成一对多处理。反向引用的形态(Profile? vs Profile[])决定了 Prisma 识别为一对一还是一对多。@unique 在数据库层面真正执行了这个约束。
const userWithProfile = await prisma.user.findUnique({ where: { id: 1 }, include: { profile: true },
}); // 同时创建用户和 profile,一步搞定
await prisma.user.create({ data: { name: "张三", email: "zhangsan@example.com", profile: { create: { bio: "写代码的", avatar: "zhangsan.png" }, }, },
});
嵌套 create 是 Prisma 最好用的特性之一。两张表,一行代码,一个事务。
用户可以收藏感兴趣的职位。每个用户收藏多个职位,每个职位可以被多个用户收藏。
MySQL 用中间表处理这种关系。Prisma 有两种选择:隐式(Prisma 自动生成中间表)和显式(自己建表)。这里讲显式,因为和 MySQL 完全对应,而且以后要加字段时更灵活——实际项目中几乎总是需要加字段的。
┌────────┐ 1 N ┌──────────┐ N 1 ┌─────────────┐
│ User │──────>│ SavedJob │<──────│ JobPosting │
└────────┘ └──────────┘ └─────────────┘
多对多本质上是两个一对多在中间碰头。
CREATE TABLE SavedJob ( userId INT NOT NULL, jobPostingId INT NOT NULL, savedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (userId, jobPostingId), FOREIGN KEY (userId) REFERENCES User(id), FOREIGN KEY (jobPostingId) REFERENCES JobPosting(id)
);
SavedJob 表存两个外键,两个一起作为主键。这样保证同一个职位不会被同一个用户收藏两次。
model User { id Int @id @default(autoincrement()) name String email String @unique jobPostings JobPosting[] savedJobs SavedJob[]
} model JobPosting { id Int @id @default(autoincrement()) title String salary Int? user User @relation(fields: [userId], references: [id]) userId Int savedBy SavedJob[]
} model SavedJob { user User @relation(fields: [userId], references: [id]) userId Int jobPosting JobPosting @relation(fields: [jobPostingId], references: [id]) jobPostingId Int savedAt DateTime @default(now()) @@id([userId, jobPostingId])
}
几个注意点:
SavedJob 就是一张普通模型。它有自己的字段,包括两个外键。就是 MySQL 里的中间表,只是用 Prisma 模型写了出来。@@id([userId, jobPostingId]) 是复合主键。效果和 MySQL 的 PRIMARY KEY (userId, jobPostingId) 一样。User 和 JobPosting 各自都有一个 SavedJob[] 字段。因为两张表都可以作为"一"端,指向 SavedJob 这个"多"端。注意:数组在 Prisma 里永远不可能是可选的。写
SavedJob[],不要写SavedJob[]?。空数组已经表示"没有",所以"缺失"和"空"没有区别。?修饰符只对标量(String?)和单数关系(Profile?)有效。
显式写法比隐式多打一些字,但能自带 savedAt 时间戳,而且跟 MySQL 脑子里的预期完全对应。
// 用户1收藏职位7
await prisma.savedJob.create({ data: { userId: 1, jobPostingId: 7, },
}); // 用户1收藏的所有职位,带职位详情
const saved = await prisma.savedJob.findMany({ where: { userId: 1 }, include: { jobPosting: true },
}); // 取消收藏:删掉这条中间记录
await prisma.savedJob.delete({ where: { userId_jobPostingId: { userId: 1, jobPostingId: 7 }, },
});
userId_jobPostingId 这种写法是 Prisma 暴露复合主键的方式。两个字段名以下划线连接。
where: { userId: 1, jobPostingId: 7 }?好问题。看起来应该可以,但 delete 不行。原因如下。
prisma.savedJob.delete() 的 where 只接受唯一标识符。复合主键的情况下,唯一标识符是两个字段合在一起,单拿任何一个都不够。userId: 1 单独存在不唯一(用户1可能收藏了50个职位),jobPostingId: 7 同样。所以 Prisma 要求把两个值打包成一个对象,也就是 userId_jobPostingId 这个包装。
平铺的写法可以用,但只适用于接受任意过滤条件的"复数"方法:
// 可以:deleteMany 接受任意过滤条件
await prisma.savedJob.deleteMany({ where: { userId: 1, jobPostingId: 7 },
}); // 不行:delete 需要唯一键
await prisma.savedJob.delete({ where: { userId: 1, jobPostingId: 7 },
});
所以规则还是那样:单数方法需要唯一键,复数方法接受任意条件。复合主键只是让单数方法多了个包装步骤。
| 方法 | 复合主键语法 |
|---|---|
delete |
包装后:{ userId_jobPostingId: { userId, jobPostingId } }
|
update |
包装(同样) |
findUnique |
包装(同样) |
deleteMany |
平铺:{ userId, jobPostingId } |
updateMany |
平铺 |
findMany |
平铺 |
findFirst |
平铺 |
有时候中间表真的只是个连接,没有 savedAt、备注、或者其他字段。就是"这个用户关联那个职位"或者"这个职位有这些技能"。这种情况下显式建 SavedJob 就过度了,Prisma 提供了更短的写法,叫隐式多对多。
在两张模型上各写一个互相指向的列表字段,不用 @relation,也不需要中间模型:
model JobPosting { id Int @id @default(autoincrement()) title String skills Skill[]
} model Skill { id Int @id @default(autoincrement()) name String @unique jobs JobPosting[]
}
就这些。Prisma 在背后自动创建一张隐藏的中间表(名字类似 _JobPostingToSkill)并帮你管理。你永远不需要写这个中间模型。
关联和取消关联是通过父模型操作的,不是通过中间表:
// 给职位添加技能
await prisma.jobPosting.update({ where: { id: 1 }, data: { skills: { connect: [{ id: 5 }, { id: 7 }] }, },
}); // 查一个职位,同时带出技能
const job = await prisma.jobPosting.findUnique({ where: { id: 1 }, include: { skills: true },
}); // 从职位移除一个技能
await prisma.jobPosting.update({ where: { id: 1 }, data: { skills: { disconnect: { id: 7 } }, },
});
没有 prisma.jobPostingSkill 可以直接查,因为根本没有这个模型。这就是取舍。
| 情况 | 选哪种 |
|---|---|
| 只是连接,不需要额外字段 | 隐式 |
| 需要时间戳、状态、角色等中间字段 | 显式模型 |
| 需要查询中间表本身(比如"上周所有收藏记录") | 显式模型 |
| 以后可能加字段 | 显式模型 |
一个实用经验:先用隐式,一旦你想加字段就立刻升级成显式。迁移成本很小,而且不用为永远用不上的中间模型多操心。
回到我们的应用:
User 收藏 JobPosting → 显式(需要 savedAt)。JobPosting 有多个 Skill → 隐式(不需要额外字段)。两种都是合法的多对多,只是"需要了解中间表多少"这个尺度不同。
大多数情况下 Prisma 能自己搞清楚哪个反向引用对应哪个关系。但有一种情况它搞不定,需要你帮忙:两张模型之间有超过一条关系。
假设招聘平台加了管理员审核功能。JobPosting 由发布者创建,由管理员审批。这两个角色都是 User。
写出来是这样的:
model JobPosting { id Int @id @default(autoincrement()) title String poster User @relation(fields: [posterId], references: [id]) posterId Int approvedBy User? @relation(fields: [approvedById], references: [id]) approvedById Int?
} model User { id Int @id @default(autoincrement()) postedJobs JobPosting[] approvedJobs JobPosting[]
}
跑一下 prisma validate 就报错了。Prisma 看到 JobPosting 上有两个 User 字段,User 上有两个 JobPosting[] 字段,但它不知道哪个跟哪个配对。postedJobs 应该跟着 poster 还是 approvedBy?它猜不出来。
解决方法是给关系起名字,让 Prisma 能配对上:
model JobPosting { id Int @id @default(autoincrement()) title String poster User @relation("PostedBy", fields: [posterId], references: [id]) posterId Int approvedBy User? @relation("ApprovedBy", fields: [approvedById], references: [id]) approvedById Int?
} model User { id Int @id @default(autoincrement()) postedJobs JobPosting[] @relation("PostedBy") approvedJobs JobPosting[] @relation("ApprovedBy")
}
@relation 的第一个参数现在是个字符串标签。同一关系的两端用同一个标签。这样 Prisma 就知道 postedJobs 属于 poster,approvedJobs 属于 approvedBy。
名字本身随便取,好读就行。"PostedBy"、"poster"、"AuthoredBy" 都行。它们不会出现在你的 TypeScript 代码里,只是给 Prisma 用来关联 schema 的。
如果你写过 Eloquent,可能会觉得似曾相识。Laravel 里当一个模型对同一个另一模型有多条关系时,也得明确指定外键列,不然 Eloquent 会猜错:
class JobPosting extends Model
{ public function poster() { return $this->belongsTo(User::class, 'poster_id'); } public function approvedBy() { return $this->belongsTo(User::class, 'approved_by_id'); }
}
同一个问题(两张模型之间有多条关系),不同的解法。Laravel 要列名,Prisma 要关系名。两者做的是同一件事:告诉框架"别猜,用这个"。
如果两张模型之间只有一条关系,别加名字,默认值够用:
poster User @relation(fields: [posterId], references: [id])
在这里加 "PostedBy" 没有意义。关系名只在需要消除歧义时才有用,给单关系加上去只是增加噪音。
同样的问题也出现在模型指向自己的时候。比如"用户关注其他用户":
model User { id Int @id @default(autoincrement()) name String followers User[] @relation("UserFollows") following User[] @relation("UserFollows")
}
User 有两个列表字段都指向 User。不加 "UserFollows" 标签的话,Prisma 搞不清哪边是哪边。命了名才能配对上。
(自引用场景通常也需要一个显式中间模型,但命名思路是一样的。)
所有内容总结成一张表,收藏这篇备用。
| 形态 | 外键在哪边? | 反向引用类型 | 备注 |
|---|---|---|---|
| 一对多 | 有(多的一端) | Model[] |
最常见的形态 |
| 一对一 | 有,加 @unique |
Model? |
@unique 是关键 |
| 多对多(隐式) | 隐藏,Prisma 管理 | 两端都是 Model[] |
中间表没额外字段时用 |
| 多对多(显式) | 两个外键在中间模型 | 两端都是 JoinModel[] |
中间表有 savedAt 等字段时用 |
Schema 规则:
@relation(fields, references) 放在有外键的那一端。@relation 属性。[Model] 是"多个"。Model? 是"可选一个"。Model(无符号)是"恰好一个"。Model[],不写 Model[]?。@relation("PostedBy", ...),同一关系的两端名字相同。onDelete真实项目里需要决定:删用户时,他发的职位怎么处理?MySQL 有 ON DELETE CASCADE 那一套。Prisma 有同样的选项,这样写:
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
选项如下:
| 选项 | 行为 |
|---|---|
Cascade |
父记录删除时,子记录同步删除 |
SetNull |
父记录删除时,子记录的外键设为 null |
Restrict |
有子记录存在时拒绝删除父记录(默认行为) |
NoAction |
类似 Restrict,区别在数据库层面的细节 |
SetDefault |
父记录删除时,子记录的外键设为其默认值 |
我们的应用:如果希望删用户时同时删掉他发布的所有职位,就在 JobPosting.user 的关系上加 onDelete: Cascade。和 MySQL 完全对应。
外键在有
@relation(fields, references)的那一端。另一端只是命名这个关系,好让代码里能访问到它。多对多就是两个一对多在中间模型碰头,和 MySQL 里的 join table 完全一样。
如果这两句话听起来合理,你就掌握了全貌。connect、include、复合主键,都只是上面这套逻辑的语法糖。
打开你自己的 schema,对每条关系试着回答这三个问题:
Model、Model?、还是 Model[])?三个问题都能答上来,模型就稳了。这篇做的招聘平台是个好模板,新项目起步时随时回来对照着看。
原文链接:https://dev.to/prisma/prisma-relationships-finally-explained-with-mysql-side-by-side-2m6n