site logo

Marico's space

Prisma 关系,一文读懂(MySQL 对比)

算法解析 2026-05-09 03:12:26 7

最近在项目里折腾 Prisma,关系配置这块被绕了好几圈才搞清楚。这篇把几种常见的表关系讲清楚,每种都配上 MySQL 的写法和平面图,两边对照着看,理解起来快得多。

你本来就会 MySQL 和 ER 图,这篇的目标不是教你怎么写外键,而是让 Prisma 的语法真正"对得上号",不再靠猜。

唯一核心思想,搞懂就通了

大多数人卡在这里,是因为 Prisma 要求在两个模型上都声明关系。看起来像是在说两遍一样,但其实不是。

记住这条规则,所有东西都通了:

外键列只存在于其中一张表。
两张模型互相命名,这样 Prisma 从两个方向都能找到关联。

MySQL 里外键只写一次,写在持有外键的那张表上。Prisma 仍然这样处理,但还会问另一张表从自己的角度命名这个关系——纯粹是为了 JavaScript 代码里能写 user.jobPostings。第二个声明不会产生任何额外的列。

记住这个,后面看例子就清晰了。

我们做的这个应用

一个简单的招聘平台,追踪三样东西:

  • User(使用平台的用户)
  • Profile(用户的扩展信息,比如简介和头像)
  • JobPosting(用户发布的职位)
  • SavedJob(用户收藏的职位)

这覆盖了四种常见的关系形态:

形态 在我们应用中的例子
一对一 一个 User 对应一个 Profile
一对多 一个 User 发布多个 JobPosting
多对一 一个 JobPosting 属于一个 User(同一个东西)
多对多 用户收藏多个职位,职位被多个用户收藏

逐个来看。

1. 一对多(和多对一)

这是最常见的形态。一个用户发多个职位,每个职位属于一个用户。

ER 图

┌────────┐ 1 N ┌─────────────┐
│ User │───────────>│ JobPosting │
└────────┘ └─────────────┘

箭头从 User("一"端)指向 JobPosting("多"端)。

MySQL 版本

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 的列。用户不需要知道自己发了哪些职位,职位知道自己属于哪个用户就行了。

Prisma 版本

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
}

注意三件事:

  1. 真正的列是 JobPosting 上的 userId这就是外键,和你在 MySQL 里写的完全一样。
  2. user User @relation(...) 不创建列。它叫"关系字段",告诉 Prisma:"我的 userId 列指向 Userid 列,代码里叫它 .user"。
  3. User 上的 jobPostings JobPosting[] 也不创建列。这是反向引用,让你能写 user.jobPostings 来查询。

数据库里一个外键,schema 里两个关系字段。每个模型一个。

@relation(fields: [userId], references: [id]) 到底在说啥

把它读成一句话:

"用我的 userId 列(fields)指向 Userid 列(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 语法里最实用的操作之一,一旦掌握就离不开了。

2. 一对一

一个用户有一个扩展 profile,profile 只属于这一个用户。

ER 图

┌────────┐ 1 1 ┌─────────┐
│ User │────────────│ Profile │
└────────┘ └─────────┘

一对一画出来是条直线,没有箭头,因为两端都是"1",没有"多"端可指。基数标签承载了全部含义。

MySQL 版本

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 了。

Prisma 版本

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
}

和一对多比有两处变化:

  1. profile Profile? 而不是 Profile[]单数,不是列表。? 表示"可选",符合现实:用户可能还没创建 profile。
  2. 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 最好用的特性之一。两张表,一行代码,一个事务。

3. 多对多

用户可以收藏感兴趣的职位。每个用户收藏多个职位,每个职位可以被多个用户收藏。

MySQL 用中间表处理这种关系。Prisma 有两种选择:隐式(Prisma 自动生成中间表)和显式(自己建表)。这里讲显式,因为和 MySQL 完全对应,而且以后要加字段时更灵活——实际项目中几乎总是需要加字段的。

ER 图

┌────────┐ 1 N ┌──────────┐ N 1 ┌─────────────┐
│ User │──────>│ SavedJob │<──────│ JobPosting │
└────────┘ └──────────┘ └─────────────┘

多对多本质上是两个一对多在中间碰头。

MySQL 版本

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 表存两个外键,两个一起作为主键。这样保证同一个职位不会被同一个用户收藏两次。

Prisma 版本

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])
}

几个注意点:

  1. SavedJob 就是一张普通模型。它有自己的字段,包括两个外键。就是 MySQL 里的中间表,只是用 Prisma 模型写了出来。
  2. @@id([userId, jobPostingId]) 是复合主键。效果和 MySQL 的 PRIMARY KEY (userId, jobPostingId) 一样。
  3. UserJobPosting 各自都有一个 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 属于 posterapprovedJobs 属于 approvedBy

名字本身随便取,好读就行。"PostedBy""poster""AuthoredBy" 都行。它们不会出现在你的 TypeScript 代码里,只是给 Prisma 用来关联 schema 的。

和 Laravel 的类比

如果你写过 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 完全一样。

如果这两句话听起来合理,你就掌握了全貌。connectinclude、复合主键,都只是上面这套逻辑的语法糖。

小练习

打开你自己的 schema,对每条关系试着回答这三个问题:

  1. 实际的外键列在哪边?
  2. 另一边的反向引用是什么类型(ModelModel?、还是 Model[])?
  3. 删除时要级联吗,还是希望直接报错?

三个问题都能答上来,模型就稳了。这篇做的招聘平台是个好模板,新项目起步时随时回来对照着看。

原文链接:https://dev.to/prisma/prisma-relationships-finally-explained-with-mysql-side-by-side-2m6n