diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a8dcb9c..420ba7658 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 000000000..dbbb4952d --- /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 dc48a1301..d0e0318bd 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 51b70b2ae..a66f38a0e 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 2af2f1715..313822b3f 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 c57bac35e..b391e3008 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 2af2f1715..08bd9f400 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 c57bac35e..b391e3008 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 000000000..ae4d53012 --- /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 91fc506bf..36e71f3e0 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 53ae87037..e4b2da68b 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 5cd7cbb71..62653f1fb 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 2523c2a41..b6df5884f 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 000000000..d0224e631 --- /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 0905bc7a0..100fe13f5 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 59c34d0eb..b0dd26ccd 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 72e692284..dec95eaef 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 000000000..f5dadd104 --- /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 502a3add0..9bf6788cf 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 82e0d55e8..55d9e353f 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