-
Notifications
You must be signed in to change notification settings - Fork 265
fix(NODE-7436): EJSON.stringify type signature #870
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,6 +58,30 @@ export type EJSONParseOptions = EJSONOptionsBase & { | |
| /** @public */ | ||
| export type EJSONOptions = EJSONSerializeOptions & EJSONParseOptions; | ||
|
|
||
| /** @internal */ | ||
| function isEJSONSerializeOptions(value: unknown): value is EJSONSerializeOptions { | ||
| if (value == null || typeof value !== 'object') { | ||
| return false; | ||
| } | ||
|
|
||
| // Check that all properties, if present, are of the correct type | ||
| if ('legacy' in value && typeof value.legacy !== 'boolean') { | ||
| return false; | ||
| } | ||
| if ('relaxed' in value && typeof value.relaxed !== 'boolean') { | ||
| return false; | ||
| } | ||
| if ('ignoreUndefined' in value && typeof value.ignoreUndefined !== 'boolean') { | ||
| return false; | ||
| } | ||
|
|
||
| // Check that there are no invalid properties (only known EJSON serialize options) | ||
| const validKeys = ['legacy', 'relaxed', 'ignoreUndefined']; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This creates a maintenance footgun with to compiler warning to catch if |
||
| const keys = Object.keys(value); | ||
|
|
||
| return keys.every(key => validKeys.includes(key)); | ||
| } | ||
|
|
||
| /** @internal */ | ||
| type BSONType = | ||
| | Binary | ||
|
|
@@ -443,6 +467,7 @@ function parse(text: string, options?: EJSONParseOptions): any { | |
| }); | ||
| } | ||
|
|
||
| /* eslint-disable @typescript-eslint/no-explicit-any */ | ||
| /** | ||
| * Converts a BSON document to an Extended JSON string, optionally replacing values if a replacer | ||
| * function is specified or optionally including only the specified properties if a replacer array | ||
|
|
@@ -464,28 +489,65 @@ function parse(text: string, options?: EJSONParseOptions): any { | |
| * | ||
| * // prints '{"int32":10}' | ||
| * console.log(EJSON.stringify(doc)); | ||
| * | ||
| * // prints '{"int32":{"$numberInt":"10"}}' with 2 space indentation | ||
| * console.log(EJSON.stringify(doc, { relaxed: false }, 2)); | ||
| * ``` | ||
| */ | ||
| function stringify( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These overloads add type-level documentation of supported call patterns, but they also add maintenance hurdle. Since the runtime checks are needed regardless, and the existing signature with the union type already communicates that options can go in the second position - this doesn't provide enough value to justify the complexity. |
||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| value: any, | ||
| replacer?: | ||
| replacer?: (number | string)[] | ((this: any, key: string, value: any) => any) | null, | ||
| space?: string | number, | ||
| options?: EJSONSerializeOptions | ||
| ): string; | ||
| function stringify( | ||
| value: any, | ||
| replacer?: (number | string)[] | ((this: any, key: string, value: any) => any) | null, | ||
| options?: EJSONSerializeOptions | ||
| ): string; | ||
| function stringify(value: any, options?: EJSONSerializeOptions, space?: string | number): string; | ||
| function stringify( | ||
| value: any, | ||
| replacerOrOptions?: | ||
| | (number | string)[] | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| | ((this: any, key: string, value: any) => any) | ||
| | null | ||
| | EJSONSerializeOptions, | ||
| space?: string | number, | ||
| spaceOrOptions?: string | number | EJSONSerializeOptions, | ||
| options?: EJSONSerializeOptions | ||
| ): string { | ||
| if (space != null && typeof space === 'object') { | ||
| options = space; | ||
| space = 0; | ||
| } | ||
| if (replacer != null && typeof replacer === 'object' && !Array.isArray(replacer)) { | ||
| options = replacer; | ||
| let replacer: | ||
| | ((this: any, key: string, value: any) => any) | ||
| | (number | string)[] | ||
| | null | ||
| | undefined; | ||
| let space: string | number | undefined; | ||
| /* eslint-enable @typescript-eslint/no-explicit-any */ | ||
|
|
||
| // Check if second parameter is options | ||
| if (isEJSONSerializeOptions(replacerOrOptions)) { | ||
| options = replacerOrOptions; | ||
| replacer = undefined; | ||
| space = 0; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is the original bug and removing just this line (avoid unconditional discarding the |
||
| space = | ||
| typeof spaceOrOptions === 'number' || typeof spaceOrOptions === 'string' | ||
| ? spaceOrOptions | ||
| : undefined; | ||
| } | ||
| // Check if third parameter is options | ||
| else if (isEJSONSerializeOptions(spaceOrOptions)) { | ||
| options = spaceOrOptions; | ||
| replacer = replacerOrOptions; | ||
| space = undefined; | ||
| } | ||
| // Standard case: replacer, space, options | ||
| else { | ||
| replacer = replacerOrOptions; | ||
| space = | ||
| typeof spaceOrOptions === 'number' || typeof spaceOrOptions === 'string' | ||
| ? spaceOrOptions | ||
| : undefined; | ||
| } | ||
|
|
||
| const serializeOptions = Object.assign({ relaxed: true, legacy: false }, options, { | ||
| seenObjects: [{ propertyName: '(root)', obj: null }] | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -733,4 +733,153 @@ describe('Extended JSON', function () { | |
| expect(() => EJSON.stringify(input)).to.throw(BSONError); | ||
| }); | ||
| }); | ||
|
|
||
| context('stringify: Parameter signature combinations', function () { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test coverage here is great, and these tests are valuable independent of which implementation approach is chosen. Please keep them even with a simplified fix. |
||
| const testDoc = { | ||
| objectId: ObjectId.createFromHexString('111111111111111111111111'), | ||
| int32Number: 300, | ||
| name: 'test' | ||
| }; | ||
|
|
||
| it('should work with (value) - only value parameter', function () { | ||
| const result = EJSON.stringify(testDoc); | ||
| expect(result).to.equal( | ||
| '{"objectId":{"$oid":"111111111111111111111111"},"int32Number":300,"name":"test"}' | ||
| ); | ||
| }); | ||
|
|
||
| it('should work with (value, null, space) - replacer null, space number', function () { | ||
| const result = EJSON.stringify(testDoc, null, 2); | ||
| expect(result).to.equal(`{ | ||
| "objectId": { | ||
| "$oid": "111111111111111111111111" | ||
| }, | ||
| "int32Number": 300, | ||
| "name": "test" | ||
| }`); | ||
| }); | ||
|
|
||
| it('should work with (value, null, space, options) - replacer null, space, options', function () { | ||
| const result = EJSON.stringify(testDoc, null, 2, { relaxed: false }); | ||
| expect(result).to.equal(`{ | ||
| "objectId": { | ||
| "$oid": "111111111111111111111111" | ||
| }, | ||
| "int32Number": { | ||
| "$numberInt": "300" | ||
| }, | ||
| "name": "test" | ||
| }`); | ||
| }); | ||
|
|
||
| it('should work with (value, array, space) - replacer array, space', function () { | ||
| const result = EJSON.stringify(testDoc, ['objectId', '$oid', 'name'], 2); | ||
| expect(result).to.equal(`{ | ||
| "objectId": { | ||
| "$oid": "111111111111111111111111" | ||
| }, | ||
| "name": "test" | ||
| }`); | ||
| }); | ||
|
|
||
| it('should work with (value, array, space, options) - replacer array, space, options', function () { | ||
| const result = EJSON.stringify(testDoc, ['objectId', '$oid'], 2, { relaxed: false }); | ||
| expect(result).to.equal(`{ | ||
| "objectId": { | ||
| "$oid": "111111111111111111111111" | ||
| } | ||
| }`); | ||
| }); | ||
|
|
||
| it('should work with (value, function, space) - replacer function, space', function () { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const replacer = function (key: string, value: any) { | ||
| return key === 'name' ? undefined : value; | ||
| }; | ||
| const result = EJSON.stringify(testDoc, replacer, 2); | ||
| expect(result).to.equal(`{ | ||
| "objectId": { | ||
| "$oid": "111111111111111111111111" | ||
| }, | ||
| "int32Number": 300 | ||
| }`); | ||
| }); | ||
|
|
||
| it('should work with (value, function, space, options) - replacer function, space, options', function () { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const replacer = function (key: string, value: any) { | ||
| return key === 'name' ? undefined : value; | ||
| }; | ||
| const result = EJSON.stringify(testDoc, replacer, 2, { relaxed: false }); | ||
| expect(result).to.equal(`{ | ||
| "objectId": { | ||
| "$oid": "111111111111111111111111" | ||
| }, | ||
| "int32Number": { | ||
| "$numberInt": "300" | ||
| } | ||
| }`); | ||
| }); | ||
|
|
||
| it('should work with (value, null, options) - replacer null, options', function () { | ||
| const result = EJSON.stringify(testDoc, null, { relaxed: false }); | ||
| expect(result).to.equal( | ||
| '{"objectId":{"$oid":"111111111111111111111111"},"int32Number":{"$numberInt":"300"},"name":"test"}' | ||
| ); | ||
| }); | ||
|
|
||
| it('should work with (value, array, options) - replacer array, options', function () { | ||
| const result = EJSON.stringify(testDoc, ['objectId', '$oid'], { relaxed: false }); | ||
| expect(result).to.equal('{"objectId":{"$oid":"111111111111111111111111"}}'); | ||
| }); | ||
|
|
||
| it('should work with (value, function, options) - replacer function, options', function () { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const replacer = function (key: string, value: any) { | ||
| return key === 'int32Number' ? undefined : value; | ||
| }; | ||
| const result = EJSON.stringify(testDoc, replacer, { relaxed: false }); | ||
| expect(result).to.equal('{"objectId":{"$oid":"111111111111111111111111"},"name":"test"}'); | ||
| }); | ||
|
|
||
| it('should work with (value, options) - value and options only', function () { | ||
| const result = EJSON.stringify(testDoc, { relaxed: false }); | ||
| expect(result).to.equal( | ||
| '{"objectId":{"$oid":"111111111111111111111111"},"int32Number":{"$numberInt":"300"},"name":"test"}' | ||
| ); | ||
| }); | ||
|
|
||
| it('should work with (value, options, space) - value, options, space (second overload)', function () { | ||
| const result = EJSON.stringify(testDoc, { relaxed: false }, 2); | ||
| expect(result).to.equal(`{ | ||
| "objectId": { | ||
| "$oid": "111111111111111111111111" | ||
| }, | ||
| "int32Number": { | ||
| "$numberInt": "300" | ||
| }, | ||
| "name": "test" | ||
| }`); | ||
| }); | ||
|
|
||
| it('should work with string space parameter', function () { | ||
| const result = EJSON.stringify(testDoc, null, '\t', { relaxed: false }); | ||
| expect(result).to.equal(`{ | ||
| \t"objectId": { | ||
| \t\t"$oid": "111111111111111111111111" | ||
| \t}, | ||
| \t"int32Number": { | ||
| \t\t"$numberInt": "300" | ||
| \t}, | ||
| \t"name": "test" | ||
| }`); | ||
| }); | ||
|
|
||
| it('should work with space 0 (no formatting)', function () { | ||
| const result = EJSON.stringify(testDoc, null, 0, { relaxed: false }); | ||
| expect(result).to.equal( | ||
| '{"objectId":{"$oid":"111111111111111111111111"},"int32Number":{"$numberInt":"300"},"name":"test"}' | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The strict key check causes silent misbehavior on typos. If something like
{relaxd: false}will be passed - this guard returnsfalseand the object will silently be forwarded toJSON.stringifyas a replacer - producing wrong output, the existingtypeof replacer === 'object' && !Array.isArray(replacer)would correctly treat it as options. This is more forgiving and safer.