Skip to content

Cache result of validation #1730

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 41 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
35ce576
cache result of validation
shmax Jul 11, 2025
03db33d
remove ttl
shmax Jul 11, 2025
c3a7eb4
stanning
shmax Jul 11, 2025
a2c38f4
stanning
shmax Jul 11, 2025
6782bad
downgrade to satisfy 7.4
shmax Jul 11, 2025
6317931
use custom interface
shmax Jul 12, 2025
73c9774
files
shmax Jul 12, 2025
6d7ace0
cleanup
shmax Jul 12, 2025
708c85b
cleanup
shmax Jul 12, 2025
f35cb3c
remove trailing comma
shmax Jul 12, 2025
d71e123
fix tests
shmax Jul 12, 2025
cf20ef3
formatting
shmax Jul 14, 2025
60bffe6
move to file
shmax Jul 14, 2025
40c000b
use shorthand, formatting
shmax Jul 14, 2025
7d515fd
assert both results
shmax Jul 14, 2025
c2a82a4
ignore specific error
shmax Jul 14, 2025
51d1087
documentation
shmax Jul 15, 2025
a745345
Autofix
autofix-ci[bot] Jul 15, 2025
1277bb3
use serialize
shmax Jul 16, 2025
59a0cd7
simplify conditional
spawnia Jul 17, 2025
6679cb8
unify formatting
spawnia Jul 17, 2025
8d21f38
Merge branch 'master' into validation-cache
spawnia Jul 17, 2025
de245ff
Polish tests and docs
spawnia Jul 17, 2025
d166e41
Clean up types
spawnia Jul 17, 2025
4b3aafe
include rules in the fun
shmax Jul 18, 2025
9683c02
Autofix
autofix-ci[bot] Jul 18, 2025
02013c3
update comments
shmax Jul 18, 2025
1e55f48
Merge branch 'validation-cache' of github.com:shmax/graphql-php into …
shmax Jul 18, 2025
127b68f
remove comment
shmax Jul 18, 2025
b4865e9
add comments about keys
shmax Jul 23, 2025
4ec12df
pass rules to markValidated
shmax Jul 23, 2025
707f257
add docs
shmax Jul 23, 2025
4c3d419
formatting
shmax Jul 23, 2025
b5fdd3e
remove redundant
shmax Jul 23, 2025
5d9e714
Autofix
autofix-ci[bot] Jul 23, 2025
b8c62dc
Improve docs
spawnia Jul 23, 2025
7a2b3dc
Autofix
autofix-ci[bot] Jul 23, 2025
e8fedcc
make it more clear that samples are using hand-rolled classes
shmax Jul 23, 2025
c91420c
Merge branch 'validation-cache' of github.com:shmax/graphql-php into …
shmax Jul 23, 2025
22e15cc
remove see reference
shmax Jul 23, 2025
cbebfce
Autofix
autofix-ci[bot] Jul 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 6 additions & 3 deletions docs/class-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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
```

Expand Down
12 changes: 8 additions & 4 deletions src/GraphQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();

Expand All @@ -103,7 +105,8 @@ public static function executeQuery(
$variableValues,
$operationName,
$fieldResolver,
$validationRules
$validationRules,
$cache
);

return $promiseAdapter->wait($promise);
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
21 changes: 16 additions & 5 deletions src/Validator/DocumentValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand All @@ -124,7 +130,13 @@ public static function validate(
)
);

return $context->getErrors();
$errors = $context->getErrors();

if (isset($cache) && $errors === []) {
$cache->markValidated($schema, $ast);
}

return $errors;
}

/**
Expand Down Expand Up @@ -273,7 +285,6 @@ public static function validateSDL(
?array $rules = null
): array {
$rules ??= self::sdlRules();

if ($rules === []) {
return [];
}
Expand Down
39 changes: 39 additions & 0 deletions src/Validator/ValidationCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php declare(strict_types=1);

namespace GraphQL\Validator;

use GraphQL\Language\AST\DocumentNode;
use GraphQL\Tests\PsrValidationCacheAdapter;
use GraphQL\Type\Schema;

/**
* Implement this interface and pass an instance to GraphQL::executeQuery to enable caching of successful query validations.
*
* This can improve performance by skipping validation for known-good query and schema combinations.
* You are responsible for defining how cache keys are computed, and when validation should be skipped.
*
* @see PsrValidationCacheAdapter for a toy implementation.
*/
interface ValidationCache
{
/**
* Determine whether the given schema + AST pair has already been successfully validated.
*
* This method should return true if the query has previously passed validation for the provided schema.
* Only successful validations should be considered "cached" — failed validations are not cached.
*
* This allows for optimizations in systems where validation may not be necessary on every request.
* For example, you can always return true for persisted queries that are known to be valid ahead of time.
*
* @return bool true if validation for the given schema + AST is already known to be valid; false otherwise
*/
public function isValidated(Schema $schema, DocumentNode $ast): bool;

/**
* Mark the given schema + AST pair 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): void;
}
28 changes: 28 additions & 0 deletions tests/Executor/TestClasses/SpyValidationCacheAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php declare(strict_types=1);

namespace GraphQL\Tests\Executor\TestClasses;

use GraphQL\Language\AST\DocumentNode;
use GraphQL\Tests\PsrValidationCacheAdapter;
use GraphQL\Type\Schema;

final class SpyValidationCacheAdapter extends PsrValidationCacheAdapter
{
public int $isValidatedCalls = 0;

public int $markValidatedCalls = 0;

public function isValidated(Schema $schema, DocumentNode $ast): bool
{
++$this->isValidatedCalls;

return parent::isValidated($schema, $ast);
}

public function markValidated(Schema $schema, DocumentNode $ast): void
{
++$this->markValidatedCalls;

parent::markValidated($schema, $ast);
}
}
101 changes: 101 additions & 0 deletions tests/Executor/ValidationWithCacheTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php declare(strict_types=1);

namespace GraphQL\Tests\Executor;

use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
use GraphQL\Deferred;
use GraphQL\GraphQL;
use GraphQL\Tests\Executor\TestClasses\Cat;
use GraphQL\Tests\Executor\TestClasses\Dog;
use GraphQL\Tests\Executor\TestClasses\SpyValidationCacheAdapter;
use GraphQL\Type\Definition\InterfaceType;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Psr16Cache;

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::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);
}
}
75 changes: 75 additions & 0 deletions tests/PsrValidationCacheAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php declare(strict_types=1);

namespace GraphQL\Tests;

use GraphQL\Error\Error;
use GraphQL\Error\InvariantViolation;
use GraphQL\Error\SerializationError;
use GraphQL\Language\AST\DocumentNode;
use GraphQL\Type\Schema;
use GraphQL\Utils\SchemaPrinter;
use GraphQL\Validator\ValidationCache;
use Psr\SimpleCache\CacheInterface;
use Psr\SimpleCache\InvalidArgumentException;

class PsrValidationCacheAdapter implements ValidationCache
{
private CacheInterface $cache;

private int $ttlSeconds;

public function __construct(
CacheInterface $cache,
int $ttlSeconds = 300
) {
$this->cache = $cache;
$this->ttlSeconds = $ttlSeconds;
}

/**
* @throws \JsonException
* @throws Error
* @throws InvalidArgumentException&\Throwable
* @throws InvariantViolation
* @throws SerializationError
*/
public function isValidated(Schema $schema, DocumentNode $ast): bool
{
$key = $this->buildKey($schema, $ast);

return $this->cache->has($key); // @phpstan-ignore missingType.checkedException (annotated as a union with Throwable)
}

/**
* @throws \JsonException
* @throws Error
* @throws InvalidArgumentException&\Throwable
* @throws InvariantViolation
* @throws SerializationError
*/
public function markValidated(Schema $schema, DocumentNode $ast): void
{
$key = $this->buildKey($schema, $ast);

$this->cache->set($key, true, $this->ttlSeconds); // @phpstan-ignore missingType.checkedException (annotated as a union with Throwable)
}

/**
* @throws \JsonException
* @throws Error
* @throws InvariantViolation
* @throws SerializationError
*/
private function buildKey(Schema $schema, DocumentNode $ast): string
{
/**
* This default strategy generates a cache key by hashing the printed schema and AST.
* 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));

return "graphql_validation_{$schemaHash}_{$astHash}";
}
}
Loading