Mongoose中的填充查询(populate)类似关系型数据库中的“连接查询”,通过populate()函数,使你可以在一个文档中引用另一个集合中的文档,并将其填充到指定文档路径中。
备注:也有人将populate译为“联表”,本系列文档中统一使用“填充”。
- 概述
- 保存引用
- 填充
- 设置填充字段
- 字段选择
- 填充多个路径
- 查询条件与其它选项
- 引用子文档
- 填充己存在的文档
- 填充多个己存在的文档
- 多层级填充
- 跨数据库填充
refPath动态引用- 虚拟(
virtual)属性/路径填充 - 中间件中填充
1. 概述
MongoDB在>=3.2版本中提供了类似连接的$lookup聚合运算符。而在Mongoose中,有一个更强大的替代方法叫做populate(),它允许你引用其它集合中的文档。
填充(Population)是使用来自其它集合中的文档自动替换文档中的指定路径的过程。填充可以是单个文档、多个文档、普通对象、多个普通对象或从查询返回的所有对象。来看一些例子:
var mongoose = require('mongoose');
var Schema = mongoose.Schema;
var personSchema = Schema({
_id: Schema.Types.ObjectId,
name: String,
age: Number,
stories: [{ type: Schema.Types.ObjectId, ref: 'Story' }]
});
var storySchema = Schema({
author: { type: Schema.Types.ObjectId, ref: 'Person' },
title: String,
fans: [{ type: Schema.Types.ObjectId, ref: 'Person' }]
});
var Story = mongoose.model('Story', storySchema);
var Person = mongoose.model('Person', personSchema);
以上我们创建了两个Model。其中,Person模型有一个stories字段,其被设置为ObjectId数组。ref选项会告诉Mongoose哪个Model会在填充的时候使用,在我们示例中为Story模型,所存储的_id必须是Story模型中的文档的_id。
注意:ObjectId, Number, String和Buffer都可以用于引用(ref)。但是,除非必要情况下,更推荐使用ObjectId。
2. 保存引用
将ref保存到其他文档的与通常保存属性的方式相同,只需指定_id值:
var author = new Person({
_id: new mongoose.Types.ObjectId(),
name: 'Ian Fleming',
age: 50
});
author.save(function (err) {
if (err) return handleError(err);
var story1 = new Story({
title: 'Casino Royale',
author: author._id // assign the _id from the person
});
story1.save(function (err) {
if (err) return handleError(err);
// thats it!
});
});
3. 填充
目前为止,我们所做的并没什么不同,只是创建了一个Preson和Story。接下来看一下,怎样在查询绑定时填充story的author:
Story.
findOne({ title: 'Casino Royale' }).
populate('author').
exec(function (err, story) {
if (err) return handleError(err);
console.log('The author is %s', story.author.name);
// prints "The author is Ian Fleming"
});
被填充的路径不再是原始的_id,其值将被替换为从数据库返回的mongoose文档,此操作会在返回结果之前执行单独的查询。
ref值是一个数组时同样可用,只需要在查询时调用populate方法,文档数组就会替换原有的_id。
4. 设置填充字段
在Mongoose>= 4.0后,我们可以像下面这样手工设置填充字段:
Story.findOne({ title: 'Casino Royale' }, function(error, story) {
if (error) {
return handleError(error);
}
story.author = author;
console.log(story.author.name); // prints "Ian Fleming"
});
5. 字段选择
如果我们只想返回填充的文档某些字段,该怎么操作呢?这时可以将所需的字段名称作为第二个参数传递给populate方法来实现:
Story.
findOne({ title: /casino royale/i }).
populate('author', 'name'). // 仅返回 Person 的'name'字段
exec(function (err, story) {
if (err) return handleError(err);
console.log('The author is %s', story.author.name);
// prints "The author is Ian Fleming"
console.log('The authors age is %s', story.author.age);
// prints "The authors age is null'
});
6. 填充多个路径
需要填充多个路径时,只需要多次调用populate()方法即可:
Story.
find(...).
populate('fans').
populate('author').
exec();
但是,如果在同一个路径上多次调用populate()方法,仅最后一次调用会生效:
// The 2nd `populate()` call below overwrites the first because they
// both populate 'fans'.
Story.
find().
populate({ path: 'fans', select: 'name' }).
populate({ path: 'fans', select: 'email' });
// The above is equivalent to:
Story.find().populate({ path: 'fans', select: 'email' });
7. 查询条件与其它选项
接下来,我们想按年龄(age)来对的fans进行筛选,并且只返回他们的名字,并且最多返回其中的5个。这时,可以像下面这样操作:
Story.
find(...).
populate({
path: 'fans',
match: { age: { $gte: 21 }},
// Explicitly exclude `_id`, see http://bit.ly/2aEfTdB
select: 'name -_id',
options: { limit: 5 }
}).
exec();
8. 引用子文档
在前面我们通过story引用到了author,但我们可能会发现,如果是通过author对象则无法获取story。因为没有任何story对象被“推送”到author.stories。
首先,你可能希望author知道哪些story是他们的。通常,你的模式应该在“many”侧具有父指针来处理一对多(one-to-many)关系。或者,你可以有一个指向子对象的数组,并可以将文档push()到数组,如下所示。
author.stories.push(story1); author.save(callback);
这样我们就可以组合执行find和populate:
Person.
findOne({ name: 'Ian Fleming' }).
populate('stories'). // only works if we pushed refs to children
exec(function (err, person) {
if (err) return handleError(err);
console.log(person);
});
值得考虑的是,我们是否确定需要两组指针,因为它们可能会失去同步。相反,我们也可以跳过填充而直接找到所需要的stroy:
Story.
find({ author: author._id }).
exec(function (err, stories) {
if (err) return handleError(err);
console.log('The stories are an array: ', stories);
});
通过查询填充所返回的文档是全功能的(是一个Mongoose文档),可remove、可save,除非指定了lean选项。不要将它们与子文档混淆。调用remove方法时要小心,因为这些文档会从数据库中删除,而不仅仅是数组。
9. 填充己存在的文档
如果我们已经有一个mongoose文档并想要填充它的一些路径,mongoose >= 3.6的document#populate()方法支持这一功能。
10. 填充多个己存在的文档
如果我们有一个或多个mongoose文档甚至普通对象(像mapReduce的输出),可以使用mongoose >= 3.6所提供的Model.populate()方法来填充。这也是document#populate()和query#populate填充文档的方式。
11. 多层级填充
假设有如下一个Schema,用于跟踪用户(user)的朋友(friend):
var userSchema = new Schema({
name: String,
friends: [{ type: ObjectId, ref: 'User' }]
});
populate使你有了一个用户的朋友列表,这时如果还想得到用户的朋友的朋友呢?可以指定populate选项来告诉mongoose填充所有用户朋友的friends数组:
User.
findOne({ name: 'Val' }).
populate({
path: 'friends',
// Get friends of friends - populate the 'friends' array for every friend
populate: { path: 'friends' }
});
12. 跨数据库填充
假设有一个表示事件的模式(eventSchema),以及一个表示会话的模式(conversationSchema)。 每个事件都有一个对应的会话线程:
var eventSchema = new Schema({
name: String,
// The id of the corresponding conversation
// Notice there's no ref here!
conversation: ObjectId
});
var conversationSchema = new Schema({
numMessages: Number
});
此外,假设事件和会话存储在不同的MongoDB实例中。
var db1 = mongoose.createConnection('localhost:27000/db1');
var db2 = mongoose.createConnection('localhost:27001/db2');
var Event = db1.model('Event', eventSchema);
var Conversation = db2.model('Conversation', conversationSchema);
在这种情况下,将无法正常使用populate()。conversation字段将始终为null,因为populate()不知道要使用哪个模型。但是,可以显式指定模型:
Event.
find().
populate({ path: 'conversation', model: Conversation }).
exec(function(error, docs) { /* ... */ });
这可以称为“跨数据库填充”,因为它使你能够跨MongoDB数据库,甚至跨MongoDB实例填充。
13. refPath动态引用
Mongoose还可以根据文档中属性的值从多个集合中填充。例如,构建一个用于存储评论(comment)的模式,用户可以评论博客文章或产品:
const commentSchema = new Schema({
body: { type: String, required: true },
on: {
type: Schema.Types.ObjectId,
required: true,
// Instead of a hardcoded model name in `ref`, `refPath` means Mongoose
// will look at the `onModel` property to find the right model.
refPath: 'onModel'
},
onModel: {
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每个文档所应使用的模型。
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',
on: book._id,
onModel: 'Product'
});
const commentOnPost = await Comment.create({
body: 'Very informative',
on: post._id,
onModel: '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('on').sort({ body: 1 });
comments[0].on.name; // "The Count of Monte Cristo"
comments[1].on.title; // "Top 10 French Novels"
另一种方法是在commentSchema上定义单独的blogPost和product属性,然后在两个属性上populate():
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属性适用于这个简单示例。但是,如果也允许用户对文章或其他评论发表评论,则需要向模式添加更多属性。除非你使用mongoose-autopopulate,否则你还需要对每个属性进行额外的populate()调用。使用refPath意味着你只需要2个模式路径和一个populate()调用,而无论commentSchema可以指向多少个模型。
14. 虚拟(virtual)属性/路径填充
目前为止,我们都是基于_id字段进行的填充,但在某些情况下,这并不适用。特别是,无限制增长的数组是MongoDB反模式(One-to-Many)。使用mongoose虚拟属性,可以在文档之间定义更复杂的关系。
var PersonSchema = new Schema({
name: String,
band: String
});
var BandSchema = new Schema({
name: String
});
BandSchema.virtual('members', {
ref: 'Person', // The model to use
localField: 'name', // Find people where `localField`
foreignField: 'band', // is equal to `foreignField`
// If `justOne` is true, 'members' will be a single doc as opposed to
// an array. `justOne` is false by default.
justOne: false,
options: { sort: { name: -1 }, limit: 5 } // Query options, see http://bit.ly/mongoose-query-options
});
var Person = mongoose.model('Person', PersonSchema);
var Band = mongoose.model('Band', BandSchema);
/**
* Suppose you have 2 bands: "Guns N' Roses" and "Motley Crue"
* And 4 people: "Axl Rose" and "Slash" with "Guns N' Roses", and
* "Vince Neil" and "Nikki Sixx" with "Motley Crue"
*/
Band.find({}).populate('members').exec(function(error, bands) {
/* `bands.members` is now an array of instances of `Person` */
});
需要注意,虚拟属性默认并不包含在toJSON()的输出中。如果要在使用依赖于JSON.stringify()的函数(如:Express的res.json()函数)中显示虚拟属性填充,则需要在模式的的toJSON选项上设置virtuals:true选项:
// Set `virtuals: true` so `res.json()` works
var BandSchema = new Schema({
name: String
}, { toJSON: { virtuals: true } });
如果您正在使用填充投影(projection),应确保在投影中包含foreignField:
Band.
find({}).
populate({ path: 'members', select: 'name' }).
exec(function(error, bands) {
// Won't work, foreign field `band` is not selected in the projection
});
Band.
find({}).
populate({ path: 'members', select: 'name band' }).
exec(function(error, bands) {
// Works, foreign field `band` is selected
});
15. 中间件中填充
还可以pre或post勾子中使用填充。如果始终要填充某个字段,请查看mongoose-autopopulate插件。
// 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 (let doc of docs) {
if (doc.isPublic) {
await doc.populate('user').execPopulate();
}
}
});
// `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').execPopulate().then(function() {
next();
});
});
下一步
现在我们已经介绍了populate(),接下来我们来看一下鉴别器。
变更记录
- [2018-11-23] 基于Mongoose官方文档
v5.3.12首次发布
