填充

MongoDB 在版本 >= 3.2 中具有类似连接的 $lookup 聚合运算符。 Mongoose 有一个更强大的替代方案,称为 populate(),它允许你引用其他集合中的文档。

填充是用其他集合中的文档自动替换文档中指定路径的过程。 我们可以填充单个文档、多个文档、一个普通对象、多个普通对象或从查询返回的所有对象。 让我们看一些例子。

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

保存参考

将引用保存到其他文档的工作方式与通常保存属性的方式相同,只需分配 _id 值:

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!

你可以在 ObjectIdNumberStringBuffer 路径上设置 ref 选项。 populate() 适用于 ObjectId、数字、字符串和缓冲区。 但是,我们建议使用 ObjectId 作为 _id 属性(因此使用 ObjectId 作为 ref 属性),除非你有充分的理由不这样做。 这是因为,如果你创建一个没有 _id 属性的新文档,MongoDB 会将 _id 设置为 ObjectId,因此,如果你将 _id 属性设置为数字,则需要格外小心,不要插入没有数字 _id 的文档。

人口

到目前为止,我们还没有做任何不同的事情。 我们只是创建了一个 Person 和一个 Story。 现在让我们看一下使用查询构建器填充故事的 author

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 文档。

引用数组的工作方式相同。 只需在查询中调用 populate 方法,就会返回一个文档数组来代替原来的 _id

设置填充字段

你可以通过将属性设置到文档来手动填充该属性。 该文档必须是你的 ref 属性引用的模型的实例。

const story = await Story.findOne({ title: 'Casino Royale' });
story.author = author;
console.log(story.author.name); // prints "Ian Fleming"

检查字段是否已填充

你可以调用 populated() 函数来检查字段是否已填充。 如果 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

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

如果没有国外文档怎么办?

Mongoose populate 的行为与传统的 SQL 连接 不同。 当没有文档时,story.author 将是 null。 这类似于 SQL 中的 左连接

await Person.deleteMany({ name: 'Ian Fleming' });

const story = await Story.findOne({ title: 'Casino Royale' }).populate('author');
story.author; // `null`

如果 storySchema 中有一个 authors 数组,populate() 将为你提供一个空数组。

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; // `[]`

字段选择

如果我们只想为填充的文档返回一些特定字段怎么办? 这可以通过将通常的 字段名称语法 作为第二个参数传递给 populate 方法来完成:

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

填充多个路径

如果我们想同时填充多个路径怎么办?

await Story.
  find({ /* ... */ }).
  populate('fans').
  populate('author').
  exec();

如果同一路径多次调用 populate(),则只有最后一次生效。

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

查询条件及其他选项

如果我们想根据粉丝的年龄填充粉丝数组并只选择他们的名字怎么办?

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 文档。

例如,假设你 populate() 了一个故事的 author,而 author 不满足 match。 那么故事的 author 就会是 null

const story = await Story.
  findOne({ title: 'Casino Royale' }).
  populate({ path: 'author', name: { $ne: 'Ian Fleming' } }).
  exec();
story.author; // `null`

一般来说,没有办法让 populate() 根据故事 author 的属性来过滤故事。 例如,以下查询不会返回任何结果,即使填充了 author

const story = await Story.
  findOne({ 'author.name': 'Ian Fleming' }).
  populate('author').
  exec();
story; // null

如果你想按作者名称过滤故事,则应使用 denormalization

limitperDocumentLimit

Populate 确实支持 limit 选项,但是,目前它对每个文档进行 not 限制以实现向后兼容性。 例如,假设你有 2 个故事:

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 个粉丝:

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() 变慢。

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

指向子级

然而,我们可能会发现,如果我们使用 author 对象,我们无法获取故事列表。 这是因为没有任何 story 对象曾经 'pushed' 到 author.stories 上。

这里有两种观点。 首先,你可能希望 author 知道哪些故事是他们的。 通常,你的结构应通过在 'many' 端具有父指针来解析一对多关系。 但是,如果你有充分的理由想要一个子指针数组,你可以将 push() 文档放入该数组中,如下所示。

await story1.save();

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

这使我们能够执行 findpopulate 组合:

const person = await Person.
  findOne({ name: 'Ian Fleming' }).
  populate('stories').
  exec(); // only works if we pushed refs to children
console.log(person);

我们是否真的需要两组指针是有争议的,因为它们可能会不同步。 相反,我们可以跳过填充并直接 find() 我们感兴趣的故事。

const stories = await Story.
  find({ author: author._id }).
  exec();
console.log('The stories are an array: ', stories);

除非指定了 lean 选项,否则从 查询人口 返回的文档将成为功能齐全、removeable、saveable 的文档。 不要将它们与 子文档 混淆。 调用其删除方法时要小心,因为你将从数据库中删除它,而不仅仅是从数组中删除。

填充现有文档

如果你有一个现有的 mongoose 文档并想要填充它的一些路径,你可以使用 Document#populate() 方法。

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() 或使用路径数组来填充多个路径

await person.populate(['stories', 'fans']);
person.populated('fans'); // Array of ObjectIds

填充多个现有文档

如果我们有一个或多个 Mongoose 文档,甚至是普通对象(如 mapReduce 输出),我们可以使用 Model.populate() 方法填充它们。 这是 Document#populate()Query#populate() 用于填充文档的内容。

