From a423f0c8cb8357c16b1ba8c3174bbad8231c6c7b Mon Sep 17 00:00:00 2001 From: faceapps Date: Sat, 21 Mar 2026 19:07:19 +0530 Subject: [PATCH 1/7] fix: strip undefined values in `ParseServerRESTController` to match HTTP mode behavior When `directAccess: true` is enabled, `ParseServerRESTController` passes request data directly to Parse Server internals without JSON serialization. In HTTP mode, `JSON.stringify()` naturally strips `undefined` values from payloads. With directAccess, `undefined` values are preserved, passed to MongoDB's `$set` operator, and converted to `null` by the BSON driver. This causes fields that should be absent to be stored as `null`, breaking `doesNotExist` queries and changing data semantics. The fix adds a `stripUndefined()` helper that removes keys with `undefined` values from the request body, making directAccess behave identically to HTTP mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- spec/ParseServerRESTController.spec.js | 18 ++++++++++++++++++ src/ParseServerRESTController.js | 15 ++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/spec/ParseServerRESTController.spec.js b/spec/ParseServerRESTController.spec.js index 31d1f5aec7..261735a82f 100644 --- a/spec/ParseServerRESTController.spec.js +++ b/spec/ParseServerRESTController.spec.js @@ -675,4 +675,22 @@ describe('ParseServerRESTController', () => { const result = await Parse.Push.getPushStatus(pushStatusId); expect(result.id).toBe(pushStatusId); }); + + it('should not convert undefined values to null on update with directAccess', async () => { + const createRes = await RESTController.request('POST', '/classes/MyObject', { + presentField: 'hello', + }); + expect(createRes.objectId).toBeDefined(); + + await RESTController.request('PUT', `/classes/MyObject/${createRes.objectId}`, { + presentField: 'updated', + absentField: undefined, + }); + + const getRes = await RESTController.request('GET', `/classes/MyObject/${createRes.objectId}`); + + expect(getRes.presentField).toBe('updated'); + expect(getRes.absentField).toBeUndefined(); + expect('absentField' in getRes).toBe(false); + }); }); diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js index 9ec4b6f86e..74334736e1 100644 --- a/src/ParseServerRESTController.js +++ b/src/ParseServerRESTController.js @@ -29,6 +29,19 @@ function getAuth(options = {}, config) { }); } +function stripUndefined(obj) { + if (obj === null || obj === undefined || typeof obj !== 'object' || Array.isArray(obj)) { + return obj; + } + const result = {}; + for (const key of Object.keys(obj)) { + if (obj[key] !== undefined) { + result[key] = obj[key]; + } + } + return result; +} + function ParseServerRESTController(applicationId, router) { function handleRequest(method, path, data = {}, options = {}, config) { // Store the arguments, for later use if internal fails @@ -113,7 +126,7 @@ function ParseServerRESTController(applicationId, router) { return new Promise((resolve, reject) => { getAuth(options, config).then(auth => { const request = { - body: data, + body: stripUndefined(data), config, auth, info: { From e98ffc9abd14dcd1df04e461642375f61ec5e415 Mon Sep 17 00:00:00 2001 From: faceapps Date: Sat, 21 Mar 2026 21:13:28 +0530 Subject: [PATCH 2/7] refactor: use JSON.parse(JSON.stringify()) instead of stripUndefined MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON.parse(JSON.stringify(data)) perfectly replicates HTTP mode's serialization roundtrip — stripping undefined at all nesting levels, not just top-level keys. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ParseServerRESTController.js | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js index 74334736e1..2ff5fd1808 100644 --- a/src/ParseServerRESTController.js +++ b/src/ParseServerRESTController.js @@ -29,17 +29,11 @@ function getAuth(options = {}, config) { }); } -function stripUndefined(obj) { - if (obj === null || obj === undefined || typeof obj !== 'object' || Array.isArray(obj)) { - return obj; +function jsonCopy(obj) { + if (obj === undefined) { + return undefined; } - const result = {}; - for (const key of Object.keys(obj)) { - if (obj[key] !== undefined) { - result[key] = obj[key]; - } - } - return result; + return JSON.parse(JSON.stringify(obj)); } function ParseServerRESTController(applicationId, router) { @@ -126,7 +120,7 @@ function ParseServerRESTController(applicationId, router) { return new Promise((resolve, reject) => { getAuth(options, config).then(auth => { const request = { - body: stripUndefined(data), + body: jsonCopy(data), config, auth, info: { From 5275d964399e6ba381fba0fcd06e5eb488ac1a3c Mon Sep 17 00:00:00 2001 From: faceapps Date: Sat, 21 Mar 2026 21:18:22 +0530 Subject: [PATCH 3/7] refactor: use recursive stripUndefined instead of JSON.parse(JSON.stringify()) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON.parse(JSON.stringify()) is expensive. Replace with a recursive stripUndefined that walks the object tree efficiently — strips undefined keys from objects at all nesting levels and converts undefined array elements to null, matching JSON.stringify behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ParseServerRESTController.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js index 2ff5fd1808..cc92dbb3bf 100644 --- a/src/ParseServerRESTController.js +++ b/src/ParseServerRESTController.js @@ -29,11 +29,20 @@ function getAuth(options = {}, config) { }); } -function jsonCopy(obj) { - if (obj === undefined) { - return undefined; +function stripUndefined(obj) { + if (obj === null || obj === undefined || typeof obj !== 'object') { + return obj; } - return JSON.parse(JSON.stringify(obj)); + if (Array.isArray(obj)) { + return obj.map(item => item === undefined ? null : stripUndefined(item)); + } + const result = {}; + for (const key of Object.keys(obj)) { + if (obj[key] !== undefined) { + result[key] = stripUndefined(obj[key]); + } + } + return result; } function ParseServerRESTController(applicationId, router) { @@ -120,7 +129,7 @@ function ParseServerRESTController(applicationId, router) { return new Promise((resolve, reject) => { getAuth(options, config).then(auth => { const request = { - body: jsonCopy(data), + body: stripUndefined(data), config, auth, info: { From 41223c02778a54410280f8b4cfb35d98ed172928 Mon Sep 17 00:00:00 2001 From: faceapps Date: Mon, 23 Mar 2026 06:38:31 +0530 Subject: [PATCH 4/7] test: Add HTTP mode comparison test for undefined values on update Adds a companion test that verifies the same behavior without directAccess (HTTP mode) to confirm both paths yield identical results, as requested in review. Co-Authored-By: Claude Opus 4.6 (1M context) --- spec/ParseServerRESTController.spec.js | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/spec/ParseServerRESTController.spec.js b/spec/ParseServerRESTController.spec.js index 261735a82f..173adb1f30 100644 --- a/spec/ParseServerRESTController.spec.js +++ b/spec/ParseServerRESTController.spec.js @@ -2,6 +2,7 @@ const ParseServerRESTController = require('../lib/ParseServerRESTController') .ParseServerRESTController; const ParseServer = require('../lib/ParseServer').default; const Parse = require('parse/node').Parse; +const request = require('../lib/request'); let RESTController; @@ -693,4 +694,40 @@ describe('ParseServerRESTController', () => { expect(getRes.absentField).toBeUndefined(); expect('absentField' in getRes).toBe(false); }); + + it('should not convert undefined values to null on update without directAccess (HTTP mode)', async () => { + const serverURL = 'http://localhost:8378/1'; + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }; + + const createRes = await request({ + method: 'POST', + headers, + url: `${serverURL}/classes/MyObject`, + body: JSON.stringify({ presentField: 'hello' }), + }); + const { objectId } = JSON.parse(createRes.text); + expect(objectId).toBeDefined(); + + await request({ + method: 'PUT', + headers, + url: `${serverURL}/classes/MyObject/${objectId}`, + body: JSON.stringify({ presentField: 'updated', absentField: undefined }), + }); + + const getRes = await request({ + method: 'GET', + headers, + url: `${serverURL}/classes/MyObject/${objectId}`, + }); + const result = JSON.parse(getRes.text); + + expect(result.presentField).toBe('updated'); + expect(result.absentField).toBeUndefined(); + expect('absentField' in result).toBe(false); + }); }); From 29289315fb16926f7f8af2827533da862ec2bad3 Mon Sep 17 00:00:00 2001 From: faceapps Date: Mon, 23 Mar 2026 06:43:53 +0530 Subject: [PATCH 5/7] refactor: Let HTTP layer handle JSON serialization in test Remove explicit JSON.stringify/JSON.parse calls from the HTTP mode test, letting the request helper's encodeBody handle serialization naturally. This better demonstrates that HTTP mode inherently strips undefined values. Co-Authored-By: Claude Opus 4.6 (1M context) --- spec/ParseServerRESTController.spec.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/spec/ParseServerRESTController.spec.js b/spec/ParseServerRESTController.spec.js index 173adb1f30..85a5867910 100644 --- a/spec/ParseServerRESTController.spec.js +++ b/spec/ParseServerRESTController.spec.js @@ -707,27 +707,25 @@ describe('ParseServerRESTController', () => { method: 'POST', headers, url: `${serverURL}/classes/MyObject`, - body: JSON.stringify({ presentField: 'hello' }), + body: { presentField: 'hello' }, }); - const { objectId } = JSON.parse(createRes.text); - expect(objectId).toBeDefined(); + expect(createRes.data.objectId).toBeDefined(); await request({ method: 'PUT', headers, - url: `${serverURL}/classes/MyObject/${objectId}`, - body: JSON.stringify({ presentField: 'updated', absentField: undefined }), + url: `${serverURL}/classes/MyObject/${createRes.data.objectId}`, + body: { presentField: 'updated', absentField: undefined }, }); const getRes = await request({ method: 'GET', headers, - url: `${serverURL}/classes/MyObject/${objectId}`, + url: `${serverURL}/classes/MyObject/${createRes.data.objectId}`, }); - const result = JSON.parse(getRes.text); - expect(result.presentField).toBe('updated'); - expect(result.absentField).toBeUndefined(); - expect('absentField' in result).toBe(false); + expect(getRes.data.presentField).toBe('updated'); + expect(getRes.data.absentField).toBeUndefined(); + expect('absentField' in getRes.data).toBe(false); }); }); From 5910c02c9d0305380e3535b87ac7b9738469d49b Mon Sep 17 00:00:00 2001 From: faceapps Date: Mon, 23 Mar 2026 23:17:39 +0530 Subject: [PATCH 6/7] fix: strip undefined values from directAccess response to match HTTP behavior Co-Authored-By: Claude Opus 4.6 (1M context) --- spec/ParseServerRESTController.spec.js | 59 ++++++++++++++++++++++++++ src/ParseServerRESTController.js | 5 ++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/spec/ParseServerRESTController.spec.js b/spec/ParseServerRESTController.spec.js index 85a5867910..51e9433feb 100644 --- a/spec/ParseServerRESTController.spec.js +++ b/spec/ParseServerRESTController.spec.js @@ -520,6 +520,65 @@ describe('ParseServerRESTController', () => { ); }); + it('should strip undefined values from cloud function responses (with directAccess)', async () => { + Parse.Cloud.define('returnUndefinedValues', () => { + return { + definedKey: 'value', + undefinedKey: undefined, + nested: { a: 1, b: undefined }, + arrayWithUndefined: [1, undefined, 3], + }; + }); + + const res = await RESTController.request( + 'POST', + '/functions/returnUndefinedValues', + {}, + { useMasterKey: true } + ); + + expect(res.result.definedKey).toEqual('value'); + expect(res.result.undefinedKey).toBeUndefined(); + expect(Object.hasOwnProperty.call(res.result, 'undefinedKey')).toBe(false); + expect(res.result.nested.a).toEqual(1); + expect(Object.hasOwnProperty.call(res.result.nested, 'b')).toBe(false); + expect(res.result.arrayWithUndefined).toEqual([1, null, 3]); + }); + + it('should strip undefined values from cloud function responses (without directAccess)', async () => { + Parse.Cloud.define('returnUndefinedValuesHTTP', () => { + return { + definedKey: 'value', + undefinedKey: undefined, + nested: { a: 1, b: undefined }, + arrayWithUndefined: [1, undefined, 3], + }; + }); + + const serverURL = 'http://localhost:8378/1'; + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }; + + const res = await request({ + method: 'POST', + headers, + url: `${serverURL}/functions/returnUndefinedValuesHTTP`, + body: {}, + }); + + const result = res.data.result; + + expect(result.definedKey).toEqual('value'); + expect(result.undefinedKey).toBeUndefined(); + expect(Object.hasOwnProperty.call(result, 'undefinedKey')).toBe(false); + expect(result.nested.a).toEqual(1); + expect(Object.hasOwnProperty.call(result.nested, 'b')).toBe(false); + expect(result.arrayWithUndefined).toEqual([1, null, 3]); + }); + it('ensures sessionTokens are properly handled', async () => { const user = await Parse.User.signUp('user', 'pass'); const sessionToken = user.getSessionToken(); diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js index cc92dbb3bf..3c81796076 100644 --- a/src/ParseServerRESTController.js +++ b/src/ParseServerRESTController.js @@ -147,10 +147,11 @@ function ParseServerRESTController(applicationId, router) { .then( resp => { const { response, status, headers = {} } = resp; + const strippedResponse = stripUndefined(response); if (options.returnStatus) { - resolve({ ...response, _status: status, _headers: headers }); + resolve({ ...strippedResponse, _status: status, _headers: headers }); } else { - resolve(response); + resolve(strippedResponse); } }, err => { From 6bf3a11252fb60abf502bdf80fb1c401f49835a3 Mon Sep 17 00:00:00 2001 From: Yogendra Singh Date: Sat, 28 Mar 2026 07:58:35 +0530 Subject: [PATCH 7/7] fix: Use JSON.parse(JSON.stringify()) for HTTP parity, strip query params, add tests - Replace custom stripUndefined with JSON.parse(JSON.stringify()) for correct JSON.stringify semantics (handles toJSON, nested objects, special types) - Strip undefined from GET query params for full HTTP parity - Add nested undefined assertions to existing update tests - Add POST (create) tests with undefined values for both directAccess and HTTP mode Co-Authored-By: Claude Opus 4.6 (1M context) --- spec/ParseServerRESTController.spec.js | 50 +++++++++++++++++++++++++- src/ParseServerRESTController.js | 22 ++---------- 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/spec/ParseServerRESTController.spec.js b/spec/ParseServerRESTController.spec.js index 51e9433feb..6e8576ba13 100644 --- a/spec/ParseServerRESTController.spec.js +++ b/spec/ParseServerRESTController.spec.js @@ -745,6 +745,7 @@ describe('ParseServerRESTController', () => { await RESTController.request('PUT', `/classes/MyObject/${createRes.objectId}`, { presentField: 'updated', absentField: undefined, + nested: { absentField: undefined, presentField: 'value' }, }); const getRes = await RESTController.request('GET', `/classes/MyObject/${createRes.objectId}`); @@ -752,6 +753,23 @@ describe('ParseServerRESTController', () => { expect(getRes.presentField).toBe('updated'); expect(getRes.absentField).toBeUndefined(); expect('absentField' in getRes).toBe(false); + expect(getRes.nested).toBeDefined(); + expect(getRes.nested.presentField).toBe('value'); + expect('absentField' in getRes.nested).toBe(false); + }); + + it('should not convert undefined values to null on create with directAccess', async () => { + const createRes = await RESTController.request('POST', '/classes/MyObject', { + presentField: 'hello', + absentField: undefined, + }); + expect(createRes.objectId).toBeDefined(); + + const getRes = await RESTController.request('GET', `/classes/MyObject/${createRes.objectId}`); + + expect(getRes.presentField).toBe('hello'); + expect(getRes.absentField).toBeUndefined(); + expect('absentField' in getRes).toBe(false); }); it('should not convert undefined values to null on update without directAccess (HTTP mode)', async () => { @@ -774,7 +792,7 @@ describe('ParseServerRESTController', () => { method: 'PUT', headers, url: `${serverURL}/classes/MyObject/${createRes.data.objectId}`, - body: { presentField: 'updated', absentField: undefined }, + body: { presentField: 'updated', absentField: undefined, nested: { absentField: undefined, presentField: 'value' } }, }); const getRes = await request({ @@ -786,5 +804,35 @@ describe('ParseServerRESTController', () => { expect(getRes.data.presentField).toBe('updated'); expect(getRes.data.absentField).toBeUndefined(); expect('absentField' in getRes.data).toBe(false); + expect(getRes.data.nested).toBeDefined(); + expect(getRes.data.nested.presentField).toBe('value'); + expect('absentField' in getRes.data.nested).toBe(false); + }); + + it('should not convert undefined values to null on create without directAccess (HTTP mode)', async () => { + const serverURL = 'http://localhost:8378/1'; + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }; + + const createRes = await request({ + method: 'POST', + headers, + url: `${serverURL}/classes/MyObject`, + body: { presentField: 'hello', absentField: undefined }, + }); + expect(createRes.data.objectId).toBeDefined(); + + const getRes = await request({ + method: 'GET', + headers, + url: `${serverURL}/classes/MyObject/${createRes.data.objectId}`, + }); + + expect(getRes.data.presentField).toBe('hello'); + expect(getRes.data.absentField).toBeUndefined(); + expect('absentField' in getRes.data).toBe(false); }); }); diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js index 3c81796076..bc7ff564da 100644 --- a/src/ParseServerRESTController.js +++ b/src/ParseServerRESTController.js @@ -29,22 +29,6 @@ function getAuth(options = {}, config) { }); } -function stripUndefined(obj) { - if (obj === null || obj === undefined || typeof obj !== 'object') { - return obj; - } - if (Array.isArray(obj)) { - return obj.map(item => item === undefined ? null : stripUndefined(item)); - } - const result = {}; - for (const key of Object.keys(obj)) { - if (obj[key] !== undefined) { - result[key] = stripUndefined(obj[key]); - } - } - return result; -} - function ParseServerRESTController(applicationId, router) { function handleRequest(method, path, data = {}, options = {}, config) { // Store the arguments, for later use if internal fails @@ -129,7 +113,7 @@ function ParseServerRESTController(applicationId, router) { return new Promise((resolve, reject) => { getAuth(options, config).then(auth => { const request = { - body: stripUndefined(data), + body: JSON.parse(JSON.stringify(data)), config, auth, info: { @@ -138,7 +122,7 @@ function ParseServerRESTController(applicationId, router) { installationId: options.installationId, context: options.context || {}, }, - query, + query: query ? JSON.parse(JSON.stringify(query)) : query, }; return Promise.resolve() .then(() => { @@ -147,7 +131,7 @@ function ParseServerRESTController(applicationId, router) { .then( resp => { const { response, status, headers = {} } = resp; - const strippedResponse = stripUndefined(response); + const strippedResponse = JSON.parse(JSON.stringify(response)); if (options.returnStatus) { resolve({ ...strippedResponse, _status: status, _headers: headers }); } else {