mongoose
Version:
Mongoose MongoDB ORM
861 lines (724 loc) • 20 kB
JavaScript
/**
* Module dependencies.
*/
var Document = require('./document')
, MongooseArray = require('./types/array')
, DocumentArray = require('./types/documentarray')
, MongooseBuffer = require('./types/buffer')
, MongooseError = require('./error')
, Query = require('./query')
, utils = require('./utils')
, EventEmitter = utils.EventEmitter
, merge = utils.merge
, Promise = require('./promise');
/**
* Model constructor
*
* @param {Object} values to set
* @api public
*/
function Model (doc) {
Document.call(this, doc);
};
/**
* Inherits from Document.
*/
Model.prototype.__proto__ = Document.prototype;
/**
* Connection the model uses. Set by the Connection or if absent set to the
* default mongoose connection;
*
* @api public
*/
Model.prototype.db;
/**
* Collection the model uses. Set by Mongoose instance
*
* @api public
*/
Model.prototype.collection;
/**
* Model name.
*
* @api public
*/
Model.prototype.modelName;
/**
* Returns what paths can be populated
*
* @param {query} query object
* @return {Object] population paths
* @api private
*/
Model.prototype.getPopulationKeys = function (query) {
var paths = {};
if (query && query.options.populate) {
for (var name in query.options.populate) {
var schema = this.schema.path(name);
if (!schema) {
// if the path is not recognized, it's potentially embedded docs
// walk path atoms from right to left to find a matching path
var pieces = name.split('.')
, len = pieces.length;
for (var i = pieces.length; i > 0; i--) {
var path = pieces.slice(0, i).join('.')
, pathSchema = this.schema.path(path);
// loop until we find an array schema
if (pathSchema && pathSchema.caster) {
if (!paths[path]) {
paths[path] = query.options.populate[name];
paths[path].sub = {};
}
paths[path].sub[pieces.slice(i).join('.')] = 1;
break;
}
}
} else {
paths[name] = query.options.populate[name];
}
}
}
return paths;
};
/**
* Populates an object
*
* @param {SchemaType} schema type for the oid
* @param {Object} object id or array of object ids
* @param {Array} fields to select
* @param {Function} callback
* @api private
*/
Model.prototype.populate = function (schema, oid, fields, fn) {
if (Array.isArray(oid)) {
if (oid.length) {
var total = oid.length
, model = this.model(schema.caster.options.ref)
, arr = []
oid.forEach(function (id, i) {
// TODO: optimize with $in and an array ObjectId
model.findById(id, fields, function (err, obj) {
if (err) return fn(err);
arr[i] = obj;
--total || fn(null, arr);
});
});
} else {
fn(null, oid);
}
} else {
this.model(schema.options.ref).findById(oid, fields, fn)
}
};
/**
* Performs auto-population of relations.
*
* @param {Object} document returned by mongo
* @param {Query} query that originated the initialization
* @param {Function} callback
* @api private
*/
Model.prototype.init = function (doc, query, fn) {
if ('function' == typeof query) {
fn = query;
query = null;
}
var populate = this.getPopulationKeys(query)
, self = this;
if (!Object.keys(populate).length) {
// if no population from other models is necessary
return Document.prototype.init.call(this, doc, fn);
}
function error (err) {
if (fn.run) return;
fn.run = true;
fn(err);
}
function init (obj, prefix, fn) {
prefix = prefix || '';
var keys = Object.keys(obj)
, len = keys.length;
function next () {
if (--len < 0) return fn();
var i = keys[len]
, path = prefix + i
, schema = self.schema.path(path);
if (!schema && obj[i] && obj[i].constructor == Object) {
// assume nested object
init(obj[i], path + '.', next);
} else {
if (obj[i] && schema && populate[path]) {
if (populate[path].sub) {
var total = 0;
obj[i].forEach(function (subobj) {
for (var key in populate[path].sub) {
if (subobj[key]) {
(function (key) {
total++;
self.populate(
schema.schema.path(key)
, subobj[key]
, populate[path]
, function (err, doc) {
if (err) return error(err);
subobj[key] = doc;
--total || next();
}
);
})(key);
}
}
});
} else {
self.populate(schema, obj[i], populate[path], function (err, doc) {
if (err) return error(err);
obj[i] = doc;
next();
});
}
} else {
next();
}
}
};
next();
};
init(doc, '', function (err) {
if (err) return fn(err);
Document.prototype.init.call(self, doc, fn);
});
return this;
};
function makeSaveHandler(promise, self) {
return function (err) {
if (err) return promise.error(err);
promise.complete(self);
self.emit('save', self);
promise = null;
self = null;
};
};
/**
* Saves this document.
*
* @see Model#registerHooks
* @param {Function} fn
* @api public
*/
Model.prototype.save = function (fn) {
var promise = new Promise(fn)
, options = {}
, self = this
, complete = makeSaveHandler(promise, self);
if (this.options.safe) {
options.safe = true;
}
if (this.isNew) {
// send entire doc
this.collection.insert(this.toObject(), options, complete);
this.isNew = false;
// clear atomics
this._dirty().forEach(function (dirt) {
var type = dirt.value;
if (type && type._path && type.doAtomics) {
type._atomics = {};
}
});
} else {
// optimization, _delta() clears atomics too
var delta = this._delta();
if (Object.keys(delta).length) {
this.collection.update({ _id: this._doc._id }, delta, options, complete);
} else {
complete(null);
}
}
// Clear 'modify'('dirty') cache
this._activePaths.clear('modify');
this.schema.requiredPaths.forEach(function (path) {
self._activePaths.require(path);
});
};
/**
* Returns the dirty paths / vals
*
* @api private
*/
Model.prototype._dirty = function () {
var self = this;
return this._activePaths.map('modify', function (path) {
return { path: path
, value: self.getValue(path)
, schema: self._path(path) };
});
}
/**
* Produces a special query document of the modified properties.
* @api private
*/
Model.prototype._delta = function () {
// send delta
var self = this
, delta
, useSet = this.options['use$SetOnSave'];
return this._dirty().reduce(function (delta, data) {
var type = data.value
, schema = data.schema
, atomics, val, obj;
if (type === null || type === undefined) {
if (!('$set' in delta))
delta['$set'] = {};
delta['$set'][data.path] = type;
} else if (type._path && type.doAtomics) {
// a MongooseArray or MongooseNumber
atomics = type._atomics;
var ops = Object.keys(atomics)
, i = ops.length
, op;
while (i--) {
op = ops[i]
if (op === '$pushAll' || op === '$pullAll') {
if (atomics[op].length === 1) {
val = atomics[op][0];
delete atomics[op];
op = op.replace('All', '');
atomics[op] = val;
}
}
val = atomics[op];
obj = delta[op] = delta[op] || {};
if (op === '$pull' || op === '$push') {
if (val.constructor !== Object) {
if (Array.isArray(val)) val = [val];
// TODO Should we place pull and push casting into the pull and push methods?
val = schema.cast(val)[0];
}
}
obj[data.path] = val.toObject
? val.toObject() // If the value is an array
: Array.isArray(val)
? val.map(function (mem) {
return mem.toObject
? mem.toObject()
: mem.valueOf
? mem.valueOf()
: mem;
})
: val.valueOf
? val.valueOf() // Numbers
: val;
}
type._atomics = {};
} else {
// normalize MongooseArray or MongooseNumber
if (type instanceof MongooseArray ||
type instanceof MongooseBuffer) {
type = type.toObject();
} else if (type._path)
type = type.valueOf();
if (useSet) {
if (!('$set' in delta))
delta['$set'] = {};
delta['$set'][data.path] = type;
} else
delta[data.path] = type;
}
return delta;
}, {});
}
/**
* Remove the document
*
* @param {Function} callback
* @api public
*/
Model.prototype.remove = function (fn) {
if (this.removing || this.removed)
return this;
if (!this.removing) {
var promise = this.removing = new Promise(fn)
, self = this;
this.collection.remove({ _id: this._doc._id }, function (err) {
if (err) return promise.error(err);
promise.complete();
self.emit('remove');
});
}
return this;
};
/**
* Register hooks override
*
* @api private
*/
Model.prototype.registerHooks = function () {
// make sure to pass along all the errors from subdocuments
this.pre('save', function (next) {
// we keep the error semaphore to make sure we don't
// call `save` unnecessarily (we only need 1 error)
var subdocs = 0
, error = false
, self = this;
var arrays = this._activePaths
.map('init', 'modify', function (i) {
return self.getValue(i);
})
.filter(function (val) {
return (val && val instanceof DocumentArray && val.length);
});
if (!arrays.length)
return next();
arrays.forEach(function (array) {
subdocs += array.length;
array.forEach(function (value) {
if (!error)
value.save(function (err) {
if (!error) {
if (err) {
error = true;
next(err);
} else
--subdocs || next();
}
});
});
});
}, function (err) {
this.db.emit('error', err);
});
Document.prototype.registerHooks.call(this);
};
/**
* Shortcut to access another model.
*
* @param {String} model name
*/
Model.prototype.model = function (name) {
return this.db.model(name);
};
/**
* Access the options defined in the schema
*
* @api private
*/
Model.prototype.__defineGetter__('options', function () {
return this.schema ? this.schema.options : {};
});
/**
* Give the constructor the ability to emit events.
*/
for (var i in EventEmitter.prototype)
Model[i] = EventEmitter.prototype[i];
/**
* Called when the model compiles
*
* @api private
*/
Model.init = function () {
// build indexes
var self = this
, indexes = this.schema.indexes
, count = indexes.length;
indexes.forEach(function (index) {
self.collection.ensureIndex(index[0], index[1], function (err) {
if (err) return self.db.emit('error', err);
--count || self.emit('index');
});
});
};
/**
* Document schema
*
* @api public
*/
Model.schema;
/**
* Database instance the model uses.
*
* @api public
*/
Model.db;
/**
* Collection the model uses.
*
* @api public
*/
Model.collection;
/**
* Define properties that access the prototype.
*/
['db', 'collection', 'schema', 'options'].forEach(function(prop){
Model.__defineGetter__(prop, function(){
return this.prototype[prop];
});
});
/**
* Module exports.
*/
module.exports = exports = Model;
Model.remove = function (conditions, callback) {
if ('function' === typeof conditions) {
callback = conditions;
conditions = {};
}
var query = new Query(conditions).bind(this, 'remove');
if ('undefined' === typeof callback)
return query;
this._applyNamedScope(query);
return query.remove(callback);
};
/**
* Finds documents
*
* Examples:
* // retrieve only certain keys
* MyModel.find({ name: /john/i }, ['name', 'friends'], function () { })
*
* // pass options
* MyModel.find({ name: /john/i }, [], { skip: 10 } )
*
* @param {Object} conditions
* @param {Object/Function} (optional) fields to hydrate or callback
* @param {Function} callback
* @api public
*/
Model.find = function (conditions, fields, options, callback) {
if ('function' == typeof conditions) {
callback = conditions;
conditions = {};
fields = null;
options = null;
} else if ('function' == typeof fields) {
callback = fields;
fields = null;
options = null;
} else if ('function' == typeof options) {
callback = options;
options = null;
}
var query = new Query(conditions, options).select(fields).bind(this, 'find');
if ('undefined' === typeof callback)
return query;
this._applyNamedScope(query);
return query.find(callback);
};
/**
* Merges the current named scope query into `query`.
*
* @param {Query} query
* @api private
*/
Model._applyNamedScope = function (query) {
var cQuery = this._cumulativeQuery;
if (cQuery) {
merge(query._conditions, cQuery._conditions);
if (query._fields && cQuery._fields)
merge(query._fields, cQuery._fields);
if (query.options && cQuery.options)
merge(query.options, cQuery.options);
delete this._cumulativeQuery;
}
return query;
}
/**
* Finds by id
*
* @param {ObjectId/Object} objectid, or a value that can be casted to it
* @api public
*/
Model.findById = function (id, fields, options, callback) {
return this.findOne({ _id: id }, fields, options, callback);
};
/**
* Finds one document
*
* @param {Object} conditions
* @param {Object/Function} (optional) fields to hydrate or callback
* @param {Function} callback
* @api public
*/
Model.findOne = function (conditions, fields, options, callback) {
if ('function' == typeof options) {
// TODO Handle all 3 of the following scenarios
// Hint: Only some of these scenarios are possible if cQuery is present
// Scenario: findOne(conditions, fields, callback);
// Scenario: findOne(fields, options, callback);
// Scenario: findOne(conditions, options, callback);
callback = options;
options = null;
} else if ('function' == typeof fields) {
// TODO Handle all 2 of the following scenarios
// Scenario: findOne(conditions, callback)
// Scenario: findOne(fields, callback)
// Scenario: findOne(options, callback);
callback = fields;
fields = null;
options = null;
} else if ('function' == typeof conditions) {
callback = conditions;
conditions = {};
fields = null;
options = null;
}
var query = new Query(conditions, options).select(fields).bind(this, 'findOne');
if ('undefined' == typeof callback)
return query;
this._applyNamedScope(query);
return query.findOne(callback);
};
/**
* Counts documents
*
* @param {Object} conditions
* @param {Function} optional callback
* @api public
*/
Model.count = function (conditions, callback) {
var query = new Query(conditions).bind(this, 'count');
if ('undefined' == typeof callback)
return query;
this._applyNamedScope(query);
return query.count(callback);
};
Model.distinct = function (field, conditions, callback) {
var query = new Query(conditions).bind(this, 'distinct');
if ('undefined' == typeof callback) {
query._distinctArg = field;
return query;
}
this._applyNamedScope(query);
return query.distinct(field, callback);
};
/**
* `where` enables a very nice sugary api for doing your queries.
* For example, instead of writing:
* User.find({age: {$gte: 21, $lte: 65}}, callback);
* we can instead write more readably:
* User.where('age').gte(21).lte(65);
* Moreover, you can also chain a bunch of these together like:
* User
* .where('age').gte(21).lte(65)
* .where('name', /^b/i) // All names that begin where b or B
* .where('friends').slice(10);
* @param {String} path
* @param {Object} val (optional)
* @return {Query}
* @api public
*/
Model.where = function (path, val) {
var q = new Query().bind(this, 'find');
return q.where.apply(q, arguments);
};
/**
* Sometimes you need to query for things in mongodb using a JavaScript
* expression. You can do so via find({$where: javascript}), or you can
* use the mongoose shortcut method $where via a Query chain or from
* your mongoose Model.
*
* @param {String|Function} js is a javascript string or anonymous function
* @return {Query}
* @api public
*/
Model.$where = function () {
var q = new Query().bind(this, 'find');
return q.$where.apply(q, arguments);
};
/**
* Shortcut for creating a new Document that is automatically saved
* to the db if valid.
*
* @param {Object} doc
* @param {Function} callback
* @api public
*/
Model.create = function (doc, fn) {
var args = utils.args(arguments)
, lastArg = args[args.length-1]
, docs = []
, count;
if (typeof lastArg === 'function') {
fn = args.pop();
}
count = args.length;
var self = this;
args.forEach(function (arg, i) {
var doc = new self(arg);
docs[i] = doc;
doc.save(function (err) {
if (err) return fn(err);
--count || fn.apply(null, [null].concat(docs));
});
});
};
/**
* Updates documents.
*
* Examples:
*
* MyModel.update({ age: { $gt: 18 } }, { oldEnough: true }, fn);
* MyModel.update({ name: 'Tobi' }, { ferret: true }, { multi: true }, fn);
*
* Valid options:
* - safe (boolean) safe mode (defaults to value set in schema (false))
* - upsert (boolean) whether to create the doc if it doesn't match (false)
* - multi (boolean) whether multiple documents should be update (false)
*
* @param {Object} conditions
* @param {Object] doc
* @param {Object} options
* @param {Function} callback
* @return {Query}
* @api public
*/
Model.update = function (conditions, doc, options, callback) {
if (arguments.length < 4) {
if ('function' === typeof options) {
// Scenario: update(conditions, doc, callback)
callback = options;
options = null;
} else if ('function' === typeof doc) {
// Scenario: update(doc, callback);
callback = doc;
doc = conditions;
conditions = {};
options = null;
}
}
var query = new Query(conditions, options).bind(this, 'update', doc);
if ('undefined' == typeof callback)
return query;
this._applyNamedScope(query);
return query.update(doc, callback);
};
/**
* Compiler utility.
*
* @param {String} model name
* @param {Schema} schema object
* @param {String} collection name
* @param {Connection} connection to use
* @param {Mongoose} mongoose instance
* @api private
*/
Model.compile = function (name, schema, collectionName, connection, base) {
// generate new class
function model () {
Model.apply(this, arguments);
};
model.modelName = name;
model.__proto__ = Model;
model.prototype.__proto__ = Model.prototype;
model.prototype.base = base;
model.prototype.schema = schema;
model.prototype.db = connection;
model.prototype.collection = connection.collection(collectionName);
// apply methods
for (var i in schema.methods)
model.prototype[i] = schema.methods[i];
// apply statics
for (var i in schema.statics)
model[i] = schema.statics[i];
// apply named scopes
if (schema.namedScopes) schema.namedScopes.compile(model);
return model;
};