跨多个级别填充

假设你有一个用户结构来跟踪用户的朋友。

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

填充可以让你获取用户朋友的列表,但是如果你还想要用户朋友的朋友怎么办? 指定 populate 选项告诉 mongoose 填充所有用户好友的 friends 数组:

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

跨数据库填充

假设你有一个表示事件的结构和一个表示对话的结构。 每个事件都有一个相应的对话线程。

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 上。

// Works
const events = await Event.
  find().
  populate('conversation');

这被称为 "跨数据库填充,",因为它使你能够跨 MongoDB 数据库甚至跨 MongoDB 实例进行填充。

如果你在定义 eventSchema 时无权访问模型实例,也可以传递 模型实例作为 populate() 的选项

const events = await Event.
  find().
  // The `model` option specifies the model to use for populating.
  populate({ path: 'conversation', model: Conversation });

通过 refPath 动态引用

Mongoose 还可以根据文档中属性的值从多个集合中填充。 假设你正在构建一个用于存储评论的结构。 用户可以对博客文章或产品发表评论。

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 对每个文档使用的模型。

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 上定义单独的 blogPostproduct 属性,然后在这两个属性上定义 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"

定义单独的 blogPostproduct 属性适用于这个简单的示例。 但是,如果你决定允许用户对文章或其他评论发表评论,则需要向结构添加更多属性。 你还需要为每个属性进行额外的 populate() 调用,除非你使用 mongoose-autopopulate。 使用 refPath 意味着你只需要 2 个结构路径和一个 populate() 调用,无论你的 commentSchema 可以指向多少个模型。

填充虚拟

到目前为止,你仅根据 _id 字段进行了填充。 然而,有时这并不是正确的选择。 例如,假设你有 2 个模型: AuthorBlogPost

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

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(),如下所示。

// 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,如下所示。

const author = await Author.findOne().populate('posts');

author.posts[0].title; // Title of the first blog post

请记住,默认情况下,虚拟值不包含在 toJSON()toObject() 输出中。 如果你希望在使用 Express 的 res.json() 功能console.log() 等函数时显示填充虚拟值,请在结构的 toJSONtoObject() 选项上设置 virtuals: true 选项。

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 包含在投影中。

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

填充虚拟:计数选项

填充虚拟还支持计算具有匹配 foreignField 的文档数量,而不是文档本身。 在虚拟机上设置 count 选项:

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

填充虚拟:匹配选项

填充虚拟的另一个选项是 match。 此选项向 Mongoose 使用 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 的博客文章。

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 选项,如下所示。

// Overwrite the `match` option specified in `AuthorSchema.virtual()` for this
// single `populate()` call.
await Author.findOne().populate({ path: posts, match: {} });

你还可以将 match 选项设置为 populate() 调用中的函数。 如果你想合并 populate() 匹配选项而不是覆盖,请使用以下命令。

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

填充映射

映射 是表示具有任意字符串键的对象的类型。 例如,在下面的结构中,members 是从字符串到 ObjectId 的映射。

const BandSchema = new Schema({
  name: String,
  members: {
    type: Map,
    of: {
      type: 'ObjectId',
      ref: 'Person'
    }
  }
});
const Band = mongoose.model('Band', bandSchema);

该映射有 ref,这意味着你可以使用 populate() 来填充映射中的所有 ObjectId。 假设你有以下 band 文档:

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 查看映射中的每个键。

const band = await Band.findOne({ name: 'Motley Crue' }).populate('members.$*');

band.members.get('singer'); // { _id: ..., name: 'Vince Neil' }

你还可以使用 $* 填充子文档映射中的路径。 例如,假设你有以下 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.$*.authorpopulate() 每本书的作者:

const libraries = await Library.find().populate('books.$*.author');

填充中间件

你可以在 hooks 之前或之后填充。 如果你想始终填充某个字段,请查看 Mongoose 自动填充插件

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

在中间件中填充多个路径

当你总是想要填充某些字段时,在中间件中填充多个路径会很有帮助。 但是,实现比你想象的要复杂一点。 你可能期望它的工作方式如下:

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 字段,请求将被挂在无限循环中。

为了避免这种情况,我们必须添加 _recursed 选项,这样我们的中间件就可以避免递归填充。 下面的示例将使其按预期工作。

userSchema.pre('find', function(next) {
  if (this.options._recursed) {
    return next();
  }
  this.populate({ path: 'followers following', options: { _recursed: true } });
  next();
});

或者,你可以查看 Mongoose 自动填充插件

转换填充的文档

你可以使用 transform 选项操作填充的文档。 如果你指定 transform 函数,Mongoose 将使用两个参数对结果中的每个填充文档调用此函数: 填充的文档,以及用于填充文档的原始 ID。 这使你可以更好地控制 populate() 执行的结果。 当你填充多个文档时,它特别有用。

transform 选项的 原始动机 允许在未找到文档时保留未填充的 _id,而不是将值设置为 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" 填充的文档,如下所示。

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。 例如,假设你想要在文档上设置语言代码以实现国际化,如下所示。

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

你可以为所有填充的练习设置语言代码,如下所示:

// 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'