Skip to content
Open
Show file tree
Hide file tree
Changes from 14 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
294 changes: 294 additions & 0 deletions benchmarks/TrustResultBench.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
<?php declare(strict_types=1);

namespace GraphQL\Benchmarks;

use GraphQL\GraphQL;
use GraphQL\Language\AST\DocumentNode;
use GraphQL\Language\Parser;
use GraphQL\Language\Source;
use GraphQL\Type\Definition\CustomScalarType;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;

/**
* Benchmarks comparing query execution performance with and without $trustResult.
*
* $trustResult=true skips per-field/per-object:
* - Leaf value serialization (scalar type conversions)
* - isTypeOf validation checks
*
* Scenarios:
* 1. Built-in scalars at scale — 500 items × 8 fields = 4 000 serialize() calls
* 2. Custom scalar with non-trivial serialization — shows benefit when serialize() does real work
* 3. isTypeOf validation — 100 objects each triggering an isTypeOf() callback
* 4. Realistic combined — scalars + nested objects in a typical "list page" query
*
* @BeforeMethods({"setUp"})
*
* @OutputTimeUnit("milliseconds", precision=3)
*
* @Warmup(5)
*
* @Revs(200)
*
* @Iterations(10)
*/
class TrustResultBench
{
private Schema $builtinScalarSchema;

private DocumentNode $builtinScalarQuery;

private Schema $customScalarSchema;

private DocumentNode $customScalarQuery;

private Schema $typeOfSchema;

private DocumentNode $typeOfQuery;

private Schema $combinedSchema;

private DocumentNode $combinedQuery;

public function setUp(): void
{
$this->setupBuiltinScalarScenario();
$this->setupCustomScalarScenario();
$this->setupTypeOfScenario();
$this->setupCombinedScenario();
}

/**
* Scenario 1: 500 items × 8 built-in scalar fields = 4 000 serialize() calls.
* Built-in serialize() is cheap (type coercion), so gains here are modest.
*/
private function setupBuiltinScalarScenario(): void
{
$items = array_fill(0, 500, [
'id' => 1,
'name' => 'Widget',
'price' => 9.99,
'active' => true,
'stock' => 42,
'rating' => 4.5,
'views' => 1337,
'score' => 99,
]);

$productType = new ObjectType([
'name' => 'Product',
'fields' => [
'id' => ['type' => Type::int()],
'name' => ['type' => Type::string()],
'price' => ['type' => Type::float()],
'active' => ['type' => Type::boolean()],
'stock' => ['type' => Type::int()],
'rating' => ['type' => Type::float()],
'views' => ['type' => Type::int()],
'score' => ['type' => Type::int()],
],
]);

$this->builtinScalarSchema = new Schema([
'query' => new ObjectType([
'name' => 'Query',
'fields' => [
'products' => [
'type' => Type::listOf($productType),
'resolve' => static fn (): array => $items,
],
],
]),
]);

$this->builtinScalarQuery = Parser::parse(new Source(
'{ products { id name price active stock rating views score } }'
));
}

public function benchBuiltinScalarFields(): void
{
GraphQL::executeQuery($this->builtinScalarSchema, $this->builtinScalarQuery);
}

public function benchBuiltinScalarFieldsTrusted(): void
{
GraphQL::executeQuery($this->builtinScalarSchema, $this->builtinScalarQuery, null, null, null, null, null, null, true);
}

/**
* Scenario 2: 100 items × 5 custom scalar fields. The custom serialize() does real work
* (slug normalisation + validation), making the per-field cost measurable.
*/
private function setupCustomScalarScenario(): void
{
$items = array_fill(0, 100, [
'code' => 'WIDGET-001',
'slug' => 'my-product-slug',
'ref' => 'REF-ABC-123',
'tag' => 'electronics/gadgets',
'sku' => 'SKU-XYZ-999',
]);

// A scalar that lowercases and strips non-alphanumeric characters on serialize.
$slugScalar = new CustomScalarType([
'name' => 'Slug',
'serialize' => static fn ($value): string => strtolower(
preg_replace('/[^a-z0-9\-]/i', '-', (string) $value) ?? (string) $value
),
'parseValue' => static fn ($value): string => (string) $value,
'parseLiteral' => static fn ($ast): string => $ast->value,
]);

$itemType = new ObjectType([
'name' => 'Item',
'fields' => [
'code' => ['type' => $slugScalar],
'slug' => ['type' => $slugScalar],
'ref' => ['type' => $slugScalar],
'tag' => ['type' => $slugScalar],
'sku' => ['type' => $slugScalar],
],
]);

$this->customScalarSchema = new Schema([
'query' => new ObjectType([
'name' => 'Query',
'fields' => [
'items' => [
'type' => Type::listOf($itemType),
'resolve' => static fn (): array => $items,
],
],
]),
]);

$this->customScalarQuery = Parser::parse(new Source(
'{ items { code slug ref tag sku } }'
));
}

public function benchCustomScalarFields(): void
{
GraphQL::executeQuery($this->customScalarSchema, $this->customScalarQuery);
}

public function benchCustomScalarFieldsTrusted(): void
{
GraphQL::executeQuery($this->customScalarSchema, $this->customScalarQuery, null, null, null, null, null, null, true);
}

/**
* Scenario 3: 100 objects each triggering an isTypeOf() callback.
* trustResult skips all isTypeOf checks.
*/
private function setupTypeOfScenario(): void
{
$users = array_fill(0, 100, [
'__typename' => 'User',
'id' => 1,
'name' => 'Alice',
'email' => 'alice@example.com',
]);

$userType = new ObjectType([
'name' => 'User',
'fields' => [
'id' => ['type' => Type::int()],
'name' => ['type' => Type::string()],
'email' => ['type' => Type::string()],
],
'isTypeOf' => static fn ($value): bool => is_array($value)
&& ($value['__typename'] ?? null) === 'User',
]);

$this->typeOfSchema = new Schema([
'query' => new ObjectType([
'name' => 'Query',
'fields' => [
'users' => [
'type' => Type::listOf($userType),
'resolve' => static fn (): array => $users,
],
],
]),
]);

$this->typeOfQuery = Parser::parse(new Source('{ users { id name email } }'));
}

public function benchIsTypeOf(): void
{
GraphQL::executeQuery($this->typeOfSchema, $this->typeOfQuery);
}

public function benchIsTypeOfTrusted(): void
{
GraphQL::executeQuery($this->typeOfSchema, $this->typeOfQuery, null, null, null, null, null, null, true);
}

/**
* Scenario 4: 30 orders with a nested customer object and 5 scalar fields each.
* Combines scalar serialization (30 × 8 = 240 calls) and nested object resolution.
*/
private function setupCombinedScenario(): void
{
$orders = array_fill(0, 30, [
'id' => 1,
'status' => 'shipped',
'total' => 99.99,
'quantity' => 3,
'note' => 'fragile',
'customer' => ['id' => 42, 'name' => 'Bob', 'tier' => 'gold'],
]);

$customerType = new ObjectType([
'name' => 'Customer',
'fields' => [
'id' => ['type' => Type::int()],
'name' => ['type' => Type::string()],
'tier' => ['type' => Type::string()],
],
]);

$orderType = new ObjectType([
'name' => 'Order',
'fields' => [
'id' => ['type' => Type::int()],
'status' => ['type' => Type::string()],
'total' => ['type' => Type::float()],
'quantity' => ['type' => Type::int()],
'note' => ['type' => Type::string()],
'customer' => ['type' => $customerType],
],
]);

$this->combinedSchema = new Schema([
'query' => new ObjectType([
'name' => 'Query',
'fields' => [
'orders' => [
'type' => Type::listOf($orderType),
'resolve' => static fn (): array => $orders,
],
],
]),
]);

$this->combinedQuery = Parser::parse(new Source(
'{ orders { id status total quantity note customer { id name tier } } }'
));
}

public function benchCombinedQuery(): void
{
GraphQL::executeQuery($this->combinedSchema, $this->combinedQuery);
}

public function benchCombinedQueryTrusted(): void
{
GraphQL::executeQuery($this->combinedSchema, $this->combinedQuery, null, null, null, null, null, null, true);
}
}
27 changes: 22 additions & 5 deletions docs/class-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ See [related documentation](executing-queries.md).
* A set of rules for query validation step. Default value is all available rules.
* Empty array would allow to skip query validation (may be convenient for persisted
* queries which are validated before persisting and assumed valid during execution)
* trustResult:
* When true, assumes resolver results are already correctly typed and serialized
* and skips normal type and serialization checks. This is purely for
* performance optimization. The tradeoff is potentially corrupted results for the client
* if resolvers return malformed data. Only enable this when you are confident
* that your resolvers are safely and correctly returning data conforming to
* the schema.
*
* @param string|DocumentNode $source
* @param mixed $rootValue
Expand All @@ -72,7 +79,8 @@ static function executeQuery(
?array $variableValues = null,
?string $operationName = null,
?callable $fieldResolver = null,
?array $validationRules = null
?array $validationRules = null,
bool $trustResult = false
): GraphQL\Executor\ExecutionResult
```

Expand All @@ -86,6 +94,12 @@ static function executeQuery(
* @param mixed $context
* @param array<string, mixed>|null $variableValues
* @param array<ValidationRule>|null $validationRules Defaults to using all available rules
* @param bool $trustResult When true, assumes resolver results are already correctly typed
* and serialized and skips normal type and serialization checks.
* This is purely for performance optimization. The tradeoff is
* potentially corrupted results for the client if resolvers return
* malformed data. Only enable this when you are confident that
* your resolvers correctly return data conforming to the schema.
*
* @api
*
Expand All @@ -100,7 +114,8 @@ static function promiseToExecute(
?array $variableValues = null,
?string $operationName = null,
?callable $fieldResolver = null,
?array $validationRules = null
?array $validationRules = null,
bool $trustResult = false
): GraphQL\Executor\Promise\Promise
```

