Skip to content

Commit c77a2ba

Browse files
committed
feat: fix nested rule logic
1 parent 5dd4937 commit c77a2ba

File tree

2 files changed

+145
-129
lines changed

2 files changed

+145
-129
lines changed
Lines changed: 144 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<?php
22
/**
3-
* Corrected Rule to detect and warn about overly nested GraphQL queries,
4-
* with proper fragment handling.
3+
* Rule to detect and warn about overly nested GraphQL queries,
4+
* with proper fragment handling and recursion protection.
55
*
66
* @package WPGraphQL\Debug\Analysis\Rules
77
*/
@@ -15,7 +15,6 @@
1515
use GraphQL\Language\AST\FragmentDefinitionNode;
1616
use GraphQL\Language\AST\FragmentSpreadNode;
1717
use GraphQL\Language\AST\InlineFragmentNode;
18-
use GraphQL\Language\AST\Node;
1918
use GraphQL\Language\AST\OperationDefinitionNode;
2019
use GraphQL\Language\AST\SelectionSetNode;
2120
use GraphQL\Language\Parser;
@@ -25,122 +24,145 @@
2524

2625
class NestedQuery implements AnalyzerItemInterface {
2726

28-
protected int $maxDepth;
29-
protected ?string $message = null;
30-
31-
/**
32-
* @param int $maxDepth The maximum allowed nesting depth.
33-
*/
34-
public function __construct( int $maxDepth = 8 ) {
35-
$this->maxDepth = $maxDepth;
36-
}
37-
38-
/**
39-
* @inheritDoc
40-
*/
41-
public function analyze( string $query, array $variables = [], ?Schema $schema = null ): array {
42-
$triggered = false;
43-
$maxDepthReached = 0;
44-
45-
try {
46-
/** @var DocumentNode $ast */
47-
$ast = Parser::parse( $query );
48-
} catch ( SyntaxError $error ) {
49-
$this->message = 'Failed to analyze nesting depth due to GraphQL syntax error: ' . $error->getMessage();
50-
return [
51-
'triggered' => false,
52-
'message' => $this->message,
53-
'details' => [ 'maxDepthReached' => 0 ],
54-
];
55-
}
56-
57-
// Store fragment definitions in a map for easy lookup
58-
$fragments = [];
59-
foreach ( $ast->definitions as $definition ) {
60-
if ( $definition instanceof FragmentDefinitionNode ) {
61-
$fragments[ $definition->name->value ] = $definition;
62-
}
63-
}
64-
65-
// Find the operation definition and analyze its depth
66-
foreach ( $ast->definitions as $definition ) {
67-
if ( $definition instanceof OperationDefinitionNode ) {
68-
// Start the recursion with a depth of 1, as the first field is the first level of nesting.
69-
$currentDepth = $this->getSelectionSetDepth( $definition->selectionSet, $fragments, 1 );
70-
$maxDepthReached = max( $maxDepthReached, $currentDepth );
71-
}
72-
}
73-
74-
// Use >= to trigger the rule when the max depth is reached or exceeded.
75-
if ( $maxDepthReached >= $this->maxDepth ) {
76-
$triggered = true;
77-
$this->message = sprintf(
78-
'Nested query depth of %d reached or exceeded the configured maximum of %d.',
79-
$maxDepthReached,
80-
$this->maxDepth
81-
);
82-
} else {
83-
$this->message = sprintf(
84-
'Nested query depth of %d is within the allowed limit of %d.',
85-
$maxDepthReached,
86-
$this->maxDepth
87-
);
88-
}
89-
90-
return [
91-
'triggered' => $triggered,
92-
'message' => $this->message,
93-
'details' => [
94-
'maxDepthReached' => $maxDepthReached,
95-
'maxAllowed' => $this->maxDepth,
96-
],
97-
];
98-
}
99-
100-
/**
101-
* Recursively calculates the maximum depth of a SelectionSet, including fragments.
102-
*
103-
* @param SelectionSetNode $selectionSet The selection set to analyze.
104-
* @param array $fragments Map of fragment definitions.
105-
* @param int $currentDepth The current depth of the traversal.
106-
*
107-
* @return int The maximum depth found.
108-
*/
109-
protected function getSelectionSetDepth( SelectionSetNode $selectionSet, array $fragments, int $currentDepth = 0 ): int {
110-
$maxDepth = $currentDepth;
111-
112-
foreach ( $selectionSet->selections as $selection ) {
113-
$selectionMaxDepth = 0;
114-
115-
if ( $selection instanceof FieldNode && $selection->selectionSet instanceof SelectionSetNode ) {
116-
$selectionMaxDepth = $this->getSelectionSetDepth( $selection->selectionSet, $fragments, $currentDepth + 1 );
117-
}
118-
119-
if ( $selection instanceof FragmentSpreadNode ) {
120-
// If a fragment spread is found, get the fragment definition and recurse.
121-
if ( isset( $fragments[ $selection->name->value ] ) ) {
122-
/** @var FragmentDefinitionNode $fragmentDefinition */
123-
$fragmentDefinition = $fragments[ $selection->name->value ];
124-
// The depth of the fragment is added to the current depth.
125-
$selectionMaxDepth = $this->getSelectionSetDepth( $fragmentDefinition->selectionSet, $fragments, $currentDepth + 1 );
126-
}
127-
}
128-
129-
if ( $selection instanceof InlineFragmentNode && $selection->selectionSet instanceof SelectionSetNode ) {
130-
// An inline fragment is also a new selection set, so recurse.
131-
$selectionMaxDepth = $this->getSelectionSetDepth( $selection->selectionSet, $fragments, $currentDepth + 1 );
132-
}
133-
134-
$maxDepth = max( $maxDepth, $selectionMaxDepth );
135-
}
136-
137-
return $maxDepth;
138-
}
139-
140-
/**
141-
* @inheritDoc
142-
*/
143-
public function getKey(): string {
144-
return 'nestedQuery';
145-
}
146-
}
27+
protected int $maxDepth;
28+
protected ?string $message = null;
29+
30+
/**
31+
* @param int $maxDepth The maximum allowed nesting depth.
32+
*/
33+
public function __construct( int $maxDepth = 8 ) {
34+
$this->maxDepth = $maxDepth;
35+
}
36+
37+
/**
38+
* @inheritDoc
39+
*/
40+
public function analyze( string $query, array $variables = [], ?Schema $schema = null ): array {
41+
$triggered = false;
42+
$maxDepthReached = 0;
43+
44+
try {
45+
/** @var DocumentNode $ast */
46+
$ast = Parser::parse( $query );
47+
} catch ( SyntaxError $error ) {
48+
$this->message = 'Failed to analyze nesting depth due to GraphQL syntax error: ' . $error->getMessage();
49+
return [
50+
'triggered' => false,
51+
'message' => $this->message,
52+
'details' => [ 'maxDepthReached' => 0 ],
53+
];
54+
}
55+
56+
// Store fragment definitions for lookup
57+
$fragments = [];
58+
foreach ( $ast->definitions as $definition ) {
59+
if ( $definition instanceof FragmentDefinitionNode ) {
60+
$fragments[ $definition->name->value ] = $definition;
61+
}
62+
}
63+
64+
// Analyze depth for each operation definition
65+
foreach ( $ast->definitions as $definition ) {
66+
if ( $definition instanceof OperationDefinitionNode ) {
67+
// Start depth at 1 because the first field is depth level 1.
68+
$currentDepth = $this->getSelectionSetDepth( $definition->selectionSet, $fragments, 1, [] );
69+
$maxDepthReached = max( $maxDepthReached, $currentDepth );
70+
}
71+
}
72+
73+
if ( $maxDepthReached >= $this->maxDepth ) {
74+
$triggered = true;
75+
$this->message = sprintf(
76+
'Nested query depth of %d reached or exceeded the configured maximum of %d.',
77+
$maxDepthReached,
78+
$this->maxDepth
79+
);
80+
} else {
81+
$this->message = sprintf(
82+
'Nested query depth of %d is within the allowed limit of %d.',
83+
$maxDepthReached,
84+
$this->maxDepth
85+
);
86+
}
87+
88+
return [
89+
'triggered' => $triggered,
90+
'message' => $this->message,
91+
'details' => [
92+
'maxDepthReached' => $maxDepthReached,
93+
'maxAllowed' => $this->maxDepth,
94+
],
95+
];
96+
}
97+
98+
/**
99+
* Recursively calculates the maximum depth of a SelectionSet, including fragments.
100+
*
101+
* @param SelectionSetNode $selectionSet The selection set to analyze.
102+
* @param array $fragments Map of fragment definitions.
103+
* @param int $currentDepth The current depth of traversal.
104+
* @param array $visitedFragments Names of fragments already visited.
105+
*
106+
* @return int The maximum depth found.
107+
*/
108+
protected function getSelectionSetDepth(
109+
SelectionSetNode $selectionSet,
110+
array $fragments,
111+
int $currentDepth = 0,
112+
array $visitedFragments = []
113+
): int {
114+
$maxDepth = $currentDepth;
115+
116+
foreach ( $selectionSet->selections as $selection ) {
117+
$selectionMaxDepth = $currentDepth;
118+
119+
// Field node
120+
if ( $selection instanceof FieldNode && $selection->selectionSet instanceof SelectionSetNode ) {
121+
$selectionMaxDepth = $this->getSelectionSetDepth(
122+
$selection->selectionSet,
123+
$fragments,
124+
$currentDepth + 1,
125+
$visitedFragments
126+
);
127+
}
128+
129+
// Fragment spread
130+
if ( $selection instanceof FragmentSpreadNode ) {
131+
$fragName = $selection->name->value;
132+
if ( isset( $fragments[ $fragName ] ) && ! in_array( $fragName, $visitedFragments, true ) ) {
133+
$visitedFragments[] = $fragName;
134+
/** @var FragmentDefinitionNode $fragmentDefinition */
135+
$fragmentDefinition = $fragments[ $fragName ];
136+
// Inline fragment's selections at the current depth (no +1 here)
137+
$selectionMaxDepth = $this->getSelectionSetDepth(
138+
$fragmentDefinition->selectionSet,
139+
$fragments,
140+
$currentDepth,
141+
$visitedFragments
142+
);
143+
}
144+
}
145+
146+
// Inline fragment
147+
if ( $selection instanceof InlineFragmentNode && $selection->selectionSet instanceof SelectionSetNode ) {
148+
$selectionMaxDepth = $this->getSelectionSetDepth(
149+
$selection->selectionSet,
150+
$fragments,
151+
$currentDepth + 1,
152+
$visitedFragments
153+
);
154+
}
155+
156+
$maxDepth = max( $maxDepth, $selectionMaxDepth );
157+
}
158+
159+
return $maxDepth;
160+
}
161+
162+
/**
163+
* @inheritDoc
164+
*/
165+
public function getKey(): string {
166+
return 'nestedQuery';
167+
}
168+
}

