Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions spec/ParseQuery.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
25 changes: 24 additions & 1 deletion src/Adapters/Storage/Mongo/MongoStorageAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
28 changes: 27 additions & 1 deletion src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
});
});
}

Expand Down
8 changes: 7 additions & 1 deletion src/GraphQL/loaders/parseClassQueries.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, ''))
Comment on lines +113 to +116
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: i'm not sure to understand what is happening here, because the first replace will strip the edges.node one time, if you try to remove subsequent edges.node like

users.edges.node.aRelation.edges.node.name not sure about the impact and if developers use the "edges.node" inside a object field it can break implementation, i think there is an issue here or wrong approach

What was the issue here ?

Copy link
Author

@swittk swittk Oct 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest I'm not exactly sure; I saw the Relation column logic fix as relatively straightforward, but then when I ran the whole test suite I encountered a single test in GraphQL that was failing (ParseGraphQLServer Auto API Schema Data Types should support deep nested creation) ; did a few searches around and found that code block that stripped out the connections.
As far as I understand, the first filter originally had a single replace call only stripping the leading edges.node. segment, which produced values like aRelation.edges.node.name for nested
connection selections. Because those values still contained edges.node, the
final filter removed them entirely before they reached extractKeysAndInclude.
In other words, the original logic intentionally dropped any field that
contained a second connection layer (I'm not sure why that was the case originally, but anyway..).
The hypothesis I had was that, since I prevented the relation column from surfacing in queries when specific keys to return were specified (as we expect), the original relation fields which were originally always sent out regardless of any conditions then stopped appearing for this specific GraphQL test.

So I tried a fix replacing all explicit consecutive .edges.node (which for typical Parse fields shouldn't exist as such explicitly anyway) and that test passed and didn't think too much of it. I would be happy to know if I was correct in the assumption anyway. I must admit that I don't fully get all of the GraphQL part, but I hope I understood it enough to fix the issue.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you need to drop some console.logs on alpha branch to check how this specific GQL test behave, and what is going though the rest call using the GQL api, and then follow the same process on your PR.

You are maybe right something was wrong before, but worked, because keys feature is permissive (like you can select non existant fields)

Keep me updated :)

.filter(field => field.indexOf('edges.node') < 0)
);
const parseOrder = order && order.join(',');
Expand Down
18 changes: 12 additions & 6 deletions src/GraphQL/loaders/parseClassTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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(',');
Expand Down
4 changes: 3 additions & 1 deletion src/GraphQL/parseGraphQLUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'; }
Expand Down