Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Semantic nullability rfc implementation #4337

Open
wants to merge 10 commits into
base: 16.x.x
Choose a base branch
from
1 change: 1 addition & 0 deletions src/__tests__/starWarsIntrospection-test.ts
Original file line number Diff line number Diff line change
@@ -42,6 +42,7 @@ describe('Star Wars Introspection Tests', () => {
{ name: '__TypeKind' },
{ name: '__Field' },
{ name: '__InputValue' },
{ name: '__TypeNullability' },
{ name: '__EnumValue' },
{ name: '__Directive' },
{ name: '__DirectiveLocation' },
174 changes: 174 additions & 0 deletions src/execution/__tests__/semantic-nullability-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';

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

import type { ExecutableDefinitionNode, FieldNode } from '../../language/ast';
import { parse } from '../../language/parser';

import {
GraphQLNonNull,
GraphQLObjectType,
GraphQLSemanticNonNull,
} from '../../type/definition';
import { GraphQLString } from '../../type/scalars';
import { GraphQLSchema } from '../../type/schema';

import { execute } from '../execute';

describe('Execute: Handles Semantic Nullability', () => {
const DeepDataType = new GraphQLObjectType({
name: 'DeepDataType',
fields: {
f: { type: new GraphQLNonNull(GraphQLString) },
},
});

const DataType: GraphQLObjectType = new GraphQLObjectType({
name: 'DataType',
fields: () => ({
a: { type: GraphQLString },
b: { type: new GraphQLSemanticNonNull(GraphQLString) },
c: { type: new GraphQLNonNull(GraphQLString) },
d: { type: new GraphQLSemanticNonNull(DeepDataType) },
}),
});

it('SemanticNonNull throws error on null without error', async () => {
const data = {
b: () => null,
};

const document = parse(`
query {
b
}
`);

const result = await execute({
schema: new GraphQLSchema({ query: DataType }),
document,
rootValue: data,
});

const executable = document.definitions[0] as ExecutableDefinitionNode;
const selectionSet = executable.selectionSet.selections[0];

expect(result).to.deep.equal({
data: {
b: null,
},
errors: [
new GraphQLError(
'Cannot return null for semantic-non-nullable field DataType.b.',
{
nodes: selectionSet,
path: ['b'],
},
),
],
});
});

it('SemanticNonNull succeeds on null with error', async () => {
const data = {
b: () => {
throw new Error('Something went wrong');
},
};

const document = parse(`
query {
b
}
`);

const executable = document.definitions[0] as ExecutableDefinitionNode;
const selectionSet = executable.selectionSet.selections[0];

const result = await execute({
schema: new GraphQLSchema({ query: DataType }),
document,
rootValue: data,
});

expect(result).to.deep.equal({
data: {
b: null,
},
errors: [
new GraphQLError('Something went wrong', {
nodes: selectionSet,
path: ['b'],
}),
],
});
});

it('SemanticNonNull halts null propagation', async () => {
const deepData = {
f: () => null,
};

const data = {
d: () => deepData,
};

const document = parse(`
query {
d {
f
}
}
`);

const result = await execute({
schema: new GraphQLSchema({ query: DataType }),
document,
rootValue: data,
});

const executable = document.definitions[0] as ExecutableDefinitionNode;
const dSelectionSet = executable.selectionSet.selections[0] as FieldNode;
const fSelectionSet = dSelectionSet.selectionSet?.selections[0];

expect(result).to.deep.equal({
data: {
d: null,
},
errors: [
new GraphQLError(
'Cannot return null for non-nullable field DeepDataType.f.',
{
nodes: fSelectionSet,
path: ['d', 'f'],
},
),
],
});
});

it('SemanticNullable allows non-null values', async () => {
const data = {
a: () => 'Apple',
};

const document = parse(`
query {
a
}
`);

const result = await execute({
schema: new GraphQLSchema({ query: DataType }),
document,
rootValue: data,
});

expect(result).to.deep.equal({
data: {
a: 'Apple',
},
});
});
});
20 changes: 20 additions & 0 deletions src/execution/execute.ts
Original file line number Diff line number Diff line change
@@ -43,6 +43,7 @@ import {
isListType,
isNonNullType,
isObjectType,
isSemanticNonNullType,
} from '../type/definition';
import {
SchemaMetaFieldDef,
@@ -658,6 +659,25 @@ function completeValue(
return completed;
}

// If field type is SemanticNonNull, complete for inner type, and throw field error
// if result is null and an error doesn't exist.
if (isSemanticNonNullType(returnType)) {
const completed = completeValue(
exeContext,
returnType.ofType,
fieldNodes,
info,
path,
result,
);
if (completed === null) {
throw new Error(
`Cannot return null for semantic-non-nullable field ${info.parentType.name}.${info.fieldName}.`,
);
}
return completed;
}

// If result value is null or undefined then return null.
if (result == null) {
return null;
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -48,6 +48,7 @@ export {
GraphQLInputObjectType,
GraphQLList,
GraphQLNonNull,
GraphQLSemanticNonNull,
// Standard GraphQL Scalars
specifiedScalarTypes,
GraphQLInt,
@@ -74,6 +75,7 @@ export {
__Schema,
__Directive,
__DirectiveLocation,
__TypeNullability,
__Type,
__Field,
__InputValue,
@@ -95,6 +97,7 @@ export {
isInputObjectType,
isListType,
isNonNullType,
isSemanticNonNullType,
isInputType,
isOutputType,
isLeafType,
@@ -120,6 +123,7 @@ export {
assertInputObjectType,
assertListType,
assertNonNullType,
assertSemanticNonNullType,
assertInputType,
assertOutputType,
assertLeafType,
@@ -286,6 +290,7 @@ export type {
TypeNode,
NamedTypeNode,
ListTypeNode,
SemanticNonNullTypeNode,
NonNullTypeNode,
TypeSystemDefinitionNode,
SchemaDefinitionNode,
@@ -481,6 +486,7 @@ export type {
IntrospectionNamedTypeRef,
IntrospectionListTypeRef,
IntrospectionNonNullTypeRef,
IntrospectionSemanticNonNullTypeRef,
IntrospectionField,
IntrospectionInputValue,
IntrospectionEnumValue,
60 changes: 60 additions & 0 deletions src/language/__tests__/parser-test.ts
Original file line number Diff line number Diff line change
@@ -657,4 +657,64 @@ describe('Parser', () => {
});
});
});

describe('parseDocumentDirective', () => {
it("doesn't throw on document-level directive", () => {
parse(dedent`
@SemanticNullability
type Query {
hello: String
world: String?
foo: String!
}
`);
});

it('parses semantic-non-null types', () => {
const result = parseType('MyType', { allowSemanticNullability: true });
expectJSON(result).toDeepEqual({
kind: Kind.SEMANTIC_NON_NULL_TYPE,
loc: { start: 0, end: 6 },
type: {
kind: Kind.NAMED_TYPE,
loc: { start: 0, end: 6 },
name: {
kind: Kind.NAME,
loc: { start: 0, end: 6 },
value: 'MyType',
},
},
});
});

it('parses nullable types', () => {
const result = parseType('MyType?', { allowSemanticNullability: true });
expectJSON(result).toDeepEqual({
kind: Kind.NAMED_TYPE,
loc: { start: 0, end: 6 },
name: {
kind: Kind.NAME,
loc: { start: 0, end: 6 },
value: 'MyType',
},
});
});

it('parses non-nullable types', () => {
const result = parseType('MyType!', { allowSemanticNullability: true });
expectJSON(result).toDeepEqual({
kind: Kind.NON_NULL_TYPE,
loc: { start: 0, end: 7 },
type: {
kind: Kind.NAMED_TYPE,
loc: { start: 0, end: 6 },
name: {
kind: Kind.NAME,
loc: { start: 0, end: 6 },
value: 'MyType',
},
},
});
});
});
});
1 change: 1 addition & 0 deletions src/language/__tests__/predicates-test.ts
Original file line number Diff line number Diff line change
@@ -92,6 +92,7 @@ describe('AST node predicates', () => {
'NamedType',
'ListType',
'NonNullType',
'SemanticNonNullType',
]);
});

39 changes: 38 additions & 1 deletion src/language/__tests__/schema-printer-test.ts
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ import { dedent } from '../../__testUtils__/dedent';
import { kitchenSinkSDL } from '../../__testUtils__/kitchenSinkSDL';

import { Kind } from '../kinds';
import { parse } from '../parser';
import { parse, parseType } from '../parser';
import { print } from '../printer';

describe('Printer: SDL document', () => {
@@ -180,4 +180,41 @@ describe('Printer: SDL document', () => {
}
`);
});

it('prints NamedType', () => {
expect(
print(parseType('MyType', { allowSemanticNullability: false }), {
useSemanticNullability: false,
}),
).to.equal(dedent`MyType`);
});

it('prints SemanticNullableType', () => {
expect(
print(parseType('MyType?', { allowSemanticNullability: true }), {
useSemanticNullability: true,
}),
).to.equal(dedent`MyType?`);
});
Comment on lines +192 to +198
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think anything printed with allowSemanticNullability, will need to be prefixed with the document directive (@SemanticNullability) because otherwise the contents are liable to misinterpretation.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's less allow and more just on or off, right? What it's set to changes how a type is interpreted:

Traditional @semanticNullability
Int? ERROR! Int
Int Int SemanticNonNull
Int! NonNull NonNull

Document directives must come at the top of the document, and in my mental model they effectively change the "mode" of the rest of the parsing. You might think of other document directives that might change the mode, e.g. @commentsAreDescriptions to re-enable the ancient parsing feature where comments (rather than strings) before fields would become descriptions; or @prefixedDirectives which might put directives before things rather than after. (I'm not proposing either of these be made real, I'm just trying to show that the space for document directives is not just a set of size 1.)

Once you hit your first non-ignored non-document-directive token the mode is then frozen, and you can pass that mode into parseType and other parsing related functions.

Thus I think it would be more like:

Suggested change
it('prints SemanticNullableType', () => {
expect(
print(parseType('MyType?', { allowSemanticNullability: true }), {
useSemanticNullability: true,
}),
).to.equal(dedent`MyType?`);
});
it('prints SemanticNullableType', () => {
expect(
print(parseType('MyType?', { mode: { semanticNullability: true }}), {
useSemanticNullability: true,
}),
).to.equal(dedent`MyType?`);
});

I'm not sure "mode" is really the right name to use for the result of applying document level directives, suggestions welcome. It could be that we just take the document-level directives and store their literal values into the object; for example:

@semanticNullability
@behavior(onError: PROPAGATE, onDeprecated: ERROR)
@compositeScalars(serialize: true)

{ __typename }

might have a "mode" of:

const parseOptions = {
  mode: {
    semanticNullability: {},
    behavior: { onError: "PROPAGATE", onDeprecated: "ERROR" },
    compositeScalars: { serialize: true },
  }
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Note that this mode should also be an output of parsing, e.g. we might use a document directive in an executable document to enable @noErrorPropagation - and we'll need to see that. Originally I thought that @noErrorPropagation would go on the operation itself rather than the document, but the issue with that is that fragments will have two modes if they're used in two operations with different @noErrorPropagation settings, so codegen becomes complicated. Instead, making it a document directive means it applies to all operations and fragments in a single document in unison.)


it('prints SemanticNonNullType', () => {
expect(
print(parseType('MyType', { allowSemanticNullability: true }), {
useSemanticNullability: true,
}),
).to.equal(dedent`MyType`);
});

it('prints NonNullType', () => {
expect(
print(parseType('MyType!', { allowSemanticNullability: true }), {
useSemanticNullability: true,
}),
).to.equal(dedent`MyType!`);
expect(
print(parseType('MyType!', { allowSemanticNullability: false }), {
useSemanticNullability: true,
}),
).to.equal(dedent`MyType!`);
});
});
Loading
Oops, something went wrong.