Skip to content

Commit

Permalink
federation support
Browse files Browse the repository at this point in the history
  • Loading branch information
aexol committed Jun 6, 2024
1 parent 33dcd12 commit 183d698
Show file tree
Hide file tree
Showing 7 changed files with 258 additions and 104 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "graphql-js-tree",
"version": "2.0.1",
"version": "2.0.2",
"private": false,
"license": "MIT",
"description": "GraphQL Parser providing simplier structure",
Expand Down
29 changes: 29 additions & 0 deletions src/TreeOperations/merge/arguments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ParserField, Options } from '@/Models';
import { MergeError } from '@/TreeOperations/merge/common';

export const mergeArguments = (parentName: string, args1: ParserField[], args2: ParserField[]) => {
args2
.filter((a) => a.type.fieldType.type === Options.required)
.forEach((a2) => {
if (!args1.find((a1) => a1.name === a2.name))
throw new MergeError({
conflictingNode: parentName,
conflictingField: a2.name,
message: 'Cannot merge when required argument does not exist in correlated node',
});
});
return args1
.map((a1) => {
const equivalentA2 = args2.find((a2) => a2.name === a1.name);
if (!equivalentA2 && a1.type.fieldType.type === Options.required)
throw new MergeError({
conflictingNode: parentName,
conflictingField: a1.name,
message: 'Cannot merge when required argument does not exist in correlated node',
});
if (!equivalentA2) return;
if (a1.type.fieldType.type === Options.required) return a1;
if (equivalentA2.type.fieldType.type === Options.required) return equivalentA2;
})
.filter(<T>(v: T | undefined): v is T => !!v);
};
13 changes: 13 additions & 0 deletions src/TreeOperations/merge/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export class MergeError extends Error {
constructor(
public errorParams: {
conflictingNode: string;
conflictingField?: string;
message?: string;
},
) {
super('Merging error');
}
}

export type ErrorConflict = { conflictingNode: string; conflictingField?: string };
87 changes: 64 additions & 23 deletions src/TreeOperations/merge.ts → src/TreeOperations/merge/merge.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,67 @@
import { ParserField, ParserTree, TypeSystemDefinition } from '@/Models';
import { ParserField, ParserTree, TypeDefinition, TypeSystemDefinition } from '@/Models';
import { Parser } from '@/Parser';
import { mergeArguments } from '@/TreeOperations/merge/arguments';
import { MergeError, ErrorConflict } from '@/TreeOperations/merge/common';
import { isExtensionNode } from '@/TreeOperations/shared';
import { TreeToGraphQL } from '@/TreeToGraphQL';
import { generateNodeId } from '@/shared';
import { generateNodeId, getTypeName } from '@/shared';

const detectConflictOnBaseNode = (n1: ParserField, n2: ParserField) => {
if (n1.data.type !== n2.data.type)
throw new MergeError({
conflictingNode: n1.name,
message: `Data type conflict of nodes ${n1.name} and ${n2.name}`,
});
if (JSON.stringify(n1.interfaces) !== JSON.stringify(n2.interfaces))
throw new MergeError({
conflictingNode: n1.name,
message: `Data type conflict of nodes ${n1.name} and ${n2.name}`,
});
};

const detectConflictOnFieldNode = (parentName: string, f1: ParserField, f2: ParserField) => {
const [f1Type, f2Type] = [getTypeName(f1.type.fieldType), getTypeName(f2.type.fieldType)];
if (f1Type !== f2Type)
throw new MergeError({
conflictingNode: parentName,
conflictingField: f1.name,
message: `Data type conflict of node ${parentName} field ${f1.name} `,
});
};
const addFromLibrary = (n: ParserField): ParserField => ({ ...n, fromLibrary: true });

const mergeFields = (parentName: string, fields1: ParserField[], fields2: ParserField[]) => {
const mergedCommonFieldsAndF1Fields = fields1
.map((f1) => {
const commonField = fields2.find((f2) => f2.name === f1.name);
if (!commonField) return f1;
detectConflictOnFieldNode(parentName, f1, commonField);
const mergedField: ParserField = {
...f1,
args: mergeArguments(f1.name, f1.args, commonField.args),
};
return mergedField;
})
.filter(<T>(f: T | undefined): f is T => !!f);
const otherF2Fields = fields2.filter((f2) => !fields1.find((f1) => f1.name === f2.name));
return [...mergedCommonFieldsAndF1Fields, ...otherF2Fields];
};

