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..2303f9b6aa 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) && keys.length > 0) { + // 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..73ae3a79ac 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 (Array.isArray(selectedKeys) && selectedKeys.length > 0) { + // 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; + }); }); } diff --git a/src/GraphQL/loaders/parseClassQueries.js b/src/GraphQL/loaders/parseClassQueries.js index edf210ace3..c8d4c6eea5 100644 --- a/src/GraphQL/loaders/parseClassQueries.js +++ b/src/GraphQL/loaders/parseClassQueries.js @@ -107,7 +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.', '')) + // 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 c6c08c8889..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,7 +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.', '')) + // 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 f1194784cb..496e11739e 100644 --- a/src/GraphQL/parseGraphQLUtils.js +++ b/src/GraphQL/parseGraphQLUtils.js @@ -20,7 +20,9 @@ export function toGraphQLError(error) { } export const extractKeysAndInclude = selectedFields => { - selectedFields = selectedFields.filter(field => !field.includes('__typename')); + selectedFields = selectedFields + .filter(field => !field.includes('__typename')) + // Handles "id" field for both current and included objects selectedFields = selectedFields.map(field => { if (field === 'id') { return 'objectId'; }