Skip to content

Commit f4cfe1e

Browse files
committedMay 16, 2024·
feat(model): add throwOnValidationError option for opting into getting MongooseBulkWriteError if all valid operations succeed in bulkWrite() and insertMany()
Backport #13410 Backport #14587 Fix #14572
1 parent eb61572 commit f4cfe1e

File tree

4 files changed

+195
-0
lines changed

4 files changed

+195
-0
lines changed
 

‎lib/error/bulkWriteError.js

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*!
2+
* Module dependencies.
3+
*/
4+
5+
'use strict';
6+
7+
const MongooseError = require('./');
8+
9+
10+
/**
11+
* If `bulkWrite()` or `insertMany()` has validation errors, but
12+
* all valid operations succeed, and 'throwOnValidationError' is true,
13+
* Mongoose will throw this error.
14+
*
15+
* @api private
16+
*/
17+
18+
class MongooseBulkWriteError extends MongooseError {
19+
constructor(validationErrors, results, rawResult, operation) {
20+
let preview = validationErrors.map(e => e.message).join(', ');
21+
if (preview.length > 200) {
22+
preview = preview.slice(0, 200) + '...';
23+
}
24+
super(`${operation} failed with ${validationErrors.length} Mongoose validation errors: ${preview}`);
25+
26+
this.validationErrors = validationErrors;
27+
this.results = results;
28+
this.rawResult = rawResult;
29+
this.operation = operation;
30+
}
31+
}
32+
33+
Object.defineProperty(MongooseBulkWriteError.prototype, 'name', {
34+
value: 'MongooseBulkWriteError'
35+
});
36+
37+
/*!
38+
* exports
39+
*/
40+
41+
module.exports = MongooseBulkWriteError;

‎lib/model.js

