diff --git a/benchmarks/TrustResultBench.php b/benchmarks/TrustResultBench.php new file mode 100644 index 000000000..ed388b03e --- /dev/null +++ b/benchmarks/TrustResultBench.php @@ -0,0 +1,294 @@ +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); + } +} diff --git a/docs/class-reference.md b/docs/class-reference.md index f6e627c43..88db5b8a9 100644 --- a/docs/class-reference.md +++ b/docs/class-reference.md @@ -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 @@ -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 ``` @@ -86,6 +94,12 @@ static function executeQuery( * @param mixed $context * @param array|null $variableValues * @param array|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 * @@ -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 ``` @@ -1590,7 +1605,7 @@ Implements the "Evaluating requests" section of the GraphQL specification. ```php @phpstan-type ArgsMapper callable(array, FieldDefinition, FieldNode, mixed): mixed @phpstan-type FieldResolver callable(mixed, array, mixed, ResolveInfo): mixed -@phpstan-type ImplementationFactory callable(PromiseAdapter, Schema, DocumentNode, mixed, mixed, array, ?string, callable, callable): ExecutorImplementation +@phpstan-type ImplementationFactory callable(PromiseAdapter, Schema, DocumentNode, mixed, mixed, array, ?string, callable, ?callable, bool): ExecutorImplementation ``` @see \GraphQL\Tests\Executor\ExecutorTest @@ -1621,7 +1636,8 @@ static function execute( $contextValue = null, ?array $variableValues = null, ?string $operationName = null, - ?callable $fieldResolver = null + ?callable $fieldResolver = null, + bool $trustResult = false ): GraphQL\Executor\ExecutionResult ``` @@ -1650,7 +1666,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 ``` diff --git a/src/Executor/ExecutionContext.php b/src/Executor/ExecutionContext.php index 4450b61d3..99867e3d3 100644 --- a/src/Executor/ExecutionContext.php +++ b/src/Executor/ExecutionContext.php @@ -54,6 +54,8 @@ class ExecutionContext public PromiseAdapter $promiseAdapter; + public bool $trustResult; + /** * @param array $fragments * @param mixed $rootValue @@ -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; @@ -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 diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index f7ce2bff1..2c76c645b 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -18,7 +18,7 @@ * * @phpstan-type ArgsMapper callable(array, FieldDefinition, FieldNode, mixed): mixed * @phpstan-type FieldResolver callable(mixed, array, mixed, ResolveInfo): mixed - * @phpstan-type ImplementationFactory callable(PromiseAdapter, Schema, DocumentNode, mixed, mixed, array, ?string, callable, callable): ExecutorImplementation + * @phpstan-type ImplementationFactory callable(PromiseAdapter, Schema, DocumentNode, mixed, mixed, array, ?string, callable, ?callable, bool): ExecutorImplementation * * @see \GraphQL\Tests\Executor\ExecutorTest */ @@ -125,7 +125,8 @@ public static function execute( $contextValue = null, ?array $variableValues = null, ?string $operationName = null, - ?callable $fieldResolver = null + ?callable $fieldResolver = null, + bool $trustResult = false ): ExecutionResult { $promiseAdapter = new SyncPromiseAdapter(); @@ -137,7 +138,9 @@ public static function execute( $contextValue, $variableValues, $operationName, - $fieldResolver + $fieldResolver, + null, + $trustResult ); return $promiseAdapter->wait($result); @@ -167,7 +170,8 @@ public static function promiseToExecute( ?array $variableValues = null, ?string $operationName = null, ?callable $fieldResolver = null, - ?callable $argsMapper = null + ?callable $argsMapper = null, + bool $trustResult = false ): Promise { $executor = (self::$implementationFactory)( $promiseAdapter, @@ -179,6 +183,7 @@ public static function promiseToExecute( $operationName, $fieldResolver ?? self::$defaultFieldResolver, $argsMapper ?? self::$defaultArgsMapper, + $trustResult, ); return $executor->doExecute(); diff --git a/src/Executor/ReferenceExecutor.php b/src/Executor/ReferenceExecutor.php index ecd3514f5..e3a2b8d0f 100644 --- a/src/Executor/ReferenceExecutor.php +++ b/src/Executor/ReferenceExecutor.php @@ -105,7 +105,8 @@ public static function create( array $variableValues, ?string $operationName, callable $fieldResolver, - ?callable $argsMapper = null // TODO make non-optional in next major release + ?callable $argsMapper = null, // TODO make non-optional in next major release + bool $trustResult = false ): ExecutorImplementation { $exeContext = static::buildExecutionContext( $schema, @@ -117,6 +118,7 @@ public static function create( $fieldResolver, $argsMapper ?? Executor::getDefaultArgsMapper(), $promiseAdapter, + $trustResult ); if (is_array($exeContext)) { @@ -152,7 +154,8 @@ protected static function buildExecutionContext( ?string $operationName, callable $fieldResolver, callable $argsMapper, - PromiseAdapter $promiseAdapter + PromiseAdapter $promiseAdapter, + bool $trustResult = false ) { /** @var list $errors */ $errors = []; @@ -229,7 +232,8 @@ protected static function buildExecutionContext( $errors, $fieldResolver, $argsMapper, - $promiseAdapter + $promiseAdapter, + $trustResult ); } @@ -884,7 +888,7 @@ protected function completeValue( $result, $contextValue ); - if ($completed === null) { + if ($completed === null && ! $this->exeContext->trustResult) { throw new InvariantViolation("Cannot return null for non-nullable field \"{$info->parentType}.{$info->fieldName}\"."); } @@ -1053,6 +1057,10 @@ protected function completeListValue( */ protected function completeLeafValue(LeafType $returnType, $result) { + if ($this->exeContext->trustResult) { + return $result; + } + try { return $returnType->serialize($result); } catch (\Throwable $error) { @@ -1225,36 +1233,38 @@ protected function completeObjectValue( // If there is an isTypeOf predicate function, call it with the // current result. If isTypeOf returns false, then raise an error rather // than continuing execution. - $isTypeOf = $returnType->isTypeOf($result, $contextValue, $info); - if ($isTypeOf !== null) { - $promise = $this->getPromise($isTypeOf); - if ($promise !== null) { - return $promise->then(function ($isTypeOfResult) use ( - $contextValue, - $returnType, - $fieldNodes, - $path, - $unaliasedPath, - $result - ) { - if (! $isTypeOfResult) { - throw $this->invalidReturnTypeError($returnType, $result, $fieldNodes); - } - - return $this->collectAndExecuteSubfields( + if (! $this->exeContext->trustResult) { + $isTypeOf = $returnType->isTypeOf($result, $contextValue, $info); + if ($isTypeOf !== null) { + $promise = $this->getPromise($isTypeOf); + if ($promise !== null) { + return $promise->then(function ($isTypeOfResult) use ( + $contextValue, $returnType, $fieldNodes, $path, $unaliasedPath, - $result, - $contextValue - ); - }); - } + $result + ) { + if (! $isTypeOfResult) { + throw $this->invalidReturnTypeError($returnType, $result, $fieldNodes); + } - assert(is_bool($isTypeOf), 'Promise would return early'); - if (! $isTypeOf) { - throw $this->invalidReturnTypeError($returnType, $result, $fieldNodes); + return $this->collectAndExecuteSubfields( + $returnType, + $fieldNodes, + $path, + $unaliasedPath, + $result, + $contextValue + ); + }); + } + + assert(is_bool($isTypeOf), 'Promise would return early'); + if (! $isTypeOf) { + throw $this->invalidReturnTypeError($returnType, $result, $fieldNodes); + } } } diff --git a/src/GraphQL.php b/src/GraphQL.php index b96d489ff..08f51a8b1 100644 --- a/src/GraphQL.php +++ b/src/GraphQL.php @@ -70,6 +70,13 @@ class GraphQL * 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 @@ -90,7 +97,8 @@ public static function executeQuery( ?array $variableValues = null, ?string $operationName = null, ?callable $fieldResolver = null, - ?array $validationRules = null + ?array $validationRules = null, + bool $trustResult = false ): ExecutionResult { $promiseAdapter = new SyncPromiseAdapter(); @@ -103,7 +111,8 @@ public static function executeQuery( $variableValues, $operationName, $fieldResolver, - $validationRules + $validationRules, + $trustResult ); return $promiseAdapter->wait($promise); @@ -118,6 +127,12 @@ public static function executeQuery( * @param mixed $context * @param array|null $variableValues * @param array|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 * @@ -132,7 +147,8 @@ public static function promiseToExecute( ?array $variableValues = null, ?string $operationName = null, ?callable $fieldResolver = null, - ?array $validationRules = null + ?array $validationRules = null, + bool $trustResult = false ): Promise { try { $documentNode = $source instanceof DocumentNode @@ -168,7 +184,9 @@ public static function promiseToExecute( $context, $variableValues, $operationName, - $fieldResolver + $fieldResolver, + null, + $trustResult ); } catch (Error $e) { return $promiseAdapter->createFulfilled( diff --git a/src/Server/Helper.php b/src/Server/Helper.php index c77e3af61..5485e3e5c 100644 --- a/src/Server/Helper.php +++ b/src/Server/Helper.php @@ -299,7 +299,8 @@ protected function promiseToExecuteOperation( $op->variables, $op->operation, $config->getFieldResolver(), - $this->resolveValidationRules($config, $op, $doc, $operationType) + $this->resolveValidationRules($config, $op, $doc, $operationType), + $config->getTrustResult() ); } catch (RequestError $e) { $result = $promiseAdapter->createFulfilled( diff --git a/src/Server/ServerConfig.php b/src/Server/ServerConfig.php index 08d7517ee..f7e8d7485 100644 --- a/src/Server/ServerConfig.php +++ b/src/Server/ServerConfig.php @@ -85,6 +85,9 @@ public static function create(array $config = []): self case 'promiseAdapter': $instance->setPromiseAdapter($value); break; + case 'trustResult': + $instance->setTrustResult($value); + break; default: throw new InvariantViolation("Unknown server config option: {$key}"); } @@ -135,6 +138,8 @@ public static function create(array $config = []): self private ?PromiseAdapter $promiseAdapter = null; + private bool $trustResult = false; + /** * @var callable|null * @@ -276,6 +281,14 @@ public function setPromiseAdapter(PromiseAdapter $promiseAdapter): self return $this; } + /** @api */ + public function setTrustResult(bool $trustResult): self + { + $this->trustResult = $trustResult; + + return $this; + } + /** @return mixed|callable */ public function getContext() { @@ -344,4 +357,9 @@ public function getQueryBatching(): bool { return $this->queryBatching; } + + public function getTrustResult(): bool + { + return $this->trustResult; + } } diff --git a/tests/Executor/ExecutorTest.php b/tests/Executor/ExecutorTest.php index 812c33d4a..219b6575f 100644 --- a/tests/Executor/ExecutorTest.php +++ b/tests/Executor/ExecutorTest.php @@ -8,6 +8,9 @@ use GraphQL\Error\FormattedError; use GraphQL\Error\UserError; use GraphQL\Executor\Executor; +use GraphQL\Executor\Promise\PromiseAdapter; +use GraphQL\Executor\ReferenceExecutor; +use GraphQL\Language\AST\DocumentNode; use GraphQL\Language\AST\OperationDefinitionNode; use GraphQL\Language\Parser; use GraphQL\Tests\Executor\TestClasses\NotSpecial; @@ -30,6 +33,9 @@ final class ExecutorTest extends TestCase public function tearDown(): void { Executor::setDefaultPromiseAdapter(); + Executor::setImplementationFactory([ReferenceExecutor::class, 'create']); + + parent::tearDown(); } // Execute: Handles basic execution tasks @@ -1362,4 +1368,53 @@ public function __get(string $name): ?int $result->toArray() ); } + + public function testCustomImplementationFactoryIgnoresExtraArguments(): void + { + $called = false; + + Executor::setImplementationFactory( + // Represents older userland implementations that don't know newly added args + static function ( + PromiseAdapter $promiseAdapter, + Schema $schema, + DocumentNode $documentNode, + $rootValue, + $contextValue, + array $variableValues, + ?string $operationName, + callable $fieldResolver + ) use (&$called) { + $called = true; + + return ReferenceExecutor::create( + $promiseAdapter, + $schema, + $documentNode, + $rootValue, + $contextValue, + $variableValues, + $operationName, + $fieldResolver + ); + } + ); + + $doc = '{ a }'; + $data = ['a' => 'b']; + $ast = Parser::parse($doc); + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Type', + 'fields' => [ + 'a' => Type::string(), + ], + ]), + ]); + + $executor = Executor::execute($schema, $ast, $data); + + self::assertTrue($called); + self::assertSame(['data' => $data], $executor->toArray()); + } } diff --git a/tests/Executor/TrustResultTest.php b/tests/Executor/TrustResultTest.php new file mode 100644 index 000000000..9cc48ee3d --- /dev/null +++ b/tests/Executor/TrustResultTest.php @@ -0,0 +1,136 @@ + new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'list' => [ + 'type' => Type::listOf(Type::string()), + 'resolve' => static fn (): string => 'not an iterable', + ], + ], + ]), + ]); + + $query = '{ list }'; + + // Without trustResult, it should have an error in the result + $result = Executor::execute($schema, Parser::parse($query)); + self::assertCount(1, $result->errors); + self::assertStringContainsString('Expected field Query.list to return iterable, but got: string.', $result->errors[0]->getMessage()); + + // With trustResult, it should NOT skip the InvariantViolation error since it's required for type safety + $result = Executor::execute($schema, Parser::parse($query), null, null, null, null, null, true); + self::assertCount(1, $result->errors); + self::assertStringContainsString('Expected field Query.list to return iterable, but got: string.', $result->errors[0]->getMessage()); + } + + public function testTrustResultSkipsLeafSerialization(): void + { + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'scalar' => [ + 'type' => Type::int(), + 'resolve' => static fn (): string => '123', // should be int, but we trust it + ], + ], + ]), + ]); + + $query = '{ scalar }'; + + // Without trustResult, it returns 123 (int) because Type::int() serializes '123' to 123 + $result = Executor::execute($schema, Parser::parse($query)); + self::assertIsArray($result->data); + self::assertSame(123, $result->data['scalar']); + + // With trustResult, it should return '123' (string) directly without serialization + $result = Executor::execute($schema, Parser::parse($query), null, null, null, null, null, true); + self::assertIsArray($result->data); + self::assertSame('123', $result->data['scalar']); + } + + public function testTrustResultSkipsIsTypeOfValidation(): void + { + $someType = new ObjectType([ + 'name' => 'SomeType', + 'fields' => [ + 'foo' => [ + 'type' => Type::string(), + 'resolve' => static fn ($root) => $root['foo'] ?? null, + ], + ], + 'isTypeOf' => static fn ($value): bool => is_array($value) && isset($value['valid']) && $value['valid'] === true, + ]); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'obj' => [ + 'type' => $someType, + 'resolve' => static fn (): array => ['foo' => 'bar', 'valid' => false], + ], + ], + ]), + ]); + + $query = '{ obj { foo } }'; + + // Without trustResult, it should have an error + $result = Executor::execute($schema, Parser::parse($query)); + self::assertCount(1, $result->errors); + self::assertStringContainsString('Expected value of type "SomeType" but got:', $result->errors[0]->getMessage()); + + // With trustResult, it should skip isTypeOf check + $result = Executor::execute($schema, Parser::parse($query), null, null, null, null, null, true); + self::assertCount(0, $result->errors); + self::assertIsArray($result->data); + self::assertIsArray($result->data['obj']); + self::assertSame('bar', $result->data['obj']['foo']); + } + + public function testTrustResultSkipsNonNullValidation(): void + { + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'nonNull' => [ + 'type' => Type::nonNull(Type::string()), + 'resolve' => static fn (): ?string => null, + ], + ], + ]), + ]); + + $query = '{ nonNull }'; + + // Without trustResult, it should have an error + $result = Executor::execute($schema, Parser::parse($query)); + self::assertCount(1, $result->errors); + self::assertStringContainsString('Cannot return null for non-nullable field "Query.nonNull".', $result->errors[0]->getMessage()); + + // With trustResult, it returns null without error + $result = Executor::execute($schema, Parser::parse($query), null, null, null, null, null, true); + self::assertCount(0, $result->errors); + self::assertIsArray($result->data); + self::assertNull($result->data['nonNull']); + } +} diff --git a/tests/GraphQLTest.php b/tests/GraphQLTest.php index 04c869f99..54dc700a7 100644 --- a/tests/GraphQLTest.php +++ b/tests/GraphQLTest.php @@ -38,4 +38,30 @@ public function testPromiseToExecute(): void $result = $promiseAdapter->wait($promise); self::assertSame(['data' => ['sayHi' => 'Hi John!']], $result->toArray()); } + + public function testTrustResultFlagPropagatesViaExecuteQuery(): void + { + $schema = new Schema( + (new SchemaConfig()) + ->setQuery(new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'scalarNumber' => [ + 'type' => Type::int(), + 'resolve' => static fn (): string => '123', // should be int, but we trust it + ], + ], + ])) + ); + + $query = '{ scalarNumber }'; + + // Without trustResult, it returns 123 (int) because Type::int() serializes '123' to 123 + $result = GraphQL::executeQuery($schema, $query); + self::assertSame(['data' => ['scalarNumber' => 123]], $result->toArray()); + + // With trustResult, it should return '123' (string) directly without serialization + $result = GraphQL::executeQuery($schema, $query, null, null, null, null, null, null, true); + self::assertSame(['data' => ['scalarNumber' => '123']], $result->toArray()); + } }