diff --git a/src/__testUtils__/kitchenSinkQuery.ts b/src/__testUtils__/kitchenSinkQuery.ts index 9ed9a7e983..ff989d4b46 100644 --- a/src/__testUtils__/kitchenSinkQuery.ts +++ b/src/__testUtils__/kitchenSinkQuery.ts @@ -10,6 +10,21 @@ query queryName($foo: ComplexType, $site: Site = MOBILE) @onQuery { ...frag @onFragmentSpread } } + + field3! + field4? + requiredField5: field5! + requiredSelectionSet(first: 10)! @directive { + field + } + + unsetListItemsRequiredList: listField[]! + requiredListItemsUnsetList: listField[!] + requiredListItemsRequiredList: listField[!]! + unsetListItemsOptionalList: listField[]? + optionalListItemsUnsetList: listField[?] + optionalListItemsOptionalList: listField[?]? + multidimensionalList: listField[[[!]!]!]! } ... @skip(unless: $foo) { id diff --git a/src/index.ts b/src/index.ts index bce254f808..b3a06a18cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -229,6 +229,7 @@ export { isDefinitionNode, isExecutableDefinitionNode, isSelectionNode, + isNullabilityAssertionNode, isValueNode, isConstValueNode, isTypeNode, @@ -260,6 +261,10 @@ export type { SelectionNode, FieldNode, ArgumentNode, + NullabilityAssertionNode, + NonNullAssertionNode, + ErrorBoundaryNode, + ListNullabilityOperatorNode, ConstArgumentNode, FragmentSpreadNode, InlineFragmentNode, diff --git a/src/language/__tests__/lexer-test.ts b/src/language/__tests__/lexer-test.ts index 46bf971d0a..0f19d86063 100644 --- a/src/language/__tests__/lexer-test.ts +++ b/src/language/__tests__/lexer-test.ts @@ -936,6 +936,13 @@ describe('Lexer', () => { value: undefined, }); + expect(lexOne('?')).to.contain({ + kind: TokenKind.QUESTION_MARK, + start: 0, + end: 1, + value: undefined, + }); + expect(lexOne('$')).to.contain({ kind: TokenKind.DOLLAR, start: 0, @@ -1181,6 +1188,7 @@ describe('isPunctuatorTokenKind', () => { it('returns true for punctuator tokens', () => { expect(isPunctuatorToken('!')).to.equal(true); + expect(isPunctuatorToken('?')).to.equal(true); expect(isPunctuatorToken('$')).to.equal(true); expect(isPunctuatorToken('&')).to.equal(true); expect(isPunctuatorToken('(')).to.equal(true); diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index 3571b75700..651da1cab1 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -12,6 +12,10 @@ import { parse, parseConstValue, parseType, parseValue } from '../parser'; import { Source } from '../source'; import { TokenKind } from '../tokenKind'; +function parseCCN(source: string) { + return parse(source, { experimentalClientControlledNullability: true }); +} + function expectSyntaxError(text: string) { return expectToThrowJSON(() => parse(text)); } @@ -153,7 +157,7 @@ describe('Parser', () => { }); it('parses kitchen sink', () => { - expect(() => parse(kitchenSinkQuery)).to.not.throw(); + expect(() => parseCCN(kitchenSinkQuery)).to.not.throw(); }); it('allows non-keywords anywhere a Name is allowed', () => { @@ -224,6 +228,206 @@ describe('Parser', () => { ).to.not.throw(); }); + it('parses required field', () => { + const result = parseCCN('{ requiredField! }'); + + expectJSON(result).toDeepNestedProperty( + 'definitions[0].selectionSet.selections[0].nullabilityAssertion', + { + kind: Kind.NON_NULL_ASSERTION, + loc: { start: 15, end: 16 }, + nullabilityAssertion: undefined, + }, + ); + }); + + it('parses optional field', () => { + expect(() => parseCCN('{ optionalField? }')).to.not.throw(); + }); + + it('does not parse field with multiple designators', () => { + expect(() => parseCCN('{ optionalField?! }')).to.throw( + 'Syntax Error: Expected Name, found "!".', + ); + + expect(() => parseCCN('{ optionalField!? }')).to.throw( + 'Syntax Error: Expected Name, found "?".', + ); + }); + + it('parses required with alias', () => { + expect(() => parseCCN('{ requiredField: field! }')).to.not.throw(); + }); + + it('parses optional with alias', () => { + expect(() => parseCCN('{ requiredField: field? }')).to.not.throw(); + }); + + it('does not parse aliased field with bang on left of colon', () => { + expect(() => parseCCN('{ requiredField!: field }')).to.throw(); + }); + + it('does not parse aliased field with question mark on left of colon', () => { + expect(() => parseCCN('{ requiredField?: field }')).to.throw(); + }); + + it('does not parse aliased field with bang on left and right of colon', () => { + expect(() => parseCCN('{ requiredField!: field! }')).to.throw(); + }); + + it('does not parse aliased field with question mark on left and right of colon', () => { + expect(() => parseCCN('{ requiredField?: field? }')).to.throw(); + }); + + it('does not parse designator on query', () => { + expect(() => parseCCN('query? { field }')).to.throw(); + }); + + it('parses required within fragment', () => { + expect(() => + parseCCN('fragment MyFragment on Query { field! }'), + ).to.not.throw(); + }); + + it('parses optional within fragment', () => { + expect(() => + parseCCN('fragment MyFragment on Query { field? }'), + ).to.not.throw(); + }); + + it('parses field with required list elements', () => { + const result = parseCCN('{ field[!] }'); + + expectJSON(result).toDeepNestedProperty( + 'definitions[0].selectionSet.selections[0].nullabilityAssertion', + { + kind: Kind.LIST_NULLABILITY_OPERATOR, + loc: { start: 7, end: 10 }, + nullabilityAssertion: { + kind: Kind.NON_NULL_ASSERTION, + loc: { start: 8, end: 9 }, + nullabilityAssertion: undefined, + }, + }, + ); + }); + + it('parses field with optional list elements', () => { + const result = parseCCN('{ field[?] }'); + + expectJSON(result).toDeepNestedProperty( + 'definitions[0].selectionSet.selections[0].nullabilityAssertion', + { + kind: Kind.LIST_NULLABILITY_OPERATOR, + loc: { start: 7, end: 10 }, + nullabilityAssertion: { + kind: Kind.ERROR_BOUNDARY, + loc: { start: 8, end: 9 }, + nullabilityAssertion: undefined, + }, + }, + ); + }); + + it('parses field with required list', () => { + const result = parseCCN('{ field[]! }'); + + expectJSON(result).toDeepNestedProperty( + 'definitions[0].selectionSet.selections[0].nullabilityAssertion', + { + kind: Kind.NON_NULL_ASSERTION, + loc: { start: 7, end: 10 }, + nullabilityAssertion: { + kind: Kind.LIST_NULLABILITY_OPERATOR, + loc: { start: 7, end: 9 }, + nullabilityAssertion: undefined, + }, + }, + ); + }); + + it('parses field with optional list', () => { + const result = parseCCN('{ field[]? }'); + + expectJSON(result).toDeepNestedProperty( + 'definitions[0].selectionSet.selections[0].nullabilityAssertion', + { + kind: Kind.ERROR_BOUNDARY, + loc: { start: 7, end: 10 }, + nullabilityAssertion: { + kind: Kind.LIST_NULLABILITY_OPERATOR, + loc: { start: 7, end: 9 }, + nullabilityAssertion: undefined, + }, + }, + ); + }); + + it('parses multidimensional field with mixed list elements', () => { + const result = parseCCN('{ field[[[?]!]]! }'); + + expectJSON(result).toDeepNestedProperty( + 'definitions[0].selectionSet.selections[0].nullabilityAssertion', + { + kind: Kind.NON_NULL_ASSERTION, + loc: { start: 7, end: 16 }, + nullabilityAssertion: { + kind: Kind.LIST_NULLABILITY_OPERATOR, + loc: { start: 7, end: 15 }, + nullabilityAssertion: { + kind: Kind.LIST_NULLABILITY_OPERATOR, + loc: { start: 8, end: 14 }, + nullabilityAssertion: { + kind: Kind.NON_NULL_ASSERTION, + loc: { start: 9, end: 13 }, + nullabilityAssertion: { + kind: Kind.LIST_NULLABILITY_OPERATOR, + loc: { start: 9, end: 12 }, + nullabilityAssertion: { + kind: Kind.ERROR_BOUNDARY, + loc: { start: 10, end: 11 }, + nullabilityAssertion: undefined, + }, + }, + }, + }, + }, + }, + ); + }); + + it('does not parse field with unbalanced brackets', () => { + expect(() => parseCCN('{ field[[] }')).to.throw( + 'Syntax Error: Expected "]", found "}".', + ); + + expect(() => parseCCN('{ field[]] }')).to.throw( + 'Syntax Error: Expected Name, found "]".', + ); + + expect(() => parse('{ field] }')).to.throw( + 'Syntax Error: Expected Name, found "]".', + ); + + expect(() => parseCCN('{ field[ }')).to.throw( + 'Syntax Error: Expected "]", found "}".', + ); + }); + + it('does not parse field with assorted invalid nullability designators', () => { + expect(() => parseCCN('{ field[][] }')).to.throw( + 'Syntax Error: Expected Name, found "[".', + ); + + expect(() => parseCCN('{ field[!!] }')).to.throw( + 'Syntax Error: Expected "]", found "!".', + ); + + expect(() => parseCCN('{ field[]?! }')).to.throw( + 'Syntax Error: Expected Name, found "!".', + ); + }); + it('creates ast', () => { const result = parse(dedent` { @@ -274,6 +478,7 @@ describe('Parser', () => { loc: { start: 9, end: 14 }, }, ], + nullabilityAssertion: undefined, directives: [], selectionSet: { kind: Kind.SELECTION_SET, @@ -289,6 +494,7 @@ describe('Parser', () => { value: 'id', }, arguments: [], + nullabilityAssertion: undefined, directives: [], selectionSet: undefined, }, @@ -302,6 +508,7 @@ describe('Parser', () => { value: 'name', }, arguments: [], + nullabilityAssertion: undefined, directives: [], selectionSet: undefined, }, @@ -349,6 +556,7 @@ describe('Parser', () => { value: 'node', }, arguments: [], + nullabilityAssertion: undefined, directives: [], selectionSet: { kind: Kind.SELECTION_SET, @@ -364,6 +572,7 @@ describe('Parser', () => { value: 'id', }, arguments: [], + nullabilityAssertion: undefined, directives: [], selectionSet: undefined, }, diff --git a/src/language/__tests__/predicates-test.ts b/src/language/__tests__/predicates-test.ts index 13477f8de9..341a5715db 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -8,6 +8,7 @@ import { isConstValueNode, isDefinitionNode, isExecutableDefinitionNode, + isNullabilityAssertionNode, isSelectionNode, isTypeDefinitionNode, isTypeExtensionNode, @@ -62,6 +63,14 @@ describe('AST node predicates', () => { ]); }); + it('isNullabilityAssertionNode', () => { + expect(filterNodes(isNullabilityAssertionNode)).to.deep.equal([ + 'ListNullabilityOperator', + 'NonNullAssertion', + 'ErrorBoundary', + ]); + }); + it('isValueNode', () => { expect(filterNodes(isValueNode)).to.deep.equal([ 'Variable', diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index 73e760d696..1050164763 100644 --- a/src/language/__tests__/printer-test.ts +++ b/src/language/__tests__/printer-test.ts @@ -139,11 +139,17 @@ describe('Printer: Query document', () => { }); it('prints kitchen sink without altering ast', () => { - const ast = parse(kitchenSinkQuery, { noLocation: true }); + const ast = parse(kitchenSinkQuery, { + noLocation: true, + experimentalClientControlledNullability: true, + }); const astBeforePrintCall = JSON.stringify(ast); const printed = print(ast); - const printedAST = parse(printed, { noLocation: true }); + const printedAST = parse(printed, { + noLocation: true, + experimentalClientControlledNullability: true, + }); expect(printedAST).to.deep.equal(ast); expect(JSON.stringify(ast)).to.equal(astBeforePrintCall); @@ -161,6 +167,19 @@ describe('Printer: Query document', () => { ...frag @onFragmentSpread } } + field3! + field4? + requiredField5: field5! + requiredSelectionSet(first: 10)! @directive { + field + } + unsetListItemsRequiredList: listField[]! + requiredListItemsUnsetList: listField[!] + requiredListItemsRequiredList: listField[!]! + unsetListItemsOptionalList: listField[]? + optionalListItemsUnsetList: listField[?] + optionalListItemsOptionalList: listField[?]? + multidimensionalList: listField[[[!]!]!]! } ... @skip(unless: $foo) { id diff --git a/src/language/__tests__/visitor-test.ts b/src/language/__tests__/visitor-test.ts index 9149b103e3..a15f3df425 100644 --- a/src/language/__tests__/visitor-test.ts +++ b/src/language/__tests__/visitor-test.ts @@ -505,8 +505,10 @@ describe('Visitor', () => { ]); }); - it('visits kitchen sink', () => { - const ast = parse(kitchenSinkQuery); + it('n', () => { + const ast = parse(kitchenSinkQuery, { + experimentalClientControlledNullability: true, + }); const visited: Array = []; const argsStack: Array = []; @@ -654,6 +656,272 @@ describe('Visitor', () => { ['leave', 'Field', 1, undefined], ['leave', 'SelectionSet', 'selectionSet', 'Field'], ['leave', 'Field', 0, undefined], + ['enter', 'Field', 1, undefined], + ['enter', 'Name', 'name', 'Field'], + ['leave', 'Name', 'name', 'Field'], + ['enter', 'NonNullAssertion', 'nullabilityAssertion', 'Field'], + ['leave', 'NonNullAssertion', 'nullabilityAssertion', 'Field'], + ['leave', 'Field', 1, undefined], + ['enter', 'Field', 2, undefined], + ['enter', 'Name', 'name', 'Field'], + ['leave', 'Name', 'name', 'Field'], + ['enter', 'ErrorBoundary', 'nullabilityAssertion', 'Field'], + ['leave', 'ErrorBoundary', 'nullabilityAssertion', 'Field'], + ['leave', 'Field', 2, undefined], + ['enter', 'Field', 3, undefined], + ['enter', 'Name', 'alias', 'Field'], + ['leave', 'Name', 'alias', 'Field'], + ['enter', 'Name', 'name', 'Field'], + ['leave', 'Name', 'name', 'Field'], + ['enter', 'NonNullAssertion', 'nullabilityAssertion', 'Field'], + ['leave', 'NonNullAssertion', 'nullabilityAssertion', 'Field'], + ['leave', 'Field', 3, undefined], + ['enter', 'Field', 4, undefined], + ['enter', 'Name', 'name', 'Field'], + ['leave', 'Name', 'name', 'Field'], + ['enter', 'Argument', 0, undefined], + ['enter', 'Name', 'name', 'Argument'], + ['leave', 'Name', 'name', 'Argument'], + ['enter', 'IntValue', 'value', 'Argument'], + ['leave', 'IntValue', 'value', 'Argument'], + ['leave', 'Argument', 0, undefined], + ['enter', 'Directive', 0, undefined], + ['enter', 'Name', 'name', 'Directive'], + ['leave', 'Name', 'name', 'Directive'], + ['leave', 'Directive', 0, undefined], + ['enter', 'SelectionSet', 'selectionSet', 'Field'], + ['enter', 'Field', 0, undefined], + ['enter', 'Name', 'name', 'Field'], + ['leave', 'Name', 'name', 'Field'], + ['leave', 'Field', 0, undefined], + ['leave', 'SelectionSet', 'selectionSet', 'Field'], + ['enter', 'NonNullAssertion', 'nullabilityAssertion', 'Field'], + ['leave', 'NonNullAssertion', 'nullabilityAssertion', 'Field'], + ['leave', 'Field', 4, undefined], + ['enter', 'Field', 5, undefined], + ['enter', 'Name', 'alias', 'Field'], + ['leave', 'Name', 'alias', 'Field'], + ['enter', 'Name', 'name', 'Field'], + ['leave', 'Name', 'name', 'Field'], + ['enter', 'NonNullAssertion', 'nullabilityAssertion', 'Field'], + [ + 'enter', + 'ListNullabilityOperator', + 'nullabilityAssertion', + 'NonNullAssertion', + ], + [ + 'leave', + 'ListNullabilityOperator', + 'nullabilityAssertion', + 'NonNullAssertion', + ], + ['leave', 'NonNullAssertion', 'nullabilityAssertion', 'Field'], + ['leave', 'Field', 5, undefined], + ['enter', 'Field', 6, undefined], + ['enter', 'Name', 'alias', 'Field'], + ['leave', 'Name', 'alias', 'Field'], + ['enter', 'Name', 'name', 'Field'], + ['leave', 'Name', 'name', 'Field'], + ['enter', 'ListNullabilityOperator', 'nullabilityAssertion', 'Field'], + [ + 'enter', + 'NonNullAssertion', + 'nullabilityAssertion', + 'ListNullabilityOperator', + ], + [ + 'leave', + 'NonNullAssertion', + 'nullabilityAssertion', + 'ListNullabilityOperator', + ], + ['leave', 'ListNullabilityOperator', 'nullabilityAssertion', 'Field'], + ['leave', 'Field', 6, undefined], + ['enter', 'Field', 7, undefined], + ['enter', 'Name', 'alias', 'Field'], + ['leave', 'Name', 'alias', 'Field'], + ['enter', 'Name', 'name', 'Field'], + ['leave', 'Name', 'name', 'Field'], + ['enter', 'NonNullAssertion', 'nullabilityAssertion', 'Field'], + [ + 'enter', + 'ListNullabilityOperator', + 'nullabilityAssertion', + 'NonNullAssertion', + ], + [ + 'enter', + 'NonNullAssertion', + 'nullabilityAssertion', + 'ListNullabilityOperator', + ], + [ + 'leave', + 'NonNullAssertion', + 'nullabilityAssertion', + 'ListNullabilityOperator', + ], + [ + 'leave', + 'ListNullabilityOperator', + 'nullabilityAssertion', + 'NonNullAssertion', + ], + ['leave', 'NonNullAssertion', 'nullabilityAssertion', 'Field'], + ['leave', 'Field', 7, undefined], + ['enter', 'Field', 8, undefined], + ['enter', 'Name', 'alias', 'Field'], + ['leave', 'Name', 'alias', 'Field'], + ['enter', 'Name', 'name', 'Field'], + ['leave', 'Name', 'name', 'Field'], + ['enter', 'ErrorBoundary', 'nullabilityAssertion', 'Field'], + [ + 'enter', + 'ListNullabilityOperator', + 'nullabilityAssertion', + 'ErrorBoundary', + ], + [ + 'leave', + 'ListNullabilityOperator', + 'nullabilityAssertion', + 'ErrorBoundary', + ], + ['leave', 'ErrorBoundary', 'nullabilityAssertion', 'Field'], + ['leave', 'Field', 8, undefined], + ['enter', 'Field', 9, undefined], + ['enter', 'Name', 'alias', 'Field'], + ['leave', 'Name', 'alias', 'Field'], + ['enter', 'Name', 'name', 'Field'], + ['leave', 'Name', 'name', 'Field'], + ['enter', 'ListNullabilityOperator', 'nullabilityAssertion', 'Field'], + [ + 'enter', + 'ErrorBoundary', + 'nullabilityAssertion', + 'ListNullabilityOperator', + ], + [ + 'leave', + 'ErrorBoundary', + 'nullabilityAssertion', + 'ListNullabilityOperator', + ], + ['leave', 'ListNullabilityOperator', 'nullabilityAssertion', 'Field'], + ['leave', 'Field', 9, undefined], + ['enter', 'Field', 10, undefined], + ['enter', 'Name', 'alias', 'Field'], + ['leave', 'Name', 'alias', 'Field'], + ['enter', 'Name', 'name', 'Field'], + ['leave', 'Name', 'name', 'Field'], + ['enter', 'ErrorBoundary', 'nullabilityAssertion', 'Field'], + [ + 'enter', + 'ListNullabilityOperator', + 'nullabilityAssertion', + 'ErrorBoundary', + ], + [ + 'enter', + 'ErrorBoundary', + 'nullabilityAssertion', + 'ListNullabilityOperator', + ], + [ + 'leave', + 'ErrorBoundary', + 'nullabilityAssertion', + 'ListNullabilityOperator', + ], + [ + 'leave', + 'ListNullabilityOperator', + 'nullabilityAssertion', + 'ErrorBoundary', + ], + ['leave', 'ErrorBoundary', 'nullabilityAssertion', 'Field'], + ['leave', 'Field', 10, undefined], + ['enter', 'Field', 11, undefined], + ['enter', 'Name', 'alias', 'Field'], + ['leave', 'Name', 'alias', 'Field'], + ['enter', 'Name', 'name', 'Field'], + ['leave', 'Name', 'name', 'Field'], + ['enter', 'NonNullAssertion', 'nullabilityAssertion', 'Field'], + [ + 'enter', + 'ListNullabilityOperator', + 'nullabilityAssertion', + 'NonNullAssertion', + ], + [ + 'enter', + 'NonNullAssertion', + 'nullabilityAssertion', + 'ListNullabilityOperator', + ], + [ + 'enter', + 'ListNullabilityOperator', + 'nullabilityAssertion', + 'NonNullAssertion', + ], + [ + 'enter', + 'NonNullAssertion', + 'nullabilityAssertion', + 'ListNullabilityOperator', + ], + [ + 'enter', + 'ListNullabilityOperator', + 'nullabilityAssertion', + 'NonNullAssertion', + ], + [ + 'enter', + 'NonNullAssertion', + 'nullabilityAssertion', + 'ListNullabilityOperator', + ], + [ + 'leave', + 'NonNullAssertion', + 'nullabilityAssertion', + 'ListNullabilityOperator', + ], + [ + 'leave', + 'ListNullabilityOperator', + 'nullabilityAssertion', + 'NonNullAssertion', + ], + [ + 'leave', + 'NonNullAssertion', + 'nullabilityAssertion', + 'ListNullabilityOperator', + ], + [ + 'leave', + 'ListNullabilityOperator', + 'nullabilityAssertion', + 'NonNullAssertion', + ], + [ + 'leave', + 'NonNullAssertion', + 'nullabilityAssertion', + 'ListNullabilityOperator', + ], + [ + 'leave', + 'ListNullabilityOperator', + 'nullabilityAssertion', + 'NonNullAssertion', + ], + ['leave', 'NonNullAssertion', 'nullabilityAssertion', 'Field'], + ['leave', 'Field', 11, undefined], ['leave', 'SelectionSet', 'selectionSet', 'InlineFragment'], ['leave', 'InlineFragment', 1, undefined], ['enter', 'InlineFragment', 2, undefined], diff --git a/src/language/ast.ts b/src/language/ast.ts index cab8e4bed3..8faffaec7d 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -180,7 +180,10 @@ export type ASTNode = | InterfaceTypeExtensionNode | UnionTypeExtensionNode | EnumTypeExtensionNode - | InputObjectTypeExtensionNode; + | InputObjectTypeExtensionNode + | NonNullAssertionNode + | ErrorBoundaryNode + | ListNullabilityOperatorNode; /** * Utility type listing all nodes indexed by their kind. @@ -207,8 +210,22 @@ export const QueryDocumentKeys: { VariableDefinition: ['variable', 'type', 'defaultValue', 'directives'], Variable: ['name'], SelectionSet: ['selections'], - Field: ['alias', 'name', 'arguments', 'directives', 'selectionSet'], + Field: [ + 'alias', + 'name', + 'arguments', + 'directives', + 'selectionSet', + // Note: Client Controlled Nullability is experimental and may be changed + // or removed in the future. + 'nullabilityAssertion', + ], Argument: ['name', 'value'], + // Note: Client Controlled Nullability is experimental and may be changed + // or removed in the future. + ListNullabilityOperator: ['nullabilityAssertion'], + NonNullAssertion: ['nullabilityAssertion'], + ErrorBoundary: ['nullabilityAssertion'], FragmentSpread: ['name', 'directives'], InlineFragment: ['typeCondition', 'directives', 'selectionSet'], @@ -359,10 +376,36 @@ export interface FieldNode { readonly alias?: NameNode; readonly name: NameNode; readonly arguments?: ReadonlyArray; + // Note: Client Controlled Nullability is experimental + // and may be changed or removed in the future. + readonly nullabilityAssertion?: NullabilityAssertionNode; readonly directives?: ReadonlyArray; readonly selectionSet?: SelectionSetNode; } +export type NullabilityAssertionNode = + | NonNullAssertionNode + | ErrorBoundaryNode + | ListNullabilityOperatorNode; + +export interface ListNullabilityOperatorNode { + readonly kind: Kind.LIST_NULLABILITY_OPERATOR; + readonly loc?: Location; + readonly nullabilityAssertion?: NullabilityAssertionNode; +} + +export interface NonNullAssertionNode { + readonly kind: Kind.NON_NULL_ASSERTION; + readonly loc?: Location; + readonly nullabilityAssertion?: ListNullabilityOperatorNode; +} + +export interface ErrorBoundaryNode { + readonly kind: Kind.ERROR_BOUNDARY; + readonly loc?: Location; + readonly nullabilityAssertion?: ListNullabilityOperatorNode; +} + export interface ArgumentNode { readonly kind: Kind.ARGUMENT; readonly loc?: Location; diff --git a/src/language/index.ts b/src/language/index.ts index ad8e082c6e..0cb82194d5 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -34,6 +34,10 @@ export type { SelectionSetNode, SelectionNode, FieldNode, + NullabilityAssertionNode, + NonNullAssertionNode, + ErrorBoundaryNode, + ListNullabilityOperatorNode, ArgumentNode, ConstArgumentNode, FragmentSpreadNode, @@ -88,6 +92,7 @@ export { isDefinitionNode, isExecutableDefinitionNode, isSelectionNode, + isNullabilityAssertionNode, isValueNode, isConstValueNode, isTypeNode, diff --git a/src/language/kinds.ts b/src/language/kinds.ts index ccbca959f6..d606c12cbe 100644 --- a/src/language/kinds.ts +++ b/src/language/kinds.ts @@ -13,6 +13,11 @@ export enum Kind { FIELD = 'Field', ARGUMENT = 'Argument', + /** Nullability Modifiers */ + LIST_NULLABILITY_OPERATOR = 'ListNullabilityOperator', + NON_NULL_ASSERTION = 'NonNullAssertion', + ERROR_BOUNDARY = 'ErrorBoundary', + /** Fragments */ FRAGMENT_SPREAD = 'FragmentSpread', INLINE_FRAGMENT = 'InlineFragment', diff --git a/src/language/lexer.ts b/src/language/lexer.ts index 818f81b286..c3dc9322fe 100644 --- a/src/language/lexer.ts +++ b/src/language/lexer.ts @@ -91,6 +91,7 @@ export class Lexer { export function isPunctuatorTokenKind(kind: TokenKind): boolean { return ( kind === TokenKind.BANG || + kind === TokenKind.QUESTION_MARK || kind === TokenKind.DOLLAR || kind === TokenKind.AMP || kind === TokenKind.PAREN_L || @@ -281,6 +282,13 @@ function readNextToken(lexer: Lexer, start: number): Token { return createToken(lexer, TokenKind.PIPE, position, position + 1); case 0x007d: // } return createToken(lexer, TokenKind.BRACE_R, position, position + 1); + case 0x003f: // ? + return createToken( + lexer, + TokenKind.QUESTION_MARK, + position, + position + 1, + ); // StringValue case 0x0022: // " if ( diff --git a/src/language/parser.ts b/src/language/parser.ts index 282ee16859..e4bd5e8796 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -20,6 +20,7 @@ import type { EnumTypeExtensionNode, EnumValueDefinitionNode, EnumValueNode, + ErrorBoundaryNode, FieldDefinitionNode, FieldNode, FloatValueNode, @@ -32,11 +33,14 @@ import type { InterfaceTypeDefinitionNode, InterfaceTypeExtensionNode, IntValueNode, + ListNullabilityOperatorNode, ListTypeNode, ListValueNode, NamedTypeNode, NameNode, + NonNullAssertionNode, NonNullTypeNode, + NullabilityAssertionNode, NullValueNode, ObjectFieldNode, ObjectTypeDefinitionNode, @@ -94,6 +98,29 @@ export interface ParseOptions { * ``` */ allowLegacyFragmentVariables?: boolean; + + /** + * EXPERIMENTAL: + * + * If enabled, the parser will understand and parse Client Controlled Nullability + * Designators contained in Fields. They'll be represented in the + * `nullabilityAssertion` field of the FieldNode. + * + * The syntax looks like the following: + * + * ```graphql + * { + * nullableField! + * nonNullableField? + * nonNullableSelectionSet? { + * childField! + * } + * } + * ``` + * Note: this feature is experimental and may change or be removed in the + * future. + */ + experimentalClientControlledNullability?: boolean; } /** @@ -431,6 +458,9 @@ export class Parser { alias, name, arguments: this.parseArguments(false), + // Experimental support for Client Controlled Nullability changes + // the grammar of Field: + nullabilityAssertion: this.parseNullabilityAssertion(), directives: this.parseDirectives(false), selectionSet: this.peek(TokenKind.BRACE_L) ? this.parseSelectionSet() @@ -438,6 +468,41 @@ export class Parser { }); } + // TODO: add grammar comment after it finalizes + parseNullabilityAssertion(): NullabilityAssertionNode | undefined { + // Note: Client Controlled Nullability is experimental and may be changed or + // removed in the future. + if (this._options?.experimentalClientControlledNullability !== true) { + return undefined; + } + + const start = this._lexer.token; + let nullabilityAssertion; + + if (this.expectOptionalToken(TokenKind.BRACKET_L)) { + const innerModifier = this.parseNullabilityAssertion(); + this.expectToken(TokenKind.BRACKET_R); + nullabilityAssertion = this.node(start, { + kind: Kind.LIST_NULLABILITY_OPERATOR, + nullabilityAssertion: innerModifier, + }); + } + + if (this.expectOptionalToken(TokenKind.BANG)) { + nullabilityAssertion = this.node(start, { + kind: Kind.NON_NULL_ASSERTION, + nullabilityAssertion, + }); + } else if (this.expectOptionalToken(TokenKind.QUESTION_MARK)) { + nullabilityAssertion = this.node(start, { + kind: Kind.ERROR_BOUNDARY, + nullabilityAssertion, + }); + } + + return nullabilityAssertion; + } + /** * Arguments[Const] : ( Argument[?Const]+ ) */ diff --git a/src/language/predicates.ts b/src/language/predicates.ts index a390f4ee55..8a96681e9c 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -3,6 +3,7 @@ import type { ConstValueNode, DefinitionNode, ExecutableDefinitionNode, + NullabilityAssertionNode, SelectionNode, TypeDefinitionNode, TypeExtensionNode, @@ -38,6 +39,16 @@ export function isSelectionNode(node: ASTNode): node is SelectionNode { ); } +export function isNullabilityAssertionNode( + node: ASTNode, +): node is NullabilityAssertionNode { + return ( + node.kind === Kind.LIST_NULLABILITY_OPERATOR || + node.kind === Kind.NON_NULL_ASSERTION || + node.kind === Kind.ERROR_BOUNDARY + ); +} + export function isValueNode(node: ASTNode): node is ValueNode { return ( node.kind === Kind.VARIABLE || diff --git a/src/language/printer.ts b/src/language/printer.ts index 38cb25444b..127e6cc4b8 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -55,20 +55,53 @@ const printDocASTReducer: ASTReducer = { SelectionSet: { leave: ({ selections }) => block(selections) }, Field: { - leave({ alias, name, arguments: args, directives, selectionSet }) { - const prefix = wrap('', alias, ': ') + name; + leave({ + alias, + name, + arguments: args, + nullabilityAssertion, + directives, + selectionSet, + }) { + const prefix = join([wrap('', alias, ': '), name], ''); let argsLine = prefix + wrap('(', join(args, ', '), ')'); if (argsLine.length > MAX_LINE_LENGTH) { argsLine = prefix + wrap('(\n', indent(join(args, '\n')), '\n)'); } - return join([argsLine, join(directives, ' '), selectionSet], ' '); + return join([ + argsLine, + // Note: Client Controlled Nullability is experimental and may be + // changed or removed in the future. + nullabilityAssertion, + wrap(' ', join(directives, ' ')), + wrap(' ', selectionSet), + ]); }, }, - Argument: { leave: ({ name, value }) => name + ': ' + value }, + // Nullability Modifiers + + ListNullabilityOperator: { + leave({ nullabilityAssertion }) { + return join(['[', nullabilityAssertion, ']']); + }, + }, + + NonNullAssertion: { + leave({ nullabilityAssertion }) { + return join([nullabilityAssertion, '!']); + }, + }, + + ErrorBoundary: { + leave({ nullabilityAssertion }) { + return join([nullabilityAssertion, '?']); + }, + }, + // Fragments FragmentSpread: { diff --git a/src/language/tokenKind.ts b/src/language/tokenKind.ts index e1fec5a7d2..1bba933d50 100644 --- a/src/language/tokenKind.ts +++ b/src/language/tokenKind.ts @@ -6,6 +6,7 @@ export enum TokenKind { SOF = '', EOF = '', BANG = '!', + QUESTION_MARK = '?', DOLLAR = '$', AMP = '&', PAREN_L = '(', diff --git a/src/utilities/__tests__/stripIgnoredCharacters-test.ts b/src/utilities/__tests__/stripIgnoredCharacters-test.ts index caf6529bbf..a6c8bc414c 100644 --- a/src/utilities/__tests__/stripIgnoredCharacters-test.ts +++ b/src/utilities/__tests__/stripIgnoredCharacters-test.ts @@ -208,8 +208,14 @@ describe('stripIgnoredCharacters', () => { const strippedQuery = stripIgnoredCharacters(kitchenSinkQuery); expect(stripIgnoredCharacters(strippedQuery)).to.equal(strippedQuery); - const queryAST = parse(kitchenSinkQuery, { noLocation: true }); - const strippedAST = parse(strippedQuery, { noLocation: true }); + const queryAST = parse(kitchenSinkQuery, { + noLocation: true, + experimentalClientControlledNullability: true, + }); + const strippedAST = parse(strippedQuery, { + noLocation: true, + experimentalClientControlledNullability: true, + }); expect(strippedAST).to.deep.equal(queryAST); });