From bebb209bd4663003eea02088b09f4842f631e720 Mon Sep 17 00:00:00 2001 From: Konstantin Babushkin Date: Fri, 20 Sep 2024 12:03:55 +0200 Subject: [PATCH] Cache query validation results --- CHANGELOG.md | 4 + benchmarks/HugeRequestBench.php | 94 +++++++ benchmarks/HugeResponseBench.php | 24 +- benchmarks/QueryBench.php | 42 ++- docs/6/performance/query-caching.md | 8 + docs/6/security/validation.md | 6 +- docs/master/performance/query-caching.md | 9 + docs/master/security/validation.md | 6 +- .../CacheableValidationRulesProvider.php | 40 +++ src/GraphQL.php | 84 +++++- src/LighthouseServiceProvider.php | 4 +- src/Schema/AST/DocumentAST.php | 9 + src/Schema/SchemaBuilder.php | 5 + .../ProvidesCacheableValidationRules.php | 29 ++ .../Contracts/ProvidesValidationRules.php | 6 +- src/lighthouse.php | 26 ++ tests/Integration/QueryCachingTest.php | 33 +-- tests/Integration/ValidationCachingTest.php | 253 ++++++++++++++++++ tests/Unit/Schema/AST/DocumentASTTest.php | 11 +- tests/Unit/Schema/SchemaBuilderTest.php | 3 +- 20 files changed, 650 insertions(+), 46 deletions(-) create mode 100644 benchmarks/HugeRequestBench.php create mode 100644 src/Execution/CacheableValidationRulesProvider.php create mode 100644 src/Support/Contracts/ProvidesCacheableValidationRules.php create mode 100644 tests/Integration/ValidationCachingTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a8dcb9cd..420ba7658a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Added + +- Cache query validation results https://github.com/nuwave/lighthouse/pull/2603 + ## v6.44.2 ### Fixed diff --git a/benchmarks/HugeRequestBench.php b/benchmarks/HugeRequestBench.php new file mode 100644 index 0000000000..dbbb4952d8 --- /dev/null +++ b/benchmarks/HugeRequestBench.php @@ -0,0 +1,94 @@ +query ??= $this->generateQuery(1); + $this->graphQL($this->query); + } + + /** + * @Warmup(1) + * + * @Revs(10) + * + * @Iterations(10) + * + * @ParamProviders({"providePerformanceTuning"}) + * + * @BeforeMethods("setPerformanceTuning") + */ + public function benchmark10(): void + { + $this->query ??= $this->generateQuery(10); + $this->graphQL($this->query); + } + + /** + * @Warmup(1) + * + * @Revs(10) + * + * @Iterations(10) + * + * @ParamProviders({"providePerformanceTuning"}) + * + * @BeforeMethods("setPerformanceTuning") + */ + public function benchmark100(): void + { + $this->query ??= $this->generateQuery(100); + $this->graphQL($this->query); + } +} diff --git a/benchmarks/HugeResponseBench.php b/benchmarks/HugeResponseBench.php index dc48a1301c..d0e0318bdc 100644 --- a/benchmarks/HugeResponseBench.php +++ b/benchmarks/HugeResponseBench.php @@ -50,9 +50,15 @@ public function resolve(): array } /** + * @Warmup(1) + * + * @Revs(10) + * * @Iterations(10) * - * @OutputTimeUnit("seconds", precision=3) + * @ParamProviders({"providePerformanceTuning"}) + * + * @BeforeMethods("setPerformanceTuning") */ public function benchmark1(): void { @@ -66,9 +72,15 @@ public function benchmark1(): void } /** + * @Warmup(1) + * + * @Revs(10) + * * @Iterations(10) * - * @OutputTimeUnit("seconds", precision=3) + * @ParamProviders({"providePerformanceTuning"}) + * + * @BeforeMethods("setPerformanceTuning") */ public function benchmark100(): void { @@ -84,9 +96,15 @@ public function benchmark100(): void } /** + * @Warmup(1) + * + * @Revs(10) + * * @Iterations(10) * - * @OutputTimeUnit("seconds", precision=3) + * @ParamProviders({"providePerformanceTuning"}) + * + * @BeforeMethods("setPerformanceTuning") */ public function benchmark10k(): void { diff --git a/benchmarks/QueryBench.php b/benchmarks/QueryBench.php index 51b70b2aec..a66f38a0ef 100644 --- a/benchmarks/QueryBench.php +++ b/benchmarks/QueryBench.php @@ -20,7 +20,10 @@ public function setUp(): void { parent::setUp(); - $routeName = config('lighthouse.route.name'); + $configRepository = $this->app->make(ConfigRepository::class); + assert($configRepository instanceof ConfigRepository); + + $routeName = $configRepository->get('lighthouse.route.name'); $this->graphQLEndpoint = route($routeName); } @@ -35,15 +38,40 @@ protected function graphQLEndpointUrl(array $routeParams = []): string } /** - * Define environment setup. + * Set up function with the performance tuning. * - * @param \Illuminate\Foundation\Application $app + * @param array{0: bool, 1: bool, 2: bool} $params Performance tuning parameters */ - protected function getEnvironmentSetUp($app): void + public function setPerformanceTuning(array $params): void { - parent::getEnvironmentSetUp($app); + $this->setUp(); + + $configRepository = $this->app->make(ConfigRepository::class); + assert($configRepository instanceof ConfigRepository); + + if ($params[0]) { + $configRepository->set('lighthouse.field_middleware', []); + } + + $configRepository->set('lighthouse.query_cache.enable', $params[1]); + $configRepository->set('lighthouse.validation_cache.enable', $params[2]); + } - $config = $app->make(ConfigRepository::class); - $config->set('lighthouse.field_middleware', []); + /** + * Indexes: + * 0: Remove all middlewares + * 1: Enable query cache + * 2: Enable validation cache + * + * @return array + */ + public function providePerformanceTuning(): array + { + return [ + 'nothing' => [false, false, false], + 'query cache' => [false, true, false], + 'query + validation cache' => [false, true, true], + 'everything' => [true, true, true], + ]; } } diff --git a/docs/6/performance/query-caching.md b/docs/6/performance/query-caching.md index 2af2f17152..313822b3f6 100644 --- a/docs/6/performance/query-caching.md +++ b/docs/6/performance/query-caching.md @@ -15,6 +15,14 @@ Lighthouse supports Automatic Persisted Queries (APQ), compatible with the APQ is enabled by default, but depends on query caching being enabled. +## Query validation caching + +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. + +Query validation caching is disabled by default. You can enable it by setting `validation_cache.enable` to `true` in the +configuration in `config/lighthouse.php`. + ## 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/docs/6/security/validation.md b/docs/6/security/validation.md index c57bac35ea..b391e30089 100644 --- a/docs/6/security/validation.md +++ b/docs/6/security/validation.md @@ -314,12 +314,12 @@ By default, Lighthouse enables all default query validation rules from `webonyx/ This covers fundamental checks, e.g. queried fields match the schema, variables have values of the correct type. If you want to add custom rules or change which ones are used, you can bind a custom implementation -of the interface `\Nuwave\Lighthouse\Support\Contracts\ProvidesValidationRules` through a service provider. +of the interface `\Nuwave\Lighthouse\Support\Contracts\ProvidesCacheableValidationRules` through a service provider. ```php use Nuwave\Lighthouse\Support\Contracts\ProvidesValidationRules; -final class MyCustomRulesProvider implements ProvidesValidationRules {} +final class MyCustomRulesProvider implements ProvidesCacheableValidationRules {} -$this->app->bind(ProvidesValidationRules::class, MyCustomRulesProvider::class); +$this->app->bind(ProvidesCacheableValidationRules::class, MyCustomRulesProvider::class); ``` diff --git a/docs/master/performance/query-caching.md b/docs/master/performance/query-caching.md index 2af2f17152..08bd9f4003 100644 --- a/docs/master/performance/query-caching.md +++ b/docs/master/performance/query-caching.md @@ -15,6 +15,15 @@ Lighthouse supports Automatic Persisted Queries (APQ), compatible with the APQ is enabled by default, but depends on query caching being enabled. +## Query validation caching + +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. + +Query validation caching is disabled by default. +You can enable it by setting `validation_cache.enable` to `true` in `config/lighthouse.php`. + ## 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/docs/master/security/validation.md b/docs/master/security/validation.md index c57bac35ea..b391e30089 100644 --- a/docs/master/security/validation.md +++ b/docs/master/security/validation.md @@ -314,12 +314,12 @@ By default, Lighthouse enables all default query validation rules from `webonyx/ This covers fundamental checks, e.g. queried fields match the schema, variables have values of the correct type. If you want to add custom rules or change which ones are used, you can bind a custom implementation -of the interface `\Nuwave\Lighthouse\Support\Contracts\ProvidesValidationRules` through a service provider. +of the interface `\Nuwave\Lighthouse\Support\Contracts\ProvidesCacheableValidationRules` through a service provider. ```php use Nuwave\Lighthouse\Support\Contracts\ProvidesValidationRules; -final class MyCustomRulesProvider implements ProvidesValidationRules {} +final class MyCustomRulesProvider implements ProvidesCacheableValidationRules {} -$this->app->bind(ProvidesValidationRules::class, MyCustomRulesProvider::class); +$this->app->bind(ProvidesCacheableValidationRules::class, MyCustomRulesProvider::class); ``` diff --git a/src/Execution/CacheableValidationRulesProvider.php b/src/Execution/CacheableValidationRulesProvider.php new file mode 100644 index 0000000000..ae4d530126 --- /dev/null +++ b/src/Execution/CacheableValidationRulesProvider.php @@ -0,0 +1,40 @@ + new QueryDepth($this->configRepository->get('lighthouse.security.max_query_depth', 0)), + DisableIntrospection::class => new DisableIntrospection($this->configRepository->get('lighthouse.security.disable_introspection', 0)), + ] + DocumentValidator::allRules(); + + unset($result[QueryComplexity::class]); + + return $result; + } + + public function validationRules(): ?array + { + $maxQueryComplexity = $this->configRepository->get('lighthouse.security.max_query_complexity', 0); + + return $maxQueryComplexity === 0 + ? [] + : [ + QueryComplexity::class => new QueryComplexity($maxQueryComplexity), + ]; + } +} diff --git a/src/GraphQL.php b/src/GraphQL.php index 91fc506bf2..36e71f3e02 100644 --- a/src/GraphQL.php +++ b/src/GraphQL.php @@ -12,6 +12,9 @@ use GraphQL\Server\Helper as GraphQLHelper; use GraphQL\Server\OperationParams; use GraphQL\Server\RequestError; +use GraphQL\Type\Schema; +use GraphQL\Validator\DocumentValidator; +use GraphQL\Validator\Rules\QueryComplexity; use Illuminate\Container\Container; use Illuminate\Contracts\Cache\Factory as CacheFactory; use Illuminate\Contracts\Config\Repository as ConfigRepository; @@ -25,6 +28,7 @@ use Nuwave\Lighthouse\Events\StartExecution; use Nuwave\Lighthouse\Events\StartOperationOrOperations; use Nuwave\Lighthouse\Execution\BatchLoader\BatchLoaderRegistry; +use Nuwave\Lighthouse\Execution\CacheableValidationRulesProvider; use Nuwave\Lighthouse\Execution\ErrorPool; use Nuwave\Lighthouse\Schema\SchemaBuilder; use Nuwave\Lighthouse\Schema\Values\FieldValue; @@ -76,14 +80,14 @@ public function executeQueryString( ?string $operationName = null, ): array { try { - $parsedQuery = $this->parse($query); + $parsedQuery = $this->parse($query, $queryHash); } catch (SyntaxError $syntaxError) { return $this->toSerializableArray( new ExecutionResult(null, [$syntaxError]), ); } - return $this->executeParsedQuery($parsedQuery, $context, $variables, $root, $operationName); + return $this->executeParsedQuery($parsedQuery, $context, $variables, $root, $operationName, $queryHash); } /** @@ -101,8 +105,9 @@ public function executeParsedQuery( ?array $variables = [], mixed $root = null, ?string $operationName = null, + ?string $queryHash = null, ): array { - $result = $this->executeParsedQueryRaw($query, $context, $variables, $root, $operationName); + $result = $this->executeParsedQueryRaw($query, $context, $variables, $root, $operationName, $queryHash); return $this->toSerializableArray($result); } @@ -118,6 +123,7 @@ public function executeParsedQueryRaw( ?array $variables = [], mixed $root = null, ?string $operationName = null, + ?string $queryHash = null, ): ExecutionResult { // Building the executable schema might take a while to do, // so we do it before we fire the StartExecution event. @@ -128,6 +134,15 @@ public function executeParsedQueryRaw( new StartExecution($schema, $query, $variables, $operationName, $context), ); + if ($this->providesValidationRules instanceof CacheableValidationRulesProvider) { + $validationRules = $this->providesValidationRules->cacheableValidationRules(); + + $errors = $this->validateCacheableRules($validationRules, $schema, $this->schemaBuilder->schemaHash(), $query, $queryHash); + if ($errors !== []) { + return new ExecutionResult(null, $errors); + } + } + $result = GraphQLBase::executeQuery( $schema, $query, @@ -237,6 +252,7 @@ public function executeOperation(OperationParams $params, GraphQLContext $contex $params->variables, null, $params->operation, + $params->queryId, ); } catch (\Throwable $throwable) { return $this->toSerializableArray( @@ -252,9 +268,10 @@ public function executeOperation(OperationParams $params, GraphQLContext $contex * * @api */ - public function parse(string $query): DocumentNode + public function parse(string $query, ?string &$hash = null): DocumentNode { $cacheConfig = $this->configRepository->get('lighthouse.query_cache'); + $hash = hash('sha256', $query); if (! $cacheConfig['enable']) { return $this->parseQuery($query); @@ -263,10 +280,8 @@ public function parse(string $query): DocumentNode $cacheFactory = Container::getInstance()->make(CacheFactory::class); $store = $cacheFactory->store($cacheConfig['store']); - $sha256 = hash('sha256', $query); - return $store->remember( - "lighthouse:query:{$sha256}", + "lighthouse:query:{$hash}", $cacheConfig['ttl'], fn (): DocumentNode => $this->parseQuery($query), ); @@ -373,4 +388,59 @@ protected function parseQuery(string $query): DocumentNode 'noLocation' => ! $this->configRepository->get('lighthouse.parse_source_location'), ]); } + + /** + * Provides a result for cacheable validation rules by running them or retrieving it from the cache. + * + * @param array $validationRules + * + * @return array<\GraphQL\Error\Error> + */ + protected function validateCacheableRules( + array $validationRules, + Schema $schema, + string $schemaHash, + DocumentNode $query, + ?string $queryHash, + ): array { + foreach ($validationRules as $rule) { + if ($rule instanceof QueryComplexity) { + throw new \InvalidArgumentException('The QueryComplexity rule must not be registered in cacheableValidationRules, as it depends on variables.'); + } + } + + if ($queryHash === null) { + return DocumentValidator::validate($schema, $query, $validationRules); + } + + $cacheConfig = $this->configRepository->get('lighthouse.validation_cache'); + + if (! isset($cacheConfig['enable']) || ! $cacheConfig['enable']) { + return DocumentValidator::validate($schema, $query, $validationRules); + } + + $cacheKey = "lighthouse:validation:{$schemaHash}:{$queryHash}"; + + $cacheFactory = Container::getInstance()->make(CacheFactory::class); + assert($cacheFactory instanceof CacheFactory); + + $store = $cacheFactory->store($cacheConfig['store']); + $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; + } + + $store->put($cacheKey, $result, $cacheConfig['ttl']); + + return $result; + } } diff --git a/src/LighthouseServiceProvider.php b/src/LighthouseServiceProvider.php index 53ae870370..e4b2da68ba 100644 --- a/src/LighthouseServiceProvider.php +++ b/src/LighthouseServiceProvider.php @@ -27,11 +27,11 @@ use Nuwave\Lighthouse\Console\ValidateSchemaCommand; use Nuwave\Lighthouse\Console\ValidatorCommand; use Nuwave\Lighthouse\Events\RegisterDirectiveNamespaces; +use Nuwave\Lighthouse\Execution\CacheableValidationRulesProvider; use Nuwave\Lighthouse\Execution\ContextFactory; use Nuwave\Lighthouse\Execution\ContextSerializer; use Nuwave\Lighthouse\Execution\ErrorPool; use Nuwave\Lighthouse\Execution\SingleResponse; -use Nuwave\Lighthouse\Execution\ValidationRulesProvider; use Nuwave\Lighthouse\Http\Responses\ResponseStream; use Nuwave\Lighthouse\Schema\AST\ASTBuilder; use Nuwave\Lighthouse\Schema\DirectiveLocator; @@ -100,7 +100,7 @@ public function provideSubscriptionResolver(FieldValue $fieldValue): \Closure } }); - $this->app->bind(ProvidesValidationRules::class, ValidationRulesProvider::class); + $this->app->bind(ProvidesValidationRules::class, CacheableValidationRulesProvider::class); $this->commands(self::COMMANDS); } diff --git a/src/Schema/AST/DocumentAST.php b/src/Schema/AST/DocumentAST.php index 5cd7cbb71b..62653f1fbb 100644 --- a/src/Schema/AST/DocumentAST.php +++ b/src/Schema/AST/DocumentAST.php @@ -32,6 +32,7 @@ * directives: array>, * classNameToObjectTypeName: ClassNameToObjectTypeName, * schemaExtensions: array>, + * hash: string, * } * * @implements \Illuminate\Contracts\Support\Arrayable @@ -46,6 +47,8 @@ class DocumentAST implements Arrayable public const SCHEMA_EXTENSIONS = 'schemaExtensions'; + public const HASH = 'hash'; + /** * The types within the schema. * @@ -88,6 +91,9 @@ class DocumentAST implements Arrayable /** @var array */ public array $schemaExtensions = []; + /** A hash of the schema. */ + public string $hash; + /** Create a new DocumentAST instance from a schema. */ public static function fromSource(string $schema): self { @@ -104,6 +110,7 @@ public static function fromSource(string $schema): self } $instance = new static(); + $instance->hash = hash('sha256', $schema); foreach ($documentNode->definitions as $definition) { if ($definition instanceof TypeDefinitionNode) { @@ -195,6 +202,7 @@ public function toArray(): array self::DIRECTIVES => array_map([AST::class, 'toArray'], $this->directives), self::CLASS_NAME_TO_OBJECT_TYPE_NAME => $this->classNameToObjectTypeNames, self::SCHEMA_EXTENSIONS => array_map([AST::class, 'toArray'], $this->schemaExtensions), + self::HASH => $this->hash, ]; } @@ -231,6 +239,7 @@ protected function hydrateFromArray(array $ast): void self::DIRECTIVES => $directives, self::CLASS_NAME_TO_OBJECT_TYPE_NAME => $this->classNameToObjectTypeNames, self::SCHEMA_EXTENSIONS => $schemaExtensions, + self::HASH => $this->hash, ] = $ast; // Utilize the NodeList for lazy unserialization for performance gains. diff --git a/src/Schema/SchemaBuilder.php b/src/Schema/SchemaBuilder.php index 2523c2a41d..b6df5884f1 100644 --- a/src/Schema/SchemaBuilder.php +++ b/src/Schema/SchemaBuilder.php @@ -28,6 +28,11 @@ public function schema(): Schema ); } + public function schemaHash(): string + { + return $this->astBuilder->documentAST()->hash; + } + /** Build an executable schema from an AST. */ protected function build(DocumentAST $documentAST): Schema { diff --git a/src/Support/Contracts/ProvidesCacheableValidationRules.php b/src/Support/Contracts/ProvidesCacheableValidationRules.php new file mode 100644 index 0000000000..d0224e631b --- /dev/null +++ b/src/Support/Contracts/ProvidesCacheableValidationRules.php @@ -0,0 +1,29 @@ + + */ + public function cacheableValidationRules(): array; + + /** + * Rules where the result also depends on variables or other data. + * + * These rules are always executed and their result is never cached. + * + * Returning `null` enables all available rules, + * returning `[]` skips query validation entirely. + * + * @return array|null + */ + public function validationRules(): ?array; +} diff --git a/src/Support/Contracts/ProvidesValidationRules.php b/src/Support/Contracts/ProvidesValidationRules.php index 0905bc7a08..100fe13f50 100644 --- a/src/Support/Contracts/ProvidesValidationRules.php +++ b/src/Support/Contracts/ProvidesValidationRules.php @@ -5,10 +5,10 @@ interface ProvidesValidationRules { /** - * A set of rules for query validation step. + * Rules to use for query validation. * - * Returning `null` enables all available rules. - * Empty array skips query validation entirely. + * Returning `null` enables all available rules, + * returning `[]` skips query validation entirely. * * @return array|null */ diff --git a/src/lighthouse.php b/src/lighthouse.php index 59c34d0eb7..b0dd26ccd1 100644 --- a/src/lighthouse.php +++ b/src/lighthouse.php @@ -134,6 +134,32 @@ 'ttl' => env('LIGHTHOUSE_QUERY_CACHE_TTL', 24 * 60 * 60), ], + /* + |-------------------------------------------------------------------------- + | Validation Cache + |-------------------------------------------------------------------------- + | + | Caches the result of validating queries to boost performance on subsequent requests. + | + */ + + 'validation_cache' => [ + /* + * Setting to true enables validation caching. + */ + 'enable' => env('LIGHTHOUSE_VALIDATION_CACHE_ENABLE', false), + + /* + * Allows using a specific cache store, uses the app's default if set to null. + */ + 'store' => env('LIGHTHOUSE_VALIDATION_CACHE_STORE', null), + + /* + * Duration in seconds the validation result should remain cached, null means forever. + */ + 'ttl' => env('LIGHTHOUSE_VALIDATION_CACHE_TTL', 24 * 60 * 60), + ], + /* |-------------------------------------------------------------------------- | Parse source location diff --git a/tests/Integration/QueryCachingTest.php b/tests/Integration/QueryCachingTest.php index 72e6922841..dec95eaefa 100644 --- a/tests/Integration/QueryCachingTest.php +++ b/tests/Integration/QueryCachingTest.php @@ -14,8 +14,9 @@ public function testEnabled(): void { $config = $this->app->make(ConfigRepository::class); $config->set('lighthouse.query_cache.enable', true); + $config->set('lighthouse.validation_cache.enable', false); - Event::fake(); + $event = Event::fake(); $this->graphQL(/** @lang GraphQL */ ' { @@ -27,9 +28,9 @@ public function testEnabled(): void ], ]); - Event::assertDispatchedTimes(CacheMissed::class, 1); - Event::assertDispatchedTimes(CacheHit::class, 0); - Event::assertDispatchedTimes(KeyWritten::class, 1); + $event->assertDispatchedTimes(CacheMissed::class, 1); + $event->assertDispatchedTimes(CacheHit::class, 0); + $event->assertDispatchedTimes(KeyWritten::class, 1); // second request should be hit $this->graphQL(/** @lang GraphQL */ ' @@ -42,17 +43,18 @@ public function testEnabled(): void ], ]); - Event::assertDispatchedTimes(CacheMissed::class, 1); - Event::assertDispatchedTimes(CacheHit::class, 1); - Event::assertDispatchedTimes(KeyWritten::class, 1); + $event->assertDispatchedTimes(CacheMissed::class, 1); + $event->assertDispatchedTimes(CacheHit::class, 1); + $event->assertDispatchedTimes(KeyWritten::class, 1); } public function testDifferentQueriesHasDifferentKeys(): void { $config = $this->app->make(ConfigRepository::class); $config->set('lighthouse.query_cache.enable', true); + $config->set('lighthouse.validation_cache.enable', false); - Event::fake(); + $event = Event::fake(); $this->graphQL(/** @lang GraphQL */ ' { @@ -75,17 +77,18 @@ public function testDifferentQueriesHasDifferentKeys(): void ], ]); - Event::assertDispatchedTimes(CacheMissed::class, 2); - Event::assertDispatchedTimes(CacheHit::class, 0); - Event::assertDispatchedTimes(KeyWritten::class, 2); + $event->assertDispatchedTimes(CacheMissed::class, 2); + $event->assertDispatchedTimes(CacheHit::class, 0); + $event->assertDispatchedTimes(KeyWritten::class, 2); } public function testDisabled(): void { $config = $this->app->make(ConfigRepository::class); $config->set('lighthouse.query_cache.enable', false); + $config->set('lighthouse.validation_cache.enable', false); - Event::fake(); + $event = Event::fake(); $this->graphQL(/** @lang GraphQL */ ' { @@ -97,8 +100,8 @@ public function testDisabled(): void ], ]); - Event::assertDispatchedTimes(CacheMissed::class, 0); - Event::assertDispatchedTimes(CacheHit::class, 0); - Event::assertDispatchedTimes(KeyWritten::class, 0); + $event->assertDispatchedTimes(CacheMissed::class, 0); + $event->assertDispatchedTimes(CacheHit::class, 0); + $event->assertDispatchedTimes(KeyWritten::class, 0); } } diff --git a/tests/Integration/ValidationCachingTest.php b/tests/Integration/ValidationCachingTest.php new file mode 100644 index 0000000000..f5dadd1042 --- /dev/null +++ b/tests/Integration/ValidationCachingTest.php @@ -0,0 +1,253 @@ +app->make(ConfigRepository::class); + $config->set('lighthouse.query_cache.enable', false); + $config->set('lighthouse.validation_cache.enable', true); + + $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); + + // second request should be hit + $this->graphQL(/** @lang GraphQL */ ' + { + foo + } + ')->assertExactJson([ + 'data' => [ + 'foo' => Foo::THE_ANSWER, + ], + ]); + + $event->assertDispatchedTimes(CacheMissed::class, 1); + $event->assertDispatchedTimes(CacheHit::class, 1); + $event->assertDispatchedTimes(KeyWritten::class, 1); + } + + public function testDisabled(): void + { + $config = $this->app->make(ConfigRepository::class); + $config->set('lighthouse.query_cache.enable', false); + $config->set('lighthouse.validation_cache.enable', false); + + $event = Event::fake(); + + $this->graphQL(/** @lang GraphQL */ ' + { + foo + } + ')->assertExactJson([ + 'data' => [ + 'foo' => Foo::THE_ANSWER, + ], + ]); + + $event->assertDispatchedTimes(CacheMissed::class, 0); + $event->assertDispatchedTimes(CacheHit::class, 0); + $event->assertDispatchedTimes(KeyWritten::class, 0); + } + + public function testConfigMissing(): void + { + $config = $this->app->make(ConfigRepository::class); + $config->set('lighthouse.query_cache.enable', false); + $config->set('lighthouse.validation_cache', null); + + $event = Event::fake(); + + $this->graphQL(/** @lang GraphQL */ ' + { + foo + } + ')->assertExactJson([ + 'data' => [ + 'foo' => Foo::THE_ANSWER, + ], + ]); + + $event->assertDispatchedTimes(CacheMissed::class, 0); + $event->assertDispatchedTimes(CacheHit::class, 0); + $event->assertDispatchedTimes(KeyWritten::class, 0); + } + + public function testErrorsAreNotCached(): void + { + $config = $this->app->make(ConfigRepository::class); + $config->set('lighthouse.query_cache.enable', false); + $config->set('lighthouse.validation_cache.enable', true); + + $event = Event::fake(); + + $this->graphQL(/** @lang GraphQL */ ' + { + bar + } + ')->assertGraphQLErrorMessage('Cannot query field "bar" on type "Query".'); + + $event->assertDispatchedTimes(CacheMissed::class, 1); + $event->assertDispatchedTimes(CacheHit::class, 0); + $event->assertDispatchedTimes(KeyWritten::class, 0); + } + + public function testDifferentQueriesHasDifferentKeys(): void + { + $config = $this->app->make(ConfigRepository::class); + $config->set('lighthouse.query_cache.enable', false); + $config->set('lighthouse.validation_cache.enable', true); + + $event = Event::fake(); + + $this->graphQL(/** @lang GraphQL */ ' + { + foo + } + ')->assertExactJson([ + 'data' => [ + 'foo' => Foo::THE_ANSWER, + ], + ]); + + $this->graphQL(/** @lang GraphQL */ ' + { + foo + foo + } + ')->assertExactJson([ + 'data' => [ + 'foo' => Foo::THE_ANSWER, + ], + ]); + + $event->assertDispatchedTimes(CacheMissed::class, 2); + $event->assertDispatchedTimes(CacheHit::class, 0); + $event->assertDispatchedTimes(KeyWritten::class, 2); + } + + public function testSameSchemaAndSameQueryHaveSameKeys(): void + { + $config = $this->app->make(ConfigRepository::class); + $config->set('lighthouse.query_cache.enable', false); + $config->set('lighthouse.validation_cache.enable', true); + + $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); + + $config = $this->app->make(ConfigRepository::class); + $config->set('lighthouse.query_cache.enable', false); + $config->set('lighthouse.validation_cache.enable', true); + + $this->graphQL(/** @lang GraphQL */ ' + { + foo + } + ')->assertExactJson([ + 'data' => [ + 'foo' => Foo::THE_ANSWER, + ], + ]); + + $event->assertDispatchedTimes(CacheMissed::class, 1); + $event->assertDispatchedTimes(CacheHit::class, 1); + $event->assertDispatchedTimes(KeyWritten::class, 1); + } + + public function testDifferentSchemasHasDifferentKeys(): void + { + $config = $this->app->make(ConfigRepository::class); + $config->set('lighthouse.query_cache.enable', false); + $config->set('lighthouse.validation_cache.enable', true); + + $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); + + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' +type Query { + bar: String +} + +GRAPHQL; + // 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); + + $config = $this->app->make(ConfigRepository::class); + $config->set('lighthouse.query_cache.enable', false); + $config->set('lighthouse.validation_cache.enable', true); + + $this->graphQL(/** @lang GraphQL */ ' + { + foo + } + ')->assertGraphQLErrorMessage('Cannot query field "foo" on type "Query".'); + + $event->assertDispatchedTimes(CacheMissed::class, 2); + $event->assertDispatchedTimes(CacheHit::class, 0); + $event->assertDispatchedTimes(KeyWritten::class, 1); + } +} diff --git a/tests/Unit/Schema/AST/DocumentASTTest.php b/tests/Unit/Schema/AST/DocumentASTTest.php index 502a3add06..9bf6788cf0 100644 --- a/tests/Unit/Schema/AST/DocumentASTTest.php +++ b/tests/Unit/Schema/AST/DocumentASTTest.php @@ -18,16 +18,21 @@ final class DocumentASTTest extends TestCase { public function testParsesSimpleSchema(): void { - $documentAST = DocumentAST::fromSource(/** @lang GraphQL */ ' + $schema = /** @lang GraphQL */ ' type Query { foo: Int } - '); + '; + // calculated as hash('sha256', $schema) + $schemaHash = '99fd7bd3f58a98d8932c1f5d1da718707f6f471e93d96e0bc913436445a947ac'; + $documentAST = DocumentAST::fromSource($schema); $this->assertInstanceOf( ObjectTypeDefinitionNode::class, $documentAST->types[RootType::QUERY], ); + + $this->assertSame($schemaHash, $documentAST->hash); } public function testThrowsOnInvalidSchema(): void @@ -111,5 +116,7 @@ public function testBeSerialized(): void $schemaExtension = $reserialized->schemaExtensions[0]; $this->assertInstanceOf(SchemaExtensionNode::class, $schemaExtension); $this->assertInstanceOf(DirectiveNode::class, $schemaExtension->directives[0]); + + $this->assertSame($documentAST->hash, $reserialized->hash); } } diff --git a/tests/Unit/Schema/SchemaBuilderTest.php b/tests/Unit/Schema/SchemaBuilderTest.php index 82e0d55e88..55d9e353f0 100644 --- a/tests/Unit/Schema/SchemaBuilderTest.php +++ b/tests/Unit/Schema/SchemaBuilderTest.php @@ -9,6 +9,7 @@ use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ObjectType; use Nuwave\Lighthouse\Schema\RootType; +use Nuwave\Lighthouse\Schema\SchemaBuilder; use Tests\TestCase; final class SchemaBuilderTest extends TestCase @@ -18,7 +19,7 @@ public function testGeneratesValidSchema(): void $this->buildSchemaWithPlaceholderQuery('') ->assertValid(); - $this->expectNotToPerformAssertions(); + $this->assertNotNull($this->app->make(SchemaBuilder::class)->schemaHash()); } public function testGeneratesWithEmptyQueryType(): void