diff --git a/CHANGELOG.md b/CHANGELOG.md index d2d7492dd84..5dddbdedfec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +8.1.3 / 2024-02-16 +================== + * fix: avoid corrupting $set-ed arrays when transaction error occurs #14346 #14340 + * fix(populate): handle ref() functions that return a model instance #14343 #14249 + * fix: insert version key when using insertMany even if `toObject.versionKey` set to false #14344 + * fix(cursor): make aggregation cursor support transform option to match query cursor #14348 #14331 + * docs(document): clarify that transform function option applies to subdocs #13757 + +8.1.2 / 2024-02-08 +================== + * fix: include virtuals in document array toString() output if toObject.virtuals set #14335 #14315 + * fix(document): handle setting nested path to spread doc with extra properties #14287 #14269 + * fix(populate): call setter on virtual populated path with populated doc instead of undefined #14314 + * fix(QueryCursor): remove callback parameter of AggregationCursor and QueryCursor #14299 [DevooKim](https://github.com/DevooKim) + * types: add typescript support for arbitrary fields for the options parameter of Model functions which are of type MongooseQueryOptions #14342 #14341 [FaizBShah](https://github.com/FaizBShah) + * types(model): correct return type for findOneAndUpdate with includeResultMetadata and lean set #14336 #14303 + * types(connection): add type definition for `createCollections()` #14295 #14279 + * docs(timestamps): clarify that replaceOne() and findOneAndReplace() overwrite timestamps #14337 #14309 + 8.1.1 / 2024-01-24 ================== * fix(model): throw readable error when calling Model() with a string instead of model() #14288 #14281 diff --git a/lib/cursor/aggregationCursor.js b/lib/cursor/aggregationCursor.js index 25d81b20803..5462a6e60e6 100644 --- a/lib/cursor/aggregationCursor.js +++ b/lib/cursor/aggregationCursor.js @@ -57,12 +57,20 @@ util.inherits(AggregationCursor, Readable); function _init(model, c, agg) { if (!model.collection.buffer) { model.hooks.execPre('aggregate', agg, function() { + if (typeof agg.options?.cursor?.transform === 'function') { + c._transforms.push(agg.options.cursor.transform); + } + c.cursor = model.collection.aggregate(agg._pipeline, agg.options || {}); c.emit('cursor', c.cursor); }); } else { model.collection.emitter.once('queue', function() { model.hooks.execPre('aggregate', agg, function() { + if (typeof agg.options?.cursor?.transform === 'function') { + c._transforms.push(agg.options.cursor.transform); + } + c.cursor = model.collection.aggregate(agg._pipeline, agg.options || {}); c.emit('cursor', c.cursor); }); diff --git a/lib/document.js b/lib/document.js index b9f39a82c12..4127102f0c9 100644 --- a/lib/document.js +++ b/lib/document.js @@ -3897,14 +3897,24 @@ Document.prototype.$toObject = function(options, json) { * * _Note: if a transform function returns `undefined`, the return value will be ignored._ * - * Transformations may also be applied inline, overridding any transform set in the options: + * Transformations may also be applied inline, overridding any transform set in the schema options. + * Any transform function specified in `toObject` options also propagates to any subdocuments. * - * function xform (doc, ret, options) { - * return { inline: ret.name, custom: true } + * function deleteId(doc, ret, options) { + * delete ret._id; + * return ret; * } * - * // pass the transform as an inline option - * doc.toObject({ transform: xform }); // { inline: 'Wreck-it Ralph', custom: true } + * const schema = mongoose.Schema({ name: String, docArr: [{ name: String }] }); + * const TestModel = mongoose.model('Test', schema); + * + * const doc = new TestModel({ name: 'test', docArr: [{ name: 'test' }] }); + * + * // pass the transform as an inline option. Deletes `_id` property + * // from both the top-level document and the subdocument. + * const obj = doc.toObject({ transform: deleteId }); + * obj._id; // undefined + * obj.docArr[0]._id; // undefined * * If you want to skip transformations, use `transform: false`: * @@ -4283,7 +4293,8 @@ Document.prototype.inspect = function(options) { opts = options; opts.minimize = false; } - const ret = this.toObject(opts); + + const ret = arguments.length > 0 ? this.toObject(opts) : this.toObject(); if (ret == null) { // If `toObject()` returns null, `this` is still an object, so if `inspect()` diff --git a/lib/helpers/populate/getModelsMapForPopulate.js b/lib/helpers/populate/getModelsMapForPopulate.js index 6fcb7de9ec9..ae313bb1e87 100644 --- a/lib/helpers/populate/getModelsMapForPopulate.js +++ b/lib/helpers/populate/getModelsMapForPopulate.js @@ -494,7 +494,7 @@ function addModelNamesToMap(model, map, available, modelNames, options, data, re let k = modelNames.length; while (k--) { - const modelName = modelNames[k]; + let modelName = modelNames[k]; if (modelName == null) { continue; } @@ -504,6 +504,7 @@ function addModelNamesToMap(model, map, available, modelNames, options, data, re Model = options.model; } else if (modelName[modelSymbol]) { Model = modelName; + modelName = Model.modelName; } else { try { Model = _getModelFromConn(connection, modelName); diff --git a/lib/options.js b/lib/options.js index 4826e59fdc5..3bae58e1200 100644 --- a/lib/options.js +++ b/lib/options.js @@ -11,5 +11,6 @@ exports.internalToObjectOptions = { _skipDepopulateTopLevel: true, depopulate: true, flattenDecimals: false, - useProjection: false + useProjection: false, + versionKey: true }; diff --git a/lib/plugins/trackTransaction.js b/lib/plugins/trackTransaction.js index 1a409a026eb..af5c7c84da4 100644 --- a/lib/plugins/trackTransaction.js +++ b/lib/plugins/trackTransaction.js @@ -85,7 +85,7 @@ function mergeAtomics(destination, source) { destination.$addToSet = (destination.$addToSet || []).concat(source.$addToSet); } if (source.$set != null) { - destination.$set = Object.assign(destination.$set || {}, source.$set); + destination.$set = Array.isArray(source.$set) ? [...source.$set] : Object.assign({}, source.$set); } return destination; diff --git a/lib/schema.js b/lib/schema.js index 5941b8ebf5e..94435b86e09 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2144,6 +2144,18 @@ Schema.prototype.set = function(key, value, tags) { if (key === 'strictQuery') { _propagateOptionsToImplicitlyCreatedSchemas(this, { strictQuery: value }); } + if (key === 'toObject') { + value = { ...value }; + // Avoid propagating transform to implicitly created schemas re: gh-3279 + delete value.transform; + _propagateOptionsToImplicitlyCreatedSchemas(this, { toObject: value }); + } + if (key === 'toJSON') { + value = { ...value }; + // Avoid propagating transform to implicitly created schemas re: gh-3279 + delete value.transform; + _propagateOptionsToImplicitlyCreatedSchemas(this, { toJSON: value }); + } return this; }; diff --git a/lib/types/documentArray/methods/index.js b/lib/types/documentArray/methods/index.js index 109f3450a0f..00b47c434ba 100644 --- a/lib/types/documentArray/methods/index.js +++ b/lib/types/documentArray/methods/index.js @@ -13,6 +13,8 @@ const arrayPathSymbol = require('../../../helpers/symbols').arrayPathSymbol; const arraySchemaSymbol = require('../../../helpers/symbols').arraySchemaSymbol; const documentArrayParent = require('../../../helpers/symbols').documentArrayParent; +const _baseToString = Array.prototype.toString; + const methods = { /*! * ignore @@ -22,6 +24,15 @@ const methods = { return this.toObject(internalToObjectOptions); }, + toString() { + return _baseToString.call(this.__array.map(subdoc => { + if (subdoc != null && subdoc.$__ != null) { + return subdoc.toString(); + } + return subdoc; + })); + }, + /*! * ignore */ diff --git a/lib/types/subdocument.js b/lib/types/subdocument.js index 8f9dd94ce60..8ea50d03ecc 100644 --- a/lib/types/subdocument.js +++ b/lib/types/subdocument.js @@ -400,11 +400,7 @@ Subdocument.prototype.populate = function() { */ Subdocument.prototype.inspect = function() { - return this.toObject({ - transform: false, - virtuals: false, - flattenDecimals: false - }); + return this.toObject(); }; if (util.inspect.custom) { diff --git a/package.json b/package.json index ded3700b4a6..deb36918712 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mongoose", "description": "Mongoose MongoDB ODM", - "version": "8.1.1", + "version": "8.1.3", "author": "Guillermo Rauch ", "keywords": [ "mongodb", diff --git a/scripts/loadSponsorData.js b/scripts/loadSponsorData.js index 00e0ca36b30..9e7199fd7c5 100644 --- a/scripts/loadSponsorData.js +++ b/scripts/loadSponsorData.js @@ -51,8 +51,7 @@ async function run() { const OpenCollectiveSponsor = mongoose.model('OpenCollectiveSponsor', mongoose.Schema({ openCollectiveId: { - type: Number, - required: true + type: Number }, website: { type: String, diff --git a/test/aggregate.test.js b/test/aggregate.test.js index 52e98e413a2..a746e143e8d 100644 --- a/test/aggregate.test.js +++ b/test/aggregate.test.js @@ -7,6 +7,7 @@ const start = require('./common'); const assert = require('assert'); +const stream = require('stream'); const Aggregate = require('../lib/aggregate'); @@ -1215,6 +1216,32 @@ describe('aggregate: ', function() { assert.equal(res[1].test, 'a test'); }); + it('cursor supports transform option (gh-14331)', async function() { + const mySchema = new Schema({ name: String }); + const Test = db.model('Test', mySchema); + + await Test.deleteMany({}); + await Test.create([{ name: 'Apple' }, { name: 'Apple' }]); + + let resolve; + const waitForStream = new Promise(innerResolve => { + resolve = innerResolve; + }); + const otherStream = new stream.Writable({ + write(chunk, encoding, callback) { + resolve(chunk.toString()); + callback(); + } + }); + + await Test. + aggregate([{ $match: { name: 'Apple' } }]). + cursor({ transform: JSON.stringify }). + pipe(otherStream); + const streamValue = await waitForStream; + assert.ok(streamValue.includes('"name":"Apple"'), streamValue); + }); + describe('Mongo 3.6 options', function() { before(async function() { await onlyTestAtOrAbove('3.6', this); diff --git a/test/docs/transactions.test.js b/test/docs/transactions.test.js index d196fab7180..5d0277a5aa4 100644 --- a/test/docs/transactions.test.js +++ b/test/docs/transactions.test.js @@ -397,6 +397,62 @@ describe('transactions', function() { assert.equal(docs[0].name, 'test'); }); + it('handles resetting array state with $set atomic (gh-13698)', async function() { + db.deleteModel(/Test/); + const subItemSchema = new mongoose.Schema( + { + name: { type: String, required: true } + }, + { _id: false } + ); + + const itemSchema = new mongoose.Schema( + { + name: { type: String, required: true }, + subItems: { type: [subItemSchema], required: true } + }, + { _id: false } + ); + + const schema = new mongoose.Schema({ + items: { type: [itemSchema], required: true } + }); + + const Test = db.model('Test', schema); + + const { _id } = await Test.create({ + items: [ + { name: 'test1', subItems: [{ name: 'x1' }] }, + { name: 'test2', subItems: [{ name: 'x2' }] } + ] + }); + + const doc = await Test.findById(_id).orFail(); + let attempt = 0; + + await db.transaction(async(session) => { + await doc.save({ session }); + + if (attempt === 0) { + attempt += 1; + throw new mongoose.mongo.MongoServerError({ + message: 'Test transient transaction failures & retries', + errorLabels: [mongoose.mongo.MongoErrorLabel.TransientTransactionError] + }); + } + }); + + const { items } = await Test.findById(_id).orFail(); + assert.ok(Array.isArray(items)); + assert.equal(items.length, 2); + assert.equal(items[0].name, 'test1'); + assert.equal(items[0].subItems.length, 1); + assert.equal(items[0].subItems[0].name, 'x1'); + assert.equal(items[1].name, 'test2'); + assert.equal(items[1].subItems.length, 1); + assert.equal(items[1].subItems[0].name, 'x2'); + }); + it('transaction() retains modified status for documents created outside of the transaction then modified inside the transaction (gh-13973)', async function() { db.deleteModel(/Test/); const Test = db.model('Test', Schema({ status: String })); diff --git a/test/document.test.js b/test/document.test.js index bd0de3fbb62..8f67cf1d74e 100644 --- a/test/document.test.js +++ b/test/document.test.js @@ -12953,6 +12953,25 @@ describe('document', function() { }; assert.equal(person.address.zip, 54321); }); + + it('includes virtuals in doc array toString() output if virtuals enabled on toObject (gh-14315)', function() { + const schema = new Schema({ + docArr: [{ childId: mongoose.ObjectId }] + }); + schema.virtual('docArr.child', { ref: 'Child', localField: 'docArr.childId', foreignField: '_id' }); + schema.set('toObject', { virtuals: true }); + schema.set('toJSON', { virtuals: true }); + const Test = db.model('Test', schema); + const Child = db.model('Child', new Schema({ + name: String + })); + + const child = new Child({ name: 'test child' }); + const doc = new Test({ docArr: [{ childId: child._id }] }); + doc.docArr[0].child = child; + assert.ok(doc.docArr.toString().includes('child'), doc.docArr.toString()); + assert.ok(doc.docArr.toString().includes('test child'), doc.docArr.toString()); + }); }); describe('Check if instance function that is supplied in schema option is availabe', function() { diff --git a/test/model.populate.test.js b/test/model.populate.test.js index 2252265cb8a..65b3b36b2cc 100644 --- a/test/model.populate.test.js +++ b/test/model.populate.test.js @@ -10922,4 +10922,50 @@ describe('model: populate:', function() { assert.equal(personFromDb.userType, 'User'); assert.equal(personFromDb.userId.toHexString(), user._id.toHexString()); }); + + it('handles ref() function that returns a model (gh-14249)', async function() { + const aSchema = new Schema({ + name: String + }); + + const bSchema = new Schema({ + name: String + }); + + const CategoryAModel = db.model('Test', aSchema); + const CategoryBModel = db.model('Test1', bSchema); + + const testSchema = new Schema({ + category: String, + subdoc: { + type: Schema.Types.ObjectId, + ref: function() { + return this.category === 'catA' ? CategoryAModel : CategoryBModel; + } + } + }); + + const parentSchema = new Schema({ + name: String, + children: [testSchema] + }); + const Parent = db.model('Parent', parentSchema); + + const A = await CategoryAModel.create({ + name: 'A' + }); + const B = await CategoryBModel.create({ + name: 'B' + }); + + const doc = await Parent.create({ + name: 'Parent', + children: [{ category: 'catA', subdoc: A._id }, { category: 'catB', subdoc: B._id }] + }); + + const res = await Parent.findById(doc._id).populate('children.subdoc'); + assert.equal(res.children.length, 2); + assert.equal(res.children[0].subdoc.name, 'A'); + assert.equal(res.children[1].subdoc.name, 'B'); + }); }); diff --git a/test/model.test.js b/test/model.test.js index 3bd48f7018d..ad989eb734b 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -7315,6 +7315,21 @@ describe('Model', function() { TestModel.recompileSchema(); assert.equal(doc.myVirtual, 'Hello from myVirtual'); }); + + it('inserts versionKey even if schema has `toObject.versionKey` set to false (gh-14344)', async function() { + const schema = new mongoose.Schema( + { name: String }, + { versionKey: '__v', toObject: { versionKey: false } } + ); + + const Model = db.model('Test', schema); + + await Model.insertMany([{ name: 'x' }]); + + const doc = await Model.findOne(); + + assert.strictEqual(doc.__v, 0); + }); }); diff --git a/types/query.d.ts b/types/query.d.ts index 68d2e5072a5..128d7fd26a7 100644 --- a/types/query.d.ts +++ b/types/query.d.ts @@ -32,7 +32,9 @@ declare module 'mongoose' { 'strictQuery' | 'timestamps' | 'translateAliases' - >; + > & { + [other: string]: any; + }; type ProjectionFields = { [Key in keyof DocType]?: any } & Record;