Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
140 changes: 140 additions & 0 deletions src/execution/__tests__/oneof-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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 {
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 });
}

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

it('accepts a good default value', () => {
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.',
},
],
});
});
});
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export {
GraphQLSkipDirective,
GraphQLDeprecatedDirective,
GraphQLSpecifiedByDirective,
GraphQLOneOfDirective,
// "Enum" of Type Kinds
TypeKind,
// Constant Deprecation Reason
Expand Down
56 changes: 56 additions & 0 deletions src/type/__tests__/introspection-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,17 @@ describe('Introspection', () => {
isDeprecated: false,
deprecationReason: null,
},
{
name: 'oneOf',
args: [],
type: {
kind: 'SCALAR',
name: 'Boolean',
ofType: null,
},
isDeprecated: false,
deprecationReason: null,
},
],
inputFields: null,
interfaces: [],
Expand Down Expand Up @@ -989,6 +1000,12 @@ describe('Introspection', () => {
},
],
},
{
name: 'oneOf',
isRepeatable: false,
locations: ['INPUT_OBJECT'],
args: [],
},
],
},
},
Expand Down Expand Up @@ -1519,6 +1536,45 @@ describe('Introspection', () => {
});
});

it('identifies oneOf for input objects', () => {
const schema = buildSchema(`
input SomeInputObject @oneOf {
a: String
}

input AnotherInputObject {
a: String
b: String
}

type Query {
someField(someArg: SomeInputObject): String
}
`);

const source = `
{
a: __type(name: "SomeInputObject") {
oneOf
}
b: __type(name: "AnotherInputObject") {
oneOf
}
}
`;

expect(graphqlSync({ schema, source })).to.deep.equal({
data: {
a: {
oneOf: true,
},
b: {
oneOf: false,
},
},
});
});

it('fails as expected on the __type root field without an arg', () => {
const schema = buildSchema(`
type Query {
Expand Down
43 changes: 42 additions & 1 deletion src/type/__tests__/validation-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ describe('Type System: A Schema must have Object root types', () => {
]);
});

it('rejects a schema extended with invalid root types', () => {
it('rejects a Schema extended with invalid root types', () => {
let schema = buildSchema(`
input SomeInputObject {
test: String
Expand Down Expand Up @@ -1663,6 +1663,47 @@ describe('Type System: Input Object fields must have input types', () => {
});
});

describe('Type System: OneOf Input Object fields must be nullable', () => {
it('rejects non-nullable fields', () => {
const schema = buildSchema(`
type Query {
test(arg: SomeInputObject): String
}

input SomeInputObject @oneOf {
a: String
b: String!
}
`);
expectJSON(validateSchema(schema)).toDeepEqual([
{
message: 'OneOf input field SomeInputObject.b must be nullable.',
locations: [{ line: 8, column: 12 }],
},
]);
});

it('rejects fields with default values', () => {
const schema = buildSchema(`
type Query {
test(arg: SomeInputObject): String
}

input SomeInputObject @oneOf {
a: String
b: String = "foo"
}
`);
expectJSON(validateSchema(schema)).toDeepEqual([
{
message:
'OneOf input field SomeInputObject.b cannot have a default value.',
locations: [{ line: 8, column: 9 }],
},
]);
});
});

describe('Objects must adhere to Interface they implement', () => {
it('accepts an Object which implements an Interface', () => {
const schema = buildSchema(`
Expand Down
4 changes: 4 additions & 0 deletions src/type/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1611,6 +1611,7 @@ export class GraphQLInputObjectType {
extensions: Readonly<GraphQLInputObjectTypeExtensions>;
astNode: Maybe<InputObjectTypeDefinitionNode>;
extensionASTNodes: ReadonlyArray<InputObjectTypeExtensionNode>;
isOneOf: boolean;

private _fields: ThunkObjMap<GraphQLInputField>;

Expand All @@ -1620,6 +1621,7 @@ export class GraphQLInputObjectType {
this.extensions = toObjMap(config.extensions);
this.astNode = config.astNode;
this.extensionASTNodes = config.extensionASTNodes ?? [];
this.isOneOf = config.isOneOf ?? false;

this._fields = defineInputFieldMap.bind(undefined, config);
}
Expand Down Expand Up @@ -1652,6 +1654,7 @@ export class GraphQLInputObjectType {
extensions: this.extensions,
astNode: this.astNode,
extensionASTNodes: this.extensionASTNodes,
isOneOf: this.isOneOf,
};
}

Expand Down Expand Up @@ -1697,6 +1700,7 @@ export interface GraphQLInputObjectTypeConfig {
extensions?: Maybe<Readonly<GraphQLInputObjectTypeExtensions>>;
astNode?: Maybe<InputObjectTypeDefinitionNode>;
extensionASTNodes?: Maybe<ReadonlyArray<InputObjectTypeExtensionNode>>;
isOneOf?: boolean;
}

interface GraphQLInputObjectTypeNormalizedConfig
Expand Down
11 changes: 11 additions & 0 deletions src/type/directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,16 @@ export const GraphQLSpecifiedByDirective: GraphQLDirective =
},
});

/**
* Used to declare an Input Object as a OneOf Input Objects.
*/
export const GraphQLOneOfDirective: GraphQLDirective = new GraphQLDirective({
name: 'oneOf',
description: 'Indicates an Input Object is a OneOf Input Object.',
locations: [DirectiveLocation.INPUT_OBJECT],
args: {},
});

/**
* The full list of specified directives.
*/
Expand All @@ -218,6 +228,7 @@ export const specifiedDirectives: ReadonlyArray<GraphQLDirective> =
GraphQLSkipDirective,
GraphQLDeprecatedDirective,
GraphQLSpecifiedByDirective,
GraphQLOneOfDirective,
]);

export function isSpecifiedDirective(directive: GraphQLDirective): boolean {
Expand Down
1 change: 1 addition & 0 deletions src/type/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export {
GraphQLSkipDirective,
GraphQLDeprecatedDirective,
GraphQLSpecifiedByDirective,
GraphQLOneOfDirective,
// Constant Deprecation Reason
DEFAULT_DEPRECATION_REASON,
} from './directives';
Expand Down
10 changes: 10 additions & 0 deletions src/type/introspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,16 @@ export const __Type: GraphQLObjectType = new GraphQLObjectType({
type: __Type,
resolve: (type) => ('ofType' in type ? type.ofType : undefined),
},
oneOf: {
type: GraphQLBoolean,
resolve: (type) => {
if (isInputObjectType(type)) {
return type.isOneOf;
}

return null;
},
},
} as GraphQLFieldConfigMap<GraphQLType, unknown>),
});

Expand Down
24 changes: 24 additions & 0 deletions src/type/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,30 @@ function validateInputFields(
[getDeprecatedDirectiveNode(field.astNode), field.astNode?.type],
);
}

if (inputObj.isOneOf) {
validateOneOfInputObjectField(inputObj, field, context);
}
}
}

function validateOneOfInputObjectField(
type: GraphQLInputObjectType,
field: GraphQLInputField,
context: SchemaValidationContext,
): void {
if (isNonNullType(field.type)) {
context.reportError(
`OneOf input field ${type.name}.${field.name} must be nullable.`,
field.astNode?.type,
);
}

if (field.defaultValue) {
context.reportError(
`OneOf input field ${type.name}.${field.name} cannot have a default value.`,
field.astNode,
);
}
}

Expand Down
Loading