Skip to main content
Version: 6.4

第 2 章:关系

在本节中,我们将添加更多实体,创建一个要扩展的公共基础实体,并定义它们之间的关系。

¥In this section, we will add more entities, create a common base entity to extend from, and define relationships between them.

创建和更新的时间戳

¥Created and updated timestamps

在我们添加更多实体之前,让我们稍微重构一下现有的 User 实体。我们希望存储实体创建时间和上次更新时间的时间戳。为此,请引入两个新属性:

¥Before we add more entities, let's refactor our existing User entity a bit. We would like to store timestamps of when the entity was created and when it was updated for the last time. To do that, introduce two new properties:

user.entity.ts
@Property()
createdAt = new Date();

@Property({ onUpdate: () => new Date() })
updatedAt = new Date();

如果 ORM 检测到实体已更新,则此处的 onUpdate 选项将在 flush 操作期间执行。对于创建查询,我们依赖于属性初始化器 - 我们已经说过,在创建托管实体时,ORM 永远不会调用你的实体构造函数,因此这样使用它是安全的。

¥The onUpdate option here will be executed during the flush operation if the ORM detects the entity was updated. For create query, we depend on the property initializers - we already said that the ORM will never call your entity constructor when creating managed entities, so it is safe to use it like this.

自定义基础实体

¥Custom base entity

现在假设我们希望在我们的应用中的每个实体上都有这些时间戳。我们可以将这些常见属性重构为自定义基础实体。将以下内容放入 src/modules/common/base.entity.ts

¥Now let's say we want to have these timestamps on every entity in our app. We can refactor such common properties out to a custom base entity. Put the following into src/modules/common/base.entity.ts:

base.entity.ts
import { PrimaryKey, Property } from '@mikro-orm/core';

export abstract class BaseEntity {

@PrimaryKey()
id!: number;

@Property()
createdAt = new Date();

@Property({ onUpdate: () => new Date() })
updatedAt = new Date();

}

你可以看到基本实体看起来像任何其他实体,但有一个例外 - 它没有 @Entity() 装饰器。有些用例中,你甚至希望对基本实体使用装饰器,在这种情况下,将 abstract: true 添加到装饰器选项中,例如 @Entity({ abstract: true })

¥You can see a base entity looks like any other entity, with one exception - it does not have the @Entity() decorator. There are some use cases where you will want to use the decorator even for the base entity, in that case, add abstract: true to the decorator options, e.g. @Entity({ abstract: true }).

现在从 User 实体扩展此基本实体并删除它定义的属性:

¥Now extend this base entity from the User entity and remove the properties it defines:

你可以看到带有 .js 扩展的导入 - 这对于 ESM 项目是强制性的。如果你的项目针对 CommonJS,请删除它。

¥You can see the import with .js extension - this is mandatory for ESM projects. If your project is targeting CommonJS, drop it.

user.entity.ts
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
import { BaseEntity } from '../common/base.entity.js';

@Entity()
export class User extends BaseEntity {

@Property()
fullName!: string;

@Property()
email!: string;

@Property()
password!: string;

@Property({ type: 'text' })
bio = '';

}

更多实体

¥More entities

是时候添加 Article 实体了,它将有 4 个字符串属性和一个关系 - 指向 User 实体的 ManyToOne 关系。如你所料,它将转到 src/modules/article/article.entity.ts 文件。

¥Time to add the Article entity, it will have 4 string properties and one relationship - a ManyToOne relation pointing to the User entity. As you expected, it will go to the src/modules/article/article.entity.ts file.

article.entity.ts
import { Entity, ManyToOne, Property, t } from '@mikro-orm/core';
import { BaseEntity } from '../common/base.entity.js';
import { User } from '../user/user.entity.js';

@Entity()
export class Article extends BaseEntity {

@Property({ unique: true })
slug!: string;

@Property({ index: true })
title!: string;

@Property({ length: 1000 })
description!: string;

@Property({ type: t.text, lazy: true })
text!: string;

@ManyToOne()
author!: User;

}

让我们分解一下,有一些我们以前没有见过的新增内容。

¥Let's break this down, there are some new additions we haven't seen before.

  • slug 属性被标记为 unique,这将导致对该列的唯一约束

    ¥slug property is marked as unique, this will result in a unique constraint over the column

  • title 属性被标记为 indexed

    ¥title property is marked as indexed

  • description 属性有一个 length 选项,对于大多数 SQL 驱动程序,该列将导致 varchar(1000)

    ¥description property has a length option, the column will result in varchar(1000) with most SQL drivers

  • text 属性使用 t.text 映射类型,并被标记为 lazy,这意味着它不会被自动选择

    ¥text property uses the t.text mapped type, and is marked as lazy, meaning it won't be selected automatically

  • author 属性是我们的第一个关系

    ¥author property is our first relationship

