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..20a33c2ffa --- /dev/null +++ b/src/execution/__tests__/semantic-nullability-test.ts @@ -0,0 +1,220 @@ +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, + 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: new GraphQLSemanticNonNull(GraphQLString) }, + c: { type: new GraphQLNonNull(GraphQLString) }, + d: { type: new GraphQLSemanticNonNull(DeepDataType) }, + }), + }); + + it('SemanticNonNull throws error on null without error', async () => { + const data = { + a: () => 'Apple', + b: () => null, + c: () => 'Cookie', + }; + + const document = parse(` + query { + b + } + `); + + const result = await execute({ + schema: new GraphQLSchema({ query: DataType }), + document, + rootValue: data, + }); + + const executable = document.definitions?.values().next() + .value as ExecutableDefinitionNode; + const selectionSet = executable.selectionSet.selections + .values() + .next().value; + + 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 = { + a: () => 'Apple', + b: () => { + throw new Error('Something went wrong'); + }, + c: () => 'Cookie', + }; + + const document = parse(` + query { + b + } + `); + + const executable = document.definitions?.values().next() + .value as ExecutableDefinitionNode; + const selectionSet = executable.selectionSet.selections + .values() + .next().value; + + 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 = { + a: () => 'Apple', + b: () => null, + c: () => 'Cookie', + d: () => deepData, + }; + + const document = parse(` + query { + d { + f + } + } + `); + + const result = await execute({ + schema: new GraphQLSchema({ query: DataType }), + document, + rootValue: data, + }); + + const executable = document.definitions?.values().next() + .value as ExecutableDefinitionNode; + const dSelectionSet = executable.selectionSet.selections.values().next() + .value as FieldNode; + const fSelectionSet = dSelectionSet.selectionSet?.selections + .values() + .next().value; + + 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 null values', async () => { + const data = { + a: () => null, + b: () => null, + c: () => 'Cookie', + }; + + const document = parse(` + query { + a + } + `); + + const result = await execute({ + schema: new GraphQLSchema({ query: DataType }), + document, + rootValue: data, + }); + + expect(result).to.deep.equal({ + data: { + a: null, + }, + }); + }); + + it('SemanticNullable allows non-null values', async () => { + const data = { + a: () => 'Apple', + b: () => null, + c: () => 'Cookie', + }; + + 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', + }, + }); + }); +}); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 55c22ea9de..b50395d2e3 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -43,6 +43,8 @@ import { isListType, isNonNullType, isObjectType, + isSemanticNonNullType, + isSemanticNullableType, } from '../type/definition'; import { SchemaMetaFieldDef, @@ -115,6 +117,7 @@ export interface ExecutionContext { typeResolver: GraphQLTypeResolver; subscribeFieldResolver: GraphQLFieldResolver; errors: Array; + errorPropagation: boolean; } /** @@ -152,6 +155,12 @@ export interface ExecutionArgs { fieldResolver?: Maybe>; typeResolver?: Maybe>; subscribeFieldResolver?: Maybe>; + /** + * Set to `false` to disable error propagation. Experimental. + * + * @experimental + */ + errorPropagation?: boolean; } /** @@ -286,6 +295,7 @@ export function buildExecutionContext( fieldResolver, typeResolver, subscribeFieldResolver, + errorPropagation, } = args; let operation: OperationDefinitionNode | undefined; @@ -347,6 +357,7 @@ export function buildExecutionContext( typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, errors: [], + errorPropagation: errorPropagation ?? true, }; } @@ -585,6 +596,7 @@ export function buildResolveInfo( rootValue: exeContext.rootValue, operation: exeContext.operation, variableValues: exeContext.variableValues, + errorPropagation: exeContext.errorPropagation, }; } @@ -595,7 +607,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; } @@ -658,6 +670,37 @@ 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 field type is SemanticNullable, complete for inner type + if (isSemanticNullableType(returnType)) { + return completeValue( + exeContext, + returnType.ofType, + fieldNodes, + info, + path, + result, + ); + } + // If result value is null or undefined then return null. if (result == null) { return null; 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 877939d879..2940df03fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, @@ -287,6 +291,7 @@ export type { NamedTypeNode, ListTypeNode, NonNullTypeNode, + SemanticNonNullTypeNode, TypeSystemDefinitionNode, SchemaDefinitionNode, OperationTypeDefinitionNode, @@ -480,6 +485,7 @@ export type { IntrospectionNamedTypeRef, IntrospectionListTypeRef, IntrospectionNonNullTypeRef, + IntrospectionSemanticNonNullTypeRef, IntrospectionField, IntrospectionInputValue, IntrospectionEnumValue, diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index 87e7b92c34..5567fc70cf 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -652,4 +652,69 @@ describe('Parser', () => { }); }); }); + + describe('parseDocumentDirective', () => { + it('doesnt 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.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?', { 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..32ef7d1fe1 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', + 'SemanticNonNullType', ]); }); 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 29029342a1..a17edbb9bc 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -161,6 +161,8 @@ export type ASTNode = | NamedTypeNode | ListTypeNode | NonNullTypeNode + | SemanticNonNullTypeNode + | SemanticNullableTypeNode | SchemaDefinitionNode | OperationTypeDefinitionNode | ScalarTypeDefinitionNode @@ -235,6 +237,8 @@ export const QueryDocumentKeys: { NamedType: ['name'], ListType: ['type'], NonNullType: ['type'], + SemanticNonNullType: ['type'], + SemanticNullableType: ['type'], SchemaDefinition: ['description', 'directives', 'operationTypes'], OperationTypeDefinition: ['type'], @@ -520,7 +524,12 @@ export interface ConstDirectiveNode { /** Type Reference */ -export type TypeNode = NamedTypeNode | ListTypeNode | NonNullTypeNode; +export type TypeNode = + | NamedTypeNode + | ListTypeNode + | NonNullTypeNode + | SemanticNonNullTypeNode + | SemanticNullableTypeNode; export interface NamedTypeNode { readonly kind: Kind.NAMED_TYPE; @@ -540,6 +549,18 @@ export interface NonNullTypeNode { readonly type: NamedTypeNode | ListTypeNode; } +export interface SemanticNonNullTypeNode { + readonly kind: Kind.SEMANTIC_NON_NULL_TYPE; + readonly loc?: Location; + 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..a760fd21b3 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -67,6 +67,7 @@ export type { NamedTypeNode, ListTypeNode, NonNullTypeNode, + SemanticNonNullTypeNode, TypeSystemDefinitionNode, SchemaDefinitionNode, OperationTypeDefinitionNode, diff --git a/src/language/kinds.ts b/src/language/kinds.ts index cd05f66a3b..7111a94834 100644 --- a/src/language/kinds.ts +++ b/src/language/kinds.ts @@ -37,6 +37,8 @@ enum Kind { NAMED_TYPE = 'NamedType', LIST_TYPE = 'ListType', NON_NULL_TYPE = 'NonNullType', + SEMANTIC_NON_NULL_TYPE = 'SemanticNonNullType', + 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 eb54a0376b..e947021baf 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -50,6 +50,8 @@ import type { SchemaExtensionNode, SelectionNode, SelectionSetNode, + SemanticNonNullTypeNode, + SemanticNullableTypeNode, StringValueNode, Token, TypeNode, @@ -103,6 +105,8 @@ export interface ParseOptions { * ``` */ allowLegacyFragmentVariables?: boolean; + + useSemanticNullability?: boolean; } /** @@ -249,6 +253,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(); } @@ -740,6 +754,7 @@ export class Parser { * - NamedType * - ListType * - NonNullType + * - SemanticNonNullType */ parseTypeReference(): TypeNode { const start = this._lexer.token; @@ -755,6 +770,25 @@ export class Parser { type = this.parseNamedType(); } + if (this._options.useSemanticNullability) { + if (this.expectOptionalToken(TokenKind.BANG)) { + return this.node(start, { + kind: Kind.NON_NULL_TYPE, + type, + }); + } else if (this.expectOptionalToken(TokenKind.QUESTION_MARK)) { + return this.node(start, { + kind: Kind.SEMANTIC_NULLABLE_TYPE, + type, + }); + } + + return this.node(start, { + kind: Kind.SEMANTIC_NON_NULL_TYPE, + type, + }); + } + if (this.expectOptionalToken(TokenKind.BANG)) { return this.node(start, { kind: Kind.NON_NULL_TYPE, diff --git a/src/language/predicates.ts b/src/language/predicates.ts index a390f4ee55..3ddf52b94c 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_NON_NULL_TYPE ); } diff --git a/src/language/printer.ts b/src/language/printer.ts index e95c118d8b..17b805e624 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -6,6 +6,13 @@ import { printString } from './printString'; import type { ASTReducer } from './visitor'; import { visit } from './visitor'; +/** + * Configuration options to control parser behavior + */ +export interface PrintOptions { + useSemanticNullability?: boolean; +} + /** * Converts an AST into a string, using one set of reasonable * formatting rules. @@ -131,6 +138,8 @@ const printDocASTReducer: ASTReducer = { NamedType: { leave: ({ name }) => name }, ListType: { leave: ({ type }) => '[' + type + ']' }, NonNullType: { leave: ({ type }) => type + '!' }, + SemanticNonNullType: { 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 29994c76ed..08273f495f 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_NON_NULL', + 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..1c576e8eaa 100644 --- a/src/type/__tests__/predicate-test.ts +++ b/src/type/__tests__/predicate-test.ts @@ -23,6 +23,7 @@ import { assertObjectType, assertOutputType, assertScalarType, + assertSemanticNonNullType, assertType, assertUnionType, assertWrappingType, @@ -35,6 +36,7 @@ import { GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, + GraphQLSemanticNonNull, GraphQLUnionType, isAbstractType, isCompositeType, @@ -52,6 +54,7 @@ import { isRequiredArgument, isRequiredInputField, isScalarType, + isSemanticNonNullType, isType, isUnionType, isWrappingType, @@ -298,6 +301,47 @@ describe('Type predicates', () => { expect(() => assertNonNullType(new GraphQLList(new GraphQLNonNull(ObjectType))), ).to.throw(); + expect(isNonNullType(new GraphQLSemanticNonNull(ObjectType))).to.equal( + false, + ); + expect(() => + assertNonNullType(new GraphQLSemanticNonNull(ObjectType)), + ).to.throw(); + }); + }); + + describe('isSemanticNonNullType', () => { + it('returns true for a semantic-non-null wrapped type', () => { + expect( + isSemanticNonNullType(new GraphQLSemanticNonNull(ObjectType)), + ).to.equal(true); + expect(() => + assertSemanticNonNullType(new GraphQLSemanticNonNull(ObjectType)), + ).to.not.throw(); + }); + + it('returns false for an unwrapped type', () => { + expect(isSemanticNonNullType(ObjectType)).to.equal(false); + expect(() => assertSemanticNonNullType(ObjectType)).to.throw(); + }); + + it('returns false for a not non-null wrapped type', () => { + expect( + isSemanticNonNullType( + new GraphQLList(new GraphQLSemanticNonNull(ObjectType)), + ), + ).to.equal(false); + expect(() => + assertSemanticNonNullType( + new GraphQLList(new GraphQLSemanticNonNull(ObjectType)), + ), + ).to.throw(); + expect(isSemanticNonNullType(new GraphQLNonNull(ObjectType))).to.equal( + false, + ); + expect(() => + assertSemanticNonNullType(new GraphQLNonNull(ObjectType)), + ).to.throw(); }); }); @@ -476,6 +520,12 @@ describe('Type predicates', () => { expect(() => assertWrappingType(new GraphQLNonNull(ObjectType)), ).to.not.throw(); + expect(isWrappingType(new GraphQLSemanticNonNull(ObjectType))).to.equal( + true, + ); + expect(() => + assertWrappingType(new GraphQLSemanticNonNull(ObjectType)), + ).to.not.throw(); }); it('returns false for unwrapped types', () => { @@ -497,6 +547,14 @@ describe('Type predicates', () => { expect(() => assertNullableType(new GraphQLList(new GraphQLNonNull(ObjectType))), ).to.not.throw(); + expect( + isNullableType(new GraphQLList(new GraphQLSemanticNonNull(ObjectType))), + ).to.equal(true); + expect(() => + assertNullableType( + new GraphQLList(new GraphQLSemanticNonNull(ObjectType)), + ), + ).to.not.throw(); }); it('returns false for non-null types', () => { @@ -504,6 +562,12 @@ describe('Type predicates', () => { expect(() => assertNullableType(new GraphQLNonNull(ObjectType)), ).to.throw(); + expect(isNullableType(new GraphQLSemanticNonNull(ObjectType))).to.equal( + false, + ); + expect(() => + assertNullableType(new GraphQLSemanticNonNull(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..9d2fc95089 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -66,6 +66,24 @@ export type GraphQLType = | GraphQLEnumType | GraphQLInputObjectType | GraphQLList + > + | GraphQLSemanticNonNull< + | GraphQLScalarType + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | GraphQLEnumType + | GraphQLInputObjectType + | GraphQLList + > + | GraphQLSemanticNullable< + | GraphQLScalarType + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | GraphQLEnumType + | GraphQLInputObjectType + | GraphQLList >; export function isType(type: unknown): type is GraphQLType { @@ -77,7 +95,8 @@ export function isType(type: unknown): type is GraphQLType { isEnumType(type) || isInputObjectType(type) || isListType(type) || - isNonNullType(type) + isNonNullType(type) || + isSemanticNonNullType(type) ); } @@ -203,6 +222,58 @@ export function assertNonNullType(type: unknown): GraphQLNonNull { return type; } +export function isSemanticNonNullType( + type: GraphQLInputType, +): type is GraphQLSemanticNonNull; +export function isSemanticNonNullType( + type: GraphQLOutputType, +): type is GraphQLSemanticNonNull; +export function isSemanticNonNullType( + type: unknown, +): type is GraphQLSemanticNonNull; +export function isSemanticNonNullType( + type: unknown, +): type is GraphQLSemanticNonNull { + return instanceOf(type, GraphQLSemanticNonNull); +} + +export function assertSemanticNonNullType( + type: unknown, +): GraphQLSemanticNonNull { + if (!isSemanticNonNullType(type)) { + throw new Error( + `Expected ${inspect(type)} to be a GraphQL Semantic-Non-Null type.`, + ); + } + 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. */ @@ -217,13 +288,16 @@ export type GraphQLInputType = | GraphQLInputObjectType | GraphQLList >; +// Note: GraphQLSemanticNonNull 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)) + (!isSemanticNonNullType(type) && + isWrappingType(type) && + isInputType(type.ofType)) ); } @@ -251,6 +325,14 @@ export type GraphQLOutputType = | GraphQLUnionType | GraphQLEnumType | GraphQLList + > + | GraphQLSemanticNonNull< + | GraphQLScalarType + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | GraphQLEnumType + | GraphQLList >; export function isOutputType(type: unknown): type is GraphQLOutputType { @@ -414,16 +496,118 @@ export class GraphQLNonNull { } } +/** + * Semantic-Non-Null Type Wrapper + * + * A semantic-non-null is a wrapping type which points to another type. + * Semantic-non-null types enforce that their values are never null unless + * caused by an error being raised. It is useful for fields which you can make + * a guarantee on non-nullability in a no-error case, for example when you know + * that a related entity must exist (but acknowledge that retrieving it may + * produce an error). + * + * Example: + * + * ```ts + * const RowType = new GraphQLObjectType({ + * name: 'Row', + * fields: () => ({ + * email: { type: new GraphQLSemanticNonNull(GraphQLString) }, + * }) + * }) + * ``` + * Note: the enforcement of non-nullability occurs within the executor. + * + * @experimental + */ +export class GraphQLSemanticNonNull { + 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 'GraphQLSemanticNonNull'; + } + + toString(): string { + return String(this.ofType); + } + + toJSON(): string { + return this.toString(); + } +} + +/** + * 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 + | GraphQLSemanticNonNull + | GraphQLSemanticNullable; export function isWrappingType(type: unknown): type is GraphQLWrappingType { - return isListType(type) || isNonNullType(type); + return ( + isListType(type) || + isNonNullType(type) || + isSemanticNonNullType(type) || + isSemanticNullableType(type) + ); } export function assertWrappingType(type: unknown): GraphQLWrappingType { @@ -446,7 +630,7 @@ export type GraphQLNullableType = | GraphQLList; export function isNullableType(type: unknown): type is GraphQLNullableType { - return isType(type) && !isNonNullType(type); + return isType(type) && !isNonNullType(type) && !isSemanticNonNullType(type); } export function assertNullableType(type: unknown): GraphQLNullableType { @@ -458,7 +642,7 @@ export function assertNullableType(type: unknown): GraphQLNullableType { export function getNullableType(type: undefined | null): void; export function getNullableType( - type: T | GraphQLNonNull, + type: T | GraphQLNonNull | GraphQLSemanticNonNull, ): T; export function getNullableType( type: Maybe, @@ -467,12 +651,14 @@ export function getNullableType( type: Maybe, ): GraphQLNullableType | undefined { if (type) { - return isNonNullType(type) ? type.ofType : type; + return isNonNullType(type) || isSemanticNonNullType(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 SemanticNonNull */ export type GraphQLNamedType = GraphQLNamedInputType | GraphQLNamedOutputType; @@ -988,6 +1174,8 @@ export interface GraphQLResolveInfo { readonly rootValue: unknown; readonly operation: OperationDefinitionNode; readonly variableValues: { [variable: string]: unknown }; + /** @experimental */ + readonly errorPropagation: boolean; } /** @@ -1070,6 +1258,7 @@ export interface GraphQLArgument { } export function isRequiredArgument(arg: GraphQLArgument): boolean { + // Note: input types cannot be SemanticNonNull return isNonNullType(arg.type) && arg.defaultValue === undefined; } @@ -1761,6 +1950,7 @@ export interface GraphQLInputField { } export function isRequiredInputField(field: GraphQLInputField): boolean { + // Note: input types cannot be SemanticNonNull return isNonNullType(field.type) && field.defaultValue === undefined; } diff --git a/src/type/index.ts b/src/type/index.ts index cf276d1e02..e6cf627bd5 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -23,6 +23,7 @@ export { isInputObjectType, isListType, isNonNullType, + isSemanticNonNullType, isInputType, isOutputType, isLeafType, @@ -43,6 +44,7 @@ export { assertInputObjectType, assertListType, assertNonNullType, + assertSemanticNonNullType, assertInputType, assertOutputType, assertLeafType, @@ -64,6 +66,7 @@ export { // Type Wrappers GraphQLList, GraphQLNonNull, + GraphQLSemanticNonNull, } 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..186a20f8d3 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -19,6 +19,7 @@ import { GraphQLList, GraphQLNonNull, GraphQLObjectType, + GraphQLSemanticNonNull, isAbstractType, isEnumType, isInputObjectType, @@ -27,6 +28,7 @@ import { isNonNullType, isObjectType, isScalarType, + isSemanticNonNullType, isUnionType, } from './definition'; import type { GraphQLDirective } from './directives'; @@ -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,6 +273,9 @@ export const __Type: GraphQLObjectType = new GraphQLObjectType({ if (isNonNullType(type)) { return TypeKind.NON_NULL; } + if (isSemanticNonNullType(type)) { + return TypeKind.SEMANTIC_NON_NULL; + } /* c8 ignore next 3 */ // Not reachable, all possible types have been considered) invariant(false, `Unexpected type: "${inspect(type)}".`); @@ -366,7 +405,24 @@ 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), @@ -379,6 +435,37 @@ export const __Field: GraphQLObjectType = new GraphQLObjectType({ } 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 (isSemanticNonNullType(type)) { + return convertOutputTypeToNullabilityMode(type.ofType, mode); + } else if (isListType(type)) { + return new GraphQLList( + convertOutputTypeToNullabilityMode(type.ofType, mode), + ); + } + return type; + } + if (isNonNullType(type) || isSemanticNonNullType(type)) { + return new GraphQLSemanticNonNull( + convertOutputTypeToNullabilityMode(type.ofType, mode), + ); + } else if (isListType(type)) { + return new GraphQLList( + convertOutputTypeToNullabilityMode(type.ofType, mode), + ); + } + return type; +} + export const __InputValue: GraphQLObjectType = new GraphQLObjectType({ name: '__InputValue', description: @@ -452,6 +539,7 @@ enum TypeKind { INPUT_OBJECT = 'INPUT_OBJECT', LIST = 'LIST', NON_NULL = 'NON_NULL', + SEMANTIC_NON_NULL = 'SEMANTIC_NON_NULL', } export { TypeKind }; @@ -497,6 +585,11 @@ export const __TypeKind: GraphQLEnumType = new GraphQLEnumType({ description: 'Indicates this type is a non-null. `ofType` is a valid field.', }, + SEMANTIC_NON_NULL: { + value: TypeKind.SEMANTIC_NON_NULL, + description: + 'Indicates this type is a semantic-non-null. `ofType` is a valid field.', + }, }, }); @@ -553,6 +646,7 @@ export const introspectionTypes: ReadonlyArray = __Schema, __Directive, __DirectiveLocation, + __TypeNullability, __Type, __Field, __InputValue, diff --git a/src/utilities/__tests__/printSchema-test.ts b/src/utilities/__tests__/printSchema-test.ts index 37af4a60f7..b651bf16a8 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-non-null. \`ofType\` is a valid field.""" + SEMANTIC_NON_NULL } """ @@ -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..c605025035 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 SemanticNonNull if (isNonNullType(type)) { const astValue = astFromValue(value, type.ofType); if (astValue?.kind === Kind.NULL) { diff --git a/src/utilities/buildClientSchema.ts b/src/utilities/buildClientSchema.ts index 83f6abada8..9b0809adf5 100644 --- a/src/utilities/buildClientSchema.ts +++ b/src/utilities/buildClientSchema.ts @@ -22,6 +22,7 @@ import { GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, + GraphQLSemanticNonNull, GraphQLUnionType, isInputType, isOutputType, @@ -137,6 +138,14 @@ export function buildClientSchema( const nullableType = getType(nullableRef); return new GraphQLNonNull(assertNullableType(nullableType)); } + if (typeRef.kind === TypeKind.SEMANTIC_NON_NULL) { + const nullableRef = typeRef.ofType; + if (!nullableRef) { + throw new Error('Decorated type deeper than introspection query.'); + } + const nullableType = getType(nullableRef); + return new GraphQLSemanticNonNull(assertNullableType(nullableType)); + } return getNamedType(typeRef); } diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index d53752d919..876aae277f 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -53,6 +53,7 @@ import { GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, + GraphQLSemanticNonNull, GraphQLUnionType, isEnumType, isInputObjectType, @@ -61,6 +62,7 @@ import { isNonNullType, isObjectType, isScalarType, + isSemanticNonNullType, isUnionType, } from '../type/definition'; import { @@ -225,6 +227,10 @@ export function extendSchemaImpl( // @ts-expect-error return new GraphQLNonNull(replaceType(type.ofType)); } + if (isSemanticNonNullType(type)) { + // @ts-expect-error + return new GraphQLSemanticNonNull(replaceType(type.ofType)); + } // @ts-expect-error FIXME return replaceNamedType(type); } @@ -432,6 +438,9 @@ export function extendSchemaImpl( if (node.kind === Kind.NON_NULL_TYPE) { return new GraphQLNonNull(getWrappedType(node.type)); } + if (node.kind === Kind.SEMANTIC_NON_NULL_TYPE) { + return new GraphQLSemanticNonNull(getWrappedType(node.type)); + } return getNamedType(node); } diff --git a/src/utilities/findBreakingChanges.ts b/src/utilities/findBreakingChanges.ts index 2489af9d62..5ed0313ae3 100644 --- a/src/utilities/findBreakingChanges.ts +++ b/src/utilities/findBreakingChanges.ts @@ -26,6 +26,7 @@ import { isRequiredArgument, isRequiredInputField, isScalarType, + isSemanticNonNullType, isUnionType, } from '../type/definition'; import { isSpecifiedScalarType } from '../type/scalars'; @@ -458,6 +459,9 @@ function isChangeSafeForObjectOrInterfaceField( )) || // moving from nullable to non-null of the same underlying type is safe (isNonNullType(newType) && + isChangeSafeForObjectOrInterfaceField(oldType, newType.ofType)) || + // moving from nullable to semantic-non-null of the same underlying type is safe + (isSemanticNonNullType(newType) && isChangeSafeForObjectOrInterfaceField(oldType, newType.ofType)) ); } @@ -470,11 +474,28 @@ function isChangeSafeForObjectOrInterfaceField( ); } + if (isSemanticNonNullType(oldType)) { + return ( + // if they're both semantic-non-null, make sure the underlying types are compatible + (isSemanticNonNullType(newType) && + isChangeSafeForObjectOrInterfaceField( + oldType.ofType, + newType.ofType, + )) || + // moving from semantic-non-null to 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 (isNonNullType(newType) && + isChangeSafeForObjectOrInterfaceField(oldType, newType.ofType)) || + // moving from nullable to semantic-non-null of the same underlying type is safe + (isSemanticNonNullType(newType) && isChangeSafeForObjectOrInterfaceField(oldType, newType.ofType)) ); } diff --git a/src/utilities/getIntrospectionQuery.ts b/src/utilities/getIntrospectionQuery.ts index c502f0f7b4..26340991ce 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 IntrospectionSemanticNonNullTypeRef< + T extends IntrospectionTypeRef = IntrospectionTypeRef, +> { + readonly kind: 'SEMANTIC_NON_NULL'; + readonly ofType: T; +} + export type IntrospectionTypeRef = | IntrospectionNamedTypeRef | IntrospectionListTypeRef | IntrospectionNonNullTypeRef< IntrospectionNamedTypeRef | IntrospectionListTypeRef + > + | IntrospectionSemanticNonNullTypeRef< + IntrospectionNamedTypeRef | IntrospectionListTypeRef >; export type IntrospectionOutputTypeRef = diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 452b975233..fa69583012 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -20,6 +20,7 @@ export type { IntrospectionNamedTypeRef, IntrospectionListTypeRef, IntrospectionNonNullTypeRef, + IntrospectionSemanticNonNullTypeRef, IntrospectionField, IntrospectionInputValue, IntrospectionEnumValue, diff --git a/src/utilities/lexicographicSortSchema.ts b/src/utilities/lexicographicSortSchema.ts index 26b6908c9f..5beb646859 100644 --- a/src/utilities/lexicographicSortSchema.ts +++ b/src/utilities/lexicographicSortSchema.ts @@ -19,6 +19,7 @@ import { GraphQLList, GraphQLNonNull, GraphQLObjectType, + GraphQLSemanticNonNull, GraphQLUnionType, isEnumType, isInputObjectType, @@ -27,6 +28,7 @@ import { isNonNullType, isObjectType, isScalarType, + isSemanticNonNullType, 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 (isSemanticNonNullType(type)) { + // @ts-expect-error + return new GraphQLSemanticNonNull(replaceType(type.ofType)); } // @ts-expect-error FIXME: TS Conversion return replaceNamedType(type); diff --git a/src/utilities/typeComparators.ts b/src/utilities/typeComparators.ts index 287be40bfe..338ca24528 100644 --- a/src/utilities/typeComparators.ts +++ b/src/utilities/typeComparators.ts @@ -5,6 +5,7 @@ import { isListType, isNonNullType, isObjectType, + isSemanticNonNullType, } 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 (isSemanticNonNullType(typeA) && isSemanticNonNullType(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,8 +58,15 @@ export function isTypeSubTypeOf( } return false; } - if (isNonNullType(maybeSubType)) { - // If superType is nullable, maybeSubType may be non-null or nullable. + // If superType is semantic-non-null, maybeSubType must be semantic-non-null or non-null. + if (isSemanticNonNullType(superType)) { + if (isNonNullType(maybeSubType) || isSemanticNonNullType(maybeSubType)) { + return isTypeSubTypeOf(schema, maybeSubType.ofType, superType.ofType); + } + return false; + } + if (isNonNullType(maybeSubType) || isSemanticNonNullType(maybeSubType)) { + // If superType is nullable, maybeSubType may be non-null, semantic-non-null, or nullable. return isTypeSubTypeOf(schema, maybeSubType.ofType, superType); } diff --git a/src/utilities/typeFromAST.ts b/src/utilities/typeFromAST.ts index 7510df1046..c5d5f537a2 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, + GraphQLSemanticNonNull, +} 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_NON_NULL_TYPE: { + const innerType = typeFromAST(schema, typeNode.type); + return innerType && new GraphQLSemanticNonNull(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 4305064a6f..8ecd96212f 100644 --- a/src/validation/rules/OverlappingFieldsCanBeMergedRule.ts +++ b/src/validation/rules/OverlappingFieldsCanBeMergedRule.ts @@ -27,6 +27,7 @@ import { isListType, isNonNullType, isObjectType, + isSemanticNonNullType, } from '../../type/definition'; import { sortValueNode } from '../../utilities/sortValueNode'; @@ -695,6 +696,14 @@ function doTypesConflict( if (isNonNullType(type2)) { return true; } + if (isSemanticNonNullType(type1)) { + return isSemanticNonNullType(type2) + ? doTypesConflict(type1.ofType, type2.ofType) + : true; + } + if (isSemanticNonNullType(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;