plugins/wpgraphql-debug-extensions/src/Plugin.php

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,23 +82,17 @@ private function setup(): void {
8282
if ( $query_analyzer_instance instanceof OriginalQueryAnalyzer ) {
8383
$debug_analyzer = new QueryAnalyzer( $query_analyzer_instance );
8484

85-
// Define a structured list of rules to register.
86-
// The value is an array containing the class name and its constructor arguments.
8785
$analyzer_items = [
8886
'complexity' => [ 'class' => Complexity::class, 'args' => [] ],
8987
'unfiltered_lists' => [ 'class' => UnfilteredLists::class, 'args' => [] ],
9088
'nested_query' => [ 'class' => NestedQuery::class, 'args' => [ ] ],
9189
];
92-
93-
// Apply a filter to allow other plugins to add, remove, or modify this list.
9490
$analyzer_items = apply_filters( 'graphql_debug_extensions_analyzer_items', $analyzer_items );
9591

96-
// Loop through the filtered list and instantiate each class.
9792
foreach ( $analyzer_items as $item_config ) {
9893
$class_name = $item_config['class'];
9994
$args = $item_config['args'] ?? [];
100-
101-
// Instantiate the class with its constructor arguments using the spread operator.
95+
10296
$instance = new $class_name( ...$args );
10397
$debug_analyzer->addAnalyzerItem( $instance );
10498
}

0 commit comments

Comments
 (0)