reflect-metadata provider默认

这里的示例全部基于 @mikro-orm/reflection 包,该包基于 TypeScript 编译器 API 通过 ts-morph 帮助进行高级类型反射。如果你使用的是默认 reflect-metadata 提供程序,则需要将一些内容添加到装饰器选项中才能使其正常工作。假设 author 属性是可选的,ts-morph 可以很好地推断出一切,但使用 reflect-metadata 你必须这样做:

¥The examples here are all based on the @mikro-orm/reflection package that helps with advanced type reflection via ts-morph, based on TypeScript Compiler API. If you are using the default reflect-metadata provider, there are things you will need to add to the decorator options to make things work. Let's say the author property is optional, ts-morph would infer everything just fine, but with reflect-metadata you would have to do this:

// with @mikro-orm/reflection package (ts-morph)
@ManyToOne()
author?: User;

// with the default provider (reflect-metadata)
@ManyToOne({ entity: () => User, nullable: true })
author?: User;

有关更多示例,请参阅 文档

¥Consult the docs for more examples.

关系类型

¥Types of relations

MikroORM 中有 4 种类型的实体关系:

¥There are 4 types of entity relationships in MikroORM:

  • ManyToOne:当前实体的许多实例引用被引用实体的一个实例。

    ¥ManyToOne: Many instances of the current Entity refer to One instance of the referred Entity.

  • OneToMany:当前实体的一个实例对所引用的实体有许多实例(引用)。

    ¥OneToMany: One instance of the current Entity has Many instances (references) to the referred Entity.

  • OneToOne:当前实体的一个实例引用所引用实体的一个实例。

    ¥OneToOne: One instance of the current Entity refers to One instance of the referred Entity.

  • ManyToMany:当前实体的许多实例引用被引用实体的许多实例。

    ¥ManyToMany: Many instances of the current Entity refers to Many instances of the referred Entity.

关系可以是单向的,也可以是双向的。单向关系仅在一侧(拥有方)定义。在两侧都定义了双向关系,而一侧是拥有方(存储引用),由指向反侧的 inversedBy 属性标记。在反面,我们用指向所有者的 mappedBy 属性来定义它。

¥Relations can be unidirectional and bidirectional. Unidirectional relation is defined only on one side (the owning side). Bidirectional ones are defined on both sides, while one is owning side (where references are stored), marked by inversedBy attribute pointing to the inverse side. On the inversed side we define it with mappedBy attribute pointing back to the owner.

在对双向关系进行建模时,你也可以省略 inversedBy 属性,在反面定义 mappedBy 就足够了,因为它将自动连接。

¥When modeling bidirectional relationship, you can also omit the inversedBy attribute, defining mappedBy on the inverse side is enough as it will be auto-wired.

查看文档中的 建模实体关系 部分以获取每种类型的更多详细信息和示例。

¥Check the Modeling Entity Relationships section in the documentation for more details and examples for each of the types.

使用关系

¥Working with relations

让我们回到 server.ts 文件,并使用我们的新 Article 实体(即其 author 关系)尝试一些操作。

¥Let's get back to the server.ts file and try a few things out with our new Article entity, namely with its author relation.

创建实体图

¥Creating entity graph

到目前为止,我们手动使用实体构造函数来创建实体实例。有时我们可能想要创建整个实体图,包括关系。你可以为此使用 em.create(),它是一种为你创建实体实例的同步方法。它允许你创建深度实体图,将关系的外键映射到正确类型的实体引用。此方法还将在创建的实体上调用 em.persist()(除非通过 persistOnCreate 选项禁用)。

¥So far we used the entity constructor manually to create an entity instance. Sometimes we might want to create the whole entity graph, including relations. You can use em.create() for that, it is a synchronous method that creates the entity instance for you. It allows you to create a deep entity graph, mapping foreign keys of your relations to entity references of the correct type. This method will also call em.persist() on the created entity (unless disabled via persistOnCreate option).

你可以擦除 server.ts 文件的大部分内容,只保留 ORM init 的初始部分,直到第一个 User 实体被刷新,加上最后的 orm.close() 调用。我们以后不会再使用此代码,它只是供你使用的游乐场。

¥You can wipe most of the contents of server.ts file and keep only the initial part with ORM init, up to the point where the first User entity gets flushed, plus the orm.close() call at the end. We won't be using this code going forward, it is just a playground for you.

