diff --git a/composer.json b/composer.json index 85b6ca3f4..86d36c506 100644 --- a/composer.json +++ b/composer.json @@ -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", @@ -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", @@ -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": { diff --git a/docs/master/performance/query-caching.md b/docs/master/performance/query-caching.md index e3efc12ab..f1d91dd58 100644 --- a/docs/master/performance/query-caching.md +++ b/docs/master/performance/query-caching.md @@ -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 @@ -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`: diff --git a/src/Execution/LighthouseValidationCache.php b/src/Execution/LighthouseValidationCache.php new file mode 100644 index 000000000..3f965ce1b --- /dev/null +++ b/src/Execution/LighthouseValidationCache.php @@ -0,0 +1,64 @@ + */ + 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."); + } +} diff --git a/src/GraphQL.php b/src/GraphQL.php index 9cd758f94..f1cde3d03 100644 --- a/src/GraphQL.php +++ b/src/GraphQL.php @@ -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; @@ -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); @@ -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'], + ), + ); } } diff --git a/tests/Integration/ValidationCachingTest.php b/tests/Integration/ValidationCachingTest.php index 0cb2e88df..e4590efd4 100644 --- a/tests/Integration/ValidationCachingTest.php +++ b/tests/Integration/ValidationCachingTest.php @@ -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); + } }