Automattic\WooCommerce\Internal\Api

QueryComplexityRule{} │ WC 1.0└─ QueryComplexity

QueryComplexity validation rule that returns a generic error message when the complexity is exceeded. Admins can still read both values via debug mode; 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.

No Hooks.

Usage

$QueryComplexityRule = new QueryComplexityRule();
// use class methods

Methods

  1. public getVisitor( QueryValidationContext $context )
  2. public static maxQueryComplexityErrorMessage( int $max, int $count )
  3. private add_saturating( int $a, int $b )
  4. protected buildFieldArguments( FieldNode $node )
  5. protected directiveExcludesField( FieldNode $node )
  6. protected fieldComplexity( SelectionSetNode $selection_set )
  7. protected fieldDefinition( FieldNode $field )
  8. private get_coerced_variable_values()
  9. protected nodeComplexity( SelectionNode $node )

QueryComplexityRule{} code WC 11.1.2

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}.").
	 *
	 * @param int $max   The configured maximum complexity (unused).
	 * @param int $count The computed query complexity (unused).
	 */
	public static function maxQueryComplexityErrorMessage( int $max, int $count ): string {
		return 'Maximum query complexity exceeded.';
	}
}