Skip to content

Commit 126a43c

Browse files
committed
feat: add astVisitor() helper which allows to traverse and change AST from directoryToAst() method
1 parent 5b2100f commit 126a43c

File tree

3 files changed

+254
-0
lines changed

3 files changed

+254
-0
lines changed

src/__tests__/astVisitor-test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { astVisitor, VISITOR_REMOVE_NODE, VISITOR_SKIP_CHILDREN } from '../astVisitor';
2+
import { directoryToAst, AstRootNode } from '../directoryToAst';
3+
import { astToSchema } from '../astToSchema';
4+
import { graphql } from 'graphql';
5+
6+
describe('astVisitor', () => {
7+
let ast: AstRootNode;
8+
9+
beforeEach(() => {
10+
ast = directoryToAst(module, { relativePath: './__testSchema__' });
11+
});
12+
13+
it('should visit all ROOT_TYPEs', () => {
14+
const names: string[] = [];
15+
astVisitor(ast, {
16+
ROOT_TYPE: (node, info) => {
17+
names.push(info.name);
18+
expect(info.parent).toBe(ast);
19+
},
20+
});
21+
expect(names.sort()).toEqual(['query', 'mutation'].sort());
22+
});
23+
24+
it('should visit all DIRs', () => {
25+
const dirs: string[] = [];
26+
astVisitor(ast, {
27+
DIR: (node, info) => {
28+
dirs.push(`${info.path.join('.')}.${info.name}`);
29+
},
30+
});
31+
expect(dirs.sort()).toEqual(
32+
[
33+
'mutation.auth',
34+
'mutation.auth.nested',
35+
'mutation.logs.nested',
36+
'mutation.user',
37+
'query.auth',
38+
'query.auth.nested',
39+
'query.me',
40+
'query.user',
41+
].sort()
42+
);
43+
});
44+
45+
it('should visit all FILEs', () => {
46+
const files: string[] = [];
47+
astVisitor(ast, {
48+
FILE: (node, info) => {
49+
files.push(`${info.path.join('.')}.${info.name}`);
50+
},
51+
});
52+
expect(files.sort()).toEqual(
53+
[
54+
'mutation.auth.login',
55+
'mutation.auth.logout',
56+
'mutation.auth.nested.method',
57+
'mutation.logs.nested.list',
58+
'mutation.user.create',
59+
'mutation.user.update',
60+
'query.auth.isLoggedIn',
61+
'query.auth.nested.method',
62+
'query.field',
63+
'query.me.address.city',
64+
'query.me.address.street',
65+
'query.me.name',
66+
'query.some.index',
67+
'query.some.nested',
68+
'query.user.extendedData',
69+
'query.user.roles',
70+
].sort()
71+
);
72+
});
73+
74+
it('`null` should remove nodes from ast', () => {
75+
astVisitor(ast, {
76+
DIR: () => {
77+
return VISITOR_REMOVE_NODE;
78+
},
79+
FILE: () => {
80+
return VISITOR_REMOVE_NODE;
81+
},
82+
});
83+
expect(ast.children.query?.children).toEqual({});
84+
});
85+
86+
it('`false` should not traverse children', () => {
87+
const files: string[] = [];
88+
astVisitor(ast, {
89+
ROOT_TYPE: (node, info) => {
90+
// skip all from `query`
91+
if (info.name === 'query') return VISITOR_SKIP_CHILDREN;
92+
},
93+
DIR: (node, info) => {
94+
// skip all except `auth` dir
95+
if (info.name !== 'auth') return VISITOR_SKIP_CHILDREN;
96+
},
97+
FILE: (node, info) => {
98+
files.push(`${info.path.join('.')}.${info.name}`);
99+
},
100+
});
101+
expect(files.sort()).toEqual(['mutation.auth.login', 'mutation.auth.logout'].sort());
102+
});
103+
104+
it('`any_node` should replace current node', () => {
105+
astVisitor(ast, {
106+
ROOT_TYPE: () => {
107+
return { absPath: '', children: {}, kind: 'rootType', name: 'MOCK' };
108+
},
109+
});
110+
111+
expect(ast.children).toEqual({
112+
mutation: { absPath: '', children: {}, kind: 'rootType', name: 'MOCK' },
113+
query: { absPath: '', children: {}, kind: 'rootType', name: 'MOCK' },
114+
});
115+
});
116+
117+
it('try to wrap all mutations', async () => {
118+
const logs: any[] = [];
119+
astVisitor(ast, {
120+
ROOT_TYPE: (node) => {
121+
if (node.name !== 'mutation') {
122+
return VISITOR_SKIP_CHILDREN;
123+
}
124+
},
125+
FILE: (node) => {
126+
const currentResolve = node.code.default.resolve;
127+
if (currentResolve) {
128+
const description = node.code.default.description;
129+
node.code.default.resolve = (s: any, a: any, c: any, i: any) => {
130+
logs.push({
131+
description,
132+
args: a,
133+
});
134+
return currentResolve(s, a, c, i);
135+
};
136+
}
137+
},
138+
});
139+
const schema = astToSchema(ast).buildSchema();
140+
const result = await graphql(
141+
schema,
142+
`
143+
mutation {
144+
auth {
145+
login(email: "[email protected]", password: "123")
146+
}
147+
}
148+
`
149+
);
150+
expect(result).toEqual({ data: { auth: { login: true } } });
151+
expect(logs).toEqual([
152+
{
153+
description: 'Login operation',
154+
args: { email: '[email protected]', password: '123' },
155+
},
156+
]);
157+
});
158+
});

