Skip to content

Commit cdad2b6

Browse files
spawniaclaude
andcommitted
Use ValidationCache interface from webonyx/graphql-php
Implement the ValidationCache interface from webonyx/graphql-php#1730 to improve validation result caching with automatic cache invalidation. Cache key now includes: - Library versions (webonyx/graphql-php and nuwave/lighthouse) - Schema hash - Query hash - Rule configuration hash (max_query_depth, disable_introspection) This eliminates the need for manual cache clearing when upgrading graphql-php or lighthouse, as the cache auto-invalidates on version changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b65279b commit cdad2b6

4 files changed

Lines changed: 167 additions & 21 deletions

File tree

docs/master/performance/query-caching.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ php artisan lighthouse:clear-query-cache
5353

5454
Other reasons to clear the query cache completely include:
5555

56-
- you plan to upgrade the package `webonyx/graphql-php` to a new version that changes the internal representation of parsed queries
5756
- you have stale queries in your cache that have an inappropriate or missing TTL
5857
- you want to free up disk space used by cached query files
5958

@@ -68,11 +67,23 @@ APQ is enabled by default, but depends on query caching being enabled.
6867

6968
Lighthouse can cache the result of the query validation process as well.
7069
It only caches queries without errors.
71-
`QueryComplexity` validation can not be cached as it is dependent on the query, so it is always executed.
70+
`QueryComplexity` validation can not be cached as it depends on runtime variables, so it is always executed.
7271

7372
Query validation caching is disabled by default.
7473
You can enable it by setting `validation_cache.enable` to `true` in `config/lighthouse.php`.
7574

75+
### Cache key components
76+
77+
The validation cache key includes:
78+
79+
- Library versions (`webonyx/graphql-php` and `nuwave/lighthouse`) - cache is automatically invalidated when upgrading
80+
- Schema hash - cache is invalidated when the schema changes
81+
- Query hash - each unique query has its own cache entry
82+
- Rule configuration hash (`max_query_depth`, `disable_introspection`) - cache is invalidated when security settings change
83+
84+
This ensures that cached validation results are automatically invalidated when any of the inputs that affect validation change.
85+
You do not need to manually clear the cache when upgrading these libraries.
86+
7687
## Testing caveats
7788

7889
If you are mocking Laravel cache classes like `Illuminate\Support\Facades\Cache` or `Illuminate\Cache\Repository` and asserting expectations in your unit tests, it might be best to disable the query cache in your `phpunit.xml`:
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nuwave\Lighthouse\Execution;
4+
5+
use Composer\InstalledVersions;
6+
use GraphQL\Language\AST\DocumentNode;
7+
use GraphQL\Type\Schema;
8+
use GraphQL\Validator\ValidationCache;
9+
use Illuminate\Contracts\Cache\Repository as CacheRepository;
10+
11+
class LighthouseValidationCache implements ValidationCache
12+
{
13+
public function __construct(
14+
protected CacheRepository $cache,
15+
protected string $schemaHash,
16+
protected string $queryHash,
17+
protected string $rulesConfigHash,
18+
protected int $ttl,
19+
) {}
20+
21+
public function isValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): bool
22+
{
23+
return $this->cache->has($this->cacheKey());
24+
}
25+
26+
public function markValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): void
27+
{
28+
$this->cache->put($this->cacheKey(), true, $this->ttl);
29+
}
30+
31+
private function cacheKey(): string
32+
{
33+
$versions = (InstalledVersions::getVersion('webonyx/graphql-php') ?? '')
34+
. (InstalledVersions::getVersion('nuwave/lighthouse') ?? '');
35+
36+
return "lighthouse:validation:{$this->schemaHash}:{$this->queryHash}:{$this->rulesConfigHash}:" . hash('sha256', $versions);
37+
}
38+
}

src/GraphQL.php

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use Nuwave\Lighthouse\Execution\BatchLoader\BatchLoaderRegistry;
3232
use Nuwave\Lighthouse\Execution\CacheableValidationRulesProvider;
3333
use Nuwave\Lighthouse\Execution\ErrorPool;
34+
use Nuwave\Lighthouse\Execution\LighthouseValidationCache;
3435
use Nuwave\Lighthouse\Schema\SchemaBuilder;
3536
use Nuwave\Lighthouse\Schema\Values\FieldValue;
3637
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
@@ -396,36 +397,26 @@ protected function validateCacheableRules(
396397
}
397398

398399
if ($queryHash === null) {
399-
return DocumentValidator::validate($schema, $query, $validationRules); // @phpstan-ignore return.type (TODO remove ignore when requiring a newer version of webonyx/graphql-php)
400+
return DocumentValidator::validate($schema, $query, $validationRules);
400401
}
401402

402403
$validationCacheConfig = $this->configRepository->get('lighthouse.validation_cache');
403404

404405
if (! $validationCacheConfig['enable']) {
405-
return DocumentValidator::validate($schema, $query, $validationRules); // @phpstan-ignore return.type (TODO remove ignore when requiring a newer version of webonyx/graphql-php)
406+
return DocumentValidator::validate($schema, $query, $validationRules);
406407
}
407408

408409
$cacheFactory = Container::getInstance()->make(CacheFactory::class);
409410
$store = $cacheFactory->store($validationCacheConfig['store']);
410411

411-
$cacheKey = "lighthouse:validation:{$schemaHash}:{$queryHash}";
412+
// Compute a hash of rule configurations that affect validation behavior
413+
$rulesConfigHash = hash('sha256', json_encode([
414+
'max_query_depth' => $this->configRepository->get('lighthouse.security.max_query_depth', 0),
415+
'disable_introspection' => $this->configRepository->get('lighthouse.security.disable_introspection', 0),
416+
]) ?: '');
412417

