Skip to content

Commit 699ec58

Browse files
twofIvanGoncharov
andauthored
[RFC] Client Controlled Nullability experiment implementation w/o execution (#3418)
Co-authored-by: Ivan Goncharov <[email protected]>
1 parent 59c87c3 commit 699ec58

16 files changed

+723
-13
lines changed

src/__testUtils__/kitchenSinkQuery.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@ query queryName($foo: ComplexType, $site: Site = MOBILE) @onQuery {
1010
...frag @onFragmentSpread
1111
}
1212
}
13+
14+
field3!
15+
field4?
16+
requiredField5: field5!
17+
requiredSelectionSet(first: 10)! @directive {
18+
field
19+
}
20+
21+
unsetListItemsRequiredList: listField[]!
22+
requiredListItemsUnsetList: listField[!]
23+
requiredListItemsRequiredList: listField[!]!
24+
unsetListItemsOptionalList: listField[]?
25+
optionalListItemsUnsetList: listField[?]
26+
optionalListItemsOptionalList: listField[?]?
27+
multidimensionalList: listField[[[!]!]!]!
1328
}
1429
... @skip(unless: $foo) {
1530
id

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export {
229229
isDefinitionNode,
230230
isExecutableDefinitionNode,
231231
isSelectionNode,
232+
isNullabilityAssertionNode,
232233
isValueNode,
233234
isConstValueNode,
234235
isTypeNode,
@@ -260,6 +261,10 @@ export type {
260261
SelectionNode,
261262
FieldNode,
262263
ArgumentNode,
264+
NullabilityAssertionNode,
265+
NonNullAssertionNode,
266+
ErrorBoundaryNode,
267+
ListNullabilityOperatorNode,
263268
ConstArgumentNode,
264269
FragmentSpreadNode,
265270
InlineFragmentNode,

src/language/__tests__/lexer-test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,13 @@ describe('Lexer', () => {
936936
value: undefined,
937937
});
938938

939+
expect(lexOne('?')).to.contain({
940+
kind: TokenKind.QUESTION_MARK,
941+
start: 0,
942+
end: 1,
943+
value: undefined,
944+
});
945+
939946
expect(lexOne('$')).to.contain({
940947
kind: TokenKind.DOLLAR,
941948
start: 0,
@@ -1181,6 +1188,7 @@ describe('isPunctuatorTokenKind', () => {
11811188

11821189
it('returns true for punctuator tokens', () => {
11831190
expect(isPunctuatorToken('!')).to.equal(true);
1191+
expect(isPunctuatorToken('?')).to.equal(true);
11841192
expect(isPunctuatorToken('$')).to.equal(true);
11851193
expect(isPunctuatorToken('&')).to.equal(true);
11861194
expect(isPunctuatorToken('(')).to.equal(true);

src/language/__tests__/parser-test.ts

Lines changed: 210 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import { parse, parseConstValue, parseType, parseValue } from '../parser';
1212
import { Source } from '../source';
1313
import { TokenKind } from '../tokenKind';
1414

15+
function parseCCN(source: string) {
16+
return parse(source, { experimentalClientControlledNullability: true });
17+
}
18+
1519
function expectSyntaxError(text: string) {
1620
return expectToThrowJSON(() => parse(text));
1721
}
@@ -153,7 +157,7 @@ describe('Parser', () => {
153157
});
154158

155159
it('parses kitchen sink', () => {
156-
expect(() => parse(kitchenSinkQuery)).to.not.throw();
160+
expect(() => parseCCN(kitchenSinkQuery)).to.not.throw();
157161
});
158162

159163
it('allows non-keywords anywhere a Name is allowed', () => {
@@ -224,6 +228,206 @@ describe('Parser', () => {
224228
).to.not.throw();
225229
});
226230

231+
it('parses required field', () => {
232+
const result = parseCCN('{ requiredField! }');
233+
234+
expectJSON(result).toDeepNestedProperty(
235+
'definitions[0].selectionSet.selections[0].nullabilityAssertion',
236+
{
237+
kind: Kind.NON_NULL_ASSERTION,
238+
loc: { start: 15, end: 16 },
239+
nullabilityAssertion: undefined,
240+
},
241+
);
242+
});
243+
244+
it('parses optional field', () => {
245+
expect(() => parseCCN('{ optionalField? }')).to.not.throw();
246+
});
247+
248+
it('does not parse field with multiple designators', () => {
249+
expect(() => parseCCN('{ optionalField?! }')).to.throw(
250+
'Syntax Error: Expected Name, found "!".',
251+
);
252+
253+
expect(() => parseCCN('{ optionalField!? }')).to.throw(
254+
'Syntax Error: Expected Name, found "?".',
255+
);
256+
});
257+
258+
it('parses required with alias', () => {
259+
expect(() => parseCCN('{ requiredField: field! }')).to.not.throw();
260+
});
261+
262+
it('parses optional with alias', () => {
263+
expect(() => parseCCN('{ requiredField: field? }')).to.not.throw();
264+
});
265+
266+
it('does not parse aliased field with bang on left of colon', () => {
267+
expect(() => parseCCN('{ requiredField!: field }')).to.throw();
268+
});
269+
270+
it('does not parse aliased field with question mark on left of colon', () => {
271+
expect(() => parseCCN('{ requiredField?: field }')).to.throw();
272+
});
273+
274+
it('does not parse aliased field with bang on left and right of colon', () => {
275+
expect(() => parseCCN('{ requiredField!: field! }')).to.throw();
276+
});
277+
278+
it('does not parse aliased field with question mark on left and right of colon', () => {
279+
expect(() => parseCCN('{ requiredField?: field? }')).to.throw();
280+
});
281+
282+
it('does not parse designator on query', () => {
283+
expect(() => parseCCN('query? { field }')).to.throw();
284+
});
285+
286+
it('parses required within fragment', () => {
287+
expect(() =>
288+
parseCCN('fragment MyFragment on Query { field! }'),
289+
).to.not.throw();
290+
});
291+
292+
it('parses optional within fragment', () => {
293+
expect(() =>
294+
parseCCN('fragment MyFragment on Query { field? }'),
295+
).to.not.throw();
296+
});
297+
298+
it('parses field with required list elements', () => {
299+
const result = parseCCN('{ field[!] }');
300+
301+
expectJSON(result).toDeepNestedProperty(
302+
'definitions[0].selectionSet.selections[0].nullabilityAssertion',
303+
{
304+
kind: Kind.LIST_NULLABILITY_OPERATOR,
305+
loc: { start: 7, end: 10 },
306+
nullabilityAssertion: {
307+
kind: Kind.NON_NULL_ASSERTION,
308+
loc: { start: 8, end: 9 },
309+
nullabilityAssertion: undefined,
310+
},
311+
},
312+
);
313+
});
314+
315+
it('parses field with optional list elements', () => {
316+
const result = parseCCN('{ field[?] }');
317+
318+
expectJSON(result).toDeepNestedProperty(
319+
'definitions[0].selectionSet.selections[0].nullabilityAssertion',
320+
{
321+
kind: Kind.LIST_NULLABILITY_OPERATOR,
322+
loc: { start: 7, end: 10 },
323+
nullabilityAssertion: {
324+
kind: Kind.ERROR_BOUNDARY,
325+
loc: { start: 8, end: 9 },
326+
nullabilityAssertion: undefined,
327+
},
328+
},
329+
);
330+
});
331+
332+
it('parses field with required list', () => {
333+
const result = parseCCN('{ field[]! }');
334+
335+
expectJSON(result).toDeepNestedProperty(
336+
'definitions[0].selectionSet.selections[0].nullabilityAssertion',
337+
{
338+
kind: Kind.NON_NULL_ASSERTION,
339+
loc: { start: 7, end: 10 },
340+
nullabilityAssertion: {
341+
kind: Kind.LIST_NULLABILITY_OPERATOR,
342+
loc: { start: 7, end: 9 },
343+
nullabilityAssertion: undefined,
344+
},
345+
},
346+
);
347+
});
348+
349+
it('parses field with optional list', () => {
350+
const result = parseCCN('{ field[]? }');
351+
352+
expectJSON(result).toDeepNestedProperty(
353+
'definitions[0].selectionSet.selections[0].nullabilityAssertion',
354+
{
355+
kind: Kind.ERROR_BOUNDARY,
356+
loc: { start: 7, end: 10 },
357+
nullabilityAssertion: {
358+
kind: Kind.LIST_NULLABILITY_OPERATOR,
359+
loc: { start: 7, end: 9 },
360+
nullabilityAssertion: undefined,
361+
},
362+
},
363+
);
364+
});
365+
366+
it('parses multidimensional field with mixed list elements', () => {
367+
const result = parseCCN('{ field[[[?]!]]! }');
368+
369+
expectJSON(result).toDeepNestedProperty(
370+
'definitions[0].selectionSet.selections[0].nullabilityAssertion',
371+
{
372+
kind: Kind.NON_NULL_ASSERTION,
373+
loc: { start: 7, end: 16 },
374+
nullabilityAssertion: {
375+
kind: Kind.LIST_NULLABILITY_OPERATOR,
376+
loc: { start: 7, end: 15 },
377+
nullabilityAssertion: {
378+
kind: Kind.LIST_NULLABILITY_OPERATOR,
379+
loc: { start: 8, end: 14 },
380+
nullabilityAssertion: {
381+
kind: Kind.NON_NULL_ASSERTION,
382+
loc: { start: 9, end: 13 },
383+
nullabilityAssertion: {
384+
kind: Kind.LIST_NULLABILITY_OPERATOR,
385+
loc: { start: 9, end: 12 },
386+
nullabilityAssertion: {
387+
kind: Kind.ERROR_BOUNDARY,
388+
loc: { start: 10, end: 11 },
389+
nullabilityAssertion: undefined,
390+
},
391+
},
392+
},
393+
},
394+
},
395+
},
396+
);
397+
});
398+
399+
it('does not parse field with unbalanced brackets', () => {
400+
expect(() => parseCCN('{ field[[] }')).to.throw(
401+
'Syntax Error: Expected "]", found "}".',
402+
);
403+
404+
expect(() => parseCCN('{ field[]] }')).to.throw(
405+
'Syntax Error: Expected Name, found "]".',
406+
);
407+
408+
expect(() => parse('{ field] }')).to.throw(
409+
'Syntax Error: Expected Name, found "]".',
410+
);
411+
412+
expect(() => parseCCN('{ field[ }')).to.throw(
413+
'Syntax Error: Expected "]", found "}".',
414+
);
415+
});
416+
417+
it('does not parse field with assorted invalid nullability designators', () => {
418+
expect(() => parseCCN('{ field[][] }')).to.throw(
419+
'Syntax Error: Expected Name, found "[".',
420+
);
421+
422+
expect(() => parseCCN('{ field[!!] }')).to.throw(
423+
'Syntax Error: Expected "]", found "!".',
424+
);
425+
426+
expect(() => parseCCN('{ field[]?! }')).to.throw(
427+
'Syntax Error: Expected Name, found "!".',
428+
);
429+
});
430+
227431
it('creates ast', () => {
228432
const result = parse(dedent`
229433
{
@@ -274,6 +478,7 @@ describe('Parser', () => {
274478
loc: { start: 9, end: 14 },
275479
},
276480
],
481+
nullabilityAssertion: undefined,
277482
directives: [],
278483
selectionSet: {
279484
kind: Kind.SELECTION_SET,
@@ -289,6 +494,7 @@ describe('Parser', () => {
289494
value: 'id',
290495
},
291496
arguments: [],
497+
nullabilityAssertion: undefined,
292498
directives: [],
293499
selectionSet: undefined,
294500
},
@@ -302,6 +508,7 @@ describe('Parser', () => {
302508
value: 'name',
303509
},
304510
arguments: [],
511+
nullabilityAssertion: undefined,
305512
directives: [],
306513
selectionSet: undefined,
307514
},
@@ -349,6 +556,7 @@ describe('Parser', () => {
349556
value: 'node',
350557
},
351558
arguments: [],
559+
nullabilityAssertion: undefined,
352560
directives: [],
353561
selectionSet: {
354562
kind: Kind.SELECTION_SET,
@@ -364,6 +572,7 @@ describe('Parser', () => {
364572
value: 'id',
365573
},
366574
arguments: [],
575+
nullabilityAssertion: undefined,
367576
directives: [],
368577
selectionSet: undefined,
369578
},

src/language/__tests__/predicates-test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isConstValueNode,
99
isDefinitionNode,
1010
isExecutableDefinitionNode,
11+
isNullabilityAssertionNode,
1112
isSelectionNode,
1213
isTypeDefinitionNode,
1314
isTypeExtensionNode,
@@ -62,6 +63,14 @@ describe('AST node predicates', () => {
6263
]);
6364
});
6465

66+
it('isNullabilityAssertionNode', () => {
67+
expect(filterNodes(isNullabilityAssertionNode)).to.deep.equal([
68+
'ListNullabilityOperator',
69+
'NonNullAssertion',
70+
'ErrorBoundary',
71+
]);
72+
});
73+
6574
it('isValueNode', () => {
6675
expect(filterNodes(isValueNode)).to.deep.equal([
6776
'Variable',

src/language/__tests__/printer-test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,17 @@ describe('Printer: Query document', () => {
139139
});
140140

141141
it('prints kitchen sink without altering ast', () => {
142-
const ast = parse(kitchenSinkQuery, { noLocation: true });
142+
const ast = parse(kitchenSinkQuery, {
143+
noLocation: true,
144+
experimentalClientControlledNullability: true,
145+
});
143146

144147
const astBeforePrintCall = JSON.stringify(ast);
145148
const printed = print(ast);
146-
const printedAST = parse(printed, { noLocation: true });
149+
const printedAST = parse(printed, {
150+
noLocation: true,
151+
experimentalClientControlledNullability: true,
152+
});
147153

148154
expect(printedAST).to.deep.equal(ast);
149155
expect(JSON.stringify(ast)).to.equal(astBeforePrintCall);
@@ -161,6 +167,19 @@ describe('Printer: Query document', () => {
161167
...frag @onFragmentSpread
162168
}
163169
}
170+
field3!
171+
field4?
172+
requiredField5: field5!
173+
requiredSelectionSet(first: 10)! @directive {
174+
field
175+
}
176+
unsetListItemsRequiredList: listField[]!
177+
requiredListItemsUnsetList: listField[!]
178+
requiredListItemsRequiredList: listField[!]!
179+
unsetListItemsOptionalList: listField[]?
180+
optionalListItemsUnsetList: listField[?]
181+
optionalListItemsOptionalList: listField[?]?
182+
multidimensionalList: listField[[[!]!]!]!
164183
}
165184
... @skip(unless: $foo) {
166185
id

0 commit comments

Comments
 (0)