From fa3094b63098b53907c68d0d5f5258ec24824c61 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sat, 9 Jul 2022 16:18:50 -0400 Subject: [PATCH 01/23] preliminary fragment support. Refactored ASTNodeFunctions into ASTParser class to store fragment complexity in closure --- src/analysis/ASTnodefunctions.ts | 263 +++++++++++++++---------- src/analysis/typeComplexityAnalysis.ts | 5 +- 2 files changed, 162 insertions(+), 106 deletions(-) diff --git a/src/analysis/ASTnodefunctions.ts b/src/analysis/ASTnodefunctions.ts index 00655d9..c5851be 100644 --- a/src/analysis/ASTnodefunctions.ts +++ b/src/analysis/ASTnodefunctions.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-use-before-define */ import { DocumentNode, FieldNode, @@ -6,9 +5,8 @@ import { DefinitionNode, Kind, SelectionNode, - isConstValueNode, } from 'graphql'; -import { FieldWeight, TypeWeightObject, Variables } from '../@types/buildTypeWeights'; +import { TypeWeightObject, Variables } from '../@types/buildTypeWeights'; /** * The AST node functions call each other following the nested structure below * Each function handles a specific GraphQL AST node type @@ -29,120 +27,177 @@ import { FieldWeight, TypeWeightObject, Variables } from '../@types/buildTypeWei * */ -export function fieldNode( - node: FieldNode, - typeWeights: TypeWeightObject, - variables: Variables, - parentName: string -): number { - let complexity = 0; - // '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; +class ASTParser { + fragmentCache: { [index: string]: number }; - 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) { - selectionsCost += selectionSetNode( - node.selectionSet, - typeWeights, - variables, - 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 { - // field is a scalar and 'weight' is a number - const { weight } = typeWeights[parentName].fields[node.name.value]; - if (typeof weight === 'number') { - complexity += weight; - } + constructor() { + this.fragmentCache = {}; } - return complexity; -} -export function selectionNode( - node: SelectionNode, - typeWeights: TypeWeightObject, - variables: Variables, - parentName: string -): number { - let complexity = 0; - // 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 - complexity += fieldNode(node, typeWeights, variables, parentName); - } - // TODO: add checks for Kind.FRAGMENT_SPREAD and Kind.INLINE_FRAGMENT here - return complexity; -} -export function selectionSetNode( - node: SelectionSetNode, - typeWeights: TypeWeightObject, - variables: Variables, - parentName: string -): number { - let complexity = 0; - // iterate shrough the 'selections' array on the seletion set node - for (let i = 0; i < node.selections.length; i += 1) { - // call the function to handle seletion nodes - // pass the current parent through because selection sets act only as intermediaries - complexity += selectionNode(node.selections[i], typeWeights, variables, parentName); - } - return complexity; -} + fieldNode( + node: FieldNode, + typeWeights: TypeWeightObject, + variables: Variables, + parentName: string + ): number { + let complexity = 0; + // '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; -export function definitionNode( - node: DefinitionNode, - typeWeights: TypeWeightObject, - variables: Variables -): number { - let complexity = 0; - // check the kind property against the set of definiton nodes that are possible - if (node.kind === Kind.OPERATION_DEFINITION) { - // check if the operation is in the type weights object. - 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) { - complexity += selectionSetNode( + selectionsCost += this.selectionSetNode( node.selectionSet, typeWeights, variables, - node.operation + 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 { + // 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; } - // 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') - return complexity; -} -export function documentNode( - node: DocumentNode, - typeWeights: TypeWeightObject, - variables: Variables -): number { - let complexity = 0; - // iterate through 'definitions' array on the document node - for (let i = 0; i < node.definitions.length; i += 1) { - // call the function to handle the various types of definition nodes - complexity += definitionNode(node.definitions[i], typeWeights, variables); + selectionNode( + node: SelectionNode, + typeWeights: TypeWeightObject, + variables: Variables, + parentName: string + ): number { + let complexity = 0; + // 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 + complexity += this.fieldNode(node, typeWeights, variables, parentName); + } else if (node.kind === Kind.FRAGMENT_SPREAD) { + complexity += this.fragmentCache[node.name.value]; + // This is a leaf + // need to parse fragment definition at root and get the result here + } + // TODO: add checks for Kind.FRAGMENT_SPREAD and Kind.INLINE_FRAGMENT here + return complexity; + } + + selectionSetNode( + node: SelectionSetNode, + typeWeights: TypeWeightObject, + variables: Variables, + parentName: string + ): number { + let complexity = 0; + // iterate shrough the 'selections' array on the seletion set node + for (let i = 0; i < node.selections.length; i += 1) { + // call the function to handle seletion nodes + // pass the current parent through because selection sets act only as intermediaries + complexity += this.selectionNode( + node.selections[i], + typeWeights, + variables, + parentName + ); + } + return complexity; + } + + definitionNode( + node: DefinitionNode, + typeWeights: TypeWeightObject, + variables: Variables + ): number { + // TODO: this is initialized with every call. Can we initialize per request + // This needs to be cleared at the end of each request + // Can we setup a callback or listener? + const fragments: { [index: string]: number } = {}; + + let complexity = 0; + // check the kind property against the set of definiton nodes that are possible + if (node.kind === Kind.OPERATION_DEFINITION) { + // check if the operation is in the type weights object. + 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) { + complexity += this.selectionSetNode( + node.selectionSet, + typeWeights, + variables, + node.operation + ); + } + } + } else if (node.kind === Kind.FRAGMENT_DEFINITION) { + // Fragments can only be defined on the root type. + // Parse the complexity of this fragment and store it for use when analyzing other + // nodes. Only need to parse fragment complexity once + // When analyzing the complexity of a query using a fragment the complexity of the + // fragment should be added to the selection cost for the query. + + // interface FragmentDefinitionNode { + // readonly kind: Kind.FRAGMENT_DEFINITION; + // readonly loc?: Location; + // readonly name: NameNode; + // /** @deprecated variableDefinitions will be removed in v17.0.0 */ + // readonly variableDefinitions?: ReadonlyArray; + // readonly typeCondition: NamedTypeNode; + // readonly directives?: ReadonlyArray; + // readonly selectionSet: SelectionSetNode; + // } + // TODO: Handle variables or at least add tests for fragments containing variables + const namedType = node.typeCondition.name.value; + // Duplicate fragment names are now allowed by the GrapQL spec and an error is thrown if used. + const fragmentName = node.name.value; + if (this.fragmentCache[fragmentName]) return this.fragmentCache[fragmentName]; + + const fragmentComplexity = this.selectionSetNode( + node.selectionSet, + typeWeights, + variables, + namedType.toLowerCase() + ); + this.fragmentCache[fragmentName] = fragmentComplexity; + return complexity; // 0. Don't count complexity here. Only when fragment is used. + } + // TODO: Verify that are no other type definition nodes that need to be handled (see ast.d.ts in 'graphql') + return complexity; + } + + documentNode(node: DocumentNode, typeWeights: TypeWeightObject, variables: Variables): number { + let complexity = 0; + // iterate through 'definitions' array on the document node + // FIXME: create a copy to preserve original AST order if needed elsewhere + const sortedDefinitions = [...node.definitions].sort((a, b) => + a.kind.localeCompare(b.kind) + ); + for (let i = 0; i < sortedDefinitions.length; i += 1) { + // call the function to handle the various types of definition nodes + // FIXME: Need to parse fragment definitions first so that remaining complexity has access to query complexities + complexity += this.definitionNode(sortedDefinitions[i], typeWeights, variables); + } + return complexity; } - return complexity; } + +export default ASTParser; diff --git a/src/analysis/typeComplexityAnalysis.ts b/src/analysis/typeComplexityAnalysis.ts index 6db00d7..5e196fe 100644 --- a/src/analysis/typeComplexityAnalysis.ts +++ b/src/analysis/typeComplexityAnalysis.ts @@ -1,6 +1,6 @@ import { DocumentNode } from 'graphql'; import { TypeWeightObject, Variables } from '../@types/buildTypeWeights'; -import { documentNode } from './ASTnodefunctions'; +import ASTParser from './ASTnodefunctions'; /** * Calculate the complexity for the query by recursivly traversing through the query AST, @@ -18,7 +18,8 @@ function getQueryTypeComplexity( typeWeights: TypeWeightObject ): number { let complexity = 0; - complexity += documentNode(queryAST, typeWeights, variables); + const parser = new ASTParser(); + complexity += parser.documentNode(queryAST, typeWeights, variables); return complexity; } From acebc1c746d97c90b17485f7fc761df88d39b641 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sun, 10 Jul 2022 11:28:25 -0400 Subject: [PATCH 02/23] added typeWeight and variable parameters to ASTParser constructor --- src/analysis/ASTnodefunctions.ts | 89 +++++++------------- src/analysis/typeComplexityAnalysis.ts | 4 +- test/analysis/typeComplexityAnalysis.test.ts | 2 +- 3 files changed, 32 insertions(+), 63 deletions(-) diff --git a/src/analysis/ASTnodefunctions.ts b/src/analysis/ASTnodefunctions.ts index c5851be..1812409 100644 --- a/src/analysis/ASTnodefunctions.ts +++ b/src/analysis/ASTnodefunctions.ts @@ -28,50 +28,50 @@ import { TypeWeightObject, Variables } from '../@types/buildTypeWeights'; */ class ASTParser { + typeWeights: TypeWeightObject; + + variables: Variables; + fragmentCache: { [index: string]: number }; - constructor() { + constructor(typeWeights: TypeWeightObject, variables: Variables) { + this.typeWeights = typeWeights; + this.variables = variables; this.fragmentCache = {}; } - fieldNode( - node: FieldNode, - typeWeights: TypeWeightObject, - variables: Variables, - parentName: string - ): number { + fieldNode(node: FieldNode, parentName: string): number { let complexity = 0; // 'resolvedTypeName' is the name of the Schema Type that this field resolves to const resolvedTypeName = - node.name.value in typeWeights + node.name.value in this.typeWeights ? node.name.value - : typeWeights[parentName].fields[node.name.value]?.resolveTo || null; + : this.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; + const weightFunction = this.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) { - selectionsCost += this.selectionSetNode( - node.selectionSet, - typeWeights, - variables, - resolvedTypeName - ); + selectionsCost += this.selectionSetNode(node.selectionSet, 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); + calculatedWeight += weightFunction( + [...node.arguments], + this.variables, + selectionsCost + ); } else { - calculatedWeight += typeWeights[resolvedTypeName].weight + selectionsCost; + calculatedWeight += this.typeWeights[resolvedTypeName].weight + selectionsCost; } complexity += calculatedWeight; } else { // field is a scalar and 'weight' is a number - const { weight } = typeWeights[parentName].fields[node.name.value]; + const { weight } = this.typeWeights[parentName].fields[node.name.value]; if (typeof weight === 'number') { complexity += weight; } @@ -79,17 +79,12 @@ class ASTParser { return complexity; } - selectionNode( - node: SelectionNode, - typeWeights: TypeWeightObject, - variables: Variables, - parentName: string - ): number { + selectionNode(node: SelectionNode, parentName: string): number { let complexity = 0; // 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 - complexity += this.fieldNode(node, typeWeights, variables, parentName); + complexity += this.fieldNode(node, parentName); } else if (node.kind === Kind.FRAGMENT_SPREAD) { complexity += this.fragmentCache[node.name.value]; // This is a leaf @@ -99,53 +94,29 @@ class ASTParser { return complexity; } - selectionSetNode( - node: SelectionSetNode, - typeWeights: TypeWeightObject, - variables: Variables, - parentName: string - ): number { + selectionSetNode(node: SelectionSetNode, parentName: string): number { let complexity = 0; // iterate shrough the 'selections' array on the seletion set node for (let i = 0; i < node.selections.length; i += 1) { // call the function to handle seletion nodes // pass the current parent through because selection sets act only as intermediaries - complexity += this.selectionNode( - node.selections[i], - typeWeights, - variables, - parentName - ); + complexity += this.selectionNode(node.selections[i], parentName); } return complexity; } - definitionNode( - node: DefinitionNode, - typeWeights: TypeWeightObject, - variables: Variables - ): number { - // TODO: this is initialized with every call. Can we initialize per request - // This needs to be cleared at the end of each request - // Can we setup a callback or listener? - const fragments: { [index: string]: number } = {}; - + definitionNode(node: DefinitionNode): number { let complexity = 0; // check the kind property against the set of definiton nodes that are possible if (node.kind === Kind.OPERATION_DEFINITION) { // check if the operation is in the type weights object. - if (node.operation.toLocaleLowerCase() in typeWeights) { + if (node.operation.toLocaleLowerCase() in this.typeWeights) { // if it is, it is an object type, add it's type weight to the total - complexity += typeWeights[node.operation].weight; + complexity += this.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) { - complexity += this.selectionSetNode( - node.selectionSet, - typeWeights, - variables, - node.operation - ); + complexity += this.selectionSetNode(node.selectionSet, node.operation); } } } else if (node.kind === Kind.FRAGMENT_DEFINITION) { @@ -173,8 +144,6 @@ class ASTParser { const fragmentComplexity = this.selectionSetNode( node.selectionSet, - typeWeights, - variables, namedType.toLowerCase() ); this.fragmentCache[fragmentName] = fragmentComplexity; @@ -184,7 +153,7 @@ class ASTParser { return complexity; } - documentNode(node: DocumentNode, typeWeights: TypeWeightObject, variables: Variables): number { + documentNode(node: DocumentNode): number { let complexity = 0; // iterate through 'definitions' array on the document node // FIXME: create a copy to preserve original AST order if needed elsewhere @@ -194,7 +163,7 @@ class ASTParser { for (let i = 0; i < sortedDefinitions.length; i += 1) { // call the function to handle the various types of definition nodes // FIXME: Need to parse fragment definitions first so that remaining complexity has access to query complexities - complexity += this.definitionNode(sortedDefinitions[i], typeWeights, variables); + complexity += this.definitionNode(sortedDefinitions[i]); } return complexity; } diff --git a/src/analysis/typeComplexityAnalysis.ts b/src/analysis/typeComplexityAnalysis.ts index 5e196fe..366d4b6 100644 --- a/src/analysis/typeComplexityAnalysis.ts +++ b/src/analysis/typeComplexityAnalysis.ts @@ -18,8 +18,8 @@ function getQueryTypeComplexity( typeWeights: TypeWeightObject ): number { let complexity = 0; - const parser = new ASTParser(); - complexity += parser.documentNode(queryAST, typeWeights, variables); + const parser = new ASTParser(typeWeights, variables); + complexity += parser.documentNode(queryAST); return complexity; } diff --git a/test/analysis/typeComplexityAnalysis.test.ts b/test/analysis/typeComplexityAnalysis.test.ts index b532633..5c5446d 100644 --- a/test/analysis/typeComplexityAnalysis.test.ts +++ b/test/analysis/typeComplexityAnalysis.test.ts @@ -280,7 +280,7 @@ describe('Test getQueryTypeComplexity function', () => { expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + hero/character 1 }); - xdescribe('with fragments', () => { + describe('with fragments', () => { test('that have a complexity of zero', () => { query = ` query { From 7498ba1bf8818d4a434cc55c8c008be26ab0cf1d Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sun, 10 Jul 2022 15:05:33 -0400 Subject: [PATCH 03/23] added FRAGMENT_DEFINITION support. refactored fieldNode to for error clariry --- src/analysis/ASTnodefunctions.ts | 141 ++++++++++++++++++++----------- 1 file changed, 90 insertions(+), 51 deletions(-) diff --git a/src/analysis/ASTnodefunctions.ts b/src/analysis/ASTnodefunctions.ts index 1812409..45fb929 100644 --- a/src/analysis/ASTnodefunctions.ts +++ b/src/analysis/ASTnodefunctions.ts @@ -6,7 +6,7 @@ import { Kind, SelectionNode, } from 'graphql'; -import { TypeWeightObject, Variables } from '../@types/buildTypeWeights'; +import { FieldWeight, TypeWeightObject, Variables } from '../@types/buildTypeWeights'; /** * The AST node functions call each other following the nested structure below * Each function handles a specific GraphQL AST node type @@ -40,40 +40,83 @@ class ASTParser { this.fragmentCache = {}; } + private calculateCost( + node: FieldNode, + parentName: string, + typeName: string, + typeWeight: FieldWeight + ) { + let complexity = 0; + // field resolves to an object or a list with possible selections + let selectionsCost = 0; + let calculatedWeight = 0; + + // call the function to handle selection set node with selectionSet property if it is not undefined + if (node.selectionSet) { + selectionsCost += this.selectionSetNode(node.selectionSet, typeName); + } + // 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 typeWeight === 'function') { + // FIXME: May never happen but what if weight is a function and arguments don't exist + calculatedWeight += typeWeight([...node.arguments], this.variables, selectionsCost); + } else { + calculatedWeight += this.typeWeights[typeName].weight + selectionsCost; + } + complexity += calculatedWeight; + + return complexity; + } + fieldNode(node: FieldNode, parentName: string): number { let complexity = 0; - // 'resolvedTypeName' is the name of the Schema Type that this field resolves to - const resolvedTypeName = - node.name.value in this.typeWeights - ? node.name.value - : this.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 = this.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) { - selectionsCost += this.selectionSetNode(node.selectionSet, 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], - this.variables, - selectionsCost - ); + const parentType = this.typeWeights[parentName]; + if (!parentType) { + throw new Error( + `ERROR: ASTParser Failed to obtain parentType for parent: ${parentName} and node: ${node.name.value}` + ); + } + let typeName: string | undefined; + let typeWeight: FieldWeight | undefined; + + if (node.name.value in this.typeWeights) { + // node is an object type n the typeWeight root + typeName = node.name.value; + typeWeight = this.typeWeights[typeName].weight; + complexity += this.calculateCost(node, parentName, typeName, typeWeight); + } else if (parentType.fields[node.name.value].resolveTo) { + // field resolves to another type in type weights or a list + typeName = parentType.fields[node.name.value].resolveTo; + typeWeight = parentType.fields[node.name.value].weight; + // if this is a list typeWeight is a weight function + // otherwise the weight would be null as the weight is defined on the typeWeights root + if (typeName && typeWeight) { + // Type is a list and has a weight function + complexity += this.calculateCost(node, parentName, typeName, typeWeight); + } else if (typeName) { + // resolve type exists at root of typeWeight object and is not a list + typeWeight = this.typeWeights[typeName].weight; + complexity += this.calculateCost(node, parentName, typeName, typeWeight); } else { - calculatedWeight += this.typeWeights[resolvedTypeName].weight + selectionsCost; + throw new Error( + `ERROR: ASTParser Failed to obtain resolved type name or weight for node: ${parentName}.${node.name.value}` + ); } - complexity += calculatedWeight; } else { - // field is a scalar and 'weight' is a number - const { weight } = this.typeWeights[parentName].fields[node.name.value]; - if (typeof weight === 'number') { - complexity += weight; + // field is a scalar + typeName = node.name.value; + if (typeName) { + typeWeight = this.typeWeights[parentName].fields[typeName].weight; + if (typeof typeWeight === 'number') { + complexity += typeWeight; + } else { + throw new Error( + `ERROR: ASTParser Failed to obtain type weight for ${parentName}.${node.name.value}` + ); + } + } else { + throw new Error( + `ERROR: ASTParser Else Failed to obtain type name for ${parentName}.${node.name.value}` + ); } } return complexity; @@ -84,13 +127,17 @@ class ASTParser { // 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 - complexity += this.fieldNode(node, parentName); + complexity += this.fieldNode(node, parentName.toLowerCase()); } else if (node.kind === Kind.FRAGMENT_SPREAD) { complexity += this.fragmentCache[node.name.value]; // This is a leaf // need to parse fragment definition at root and get the result here + } else if (node.kind === Kind.INLINE_FRAGMENT) { + throw new Error('ERROR: ASTParser.selectionNode: INLINE_FRAGMENTS not supported'); + } else { + // FIXME: Consider removing this check. SelectionNodes cannot have any other kind in the current spec. + throw new Error(`ERROR: ASTParser.selectionNode: node type not supported`); } - // TODO: add checks for Kind.FRAGMENT_SPREAD and Kind.INLINE_FRAGMENT here return complexity; } @@ -121,35 +168,27 @@ class ASTParser { } } else if (node.kind === Kind.FRAGMENT_DEFINITION) { // Fragments can only be defined on the root type. - // Parse the complexity of this fragment and store it for use when analyzing other - // nodes. Only need to parse fragment complexity once - // When analyzing the complexity of a query using a fragment the complexity of the - // fragment should be added to the selection cost for the query. - - // interface FragmentDefinitionNode { - // readonly kind: Kind.FRAGMENT_DEFINITION; - // readonly loc?: Location; - // readonly name: NameNode; - // /** @deprecated variableDefinitions will be removed in v17.0.0 */ - // readonly variableDefinitions?: ReadonlyArray; - // readonly typeCondition: NamedTypeNode; - // readonly directives?: ReadonlyArray; - // readonly selectionSet: SelectionSetNode; - // } - // TODO: Handle variables or at least add tests for fragments containing variables + // Parse the complexity of this fragment once and store it for use when analyzing other + // nodes. The complexity of a fragment can be added to the selection cost for the query. const namedType = node.typeCondition.name.value; - // Duplicate fragment names are now allowed by the GrapQL spec and an error is thrown if used. + // Duplicate fragment names are not allowed by the GraphQL spec and an error is thrown if used. const fragmentName = node.name.value; + if (this.fragmentCache[fragmentName]) return this.fragmentCache[fragmentName]; const fragmentComplexity = this.selectionSetNode( node.selectionSet, namedType.toLowerCase() ); + this.fragmentCache[fragmentName] = fragmentComplexity; - return complexity; // 0. Don't count complexity here. Only when fragment is used. + return complexity; // Don't count complexity here. Only when fragment is used. + } else { + // TODO: Verify that are no other type definition nodes that need to be handled (see ast.d.ts in 'graphql') + // Other types include TypeSystemDefinitionNode (Schema, Type, Directvie) and + // TypeSystemExtensionNode(Schema, Type); + throw new Error(`ERROR: ASTParser.definitionNode: ${node.kind} type not supported`); } - // TODO: Verify that are no other type definition nodes that need to be handled (see ast.d.ts in 'graphql') return complexity; } From ee2ba0bdd7e4b6d68b27f78f5ddec2d787cf215f Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sun, 10 Jul 2022 15:23:10 -0400 Subject: [PATCH 04/23] preliminary suppor for inline fragments on interfaces --- src/analysis/ASTnodefunctions.ts | 24 ++++++++++++++++++-- test/analysis/typeComplexityAnalysis.test.ts | 4 ++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/analysis/ASTnodefunctions.ts b/src/analysis/ASTnodefunctions.ts index 45fb929..f17cbe0 100644 --- a/src/analysis/ASTnodefunctions.ts +++ b/src/analysis/ASTnodefunctions.ts @@ -5,6 +5,7 @@ import { DefinitionNode, Kind, SelectionNode, + NamedTypeNode, } from 'graphql'; import { FieldWeight, TypeWeightObject, Variables } from '../@types/buildTypeWeights'; /** @@ -115,7 +116,7 @@ class ASTParser { } } else { throw new Error( - `ERROR: ASTParser Else Failed to obtain type name for ${parentName}.${node.name.value}` + `ERROR: ASTParser Failed to obtain type name for ${parentName}.${node.name.value}` ); } } @@ -133,7 +134,26 @@ class ASTParser { // This is a leaf // need to parse fragment definition at root and get the result here } else if (node.kind === Kind.INLINE_FRAGMENT) { - throw new Error('ERROR: ASTParser.selectionNode: INLINE_FRAGMENTS not supported'); + // export interface InlineFragmentNode { + // readonly kind: Kind.INLINE_FRAGMENT; + // readonly loc?: Location; + // readonly typeCondition?: NamedTypeNode; + // readonly directives?: ReadonlyArray; + // readonly selectionSet: SelectionSetNode; + // } + + // FIXME: When would typeConditoin not be present? + const { typeCondition } = node; + if (!typeCondition) { + throw new Error( + 'ERROR: ASTParser.selectionNode: Inline Fragment missing type condition' + ); + } + // named type is the type from which inner fields should be take + complexity += this.selectionSetNode( + node.selectionSet, + typeCondition.name.value.toLowerCase() + ); } else { // FIXME: Consider removing this check. SelectionNodes cannot have any other kind in the current spec. throw new Error(`ERROR: ASTParser.selectionNode: node type not supported`); diff --git a/test/analysis/typeComplexityAnalysis.test.ts b/test/analysis/typeComplexityAnalysis.test.ts index ae642df..5bbd506 100644 --- a/test/analysis/typeComplexityAnalysis.test.ts +++ b/test/analysis/typeComplexityAnalysis.test.ts @@ -405,8 +405,8 @@ describe('Test getQueryTypeComplexity function', () => { }); }); - xdescribe('with inline fragments', () => { - describe('on union types', () => { + describe('with inline fragments', () => { + xdescribe('on union types', () => { test('that have a complexity of zero', () => { query = ` query { From fca1a985b5ce5dead944ec5daa0ce1da2253929b Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sun, 10 Jul 2022 15:37:40 -0400 Subject: [PATCH 05/23] accounted for case if inline fragment is missing type condition --- src/analysis/ASTnodefunctions.ts | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/src/analysis/ASTnodefunctions.ts b/src/analysis/ASTnodefunctions.ts index f17cbe0..6b1ff34 100644 --- a/src/analysis/ASTnodefunctions.ts +++ b/src/analysis/ASTnodefunctions.ts @@ -134,26 +134,14 @@ class ASTParser { // This is a leaf // need to parse fragment definition at root and get the result here } else if (node.kind === Kind.INLINE_FRAGMENT) { - // export interface InlineFragmentNode { - // readonly kind: Kind.INLINE_FRAGMENT; - // readonly loc?: Location; - // readonly typeCondition?: NamedTypeNode; - // readonly directives?: ReadonlyArray; - // readonly selectionSet: SelectionSetNode; - // } - - // FIXME: When would typeConditoin not be present? const { typeCondition } = node; - if (!typeCondition) { - throw new Error( - 'ERROR: ASTParser.selectionNode: Inline Fragment missing type condition' - ); - } + // named type is the type from which inner fields should be take - complexity += this.selectionSetNode( - node.selectionSet, - typeCondition.name.value.toLowerCase() - ); + // If the TypeCondition is omitted, an inline fragment is considered to be of the same type as the enclosing context + const namedType = typeCondition ? typeCondition.name.value.toLowerCase() : parentName; + + // TODO: Handle directives like @include + complexity += this.selectionSetNode(node.selectionSet, namedType); } else { // FIXME: Consider removing this check. SelectionNodes cannot have any other kind in the current spec. throw new Error(`ERROR: ASTParser.selectionNode: node type not supported`); From e4f6a0bcac6b2192fd15b86c41743211565f3bf1 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sun, 10 Jul 2022 17:10:54 -0400 Subject: [PATCH 06/23] inline fragment test correction --- test/analysis/typeComplexityAnalysis.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/analysis/typeComplexityAnalysis.test.ts b/test/analysis/typeComplexityAnalysis.test.ts index d9f8a0f..c956b17 100644 --- a/test/analysis/typeComplexityAnalysis.test.ts +++ b/test/analysis/typeComplexityAnalysis.test.ts @@ -761,7 +761,7 @@ describe('Test getQueryTypeComplexity function', () => { mockCharacterFriendsFunction.mockReturnValueOnce(3); mockHumanFriendsFunction.mockReturnValueOnce(2); // Query 1 + 1 hero + ...Character 3 + ...Human 2 = 7 - expect(getQueryTypeComplexity(parse(query), {}, typeWeights)).toBe(5); + expect(getQueryTypeComplexity(parse(query), {}, typeWeights)).toBe(7); }); }); }); From 50eb5c8752b4d3b125a03cf9b710858e128716f5 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sun, 10 Jul 2022 17:24:50 -0400 Subject: [PATCH 07/23] updated uniontypeweight object --- test/analysis/typeComplexityAnalysis.test.ts | 27 +++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/test/analysis/typeComplexityAnalysis.test.ts b/test/analysis/typeComplexityAnalysis.test.ts index 607131a..624e547 100644 --- a/test/analysis/typeComplexityAnalysis.test.ts +++ b/test/analysis/typeComplexityAnalysis.test.ts @@ -414,8 +414,9 @@ describe('Test getQueryTypeComplexity function', () => { }); }); - xdescribe('with inline fragments', () => { + describe('with inline fragments', () => { describe('on union types', () => { + let unionTypeWeights: TypeWeightObject; beforeAll(() => { // type Query { // hero(episode: Episode): Character @@ -434,7 +435,7 @@ describe('Test getQueryTypeComplexity function', () => { // primaryFunction: String // friends(first: Int): [Character] // } - typeWeights = { + unionTypeWeights = { query: { weight: 1, fields: { @@ -485,7 +486,9 @@ describe('Test getQueryTypeComplexity function', () => { } }`; // Query 1 + 1 hero - expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); + expect(getQueryTypeComplexity(parse(query), variables, unionTypeWeights)).toBe( + 2 + ); }); test('that have differing complexities', () => { @@ -506,7 +509,9 @@ describe('Test getQueryTypeComplexity function', () => { }`; // Query 1 + 1 hero + max(Droid 0, Human 3) = 5 mockHumanFriendsFunction.mockReturnValueOnce(3); - expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(5); + expect(getQueryTypeComplexity(parse(query), variables, unionTypeWeights)).toBe( + 5 + ); }); test('that contain an object and a non-zero complexity', () => { @@ -528,7 +533,9 @@ describe('Test getQueryTypeComplexity function', () => { mockCharacterFriendsFunction.mockReturnValueOnce(3); variables = { first: 3 }; // Query 1 + 1 hero + 3 friends/character - expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(5); + expect(getQueryTypeComplexity(parse(query), variables, unionTypeWeights)).toBe( + 5 + ); }); test('that use a variable', () => { @@ -550,7 +557,9 @@ describe('Test getQueryTypeComplexity function', () => { mockDroidFriendsFunction.mockReturnValueOnce(3); variables = { first: 3 }; // Query 1 + 1 hero + max(Droid 3, Human 0) = 5 - expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(5); + expect(getQueryTypeComplexity(parse(query), variables, unionTypeWeights)).toBe( + 5 + ); }); test('that do not have a TypeCondition', () => { @@ -570,7 +579,7 @@ describe('Test getQueryTypeComplexity function', () => { }`; mockCharacterFriendsFunction.mockReturnValueOnce(3); // Query 1 + 1 hero + max(Character 3, Human 0) = 5 - expect(getQueryTypeComplexity(parse(query), {}, typeWeights)).toBe(5); + expect(getQueryTypeComplexity(parse(query), {}, unionTypeWeights)).toBe(5); }); xtest('that include a directive', () => { @@ -590,7 +599,7 @@ describe('Test getQueryTypeComplexity function', () => { }`; mockCharacterFriendsFunction.mockReturnValueOnce(3); // Query 1 + 1 hero + max(...Character 3, ...Human 0) = 5 - expect(getQueryTypeComplexity(parse(query), {}, typeWeights)).toBe(5); + expect(getQueryTypeComplexity(parse(query), {}, unionTypeWeights)).toBe(5); }); test('and multiple fragments apply to the selection set', () => { @@ -613,7 +622,7 @@ describe('Test getQueryTypeComplexity function', () => { mockCharacterFriendsFunction.mockReturnValueOnce(3); mockHumanFriendsFunction.mockReturnValueOnce(2); // Query 1 + 1 hero + ...Character 3 + ...Human 2 = 7 - expect(getQueryTypeComplexity(parse(query), {}, typeWeights)).toBe(7); + expect(getQueryTypeComplexity(parse(query), {}, unionTypeWeights)).toBe(7); }); }); From 228cf62faa13c3cfb6ef07e5f426d30d87bf809c Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sun, 10 Jul 2022 17:47:04 -0400 Subject: [PATCH 08/23] more descriptive error message for fieldNode function --- src/analysis/ASTnodefunctions.ts | 93 +++++++++++--------- test/analysis/typeComplexityAnalysis.test.ts | 4 + 2 files changed, 55 insertions(+), 42 deletions(-) diff --git a/src/analysis/ASTnodefunctions.ts b/src/analysis/ASTnodefunctions.ts index 84be981..1bb2722 100644 --- a/src/analysis/ASTnodefunctions.ts +++ b/src/analysis/ASTnodefunctions.ts @@ -69,58 +69,67 @@ class ASTParser { } fieldNode(node: FieldNode, parentName: string): number { - let complexity = 0; - const parentType = this.typeWeights[parentName]; - if (!parentType) { - throw new Error( - `ERROR: ASTParser Failed to obtain parentType for parent: ${parentName} and node: ${node.name.value}` - ); - } - let typeName: string | undefined; - let typeWeight: FieldWeight | undefined; - - if (node.name.value in this.typeWeights) { - // node is an object type n the typeWeight root - typeName = node.name.value; - typeWeight = this.typeWeights[typeName].weight; - complexity += this.calculateCost(node, parentName, typeName, typeWeight); - } else if (parentType.fields[node.name.value].resolveTo) { - // field resolves to another type in type weights or a list - typeName = parentType.fields[node.name.value].resolveTo; - typeWeight = parentType.fields[node.name.value].weight; - // if this is a list typeWeight is a weight function - // otherwise the weight would be null as the weight is defined on the typeWeights root - if (typeName && typeWeight) { - // Type is a list and has a weight function - complexity += this.calculateCost(node, parentName, typeName, typeWeight); - } else if (typeName) { - // resolve type exists at root of typeWeight object and is not a list - typeWeight = this.typeWeights[typeName].weight; - complexity += this.calculateCost(node, parentName, typeName, typeWeight); - } else { + try { + let complexity = 0; + const parentType = this.typeWeights[parentName]; + if (!parentType) { throw new Error( - `ERROR: ASTParser Failed to obtain resolved type name or weight for node: ${parentName}.${node.name.value}` + `ERROR: ASTParser Failed to obtain parentType for parent: ${parentName} and node: ${node.name.value}` ); } - } else { - // field is a scalar - typeName = node.name.value; - if (typeName) { - typeWeight = parentType.fields[typeName].weight; - if (typeof typeWeight === 'number') { - complexity += typeWeight; + let typeName: string | undefined; + let typeWeight: FieldWeight | undefined; + + if (node.name.value in this.typeWeights) { + // node is an object type n the typeWeight root + typeName = node.name.value; + typeWeight = this.typeWeights[typeName].weight; + complexity += this.calculateCost(node, parentName, typeName, typeWeight); + } else if (parentType.fields[node.name.value].resolveTo) { + // field resolves to another type in type weights or a list + typeName = parentType.fields[node.name.value].resolveTo; + typeWeight = parentType.fields[node.name.value].weight; + // if this is a list typeWeight is a weight function + // otherwise the weight would be null as the weight is defined on the typeWeights root + if (typeName && typeWeight) { + // Type is a list and has a weight function + complexity += this.calculateCost(node, parentName, typeName, typeWeight); + } else if (typeName) { + // resolve type exists at root of typeWeight object and is not a list + typeWeight = this.typeWeights[typeName].weight; + complexity += this.calculateCost(node, parentName, typeName, typeWeight); } else { throw new Error( - `ERROR: ASTParser Failed to obtain type weight for ${parentName}.${node.name.value}` + `ERROR: ASTParser Failed to obtain resolved type name or weight for node: ${parentName}.${node.name.value}` ); } } else { - throw new Error( - `ERROR: ASTParser Failed to obtain type name for ${parentName}.${node.name.value}` - ); + // field is a scalar + typeName = node.name.value; + if (typeName) { + typeWeight = parentType.fields[typeName].weight; + if (typeof typeWeight === 'number') { + complexity += typeWeight; + } else { + throw new Error( + `ERROR: ASTParser Failed to obtain type weight for ${parentName}.${node.name.value}` + ); + } + } else { + throw new Error( + `ERROR: ASTParser Failed to obtain type name for ${parentName}.${node.name.value}` + ); + } } + return complexity; + } catch (err) { + throw new Error( + `ERROR: ASTParser.fieldNode Uncaught error handling ${parentName}.${ + node.name.value + }\n + ${err instanceof Error && err.stack}` + ); } - return complexity; } selectionNode(node: SelectionNode, parentName: string): number { diff --git a/test/analysis/typeComplexityAnalysis.test.ts b/test/analysis/typeComplexityAnalysis.test.ts index a22ee6d..846b130 100644 --- a/test/analysis/typeComplexityAnalysis.test.ts +++ b/test/analysis/typeComplexityAnalysis.test.ts @@ -444,6 +444,10 @@ describe('Test getQueryTypeComplexity function', () => { }, }, }, + character: { + weight: 1, + fields: {}, + }, human: { weight: 1, fields: { From a2524b781ca61c80a9ac49361a0fd411f7d9285b Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sun, 10 Jul 2022 17:52:25 -0400 Subject: [PATCH 09/23] renamed AST parse file --- src/analysis/{ASTnodefunctions.ts => ASTParser.ts} | 0 src/analysis/typeComplexityAnalysis.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/analysis/{ASTnodefunctions.ts => ASTParser.ts} (100%) diff --git a/src/analysis/ASTnodefunctions.ts b/src/analysis/ASTParser.ts similarity index 100% rename from src/analysis/ASTnodefunctions.ts rename to src/analysis/ASTParser.ts diff --git a/src/analysis/typeComplexityAnalysis.ts b/src/analysis/typeComplexityAnalysis.ts index 366d4b6..9855cef 100644 --- a/src/analysis/typeComplexityAnalysis.ts +++ b/src/analysis/typeComplexityAnalysis.ts @@ -1,6 +1,6 @@ import { DocumentNode } from 'graphql'; import { TypeWeightObject, Variables } from '../@types/buildTypeWeights'; -import ASTParser from './ASTnodefunctions'; +import ASTParser from './ASTParser'; /** * Calculate the complexity for the query by recursivly traversing through the query AST, From 81d105c3ed85a336b94f3e0e2e22ac620d1991f0 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sun, 10 Jul 2022 21:56:21 -0400 Subject: [PATCH 10/23] Revert "renamed AST parse file" for easier review of changes This reverts commit a2524b781ca61c80a9ac49361a0fd411f7d9285b. --- src/analysis/{ASTParser.ts => ASTnodefunctions.ts} | 0 src/analysis/typeComplexityAnalysis.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/analysis/{ASTParser.ts => ASTnodefunctions.ts} (100%) diff --git a/src/analysis/ASTParser.ts b/src/analysis/ASTnodefunctions.ts similarity index 100% rename from src/analysis/ASTParser.ts rename to src/analysis/ASTnodefunctions.ts diff --git a/src/analysis/typeComplexityAnalysis.ts b/src/analysis/typeComplexityAnalysis.ts index 9855cef..366d4b6 100644 --- a/src/analysis/typeComplexityAnalysis.ts +++ b/src/analysis/typeComplexityAnalysis.ts @@ -1,6 +1,6 @@ import { DocumentNode } from 'graphql'; import { TypeWeightObject, Variables } from '../@types/buildTypeWeights'; -import ASTParser from './ASTParser'; +import ASTParser from './ASTnodefunctions'; /** * Calculate the complexity for the query by recursivly traversing through the query AST, From 24754d4d807a3d40f27eb0ec1211bdb69d7164c5 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Wed, 13 Jul 2022 22:43:32 -0400 Subject: [PATCH 11/23] updated type weight tests for unions in prep for refactor mentioned in #44 --- package.json | 2 +- test/analysis/buildTypeWeights.test.ts | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7d22897..5a60f1e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "index.js", "type": "module", "scripts": { - "test": "jest --passWithNoTests", + "test": "jest --passWithNoTests --coverage", "lint": "eslint src test", "lint:fix": "eslint --fix src test @types", "prettier": "prettier --write .", diff --git a/test/analysis/buildTypeWeights.test.ts b/test/analysis/buildTypeWeights.test.ts index 48b57cc..c653f4c 100644 --- a/test/analysis/buildTypeWeights.test.ts +++ b/test/analysis/buildTypeWeights.test.ts @@ -464,26 +464,37 @@ describe('Test buildTypeWeightsFromSchema function', () => { schema = buildSchema(` union SearchResult = Human | Droid type Human{ + name: String homePlanet: String + search(first: Int!): [SearchResult] } type Droid { + name: String primaryFunction: String + search(first: Int!): [SearchResult] }`); expect(buildTypeWeightsFromSchema(schema)).toEqual({ searchresult: { weight: 1, - fields: {}, + fields: { + name: { weight: 0 }, + search: { resolveTo: 'searchresult' }, + }, }, human: { weight: 1, fields: { + name: { weight: 0 }, homePlanet: { weight: 0 }, + search: { resolveTo: 'searchresult' }, }, }, droid: { weight: 1, fields: { + name: { weight: 0 }, primaryFunction: { weight: 0 }, + search: { resolveTo: 'searchresult' }, }, }, }); From 7365a0d26fbfa731c064e86b856ae174a9bb3e94 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Fri, 15 Jul 2022 19:40:27 -0400 Subject: [PATCH 12/23] handling basic union types. no support for list weights or non-null types --- src/analysis/buildTypeWeights.ts | 141 ++++++++++++++++++++++++++++--- 1 file changed, 127 insertions(+), 14 deletions(-) diff --git a/src/analysis/buildTypeWeights.ts b/src/analysis/buildTypeWeights.ts index c5dcea9..2cc8170 100644 --- a/src/analysis/buildTypeWeights.ts +++ b/src/analysis/buildTypeWeights.ts @@ -3,9 +3,7 @@ import { GraphQLArgument, GraphQLNamedType, GraphQLObjectType, - GraphQLScalarType, GraphQLInterfaceType, - GraphQLList, GraphQLOutputType, isCompositeType, isEnumType, @@ -17,8 +15,10 @@ import { isUnionType, Kind, ValueNode, + GraphQLUnionType, + GraphQLFieldMap, + GraphQLField, } from 'graphql'; -import { Maybe } from 'graphql/jsutils/Maybe'; import { ObjMap } from 'graphql/jsutils/ObjMap'; import { GraphQLSchema } from 'graphql/type/schema'; import { @@ -27,6 +27,7 @@ import { TypeWeightObject, Variables, Type, + Fields, } from '../@types/buildTypeWeights'; export const KEYWORDS = ['first', 'last', 'limit']; @@ -86,13 +87,9 @@ function parseObjectFields( ) { result.fields[field] = { weight: typeWeights.scalar, + // resolveTo: fields[field].name.toLowerCase(), }; - } else if ( - isInterfaceType(fieldType) || - isUnionType(fieldType) || - isEnumType(fieldType) || - isObjectType(fieldType) - ) { + } else if (isInterfaceType(fieldType) || isEnumType(fieldType) || isObjectType(fieldType)) { result.fields[field] = { resolveTo: fieldType.name.toLocaleLowerCase(), }; @@ -157,14 +154,43 @@ function parseObjectFields( } }); } + } else if (isUnionType(fieldType)) { + // Users must query union types using inline fragments to resolve field specific to one of the types in the union + // however, if a type is shared by all types in the union it can be queried outside of the inline fragment + // any common fields should be added to fields on the union type itself in addition to the comprising types + // Get all types in the union + // iterate through all types creating a set of type names + // add resulting set to fields + // FIXME: What happens if two types share a name that resolve to different types => invalid query? + result.fields[field] = { + resolveTo: fieldType.name.toLocaleLowerCase(), + }; } else { // ? what else can get through here + throw new Error(`ERROR: buildTypeWeight: Unsupported field type: ${fieldType}`); } }); return result; } +/** + * Recursively compares two types for type equality based on name + * @param a + * @param b + * @returns + */ +function compareTypes(a: GraphQLOutputType, b: GraphQLOutputType): boolean { + return ( + (isObjectType(b) && isObjectType(a) && a.name === b.name) || + (isUnionType(b) && isUnionType(a) && a.name === b.name) || + (isInterfaceType(b) && isInterfaceType(a) && a.name === b.name) || + (isScalarType(b) && isScalarType(a) && a.name === b.name) || + (isListType(b) && isListType(a) && compareTypes(b.ofType, a.ofType)) || + (isNonNullType(b) && isNonNullType(a) && compareTypes(a.ofType, b.ofType)) + ); +} + /** * Parses all types in the provided schema object excempt for Query, Mutation * and built in types that begin with '__' and outputs a new TypeWeightObject @@ -177,6 +203,8 @@ function parseTypes(schema: GraphQLSchema, typeWeights: TypeWeightSet): TypeWeig const result: TypeWeightObject = {}; + const unions: GraphQLUnionType[] = []; + // Handle Object, Interface, Enum and Union types Object.keys(typeMap).forEach((type) => { const typeName: string = type.toLowerCase(); @@ -193,18 +221,103 @@ function parseTypes(schema: GraphQLSchema, typeWeights: TypeWeightSet): TypeWeig weight: typeWeights.scalar, }; } else if (isUnionType(currentType)) { - // FIXME: will need information on fields inorder calculate comlpextiy - result[typeName] = { - fields: {}, - weight: typeWeights.object, - }; + unions.push(currentType); } else { + // FIXME: Scalar types are listed here throw new Error(`ERROR: buildTypeWeight: Unsupported type: ${currentType}`); // ? what else can get through here // ? inputTypes? } } }); + type FieldMap = { [index: string]: GraphQLOutputType }; + type CommonFields = { [index: string]: Type }; + + unions.forEach((unionType: GraphQLUnionType) => { + /** Start with the fields for the first object. Store fieldnamd and type + * reduce by selecting fields common to each type + * compare both fieldname and output type accounting for lists and non-nulls + * for object + * compare name of output type + * for lists + * compare ofType and ofType name if not onother list/non-null + * for non-nulls + * compare oftype and ofTypeName (if not another non-null) + * */ + + // types is an array mapping each field name to it's respective output type + const types: FieldMap[] = unionType.getTypes().map((objectType: GraphQLObjectType) => { + const fields: GraphQLFieldMap = objectType.getFields(); + + const fieldMap: { [index: string]: GraphQLOutputType } = {}; + Object.keys(fields).forEach((field: string) => { + fieldMap[field] = fields[field].type; + }); + return fieldMap; + }); + + const common: FieldMap = types.reduce((prev: FieldMap, fieldMap: FieldMap): FieldMap => { + // iterate through the field map checking the types for any common field names + const commonFields: FieldMap = {}; + Object.keys(prev).forEach((field: string) => { + if (fieldMap[field]) { + if (compareTypes(prev[field], fieldMap[field])) { + // they match add the type to the next set + commonFields[field] = prev[field]; + } + } + }); + return commonFields; + }); + + // transform commonFields into the correct format + const fieldTypes: Fields = {}; + + Object.keys(common).forEach((field: string) => { + // if a scalar => weight + // object => resolveTo + // list => // resolveTo + weight(function) + const current = common[field]; + if (isScalarType(current)) { + fieldTypes[field] = { + weight: typeWeights.scalar, + }; + } + // else if (isObjectType(current)) { + // fieldTypes[field] = { + // resolveTo: current.name, + // }; + // } + else if (isListType(current)) { + throw new Error('list types not supported on unions'); + fieldTypes[field] = { + resolveTo: 'test', // get resolve type problem is recursive data structure (i.e. list of lists) + // weight: TODO: Get the function for resolving + }; + } else if (isNonNullType(current)) { + throw new Error('non null types not supported on unions'); + // TODO: also a recursive data structure + } else { + throw new Error('Unandled union type. Should never get here'); + } + }); + result[unionType.name.toLowerCase()] = { + fields: fieldTypes, + weight: typeWeights.object, + }; + + // + // objects are not. they exist at the root. + // FIXME: Is it worth adding objects as a field? + // yes, I think so => refactor fieldNode parser + // if it resolves to object then add commonFields set + // i think we have this already. + // double check the non-null tests + // commonFields.add({ + // weight, + // }); + }); + return result; } From c1cf473a999b7f758c1ed0b1be0d270c386679b1 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Fri, 15 Jul 2022 20:07:36 -0400 Subject: [PATCH 13/23] corrected union tests --- test/analysis/buildTypeWeights.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/analysis/buildTypeWeights.test.ts b/test/analysis/buildTypeWeights.test.ts index c653f4c..0cdda1a 100644 --- a/test/analysis/buildTypeWeights.test.ts +++ b/test/analysis/buildTypeWeights.test.ts @@ -478,7 +478,10 @@ describe('Test buildTypeWeightsFromSchema function', () => { weight: 1, fields: { name: { weight: 0 }, - search: { resolveTo: 'searchresult' }, + search: { + resolveTo: 'searchresult', + weight: expect.any(Function), + }, }, }, human: { @@ -486,7 +489,10 @@ describe('Test buildTypeWeightsFromSchema function', () => { fields: { name: { weight: 0 }, homePlanet: { weight: 0 }, - search: { resolveTo: 'searchresult' }, + search: { + resolveTo: 'searchresult', + weight: expect.any(Function), + }, }, }, droid: { @@ -494,7 +500,10 @@ describe('Test buildTypeWeightsFromSchema function', () => { fields: { name: { weight: 0 }, primaryFunction: { weight: 0 }, - search: { resolveTo: 'searchresult' }, + search: { + resolveTo: 'searchresult', + weight: expect.any(Function), + }, }, }, }); From c5ea4203656cfd04d97d638c98fbd891bd827954 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Fri, 15 Jul 2022 20:14:08 -0400 Subject: [PATCH 14/23] preliminary refactor for unions. common field names now appear in the union type in reponse to #44 --- src/@types/buildTypeWeights.d.ts | 9 +++++ src/analysis/buildTypeWeights.ts | 62 ++++++++++++-------------------- 2 files changed, 32 insertions(+), 39 deletions(-) diff --git a/src/@types/buildTypeWeights.d.ts b/src/@types/buildTypeWeights.d.ts index 4b9f063..4c65c8f 100644 --- a/src/@types/buildTypeWeights.d.ts +++ b/src/@types/buildTypeWeights.d.ts @@ -31,3 +31,12 @@ export interface TypeWeightSet { type Variables = { [index: string]: readonly unknown; }; + +// Type for use when getting fields for union types +type FieldMap = { + [index: string]: { + type: GraphQLOutputType; + weight?: FieldWeight; + resolveTo?: string; + }; +}; diff --git a/src/analysis/buildTypeWeights.ts b/src/analysis/buildTypeWeights.ts index 2cc8170..d2edf11 100644 --- a/src/analysis/buildTypeWeights.ts +++ b/src/analysis/buildTypeWeights.ts @@ -17,7 +17,6 @@ import { ValueNode, GraphQLUnionType, GraphQLFieldMap, - GraphQLField, } from 'graphql'; import { ObjMap } from 'graphql/jsutils/ObjMap'; import { GraphQLSchema } from 'graphql/type/schema'; @@ -28,6 +27,8 @@ import { Variables, Type, Fields, + FieldWeight, + FieldMap, } from '../@types/buildTypeWeights'; export const KEYWORDS = ['first', 'last', 'limit']; @@ -158,10 +159,6 @@ function parseObjectFields( // Users must query union types using inline fragments to resolve field specific to one of the types in the union // however, if a type is shared by all types in the union it can be queried outside of the inline fragment // any common fields should be added to fields on the union type itself in addition to the comprising types - // Get all types in the union - // iterate through all types creating a set of type names - // add resulting set to fields - // FIXME: What happens if two types share a name that resolve to different types => invalid query? result.fields[field] = { resolveTo: fieldType.name.toLocaleLowerCase(), }; @@ -230,11 +227,8 @@ function parseTypes(schema: GraphQLSchema, typeWeights: TypeWeightSet): TypeWeig } }); - type FieldMap = { [index: string]: GraphQLOutputType }; - type CommonFields = { [index: string]: Type }; - unions.forEach((unionType: GraphQLUnionType) => { - /** Start with the fields for the first object. Store fieldnamd and type + /** Start with the fields for the first object. Store fieldname, type, weight and resolve to for later use * reduce by selecting fields common to each type * compare both fieldname and output type accounting for lists and non-nulls * for object @@ -249,9 +243,17 @@ function parseTypes(schema: GraphQLSchema, typeWeights: TypeWeightSet): TypeWeig const types: FieldMap[] = unionType.getTypes().map((objectType: GraphQLObjectType) => { const fields: GraphQLFieldMap = objectType.getFields(); - const fieldMap: { [index: string]: GraphQLOutputType } = {}; + const fieldMap: FieldMap = {}; Object.keys(fields).forEach((field: string) => { - fieldMap[field] = fields[field].type; + // Get the weight of this field on from parent type on the root typeWeight object. + // this only exists for scalars and lists (which resolve to a function); + const { weight, resolveTo } = result[objectType.name.toLowerCase()].fields[field]; + + fieldMap[field] = { + type: fields[field].type, + weight, // will only be undefined for object types + resolveTo, + }; }); return fieldMap; }); @@ -261,7 +263,7 @@ function parseTypes(schema: GraphQLSchema, typeWeights: TypeWeightSet): TypeWeig const commonFields: FieldMap = {}; Object.keys(prev).forEach((field: string) => { if (fieldMap[field]) { - if (compareTypes(prev[field], fieldMap[field])) { + if (compareTypes(prev[field].type, fieldMap[field].type)) { // they match add the type to the next set commonFields[field] = prev[field]; } @@ -274,48 +276,30 @@ function parseTypes(schema: GraphQLSchema, typeWeights: TypeWeightSet): TypeWeig const fieldTypes: Fields = {}; Object.keys(common).forEach((field: string) => { - // if a scalar => weight - // object => resolveTo - // list => // resolveTo + weight(function) - const current = common[field]; + // scalar => weight + // list => resolveTo + weight(function) + // fields that resolve to objects do not need to appear on the union type + const current = common[field].type; if (isScalarType(current)) { fieldTypes[field] = { - weight: typeWeights.scalar, + weight: common[field].weight, }; - } - // else if (isObjectType(current)) { - // fieldTypes[field] = { - // resolveTo: current.name, - // }; - // } - else if (isListType(current)) { - throw new Error('list types not supported on unions'); + } else if (isListType(current)) { fieldTypes[field] = { - resolveTo: 'test', // get resolve type problem is recursive data structure (i.e. list of lists) - // weight: TODO: Get the function for resolving + resolveTo: common[field].resolveTo, + weight: common[field].weight, }; } else if (isNonNullType(current)) { throw new Error('non null types not supported on unions'); // TODO: also a recursive data structure } else { - throw new Error('Unandled union type. Should never get here'); + throw new Error('Unhandled union type. Should never get here'); } }); result[unionType.name.toLowerCase()] = { fields: fieldTypes, weight: typeWeights.object, }; - - // - // objects are not. they exist at the root. - // FIXME: Is it worth adding objects as a field? - // yes, I think so => refactor fieldNode parser - // if it resolves to object then add commonFields set - // i think we have this already. - // double check the non-null tests - // commonFields.add({ - // weight, - // }); }); return result; From 5d2568da38da60877dac2e9757922339e785b06f Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Fri, 15 Jul 2022 20:16:30 -0400 Subject: [PATCH 15/23] added notes for additional union test cases --- test/analysis/buildTypeWeights.test.ts | 88 ++++++++++++++------------ 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/test/analysis/buildTypeWeights.test.ts b/test/analysis/buildTypeWeights.test.ts index 0cdda1a..f0b46a1 100644 --- a/test/analysis/buildTypeWeights.test.ts +++ b/test/analysis/buildTypeWeights.test.ts @@ -460,52 +460,60 @@ describe('Test buildTypeWeightsFromSchema function', () => { }); }); - test('union types', () => { - schema = buildSchema(` - union SearchResult = Human | Droid - type Human{ - name: String - homePlanet: String - search(first: Int!): [SearchResult] - } - type Droid { - name: String - primaryFunction: String - search(first: Int!): [SearchResult] - }`); - expect(buildTypeWeightsFromSchema(schema)).toEqual({ - searchresult: { - weight: 1, - fields: { - name: { weight: 0 }, - search: { - resolveTo: 'searchresult', - weight: expect.any(Function), + describe('union types', () => { + test('union types', () => { + schema = buildSchema(` + union SearchResult = Human | Droid + type Human{ + name: String + homePlanet: String + search(first: Int!): [SearchResult] + } + type Droid { + name: String + primaryFunction: String + search(first: Int!): [SearchResult] + }`); + expect(buildTypeWeightsFromSchema(schema)).toEqual({ + searchresult: { + weight: 1, + fields: { + name: { weight: 0 }, + search: { + resolveTo: 'searchresult', + weight: expect.any(Function), + }, }, }, - }, - human: { - weight: 1, - fields: { - name: { weight: 0 }, - homePlanet: { weight: 0 }, - search: { - resolveTo: 'searchresult', - weight: expect.any(Function), + human: { + weight: 1, + fields: { + name: { weight: 0 }, + homePlanet: { weight: 0 }, + search: { + resolveTo: 'searchresult', + weight: expect.any(Function), + }, }, }, - }, - droid: { - weight: 1, - fields: { - name: { weight: 0 }, - primaryFunction: { weight: 0 }, - search: { - resolveTo: 'searchresult', - weight: expect.any(Function), + droid: { + weight: 1, + fields: { + name: { weight: 0 }, + primaryFunction: { weight: 0 }, + search: { + resolveTo: 'searchresult', + weight: expect.any(Function), + }, }, }, - }, + }); + }); + + xtest('additional test cases for ...', () => { + // TODO: unions with non-null types + // unions with lists of non-null types + // lists with > 2 levels of nesting (may need to add these for lists on other types as well) }); }); From 1b08294a6b022ff0f9f92bb1e89a4c45f856b6bf Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Fri, 15 Jul 2022 20:42:34 -0400 Subject: [PATCH 16/23] handled inline fragments with differing complexities --- src/analysis/ASTnodefunctions.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/analysis/ASTnodefunctions.ts b/src/analysis/ASTnodefunctions.ts index 1bb2722..c9d1456 100644 --- a/src/analysis/ASTnodefunctions.ts +++ b/src/analysis/ASTnodefunctions.ts @@ -5,7 +5,6 @@ import { DefinitionNode, Kind, SelectionNode, - NamedTypeNode, } from 'graphql'; import { FieldWeight, TypeWeightObject, Variables } from '../@types/buildTypeWeights'; /** @@ -160,13 +159,30 @@ class ASTParser { selectionSetNode(node: SelectionSetNode, parentName: string): number { let complexity = 0; + let maxFragmentComplexity = 0; // iterate shrough the 'selections' array on the seletion set node for (let i = 0; i < node.selections.length; i += 1) { // call the function to handle seletion nodes // pass the current parent through because selection sets act only as intermediaries - complexity += this.selectionNode(node.selections[i], parentName); + const selectionNode = node.selections[i]; + const selectionCost = this.selectionNode(node.selections[i], parentName); + + // we need to get the largest possible complexity so we save the largest inline fragment + // FIXME: Consider the case where 2 typed fragments are applicable + // e.g. ...UnionType and ...PartofTheUnion + // this case these complexities should be summed in order to be accurate + // However an estimation suffice + if (selectionNode.kind === Kind.INLINE_FRAGMENT) { + if (!selectionNode.typeCondition) { + // complexity is always applicable + complexity += selectionCost; + } else if (selectionCost > maxFragmentComplexity) + maxFragmentComplexity = selectionCost; + } else { + complexity += selectionCost; + } } - return complexity; + return complexity + maxFragmentComplexity; } definitionNode(node: DefinitionNode): number { From 5f7b1851473240560452a2e0c46e5e58bcc3eb2a Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Fri, 15 Jul 2022 21:22:53 -0400 Subject: [PATCH 17/23] corrected tests for union types --- test/analysis/typeComplexityAnalysis.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/analysis/typeComplexityAnalysis.test.ts b/test/analysis/typeComplexityAnalysis.test.ts index 886d2df..52e0f9c 100644 --- a/test/analysis/typeComplexityAnalysis.test.ts +++ b/test/analysis/typeComplexityAnalysis.test.ts @@ -417,7 +417,8 @@ describe('Test getQueryTypeComplexity function', () => { describe('with inline fragments', () => { describe('on union types', () => { let unionTypeWeights: TypeWeightObject; - beforeAll(() => { + let mockHumanCharacterFriendsFunction: jest.Mock; + beforeEach(() => { // type Query { // hero(episode: Episode): Character // } @@ -435,6 +436,7 @@ describe('Test getQueryTypeComplexity function', () => { // primaryFunction: String // friends(first: Int): [Character] // } + mockHumanCharacterFriendsFunction = jest.fn(); unionTypeWeights = { query: { weight: 1, @@ -446,7 +448,15 @@ describe('Test getQueryTypeComplexity function', () => { }, character: { weight: 1, - fields: {}, + fields: { + name: { + weight: 0, + }, + friends: { + resolveTo: 'character', + weight: mockCharacterFriendsFunction, + }, + }, }, human: { weight: 1, @@ -455,7 +465,7 @@ describe('Test getQueryTypeComplexity function', () => { homePlanet: { weight: 0 }, friends: { resolveTo: 'character', - weight: mockCharacterFriendsFunction, + weight: mockHumanCharacterFriendsFunction, }, humanFriends: { resolveTo: 'human', @@ -515,7 +525,7 @@ describe('Test getQueryTypeComplexity function', () => { } }`; // Query 1 + 1 hero + max(Droid 2, Human 3) = 5 - mockCharacterFriendsFunction.mockReturnValueOnce(3); + mockHumanCharacterFriendsFunction.mockReturnValueOnce(3); mockDroidFriendsFunction.mockReturnValueOnce(1); expect(getQueryTypeComplexity(parse(query), variables, unionTypeWeights)).toBe( 5 From 1bbb2628e14f2191a4304bf06a773d8dabdce5c1 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Fri, 15 Jul 2022 21:27:07 -0400 Subject: [PATCH 18/23] added if block with note to handle non-null wrappers when building type weights. --- src/analysis/buildTypeWeights.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/analysis/buildTypeWeights.ts b/src/analysis/buildTypeWeights.ts index d2edf11..5cb40a9 100644 --- a/src/analysis/buildTypeWeights.ts +++ b/src/analysis/buildTypeWeights.ts @@ -162,6 +162,9 @@ function parseObjectFields( result.fields[field] = { resolveTo: fieldType.name.toLocaleLowerCase(), }; + } else if (isNonNullType(fieldType)) { + // TODO: Implment non-null types + // not throwing and error since it causes typeWeight tests to break } else { // ? what else can get through here throw new Error(`ERROR: buildTypeWeight: Unsupported field type: ${fieldType}`); From d3335ae741be60be34a5b72438d665b065b3d5e2 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Fri, 15 Jul 2022 21:47:53 -0400 Subject: [PATCH 19/23] minor cleanup --- src/analysis/buildTypeWeights.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/analysis/buildTypeWeights.ts b/src/analysis/buildTypeWeights.ts index 5cb40a9..802742e 100644 --- a/src/analysis/buildTypeWeights.ts +++ b/src/analysis/buildTypeWeights.ts @@ -27,7 +27,6 @@ import { Variables, Type, Fields, - FieldWeight, FieldMap, } from '../@types/buildTypeWeights'; @@ -244,7 +243,7 @@ function parseTypes(schema: GraphQLSchema, typeWeights: TypeWeightSet): TypeWeig // types is an array mapping each field name to it's respective output type const types: FieldMap[] = unionType.getTypes().map((objectType: GraphQLObjectType) => { - const fields: GraphQLFieldMap = objectType.getFields(); + const fields: GraphQLFieldMap = objectType.getFields(); const fieldMap: FieldMap = {}; Object.keys(fields).forEach((field: string) => { From 54013c4c5ef2cc352de80d599ae89871071818f1 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sat, 16 Jul 2022 15:23:26 -0400 Subject: [PATCH 20/23] added objects to union type --- src/analysis/buildTypeWeights.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/analysis/buildTypeWeights.ts b/src/analysis/buildTypeWeights.ts index 802742e..e9272f4 100644 --- a/src/analysis/buildTypeWeights.ts +++ b/src/analysis/buildTypeWeights.ts @@ -286,6 +286,11 @@ function parseTypes(schema: GraphQLSchema, typeWeights: TypeWeightSet): TypeWeig fieldTypes[field] = { weight: common[field].weight, }; + } else if (isObjectType(current) || isInterfaceType(current) || isUnionType(current)) { + fieldTypes[field] = { + resolveTo: common[field].resolveTo, + weight: typeWeights.object, + }; } else if (isListType(current)) { fieldTypes[field] = { resolveTo: common[field].resolveTo, From 4ba017b32214140e5afb5da59411f4623f8f09ef Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Mon, 18 Jul 2022 19:45:16 -0400 Subject: [PATCH 21/23] refactored union parsing for clarity --- src/analysis/buildTypeWeights.ts | 233 +++++++++++++++++++------------ 1 file changed, 140 insertions(+), 93 deletions(-) diff --git a/src/analysis/buildTypeWeights.ts b/src/analysis/buildTypeWeights.ts index e9272f4..3b16db3 100644 --- a/src/analysis/buildTypeWeights.ts +++ b/src/analysis/buildTypeWeights.ts @@ -89,7 +89,12 @@ function parseObjectFields( weight: typeWeights.scalar, // resolveTo: fields[field].name.toLowerCase(), }; - } else if (isInterfaceType(fieldType) || isEnumType(fieldType) || isObjectType(fieldType)) { + } else if ( + isInterfaceType(fieldType) || + isEnumType(fieldType) || + isObjectType(fieldType) || + isUnionType(fieldType) + ) { result.fields[field] = { resolveTo: fieldType.name.toLocaleLowerCase(), }; @@ -154,13 +159,6 @@ function parseObjectFields( } }); } - } else if (isUnionType(fieldType)) { - // Users must query union types using inline fragments to resolve field specific to one of the types in the union - // however, if a type is shared by all types in the union it can be queried outside of the inline fragment - // any common fields should be added to fields on the union type itself in addition to the comprising types - result.fields[field] = { - resolveTo: fieldType.name.toLocaleLowerCase(), - }; } else if (isNonNullType(fieldType)) { // TODO: Implment non-null types // not throwing and error since it causes typeWeight tests to break @@ -174,12 +172,14 @@ function parseObjectFields( } /** - * Recursively compares two types for type equality based on name + * Recursively compares two types for type equality based on type name * @param a * @param b - * @returns + * @returns true if the types are recursively equal. */ function compareTypes(a: GraphQLOutputType, b: GraphQLOutputType): boolean { + // Base Case: Object or Scalar => compare type names + // Recursive Case(List / NonNull): compare ofType return ( (isObjectType(b) && isObjectType(a) && a.name === b.name) || (isUnionType(b) && isUnionType(a) && a.name === b.name) || @@ -191,110 +191,118 @@ function compareTypes(a: GraphQLOutputType, b: GraphQLOutputType): boolean { } /** - * 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 typeWeights - * @returns + * + * @param unionType union type to be parsed + * @param typeWeightObject type weight mapping object that must already contain all of the types in the schema. + * @returns object mapping field names for each union type to their respective weights, resolve type names and resolve type object */ -function parseTypes(schema: GraphQLSchema, typeWeights: TypeWeightSet): TypeWeightObject { - const typeMap: ObjMap = schema.getTypeMap(); - - const result: TypeWeightObject = {}; +function getFieldsForUnionType( + unionType: GraphQLUnionType, + typeWeightObject: TypeWeightObject +): FieldMap[] { + return unionType.getTypes().map((objectType: GraphQLObjectType) => { + // Get the field data for this type + const fields: GraphQLFieldMap = objectType.getFields(); - const unions: GraphQLUnionType[] = []; + const fieldMap: FieldMap = {}; + Object.keys(fields).forEach((field: string) => { + // Get the weight of this field on from parent type on the root typeWeight object. + // this only exists for scalars and lists (which resolve to a function); + const { weight, resolveTo } = + typeWeightObject[objectType.name.toLowerCase()].fields[field]; - // Handle Object, Interface, Enum and Union types - Object.keys(typeMap).forEach((type) => { - const typeName: string = type.toLowerCase(); - const currentType: GraphQLNamedType = typeMap[type]; + fieldMap[field] = { + type: fields[field].type, + weight, // will only be undefined for object types + resolveTo, + }; + }); + return fieldMap; + }); +} - // Get all types that aren't Query or Mutation or a built in type that starts with '__' - if (!type.startsWith('__')) { - if (isObjectType(currentType) || isInterfaceType(currentType)) { - // Add the type and it's associated fields to the result - result[typeName] = parseObjectFields(currentType, result, typeWeights); - } else if (isEnumType(currentType)) { - result[typeName] = { - fields: {}, - weight: typeWeights.scalar, - }; - } else if (isUnionType(currentType)) { - unions.push(currentType); - } else { - // FIXME: Scalar types are listed here throw new Error(`ERROR: buildTypeWeight: Unsupported type: ${currentType}`); - // ? what else can get through here - // ? inputTypes? +/** + * + * @param typesInUnion + * @returns a single field map containg information for fields common to the union + */ +function getSharedFieldsFromUnionTypes(typesInUnion: FieldMap[]): FieldMap { + return typesInUnion.reduce((prev: FieldMap, fieldMap: FieldMap): FieldMap => { + // iterate through the field map checking the types for any common field names + const sharedFields: FieldMap = {}; + Object.keys(prev).forEach((field: string) => { + if (fieldMap[field]) { + if (compareTypes(prev[field].type, fieldMap[field].type)) { + // they match add the type to the next set + sharedFields[field] = prev[field]; + } } - } + }); + return sharedFields; }); +} - unions.forEach((unionType: GraphQLUnionType) => { - /** Start with the fields for the first object. Store fieldname, type, weight and resolve to for later use - * reduce by selecting fields common to each type - * compare both fieldname and output type accounting for lists and non-nulls - * for object - * compare name of output type - * for lists - * compare ofType and ofType name if not onother list/non-null - * for non-nulls - * compare oftype and ofTypeName (if not another non-null) +/** + * Parses the provided union types and returns a type weight object with any fields common to all types + * in a union added to the union type + * @param unionTypes union types to be parsed. + * @param typeWeights object specifying generic type weights. + * @param typeWeightObject original type weight object + * @returns + */ +function parseUnionTypes( + unionTypes: GraphQLUnionType[], + typeWeights: TypeWeightSet, + typeWeightObject: TypeWeightObject +) { + const typeWeightsWithUnions: TypeWeightObject = { ...typeWeightObject }; + + unionTypes.forEach((unionType: GraphQLUnionType) => { + /** + * 1. For each provided union type. We first obtain the fields for each object that + * is part of the union and store these in an object + * When obtaining types, save: + * - field name + * - type object to which the field resolves. This holds any information for recursive types (lists / not null / unions) + * - weight - for easy lookup later + * - resolveTo type - for easy lookup later + * 2. We then reduce the array of objects from step 1 a single object only containing fields + * common to each type in the union. To determine field "equality" we compare the field names and + * recursively compare the field types: * */ // types is an array mapping each field name to it's respective output type - const types: FieldMap[] = unionType.getTypes().map((objectType: GraphQLObjectType) => { - const fields: GraphQLFieldMap = objectType.getFields(); - - const fieldMap: FieldMap = {}; - Object.keys(fields).forEach((field: string) => { - // Get the weight of this field on from parent type on the root typeWeight object. - // this only exists for scalars and lists (which resolve to a function); - const { weight, resolveTo } = result[objectType.name.toLowerCase()].fields[field]; - - fieldMap[field] = { - type: fields[field].type, - weight, // will only be undefined for object types - resolveTo, - }; - }); - return fieldMap; - }); + // const typesInUnion = getFieldsForUnionType(unionType, typeWeightObject); + const typesInUnion: FieldMap[] = getFieldsForUnionType(unionType, typeWeightObject); - const common: FieldMap = types.reduce((prev: FieldMap, fieldMap: FieldMap): FieldMap => { - // iterate through the field map checking the types for any common field names - const commonFields: FieldMap = {}; - Object.keys(prev).forEach((field: string) => { - if (fieldMap[field]) { - if (compareTypes(prev[field].type, fieldMap[field].type)) { - // they match add the type to the next set - commonFields[field] = prev[field]; - } - } - }); - return commonFields; - }); + // reduce the data for all the types in the union + const commonFields: FieldMap = getSharedFieldsFromUnionTypes(typesInUnion); - // transform commonFields into the correct format + // transform commonFields into the correct format for the type weight object const fieldTypes: Fields = {}; - Object.keys(common).forEach((field: string) => { - // scalar => weight - // list => resolveTo + weight(function) - // fields that resolve to objects do not need to appear on the union type - const current = common[field].type; + Object.keys(commonFields).forEach((field: string) => { + /** + * The type weight object requires that: + * a. scalars have a weight + * b. lists have a resolveTo and weight property + * c. objects have a resolveTo type. + * */ + + const current = commonFields[field].type; if (isScalarType(current)) { fieldTypes[field] = { - weight: common[field].weight, + weight: commonFields[field].weight, }; } else if (isObjectType(current) || isInterfaceType(current) || isUnionType(current)) { fieldTypes[field] = { - resolveTo: common[field].resolveTo, + resolveTo: commonFields[field].resolveTo, weight: typeWeights.object, }; } else if (isListType(current)) { fieldTypes[field] = { - resolveTo: common[field].resolveTo, - weight: common[field].weight, + resolveTo: commonFields[field].resolveTo, + weight: commonFields[field].weight, }; } else if (isNonNullType(current)) { throw new Error('non null types not supported on unions'); @@ -303,13 +311,52 @@ function parseTypes(schema: GraphQLSchema, typeWeights: TypeWeightSet): TypeWeig throw new Error('Unhandled union type. Should never get here'); } }); - result[unionType.name.toLowerCase()] = { + typeWeightsWithUnions[unionType.name.toLowerCase()] = { fields: fieldTypes, weight: typeWeights.object, }; }); - return result; + return typeWeightsWithUnions; +} +/** + * 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 typeWeights + * @returns + */ +function parseTypes(schema: GraphQLSchema, typeWeights: TypeWeightSet): TypeWeightObject { + const typeMap: ObjMap = schema.getTypeMap(); + + const result: TypeWeightObject = {}; + + const unions: GraphQLUnionType[] = []; + + // 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.startsWith('__')) { + if (isObjectType(currentType) || isInterfaceType(currentType)) { + // Add the type and it's associated fields to the result + result[typeName] = parseObjectFields(currentType, result, typeWeights); + } else if (isEnumType(currentType)) { + result[typeName] = { + fields: {}, + weight: typeWeights.scalar, + }; + } else if (isUnionType(currentType)) { + unions.push(currentType); + } else if (!isScalarType(currentType)) { + throw new Error(`ERROR: buildTypeWeight: Unsupported type: ${currentType}`); + } + } + }); + + return parseUnionTypes(unions, typeWeights, result); } /** From 859ba4d0cac03f2d1b657b449403313182081459 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Mon, 18 Jul 2022 19:49:44 -0400 Subject: [PATCH 22/23] udpated ast parser comments --- src/analysis/ASTnodefunctions.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/analysis/ASTnodefunctions.ts b/src/analysis/ASTnodefunctions.ts index c9d1456..436e607 100644 --- a/src/analysis/ASTnodefunctions.ts +++ b/src/analysis/ASTnodefunctions.ts @@ -214,8 +214,8 @@ class ASTParser { namedType.toLowerCase() ); + // Don't count fragment complexity in the node's complexity. Only when fragment is used. this.fragmentCache[fragmentName] = fragmentComplexity; - return complexity; // Don't count complexity here. Only when fragment is used. } else { // TODO: Verify that are no other type definition nodes that need to be handled (see ast.d.ts in 'graphql') // Other types include TypeSystemDefinitionNode (Schema, Type, Directvie) and @@ -227,14 +227,13 @@ class ASTParser { documentNode(node: DocumentNode): number { let complexity = 0; - // iterate through 'definitions' array on the document node - // FIXME: create a copy to preserve original AST order if needed elsewhere + // sort the definitions array by kind so that fragments are always parsed first. + // Fragments must be parsed first so that their complexity is available to other nodes. const sortedDefinitions = [...node.definitions].sort((a, b) => a.kind.localeCompare(b.kind) ); for (let i = 0; i < sortedDefinitions.length; i += 1) { // call the function to handle the various types of definition nodes - // FIXME: Need to parse fragment definitions first so that remaining complexity has access to query complexities complexity += this.definitionNode(sortedDefinitions[i]); } return complexity; From afd8c63f2ab461909200a2de35ce44d4a7093a02 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Tue, 19 Jul 2022 18:26:47 -0400 Subject: [PATCH 23/23] renamed ASTnodefunctions to ASTParser to align with class name --- src/analysis/{ASTnodefunctions.ts => ASTParser.ts} | 0 src/analysis/typeComplexityAnalysis.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/analysis/{ASTnodefunctions.ts => ASTParser.ts} (100%) diff --git a/src/analysis/ASTnodefunctions.ts b/src/analysis/ASTParser.ts similarity index 100% rename from src/analysis/ASTnodefunctions.ts rename to src/analysis/ASTParser.ts diff --git a/src/analysis/typeComplexityAnalysis.ts b/src/analysis/typeComplexityAnalysis.ts index 366d4b6..9855cef 100644 --- a/src/analysis/typeComplexityAnalysis.ts +++ b/src/analysis/typeComplexityAnalysis.ts @@ -1,6 +1,6 @@ import { DocumentNode } from 'graphql'; import { TypeWeightObject, Variables } from '../@types/buildTypeWeights'; -import ASTParser from './ASTnodefunctions'; +import ASTParser from './ASTParser'; /** * Calculate the complexity for the query by recursivly traversing through the query AST,