Skip to content

Commit

Permalink
feat(model): add castObject() function that casts a POJO to the mod…
Browse files Browse the repository at this point in the history
…el's schema

Fix #11945
  • Loading branch information
vkarpov15 committed Jul 18, 2022
1 parent 67c8d16 commit 9ecbfeb
Show file tree
Hide file tree
Showing 5 changed files with 196 additions and 23 deletions.
9 changes: 9 additions & 0 deletions lib/error/validation.js
Expand Up @@ -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);
}
Expand Down
15 changes: 15 additions & 0 deletions 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);
}
}
};
117 changes: 94 additions & 23 deletions lib/model.js
Expand Up @@ -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');
Expand Down Expand Up @@ -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()`.
*
Expand Down Expand Up @@ -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;
Expand All @@ -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]];
Expand All @@ -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);
Expand Down
75 changes: 75 additions & 0 deletions test/model.test.js
Expand Up @@ -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() {
Expand Down
3 changes: 3 additions & 0 deletions types/models.d.ts
Expand Up @@ -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
Expand Down

0 comments on commit 9ecbfeb

Please sign in to comment.