From ff188185b4770b3835c46724fce6df77e25f67a2 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 25 Nov 2024 17:11:40 -0500 Subject: [PATCH 1/5] feat: allow specifying error message override for duplicate key errors `unique: true` Fix #12844 --- lib/helpers/schema/getIndexes.js | 5 ++++ lib/model.js | 3 ++- lib/query.js | 2 ++ lib/schema.js | 28 +++++++++++++++++++++++ lib/schemaType.js | 39 +++++++++++++++++++++++++++----- test/document.test.js | 19 ++++++++++++++++ 6 files changed, 89 insertions(+), 7 deletions(-) diff --git a/lib/helpers/schema/getIndexes.js b/lib/helpers/schema/getIndexes.js index 63aa4add4f9..706439d321d 100644 --- a/lib/helpers/schema/getIndexes.js +++ b/lib/helpers/schema/getIndexes.js @@ -39,6 +39,11 @@ module.exports = function getIndexes(schema) { continue; } + if (path._duplicateKeyErrorMessage != null) { + schema._duplicateKeyErrorMessagesByPath = schema._duplicateKeyErrorMessagesByPath || {}; + schema._duplicateKeyErrorMessagesByPath[key] = path._duplicateKeyErrorMessage; + } + if (path.$isMongooseDocumentArray || path.$isSingleNested) { if (get(path, 'options.excludeIndexes') !== true && get(path, 'schemaOptions.excludeIndexes') !== true && diff --git a/lib/model.js b/lib/model.js index dd7f3227d83..de571fdfe65 100644 --- a/lib/model.js +++ b/lib/model.js @@ -436,6 +436,7 @@ Model.prototype.$__handleSave = function(options, callback) { Model.prototype.$__save = function(options, callback) { this.$__handleSave(options, (error, result) => { if (error) { + error = this.$__schema._transformDuplicateKeyError(error); const hooks = this.$__schema.s.hooks; return hooks.execPost('save:error', this, [this], { error: error }, (error) => { callback(error, this); @@ -3356,7 +3357,7 @@ Model.bulkWrite = async function bulkWrite(ops, options) { let error; [res, error] = await this.$__collection.bulkWrite(validOps, options). then(res => ([res, null])). - catch(err => ([null, err])); + catch(error => ([null, error])); if (error) { if (validationErrors.length > 0) { diff --git a/lib/query.js b/lib/query.js index 99a67d55ddb..fd3d311df4f 100644 --- a/lib/query.js +++ b/lib/query.js @@ -4466,6 +4466,8 @@ Query.prototype.exec = async function exec(op) { } else { error = err; } + + error = this.model.schema._transformDuplicateKeyError(error); } res = await _executePostHooks(this, res, error); diff --git a/lib/schema.js b/lib/schema.js index a9d23fd6199..ff678702b42 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2799,6 +2799,34 @@ Schema.prototype._getPathType = function(path) { return search(path.split('.'), _this); }; +/*! + * + */ + +Schema.prototype._transformDuplicateKeyError = function _transformDuplicateKeyError(error) { + if (!this._duplicateKeyErrorMessagesByPath) { + return error; + } + if (error.code !== 11000 && error.code !== 11001) { + return error; + } + + if (error.keyPattern != null) { + const keyPattern = error.keyPattern; + const keys = Object.keys(keyPattern); + if (keys.length !== 1) { + return error; + } + const firstKey = keys[0]; + if (!this._duplicateKeyErrorMessagesByPath.hasOwnProperty(firstKey)) { + return error; + } + return new MongooseError(this._duplicateKeyErrorMessagesByPath[firstKey], { cause: error }); + } + + return error; +}; + /*! * ignore */ diff --git a/lib/schemaType.js b/lib/schemaType.js index e5aa476468f..d2ba744f2fd 100644 --- a/lib/schemaType.js +++ b/lib/schemaType.js @@ -74,7 +74,6 @@ function SchemaType(path, options, instance) { this.options = new Options(options); this._index = null; - if (utils.hasUserDefinedProperty(this.options, 'immutable')) { this.$immutable = this.options.immutable; @@ -447,21 +446,38 @@ SchemaType.prototype.index = function(options) { * * _NOTE: violating the constraint returns an `E11000` error from MongoDB when saving, not a Mongoose validation error._ * - * @param {Boolean} bool + * You can optionally specify an error message to replace MongoDB's default `E11000 duplicate key error` message. + * The following will throw a "Email must be unique" error if `save()`, `updateOne()`, `updateMany()`, `replaceOne()`, + * `findOneAndUpdate()`, or `findOneAndReplace()` throws a duplicate key error: + * + * ```javascript + * new Schema({ + * email: { + * type: String, + * unique: [true, 'Email must be unique'] + * } + * }); + * ``` + * + * Note that the above syntax does **not** work for `bulkWrite()` or `insertMany()`. `bulkWrite()` and `insertMany()` + * will still throw MongoDB's default `E11000 duplicate key error` message. + * + * @param {Boolean} value + * @param {String} message * @return {SchemaType} this * @api public */ -SchemaType.prototype.unique = function(bool) { +SchemaType.prototype.unique = function unique(value, message) { if (this._index === false) { - if (!bool) { + if (!value) { return; } throw new Error('Path "' + this.path + '" may not have `index` set to ' + 'false and `unique` set to true'); } - if (!this.options.hasOwnProperty('index') && bool === false) { + if (!this.options.hasOwnProperty('index') && value === false) { return this; } @@ -471,7 +487,10 @@ SchemaType.prototype.unique = function(bool) { this._index = { type: this._index }; } - this._index.unique = bool; + this._index.unique = !!value; + if (typeof message === 'string') { + this._duplicateKeyErrorMessage = message; + } return this; }; @@ -1743,6 +1762,14 @@ SchemaType.prototype.getEmbeddedSchemaType = function getEmbeddedSchemaType() { return this.$embeddedSchemaType; }; +/*! + * If _duplicateKeyErrorMessage is a string, replace unique index errors "E11000 duplicate key error" with this string. + * + * @api private + */ + +SchemaType.prototype._duplicateKeyErrorMessage = null; + /*! * Module exports. */ diff --git a/test/document.test.js b/test/document.test.js index 813b34ebff9..dc55d1f10dc 100644 --- a/test/document.test.js +++ b/test/document.test.js @@ -14161,6 +14161,25 @@ describe('document', function() { const fromDb = await ParentModel.findById(doc._id).orFail(); assert.strictEqual(fromDb.quests[0].campaign.milestones, null); }); + + it('handles custom error message for duplicate key errors (gh-12844)', async function() { + const schema = new Schema({ + name: String, + email: { type: String, unique: [true, 'Email must be unique'] } + }); + const Model = db.model('Test', schema); + await Model.init(); + + await Model.create({ email: 'test@example.com' }); + + let duplicateKeyError = await Model.create({ email: 'test@example.com' }).catch(err => err); + assert.strictEqual(duplicateKeyError.message, 'Email must be unique'); + assert.strictEqual(duplicateKeyError.cause.code, 11000); + + duplicateKeyError = await Model.updateOne({ name: 'test' }, { email: 'test@example.com' }, { upsert: true }).catch(err => err); + assert.strictEqual(duplicateKeyError.message, 'Email must be unique'); + assert.strictEqual(duplicateKeyError.cause.code, 11000); + }); }); describe('Check if instance function that is supplied in schema option is available', function() { From cc38a26e24784caae961c454ab8ef092f5652794 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 25 Nov 2024 17:31:05 -0500 Subject: [PATCH 2/5] types: allow `[true, string]` for unique --- test/types/schema.test.ts | 1 + types/index.d.ts | 2 +- types/indexes.d.ts | 6 ++++-- types/schematypes.d.ts | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/test/types/schema.test.ts b/test/types/schema.test.ts index 82988a05b12..13408eaf293 100644 --- a/test/types/schema.test.ts +++ b/test/types/schema.test.ts @@ -101,6 +101,7 @@ movieSchema.index({ title: 'text' }, { }); movieSchema.index({ rating: -1 }); movieSchema.index({ title: 1 }, { unique: true }); +movieSchema.index({ title: 1 }, { unique: [true, 'Title must be unique'] as const }); movieSchema.index({ tile: 'ascending' }); movieSchema.index({ tile: 'asc' }); movieSchema.index({ tile: 'descending' }); diff --git a/types/index.d.ts b/types/index.d.ts index 668f67e55d1..13fc96103c2 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -309,7 +309,7 @@ declare module 'mongoose' { eachPath(fn: (path: string, type: SchemaType) => void): this; /** Defines an index (most likely compound) for this schema. */ - index(fields: IndexDefinition, options?: IndexOptions): this; + index(fields: IndexDefinition, options?: Omit & { unique?: boolean | undefined | [true, string] }): this; /** * Define a search index for this schema. diff --git a/types/indexes.d.ts b/types/indexes.d.ts index 805705905a2..87cd8a20630 100644 --- a/types/indexes.d.ts +++ b/types/indexes.d.ts @@ -63,7 +63,7 @@ declare module 'mongoose' { type ConnectionSyncIndexesResult = Record; type OneCollectionSyncIndexesResult = Array & mongodb.MongoServerError; - interface IndexOptions extends mongodb.CreateIndexesOptions { + type IndexOptions = Omit & { /** * `expires` utilizes the `ms` module from [guille](https://github.com/guille/) allowing us to use a friendlier syntax: * @@ -86,7 +86,9 @@ declare module 'mongoose' { */ expires?: number | string; weights?: Record; - } + + unique?: boolean | [true, string] + }; type SearchIndexDescription = mongodb.SearchIndexDescription; } diff --git a/types/schematypes.d.ts b/types/schematypes.d.ts index aff686e1ec9..f10b633eec8 100644 --- a/types/schematypes.d.ts +++ b/types/schematypes.d.ts @@ -111,7 +111,7 @@ declare module 'mongoose' { * will build a unique index on this path when the * model is compiled. [The `unique` option is **not** a validator](/docs/validation.html#the-unique-option-is-not-a-validator). */ - unique?: boolean | number; + unique?: boolean | number | [true, string]; /** * If [truthy](https://masteringjs.io/tutorials/fundamentals/truthy), Mongoose will From d74e0748815656539034ec8ee7101f799e312dd4 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 3 Dec 2024 11:15:39 -0500 Subject: [PATCH 3/5] Update types/index.d.ts Co-authored-by: hasezoey --- types/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/index.d.ts b/types/index.d.ts index 13fc96103c2..7cb7fdc4108 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -309,7 +309,7 @@ declare module 'mongoose' { eachPath(fn: (path: string, type: SchemaType) => void): this; /** Defines an index (most likely compound) for this schema. */ - index(fields: IndexDefinition, options?: Omit & { unique?: boolean | undefined | [true, string] }): this; + index(fields: IndexDefinition, options?: Omit & { unique?: boolean | [true, string] }): this; /** * Define a search index for this schema. From acc8ea9fbbcd0962024c42d6d8f9b7da9df77ec2 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 3 Dec 2024 11:15:54 -0500 Subject: [PATCH 4/5] Update lib/schemaType.js Co-authored-by: hasezoey --- lib/schemaType.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/schemaType.js b/lib/schemaType.js index d2ba744f2fd..97e01cf798c 100644 --- a/lib/schemaType.js +++ b/lib/schemaType.js @@ -463,7 +463,7 @@ SchemaType.prototype.index = function(options) { * will still throw MongoDB's default `E11000 duplicate key error` message. * * @param {Boolean} value - * @param {String} message + * @param {String} [message] * @return {SchemaType} this * @api public */ From 023b3c08c400051f95764594ed7f72c2b916f5e7 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 3 Dec 2024 11:17:56 -0500 Subject: [PATCH 5/5] docs: add comment for _transformDuplicateKeyError --- lib/schema.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/schema.js b/lib/schema.js index ff678702b42..6410df53cd1 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2799,8 +2799,13 @@ Schema.prototype._getPathType = function(path) { return search(path.split('.'), _this); }; -/*! +/** + * Transforms the duplicate key error by checking for duplicate key error messages by path. + * If no duplicate key error messages are found, returns the original error. * + * @param {Error} error The error to transform + * @returns {Error} The transformed error + * @api private */ Schema.prototype._transformDuplicateKeyError = function _transformDuplicateKeyError(error) {