From d0185036f2aacb5b4bb0fb505168307d99c569bc Mon Sep 17 00:00:00 2001 From: Switt Kongdachalert Date: Fri, 10 Oct 2025 10:10:17 +0700 Subject: [PATCH 1/4] likely solves the issue? --- spec/ParseQuery.spec.js | 65 +++++++++++++++++++ .../Storage/Mongo/MongoStorageAdapter.js | 25 ++++++- .../Postgres/PostgresStorageAdapter.js | 28 +++++++- 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index 98ef70564f..6e132fea61 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -3770,6 +3770,71 @@ describe('Parse.Query testing', () => { expect(response.data.results[0].hello).toBe('world'); }); + it('respects keys selection for relation fields', async () => { + const parent = new Parse.Object('Parent'); + parent.set('name', 'p1'); + const child = new Parse.Object('Child'); + await Parse.Object.saveAll([child, parent]); + + parent.relation('children').add(child); + await parent.save(); + + // if we select only the name column we expect only that key. + const omitRelation = await request({ + url: Parse.serverURL + '/classes/Parent', + qs: { + keys: 'name', + where: JSON.stringify({ objectId: parent.id }), + }, + headers: masterKeyHeaders, + }); + expect(omitRelation.data.results.length).toBe(1); + expect(omitRelation.data.results[0].name).toBe('p1'); + expect(omitRelation.data.results[0].children).toBeUndefined(); + + // if we also include key of the children Relation column it should also be included + const includeRelation = await request({ + url: Parse.serverURL + '/classes/Parent', + qs: { + keys: 'name,children', + where: JSON.stringify({ objectId: parent.id }), + }, + headers: masterKeyHeaders, + }); + expect(includeRelation.data.results.length).toBe(1); + expect(includeRelation.data.results[0].children).toEqual({ + __type: 'Relation', + className: 'Child', + }); + + // if we exclude the children (Relation) column we expect it to not be returned. + const excludeRelation = await request({ + url: Parse.serverURL + '/classes/Parent', + qs: { + excludeKeys: 'children', + where: JSON.stringify({ objectId: parent.id }), + }, + headers: masterKeyHeaders, + }); + expect(excludeRelation.data.results.length).toBe(1); + expect(excludeRelation.data.results[0].name).toBe('p1'); + expect(excludeRelation.data.results[0].children).toBeUndefined(); + + // Default should still work, getting the relation column as normal. + const defaultResponse = await request({ + url: Parse.serverURL + '/classes/Parent', + qs: { + where: JSON.stringify({ objectId: parent.id }), + }, + headers: masterKeyHeaders, + }); + expect(defaultResponse.data.results.length).toBe(1); + expect(defaultResponse.data.results[0].children).toEqual({ + __type: 'Relation', + className: 'Child', + }); + }); + it('select keys with each query', function (done) { const obj = new TestObject({ foo: 'baz', bar: 1 }); diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 481d5257d9..0a02a1e519 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -664,7 +664,30 @@ export class MongoStorageAdapter implements StorageAdapter { if (explain) { return objects; } - return objects.map(object => mongoObjectToParseObject(className, object, schema)); + return objects.map(object => { + const parseObject = mongoObjectToParseObject(className, object, schema); + // If there are returned keys specified; we filter them first. + // We need to do this because in `mongoObjectToParseObject`, all 'Relation' fields + // are copied over from schema without any filters. (either keep this filtering here + // or pass keys into `mongoObjectToParseObject` via additional optional parameter) + if (Array.isArray(keys)) { + // set of string keys + const keysSet = new Set(keys); + const shouldIncludeField = (fieldName) => { + return keysSet.has(fieldName); + }; + // filter out relation fields + Object.keys(schema.fields).forEach(fieldName => { + if ( + schema.fields[fieldName].type === 'Relation' && + !shouldIncludeField(fieldName) + ) { + delete parseObject[fieldName]; + } + }); + } + return parseObject; + }); }) .catch(err => this.handleError(err)); } diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 7eaafcbde2..9c635fe8f7 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1873,6 +1873,9 @@ export class PostgresStorageAdapter implements StorageAdapter { sortPattern = `ORDER BY ${where.sorts.join()}`; } + // For postgres adapter we need to copy the `keys` variable to selectedKeys first + // because `keys` will be mutated in the next block. + const selectedKeys = Array.isArray(keys) ? keys.slice() : undefined; let columns = '*'; if (keys) { // Exclude empty keys @@ -1918,7 +1921,30 @@ export class PostgresStorageAdapter implements StorageAdapter { if (explain) { return results; } - return results.map(object => this.postgresObjectToParseObject(className, object, schema)); + return results.map(object => { + const parseObject = this.postgresObjectToParseObject(className, object, schema); + // If there are returned keys specified; we filter them first. + // We need to do this because in `postgresObjectToParseObject`, all 'Relation' fields + // are copied over from schema without any filters. (either keep this filtering here + // or pass keys into `postgresObjectToParseObject` via additional optional parameter) + if (selectedKeys) { + // set of string keys + const keysSet = new Set(selectedKeys); + const shouldIncludeField = (fieldName) => { + return keysSet.has(fieldName); + }; + // filter out relation fields + Object.keys(schema.fields).forEach(fieldName => { + if ( + schema.fields[fieldName].type === 'Relation' && + !shouldIncludeField(fieldName) + ) { + delete parseObject[fieldName]; + } + }); + } + return parseObject; + }); }); } From 66182cede3230ac0aee7a29afd20e48bbbad248a Mon Sep 17 00:00:00 2001 From: Switt Kongdachalert Date: Fri, 10 Oct 2025 11:08:38 +0700 Subject: [PATCH 2/4] fix graphql errors caused by faulty edges.node handling that had been masked when relation fields always returned --- src/Adapters/Storage/Mongo/MongoStorageAdapter.js | 2 +- .../Storage/Postgres/PostgresStorageAdapter.js | 2 +- src/GraphQL/loaders/parseClassQueries.js | 1 + src/GraphQL/loaders/parseClassTypes.js | 1 + src/GraphQL/parseGraphQLUtils.js | 10 +++++++++- 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 0a02a1e519..2303f9b6aa 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -670,7 +670,7 @@ export class MongoStorageAdapter implements StorageAdapter { // We need to do this because in `mongoObjectToParseObject`, all 'Relation' fields // are copied over from schema without any filters. (either keep this filtering here // or pass keys into `mongoObjectToParseObject` via additional optional parameter) - if (Array.isArray(keys)) { + if (Array.isArray(keys) && keys.length > 0) { // set of string keys const keysSet = new Set(keys); const shouldIncludeField = (fieldName) => { diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 9c635fe8f7..0c623de514 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1927,7 +1927,7 @@ export class PostgresStorageAdapter implements StorageAdapter { // We need to do this because in `postgresObjectToParseObject`, all 'Relation' fields // are copied over from schema without any filters. (either keep this filtering here // or pass keys into `postgresObjectToParseObject` via additional optional parameter) - if (selectedKeys) { + if (selectedKeys.length > 0) { // set of string keys const keysSet = new Set(selectedKeys); const shouldIncludeField = (fieldName) => { diff --git a/src/GraphQL/loaders/parseClassQueries.js b/src/GraphQL/loaders/parseClassQueries.js index edf210ace3..d52437f481 100644 --- a/src/GraphQL/loaders/parseClassQueries.js +++ b/src/GraphQL/loaders/parseClassQueries.js @@ -108,6 +108,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG selectedFields .filter(field => field.startsWith('edges.node.')) .map(field => field.replace('edges.node.', '')) + .map(field => field.replace(/\.edges\.node/g, '')) .filter(field => field.indexOf('edges.node') < 0) ); const parseOrder = order && order.join(','); diff --git a/src/GraphQL/loaders/parseClassTypes.js b/src/GraphQL/loaders/parseClassTypes.js index c6c08c8889..9b76140599 100644 --- a/src/GraphQL/loaders/parseClassTypes.js +++ b/src/GraphQL/loaders/parseClassTypes.js @@ -387,6 +387,7 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla selectedFields .filter(field => field.startsWith('edges.node.')) .map(field => field.replace('edges.node.', '')) + .map(field => field.replace(/\.edges\.node/g, '')) .filter(field => field.indexOf('edges.node') < 0) ); const parseOrder = order && order.join(','); diff --git a/src/GraphQL/parseGraphQLUtils.js b/src/GraphQL/parseGraphQLUtils.js index f1194784cb..a0064f2433 100644 --- a/src/GraphQL/parseGraphQLUtils.js +++ b/src/GraphQL/parseGraphQLUtils.js @@ -20,7 +20,15 @@ export function toGraphQLError(error) { } export const extractKeysAndInclude = selectedFields => { - selectedFields = selectedFields.filter(field => !field.includes('__typename')); + selectedFields = selectedFields + .filter(field => !field.includes('__typename')) + // GraphQL relation connections expose data under `edges.node.*`. Those + // segments do not correspond to actual Parse fields, so strip them to + // ensure the root relation key remains in the keys list (e.g. convert + // `users.edges.node.username` -> `users.username`). This preserves the + // synthetic relation placeholders that Parse injects while still + // respecting field projections. + .map(field => field.replace(/\.edges\.node/g, '')); // Handles "id" field for both current and included objects selectedFields = selectedFields.map(field => { if (field === 'id') { return 'objectId'; } From b77a24b94c844d8a2020f2545ae97d6c86aeb0ba Mon Sep 17 00:00:00 2001 From: Switt Kongdachalert Date: Fri, 10 Oct 2025 11:48:40 +0700 Subject: [PATCH 3/4] removed redundant logic and moved comments to outside for posterity --- src/GraphQL/loaders/parseClassQueries.js | 9 +++++++-- src/GraphQL/loaders/parseClassTypes.js | 19 ++++++++++++------- src/GraphQL/parseGraphQLUtils.js | 8 +------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/GraphQL/loaders/parseClassQueries.js b/src/GraphQL/loaders/parseClassQueries.js index d52437f481..c8d4c6eea5 100644 --- a/src/GraphQL/loaders/parseClassQueries.js +++ b/src/GraphQL/loaders/parseClassQueries.js @@ -107,8 +107,13 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG const { keys, include } = extractKeysAndInclude( selectedFields .filter(field => field.startsWith('edges.node.')) - .map(field => field.replace('edges.node.', '')) - .map(field => field.replace(/\.edges\.node/g, '')) + // GraphQL relation connections expose data under `edges.node.*`. Those + // segments do not correspond to actual Parse fields, so strip them to + // ensure the root relation key remains in the keys list (e.g. convert + // `users.edges.node.username` -> `users.username`). This preserves the + // synthetic relation placeholders that Parse injects while still + // respecting field projections. + .map(field => field.replace('edges.node.', '').replace(/\.edges\.node/g, '')) .filter(field => field.indexOf('edges.node') < 0) ); const parseOrder = order && order.join(','); diff --git a/src/GraphQL/loaders/parseClassTypes.js b/src/GraphQL/loaders/parseClassTypes.js index 9b76140599..98558101cb 100644 --- a/src/GraphQL/loaders/parseClassTypes.js +++ b/src/GraphQL/loaders/parseClassTypes.js @@ -351,11 +351,11 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla ...defaultGraphQLTypes.PARSE_OBJECT_FIELDS, ...(className === '_User' ? { - authDataResponse: { - description: `auth provider response when triggered on signUp/logIn.`, - type: defaultGraphQLTypes.OBJECT, - }, - } + authDataResponse: { + description: `auth provider response when triggered on signUp/logIn.`, + type: defaultGraphQLTypes.OBJECT, + }, + } : {}), }; const outputFields = () => { @@ -386,8 +386,13 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla const { keys, include } = extractKeysAndInclude( selectedFields .filter(field => field.startsWith('edges.node.')) - .map(field => field.replace('edges.node.', '')) - .map(field => field.replace(/\.edges\.node/g, '')) + // GraphQL relation connections expose data under `edges.node.*`. Those + // segments do not correspond to actual Parse fields, so strip them to + // ensure the root relation key remains in the keys list (e.g. convert + // `users.edges.node.username` -> `users.username`). This preserves the + // synthetic relation placeholders that Parse injects while still + // respecting field projections. + .map(field => field.replace('edges.node.', '').replace(/\.edges\.node/g, '')) .filter(field => field.indexOf('edges.node') < 0) ); const parseOrder = order && order.join(','); diff --git a/src/GraphQL/parseGraphQLUtils.js b/src/GraphQL/parseGraphQLUtils.js index a0064f2433..496e11739e 100644 --- a/src/GraphQL/parseGraphQLUtils.js +++ b/src/GraphQL/parseGraphQLUtils.js @@ -22,13 +22,7 @@ export function toGraphQLError(error) { export const extractKeysAndInclude = selectedFields => { selectedFields = selectedFields .filter(field => !field.includes('__typename')) - // GraphQL relation connections expose data under `edges.node.*`. Those - // segments do not correspond to actual Parse fields, so strip them to - // ensure the root relation key remains in the keys list (e.g. convert - // `users.edges.node.username` -> `users.username`). This preserves the - // synthetic relation placeholders that Parse injects while still - // respecting field projections. - .map(field => field.replace(/\.edges\.node/g, '')); + // Handles "id" field for both current and included objects selectedFields = selectedFields.map(field => { if (field === 'id') { return 'objectId'; } From f3fed8a41345737f99356669f55f5e334939e5b9 Mon Sep 17 00:00:00 2001 From: Switt Kongdachalert Date: Sat, 11 Oct 2025 13:39:52 +0700 Subject: [PATCH 4/4] guard array --- src/Adapters/Storage/Postgres/PostgresStorageAdapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 0c623de514..73ae3a79ac 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1927,7 +1927,7 @@ export class PostgresStorageAdapter implements StorageAdapter { // We need to do this because in `postgresObjectToParseObject`, all 'Relation' fields // are copied over from schema without any filters. (either keep this filtering here // or pass keys into `postgresObjectToParseObject` via additional optional parameter) - if (selectedKeys.length > 0) { + if (Array.isArray(selectedKeys) && selectedKeys.length > 0) { // set of string keys const keysSet = new Set(selectedKeys); const shouldIncludeField = (fieldName) => {