diff --git a/composer.json b/composer.json index 39bfebf3a..c022200bb 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/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/Validator/DocumentValidator.php b/src/Validator/DocumentValidator.php index 0c4eb498f..50037d58a 100644 --- a/src/Validator/DocumentValidator.php +++ b/src/Validator/DocumentValidator.php @@ -99,16 +99,22 @@ 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)) { + $cached = $cache->isValidated($schema, $ast); + if ($cached) { + return []; + } + } + $rules ??= static::allRules(); if ($rules === []) { return []; } $typeInfo ??= new TypeInfo($schema); - $context = new QueryValidationContext($schema, $ast, $typeInfo); $visitors = []; @@ -124,7 +130,14 @@ public static function validate( ) ); - return $context->getErrors(); + $errors = $context->getErrors(); + + // Only cache clean results + if (isset($cache) && count($errors) === 0) { + $cache->markValidated($schema, $ast); + } + + return $errors; } /** diff --git a/src/Validator/ValidationCache.php b/src/Validator/ValidationCache.php new file mode 100644 index 000000000..755679b83 --- /dev/null +++ b/src/Validator/ValidationCache.php @@ -0,0 +1,23 @@ +isValidatedCalls; + + return parent::isValidated($schema, $ast); + } + + public function markValidated(Schema $schema, DocumentNode $ast): void + { + ++$this->markValidatedCalls; + parent::markValidated($schema, $ast); + } +} + +final class ValidationWithCacheTest extends TestCase +{ + use ArraySubsetAsserts; + + public function testIsValidationCachedWithAdapter(): void + { + $cache = new SpyValidationCacheAdapter(new Psr16Cache(new ArrayAdapter())); + $petType = new InterfaceType([ + 'name' => 'Pet', + 'fields' => [ + 'name' => ['type' => 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' => Type::string()], + 'woofs' => ['type' => 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' => Type::string()], + 'meows' => ['type' => 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 + GraphQL::executeQuery($schema, $query, null, null, null, null, null, null, $cache)->toArray(); + $result = GraphQL::executeQuery($schema, $query, null, null, null, null, null, null, $cache)->toArray(); + + // ✅ Assert that validation only happened once + self::assertEquals(2, $cache->isValidatedCalls, 'Should check cache twice'); + self::assertEquals(1, $cache->markValidatedCalls, 'Should mark as validated once'); + + $expected = [ + 'data' => [ + 'pets' => [ + ['name' => 'Odie', 'woofs' => true], + ['name' => 'Garfield', 'meows' => false], + ], + ], + ]; + + self::assertEquals($expected, $result); + } +} diff --git a/tests/PsrValidationCacheAdapter.php b/tests/PsrValidationCacheAdapter.php new file mode 100644 index 000000000..d03e19794 --- /dev/null +++ b/tests/PsrValidationCacheAdapter.php @@ -0,0 +1,67 @@ +ttl = $ttlSeconds; + $this->cache = $cache; + } + + /** @throws InvalidArgumentException */ + public function isValidated(Schema $schema, DocumentNode $ast): bool + { + try { + $key = $this->buildKey($schema, $ast); + + /** @phpstan-ignore-next-line */ + return $this->cache->has($key); + } catch (\Throwable $e) { + return false; + } + } + + /** @throws InvalidArgumentException */ + public function markValidated(Schema $schema, DocumentNode $ast): void + { + try { + $key = $this->buildKey($schema, $ast); + /** @phpstan-ignore-next-line */ + $this->cache->set($key, true, $this->ttl); + } catch (\Throwable $e) { + // ignore silently + } + } + + /** + * @throws \GraphQL\Error\Error + * @throws \GraphQL\Error\InvariantViolation + * @throws \GraphQL\Error\SerializationError + * @throws \JsonException + */ + private function buildKey(Schema $schema, DocumentNode $ast): string + { + // NOTE: You can override this strategy if you want to make schema fingerprinting cheaper + $schemaHash = md5(SchemaPrinter::doPrint($schema)); + $astHash = md5($ast->__toString()); + + return self::KEY_PREFIX . $schemaHash . '_' . $astHash; + } +}