diff --git a/src/type/definition.js b/src/type/definition.js index 287d3ac5f0..827266cbf0 100644 --- a/src/type/definition.js +++ b/src/type/definition.js @@ -989,6 +989,7 @@ export class GraphQLUnionType { name: string; description: ?string; astNode: ?UnionTypeDefinitionNode; + extensionASTNodes: ?$ReadOnlyArray; resolveType: ?GraphQLTypeResolver<*, *>; _typeConfig: GraphQLUnionTypeConfig<*, *>; @@ -1081,6 +1082,7 @@ export class GraphQLEnumType /* */ { name: string; description: ?string; astNode: ?EnumTypeDefinitionNode; + extensionASTNodes: ?$ReadOnlyArray; _values: Array */>; _valueLookup: Map; @@ -1230,6 +1232,7 @@ export class GraphQLInputObjectType { name: string; description: ?string; astNode: ?InputObjectTypeDefinitionNode; + extensionASTNodes: ?$ReadOnlyArray; _typeConfig: GraphQLInputObjectTypeConfig; _fields: GraphQLInputFieldMap; diff --git a/src/utilities/__tests__/extendSchema-test.js b/src/utilities/__tests__/extendSchema-test.js index 9488a756f4..5a439d4a55 100644 --- a/src/utilities/__tests__/extendSchema-test.js +++ b/src/utilities/__tests__/extendSchema-test.js @@ -21,6 +21,7 @@ import { GraphQLID, GraphQLString, GraphQLEnumType, + GraphQLInputObjectType, GraphQLNonNull, GraphQLList, isScalarType, @@ -77,6 +78,13 @@ const SomeEnumType = new GraphQLEnumType({ }, }); +const SomeInputType = new GraphQLInputObjectType({ + name: 'SomeInput', + fields: () => ({ + fooArg: { type: GraphQLString }, + }), +}); + const testSchema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'Query', @@ -88,6 +96,10 @@ const testSchema = new GraphQLSchema({ args: { id: { type: GraphQLNonNull(GraphQLID) } }, type: SomeInterfaceType, }, + someInput: { + args: { input: { type: SomeInputType } }, + type: GraphQLString, + }, }), }), types: [FooType, BarType], @@ -200,6 +212,52 @@ describe('extendSchema', () => { `); }); + it('extends enums by adding new values', () => { + const extendedSchema = extendTestSchema(` + extend enum SomeEnum { + NEW_ENUM + } + `); + expect(printTestSchemaChanges(extendedSchema)).to.equal(dedent` + enum SomeEnum { + ONE + TWO + NEW_ENUM + } + `); + }); + + it('extends unions by adding new types', () => { + const extendedSchema = extendTestSchema(` + extend union SomeUnion = TestNewType + + type TestNewType { + foo: String + } + `); + expect(printTestSchemaChanges(extendedSchema)).to.equal(dedent` + union SomeUnion = Foo | Biz | TestNewType + + type TestNewType { + foo: String + } + `); + }); + + it('extends inputs by adding new fields', () => { + const extendedSchema = extendTestSchema(` + extend input SomeInput { + newField: String + } + `); + expect(printTestSchemaChanges(extendedSchema)).to.equal(dedent` + input SomeInput { + fooArg: String + newField: String + } + `); + }); + it('correctly assign AST nodes to new and extended types', () => { const extendedSchema = extendTestSchema(` extend type Query { @@ -321,7 +379,21 @@ describe('extendSchema', () => { expect(deprecatedFieldDef.deprecationReason).to.equal('not used anymore'); }); - it('extends objects by adding new unused types', () => { + it('extends enums with deprecated values', () => { + const extendedSchema = extendTestSchema(` + extend enum SomeEnum { + DEPRECATED @deprecated(reason: "do not use") + } + `); + + const deprecatedEnumDef = extendedSchema + .getType('SomeEnum') + .getValue('DEPRECATED'); + expect(deprecatedEnumDef.isDeprecated).to.equal(true); + expect(deprecatedEnumDef.deprecationReason).to.equal('do not use'); + }); + + it('adds new unused object type', () => { const extendedSchema = extendTestSchema(` type Unused { someField: String @@ -335,6 +407,52 @@ describe('extendSchema', () => { `); }); + it('adds new unused enum type', () => { + const extendedSchema = extendTestSchema(` + enum UnusedEnum { + SOME + } + `); + expect(extendedSchema).to.not.equal(testSchema); + expect(printTestSchemaChanges(extendedSchema)).to.equal(dedent` + enum UnusedEnum { + SOME + } + `); + }); + + it('adds new unused input object type', () => { + const extendedSchema = extendTestSchema(` + input UnusedInput { + someInput: String + } + `); + expect(extendedSchema).to.not.equal(testSchema); + expect(printTestSchemaChanges(extendedSchema)).to.equal(dedent` + input UnusedInput { + someInput: String + } + `); + }); + + it('adds new union using new object type', () => { + const extendedSchema = extendTestSchema(` + type DummyUnionMember { + someField: String + } + + union UnusedUnion = DummyUnionMember + `); + expect(extendedSchema).to.not.equal(testSchema); + expect(printTestSchemaChanges(extendedSchema)).to.equal(dedent` + type DummyUnionMember { + someField: String + } + + union UnusedUnion = DummyUnionMember + `); + }); + it('extends objects by adding new fields with arguments', () => { const extendedSchema = extendTestSchema(` extend type Foo { @@ -487,7 +605,7 @@ describe('extendSchema', () => { `); }); - it('extends objects multiple times', () => { + it('extends different types multiple times', () => { const extendedSchema = extendTestSchema(` extend type Biz implements NewInterface { buzz: String @@ -507,6 +625,34 @@ describe('extendSchema', () => { interface NewInterface { buzz: String } + + extend enum SomeEnum { + THREE + } + + extend enum SomeEnum { + FOUR + } + + extend union SomeUnion = Boo + + extend union SomeUnion = Joo + + type Boo { + fieldA: String + } + + type Joo { + fieldB: String + } + + extend input SomeInput { + fieldA: String + } + + extend input SomeInput { + fieldB: String + } `); expect(printTestSchemaChanges(extendedSchema)).to.equal(dedent` type Biz implements NewInterface & SomeInterface { @@ -518,9 +664,32 @@ describe('extendSchema', () => { newFieldB: Float } + type Boo { + fieldA: String + } + + type Joo { + fieldB: String + } + interface NewInterface { buzz: String } + + enum SomeEnum { + ONE + TWO + THREE + FOUR + } + + input SomeInput { + fooArg: String + fieldA: String + fieldB: String + } + + union SomeUnion = Foo | Biz | Boo | Joo `); }); @@ -740,36 +909,102 @@ describe('extendSchema', () => { }); it('does not allow replacing an existing type', () => { - const ast = parse(` + const typeAst = parse(` type Bar { baz: String } `); - expect(() => extendSchema(testSchema, ast)).to.throw( + expect(() => extendSchema(testSchema, typeAst)).to.throw( 'Type "Bar" already exists in the schema. It cannot also be defined ' + 'in this type definition.', ); + + const enumAst = parse(` + enum SomeEnum { + FOO + } + `); + expect(() => extendSchema(testSchema, enumAst)).to.throw( + 'Type "SomeEnum" already exists in the schema. It cannot also be defined ' + + 'in this type definition.', + ); + + const unionAst = parse(` + union SomeUnion = Foo + `); + expect(() => extendSchema(testSchema, unionAst)).to.throw( + 'Type "SomeUnion" already exists in the schema. It cannot also be defined ' + + 'in this type definition.', + ); + + const inputAst = parse(` + input SomeInput { + some: String + } + `); + expect(() => extendSchema(testSchema, inputAst)).to.throw( + 'Type "SomeInput" already exists in the schema. It cannot also be defined ' + + 'in this type definition.', + ); }); it('does not allow replacing an existing field', () => { - const ast = parse(` + const typeAst = parse(` extend type Bar { foo: Foo } `); - expect(() => extendSchema(testSchema, ast)).to.throw( + expect(() => extendSchema(testSchema, typeAst)).to.throw( 'Field "Bar.foo" already exists in the schema. It cannot also be ' + 'defined in this type extension.', ); + + const enumAst = parse(` + extend enum SomeEnum { + ONE + } + `); + expect(() => extendSchema(testSchema, enumAst)).to.throw( + 'Enum value "SomeEnum.ONE" already exists in the schema. It cannot ' + + 'also be defined in this type extension.', + ); + + const inputAst = parse(` + extend input SomeInput { + fooArg: String + } + `); + expect(() => extendSchema(testSchema, inputAst)).to.throw( + 'Field "SomeInput.fooArg" already exists in the schema. It cannot also be ' + + 'defined in this type extension.', + ); }); it('does not allow referencing an unknown type', () => { - const ast = parse(` + const typeAst = parse(` extend type Bar { quix: Quix } `); - expect(() => extendSchema(testSchema, ast)).to.throw( + expect(() => extendSchema(testSchema, typeAst)).to.throw( + 'Unknown type: "Quix". Ensure that this type exists either in the ' + + 'original schema, or is added in a type definition.', + ); + + const unionAst = parse(` + extend union SomeUnion = Quix + `); + expect(() => extendSchema(testSchema, unionAst)).to.throw( + 'Unknown type: "Quix". Ensure that this type exists either in the ' + + 'original schema, or is added in a type definition.', + ); + + const inputAst = parse(` + extend input SomeInput { + quix: Quix + } + `); + expect(() => extendSchema(testSchema, inputAst)).to.throw( 'Unknown type: "Quix". Ensure that this type exists either in the ' + 'original schema, or is added in a type definition.', ); @@ -799,6 +1034,44 @@ describe('extendSchema', () => { ); }); + it('does not allow extending an unknown enum type', () => { + const ast = parse(` + extend enum UnknownEnumType { + BAZ + } + `); + expect(() => extendSchema(testSchema, ast)).to.throw( + 'Cannot extend type "UnknownEnumType" because it does not ' + + 'exist in the existing schema.', + ); + }); + + it('does not allow extending an unknown union type', () => { + const ast = parse(` + extend union UnknownUnionType = Baz + + type Baz { + foo: String + } + `); + expect(() => extendSchema(testSchema, ast)).to.throw( + 'Cannot extend type "UnknownUnionType" because it does not ' + + 'exist in the existing schema.', + ); + }); + + it('does not allow extending an unknown input object type', () => { + const ast = parse(` + extend input UnknownInputObjectType { + foo: String + } + `); + expect(() => extendSchema(testSchema, ast)).to.throw( + 'Cannot extend type "UnknownInputObjectType" because it does not ' + + 'exist in the existing schema.', + ); + }); + it('maintains configuration of the original schema object', () => { const testSchemaWithLegacyNames = new GraphQLSchema({ query: new GraphQLObjectType({ @@ -875,6 +1148,41 @@ describe('extendSchema', () => { 'Cannot extend non-object type "String".', ); }); + + it('not an enum', () => { + const ast = parse(` + extend enum Foo { + BAZ + } + `); + expect(() => extendSchema(testSchema, ast)).to.throw( + 'Cannot extend non-enum type "Foo".', + ); + }); + + it('not an union', () => { + const ast = parse(` + extend union Foo = Baz + + type Baz { + foo: String + } + `); + expect(() => extendSchema(testSchema, ast)).to.throw( + 'Cannot extend non-union type "Foo".', + ); + }); + + it('not an input object', () => { + const ast = parse(` + extend input Foo { + Baz: String + } + `); + expect(() => extendSchema(testSchema, ast)).to.throw( + 'Cannot extend non-input object type "Foo".', + ); + }); }); describe('can add additional root operation types', () => { diff --git a/src/utilities/buildASTSchema.js b/src/utilities/buildASTSchema.js index 2ff2f7dd1d..85f85ac5cf 100644 --- a/src/utilities/buildASTSchema.js +++ b/src/utilities/buildASTSchema.js @@ -41,6 +41,11 @@ import type { import type { DirectiveLocationEnum } from '../language/directiveLocation'; +import type { + GraphQLEnumValueConfig, + GraphQLInputField, +} from '../type/definition'; + import { assertNullableType, GraphQLScalarType, @@ -318,6 +323,28 @@ export class ASTDefinitionBuilder { }; } + buildInputField(value: InputValueDefinitionNode): GraphQLInputField { + // Note: While this could make assertions to get the correctly typed + // value, that would throw immediately while type system validation + // with validateSchema() will produce more actionable results. + const type: any = this._buildWrappedType(value.type); + return { + name: value.name.value, + type, + description: getDescription(value, this._options), + defaultValue: valueFromAST(value.defaultValue, type), + astNode: value, + }; + } + + buildEnumValue(value: EnumValueDefinitionNode): GraphQLEnumValueConfig { + return { + description: getDescription(value, this._options), + deprecationReason: getDeprecationReason(value), + astNode: value, + }; + } + _makeSchemaDef(def: TypeDefinitionNode): GraphQLNamedType { switch (def.kind) { case Kind.OBJECT_TYPE_DEFINITION: @@ -368,18 +395,7 @@ export class ASTDefinitionBuilder { return keyValMap( values, value => value.name.value, - value => { - // Note: While this could make assertions to get the correctly typed - // value, that would throw immediately while type system validation - // with validateSchema() will produce more actionable results. - const type: any = this._buildWrappedType(value.type); - return { - type, - description: getDescription(value, this._options), - defaultValue: valueFromAST(value.defaultValue, type), - astNode: value, - }; - }, + value => this.buildInputField(value), ); } @@ -396,21 +412,21 @@ export class ASTDefinitionBuilder { return new GraphQLEnumType({ name: def.name.value, description: getDescription(def, this._options), - values: def.values - ? keyValMap( - def.values, - enumValue => enumValue.name.value, - enumValue => ({ - description: getDescription(enumValue, this._options), - deprecationReason: getDeprecationReason(enumValue), - astNode: enumValue, - }), - ) - : {}, + values: this._makeValueDefMap(def), astNode: def, }); } + _makeValueDefMap(def: EnumTypeDefinitionNode) { + return def.values + ? keyValMap( + def.values, + enumValue => enumValue.name.value, + enumValue => this.buildEnumValue(enumValue), + ) + : {}; + } + _makeUnionDef(def: UnionTypeDefinitionNode) { return new GraphQLUnionType({ name: def.name.value, diff --git a/src/utilities/extendSchema.js b/src/utilities/extendSchema.js index e72b6c0fd0..ae4d439722 100644 --- a/src/utilities/extendSchema.js +++ b/src/utilities/extendSchema.js @@ -9,6 +9,7 @@ import invariant from '../jsutils/invariant'; import keyMap from '../jsutils/keyMap'; +import keyValMap from '../jsutils/keyValMap'; import objectValues from '../jsutils/objectValues'; import { ASTDefinitionBuilder } from './buildASTSchema'; import { GraphQLError } from '../error/GraphQLError'; @@ -17,17 +18,26 @@ import { isIntrospectionType } from '../type/introspection'; import type { GraphQLSchemaValidationOptions } from '../type/schema'; +import type { + GraphQLArgument, + GraphQLFieldConfigArgumentMap, +} from '../type/definition'; + import { isObjectType, isInterfaceType, isUnionType, isListType, isNonNullType, + isEnumType, + isInputObjectType, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLInterfaceType, GraphQLUnionType, + GraphQLEnumType, + GraphQLInputObjectType, } from '../type/definition'; import { GraphQLDirective } from '../type/directives'; @@ -127,6 +137,9 @@ export function extendSchema( break; case Kind.OBJECT_TYPE_EXTENSION: case Kind.INTERFACE_TYPE_EXTENSION: + case Kind.ENUM_TYPE_EXTENSION: + case Kind.INPUT_OBJECT_TYPE_EXTENSION: + case Kind.UNION_TYPE_EXTENSION: // Sanity check that this type extension exists within the // schema's existing types. const extendedTypeName = def.name.value; @@ -158,9 +171,6 @@ export function extendSchema( directiveDefinitions.push(def); break; case Kind.SCALAR_TYPE_EXTENSION: - case Kind.UNION_TYPE_EXTENSION: - case Kind.ENUM_TYPE_EXTENSION: - case Kind.INPUT_OBJECT_TYPE_EXTENSION: throw new Error( `The ${def.kind} kind is not yet supported by extendSchema().`, ); @@ -257,7 +267,7 @@ export function extendSchema( // this scope and have access to the schema, cache, and newly defined types. function getMergedDirectives(): Array { - const existingDirectives = schema.getDirectives(); + const existingDirectives = schema.getDirectives().map(extendDirective); invariant(existingDirectives, 'schema must have default directives'); return existingDirectives.concat( @@ -283,6 +293,10 @@ export function extendSchema( extendTypeCache[name] = extendInterfaceType(type); } else if (isUnionType(type)) { extendTypeCache[name] = extendUnionType(type); + } else if (isEnumType(type)) { + extendTypeCache[name] = extendEnumType(type); + } else if (isInputObjectType(type)) { + extendTypeCache[name] = extendInputObjectType(type); } else { // This type is not yet extendable. extendTypeCache[name] = type; @@ -291,6 +305,126 @@ export function extendSchema( return (extendTypeCache[name]: any); } + function extendDirective(directive: GraphQLDirective): GraphQLDirective { + return new GraphQLDirective({ + name: directive.name, + description: directive.description, + locations: directive.locations, + args: extendArgs(directive.args), + astNode: directive.astNode, + }); + } + + function getExtendedType(type: T): T { + if (!extendTypeCache[type.name]) { + extendTypeCache[type.name] = extendType(type); + } + return (extendTypeCache[type.name]: any); + } + + function extendInputObjectType( + type: GraphQLInputObjectType, + ): GraphQLInputObjectType { + const name = type.name; + const extensionASTNodes = typeExtensionsMap[name] + ? type.extensionASTNodes + ? type.extensionASTNodes.concat(typeExtensionsMap[name]) + : typeExtensionsMap[name] + : type.extensionASTNodes; + return new GraphQLInputObjectType({ + name, + description: type.description, + fields: () => extendInputFieldMap(type), + astNode: type.astNode, + extensionASTNodes, + }); + } + + function extendInputFieldMap(type: GraphQLInputObjectType) { + const newFieldMap = Object.create(null); + const oldFieldMap = type.getFields(); + Object.keys(oldFieldMap).forEach(fieldName => { + const field = oldFieldMap[fieldName]; + newFieldMap[fieldName] = { + description: field.description, + type: extendType(field.type), + defaultValue: field.defaultValue, + astNode: field.astNode, + }; + }); + + // If there are any extensions to the fields, apply those here. + const extensions = typeExtensionsMap[type.name]; + if (extensions) { + extensions.forEach(extension => { + extension.fields.forEach(field => { + const fieldName = field.name.value; + if (oldFieldMap[fieldName]) { + throw new GraphQLError( + `Field "${type.name}.${fieldName}" already exists in the ` + + 'schema. It cannot also be defined in this type extension.', + [field], + ); + } + newFieldMap[fieldName] = astBuilder.buildInputField(field); + }); + }); + } + + return newFieldMap; + } + + function extendEnumType(type: GraphQLEnumType): GraphQLEnumType { + const name = type.name; + const extensionASTNodes = typeExtensionsMap[name] + ? type.extensionASTNodes + ? type.extensionASTNodes.concat(typeExtensionsMap[name]) + : typeExtensionsMap[name] + : type.extensionASTNodes; + return new GraphQLEnumType({ + name, + description: type.description, + values: extendValueMap(type), + astNode: type.astNode, + extensionASTNodes, + }); + } + + function extendValueMap(type: GraphQLEnumType) { + const newValueMap = Object.create(null); + const oldValueMap = keyMap(type.getValues(), value => value.name); + Object.keys(oldValueMap).forEach(valueName => { + const value = oldValueMap[valueName]; + newValueMap[valueName] = { + name: value.name, + description: value.description, + value: value.value, + deprecationReason: value.deprecationReason, + astNode: value.astNode, + }; + }); + + // If there are any extensions to the values, apply those here. + const extensions = typeExtensionsMap[type.name]; + if (extensions) { + extensions.forEach(extension => { + extension.values.forEach(value => { + const valueName = value.name.value; + if (oldValueMap[valueName]) { + throw new GraphQLError( + `Enum value "${type.name}.${valueName}" already exists in the ` + + 'schema. It cannot also be defined in this type extension.', + [value], + ); + } + newValueMap[valueName] = astBuilder.buildEnumValue(value); + }); + }); + } + + return newValueMap; + } + function extendObjectType(type: GraphQLObjectType): GraphQLObjectType { const name = type.name; const extensionASTNodes = typeExtensionsMap[name] @@ -309,6 +443,21 @@ export function extendSchema( }); } + function extendArgs( + args: Array, + ): GraphQLFieldConfigArgumentMap { + return keyValMap( + args, + arg => arg.name, + arg => ({ + type: extendType(arg.type), + defaultValue: arg.defaultValue, + description: arg.description, + astNode: arg.astNode, + }), + ); + } + function extendInterfaceType( type: GraphQLInterfaceType, ): GraphQLInterfaceType { @@ -329,12 +478,34 @@ export function extendSchema( } function extendUnionType(type: GraphQLUnionType): GraphQLUnionType { + const name = type.name; + const extensionASTNodes = typeExtensionsMap[name] + ? type.extensionASTNodes + ? type.extensionASTNodes.concat(typeExtensionsMap[name]) + : typeExtensionsMap[name] + : type.extensionASTNodes; + const unionTypes = type.getTypes().map(getExtendedType); + + // If there are any extensions to the union, apply those here. + const extensions = typeExtensionsMap[type.name]; + if (extensions) { + extensions.forEach(extension => { + extension.types.forEach(namedType => { + // Note: While this could make early assertions to get the correctly + // typed values, that would throw immediately while type system + // validation with validateSchema() will produce more actionable results. + unionTypes.push((astBuilder.buildType(namedType): any)); + }); + }); + } + return new GraphQLUnionType({ - name: type.name, + name, description: type.description, - types: type.getTypes().map(extendNamedType), + types: unionTypes, astNode: type.astNode, resolveType: type.resolveType, + extensionASTNodes, }); } @@ -368,7 +539,7 @@ export function extendSchema( description: field.description, deprecationReason: field.deprecationReason, type: extendType(field.type), - args: keyMap(field.args, arg => arg.name), + args: extendArgs(field.args), astNode: field.astNode, resolve: field.resolve, }; @@ -424,5 +595,27 @@ function checkExtensionNode(type, node) { ); } break; + case Kind.ENUM_TYPE_EXTENSION: + if (!isEnumType(type)) { + throw new GraphQLError(`Cannot extend non-enum type "${type.name}".`, [ + node, + ]); + } + break; + case Kind.UNION_TYPE_EXTENSION: + if (!isUnionType(type)) { + throw new GraphQLError(`Cannot extend non-union type "${type.name}".`, [ + node, + ]); + } + break; + case Kind.INPUT_OBJECT_TYPE_EXTENSION: + if (!isInputObjectType(type)) { + throw new GraphQLError( + `Cannot extend non-input object type "${type.name}".`, + [node], + ); + } + break; } }