Skip to content

Commit beba447

Browse files
authored
Merge pull request Automattic#15059 from Automattic/vkarpov15/Automatticgh-12844
feat: allow specifying error message override for duplicate key errors `unique: true`
2 parents 107fe2a + ebc6e10 commit beba447

File tree

10 files changed

+101
-11
lines changed

10 files changed

+101
-11
lines changed

lib/helpers/schema/getIndexes.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ module.exports = function getIndexes(schema) {
3939
continue;
4040
}
4141

42+
if (path._duplicateKeyErrorMessage != null) {
43+
schema._duplicateKeyErrorMessagesByPath = schema._duplicateKeyErrorMessagesByPath || {};
44+
schema._duplicateKeyErrorMessagesByPath[key] = path._duplicateKeyErrorMessage;
45+
}
46+
4247
if (path.$isMongooseDocumentArray || path.$isSingleNested) {
4348
if (get(path, 'options.excludeIndexes') !== true &&
4449
get(path, 'schemaOptions.excludeIndexes') !== true &&

lib/model.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ Model.prototype.$__handleSave = function(options, callback) {
436436
Model.prototype.$__save = function(options, callback) {
437437
this.$__handleSave(options, (error, result) => {
438438
if (error) {
439+
error = this.$__schema._transformDuplicateKeyError(error);
439440
const hooks = this.$__schema.s.hooks;
440441
return hooks.execPost('save:error', this, [this], { error: error }, (error) => {
441442
callback(error, this);
@@ -3356,7 +3357,7 @@ Model.bulkWrite = async function bulkWrite(ops, options) {
33563357
let error;
33573358
[res, error] = await this.$__collection.bulkWrite(validOps, options).
33583359
then(res => ([res, null])).
3359-
catch(err => ([null, err]));
3360+
catch(error => ([null, error]));
33603361

33613362
if (error) {
33623363
if (validationErrors.length > 0) {

lib/query.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4466,6 +4466,8 @@ Query.prototype.exec = async function exec(op) {
44664466
} else {
44674467
error = err;
44684468
}
4469+
4470+
error = this.model.schema._transformDuplicateKeyError(error);
44694471
}
44704472

44714473
res = await _executePostHooks(this, res, error);

lib/schema.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2799,6 +2799,39 @@ Schema.prototype._getPathType = function(path) {
27992799
return search(path.split('.'), _this);
28002800
};
28012801

2802+
/**
2803+
* Transforms the duplicate key error by checking for duplicate key error messages by path.
2804+
* If no duplicate key error messages are found, returns the original error.
2805+
*
2806+
* @param {Error} error The error to transform
2807+
* @returns {Error} The transformed error
2808+
* @api private
2809+
*/
2810+
2811+
Schema.prototype._transformDuplicateKeyError = function _transformDuplicateKeyError(error) {
2812+
if (!this._duplicateKeyErrorMessagesByPath) {
2813+
return error;
2814+
}
2815+
if (error.code !== 11000 && error.code !== 11001) {
2816+
return error;
2817+
}
2818+
2819+
if (error.keyPattern != null) {
2820+
const keyPattern = error.keyPattern;
2821+
const keys = Object.keys(keyPattern);
2822+
if (keys.length !== 1) {
2823+
return error;
2824+
}
2825+
const firstKey = keys[0];
2826+
if (!this._duplicateKeyErrorMessagesByPath.hasOwnProperty(firstKey)) {
2827+
return error;
2828+
}
2829+
return new MongooseError(this._duplicateKeyErrorMessagesByPath[firstKey], { cause: error });
2830+
}
2831+
2832+
return error;
2833+
};
2834+
28022835
/*!
28032836
* ignore
28042837
*/

lib/schemaType.js

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ function SchemaType(path, options, instance) {
7474
this.options = new Options(options);
7575
this._index = null;
7676

77-
7877
if (utils.hasUserDefinedProperty(this.options, 'immutable')) {
7978
this.$immutable = this.options.immutable;
8079

@@ -447,21 +446,38 @@ SchemaType.prototype.index = function(options) {
447446
*
448447
* _NOTE: violating the constraint returns an `E11000` error from MongoDB when saving, not a Mongoose validation error._
449448
*
450-
* @param {Boolean} bool
449+
* You can optionally specify an error message to replace MongoDB's default `E11000 duplicate key error` message.
450+
* The following will throw a "Email must be unique" error if `save()`, `updateOne()`, `updateMany()`, `replaceOne()`,
451+
* `findOneAndUpdate()`, or `findOneAndReplace()` throws a duplicate key error:
452+
*
453+
* ```javascript
454+
* new Schema({
455+
* email: {
456+
* type: String,
457+
* unique: [true, 'Email must be unique']
458+
* }
459+
* });
460+
* ```
461+
*
462+
* Note that the above syntax does **not** work for `bulkWrite()` or `insertMany()`. `bulkWrite()` and `insertMany()`
463+
* will still throw MongoDB's default `E11000 duplicate key error` message.
464+
*
465+
* @param {Boolean} value
466+
* @param {String} [message]
451467
* @return {SchemaType} this
452468
* @api public
453469
*/
454470

455-
SchemaType.prototype.unique = function(bool) {
471+
SchemaType.prototype.unique = function unique(value, message) {
456472
if (this._index === false) {
457-
if (!bool) {
473+
if (!value) {
458474
return;
459475
}
460476
throw new Error('Path "' + this.path + '" may not have `index` set to ' +
461477
'false and `unique` set to true');
462478
}
463479

464-
if (!this.options.hasOwnProperty('index') && bool === false) {
480+
if (!this.options.hasOwnProperty('index') && value === false) {
465481
return this;
466482
}
467483

@@ -471,7 +487,10 @@ SchemaType.prototype.unique = function(bool) {
471487
this._index = { type: this._index };
472488
}
473489

474-
this._index.unique = bool;
490+
this._index.unique = !!value;
491+
if (typeof message === 'string') {
492+
this._duplicateKeyErrorMessage = message;
493+
}
475494
return this;
476495
};
477496

@@ -1743,6 +1762,14 @@ SchemaType.prototype.getEmbeddedSchemaType = function getEmbeddedSchemaType() {
17431762
return this.$embeddedSchemaType;
17441763
};
17451764

1765+
/*!
1766+
* If _duplicateKeyErrorMessage is a string, replace unique index errors "E11000 duplicate key error" with this string.
1767+
*
1768+
* @api private
1769+
*/
1770+
1771+
SchemaType.prototype._duplicateKeyErrorMessage = null;
1772+
17461773
/*!
17471774
* Module exports.
17481775
*/

test/document.test.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14161,6 +14161,25 @@ describe('document', function() {
1416114161
const fromDb = await ParentModel.findById(doc._id).orFail();
1416214162
assert.strictEqual(fromDb.quests[0].campaign.milestones, null);
1416314163
});
14164+
14165+
it('handles custom error message for duplicate key errors (gh-12844)', async function() {
14166+
const schema = new Schema({
14167+
name: String,
14168+
email: { type: String, unique: [true, 'Email must be unique'] }
14169+
});
14170+
const Model = db.model('Test', schema);
14171+
await Model.init();
14172+
14173+
await Model.create({ email: '[email protected]' });
14174+
14175+
let duplicateKeyError = await Model.create({ email: '[email protected]' }).catch(err => err);
14176+
assert.strictEqual(duplicateKeyError.message, 'Email must be unique');
14177+
assert.strictEqual(duplicateKeyError.cause.code, 11000);
14178+
14179+
duplicateKeyError = await Model.updateOne({ name: 'test' }, { email: '[email protected]' }, { upsert: true }).catch(err => err);
14180+
assert.strictEqual(duplicateKeyError.message, 'Email must be unique');
14181+
assert.strictEqual(duplicateKeyError.cause.code, 11000);
14182+
});
1416414183
});
1416514184

1416614185
describe('Check if instance function that is supplied in schema option is available', function() {

test/types/schema.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ movieSchema.index({ title: 'text' }, {
101101
});
102102
movieSchema.index({ rating: -1 });
103103
movieSchema.index({ title: 1 }, { unique: true });
104+
movieSchema.index({ title: 1 }, { unique: [true, 'Title must be unique'] as const });
104105
movieSchema.index({ tile: 'ascending' });
105106
movieSchema.index({ tile: 'asc' });
106107
movieSchema.index({ tile: 'descending' });

types/index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ declare module 'mongoose' {
309309
eachPath(fn: (path: string, type: SchemaType) => void): this;
310310

311311
/** Defines an index (most likely compound) for this schema. */
312-
index(fields: IndexDefinition, options?: IndexOptions): this;
312+
index(fields: IndexDefinition, options?: Omit<IndexOptions, 'unique'> & { unique?: boolean | [true, string] }): this;
313313

314314
/**
315315
* Define a search index for this schema.

types/indexes.d.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ declare module 'mongoose' {
6363
type ConnectionSyncIndexesResult = Record<string, OneCollectionSyncIndexesResult>;
6464
type OneCollectionSyncIndexesResult = Array<string> & mongodb.MongoServerError;
6565

66-
interface IndexOptions extends mongodb.CreateIndexesOptions {
66+
type IndexOptions = Omit<mongodb.CreateIndexesOptions, 'expires' | 'weights' | 'unique'> & {
6767
/**
6868
* `expires` utilizes the `ms` module from [guille](https://github.com/guille/) allowing us to use a friendlier syntax:
6969
*
@@ -86,7 +86,9 @@ declare module 'mongoose' {
8686
*/
8787
expires?: number | string;
8888
weights?: Record<string, number>;
89-
}
89+
90+
unique?: boolean | [true, string]
91+
};
9092

9193
type SearchIndexDescription = mongodb.SearchIndexDescription;
9294
}

types/schematypes.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ declare module 'mongoose' {
111111
* will build a unique index on this path when the
112112
* model is compiled. [The `unique` option is **not** a validator](/docs/validation.html#the-unique-option-is-not-a-validator).
113113
*/
114-
unique?: boolean | number;
114+
unique?: boolean | number | [true, string];
115115

116116
/**
117117
* If [truthy](https://masteringjs.io/tutorials/fundamentals/truthy), Mongoose will

0 commit comments

Comments
 (0)