server.ts
// create new user entity instance via constructor
const user = new User();
user.email = 'foo@bar.com';
user.fullName = 'Foo Bar';
user.password = '123456';

// fork first to have a separate context
const em = orm.em.fork();

// first mark the entity with `persist()`, then `flush()`
await em.persist(user).flush();

// clear the context to simulate fresh request
em.clear();

// create the article instance
const article = em.create(Article, {
title: 'Foo is Bar',
text: 'Lorem impsum dolor sit amet',
author: user.id,
});

// `em.create` calls `em.persist` automatically, so flush is enough
await em.flush();
console.log(article);
信息em.clear()

如果你仔细检查此代码片段,你可能会发现新的神秘 em.clear() 调用。它有什么用?它清除 EntityManager 的上下文,这意味着它将分离它管理的所有实体。它将使 EntityManager 处于与通过 em.fork() 创建新分支相同的状态。你通常不需要在你的应用中使用它,但它对于单元测试非常方便,可以模拟新传入的请求。如果你愿意,你也可以明确使用 fork。

¥If you carefully checked this snippet, you probably found that new mysterious em.clear() call. What does it do? It clears the context for the EntityManager, meaning it will detach all the entities it was managing. It will bring the EntityManager to the same state as if you would create a fresh fork via em.fork(). You won't usually need this in your app, but it is very handy for unit testing, to simulate new requests coming in. You may as well use forks explicitly if you want.

但是,等等,编辑器在抱怨某事。你可能会看到这个神秘的错误:

¥But wait, the editor is complaining about something. You probably see this cryptic error:

Argument of type '{ slug: string; title: string; description: string; text: string; author: number; }' is not assignable to parameter of type 'RequiredEntityData<Article>'.
Type '{ slug: string; title: string; description: string; text: string; author: number; }' is missing the following properties from type '{ slug: string; title: string; description: string; text: string; author: EntityData<User> | { id?: number | null | undefined; fullName?: string | null | undefined; email?: string | ... 1 more ... | undefined; password?: string | ... 1 more ... | undefined; bio?: string | ... 1 more ... | undefined; } | EntityDataPr...': createdAt, updatedAt ts(2345)

它确实有点丑陋,但如果你仔细观察,你会在最开始和最后看到重要的细节。此错误告诉我们传递给 em.create() 的对象不完整 - 它缺少两个属性,即 createdAtupdatedAt 时间戳。但是我们通过属性初始化器为它们定义默认值,这里的问题是什么?

¥It's indeed a bit ugly, but if you look carefully, you will see the important details at the very beginning and at the very end. This error tells us the object we are passing into em.create() is not complete - it is missing two properties, the createdAt and updatedAt timestamps. But we define the default value for them via property initializer, what's the problem here?

问题是,没有简单的方法来判断对象属性是否具有初始化程序 - 对于 TypeScript,我们的 createdAtupdatedAt 属性都是必需的。要在保留严格类型检查的同时解决这个问题,你可以使用 OptionalProps 符号。由于两个有问题的属性都存在于 BaseEntity 中,因此将其放在那里:

¥The thing is, there is no easy way to tell whether an object property has an initializer or not - for TypeScript our createdAt and updatedAt properties are both mandatory. To get around this while preserving the strict type checking, you can use the OptionalProps symbol. As both of the problematic properties live in the BaseEntity, put it there:

