Skip to content

Commit 75b03d3

Browse files
committed
feat(document): add schemaFieldsOnly option to toObject() and toJSON()
Fix Automattic#15218
1 parent b502e8c commit 75b03d3

File tree

3 files changed

+135
-18
lines changed

3 files changed

+135
-18
lines changed

lib/document.js

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3834,15 +3834,39 @@ Document.prototype.$toObject = function(options, json) {
38343834
// Parent options should only bubble down for subdocuments, not populated docs
38353835
options._parentOptions = this.$isSubdocument ? options : null;
38363836

3837-
// remember the root transform function
3838-
// to save it from being overwritten by sub-transform functions
3839-
// const originalTransform = options.transform;
3837+
const schemaFieldsOnly = options._calledWithOptions.schemaFieldsOnly
3838+
?? options.schemaFieldsOnly
3839+
?? defaultOptions.schemaFieldsOnly
3840+
?? false;
38403841

38413842
let ret;
38423843
if (hasOnlyPrimitiveValues && !options.flattenObjectIds) {
38433844
// Fast path: if we don't have any nested objects or arrays, we only need a
38443845
// shallow clone.
3845-
ret = this.$__toObjectShallow();
3846+
ret = this.$__toObjectShallow(schemaFieldsOnly);
3847+
} else if (schemaFieldsOnly) {
3848+
ret = {};
3849+
for (const path of Object.keys(this.$__schema.paths)) {
3850+
const value = this.$__getValue(path);
3851+
if (value === undefined) {
3852+
continue;
3853+
}
3854+
let pathToSet = path;
3855+
let objToSet = ret;
3856+
if (path.indexOf('.') !== -1) {
3857+
const segments = path.split('.');
3858+
pathToSet = segments[segments.length - 1];
3859+
for (let i = 0; i < segments.length - 1; ++i) {
3860+
objToSet[segments[i]] = objToSet[segments[i]] ?? {};
3861+
objToSet = objToSet[segments[i]];
3862+
}
3863+
}
3864+
if (value === null) {
3865+
objToSet[pathToSet] = null;
3866+
continue;
3867+
}
3868+
objToSet[pathToSet] = clone(value, options);
3869+
}
38463870
} else {
38473871
ret = clone(this._doc, options) || {};
38483872
}
@@ -3908,10 +3932,12 @@ Document.prototype.$toObject = function(options, json) {
39083932
* Internal shallow clone alternative to `$toObject()`: much faster, no options processing
39093933
*/
39103934

3911-
Document.prototype.$__toObjectShallow = function $__toObjectShallow() {
3935+
Document.prototype.$__toObjectShallow = function $__toObjectShallow(schemaFieldsOnly) {
39123936
const ret = {};
39133937
if (this._doc != null) {
3914-
for (const key of Object.keys(this._doc)) {
3938+
const keys = schemaFieldsOnly ? Object.keys(this.$__schema.paths) : Object.keys(this._doc);
3939+
for (const key of keys) {
3940+
// Safe to do this even in the schemaFieldsOnly case because we assume there's no nested paths
39153941
const value = this._doc[key];
39163942
if (value instanceof Date) {
39173943
ret[key] = new Date(value);
@@ -4064,6 +4090,7 @@ Document.prototype.$__toObjectShallow = function $__toObjectShallow() {
40644090
* @param {Boolean} [options.flattenMaps=false] if true, convert Maps to POJOs. Useful if you want to `JSON.stringify()` the result of `toObject()`.
40654091
* @param {Boolean} [options.flattenObjectIds=false] if true, convert any ObjectIds in the result to 24 character hex strings.
40664092
* @param {Boolean} [options.useProjection=false] - If true, omits fields that are excluded in this document's projection. Unless you specified a projection, this will omit any field that has `select: false` in the schema.
4093+
* @param {Boolean} [options.schemaFieldsOnly=false] - If true, the resulting object will only have fields that are defined in the document's schema. By default, `toObject()` returns all fields in the underlying document from MongoDB, including ones that are not listed in the schema.
40674094
* @return {Object} document as a plain old JavaScript object (POJO). This object may contain ObjectIds, Maps, Dates, mongodb.Binary, Buffers, and other non-POJO values.
40684095
* @see mongodb.Binary https://mongodb.github.io/node-mongodb-native/4.9/classes/Binary.html
40694096
* @api public
@@ -4334,6 +4361,7 @@ function omitDeselectedFields(self, json) {
43344361
* @param {Object} options
43354362
* @param {Boolean} [options.flattenMaps=true] if true, convert Maps to [POJOs](https://masteringjs.io/tutorials/fundamentals/pojo). Useful if you want to `JSON.stringify()` the result.
43364363
* @param {Boolean} [options.flattenObjectIds=false] if true, convert any ObjectIds in the result to 24 character hex strings.
4364+
* @param {Boolean} [options.schemaFieldsOnly=false] - If true, the resulting object will only have fields that are defined in the document's schema. By default, `toJSON()` returns all fields in the underlying document from MongoDB, including ones that are not listed in the schema.
43374365
* @return {Object}
43384366
* @see Document#toObject https://mongoosejs.com/docs/api/document.html#Document.prototype.toObject()
43394367
* @see JSON.stringify() in JavaScript https://thecodebarbarian.com/the-80-20-guide-to-json-stringify-in-javascript.html

test/document.test.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14296,6 +14296,93 @@ describe('document', function() {
1429614296

1429714297
delete mongoose.Schema.Types.CustomType;
1429814298
});
14299+
14300+
it('supports schemaFieldsOnly option for toObject() (gh-15258)', async function() {
14301+
const schema = new Schema({ key: String }, { discriminatorKey: 'key' });
14302+
const subschema1 = new Schema({ field1: String });
14303+
const subschema2 = new Schema({ field2: String });
14304+
14305+
const Discriminator = db.model('Test', schema);
14306+
Discriminator.discriminator('type1', subschema1);
14307+
Discriminator.discriminator('type2', subschema2);
14308+
14309+
const type1Key = 'type1';
14310+
const type2Key = 'type2';
14311+
14312+
const doc = await Discriminator.create({
14313+
key: type1Key,
14314+
field1: 'test value'
14315+
});
14316+
14317+
await Discriminator.updateOne(
14318+
{ _id: doc._id },
14319+
{
14320+
key: type2Key,
14321+
field2: 'test2'
14322+
},
14323+
{ overwriteDiscriminatorKey: true }
14324+
);
14325+
14326+
const doc2 = await Discriminator.findById(doc).orFail();
14327+
assert.strictEqual(doc2.field1, undefined);
14328+
assert.strictEqual(doc2.field2, 'test2');
14329+
14330+
const obj = doc2.toObject();
14331+
assert.strictEqual(obj.field2, 'test2');
14332+
assert.strictEqual(obj.field1, 'test value');
14333+
14334+
const obj2 = doc2.toObject({ schemaFieldsOnly: true });
14335+
assert.strictEqual(obj.field2, 'test2');
14336+
assert.strictEqual(obj2.field1, undefined);
14337+
});
14338+
14339+
it('supports schemaFieldsOnly on nested paths, subdocuments, and arrays (gh-15258)', async function() {
14340+
const subSchema = new Schema({
14341+
title: String,
14342+
description: String
14343+
}, { _id: false });
14344+
const taskSchema = new Schema({
14345+
name: String,
14346+
details: {
14347+
dueDate: Date,
14348+
priority: Number
14349+
},
14350+
subtask: subSchema,
14351+
tasks: [subSchema]
14352+
});
14353+
const Task = db.model('Test', taskSchema);
14354+
14355+
const doc = await Task.create({
14356+
_id: '0'.repeat(24),
14357+
name: 'Test Task',
14358+
details: {
14359+
dueDate: new Date('2024-01-01'),
14360+
priority: 1
14361+
},
14362+
subtask: {
14363+
title: 'Subtask 1',
14364+
description: 'Test Description'
14365+
},
14366+
tasks: [{
14367+
title: 'Array Task 1',
14368+
description: 'Array Description 1'
14369+
}]
14370+
});
14371+
14372+
doc._doc.details.extraField = 'extra';
14373+
doc._doc.subtask.extraField = 'extra';
14374+
doc._doc.tasks[0].extraField = 'extra';
14375+
14376+
const obj = doc.toObject({ schemaFieldsOnly: true });
14377+
assert.deepStrictEqual(obj, {
14378+
name: 'Test Task',
14379+
details: { dueDate: new Date('2024-01-01T00:00:00.000Z'), priority: 1 },
14380+
subtask: { title: 'Subtask 1', description: 'Test Description' },
14381+
tasks: [{ title: 'Array Task 1', description: 'Array Description 1' }],
14382+
_id: new mongoose.Types.ObjectId('0'.repeat(24)),
14383+
__v: 0
14384+
});
14385+
});
1429914386
});
1430014387

1430114388
describe('Check if instance function that is supplied in schema option is available', function() {

types/index.d.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -204,30 +204,32 @@ declare module 'mongoose' {
204204
}
205205

206206
export interface ToObjectOptions<THydratedDocumentType = HydratedDocument<unknown>> {
207-
/** apply all getters (path and virtual getters) */
208-
getters?: boolean;
209-
/** apply virtual getters (can override getters option) */
210-
virtuals?: boolean | string[];
211207
/** if `options.virtuals = true`, you can set `options.aliases = false` to skip applying aliases. This option is a no-op if `options.virtuals = false`. */
212208
aliases?: boolean;
209+
/** if true, replace any conventionally populated paths with the original id in the output. Has no affect on virtual populated paths. */
210+
depopulate?: boolean;
211+
/** if true, convert Maps to POJOs. Useful if you want to `JSON.stringify()` the result of `toObject()`. */
212+
flattenMaps?: boolean;
213+
/** if true, convert any ObjectIds in the result to 24 character hex strings. */
214+
flattenObjectIds?: boolean;
215+
/** apply all getters (path and virtual getters) */
216+
getters?: boolean;
213217
/** remove empty objects (defaults to true) */
214218
minimize?: boolean;
219+
/** If true, the resulting object will only have fields that are defined in the document's schema. By default, `toJSON()` returns all fields in the underlying document from MongoDB, including ones that are not listed in the schema. */
220+
schemaFieldsOnly?: boolean;
215221
/** if set, mongoose will call this function to allow you to transform the returned object */
216222
transform?: boolean | ((
217223
doc: THydratedDocumentType,
218224
ret: Record<string, any>,
219225
options: ToObjectOptions<THydratedDocumentType>
220226
) => any);
221-
/** if true, replace any conventionally populated paths with the original id in the output. Has no affect on virtual populated paths. */
222-
depopulate?: boolean;
223-
/** if false, exclude the version key (`__v` by default) from the output */
224-
versionKey?: boolean;
225-
/** if true, convert Maps to POJOs. Useful if you want to `JSON.stringify()` the result of `toObject()`. */
226-
flattenMaps?: boolean;
227-
/** if true, convert any ObjectIds in the result to 24 character hex strings. */
228-
flattenObjectIds?: boolean;
229227
/** If true, omits fields that are excluded in this document's projection. Unless you specified a projection, this will omit any field that has `select: false` in the schema. */
230228
useProjection?: boolean;
229+
/** if false, exclude the version key (`__v` by default) from the output */
230+
versionKey?: boolean;
231+
/** apply virtual getters (can override getters option) */
232+
virtuals?: boolean | string[];
231233
}
232234

233235
export type DiscriminatorModel<M, T> = T extends Model<infer T, infer TQueryHelpers, infer TInstanceMethods, infer TVirtuals>

0 commit comments

Comments
 (0)