diff --git a/lib/error/validation.js b/lib/error/validation.js index 5ca993a341d..a3a830825bc 100644 --- a/lib/error/validation.js +++ b/lib/error/validation.js @@ -52,6 +52,15 @@ class ValidationError extends MongooseError { * add message */ addError(path, error) { + if (error instanceof ValidationError) { + const { errors } = error; + for (const errorPath of Object.keys(errors)) { + this.addError(`${path}.${errorPath}`, errors[errorPath]); + } + + return; + } + this.errors[path] = error; this.message = this._message + ': ' + _generateMessage(this); } diff --git a/lib/helpers/model/pushNestedArrayPaths.js b/lib/helpers/model/pushNestedArrayPaths.js new file mode 100644 index 00000000000..7f234faa213 --- /dev/null +++ b/lib/helpers/model/pushNestedArrayPaths.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = function pushNestedArrayPaths(paths, nestedArray, path) { + if (nestedArray == null) { + return; + } + + for (let i = 0; i < nestedArray.length; ++i) { + if (Array.isArray(nestedArray[i])) { + pushNestedArrayPaths(paths, nestedArray[i], path + '.' + i); + } else { + paths.push(path + '.' + i); + } + } +}; diff --git a/lib/model.js b/lib/model.js index 9de649739e4..798f71949c2 100644 --- a/lib/model.js +++ b/lib/model.js @@ -56,6 +56,7 @@ const leanPopulateMap = require('./helpers/populate/leanPopulateMap'); const modifiedPaths = require('./helpers/update/modifiedPaths'); const parallelLimit = require('./helpers/parallelLimit'); const prepareDiscriminatorPipeline = require('./helpers/aggregate/prepareDiscriminatorPipeline'); +const pushNestedArrayPaths = require('./helpers/model/pushNestedArrayPaths'); const removeDeselectedForeignField = require('./helpers/populate/removeDeselectedForeignField'); const setDottedPath = require('./helpers/path/setDottedPath'); const util = require('util'); @@ -3681,6 +3682,96 @@ Model.applyDefaults = function applyDefaults(doc) { return doc; }; +/** + * Cast the given POJO to the model's schema + * + * #### Example: + * const Test = mongoose.model('Test', Schema({ num: Number })); + * + * const obj = Test.castObject({ num: '42' }); + * obj.num; // 42 as a number + * + * Test.castObject({ num: 'not a number' }); // Throws a ValidationError + * + * @param {Object} obj object or document to cast + * @returns {Object} POJO casted to the model's schema + * @throws {ValidationError} if casting failed for at least one path + * @api public + */ + +Model.castObject = function castObject(obj) { + const ret = {}; + + const schema = this.schema; + const paths = Object.keys(schema.paths); + + for (const path of paths) { + const schemaType = schema.path(path); + if (!schemaType || !schemaType.$isMongooseArray) { + continue; + } + + const val = get(obj, path); + pushNestedArrayPaths(paths, val, path); + } + + let error = null; + + for (const path of paths) { + const schemaType = schema.path(path); + if (schemaType == null) { + continue; + } + + let val = get(obj, path, void 0); + + if (val == null) { + continue; + } + + const pieces = path.indexOf('.') === -1 ? [path] : path.split('.'); + let cur = ret; + for (let i = 0; i < pieces.length - 1; ++i) { + if (cur[pieces[i]] == null) { + cur[pieces[i]] = isNaN(pieces[i + 1]) ? {} : []; + } + cur = cur[pieces[i]]; + } + + if (schemaType.$isMongooseDocumentArray) { + continue; + } + if (schemaType.$isSingleNested || schemaType.$isMongooseDocumentArrayElement) { + try { + val = Model.castObject.call(schemaType.caster, val); + } catch (err) { + error = error || new ValidationError(); + error.addError(path, err); + continue; + } + + cur[pieces[pieces.length - 1]] = val; + continue; + } + + try { + val = schemaType.cast(val); + cur[pieces[pieces.length - 1]] = val; + } catch (err) { + error = error || new ValidationError(); + error.addError(path, err); + + continue; + } + } + + if (error != null) { + throw error; + } + + return ret; +}; + /** * Build bulk write operations for `bulkSave()`. * @@ -4278,7 +4369,7 @@ Model.validate = function validate(obj, pathsToValidate, context, callback) { } const val = get(obj, path); - pushNestedArrayPaths(val, path); + pushNestedArrayPaths(paths, val, path); } let remaining = paths.length; @@ -4291,7 +4382,7 @@ Model.validate = function validate(obj, pathsToValidate, context, callback) { continue; } - const pieces = path.split('.'); + const pieces = path.indexOf('.') === -1 ? [path] : path.split('.'); let cur = obj; for (let i = 0; i < pieces.length - 1; ++i) { cur = cur[pieces[i]]; @@ -4315,32 +4406,12 @@ Model.validate = function validate(obj, pathsToValidate, context, callback) { schemaType.doValidate(val, err => { if (err) { error = error || new ValidationError(); - if (err instanceof ValidationError) { - for (const _err of Object.keys(err.errors)) { - error.addError(`${path}.${err.errors[_err].path}`, _err); - } - } else { - error.addError(err.path, err); - } + error.addError(path, err); } _checkDone(); }, context, { path: path }); } - function pushNestedArrayPaths(nestedArray, path) { - if (nestedArray == null) { - return; - } - - for (let i = 0; i < nestedArray.length; ++i) { - if (Array.isArray(nestedArray[i])) { - pushNestedArrayPaths(nestedArray[i], path + '.' + i); - } else { - paths.push(path + '.' + i); - } - } - } - function _checkDone() { if (--remaining <= 0) { return cb(error); diff --git a/test/model.test.js b/test/model.test.js index 01585f75b9f..0d8dc5355d2 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -8805,6 +8805,81 @@ describe('Model', function() { }); }); }); + + describe('castObject() (gh-11945)', function() { + it('casts values', function() { + const Test = db.model('Test', mongoose.Schema({ + _id: false, + num: Number, + nested: { + num: Number + }, + subdoc: { + type: mongoose.Schema({ + _id: false, + num: Number + }), + default: () => ({}) + }, + docArr: [{ + _id: false, + num: Number + }] + })); + + const obj = { + num: '1', + nested: { num: '2' }, + subdoc: { num: '3' }, + docArr: [{ num: '4' }] + }; + const ret = Test.castObject(obj); + assert.deepStrictEqual(ret, { + num: 1, + nested: { num: 2 }, + subdoc: { num: 3 }, + docArr: [{ num: 4 }] + }); + }); + + it('throws if cannot cast', function() { + const Test = db.model('Test', mongoose.Schema({ + _id: false, + num: Number, + nested: { + num: Number + }, + subdoc: { + type: mongoose.Schema({ + _id: false, + num: Number + }) + }, + docArr: [{ + _id: false, + num: Number + }] + })); + + const obj = { + num: 'foo', + nested: { num: 'bar' }, + subdoc: { num: 'baz' }, + docArr: [{ num: 'qux' }] + }; + let error; + try { + Test.castObject(obj); + } catch (err) { + error = err; + } + assert.ok(error); + assert.equal(error.errors['num'].name, 'CastError'); + assert.equal(error.errors['nested.num'].name, 'CastError'); + assert.equal(error.errors['subdoc.num'].name, 'CastError'); + assert.equal(error.errors['docArr.0.num'].name, 'CastError'); + }); + }); }); describe('Check if static function that is supplied in schema option is available', function() { diff --git a/types/models.d.ts b/types/models.d.ts index 9fae6afa66e..b30ac21d62f 100644 --- a/types/models.d.ts +++ b/types/models.d.ts @@ -133,6 +133,9 @@ declare module 'mongoose' { */ baseModelName: string | undefined; + /* Cast the given POJO to the model's schema */ + castObject(obj: AnyObject): T; + /** * Sends multiple `insertOne`, `updateOne`, `updateMany`, `replaceOne`, * `deleteOne`, and/or `deleteMany` operations to the MongoDB server in one