base.entity.ts
import { OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';

export abstract class BaseEntity {

[OptionalProps]?: 'createdAt' | 'updatedAt';

@PrimaryKey()
id!: number;

@Property()
createdAt = new Date();

@Property({ onUpdate: () => new Date() })
updatedAt = new Date();

}

通过此更改,你可以看到 TypeScript 错误现在消失了。运行 npm start,你将看到 Article 实体将被持久化并记录到控制台:

¥With this change, you can see the TypeScript error is now gone. Running the npm start, you will see the Article entity will get persisted and logged to the console:

[query] begin
[query] insert into `article` (`author_id`, `created_at`, `description`, `slug`, `text`, `title`, `updated_at`) values (1, 1662908804371, 'Foo is bar', 'foo', 'Lorem impsum...', 'Foo', 1662908804371) [took 0 ms]
[query] commit
Article {
id: 1,
createdAt: 2022-09-11T15:06:44.371Z,
updatedAt: 2022-09-11T15:06:44.371Z,
slug: 'foo',
title: 'Foo',
description: 'Foo is bar',
text: 'Lorem impsum...',
author: (User) { id: 1 }
}

还记得我们之前讨论过的实体引用和加载状态吗?你可以看到,在这里,Article.author 是一个只有主键的实体引用。它会自动记录为 (User),因此你可以轻松判断任何实体的加载状态,但实际上它是完全相同的 User 实体实例:

¥Remember the entity references and loaded state we discussed earlier? You can see that here in action, the Article.author is an entity reference with just the primary key. It is automatically logged as (User) so you can easily tell the loaded state of any entity, but it is in fact the very same User entity instance:

console.log('it really is a User', article.author instanceof User); // true
console.log('but not initialized', wrap(article.author).isInitialized()); // false

使用实体构造函数

¥Using entity constructor

每个 Article 都可以通过唯一的 slug 进行标识 - 可用于查找文章的 URL 片段。目前,它只是一个常规的字符串属性,但我们可以做得更好。值应始终绑定到文章标题。为简单起见,我们将使用以下函数:

¥Every Article can be identified by a unique slug - a URL fragment that can be used to look up the article. Currently, it is just a regular string property, but we can do better here. The value should be always bound to the article title. For simplicity, we will use the following function:

function convertToSlug(text: string) {
return text.toLowerCase()
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
}

我们希望在文章创建后 URL 保持不变,因此让我们在 Article 构造函数中生成 slug。类似地,我们想要加载添加到文章中的所有标签。

¥We want the URL to remain the same after the article gets created, so let's generate the slug inside Article constructor. Similarly, you can use the text property and store its first 1000 characters as the description:

article.entity.ts
import { Entity, ManyToOne, Property, t } from '@mikro-orm/core';
import { BaseEntity } from '../common/base.entity.js';
import { User } from '../user/user.entity.js';

function convertToSlug(text: string) {
return text.toLowerCase()
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
}

@Entity()
export class Article extends BaseEntity {

@Property({ unique: true })
slug: string;

@Property({ index: true })
title: string;

@Property({ length: 1000 })
description: string;

@Property({ type: t.text, lazy: true })
text: string;

@ManyToOne()
author: User;

constructor(title: string, text: string, author: User) {
super();
this.title = title;
this.text = text;
this.author = author;
this.slug = convertToSlug(title);
this.description = this.text.substring(0, 999) + '…';
}

}

通过此更改,slugdescription 属性也是可选的 - 但 em.create() 抱怨它们。你需要将它们添加到 OptionalProps 定义中,就像之前的时间戳一样。但是这些是 Article 实体属性,因此我们应该以某种方式在 Article 实体中执行此操作。也许像这样?

¥With this change, the slug and description properties are optional too - but em.create() complains about them. You need to add them to the OptionalProps definition, as with the timestamps before. But these are the Article entity properties, so we should do it in the Article entity somehow. Maybe like this?

article.entity.ts
export class Article extends BaseEntity {
[OptionalProps]?: 'slug' | 'description';
}

不幸的是,你会看到类似这样的 TypeScript 错误:

¥Unfortunately not, you will see TypeScript error like this one:

Property '[OptionalProps]' in type 'Article' is not assignable to the same property in base type 'BaseEntity'.
Type '"slug" | "description" | undefined' is not assignable to type '"createdAt" | "updatedAt" | undefined'.
Type '"slug"' is not assignable to type '"createdAt" | "updatedAt" | undefined'. ts(2416)

泛型来救援!

¥Generics to the rescue!

这里的解决方案可能不清楚,但它非常简单。我们无需重新定义 OptionalProps 属性,而是在 BaseEntity 类上定义一个通用类型参数,并将 Article 特定属性传递给基实体。

¥The solution here might not be clear, but it is very simple. Instead of redefining the OptionalProps property, we define a generic type argument on the BaseEntity class and pass Article specific properties down to the base entity.

base.entity.ts
export abstract class BaseEntity<Optional = never> {
[OptionalProps]?: 'createdAt' | 'updatedAt' | Optional;
// all properties remain the same
}

我们为我们的类型选择了默认值 never - 这是因为与 never 的联合将始终产生相同的类型,例如 string | neverstring 相同。

¥We picked the default value of never for our type - this is because a union with never will always yield the same type, e.g. string | never is the same as just string.

article.entity.ts
export class Article extends BaseEntity<'slug' | 'description'> {
// all properties remain the same
}

现在即使没有 slugdescription,[em.create()`](/api/core/class/EntityManager#create) 调用也可以工作:

¥Now the em.create() call work even without the slug and `description:

const article = em.create(Article, {
title: 'Foo is Bar',
text: 'Lorem impsum dolor sit amet',
author: user.id,
});
console.log(article);

运行 npm start 我们可以看到 slugdescription 填充了生成的值:

¥Running npm start we can see the slug and description populated with generated values:

Article {
id: 1,
createdAt: 2022-09-11T16:08:16.489Z,
updatedAt: 2022-09-11T16:08:16.489Z,
slug: 'foo-is-bar',
title: 'Foo is Bar',
description: 'Lorem impsum dolor sit amet…',
text: 'Lorem impsum dolor sit amet',
author: (User) { id: '1' }
}

使用 Opt 类型的替代方法

¥Alternative approach with Opt type

让 TypeScript 知道哪些属性是可选的另一种方法是 Opt 类型,你可以将它与实际属性类型相交。这样,上述扩展类的问题就不再存在了,因为我们在属性级别进行操作:

¥Another way to make TypeScript aware of what properties are optional is the Opt type, you can intersect it with the actual property type. This way the above problem with extending classes is no longer present, as we operate on property level:

article.entity.ts
export class Article extends BaseEntity {

@Property({ unique: true })
slug: string & Opt;

@Property({ length: 1000 })
description: Opt<string>; // can be used via generics too

// ...

}

填充关系

¥Populating relationships

如果我们想将 Articleauthor 关系一起获取怎么办?我们可以使用 populate 提示:

¥What if we want to fetch the Article together with the author relation? We can use populate hints for that:

server.ts
// clear the context to simulate fresh request
em.clear();

// find article by id and populate its author
const articleWithAuthor = await em.findOne(Article, article.id, { populate: ['author'] });
console.log(articleWithAuthor);

照常运行 npm start

¥Run the npm start as usual:

[query] select `a0`.`id`, `a0`.`created_at`, `a0`.`updated_at`, `a0`.`slug`, `a0`.`title`, `a0`.`description`, `a0`.`author_id` from `article` as `a0` where `a0`.`id` = 1 limit 1 [took 1 ms]
[query] select `u0`.* from `user` as `u0` where `u0`.`id` in (1) order by `u0`.`id` asc [took 0 ms]
Article {
id: 1,
createdAt: 2022-09-11T16:57:57.941Z,
updatedAt: 2022-09-11T16:57:57.941Z,
slug: 'foo-is-bar',
title: 'Foo is Bar',
description: 'Lorem impsum dolor sit amet…',
text: undefined,
author: User {
id: 1,
fullName: 'Foo Bar',
email: 'foo@bar.com',
password: '123456',
bio: ''
}
}

惰性标量属性

¥Lazy scalar properties

你可以看到 text 属性是 undefined - 这是因为我们将其标记为 lazy,因此不会自动选择该值。如果我们添加 text 来填充提示,我们将获得以下值:

¥You can see the text property being undefined - this is because we marked it as lazy, therefore the value is not automatically selected. If we add the text to populate hint, we will get the value:

server.ts
const articleWithAuthor = await em.findOne(Article, article.id, {
populate: ['author', 'text'],
});

或者如果实体已加载,则可以使用 em.populate()

¥Or if the entity is already loaded, you can use em.populate():

server.ts
const articleWithAuthor = await em.findOne(Article, article.id, {
populate: ['author'],
});
await em.populate(articleWithAuthor!, ['text']);

加载策略

¥Loading strategies

你可以看到两个查询被触发,一个用于加载 Article 实体,另一个用于加载 author 关系(因此是 User 实体)。为什么不使用单个连接查询?这主要是历史原因,但 MikroORM 最初是作为 MongoDB 专用 ORM 诞生的,因此,对每个数据库表使用单独的查询是实现此类功能的最佳方法。

¥You can see two queries being fired, one for loading the Article entity, another loading the author relation (so a User entity). Why not a single joined query? This is mostly a historic reason, but MikroORM was originally born as a MongoDB-only ORM, and as such, using separate queries for each database table was the best approach to bring such functionality.

此默认行为称为 LoadStrategy.SELECT_IN。如果你想要一个将连接两个表的单个查询,请使用 LoadStrategy.JOINED:

¥This default behavior is called LoadStrategy.SELECT_IN. If you want to have a single query that will be joining the two tables instead of this, use LoadStrategy.JOINED:

server.ts
const articleWithAuthor = await em.findOne(Article, article.id, {
populate: ['author', 'text'],
strategy: LoadStrategy.JOINED,
});

结果:

¥Which yields:

[query] select `a0`.`id`, `a0`.`created_at`, `a0`.`updated_at`, `a0`.`slug`, `a0`.`title`, `a0`.`description`, `a0`.`text`, `a0`.`author_id`, `a1`.`id` as `a1__id`, `a1`.`full_name` as `a1__full_name`, `a1`.`email` as `a1__email`, `a1`.`password` as `a1__password`, `a1`.`bio` as `a1__bio` from `article` as `a0` left join `user` as `a1` on `a0`.`author_id` = `a1`.`id` where `a0`.`id` = 1 [took 1 ms]
Article {
id: 1,
createdAt: 2022-09-11T17:09:10.984Z,
updatedAt: 2022-09-11T17:09:10.984Z,
slug: 'foo-is-bar',
title: 'Foo is Bar',
description: 'Lorem impsum dolor sit amet…',
text: 'Lorem impsum dolor sit amet',
author: User {
id: 1,
fullName: 'Foo Bar',
email: 'foo@bar.com',
password: '123456',
bio: ''
}
}

两种方法各有利弊,请使用最适合当前用例的方法。如果你愿意,你可以通过 ORM 配置中的 loadStrategy 选项全局更改加载策略。

¥Both approaches have their pros and cons, use the one that suite best the use case at hand. If you want, you can change the loading strategy globally via loadStrategy option in the ORM config.

序列化

¥Serialization

密码呢?查看示例代码(前端):我们可以看到用户的密码,纯文本!我们需要对其进行哈希处理,并通过添加 hidden 序列化标志确保它永远不会泄漏到 API 响应中。此外,我们可以将其标记为 lazy,就像我们对 Article.text 所做的那样,因为我们很少想选择它。

¥What about the password? Seeing the logger Article entity with populated author, there is something we need to fix. We can see the user's password, in plain text! We will need to hash it and ensure it never leaks to the API response by adding hidden serialization flag. Moreover, we can mark it as lazy, just like we did with the Article.text, as we rarely want to select it.

现在,让我们使用可以同步创建的 sha256 算法,并在构造函数中对值进行哈希处理:

¥For now, let's use sha256 algorithm which we can create synchronously, and hash the value inside the constructor:

user.entity.ts
import crypto from 'crypto';

@Entity()
export class User extends BaseEntity<'bio'> {

@Property()
fullName: string;

@Property()
email: string;

@Property({ hidden: true, lazy: true })
password: string;

@Property({ type: 'text' })
bio = '';

constructor(fullName: string, email: string, password: string) {
super();
this.fullName = fullName;
this.email = email;
this.password = User.hashPassword(password);
}

static hashPassword(password: string) {
return crypto.createHmac('sha256', password).digest('hex');
}

}

现在更改我们创建 User 实体的部分,因为现在需要构造函数:

¥Now change the part where we create our User entity, as the constructor is now required:

const user = new User('Foo Bar', 'foo@bar.com', '123456');
console.log(user);

运行 npm start 后,你可以看到密码已散列,稍后加载 Article.author 时,密码不再被选中:

¥After running npm start, you can see that the password is hashed, and later when you load the Article.author, the password is no longer selected:

User {
id: undefined,
createdAt: 2022-09-11T17:22:31.619Z,
updatedAt: 2022-09-11T17:22:31.619Z,
fullName: 'Foo Bar',
email: 'foo@bar.com',
password: 'b946ccc987465afcda7e45b1715219711a13518d1f1663b8c53b848cb0143441',
bio: ''
}

目前这应该足够好了。别担心,稍后我们会通过生命周期钩子使用 argon2 来改进这一点,但首先要做的事情是!

¥That should be good enough for the time being. Don't worry, we will improve on this, later on, using argon2 via lifecycle hooks, but first things first!

集合:一对多和多对多

¥Collections: OneToMany and ManyToMany

你已打开 User 实体,让我们为其添加一个属性。你拥有 Article.author 属性,该属性定义 ArticleUser 实体之间关系的所有者。现在定义反面 - 对于 ManyToOne 关系,它是 OneToMany 类型 - 由 Article 实体的 Collection 表示:

¥You got the User entity opened, let's add one more property to it. You have the Article.author property that defines the owning side of this relationship between Article and User entities. Now define the inverse side—for ManyToOne relation it is the OneToMany kind—represented by a Collection of Article entities:

@Entity()
export class User extends BaseEntity<'bio'> {

// ...

@OneToMany({ mappedBy: 'author' })
articles = new Collection<Article>(this);

}

MikroORM 通过 Collection 类表示关系。在我们深入了解它的含义之前,让我们向 Article 模块添加一个实体来测试 ManyToMany 关系。它将是一个 Tag 实体,因此我们可以根据一些动态定义的标签对文章进行分类。

¥MikroORM represents the relation via the Collection class. Before we dive into what it means, let's add one more entity to the Article module to test the ManyToMany relation too. It will be a Tag entity, so we can categorize the article based on some dynamically defined tags.

Tag 实体在语义上属于 Article 模块,所以我们把它放在那里,放到 src/modules/article/tag.entity.ts 文件中。

¥The Tag entity semantically belongs to the Article module, so let's put it there, to the src/modules/article/tag.entity.ts file.

tag.entity.ts
import { Collection, Entity, ManyToMany, Property } from '@mikro-orm/core';
import { Article } from './article.entity.js';
import { BaseEntity } from '../common/base.entity.js';

@Entity()
export class Tag extends BaseEntity {

@Property({ length: 20 })
name!: string;

@ManyToMany({ mappedBy: 'tags' })
articles = new Collection<Article>(this);

}

我们还需要定义拥有方,即 Article.tags

¥And we need to define the owning side too, which is Article.tags:

article.entity.ts
@ManyToMany()
tags = new Collection<Tag>(this);

从反方通过 mappedBy 选项指向拥有方就足够了(反之亦然)。如果你想要从拥有方定义关系,请使用 inversedBy 选项。未定义这两个中的任何一个的多对多关系始终被视为拥有方。

¥It is enough to point to the owning side via mappedBy option from the inverse side (or vice versa). If you want to define the relation from owning side, use inversedBy option. A ManyToMany relation that does not define any of those two is always considered the owning side.

article.entity.ts
@ManyToMany({ inversedBy: 'articles' })
tags = new Collection<Tag>(this);

使用集合

¥Working with collections

Collection 类实现了 可迭代协议,因此你可以使用 for of 循环对其进行迭代。

¥The Collection class implements the iterable protocol, so you can use for of loop to iterate through it.

访问集合项的另一种方法是使用括号语法,就像我们访问数组项时一样。请记住,这种方法不会检查集合是否已初始化,而在这种情况下使用 getItems() 方法会抛出错误。

¥Another way to access collection items is to use bracket syntax like when we access array items. Keep in mind that this approach will not check if the collection is initialized, while using the getItems() method will throw an error in this case.

请注意,Collection 中的数组访问仅可用于读取已加载的项目,我们无法通过这种方式将新项目添加到 Collection

¥Note that array access in Collection is available only for reading already loaded items, we cannot add new items to Collection this way.

要获取存储在 Collection 中的所有实体,我们可以使用 getItems() 方法。如果 Collection 未初始化,它将抛出。如果我们想禁用此验证,我们可以使用 getItems(false)。这将为我们提供由身份映射管理的实体实例。

¥To get all entities stored in a Collection, we can use getItems() method. It will throw in case the Collection is not initialized. If we want to disable this validation, we can use getItems(false). This will give us the entity instances managed by the identity map.

或者,你可以使用 toArray(),它将 Collection 序列化为 DTO 数组。修改这些不会对实际实体实例产生影响。

¥Alternatively, you can use toArray() which will serialize the Collection to an array of DTOs. Modifying those will have no effect on the actual entity instances.

提示em.findOneOrFail()

到目前为止,我们使用了 em.findOne(),如果在数据库中找不到实体,它可以返回 null。这导致 TypeScript 中大量使用非空断言运算符,这可能会变得混乱。更好的解决方案是使用 em.findOneOrFail(),它始终返回实体或抛出错误,即 ORM 提供的 NotFoundError 实例。

¥So far we used em.findOne() which can return null if the entity is not found in the database. This results in extensive usage of the non-null assertion operator in TypeScript, which can get messy. A better solution is to use em.findOneOrFail(), which always returns the entity or throws an error, namely an instance of NotFoundError provided by the ORM.

// clear the context to simulate fresh request
em.clear();

// populating User.articles collection
const user = await em.findOneOrFail(User, 1, { populate: ['articles'] });
console.log(user);

// or you could lazy load the collection later via `init()` method
if (!user.articles.isInitialized()) {
await user.articles.init();
}

// to ensure collection is loaded (but do nothing if it already is), use `loadItems()` method
await user.articles.loadItems();

for (const article of user.articles) {
console.log(article.title);
console.log(article.author.fullName); // the `article.author` is linked automatically thanks to the Identity Map
}

Collection.init() 将始终查询数据库,而 Collection.loadItems() 仅在集合尚未初始化时才查询,因此调用 Collection.loadItems() 是安全的,无需先前的 isInitialized() 检查。

¥Collection.init() will always query the database, while Collection.loadItems() does only if the collection is not yet initialized, so calling Collection.loadItems() is safe without previous isInitialized() check.

运行此脚本,我们得到以下内容:

¥Running this, we get the following:

User {
id: 1,
createdAt: 2022-09-11T18:18:14.376Z,
updatedAt: 2022-09-11T18:18:14.376Z,
fullName: 'Foo Bar',
email: 'foo@bar.com',
password: undefined,
bio: '',
articles: Collection<Article> {
'0': Article {
id: 1,
createdAt: 2022-09-11T18:18:14.384Z,
updatedAt: 2022-09-11T18:18:14.384Z,
slug: 'foo-is-bar',
title: 'Foo is Bar',
description: 'Lorem impsum dolor sit amet…',
text: undefined,
author: [User],
tags: [Collection<Tag>]
},
initialized: true,
dirty: false
}
}

现在尝试向第一篇文章添加一些标签:

¥Now try to add some tags to the first article:

server.ts
// create some tags and assign them to the first article
const [article] = user.articles;
const newTag = em.create(Tag, { name: 'new' });
const oldTag = em.create(Tag, { name: 'old' });
article.tags.add(newTag, oldTag);
await em.flush();
console.log(article.tags);

为了方便,尝试删除其中一个标签:

¥And just for the sake of it, try to remove one of the tags:

server.ts
// to remove items from collection, we first need to initialize it, we can use `init()`, `loadItems()` or `em.populate()`
await em.populate(article, ['tags']);

// remove 'old' tag by reference
article.tags.remove(oldTag);

// or via callback
article.tags.remove(t => t.id === oldTag.id);

await em.flush();

有关更多信息和示例,请参阅文档中的 收藏部分

¥Refer to the Collections section in the docs for more information and examples.

事件和生命周期钩子

¥Events and life cycle hooks

是时候改进我们的密码哈希了。让我们使用 argon2 包,它提供 hashverify 函数。它们都是异步的,所以我们不能像以前一样在实体构造函数中使用它们。相反,我们需要使用生命周期钩子,即 @BeforeCreate()@BeforeUpdate()

¥Time to improve our password hashing. Let's use the argon2 package, which provides hash and verify functions. They are both async, so we cannot use them inside the entity constructor like before. Instead, we need to use the lifecycle hooks, namely @BeforeCreate() and @BeforeUpdate().

不要忘记通过 npm install argon2 安装 argon2 包。

¥Don't forget to install the argon2 package via npm install argon2.

计划如下:

¥The plan is following:

  • 通过构造函数分配时,密码将保留为纯文本

    ¥the password will remain in plaintext when assigned via the constructor

  • hashPassword 函数将成为事件处理程序,我们用 @BeforeCreate()@BeforeUpdate() 对其进行修饰

    ¥hashPassword function will become an event handler, we decorate it with @BeforeCreate() and @BeforeUpdate()

  • 因此,它将在刷新期间获取 EventArgs 参数,我们使用它来检测密码属性是否更改

    ¥as such, it will get the EventArgs parameter during the flush, we use that to detect if the password property changed

  • args.changeSet 包含定义有关实体及其状态的元数据的 ChangeSet 对象

    ¥the args.changeSet holds the ChangeSet object defining the metadata about the entity and its state

  • ChangeSet.payload 保存实际计算出的差异

    ¥ChangeSet.payload holds the actual computed difference

  • 我们稍后向 User 实体添加一个新的 verifyPassword() 方法

    ¥we add a new verifyPassword() method to the User entity to later

import { hash, verify } from 'argon2';

export class User extends BaseEntity<'bio'> {

// ...

constructor(fullName: string, email: string, password: string) {
super();
this.fullName = fullName;
this.email = email;
this.password = password; // keep plain text, will be hashed via hooks
}

@BeforeCreate()
@BeforeUpdate()
async hashPassword(args: EventArgs<User>) {
// hash only if the password was changed
const password = args.changeSet?.payload.password;

if (password) {
this.password = await hash(password);
}
}

async verifyPassword(password: string) {
return verify(this.password, password);
}

}

⛳ 检查点 2

¥⛳ Checkpoint 2

我们添加了 2 个新实体:ArticleTag 以及一个公共 BaseEntity。你可以在此处找到适用于当前状态的 StackBlitz:

¥We added 2 new entities: Article and Tag and a common BaseEntity. You can find working StackBlitz for the current state here:

由于 ts-node 中 ESM 支持的工作方式,无法在 StackBlitz 项目中使用它 - 我们需要改用 node --loader。我们还使用内存数据库,SQLite 功能可通过特殊数据库名称 :memory: 获得。

¥Due to the nature of how the ESM support in ts-node works, it is not possible to use it inside StackBlitz project - we need to use node --loader instead. We also use in-memory database, SQLite feature available via special database name :memory:.

这是我们本章之后的 server.ts file

¥This is our server.ts file after this chapter: