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, 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 = []; + 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 }>) { 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..665e91a1c1 100644 --- a/src/utilities/typeFromAST.ts +++ b/src/utilities/typeFromAST.ts @@ -50,9 +50,13 @@ export function typeFromAST( 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: { + /** c8 ignore start */ const innerType = typeFromAST(schema, typeNode.type); return innerType && new GraphQLSemanticNonNull(innerType); + /** c8 ignore stop */ } 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 }, + ], + }, + ]); + }); + }); });