MikroORM 复合主键完整指南:从原生类型主键到外键派生标识与带元数据的联结表

发布时间:2026/9/28 7:36:47
MikroORM 复合主键完整指南:从原生类型主键到外键派生标识与带元数据的联结表 后端【免费下载链接】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 自 3.5 版本起原生支持复合主键Composite Primary Keys覆盖「多个原生类型字段组合主键」「通过外键实体派生身份Derived Identity」「带附加元数据的联结表实体」等关系型数据库中的典型建模场景。本文以版本 7.2 文档为基础结合仓库源码与测试用例系统讲解复合主键的语义、四种实体定义方式、查询/持久化/关联用法以及 QueryBuilder 中的元组表示帮助你在实际项目中正确建模复合主键实体并理解其底层映射行为。支持说明复合主键能力自 MikroORM 3.5 版本加入本文档适用于当前仓库 docs/versioned_docs/version-7.2/composite-keys.md 对应的 7.2 版本。本文所有示例代码均以该文档为骨架展开。总览复合主键的语义与使用前提复合主键是关系型数据库的经典概念MikroORM 对它的支持非常全面涵盖以下几类场景纯原生类型组合多个标量属性如string、number共同构成主键外键作为主键Derived Identity一个或多个ManyToOne/OneToOne关联本身即充当主键实体身份由父实体决定复合主键实体参与关联复合主键实体可以作为其他实体关联的目标MikroORM 会自动生成对应的复合外键联结表实体化将 M:N 的中间表建模为带附加业务字段的实体如订单项携带数量与成交价。在使用上有两条基本规则需要先记住持久化前必须赋值主键值必须在调用em.persist(entity)之前设置完毕查询时需提供全部主键无论是用对象条件还是主键元组数组都必须覆盖定义的全部主键字段。一个巧妙的工程实践是把主键字段设为构造函数必填参数从而在编译期和运行期都保证「持久化前主键一定有值」这一点在文档的「Primitive Types only」示例中直接体现constructor(name: string, year: number)。一、纯原生类型复合主键Primitive Types only假设要为「汽车」建立数据库以name车型名与year生产年份共同作为主键。MikroORM 7.2 支持四种等价的实体定义方式下文逐一给出完整示例。1.1 方式一defineEntity class推荐defineEntity是 MikroORM 7 推荐的实体定义 API通过primaryKeys选项声明主键组合再以class成员与setClass绑定实体类const CarSchema defineEntity({ name: Car, properties: { name: p.string(), year: p.integer(), }, primaryKeys: [name, year], }); export class Car extends CarSchema.class {} CarSchema.setClass(Car);1.2 方式二纯defineEntity无类实体如果不需要类实体直接导出defineEntity的结果即可export const Car defineEntity({ name: Car, properties: { name: p.string(), year: p.integer(), }, primaryKeys: [name, year], });从源码看defineEntity的类型定义中primaryKeys?: TPK InferPrimaryKeyConstraintTProperties[]见 packages/core/src/entity/defineEntity.ts它会基于properties的类型推断并校验主键约束保证声明的字段确实存在于实体属性中。1.3 方式三reflect-metadata 装饰器使用装饰器 反射元数据的方式每个主键属性标注PrimaryKey()Entity() export class Car { PrimaryKey() name: string; PrimaryKey() year: number; // 用于 FilterQuery 中正确的类型检查 [PrimaryKeyProp]?: [name, year]; constructor(name: string, year: number) { this.name name; this.year year; } }1.4 方式四ts-morph 装饰器显式类型与方式三几乎一致只是类型信息由 ts-morph 编译器插件显式提供Entity() export class Car { PrimaryKey() name: string; PrimaryKey() year: number; [PrimaryKeyProp]?: [name, year]; constructor(name: string, year: number) { this.name name; this.year year; } }关于PrimaryKeyProp符号仓库源码中的定义与解释为Symbol used to declare the primary key property name(s) on an entity (e.g., [PrimaryKeyProp]?: id)见 packages/core/src/typings.ts。它有两个关键作用复合主键必须声明当实体有复合主键时需要通过[PrimaryKeyProp]?: [name, year]声明主键属性的顺序这样FilterQuery的类型检查才知道主键元组的正确形状单标量主键可省略如果实体只有一个标量主键且属性名为id: number | string | bigint、_id: any或uuid: string则无需声明PrimaryKeyProp。1.5 使用持久化与查询const car new Car(Audi A8, 2010); await em.persist(car).flush();查询时提供全部主键对象条件与主键元组两种写法等价const audi1 await em.findOneOrFail(Car, { name: Audi A8, year: 2010 }); const audi2 await em.findOneOrFail(Car, [Audi A8, 2010]);注意使用第二种「主键元组」写法时必须如Car实体那样通过PrimaryKeyProp声明主键类型否则类型检查无法推断元组结构。1.6 参与关联自动生成复合外键复合主键实体可以出现在关联中。例如一个CarOwner实体通过ManyToOne(() Car)引用CarMikroORM 会自动为Car的name和year各生成一个外键列。仓库测试实体Car2与CarOwner2正是这一场景的直接验证tests/entities-sql/Car2.tsname、year均标注PrimaryKey()并声明[PrimaryKeyProp]?: [name, year]同时用Index为主键列建立索引tests/entities-sql/CarOwner2.tsCarOwner2拥有自增主键并通过ManyToOne(() Car2, { index: car_owner2_car_name_car_year_idx })引用Car2复合外键指向car_name、car_year两列。对应的测试 tests/features/composite-keys/composite-keys.mysql.test.ts 演示了按对象条件、按实体引用、以及JOINED策略下 populate 复合主键关联的完整流程。二、通过外键实体派生身份Identity through foreign Entities大量业务场景中实体的身份应由一个或多个父实体决定典型包括动态属性Dynamic Attributes如Article的每个属性主键为article_idattribute_name派生身份Derived Identity如Person的Address对象主键为user_id——这虽然不是复合主键但身份由外键实体与外键决定带元数据的联结表如两个文章之间的关联附带描述与评分。通过外键实体映射身份的语义只有两条规则只允许在ManyToOne或OneToOne关联上使用在装饰器/属性构建器中标记primary: true。2.1 Use-Case 1动态属性Dynamic Attributes继续「文章拥有任意属性」的例子。Article为主实体ArticleAttribute的主键由「指向Article的外键」与「属性名字符串」共同构成。defineEntity写法article用p.manyToOne(Article).primary()标记attribute用p.string().primary()标记并在primaryKeys中声明组合const ArticleSchema defineEntity({ name: Article, properties: { id: p.integer().primary().autoincrement(), title: p.string(), attributes: () p.oneToMany(ArticleAttribute).mappedBy(article).cascade(Cascade.ALL), }, }); export class Article extends ArticleSchema.class {} ArticleSchema.setClass(Article); const ArticleAttributeSchema defineEntity({ name: ArticleAttribute, properties: { article: () p.manyToOne(Article).primary(), attribute: p.string().primary(), value: p.string(), }, primaryKeys: [article, attribute], }); export class ArticleAttribute extends ArticleAttributeSchema.class {} ArticleAttributeSchema.setClass(ArticleAttribute);reflect-metadata 写法Entity() export class Article { PrimaryKey() id!: number; Property() title!: string; OneToMany(() ArticleAttribute, attr attr.article, { cascade: Cascade.ALL }) attributes new CollectionArticleAttribute(this); } Entity() export class ArticleAttribute { ManyToOne(() Article, { primary: true }) article: Article; PrimaryKey() attribute: string; Property() value!: string; [PrimaryKeyProp]?: [article, attribute]; // FilterQuery 类型检查必需 constructor(name: string, value: string, article: Article) { this.attribute name; this.value value; this.article article; } }这里再次看到把主键字段作为构造函数必填参数的实践ArticleAttribute的构造函数要求先传入name、value与article天然满足「em.persist()前主键必须有值」的约束。Article.attributes配置了Cascade.ALL意味着持久化Article时其动态属性会级联写入。2.2 Use-Case 2简单派生身份Simple Derived Identity当两个对象通过OneToOne关联且依赖方应复用被依赖方的主键时使用派生身份。经典例子是「用户-地址」Address的主键即User的主键。defineEntity写法const UserSchema defineEntity({ name: User, properties: { id: p.integer().primary().autoincrement(), address: () p.oneToOne(Address).inversedBy(user).cascade(Cascade.ALL), }, }); export class User extends UserSchema.class {} UserSchema.setClass(User); const AddressSchema defineEntity({ name: Address, properties: { user: () p.oneToOne(User).primary(), }, primaryKeys: [user], }); export class Address extends AddressSchema.class {} AddressSchema.setClass(Address);reflect-metadata 写法Entity() export class User { PrimaryKey() id!: number; OneToOne(() Address, address address.user, { cascade: [Cascade.ALL], nullable: true }) address?: Address; // 虚拟属性反方向用于查询该关联 } Entity() export class Address { OneToOne(() User, { primary: true }) user!: User; [PrimaryKeyProp]?: user; // FilterQuery 类型检查必需 }注意这里的[PrimaryKeyProp]?: user是字符串形式而不是元组——因为Address的主键只有一个属性user这展示了PrimaryKeyProp对单字段派生主键的另一种写法。User.address是反向虚拟属性声明为可空nullable: true仅用于查询关联Address.user才是真正持有主键的一方。2.3 Use-Case 3带元数据的联结表Join-Table with Metadata经典「订单-商品-订单项」模型OrderItem同时引用Order与Product两者组合为主键并携带amount购买数量与offeredPrice成交价等附加业务字段。defineEntity写法const OrderSchema defineEntity({ name: Order, properties: { id: p.integer().primary().autoincrement(), customer: () p.manyToOne(Customer), items: () p.oneToMany(OrderItem).mappedBy(order), paid: p.boolean().default(false), shipped: p.boolean().default(false), created: p.datetime().onCreate(() new Date()), }, }); export class Order extends OrderSchema.class {} OrderSchema.setClass(Order); const ProductSchema defineEntity({ name: Product, properties: { id: p.integer().primary().autoincrement(), name: p.string(), currentPrice: p.float(), }, }); export class Product extends ProductSchema.class {} ProductSchema.setClass(Product); const OrderItemSchema defineEntity({ name: OrderItem, properties: { order: () p.manyToOne(Order).primary(), product: () p.manyToOne(Product).primary(), amount: p.integer().default(1), offeredPrice: p.float(), }, primaryKeys: [order, product], }); export class OrderItem extends OrderItemSchema.class {} OrderItemSchema.setClass(OrderItem);reflect-metadata 写法Entity() export class Order { PrimaryKey() id!: number; ManyToOne(() Customer) customer: Customer; OneToMany(() OrderItem, item item.order) items new CollectionOrderItem(this); Property() paid false; Property() shipped false; Property() created new Date(); constructor(customer: Customer) { this.customer customer; } } Entity() export class Product { PrimaryKey() id!: number; Property() name!: string; Property() currentPrice!: number; } Entity() export class OrderItem { ManyToOne(() Order, { primary: true }) order: Order; ManyToOne(() Product, { primary: true }) product: Product; Property() amount 1; Property() offeredPrice: number; [PrimaryKeyProp]?: [order, product]; // FilterQuery 类型检查必需 constructor(order: Order, product: Product, amount 1) { this.order order; this.product product; this.offeredPrice product.currentPrice; } }构造函数中this.offeredPrice product.currentPrice展示了在创建联结实体时快照父实体价格的常用技巧——成交价在历史中不应随商品当前价格变动。2.4 将联结实体接入 M:N 关系pivotEntity选项默认情况下MikroORM 在底层使用自动生成的联结表实体来表示 M:N 的中间表。你可以通过pivotEntity选项提供自己的实现将上面定义的OrderItem作为Order.products这条 M:N 关系的联结实体Entity() export class Order { ManyToMany({ entity: () Product, pivotEntity: () OrderItem }) products new CollectionProduct(this); }pivotEntity的实现要求恰好两个many-to-one属性第一个指向 M:N 关系的拥有方实体Order第二个指向目标实体Product双向 M:N 只需在拥有方指定pivotEntity两侧仍要通过inversedBy或mappedBy连接Entity() export class Product { ManyToMany({ entity: () Order, mappedBy: o o.products }) orders new CollectionOrder(this); }向这种 M:N 集合新增元素时联结实体的所有非外键属性都需要数据库级默认值否则无法在只提供两端主键的情况下完成 INSERTEntity() export class OrderItem { ManyToOne({ primary: true }) order: Order; ManyToOne({ primary: true }) product: Product; Property({ default: 1 }) amount!: number; }也可以直接操作联结实体本身进行增删// 创建新订单项 const item em.create(OrderItem, { order: 123, product: 321, amount: 999, }); await em.persist(item).flush(); // 或通过 delete 查询删除订单项 em.nativeDelete(OrderItem, { order: 123, product: 321 });此外还可以如前述示例那样定义指向联结实体的 1:m 属性用于修改集合同时保留 M:N 属性用于更便捷的读取与过滤——两种视图可以并存。三、复合主键与 QueryBuilder元组表示在内部复合主键被表示为元组tuple元组中值的顺序与定义主键时的属性顺序完全一致。这一表示贯穿查询条件与引用获取。3.1 三种 where 写法的 SQL 差异以CarOwner引用复合主键Car为例const qb1 em.createQueryBuilder(CarOwner); qb1.select(*).where({ car: { name: Audi A8, year: 2010 } }); console.log(qb1.getQuery()); // select e0.* from car_owner as e0 where e0.name ? and e0.year ? const qb2 em.createQueryBuilder(CarOwner); qb2.select(*).where({ car: [Audi A8, 2010] }); console.log(qb2.getQuery()); // select e0.* from car_owner as e0 where (e0.car_name, e0.car_year) (?, ?) const qb3 em.createQueryBuilder(CarOwner); qb3.select(*).where({ car: [[Audi A8, 2010]] }); console.log(qb3.getQuery()); // select e0.* from car_owner as e0 where (e0.car_name, e0.car_year) in ((?, ?))三种写法生成的 SQL 语义不同理解它们有助于按需选择对象条件展开为多个等值条件的 AND 组合单元素组生成行值比较(col1, col2) (?, ?)精确匹配一组主键嵌套元组数组生成IN子查询形式适合批量匹配多组主键。3.2 获取复合主键实体引用getReference同样接受主键元组返回的是符合Car类型实例化后为Car实例的引用对象const ref em.getReference(Car, [Audi A8, 2010]); console.log(ref instanceof Car); // true如果以单个标量值调用复合主键实体的getReference会触发校验错误。测试 tests/features/composite-keys/composite-keys.mysql.test.ts 中直接断言orm.em.getReference(Car2, 1 as any)会抛出Composite key required for entity Car2.异常——这印证了元组主键是复合主键实体引用获取的强制约定。四、源码视角复合主键的类型系统与校验复合主键在 MikroORM 中不是「装饰器语法糖」而是贯穿类型系统、元数据校验与查询生成的底层一等公民。可以从三个源码位置交叉印证类型声明packages/core/src/typings.ts 定义了PrimaryKeyProp Symbol(PrimaryKeyProp)注释明确指出它用于声明实体的主键属性名同文件 L366-L389 的类型工具PrimaryProperty等通过读取[PrimaryKeyProp]推断实体的主键类型——这就是「声明了PrimaryKeyProp才能正确推断主键元组类型」的根源。元数据校验packages/core/src/metadata/MetadataValidator.ts 校验每个非 embeddable 实体必须拥有主键!meta.primaryKeys || meta.primaryKeys.length 0时报错L523 附近在处理目标实体时显式判断targetMeta.compositePK说明复合主键在关联目标解析中是被专门分支处理的。实体定义 APIpackages/core/src/entity/defineEntity.ts 的primaryKeys选项通过InferPrimaryKeyConstraintTProperties在类型层面约束主键声明必须来自属性集合配合属性构建器的.primary()标记如p.string().primary()实现与装饰器方式完全等价的声明能力。仓库测试目录 tests/features/composite-keys/ 汇集了大量针对复合主键的回归测试composite-keys.mysql.test.ts覆盖了 MySQL 下纯类型复合主键、派生身份Address2/Author2、联结实体FooParam2以及JOINED/SELECT_IN加载策略下的 populate 场景GH1914.test.ts验证了用实体引用作为复合条件进行findOneOrFailGH5629.test.ts、GH5774.test.ts等则覆盖了复合主键继承与多态关联的边界情况。阅读这些测试可以快速获得真实可运行的复合主键用法全貌。五、最佳实践小结结合文档与仓库实现使用复合主键时建议遵循以下实践构造器强制主键将主键字段设为构造函数必填参数如constructor(name: string, year: number)在类型层面保证em.persist()前主键已赋值复合主键务必声明PrimaryKeyProp只有声明[PrimaryKeyProp]?: [...主键属性]FilterQuery才能正确校验对象条件与主键元组两种查询写法单标量主键且命名为id/_id/uuid时可省略按需选择查询形态单条精确查询用对象条件或单元素组批量匹配用嵌套元组数组IN语义联结实体注意默认值作为pivotEntity使用时非外键业务字段要提供数据库级default否则无法在仅给定两端主键时插入区分加载视图需要修改联结数据时用指向联结实体的 1:m 属性需要便捷读取与过滤时用 M:N 属性二者并存不冲突。复合主键是建模「身份由多列或父实体决定」的领域模型的利器。借助 MikroORM 对原生类型复合主键、外键派生身份与带元数据联结表的完整支持你可以用统一的实体语法表达这些复杂的数据库结构并让 ORM 自动处理复合外键生成、级联持久化与类型安全的复合查询。赞分享后端【免费下载链接】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 复合主键Composite Primary Keys实战指南原始类型复合键、外键派生身份与带元数据联结表MikroORM 复合主键Composite Primary Keys实战指南原始类型复合键、外键派生身份与带元数据联结表 复合主键是关系型数据库中一个非后端MikroORM 复合主键完全指南从原始类型组合到外键派生身份与元数据连接表MikroORM 复合主键完全指南从原始类型组合到外键派生身份与元数据连接表 本文以 MikroORMTypeScript 数据映射 ORM官方文档的 C后端MikroORM 复合主键与外键派生主键实战指南MikroORM 复合主键与外键派生主键实战指南 MikroORM 从 3.5 版本起原生支持复合主键Composite Primary Keys既能用多后端上一篇Sushi3分钟搞定字幕同步告别手动调整的烦恼下一篇Kimi CLI Wire 协议深度解析initialize 握手与外部工具External Tools接入指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询