Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/plugins/cost-limit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.`
Expand All @@ -85,6 +86,7 @@ class CostLimitVisitor {
node: FieldNode | FragmentDefinitionNode | InlineFragmentNode | OperationDefinitionNode | FragmentSpreadNode,
depth = 0,
): number {

if (this.config.ignoreIntrospection && 'name' in node && node.name?.value === '__schema') {
return 0;
}
Expand All @@ -96,6 +98,16 @@ class CostLimitVisitor {
let cost = this.config.scalarCost;
if ('selectionSet' in node && node.selectionSet) {
cost = this.config.objectCost;

// Check if the node has first/last arguments and assign value to setMultiplier
const setMultiplier = 'arguments' in node && node.arguments ?
node.arguments.reduce((mult, arg) => {
if (arg.name.value === 'first' || arg.name.value === 'last') {
return arg.value.kind === 'IntValue' ? parseInt(arg.value.value, 10) : 1;
}
return mult;
}, 1) : 1;
Copy link
Member

@LMaxence LMaxence Apr 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your contribution :)

This function is already fairly complex, and it would be preferred that we avoid using a reduce in favor of more readable items:

let multiplier = 1

if ('arguments' in node && node.arguments) {
   if (arg.name.value === 'first' || arg.name.value === 'last') {
     multiplier = arg.value.kind === 'IntValue' ? parseInt(arg.value.value, 10) : multiplier
   }
}

Also, we should check that parseInt does not return NaN

Copy link

@marceloverdijk marceloverdijk Apr 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be nice if this could be configurable, like supporting a limit argument as well.

Copy link

@quinkinser quinkinser Jul 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The cost-multiplier logic based on first/last arguments is a solid feature, but it’s hidden as an implementation detail. It also frames this as a Relay specific addition, which probably isn't the main goal (I use relay spec, so I'm not complaining about that!).

To improve clarity and flexibility as mentioned by @marceloverdijk, could we expose it as a config option? Naming ideas below:

costMultiplyingArgs: string[] | false; // or empty array if don't want to deal with false

// or
costMultiplyingPaginationArgs: string[] | false;

// the default could be ['first', 'last'] if needed for backwards compat

This would:

  • Make the behavior visible in the public API (even if users don't deviate from default, at least it's clear there is a cost multiplier for pagination args)
  • Let users extend it to support limit, take, etc.
  • Let users disable it entirely (setting to false or empty array)

Right now, users might miss that this logic is in effect, or be unable to adapt it to different pagination conventions.


for (const child of node.selectionSet.selections) {
if (
this.config.flattenFragments &&
Expand All @@ -106,6 +118,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;
Expand Down
82 changes: 80 additions & 2 deletions packages/plugins/cost-limit/test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const typeDefinitions = `
}

type Query {
books: [Book]
books(first: Int, last: Int): [Book]
getBook(title: String): Book
}
`;
Expand All @@ -30,7 +30,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),
},
};
Expand Down Expand Up @@ -285,4 +293,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.`,
]);
});
});