ManyToMany
本指南涵盖 manyToMany 关联关系。你将学会如何:
- 声明
manyToMany关联关系并理解其选项 - 包含中间表列和时间戳
- 加载、约束和过滤关联行
- 直接对中间表列进行查询
- 通过
attach、detach、sync以及 save 和 create 助手方法持久化关联关系
概述
manyToMany 关联关系通过一个持有双方外键的中间表连接两个模型。两个模型都不直接持有对方的外键。一个用户有多个技能,一个技能属于多个用户;一篇文章有多个标签,一个标签属于多篇文章。
import { manyToMany } from '@adonisjs/lucid/orm'
import type { ManyToMany } from '@adonisjs/lucid/types/relations'
import { UsersSchema } from '#database/schema'
import Skill from '#models/skill'
export default class User extends UsersSchema {
@manyToMany(() => Skill)
declare skills: ManyToMany<typeof Skill>
}
多对多关联关系的两侧通常都会声明。Skill 模型声明 manyToMany(() => User) 以遍历回其用户。
参见关联关系简介了解支持此关联关系的迁移,包括中间表布局和防止重复对的复合唯一索引。
选项
装饰器接受一个 options 对象作为第二个参数。
@manyToMany(() => Skill, {
pivotTable: 'user_skills',
pivotForeignKey: 'user_id',
pivotRelatedForeignKey: 'skill_id',
pivotColumns: ['proficiency', 'last_used_at'],
pivotTimestamps: true,
})
declare skills: ManyToMany<typeof Skill>
-
pivotTable
-
中间表的名称。默认为两个模型名按字母顺序排序并用下划线连接的 snake_case 形式。
User和Skill产生skill_user。当你的中间表使用不同名称时覆盖。 -
localKey
-
此模型上中间表的
pivotForeignKey指向的列。默认为此模型的主键。 -
pivotForeignKey
-
中间表上指向此模型的列。默认为
{ThisModel}_{primaryKey}的 snake_case 形式。对于User.primaryKey = 'id',默认值为user_id。 -
relatedKey
-
关联模型上中间表的
pivotRelatedForeignKey指向的列。默认为关联模型的主键。 -
pivotRelatedForeignKey
-
中间表上指向关联模型的列。默认为
{RelatedModel}_{primaryKey}的 snake_case 形式。对于Skill.primaryKey = 'id',默认值为skill_id。 -
pivotColumns
-
每次加载关联关系时要选择的额外中间表列数组。选择的列存放在每个加载的关联实例的
$extras.pivot_{column}上。参见读取中间表数据。@manyToMany(() => Skill, {pivotColumns: ['proficiency', 'last_used_at'],})declare skills: ManyToMany<typeof Skill> -
pivotTimestamps
-
在中间表行上启用创建和更新时间戳。当通过
attach、sync、save或create插入行时,Lucid 会自动设置这些值。// 使用默认列名启用(created_at, updated_at)@manyToMany(() => Skill, { pivotTimestamps: true })declare skills: ManyToMany<typeof Skill>// 自定义列名或禁用其中一个@manyToMany(() => Skill, {pivotTimestamps: {createdAt: 'joined_at',updatedAt: false,},})declare skills: ManyToMany<typeof Skill>当
pivotTimestamps启用时,对应的列必须存在于中间表迁移中。 -
onQuery
-
Lucid 为该关联关系生成的每个读取查询上运行的回调。
@manyToMany(() => Skill, {onQuery: (query) => query.whereNull('skills.deleted_at'),})declare skills: ManyToMany<typeof Skill>在
preload、related('skills').query()、related('skills').pivotQuery()以及has、whereHas、withCount和withAggregate使用的子查询上触发。不在attach、detach、sync、save、saveMany、create或createMany上触发,因为它们直接写入中间表。 -
meta
-
附加到关联关系定义的任意元数据。Lucid 不读取此字段;它可用于你在运行时检查关联关系定义的工具。
加载关联行
使用 preload 预加载
在查询构建器上调用 preload('skills') 以在每个返回的用户上水合关联关系。Lucid 自动连接中间表,因此声明的 pivotColumns 会免费带来。
const users = await User.query().preload('skills')
users.forEach((user) => {
user.skills.forEach((skill) => {
console.log(skill.name, skill.$extras.pivot_proficiency)
})
})
传入回调以过滤或排序关联关系查询,并可选择在查询时使用 .pivotColumns([...]) 选择额外的中间表列。
await User.query().preload('skills', (skillsQuery) => {
skillsQuery
.wherePivot('proficiency', '>=', 3)
.pivotColumns(['notes'])
.orderBy('name', 'asc')
})
当没有关联行时,user.skills 是一个空数组。
从实例延迟加载
当你已经拥有父实例且仅在某些代码路径中需要关联关系时,通过 related('skills').query() 构建查询。
const user = await User.findOrFail(params.id)
const topSkills = await user
.related('skills')
.query()
.wherePivot('proficiency', '>=', 4)
.orderBy('name', 'asc')
读取仅中间表数据
related('skills').pivotQuery() 仅对中间表运行查询,而不加载关联行。当你只需要检查或更新中间表属性时很有用。
const user = await User.findOrFail(params.id)
// 计算此用户标记为专家的技能数量
const expertCount = await user
.related('skills')
.pivotQuery()
.wherePivot('proficiency', 5)
.count('* as total')
按关联关系过滤
在父记录的查询构建器上使用 has 和 whereHas 以基于关联记录的存在性限制行。manyToMany 支持带有运算符和值的基于计数的过滤。
// 至少有一个技能的用户
const skilled = await User.query().has('skills')
// 有五个或更多技能的用户
const highlySkilled = await User.query().has('skills', '>=', 5)
// 至少有一个专家级技能的用户
const experts = await User.query().whereHas('skills', (skillsQuery) => {
skillsQuery.wherePivot('proficiency', 5)
})
组合和取反的变体:
| 方法 | 描述 |
|---|---|
has / andHas | 关联关系有匹配行 |
orHas | OR 组合的存在性检查 |
doesntHave / andDoesntHave | 关联关系没有匹配行 |
orDoesntHave | OR 组合的不存在检查 |
whereHas / andWhereHas | 关联关系有带约束的匹配行 |
orWhereHas | OR 组合的 whereHas |
whereDoesntHave / andWhereDoesntHave | 关联关系没有带约束的匹配行 |
orWhereDoesntHave | OR 组合的 whereDoesntHave |
聚合
使用 withCount 和 withAggregate 从关联关系加载派生值,而不加载行本身。结果存放在父记录的 $extras 对象上。
const users = await User.query().withCount('skills')
users.forEach((user) => {
console.log(user.$extras.skills_count)
})
当默认的 {relation}_count 不是你想要的时,通过回调覆盖别名。
const users = await User
.query()
.withCount('skills', (query) => {
query.wherePivot('proficiency', '>=', 4).as('expertSkillsCount')
})
withAggregate 运行任何聚合函数。
const users = await User
.query()
.withAggregate('skills', (query) => {
query.max('pivot_last_used_at').as('lastSkillUsedAt')
})
中间表特定的查询子句
关联关系查询上的常规 where 子句应用于关联模型的列。要对中间表应用子句,使用 wherePivot 系列。
const user = await User.findOrFail(params.id)
const experts = await user
.related('skills')
.query()
.wherePivot('proficiency', 5)
.whereInPivot('category', ['languages', 'frameworks'])
完整的中间表感知助手集合:
| 方法 | 描述 |
|---|---|
wherePivot(key, op?, value) / andWherePivot | 在中间表列上添加 WHERE 子句 |
orWherePivot | OR 组合的 wherePivot |
whereNotPivot / andWhereNotPivot | 在中间表列上的 WHERE NOT |
orWhereNotPivot | OR 组合的 whereNotPivot |
whereInPivot(key, values) / andWhereInPivot | 在中间表列上的 WHERE IN |
orWhereInPivot | OR 组合的 whereInPivot |
whereNotInPivot / andWhereNotInPivot | 在中间表列上的 WHERE NOT IN |
orWhereNotInPivot | OR 组合的 whereNotInPivot |
pivotColumns([...]) | 仅为此次查询选择额外的中间表列 |
读取中间表数据
通过装饰器上的 pivotColumns 声明或通过查询上的 .pivotColumns([...]) 选择的中间表列,以 $extras.pivot_{column} 键存放在每个关联实例上。
const user = await User.query().preload('skills').firstOrFail()
user.skills.forEach((skill) => {
console.log(skill.name, skill.$extras.pivot_proficiency, skill.$extras.pivot_last_used_at)
})
如果 pivotTimestamps 启用,$extras.pivot_created_at 和 $extras.pivot_updated_at(或你的自定义列名)可一并使用。
有关将中间表数据序列化为 JSON 响应,请参见序列化模型指南。
通过关联关系持久化
以下每个持久化方法都在托管事务中运行。父记录首先被保存,关联行被创建或更新,中间表行一起插入或更新。如果任何操作失败,整批操作回滚。
attach
attach 向中间表插入行。传入要附加的关联 id 数组,或传入键为关联 id、值为中间表属性的对象。
const user = await User.findOrFail(1)
// 通过 id 附加
await user.related('skills').attach([1, 2, 3])
// 每个关联关系带有中间表属性
await user.related('skills').attach({
1: { proficiency: 5, notes: 'primary' },
2: { proficiency: 3 },
3: {},
})
attach 不去重。传入已存在于中间表中的 id 会产生错误(或如果没有定义唯一索引则产生重复行)。当你想要幂等行为时使用 sync。
detach
detach 移除中间表行。传入关联 id 数组以分离特定行,或不带参数调用以分离此父记录的所有关联行。
// 分离特定技能 id
await user.related('skills').detach([2, 3])
// 分离此用户的所有技能
await user.related('skills').detach()
detach 不会删除关联行本身。它仅从中间表中移除行。
sync
sync 将中间表状态与目标集合协调。传入 id 数组或 id 到属性的对象。Lucid 计算差异并:
- 插入目标中存在但数据库中尚未存在的中间表行
- 更新属性已更改的中间表行
- 删除数据库中存在但目标中不存在的中间表行
// 完全用此集合替换用户的技能
await user.related('skills').sync([1, 2, 3])
// 带有中间表属性
await user.related('skills').sync({
1: { proficiency: 5 },
2: { proficiency: 3 },
3: { proficiency: 1 },
})
要在不移除缺失行的情况下添加和更新,传入 false 作为第二个参数。这将 sync 转变为幂等的 attach,同时更新现有行上的中间表属性。
await user.related('skills').sync([1, 2, 3], false)
// 技能 1、2、3 如果缺失则被附加;其他技能保持不变
save 和 saveMany
save(related) 持久化关联模型实例并附加它。如果关联行已存在且有主键,Lucid 保存它(当没有列更改时为 no-op)并通过 sync 确保中间表行存在。saveMany(related[]) 对多个做同样的事。
const skill = new Skill()
skill.name = 'TypeScript'
await user.related('skills').save(skill)
在 save 上传入中间表属性作为第三个参数,或在 saveMany 上传入与关联模型对齐的数组。
await user.related('skills').save(skill, true, { proficiency: 4 })
await user.related('skills').saveMany(
[skillA, skillB],
true,
[{ proficiency: 5 }, { proficiency: 3 }]
)
第二个参数(performSync)控制重复处理。true(默认值)运行 sync 步骤以避免重复的中间表行。false 回退到普通的 attach,这更快但在重复对上会产生错误。
create 和 createMany
create(values) 从值构建新的关联实例,持久化它,并将其附加到父记录。createMany(values[]) 对多个做同样的事。两者都接受中间表属性作为额外参数。
const skill = await user.related('skills').create(
{ name: 'TypeScript' },
{ proficiency: 4 }
)
const skills = await user.related('skills').createMany(
[{ name: 'TypeScript' }, { name: 'PostgreSQL' }],
[{ proficiency: 5 }, { proficiency: 3 }]
)
分页
当你通过 related('skills').query() 延迟加载关联关系时,paginate(page, perPage) 可以工作。在 preload 回调内分页会抛出异常,因为单个分页器无法表达每个父记录的页面边界。
const user = await User.findOrFail(params.id)
const skills = await user
.related('skills')
.query()
.orderBy('name', 'asc')
.paginate(page, 20)
参见分页指南了解分页器 API、URL 自定义和 transformer 集成。