Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enforce Oneof behavior at execution time #3

Open
wants to merge 3 commits into
base: oneof-validation
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 0 additions & 1 deletion cspell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ words:
- graphiql
- sublinks
- instanceof
- oneof

# Different names used inside tests
- Skywalker
Expand Down
273 changes: 273 additions & 0 deletions src/execution/__tests__/oneof-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import { describe, it } from 'mocha';

import { expectJSON } from '../../__testUtils__/expectJSON';

import { parse } from '../../language/parser';

import { buildSchema } from '../../utilities/buildASTSchema';

import type { ExecutionResult } from '../execute';
import { execute } from '../execute';

const schema = buildSchema(`
type Query {
test(input: TestInputObject!): TestObject
}

input TestInputObject @oneOf {
a: String
b: Int
}

type TestObject @oneOf {
a: String
b: Int
}

schema {
query: Query
}
`);

function executeQuery(
query: string,
rootValue: unknown,
variableValues?: { [variable: string]: unknown },
): ExecutionResult | Promise<ExecutionResult> {
return execute({ schema, document: parse(query), rootValue, variableValues });
}

async function executeQueryAsync(
query: string,
rootValue: unknown,
variableValues?: { [variable: string]: unknown },
): Promise<ExecutionResult> {
const result = await execute({
schema,
document: parse(query),
rootValue,
variableValues,
});
return result;
}

describe('Execute: Handles OneOf Input Objects and OneOf Objects', () => {
describe('OneOf Input Objects', () => {
const rootValue = {
test({ input }: { input: { a?: string; b?: number } }) {
return input;
},
};

it('accepts a good default value', () => {
Copy link

Choose a reason for hiding this comment

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

Do we also need test coverage for field argument defaults and input object input field defaults that use oneOf input objects?

EDIT: I guess the validation for those scenarios is still pending based on graphql#3049.

const query = `
query ($input: TestInputObject! = {a: "abc"}) {
test(input: $input) {
a
b
}
}
`;
const result = executeQuery(query, rootValue);

expectJSON(result).toDeepEqual({
data: {
test: {
a: 'abc',
b: null,
},
},
});
});

it('rejects a bad default value', () => {
const query = `
query ($input: TestInputObject! = {a: "abc", b: 123}) {
test(input: $input) {
a
b
}
}
`;
const result = executeQuery(query, rootValue);

expectJSON(result).toDeepEqual({
data: {
test: null,
},
errors: [
{
locations: [{ column: 23, line: 3 }],
message:
'Argument "input" of non-null type "TestInputObject!" must not be null.',
path: ['test'],
},
],
});
});

it('accepts a good variable', () => {
const query = `
query ($input: TestInputObject!) {
test(input: $input) {
a
b
}
}
`;
const result = executeQuery(query, rootValue, { input: { a: 'abc' } });

expectJSON(result).toDeepEqual({
data: {
test: {
a: 'abc',
b: null,
},
},
});
});

it('rejects a bad variable', () => {
const query = `
query ($input: TestInputObject!) {
test(input: $input) {
a
b
}
}
`;
const result = executeQuery(query, rootValue, {
input: { a: 'abc', b: 123 },
});

expectJSON(result).toDeepEqual({
errors: [
{
locations: [{ column: 16, line: 2 }],
message:
'Variable "$input" got invalid value { a: "abc", b: 123 }; Exactly one key must be specified.',
},
],
});
});
});

describe('OneOf Objects', () => {
const query = `
query ($input: TestInputObject! = {a: "abc"}) {
test(input: $input) {
a
b
}
}
`;

it('works with a single, non-null value', () => {
const rootValue = {
test: {
a: null,
b: 123,
},
};
const result = executeQuery(query, rootValue);

expectJSON(result).toDeepEqual({
data: {
test: {
a: null,
b: 123,
},
},
});
});

it('works with a single, non-null, async value', async () => {
const rootValue = {
test() {
return {
a: null,
b: () => new Promise((resolve) => resolve(123)),
};
},
};
const result = await executeQueryAsync(query, rootValue);

expectJSON(result).toDeepEqual({
data: {
test: {
a: null,
b: 123,
},
},
});
});

it('errors when there are no non-null values', () => {
const rootValue = {
test: {
a: null,
b: null,
},
};
const result = executeQuery(query, rootValue);

expectJSON(result).toDeepEqual({
data: { test: null },
errors: [
{
locations: [{ column: 11, line: 3 }],
message:
'OneOf Object "TestObject" must have exactly one non-null field but got 0.',
path: ['test'],
},
],
});
});

it('errors when there are multiple non-null values', () => {
const rootValue = {
test: {
a: 'abc',
b: 456,
},
};
const result = executeQuery(query, rootValue);

expectJSON(result).toDeepEqual({
data: { test: null },
errors: [
{
locations: [{ column: 11, line: 3 }],
message:
'OneOf Object "TestObject" must have exactly one non-null field but got 2.',
path: ['test'],
},
],
});
});

it('errors when there are multiple non-null, async values', async () => {
const rootValue = {
test() {
return {
a: 'abc',
b: () => new Promise((resolve) => resolve(123)),
};
},
};
const result = await executeQueryAsync(query, rootValue);

expectJSON(result).toDeepEqual({
data: { test: null },
errors: [
{
locations: [{ column: 11, line: 3 }],
message:
'OneOf Object "TestObject" must have exactly one non-null field but got 2.',
path: ['test'],
},
],
});
});
});
});
63 changes: 61 additions & 2 deletions src/execution/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,12 +927,13 @@ function completeObjectValue(
if (!resolvedIsTypeOf) {
throw invalidReturnTypeError(returnType, result, fieldNodes);
}
return executeFields(
return executeFieldsWithOneOfValidation(
exeContext,
returnType,
result,
path,
subFieldNodes,
fieldNodes,
);
});
}
Expand All @@ -942,7 +943,65 @@ function completeObjectValue(
}
}

return executeFields(exeContext, returnType, result, path, subFieldNodes);
return executeFieldsWithOneOfValidation(
exeContext,
returnType,
result,
path,
subFieldNodes,
fieldNodes,
);
}

function executeFieldsWithOneOfValidation(
exeContext: ExecutionContext,
parentType: GraphQLObjectType,
sourceValue: unknown,
path: Path | undefined,
fields: Map<string, ReadonlyArray<FieldNode>>,
fieldNodes: ReadonlyArray<FieldNode>,
): PromiseOrValue<ObjMap<unknown>> {
const value = executeFields(
exeContext,
parentType,
sourceValue,
path,
fields,
);
if (!parentType.isOneOf) {
return value;
}

if (isPromise(value)) {
return value.then((resolvedValue) => {
validateOneOfValue(resolvedValue, parentType, fieldNodes);
return resolvedValue;
});
}

validateOneOfValue(value, parentType, fieldNodes);
return value;
}

function validateOneOfValue(
value: ObjMap<unknown>,
returnType: GraphQLObjectType,
fieldNodes: ReadonlyArray<FieldNode>,
): void {
let nonNullCount = 0;

for (const field in value) {
if (value[field] !== null) {
nonNullCount += 1;
}
}

if (nonNullCount !== 1) {
throw new GraphQLError(
`OneOf Object "${returnType.name}" must have exactly one non-null field but got ${nonNullCount}.`,
fieldNodes,
);
}
}

function invalidReturnTypeError(
Expand Down