Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(merge): add onFieldTypeConflict to fix field type conflicts manually #2065

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/automerge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ jobs:
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
MERGE_METHOD: 'squash'
LABELS: automerge
MERGE_LABELS: automerge
79 changes: 39 additions & 40 deletions packages/merge/src/typedefs-mergers/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,75 +5,74 @@ import { mergeDirectives } from './directives';
import { isNotEqual, compareNodes } from '@graphql-tools/utils';
import { mergeArguments } from './arguments';

function fieldAlreadyExists(fieldsArr: ReadonlyArray<any>, 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<FieldDefNode>, 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<T extends FieldDefinitionNode | InputValueDefinitionNode>(
type: { name: NameNode },
type: NamedDefNode,
f1: ReadonlyArray<T>,
f2: ReadonlyArray<T>,
config: Config
): T[] {
const result: T[] = [...f2];

for (const field of f1) {
if (fieldAlreadyExists(result, field)) {
const existing: any = result.find((f: any) => 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 {
Expand Down Expand Up @@ -102,5 +101,5 @@ function safeChangeForFieldType(oldType: TypeNode, newType: TypeNode, ignoreNull
);
}

return false;
return ignoreNullability;
}
17 changes: 17 additions & 0 deletions packages/merge/src/typedefs-mergers/merge-typedefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
type CompareFn<T> = (a: T, b: T) => number;
Expand Down Expand Up @@ -57,6 +58,22 @@ export interface Config {
exclusions?: string[];
sort?: boolean | CompareFn<string>;
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;
}

/**
Expand Down
56 changes: 55 additions & 1 deletion packages/merge/tests/merge-typedefs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down Expand Up @@ -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
}
`);
});
});