Skip to content

Commit 99b0ca4

Browse files
committed
feat: add complexity rule
1 parent aa7d567 commit 99b0ca4

File tree

3 files changed

+214
-94
lines changed

3 files changed

+214
-94
lines changed
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
/**
3+
* GraphQL Query Complexity Calculator.
4+
*
5+
* @package WPGraphQL\Debug\Analysis\Metrics
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace WPGraphQL\Debug\Analysis\Metrics;
11+
12+
use GraphQL\Language\AST\BooleanValueNode;
13+
use GraphQL\Language\AST\FieldNode;
14+
use GraphQL\Language\AST\FragmentSpreadNode;
15+
use GraphQL\Language\AST\InlineFragmentNode;
16+
use GraphQL\Language\AST\VariableNode;
17+
use GraphQL\Language\Parser;
18+
use GraphQL\Language\Visitor;
19+
use GraphQL\Type\Schema;
20+
use GraphQL\Error\SyntaxError;
21+
22+
/**
23+
* Class Complexity
24+
*
25+
* Calculates the complexity of a GraphQL query based on field, fragment, and inline fragment counts,
26+
* with optional consideration for @skip and @include directives.
27+
*/
28+
class Complexity {
29+
30+
/**
31+
* Calculates the estimated complexity of a GraphQL query.
32+
*
33+
* This calculation counts each Field, FragmentSpread, and InlineFragment as 1 unit of complexity.
34+
* It also attempts to respect @skip and @include directives based on provided variables.
35+
*
36+
* @param string $query The GraphQL query string.
37+
* @param array $variables Optional: Variables provided with the query, used for directive evaluation.
38+
* @param Schema|null $schema Optional: The GraphQL schema, useful if advanced directive resolution (beyond simple boolean/variable check) is needed.
39+
* @return array The calculated complexity.
40+
* @throws SyntaxError If the query string is invalid and cannot be parsed.
41+
*/
42+
public function calculate( string $query, array $variables = [], ?Schema $schema = null ): array {
43+
try {
44+
$ast = Parser::parse( $query );
45+
} catch (SyntaxError $error) {
46+
// Re-throw or handle as appropriate for your error logging.
47+
throw $error;
48+
}
49+
50+
$complexity = 0;
51+
52+
Visitor::visit(
53+
$ast,
54+
[
55+
'enter' => function ($node) use (&$complexity, $variables, $schema) {
56+
// Count Field nodes
57+
if ( $node instanceof FieldNode ) {
58+
$include = true;
59+
60+
// Handle @skip and @include directives
61+
if ( ! empty( $node->directives ) ) {
62+
foreach ( $node->directives as $directive ) {
63+
$name = $directive->name->value;
64+
$ifArg = null;
65+
66+
foreach ( $directive->arguments as $arg ) {
67+
if ( 'if' === $arg->name->value ) {
68+
$ifArg = $arg->value;
69+
break;
70+
}
71+
}
72+
73+
$ifValue = true; // Default behavior if 'if' argument is missing or not a boolean/variable.
74+
75+
if ( $ifArg instanceof VariableNode ) {
76+
$varName = $ifArg->name->value;
77+
$ifValue = $variables[ $varName ] ?? true;
78+
} elseif ( $ifArg instanceof BooleanValueNode ) {
79+
// Use the boolean literal value
80+
$ifValue = $ifArg->value;
81+
}
82+
83+
if ( 'skip' === $name && true === $ifValue ) {
84+
$include = false;
85+
break;
86+
}
87+
if ( 'include' === $name && false === $ifValue ) {
88+
$include = false;
89+
break;
90+
}
91+
}
92+
}
93+
94+
if ( $include ) {
95+
$complexity += 1;
96+
}
97+
} elseif ( $node instanceof FragmentSpreadNode ) {
98+
// Count FragmentSpread nodes
99+
$complexity += 1;
100+
} elseif ( $node instanceof InlineFragmentNode ) {
101+
// Count InlineFragment nodes
102+
$complexity += 1;
103+
}
104+
},
105+
]
106+
);
107+
108+
$value = $complexity;
109+
110+
return [
111+
'value' => $value,
112+
'note' => $this->getComplexityNote( $value ),
113+
];
114+
}
115+
116+
/**
117+
* Determines the descriptive note for the complexity value based on predefined ranges.
118+
*
119+
* @param int|null $complexityValue The calculated complexity value.
120+
* @return string The descriptive note.
121+
*/
122+
private function getComplexityNote( ?int $complexityValue ): string {
123+
if ( ! is_numeric( $complexityValue ) ) {
124+
return 'Complexity could not be determined.';
125+
}
126+
127+
if ( $complexityValue <= 20 ) {
128+
return 'Low complexity, excellent for performance.';
129+
} elseif ( $complexityValue <= 50 ) {
130+
return 'Moderate complexity, generally good for most applications.';
131+
} elseif ( $complexityValue <= 100 ) {
132+
return 'High complexity, consider optimizing larger queries for better performance.';
133+
} else {
134+
return 'Very high complexity, significant optimization is highly recommended to prevent performance issues.';
135+
}
136+
}
137+
138+
/**
139+
* @inheritDoc
140+
*/
141+
public function getKey(): string {
142+
return 'complexity';
143+
}
144+
}

