Skip to content

Commit ae6e5e9

Browse files
committed
added logic to sort GraphQL schemas based on dependencies
1 parent e0a9efd commit ae6e5e9

File tree

5 files changed

+378
-6
lines changed

5 files changed

+378
-6
lines changed

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"@graphql-codegen/cli": "4.0.1",
5151
"@graphql-codegen/typescript": "^4.0.0",
5252
"@tsconfig/recommended": "1.0.2",
53+
"@types/graphlib": "^2.1.8",
5354
"@types/jest": "29.5.2",
5455
"@types/node": "^20.1.3",
5556
"@typescript-eslint/eslint-plugin": "5.59.8",
@@ -59,6 +60,7 @@
5960
"myzod": "1.10.0",
6061
"npm-run-all": "4.1.5",
6162
"prettier": "2.8.8",
63+
"ts-dedent": "^2.2.0",
6264
"ts-jest": "29.1.0",
6365
"typescript": "5.1.3",
6466
"yup": "1.2.0",
@@ -69,6 +71,7 @@
6971
"@graphql-codegen/schema-ast": "4.0.0",
7072
"@graphql-codegen/visitor-plugin-common": "^4.0.0",
7173
"@graphql-tools/utils": "^10.0.0",
74+
"graphlib": "^2.1.8",
7275
"graphql": "^16.6.0"
7376
},
7477
"peerDependencies": {

src/graphql.ts

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1-
import { ListTypeNode, NonNullTypeNode, NamedTypeNode, TypeNode, ObjectTypeDefinitionNode } from 'graphql';
1+
import {
2+
ListTypeNode,
3+
NonNullTypeNode,
4+
NamedTypeNode,
5+
TypeNode,
6+
ObjectTypeDefinitionNode,
7+
visit,
8+
DocumentNode,
9+
DefinitionNode,
10+
NameNode,
11+
ASTNode,
12+
GraphQLSchema,
13+
} from 'graphql';
14+
import { Graph } from 'graphlib';
215

316
export const isListType = (typ?: TypeNode): typ is ListTypeNode => typ?.kind === 'ListType';
417
export const isNonNullType = (typ?: TypeNode): typ is NonNullTypeNode => typ?.kind === 'NonNullType';
@@ -20,3 +33,134 @@ export const ObjectTypeDefinitionBuilder = (
2033
return callback(node);
2134
};
2235
};
36+
37+
export const topologicalSortAST = (schema: GraphQLSchema, ast: DocumentNode): DocumentNode => {
38+
const dependencyGraph = new Graph();
39+
const targetKinds = [
40+
'ObjectTypeDefinition',
41+
'InputObjectTypeDefinition',
42+
'EnumTypeDefinition',
43+
'UnionTypeDefinition',
44+
'ScalarTypeDefinition',
45+
];
46+
47+
visit<DocumentNode>(ast, {
48+
enter: node => {
49+
switch (node.kind) {
50+
case 'ObjectTypeDefinition':
51+
case 'InputObjectTypeDefinition': {
52+
const typeName = node.name.value;
53+
dependencyGraph.setNode(typeName);
54+
55+
if (node.fields) {
56+
node.fields.forEach(field => {
57+
if (field.type.kind === 'NamedType') {
58+
const dependency = field.type.name.value;
59+
const typ = schema.getType(dependency);
60+
if (typ?.astNode?.kind === undefined || !targetKinds.includes(typ.astNode.kind)) {
61+
return;
62+
}
63+
if (!dependencyGraph.hasNode(dependency)) {
64+
dependencyGraph.setNode(dependency);
65+
}
66+
dependencyGraph.setEdge(typeName, dependency);
67+
}
68+
});
69+
}
70+
break;
71+
}
72+
case 'ScalarTypeDefinition':
73+
case 'EnumTypeDefinition': {
74+
dependencyGraph.setNode(node.name.value);
75+
break;
76+
}
77+
case 'UnionTypeDefinition': {
78+
const dependency = node.name.value;
79+
if (!dependencyGraph.hasNode(dependency)) {
80+
dependencyGraph.setNode(dependency);
81+
}
82+
node.types?.forEach(type => {
83+
const dependency = type.name.value;
84+
const typ = schema.getType(dependency);
85+
if (typ?.astNode?.kind === undefined || !targetKinds.includes(typ.astNode.kind)) {
86+
return;
87+
}
88+
dependencyGraph.setEdge(node.name.value, dependency);
89+
});
90+
break;
91+
}
92+
default:
93+
break;
94+
}
95+
},
96+
});
97+
98+
const sorted = topsort(dependencyGraph);
99+
100+
// Create a map of definitions for quick access, using the definition's name as the key.
101+
const definitionsMap: Map<string, DefinitionNode> = new Map();
102+
ast.definitions.forEach(definition => {
103+
if (hasNameField(definition) && definition.name) {
104+
definitionsMap.set(definition.name.value, definition);
105+
}
106+
});
107+
108+
// Two arrays to store sorted and not sorted definitions.
109+
const sortedDefinitions: DefinitionNode[] = [];
110+
const notSortedDefinitions: DefinitionNode[] = [];
111+
112+
// Iterate over sorted type names and retrieve their corresponding definitions.
113+
sorted.forEach(sortedType => {
114+
const definition = definitionsMap.get(sortedType);
115+
if (definition) {
116+
sortedDefinitions.push(definition);
117+
definitionsMap.delete(sortedType);
118+
}
119+
});
120+
121+
// Definitions that are left in the map were not included in sorted list
122+
// Add them to notSortedDefinitions.
123+
definitionsMap.forEach(definition => notSortedDefinitions.push(definition));
124+
125+
const definitions = [...sortedDefinitions, ...notSortedDefinitions];
126+
127+
if (definitions.length !== ast.definitions.length) {
128+
throw new Error(
129+
`unexpected definition length after sorted: want ${ast.definitions.length} but got ${definitions.length}`
130+
);
131+
}
132+
133+
return {
134+
...ast,
135+
definitions: definitions as ReadonlyArray<DefinitionNode>,
136+
};
137+
};
138+
139+
const hasNameField = (node: ASTNode): node is DefinitionNode & { name?: NameNode } => {
140+
return 'name' in node;
141+
};
142+
143+
// Re-implemented w/o CycleException version
144+
// https://github.com/dagrejs/graphlib/blob/8d27cb89029081c72eb89dde652602805bdd0a34/lib/alg/topsort.js
145+
export const topsort = (g: Graph): string[] => {
146+
const visited: Record<string, boolean> = {};
147+
const stack: Record<string, boolean> = {};
148+
const results: any[] = [];
149+
150+
function visit(node: string) {
151+
if (!(node in visited)) {
152+
stack[node] = true;
153+
visited[node] = true;
154+
const predecessors = g.predecessors(node);
155+
if (Array.isArray(predecessors)) {
156+
predecessors.forEach(node => visit(node));
157+
}
158+
delete stack[node];
159+
results.push(node);
160+
}
161+
}
162+
163+
g.sinks().forEach(node => visit(node));
164+
165+
return results.reverse();
166+
};

