Skip to content

Commit 626fad2

Browse files
authored
fix: setting a field to null does not delete it via GraphQL API (#7649)
BREAKING CHANGE: To delete a field via the GraphQL API, the field value has to be set to `null`. Previously, setting a field value to `null` would save a null value in the database, which was not according to the [GraphQL specs](https://spec.graphql.org/June2018/#sec-Null-Value). To delete a file field use `file: null`, the previous way of using `file: { file: null }` has become obsolete.
1 parent 4c29d4d commit 626fad2

File tree

5 files changed

+193
-14
lines changed

5 files changed

+193
-14
lines changed

CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ ___
101101
# [Unreleased (master branch)](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.1...master)
102102

103103
## Breaking Changes
104-
- (none)
104+
- feat: `null` value on field during graphql mutation now unset the value from the database, file unset changed
105105
## Features
106106
- (none)
107107
## Bug Fixes

spec/ParseGraphQLServer.spec.js

+157-1
Original file line numberDiff line numberDiff line change
@@ -6606,6 +6606,162 @@ describe('ParseGraphQLServer', () => {
66066606
);
66076607
});
66086608
});
6609+
6610+
it('should unset fields when null used on update/create', async () => {
6611+
const customerSchema = new Parse.Schema('Customer');
6612+
customerSchema.addString('aString');
6613+
customerSchema.addBoolean('aBoolean');
6614+
customerSchema.addDate('aDate');
6615+
customerSchema.addArray('aArray');
6616+
customerSchema.addGeoPoint('aGeoPoint');
6617+
customerSchema.addPointer('aPointer', 'Customer');
6618+
customerSchema.addObject('aObject');
6619+
customerSchema.addPolygon('aPolygon');
6620+
await customerSchema.save();
6621+
6622+
await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear();
6623+
6624+
const cus = new Parse.Object('Customer');
6625+
await cus.save({ aString: 'hello' });
6626+
6627+
const fields = {
6628+
aString: "i'm string",
6629+
aBoolean: true,
6630+
aDate: new Date().toISOString(),
6631+
aArray: ['hello', 1],
6632+
aGeoPoint: { latitude: 30, longitude: 30 },
6633+
aPointer: { link: cus.id },
6634+
aObject: { prop: { subprop: 1 }, prop2: 'test' },
6635+
aPolygon: [
6636+
{ latitude: 30, longitude: 30 },
6637+
{ latitude: 31, longitude: 31 },
6638+
{ latitude: 32, longitude: 32 },
6639+
{ latitude: 30, longitude: 30 },
6640+
],
6641+
};
6642+
const nullFields = Object.keys(fields).reduce((acc, k) => ({ ...acc, [k]: null }), {});
6643+
const result = await apolloClient.mutate({
6644+
mutation: gql`
6645+
mutation CreateCustomer($input: CreateCustomerInput!) {
6646+
createCustomer(input: $input) {
6647+
customer {
6648+
id
6649+
aString
6650+
aBoolean
6651+
aDate
6652+
aArray {
6653+
... on Element {
6654+
value
6655+
}
6656+
}
6657+
aGeoPoint {
6658+
longitude
6659+
latitude
6660+
}
6661+
aPointer {
6662+
objectId
6663+
}
6664+
aObject
6665+
aPolygon {
6666+
longitude
6667+
latitude
6668+
}
6669+
}
6670+
}
6671+
}
6672+
`,
6673+
variables: {
6674+
input: { fields },
6675+
},
6676+
});
6677+
const {
6678+
data: {
6679+
createCustomer: {
6680+
customer: { aPointer, aArray, id, ...otherFields },
6681+
},
6682+
},
6683+
} = result;
6684+
expect(id).toBeDefined();
6685+
delete otherFields.__typename;
6686+
delete otherFields.aGeoPoint.__typename;
6687+
otherFields.aPolygon.forEach(v => {
6688+
delete v.__typename;
6689+
});
6690+
expect({
6691+
...otherFields,
6692+
aPointer: { link: aPointer.objectId },
6693+
aArray: aArray.map(({ value }) => value),
6694+
}).toEqual(fields);
6695+
6696+
const updated = await apolloClient.mutate({
6697+
mutation: gql`
6698+
mutation UpdateCustomer($input: UpdateCustomerInput!) {
6699+
updateCustomer(input: $input) {
6700+
customer {
6701+
aString
6702+
aBoolean
6703+
aDate
6704+
aArray {
6705+
... on Element {
6706+
value
6707+
}
6708+
}
6709+
aGeoPoint {
6710+
longitude
6711+
latitude
6712+
}
6713+
aPointer {
6714+
objectId
6715+
}
6716+
aObject
6717+
aPolygon {
6718+
longitude
6719+
latitude
6720+
}
6721+
}
6722+
}
6723+
}
6724+
`,
6725+
variables: {
6726+
input: { fields: nullFields, id },
6727+
},
6728+
});
6729+
const {
6730+
data: {
6731+
updateCustomer: { customer },
6732+
},
6733+
} = updated;
6734+
delete customer.__typename;
6735+
expect(Object.keys(customer).length).toEqual(8);
6736+
Object.keys(customer).forEach(k => {
6737+
expect(customer[k]).toBeNull();
6738+
});
6739+
try {
6740+
const queryResult = await apolloClient.query({
6741+
query: gql`
6742+
query getEmptyCustomer($where: CustomerWhereInput!) {
6743+
customers(where: $where) {
6744+
edges {
6745+
node {
6746+
id
6747+
}
6748+
}
6749+
}
6750+
}
6751+
`,
6752+
variables: {
6753+
where: Object.keys(fields).reduce(
6754+
(acc, k) => ({ ...acc, [k]: { exists: false } }),
6755+
{}
6756+
),
6757+
},
6758+
});
6759+
6760+
expect(queryResult.data.customers.edges.length).toEqual(1);
6761+
} catch (e) {
6762+
console.log(JSON.stringify(e));
6763+
}
6764+
});
66096765
});
66106766

