MikroORM Collections 完全指南:OneToMany 与 ManyToMany 关系集合的初始化、操作与高级用法
发布时间:2026/9/25 3:28:58来源:尧图网络
后端【免费下载链接】mikro-ormTypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, MariaDB, MS SQL Server, PostgreSQL and SQLite/libSQL databases.项目地址https://gitcode.com/gh_mirrors/mi/mikro-orm点击查看免费下载MikroORM 将OneToMany与ManyToMany属性统一封装在Collection包装器中为开发者提供了一套类数组、可迭代、可惰性加载的关系集合 API。本文基于 docs/versioned_docs/version-6.6/collections.md 的完整内容结合 packages/core/src/entity/Collection.ts 源码实现展开讲解帮助你掌握集合的初始化语义、增删传播机制、pivot 表定制、局部加载与过滤排序等实战技巧并理解这些 API 在底层是如何工作的。Collection 是什么一对多与多对多关系的统一包装器在 MikroORM 中无论是OneToMany一对多还是ManyToMany多对多关系的“多”一侧都保存在一个Collection实例中。它并不是一个普通的数组而是一个由 ORM 托管的、带有初始化状态initialized、脏标记dirty、快照snapshot与传播逻辑propagation的集合对象。从源码结构看Collection类内部使用SetT存储条目并通过#initialized、#dirty、#partial、#readonly、#snapshot等私有状态记录集合的生命周期见 Collection.ts。理解这些状态是正确使用集合 API 的前提initialized集合是否已经从数据库加载了条目dirty集合是否被修改过添加/移除过条目partial集合是否只加载了部分条目局部加载时置位此时禁用传播readonly通过matching({ store: true })写入后集合变为只读add/remove会抛错。遍历与读取集合项Collection实现了迭代器协议因此可以直接用for...of循环遍历它。遍历时每个条目都是已加载的实体实例而不是普通引用const author em.findOne(Author, ..., { populate: [books] }); // 一次性加载 books 集合 // 或者稍后通过 load() 方法惰性加载它与 init() 不同 // 会先检查集合状态若已初始化则什么都不做 // await author.books.load(); for (const book of author.books) { console.log(book.title); // 已初始化 console.log(book.author.isInitialized()); // true console.log(book.author.id); console.log(book.author.name); // Jon Snow console.log(book.publisher); // 仅仅是引用 console.log(book.publisher.isInitialized()); // false console.log(book.publisher.id); console.log(book.publisher.name); // undefined }除了for...of还可以像访问数组元素一样使用方括号语法console.log(author.books[1]); // Book console.log(author.books[12345]); // undefined即使集合未初始化也不会报错需要特别注意的是方括号访问不会检查集合是否已初始化而get()方法此处指getItems()在集合未初始化时会抛出异常。另外方括号语法只能读取已加载的条目不能通过这种方式向集合添加新条目。如果要一次性取出集合中的所有实体使用getItems()console.log(author.books.getItems()); // Book[]它默认会在集合未初始化时抛错若想关闭这项校验可以传入getItems(false)此时返回的是由身份映射identity map托管的实体实例。从源码看getItems(check)正是通过checkInitialized()实现该校验的见 Collection.ts。如果想要的是可序列化的 DTO 数组则用toArray()——它会把集合序列化为普通对象数组修改这些 DTO不会影响真实实体实例console.log(author.books.toArray()); // EntityDTOBook[]初始化与惰性加载load()、init()、loadItems()、loadCount()一个没有被 populate 的集合是未初始化的。此时你不能调用任何“读取型”方法如getItems()、count()否则会抛出形如CollectionBook of entity Author[...] not initialized的异常该异常由 Collection.ts 中的checkInitialized()抛出。MikroORM 提供了几个语义不同的初始化入口const author em.findOne(Author, ...); // books 集合未被 populate const count await author.books.loadCount(); // 从数据库查询集合条目数量而非统计已加载条目 console.log(author.books.getItems()); // 抛错集合尚未初始化 console.log(await author.books.loadItems()); // 若未加载则先初始化再以数组返回条目 Book[]各方法的区别方法语义load(options?)确保集合已加载若已初始化则不会重复加载返回Collection自身与Reference.load()行为一致见 Collection.tsinit(options?)强制初始化集合重新从数据库加载条目见 Collection.tsloadItems(options?)load()getItems(false)的组合直接返回实体数组见 Collection.tsloadCount(options?)走em.count()查询数据库中的条目数结果会被缓存可用refresh: true强制刷新见 Collection.tsloadCount()的源码实现值得一提它优先复用缓存的#count在启用 dataloader全局或按查询启用DataloaderType.COLLECTION时多次调用会被批量合并成一次分组查询对于不使用 pivot 表的 MongoDB 多对多 owning 侧它直接返回this.length因为引用就内嵌在实体上。修改集合add()、remove()、removeAll() 与集合状态查询集合的增删方法在设计上刻意与“删除实体”区分开// 集合需要先初始化才能操作 author.books.add(book); console.log(author.books.contains(book)); // true console.log(author.books.exists(item item book)); // true console.log(author.books.find(item item book)); // book console.log(author.books.map(item item.title)); // 书名数组 console.log(author.books.filter(item item.title.startsWith(Foo))); // 满足回调的书籍数组 author.books.remove(book); console.log(author.books.contains(book)); // false author.books.add(book); console.log(author.books.count()); // 1 console.log(author.books.slice(0, 1)); // Book[] console.log(author.books.slice()); // Book[] console.log(author.books.slice().length); // 1 author.books.removeAll(); console.log(author.books.isEmpty()); // true console.log(author.books.contains(book)); // false console.log(author.books.count()); // 0 console.log(author.books.getItems()); // Book[] console.log(author.books.getIdentifiers()); // string | number 数组 console.log(author.books.getIdentifiers(_id)); // ObjectId 数组这些方法在 Collection.ts 中均有对应实现add()第 240 行起、remove()第 278 行起、removeAll()第 682 行起、contains()第 333 行起、count()第 343 行起、slice()第 710 行起、exists()第 734 行起、find()第 759 行起、filter()第 785 行起、map()第 802 行起、reduce()第 817 行起、getIdentifiers()第 584 行起。从集合中移除项并不等于删除实体从集合中移除条目语义是“断开关系”而不是“从数据库删除实体”。Collection.remove()与em.remove()完全是两回事——前者只是把条目从集合中拿出来数据库中的记录原封不动。同理用em.assign()更新实体时把条目从集合中移除这些条目不会被自动从数据库删除。如果你希望“从集合移除即删除实体”需要为关系属性开启orphanRemoval: true告诉 ORM“我不允许存在孤儿实体因此这些被摘除的条目应当被删除”OneToMany({ entity: () Book, mappedBy: author, orphanRemoval: true }) books new CollectionBook(this);在源码中remove()会在属性声明了orphanRemoval时调用em.getUnitOfWork().scheduleOrphanRemoval(entity)见 Collection.ts而add()则会通过cancelOrphanRemoval()撤销之前调度的孤儿删除第 267 行、第 530 行。更完整的级联与孤儿移除语义可参考 docs/docs/cascading.md——那里明确说明orphanRemoval在删除操作上等价于Cascade.REMOVE同时指定二者是冗余的。定义 OneToMany 集合OneToMany集合是ManyToOne引用的逆侧inverse side必须通过mappedBy或选项对象中的mappedBy指向 owning 侧的属性名ORM 据此知道外键fk落在哪一侧Entity() export class Book { PrimaryKey() _id!: ObjectId; ManyToOne() author!: Author; } Entity() export class Author { PrimaryKey() _id!: ObjectId; OneToMany(() Book, book book.author) books1 new CollectionBook(this); // 或者使用选项对象形式 OneToMany({ entity: () Book, mappedBy: author }) books2 new CollectionBook(this); }注意两种书写方式是等价的函数形式OneToMany(() Book, book book.author)的第二个参数即mappedBy选项对象形式则用{ entity: () Book, mappedBy: author }显式声明。new CollectionBook(this)中的this是集合的 owner 实体这是声明集合属性的固定写法。定义 ManyToMany 集合对于ManyToManySQL 驱动使用pivot 表中间表保存对双方实体的引用而 MongoDB 不需要连接表所有引用以ObjectId数组的形式直接内嵌在 owning 实体上。单向Unidirectional单向多对多只在一边定义。如果只提供entity属性则该侧被视为 owning 侧ManyToMany(() Book) books1 new CollectionBook(this); // 或者通过选项对象显式标记为 owner ManyToMany({ entity: () Book, owner: true }) books2 new CollectionBook(this);双向Bidirectional双向多对多在两侧各定义一次其中一侧是 owning 侧真正存储引用的地方通过inversedBy指向逆侧逆侧通过mappedBy指回 owning 侧// owning 侧显式声明 owner ManyToMany(() BookTag, tag tag.books, { owner: true }) tags new CollectionBookTag(this); // 或者用选项对象形式inversedBy 指向逆侧属性名 ManyToMany({ entity: () BookTag, inversedBy: books }) tags new CollectionBookTag(this);// 逆侧mappedBy 指回 owning 侧属性名 ManyToMany(() Book, book book.tags) books new CollectionBook(this); // 或者用选项对象形式 ManyToMany({ entity: () Book, mappedBy: tags }) books new CollectionBook(this);自定义 pivot 实体默认情况下ORM 会为 pivot 表自动生成一个内部实体。从 v5.1 开始可以通过pivotEntity选项提供自定义实现。pivot 实体必须恰好包含两个ManyToOne属性第一个指向 owning 实体第二个指向多对多关系的目标实体Entity() export class Order { ManyToMany({ entity: () Product, pivotEntity: () OrderItem }) products new CollectionProduct(this); }对于双向 M:N只需在 owning 侧指定pivotEntity两侧仍通过inversedBy/mappedBy互相关联Entity() export class Product { ManyToMany({ entity: () Order, mappedBy: o o.products }) orders new CollectionOrder(this); }自定义 pivot 实体的一个关键约束是如果要用 ORM 向这类 M:N 集合添加新条目所有非外键属性都必须定义数据库级别的默认值否则插入 pivot 行时缺少这些值会失败Entity() export class OrderItem { ManyToOne({ primary: true }) order: Order; ManyToOne({ primary: true }) product: Product; Property({ default: 1 }) amount!: number; }也可以直接绕开集合、操作 pivot 实体本身// 创建新条目 const item em.create(OrderItem, { order: 123, product: 321, amount: 999, }); await em.persist(item).flush(); // 或通过删除查询移除条目 const em.nativeDelete(OrderItem, { order: 123, product: 321 });此外还可以像前面的例子那样定义指向 pivot 实体的 1:m 属性来修改集合同时保留 M:N 属性用于更方便的读取与过滤。固定集合条目的顺序自 v3 起多对多集合不再强制要求 auto-increment 主键过去用主键保证固定顺序。现在通过fixedOrder: true保持集合的固定插入顺序此时 schema 生成器会把 pivot 表转换为带自增主键id的表也可以通过fixedOrderColumn: order自定义排序列名ManyToMany({ entity: () BookTag, fixedOrder: true }) tags new CollectionBookTag(this); ManyToMany({ entity: () BookTag, fixedOrder: true, fixedOrderColumn: order }) tags new CollectionBookTag(this);与之相对的是orderBy: { ... }属性它用于完全加载集合含条目时按引用实体的属性排序而不是按 pivot 表列排序。可以这样区分fixedOrder维护的是“条目被加入的顺序”orderBy则是“查询时按业务字段排序”。在源码 MetadataDiscovery.ts 中可以确认pivot 表的主键列默认取fixedOrderColumn或命名策略的referenceColumnName()当声明fixedOrder时pivot 表才会生成自增主键列第 1131、1157、1208、1218 行可见primary: !prop.fixedOrder的列定义逻辑。只填充引用Populating references有时我们只想知道集合里有哪些条目而不关心条目的字段值。此时可以只加载“引用”reference集合本身已初始化但每个条目是未初始化的引用const book1 await em.findOne(Book, 1, { populate: [tags:ref] }); console.log(book1.tags.isInitialized()); // true console.log(wrap(book1.tags[0]).isInitialized()); // false // 或者用 init({ ref: true }) const book2 await em.findOne(Book, 1); await book2.tags.init({ ref: true }); console.log(book2.tags.isInitialized()); // true console.log(wrap(book2.tags[0]).isInitialized()); // false从源码看init()在构造 populate 提示时会根据options.ref附加:ref后缀见 Collection.ts从而实现“只加载引用”的查询优化。注意这里区分了两个层面的初始化isInitialized()默认只检查集合层fully参数为 false 时而isInitialized(true)会进一步检查每个条目是否初始化见 Collection.ts。add() 与 remove() 操作的传播Propagation当你调用Collection.add()时条目不仅被加入当前集合这个动作还会传播到它的对侧集合// 一对多从 owning 侧的 ManyToOne 反推 const author new Author(...); const book new Book(...); author.books.add(book); console.log(book.author); // 借助传播author 被自动设置多对多的传播是双向的——无论是从 owning 侧还是逆侧操作都有效// 多对多owning 侧与逆侧都可以触发传播 const book new Book(...); const tag new BookTag(...); book.tags.add(tag); console.log(tag.books.contains(book)); // true tag.books.add(book); console.log(book.tags.contains(tag)); // true自 v5.2.2 起向逆侧 M:N 集合添加新条目的传播在 owning 集合未初始化时也能工作但移除操作的传播仍然要求两侧集合都已初始化。源码层面add()会对每个新条目调用this.propagate(entity, add)Collection.tspropagate()根据当前集合是 owning 侧还是逆侧分别走propagateToInverseSide或propagateToOwningSide第 943-949 行。同时源码注释也给出了一条重要实践建议虽然传播对 M:N 逆侧同样有效但应该始终通过 owning 侧来操作集合否则在 owning 侧已初始化的情况下修改逆侧会抛出cannotModifyInverseCollection校验错误第 542-572 行的validateModification()。Collection.remove()的传播行为与add()相同。初始化集合时的过滤与排序通过collection.init()初始化集合时可以同时传入where过滤条件和orderBy排序条件await book.tags.init({ where: { active: true }, orderBy: { name: QueryOrder.DESC }, });init()会把这两个选项包装成针对该集合的嵌套条件与排序见 Collection.tswhere: { [this.property.name]: options.where }、orderBy: { [this.property.name]: options.orderBy }。注意永远不要修改部分加载partial的集合——#partial标记的集合传播会被禁用第 51 行注释对其增删会产生与数据库状态不一致的结果。声明式局部加载Declarative partial loading集合不仅可以代表目标实体的全部也可以只代表其中的一个子集——直接在实体定义中用where声明Entity() class Author { OneToMany(() Book, b b.author) books new CollectionBook(this); OneToMany(() Book, b b.author, { where: { favorite: true } }) favoriteBooks new CollectionBook(this); }多对多同样适用。如果多个关系映射到同一张 pivot 表必须显式指定表名或复用同一个 pivot 实体否则 ORM 无法区分Entity() class Book { ManyToMany(() BookTag) tags new CollectionBookTag(this); ManyToMany({ entity: () BookTag, pivotTable: book_tags, where: { popular: true }, }) popularTags new CollectionBookTag(this); }用 matching() 切片集合Collection.matching()允许从集合中按查询条件“切片”出部分数据支持分页与排序。默认只返回查询结果列表、不写入集合传入store: true则会把这批条目存入集合——同时集合被标记为readonly此后的add()、remove()等修改方法会抛错const a await em.findOneOrFail(Author, 1); // 只查询并返回列表不改变集合状态 const books await a.books.matching({ limit: 3, offset: 10, orderBy: { title: asc } }); console.log(books); // [Book, Book, Book] console.log(a.books.isInitialized()); // false // 把结果存入集合集合变为只读 const tags await books[0].tags.matching({ limit: 3, offset: 5, orderBy: { name: asc }, store: true, }); console.log(tags); // [BookTag, BookTag, BookTag] console.log(books[0].tags.isInitialized()); // true console.log(books[0].tags.getItems()); // [BookTag, BookTag, BookTag]MatchingOptions在源码中定义Collection.ts继承FindOptions并额外支持where、store与事务上下文ctx。实现上第 177-217 行对于走 pivot 表的 M:N 集合它会合并三层排序查询排序 关系声明排序 目标实体元数据排序并直接通过驱动从 pivot 表加载store: true时调用hydrate(items, true)写入集合、置位#readonly true第 209-214 行。集合映射辅助方法indexBy()Collection提供了一系列便捷方法map、filter、reduce、exists、find、slice等见上文遍历小节其中indexBy()用于把集合转换成键值字典。第一个参数是索引键// 假设 user.settings 是 CollectionOption const settingsDictionary user.settings.indexBy(key); // settingsDictionary 的类型是 Recordstring, Option第二个参数可以把值映射为属性值而不是整个实体const settingsDictionary user.settings.indexBy(key, value); // settingsDictionary 的类型是 Recordstring, string其底层实现基于reduce()对每个条目执行obj[item[key]] ?? valueKey ? item[valueKey] : item因此当多个条目拥有相同键时只有第一个会出现在字典中见 Collection.ts。小结Collection是 MikroORM 关系模型的核心抽象掌握它等于掌握了 1:m 与 m:n 关系的全部操作面。要点回顾读取for...of、方括号不校验初始化、getItems()默认校验初始化、toArray()序列化为 DTO、getIdentifiers()取主键数组初始化load()幂等加载、init()强制加载、loadItems()直接拿数组、loadCount()查库计数修改add()/remove()/removeAll()/set()其中移除只是断开关系删除实体需配合orphanRemoval: true传播增删操作自动同步到对侧集合操作应始终走 owning 侧定制自定义 pivot 实体pivotEntity、固定插入顺序fixedOrder/fixedOrderColumn、业务排序orderBy、声明式局部加载where高级查询init({ where, orderBy })、matching({ limit, offset, store })、indexBy()字典化。上述全部 API 的完整实现位于 packages/core/src/entity/Collection.tspivot 表与fixedOrder的元数据推导逻辑可查看 packages/core/src/metadata/MetadataDiscovery.ts相关行为测试则集中在 tests/features/collection 目录如 collection-operators.test.ts 与 collection-order.test.ts读者可自行深入验证。赞分享后端【免费下载链接】mikro-ormTypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, MariaDB, MS SQL Server, PostgreSQL and SQLite/libSQL databases.项目地址https://gitcode.com/gh_mirrors/mi/mikro-orm点击查看免费下载相关推荐MikroORM 实体关系建模全指南ManyToOne / OneToMany / OneToOne / ManyToMany 的声明与双向关联详解MikroORM 实体关系建模全指南ManyToOne / OneToMany / OneToOne / ManyToMany 的声明与双向关联详解 Mikr后端Python集合操作完全指南交集、并集、差集的高级用法Python集合操作完全指南交集、并集、差集的高级用法 Python集合是处理唯一元素和数学运算的强大工具但很多开发者只停留在基础用法上。本文将带你深入探索文档教程Bokeh WebGL 示例深度解析GPU 加速渲染的测试矩阵与实战指南Bokeh WebGL 示例深度解析GPU 加速渲染的测试矩阵与实战指南 Bokeh 的 examples/output/webgl/ 目录集中存放了展示 W后端上一篇Vue.js 3 单文件组件(SFC)深入解析script setup语法糖的终极使用指南下一篇Mole终极指南用命令行工具彻底优化你的Mac性能创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
网站建设高端定制企业官网