Commit b968daa38d4 for woocommerce
commit b968daa38d496a70e8cbe900c39dbff1f9b6fc21
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Tue Sep 1 17:55:44 2026 +0200
Fix fragments handling in the GraphQL infrastructure exposed by the dual API (#68235)
diff --git a/plugins/woocommerce/changelog/fix-graphql-fragments-processing b/plugins/woocommerce/changelog/fix-graphql-fragments-processing
new file mode 100644
index 00000000000..943837799e6
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-graphql-fragments-processing
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix unnecessary repeated processing of fragments in the GraphQL side of the dual API.
diff --git a/plugins/woocommerce/phpcs.xml b/plugins/woocommerce/phpcs.xml
index 0df2e8a0b97..b451992e4a5 100644
--- a/plugins/woocommerce/phpcs.xml
+++ b/plugins/woocommerce/phpcs.xml
@@ -275,6 +275,8 @@
<exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
<exclude-pattern>src/Api/Infrastructure/GraphQLControllerBase.php</exclude-pattern>
<exclude-pattern>src/Api/Infrastructure/QueryInfoExtractor.php</exclude-pattern>
+ <exclude-pattern>src/Internal/Api/QueryComplexityRule.php</exclude-pattern>
+ <exclude-pattern>src/Internal/Api/QueryDepthRule.php</exclude-pattern>
</rule>
<!-- API build scripts use empty if/elseif for intentional no-ops (e.g. enum
diff --git a/plugins/woocommerce/src/Api/Infrastructure/QueryInfoExtractor.php b/plugins/woocommerce/src/Api/Infrastructure/QueryInfoExtractor.php
index 0140ac74691..92f6b0e3133 100644
--- a/plugins/woocommerce/src/Api/Infrastructure/QueryInfoExtractor.php
+++ b/plugins/woocommerce/src/Api/Infrastructure/QueryInfoExtractor.php
@@ -22,9 +22,10 @@ use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionSetNode;
* - Leaf field (no args, no sub-selection) => true
* - Field with sub-selections => nested associative array
* - Field arguments => '__args' reserved key
- * - Inline fragments => '...TypeName' prefix key
- * - Named fragment spreads => expanded inline (merged into the parent as
- * siblings of the other selections), matching how GraphQL evaluates them
+ * - Inline fragments with a type condition => '...TypeName' prefix key
+ * - Inline fragments without a type condition and named fragment spreads =>
+ * expanded inline (merged into the parent as siblings of the other
+ * selections), matching how GraphQL evaluates them
* - Top-level query args included via '__args'
*/
class QueryInfoExtractor {
@@ -56,6 +57,26 @@ class QueryInfoExtractor {
* @return array The query info tree for the selection set.
*/
public static function extract( ?SelectionSetNode $selection_set, array $variable_values, array $fragments = array() ): array {
+ $expanded_fragments = array();
+
+ return self::extract_selection_set( $selection_set, $variable_values, $fragments, $expanded_fragments );
+ }
+
+ /**
+ * Recursive worker behind {@see self::extract()}.
+ *
+ * Named fragments are expanded once per extract() call and the result is
+ * reused for every further spread, so the work stays proportional to the
+ * size of the document. This runs after validation, whose limits don't
+ * bound how often a fragment is spread.
+ *
+ * @param ?SelectionSetNode $selection_set The selection set to process.
+ * @param array $variable_values Variable values for resolving arguments.
+ * @param array<string, FragmentDefinitionNode> $fragments Named fragment definitions from the document.
+ * @param array<string, array> $expanded_fragments Memoized expansions, keyed by fragment name. Passed by reference so the whole walk shares one cache.
+ * @return array The query info tree for the selection set.
+ */
+ private static function extract_selection_set( ?SelectionSetNode $selection_set, array $variable_values, array $fragments, array &$expanded_fragments ): array {
if ( null === $selection_set ) {
return array();
}
@@ -65,11 +86,17 @@ class QueryInfoExtractor {
foreach ( $selection_set->selections as $selection ) {
if ( $selection instanceof FieldNode ) {
$field_name = $selection->name->value;
- $result[ $field_name ] = self::build_field_entry( $selection, $variable_values, $fragments );
+ $result[ $field_name ] = self::build_field_entry( $selection, $variable_values, $fragments, $expanded_fragments );
} elseif ( $selection instanceof InlineFragmentNode ) {
- $type_name = $selection->typeCondition->name->value;
- $key = '...' . $type_name;
- $result[ $key ] = self::extract( $selection->selectionSet, $variable_values, $fragments );
+ $sub = self::extract_selection_set( $selection->selectionSet, $variable_values, $fragments, $expanded_fragments );
+ if ( null === $selection->typeCondition ) {
+ // No `on Type` clause (e.g. `... @include(if: $flag) { ... }`):
+ // the fragment applies to the parent type, so merge it like
+ // a named fragment spread.
+ $result = self::merge_selections( $result, $sub );
+ } else {
+ $result[ '...' . $selection->typeCondition->name->value ] = $sub;
+ }
} elseif ( $selection instanceof FragmentSpreadNode ) {
// Expand named fragment spreads inline: their fields become
// siblings of the other selections, matching how GraphQL
@@ -79,11 +106,10 @@ class QueryInfoExtractor {
// recursive merge so overlapping selections are unioned
// rather than replaced — `array_merge` would drop the
// existing sub-selection under the same field name.
- $fragment = $fragments[ $selection->name->value ] ?? null;
- if ( null === $fragment ) {
+ $spread = self::expand_fragment( $selection->name->value, $variable_values, $fragments, $expanded_fragments );
+ if ( null === $spread ) {
continue;
}
- $spread = self::extract( $fragment->selectionSet, $variable_values, $fragments );
$result = self::merge_selections( $result, $spread );
}
}
@@ -91,15 +117,44 @@ class QueryInfoExtractor {
return $result;
}
+ /**
+ * Expand a named fragment into its query info tree, memoizing the result.
+ *
+ * @param string $name The fragment name.
+ * @param array $variable_values Variable values for resolving arguments.
+ * @param array<string, FragmentDefinitionNode> $fragments Named fragment definitions from the document.
+ * @param array<string, array> $expanded_fragments Memoized expansions, keyed by fragment name.
+ * @return ?array The expanded tree, or null when the fragment is not defined.
+ */
+ private static function expand_fragment( string $name, array $variable_values, array $fragments, array &$expanded_fragments ): ?array {
+ if ( array_key_exists( $name, $expanded_fragments ) ) {
+ return $expanded_fragments[ $name ];
+ }
+
+ $fragment = $fragments[ $name ] ?? null;
+ if ( null === $fragment ) {
+ return null;
+ }
+
+ // Seed the entry before recursing so a fragment cycle expands to nothing
+ // instead of recursing forever (defensive: NoFragmentCycles rejects
+ // such documents during validation).
+ $expanded_fragments[ $name ] = array();
+ $expanded_fragments[ $name ] = self::extract_selection_set( $fragment->selectionSet, $variable_values, $fragments, $expanded_fragments );
+
+ return $expanded_fragments[ $name ];
+ }
+
/**
* Build the entry for a single field node.
*
- * @param FieldNode $field The field node.
- * @param array $variable_values Variable values for resolving arguments.
- * @param array<string, FragmentDefinitionNode> $fragments Named fragment definitions from the document.
+ * @param FieldNode $field The field node.
+ * @param array $variable_values Variable values for resolving arguments.
+ * @param array<string, FragmentDefinitionNode> $fragments Named fragment definitions from the document.
+ * @param array<string, array> $expanded_fragments Memoized fragment expansions, keyed by fragment name.
* @return array|bool True for leaf fields, associative array otherwise.
*/
- private static function build_field_entry( FieldNode $field, array $variable_values, array $fragments ): array|bool {
+ private static function build_field_entry( FieldNode $field, array $variable_values, array $fragments, array &$expanded_fragments ): array|bool {
$has_args = ! empty( $field->arguments ) && count( $field->arguments ) > 0;
$has_sub_selection = null !== $field->selectionSet;
@@ -118,7 +173,7 @@ class QueryInfoExtractor {
}
if ( $has_sub_selection ) {
- $sub = self::extract( $field->selectionSet, $variable_values, $fragments );
+ $sub = self::extract_selection_set( $field->selectionSet, $variable_values, $fragments, $expanded_fragments );
$entry = self::merge_selections( $entry, $sub );
}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
index a3da661a0ea..888b1f56ac5 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
@@ -1 +1 @@
-2026-05-21T10:29:44+00:00
\ No newline at end of file
+2026-09-01T14:24:30+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
index 59f41b2e8ec..1326460d955 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
@@ -1 +1 @@
-2965921ea12d55aff3aa621ff023f57cf0dde87517b5c2fabf7ad47752f8a128
\ No newline at end of file
+8a1772a0ce7165390f95a55b811332ed47a895440da445291275295d06089e70
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/QueryComplexityRule.php b/plugins/woocommerce/src/Internal/Api/QueryComplexityRule.php
index f52e2009895..fc260660c67 100644
--- a/plugins/woocommerce/src/Internal/Api/QueryComplexityRule.php
+++ b/plugins/woocommerce/src/Internal/Api/QueryComplexityRule.php
@@ -4,15 +4,272 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Api;
+use Automattic\WooCommerce\Vendor\GraphQL\Error\Error;
+use Automattic\WooCommerce\Vendor\GraphQL\Executor\Values;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FieldNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FragmentSpreadNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\NodeKind;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionSetNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\Directive;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\FieldDefinition;
+use Automattic\WooCommerce\Vendor\GraphQL\Validator\QueryValidationContext;
use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\QueryComplexity;
/**
- * QueryComplexity validation rule that returns a generic error message when the complexity is exceeded.
+ * QueryComplexity validation rule that returns a generic error message when
+ * the complexity is exceeded. Admins can still read both values via debug
+ * mode; see {@see GraphQLController} step 8.
*
- * Admins can still read both values via debug mode; see
- * {@see GraphQLController} step 8.
+ * Unlike the stock webonyx rule, the work done stays proportional to the size
+ * of the document: each named fragment is scored once and the result reused
+ * for every spread, variable values are coerced once instead of once per
+ * directive or complexity callback, field definitions come from the visitor's
+ * TypeInfo instead of being re-collected for every selection set, and scores
+ * saturate at {@see self::COMPLEXITY_CEILING} instead of overflowing.
*/
class QueryComplexityRule extends QueryComplexity {
+ /**
+ * Upper bound for computed complexity scores.
+ *
+ * Far above any configurable limit, so real scores stay exact, while leaving
+ * headroom below PHP_INT_MAX for complexity callbacks to multiply a saturated
+ * child score by a page size without overflowing.
+ */
+ public const COMPLEXITY_CEILING = PHP_INT_MAX >> 10;
+
+ /**
+ * Memoized complexity of each named fragment, keyed by fragment name.
+ *
+ * @var array<string, int>
+ */
+ private array $fragment_complexities = array();
+
+ /**
+ * Names of the fragments whose complexity is currently being computed;
+ * guards against fragment cycles (which the NoFragmentCycles rule reports).
+ *
+ * @var array<string, true>
+ */
+ private array $fragments_in_progress = array();
+
+ /**
+ * Variable values coerced for the current document, or null when not yet computed.
+ *
+ * @var ?array<string, mixed>
+ */
+ private ?array $coerced_variable_values = null;
+
+ /**
+ * Schema definition of every field node in the document, keyed by the
+ * node's spl_object_id(). Populated as the visitor enters each field.
+ *
+ * @var array<int, ?FieldDefinition>
+ */
+ private array $field_definitions = array();
+
+ /**
+ * Reset the per-document state, then replace the stock SELECTION_SET
+ * callback, which re-collects field definitions through every fragment
+ * reachable from each selection set, with recording the definition that
+ * TypeInfo already resolves as the visitor enters each field.
+ *
+ * @param QueryValidationContext $context The validation context.
+ * @return array The visitor definition.
+ */
+ public function getVisitor( QueryValidationContext $context ): array {
+ $this->fragment_complexities = array();
+ $this->fragments_in_progress = array();
+ $this->coerced_variable_values = null;
+ $this->field_definitions = array();
+
+ $visitor = parent::getVisitor( $context );
+ if ( array() === $visitor ) {
+ // The rule is disabled.
+ return $visitor;
+ }
+
+ unset( $visitor[ NodeKind::SELECTION_SET ] );
+ $visitor[ NodeKind::FIELD ] = function ( FieldNode $node ) use ( $context ): void {
+ $this->field_definitions[ spl_object_id( $node ) ] = $context->getFieldDef();
+ };
+
+ return $visitor;
+ }
+
+ /**
+ * Look up the schema definition recorded for a field node.
+ *
+ * @param FieldNode $field The field node.
+ * @return ?FieldDefinition The definition, or null when the field doesn't exist on its parent type.
+ */
+ protected function fieldDefinition( FieldNode $field ): ?FieldDefinition {
+ return $this->field_definitions[ spl_object_id( $field ) ] ?? null;
+ }
+
+ /**
+ * Sum the complexity of a selection set's selections, saturating at
+ * {@see self::COMPLEXITY_CEILING}.
+ *
+ * @param SelectionSetNode $selection_set The selection set to score.
+ * @return int The (possibly saturated) complexity.
+ * @throws \Exception When variable or argument coercion fails.
+ */
+ protected function fieldComplexity( SelectionSetNode $selection_set ): int {
+ $complexity = 0;
+
+ foreach ( $selection_set->selections as $selection ) {
+ $complexity = $this->add_saturating( $complexity, $this->nodeComplexity( $selection ) );
+ }
+
+ return $complexity;
+ }
+
+ /**
+ * Score a single selection. Named fragments are scored once and the result
+ * reused for every spread; everything else is delegated to the stock rule.
+ *
+ * @param SelectionNode $node The selection to score.
+ * @return int The complexity of the selection.
+ * @throws \Exception When variable or argument coercion fails.
+ */
+ protected function nodeComplexity( SelectionNode $node ): int {
+ if ( ! $node instanceof FragmentSpreadNode ) {
+ return parent::nodeComplexity( $node );
+ }
+
+ $fragment = $this->getFragment( $node );
+ if ( is_null( $fragment ) ) {
+ return 0;
+ }
+
+ $name = $fragment->name->value;
+ if ( array_key_exists( $name, $this->fragment_complexities ) ) {
+ return $this->fragment_complexities[ $name ];
+ }
+
+ // A fragment that (transitively) spreads itself has unbounded
+ // complexity. NoFragmentCycles reports the actual error.
+ if ( isset( $this->fragments_in_progress[ $name ] ) ) {
+ return self::COMPLEXITY_CEILING;
+ }
+
+ $this->fragments_in_progress[ $name ] = true;
+ try {
+ $complexity = $this->fieldComplexity( $fragment->selectionSet );
+ } finally {
+ unset( $this->fragments_in_progress[ $name ] );
+ }
+
+ $this->fragment_complexities[ $name ] = $complexity;
+
+ return $complexity;
+ }
+
+ /**
+ * Whether `@include` / `@skip` directives exclude the field from execution.
+ *
+ * Same semantics as the stock rule, but variable values are coerced once
+ * per document (see {@see self::get_coerced_variable_values()}).
+ *
+ * @param FieldNode $node The field node.
+ * @return bool True when the field will not be executed.
+ * @throws \Exception When variable coercion fails.
+ */
+ protected function directiveExcludesField( FieldNode $node ): bool {
+ foreach ( $node->directives as $directive_node ) {
+ $directive_name = $directive_node->name->value;
+
+ if ( Directive::INCLUDE_NAME === $directive_name ) {
+ $include_arguments = Values::getArgumentValues(
+ Directive::includeDirective(),
+ $directive_node,
+ $this->get_coerced_variable_values()
+ );
+ if ( false === $include_arguments['if'] ) {
+ return true;
+ }
+ } elseif ( Directive::SKIP_NAME === $directive_name ) {
+ $skip_arguments = Values::getArgumentValues(
+ Directive::skipDirective(),
+ $directive_node,
+ $this->get_coerced_variable_values()
+ );
+ if ( true === $skip_arguments['if'] ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Build the argument values handed to a field's complexity callback.
+ *
+ * Same semantics as the stock rule, but variable values are coerced once
+ * per document (see {@see self::get_coerced_variable_values()}).
+ *
+ * @param FieldNode $node The field node.
+ * @return array<string, mixed> The coerced argument values.
+ * @throws \Exception When variable or argument coercion fails.
+ */
+ protected function buildFieldArguments( FieldNode $node ): array {
+ $field_definition = $this->fieldDefinition( $node );
+
+ return $field_definition instanceof FieldDefinition
+ ? Values::getArgumentValues( $field_definition, $node, $this->get_coerced_variable_values() )
+ : array();
+ }
+
+ /**
+ * Coerce the document's variable values against their definitions,
+ * once per document.
+ *
+ * @return array<string, mixed> The coerced variable values.
+ * @throws Error When the provided variables don't satisfy their definitions (same error the stock rule throws).
+ */
+ private function get_coerced_variable_values(): array {
+ if ( ! is_null( $this->coerced_variable_values ) ) {
+ return $this->coerced_variable_values;
+ }
+
+ list( $errors, $variable_values ) = Values::getVariableValues(
+ $this->context->getSchema(),
+ $this->variableDefs,
+ $this->getRawVariableValues()
+ );
+
+ if ( ! empty( $errors ) ) {
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON by the GraphQL error formatter.
+ throw new Error(
+ implode(
+ "\n\n",
+ array_map( static fn( Error $error ): string => $error->getMessage(), $errors )
+ )
+ );
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+
+ $this->coerced_variable_values = $variable_values ?? array();
+
+ return $this->coerced_variable_values;
+ }
+
+ /**
+ * Add two complexity scores, saturating at {@see self::COMPLEXITY_CEILING}.
+ *
+ * @param int $a First score.
+ * @param int $b Second score.
+ * @return int The saturated sum.
+ */
+ private function add_saturating( int $a, int $b ): int {
+ $sum = $a + $b;
+
+ // An int overflow turns the sum into a float, which is also above the ceiling.
+ return $sum > self::COMPLEXITY_CEILING ? self::COMPLEXITY_CEILING : (int) $sum;
+ }
+
/**
* Override webonyx's default ("Max query complexity should be {max} but
* got {count}.").
diff --git a/plugins/woocommerce/src/Internal/Api/QueryDepthRule.php b/plugins/woocommerce/src/Internal/Api/QueryDepthRule.php
index 3607f90383d..1b1d885c6c9 100644
--- a/plugins/woocommerce/src/Internal/Api/QueryDepthRule.php
+++ b/plugins/woocommerce/src/Internal/Api/QueryDepthRule.php
@@ -4,15 +4,91 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Api;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FragmentSpreadNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\Node;
+use Automattic\WooCommerce\Vendor\GraphQL\Validator\QueryValidationContext;
use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\QueryDepth;
/**
- * QueryDepth validation rule that returns a generic error message when the depth is exceeded.
- *
- * Admins can still read both values via debug mode; see
+ * QueryDepth validation rule that returns a generic error message when the
+ * depth is exceeded. Admins can still read both values via debug mode; see
* {@see GraphQLController} step 8.
+ *
+ * Unlike the stock webonyx rule, which walks a named fragment again on every
+ * spread, each fragment's depth is computed once, relative to the position it
+ * is spread at, and reused.
*/
class QueryDepthRule extends QueryDepth {
+ /**
+ * Sentinel for a selection tree with no nested selection sets, which adds
+ * no depth wherever it is spread. The stock walk only ever raises the
+ * running maximum, so seeding it with -1 makes the same walk report either
+ * the relative depth (>= 0) or this sentinel.
+ */
+ private const NO_NESTED_FIELDS = -1;
+
+ /**
+ * Memoized relative depth of each named fragment, keyed by fragment name.
+ *
+ * @var array<string, int>
+ */
+ private array $fragment_depths = array();
+
+ /**
+ * Reset the per-document memoization before delegating to the stock visitor.
+ *
+ * @param QueryValidationContext $context The validation context.
+ * @return array The visitor definition.
+ */
+ public function getVisitor( QueryValidationContext $context ): array {
+ $this->fragment_depths = array();
+
+ return parent::getVisitor( $context );
+ }
+
+ /**
+ * Compute the depth reached below a selection. Named fragment spreads use
+ * the fragment's relative depth, computed once; everything else is
+ * delegated to the stock rule.
+ *
+ * @param Node $node The selection node.
+ * @param int $depth The depth the selection sits at.
+ * @param int $max_depth The maximum depth seen so far.
+ * @return int The updated maximum depth.
+ */
+ protected function nodeDepth( Node $node, int $depth = 0, int $max_depth = 0 ): int {
+ if ( ! $node instanceof FragmentSpreadNode ) {
+ return parent::nodeDepth( $node, $depth, $max_depth );
+ }
+
+ $fragment = $this->getFragment( $node );
+ if ( is_null( $fragment ) ) {
+ return $max_depth;
+ }
+
+ $name = $fragment->name->value;
+ if ( ! array_key_exists( $name, $this->fragment_depths ) ) {
+ // Same cycle guard as the stock rule: a fragment that (transitively)
+ // spreads itself is reported as exceeding the limit.
+ if ( isset( $this->calculatedFragments[ $name ] ) ) {
+ return $this->maxQueryDepth + 1;
+ }
+
+ $this->calculatedFragments[ $name ] = true;
+ try {
+ $this->fragment_depths[ $name ] = $this->fieldDepth( $fragment, 0, self::NO_NESTED_FIELDS );
+ } finally {
+ unset( $this->calculatedFragments[ $name ] );
+ }
+ }
+
+ $relative_depth = $this->fragment_depths[ $name ];
+
+ return self::NO_NESTED_FIELDS === $relative_depth
+ ? $max_depth
+ : max( $max_depth, $depth + $relative_depth );
+ }
+
/**
* Override webonyx's default ("Max query depth should be {max} but
* got {count}.").
diff --git a/plugins/woocommerce/tests/php/src/Api/Infrastructure/QueryInfoExtractorTest.php b/plugins/woocommerce/tests/php/src/Api/Infrastructure/QueryInfoExtractorTest.php
index 7767d41ed9c..6a3b686225a 100644
--- a/plugins/woocommerce/tests/php/src/Api/Infrastructure/QueryInfoExtractorTest.php
+++ b/plugins/woocommerce/tests/php/src/Api/Infrastructure/QueryInfoExtractorTest.php
@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Tests\Api\Infrastructure;
use Automattic\WooCommerce\Api\Infrastructure\QueryInfoExtractor;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\CountingNodeList;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FieldNode;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FragmentDefinitionNode;
@@ -118,6 +119,24 @@ class QueryInfoExtractorTest extends WC_Unit_Test_Case {
$this->assertSame( true, $tree['...Gadget']['parts_count'] ?? null );
}
+ /**
+ * @testdox extract merges inline fragments without a type condition into the parent.
+ */
+ public function test_extract_merges_inline_fragments_without_type_condition(): void {
+ [ $field ] = $this->parse_top_field(
+ '{ thing { id ... { name } ... @include(if: true) { sku reviews { nodes { id } } } ... on Widget { color } } }'
+ );
+
+ $tree = QueryInfoExtractor::extract( $field->selectionSet, array() );
+
+ $this->assertSame( true, $tree['id'] ?? null );
+ $this->assertSame( true, $tree['name'] ?? null );
+ $this->assertSame( true, $tree['sku'] ?? null );
+ $this->assertSame( true, $tree['reviews']['nodes']['id'] ?? null );
+ $this->assertSame( true, $tree['...Widget']['color'] ?? null );
+ $this->assertArrayNotHasKey( '...', $tree );
+ }
+
/**
* @testdox extract expands named fragment spreads inline into the parent.
*/
@@ -191,4 +210,114 @@ class QueryInfoExtractorTest extends WC_Unit_Test_Case {
$this->assertArrayNotHasKey( '__args', $tree );
}
+
+ // The enable above closes the ResolveInfo block only; restore the file-level suppression for the AST properties used below.
+ // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+
+ /**
+ * @testdox extract expands each named fragment once, so a document whose fragments spread each other twice is processed in linear work.
+ */
+ public function test_extract_expands_duplicate_fragment_spreads_once_per_fragment(): void {
+ // Each fragment spreads the next one twice.
+ $fragment_count = 40;
+ $source = "{ product { ...F0 } }\n";
+ for ( $i = 0; $i < $fragment_count - 1; $i++ ) {
+ $next = $i + 1;
+ $source .= "fragment F{$i} on Product { ...F{$next} ...F{$next} }\n";
+ }
+ $source .= 'fragment F' . ( $fragment_count - 1 ) . " on Product { id name }\n";
+
+ [ $field, $fragments ] = $this->parse_top_field( $source );
+ foreach ( $fragments as $fragment ) {
+ CountingNodeList::instrument( $fragment->selectionSet );
+ }
+ CountingNodeList::reset();
+
+ $tree = QueryInfoExtractor::extract( $field->selectionSet, array(), $fragments );
+
+ // Each fragment's selections are iterated exactly once, rather than once per spread.
+ $this->assertSame( $fragment_count, CountingNodeList::$iterations );
+ $this->assertSame(
+ array(
+ 'id' => true,
+ 'name' => true,
+ ),
+ $tree
+ );
+ }
+
+ /**
+ * @testdox extract yields the same tree for repeated spreads of a memoized fragment as for a single spread.
+ */
+ public function test_extract_repeated_spreads_of_the_same_fragment_are_idempotent(): void {
+ [ $field, $fragments ] = $this->parse_top_field(
+ '{ root { product { id ...Details } ...Extra ...Extra } } '
+ . 'fragment Details on Product { name price { amount } } '
+ . 'fragment Extra on Root { product { price { currency } } other(id: 3) { id } }'
+ );
+
+ $tree = QueryInfoExtractor::extract( $field->selectionSet, array(), $fragments );
+
+ $this->assertSame(
+ array(
+ 'product' => array(
+ 'id' => true,
+ 'name' => true,
+ 'price' => array(
+ 'amount' => true,
+ 'currency' => true,
+ ),
+ ),
+ 'other' => array(
+ '__args' => array( 'id' => 3 ),
+ 'id' => true,
+ ),
+ ),
+ $tree
+ );
+ }
+
+ /**
+ * @testdox extract expands the same fragment under every parent it is spread in.
+ */
+ public function test_extract_expands_the_same_fragment_under_different_parents(): void {
+ [ $field, $fragments ] = $this->parse_top_field( '{ root { a { ...F } b { ...F } } } fragment F on Thing { x y }' );
+
+ $tree = QueryInfoExtractor::extract( $field->selectionSet, array(), $fragments );
+
+ $this->assertSame(
+ array(
+ 'a' => array(
+ 'x' => true,
+ 'y' => true,
+ ),
+ 'b' => array(
+ 'x' => true,
+ 'y' => true,
+ ),
+ ),
+ $tree
+ );
+ }
+
+ /**
+ * @testdox extract terminates on a fragment cycle instead of recursing forever.
+ */
+ public function test_extract_terminates_on_fragment_cycles(): void {
+ [ $field, $fragments ] = $this->parse_top_field( '{ root { ...A } } fragment A on Root { a ...B } fragment B on Root { b ...A }' );
+
+ $tree = QueryInfoExtractor::extract( $field->selectionSet, array(), $fragments );
+
+ $this->assertArrayHasKey( 'a', $tree );
+ $this->assertArrayHasKey( 'b', $tree );
+ }
+
+ /**
+ * @testdox extract ignores spreads of undefined fragments.
+ */
+ public function test_extract_ignores_undefined_fragments(): void {
+ [ $field ] = $this->parse_top_field( '{ root { a ...Missing } }' );
+
+ $this->assertSame( array( 'a' => true ), QueryInfoExtractor::extract( $field->selectionSet, array() ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/CountingNodeList.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/CountingNodeList.php
new file mode 100644
index 00000000000..0be920be345
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/CountingNodeList.php
@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures;
+
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FragmentDefinitionNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\NodeList;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionSetNode;
+
+/**
+ * NodeList that counts how many times it is iterated.
+ *
+ * Tests swap it in for the `selections` of fragment selection sets to assert
+ * how many times a walk over the document visits each fragment, however often
+ * the fragment is spread.
+ */
+final class CountingNodeList extends NodeList {
+ /**
+ * Number of iterations started over any CountingNodeList since the last reset().
+ *
+ * @var int
+ */
+ public static int $iterations = 0;
+
+ /**
+ * Reset the iteration counter.
+ */
+ public static function reset(): void {
+ self::$iterations = 0;
+ }
+
+ /**
+ * Replace the selections of a selection set with a counting copy.
+ *
+ * @param SelectionSetNode $selection_set The selection set to instrument.
+ */
+ public static function instrument( SelectionSetNode $selection_set ): void {
+ $selection_set->selections = new self( iterator_to_array( $selection_set->selections ) );
+ }
+
+ /**
+ * Instrument the selection set of every named fragment in a document.
+ *
+ * @param DocumentNode $document The parsed document.
+ * @return int The number of fragments instrumented.
+ */
+ public static function instrument_fragments( DocumentNode $document ): int {
+ $count = 0;
+ foreach ( $document->definitions as $definition ) {
+ if ( $definition instanceof FragmentDefinitionNode ) {
+ self::instrument( $definition->selectionSet );
+ ++$count;
+ }
+ }
+ return $count;
+ }
+
+ /**
+ * Count the iteration, then iterate as usual.
+ */
+ public function getIterator(): \Traversable {
+ ++self::$iterations;
+ return parent::getIterator();
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
index 65a15c026d1..74f92945854 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
@@ -6,9 +6,12 @@ namespace Automattic\WooCommerce\Tests\Internal\Api;
use Automattic\WooCommerce\Api\Infrastructure\GraphQLControllerBase;
use Automattic\WooCommerce\Internal\Api\QueryCache;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\CountingNodeList;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver as DummyContainer;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLController as DummyGraphQLController;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\Parser;
use WC_REST_Unit_Test_Case;
/**
@@ -226,6 +229,109 @@ class GraphQLControllerExecutionTest extends WC_REST_Unit_Test_Case {
$this->assertNotEmpty( $response->get_data()['errors'] ?? array() );
}
+ /**
+ * Build a document in which each named fragment spreads the next one twice,
+ * so the number of spreads reachable from the root doubles with every fragment.
+ *
+ * @param int $fragment_count Number of chained fragments.
+ * @param string $type_name Type condition of the fragments.
+ * @param string $root Selection set body of the operation (must spread F0).
+ * @param string $leaf Selection set body of the last fragment.
+ */
+ private function build_duplicate_spread_chain( int $fragment_count, string $type_name, string $root, string $leaf ): string {
+ $document = "query Q { {$root} }\n";
+ for ( $i = 0; $i < $fragment_count - 1; $i++ ) {
+ $next = $i + 1;
+ $document .= "fragment F{$i} on {$type_name} { ...F{$next} ...F{$next} }\n";
+ }
+ $last = $fragment_count - 1;
+
+ return $document . "fragment F{$last} on {$type_name} { {$leaf} }\n";
+ }
+
+ /**
+ * Upper bound on how many times the request pipeline (validation rules,
+ * executor, query info extraction) may iterate one fragment's selections:
+ * a handful of passes that each visit a fragment once (4 when validation
+ * rejects the document and 6 when it executes, at the time of writing),
+ * as opposed to once per spread.
+ */
+ private const MAX_ITERATIONS_PER_FRAGMENT = 32;
+
+ /**
+ * Make the controller process a pre-parsed document whose fragment
+ * selection sets count how often they are iterated.
+ *
+ * Injected through a QueryCache double so the AST reaches the controller
+ * as-is (the real cache would re-parse the query string). Resets the
+ * iteration counter.
+ *
+ * @param string $query The GraphQL document.
+ * @return int The number of fragments in the document.
+ */
+ private function inject_counting_document( string $query ): int {
+ $document = Parser::parse( $query, array( 'noLocation' => true ) );
+ $fragment_count = CountingNodeList::instrument_fragments( $document );
+ CountingNodeList::reset();
+
+ $cache = new class( $document ) extends QueryCache {
+ /**
+ * Constructor.
+ *
+ * @param DocumentNode $document The document to hand to the controller.
+ */
+ public function __construct( private DocumentNode $document ) {}
+
+ /**
+ * Return the injected document regardless of the request.
+ *
+ * @param ?string $query Ignored.
+ * @param array $extensions Ignored.
+ */
+ public function resolve( ?string $query, array $extensions ): DocumentNode {
+ unset( $query, $extensions );
+ return $this->document;
+ }
+ };
+ $this->sut->init( $cache );
+
+ return $fragment_count;
+ }
+
+ /**
+ * @testdox handle_request rejects a document whose fragments spread each other twice, visiting each fragment a bounded number of times.
+ */
+ public function test_handle_request_rejects_duplicate_spread_document_with_bounded_work(): void {
+ $query = $this->build_duplicate_spread_chain( 24, 'Query', '...F0', '__typename' );
+ $fragment_count = $this->inject_counting_document( $query );
+
+ $response = $this->sut->handle_request( $this->post_request( array( 'query' => $query ) ) );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'Maximum query complexity exceeded.', $response->get_data()['errors'][0]['message'] ?? null );
+ $this->assertLessThanOrEqual( self::MAX_ITERATIONS_PER_FRAGMENT * $fragment_count, CountingNodeList::$iterations );
+ }
+
+ /**
+ * @testdox handle_request executes a zero-complexity document whose fragments spread each other twice, visiting each fragment a bounded number of times in the resolvers.
+ */
+ public function test_handle_request_executes_zero_complexity_duplicate_spread_document_with_bounded_work(): void {
+ // `first: 0` keeps the score within the limit, so the document reaches
+ // the resolver and its QueryInfoExtractor call, which must also expand
+ // each fragment only once.
+ $query = $this->build_duplicate_spread_chain( 24, 'WidgetConnection', 'widgets(first: 0) { ...F0 }', '__typename' );
+ $fragment_count = $this->inject_counting_document( $query );
+
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $response = $this->sut->handle_request( $this->post_request( array( 'query' => $query ) ) );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'WidgetConnection', $response->get_data()['data']['widgets']['__typename'] ?? null );
+ $this->assertLessThanOrEqual( self::MAX_ITERATIONS_PER_FRAGMENT * $fragment_count, CountingNodeList::$iterations );
+ }
+
/**
* @testdox handle_request blocks introspection for low-privilege callers.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/QueryComplexityRuleTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/QueryComplexityRuleTest.php
new file mode 100644
index 00000000000..eaf5772e8bf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/QueryComplexityRuleTest.php
@@ -0,0 +1,289 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Internal\Api\QueryComplexityRule;
+use Automattic\WooCommerce\Vendor\GraphQL\Error\Error;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionSetNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\Parser;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\CustomScalarType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\ObjectType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\Type;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Schema;
+use Automattic\WooCommerce\Vendor\GraphQL\Validator\DocumentValidator;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for {@see QueryComplexityRule}.
+ *
+ * The rule is exercised through DocumentValidator against a small hand-built
+ * schema, the same way GraphQLControllerBase wires it (stock rules plus ours).
+ */
+class QueryComplexityRuleTest extends WC_Unit_Test_Case {
+ /**
+ * Number of times the Counted scalar's parseValue callback ran.
+ *
+ * @var int
+ */
+ private int $parse_value_calls = 0;
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->parse_value_calls = 0;
+ }
+
+ /**
+ * Build the test schema.
+ *
+ * type Query { a: String, b: String, c: String, item: Item, items(first: Int): [Item], counted(values: [Counted!]): String }
+ * type Item { id: Int, name: String }
+ * scalar Counted (parseValue is instrumented)
+ *
+ * `items` carries a complexity callback multiplying the children's score
+ * by `first`, like WooCommerce's connection fields do.
+ */
+ private function build_schema(): Schema {
+ $counted = new CustomScalarType(
+ array(
+ 'name' => 'Counted',
+ 'serialize' => static fn( $value ) => $value,
+ 'parseValue' => function ( $value ) {
+ ++$this->parse_value_calls;
+ return $value;
+ },
+ 'parseLiteral' => static fn( $node ) => $node->value ?? null,
+ )
+ );
+
+ $item = new ObjectType(
+ array(
+ 'name' => 'Item',
+ 'fields' => array(
+ 'id' => Type::int(),
+ 'name' => Type::string(),
+ ),
+ )
+ );
+
+ $query = new ObjectType(
+ array(
+ 'name' => 'Query',
+ 'fields' => array(
+ 'a' => Type::string(),
+ 'b' => Type::string(),
+ 'c' => Type::string(),
+ 'item' => $item,
+ 'items' => array(
+ 'type' => Type::listOf( $item ),
+ 'args' => array( 'first' => Type::int() ),
+ 'complexity' => static fn( int $children, array $args ): int => ( $args['first'] ?? 1 ) * ( $children + 1 ),
+ ),
+ 'counted' => array(
+ 'type' => Type::string(),
+ 'args' => array( 'values' => Type::listOf( Type::nonNull( $counted ) ) ),
+ ),
+ ),
+ )
+ );
+
+ return new Schema( array( 'query' => $query ) );
+ }
+
+ /**
+ * Validate a document with the stock rules plus a QueryComplexityRule.
+ *
+ * @param string $query The GraphQL document.
+ * @param int $max_complexity The complexity limit.
+ * @param array $variables Raw variable values, as sent by the client.
+ * @param bool $only_this_rule When true, validate with the complexity rule alone (no stock rules).
+ * @param ?QueryComplexityRule $rule A pre-built (e.g. instrumented) rule instance to use instead of a fresh one.
+ * @return array{0: Error[], 1: QueryComplexityRule} The validation errors and the rule instance.
+ */
+ private function validate( string $query, int $max_complexity, array $variables = array(), bool $only_this_rule = false, ?QueryComplexityRule $rule = null ): array {
+ $sut = $rule ?? new QueryComplexityRule( $max_complexity );
+ $sut->setRawVariableValues( $variables );
+
+ $rules = $only_this_rule ? array() : array_values( DocumentValidator::allRules() );
+ $rules[] = $sut;
+
+ $errors = DocumentValidator::validate( $this->build_schema(), Parser::parse( $query ), $rules );
+
+ return array( $errors, $sut );
+ }
+
+ /**
+ * Build a document in which each named fragment spreads the next one twice,
+ * so the number of spreads reachable from the root doubles with every fragment.
+ *
+ * @param int $fragment_count Number of chained fragments.
+ * @param string $leaf Selection set body of the last fragment.
+ */
+ private function build_duplicate_spread_chain( int $fragment_count, string $leaf = 'a' ): string {
+ $document = "query Q { ...F0 }\n";
+ for ( $i = 0; $i < $fragment_count - 1; $i++ ) {
+ $next = $i + 1;
+ $document .= "fragment F{$i} on Query { ...F{$next} ...F{$next} }\n";
+ }
+ $last = $fragment_count - 1;
+
+ return $document . "fragment F{$last} on Query { {$leaf} }\n";
+ }
+
+ /**
+ * @testdox Duplicate fragment spreads are scored once per fragment, so a document whose fragments spread each other twice is scored in linear work.
+ */
+ public function test_duplicate_fragment_spreads_are_scored_once_per_fragment(): void {
+ $fragment_count = 40;
+ $query = $this->build_duplicate_spread_chain( $fragment_count );
+
+ $sut = new class( 1000 ) extends QueryComplexityRule {
+ /**
+ * Number of selection sets scored.
+ *
+ * @var int
+ */
+ public int $selection_sets_scored = 0;
+
+ /**
+ * Count the call, then score as usual.
+ *
+ * @param SelectionSetNode $selection_set The selection set to score.
+ */
+ protected function fieldComplexity( SelectionSetNode $selection_set ): int {
+ ++$this->selection_sets_scored;
+ return parent::fieldComplexity( $selection_set );
+ }
+ };
+
+ list( $errors ) = $this->validate( $query, 1000, array(), false, $sut );
+
+ // The operation's selection set plus each fragment's exactly once, rather than once per spread.
+ $this->assertSame( $fragment_count + 1, $sut->selection_sets_scored );
+ $this->assertCount( 1, $errors );
+ $this->assertSame( 'Maximum query complexity exceeded.', $errors[0]->getMessage() );
+ // Memoization must not change the score: the leaf still counts once per spread.
+ $this->assertSame( 2 ** 39, $sut->getQueryComplexity() );
+ }
+
+ /**
+ * @testdox Fragment spreads are scored exactly as if the fragment had been written inline.
+ */
+ public function test_fragment_spread_scores_match_inline_expansion(): void {
+ $with_fragments = '{ ...F0 } fragment F0 on Query { ...F1 ...F1 } fragment F1 on Query { a b }';
+ $inline = '{ a b a b }';
+
+ list( $errors, $sut ) = $this->validate( $with_fragments, 4 );
+ $this->assertSame( array(), $errors );
+ $this->assertSame( 4, $sut->getQueryComplexity() );
+
+ list( , $inline_sut ) = $this->validate( $inline, 4 );
+ $this->assertSame( $inline_sut->getQueryComplexity(), $sut->getQueryComplexity() );
+
+ list( $errors ) = $this->validate( $with_fragments, 3 );
+ $this->assertCount( 1, $errors );
+ $this->assertSame( 'Maximum query complexity exceeded.', $errors[0]->getMessage() );
+ }
+
+ /**
+ * @testdox The computed score saturates at COMPLEXITY_CEILING instead of overflowing PHP's int.
+ */
+ public function test_score_saturates_instead_of_overflowing(): void {
+ // 2^69 would overflow a 64-bit int (and surface as a TypeError).
+ $query = $this->build_duplicate_spread_chain( 70 );
+
+ list( $errors, $sut ) = $this->validate( $query, 1000 );
+
+ $this->assertCount( 1, $errors );
+ $this->assertSame( 'Maximum query complexity exceeded.', $errors[0]->getMessage() );
+ $this->assertSame( QueryComplexityRule::COMPLEXITY_CEILING, $sut->getQueryComplexity() );
+ }
+
+ /**
+ * @testdox A fragment cycle terminates and is scored as exceeding the limit, even without the NoFragmentCycles rule.
+ */
+ public function test_fragment_cycle_terminates(): void {
+ $query = '{ ...A } fragment A on Query { a ...B } fragment B on Query { b ...A }';
+
+ list( $errors ) = $this->validate( $query, 1000, array(), true );
+
+ $this->assertCount( 1, $errors );
+ $this->assertSame( 'Maximum query complexity exceeded.', $errors[0]->getMessage() );
+ }
+
+ /**
+ * @testdox Variable values are coerced once per document, not once per @include/@skip directive.
+ */
+ public function test_variables_are_coerced_once_per_document(): void {
+ $query = 'query Q($values: [Counted!], $flag: Boolean!) {
+ a @include(if: $flag)
+ b @include(if: $flag)
+ c @skip(if: $flag)
+ counted(values: $values)
+ }';
+
+ list( $errors, $sut ) = $this->validate(
+ $query,
+ 1000,
+ array(
+ 'values' => array( 1, 2, 3 ),
+ 'flag' => true,
+ )
+ );
+
+ $this->assertSame( array(), $errors );
+ // a, b, counted (c is skipped).
+ $this->assertSame( 3, $sut->getQueryComplexity() );
+ // One parseValue call per list element, regardless of how many directives the document carries.
+ $this->assertSame( 3, $this->parse_value_calls );
+ }
+
+ /**
+ * @testdox Fields excluded by @include / @skip (literal or variable-driven) don't count, including when both directives are present.
+ */
+ public function test_include_and_skip_directives_exclude_fields(): void {
+ list( $errors, $sut ) = $this->validate( '{ a @include(if: false) b @skip(if: true) c @include(if: true) @skip(if: true) item { id } }', 1000 );
+ $this->assertSame( array(), $errors );
+ $this->assertSame( 2, $sut->getQueryComplexity() );
+
+ list( $errors, $sut ) = $this->validate(
+ 'query Q($show: Boolean!) { a @include(if: $show) b @skip(if: $show) }',
+ 1000,
+ array( 'show' => false )
+ );
+ $this->assertSame( array(), $errors );
+ $this->assertSame( 1, $sut->getQueryComplexity() );
+ }
+
+ /**
+ * @testdox Complexity callbacks receive the coerced field arguments, for fields both in operations and inside fragments.
+ */
+ public function test_complexity_callback_receives_arguments(): void {
+ list( $errors, $sut ) = $this->validate( 'query Q($n: Int) { items(first: $n) { id name } }', 1000, array( 'n' => 10 ) );
+ $this->assertSame( array(), $errors );
+ // 10 * (2 children + 1).
+ $this->assertSame( 30, $sut->getQueryComplexity() );
+
+ list( $errors, $sut ) = $this->validate( '{ ...F } fragment F on Query { items(first: 5) { id } }', 1000 );
+ $this->assertSame( array(), $errors );
+ $this->assertSame( 10, $sut->getQueryComplexity() );
+
+ list( $errors ) = $this->validate( '{ items(first: 100) { id name } }', 100 );
+ $this->assertCount( 1, $errors );
+ $this->assertSame( 'Maximum query complexity exceeded.', $errors[0]->getMessage() );
+ }
+
+ /**
+ * @testdox Missing required variables surface as a coercion error rather than as a crash.
+ */
+ public function test_missing_required_variable_is_reported(): void {
+ $this->expectException( Error::class );
+ $this->expectExceptionMessageMatches( '/\$flag/' );
+
+ $this->validate( 'query Q($flag: Boolean!) { a @include(if: $flag) }', 1000, array() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/QueryDepthRuleTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/QueryDepthRuleTest.php
new file mode 100644
index 00000000000..ebe27d9374c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/QueryDepthRuleTest.php
@@ -0,0 +1,176 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Internal\Api\QueryDepthRule;
+use Automattic\WooCommerce\Vendor\GraphQL\Error\Error;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\Node;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\Parser;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\ObjectType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\Type;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Schema;
+use Automattic\WooCommerce\Vendor\GraphQL\Validator\DocumentValidator;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for {@see QueryDepthRule}.
+ *
+ * Depth is counted the way the stock webonyx rule counts it: the nesting
+ * level (root = 0) of the deepest field that itself has a selection set.
+ * `{ node { leaf } }` has depth 0, `{ node { node { leaf } } }` has depth 1.
+ */
+class QueryDepthRuleTest extends WC_Unit_Test_Case {
+ /**
+ * Build the test schema: type Query { node: Node, leaf: String } type Node { node: Node, leaf: String }.
+ */
+ private function build_schema(): Schema {
+ $node = new ObjectType(
+ array(
+ 'name' => 'Node',
+ 'fields' => static function () use ( &$node ): array {
+ return array(
+ 'node' => $node,
+ 'leaf' => Type::string(),
+ );
+ },
+ )
+ );
+
+ $query = new ObjectType(
+ array(
+ 'name' => 'Query',
+ 'fields' => array(
+ 'node' => $node,
+ 'leaf' => Type::string(),
+ ),
+ )
+ );
+
+ return new Schema( array( 'query' => $query ) );
+ }
+
+ /**
+ * Validate a document with the stock rules plus a QueryDepthRule.
+ *
+ * @param string $query The GraphQL document.
+ * @param int $max_depth The depth limit.
+ * @param bool $only_this_rule When true, validate with the depth rule alone (no stock rules).
+ * @param ?QueryDepthRule $rule A pre-built (e.g. instrumented) rule instance to use instead of a fresh one.
+ * @return Error[] The validation errors.
+ */
+ private function validate( string $query, int $max_depth, bool $only_this_rule = false, ?QueryDepthRule $rule = null ): array {
+ $sut = $rule ?? new QueryDepthRule( $max_depth );
+
+ $rules = $only_this_rule ? array() : array_values( DocumentValidator::allRules() );
+ $rules[] = $sut;
+
+ return DocumentValidator::validate( $this->build_schema(), Parser::parse( $query ), $rules );
+ }
+
+ /**
+ * Assert that a document is exactly at the given depth: accepted with that
+ * limit, rejected with one less.
+ *
+ * @param int $expected_depth The expected depth (must be >= 2, since a limit of 0 disables the rule).
+ * @param string $query The GraphQL document.
+ */
+ private function assert_depth( int $expected_depth, string $query ): void {
+ $this->assertSame( array(), $this->validate( $query, $expected_depth ), "Expected depth {$expected_depth} to be accepted." );
+
+ $errors = $this->validate( $query, $expected_depth - 1 );
+ $this->assertCount( 1, $errors, 'Expected depth ' . ( $expected_depth - 1 ) . ' to be rejected.' );
+ $this->assertSame( 'Maximum query depth exceeded.', $errors[0]->getMessage() );
+ }
+
+ /**
+ * @testdox Duplicate fragment spreads are walked once per fragment, so a document whose fragments spread each other twice is validated in linear work.
+ */
+ public function test_duplicate_fragment_spreads_are_walked_once_per_fragment(): void {
+ // Each fragment spreads the next one twice.
+ $fragment_count = 40;
+ $query = "query Q { ...F0 }\n";
+ for ( $i = 0; $i < $fragment_count - 1; $i++ ) {
+ $next = $i + 1;
+ $query .= "fragment F{$i} on Query { ...F{$next} ...F{$next} }\n";
+ }
+ $query .= 'fragment F' . ( $fragment_count - 1 ) . " on Query { leaf }\n";
+
+ $sut = new class( 15 ) extends QueryDepthRule {
+ /**
+ * Number of selection trees walked.
+ *
+ * @var int
+ */
+ public int $trees_walked = 0;
+
+ /**
+ * Count the call, then walk as usual.
+ *
+ * @param Node $node The node whose selection set is walked.
+ * @param int $depth The depth the node sits at.
+ * @param int $max_depth The maximum depth seen so far.
+ */
+ protected function fieldDepth( Node $node, int $depth = 0, int $max_depth = 0 ): int {
+ ++$this->trees_walked;
+ return parent::fieldDepth( $node, $depth, $max_depth );
+ }
+ };
+
+ $errors = $this->validate( $query, 15, false, $sut );
+
+ // The operation plus each fragment exactly once, rather than once per spread.
+ $this->assertSame( $fragment_count + 1, $sut->trees_walked );
+ $this->assertSame( array(), $errors );
+ }
+
+ /**
+ * @testdox A fragment's depth is counted relative to the position it is spread at.
+ */
+ public function test_fragment_depth_is_relative_to_spread_position(): void {
+ $fragment = ' fragment F on Node { node { leaf } }';
+
+ $this->assert_depth( 2, '{ node { node { ...F } } }' . $fragment );
+ $this->assert_depth( 3, '{ node { node { node { ...F } } } }' . $fragment );
+ }
+
+ /**
+ * @testdox The same fragment spread at two different depths counts at the deeper one.
+ */
+ public function test_same_fragment_at_different_depths_counts_the_deepest(): void {
+ $query = '{ shallow: node { ...F } deep: node { node { ...F } } } fragment F on Node { node { leaf } }';
+
+ $this->assert_depth( 2, $query );
+ }
+
+ /**
+ * @testdox A fragment with no nested selections adds no depth wherever it is spread.
+ */
+ public function test_fragment_without_nested_fields_adds_no_depth(): void {
+ $query = '{ node { node { node { ...F } } } } fragment F on Node { leaf }';
+
+ $this->assert_depth( 2, $query );
+ }
+
+ /**
+ * @testdox Nested fragment spreads compose their relative depths.
+ */
+ public function test_nested_fragment_spreads_compose(): void {
+ $query = '{ node { ...F } } fragment F on Node { node { ...G } } fragment G on Node { node { node { leaf } } }';
+
+ $this->assert_depth( 3, $query );
+ }
+
+ /**
+ * @testdox A fragment cycle terminates and is reported as exceeding the limit, even without the NoFragmentCycles rule.
+ */
+ public function test_fragment_cycle_terminates(): void {
+ $query = '{ ...A } fragment A on Query { node { ...B } } fragment B on Query { node { ...A } }';
+
+ $errors = $this->validate( $query, 100, true );
+
+ $this->assertCount( 1, $errors );
+ $this->assertSame( 'Maximum query depth exceeded.', $errors[0]->getMessage() );
+ }
+}