Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"require": {
"php": "^8",
"ext-json": "*",
"composer-runtime-api": "^2",
"haydenpierce/class-finder": "^0.4 || ^0.5",
"illuminate/auth": "^9 || ^10 || ^11 || ^12",
"illuminate/bus": "^9 || ^10 || ^11 || ^12",
Expand All @@ -40,7 +41,7 @@
"illuminate/validation": "^9 || ^10 || ^11 || ^12",
"laragraph/utils": "^1.5 || ^2",
"thecodingmachine/safe": "^1 || ^2 || ^3",
"webonyx/graphql-php": "^15"
"webonyx/graphql-php": "dev-validation-cache as 15.x-dev"
},
"require-dev": {
"algolia/algoliasearch-client-php": "^3",
Expand Down Expand Up @@ -79,6 +80,12 @@
"mll-lab/laravel-graphiql": "A graphical interactive in-browser GraphQL IDE - integrated with Laravel",
"pusher/pusher-php-server": "Required when using the Pusher Subscriptions driver"
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/webonyx/graphql-php.git"
}
],
"minimum-stability": "dev",
"prefer-stable": true,
"autoload": {
Expand Down
15 changes: 13 additions & 2 deletions docs/master/performance/query-caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ php artisan lighthouse:clear-query-cache

Other reasons to clear the query cache completely include:

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

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

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

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

### Cache key components

The validation cache key includes:

- Library versions (`webonyx/graphql-php` and `nuwave/lighthouse`) - cache is automatically invalidated when upgrading
- Schema hash - cache is invalidated when the schema changes
- Query hash - each unique query has its own cache entry
- Rule configuration hash (`max_query_depth`, `disable_introspection`) - cache is invalidated when security settings change

This ensures that cached validation results are automatically invalidated when any of the inputs that affect validation change.
You do not need to manually clear the cache when upgrading these libraries.

## Testing caveats

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`:
Expand Down
64 changes: 64 additions & 0 deletions src/Execution/LighthouseValidationCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php declare(strict_types=1);

namespace Nuwave\Lighthouse\Execution;

use Composer\InstalledVersions;
use GraphQL\Language\AST\DocumentNode;
use GraphQL\Type\Schema;
use GraphQL\Validator\ValidationCache;
use Illuminate\Contracts\Cache\Repository as CacheRepository;

class LighthouseValidationCache implements ValidationCache
{
/** @var array<string> */
protected const RELEVANT_PACKAGES = [
'nuwave/lighthouse',
'webonyx/graphql-php',
];

protected string $packagesHash;

public function __construct(
protected CacheRepository $cache,
protected string $schemaHash,
protected string $queryHash,
protected string $rulesConfigHash,
protected int $ttl,
) {}

public function isValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): bool
{
return $this->cache->has($this->cacheKey());
}

public function markValidated(Schema $schema, DocumentNode $ast, ?array $rules = null): void
{
$this->cache->put($this->cacheKey(), true, $this->ttl);
}

protected function cacheKey(): string
{
return "lighthouse:validation:{$this->schemaHash}:{$this->queryHash}:{$this->rulesConfigHash}:{$this->packagesHash()}";
}

protected function packagesHash(): string
{
return $this->packagesHash ??= $this->buildPackagesHash();
}

protected function buildPackagesHash(): string
{
$versions = [];
foreach (self::RELEVANT_PACKAGES as $package) {
$versions[$package] = $this->requireVersion($package);
}

return hash('sha256', \Safe\json_encode($versions));
}

protected function requireVersion(string $package): string
{
return InstalledVersions::getVersion($package)
?? throw new \RuntimeException("Could not determine version of {$package} package.");
}
}
48 changes: 24 additions & 24 deletions src/GraphQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@
use Nuwave\Lighthouse\Execution\BatchLoader\BatchLoaderRegistry;
use Nuwave\Lighthouse\Execution\CacheableValidationRulesProvider;
use Nuwave\Lighthouse\Execution\ErrorPool;
use Nuwave\Lighthouse\Execution\LighthouseValidationCache;
use Nuwave\Lighthouse\Schema\SchemaBuilder;
use Nuwave\Lighthouse\Schema\Values\FieldValue;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
use Nuwave\Lighthouse\Support\Contracts\ProvidesCacheableValidationRules;
use Nuwave\Lighthouse\Support\Contracts\ProvidesValidationRules;
use Nuwave\Lighthouse\Support\Utils as LighthouseUtils;

Expand Down Expand Up @@ -138,7 +140,7 @@ public function executeParsedQueryRaw(
new StartExecution($schema, $query, $variables, $operationName, $context),
);

if ($this->providesValidationRules instanceof CacheableValidationRulesProvider) {
if ($this->providesValidationRules instanceof ProvidesCacheableValidationRules) {
$cacheableValidationRules = $this->providesValidationRules->cacheableValidationRules();

$errors = $this->validateCacheableRules($cacheableValidationRules, $schema, $this->schemaBuilder->schemaHash(), $query, $queryHash);
Expand Down Expand Up @@ -396,36 +398,34 @@ protected function validateCacheableRules(
}

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

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

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

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

$cacheKey = "lighthouse:validation:{$schemaHash}:{$queryHash}";

$cachedResult = $store->get($cacheKey);
if ($cachedResult !== null) {
return $cachedResult;
}

$result = DocumentValidator::validate($schema, $query, $validationRules);

// If there are any errors, we return them without caching them.
// As of webonyx/graphql-php 15.14.0, GraphQL\Error\Error is not serializable.
// We would have to figure out how to serialize them properly to cache them.
if ($result !== []) {
return $result; // @phpstan-ignore return.type (TODO remove ignore when requiring a newer version of webonyx/graphql-php)
}

$store->put($cacheKey, $result, $validationCacheConfig['ttl']);

return $result;
$cacheStore = $cacheFactory->store($validationCacheConfig['cacheStore']);

$rulesConfigHash = hash('sha256', \Safe\json_encode([
'max_query_depth' => $this->configRepository->get('lighthouse.security.max_query_depth', 0),
'disable_introspection' => $this->configRepository->get('lighthouse.security.disable_introspection', 0),
]));

return DocumentValidator::validate(
schema: $schema,
ast: $query,
rules: $validationRules,
cache: new LighthouseValidationCache(
cache: $cacheStore,
schemaHash: $schemaHash,
queryHash: $queryHash,
rulesConfigHash: $rulesConfigHash,
ttl: $validationCacheConfig['ttl'],
),
);
}
}
106 changes: 106 additions & 0 deletions tests/Integration/ValidationCachingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,110 @@ public function testDifferentSchemasHasDifferentKeys(): void
$event->assertDispatchedTimes(CacheHit::class, 0);
$event->assertDispatchedTimes(KeyWritten::class, 1);
}

public function testDifferentMaxQueryDepthHasDifferentKeys(): void
{
$config = $this->app->make(ConfigRepository::class);
$config->set('lighthouse.query_cache.enable', false);
$config->set('lighthouse.validation_cache.enable', true);
$config->set('lighthouse.security.max_query_depth', 10);

$event = Event::fake();

$this->graphQL(/** @lang GraphQL */ '
{
foo
}
')->assertExactJson([
'data' => [
'foo' => Foo::THE_ANSWER,
],
]);

$event->assertDispatchedTimes(CacheMissed::class, 1);
$event->assertDispatchedTimes(CacheHit::class, 0);
$event->assertDispatchedTimes(KeyWritten::class, 1);

// refresh container, but keep the same cache
$cacheFactory = $this->app->make(CacheFactory::class);
$this->refreshApplication();
$this->setUp();

$this->app->instance(EventsDispatcher::class, $event);
$this->app->instance(CacheFactory::class, $cacheFactory);

// Change the max_query_depth configuration
$config = $this->app->make(ConfigRepository::class);
$config->set('lighthouse.query_cache.enable', false);
$config->set('lighthouse.validation_cache.enable', true);
$config->set('lighthouse.security.max_query_depth', 20);

// Same query should miss because config changed
$this->graphQL(/** @lang GraphQL */ '
{
foo
}
')->assertExactJson([
'data' => [
'foo' => Foo::THE_ANSWER,
],
]);

$event->assertDispatchedTimes(CacheMissed::class, 2);
$event->assertDispatchedTimes(CacheHit::class, 0);
$event->assertDispatchedTimes(KeyWritten::class, 2);
}

public function testDifferentDisableIntrospectionHasDifferentKeys(): void
{
$config = $this->app->make(ConfigRepository::class);
$config->set('lighthouse.query_cache.enable', false);
$config->set('lighthouse.validation_cache.enable', true);
$config->set('lighthouse.security.disable_introspection', 0);

$event = Event::fake();

$this->graphQL(/** @lang GraphQL */ '
{
foo
}
')->assertExactJson([
'data' => [
'foo' => Foo::THE_ANSWER,
],
]);

$event->assertDispatchedTimes(CacheMissed::class, 1);
$event->assertDispatchedTimes(CacheHit::class, 0);
$event->assertDispatchedTimes(KeyWritten::class, 1);

// refresh container, but keep the same cache
$cacheFactory = $this->app->make(CacheFactory::class);
$this->refreshApplication();
$this->setUp();

$this->app->instance(EventsDispatcher::class, $event);
$this->app->instance(CacheFactory::class, $cacheFactory);

// Change the disable_introspection configuration
$config = $this->app->make(ConfigRepository::class);
$config->set('lighthouse.query_cache.enable', false);
$config->set('lighthouse.validation_cache.enable', true);
$config->set('lighthouse.security.disable_introspection', 1);

// Same query should miss because config changed
$this->graphQL(/** @lang GraphQL */ '
{
foo
}
')->assertExactJson([
'data' => [
'foo' => Foo::THE_ANSWER,
],
]);

$event->assertDispatchedTimes(CacheMissed::class, 2);
$event->assertDispatchedTimes(CacheHit::class, 0);
$event->assertDispatchedTimes(KeyWritten::class, 2);
}
}
Loading