Skip to content

Commit f7aedf2

Browse files
authored
refactor: clean up AST traversal and report building (#26)
* Refactor visitor code * Improve naming of various funcs * Add more explicit structure for report building and split up logic
1 parent 0be17a2 commit f7aedf2

7 files changed

Lines changed: 353 additions & 125 deletions

File tree

src/findJSGraphQLTags.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import findJSGraphQLTags from "./findJSGraphQLTags";
22

3+
// TODO: add test for queries/mutations/subscriptions without names
4+
// TODO: add test for subscriptions
5+
36
describe("findJSGraphQLTags", () => {
47
test("returns GraphQL tags given JS source code", () => {
58
const js = `

src/findJSGraphQLTags.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import parser = require("@babel/parser");
1+
import { parse, ParserOptions, ParserPlugin } from "@babel/parser";
22
import traverse, { NodePath } from "@babel/traverse";
33
import {
44
Expression,
@@ -10,7 +10,7 @@ import { GraphQLTag } from "./types";
1010

1111
// https://github.com/facebook/relay/blob/master/packages/relay-compiler/language/javascript/FindGraphQLTags.js
1212

13-
const plugins: parser.ParserPlugin[] = [
13+
const plugins: ParserPlugin[] = [
1414
"asyncGenerators",
1515
"classProperties",
1616
["decorators", { decoratorsBeforeExport: true }],
@@ -26,7 +26,7 @@ const plugins: parser.ParserPlugin[] = [
2626
"optionalCatchBinding"
2727
];
2828

29-
const PARSER_OPTIONS: parser.ParserOptions = {
29+
const PARSER_OPTIONS: ParserOptions = {
3030
allowImportExportEverywhere: true,
3131
allowReturnOutsideFunction: true,
3232
allowSuperOutsideMethod: true,
@@ -35,16 +35,16 @@ const PARSER_OPTIONS: parser.ParserOptions = {
3535
strictMode: false
3636
};
3737

38-
function find(text: string, filePath: string): Array<GraphQLTag> {
39-
const result: Array<GraphQLTag> = [];
40-
const ast = parser.parse(text, PARSER_OPTIONS);
38+
function findGraphQLTags(code: string, filePath: string): GraphQLTag[] {
39+
const results: GraphQLTag[] = [];
40+
const ast = parse(code, PARSER_OPTIONS);
4141

4242
const visitors = {
4343
TaggedTemplateExpression: ({
4444
node
4545
}: NodePath<TaggedTemplateExpression>) => {
4646
if (isGraphQLTag(node.tag)) {
47-
result.push({
47+
results.push({
4848
template: node.quasi.quasis[0].value.raw,
4949
sourceLocationOffset: getSourceLocationOffset(node.quasi),
5050
filePath
@@ -54,7 +54,7 @@ function find(text: string, filePath: string): Array<GraphQLTag> {
5454
};
5555

5656
traverse(ast, visitors);
57-
return result;
57+
return results;
5858
}
5959

6060
function isGraphQLTag(tag: Expression): boolean {
@@ -81,4 +81,4 @@ function getSourceLocationOffset(
8181
};
8282
}
8383

84-
export default find;
84+
export default findGraphQLTags;

src/getFieldInfo.ts

Lines changed: 30 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {
2-
ASTNode,
32
FieldNode,
3+
FragmentDefinitionNode,
44
getLocation,
5+
OperationDefinitionNode,
56
parse,
67
Source,
78
TypeInfo,
@@ -20,59 +21,41 @@ export interface FieldInfo {
2021
filePath: string;
2122
}
2223

23-
function getFeildInfo(
24-
{ template, sourceLocationOffset, filePath }: GraphQLTag,
24+
function findFields(
25+
graphQLTag: GraphQLTag,
2526
typeInfo: TypeInfo,
2627
cb: (fieldInfo: FieldInfo) => void
2728
) {
28-
const ast = parse(template);
29+
const ast = parse(graphQLTag.template);
2930

3031
visit(
3132
ast,
3233
visitWithTypeInfo(typeInfo, {
33-
OperationDefinition(graphqlNode) {
34-
if (graphqlNode.name) {
35-
visitFields(
36-
graphqlNode,
37-
graphqlNode.name.value,
38-
typeInfo,
39-
template,
40-
sourceLocationOffset,
41-
filePath,
42-
cb
43-
);
44-
} else {
45-
throw new Error(`No name for OperationDefinition`);
46-
}
34+
OperationDefinition(node) {
35+
visitFields(node, graphQLTag, typeInfo, cb);
4736
},
48-
FragmentDefinition(graphqlNode) {
49-
if (graphqlNode.name) {
50-
visitFields(
51-
graphqlNode,
52-
graphqlNode.name.value,
53-
typeInfo,
54-
template,
55-
sourceLocationOffset,
56-
filePath,
57-
cb
58-
);
59-
} else {
60-
throw new Error(`No name for FragmentDefinition`);
61-
}
37+
FragmentDefinition(node) {
38+
visitFields(node, graphQLTag, typeInfo, cb);
6239
}
6340
})
6441
);
6542
}
6643

6744
function visitFields(
68-
node: ASTNode,
69-
operationOrFragmentName: string,
45+
node: OperationDefinitionNode | FragmentDefinitionNode,
46+
graphQLTag: GraphQLTag,
7047
typeInfo: TypeInfo,
71-
template: string,
72-
sourceLocationOffset: { line: number; column: number },
73-
filePath: string,
7448
cb: (fieldInfo: FieldInfo) => void
7549
) {
50+
if (!node.name) {
51+
throw new Error(
52+
"visitFields expects OperationDefinitions and FragmentDefinitions to be named"
53+
);
54+
}
55+
56+
const { filePath, sourceLocationOffset, template } = graphQLTag;
57+
const operationOrFragmentName = node.name.value;
58+
7659
visit(
7760
node,
7861
visitWithTypeInfo(typeInfo, {
@@ -85,15 +68,21 @@ function visitFields(
8568
const nodeName = graphqlNode.name.value;
8669

8770
if (!parentType) {
88-
throw new Error(`No parent type for ${nodeName}`);
71+
throw new Error(
72+
`visitFields expects fields to have a parent type. No parent type for ${nodeName}`
73+
);
8974
}
9075

9176
if (!nodeType) {
92-
throw new Error(`No type for ${nodeName}`);
77+
throw new Error(
78+
`visitFields expects fields to have a type. No type for ${nodeName}`
79+
);
9380
}
9481

9582
if (!graphqlNode.loc) {
96-
throw new Error(`No location for ${nodeName}`);
83+
throw new Error(
84+
`visitFields expects fields to have a location. No location for ${nodeName}`
85+
);
9786
}
9887

9988
const loc = graphqlNode.loc;
@@ -124,4 +113,4 @@ function isClientOnlyField(field: FieldNode): boolean {
124113
return !!clientOnlyDirective;
125114
}
126115

127-
export default getFeildInfo;
116+
export default findFields;

src/index.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,8 @@ function assertOutputMatchesSnapshot(output: {
243243
type.fields.map((field: any) => {
244244
expect(omit(["occurrences"], field)).toMatchSnapshot();
245245

246-
field.occurrences.map((occurence: any) => {
247-
expect(occurence).toMatchSnapshot({
246+
field.occurrences.map((occurrence: any) => {
247+
expect(occurrence).toMatchSnapshot({
248248
filename: expect.stringMatching(
249249
/^https:\/\/github.com\/CDThomas\/graphql-usage\/tree\/.*\.(js|jsx|ts|tsx)#L\d$/
250250
)

src/index.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import findTSGraphQLTags from "./findTSGraphQLTags";
1818
import flatten from "./flatten";
1919
import getFeildInfo, { FieldInfo } from "./getFieldInfo";
2020
import { getGitHubBaseURL, getGitProjectRoot } from "./gitUtils";
21-
import { buildReport, Report } from "./report";
21+
import { addOccurrence, buildInitialState, format, Report } from "./report";
2222
import createServer from "./server";
2323
import { GraphQLTag } from "./types";
2424

@@ -125,15 +125,29 @@ async function analyzeFiles(
125125
});
126126

127127
const data = await Promise.all<SourceFile>(
128-
files.map(unary(partialRight(readFiles, [sourceDir])))
128+
files.map(unary(partialRight(readFile, [sourceDir])))
129129
);
130130
let tags = flatten(data.map(findGraphQLTags));
131131

132-
const info: FieldInfo[] = [];
133-
const cb = (fieldInfo: FieldInfo) => info.push(fieldInfo);
134-
findFields(schema, tags, cb);
132+
const state = buildInitialState(schema);
133+
findFields(
134+
schema,
135+
tags,
136+
({ parentType, name, filePath, line, rootNodeName }: FieldInfo) => {
137+
const gitHubFileURL = filePath.replace(
138+
path.resolve(gitDir),
139+
gitHubBaseURL
140+
);
141+
const link = `${gitHubFileURL}#L${line}`;
142+
143+
addOccurrence(state, parentType, name, {
144+
filename: link,
145+
rootNodeName
146+
});
147+
}
148+
);
135149

136-
return buildReport(info, schema, gitDir, gitHubBaseURL);
150+
return format(state);
137151
}
138152

139153
async function readSchema(schemaFile: string): Promise<GraphQLSchema> {
@@ -181,7 +195,7 @@ interface SourceFile {
181195
fullPath: string;
182196
}
183197

184-
async function readFiles(
198+
async function readFile(
185199
filePath: string,
186200
sourceDir: string
187201
): Promise<SourceFile> {

src/report.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { buildSchema, isObjectType } from "graphql";
2+
3+
import { addOccurrence, buildInitialState, format } from "./report";
4+
5+
const testSchema = buildSchema(`
6+
type Query {
7+
books: [Book]
8+
}
9+
10+
type Book {
11+
title: String!
12+
pageCount: Int
13+
isPublished: Boolean
14+
}
15+
`);
16+
17+
describe("buildInitialState", () => {
18+
test("includes a property for each named type in the schema", () => {
19+
// TODO: non-object types
20+
const expectedTypes = testSchema.toConfig().types.filter(isObjectType);
21+
22+
const { types } = buildInitialState(testSchema);
23+
24+
expectedTypes.forEach(type => {
25+
expect(types).toHaveProperty(type.name);
26+
});
27+
});
28+
29+
test("builds the correct properties for Object types", () => {
30+
const { types } = buildInitialState(testSchema);
31+
32+
expect(types.Book).toEqual({
33+
fields: expect.any(Object),
34+
// kind: "Object",
35+
name: "Book"
36+
});
37+
});
38+
39+
test("includes a property for each field of an Object type", () => {
40+
const initialState = buildInitialState(testSchema);
41+
const fields = initialState.types.Book.fields;
42+
43+
expect(fields).toEqual({
44+
title: expect.any(Object),
45+
pageCount: expect.any(Object),
46+
isPublished: expect.any(Object)
47+
});
48+
});
49+
50+
test("includes the correct properties for individual object fields", () => {
51+
const initialState = buildInitialState(testSchema);
52+
const field = initialState.types.Book.fields.title;
53+
54+
expect(field).toEqual({
55+
name: "title",
56+
occurrences: [],
57+
type: {
58+
kind: "NonNull",
59+
name: null,
60+
ofType: {
61+
kind: "Scalar",
62+
name: "String",
63+
ofType: null
64+
}
65+
}
66+
});
67+
});
68+
});
69+
70+
describe("addOccurrence", () => {
71+
test("adds an occurrence to the given field", () => {
72+
const state = buildInitialState(testSchema);
73+
74+
addOccurrence(state, "Book", "title", {
75+
filename: "src/Component.js",
76+
rootNodeName: "ComponentQuery"
77+
});
78+
79+
expect(state.types.Book.fields.title.occurrences).toEqual([
80+
{
81+
filename: "src/Component.js",
82+
rootNodeName: "ComponentQuery"
83+
}
84+
]);
85+
});
86+
});
87+
88+
describe("format", () => {
89+
test("formats types as a list sorted alphabetically", () => {
90+
const state = buildInitialState(testSchema);
91+
const report = format(state);
92+
93+
expect(report.data.types.map(({ name }) => name)).toEqual([
94+
"Book",
95+
"Query",
96+
"__Directive",
97+
"__EnumValue",
98+
"__Field",
99+
"__InputValue",
100+
"__Schema",
101+
"__Type"
102+
]);
103+
});
104+
105+
test("formats fields as a list sorted alphabetically", () => {
106+
const state = buildInitialState(testSchema);
107+
const report = format(state);
108+
109+
const bookType = report.data.types.find(type => type.name === "Book")!;
110+
expect(bookType.fields.map(({ name }) => name)).toEqual([
111+
"isPublished",
112+
"pageCount",
113+
"title"
114+
]);
115+
});
116+
});

0 commit comments

Comments
 (0)