Skip to content

Commit

Permalink
[RFC] Client Controlled Nullability experiment implementation w/o exe…
Browse files Browse the repository at this point in the history
…cution (graphql#3418)

Co-authored-by: Ivan Goncharov <[email protected]>
  • Loading branch information
twof and IvanGoncharov authored Jun 28, 2022
1 parent 59c87c3 commit 699ec58
Show file tree
Hide file tree
Showing 16 changed files with 723 additions and 13 deletions.
15 changes: 15 additions & 0 deletions src/__testUtils__/kitchenSinkQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export {
isDefinitionNode,
isExecutableDefinitionNode,
isSelectionNode,
isNullabilityAssertionNode,
isValueNode,
isConstValueNode,
isTypeNode,
Expand Down Expand Up @@ -260,6 +261,10 @@ export type {
SelectionNode,
FieldNode,
ArgumentNode,
NullabilityAssertionNode,
NonNullAssertionNode,
ErrorBoundaryNode,
ListNullabilityOperatorNode,
ConstArgumentNode,
FragmentSpreadNode,
InlineFragmentNode,
Expand Down
8 changes: 8 additions & 0 deletions src/language/__tests__/lexer-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
211 changes: 210 additions & 1 deletion src/language/__tests__/parser-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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`
{
Expand Down Expand Up @@ -274,6 +478,7 @@ describe('Parser', () => {
loc: { start: 9, end: 14 },
},
],
nullabilityAssertion: undefined,
directives: [],
selectionSet: {
kind: Kind.SELECTION_SET,
Expand All @@ -289,6 +494,7 @@ describe('Parser', () => {
value: 'id',
},
arguments: [],
nullabilityAssertion: undefined,
directives: [],
selectionSet: undefined,
},
Expand All @@ -302,6 +508,7 @@ describe('Parser', () => {
value: 'name',
},
arguments: [],
nullabilityAssertion: undefined,
directives: [],
selectionSet: undefined,
},
Expand Down Expand Up @@ -349,6 +556,7 @@ describe('Parser', () => {
value: 'node',
},
arguments: [],
nullabilityAssertion: undefined,
directives: [],
selectionSet: {
kind: Kind.SELECTION_SET,
Expand All @@ -364,6 +572,7 @@ describe('Parser', () => {
value: 'id',
},
arguments: [],
nullabilityAssertion: undefined,
directives: [],
selectionSet: undefined,
},
Expand Down
9 changes: 9 additions & 0 deletions src/language/__tests__/predicates-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isConstValueNode,
isDefinitionNode,
isExecutableDefinitionNode,
isNullabilityAssertionNode,
isSelectionNode,
isTypeDefinitionNode,
isTypeExtensionNode,
Expand Down Expand Up @@ -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',
Expand Down
23 changes: 21 additions & 2 deletions src/language/__tests__/printer-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down
Loading

0 comments on commit 699ec58

Please sign in to comment.