填充
¥Populate
MongoDB 在版本 >= 3.2 中具有类似连接的 $查找 聚合运算符。Mongoose 有一个更强大的替代方案,称为 populate()
,它允许你引用其他集合中的文档。
¥MongoDB has the join-like $lookup aggregation operator in versions >= 3.2. Mongoose has a more powerful alternative called populate()
, which lets you reference documents in other collections.
填充是用其他集合中的文档自动替换文档中指定路径的过程。我们可以填充单个文档、多个文档、一个普通对象、多个普通对象或从查询返回的所有对象。让我们看一些例子。
¥Population is the process of automatically replacing the specified paths in the document with document(s) from other collection(s). We may populate a single document, multiple documents, a plain object, multiple plain objects, or all objects returned from a query. Let's look at some examples.
const mongoose = require('mongoose');
const { Schema } = mongoose;
const personSchema = Schema({
_id: Schema.Types.ObjectId,
name: String,
age: Number,
stories: [{ type: Schema.Types.ObjectId, ref: 'Story' }]
});
const storySchema = Schema({
author: { type: Schema.Types.ObjectId, ref: 'Person' },
title: String,
fans: [{ type: Schema.Types.ObjectId, ref: 'Person' }]
});
const Story = mongoose.model('Story', storySchema);
const Person = mongoose.model('Person', personSchema);
到目前为止我们已经创建了两个 模型。我们的 Person
模型将其 stories
字段设置为 ObjectId
数组。ref
选项告诉 Mongoose 在填充过程中使用哪个模型,在我们的例子中是 Story
模型。我们在这里存储的所有 _id
都必须是 Story
模型中的文档 _id
。
¥So far we've created two Models. Our Person
model has
its stories
field set to an array of ObjectId
s. The ref
option is
what tells Mongoose which model to use during population, in our case
the Story
model. All _id
s we store here must be document _id
s from
the Story
model.
- 保存引用
- 人口
- 检查字段是否已填充
- 设置填充字段
- 如果没有国外文件怎么办?
- 字段选择
- 填充多个路径
- 查询条件及其他选项
limit
与perDocumentLimit
- 子级引用
- 填充现有文档
- 填充多个现有文档
- 跨多个级别填充
- 跨数据库填充
- 通过
refPath
的动态参考 - 通过
ref
的动态参考 - 填充虚拟
- 填充虚拟:计数选项
- 填充虚拟:匹配选项
- 填充映射
- 填充中间件
- 在中间件中填充多个路径
- 转换填充的文档
保存引用
¥Saving refs
将引用保存到其他文档的工作方式与通常保存属性的方式相同,只需分配 _id
值:
¥Saving refs to other documents works the same way you normally save
properties, just assign the _id
value:
const author = new Person({
_id: new mongoose.Types.ObjectId(),
name: 'Ian Fleming',
age: 50
});
await author.save();
const story1 = new Story({
title: 'Casino Royale',
author: author._id // assign the _id from the person
});
await story1.save();
// that's it!
你可以在 ObjectId
、Number
、String
和 Buffer
路径上设置 ref
选项。populate()
适用于 ObjectId、数字、字符串和缓冲区。但是,我们建议使用 ObjectId 作为 _id
属性(因此使用 ObjectId 作为 ref
属性),除非你有充分的理由不这样做。这是因为,如果你创建一个没有 _id
属性的新文档,MongoDB 会将 _id
设置为 ObjectId,因此,如果你将 _id
属性设置为数字,则需要格外小心,不要插入没有数字 _id
的文档。
¥You can set the ref
option on ObjectId
, Number
, String
, and Buffer
paths.
populate()
works with ObjectIds, numbers, strings, and buffers.
However, we recommend using ObjectIds as _id
properties (and thus ObjectIds for ref
properties) unless you have a good reason not to.
That is because MongoDB will set _id
to an ObjectId if you create a new document without an _id
property, so if you make your _id
property a Number, you need to be extra careful not to insert a document without a numeric _id
.
人口
¥Population
到目前为止,我们还没有做任何不同的事情。我们只是创建了一个 Person
和一个 Story
。现在让我们看一下使用查询构建器填充故事的 author
:
¥So far we haven't done anything much different. We've merely created a
Person
and a Story
. Now let's take a look at populating our story's
author
using the query builder:
const story = await Story.
findOne({ title: 'Casino Royale' }).
populate('author').
exec();
// prints "The author is Ian Fleming"
console.log('The author is %s', story.author.name);
填充的路径不再设置为其原始 _id
,它们的值被替换为通过在返回结果之前执行单独的查询从数据库返回的 mongoose 文档。
¥Populated paths are no longer set to their original _id
, their value
is replaced with the mongoose document returned from the database by
performing a separate query before returning the results.
引用数组的工作方式相同。只需在查询中调用 populate 方法,就会返回一个文档数组来代替原来的 _id
。
¥Arrays of refs work the same way. Just call the
populate method on the query and an
array of documents will be returned in place of the original _id
s.
设置填充字段
¥Setting Populated Fields
你可以通过将属性设置到文档来手动填充该属性。该文档必须是你的 ref
属性引用的模型的实例。
¥You can manually populate a property by setting it to a document. The document
must be an instance of the model your ref
property refers to.
const story = await Story.findOne({ title: 'Casino Royale' });
story.author = author;
console.log(story.author.name); // prints "Ian Fleming"
你还可以将文档或 POJO 推送到已填充的数组上,如果它们的 ref
匹配,Mongoose 将添加这些文档。
¥You can also push documents or POJOs onto a populated array, and Mongoose will add those documents if their ref
matches.
const fan1 = await Person.create({ name: 'Sean' });
await Story.updateOne({ title: 'Casino Royale' }, { $push: { fans: { $each: [fan1._id] } } });
const story = await Story.findOne({ title: 'Casino Royale' }).populate('fans');
story.fans[0].name; // 'Sean'
const fan2 = await Person.create({ name: 'George' });
story.fans.push(fan2);
story.fans[1].name; // 'George'
story.fans.push({ name: 'Roger' });
story.fans[2].name; // 'Roger'
如果你推送非 POJO 和非文档值(例如 ObjectId),Mongoose >= 8.7.0
将清空整个数组。
¥If you push a non-POJO and non-document value, like an ObjectId, Mongoose >= 8.7.0
will depopulate the entire array.
const fan4 = await Person.create({ name: 'Timothy' });
story.fans.push(fan4._id); // Push the `_id`, not the full document
story.fans[0].name; // undefined, `fans[0]` is now an ObjectId
story.fans[0].toString() === fan1._id.toString(); // true
检查字段是否已填充
¥Checking Whether a Field is Populated
你可以调用 populated()
函数来检查字段是否已填充。如果 populated()
返回 真实值,你可以假设该字段已填充。
¥You can call the populated()
function to check whether a field is populated.
If populated()
returns a truthy value,
you can assume the field is populated.
story.populated('author'); // truthy
story.depopulate('author'); // Make `author` not populated anymore
story.populated('author'); // undefined
检查路径是否已填充的常见原因是获取 author
id。但是,为了你的方便,Mongoose 添加了 ObjectId 实例的 _id
getter,因此无论 author
是否填充,你都可以使用 story.author._id
。
¥A common reason for checking whether a path is populated is getting the author
id. However, for your convenience, Mongoose adds a _id
getter to ObjectId instances
so you can use story.author._id
regardless of whether author
is populated.
story.populated('author'); // truthy
story.author._id; // ObjectId
story.depopulate('author'); // Make `author` not populated anymore
story.populated('author'); // undefined
story.author instanceof ObjectId; // true
story.author._id; // ObjectId, because Mongoose adds a special getter
如果没有国外文件怎么办?
¥What If There's No Foreign Document?
Mongoose populate 的行为与传统的 SQL 连接 不同。当没有文档时,story.author
将是 null
。这类似于 SQL 中的 左连接。
¥Mongoose populate doesn't behave like conventional
SQL joins. When there's no
document, story.author
will be null
. This is analogous to a
left join in SQL.
await Person.deleteMany({ name: 'Ian Fleming' });
const story = await Story.findOne({ title: 'Casino Royale' }).populate('author');
story.author; // `null`
如果 storySchema
中有一个 authors
数组,populate()
将为你提供一个空数组。
¥If you have an array of authors
in your storySchema
, populate()
will
give you an empty array instead.
const storySchema = Schema({
authors: [{ type: Schema.Types.ObjectId, ref: 'Person' }],
title: String
});
// Later
const story = await Story.findOne({ title: 'Casino Royale' }).populate('authors');
story.authors; // `[]`
字段选择
¥Field Selection
如果我们只想为填充的文档返回一些特定字段怎么办?这可以通过将通常的 字段名称语法 作为第二个参数传递给 populate 方法来完成:
¥What if we only want a few specific fields returned for the populated documents? This can be accomplished by passing the usual field name syntax as the second argument to the populate method:
const story = await Story.
findOne({ title: /casino royale/i }).
populate('author', 'name').
exec(); // only return the Persons name
// prints "The author is Ian Fleming"
console.log('The author is %s', story.author.name);
// prints "The authors age is null"
console.log('The authors age is %s', story.author.age);
填充多个路径
¥Populating Multiple Paths
如果我们想同时填充多个路径怎么办?
¥What if we wanted to populate multiple paths at the same time?
await Story.
find({ /* ... */ }).
populate('fans').
populate('author').
exec();
如果同一路径多次调用 populate()
,则只有最后一次生效。
¥If you call populate()
multiple times with the same path, only the last
one will take effect.
// The 2nd `populate()` call below overwrites the first because they
// both populate 'fans'.
await Story.
find().
populate({ path: 'fans', select: 'name' }).
populate({ path: 'fans', select: 'email' });
// The above is equivalent to:
await Story.find().populate({ path: 'fans', select: 'email' });
查询条件及其他选项
¥Query conditions and other options
如果我们想根据粉丝的年龄填充粉丝数组并只选择他们的名字怎么办?
¥What if we wanted to populate our fans array based on their age and select just their names?
await Story.
find().
populate({
path: 'fans',
match: { age: { $gte: 21 } },
// Explicitly exclude `_id`, see http://bit.ly/2aEfTdB
select: 'name -_id'
}).
exec();
match
选项不会过滤掉 Story
文档。如果没有满足 match
的文档,你将得到一个带有空 fans
数组的 Story
文档。
¥The match
option doesn't filter out Story
documents. If there are no documents that satisfy match
,
you'll get a Story
document with an empty fans
array.
例如,假设你 populate()
了一个故事的 author
,而 author
不满足 match
。那么故事的 author
就会是 null
。
¥For example, suppose you populate()
a story's author
and the author
doesn't satisfy match
. Then
the story's author
will be null
.
const story = await Story.
findOne({ title: 'Casino Royale' }).
populate({ path: 'author', match: { name: { $ne: 'Ian Fleming' } } }).
exec();
story.author; // `null`
一般来说,没有办法让 populate()
根据故事 author
的属性来过滤故事。例如,以下查询不会返回任何结果,即使填充了 author
。
¥In general, there is no way to make populate()
filter stories based on properties of the story's author
.
For example, the below query won't return any results, even though author
is populated.
const story = await Story.
findOne({ 'author.name': 'Ian Fleming' }).
populate('author').
exec();
story; // null
如果你想按作者名称过滤故事,则应使用 denormalization。
¥If you want to filter stories by their author's name, you should use denormalization.
limit
与 perDocumentLimit
¥limit
vs. perDocumentLimit
Populate 确实支持 limit
选项,但是,它目前不限制每个文档的向后兼容性。例如,假设你有 2 个故事:
¥Populate does support a limit
option, however, it currently
does not limit on a per-document basis for backwards compatibility. For example,
suppose you have 2 stories:
await Story.create([
{ title: 'Casino Royale', fans: [1, 2, 3, 4, 5, 6, 7, 8] },
{ title: 'Live and Let Die', fans: [9, 10] }
]);
如果你使用 limit
选项来 populate()
,你会发现第二个故事有 0 个粉丝:
¥If you were to populate()
using the limit
option, you
would find that the 2nd story has 0 fans:
const stories = await Story.find().populate({
path: 'fans',
options: { limit: 2 }
});
stories[0].name; // 'Casino Royale'
stories[0].fans.length; // 2
// 2nd story has 0 fans!
stories[1].name; // 'Live and Let Die'
stories[1].fans.length; // 0
这是因为,为了避免对每个文档执行单独的查询,Mongoose 而是使用 numDocuments * limit
作为限制来查询粉丝。如果你需要正确的 limit
,则应使用 perDocumentLimit
选项(Mongoose 5.9.0 中的新增功能)。请记住,populate()
将为每个故事执行单独的查询,这可能会导致 populate()
变慢。
¥That's because, in order to avoid executing a separate query
for each document, Mongoose instead queries for fans using
numDocuments * limit
as the limit. If you need the correct
limit
, you should use the perDocumentLimit
option (new in Mongoose 5.9.0).
Just keep in mind that populate()
will execute a separate query
for each story, which may cause populate()
to be slower.
const stories = await Story.find().populate({
path: 'fans',
// Special option that tells Mongoose to execute a separate query
// for each `story` to make sure we get 2 fans for each story.
perDocumentLimit: 2
});
stories[0].name; // 'Casino Royale'
stories[0].fans.length; // 2
stories[1].name; // 'Live and Let Die'
stories[1].fans.length; // 2
子级引用
¥Refs to children
然而,我们可能会发现,如果我们使用 author
对象,我们无法获取故事列表。这是因为没有任何 story
对象曾经 'pushed' 到 author.stories
上。
¥We may find however, if we use the author
object, we are unable to get a
list of the stories. This is because no story
objects were ever 'pushed'
onto author.stories
.
这里有两种观点。首先,你可能希望 author
知道哪些故事是他们的。通常,你的结构应通过在 'many' 端具有父指针来解析一对多关系。但是,如果你有充分的理由想要一个子指针数组,你可以将 push()
文档放入该数组中,如下所示。
¥There are two perspectives here. First, you may want the author
to know
which stories are theirs. Usually, your schema should resolve
one-to-many relationships by having a parent pointer in the 'many' side.
But, if you have a good reason to want an array of child pointers, you
can push()
documents onto the array as shown below.
await story1.save();
author.stories.push(story1);
await author.save();
这使我们能够执行 find
和 populate
组合:
¥This allows us to perform a find
and populate
combo:
const person = await Person.
findOne({ name: 'Ian Fleming' }).
populate('stories').
exec(); // only works if we pushed refs to children
console.log(person);
我们是否真的需要两组指针是有争议的,因为它们可能会不同步。相反,我们可以跳过填充并直接 find()
我们感兴趣的故事。
¥It is debatable that we really want two sets of pointers as they may get
out of sync. Instead we could skip populating and directly find()
the
stories we are interested in.
const stories = await Story.
find({ author: author._id }).
exec();
console.log('The stories are an array: ', stories);
除非指定了 lean 选项,否则从 查询人口 返回的文档将成为功能齐全、remove
able、save
able 的文档。不要将它们与 子文档 混淆。调用其删除方法时要小心,因为你将从数据库中删除它,而不仅仅是从数组中删除。
¥The documents returned from
query population become fully
functional, remove
able, save
able documents unless the
lean option is specified. Do not confuse
them with sub docs. Take caution when calling its
remove method because you'll be removing it from the database, not just
the array.
填充现有文档
¥Populating an existing document
如果你有一个现有的 mongoose 文档并想要填充它的一些路径,你可以使用 Document#populate() 方法。
¥If you have an existing mongoose document and want to populate some of its paths, you can use the Document#populate() method.
const person = await Person.findOne({ name: 'Ian Fleming' });
person.populated('stories'); // null
// Call the `populate()` method on a document to populate a path.
await person.populate('stories');
person.populated('stories'); // Array of ObjectIds
person.stories[0].name; // 'Casino Royale'
Document#populate()
方法不支持链接。你需要多次调用 populate()
或使用路径数组来填充多个路径
¥The Document#populate()
method does not support chaining.
You need to call populate()
multiple times, or with an array of paths, to populate multiple paths
await person.populate(['stories', 'fans']);
person.populated('fans'); // Array of ObjectIds
填充多个现有文档
¥Populating multiple existing documents
如果我们有一个或多个 Mongoose 文档,甚至是普通对象(如 mapReduce 输出),我们可以使用 Model.populate() 方法填充它们。这是 Document#populate()
和 Query#populate()
用于填充文档的内容。
¥If we have one or many mongoose documents or even plain objects
(like mapReduce output), we may
populate them using the Model.populate()
method. This is what Document#populate()
and Query#populate()
use to populate documents.
跨多个级别填充
¥Populating across multiple levels
假设你有一个用户结构来跟踪用户的朋友。
¥Say you have a user schema which keeps track of the user's friends.
const userSchema = new Schema({
name: String,
friends: [{ type: ObjectId, ref: 'User' }]
});
填充可以让你获取用户朋友的列表,但是如果你还想要用户朋友的朋友怎么办?指定 populate
选项告诉 mongoose 填充所有用户好友的 friends
数组:
¥Populate lets you get a list of a user's friends, but what if you also
wanted a user's friends of friends? Specify the populate
option to tell
mongoose to populate the friends
array of all the user's friends:
await User.
findOne({ name: 'Val' }).
populate({
path: 'friends',
// Get friends of friends - populate the 'friends' array for every friend
populate: { path: 'friends' }
});
跨数据库填充
¥Cross Database Populate
假设你有一个表示事件的结构和一个表示对话的结构。每个事件都有一个相应的对话线程。
¥Let's say you have a schema representing events, and a schema representing conversations. Each event has a corresponding conversation thread.
const db1 = mongoose.createConnection('mongodb://127.0.0.1:27000/db1');
const db2 = mongoose.createConnection('mongodb://127.0.0.1:27001/db2');
const conversationSchema = new Schema({ numMessages: Number });
const Conversation = db2.model('Conversation', conversationSchema);
const eventSchema = new Schema({
name: String,
conversation: {
type: ObjectId,
ref: Conversation // `ref` is a **Model class**, not a string
}
});
const Event = db1.model('Event', eventSchema);
在上面的示例中,事件和对话存储在单独的 MongoDB 数据库中。字符串 ref
在这种情况下不起作用,因为 Mongoose 假定字符串 ref
引用同一连接上的模型名称。在上面的示例中,会话模型注册在 db2
上,而不是 db1
上。
¥In the above example, events and conversations are stored in separate MongoDB
databases. String ref
will not work in this situation, because Mongoose
assumes a string ref
refers to a model name on the same connection. In
the above example, the conversation model is registered on db2
, not db1
.
// Works
const events = await Event.
find().
populate('conversation');
这被称为 "跨数据库填充,",因为它使你能够跨 MongoDB 数据库甚至跨 MongoDB 实例进行填充。
¥This is known as a "cross-database populate," because it enables you to populate across MongoDB databases and even across MongoDB instances.
如果你在定义 eventSchema
时无权访问模型实例,也可以传递 模型实例作为 populate()
的选项。
¥If you don't have access to the model instance when defining your eventSchema
,
you can also pass the model instance as an option to populate()
.
const events = await Event.
find().
// The `model` option specifies the model to use for populating.
populate({ path: 'conversation', model: Conversation });
通过 refPath
的动态参考
¥Dynamic References via refPath
Mongoose 还可以根据文档中属性的值从多个集合中填充。假设你正在构建一个用于存储评论的结构。用户可以对博客文章或产品发表评论。
¥Mongoose can also populate from multiple collections based on the value of a property in the document. Let's say you're building a schema for storing comments. A user may comment on either a blog post or a product.
const commentSchema = new Schema({
body: { type: String, required: true },
doc: {
type: Schema.Types.ObjectId,
required: true,
// Instead of a hardcoded model name in `ref`, `refPath` means Mongoose
// will look at the `docModel` property to find the right model.
refPath: 'docModel'
},
docModel: {
type: String,
required: true,
enum: ['BlogPost', 'Product']
}
});
const Product = mongoose.model('Product', new Schema({ name: String }));
const BlogPost = mongoose.model('BlogPost', new Schema({ title: String }));
const Comment = mongoose.model('Comment', commentSchema);
refPath
选项是 ref
的更复杂的替代方案。如果 ref
是一个字符串,Mongoose 将始终查询相同的模型来查找填充的子文档。使用 refPath
,你可以配置 Mongoose 对每个文档使用的模型。
¥The refPath
option is a more sophisticated alternative to ref
.
If ref
is a string, Mongoose will always query the same model to find the populated subdocs.
With refPath
, you can configure what model Mongoose uses for each document.
const book = await Product.create({ name: 'The Count of Monte Cristo' });
const post = await BlogPost.create({ title: 'Top 10 French Novels' });
const commentOnBook = await Comment.create({
body: 'Great read',
doc: book._id,
docModel: 'Product'
});
const commentOnPost = await Comment.create({
body: 'Very informative',
doc: post._id,
docModel: 'BlogPost'
});
// The below `populate()` works even though one comment references the
// 'Product' collection and the other references the 'BlogPost' collection.
const comments = await Comment.find().populate('doc').sort({ body: 1 });
comments[0].doc.name; // "The Count of Monte Cristo"
comments[1].doc.title; // "Top 10 French Novels"
另一种方法是在 commentSchema
上定义单独的 blogPost
和 product
属性,然后在这两个属性上定义 populate()
。
¥An alternative approach is to define separate blogPost
and product
properties on commentSchema
, and then populate()
on both properties.
const commentSchema = new Schema({
body: { type: String, required: true },
product: {
type: Schema.Types.ObjectId,
required: true,
ref: 'Product'
},
blogPost: {
type: Schema.Types.ObjectId,
required: true,
ref: 'BlogPost'
}
});
// ...
// The below `populate()` is equivalent to the `refPath` approach, you
// just need to make sure you `populate()` both `product` and `blogPost`.
const comments = await Comment.find().
populate('product').
populate('blogPost').
sort({ body: 1 });
comments[0].product.name; // "The Count of Monte Cristo"
comments[1].blogPost.title; // "Top 10 French Novels"
定义单独的 blogPost
和 product
属性适用于这个简单的示例。但是,如果你决定允许用户对文章或其他评论发表评论,则需要向结构添加更多属性。你还需要为每个属性进行额外的 populate()
调用,除非你使用 mongoose-autopopulate。使用 refPath
意味着你只需要 2 个结构路径和一个 populate()
调用,无论你的 commentSchema
可以指向多少个模型。
¥Defining separate blogPost
and product
properties works for this simple
example. But, if you decide to allow users to also comment on articles or
other comments, you'll need to add more properties to your schema. You'll
also need an extra populate()
call for every property, unless you use
mongoose-autopopulate.
Using refPath
means you only need 2 schema paths and one populate()
call
regardless of how many models your commentSchema
can point to.
你还可以为 refPath
分配一个函数,这意味着 Mongoose 根据正在填充的文档上的值选择 refPath。
¥You could also assign a function to refPath
, which means Mongoose selects a refPath depending on a value on the document being populated.
const commentSchema = new Schema({
body: { type: String, required: true },
commentType: {
type: String,
enum: ['comment', 'review']
},
entityId: {
type: Schema.Types.ObjectId,
required: true,
refPath: function () {
return this.commentType === 'review' ? this.reviewEntityModel : this.commentEntityModel; // 'this' refers to the document being populated
}
},
commentEntityModel: {
type: String,
required: true,
enum: ['BlogPost', 'Review']
},
reviewEntityModel: {
type: String,
required: true,
enum: ['Vendor', 'Product']
}
});
通过 ref
的动态参考
¥Dynamic References via ref
就像 refPath
一样,ref
也可以被分配一个功能。
¥Just like refPath
, ref
can also be assigned a function.
const commentSchema = new Schema({
body: { type: String, required: true },
verifiedBuyer: Boolean
doc: {
type: Schema.Types.ObjectId,
required: true,
ref: function() {
return this.verifiedBuyer ? 'Product' : 'BlogPost'; // 'this' refers to the document being populated
}
},
});
填充虚拟
¥Populate Virtuals
到目前为止,你仅根据 _id
字段进行了填充。然而,有时这并不是正确的选择。例如,假设你有 2 个模型:Author
和 BlogPost
。
¥So far you've only populated based on the _id
field.
However, that's sometimes not the right choice.
For example, suppose you have 2 models: Author
and BlogPost
.
const AuthorSchema = new Schema({
name: String,
posts: [{ type: mongoose.Schema.Types.ObjectId, ref: 'BlogPost' }]
});
const BlogPostSchema = new Schema({
title: String,
comments: [{
author: { type: mongoose.Schema.Types.ObjectId, ref: 'Author' },
content: String
}]
});
const Author = mongoose.model('Author', AuthorSchema, 'Author');
const BlogPost = mongoose.model('BlogPost', BlogPostSchema, 'BlogPost');
上面是一个糟糕的模式设计的例子。为什么?假设你有一位非常多产的作者,撰写了超过 10,000 篇博客文章。那个 author
文档会很大,超过 12kb,大文档会导致服务器和客户端上的性能问题。最小基数原理 规定一对多关系(例如作者与博客文章)应存储在 "many" 端。换句话说,博客文章应该存储他们的 author
,作者不应该存储他们所有的 posts
。
¥The above is an example of bad schema design. Why?
Suppose you have an extremely prolific author that writes over 10k blog posts.
That author
document will be huge, over 12kb, and large documents lead to performance issues on both server and client.
The Principle of Least Cardinality states that one-to-many relationships, like author to blog post, should be stored on the "many" side.
In other words, blog posts should store their author
, authors should not store all their posts
.
const AuthorSchema = new Schema({
name: String
});
const BlogPostSchema = new Schema({
title: String,
author: { type: mongoose.Schema.Types.ObjectId, ref: 'Author' },
comments: [{
author: { type: mongoose.Schema.Types.ObjectId, ref: 'Author' },
content: String
}]
});
不幸的是,这两个结构,正如所写的,不支持填充作者的博客文章列表。这就是虚拟填充的用武之地。虚拟填充意味着在具有 ref
选项的虚拟属性上调用 populate()
,如下所示。
¥Unfortunately, these two schemas, as written, don't support populating an author's list of blog posts.
That's where virtual populate comes in.
Virtual populate means calling populate()
on a virtual property that has a ref
option as shown below.
// Specifying a virtual with a `ref` property is how you enable virtual
// population
AuthorSchema.virtual('posts', {
ref: 'BlogPost',
localField: '_id',
foreignField: 'author'
});
const Author = mongoose.model('Author', AuthorSchema, 'Author');
const BlogPost = mongoose.model('BlogPost', BlogPostSchema, 'BlogPost');
然后你可以 populate()
作者的 posts
,如下所示。
¥You can then populate()
the author's posts
as shown below.
const author = await Author.findOne().populate('posts');
author.posts[0].title; // Title of the first blog post
请记住,默认情况下,虚拟值不包含在 toJSON()
和 toObject()
输出中。如果你希望在使用 Express 的 res.json()
function 或 console.log()
等函数时显示填充虚拟值,请在结构的 toJSON
和 toObject()
选项上设置 virtuals: true
选项。
¥Keep in mind that virtuals are not included in toJSON()
and toObject()
output by default.
If you want populate virtuals to show up when using functions like Express' res.json()
function or console.log()
, set the virtuals: true
option on your schema's toJSON
and toObject()
options.
const authorSchema = new Schema({ name: String }, {
toJSON: { virtuals: true }, // So `res.json()` and other `JSON.stringify()` functions include virtuals
toObject: { virtuals: true } // So `console.log()` and other functions that use `toObject()` include virtuals
});
如果你使用填充投影,请确保 foreignField
包含在投影中。
¥If you're using populate projections, make sure foreignField
is included
in the projection.
let authors = await Author.
find({}).
// Won't work because the foreign field `author` is not selected
populate({ path: 'posts', select: 'title' }).
exec();
authors = await Author.
find({}).
// Works, foreign field `author` is selected
populate({ path: 'posts', select: 'title author' }).
exec();
填充虚拟对象:计数选项
¥Populate Virtuals: The Count Option
填充虚拟还支持计算具有匹配 foreignField
的文档数量,而不是文档本身。在虚拟机上设置 count
选项:
¥Populate virtuals also support counting the number of documents with
matching foreignField
as opposed to the documents themselves. Set the
count
option on your virtual:
const PersonSchema = new Schema({
name: String,
band: String
});
const BandSchema = new Schema({
name: String
});
BandSchema.virtual('numMembers', {
ref: 'Person', // The model to use
localField: 'name', // Find people where `localField`
foreignField: 'band', // is equal to `foreignField`
count: true // And only get the number of docs
});
// Later
const doc = await Band.findOne({ name: 'Motley Crue' }).
populate('numMembers');
doc.numMembers; // 2
填充虚拟对象:匹配选项
¥Populate Virtuals: The Match Option
填充虚拟的另一个选项是 match
。此选项向 Mongoose 使用 populate()
的查询添加额外的过滤条件:
¥Another option for Populate virtuals is match
.
This option adds an extra filter condition to the query Mongoose uses to populate()
:
// Same example as 'Populate Virtuals' section
AuthorSchema.virtual('posts', {
ref: 'BlogPost',
localField: '_id',
foreignField: 'author',
match: { archived: false } // match option with basic query selector
});
const Author = mongoose.model('Author', AuthorSchema, 'Author');
const BlogPost = mongoose.model('BlogPost', BlogPostSchema, 'BlogPost');
// After population
const author = await Author.findOne().populate('posts');
author.posts; // Array of not `archived` posts
你还可以将 match
选项设置为函数。这允许根据正在填充的文档配置 match
。例如,假设你只想填充 tags
包含作者之一的 favoriteTags
的博客文章。
¥You can also set the match
option to a function.
That allows configuring the match
based on the document being populated.
For example, suppose you only want to populate blog posts whose tags
contain one of the author's favoriteTags
.
AuthorSchema.virtual('posts', {
ref: 'BlogPost',
localField: '_id',
foreignField: 'author',
// Add an additional filter `{ tags: author.favoriteTags }` to the populate query
// Mongoose calls the `match` function with the document being populated as the
// first argument.
match: author => ({ tags: author.favoriteTags })
});
你可以在调用 populate()
时覆盖 match
选项,如下所示。
¥You can overwrite the match
option when calling populate()
as follows.
// Overwrite the `match` option specified in `AuthorSchema.virtual()` for this
// single `populate()` call.
await Author.findOne().populate({ path: posts, match: {} });
你还可以将 match
选项设置为 populate()
调用中的函数。如果你想合并 populate()
匹配选项而不是覆盖,请使用以下命令。
¥You can also set the match
option to a function in your populate()
call.
If you want to merge your populate()
match option, rather than overwriting, use the following.
await Author.findOne().populate({
path: posts,
// Add `isDeleted: false` to the virtual's default `match`, so the `match`
// option would be `{ tags: author.favoriteTags, isDeleted: false }`
match: (author, virtual) => ({
...virtual.options.match(author),
isDeleted: false
})
});
填充映射
¥Populating Maps
映射 是表示具有任意字符串键的对象的类型。例如,在下面的结构中,members
是从字符串到 ObjectId 的映射。
¥Maps are a type that represents an object with arbitrary
string keys. For example, in the below schema, members
is a map from strings to ObjectIds.
const BandSchema = new Schema({
name: String,
members: {
type: Map,
of: {
type: 'ObjectId',
ref: 'Person'
}
}
});
const Band = mongoose.model('Band', bandSchema);
该映射有 ref
,这意味着你可以使用 populate()
来填充映射中的所有 ObjectId。假设你有以下 band
文档:
¥This map has a ref
, which means you can use populate()
to populate all the ObjectIds
in the map. Suppose you have the below band
document:
const person1 = new Person({ name: 'Vince Neil' });
const person2 = new Person({ name: 'Mick Mars' });
const band = new Band({
name: 'Motley Crue',
members: {
singer: person1._id,
guitarist: person2._id
}
});
你可以通过填充特殊路径 members.$*
来对映射中的每个元素进行 populate()
。$*
是一种特殊的语法,它告诉 Mongoose 查看映射中的每个键。
¥You can populate()
every element in the map by populating the special path members.$*
.
$*
is a special syntax that tells Mongoose to look at every key in the map.
const band = await Band.findOne({ name: 'Motley Crue' }).populate('members.$*');
band.members.get('singer'); // { _id: ..., name: 'Vince Neil' }
你还可以使用 $*
填充子文档映射中的路径。例如,假设你有以下 librarySchema
:
¥You can also populate paths in maps of subdocuments using $*
. For example, suppose you
have the below librarySchema
:
const librarySchema = new Schema({
name: String,
books: {
type: Map,
of: new Schema({
title: String,
author: {
type: 'ObjectId',
ref: 'Person'
}
})
}
});
const Library = mongoose.model('Library', librarySchema);
你可以通过填充 books.$*.author
来 populate()
每本书的作者:
¥You can populate()
every book's author by populating books.$*.author
:
const libraries = await Library.find().populate('books.$*.author');
填充中间件
¥Populate in Middleware
你可以在 hooks 之前或之后填充。如果你想始终填充某个字段,请查看 Mongoose 自动填充插件。
¥You can populate in either pre or post hooks. If you want to always populate a certain field, check out the mongoose-autopopulate plugin.
// Always attach `populate()` to `find()` calls
MySchema.pre('find', function() {
this.populate('user');
});
// Always `populate()` after `find()` calls. Useful if you want to selectively populate
// based on the docs found.
MySchema.post('find', async function(docs) {
for (const doc of docs) {
if (doc.isPublic) {
await doc.populate('user');
}
}
});
// `populate()` after saving. Useful for sending populated data back to the client in an
// update API endpoint
MySchema.post('save', function(doc, next) {
doc.populate('user').then(function() {
next();
});
});
在中间件中填充多个路径
¥Populating Multiple Paths in Middleware
当你总是想要填充某些字段时,在中间件中填充多个路径会很有帮助。但是,实现比你想象的要复杂一点。你可能期望它的工作方式如下:
¥Populating multiple paths in middleware can be helpful when you always want to populate some fields. But, the implementation is just a tiny bit trickier than what you may think. Here's how you may expect it to work:
const userSchema = new Schema({
email: String,
password: String,
followers: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
following: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]
});
userSchema.pre('find', function(next) {
this.populate('followers following');
next();
});
const User = mongoose.model('User', userSchema);
然而,这是行不通的。默认情况下,在中间件中传递多个路径到 populate()
将触发无限递归,这意味着它基本上会为提供给 populate()
方法的所有路径触发相同的中间件 - 例如,this.populate('followers following')
将为 followers
和 following
字段触发相同的中间件,并且请求将挂在无限循环中。
¥However, this will not work. By default, passing multiple paths to populate()
in the middleware will trigger an infinite recursion, which means that it will basically trigger the same middleware for all of the paths provided to the populate()
method - For example, this.populate('followers following')
will trigger the same middleware for both followers
and following
fields and the request will just be left hanging in an infinite loop.
为了避免这种情况,我们必须添加 _recursed
选项,这样我们的中间件就可以避免递归填充。下面的示例将使其按预期工作。
¥To avoid this, we have to add the _recursed
option, so that our middleware will avoid populating recursively. The example below will make it work as expected.
userSchema.pre('find', function(next) {
if (this.options._recursed) {
return next();
}
this.populate({ path: 'followers following', options: { _recursed: true } });
next();
});
或者,你可以查看 Mongoose 自动填充插件。
¥Alternatively, you can check out the mongoose-autopopulate plugin.
转换填充的文档
¥Transform populated documents
你可以使用 transform
选项操作填充的文档。如果你指定 transform
函数,Mongoose 将使用两个参数对结果中的每个填充文档调用此函数:填充的文档,以及用于填充文档的原始 ID。这使你可以更好地控制 populate()
执行的结果。当你填充多个文档时,它特别有用。
¥You can manipulate populated documents using the transform
option.
If you specify a transform
function, Mongoose will call this function on every populated document in the result with two arguments: the populated document, and the original id used to populate the document.
This gives you more control over the result of the populate()
execution.
It is especially useful when you're populating multiple documents.
transform
选项的 原始动机 允许在未找到文档时保留未填充的 _id
,而不是将值设置为 null
:
¥The original motivation for the transform
option was to give the ability to leave the unpopulated _id
if no document was found, instead of setting the value to null
:
// With `transform`
doc = await Parent.findById(doc).populate([
{
path: 'child',
// If `doc` is null, use the original id instead
transform: (doc, id) => doc == null ? id : doc
}
]);
doc.child; // 634d1a5744efe65ae09142f9
doc.children; // [ 634d1a67ac15090a0ca6c0ea, { _id: 634d1a4ddb804d17d95d1c7f, name: 'Luke', __v: 0 } ]
你可以从 transform()
返回任何值。例如,你可以使用 transform()
到 "flatten" 填充的文档,如下所示。
¥You can return any value from transform()
.
For example, you can use transform()
to "flatten" populated documents as follows.
let doc = await Parent.create({ children: [{ name: 'Luke' }, { name: 'Leia' }] });
doc = await Parent.findById(doc).populate([{
path: 'children',
transform: doc => doc == null ? null : doc.name
}]);
doc.children; // ['Luke', 'Leia']
transform()
的另一个用例是在填充的文档上设置 $locals
值,以将参数传递给 getters 和 virtuals。例如,假设你想要在文档上设置语言代码以实现国际化,如下所示。
¥Another use case for transform()
is setting $locals
values on populated documents to pass parameters to getters and virtuals.
For example, suppose you want to set a language code on your document for internationalization purposes as follows.
const internationalizedStringSchema = new Schema({
en: String,
es: String
});
const ingredientSchema = new Schema({
// Instead of setting `name` to just a string, set `name` to a map
// of language codes to strings.
name: {
type: internationalizedStringSchema,
// When you access `name`, pull the document's locale
get: function(value) {
return value[this.$locals.language || 'en'];
}
}
});
const recipeSchema = new Schema({
ingredients: [{ type: mongoose.ObjectId, ref: 'Ingredient' }]
});
const Ingredient = mongoose.model('Ingredient', ingredientSchema);
const Recipe = mongoose.model('Recipe', recipeSchema);
你可以为所有填充的练习设置语言代码,如下所示:
¥You can set the language code on all populated exercises as follows:
// Create some sample data
const { _id } = await Ingredient.create({
name: {
en: 'Eggs',
es: 'Huevos'
}
});
await Recipe.create({ ingredients: [_id] });
// Populate with setting `$locals.language` for internationalization
const language = 'es';
const recipes = await Recipe.find().populate({
path: 'ingredients',
transform: function(doc) {
doc.$locals.language = language;
return doc;
}
});
// Gets the ingredient's name in Spanish `name.es`
recipes[0].ingredients[0].name; // 'Huevos'