const mergeNode = (n1: ParserField, n2: ParserField) => {
const args = [...n1.args, ...n2.args.map(addFromLibrary)];
detectConflictOnBaseNode(n1, n2);
const args =
n1.data.type === TypeDefinition.InputObjectTypeDefinition
? mergeArguments(n1.name, n1.args, n2.args)
: mergeFields(n1.name, n1.args, n2.args.map(addFromLibrary));

const mergedNode = {
...n1,
id: generateNodeId(n1.name, n1.data.type, args),
args,
directives: [...n1.directives, ...n2.directives.map(addFromLibrary)],
interfaces: [...n1.interfaces, ...n2.interfaces],
} as ParserField;
//dedupe
mergedNode.args = mergedNode.args.filter((a, i) => mergedNode.args.findIndex((aa) => aa.name === a.name) === i);

mergedNode.directives = mergedNode.directives.filter(
(a, i) => mergedNode.directives.findIndex((aa) => aa.name === a.name) === i,
);
Expand All @@ -30,7 +75,7 @@ export const mergeTrees = (tree1: ParserTree, tree2: ParserTree) => {
const mergedNodesT1: ParserField[] = [];
const mergedNodesT2: ParserField[] = [];
const mergeResultNodes: ParserField[] = [];
const errors: Array<{ conflictingNode: string; conflictingField: string }> = [];
const errors: Array<ErrorConflict> = [];
const filteredTree2Nodes = tree2.nodes.filter((t) => t.data.type !== TypeSystemDefinition.SchemaDefinition);
// merge nodes
tree1.nodes.forEach((t1n) => {
Expand All @@ -48,23 +93,19 @@ export const mergeTrees = (tree1: ParserTree, tree2: ParserTree) => {
}
}
});
} else {
// Check if arg named same and different typings -> throw
mergedNodesT1.push(t1n);
mergedNodesT2.push(matchingNode);
t1n.args.forEach((t1nA) => {
const matchingArg = matchingNode.args.find((mNA) => mNA.name === t1nA.name);
if (matchingArg) {
if (JSON.stringify(matchingArg) !== JSON.stringify(t1nA)) {
errors.push({
conflictingField: t1nA.name,
conflictingNode: t1n.name,
});
}
}
});
if (!errors.length) {
mergeResultNodes.push(mergeNode(t1n, matchingNode));
return;
}
mergedNodesT1.push(t1n);
mergedNodesT2.push(matchingNode);
try {
const mergeNodeResult = mergeNode(t1n, matchingNode);
mergeResultNodes.push(mergeNodeResult);
} catch (error) {
if (error instanceof MergeError) {
errors.push({
conflictingNode: error.errorParams.conflictingNode,
conflictingField: error.errorParams.conflictingField,
});
}
}
}
Expand Down
115 changes: 115 additions & 0 deletions src/__tests__/TreeOperations/merge/merge.input.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { mergeSDLs } from '@/TreeOperations/merge/merge';
import { expectTrimmedEqual } from '@/__tests__/TestUtils';

describe('Merging GraphQL Inputs and field arguments', () => {
it('Should merge inputs leaving only common fields.', () => {
const baseSchema = `
input UserInput {
name: String!
age: Int # Not in Subgraph B
}
`;

const mergingSchema = `
input UserInput {
name: String!
email: String # Not in Subgraph A
}
`;
const t1 = mergeSDLs(baseSchema, mergingSchema);
if (t1.__typename === 'error') throw new Error('Invalid parse');
expectTrimmedEqual(
t1.sdl,
`
input UserInput{
name: String!
}`,
);
});
it('Should merge inputs marking fields required.', () => {
const baseSchema = `
input UserInput {
name: String!
age: Int
}
`;

const mergingSchema = `
input UserInput {
name: String
age: Int!
}
`;
const t1 = mergeSDLs(baseSchema, mergingSchema);
if (t1.__typename === 'error') throw new Error('Invalid parse');
expectTrimmedEqual(
t1.sdl,
`
input UserInput{
name: String!
age: Int!
}`,
);
});
it('Should not merge inputs', () => {
const baseSchema = `
input UserInput {
name: String!
}
`;

const mergingSchema = `
input UserInput {
name: String!
email: String!
}
`;
const t1 = mergeSDLs(baseSchema, mergingSchema);
if (t1.__typename === 'success') console.log(t1.sdl);
expect(t1.__typename).toEqual('error');
});
it('Should merge field arguments marking them required.', () => {
const baseSchema = `
type Main{
getUsers(funny: Boolean, premium: String!): String!
}
`;

const mergingSchema = `
type Main{
getUsers(funny: Boolean!, premium: String): String!
}
`;
const t1 = mergeSDLs(baseSchema, mergingSchema);
if (t1.__typename === 'error') throw new Error('Invalid parse');
expectTrimmedEqual(
t1.sdl,
`
type Main{
getUsers(funny: Boolean! premium: String!): String!
}`,
);
});
it('Should merge field arguments leaving only common fields.', () => {
const baseSchema = `
type Main{
getUsers(premium: String!): String!
}
`;

const mergingSchema = `
type Main{
getUsers(funny: Boolean, premium: String): String!
}
`;
const t1 = mergeSDLs(baseSchema, mergingSchema);
if (t1.__typename === 'error') throw new Error('Invalid parse');
expectTrimmedEqual(
t1.sdl,
`
type Main{
getUsers(premium: String!): String!
}`,
);
});
});
34 changes: 34 additions & 0 deletions src/__tests__/TreeOperations/merge/merge.interfaces.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { mergeSDLs } from '@/TreeOperations/merge/merge';

// const mergingErrorSchema = `
// type Person{
// lastName: String
// }
// `;

describe('Merging GraphQL Schemas', () => {
it('should not merge interfaces and implementation of both nodes', () => {
const baseSchema = `
type Person implements Node{
firstName: String
health: String
_id: String
}
interface Node {
_id: String
}
`;

const mergingSchema = `
type Person implements Dateable{
lastName: String
createdAt: String
}
interface Dateable {
createdAt: String
}
`;
const t1 = mergeSDLs(baseSchema, mergingSchema);
expect(t1.__typename).toEqual('error');
});
});
Loading

0 comments on commit 183d698

Please sign in to comment.