填充 (Populate)

在版本 >= 3.2 中,MongoDB 有類似 join 的 $lookup 聚合運算符。Mongoose 有一個更強大的替代方案,稱為 populate(),它允許您參考其他集合中的文件。

填充 (Population) 是將文件中指定的路徑自動替換為來自其他集合的文件之過程。我們可以填充單個文件、多個文件、普通物件、多個普通物件,或從查詢返回的所有物件。讓我們看一些範例。

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() 可與 ObjectIds、數字、字串和緩衝區搭配使用。但是,除非您有充分的理由不這樣做,否則我們建議使用 ObjectIds 作為 _id 屬性 (因此 ObjectIds 用於 ref 屬性)。這是因為如果您建立沒有 _id 屬性的新文件,MongoDB 會將 _id 設定為 ObjectId,因此如果您將 _id 屬性設定為數字,則需要格外小心,避免插入沒有數字 _id 的文件。

填充 (Population)

到目前為止,我們沒有做太多不同的事情。我們僅僅建立了 PersonStory。現在,讓我們看看如何使用查詢建構器填充我們故事的 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"

您也可以將文件或 POJO 推送到已填充的陣列上,如果它們的 ref 符合,Mongoose 會新增這些文件。

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 將會取消填充整個陣列。

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

檢查欄位是否已填充

您可以呼叫 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 填充的行為不像傳統的 SQL join。當沒有文件時,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', match: { 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

如果您想依其作者的名稱來篩選故事,則應使用反正規化

limitperDocumentLimit

Populate 確實支援 limit 選項,但是,為了向後相容性,目前不會在每個文件的基礎上限制。例如,假設您有 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 物件被「推送」到 author.stories

這裡有兩種觀點。首先,您可能希望 author 知道哪些故事是他們的。通常,您的結構描述應透過在「多」方中具有父指標來解析一對多關係。但是,如果您有充分的理由需要子指標陣列,您可以將文件 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 選項,否則從 查詢填充 傳回的文件將成為功能齊全、可 remove、可 save 的文件。請勿將它們與 子文件 混淆。呼叫其 remove 方法時要小心,因為您將從資料庫中移除它,而不僅僅是從陣列中移除它。

填充現有文件

如果您有現有的 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 允許您取得使用者朋友的清單,但是如果您也想要使用者朋友的朋友,該怎麼辦?指定 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 也可以根據文件中屬性的值,從多個集合中進行填充 (populate)。假設您正在建立一個用於儲存評論的 schema。使用者可能會評論一篇部落格文章或一個產品。

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 屬性適用於這個簡單的範例。但是,如果您決定允許使用者也評論文章或其他評論,您將需要在 schema 中新增更多屬性。您還需要為每個屬性額外呼叫 populate(),除非您使用 mongoose-autopopulate。使用 refPath 意味著您只需要 2 個 schema 路徑和一個 populate() 呼叫,無論您的 commentSchema 可以指向多少個模型。

您也可以將函式指派給 refPath,這表示 Mongoose 會根據被填充的文件上的值來選擇 refPath。

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 的動態參考

就像 refPath 一樣,也可以將函式指派給 ref

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

填充虛擬屬性 (Virtuals)

到目前為止,您只根據 _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');

以上是一個糟糕的 schema 設計範例。為什麼呢?假設您有一位多產的作者,撰寫了超過 1 萬篇部落格文章。該 author 文件將會非常巨大,超過 12kb,而大型文件會導致伺服器和用戶端的效能問題。最小基數原則指出,一對多關係(如作者對部落格文章)應儲存在「多」的一方。換句話說,部落格文章應該儲存它們的 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
  }]
});

不幸的是,這兩個 schema 按照目前的方式撰寫,不支援填充作者的部落格文章列表。這就是虛擬填充的用武之地。虛擬填充是指在具有 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');

然後,您可以如下所示填充作者的 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() 等函式時顯示填充的虛擬屬性,請在 schema 的 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
});

如果您正在使用填充的投影 (projection),請確保投影中包含 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: {} });

您也可以在 populate() 呼叫中將函式設定為 match 選項。如果您想要合併 populate() 的 match 選項,而不是覆寫它,請使用以下方法。

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

填充映射 (Maps)

Maps 是一種代表具有任意字串鍵的物件的類型。例如,在下面的 schema 中,members 是一個從字串到 ObjectIds 的映射。

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

此映射具有 ref,這表示您可以使用 populate() 來填充映射中的所有 ObjectIds。假設您有以下的 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');

在中介層中填充

您可以在 pre 或 post hook 中進行填充。如果您想要始終填充某個欄位,請查看 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 (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') 將會為 followersfollowing 欄位觸發相同的中間件,而且請求只會無限迴圈地掛起。

為了避免這種情況,我們必須新增 _recursed 選項,以便我們的中間件避免遞迴填充。下面的範例將使其如預期般運作。

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

或者,您可以查看 mongoose-autopopulate 外掛程式

轉換已填充的文件

您可以使用 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() 來「扁平化」填充的文件,如下所示。

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 和虛擬屬性。例如,假設您想要在您的文件中設定一個用於國際化的語言代碼,如下所示。

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'