diff --git a/plugins/wpgraphql-debug-extensions/src/Analysis/Interfaces/AnalyzerItemInterface.php b/plugins/wpgraphql-debug-extensions/src/Analysis/Interfaces/AnalyzerItemInterface.php new file mode 100644 index 00000000..eaee3087 --- /dev/null +++ b/plugins/wpgraphql-debug-extensions/src/Analysis/Interfaces/AnalyzerItemInterface.php @@ -0,0 +1,34 @@ + $variables Optional: Variables provided with the query. + * @param Schema|null $schema Optional: The GraphQL schema. + * @return array An associative array representing the analysis result. + * For metrics, it might contain 'value' and 'note'. + * For rules, it might contain 'triggered' and 'message'. + */ + public function analyze( string $query, array $variables = [], ?Schema $schema = null ): array; + + /** + * Returns the key under which this item's result should appear in the 'debugExtensions' output. + * E.g., 'complexity', 'nestedQueryRule', 'excessiveFieldsRule'. + * + * @return string The unique key for the analyzer item. + */ + public function getKey(): string; +} \ No newline at end of file diff --git a/plugins/wpgraphql-debug-extensions/src/Analysis/Metrics/Complexity.php b/plugins/wpgraphql-debug-extensions/src/Analysis/Metrics/Complexity.php deleted file mode 100644 index e4cdc8ab..00000000 --- a/plugins/wpgraphql-debug-extensions/src/Analysis/Metrics/Complexity.php +++ /dev/null @@ -1,143 +0,0 @@ - function ($node) use (&$complexity, $variables, $schema) { - // Count Field nodes - if ( $node instanceof FieldNode ) { - $include = true; - - // Handle @skip and @include directives - if ( ! empty( $node->directives ) ) { - foreach ( $node->directives as $directive ) { - $name = $directive->name->value; - $ifArg = null; - - foreach ( $directive->arguments as $arg ) { - if ( 'if' === $arg->name->value ) { - $ifArg = $arg->value; - break; - } - } - - $ifValue = true; // Default behavior if 'if' argument is missing or not a boolean/variable. - - if ( $ifArg instanceof VariableNode ) { - $varName = $ifArg->name->value; - $ifValue = $variables[ $varName ] ?? true; - } elseif ( $ifArg instanceof BooleanValueNode ) { - // Use the boolean literal value - $ifValue = $ifArg->value; - } - - if ( 'skip' === $name && true === $ifValue ) { - $include = false; - break; - } - if ( 'include' === $name && false === $ifValue ) { - $include = false; - break; - } - } - } - - if ( $include ) { - $complexity += 1; - } - } elseif ( $node instanceof FragmentSpreadNode ) { - // Count FragmentSpread nodes - $complexity += 1; - } elseif ( $node instanceof InlineFragmentNode ) { - // Count InlineFragment nodes - $complexity += 1; - } - }, - ] - ); - - $value = $complexity; - - return [ - 'value' => $value, - 'note' => $this->getComplexityNote( $value ), - ]; - } - - /** - * Determines the descriptive note for the complexity value based on predefined ranges. - * - * @param int|null $complexityValue The calculated complexity value. - * @return string The descriptive note. - */ - private function getComplexityNote( ?int $complexityValue ): string { - if ( ! is_numeric( $complexityValue ) ) { - return 'Complexity could not be determined.'; - } - - if ( $complexityValue <= 20 ) { - return 'Low complexity, excellent for performance.'; - } elseif ( $complexityValue <= 50 ) { - return 'Moderate complexity, generally good for most applications.'; - } elseif ( $complexityValue <= 100 ) { - return 'High complexity, consider optimizing larger queries for better performance.'; - } else { - return 'Very high complexity, significant optimization is highly recommended to prevent performance issues.'; - } - } - - /** - * @inheritDoc - */ - public function getKey(): string { - return 'complexity'; - } -} diff --git a/plugins/wpgraphql-debug-extensions/src/Analysis/QueryAnalyzer.php b/plugins/wpgraphql-debug-extensions/src/Analysis/QueryAnalyzer.php index c2bcf2a0..0c34c7ac 100644 --- a/plugins/wpgraphql-debug-extensions/src/Analysis/QueryAnalyzer.php +++ b/plugins/wpgraphql-debug-extensions/src/Analysis/QueryAnalyzer.php @@ -2,99 +2,113 @@ /** * Extends WPGraphQL's Query Analyzer to add custom heuristic rules and metrics. * - * @package WPGraphQL\Debug + * @package WPGraphQL\Debug\Analysis */ declare(strict_types=1); namespace WPGraphQL\Debug\Analysis; -use WPGraphQL\Debug\Analysis\Metrics\Complexity; +use WPGraphQL\Debug\Analysis\Interfaces\AnalyzerItemInterface; use WPGraphQL\Utils\QueryAnalyzer as OriginalQueryAnalyzer; /** - * Class QueryAnalyzerExtension + * Class QueryAnalyzer * * This class hooks into the WPGraphQL Query Analyzer to add custom analysis. */ class QueryAnalyzer { - /** - * @var QueryAnalyzer The instance of the WPGraphQL Query Analyzer. - */ - protected OriginalQueryAnalyzer $query_analyzer; + /** + * @var OriginalQueryAnalyzer The instance of the WPGraphQL Query Analyzer from the core plugin. + */ + protected OriginalQueryAnalyzer $query_analyzer; - /** - * @var string|null The GraphQL query string for the current request. - */ - protected ?string $currentQuery = null; + /** + * @var AnalyzerItemInterface[] An array of registered analyzer items (metrics and rules). + */ + protected array $analyzerItems = []; - /** - * @var array The variables for the current GraphQL request. - */ - protected array $currentVariables = []; + /** + * Constructor for the QueryAnalyzerExtension. + * + * @param OriginalQueryAnalyzer $query_analyzer The instance of the WPGraphQL Query Analyzer. + */ + public function __construct( OriginalQueryAnalyzer $query_analyzer ) { + $this->query_analyzer = $query_analyzer; + } - /** - * Constructor for the QueryAnalyzerExtension. - * - * @param OriginalQueryAnalyzer $query_analyzer The instance of the WPGraphQL Query Analyzer. - */ - public function __construct( OriginalQueryAnalyzer $query_analyzer ) { - $this->query_analyzer = $query_analyzer; - } + /** + * Adds an AnalyzerItem (metric or rule) to be processed. + * + * @param AnalyzerItemInterface $item The item to add. + * @return void + */ + public function addAnalyzerItem( AnalyzerItemInterface $item ): void { + $this->analyzerItems[] = $item; + } - /** - * Initializes the extension by adding necessary WordPress hooks. - */ - public function init(): void { - add_filter( 'graphql_query_analyzer_graphql_keys', [ $this, 'addMetricsToAnalyzerOutput' ], 10, 5 ); - } + /** + * Initializes the extension by adding necessary WordPress hooks. + */ + public function init(): void { + // This filter allows us to inject custom data into the 'debugExtensions' part of the GraphQL response. + add_filter( 'graphql_query_analyzer_graphql_keys', [ $this, 'addAnalysisToOutput' ], 10, 5 ); + } - /** - * Adds new metrics and analysis results to the Query Analyzer's output. - * This method is a callback for the 'graphql_query_analyzer_graphql_keys' filter. - * - * @param array $graphql_keys Existing data from the Query Analyzer. - * @param string $return_keys The keys returned to the X-GraphQL-Keys header. - * @param string $skipped_keys The keys that were skipped. - * @param string[] $return_keys_array The keys returned in array format. - * @param string[] $skipped_keys_array The keys skipped in array format. - * @return array The modified GraphQL keys with custom metrics. - */ - public function addMetricsToAnalyzerOutput( - array $graphql_keys, - string $return_keys, - string $skipped_keys, - array $return_keys_array, - array $skipped_keys_array - ): array { - $complexityValue = null; - $complexityNote = 'Could not compute complexity'; + /** + * Adds new metrics and analysis results to the Query Analyzer's output. + * This method is a callback for the 'graphql_query_analyzer_graphql_keys' filter. + * + * @param array $graphql_keys Existing data from the Query Analyzer. + * @param string $return_keys The keys returned to the X-GraphQL-Keys header. (unused here) + * @param string $skipped_keys The keys that were skipped. (unused here) + * @param string[] $return_keys_array The keys returned in array format. (unused here) + * @param string[] $skipped_keys_array The keys skipped in array format. (unused here) + * @return array The modified GraphQL keys with custom metrics. + */ + public function addAnalysisToOutput( + array $graphql_keys, + string $return_keys, // Keep for filter signature, but not used. + string $skipped_keys, // Keep for filter signature, but not used. + array $return_keys_array, // Keep for filter signature, but not used. + array $skipped_keys_array // Keep for filter signature, but not used. + ): array { + if ( ! isset( $graphql_keys['debugExtensions'] ) ) { + $graphql_keys['debugExtensions'] = []; + } - $request = $this->query_analyzer->get_request(); - $currentQuery = $request->params->query ?? null; - $currentVariables = (array) ( $request->params->variables ?? [] ); + $request = $this->query_analyzer->get_request(); + $currentQuery = $request->params->query ?? null; + $currentVariables = (array) ( $request->params->variables ?? [] ); + $schema = $this->query_analyzer->get_schema(); - // Add some logging to debug. - error_log( 'QueryAnalyzerExtension: addCustomMetricsToAnalyzerOutput called.' ); - error_log( 'QueryAnalyzerExtension: Retrieved Query: ' . ( $currentQuery ?? 'NULL' ) ); - error_log( 'QueryAnalyzerExtension: Retrieved Variables: ' . print_r( $currentVariables, true ) ); - if ( ! empty( $currentQuery ) ) { - try { - $complexityMetrics = new Complexity(); - $schema = $this->query_analyzer->get_schema(); - $complexityValue = $complexityMetrics->calculate( $currentQuery, $currentVariables, $schema ); + foreach ( $this->analyzerItems as $item ) { + try { + if ( ! empty( $currentQuery ) ) { + $result = $item->analyze( $currentQuery, $currentVariables, $schema ); + } else { + $result = [ + 'value' => null, + 'note' => 'No query provided for analysis.', + ]; + } + } catch ( \Exception $e ) { + error_log( sprintf( + 'WPGraphQL Debug Extensions: Analysis item "%s" failed: %s', + $item->getKey(), + $e->getMessage() + ) ); + $result = [ + 'value' => null, + 'note' => 'Analysis failed: ' . $e->getMessage(), + 'error' => true, + ]; + } - } catch (\Exception $e) { - error_log( 'WPGraphQL Debug Extensions: Complexity calculation failed: ' . $e->getMessage() ); - $complexityNote .= ': ' . $e->getMessage(); - } - } - if ( ! isset( $graphql_keys['debugExtensions'] ) ) { - $graphql_keys['debugExtensions'] = []; - } - $graphql_keys['debugExtensions']['complexity'] = $complexityValue; + $graphql_keys['debugExtensions'][ $item->getKey() ] = $result; + } - return $graphql_keys; - } + return $graphql_keys; + } } \ No newline at end of file diff --git a/plugins/wpgraphql-debug-extensions/src/Analysis/Rules/Complexity.php b/plugins/wpgraphql-debug-extensions/src/Analysis/Rules/Complexity.php new file mode 100644 index 00000000..a30ff06a --- /dev/null +++ b/plugins/wpgraphql-debug-extensions/src/Analysis/Rules/Complexity.php @@ -0,0 +1,132 @@ +internalNote = 'Complexity calculation failed due to GraphQL syntax error: ' . $error->getMessage(); + error_log( 'WPGraphQL Debug Extensions: ' . $this->internalNote ); + return [ + 'value' => null, + 'note' => $this->internalNote, + ]; + } + + $complexity = 0; + Visitor::visit( + $ast, + [ + 'enter' => function ( $node ) use ( &$complexity, $variables ) { + if ( $node instanceof FieldNode ) { + $include = true; + + if ( ! empty( $node->directives ) ) { + foreach ( $node->directives as $directive ) { + $name = $directive->name->value; + $ifArg = null; + + foreach ( $directive->arguments as $arg ) { + if ( 'if' === $arg->name->value ) { + $ifArg = $arg->value; + break; + } + } + + $ifValue = true; + + if ( $ifArg instanceof VariableNode ) { + $varName = $ifArg->name->value; + $ifValue = $variables[ $varName ] ?? true; + } elseif ( $ifArg instanceof BooleanValueNode ) { + $ifValue = $ifArg->value; + } + + if ( 'skip' === $name && true === $ifValue ) { + $include = false; + break; + } + if ( 'include' === $name && false === $ifValue ) { + $include = false; + break; + } + } + } + + if ( $include ) { + $complexity += 1; + } + } elseif ( $node instanceof FragmentSpreadNode ) { + $complexity += 1; + } elseif ( $node instanceof InlineFragmentNode ) { + $complexity += 1; + } + }, + ] + ); + + $value = $complexity; + $this->internalNote = $this->getComplexityNote( $value ); + + return [ + 'value' => $value, + 'note' => $this->internalNote, + ]; + } + + /** + * Determines the descriptive note for the complexity value based on predefined ranges. + * + * @param int|null $complexityValue The calculated complexity value. + * @return string The descriptive note. + */ + private function getComplexityNote( ?int $complexityValue ): string { + if ( ! is_numeric( $complexityValue ) ) { + return 'Complexity could not be determined.'; + } + + if ( $complexityValue <= 20 ) { + return 'Low complexity, excellent for performance.'; + } elseif ( $complexityValue <= 50 ) { + return 'Moderate complexity, generally good for most applications.'; + } elseif ( $complexityValue <= 100 ) { + return 'High complexity, consider optimizing larger queries for better performance.'; + } else { + return 'Very high complexity, significant optimization is highly recommended to prevent performance issues.'; + } + } + + /** + * @inheritDoc + */ + public function getKey(): string { + return 'complexity'; + } +} diff --git a/plugins/wpgraphql-debug-extensions/src/Analysis/Rules/UnfilteredLists.php b/plugins/wpgraphql-debug-extensions/src/Analysis/Rules/UnfilteredLists.php new file mode 100644 index 00000000..70654364 --- /dev/null +++ b/plugins/wpgraphql-debug-extensions/src/Analysis/Rules/UnfilteredLists.php @@ -0,0 +1,148 @@ +paginationArgs = $paginationArgs; + } + + /** + * @inheritDoc + */ + public function analyze( string $query, array $variables = [], ?Schema $schema = null ): array { + $triggered = false; + $unpaginatedLists = []; + + if ( ! $schema instanceof Schema ) { + $this->message = 'Schema not provided, cannot analyze unfiltered lists accurately.'; + return [ + 'triggered' => false, + 'message' => $this->message, + 'details' => [], + ]; + } + + try { + $ast = Parser::parse( $query ); + } catch ( SyntaxError $error ) { + $this->message = 'Failed to analyze unfiltered lists due to GraphQL syntax error: ' . $error->getMessage(); + return [ + 'triggered' => false, + 'message' => $this->message, + 'details' => [], + ]; + } + + $typeInfo = new TypeInfo( $schema ); + + Visitor::visit( + $ast, + Visitor::visitWithTypeInfo( + $typeInfo, + [ + 'enter' => function ( $node ) use ( &$unpaginatedLists, $typeInfo ) { + if ( $node instanceof FieldNode ) { + $parentType = $typeInfo->getParentType(); + + // Only proceed if we are in an ObjectType context (e.g., RootQuery, Post) + if ( $parentType instanceof ObjectType ) { + try { + $fieldDefinition = $parentType->getField( $node->name->value ); + } catch ( \Exception $e ) { + // Field not found in parent type's definition, skip. + // This can happen for introspection fields or aliased fields not directly + // resolvable without full schema traversal (TypeInfo usually handles this). + return; + } + + // Check if the field definition itself accepts any of our pagination arguments + $fieldDefinitionAcceptsPagination = false; + if ( ! empty( $fieldDefinition->args ) ) { + foreach ( $fieldDefinition->args as $argDef ) { + if ( in_array( $argDef->name, $this->paginationArgs, true ) ) { + $fieldDefinitionAcceptsPagination = true; + break; + } + } + } + + // If the schema definition for this field indicates it can be paginated, + // now check if the *actual query* for this field includes pagination arguments. + if ( $fieldDefinitionAcceptsPagination ) { + $queryHasPagination = false; + if ( ! empty( $node->arguments ) ) { + foreach ( $node->arguments as $arg ) { + if ( in_array( $arg->name->value, $this->paginationArgs, true ) ) { + $queryHasPagination = true; + break; + } + } + } + + if ( ! $queryHasPagination ) { + // This field is a paginatable connection in the schema, + // but the current query does not apply pagination. + $unpaginatedLists[] = $node->name->value; + } + } + } + } + }, + ] + ) + ); + + if ( ! empty( $unpaginatedLists ) ) { + $triggered = true; + $this->message = sprintf( + 'Unfiltered list queries detected for: %s. Consider adding pagination (e.g., %s) to these fields for performance.', + implode( ', ', array_unique( $unpaginatedLists ) ), + implode( ', ', $this->paginationArgs ) + ); + } else { + $this->message = 'No unfiltered list queries detected.'; + } + + return [ + 'triggered' => $triggered, + 'message' => $this->message, + 'details' => array_values( array_unique( $unpaginatedLists ) ), // Ensure unique and re-indexed + 'paginationArgsChecked' => $this->paginationArgs, + ]; + } + + /** + * @inheritDoc + */ + public function getKey(): string { + return 'unfilteredLists'; + } +} \ No newline at end of file diff --git a/plugins/wpgraphql-debug-extensions/src/Plugin.php b/plugins/wpgraphql-debug-extensions/src/Plugin.php index b3254386..918fb329 100644 --- a/plugins/wpgraphql-debug-extensions/src/Plugin.php +++ b/plugins/wpgraphql-debug-extensions/src/Plugin.php @@ -12,6 +12,8 @@ use AxeWP\GraphQL\Helper\Helper; use WPGraphQL\Debug\Analysis\QueryAnalyzer; use WPGraphQL\Utils\QueryAnalyzer as OriginalQueryAnalyzer; +use WPGraphQL\Debug\Analysis\Rules\Complexity; +use WPGraphQL\Debug\Analysis\Rules\UnfilteredLists; /** * Plugin singleton class. @@ -73,14 +75,13 @@ private function setup(): void { add_action( 'graphql_determine_graphql_keys', function ($query_analyzer_instance) { static $initialized = false; - // Only initialize once per request to prevent redundant hooks/logic. if ( $initialized ) { return; } - - // Ensure that the received instance is indeed a QueryAnalyzer. if ( $query_analyzer_instance instanceof OriginalQueryAnalyzer ) { $debug_analyzer = new QueryAnalyzer( $query_analyzer_instance ); + $debug_analyzer->addAnalyzerItem( new Complexity() ); + $debug_analyzer->addAnalyzerItem( new UnfilteredLists() ); $debug_analyzer->init(); $initialized = true;