Skip to content

Commit e4c287c

Browse files
andimarekclaude
andcommitted
Add query depth and field count limits to Validator
This provides a lightweight alternative to ExecutableNormalizedOperation (ENO) for tracking query complexity during validation. New features: - QueryComplexityLimits class with maxDepth and maxFieldsCount settings - Configuration via GraphQLContext using QueryComplexityLimits.KEY - Fragment fields counted at each spread site (like ENO) - Depth tracking measures nested Field nodes - New validation error types: MaxQueryDepthExceeded, MaxQueryFieldsExceeded Implementation notes: - Fragment complexity is calculated lazily during first spread traversal - No additional AST traversal needed - complexity tracked during normal validation traversal - Subsequent spreads of the same fragment add the stored complexity Usage: ```java QueryComplexityLimits limits = QueryComplexityLimits.newLimits() .maxDepth(10) .maxFieldsCount(100) .build(); ExecutionInput input = ExecutionInput.newExecutionInput() .query(query) .graphQLContext(ctx -> ctx.put(QueryComplexityLimits.KEY, limits)) .build(); ``` Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e636a77 commit e4c287c

13 files changed

+871
-10
lines changed

src/main/java/graphql/GraphQL.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import graphql.language.Document;
2727
import graphql.schema.GraphQLSchema;
2828
import graphql.validation.OperationValidationRule;
29+
import graphql.validation.QueryComplexityLimits;
2930
import graphql.validation.ValidationError;
3031
import org.jspecify.annotations.NullMarked;
3132
import org.jspecify.annotations.NullUnmarked;
@@ -600,8 +601,9 @@ private List<ValidationError> validate(ExecutionInput executionInput, Document d
600601
validationCtx.onDispatched();
601602

602603
Predicate<OperationValidationRule> validationRulePredicate = executionInput.getGraphQLContext().getOrDefault(ParseAndValidate.INTERNAL_VALIDATION_PREDICATE_HINT, r -> true);
603-
Locale locale = executionInput.getLocale();
604-
List<ValidationError> validationErrors = ParseAndValidate.validate(graphQLSchema, document, validationRulePredicate, locale);
604+
Locale locale = executionInput.getLocale() != null ? executionInput.getLocale() : Locale.getDefault();
605+
QueryComplexityLimits limits = executionInput.getGraphQLContext().get(QueryComplexityLimits.KEY);
606+
List<ValidationError> validationErrors = ParseAndValidate.validate(graphQLSchema, document, validationRulePredicate, locale, limits);
605607

606608
validationCtx.onCompleted(validationErrors, null);
607609
return validationErrors;

src/main/java/graphql/ParseAndValidate.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import graphql.parser.ParserOptions;
88
import graphql.schema.GraphQLSchema;
99
import graphql.validation.OperationValidationRule;
10+
import graphql.validation.QueryComplexityLimits;
1011
import graphql.validation.ValidationError;
1112
import graphql.validation.Validator;
1213
import org.jspecify.annotations.NonNull;
@@ -118,8 +119,23 @@ public static List<ValidationError> validate(@NonNull GraphQLSchema graphQLSchem
118119
* @return a result object that indicates how this operation went
119120
*/
120121
public static List<ValidationError> validate(@NonNull GraphQLSchema graphQLSchema, @NonNull Document parsedDocument, @NonNull Predicate<OperationValidationRule> rulePredicate, @NonNull Locale locale) {
122+
return validate(graphQLSchema, parsedDocument, rulePredicate, locale, null);
123+
}
124+
125+
/**
126+
* This can be called to validate a parsed graphql query.
127+
*
128+
* @param graphQLSchema the graphql schema to validate against
129+
* @param parsedDocument the previously parsed document
130+
* @param rulePredicate this predicate is used to decide what validation rules will be applied
131+
* @param locale the current locale
132+
* @param limits optional query complexity limits to enforce
133+
*
134+
* @return a result object that indicates how this operation went
135+
*/
136+
public static List<ValidationError> validate(@NonNull GraphQLSchema graphQLSchema, @NonNull Document parsedDocument, @NonNull Predicate<OperationValidationRule> rulePredicate, @NonNull Locale locale, QueryComplexityLimits limits) {
121137
Validator validator = new Validator();
122-
return validator.validateDocument(graphQLSchema, parsedDocument, rulePredicate, locale);
138+
return validator.validateDocument(graphQLSchema, parsedDocument, rulePredicate, locale, limits);
123139
}
124140

125141
/**
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package graphql.validation;
2+
3+
import graphql.Internal;
4+
import org.jspecify.annotations.NullMarked;
5+
6+
/**
7+
* Holds pre-calculated complexity metrics for a fragment definition.
8+
* This is used to efficiently track query complexity when fragments are spread
9+
* at multiple locations in a query.
10+
*/
11+
@Internal
12+
@NullMarked
13+
class FragmentComplexityInfo {
14+
15+
private final int fieldCount;
16+
private final int maxDepth;
17+
18+
FragmentComplexityInfo(int fieldCount, int maxDepth) {
19+
this.fieldCount = fieldCount;
20+
this.maxDepth = maxDepth;
21+
}
22+
23+
/**
24+
* @return the total number of fields in this fragment, including fields from nested fragments
25+
*/
26+
int getFieldCount() {
27+
return fieldCount;
28+
}
29+
30+
/**
31+
* @return the maximum depth of fields within this fragment
32+
*/
33+
int getMaxDepth() {
34+
return maxDepth;
35+
}
36+
37+
@Override
38+
public String toString() {
39+
return "FragmentComplexityInfo{" +
40+
"fieldCount=" + fieldCount +
41+
", maxDepth=" + maxDepth +
42+
'}';
43+
}
44+
}

src/main/java/graphql/validation/OperationValidator.java

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,16 @@ public class OperationValidator implements DocumentVisitor {
328328
// --- State: SubscriptionUniqueRootField ---
329329
private final FieldCollector fieldCollector = new FieldCollector();
330330

331+
// --- State: Query Complexity Limits ---
332+
private int fieldCount = 0;
333+
private int currentFieldDepth = 0;
334+
private int maxFieldDepthSeen = 0;
335+
private final QueryComplexityLimits complexityLimits;
336+
// Fragment complexity calculated lazily during first spread
337+
private final Map<String, FragmentComplexityInfo> fragmentComplexityMap = new HashMap<>();
338+
// Max depth seen during current fragment traversal (for calculating fragment's internal depth)
339+
private int fragmentTraversalMaxDepth = 0;
340+
331341
// --- Track whether we're in a context where fragment spread rules should run ---
332342
// fragmentRetraversalDepth == 0 means we're NOT inside a manually-traversed fragment => run non-fragment-spread checks
333343
// operationScope means we're inside an operation => can trigger fragment traversal
@@ -340,6 +350,7 @@ public OperationValidator(ValidationContext validationContext, ValidationErrorCo
340350
this.validationUtil = new ValidationUtil();
341351
this.rulePredicate = rulePredicate;
342352
this.allRulesEnabled = detectAllRulesEnabled(rulePredicate);
353+
this.complexityLimits = validationContext.getQueryComplexityLimits();
343354
prepareFragmentSpreadsMap();
344355
}
345356

@@ -388,6 +399,29 @@ private boolean shouldRunOperationScopedRules() {
388399
return operationScope;
389400
}
390401

402+
// ==================== Query Complexity Limit Helpers ====================
403+
404+
private void checkFieldCountLimit() {
405+
if (fieldCount > complexityLimits.getMaxFieldsCount()) {
406+
throw new QueryComplexityLimitsExceeded(
407+
ValidationErrorType.MaxQueryFieldsExceeded,
408+
complexityLimits.getMaxFieldsCount(),
409+
fieldCount);
410+
}
411+
}
412+
413+
private void checkDepthLimit(int depth) {
414+
if (depth > maxFieldDepthSeen) {
415+
maxFieldDepthSeen = depth;
416+
if (maxFieldDepthSeen > complexityLimits.getMaxDepth()) {
417+
throw new QueryComplexityLimitsExceeded(
418+
ValidationErrorType.MaxQueryDepthExceeded,
419+
complexityLimits.getMaxDepth(),
420+
maxFieldDepthSeen);
421+
}
422+
}
423+
}
424+
391425
@Override
392426
public void enter(Node node, List<Node> ancestors) {
393427
validationContext.getTraversalContext().enter(node, ancestors);
@@ -401,6 +435,17 @@ public void enter(Node node, List<Node> ancestors) {
401435
} else if (node instanceof VariableDefinition) {
402436
checkVariableDefinition((VariableDefinition) node);
403437
} else if (node instanceof Field) {
438+
// Track complexity only during operation scope
439+
if (operationScope) {
440+
fieldCount++;
441+
currentFieldDepth++;
442+
checkFieldCountLimit();
443+
checkDepthLimit(currentFieldDepth);
444+
// Track max depth during fragment traversal for storing later
445+
if (fragmentRetraversalDepth > 0 && currentFieldDepth > fragmentTraversalMaxDepth) {
446+
fragmentTraversalMaxDepth = currentFieldDepth;
447+
}
448+
}
404449
checkField((Field) node);
405450
} else if (node instanceof InlineFragment) {
406451
checkInlineFragment((InlineFragment) node);
@@ -433,6 +478,10 @@ public void leave(Node node, List<Node> ancestors) {
433478
leaveSelectionSet();
434479
} else if (node instanceof FragmentDefinition) {
435480
leaveFragmentDefinition();
481+
} else if (node instanceof Field) {
482+
if (operationScope) {
483+
currentFieldDepth--;
484+
}
436485
}
437486
}
438487

@@ -611,14 +660,50 @@ private void checkFragmentSpread(FragmentSpread node, List<Node> ancestors) {
611660
}
612661
}
613662

614-
// Manually traverse into fragment definition during operation scope
663+
// Handle complexity tracking and fragment traversal
615664
if (operationScope) {
616-
FragmentDefinition fragment = validationContext.getFragment(node.getName());
617-
if (fragment != null && !visitedFragmentSpreads.contains(node.getName())) {
618-
visitedFragmentSpreads.add(node.getName());
665+
String fragmentName = node.getName();
666+
FragmentDefinition fragment = validationContext.getFragment(fragmentName);
667+
668+
if (visitedFragmentSpreads.contains(fragmentName)) {
669+
// Subsequent spread - add stored complexity (don't traverse again)
670+
FragmentComplexityInfo info = fragmentComplexityMap.get(fragmentName);
671+
if (info != null) {
672+
fieldCount += info.getFieldCount();
673+
checkFieldCountLimit();
674+
int potentialDepth = currentFieldDepth + info.getMaxDepth();
675+
checkDepthLimit(potentialDepth);
676+
// Update max depth if we're inside a fragment traversal
677+
if (fragmentRetraversalDepth > 0 && potentialDepth > fragmentTraversalMaxDepth) {
678+
fragmentTraversalMaxDepth = potentialDepth;
679+
}
680+
}
681+
} else if (fragment != null) {
682+
// First spread - traverse and track complexity
683+
visitedFragmentSpreads.add(fragmentName);
684+
685+
int fieldCountBefore = fieldCount;
686+
int depthAtEntry = currentFieldDepth;
687+
int previousFragmentMaxDepth = fragmentTraversalMaxDepth;
688+
689+
// Initialize max depth tracking for this fragment
690+
fragmentTraversalMaxDepth = currentFieldDepth;
691+
619692
fragmentRetraversalDepth++;
620693
new LanguageTraversal(ancestors).traverse(fragment, this);
621694
fragmentRetraversalDepth--;
695+
696+
// Calculate and store fragment complexity
697+
int fragmentFieldCount = fieldCount - fieldCountBefore;
698+
int fragmentMaxInternalDepth = fragmentTraversalMaxDepth - depthAtEntry;
699+
700+
fragmentComplexityMap.put(fragmentName,
701+
new FragmentComplexityInfo(fragmentFieldCount, fragmentMaxInternalDepth));
702+
703+
// Restore max depth for outer fragment (if nested)
704+
if (fragmentRetraversalDepth > 0 && previousFragmentMaxDepth > fragmentTraversalMaxDepth) {
705+
fragmentTraversalMaxDepth = previousFragmentMaxDepth;
706+
}
622707
}
623708
}
624709
}
@@ -724,6 +809,12 @@ private void leaveOperationDefinition() {
724809
}
725810
}
726811
}
812+
813+
// Reset complexity counters for next operation
814+
fieldCount = 0;
815+
currentFieldDepth = 0;
816+
maxFieldDepthSeen = 0;
817+
fragmentTraversalMaxDepth = 0;
727818
}
728819

729820
private void leaveSelectionSet() {

0 commit comments

Comments
 (0)