413-
$cachedResult = $store->get($cacheKey);
414-
if ($cachedResult !== null) {
415-
return $cachedResult;
416-
}
417-
418-
$result = DocumentValidator::validate($schema, $query, $validationRules);
419-
420-
// If there are any errors, we return them without caching them.
421-
// As of webonyx/graphql-php 15.14.0, GraphQL\Error\Error is not serializable.
422-
// We would have to figure out how to serialize them properly to cache them.
423-
if ($result !== []) {
424-
return $result; // @phpstan-ignore return.type (TODO remove ignore when requiring a newer version of webonyx/graphql-php)
425-
}
426-
427-
$store->put($cacheKey, $result, $validationCacheConfig['ttl']);
418+
$cache = new LighthouseValidationCache($store, $schemaHash, $queryHash, $rulesConfigHash, $validationCacheConfig['ttl']);
428419

429-
return $result;
420+
return DocumentValidator::validate($schema, $query, $validationRules, null, $cache);
430421
}
431422
}

tests/Integration/ValidationCachingTest.php

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,4 +225,110 @@ public function testDifferentSchemasHasDifferentKeys(): void
225225
$event->assertDispatchedTimes(CacheHit::class, 0);
226226
$event->assertDispatchedTimes(KeyWritten::class, 1);
227227
}
228+
229+
public function testDifferentMaxQueryDepthHasDifferentKeys(): void
230+
{
231+
$config = $this->app->make(ConfigRepository::class);
232+
$config->set('lighthouse.query_cache.enable', false);
233+
$config->set('lighthouse.validation_cache.enable', true);
234+
$config->set('lighthouse.security.max_query_depth', 10);
235+
236+
$event = Event::fake();
237+
238+
$this->graphQL(/** @lang GraphQL */ '
239+
{
240+
foo
241+
}
242+
')->assertExactJson([
243+
'data' => [
244+
'foo' => Foo::THE_ANSWER,
245+
],
246+
]);
247+
248+
$event->assertDispatchedTimes(CacheMissed::class, 1);
249+
$event->assertDispatchedTimes(CacheHit::class, 0);
250+
$event->assertDispatchedTimes(KeyWritten::class, 1);
251+
252+
// refresh container, but keep the same cache
253+
$cacheFactory = $this->app->make(CacheFactory::class);
254+
$this->refreshApplication();
255+
$this->setUp();
256+
257+
$this->app->instance(EventsDispatcher::class, $event);
258+
$this->app->instance(CacheFactory::class, $cacheFactory);
259+
260+
// Change the max_query_depth configuration
261+
$config = $this->app->make(ConfigRepository::class);
262+
$config->set('lighthouse.query_cache.enable', false);
263+
$config->set('lighthouse.validation_cache.enable', true);
264+
$config->set('lighthouse.security.max_query_depth', 20);
265+
266+
// Same query should miss because config changed
267+
$this->graphQL(/** @lang GraphQL */ '
268+
{
269+
foo
270+
}
271+
')->assertExactJson([
272+
'data' => [
273+
'foo' => Foo::THE_ANSWER,
274+
],
275+
]);
276+
277+
$event->assertDispatchedTimes(CacheMissed::class, 2);
278+
$event->assertDispatchedTimes(CacheHit::class, 0);
279+
$event->assertDispatchedTimes(KeyWritten::class, 2);
280+
}
281+
282+
public function testDifferentDisableIntrospectionHasDifferentKeys(): void
283+
{
284+
$config = $this->app->make(ConfigRepository::class);
285+
$config->set('lighthouse.query_cache.enable', false);
286+
$config->set('lighthouse.validation_cache.enable', true);
287+
$config->set('lighthouse.security.disable_introspection', 0);
288+
289+
$event = Event::fake();
290+
291+
$this->graphQL(/** @lang GraphQL */ '
292+
{
293+
foo
294+
}
295+
')->assertExactJson([
296+
'data' => [
297+
'foo' => Foo::THE_ANSWER,
298+
],
299+
]);
300+
301+
$event->assertDispatchedTimes(CacheMissed::class, 1);
302+
$event->assertDispatchedTimes(CacheHit::class, 0);
303+
$event->assertDispatchedTimes(KeyWritten::class, 1);
304+
305+
// refresh container, but keep the same cache
306+
$cacheFactory = $this->app->make(CacheFactory::class);
307+
$this->refreshApplication();
308+
$this->setUp();
309+
310+
$this->app->instance(EventsDispatcher::class, $event);
311+
$this->app->instance(CacheFactory::class, $cacheFactory);
312+
313+
// Change the disable_introspection configuration
314+
$config = $this->app->make(ConfigRepository::class);
315+
$config->set('lighthouse.query_cache.enable', false);
316+
$config->set('lighthouse.validation_cache.enable', true);
317+
$config->set('lighthouse.security.disable_introspection', 1);
318+
319+
// Same query should miss because config changed
320+
$this->graphQL(/** @lang GraphQL */ '
321+
{
322+
foo
323+
}
324+
')->assertExactJson([
325+
'data' => [
326+
'foo' => Foo::THE_ANSWER,
327+
],
328+
]);
329+
330+
$event->assertDispatchedTimes(CacheMissed::class, 2);
331+
$event->assertDispatchedTimes(CacheHit::class, 0);
332+
$event->assertDispatchedTimes(KeyWritten::class, 2);
333+
}
228334
}

0 commit comments

Comments
 (0)