diff --git a/.vscode/launch.json b/.vscode/launch.json index 81ae313..9ba126c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,7 +3,9 @@ // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", - "configurations": [{ + "configurations": [ + + { "type": "node", "request": "launch", "name": "Jest Tests", diff --git a/src/@types/buildTypeWeights.d.ts b/src/@types/buildTypeWeights.d.ts index 26ea9f5..4b9f063 100644 --- a/src/@types/buildTypeWeights.d.ts +++ b/src/@types/buildTypeWeights.d.ts @@ -1,7 +1,11 @@ +export interface Field { + resolveTo?: string; + weight?: FieldWeight; +} export interface Fields { - [index: string]: FieldWeight; + [index: string]: Field; } -export type WeightFunction = (args: ArgumentNode[]) => number; +export type WeightFunction = (args: ArgumentNode[], variables, selectionsCost: number) => number; export type FieldWeight = number | WeightFunction; export interface Type { readonly weight: number; @@ -17,7 +21,13 @@ export interface TypeWeightConfig { scalar?: number; connection?: number; } - +export interface TypeWeightSet { + mutation: number; + query: number; + object: number; + scalar: number; + connection: number; +} type Variables = { [index: string]: readonly unknown; }; diff --git a/src/analysis/ASTnodefunctions.ts b/src/analysis/ASTnodefunctions.ts index fe407d1..00655d9 100644 --- a/src/analysis/ASTnodefunctions.ts +++ b/src/analysis/ASTnodefunctions.ts @@ -6,25 +6,9 @@ import { DefinitionNode, Kind, SelectionNode, - ArgumentNode, + isConstValueNode, } from 'graphql'; import { FieldWeight, TypeWeightObject, Variables } from '../@types/buildTypeWeights'; - -// TODO: handle variables and arguments -// ! this is not functional -const getArgObj = (args: ArgumentNode[]): { [index: string]: any } => { - const argObj: { [index: string]: any } = {}; - for (let i = 0; i < args.length; i + 1) { - const node = args[i]; - if (args[i].value.kind !== Kind.VARIABLE) { - if (args[i].value.kind === Kind.INT) { - // FIXME: this does not work - argObj[args[i].name.value] = args[i].value; - } - } - } - return argObj; -}; /** * The AST node functions call each other following the nested structure below * Each function handles a specific GraphQL AST node type @@ -52,34 +36,39 @@ export function fieldNode( parentName: string ): number { let complexity = 0; - // console.log('fieldNode', node, parentName); - // check if the field name is in the type weight object. - if (node.name.value.toLocaleLowerCase() in typeWeights) { - // if it is, than the field is an object type, add itss type weight to the total - complexity += typeWeights[node.name.value].weight; + // 'resolvedTypeName' is the name of the Schema Type that this field resolves to + const resolvedTypeName = + node.name.value in typeWeights + ? node.name.value + : typeWeights[parentName].fields[node.name.value]?.resolveTo || null; + + if (resolvedTypeName) { + // field resolves to an object or a list with possible selections + let selectionsCost = 0; + let calculatedWeight = 0; + const weightFunction = typeWeights[parentName]?.fields[node.name.value]?.weight; + // call the function to handle selection set node with selectionSet property if it is not undefined if (node.selectionSet) { - complexity += selectionSetNode( + selectionsCost += selectionSetNode( node.selectionSet, typeWeights, variables, - node.name.value + resolvedTypeName ); } + // if there are arguments and this is a list, call the 'weightFunction' to get the weight of this field. otherwise the weight is static and can be accessed through the typeWeights object + if (node.arguments && typeof weightFunction === 'function') { + calculatedWeight += weightFunction([...node.arguments], variables, selectionsCost); + } else { + calculatedWeight += typeWeights[resolvedTypeName].weight + selectionsCost; + } + complexity += calculatedWeight; } else { - // otherwise the field is a scalar or a list. - const fieldWeight: FieldWeight = typeWeights[parentName].fields[node.name.value]; - if (typeof fieldWeight === 'number') { - // if the feild weight is a number, add the number to the total complexity - complexity += fieldWeight; - } else if (node.arguments) { - // BUG: This code is reached when fieldWeight is undefined, which could result from an invalid query or this type - // missing from the typeWeight object. If left unhandled an error is thrown - // otherwise the the feild weight is a list, invoke the function with variables - // TODO: calculate the complexity for lists with arguments and varibales - // ! this is not functional - // iterate through the arguments to build the object to - complexity += fieldWeight([...node.arguments]); + // field is a scalar and 'weight' is a number + const { weight } = typeWeights[parentName].fields[node.name.value]; + if (typeof weight === 'number') { + complexity += weight; } } return complexity; @@ -92,7 +81,6 @@ export function selectionNode( parentName: string ): number { let complexity = 0; - // console.log('selectionNode', node, parentName); // check the kind property against the set of selection nodes that are possible if (node.kind === Kind.FIELD) { // call the function that handle field nodes @@ -101,7 +89,6 @@ export function selectionNode( // TODO: add checks for Kind.FRAGMENT_SPREAD and Kind.INLINE_FRAGMENT here return complexity; } - export function selectionSetNode( node: SelectionSetNode, typeWeights: TypeWeightObject, @@ -130,14 +117,16 @@ export function definitionNode( if (node.operation.toLocaleLowerCase() in typeWeights) { // if it is, it is an object type, add it's type weight to the total complexity += typeWeights[node.operation].weight; + // console.log(`the weight of ${node.operation} is ${complexity}`); // call the function to handle selection set node with selectionSet property if it is not undefined - if (node.selectionSet) + if (node.selectionSet) { complexity += selectionSetNode( node.selectionSet, typeWeights, variables, node.operation ); + } } } // TODO: add checks for Kind.FRAGMENT_DEFINITION here (there are other type definition nodes that i think we can ignore. see ast.d.ts in 'graphql') diff --git a/src/analysis/buildTypeWeights.ts b/src/analysis/buildTypeWeights.ts index bef0d69..c5dcea9 100644 --- a/src/analysis/buildTypeWeights.ts +++ b/src/analysis/buildTypeWeights.ts @@ -1,9 +1,11 @@ import { ArgumentNode, GraphQLArgument, - GraphQLFieldMap, GraphQLNamedType, GraphQLObjectType, + GraphQLScalarType, + GraphQLInterfaceType, + GraphQLList, GraphQLOutputType, isCompositeType, isEnumType, @@ -19,7 +21,13 @@ import { import { Maybe } from 'graphql/jsutils/Maybe'; import { ObjMap } from 'graphql/jsutils/ObjMap'; import { GraphQLSchema } from 'graphql/type/schema'; -import { TypeWeightConfig, TypeWeightObject } from '../@types/buildTypeWeights'; +import { + TypeWeightConfig, + TypeWeightSet, + TypeWeightObject, + Variables, + Type, +} from '../@types/buildTypeWeights'; export const KEYWORDS = ['first', 'last', 'limit']; @@ -30,109 +38,130 @@ const DEFAULT_OBJECT_WEIGHT = 1; const DEFAULT_SCALAR_WEIGHT = 0; const DEFAULT_CONNECTION_WEIGHT = 2; const DEFAULT_QUERY_WEIGHT = 1; - -// FIXME: What about Interface defaults - -/** - * Default TypeWeight Configuration: - * mutation: 10 - * object: 1 - * scalar: 0 - * connection: 2 - */ -export const defaultTypeWeightsConfig: TypeWeightConfig = { +export const defaultTypeWeightsConfig: TypeWeightSet = { mutation: DEFAULT_MUTATION_WEIGHT, object: DEFAULT_OBJECT_WEIGHT, scalar: DEFAULT_SCALAR_WEIGHT, connection: DEFAULT_CONNECTION_WEIGHT, + query: DEFAULT_QUERY_WEIGHT, }; +// FIXME: What about Interface defaults + /** - * Parses the Query type in the provided schema object and outputs a new TypeWeightObject - * @param schema - * @param typeWeightObject - * @param typeWeights - * @returns + * Parses the fields on an object type (query, object, interface) and returns field weights in type weight object format + * + * @param {(GraphQLObjectType | GraphQLInterfaceType)} type + * @param {TypeWeightObject} typeWeightObject + * @param {TypeWeightSet} typeWeights + * @return {*} {Type} */ -function parseQuery( - schema: GraphQLSchema, +function parseObjectFields( + type: GraphQLObjectType | GraphQLInterfaceType, typeWeightObject: TypeWeightObject, - typeWeights: TypeWeightConfig -): TypeWeightObject { - // Get any Query fields (these are the queries that the API exposes) - const queryType: Maybe = schema.getQueryType(); - - if (!queryType) return typeWeightObject; - - const result: TypeWeightObject = { ...typeWeightObject }; - - result.query = { - weight: typeWeights.query || DEFAULT_QUERY_WEIGHT, - // fields gets populated with the query fields and associated weights. - fields: {}, - }; - - const queryFields: GraphQLFieldMap = queryType.getFields(); - - Object.keys(queryFields).forEach((field) => { - // this is the type the query resolves to - const resolveType: GraphQLOutputType = queryFields[field].type; - - // check if any of our keywords 'first', 'last', 'limit' exist in the arg list - queryFields[field].args.forEach((arg: GraphQLArgument) => { - // If query has an argument matching one of the limiting keywords and resolves to a list then the weight of the query - // should be dependent on both the weight of the resolved type and the limiting argument. - // FIXME: Can nonnull wrap list types? - // BUG: Lists need to be accounted for in all types not just queries - if (KEYWORDS.includes(arg.name) && isListType(resolveType)) { - // Get the type that comprises the list - const listType = resolveType.ofType; - - // FIXME: This function can only handle integer arguments for one of the keyword params. - // In order to handle variable arguments, we may need to accept a second parameter so that the complexity aglorithm - // can pass in the variables as well. - // FIXME: If the weight of the resolveType is 0 the weight can be set to 0 rather than a function. - result.query.fields[field] = (args: ArgumentNode[]): number => { - // TODO: Test this function - const limitArg: ArgumentNode | undefined = args.find( - (cur) => cur.name.value === arg.name - ); - - if (limitArg) { - const node: ValueNode = limitArg.value; - - if (Kind.INT === node.kind) { - const multiplier = Number(node.value || arg.defaultValue); - const weight = isCompositeType(listType) - ? result[listType.name.toLowerCase()].weight - : typeWeights.scalar || DEFAULT_SCALAR_WEIGHT; // Note this includes enums - - return weight * multiplier; - } - - if (Kind.VARIABLE === node.kind) { - // TODO: Get variable value and return - // const multiplier: number = - // return result[listType.name.toLowerCase()].weight * multiplier; - throw new Error( - 'ERROR: buildTypeWeights Variable arge values not supported;' - ); - } - } - - // FIXME: The list is unbounded. Return the object weight for - throw new Error( - `ERROR: buildTypeWeights: Unbouned list complexity not supported. Query results should be limited with ${KEYWORDS}` - ); + typeWeights: TypeWeightSet +): Type { + let result: Type; + switch (type.name) { + case 'Query': + result = { weight: typeWeights.query, fields: {} }; + break; + case 'Mutation': + result = { weight: typeWeights.mutation, fields: {} }; + break; + default: + result = { weight: typeWeights.object, fields: {} }; + break; + } + + const fields = type.getFields(); + + // Iterate through the fields and add the required data to the result + Object.keys(fields).forEach((field: string) => { + // The GraphQL type that this field represents + const fieldType: GraphQLOutputType = fields[field].type; + if ( + isScalarType(fieldType) || + (isNonNullType(fieldType) && isScalarType(fieldType.ofType)) + ) { + result.fields[field] = { + weight: typeWeights.scalar, + }; + } else if ( + isInterfaceType(fieldType) || + isUnionType(fieldType) || + isEnumType(fieldType) || + isObjectType(fieldType) + ) { + result.fields[field] = { + resolveTo: fieldType.name.toLocaleLowerCase(), + }; + } else if (isListType(fieldType)) { + // 'listType' is the GraphQL type that the list resolves to + const listType = fieldType.ofType; + if (isScalarType(listType) && typeWeights.scalar === 0) { + // list won't compound if weight is zero + result.fields[field] = { + weight: typeWeights.scalar, + }; + } else if (isEnumType(listType) && typeWeights.scalar === 0) { + // list won't compound if weight of enum is zero + result.fields[field] = { + resolveTo: listType.toString().toLocaleLowerCase(), }; + } else { + fields[field].args.forEach((arg: GraphQLArgument) => { + // If field has an argument matching one of the limiting keywords and resolves to a list + // then the weight of the field should be dependent on both the weight of the resolved type and the limiting argument. + // FIXME: Can nonnull wrap list types? + if (KEYWORDS.includes(arg.name)) { + // Get the type that comprises the list + result.fields[field] = { + resolveTo: listType.toString().toLocaleLowerCase(), + weight: ( + args: ArgumentNode[], + variables: Variables, + selectionsCost: number + ): number => { + const limitArg: ArgumentNode | undefined = args.find( + (cur) => cur.name.value === arg.name + ); + const weight = isCompositeType(listType) + ? typeWeightObject[listType.name.toLowerCase()].weight + : typeWeights.scalar; // Note this includes enums + if (limitArg) { + const node: ValueNode = limitArg.value; + let multiplier = 1; + if (Kind.INT === node.kind) { + multiplier = Number(node.value || arg.defaultValue); + } + if (Kind.VARIABLE === node.kind) { + multiplier = Number( + variables[node.name.value] || arg.defaultValue + ); + } + return multiplier * (selectionsCost + weight); + // ? what else can get through here + } + // if there is no argument provided with the query, check the schema for a default + if (arg.defaultValue) { + return Number(arg.defaultValue) * (selectionsCost + weight); + } + + // FIXME: The list is unbounded. Return the object weight for + throw new Error( + `ERROR: buildTypeWeights: Unbouned list complexity not supported. Query results should be limited with ${KEYWORDS}` + ); + }, + }; + } + }); } - }); - - // if the field is a scalar or an enum set weight accordingly. It is not a list in this case - if (isScalarType(resolveType) || isEnumType(resolveType)) { - result.query.fields[field] = typeWeights.scalar || DEFAULT_SCALAR_WEIGHT; + } else { + // ? what else can get through here } }); + return result; } @@ -140,57 +169,38 @@ function parseQuery( * Parses all types in the provided schema object excempt for Query, Mutation * and built in types that begin with '__' and outputs a new TypeWeightObject * @param schema - * @param typeWeightObject * @param typeWeights * @returns */ -function parseTypes( - schema: GraphQLSchema, - typeWeightObject: TypeWeightObject, - typeWeights: TypeWeightConfig -): TypeWeightObject { +function parseTypes(schema: GraphQLSchema, typeWeights: TypeWeightSet): TypeWeightObject { const typeMap: ObjMap = schema.getTypeMap(); - const result: TypeWeightObject = { ...typeWeightObject }; + const result: TypeWeightObject = {}; // Handle Object, Interface, Enum and Union types Object.keys(typeMap).forEach((type) => { const typeName: string = type.toLowerCase(); - const currentType: GraphQLNamedType = typeMap[type]; + // Get all types that aren't Query or Mutation or a built in type that starts with '__' - if (type !== 'Query' && type !== 'Mutation' && !type.startsWith('__')) { + if (!type.startsWith('__')) { if (isObjectType(currentType) || isInterfaceType(currentType)) { // Add the type and it's associated fields to the result - result[typeName] = { - fields: {}, - weight: typeWeights.object || DEFAULT_OBJECT_WEIGHT, - }; - - const fields = currentType.getFields(); - - Object.keys(fields).forEach((field: string) => { - const fieldType: GraphQLOutputType = fields[field].type; - - // Only scalars are considered here any other types should be references from the top level of the type weight object. - if ( - isScalarType(fieldType) || - (isNonNullType(fieldType) && isScalarType(fieldType.ofType)) - ) { - result[typeName].fields[field] = - typeWeights.scalar || DEFAULT_SCALAR_WEIGHT; - } - }); + result[typeName] = parseObjectFields(currentType, result, typeWeights); } else if (isEnumType(currentType)) { result[typeName] = { fields: {}, - weight: typeWeights.scalar || DEFAULT_SCALAR_WEIGHT, + weight: typeWeights.scalar, }; } else if (isUnionType(currentType)) { + // FIXME: will need information on fields inorder calculate comlpextiy result[typeName] = { fields: {}, - weight: typeWeights.object || DEFAULT_OBJECT_WEIGHT, + weight: typeWeights.object, }; + } else { + // ? what else can get through here + // ? inputTypes? } } }); @@ -217,7 +227,7 @@ function buildTypeWeightsFromSchema( if (!schema) throw new Error('Missing Argument: schema is required'); // Merge the provided type weights with the default to account for missing values - const typeWeights: TypeWeightConfig = { + const typeWeights: TypeWeightSet = { ...defaultTypeWeightsConfig, ...typeWeightsConfig, }; @@ -229,8 +239,7 @@ function buildTypeWeightsFromSchema( } }); - const objectTypeWeights = parseTypes(schema, {}, typeWeights); - return parseQuery(schema, objectTypeWeights, typeWeights); + return parseTypes(schema, typeWeights); } export default buildTypeWeightsFromSchema; diff --git a/test/analysis/buildTypeWeights.test.ts b/test/analysis/buildTypeWeights.test.ts index a3f07c5..4a94e06 100644 --- a/test/analysis/buildTypeWeights.test.ts +++ b/test/analysis/buildTypeWeights.test.ts @@ -4,8 +4,13 @@ import { GraphQLSchema } from 'graphql/type/schema'; import buildTypeWeightsFromSchema from '../../src/analysis/buildTypeWeights'; // these types allow the tests to overwite properties on the typeWeightObject + +export interface TestField { + resolveTo?: string; + weight?: number; +} interface TestFields { - [index: string]: number; + [index: string]: TestField; } interface TestType { @@ -34,8 +39,8 @@ describe('Test buildTypeWeightsFromSchema function', () => { query: { weight: 1, fields: { - name: 0, - email: 0, + name: { weight: 0 }, + email: { weight: 0 }, }, }, }); @@ -60,20 +65,23 @@ describe('Test buildTypeWeightsFromSchema function', () => { expect(buildTypeWeightsFromSchema(schema)).toEqual({ query: { weight: 1, - fields: {}, + fields: { + movie: { resolveTo: 'movie' }, + user: { resolveTo: 'user' }, + }, }, user: { weight: 1, fields: { - name: 0, - email: 0, + name: { weight: 0 }, + email: { weight: 0 }, }, }, movie: { weight: 1, fields: { - name: 0, - director: 0, + name: { weight: 0 }, + director: { weight: 0 }, }, }, }); @@ -98,18 +106,23 @@ describe('Test buildTypeWeightsFromSchema function', () => { expect(buildTypeWeightsFromSchema(schema)).toEqual({ query: { weight: 1, - fields: {}, + fields: { + movie: { resolveTo: 'movie' }, + user: { resolveTo: 'user' }, + }, }, user: { weight: 1, fields: { - name: 0, + name: { weight: 0 }, + film: { resolveTo: 'movie' }, }, }, movie: { weight: 1, fields: { - name: 0, + name: { weight: 0 }, + director: { resolveTo: 'user' }, }, }, }); @@ -130,11 +143,11 @@ describe('Test buildTypeWeightsFromSchema function', () => { test: { weight: 1, fields: { - num: 0, - id: 0, - float: 0, - bool: 0, - string: 0, + num: { weight: 0 }, + id: { weight: 0 }, + float: { weight: 0 }, + bool: { weight: 0 }, + string: { weight: 0 }, }, }, }); @@ -152,13 +165,15 @@ describe('Test buildTypeWeightsFromSchema function', () => { expect(buildTypeWeightsFromSchema(schema)).toEqual({ query: { weight: 1, - fields: {}, + fields: { + character: { resolveTo: 'character' }, + }, }, character: { weight: 1, fields: { - id: 0, - name: 0, + id: { weight: 0 }, + name: { weight: 0 }, }, }, }); @@ -181,13 +196,15 @@ describe('Test buildTypeWeightsFromSchema function', () => { expect(buildTypeWeightsFromSchema(schema)).toEqual({ query: { weight: 1, - fields: {}, + fields: { + hero: { resolveTo: 'character' }, + }, }, character: { weight: 1, fields: { - id: 0, - name: 0, + id: { weight: 0 }, + name: { weight: 0 }, }, }, episode: { @@ -220,16 +237,26 @@ describe('Test buildTypeWeightsFromSchema function', () => { query: { weight: 1, fields: { - reviews: expect.any(Function), - heroes: expect.any(Function), - villains: expect.any(Function), + reviews: { + resolveTo: 'review', + weight: expect.any(Function), + }, + heroes: { + resolveTo: 'review', + weight: expect.any(Function), + }, + villains: { + resolveTo: 'review', + weight: expect.any(Function), + }, }, }, review: { weight: 1, fields: { - stars: 0, - commentary: 0, + stars: { weight: 0 }, + commentary: { weight: 0 }, + episode: { resolveTo: 'episode' }, }, }, episode: { @@ -239,7 +266,7 @@ describe('Test buildTypeWeightsFromSchema function', () => { }); }); - xtest('are not on the Query type', () => { + test('are not on the Query type', () => { schema = buildSchema(` type Query { reviews(episode: Episode!, first: Int): [Movie] @@ -264,22 +291,26 @@ describe('Test buildTypeWeightsFromSchema function', () => { query: { weight: 1, fields: { - reviews: expect.any(Function), + reviews: { + resolveTo: 'movie', + weight: expect.any(Function), + }, }, }, movie: { weight: 1, fields: { - stars: 0, - commentary: 0, - heroes: expect.any(Function), - villains: expect.any(Function), + stars: { weight: 0 }, + commentary: { weight: 0 }, + episode: { resolveTo: 'episode' }, + heroes: { resolveTo: 'character', weight: expect.any(Function) }, + villains: { resolveTo: 'character', weight: expect.any(Function) }, }, }, character: { weight: 1, fields: { - name: 0, + name: { weight: 0 }, }, }, episode: { @@ -289,7 +320,7 @@ describe('Test buildTypeWeightsFromSchema function', () => { }); }); - xtest('the list resolves to an enum or scalar', () => { + test('the list resolves to an enum or scalar', () => { schema = buildSchema(` type Query { episodes(first: Int): [Episode] @@ -306,9 +337,9 @@ describe('Test buildTypeWeightsFromSchema function', () => { query: { weight: 1, fields: { - episodes: 0, - heroes: 0, - villains: 0, + episodes: { resolveTo: 'episode' }, + heroes: { weight: 0 }, + villains: { weight: 0 }, }, }, episode: { @@ -318,7 +349,7 @@ describe('Test buildTypeWeightsFromSchema function', () => { }); }); - xtest('the list resolves to an enum or scalar and a custom scalar weight was configured', () => { + test('the list resolves to an enum or scalar and a custom scalar weight was configured', () => { schema = buildSchema(` type Query { episodes(first: Int): [Episode] @@ -335,13 +366,22 @@ describe('Test buildTypeWeightsFromSchema function', () => { query: { weight: 1, fields: { - episodes: 11, - heroes: 11, - villains: 11, + episodes: { + resolveTo: 'episode', + weight: expect.any(Function), + }, + heroes: { + resolveTo: 'int', + weight: expect.any(Function), + }, + villains: { + resolveTo: 'string', + weight: expect.any(Function), + }, }, }, episode: { - weight: 0, + weight: 11, fields: {}, }, }); @@ -363,7 +403,13 @@ describe('Test buildTypeWeightsFromSchema function', () => { human: { weight: 1, fields: { - friends: expect.any(Function), + id: { weight: 0 }, + name: { weight: 0 }, + hamePlanet: { weight: 0 }, + friends: { + resolvesTo: 'human', + weight: expect.any(Function), + }, }, }, }); @@ -391,24 +437,24 @@ describe('Test buildTypeWeightsFromSchema function', () => { character: { weight: 1, fields: { - id: 0, - name: 0, + id: { weight: 0 }, + name: { weight: 0 }, }, }, human: { weight: 1, fields: { - id: 0, - name: 0, - homePlanet: 0, + id: { weight: 0 }, + name: { weight: 0 }, + homePlanet: { weight: 0 }, }, }, droid: { weight: 1, fields: { - id: 0, - name: 0, - primaryFunction: 0, + id: { weight: 0 }, + name: { weight: 0 }, + primaryFunction: { weight: 0 }, }, }, }); @@ -431,13 +477,13 @@ describe('Test buildTypeWeightsFromSchema function', () => { human: { weight: 1, fields: { - homePlanet: 0, + homePlanet: { weight: 0 }, }, }, droid: { weight: 1, fields: { - primaryFunction: 0, + primaryFunction: { weight: 0 }, }, }, }); @@ -475,18 +521,23 @@ describe('Test buildTypeWeightsFromSchema function', () => { expectedOutput = { query: { weight: 1, - fields: {}, + fields: { + movie: { resolveTo: 'movie' }, + user: { resolveTo: 'user' }, + }, }, user: { weight: 1, fields: { - name: 0, + name: { weight: 0 }, + film: { resolveTo: 'movie' }, }, }, movie: { weight: 1, fields: { - name: 0, + name: { weight: 0 }, + director: { resolveTo: 'user' }, }, }, }; @@ -509,6 +560,19 @@ describe('Test buildTypeWeightsFromSchema function', () => { expectedOutput.user.weight = 2; expectedOutput.movie.weight = 2; + // expectedOutput.query.weight = 2; + + expect(typeWeightObject).toEqual(expectedOutput); + }); + + test('object parameter set to 0', () => { + const typeWeightObject = buildTypeWeightsFromSchema(schema, { + object: 0, + }); + + expectedOutput.user.weight = 0; + expectedOutput.movie.weight = 0; + // expectedOutput.query.weight = 2; expect(typeWeightObject).toEqual(expectedOutput); }); @@ -518,8 +582,8 @@ describe('Test buildTypeWeightsFromSchema function', () => { scalar: 2, }); - expectedOutput.user.fields.name = 2; - expectedOutput.movie.fields.name = 2; + expectedOutput.user.fields.name.weight = 2; + expectedOutput.movie.fields.name.weight = 2; expect(typeWeightObject).toEqual(expectedOutput); }); diff --git a/test/analysis/typeComplexityAnalysis.test.ts b/test/analysis/typeComplexityAnalysis.test.ts index dcafbe2..fa1d89f 100644 --- a/test/analysis/typeComplexityAnalysis.test.ts +++ b/test/analysis/typeComplexityAnalysis.test.ts @@ -64,7 +64,7 @@ import { TypeWeightObject, Variables } from '../../src/@types/buildTypeWeights'; type Test { name: String, - variable: Scalars + scalars: Scalars } * @@ -107,13 +107,29 @@ const typeWeights: TypeWeightObject = { // object type weight: 1, fields: { - reviews: mockWeightFunction, - hero: 1, - search: jest.fn(), // FIXME: Unbounded list result - character: 1, - droid: 1, - human: 1, - scalars: 1, + reviews: { + resolveTo: 'review', + weight: mockWeightFunction, + }, + hero: { + resolveTo: 'character', + }, + search: { + resolveTo: 'searchresult', + weight: jest.fn(), // FIXME: Unbounded list result + }, + character: { + resolveTo: 'character', + }, + droid: { + resolveTo: 'droid', + }, + human: { + resolveTo: 'human', + }, + scalars: { + resolveTo: 'scalars', + }, }, }, episode: { @@ -125,40 +141,55 @@ const typeWeights: TypeWeightObject = { // interface weight: 1, fields: { - id: 0, - name: 0, - // FIXME: add the function definition for the 'friends' field which returns a list + id: { weight: 0 }, + name: { weight: 0 }, + appearsIn: { resolveTo: 'episode' }, + friends: { + resolveTo: 'character', + weight: mockHumanFriendsFunction, + }, + scalarList: { + weight: 0, + }, }, }, human: { // implements an interface weight: 1, fields: { - id: 0, - name: 0, - homePlanet: 0, - appearsIn: 0, - friends: mockHumanFriendsFunction, + id: { weight: 0 }, + name: { weight: 0 }, + appearsIn: { resolveTo: 'episode' }, + homePlanet: { weight: 0 }, + friends: { + resolveTo: 'character', + weight: mockHumanFriendsFunction, + }, }, }, droid: { // implements an interface weight: 1, fields: { - id: 0, - name: 0, - appearsIn: 0, - friends: mockDroidFriendsFunction, + id: { weight: 0 }, + name: { weight: 0 }, + appearsIn: { resolveTo: 'episode' }, + primaryFunction: { weight: 0 }, + friends: { + resolveTo: 'character', + weight: mockDroidFriendsFunction, + }, }, }, review: { weight: 1, fields: { - stars: 0, - commentary: 0, + episode: { resolveTo: 'episode' }, + stars: { weight: 0 }, + commentary: { weight: 0 }, }, }, - searchResult: { + searchresult: { // union type weight: 1, fields: {}, @@ -166,17 +197,19 @@ const typeWeights: TypeWeightObject = { scalars: { weight: 1, // object weight is 1, all scalar feilds have weight 0 fields: { - num: 0, - id: 0, - float: 0, - bool: 0, - string: 0, + num: { weight: 0 }, + id: { weight: 0 }, + float: { weight: 0 }, + bool: { weight: 0 }, + string: { weight: 0 }, + test: { resolveTo: 'test' }, }, }, test: { weight: 1, fields: { - name: 0, + name: { weight: 0 }, + scalars: { resolveTo: 'scalars' }, }, }, }; @@ -195,6 +228,11 @@ describe('Test getQueryTypeComplexity function', () => { expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + Scalars 1 }); + xtest('with one with capital first letter for field', () => { + query = `query { Scalars { num } }`; + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + Scalars 1 + }); + test('with two or more fields', () => { query = `query { scalars { num } test { name } }`; expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(3); // Query 1 + scalars 1 + test 1 @@ -285,51 +323,30 @@ describe('Test getQueryTypeComplexity function', () => { mockWeightFunction.mockReturnValueOnce(3); expect(getQueryTypeComplexity(parse(query), {}, typeWeights)).toBe(4); // 1 Query + 3 reviews expect(mockWeightFunction.mock.calls.length).toBe(1); - expect(mockWeightFunction.mock.calls[0].length).toBe(1); + expect(mockWeightFunction.mock.calls[0].length).toBe(3); // calling with arguments and variables variables = { first: 4 }; mockWeightFunction.mockReturnValueOnce(4); query = `query queryVariables($first: Int) {reviews(episode: EMPIRE, first: $first) { stars, commentary } }`; expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(5); // 1 Query + 4 reviews expect(mockWeightFunction.mock.calls.length).toBe(2); - expect(mockWeightFunction.mock.calls[1].length).toBe(1); + expect(mockWeightFunction.mock.calls[1].length).toBe(3); // calling with arguments and variables }); - xdescribe('with nested lists', () => { + describe('with nested lists', () => { test('and simple nesting', () => { - query = ` - query { - human(id: 1) { - name, - friends(first: 5) { - name, - friends(first: 3){ - name - } - } - } - }`; - mockHumanFriendsFunction.mockReturnValueOnce(5).mockReturnValueOnce(3); - expect(getQueryTypeComplexity(parse(query), {}, typeWeights)).toBe(17); // 1 Query + 1 human/character + (5 friends/character X 3 friends/characters) + query = `query { human(id: 1) { name, friends(first: 5) { name, friends(first: 3){ name }}}} `; + mockHumanFriendsFunction.mockReturnValueOnce(3).mockReturnValueOnce(20); + expect(getQueryTypeComplexity(parse(query), {}, typeWeights)).toBe(22); // 1 Query + 1 human/character + (5 friends/character X (1 friend + 3 friends/characters)) expect(mockHumanFriendsFunction.mock.calls.length).toBe(2); }); test('and inner scalar lists', () => { query = ` - query { - human(id: 1) { - name, - friends(first: 5) { - name, - scalarList(first: 3){ - name - } - } - } - }`; + query { human(id: 1) { name, friends(first: 5) { name, scalarList(first: 3)} }}`; mockHumanFriendsFunction.mockReturnValueOnce(5); expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(7); // 1 Query + 1 human/character + 5 friends/character + 0 scalarList - expect(mockHumanFriendsFunction.mock.calls.length).toBe(2); + expect(mockHumanFriendsFunction.mock.calls.length).toBe(1); }); }); diff --git a/test/analysis/weightFunction.test.ts b/test/analysis/weightFunction.test.ts index 15b943f..cac1a47 100644 --- a/test/analysis/weightFunction.test.ts +++ b/test/analysis/weightFunction.test.ts @@ -25,19 +25,23 @@ describe('Weight Function correctly parses Argument Nodes if', () => { episode: Episode stars: Int! commentary: String + scalarList(last: Int): [Int] + objectList(first: Int): [Object] + } + type Object { + hi: String } enum Episode { NEWHOPE EMPIRE JEDI }`); - // building the typeWeights object here since we're testing the weight function created in // the typeWeights object const typeWeights: TypeWeightObject = buildTypeWeightsFromSchema(schema); describe('a default value is provided in the schema', () => { - xtest('and a value is not provided with the query', () => { + test('and a value is not provided with the query', () => { const query = `query { reviews(episode: NEWHOPE) { stars, episode } }`; const queryAST: DocumentNode = parse(query); expect(getQueryTypeComplexity(queryAST, {}, typeWeights)).toBe(6); @@ -49,7 +53,7 @@ describe('Weight Function correctly parses Argument Nodes if', () => { expect(getQueryTypeComplexity(queryAST, {}, typeWeights)).toBe(4); }); - xtest('and the argument is passed in as a variable', () => { + test('and the argument is passed in as a variable', () => { const query = `query variableQuery ($items: Int){ reviews(episode: NEWHOPE, first: $items) { stars, episode } }`; const queryAST: DocumentNode = parse(query); expect(getQueryTypeComplexity(queryAST, { items: 7, first: 4 }, typeWeights)).toBe(8); @@ -71,7 +75,7 @@ describe('Weight Function correctly parses Argument Nodes if', () => { expect(getQueryTypeComplexity(queryAST, {}, typeWeights)).toBe(4); }); - xtest('and the argument is passed in as a variable', () => { + test('and the argument is passed in as a variable', () => { const query = `query variableQuery ($items: Int){ heroes(episode: NEWHOPE, first: $items) { stars, episode } }`; const queryAST: DocumentNode = parse(query); expect(getQueryTypeComplexity(queryAST, { items: 7 }, typeWeights)).toBe(8); @@ -87,12 +91,47 @@ describe('Weight Function correctly parses Argument Nodes if', () => { expect(getQueryTypeComplexity(queryAST, {}, customTypeWeights)).toBe(10); }); + test('a custom object weight was set to 0', () => { + const customTypeWeights: TypeWeightObject = buildTypeWeightsFromSchema(schema, { + object: 0, + }); + const query = `query { heroes(episode: NEWHOPE, first: 3) { stars, episode } }`; + const queryAST: DocumentNode = parse(query); + expect(getQueryTypeComplexity(queryAST, {}, customTypeWeights)).toBe(1); // 1 query + }); + test('a custom scalar weight was set to greater than 0', () => { + const customTypeWeights: TypeWeightObject = buildTypeWeightsFromSchema(schema, { + scalar: 2, + }); + const query = `query { heroes(episode: NEWHOPE, first: 3) { stars, episode } }`; + const queryAST: DocumentNode = parse(query); + expect(getQueryTypeComplexity(queryAST, {}, customTypeWeights)).toBe(16); + }); + test('variable names matching limiting keywords do not interfere with scalar argument values', () => { const query = `query variableQuery ($items: Int){ heroes(episode: NEWHOPE, first: 3) { stars, episode } }`; const queryAST: DocumentNode = parse(query); expect(getQueryTypeComplexity(queryAST, { first: 7 }, typeWeights)).toBe(4); }); + test('nested queries with lists', () => { + const query = `query { reviews(episode: NEWHOPE, first: 2) {stars, objectList(first: 3) {hi}}} `; + expect(getQueryTypeComplexity(parse(query), {}, typeWeights)).toBe(9); // 1 Query + 2 review + (2 * 3 objects) + }); + + test('queries with inner scalar lists', () => { + const query = `query { reviews(episode: NEWHOPE, first: 2) {stars, scalarList(last: 3) }}`; + expect(getQueryTypeComplexity(parse(query), {}, typeWeights)).toBe(3); // 1 Query + 2 reviews + }); + + test('queries with inner scalar lists and custom scalar weight greater than 0', () => { + const customTypeWeights: TypeWeightObject = buildTypeWeightsFromSchema(schema, { + scalar: 2, + }); + const query = `query { reviews(episode: NEWHOPE, first: 2) {stars, scalarList(last: 3) }}`; + expect(getQueryTypeComplexity(parse(query), {}, customTypeWeights)).toBe(19); // 1 Query + 2 reviews + 2 * (2 stars + (3 * 2 scalarList) + }); + xtest('an invalid arg type is provided', () => { const query = `query { heroes(episode: NEWHOPE, first = 3) { stars, episode } }`; const queryAST: DocumentNode = parse(query);