NocoBase 多对多(数组)字段(M2M Array)完整指南:用数组字段替代中间表实现多对多关联
【免费下载链接】nocobaseNocoBase is an open-source AI + no-code platform for building business systems fast. Instead of generating everything from scratch, AI works on top of production-proven infrastructure and a WYSIWYG no-code interface, so you get both speed and reliability.项目地址: https://gitcode.com/GitHub_Trending/no/nocobase
多对多(数组)关联是 NocoBase@nocobase/plugin-field-m2m-array插件提供的特殊关联类型,它允许你在源表中用一个数组字段直接保存目标表记录的多个唯一键,从而建立"文章 ↔ 标签"这类多对多关系而无需创建中间表。本文将以该插件为核心,系统讲解字段配置的四个参数、各数据库下数组字段的类型映射、数据读写 API 行为,并结合仓库源码剖析其自动建字段、写入钩子与校验规则等底层实现,帮助你判断何时该用、如何配置、以及它相对标准多对多的边界与限制。
什么是多对多(数组)关联
在常规的数据建模中,多对多关系通常依赖一张中间表(关联表)来记录两端记录的外键对应关系。而 NocoBase 提供的"多对多(数组)"则是一种去中间表的实现:在源表上新增一个数组字段,数组中的每个元素存放目标表中某条记录的"唯一键"(Target key)。
以文档中的经典案例为例:存在"文章"(Articles)和"标签"(Tags)两个实体,一篇文章可以打多个标签,一个标签也可以被多篇文章使用。此时可以在文章表中用一个数组字段(例如tag_ids)保存标签表对应记录的 ID,例如tag_ids = [1, 3, 7],就表示这篇文章关联了 ID 为 1、3、7 的三个标签。
该能力由 plugin-field-m2m-array 插件 提供,前端注册了mbm(Many-to-Many By Array)字段界面,后端注册了belongsToArray字段类型。
重要使用建议
官方文档明确提示了两条注意事项,使用前务必知悉:
- 优先使用中间表建立标准的多对多关系,仅在确有场景诉求时再使用本类型。中间表方案支持更丰富的关联属性(如排序、额外元数据)、更强的查询能力与更规范的数据约束。
- 目前只有使用 PostgreSQL 时,才支持用目标表的字段过滤源表数据。例如在文章-标签场景中,只有 PostgreSQL 支持用标签表的其他字段(如标题
title)来过滤文章。MySQL、SQLite 等数据库不具备该过滤能力。
字段配置界面与四个核心参数
在数据表字段配置中,选择"多对多(数组)"(Many to many (array))类型后,界面中需要依次配置以下参数。
Source collection(源表)
源表即当前字段所在的表。上例中的"文章"表就是源表,数组字段tag_ids就落在源表上。
Target collection(目标表)
目标表即要与源表建立关联关系的表。上例中的"标签"表就是目标表。该参数在源码层面即BelongsToArrayField选项中的target,在 belongs-to-array-field.ts 的checkTargetCollection()中会校验目标集合必须存在,否则该字段会进入"待处理"状态等待目标集合就绪。
Foreign key(外键)
即源表中用于存储目标表 Target key 的数组字段,它是"多对多(数组)"的核心载体。NocoBase 中该字段的类型为set,其在各数据库中的实际存储类型如下:
| NocoBase 字段类型 | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
set | array | JSON | JSON |
也就是说:PostgreSQL 会创建真正的ARRAY列(如ARRAY(VARCHAR)、ARRAY(BIGINT)),而 MySQL 与 SQLite 则以JSON列承载数组数据。这一映射在插件的服务端测试中有明确断言,可参见 m2m-array-bigint-api.test.ts 与 m2m-array-string-api.test.ts。
Target key(目标键)
源表数组字段中存储的值,对应目标表上的某个字段,该字段必须具备唯一性(如目标表的主键 ID,或一个有唯一约束的业务编码)。在 belongs-to-array-field.ts 的checkAssociationKeys()中:
targetKey为必填,缺失时抛出Target key is required in the options of many to many (array) field.;- 外键字段类型必须是
ARRAY、JSON或JSONB,否则抛出类型错误; - 在 PostgreSQL 下,还会校验数组元素类型与目标键类型必须一致,例如目标键是
BIGINT而数组元素是STRING会直接报错。
底层实现原理:从字段注册到数据写入
字段类型注册与生命周期钩子
插件服务端在 plugin.ts 的load()阶段完成了三件事:
- 在数据源管理器添加数据源之前,向
SequelizeCollectionManager注册belongsToArray字段类型; - 监听
fields.afterCreate事件,调用createForeignKey钩子自动创建外键数组字段; - 监听
fields.beforeDestroy事件,调用beforeDestroyForeignKey钩子保证删除外键字段时联动删除关联字段。
自动创建外键数组字段
配置"多对多(数组)"字段时,用户其实只需要声明关联本身,外键数组字段可以由插件自动创建。在 create-foreign-key.ts 中可以看到自动创建的规则:
- 若源表中不存在名为
foreignKey的字段,则自动创建; - 新字段的类型为
set、dataType: 'array'; - 元素类型(elementType)根据目标键字段类型自动推导:
nanoid、sequence、uid映射为string,snowflakeId映射为bigInt,其余类型直接沿用目标字段类型(见 belongs-to-array-field.ts 中的elementTypeMap); - 同时会为外键字段标记
isForeignKey: true,避免其被当成普通业务字段维护。
写入时自动解析并同步数组
真正建立关联的核心逻辑在setForeignKeyArray钩子(挂载于beforeSave),见 belongs-to-array-field.ts。当写入记录时,它接受三种形式的输入并统一归一化为目标键数组写入外键字段:
- 标量或基础类型数组:如
tags: 'a'或tags: ['a', 'c'],直接作为目标键值收集; - 对象数组:如
tags: [{ stringCode: 'a' }, { stringCode: 'c' }],会先从目标表中查出这些对象对应的targetKey值; - 对象中携带新数据:对于目标表中不存在的键(对象且没有匹配的 targetKey),会自动在目标表创建对应记录(
bulkCreate),并把新记录的 targetKey 一并写回;对已存在的对象则执行update同步其字段。
置空(tags: null)时,外键数组会被重置为[]。
关联查询与目标表字段过滤
查询方面,核心实现位于数据库层的 belongs-to-array-repository.ts:
BelongsToArrayRepository.find()(L89-L112)会先读取源记录的外键数组,再把它转成对目标表仓库的查询(filter: { [targetKey]: tks }),因此appends: ['tags']即可把关联对象展开返回;BelongsToArrayAssociation.generateInclude()(L58-L71)通过generateJoinOnForJSONArray生成基于 JSON 数组的 JOIN 条件,这正是"用目标表字段过滤源表数据"的基础——该能力依赖 PostgreSQL 的 JSON/数组查询语义,也解释了为何仅 PostgreSQL 支持此过滤。
API 使用示例(基于仓库测试用例)
插件在 server/tests下提供了完整的端到端测试,以下示例提取自 m2m-array-string-api.test.ts(目标键为字符串stringCode)与 m2m-array-bigint-api.test.ts(目标键为主键id),可直接复用到业务代码中。
创建数据
// 以对象数组写入:关联已存在的标签 const user = await db.getRepository('users').create({ values: { id: 3, username: 'c', tags: [{ stringCode: 'a' }, { stringCode: 'c' }] }, }); // user.tag_ids => ['a', 'c'] // 以基础类型数组写入:直接给目标键 const user2 = await db.getRepository('users').create({ values: { id: 4, username: 'd', tags: ['a', 'c'] }, }); // 以单个对象写入:自动归一化为单元素数组 const user3 = await db.getRepository('users').create({ values: { id: 5, username: 'e', tags: { stringCode: 'a' } }, }); // user3.tag_ids => ['a'] // 写入不存在的目标记录:自动在目标表创建 const user4 = await db.getRepository('users').create({ values: { id: 5, username: 'e', tags: [{ stringCode: 'd', title: 'd' }] }, }); // 标签表中会自动生成 stringCode='d' 的记录查询与展开关联
// 不展开:返回数组字段原始值 const users = await db.getRepository('users').find(); // [{ id: 1, username: 'a', tag_ids: ['a', 'b'] }, ...] // 展开:appends 关联字段 const users2 = await db.getRepository('users').find({ appends: ['tags'] }); // [{ id: 1, username: 'a', tags: [{ stringCode: 'a', title: 'a' }, { stringCode: 'b', title: 'b' }] }, ...] // 单条查询 const user = await db.getRepository('users').findOne({ filterByTk: 1, appends: ['tags'] }); // 关联仓库查询:users 表 id=1 的记录关联的所有标签 const repo = db.getRepository('users.tags', 1); const tags = await repo.find();更新与清空
// 整体替换关联 await db.getRepository('users').update({ filterByTk: 6, values: { tags: ['b', 'c'] } }); // tag_ids => ['b', 'c'] // 对象数组形式更新(不存在的目标自动创建) await db.getRepository('users').update({ filterByTk: 7, values: { tags: [{ stringCode: 'e', title: 'e' }] }, }); // 置空关联 await db.getRepository('users').update({ filterByTk: 6, values: { tags: null } }); // tag_ids => []用目标表字段过滤源表(仅 PostgreSQL)
// 查找所有关联了标题包含 'a' 的标签的用户 const res = await db.getRepository('users').find({ filter: { 'tags.title': { $includes: ['a'] } }, });约束与校验一览(源码级证据)
综合 belongs-to-array-field.ts 与两个钩子文件,配置该字段时会触发以下校验与联动:
| 规则 | 触发条件 | 行为 |
|---|---|---|
| 目标表必填 | 配置belongsToArray字段时 | 目标集合不存在则挂起等待(addPendingField) |
| 目标键必填 | 配置belongsToArray字段时 | 抛错Target key is required |
| 外键必须是数组类字段 | 校验外键字段时 | 非ARRAY/JSON/JSONB抛错 |
| 元素类型匹配(仅 PostgreSQL) | PostgreSQL 下校验时 | 数组元素类型与目标键类型不一致抛错 |
| 命名冲突 | 自动创建外键时 | 外键名与关联字段名相同抛错(Naming collision) |
| 删除联动 | 删除外键数组字段时 | 自动删除引用该外键的belongsToArray关联字段(before-destroy-foreign-key.ts) |
上述规则均有对应测试用例佐证,见 belongs-to-array-field.test.ts。
适用场景与选择建议
结合文档建议与源码能力,可以得出如下判断:
- 适合:标签/分类/多选维度这类"两端结构简单、关联本身无需附加属性、读写以整组替换为主"的场景;希望省去中间表建模与联表查询成本的场景;PostgreSQL 用户需要按目标表字段反查源数据的场景。
- 不适合:需要为关联关系记录额外信息(如关联创建时间、排序、角色)的场景——此时应回退到标准的多对多(中间表)方案;MySQL/SQLite 用户如果核心诉求是用目标表字段过滤源表,该能力不可用。
另外需要注意 bigInt 目标键在 PostgreSQL 下的表现:数组元素会以字符串形式返回(如['1', '2']),而 MySQL/SQLite 下保持数字形式([1, 2]),跨库开发时需要留意类型差异(测试断言见 m2m-array-bigint-api.test.ts)。
深入阅读
- 插件完整源码:packages/plugins/@nocobase/plugin-field-m2m-array/src
- 服务端字段实现:belongs-to-array-field.ts
- 关联仓库与 JOIN 实现:belongs-to-array-repository.ts
- 端到端测试:server/tests
- 标准多对多(中间表)方案:data-modeling/collection-fields/associations/m2m
【免费下载链接】nocobaseNocoBase is an open-source AI + no-code platform for building business systems fast. Instead of generating everything from scratch, AI works on top of production-proven infrastructure and a WYSIWYG no-code interface, so you get both speed and reliability.项目地址: https://gitcode.com/GitHub_Trending/no/nocobase
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考