From 17461b9b7c9124bcccca1282570a8b5f166b9c12 Mon Sep 17 00:00:00 2001 From: Jovi De Croock <decroockjovi@gmail.com> Date: Sun, 2 Feb 2025 08:45:34 +0100 Subject: [PATCH 01/10] Implement first version Co-Authored-By: Benjie <code@benjiegillam.com> Co-Authored-By: twof <hello@alex.dev> --- src/__tests__/starWarsIntrospection-test.ts | 1 + src/execution/__tests__/executor-test.ts | 2 + .../__tests__/semantic-nullability-test.ts | 220 ++++++++++++++++++ src/execution/execute.ts | 44 ++++ src/graphql.ts | 8 + src/index.ts | 6 + src/language/__tests__/parser-test.ts | 64 +++++ src/language/__tests__/predicates-test.ts | 1 + src/language/__tests__/schema-printer-test.ts | 29 ++- src/language/ast.ts | 24 +- src/language/index.ts | 1 + src/language/kinds.ts | 2 + src/language/lexer.ts | 5 +- src/language/parser.ts | 24 ++ src/language/predicates.ts | 4 +- src/language/printer.ts | 9 + src/language/tokenKind.ts | 1 + src/type/__tests__/introspection-test.ts | 54 ++++- src/type/__tests__/predicate-test.ts | 66 +++++- src/type/__tests__/schema-test.ts | 1 + src/type/definition.ts | 206 +++++++++++++++- src/type/index.ts | 4 + src/type/introspection.ts | 97 +++++++- src/utilities/__tests__/printSchema-test.ts | 22 +- src/utilities/buildClientSchema.ts | 9 + src/utilities/extendSchema.ts | 13 ++ src/utilities/findBreakingChanges.ts | 21 ++ src/utilities/getIntrospectionQuery.ts | 25 +- src/utilities/index.ts | 1 + src/utilities/lexicographicSortSchema.ts | 5 + src/utilities/typeComparators.ts | 19 +- src/utilities/typeFromAST.ts | 10 +- .../rules/OverlappingFieldsCanBeMergedRule.ts | 9 + 33 files changed, 984 insertions(+), 23 deletions(-) create mode 100644 src/execution/__tests__/semantic-nullability-test.ts 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..afc73d5c08 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..33aa1dd6f1 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<any, any>; subscribeFieldResolver: GraphQLFieldResolver<any, any>; errors: Array<GraphQLError>; + errorPropagation: boolean; } /** @@ -152,6 +155,13 @@ export interface ExecutionArgs { fieldResolver?: Maybe<GraphQLFieldResolver<any, any>>; typeResolver?: Maybe<GraphQLTypeResolver<any, any>>; subscribeFieldResolver?: Maybe<GraphQLFieldResolver<any, any>>; + /** + * Set to `false` to disable error propagation. Experimental. + * TODO: describe what this does + * + * @experimental + */ + errorPropagation?: boolean; } /** @@ -286,6 +296,7 @@ export function buildExecutionContext( fieldResolver, typeResolver, subscribeFieldResolver, + errorPropagation } = args; let operation: OperationDefinitionNode | undefined; @@ -347,6 +358,7 @@ export function buildExecutionContext( typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, errors: [], + errorPropagation: errorPropagation ?? true, }; } @@ -585,6 +597,7 @@ export function buildResolveInfo( rootValue: exeContext.rootValue, operation: exeContext.operation, variableValues: exeContext.variableValues, + errorPropagation: exeContext.errorPropagation, }; } @@ -658,6 +671,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<string>; fieldResolver?: Maybe<GraphQLFieldResolver<any, any>>; typeResolver?: Maybe<GraphQLTypeResolver<any, any>>; + /** + * Set to `false` to disable error propagation. Experimental. + * + * @experimental + */ + errorPropagation?: boolean; } export function graphql(args: GraphQLArgs): Promise<ExecutionResult> { @@ -106,6 +112,7 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue<ExecutionResult> { operationName, fieldResolver, typeResolver, + errorPropagation, } = args; // Validate Schema @@ -138,5 +145,6 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue<ExecutionResult> { operationName, fieldResolver, typeResolver, + errorPropagation, }); } diff --git a/src/index.ts b/src/index.ts index 73c713a203..a911680a67 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, @@ -286,6 +290,7 @@ export type { TypeNode, NamedTypeNode, ListTypeNode, + SemanticNonNullTypeNode, NonNullTypeNode, TypeSystemDefinitionNode, SchemaDefinitionNode, @@ -481,6 +486,7 @@ export type { IntrospectionNamedTypeRef, IntrospectionListTypeRef, IntrospectionNonNullTypeRef, + IntrospectionSemanticNonNullTypeRef, IntrospectionField, IntrospectionInputValue, IntrospectionEnumValue, diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index caa922a27d..9c23d6d46b 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -657,4 +657,68 @@ 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', { allowSemanticNullability: true }); + expectJSON(result).toDeepEqual({ + kind: Kind.SEMANTIC_NON_NULL_TYPE, + loc: { start: 0, end: 6 }, + type: { + kind: Kind.NAMED_TYPE, + loc: { start: 0, end: 6 }, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: 'MyType', + }, + }, + }); + }); + + it('parses nullable types', () => { + const result = parseType('MyType?', { allowSemanticNullability: true }); + expectJSON(result).toDeepEqual({ + kind: Kind.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!', { allowSemanticNullability: true }); + expectJSON(result).toDeepEqual({ + kind: Kind.NON_NULL_TYPE, + loc: { start: 0, end: 7 }, + type: { + kind: Kind.NAMED_TYPE, + loc: { start: 0, end: 6 }, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: 'MyType', + }, + }, + }); + }); + }); }); 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..a5f803bc1d 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', { allowSemanticNullability: false })), + ).to.equal(dedent`MyType`); + }); + + it('prints SemanticNullableType', () => { + expect( + print(parseType('MyType?', { allowSemanticNullability: true })), + ).to.equal(dedent`MyType?`); + }); + + it('prints SemanticNonNullType', () => { + expect( + print(parseType('MyType', { allowSemanticNullability: true })), + ).to.equal(dedent`MyType`); + }); + + it('prints NonNullType', () => { + expect( + print(parseType('MyType!', { allowSemanticNullability: true })), + ).to.equal(dedent`MyType!`); + expect( + print(parseType('MyType!', { allowSemanticNullability: false })), + ).to.equal(dedent`MyType!`); + }); }); diff --git a/src/language/ast.ts b/src/language/ast.ts index 6137eb6c1a..dbe03aad06 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'], @@ -519,9 +523,27 @@ export interface ConstDirectiveNode { readonly arguments?: ReadonlyArray<ConstArgumentNode>; } + +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 Reference */ -export type TypeNode = NamedTypeNode | ListTypeNode | NonNullTypeNode; +export type TypeNode = + | NamedTypeNode + | ListTypeNode + | NonNullTypeNode + | SemanticNonNullTypeNode + | SemanticNullableTypeNode; export interface NamedTypeNode { readonly kind: Kind.NAMED_TYPE; 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..b41ae415ac 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,9 +247,11 @@ 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 0x003f: // ? + return createToken(lexer, TokenKind.QUESTION_MARK, position, position + 1); case 0x0024: // $ return createToken(lexer, TokenKind.DOLLAR, position, position + 1); case 0x0026: // & diff --git a/src/language/parser.ts b/src/language/parser.ts index 03e4166210..84131bd603 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -51,6 +51,8 @@ import type { SelectionNode, SelectionSetNode, StringValueNode, + SemanticNonNullTypeNode, + SemanticNullableTypeNode, Token, TypeNode, TypeSystemExtensionNode, @@ -103,6 +105,18 @@ export interface ParseOptions { * ``` */ allowLegacyFragmentVariables?: boolean; + + /** + * When enabled, the parser will understand and parse semantic nullability + * annotations. This means that every type suffixed with `!` will remain + * non-nulllable, every type suffxed with `?` will be the classic nullable, and + * types without a suffix will be semantically nullable. Semantic nullability + * will be the new default when this is enabled. A semantically nullable type + * can only be null when there's an error assocaited with the field. + * + * @experimental + */ + allowSemanticNullability?: boolean; } /** @@ -258,6 +272,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.allowSemanticNullability = true; + } + } + if (this.peek(TokenKind.BRACE_L)) { return this.parseOperationDefinition(); } diff --git a/src/language/predicates.ts b/src/language/predicates.ts index a390f4ee55..d528e6c3c2 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -67,7 +67,9 @@ 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 || + node.kind === Kind.SEMANTIC_NULLABLE_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<string> = { 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 = '<SOF>', EOF = '<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..08273f495f 100644 --- a/src/type/__tests__/introspection-test.ts +++ b/src/type/__tests__/introspection-test.ts @@ -32,7 +32,7 @@ describe('Introspection', () => { expect(result).to.deep.equal({ data: { __schema: { - queryType: { name: 'SomeObject', kind: 'OBJECT' }, + queryType: { name: 'SomeObject' }, mutationType: null, subscriptionType: null, types: [ @@ -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..774199a368 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(); }); }); @@ -701,4 +765,4 @@ describe('Directive predicates', () => { expect(isSpecifiedDirective(Directive)).to.equal(false); }); }); -}); +}); \ No newline at end of file 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..ff1238793b 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -66,7 +66,25 @@ export type GraphQLType = | GraphQLEnumType | GraphQLInputObjectType | GraphQLList<GraphQLType> - >; + > + | GraphQLSemanticNonNull< + | GraphQLScalarType + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | GraphQLEnumType + | GraphQLInputObjectType + | GraphQLList<GraphQLType> + > + | GraphQLSemanticNullable< + | GraphQLScalarType + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | GraphQLEnumType + | GraphQLInputObjectType + | GraphQLList<GraphQLType> + >; export function isType(type: unknown): type is GraphQLType { return ( @@ -77,7 +95,9 @@ export function isType(type: unknown): type is GraphQLType { isEnumType(type) || isInputObjectType(type) || isListType(type) || - isNonNullType(type) + isNonNullType(type) || + isNonNullType(type) || + isSemanticNonNullType(type) ); } @@ -203,6 +223,58 @@ export function assertNonNullType(type: unknown): GraphQLNonNull<GraphQLType> { return type; } +export function isSemanticNonNullType( + type: GraphQLInputType, +): type is GraphQLSemanticNonNull<GraphQLInputType>; +export function isSemanticNonNullType( + type: GraphQLOutputType, +): type is GraphQLSemanticNonNull<GraphQLOutputType>; +export function isSemanticNonNullType( + type: unknown, +): type is GraphQLSemanticNonNull<GraphQLType>; +export function isSemanticNonNullType( + type: unknown, +): type is GraphQLSemanticNonNull<GraphQLType> { + return instanceOf(type, GraphQLSemanticNonNull); +} + +export function assertSemanticNonNullType( + type: unknown, +): GraphQLSemanticNonNull<GraphQLType> { + 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<GraphQLInputType>; +export function isSemanticNullableType( + type: GraphQLOutputType, +): type is GraphQLSemanticNullable<GraphQLOutputType>; +export function isSemanticNullableType( + type: unknown, +): type is GraphQLSemanticNullable<GraphQLType>; +export function isSemanticNullableType( + type: unknown, +): type is GraphQLSemanticNullable<GraphQLType> { + return instanceOf(type, GraphQLSemanticNullable); +} + +export function assertSemanticNullableType( + type: unknown, +): GraphQLSemanticNullable<GraphQLType> { + 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. */ @@ -223,7 +295,9 @@ export function isInputType(type: unknown): type is GraphQLInputType { isScalarType(type) || isEnumType(type) || isInputObjectType(type) || - (isWrappingType(type) && isInputType(type.ofType)) + (!isSemanticNonNullType(type) && + isWrappingType(type) && + isInputType(type.ofType)) ); } @@ -251,7 +325,13 @@ export type GraphQLOutputType = | GraphQLUnionType | GraphQLEnumType | GraphQLList<GraphQLOutputType> - >; + > | GraphQLSemanticNonNull< + | GraphQLScalarType + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | GraphQLEnumType + | GraphQLList<GraphQLOutputType> >; export function isOutputType(type: unknown): type is GraphQLOutputType { return ( @@ -414,16 +494,118 @@ export class GraphQLNonNull<T extends GraphQLNullableType> { } } +/** + * 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<T extends GraphQLNullableType> { + 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<T extends GraphQLNullableType> { + 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<GraphQLType> - | GraphQLNonNull<GraphQLType>; + | GraphQLNonNull<GraphQLType> + | GraphQLSemanticNonNull<GraphQLType> + | GraphQLSemanticNullable<GraphQLType>; 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 +628,7 @@ export type GraphQLNullableType = | GraphQLList<GraphQLType>; 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 +640,7 @@ export function assertNullableType(type: unknown): GraphQLNullableType { export function getNullableType(type: undefined | null): void; export function getNullableType<T extends GraphQLNullableType>( - type: T | GraphQLNonNull<T>, + type: T | GraphQLNonNull<T> | GraphQLSemanticNonNull<T>, ): T; export function getNullableType( type: Maybe<GraphQLType>, @@ -467,12 +649,14 @@ export function getNullableType( type: Maybe<GraphQLType>, ): 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 +1172,8 @@ export interface GraphQLResolveInfo { readonly rootValue: unknown; readonly operation: OperationDefinitionNode; readonly variableValues: { [variable: string]: unknown }; + /** @experimental */ + readonly errorPropagation: boolean; } /** 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..7dbce27eec 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,25 @@ 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 +436,37 @@ export const __Field: GraphQLObjectType = new GraphQLObjectType({ } as GraphQLFieldConfigMap<GraphQLField<unknown, unknown>, 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 +540,7 @@ enum TypeKind { INPUT_OBJECT = 'INPUT_OBJECT', LIST = 'LIST', NON_NULL = 'NON_NULL', + SEMANTIC_NON_NULL = 'SEMANTIC_NON_NULL', } export { TypeKind }; @@ -497,6 +586,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 +647,7 @@ export const introspectionTypes: ReadonlyArray<GraphQLNamedType> = __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/buildClientSchema.ts b/src/utilities/buildClientSchema.ts index 83f6abada8..bcc69557cb 100644 --- a/src/utilities/buildClientSchema.ts +++ b/src/utilities/buildClientSchema.ts @@ -23,6 +23,7 @@ import { GraphQLObjectType, GraphQLScalarType, GraphQLUnionType, + GraphQLSemanticNonNull, isInputType, isOutputType, } from '../type/definition'; @@ -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..452bc8cdab 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -54,6 +54,8 @@ import { GraphQLObjectType, GraphQLScalarType, GraphQLUnionType, + GraphQLSemanticNonNull, + GraphQLSemanticNullable, isEnumType, isInputObjectType, isInterfaceType, @@ -61,6 +63,7 @@ import { isNonNullType, isObjectType, isScalarType, + isSemanticNonNullType, isUnionType, } from '../type/definition'; import { @@ -225,6 +228,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 +439,12 @@ 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)); + } + 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..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 373b474ed5..dda0e7f19a 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<GraphQLNamedType>(type); diff --git a/src/utilities/typeComparators.ts b/src/utilities/typeComparators.ts index 287be40bfe..13311780ff 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,17 @@ 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 8397a35b80..182215fd3f 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'; @@ -723,6 +724,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; } From 0f13010005ef8441fa874bc1f9bbe9d5388c2e2b Mon Sep 17 00:00:00 2001 From: Jovi De Croock <decroockjovi@gmail.com> Date: Sun, 2 Feb 2025 09:03:46 +0100 Subject: [PATCH 02/10] Fix a few tests --- src/execution/__tests__/executor-test.ts | 2 +- src/execution/execute.ts | 6 +-- src/language/__tests__/parser-test.ts | 2 +- src/language/__tests__/predicates-test.ts | 1 + src/language/ast.ts | 1 - src/language/lexer.ts | 7 +++- src/language/parser.ts | 25 ++++++++++-- src/type/__tests__/introspection-test.ts | 2 +- src/type/__tests__/predicate-test.ts | 2 +- src/type/definition.ts | 50 ++++++++++++----------- src/type/introspection.ts | 8 ++-- src/utilities/buildClientSchema.ts | 2 +- src/utilities/extendSchema.ts | 2 +- src/utilities/typeFromAST.ts | 5 +++ 14 files changed, 73 insertions(+), 42 deletions(-) diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index afc73d5c08..a7bc1c8265 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -276,7 +276,7 @@ describe('Execute: Handles basic execution tasks', () => { schema, rootValue, operation, - errorPropagation: true + errorPropagation: true, }); const field = operation.selectionSet.selections[0]; diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 33aa1dd6f1..0bfbcf3f3e 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -161,7 +161,7 @@ export interface ExecutionArgs { * * @experimental */ - errorPropagation?: boolean; + errorPropagation?: boolean; } /** @@ -296,7 +296,7 @@ export function buildExecutionContext( fieldResolver, typeResolver, subscribeFieldResolver, - errorPropagation + errorPropagation, } = args; let operation: OperationDefinitionNode | undefined; @@ -671,7 +671,7 @@ function completeValue( return completed; } - // If field type is SemanticNonNull, complete for inner type, and throw field error + // 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( diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index 9c23d6d46b..4c134f2be0 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -659,7 +659,7 @@ describe('Parser', () => { }); describe('parseDocumentDirective', () => { - it('doesnt throw on document-level directive', () => { + it('doesn\'t throw on document-level directive', () => { parse(dedent` @SemanticNullability type Query { diff --git a/src/language/__tests__/predicates-test.ts b/src/language/__tests__/predicates-test.ts index 32ef7d1fe1..aa41961177 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -93,6 +93,7 @@ describe('AST node predicates', () => { 'ListType', 'NonNullType', 'SemanticNonNullType', + 'SemanticNullableType', ]); }); diff --git a/src/language/ast.ts b/src/language/ast.ts index dbe03aad06..57beb3c573 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -523,7 +523,6 @@ export interface ConstDirectiveNode { readonly arguments?: ReadonlyArray<ConstArgumentNode>; } - export interface SemanticNonNullTypeNode { readonly kind: Kind.SEMANTIC_NON_NULL_TYPE; readonly loc?: Location; diff --git a/src/language/lexer.ts b/src/language/lexer.ts index b41ae415ac..86ff5edb6f 100644 --- a/src/language/lexer.ts +++ b/src/language/lexer.ts @@ -251,7 +251,12 @@ function readNextToken(lexer: Lexer, start: number): Token { case 0x0021: // ! return createToken(lexer, TokenKind.BANG, position, position + 1); case 0x003f: // ? - return createToken(lexer, TokenKind.QUESTION_MARK, position, position + 1); + return createToken( + lexer, + TokenKind.QUESTION_MARK, + position, + position + 1, + ); case 0x0024: // $ return createToken(lexer, TokenKind.DOLLAR, position, position + 1); case 0x0026: // & diff --git a/src/language/parser.ts b/src/language/parser.ts index 84131bd603..5743eef9da 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -50,9 +50,9 @@ import type { SchemaExtensionNode, SelectionNode, SelectionSetNode, - StringValueNode, SemanticNonNullTypeNode, SemanticNullableTypeNode, + StringValueNode, Token, TypeNode, TypeSystemExtensionNode, @@ -109,10 +109,10 @@ export interface ParseOptions { /** * When enabled, the parser will understand and parse semantic nullability * annotations. This means that every type suffixed with `!` will remain - * non-nulllable, every type suffxed with `?` will be the classic nullable, and + * non-nullable, every type suffixed with `?` will be the classic nullable, and * types without a suffix will be semantically nullable. Semantic nullability * will be the new default when this is enabled. A semantically nullable type - * can only be null when there's an error assocaited with the field. + * can only be null when there's an error associated with the field. * * @experimental */ @@ -788,6 +788,25 @@ export class Parser { type = this.parseNamedType(); } + if (this._options.allowSemanticNullability) { + if (this.expectOptionalToken(TokenKind.BANG)) { + return this.node<NonNullTypeNode>(start, { + kind: Kind.NON_NULL_TYPE, + type, + }); + } else if (this.expectOptionalToken(TokenKind.QUESTION_MARK)) { + return this.node<SemanticNullableTypeNode>(start, { + kind: Kind.SEMANTIC_NULLABLE_TYPE, + type, + }); + } + + return this.node<SemanticNonNullTypeNode>(start, { + kind: Kind.SEMANTIC_NON_NULL_TYPE, + type, + }); + } + if (this.expectOptionalToken(TokenKind.BANG)) { return this.node<NonNullTypeNode>(start, { kind: Kind.NON_NULL_TYPE, diff --git a/src/type/__tests__/introspection-test.ts b/src/type/__tests__/introspection-test.ts index 08273f495f..9b0eaa11a4 100644 --- a/src/type/__tests__/introspection-test.ts +++ b/src/type/__tests__/introspection-test.ts @@ -32,7 +32,7 @@ describe('Introspection', () => { expect(result).to.deep.equal({ data: { __schema: { - queryType: { name: 'SomeObject' }, + queryType: { kind: 'OBJECT', name: 'SomeObject' }, mutationType: null, subscriptionType: null, types: [ diff --git a/src/type/__tests__/predicate-test.ts b/src/type/__tests__/predicate-test.ts index 774199a368..1c576e8eaa 100644 --- a/src/type/__tests__/predicate-test.ts +++ b/src/type/__tests__/predicate-test.ts @@ -765,4 +765,4 @@ describe('Directive predicates', () => { expect(isSpecifiedDirective(Directive)).to.equal(false); }); }); -}); \ No newline at end of file +}); diff --git a/src/type/definition.ts b/src/type/definition.ts index ff1238793b..80887c852d 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -67,24 +67,24 @@ export type GraphQLType = | GraphQLInputObjectType | GraphQLList<GraphQLType> > - | GraphQLSemanticNonNull< - | GraphQLScalarType - | GraphQLObjectType - | GraphQLInterfaceType - | GraphQLUnionType - | GraphQLEnumType - | GraphQLInputObjectType - | GraphQLList<GraphQLType> - > + | GraphQLSemanticNonNull< + | GraphQLScalarType + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | GraphQLEnumType + | GraphQLInputObjectType + | GraphQLList<GraphQLType> + > | GraphQLSemanticNullable< - | GraphQLScalarType - | GraphQLObjectType - | GraphQLInterfaceType - | GraphQLUnionType - | GraphQLEnumType - | GraphQLInputObjectType - | GraphQLList<GraphQLType> - >; + | GraphQLScalarType + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | GraphQLEnumType + | GraphQLInputObjectType + | GraphQLList<GraphQLType> + >; export function isType(type: unknown): type is GraphQLType { return ( @@ -325,13 +325,15 @@ export type GraphQLOutputType = | GraphQLUnionType | GraphQLEnumType | GraphQLList<GraphQLOutputType> - > | GraphQLSemanticNonNull< - | GraphQLScalarType - | GraphQLObjectType - | GraphQLInterfaceType - | GraphQLUnionType - | GraphQLEnumType - | GraphQLList<GraphQLOutputType> >; + > + | GraphQLSemanticNonNull< + | GraphQLScalarType + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | GraphQLEnumType + | GraphQLList<GraphQLOutputType> + >; export function isOutputType(type: unknown): type is GraphQLOutputType { return ( diff --git a/src/type/introspection.ts b/src/type/introspection.ts index 7dbce27eec..b77ea37380 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -417,10 +417,10 @@ export const __Field: GraphQLObjectType = new GraphQLObjectType({ } const mode = - nullability === TypeNullability.AUTO - ? info.errorPropagation - ? TypeNullability.TRADITIONAL - : TypeNullability.SEMANTIC + nullability === TypeNullability.AUTO + ? info.errorPropagation + ? TypeNullability.TRADITIONAL + : TypeNullability.SEMANTIC : nullability; return convertOutputTypeToNullabilityMode(field.type, mode); }, diff --git a/src/utilities/buildClientSchema.ts b/src/utilities/buildClientSchema.ts index bcc69557cb..9b0809adf5 100644 --- a/src/utilities/buildClientSchema.ts +++ b/src/utilities/buildClientSchema.ts @@ -22,8 +22,8 @@ import { GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, - GraphQLUnionType, GraphQLSemanticNonNull, + GraphQLUnionType, isInputType, isOutputType, } from '../type/definition'; diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index 452bc8cdab..40ba62c964 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -53,9 +53,9 @@ import { GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, - GraphQLUnionType, GraphQLSemanticNonNull, GraphQLSemanticNullable, + GraphQLUnionType, isEnumType, isInputObjectType, isInterfaceType, diff --git a/src/utilities/typeFromAST.ts b/src/utilities/typeFromAST.ts index c5d5f537a2..9e5bc9b925 100644 --- a/src/utilities/typeFromAST.ts +++ b/src/utilities/typeFromAST.ts @@ -11,6 +11,7 @@ import { GraphQLList, GraphQLNonNull, GraphQLSemanticNonNull, + GraphQLSemanticNullable, } from '../type/definition'; import type { GraphQLSchema } from '../type/schema'; @@ -54,6 +55,10 @@ export function typeFromAST( const innerType = typeFromAST(schema, typeNode.type); return innerType && new GraphQLSemanticNonNull(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); } From 869ca46f24446ef152b54165dc866f428aa2dfea Mon Sep 17 00:00:00 2001 From: Jovi De Croock <decroockjovi@gmail.com> Date: Wed, 5 Feb 2025 09:17:48 +0100 Subject: [PATCH 03/10] Propose single wrapping type (#4339) This reduces the new AST-nodes to only be for the newly introduced type, this does make it so that when we invoke `print` we have to rely on the user to either specify that we're in semantic nullability mode _or_ we could do a pre-traverse and when we enter a node with semantic-non-null we toggle it on ourselves. The main reasoning behind removing the new name for our existing null type is that I would prefer to be backwards compatible in terms of schema structure. This because it might become complex for people to reason about composed schemas, i.e. a lot of individually parsed schemas that later on compose into a larger one. I know that _technically_ this is covered because in the classic ones we'll have the non wrapped null type and in the modern ones we'll have the semantic nullable wrapped type. For schema-builders like pothos and others I think this is rather complex to reason about _and_ to supply us with. I would instead choose to absorb this complexity in the feature and stay backwards compatible. This also sets us up for the SDL not being a breaking change, we only add one AST-type, what's left now is to settle on a semantic non-null syntax and making everything backwards compatible. --- .../__tests__/semantic-nullability-test.ts | 3 +- src/execution/execute.ts | 13 - src/language/__tests__/parser-test.ts | 16 +- src/language/__tests__/predicates-test.ts | 1 - src/language/__tests__/schema-printer-test.ts | 20 +- src/language/ast.ts | 11 +- src/language/kinds.ts | 1 - src/language/parser.ts | 6 +- src/language/predicates.ts | 3 +- src/language/printer.ts | 597 +++++++++--------- src/type/definition.ts | 91 +-- src/utilities/extendSchema.ts | 4 - src/utilities/typeFromAST.ts | 5 - 13 files changed, 332 insertions(+), 439 deletions(-) diff --git a/src/execution/__tests__/semantic-nullability-test.ts b/src/execution/__tests__/semantic-nullability-test.ts index 20a33c2ffa..6d9098d016 100644 --- a/src/execution/__tests__/semantic-nullability-test.ts +++ b/src/execution/__tests__/semantic-nullability-test.ts @@ -10,7 +10,6 @@ import { GraphQLNonNull, GraphQLObjectType, GraphQLSemanticNonNull, - GraphQLSemanticNullable, } from '../../type/definition'; import { GraphQLString } from '../../type/scalars'; import { GraphQLSchema } from '../../type/schema'; @@ -28,7 +27,7 @@ describe('Execute: Handles Semantic Nullability', () => { const DataType: GraphQLObjectType = new GraphQLObjectType({ name: 'DataType', fields: () => ({ - a: { type: new GraphQLSemanticNullable(GraphQLString) }, + a: { type: GraphQLString }, b: { type: new GraphQLSemanticNonNull(GraphQLString) }, c: { type: new GraphQLNonNull(GraphQLString) }, d: { type: new GraphQLSemanticNonNull(DeepDataType) }, diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 0bfbcf3f3e..055b778983 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -44,7 +44,6 @@ import { isNonNullType, isObjectType, isSemanticNonNullType, - isSemanticNullableType, } from '../type/definition'; import { SchemaMetaFieldDef, @@ -690,18 +689,6 @@ function completeValue( 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/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index 4c134f2be0..f3577ef64d 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -659,7 +659,7 @@ describe('Parser', () => { }); describe('parseDocumentDirective', () => { - it('doesn\'t throw on document-level directive', () => { + it("doesn't throw on document-level directive", () => { parse(dedent` @SemanticNullability type Query { @@ -690,16 +690,12 @@ describe('Parser', () => { it('parses nullable types', () => { const result = parseType('MyType?', { allowSemanticNullability: true }); expectJSON(result).toDeepEqual({ - kind: Kind.SEMANTIC_NULLABLE_TYPE, - loc: { start: 0, end: 7 }, - type: { - kind: Kind.NAMED_TYPE, + kind: Kind.NAMED_TYPE, + loc: { start: 0, end: 6 }, + name: { + kind: Kind.NAME, loc: { start: 0, end: 6 }, - name: { - kind: Kind.NAME, - loc: { start: 0, end: 6 }, - value: 'MyType', - }, + value: 'MyType', }, }); }); diff --git a/src/language/__tests__/predicates-test.ts b/src/language/__tests__/predicates-test.ts index aa41961177..32ef7d1fe1 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -93,7 +93,6 @@ describe('AST node predicates', () => { 'ListType', 'NonNullType', 'SemanticNonNullType', - 'SemanticNullableType', ]); }); diff --git a/src/language/__tests__/schema-printer-test.ts b/src/language/__tests__/schema-printer-test.ts index a5f803bc1d..a2e3fa070d 100644 --- a/src/language/__tests__/schema-printer-test.ts +++ b/src/language/__tests__/schema-printer-test.ts @@ -183,28 +183,38 @@ describe('Printer: SDL document', () => { it('prints NamedType', () => { expect( - print(parseType('MyType', { allowSemanticNullability: false })), + print(parseType('MyType', { allowSemanticNullability: false }), { + useSemanticNullability: false, + }), ).to.equal(dedent`MyType`); }); it('prints SemanticNullableType', () => { expect( - print(parseType('MyType?', { allowSemanticNullability: true })), + print(parseType('MyType?', { allowSemanticNullability: true }), { + useSemanticNullability: true, + }), ).to.equal(dedent`MyType?`); }); it('prints SemanticNonNullType', () => { expect( - print(parseType('MyType', { allowSemanticNullability: true })), + print(parseType('MyType', { allowSemanticNullability: true }), { + useSemanticNullability: true, + }), ).to.equal(dedent`MyType`); }); it('prints NonNullType', () => { expect( - print(parseType('MyType!', { allowSemanticNullability: true })), + print(parseType('MyType!', { allowSemanticNullability: true }), { + useSemanticNullability: true, + }), ).to.equal(dedent`MyType!`); expect( - print(parseType('MyType!', { allowSemanticNullability: false })), + print(parseType('MyType!', { allowSemanticNullability: false }), { + useSemanticNullability: true, + }), ).to.equal(dedent`MyType!`); }); }); diff --git a/src/language/ast.ts b/src/language/ast.ts index 57beb3c573..21c4160464 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -162,7 +162,6 @@ export type ASTNode = | ListTypeNode | NonNullTypeNode | SemanticNonNullTypeNode - | SemanticNullableTypeNode | SchemaDefinitionNode | OperationTypeDefinitionNode | ScalarTypeDefinitionNode @@ -238,7 +237,6 @@ export const QueryDocumentKeys: { ListType: ['type'], NonNullType: ['type'], SemanticNonNullType: ['type'], - SemanticNullableType: ['type'], SchemaDefinition: ['description', 'directives', 'operationTypes'], OperationTypeDefinition: ['type'], @@ -529,20 +527,13 @@ export interface SemanticNonNullTypeNode { readonly type: NamedTypeNode | ListTypeNode; } -export interface SemanticNullableTypeNode { - readonly kind: Kind.SEMANTIC_NULLABLE_TYPE; - readonly loc?: Location; - readonly type: NamedTypeNode | ListTypeNode; -} - /** Type Reference */ export type TypeNode = | NamedTypeNode | ListTypeNode | NonNullTypeNode - | SemanticNonNullTypeNode - | SemanticNullableTypeNode; + | SemanticNonNullTypeNode; export interface NamedTypeNode { readonly kind: Kind.NAMED_TYPE; diff --git a/src/language/kinds.ts b/src/language/kinds.ts index 7111a94834..e91373746c 100644 --- a/src/language/kinds.ts +++ b/src/language/kinds.ts @@ -38,7 +38,6 @@ enum Kind { 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/parser.ts b/src/language/parser.ts index 5743eef9da..790aee3b4b 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -51,7 +51,6 @@ import type { SelectionNode, SelectionSetNode, SemanticNonNullTypeNode, - SemanticNullableTypeNode, StringValueNode, Token, TypeNode, @@ -795,10 +794,7 @@ export class Parser { type, }); } else if (this.expectOptionalToken(TokenKind.QUESTION_MARK)) { - return this.node<SemanticNullableTypeNode>(start, { - kind: Kind.SEMANTIC_NULLABLE_TYPE, - type, - }); + return type; } return this.node<SemanticNonNullTypeNode>(start, { diff --git a/src/language/predicates.ts b/src/language/predicates.ts index d528e6c3c2..3ddf52b94c 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -68,8 +68,7 @@ export function isTypeNode(node: ASTNode): node is TypeNode { node.kind === Kind.NAMED_TYPE || node.kind === Kind.LIST_TYPE || node.kind === Kind.NON_NULL_TYPE || - node.kind === Kind.SEMANTIC_NON_NULL_TYPE || - node.kind === Kind.SEMANTIC_NULLABLE_TYPE + node.kind === Kind.SEMANTIC_NON_NULL_TYPE ); } diff --git a/src/language/printer.ts b/src/language/printer.ts index 17b805e624..2b14ec5dfd 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -2,6 +2,7 @@ import type { Maybe } from '../jsutils/Maybe'; import type { ASTNode } from './ast'; import { printBlockString } from './blockString'; +import { Kind } from './kinds'; import { printString } from './printString'; import type { ASTReducer } from './visitor'; import { visit } from './visitor'; @@ -17,302 +18,314 @@ export interface PrintOptions { * Converts an AST into a string, using one set of reasonable * formatting rules. */ -export function print(ast: ASTNode): string { - return visit(ast, printDocASTReducer); -} +export function print(ast: ASTNode, options: PrintOptions = {}): string { + return visit<string>(ast, { + Name: { leave: (node) => node.value }, + Variable: { leave: (node) => '$' + node.name }, -const MAX_LINE_LENGTH = 80; + // Document -const printDocASTReducer: ASTReducer<string> = { - Name: { leave: (node) => node.value }, - Variable: { leave: (node) => '$' + node.name }, - - // Document - - Document: { - leave: (node) => join(node.definitions, '\n\n'), - }, - - OperationDefinition: { - leave(node) { - const varDefs = wrap('(', join(node.variableDefinitions, ', '), ')'); - const prefix = join( - [ - node.operation, - join([node.name, varDefs]), - join(node.directives, ' '), - ], - ' ', - ); - - // Anonymous queries with no directives or variable definitions can use - // the query short form. - return (prefix === 'query' ? '' : prefix + ' ') + node.selectionSet; + Document: { + leave: (node) => join(node.definitions, '\n\n'), }, - }, - - VariableDefinition: { - leave: ({ variable, type, defaultValue, directives }) => - variable + - ': ' + - type + - wrap(' = ', defaultValue) + - wrap(' ', join(directives, ' ')), - }, - SelectionSet: { leave: ({ selections }) => block(selections) }, - - Field: { - leave({ alias, name, arguments: args, directives, selectionSet }) { - const prefix = wrap('', alias, ': ') + name; - let argsLine = prefix + wrap('(', join(args, ', '), ')'); - - if (argsLine.length > MAX_LINE_LENGTH) { - argsLine = prefix + wrap('(\n', indent(join(args, '\n')), '\n)'); - } - - return join([argsLine, join(directives, ' '), selectionSet], ' '); + + OperationDefinition: { + leave(node) { + const varDefs = wrap('(', join(node.variableDefinitions, ', '), ')'); + const prefix = join( + [ + node.operation, + join([node.name, varDefs]), + join(node.directives, ' '), + ], + ' ', + ); + + // Anonymous queries with no directives or variable definitions can use + // the query short form. + return (prefix === 'query' ? '' : prefix + ' ') + node.selectionSet; + }, + }, + + VariableDefinition: { + leave: ({ variable, type, defaultValue, directives }) => + variable + + ': ' + + type + + wrap(' = ', defaultValue) + + wrap(' ', join(directives, ' ')), + }, + SelectionSet: { leave: ({ selections }) => block(selections) }, + + Field: { + leave({ alias, name, arguments: args, directives, selectionSet }) { + const prefix = wrap('', alias, ': ') + name; + let argsLine = prefix + wrap('(', join(args, ', '), ')'); + + if (argsLine.length > MAX_LINE_LENGTH) { + argsLine = prefix + wrap('(\n', indent(join(args, '\n')), '\n)'); + } + + return join([argsLine, join(directives, ' '), selectionSet], ' '); + }, + }, + + Argument: { leave: ({ name, value }) => name + ': ' + value }, + + // Fragments + + FragmentSpread: { + leave: ({ name, directives }) => + '...' + name + wrap(' ', join(directives, ' ')), + }, + + InlineFragment: { + leave: ({ typeCondition, directives, selectionSet }) => + join( + [ + '...', + wrap('on ', typeCondition), + join(directives, ' '), + selectionSet, + ], + ' ', + ), + }, + + FragmentDefinition: { + leave: ({ + name, + typeCondition, + variableDefinitions, + directives, + selectionSet, + }) => + // Note: fragment variable definitions are experimental and may be changed + // or removed in the future. + `fragment ${name}${wrap('(', join(variableDefinitions, ', '), ')')} ` + + `on ${typeCondition} ${wrap('', join(directives, ' '), ' ')}` + + selectionSet, }, - }, - - Argument: { leave: ({ name, value }) => name + ': ' + value }, - - // Fragments - - FragmentSpread: { - leave: ({ name, directives }) => - '...' + name + wrap(' ', join(directives, ' ')), - }, - - InlineFragment: { - leave: ({ typeCondition, directives, selectionSet }) => - join( - [ - '...', - wrap('on ', typeCondition), - join(directives, ' '), - selectionSet, - ], - ' ', - ), - }, - - FragmentDefinition: { - leave: ({ - name, - typeCondition, - variableDefinitions, - directives, - selectionSet, - }) => - // Note: fragment variable definitions are experimental and may be changed - // or removed in the future. - `fragment ${name}${wrap('(', join(variableDefinitions, ', '), ')')} ` + - `on ${typeCondition} ${wrap('', join(directives, ' '), ' ')}` + - selectionSet, - }, - - // Value - - IntValue: { leave: ({ value }) => value }, - FloatValue: { leave: ({ value }) => value }, - StringValue: { - leave: ({ value, block: isBlockString }) => - isBlockString ? printBlockString(value) : printString(value), - }, - BooleanValue: { leave: ({ value }) => (value ? 'true' : 'false') }, - NullValue: { leave: () => 'null' }, - EnumValue: { leave: ({ value }) => value }, - ListValue: { leave: ({ values }) => '[' + join(values, ', ') + ']' }, - ObjectValue: { leave: ({ fields }) => '{' + join(fields, ', ') + '}' }, - ObjectField: { leave: ({ name, value }) => name + ': ' + value }, - - // Directive - - Directive: { - leave: ({ name, arguments: args }) => - '@' + name + wrap('(', join(args, ', '), ')'), - }, - - // Type - - NamedType: { leave: ({ name }) => name }, - ListType: { leave: ({ type }) => '[' + type + ']' }, - NonNullType: { leave: ({ type }) => type + '!' }, - SemanticNonNullType: { leave: ({ type }) => type }, - SemanticNullableType: { leave: ({ type }) => type + '?' }, - - // Type System Definitions - - SchemaDefinition: { - leave: ({ description, directives, operationTypes }) => - wrap('', description, '\n') + - join(['schema', join(directives, ' '), block(operationTypes)], ' '), - }, - - OperationTypeDefinition: { - leave: ({ operation, type }) => operation + ': ' + type, - }, - - ScalarTypeDefinition: { - leave: ({ description, name, directives }) => - wrap('', description, '\n') + - join(['scalar', name, join(directives, ' ')], ' '), - }, - - ObjectTypeDefinition: { - leave: ({ description, name, interfaces, directives, fields }) => - wrap('', description, '\n') + - join( - [ - 'type', - name, - wrap('implements ', join(interfaces, ' & ')), - join(directives, ' '), - block(fields), - ], - ' ', - ), - }, - - FieldDefinition: { - leave: ({ description, name, arguments: args, type, directives }) => - wrap('', description, '\n') + - name + - (hasMultilineItems(args) - ? wrap('(\n', indent(join(args, '\n')), '\n)') - : wrap('(', join(args, ', '), ')')) + - ': ' + - type + - wrap(' ', join(directives, ' ')), - }, - - InputValueDefinition: { - leave: ({ description, name, type, defaultValue, directives }) => - wrap('', description, '\n') + - join( - [name + ': ' + type, wrap('= ', defaultValue), join(directives, ' ')], - ' ', - ), - }, - - InterfaceTypeDefinition: { - leave: ({ description, name, interfaces, directives, fields }) => - wrap('', description, '\n') + - join( - [ - 'interface', - name, - wrap('implements ', join(interfaces, ' & ')), - join(directives, ' '), - block(fields), - ], - ' ', - ), - }, - - UnionTypeDefinition: { - leave: ({ description, name, directives, types }) => - wrap('', description, '\n') + - join( - ['union', name, join(directives, ' '), wrap('= ', join(types, ' | '))], - ' ', - ), - }, - - EnumTypeDefinition: { - leave: ({ description, name, directives, values }) => - wrap('', description, '\n') + - join(['enum', name, join(directives, ' '), block(values)], ' '), - }, - - EnumValueDefinition: { - leave: ({ description, name, directives }) => - wrap('', description, '\n') + join([name, join(directives, ' ')], ' '), - }, - - InputObjectTypeDefinition: { - leave: ({ description, name, directives, fields }) => - wrap('', description, '\n') + - join(['input', name, join(directives, ' '), block(fields)], ' '), - }, - - DirectiveDefinition: { - leave: ({ description, name, arguments: args, repeatable, locations }) => - wrap('', description, '\n') + - 'directive @' + - name + - (hasMultilineItems(args) - ? wrap('(\n', indent(join(args, '\n')), '\n)') - : wrap('(', join(args, ', '), ')')) + - (repeatable ? ' repeatable' : '') + - ' on ' + - join(locations, ' | '), - }, - - SchemaExtension: { - leave: ({ directives, operationTypes }) => - join( - ['extend schema', join(directives, ' '), block(operationTypes)], - ' ', - ), - }, - - ScalarTypeExtension: { - leave: ({ name, directives }) => - join(['extend scalar', name, join(directives, ' ')], ' '), - }, - - ObjectTypeExtension: { - leave: ({ name, interfaces, directives, fields }) => - join( - [ - 'extend type', - name, - wrap('implements ', join(interfaces, ' & ')), - join(directives, ' '), - block(fields), - ], - ' ', - ), - }, - - InterfaceTypeExtension: { - leave: ({ name, interfaces, directives, fields }) => - join( - [ - 'extend interface', - name, - wrap('implements ', join(interfaces, ' & ')), - join(directives, ' '), - block(fields), - ], - ' ', - ), - }, - - UnionTypeExtension: { - leave: ({ name, directives, types }) => - join( - [ - 'extend union', - name, - join(directives, ' '), - wrap('= ', join(types, ' | ')), - ], - ' ', - ), - }, - - EnumTypeExtension: { - leave: ({ name, directives, values }) => - join(['extend enum', name, join(directives, ' '), block(values)], ' '), - }, - - InputObjectTypeExtension: { - leave: ({ name, directives, fields }) => - join(['extend input', name, join(directives, ' '), block(fields)], ' '), - }, -}; + + // Value + + IntValue: { leave: ({ value }) => value }, + FloatValue: { leave: ({ value }) => value }, + StringValue: { + leave: ({ value, block: isBlockString }) => + isBlockString ? printBlockString(value) : printString(value), + }, + BooleanValue: { leave: ({ value }) => (value ? 'true' : 'false') }, + NullValue: { leave: () => 'null' }, + EnumValue: { leave: ({ value }) => value }, + ListValue: { leave: ({ values }) => '[' + join(values, ', ') + ']' }, + ObjectValue: { leave: ({ fields }) => '{' + join(fields, ', ') + '}' }, + ObjectField: { leave: ({ name, value }) => name + ': ' + value }, + + // Directive + + Directive: { + leave: ({ name, arguments: args }) => + '@' + name + wrap('(', join(args, ', '), ')'), + }, + + // Type + + NamedType: { + leave: ({ name }, _, parent) => + parent && + !Array.isArray(parent) && + ((parent as ASTNode).kind === Kind.SEMANTIC_NON_NULL_TYPE || + (parent as ASTNode).kind === Kind.NON_NULL_TYPE) + ? name + : options?.useSemanticNullability + ? `${name}?` + : name, + }, + ListType: { leave: ({ type }) => '[' + type + ']' }, + NonNullType: { leave: ({ type }) => type + '!' }, + SemanticNonNullType: { leave: ({ type }) => type }, + + // Type System Definitions + + SchemaDefinition: { + leave: ({ description, directives, operationTypes }) => + wrap('', description, '\n') + + join(['schema', join(directives, ' '), block(operationTypes)], ' '), + }, + + OperationTypeDefinition: { + leave: ({ operation, type }) => operation + ': ' + type, + }, + + ScalarTypeDefinition: { + leave: ({ description, name, directives }) => + wrap('', description, '\n') + + join(['scalar', name, join(directives, ' ')], ' '), + }, + + ObjectTypeDefinition: { + leave: ({ description, name, interfaces, directives, fields }) => + wrap('', description, '\n') + + join( + [ + 'type', + name, + wrap('implements ', join(interfaces, ' & ')), + join(directives, ' '), + block(fields), + ], + ' ', + ), + }, + + FieldDefinition: { + leave: ({ description, name, arguments: args, type, directives }) => + wrap('', description, '\n') + + name + + (hasMultilineItems(args) + ? wrap('(\n', indent(join(args, '\n')), '\n)') + : wrap('(', join(args, ', '), ')')) + + ': ' + + type + + wrap(' ', join(directives, ' ')), + }, + + InputValueDefinition: { + leave: ({ description, name, type, defaultValue, directives }) => + wrap('', description, '\n') + + join( + [name + ': ' + type, wrap('= ', defaultValue), join(directives, ' ')], + ' ', + ), + }, + + InterfaceTypeDefinition: { + leave: ({ description, name, interfaces, directives, fields }) => + wrap('', description, '\n') + + join( + [ + 'interface', + name, + wrap('implements ', join(interfaces, ' & ')), + join(directives, ' '), + block(fields), + ], + ' ', + ), + }, + + UnionTypeDefinition: { + leave: ({ description, name, directives, types }) => + wrap('', description, '\n') + + join( + [ + 'union', + name, + join(directives, ' '), + wrap('= ', join(types, ' | ')), + ], + ' ', + ), + }, + + EnumTypeDefinition: { + leave: ({ description, name, directives, values }) => + wrap('', description, '\n') + + join(['enum', name, join(directives, ' '), block(values)], ' '), + }, + + EnumValueDefinition: { + leave: ({ description, name, directives }) => + wrap('', description, '\n') + join([name, join(directives, ' ')], ' '), + }, + + InputObjectTypeDefinition: { + leave: ({ description, name, directives, fields }) => + wrap('', description, '\n') + + join(['input', name, join(directives, ' '), block(fields)], ' '), + }, + + DirectiveDefinition: { + leave: ({ description, name, arguments: args, repeatable, locations }) => + wrap('', description, '\n') + + 'directive @' + + name + + (hasMultilineItems(args) + ? wrap('(\n', indent(join(args, '\n')), '\n)') + : wrap('(', join(args, ', '), ')')) + + (repeatable ? ' repeatable' : '') + + ' on ' + + join(locations, ' | '), + }, + + SchemaExtension: { + leave: ({ directives, operationTypes }) => + join( + ['extend schema', join(directives, ' '), block(operationTypes)], + ' ', + ), + }, + + ScalarTypeExtension: { + leave: ({ name, directives }) => + join(['extend scalar', name, join(directives, ' ')], ' '), + }, + + ObjectTypeExtension: { + leave: ({ name, interfaces, directives, fields }) => + join( + [ + 'extend type', + name, + wrap('implements ', join(interfaces, ' & ')), + join(directives, ' '), + block(fields), + ], + ' ', + ), + }, + + InterfaceTypeExtension: { + leave: ({ name, interfaces, directives, fields }) => + join( + [ + 'extend interface', + name, + wrap('implements ', join(interfaces, ' & ')), + join(directives, ' '), + block(fields), + ], + ' ', + ), + }, + + UnionTypeExtension: { + leave: ({ name, directives, types }) => + join( + [ + 'extend union', + name, + join(directives, ' '), + wrap('= ', join(types, ' | ')), + ], + ' ', + ), + }, + + EnumTypeExtension: { + leave: ({ name, directives, values }) => + join(['extend enum', name, join(directives, ' '), block(values)], ' '), + }, + + InputObjectTypeExtension: { + leave: ({ name, directives, fields }) => + join(['extend input', name, join(directives, ' '), block(fields)], ' '), + }, + }); +} + +const MAX_LINE_LENGTH = 80; /** * Given maybeArray, print an empty string if it is null or empty, otherwise diff --git a/src/type/definition.ts b/src/type/definition.ts index 80887c852d..bcd3862c89 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -75,15 +75,6 @@ export type GraphQLType = | GraphQLEnumType | GraphQLInputObjectType | GraphQLList<GraphQLType> - > - | GraphQLSemanticNullable< - | GraphQLScalarType - | GraphQLObjectType - | GraphQLInterfaceType - | GraphQLUnionType - | GraphQLEnumType - | GraphQLInputObjectType - | GraphQLList<GraphQLType> >; export function isType(type: unknown): type is GraphQLType { @@ -249,32 +240,6 @@ export function assertSemanticNonNullType( return type; } -export function isSemanticNullableType( - type: GraphQLInputType, -): type is GraphQLSemanticNullable<GraphQLInputType>; -export function isSemanticNullableType( - type: GraphQLOutputType, -): type is GraphQLSemanticNullable<GraphQLOutputType>; -export function isSemanticNullableType( - type: unknown, -): type is GraphQLSemanticNullable<GraphQLType>; -export function isSemanticNullableType( - type: unknown, -): type is GraphQLSemanticNullable<GraphQLType> { - return instanceOf(type, GraphQLSemanticNullable); -} - -export function assertSemanticNullableType( - type: unknown, -): GraphQLSemanticNullable<GraphQLType> { - 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. */ @@ -545,52 +510,6 @@ export class GraphQLSemanticNonNull<T extends GraphQLNullableType> { } } -/** - * 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<T extends GraphQLNullableType> { - 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 */ @@ -598,16 +517,10 @@ export class GraphQLSemanticNullable<T extends GraphQLNullableType> { export type GraphQLWrappingType = | GraphQLList<GraphQLType> | GraphQLNonNull<GraphQLType> - | GraphQLSemanticNonNull<GraphQLType> - | GraphQLSemanticNullable<GraphQLType>; + | GraphQLSemanticNonNull<GraphQLType>; export function isWrappingType(type: unknown): type is GraphQLWrappingType { - return ( - isListType(type) || - isNonNullType(type) || - isSemanticNonNullType(type) || - isSemanticNullableType(type) - ); + return isListType(type) || isNonNullType(type) || isSemanticNonNullType(type); } export function assertWrappingType(type: unknown): GraphQLWrappingType { diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index 40ba62c964..876aae277f 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -54,7 +54,6 @@ import { GraphQLObjectType, GraphQLScalarType, GraphQLSemanticNonNull, - GraphQLSemanticNullable, GraphQLUnionType, isEnumType, isInputObjectType, @@ -442,9 +441,6 @@ export function extendSchemaImpl( if (node.kind === Kind.SEMANTIC_NON_NULL_TYPE) { return new GraphQLSemanticNonNull(getWrappedType(node.type)); } - if (node.kind === Kind.SEMANTIC_NULLABLE_TYPE) { - return new GraphQLSemanticNullable(getWrappedType(node.type)); - } return getNamedType(node); } diff --git a/src/utilities/typeFromAST.ts b/src/utilities/typeFromAST.ts index 9e5bc9b925..c5d5f537a2 100644 --- a/src/utilities/typeFromAST.ts +++ b/src/utilities/typeFromAST.ts @@ -11,7 +11,6 @@ import { GraphQLList, GraphQLNonNull, GraphQLSemanticNonNull, - GraphQLSemanticNullable, } from '../type/definition'; import type { GraphQLSchema } from '../type/schema'; @@ -55,10 +54,6 @@ export function typeFromAST( const innerType = typeFromAST(schema, typeNode.type); return innerType && new GraphQLSemanticNonNull(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); } From e0c242586c31a91d1f2542da8ea384036c4fba17 Mon Sep 17 00:00:00 2001 From: jdecroock <decroockjovi@gmail.com> Date: Wed, 5 Feb 2025 16:45:22 +0100 Subject: [PATCH 04/10] Fix linting --- src/language/printer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/language/printer.ts b/src/language/printer.ts index 2b14ec5dfd..66d591d619 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -4,7 +4,6 @@ import type { ASTNode } from './ast'; import { printBlockString } from './blockString'; import { Kind } from './kinds'; import { printString } from './printString'; -import type { ASTReducer } from './visitor'; import { visit } from './visitor'; /** From 7121006dd42fdf6e4ba8847bad97a11015b480a6 Mon Sep 17 00:00:00 2001 From: Alex Reilly <fabiobean2@gmail.com> Date: Thu, 6 Feb 2025 23:17:42 -0800 Subject: [PATCH 05/10] fixed tests that were using print (#4341) There were a few tests that broke due to changes to print params --- .../__tests__/buildASTSchema-test.ts | 2 +- src/utilities/__tests__/extendSchema-test.ts | 8 +++++--- .../__tests__/separateOperations-test.ts | 20 ++++++++++++++----- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/utilities/__tests__/buildASTSchema-test.ts b/src/utilities/__tests__/buildASTSchema-test.ts index 29280474ec..a3e23affe9 100644 --- a/src/utilities/__tests__/buildASTSchema-test.ts +++ b/src/utilities/__tests__/buildASTSchema-test.ts @@ -60,7 +60,7 @@ function expectASTNode(obj: Maybe<{ readonly astNode: Maybe<ASTNode> }>) { function expectExtensionASTNodes(obj: { readonly extensionASTNodes: ReadonlyArray<ASTNode>; }) { - return expect(obj.extensionASTNodes.map(print).join('\n\n')); + return expect(obj.extensionASTNodes.map((node) => print(node)).join('\n\n')); } describe('Schema Builder', () => { diff --git a/src/utilities/__tests__/extendSchema-test.ts b/src/utilities/__tests__/extendSchema-test.ts index 86baf0e699..171b0e6b62 100644 --- a/src/utilities/__tests__/extendSchema-test.ts +++ b/src/utilities/__tests__/extendSchema-test.ts @@ -39,7 +39,7 @@ import { printSchema } from '../printSchema'; function expectExtensionASTNodes(obj: { readonly extensionASTNodes: ReadonlyArray<ASTNode>; }) { - return expect(obj.extensionASTNodes.map(print).join('\n\n')); + return expect(obj.extensionASTNodes.map((node) => print(node)).join('\n\n')); } function expectASTNode(obj: Maybe<{ readonly astNode: Maybe<ASTNode> }>) { @@ -51,10 +51,12 @@ function expectSchemaChanges( schema: GraphQLSchema, extendedSchema: GraphQLSchema, ) { - const schemaDefinitions = parse(printSchema(schema)).definitions.map(print); + const schemaDefinitions = parse(printSchema(schema)).definitions.map((node) => + print(node), + ); return expect( parse(printSchema(extendedSchema)) - .definitions.map(print) + .definitions.map((node) => print(node)) .filter((def) => !schemaDefinitions.includes(def)) .join('\n\n'), ); diff --git a/src/utilities/__tests__/separateOperations-test.ts b/src/utilities/__tests__/separateOperations-test.ts index 2f14bae9ac..aacf7bc15f 100644 --- a/src/utilities/__tests__/separateOperations-test.ts +++ b/src/utilities/__tests__/separateOperations-test.ts @@ -49,7 +49,9 @@ describe('separateOperations', () => { } `); - const separatedASTs = mapValue(separateOperations(ast), print); + const separatedASTs = mapValue(separateOperations(ast), (node) => + print(node), + ); expect(separatedASTs).to.deep.equal({ '': dedent` { @@ -128,7 +130,9 @@ describe('separateOperations', () => { } `); - const separatedASTs = mapValue(separateOperations(ast), print); + const separatedASTs = mapValue(separateOperations(ast), (node) => + print(node), + ); expect(separatedASTs).to.deep.equal({ One: dedent` query One { @@ -178,7 +182,9 @@ describe('separateOperations', () => { } `); - const separatedASTs = mapValue(separateOperations(ast), print); + const separatedASTs = mapValue(separateOperations(ast), (node) => + print(node), + ); expect(separatedASTs).to.deep.equal({ '': dedent` { @@ -215,7 +221,9 @@ describe('separateOperations', () => { type Bar `); - const separatedASTs = mapValue(separateOperations(ast), print); + const separatedASTs = mapValue(separateOperations(ast), (node) => + print(node), + ); expect(separatedASTs).to.deep.equal({ Foo: dedent` query Foo { @@ -241,7 +249,9 @@ describe('separateOperations', () => { } `); - const separatedASTs = mapValue(separateOperations(ast), print); + const separatedASTs = mapValue(separateOperations(ast), (node) => + print(node), + ); expect(separatedASTs).to.deep.equal({ '': dedent` { From 2321f932952e81d736cb9f8c24f3d9ac8e9010ce Mon Sep 17 00:00:00 2001 From: Jovi De Croock <decroockjovi@gmail.com> Date: Wed, 12 Feb 2025 03:54:43 +0100 Subject: [PATCH 06/10] Cleanup and code coverage --- .../__tests__/semantic-nullability-test.ts | 26 ---- src/type/__tests__/introspection-test.ts | 116 ++++++++++++-- src/type/directives.ts | 11 ++ src/type/introspection.ts | 47 ++---- src/utilities/__tests__/TypeInfo-test.ts | 62 ++++++++ .../__tests__/buildClientSchema-test.ts | 60 ++++++++ src/utilities/__tests__/extendSchema-test.ts | 36 ++++- .../__tests__/findBreakingChanges-test.ts | 100 ++++++++++++ .../__tests__/getIntrospectionQuery-test.ts | 9 ++ .../__tests__/lexicographicSortSchema-test.ts | 54 +++++++ src/utilities/__tests__/printSchema-test.ts | 16 +- .../__tests__/typeComparators-test.ts | 54 +++++++ src/utilities/buildClientSchema.ts | 1 + src/utilities/getIntrospectionQuery.ts | 8 +- src/utilities/printSchema.ts | 44 ++++-- src/utilities/typeComparators.ts | 2 +- src/utilities/typeFromAST.ts | 10 +- .../OverlappingFieldsCanBeMergedRule-test.ts | 145 ++++++++++++++++++ 18 files changed, 698 insertions(+), 103 deletions(-) diff --git a/src/execution/__tests__/semantic-nullability-test.ts b/src/execution/__tests__/semantic-nullability-test.ts index 6d9098d016..613ab91d1c 100644 --- a/src/execution/__tests__/semantic-nullability-test.ts +++ b/src/execution/__tests__/semantic-nullability-test.ts @@ -165,32 +165,6 @@ describe('Execute: Handles Semantic Nullability', () => { }); }); - 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', diff --git a/src/type/__tests__/introspection-test.ts b/src/type/__tests__/introspection-test.ts index 9b0eaa11a4..09c12abb06 100644 --- a/src/type/__tests__/introspection-test.ts +++ b/src/type/__tests__/introspection-test.ts @@ -523,7 +523,7 @@ describe('Introspection', () => { ofType: null, }, }, - defaultValue: 'AUTO', + defaultValue: 'TRADITIONAL', }, ], type: { @@ -667,21 +667,11 @@ describe('Introspection', () => { 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, @@ -1804,4 +1794,108 @@ describe('Introspection', () => { }); expect(result).to.not.have.property('errors'); }); + + describe('semantic nullability', () => { + it('casts semantic-non-null types to nullable types in traditional mode', () => { + const schema = buildSchema(` + @SemanticNullability + type Query { + someField: String! + someField2: String + someField3: String? + } + `); + + const source = getIntrospectionQuery({ + nullability: 'TRADITIONAL', + }); + + const result = graphqlSync({ schema, source }); + // @ts-expect-error + const queryType = result.data?.__schema?.types.find( + // @ts-expect-error + (t) => t.name === 'Query', + ); + const defaults = { + args: [], + deprecationReason: null, + description: null, + isDeprecated: false, + }; + expect(queryType?.fields).to.deep.equal([ + { + name: 'someField', + ...defaults, + type: { + kind: 'NON_NULL', + name: null, + ofType: { kind: 'SCALAR', name: 'String', ofType: null }, + }, + }, + { + name: 'someField2', + ...defaults, + type: { kind: 'SCALAR', name: 'String', ofType: null }, + }, + { + name: 'someField3', + ...defaults, + type: { kind: 'SCALAR', name: 'String', ofType: null }, + }, + ]); + }); + + it('returns semantic-non-null types in full mode', () => { + const schema = buildSchema(` + @SemanticNullability + type Query { + someField: String! + someField2: String + someField3: String? + } + `); + + const source = getIntrospectionQuery({ + nullability: 'FULL', + }); + + const result = graphqlSync({ schema, source }); + // @ts-expect-error + const queryType = result.data?.__schema?.types.find( + // @ts-expect-error + (t) => t.name === 'Query', + ); + const defaults = { + args: [], + deprecationReason: null, + description: null, + isDeprecated: false, + }; + expect(queryType?.fields).to.deep.equal([ + { + name: 'someField', + ...defaults, + type: { + kind: 'NON_NULL', + name: null, + ofType: { kind: 'SCALAR', name: 'String', ofType: null }, + }, + }, + { + name: 'someField2', + ...defaults, + type: { + kind: 'SEMANTIC_NON_NULL', + name: null, + ofType: { kind: 'SCALAR', name: 'String', ofType: null }, + }, + }, + { + name: 'someField3', + ...defaults, + type: { kind: 'SCALAR', name: 'String', ofType: null }, + }, + ]); + }); + }); }); diff --git a/src/type/directives.ts b/src/type/directives.ts index 6881f20532..276eb38aa7 100644 --- a/src/type/directives.ts +++ b/src/type/directives.ts @@ -165,6 +165,17 @@ export const GraphQLSkipDirective: GraphQLDirective = new GraphQLDirective({ }, }); +/** + * Used to indicate that the nullability of the document will be parsed as semantic-non-null types. + */ +export const GraphQLSemanticNullabilityDirective: GraphQLDirective = + new GraphQLDirective({ + name: 'SemanticNullability', + description: + 'Indicates that the nullability of the document will be parsed as semantic-non-null types.', + locations: [DirectiveLocation.SCHEMA], + }); + /** * Constant string used for default reason for a deprecation. */ diff --git a/src/type/introspection.ts b/src/type/introspection.ts index b77ea37380..950cf8958e 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -206,36 +206,23 @@ 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', + description: + 'This represents the type of nullability we want to return as part of the introspection.', 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!', + description: 'Allow for returning semantic-non-null types.', }, }, }); @@ -408,22 +395,11 @@ export const __Field: GraphQLObjectType = new GraphQLObjectType({ args: { nullability: { type: new GraphQLNonNull(__TypeNullability), - defaultValue: TypeNullability.AUTO, + defaultValue: TypeNullability.TRADITIONAL, }, }, - 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); - }, + resolve: (field, { nullability }, _context) => + convertOutputTypeToNullabilityMode(field.type, nullability), }, isDeprecated: { type: new GraphQLNonNull(GraphQLBoolean), @@ -436,10 +412,9 @@ export const __Field: GraphQLObjectType = new GraphQLObjectType({ } as GraphQLFieldConfigMap<GraphQLField<unknown, unknown>, unknown>), }); -// TODO: move this elsewhere, rename, memoize function convertOutputTypeToNullabilityMode( type: GraphQLType, - mode: TypeNullability.TRADITIONAL | TypeNullability.SEMANTIC, + mode: TypeNullability, ): GraphQLType { if (mode === TypeNullability.TRADITIONAL) { if (isNonNullType(type)) { @@ -455,7 +430,12 @@ function convertOutputTypeToNullabilityMode( } return type; } - if (isNonNullType(type) || isSemanticNonNullType(type)) { + + if (isNonNullType(type)) { + return new GraphQLNonNull( + convertOutputTypeToNullabilityMode(type.ofType, mode), + ); + } else if (isSemanticNonNullType(type)) { return new GraphQLSemanticNonNull( convertOutputTypeToNullabilityMode(type.ofType, mode), ); @@ -464,6 +444,7 @@ function convertOutputTypeToNullabilityMode( convertOutputTypeToNullabilityMode(type.ofType, mode), ); } + return type; } diff --git a/src/utilities/__tests__/TypeInfo-test.ts b/src/utilities/__tests__/TypeInfo-test.ts index 5c04458c51..48f50d21b7 100644 --- a/src/utilities/__tests__/TypeInfo-test.ts +++ b/src/utilities/__tests__/TypeInfo-test.ts @@ -457,4 +457,66 @@ describe('visitWithTypeInfo', () => { ['leave', 'SelectionSet', null, 'Human', 'Human'], ]); }); + + it('supports traversals of semantic non-null types', () => { + const schema = buildSchema(` + @SemanticNullability + type Query { + id: String! + name: String + something: String? + } + `); + + const typeInfo = new TypeInfo(schema); + + const visited: Array<any> = []; + const ast = parse('{ id name something }'); + + visit( + ast, + visitWithTypeInfo(typeInfo, { + enter(node) { + const type = typeInfo.getType(); + visited.push([ + 'enter', + node.kind, + node.kind === 'Name' ? node.value : null, + String(type), + ]); + }, + leave(node) { + const type = typeInfo.getType(); + visited.push([ + 'leave', + node.kind, + node.kind === 'Name' ? node.value : null, + // TODO: inspect currently returns "String" for a nullable type + String(type), + ]); + }, + }), + ); + + expect(visited).to.deep.equal([ + ['enter', 'Document', null, 'undefined'], + ['enter', 'OperationDefinition', null, 'Query'], + ['enter', 'SelectionSet', null, 'Query'], + ['enter', 'Field', null, 'String!'], + ['enter', 'Name', 'id', 'String!'], + ['leave', 'Name', 'id', 'String!'], + ['leave', 'Field', null, 'String!'], + ['enter', 'Field', null, 'String'], + ['enter', 'Name', 'name', 'String'], + ['leave', 'Name', 'name', 'String'], + ['leave', 'Field', null, 'String'], + ['enter', 'Field', null, 'String'], + ['enter', 'Name', 'something', 'String'], + ['leave', 'Name', 'something', 'String'], + ['leave', 'Field', null, 'String'], + ['leave', 'SelectionSet', null, 'Query'], + ['leave', 'OperationDefinition', null, 'Query'], + ['leave', 'Document', null, 'undefined'], + ]); + }); }); diff --git a/src/utilities/__tests__/buildClientSchema-test.ts b/src/utilities/__tests__/buildClientSchema-test.ts index e8cf046921..59b78024e6 100644 --- a/src/utilities/__tests__/buildClientSchema-test.ts +++ b/src/utilities/__tests__/buildClientSchema-test.ts @@ -9,6 +9,7 @@ import { assertEnumType, GraphQLEnumType, GraphQLObjectType, + GraphQLSemanticNonNull, } from '../../type/definition'; import { GraphQLBoolean, @@ -983,4 +984,63 @@ describe('Type System: build schema from introspection', () => { ); }); }); + + describe('SemanticNullability', () => { + it('should build a client schema with semantic-non-null types', () => { + const sdl = dedent` + @SemanticNullability + + type Query { + foo: String + bar: String? + } + `; + const schema = buildSchema(sdl, { assumeValid: true }); + const introspection = introspectionFromSchema(schema, { + nullability: 'FULL', + }); + + const clientSchema = buildClientSchema(introspection); + expect(printSchema(clientSchema)).to.equal(sdl); + + const defaults = { + args: [], + astNode: undefined, + deprecationReason: null, + description: null, + extensions: {}, + resolve: undefined, + subscribe: undefined, + }; + expect(clientSchema.getType('Query')).to.deep.include({ + name: 'Query', + _fields: { + foo: { + ...defaults, + name: 'foo', + type: new GraphQLSemanticNonNull(GraphQLString), + }, + bar: { ...defaults, name: 'bar', type: GraphQLString }, + }, + }); + }); + + it('should throw when semantic-non-null types are too deep', () => { + const sdl = dedent` + @SemanticNullability + + type Query { + bar: [[[[[[String?]]]]]]? + } + `; + const schema = buildSchema(sdl, { assumeValid: true }); + const introspection = introspectionFromSchema(schema, { + nullability: 'FULL', + }); + + expect(() => buildClientSchema(introspection)).to.throw( + 'Decorated type deeper than introspection query.', + ); + }); + }); }); diff --git a/src/utilities/__tests__/extendSchema-test.ts b/src/utilities/__tests__/extendSchema-test.ts index 171b0e6b62..a70ff2fb47 100644 --- a/src/utilities/__tests__/extendSchema-test.ts +++ b/src/utilities/__tests__/extendSchema-test.ts @@ -50,13 +50,16 @@ function expectASTNode(obj: Maybe<{ readonly astNode: Maybe<ASTNode> }>) { function expectSchemaChanges( schema: GraphQLSchema, extendedSchema: GraphQLSchema, + semanticNullability: boolean = false, ) { const schemaDefinitions = parse(printSchema(schema)).definitions.map((node) => - print(node), + print(node, { useSemanticNullability: semanticNullability }), ); return expect( parse(printSchema(extendedSchema)) - .definitions.map((node) => print(node)) + .definitions.map((node) => + print(node, { useSemanticNullability: semanticNullability }), + ) .filter((def) => !schemaDefinitions.includes(def)) .join('\n\n'), ); @@ -88,6 +91,34 @@ describe('extendSchema', () => { }); }); + it('extends objects by adding new fields in semantic nullability mode', () => { + const schema = buildSchema(` + @SemanticNullability + type Query { + someObject: String + } + `); + const extensionSDL = dedent` + @SemanticNullability + extend type Query { + newSemanticNonNullField: String + newSemanticNullableField: String? + newNonNullField: String! + } + `; + const extendedSchema = extendSchema(schema, parse(extensionSDL)); + + expect(validateSchema(extendedSchema)).to.deep.equal([]); + expectSchemaChanges(schema, extendedSchema, true).to.equal(dedent` + type Query { + someObject: String + newSemanticNonNullField: String + newSemanticNullableField: String? + newNonNullField: String! + } + `); + }); + it('extends objects by adding new fields', () => { const schema = buildSchema(` type Query { @@ -99,6 +130,7 @@ describe('extendSchema', () => { tree: [SomeObject]! """Old field description.""" oldField: String + } interface SomeInterface { diff --git a/src/utilities/__tests__/findBreakingChanges-test.ts b/src/utilities/__tests__/findBreakingChanges-test.ts index ba526deb48..f54b8c08ed 100644 --- a/src/utilities/__tests__/findBreakingChanges-test.ts +++ b/src/utilities/__tests__/findBreakingChanges-test.ts @@ -577,6 +577,106 @@ describe('findBreakingChanges', () => { expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([]); }); + it('should consider semantic non-null output types that change type as breaking', () => { + const oldSchema = buildSchema(` + @SemanticNullability + type Type1 { + field1: String + } + `); + + const newSchema = buildSchema(` + @SemanticNullability + type Type1 { + field1: Int + } + `); + + expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ + { + description: 'Type1.field1 changed type from String to Int.', + type: BreakingChangeType.FIELD_CHANGED_KIND, + }, + ]); + }); + + it('should consider output types that move away from SemanticNonNull to non-null as non-breaking', () => { + const oldSchema = buildSchema(` + @SemanticNullability + type Type1 { + field1: String + } + `); + + const newSchema = buildSchema(` + @SemanticNullability + type Type1 { + field1: String! + } + `); + + expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([]); + }); + + it('should consider output types that move away from nullable to semantic non-null as non-breaking', () => { + const oldSchema = buildSchema(` + @SemanticNullability + type Type1 { + field1: String? + } + `); + + const newSchema = buildSchema(` + @SemanticNullability + type Type1 { + field1: String + } + `); + + expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([]); + }); + + it('should consider list output types that move away from nullable to semantic non-null as non-breaking', () => { + const oldSchema = buildSchema(` + @SemanticNullability + type Type1 { + field1: [String?]? + } + `); + + const newSchema = buildSchema(` + @SemanticNullability + type Type1 { + field1: [String] + } + `); + + expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([]); + }); + + it('should consider output types that move away from SemanticNonNull to null as breaking', () => { + const oldSchema = buildSchema(` + @SemanticNullability + type Type1 { + field1: String + } + `); + + const newSchema = buildSchema(` + @SemanticNullability + type Type1 { + field1: String? + } + `); + + expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ + { + description: 'Type1.field1 changed type from String to String.', + type: BreakingChangeType.FIELD_CHANGED_KIND, + }, + ]); + }); + it('should detect interfaces removed from types', () => { const oldSchema = buildSchema(` interface Interface1 diff --git a/src/utilities/__tests__/getIntrospectionQuery-test.ts b/src/utilities/__tests__/getIntrospectionQuery-test.ts index 86d1c549db..6aa31ae971 100644 --- a/src/utilities/__tests__/getIntrospectionQuery-test.ts +++ b/src/utilities/__tests__/getIntrospectionQuery-test.ts @@ -125,6 +125,15 @@ describe('getIntrospectionQuery', () => { expectIntrospectionQuery({ oneOf: false }).toNotMatch('isOneOf'); }); + it('include "nullability" argument on object fields', () => { + expect( + getIntrospectionQuery({ nullability: 'TRADITIONAL' }), + ).to.not.contain('type(nullability:'); + expect(getIntrospectionQuery({ nullability: 'FULL' })).to.contain( + 'type(nullability:', + ); + }); + it('include deprecated input field and args', () => { expectIntrospectionQuery().toMatch('includeDeprecated: true', 2); diff --git a/src/utilities/__tests__/lexicographicSortSchema-test.ts b/src/utilities/__tests__/lexicographicSortSchema-test.ts index bce12e3ac5..2187964740 100644 --- a/src/utilities/__tests__/lexicographicSortSchema-test.ts +++ b/src/utilities/__tests__/lexicographicSortSchema-test.ts @@ -63,6 +63,60 @@ describe('lexicographicSortSchema', () => { `); }); + it('sort fields w/ semanticNonNull', () => { + const sorted = sortSDL(` + @SemanticNullability + + input Bar { + barB: String! + barA: String + barC: [String] + } + + interface FooInterface { + fooB: String! + fooA: String + fooC: [String] + } + + type FooType implements FooInterface { + fooC: [String] + fooA: String + fooB: String! + } + + type Query { + dummy(arg: Bar): FooType? + } + `); + + expect(sorted).to.equal(dedent` + @SemanticNullability + + input Bar { + barA: String + barB: String! + barC: [String] + } + + interface FooInterface { + fooA: String + fooB: String! + fooC: [String] + } + + type FooType implements FooInterface { + fooA: String + fooB: String! + fooC: [String] + } + + type Query { + dummy(arg: Bar): FooType? + } + `); + }); + it('sort implemented interfaces', () => { const sorted = sortSDL(` interface FooA { diff --git a/src/utilities/__tests__/printSchema-test.ts b/src/utilities/__tests__/printSchema-test.ts index b651bf16a8..e94bd2fb79 100644 --- a/src/utilities/__tests__/printSchema-test.ts +++ b/src/utilities/__tests__/printSchema-test.ts @@ -782,7 +782,7 @@ describe('Type System Printer', () => { name: String! description: String args(includeDeprecated: Boolean = false): [__InputValue!]! - type(nullability: __TypeNullability! = AUTO): __Type! + type(nullability: __TypeNullability! = TRADITIONAL): __Type! isDeprecated: Boolean! deprecationReason: String } @@ -803,20 +803,14 @@ describe('Type System Printer', () => { deprecationReason: String } - """TODO""" + """ + This represents the type of nullability we want to return as part of the introspection. + """ 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! - """ + """Allow for returning semantic-non-null types.""" FULL } diff --git a/src/utilities/__tests__/typeComparators-test.ts b/src/utilities/__tests__/typeComparators-test.ts index f2709bf740..f7dbe6905f 100644 --- a/src/utilities/__tests__/typeComparators-test.ts +++ b/src/utilities/__tests__/typeComparators-test.ts @@ -7,6 +7,7 @@ import { GraphQLList, GraphQLNonNull, GraphQLObjectType, + GraphQLSemanticNonNull, GraphQLUnionType, } from '../../type/definition'; import { GraphQLFloat, GraphQLInt, GraphQLString } from '../../type/scalars'; @@ -20,6 +21,15 @@ describe('typeComparators', () => { expect(isEqualType(GraphQLString, GraphQLString)).to.equal(true); }); + it('semantic non-null is equal to semantic non-null', () => { + expect( + isEqualType( + new GraphQLSemanticNonNull(GraphQLString), + new GraphQLSemanticNonNull(GraphQLString), + ), + ).to.equal(true); + }); + it('int and float are not equal', () => { expect(isEqualType(GraphQLInt, GraphQLFloat)).to.equal(false); }); @@ -81,6 +91,50 @@ describe('typeComparators', () => { ).to.equal(true); }); + it('semantic non-null is subtype of nullable', () => { + const schema = testSchema({ field: { type: GraphQLString } }); + expect( + isTypeSubTypeOf( + schema, + new GraphQLSemanticNonNull(GraphQLInt), + GraphQLInt, + ), + ).to.equal(true); + }); + + it('semantic non-null is subtype of semantic non-null', () => { + const schema = testSchema({ field: { type: GraphQLString } }); + expect( + isTypeSubTypeOf( + schema, + new GraphQLSemanticNonNull(GraphQLInt), + new GraphQLSemanticNonNull(GraphQLInt), + ), + ).to.equal(true); + }); + + it('semantic non-null is a subtype of non-null', () => { + const schema = testSchema({ field: { type: GraphQLString } }); + expect( + isTypeSubTypeOf( + schema, + new GraphQLSemanticNonNull(GraphQLInt), + new GraphQLNonNull(GraphQLInt), + ), + ).to.equal(true); + }); + + it('non-null is a subtype of semantic non-null', () => { + const schema = testSchema({ field: { type: GraphQLString } }); + expect( + isTypeSubTypeOf( + schema, + new GraphQLNonNull(GraphQLInt), + new GraphQLSemanticNonNull(GraphQLInt), + ), + ).to.equal(true); + }); + it('nullable is not subtype of non-null', () => { const schema = testSchema({ field: { type: GraphQLString } }); expect( diff --git a/src/utilities/buildClientSchema.ts b/src/utilities/buildClientSchema.ts index 9b0809adf5..739e758bf4 100644 --- a/src/utilities/buildClientSchema.ts +++ b/src/utilities/buildClientSchema.ts @@ -138,6 +138,7 @@ 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) { diff --git a/src/utilities/getIntrospectionQuery.ts b/src/utilities/getIntrospectionQuery.ts index dda0e7f19a..cf5dc40797 100644 --- a/src/utilities/getIntrospectionQuery.ts +++ b/src/utilities/getIntrospectionQuery.ts @@ -42,13 +42,11 @@ export interface IntrospectionOptions { /** * 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'; + nullability?: 'TRADITIONAL' | 'FULL'; } /** @@ -63,7 +61,7 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { schemaDescription: false, inputValueDeprecation: false, oneOf: false, - nullability: null, + nullability: 'TRADITIONAL', ...options, }; @@ -118,7 +116,7 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { args${inputDeprecation('(includeDeprecated: true)')} { ...InputValue } - type${nullability ? `(nullability: ${nullability})` : ''} { + type${nullability === 'FULL' ? `(nullability: ${nullability})` : ''} { ...TypeRef } isDeprecated diff --git a/src/utilities/printSchema.ts b/src/utilities/printSchema.ts index edac6262c5..e44c280e20 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -18,9 +18,11 @@ import type { GraphQLUnionType, } from '../type/definition'; import { + GraphQLSemanticNonNull, isEnumType, isInputObjectType, isInterfaceType, + isNullableType, isObjectType, isScalarType, isUnionType, @@ -59,11 +61,19 @@ function printFilteredSchema( ): string { const directives = schema.getDirectives().filter(directiveFilter); const types = Object.values(schema.getTypeMap()).filter(typeFilter); + const hasSemanticNonNull = types.some( + (type) => + (isObjectType(type) || isInterfaceType(type)) && + Object.values(type.getFields()).some( + (field) => field.type instanceof GraphQLSemanticNonNull, + ), + ); return [ + hasSemanticNonNull ? '@SemanticNullability' : '', printSchemaDefinition(schema), ...directives.map((directive) => printDirective(directive)), - ...types.map((type) => printType(type)), + ...types.map((type) => printType(type, hasSemanticNonNull)), ] .filter(Boolean) .join('\n\n'); @@ -128,15 +138,18 @@ function isSchemaOfCommonNames(schema: GraphQLSchema): boolean { return true; } -export function printType(type: GraphQLNamedType): string { +export function printType( + type: GraphQLNamedType, + hasSemanticNonNull: boolean = false, +): string { if (isScalarType(type)) { return printScalar(type); } if (isObjectType(type)) { - return printObject(type); + return printObject(type, hasSemanticNonNull); } if (isInterfaceType(type)) { - return printInterface(type); + return printInterface(type, hasSemanticNonNull); } if (isUnionType(type)) { return printUnion(type); @@ -167,21 +180,27 @@ function printImplementedInterfaces( : ''; } -function printObject(type: GraphQLObjectType): string { +function printObject( + type: GraphQLObjectType, + hasSemanticNonNull: boolean, +): string { return ( printDescription(type) + `type ${type.name}` + printImplementedInterfaces(type) + - printFields(type) + printFields(type, hasSemanticNonNull) ); } -function printInterface(type: GraphQLInterfaceType): string { +function printInterface( + type: GraphQLInterfaceType, + hasSemanticNonNull: boolean, +): string { return ( printDescription(type) + `interface ${type.name}` + printImplementedInterfaces(type) + - printFields(type) + printFields(type, hasSemanticNonNull) ); } @@ -217,7 +236,10 @@ function printInputObject(type: GraphQLInputObjectType): string { ); } -function printFields(type: GraphQLObjectType | GraphQLInterfaceType): string { +function printFields( + type: GraphQLObjectType | GraphQLInterfaceType, + hasSemanticNonNull: boolean, +): string { const fields = Object.values(type.getFields()).map( (f, i) => printDescription(f, ' ', !i) + @@ -225,7 +247,9 @@ function printFields(type: GraphQLObjectType | GraphQLInterfaceType): string { f.name + printArgs(f.args, ' ') + ': ' + - String(f.type) + + (hasSemanticNonNull && isNullableType(f.type) + ? `${f.type}?` + : String(f.type)) + printDeprecated(f.deprecationReason), ); return printBlock(fields); diff --git a/src/utilities/typeComparators.ts b/src/utilities/typeComparators.ts index 13311780ff..5b7c498c65 100644 --- a/src/utilities/typeComparators.ts +++ b/src/utilities/typeComparators.ts @@ -53,7 +53,7 @@ export function isTypeSubTypeOf( // If superType is non-null, maybeSubType must also be non-null. if (isNonNullType(superType)) { - if (isNonNullType(maybeSubType)) { + if (isNonNullType(maybeSubType) || isSemanticNonNullType(maybeSubType)) { return isTypeSubTypeOf(schema, maybeSubType.ofType, superType.ofType); } return false; diff --git a/src/utilities/typeFromAST.ts b/src/utilities/typeFromAST.ts index c5d5f537a2..c89b66ea96 100644 --- a/src/utilities/typeFromAST.ts +++ b/src/utilities/typeFromAST.ts @@ -50,10 +50,12 @@ 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); - } + // We only use typeFromAST for fragment/variable type inference + // which should not be affected by semantic non-null types + // 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/__tests__/OverlappingFieldsCanBeMergedRule-test.ts b/src/validation/__tests__/OverlappingFieldsCanBeMergedRule-test.ts index 7418c3e4e8..a9d7ef2d14 100644 --- a/src/validation/__tests__/OverlappingFieldsCanBeMergedRule-test.ts +++ b/src/validation/__tests__/OverlappingFieldsCanBeMergedRule-test.ts @@ -1192,4 +1192,149 @@ describe('Validate: Overlapping fields can be merged', () => { } `); }); + + describe('semantic non-null', () => { + const schema = buildSchema(` + @SemanticNullability + type Query { + box: Box + } + + interface Box { + id: String + } + + type IntBox implements Box { + id: String + field: Int + field2: Int? + field3: Int + } + + type StringBox implements Box { + id: String + field: String + field2: Int + field3: Int + } + `); + + it('does not error when non-null and semantic non-null overlap with same type', () => { + expectErrorsWithSchema( + schema, + ` + { + box { + ... on IntBox { + id + } + ... on StringBox { + id + } + } + } + `, + ).toDeepEqual([]); + }); + + it('does not error when two semantic non-null fields overlap with same type', () => { + expectErrorsWithSchema( + schema, + ` + { + box { + ... on IntBox { + field3 + } + ... on StringBox { + field3 + } + } + } + `, + ).toDeepEqual([]); + }); + + it('errors when 2 semantic non-null fields overlap with different types', () => { + expectErrorsWithSchema( + schema, + ` + { + box { + ... on IntBox { + field + } + ... on StringBox { + field + } + } + } + `, + ).toDeepEqual([ + { + message: + 'Fields "field" conflict because they return conflicting types "Int" and "String". Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 5, column: 17 }, + { line: 8, column: 17 }, + ], + }, + ]); + }); + + it('errors when semantic non-null and nullable fields overlap with different types', () => { + expectErrorsWithSchema( + schema, + ` + { + box { + ... on StringBox { + field2 + } + ... on IntBox { + field2 + } + } + } + `, + ).toDeepEqual([ + { + message: + 'Fields "field2" conflict because they return conflicting types "Int" and "Int". Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 5, column: 17 }, + { line: 8, column: 17 }, + ], + }, + ]); + }); + + it('errors when non-null and semantic non-null overlap with different types', () => { + expectErrorsWithSchema( + schema, + ` + { + box { + ... on IntBox { + field2 + } + ... on StringBox { + field2 + } + } + } + `, + ).toDeepEqual([ + { + // TODO: inspect currently returns "Int" for both types + message: + 'Fields "field2" conflict because they return conflicting types "Int" and "Int". Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 5, column: 17 }, + { line: 8, column: 17 }, + ], + }, + ]); + }); + }); }); From 4c8a02bde8556b3f06a7b63fb20059833d7f3036 Mon Sep 17 00:00:00 2001 From: Jovi De Croock <decroockjovi@gmail.com> Date: Wed, 12 Feb 2025 05:30:14 +0100 Subject: [PATCH 07/10] Remove SemanticNonNull from TypeNode This type is reused for variables, inputs and list-types which can't be smantically non-null. list-types can be but only if they are used in an SDL context. This is kind of a short-coming of our types, we conflate SDL and execution language. --- .../__tests__/semantic-nullability-test.ts | 31 +++++-------------- src/language/ast.ts | 10 ++---- src/language/parser.ts | 11 ++++--- src/utilities/extendSchema.ts | 5 ++- src/utilities/typeFromAST.ts | 14 ++------- 5 files changed, 22 insertions(+), 49 deletions(-) diff --git a/src/execution/__tests__/semantic-nullability-test.ts b/src/execution/__tests__/semantic-nullability-test.ts index 613ab91d1c..38c60825ec 100644 --- a/src/execution/__tests__/semantic-nullability-test.ts +++ b/src/execution/__tests__/semantic-nullability-test.ts @@ -36,9 +36,7 @@ describe('Execute: Handles Semantic Nullability', () => { it('SemanticNonNull throws error on null without error', async () => { const data = { - a: () => 'Apple', b: () => null, - c: () => 'Cookie', }; const document = parse(` @@ -53,11 +51,8 @@ describe('Execute: Handles Semantic Nullability', () => { rootValue: data, }); - const executable = document.definitions?.values().next() - .value as ExecutableDefinitionNode; - const selectionSet = executable.selectionSet.selections - .values() - .next().value; + const executable = document.definitions[0] as ExecutableDefinitionNode; + const selectionSet = executable.selectionSet.selections[0]; expect(result).to.deep.equal({ data: { @@ -77,11 +72,9 @@ describe('Execute: Handles Semantic Nullability', () => { it('SemanticNonNull succeeds on null with error', async () => { const data = { - a: () => 'Apple', b: () => { throw new Error('Something went wrong'); }, - c: () => 'Cookie', }; const document = parse(` @@ -90,11 +83,8 @@ describe('Execute: Handles Semantic Nullability', () => { } `); - const executable = document.definitions?.values().next() - .value as ExecutableDefinitionNode; - const selectionSet = executable.selectionSet.selections - .values() - .next().value; + const executable = document.definitions[0] as ExecutableDefinitionNode; + const selectionSet = executable.selectionSet.selections[0]; const result = await execute({ schema: new GraphQLSchema({ query: DataType }), @@ -121,9 +111,6 @@ describe('Execute: Handles Semantic Nullability', () => { }; const data = { - a: () => 'Apple', - b: () => null, - c: () => 'Cookie', d: () => deepData, }; @@ -141,13 +128,9 @@ describe('Execute: Handles Semantic Nullability', () => { 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; + const executable = document.definitions[0] as ExecutableDefinitionNode; + const dSelectionSet = executable.selectionSet.selections[0] as FieldNode; + const fSelectionSet = dSelectionSet.selectionSet?.selections[0]; expect(result).to.deep.equal({ data: { diff --git a/src/language/ast.ts b/src/language/ast.ts index 21c4160464..2149034e07 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -529,11 +529,7 @@ export interface SemanticNonNullTypeNode { /** Type Reference */ -export type TypeNode = - | NamedTypeNode - | ListTypeNode - | NonNullTypeNode - | SemanticNonNullTypeNode; +export type TypeNode = NamedTypeNode | ListTypeNode | NonNullTypeNode; export interface NamedTypeNode { readonly kind: Kind.NAMED_TYPE; @@ -544,7 +540,7 @@ export interface NamedTypeNode { export interface ListTypeNode { readonly kind: Kind.LIST_TYPE; readonly loc?: Location; - readonly type: TypeNode; + readonly type: TypeNode | SemanticNonNullTypeNode; } export interface NonNullTypeNode { @@ -609,7 +605,7 @@ export interface FieldDefinitionNode { readonly description?: StringValueNode; readonly name: NameNode; readonly arguments?: ReadonlyArray<InputValueDefinitionNode>; - readonly type: TypeNode; + readonly type: TypeNode | SemanticNonNullTypeNode; readonly directives?: ReadonlyArray<ConstDirectiveNode>; } diff --git a/src/language/parser.ts b/src/language/parser.ts index 790aee3b4b..c96dd25ca6 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -184,7 +184,7 @@ export function parseConstValue( export function parseType( source: string | Source, options?: ParseOptions | undefined, -): TypeNode { +): TypeNode | SemanticNonNullTypeNode { const parser = new Parser(source, options); parser.expectToken(TokenKind.SOF); const type = parser.parseTypeReference(); @@ -403,7 +403,8 @@ export class Parser { return this.node<VariableDefinitionNode>(this._lexer.token, { kind: Kind.VARIABLE_DEFINITION, variable: this.parseVariable(), - type: (this.expectToken(TokenKind.COLON), this.parseTypeReference()), + type: (this.expectToken(TokenKind.COLON), + this.parseTypeReference()) as TypeNode, defaultValue: this.expectOptionalToken(TokenKind.EQUALS) ? this.parseConstValueLiteral() : undefined, @@ -773,7 +774,7 @@ export class Parser { * - ListType * - NonNullType */ - parseTypeReference(): TypeNode { + parseTypeReference(): TypeNode | SemanticNonNullTypeNode { const start = this._lexer.token; let type; if (this.expectOptionalToken(TokenKind.BRACKET_L)) { @@ -781,7 +782,7 @@ export class Parser { this.expectToken(TokenKind.BRACKET_R); type = this.node<ListTypeNode>(start, { kind: Kind.LIST_TYPE, - type: innerType, + type: innerType as TypeNode, }); } else { type = this.parseNamedType(); @@ -992,7 +993,7 @@ export class Parser { kind: Kind.INPUT_VALUE_DEFINITION, description, name, - type, + type: type as TypeNode, defaultValue, directives, }); diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index 876aae277f..440df30c4c 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -24,6 +24,7 @@ import type { ScalarTypeExtensionNode, SchemaDefinitionNode, SchemaExtensionNode, + SemanticNonNullTypeNode, TypeDefinitionNode, TypeNode, UnionTypeDefinitionNode, @@ -431,7 +432,9 @@ export function extendSchemaImpl( return type; } - function getWrappedType(node: TypeNode): GraphQLType { + function getWrappedType( + node: TypeNode | SemanticNonNullTypeNode, + ): GraphQLType { if (node.kind === Kind.LIST_TYPE) { return new GraphQLList(getWrappedType(node.type)); } diff --git a/src/utilities/typeFromAST.ts b/src/utilities/typeFromAST.ts index c89b66ea96..62e66826b1 100644 --- a/src/utilities/typeFromAST.ts +++ b/src/utilities/typeFromAST.ts @@ -7,11 +7,7 @@ import type { import { Kind } from '../language/kinds'; import type { GraphQLNamedType, GraphQLType } from '../type/definition'; -import { - GraphQLList, - GraphQLNonNull, - GraphQLSemanticNonNull, -} from '../type/definition'; +import { GraphQLList, GraphQLNonNull } from '../type/definition'; import type { GraphQLSchema } from '../type/schema'; /** @@ -43,19 +39,13 @@ export function typeFromAST( ): GraphQLType | undefined { switch (typeNode.kind) { case Kind.LIST_TYPE: { - const innerType = typeFromAST(schema, typeNode.type); + const innerType = typeFromAST(schema, typeNode.type as TypeNode); return innerType && new GraphQLList(innerType); } case Kind.NON_NULL_TYPE: { const innerType = typeFromAST(schema, typeNode.type); return innerType && new GraphQLNonNull(innerType); } - // We only use typeFromAST for fragment/variable type inference - // which should not be affected by semantic non-null types - // 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); } From ac213fabdaecb5953eb9680b1b3b6ab46ea75afc Mon Sep 17 00:00:00 2001 From: Jovi De Croock <decroockjovi@gmail.com> Date: Wed, 12 Feb 2025 05:40:32 +0100 Subject: [PATCH 08/10] Remove unused funcs --- src/execution/__tests__/semantic-nullability-test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/execution/__tests__/semantic-nullability-test.ts b/src/execution/__tests__/semantic-nullability-test.ts index 38c60825ec..c35481a509 100644 --- a/src/execution/__tests__/semantic-nullability-test.ts +++ b/src/execution/__tests__/semantic-nullability-test.ts @@ -151,8 +151,6 @@ describe('Execute: Handles Semantic Nullability', () => { it('SemanticNullable allows non-null values', async () => { const data = { a: () => 'Apple', - b: () => null, - c: () => 'Cookie', }; const document = parse(` From 1861f71082c74d774b9b18a2c5791c54191641d1 Mon Sep 17 00:00:00 2001 From: jdecroock <decroockjovi@gmail.com> Date: Wed, 12 Feb 2025 08:47:29 +0100 Subject: [PATCH 09/10] Be stricter about types --- src/language/ast.ts | 15 +++++++++++++-- src/utilities/extendSchema.ts | 7 ++----- src/utilities/typeFromAST.ts | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/language/ast.ts b/src/language/ast.ts index 2149034e07..4469a34424 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -530,6 +530,11 @@ export interface SemanticNonNullTypeNode { /** Type Reference */ export type TypeNode = NamedTypeNode | ListTypeNode | NonNullTypeNode; +export type SchemaOutputTypeNode = + | NamedTypeNode + | ListTypeNode + | NonNullTypeNode + | SemanticNonNullTypeNode; export interface NamedTypeNode { readonly kind: Kind.NAMED_TYPE; @@ -540,7 +545,13 @@ export interface NamedTypeNode { export interface ListTypeNode { readonly kind: Kind.LIST_TYPE; readonly loc?: Location; - readonly type: TypeNode | SemanticNonNullTypeNode; + readonly type: TypeNode; +} + +export interface SchemaListTypeNode { + readonly kind: Kind.LIST_TYPE; + readonly loc?: Location; + readonly type: SchemaOutputTypeNode; } export interface NonNullTypeNode { @@ -605,7 +616,7 @@ export interface FieldDefinitionNode { readonly description?: StringValueNode; readonly name: NameNode; readonly arguments?: ReadonlyArray<InputValueDefinitionNode>; - readonly type: TypeNode | SemanticNonNullTypeNode; + readonly type: SchemaOutputTypeNode; readonly directives?: ReadonlyArray<ConstDirectiveNode>; } diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index 440df30c4c..1e9b69c55b 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -24,9 +24,8 @@ import type { ScalarTypeExtensionNode, SchemaDefinitionNode, SchemaExtensionNode, - SemanticNonNullTypeNode, + SchemaOutputTypeNode, TypeDefinitionNode, - TypeNode, UnionTypeDefinitionNode, UnionTypeExtensionNode, } from '../language/ast'; @@ -432,9 +431,7 @@ export function extendSchemaImpl( return type; } - function getWrappedType( - node: TypeNode | SemanticNonNullTypeNode, - ): GraphQLType { + function getWrappedType(node: SchemaOutputTypeNode): GraphQLType { if (node.kind === Kind.LIST_TYPE) { return new GraphQLList(getWrappedType(node.type)); } diff --git a/src/utilities/typeFromAST.ts b/src/utilities/typeFromAST.ts index 62e66826b1..7510df1046 100644 --- a/src/utilities/typeFromAST.ts +++ b/src/utilities/typeFromAST.ts @@ -39,7 +39,7 @@ export function typeFromAST( ): GraphQLType | undefined { switch (typeNode.kind) { case Kind.LIST_TYPE: { - const innerType = typeFromAST(schema, typeNode.type as TypeNode); + const innerType = typeFromAST(schema, typeNode.type); return innerType && new GraphQLList(innerType); } case Kind.NON_NULL_TYPE: { From 855e4d77d2de4a380e4e5dfa949210b2432deb4c Mon Sep 17 00:00:00 2001 From: Jovi De Croock <decroockjovi@gmail.com> Date: Sat, 15 Feb 2025 08:49:47 +0100 Subject: [PATCH 10/10] Remove errorPropagation option --- src/execution/__tests__/executor-test.ts | 2 -- src/execution/execute.ts | 11 ----------- src/graphql.ts | 8 -------- src/type/definition.ts | 2 -- 4 files changed, 23 deletions(-) diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index a7bc1c8265..c758d3e426 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -263,7 +263,6 @@ describe('Execute: Handles basic execution tasks', () => { 'rootValue', 'operation', 'variableValues', - 'errorPropagation', ); const operation = document.definitions[0]; @@ -276,7 +275,6 @@ describe('Execute: Handles basic execution tasks', () => { schema, rootValue, operation, - errorPropagation: true, }); const field = operation.selectionSet.selections[0]; diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 055b778983..cf5183e126 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -116,7 +116,6 @@ export interface ExecutionContext { typeResolver: GraphQLTypeResolver<any, any>; subscribeFieldResolver: GraphQLFieldResolver<any, any>; errors: Array<GraphQLError>; - errorPropagation: boolean; } /** @@ -154,13 +153,6 @@ export interface ExecutionArgs { fieldResolver?: Maybe<GraphQLFieldResolver<any, any>>; typeResolver?: Maybe<GraphQLTypeResolver<any, any>>; subscribeFieldResolver?: Maybe<GraphQLFieldResolver<any, any>>; - /** - * Set to `false` to disable error propagation. Experimental. - * TODO: describe what this does - * - * @experimental - */ - errorPropagation?: boolean; } /** @@ -295,7 +287,6 @@ export function buildExecutionContext( fieldResolver, typeResolver, subscribeFieldResolver, - errorPropagation, } = args; let operation: OperationDefinitionNode | undefined; @@ -357,7 +348,6 @@ export function buildExecutionContext( typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, errors: [], - errorPropagation: errorPropagation ?? true, }; } @@ -596,7 +586,6 @@ export function buildResolveInfo( rootValue: exeContext.rootValue, operation: exeContext.operation, variableValues: exeContext.variableValues, - errorPropagation: exeContext.errorPropagation, }; } diff --git a/src/graphql.ts b/src/graphql.ts index d3f05f991e..bc6fb9bb72 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -66,12 +66,6 @@ export interface GraphQLArgs { operationName?: Maybe<string>; fieldResolver?: Maybe<GraphQLFieldResolver<any, any>>; typeResolver?: Maybe<GraphQLTypeResolver<any, any>>; - /** - * Set to `false` to disable error propagation. Experimental. - * - * @experimental - */ - errorPropagation?: boolean; } export function graphql(args: GraphQLArgs): Promise<ExecutionResult> { @@ -112,7 +106,6 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue<ExecutionResult> { operationName, fieldResolver, typeResolver, - errorPropagation, } = args; // Validate Schema @@ -145,6 +138,5 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue<ExecutionResult> { operationName, fieldResolver, typeResolver, - errorPropagation, }); } diff --git a/src/type/definition.ts b/src/type/definition.ts index bcd3862c89..b0c7d0c52f 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -1087,8 +1087,6 @@ export interface GraphQLResolveInfo { readonly rootValue: unknown; readonly operation: OperationDefinitionNode; readonly variableValues: { [variable: string]: unknown }; - /** @experimental */ - readonly errorPropagation: boolean; } /**