Skip to content

Commit d286d55

Browse files
authored
Automatic query complexity (#612)
* Automatic query complexity * Fix tests after master merge * Code style * Add annotation reference and add some tests * Fix PHPStan fails * Fix PHPUnit test on prefer-lowest * Fix docs generation
1 parent 96c323a commit d286d55

21 files changed

+1194
-321
lines changed

src/Annotations/Cost.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Annotations;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)]
10+
class Cost implements MiddlewareAnnotationInterface
11+
{
12+
/**
13+
* @param int $complexity Complexity for that field
14+
* @param string[] $multipliers Names of fields by value of which complexity will be multiplied
15+
* @param ?int $defaultMultiplier Default multiplier value if all multipliers are missing/null
16+
*/
17+
public function __construct(
18+
public readonly int $complexity = 1,
19+
public readonly array $multipliers = [],
20+
public readonly int|null $defaultMultiplier = null,
21+
) {
22+
}
23+
}

src/Annotations/MiddlewareAnnotations.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ public function __construct(private array $annotations)
2121
/**
2222
* Return annotations of the $className type
2323
*
24-
* @return array<int, MiddlewareAnnotationInterface>
24+
* @param class-string<TAnnotation> $className
25+
*
26+
* @return array<int, TAnnotation>
27+
*
28+
* @template TAnnotation of MiddlewareAnnotationInterface
2529
*/
2630
public function getAnnotationsByType(string $className): array
2731
{
@@ -32,6 +36,12 @@ public function getAnnotationsByType(string $className): array
3236

3337
/**
3438
* Returns at most 1 annotation of the $className type.
39+
*
40+
* @param class-string<TAnnotation> $className
41+
*
42+
* @return TAnnotation|null
43+
*
44+
* @template TAnnotation of MiddlewareAnnotationInterface
3545
*/
3646
public function getAnnotationByType(string $className): MiddlewareAnnotationInterface|null
3747
{

src/Http/Psr15GraphQLMiddlewareBuilder.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
use GraphQL\Error\DebugFlag;
99
use GraphQL\Server\ServerConfig;
1010
use GraphQL\Type\Schema;
11+
use GraphQL\Validator\DocumentValidator;
12+
use GraphQL\Validator\Rules\QueryComplexity;
13+
use GraphQL\Validator\Rules\ValidationRule;
1114
use Laminas\Diactoros\ResponseFactory;
1215
use Laminas\Diactoros\StreamFactory;
1316
use Psr\Http\Message\ResponseFactoryInterface;
@@ -21,6 +24,7 @@
2124
use TheCodingMachine\GraphQLite\Server\PersistedQuery\NotSupportedPersistedQueryLoader;
2225

2326
use function class_exists;
27+
use function is_callable;
2428

2529
/**
2630
* A factory generating a PSR-15 middleware tailored for GraphQLite.
@@ -38,6 +42,9 @@ class Psr15GraphQLMiddlewareBuilder
3842

3943
private HttpCodeDeciderInterface $httpCodeDecider;
4044

45+
/** @var ValidationRule[] */
46+
private array $addedValidationRules = [];
47+
4148
public function __construct(Schema $schema)
4249
{
4350
$this->config = new ServerConfig();
@@ -97,6 +104,18 @@ public function useAutomaticPersistedQueries(CacheInterface $cache, DateInterval
97104
return $this;
98105
}
99106

107+
public function limitQueryComplexity(int $complexity): self
108+
{
109+
return $this->addValidationRule(new QueryComplexity($complexity));
110+
}
111+
112+
public function addValidationRule(ValidationRule $rule): self
113+
{
114+
$this->addedValidationRules[] = $rule;
115+
116+
return $this;
117+
}
118+
100119
public function createMiddleware(): MiddlewareInterface
101120
{
102121
if ($this->responseFactory === null && ! class_exists(ResponseFactory::class)) {
@@ -109,6 +128,21 @@ public function createMiddleware(): MiddlewareInterface
109128
}
110129
$this->streamFactory = $this->streamFactory ?: new StreamFactory();
111130

131+
// If getValidationRules() is null in the config, DocumentValidator will default to DocumentValidator::allRules().
132+
// So if we only added given rule, all of the default rules would not be validated, so we must also provide them.
133+
$originalValidationRules = $this->config->getValidationRules() ?? DocumentValidator::allRules();
134+
135+
$this->config->setValidationRules(function (...$args) use ($originalValidationRules) {
136+
if (is_callable($originalValidationRules)) {
137+
$originalValidationRules = $originalValidationRules(...$args);
138+
}
139+
140+
return [
141+
...$originalValidationRules,
142+
...$this->addedValidationRules,
143+
];
144+
});
145+
112146
return new WebonyxGraphqlMiddleware($this->config, $this->responseFactory, $this->streamFactory, $this->httpCodeDecider, $this->url);
113147
}
114148
}

src/Middlewares/AuthorizationFieldMiddleware.php

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,15 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler
3434
$annotations = $queryFieldDescriptor->getMiddlewareAnnotations();
3535

3636
$loggedAnnotation = $annotations->getAnnotationByType(Logged::class);
37-
assert($loggedAnnotation === null || $loggedAnnotation instanceof Logged);
3837
$rightAnnotation = $annotations->getAnnotationByType(Right::class);
39-
assert($rightAnnotation === null || $rightAnnotation instanceof Right);
4038

4139
// Avoid wrapping resolver callback when no annotations are specified.
4240
if (! $loggedAnnotation && ! $rightAnnotation) {
4341
return $fieldHandler->handle($queryFieldDescriptor);
4442
}
4543

4644
$failWith = $annotations->getAnnotationByType(FailWith::class);
47-
assert($failWith === null || $failWith instanceof FailWith);
4845
$hideIfUnauthorized = $annotations->getAnnotationByType(HideIfUnauthorized::class);
49-
assert($hideIfUnauthorized instanceof HideIfUnauthorized || $hideIfUnauthorized === null);
5046

5147
if ($failWith !== null && $hideIfUnauthorized !== null) {
5248
throw IncompatibleAnnotationsException::cannotUseFailWithAndHide();

src/Middlewares/AuthorizationInputFieldMiddleware.php

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,14 @@ public function process(InputFieldDescriptor $inputFieldDescriptor, InputFieldHa
3232
$annotations = $inputFieldDescriptor->getMiddlewareAnnotations();
3333

3434
$loggedAnnotation = $annotations->getAnnotationByType(Logged::class);
35-
assert($loggedAnnotation === null || $loggedAnnotation instanceof Logged);
3635
$rightAnnotation = $annotations->getAnnotationByType(Right::class);
37-
assert($rightAnnotation === null || $rightAnnotation instanceof Right);
3836

3937
// Avoid wrapping resolver callback when no annotations are specified.
4038
if (! $loggedAnnotation && ! $rightAnnotation) {
4139
return $inputFieldHandler->handle($inputFieldDescriptor);
4240
}
4341

4442
$hideIfUnauthorized = $annotations->getAnnotationByType(HideIfUnauthorized::class);
45-
assert($hideIfUnauthorized instanceof HideIfUnauthorized || $hideIfUnauthorized === null);
4643

4744
if ($hideIfUnauthorized !== null && ! $this->isAuthorized($loggedAnnotation, $rightAnnotation)) {
4845
return null;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Middlewares;
6+
7+
use GraphQL\Type\Definition\FieldDefinition;
8+
use TheCodingMachine\GraphQLite\Annotations\Cost;
9+
use TheCodingMachine\GraphQLite\QueryFieldDescriptor;
10+
11+
use function implode;
12+
use function is_int;
13+
14+
/**
15+
* Reference implementation: https://github.com/ChilliCream/graphql-platform/blob/388f5c988bbb806e46e2315f1844ea5bb63096f2/src/HotChocolate/Core/src/Execution/Options/ComplexityAnalyzerSettings.cs#L58
16+
*/
17+
class CostFieldMiddleware implements FieldMiddlewareInterface
18+
{
19+
public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandlerInterface $fieldHandler): FieldDefinition|null
20+
{
21+
$costAttribute = $queryFieldDescriptor->getMiddlewareAnnotations()->getAnnotationByType(Cost::class);
22+
23+
if (! $costAttribute) {
24+
return $fieldHandler->handle($queryFieldDescriptor);
25+
}
26+
27+
$field = $fieldHandler->handle(
28+
$queryFieldDescriptor->withAddedCommentLines($this->buildQueryComment($costAttribute)),
29+
);
30+
31+
if (! $field) {
32+
return $field;
33+
}
34+
35+
$field->complexityFn = static function (int $childrenComplexity, array $fieldArguments) use ($costAttribute): int {
36+
if (! $costAttribute->multipliers) {
37+
return $costAttribute->complexity + $childrenComplexity;
38+
}
39+
40+
$cost = $costAttribute->complexity + $childrenComplexity;
41+
$needsDefaultMultiplier = true;
42+
43+
foreach ($costAttribute->multipliers as $multiplier) {
44+
$value = $fieldArguments[$multiplier] ?? null;
45+
46+
if (! is_int($value)) {
47+
continue;
48+
}
49+
50+
$cost *= $value;
51+
$needsDefaultMultiplier = false;
52+
}
53+
54+
if ($needsDefaultMultiplier && $costAttribute->defaultMultiplier !== null) {
55+
$cost *= $costAttribute->defaultMultiplier;
56+
}
57+
58+
return $cost;
59+
};
60+
61+
return $field;
62+
}
63+
64+
private function buildQueryComment(Cost $costAttribute): string
65+
{
66+
return 'Cost: ' .
67+
implode(', ', [
68+
'complexity = ' . $costAttribute->complexity,
69+
'multipliers = [' . implode(', ', $costAttribute->multipliers) . ']',
70+
'defaultMultiplier = ' . ($costAttribute->defaultMultiplier ?? 'null'),
71+
]);
72+
}
73+
}

src/Middlewares/SecurityFieldMiddleware.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler
4242
}
4343

4444
$failWith = $annotations->getAnnotationByType(FailWith::class);
45-
assert($failWith instanceof FailWith || $failWith === null);
4645

4746
// If the failWith value is null and the return type is non nullable, we must set it to nullable.
4847
$makeReturnTypeNullable = false;

src/QueryFieldDescriptor.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,15 @@ public function withComment(string|null $comment): self
174174
return $this->with(comment: $comment);
175175
}
176176

177+
public function withAddedCommentLines(string $comment): self
178+
{
179+
if (! $this->comment) {
180+
return $this->withComment($comment);
181+
}
182+
183+
return $this->withComment($this->comment . "\n" . $comment);
184+
}
185+
177186
public function getDeprecationReason(): string|null
178187
{
179188
return $this->deprecationReason;

src/SchemaFactory.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
use TheCodingMachine\GraphQLite\Mappers\TypeMapperInterface;
4242
use TheCodingMachine\GraphQLite\Middlewares\AuthorizationFieldMiddleware;
4343
use TheCodingMachine\GraphQLite\Middlewares\AuthorizationInputFieldMiddleware;
44+
use TheCodingMachine\GraphQLite\Middlewares\CostFieldMiddleware;
4445
use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewareInterface;
4546
use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewarePipe;
4647
use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewareInterface;
@@ -211,9 +212,7 @@ public function addParameterMiddleware(ParameterMiddlewareInterface $parameterMi
211212
return $this;
212213
}
213214

214-
/**
215-
* @deprecated Use PHP8 Attributes instead
216-
*/
215+
/** @deprecated Use PHP8 Attributes instead */
217216
public function setDoctrineAnnotationReader(Reader $annotationReader): self
218217
{
219218
$this->doctrineAnnotationReader = $annotationReader;
@@ -349,7 +348,7 @@ public function createSchema(): Schema
349348

350349
$namespaceFactory = new NamespaceFactory($namespacedCache, $this->classNameMapper, $this->globTTL);
351350
$nsList = array_map(
352-
static fn(string $namespace) => $namespaceFactory->createNamespace($namespace),
351+
static fn (string $namespace) => $namespaceFactory->createNamespace($namespace),
353352
$this->typeNamespaces,
354353
);
355354

@@ -363,6 +362,7 @@ public function createSchema(): Schema
363362
// TODO: add a logger to the SchemaFactory and make use of it everywhere (and most particularly in SecurityFieldMiddleware)
364363
$fieldMiddlewarePipe->pipe(new SecurityFieldMiddleware($expressionLanguage, $authenticationService, $authorizationService));
365364
$fieldMiddlewarePipe->pipe(new AuthorizationFieldMiddleware($authenticationService, $authorizationService));
365+
$fieldMiddlewarePipe->pipe(new CostFieldMiddleware());
366366

367367
$inputFieldMiddlewarePipe = new InputFieldMiddlewarePipe();
368368
foreach ($this->inputFieldMiddlewares as $inputFieldMiddleware) {
@@ -390,7 +390,7 @@ public function createSchema(): Schema
390390
$rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $annotationReader, $symfonyCache, $nsList);
391391
}
392392

393-
if (!empty($this->rootTypeMapperFactories)) {
393+
if (! empty($this->rootTypeMapperFactories)) {
394394
$rootSchemaFactoryContext = new RootTypeMapperFactoryContext(
395395
$annotationReader,
396396
$typeResolver,
@@ -458,7 +458,7 @@ public function createSchema(): Schema
458458
));
459459
}
460460

461-
if (!empty($this->typeMapperFactories) || !empty($this->queryProviderFactories)) {
461+
if (! empty($this->typeMapperFactories) || ! empty($this->queryProviderFactories)) {
462462
$context = new FactoryContext(
463463
$annotationReader,
464464
$typeResolver,

tests/Fixtures/Integration/Controllers/ArticleController.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,27 @@
22

33
namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Controllers;
44

5+
use TheCodingMachine\GraphQLite\Annotations\Cost;
56
use TheCodingMachine\GraphQLite\Annotations\Mutation;
7+
use TheCodingMachine\GraphQLite\Annotations\Query;
68
use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Article;
9+
use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Contact;
10+
use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\User;
711

812
class ArticleController
913
{
14+
/**
15+
* @return Article[]
16+
*/
17+
#[Query]
18+
#[Cost(complexity: 5, multipliers: ['take'], defaultMultiplier: 500)]
19+
public function articles(?int $take = 10): array
20+
{
21+
return [
22+
new Article('Title'),
23+
];
24+
}
25+
1026

1127
/**
1228
* @Mutation()

0 commit comments

Comments
 (0)