From 5d65e17f14fd3c09651cf59fb6816031bf62d873 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 1 Apr 2025 15:07:55 -0400 Subject: [PATCH 1/3] feat(subdocument): support schematype-level minimize option to disable minimizing empty subdocuments Fix #15313 --- lib/helpers/clone.js | 5 ----- lib/schema/subdocument.js | 1 + lib/types/subdocument.js | 19 +++++++++++++++++++ test/types.subdocument.test.js | 9 +++++++++ types/schematypes.d.ts | 3 +++ 5 files changed, 32 insertions(+), 5 deletions(-) diff --git a/lib/helpers/clone.js b/lib/helpers/clone.js index a19e7a6238f..e2638020ba3 100644 --- a/lib/helpers/clone.js +++ b/lib/helpers/clone.js @@ -57,7 +57,6 @@ function clone(obj, options, isArrayChild) { return clonedDoc; } } - const isSingleNested = obj.$isSingleNested; if (isPOJO(obj) && obj.$__ != null && obj._doc != null) { return obj._doc; @@ -70,10 +69,6 @@ function clone(obj, options, isArrayChild) { ret = obj.toObject(options); } - if (options && options.minimize && !obj.constructor.$__required && isSingleNested && Object.keys(ret).length === 0) { - return undefined; - } - return ret; } diff --git a/lib/schema/subdocument.js b/lib/schema/subdocument.js index 3afdb8ee281..e46a8b6775a 100644 --- a/lib/schema/subdocument.js +++ b/lib/schema/subdocument.js @@ -90,6 +90,7 @@ function _createConstructor(schema, baseClass, options) { _embedded.prototype = Object.create(proto); _embedded.prototype.$__setSchema(schema); _embedded.prototype.constructor = _embedded; + _embedded.prototype.$__schemaTypeOptions = options; _embedded.$__required = options?.required; _embedded.base = schema.base; _embedded.schema = schema; diff --git a/lib/types/subdocument.js b/lib/types/subdocument.js index b1984d08ebf..74b72ee66b8 100644 --- a/lib/types/subdocument.js +++ b/lib/types/subdocument.js @@ -412,6 +412,25 @@ if (util.inspect.custom) { Subdocument.prototype[util.inspect.custom] = Subdocument.prototype.inspect; } +/** + * Override `$toObject()` to handle minimizing the whole path. Should not minimize if schematype-level minimize + * is set to false re: gh-11247, gh-14058, gh-14151 + */ + +Subdocument.prototype.$toObject = function $toObject(options, json) { + const ret = Document.prototype.$toObject.call(this, options, json); + + // If `$toObject()` was called recursively, respect the minimize option, including schematype level minimize. + // If minimize is set, then we can minimize out the whole object. + if (Object.keys(ret).length === 0 && options?._calledWithOptions != null) { + const minimize = options._calledWithOptions?.minimize ?? this?.$__schemaTypeOptions?.minimize ?? options.minimize; + if (minimize && !this.constructor.$__required) { + return undefined; + } + } + return ret; +}; + /** * Registers remove event listeners for triggering * on subdocuments. diff --git a/test/types.subdocument.test.js b/test/types.subdocument.test.js index db12ffb154d..b976cd2ceda 100644 --- a/test/types.subdocument.test.js +++ b/test/types.subdocument.test.js @@ -97,4 +97,13 @@ describe('types.subdocument', function() { assert.ok(doc.child.isModified('id')); }); }); + + it('respects schematype-level minimize (gh-15313)', function() { + const MySubSchema = new Schema({}, { _id: false }); + const MySchema = new Schema({ myfield: { type: MySubSchema, minimize: false } }); + const MyModel = db.model('MyModel', MySchema); + + const doc = new MyModel({ myfield: {} }); + assert.deepStrictEqual(doc.toObject().myfield, {}); + }); }); diff --git a/types/schematypes.d.ts b/types/schematypes.d.ts index 7e648348ab4..d559162d6ff 100644 --- a/types/schematypes.d.ts +++ b/types/schematypes.d.ts @@ -173,6 +173,9 @@ declare module 'mongoose' { /** The maximum value allowed for this path. Only allowed for numbers and dates. */ max?: number | NativeDate | [number, string] | [NativeDate, string] | readonly [number, string] | readonly [NativeDate, string]; + /** Set to false to disable minimizing empty single nested subdocuments by default */ + minimize?: boolean; + /** Defines a TTL index on this path. Only allowed for dates. */ expires?: string | number; From 80b428b51627e7bdcb84706d7ec872e399d5a650 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 1 Apr 2025 15:16:06 -0400 Subject: [PATCH 2/3] fix: also propagate schematype-level minimize down --- lib/document.js | 2 ++ test/types.subdocument.test.js | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/document.js b/lib/document.js index b8a517fb8fd..ec93f456a2b 100644 --- a/lib/document.js +++ b/lib/document.js @@ -3796,6 +3796,8 @@ Document.prototype.$toObject = function(options, json) { let _minimize; if (options._calledWithOptions.minimize != null) { _minimize = options.minimize; + } else if (this.$__schemaTypeOptions?.minimize != null) { + _minimize = this.$__schemaTypeOptions.minimize; } else if (defaultOptions != null && defaultOptions.minimize != null) { _minimize = defaultOptions.minimize; } else { diff --git a/test/types.subdocument.test.js b/test/types.subdocument.test.js index b976cd2ceda..aa292678639 100644 --- a/test/types.subdocument.test.js +++ b/test/types.subdocument.test.js @@ -99,11 +99,14 @@ describe('types.subdocument', function() { }); it('respects schematype-level minimize (gh-15313)', function() { - const MySubSchema = new Schema({}, { _id: false }); + const MySubSchema = new Schema({}, { strict: false, _id: false }); const MySchema = new Schema({ myfield: { type: MySubSchema, minimize: false } }); const MyModel = db.model('MyModel', MySchema); const doc = new MyModel({ myfield: {} }); assert.deepStrictEqual(doc.toObject().myfield, {}); + + const doc2 = new MyModel({ myfield: { empty: {} } }); + assert.deepStrictEqual(doc2.toObject().myfield, { empty: {} }); }); }); From 5853a8b4bae983eab21dcc7fdb61b535a17668be Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Wed, 2 Apr 2025 13:20:31 -0400 Subject: [PATCH 3/3] feat: add minimize to SchemaSubdocumentOptions --- lib/options/schemaSubdocumentOptions.js | 26 ++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/options/schemaSubdocumentOptions.js b/lib/options/schemaSubdocumentOptions.js index 07c906726f4..ab58d66ab42 100644 --- a/lib/options/schemaSubdocumentOptions.js +++ b/lib/options/schemaSubdocumentOptions.js @@ -31,7 +31,7 @@ const opts = require('./propertyOptions'); * parentSchema.path('child').schema.options._id; // false * * @api public - * @property of + * @property _id * @memberOf SchemaSubdocumentOptions * @type {Function|string} * @instance @@ -39,4 +39,28 @@ const opts = require('./propertyOptions'); Object.defineProperty(SchemaSubdocumentOptions.prototype, '_id', opts); +/** + * If set, overwrites the child schema's `minimize` option. In addition, configures whether the entire + * subdocument can be minimized out. + * + * #### Example: + * + * const childSchema = Schema({ name: String }); + * const parentSchema = Schema({ + * child: { type: childSchema, minimize: false } + * }); + * const ParentModel = mongoose.model('Parent', parentSchema); + * // Saves `{ child: {} }` to the db. Without `minimize: false`, Mongoose would remove the empty + * // object and save `{}` to the db. + * await ParentModel.create({ child: {} }); + * + * @api public + * @property minimize + * @memberOf SchemaSubdocumentOptions + * @type {Function|string} + * @instance + */ + +Object.defineProperty(SchemaSubdocumentOptions.prototype, 'minimize', opts); + module.exports = SchemaSubdocumentOptions;