Skip to content

Commit 50bb968

Browse files
committed
Validate oneOf objects at execution time
This ensures the OneOf Objects resolve to an object with exactly one non-null entry.
1 parent 15e39f5 commit 50bb968

File tree

2 files changed

+196
-4
lines changed

2 files changed

+196
-4
lines changed

src/execution/__tests__/oneof-test.ts

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,22 @@ function executeQuery(
3737
return execute({ schema, document: parse(query), rootValue, variableValues });
3838
}
3939

40-
describe('Execute: Handles Oneof Input Objects and Oneof Objects', () => {
41-
describe('Oneof Input Objects', () => {
40+
async function executeQueryAsync(
41+
query: string,
42+
rootValue: unknown,
43+
variableValues?: { [variable: string]: unknown },
44+
): Promise<ExecutionResult> {
45+
const result = await execute({
46+
schema,
47+
document: parse(query),
48+
rootValue,
49+
variableValues,
50+
});
51+
return result;
52+
}
53+
54+
describe('Execute: Handles OneOf Input Objects and OneOf Objects', () => {
55+
describe('OneOf Input Objects', () => {
4256
const rootValue = {
4357
test({ input }: { input: { a?: string; b?: number } }) {
4458
return input;
@@ -137,4 +151,123 @@ describe('Execute: Handles Oneof Input Objects and Oneof Objects', () => {
137151
});
138152
});
139153
});
154+
155+
describe('OneOf Objects', () => {
156+
const query = `
157+
query ($input: TestInputObject! = {a: "abc"}) {
158+
test(input: $input) {
159+
a
160+
b
161+
}
162+
}
163+
`;
164+
165+
it('works with a single, non-null value', () => {
166+
const rootValue = {
167+
test: {
168+
a: null,
169+
b: 123,
170+
},
171+
};
172+
const result = executeQuery(query, rootValue);
173+
174+
expectJSON(result).toDeepEqual({
175+
data: {
176+
test: {
177+
a: null,
178+
b: 123,
179+
},
180+
},
181+
});
182+
});
183+
184+
it('works with a single, non-null, async value', async () => {
185+
const rootValue = {
186+
test() {
187+
return {
188+
a: null,
189+
b: () => new Promise((resolve) => resolve(123)),
190+
};
191+
},
192+
};
193+
const result = await executeQueryAsync(query, rootValue);
194+
195+
expectJSON(result).toDeepEqual({
196+
data: {
197+
test: {
198+
a: null,
199+
b: 123,
200+
},
201+
},
202+
});
203+
});
204+
205+
it('errors when there are no non-null values', () => {
206+
const rootValue = {
207+
test: {
208+
a: null,
209+
b: null,
210+
},
211+
};
212+
const result = executeQuery(query, rootValue);
213+
214+
expectJSON(result).toDeepEqual({
215+
data: { test: null },
216+
errors: [
217+
{
218+
locations: [{ column: 11, line: 3 }],
219+
message:
220+
'OneOf Object "TestObject" must have exactly one non-null field but got 0.',
221+
path: ['test'],
222+
},
223+
],
224+
});
225+
});
226+
227+
it('errors when there are multiple non-null values', () => {
228+
const rootValue = {
229+
test: {
230+
a: 'abc',
231+
b: 456,
232+
},
233+
};
234+
const result = executeQuery(query, rootValue);
235+
236+
expectJSON(result).toDeepEqual({
237+
data: { test: null },
238+
errors: [
239+
{
240+
locations: [{ column: 11, line: 3 }],
241+
message:
242+
'OneOf Object "TestObject" must have exactly one non-null field but got 2.',
243+
path: ['test'],
244+
},
245+
],
246+
});
247+
});
248+
249+
it('errors when there are multiple non-null, async values', async () => {
250+
const rootValue = {
251+
test() {
252+
return {
253+
a: 'abc',
254+
b: () => new Promise((resolve) => resolve(123)),
255+
};
256+
},
257+
};
258+
const result = await executeQueryAsync(query, rootValue);
259+
260+
expectJSON(result).toDeepEqual({
261+
data: { test: null },
262+
errors: [
263+
{
264+
locations: [{ column: 11, line: 3 }],
265+
message:
266+
'OneOf Object "TestObject" must have exactly one non-null field but got 2.',
267+
path: ['test'],
268+
},
269+
],
270+
});
271+
});
272+
});
140273
});

src/execution/execute.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -927,12 +927,13 @@ function completeObjectValue(
927927
if (!resolvedIsTypeOf) {
928928
throw invalidReturnTypeError(returnType, result, fieldNodes);
929929
}
930-
return executeFields(
930+
return executeFieldsWithOneOfValidation(
931931
exeContext,
932932
returnType,
933933
result,
934934
path,
935935
subFieldNodes,
936+
fieldNodes,
936937
);
937938
});
938939
}
@@ -942,7 +943,65 @@ function completeObjectValue(
942943
}
943944
}
944945

945-
return executeFields(exeContext, returnType, result, path, subFieldNodes);
946+
return executeFieldsWithOneOfValidation(
947+
exeContext,
948+
returnType,
949+
result,
950+
path,
951+
subFieldNodes,
952+
fieldNodes,
953+
);
954+
}
955+
956+
function executeFieldsWithOneOfValidation(
957+
exeContext: ExecutionContext,
958+
parentType: GraphQLObjectType,
959+
sourceValue: unknown,
960+
path: Path | undefined,
961+
fields: Map<string, ReadonlyArray<FieldNode>>,
962+
fieldNodes: ReadonlyArray<FieldNode>,
963+
): PromiseOrValue<ObjMap<unknown>> {
964+
const value = executeFields(
965+
exeContext,
966+
parentType,
967+
sourceValue,
968+
path,
969+
fields,
970+
);
971+
if (!parentType.isOneOf) {
972+
return value;
973+
}
974+
975+
if (isPromise(value)) {
976+
return value.then((resolvedValue) => {
977+
validateOneOfValue(resolvedValue, parentType, fieldNodes);
978+
return resolvedValue;
979+
});
980+
}
981+
982+
validateOneOfValue(value, parentType, fieldNodes);
983+
return value;
984+
}
985+
986+
function validateOneOfValue(
987+
value: ObjMap<unknown>,
988+
returnType: GraphQLObjectType,
989+
fieldNodes: ReadonlyArray<FieldNode>,
990+
): void {
991+
let nonNullCount = 0;
992+
993+
for (const field in value) {
994+
if (value[field] !== null) {
995+
nonNullCount += 1;
996+
}
997+
}
998+
999+
if (nonNullCount !== 1) {
1000+
throw new GraphQLError(
1001+
`OneOf Object "${returnType.name}" must have exactly one non-null field but got ${nonNullCount}.`,
1002+
fieldNodes,
1003+
);
1004+
}
9461005
}
9471006

9481007
function invalidReturnTypeError(

0 commit comments

Comments
 (0)