diff --git a/docs/populate.jade b/docs/populate.jade index fb181e5bcb1..295a64ea23e 100644 --- a/docs/populate.jade +++ b/docs/populate.jade @@ -444,8 +444,6 @@ block content

Populate Virtuals

- _New in 4.5.0_ - So far you've only populated based on the `_id` field. However, that's sometimes not the right choice. In particular, [arrays that grow without bound are a MongoDB anti-pattern](https://docs.mongodb.com/manual/tutorial/model-referenced-one-to-many-relationships-between-documents/). @@ -453,12 +451,12 @@ block content between documents. ```javascript - var PersonSchema = new Schema({ + const PersonSchema = new Schema({ name: String, band: String }); - var BandSchema = new Schema({ + const BandSchema = new Schema({ name: String }); BandSchema.virtual('members', { @@ -471,8 +469,8 @@ block content options: { sort: { name: -1 }, limit: 5 } // Query options, see http://bit.ly/mongoose-query-options }); - var Person = mongoose.model('Person', PersonSchema); - var Band = mongoose.model('Band', BandSchema); + const Person = mongoose.model('Person', PersonSchema); + const Band = mongoose.model('Band', BandSchema); /** * Suppose you have 2 bands: "Guns N' Roses" and "Motley Crue" @@ -491,7 +489,7 @@ block content ```javascript // Set `virtuals: true` so `res.json()` works - var BandSchema = new Schema({ + const BandSchema = new Schema({ name: String }, { toJSON: { virtuals: true } }); ``` @@ -515,6 +513,34 @@ block content }); ``` +

Populate Virtuals: The Count Option

+ + Populate virtuals also support counting the number of documents with + matching `foreignField` as opposed to the documents themselves. Set the + `count` option on your virtual: + + ```javascript + 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 + ``` +

Populate in Middleware

You can populate in either pre or post [hooks](http://mongoosejs.com/docs/middleware.html). If you want to diff --git a/lib/aggregate.js b/lib/aggregate.js index a506cf20cf0..28bea5c4ea6 100644 --- a/lib/aggregate.js +++ b/lib/aggregate.js @@ -700,7 +700,7 @@ Aggregate.prototype.explain = function(callback) { } cb(null, result); }); - }); + }, this._model.events); }; /** @@ -823,16 +823,16 @@ Aggregate.prototype.cursor = function(options) { * @param {Boolean} value * @return {Aggregate} this * @api public - * @see mongodb http://mongodb.github.io/node-mongodb-native/2.2/api/Cursor.html#addCursorFlag + * @deprecated Use [`.option()`](api.html#aggregate_Aggregate-option) instead. Note that MongoDB aggregations do **not** support a `noCursorTimeout` option. */ -Aggregate.prototype.addCursorFlag = function(flag, value) { +Aggregate.prototype.addCursorFlag = util.deprecate(function(flag, value) { if (!this.options) { this.options = {}; } this.options[flag] = value; return this; -}; +}, 'Mongoose: `Aggregate#addCursorFlag()` is deprecated, use `option()` instead'); /** * Adds a collation @@ -963,7 +963,7 @@ Aggregate.prototype.exec = function(callback) { }); }); }); - }); + }, model.events); }; /** @@ -982,6 +982,20 @@ Aggregate.prototype.then = function(resolve, reject) { return this.exec().then(resolve, reject); }; +/** + * Executes the query returning a `Promise` which will be + * resolved with either the doc(s) or rejected with the error. + * Like [`.then()`](#query_Query-then), but only takes a rejection handler. + * + * @param {Function} [reject] + * @return {Promise} + * @api public + */ + +Aggregate.prototype.catch = function(reject) { + return this.exec().then(null, reject); +}; + /** * Returns an asyncIterator for use with [`for/await/of` loops](http://bit.ly/async-iterators) * This function *only* works for `find()` queries. diff --git a/lib/browserDocument.js b/lib/browserDocument.js index 80976ca268f..b1c803d7243 100644 --- a/lib/browserDocument.js +++ b/lib/browserDocument.js @@ -73,6 +73,12 @@ function Document(obj, schema, fields, skipId, skipInit) { Document.prototype = Object.create(NodeJSDocument.prototype); Document.prototype.constructor = Document; +/*! + * ignore + */ + +Document.events = new EventEmitter(); + /*! * Browser doc exposes the event emitter API */ diff --git a/lib/cast/date.js b/lib/cast/date.js new file mode 100644 index 00000000000..ac17006df70 --- /dev/null +++ b/lib/cast/date.js @@ -0,0 +1,41 @@ +'use strict'; + +const assert = require('assert'); + +module.exports = function castDate(value) { + // Support empty string because of empty form values. Originally introduced + // in https://github.com/Automattic/mongoose/commit/efc72a1898fc3c33a319d915b8c5463a22938dfe + if (value == null || value === '') { + return null; + } + + if (value instanceof Date) { + assert.ok(!isNaN(value.valueOf())); + + return value; + } + + let date; + + assert.ok(typeof value !== 'boolean'); + + if (value instanceof Number || typeof value === 'number') { + date = new Date(value); + } else if (typeof value === 'string' && !isNaN(Number(value)) && (Number(value) >= 275761 || Number(value) < -271820)) { + // string representation of milliseconds take this path + date = new Date(Number(value)); + } else if (typeof value.valueOf === 'function') { + // support for moment.js. This is also the path strings will take because + // strings have a `valueOf()` + date = new Date(value.valueOf()); + } else { + // fallback + date = new Date(value); + } + + if (!isNaN(date.valueOf())) { + return date; + } + + assert.ok(false); +}; \ No newline at end of file diff --git a/lib/cast/decimal128.js b/lib/cast/decimal128.js new file mode 100644 index 00000000000..bfb1578c406 --- /dev/null +++ b/lib/cast/decimal128.js @@ -0,0 +1,36 @@ +'use strict'; + +const Decimal128Type = require('../types/decimal128'); +const assert = require('assert'); + +module.exports = function castDecimal128(value) { + if (value == null) { + return value; + } + + if (typeof value === 'object' && typeof value.$numberDecimal === 'string') { + return Decimal128Type.fromString(value.$numberDecimal); + } + + if (value instanceof Decimal128Type) { + return value; + } + + if (typeof value === 'string') { + return Decimal128Type.fromString(value); + } + + if (Buffer.isBuffer(value)) { + return new Decimal128Type(value); + } + + if (typeof value === 'number') { + return Decimal128Type.fromString(String(value)); + } + + if (typeof value.valueOf === 'function' && typeof value.valueOf() === 'string') { + return Decimal128Type.fromString(value.valueOf()); + } + + assert.ok(false); +}; \ No newline at end of file diff --git a/lib/cast/number.js b/lib/cast/number.js index 180973547f3..abc22f65cb9 100644 --- a/lib/cast/number.js +++ b/lib/cast/number.js @@ -1,6 +1,6 @@ 'use strict'; -const CastError = require('../error/cast'); +const assert = require('assert'); /*! * Given a value, cast it to a number, or throw a `CastError` if the value @@ -9,14 +9,12 @@ const CastError = require('../error/cast'); * @param {Any} value * @param {String} [path] optional the path to set on the CastError * @return {Boolean|null|undefined} - * @throws {CastError} if `value` is not one of the allowed values + * @throws {Error} if `value` is not one of the allowed values * @api private */ -module.exports = function castNumber(val, path) { - if (isNaN(val)) { - throw new CastError('number', val, path); - } +module.exports = function castNumber(val) { + assert.ok(!isNaN(val)); if (val == null) { return val; @@ -29,9 +27,7 @@ module.exports = function castNumber(val, path) { val = Number(val); } - if (isNaN(val)) { - throw new CastError('number', val, path); - } + assert.ok(!isNaN(val)); if (val instanceof Number) { return val; } @@ -45,5 +41,5 @@ module.exports = function castNumber(val, path) { return new Number(val); } - throw new CastError('number', val, path); + assert.ok(false); }; diff --git a/lib/cast/objectid.js b/lib/cast/objectid.js new file mode 100644 index 00000000000..67cffb54304 --- /dev/null +++ b/lib/cast/objectid.js @@ -0,0 +1,29 @@ +'use strict'; + +const ObjectId = require('../driver').get().ObjectId; +const assert = require('assert'); + +module.exports = function castObjectId(value) { + if (value == null) { + return value; + } + + if (value instanceof ObjectId) { + return value; + } + + if (value._id) { + if (value._id instanceof ObjectId) { + return value._id; + } + if (value._id.toString instanceof Function) { + return new ObjectId(value._id.toString()); + } + } + + if (value.toString instanceof Function) { + return new ObjectId(value.toString()); + } + + assert.ok(false); +}; \ No newline at end of file diff --git a/lib/collection.js b/lib/collection.js index 7794d092ea2..1f797058b44 100644 --- a/lib/collection.js +++ b/lib/collection.js @@ -174,6 +174,14 @@ Collection.prototype.findOneAndDelete = function() { throw new Error('Collection#findOneAndDelete unimplemented by driver'); }; +/** + * Abstract method that drivers must implement. + */ + +Collection.prototype.findOneAndReplace = function() { + throw new Error('Collection#findOneAndReplace unimplemented by driver'); +}; + /** * Abstract method that drivers must implement. */ diff --git a/lib/cursor/QueryCursor.js b/lib/cursor/QueryCursor.js index f7032a9216d..d0f2f2c5530 100644 --- a/lib/cursor/QueryCursor.js +++ b/lib/cursor/QueryCursor.js @@ -41,6 +41,7 @@ function QueryCursor(query, options) { const model = query.model; this._mongooseOptions = {}; this._transforms = []; + this.model = model; model.hooks.execPre('find', query, () => { this._transforms = this._transforms.concat(query._transforms.slice()); if (options.transform) { @@ -157,7 +158,7 @@ QueryCursor.prototype.close = function(callback) { this.emit('close'); cb(null); }); - }); + }, this.model.events); }; /** @@ -178,7 +179,7 @@ QueryCursor.prototype.next = function(callback) { } cb(null, doc); }); - }); + }, this.model.events); }; /** diff --git a/lib/document.js b/lib/document.js index 6cf5fba9c11..d03b64b38d0 100644 --- a/lib/document.js +++ b/lib/document.js @@ -594,10 +594,17 @@ Document.prototype.update = function update() { * @instance */ -Document.prototype.updateOne = function updateOne() { - const args = utils.args(arguments); - args.unshift({_id: this._id}); - return this.constructor.updateOne.apply(this.constructor, args); +Document.prototype.updateOne = function updateOne(doc, options, callback) { + return utils.promiseOrCallback(callback, + cb => this.$__updateOne(doc, options, cb), this.constructor.events); +}; + +/*! + * ignore + */ + +Document.prototype.$__updateOne = function $__updateOne(doc, options, callback) { + return this.constructor.updateOne({ _id: this._id }, doc, options, callback); }; /** @@ -1705,7 +1712,7 @@ Document.prototype.validate = function(options, callback) { return utils.promiseOrCallback(callback, cb => this.$__validate(function(error) { cb(error); - })); + }), this.constructor.events); }; /*! @@ -3012,7 +3019,7 @@ Document.prototype.populate = function populate() { Document.prototype.execPopulate = function(callback) { return utils.promiseOrCallback(callback, cb => { this.populate(cb); - }); + }, this.constructor.events); }; /** diff --git a/lib/helpers/model/applyHooks.js b/lib/helpers/model/applyHooks.js index f8beeceaf10..fff81eac106 100644 --- a/lib/helpers/model/applyHooks.js +++ b/lib/helpers/model/applyHooks.js @@ -16,6 +16,7 @@ applyHooks.middlewareFunctions = [ 'save', 'validate', 'remove', + 'updateOne', 'init' ]; @@ -58,10 +59,13 @@ function applyHooks(model, schema, options) { // information. const middleware = schema.s.hooks.filter(hook => { - if (hook.name !== 'remove') { - return true; + if (hook.name === 'updateOne') { + return !!hook['document']; } - return hook['document'] == null || !!hook['document']; + if (hook.name === 'remove') { + return hook['document'] == null || !!hook['document']; + } + return true; }); objToDecorate.$__save = middleware. @@ -70,6 +74,8 @@ function applyHooks(model, schema, options) { createWrapper('validate', objToDecorate.$__validate, null, kareemOptions); objToDecorate.$__remove = middleware. createWrapper('remove', objToDecorate.$__remove, null, kareemOptions); + objToDecorate.$__updateOne = middleware. + createWrapper('updateOne', objToDecorate.$__updateOne, null, kareemOptions); objToDecorate.$__init = middleware. createWrapperSync('init', objToDecorate.$__init, null, kareemOptions); diff --git a/lib/helpers/populate/assignRawDocsToIdStructure.js b/lib/helpers/populate/assignRawDocsToIdStructure.js index ad37ec1a50c..1de37c6e8d4 100644 --- a/lib/helpers/populate/assignRawDocsToIdStructure.js +++ b/lib/helpers/populate/assignRawDocsToIdStructure.js @@ -49,11 +49,13 @@ function assignRawDocsToIdStructure(rawIds, resultDocs, resultOrder, options, re sid = String(id); - if (recursed) { - // apply find behavior + doc = resultDocs[sid]; + // If user wants separate copies of same doc, use this option + if (options.clone) { + doc = doc.constructor.hydrate(doc._doc); + } - // assign matching documents in original order unless sorting - doc = resultDocs[sid]; + if (recursed) { if (doc) { if (sorting) { newOrder[resultOrder[sid]] = doc; @@ -65,7 +67,7 @@ function assignRawDocsToIdStructure(rawIds, resultDocs, resultOrder, options, re } } else { // apply findOne behavior - if document in results, assign, else assign null - newOrder[i] = doc = resultDocs[sid] || null; + newOrder[i] = doc || null; } } diff --git a/lib/helpers/query/applyQueryMiddleware.js b/lib/helpers/query/applyQueryMiddleware.js index f1ce4311066..3a987c40392 100644 --- a/lib/helpers/query/applyQueryMiddleware.js +++ b/lib/helpers/query/applyQueryMiddleware.js @@ -13,11 +13,14 @@ module.exports = applyQueryMiddleware; applyQueryMiddleware.middlewareFunctions = [ 'count', 'countDocuments', + 'deleteMany', + 'deleteOne', 'estimatedDocumentCount', 'find', 'findOne', 'findOneAndDelete', 'findOneAndRemove', + 'findOneAndReplace', 'findOneAndUpdate', 'remove', 'replaceOne', @@ -41,10 +44,13 @@ function applyQueryMiddleware(Query, model) { }; const middleware = model.hooks.filter(hook => { - if (hook.name !== 'remove') { - return true; + if (hook.name === 'updateOne') { + return hook.query == null || !!hook.query; } - return !!hook.query; + if (hook.name === 'remove') { + return !!hook.query; + } + return true; }); // `update()` thunk has a different name because `_update` was already taken diff --git a/lib/helpers/query/castUpdate.js b/lib/helpers/query/castUpdate.js index 7cd9c5aa5ad..47cf6d4c40d 100644 --- a/lib/helpers/query/castUpdate.js +++ b/lib/helpers/query/castUpdate.js @@ -352,9 +352,14 @@ const overwriteOps = { function castUpdateVal(schema, val, op, $conditional, context, path) { if (!schema) { // non-existing schema path - return op in numberOps ? - castNumber(val, path) : - val; + if (op in numberOps) { + try { + return castNumber(val); + } catch (err) { + throw new CastError('number', val, path); + } + } + return val; } const cond = schema.caster && op in castOps && @@ -390,7 +395,11 @@ function castUpdateVal(schema, val, op, $conditional, context, path) { context: context }); } - return castNumber(val, schema.path); + try { + return castNumber(val); + } catch (error) { + throw new CastError('number', val, schema.path); + } } if (op === '$currentDate') { if (typeof val === 'object') { diff --git a/lib/index.js b/lib/index.js index 37efff4eced..cd5614a9768 100644 --- a/lib/index.js +++ b/lib/index.js @@ -863,6 +863,22 @@ Mongoose.prototype.Decimal128 = SchemaTypes.Decimal128; Mongoose.prototype.Mixed = SchemaTypes.Mixed; +/** + * The Mongoose Number [SchemaType](/docs/schematypes.html). Used for + * declaring paths in your schema that Mongoose should cast to numbers. + * + * ####Example: + * + * const schema = new Schema({ num: mongoose.Number }); + * // Equivalent to: + * const schema = new Schema({ num: 'number' }); + * + * @property Number + * @api public + */ + +Mongoose.prototype.Number = SchemaTypes.Number; + /** * The [MongooseError](#error_MongooseError) constructor. * diff --git a/lib/model.js b/lib/model.js index 98e5fadbf43..afeb9bc5e5c 100644 --- a/lib/model.js +++ b/lib/model.js @@ -162,6 +162,26 @@ Model.prototype.$where; Model.prototype.baseModelName; +/** + * Event emitter that reports any errors that occurred. Useful for global error + * handling. + * + * ####Example: + * + * MyModel.events.on('error', err => console.log(err.message)); + * + * // Prints a 'CastError' because of the above handler + * await MyModel.findOne({ _id: 'notanid' }).catch({} => {}); + * + * @api public + * @fires error whenever any query or model function errors + * @property events + * @memberOf Model + * @static + */ + +Model.events; + /*! * ignore */ @@ -436,7 +456,7 @@ Model.prototype.save = function(options, fn) { } cb(null, this); }); - }); + }, this.constructor.events); }; /*! @@ -861,7 +881,7 @@ Model.prototype.remove = function remove(options, fn) { return utils.promiseOrCallback(fn, cb => { this.$__remove(options, cb); - }); + }, this.constructor.events); }; /** @@ -1134,7 +1154,7 @@ Model.createCollection = function createCollection(options, callback) { this.collection = this.db.collection(this.collection.collectionName, options); cb(null, this.collection); })); - }); + }, this.events); }; /** @@ -1241,7 +1261,7 @@ Model.syncIndexes = function syncIndexes(options, callback) { cb(null, dropped); }); }); - }); + }, this.events); }; /** @@ -1269,7 +1289,7 @@ Model.listIndexes = function init(callback) { } else { _listIndexes(cb); } - }); + }, this.events); }; /** @@ -1318,7 +1338,7 @@ Model.ensureIndexes = function ensureIndexes(options, callback) { } cb(null); }); - }); + }, this.events); }; /** @@ -2419,6 +2439,76 @@ Model.findByIdAndDelete = function(id, options, callback) { return this.findOneAndDelete({_id: id}, options, callback); }; +/** + * Issue a MongoDB `findOneAndReplace()` command. + * + * Finds a matching document, replaces it with the provided doc, and passes the + * returned doc to the callback. + * + * Executes immediately if `callback` is passed else a Query object is returned. + * + * This function triggers the following query middleware. + * + * - `findOneAndReplace()` + * + * ####Options: + * + * - `sort`: if multiple docs are found by the conditions, sets the sort order to choose which doc to update + * - `maxTimeMS`: puts a time limit on the query - requires mongodb >= 2.6.0 + * - `select`: sets the document fields to return + * - `projection`: like select, it determines which fields to return, ex. `{ projection: { _id: 0 } }` + * - `rawResult`: if true, returns the [raw result from the MongoDB driver](http://mongodb.github.io/node-mongodb-native/2.0/api/Collection.html#findAndModify) + * - `strict`: overwrites the schema's [strict mode option](http://mongoosejs.com/docs/guide.html#strict) for this update + * + * ####Examples: + * + * A.findOneAndReplace(conditions, options, callback) // executes + * A.findOneAndReplace(conditions, options) // return Query + * A.findOneAndReplace(conditions, callback) // executes + * A.findOneAndReplace(conditions) // returns Query + * A.findOneAndReplace() // returns Query + * + * Values are cast to their appropriate types when using the findAndModify helpers. + * However, the below are not executed by default. + * + * - defaults. Use the `setDefaultsOnInsert` option to override. + * + * @param {Object} conditions + * @param {Object} [options] optional see [`Query.prototype.setOptions()`](http://mongoosejs.com/docs/api.html#query_Query-setOptions) + * @param {Function} [callback] + * @return {Query} + * @api public + */ + +Model.findOneAndReplace = function(conditions, options, callback) { + if (arguments.length === 1 && typeof conditions === 'function') { + const msg = 'Model.findOneAndDelete(): First argument must not be a function.\n\n' + + ' ' + this.modelName + '.findOneAndDelete(conditions, callback)\n' + + ' ' + this.modelName + '.findOneAndDelete(conditions)\n' + + ' ' + this.modelName + '.findOneAndDelete()\n'; + throw new TypeError(msg); + } + + if (typeof options === 'function') { + callback = options; + options = undefined; + } + if (callback) { + callback = this.$wrapCallback(callback); + } + + let fields; + if (options) { + fields = options.select; + options.select = undefined; + } + + const mq = new this.Query({}, {}, this, this.collection); + mq.select(fields); + + return mq.findOneAndReplace(conditions, options, callback); +}; + /** * Issue a mongodb findAndModify remove command. * @@ -2673,7 +2763,7 @@ Model.create = function create(doc, options, callback) { cb.apply(this, [null].concat(savedDocs)); } }); - }); + }, this.events); }; /** @@ -2781,7 +2871,7 @@ Model.insertMany = function(arr, options, callback) { } return utils.promiseOrCallback(callback, cb => { this.$__insertMany(arr, options, cb); - }); + }, this.events); }; /*! @@ -2957,7 +3047,7 @@ Model.bulkWrite = function(ops, options, callback) { cb(null, res); }); }); - }); + }, this.events); }; /** @@ -3277,7 +3367,7 @@ Model.mapReduce = function mapReduce(o, callback) { cb(null, res); }); - }); + }, this.events); }; /** @@ -3433,7 +3523,7 @@ Model.geoSearch = function(conditions, options, callback) { res.results[i].init(temp, {}, init); } }); - }); + }, this.events); }; /** @@ -3501,6 +3591,7 @@ Model.geoSearch = function(conditions, options, callback) { * @param {Object} options A hash of key/val (path, options) used for population. * @param {boolean} [options.retainNullValues=false] by default, Mongoose removes null and undefined values from populated arrays. Use this option to make `populate()` retain `null` and `undefined` array entries. * @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 {Function} [callback(err,doc)] Optional callback, executed upon completion. Receives `err` and the `doc(s)`. * @return {Promise} * @api public @@ -3520,7 +3611,7 @@ Model.populate = function(docs, paths, callback) { return utils.promiseOrCallback(callback, cb => { _populate(_this, docs, paths, cache, cb); - }); + }, this.events); }; /*! @@ -3650,12 +3741,33 @@ function populate(model, docs, options, callback) { } } + // If just setting count, skip everything else + if (mod.count) { + mod.model.countDocuments(match, function(err, count) { + if (err != null) { + return callback(err); + } + + for (const doc of docs) { + try { + utils.setValue(mod.options.path, count, doc); + } catch (err) { + return callback(err); + } + } + + callback(null); + }); + continue; + } + if (mod.options.options && mod.options.options.limit) { assignmentOpts.originalLimit = mod.options.options.limit; mod.options.options.limit = mod.options.options.limit * ids.length; } const subPopulate = utils.clone(mod.options.populate); + const query = mod.model.find(match, select, mod.options.options); // If we're doing virtual populate and projection is inclusive and foreign @@ -3802,11 +3914,12 @@ function populate(model, docs, options, callback) { */ function assignVals(o) { - // Glob all options together because `populateOptions` is confusing - const retainNullValues = get(o, 'allOptions.options.options.retainNullValues', false); - const populateOptions = Object.assign({}, o.options, { - justOne: o.justOne, - retainNullValues: retainNullValues + // Options that aren't explicitly listed in `populateOptions` + const userOptions = get(o, 'allOptions.options.options'); + // `o.options` contains options explicitly listed in `populateOptions`, like + // `match` and `limit`. + const populateOptions = Object.assign({}, o.options, userOptions, { + justOne: o.justOne }); // replace the original ids in our intermediate _ids structure @@ -3958,6 +4071,7 @@ function getModelsMapForPopulate(model, docs, options) { const virtual = getVirtual(model.schema, options.path); let localField; + let count = false; if (virtual && virtual.options) { const virtualPrefix = virtual.$nestedSchemaPath ? virtual.$nestedSchemaPath + '.' : ''; @@ -3966,6 +4080,7 @@ function getModelsMapForPopulate(model, docs, options) { } else { localField = virtualPrefix + virtual.options.localField; } + count = virtual.options.count; } else { localField = options.path; } @@ -4078,7 +4193,8 @@ function getModelsMapForPopulate(model, docs, options) { foreignField: new Set([foreignField]), justOne: justOne, isVirtual: isVirtual, - virtual: virtual + virtual: virtual, + count: count }; map.push(available[modelName]); } else { @@ -4386,6 +4502,7 @@ Model.compile = function compile(name, schema, collectionName, connection, base) model.db = model.prototype.db = connection; model.discriminators = model.prototype.discriminators = undefined; model[modelSymbol] = true; + model.events = new EventEmitter(); model.prototype.$__setSchema(schema); diff --git a/lib/query.js b/lib/query.js index 0deef281142..917b93a9ffe 100644 --- a/lib/query.js +++ b/lib/query.js @@ -1039,6 +1039,7 @@ Query.prototype.session = function session(v) { * - `deleteOne()` * - `deleteMany()` * - `findOneAndDelete()` + * - `findOneAndReplace()` * - `findOneAndUpdate()` * - `remove()` * - `update()` @@ -1080,6 +1081,7 @@ Query.prototype.w = function w(val) { * - `deleteOne()` * - `deleteMany()` * - `findOneAndDelete()` + * - `findOneAndReplace()` * - `findOneAndUpdate()` * - `remove()` * - `update()` @@ -1119,6 +1121,7 @@ Query.prototype.j = function j(val) { * - `deleteOne()` * - `deleteMany()` * - `findOneAndDelete()` + * - `findOneAndReplace()` * - `findOneAndUpdate()` * - `remove()` * - `update()` @@ -1344,6 +1347,30 @@ Query.prototype.explain = function(verbose) { return this; }; +/** + * Sets the [maxTimeMS](https://docs.mongodb.com/manual/reference/method/cursor.maxTimeMS/) + * option. This will tell the MongoDB server to abort if the query or write op + * has been running for more than `ms` milliseconds. + * + * Calling `query.maxTimeMS(v)` is equivalent to `query.setOption({ maxTimeMS: v })` + * + * ####Example: + * + * const query = new Query(); + * // Throws an error 'operation exceeded time limit' as long as there's + * // >= 1 doc in the queried collection + * const res = await query.find({ $where: 'sleep(1000) || true' }).maxTimeMS(100); + * + * @param {Number} [ms] The number of milliseconds + * @return {Query} this + * @api public + */ + +Query.prototype.maxTimeMS = function(ms) { + this.options.maxTimeMS = ms; + return this; +}; + /** * Returns the current query conditions as a JSON object. * @@ -2943,6 +2970,121 @@ Query.prototype._findOneAndDelete = function(callback) { })); }; +/** + * Issues a MongoDB [findOneAndReplace](https://docs.mongodb.com/manual/reference/method/db.collection.findOneAndReplace/) command. + * + * Finds a matching document, removes it, and passes the found document (if any) to the callback. Executes immediately if `callback` is passed. + * + * This function triggers the following middleware. + * + * - `findOneAndReplace()` + * + * ####Available options + * + * - `sort`: if multiple docs are found by the conditions, sets the sort order to choose which doc to update + * - `maxTimeMS`: puts a time limit on the query - requires mongodb >= 2.6.0 + * - `rawResult`: if true, resolves to the [raw result from the MongoDB driver](http://mongodb.github.io/node-mongodb-native/2.0/api/Collection.html#findAndModify) + * + * ####Callback Signature + * function(error, doc) { + * // error: any errors that occurred + * // doc: the document before updates are applied if `new: false`, or after updates if `new = true` + * } + * + * ####Examples + * + * A.where().findOneAndReplace(conditions, options, callback) // executes + * A.where().findOneAndReplace(conditions, options) // return Query + * A.where().findOneAndReplace(conditions, callback) // executes + * A.where().findOneAndReplace(conditions) // returns Query + * A.where().findOneAndReplace(callback) // executes + * A.where().findOneAndReplace() // returns Query + * + * @method findOneAndReplace + * @memberOf Query + * @param {Object} [conditions] + * @param {Object} [options] + * @param {Boolean} [options.rawResult] if true, returns the [raw result from the MongoDB driver](http://mongodb.github.io/node-mongodb-native/2.0/api/Collection.html#findAndModify) + * @param {Boolean|String} [options.strict] overwrites the schema's [strict mode option](http://mongoosejs.com/docs/guide.html#strict) + * @param {Function} [callback] optional params are (error, document) + * @return {Query} this + * @api public + */ + +Query.prototype.findOneAndReplace = function(conditions, options, callback) { + this.op = 'findOneAndReplace'; + this._validate(); + + switch (arguments.length) { + case 2: + if (typeof options === 'function') { + callback = options; + options = {}; + } + break; + case 1: + if (typeof conditions === 'function') { + callback = conditions; + conditions = undefined; + options = undefined; + } + break; + } + + if (mquery.canMerge(conditions)) { + this.merge(conditions); + } + + options && this.setOptions(options); + + if (!callback) { + return this; + } + + this._findOneAndReplace(callback); + + return this; +}; + +/*! + * Thunk around findOneAndReplace() + * + * @param {Function} [callback] + * @return {Query} this + * @api private + */ +Query.prototype._findOneAndReplace = function(callback) { + this._castConditions(); + + if (this.error() != null) { + callback(this.error()); + return null; + } + + const filter = this._conditions; + const options = this._optionsForExec(); + let fields = null; + + if (this._fields != null) { + options.projection = this._castFields(utils.clone(this._fields)); + fields = options.projection; + if (fields instanceof Error) { + callback(fields); + return null; + } + } + + this._collection.collection.findOneAndReplace(filter, options, (err, res) => { + if (err) { + return callback(err); + } + + const doc = res.value; + + return this._completeOne(doc, res, callback); + }); +}; + /*! * Thunk around findOneAndRemove() * @@ -3687,11 +3829,31 @@ function _update(query, op, filter, doc, options, callback) { } /** + * Runs a function `fn` and treats the return value of `fn` as the new value + * for the query to resolve to. + * + * Any functions you pass to `map()` will run **after** any post hooks. + * + * ####Example: * + * const res = await MyModel.findOne().map(res => { + * // Sets a `loadedAt` property on the doc that tells you the time the + * // document was loaded. + * return res == null ? + * res : + * Object.assign(res, { loadedAt: new Date() }); + * }); + * + * @method map + * @memberOf Query + * @instance + * @param {Function} fn function to run to transform the query result + * @return {Query} this */ Query.prototype.map = function(fn) { this._transforms.push(fn); + return this; }; /** @@ -3818,7 +3980,7 @@ Query.prototype.exec = function exec(op, callback) { cb(null, res); }); - }); + }, this.model.events); }; /*! diff --git a/lib/schema.js b/lib/schema.js index d44a9b7ba3c..e8ab068acce 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -67,7 +67,7 @@ let id = 0; * * _When nesting schemas, (`children` in the example above), always declare the child schema first before passing it into its parent._ * - * @param {Object} definition + * @param {Object|Schema|Array} [definition] Can be one of: object describing schema paths, or schema to copy, or array of objects and schemas * @param {Object} [options] * @inherits NodeJS EventEmitter http://nodejs.org/api/events.html#events_class_events_eventemitter * @event `init`: Emitted after the schema is compiled into a `Model`. @@ -106,7 +106,11 @@ function Schema(obj, options) { this.options = this.defaultOptions(options); // build paths - if (obj) { + if (Array.isArray(obj)) { + for (const definition of obj) { + this.add(definition); + } + } else if (obj) { this.add(obj); } @@ -1356,6 +1360,11 @@ Schema.prototype.indexes = function() { * * @param {String} name * @param {Object} [options] + * @param {String|Model} [options.ref] model name or model instance. Marks this as a [populate virtual](populate.html#populate-virtuals). + * @param {String|Function} [options.localField] Required for populate virtuals. See [populate virtual docs](populate.html#populate-virtuals) for more information. + * @param {String|Function} [options.foreignField] Required for populate virtuals. See [populate virtual docs](populate.html#populate-virtuals) for more information. + * @param {Boolean|Function} [options.justOne=false] Only works with populate virtuals. If truthy, will be a single doc or `null`. Otherwise, the populate virtual will be an array. + * @param {Boolean} [options.count=false] Only works with populate virtuals. If truthy, this populate virtual will contain the number of documents rather than the documents themselves when you `populate()`. * @return {VirtualType} */ @@ -1376,7 +1385,7 @@ Schema.prototype.virtual = function(name, options) { this.$$populatedVirtuals = {}; } - if (options.justOne) { + if (options.justOne || options.count) { this.$$populatedVirtuals[name] = Array.isArray(_v) ? _v[0] : _v; @@ -1407,13 +1416,13 @@ Schema.prototype.virtual = function(name, options) { this.$$populatedVirtuals = {}; } - if (options.justOne) { + if (options.justOne || options.count) { this.$$populatedVirtuals[name] = Array.isArray(_v) ? _v[0] : _v; if (typeof this.$$populatedVirtuals[name] !== 'object') { - this.$$populatedVirtuals[name] = null; + this.$$populatedVirtuals[name] = options.count ? _v : null; } } else { this.$$populatedVirtuals[name] = Array.isArray(_v) ? diff --git a/lib/schema/boolean.js b/lib/schema/boolean.js index 47c5be901ca..f25d6e66532 100644 --- a/lib/schema/boolean.js +++ b/lib/schema/boolean.js @@ -4,6 +4,7 @@ * Module dependencies. */ +const CastError = require('../error/cast'); const SchemaType = require('../schematype'); const castBoolean = require('../cast/boolean'); const utils = require('../utils'); @@ -35,6 +36,70 @@ SchemaBoolean.schemaName = 'Boolean'; SchemaBoolean.prototype = Object.create(SchemaType.prototype); SchemaBoolean.prototype.constructor = SchemaBoolean; +/*! + * ignore + */ + +SchemaBoolean._cast = castBoolean; + +/** + * Get/set the function used to cast arbitrary values to objectids. + * + * ####Example: + * + * // Make Mongoose only try to cast strings + * const original = mongoose.ObjectId.cast(); + * mongoose.ObjectId.cast(v => { + * assert.ok(v == null || typeof v === 'string'); + * return original(v); + * }); + * + * // Or disable casting entirely + * mongoose.ObjectId.cast(false); + * + * @param {Function} caster + * @return {Function} + * @function get + * @static + * @api public + */ + +SchemaBoolean.cast = function cast(caster) { + if (arguments.length === 0) { + return this._cast; + } + if (caster === false) { + caster = v => { + if (v != null && typeof v !== 'boolean') { + throw new Error(); + } + return v; + }; + } + this._cast = caster; + + return this._cast; +}; + +/*! + * ignore + */ + +SchemaBoolean._checkRequired = v => v === true || v === false; + +/** + * Override the function the required validator uses to check whether a string + * passes the `required` check. + * + * @param {Function} fn + * @return {Function} + * @function checkRequired + * @static + * @api public + */ + +SchemaBoolean.checkRequired = SchemaType.checkRequired; + /** * Check if the given value satisfies a required validator. For a boolean * to satisfy a required validator, it must be strictly equal to true or to @@ -46,7 +111,7 @@ SchemaBoolean.prototype.constructor = SchemaBoolean; */ SchemaBoolean.prototype.checkRequired = function(value) { - return value === true || value === false; + return this.constructor._checkRequired(value); }; /** @@ -98,7 +163,11 @@ Object.defineProperty(SchemaBoolean, 'convertToFalse', { */ SchemaBoolean.prototype.cast = function(value) { - return castBoolean(value, this.path); + try { + return this.constructor.cast()(value); + } catch (error) { + throw new CastError('Boolean', value, this.path); + } }; SchemaBoolean.$conditionalHandlers = diff --git a/lib/schema/buffer.js b/lib/schema/buffer.js index c0697392c91..8bf6bc52bf2 100644 --- a/lib/schema/buffer.js +++ b/lib/schema/buffer.js @@ -41,6 +41,33 @@ SchemaBuffer.schemaName = 'Buffer'; SchemaBuffer.prototype = Object.create(SchemaType.prototype); SchemaBuffer.prototype.constructor = SchemaBuffer; +/*! + * ignore + */ + +SchemaBuffer._checkRequired = v => !!(v && v.length); + +/** + * Override the function the required validator uses to check whether a string + * passes the `required` check. + * + * ####Example: + * + * // Allow empty strings to pass `required` check + * mongoose.Schema.Types.String.checkRequired(v => v != null); + * + * const M = mongoose.model({ buf: { type: Buffer, required: true } }); + * new M({ buf: Buffer.from('') }).validateSync(); // validation passes! + * + * @param {Function} fn + * @return {Function} + * @function checkRequired + * @static + * @api public + */ + +SchemaBuffer.checkRequired = SchemaType.checkRequired; + /** * Check if the given value satisfies a required validator. To satisfy a * required validator, a buffer must not be null or undefined and have @@ -56,7 +83,7 @@ SchemaBuffer.prototype.checkRequired = function(value, doc) { if (SchemaType._isRef(this, value, doc, true)) { return !!value; } - return !!(value && value.length); + return this.constructor._checkRequired(value); }; /** diff --git a/lib/schema/date.js b/lib/schema/date.js index e96a9ee5ec7..6e06cb7e4cd 100644 --- a/lib/schema/date.js +++ b/lib/schema/date.js @@ -5,6 +5,7 @@ 'use strict'; const MongooseError = require('../error'); +const castDate = require('../cast/date'); const utils = require('../utils'); const SchemaType = require('../schematype'); @@ -38,6 +39,52 @@ SchemaDate.schemaName = 'Date'; SchemaDate.prototype = Object.create(SchemaType.prototype); SchemaDate.prototype.constructor = SchemaDate; +/*! + * ignore + */ + +SchemaDate._cast = castDate; + +/** + * Get/set the function used to cast arbitrary values to dates. + * + * ####Example: + * + * // Mongoose converts empty string '' into `null` for date types. You + * // can create a custom caster to disable it. + * const original = mongoose.Schema.Types.Date.cast(); + * mongoose.Schema.Types.Date.cast(v => { + * assert.ok(v !== ''); + * return original(v); + * }); + * + * // Or disable casting entirely + * mongoose.Schema.Types.Date.cast(false); + * + * @param {Function} caster + * @return {Function} + * @function get + * @static + * @api public + */ + +SchemaDate.cast = function cast(caster) { + if (arguments.length === 0) { + return this._cast; + } + if (caster === false) { + caster = v => { + if (v != null && !(v instanceof Date)) { + throw new Error(); + } + return v; + }; + } + this._cast = caster; + + return this._cast; +}; + /** * Declares a TTL index (rounded to the nearest second) for _Date_ types only. * @@ -79,6 +126,33 @@ SchemaDate.prototype.expires = function(when) { return this; }; +/*! + * ignore + */ + +SchemaDate._checkRequired = v => v instanceof Date; + +/** + * Override the function the required validator uses to check whether a string + * passes the `required` check. + * + * ####Example: + * + * // Allow empty strings to pass `required` check + * mongoose.Schema.Types.String.checkRequired(v => v != null); + * + * const M = mongoose.model({ str: { type: String, required: true } }); + * new M({ str: '' }).validateSync(); // `null`, validation passes! + * + * @param {Function} fn + * @return {Function} + * @function checkRequired + * @static + * @api public + */ + +SchemaDate.checkRequired = SchemaType.checkRequired; + /** * Check if the given value satisfies a required validator. To satisfy * a required validator, the given value must be an instance of `Date`. @@ -89,8 +163,11 @@ SchemaDate.prototype.expires = function(when) { * @api public */ -SchemaDate.prototype.checkRequired = function(value) { - return value instanceof Date; +SchemaDate.prototype.checkRequired = function(value, doc) { + if (SchemaType._isRef(this, value, doc, true)) { + return !!value; + } + return this.constructor._checkRequired(value); }; /** @@ -213,44 +290,12 @@ SchemaDate.prototype.max = function(value, message) { */ SchemaDate.prototype.cast = function(value) { - // If null or undefined - if (value === null || value === void 0 || value === '') { - return null; - } - - if (value instanceof Date) { - if (isNaN(value.valueOf())) { - throw new CastError('date', value, this.path); - } - - return value; - } - - let date; - - if (typeof value === 'boolean') { + const _castDate = this.constructor.cast(); + try { + return _castDate(value); + } catch (error) { throw new CastError('date', value, this.path); } - - if (value instanceof Number || typeof value === 'number') { - date = new Date(value); - } else if (typeof value === 'string' && !isNaN(Number(value)) && (Number(value) >= 275761 || Number(value) < -271820)) { - // string representation of milliseconds take this path - date = new Date(Number(value)); - } else if (typeof value.valueOf === 'function') { - // support for moment.js. This is also the path strings will take because - // strings have a `valueOf()` - date = new Date(value.valueOf()); - } else { - // fallback - date = new Date(value); - } - - if (!isNaN(date.valueOf())) { - return date; - } - - throw new CastError('date', value, this.path); }; /*! diff --git a/lib/schema/decimal128.js b/lib/schema/decimal128.js index bc7795a1436..0338905ffe8 100644 --- a/lib/schema/decimal128.js +++ b/lib/schema/decimal128.js @@ -7,7 +7,9 @@ const SchemaType = require('../schematype'); const CastError = SchemaType.CastError; const Decimal128Type = require('../types/decimal128'); +const castDecimal128 = require('../cast/decimal128'); const utils = require('../utils'); + let Document; /** @@ -37,6 +39,70 @@ Decimal128.schemaName = 'Decimal128'; Decimal128.prototype = Object.create(SchemaType.prototype); Decimal128.prototype.constructor = Decimal128; +/*! + * ignore + */ + +Decimal128._cast = castDecimal128; + +/** + * Get/set the function used to cast arbitrary values to decimals. + * + * ####Example: + * + * // Make Mongoose only refuse to cast numbers as decimal128 + * const original = mongoose.Schema.Types.Decimal128.cast(); + * mongoose.Decimal128.cast(v => { + * assert.ok(typeof v !== 'number'); + * return original(v); + * }); + * + * // Or disable casting entirely + * mongoose.Decimal128.cast(false); + * + * @param {Function} [caster] + * @return {Function} + * @function get + * @static + * @api public + */ + +Decimal128.cast = function cast(caster) { + if (arguments.length === 0) { + return this._cast; + } + if (caster === false) { + caster = v => { + if (v != null && !(v instanceof Decimal128Type)) { + throw new Error(); + } + return v; + }; + } + this._cast = caster; + + return this._cast; +}; + +/*! + * ignore + */ + +Decimal128._checkRequired = v => v instanceof Decimal128Type; + +/** + * Override the function the required validator uses to check whether a string + * passes the `required` check. + * + * @param {Function} fn + * @return {Function} + * @function checkRequired + * @static + * @api public + */ + +Decimal128.checkRequired = SchemaType.checkRequired; + /** * Check if the given value satisfies a required validator. * @@ -50,7 +116,7 @@ Decimal128.prototype.checkRequired = function checkRequired(value, doc) { if (SchemaType._isRef(this, value, doc, true)) { return !!value; } - return value instanceof Decimal128Type; + return this.constructor._checkRequired(value); }; /** @@ -104,35 +170,12 @@ Decimal128.prototype.cast = function(value, doc, init) { return ret; } - if (value == null) { - return value; + const _castDecimal128 = this.constructor.cast(); + try { + return _castDecimal128(value); + } catch (error) { + throw new CastError('Decimal128', value, this.path); } - - if (typeof value === 'object' && typeof value.$numberDecimal === 'string') { - return Decimal128Type.fromString(value.$numberDecimal); - } - - if (value instanceof Decimal128Type) { - return value; - } - - if (typeof value === 'string') { - return Decimal128Type.fromString(value); - } - - if (Buffer.isBuffer(value)) { - return new Decimal128Type(value); - } - - if (typeof value === 'number') { - return Decimal128Type.fromString(String(value)); - } - - if (typeof value.valueOf === 'function' && typeof value.valueOf() === 'string') { - return Decimal128Type.fromString(value.valueOf()); - } - - throw new CastError('Decimal128', value, this.path); }; /*! diff --git a/lib/schema/documentarray.js b/lib/schema/documentarray.js index 1a074f737e9..39e8f7447b1 100644 --- a/lib/schema/documentarray.js +++ b/lib/schema/documentarray.js @@ -82,6 +82,7 @@ function _createConstructor(schema, options) { EmbeddedDocument.schema = schema; EmbeddedDocument.prototype.constructor = EmbeddedDocument; EmbeddedDocument.$isArraySubdocument = true; + EmbeddedDocument.events = new EventEmitter(); // apply methods for (const i in schema.methods) { diff --git a/lib/schema/embedded.js b/lib/schema/embedded.js index 537ca444a17..ba837c98af4 100644 --- a/lib/schema/embedded.js +++ b/lib/schema/embedded.js @@ -78,6 +78,7 @@ function _createConstructor(schema) { _embedded.prototype.constructor = _embedded; _embedded.schema = schema; _embedded.$isSingleNested = true; + _embedded.events = new EventEmitter(); _embedded.prototype.toBSON = function() { return this.toObject(internalToObjectOptions); }; diff --git a/lib/schema/mixed.js b/lib/schema/mixed.js index 6f461d98790..672cc519167 100644 --- a/lib/schema/mixed.js +++ b/lib/schema/mixed.js @@ -50,6 +50,26 @@ Mixed.schemaName = 'Mixed'; Mixed.prototype = Object.create(SchemaType.prototype); Mixed.prototype.constructor = Mixed; +/** + * Attaches a getter for all Mixed paths. + * + * ####Example: + * + * // Hide the 'hidden' path + * mongoose.Schema.Mixed.get(v => Object.assign({}, v, { hidden: null })); + * + * const Model = mongoose.model('Test', new Schema({ test: {} })); + * new Model({ test: { hidden: 'Secret!' } }).test.hidden; // null + * + * @param {Function} getter + * @return {this} + * @function get + * @static + * @api public + */ + +Mixed.get = SchemaType.get; + /** * Casts `val` for Mixed. * diff --git a/lib/schema/number.js b/lib/schema/number.js index ada26f31b6b..49dc63119e4 100644 --- a/lib/schema/number.js +++ b/lib/schema/number.js @@ -26,6 +26,71 @@ function SchemaNumber(key, options) { SchemaType.call(this, key, options, 'Number'); } +/** + * Attaches a getter for all Number instances. + * + * ####Example: + * + * // Make all numbers round down + * mongoose.Number.get(function(v) { return Math.floor(v); }); + * + * const Model = mongoose.model('Test', new Schema({ test: Number })); + * new Model({ test: 3.14 }).test; // 3 + * + * @param {Function} getter + * @return {this} + * @function get + * @static + * @api public + */ + +SchemaNumber.get = SchemaType.get; + +/*! + * ignore + */ + +SchemaNumber._cast = castNumber; + +/** + * Get/set the function used to cast arbitrary values to numbers. + * + * ####Example: + * + * // Make Mongoose cast empty strings '' to 0 for paths declared as numbers + * const original = mongoose.Number.cast(); + * mongoose.Number.cast(v => { + * if (v === '') { return 0; } + * return original(v); + * }); + * + * // Or disable casting entirely + * mongoose.Number.cast(false); + * + * @param {Function} caster + * @return {Function} + * @function get + * @static + * @api public + */ + +SchemaNumber.cast = function cast(caster) { + if (arguments.length === 0) { + return this._cast; + } + if (caster === false) { + caster = v => { + if (typeof v !== 'number') { + throw new Error(); + } + return v; + }; + } + this._cast = caster; + + return this._cast; +}; + /** * This schema type's name, to defend against minifiers that mangle * function names. @@ -40,6 +105,25 @@ SchemaNumber.schemaName = 'Number'; SchemaNumber.prototype = Object.create(SchemaType.prototype); SchemaNumber.prototype.constructor = SchemaNumber; +/*! + * ignore + */ + +SchemaNumber._checkRequired = v => typeof v === 'number' || v instanceof Number; + +/** + * Override the function the required validator uses to check whether a string + * passes the `required` check. + * + * @param {Function} fn + * @return {Function} + * @function checkRequired + * @static + * @api public + */ + +SchemaNumber.checkRequired = SchemaType.checkRequired; + /** * Check if the given value satisfies a required validator. * @@ -53,7 +137,7 @@ SchemaNumber.prototype.checkRequired = function checkRequired(value, doc) { if (SchemaType._isRef(this, value, doc, true)) { return !!value; } - return typeof value === 'number' || value instanceof Number; + return this.constructor._checkRequired(value); }; /** @@ -211,7 +295,11 @@ SchemaNumber.prototype.cast = function(value, doc, init) { value._id : // documents value; - return castNumber(val, this.path); + try { + return this.constructor.cast()(val); + } catch (err) { + throw new CastError('number', val, this.path); + } }; /*! diff --git a/lib/schema/objectid.js b/lib/schema/objectid.js index 3637572c3aa..efe1eee7f71 100644 --- a/lib/schema/objectid.js +++ b/lib/schema/objectid.js @@ -4,6 +4,7 @@ 'use strict'; +const castObjectId = require('../cast/objectid'); const SchemaType = require('../schematype'); const oid = require('../types/objectid'); const utils = require('../utils'); @@ -47,6 +48,26 @@ ObjectId.schemaName = 'ObjectId'; ObjectId.prototype = Object.create(SchemaType.prototype); ObjectId.prototype.constructor = ObjectId; +/** + * Attaches a getter for all ObjectId instances + * + * ####Example: + * + * // Always convert to string when getting an ObjectId + * mongoose.ObjectId.get(v => v.toString()); + * + * const Model = mongoose.model('Test', new Schema({})); + * typeof (new Model({})._id); // 'string' + * + * @param {Function} getter + * @return {this} + * @function get + * @static + * @api public + */ + +ObjectId.get = SchemaType.get; + /** * Adds an auto-generated ObjectId default if turnOn is true. * @param {Boolean} turnOn auto generated ObjectId defaults @@ -63,6 +84,79 @@ ObjectId.prototype.auto = function(turnOn) { return this; }; +/*! + * ignore + */ + +ObjectId._checkRequired = v => v instanceof oid; + +/*! + * ignore + */ + +ObjectId._cast = castObjectId; + +/** + * Get/set the function used to cast arbitrary values to objectids. + * + * ####Example: + * + * // Make Mongoose only try to cast length 24 strings. By default, any 12 + * // char string is a valid ObjectId. + * const original = mongoose.ObjectId.cast(); + * mongoose.ObjectId.cast(v => { + * assert.ok(typeof v !== 'string' || v.length === 24); + * return original(v); + * }); + * + * // Or disable casting entirely + * mongoose.ObjectId.cast(false); + * + * @param {Function} caster + * @return {Function} + * @function get + * @static + * @api public + */ + +ObjectId.cast = function cast(caster) { + if (arguments.length === 0) { + return this._cast; + } + if (caster === false) { + caster = v => { + if (!(v instanceof oid)) { + throw new Error(); + } + return v; + }; + } + this._cast = caster; + + return this._cast; +}; + +/** + * Override the function the required validator uses to check whether a string + * passes the `required` check. + * + * ####Example: + * + * // Allow empty strings to pass `required` check + * mongoose.Schema.Types.String.checkRequired(v => v != null); + * + * const M = mongoose.model({ str: { type: String, required: true } }); + * new M({ str: '' }).validateSync(); // `null`, validation passes! + * + * @param {Function} fn + * @return {Function} + * @function checkRequired + * @static + * @api public + */ + +ObjectId.checkRequired = SchemaType.checkRequired; + /** * Check if the given value satisfies a required validator. * @@ -76,7 +170,7 @@ ObjectId.prototype.checkRequired = function checkRequired(value, doc) { if (SchemaType._isRef(this, value, doc, true)) { return !!value; } - return value instanceof oid; + return this.constructor._checkRequired(value); }; /** @@ -132,32 +226,11 @@ ObjectId.prototype.cast = function(value, doc, init) { return ret; } - if (value === null || value === undefined) { - return value; - } - - if (value instanceof oid) { - return value; + try { + return this.constructor.cast()(value); + } catch (error) { + throw new CastError('ObjectId', value, this.path); } - - if (value._id) { - if (value._id instanceof oid) { - return value._id; - } - if (value._id.toString instanceof Function) { - return new oid(value._id.toString()); - } - } - - if (value.toString instanceof Function) { - try { - return new oid(value.toString()); - } catch (err) { - throw new CastError('ObjectId', value, this.path); - } - } - - throw new CastError('ObjectId', value, this.path); }; /*! diff --git a/lib/schema/operators/helpers.js b/lib/schema/operators/helpers.js index 755f157e51f..a17951cd72e 100644 --- a/lib/schema/operators/helpers.js +++ b/lib/schema/operators/helpers.js @@ -4,9 +4,7 @@ * Module requirements. */ -const Types = { - Number: require('../number') -}; +const SchemaNumber = require('../number'); /*! * @ignore @@ -20,7 +18,7 @@ exports.castArraysOfNumbers = castArraysOfNumbers; */ function castToNumber(val) { - return Types.Number.prototype.cast.call(this, val); + return SchemaNumber.cast()(val); } function castArraysOfNumbers(arr, self) { diff --git a/lib/schema/string.js b/lib/schema/string.js index 91097d8782a..c2e48e4f8e1 100644 --- a/lib/schema/string.js +++ b/lib/schema/string.js @@ -41,6 +41,98 @@ SchemaString.schemaName = 'String'; SchemaString.prototype = Object.create(SchemaType.prototype); SchemaString.prototype.constructor = SchemaString; +/*! + * ignore + */ + +SchemaString._cast = castString; + +/** + * Get/set the function used to cast arbitrary values to objectids. + * + * ####Example: + * + * // Make Mongoose only try to cast strings + * const original = mongoose.ObjectId.cast(); + * mongoose.ObjectId.cast(v => { + * assert.ok(v == null || typeof v === 'string'); + * return original(v); + * }); + * + * // Or disable casting entirely + * mongoose.ObjectId.cast(false); + * + * @param {Function} caster + * @return {Function} + * @function get + * @static + * @api public + */ + +SchemaString.cast = function cast(caster) { + if (arguments.length === 0) { + return this._cast; + } + if (caster === false) { + caster = v => { + if (v != null && typeof v !== 'string') { + throw new Error(); + } + return v; + }; + } + this._cast = caster; + + return this._cast; +}; + +/** + * Attaches a getter for all String instances. + * + * ####Example: + * + * // Make all numbers round down + * mongoose.Schema.String.get(v => v.toLowerCase()); + * + * const Model = mongoose.model('Test', new Schema({ test: String })); + * new Model({ test: 'FOO' }).test; // 'foo' + * + * @param {Function} getter + * @return {this} + * @function get + * @static + * @api public + */ + +SchemaString.get = SchemaType.get; + +/*! + * ignore + */ + +SchemaString._checkRequired = v => (v instanceof String || typeof v === 'string') && v.length; + +/** + * Override the function the required validator uses to check whether a string + * passes the `required` check. + * + * ####Example: + * + * // Allow empty strings to pass `required` check + * mongoose.Schema.Types.String.checkRequired(v => v != null); + * + * const M = mongoose.model({ str: { type: String, required: true } }); + * new M({ str: '' }).validateSync(); // `null`, validation passes! + * + * @param {Function} fn + * @return {Function} + * @function checkRequired + * @static + * @api public + */ + +SchemaString.checkRequired = SchemaType.checkRequired; + /** * Adds an enum validator * @@ -399,7 +491,7 @@ SchemaString.prototype.checkRequired = function checkRequired(value, doc) { if (SchemaType._isRef(this, value, doc, true)) { return !!value; } - return (value instanceof String || typeof value === 'string') && value.length; + return this.constructor._checkRequired(value, doc); }; /** @@ -442,7 +534,11 @@ SchemaString.prototype.cast = function(value, doc, init) { return ret; } - return castString(value, this.path); + try { + return this.constructor.cast()(value); + } catch (error) { + throw new CastError('string', value, this.path); + } }; /*! diff --git a/lib/schematype.js b/lib/schematype.js index 29ad9043e64..66cb82e4833 100644 --- a/lib/schematype.js +++ b/lib/schematype.js @@ -34,8 +34,10 @@ function SchemaType(path, options, instance) { this.path = path; this.instance = instance; this.validators = []; + this.getters = this.constructor.hasOwnProperty('getters') ? + this.constructor.getters.slice() : + []; this.setters = []; - this.getters = []; this.options = options; this._index = null; this.selected; @@ -68,6 +70,26 @@ function SchemaType(path, options, instance) { }); } +/** + * Attaches a getter for all instances of this schema type. + * + * ####Example: + * + * // Make all numbers round down + * mongoose.Number.get(function(v) { return Math.floor(v); }); + * + * @param {Function} getter + * @return {this} + * @function get + * @static + * @api public + */ + +SchemaType.get = function(getter) { + this.getters = this.hasOwnProperty('getters') ? this.getters : []; + this.getters.push(getter); +}; + /** * Sets a default value for this SchemaType. * @@ -1193,6 +1215,25 @@ SchemaType.prototype._castForQuery = function(val) { return this.applySetters(val, this.$$context); }; +/** + * Override the function the required validator uses to check whether a string + * passes the `required` check. + * + * @param {Function} fn + * @return {Function} + * @function checkRequired + * @static + * @api public + */ + +SchemaType.checkRequired = function(fn) { + if (arguments.length > 0) { + this._checkRequired = fn; + } + + return this._checkRequired; +}; + /** * Default check for if this path satisfies the `required` validator. * diff --git a/lib/types/documentarray.js b/lib/types/documentarray.js index 09b227c1b76..3c90b64e8b1 100644 --- a/lib/types/documentarray.js +++ b/lib/types/documentarray.js @@ -4,15 +4,15 @@ * Module dependencies. */ +const Document = require('../document'); const MongooseArray = require('./array'); const ObjectId = require('./objectid'); -const ObjectIdSchema = require('../schema/objectid'); +const castObjectId = require('../cast/objectid'); const get = require('../helpers/get'); -const internalToObjectOptions = require('../options').internalToObjectOptions; -const utils = require('../utils'); -const Document = require('../document'); const getDiscriminatorByValue = require('../queryhelpers').getDiscriminatorByValue; +const internalToObjectOptions = require('../options').internalToObjectOptions; const util = require('util'); +const utils = require('../utils'); const documentArrayParent = require('../helpers/symbols').documentArrayParent; @@ -200,15 +200,12 @@ MongooseDocumentArray.mixin = { */ id: function(id) { - let casted, - sid, - _id; + let casted; + let sid; + let _id; try { - const casted_ = ObjectIdSchema.prototype.cast.call({}, id); - if (casted_) { - casted = String(casted_); - } + casted = castObjectId(id).toString(); } catch (e) { casted = null; } diff --git a/lib/utils.js b/lib/utils.js index b951522804b..d0ba37a15e0 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -14,6 +14,8 @@ const mpath = require('mpath'); const ms = require('ms'); const Buffer = require('safe-buffer').Buffer; +const emittedSymbol = Symbol.for('mongoose:emitted'); + let MongooseBuffer; let MongooseArray; let Document; @@ -233,15 +235,25 @@ const clone = exports.clone; * ignore */ -exports.promiseOrCallback = function promiseOrCallback(callback, fn) { +exports.promiseOrCallback = function promiseOrCallback(callback, fn, ee) { if (typeof callback === 'function') { - try { - return fn(callback); - } catch (error) { - return process.nextTick(() => { - throw error; - }); - } + return fn(function(error) { + if (error != null) { + if (ee != null && ee.listeners('error').length > 0 && !error[emittedSymbol]) { + error[emittedSymbol] = true; + ee.emit('error', error); + } + try { + callback(error); + } catch (error) { + return process.nextTick(() => { + throw error; + }); + } + return; + } + callback.apply(this, arguments); + }); } const Promise = PromiseProvider.get(); @@ -249,6 +261,10 @@ exports.promiseOrCallback = function promiseOrCallback(callback, fn) { return new Promise((resolve, reject) => { fn(function(error, res) { if (error != null) { + if (ee.listeners('error').length > 0 && !error[emittedSymbol]) { + error[emittedSymbol] = true; + ee.emit('error', error); + } return reject(error); } if (arguments.length > 2) { @@ -541,17 +557,20 @@ exports.expires = function expires(object) { * Populate options constructor */ -function PopulateOptions(path, select, match, options, model, subPopulate, justOne) { - this.path = path; - this.match = match; - this.select = select; - this.options = options; - this.model = model; - if (typeof subPopulate === 'object') { - this.populate = subPopulate; +function PopulateOptions(obj) { + this.path = obj.path; + this.match = obj.match; + this.select = obj.select; + this.options = obj.options; + this.model = obj.model; + if (typeof obj.subPopulate === 'object') { + this.populate = obj.subPopulate; + } + if (obj.justOne != null) { + this.justOne = obj.justOne; } - if (justOne != null) { - this.justOne = justOne; + if (obj.count != null) { + this.count = obj.count; } this._docs = {}; } @@ -566,7 +585,7 @@ exports.PopulateOptions = PopulateOptions; * populate helper */ -exports.populate = function populate(path, select, model, match, options, subPopulate, justOne) { +exports.populate = function populate(path, select, model, match, options, subPopulate, justOne, count) { // The order of select/conditions args is opposite Model.find but // necessary to keep backward compatibility (select could be // an array, string, or object literal). @@ -598,14 +617,7 @@ exports.populate = function populate(path, select, model, match, options, subPop const singles = makeSingles(path); return singles.map(function(o) { if (o.populate && !(o.match || o.options)) { - return exports.populate( - o.path, - o.select, - o.model, - o.match, - o.options, - o.populate, - o.justOne)[0]; + return exports.populate(o)[0]; } else { return exports.populate(o)[0]; } @@ -620,6 +632,7 @@ exports.populate = function populate(path, select, model, match, options, subPop subPopulate = path.populate; justOne = path.justOne; path = path.path; + count = path.count; } } else if (typeof model === 'object') { options = match; @@ -654,7 +667,16 @@ exports.populate = function populate(path, select, model, match, options, subPop const paths = path.split(' '); options = exports.clone(options); for (let i = 0; i < paths.length; ++i) { - ret.push(new PopulateOptions(paths[i], select, match, options, model, subPopulate, justOne)); + ret.push(new PopulateOptions({ + path: paths[i], + select: select, + match: match, + options: options, + model: model, + subPopulate: subPopulate, + justOne: justOne, + count: count + })); } return ret; diff --git a/test/aggregate.test.js b/test/aggregate.test.js index a59f920f171..7dd9bde10a6 100644 --- a/test/aggregate.test.js +++ b/test/aggregate.test.js @@ -1132,6 +1132,17 @@ describe('aggregate: ', function() { }); }); + it('catch() (gh-7267)', function() { + const MyModel = db.model('gh7267', {}); + + return co(function * () { + const err = yield MyModel.aggregate([{ $group: { foo: 'bar' } }]). + catch(err => err); + assert.ok(err instanceof Error); + assert.equal(err.name, 'MongoError'); + }); + }); + it('cursor() without options (gh-3855)', function(done) { const db = start(); diff --git a/test/document.test.js b/test/document.test.js index e67c6033087..4073d7c2464 100644 --- a/test/document.test.js +++ b/test/document.test.js @@ -6485,6 +6485,44 @@ describe('document', function() { }); }); + it('updateOne() hooks (gh-7133)', function() { + const schema = new mongoose.Schema({ name: String }); + + let queryCount = 0; + let docCount = 0; + + schema.pre('updateOne', () => ++queryCount); + schema.pre('updateOne', { document: true, query: false }, () => ++docCount); + + let removeCount1 = 0; + let removeCount2 = 0; + schema.pre('remove', () => ++removeCount1); + schema.pre('remove', { document: true, query: false }, () => ++removeCount2); + + const Model = db.model('gh7133', schema); + + return co(function*() { + const doc = new Model({ name: 'test' }); + yield doc.save(); + + assert.equal(queryCount, 0); + assert.equal(docCount, 0); + + yield doc.updateOne({ name: 'test2' }); + + assert.equal(queryCount, 1); + assert.equal(docCount, 1); + + assert.equal(removeCount1, 0); + assert.equal(removeCount2, 0); + + yield doc.remove(); + + assert.equal(removeCount1, 1); + assert.equal(removeCount2, 1); + }); + }); + it('doesnt mark single nested doc date as modified if setting with string (gh-7264)', function() { const subSchema = new mongoose.Schema({ date2: Date diff --git a/test/model.findOneAndReplace.test.js b/test/model.findOneAndReplace.test.js new file mode 100644 index 00000000000..6b775d13e8d --- /dev/null +++ b/test/model.findOneAndReplace.test.js @@ -0,0 +1,423 @@ +'use strict'; + +/** + * Test dependencies. + */ + +const assert = require('assert'); +const co = require('co'); +const start = require('./common'); +const random = require('../lib/utils').random; + +const mongoose = start.mongoose; +const Schema = mongoose.Schema; +const ObjectId = Schema.Types.ObjectId; +const DocumentObjectId = mongoose.Types.ObjectId; + +describe('model: findOneAndReplace:', function() { + let Comments; + let BlogPost; + let modelname; + let collection; + let strictSchema; + let db; + + before(function() { + Comments = new Schema; + + Comments.add({ + title: String, + date: Date, + body: String, + comments: [Comments] + }); + + BlogPost = new Schema({ + title: String, + author: String, + slug: String, + date: Date, + meta: { + date: Date, + visitors: Number + }, + published: Boolean, + mixed: {}, + numbers: [Number], + owners: [ObjectId], + comments: [Comments] + }); + + BlogPost.virtual('titleWithAuthor') + .get(function() { + return this.get('title') + ' by ' + this.get('author'); + }) + .set(function(val) { + const split = val.split(' by '); + this.set('title', split[0]); + this.set('author', split[1]); + }); + + BlogPost.method('cool', function() { + return this; + }); + + BlogPost.static('woot', function() { + return this; + }); + + modelname = 'ReplaceOneBlogPost'; + mongoose.model(modelname, BlogPost); + + collection = 'replaceoneblogposts'; + + strictSchema = new Schema({name: String}, {strict: true}); + mongoose.model('ReplaceOneStrictSchema', strictSchema); + + db = start(); + }); + + after(function(done) { + db.close(done); + }); + + it('returns the original document', function() { + const M = db.model(modelname, collection); + const title = 'remove muah'; + + const post = new M({title: title}); + + return co(function*() { + yield post.save(); + + const doc = yield M.findOneAndReplace({title: title}); + + assert.equal(post.id, doc.id); + }); + }); + + it('options/conditions/doc are merged when no callback is passed', function(done) { + const M = db.model(modelname, collection); + + const now = new Date; + let query; + + // Model.findOneAndReplace + query = M.findOneAndReplace({author: 'aaron'}, {select: 'author'}); + assert.equal(query._fields.author, 1); + assert.equal(query._conditions.author, 'aaron'); + + query = M.findOneAndReplace({author: 'aaron'}); + assert.equal(query._fields, undefined); + assert.equal(query._conditions.author, 'aaron'); + + query = M.findOneAndReplace(); + assert.equal(query.options.new, undefined); + assert.equal(query._fields, undefined); + assert.equal(query._conditions.author, undefined); + + // Query.findOneAndReplace + query = M.where('author', 'aaron').findOneAndReplace({date: now}); + assert.equal(query._fields, undefined); + assert.equal(query._conditions.date, now); + assert.equal(query._conditions.author, 'aaron'); + + query = M.find().findOneAndReplace({author: 'aaron'}, {select: 'author'}); + assert.equal(query._fields.author, 1); + assert.equal(query._conditions.author, 'aaron'); + + query = M.find().findOneAndReplace(); + assert.equal(query._fields, undefined); + assert.equal(query._conditions.author, undefined); + done(); + }); + + it('executes when a callback is passed', function(done) { + const M = db.model(modelname, collection + random()); + let pending = 5; + + M.findOneAndReplace({name: 'aaron1'}, {select: 'name'}, cb); + M.findOneAndReplace({name: 'aaron1'}, cb); + M.where().findOneAndReplace({name: 'aaron1'}, {select: 'name'}, cb); + M.where().findOneAndReplace({name: 'aaron1'}, cb); + M.where('name', 'aaron1').findOneAndReplace(cb); + + function cb(err, doc) { + assert.ifError(err); + assert.equal(doc, null); // no previously existing doc + if (--pending) return; + done(); + } + }); + + it('executed with only a callback throws', function(done) { + const M = db.model(modelname, collection); + let err; + + try { + M.findOneAndReplace(function() {}); + } catch (e) { + err = e; + } + + assert.ok(/First argument must not be a function/.test(err)); + done(); + }); + + it('executed with only a callback throws', function(done) { + const M = db.model(modelname, collection); + let err; + + try { + M.findByIdAndDelete(function() {}); + } catch (e) { + err = e; + } + + assert.ok(/First argument must not be a function/.test(err)); + done(); + }); + + it('executes when a callback is passed', function(done) { + const M = db.model(modelname, collection + random()); + const _id = new DocumentObjectId; + let pending = 2; + + M.findByIdAndDelete(_id, {select: 'name'}, cb); + M.findByIdAndDelete(_id, cb); + + function cb(err, doc) { + assert.ifError(err); + assert.equal(doc, null); // no previously existing doc + if (--pending) return; + done(); + } + }); + + it('returns the original document', function(done) { + const M = db.model(modelname, collection); + const title = 'remove muah pleez'; + + const post = new M({title: title}); + post.save(function(err) { + assert.ifError(err); + M.findByIdAndDelete(post.id, function(err, doc) { + assert.ifError(err); + assert.equal(post.id, doc.id); + M.findById(post.id, function(err, gone) { + assert.ifError(err); + assert.equal(gone, null); + done(); + }); + }); + }); + }); + + it('options/conditions/doc are merged when no callback is passed', function(done) { + const M = db.model(modelname, collection); + const _id = new DocumentObjectId(); + + let query; + + // Model.findByIdAndDelete + query = M.findByIdAndDelete(_id, {select: 'author'}); + assert.equal(query._fields.author, 1); + assert.equal(query._conditions._id.toString(), _id.toString()); + + query = M.findByIdAndDelete(_id.toString()); + assert.equal(query._fields, undefined); + assert.equal(query._conditions._id, _id.toString()); + + query = M.findByIdAndDelete(); + assert.equal(query.options.new, undefined); + assert.equal(query._fields, undefined); + assert.equal(query._conditions._id, undefined); + done(); + }); + + it('supports v3 select string syntax', function(done) { + const M = db.model(modelname, collection); + const _id = new DocumentObjectId(); + + let query; + + query = M.findByIdAndDelete(_id, {select: 'author -title'}); + assert.strictEqual(1, query._fields.author); + assert.strictEqual(0, query._fields.title); + + query = M.findOneAndReplace({}, {select: 'author -title'}); + assert.strictEqual(1, query._fields.author); + assert.strictEqual(0, query._fields.title); + done(); + }); + + it('supports v3 select object syntax', function(done) { + const M = db.model(modelname, collection); + const _id = new DocumentObjectId; + + let query; + + query = M.findByIdAndDelete(_id, {select: {author: 1, title: 0}}); + assert.strictEqual(1, query._fields.author); + assert.strictEqual(0, query._fields.title); + + query = M.findOneAndReplace({}, {select: {author: 1, title: 0}}); + assert.strictEqual(1, query._fields.author); + assert.strictEqual(0, query._fields.title); + done(); + }); + + it('supports v3 sort string syntax', function(done) { + const M = db.model(modelname, collection); + const _id = new DocumentObjectId(); + + let query; + + query = M.findByIdAndDelete(_id, {sort: 'author -title'}); + assert.equal(Object.keys(query.options.sort).length, 2); + assert.equal(query.options.sort.author, 1); + assert.equal(query.options.sort.title, -1); + + query = M.findOneAndReplace({}, {sort: 'author -title'}); + assert.equal(Object.keys(query.options.sort).length, 2); + assert.equal(query.options.sort.author, 1); + assert.equal(query.options.sort.title, -1); + done(); + }); + + it('supports v3 sort object syntax', function(done) { + const M = db.model(modelname, collection); + const _id = new DocumentObjectId(); + + let query; + + query = M.findByIdAndDelete(_id, {sort: {author: 1, title: -1}}); + assert.equal(Object.keys(query.options.sort).length, 2); + assert.equal(query.options.sort.author, 1); + assert.equal(query.options.sort.title, -1); + + query = M.findOneAndReplace(_id, {sort: {author: 1, title: -1}}); + assert.equal(Object.keys(query.options.sort).length, 2); + assert.equal(query.options.sort.author, 1); + assert.equal(query.options.sort.title, -1); + done(); + }); + + it('supports population (gh-1395)', function(done) { + const M = db.model('A', {name: String}); + const N = db.model('B', {a: {type: Schema.ObjectId, ref: 'A'}, i: Number}); + + M.create({name: 'i am an A'}, function(err, a) { + if (err) return done(err); + N.create({a: a._id, i: 10}, function(err, b) { + if (err) return done(err); + + N.findOneAndReplace({_id: b._id}, {a: a._id}) + .populate('a') + .exec(function(err, doc) { + if (err) return done(err); + assert.ok(doc); + assert.ok(doc.a); + assert.equal(doc.a.name, 'i am an A'); + done(); + }); + }); + }); + }); + + it('only calls setters once (gh-6203)', function() { + return co(function*() { + const calls = []; + const userSchema = new mongoose.Schema({ + name: String, + foo: { + type: String, + set: function(val) { + calls.push(val); + return val + val; + } + } + }); + const Model = db.model('gh6203', userSchema); + + yield Model.findOneAndReplace({ foo: '123' }, { name: 'bar' }); + + assert.deepEqual(calls, ['123']); + }); + }); + + describe('middleware', function() { + it('works', function(done) { + const s = new Schema({ + topping: {type: String, default: 'bacon'}, + base: String + }); + + let preCount = 0; + s.pre('findOneAndReplace', function() { + ++preCount; + }); + + let postCount = 0; + s.post('findOneAndReplace', function() { + ++postCount; + }); + + const Breakfast = db.model('gh-439', s); + const breakfast = new Breakfast({ + base: 'eggs' + }); + + breakfast.save(function(error) { + assert.ifError(error); + + Breakfast.findOneAndReplace( + {base: 'eggs'}, + {}, + function(error, breakfast) { + assert.ifError(error); + assert.equal(breakfast.base, 'eggs'); + assert.equal(preCount, 1); + assert.equal(postCount, 1); + done(); + }); + }); + }); + + it('works with exec() (gh-439)', function(done) { + const s = new Schema({ + topping: {type: String, default: 'bacon'}, + base: String + }); + + let preCount = 0; + s.pre('findOneAndReplace', function() { + ++preCount; + }); + + let postCount = 0; + s.post('findOneAndReplace', function() { + ++postCount; + }); + + const Breakfast = db.model('Breakfast', s); + const breakfast = new Breakfast({ + base: 'eggs' + }); + + breakfast.save(function(error) { + assert.ifError(error); + + Breakfast. + findOneAndReplace({base: 'eggs'}, {}). + exec(function(error, breakfast) { + assert.ifError(error); + assert.equal(breakfast.base, 'eggs'); + assert.equal(preCount, 1); + assert.equal(postCount, 1); + done(); + }); + }); + }); + }); +}); diff --git a/test/model.populate.test.js b/test/model.populate.test.js index c77b1532e9f..207ae14f919 100644 --- a/test/model.populate.test.js +++ b/test/model.populate.test.js @@ -7940,7 +7940,47 @@ describe('model: populate:', function() { }); }); - it('explicit option overrides refPath (gh-7273)', function() { + it('count option (gh-4469)', function() { + const childSchema = new Schema({ parentId: mongoose.ObjectId }); + + const parentSchema = new Schema({ name: String }); + parentSchema.virtual('childCount', { + ref: 'gh4469_Child', + localField: '_id', + foreignField: 'parentId', + count: true + }); + + parentSchema.virtual('children', { + ref: 'gh4469_Child', + localField: '_id', + foreignField: 'parentId', + count: false + }); + + const Child = db.model('gh4469_Child', childSchema); + const Parent = db.model('gh4469_Parent', parentSchema); + + return co(function*() { + const p = yield Parent.create({ name: 'test' }); + + yield Child.create([{ parentId: p._id }, { parentId: p._id }, {}]); + + let doc = yield Parent.findOne().populate('children childCount'); + assert.equal(doc.childCount, 2); + assert.equal(doc.children.length, 2); + + doc = yield Parent.find().populate('children childCount').then(res => res[0]); + assert.equal(doc.childCount, 2); + assert.equal(doc.children.length, 2); + + doc = yield Parent.find().populate('childCount').then(res => res[0]); + assert.equal(doc.childCount, 2); + assert.equal(doc.children, null); + }); + }); + + it('explicit model option overrides refPath (gh-7273)', function() { const userSchema = new Schema({ name: String }); const User1 = db.model('gh7273_User_1', userSchema); db.model('gh7273_User_2', userSchema); @@ -7967,6 +8007,37 @@ describe('model: populate:', function() { }); }); + it('clone option means identical ids get separate copies of doc (gh-3258)', function() { + const userSchema = new Schema({ name: String }); + const User = db.model('gh3258_User', userSchema); + + const postSchema = new Schema({ + user: { + type: mongoose.ObjectId, + ref: User + }, + title: String + }); + + const Post = db.model('gh3258_Post', postSchema); + + return co(function*() { + const user = yield User.create({ name: 'val' }); + yield Post.create([ + { title: 'test1', user: user }, + { title: 'test2', user: user } + ]); + + const posts = yield Post.find().populate({ + path: 'user', + options: { clone: true } + }); + + posts[0].user.name = 'val2'; + assert.equal(posts[1].user.name, 'val'); + }); + }); + it('populate single path with numeric path underneath doc array (gh-7273)', function() { const schema = new Schema({ arr1: [{ diff --git a/test/model.test.js b/test/model.test.js index 000b4c2c6b2..0484b624bab 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -6143,4 +6143,32 @@ describe('Model', function() { const _schema = JSON.parse(JSON.stringify(Model.schema)); assert.ok(_schema.obj.nested); }); + + it('Model.events() (gh-7125)', function() { + const Model = db.model('gh7125', Schema({ + name: { type: String, validate: () => false } + })); + + let called = []; + Model.events.on('error', err => { called.push(err); }); + + return co(function*() { + yield Model.findOne({ _id: 'notanid' }).catch(() => {}); + assert.equal(called.length, 1); + assert.equal(called[0].name, 'CastError'); + + called = []; + + const doc = new Model({ name: 'fail' }); + yield doc.save().catch(() => {}); + assert.equal(called.length, 1); + assert.equal(called[0].name, 'ValidationError'); + + called = []; + + yield Model.aggregate([{ $group: { fail: true } }]).exec().catch(() => {}); + assert.equal(called.length, 1); + assert.equal(called[0].name, 'MongoError'); + }); + }); }); diff --git a/test/query.middleware.test.js b/test/query.middleware.test.js index f232451902a..60e994130a4 100644 --- a/test/query.middleware.test.js +++ b/test/query.middleware.test.js @@ -1,6 +1,9 @@ 'use strict'; + const start = require('./common'); const assert = require('power-assert'); +const co = require('co'); + const mongoose = start.mongoose; const Schema = mongoose.Schema; @@ -371,6 +374,58 @@ describe('query middleware', function() { }); }); + it('deleteOne() (gh-7195)', function() { + let preCount = 0; + let postCount = 0; + + schema.pre('deleteOne', function() { + ++preCount; + }); + + schema.post('deleteOne', function() { + ++postCount; + }); + + return co(function*() { + const Model = db.model('gh7195_deleteOne', schema); + yield Model.create([{ title: 'foo' }, { title: 'bar' }]); + + yield Model.deleteOne(); + + assert.equal(preCount, 1); + assert.equal(postCount, 1); + + const count = yield Model.countDocuments(); + assert.equal(count, 1); + }); + }); + + it('deleteMany() (gh-7195)', function() { + let preCount = 0; + let postCount = 0; + + schema.pre('deleteMany', function() { + ++preCount; + }); + + schema.post('deleteMany', function() { + ++postCount; + }); + + return co(function*() { + const Model = db.model('gh7195_deleteMany', schema); + yield Model.create([{ title: 'foo' }, { title: 'bar' }]); + + yield Model.deleteMany(); + + assert.equal(preCount, 1); + assert.equal(postCount, 1); + + const count = yield Model.countDocuments(); + assert.equal(count, 0); + }); + }); + it('error handlers (gh-2284)', function(done) { const testSchema = new Schema({ title: { type: String, unique: true } }); diff --git a/test/query.test.js b/test/query.test.js index bd1563d8a1a..a509f89b732 100644 --- a/test/query.test.js +++ b/test/query.test.js @@ -2773,6 +2773,21 @@ describe('Query', function() { }); }); + it('map (gh-7142)', function() { + const Model = db.model('gh7142', new Schema({ name: String })); + + return co(function*() { + yield Model.create({ name: 'test' }); + const now = new Date(); + const res = yield Model.findOne().map(res => { + res.loadedAt = now; + return res; + }); + + assert.equal(res.loadedAt, now); + }); + }); + describe('orFail (gh-6841)', function() { let Model; @@ -3282,4 +3297,18 @@ describe('Query', function() { assert.equal(doc.hasDefault, 'success'); }); }); + + it('maxTimeMS() (gh-7254)', function() { + const Model = db.model('gh7254', new Schema({})); + + return co(function*() { + yield Model.create({}); + + const res = yield Model.find({ $where: 'sleep(1000) || true' }). + maxTimeMS(10). + then(() => null, err => err); + assert.ok(res); + assert.ok(res.message.indexOf('time limit') !== -1, res.message); + }); + }); }); diff --git a/test/schema.test.js b/test/schema.test.js index 353d4cdb793..5d5f85ef1aa 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -6,7 +6,7 @@ const start = require('./common'); const mongoose = start.mongoose; -const assert = require('power-assert'); +const assert = require('assert'); const Schema = mongoose.Schema; const Document = mongoose.Document; const VirtualType = mongoose.VirtualType; @@ -1368,6 +1368,17 @@ describe('schema', function() { }); }); }); + + it('array of of schemas and objects (gh-7218)', function(done) { + const baseSchema = new Schema({ created: Date }, { id: true }); + const s = new Schema([baseSchema, { name: String }], { id: false }); + + assert.ok(s.path('created')); + assert.ok(s.path('name')); + assert.ok(!s.options.id); + + done(); + }); }); describe('property names', function() { diff --git a/test/schematype.cast.test.js b/test/schematype.cast.test.js new file mode 100644 index 00000000000..2007b52739a --- /dev/null +++ b/test/schematype.cast.test.js @@ -0,0 +1,194 @@ +'use strict'; + +const ObjectId = require('bson').ObjectId; +const Schema = require('../lib/schema'); +const assert = require('assert'); + +describe('SchemaType.cast() (gh-7045)', function() { + const original = {}; + + beforeEach(function() { + original.objectid = Schema.ObjectId.cast(); + original.boolean = Schema.Types.Boolean.cast(); + original.string = Schema.Types.String.cast(); + original.date = Schema.Types.Date.cast(); + original.decimal128 = Schema.Types.Decimal128.cast(); + }); + + afterEach(function() { + Schema.ObjectId.cast(original.objectid); + Schema.Types.Boolean.cast(original.boolean); + Schema.Types.String.cast(original.string); + Schema.Types.Date.cast(original.date); + Schema.Types.Decimal128.cast(original.decimal128); + }); + + it('with inheritance', function() { + class CustomObjectId extends Schema.ObjectId {} + + CustomObjectId.cast(v => { + assert.ok(v == null || (typeof v === 'string' && v.length === 24)); + return original.objectid(v); + }); + + const objectid = new CustomObjectId('test', { suppressWarning: true }); + const baseObjectId = new Schema.ObjectId('test', { suppressWarning: true }); + + let threw = false; + try { + objectid.cast('12charstring'); + } catch (error) { + threw = true; + assert.equal(error.name, 'CastError'); + } + + objectid.cast('000000000000000000000000'); // Should not throw + + // Base objectid shouldn't throw + baseObjectId.cast('12charstring'); + baseObjectId.cast('000000000000000000000000'); + + assert.ok(threw); + }); + + it('handles objectid', function() { + Schema.ObjectId.cast(v => { + assert.ok(v == null || typeof v === 'string'); + return original.objectid(v); + }); + + const objectid = new Schema.ObjectId('test', { suppressWarning: true }); + + let threw = false; + try { + objectid.cast({ toString: () => '000000000000000000000000' }); + } catch (error) { + threw = true; + assert.equal(error.name, 'CastError'); + } + assert.ok(threw); + }); + + it('handles disabling casting', function() { + Schema.ObjectId.cast(false); + + const objectid = new Schema.ObjectId('test', { suppressWarning: true }); + + let threw = false; + try { + objectid.cast('000000000000000000000000'); + } catch (error) { + threw = true; + assert.equal(error.name, 'CastError'); + } + assert.ok(threw); + + objectid.cast(new ObjectId()); // Should not throw + }); + + it('handles boolean', function() { + Schema.ObjectId.cast(v => { + assert.ok(v == null || typeof v === 'string'); + return original.objectid(v); + }); + + const objectid = new Schema.ObjectId('test', { suppressWarning: true }); + + let threw = false; + try { + objectid.cast(123); + } catch (error) { + threw = true; + assert.equal(error.name, 'CastError'); + } + assert.ok(threw); + }); + + it('handles disabling casting', function() { + Schema.Types.Boolean.cast(false); + + const b = new Schema.Types.Boolean(); + + let threw = false; + try { + b.cast(1); + } catch (error) { + threw = true; + assert.equal(error.name, 'CastError'); + } + assert.ok(threw); + + b.cast(true); // Should not throw + }); + + describe('string', function() { + it('supports custom cast functions', function() { + Schema.Types.String.cast(v => { + assert.ok(v.length < 10); + return original.string(v); + }); + + const s = new Schema.Types.String(); + s.cast('short'); // Should not throw + + assert.throws(() => s.cast('wayyyy too long'), /CastError/); + }); + + it('supports disabling casting', function() { + Schema.Types.String.cast(false); + + const s = new Schema.Types.String(); + s.cast('short'); // Should not throw + + assert.throws(() => s.cast(123), /CastError/); + }); + }); + + describe('date', function() { + it('supports custom cast functions', function() { + Schema.Types.Date.cast(v => { + assert.ok(v !== ''); + return original.date(v); + }); + + const d = new Schema.Types.Date(); + d.cast('2018-06-01'); // Should not throw + d.cast(new Date()); // Should not throw + + assert.throws(() => d.cast(''), /CastError/); + }); + + it('supports disabling casting', function() { + Schema.Types.Date.cast(false); + + const d = new Schema.Types.Date(); + d.cast(new Date()); // Should not throw + + assert.throws(() => d.cast('2018-06-01'), /CastError/); + }); + }); + + describe('decimal128', function() { + it('supports custom cast functions', function() { + Schema.Types.Decimal128.cast(v => { + assert.ok(typeof v !== 'number'); + return original.date(v); + }); + + const d = new Schema.Types.Decimal128(); + d.cast('1000'); // Should not throw + + assert.throws(() => d.cast(1000), /CastError/); + }); + + it('supports disabling casting', function() { + Schema.Types.Decimal128.cast(false); + + const d = new Schema.Types.Decimal128(); + assert.throws(() => d.cast('1000'), /CastError/); + assert.throws(() => d.cast(1000), /CastError/); + + d.cast(original.decimal128('1000')); // Should not throw + }); + }); +}); \ No newline at end of file diff --git a/test/types.number.test.js b/test/types.number.test.js index 49f33177822..3ade9579897 100644 --- a/test/types.number.test.js +++ b/test/types.number.test.js @@ -121,4 +121,51 @@ describe('types.number', function() { } assert.ok(/CastError/.test(err)); }); + + describe('custom caster (gh-7045)', function() { + let original = {}; + + beforeEach(function() { + original = SchemaNumber.cast(); + }); + + afterEach(function() { + SchemaNumber.cast(original); + }); + + it('disallow empty string', function() { + SchemaNumber.cast(v => { + assert.ok(v !== ''); + return original(v); + }); + + const num = new SchemaNumber(); + + let err; + try { + num.cast(''); + } catch (e) { + err = e; + } + assert.ok(/CastError/.test(err)); + + num.cast('123'); // Should be ok + }); + + it('disable casting', function() { + SchemaNumber.cast(false); + + const num = new SchemaNumber(); + + let err; + try { + num.cast('123'); + } catch (e) { + err = e; + } + assert.ok(/CastError/.test(err)); + + num.cast(123); // Should be ok + }); + }); });