Expand Down Expand Up @@ -1612,7 +1627,7 @@ Implements the "Evaluating requests" section of the GraphQL specification.
```php
@phpstan-type ArgsMapper callable(array<string, mixed>, FieldDefinition, FieldNode, mixed): mixed
@phpstan-type FieldResolver callable(mixed, array<string, mixed>, mixed, ResolveInfo): mixed
@phpstan-type ImplementationFactory callable(PromiseAdapter, Schema, DocumentNode, mixed, mixed, array<mixed>, ?string, callable, callable): ExecutorImplementation
@phpstan-type ImplementationFactory callable(PromiseAdapter, Schema, DocumentNode, mixed, mixed, array<mixed>, ?string, callable, ?callable, bool): ExecutorImplementation
```

@see \GraphQL\Tests\Executor\ExecutorTest
Expand Down Expand Up @@ -1643,7 +1658,8 @@ static function execute(
$contextValue = null,
?array $variableValues = null,
?string $operationName = null,
?callable $fieldResolver = null
?callable $fieldResolver = null,
bool $trustResult = false
): GraphQL\Executor\ExecutionResult
```

Expand Down Expand Up @@ -1672,7 +1688,8 @@ static function promiseToExecute(
?array $variableValues = null,
?string $operationName = null,
?callable $fieldResolver = null,
?callable $argsMapper = null
?callable $argsMapper = null,
bool $trustResult = false
): GraphQL\Executor\Promise\Promise
```

Expand Down
6 changes: 5 additions & 1 deletion src/Executor/ExecutionContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ class ExecutionContext

public PromiseAdapter $promiseAdapter;

public bool $trustResult;

/**
* @param array<string, FragmentDefinitionNode> $fragments
* @param mixed $rootValue
Expand All @@ -73,7 +75,8 @@ public function __construct(
array $errors,
callable $fieldResolver,
callable $argsMapper,
PromiseAdapter $promiseAdapter
PromiseAdapter $promiseAdapter,
bool $trustResult = false
) {
$this->schema = $schema;
$this->fragments = $fragments;
Expand All @@ -85,6 +88,7 @@ public function __construct(
$this->fieldResolver = $fieldResolver;
$this->argsMapper = $argsMapper;
$this->promiseAdapter = $promiseAdapter;
$this->trustResult = $trustResult;
}

public function addError(Error $error): void
Expand Down
Loading
Loading