Skip to content
Merged
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
16 changes: 10 additions & 6 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22, 24, 26]
graphql-version: ['~15.0', '~16.0']
steps:
- name: Checkout repository
Expand All @@ -21,13 +22,13 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 'latest'
node-version: ${{ matrix.node-version }}

- name: Restore cache
uses: actions/cache@v3
with:
path: node_modules
key: v1-dependencies-${{ hashFiles('package.json') }}-${{ matrix.graphql-version }}
key: v1-dependencies-${{ hashFiles('package.json') }}-${{ matrix.node-version }}-${{ matrix.graphql-version }}
restore-keys: |
v1-dependencies-

Expand All @@ -47,27 +48,30 @@ jobs:
uses: actions/cache@v3
with:
path: node_modules
key: v1-dependencies-${{ hashFiles('package.json') }}-${{ matrix.graphql-version }}
key: v1-dependencies-${{ hashFiles('package.json') }}-${{ matrix.node-version }}-${{ matrix.graphql-version }}

- name: Run tests
run: yarn test

test-and-build-with-typecheck:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22, 24, 26]
steps:
- name: Checkout repository
uses: actions/checkout@v2

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 'latest'
node-version: ${{ matrix.node-version }}

- name: Restore cache
uses: actions/cache@v3
with:
path: node_modules
key: v1-dependencies-${{ hashFiles('package.json') }}
key: v1-dependencies-${{ hashFiles('package.json') }}-${{ matrix.node-version }}
restore-keys: |
v1-dependencies-

Expand All @@ -78,7 +82,7 @@ jobs:
uses: actions/cache@v3
with:
path: node_modules
key: v1-dependencies-${{ hashFiles('package.json') }}
key: v1-dependencies-${{ hashFiles('package.json') }}-${{ matrix.node-version }}

- name: Run tests
run: yarn test
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,21 @@
},
"author": "Ivo Meißner",
"license": "MIT",
"resolutions": {
"yargs": "^18.0.0"
},
"devDependencies": {
"@types/assert": "^1.5.6",
"@types/chai": "^4.2.22",
"@types/lodash.get": "^4.4.6",
"@types/mocha": "^9.0.0",
"@types/mocha": "^10.0.0",
"@types/semver": "^7.3.9",
"@typescript-eslint/eslint-plugin": "^5.1.0",
"@typescript-eslint/parser": "^5.1.0",
"chai": "^4.3.4",
"eslint": "^8.0.1",
"graphql": "~14.6.0 || ~15.0.0 || ~16.0.0",
"mocha": "^9.1.3",
"mocha": "^11.7.6",
"prettier": "^2.4.1",
"rimraf": "^3.0.2",
"semver": "^7.3.5",
Expand Down
29 changes: 23 additions & 6 deletions src/QueryComplexity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,8 @@ export default class QueryComplexity {
| GraphQLObjectType
| GraphQLInterfaceType
| GraphQLUnionType
| undefined
| undefined,
activeFragments: Set<string> = new Set()
): number {
if (node.selectionSet && typeDef) {
let fields: GraphQLFieldMap<any, any> = {};
Expand Down Expand Up @@ -368,7 +369,11 @@ export default class QueryComplexity {
// Check if we have child complexity
let childComplexity = 0;
if (isCompositeType(fieldType)) {
childComplexity = this.nodeComplexity(childNode, fieldType);
childComplexity = this.nodeComplexity(
childNode,
fieldType,
activeFragments
);
}

// Run estimators one after another and return first valid complexity
Expand Down Expand Up @@ -410,22 +415,33 @@ export default class QueryComplexity {
break;
}
case Kind.FRAGMENT_SPREAD: {
const fragment = this.context.getFragment(childNode.name.value);
const fragmentName = childNode.name.value;
const fragment = this.context.getFragment(fragmentName);
// Unknown fragment, should be caught by other validation rules
if (!fragment) {
break;
}
// Circular fragment reference — skip to avoid infinite recursion
if (activeFragments.has(fragmentName)) {
break;
}
const fragmentType = this.context
.getSchema()
.getType(fragment.typeCondition.name.value);
// Invalid fragment type, ignore. Should be caught by other validation rules
if (!isCompositeType(fragmentType)) {
break;
}
// Track this fragment on the active path so deeper spreads can
// detect cycles, then remove it on the way back up (backtracking)
// to avoid copying the set on every descent.
activeFragments.add(fragmentName);
const nodeComplexity = this.nodeComplexity(
fragment,
fragmentType
fragmentType,
activeFragments
);
activeFragments.delete(fragmentName);
if (isAbstractType(fragmentType)) {
// Add fragment complexity for all possible types
innerComplexities = addComplexities(
Expand Down Expand Up @@ -459,7 +475,8 @@ export default class QueryComplexity {

const nodeComplexity = this.nodeComplexity(
childNode,
inlineFragmentType
inlineFragmentType,
activeFragments
);
if (isAbstractType(inlineFragmentType)) {
// Add fragment complexity for all possible types
Expand All @@ -483,7 +500,7 @@ export default class QueryComplexity {
}
default: {
innerComplexities = addComplexities(
this.nodeComplexity(childNode, typeDef),
this.nodeComplexity(childNode, typeDef, activeFragments),
complexities,
possibleTypeNames
);
Expand Down
46 changes: 46 additions & 0 deletions src/__tests__/QueryComplexity-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,52 @@ describe('QueryComplexity analysis', () => {
).to.throw('Query exceeds the maximum allowed number of nodes.');
});

it('should handle self-referencing fragments without infinite loop', () => {
const query = parse(`
query {
...A
}
fragment A on Query {
scalar
...A
}
`);

// The back-edge spread (…A inside A) is skipped; only the non-recursive
// fields contribute to complexity.
const complexity = getComplexity({
estimators: [simpleEstimator({ defaultComplexity: 1 })],
schema,
query,
});
expect(complexity).to.equal(1);
});

it('should handle mutually recursive fragments without infinite loop', () => {
const query = parse(`
query {
...A
}
fragment A on Query {
scalar
...B
}
fragment B on Query {
scalar
...A
}
`);

// A spreads B (new path), B tries to spread A (already active) — skipped.
// Only the two `scalar` fields contribute.
const complexity = getComplexity({
estimators: [simpleEstimator({ defaultComplexity: 1 })],
schema,
query,
});
expect(complexity).to.equal(2);
});

it('should limit the number of query nodes to 10_000 by default', () => {
const failingQuery = parse(`
query {
Expand Down
Loading
Loading