From 06ba685709dd107978fc15e32d5236d17707a7b2 Mon Sep 17 00:00:00 2001 From: Ofir Stiber Date: Sat, 29 Nov 2025 15:05:44 +0200 Subject: [PATCH 1/3] feat(EJSON): add ignoreUndefined option to EJSON serialization - Introduced `ignoreUndefined` option to `EJSON.stringify` to omit undefined values from the output. - Updated tests to cover various scenarios for the new option, including nested objects and arrays. - Ensured compatibility with existing options like `relaxed` and `replacer` functions. --- src/extended_json.ts | 7 +++- test/node/extended_json.test.ts | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/extended_json.ts b/src/extended_json.ts index 4e2613a9..d8f7e6cd 100644 --- a/src/extended_json.ts +++ b/src/extended_json.ts @@ -39,6 +39,11 @@ export type EJSONOptions = { * @defaultValue `false` */ useBigInt64?: boolean; + /** + * Omits undefined values from the output instead of converting them to null + * @defaultValue `false` + */ + ignoreUndefined?: boolean; }; /** @internal */ @@ -242,7 +247,7 @@ function serializeValue(value: any, options: EJSONSerializeOptions): any { if (Array.isArray(value)) return serializeArray(value, options); - if (value === undefined) return null; + if (value === undefined) return options.ignoreUndefined ? undefined : null; if (value instanceof Date || isDate(value)) { const dateNum = value.getTime(), diff --git a/test/node/extended_json.test.ts b/test/node/extended_json.test.ts index 2ce482b6..44b4dcd1 100644 --- a/test/node/extended_json.test.ts +++ b/test/node/extended_json.test.ts @@ -574,6 +574,70 @@ describe('Extended JSON', function () { expect(result).to.deep.equal({ a: 1 }); }); + describe('ignoreUndefined option', () => { + it('should convert undefined to null by default', () => { + const doc = { a: 1, b: undefined, c: 'test' }; + const serialized = EJSON.stringify(doc); + expect(serialized).to.equal('{"a":1,"b":null,"c":"test"}'); + }); + + it('should omit undefined values when ignoreUndefined is true', () => { + const doc = { a: 1, b: undefined, c: 'test' }; + const serialized = EJSON.stringify(doc, { ignoreUndefined: true }); + expect(serialized).to.equal('{"a":1,"c":"test"}'); + }); + + it('should handle nested undefined values with ignoreUndefined: true', () => { + const doc = { a: 1, nested: { b: undefined, c: 2 }, d: 'test' }; + const serialized = EJSON.stringify(doc, { ignoreUndefined: true }); + expect(serialized).to.equal('{"a":1,"nested":{"c":2},"d":"test"}'); + }); + + it('should handle nested undefined values without ignoreUndefined (default behavior)', () => { + const doc = { a: 1, nested: { b: undefined, c: 2 }, d: 'test' }; + const serialized = EJSON.stringify(doc); + expect(serialized).to.equal('{"a":1,"nested":{"b":null,"c":2},"d":"test"}'); + }); + + it('should handle undefined in arrays with ignoreUndefined: true', () => { + const doc = { arr: [1, undefined, 3] }; + const serialized = EJSON.stringify(doc, { ignoreUndefined: true }); + // JSON.stringify converts undefined array elements to null + expect(serialized).to.equal('{"arr":[1,null,3]}'); + }); + + it('should handle undefined in arrays without ignoreUndefined (default behavior)', () => { + const doc = { arr: [1, undefined, 3] }; + const serialized = EJSON.stringify(doc); + expect(serialized).to.equal('{"arr":[1,null,3]}'); + }); + + it('should handle object with all undefined values with ignoreUndefined: true', () => { + const doc = { a: undefined, b: undefined }; + const serialized = EJSON.stringify(doc, { ignoreUndefined: true }); + expect(serialized).to.equal('{}'); + }); + + it('should work with other options like relaxed', () => { + const doc = { a: new Int32(10), b: undefined, c: new Double(3.14) }; + const serialized = EJSON.stringify(doc, { ignoreUndefined: true, relaxed: false }); + expect(serialized).to.equal('{"a":{"$numberInt":"10"},"c":{"$numberDouble":"3.14"}}'); + }); + + it('should work with replacer function', () => { + const doc = { a: 1, b: undefined, c: 2 }; + const replacer = (key: string, value: unknown) => (key === 'a' ? 100 : value); + const serialized = EJSON.stringify(doc, replacer, 0, { ignoreUndefined: true }); + expect(serialized).to.equal('{"a":100,"c":2}'); + }); + + it('should work with space parameter', () => { + const doc = { a: 1, b: undefined }; + const serialized = EJSON.stringify(doc, undefined, 2, { ignoreUndefined: true }); + expect(serialized).to.equal('{\n "a": 1\n}'); + }); + }); + it(`throws if Symbol.for('@@mdb.bson.version') is the wrong version in EJSON.stringify`, () => { expect(() => EJSON.stringify({ From 02bfee9cb80d6a2f9ca96f9752cec30c969cd67f Mon Sep 17 00:00:00 2001 From: Ofir Stiber Date: Fri, 12 Dec 2025 23:55:09 +0200 Subject: [PATCH 2/3] =?UTF-8?q?refactor(EJSON):=20=E2=99=BB=EF=B8=8F=20=20?= =?UTF-8?q?separate=20serialize=20and=20parse=20option=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce EJSONSerializeOptions and EJSONParseOptions to distinguish between serialization-only options (ignoreUndefined) and parse-only options (useBigInt64). EJSONOptions remains as the intersection for backward compatibility. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/bson.ts | 2 +- src/extended_json.ts | 44 ++++++++++++++++++++++++++++---------------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/bson.ts b/src/bson.ts index 46678360..f584b571 100644 --- a/src/bson.ts +++ b/src/bson.ts @@ -22,7 +22,7 @@ export type { CodeExtended } from './code'; export type { DBRefLike } from './db_ref'; export type { Decimal128Extended } from './decimal128'; export type { DoubleExtended } from './double'; -export type { EJSONOptions } from './extended_json'; +export type { EJSONOptions, EJSONSerializeOptions, EJSONParseOptions } from './extended_json'; export type { Int32Extended } from './int_32'; export type { LongExtended } from './long'; export type { MaxKeyExtended } from './max_key'; diff --git a/src/extended_json.ts b/src/extended_json.ts index d8f7e6cd..0122fe0b 100644 --- a/src/extended_json.ts +++ b/src/extended_json.ts @@ -23,8 +23,8 @@ import { BSONRegExp } from './regexp'; import { BSONSymbol } from './symbol'; import { Timestamp } from './timestamp'; -/** @public */ -export type EJSONOptions = { +/** @internal */ +type EJSONOptionsBase = { /** * Output using the Extended JSON v1 spec * @defaultValue `false` @@ -32,13 +32,13 @@ export type EJSONOptions = { legacy?: boolean; /** * Enable Extended JSON's `relaxed` mode, which attempts to return native JS types where possible, rather than BSON types - * @defaultValue `false` */ - relaxed?: boolean; - /** - * Enable native bigint support * @defaultValue `false` */ - useBigInt64?: boolean; + relaxed?: boolean; +}; + +/** @public */ +export type EJSONSerializeOptions = EJSONOptionsBase & { /** * Omits undefined values from the output instead of converting them to null * @defaultValue `false` @@ -46,6 +46,18 @@ export type EJSONOptions = { ignoreUndefined?: boolean; }; +/** @public */ +export type EJSONParseOptions = EJSONOptionsBase & { + /** + * Enable native bigint support + * @defaultValue `false` + */ + useBigInt64?: boolean; +}; + +/** @public */ +export type EJSONOptions = EJSONSerializeOptions & EJSONParseOptions; + /** @internal */ type BSONType = | Binary @@ -179,12 +191,12 @@ function deserializeValue(value: any, options: EJSONOptions = {}) { return value; } -type EJSONSerializeOptions = EJSONOptions & { +type EJSONSerializeInternalOptions = EJSONSerializeOptions & { seenObjects: { obj: unknown; propertyName: string }[]; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any -function serializeArray(array: any[], options: EJSONSerializeOptions): any[] { +function serializeArray(array: any[], options: EJSONSerializeInternalOptions): any[] { return array.map((v: unknown, index: number) => { options.seenObjects.push({ propertyName: `index ${index}`, obj: null }); try { @@ -202,7 +214,7 @@ function getISOString(date: Date) { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -function serializeValue(value: any, options: EJSONSerializeOptions): any { +function serializeValue(value: any, options: EJSONSerializeInternalOptions): any { if (value instanceof Map || isMap(value)) { const obj: Record = Object.create(null); for (const [k, v] of value) { @@ -331,7 +343,7 @@ const BSON_TYPE_MAPPINGS = { } as const; // eslint-disable-next-line @typescript-eslint/no-explicit-any -function serializeDocument(doc: any, options: EJSONSerializeOptions) { +function serializeDocument(doc: any, options: EJSONSerializeInternalOptions) { if (doc == null || typeof doc !== 'object') throw new BSONError('not an object instance'); const bsontype: BSONType['_bsontype'] = doc._bsontype; @@ -415,7 +427,7 @@ function serializeDocument(doc: any, options: EJSONSerializeOptions) { * ``` */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -function parse(text: string, options?: EJSONOptions): any { +function parse(text: string, options?: EJSONParseOptions): any { const ejsonOptions = { useBigInt64: options?.useBigInt64 ?? false, relaxed: options?.relaxed ?? true, @@ -458,9 +470,9 @@ function stringify( // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any, // eslint-disable-next-line @typescript-eslint/no-explicit-any - replacer?: (number | string)[] | ((this: any, key: string, value: any) => any) | EJSONOptions, + replacer?: (number | string)[] | ((this: any, key: string, value: any) => any) | EJSONSerializeOptions, space?: string | number, - options?: EJSONOptions + options?: EJSONSerializeOptions ): string { if (space != null && typeof space === 'object') { options = space; @@ -486,7 +498,7 @@ function stringify( * @param options - Optional settings passed to the `stringify` function */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -function EJSONserialize(value: any, options?: EJSONOptions): Document { +function EJSONserialize(value: any, options?: EJSONSerializeOptions): Document { options = options || {}; return JSON.parse(stringify(value, options)); } @@ -498,7 +510,7 @@ function EJSONserialize(value: any, options?: EJSONOptions): Document { * @param options - Optional settings passed to the parse method */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -function EJSONdeserialize(ejson: Document, options?: EJSONOptions): any { +function EJSONdeserialize(ejson: Document, options?: EJSONParseOptions): any { options = options || {}; return parse(JSON.stringify(ejson), options); } From 2a5ab4505a30146adb5f3298478faa20a8e99fa6 Mon Sep 17 00:00:00 2001 From: Ofir Stiber Date: Sat, 13 Dec 2025 00:09:35 +0200 Subject: [PATCH 3/3] =?UTF-8?q?test(EJSON):=20=E2=9C=85=20add=20ignoreUnde?= =?UTF-8?q?fined=20tests=20for=20EJSON.serialize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/node/extended_json.test.ts | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/test/node/extended_json.test.ts b/test/node/extended_json.test.ts index 44b4dcd1..f93c35ba 100644 --- a/test/node/extended_json.test.ts +++ b/test/node/extended_json.test.ts @@ -638,6 +638,56 @@ describe('Extended JSON', function () { }); }); + describe('ignoreUndefined option in EJSON.serialize', () => { + it('should convert undefined to null by default', () => { + const doc = { a: 1, b: undefined, c: 'test' }; + const serialized = EJSON.serialize(doc); + expect(serialized).to.deep.equal({ a: 1, b: null, c: 'test' }); + }); + + it('should omit undefined values when ignoreUndefined is true', () => { + const doc = { a: 1, b: undefined, c: 'test' }; + const serialized = EJSON.serialize(doc, { ignoreUndefined: true }); + expect(serialized).to.deep.equal({ a: 1, c: 'test' }); + }); + + it('should handle nested undefined values with ignoreUndefined: true', () => { + const doc = { a: 1, nested: { b: undefined, c: 2 }, d: 'test' }; + const serialized = EJSON.serialize(doc, { ignoreUndefined: true }); + expect(serialized).to.deep.equal({ a: 1, nested: { c: 2 }, d: 'test' }); + }); + + it('should handle nested undefined values without ignoreUndefined (default behavior)', () => { + const doc = { a: 1, nested: { b: undefined, c: 2 }, d: 'test' }; + const serialized = EJSON.serialize(doc); + expect(serialized).to.deep.equal({ a: 1, nested: { b: null, c: 2 }, d: 'test' }); + }); + + it('should handle undefined in arrays with ignoreUndefined: true', () => { + const doc = { arr: [1, undefined, 3] }; + const serialized = EJSON.serialize(doc, { ignoreUndefined: true }); + expect(serialized).to.deep.equal({ arr: [1, null, 3] }); + }); + + it('should handle undefined in arrays without ignoreUndefined (default behavior)', () => { + const doc = { arr: [1, undefined, 3] }; + const serialized = EJSON.serialize(doc); + expect(serialized).to.deep.equal({ arr: [1, null, 3] }); + }); + + it('should handle object with all undefined values with ignoreUndefined: true', () => { + const doc = { a: undefined, b: undefined }; + const serialized = EJSON.serialize(doc, { ignoreUndefined: true }); + expect(serialized).to.deep.equal({}); + }); + + it('should work with relaxed: false option', () => { + const doc = { a: new Int32(10), b: undefined, c: new Double(3.14) }; + const serialized = EJSON.serialize(doc, { ignoreUndefined: true, relaxed: false }); + expect(serialized).to.deep.equal({ a: { $numberInt: '10' }, c: { $numberDouble: '3.14' } }); + }); + }); + it(`throws if Symbol.for('@@mdb.bson.version') is the wrong version in EJSON.stringify`, () => { expect(() => EJSON.stringify({