plugins/wpgraphql-debug-extensions/src/Analysis/QueryAnalyzer.php

Lines changed: 70 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@
99

1010
namespace WPGraphQL\Debug\Analysis;
1111

12+
use WPGraphQL\Debug\Analysis\Metrics\Complexity;
1213
use WPGraphQL\Utils\QueryAnalyzer as OriginalQueryAnalyzer;
13-
use WPGraphQL\Request;
14-
use GraphQL\Type\Schema;
1514

1615
/**
1716
* Class QueryAnalyzerExtension
@@ -20,102 +19,82 @@
2019
*/
2120
class QueryAnalyzer {
2221

23-
/**
24-
* @var QueryAnalyzer The instance of the WPGraphQL Query Analyzer.
25-
*/
26-
protected OriginalQueryAnalyzer $query_analyzer;
22+
/**
23+
* @var QueryAnalyzer The instance of the WPGraphQL Query Analyzer.
24+
*/
25+
protected OriginalQueryAnalyzer $query_analyzer;
2726

28-
/**
29-
* Constructor for the QueryAnalyzerExtension.
30-
*
31-
* @param OriginalQueryAnalyzer $query_analyzer The instance of the WPGraphQL Query Analyzer.
32-
*/
33-
public function __construct( OriginalQueryAnalyzer $query_analyzer ) {
34-
$this->query_analyzer = $query_analyzer;
35-
}
27+
/**
28+
* @var string|null The GraphQL query string for the current request.
29+
*/
30+
protected ?string $currentQuery = null;
3631

37-
/**
38-
* Initializes the extension by adding necessary WordPress hooks.
39-
*/
40-
public function init(): void {
41-
add_filter( 'graphql_query_analyzer_graphql_keys', [ $this, 'addMetricsToAnalyzerOutput' ], 10, 5 );
42-
}
32+
/**
33+
* @var array<string,mixed> The variables for the current GraphQL request.
34+
*/
35+
protected array $currentVariables = [];
4336

44-
/**
45-
* Adds new metrics and analysis results to the Query Analyzer's output.
46-
* This method is a callback for the 'graphql_query_analyzer_graphql_keys' filter.
47-
*
48-
* @param array<string,mixed> $graphql_keys Existing data from the Query Analyzer.
49-
* @param string $return_keys The keys returned to the X-GraphQL-Keys header.
50-
* @param string $skipped_keys The keys that were skipped.
51-
* @param string[] $return_keys_array The keys returned in array format.
52-
* @param string[] $skipped_keys_array The keys skipped in array format.
53-
* @return array<string,mixed> The modified GraphQL keys with custom metrics.
54-
*/
55-
public function addMetricsToAnalyzerOutput(
56-
array $graphql_keys,
57-
string $return_keys,
58-
string $skipped_keys,
59-
array $return_keys_array,
60-
array $skipped_keys_array
61-
): array {
62-
// Simulate deeply nested queries check
63-
$hasDeepNesting = $this->analyzeNestingDepth();
37+
/**
38+
* Constructor for the QueryAnalyzerExtension.
39+
*
40+
* @param OriginalQueryAnalyzer $query_analyzer The instance of the WPGraphQL Query Analyzer.
41+
*/
42+
public function __construct( OriginalQueryAnalyzer $query_analyzer ) {
43+
$this->query_analyzer = $query_analyzer;
44+
}
6445

65-
// Simulate excessive field selection check
66-
$hasExcessiveFields = $this->analyzeFieldSelection();
46+
/**
47+
* Initializes the extension by adding necessary WordPress hooks.
48+
*/
49+
public function init(): void {
50+
add_filter( 'graphql_query_analyzer_graphql_keys', [ $this, 'addMetricsToAnalyzerOutput' ], 10, 5 );
51+
}
6752

68-
// Simulate a custom complexity score calculation
69-
$customComplexityScore = $this->calculateCustomComplexity();
53+
/**
54+
* Adds new metrics and analysis results to the Query Analyzer's output.
55+
* This method is a callback for the 'graphql_query_analyzer_graphql_keys' filter.
56+
*
57+
* @param array<string,mixed> $graphql_keys Existing data from the Query Analyzer.
58+
* @param string $return_keys The keys returned to the X-GraphQL-Keys header.
59+
* @param string $skipped_keys The keys that were skipped.
60+
* @param string[] $return_keys_array The keys returned in array format.
61+
* @param string[] $skipped_keys_array The keys skipped in array format.
62+
* @return array<string,mixed> The modified GraphQL keys with custom metrics.
63+
*/
64+
public function addMetricsToAnalyzerOutput(
65+
array $graphql_keys,
66+
string $return_keys,
67+
string $skipped_keys,
68+
array $return_keys_array,
69+
array $skipped_keys_array
70+
): array {
71+
$complexityValue = null;
72+
$complexityNote = 'Could not compute complexity';
7073

71-
// Add your custom data under a new key within the 'queryAnalyzer' extension.
72-
$graphql_keys['DebugExtensionsAnalysis'] = [
73-
'heuristicRules' => [
74-
'deepNestingDetected' => $hasDeepNesting,
75-
'excessiveFieldsDetected' => $hasExcessiveFields,
76-
],
77-
'performanceMetrics' => [
78-
'customComplexityScore' => $customComplexityScore,
79-
'dummyMemoryUsageKB' => rand( 1024, 8192 ),
80-
'dummyExecutionTimeMs' => rand( 50, 500 ),
81-
],
82-
];
74+
$request = $this->query_analyzer->get_request();
75+
$currentQuery = $request->params->query ?? null;
76+
$currentVariables = (array) ( $request->params->variables ?? [] );
8377

84-
return $graphql_keys;
85-
}
78+
// Add some logging to debug.
79+
error_log( 'QueryAnalyzerExtension: addCustomMetricsToAnalyzerOutput called.' );
80+
error_log( 'QueryAnalyzerExtension: Retrieved Query: ' . ( $currentQuery ?? 'NULL' ) );
81+
error_log( 'QueryAnalyzerExtension: Retrieved Variables: ' . print_r( $currentVariables, true ) );
82+
if ( ! empty( $currentQuery ) ) {
83+
try {
84+
$complexityMetrics = new Complexity();
85+
$schema = $this->query_analyzer->get_schema();
86+
$complexityValue = $complexityMetrics->calculate( $currentQuery, $currentVariables, $schema );
8687

87-
/**
88-
* Placeholder method to simulate analysis of nesting depth.
89-
* In a real implementation, this would parse the query AST
90-
* and determine nesting levels.
91-
*
92-
* @return bool True if deep nesting is detected, false otherwise.
93-
*/
94-
protected function analyzeNestingDepth(): bool {
95-
// For now, return a random boolean for demonstration.
96-
return (bool) rand( 0, 1 );
97-
}
88+
} catch (\Exception $e) {
89+
error_log( 'WPGraphQL Debug Extensions: Complexity calculation failed: ' . $e->getMessage() );
90+
$complexityNote .= ': ' . $e->getMessage();
91+
}
92+
}
93+
if ( ! isset( $graphql_keys['debugExtensions'] ) ) {
94+
$graphql_keys['debugExtensions'] = [];
95+
}
96+
$graphql_keys['debugExtensions']['complexity'] = $complexityValue;
9897

99-
/**
100-
* Placeholder method to simulate analysis of excessive field selection.
101-
* In a real implementation, this would count fields selected per type
102-
* and compare against a threshold.
103-
*
104-
* @return bool True if excessive fields are detected, false otherwise.
105-
*/
106-
protected function analyzeFieldSelection(): bool {
107-
// For now, return a random boolean for demonstration.
108-
return (bool) rand( 0, 1 );
109-
}
110-
111-
/**
112-
* Placeholder method to simulate a custom complexity score calculation.
113-
* This could combine factors like nesting, field count, list fetches, etc.
114-
*
115-
* @return int A dummy complexity score.
116-
*/
117-
protected function calculateCustomComplexity(): int {
118-
// For now, return a random integer.
119-
return rand( 100, 1000 );
120-
}
98+
return $graphql_keys;
99+
}
121100
}

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,6 @@ private function setup(): void {
8080

8181
// Ensure that the received instance is indeed a QueryAnalyzer.
8282
if ( $query_analyzer_instance instanceof OriginalQueryAnalyzer ) {
83-
// Create an instance of your custom QueryAnalyzer.
84-
// Pass the core QueryAnalyzer instance to its constructor
85-
// so your extension can access its methods and properties.
8683
$debug_analyzer = new QueryAnalyzer( $query_analyzer_instance );
8784

8885
// Initialize your extension. This is where it will register its own hooks

0 commit comments

Comments
 (0)