Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/execution/__tests__/nonnull-test.ts
Original file line number Diff line number Diff line change
@@ -647,7 +647,7 @@ describe('Execute: handles non-nullable types', () => {
errors: [
{
message:
'Argument "cannotBeNull" of non-null type "String!" must not be null.',
'Argument "cannotBeNull" has invalid value: Expected value of non-null type "String!" not to be null.',
locations: [{ line: 3, column: 42 }],
path: ['withNonNullArg'],
},
@@ -677,7 +677,7 @@ describe('Execute: handles non-nullable types', () => {
errors: [
{
message:
'Argument "cannotBeNull" of required type "String!" was provided the variable "$testVar" which was not provided a runtime value.',
'Argument "cannotBeNull" has invalid value: Expected variable "$testVar" provided to type "String!" to provide a runtime value.',
locations: [{ line: 3, column: 42 }],
path: ['withNonNullArg'],
},
@@ -705,7 +705,7 @@ describe('Execute: handles non-nullable types', () => {
errors: [
{
message:
'Argument "cannotBeNull" of non-null type "String!" must not be null.',
'Argument "cannotBeNull" has invalid value: Expected variable "$testVar" provided to non-null type "String!" not to be null.',
locations: [{ line: 3, column: 43 }],
path: ['withNonNullArg'],
},
153 changes: 149 additions & 4 deletions src/execution/__tests__/oneof-test.ts
Original file line number Diff line number Diff line change
@@ -30,7 +30,12 @@ function executeQuery(
rootValue: unknown,
variableValues?: { [variable: string]: unknown },
): ExecutionResult | Promise<ExecutionResult> {
return execute({ schema, document: parse(query), rootValue, variableValues });
return execute({
schema,
document: parse(query, { experimentalFragmentArguments: true }),
rootValue,
variableValues,
});
}

describe('Execute: Handles OneOf Input Objects', () => {
@@ -83,7 +88,7 @@ describe('Execute: Handles OneOf Input Objects', () => {
message:
// This type of error would be caught at validation-time
// hence the vague error message here.
'Argument "input" of non-null type "TestInputObject!" must not be null.',
'Argument "input" has invalid value: Expected variable "$input" provided to type "TestInputObject!" to provide a runtime value.',
path: ['test'],
},
],
@@ -134,6 +139,28 @@ describe('Execute: Handles OneOf Input Objects', () => {
});
});

it('rejects a variable with a nulled key', () => {
const query = `
query ($input: TestInputObject!) {
test(input: $input) {
a
b
}
}
`;
const result = executeQuery(query, rootValue, { input: { a: null } });

expectJSON(result).toDeepEqual({
errors: [
{
message:
'Variable "$input" has invalid value: Field "a" for OneOf type "TestInputObject" must be non-null.',
locations: [{ line: 2, column: 16 }],
},
],
});
});

it('rejects a variable with multiple non-null keys', () => {
const query = `
query ($input: TestInputObject!) {
@@ -152,7 +179,7 @@ describe('Execute: Handles OneOf Input Objects', () => {
{
locations: [{ column: 16, line: 2 }],
message:
'Variable "$input" got invalid value { a: "abc", b: 123 }; Exactly one key must be specified for OneOf type "TestInputObject".',
'Variable "$input" has invalid value: Exactly one key must be specified for OneOf type "TestInputObject".',
},
],
});
@@ -176,7 +203,125 @@ describe('Execute: Handles OneOf Input Objects', () => {
{
locations: [{ column: 16, line: 2 }],
message:
'Variable "$input" got invalid value { a: "abc", b: null }; Exactly one key must be specified for OneOf type "TestInputObject".',
'Variable "$input" has invalid value: Exactly one key must be specified for OneOf type "TestInputObject".',
},
],
});
});

it('errors with nulled variable for field', () => {
const query = `
query ($a: String) {
test(input: { a: $a }) {
a
b
}
}
`;
const result = executeQuery(query, rootValue, { a: null });

expectJSON(result).toDeepEqual({
data: {
test: null,
},
errors: [
{
// A nullable variable in a oneOf field position would be caught at validation-time
// hence the vague error message here.
message:
'Argument "input" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" not to be null.',
locations: [{ line: 3, column: 23 }],
path: ['test'],
},
],
});
});

it('errors with missing variable for field', () => {
const query = `
query ($a: String) {
test(input: { a: $a }) {
a
b
}
}
`;
const result = executeQuery(query, rootValue);

expectJSON(result).toDeepEqual({
data: {
test: null,
},
errors: [
{
// A nullable variable in a oneOf field position would be caught at validation-time
// hence the vague error message here.
message:
'Argument "input" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" to provide a runtime value.',
locations: [{ line: 3, column: 23 }],
path: ['test'],
},
],
});
});

it('errors with nulled fragment variable for field', () => {
const query = `
query {
...TestFragment(a: null)
}
fragment TestFragment($a: String) on Query {
test(input: { a: $a }) {
a
b
}
}
`;
const result = executeQuery(query, rootValue, { a: null });

expectJSON(result).toDeepEqual({
data: {
test: null,
},
errors: [
{
// A nullable variable in a oneOf field position would be caught at validation-time
// hence the vague error message here.
message:
'Argument "input" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" not to be null.',
locations: [{ line: 6, column: 23 }],
path: ['test'],
},
],
});
});

it('errors with missing fragment variable for field', () => {
const query = `
query {
...TestFragment
}
fragment TestFragment($a: String) on Query {
test(input: { a: $a }) {
a
b
}
}
`;
const result = executeQuery(query, rootValue);

expectJSON(result).toDeepEqual({
data: {
test: null,
},
errors: [
{
// A nullable variable in a oneOf field position would be caught at validation-time
// hence the vague error message here.
message:
'Argument "input" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" to provide a runtime value.',
locations: [{ line: 6, column: 23 }],
path: ['test'],
},
],
});
2 changes: 1 addition & 1 deletion src/execution/__tests__/subscribe-test.ts
Original file line number Diff line number Diff line change
@@ -567,7 +567,7 @@ describe('Subscription Initialization Phase', () => {
errors: [
{
message:
'Variable "$arg" got invalid value "meow"; Int cannot represent non-integer value: "meow"',
'Variable "$arg" has invalid value: Int cannot represent non-integer value: "meow"',
locations: [{ line: 2, column: 21 }],
},
],
55 changes: 34 additions & 21 deletions src/execution/__tests__/variables-test.ts
Original file line number Diff line number Diff line change
@@ -82,6 +82,15 @@ const TestInputObject = new GraphQLInputObjectType({
},
});

const TestOneOfInputObject = new GraphQLInputObjectType({
name: 'TestOneOfInputObject',
fields: {
a: { type: GraphQLString },
b: { type: GraphQLString },
},
isOneOf: true,
});

const TestNestedInputObject = new GraphQLInputObjectType({
name: 'TestNestedInputObject',
fields: {
@@ -124,6 +133,9 @@ const TestType = new GraphQLObjectType({
type: new GraphQLNonNull(TestEnum),
}),
fieldWithObjectInput: fieldWithInputArg({ type: TestInputObject }),
fieldWithOneOfObjectInput: fieldWithInputArg({
type: TestOneOfInputObject,
}),
fieldWithNullableStringInput: fieldWithInputArg({ type: GraphQLString }),
fieldWithNonNullableStringInput: fieldWithInputArg({
type: new GraphQLNonNull(GraphQLString),
@@ -273,7 +285,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Argument "input" of type "TestInputObject" has invalid value ["foo", "bar", "baz"].',
'Argument "input" has invalid value: Expected value of type "TestInputObject" to be an object, found: ["foo", "bar", "baz"].',
path: ['fieldWithObjectInput'],
locations: [{ line: 3, column: 41 }],
},
@@ -309,9 +321,10 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Argument "input" of type "TestInputObject" has invalid value { c: "foo", e: "bar" }.',
'Argument "input" has invalid value at .e: FaultyScalarErrorMessage',
path: ['fieldWithObjectInput'],
locations: [{ line: 3, column: 41 }],
locations: [{ line: 3, column: 13 }],
extensions: { code: 'FaultyScalarErrorExtensionCode' },
},
],
});
@@ -465,7 +478,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Variable "$input" got invalid value "ExternalValue" at "input.e"; FaultyScalarErrorMessage',
'Variable "$input" has invalid value at .e: Argument "input" has invalid value at .e: FaultyScalarErrorMessage',
locations: [{ line: 2, column: 16 }],
extensions: { code: 'FaultyScalarErrorExtensionCode' },
},
@@ -481,7 +494,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Variable "$input" got invalid value null at "input.c"; Expected non-nullable type "String!" not to be null.',
'Variable "$input" has invalid value at .c: Expected value of non-null type "String!" not to be null.',
locations: [{ line: 2, column: 16 }],
},
],
@@ -495,7 +508,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Variable "$input" got invalid value "foo bar"; Expected type "TestInputObject" to be an object.',
'Variable "$input" has invalid value: Expected value of type "TestInputObject" to be an object, found: "foo bar".',
locations: [{ line: 2, column: 16 }],
},
],
@@ -509,7 +522,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Variable "$input" got invalid value { a: "foo", b: "bar" }; Field "TestInputObject.c" of required type "String!" was not provided.',
'Variable "$input" has invalid value: Expected value of type "TestInputObject" to include required field "c", found: { a: "foo", b: "bar" }.',
locations: [{ line: 2, column: 16 }],
},
],
@@ -528,12 +541,12 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Variable "$input" got invalid value { a: "foo" } at "input.na"; Field "TestInputObject.c" of required type "String!" was not provided.',
'Variable "$input" has invalid value at .na: Expected value of type "TestInputObject" to include required field "c", found: { a: "foo" }.',
locations: [{ line: 2, column: 18 }],
},
{
message:
'Variable "$input" got invalid value { na: { a: "foo" } }; Field "TestNestedInputObject.nb" of required type "String!" was not provided.',
'Variable "$input" has invalid value: Expected value of type "TestNestedInputObject" to include required field "nb", found: { na: { a: "foo" } }.',
locations: [{ line: 2, column: 18 }],
},
],
@@ -550,7 +563,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Variable "$input" got invalid value { a: "foo", b: "bar", c: "baz", extra: "dog" }; Field "extra" is not defined by type "TestInputObject".',
'Variable "$input" has invalid value: Expected value of type "TestInputObject" not to include unknown field "extra", found: { a: "foo", b: "bar", c: "baz", extra: "dog" }.',
locations: [{ line: 2, column: 16 }],
},
],
@@ -725,7 +738,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Variable "$value" of required type "String!" was not provided.',
'Variable "$value" has invalid value: Expected a value of non-null type "String!" to be provided.',
locations: [{ line: 2, column: 16 }],
},
],
@@ -744,7 +757,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Variable "$value" of non-null type "String!" must not be null.',
'Variable "$value" has invalid value: Expected value of non-null type "String!" not to be null.',
locations: [{ line: 2, column: 16 }],
},
],
@@ -810,7 +823,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Variable "$value" got invalid value [1, 2, 3]; String cannot represent a non string value: [1, 2, 3]',
'Variable "$value" has invalid value: String cannot represent a non string value: [1, 2, 3]',
locations: [{ line: 2, column: 16 }],
},
],
@@ -838,7 +851,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Argument "input" of required type "String!" was provided the variable "$foo" which was not provided a runtime value.',
'Argument "input" has invalid value: Expected variable "$foo" provided to type "String!" to provide a runtime value.',
locations: [{ line: 3, column: 50 }],
path: ['fieldWithNonNullableStringInput'],
},
@@ -893,7 +906,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Variable "$input" of non-null type "[String]!" must not be null.',
'Variable "$input" has invalid value: Expected value of non-null type "[String]!" not to be null.',
locations: [{ line: 2, column: 16 }],
},
],
@@ -956,7 +969,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Variable "$input" got invalid value null at "input[1]"; Expected non-nullable type "String!" not to be null.',
'Variable "$input" has invalid value at [1]: Expected value of non-null type "String!" not to be null.',
locations: [{ line: 2, column: 16 }],
},
],
@@ -975,7 +988,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Variable "$input" of non-null type "[String!]!" must not be null.',
'Variable "$input" has invalid value: Expected value of non-null type "[String!]!" not to be null.',
locations: [{ line: 2, column: 16 }],
},
],
@@ -1005,7 +1018,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Variable "$input" got invalid value null at "input[1]"; Expected non-nullable type "String!" not to be null.',
'Variable "$input" has invalid value at [1]: Expected value of non-null type "String!" not to be null.',
locations: [{ line: 2, column: 16 }],
},
],
@@ -1090,7 +1103,7 @@ describe('Execute: Handles inputs', () => {
errors: [
{
message:
'Argument "input" of type "String" has invalid value WRONG_TYPE.',
'Argument "input" has invalid value: String cannot represent a non string value: WRONG_TYPE',
locations: [{ line: 3, column: 48 }],
path: ['fieldWithDefaultArgumentValue'],
},
@@ -1130,7 +1143,7 @@ describe('Execute: Handles inputs', () => {

function invalidValueError(value: number, index: number) {
return {
message: `Variable "$input" got invalid value ${value} at "input[${index}]"; String cannot represent a non string value: ${value}`,
message: `Variable "$input" has invalid value at [${index}]: String cannot represent a non string value: ${value}`,
locations: [{ line: 2, column: 14 }],
};
}
@@ -1299,7 +1312,7 @@ describe('Execute: Handles inputs', () => {
expect(result).to.have.property('errors');
expect(result.errors).to.have.length(1);
expect(result.errors?.at(0)?.message).to.match(
/Argument "value" of non-null type "String!"/,
/Argument "value" has invalid value: Expected value of non-null type "String!" not to be null./,
);
});

149 changes: 64 additions & 85 deletions src/execution/values.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { inspect } from '../jsutils/inspect.js';
import { invariant } from '../jsutils/invariant.js';
import type { Maybe } from '../jsutils/Maybe.js';
import type { ObjMap, ReadOnlyObjMap } from '../jsutils/ObjMap.js';
import { printPathArray } from '../jsutils/printPathArray.js';
@@ -12,10 +12,9 @@ import type {
VariableDefinitionNode,
} from '../language/ast.js';
import { Kind } from '../language/kinds.js';
import { print } from '../language/printer.js';

import type { GraphQLArgument, GraphQLField } from '../type/definition.js';
import { isNonNullType } from '../type/definition.js';
import { isNonNullType, isRequiredArgument } from '../type/definition.js';
import type { GraphQLDirective } from '../type/directives.js';
import type { GraphQLSchema } from '../type/schema.js';

@@ -24,6 +23,10 @@ import {
coerceInputLiteral,
coerceInputValue,
} from '../utilities/coerceInputValue.js';
import {
validateInputLiteral,
validateInputValue,
} from '../utilities/validateInputValue.js';

import type { GraphQLVariableSignature } from './getVariableSignature.js';
import { getVariableSignature } from './getVariableSignature.js';
@@ -103,60 +106,41 @@ function coerceVariableValues(
}

const { name: varName, type: varType } = varSignature;
let value: unknown;
if (!Object.hasOwn(inputs, varName)) {
const defaultValue = varSignature.defaultValue;
if (defaultValue) {
sources[varName] = { signature: varSignature };
coerced[varName] = coerceDefaultValue(
defaultValue,
varType,
hideSuggestions,
);
} else if (isNonNullType(varType)) {
const varTypeStr = inspect(varType);
onError(
new GraphQLError(
`Variable "$${varName}" of required type "${varTypeStr}" was not provided.`,
{ nodes: varDefNode },
),
);
} else {
sources[varName] = { signature: varSignature };
sources[varName] = { signature: varSignature };
if (varDefNode.defaultValue) {
coerced[varName] = coerceInputLiteral(varDefNode.defaultValue, varType);
continue;
} else if (!isNonNullType(varType)) {
// Non-provided values for nullable variables are omitted.
continue;
}
continue;
} else {
value = inputs[varName];
sources[varName] = { signature: varSignature, value };
}

const value = inputs[varName];
if (value === null && isNonNullType(varType)) {
const varTypeStr = inspect(varType);
onError(
new GraphQLError(
`Variable "$${varName}" of non-null type "${varTypeStr}" must not be null.`,
{ nodes: varDefNode },
),
const coercedValue = coerceInputValue(value, varType);
if (coercedValue !== undefined) {
coerced[varName] = coercedValue;
} else {
validateInputValue(
value,
varType,
(error, path) => {
onError(
new GraphQLError(
`Variable "$${varName}" has invalid value${printPathArray(path)}: ${
error.message
}`,
{ nodes: varDefNode, originalError: error },
),
);
},
hideSuggestions,
);
continue;
}

sources[varName] = { signature: varSignature, value };
coerced[varName] = coerceInputValue(
value,
varType,
(path, invalidValue, error) => {
let prefix =
`Variable "$${varName}" got invalid value ` + inspect(invalidValue);
if (path.length > 0) {
prefix += ` at "${varName}${printPathArray(path)}"`;
}
onError(
new GraphQLError(prefix + '; ' + error.message, {
nodes: varDefNode,
originalError: error,
}),
);
},
hideSuggestions,
);
}

return { sources, coerced };
@@ -232,78 +216,73 @@ export function experimentalGetArgumentValues(
const argType = argDef.type;
const argumentNode = argNodeMap.get(name);

if (argumentNode == null) {
if (!argumentNode) {
if (isRequiredArgument(argDef)) {
// Note: ProvidedRequiredArgumentsRule validation should catch this before
// execution. This is a runtime check to ensure execution does not
// continue with an invalid argument value.
throw new GraphQLError(
`Argument "${argDef.name}" of required type "${argType}" was not provided.`,
{ nodes: node },
);
}
if (argDef.defaultValue) {
coercedValues[name] = coerceDefaultValue(
argDef.defaultValue,
argDef.type,
hideSuggestions,
);
} else if (isNonNullType(argType)) {
throw new GraphQLError(
`Argument "${name}" of required type "${inspect(argType)}" ` +
'was not provided.',
{ nodes: node },
);
}
continue;
}

const valueNode = argumentNode.value;
let isNull = valueNode.kind === Kind.NULL;

// Variables without a value are treated as if no argument was provided if
// the argument is not required.
if (valueNode.kind === Kind.VARIABLE) {
const variableName = valueNode.name.value;
const scopedVariableValues = fragmentVariableValues?.sources[variableName]
? fragmentVariableValues
: variableValues;
if (
scopedVariableValues == null ||
!Object.hasOwn(scopedVariableValues.coerced, variableName)
(scopedVariableValues == null ||
!Object.hasOwn(scopedVariableValues.coerced, variableName)) &&
!isRequiredArgument(argDef)
) {
if (argDef.defaultValue) {
coercedValues[name] = coerceDefaultValue(
argDef.defaultValue,
argDef.type,
hideSuggestions,
);
} else if (isNonNullType(argType)) {
throw new GraphQLError(
`Argument "${name}" of required type "${inspect(argType)}" ` +
`was provided the variable "$${variableName}" which was not provided a runtime value.`,
{ nodes: valueNode },
);
}
continue;
}
isNull = scopedVariableValues.coerced[variableName] == null;
}

if (isNull && isNonNullType(argType)) {
throw new GraphQLError(
`Argument "${name}" of non-null type "${inspect(argType)}" ` +
'must not be null.',
{ nodes: valueNode },
);
}

const coercedValue = coerceInputLiteral(
valueNode,
argType,
variableValues,
fragmentVariableValues,
hideSuggestions,
);
if (coercedValue === undefined) {
// Note: ValuesOfCorrectTypeRule validation should catch this before
// execution. This is a runtime check to ensure execution does not
// continue with an invalid argument value.
throw new GraphQLError(
`Argument "${name}" of type "${inspect(
argType,
)}" has invalid value ${print(valueNode)}.`,
{ nodes: valueNode },
validateInputLiteral(
valueNode,
argType,
(error, path) => {
error.message = `Argument "${argDef.name}" has invalid value${printPathArray(
path,
)}: ${error.message}`;
throw error;
},
variableValues,
fragmentVariableValues,
hideSuggestions,
);
/* c8 ignore next */
invariant(false, 'Invalid argument');
}
coercedValues[name] = coercedValue;
}
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -456,10 +456,14 @@ export {
replaceVariables,
// Create a GraphQL literal (AST) from a JavaScript input value.
valueToLiteral,
// Coerces a JavaScript value to a GraphQL type, or produces errors.
// Coerces a JavaScript value to a GraphQL type, or returns undefined.
coerceInputValue,
// Coerces a GraphQL literal (AST) to a GraphQL type, or returns undefined.
coerceInputLiteral,
// Validate a JavaScript value with a GraphQL type, collecting all errors.
validateInputValue,
// Validate a GraphQL literal (AST) with a GraphQL type, collecting all errors.
validateInputLiteral,
// Concatenates multiple AST together.
concatAST,
// Separates an AST into an AST per Operation.
11 changes: 6 additions & 5 deletions src/jsutils/printPathArray.ts
Original file line number Diff line number Diff line change
@@ -2,9 +2,10 @@
* Build a string describing the path.
*/
export function printPathArray(path: ReadonlyArray<string | number>): string {
return path
.map((key) =>
typeof key === 'number' ? '[' + key.toString() + ']' : '.' + key,
)
.join('');
if (path.length === 0) {
return '';
}
return ` at ${path
.map((key) => (typeof key === 'number' ? `[${key}]` : `.${key}`))
.join('')}`;
}
2 changes: 1 addition & 1 deletion src/type/__tests__/enumType-test.ts
Original file line number Diff line number Diff line change
@@ -306,7 +306,7 @@ describe('Type System: Enum Values', () => {
errors: [
{
message:
'Variable "$color" got invalid value 2; Enum "Color" cannot represent non-string value: 2.',
'Variable "$color" has invalid value: Enum "Color" cannot represent non-string value: 2.',
locations: [{ line: 1, column: 8 }],
},
],
5 changes: 4 additions & 1 deletion src/type/definition.ts
Original file line number Diff line number Diff line change
@@ -40,6 +40,7 @@ import type {
import { Kind } from '../language/kinds.js';
import { print } from '../language/printer.js';

import type { GraphQLVariableSignature } from '../execution/getVariableSignature.js';
import type { VariableValues } from '../execution/values.js';

import { valueFromASTUntyped } from '../utilities/valueFromASTUntyped.js';
@@ -1084,7 +1085,9 @@ export interface GraphQLArgument {
astNode: Maybe<InputValueDefinitionNode>;
}

export function isRequiredArgument(arg: GraphQLArgument): boolean {
export function isRequiredArgument(
arg: GraphQLArgument | GraphQLVariableSignature,
): boolean {
return isNonNullType(arg.type) && arg.defaultValue === undefined;
}

485 changes: 77 additions & 408 deletions src/utilities/__tests__/coerceInputValue-test.ts

Large diffs are not rendered by default.

921 changes: 921 additions & 0 deletions src/utilities/__tests__/validateInputValue-test.ts

Large diffs are not rendered by default.

254 changes: 53 additions & 201 deletions src/utilities/coerceInputValue.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
import { didYouMean } from '../jsutils/didYouMean.js';
import { inspect } from '../jsutils/inspect.js';
import { invariant } from '../jsutils/invariant.js';
import { isIterableObject } from '../jsutils/isIterableObject.js';
import { isObjectLike } from '../jsutils/isObjectLike.js';
import type { Maybe } from '../jsutils/Maybe.js';
import type { Path } from '../jsutils/Path.js';
import { addPath, pathToArray } from '../jsutils/Path.js';
import { printPathArray } from '../jsutils/printPathArray.js';
import { suggestionList } from '../jsutils/suggestionList.js';

import { GraphQLError } from '../error/GraphQLError.js';

import type { ValueNode, VariableNode } from '../language/ast.js';
import { Kind } from '../language/kinds.js';
@@ -21,7 +12,6 @@ import type {
import {
assertLeafType,
isInputObjectType,
isLeafType,
isListType,
isNonNullType,
isRequiredInputField,
@@ -31,224 +21,104 @@ import type { VariableValues } from '../execution/values.js';

import { replaceVariables } from './replaceVariables.js';

type OnErrorCB = (
path: ReadonlyArray<string | number>,
invalidValue: unknown,
error: GraphQLError,
) => void;

/**
* Coerces a JavaScript value given a GraphQL Input Type.
*
* Returns `undefined` when the value could not be validly coerced according to
* the provided type.
*/
export function coerceInputValue(
inputValue: unknown,
type: GraphQLInputType,
onError: OnErrorCB = defaultOnError,
hideSuggestions?: Maybe<boolean>,
): unknown {
return coerceInputValueImpl(
inputValue,
type,
onError,
undefined,
hideSuggestions,
);
}

function defaultOnError(
path: ReadonlyArray<string | number>,
invalidValue: unknown,
error: GraphQLError,
): void {
let errorPrefix = 'Invalid value ' + inspect(invalidValue);
if (path.length > 0) {
errorPrefix += ` at "value${printPathArray(path)}"`;
}
error.message = errorPrefix + ': ' + error.message;
throw error;
}

function coerceInputValueImpl(
inputValue: unknown,
type: GraphQLInputType,
onError: OnErrorCB,
path: Path | undefined,
hideSuggestions?: Maybe<boolean>,
): unknown {
if (isNonNullType(type)) {
if (inputValue != null) {
return coerceInputValueImpl(
inputValue,
type.ofType,
onError,
path,
hideSuggestions,
);
if (inputValue == null) {
return; // Invalid: intentionally return no value.
}
onError(
pathToArray(path),
inputValue,
new GraphQLError(
`Expected non-nullable type "${inspect(type)}" not to be null.`,
),
);
return;
return coerceInputValue(inputValue, type.ofType);
}

if (inputValue == null) {
// Explicitly return the value null.
return null;
return null; // Explicitly return the value null.
}

if (isListType(type)) {
const itemType = type.ofType;
if (isIterableObject(inputValue)) {
return Array.from(inputValue, (itemValue, index) => {
const itemPath = addPath(path, index, undefined);
return coerceInputValueImpl(
itemValue,
itemType,
onError,
itemPath,
hideSuggestions,
);
});
if (!isIterableObject(inputValue)) {
// Lists accept a non-list value as a list of one.
const coercedItem = coerceInputValue(inputValue, type.ofType);
if (coercedItem === undefined) {
return; // Invalid: intentionally return no value.
}
return [coercedItem];
}
// Lists accept a non-list value as a list of one.
return [
coerceInputValueImpl(
inputValue,
itemType,
onError,
path,
hideSuggestions,
),
];
const coercedValue = [];
for (const itemValue of inputValue) {
const coercedItem = coerceInputValue(itemValue, type.ofType);
if (coercedItem === undefined) {
return; // Invalid: intentionally return no value.
}
coercedValue.push(coercedItem);
}
return coercedValue;
}

if (isInputObjectType(type)) {
if (!isObjectLike(inputValue)) {
onError(
pathToArray(path),
inputValue,
new GraphQLError(`Expected type "${type}" to be an object.`),
);
return;
return; // Invalid: intentionally return no value.
}

const coercedValue: any = {};
const fieldDefs = type.getFields();

const hasUndefinedField = Object.keys(inputValue).some(
(name) => !Object.hasOwn(fieldDefs, name),
);
if (hasUndefinedField) {
return; // Invalid: intentionally return no value.
}
for (const field of Object.values(fieldDefs)) {
const fieldValue = inputValue[field.name];

if (fieldValue === undefined) {
if (isRequiredInputField(field)) {
return; // Invalid: intentionally return no value.
}
if (field.defaultValue) {
coercedValue[field.name] = coerceDefaultValue(
field.defaultValue,
field.type,
hideSuggestions,
);
} else if (isNonNullType(field.type)) {
const typeStr = inspect(field.type);
onError(
pathToArray(path),
inputValue,
new GraphQLError(
`Field "${type}.${field.name}" of required type "${typeStr}" was not provided.`,
),
);
}
continue;
}

coercedValue[field.name] = coerceInputValueImpl(
fieldValue,
field.type,
onError,
addPath(path, field.name, type.name),
hideSuggestions,
);
}

// Ensure every provided field is defined.
for (const fieldName of Object.keys(inputValue)) {
if (fieldDefs[fieldName] == null) {
const suggestions = suggestionList(
fieldName,
Object.keys(type.getFields()),
);
onError(
pathToArray(path),
inputValue,
new GraphQLError(
`Field "${fieldName}" is not defined by type "${type}".` +
(hideSuggestions ? '' : didYouMean(suggestions)),
),
);
} else {
const coercedField = coerceInputValue(fieldValue, field.type);
if (coercedField === undefined) {
return; // Invalid: intentionally return no value.
}
coercedValue[field.name] = coercedField;
}
}

if (type.isOneOf) {
const keys = Object.keys(coercedValue);
if (keys.length !== 1) {
onError(
pathToArray(path),
inputValue,
new GraphQLError(
`Exactly one key must be specified for OneOf type "${type}".`,
),
);
} else {
const key = keys[0];
const value = coercedValue[key];
if (value === null) {
onError(
pathToArray(path).concat(key),
value,
new GraphQLError(`Field "${key}" must be non-null.`),
);
}
return; // Invalid: intentionally return no value.
}

const key = keys[0];
const value = coercedValue[key];
if (value === null) {
return; // Invalid: intentionally return no value.
}
}

return coercedValue;
}

if (isLeafType(type)) {
let parseResult;
const leafType = assertLeafType(type);

// Scalars and Enums determine if an input value is valid via coerceInputValue(),
// which can throw to indicate failure. If it throws, maintain a reference
// to the original error.
try {
parseResult = type.coerceInputValue(inputValue, hideSuggestions);
} catch (error) {
if (error instanceof GraphQLError) {
onError(pathToArray(path), inputValue, error);
} else {
onError(
pathToArray(path),
inputValue,
new GraphQLError(`Expected type "${type}". ` + error.message, {
originalError: error,
}),
);
}
return;
}
if (parseResult === undefined) {
onError(
pathToArray(path),
inputValue,
new GraphQLError(`Expected type "${type}".`),
);
}
return parseResult;
try {
return leafType.coerceInputValue(inputValue);
} catch (_error) {
// Invalid: ignore error and intentionally return no value.
}
/* c8 ignore next 3 */
// Not reachable, all possible types have been considered.
invariant(false, 'Unexpected input type: ' + inspect(type));
}

/**
@@ -262,7 +132,6 @@ export function coerceInputLiteral(
type: GraphQLInputType,
variableValues?: Maybe<VariableValues>,
fragmentVariableValues?: Maybe<VariableValues>,
hideSuggestions?: Maybe<boolean>,
): unknown {
if (valueNode.kind === Kind.VARIABLE) {
const coercedVariableValue = getCoercedVariableValue(
@@ -287,7 +156,6 @@ export function coerceInputLiteral(
type.ofType,
variableValues,
fragmentVariableValues,
hideSuggestions,
);
}

@@ -303,7 +171,6 @@ export function coerceInputLiteral(
type.ofType,
variableValues,
fragmentVariableValues,
hideSuggestions,
);
if (itemValue === undefined) {
return; // Invalid: intentionally return no value.
@@ -317,7 +184,6 @@ export function coerceInputLiteral(
type.ofType,
variableValues,
fragmentVariableValues,
hideSuggestions,
);
if (itemValue === undefined) {
if (
@@ -374,7 +240,6 @@ export function coerceInputLiteral(
coercedValue[field.name] = coerceDefaultValue(
field.defaultValue,
field.type,
hideSuggestions,
);
}
} else {
@@ -383,7 +248,6 @@ export function coerceInputLiteral(
field.type,
variableValues,
fragmentVariableValues,
hideSuggestions,
);
if (fieldValue === undefined) {
return; // Invalid: intentionally return no value.
@@ -411,13 +275,8 @@ export function coerceInputLiteral(
return leafType.coerceInputLiteral
? leafType.coerceInputLiteral(
replaceVariables(valueNode, variableValues, fragmentVariableValues),
hideSuggestions,
)
: leafType.parseLiteral(
valueNode,
variableValues?.coerced,
hideSuggestions,
);
: leafType.parseLiteral(valueNode, variableValues?.coerced);
} catch (_error) {
// Invalid: ignore error and intentionally return no value.
}
@@ -443,19 +302,12 @@ function getCoercedVariableValue(
export function coerceDefaultValue(
defaultValue: GraphQLDefaultValueUsage,
type: GraphQLInputType,
hideSuggestions?: Maybe<boolean>,
): unknown {
// Memoize the result of coercing the default value in a hidden field.
let coercedValue = (defaultValue as any)._memoizedCoercedValue;
if (coercedValue === undefined) {
coercedValue = defaultValue.literal
? coerceInputLiteral(
defaultValue.literal,
type,
undefined,
undefined,
hideSuggestions,
)
? coerceInputLiteral(defaultValue.literal, type)
: defaultValue.value;
(defaultValue as any)._memoizedCoercedValue = coercedValue;
}
9 changes: 8 additions & 1 deletion src/utilities/index.ts
Original file line number Diff line number Diff line change
@@ -78,12 +78,19 @@ export { replaceVariables } from './replaceVariables.js';
export { valueToLiteral } from './valueToLiteral.js';

export {
// Coerces a JavaScript value to a GraphQL type, or produces errors.
// Coerces a JavaScript value to a GraphQL type, or returns undefined.
coerceInputValue,
// Coerces a GraphQL literal (AST) to a GraphQL type, or returns undefined.
coerceInputLiteral,
} from './coerceInputValue.js';

export {
// Validate a JavaScript value with a GraphQL type, collecting all errors.
validateInputValue,
// Validate a GraphQL literal (AST) with a GraphQL type, collecting all errors.
validateInputLiteral,
} from './validateInputValue.js';

// Concatenates multiple AST together.
export { concatAST } from './concatAST.js';

507 changes: 507 additions & 0 deletions src/utilities/validateInputValue.ts

Large diffs are not rendered by default.

37 changes: 17 additions & 20 deletions src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts
Original file line number Diff line number Diff line change
@@ -3,8 +3,6 @@ import { describe, it } from 'mocha';

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

import { inspect } from '../../jsutils/inspect.js';

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

import { GraphQLObjectType, GraphQLScalarType } from '../../type/definition.js';
@@ -833,7 +831,7 @@ describe('Validate: Values of correct type', () => {
}
`).toDeepEqual([
{
message: 'Expected value of type "Int!", found null.',
message: 'Expected value of non-null type "Int!" not to be null.',
locations: [{ line: 4, column: 32 }],
},
]);
@@ -947,7 +945,7 @@ describe('Validate: Values of correct type', () => {
`).toDeepEqual([
{
message:
'Field "ComplexInput.requiredField" of required type "Boolean!" was not provided.',
'Expected value of type "ComplexInput" to include required field "requiredField", found: { intField: 4 }.',
locations: [{ line: 4, column: 41 }],
},
]);
@@ -983,7 +981,7 @@ describe('Validate: Values of correct type', () => {
}
`).toDeepEqual([
{
message: 'Expected value of type "Boolean!", found null.',
message: 'Expected value of non-null type "Boolean!" not to be null.',
locations: [{ line: 6, column: 29 }],
},
]);
@@ -1002,7 +1000,7 @@ describe('Validate: Values of correct type', () => {
`).toDeepEqual([
{
message:
'Field "invalidField" is not defined by type "ComplexInput". Did you mean "intField"?',
'Expected value of type "ComplexInput" not to include unknown field "invalidField". Did you mean "intField"? Found: { requiredField: true, invalidField: "value" }.',
locations: [{ line: 6, column: 15 }],
},
]);
@@ -1024,7 +1022,7 @@ describe('Validate: Values of correct type', () => {
).toDeepEqual([
{
message:
'Field "invalidField" is not defined by type "ComplexInput".',
'Expected value of type "ComplexInput" not to include unknown field "invalidField", found: { requiredField: true, invalidField: "value" }.',
locations: [{ line: 6, column: 15 }],
},
]);
@@ -1033,10 +1031,8 @@ describe('Validate: Values of correct type', () => {
it('reports original error for custom scalar which throws', () => {
const customScalar = new GraphQLScalarType({
name: 'Invalid',
coerceInputValue(value) {
throw new Error(
`Invalid scalar is always invalid: ${inspect(value)}`,
);
coerceInputValue() {
throw new Error('Invalid scalar is always invalid.');
},
});

@@ -1058,14 +1054,14 @@ describe('Validate: Values of correct type', () => {
expectJSON(errors).toDeepEqual([
{
message:
'Expected value of type "Invalid", found 123; Invalid scalar is always invalid: 123',
'Expected value of type "Invalid", but encountered error "Invalid scalar is always invalid."; found: 123.',
locations: [{ line: 1, column: 19 }],
},
]);

expect(errors[0]).to.have.nested.property(
'originalError.message',
'Invalid scalar is always invalid: 123',
'Invalid scalar is always invalid.',
);
});

@@ -1091,7 +1087,7 @@ describe('Validate: Values of correct type', () => {

expectErrorsWithSchema(schema, '{ invalidArg(arg: 123) }').toDeepEqual([
{
message: 'Expected value of type "CustomScalar", found 123.',
message: 'Expected value of type "CustomScalar", found: 123.',
locations: [{ line: 1, column: 19 }],
},
]);
@@ -1150,7 +1146,8 @@ describe('Validate: Values of correct type', () => {
}
`).toDeepEqual([
{
message: 'Field "OneOfInput.stringField" must be non-null.',
message:
'Field "OneOfInput.stringField" used for OneOf Input Object must be non-null.',
locations: [{ line: 4, column: 37 }],
},
]);
@@ -1244,15 +1241,15 @@ describe('Validate: Values of correct type', () => {
}
`).toDeepEqual([
{
message: 'Expected value of type "Int!", found null.',
message: 'Expected value of non-null type "Int!" not to be null.',
locations: [{ line: 3, column: 22 }],
},
{
message: 'Expected value of type "String!", found null.',
message: 'Expected value of non-null type "String!" not to be null.',
locations: [{ line: 4, column: 25 }],
},
{
message: 'Expected value of type "Boolean!", found null.',
message: 'Expected value of non-null type "Boolean!" not to be null.',
locations: [{ line: 5, column: 47 }],
},
]);
@@ -1278,7 +1275,7 @@ describe('Validate: Values of correct type', () => {
},
{
message:
'Expected value of type "ComplexInput", found "NotVeryComplex".',
'Expected value of type "ComplexInput" to be an object, found: "NotVeryComplex".',
locations: [{ line: 5, column: 30 }],
},
]);
@@ -1311,7 +1308,7 @@ describe('Validate: Values of correct type', () => {
`).toDeepEqual([
{
message:
'Field "ComplexInput.requiredField" of required type "Boolean!" was not provided.',
'Expected value of type "ComplexInput" to include required field "requiredField", found: { intField: 3 }.',
locations: [{ line: 2, column: 55 }],
},
]);
203 changes: 34 additions & 169 deletions src/validation/rules/ValuesOfCorrectTypeRule.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,11 @@
import { didYouMean } from '../../jsutils/didYouMean.js';
import { inspect } from '../../jsutils/inspect.js';
import { suggestionList } from '../../jsutils/suggestionList.js';
import type { Maybe } from '../../jsutils/Maybe.js';

import { GraphQLError } from '../../error/GraphQLError.js';

import type {
ObjectFieldNode,
ObjectValueNode,
ValueNode,
} from '../../language/ast.js';
import { Kind } from '../../language/kinds.js';
import { print } from '../../language/printer.js';
import type { ValueNode } from '../../language/ast.js';
import type { ASTVisitor } from '../../language/visitor.js';

import type { GraphQLInputObjectType } from '../../type/definition.js';
import {
getNamedType,
getNullableType,
isInputObjectType,
isLeafType,
isListType,
isNonNullType,
isRequiredInputField,
} from '../../type/definition.js';
import type { GraphQLInputType } from '../../type/index.js';

import { replaceVariables } from '../../utilities/replaceVariables.js';
import { validateInputLiteral } from '../../utilities/validateInputValue.js';

import type { ValidationContext } from '../ValidationContext.js';

@@ -40,162 +21,46 @@ export function ValuesOfCorrectTypeRule(
context: ValidationContext,
): ASTVisitor {
return {
ListValue(node) {
NullValue: (node) =>
isValidValueNode(context, node, context.getInputType()),
ListValue: (node) =>
// Note: TypeInfo will traverse into a list's item type, so look to the
// parent input type to check if it is a list.
const type = getNullableType(context.getParentInputType());
if (!isListType(type)) {
isValidValueNode(context, node);
return false; // Don't traverse further.
}
},
ObjectValue(node) {
const type = getNamedType(context.getInputType());
if (!isInputObjectType(type)) {
isValidValueNode(context, node);
return false; // Don't traverse further.
}
// Ensure every required field exists.
const fieldNodeMap = new Map(
node.fields.map((field) => [field.name.value, field]),
);
for (const fieldDef of Object.values(type.getFields())) {
const fieldNode = fieldNodeMap.get(fieldDef.name);
if (!fieldNode && isRequiredInputField(fieldDef)) {
const typeStr = inspect(fieldDef.type);
context.reportError(
new GraphQLError(
`Field "${type}.${fieldDef.name}" of required type "${typeStr}" was not provided.`,
{ nodes: node },
),
);
}
}

if (type.isOneOf) {
validateOneOfInputObject(context, node, type, fieldNodeMap);
}
},
ObjectField(node) {
const parentType = getNamedType(context.getParentInputType());
const fieldType = context.getInputType();
if (!fieldType && isInputObjectType(parentType)) {
const suggestions = context.hideSuggestions
? []
: suggestionList(
node.name.value,
Object.keys(parentType.getFields()),
);
context.reportError(
new GraphQLError(
`Field "${node.name.value}" is not defined by type "${parentType}".` +
didYouMean(suggestions),
{ nodes: node },
),
);
}
},
NullValue(node) {
const type = context.getInputType();
if (isNonNullType(type)) {
context.reportError(
new GraphQLError(
`Expected value of type "${inspect(type)}", found ${print(node)}.`,
{ nodes: node },
),
);
}
},
EnumValue: (node) => isValidValueNode(context, node),
IntValue: (node) => isValidValueNode(context, node),
FloatValue: (node) => isValidValueNode(context, node),
StringValue: (node) => isValidValueNode(context, node),
BooleanValue: (node) => isValidValueNode(context, node),
isValidValueNode(context, node, context.getParentInputType()),
ObjectValue: (node) =>
isValidValueNode(context, node, context.getInputType()),
EnumValue: (node) =>
isValidValueNode(context, node, context.getInputType()),
IntValue: (node) => isValidValueNode(context, node, context.getInputType()),
FloatValue: (node) =>
isValidValueNode(context, node, context.getInputType()),
StringValue: (node) =>
isValidValueNode(context, node, context.getInputType()),
BooleanValue: (node) =>
isValidValueNode(context, node, context.getInputType()),
};
}

/**
* Any value literal may be a valid representation of a Scalar, depending on
* that scalar type.
*/
function isValidValueNode(context: ValidationContext, node: ValueNode): void {
// Report any error at the full type expected by the location.
const locationType = context.getInputType();
if (!locationType) {
return;
}

const type = getNamedType(locationType);

if (!isLeafType(type)) {
const typeStr = inspect(locationType);
context.reportError(
new GraphQLError(
`Expected value of type "${typeStr}", found ${print(node)}.`,
{ nodes: node },
),
);
return;
}

// Scalars and Enums determine if a literal value is valid via coerceInputLiteral(),
// which may throw or return undefined to indicate an invalid value.
try {
const parseResult = type.coerceInputLiteral
? type.coerceInputLiteral(replaceVariables(node), context.hideSuggestions)
: type.parseLiteral(node, undefined, context.hideSuggestions);
if (parseResult === undefined) {
const typeStr = inspect(locationType);
context.reportError(
new GraphQLError(
`Expected value of type "${typeStr}", found ${print(node)}.`,
{ nodes: node },
),
);
}
} catch (error) {
const typeStr = inspect(locationType);
if (error instanceof GraphQLError) {
context.reportError(error);
} else {
context.reportError(
new GraphQLError(
`Expected value of type "${typeStr}", found ${print(node)}; ` +
error.message,
{ nodes: node, originalError: error },
),
);
}
}
}

function validateOneOfInputObject(
function isValidValueNode(
context: ValidationContext,
node: ObjectValueNode,
type: GraphQLInputObjectType,
fieldNodeMap: Map<string, ObjectFieldNode>,
): void {
const keys = Array.from(fieldNodeMap.keys());
const isNotExactlyOneField = keys.length !== 1;

if (isNotExactlyOneField) {
context.reportError(
new GraphQLError(
`OneOf Input Object "${type}" must specify exactly one key.`,
{ nodes: [node] },
),
);
return;
}

const value = fieldNodeMap.get(keys[0])?.value;
const isNullLiteral = !value || value.kind === Kind.NULL;

if (isNullLiteral) {
context.reportError(
new GraphQLError(`Field "${type}.${keys[0]}" must be non-null.`, {
nodes: [node],
}),
node: ValueNode,
inputType: Maybe<GraphQLInputType>,
): false {
if (inputType) {
validateInputLiteral(
node,
inputType,
(error) => {
context.reportError(error);
},
undefined,
undefined,
context.hideSuggestions,
);
}
return false;
}