66116767
describe('Files Mutations', () => {
@@ -9141,7 +9297,7 @@ describe('ParseGraphQLServer', () => {
91419297
const mutationResult = await apolloClient.mutate({
91429298
mutation: gql`
91439299
mutation UnlinkFile($id: ID!) {
9144-
updateSomeClass(input: { id: $id, fields: { someField: { file: null } } }) {
9300+
updateSomeClass(input: { id: $id, fields: { someField: null } }) {
91459301
someClass {
91469302
someField {
91479303
name

src/GraphQL/loaders/defaultGraphQLTypes.js

+3-7
Original file line numberDiff line numberDiff line change
@@ -357,21 +357,17 @@ const FILE_INFO = new GraphQLObjectType({
357357

358358
const FILE_INPUT = new GraphQLInputObjectType({
359359
name: 'FileInput',
360+
description:
361+
'If this field is set to null the file will be unlinked (the file will not be deleted on cloud storage).',
360362
fields: {
361363
file: {
362-
description:
363-
'A File Scalar can be an url or a FileInfo object. If this field is set to null the file will be unlinked.',
364+
description: 'A File Scalar can be an url or a FileInfo object.',
364365
type: FILE,
365366
},
366367
upload: {
367368
description: 'Use this field if you want to create a new file.',
368369
type: GraphQLUpload,
369370
},
370-
unlink: {
371-
description:
372-
'Use this field if you want to unlink the file (the file will not be deleted on cloud storage)',
373-
type: GraphQLBoolean,
374-
},
375371
},
376372
});
377373

src/GraphQL/loaders/parseClassMutations.js

+10-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLControlle
1010
import { transformClassNameToGraphQL } from '../transformers/className';
1111
import { transformTypes } from '../transformers/mutation';
1212

13+
const filterDeletedFields = fields =>
14+
Object.keys(fields).reduce((acc, key) => {
15+
if (typeof fields[key] === 'object' && fields[key]?.__op === 'Delete') {
16+
acc[key] = null;
17+
}
18+
return acc;
19+
}, fields);
20+
1321
const getOnlyRequiredFields = (
1422
updatedFields,
1523
selectedFieldsString,
@@ -131,7 +139,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
131139
[getGraphQLQueryName]: {
132140
...createdObject,
133141
updatedAt: createdObject.createdAt,
134-
...parseFields,
142+
...filterDeletedFields(parseFields),
135143
...optimizedObject,
136144
},
137145
};
@@ -240,7 +248,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
240248
[getGraphQLQueryName]: {
241249
objectId: id,
242250
...updatedObject,
243-
...parseFields,
251+
...filterDeletedFields(parseFields),
244252
...optimizedObject,
245253
},
246254
};

src/GraphQL/transformers/mutation.js

+22-3
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,17 @@ const transformTypes = async (
3030
if (inputTypeField) {
3131
switch (true) {
3232
case inputTypeField.type === defaultGraphQLTypes.GEO_POINT_INPUT:
33+
if (fields[field] === null) {
34+
fields[field] = { __op: 'Delete' };
35+
break;
36+
}
3337
fields[field] = transformers.geoPoint(fields[field]);
3438
break;
3539
case inputTypeField.type === defaultGraphQLTypes.POLYGON_INPUT:
40+
if (fields[field] === null) {
41+
fields[field] = { __op: 'Delete' };
42+
break;
43+
}
3644
fields[field] = transformers.polygon(fields[field]);
3745
break;
3846
case inputTypeField.type === defaultGraphQLTypes.FILE_INPUT:
@@ -48,6 +56,10 @@ const transformTypes = async (
4856
);
4957
break;
5058
case parseClass.fields[field].type === 'Pointer':
59+
if (fields[field] === null) {
60+
fields[field] = { __op: 'Delete' };
61+
break;
62+
}
5163
fields[field] = await transformers.pointer(
5264
parseClass.fields[field].targetClass,
5365
field,
@@ -56,6 +68,12 @@ const transformTypes = async (
5668
req
5769
);
5870
break;
71+
default:
72+
if (fields[field] === null) {
73+
fields[field] = { __op: 'Delete' };
74+
return;
75+
}
76+
break;
5977
}
6078
}
6179
});
@@ -66,10 +84,11 @@ const transformTypes = async (
6684
};
6785

6886
const transformers = {
69-
file: async ({ file, upload }, { config }) => {
70-
if (file === null && !upload) {
71-
return null;
87+
file: async (input, { config }) => {
88+
if (input === null) {
89+
return { __op: 'Delete' };
7290
}
91+
const { file, upload } = input;
7392
if (upload) {
7493
const { fileInfo } = await handleUpload(upload, config);
7594
return { ...fileInfo, __type: 'File' };

0 commit comments

Comments
 (0)