diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml index 1dee9567a40..7afb75292d4 100644 --- a/.github/workflows/automerge.yml +++ b/.github/workflows/automerge.yml @@ -32,4 +32,4 @@ jobs: env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' MERGE_METHOD: 'squash' - LABELS: automerge + MERGE_LABELS: automerge diff --git a/packages/merge/src/typedefs-mergers/fields.ts b/packages/merge/src/typedefs-mergers/fields.ts index 6591156edb8..4696ab48a1d 100644 --- a/packages/merge/src/typedefs-mergers/fields.ts +++ b/packages/merge/src/typedefs-mergers/fields.ts @@ -5,25 +5,23 @@ import { mergeDirectives } from './directives'; import { isNotEqual, compareNodes } from '@graphql-tools/utils'; import { mergeArguments } from './arguments'; -function fieldAlreadyExists(fieldsArr: ReadonlyArray, otherField: any): boolean { - const result: FieldDefinitionNode | null = fieldsArr.find(field => field.name.value === otherField.name.value); - - if (result) { - const t1 = extractType(result.type); - const t2 = extractType(otherField.type); +type FieldDefNode = FieldDefinitionNode | InputValueDefinitionNode; +type NamedDefNode = { name: NameNode }; +export type OnFieldTypeConflict = ( + existingField: FieldDefNode, + otherField: FieldDefNode, + type: NamedDefNode, + config: Config +) => FieldDefNode; - if (t1.name.value !== t2.name.value) { - throw new Error( - `Field "${otherField.name.value}" already defined with a different type. Declared as "${t1.name.value}", but you tried to override with "${t2.name.value}"` - ); - } - } +function fieldAlreadyExists(fieldsArr: ReadonlyArray, otherField: FieldDefNode): [FieldDefNode, number] { + const resultIndex: number | null = fieldsArr.findIndex(field => field.name.value === otherField.name.value); - return !!result; + return [resultIndex > -1 ? fieldsArr[resultIndex] : null, resultIndex]; } export function mergeFields( - type: { name: NameNode }, + type: NamedDefNode, f1: ReadonlyArray, f2: ReadonlyArray, config: Config @@ -31,49 +29,50 @@ export function mergeFields f.name.value === (field as any).name.value); - - if (config && config.throwOnConflict) { - preventConflicts(type, existing, field, false); - } else { - preventConflicts(type, existing, field, true); - } - - if (isNonNullTypeNode(field.type) && !isNonNullTypeNode(existing.type)) { - existing.type = field.type; - } - - existing.arguments = mergeArguments(field['arguments'] || [], existing.arguments || [], config); - existing.directives = mergeDirectives(field.directives, existing.directives, config); - existing.description = field.description || existing.description; + const [existing, existingIndex] = fieldAlreadyExists(result, field); + if (existing) { + const onFieldTypeConflict = config?.onFieldTypeConflict || preventConflicts; + const newField: any = onFieldTypeConflict(existing, field, type, config) as T; + newField.arguments = mergeArguments(field['arguments'] || [], existing['arguments'] || [], config); + newField.directives = mergeDirectives(field.directives, existing.directives, config); + newField.description = field.description || existing.description; + result[existingIndex] = newField; } else { result.push(field); } } - if (config && config.sort) { + if (config?.sort) { result.sort(compareNodes); } - if (config && config.exclusions) { + if (config?.exclusions) { return result.filter(field => !config.exclusions.includes(`${type.name.value}.${field.name.value}`)); } return result; } -function preventConflicts( - type: { name: NameNode }, - a: FieldDefinitionNode | InputValueDefinitionNode, - b: FieldDefinitionNode | InputValueDefinitionNode, - ignoreNullability = false -) { +function preventConflicts(a: FieldDefNode, b: FieldDefNode, type: { name: NameNode }, config: Config) { const aType = printTypeNode(a.type); const bType = printTypeNode(b.type); if (isNotEqual(aType, bType)) { - if (safeChangeForFieldType(a.type, b.type, ignoreNullability) === false) { + const t1 = extractType(a.type); + const t2 = extractType(b.type); + + if (t1.name.value !== t2.name.value) { + throw new Error( + `Field "${b.name.value}" already defined with a different type. Declared as "${t1.name.value}", but you tried to override with "${t2.name.value}"` + ); + } + if (!safeChangeForFieldType(a.type, b.type, !config?.throwOnConflict)) { throw new Error(`Field '${type.name.value}.${a.name.value}' changed type from '${aType}' to '${bType}'`); } } + + if (isNonNullTypeNode(b.type) && !isNonNullTypeNode(a.type)) { + (a as any).type = b.type; + } + + return a; } function safeChangeForFieldType(oldType: TypeNode, newType: TypeNode, ignoreNullability = false): boolean { @@ -102,5 +101,5 @@ function safeChangeForFieldType(oldType: TypeNode, newType: TypeNode, ignoreNull ); } - return false; + return ignoreNullability; } diff --git a/packages/merge/src/typedefs-mergers/merge-typedefs.ts b/packages/merge/src/typedefs-mergers/merge-typedefs.ts index e604c2c0f9b..49d98ba0fab 100644 --- a/packages/merge/src/typedefs-mergers/merge-typedefs.ts +++ b/packages/merge/src/typedefs-mergers/merge-typedefs.ts @@ -3,6 +3,7 @@ import { isSourceTypes, isStringTypes, isSchemaDefinition } from './utils'; import { MergedResultMap, mergeGraphQLNodes } from './merge-nodes'; import { resetComments, printWithComments } from './comments'; import { createSchemaDefinition, printSchemaWithDirectives } from '@graphql-tools/utils'; +import { OnFieldTypeConflict } from './fields'; type Omit = Pick>; type CompareFn = (a: T, b: T) => number; @@ -57,6 +58,22 @@ export interface Config { exclusions?: string[]; sort?: boolean | CompareFn; convertExtensions?: boolean; + /** + * Called if types of the same fields are different + * + * Default: false + * + * @example: + * Given: + * ```graphql + * type User { a: String } + * type User { a: Int } + * ``` + * + * Instead of throwing `already defined with a different type` error, + * `onFieldTypeConflict` function is called. + */ + onFieldTypeConflict?: OnFieldTypeConflict; } /** diff --git a/packages/merge/tests/merge-typedefs.spec.ts b/packages/merge/tests/merge-typedefs.spec.ts index efb4b9c6f09..13c42966f1d 100644 --- a/packages/merge/tests/merge-typedefs.spec.ts +++ b/packages/merge/tests/merge-typedefs.spec.ts @@ -6,6 +6,7 @@ import { stripWhitespaces } from './utils'; import gql from 'graphql-tag'; import { readFileSync } from 'fs'; import { join } from 'path'; +import '../../testing/to-be-similar-gql-doc'; const introspectionSchema = JSON.parse(readFileSync(join(__dirname, './schema.json'), 'utf8')); @@ -1343,5 +1344,58 @@ describe('Merge TypeDefs', () => { expect(mergeDirectives(directivesOne, directivesTwo, config)).toEqual(directivesTwo); }); - }) + }); + it('should call onFieldTypeConflict if there are two different types', () => { + const onFieldTypeConflict = jest.fn().mockImplementation((_, r) => r); + const typeDefs1 = parse(/* GraphQL */` + type Query { + foo: Int + } + `); + const typeDefs2 = parse(/* GraphQL */` + type Query { + foo: String + } + `); + const mergedTypeDefs = mergeTypeDefs([typeDefs1, typeDefs2], { + onFieldTypeConflict, + }); + expect(print(onFieldTypeConflict.mock.calls[0][0])).toContain('foo: Int'); + expect(print(onFieldTypeConflict.mock.calls[0][1])).toContain('foo: String'); + expect(print(mergedTypeDefs)).toBeSimilarGqlDoc(/* GraphQL */` + schema { + query: Query + } + + type Query { + foo: String + } + `); + }); it('should call onFieldTypeConflict if there are two same types but with different nullability', () => { + const onFieldTypeConflict = jest.fn().mockImplementation((_, r) => r); + const typeDefs1 = parse(/* GraphQL */` + type Query { + foo: String! + } + `); + const typeDefs2 = parse(/* GraphQL */` + type Query { + foo: String + } + `); + const mergedTypeDefs = mergeTypeDefs([typeDefs1, typeDefs2], { + onFieldTypeConflict, + }); + expect(print(onFieldTypeConflict.mock.calls[0][0])).toContain('foo: String!'); + expect(print(onFieldTypeConflict.mock.calls[0][1])).toContain('foo: String'); + expect(print(mergedTypeDefs)).toBeSimilarGqlDoc(/* GraphQL */` + schema { + query: Query + } + + type Query { + foo: String + } + `); + }); });