Skip to content

Commit

Permalink
feat(populate): add transform option that Mongoose will call on eve…
Browse files Browse the repository at this point in the history
…ry populated doc

Fix #3775
  • Loading branch information
vkarpov15 committed Feb 21, 2021
1 parent 83fd8aa commit 78efd9f
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 11 deletions.
32 changes: 21 additions & 11 deletions lib/helpers/populate/assignVals.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const utils = require('../../utils');

module.exports = function assignVals(o) {
// Options that aren't explicitly listed in `populateOptions`
const userOptions = get(o, 'allOptions.options.options');
const userOptions = Object.assign({}, get(o, 'allOptions.options.options'), get(o, 'allOptions.options'));
// `o.options` contains options explicitly listed in `populateOptions`, like
// `match` and `limit`.
const populateOptions = Object.assign({}, o.options, userOptions, {
Expand All @@ -34,6 +34,8 @@ module.exports = function assignVals(o) {
const options = o.options;
const count = o.count && o.isVirtual;

let i;

function setValue(val) {
if (count) {
return val;
Expand Down Expand Up @@ -61,14 +63,14 @@ module.exports = function assignVals(o) {
val[i] = ret[i];
}

return valueFilter(val[0], options, populateOptions, populatedModel);
return valueFilter(val[0], options, populateOptions, o.allIds[i]);
} else if (o.justOne === false && !Array.isArray(val)) {
return valueFilter([val], options, populateOptions, populatedModel);
return valueFilter([val], options, populateOptions, o.allIds[i]);
}
return valueFilter(val, options, populateOptions, populatedModel);
return valueFilter(val, options, populateOptions, o.allIds[i]);
}

for (let i = 0; i < docs.length; ++i) {
for (i = 0; i < docs.length; ++i) {
const existingVal = mpath.get(o.path, docs[i], lookupLocalFields);
if (existingVal == null && !getVirtual(o.originalModel.schema, o.path)) {
continue;
Expand Down Expand Up @@ -195,15 +197,19 @@ function numDocs(v) {
* that population mapping can occur.
*/

function valueFilter(val, assignmentOpts, populateOptions) {
function valueFilter(val, assignmentOpts, populateOptions, allIds) {
const userSpecifiedTransform = typeof populateOptions.transform === 'function';
const transform = userSpecifiedTransform ? populateOptions.transform : noop;
if (Array.isArray(val)) {
// find logic
const ret = [];
const numValues = val.length;
for (let i = 0; i < numValues; ++i) {
const subdoc = val[i];
if (!isPopulatedObject(subdoc) && (!populateOptions.retainNullValues || subdoc != null)) {
let subdoc = val[i];
if (!isPopulatedObject(subdoc) && (!populateOptions.retainNullValues || subdoc != null) && !userSpecifiedTransform) {
continue;
} else if (userSpecifiedTransform) {
subdoc = transform(isPopulatedObject(subdoc) ? subdoc : null, allIds[i]);
}
maybeRemoveId(subdoc, assignmentOpts);
ret.push(subdoc);
Expand All @@ -227,20 +233,20 @@ function valueFilter(val, assignmentOpts, populateOptions) {
// findOne
if (isPopulatedObject(val)) {
maybeRemoveId(val, assignmentOpts);
return val;
return transform(val, allIds);
}

if (val instanceof Map) {
return val;
}

if (populateOptions.justOne === true) {
return (val == null ? val : null);
return val == null ? transform(val, allIds) : transform(null, allIds);
}
if (populateOptions.justOne === false) {
return [];
}
return val == null ? val : null;
return val == null ? transform(val, allIds) : transform(null, allIds);
}

/*!
Expand Down Expand Up @@ -271,4 +277,8 @@ function isPopulatedObject(obj) {
obj.$isMongooseMap ||
obj.$__ != null ||
leanPopulateMap.has(obj);
}

function noop(v) {
return v;
}
1 change: 1 addition & 0 deletions lib/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -4268,6 +4268,7 @@ Model.geoSearch = function(conditions, options, callback) {
* @param {Boolean} [options.skipInvalidIds=false] By default, Mongoose throws a cast error if `localField` and `foreignField` schemas don't line up. If you enable this option, Mongoose will instead filter out any `localField` properties that cannot be casted to `foreignField`'s schema type.
* @param {Number} [options.perDocumentLimit=null] For legacy reasons, `limit` with `populate()` may give incorrect results because it only executes a single query for every document being populated. If you set `perDocumentLimit`, Mongoose will ensure correct `limit` per document by executing a separate query for each document to `populate()`. For example, `.find().populate({ path: 'test', perDocumentLimit: 2 })` will execute 2 additional queries if `.find()` returns 2 documents.
* @param {Object} [options.options=null] Additional options like `limit` and `lean`.
* @param {Function} [options.transform=null] Function that Mongoose will call on every populated document that allows you to transform the populated document.
* @param {Function} [callback(err,doc)] Optional callback, executed upon completion. Receives `err` and the `doc(s)`.
* @return {Promise}
* @api public
Expand Down
1 change: 1 addition & 0 deletions lib/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -4623,6 +4623,7 @@ function castDoc(query, overwrite) {
* @param {boolean} [options.getters=false] if true, Mongoose will call any getters defined on the `localField`. By default, Mongoose gets the raw value of `localField`. For example, you would need to set this option to `true` if you wanted to [add a `lowercase` getter to your `localField`](/docs/schematypes.html#schematype-options).
* @param {boolean} [options.clone=false] When you do `BlogPost.find().populate('author')`, blog posts with the same author will share 1 copy of an `author` doc. Enable this option to make Mongoose clone populated docs before assigning them.
* @param {Object|Function} [options.match=null] Add an additional filter to the populate query. Can be a filter object containing [MongoDB query syntax](https://docs.mongodb.com/manual/tutorial/query-documents/), or a function that returns a filter object.
* @param {Function} [options.transform=null] Function that Mongoose will call on every populated document that allows you to transform the populated document.
* @param {Object} [options.options=null] Additional options like `limit` and `lean`.
* @see population ./populate.html
* @see Query#select #query_Query-select
Expand Down
97 changes: 97 additions & 0 deletions test/model.populate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9981,4 +9981,101 @@ describe('model: populate:', function() {
assert.deepEqual(posts[1].toObject().commentsIds.map(c => c.content), ['Im used in two posts', 'Nice second post']);
});
});

it('supports `transform` option (gh-3375)', function() {
const parentSchema = new Schema({
name: String,
children: [{ type: 'ObjectId', ref: 'Child' }],
child: { type: 'ObjectId', ref: 'Child' }
});
const Parent = db.model('Parent', parentSchema);

const Child = db.model('Child', Schema({ name: String }));

return co(function*() {
const children = yield Child.create([{ name: 'Luke' }, { name: 'Leia' }]);
let p = yield Parent.create({
name: 'Anakin',
children: children,
child: children[0]._id
});

let called = [];

p = yield Parent.findById(p).populate({
path: 'children',
transform: function(doc, id) {
called.push({
doc: doc,
id: id
});

return id;
}
});

assert.equal(called.length, 2);
assert.equal(called[0].doc.name, 'Luke');
assert.equal(called[0].id.toHexString(), children[0]._id.toHexString());

assert.equal(called[1].doc.name, 'Leia');
assert.equal(called[1].id.toHexString(), children[1]._id.toHexString());

called = [];
p = yield Parent.findById(p).populate({
path: 'child',
transform: function(doc, id) {
called.push({
doc: doc,
id: id
});

return id;
}
});

assert.equal(called.length, 1);
assert.equal(called[0].doc.name, 'Luke');
assert.equal(called[0].id.toHexString(), children[0]._id.toHexString());

const newId = new mongoose.Types.ObjectId();
yield Parent.updateOne({ _id: p._id }, { $push: { children: newId } });

called = [];
p = yield Parent.findById(p).populate({
path: 'children',
transform: function(doc, id) {
called.push({
doc: doc,
id: id
});

return id;
}
});
assert.equal(called.length, 3);
assert.strictEqual(called[2].doc, null);
assert.equal(called[2].id.toHexString(), newId.toHexString());

assert.equal(p.children[2].toHexString(), newId.toHexString());

yield Parent.updateOne({ _id: p._id }, { $set: { child: newId } });
called = [];
p = yield Parent.findById(p).populate({
path: 'child',
transform: function(doc, id) {
called.push({
doc: doc,
id: id
});

return id;
}
});

assert.equal(called.length, 1);
assert.strictEqual(called[0].doc, null);
assert.equal(called[0].id.toHexString(), newId.toHexString());
});
});
});

0 comments on commit 78efd9f

Please sign in to comment.