diff --git a/composer.json b/composer.json index 86569617e..4fc5087d8 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,11 @@ "phpstan/phpstan-strict-rules": "2.0.4", "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", "psr/http-message": "^1 || ^2", + "psr/simple-cache": "^1.0", "react/http": "^1.6", "react/promise": "^2.0 || ^3.0", "rector/rector": "^2.0", + "symfony/cache": "^5.4", "symfony/polyfill-php81": "^1.23", "symfony/var-exporter": "^5 || ^6 || ^7", "thecodingmachine/safe": "^1.3 || ^2 || ^3" diff --git a/docs/class-reference.md b/docs/class-reference.md index 5b79dba4e..3fd6e1434 100644 --- a/docs/class-reference.md +++ b/docs/class-reference.md @@ -70,7 +70,8 @@ static function executeQuery( ?array $variableValues = null, ?string $operationName = null, ?callable $fieldResolver = null, - ?array $validationRules = null + ?array $validationRules = null, + ?GraphQL\Validator\ValidationCache $cache = null ): GraphQL\Executor\ExecutionResult ``` @@ -98,7 +99,8 @@ static function promiseToExecute( ?array $variableValues = null, ?string $operationName = null, ?callable $fieldResolver = null, - ?array $validationRules = null + ?array $validationRules = null, + ?GraphQL\Validator\ValidationCache $cache = null ): GraphQL\Executor\Promise\Promise ``` @@ -1811,7 +1813,8 @@ static function validate( GraphQL\Type\Schema $schema, GraphQL\Language\AST\DocumentNode $ast, ?array $rules = null, - ?GraphQL\Utils\TypeInfo $typeInfo = null + ?GraphQL\Utils\TypeInfo $typeInfo = null, + ?GraphQL\Validator\ValidationCache $cache = null ): array ``` diff --git a/docs/executing-queries.md b/docs/executing-queries.md index 7c5ad5282..5e07617b7 100644 --- a/docs/executing-queries.md +++ b/docs/executing-queries.md @@ -210,3 +210,135 @@ $server = new StandardServer([ 'validationRules' => $myValidationRules ]); ``` + +## Validation Caching + +Validation is a required step in GraphQL execution, but it can become a performance bottleneck. +In production environments, queries are often static or pre-generated (e.g. persisted queries or queries emitted by client libraries). +This means that many queries will be identical and their validation results can be reused. + +To optimize for this, `graphql-php` allows skipping validation for known valid queries. +Leverage pluggable validation caching by passing an implementation of the `GraphQL\Validator\ValidationCache` interface to `GraphQL::executeQuery()`: + +```php +use GraphQL\Validator\ValidationCache; +use GraphQL\GraphQL; + +$validationCache = new MyPsrValidationCacheAdapter(); + +$result = GraphQL::executeQuery( + $schema, + $queryString, + $rootValue, + $context, + $variableValues, + $operationName, + $fieldResolver, + $validationRules, + $validationCache +); +``` + +### Key Generation Tips + +You are responsible for generating cache keys that are unique and dependent on the following inputs: + +- the client-given query +- the current schema +- the passed validation rules and their implementation +- the implementation of `graphql-php` + +Here are some tips: + +- Using `serialize()` directly on the schema object may error due to closures or circular references. + Instead, use `GraphQL\Utils\SchemaPrinter::doPrint($schema)` to get a stable string representation of the schema. +- If using custom validation rules, be sure to account for them in your key (e.g., by serializing or listing their class names and versioning them). +- Include the version number of the `webonyx/graphql-php` package to account for implementation changes in the library. +- Use a stable hash function like `md5()` or `sha256()` to generate the key from the schema, AST, and rules. +- Improve performance even further by hashing inputs known before deploying such as the schema or the installed package version. + You may store the hash in an environment variable or a constant to avoid recalculating it on every request. + +### Sample Implementation + +```php +use GraphQL\Validator\ValidationCache; +use GraphQL\Language\AST\DocumentNode; +use GraphQL\Type\Schema; +use GraphQL\Utils\SchemaPrinter; +use Psr\SimpleCache\CacheInterface; +use Composer\InstalledVersions; + +/** + * Reference implementation of ValidationCache using PSR-16 cache. + * + * @see GraphQl\Tests\PsrValidationCacheAdapter + */ +class MyPsrValidationCacheAdapter implements ValidationCache +{ + private CacheInterface $cache; + + private int $ttlSeconds; + + public function __construct( + CacheInterface $cache, + int $ttlSeconds = 300 + ) { + $this->cache = $cache; + $this->ttlSeconds = $ttlSeconds; + } + + public function isValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): bool + { + $key = $this->buildKey($schema, $ast); + return $this->cache->has($key); + } + + public function markValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): void + { + $key = $this->buildKey($schema, $ast); + $this->cache->set($key, true, $this->ttlSeconds); + } + + private function buildKey(Schema $schema, DocumentNode $ast, ?array $rules = null): string + { + // Include package version to account for implementation changes + $libraryVersion = \Composer\InstalledVersions::getVersion('webonyx/graphql-php') + ?? throw new \RuntimeException('webonyx/graphql-php version not found. Ensure the package is installed.'); + + // Use a stable hash for the schema + $schemaHash = md5(SchemaPrinter::doPrint($schema)); + + // Serialize AST and rules — both are predictable and safe in this context + $astHash = md5(serialize($ast)); + $rulesHash = md5(serialize($rules)); + + return "graphql_validation_{$libraryVersion}_{$schemaHash}_{$astHash}_{$rulesHash}"; + } +} +``` + +An optimized version of `buildKey` might leverage a key prefix for inputs known before deployment. +For example, you may run the following once during deployment and save the output in an environment variable `GRAPHQL_VALIDATION_KEY_PREFIX`: + +```php +$libraryVersion = \Composer\InstalledVersions::getVersion('webonyx/graphql-php') + ?? throw new \RuntimeException('webonyx/graphql-php version not found. Ensure the package is installed.'); + +$schemaHash = md5(SchemaPrinter::doPrint($schema)); + +echo "{$libraryVersion}_{$schemaHash}"; +``` + +Then use the environment variable in your key generation: + +```php + private function buildKey(Schema $schema, DocumentNode $ast, ?array $rules = null): string + { + $keyPrefix = getenv('GRAPHQL_VALIDATION_KEY_PREFIX') + ?? throw new \RuntimeException('Environment variable GRAPHQL_VALIDATION_KEY_PREFIX is not set.'); + $astHash = md5(serialize($ast)); + $rulesHash = md5(serialize($rules)); + + return "graphql_validation_{$keyPrefix}_{$astHash}_{$rulesHash}"; + } +``` diff --git a/src/GraphQL.php b/src/GraphQL.php index 919b09dec..80e65803e 100644 --- a/src/GraphQL.php +++ b/src/GraphQL.php @@ -19,6 +19,7 @@ use GraphQL\Validator\DocumentValidator; use GraphQL\Validator\Rules\QueryComplexity; use GraphQL\Validator\Rules\ValidationRule; +use GraphQL\Validator\ValidationCache; /** * This is the primary facade for fulfilling GraphQL operations. @@ -90,7 +91,8 @@ public static function executeQuery( ?array $variableValues = null, ?string $operationName = null, ?callable $fieldResolver = null, - ?array $validationRules = null + ?array $validationRules = null, + ?ValidationCache $cache = null ): ExecutionResult { $promiseAdapter = new SyncPromiseAdapter(); @@ -103,7 +105,8 @@ public static function executeQuery( $variableValues, $operationName, $fieldResolver, - $validationRules + $validationRules, + $cache ); return $promiseAdapter->wait($promise); @@ -132,7 +135,8 @@ public static function promiseToExecute( ?array $variableValues = null, ?string $operationName = null, ?callable $fieldResolver = null, - ?array $validationRules = null + ?array $validationRules = null, + ?ValidationCache $cache = null ): Promise { try { $documentNode = $source instanceof DocumentNode @@ -152,7 +156,7 @@ public static function promiseToExecute( } } - $validationErrors = DocumentValidator::validate($schema, $documentNode, $validationRules); + $validationErrors = DocumentValidator::validate($schema, $documentNode, $validationRules, null, $cache); if ($validationErrors !== []) { return $promiseAdapter->createFulfilled( diff --git a/src/Server/ServerConfig.php b/src/Server/ServerConfig.php index 08d7517ee..5ffdb600c 100644 --- a/src/Server/ServerConfig.php +++ b/src/Server/ServerConfig.php @@ -124,7 +124,7 @@ public static function create(array $config = []): self private bool $queryBatching = false; /** - * @var array|callable|null + * @var array|callable|null * * @phpstan-var ValidationRulesOption */ @@ -315,7 +315,7 @@ public function getPromiseAdapter(): ?PromiseAdapter } /** - * @return array|callable|null + * @return array|callable|null * * @phpstan-return ValidationRulesOption */ diff --git a/src/Validator/DocumentValidator.php b/src/Validator/DocumentValidator.php index 0c4eb498f..785743e90 100644 --- a/src/Validator/DocumentValidator.php +++ b/src/Validator/DocumentValidator.php @@ -99,20 +99,25 @@ public static function validate( Schema $schema, DocumentNode $ast, ?array $rules = null, - ?TypeInfo $typeInfo = null + ?TypeInfo $typeInfo = null, + ?ValidationCache $cache = null ): array { - $rules ??= static::allRules(); + if (isset($cache)) { + if ($cache->isValidated($schema, $ast, $rules)) { + return []; + } + } - if ($rules === []) { + $finalRules = $rules ?? static::allRules(); + if ($finalRules === []) { return []; } $typeInfo ??= new TypeInfo($schema); - $context = new QueryValidationContext($schema, $ast, $typeInfo); $visitors = []; - foreach ($rules as $rule) { + foreach ($finalRules as $rule) { $visitors[] = $rule->getVisitor($context); } @@ -124,7 +129,13 @@ public static function validate( ) ); - return $context->getErrors(); + $errors = $context->getErrors(); + + if (isset($cache) && $errors === []) { + $cache->markValidated($schema, $ast, $rules); + } + + return $errors; } /** @@ -273,7 +284,6 @@ public static function validateSDL( ?array $rules = null ): array { $rules ??= self::sdlRules(); - if ($rules === []) { return []; } diff --git a/src/Validator/ValidationCache.php b/src/Validator/ValidationCache.php new file mode 100644 index 000000000..597cfd9b3 --- /dev/null +++ b/src/Validator/ValidationCache.php @@ -0,0 +1,46 @@ +|null $rules + * + * @return bool true if validation for the given schema + AST + rules is already known to be valid; false otherwise + */ + public function isValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): bool; + + /** + * @param array|null $rules + * + * Mark the given schema/AST/rules set as successfully validated. + * + * This is typically called after a query passes validation. + * You should store enough information to recognize this combination on future requests. + */ + public function markValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): void; +} diff --git a/tests/Executor/TestClasses/SpyValidationCacheAdapter.php b/tests/Executor/TestClasses/SpyValidationCacheAdapter.php new file mode 100644 index 000000000..655676138 --- /dev/null +++ b/tests/Executor/TestClasses/SpyValidationCacheAdapter.php @@ -0,0 +1,28 @@ +isValidatedCalls; + + return parent::isValidated($schema, $ast); + } + + public function markValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): void + { + ++$this->markValidatedCalls; + + parent::markValidated($schema, $ast); + } +} diff --git a/tests/Executor/ValidationWithCacheTest.php b/tests/Executor/ValidationWithCacheTest.php new file mode 100644 index 000000000..4b6d60b43 --- /dev/null +++ b/tests/Executor/ValidationWithCacheTest.php @@ -0,0 +1,101 @@ + 'Pet', + 'fields' => [ + 'name' => Type::string(), + ], + ]); + + $DogType = new ObjectType([ + 'name' => 'Dog', + 'interfaces' => [$petType], + 'isTypeOf' => static fn ($obj): Deferred => new Deferred(static fn (): bool => $obj instanceof Dog), + 'fields' => [ + 'name' => Type::string(), + 'woofs' => Type::boolean(), + ], + ]); + + $CatType = new ObjectType([ + 'name' => 'Cat', + 'interfaces' => [$petType], + 'isTypeOf' => static fn ($obj): Deferred => new Deferred(static fn (): bool => $obj instanceof Cat), + 'fields' => [ + 'name' => Type::string(), + 'meows' => Type::boolean(), + ], + ]); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'pets' => [ + 'type' => Type::listOf($petType), + 'resolve' => static fn (): array => [ + new Dog('Odie', true), + new Cat('Garfield', false), + ], + ], + ], + ]), + 'types' => [$CatType, $DogType], + ]); + + $query = '{ + pets { + name + ... on Dog { + woofs + } + ... on Cat { + meows + } + } + }'; + + // make the same call twice in a row. We'll then inspect the cache object to count calls + $resultA = GraphQL::executeQuery($schema, $query, null, null, null, null, null, null, $cache)->toArray(); + $resultB = GraphQL::executeQuery($schema, $query, null, null, null, null, null, null, $cache)->toArray(); + + // ✅ Assert that validation only happened once + self::assertSame(2, $cache->isValidatedCalls, 'Should check cache twice'); + self::assertSame(1, $cache->markValidatedCalls, 'Should mark as validated once'); + + $expected = [ + 'data' => [ + 'pets' => [ + ['name' => 'Odie', 'woofs' => true], + ['name' => 'Garfield', 'meows' => false], + ], + ], + ]; + + self::assertSame($expected, $resultA); + self::assertSame($expected, $resultB); + } +} diff --git a/tests/PsrValidationCacheAdapter.php b/tests/PsrValidationCacheAdapter.php new file mode 100644 index 000000000..a6cf86d8c --- /dev/null +++ b/tests/PsrValidationCacheAdapter.php @@ -0,0 +1,83 @@ +cache = $cache; + $this->ttlSeconds = $ttlSeconds; + } + + /** + * @param array|null $rules + * + * @throws \JsonException + * @throws Error + * @throws InvalidArgumentException&\Throwable + * @throws InvariantViolation + * @throws SerializationError + */ + public function isValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): bool + { + $key = $this->buildKey($schema, $ast); + + return $this->cache->has($key); // @phpstan-ignore missingType.checkedException (annotated as a union with Throwable) + } + + /** + * @param array|null $rules + * + * @throws \JsonException + * @throws Error + * @throws InvalidArgumentException&\Throwable + * @throws InvariantViolation + * @throws SerializationError + */ + public function markValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): void + { + $key = $this->buildKey($schema, $ast); + + $this->cache->set($key, true, $this->ttlSeconds); // @phpstan-ignore missingType.checkedException (annotated as a union with Throwable) + } + + /** + * @param array|null $rules + * + * @throws \JsonException + * @throws Error + * @throws InvariantViolation + * @throws SerializationError + */ + private function buildKey(Schema $schema, DocumentNode $ast, ?array $rules = null): string + { + /** + * This default strategy generates a cache key by hashing the printed schema, AST, and any custom rules. + * You'll likely want to replace this with a more stable or efficient method for fingerprinting the schema. + * For example, you may use a build-time hash, schema version number, or an environment-based identifier. + */ + $schemaHash = md5(SchemaPrinter::doPrint($schema)); + $astHash = md5(serialize($ast)); + $rulesHash = md5(serialize($rules)); + + return "graphql_validation_{$schemaHash}_{$astHash}_{$rulesHash}"; + } +}