Created
September 23, 2024 13:12
-
-
Save RStankov/6577a9101fbfa19079c0611d910f7a7c to your computer and use it in GitHub Desktop.
Detects unused GraphQL fields
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as glob from 'glob'; | |
import difference from 'lodash/difference'; | |
import startCase from 'lodash/startCase'; | |
import union from 'lodash/union'; | |
import uniq from 'lodash/uniq'; | |
import { CodeFileLoader } from '@graphql-tools/code-file-loader'; | |
import { DocumentNode, DefinitionNode, OperationDefinitionNode } from 'graphql'; | |
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'; | |
import { loadDocuments } from '@graphql-tools/load'; | |
import { visit } from 'graphql/language/visitor'; | |
type IOptions = { | |
schema: ISchema; | |
ignore?: string[]; | |
pattern?: string; | |
}; | |
export default async function detectUnusedFields({ | |
schema, | |
ignore = ['**/node_modules/**'], | |
pattern = '**/*.graphql', | |
}: IOptions): Promise<string[]> { | |
const fields = getSchemaFields(schema); | |
const files = glob.sync(pattern, { | |
ignore, | |
realpath: true, | |
}); | |
const operations = await Promise.all(files.map(operationsFormFile)); | |
return difference( | |
Object.keys(fields), | |
replaceOperationWithFields(union(...operations), fields), | |
); | |
} | |
function getSchemaFields(schema: ISchema): ITypesMap { | |
return schema.__schema.types.reduce((acc, schemaType) => { | |
if (schemaType.kind !== 'OBJECT' || schemaType.name.startsWith('__')) { | |
return acc; | |
} | |
(schemaType.fields || []).forEach((field) => { | |
let type = field.type; | |
while (type.ofType) { | |
type = type.ofType; | |
} | |
acc[`${schemaType.name}.${field.name}`] = type.name; | |
}); | |
return acc; | |
}, {}); | |
} | |
async function operationsFormFile(file: string): Promise<string[]> { | |
const sources = await loadDocuments(file, { | |
loaders: [new CodeFileLoader(), new GraphQLFileLoader()], | |
}); | |
return sources.reduce((acc, { document }) => { | |
if (document === undefined) { | |
return acc; | |
} | |
return acc.concat(extractOperations(document)); | |
}, [] as string[]); | |
} | |
function extractOperations(document: DocumentNode): string[] { | |
const operation = document.definitions.find( | |
(node: DefinitionNode): node is OperationDefinitionNode => | |
node.kind === 'OperationDefinition', | |
)?.operation; | |
if (!operation) { | |
return []; | |
} | |
const paths: string[] = []; | |
let currentPath: string[] = []; | |
let currentScope = startCase(operation); | |
let previousPath: string[] = []; | |
let previousScope = ''; | |
visit(document, { | |
enter(node) { | |
switch (node.kind) { | |
case 'Field': | |
currentPath.push(node.name.value); | |
paths.push(`${currentScope}.${currentPath.join('.')}`); | |
break; | |
case 'FragmentDefinition': | |
currentScope = node.typeCondition?.name?.value as string; | |
currentPath = []; | |
break; | |
case 'InlineFragment': | |
previousScope = currentScope; | |
previousPath = currentPath; | |
paths.push(`${currentScope}.${currentPath.join('.')}`); | |
currentScope = node.typeCondition?.name?.value as string; | |
currentPath = []; | |
break; | |
default: | |
break; | |
} | |
}, | |
leave(node) { | |
switch (node.kind) { | |
case 'Field': | |
currentPath.pop(); | |
break; | |
case 'FragmentDefinition': | |
currentScope = startCase(operation); | |
break; | |
case 'InlineFragment': | |
currentPath = previousPath; | |
currentScope = previousScope; | |
break; | |
default: | |
break; | |
} | |
}, | |
}); | |
return uniq(paths); | |
} | |
function replaceOperationWithFields(paths: string[], typesMap: ITypesMap): string[] { | |
return paths.map((path) => { | |
const parts = path.split('.'); | |
if (parts.length < 3) { | |
return path; | |
} | |
let head = parts.shift(); | |
while (parts.length > 1) { | |
head = typesMap[`${head}.${parts.shift()}`]; | |
} | |
return `${head}.${parts.shift()}`; | |
}); | |
} | |
type ITypesMap = Record<string, string>; | |
type ISchema = { | |
__schema: { | |
types: ISchemaType[]; | |
}; | |
}; | |
type ISchemaType = { | |
name: string; | |
kind: ISchemaTypeKind; | |
fields: ISchemaField[]; | |
}; | |
type ISchemaField = { | |
name: string; | |
type: ISchemaFieldType; | |
}; | |
type ISchemaFieldType = { | |
name: string; | |
kind: ISchemaTypeKind; | |
ofType: ISchemaFieldType | null; | |
}; | |
type ISchemaTypeKind = | |
| 'OBJECT' | |
| 'INTERFACE' | |
| 'UNION' | |
| 'ENUM' | |
| 'INPUT_OBJECT' | |
| 'SCALAR' | |
| 'LIST' | |
| 'NON_NULL'; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment