Skip to content

Commit

Permalink
feat(document): add schemaFieldsOnly option to toObject() and toJSON()
Browse files Browse the repository at this point in the history
Fix #15218
  • Loading branch information
vkarpov15 committed Feb 13, 2025
1 parent b502e8c commit 75b03d3
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 18 deletions.
40 changes: 34 additions & 6 deletions lib/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -3834,15 +3834,39 @@ Document.prototype.$toObject = function(options, json) {
// Parent options should only bubble down for subdocuments, not populated docs
options._parentOptions = this.$isSubdocument ? options : null;

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

let ret;
if (hasOnlyPrimitiveValues && !options.flattenObjectIds) {
// Fast path: if we don't have any nested objects or arrays, we only need a
// shallow clone.
ret = this.$__toObjectShallow();
ret = this.$__toObjectShallow(schemaFieldsOnly);
} else if (schemaFieldsOnly) {
ret = {};
for (const path of Object.keys(this.$__schema.paths)) {
const value = this.$__getValue(path);
if (value === undefined) {
continue;
}
let pathToSet = path;
let objToSet = ret;
if (path.indexOf('.') !== -1) {
const segments = path.split('.');
pathToSet = segments[segments.length - 1];
for (let i = 0; i < segments.length - 1; ++i) {
objToSet[segments[i]] = objToSet[segments[i]] ?? {};
objToSet = objToSet[segments[i]];
}
}
if (value === null) {
objToSet[pathToSet] = null;
continue;
}
objToSet[pathToSet] = clone(value, options);
}
} else {
ret = clone(this._doc, options) || {};
}
Expand Down Expand Up @@ -3908,10 +3932,12 @@ Document.prototype.$toObject = function(options, json) {
* Internal shallow clone alternative to `$toObject()`: much faster, no options processing
*/

Document.prototype.$__toObjectShallow = function $__toObjectShallow() {
Document.prototype.$__toObjectShallow = function $__toObjectShallow(schemaFieldsOnly) {
const ret = {};
if (this._doc != null) {
for (const key of Object.keys(this._doc)) {
const keys = schemaFieldsOnly ? Object.keys(this.$__schema.paths) : Object.keys(this._doc);
for (const key of keys) {
// Safe to do this even in the schemaFieldsOnly case because we assume there's no nested paths
const value = this._doc[key];
if (value instanceof Date) {
ret[key] = new Date(value);
Expand Down Expand Up @@ -4064,6 +4090,7 @@ Document.prototype.$__toObjectShallow = function $__toObjectShallow() {
* @param {Boolean} [options.flattenMaps=false] if true, convert Maps to POJOs. Useful if you want to `JSON.stringify()` the result of `toObject()`.
* @param {Boolean} [options.flattenObjectIds=false] if true, convert any ObjectIds in the result to 24 character hex strings.
* @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.
* @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.
* @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.
* @see mongodb.Binary https://mongodb.github.io/node-mongodb-native/4.9/classes/Binary.html
* @api public
Expand Down Expand Up @@ -4334,6 +4361,7 @@ function omitDeselectedFields(self, json) {
* @param {Object} options
* @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.
* @param {Boolean} [options.flattenObjectIds=false] if true, convert any ObjectIds in the result to 24 character hex strings.
* @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.
* @return {Object}
* @see Document#toObject https://mongoosejs.com/docs/api/document.html#Document.prototype.toObject()
* @see JSON.stringify() in JavaScript https://thecodebarbarian.com/the-80-20-guide-to-json-stringify-in-javascript.html
Expand Down
87 changes: 87 additions & 0 deletions test/document.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14296,6 +14296,93 @@ describe('document', function() {

delete mongoose.Schema.Types.CustomType;
});

it('supports schemaFieldsOnly option for toObject() (gh-15258)', async function() {
const schema = new Schema({ key: String }, { discriminatorKey: 'key' });
const subschema1 = new Schema({ field1: String });
const subschema2 = new Schema({ field2: String });

const Discriminator = db.model('Test', schema);
Discriminator.discriminator('type1', subschema1);
Discriminator.discriminator('type2', subschema2);

const type1Key = 'type1';
const type2Key = 'type2';

const doc = await Discriminator.create({
key: type1Key,
field1: 'test value'
});

await Discriminator.updateOne(
{ _id: doc._id },
{
key: type2Key,
field2: 'test2'
},
{ overwriteDiscriminatorKey: true }
);

const doc2 = await Discriminator.findById(doc).orFail();
assert.strictEqual(doc2.field1, undefined);
assert.strictEqual(doc2.field2, 'test2');

const obj = doc2.toObject();
assert.strictEqual(obj.field2, 'test2');
assert.strictEqual(obj.field1, 'test value');

const obj2 = doc2.toObject({ schemaFieldsOnly: true });
assert.strictEqual(obj.field2, 'test2');
assert.strictEqual(obj2.field1, undefined);
});

it('supports schemaFieldsOnly on nested paths, subdocuments, and arrays (gh-15258)', async function() {
const subSchema = new Schema({
title: String,
description: String
}, { _id: false });
const taskSchema = new Schema({
name: String,
details: {
dueDate: Date,
priority: Number
},
subtask: subSchema,
tasks: [subSchema]
});
const Task = db.model('Test', taskSchema);

const doc = await Task.create({
_id: '0'.repeat(24),
name: 'Test Task',
details: {
dueDate: new Date('2024-01-01'),
priority: 1
},
subtask: {
title: 'Subtask 1',
description: 'Test Description'
},
tasks: [{
title: 'Array Task 1',
description: 'Array Description 1'
}]
});

doc._doc.details.extraField = 'extra';
doc._doc.subtask.extraField = 'extra';
doc._doc.tasks[0].extraField = 'extra';

const obj = doc.toObject({ schemaFieldsOnly: true });
assert.deepStrictEqual(obj, {
name: 'Test Task',
details: { dueDate: new Date('2024-01-01T00:00:00.000Z'), priority: 1 },
subtask: { title: 'Subtask 1', description: 'Test Description' },
tasks: [{ title: 'Array Task 1', description: 'Array Description 1' }],
_id: new mongoose.Types.ObjectId('0'.repeat(24)),
__v: 0
});
});
});

describe('Check if instance function that is supplied in schema option is available', function() {
Expand Down
26 changes: 14 additions & 12 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,30 +204,32 @@ declare module 'mongoose' {
}

export interface ToObjectOptions<THydratedDocumentType = HydratedDocument<unknown>> {
/** apply all getters (path and virtual getters) */
getters?: boolean;
/** apply virtual getters (can override getters option) */
virtuals?: boolean | string[];
/** if `options.virtuals = true`, you can set `options.aliases = false` to skip applying aliases. This option is a no-op if `options.virtuals = false`. */
aliases?: boolean;
/** if true, replace any conventionally populated paths with the original id in the output. Has no affect on virtual populated paths. */
depopulate?: boolean;
/** if true, convert Maps to POJOs. Useful if you want to `JSON.stringify()` the result of `toObject()`. */
flattenMaps?: boolean;
/** if true, convert any ObjectIds in the result to 24 character hex strings. */
flattenObjectIds?: boolean;
/** apply all getters (path and virtual getters) */
getters?: boolean;
/** remove empty objects (defaults to true) */
minimize?: boolean;
/** 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. */
schemaFieldsOnly?: boolean;
/** if set, mongoose will call this function to allow you to transform the returned object */
transform?: boolean | ((
doc: THydratedDocumentType,
ret: Record<string, any>,
options: ToObjectOptions<THydratedDocumentType>
) => any);
/** if true, replace any conventionally populated paths with the original id in the output. Has no affect on virtual populated paths. */
depopulate?: boolean;
/** if false, exclude the version key (`__v` by default) from the output */
versionKey?: boolean;
/** if true, convert Maps to POJOs. Useful if you want to `JSON.stringify()` the result of `toObject()`. */
flattenMaps?: boolean;
/** if true, convert any ObjectIds in the result to 24 character hex strings. */
flattenObjectIds?: boolean;
/** 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. */
useProjection?: boolean;
/** if false, exclude the version key (`__v` by default) from the output */
versionKey?: boolean;
/** apply virtual getters (can override getters option) */
virtuals?: boolean | string[];
}

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

0 comments on commit 75b03d3

Please sign in to comment.