diff --git a/src/__tests__/starWarsIntrospection-test.ts b/src/__tests__/starWarsIntrospection-test.ts index d637787c4a..0dc95f0a7e 100644 --- a/src/__tests__/starWarsIntrospection-test.ts +++ b/src/__tests__/starWarsIntrospection-test.ts @@ -42,6 +42,7 @@ describe('Star Wars Introspection Tests', () => { { name: '__TypeKind' }, { name: '__Field' }, { name: '__InputValue' }, + { name: '__TypeNullability' }, { name: '__EnumValue' }, { name: '__Directive' }, { name: '__DirectiveLocation' }, diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index c758d3e426..a7bc1c8265 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -263,6 +263,7 @@ describe('Execute: Handles basic execution tasks', () => { 'rootValue', 'operation', 'variableValues', + 'errorPropagation', ); const operation = document.definitions[0]; @@ -275,6 +276,7 @@ describe('Execute: Handles basic execution tasks', () => { schema, rootValue, operation, + errorPropagation: true, }); const field = operation.selectionSet.selections[0]; diff --git a/src/execution/__tests__/semantic-nullability-test.ts b/src/execution/__tests__/semantic-nullability-test.ts new file mode 100644 index 0000000000..822a86bfc5 --- /dev/null +++ b/src/execution/__tests__/semantic-nullability-test.ts @@ -0,0 +1,180 @@ +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON'; + +import type { ObjMap } from '../../jsutils/ObjMap'; + +import { parse } from '../../language/parser'; + +import { + GraphQLNonNull, + GraphQLObjectType, + GraphQLSemanticNullable, +} 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: new GraphQLSemanticNullable(GraphQLString) }, + b: { type: GraphQLString }, + c: { type: new GraphQLNonNull(GraphQLString) }, + d: { type: DeepDataType }, + }), + }); + + const schema = new GraphQLSchema({ + useSemanticNullability: true, + query: DataType, + }); + + function executeWithSemanticNullability( + query: string, + rootValue: ObjMap, + ) { + return execute({ + schema, + document: parse(query), + rootValue, + }); + } + + it('SemanticNonNull throws error on null without error', async () => { + const data = { + b: () => null, + }; + + const query = ` + query { + b + } + `; + + const result = await executeWithSemanticNullability(query, data); + + expectJSON(result).toDeepEqual({ + data: { + b: null, + }, + errors: [ + { + message: + 'Cannot return null for semantic-non-nullable field DataType.b.', + path: ['b'], + locations: [{ line: 3, column: 9 }], + }, + ], + }); + }); + + it('SemanticNonNull succeeds on null with error', async () => { + const data = { + b: () => { + throw new Error('Something went wrong'); + }, + }; + + const query = ` + query { + b + } + `; + + const result = await executeWithSemanticNullability(query, data); + + expectJSON(result).toDeepEqual({ + data: { + b: null, + }, + errors: [ + { + message: 'Something went wrong', + path: ['b'], + locations: [{ line: 3, column: 9 }], + }, + ], + }); + }); + + it('SemanticNonNull halts null propagation', async () => { + const data = { + d: () => ({ + f: () => null, + }), + }; + + const query = ` + query { + d { + f + } + } + `; + + const result = await executeWithSemanticNullability(query, data); + + expectJSON(result).toDeepEqual({ + data: { + d: null, + }, + errors: [ + { + message: 'Cannot return null for non-nullable field DeepDataType.f.', + path: ['d', 'f'], + locations: [{ line: 4, column: 11 }], + }, + ], + }); + }); + + it('SemanticNullable allows null values', async () => { + const data = { + a: () => null, + }; + + const query = ` + query { + a + } + `; + + const result = await executeWithSemanticNullability(query, data); + + expectJSON(result).toDeepEqual({ + data: { + a: null, + }, + }); + }); + + it('SemanticNullable allows non-null values', async () => { + const data = { + a: () => 'Apple', + }; + + const query = ` + query { + a + } + `; + + const result = await executeWithSemanticNullability(query, data); + + expectJSON(result).toDeepEqual({ + data: { + a: 'Apple', + }, + }); + }); +}); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 55c22ea9de..7b753db08b 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -43,6 +43,7 @@ import { isListType, isNonNullType, isObjectType, + isSemanticNullableType, } from '../type/definition'; import { SchemaMetaFieldDef, @@ -115,6 +116,7 @@ export interface ExecutionContext { typeResolver: GraphQLTypeResolver; subscribeFieldResolver: GraphQLFieldResolver; errors: Array; + errorPropagation: boolean; } /** @@ -152,6 +154,12 @@ export interface ExecutionArgs { fieldResolver?: Maybe>; typeResolver?: Maybe>; subscribeFieldResolver?: Maybe>; + /** + * Set to `false` to disable error propagation. Experimental. + * + * @experimental + */ + errorPropagation?: boolean; } /** @@ -286,6 +294,7 @@ export function buildExecutionContext( fieldResolver, typeResolver, subscribeFieldResolver, + errorPropagation, } = args; let operation: OperationDefinitionNode | undefined; @@ -347,6 +356,7 @@ export function buildExecutionContext( typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, errors: [], + errorPropagation: errorPropagation ?? true, }; } @@ -585,6 +595,7 @@ export function buildResolveInfo( rootValue: exeContext.rootValue, operation: exeContext.operation, variableValues: exeContext.variableValues, + errorPropagation: exeContext.errorPropagation, }; } @@ -595,7 +606,7 @@ function handleFieldError( ): null { // If the field type is non-nullable, then it is resolved without any // protection from errors, however it still properly locates the error. - if (isNonNullType(returnType)) { + if (exeContext.errorPropagation && isNonNullType(returnType)) { throw error; } @@ -639,78 +650,83 @@ function completeValue( throw result; } - // If field type is NonNull, complete for inner type, and throw field error - // if result is null. + let nonNull; + let semanticNull; + let nullableType; if (isNonNullType(returnType)) { - const completed = completeValue( - exeContext, - returnType.ofType, - fieldNodes, - info, - path, - result, - ); - if (completed === null) { - throw new Error( - `Cannot return null for non-nullable field ${info.parentType.name}.${info.fieldName}.`, - ); - } - return completed; + nonNull = true; + nullableType = returnType.ofType; + } else if (isSemanticNullableType(returnType)) { + semanticNull = true; + nullableType = returnType.ofType; + } else { + nullableType = returnType; } - // If result value is null or undefined then return null. + let completed; if (result == null) { - return null; - } - - // If field type is List, complete each item in the list with the inner type - if (isListType(returnType)) { - return completeListValue( + // If result value is null or undefined then return null. + completed = null; + } else if (isListType(nullableType)) { + // If field type is List, complete each item in the list with the inner type + completed = completeListValue( exeContext, - returnType, + nullableType, fieldNodes, info, path, result, ); - } - - // If field type is a leaf type, Scalar or Enum, serialize to a valid value, - // returning null if serialization is not possible. - if (isLeafType(returnType)) { - return completeLeafValue(returnType, result); - } - - // If field type is an abstract type, Interface or Union, determine the - // runtime Object type and complete for that type. - if (isAbstractType(returnType)) { - return completeAbstractValue( + } else if (isLeafType(nullableType)) { + // If field type is a leaf type, Scalar or Enum, serialize to a valid value, + // returning null if serialization is not possible. + completed = completeLeafValue(nullableType, result); + } else if (isAbstractType(nullableType)) { + // If field type is an abstract type, Interface or Union, determine the + // runtime Object type and complete for that type. + completed = completeAbstractValue( exeContext, - returnType, + nullableType, fieldNodes, info, path, result, ); - } - - // If field type is Object, execute and complete all sub-selections. - if (isObjectType(returnType)) { - return completeObjectValue( + } else if (isObjectType(nullableType)) { + // If field type is Object, execute and complete all sub-selections. + completed = completeObjectValue( exeContext, - returnType, + nullableType, fieldNodes, info, path, result, ); + } else { + /* c8 ignore next 6 */ + // Not reachable, all possible output types have been considered. + invariant( + false, + 'Cannot complete value of unexpected output type: ' + + inspect(nullableType), + ); } - /* c8 ignore next 6 */ - // Not reachable, all possible output types have been considered. - invariant( - false, - 'Cannot complete value of unexpected output type: ' + inspect(returnType), - ); + + if (completed === null) { + if (nonNull) { + throw new Error( + `Cannot return null for non-nullable field ${info.parentType.name}.${info.fieldName}.`, + ); + } + + if (!semanticNull && exeContext.schema.usingSemanticNullability) { + throw new Error( + `Cannot return null for semantic-non-nullable field ${info.parentType.name}.${info.fieldName}.`, + ); + } + } + + return completed; } /** diff --git a/src/graphql.ts b/src/graphql.ts index bc6fb9bb72..d3f05f991e 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -66,6 +66,12 @@ export interface GraphQLArgs { operationName?: Maybe; fieldResolver?: Maybe>; typeResolver?: Maybe>; + /** + * Set to `false` to disable error propagation. Experimental. + * + * @experimental + */ + errorPropagation?: boolean; } export function graphql(args: GraphQLArgs): Promise { @@ -106,6 +112,7 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue { operationName, fieldResolver, typeResolver, + errorPropagation, } = args; // Validate Schema @@ -138,5 +145,6 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue { operationName, fieldResolver, typeResolver, + errorPropagation, }); } diff --git a/src/index.ts b/src/index.ts index 73c713a203..e2eb5ee073 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,6 +74,7 @@ export { __Schema, __Directive, __DirectiveLocation, + __TypeNullability, __Type, __Field, __InputValue, @@ -95,6 +96,7 @@ export { isInputObjectType, isListType, isNonNullType, + isSemanticNullableType, isInputType, isOutputType, isLeafType, @@ -120,6 +122,7 @@ export { assertInputObjectType, assertListType, assertNonNullType, + assertSemanticNullableType, assertInputType, assertOutputType, assertLeafType, @@ -287,6 +290,7 @@ export type { NamedTypeNode, ListTypeNode, NonNullTypeNode, + SemanticNullableTypeNode, TypeSystemDefinitionNode, SchemaDefinitionNode, OperationTypeDefinitionNode, @@ -481,6 +485,7 @@ export type { IntrospectionNamedTypeRef, IntrospectionListTypeRef, IntrospectionNonNullTypeRef, + IntrospectionSemanticNullableTypeRef, IntrospectionField, IntrospectionInputValue, IntrospectionEnumValue, diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index caa922a27d..4cb4a1465b 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -657,4 +657,65 @@ describe('Parser', () => { }); }); }); + + describe('parseDocumentDirective', () => { + it('does not 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', { useSemanticNullability: 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 nullable types', () => { + const result = parseType('MyType?', { useSemanticNullability: true }); + expectJSON(result).toDeepEqual({ + kind: Kind.SEMANTIC_NULLABLE_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', + }, + }, + }); + }); + + it('parses non-nullable types', () => { + const result = parseType('MyType!', { useSemanticNullability: 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', + }, + }, + }); + }); + }); }); diff --git a/src/language/__tests__/predicates-test.ts b/src/language/__tests__/predicates-test.ts index 13477f8de9..14f7f14707 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -92,6 +92,7 @@ describe('AST node predicates', () => { 'NamedType', 'ListType', 'NonNullType', + 'SemanticNullableType', ]); }); diff --git a/src/language/__tests__/schema-printer-test.ts b/src/language/__tests__/schema-printer-test.ts index 41cf6c5419..ea4be63a3b 100644 --- a/src/language/__tests__/schema-printer-test.ts +++ b/src/language/__tests__/schema-printer-test.ts @@ -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,31 @@ describe('Printer: SDL document', () => { } `); }); + + it('prints NamedType', () => { + expect( + print(parseType('MyType', { useSemanticNullability: false })), + ).to.equal(dedent`MyType`); + }); + + it('prints SemanticNullableType', () => { + expect( + print(parseType('MyType?', { useSemanticNullability: true })), + ).to.equal(dedent`MyType?`); + }); + + it('prints SemanticNonNullType', () => { + expect( + print(parseType('MyType', { useSemanticNullability: true })), + ).to.equal(dedent`MyType`); + }); + + it('prints NonNullType', () => { + expect( + print(parseType('MyType!', { useSemanticNullability: true })), + ).to.equal(dedent`MyType!`); + expect( + print(parseType('MyType!', { useSemanticNullability: false })), + ).to.equal(dedent`MyType!`); + }); }); diff --git a/src/language/ast.ts b/src/language/ast.ts index 6137eb6c1a..81154e88d7 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -161,6 +161,7 @@ export type ASTNode = | NamedTypeNode | ListTypeNode | NonNullTypeNode + | SemanticNullableTypeNode | SchemaDefinitionNode | OperationTypeDefinitionNode | ScalarTypeDefinitionNode @@ -235,6 +236,7 @@ export const QueryDocumentKeys: { NamedType: ['name'], ListType: ['type'], NonNullType: ['type'], + SemanticNullableType: ['type'], SchemaDefinition: ['description', 'directives', 'operationTypes'], OperationTypeDefinition: ['type'], @@ -521,7 +523,11 @@ export interface ConstDirectiveNode { /** Type Reference */ -export type TypeNode = NamedTypeNode | ListTypeNode | NonNullTypeNode; +export type TypeNode = + | NamedTypeNode + | ListTypeNode + | NonNullTypeNode + | SemanticNullableTypeNode; export interface NamedTypeNode { readonly kind: Kind.NAMED_TYPE; @@ -541,6 +547,12 @@ export interface NonNullTypeNode { readonly type: NamedTypeNode | ListTypeNode; } +export interface SemanticNullableTypeNode { + readonly kind: Kind.SEMANTIC_NULLABLE_TYPE; + readonly loc?: Location; + readonly type: NamedTypeNode | ListTypeNode; +} + /** Type System Definition */ export type TypeSystemDefinitionNode = diff --git a/src/language/index.ts b/src/language/index.ts index ec4d195e1a..1e4e4d947f 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -67,6 +67,7 @@ export type { NamedTypeNode, ListTypeNode, NonNullTypeNode, + SemanticNullableTypeNode, TypeSystemDefinitionNode, SchemaDefinitionNode, OperationTypeDefinitionNode, diff --git a/src/language/kinds.ts b/src/language/kinds.ts index cd05f66a3b..a9ff334472 100644 --- a/src/language/kinds.ts +++ b/src/language/kinds.ts @@ -37,6 +37,7 @@ enum Kind { NAMED_TYPE = 'NamedType', LIST_TYPE = 'ListType', NON_NULL_TYPE = 'NonNullType', + SEMANTIC_NULLABLE_TYPE = 'SemanticNullableType', /** Type System Definitions */ SCHEMA_DEFINITION = 'SchemaDefinition', diff --git a/src/language/lexer.ts b/src/language/lexer.ts index 818f81b286..1696fa5c83 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 || @@ -246,7 +247,7 @@ function readNextToken(lexer: Lexer, start: number): Token { // - FloatValue // - StringValue // - // Punctuator :: one of ! $ & ( ) ... : = @ [ ] { | } + // Punctuator :: one of ! $ & ( ) ? ... : = @ [ ] { | } case 0x0021: // ! return createToken(lexer, TokenKind.BANG, position, position + 1); case 0x0024: // $ @@ -257,6 +258,13 @@ function readNextToken(lexer: Lexer, start: number): Token { return createToken(lexer, TokenKind.PAREN_L, position, position + 1); case 0x0029: // ) return createToken(lexer, TokenKind.PAREN_R, position, position + 1); + case 0x003f: // ? + return createToken( + lexer, + TokenKind.QUESTION_MARK, + position, + position + 1, + ); case 0x002e: // . if ( body.charCodeAt(position + 1) === 0x002e && diff --git a/src/language/parser.ts b/src/language/parser.ts index 03e4166210..22303077ea 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -50,6 +50,7 @@ import type { SchemaExtensionNode, SelectionNode, SelectionSetNode, + SemanticNullableTypeNode, StringValueNode, Token, TypeNode, @@ -103,6 +104,8 @@ export interface ParseOptions { * ``` */ allowLegacyFragmentVariables?: boolean; + + useSemanticNullability?: boolean; } /** @@ -258,6 +261,16 @@ export class Parser { * - InputObjectTypeDefinition */ parseDefinition(): DefinitionNode { + const directives = this.parseDirectives(false); + // If a document-level SemanticNullability directive exists as + // the first element in a document, then all parsing will + // happen in SemanticNullability mode. + for (const directive of directives) { + if (directive.name.value === 'SemanticNullability') { + this._options.useSemanticNullability = true; + } + } + if (this.peek(TokenKind.BRACE_L)) { return this.parseOperationDefinition(); } @@ -749,6 +762,7 @@ export class Parser { * - NamedType * - ListType * - NonNullType + * - SemanticNullableType */ parseTypeReference(): TypeNode { const start = this._lexer.token; @@ -769,6 +783,14 @@ export class Parser { kind: Kind.NON_NULL_TYPE, type, }); + } else if ( + this._options.useSemanticNullability && + this.expectOptionalToken(TokenKind.QUESTION_MARK) + ) { + return this.node(start, { + kind: Kind.SEMANTIC_NULLABLE_TYPE, + type, + }); } return type; diff --git a/src/language/predicates.ts b/src/language/predicates.ts index a390f4ee55..5d1454147a 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -67,7 +67,8 @@ export function isTypeNode(node: ASTNode): node is TypeNode { return ( node.kind === Kind.NAMED_TYPE || node.kind === Kind.LIST_TYPE || - node.kind === Kind.NON_NULL_TYPE + node.kind === Kind.NON_NULL_TYPE || + node.kind === Kind.SEMANTIC_NULLABLE_TYPE ); } diff --git a/src/language/printer.ts b/src/language/printer.ts index e95c118d8b..bec2110721 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -131,6 +131,7 @@ const printDocASTReducer: ASTReducer = { NamedType: { leave: ({ name }) => name }, ListType: { leave: ({ type }) => '[' + type + ']' }, NonNullType: { leave: ({ type }) => type + '!' }, + SemanticNullableType: { leave: ({ type }) => type + '?' }, // Type System Definitions diff --git a/src/language/tokenKind.ts b/src/language/tokenKind.ts index 0c260df99e..0b651d36b0 100644 --- a/src/language/tokenKind.ts +++ b/src/language/tokenKind.ts @@ -6,6 +6,7 @@ enum TokenKind { SOF = '', EOF = '', BANG = '!', + QUESTION_MARK = '?', DOLLAR = '$', AMP = '&', PAREN_L = '(', diff --git a/src/type/__tests__/introspection-test.ts b/src/type/__tests__/introspection-test.ts index 8c5cacba0d..19e51732aa 100644 --- a/src/type/__tests__/introspection-test.ts +++ b/src/type/__tests__/introspection-test.ts @@ -437,6 +437,11 @@ describe('Introspection', () => { isDeprecated: false, deprecationReason: null, }, + { + name: 'SEMANTIC_NULLABLE', + isDeprecated: false, + deprecationReason: null, + }, ], possibleTypes: null, }, @@ -506,7 +511,21 @@ describe('Introspection', () => { }, { name: 'type', - args: [], + args: [ + { + name: 'nullability', + type: { + kind: 'NON_NULL', + name: null, + ofType: { + kind: 'ENUM', + name: '__TypeNullability', + ofType: null, + }, + }, + defaultValue: 'AUTO', + }, + ], type: { kind: 'NON_NULL', name: null, @@ -640,6 +659,37 @@ describe('Introspection', () => { enumValues: null, possibleTypes: null, }, + { + kind: 'ENUM', + name: '__TypeNullability', + specifiedByURL: null, + fields: null, + inputFields: null, + interfaces: null, + enumValues: [ + { + name: 'AUTO', + isDeprecated: false, + deprecationReason: null, + }, + { + name: 'TRADITIONAL', + isDeprecated: false, + deprecationReason: null, + }, + { + name: 'SEMANTIC', + isDeprecated: false, + deprecationReason: null, + }, + { + name: 'FULL', + isDeprecated: false, + deprecationReason: null, + }, + ], + possibleTypes: null, + }, { kind: 'OBJECT', name: '__EnumValue', diff --git a/src/type/__tests__/predicate-test.ts b/src/type/__tests__/predicate-test.ts index 81e721e7df..47017b560a 100644 --- a/src/type/__tests__/predicate-test.ts +++ b/src/type/__tests__/predicate-test.ts @@ -23,6 +23,7 @@ import { assertObjectType, assertOutputType, assertScalarType, + assertSemanticNullableType, assertType, assertUnionType, assertWrappingType, @@ -35,6 +36,7 @@ import { GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, + GraphQLSemanticNullable, GraphQLUnionType, isAbstractType, isCompositeType, @@ -52,6 +54,7 @@ import { isRequiredArgument, isRequiredInputField, isScalarType, + isSemanticNullableType, isType, isUnionType, isWrappingType, @@ -298,6 +301,43 @@ describe('Type predicates', () => { expect(() => assertNonNullType(new GraphQLList(new GraphQLNonNull(ObjectType))), ).to.throw(); + expect(isNonNullType(ObjectType)).to.equal(false); + expect(() => assertNonNullType(ObjectType)).to.throw(); + }); + }); + + describe('isSemanticNullableType', () => { + it('returns true for a semantic-non-null wrapped type', () => { + expect( + isSemanticNullableType(new GraphQLSemanticNullable(ObjectType)), + ).to.equal(true); + expect(() => + assertSemanticNullableType(new GraphQLSemanticNullable(ObjectType)), + ).to.not.throw(); + }); + + it('returns false for an unwrapped type', () => { + expect(isSemanticNullableType(ObjectType)).to.equal(false); + expect(() => assertSemanticNullableType(ObjectType)).to.throw(); + }); + + it('returns false for a not semantic-non-null wrapped type', () => { + expect( + isSemanticNullableType( + new GraphQLList(new GraphQLSemanticNullable(ObjectType)), + ), + ).to.equal(false); + expect(() => + assertSemanticNullableType( + new GraphQLList(new GraphQLSemanticNullable(ObjectType)), + ), + ).to.throw(); + expect(isSemanticNullableType(new GraphQLNonNull(ObjectType))).to.equal( + false, + ); + expect(() => + assertSemanticNullableType(new GraphQLNonNull(ObjectType)), + ).to.throw(); }); }); @@ -476,6 +516,12 @@ describe('Type predicates', () => { expect(() => assertWrappingType(new GraphQLNonNull(ObjectType)), ).to.not.throw(); + expect(isWrappingType(new GraphQLSemanticNullable(ObjectType))).to.equal( + true, + ); + expect(() => + assertWrappingType(new GraphQLSemanticNullable(ObjectType)), + ).to.not.throw(); }); it('returns false for unwrapped types', () => { @@ -497,6 +543,16 @@ describe('Type predicates', () => { expect(() => assertNullableType(new GraphQLList(new GraphQLNonNull(ObjectType))), ).to.not.throw(); + expect( + isNullableType( + new GraphQLList(new GraphQLSemanticNullable(ObjectType)), + ), + ).to.equal(true); + expect(() => + assertNullableType( + new GraphQLList(new GraphQLSemanticNullable(ObjectType)), + ), + ).to.not.throw(); }); it('returns false for non-null types', () => { @@ -504,6 +560,12 @@ describe('Type predicates', () => { expect(() => assertNullableType(new GraphQLNonNull(ObjectType)), ).to.throw(); + expect(isNullableType(new GraphQLSemanticNullable(ObjectType))).to.equal( + false, + ); + expect(() => + assertNullableType(new GraphQLSemanticNullable(ObjectType)), + ).to.throw(); }); }); diff --git a/src/type/__tests__/schema-test.ts b/src/type/__tests__/schema-test.ts index 8a31b50ada..dc2c7c75c8 100644 --- a/src/type/__tests__/schema-test.ts +++ b/src/type/__tests__/schema-test.ts @@ -301,6 +301,7 @@ describe('Type System: Schema', () => { '__TypeKind', '__Field', '__InputValue', + '__TypeNullability', '__EnumValue', '__Directive', '__DirectiveLocation', diff --git a/src/type/definition.ts b/src/type/definition.ts index 7eaac560dc..5d53a039c9 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -66,6 +66,15 @@ export type GraphQLType = | GraphQLEnumType | GraphQLInputObjectType | GraphQLList + > + | GraphQLSemanticNullable< + | GraphQLScalarType + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | GraphQLEnumType + | GraphQLInputObjectType + | GraphQLList >; export function isType(type: unknown): type is GraphQLType { @@ -77,7 +86,8 @@ export function isType(type: unknown): type is GraphQLType { isEnumType(type) || isInputObjectType(type) || isListType(type) || - isNonNullType(type) + isNonNullType(type) || + isSemanticNullableType(type) ); } @@ -203,6 +213,32 @@ export function assertNonNullType(type: unknown): GraphQLNonNull { return type; } +export function isSemanticNullableType( + type: GraphQLInputType, +): type is GraphQLSemanticNullable; +export function isSemanticNullableType( + type: GraphQLOutputType, +): type is GraphQLSemanticNullable; +export function isSemanticNullableType( + type: unknown, +): type is GraphQLSemanticNullable; +export function isSemanticNullableType( + type: unknown, +): type is GraphQLSemanticNullable { + return instanceOf(type, GraphQLSemanticNullable); +} + +export function assertSemanticNullableType( + type: unknown, +): GraphQLSemanticNullable { + if (!isSemanticNullableType(type)) { + throw new Error( + `Expected ${inspect(type)} to be a GraphQL Semantic-Non-Null type.`, + ); + } + return type; +} + /** * These types may be used as input types for arguments and directives. */ @@ -218,12 +254,15 @@ export type GraphQLInputType = | GraphQLList >; +// Note: GraphQLSemanticNullableType is currently not allowed for input types export function isInputType(type: unknown): type is GraphQLInputType { return ( isScalarType(type) || isEnumType(type) || isInputObjectType(type) || - (isWrappingType(type) && isInputType(type.ofType)) + (!isSemanticNullableType(type) && + isWrappingType(type) && + isInputType(type.ofType)) ); } @@ -251,6 +290,14 @@ export type GraphQLOutputType = | GraphQLUnionType | GraphQLEnumType | GraphQLList + > + | GraphQLSemanticNullable< + | GraphQLScalarType + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | GraphQLEnumType + | GraphQLList >; export function isOutputType(type: unknown): type is GraphQLOutputType { @@ -414,16 +461,65 @@ export class GraphQLNonNull { } } +/** + * Semantic-Nullable Type Wrapper + * + * A semantic-nullable is a wrapping type which points to another type. + * Semantic-nullable types allow their values to be null. + * + * Example: + * + * ```ts + * const RowType = new GraphQLObjectType({ + * name: 'Row', + * fields: () => ({ + * email: { type: new GraphQLSemanticNullable(GraphQLString) }, + * }) + * }) + * ``` + * Note: This is equivalent to the unadorned named type that is + * used by GraphQL when it is not operating in SemanticNullability mode. + * + * @experimental + */ +export class GraphQLSemanticNullable { + readonly ofType: T; + + constructor(ofType: T) { + devAssert( + isNullableType(ofType), + `Expected ${inspect(ofType)} to be a GraphQL nullable type.`, + ); + + this.ofType = ofType; + } + + get [Symbol.toStringTag]() { + return 'GraphQLSemanticNullable'; + } + + toString(): string { + return String(this.ofType) + '?'; + } + + toJSON(): string { + return this.toString(); + } +} + /** * These types wrap and modify other types */ export type GraphQLWrappingType = | GraphQLList - | GraphQLNonNull; + | GraphQLNonNull + | GraphQLSemanticNullable; export function isWrappingType(type: unknown): type is GraphQLWrappingType { - return isListType(type) || isNonNullType(type); + return ( + isListType(type) || isNonNullType(type) || isSemanticNullableType(type) + ); } export function assertWrappingType(type: unknown): GraphQLWrappingType { @@ -446,7 +542,7 @@ export type GraphQLNullableType = | GraphQLList; export function isNullableType(type: unknown): type is GraphQLNullableType { - return isType(type) && !isNonNullType(type); + return isType(type) && !isNonNullType(type) && !isSemanticNullableType(type); } export function assertNullableType(type: unknown): GraphQLNullableType { @@ -458,7 +554,7 @@ export function assertNullableType(type: unknown): GraphQLNullableType { export function getNullableType(type: undefined | null): void; export function getNullableType( - type: T | GraphQLNonNull, + type: T | GraphQLNonNull | GraphQLSemanticNullable, ): T; export function getNullableType( type: Maybe, @@ -467,12 +563,14 @@ export function getNullableType( type: Maybe, ): GraphQLNullableType | undefined { if (type) { - return isNonNullType(type) ? type.ofType : type; + return isNonNullType(type) || isSemanticNullableType(type) + ? type.ofType + : type; } } /** - * These named types do not include modifiers like List or NonNull. + * These named types do not include modifiers like List, NonNull, or SemanticNullable */ export type GraphQLNamedType = GraphQLNamedInputType | GraphQLNamedOutputType; @@ -988,6 +1086,8 @@ export interface GraphQLResolveInfo { readonly rootValue: unknown; readonly operation: OperationDefinitionNode; readonly variableValues: { [variable: string]: unknown }; + /** @experimental */ + readonly errorPropagation: boolean; } /** @@ -1070,6 +1170,7 @@ export interface GraphQLArgument { } export function isRequiredArgument(arg: GraphQLArgument): boolean { + // Note: input types cannot be SemanticNullable return isNonNullType(arg.type) && arg.defaultValue === undefined; } @@ -1761,6 +1862,7 @@ export interface GraphQLInputField { } export function isRequiredInputField(field: GraphQLInputField): boolean { + // Note: input types cannot be SemanticNullable return isNonNullType(field.type) && field.defaultValue === undefined; } diff --git a/src/type/index.ts b/src/type/index.ts index cf276d1e02..ccffbaf93d 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -23,6 +23,7 @@ export { isInputObjectType, isListType, isNonNullType, + isSemanticNullableType, isInputType, isOutputType, isLeafType, @@ -43,6 +44,7 @@ export { assertInputObjectType, assertListType, assertNonNullType, + assertSemanticNullableType, assertInputType, assertOutputType, assertLeafType, @@ -64,6 +66,7 @@ export { // Type Wrappers GraphQLList, GraphQLNonNull, + GraphQLSemanticNullable, } from './definition'; export type { @@ -167,6 +170,7 @@ export { __Schema, __Directive, __DirectiveLocation, + __TypeNullability, __Type, __Field, __InputValue, diff --git a/src/type/introspection.ts b/src/type/introspection.ts index 2c66ca5098..1e90eb1abf 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -19,6 +19,7 @@ import { GraphQLList, GraphQLNonNull, GraphQLObjectType, + GraphQLSemanticNullable, isAbstractType, isEnumType, isInputObjectType, @@ -27,6 +28,7 @@ import { isNonNullType, isObjectType, isScalarType, + isSemanticNullableType, isUnionType, } from './definition'; import type { GraphQLDirective } from './directives'; @@ -40,7 +42,7 @@ export const __Schema: GraphQLObjectType = new GraphQLObjectType({ fields: () => ({ description: { - type: GraphQLString, + type: new GraphQLSemanticNullable(GraphQLString), resolve: (schema) => schema.description, }, types: { @@ -58,13 +60,13 @@ export const __Schema: GraphQLObjectType = new GraphQLObjectType({ mutationType: { description: 'If this server supports mutation, the type that mutation operations will be rooted at.', - type: __Type, + type: new GraphQLSemanticNullable(__Type), resolve: (schema) => schema.getMutationType(), }, subscriptionType: { description: 'If this server support subscription, the type that subscription operations will be rooted at.', - type: __Type, + type: new GraphQLSemanticNullable(__Type), resolve: (schema) => schema.getSubscriptionType(), }, directives: { @@ -88,7 +90,7 @@ export const __Directive: GraphQLObjectType = new GraphQLObjectType({ resolve: (directive) => directive.name, }, description: { - type: GraphQLString, + type: new GraphQLSemanticNullable(GraphQLString), resolve: (directive) => directive.description, }, isRepeatable: { @@ -204,6 +206,40 @@ export const __DirectiveLocation: GraphQLEnumType = new GraphQLEnumType({ }, }); +// TODO: rename enum and options +enum TypeNullability { + AUTO = 'AUTO', + TRADITIONAL = 'TRADITIONAL', + SEMANTIC = 'SEMANTIC', + FULL = 'FULL', +} + +// TODO: rename +export const __TypeNullability: GraphQLEnumType = new GraphQLEnumType({ + name: '__TypeNullability', + description: 'TODO', + values: { + AUTO: { + value: TypeNullability.AUTO, + description: + 'Determines nullability mode based on errorPropagation mode.', + }, + TRADITIONAL: { + value: TypeNullability.TRADITIONAL, + description: 'Turn semantic-non-null types into nullable types.', + }, + SEMANTIC: { + value: TypeNullability.SEMANTIC, + description: 'Turn non-null types into semantic-non-null types.', + }, + FULL: { + value: TypeNullability.FULL, + description: + 'Render the true nullability in the schema; be prepared for new types of nullability in future!', + }, + }, +}); + export const __Type: GraphQLObjectType = new GraphQLObjectType({ name: '__Type', description: @@ -237,29 +273,34 @@ export const __Type: GraphQLObjectType = new GraphQLObjectType({ if (isNonNullType(type)) { return TypeKind.NON_NULL; } + if (isSemanticNullableType(type)) { + return TypeKind.SEMANTIC_NULLABLE; + } /* c8 ignore next 3 */ // Not reachable, all possible types have been considered) invariant(false, `Unexpected type: "${inspect(type)}".`); }, }, name: { - type: GraphQLString, + type: new GraphQLSemanticNullable(GraphQLString), resolve: (type) => ('name' in type ? type.name : undefined), }, description: { - type: GraphQLString, + type: new GraphQLSemanticNullable(GraphQLString), resolve: (type) => // FIXME: add test case /* c8 ignore next */ 'description' in type ? type.description : undefined, }, specifiedByURL: { - type: GraphQLString, + type: new GraphQLSemanticNullable(GraphQLString), resolve: (obj) => 'specifiedByURL' in obj ? obj.specifiedByURL : undefined, }, fields: { - type: new GraphQLList(new GraphQLNonNull(__Field)), + type: new GraphQLSemanticNullable( + new GraphQLList(new GraphQLNonNull(__Field)), + ), args: { includeDeprecated: { type: GraphQLBoolean, defaultValue: false }, }, @@ -273,7 +314,9 @@ export const __Type: GraphQLObjectType = new GraphQLObjectType({ }, }, interfaces: { - type: new GraphQLList(new GraphQLNonNull(__Type)), + type: new GraphQLSemanticNullable( + new GraphQLList(new GraphQLNonNull(__Type)), + ), resolve(type) { if (isObjectType(type) || isInterfaceType(type)) { return type.getInterfaces(); @@ -281,7 +324,9 @@ export const __Type: GraphQLObjectType = new GraphQLObjectType({ }, }, possibleTypes: { - type: new GraphQLList(new GraphQLNonNull(__Type)), + type: new GraphQLSemanticNullable( + new GraphQLList(new GraphQLNonNull(__Type)), + ), resolve(type, _args, _context, { schema }) { if (isAbstractType(type)) { return schema.getPossibleTypes(type); @@ -289,7 +334,9 @@ export const __Type: GraphQLObjectType = new GraphQLObjectType({ }, }, enumValues: { - type: new GraphQLList(new GraphQLNonNull(__EnumValue)), + type: new GraphQLSemanticNullable( + new GraphQLList(new GraphQLNonNull(__EnumValue)), + ), args: { includeDeprecated: { type: GraphQLBoolean, defaultValue: false }, }, @@ -303,7 +350,9 @@ export const __Type: GraphQLObjectType = new GraphQLObjectType({ }, }, inputFields: { - type: new GraphQLList(new GraphQLNonNull(__InputValue)), + type: new GraphQLSemanticNullable( + new GraphQLList(new GraphQLNonNull(__InputValue)), + ), args: { includeDeprecated: { type: GraphQLBoolean, @@ -320,11 +369,11 @@ export const __Type: GraphQLObjectType = new GraphQLObjectType({ }, }, ofType: { - type: __Type, + type: new GraphQLSemanticNullable(__Type), resolve: (type) => ('ofType' in type ? type.ofType : undefined), }, isOneOf: { - type: GraphQLBoolean, + type: new GraphQLSemanticNullable(GraphQLBoolean), resolve: (type) => { if (isInputObjectType(type)) { return type.isOneOf; @@ -345,7 +394,7 @@ export const __Field: GraphQLObjectType = new GraphQLObjectType({ resolve: (field) => field.name, }, description: { - type: GraphQLString, + type: new GraphQLSemanticNullable(GraphQLString), resolve: (field) => field.description, }, args: { @@ -366,19 +415,65 @@ export const __Field: GraphQLObjectType = new GraphQLObjectType({ }, type: { type: new GraphQLNonNull(__Type), - resolve: (field) => field.type, + args: { + nullability: { + type: new GraphQLNonNull(__TypeNullability), + defaultValue: TypeNullability.AUTO, + }, + }, + resolve: (field, { nullability }, _context, info) => { + if (nullability === TypeNullability.FULL) { + return field.type; + } + const mode = + nullability === TypeNullability.AUTO + ? info.errorPropagation + ? TypeNullability.TRADITIONAL + : TypeNullability.SEMANTIC + : nullability; + return convertOutputTypeToNullabilityMode(field.type, mode); + }, }, isDeprecated: { type: new GraphQLNonNull(GraphQLBoolean), resolve: (field) => field.deprecationReason != null, }, deprecationReason: { - type: GraphQLString, + type: new GraphQLSemanticNullable(GraphQLString), resolve: (field) => field.deprecationReason, }, } as GraphQLFieldConfigMap, unknown>), }); +// TODO: move this elsewhere, rename, memoize +function convertOutputTypeToNullabilityMode( + type: GraphQLType, + mode: TypeNullability.TRADITIONAL | TypeNullability.SEMANTIC, +): GraphQLType { + if (mode === TypeNullability.TRADITIONAL) { + if (isNonNullType(type)) { + return new GraphQLNonNull( + convertOutputTypeToNullabilityMode(type.ofType, mode), + ); + } else if (isSemanticNullableType(type)) { + return convertOutputTypeToNullabilityMode(type.ofType, mode); + } else if (isListType(type)) { + return new GraphQLList( + convertOutputTypeToNullabilityMode(type.ofType, mode), + ); + } + return type; + } + if (isNonNullType(type) || !isSemanticNullableType(type)) { + return convertOutputTypeToNullabilityMode(type, mode); + } else if (isListType(type)) { + return new GraphQLList( + convertOutputTypeToNullabilityMode(type.ofType, mode), + ); + } + return type; +} + export const __InputValue: GraphQLObjectType = new GraphQLObjectType({ name: '__InputValue', description: @@ -390,7 +485,7 @@ export const __InputValue: GraphQLObjectType = new GraphQLObjectType({ resolve: (inputValue) => inputValue.name, }, description: { - type: GraphQLString, + type: new GraphQLSemanticNullable(GraphQLString), resolve: (inputValue) => inputValue.description, }, type: { @@ -398,7 +493,7 @@ export const __InputValue: GraphQLObjectType = new GraphQLObjectType({ resolve: (inputValue) => inputValue.type, }, defaultValue: { - type: GraphQLString, + type: new GraphQLSemanticNullable(GraphQLString), description: 'A GraphQL-formatted string representing the default value for this input value.', resolve(inputValue) { @@ -412,7 +507,7 @@ export const __InputValue: GraphQLObjectType = new GraphQLObjectType({ resolve: (field) => field.deprecationReason != null, }, deprecationReason: { - type: GraphQLString, + type: new GraphQLSemanticNullable(GraphQLString), resolve: (obj) => obj.deprecationReason, }, } as GraphQLFieldConfigMap), @@ -429,7 +524,7 @@ export const __EnumValue: GraphQLObjectType = new GraphQLObjectType({ resolve: (enumValue) => enumValue.name, }, description: { - type: GraphQLString, + type: new GraphQLSemanticNullable(GraphQLString), resolve: (enumValue) => enumValue.description, }, isDeprecated: { @@ -437,7 +532,7 @@ export const __EnumValue: GraphQLObjectType = new GraphQLObjectType({ resolve: (enumValue) => enumValue.deprecationReason != null, }, deprecationReason: { - type: GraphQLString, + type: new GraphQLSemanticNullable(GraphQLString), resolve: (enumValue) => enumValue.deprecationReason, }, } as GraphQLFieldConfigMap), @@ -452,6 +547,7 @@ enum TypeKind { INPUT_OBJECT = 'INPUT_OBJECT', LIST = 'LIST', NON_NULL = 'NON_NULL', + SEMANTIC_NULLABLE = 'SEMANTIC_NULLABLE', } export { TypeKind }; @@ -497,6 +593,11 @@ export const __TypeKind: GraphQLEnumType = new GraphQLEnumType({ description: 'Indicates this type is a non-null. `ofType` is a valid field.', }, + SEMANTIC_NULLABLE: { + value: TypeKind.SEMANTIC_NULLABLE, + description: + 'Indicates this type is a semantic-nullable. `ofType` is a valid field.', + }, }, }); @@ -553,6 +654,7 @@ export const introspectionTypes: ReadonlyArray = __Schema, __Directive, __DirectiveLocation, + __TypeNullability, __Type, __Field, __InputValue, diff --git a/src/type/schema.ts b/src/type/schema.ts index 97c2782145..16c6a04abb 100644 --- a/src/type/schema.ts +++ b/src/type/schema.ts @@ -128,6 +128,7 @@ export interface GraphQLSchemaExtensions { * ``` */ export class GraphQLSchema { + usingSemanticNullability: Maybe; description: Maybe; extensions: Readonly; astNode: Maybe; @@ -164,6 +165,7 @@ export class GraphQLSchema { `${inspect(config.directives)}.`, ); + this.usingSemanticNullability = config.useSemanticNullability; this.description = config.description; this.extensions = toObjMap(config.extensions); this.astNode = config.astNode; @@ -352,6 +354,7 @@ export class GraphQLSchema { toConfig(): GraphQLSchemaNormalizedConfig { return { + useSemanticNullability: this.usingSemanticNullability, description: this.description, query: this.getQueryType(), mutation: this.getMutationType(), @@ -380,6 +383,7 @@ export interface GraphQLSchemaValidationOptions { } export interface GraphQLSchemaConfig extends GraphQLSchemaValidationOptions { + useSemanticNullability?: Maybe; description?: Maybe; query?: Maybe; mutation?: Maybe; @@ -395,6 +399,7 @@ export interface GraphQLSchemaConfig extends GraphQLSchemaValidationOptions { * @internal */ export interface GraphQLSchemaNormalizedConfig extends GraphQLSchemaConfig { + useSemanticNullability?: Maybe; description: Maybe; types: ReadonlyArray; directives: ReadonlyArray; diff --git a/src/utilities/__tests__/printSchema-test.ts b/src/utilities/__tests__/printSchema-test.ts index 37af4a60f7..a865889376 100644 --- a/src/utilities/__tests__/printSchema-test.ts +++ b/src/utilities/__tests__/printSchema-test.ts @@ -770,6 +770,9 @@ describe('Type System Printer', () => { """Indicates this type is a non-null. \`ofType\` is a valid field.""" NON_NULL + + """Indicates this type is a semantic-nullable. \`ofType\` is a valid field.""" + SEMANTIC_NULLABLE } """ @@ -779,7 +782,7 @@ describe('Type System Printer', () => { name: String! description: String args(includeDeprecated: Boolean = false): [__InputValue!]! - type: __Type! + type(nullability: __TypeNullability! = AUTO): __Type! isDeprecated: Boolean! deprecationReason: String } @@ -800,6 +803,23 @@ describe('Type System Printer', () => { deprecationReason: String } + """TODO""" + enum __TypeNullability { + """Determines nullability mode based on errorPropagation mode.""" + AUTO + + """Turn semantic-non-null types into nullable types.""" + TRADITIONAL + + """Turn non-null types into semantic-non-null types.""" + SEMANTIC + + """ + Render the true nullability in the schema; be prepared for new types of nullability in future! + """ + FULL + } + """ One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string. """ diff --git a/src/utilities/astFromValue.ts b/src/utilities/astFromValue.ts index 1a880449c8..3a655c321b 100644 --- a/src/utilities/astFromValue.ts +++ b/src/utilities/astFromValue.ts @@ -42,6 +42,7 @@ export function astFromValue( value: unknown, type: GraphQLInputType, ): Maybe { + // Note: input types cannot be SemanticNullable if (isNonNullType(type)) { const astValue = astFromValue(value, type.ofType); if (astValue?.kind === Kind.NULL) { diff --git a/src/utilities/buildASTSchema.ts b/src/utilities/buildASTSchema.ts index eeff08e6ed..b4e7134d26 100644 --- a/src/utilities/buildASTSchema.ts +++ b/src/utilities/buildASTSchema.ts @@ -46,7 +46,17 @@ export function buildASTSchema( assertValidSDL(documentAST); } + let useSemanticNullability; + for (const definition of documentAST.definitions) { + if (definition.kind === Kind.DIRECTIVE_DEFINITION) { + if (definition.name.value === 'SemanticNullability') { + useSemanticNullability = true; + } + } + } + const emptySchemaConfig = { + useSemanticNullability, description: undefined, types: [], directives: [], @@ -102,6 +112,7 @@ export function buildSchema( const document = parse(source, { noLocation: options?.noLocation, allowLegacyFragmentVariables: options?.allowLegacyFragmentVariables, + useSemanticNullability: options?.useSemanticNullability, }); return buildASTSchema(document, { diff --git a/src/utilities/buildClientSchema.ts b/src/utilities/buildClientSchema.ts index 83f6abada8..1329a7a4d9 100644 --- a/src/utilities/buildClientSchema.ts +++ b/src/utilities/buildClientSchema.ts @@ -22,6 +22,7 @@ import { GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, + GraphQLSemanticNullable, GraphQLUnionType, isInputType, isOutputType, @@ -137,6 +138,14 @@ export function buildClientSchema( const nullableType = getType(nullableRef); return new GraphQLNonNull(assertNullableType(nullableType)); } + if (typeRef.kind === TypeKind.SEMANTIC_NULLABLE) { + const nonSemanticNullableRef = typeRef.ofType; + if (!nonSemanticNullableRef) { + throw new Error('Decorated type deeper than introspection query.'); + } + const nullableType = getType(nonSemanticNullableRef); + return new GraphQLSemanticNullable(assertNullableType(nullableType)); + } return getNamedType(typeRef); } diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index d53752d919..7895cdc378 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -53,6 +53,7 @@ import { GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, + GraphQLSemanticNullable, GraphQLUnionType, isEnumType, isInputObjectType, @@ -61,6 +62,7 @@ import { isNonNullType, isObjectType, isScalarType, + isSemanticNullableType, isUnionType, } from '../type/definition'; import { @@ -90,6 +92,8 @@ interface Options extends GraphQLSchemaValidationOptions { * Default: false */ assumeValidSDL?: boolean; + + useSemanticNullability?: boolean; } /** @@ -225,6 +229,10 @@ export function extendSchemaImpl( // @ts-expect-error return new GraphQLNonNull(replaceType(type.ofType)); } + if (isSemanticNullableType(type)) { + // @ts-expect-error + return new GraphQLSemanticNullable(replaceType(type.ofType)); + } // @ts-expect-error FIXME return replaceNamedType(type); } @@ -432,6 +440,9 @@ export function extendSchemaImpl( if (node.kind === Kind.NON_NULL_TYPE) { return new GraphQLNonNull(getWrappedType(node.type)); } + if (node.kind === Kind.SEMANTIC_NULLABLE_TYPE) { + return new GraphQLSemanticNullable(getWrappedType(node.type)); + } return getNamedType(node); } diff --git a/src/utilities/findBreakingChanges.ts b/src/utilities/findBreakingChanges.ts index 2489af9d62..5c6b84dd7d 100644 --- a/src/utilities/findBreakingChanges.ts +++ b/src/utilities/findBreakingChanges.ts @@ -26,6 +26,7 @@ import { isRequiredArgument, isRequiredInputField, isScalarType, + isSemanticNullableType, isUnionType, } from '../type/definition'; import { isSpecifiedScalarType } from '../type/scalars'; @@ -456,7 +457,7 @@ function isChangeSafeForObjectOrInterfaceField( oldType.ofType, newType.ofType, )) || - // moving from nullable to non-null of the same underlying type is safe + // moving from semantic-non-null to non-null of the same underlying type is safe (isNonNullType(newType) && isChangeSafeForObjectOrInterfaceField(oldType, newType.ofType)) ); @@ -470,10 +471,25 @@ function isChangeSafeForObjectOrInterfaceField( ); } + if (isSemanticNullableType(oldType)) { + return ( + // if they're both semantic-nullable, make sure the underlying types are compatible + isSemanticNullableType(newType) || + (isNonNullType(newType) && + isChangeSafeForObjectOrInterfaceField( + oldType.ofType, + newType.ofType, + )) || + // moving from semantic-nullable to semantic-non-null of the same underlying type is safe + (isNonNullType(newType) && + isChangeSafeForObjectOrInterfaceField(oldType.ofType, newType.ofType)) + ); + } + return ( // if they're both named types, see if their names are equivalent (isNamedType(newType) && oldType.name === newType.name) || - // moving from nullable to non-null of the same underlying type is safe + // moving from semantic-non-null to non-null of the same underlying type is safe (isNonNullType(newType) && isChangeSafeForObjectOrInterfaceField(oldType, newType.ofType)) ); diff --git a/src/utilities/getIntrospectionQuery.ts b/src/utilities/getIntrospectionQuery.ts index 373b474ed5..7318d1909c 100644 --- a/src/utilities/getIntrospectionQuery.ts +++ b/src/utilities/getIntrospectionQuery.ts @@ -38,6 +38,17 @@ export interface IntrospectionOptions { * Default: false */ oneOf?: boolean; + + /** + * Choose the type of nullability you would like to see. + * + * - AUTO: SEMANTIC if errorPropagation is set to false, otherwise TRADITIONAL + * - TRADITIONAL: all GraphQLSemanticNonNull will be unwrapped + * - SEMANTIC: all GraphQLNonNull will be converted to GraphQLSemanticNonNull + * - FULL: the true nullability will be returned + * + */ + nullability?: 'AUTO' | 'TRADITIONAL' | 'SEMANTIC' | 'FULL'; } /** @@ -52,6 +63,7 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { schemaDescription: false, inputValueDeprecation: false, oneOf: false, + nullability: null, ...options, }; @@ -70,6 +82,7 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { return optionsWithDefault.inputValueDeprecation ? str : ''; } const oneOf = optionsWithDefault.oneOf ? 'isOneOf' : ''; + const nullability = optionsWithDefault.nullability; return ` query IntrospectionQuery { @@ -105,7 +118,7 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { args${inputDeprecation('(includeDeprecated: true)')} { ...InputValue } - type { + type${nullability ? `(nullability: ${nullability})` : ''} { ...TypeRef } isDeprecated @@ -285,11 +298,21 @@ export interface IntrospectionNonNullTypeRef< readonly ofType: T; } +export interface IntrospectionSemanticNullableTypeRef< + T extends IntrospectionTypeRef = IntrospectionTypeRef, +> { + readonly kind: 'SEMANTIC_NULLABLE'; + readonly ofType: T; +} + export type IntrospectionTypeRef = | IntrospectionNamedTypeRef | IntrospectionListTypeRef | IntrospectionNonNullTypeRef< IntrospectionNamedTypeRef | IntrospectionListTypeRef + > + | IntrospectionSemanticNullableTypeRef< + IntrospectionNamedTypeRef | IntrospectionListTypeRef >; export type IntrospectionOutputTypeRef = diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 452b975233..f612dd6daa 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -20,6 +20,7 @@ export type { IntrospectionNamedTypeRef, IntrospectionListTypeRef, IntrospectionNonNullTypeRef, + IntrospectionSemanticNullableTypeRef, IntrospectionField, IntrospectionInputValue, IntrospectionEnumValue, diff --git a/src/utilities/lexicographicSortSchema.ts b/src/utilities/lexicographicSortSchema.ts index 26b6908c9f..dfb70e663b 100644 --- a/src/utilities/lexicographicSortSchema.ts +++ b/src/utilities/lexicographicSortSchema.ts @@ -19,6 +19,7 @@ import { GraphQLList, GraphQLNonNull, GraphQLObjectType, + GraphQLSemanticNullable, GraphQLUnionType, isEnumType, isInputObjectType, @@ -27,6 +28,7 @@ import { isNonNullType, isObjectType, isScalarType, + isSemanticNullableType, isUnionType, } from '../type/definition'; import { GraphQLDirective } from '../type/directives'; @@ -62,6 +64,9 @@ export function lexicographicSortSchema(schema: GraphQLSchema): GraphQLSchema { } else if (isNonNullType(type)) { // @ts-expect-error return new GraphQLNonNull(replaceType(type.ofType)); + } else if (isSemanticNullableType(type)) { + // @ts-expect-error + return new GraphQLSemanticNullable(replaceType(type.ofType)); } // @ts-expect-error FIXME: TS Conversion return replaceNamedType(type); diff --git a/src/utilities/printSchema.ts b/src/utilities/printSchema.ts index edac6262c5..d13012db60 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -14,6 +14,7 @@ import type { GraphQLInterfaceType, GraphQLNamedType, GraphQLObjectType, + GraphQLOutputType, GraphQLScalarType, GraphQLUnionType, } from '../type/definition'; @@ -23,6 +24,7 @@ import { isInterfaceType, isObjectType, isScalarType, + isSemanticNullableType, isUnionType, } from '../type/definition'; import type { GraphQLDirective } from '../type/directives'; @@ -36,16 +38,32 @@ import type { GraphQLSchema } from '../type/schema'; import { astFromValue } from './astFromValue'; -export function printSchema(schema: GraphQLSchema): string { +interface PrintOptions { + usingSemanticNullability?: boolean; +} + +export function printSchema( + schema: GraphQLSchema, + options: PrintOptions = {}, +): string { return printFilteredSchema( schema, (n) => !isSpecifiedDirective(n), isDefinedType, + options, ); } -export function printIntrospectionSchema(schema: GraphQLSchema): string { - return printFilteredSchema(schema, isSpecifiedDirective, isIntrospectionType); +export function printIntrospectionSchema( + schema: GraphQLSchema, + options: PrintOptions = {}, +): string { + return printFilteredSchema( + schema, + isSpecifiedDirective, + isIntrospectionType, + options, + ); } function isDefinedType(type: GraphQLNamedType): boolean { @@ -56,14 +74,19 @@ function printFilteredSchema( schema: GraphQLSchema, directiveFilter: (type: GraphQLDirective) => boolean, typeFilter: (type: GraphQLNamedType) => boolean, + options: PrintOptions, ): string { const directives = schema.getDirectives().filter(directiveFilter); const types = Object.values(schema.getTypeMap()).filter(typeFilter); + const usingSemanticNullability = + options.usingSemanticNullability ?? schema.usingSemanticNullability; + return [ + usingSemanticNullability ? '@SemanticNullability' : undefined, printSchemaDefinition(schema), ...directives.map((directive) => printDirective(directive)), - ...types.map((type) => printType(type)), + ...types.map((type) => printType(type, usingSemanticNullability)), ] .filter(Boolean) .join('\n\n'); @@ -128,15 +151,18 @@ function isSchemaOfCommonNames(schema: GraphQLSchema): boolean { return true; } -export function printType(type: GraphQLNamedType): string { +export function printType( + type: GraphQLNamedType, + usingSemanticNullability?: Maybe, +): string { if (isScalarType(type)) { return printScalar(type); } if (isObjectType(type)) { - return printObject(type); + return printObject(type, usingSemanticNullability); } if (isInterfaceType(type)) { - return printInterface(type); + return printInterface(type, usingSemanticNullability); } if (isUnionType(type)) { return printUnion(type); @@ -167,21 +193,27 @@ function printImplementedInterfaces( : ''; } -function printObject(type: GraphQLObjectType): string { +function printObject( + type: GraphQLObjectType, + usingSemanticNullability: Maybe, +): string { return ( printDescription(type) + `type ${type.name}` + printImplementedInterfaces(type) + - printFields(type) + printFields(type, usingSemanticNullability) ); } -function printInterface(type: GraphQLInterfaceType): string { +function printInterface( + type: GraphQLInterfaceType, + usingSemanticNullability: Maybe, +): string { return ( printDescription(type) + `interface ${type.name}` + printImplementedInterfaces(type) + - printFields(type) + printFields(type, usingSemanticNullability) ); } @@ -217,7 +249,10 @@ function printInputObject(type: GraphQLInputObjectType): string { ); } -function printFields(type: GraphQLObjectType | GraphQLInterfaceType): string { +function printFields( + type: GraphQLObjectType | GraphQLInterfaceType, + usingSemanticNullability: Maybe, +): string { const fields = Object.values(type.getFields()).map( (f, i) => printDescription(f, ' ', !i) + @@ -225,12 +260,25 @@ function printFields(type: GraphQLObjectType | GraphQLInterfaceType): string { f.name + printArgs(f.args, ' ') + ': ' + - String(f.type) + + printReturnType(f.type, usingSemanticNullability) + printDeprecated(f.deprecationReason), ); return printBlock(fields); } +function printReturnType( + type: GraphQLOutputType, + usingSemanticNullability: Maybe, +): string { + if (usingSemanticNullability) { + return String(type); + } + if (isSemanticNullableType(type)) { + return String(type.ofType); + } + return String(type); +} + function printBlock(items: ReadonlyArray): string { return items.length !== 0 ? ' {\n' + items.join('\n') + '\n}' : ''; } diff --git a/src/utilities/typeComparators.ts b/src/utilities/typeComparators.ts index 287be40bfe..b276975afe 100644 --- a/src/utilities/typeComparators.ts +++ b/src/utilities/typeComparators.ts @@ -5,6 +5,7 @@ import { isListType, isNonNullType, isObjectType, + isSemanticNullableType, } from '../type/definition'; import type { GraphQLSchema } from '../type/schema'; @@ -22,6 +23,11 @@ export function isEqualType(typeA: GraphQLType, typeB: GraphQLType): boolean { return isEqualType(typeA.ofType, typeB.ofType); } + // If either type is semantic-non-null, the other must also be semantic-non-null. + if (isSemanticNullableType(typeA) && isSemanticNullableType(typeB)) { + return isEqualType(typeA.ofType, typeB.ofType); + } + // If either type is a list, the other must also be a list. if (isListType(typeA) && isListType(typeB)) { return isEqualType(typeA.ofType, typeB.ofType); @@ -52,9 +58,13 @@ export function isTypeSubTypeOf( } return false; } - if (isNonNullType(maybeSubType)) { - // If superType is nullable, maybeSubType may be non-null or nullable. - return isTypeSubTypeOf(schema, maybeSubType.ofType, superType); + + // If superType is semantic-nullable, maybeSubType may be non-null, semantic-non-null, or nullable. + if (isSemanticNullableType(superType)) { + if (isSemanticNullableType(maybeSubType) || isNonNullType(maybeSubType)) { + return isTypeSubTypeOf(schema, maybeSubType.ofType, superType); + } + return isTypeSubTypeOf(schema, maybeSubType, superType); } // If superType type is a list, maybeSubType type must also be a list. @@ -69,6 +79,14 @@ export function isTypeSubTypeOf( return false; } + // If superType is semantic-non-null, maybeSubType must be semantic-non-null or non-null. + if (isNonNullType(maybeSubType)) { + return isTypeSubTypeOf(schema, maybeSubType.ofType, superType); + } + if (isSemanticNullableType(maybeSubType)) { + return false; + } + // If superType type is an abstract type, check if it is super type of maybeSubType. // Otherwise, the child type is not a valid subtype of the parent type. return ( diff --git a/src/utilities/typeFromAST.ts b/src/utilities/typeFromAST.ts index 7510df1046..3c020671ff 100644 --- a/src/utilities/typeFromAST.ts +++ b/src/utilities/typeFromAST.ts @@ -7,7 +7,11 @@ import type { import { Kind } from '../language/kinds'; import type { GraphQLNamedType, GraphQLType } from '../type/definition'; -import { GraphQLList, GraphQLNonNull } from '../type/definition'; +import { + GraphQLList, + GraphQLNonNull, + GraphQLSemanticNullable, +} from '../type/definition'; import type { GraphQLSchema } from '../type/schema'; /** @@ -46,6 +50,10 @@ export function typeFromAST( const innerType = typeFromAST(schema, typeNode.type); return innerType && new GraphQLNonNull(innerType); } + case Kind.SEMANTIC_NULLABLE_TYPE: { + const innerType = typeFromAST(schema, typeNode.type); + return innerType && new GraphQLSemanticNullable(innerType); + } case Kind.NAMED_TYPE: return schema.getType(typeNode.name.value); } diff --git a/src/validation/rules/OverlappingFieldsCanBeMergedRule.ts b/src/validation/rules/OverlappingFieldsCanBeMergedRule.ts index 8397a35b80..cf7b8d6f37 100644 --- a/src/validation/rules/OverlappingFieldsCanBeMergedRule.ts +++ b/src/validation/rules/OverlappingFieldsCanBeMergedRule.ts @@ -27,6 +27,7 @@ import { isListType, isNonNullType, isObjectType, + isSemanticNullableType, } from '../../type/definition'; import { sortValueNode } from '../../utilities/sortValueNode'; @@ -723,6 +724,14 @@ function doTypesConflict( if (isNonNullType(type2)) { return true; } + if (isSemanticNullableType(type1)) { + return isSemanticNullableType(type2) + ? doTypesConflict(type1.ofType, type2.ofType) + : true; + } + if (isSemanticNullableType(type2)) { + return true; + } if (isLeafType(type1) || isLeafType(type2)) { return type1 !== type2; } diff --git a/src/validation/rules/ValuesOfCorrectTypeRule.ts b/src/validation/rules/ValuesOfCorrectTypeRule.ts index 3f284d7103..716135effd 100644 --- a/src/validation/rules/ValuesOfCorrectTypeRule.ts +++ b/src/validation/rules/ValuesOfCorrectTypeRule.ts @@ -118,6 +118,7 @@ export function ValuesOfCorrectTypeRule( ), ); } + // Note: SemanticNonNull cannot happen on input. }, EnumValue: (node) => isValidValueNode(context, node), IntValue: (node) => isValidValueNode(context, node), diff --git a/src/validation/rules/VariablesInAllowedPositionRule.ts b/src/validation/rules/VariablesInAllowedPositionRule.ts index a0b7e991a6..2871b49bba 100644 --- a/src/validation/rules/VariablesInAllowedPositionRule.ts +++ b/src/validation/rules/VariablesInAllowedPositionRule.ts @@ -88,6 +88,7 @@ function allowedVariableUsage( locationType: GraphQLType, locationDefaultValue: Maybe, ): boolean { + // Note: SemanticNonNull cannot occur on input. if (isNonNullType(locationType) && !isNonNullType(varType)) { const hasNonNullVariableDefaultValue = varDefaultValue != null && varDefaultValue.kind !== Kind.NULL;