diff --git a/src/execution/__tests__/nonnull-test.ts b/src/execution/__tests__/nonnull-test.ts index 223c552c33..dff67abd63 100644 --- a/src/execution/__tests__/nonnull-test.ts +++ b/src/execution/__tests__/nonnull-test.ts @@ -616,7 +616,7 @@ describe('Execute: handles non-nullable types', () => { errors: [ { message: - 'Argument "cannotBeNull" of required type "String!" was not provided.', + 'Argument Query.withNonNullArg(cannotBeNull:) of required type String! was not provided.', locations: [{ line: 3, column: 13 }], path: ['withNonNullArg'], }, @@ -643,7 +643,7 @@ describe('Execute: handles non-nullable types', () => { errors: [ { message: - 'Argument "cannotBeNull" of non-null type "String!" must not be null.', + 'Argument Query.withNonNullArg(cannotBeNull:) of non-null type String! must not be null.', locations: [{ line: 3, column: 42 }], path: ['withNonNullArg'], }, @@ -673,7 +673,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 Query.withNonNullArg(cannotBeNull:) of required type String! was provided the variable "$testVar" which was not provided a runtime value.', locations: [{ line: 3, column: 42 }], path: ['withNonNullArg'], }, @@ -701,7 +701,7 @@ describe('Execute: handles non-nullable types', () => { errors: [ { message: - 'Argument "cannotBeNull" of non-null type "String!" must not be null.', + 'Argument Query.withNonNullArg(cannotBeNull:) of non-null type String! must not be null.', locations: [{ line: 3, column: 43 }], path: ['withNonNullArg'], }, diff --git a/src/execution/__tests__/variables-test.ts b/src/execution/__tests__/variables-test.ts index ebf7d0b169..27404ea5a2 100644 --- a/src/execution/__tests__/variables-test.ts +++ b/src/execution/__tests__/variables-test.ts @@ -203,7 +203,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Argument "input" has invalid value ["foo", "bar", "baz"].', + 'Argument TestType.fieldWithObjectInput(input:) of type TestInputObject has invalid value ["foo", "bar", "baz"].', path: ['fieldWithObjectInput'], locations: [{ line: 3, column: 41 }], }, @@ -617,7 +617,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$value" of required type "String!" was not provided.', + 'Variable "$value" of required type String! was not provided.', locations: [{ line: 2, column: 16 }], }, ], @@ -636,7 +636,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$value" of non-null type "String!" must not be null.', + 'Variable "$value" of non-null type String! must not be null.', locations: [{ line: 2, column: 16 }], }, ], @@ -682,7 +682,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Argument "input" of required type "String!" was not provided.', + 'Argument TestType.fieldWithNonNullableStringInput(input:) of required type String! was not provided.', locations: [{ line: 1, column: 3 }], path: ['fieldWithNonNullableStringInput'], }, @@ -730,7 +730,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 TestType.fieldWithNonNullableStringInput(input:) of required type String! was provided the variable "$foo" which was not provided a runtime value.', locations: [{ line: 3, column: 50 }], path: ['fieldWithNonNullableStringInput'], }, @@ -785,7 +785,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" of non-null type "[String]!" must not be null.', + 'Variable "$input" of non-null type [String]! must not be null.', locations: [{ line: 2, column: 16 }], }, ], @@ -867,7 +867,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" of non-null type "[String!]!" must not be null.', + 'Variable "$input" of non-null type [String!]! must not be null.', locations: [{ line: 2, column: 16 }], }, ], @@ -916,7 +916,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" expected value of type "TestType!" which cannot be used as an input type.', + 'Variable "$input" expected value of type TestType! which cannot be used as an input type.', locations: [{ line: 2, column: 24 }], }, ], @@ -935,7 +935,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" expected value of type "UnknownType!" which cannot be used as an input type.', + 'Variable "$input" expected value of type UnknownType! which cannot be used as an input type.', locations: [{ line: 2, column: 24 }], }, ], @@ -981,7 +981,8 @@ describe('Execute: Handles inputs', () => { }, errors: [ { - message: 'Argument "input" has invalid value WRONG_TYPE.', + message: + 'Argument TestType.fieldWithDefaultArgumentValue(input:) of type String has invalid value WRONG_TYPE.', locations: [{ line: 3, column: 48 }], path: ['fieldWithDefaultArgumentValue'], }, diff --git a/src/execution/values.ts b/src/execution/values.ts index 6d4c95b85a..346abc38e9 100644 --- a/src/execution/values.ts +++ b/src/execution/values.ts @@ -20,8 +20,10 @@ import type { GraphQLDirective } from '../type/directives'; import { isInputType, isNonNullType } from '../type/definition'; import { typeFromAST } from '../utilities/typeFromAST'; -import { valueFromAST } from '../utilities/valueFromAST'; -import { coerceInputValue } from '../utilities/coerceInputValue'; +import { + coerceInputValue, + coerceInputLiteral, +} from '../utilities/coerceInputValue'; type CoercedVariableValues = | { errors: ReadonlyArray; coerced?: never } @@ -87,7 +89,7 @@ function coerceVariableValues( const varTypeStr = print(varDefNode.type); onError( new GraphQLError( - `Variable "$${varName}" expected value of type "${varTypeStr}" which cannot be used as an input type.`, + `Variable "$${varName}" expected value of type ${varTypeStr} which cannot be used as an input type.`, varDefNode.type, ), ); @@ -96,12 +98,14 @@ function coerceVariableValues( if (!hasOwnProperty(inputs, varName)) { if (varDefNode.defaultValue) { - coercedValues[varName] = valueFromAST(varDefNode.defaultValue, varType); + coercedValues[varName] = coerceInputLiteral( + varDefNode.defaultValue, + varType, + ); } else if (isNonNullType(varType)) { - const varTypeStr = inspect(varType); onError( new GraphQLError( - `Variable "$${varName}" of required type "${varTypeStr}" was not provided.`, + `Variable "$${varName}" of required type ${varType} was not provided.`, varDefNode, ), ); @@ -111,10 +115,9 @@ function coerceVariableValues( 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.`, + `Variable "$${varName}" of non-null type ${varType} must not be null.`, varDefNode, ), ); @@ -178,8 +181,7 @@ export function getArgumentValues( coercedValues[name] = argDef.defaultValue; } else if (isNonNullType(argType)) { throw new GraphQLError( - `Argument "${name}" of required type "${inspect(argType)}" ` + - 'was not provided.', + `Argument ${argDef} of required type ${argType} was not provided.`, node, ); } @@ -199,7 +201,7 @@ export function getArgumentValues( coercedValues[name] = argDef.defaultValue; } else if (isNonNullType(argType)) { throw new GraphQLError( - `Argument "${name}" of required type "${inspect(argType)}" ` + + `Argument ${argDef} of required type ${argType} ` + `was provided the variable "$${variableName}" which was not provided a runtime value.`, valueNode, ); @@ -211,19 +213,20 @@ export function getArgumentValues( if (isNull && isNonNullType(argType)) { throw new GraphQLError( - `Argument "${name}" of non-null type "${inspect(argType)}" ` + - 'must not be null.', + `Argument ${argDef} of non-null type ${argType} must not be null.`, valueNode, ); } - const coercedValue = valueFromAST(valueNode, argType, variableValues); + const coercedValue = coerceInputLiteral(valueNode, argType, variableValues); 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}" has invalid value ${print(valueNode)}.`, + `Argument ${argDef} of type ${argType} has invalid value ${print( + valueNode, + )}.`, valueNode, ); } diff --git a/src/index.ts b/src/index.ts index d9d02c9245..183d249a89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,14 +32,17 @@ export { graphql, graphqlSync } from './graphql'; /** Create and operate on GraphQL type definitions and schema. */ export { /** Definitions */ - GraphQLSchema, - GraphQLDirective, + GraphQLSchemaElement, GraphQLScalarType, GraphQLObjectType, GraphQLInterfaceType, GraphQLUnionType, GraphQLEnumType, GraphQLInputObjectType, + GraphQLField, + GraphQLArgument, + GraphQLEnumValue, + GraphQLInputField, GraphQLList, GraphQLNonNull, /** Standard GraphQL Scalars */ @@ -144,23 +147,19 @@ export type { GraphQLSchemaExtensions, GraphQLDirectiveConfig, GraphQLDirectiveExtensions, - GraphQLArgument, GraphQLArgumentConfig, GraphQLArgumentExtensions, GraphQLEnumTypeConfig, GraphQLEnumTypeExtensions, - GraphQLEnumValue, GraphQLEnumValueConfig, GraphQLEnumValueConfigMap, GraphQLEnumValueExtensions, - GraphQLField, GraphQLFieldConfig, GraphQLFieldConfigArgumentMap, GraphQLFieldConfigMap, GraphQLFieldExtensions, GraphQLFieldMap, GraphQLFieldResolver, - GraphQLInputField, GraphQLInputFieldConfig, GraphQLInputFieldConfigMap, GraphQLInputFieldExtensions, @@ -201,6 +200,7 @@ export { parseValue, parseConstValue, parseType, + parseSchemaCoordinate, /** Print */ print, /** Visit */ @@ -221,6 +221,7 @@ export { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from './language/index'; export type { @@ -295,6 +296,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './language/index'; /** Execute GraphQL queries. */ @@ -406,8 +408,6 @@ export { printIntrospectionSchema, /** Create a GraphQLType from a GraphQL language AST. */ typeFromAST, - /** Create a JavaScript value from a GraphQL language AST with a Type. */ - valueFromAST, /** Create a JavaScript value from a GraphQL language AST without a Type. */ valueFromASTUntyped, /** Create a GraphQL language AST from a JavaScript value. */ @@ -417,6 +417,8 @@ export { visitWithTypeInfo, /** Coerces a JavaScript value to a GraphQL type, or produces errors. */ coerceInputValue, + /** Coerces a GraphQL literal (AST) to a GraphQL type, or returns undefined. */ + coerceInputLiteral, /** Concatenates multiple AST together. */ concatAST, /** Separates an AST into an AST per Operation. */ @@ -436,6 +438,8 @@ export { DangerousChangeType, findBreakingChanges, findDangerousChanges, + resolveSchemaCoordinate, + resolveASTSchemaCoordinate, } from './utilities/index'; export type { @@ -465,4 +469,5 @@ export type { BreakingChange, DangerousChange, TypedQueryDocumentNode, + ResolvedSchemaElement, } from './utilities/index'; diff --git a/src/jsutils/hasOwnProperty.ts b/src/jsutils/hasOwnProperty.ts new file mode 100644 index 0000000000..1ae8870631 --- /dev/null +++ b/src/jsutils/hasOwnProperty.ts @@ -0,0 +1,6 @@ +/** + * Determines if a provided object has a given property name. + */ +export function hasOwnProperty(obj: {}, prop: string): boolean { + return Object.prototype.hasOwnProperty.call(obj, prop); +} diff --git a/src/language/__tests__/lexer-test.ts b/src/language/__tests__/lexer-test.ts index 591b38d456..18d7da98f9 100644 --- a/src/language/__tests__/lexer-test.ts +++ b/src/language/__tests__/lexer-test.ts @@ -30,12 +30,12 @@ function expectSyntaxError(text: string) { describe('Lexer', () => { it('disallows uncommon control characters', () => { expectSyntaxError('\u0007').to.deep.equal({ - message: 'Syntax Error: Cannot contain the invalid character "\\u0007".', + message: 'Syntax Error: Invalid character: U+0007.', locations: [{ line: 1, column: 1 }], }); }); - it('accepts BOM header', () => { + it('ignores BOM header', () => { expect(lexOne('\uFEFF foo')).to.contain({ kind: TokenKind.NAME, start: 2, @@ -139,6 +139,13 @@ describe('Lexer', () => { value: 'foo', }); + expect(lexOne('\t\tfoo\t\t')).to.contain({ + kind: TokenKind.NAME, + start: 2, + end: 5, + value: 'foo', + }); + expect( lexOne(` #comment @@ -167,7 +174,7 @@ describe('Lexer', () => { caughtError = error; } expect(String(caughtError)).to.equal(dedent` - Syntax Error: Cannot parse the unexpected character "?". + Syntax Error: Unexpected character: "?". GraphQL request:3:5 2 | @@ -187,7 +194,7 @@ describe('Lexer', () => { caughtError = error; } expect(String(caughtError)).to.equal(dedent` - Syntax Error: Cannot parse the unexpected character "?". + Syntax Error: Unexpected character: "?". foo.js:13:6 12 | @@ -206,7 +213,7 @@ describe('Lexer', () => { caughtError = error; } expect(String(caughtError)).to.equal(dedent` - Syntax Error: Cannot parse the unexpected character "?". + Syntax Error: Unexpected character: "?". foo.js:1:5 1 | ? @@ -294,13 +301,13 @@ describe('Lexer', () => { expectSyntaxError('"contains unescaped \u0007 control char"').to.deep.equal( { - message: 'Syntax Error: Invalid character within String: "\\u0007".', + message: 'Syntax Error: Invalid character within String: U+0007.', locations: [{ line: 1, column: 21 }], }, ); expectSyntaxError('"null-byte is not \u0000 end of file"').to.deep.equal({ - message: 'Syntax Error: Invalid character within String: "\\u0000".', + message: 'Syntax Error: Invalid character within String: U+0000.', locations: [{ line: 1, column: 19 }], }); @@ -315,38 +322,38 @@ describe('Lexer', () => { }); expectSyntaxError('"bad \\z esc"').to.deep.equal({ - message: 'Syntax Error: Invalid character escape sequence: \\z.', - locations: [{ line: 1, column: 7 }], + message: 'Syntax Error: Invalid character escape sequence: "\\z".', + locations: [{ line: 1, column: 6 }], }); expectSyntaxError('"bad \\x esc"').to.deep.equal({ - message: 'Syntax Error: Invalid character escape sequence: \\x.', - locations: [{ line: 1, column: 7 }], + message: 'Syntax Error: Invalid character escape sequence: "\\x".', + locations: [{ line: 1, column: 6 }], }); expectSyntaxError('"bad \\u1 esc"').to.deep.equal({ - message: 'Syntax Error: Invalid character escape sequence: \\u1 es.', - locations: [{ line: 1, column: 7 }], + message: 'Syntax Error: Invalid Unicode escape sequence: "\\u1 es".', + locations: [{ line: 1, column: 6 }], }); expectSyntaxError('"bad \\u0XX1 esc"').to.deep.equal({ - message: 'Syntax Error: Invalid character escape sequence: \\u0XX1.', - locations: [{ line: 1, column: 7 }], + message: 'Syntax Error: Invalid Unicode escape sequence: "\\u0XX1".', + locations: [{ line: 1, column: 6 }], }); expectSyntaxError('"bad \\uXXXX esc"').to.deep.equal({ - message: 'Syntax Error: Invalid character escape sequence: \\uXXXX.', - locations: [{ line: 1, column: 7 }], + message: 'Syntax Error: Invalid Unicode escape sequence: "\\uXXXX".', + locations: [{ line: 1, column: 6 }], }); expectSyntaxError('"bad \\uFXXX esc"').to.deep.equal({ - message: 'Syntax Error: Invalid character escape sequence: \\uFXXX.', - locations: [{ line: 1, column: 7 }], + message: 'Syntax Error: Invalid Unicode escape sequence: "\\uFXXX".', + locations: [{ line: 1, column: 6 }], }); expectSyntaxError('"bad \\uXXXF esc"').to.deep.equal({ - message: 'Syntax Error: Invalid character escape sequence: \\uXXXF.', - locations: [{ line: 1, column: 7 }], + message: 'Syntax Error: Invalid Unicode escape sequence: "\\uXXXF".', + locations: [{ line: 1, column: 6 }], }); }); @@ -482,14 +489,14 @@ describe('Lexer', () => { expectSyntaxError( '"""contains unescaped \u0007 control char"""', ).to.deep.equal({ - message: 'Syntax Error: Invalid character within String: "\\u0007".', + message: 'Syntax Error: Invalid character within String: U+0007.', locations: [{ line: 1, column: 23 }], }); expectSyntaxError( '"""null-byte is not \u0000 end of file"""', ).to.deep.equal({ - message: 'Syntax Error: Invalid character within String: "\\u0000".', + message: 'Syntax Error: Invalid character within String: U+0000.', locations: [{ line: 1, column: 21 }], }); }); @@ -625,7 +632,7 @@ describe('Lexer', () => { }); expectSyntaxError('+1').to.deep.equal({ - message: 'Syntax Error: Cannot parse the unexpected character "+".', + message: 'Syntax Error: Unexpected character: "+".', locations: [{ line: 1, column: 1 }], }); @@ -650,7 +657,8 @@ describe('Lexer', () => { }); expectSyntaxError('.123').to.deep.equal({ - message: 'Syntax Error: Cannot parse the unexpected character ".".', + message: + 'Syntax Error: Invalid number, expected digit before ".", did you mean "0.123"?', locations: [{ line: 1, column: 1 }], }); @@ -674,6 +682,11 @@ describe('Lexer', () => { locations: [{ line: 1, column: 5 }], }); + expectSyntaxError('1.0e"').to.deep.equal({ + message: "Syntax Error: Invalid number, expected digit but got: '\"'.", + locations: [{ line: 1, column: 5 }], + }); + expectSyntaxError('1.2e3e').to.deep.equal({ message: 'Syntax Error: Invalid number, expected digit but got: "e".', locations: [{ line: 1, column: 6 }], @@ -708,7 +721,7 @@ describe('Lexer', () => { locations: [{ line: 1, column: 2 }], }); expectSyntaxError('1\u00DF').to.deep.equal({ - message: 'Syntax Error: Cannot parse the unexpected character "\\u00DF".', + message: 'Syntax Error: Unexpected character: U+00DF.', locations: [{ line: 1, column: 2 }], }); expectSyntaxError('1.23f').to.deep.equal({ @@ -750,6 +763,13 @@ describe('Lexer', () => { value: undefined, }); + expect(lexOne('.')).to.contain({ + kind: TokenKind.DOT, + start: 0, + end: 1, + value: undefined, + }); + expect(lexOne('...')).to.contain({ kind: TokenKind.SPREAD, start: 0, @@ -816,22 +836,27 @@ describe('Lexer', () => { it('lex reports useful unknown character error', () => { expectSyntaxError('..').to.deep.equal({ - message: 'Syntax Error: Cannot parse the unexpected character ".".', + message: 'Syntax Error: Unexpected "..", did you mean "..."?', locations: [{ line: 1, column: 1 }], }); expectSyntaxError('?').to.deep.equal({ - message: 'Syntax Error: Cannot parse the unexpected character "?".', + message: 'Syntax Error: Unexpected character: "?".', locations: [{ line: 1, column: 1 }], }); - expectSyntaxError('\u203B').to.deep.equal({ - message: 'Syntax Error: Cannot parse the unexpected character "\\u203B".', + expectSyntaxError('\u00AA').to.deep.equal({ + message: 'Syntax Error: Unexpected character: U+00AA.', locations: [{ line: 1, column: 1 }], }); - expectSyntaxError('\u200b').to.deep.equal({ - message: 'Syntax Error: Cannot parse the unexpected character "\\u200B".', + expectSyntaxError('\u0AAA').to.deep.equal({ + message: 'Syntax Error: Unexpected character: U+0AAA.', + locations: [{ line: 1, column: 1 }], + }); + + expectSyntaxError('\u203B').to.deep.equal({ + message: 'Syntax Error: Unexpected character: U+203B.', locations: [{ line: 1, column: 1 }], }); }); @@ -894,6 +919,31 @@ describe('Lexer', () => { TokenKind.EOF, ]); }); + + it('lexes comments', () => { + expect(lexOne('# Comment').prev).to.contain({ + kind: TokenKind.COMMENT, + start: 0, + end: 9, + value: ' Comment', + }); + expect(lexOne('# Comment\nAnother line').prev).to.contain({ + kind: TokenKind.COMMENT, + start: 0, + end: 9, + value: ' Comment', + }); + expect(lexOne('# Comment\r\nAnother line').prev).to.contain({ + kind: TokenKind.COMMENT, + start: 0, + end: 9, + value: ' Comment', + }); + expectSyntaxError('# \u0007').to.deep.equal({ + message: 'Syntax Error: Invalid character: U+0007.', + locations: [{ line: 1, column: 3 }], + }); + }); }); describe('isPunctuatorTokenKind', () => { diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index d042bec291..257a04d745 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -9,7 +9,13 @@ import { inspect } from '../../jsutils/inspect'; import { Kind } from '../kinds'; import { Source } from '../source'; import { TokenKind } from '../tokenKind'; -import { parse, parseValue, parseConstValue, parseType } from '../parser'; +import { + parse, + parseValue, + parseConstValue, + parseType, + parseSchemaCoordinate, +} from '../parser'; import { toJSONDeep } from './toJSONDeep'; @@ -619,4 +625,129 @@ describe('Parser', () => { }); }); }); + + describe('parseSchemaCoordinate', () => { + it('parses Name', () => { + const result = parseSchemaCoordinate('MyType'); + expect(toJSONDeep(result)).to.deep.equal({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 6 }, + ofDirective: false, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: 'MyType', + }, + memberName: undefined, + argumentName: undefined, + }); + }); + + it('parses Name . Name', () => { + const result = parseSchemaCoordinate('MyType.field'); + expect(toJSONDeep(result)).to.deep.equal({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 12 }, + ofDirective: false, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: 'MyType', + }, + memberName: { + kind: Kind.NAME, + loc: { start: 7, end: 12 }, + value: 'field', + }, + argumentName: undefined, + }); + }); + + it('rejects Name . Name . Name', () => { + expect(() => parseSchemaCoordinate('MyType.field.deep')) + .to.throw() + .to.deep.equal({ + message: 'Syntax Error: Expected , found ".".', + locations: [{ line: 1, column: 13 }], + }); + }); + + it('parses Name . Name ( Name : )', () => { + const result = parseSchemaCoordinate('MyType.field(arg:)'); + expect(toJSONDeep(result)).to.deep.equal({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 18 }, + ofDirective: false, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: 'MyType', + }, + memberName: { + kind: Kind.NAME, + loc: { start: 7, end: 12 }, + value: 'field', + }, + argumentName: { + kind: Kind.NAME, + loc: { start: 13, end: 16 }, + value: 'arg', + }, + }); + }); + + it('rejects Name . Name ( Name : Name )', () => { + expect(() => parseSchemaCoordinate('MyType.field(arg: value)')) + .to.throw() + .to.deep.equal({ + message: 'Syntax Error: Expected ")", found Name "value".', + locations: [{ line: 1, column: 19 }], + }); + }); + + it('parses @ Name', () => { + const result = parseSchemaCoordinate('@myDirective'); + expect(toJSONDeep(result)).to.deep.equal({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 12 }, + ofDirective: true, + name: { + kind: Kind.NAME, + loc: { start: 1, end: 12 }, + value: 'myDirective', + }, + memberName: undefined, + argumentName: undefined, + }); + }); + + it('parses @ Name ( Name : )', () => { + const result = parseSchemaCoordinate('@myDirective(arg:)'); + expect(toJSONDeep(result)).to.deep.equal({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 18 }, + ofDirective: true, + name: { + kind: Kind.NAME, + loc: { start: 1, end: 12 }, + value: 'myDirective', + }, + memberName: undefined, + argumentName: { + kind: Kind.NAME, + loc: { start: 13, end: 16 }, + value: 'arg', + }, + }); + }); + + it('rejects @ Name . Name', () => { + expect(() => parseSchemaCoordinate('@myDirective.field')) + .to.throw() + .to.deep.equal({ + message: 'Syntax Error: Expected , found ".".', + locations: [{ line: 1, column: 13 }], + }); + }); + }); }); diff --git a/src/language/__tests__/predicates-test.ts b/src/language/__tests__/predicates-test.ts index b90e2b31e9..978bfbedcb 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -15,6 +15,7 @@ import { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from '../predicates'; function filterNodes(predicate: (node: ASTNode) => boolean): Array { @@ -141,4 +142,10 @@ describe('AST node predicates', () => { 'InputObjectTypeExtension', ]); }); + + it('isSchemaCoordinateNode', () => { + expect(filterNodes(isSchemaCoordinateNode)).to.deep.equal([ + 'SchemaCoordinate', + ]); + }); }); diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index cfa1e14052..3abd84e574 100644 --- a/src/language/__tests__/printer-test.ts +++ b/src/language/__tests__/printer-test.ts @@ -3,8 +3,8 @@ import { describe, it } from 'mocha'; import { dedent, dedentString } from '../../__testUtils__/dedent'; import { kitchenSinkQuery } from '../../__testUtils__/kitchenSinkQuery'; +import { parseSchemaCoordinate, parse } from '../parser'; -import { parse } from '../parser'; import { print } from '../printer'; describe('Printer: Query document', () => { @@ -216,4 +216,18 @@ describe('Printer: Query document', () => { `), ); }); + + it('prints schema coordinates', () => { + expect(print(parseSchemaCoordinate(' Name '))).to.equal('Name'); + expect(print(parseSchemaCoordinate(' Name . field '))).to.equal( + 'Name.field', + ); + expect(print(parseSchemaCoordinate(' Name . field ( arg: )'))).to.equal( + 'Name.field(arg:)', + ); + expect(print(parseSchemaCoordinate(' @ name '))).to.equal('@name'); + expect(print(parseSchemaCoordinate(' @ name (arg:) '))).to.equal( + '@name(arg:)', + ); + }); }); diff --git a/src/language/ast.ts b/src/language/ast.ts index 77cdf06de5..f69cc066f2 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -96,7 +96,6 @@ export class Token { end: number, line: number, column: number, - prev: Token | null, value?: string, ) { this.kind = kind; @@ -105,7 +104,7 @@ export class Token { this.line = line; this.column = column; this.value = value as string; - this.prev = prev; + this.prev = null; this.next = null; } @@ -177,7 +176,8 @@ export type ASTNode = | InterfaceTypeExtensionNode | UnionTypeExtensionNode | EnumTypeExtensionNode - | InputObjectTypeExtensionNode; + | InputObjectTypeExtensionNode + | SchemaCoordinateNode; /** * Utility type listing all nodes indexed by their kind. @@ -226,6 +226,7 @@ export interface ASTKindToNode { UnionTypeExtension: UnionTypeExtensionNode; EnumTypeExtension: EnumTypeExtensionNode; InputObjectTypeExtension: InputObjectTypeExtensionNode; + SchemaCoordinate: SchemaCoordinateNode; } /** Name */ @@ -671,3 +672,14 @@ export interface InputObjectTypeExtensionNode { readonly directives?: ReadonlyArray; readonly fields?: ReadonlyArray; } + +// Schema Coordinates + +export interface SchemaCoordinateNode { + readonly kind: 'SchemaCoordinate'; + readonly loc?: Location; + readonly ofDirective: boolean; + readonly name: NameNode; + readonly memberName?: NameNode; + readonly argumentName?: NameNode; +} diff --git a/src/language/index.ts b/src/language/index.ts index dfe4e53584..b53f89e26a 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -13,7 +13,13 @@ export type { TokenKindEnum } from './tokenKind'; export { Lexer } from './lexer'; -export { parse, parseValue, parseConstValue, parseType } from './parser'; +export { + parse, + parseValue, + parseConstValue, + parseType, + parseSchemaCoordinate, +} from './parser'; export type { ParseOptions } from './parser'; export { print } from './printer'; @@ -85,6 +91,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './ast'; export { @@ -98,6 +105,7 @@ export { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from './predicates'; export { DirectiveLocation } from './directiveLocation'; diff --git a/src/language/kinds.ts b/src/language/kinds.ts index b5c0058827..fe1063abb3 100644 --- a/src/language/kinds.ts +++ b/src/language/kinds.ts @@ -66,6 +66,9 @@ export const Kind = Object.freeze({ UNION_TYPE_EXTENSION: 'UnionTypeExtension', ENUM_TYPE_EXTENSION: 'EnumTypeExtension', INPUT_OBJECT_TYPE_EXTENSION: 'InputObjectTypeExtension', + + /** Schema Coordinates */ + SCHEMA_COORDINATE: 'SchemaCoordinate', } as const); /** diff --git a/src/language/lexer.ts b/src/language/lexer.ts index c435a02bd8..0e0378b776 100644 --- a/src/language/lexer.ts +++ b/src/language/lexer.ts @@ -38,7 +38,7 @@ export class Lexer { lineStart: number; constructor(source: Source) { - const startOfFileToken = new Token(TokenKind.SOF, 0, 0, 0, 0, null); + const startOfFileToken = new Token(TokenKind.SOF, 0, 0, 0, 0); this.source = source; this.lastToken = startOfFileToken; @@ -64,8 +64,17 @@ export class Lexer { let token = this.token; if (token.kind !== TokenKind.EOF) { do { - // @ts-expect-error next is only mutable during parsing, so we cast to allow this. - token = token.next ?? (token.next = readToken(this, token)); + if (token.next) { + token = token.next; + } else { + // Read the next token and form a link in the token linked-list. + const nextToken = readNextToken(this, token.end); + // @ts-expect-error next is only mutable during parsing. + token.next = nextToken; + // @ts-expect-error prev is only mutable during parsing. + nextToken.prev = token; + token = nextToken; + } } while (token.kind === TokenKind.COMMENT); } return token; @@ -82,6 +91,7 @@ export function isPunctuatorTokenKind(kind: TokenKindEnum): boolean { kind === TokenKind.AMP || kind === TokenKind.PAREN_L || kind === TokenKind.PAREN_R || + kind === TokenKind.DOT || kind === TokenKind.SPREAD || kind === TokenKind.COLON || kind === TokenKind.EQUALS || @@ -94,19 +104,57 @@ export function isPunctuatorTokenKind(kind: TokenKindEnum): boolean { ); } -function printCharCode(code: number): string { +/** + * SourceCharacter :: + * - U+0009 (Horizontal Tab) + * - U+000A (New Line) + * - U+000D (Carriage Return) + * - U+0020-U+FFFF + */ +function isSourceCharacter(code: number): boolean { return ( - // NaN/undefined represents access beyond the end of the file. - isNaN(code) - ? TokenKind.EOF - : // Trust JSON for ASCII. - code < 0x007f - ? JSON.stringify(String.fromCharCode(code)) - : // Otherwise print the escaped form. - `"\\u${('00' + code.toString(16).toUpperCase()).slice(-4)}"` + code >= 0x0020 || code === 0x0009 || code === 0x000a || code === 0x000d ); } +/** + * Prints the code point (or end of file reference) at a given location in a + * source for use in error messages. + * + * Printable ASCII is printed quoted, while other points are printed in Unicode + * code point form (ie. U+1234). + */ +function printCodePointAt(lexer: Lexer, location: number): string { + const body = lexer.source.body; + if (location >= body.length) { + return TokenKind.EOF; + } + const code = body.charCodeAt(location); + // Printable ASCII + if (code >= 0x0020 && code <= 0x007e) { + return code === 0x0022 ? "'\"'" : `"${body[location]}"`; + } + // Unicode code point + const zeroPad = + code > 0xfff ? '' : code > 0xff ? '0' : code > 0xf ? '00' : '000'; + return `U+${zeroPad}${code.toString(16).toUpperCase()}`; +} + +/** + * Create a token with line and column location information. + */ +function createToken( + lexer: Lexer, + kind: TokenKindEnum, + start: number, + end: number, + value?: string, +): Token { + const line = lexer.line; + const col = 1 + start - lexer.lineStart; + return new Token(kind, start, end, line, col, value); +} + /** * Gets the next token from the source starting at the given position. * @@ -114,587 +162,638 @@ function printCharCode(code: number): string { * punctuators immediately or calls the appropriate helper function for more * complicated tokens. */ -function readToken(lexer: Lexer, prev: Token): Token { - const source = lexer.source; - const body = source.body; +function readNextToken(lexer: Lexer, start: number): Token { + const body = lexer.source.body; const bodyLength = body.length; + let position = start; - let pos = prev.end; - while (pos < bodyLength) { - const code = body.charCodeAt(pos); - - const line = lexer.line; - const col = 1 + pos - lexer.lineStart; + while (position < bodyLength) { + const code = body.charCodeAt(position); // SourceCharacter switch (code) { + // Ignored :: + // - UnicodeBOM + // - WhiteSpace + // - LineTerminator + // - Comment + // - Comma + // + // UnicodeBOM :: "Byte Order Mark (U+FEFF)" + // + // WhiteSpace :: + // - "Horizontal Tab (U+0009)" + // - "Space (U+0020)" + // + // Comma :: , case 0xfeff: // - case 9: // \t - case 32: // - case 44: // , - ++pos; + case 0x0009: // \t + case 0x0020: // + case 0x002c: // , + ++position; continue; - case 10: // \n - ++pos; + // LineTerminator :: + // - "New Line (U+000A)" + // - "Carriage Return (U+000D)" [lookahead != "New Line (U+000A)"] + // - "Carriage Return (U+000D)" "New Line (U+000A)" + case 0x000a: // \n + ++position; ++lexer.line; - lexer.lineStart = pos; + lexer.lineStart = position; continue; - case 13: // \r - if (body.charCodeAt(pos + 1) === 10) { - pos += 2; + case 0x000d: // \r + if (body.charCodeAt(position + 1) === 0x000a) { + position += 2; } else { - ++pos; + ++position; } ++lexer.line; - lexer.lineStart = pos; + lexer.lineStart = position; continue; - case 33: // ! - return new Token(TokenKind.BANG, pos, pos + 1, line, col, prev); - case 35: // # - return readComment(source, pos, line, col, prev); - case 36: // $ - return new Token(TokenKind.DOLLAR, pos, pos + 1, line, col, prev); - case 38: // & - return new Token(TokenKind.AMP, pos, pos + 1, line, col, prev); - case 40: // ( - return new Token(TokenKind.PAREN_L, pos, pos + 1, line, col, prev); - case 41: // ) - return new Token(TokenKind.PAREN_R, pos, pos + 1, line, col, prev); - case 46: // . + // Comment + case 0x0023: // # + return readComment(lexer, position); + // Token :: + // - Punctuator + // - Name + // - IntValue + // - FloatValue + // - StringValue + // + // Punctuator :: + // - DotPunctuator + // - OtherPunctuator + // + // OtherPunctuator :: one of ! $ & ( ) ... : = @ [ ] { | } + case 0x0021: // ! + return createToken(lexer, TokenKind.BANG, position, position + 1); + case 0x0024: // $ + return createToken(lexer, TokenKind.DOLLAR, position, position + 1); + case 0x0026: // & + return createToken(lexer, TokenKind.AMP, position, position + 1); + case 0x0028: // ( + return createToken(lexer, TokenKind.PAREN_L, position, position + 1); + case 0x0029: // ) + return createToken(lexer, TokenKind.PAREN_R, position, position + 1); + case 0x002e: // . if ( - body.charCodeAt(pos + 1) === 46 && - body.charCodeAt(pos + 2) === 46 + body.charCodeAt(position + 1) === 0x002e && + body.charCodeAt(position + 2) === 0x002e ) { - return new Token(TokenKind.SPREAD, pos, pos + 3, line, col, prev); + return createToken(lexer, TokenKind.SPREAD, position, position + 3); } - break; - case 58: // : - return new Token(TokenKind.COLON, pos, pos + 1, line, col, prev); - case 61: // = - return new Token(TokenKind.EQUALS, pos, pos + 1, line, col, prev); - case 64: // @ - return new Token(TokenKind.AT, pos, pos + 1, line, col, prev); - case 91: // [ - return new Token(TokenKind.BRACKET_L, pos, pos + 1, line, col, prev); - case 93: // ] - return new Token(TokenKind.BRACKET_R, pos, pos + 1, line, col, prev); - case 123: // { - return new Token(TokenKind.BRACE_L, pos, pos + 1, line, col, prev); - case 124: // | - return new Token(TokenKind.PIPE, pos, pos + 1, line, col, prev); - case 125: // } - return new Token(TokenKind.BRACE_R, pos, pos + 1, line, col, prev); - case 34: // " + return readDot(lexer, position); + case 0x003a: // : + return createToken(lexer, TokenKind.COLON, position, position + 1); + case 0x003d: // = + return createToken(lexer, TokenKind.EQUALS, position, position + 1); + case 0x0040: // @ + return createToken(lexer, TokenKind.AT, position, position + 1); + case 0x005b: // [ + return createToken(lexer, TokenKind.BRACKET_L, position, position + 1); + case 0x005d: // ] + return createToken(lexer, TokenKind.BRACKET_R, position, position + 1); + case 0x007b: // { + return createToken(lexer, TokenKind.BRACE_L, position, position + 1); + case 0x007c: // | + return createToken(lexer, TokenKind.PIPE, position, position + 1); + case 0x007d: // } + return createToken(lexer, TokenKind.BRACE_R, position, position + 1); + // StringValue + case 0x0022: // " if ( - body.charCodeAt(pos + 1) === 34 && - body.charCodeAt(pos + 2) === 34 + body.charCodeAt(position + 1) === 0x0022 && + body.charCodeAt(position + 2) === 0x0022 ) { - return readBlockString(source, pos, line, col, prev, lexer); + return readBlockString(lexer, position); } - return readString(source, pos, line, col, prev); - case 45: // - - case 48: // 0 - case 49: // 1 - case 50: // 2 - case 51: // 3 - case 52: // 4 - case 53: // 5 - case 54: // 6 - case 55: // 7 - case 56: // 8 - case 57: // 9 - return readNumber(source, pos, code, line, col, prev); - case 65: // A - case 66: // B - case 67: // C - case 68: // D - case 69: // E - case 70: // F - case 71: // G - case 72: // H - case 73: // I - case 74: // J - case 75: // K - case 76: // L - case 77: // M - case 78: // N - case 79: // O - case 80: // P - case 81: // Q - case 82: // R - case 83: // S - case 84: // T - case 85: // U - case 86: // V - case 87: // W - case 88: // X - case 89: // Y - case 90: // Z - case 95: // _ - case 97: // a - case 98: // b - case 99: // c - case 100: // d - case 101: // e - case 102: // f - case 103: // g - case 104: // h - case 105: // i - case 106: // j - case 107: // k - case 108: // l - case 109: // m - case 110: // n - case 111: // o - case 112: // p - case 113: // q - case 114: // r - case 115: // s - case 116: // t - case 117: // u - case 118: // v - case 119: // w - case 120: // x - case 121: // y - case 122: // z - return readName(source, pos, line, col, prev); + return readString(lexer, position); + } + + // IntValue | FloatValue (Digit | -) + if (isDigit(code) || code === 0x002d) { + return readNumber(lexer, position, code); } - throw syntaxError(source, pos, unexpectedCharacterMessage(code)); + // Name + if (isNameStart(code)) { + return readName(lexer, position); + } + + throw syntaxError( + lexer.source, + position, + code === 0x0027 + ? 'Unexpected single quote character (\'), did you mean to use a double quote (")?' + : isSourceCharacter(code) + ? `Unexpected character: ${printCodePointAt(lexer, position)}.` + : `Invalid character: ${printCodePointAt(lexer, position)}.`, + ); } - const line = lexer.line; - const col = 1 + pos - lexer.lineStart; - return new Token(TokenKind.EOF, bodyLength, bodyLength, line, col, prev); + return createToken(lexer, TokenKind.EOF, bodyLength, bodyLength); } /** - * Report a message that an unexpected character was encountered. + * Reads a dot token with helpful messages for negative lookahead. + * + * DotPunctuator :: `.` [lookahead != {`.`, Digit}] */ -function unexpectedCharacterMessage(code: number): string { - if (code < 0x0020 && code !== 0x0009 && code !== 0x000a && code !== 0x000d) { - return `Cannot contain the invalid character ${printCharCode(code)}.`; +function readDot(lexer: Lexer, start: number): Token { + const nextCode = lexer.source.body.charCodeAt(start + 1); + // Full Stop (.) + if (nextCode === 0x002e) { + throw syntaxError( + lexer.source, + start, + 'Unexpected "..", did you mean "..."?', + ); } - - if (code === 39) { - // ' - return 'Unexpected single quote character (\'), did you mean to use a double quote (")?'; + if (isDigit(nextCode)) { + const digits = lexer.source.body.slice( + start + 1, + readDigits(lexer, start + 1, nextCode), + ); + throw syntaxError( + lexer.source, + start, + `Invalid number, expected digit before ".", did you mean "0.${digits}"?`, + ); } - - return `Cannot parse the unexpected character ${printCharCode(code)}.`; + return createToken(lexer, TokenKind.DOT, start, start + 1); } /** * Reads a comment token from the source file. * - * #[\u0009\u0020-\uFFFF]* + * Comment :: # CommentChar* [lookahead != CommentChar] + * + * CommentChar :: SourceCharacter but not LineTerminator */ -function readComment( - source: Source, - start: number, - line: number, - col: number, - prev: Token | null, -): Token { - const body = source.body; - let code; - let position = start; +function readComment(lexer: Lexer, start: number): Token { + const body = lexer.source.body; + const bodyLength = body.length; + let position = start + 1; - do { - code = body.charCodeAt(++position); - } while ( - !isNaN(code) && - // SourceCharacter but not LineTerminator - (code > 0x001f || code === 0x0009) - ); + while (position < bodyLength) { + const code = body.charCodeAt(position); + + // LineTerminator (\n | \r) + if (code === 0x000a || code === 0x000d) { + break; + } - return new Token( + // SourceCharacter + if (isSourceCharacter(code)) { + ++position; + } else { + break; + } + } + + return createToken( + lexer, TokenKind.COMMENT, start, position, - line, - col, - prev, body.slice(start + 1, position), ); } /** - * Reads a number token from the source file, either a float - * or an int depending on whether a decimal point appears. + * Reads a number token from the source file, either a FloatValue or an IntValue + * depending on whether a FractionalPart or ExponentPart is encountered. + * + * IntValue :: IntegerPart [lookahead != {Digit, `.`, NameStart}] + * + * IntegerPart :: + * - NegativeSign? 0 + * - NegativeSign? NonZeroDigit Digit* + * + * NegativeSign :: - + * + * NonZeroDigit :: Digit but not `0` + * + * FloatValue :: + * - IntegerPart FractionalPart ExponentPart [lookahead != {Digit, `.`, NameStart}] + * - IntegerPart FractionalPart [lookahead != {Digit, `.`, NameStart}] + * - IntegerPart ExponentPart [lookahead != {Digit, `.`, NameStart}] + * + * FractionalPart :: . Digit+ + * + * ExponentPart :: ExponentIndicator Sign? Digit+ * - * Int: -?(0|[1-9][0-9]*) - * Float: -?(0|[1-9][0-9]*)(\.[0-9]+)?((E|e)(+|-)?[0-9]+)? + * ExponentIndicator :: one of `e` `E` + * + * Sign :: one of + - */ -function readNumber( - source: Source, - start: number, - firstCode: number, - line: number, - col: number, - prev: Token | null, -): Token { - const body = source.body; - let code = firstCode; +function readNumber(lexer: Lexer, start: number, firstCode: number): Token { + const body = lexer.source.body; let position = start; + let code = firstCode; let isFloat = false; - if (code === 45) { - // - + // NegativeSign (-) + if (code === 0x002d) { code = body.charCodeAt(++position); } - if (code === 48) { - // 0 + // Zero (0) + if (code === 0x0030) { code = body.charCodeAt(++position); - if (code >= 48 && code <= 57) { + if (isDigit(code)) { throw syntaxError( - source, + lexer.source, position, - `Invalid number, unexpected digit after 0: ${printCharCode(code)}.`, + `Invalid number, unexpected digit after 0: ${printCodePointAt( + lexer, + position, + )}.`, ); } } else { - position = readDigits(source, position, code); + position = readDigits(lexer, position, code); code = body.charCodeAt(position); } - if (code === 46) { - // . + // Full stop (.) + if (code === 0x002e) { isFloat = true; code = body.charCodeAt(++position); - position = readDigits(source, position, code); + position = readDigits(lexer, position, code); code = body.charCodeAt(position); } - if (code === 69 || code === 101) { - // E e + // E e + if (code === 0x0045 || code === 0x0065) { isFloat = true; code = body.charCodeAt(++position); - if (code === 43 || code === 45) { - // + - + // + - + if (code === 0x002b || code === 0x002d) { code = body.charCodeAt(++position); } - position = readDigits(source, position, code); + position = readDigits(lexer, position, code); code = body.charCodeAt(position); } // Numbers cannot be followed by . or NameStart - if (code === 46 || isNameStart(code)) { + if (code === 0x002e || isNameStart(code)) { throw syntaxError( - source, + lexer.source, position, - `Invalid number, expected digit but got: ${printCharCode(code)}.`, + `Invalid number, expected digit but got: ${printCodePointAt( + lexer, + position, + )}.`, ); } - return new Token( + return createToken( + lexer, isFloat ? TokenKind.FLOAT : TokenKind.INT, start, position, - line, - col, - prev, body.slice(start, position), ); } /** - * Returns the new position in the source after reading digits. + * Returns the new position in the source after reading one or more digits. */ -function readDigits(source: Source, start: number, firstCode: number): number { - const body = source.body; +function readDigits(lexer: Lexer, start: number, firstCode: number): number { + if (!isDigit(firstCode)) { + throw syntaxError( + lexer.source, + start, + `Invalid number, expected digit but got: ${printCodePointAt( + lexer, + start, + )}.`, + ); + } + + const body = lexer.source.body; let position = start; let code = firstCode; - if (code >= 48 && code <= 57) { - // 0 - 9 - do { - code = body.charCodeAt(++position); - } while (code >= 48 && code <= 57); // 0 - 9 - return position; - } - throw syntaxError( - source, - position, - `Invalid number, expected digit but got: ${printCharCode(code)}.`, - ); + + do { + code = body.charCodeAt(++position); + } while (isDigit(code)); + + return position; } /** - * Reads a string token from the source file. + * Reads a single-quote string token from the source file. + * + * StringValue :: + * - `""` [lookahead != `"`] + * - `"` StringCharacter+ `"` + * + * StringCharacter :: + * - SourceCharacter but not `"` or `\` or LineTerminator + * - `\u` EscapedUnicode + * - `\` EscapedCharacter * - * "([^"\\\u000A\u000D]|(\\(u[0-9a-fA-F]{4}|["\\/bfnrt])))*" + * EscapedUnicode :: /[0-9A-Fa-f]{4}/ + * + * EscapedCharacter :: one of `"` `\` `/` `b` `f` `n` `r` `t` */ -function readString( - source: Source, - start: number, - line: number, - col: number, - prev: Token | null, -): Token { - const body = source.body; +function readString(lexer: Lexer, start: number): Token { + const body = lexer.source.body; + const bodyLength = body.length; let position = start + 1; let chunkStart = position; - let code = 0; let value = ''; - while ( - position < body.length && - !isNaN((code = body.charCodeAt(position))) && - // not LineTerminator - code !== 0x000a && - code !== 0x000d - ) { + while (position < bodyLength) { + const code = body.charCodeAt(position); + // Closing Quote (") - if (code === 34) { + if (code === 0x0022) { value += body.slice(chunkStart, position); - return new Token( - TokenKind.STRING, - start, - position + 1, - line, - col, - prev, - value, - ); + return createToken(lexer, TokenKind.STRING, start, position + 1, value); + } + + // Escape Sequence (\) + if (code === 0x005c) { + value += body.slice(chunkStart, position); + const escape = + body.charCodeAt(position + 1) === 0x0075 // u + ? readEscapedUnicode(lexer, position) + : readEscapedCharacter(lexer, position); + value += escape.value; + position += escape.size; + chunkStart = position; + continue; + } + + // LineTerminator (\n | \r) + if (code === 0x000a || code === 0x000d) { + break; } // SourceCharacter - if (code < 0x0020 && code !== 0x0009) { + if (isSourceCharacter(code)) { + ++position; + } else { throw syntaxError( - source, + lexer.source, position, - `Invalid character within String: ${printCharCode(code)}.`, + `Invalid character within String: ${printCodePointAt( + lexer, + position, + )}.`, ); } + } - ++position; - if (code === 92) { - // \ - value += body.slice(chunkStart, position - 1); - code = body.charCodeAt(position); - switch (code) { - case 34: - value += '"'; - break; - case 47: - value += '/'; - break; - case 92: - value += '\\'; - break; - case 98: - value += '\b'; - break; - case 102: - value += '\f'; - break; - case 110: - value += '\n'; - break; - case 114: - value += '\r'; - break; - case 116: - value += '\t'; - break; - case 117: { - // uXXXX - const charCode = uniCharCode( - body.charCodeAt(position + 1), - body.charCodeAt(position + 2), - body.charCodeAt(position + 3), - body.charCodeAt(position + 4), - ); - if (charCode < 0) { - const invalidSequence = body.slice(position + 1, position + 5); - throw syntaxError( - source, - position, - `Invalid character escape sequence: \\u${invalidSequence}.`, - ); - } - value += String.fromCharCode(charCode); - position += 4; - break; - } - default: - throw syntaxError( - source, - position, - `Invalid character escape sequence: \\${String.fromCharCode( - code, - )}.`, - ); - } - ++position; - chunkStart = position; - } + throw syntaxError(lexer.source, position, 'Unterminated string.'); +} + +// The string value and lexed size of an escape sequence. +interface EscapeSequence { + value: string; + size: number; +} + +function readEscapedUnicode(lexer: Lexer, position: number): EscapeSequence { + const body = lexer.source.body; + const code = read16BitHexCode(body, position + 2); + + if (code >= 0) { + return { value: String.fromCharCode(code), size: 6 }; } - throw syntaxError(source, position, 'Unterminated string.'); + throw syntaxError( + lexer.source, + position, + `Invalid Unicode escape sequence: "${body.slice(position, position + 6)}".`, + ); +} + +/** + * Reads four hexadecimal characters and returns the positive integer that 16bit + * hexadecimal string represents. For example, "000f" will return 15, and "dead" + * will return 57005. + * + * Returns a negative number if any char was not a valid hexadecimal digit. + */ +function read16BitHexCode(body: string, position: number): number { + // readHexDigit() returns -1 on error. ORing a negative value with any other + // value always produces a negative value. + return ( + (readHexDigit(body.charCodeAt(position)) << 12) | + (readHexDigit(body.charCodeAt(position + 1)) << 8) | + (readHexDigit(body.charCodeAt(position + 2)) << 4) | + readHexDigit(body.charCodeAt(position + 3)) + ); +} + +/** + * Reads a hexadecimal character and returns its positive integer value (0-15). + * + * '0' becomes 0, '9' becomes 9 + * 'A' becomes 10, 'F' becomes 15 + * 'a' becomes 10, 'f' becomes 15 + * + * Returns -1 if the provided character code was not a valid hexadecimal digit. + */ +function readHexDigit(code: number): number { + return code >= 0x0030 && code <= 0x0039 // 0-9 + ? code - 0x0030 + : code >= 0x0041 && code <= 0x0046 // A-F + ? code - 0x0037 + : code >= 0x0061 && code <= 0x0066 // a-f + ? code - 0x0057 + : -1; +} + +/** + * | Escaped Character | Code Point | Character Name | + * | ----------------- | ---------- | ---------------------------- | + * | {`"`} | U+0022 | double quote | + * | {`\`} | U+005C | reverse solidus (back slash) | + * | {`/`} | U+002F | solidus (forward slash) | + * | {`b`} | U+0008 | backspace | + * | {`f`} | U+000C | form feed | + * | {`n`} | U+000A | line feed (new line) | + * | {`r`} | U+000D | carriage return | + * | {`t`} | U+0009 | horizontal tab | + */ +function readEscapedCharacter(lexer: Lexer, position: number): EscapeSequence { + const body = lexer.source.body; + const code = body.charCodeAt(position + 1); + switch (code) { + case 0x0022: // " + return { value: '\u0022', size: 2 }; + case 0x005c: // \ + return { value: '\u005c', size: 2 }; + case 0x002f: // / + return { value: '\u002f', size: 2 }; + case 0x0062: // b + return { value: '\u0008', size: 2 }; + case 0x0066: // f + return { value: '\u000c', size: 2 }; + case 0x006e: // n + return { value: '\u000a', size: 2 }; + case 0x0072: // r + return { value: '\u000d', size: 2 }; + case 0x0074: // t + return { value: '\u0009', size: 2 }; + } + throw syntaxError( + lexer.source, + position, + `Invalid character escape sequence: "${body.slice( + position, + position + 2, + )}".`, + ); } /** * Reads a block string token from the source file. * - * """("?"?(\\"""|\\(?!=""")|[^"\\]))*""" + * StringValue :: + * - `"""` BlockStringCharacter* `"""` + * + * BlockStringCharacter :: + * - SourceCharacter but not `"""` or `\"""` + * - `\"""` */ -function readBlockString( - source: Source, - start: number, - line: number, - col: number, - prev: Token | null, - lexer: Lexer, -): Token { - const body = source.body; +function readBlockString(lexer: Lexer, start: number): Token { + const body = lexer.source.body; + const bodyLength = body.length; let position = start + 3; let chunkStart = position; - let code = 0; let rawValue = ''; - while (position < body.length && !isNaN((code = body.charCodeAt(position)))) { + while (position < bodyLength) { + const code = body.charCodeAt(position); + // Closing Triple-Quote (""") if ( - code === 34 && - body.charCodeAt(position + 1) === 34 && - body.charCodeAt(position + 2) === 34 + code === 0x0022 && + body.charCodeAt(position + 1) === 0x0022 && + body.charCodeAt(position + 2) === 0x0022 ) { rawValue += body.slice(chunkStart, position); - return new Token( + return createToken( + lexer, TokenKind.BLOCK_STRING, start, position + 3, - line, - col, - prev, dedentBlockStringValue(rawValue), ); } - // SourceCharacter + // Escaped Triple-Quote (\""") if ( - code < 0x0020 && - code !== 0x0009 && - code !== 0x000a && - code !== 0x000d + code === 0x005c && + body.charCodeAt(position + 1) === 0x0022 && + body.charCodeAt(position + 2) === 0x0022 && + body.charCodeAt(position + 3) === 0x0022 ) { - throw syntaxError( - source, - position, - `Invalid character within String: ${printCharCode(code)}.`, - ); + rawValue += body.slice(chunkStart, position) + '"""'; + position += 4; + chunkStart = position; + continue; } - if (code === 10) { - // new line - ++position; - ++lexer.line; - lexer.lineStart = position; - } else if (code === 13) { - // carriage return - if (body.charCodeAt(position + 1) === 10) { + // LineTerminator + if (code === 0x000a || code === 0x000d) { + if (code === 0x000d && body.charCodeAt(position + 1) === 0x000a) { position += 2; } else { ++position; } ++lexer.line; lexer.lineStart = position; - } else if ( - // Escape Triple-Quote (\""") - code === 92 && - body.charCodeAt(position + 1) === 34 && - body.charCodeAt(position + 2) === 34 && - body.charCodeAt(position + 3) === 34 - ) { - rawValue += body.slice(chunkStart, position) + '"""'; - position += 4; - chunkStart = position; - } else { + continue; + } + + // SourceCharacter + if (isSourceCharacter(code)) { ++position; + } else { + throw syntaxError( + lexer.source, + position, + `Invalid character within String: ${printCodePointAt( + lexer, + position, + )}.`, + ); } } - throw syntaxError(source, position, 'Unterminated string.'); + throw syntaxError(lexer.source, position, 'Unterminated string.'); } /** - * Converts four hexadecimal chars to the integer that the - * string represents. For example, uniCharCode('0','0','0','f') - * will return 15, and uniCharCode('0','0','f','f') returns 255. - * - * Returns a negative number on error, if a char was invalid. + * Reads an alphanumeric + underscore name from the source. * - * This is implemented by noting that char2hex() returns -1 on error, - * which means the result of ORing the char2hex() will also be negative. - */ -function uniCharCode(a: number, b: number, c: number, d: number): number { - return ( - (char2hex(a) << 12) | (char2hex(b) << 8) | (char2hex(c) << 4) | char2hex(d) - ); -} - -/** - * Converts a hex character to its integer value. - * '0' becomes 0, '9' becomes 9 - * 'A' becomes 10, 'F' becomes 15 - * 'a' becomes 10, 'f' becomes 15 + * Name :: + * - NameStart NameContinue* [lookahead != NameContinue] * - * Returns -1 on error. - */ -function char2hex(a: number): number { - return a >= 48 && a <= 57 - ? a - 48 // 0-9 - : a >= 65 && a <= 70 - ? a - 55 // A-F - : a >= 97 && a <= 102 - ? a - 87 // a-f - : -1; -} - -/** - * Reads an alphanumeric + underscore name from the source. + * NameStart :: + * - Letter + * - `_` * - * [_A-Za-z][_0-9A-Za-z]* + * NameContinue :: + * - Letter + * - Digit + * - `_` */ -function readName( - source: Source, - start: number, - line: number, - col: number, - prev: Token | null, -): Token { - const body = source.body; +function readName(lexer: Lexer, start: number): Token { + const body = lexer.source.body; const bodyLength = body.length; let position = start + 1; - let code = 0; - while ( - position !== bodyLength && - !isNaN((code = body.charCodeAt(position))) && - (code === 95 || // _ - (code >= 48 && code <= 57) || // 0-9 - (code >= 65 && code <= 90) || // A-Z - (code >= 97 && code <= 122)) // a-z - ) { - ++position; + + while (position < bodyLength) { + const code = body.charCodeAt(position); + // NameContinue + if (isLetter(code) || isDigit(code) || code === 0x005f) { + ++position; + } else { + break; + } } - return new Token( + + return createToken( + lexer, TokenKind.NAME, start, position, - line, - col, - prev, body.slice(start, position), ); } -// _ A-Z a-z function isNameStart(code: number): boolean { + return isLetter(code) || code === 0x005f; +} + +/** + * Digit :: one of + * - `0` `1` `2` `3` `4` `5` `6` `7` `8` `9` + */ +function isDigit(code: number): boolean { + return code >= 0x0030 && code <= 0x0039; +} + +/** + * Letter :: one of + * - `A` `B` `C` `D` `E` `F` `G` `H` `I` `J` `K` `L` `M` + * - `N` `O` `P` `Q` `R` `S` `T` `U` `V` `W` `X` `Y` `Z` + * - `a` `b` `c` `d` `e` `f` `g` `h` `i` `j` `k` `l` `m` + * - `n` `o` `p` `q` `r` `s` `t` `u` `v` `w` `x` `y` `z` + */ +function isLetter(code: number): boolean { return ( - code === 95 || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) + (code >= 0x0061 && code <= 0x007a) || // A-Z + (code >= 0x0041 && code <= 0x005a) // a-z ); } diff --git a/src/language/parser.ts b/src/language/parser.ts index 660fc906c1..7fe4ceb89d 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -62,6 +62,7 @@ import type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './ast'; import { Kind } from './kinds'; import { Location } from './ast'; @@ -117,8 +118,6 @@ export function parse( * * This is useful within tools that operate upon GraphQL Values directly and * in isolation of complete GraphQL documents. - * - * Consider providing the results to the utility function: valueFromAST(). */ export function parseValue( source: string | Source, @@ -167,6 +166,26 @@ export function parseType( return type; } +/** + * Given a string containing a GraphQL Schema Coordinate (ex. `Type.field`), + * parse the AST for that schema coordinate. + * Throws GraphQLError if a syntax error is encountered. + * + * Consider providing the results to the utility function: + * resolveASTSchemaCoordinate(). Or calling resolveSchemaCoordinate() directly + * with an unparsed source. + */ +export function parseSchemaCoordinate( + source: string | Source, + options?: ParseOptions, +): SchemaCoordinateNode { + const parser = new Parser(source, options); + parser.expectToken(TokenKind.SOF); + const type = parser.parseSchemaCoordinate(); + parser.expectToken(TokenKind.EOF); + return type; +} + /** * This class is exported only to assist people in implementing their own parsers * without duplicating too much code and should be used only as last resort for cases @@ -570,8 +589,8 @@ export class Parser { case TokenKind.DOLLAR: if (isConst) { this.expectToken(TokenKind.DOLLAR); - const varName = this.expectOptionalToken(TokenKind.NAME)?.value; - if (varName != null) { + if (this._lexer.token.kind === TokenKind.NAME) { + const varName = this._lexer.token.value; throw syntaxError( this._lexer.source, token.start, @@ -1351,6 +1370,42 @@ export class Parser { throw this.unexpected(start); } + // Schema Coordinates + + /** + * SchemaCoordinate : + * - Name + * - Name . Name + * - Name . Name ( Name : ) + * - @ Name + * - @ Name ( Name : ) + */ + parseSchemaCoordinate(): SchemaCoordinateNode { + const start = this._lexer.token; + const ofDirective = this.expectOptionalToken(TokenKind.AT); + const name = this.parseName(); + let memberName; + if (!ofDirective && this.expectOptionalToken(TokenKind.DOT)) { + memberName = this.parseName(); + } + let argumentName; + if ( + (ofDirective || memberName) && + this.expectOptionalToken(TokenKind.PAREN_L) + ) { + argumentName = this.parseName(); + this.expectToken(TokenKind.COLON); + this.expectToken(TokenKind.PAREN_R); + } + return this.node(start, { + kind: Kind.SCHEMA_COORDINATE, + ofDirective, + name, + memberName, + argumentName, + }); + } + // Core parsing utility functions /** @@ -1395,23 +1450,23 @@ export class Parser { } /** - * If the next token is of the given kind, return that token after advancing the lexer. - * Otherwise, do not change the parser state and return undefined. + * If the next token is of the given kind, return "true" after advancing the lexer. + * Otherwise, do not change the parser state and return "false". */ - expectOptionalToken(kind: TokenKindEnum): Maybe { + expectOptionalToken(kind: TokenKindEnum): boolean { const token = this._lexer.token; if (token.kind === kind) { this._lexer.advance(); - return token; + return true; } - return undefined; + return false; } /** * If the next token is a given keyword, advance the lexer. * Otherwise, do not change the parser state and throw an error. */ - expectKeyword(value: string) { + expectKeyword(value: string): void { const token = this._lexer.token; if (token.kind === TokenKind.NAME && token.value === value) { this._lexer.advance(); diff --git a/src/language/predicates.ts b/src/language/predicates.ts index 29e4984d5e..1a1c9b8781 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -10,6 +10,7 @@ import type { TypeDefinitionNode, TypeSystemExtensionNode, TypeExtensionNode, + SchemaCoordinateNode, } from './ast'; import { Kind } from './kinds'; @@ -110,3 +111,9 @@ export function isTypeExtensionNode(node: ASTNode): node is TypeExtensionNode { node.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION ); } + +export function isSchemaCoordinateNode( + node: ASTNode, +): node is SchemaCoordinateNode { + return node.kind === Kind.SCHEMA_COORDINATE; +} diff --git a/src/language/printer.ts b/src/language/printer.ts index 0d907fca39..2f3c7db08e 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -302,6 +302,18 @@ const printDocASTReducer: ASTReducer = { leave: ({ name, directives, fields }) => join(['extend input', name, join(directives, ' '), block(fields)], ' '), }, + + // Schema Coordinate + + SchemaCoordinate: { + leave: ({ ofDirective, name, memberName, argumentName }) => + join([ + ofDirective && '@', + name, + wrap('.', memberName), + wrap('(', argumentName, ':)'), + ]), + }, }; /** diff --git a/src/language/tokenKind.ts b/src/language/tokenKind.ts index 10e1e66a80..55097dd053 100644 --- a/src/language/tokenKind.ts +++ b/src/language/tokenKind.ts @@ -10,6 +10,7 @@ export const TokenKind = Object.freeze({ AMP: '&', PAREN_L: '(', PAREN_R: ')', + DOT: '.', SPREAD: '...', COLON: ':', EQUALS: '=', diff --git a/src/language/visitor.ts b/src/language/visitor.ts index c6ffa4c70b..723515f5b3 100644 --- a/src/language/visitor.ts +++ b/src/language/visitor.ts @@ -160,6 +160,8 @@ const QueryDocumentKeys = { UnionTypeExtension: ['name', 'directives', 'types'], EnumTypeExtension: ['name', 'directives', 'values'], InputObjectTypeExtension: ['name', 'directives', 'fields'], + + SchemaCoordinate: ['name', 'memberName', 'argumentName'], }; export const BREAK: unknown = Object.freeze({}); diff --git a/src/type/__tests__/definition-test.ts b/src/type/__tests__/definition-test.ts index 1cf4e4f397..80c17c7f40 100644 --- a/src/type/__tests__/definition-test.ts +++ b/src/type/__tests__/definition-test.ts @@ -165,11 +165,11 @@ describe('Type System: Objects', () => { }, }; const testObject1 = new GraphQLObjectType({ - name: 'Test1', + name: 'Test', fields: outputFields, }); const testObject2 = new GraphQLObjectType({ - name: 'Test2', + name: 'Test', fields: outputFields, }); @@ -191,11 +191,11 @@ describe('Type System: Objects', () => { field2: { type: ScalarType }, }; const testInputObject1 = new GraphQLInputObjectType({ - name: 'Test1', + name: 'Test', fields: inputFields, }); const testInputObject2 = new GraphQLInputObjectType({ - name: 'Test2', + name: 'Test', fields: inputFields, }); @@ -243,6 +243,7 @@ describe('Type System: Objects', () => { }); expect(objType.getFields()).to.deep.equal({ f: { + coordinate: 'SomeObject.f', name: 'f', description: undefined, type: ScalarType, @@ -270,11 +271,13 @@ describe('Type System: Objects', () => { }); expect(objType.getFields()).to.deep.equal({ f: { + coordinate: 'SomeObject.f', name: 'f', description: undefined, type: ScalarType, args: [ { + coordinate: 'SomeObject.f(arg:)', name: 'arg', description: undefined, type: ScalarType, @@ -624,6 +627,7 @@ describe('Type System: Enums', () => { expect(EnumTypeWithNullishValue.getValues()).to.deep.equal([ { + coordinate: 'EnumWithNullishValue.NULL', name: 'NULL', description: undefined, value: null, @@ -632,6 +636,7 @@ describe('Type System: Enums', () => { astNode: undefined, }, { + coordinate: 'EnumWithNullishValue.NAN', name: 'NAN', description: undefined, value: NaN, @@ -640,6 +645,7 @@ describe('Type System: Enums', () => { astNode: undefined, }, { + coordinate: 'EnumWithNullishValue.NO_CUSTOM_VALUE', name: 'NO_CUSTOM_VALUE', description: undefined, value: 'NO_CUSTOM_VALUE', @@ -730,6 +736,7 @@ describe('Type System: Input Objects', () => { }); expect(inputObjType.getFields()).to.deep.equal({ f: { + coordinate: 'SomeInputObject.f', name: 'f', description: undefined, type: ScalarType, @@ -750,6 +757,7 @@ describe('Type System: Input Objects', () => { }); expect(inputObjType.getFields()).to.deep.equal({ f: { + coordinate: 'SomeInputObject.f', name: 'f', description: undefined, type: ScalarType, diff --git a/src/type/__tests__/directive-test.ts b/src/type/__tests__/directive-test.ts index 19249b3b14..d6b6979dc6 100644 --- a/src/type/__tests__/directive-test.ts +++ b/src/type/__tests__/directive-test.ts @@ -33,6 +33,7 @@ describe('Type System: Directive', () => { name: 'Foo', args: [ { + coordinate: '@Foo(foo:)', name: 'foo', description: undefined, type: GraphQLString, @@ -42,6 +43,7 @@ describe('Type System: Directive', () => { astNode: undefined, }, { + coordinate: '@Foo(bar:)', name: 'bar', description: undefined, type: GraphQLInt, diff --git a/src/type/__tests__/enumType-test.ts b/src/type/__tests__/enumType-test.ts index c3cf23cd1c..00a4ec18dc 100644 --- a/src/type/__tests__/enumType-test.ts +++ b/src/type/__tests__/enumType-test.ts @@ -342,6 +342,7 @@ describe('Type System: Enum Values', () => { const values = ComplexEnum.getValues(); expect(values).to.have.deep.ordered.members([ { + coordinate: 'Complex.ONE', name: 'ONE', description: undefined, value: Complex1, @@ -350,6 +351,7 @@ describe('Type System: Enum Values', () => { astNode: undefined, }, { + coordinate: 'Complex.TWO', name: 'TWO', description: undefined, value: Complex2, diff --git a/src/type/__tests__/introspection-test.ts b/src/type/__tests__/introspection-test.ts index 0a480c3e71..8f12cd0398 100644 --- a/src/type/__tests__/introspection-test.ts +++ b/src/type/__tests__/introspection-test.ts @@ -1480,7 +1480,7 @@ describe('Introspection', () => { errors: [ { message: - 'Field "__type" argument "name" of type "String!" is required, but it was not provided.', + 'Argument .__type(name:) of type "String!" is required, but it was not provided.', locations: [{ line: 3, column: 9 }], }, ], diff --git a/src/type/__tests__/predicate-test.ts b/src/type/__tests__/predicate-test.ts index 94e152e1aa..b15acac6f8 100644 --- a/src/type/__tests__/predicate-test.ts +++ b/src/type/__tests__/predicate-test.ts @@ -1,11 +1,7 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import type { - GraphQLArgument, - GraphQLInputField, - GraphQLInputType, -} from '../definition'; +import type { GraphQLInputType } from '../definition'; import { GraphQLDirective, GraphQLSkipDirective, @@ -29,8 +25,10 @@ import { GraphQLScalarType, GraphQLEnumType, GraphQLInputObjectType, + GraphQLInputField, GraphQLInterfaceType, GraphQLObjectType, + GraphQLArgument, GraphQLUnionType, isType, isScalarType, @@ -567,15 +565,7 @@ describe('Type predicates', () => { type: GraphQLInputType; defaultValue?: unknown; }): GraphQLArgument { - return { - name: 'someArg', - type: config.type, - description: undefined, - defaultValue: config.defaultValue, - deprecationReason: null, - extensions: undefined, - astNode: undefined, - }; + return new GraphQLArgument('SomeType.someField', 'someArg', config); } it('returns true for required arguments', () => { @@ -615,15 +605,7 @@ describe('Type predicates', () => { type: GraphQLInputType; defaultValue?: unknown; }): GraphQLInputField { - return { - name: 'someInputField', - type: config.type, - description: undefined, - defaultValue: config.defaultValue, - deprecationReason: null, - extensions: undefined, - astNode: undefined, - }; + return new GraphQLInputField('SomeType', 'someInputField', config); } it('returns true for required input field', () => { diff --git a/src/type/__tests__/validation-test.ts b/src/type/__tests__/validation-test.ts index 6d8ef8f789..a3c8b20451 100644 --- a/src/type/__tests__/validation-test.ts +++ b/src/type/__tests__/validation-test.ts @@ -1974,7 +1974,7 @@ describe('Objects must adhere to Interface they implement', () => { expect(validateSchema(schema)).to.deep.equal([ { message: - 'Object field AnotherObject.field includes required argument requiredArg that is missing from the Interface field AnotherInterface.field.', + 'Argument AnotherObject.field(requiredArg:) must not be required type String! if not provided by the Interface field AnotherInterface.field.', locations: [ { line: 13, column: 11 }, { line: 7, column: 9 }, @@ -2169,11 +2169,11 @@ describe('Interfaces must adhere to Interface they implement', () => { } interface ParentInterface { - field(input: String): String + field(input: String!): String } interface ChildInterface implements ParentInterface { - field(input: String, anotherInput: String): String + field(input: String!, anotherInput: String): String } `); expect(validateSchema(schema)).to.deep.equal([]); @@ -2431,7 +2431,7 @@ describe('Interfaces must adhere to Interface they implement', () => { expect(validateSchema(schema)).to.deep.equal([ { message: - 'Object field ChildInterface.field includes required argument requiredArg that is missing from the Interface field ParentInterface.field.', + 'Argument ChildInterface.field(requiredArg:) must not be required type String! if not provided by the Interface field ParentInterface.field.', locations: [ { line: 13, column: 11 }, { line: 7, column: 9 }, diff --git a/src/type/definition.ts b/src/type/definition.ts index 70b3bbbe1b..09cb797413 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -522,6 +522,25 @@ export function getNamedType( } } +/** + * The base class for all Schema Elements. + */ +export class GraphQLSchemaElement { + readonly coordinate: string; + + constructor(coordinate: string) { + this.coordinate = coordinate; + } + + toString(): string { + return this.coordinate; + } + + toJSON(): string { + return this.toString(); + } +} + /** * Used while defining GraphQL types to allow for circular references in * otherwise immutable type definitions. @@ -574,7 +593,7 @@ export interface GraphQLScalarTypeExtensions { * }); * */ -export class GraphQLScalarType { +export class GraphQLScalarType extends GraphQLSchemaElement { name: string; description: Maybe; specifiedByURL: Maybe; @@ -586,6 +605,7 @@ export class GraphQLScalarType { extensionASTNodes: ReadonlyArray; constructor(config: Readonly>) { + super(config.name); const parseValue = config.parseValue ?? identityFunc; this.name = config.name; this.description = config.description; @@ -636,14 +656,6 @@ export class GraphQLScalarType { }; } - toString(): string { - return this.name; - } - - toJSON(): string { - return this.toString(); - } - get [Symbol.toStringTag]() { return 'GraphQLScalarType'; } @@ -739,7 +751,10 @@ export interface GraphQLObjectTypeExtensions<_TSource = any, _TContext = any> { * }); * */ -export class GraphQLObjectType { +export class GraphQLObjectType< + TSource = any, + TContext = any, +> extends GraphQLSchemaElement { name: string; description: Maybe; isTypeOf: Maybe>; @@ -751,6 +766,7 @@ export class GraphQLObjectType { private _interfaces: ThunkArray; constructor(config: Readonly>) { + super(config.name); this.name = config.name; this.description = config.description; this.isTypeOf = config.isTypeOf; @@ -787,7 +803,7 @@ export class GraphQLObjectType { name: this.name, description: this.description, interfaces: this.getInterfaces(), - fields: fieldsToFieldsConfig(this.getFields()), + fields: mapValue(this.getFields(), (field) => field.toConfig()), isTypeOf: this.isTypeOf, extensions: this.extensions, astNode: this.astNode, @@ -795,14 +811,6 @@ export class GraphQLObjectType { }; } - toString(): string { - return this.name; - } - - toJSON(): string { - return this.toString(); - } - get [Symbol.toStringTag]() { return 'GraphQLObjectType'; } @@ -833,90 +841,17 @@ function defineFieldMap( `${config.name} fields must be an object with field names as keys or a function which returns such an object.`, ); - return mapValue(fieldMap, (fieldConfig, fieldName) => { - devAssert( - isPlainObj(fieldConfig), - `${config.name}.${fieldName} field config must be an object.`, - ); - devAssert( - fieldConfig.resolve == null || typeof fieldConfig.resolve === 'function', - `${config.name}.${fieldName} field resolver must be a function if ` + - `provided, but got: ${inspect(fieldConfig.resolve)}.`, - ); - - const argsConfig = fieldConfig.args ?? {}; - devAssert( - isPlainObj(argsConfig), - `${config.name}.${fieldName} args must be an object with argument names as keys.`, - ); - - return { - name: fieldName, - description: fieldConfig.description, - type: fieldConfig.type, - args: defineArguments(argsConfig), - resolve: fieldConfig.resolve, - subscribe: fieldConfig.subscribe, - deprecationReason: fieldConfig.deprecationReason, - extensions: fieldConfig.extensions && toObjMap(fieldConfig.extensions), - astNode: fieldConfig.astNode, - }; - }); -} - -export function defineArguments( - config: GraphQLFieldConfigArgumentMap, -): ReadonlyArray { - return Object.entries(config).map(([argName, argConfig]) => ({ - name: argName, - description: argConfig.description, - type: argConfig.type, - defaultValue: argConfig.defaultValue, - deprecationReason: argConfig.deprecationReason, - extensions: argConfig.extensions && toObjMap(argConfig.extensions), - astNode: argConfig.astNode, - })); + return mapValue( + fieldMap, + (fieldConfig, fieldName) => + new GraphQLField(config.name, fieldName, fieldConfig), + ); } function isPlainObj(obj: unknown): boolean { return isObjectLike(obj) && !Array.isArray(obj); } -function fieldsToFieldsConfig( - fields: GraphQLFieldMap, -): GraphQLFieldConfigMap { - return mapValue(fields, (field) => ({ - description: field.description, - type: field.type, - args: argsToArgsConfig(field.args), - resolve: field.resolve, - subscribe: field.subscribe, - deprecationReason: field.deprecationReason, - extensions: field.extensions, - astNode: field.astNode, - })); -} - -/** - * @internal - */ -export function argsToArgsConfig( - args: ReadonlyArray, -): GraphQLFieldConfigArgumentMap { - return keyValMap( - args, - (arg) => arg.name, - (arg) => ({ - description: arg.description, - type: arg.type, - defaultValue: arg.defaultValue, - deprecationReason: arg.deprecationReason, - extensions: arg.extensions, - astNode: arg.astNode, - }), - ); -} - export interface GraphQLObjectTypeConfig { name: string; description?: Maybe; @@ -1038,11 +973,11 @@ export type GraphQLFieldConfigMap = ObjMap< GraphQLFieldConfig >; -export interface GraphQLField< +export class GraphQLField< TSource, TContext, TArgs = { [argument: string]: any }, -> { +> extends GraphQLSchemaElement { name: string; description: Maybe; type: GraphQLOutputType; @@ -1052,9 +987,65 @@ export interface GraphQLField< deprecationReason: Maybe; extensions: Maybe>>; astNode: Maybe; + + constructor( + parentCoordinate: string, + name: string, + config: GraphQLFieldConfig, + ) { + const coordinate = `${parentCoordinate}.${name}`; + + devAssert( + isPlainObj(config), + `${coordinate} field config must be an object.`, + ); + + devAssert( + config.resolve == null || typeof config.resolve === 'function', + `${coordinate} field resolver must be a function if ` + + `provided, but got: ${inspect(config.resolve)}.`, + ); + + const argsConfig = config.args ?? {}; + devAssert( + isPlainObj(argsConfig), + `${coordinate} args must be an object with argument names as keys.`, + ); + + super(coordinate); + this.name = name; + this.description = config.description; + this.type = config.type; + this.args = Object.entries(argsConfig).map( + ([argName, argConfig]) => + new GraphQLArgument(coordinate, argName, argConfig), + ); + this.resolve = config.resolve; + this.subscribe = config.subscribe; + this.deprecationReason = config.deprecationReason; + this.extensions = config.extensions && toObjMap(config.extensions); + this.astNode = config.astNode; + } + + toConfig(): GraphQLFieldConfig { + return { + description: this.description, + type: this.type, + args: keyValMap( + this.args, + (arg) => arg.name, + (arg) => arg.toConfig(), + ), + resolve: this.resolve, + subscribe: this.subscribe, + deprecationReason: this.deprecationReason, + extensions: this.extensions, + astNode: this.astNode, + }; + } } -export interface GraphQLArgument { +export class GraphQLArgument extends GraphQLSchemaElement { name: string; description: Maybe; type: GraphQLInputType; @@ -1062,6 +1053,33 @@ export interface GraphQLArgument { deprecationReason: Maybe; extensions: Maybe>; astNode: Maybe; + + constructor( + parentCoordinate: string, + name: string, + config: GraphQLArgumentConfig, + ) { + const coordinate = `${parentCoordinate}(${name}:)`; + super(coordinate); + this.name = name; + this.description = config.description; + this.type = config.type; + this.defaultValue = config.defaultValue; + this.deprecationReason = config.deprecationReason; + this.extensions = config.extensions && toObjMap(config.extensions); + this.astNode = config.astNode; + } + + toConfig(): GraphQLArgumentConfig { + return { + description: this.description, + type: this.type, + defaultValue: this.defaultValue, + deprecationReason: this.deprecationReason, + extensions: this.extensions, + astNode: this.astNode, + }; + } } export function isRequiredArgument(arg: GraphQLArgument): boolean { @@ -1103,7 +1121,7 @@ export interface GraphQLInterfaceTypeExtensions { * }); * */ -export class GraphQLInterfaceType { +export class GraphQLInterfaceType extends GraphQLSchemaElement { name: string; description: Maybe; resolveType: Maybe>; @@ -1115,6 +1133,7 @@ export class GraphQLInterfaceType { private _interfaces: ThunkArray; constructor(config: Readonly>) { + super(config.name); this.name = config.name; this.description = config.description; this.resolveType = config.resolveType; @@ -1151,7 +1170,7 @@ export class GraphQLInterfaceType { name: this.name, description: this.description, interfaces: this.getInterfaces(), - fields: fieldsToFieldsConfig(this.getFields()), + fields: mapValue(this.getFields(), (field) => field.toConfig()), resolveType: this.resolveType, extensions: this.extensions, astNode: this.astNode, @@ -1159,14 +1178,6 @@ export class GraphQLInterfaceType { }; } - toString(): string { - return this.name; - } - - toJSON(): string { - return this.toString(); - } - get [Symbol.toStringTag]() { return 'GraphQLInterfaceType'; } @@ -1232,7 +1243,7 @@ export interface GraphQLUnionTypeExtensions { * }); * */ -export class GraphQLUnionType { +export class GraphQLUnionType extends GraphQLSchemaElement { name: string; description: Maybe; resolveType: Maybe>; @@ -1243,6 +1254,7 @@ export class GraphQLUnionType { private _types: ThunkArray; constructor(config: Readonly>) { + super(config.name); this.name = config.name; this.description = config.description; this.resolveType = config.resolveType; @@ -1278,14 +1290,6 @@ export class GraphQLUnionType { }; } - toString(): string { - return this.name; - } - - toJSON(): string { - return this.toString(); - } - get [Symbol.toStringTag]() { return 'GraphQLUnionType'; } @@ -1358,7 +1362,7 @@ export interface GraphQLEnumTypeExtensions { * Note: If a value is not provided in a definition, the name of the enum value * will be used as its internal value. */ -export class GraphQLEnumType /* */ { +export class GraphQLEnumType /* */ extends GraphQLSchemaElement { name: string; description: Maybe; extensions: Maybe>; @@ -1370,13 +1374,21 @@ export class GraphQLEnumType /* */ { private _nameLookup: ObjMap; constructor(config: Readonly */>) { + super(config.name); this.name = config.name; this.description = config.description; this.extensions = config.extensions && toObjMap(config.extensions); this.astNode = config.astNode; this.extensionASTNodes = config.extensionASTNodes ?? []; - this._values = defineEnumValues(this.name, config.values); + devAssert( + isPlainObj(config.values), + `${this.name} values must be an object with value names as keys.`, + ); + this._values = Object.entries(config.values).map( + ([valueName, valueConfig]) => + new GraphQLEnumValue(config.name, valueName, valueConfig), + ); this._valueLookup = new Map( this._values.map((enumValue) => [enumValue.value, enumValue]), ); @@ -1449,36 +1461,20 @@ export class GraphQLEnumType /* */ { } toConfig(): GraphQLEnumTypeNormalizedConfig { - const values = keyValMap( - this.getValues(), - (value) => value.name, - (value) => ({ - description: value.description, - value: value.value, - deprecationReason: value.deprecationReason, - extensions: value.extensions, - astNode: value.astNode, - }), - ); - return { name: this.name, description: this.description, - values, + values: keyValMap( + this.getValues(), + (value) => value.name, + (value) => value.toConfig(), + ), extensions: this.extensions, astNode: this.astNode, extensionASTNodes: this.extensionASTNodes, }; } - toString(): string { - return this.name; - } - - toJSON(): string { - return this.toString(); - } - get [Symbol.toStringTag]() { return 'GraphQLEnumType'; } @@ -1494,31 +1490,6 @@ function didYouMeanEnumValue( return didYouMean('the enum value', suggestedValues); } -function defineEnumValues( - typeName: string, - valueMap: GraphQLEnumValueConfigMap /* */, -): Array */> { - devAssert( - isPlainObj(valueMap), - `${typeName} values must be an object with value names as keys.`, - ); - return Object.entries(valueMap).map(([valueName, valueConfig]) => { - devAssert( - isPlainObj(valueConfig), - `${typeName}.${valueName} must refer to an object with a "value" key ` + - `representing an internal value but got: ${inspect(valueConfig)}.`, - ); - return { - name: valueName, - description: valueConfig.description, - value: valueConfig.value !== undefined ? valueConfig.value : valueName, - deprecationReason: valueConfig.deprecationReason, - extensions: valueConfig.extensions && toObjMap(valueConfig.extensions), - astNode: valueConfig.astNode, - }; - }); -} - export interface GraphQLEnumTypeConfig { name: string; description?: Maybe; @@ -1557,13 +1528,45 @@ export interface GraphQLEnumValueConfig { astNode?: Maybe; } -export interface GraphQLEnumValue { +export class GraphQLEnumValue extends GraphQLSchemaElement { name: string; description: Maybe; value: any /* T */; deprecationReason: Maybe; extensions: Maybe>; astNode: Maybe; + + constructor( + parentCoordinate: string, + name: string, + config: GraphQLEnumValueConfig, + ) { + const coordinate = `${parentCoordinate}.${name}`; + + devAssert( + isPlainObj(config), + `${coordinate} must refer to an object with a "value" key ` + + `representing an internal value but got: ${inspect(config)}.`, + ); + + super(coordinate); + this.name = name; + this.description = config.description; + this.value = config.value !== undefined ? config.value : name; + this.deprecationReason = config.deprecationReason; + this.extensions = config.extensions && toObjMap(config.extensions); + this.astNode = config.astNode; + } + + toConfig(): GraphQLEnumValueConfig { + return { + description: this.description, + value: this.value, + deprecationReason: this.deprecationReason, + extensions: this.extensions, + astNode: this.astNode, + }; + } } /** @@ -1599,7 +1602,7 @@ export interface GraphQLInputObjectTypeExtensions { * }); * */ -export class GraphQLInputObjectType { +export class GraphQLInputObjectType extends GraphQLSchemaElement { name: string; description: Maybe; extensions: Maybe>; @@ -1609,6 +1612,7 @@ export class GraphQLInputObjectType { private _fields: ThunkObjMap; constructor(config: Readonly) { + super(config.name); this.name = config.name; this.description = config.description; this.extensions = config.extensions && toObjMap(config.extensions); @@ -1627,32 +1631,16 @@ export class GraphQLInputObjectType { } toConfig(): GraphQLInputObjectTypeNormalizedConfig { - const fields = mapValue(this.getFields(), (field) => ({ - description: field.description, - type: field.type, - defaultValue: field.defaultValue, - extensions: field.extensions, - astNode: field.astNode, - })); - return { name: this.name, description: this.description, - fields, + fields: mapValue(this.getFields(), (field) => field.toConfig()), extensions: this.extensions, astNode: this.astNode, extensionASTNodes: this.extensionASTNodes, }; } - toString(): string { - return this.name; - } - - toJSON(): string { - return this.toString(); - } - get [Symbol.toStringTag]() { return 'GraphQLInputObjectType'; } @@ -1666,22 +1654,11 @@ function defineInputFieldMap( isPlainObj(fieldMap), `${config.name} fields must be an object with field names as keys or a function which returns such an object.`, ); - return mapValue(fieldMap, (fieldConfig, fieldName) => { - devAssert( - !('resolve' in fieldConfig), - `${config.name}.${fieldName} field has a resolve property, but Input Types cannot define resolvers.`, - ); - - return { - name: fieldName, - description: fieldConfig.description, - type: fieldConfig.type, - defaultValue: fieldConfig.defaultValue, - deprecationReason: fieldConfig.deprecationReason, - extensions: fieldConfig.extensions && toObjMap(fieldConfig.extensions), - astNode: fieldConfig.astNode, - }; - }); + return mapValue( + fieldMap, + (fieldConfig, fieldName) => + new GraphQLInputField(config.name, fieldName, fieldConfig), + ); } export interface GraphQLInputObjectTypeConfig { @@ -1724,7 +1701,7 @@ export interface GraphQLInputFieldConfig { export type GraphQLInputFieldConfigMap = ObjMap; -export interface GraphQLInputField { +export class GraphQLInputField extends GraphQLSchemaElement { name: string; description: Maybe; type: GraphQLInputType; @@ -1732,6 +1709,38 @@ export interface GraphQLInputField { deprecationReason: Maybe; extensions: Maybe>; astNode: Maybe; + + constructor( + parentCoordinate: string, + name: string, + config: GraphQLInputFieldConfig, + ) { + const coordinate = `${parentCoordinate}.${name}`; + + devAssert( + !('resolve' in config), + `${coordinate} field has a resolve property, but Input Types cannot define resolvers.`, + ); + + super(coordinate); + this.name = name; + this.description = config.description; + this.type = config.type; + this.defaultValue = config.defaultValue; + this.deprecationReason = config.deprecationReason; + this.extensions = config.extensions && toObjMap(config.extensions); + this.astNode = config.astNode; + } + + toConfig(): GraphQLInputFieldConfig { + return { + description: this.description, + type: this.type, + defaultValue: this.defaultValue, + extensions: this.extensions, + astNode: this.astNode, + }; + } } export function isRequiredInputField(field: GraphQLInputField): boolean { diff --git a/src/type/directives.ts b/src/type/directives.ts index 3dff8298bb..5384895a63 100644 --- a/src/type/directives.ts +++ b/src/type/directives.ts @@ -1,5 +1,7 @@ +import type { ObjMap } from '../jsutils/ObjMap'; import { inspect } from '../jsutils/inspect'; import { toObjMap } from '../jsutils/toObjMap'; +import { keyValMap } from '../jsutils/keyValMap'; import { devAssert } from '../jsutils/devAssert'; import { instanceOf } from '../jsutils/instanceOf'; import { isObjectLike } from '../jsutils/isObjectLike'; @@ -9,16 +11,13 @@ import type { DirectiveDefinitionNode } from '../language/ast'; import type { DirectiveLocationEnum } from '../language/directiveLocation'; import { DirectiveLocation } from '../language/directiveLocation'; -import type { - GraphQLArgument, - GraphQLFieldConfigArgumentMap, -} from './definition'; -import { GraphQLString, GraphQLBoolean } from './scalars'; +import type { GraphQLArgumentConfig } from './definition'; import { - defineArguments, - argsToArgsConfig, + GraphQLArgument, + GraphQLSchemaElement, GraphQLNonNull, } from './definition'; +import { GraphQLString, GraphQLBoolean } from './scalars'; /** * Test if the given value is a GraphQL directive. @@ -53,7 +52,7 @@ export interface GraphQLDirectiveExtensions { * Directives are used by the GraphQL runtime as a way of modifying execution * behavior. Type system creators will usually not create these directly. */ -export class GraphQLDirective { +export class GraphQLDirective extends GraphQLSchemaElement { name: string; description: Maybe; locations: Array; @@ -63,6 +62,8 @@ export class GraphQLDirective { astNode: Maybe; constructor(config: Readonly) { + const coordinate = `@${config.name}`; + super(coordinate); this.name = config.name; this.description = config.description; this.locations = config.locations; @@ -73,16 +74,19 @@ export class GraphQLDirective { devAssert(config.name, 'Directive must be named.'); devAssert( Array.isArray(config.locations), - `@${config.name} locations must be an Array.`, + `${coordinate} locations must be an Array.`, ); const args = config.args ?? {}; devAssert( isObjectLike(args) && !Array.isArray(args), - `@${config.name} args must be an object with argument names as keys.`, + `${coordinate} args must be an object with argument names as keys.`, ); - this.args = defineArguments(args); + this.args = Object.entries(args).map( + ([argName, argConfig]) => + new GraphQLArgument(coordinate, argName, argConfig), + ); } toConfig(): GraphQLDirectiveNormalizedConfig { @@ -90,21 +94,17 @@ export class GraphQLDirective { name: this.name, description: this.description, locations: this.locations, - args: argsToArgsConfig(this.args), + args: keyValMap( + this.args, + (arg) => arg.name, + (arg) => arg.toConfig(), + ), isRepeatable: this.isRepeatable, extensions: this.extensions, astNode: this.astNode, }; } - toString(): string { - return '@' + this.name; - } - - toJSON(): string { - return this.toString(); - } - get [Symbol.toStringTag]() { return 'GraphQLDirective'; } @@ -114,14 +114,14 @@ export interface GraphQLDirectiveConfig { name: string; description?: Maybe; locations: Array; - args?: Maybe; + args?: Maybe>; isRepeatable?: Maybe; extensions?: Maybe>; astNode?: Maybe; } interface GraphQLDirectiveNormalizedConfig extends GraphQLDirectiveConfig { - args: GraphQLFieldConfigArgumentMap; + args: ObjMap; isRepeatable: boolean; extensions: Maybe>; } diff --git a/src/type/index.ts b/src/type/index.ts index fab8bb65dc..f4c419bb66 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -53,12 +53,17 @@ export { getNullableType, getNamedType, /** Definitions */ + GraphQLSchemaElement, GraphQLScalarType, GraphQLObjectType, GraphQLInterfaceType, GraphQLUnionType, GraphQLEnumType, GraphQLInputObjectType, + GraphQLField, + GraphQLArgument, + GraphQLEnumValue, + GraphQLInputField, /** Type Wrappers */ GraphQLList, GraphQLNonNull, @@ -78,23 +83,19 @@ export type { GraphQLNamedOutputType, ThunkArray, ThunkObjMap, - GraphQLArgument, GraphQLArgumentConfig, GraphQLArgumentExtensions, GraphQLEnumTypeConfig, GraphQLEnumTypeExtensions, - GraphQLEnumValue, GraphQLEnumValueConfig, GraphQLEnumValueConfigMap, GraphQLEnumValueExtensions, - GraphQLField, GraphQLFieldConfig, GraphQLFieldConfigArgumentMap, GraphQLFieldConfigMap, GraphQLFieldExtensions, GraphQLFieldMap, GraphQLFieldResolver, - GraphQLInputField, GraphQLInputFieldConfig, GraphQLInputFieldConfigMap, GraphQLInputFieldExtensions, diff --git a/src/type/introspection.ts b/src/type/introspection.ts index 5859aa27ed..6dfd193f5e 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -12,7 +12,6 @@ import type { GraphQLNamedType, GraphQLInputField, GraphQLEnumValue, - GraphQLField, GraphQLFieldConfigMap, } from './definition'; import { GraphQLString, GraphQLBoolean } from './scalars'; @@ -20,6 +19,7 @@ import { GraphQLList, GraphQLNonNull, GraphQLObjectType, + GraphQLField, GraphQLEnumType, isScalarType, isObjectType, @@ -480,53 +480,24 @@ export const __TypeKind: GraphQLEnumType = new GraphQLEnumType({ }, }); -/** - * Note that these are GraphQLField and not GraphQLFieldConfig, - * so the format for args is different. - */ - -export const SchemaMetaFieldDef: GraphQLField = { - name: '__schema', +export const SchemaMetaFieldDef = new GraphQLField('', '__schema', { type: new GraphQLNonNull(__Schema), description: 'Access the current type schema of this server.', - args: [], resolve: (_source, _args, _context, { schema }) => schema, - deprecationReason: undefined, - extensions: undefined, - astNode: undefined, -}; +}); -export const TypeMetaFieldDef: GraphQLField = { - name: '__type', +export const TypeMetaFieldDef = new GraphQLField('', '__type', { type: __Type, description: 'Request the type information of a single type.', - args: [ - { - name: 'name', - description: undefined, - type: new GraphQLNonNull(GraphQLString), - defaultValue: undefined, - deprecationReason: undefined, - extensions: undefined, - astNode: undefined, - }, - ], + args: { name: { type: new GraphQLNonNull(GraphQLString) } }, resolve: (_source, { name }, _context, { schema }) => schema.getType(name), - deprecationReason: undefined, - extensions: undefined, - astNode: undefined, -}; +}); -export const TypeNameMetaFieldDef: GraphQLField = { - name: '__typename', +export const TypeNameMetaFieldDef = new GraphQLField('', '__typename', { type: new GraphQLNonNull(GraphQLString), description: 'The name of the current Object type at runtime.', - args: [], resolve: (_source, _args, _context, { parentType }) => parentType.name, - deprecationReason: undefined, - extensions: undefined, - astNode: undefined, -}; +}); export const introspectionTypes: ReadonlyArray = Object.freeze([ diff --git a/src/type/validate.ts b/src/type/validate.ts index 77420d8bc5..a6de555816 100644 --- a/src/type/validate.ts +++ b/src/type/validate.ts @@ -184,17 +184,17 @@ function validateDirectives(context: SchemaValidationContext): void { // Ensure the type is an input type. if (!isInputType(arg.type)) { context.reportError( - `The type of @${directive.name}(${arg.name}:) must be Input Type ` + + `The type of ${arg} must be Input Type ` + `but got: ${inspect(arg.type)}.`, arg.astNode, ); } if (isRequiredArgument(arg) && arg.deprecationReason != null) { - context.reportError( - `Required argument @${directive.name}(${arg.name}:) cannot be deprecated.`, - [getDeprecatedDirectiveNode(arg.astNode), arg.astNode?.type], - ); + context.reportError(`Required argument ${arg} cannot be deprecated.`, [ + getDeprecatedDirectiveNode(arg.astNode), + arg.astNode?.type, + ]); } } } @@ -266,7 +266,7 @@ function validateFields( // Objects and Interfaces both must define one or more fields. if (fields.length === 0) { - context.reportError(`Type ${type.name} must define one or more fields.`, [ + context.reportError(`Type ${type} must define one or more fields.`, [ type.astNode, ...type.extensionASTNodes, ]); @@ -279,7 +279,7 @@ function validateFields( // Ensure the type is an output type if (!isOutputType(field.type)) { context.reportError( - `The type of ${type.name}.${field.name} must be Output Type ` + + `The type of ${field} must be Output Type ` + `but got: ${inspect(field.type)}.`, field.astNode?.type, ); @@ -287,25 +287,23 @@ function validateFields( // Ensure the arguments are valid for (const arg of field.args) { - const argName = arg.name; - // Ensure they are named correctly. validateName(context, arg); // Ensure the type is an input type if (!isInputType(arg.type)) { context.reportError( - `The type of ${type.name}.${field.name}(${argName}:) must be Input ` + + `The type of ${arg} must be Input ` + `Type but got: ${inspect(arg.type)}.`, arg.astNode?.type, ); } if (isRequiredArgument(arg) && arg.deprecationReason != null) { - context.reportError( - `Required argument ${type.name}.${field.name}(${argName}:) cannot be deprecated.`, - [getDeprecatedDirectiveNode(arg.astNode), arg.astNode?.type], - ); + context.reportError(`Required argument ${arg} cannot be deprecated.`, [ + getDeprecatedDirectiveNode(arg.astNode), + arg.astNode?.type, + ]); } } } @@ -319,7 +317,7 @@ function validateInterfaces( for (const iface of type.getInterfaces()) { if (!isInterfaceType(iface)) { context.reportError( - `Type ${inspect(type)} must only implement Interface types, ` + + `Type ${type} must only implement Interface types, ` + `it cannot implement ${inspect(iface)}.`, getAllImplementsInterfaceNodes(type, iface), ); @@ -328,7 +326,7 @@ function validateInterfaces( if (type === iface) { context.reportError( - `Type ${type.name} cannot implement itself because it would create a circular reference.`, + `Type ${type} cannot implement itself because it would create a circular reference.`, getAllImplementsInterfaceNodes(type, iface), ); continue; @@ -336,7 +334,7 @@ function validateInterfaces( if (ifaceTypeNames[iface.name]) { context.reportError( - `Type ${type.name} can only implement ${iface.name} once.`, + `Type ${type} can only implement ${iface} once.`, getAllImplementsInterfaceNodes(type, iface), ); continue; @@ -358,13 +356,12 @@ function validateTypeImplementsInterface( // Assert each interface field is implemented. for (const ifaceField of Object.values(iface.getFields())) { - const fieldName = ifaceField.name; - const typeField = typeFieldMap[fieldName]; + const typeField = typeFieldMap[ifaceField.name]; // Assert interface field exists on type. if (!typeField) { context.reportError( - `Interface field ${iface.name}.${fieldName} expected but ${type.name} does not provide it.`, + `Interface field ${ifaceField} expected but ${type} does not provide it.`, [ifaceField.astNode, type.astNode, ...type.extensionASTNodes], ); continue; @@ -374,22 +371,20 @@ function validateTypeImplementsInterface( // a valid subtype. (covariant) if (!isTypeSubTypeOf(context.schema, typeField.type, ifaceField.type)) { context.reportError( - `Interface field ${iface.name}.${fieldName} expects type ` + - `${inspect(ifaceField.type)} but ${type.name}.${fieldName} ` + - `is type ${inspect(typeField.type)}.`, + `Interface field ${ifaceField} expects type ${ifaceField.type} ` + + `but ${typeField} is type ${typeField.type}.`, [ifaceField.astNode?.type, typeField.astNode?.type], ); } // Assert each interface field arg is implemented. for (const ifaceArg of ifaceField.args) { - const argName = ifaceArg.name; - const typeArg = typeField.args.find((arg) => arg.name === argName); + const typeArg = typeField.args.find((arg) => arg.name === ifaceArg.name); // Assert interface field arg exists on object field. if (!typeArg) { context.reportError( - `Interface field argument ${iface.name}.${fieldName}(${argName}:) expected but ${type.name}.${fieldName} does not provide it.`, + `Interface field argument ${ifaceArg} expected but ${typeField} does not provide it.`, [ifaceArg.astNode, typeField.astNode], ); continue; @@ -400,10 +395,8 @@ function validateTypeImplementsInterface( // TODO: change to contravariant? if (!isEqualType(ifaceArg.type, typeArg.type)) { context.reportError( - `Interface field argument ${iface.name}.${fieldName}(${argName}:) ` + - `expects type ${inspect(ifaceArg.type)} but ` + - `${type.name}.${fieldName}(${argName}:) is type ` + - `${inspect(typeArg.type)}.`, + `Interface field argument ${ifaceArg} expects type ${ifaceArg.type} ` + + `but ${typeArg} is type ${typeArg.type}.`, [ // istanbul ignore next (TODO need to write coverage tests) ifaceArg.astNode?.type, @@ -418,13 +411,17 @@ function validateTypeImplementsInterface( // Assert additional arguments must not be required. for (const typeArg of typeField.args) { - const argName = typeArg.name; - const ifaceArg = ifaceField.args.find((arg) => arg.name === argName); - if (!ifaceArg && isRequiredArgument(typeArg)) { - context.reportError( - `Object field ${type.name}.${fieldName} includes required argument ${argName} that is missing from the Interface field ${iface.name}.${fieldName}.`, - [typeArg.astNode, ifaceField.astNode], + if (isRequiredArgument(typeArg)) { + const ifaceArg = ifaceField.args.find( + (arg) => arg.name === typeArg.name, ); + if (!ifaceArg) { + context.reportError( + `Argument ${typeArg} must not be required type ${typeArg.type} ` + + `if not provided by the Interface field ${ifaceField}.`, + [typeArg.astNode, ifaceField.astNode], + ); + } } } } @@ -440,8 +437,8 @@ function validateTypeImplementsAncestors( if (!ifaceInterfaces.includes(transitive)) { context.reportError( transitive === type - ? `Type ${type.name} cannot implement ${iface.name} because it would create a circular reference.` - : `Type ${type.name} must implement ${transitive.name} because it is implemented by ${iface.name}.`, + ? `Type ${type} cannot implement ${iface} because it would create a circular reference.` + : `Type ${type} must implement ${transitive} because it is implemented by ${iface}.`, [ ...getAllImplementsInterfaceNodes(iface, transitive), ...getAllImplementsInterfaceNodes(type, iface), @@ -459,7 +456,7 @@ function validateUnionMembers( if (memberTypes.length === 0) { context.reportError( - `Union type ${union.name} must define one or more member types.`, + `Union type ${union} must define one or more member types.`, [union.astNode, ...union.extensionASTNodes], ); } @@ -468,7 +465,7 @@ function validateUnionMembers( for (const memberType of memberTypes) { if (includedTypeNames[memberType.name]) { context.reportError( - `Union type ${union.name} can only include type ${memberType.name} once.`, + `Union type ${union} can only include type ${memberType} once.`, getUnionMemberTypeNodes(union, memberType.name), ); continue; @@ -476,7 +473,7 @@ function validateUnionMembers( includedTypeNames[memberType.name] = true; if (!isObjectType(memberType)) { context.reportError( - `Union type ${union.name} can only include Object types, ` + + `Union type ${union} can only include Object types, ` + `it cannot include ${inspect(memberType)}.`, getUnionMemberTypeNodes(union, String(memberType)), ); @@ -492,7 +489,7 @@ function validateEnumValues( if (enumValues.length === 0) { context.reportError( - `Enum type ${enumType.name} must define one or more values.`, + `Enum type ${enumType} must define one or more values.`, [enumType.astNode, ...enumType.extensionASTNodes], ); } @@ -504,7 +501,7 @@ function validateEnumValues( validateName(context, enumValue); if (valueName === 'true' || valueName === 'false' || valueName === 'null') { context.reportError( - `Enum type ${enumType.name} cannot include value: ${valueName}.`, + `Enum type ${enumType} cannot include value: ${valueName}.`, enumValue.astNode, ); } @@ -519,7 +516,7 @@ function validateInputFields( if (fields.length === 0) { context.reportError( - `Input Object type ${inputObj.name} must define one or more fields.`, + `Input Object type ${inputObj} must define one or more fields.`, [inputObj.astNode, ...inputObj.extensionASTNodes], ); } @@ -532,7 +529,7 @@ function validateInputFields( // Ensure the type is an input type if (!isInputType(field.type)) { context.reportError( - `The type of ${inputObj.name}.${field.name} must be Input Type ` + + `The type of ${field} must be Input Type ` + `but got: ${inspect(field.type)}.`, field.astNode?.type, ); @@ -540,7 +537,7 @@ function validateInputFields( if (isRequiredInputField(field) && field.deprecationReason != null) { context.reportError( - `Required input field ${inputObj.name}.${field.name} cannot be deprecated.`, + `Required input field ${field} cannot be deprecated.`, [ getDeprecatedDirectiveNode(field.astNode), // istanbul ignore next (TODO need to write coverage tests) @@ -591,7 +588,7 @@ function createInputObjectCircularRefsValidator( const cyclePath = fieldPath.slice(cycleIndex); const pathStr = cyclePath.map((fieldObj) => fieldObj.name).join('.'); context.reportError( - `Cannot reference Input Object "${fieldType.name}" within itself through a series of non-null fields: "${pathStr}".`, + `Cannot reference Input Object "${fieldType}" within itself through a series of non-null fields: "${pathStr}".`, cyclePath.map((fieldObj) => fieldObj.astNode), ); } diff --git a/src/utilities/__tests__/buildClientSchema-test.ts b/src/utilities/__tests__/buildClientSchema-test.ts index d65f69b54a..448ac2bcdb 100644 --- a/src/utilities/__tests__/buildClientSchema-test.ts +++ b/src/utilities/__tests__/buildClientSchema-test.ts @@ -381,6 +381,7 @@ describe('Type System: build schema from introspection', () => { // rather than using the integers defined in the "server" schema. expect(clientFoodEnum.getValues()).to.deep.equal([ { + coordinate: 'Food.VEGETABLES', name: 'VEGETABLES', description: 'Foods that are vegetables.', value: 'VEGETABLES', @@ -389,6 +390,7 @@ describe('Type System: build schema from introspection', () => { astNode: undefined, }, { + coordinate: 'Food.FRUITS', name: 'FRUITS', description: null, value: 'FRUITS', @@ -397,6 +399,7 @@ describe('Type System: build schema from introspection', () => { astNode: undefined, }, { + coordinate: 'Food.OILS', name: 'OILS', description: null, value: 'OILS', diff --git a/src/utilities/__tests__/coerceInputValue-test.ts b/src/utilities/__tests__/coerceInputValue-test.ts index 32e127619a..c76bf0cc5d 100644 --- a/src/utilities/__tests__/coerceInputValue-test.ts +++ b/src/utilities/__tests__/coerceInputValue-test.ts @@ -1,8 +1,21 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; +import type { ObjMap } from '../../jsutils/ObjMap'; +import { invariant } from '../../jsutils/invariant'; +import { identityFunc } from '../../jsutils/identityFunc'; + +import { print } from '../../language/printer'; +import { parseValue } from '../../language/parser'; + import type { GraphQLInputType } from '../../type/definition'; -import { GraphQLInt } from '../../type/scalars'; +import { + GraphQLInt, + GraphQLFloat, + GraphQLString, + GraphQLBoolean, + GraphQLID, +} from '../../type/scalars'; import { GraphQLList, GraphQLNonNull, @@ -11,7 +24,7 @@ import { GraphQLInputObjectType, } from '../../type/definition'; -import { coerceInputValue } from '../coerceInputValue'; +import { coerceInputValue, coerceInputLiteral } from '../coerceInputValue'; interface CoerceResult { value: unknown; @@ -427,3 +440,244 @@ describe('coerceInputValue', () => { }); }); }); + +describe('coerceInputLiteral', () => { + function test( + valueText: string, + type: GraphQLInputType, + expected: unknown, + variables?: ObjMap, + ) { + const ast = parseValue(valueText); + const value = coerceInputLiteral(ast, type, variables); + expect(value).to.deep.equal(expected); + } + + function testWithVariables( + variables: ObjMap, + valueText: string, + type: GraphQLInputType, + expected: unknown, + ) { + test(valueText, type, expected, variables); + } + + it('converts according to input coercion rules', () => { + test('true', GraphQLBoolean, true); + test('false', GraphQLBoolean, false); + test('123', GraphQLInt, 123); + test('123', GraphQLFloat, 123); + test('123.456', GraphQLFloat, 123.456); + test('"abc123"', GraphQLString, 'abc123'); + test('123456', GraphQLID, '123456'); + test('"123456"', GraphQLID, '123456'); + }); + + it('does not convert when input coercion rules reject a value', () => { + test('123', GraphQLBoolean, undefined); + test('123.456', GraphQLInt, undefined); + test('true', GraphQLInt, undefined); + test('"123"', GraphQLInt, undefined); + test('"123"', GraphQLFloat, undefined); + test('123', GraphQLString, undefined); + test('true', GraphQLString, undefined); + test('123.456', GraphQLString, undefined); + test('123.456', GraphQLID, undefined); + }); + + it('convert using parseLiteral from a custom scalar type', () => { + const passthroughScalar = new GraphQLScalarType({ + name: 'PassthroughScalar', + parseLiteral(node) { + invariant(node.kind === 'StringValue'); + return node.value; + }, + parseValue: identityFunc, + }); + + test('"value"', passthroughScalar, 'value'); + + const printScalar = new GraphQLScalarType({ + name: 'PrintScalar', + parseLiteral(node) { + return `~~~${print(node)}~~~`; + }, + parseValue: identityFunc, + }); + + test('"value"', printScalar, '~~~"value"~~~'); + + const throwScalar = new GraphQLScalarType({ + name: 'ThrowScalar', + parseLiteral() { + throw new Error('Test'); + }, + parseValue: identityFunc, + }); + + test('value', throwScalar, undefined); + + const returnUndefinedScalar = new GraphQLScalarType({ + name: 'ReturnUndefinedScalar', + parseLiteral() { + return undefined; + }, + parseValue: identityFunc, + }); + + test('value', returnUndefinedScalar, undefined); + }); + + it('converts enum values according to input coercion rules', () => { + const testEnum = new GraphQLEnumType({ + name: 'TestColor', + values: { + RED: { value: 1 }, + GREEN: { value: 2 }, + BLUE: { value: 3 }, + NULL: { value: null }, + NAN: { value: NaN }, + NO_CUSTOM_VALUE: { value: undefined }, + }, + }); + + test('RED', testEnum, 1); + test('BLUE', testEnum, 3); + test('3', testEnum, undefined); + test('"BLUE"', testEnum, undefined); + test('null', testEnum, null); + test('NULL', testEnum, null); + test('NULL', new GraphQLNonNull(testEnum), null); + test('NAN', testEnum, NaN); + test('NO_CUSTOM_VALUE', testEnum, 'NO_CUSTOM_VALUE'); + }); + + // Boolean! + const nonNullBool = new GraphQLNonNull(GraphQLBoolean); + // [Boolean] + const listOfBool = new GraphQLList(GraphQLBoolean); + // [Boolean!] + const listOfNonNullBool = new GraphQLList(nonNullBool); + // [Boolean]! + const nonNullListOfBool = new GraphQLNonNull(listOfBool); + // [Boolean!]! + const nonNullListOfNonNullBool = new GraphQLNonNull(listOfNonNullBool); + + it('coerces to null unless non-null', () => { + test('null', GraphQLBoolean, null); + test('null', nonNullBool, undefined); + }); + + it('coerces lists of values', () => { + test('true', listOfBool, [true]); + test('123', listOfBool, undefined); + test('null', listOfBool, null); + test('[true, false]', listOfBool, [true, false]); + test('[true, 123]', listOfBool, undefined); + test('[true, null]', listOfBool, [true, null]); + test('{ true: true }', listOfBool, undefined); + }); + + it('coerces non-null lists of values', () => { + test('true', nonNullListOfBool, [true]); + test('123', nonNullListOfBool, undefined); + test('null', nonNullListOfBool, undefined); + test('[true, false]', nonNullListOfBool, [true, false]); + test('[true, 123]', nonNullListOfBool, undefined); + test('[true, null]', nonNullListOfBool, [true, null]); + }); + + it('coerces lists of non-null values', () => { + test('true', listOfNonNullBool, [true]); + test('123', listOfNonNullBool, undefined); + test('null', listOfNonNullBool, null); + test('[true, false]', listOfNonNullBool, [true, false]); + test('[true, 123]', listOfNonNullBool, undefined); + test('[true, null]', listOfNonNullBool, undefined); + }); + + it('coerces non-null lists of non-null values', () => { + test('true', nonNullListOfNonNullBool, [true]); + test('123', nonNullListOfNonNullBool, undefined); + test('null', nonNullListOfNonNullBool, undefined); + test('[true, false]', nonNullListOfNonNullBool, [true, false]); + test('[true, 123]', nonNullListOfNonNullBool, undefined); + test('[true, null]', nonNullListOfNonNullBool, undefined); + }); + + it('uses default values for unprovided fields', () => { + const type = new GraphQLInputObjectType({ + name: 'TestInput', + fields: { + int: { type: GraphQLInt, defaultValue: 42 }, + }, + }); + + test('{}', type, { int: 42 }); + }); + + const testInputObj = new GraphQLInputObjectType({ + name: 'TestInput', + fields: { + int: { type: GraphQLInt, defaultValue: 42 }, + bool: { type: GraphQLBoolean }, + requiredBool: { type: nonNullBool }, + }, + }); + + it('coerces input objects according to input coercion rules', () => { + test('null', testInputObj, null); + test('123', testInputObj, undefined); + test('[]', testInputObj, undefined); + test('{ requiredBool: true }', testInputObj, { + int: 42, + requiredBool: true, + }); + test('{ int: null, requiredBool: true }', testInputObj, { + int: null, + requiredBool: true, + }); + test('{ int: 123, requiredBool: false }', testInputObj, { + int: 123, + requiredBool: false, + }); + test('{ bool: true, requiredBool: false }', testInputObj, { + int: 42, + bool: true, + requiredBool: false, + }); + test('{ int: true, requiredBool: true }', testInputObj, undefined); + test('{ requiredBool: null }', testInputObj, undefined); + test('{ bool: true }', testInputObj, undefined); + test('{ requiredBool: true, unknown: 123 }', testInputObj, undefined); + }); + + it('accepts variable values assuming already coerced', () => { + test('$var', GraphQLBoolean, undefined); + testWithVariables({ var: true }, '$var', GraphQLBoolean, true); + testWithVariables({ var: null }, '$var', GraphQLBoolean, null); + testWithVariables({ var: null }, '$var', nonNullBool, undefined); + }); + + it('asserts variables are provided as items in lists', () => { + test('[ $foo ]', listOfBool, [null]); + test('[ $foo ]', listOfNonNullBool, undefined); + testWithVariables({ foo: true }, '[ $foo ]', listOfNonNullBool, [true]); + // Note: variables are expected to have already been coerced, so we + // do not expect the singleton wrapping behavior for variables. + testWithVariables({ foo: true }, '$foo', listOfNonNullBool, true); + testWithVariables({ foo: [true] }, '$foo', listOfNonNullBool, [true]); + }); + + it('omits input object fields for unprovided variables', () => { + test('{ int: $foo, bool: $foo, requiredBool: true }', testInputObj, { + int: 42, + requiredBool: true, + }); + test('{ requiredBool: $foo }', testInputObj, undefined); + testWithVariables({ foo: true }, '{ requiredBool: $foo }', testInputObj, { + int: 42, + requiredBool: true, + }); + }); +}); diff --git a/src/utilities/__tests__/findBreakingChanges-test.ts b/src/utilities/__tests__/findBreakingChanges-test.ts index a4ab722084..9a36089863 100644 --- a/src/utilities/__tests__/findBreakingChanges-test.ts +++ b/src/utilities/__tests__/findBreakingChanges-test.ts @@ -56,7 +56,7 @@ describe('findBreakingChanges', () => { }, { type: BreakingChangeType.FIELD_CHANGED_KIND, - description: 'Query.foo changed type from Float to String.', + description: 'Field Query.foo changed type from Float to String.', }, ]); expect(findBreakingChanges(oldSchema, oldSchema)).to.deep.equal([]); @@ -148,55 +148,56 @@ describe('findBreakingChanges', () => { expect(changes).to.deep.equal([ { type: BreakingChangeType.FIELD_REMOVED, - description: 'Type1.field2 was removed.', + description: 'Field Type1.field2 was removed.', }, { type: BreakingChangeType.FIELD_CHANGED_KIND, - description: 'Type1.field3 changed type from String to Boolean.', + description: 'Field Type1.field3 changed type from String to Boolean.', }, { type: BreakingChangeType.FIELD_CHANGED_KIND, - description: 'Type1.field4 changed type from TypeA to TypeB.', + description: 'Field Type1.field4 changed type from TypeA to TypeB.', }, { type: BreakingChangeType.FIELD_CHANGED_KIND, - description: 'Type1.field6 changed type from String to [String].', + description: 'Field Type1.field6 changed type from String to [String].', }, { type: BreakingChangeType.FIELD_CHANGED_KIND, - description: 'Type1.field7 changed type from [String] to String.', + description: 'Field Type1.field7 changed type from [String] to String.', }, { type: BreakingChangeType.FIELD_CHANGED_KIND, - description: 'Type1.field9 changed type from Int! to Int.', + description: 'Field Type1.field9 changed type from Int! to Int.', }, { type: BreakingChangeType.FIELD_CHANGED_KIND, - description: 'Type1.field10 changed type from [Int]! to [Int].', + description: 'Field Type1.field10 changed type from [Int]! to [Int].', }, { type: BreakingChangeType.FIELD_CHANGED_KIND, - description: 'Type1.field11 changed type from Int to [Int]!.', + description: 'Field Type1.field11 changed type from Int to [Int]!.', }, { type: BreakingChangeType.FIELD_CHANGED_KIND, - description: 'Type1.field13 changed type from [Int!] to [Int].', + description: 'Field Type1.field13 changed type from [Int!] to [Int].', }, { type: BreakingChangeType.FIELD_CHANGED_KIND, - description: 'Type1.field14 changed type from [Int] to [[Int]].', + description: 'Field Type1.field14 changed type from [Int] to [[Int]].', }, { type: BreakingChangeType.FIELD_CHANGED_KIND, - description: 'Type1.field15 changed type from [[Int]] to [Int].', + description: 'Field Type1.field15 changed type from [[Int]] to [Int].', }, { type: BreakingChangeType.FIELD_CHANGED_KIND, - description: 'Type1.field16 changed type from Int! to [Int]!.', + description: 'Field Type1.field16 changed type from Int! to [Int]!.', }, { type: BreakingChangeType.FIELD_CHANGED_KIND, - description: 'Type1.field18 changed type from [[Int!]!] to [[Int!]].', + description: + 'Field Type1.field18 changed type from [[Int!]!] to [[Int!]].', }, ]); }); @@ -309,8 +310,7 @@ describe('findBreakingChanges', () => { expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ { type: BreakingChangeType.REQUIRED_INPUT_FIELD_ADDED, - description: - 'A required field requiredField on input type InputType1 was added.', + description: 'A required field InputType1.requiredField was added.', }, ]); }); @@ -359,7 +359,7 @@ describe('findBreakingChanges', () => { expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ { type: BreakingChangeType.VALUE_REMOVED_FROM_ENUM, - description: 'VALUE1 was removed from enum type EnumType1.', + description: 'Enum value EnumType1.VALUE1 was removed.', }, ]); }); @@ -388,15 +388,15 @@ describe('findBreakingChanges', () => { expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ { type: BreakingChangeType.ARG_REMOVED, - description: 'Interface1.field1 arg arg1 was removed.', + description: 'Argument Interface1.field1(arg1:) was removed.', }, { type: BreakingChangeType.ARG_REMOVED, - description: 'Interface1.field1 arg objectArg was removed.', + description: 'Argument Interface1.field1(objectArg:) was removed.', }, { type: BreakingChangeType.ARG_REMOVED, - description: 'Type1.field1 arg name was removed.', + description: 'Argument Type1.field1(name:) was removed.', }, ]); }); @@ -450,62 +450,62 @@ describe('findBreakingChanges', () => { { type: BreakingChangeType.ARG_CHANGED_KIND, description: - 'Type1.field1 arg arg1 has changed type from String to Int.', + 'Argument Type1.field1(arg1:) has changed type from String to Int.', }, { type: BreakingChangeType.ARG_CHANGED_KIND, description: - 'Type1.field1 arg arg2 has changed type from String to [String].', + 'Argument Type1.field1(arg2:) has changed type from String to [String].', }, { type: BreakingChangeType.ARG_CHANGED_KIND, description: - 'Type1.field1 arg arg3 has changed type from [String] to String.', + 'Argument Type1.field1(arg3:) has changed type from [String] to String.', }, { type: BreakingChangeType.ARG_CHANGED_KIND, description: - 'Type1.field1 arg arg4 has changed type from String to String!.', + 'Argument Type1.field1(arg4:) has changed type from String to String!.', }, { type: BreakingChangeType.ARG_CHANGED_KIND, description: - 'Type1.field1 arg arg5 has changed type from String! to Int.', + 'Argument Type1.field1(arg5:) has changed type from String! to Int.', }, { type: BreakingChangeType.ARG_CHANGED_KIND, description: - 'Type1.field1 arg arg6 has changed type from String! to Int!.', + 'Argument Type1.field1(arg6:) has changed type from String! to Int!.', }, { type: BreakingChangeType.ARG_CHANGED_KIND, description: - 'Type1.field1 arg arg8 has changed type from Int to [Int]!.', + 'Argument Type1.field1(arg8:) has changed type from Int to [Int]!.', }, { type: BreakingChangeType.ARG_CHANGED_KIND, description: - 'Type1.field1 arg arg9 has changed type from [Int] to [Int!].', + 'Argument Type1.field1(arg9:) has changed type from [Int] to [Int!].', }, { type: BreakingChangeType.ARG_CHANGED_KIND, description: - 'Type1.field1 arg arg11 has changed type from [Int] to [[Int]].', + 'Argument Type1.field1(arg11:) has changed type from [Int] to [[Int]].', }, { type: BreakingChangeType.ARG_CHANGED_KIND, description: - 'Type1.field1 arg arg12 has changed type from [[Int]] to [Int].', + 'Argument Type1.field1(arg12:) has changed type from [[Int]] to [Int].', }, { type: BreakingChangeType.ARG_CHANGED_KIND, description: - 'Type1.field1 arg arg13 has changed type from Int! to [Int]!.', + 'Argument Type1.field1(arg13:) has changed type from Int! to [Int]!.', }, { type: BreakingChangeType.ARG_CHANGED_KIND, description: - 'Type1.field1 arg arg15 has changed type from [[Int]!] to [[Int!]!].', + 'Argument Type1.field1(arg15:) has changed type from [[Int]!] to [[Int!]!].', }, ]); }); @@ -531,7 +531,8 @@ describe('findBreakingChanges', () => { expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ { type: BreakingChangeType.REQUIRED_ARG_ADDED, - description: 'A required arg newRequiredArg on Type1.field1 was added.', + description: + 'A required argument Type1.field1(newRequiredArg:) was added.', }, ]); }); @@ -720,12 +721,11 @@ describe('findBreakingChanges', () => { { type: BreakingChangeType.ARG_CHANGED_KIND, description: - 'ArgThatChanges.field1 arg id has changed type from Float to String.', + 'Argument ArgThatChanges.field1(id:) has changed type from Float to String.', }, { type: BreakingChangeType.VALUE_REMOVED_FROM_ENUM, - description: - 'VALUE0 was removed from enum type EnumTypeThatLosesAValue.', + description: 'Enum value EnumTypeThatLosesAValue.VALUE0 was removed.', }, { type: BreakingChangeType.IMPLEMENTED_INTERFACE_REMOVED, @@ -744,34 +744,35 @@ describe('findBreakingChanges', () => { }, { type: BreakingChangeType.FIELD_REMOVED, - description: 'TypeThatHasBreakingFieldChanges.field1 was removed.', + description: + 'Field TypeThatHasBreakingFieldChanges.field1 was removed.', }, { type: BreakingChangeType.FIELD_CHANGED_KIND, description: - 'TypeThatHasBreakingFieldChanges.field2 changed type from String to Boolean.', + 'Field TypeThatHasBreakingFieldChanges.field2 changed type from String to Boolean.', }, { type: BreakingChangeType.DIRECTIVE_REMOVED, - description: 'DirectiveThatIsRemoved was removed.', + description: 'Directive @DirectiveThatIsRemoved was removed.', }, { type: BreakingChangeType.DIRECTIVE_ARG_REMOVED, - description: 'arg1 was removed from DirectiveThatRemovesArg.', + description: 'Argument @DirectiveThatRemovesArg(arg1:) was removed.', }, { type: BreakingChangeType.REQUIRED_DIRECTIVE_ARG_ADDED, description: - 'A required arg arg1 on directive NonNullDirectiveAdded was added.', + 'A required argument @NonNullDirectiveAdded(arg1:) was added.', }, { type: BreakingChangeType.DIRECTIVE_REPEATABLE_REMOVED, description: - 'Repeatable flag was removed from DirectiveThatWasRepeatable.', + 'Repeatable flag was removed from @DirectiveThatWasRepeatable.', }, { type: BreakingChangeType.DIRECTIVE_LOCATION_REMOVED, - description: 'QUERY was removed from DirectiveName.', + description: 'QUERY was removed from @DirectiveName.', }, ]); }); @@ -789,7 +790,7 @@ describe('findBreakingChanges', () => { expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ { type: BreakingChangeType.DIRECTIVE_REMOVED, - description: 'DirectiveThatIsRemoved was removed.', + description: 'Directive @DirectiveThatIsRemoved was removed.', }, ]); }); @@ -808,7 +809,7 @@ describe('findBreakingChanges', () => { expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ { type: BreakingChangeType.DIRECTIVE_REMOVED, - description: `${GraphQLDeprecatedDirective.name} was removed.`, + description: `Directive ${GraphQLDeprecatedDirective} was removed.`, }, ]); }); @@ -825,7 +826,7 @@ describe('findBreakingChanges', () => { expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ { type: BreakingChangeType.DIRECTIVE_ARG_REMOVED, - description: 'arg1 was removed from DirectiveWithArg.', + description: 'Argument @DirectiveWithArg(arg1:) was removed.', }, ]); }); @@ -847,7 +848,7 @@ describe('findBreakingChanges', () => { { type: BreakingChangeType.REQUIRED_DIRECTIVE_ARG_ADDED, description: - 'A required arg newRequiredArg on directive DirectiveName was added.', + 'A required argument @DirectiveName(newRequiredArg:) was added.', }, ]); }); @@ -864,7 +865,7 @@ describe('findBreakingChanges', () => { expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ { type: BreakingChangeType.DIRECTIVE_REPEATABLE_REMOVED, - description: 'Repeatable flag was removed from DirectiveName.', + description: 'Repeatable flag was removed from @DirectiveName.', }, ]); }); @@ -881,7 +882,7 @@ describe('findBreakingChanges', () => { expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ { type: BreakingChangeType.DIRECTIVE_LOCATION_REMOVED, - description: 'QUERY was removed from DirectiveName.', + description: 'QUERY was removed from @DirectiveName.', }, ]); }); @@ -941,27 +942,27 @@ describe('findDangerousChanges', () => { { type: DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE, description: - 'Type1.field1 arg withDefaultValue defaultValue was removed.', + 'Type1.field1(withDefaultValue:) defaultValue was removed.', }, { type: DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE, description: - 'Type1.field1 arg stringArg has changed defaultValue from "test" to "Test".', + 'Type1.field1(stringArg:) has changed defaultValue from "test" to "Test".', }, { type: DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE, description: - 'Type1.field1 arg emptyArray has changed defaultValue from [] to [7].', + 'Type1.field1(emptyArray:) has changed defaultValue from [] to [7].', }, { type: DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE, description: - 'Type1.field1 arg valueArray has changed defaultValue from [["a", "b"], ["c"]] to [["b", "a"], ["d"]].', + 'Type1.field1(valueArray:) has changed defaultValue from [["a", "b"], ["c"]] to [["b", "a"], ["d"]].', }, { type: DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE, description: - 'Type1.field1 arg complexObject has changed defaultValue from {innerInputArray: [{arrayField: [1, 2, 3]}]} to {innerInputArray: [{arrayField: [3, 2, 1]}]}.', + 'Type1.field1(complexObject:) has changed defaultValue from {innerInputArray: [{arrayField: [1, 2, 3]}]} to {innerInputArray: [{arrayField: [3, 2, 1]}]}.', }, ]); }); @@ -1049,7 +1050,7 @@ describe('findDangerousChanges', () => { expect(findDangerousChanges(oldSchema, newSchema)).to.deep.equal([ { type: DangerousChangeType.VALUE_ADDED_TO_ENUM, - description: 'VALUE2 was added to enum type EnumType1.', + description: 'Enum value EnumType1.VALUE2 was added.', }, ]); }); @@ -1141,8 +1142,7 @@ describe('findDangerousChanges', () => { expect(findDangerousChanges(oldSchema, newSchema)).to.deep.equal([ { type: DangerousChangeType.OPTIONAL_INPUT_FIELD_ADDED, - description: - 'An optional field field2 on input type InputType1 was added.', + description: 'An optional field InputType1.field2 was added.', }, ]); }); @@ -1187,12 +1187,12 @@ describe('findDangerousChanges', () => { expect(findDangerousChanges(oldSchema, newSchema)).to.deep.equal([ { type: DangerousChangeType.VALUE_ADDED_TO_ENUM, - description: 'VALUE2 was added to enum type EnumType1.', + description: 'Enum value EnumType1.VALUE2 was added.', }, { type: DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE, description: - 'Type1.field1 arg argThatChangesDefaultValue has changed defaultValue from "test" to "Test".', + 'Type1.field1(argThatChangesDefaultValue:) has changed defaultValue from "test" to "Test".', }, { type: DangerousChangeType.IMPLEMENTED_INTERFACE_ADDED, @@ -1223,7 +1223,7 @@ describe('findDangerousChanges', () => { expect(findDangerousChanges(oldSchema, newSchema)).to.deep.equal([ { type: DangerousChangeType.OPTIONAL_ARG_ADDED, - description: 'An optional arg arg2 on Type1.field1 was added.', + description: 'An optional argument Type1.field1(arg2:) was added.', }, ]); }); diff --git a/src/utilities/__tests__/resolveSchemaCoordinate-test.ts b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts new file mode 100644 index 0000000000..bf7eb0af06 --- /dev/null +++ b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts @@ -0,0 +1,185 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import type { + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLObjectType, +} from '../../type/definition'; +import type { GraphQLDirective } from '../../type/directives'; + +import { buildSchema } from '../buildASTSchema'; +import { resolveSchemaCoordinate } from '../resolveSchemaCoordinate'; + +describe('resolveSchemaCoordinate', () => { + const schema = buildSchema(` + type Query { + searchBusiness(criteria: SearchCriteria!): [Business] + } + + input SearchCriteria { + name: String + filter: SearchFilter + } + + enum SearchFilter { + OPEN_NOW + DELIVERS_TAKEOUT + VEGETARIAN_MENU + } + + type Business { + id: ID + name: String + email: String @private(scope: "loggedIn") + } + + directive @private(scope: String!) on FIELD_DEFINITION + `); + + it('resolves a Named Type', () => { + expect(resolveSchemaCoordinate(schema, 'Business')).to.deep.equal({ + kind: 'NamedType', + type: schema.getType('Business'), + }); + + expect(resolveSchemaCoordinate(schema, 'String')).to.deep.equal({ + kind: 'NamedType', + type: schema.getType('String'), + }); + + expect(resolveSchemaCoordinate(schema, 'private')).to.deep.equal(undefined); + + expect(resolveSchemaCoordinate(schema, 'Unknown')).to.deep.equal(undefined); + }); + + it('resolves a Type Field', () => { + const type = schema.getType('Business') as GraphQLObjectType; + const field = type.getFields().name; + expect(resolveSchemaCoordinate(schema, 'Business.name')).to.deep.equal({ + kind: 'Field', + type, + field, + }); + + expect(resolveSchemaCoordinate(schema, 'Business.unknown')).to.deep.equal( + undefined, + ); + + expect(resolveSchemaCoordinate(schema, 'Unknown.field')).to.deep.equal( + undefined, + ); + + expect(resolveSchemaCoordinate(schema, 'String.field')).to.deep.equal( + undefined, + ); + }); + + it('does not resolve meta-fields', () => { + expect( + resolveSchemaCoordinate(schema, 'Business.__typename'), + ).to.deep.equal(undefined); + }); + + it('resolves a Input Field', () => { + const type = schema.getType('SearchCriteria') as GraphQLInputObjectType; + const inputField = type.getFields().filter; + expect( + resolveSchemaCoordinate(schema, 'SearchCriteria.filter'), + ).to.deep.equal({ + kind: 'InputField', + type, + inputField, + }); + + expect( + resolveSchemaCoordinate(schema, 'SearchCriteria.unknown'), + ).to.deep.equal(undefined); + }); + + it('resolves a Enum Value', () => { + const type = schema.getType('SearchFilter') as GraphQLEnumType; + const enumValue = type.getValue('OPEN_NOW'); + expect( + resolveSchemaCoordinate(schema, 'SearchFilter.OPEN_NOW'), + ).to.deep.equal({ + kind: 'EnumValue', + type, + enumValue, + }); + + expect( + resolveSchemaCoordinate(schema, 'SearchFilter.UNKNOWN'), + ).to.deep.equal(undefined); + }); + + it('resolves a Field Argument', () => { + const type = schema.getType('Query') as GraphQLObjectType; + const field = type.getFields().searchBusiness; + const fieldArgument = field.args.find((arg) => arg.name === 'criteria'); + expect( + resolveSchemaCoordinate(schema, 'Query.searchBusiness(criteria:)'), + ).to.deep.equal({ + kind: 'FieldArgument', + type, + field, + fieldArgument, + }); + + expect( + resolveSchemaCoordinate(schema, 'Business.name(unknown:)'), + ).to.deep.equal(undefined); + + expect( + resolveSchemaCoordinate(schema, 'Unknown.field(arg:)'), + ).to.deep.equal(undefined); + + expect( + resolveSchemaCoordinate(schema, 'Business.unknown(arg:)'), + ).to.deep.equal(undefined); + + expect( + resolveSchemaCoordinate(schema, 'SearchCriteria.name(arg:)'), + ).to.deep.equal(undefined); + }); + + it('resolves a Directive', () => { + expect(resolveSchemaCoordinate(schema, '@private')).to.deep.equal({ + kind: 'Directive', + directive: schema.getDirective('private'), + }); + + expect(resolveSchemaCoordinate(schema, '@deprecated')).to.deep.equal({ + kind: 'Directive', + directive: schema.getDirective('deprecated'), + }); + + expect(resolveSchemaCoordinate(schema, '@unknown')).to.deep.equal( + undefined, + ); + + expect(resolveSchemaCoordinate(schema, '@Business')).to.deep.equal( + undefined, + ); + }); + + it('resolves a Directive Argument', () => { + const directive = schema.getDirective('private') as GraphQLDirective; + const directiveArgument = directive.args.find( + (arg) => arg.name === 'scope', + ); + expect(resolveSchemaCoordinate(schema, '@private(scope:)')).to.deep.equal({ + kind: 'DirectiveArgument', + directive, + directiveArgument, + }); + + expect(resolveSchemaCoordinate(schema, '@private(unknown:)')).to.deep.equal( + undefined, + ); + + expect(resolveSchemaCoordinate(schema, '@unknown(arg:)')).to.deep.equal( + undefined, + ); + }); +}); diff --git a/src/utilities/__tests__/valueFromAST-test.ts b/src/utilities/__tests__/valueFromAST-test.ts deleted file mode 100644 index 6c08ccf15c..0000000000 --- a/src/utilities/__tests__/valueFromAST-test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { expect } from 'chai'; -import { describe, it } from 'mocha'; - -import type { ObjMap } from '../../jsutils/ObjMap'; -import { invariant } from '../../jsutils/invariant'; -import { identityFunc } from '../../jsutils/identityFunc'; - -import { parseValue } from '../../language/parser'; - -import type { GraphQLInputType } from '../../type/definition'; -import { - GraphQLInt, - GraphQLFloat, - GraphQLString, - GraphQLBoolean, - GraphQLID, -} from '../../type/scalars'; -import { - GraphQLList, - GraphQLNonNull, - GraphQLScalarType, - GraphQLEnumType, - GraphQLInputObjectType, -} from '../../type/definition'; - -import { valueFromAST } from '../valueFromAST'; - -describe('valueFromAST', () => { - function expectValueFrom( - valueText: string, - type: GraphQLInputType, - variables?: ObjMap, - ) { - const ast = parseValue(valueText); - const value = valueFromAST(ast, type, variables); - return expect(value); - } - - it('rejects empty input', () => { - expect(valueFromAST(null, GraphQLBoolean)).to.deep.equal(undefined); - }); - - it('converts according to input coercion rules', () => { - expectValueFrom('true', GraphQLBoolean).to.equal(true); - expectValueFrom('false', GraphQLBoolean).to.equal(false); - expectValueFrom('123', GraphQLInt).to.equal(123); - expectValueFrom('123', GraphQLFloat).to.equal(123); - expectValueFrom('123.456', GraphQLFloat).to.equal(123.456); - expectValueFrom('"abc123"', GraphQLString).to.equal('abc123'); - expectValueFrom('123456', GraphQLID).to.equal('123456'); - expectValueFrom('"123456"', GraphQLID).to.equal('123456'); - }); - - it('does not convert when input coercion rules reject a value', () => { - expectValueFrom('123', GraphQLBoolean).to.equal(undefined); - expectValueFrom('123.456', GraphQLInt).to.equal(undefined); - expectValueFrom('true', GraphQLInt).to.equal(undefined); - expectValueFrom('"123"', GraphQLInt).to.equal(undefined); - expectValueFrom('"123"', GraphQLFloat).to.equal(undefined); - expectValueFrom('123', GraphQLString).to.equal(undefined); - expectValueFrom('true', GraphQLString).to.equal(undefined); - expectValueFrom('123.456', GraphQLString).to.equal(undefined); - }); - - it('convert using parseLiteral from a custom scalar type', () => { - const passthroughScalar = new GraphQLScalarType({ - name: 'PassthroughScalar', - parseLiteral(node) { - invariant(node.kind === 'StringValue'); - return node.value; - }, - parseValue: identityFunc, - }); - - expectValueFrom('"value"', passthroughScalar).to.equal('value'); - - const throwScalar = new GraphQLScalarType({ - name: 'ThrowScalar', - parseLiteral() { - throw new Error('Test'); - }, - parseValue: identityFunc, - }); - - expectValueFrom('value', throwScalar).to.equal(undefined); - - const returnUndefinedScalar = new GraphQLScalarType({ - name: 'ReturnUndefinedScalar', - parseLiteral() { - return undefined; - }, - parseValue: identityFunc, - }); - - expectValueFrom('value', returnUndefinedScalar).to.equal(undefined); - }); - - it('converts enum values according to input coercion rules', () => { - const testEnum = new GraphQLEnumType({ - name: 'TestColor', - values: { - RED: { value: 1 }, - GREEN: { value: 2 }, - BLUE: { value: 3 }, - NULL: { value: null }, - NAN: { value: NaN }, - NO_CUSTOM_VALUE: { value: undefined }, - }, - }); - - expectValueFrom('RED', testEnum).to.equal(1); - expectValueFrom('BLUE', testEnum).to.equal(3); - expectValueFrom('3', testEnum).to.equal(undefined); - expectValueFrom('"BLUE"', testEnum).to.equal(undefined); - expectValueFrom('null', testEnum).to.equal(null); - expectValueFrom('NULL', testEnum).to.equal(null); - expectValueFrom('NULL', new GraphQLNonNull(testEnum)).to.equal(null); - expectValueFrom('NAN', testEnum).to.deep.equal(NaN); - expectValueFrom('NO_CUSTOM_VALUE', testEnum).to.equal('NO_CUSTOM_VALUE'); - }); - - // Boolean! - const nonNullBool = new GraphQLNonNull(GraphQLBoolean); - // [Boolean] - const listOfBool = new GraphQLList(GraphQLBoolean); - // [Boolean!] - const listOfNonNullBool = new GraphQLList(nonNullBool); - // [Boolean]! - const nonNullListOfBool = new GraphQLNonNull(listOfBool); - // [Boolean!]! - const nonNullListOfNonNullBool = new GraphQLNonNull(listOfNonNullBool); - - it('coerces to null unless non-null', () => { - expectValueFrom('null', GraphQLBoolean).to.equal(null); - expectValueFrom('null', nonNullBool).to.equal(undefined); - }); - - it('coerces lists of values', () => { - expectValueFrom('true', listOfBool).to.deep.equal([true]); - expectValueFrom('123', listOfBool).to.equal(undefined); - expectValueFrom('null', listOfBool).to.equal(null); - expectValueFrom('[true, false]', listOfBool).to.deep.equal([true, false]); - expectValueFrom('[true, 123]', listOfBool).to.equal(undefined); - expectValueFrom('[true, null]', listOfBool).to.deep.equal([true, null]); - expectValueFrom('{ true: true }', listOfBool).to.equal(undefined); - }); - - it('coerces non-null lists of values', () => { - expectValueFrom('true', nonNullListOfBool).to.deep.equal([true]); - expectValueFrom('123', nonNullListOfBool).to.equal(undefined); - expectValueFrom('null', nonNullListOfBool).to.equal(undefined); - expectValueFrom('[true, false]', nonNullListOfBool).to.deep.equal([ - true, - false, - ]); - expectValueFrom('[true, 123]', nonNullListOfBool).to.equal(undefined); - expectValueFrom('[true, null]', nonNullListOfBool).to.deep.equal([ - true, - null, - ]); - }); - - it('coerces lists of non-null values', () => { - expectValueFrom('true', listOfNonNullBool).to.deep.equal([true]); - expectValueFrom('123', listOfNonNullBool).to.equal(undefined); - expectValueFrom('null', listOfNonNullBool).to.equal(null); - expectValueFrom('[true, false]', listOfNonNullBool).to.deep.equal([ - true, - false, - ]); - expectValueFrom('[true, 123]', listOfNonNullBool).to.equal(undefined); - expectValueFrom('[true, null]', listOfNonNullBool).to.equal(undefined); - }); - - it('coerces non-null lists of non-null values', () => { - expectValueFrom('true', nonNullListOfNonNullBool).to.deep.equal([true]); - expectValueFrom('123', nonNullListOfNonNullBool).to.equal(undefined); - expectValueFrom('null', nonNullListOfNonNullBool).to.equal(undefined); - expectValueFrom('[true, false]', nonNullListOfNonNullBool).to.deep.equal([ - true, - false, - ]); - expectValueFrom('[true, 123]', nonNullListOfNonNullBool).to.equal( - undefined, - ); - expectValueFrom('[true, null]', nonNullListOfNonNullBool).to.equal( - undefined, - ); - }); - - const testInputObj = new GraphQLInputObjectType({ - name: 'TestInput', - fields: { - int: { type: GraphQLInt, defaultValue: 42 }, - bool: { type: GraphQLBoolean }, - requiredBool: { type: nonNullBool }, - }, - }); - - it('coerces input objects according to input coercion rules', () => { - expectValueFrom('null', testInputObj).to.equal(null); - expectValueFrom('123', testInputObj).to.equal(undefined); - expectValueFrom('[]', testInputObj).to.equal(undefined); - expectValueFrom( - '{ int: 123, requiredBool: false }', - testInputObj, - ).to.deep.equal({ - int: 123, - requiredBool: false, - }); - expectValueFrom( - '{ bool: true, requiredBool: false }', - testInputObj, - ).to.deep.equal({ - int: 42, - bool: true, - requiredBool: false, - }); - expectValueFrom('{ int: true, requiredBool: true }', testInputObj).to.equal( - undefined, - ); - expectValueFrom('{ requiredBool: null }', testInputObj).to.equal(undefined); - expectValueFrom('{ bool: true }', testInputObj).to.equal(undefined); - }); - - it('accepts variable values assuming already coerced', () => { - expectValueFrom('$var', GraphQLBoolean, {}).to.equal(undefined); - expectValueFrom('$var', GraphQLBoolean, { var: true }).to.equal(true); - expectValueFrom('$var', GraphQLBoolean, { var: null }).to.equal(null); - expectValueFrom('$var', nonNullBool, { var: null }).to.equal(undefined); - }); - - it('asserts variables are provided as items in lists', () => { - expectValueFrom('[ $foo ]', listOfBool, {}).to.deep.equal([null]); - expectValueFrom('[ $foo ]', listOfNonNullBool, {}).to.equal(undefined); - expectValueFrom('[ $foo ]', listOfNonNullBool, { - foo: true, - }).to.deep.equal([true]); - // Note: variables are expected to have already been coerced, so we - // do not expect the singleton wrapping behavior for variables. - expectValueFrom('$foo', listOfNonNullBool, { foo: true }).to.equal(true); - expectValueFrom('$foo', listOfNonNullBool, { foo: [true] }).to.deep.equal([ - true, - ]); - }); - - it('omits input object fields for unprovided variables', () => { - expectValueFrom( - '{ int: $foo, bool: $foo, requiredBool: true }', - testInputObj, - {}, - ).to.deep.equal({ int: 42, requiredBool: true }); - - expectValueFrom('{ requiredBool: $foo }', testInputObj, {}).to.equal( - undefined, - ); - - expectValueFrom('{ requiredBool: $foo }', testInputObj, { - foo: true, - }).to.deep.equal({ - int: 42, - requiredBool: true, - }); - }); -}); diff --git a/src/utilities/buildClientSchema.ts b/src/utilities/buildClientSchema.ts index e2d55eecb1..1bfe2e6901 100644 --- a/src/utilities/buildClientSchema.ts +++ b/src/utilities/buildClientSchema.ts @@ -3,7 +3,7 @@ import { devAssert } from '../jsutils/devAssert'; import { keyValMap } from '../jsutils/keyValMap'; import { isObjectLike } from '../jsutils/isObjectLike'; -import { parseValue } from '../language/parser'; +import { parseConstValue } from '../language/parser'; import type { GraphQLSchemaValidationOptions } from '../type/schema'; import type { @@ -47,7 +47,7 @@ import type { IntrospectionTypeRef, IntrospectionNamedTypeRef, } from './getIntrospectionQuery'; -import { valueFromAST } from './valueFromAST'; +import { coerceInputLiteral } from './coerceInputValue'; /** * Build a GraphQLSchema for use by client tools. @@ -368,7 +368,10 @@ export function buildClientSchema( const defaultValue = inputValueIntrospection.defaultValue != null - ? valueFromAST(parseValue(inputValueIntrospection.defaultValue), type) + ? coerceInputLiteral( + parseConstValue(inputValueIntrospection.defaultValue), + type, + ) : undefined; return { description: inputValueIntrospection.description, diff --git a/src/utilities/coerceInputValue.ts b/src/utilities/coerceInputValue.ts index 5515b2c625..021194b0c0 100644 --- a/src/utilities/coerceInputValue.ts +++ b/src/utilities/coerceInputValue.ts @@ -1,8 +1,12 @@ +import type { Maybe } from '../jsutils/Maybe'; +import type { ObjMap } from '../jsutils/ObjMap'; import type { Path } from '../jsutils/Path'; +import { hasOwnProperty } from '../jsutils/hasOwnProperty'; import { inspect } from '../jsutils/inspect'; import { invariant } from '../jsutils/invariant'; import { didYouMean } from '../jsutils/didYouMean'; import { isObjectLike } from '../jsutils/isObjectLike'; +import { keyMap } from '../jsutils/keyMap'; import { suggestionList } from '../jsutils/suggestionList'; import { printPathArray } from '../jsutils/printPathArray'; import { addPath, pathToArray } from '../jsutils/Path'; @@ -13,11 +17,16 @@ import { GraphQLError } from '../error/GraphQLError'; import type { GraphQLInputType } from '../type/definition'; import { isLeafType, + assertLeafType, isInputObjectType, isListType, isNonNullType, + isRequiredInputField, } from '../type/definition'; +import type { ValueNode } from '../language/ast'; +import { Kind } from '../language/kinds'; + type OnErrorCB = ( path: ReadonlyArray, invalidValue: unknown, @@ -186,3 +195,125 @@ function coerceInputValueImpl( // istanbul ignore next (Not reachable. All possible input types have been considered) invariant(false, 'Unexpected input type: ' + inspect(type)); } + +/** + * Produces a coerced "internal" JavaScript value given a GraphQL Value AST. + * + * Returns `undefined` when the value could not be validly coerced according to + * the provided type. + */ +export function coerceInputLiteral( + valueNode: ValueNode, + type: GraphQLInputType, + variables?: Maybe>, +): unknown { + if (valueNode.kind === Kind.VARIABLE) { + if (!variables || isMissingVariable(valueNode, variables)) { + return; // Invalid: intentionally return no value. + } + const variableValue = variables[valueNode.name.value]; + if (variableValue === null && isNonNullType(type)) { + return; // Invalid: intentionally return no value. + } + // Note: This does no further checking that this variable is correct. + // This assumes validated has checked this variable is of the correct type. + return variableValue; + } + + if (isNonNullType(type)) { + if (valueNode.kind === Kind.NULL) { + return; // Invalid: intentionally return no value. + } + return coerceInputLiteral(valueNode, type.ofType, variables); + } + + if (valueNode.kind === Kind.NULL) { + return null; // Explicitly return the value null. + } + + if (isListType(type)) { + if (valueNode.kind !== Kind.LIST) { + // Lists accept a non-list value as a list of one. + const itemValue = coerceInputLiteral(valueNode, type.ofType, variables); + if (itemValue === undefined) { + return; // Invalid: intentionally return no value. + } + return [itemValue]; + } + const coercedValue: Array = []; + for (const itemNode of valueNode.values) { + let itemValue = coerceInputLiteral(itemNode, type.ofType, variables); + if (itemValue === undefined) { + if ( + isMissingVariable(itemNode, variables) && + !isNonNullType(type.ofType) + ) { + // A missing variable within a list is coerced to null. + itemValue = null; + } else { + return; // Invalid: intentionally return no value. + } + } + coercedValue.push(itemValue); + } + return coercedValue; + } + + if (isInputObjectType(type)) { + if (valueNode.kind !== Kind.OBJECT) { + return; // Invalid: intentionally return no value. + } + + const coercedValue: { [field: string]: unknown } = {}; + const fieldDefs = type.getFields(); + const hasUndefinedField = valueNode.fields.some( + (field) => !hasOwnProperty(fieldDefs, field.name.value), + ); + if (hasUndefinedField) { + return; // Invalid: intentionally return no value. + } + const fieldNodes = keyMap(valueNode.fields, (field) => field.name.value); + for (const field of Object.values(fieldDefs)) { + const fieldNode = fieldNodes[field.name]; + if (!fieldNode || isMissingVariable(fieldNode.value, variables)) { + if (isRequiredInputField(field)) { + return; // Invalid: intentionally return no value. + } + if (field.defaultValue !== undefined) { + coercedValue[field.name] = field.defaultValue; + } + } else { + const fieldValue = coerceInputLiteral( + fieldNode.value, + field.type, + variables, + ); + if (fieldValue === undefined) { + return; // Invalid: intentionally return no value. + } + coercedValue[field.name] = fieldValue; + } + } + return coercedValue; + } + + const leafType = assertLeafType(type); + + try { + return leafType.parseLiteral(valueNode, variables); + } catch (_error) { + // Invalid: ignore error and intentionally return no value. + } +} + +// Returns true if the provided valueNode is a variable which is not defined +// in the set of variables. +function isMissingVariable( + valueNode: ValueNode, + variables: Maybe>, +): boolean { + return ( + valueNode.kind === Kind.VARIABLE && + (variables == null || variables[valueNode.name.value] === undefined) + ); +} diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index 7b82e2ce39..2597998478 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -80,7 +80,7 @@ import { GraphQLInputObjectType, } from '../type/definition'; -import { valueFromAST } from './valueFromAST'; +import { coerceInputLiteral } from './coerceInputValue'; interface Options extends GraphQLSchemaValidationOptions { /** @@ -491,7 +491,9 @@ export function extendSchemaImpl( argConfigMap[arg.name.value] = { type, description: arg.description?.value, - defaultValue: valueFromAST(arg.defaultValue, type), + defaultValue: arg.defaultValue + ? coerceInputLiteral(arg.defaultValue, type) + : undefined, deprecationReason: getDeprecationReason(arg), astNode: arg, }; @@ -518,7 +520,9 @@ export function extendSchemaImpl( inputFieldMap[field.name.value] = { type, description: field.description?.value, - defaultValue: valueFromAST(field.defaultValue, type), + defaultValue: field.defaultValue + ? coerceInputLiteral(field.defaultValue, type) + : undefined, deprecationReason: getDeprecationReason(field), astNode: field, }; diff --git a/src/utilities/findBreakingChanges.ts b/src/utilities/findBreakingChanges.ts index 0da4cceaa7..e79fd8043a 100644 --- a/src/utilities/findBreakingChanges.ts +++ b/src/utilities/findBreakingChanges.ts @@ -125,7 +125,7 @@ function findDirectiveChanges( for (const oldDirective of directivesDiff.removed) { schemaChanges.push({ type: BreakingChangeType.DIRECTIVE_REMOVED, - description: `${oldDirective.name} was removed.`, + description: `Directive ${oldDirective} was removed.`, }); } @@ -136,7 +136,7 @@ function findDirectiveChanges( if (isRequiredArgument(newArg)) { schemaChanges.push({ type: BreakingChangeType.REQUIRED_DIRECTIVE_ARG_ADDED, - description: `A required arg ${newArg.name} on directive ${oldDirective.name} was added.`, + description: `A required argument ${newArg} was added.`, }); } } @@ -144,14 +144,14 @@ function findDirectiveChanges( for (const oldArg of argsDiff.removed) { schemaChanges.push({ type: BreakingChangeType.DIRECTIVE_ARG_REMOVED, - description: `${oldArg.name} was removed from ${oldDirective.name}.`, + description: `Argument ${oldArg} was removed.`, }); } if (oldDirective.isRepeatable && !newDirective.isRepeatable) { schemaChanges.push({ type: BreakingChangeType.DIRECTIVE_REPEATABLE_REMOVED, - description: `Repeatable flag was removed from ${oldDirective.name}.`, + description: `Repeatable flag was removed from ${oldDirective}.`, }); } @@ -159,7 +159,7 @@ function findDirectiveChanges( if (!newDirective.locations.includes(location)) { schemaChanges.push({ type: BreakingChangeType.DIRECTIVE_LOCATION_REMOVED, - description: `${location} was removed from ${oldDirective.name}.`, + description: `${location} was removed from ${oldDirective}.`, }); } } @@ -183,8 +183,8 @@ function findTypeChanges( schemaChanges.push({ type: BreakingChangeType.TYPE_REMOVED, description: isSpecifiedScalarType(oldType) - ? `Standard scalar ${oldType.name} was removed because it is not referenced anymore.` - : `${oldType.name} was removed.`, + ? `Standard scalar ${oldType} was removed because it is not referenced anymore.` + : `${oldType} was removed.`, }); } @@ -208,9 +208,9 @@ function findTypeChanges( } else if (oldType.constructor !== newType.constructor) { schemaChanges.push({ type: BreakingChangeType.TYPE_CHANGED_KIND, - description: - `${oldType.name} changed from ` + - `${typeKindName(oldType)} to ${typeKindName(newType)}.`, + description: `${oldType} changed from ${typeKindName( + oldType, + )} to ${typeKindName(newType)}.`, }); } } @@ -232,12 +232,12 @@ function findInputObjectTypeChanges( if (isRequiredInputField(newField)) { schemaChanges.push({ type: BreakingChangeType.REQUIRED_INPUT_FIELD_ADDED, - description: `A required field ${newField.name} on input type ${oldType.name} was added.`, + description: `A required field ${newField} was added.`, }); } else { schemaChanges.push({ type: DangerousChangeType.OPTIONAL_INPUT_FIELD_ADDED, - description: `An optional field ${newField.name} on input type ${oldType.name} was added.`, + description: `An optional field ${newField} was added.`, }); } } @@ -245,7 +245,7 @@ function findInputObjectTypeChanges( for (const oldField of fieldsDiff.removed) { schemaChanges.push({ type: BreakingChangeType.FIELD_REMOVED, - description: `${oldType.name}.${oldField.name} was removed.`, + description: `${oldField} was removed.`, }); } @@ -257,9 +257,7 @@ function findInputObjectTypeChanges( if (!isSafe) { schemaChanges.push({ type: BreakingChangeType.FIELD_CHANGED_KIND, - description: - `${oldType.name}.${oldField.name} changed type from ` + - `${String(oldField.type)} to ${String(newField.type)}.`, + description: `${newField} changed type from ${oldField.type} to ${newField.type}.`, }); } } @@ -277,14 +275,14 @@ function findUnionTypeChanges( for (const newPossibleType of possibleTypesDiff.added) { schemaChanges.push({ type: DangerousChangeType.TYPE_ADDED_TO_UNION, - description: `${newPossibleType.name} was added to union type ${oldType.name}.`, + description: `${newPossibleType} was added to union type ${oldType}.`, }); } for (const oldPossibleType of possibleTypesDiff.removed) { schemaChanges.push({ type: BreakingChangeType.TYPE_REMOVED_FROM_UNION, - description: `${oldPossibleType.name} was removed from union type ${oldType.name}.`, + description: `${oldPossibleType} was removed from union type ${oldType}.`, }); } @@ -301,14 +299,14 @@ function findEnumTypeChanges( for (const newValue of valuesDiff.added) { schemaChanges.push({ type: DangerousChangeType.VALUE_ADDED_TO_ENUM, - description: `${newValue.name} was added to enum type ${oldType.name}.`, + description: `Enum value ${newValue} was added.`, }); } for (const oldValue of valuesDiff.removed) { schemaChanges.push({ type: BreakingChangeType.VALUE_REMOVED_FROM_ENUM, - description: `${oldValue.name} was removed from enum type ${oldType.name}.`, + description: `Enum value ${oldValue} was removed.`, }); } @@ -325,14 +323,14 @@ function findImplementedInterfacesChanges( for (const newInterface of interfacesDiff.added) { schemaChanges.push({ type: DangerousChangeType.IMPLEMENTED_INTERFACE_ADDED, - description: `${newInterface.name} added to interfaces implemented by ${oldType.name}.`, + description: `${newInterface} added to interfaces implemented by ${oldType}.`, }); } for (const oldInterface of interfacesDiff.removed) { schemaChanges.push({ type: BreakingChangeType.IMPLEMENTED_INTERFACE_REMOVED, - description: `${oldType.name} no longer implements interface ${oldInterface.name}.`, + description: `${oldType} no longer implements interface ${oldInterface}.`, }); } @@ -352,12 +350,12 @@ function findFieldChanges( for (const oldField of fieldsDiff.removed) { schemaChanges.push({ type: BreakingChangeType.FIELD_REMOVED, - description: `${oldType.name}.${oldField.name} was removed.`, + description: `Field ${oldField} was removed.`, }); } for (const [oldField, newField] of fieldsDiff.persisted) { - schemaChanges.push(...findArgChanges(oldType, oldField, newField)); + schemaChanges.push(...findArgChanges(oldField, newField)); const isSafe = isChangeSafeForObjectOrInterfaceField( oldField.type, @@ -366,9 +364,7 @@ function findFieldChanges( if (!isSafe) { schemaChanges.push({ type: BreakingChangeType.FIELD_CHANGED_KIND, - description: - `${oldType.name}.${oldField.name} changed type from ` + - `${String(oldField.type)} to ${String(newField.type)}.`, + description: `Field ${newField} changed type from ${oldField.type} to ${newField.type}.`, }); } } @@ -377,7 +373,6 @@ function findFieldChanges( } function findArgChanges( - oldType: GraphQLObjectType | GraphQLInterfaceType, oldField: GraphQLField, newField: GraphQLField, ): Array { @@ -387,7 +382,7 @@ function findArgChanges( for (const oldArg of argsDiff.removed) { schemaChanges.push({ type: BreakingChangeType.ARG_REMOVED, - description: `${oldType.name}.${oldField.name} arg ${oldArg.name} was removed.`, + description: `Argument ${oldArg} was removed.`, }); } @@ -399,15 +394,13 @@ function findArgChanges( if (!isSafe) { schemaChanges.push({ type: BreakingChangeType.ARG_CHANGED_KIND, - description: - `${oldType.name}.${oldField.name} arg ${oldArg.name} has changed type from ` + - `${String(oldArg.type)} to ${String(newArg.type)}.`, + description: `Argument ${newArg} has changed type from ${oldArg.type} to ${newArg.type}.`, }); } else if (oldArg.defaultValue !== undefined) { if (newArg.defaultValue === undefined) { schemaChanges.push({ type: DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE, - description: `${oldType.name}.${oldField.name} arg ${oldArg.name} defaultValue was removed.`, + description: `${oldArg} defaultValue was removed.`, }); } else { // Since we looking only for client's observable changes we should @@ -419,7 +412,7 @@ function findArgChanges( if (oldValueStr !== newValueStr) { schemaChanges.push({ type: DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE, - description: `${oldType.name}.${oldField.name} arg ${oldArg.name} has changed defaultValue from ${oldValueStr} to ${newValueStr}.`, + description: `${oldArg} has changed defaultValue from ${oldValueStr} to ${newValueStr}.`, }); } } @@ -430,12 +423,12 @@ function findArgChanges( if (isRequiredArgument(newArg)) { schemaChanges.push({ type: BreakingChangeType.REQUIRED_ARG_ADDED, - description: `A required arg ${newArg.name} on ${oldType.name}.${oldField.name} was added.`, + description: `A required argument ${newArg} was added.`, }); } else { schemaChanges.push({ type: DangerousChangeType.OPTIONAL_ARG_ADDED, - description: `An optional arg ${newArg.name} on ${oldType.name}.${oldField.name} was added.`, + description: `An optional argument ${newArg} was added.`, }); } } diff --git a/src/utilities/index.ts b/src/utilities/index.ts index a1411f508e..523860165a 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -61,9 +61,6 @@ export { /** Create a GraphQLType from a GraphQL language AST. */ export { typeFromAST } from './typeFromAST'; -/** Create a JavaScript value from a GraphQL language AST with a type. */ -export { valueFromAST } from './valueFromAST'; - /** Create a JavaScript value from a GraphQL language AST without a type. */ export { valueFromASTUntyped } from './valueFromASTUntyped'; @@ -73,8 +70,12 @@ export { astFromValue } from './astFromValue'; /** A helper to use within recursive-descent visitors which need to be aware of the GraphQL type system. */ export { TypeInfo, visitWithTypeInfo } from './TypeInfo'; -/** Coerces a JavaScript value to a GraphQL type, or produces errors. */ -export { coerceInputValue } from './coerceInputValue'; +export { + /** Coerces a JavaScript value to a GraphQL type, or produces errors. */ + coerceInputValue, + /** Coerces a GraphQL literal (AST) to a GraphQL type, or returns undefined. */ + coerceInputLiteral, +} from './coerceInputValue'; /** Concatenates multiple AST together. */ export { concatAST } from './concatAST'; @@ -106,3 +107,10 @@ export type { BreakingChange, DangerousChange } from './findBreakingChanges'; /** Wrapper type that contains DocumentNode and types that can be deduced from it. */ export type { TypedQueryDocumentNode } from './typedQueryDocumentNode'; + +/** Schema coordinates */ +export { + resolveSchemaCoordinate, + resolveASTSchemaCoordinate, +} from './resolveSchemaCoordinate'; +export type { ResolvedSchemaElement } from './resolveSchemaCoordinate'; diff --git a/src/utilities/resolveSchemaCoordinate.ts b/src/utilities/resolveSchemaCoordinate.ts new file mode 100644 index 0000000000..d1e15976f5 --- /dev/null +++ b/src/utilities/resolveSchemaCoordinate.ts @@ -0,0 +1,189 @@ +import type { GraphQLSchema } from '../type/schema'; +import type { SchemaCoordinateNode } from '../language/ast'; +import type { Source } from '../language/source'; +import { + isObjectType, + isInterfaceType, + isEnumType, + isInputObjectType, +} from '../type/definition'; +import { parseSchemaCoordinate } from '../language/parser'; +import type { + GraphQLNamedType, + GraphQLField, + GraphQLInputField, + GraphQLEnumValue, + GraphQLArgument, +} from '../type/definition'; +import type { GraphQLDirective } from '../type/directives'; + +/** + * A resolved schema element may be one of the following kinds: + */ +export type ResolvedSchemaElement = + | { + readonly kind: 'NamedType'; + readonly type: GraphQLNamedType; + } + | { + readonly kind: 'Field'; + readonly type: GraphQLNamedType; + readonly field: GraphQLField; + } + | { + readonly kind: 'InputField'; + readonly type: GraphQLNamedType; + readonly inputField: GraphQLInputField; + } + | { + readonly kind: 'EnumValue'; + readonly type: GraphQLNamedType; + readonly enumValue: GraphQLEnumValue; + } + | { + readonly kind: 'FieldArgument'; + readonly type: GraphQLNamedType; + readonly field: GraphQLField; + readonly fieldArgument: GraphQLArgument; + } + | { + readonly kind: 'Directive'; + readonly directive: GraphQLDirective; + } + | { + readonly kind: 'DirectiveArgument'; + readonly directive: GraphQLDirective; + readonly directiveArgument: GraphQLArgument; + }; + +/** + * A schema coordinate is resolved in the context of a GraphQL schema to + * uniquely identifies a schema element. It returns undefined if the schema + * coordinate does not resolve to a schema element. + * + * https://spec.graphql.org/draft/#sec-Schema-Coordinates.Semantics + */ +export function resolveSchemaCoordinate( + schema: GraphQLSchema, + schemaCoordinate: string | Source, +): ResolvedSchemaElement | undefined { + return resolveASTSchemaCoordinate( + schema, + parseSchemaCoordinate(schemaCoordinate), + ); +} + +/** + * Resolves schema coordinate from a parsed SchemaCoordinate node. + */ +export function resolveASTSchemaCoordinate( + schema: GraphQLSchema, + schemaCoordinate: SchemaCoordinateNode, +): ResolvedSchemaElement | undefined { + const { ofDirective, name, memberName, argumentName } = schemaCoordinate; + if (ofDirective) { + // SchemaCoordinate : + // - @ Name + // - @ Name ( Name : ) + // Let {directiveName} be the value of the first {Name}. + // Let {directive} be the directive in the {schema} named {directiveName}. + const directive = schema.getDirective(name.value); + if (!argumentName) { + // SchemaCoordinate : @ Name + // Return the directive in the {schema} named {directiveName}. + if (!directive) { + return; + } + return { kind: 'Directive', directive }; + } + + // SchemaCoordinate : @ Name ( Name : ) + // Assert {directive} must exist. + if (!directive) { + return; + } + // Let {directiveArgumentName} be the value of the second {Name}. + // Return the argument of {directive} named {directiveArgumentName}. + const directiveArgument = directive.args.find( + (arg) => arg.name === argumentName.value, + ); + if (!directiveArgument) { + return; + } + return { kind: 'DirectiveArgument', directive, directiveArgument }; + } + + // SchemaCoordinate : + // - Name + // - Name . Name + // - Name . Name ( Name : ) + // Let {typeName} be the value of the first {Name}. + // Let {type} be the type in the {schema} named {typeName}. + const type = schema.getType(name.value); + if (!memberName) { + // SchemaCoordinate : Name + // Return the type in the {schema} named {typeName}. + if (!type) { + return; + } + return { kind: 'NamedType', type }; + } + + if (!argumentName) { + // SchemaCoordinate : Name . Name + // If {type} is an Enum type: + if (isEnumType(type)) { + // Let {enumValueName} be the value of the second {Name}. + // Return the enum value of {type} named {enumValueName}. + const enumValue = type.getValue(memberName.value); + if (!enumValue) { + return; + } + return { kind: 'EnumValue', type, enumValue }; + } + // Otherwise if {type} is an Input Object type: + if (isInputObjectType(type)) { + // Let {inputFieldName} be the value of the second {Name}. + // Return the input field of {type} named {inputFieldName}. + const inputField = type.getFields()[memberName.value]; + if (!inputField) { + return; + } + return { kind: 'InputField', type, inputField }; + } + // Otherwise: + // Assert {type} must be an Object or Interface type. + if (!isObjectType(type) && !isInterfaceType(type)) { + return; + } + // Let {fieldName} be the value of the second {Name}. + // Return the field of {type} named {fieldName}. + const field = type.getFields()[memberName.value]; + if (!field) { + return; + } + return { kind: 'Field', type, field }; + } + + // SchemaCoordinate : Name . Name ( Name : ) + // Assert {type} must be an Object or Interface type. + if (!isObjectType(type) && !isInterfaceType(type)) { + return; + } + // Let {fieldName} be the value of the second {Name}. + // Let {field} be the field of {type} named {fieldName}. + const field = type.getFields()[memberName.value]; + // Assert {field} must exist. + if (!field) { + return; + } + // Let {fieldArgumentName} be the value of the third {Name}. + // Return the argument of {field} named {fieldArgumentName}. + const fieldArgument = field.args.find( + (arg) => arg.name === argumentName.value, + ); + if (!fieldArgument) { + return; + } + return { kind: 'FieldArgument', type, field, fieldArgument }; +} diff --git a/src/utilities/valueFromAST.ts b/src/utilities/valueFromAST.ts deleted file mode 100644 index 359d2145bc..0000000000 --- a/src/utilities/valueFromAST.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { ObjMap } from '../jsutils/ObjMap'; -import { keyMap } from '../jsutils/keyMap'; -import { inspect } from '../jsutils/inspect'; -import { invariant } from '../jsutils/invariant'; - -import type { ValueNode } from '../language/ast'; -import { Kind } from '../language/kinds'; - -import type { GraphQLInputType } from '../type/definition'; -import { - isLeafType, - isInputObjectType, - isListType, - isNonNullType, -} from '../type/definition'; - -import type { Maybe } from '../jsutils/Maybe'; - -/** - * Produces a JavaScript value given a GraphQL Value AST. - * - * A GraphQL type must be provided, which will be used to interpret different - * GraphQL Value literals. - * - * Returns `undefined` when the value could not be validly coerced according to - * the provided type. - * - * | GraphQL Value | JSON Value | - * | -------------------- | ------------- | - * | Input Object | Object | - * | List | Array | - * | Boolean | Boolean | - * | String | String | - * | Int / Float | Number | - * | Enum Value | Unknown | - * | NullValue | null | - * - */ -export function valueFromAST( - valueNode: Maybe, - type: GraphQLInputType, - variables?: Maybe>, -): unknown { - if (!valueNode) { - // When there is no node, then there is also no value. - // Importantly, this is different from returning the value null. - return; - } - - if (valueNode.kind === Kind.VARIABLE) { - const variableName = valueNode.name.value; - if (variables == null || variables[variableName] === undefined) { - // No valid return value. - return; - } - const variableValue = variables[variableName]; - if (variableValue === null && isNonNullType(type)) { - return; // Invalid: intentionally return no value. - } - // Note: This does no further checking that this variable is correct. - // This assumes that this query has been validated and the variable - // usage here is of the correct type. - return variableValue; - } - - if (isNonNullType(type)) { - if (valueNode.kind === Kind.NULL) { - return; // Invalid: intentionally return no value. - } - return valueFromAST(valueNode, type.ofType, variables); - } - - if (valueNode.kind === Kind.NULL) { - // This is explicitly returning the value null. - return null; - } - - if (isListType(type)) { - const itemType = type.ofType; - if (valueNode.kind === Kind.LIST) { - const coercedValues = []; - for (const itemNode of valueNode.values) { - if (isMissingVariable(itemNode, variables)) { - // If an array contains a missing variable, it is either coerced to - // null or if the item type is non-null, it considered invalid. - if (isNonNullType(itemType)) { - return; // Invalid: intentionally return no value. - } - coercedValues.push(null); - } else { - const itemValue = valueFromAST(itemNode, itemType, variables); - if (itemValue === undefined) { - return; // Invalid: intentionally return no value. - } - coercedValues.push(itemValue); - } - } - return coercedValues; - } - const coercedValue = valueFromAST(valueNode, itemType, variables); - if (coercedValue === undefined) { - return; // Invalid: intentionally return no value. - } - return [coercedValue]; - } - - if (isInputObjectType(type)) { - if (valueNode.kind !== Kind.OBJECT) { - return; // Invalid: intentionally return no value. - } - const coercedObj = Object.create(null); - const fieldNodes = keyMap(valueNode.fields, (field) => field.name.value); - for (const field of Object.values(type.getFields())) { - const fieldNode = fieldNodes[field.name]; - if (!fieldNode || isMissingVariable(fieldNode.value, variables)) { - if (field.defaultValue !== undefined) { - coercedObj[field.name] = field.defaultValue; - } else if (isNonNullType(field.type)) { - return; // Invalid: intentionally return no value. - } - continue; - } - const fieldValue = valueFromAST(fieldNode.value, field.type, variables); - if (fieldValue === undefined) { - return; // Invalid: intentionally return no value. - } - coercedObj[field.name] = fieldValue; - } - return coercedObj; - } - - // istanbul ignore else (See: 'https://github.com/graphql/graphql-js/issues/2618') - if (isLeafType(type)) { - // Scalars and Enums fulfill parsing a literal value via parseLiteral(). - // Invalid values represent a failure to parse correctly, in which case - // no value is returned. - let result; - try { - result = type.parseLiteral(valueNode, variables); - } catch (_error) { - return; // Invalid: intentionally return no value. - } - if (result === undefined) { - return; // Invalid: intentionally return no value. - } - return result; - } - - // istanbul ignore next (Not reachable. All possible input types have been considered) - invariant(false, 'Unexpected input type: ' + inspect(type)); -} - -// Returns true if the provided valueNode is a variable which is not defined -// in the set of variables. -function isMissingVariable( - valueNode: ValueNode, - variables: Maybe>, -): boolean { - return ( - valueNode.kind === Kind.VARIABLE && - (variables == null || variables[valueNode.name.value] === undefined) - ); -} diff --git a/src/utilities/valueFromASTUntyped.ts b/src/utilities/valueFromASTUntyped.ts index c3e2e92e49..e168d888f8 100644 --- a/src/utilities/valueFromASTUntyped.ts +++ b/src/utilities/valueFromASTUntyped.ts @@ -10,8 +10,8 @@ import type { ValueNode } from '../language/ast'; /** * Produces a JavaScript value given a GraphQL Value AST. * - * Unlike `valueFromAST()`, no type is provided. The resulting JavaScript value - * will reflect the provided GraphQL value AST. + * No type is provided. The resulting JavaScript value will reflect the + * provided GraphQL value AST. * * | GraphQL Value | JavaScript Value | * | -------------------- | ---------------- | diff --git a/src/validation/__tests__/NoDeprecatedCustomRule-test.ts b/src/validation/__tests__/NoDeprecatedCustomRule-test.ts index 12d66eafc2..a1483e67be 100644 --- a/src/validation/__tests__/NoDeprecatedCustomRule-test.ts +++ b/src/validation/__tests__/NoDeprecatedCustomRule-test.ts @@ -106,7 +106,7 @@ describe('Validate: no deprecated', () => { `).to.deep.equal([ { message: - 'Field "Query.someField" argument "deprecatedArg" is deprecated. Some arg reason.', + 'The argument Query.someField(deprecatedArg:) is deprecated. Some arg reason.', locations: [{ line: 3, column: 21 }], }, ]); @@ -150,7 +150,7 @@ describe('Validate: no deprecated', () => { `).to.deep.equal([ { message: - 'Directive "@someDirective" argument "deprecatedArg" is deprecated. Some arg reason.', + 'The argument @someDirective(deprecatedArg:) is deprecated. Some arg reason.', locations: [{ line: 3, column: 36 }], }, ]); @@ -255,7 +255,7 @@ describe('Validate: no deprecated', () => { it('reports error when a deprecated enum value is used', () => { const message = - 'The enum value "EnumType.DEPRECATED_VALUE" is deprecated. Some enum reason.'; + 'The enum value EnumType.DEPRECATED_VALUE is deprecated. Some enum reason.'; expectErrors(` query ( diff --git a/src/validation/__tests__/ProvidedRequiredArgumentsRule-test.ts b/src/validation/__tests__/ProvidedRequiredArgumentsRule-test.ts index 7976f46bd2..3ca5d19837 100644 --- a/src/validation/__tests__/ProvidedRequiredArgumentsRule-test.ts +++ b/src/validation/__tests__/ProvidedRequiredArgumentsRule-test.ts @@ -165,7 +165,7 @@ describe('Validate: Provided required arguments', () => { `).to.deep.equal([ { message: - 'Field "multipleReqs" argument "req1" of type "Int!" is required, but it was not provided.', + 'Argument ComplicatedArgs.multipleReqs(req1:) of type "Int!" is required, but it was not provided.', locations: [{ line: 4, column: 13 }], }, ]); @@ -181,12 +181,12 @@ describe('Validate: Provided required arguments', () => { `).to.deep.equal([ { message: - 'Field "multipleReqs" argument "req1" of type "Int!" is required, but it was not provided.', + 'Argument ComplicatedArgs.multipleReqs(req1:) of type "Int!" is required, but it was not provided.', locations: [{ line: 4, column: 13 }], }, { message: - 'Field "multipleReqs" argument "req2" of type "Int!" is required, but it was not provided.', + 'Argument ComplicatedArgs.multipleReqs(req2:) of type "Int!" is required, but it was not provided.', locations: [{ line: 4, column: 13 }], }, ]); @@ -202,7 +202,7 @@ describe('Validate: Provided required arguments', () => { `).to.deep.equal([ { message: - 'Field "multipleReqs" argument "req2" of type "Int!" is required, but it was not provided.', + 'Argument ComplicatedArgs.multipleReqs(req2:) of type "Int!" is required, but it was not provided.', locations: [{ line: 4, column: 13 }], }, ]); @@ -241,12 +241,12 @@ describe('Validate: Provided required arguments', () => { `).to.deep.equal([ { message: - 'Directive "@include" argument "if" of type "Boolean!" is required, but it was not provided.', + 'Argument @include(if:) of type "Boolean!" is required, but it was not provided.', locations: [{ line: 3, column: 15 }], }, { message: - 'Directive "@skip" argument "if" of type "Boolean!" is required, but it was not provided.', + 'Argument @skip(if:) of type "Boolean!" is required, but it was not provided.', locations: [{ line: 4, column: 18 }], }, ]); @@ -274,7 +274,7 @@ describe('Validate: Provided required arguments', () => { `).to.deep.equal([ { message: - 'Directive "@test" argument "arg" of type "String!" is required, but it was not provided.', + 'Argument @test(arg:) of type "String!" is required, but it was not provided.', locations: [{ line: 3, column: 23 }], }, ]); @@ -288,7 +288,7 @@ describe('Validate: Provided required arguments', () => { `).to.deep.equal([ { message: - 'Directive "@include" argument "if" of type "Boolean!" is required, but it was not provided.', + 'Argument @include(if:) of type "Boolean!" is required, but it was not provided.', locations: [{ line: 3, column: 23 }], }, ]); @@ -303,7 +303,7 @@ describe('Validate: Provided required arguments', () => { `).to.deep.equal([ { message: - 'Directive "@deprecated" argument "reason" of type "String!" is required, but it was not provided.', + 'Argument @deprecated(reason:) of type "String!" is required, but it was not provided.', locations: [{ line: 3, column: 23 }], }, ]); @@ -325,7 +325,7 @@ describe('Validate: Provided required arguments', () => { ).to.deep.equal([ { message: - 'Directive "@test" argument "arg" of type "String!" is required, but it was not provided.', + 'Argument @test(arg:) of type "String!" is required, but it was not provided.', locations: [{ line: 4, column: 30 }], }, ]); @@ -347,7 +347,7 @@ describe('Validate: Provided required arguments', () => { ).to.deep.equal([ { message: - 'Directive "@test" argument "arg" of type "String!" is required, but it was not provided.', + 'Argument @test(arg:) of type "String!" is required, but it was not provided.', locations: [{ line: 2, column: 29 }], }, ]); diff --git a/src/validation/rules/FieldsOnCorrectTypeRule.ts b/src/validation/rules/FieldsOnCorrectTypeRule.ts index 1b2e066042..0507ee2961 100644 --- a/src/validation/rules/FieldsOnCorrectTypeRule.ts +++ b/src/validation/rules/FieldsOnCorrectTypeRule.ts @@ -54,7 +54,7 @@ export function FieldsOnCorrectTypeRule( // Report an error, including helpful suggestions. context.reportError( new GraphQLError( - `Cannot query field "${fieldName}" on type "${type.name}".` + + `Cannot query field "${fieldName}" on type "${type}".` + suggestion, node, ), diff --git a/src/validation/rules/KnownArgumentNamesRule.ts b/src/validation/rules/KnownArgumentNamesRule.ts index d769fe4966..a32bde09fd 100644 --- a/src/validation/rules/KnownArgumentNamesRule.ts +++ b/src/validation/rules/KnownArgumentNamesRule.ts @@ -26,15 +26,14 @@ export function KnownArgumentNamesRule(context: ValidationContext): ASTVisitor { Argument(argNode) { const argDef = context.getArgument(); const fieldDef = context.getFieldDef(); - const parentType = context.getParentType(); - if (!argDef && fieldDef && parentType) { + if (!argDef && fieldDef) { const argName = argNode.name.value; const knownArgsNames = fieldDef.args.map((arg) => arg.name); const suggestions = suggestionList(argName, knownArgsNames); context.reportError( new GraphQLError( - `Unknown argument "${argName}" on field "${parentType.name}.${fieldDef.name}".` + + `Unknown argument "${argName}" on field "${fieldDef}".` + didYouMean(suggestions), argNode, ), diff --git a/src/validation/rules/ProvidedRequiredArgumentsRule.ts b/src/validation/rules/ProvidedRequiredArgumentsRule.ts index e5c970d24f..d00a8ab21c 100644 --- a/src/validation/rules/ProvidedRequiredArgumentsRule.ts +++ b/src/validation/rules/ProvidedRequiredArgumentsRule.ts @@ -44,10 +44,9 @@ export function ProvidedRequiredArgumentsRule( ); for (const argDef of fieldDef.args) { if (!providedArgs.has(argDef.name) && isRequiredArgument(argDef)) { - const argTypeStr = inspect(argDef.type); context.reportError( new GraphQLError( - `Field "${fieldDef.name}" argument "${argDef.name}" of type "${argTypeStr}" is required, but it was not provided.`, + `Argument ${argDef} of type "${argDef.type}" is required, but it was not provided.`, fieldNode, ), ); @@ -107,7 +106,7 @@ export function ProvidedRequiredArgumentsOnDirectivesRule( : print(argDef.type); context.reportError( new GraphQLError( - `Directive "@${directiveName}" argument "${argName}" of type "${argType}" is required, but it was not provided.`, + `Argument @${directiveName}(${argName}:) of type "${argType}" is required, but it was not provided.`, directiveNode, ), ); diff --git a/src/validation/rules/ValuesOfCorrectTypeRule.ts b/src/validation/rules/ValuesOfCorrectTypeRule.ts index 6d5dc5c1ca..23df08f430 100644 --- a/src/validation/rules/ValuesOfCorrectTypeRule.ts +++ b/src/validation/rules/ValuesOfCorrectTypeRule.ts @@ -1,5 +1,4 @@ import { keyMap } from '../../jsutils/keyMap'; -import { inspect } from '../../jsutils/inspect'; import { didYouMean } from '../../jsutils/didYouMean'; import { suggestionList } from '../../jsutils/suggestionList'; @@ -51,10 +50,9 @@ export function ValuesOfCorrectTypeRule( for (const fieldDef of Object.values(type.getFields())) { const fieldNode = fieldNodeMap[fieldDef.name]; if (!fieldNode && isRequiredInputField(fieldDef)) { - const typeStr = inspect(fieldDef.type); context.reportError( new GraphQLError( - `Field "${type.name}.${fieldDef.name}" of required type "${typeStr}" was not provided.`, + `Field "${fieldDef}" of required type "${fieldDef.type}" was not provided.`, node, ), ); @@ -71,7 +69,7 @@ export function ValuesOfCorrectTypeRule( ); context.reportError( new GraphQLError( - `Field "${node.name.value}" is not defined by type "${parentType.name}".` + + `Field "${node.name.value}" is not defined by type "${parentType}".` + didYouMean(suggestions), node, ), @@ -83,7 +81,7 @@ export function ValuesOfCorrectTypeRule( if (isNonNullType(type)) { context.reportError( new GraphQLError( - `Expected value of type "${inspect(type)}", found ${print(node)}.`, + `Expected value of type "${type}", found ${print(node)}.`, node, ), ); @@ -111,10 +109,9 @@ function isValidValueNode(context: ValidationContext, node: ValueNode): void { const type = getNamedType(locationType); if (!isLeafType(type)) { - const typeStr = inspect(locationType); context.reportError( new GraphQLError( - `Expected value of type "${typeStr}", found ${print(node)}.`, + `Expected value of type "${locationType}", found ${print(node)}.`, node, ), ); @@ -126,22 +123,20 @@ function isValidValueNode(context: ValidationContext, node: ValueNode): void { try { const parseResult = type.parseLiteral(node, undefined /* variables */); if (parseResult === undefined) { - const typeStr = inspect(locationType); context.reportError( new GraphQLError( - `Expected value of type "${typeStr}", found ${print(node)}.`, + `Expected value of type "${locationType}", found ${print(node)}.`, 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)}; ` + + `Expected value of type "${locationType}", found ${print(node)}; ` + error.message, node, undefined, diff --git a/src/validation/rules/VariablesInAllowedPositionRule.ts b/src/validation/rules/VariablesInAllowedPositionRule.ts index bf4c8f8f7c..9312f88548 100644 --- a/src/validation/rules/VariablesInAllowedPositionRule.ts +++ b/src/validation/rules/VariablesInAllowedPositionRule.ts @@ -1,4 +1,3 @@ -import { inspect } from '../../jsutils/inspect'; import type { Maybe } from '../../jsutils/Maybe'; import { GraphQLError } from '../../error/GraphQLError'; @@ -53,11 +52,9 @@ export function VariablesInAllowedPositionRule( defaultValue, ) ) { - const varTypeStr = inspect(varType); - const typeStr = inspect(type); context.reportError( new GraphQLError( - `Variable "$${varName}" of type "${varTypeStr}" used in position expecting type "${typeStr}".`, + `Variable "$${varName}" of type "${varType}" used in position expecting type "${type}".`, [varDef, node], ), ); diff --git a/src/validation/rules/custom/NoDeprecatedCustomRule.ts b/src/validation/rules/custom/NoDeprecatedCustomRule.ts index 38b688a203..1229db3d43 100644 --- a/src/validation/rules/custom/NoDeprecatedCustomRule.ts +++ b/src/validation/rules/custom/NoDeprecatedCustomRule.ts @@ -1,5 +1,3 @@ -import { invariant } from '../../../jsutils/invariant'; - import { GraphQLError } from '../../../error/GraphQLError'; import type { ASTVisitor } from '../../../language/visitor'; @@ -24,11 +22,9 @@ export function NoDeprecatedCustomRule(context: ValidationContext): ASTVisitor { const fieldDef = context.getFieldDef(); const deprecationReason = fieldDef?.deprecationReason; if (fieldDef && deprecationReason != null) { - const parentType = context.getParentType(); - invariant(parentType != null); context.reportError( new GraphQLError( - `The field ${parentType.name}.${fieldDef.name} is deprecated. ${deprecationReason}`, + `The field ${fieldDef} is deprecated. ${deprecationReason}`, node, ), ); @@ -38,25 +34,12 @@ export function NoDeprecatedCustomRule(context: ValidationContext): ASTVisitor { const argDef = context.getArgument(); const deprecationReason = argDef?.deprecationReason; if (argDef && deprecationReason != null) { - const directiveDef = context.getDirective(); - if (directiveDef != null) { - context.reportError( - new GraphQLError( - `Directive "@${directiveDef.name}" argument "${argDef.name}" is deprecated. ${deprecationReason}`, - node, - ), - ); - } else { - const parentType = context.getParentType(); - const fieldDef = context.getFieldDef(); - invariant(parentType != null && fieldDef != null); - context.reportError( - new GraphQLError( - `Field "${parentType.name}.${fieldDef.name}" argument "${argDef.name}" is deprecated. ${deprecationReason}`, - node, - ), - ); - } + context.reportError( + new GraphQLError( + `The argument ${argDef} is deprecated. ${deprecationReason}`, + node, + ), + ); } }, ObjectField(node) { @@ -67,7 +50,7 @@ export function NoDeprecatedCustomRule(context: ValidationContext): ASTVisitor { if (deprecationReason != null) { context.reportError( new GraphQLError( - `The input field ${inputObjectDef.name}.${inputFieldDef.name} is deprecated. ${deprecationReason}`, + `The input field ${inputFieldDef} is deprecated. ${deprecationReason}`, node, ), ); @@ -78,11 +61,9 @@ export function NoDeprecatedCustomRule(context: ValidationContext): ASTVisitor { const enumValueDef = context.getEnumValue(); const deprecationReason = enumValueDef?.deprecationReason; if (enumValueDef && deprecationReason != null) { - const enumTypeDef = getNamedType(context.getInputType()); - invariant(enumTypeDef != null); context.reportError( new GraphQLError( - `The enum value "${enumTypeDef.name}.${enumValueDef.name}" is deprecated. ${deprecationReason}`, + `The enum value ${enumValueDef} is deprecated. ${deprecationReason}`, node, ), );