src/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import { ValidationSchemaPluginConfig } from './config';
66
import { PluginFunction, Types } from '@graphql-codegen/plugin-helpers';
77
import { GraphQLSchema, visit } from 'graphql';
88
import { SchemaVisitor } from './types';
9+
import { topologicalSortAST } from './graphql';
910

1011
export const plugin: PluginFunction<ValidationSchemaPluginConfig, Types.ComplexPluginOutput> = (
1112
schema: GraphQLSchema,
1213
_documents: Types.DocumentFile[],
1314
config: ValidationSchemaPluginConfig
1415
): Types.ComplexPluginOutput => {
15-
const { schema: _schema, ast } = transformSchemaAST(schema, config);
16+
const { schema: _schema, ast } = _transformSchemaAST(schema, config);
1617
const { buildImports, initialEmit, ...visitor } = schemaVisitor(_schema, config);
1718

1819
const result = visit(ast, visitor);
@@ -35,3 +36,17 @@ const schemaVisitor = (schema: GraphQLSchema, config: ValidationSchemaPluginConf
3536
}
3637
return YupSchemaVisitor(schema, config);
3738
};
39+
40+
const _transformSchemaAST = (schema: GraphQLSchema, config: ValidationSchemaPluginConfig) => {
41+
const { schema: _schema, ast } = transformSchemaAST(schema, config);
42+
if (config.validationSchemaExportType === 'const') {
43+
return {
44+
schema: _schema,
45+
ast: topologicalSortAST(_schema, ast),
46+
};
47+
}
48+
return {
49+
schema: _schema,
50+
ast,
51+
};
52+
};

0 commit comments

Comments
 (0)