diff --git a/docs/guide.md b/docs/guide.md index fa653acc4dd..20e96e133fa 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -80,6 +80,8 @@ The permitted SchemaTypes are: * [Decimal128](api/mongoose.html#mongoose_Mongoose-Decimal128) * [Map](schematypes.html#maps) * [UUID](schematypes.html#uuid) +* [Double](schematypes.html#double) +* [Int32](schematypes.html#int32) Read more about [SchemaTypes here](schematypes.html). diff --git a/docs/schematypes.md b/docs/schematypes.md index 904bb6a8726..062b3251311 100644 --- a/docs/schematypes.md +++ b/docs/schematypes.md @@ -55,6 +55,8 @@ Check out [Mongoose's plugins search](http://plugins.mongoosejs.io) to find plug * [Schema](#schemas) * [UUID](#uuid) * [BigInt](#bigint) +* [Double](#double) +* [Int32](#int32) ### Example @@ -68,6 +70,8 @@ const schema = new Schema({ mixed: Schema.Types.Mixed, _someId: Schema.Types.ObjectId, decimal: Schema.Types.Decimal128, + double: Schema.Types.Double, + int32bit: Schema.Types.Int32, array: [], ofString: [String], ofNumber: [Number], @@ -647,6 +651,75 @@ const question = new Question({ answer: 42n }); typeof question.answer; // 'bigint' ``` +### Double {#double} + +Mongoose supports [64-bit IEEE 754-2008 floating point numbers](https://en.wikipedia.org/wiki/IEEE_754-2008_revision) as a SchemaType. +Int32s are stored as [BSON type "double" in MongoDB](https://www.mongodb.com/docs/manual/reference/bson-types/). + +```javascript +const studentsSchema = new Schema({ + id: Int32 +}); +const Student = mongoose.model('Student', schema); + +const student = new Temperature({ celsius: 1339 }); +typeof student.id; // 'number' +``` + +There are several types of values that will be successfully cast to a Double. + +```javascript +new Temperature({ celsius: '1.2e12' }).celsius; // 15 as a Double +new Temperature({ celsius: true }).celsius; // 1 as a Double +new Temperature({ celsius: false }).celsius; // 0 as a Double +new Temperature({ celsius: { valueOf: () => 83.0033 } }).celsius; // 83 as a Double +new Temperature({ celsius: '' }).celsius; // null as a Double +``` + +The following inputs will result will all result in a [CastError](validation.html#cast-errors) once validated, meaning that it will not throw on initialization, only when validated: + +* strings that do not represent a numeric string, a NaN or a null-ish value +* objects that don't have a `valueOf()` function +* an input that represents a value outside the bounds of a IEEE 754-2008 floating point + +### Int32 {#int32} + +Mongoose supports 32-bit integers as a SchemaType. +Int32s are stored as [32-bit integers in MongoDB (BSON type "int")](https://www.mongodb.com/docs/manual/reference/bson-types/). + +```javascript +const studentsSchema = new Schema({ + id: Int32 +}); +const Student = mongoose.model('Student', schema); + +const student = new Temperature({ celsius: 1339 }); +typeof student.id; // 'number' +``` + +There are several types of values that will be successfully cast to a Number. + +```javascript +new Student({ id: '15' }).id; // 15 as a Int32 +new Student({ id: true }).id; // 1 as a Int32 +new Student({ id: false }).id; // 0 as a Int32 +new Student({ id: { valueOf: () => 83 } }).id; // 83 as a Int32 +new Student({ id: '' }).id; // null as a Int32 +``` + +If you pass an object with a `valueOf()` function that returns a Number, Mongoose will +call it and assign the returned value to the path. + +The values `null` and `undefined` are not cast. + +The following inputs will result will all result in a [CastError](validation.html#cast-errors) once validated, meaning that it will not throw on initialization, only when validated: + +* NaN +* strings that cast to NaN +* objects that don't have a `valueOf()` function +* a decimal that must be rounded to be an integer +* an input that represents a value outside the bounds of an 32-bit integer + ## Getters {#getters} Getters are like virtuals for paths defined in your schema. For example, diff --git a/index.js b/index.js index 6ebbd5fd5d3..f44ddb21f3a 100644 --- a/index.js +++ b/index.js @@ -46,6 +46,7 @@ module.exports.Decimal128 = mongoose.Decimal128; module.exports.Mixed = mongoose.Mixed; module.exports.Date = mongoose.Date; module.exports.Number = mongoose.Number; +module.exports.Double = mongoose.Double; module.exports.Error = mongoose.Error; module.exports.MongooseError = mongoose.MongooseError; module.exports.now = mongoose.now; diff --git a/lib/cast/double.js b/lib/cast/double.js new file mode 100644 index 00000000000..5dfc6c1a797 --- /dev/null +++ b/lib/cast/double.js @@ -0,0 +1,50 @@ +'use strict'; + +const assert = require('assert'); +const BSON = require('bson'); +const isBsonType = require('../helpers/isBsonType'); + +/** + * Given a value, cast it to a IEEE 754-2008 floating point, or throw an `Error` if the value + * cannot be casted. `null`, `undefined`, and `NaN` are considered valid inputs. + * + * @param {Any} value + * @return {Number} + * @throws {Error} if `value` does not represent a IEEE 754-2008 floating point. If casting from a string, see [BSON Double.fromString API documentation](https://mongodb.github.io/node-mongodb-native/Next/classes/BSON.Double.html#fromString) + * @api private + */ + +module.exports = function castDouble(val) { + if (val == null || val === '') { + return null; + } + + let coercedVal; + if (isBsonType(val, 'Long')) { + coercedVal = val.toNumber(); + } else if (typeof val === 'string') { + try { + coercedVal = BSON.Double.fromString(val); + return coercedVal; + } catch { + assert.ok(false); + } + } else if (typeof val === 'object') { + const tempVal = val.valueOf() ?? val.toString(); + // ex: { a: 'im an object, valueOf: () => 'helloworld' } // throw an error + if (typeof tempVal === 'string') { + try { + coercedVal = BSON.Double.fromString(val); + return coercedVal; + } catch { + assert.ok(false); + } + } else { + coercedVal = Number(tempVal); + } + } else { + coercedVal = Number(val); + } + + return new BSON.Double(coercedVal); +}; diff --git a/lib/cast/int32.js b/lib/cast/int32.js new file mode 100644 index 00000000000..34eeae8565f --- /dev/null +++ b/lib/cast/int32.js @@ -0,0 +1,36 @@ +'use strict'; + +const isBsonType = require('../helpers/isBsonType'); +const assert = require('assert'); + +/** + * Given a value, cast it to a Int32, or throw an `Error` if the value + * cannot be casted. `null` and `undefined` are considered valid. + * + * @param {Any} value + * @return {Number} + * @throws {Error} if `value` does not represent an integer, or is outside the bounds of an 32-bit integer. + * @api private + */ + +module.exports = function castInt32(val) { + if (val == null) { + return val; + } + if (val === '') { + return null; + } + + const coercedVal = isBsonType(val, 'Long') ? val.toNumber() : Number(val); + + const INT32_MAX = 0x7FFFFFFF; + const INT32_MIN = -0x80000000; + + if (coercedVal === (coercedVal | 0) && + coercedVal >= INT32_MIN && + coercedVal <= INT32_MAX + ) { + return coercedVal; + } + assert.ok(false); +}; diff --git a/lib/connection.js b/lib/connection.js index eea265260d5..c2cb1b49b30 100644 --- a/lib/connection.js +++ b/lib/connection.js @@ -8,6 +8,7 @@ const ChangeStream = require('./cursor/changeStream'); const EventEmitter = require('events').EventEmitter; const Schema = require('./schema'); const STATES = require('./connectionState'); +const MongooseBulkWriteError = require('./error/bulkWriteError'); const MongooseError = require('./error/index'); const ServerSelectionError = require('./error/serverSelection'); const SyncIndexesError = require('./error/syncIndexes'); @@ -15,9 +16,13 @@ const applyPlugins = require('./helpers/schema/applyPlugins'); const clone = require('./helpers/clone'); const driver = require('./driver'); const get = require('./helpers/get'); +const getDefaultBulkwriteResult = require('./helpers/getDefaultBulkwriteResult'); const immediate = require('./helpers/immediate'); const utils = require('./utils'); const CreateCollectionsError = require('./error/createCollectionsError'); +const castBulkWrite = require('./helpers/model/castBulkWrite'); +const { modelSymbol } = require('./helpers/symbols'); +const isPromise = require('./helpers/isPromise'); const arrayAtomicsSymbol = require('./helpers/symbols').arrayAtomicsSymbol; const sessionNewDocuments = require('./helpers/symbols').sessionNewDocuments; @@ -419,6 +424,178 @@ Connection.prototype.createCollection = async function createCollection(collecti return this.db.createCollection(collection, options); }; +/** + * _Requires MongoDB Server 8.0 or greater_. Executes bulk write operations across multiple models in a single operation. + * You must specify the `model` for each operation: Mongoose will use `model` for casting and validation, as well as + * determining which collection to apply the operation to. + * + * #### Example: + * const Test = mongoose.model('Test', new Schema({ name: String })); + * + * await db.bulkWrite([ + * { model: Test, name: 'insertOne', document: { name: 'test1' } }, // Can specify model as a Model class... + * { model: 'Test', name: 'insertOne', document: { name: 'test2' } } // or as a model name + * ], { ordered: false }); + * + * @method bulkWrite + * @param {Array} ops + * @param {Object} [options] + * @param {Boolean} [options.ordered] If false, perform unordered operations. If true, perform ordered operations. + * @param {Session} [options.session] The session to use for the operation. + * @return {Promise} + * @see MongoDB https://www.mongodb.com/docs/manual/reference/command/bulkWrite/#mongodb-dbcommand-dbcmd.bulkWrite + * @api public + */ + + +Connection.prototype.bulkWrite = async function bulkWrite(ops, options) { + await this._waitForConnect(); + options = options || {}; + + const ordered = options.ordered == null ? true : options.ordered; + const asyncLocalStorage = this.base.transactionAsyncLocalStorage?.getStore(); + if ((!options || !options.hasOwnProperty('session')) && asyncLocalStorage?.session != null) { + options = { ...options, session: asyncLocalStorage.session }; + } + + const now = this.base.now(); + + let res = null; + if (ordered) { + const opsToSend = []; + for (const op of ops) { + if (typeof op.model !== 'string' && !op.model?.[modelSymbol]) { + throw new MongooseError('Must specify model in Connection.prototype.bulkWrite() operations'); + } + const Model = op.model[modelSymbol] ? op.model : this.model(op.model); + + if (op.name == null) { + throw new MongooseError('Must specify operation name in Connection.prototype.bulkWrite()'); + } + if (!castBulkWrite.cast.hasOwnProperty(op.name)) { + throw new MongooseError(`Unrecognized bulkWrite() operation name ${op.name}`); + } + + await castBulkWrite.cast[op.name](Model, op, options, now); + opsToSend.push({ ...op, namespace: Model.namespace() }); + } + + res = await this.client.bulkWrite(opsToSend, options); + } else { + const validOps = []; + const validOpIndexes = []; + let validationErrors = []; + const asyncValidations = []; + const results = []; + for (let i = 0; i < ops.length; ++i) { + const op = ops[i]; + if (typeof op.model !== 'string' && !op.model?.[modelSymbol]) { + const error = new MongooseError('Must specify model in Connection.prototype.bulkWrite() operations'); + validationErrors.push({ index: i, error: error }); + results[i] = error; + continue; + } + let Model; + try { + Model = op.model[modelSymbol] ? op.model : this.model(op.model); + } catch (error) { + validationErrors.push({ index: i, error: error }); + continue; + } + if (op.name == null) { + const error = new MongooseError('Must specify operation name in Connection.prototype.bulkWrite()'); + validationErrors.push({ index: i, error: error }); + results[i] = error; + continue; + } + if (!castBulkWrite.cast.hasOwnProperty(op.name)) { + const error = new MongooseError(`Unrecognized bulkWrite() operation name ${op.name}`); + validationErrors.push({ index: i, error: error }); + results[i] = error; + continue; + } + + let maybePromise = null; + try { + maybePromise = castBulkWrite.cast[op.name](Model, op, options, now); + } catch (error) { + validationErrors.push({ index: i, error: error }); + results[i] = error; + continue; + } + if (isPromise(maybePromise)) { + asyncValidations.push( + maybePromise.then( + () => { + validOps.push({ ...op, namespace: Model.namespace() }); + validOpIndexes.push(i); + }, + error => { + validationErrors.push({ index: i, error: error }); + results[i] = error; + } + ) + ); + } else { + validOps.push({ ...op, namespace: Model.namespace() }); + validOpIndexes.push(i); + } + } + + if (asyncValidations.length > 0) { + await Promise.all(asyncValidations); + } + + validationErrors = validationErrors. + sort((v1, v2) => v1.index - v2.index). + map(v => v.error); + + if (validOps.length === 0) { + if (options.throwOnValidationError && validationErrors.length) { + throw new MongooseBulkWriteError( + validationErrors, + results, + res, + 'bulkWrite' + ); + } + return getDefaultBulkwriteResult(); + } + + let error; + [res, error] = await this.client.bulkWrite(validOps, options). + then(res => ([res, null])). + catch(err => ([null, err])); + + if (error) { + if (validationErrors.length > 0) { + error.mongoose = error.mongoose || {}; + error.mongoose.validationErrors = validationErrors; + } + } + + for (let i = 0; i < validOpIndexes.length; ++i) { + results[validOpIndexes[i]] = null; + } + if (validationErrors.length > 0) { + if (options.throwOnValidationError) { + throw new MongooseBulkWriteError( + validationErrors, + results, + res, + 'bulkWrite' + ); + } else { + res.mongoose = res.mongoose || {}; + res.mongoose.validationErrors = validationErrors; + res.mongoose.results = results; + } + } + } + + return res; +}; + /** * Calls `createCollection()` on a models in a series. * diff --git a/lib/drivers/node-mongodb-native/connection.js b/lib/drivers/node-mongodb-native/connection.js index a17fc64131f..e626cb09d82 100644 --- a/lib/drivers/node-mongodb-native/connection.js +++ b/lib/drivers/node-mongodb-native/connection.js @@ -434,7 +434,6 @@ function _setClient(conn, client, options, dbName) { } } - /*! * Module exports. */ diff --git a/lib/helpers/clone.js b/lib/helpers/clone.js index 09204b8c8a4..a8dd587dbf9 100644 --- a/lib/helpers/clone.js +++ b/lib/helpers/clone.js @@ -11,6 +11,7 @@ const isObject = require('./isObject'); const isPOJO = require('./isPOJO'); const symbols = require('./symbols'); const trustedSymbol = require('./query/trusted').trustedSymbol; +const BSON = require('bson'); /** * Object clone with Mongoose natives support. @@ -30,6 +31,10 @@ function clone(obj, options, isArrayChild) { if (obj == null) { return obj; } + + if (isBsonType(obj, 'Double')) { + return new BSON.Double(obj.value); + } if (typeof obj === 'number' || typeof obj === 'string' || typeof obj === 'boolean' || typeof obj === 'bigint') { return obj; } diff --git a/lib/helpers/model/castBulkWrite.js b/lib/helpers/model/castBulkWrite.js index 6d7a780a812..fc053000db3 100644 --- a/lib/helpers/model/castBulkWrite.js +++ b/lib/helpers/model/castBulkWrite.js @@ -23,234 +23,47 @@ const setDefaultsOnInsert = require('../setDefaultsOnInsert'); module.exports = function castBulkWrite(originalModel, op, options) { const now = originalModel.base.now(); - const globalSetDefaultsOnInsert = originalModel.base.options.setDefaultsOnInsert; if (op['insertOne']) { - return (callback) => { - const model = decideModelByObject(originalModel, op['insertOne']['document']); - - const doc = new model(op['insertOne']['document']); - if (model.schema.options.timestamps && getTimestampsOpt(op['insertOne'], options)) { - doc.initializeTimestamps(); - } - if (options.session != null) { - doc.$session(options.session); - } - const versionKey = model?.schema?.options?.versionKey; - if (versionKey && doc[versionKey] == null) { - doc[versionKey] = 0; - } - op['insertOne']['document'] = doc; - - if (options.skipValidation || op['insertOne'].skipValidation) { - callback(null); - return; - } - - op['insertOne']['document'].$validate().then( - () => { callback(null); }, - err => { callback(err, null); } - ); - }; + return callback => module.exports.castInsertOne(originalModel, op['insertOne'], options).then(() => callback(null), err => callback(err)); } else if (op['updateOne']) { return (callback) => { try { - if (!op['updateOne']['filter']) { - throw new Error('Must provide a filter object.'); - } - if (!op['updateOne']['update']) { - throw new Error('Must provide an update object.'); - } - - const model = decideModelByObject(originalModel, op['updateOne']['filter']); - const schema = model.schema; - const strict = options.strict != null ? options.strict : model.schema.options.strict; - - const update = clone(op['updateOne']['update']); - - _addDiscriminatorToObject(schema, op['updateOne']['filter']); - - const doInitTimestamps = getTimestampsOpt(op['updateOne'], options); - - if (model.schema.$timestamps != null && doInitTimestamps) { - const createdAt = model.schema.$timestamps.createdAt; - const updatedAt = model.schema.$timestamps.updatedAt; - applyTimestampsToUpdate(now, createdAt, updatedAt, update, {}); - } - - if (doInitTimestamps) { - applyTimestampsToChildren(now, update, model.schema); - } - - const shouldSetDefaultsOnInsert = op['updateOne'].setDefaultsOnInsert == null ? - globalSetDefaultsOnInsert : - op['updateOne'].setDefaultsOnInsert; - if (shouldSetDefaultsOnInsert !== false) { - setDefaultsOnInsert(op['updateOne']['filter'], model.schema, update, { - setDefaultsOnInsert: true, - upsert: op['updateOne'].upsert - }); - } - - decorateUpdateWithVersionKey( - update, - op['updateOne'], - model.schema.options.versionKey - ); - - op['updateOne']['filter'] = cast(model.schema, op['updateOne']['filter'], { - strict: strict, - upsert: op['updateOne'].upsert - }); - op['updateOne']['update'] = castUpdate(model.schema, update, { - strict: strict, - upsert: op['updateOne'].upsert, - arrayFilters: op['updateOne'].arrayFilters, - overwriteDiscriminatorKey: op['updateOne'].overwriteDiscriminatorKey - }, model, op['updateOne']['filter']); - } catch (error) { - return callback(error, null); + module.exports.castUpdateOne(originalModel, op['updateOne'], options, now); + callback(null); + } catch (err) { + callback(err); } - - callback(null); }; } else if (op['updateMany']) { return (callback) => { try { - if (!op['updateMany']['filter']) { - throw new Error('Must provide a filter object.'); - } - if (!op['updateMany']['update']) { - throw new Error('Must provide an update object.'); - } - - const model = decideModelByObject(originalModel, op['updateMany']['filter']); - const schema = model.schema; - const strict = options.strict != null ? options.strict : model.schema.options.strict; - - const shouldSetDefaultsOnInsert = op['updateMany'].setDefaultsOnInsert == null ? - globalSetDefaultsOnInsert : - op['updateMany'].setDefaultsOnInsert; - - if (shouldSetDefaultsOnInsert !== false) { - setDefaultsOnInsert(op['updateMany']['filter'], model.schema, op['updateMany']['update'], { - setDefaultsOnInsert: true, - upsert: op['updateMany'].upsert - }); - } - - const doInitTimestamps = getTimestampsOpt(op['updateMany'], options); - - if (model.schema.$timestamps != null && doInitTimestamps) { - const createdAt = model.schema.$timestamps.createdAt; - const updatedAt = model.schema.$timestamps.updatedAt; - applyTimestampsToUpdate(now, createdAt, updatedAt, op['updateMany']['update'], {}); - } - if (doInitTimestamps) { - applyTimestampsToChildren(now, op['updateMany']['update'], model.schema); - } - - _addDiscriminatorToObject(schema, op['updateMany']['filter']); - - decorateUpdateWithVersionKey( - op['updateMany']['update'], - op['updateMany'], - model.schema.options.versionKey - ); - - op['updateMany']['filter'] = cast(model.schema, op['updateMany']['filter'], { - strict: strict, - upsert: op['updateMany'].upsert - }); - - op['updateMany']['update'] = castUpdate(model.schema, op['updateMany']['update'], { - strict: strict, - upsert: op['updateMany'].upsert, - arrayFilters: op['updateMany'].arrayFilters, - overwriteDiscriminatorKey: op['updateMany'].overwriteDiscriminatorKey - }, model, op['updateMany']['filter']); - } catch (error) { - return callback(error, null); + module.exports.castUpdateMany(originalModel, op['updateMany'], options, now); + callback(null); + } catch (err) { + callback(err); } - - callback(null); }; } else if (op['replaceOne']) { return (callback) => { - const model = decideModelByObject(originalModel, op['replaceOne']['filter']); - const schema = model.schema; - const strict = options.strict != null ? options.strict : model.schema.options.strict; - - _addDiscriminatorToObject(schema, op['replaceOne']['filter']); - try { - op['replaceOne']['filter'] = cast(model.schema, op['replaceOne']['filter'], { - strict: strict, - upsert: op['replaceOne'].upsert - }); - } catch (error) { - return callback(error, null); - } - - // set `skipId`, otherwise we get "_id field cannot be changed" - const doc = new model(op['replaceOne']['replacement'], strict, true); - if (model.schema.options.timestamps && getTimestampsOpt(op['replaceOne'], options)) { - doc.initializeTimestamps(); - } - if (options.session != null) { - doc.$session(options.session); - } - const versionKey = model?.schema?.options?.versionKey; - if (versionKey && doc[versionKey] == null) { - doc[versionKey] = 0; - } - op['replaceOne']['replacement'] = doc; - - if (options.skipValidation || op['replaceOne'].skipValidation) { - op['replaceOne']['replacement'] = op['replaceOne']['replacement'].toBSON(); - callback(null); - return; - } - - op['replaceOne']['replacement'].$validate().then( - () => { - op['replaceOne']['replacement'] = op['replaceOne']['replacement'].toBSON(); - callback(null); - }, - error => { - callback(error, null); - } - ); + module.exports.castReplaceOne(originalModel, op['replaceOne'], options).then(() => callback(null), err => callback(err)); }; } else if (op['deleteOne']) { return (callback) => { - const model = decideModelByObject(originalModel, op['deleteOne']['filter']); - const schema = model.schema; - - _addDiscriminatorToObject(schema, op['deleteOne']['filter']); - try { - op['deleteOne']['filter'] = cast(model.schema, - op['deleteOne']['filter']); - } catch (error) { - return callback(error, null); + module.exports.castDeleteOne(originalModel, op['deleteOne']); + callback(null); + } catch (err) { + callback(err); } - - callback(null); }; } else if (op['deleteMany']) { return (callback) => { - const model = decideModelByObject(originalModel, op['deleteMany']['filter']); - const schema = model.schema; - - _addDiscriminatorToObject(schema, op['deleteMany']['filter']); - try { - op['deleteMany']['filter'] = cast(model.schema, - op['deleteMany']['filter']); - } catch (error) { - return callback(error, null); + module.exports.castDeleteMany(originalModel, op['deleteMany']); + callback(null); + } catch (err) { + callback(err); } - - callback(null); }; } else { return (callback) => { @@ -260,6 +73,206 @@ module.exports = function castBulkWrite(originalModel, op, options) { } }; +module.exports.castInsertOne = async function castInsertOne(originalModel, insertOne, options) { + const model = decideModelByObject(originalModel, insertOne['document']); + + const doc = new model(insertOne['document']); + if (model.schema.options.timestamps && getTimestampsOpt(insertOne, options)) { + doc.initializeTimestamps(); + } + if (options.session != null) { + doc.$session(options.session); + } + const versionKey = model?.schema?.options?.versionKey; + if (versionKey && doc[versionKey] == null) { + doc[versionKey] = 0; + } + insertOne['document'] = doc; + + if (options.skipValidation || insertOne.skipValidation) { + return insertOne; + } + + await insertOne['document'].$validate(); + return insertOne; +}; + +module.exports.castUpdateOne = function castUpdateOne(originalModel, updateOne, options, now) { + if (!updateOne['filter']) { + throw new Error('Must provide a filter object.'); + } + if (!updateOne['update']) { + throw new Error('Must provide an update object.'); + } + + const model = decideModelByObject(originalModel, updateOne['filter']); + const schema = model.schema; + const strict = options.strict != null ? options.strict : model.schema.options.strict; + + const update = clone(updateOne['update']); + + _addDiscriminatorToObject(schema, updateOne['filter']); + + const doInitTimestamps = getTimestampsOpt(updateOne, options); + + if (model.schema.$timestamps != null && doInitTimestamps) { + const createdAt = model.schema.$timestamps.createdAt; + const updatedAt = model.schema.$timestamps.updatedAt; + applyTimestampsToUpdate(now, createdAt, updatedAt, update, {}); + } + + if (doInitTimestamps) { + applyTimestampsToChildren(now, update, model.schema); + } + + const globalSetDefaultsOnInsert = originalModel.base.options.setDefaultsOnInsert; + const shouldSetDefaultsOnInsert = updateOne.setDefaultsOnInsert == null ? + globalSetDefaultsOnInsert : + updateOne.setDefaultsOnInsert; + if (shouldSetDefaultsOnInsert !== false) { + setDefaultsOnInsert(updateOne['filter'], model.schema, update, { + setDefaultsOnInsert: true, + upsert: updateOne.upsert + }); + } + + decorateUpdateWithVersionKey( + update, + updateOne, + model.schema.options.versionKey + ); + + updateOne['filter'] = cast(model.schema, updateOne['filter'], { + strict: strict, + upsert: updateOne.upsert + }); + updateOne['update'] = castUpdate(model.schema, update, { + strict: strict, + upsert: updateOne.upsert, + arrayFilters: updateOne.arrayFilters, + overwriteDiscriminatorKey: updateOne.overwriteDiscriminatorKey + }, model, updateOne['filter']); + + return updateOne; +}; + +module.exports.castUpdateMany = function castUpdateMany(originalModel, updateMany, options, now) { + if (!updateMany['filter']) { + throw new Error('Must provide a filter object.'); + } + if (!updateMany['update']) { + throw new Error('Must provide an update object.'); + } + + const model = decideModelByObject(originalModel, updateMany['filter']); + const schema = model.schema; + const strict = options.strict != null ? options.strict : model.schema.options.strict; + + const globalSetDefaultsOnInsert = originalModel.base.options.setDefaultsOnInsert; + const shouldSetDefaultsOnInsert = updateMany.setDefaultsOnInsert == null ? + globalSetDefaultsOnInsert : + updateMany.setDefaultsOnInsert; + + if (shouldSetDefaultsOnInsert !== false) { + setDefaultsOnInsert(updateMany['filter'], model.schema, updateMany['update'], { + setDefaultsOnInsert: true, + upsert: updateMany.upsert + }); + } + + const doInitTimestamps = getTimestampsOpt(updateMany, options); + + if (model.schema.$timestamps != null && doInitTimestamps) { + const createdAt = model.schema.$timestamps.createdAt; + const updatedAt = model.schema.$timestamps.updatedAt; + applyTimestampsToUpdate(now, createdAt, updatedAt, updateMany['update'], {}); + } + if (doInitTimestamps) { + applyTimestampsToChildren(now, updateMany['update'], model.schema); + } + + _addDiscriminatorToObject(schema, updateMany['filter']); + + decorateUpdateWithVersionKey( + updateMany['update'], + updateMany, + model.schema.options.versionKey + ); + + updateMany['filter'] = cast(model.schema, updateMany['filter'], { + strict: strict, + upsert: updateMany.upsert + }); + + updateMany['update'] = castUpdate(model.schema, updateMany['update'], { + strict: strict, + upsert: updateMany.upsert, + arrayFilters: updateMany.arrayFilters, + overwriteDiscriminatorKey: updateMany.overwriteDiscriminatorKey + }, model, updateMany['filter']); +}; + +module.exports.castReplaceOne = async function castReplaceOne(originalModel, replaceOne, options) { + const model = decideModelByObject(originalModel, replaceOne['filter']); + const schema = model.schema; + const strict = options.strict != null ? options.strict : model.schema.options.strict; + + _addDiscriminatorToObject(schema, replaceOne['filter']); + replaceOne['filter'] = cast(model.schema, replaceOne['filter'], { + strict: strict, + upsert: replaceOne.upsert + }); + + // set `skipId`, otherwise we get "_id field cannot be changed" + const doc = new model(replaceOne['replacement'], strict, true); + if (model.schema.options.timestamps && getTimestampsOpt(replaceOne, options)) { + doc.initializeTimestamps(); + } + if (options.session != null) { + doc.$session(options.session); + } + const versionKey = model?.schema?.options?.versionKey; + if (versionKey && doc[versionKey] == null) { + doc[versionKey] = 0; + } + replaceOne['replacement'] = doc; + + if (options.skipValidation || replaceOne.skipValidation) { + replaceOne['replacement'] = replaceOne['replacement'].toBSON(); + return; + } + + await replaceOne['replacement'].$validate(); + replaceOne['replacement'] = replaceOne['replacement'].toBSON(); +}; + +module.exports.castDeleteOne = function castDeleteOne(originalModel, deleteOne) { + const model = decideModelByObject(originalModel, deleteOne['filter']); + const schema = model.schema; + + _addDiscriminatorToObject(schema, deleteOne['filter']); + + deleteOne['filter'] = cast(model.schema, deleteOne['filter']); +}; + +module.exports.castDeleteMany = function castDeleteMany(originalModel, deleteMany) { + const model = decideModelByObject(originalModel, deleteMany['filter']); + const schema = model.schema; + + _addDiscriminatorToObject(schema, deleteMany['filter']); + + deleteMany['filter'] = cast(model.schema, deleteMany['filter']); +}; + +module.exports.cast = { + insertOne: module.exports.castInsertOne, + updateOne: module.exports.castUpdateOne, + updateMany: module.exports.castUpdateMany, + replaceOne: module.exports.castReplaceOne, + deleteOne: module.exports.castDeleteOne, + deleteMany: module.exports.castDeleteMany +}; + function _addDiscriminatorToObject(schema, obj) { if (schema == null) { return; diff --git a/lib/helpers/populate/getModelsMapForPopulate.js b/lib/helpers/populate/getModelsMapForPopulate.js index bd748ed0722..2673bdd6748 100644 --- a/lib/helpers/populate/getModelsMapForPopulate.js +++ b/lib/helpers/populate/getModelsMapForPopulate.js @@ -54,6 +54,13 @@ module.exports = function getModelsMapForPopulate(model, docs, options) { doc = docs[i]; let justOne = null; + if (doc.$__ != null && doc.populated(options.path)) { + const forceRepopulate = options.forceRepopulate != null ? options.forceRepopulate : doc.constructor.base.options.forceRepopulate; + if (forceRepopulate === false) { + continue; + } + } + const docSchema = doc != null && doc.$__ != null ? doc.$__schema : modelSchema; schema = getSchemaTypes(model, docSchema, doc, options.path); 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 bad3886d302..9f347ac41c4 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) { @@ -4199,6 +4200,7 @@ Model.validate = async function validate(obj, pathsOrOptions, context) { * - options: optional query options like sort, limit, etc * - justOne: optional boolean, if true Mongoose will always set `path` to a document, or `null` if no document was found. If false, Mongoose will always set `path` to an array, which will be empty if no documents are found. Inferred from schema by default. * - strictPopulate: optional boolean, set to `false` to allow populating paths that aren't in the schema. + * - forceRepopulate: optional boolean, defaults to `true`. Set to `false` to prevent Mongoose from repopulating paths that are already populated * * #### Example: * @@ -4235,6 +4237,7 @@ Model.validate = async function validate(obj, pathsOrOptions, context) { * @param {Boolean} [options.strictPopulate=true] Set to false to allow populating paths that aren't defined in the given model's schema. * @param {Object} [options.options=null] Additional options like `limit` and `lean`. * @param {Function} [options.transform=null] Function that Mongoose will call on every populated document that allows you to transform the populated document. + * @param {Boolean} [options.forceRepopulate=true] Set to `false` to prevent Mongoose from repopulating paths that are already populated * @param {Function} [callback(err,doc)] Optional callback, executed upon completion. Receives `err` and the `doc(s)`. * @return {Promise} * @api public @@ -4879,6 +4882,23 @@ Model.inspect = function() { return `Model { ${this.modelName} }`; }; +/** + * Return the MongoDB namespace for this model as a string. The namespace is the database name, followed by '.', followed by the collection name. + * + * #### Example: + * + * const conn = mongoose.createConnection('mongodb://127.0.0.1:27017/mydb'); + * const TestModel = conn.model('Test', mongoose.Schema({ name: String })); + * + * TestModel.namespace(); // 'mydb.tests' + * + * @api public + */ + +Model.namespace = function namespace() { + return this.db.name + '.' + this.collection.collectionName; +}; + if (util.inspect.custom) { // Avoid Node deprecation warning DEP0079 Model[util.inspect.custom] = Model.inspect; diff --git a/lib/mongoose.js b/lib/mongoose.js index 2a03b638209..82bd9f50ae4 100644 --- a/lib/mongoose.js +++ b/lib/mongoose.js @@ -987,6 +987,7 @@ Mongoose.prototype.VirtualType = VirtualType; * - [ObjectId](https://mongoosejs.com/docs/schematypes.html#objectids) * - [Map](https://mongoosejs.com/docs/schematypes.html#maps) * - [Subdocument](https://mongoosejs.com/docs/schematypes.html#schemas) + * - [Int32](https://mongoosejs.com/docs/schematypes.html#int32) * * Using this exposed access to the `ObjectId` type, we can construct ids on demand. * @@ -1138,6 +1139,7 @@ Mongoose.prototype.syncIndexes = function(options) { Mongoose.prototype.Decimal128 = SchemaTypes.Decimal128; + /** * The Mongoose Mixed [SchemaType](https://mongoosejs.com/docs/schematypes.html). Used for * declaring paths in your schema that Mongoose's change tracking, casting, diff --git a/lib/query.js b/lib/query.js index 6af5e422715..d50f913cc74 100644 --- a/lib/query.js +++ b/lib/query.js @@ -4467,6 +4467,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 7caaac75920..163a6640209 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2810,6 +2810,39 @@ 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) { + 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 */ @@ -2859,6 +2892,8 @@ module.exports = exports = Schema; * - [Mixed](https://mongoosejs.com/docs/schematypes.html#mixed) * - [UUID](https://mongoosejs.com/docs/schematypes.html#uuid) * - [BigInt](https://mongoosejs.com/docs/schematypes.html#bigint) + * - [Double] (https://mongoosejs.com/docs/schematypes.html#double) + * - [Int32](https://mongoosejs.com/docs/schematypes.html#int32) * * Using this exposed access to the `Mixed` SchemaType, we can use them in our schema. * diff --git a/lib/schema/bigint.js b/lib/schema/bigint.js index 02912627dd5..6baec1f7efd 100644 --- a/lib/schema/bigint.js +++ b/lib/schema/bigint.js @@ -81,12 +81,12 @@ SchemaBigInt.setters = []; SchemaBigInt.get = SchemaType.get; /** - * Get/set the function used to cast arbitrary values to booleans. + * Get/set the function used to cast arbitrary values to bigints. * * #### Example: * * // Make Mongoose cast empty string '' to false. - * const original = mongoose.Schema.BigInt.cast(); + * const original = mongoose.Schema.Types.BigInt.cast(); * mongoose.Schema.BigInt.cast(v => { * if (v === '') { * return false; diff --git a/lib/schema/double.js b/lib/schema/double.js new file mode 100644 index 00000000000..79c94752184 --- /dev/null +++ b/lib/schema/double.js @@ -0,0 +1,212 @@ +'use strict'; + +/*! + * Module dependencies. + */ + +const CastError = require('../error/cast'); +const SchemaType = require('../schemaType'); +const castDouble = require('../cast/double'); + +/** + * Double SchemaType constructor. + * + * @param {String} path + * @param {Object} options + * @inherits SchemaType + * @api public + */ + +function SchemaDouble(path, options) { + SchemaType.call(this, path, options, 'Double'); +} + +/** + * This schema type's name, to defend against minifiers that mangle + * function names. + * + * @api public + */ +SchemaDouble.schemaName = 'Double'; + +SchemaDouble.defaultOptions = {}; + +/*! + * Inherits from SchemaType. + */ +SchemaDouble.prototype = Object.create(SchemaType.prototype); +SchemaDouble.prototype.constructor = SchemaDouble; + +/*! + * ignore + */ + +SchemaDouble._cast = castDouble; + +/** + * Sets a default option for all Double instances. + * + * #### Example: + * + * // Make all Double fields required by default + * mongoose.Schema.Double.set('required', true); + * + * @param {String} option The option you'd like to set the value for + * @param {Any} value value for option + * @return {undefined} + * @function set + * @static + * @api public + */ + +SchemaDouble.set = SchemaType.set; + +SchemaDouble.setters = []; + +/** + * Attaches a getter for all Double instances + * + * #### Example: + * + * // Converts Double to be a represent milliseconds upon access + * mongoose.Schema.Double.get(v => v == null ? '0.000 ms' : v.toString() + ' ms'); + * + * @param {Function} getter + * @return {this} + * @function get + * @static + * @api public + */ + +SchemaDouble.get = SchemaType.get; + +/*! + * ignore + */ + +SchemaDouble._defaultCaster = v => { + if (v != null) { + if (v._bsontype !== 'Double') { + throw new Error(); + } + } + + return v; +}; + +/** + * Get/set the function used to cast arbitrary values to IEEE 754-2008 floating points + * + * #### Example: + * + * // Make Mongoose cast any NaNs to 0 + * const defaultCast = mongoose.Schema.Types.Double.cast(); + * mongoose.Schema.Types.Double.cast(v => { + * if (isNaN(v)) { + * return 0; + * } + * return defaultCast(v); + * }); + * + * // Or disable casting for Doubles entirely (only JS numbers are permitted) + * mongoose.Schema.Double.cast(false); + * + * + * @param {Function} caster + * @return {Function} + * @function get + * @static + * @api public + */ + +SchemaDouble.cast = function cast(caster) { + if (arguments.length === 0) { + return this._cast; + } + if (caster === false) { + caster = this._defaultCaster; + } + + this._cast = caster; + + return this._cast; +}; + + +/*! + * ignore + */ + +SchemaDouble._checkRequired = v => v != null; +/** + * Override the function the required validator uses to check whether a value + * passes the `required` check. + * + * @param {Function} fn + * @return {Function} + * @function checkRequired + * @static + * @api public + */ + +SchemaDouble.checkRequired = SchemaType.checkRequired; + +/** + * Check if the given value satisfies a required validator. + * + * @param {Any} value + * @return {Boolean} + * @api public + */ + +SchemaDouble.prototype.checkRequired = function(value) { + return this.constructor._checkRequired(value); +}; + +/** + * Casts to Double + * + * @param {Object} value + * @param {Object} model this value is optional + * @api private + */ + +SchemaDouble.prototype.cast = function(value) { + let castDouble; + if (typeof this._castFunction === 'function') { + castDouble = this._castFunction; + } else if (typeof this.constructor.cast === 'function') { + castDouble = this.constructor.cast(); + } else { + castDouble = SchemaDouble.cast(); + } + + try { + return castDouble(value); + } catch (error) { + throw new CastError('Double', value, this.path, error, this); + } +}; + +/*! + * ignore + */ + +function handleSingle(val) { + return this.cast(val); +} + +SchemaDouble.prototype.$conditionalHandlers = { + ...SchemaType.prototype.$conditionalHandlers, + $gt: handleSingle, + $gte: handleSingle, + $lt: handleSingle, + $lte: handleSingle +}; + + +/*! + * Module exports. + */ + +module.exports = SchemaDouble; diff --git a/lib/schema/index.js b/lib/schema/index.js index 0caf091adf2..2d2e99211d1 100644 --- a/lib/schema/index.js +++ b/lib/schema/index.js @@ -19,6 +19,8 @@ exports.ObjectId = require('./objectId'); exports.String = require('./string'); exports.Subdocument = require('./subdocument'); exports.UUID = require('./uuid'); +exports.Double = require('./double'); +exports.Int32 = require('./int32'); // alias diff --git a/lib/schema/int32.js b/lib/schema/int32.js new file mode 100644 index 00000000000..51594934e91 --- /dev/null +++ b/lib/schema/int32.js @@ -0,0 +1,249 @@ +'use strict'; + +/*! + * Module dependencies. + */ + +const CastError = require('../error/cast'); +const SchemaType = require('../schemaType'); +const castInt32 = require('../cast/int32'); + +/** + * Int32 SchemaType constructor. + * + * @param {String} path + * @param {Object} options + * @inherits SchemaType + * @api public + */ + +function SchemaInt32(path, options) { + SchemaType.call(this, path, options, 'Int32'); +} + +/** + * This schema type's name, to defend against minifiers that mangle + * function names. + * + * @api public + */ +SchemaInt32.schemaName = 'Int32'; + +SchemaInt32.defaultOptions = {}; + +/*! + * Inherits from SchemaType. + */ +SchemaInt32.prototype = Object.create(SchemaType.prototype); +SchemaInt32.prototype.constructor = SchemaInt32; + +/*! + * ignore + */ + +SchemaInt32._cast = castInt32; + +/** + * Sets a default option for all Int32 instances. + * + * #### Example: + * + * // Make all Int32 fields required by default + * mongoose.Schema.Int32.set('required', true); + * + * @param {String} option The option you'd like to set the value for + * @param {Any} value value for option + * @return {undefined} + * @function set + * @static + * @api public + */ + +SchemaInt32.set = SchemaType.set; + +SchemaInt32.setters = []; + +/** + * Attaches a getter for all Int32 instances + * + * #### Example: + * + * // Converts int32 to be a represent milliseconds upon access + * mongoose.Schema.Int32.get(v => v == null ? '0 ms' : v.toString() + ' ms'); + * + * @param {Function} getter + * @return {this} + * @function get + * @static + * @api public + */ + +SchemaInt32.get = SchemaType.get; + +/*! + * ignore + */ + +SchemaInt32._defaultCaster = v => { + const INT32_MAX = 0x7FFFFFFF; + const INT32_MIN = -0x80000000; + + if (v != null) { + if (typeof v !== 'number' || v !== (v | 0) || v < INT32_MIN || v > INT32_MAX) { + throw new Error(); + } + } + + return v; +}; + +/** + * Get/set the function used to cast arbitrary values to 32-bit integers + * + * #### Example: + * + * // Make Mongoose cast NaN to 0 + * const defaultCast = mongoose.Schema.Types.Int32.cast(); + * mongoose.Schema.Types.Int32.cast(v => { + * if (isNaN(v)) { + * return 0; + * } + * return defaultCast(v); + * }); + * + * // Or disable casting for Int32s entirely (only JS numbers within 32-bit integer bounds and null-ish values are permitted) + * mongoose.Schema.Int32.cast(false); + * + * + * @param {Function} caster + * @return {Function} + * @function get + * @static + * @api public + */ + +SchemaInt32.cast = function cast(caster) { + if (arguments.length === 0) { + return this._cast; + } + if (caster === false) { + caster = this._defaultCaster; + } + + this._cast = caster; + + return this._cast; +}; + + +/*! + * ignore + */ + +SchemaInt32._checkRequired = v => v != null; +/** + * Override the function the required validator uses to check whether a value + * passes the `required` check. + * + * @param {Function} fn + * @return {Function} + * @function checkRequired + * @static + * @api public + */ + +SchemaInt32.checkRequired = SchemaType.checkRequired; + +/** + * Check if the given value satisfies a required validator. + * + * @param {Any} value + * @return {Boolean} + * @api public + */ + +SchemaInt32.prototype.checkRequired = function(value) { + return this.constructor._checkRequired(value); +}; + +/** + * Casts to Int32 + * + * @param {Object} value + * @param {Object} model this value is optional + * @api private + */ + +SchemaInt32.prototype.cast = function(value) { + let castInt32; + if (typeof this._castFunction === 'function') { + castInt32 = this._castFunction; + } else if (typeof this.constructor.cast === 'function') { + castInt32 = this.constructor.cast(); + } else { + castInt32 = SchemaInt32.cast(); + } + + try { + return castInt32(value); + } catch (error) { + throw new CastError('Int32', value, this.path, error, this); + } +}; + +/*! + * ignore + */ + +SchemaInt32.$conditionalHandlers = { + ...SchemaType.prototype.$conditionalHandlers, + $gt: handleSingle, + $gte: handleSingle, + $lt: handleSingle, + $lte: handleSingle +}; + +/*! + * ignore + */ + +function handleSingle(val, context) { + return this.castForQuery(null, val, context); +} + +/** + * Casts contents for queries. + * + * @param {String} $conditional + * @param {any} val + * @api private + */ + +SchemaInt32.prototype.castForQuery = function($conditional, val, context) { + let handler; + if ($conditional != null) { + handler = SchemaInt32.$conditionalHandlers[$conditional]; + + if (handler) { + return handler.call(this, val); + } + + return this.applySetters(null, val, context); + } + + try { + return this.applySetters(val, context); + } catch (err) { + if (err instanceof CastError && err.path === this.path && this.$fullPath != null) { + err.path = this.$fullPath; + } + throw err; + } +}; + + +/*! + * Module exports. + */ + +module.exports = SchemaInt32; diff --git a/lib/schemaType.js b/lib/schemaType.js index c15ce35faf7..ed63c47bbc7 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; }; @@ -1744,6 +1763,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/lib/validOptions.js b/lib/validOptions.js index 15fd3e634e7..6c09480def1 100644 --- a/lib/validOptions.js +++ b/lib/validOptions.js @@ -17,6 +17,7 @@ const VALID_OPTIONS = Object.freeze([ 'cloneSchemas', 'createInitialConnection', 'debug', + 'forceRepopulate', 'id', 'timestamps.createdAt.immutable', 'maxTimeMS', diff --git a/package.json b/package.json index a5de4415930..898310b7297 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,9 @@ ], "license": "MIT", "dependencies": { - "bson": "^6.7.0", + "bson": "^6.10.1", "kareem": "2.6.3", - "mongodb": "~6.10.0", + "mongodb": "~6.12.0", "mpath": "0.9.0", "mquery": "5.0.0", "ms": "2.1.3", diff --git a/test/connection.test.js b/test/connection.test.js index 03f87b40f3d..2243886f6be 100644 --- a/test/connection.test.js +++ b/test/connection.test.js @@ -11,6 +11,7 @@ const Q = require('q'); const assert = require('assert'); const mongodb = require('mongodb'); const MongooseError = require('../lib/error/index'); +const CastError = require('../lib/error/cast'); const mongoose = start.mongoose; const Schema = mongoose.Schema; @@ -1694,4 +1695,95 @@ describe('connections:', function() { assert.ok(conn); }); }); + + it('connection bulkWrite() ordered (gh-15028)', async function() { + const db = start(); + + const version = await start.mongodVersion(); + if (version[0] < 8) { + this.skip(); + return; + } + const Test = db.model('Test', new Schema({ name: { type: String, required: true } })); + + await Test.deleteMany({}); + await db.bulkWrite([{ model: 'Test', name: 'insertOne', document: { name: 'test1' } }]); + assert.ok(await Test.exists({ name: 'test1' })); + + await db.bulkWrite([{ model: Test, name: 'insertOne', document: { name: 'test2' } }]); + assert.ok(await Test.exists({ name: 'test2' })); + + await assert.rejects( + () => db.bulkWrite([{ name: 'insertOne', document: { name: 'foo' } }]), + /Must specify model in Connection.prototype.bulkWrite\(\) operations/ + ); + + await assert.rejects( + () => db.bulkWrite([{ model: Test, document: { name: 'foo' } }]), + /Must specify operation name in Connection.prototype.bulkWrite\(\)/ + ); + await assert.rejects( + () => db.bulkWrite([{ model: Test, name: 'upsertAll', document: { name: 'foo' } }]), + /Unrecognized bulkWrite\(\) operation name upsertAll/ + ); + }); + + it('connection bulkWrite() unordered (gh-15028)', async function() { + const db = start(); + + const version = await start.mongodVersion(); + if (version[0] < 8) { + this.skip(); + return; + } + + const Test = db.model('Test', new Schema({ name: { type: String, required: true }, num: Number })); + + await Test.deleteMany({}); + await db.bulkWrite([{ model: 'Test', name: 'insertOne', document: { name: 'test1' } }], { ordered: false }); + assert.ok(await Test.exists({ name: 'test1' })); + + await db.bulkWrite([{ model: Test, name: 'insertOne', document: { name: 'test2' } }], { ordered: false }); + assert.ok(await Test.exists({ name: 'test2' })); + + await assert.rejects( + () => { + return db.bulkWrite([ + { name: 'insertOne', document: { name: 'foo' } }, + { model: Test, name: 'insertOne', document: { name: 'test3' } } + ], { ordered: false, throwOnValidationError: true }); + }, + /Must specify model in Connection.prototype.bulkWrite\(\) operations/ + ); + assert.ok(await Test.exists({ name: 'test3' })); + + await assert.rejects( + () => db.bulkWrite([ + { model: Test, document: { name: 'foo' } }, + { model: Test, name: 'insertOne', document: { name: 'test4' } } + ], { ordered: false, throwOnValidationError: true }), + /Must specify operation name in Connection.prototype.bulkWrite\(\)/ + ); + assert.ok(await Test.exists({ name: 'test4' })); + + await assert.rejects( + () => db.bulkWrite([ + { model: Test, name: 'upsertAll', document: { name: 'foo' } }, + { model: Test, name: 'insertOne', document: { name: 'test5' } } + ], { ordered: false, throwOnValidationError: true }), + /Unrecognized bulkWrite\(\) operation name upsertAll/ + ); + assert.ok(await Test.exists({ name: 'test5' })); + + const res = await db.bulkWrite([ + { model: 'Test', name: 'updateOne', filter: { name: 'test5' }, update: { $set: { num: 42 } } }, + { model: 'Test', name: 'updateOne', filter: { name: 'test4' }, update: { $set: { num: 'not a number' } } } + ], { ordered: false }); + assert.equal(res.matchedCount, 1); + assert.equal(res.modifiedCount, 1); + assert.equal(res.mongoose.results.length, 2); + assert.equal(res.mongoose.results[0], null); + assert.ok(res.mongoose.results[1] instanceof CastError); + assert.ok(res.mongoose.results[1].message.includes('not a number')); + }); }); 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() { diff --git a/test/double.test.js b/test/double.test.js new file mode 100644 index 00000000000..6bf7e6c59e7 --- /dev/null +++ b/test/double.test.js @@ -0,0 +1,432 @@ +'use strict'; + +const assert = require('assert'); +const start = require('./common'); +const BSON = require('bson'); + +const mongoose = start.mongoose; +const Schema = mongoose.Schema; + + +describe('Double', function() { + beforeEach(() => mongoose.deleteModel(/Test/)); + + it('is a valid schema type', function() { + const schema = new Schema({ + myDouble: Schema.Types.Double + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble: 13 + }); + assert.deepStrictEqual(doc.myDouble, new BSON.Double(13)); + assert.equal(typeof doc.myDouble, 'object'); + }); + + describe('supports the required property', function() { + it('when value is null', async function() { + const schema = new Schema({ + Double: { + type: Schema.Types.Double, + required: true + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + double: null + }); + + const err = await doc.validate().then(() => null, err => err); + assert.ok(err); + assert.ok(err.errors['Double']); + assert.equal(err.errors['Double'].name, 'ValidatorError'); + assert.equal( + err.errors['Double'].message, + 'Path `Double` is required.' + ); + }); + it('when value is non-null', async function() { + const schema = new Schema({ + Double: { + type: Schema.Types.Double, + required: true + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + double: 3 + }); + + const err = await doc.validate().then(() => null, err => err); + assert.ok(err); + assert.ok(err.errors['Double']); + assert.equal(err.errors['Double'].name, 'ValidatorError'); + assert.equal( + err.errors['Double'].message, + 'Path `Double` is required.' + ); + }); + }); + + describe('special inputs', function() { + it('supports undefined as input', function() { + const schema = new Schema({ + myDouble: { + type: Schema.Types.Double + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble: undefined + }); + assert.deepStrictEqual(doc.myDouble, undefined); + }); + + it('supports null as input', function() { + const schema = new Schema({ + myDouble: { + type: Schema.Types.Double + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble: null + }); + assert.deepStrictEqual(doc.myDouble, null); + }); + }); + + describe('valid casts', function() { + it('casts from decimal string', function() { + const schema = new Schema({ + myDouble: { + type: Schema.Types.Double + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble: '-42.008' + }); + assert.deepStrictEqual(doc.myDouble, new BSON.Double(-42.008)); + }); + + it('casts from exponential string', function() { + const schema = new Schema({ + myDouble: { + type: Schema.Types.Double + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble: '1.22008e45' + }); + assert.deepStrictEqual(doc.myDouble, new BSON.Double(1.22008e45)); + }); + + it('casts from infinite string', function() { + const schema = new Schema({ + myDouble1: { + type: Schema.Types.Double + }, + myDouble2: { + type: Schema.Types.Double + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble1: 'Infinity', + myDouble2: '-Infinity' + }); + assert.deepStrictEqual(doc.myDouble1, new BSON.Double(Infinity)); + assert.deepStrictEqual(doc.myDouble2, new BSON.Double(-Infinity)); + }); + + it('casts from NaN string', function() { + const schema = new Schema({ + myDouble: { + type: Schema.Types.Double + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble: 'NaN' + }); + assert.deepStrictEqual(doc.myDouble, new BSON.Double('NaN')); + }); + + it('casts from number', function() { + const schema = new Schema({ + myDouble: Schema.Types.Double + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble: 988 + }); + assert.deepStrictEqual(doc.myDouble, new BSON.Double(988)); + }); + + it('casts from bigint', function() { + const schema = new Schema({ + myDouble: Schema.Types.Double + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble: -997n + }); + assert.deepStrictEqual(doc.myDouble, new BSON.Double(-997)); + }); + + it('casts from BSON.Long', function() { + const schema = new Schema({ + myDouble: Schema.Types.Double + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble: BSON.Long.fromNumber(-997987) + }); + assert.deepStrictEqual(doc.myDouble, new BSON.Double(-997987)); + }); + + it('casts from BSON.Double', function() { + const schema = new Schema({ + myDouble: Schema.Types.Double + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble: new BSON.Double(-997983.33) + }); + assert.deepStrictEqual(doc.myDouble, new BSON.Double(-997983.33)); + }); + + it('casts boolean true to 1', function() { + const schema = new Schema({ + myDouble: Schema.Types.Double + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble: true + }); + assert.deepStrictEqual(doc.myDouble, new BSON.Double(1)); + }); + + it('casts boolean false to 0', function() { + const schema = new Schema({ + myDouble: Schema.Types.Double + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble: false + }); + assert.deepStrictEqual(doc.myDouble, new BSON.Double(0)); + }); + + it('casts empty string to null', function() { + const schema = new Schema({ + myDouble: Schema.Types.Double + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble: '' + }); + assert.deepStrictEqual(doc.myDouble, null); + }); + + it('supports valueOf() function ', function() { + const schema = new Schema({ + myDouble: Schema.Types.Double + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myDouble: { a: 'random', b: { c: 'whatever' }, valueOf: () => 83.008 } + }); + assert.deepStrictEqual(doc.myDouble, new BSON.Double(83.008)); + }); + }); + + describe('cast errors', () => { + let Test; + + beforeEach(function() { + const schema = new Schema({ + myDouble: Schema.Types.Double + }); + Test = mongoose.model('Test', schema); + }); + + describe('when a non-numeric string is provided to an Double field', () => { + it('throws a CastError upon validation', async() => { + const doc = new Test({ + myDouble: 'helloworld' + }); + + assert.deepStrictEqual(doc.myDouble, undefined); + const err = await doc.validate().catch(e => e); + assert.ok(err); + assert.ok(err.errors['myDouble']); + assert.equal(err.errors['myDouble'].name, 'CastError'); + assert.equal( + err.errors['myDouble'].message, + 'Cast to Double failed for value "helloworld" (type string) at path "myDouble"' + ); + }); + }); + }); + + describe('custom casters', () => { + const defaultCast = mongoose.Schema.Types.Double.cast(); + + afterEach(() => { + mongoose.Schema.Types.Double.cast(defaultCast); + }); + + it('supports cast disabled', async() => { + mongoose.Schema.Types.Double.cast(false); + const schema = new Schema({ + myDouble1: { + type: Schema.Types.Double + }, + myDouble2: { + type: Schema.Types.Double + } + }); + const Test = mongoose.model('Test', schema); + const doc = new Test({ + myDouble1: '52', + myDouble2: new BSON.Double(52) + }); + assert.deepStrictEqual(doc.myDouble1, undefined); + assert.deepStrictEqual(doc.myDouble2, new BSON.Double(52)); + + const err = await doc.validate().catch(e => e); + assert.ok(err); + assert.ok(err.errors['myDouble1']); + }); + + it('supports custom cast', () => { + mongoose.Schema.Types.Double.cast(v => { + if (isNaN(v)) { + return new BSON.Double(2); + } + return defaultCast(v); + }); + const schema = new Schema({ + myDouble: { + type: Schema.Types.Double + } + }); + + const Test = mongoose.model('Test', schema); + const doc = new Test({ + myDouble: NaN + }); + assert.deepStrictEqual(doc.myDouble, new BSON.Double(2)); + }); + }); + + describe('mongoDB integration', function() { + let db; + let Test; + + before(async function() { + db = await start(); + + const schema = new Schema({ + myDouble: Schema.Types.Double + }); + db.deleteModel(/Test/); + Test = db.model('Test', schema); + }); + + after(async function() { + await db.close(); + }); + + beforeEach(async() => { + await Test.deleteMany({}); + }); + + describe('$type compatibility', function() { + it('is queryable as a JS number in MongoDB', async function() { + await Test.create({ myDouble: '42.04' }); + const doc = await Test.findOne({ myDouble: { $type: 'number' } }); + assert.ok(doc); + assert.deepStrictEqual(doc.myDouble, new BSON.Double(42.04)); + }); + + it('is NOT queryable as a BSON Integer in MongoDB if the value is NOT integer', async function() { + await Test.create({ myDouble: '42.04' }); + const doc = await Test.findOne({ myDouble: { $type: 'int' } }); + assert.deepStrictEqual(doc, null); + }); + + it('is queryable as a BSON Double in MongoDB when a non-integer is provided', async function() { + await Test.create({ myDouble: '42.04' }); + const doc = await Test.findOne({ myDouble: { $type: 'double' } }); + assert.deepStrictEqual(doc.myDouble, new BSON.Double(42.04)); + }); + + it('is queryable as a BSON Double in MongoDB when an integer is provided', async function() { + await Test.create({ myDouble: '42' }); + const doc = await Test.findOne({ myDouble: { $type: 'double' } }); + assert.deepStrictEqual(doc.myDouble, new BSON.Double(42)); + }); + }); + + it('can query with comparison operators', async function() { + await Test.create([ + { myDouble: 1.2 }, + { myDouble: 1.709 }, + { myDouble: 1.710 }, + { myDouble: 1.8 } + ]); + + let docs = await Test.find({ myDouble: { $gte: 1.710 } }).sort({ myDouble: 1 }); + assert.equal(docs.length, 2); + assert.deepStrictEqual(docs.map(doc => doc.myDouble), [new BSON.Double(1.710), new BSON.Double(1.8)]); + + docs = await Test.find({ myDouble: { $lt: 1.710 } }).sort({ myDouble: -1 }); + assert.equal(docs.length, 2); + assert.deepStrictEqual(docs.map(doc => doc.myDouble), [new BSON.Double(1.709), new BSON.Double(1.2)]); + }); + + it('supports populate()', async function() { + const parentSchema = new Schema({ + child: { + type: Schema.Types.Double, + ref: 'Child' + } + }); + const childSchema = new Schema({ + _id: Schema.Types.Double, + name: String + }); + const Parent = db.model('Parent', parentSchema); + const Child = db.model('Child', childSchema); + + const { _id } = await Parent.create({ child: 42 }); + await Child.create({ _id: 42, name: 'test-Double-populate' }); + + const doc = await Parent.findById(_id).populate('child'); + assert.ok(doc); + assert.equal(doc.child.name, 'test-Double-populate'); + assert.equal(doc.child._id, 42); + }); + }); +}); diff --git a/test/helpers/isBsonType.test.js b/test/helpers/isBsonType.test.js index 448aaf72db4..2fe16b68892 100644 --- a/test/helpers/isBsonType.test.js +++ b/test/helpers/isBsonType.test.js @@ -5,6 +5,8 @@ const isBsonType = require('../../lib/helpers/isBsonType'); const Decimal128 = require('mongodb').Decimal128; const ObjectId = require('mongodb').ObjectId; +const Double = require('mongodb').Double; +const Int32 = require('mongodb').Int32; describe('isBsonType', () => { it('true for any object with _bsontype property equal typename', () => { @@ -30,4 +32,12 @@ describe('isBsonType', () => { it('true for ObjectId', () => { assert.ok(isBsonType(new ObjectId(), 'ObjectId')); }); + + it('true for Double', () => { + assert.ok(isBsonType(new Double(), 'Double')); + }); + + it('true for Int32', () => { + assert.ok(isBsonType(new Int32(), 'Int32')); + }); }); diff --git a/test/int32.test.js b/test/int32.test.js new file mode 100644 index 00000000000..b05fa82305b --- /dev/null +++ b/test/int32.test.js @@ -0,0 +1,537 @@ +'use strict'; + +const assert = require('assert'); +const start = require('./common'); +const BSON = require('bson'); +const sinon = require('sinon'); + +const mongoose = start.mongoose; +const Schema = mongoose.Schema; + +const INT32_MAX = 0x7FFFFFFF; +const INT32_MIN = -0x80000000; + +describe('Int32', function() { + beforeEach(() => mongoose.deleteModel(/Test/)); + + it('is a valid schema type', function() { + const schema = new Schema({ + myInt32: Schema.Types.Int32 + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt32: 13 + }); + assert.strictEqual(doc.myInt32, 13); + assert.equal(typeof doc.myInt32, 'number'); + }); + + describe('supports the required property', function() { + it('when value is null', async function() { + const schema = new Schema({ + int32: { + type: Schema.Types.Int32, + required: true + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + int: null + }); + + const err = await doc.validate().then(() => null, err => err); + assert.ok(err); + assert.ok(err.errors['int32']); + assert.equal(err.errors['int32'].name, 'ValidatorError'); + assert.equal( + err.errors['int32'].message, + 'Path `int32` is required.' + ); + }); + it('when value is non-null', async function() { + const schema = new Schema({ + int32: { + type: Schema.Types.Int32, + required: true + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + int: 3 + }); + + const err = await doc.validate().then(() => null, err => err); + assert.ok(err); + assert.ok(err.errors['int32']); + assert.equal(err.errors['int32'].name, 'ValidatorError'); + assert.equal( + err.errors['int32'].message, + 'Path `int32` is required.' + ); + }); + }); + + describe('special inputs', function() { + it('supports INT32_MIN as input', function() { + const schema = new Schema({ + myInt: { + type: Schema.Types.Int32 + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt: INT32_MIN + }); + assert.strictEqual(doc.myInt, INT32_MIN); + }); + + it('supports INT32_MAX as input', function() { + const schema = new Schema({ + myInt: { + type: Schema.Types.Int32 + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt: INT32_MAX + }); + assert.strictEqual(doc.myInt, INT32_MAX); + }); + + it('supports undefined as input', function() { + const schema = new Schema({ + myInt: { + type: Schema.Types.Int32 + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt: undefined + }); + assert.strictEqual(doc.myInt, undefined); + }); + + it('supports null as input', function() { + const schema = new Schema({ + myInt: { + type: Schema.Types.Int32 + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt: null + }); + assert.strictEqual(doc.myInt, null); + }); + }); + + describe('valid casts', function() { + it('casts from string', function() { + const schema = new Schema({ + myInt: { + type: Schema.Types.Int32 + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt: '-42' + }); + assert.strictEqual(doc.myInt, -42); + }); + + it('casts from number', function() { + const schema = new Schema({ + myInt: Schema.Types.Int32 + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt: '-997.0' + }); + assert.strictEqual(doc.myInt, -997); + }); + + it('casts from bigint', function() { + const schema = new Schema({ + myInt: Schema.Types.Int32 + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt: -997n + }); + assert.strictEqual(doc.myInt, -997); + }); + + it('casts from BSON.Int32', function() { + const schema = new Schema({ + myInt: Schema.Types.Int32 + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt: new BSON.Int32(-997) + }); + assert.strictEqual(doc.myInt, -997); + }); + + describe('long', function() { + after(function() { + sinon.restore(); + }); + + it('casts from BSON.Long provided its value is within bounds of Int32', function() { + const schema = new Schema({ + myInt: Schema.Types.Int32 + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt: BSON.Long.fromNumber(-997) + }); + assert.strictEqual(doc.myInt, -997); + }); + + it('calls Long.toNumber when casting long', function() { + // this is a perf optimization, since long.toNumber() is faster than Number(long) + const schema = new Schema({ + myInt: Schema.Types.Int32 + }); + const Test = mongoose.model('Test', schema); + + sinon.stub(BSON.Long.prototype, 'toNumber').callsFake(function() { + return 2; + }); + + const doc = new Test({ + myInt: BSON.Long.fromNumber(-997) + }); + + assert.strictEqual(doc.myInt, 2); + }); + }); + + it('casts from BSON.Double provided its value is an integer', function() { + const schema = new Schema({ + myInt: Schema.Types.Int32 + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt: new BSON.Double(-997) + }); + assert.strictEqual(doc.myInt, -997); + }); + + it('casts boolean true to 1', function() { + const schema = new Schema({ + myInt: Schema.Types.Int32 + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt: true + }); + assert.strictEqual(doc.myInt, 1); + }); + + it('casts boolean false to 0', function() { + const schema = new Schema({ + myInt: Schema.Types.Int32 + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt: false + }); + assert.strictEqual(doc.myInt, 0); + }); + + it('casts empty string to null', function() { + const schema = new Schema({ + myInt: Schema.Types.Int32 + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt: '' + }); + assert.strictEqual(doc.myInt, null); + }); + + it('supports valueOf() function ', function() { + const schema = new Schema({ + myInt: Schema.Types.Int32 + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myInt: { a: 'random', b: { c: 'whatever' }, valueOf: () => 83 } + }); + assert.strictEqual(doc.myInt, 83); + }); + }); + + describe('cast errors', () => { + let Test; + + beforeEach(function() { + const schema = new Schema({ + myInt: Schema.Types.Int32 + }); + Test = mongoose.model('Test', schema); + }); + + describe('when a non-integer decimal input is provided to an Int32 field', () => { + it('throws a CastError upon validation', async() => { + const doc = new Test({ + myInt: -42.4 + }); + + assert.strictEqual(doc.myInt, undefined); + const err = await doc.validate().catch(e => e); + assert.ok(err); + assert.ok(err.errors['myInt']); + assert.equal(err.errors['myInt'].name, 'CastError'); + assert.equal( + err.errors['myInt'].message, + 'Cast to Int32 failed for value "-42.4" (type number) at path "myInt"' + ); + }); + }); + + describe('when a non-numeric string is provided to an Int32 field', () => { + it('throws a CastError upon validation', async() => { + const doc = new Test({ + myInt: 'helloworld' + }); + + assert.strictEqual(doc.myInt, undefined); + const err = await doc.validate().catch(e => e); + assert.ok(err); + assert.ok(err.errors['myInt']); + assert.equal(err.errors['myInt'].name, 'CastError'); + assert.equal( + err.errors['myInt'].message, + 'Cast to Int32 failed for value "helloworld" (type string) at path "myInt"' + ); + }); + }); + + describe('when a non-integer decimal string is provided to an Int32 field', () => { + it('throws a CastError upon validation', async() => { + const doc = new Test({ + myInt: '1.2' + }); + + assert.strictEqual(doc.myInt, undefined); + const err = await doc.validate().catch(e => e); + assert.ok(err); + assert.ok(err.errors['myInt']); + assert.equal(err.errors['myInt'].name, 'CastError'); + assert.equal( + err.errors['myInt'].message, + 'Cast to Int32 failed for value "1.2" (type string) at path "myInt"' + ); + }); + }); + + describe('when NaN is provided to an Int32 field', () => { + it('throws a CastError upon validation', async() => { + const doc = new Test({ + myInt: NaN + }); + + assert.strictEqual(doc.myInt, undefined); + const err = await doc.validate().catch(e => e); + assert.ok(err); + assert.ok(err.errors['myInt']); + assert.equal(err.errors['myInt'].name, 'CastError'); + assert.equal( + err.errors['myInt'].message, + 'Cast to Int32 failed for value "NaN" (type number) at path "myInt"' + ); + }); + }); + + describe('when value above INT32_MAX is provided to an Int32 field', () => { + it('throws a CastError upon validation', async() => { + const doc = new Test({ + myInt: INT32_MAX + 1 + }); + + assert.strictEqual(doc.myInt, undefined); + const err = await doc.validate().catch(e => e); + assert.ok(err); + assert.ok(err.errors['myInt']); + assert.equal(err.errors['myInt'].name, 'CastError'); + assert.equal( + err.errors['myInt'].message, + 'Cast to Int32 failed for value "2147483648" (type number) at path "myInt"' + ); + }); + }); + + describe('when value below INT32_MIN is provided to an Int32 field', () => { + it('throws a CastError upon validation', async() => { + const doc = new Test({ + myInt: INT32_MIN - 1 + }); + + assert.strictEqual(doc.myInt, undefined); + const err = await doc.validate().catch(e => e); + assert.ok(err); + assert.ok(err.errors['myInt']); + assert.equal(err.errors['myInt'].name, 'CastError'); + assert.equal( + err.errors['myInt'].message, + 'Cast to Int32 failed for value "-2147483649" (type number) at path "myInt"' + ); + }); + }); + }); + + describe('custom casters', () => { + const defaultCast = mongoose.Schema.Types.Int32.cast(); + + afterEach(() => { + mongoose.Schema.Types.Int32.cast(defaultCast); + }); + + it('supports cast disabled', async() => { + mongoose.Schema.Types.Int32.cast(false); + const schema = new Schema({ + myInt1: { + type: Schema.Types.Int32 + }, + myInt2: { + type: Schema.Types.Int32 + } + }); + const Test = mongoose.model('Test', schema); + const doc = new Test({ + myInt1: '52', + myInt2: 52 + }); + assert.strictEqual(doc.myInt1, undefined); + assert.strictEqual(doc.myInt2, 52); + + const err = await doc.validate().catch(e => e); + assert.ok(err); + assert.ok(err.errors['myInt1']); + }); + + it('supports custom cast', () => { + mongoose.Schema.Types.Int32.cast(v => { + if (isNaN(v)) { + return 0; + } + return defaultCast(v); + }); + const schema = new Schema({ + myInt: { + type: Schema.Types.Int32 + } + }); + + const Test = mongoose.model('Test', schema); + const doc = new Test({ + myInt: NaN + }); + assert.strictEqual(doc.myInt, 0); + }); + }); + + describe('mongoDB integration', function() { + let db; + let Test; + + before(async function() { + db = await start(); + + const schema = new Schema({ + myInt: Schema.Types.Int32 + }); + db.deleteModel(/Test/); + Test = db.model('Test', schema); + }); + + after(async function() { + await db.close(); + }); + + beforeEach(async() => { + await Test.deleteMany({}); + }); + + describe('$type compatibility', function() { + it('is queryable as a JS number in MongoDB', async function() { + await Test.create({ myInt: '42' }); + const doc = await Test.findOne({ myInt: { $type: 'number' } }); + assert.ok(doc); + assert.strictEqual(doc.myInt, 42); + }); + + it('is queryable as a BSON Int32 in MongoDB', async function() { + await Test.create({ myInt: '42' }); + const doc = await Test.findOne({ myInt: { $type: 'int' } }); + assert.ok(doc); + assert.strictEqual(doc.myInt, 42); + }); + + it('is NOT queryable as a BSON Double in MongoDB', async function() { + await Test.create({ myInt: '42' }); + const doc = await Test.findOne({ myInt: { $type: 'double' } }); + assert.equal(doc, undefined); + }); + }); + + it('can query with comparison operators', async function() { + await Test.create([ + { myInt: 1 }, + { myInt: 2 }, + { myInt: 3 }, + { myInt: 4 } + ]); + + let docs = await Test.find({ myInt: { $gte: 3 } }).sort({ myInt: 1 }); + assert.equal(docs.length, 2); + assert.deepStrictEqual(docs.map(doc => doc.myInt), [3, 4]); + + docs = await Test.find({ myInt: { $lt: 3 } }).sort({ myInt: -1 }); + assert.equal(docs.length, 2); + assert.deepStrictEqual(docs.map(doc => doc.myInt), [2, 1]); + }); + + it('supports populate()', async function() { + const parentSchema = new Schema({ + child: { + type: Schema.Types.Int32, + ref: 'Child' + } + }); + const childSchema = new Schema({ + _id: Schema.Types.Int32, + name: String + }); + const Parent = db.model('Parent', parentSchema); + const Child = db.model('Child', childSchema); + + const { _id } = await Parent.create({ child: 42 }); + await Child.create({ _id: 42, name: 'test-int32-populate' }); + + const doc = await Parent.findById(_id).populate('child'); + assert.ok(doc); + assert.equal(doc.child.name, 'test-int32-populate'); + assert.equal(doc.child._id, 42); + }); + }); +}); diff --git a/test/model.populate.test.js b/test/model.populate.test.js index f62e5886c9e..1d7fa43b19a 100644 --- a/test/model.populate.test.js +++ b/test/model.populate.test.js @@ -11325,4 +11325,89 @@ describe('model: populate:', function() { assert.strictEqual(doc.node.length, 1); assert.strictEqual(doc.node[0]._id, '65c7953e-c6e9-4c2f-8328-fe2de7df560d'); }); + + it('avoids repopulating if forceRepopulate is disabled (gh-14979)', async function() { + const ChildSchema = new Schema({ name: String }); + const ParentSchema = new Schema({ + children: [{ type: Schema.Types.ObjectId, ref: 'Child' }], + child: { type: 'ObjectId', ref: 'Child' } + }); + + const Child = db.model('Child', ChildSchema); + const Parent = db.model('Parent', ParentSchema); + + const child = await Child.create({ name: 'Child test' }); + let parent = await Parent.create({ child: child._id, children: [child._id] }); + + parent = await Parent.findOne({ _id: parent._id }).populate(['child', 'children']).orFail(); + child.name = 'Child test updated 1'; + await child.save(); + + await parent.populate({ path: 'child', forceRepopulate: false }); + await parent.populate({ path: 'children', forceRepopulate: false }); + assert.equal(parent.child.name, 'Child test'); + assert.equal(parent.children[0].name, 'Child test'); + + await Parent.populate([parent], { path: 'child', forceRepopulate: false }); + await Parent.populate([parent], { path: 'children', forceRepopulate: false }); + assert.equal(parent.child.name, 'Child test'); + assert.equal(parent.children[0].name, 'Child test'); + + parent.depopulate('child'); + parent.depopulate('children'); + await parent.populate({ path: 'child', forceRepopulate: false }); + await parent.populate({ path: 'children', forceRepopulate: false }); + assert.equal(parent.child.name, 'Child test updated 1'); + assert.equal(parent.children[0].name, 'Child test updated 1'); + }); + + it('handles forceRepopulate as a global option (gh-14979)', async function() { + const m = new mongoose.Mongoose(); + m.set('forceRepopulate', false); + await m.connect(start.uri); + const ChildSchema = new m.Schema({ name: String }); + const ParentSchema = new m.Schema({ + children: [{ type: Schema.Types.ObjectId, ref: 'Child' }], + child: { type: 'ObjectId', ref: 'Child' } + }); + + const Child = m.model('Child', ChildSchema); + const Parent = m.model('Parent', ParentSchema); + + const child = await Child.create({ name: 'Child test' }); + let parent = await Parent.create({ child: child._id, children: [child._id] }); + + parent = await Parent.findOne({ _id: parent._id }).populate(['child', 'children']).orFail(); + child.name = 'Child test updated 1'; + await child.save(); + + await parent.populate({ path: 'child' }); + await parent.populate({ path: 'children' }); + assert.equal(parent.child.name, 'Child test'); + assert.equal(parent.children[0].name, 'Child test'); + + await Parent.populate([parent], { path: 'child' }); + await Parent.populate([parent], { path: 'children' }); + assert.equal(parent.child.name, 'Child test'); + assert.equal(parent.children[0].name, 'Child test'); + + parent.depopulate('child'); + parent.depopulate('children'); + await parent.populate({ path: 'child' }); + await parent.populate({ path: 'children' }); + assert.equal(parent.child.name, 'Child test updated 1'); + assert.equal(parent.children[0].name, 'Child test updated 1'); + + child.name = 'Child test updated 2'; + await child.save(); + + parent.depopulate('child'); + parent.depopulate('children'); + await parent.populate({ path: 'child', forceRepopulate: true }); + await parent.populate({ path: 'children', forceRepopulate: true }); + assert.equal(parent.child.name, 'Child test updated 2'); + assert.equal(parent.children[0].name, 'Child test updated 2'); + + await m.disconnect(); + }); }); 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/test/types/schemaTypeOptions.test.ts b/test/types/schemaTypeOptions.test.ts index 5fc0e23a21d..3514b01d7e9 100644 --- a/test/types/schemaTypeOptions.test.ts +++ b/test/types/schemaTypeOptions.test.ts @@ -64,11 +64,13 @@ function defaultOptions() { expectType>(new Schema.Types.Buffer('none').defaultOptions); expectType>(new Schema.Types.Date('none').defaultOptions); expectType>(new Schema.Types.Decimal128('none').defaultOptions); + expectType>(new Schema.Types.Int32('none').defaultOptions); expectType>(new Schema.Types.DocumentArray('none').defaultOptions); expectType>(new Schema.Types.Map('none').defaultOptions); expectType>(new Schema.Types.Mixed('none').defaultOptions); expectType>(new Schema.Types.Number('none').defaultOptions); expectType>(new Schema.Types.ObjectId('none').defaultOptions); + expectType>(new Schema.Types.Double('none').defaultOptions); expectType>(new Schema.Types.Subdocument('none').defaultOptions); expectType>(new Schema.Types.UUID('none').defaultOptions); } diff --git a/types/index.d.ts b/types/index.d.ts index 668f67e55d1..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?: IndexOptions): this; + index(fields: IndexDefinition, options?: Omit & { unique?: boolean | [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/inferschematype.d.ts b/types/inferschematype.d.ts index d73ad4cb81c..38f8da1ed65 100644 --- a/types/inferschematype.d.ts +++ b/types/inferschematype.d.ts @@ -219,27 +219,29 @@ type IsSchemaTypeFromBuiltinClass = T extends (typeof String) ? true : T extends (typeof Schema.Types.Decimal128) ? true - : T extends (typeof Schema.Types.String) + : T extends (typeof Schema.Types.Int32) ? true - : T extends (typeof Schema.Types.Number) - ? true - : T extends (typeof Schema.Types.Date) + : T extends (typeof Schema.Types.String) ? true - : T extends (typeof Schema.Types.Boolean) + : T extends (typeof Schema.Types.Number) ? true - : T extends (typeof Schema.Types.Buffer) + : T extends (typeof Schema.Types.Date) ? true - : T extends Types.ObjectId + : T extends (typeof Schema.Types.Double) ? true - : T extends Types.Decimal128 + : T extends (typeof Schema.Types.Boolean) ? true - : T extends Buffer + : T extends Types.ObjectId ? true - : T extends NativeDate + : T extends Types.Decimal128 ? true - : T extends (typeof Schema.Types.Mixed) + : T extends Buffer ? true - : IfEquals; + : T extends NativeDate + ? true + : T extends (typeof Schema.Types.Mixed) + ? true + : IfEquals; /** * @summary Resolve path type by returning the corresponding type. @@ -302,18 +304,18 @@ type ResolvePathType extends true ? Types.Decimal128 : IfEquals extends true ? Types.Decimal128 : - IfEquals extends true ? bigint : - IfEquals extends true ? bigint : - PathValueType extends 'bigint' | 'BigInt' | typeof Schema.Types.BigInt | typeof BigInt ? bigint : - PathValueType extends 'uuid' | 'UUID' | typeof Schema.Types.UUID ? Buffer : - IfEquals extends true ? Buffer : - PathValueType extends MapConstructor | 'Map' ? Map> : - IfEquals extends true ? Map> : - PathValueType extends ArrayConstructor ? any[] : - PathValueType extends typeof Schema.Types.Mixed ? any: - IfEquals extends true ? any: - IfEquals extends true ? any: - PathValueType extends typeof SchemaType ? PathValueType['prototype'] : - PathValueType extends Record ? ObtainDocumentType : - unknown, - TypeHint>; + IfEquals extends true ? bigint : + IfEquals extends true ? bigint : + PathValueType extends 'bigint' | 'BigInt' | typeof Schema.Types.BigInt | typeof BigInt ? bigint : + PathValueType extends 'uuid' | 'UUID' | typeof Schema.Types.UUID ? Buffer : + IfEquals extends true ? Buffer : + PathValueType extends MapConstructor | 'Map' ? Map> : + IfEquals extends true ? Map> : + PathValueType extends ArrayConstructor ? any[] : + PathValueType extends typeof Schema.Types.Mixed ? any: + IfEquals extends true ? any: + IfEquals extends true ? any: + PathValueType extends typeof SchemaType ? PathValueType['prototype'] : + PathValueType extends Record ? ObtainDocumentType : + unknown, + TypeHint>; diff --git a/types/populate.d.ts b/types/populate.d.ts index 0db038014f9..8517c15865c 100644 --- a/types/populate.d.ts +++ b/types/populate.d.ts @@ -37,6 +37,8 @@ declare module 'mongoose' { localField?: string; /** Overwrite the schema-level foreign field to populate on if this is a populated virtual. */ foreignField?: string; + /** Set to `false` to prevent Mongoose from repopulating paths that are already populated */ + forceRepopulate?: boolean; } interface PopulateOption { diff --git a/types/schematypes.d.ts b/types/schematypes.d.ts index aff686e1ec9..d5d81c7d560 100644 --- a/types/schematypes.d.ts +++ b/types/schematypes.d.ts @@ -12,6 +12,16 @@ declare module 'mongoose' { */ type Decimal128 = Schema.Types.Decimal128; + + /** + * The Mongoose Int32 [SchemaType](/docs/schematypes.html). Used for + * declaring paths in your schema that should be + * 32-bit integers + * Do not use this to create a new Int32 instance, use `mongoose.Types.Int32` + * instead. + */ + type Int32 = Schema.Types.Int32; + /** * The Mongoose Mixed [SchemaType](/docs/schematypes.html). Used for * declaring paths in your schema that Mongoose's change tracking, casting, @@ -25,6 +35,13 @@ declare module 'mongoose' { */ type Number = Schema.Types.Number; + + /** + * The Mongoose Double [SchemaType](/docs/schematypes.html). Used for + * declaring paths in your schema that Mongoose should cast to doubles (IEEE 754-2008)/ + */ + type Double = Schema.Types.Double; + /** * The Mongoose ObjectId [SchemaType](/docs/schematypes.html). Used for * declaring paths in your schema that should be @@ -111,7 +128,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 @@ -387,6 +404,14 @@ declare module 'mongoose' { defaultOptions: Record; } + class Int32 extends SchemaType { + /** This schema type's name, to defend against minifiers that mangle function names. */ + static schemaName: 'Int32'; + + /** Default options for this SchemaType */ + defaultOptions: Record; + } + class DocumentArray extends SchemaType implements AcceptsDiscriminator { /** This schema type's name, to defend against minifiers that mangle function names. */ static schemaName: 'DocumentArray'; @@ -439,6 +464,14 @@ declare module 'mongoose' { defaultOptions: Record; } + class Double extends SchemaType { + /** This schema type's name, to defend against minifiers that mangle function names. */ + static schemaName: 'Double'; + + /** Default options for this SchemaType */ + defaultOptions: Record; + } + class ObjectId extends SchemaType { /** This schema type's name, to defend against minifiers that mangle function names. */ static schemaName: 'ObjectId';