ManyToMany

ManyToMany

本指南涵盖 manyToMany 关联关系。你将学会如何:

  • 声明 manyToMany 关联关系并理解其选项
  • 包含中间表列和时间戳
  • 加载、约束和过滤关联行
  • 直接对中间表列进行查询
  • 通过 attachdetachsync 以及 save 和 create 助手方法持久化关联关系

概述

manyToMany 关联关系通过一个持有双方外键的中间表连接两个模型。两个模型都不直接持有对方的外键。一个用户有多个技能,一个技能属于多个用户;一篇文章有多个标签,一个标签属于多篇文章。

app/models/user.ts
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 形式。UserSkill 产生 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

在中间表行上启用创建和更新时间戳。当通过 attachsyncsavecreate 插入行时,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>

preloadrelated('skills').query()related('skills').pivotQuery() 以及 haswhereHaswithCountwithAggregate 使用的子查询上触发。不在 attachdetachsyncsavesaveManycreatecreateMany 上触发,因为它们直接写入中间表。

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')

按关联关系过滤

在父记录的查询构建器上使用 haswhereHas 以基于关联记录的存在性限制行。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关联关系有匹配行
orHasOR 组合的存在性检查
doesntHave / andDoesntHave关联关系没有匹配行
orDoesntHaveOR 组合的不存在检查
whereHas / andWhereHas关联关系有带约束的匹配行
orWhereHasOR 组合的 whereHas
whereDoesntHave / andWhereDoesntHave关联关系没有带约束的匹配行
orWhereDoesntHaveOR 组合的 whereDoesntHave

聚合

使用 withCountwithAggregate 从关联关系加载派生值,而不加载行本身。结果存放在父记录的 $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 子句
orWherePivotOR 组合的 wherePivot
whereNotPivot / andWhereNotPivot在中间表列上的 WHERE NOT
orWhereNotPivotOR 组合的 whereNotPivot
whereInPivot(key, values) / andWhereInPivot在中间表列上的 WHERE IN
orWhereInPivotOR 组合的 whereInPivot
whereNotInPivot / andWhereNotInPivot在中间表列上的 WHERE NOT IN
orWhereNotInPivotOR 组合的 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 集成。