Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow specifying error message override for duplicate key errors unique: true #15059

Merged
merged 6 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lib/helpers/schema/getIndexes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
3 changes: 2 additions & 1 deletion lib/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions lib/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions lib/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -2799,6 +2799,34 @@ Schema.prototype._getPathType = function(path) {
return search(path.split('.'), _this);
};

/*!
*
*/
vkarpov15 marked this conversation as resolved.
Show resolved Hide resolved

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
*/
Expand Down
39 changes: 33 additions & 6 deletions lib/schemaType.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
vkarpov15 marked this conversation as resolved.
Show resolved Hide resolved
* @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;
}

Expand All @@ -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;
};

Expand Down Expand Up @@ -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.
*/
Expand Down
19 changes: 19 additions & 0 deletions test/document.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]' });

let duplicateKeyError = await Model.create({ email: '[email protected]' }).catch(err => err);
assert.strictEqual(duplicateKeyError.message, 'Email must be unique');
assert.strictEqual(duplicateKeyError.cause.code, 11000);

duplicateKeyError = await Model.updateOne({ name: 'test' }, { email: '[email protected]' }, { 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() {
Expand Down
1 change: 1 addition & 0 deletions test/types/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
2 changes: 1 addition & 1 deletion types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IndexOptions, 'unique'> & { unique?: boolean | undefined | [true, string] }): this;
vkarpov15 marked this conversation as resolved.
Show resolved Hide resolved

/**
* Define a search index for this schema.
Expand Down
6 changes: 4 additions & 2 deletions types/indexes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ declare module 'mongoose' {
type ConnectionSyncIndexesResult = Record<string, OneCollectionSyncIndexesResult>;
type OneCollectionSyncIndexesResult = Array<string> & mongodb.MongoServerError;

interface IndexOptions extends mongodb.CreateIndexesOptions {
type IndexOptions = Omit<mongodb.CreateIndexesOptions, 'expires' | 'weights' | 'unique'> & {
/**
* `expires` utilizes the `ms` module from [guille](https://github.com/guille/) allowing us to use a friendlier syntax:
*
Expand All @@ -86,7 +86,9 @@ declare module 'mongoose' {
*/
expires?: number | string;
weights?: Record<string, number>;
}

unique?: boolean | [true, string]
};

type SearchIndexDescription = mongodb.SearchIndexDescription;
}
2 changes: 1 addition & 1 deletion types/schematypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down