diff --git a/CHANGELOG.md b/CHANGELOG.md index 447adcf183b..4d59f08f8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +7.6.11 / 2024-04-11 +=================== + * fix(populate): avoid match function filtering out null values in populate result #14518 + * fix(schema): support setting discriminator options in Schema.prototype.discriminator() #14493 #14448 + * fix(schema): deduplicate idGetter so creating multiple models with same schema doesn't result in multiple id getters #14492 #14457 + +6.12.8 / 2024-04-10 +=================== + * fix(document): handle virtuals that are stored as objects but getter returns string with toJSON #14468 #14446 + * fix(schematype): consistently set wasPopulated to object with `value` property rather than boolean #14418 + * docs(model): add extra note about lean option for insertMany() skipping casting #14415 #14376 + 8.3.1 / 2024-04-08 ================== * fix(document): make update minimization unset property rather than setting to null #14504 #14445 diff --git a/lib/document.js b/lib/document.js index fcfb6026ec6..b5f949b2fa5 100644 --- a/lib/document.js +++ b/lib/document.js @@ -1053,7 +1053,11 @@ Document.prototype.$set = function $set(path, val, type, options) { if (path.$__isNested) { path = path.toObject(); } else { - path = path._doc; + // This ternary is to support gh-7898 (copying virtuals if same schema) + // while not breaking gh-10819, which for some reason breaks if we use toObject() + path = path.$__schema === this.$__schema + ? applyVirtuals(path, { ...path._doc }) + : path._doc; } } if (path == null) { @@ -4087,6 +4091,7 @@ function applyVirtuals(self, json, options, toObjectOptions) { ? toObjectOptions.aliases : true; + options = options || {}; let virtualsToApply = null; if (Array.isArray(options.virtuals)) { virtualsToApply = new Set(options.virtuals); @@ -4103,7 +4108,6 @@ function applyVirtuals(self, json, options, toObjectOptions) { return json; } - options = options || {}; for (i = 0; i < numPaths; ++i) { path = paths[i]; @@ -4184,7 +4188,12 @@ function applyGetters(self, json, options) { for (let ii = 0; ii < plen; ++ii) { part = parts[ii]; v = cur[part]; - if (ii === last) { + // If we've reached a non-object part of the branch, continuing would + // cause "Cannot create property 'foo' on string 'bar'" error. + // Necessary for mongoose-intl plugin re: gh-14446 + if (branch != null && typeof branch !== 'object') { + break; + } else if (ii === last) { const val = self.$get(path); branch[part] = clone(val, options); if (Array.isArray(branch[part]) && schema.paths[path].$embeddedSchemaType) { diff --git a/lib/helpers/discriminator/applyEmbeddedDiscriminators.js b/lib/helpers/discriminator/applyEmbeddedDiscriminators.js index fa0c41c5be5..01593a9b316 100644 --- a/lib/helpers/discriminator/applyEmbeddedDiscriminators.js +++ b/lib/helpers/discriminator/applyEmbeddedDiscriminators.js @@ -20,12 +20,15 @@ function applyEmbeddedDiscriminators(schema, seen = new WeakSet(), overwriteExis continue; } for (const discriminatorKey of schemaType.schema._applyDiscriminators.keys()) { - const discriminatorSchema = schemaType.schema._applyDiscriminators.get(discriminatorKey); + const { + schema: discriminatorSchema, + options + } = schemaType.schema._applyDiscriminators.get(discriminatorKey); applyEmbeddedDiscriminators(discriminatorSchema, seen); schemaType.discriminator( discriminatorKey, discriminatorSchema, - overwriteExisting ? { overwriteExisting: true } : null + overwriteExisting ? { ...options, overwriteExisting: true } : options ); } schemaType._appliedDiscriminators = true; diff --git a/lib/model.js b/lib/model.js index 1e2729320d7..d07fe2b89fb 100644 --- a/lib/model.js +++ b/lib/model.js @@ -4749,7 +4749,7 @@ function _assign(model, vals, mod, assignmentOpts) { } // flag each as result of population if (!lean) { - val.$__.wasPopulated = val.$__.wasPopulated || true; + val.$__.wasPopulated = val.$__.wasPopulated || { value: _val }; } } } diff --git a/lib/mongoose.js b/lib/mongoose.js index 97475a76868..915720b59f7 100644 --- a/lib/mongoose.js +++ b/lib/mongoose.js @@ -637,7 +637,11 @@ Mongoose.prototype._model = function(name, schema, collection, options) { if (schema._applyDiscriminators != null) { for (const disc of schema._applyDiscriminators.keys()) { - model.discriminator(disc, schema._applyDiscriminators.get(disc)); + const { + schema: discriminatorSchema, + options + } = schema._applyDiscriminators.get(disc); + model.discriminator(disc, discriminatorSchema, options); } } diff --git a/lib/schema.js b/lib/schema.js index 2b833e5ebc3..9cb1602e64f 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -626,12 +626,18 @@ Schema.prototype.defaultOptions = function(options) { * * @param {String} name the name of the discriminator * @param {Schema} schema the discriminated Schema + * @param {Object} [options] discriminator options + * @param {String} [options.value] the string stored in the `discriminatorKey` property. If not specified, Mongoose uses the `name` parameter. + * @param {Boolean} [options.clone=true] By default, `discriminator()` clones the given `schema`. Set to `false` to skip cloning. + * @param {Boolean} [options.overwriteModels=false] by default, Mongoose does not allow you to define a discriminator with the same name as another discriminator. Set this to allow overwriting discriminators with the same name. + * @param {Boolean} [options.mergeHooks=true] By default, Mongoose merges the base schema's hooks with the discriminator schema's hooks. Set this option to `false` to make Mongoose use the discriminator schema's hooks instead. + * @param {Boolean} [options.mergePlugins=true] By default, Mongoose merges the base schema's plugins with the discriminator schema's plugins. Set this option to `false` to make Mongoose use the discriminator schema's plugins instead. * @return {Schema} the Schema instance * @api public */ -Schema.prototype.discriminator = function(name, schema) { +Schema.prototype.discriminator = function(name, schema, options) { this._applyDiscriminators = this._applyDiscriminators || new Map(); - this._applyDiscriminators.set(name, schema); + this._applyDiscriminators.set(name, { schema, options }); return this; }; diff --git a/lib/schema/documentArray.js b/lib/schema/documentArray.js index 50ae0e12a6b..2af276500ff 100644 --- a/lib/schema/documentArray.js +++ b/lib/schema/documentArray.js @@ -447,19 +447,9 @@ SchemaDocumentArray.prototype.cast = function(value, doc, init, prev, options) { const Constructor = getConstructor(this.casterConstructor, rawArray[i]); - // Check if the document has a different schema (re gh-3701) - if (rawArray[i].$__ != null && !(rawArray[i] instanceof Constructor)) { - const spreadDoc = handleSpreadDoc(rawArray[i], true); - if (rawArray[i] !== spreadDoc) { - rawArray[i] = spreadDoc; - } else { - rawArray[i] = rawArray[i].toObject({ - transform: false, - // Special case: if different model, but same schema, apply virtuals - // re: gh-7898 - virtuals: rawArray[i].schema === Constructor.schema - }); - } + const spreadDoc = handleSpreadDoc(rawArray[i], true); + if (rawArray[i] !== spreadDoc) { + rawArray[i] = spreadDoc; } if (rawArray[i] instanceof Subdocument) { diff --git a/lib/schemaType.js b/lib/schemaType.js index eb9be85f2c1..b54e83fd6a8 100644 --- a/lib/schemaType.js +++ b/lib/schemaType.js @@ -1542,7 +1542,7 @@ SchemaType.prototype._castRef = function _castRef(value, doc, init) { } if (value.$__ != null) { - value.$__.wasPopulated = value.$__.wasPopulated || true; + value.$__.wasPopulated = value.$__.wasPopulated || { value: value._id }; return value; } @@ -1568,7 +1568,7 @@ SchemaType.prototype._castRef = function _castRef(value, doc, init) { !doc.$__.populated[path].options.options || !doc.$__.populated[path].options.options.lean) { ret = new pop.options[populateModelSymbol](value); - ret.$__.wasPopulated = true; + ret.$__.wasPopulated = { value: ret._id }; } return ret; diff --git a/test/document.test.js b/test/document.test.js index 6e125b82fc0..a770b3b8352 100644 --- a/test/document.test.js +++ b/test/document.test.js @@ -13234,7 +13234,100 @@ describe('document', function() { const savedDocSecond = await Test.findById(doc.id).orFail(); assert.deepStrictEqual(savedDocSecond.toObject({ minimize: false }).sub, {}); + }); + it('avoids depopulating populated subdocs underneath document arrays when copying to another document (gh-14418)', async function() { + const cartSchema = new mongoose.Schema({ + products: [ + { + product: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Product' + }, + quantity: Number + } + ], + singleProduct: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Product' + } + }); + const purchaseSchema = new mongoose.Schema({ + products: [ + { + product: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Product' + }, + quantity: Number + } + ], + singleProduct: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Product' + } + }); + const productSchema = new mongoose.Schema({ + name: String + }); + + const Cart = db.model('Cart', cartSchema); + const Purchase = db.model('Purchase', purchaseSchema); + const Product = db.model('Product', productSchema); + + const dbProduct = await Product.create({ name: 'Bug' }); + + const dbCart = await Cart.create({ + products: [ + { + product: dbProduct, + quantity: 2 + } + ], + singleProduct: dbProduct + }); + + const foundCart = await Cart.findById(dbCart._id). + populate('products.product singleProduct'); + + const purchaseFromDbCart = new Purchase({ + products: foundCart.products, + singleProduct: foundCart.singleProduct + }); + assert.equal(purchaseFromDbCart.products[0].product.name, 'Bug'); + assert.equal(purchaseFromDbCart.singleProduct.name, 'Bug'); + }); + + it('handles virtuals that are stored as objects but getter returns string with toJSON (gh-14446)', async function() { + const childSchema = new mongoose.Schema(); + + childSchema.virtual('name') + .set(function(values) { + for (const [lang, val] of Object.entries(values)) { + this.set(`name.${lang}`, val); + } + }) + .get(function() { + return this.$__getValue(`name.${this.lang}`); + }); + + childSchema.add({ name: { en: { type: String }, de: { type: String } } }); + + const ChildModel = db.model('Child', childSchema); + const ParentModel = db.model('Parent', new mongoose.Schema({ + children: [childSchema] + })); + + const child = await ChildModel.create({ name: { en: 'Stephen', de: 'Stefan' } }); + child.lang = 'en'; + assert.equal(child.name, 'Stephen'); + + const parent = await ParentModel.create({ + children: [{ name: { en: 'Stephen', de: 'Stefan' } }] + }); + parent.children[0].lang = 'de'; + const obj = parent.toJSON({ getters: true }); + assert.equal(obj.children[0].name, 'Stefan'); }); }); diff --git a/test/schema.test.js b/test/schema.test.js index 9931ecdc157..0092a44a4ee 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -72,6 +72,7 @@ describe('schema', function() { }, b: { $type: String } }, { typeKey: '$type' }); + db.deleteModel(/Test/); NestedModel = db.model('Test', NestedSchema); }); @@ -3219,4 +3220,21 @@ describe('schema', function() { assert.equal(schema.path('tags.$').caster.instance, 'String'); // actually Mixed assert.equal(schema.path('subdocs.$').casterConstructor.schema.path('name').instance, 'String'); // actually Mixed }); + it('handles discriminator options with Schema.prototype.discriminator (gh-14448)', async function() { + const eventSchema = new mongoose.Schema({ + name: String + }, { discriminatorKey: 'kind' }); + const clickedEventSchema = new mongoose.Schema({ element: String }); + eventSchema.discriminator( + 'Test2', + clickedEventSchema, + { value: 'click' } + ); + const Event = db.model('Test', eventSchema); + const ClickedModel = db.model('Test2'); + + const doc = await Event.create({ kind: 'click', element: '#hero' }); + assert.equal(doc.element, '#hero'); + assert.ok(doc instanceof ClickedModel); + }); });