diff --git a/packages/plugins/cost-limit/src/index.ts b/packages/plugins/cost-limit/src/index.ts index dfaeed1c..97b6699b 100644 --- a/packages/plugins/cost-limit/src/index.ts +++ b/packages/plugins/cost-limit/src/index.ts @@ -61,6 +61,7 @@ class CostLimitVisitor { onOperationDefinitionEnter(operation: OperationDefinitionNode): void { const complexity = this.computeComplexity(operation); + if (complexity > this.config.maxCost) { const message = this.config.exposeLimits ? `Query Cost limit of ${this.config.maxCost} exceeded, found ${complexity}.` @@ -101,6 +102,19 @@ class CostLimitVisitor { let cost = this.config.scalarCost; if ('selectionSet' in node && node.selectionSet) { cost = this.config.objectCost; + + let setMultiplier = 1; + if ('arguments' in node && node.arguments) { + for (const arg of node.arguments) { + if (arg.name.value === 'first' || arg.name.value === 'last') { + if (arg.value.kind === 'IntValue') { + setMultiplier = Math.max(parseInt(arg.value.value, 10) ?? 1, setMultiplier); + } + break; + } + } + } + for (const child of node.selectionSet.selections) { if ( this.config.flattenFragments && @@ -111,6 +125,8 @@ class CostLimitVisitor { cost += this.config.depthCostFactor * this.computeComplexity(child, depth + 1); } } + // Apply setMultiplier to the total cost of current node and its children + cost *= setMultiplier; } else if (node.kind === Kind.FRAGMENT_SPREAD) { if (this.visitedFragments.has(node.name.value)) { const visitCost = this.visitedFragments.get(node.name.value) ?? 0; diff --git a/packages/plugins/cost-limit/test/index.spec.ts b/packages/plugins/cost-limit/test/index.spec.ts index e74b8141..f80cf924 100644 --- a/packages/plugins/cost-limit/test/index.spec.ts +++ b/packages/plugins/cost-limit/test/index.spec.ts @@ -17,7 +17,7 @@ const typeDefinitions = ` } type Query { - books: [Book] + books(first: Int, last: Int): [Book] getBook(title: String): Book } `; @@ -35,7 +35,15 @@ const books = [ const resolvers = { Query: { - books: () => books, + books: (_: any, { first, last }: { first?: number; last?: number }) => { + if (first !== undefined) { + return books.slice(0, first); + } + if (last !== undefined) { + return books.slice(-last); + } + return books; + }, getBook: (_: any, { title }: { title: string }) => books.find((book) => book.title === title), }, }; @@ -353,4 +361,74 @@ describe('costLimitPlugin', () => { `Syntax Error: Query Cost limit of ${maxCost} exceeded, found 12.`, ]); }); + + it('supports pagination using first or last', async () => { + const maxCost = 20; + const testkit = createTestkit( + [ + costLimitPlugin({ + maxCost: maxCost, + objectCost: 4, + scalarCost: 2, + depthCostFactor: 1.5, + ignoreIntrospection: true, + }), + ], + schema, + ); + const result = await testkit.execute(` + query { + firstBooks: books(first: 1) { + title + author + } + + lastBooks: books(last: 1) { + title + author + } + } + `); + + assertSingleExecutionValue(result); + expect(result.errors).toBeUndefined(); + }); + + it('rejects pagination using first or last when cost limit is exceeded', async () => { + const maxCost = 57; + const testkit = createTestkit( + [ + costLimitPlugin({ + maxCost: maxCost, + objectCost: 4, + scalarCost: 2, + depthCostFactor: 1.5, + ignoreIntrospection: true, + }), + ], + schema, + ); + const result = await testkit.execute(` + query { + firstBooks: books(first: 2) { + title + author + } + ...BookFragmentLast3 + + fragment BookFragmentLast3 on Query { + books(last: 3) { + title + author + } + } + } + `); + + assertSingleExecutionValue(result); + expect(result.errors).toBeDefined(); + expect(result.errors?.map((error) => error.message)).toEqual([ + `Syntax Error: Query Cost limit of ${maxCost} exceeded, found 77.`, + ]); + }); });