src/astVisitor.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { AstRootTypeNode, AstDirNode, AstFileNode, AstRootNode } from './directoryToAst';
2+
3+
/**
4+
* Do not traverse children
5+
*/
6+
export const VISITOR_SKIP_CHILDREN = false;
7+
8+
/**
9+
* Means remove Node from Ast and do not traverse children
10+
*/
11+
export const VISITOR_REMOVE_NODE = null;
12+
13+
export type VisitorEmptyResult =
14+
| void // just move further
15+
| typeof VISITOR_REMOVE_NODE
16+
| typeof VISITOR_SKIP_CHILDREN;
17+
18+
export type VisitKindFn<NodeKind> = (
19+
node: NodeKind,
20+
info: VisitInfo
21+
) => VisitorEmptyResult | NodeKind;
22+
23+
export type AstVisitor = {
24+
DIR?: VisitKindFn<AstDirNode>;
25+
FILE?: VisitKindFn<AstFileNode>;
26+
ROOT_TYPE?: VisitKindFn<AstRootTypeNode>;
27+
};
28+
29+
export interface VisitInfo {
30+
parent: AstDirNode | AstRootTypeNode | AstRootNode;
31+
name: string;
32+
path: string[];
33+
}
34+
35+
export function astVisitor(ast: AstRootNode, visitor: AstVisitor): void {
36+
forEachKey(ast.children, (childNode, name) => {
37+
if (childNode) visitNode(childNode, visitor, { parent: ast, name, path: [] });
38+
});
39+
}
40+
41+
export function visitNode(
42+
node: AstDirNode | AstFileNode | AstRootTypeNode,
43+
visitor: AstVisitor,
44+
info: VisitInfo
45+
) {
46+
let result: VisitorEmptyResult | AstDirNode | AstFileNode | AstRootTypeNode;
47+
if (node.kind === 'dir') {
48+
if (visitor.DIR) result = visitor.DIR(node, info);
49+
} else if (node.kind === 'file') {
50+
if (visitor.FILE) result = visitor.FILE(node, info);
51+
} else if (node.kind === 'rootType') {
52+
if (visitor.ROOT_TYPE) result = visitor.ROOT_TYPE(node, info);
53+
}
54+
55+
if (result === VISITOR_REMOVE_NODE) {
56+
// `null` - means remove node from Ast and do not traverse children
57+
delete (info.parent.children as any)[info.name];
58+
return;
59+
} else if (result === VISITOR_SKIP_CHILDREN) {
60+
// `false` - do not traverse children
61+
return;
62+
} else if (result) {
63+
// replace node
64+
(info.parent.children as any)[info.name] = result;
65+
} else {
66+
// `undefined` - just move further
67+
result = node;
68+
}
69+
70+
if (result.kind === 'dir' || result.kind === 'rootType') {
71+
forEachKey(result.children, (childNode, name) => {
72+
visitNode(childNode, visitor, {
73+
parent: result as AstDirNode,
74+
name,
75+
path: [...info.path, info.name],
76+
});
77+
});
78+
}
79+
}
80+
81+
export function forEachKey<V>(
82+
obj: Record<string, V>,
83+
callback: (value: V, key: string) => void
84+
): void {
85+
Object.keys(obj).forEach((key) => {
86+
callback(obj[key], key);
87+
});
88+
}

src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,11 @@ export {
2525
} from './directoryToAst';
2626
export { astToSchema, AstToSchemaOptions } from './astToSchema';
2727
export * from './testHelpers';
28+
29+
export {
30+
astVisitor,
31+
VISITOR_REMOVE_NODE,
32+
VISITOR_SKIP_CHILDREN,
33+
AstVisitor,
34+
VisitInfo,
35+
} from './astVisitor';

0 commit comments

Comments
 (0)