Populate(填充)

MongoDB 3.2 之后,也有像 sql 里 join 的聚合操作,那就是 $lookup 而 Mongoose,拥有更强大的 populate(),可以让你在别的 collection 中引用 document。

Population 可以自动替换 document 中的指定字段,替换内容从其他 collection 获取。 我们可以填充(populate)单个或多个 document、单个或多个纯对象,甚至是 query 返回的一切对象。 下面我们看看例子:

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);

现在我们创建了两个 ModelPerson model 的 stories 字段设为 ObjectId数组。 ref 选项告诉 Mongoose 在填充的时候使用哪个 model,本例中为 Story model。 所有储存在此的 _id 都必须是 Story model 中 document 的 _id

注意: ObjectIdNumberString 以及 Buffer 都可以作为 refs 使用。 但是最好还是使用 ObjectId,除非你是进阶玩家,并且有充分理由使用其他类型 作为 refs。

保存 refs

保存 refs 与保存普通属性一样,把 _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!
  });
});

Population

至此我们做的东西还是跟平常差不多,只是创建了 PersonStory。 现在我们试试对 query 填充 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:

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,而是被指定的 document 代替,这个 document 由另一条 query 从数据库返回。

refs 数组的原理也与此相似。对 query 对象调用 populate 方法, 就能返回装载对应 _id 的 document 数组。

设置被填充字段

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"
});

字段选择

如果我们只需要填充的 document 其中一部分字段怎么办? 第二参数传入 field name syntax 就能实现。

Story.
  findOne({ title: /casino royale/i }).
  populate('author', 'name'). // only return the Persons 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'
  });

填充多个字段

要一次填充多个字段怎么办?

Story.
  find(...).
  populate('fans').
  populate('author').
  exec();

如果对同一路径 populate() 两次,只有最后一次生效。

// 第二个 `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' });

Query 条件与其他选项

如果要根据年龄来填充,只填充 name,并且,只返回最多 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();

Refs 到 children

然而我们发现,用 author 对象没办法获取 story 列表,因为 author.stories 没有被 'pushed' 任何 story 对象。

于此有两方面,首先,我们希望 author 知道哪些 story 属于他们。通常, 你的 schema 应该通过在“多”的一方使用指向它们的父节点(parent pointer)解决一对多关系问题。 另一方面,如果你有充分理由得到指向子节点(child pointer)的数组, 你可以像下面代码一样把 document push() 到数组中。

author.stories.push(story1);
author.save(callback);

然后我们就能 findpopulate 了:

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);
  });

如果父子节点互相指向,数据可能会在某一时刻失去同步。 为此我们可以不使用填充,直接 find() 我们需要的 story。

Story.
  find({ author: author._id }).
  exec(function (err, stories) {
    if (err) return handleError(err);
    console.log('The stories are an array: ', stories);
  });

query 填充后返回的 document 功能齐全, 除非设置了 lean 选项,否则它就是可 remove,可 save 的。 不要把它们和 sub docs 弄混了, 调用 remove 方法要小心,因为这样不只是从数组删除,还会从数据库删除它们。

填充现有 document

现有 document 同样可以被填充,mongoose 3.6 之后支持了 document#populate() 方法。

填充多个现有 document

如果要填充一个或多个 document 或是(像 mapReduce 输出的)对象, 我们可以使用 Model.populate() 方法,此方法适用版本同样需要大于 mongoose 3.6document#populate()query#populate() 也是使用这个方法填充 document。

多级填充

假设 user schema 记录了 user 的 friends。

var userSchema = new Schema({
  name: String,
  friends: [{ type: ObjectId, ref: 'User' }]
});

你当然可以填充得到用户的 friends 列表,但是如果要再获得他们朋友的朋友呢? 指定 populate 选项就可以了:

User.
  findOne({ name: 'Val' }).
  populate({
    path: 'friends',
    // Get friends of friends - populate the 'friends' array for every friend
    populate: { path: 'friends' }
  });

跨数据库填充

假设现在有 event schema 和 conversation schema,每个 event 对应一个 conversation 线程。

var eventSchema = new Schema({
  name: String,
  // The id of the corresponding conversation
  // 注意,这里没有 ref!
  conversation: ObjectId
});
var conversationSchema = new Schema({
  numMessages: Number
});

并且,event 和 conversation 保存在不同 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() 了,因为 populate() 不知道应该使用什么填充。 不过你可以显式指定一个 model

Event.
  find().
  populate({ path: 'conversation', model: Conversation }).
  exec(function(error, docs) { /* ... */ });

这就是“跨数据库填充”,这可以让你跨 mongodb 数据库甚至是跨 mongodb 实例填充。

动态引用

Mongoose 也可以同时从多个 collection 填充。 假设 user schema 有一系列 connection, 一个 user 可以连接到其他 user 或组织。

var userSchema = new Schema({
  name: String,
  connections: [{
    kind: String,
    item: { type: ObjectId, refPath: 'connections.kind' }
  }]
});

var organizationSchema = new Schema({ name: String, kind: String });

var User = mongoose.model('User', userSchema);
var Organization = mongoose.model('Organization', organizationSchema);

上面的 refPath 属性意味着 mongoose 会查找 connections.kind 路径, 以此确定 populate() 使用的 model。换句话说,refPath 属性可以让你动态寻找 ref

// Say we have one organization:
// `{ _id: ObjectId('000000000000000000000001'), name: "Guns N' Roses", kind: 'Band' }`
// And two users:
// {
//   _id: ObjectId('000000000000000000000002')
//   name: 'Axl Rose',
//   connections: [
//     { kind: 'User', item: ObjectId('000000000000000000000003') },
//     { kind: 'Organization', item: ObjectId('000000000000000000000001') }
//   ]
// },
// {
//   _id: ObjectId('000000000000000000000003')
//   name: 'Slash',
//   connections: []
// }
User.
  findOne({ name: 'Axl Rose' }).
  populate('connections.item').
  exec(function(error, doc) {
    // doc.connections[0].item is a User doc
    // doc.connections[1].item is an Organization doc
  });

虚拟值填充

4.5.0 新功能

目前为止你只能以 _id 为基础进行填充,然而经常会造成不便。 特别地, So far you've only populated based on the _id field. However, that's sometimes not the right choice. In particular, arrays that grow without bound are a MongoDB anti-pattern. Using mongoose virtuals, you can define more sophisticated relationships between documents.

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
});

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() function)中打印, 需要在 toJSON 中设置 virtuals: true 选项。

// Set `virtuals: true` so `res.json()` works
var BandSchema = new Schema({
  name: String
}, { toJSON: { virtuals: true } });

如果你使用了填充保护,要确保 select 中包含了 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
  });

在中间件中使用填充

你可以在 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(function() {
    next();
  });
});

下一步

现在我们介绍了 populate(),接着看看 discriminators