+48
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const Document = require('./document');
1010
const DocumentNotFoundError = require('./error/notFound');
1111
const DivergentArrayError = require('./error/divergentArray');
1212
const EventEmitter = require('events').EventEmitter;
13+
const MongooseBulkWriteError = require('./error/bulkWriteError');
1314
const MongooseBuffer = require('./types/buffer');
1415
const MongooseError = require('./error/index');
1516
const OverwriteModelError = require('./error/overwriteModel');
@@ -3375,6 +3376,7 @@ Model.startSession = function() {
33753376
* @param {Boolean} [options.lean=false] if `true`, skips hydrating the documents. This means Mongoose will **not** cast or validate any of the documents passed to `insertMany()`. This option is useful if you need the extra performance, but comes with data integrity risk. Consider using with [`castObject()`](#model_Model-castObject).
33763377
* @param {Number} [options.limit=null] this limits the number of documents being processed (validation/casting) by mongoose in parallel, this does **NOT** send the documents in batches to MongoDB. Use this option if you're processing a large number of documents and your app is running out of memory.
33773378
* @param {String|Object|Array} [options.populate=null] populates the result documents. This option is a no-op if `rawResult` is set.
3379+
* @param {Boolean} [options.throwOnValidationError=false] If true and `ordered: false`, throw an error if one of the operations failed validation, but all valid operations completed successfully.
33783380
* @param {Function} [callback] callback
33793381
* @return {Promise} resolving to the raw result from the MongoDB driver if `options.rawResult` was `true`, or the documents that passed validation, otherwise
33803382
* @api public
@@ -3419,6 +3421,7 @@ Model.$__insertMany = function(arr, options, callback) {
34193421
const limit = options.limit || 1000;
34203422
const rawResult = !!options.rawResult;
34213423
const ordered = typeof options.ordered === 'boolean' ? options.ordered : true;
3424+
const throwOnValidationError = typeof options.throwOnValidationError === 'boolean' ? options.throwOnValidationError : false;
34223425
const lean = !!options.lean;
34233426

34243427
if (!Array.isArray(arr)) {
@@ -3496,6 +3499,14 @@ Model.$__insertMany = function(arr, options, callback) {
34963499

34973500
// Quickly escape while there aren't any valid docAttributes
34983501
if (docAttributes.length === 0) {
3502+
if (throwOnValidationError) {
3503+
return callback(new MongooseBulkWriteError(
3504+
validationErrors,
3505+
results,
3506+
null,
3507+
'insertMany'
3508+
));
3509+
}
34993510
if (rawResult) {
35003511
const res = {
35013512
acknowledged: true,
@@ -3598,6 +3609,20 @@ Model.$__insertMany = function(arr, options, callback) {
35983609
}
35993610
}
36003611

3612+
if (ordered === false && throwOnValidationError && validationErrors.length > 0) {
3613+
for (let i = 0; i < results.length; ++i) {
3614+
if (results[i] === void 0) {
3615+
results[i] = docs[i];
3616+
}
3617+
}
3618+
return callback(new MongooseBulkWriteError(
3619+
validationErrors,
3620+
results,
3621+
res,
3622+
'insertMany'
3623+
));
3624+
}
3625+
36013626
if (rawResult) {
36023627
if (ordered === false) {
36033628
for (let i = 0; i < results.length; ++i) {
@@ -3728,6 +3753,7 @@ function _setIsNew(doc, val) {
37283753
* @param {Boolean} [options.skipValidation=false] Set to true to skip Mongoose schema validation on bulk write operations. Mongoose currently runs validation on `insertOne` and `replaceOne` operations by default.
37293754
* @param {Boolean} [options.bypassDocumentValidation=false] If true, disable [MongoDB server-side schema validation](https://www.mongodb.com/docs/manual/core/schema-validation/) for all writes in this bulk.
37303755
* @param {Boolean} [options.strict=null] Overwrites the [`strict` option](/docs/guide.html#strict) on schema. If false, allows filtering and writing fields not defined in the schema for all writes in this bulk.
3756+
* @param {Boolean} [options.throwOnValidationError=false] If true and `ordered: false`, throw an error if one of the operations failed validation, but all valid operations completed successfully.
37313757
* @param {Function} [callback] callback `function(error, bulkWriteOpResult) {}`
37323758
* @return {Promise} resolves to a [`BulkWriteOpResult`](https://mongodb.github.io/node-mongodb-native/4.9/classes/BulkWriteResult.html) if the operation succeeds
37333759
* @api public
@@ -3777,6 +3803,7 @@ Model.bulkWrite = function(ops, options, callback) {
37773803
let remaining = validations.length;
37783804
let validOps = [];
37793805
let validationErrors = [];
3806+
const results = [];
37803807
if (remaining === 0) {
37813808
completeUnorderedValidation.call(this);
37823809
} else {
@@ -3786,6 +3813,7 @@ Model.bulkWrite = function(ops, options, callback) {
37863813
validOps.push(i);
37873814
} else {
37883815
validationErrors.push({ index: i, error: err });
3816+
results[i] = err;
37893817
}
37903818
if (--remaining <= 0) {
37913819
completeUnorderedValidation.call(this);
@@ -3799,13 +3827,25 @@ Model.bulkWrite = function(ops, options, callback) {
37993827
map(v => v.error);
38003828

38013829
function completeUnorderedValidation() {
3830+
const validOpIndexes = validOps;
38023831
validOps = validOps.sort().map(index => ops[index]);
38033832

38043833
if (validOps.length === 0) {
3834+
if ('throwOnValidationError' in options && options.throwOnValidationError && validationErrors.length > 0) {
3835+
return cb(new MongooseBulkWriteError(
3836+
validationErrors.map(err => err.error),
3837+
results,
3838+
getDefaultBulkwriteResult(),
3839+
'bulkWrite'
3840+
));
3841+
}
38053842
return cb(null, getDefaultBulkwriteResult());
38063843
}
38073844

38083845
this.$__collection.bulkWrite(validOps, options, (error, res) => {
3846+
for (let i = 0; i < validOpIndexes.length; ++i) {
3847+
results[validOpIndexes[i]] = null;
3848+
}
38093849
if (error) {
38103850
if (validationErrors.length > 0) {
38113851
error.mongoose = error.mongoose || {};
@@ -3816,6 +3856,14 @@ Model.bulkWrite = function(ops, options, callback) {
38163856
}
38173857

38183858
if (validationErrors.length > 0) {
3859+
if ('throwOnValidationError' in options && options.throwOnValidationError) {
3860+
return cb(new MongooseBulkWriteError(
3861+
validationErrors,
3862+
results,
3863+
res,
3864+
'bulkWrite'
3865+
));
3866+
}
38193867
res.mongoose = res.mongoose || {};
38203868
res.mongoose.validationErrors = validationErrors;
38213869
}

‎test/model.test.js

+104
Original file line numberDiff line numberDiff line change
@@ -6111,6 +6111,71 @@ describe('Model', function() {
61116111
const { num } = await Test.findById(_id);
61126112
assert.equal(num, 99);
61136113
});
6114+
6115+
it('bulkWrite should throw an error if there were operations that failed validation, ' +
6116+
'but all operations that passed validation succeeded (gh-13256)', async function() {
6117+
const userSchema = new Schema({ age: { type: Number } });
6118+
const User = db.model('User', userSchema);
6119+
6120+
const createdUser = await User.create({ name: 'Test' });
6121+
6122+
const err = await User.bulkWrite([
6123+
{
6124+
updateOne: {
6125+
filter: { _id: createdUser._id },
6126+
update: { $set: { age: 'NaN' } },
6127+
upsert: true
6128+
}
6129+
},
6130+
{
6131+
updateOne: {
6132+
filter: { _id: createdUser._id },
6133+
update: { $set: { age: 13 } },
6134+
upsert: true
6135+
}
6136+
},
6137+
{
6138+
updateOne: {
6139+
filter: { _id: createdUser._id },
6140+
update: { $set: { age: 12 } },
6141+
upsert: true
6142+
}
6143+
}
6144+
], { ordered: false, throwOnValidationError: true })
6145+
.then(() => null)
6146+
.catch(err => err);
6147+
6148+
assert.ok(err);
6149+
assert.equal(err.name, 'MongooseBulkWriteError');
6150+
assert.equal(err.validationErrors[0].path, 'age');
6151+
assert.equal(err.results[0].path, 'age');
6152+
});
6153+
6154+
it('throwOnValidationError (gh-14572) (gh-13256)', async function() {
6155+
const schema = new Schema({
6156+
num: Number
6157+
});
6158+
6159+
const M = db.model('Test', schema);
6160+
6161+
const ops = [
6162+
{
6163+
insertOne: {
6164+
document: {
6165+
num: 'not a number'
6166+
}
6167+
}
6168+
}
6169+
];
6170+
6171+
const err = await M.bulkWrite(
6172+
ops,
6173+
{ ordered: false, throwOnValidationError: true }
6174+
).then(() => null, err => err);
6175+
assert.ok(err);
6176+
assert.equal(err.name, 'MongooseBulkWriteError');
6177+
assert.equal(err.validationErrors[0].errors['num'].name, 'CastError');
6178+
});
61146179
});
61156180

61166181
it('insertMany with Decimal (gh-5190)', async function() {
@@ -9028,6 +9093,45 @@ describe('Model', function() {
90289093
assert.equal(TestModel.staticFn(), 'Returned from staticFn');
90299094
});
90309095
});
9096+
9097+
it('insertMany should throw an error if there were operations that failed validation, ' +
9098+
'but all operations that passed validation succeeded (gh-13256)', async function() {
9099+
const userSchema = new Schema({
9100+
age: { type: Number }
9101+
});
9102+
9103+
const User = db.model('User', userSchema);
9104+
9105+
let err = await User.insertMany([
9106+
new User({ age: 12 }),
9107+
new User({ age: 12 }),
9108+
new User({ age: 'NaN' })
9109+
], { ordered: false, throwOnValidationError: true })
9110+
.then(() => null)
9111+
.catch(err => err);
9112+
9113+
assert.ok(err);
9114+
assert.equal(err.name, 'MongooseBulkWriteError');
9115+
assert.equal(err.validationErrors[0].errors['age'].name, 'CastError');
9116+
assert.ok(err.results[2] instanceof Error);
9117+
assert.equal(err.results[2].errors['age'].name, 'CastError');
9118+
9119+
let docs = await User.find();
9120+
assert.deepStrictEqual(docs.map(doc => doc.age), [12, 12]);
9121+
9122+
err = await User.insertMany([
9123+
new User({ age: 'NaN' })
9124+
], { ordered: false, throwOnValidationError: true })
9125+
.then(() => null)
9126+
.catch(err => err);
9127+
9128+
assert.ok(err);
9129+
assert.equal(err.name, 'MongooseBulkWriteError');
9130+
assert.equal(err.validationErrors[0].errors['age'].name, 'CastError');
9131+
9132+
docs = await User.find();
9133+
assert.deepStrictEqual(docs.map(doc => doc.age), [12, 12]);
9134+
});
90319135
});
90329136

90339137

‎types/models.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ declare module 'mongoose' {
1919
skipValidation?: boolean;
2020
strict?: boolean;
2121
timestamps?: boolean | 'throw';
22+
throwOnValidationError?: boolean;
2223
}
2324

2425
interface InsertManyOptions extends
@@ -28,6 +29,7 @@ declare module 'mongoose' {
2829
rawResult?: boolean;
2930
ordered?: boolean;
3031
lean?: boolean;
32+
throwOnValidationError?: boolean;
3133
}
3234

3335
type InsertManyResult<T> = mongodb.InsertManyResult<T> & {

0 commit comments

Comments
 (0)
Please sign in to comment.