From 0ef07a47a620b0e045be69efa9945ce0e5f64c57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivo=20Mei=C3=9Fner?= Date: Mon, 23 Sep 2019 08:42:36 +0200 Subject: [PATCH] Add fieldExtensionsEstimator, deprecate fieldConfigEstimator #19 --- README.md | 6 +- src/estimators/fieldConfig/index.ts | 7 + src/estimators/fieldExtensions/README.md | 97 ++++++ .../fieldExtensionsEstimator-test.ts | 313 ++++++++++++++++++ .../__tests__/fixtures/schema.ts | 151 +++++++++ src/estimators/fieldExtensions/index.ts | 14 + src/estimators/index.ts | 1 + yarn.lock | 12 +- 8 files changed, 594 insertions(+), 7 deletions(-) create mode 100644 src/estimators/fieldExtensions/README.md create mode 100644 src/estimators/fieldExtensions/__tests__/fieldExtensionsEstimator-test.ts create mode 100644 src/estimators/fieldExtensions/__tests__/fixtures/schema.ts create mode 100644 src/estimators/fieldExtensions/index.ts diff --git a/README.md b/README.md index c0e49f5..2707c29 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,11 @@ or write your own: last estimator in the chain for a default value. * **[`directiveEstimator`](src/estimators/directive/README.md):** Set the complexity via a directive in your schema definition (for example via GraphQL SDL) -* **[`fieldConfigEstimator`](src/estimators/fieldConfig/README.md):** The field config estimator lets you set a numeric value or a custom estimator +* **[`fieldExtensionsEstimator`](src/estimators/fieldExtensions/README.md):** The field extensions estimator lets you set a numeric value or a custom estimator + function in the field config extensions of your schema. +* **[`fieldConfigEstimator`](src/estimators/fieldConfig/README.md):** (DEPRECATED) The field config estimator lets you set a numeric value or a custom estimator function in the field config of your schema. -* **[`legacyEstimator`](src/estimators/legacy/README.md):** The legacy estimator implements the logic of previous versions. Can be used +* **[`legacyEstimator`](src/estimators/legacy/README.md):** (DEPRECATED) The legacy estimator implements the logic of previous versions. Can be used to gradually migrate your codebase to new estimators. * PRs welcome... diff --git a/src/estimators/fieldConfig/index.ts b/src/estimators/fieldConfig/index.ts index d48606f..407b9b8 100644 --- a/src/estimators/fieldConfig/index.ts +++ b/src/estimators/fieldConfig/index.ts @@ -1,6 +1,13 @@ import {ComplexityEstimator, ComplexityEstimatorArgs} from '../../QueryComplexity'; +/** + * @deprecated Use fieldExtensionsEstimator instead + */ export default function (): ComplexityEstimator { + console.warn( + 'DEPRECATION WARNING: fieldConfigEstimator is deprecated. Use fieldExtensionsEstimator instead' + ); + return (args: ComplexityEstimatorArgs) => { // Calculate complexity score if (typeof args.field.complexity === 'number') { diff --git a/src/estimators/fieldExtensions/README.md b/src/estimators/fieldExtensions/README.md new file mode 100644 index 0000000..89dead9 --- /dev/null +++ b/src/estimators/fieldExtensions/README.md @@ -0,0 +1,97 @@ +# Field Extensions Estimator + +The `fieldExtensionsEstimator` lets you define a numeric value or a custom estimator +in the field config extensions of your GraphQL schema. If no complexity is set in the field config, +the estimator does not return any value and the next estimator in the chain is executed. + +## Usage + +```typescript +import queryComplexity, { + fieldExtensionsEstimator, + simpleEstimator +} from 'graphql-query-complexity'; + +const rule = queryComplexity({ + estimators: [ + fieldExtensionsEstimator(), + + // We use the simpleEstimator as fallback so we only need to + // define the complexity for non 1 values (this is not required...) + simpleEstimator({defaultComplexity: 1}) + ] + // ... other config +}); +``` + +You can set a custom complexity as a numeric value in the field config: + +```javascript +const Post = new GraphQLObjectType({ + name: 'Post', + fields: () => ({ + title: { type: GraphQLString }, + text: { + type: GraphQLString, + extensions: { + complexity: 5 + }, + }, + }), +}); +``` + +**Example Query:** + +```graphql +query { + posts(count: 10) { + title + text + } +} +``` + +This query would result in a complexity of 7. +5 for the `text` field and 1 for each of the other fields. + +You can also pass an estimator in the field config to determine a custom complexity. +This function will provide the complexity of the child nodes as well as the field input arguments. + +The function signature is the same as for the main estimator which lets you reuse estimators: + +```typescript +type ComplexityEstimatorArgs = { + type: GraphQLCompositeType, + field: GraphQLField, + args: {[key: string]: any}, + childComplexity: number +} + +type ComplexityEstimator = (options: ComplexityEstimatorArgs) => number | void; +``` + +That way you can make a more realistic estimation of individual field complexity values: + +```javascript +const Query = new GraphQLObjectType({ + name: 'Query', + fields: () => ({ + posts: { + type: new GraphQLList(Post), + args: { + count: { + type: GraphQLInt, + defaultValue: 10 + } + }, + extensions: { + complexity: ({args, childComplexity}) => childComplexity * args.count, + }, + }, + }), +}); +``` + +This would result in a complexity of 60 since the `childComplexity` of posts (`text` 5, `title` 1) is multiplied by the +number of posts (`args.count`). diff --git a/src/estimators/fieldExtensions/__tests__/fieldExtensionsEstimator-test.ts b/src/estimators/fieldExtensions/__tests__/fieldExtensionsEstimator-test.ts new file mode 100644 index 0000000..7130bb9 --- /dev/null +++ b/src/estimators/fieldExtensions/__tests__/fieldExtensionsEstimator-test.ts @@ -0,0 +1,313 @@ +/** + * Created by Ivo Meißner on 28.07.17. + */ + +import { + parse, + TypeInfo, + ValidationContext, + visit, + visitWithTypeInfo, +} from 'graphql'; + +import {expect} from 'chai'; + +import schema from './fixtures/schema'; + +import ComplexityVisitor from '../../../QueryComplexity'; +import simpleEstimator from '../../simple'; +import fieldExtensionsEstimator from '../index'; + +describe('fieldExtensions estimator', () => { + const typeInfo = new TypeInfo(schema); + + it('should consider default scalar cost', () => { + const ast = parse(` + query { + scalar + } + `); + + const context = new ValidationContext(schema, ast, typeInfo); + const visitor = new ComplexityVisitor(context, { + maximumComplexity: 100, + estimators: [ + fieldExtensionsEstimator(), + simpleEstimator({ + defaultComplexity: 1 + }) + ] + }); + + visit(ast, visitWithTypeInfo(typeInfo, visitor)); + expect(visitor.complexity).to.equal(1); + }); + + it('should consider custom scalar cost', () => { + const ast = parse(` + query { + complexScalar + } + `); + + const context = new ValidationContext(schema, ast, typeInfo); + const visitor = new ComplexityVisitor(context, { + maximumComplexity: 100, + estimators: [ + fieldExtensionsEstimator(), + simpleEstimator({ + defaultComplexity: 1 + }) + ] + }); + + visit(ast, visitWithTypeInfo(typeInfo, visitor)); + expect(visitor.complexity).to.equal(20); + }); + + it('should consider variable scalar cost', () => { + const ast = parse(` + query { + variableScalar(count: 100) + } + `); + + const context = new ValidationContext(schema, ast, typeInfo); + const visitor = new ComplexityVisitor(context, { + maximumComplexity: 100, + estimators: [ + fieldExtensionsEstimator(), + simpleEstimator({ + defaultComplexity: 1 + }) + ] + }); + + visit(ast, visitWithTypeInfo(typeInfo, visitor)); + expect(visitor.complexity).to.equal(1000); + }); + + it('should not allow negative cost', () => { + const ast = parse(` + query { + variableScalar(count: -100) + } + `); + + const context = new ValidationContext(schema, ast, typeInfo); + const visitor = new ComplexityVisitor(context, { + maximumComplexity: 100, + estimators: [ + fieldExtensionsEstimator(), + simpleEstimator({ + defaultComplexity: 1 + }) + ] + }); + + visit(ast, visitWithTypeInfo(typeInfo, visitor)); + expect(visitor.complexity).to.equal(0); + }); + + it('should report error above threshold', () => { + const ast = parse(` + query { + variableScalar(count: 100) + } + `); + + const context = new ValidationContext(schema, ast, typeInfo); + const visitor = new ComplexityVisitor(context, { + maximumComplexity: 100, + estimators: [ + fieldExtensionsEstimator(), + simpleEstimator({ + defaultComplexity: 1 + }) + ] + }); + + visit(ast, visitWithTypeInfo(typeInfo, visitor)); + expect(visitor.complexity).to.equal(1000); + expect(context.getErrors().length).to.equal(1); + expect(context.getErrors()[0].message).to.equal( + 'The query exceeds the maximum complexity of 100. Actual complexity is 1000' + ); + }); + + it('should add inline fragments', () => { + const ast = parse(` + query { + variableScalar(count: 5) + ...on Query { + scalar + alias: scalar + } + } + `); + + const context = new ValidationContext(schema, ast, typeInfo); + const visitor = new ComplexityVisitor(context, { + maximumComplexity: 100, + estimators: [ + fieldExtensionsEstimator(), + simpleEstimator({ + defaultComplexity: 1 + }) + ] + }); + + visit(ast, visitWithTypeInfo(typeInfo, visitor)); + expect(visitor.complexity).to.equal(52); + }); + + it('should add fragments', () => { + const ast = parse(` + query { + scalar + ...QueryFragment + } + + fragment QueryFragment on Query { + variableScalar(count: 2) + } + `); + + const context = new ValidationContext(schema, ast, typeInfo); + const visitor = new ComplexityVisitor(context, { + maximumComplexity: 100, + estimators: [ + fieldExtensionsEstimator(), + simpleEstimator({ + defaultComplexity: 1 + }) + ] + }); + + visit(ast, visitWithTypeInfo(typeInfo, visitor)); + expect(visitor.complexity).to.equal(21); + }); + + it('should add complexity for union types', () => { + const ast = parse(` + query { + union { + ...on Item { + scalar + complexScalar + } + } + } + `); + + const context = new ValidationContext(schema, ast, typeInfo); + const visitor = new ComplexityVisitor(context, { + maximumComplexity: 100, + estimators: [ + fieldExtensionsEstimator(), + simpleEstimator({ + defaultComplexity: 1 + }) + ] + }); + + visit(ast, visitWithTypeInfo(typeInfo, visitor)); + expect(visitor.complexity).to.equal(22); + }); + + it('should add complexity for interface types', () => { + const ast = parse(` + query { + interface { + name + ...on NameInterface { + name + } + } + } + `); + + const context = new ValidationContext(schema, ast, typeInfo); + const visitor = new ComplexityVisitor(context, { + maximumComplexity: 100, + estimators: [ + fieldExtensionsEstimator(), + simpleEstimator({ + defaultComplexity: 1 + }) + ] + }); + + visit(ast, visitWithTypeInfo(typeInfo, visitor)); + expect(visitor.complexity).to.equal(3); + }); + + it('should add complexity for inline fragments without type condition', () => { + const ast = parse(` + query { + interface { + ... { + name + } + } + } + `); + + const context = new ValidationContext(schema, ast, typeInfo); + const visitor = new ComplexityVisitor(context, { + maximumComplexity: 100, + estimators: [ + fieldExtensionsEstimator(), + simpleEstimator({ + defaultComplexity: 1 + }) + ] + }); + + visit(ast, visitWithTypeInfo(typeInfo, visitor)); + expect(visitor.complexity).to.equal(2); + }); + + it('should add complexity for enum types', () => { + const ast = parse(` + query { + enum + } + `); + + const context = new ValidationContext(schema, ast, typeInfo); + const visitor = new ComplexityVisitor(context, { + maximumComplexity: 100, + estimators: [ + fieldExtensionsEstimator(), + simpleEstimator({ + defaultComplexity: 1 + }) + ] + }); + + visit(ast, visitWithTypeInfo(typeInfo, visitor)); + expect(visitor.complexity).to.equal(1); + }); + + it('should error on a missing non-null argument', () => { + const ast = parse(` + query { + requiredArgs + } + `); + const context = new ValidationContext(schema, ast, typeInfo); + const visitor = new ComplexityVisitor(context, { + maximumComplexity: 100, + estimators: [ + fieldExtensionsEstimator(), + simpleEstimator({ + defaultComplexity: 1 + }) + ] + }); + visit(ast, visitWithTypeInfo(typeInfo, visitor)); + expect(context.getErrors().length).to.equal(1); + expect(context.getErrors()[0].message).to.equal('Argument "count" of required type "Int!" was not provided.'); + }); +}); diff --git a/src/estimators/fieldExtensions/__tests__/fixtures/schema.ts b/src/estimators/fieldExtensions/__tests__/fixtures/schema.ts new file mode 100644 index 0000000..8281eb6 --- /dev/null +++ b/src/estimators/fieldExtensions/__tests__/fixtures/schema.ts @@ -0,0 +1,151 @@ +/** + * Created by Ivo Meißner on 28.07.17. + */ + +import { + GraphQLList, + GraphQLObjectType, + GraphQLNonNull, + GraphQLSchema, + GraphQLString, + GraphQLInt, + GraphQLEnumType, + GraphQLUnionType, + GraphQLInterfaceType, +} from 'graphql'; + +import {ComplexityEstimatorArgs} from '../../../../QueryComplexity'; + +const Item: GraphQLObjectType = new GraphQLObjectType({ + name: 'Item', + fields: () => ({ + variableList: { + type: Item, + extensions: { + complexity: (args: ComplexityEstimatorArgs) => args.childComplexity * (args.args.count || 10), + }, + args: { + count: { + type: GraphQLInt + } + } + }, + scalar: { type: GraphQLString }, + complexScalar: { + type: GraphQLString, + extensions: { + complexity: 20, + }, + }, + variableScalar: { + type: Item, + extensions: { + complexity: (args: ComplexityEstimatorArgs) => 10 * (args.args.count || 10), + }, + args: { + count: { + type: GraphQLInt + } + } + }, + list: { type: new GraphQLList(Item) }, + nonNullItem: { + type: new GraphQLNonNull(Item), + resolve: () => ({}), + }, + nonNullList: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Item))), + resolve: () => [], + }, + }), +}); + +const NameInterface = new GraphQLInterfaceType({ + name: 'NameInterface', + fields: { + name: { type: GraphQLString } + }, + resolveType: () => Item +}); + +const SecondItem = new GraphQLObjectType({ + name: 'SecondItem', + fields: () => ({ + name: {type: GraphQLString}, + scalar: {type: GraphQLString} + }), + interfaces: [ NameInterface ] +}); + +const EnumType = new GraphQLEnumType({ + name: 'RGB', + values: { + RED: { value: 0 }, + GREEN: { value: 1 }, + BLUE: { value: 2 } + } +}); + +const Union = new GraphQLUnionType({ + name: 'Union', + types: [ Item, SecondItem ], + resolveType: () => Item +}); + +const Query = new GraphQLObjectType({ + name: 'Query', + fields: () => ({ + name: { type: GraphQLString }, + variableList: { + type: Item, + extensions: { + complexity: (args: ComplexityEstimatorArgs) => args.childComplexity * (args.args.count || 10), + }, + args: { + count: { + type: GraphQLInt + } + } + }, + interface: {type: NameInterface}, + enum: {type: EnumType}, + scalar: { type: GraphQLString }, + complexScalar: { + type: GraphQLString, + extensions: { + complexity: 20, + }, + }, + union: { type: Union }, + variableScalar: { + type: Item, + extensions: { + complexity: (args: ComplexityEstimatorArgs) => 10 * (args.args.count || 10), + }, + args: { + count: { + type: GraphQLInt + } + } + }, + list: { type: new GraphQLList(Item) }, + nonNullItem: { + type: new GraphQLNonNull(Item), + resolve: () => ({}), + }, + nonNullList: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Item))), + resolve: () => [], + }, + requiredArgs: { + type: Item, + args: { + count: { + type: new GraphQLNonNull(GraphQLInt) + } + } + } + }), +}); + +export default new GraphQLSchema({ query: Query }); diff --git a/src/estimators/fieldExtensions/index.ts b/src/estimators/fieldExtensions/index.ts new file mode 100644 index 0000000..e3597df --- /dev/null +++ b/src/estimators/fieldExtensions/index.ts @@ -0,0 +1,14 @@ +import {ComplexityEstimator, ComplexityEstimatorArgs} from '../../QueryComplexity'; + +export default function (): ComplexityEstimator { + return (args: ComplexityEstimatorArgs) => { + if (args.field.extensions) { + // Calculate complexity score + if (typeof args.field.extensions.complexity === 'number') { + return args.childComplexity + args.field.extensions.complexity; + } else if (typeof args.field.extensions.complexity === 'function') { + return args.field.extensions.complexity(args); + } + } + }; +} diff --git a/src/estimators/index.ts b/src/estimators/index.ts index 984cda0..bdd1c08 100644 --- a/src/estimators/index.ts +++ b/src/estimators/index.ts @@ -2,3 +2,4 @@ export {default as simpleEstimator} from './simple'; export {default as legacyEstimator} from './legacy'; export {default as fieldConfigEstimator} from './fieldConfig'; export {default as directiveEstimator} from './directive'; +export {default as fieldExtensionsEstimator} from './fieldExtensions'; diff --git a/yarn.lock b/yarn.lock index 667c208..0be6830 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25,8 +25,10 @@ resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.3.tgz#419477a3d5202bad19e14c787940a61dc9ea6407" "@types/graphql@^0.13.0 || ^14.0.0": - version "14.0.2" - resolved "https://registry.yarnpkg.com/@types/graphql/-/graphql-14.0.2.tgz#415e3779718c1e0805e418e3a798cb39dccc58ab" + version "14.5.0" + resolved "https://registry.yarnpkg.com/@types/graphql/-/graphql-14.5.0.tgz#a545fb3bc8013a3547cf2f07f5e13a33642b75d6" + dependencies: + graphql "*" "@types/lodash.get@^4.4.4": version "4.4.6" @@ -401,9 +403,9 @@ globals@^11.7.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" -"graphql@^0.13.0 || ^14.0.0": - version "14.0.2" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.0.2.tgz#7dded337a4c3fd2d075692323384034b357f5650" +graphql@*, "graphql@^0.13.0 || ^14.0.0": + version "14.5.7" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.7.tgz#8646a3fcc07922319cc3967eba4a64b32929f77f" dependencies: iterall "^1.2.2"