UNPKG

mongoose-deep-populate

Version:

Mongoose plugin to enable deep population of nested models

338 lines (281 loc) 9.51 kB
module.exports = function (mongoose) { if (mongoose == null) { throw new Error('An instance of mongoose needs passing in') } patchQueryPrototype() return deepPopulatePlugin /** * Patches Query prototype to add `deepPopulate()` method. */ function patchQueryPrototype() { var Query = mongoose.Query , _exec = Query.prototype.exec if (Query.prototype.deepPopulate != null) { return } /** * Registers deep population on this query. * @param paths the paths to be populated. * @param options (optional) the population options. * @remark this method can only be invoked once per query. * @return {Query} */ Query.prototype.deepPopulate = function (paths, options) { if (this.model.schema.methods.deepPopulate == null) { throw new Error('Plugin was not installed') } if (this._deepPopulates) { throw new Error('deepPopulate was already invoked') } this._deepPopulates = {paths: paths, options: options} return this } /** * Monkey-patches `exec` to add deep population hook. * @param op the operation to be executed. * @param cb the callback. * @return {MongoosePromise} */ Query.prototype.exec = function (op, cb) { var deepPopulate = this._deepPopulates if (!deepPopulate) { return _exec.call(this, op, cb) } var model = this.model , paths = deepPopulate.paths , options = deepPopulate.options , defaultOptions = model.schema._defaultDeepPopulateOptions , lean = this._mongooseOptions.lean if (isFunction(op)) { cb = op op = null } else { cb = cb || noop } return createMongoosePromise(function (resolve, reject) { _exec.call(this, op, function (err, docs) { if (err) { return reject(err), cb(err) } if (!docs) { return resolve(docs), cb(null, docs) } execute(model, docs, paths, options, defaultOptions, lean, function (err, docs) { if (err) reject(err), cb(err) else resolve(docs), cb(null, docs) }) }) }.bind(this)) } } /** * Creates a Mongoose promise. */ function createMongoosePromise(resolver) { var promise // mongoose 5 and up if (parseInt(mongoose.version) >= 5) { promise = new mongoose.Promise(resolver) } // mongoose 4.1 and up else if (mongoose.Promise.ES6) { promise = new mongoose.Promise.ES6(resolver) } // backward compatibility else { promise = new mongoose.Promise resolver(promise.resolve.bind(promise, null), promise.reject.bind(promise)) } return promise } /** * Invoked by Mongoose to executes the plugin on the specified schema. */ function deepPopulatePlugin(schema, defaultOptions) { schema._defaultDeepPopulateOptions = defaultOptions = defaultOptions || {} /** * Populates this document with the specified paths. * @param paths the paths to be populated. * @param options (optional) the population options. * @param cb (optional) the callback. * @return {MongoosePromise} */ schema.methods.deepPopulate = function (paths, options, cb) { return deepPopulate(this.constructor, this, paths, options, cb) } /** * Populates provided documents with the specified paths. * @param docs the documents to be populated. * @param paths the paths to be populated. * @param options (optional) the population options. * @param cb (optional) the callback. * @return {MongoosePromise} */ schema.statics.deepPopulate = function (docs, paths, options, cb) { return deepPopulate(this, docs, paths, options, cb) } function deepPopulate(model, docs, paths, options, cb) { if (isFunction(options)) { cb = options options = null } else { cb = cb || noop } return createMongoosePromise(function (resolve, reject) { if (docs == null || docs.length === 0) { return resolve(docs), cb(null, docs) } execute(model, docs, paths, options, defaultOptions, false, function (err, docs) { if (err) reject(err), cb(err) else resolve(docs), cb(null, docs) }) }) } } function execute(model, docs, paths, options, defaultOptions, lean, cb) { var params = createParams(model, docs, paths, extend({}, defaultOptions, options), lean) if (params.paths.length === 0) cb(null, docs) else populate(params, 0, cb) } function createParams(model, docs, paths, options, lean) { var rewrite = options.rewrite , whitelist = options.whitelist , populate = options.populate paths = isArray(paths) ? paths : (paths || '').split(/[\s,]+/) // space or comma delimited paths = normalize(paths) // rewrites paths, whitelist and populate if (rewrite) { paths = paths.map(function (path) { return rewrite[path] || path }) if (whitelist) { whitelist = whitelist.map(function (path) { return rewrite[path] || path }) } if (populate) { Object.keys(populate).forEach(function (path) { var rewrittenPath = rewrite[path] || path if (rewrittenPath) populate[rewrittenPath] = populate[path] }) } } // deconstructs then filters paths using whitelist paths = normalize(deconstruct(paths)) if (whitelist) { whitelist = normalize(deconstruct(whitelist)) paths = paths.filter(function (path) { return ~whitelist.indexOf(path) }) } // computes max subpaths, i.e. levels var maxNoOfSubpaths = -1 paths.forEach(function (path) { var noOfSubpaths = path.split('.').length if (noOfSubpaths > maxNoOfSubpaths) maxNoOfSubpaths = noOfSubpaths }) var db = model.db if (!db) { var doc = isArray(docs) ? docs[0] : docs if (doc && doc.ownerDocument) db = doc.ownerDocument().constructor.db else throw new Error('Cannot retrieve database instance') } return { db: db, model: model, docs: docs, paths: paths, lean: lean, max: maxNoOfSubpaths - 1, options: populate || {} } } function populate(opts, level, cb) { var docs = opts.docs , paths = opts.paths , lvlPaths = paths.filter(function (path) { return (path.split('.').length - 1) === level }) , remained = lvlPaths.length , model, path, populateOpts while (path = lvlPaths.shift()) { model = getModelFromPath(opts.db, opts.model, path) if (model != null) { populateOpts = extend({}, opts.options[path], {path: path, model: model.modelName}) if (opts.lean) { populateOpts.options = extend({lean: true}, populateOpts.options) } model.populate(docs, populateOpts, one) } else one() } function one(err) { if (err) return nextLevel(err) if (--remained === 0) nextLevel() } function nextLevel(err) { if (err) return cb(err) if (level === opts.max) return cb(null, docs) populate(opts, ++level, cb) } } function getModelFromPath(db, model, path) { var schema = model.schema , currentPath = null , candidateModel = null path.split('.').forEach(function (subpath) { currentPath = (currentPath ? (currentPath + '.') : '') + subpath var schemaPath = schema.paths[currentPath] // no schema, possibly a subdocument, continues to find out if (!schemaPath) { candidateModel = null return } // found a schema, resets current path currentPath = null // dereferences if linked documents if (schemaPath.caster && schemaPath.caster.options) { schemaPath = schemaPath.caster } // if linked document(s), extract model and schema if (schemaPath.options && schemaPath.options.ref) { candidateModel = db.model(schemaPath.options.ref) schema = candidateModel.schema } // else, subdocuments => no model but has schema else { candidateModel = null schema = schemaPath.schema } }) return candidateModel } // Breaks ['a.b.c', ...] to ['a', 'a.b', 'a.b.c', ...] function deconstruct(paths) { var _paths = [] paths.forEach(function (path) { var currentPath = null path.split('.').forEach(function (subpath) { currentPath = (currentPath ? (currentPath + '.') : '') + subpath.trim() _paths.push(currentPath) }) }) return _paths } function normalize(paths) { return paths .map(function (path) { return path.trim() }) .filter(function (path) { return path !== '' }) .filter(function (path, index, self) { return self.indexOf(path) === index }) // removes duplicates } function extend(target) { var src for (var i = 1, l = arguments.length; i < l; i++) { src = arguments[i] for (var k in src) target[k] = src[k] } return target } function isArray(obj) { return Object.prototype.toString.call(obj) === '[object Array]' } function isFunction(obj) { return Object.prototype.toString.call(obj) === '[object Function]' || Object.prototype.toString.call(obj) === '[object AsyncFunction]' } function noop() {} }