From 34ef7b542545a5e90b5197e65464a62ef623c6d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Moitie=CC=81?= Date: Thu, 11 Jun 2026 12:18:57 +0100 Subject: [PATCH 01/36] Allow setting per-schema scalar overrides explicitly via SchemaConfig When scalar overrides are not set explicitly, they are discovered by scanning the types config. Since the executor resolves built-in scalars through Schema::getType() on essentially every operation, the first such lookup fully resolves a lazily provided types callable - silently making the documented lazy schema pattern (typeLoader + lazy types) eager for every schema, including those that define no overrides at all. Passing scalar overrides explicitly (including an empty array for none) through the new scalarOverrides option skips the scan entirely, keeping lazy type loading lazy. The default behaviour of discovering overrides by scanning types is unchanged. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 + docs/class-reference.md | 21 +++++ docs/schema-definition.md | 1 + docs/type-definitions/scalars.md | 15 +++- src/Type/Schema.php | 7 ++ src/Type/SchemaConfig.php | 64 +++++++++++++++ tests/Type/ScalarOverridesTest.php | 124 +++++++++++++++++++++++++++++ 7 files changed, 235 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f55a42c31..32379eb20 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 + +- Allow setting per-schema built-in scalar overrides explicitly via `SchemaConfig` option `scalarOverrides`, avoiding the resolution of lazily provided `types` on built-in scalar lookups https://github.com/webonyx/graphql-php/pull/1927 + ## v15.32.3 ### Fixed diff --git a/docs/class-reference.md b/docs/class-reference.md index fb0cf6bd1..81114cd8a 100644 --- a/docs/class-reference.md +++ b/docs/class-reference.md @@ -805,6 +805,7 @@ Usage example: mutation?: MaybeLazyObjectType, subscription?: MaybeLazyObjectType, types?: Types|null, + scalarOverrides?: array|null, directives?: array|null, typeLoader?: TypeLoader|null, assumeValid?: bool|null, @@ -911,6 +912,26 @@ function getTypes() function setTypes($types): self ``` +```php +/** + * @return array|null + * + * @api + */ +function getScalarOverrides(): ?array +``` + +```php +/** + * @param array|null $scalarOverrides + * + * @throws InvariantViolation + * + * @api + */ +function setScalarOverrides(?array $scalarOverrides): self +``` + ```php /** * @return array|null diff --git a/docs/schema-definition.md b/docs/schema-definition.md index 5c0ffe1d9..06ea1f14b 100644 --- a/docs/schema-definition.md +++ b/docs/schema-definition.md @@ -85,6 +85,7 @@ or an array with the following options: | subscription | `ObjectType` or `callable(): ?ObjectType` or `null` | Reserved for future subscriptions implementation. Currently presented for compatibility with introspection query of **graphql-js**, used by various clients (like Relay or GraphiQL) | | directives | `array` | A full list of [directives](type-definitions/directives.md) supported by your schema. By default, contains built-in **@skip** and **@include** directives.

If you pass your own directives and still want to use built-in directives - add them explicitly. For example:

_array_merge(GraphQL::getStandardDirectives(), [$myCustomDirective]);_ | | types | `iterable` | Additional types to register in the schema.

Use this for object types that are not directly referenced in fields but implement an interface that resolves to them via **resolveType**.

Can also contain custom scalar types named like built-in scalars (`String`, `Int`, etc.) to [override them](type-definitions/scalars.md#overriding-built-in-scalars) on a per-schema basis. | +| scalarOverrides | `array` or `null` | Custom scalar types named like built-in scalars (`String`, `Int`, etc.) that [override them](type-definitions/scalars.md#overriding-built-in-scalars) on a per-schema basis.

When `null` (default), overrides are discovered by scanning **types**, which fully resolves it even when given as a lazy callable. Pass overrides explicitly (or an empty array for none) to skip the scan. | | typeLoader | `callable(string $name): Type` | Expected to return a type instance given the name. Must always return the same instance if called multiple times, see [lazy loading](#lazy-loading-of-types). See section below on lazy type loading. | ### Using config class diff --git a/docs/type-definitions/scalars.md b/docs/type-definitions/scalars.md index 09cc82a35..6086605bb 100644 --- a/docs/type-definitions/scalars.md +++ b/docs/type-definitions/scalars.md @@ -132,4 +132,17 @@ $schema = new Schema([ The custom scalar replaces the built-in one throughout the entire schema, affecting both serialization of results and coercion of inputs. > **Note:** The `typeLoader` is never called for built-in scalar names. -> Always use `types` to override them. +> Always use `types` or `scalarOverrides` to override them. + +Discovering overrides requires scanning `types`. +When `types` is a lazy callable, the scan resolves it on the first lookup of a built-in scalar - typically the first executed query. +To avoid this in schemas that use [lazy loading of types](../schema-definition.md#lazy-loading-of-types), pass overrides explicitly through the `scalarOverrides` option instead - or pass an empty array to declare there are none: + +```php +$schema = new Schema([ + 'query' => $queryType, + 'typeLoader' => $myTypeLoader, + 'types' => $myLazyTypes, + 'scalarOverrides' => [$uppercaseString], // or [] for none +]); +``` diff --git a/src/Type/Schema.php b/src/Type/Schema.php index af38cf5fb..da186022c 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -373,6 +373,13 @@ private function loadType(string $typeName): ?Type private function getScalarOverrides(): array { if ($this->scalarOverrides === null) { + // When overrides are given explicitly, the scan of types is unnecessary. + // This keeps a lazily provided types callable unresolved, see https://github.com/webonyx/graphql-php/issues/1874. + $explicitScalarOverrides = $this->config->scalarOverrides; + if ($explicitScalarOverrides !== null) { + return $this->scalarOverrides = $explicitScalarOverrides; + } + $this->scalarOverrides = []; $builtInScalars = Type::builtInScalars(); diff --git a/src/Type/SchemaConfig.php b/src/Type/SchemaConfig.php index abf82dd4f..3b925cbe8 100644 --- a/src/Type/SchemaConfig.php +++ b/src/Type/SchemaConfig.php @@ -8,7 +8,9 @@ use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\NamedType; use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Definition\ScalarType; use GraphQL\Type\Definition\Type; +use GraphQL\Utils\Utils; /** * Configuration options for schema construction. @@ -35,6 +37,7 @@ * mutation?: MaybeLazyObjectType, * subscription?: MaybeLazyObjectType, * types?: Types|null, + * scalarOverrides?: array|null, * directives?: array|null, * typeLoader?: TypeLoader|null, * assumeValid?: bool|null, @@ -62,6 +65,18 @@ class SchemaConfig */ public $types = []; + /** + * Replacements for built-in scalar types, keyed by their name. + * + * When null, they are discovered by scanning **types**, which requires + * resolving it fully even when it is given as a lazy callable. + * Pass replacement scalars explicitly (or an empty array for none) + * to skip the scan and keep lazy type loading lazy. + * + * @var array|null + */ + public ?array $scalarOverrides = null; + /** @var array|null */ public ?array $directives = null; @@ -113,6 +128,10 @@ public static function create(array $options = []): self $config->setTypes($options['types']); } + if (isset($options['scalarOverrides'])) { + $config->setScalarOverrides($options['scalarOverrides']); + } + if (isset($options['directives'])) { $config->setDirectives($options['directives']); } @@ -252,6 +271,51 @@ public function setTypes($types): self return $this; } + /** + * @return array|null + * + * @api + */ + public function getScalarOverrides(): ?array + { + return $this->scalarOverrides; + } + + /** + * @param array|null $scalarOverrides + * + * @throws InvariantViolation + * + * @api + */ + public function setScalarOverrides(?array $scalarOverrides): self + { + if ($scalarOverrides === null) { + $this->scalarOverrides = null; + + return $this; + } + + $this->scalarOverrides = []; + foreach ($scalarOverrides as $scalarOverride) { + // @phpstan-ignore-next-line not strictly enforceable unless PHP gets generics + if (! $scalarOverride instanceof ScalarType) { + $scalarTypeClass = ScalarType::class; + $notScalarType = Utils::printSafe($scalarOverride); + throw new InvariantViolation("Expected instanceof {$scalarTypeClass}, got: {$notScalarType}."); + } + + if (! Type::isBuiltInScalarName($scalarOverride->name)) { + $builtInScalarNames = implode(', ', Type::BUILT_IN_SCALAR_NAMES); + throw new InvariantViolation("Expected scalar override to be named after a built-in scalar ({$builtInScalarNames}), got: {$scalarOverride->name}."); + } + + $this->scalarOverrides[$scalarOverride->name] = $scalarOverride; + } + + return $this; + } + /** * @return array|null * diff --git a/tests/Type/ScalarOverridesTest.php b/tests/Type/ScalarOverridesTest.php index e72e50228..07b414180 100644 --- a/tests/Type/ScalarOverridesTest.php +++ b/tests/Type/ScalarOverridesTest.php @@ -568,6 +568,130 @@ public function testGetTypeThenAssertValidBothWorkWithTypeLoader(): void self::assertSame(['data' => ['greeting' => 'HELLO WORLD', 'user' => ['name' => 'JANE']]], $result->toArray()); } + /** @see https://github.com/webonyx/graphql-php/issues/1874 */ + public function testExplicitScalarOverridesKeepLazyTypesUnresolved(): void + { + $queryType = self::createQueryType(); + $typesCalled = 0; + + $schema = new Schema([ + 'query' => $queryType, + 'typeLoader' => static fn (string $name): ?Type => $name === 'Query' ? $queryType : null, + 'types' => static function () use (&$typesCalled): array { + ++$typesCalled; + + return []; + }, + 'scalarOverrides' => [], + ]); + + self::assertSame(Type::boolean(), $schema->getType(Type::BOOLEAN)); + self::assertSame(Type::string(), $schema->getType(Type::STRING)); + + $result = GraphQL::executeQuery($schema, '{ greeting }'); + + self::assertSame(['data' => ['greeting' => 'hello world']], $result->toArray()); + self::assertSame(0, $typesCalled, 'Expected built-in scalar lookups and query execution to not resolve the lazy types callable'); + } + + public function testExplicitScalarOverridesAreUsedWithoutResolvingLazyTypes(): void + { + $uppercaseString = self::createUppercaseString(); + $queryType = self::createQueryType(); + $typesCalled = 0; + + $schema = new Schema([ + 'query' => $queryType, + 'typeLoader' => static fn (string $name): ?Type => $name === 'Query' ? $queryType : null, + 'types' => static function () use (&$typesCalled): array { + ++$typesCalled; + + return []; + }, + 'scalarOverrides' => [$uppercaseString], + ]); + + self::assertSame($uppercaseString, $schema->getType(Type::STRING)); + + $result = GraphQL::executeQuery($schema, '{ greeting }'); + + self::assertSame(['data' => ['greeting' => 'HELLO WORLD']], $result->toArray()); + self::assertSame(0, $typesCalled, 'Expected explicit scalar overrides to not resolve the lazy types callable'); + } + + public function testExplicitScalarOverridesWorkViaSchemaConfigSetter(): void + { + $uppercaseString = self::createUppercaseString(); + + $config = SchemaConfig::create() + ->setQuery(self::createQueryType()) + ->setScalarOverrides([$uppercaseString]); + + self::assertSame([Type::STRING => $uppercaseString], $config->getScalarOverrides()); + + $schema = new Schema($config); + + $schema->assertValid(); + + $result = GraphQL::executeQuery($schema, '{ greeting }'); + + self::assertSame(['data' => ['greeting' => 'HELLO WORLD']], $result->toArray()); + } + + public function testExplicitScalarOverridesApplyInTypeMap(): void + { + $uppercaseString = self::createUppercaseString(); + + $schema = new Schema([ + 'query' => self::createQueryType(), + 'scalarOverrides' => [$uppercaseString], + ]); + + self::assertSame($uppercaseString, $schema->getTypeMap()[Type::STRING]); + } + + public function testTypeMapStillResolvesLazyTypesWithExplicitScalarOverrides(): void + { + $extraType = new ObjectType([ + 'name' => 'Extra', + 'fields' => [ + 'id' => Type::id(), + ], + ]); + $queryType = self::createQueryType(); + + $schema = new Schema([ + 'query' => $queryType, + 'typeLoader' => static fn (string $name): ?Type => $name === 'Query' ? $queryType : null, + 'types' => static fn (): array => [$extraType], + 'scalarOverrides' => [], + ]); + + self::assertSame($extraType, $schema->getTypeMap()['Extra']); + } + + public function testSetScalarOverridesRejectsNonScalarTypes(): void + { + $config = new SchemaConfig(); + + $this->expectException(InvariantViolation::class); + // @phpstan-ignore-next-line intentionally wrong + $config->setScalarOverrides([self::createQueryType()]); + } + + public function testSetScalarOverridesRejectsNonBuiltInScalarNames(): void + { + $customScalar = new CustomScalarType([ + 'name' => 'DateTime', + 'serialize' => static fn ($value): string => (string) $value, + ]); + + $config = new SchemaConfig(); + + $this->expectException(InvariantViolation::class); + $config->setScalarOverrides([$customScalar]); + } + /** @see https://github.com/webonyx/graphql-php/issues/1874 */ public function testTypeLoaderIsNotCalledForBuiltInScalarNames(): void { From 6a4e016638cc66fb492ca4fe55ea6dfa4be5cf20 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:13:15 +0000 Subject: [PATCH 02/36] Update dependency phpstan/phpstan-phpunit to v2.0.18 (#1942) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c646329b2..ef132f924 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "2.2.4", - "phpstan/phpstan-phpunit": "2.0.17", + "phpstan/phpstan-phpunit": "2.0.18", "phpstan/phpstan-strict-rules": "2.0.11", "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", "psr/http-message": "^1 || ^2", From 8fb0c131d23dbef8c5434edbc42381d28bb5cf38 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:36:53 +0000 Subject: [PATCH 03/36] Update dependency phpstan/phpstan to v2.2.5 (#1943) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ef132f924..d8916fb94 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "2.2.4", + "phpstan/phpstan": "2.2.5", "phpstan/phpstan-phpunit": "2.0.18", "phpstan/phpstan-strict-rules": "2.0.11", "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", From e7ee6adb8e49fdd77a21d2080589de062c0712bd Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Sun, 5 Jul 2026 15:27:26 +0300 Subject: [PATCH 04/36] feat: Make promises generic over adopted type --- docs/class-reference.md | 18 ++++++++- phpstan.neon.dist | 16 ++++++++ .../Promise/Adapter/AmpFutureAdapter.php | 25 ++++++++++-- .../Promise/Adapter/AmpPromiseAdapter.php | 20 ++++++++-- .../Promise/Adapter/ReactPromiseAdapter.php | 38 ++++++++++++++++--- .../Promise/Adapter/SyncPromiseAdapter.php | 11 +++++- src/Executor/Promise/Promise.php | 23 ++++++++--- src/Executor/Promise/PromiseAdapter.php | 18 ++++++++- .../Executor/Promise/AmpFutureAdapterTest.php | 28 +++----------- .../Promise/AmpPromiseAdapterTest.php | 5 --- .../Promise/SyncPromiseAdapterTest.php | 16 +++----- 11 files changed, 154 insertions(+), 64 deletions(-) diff --git a/docs/class-reference.md b/docs/class-reference.md index fb0cf6bd1..ebdba9ebc 100644 --- a/docs/class-reference.md +++ b/docs/class-reference.md @@ -1805,6 +1805,8 @@ function toArray(int $debug = 'GraphQL\\Error\\DebugFlag::NONE'): array Provides a means for integration of async PHP platforms ([related docs](data-fetching.md#async-php)). +@template TAdopted = mixed + ### GraphQL\Executor\Promise\PromiseAdapter Methods ```php @@ -1824,6 +1826,8 @@ function isThenable($value): bool * * @param mixed $thenable * + * @phpstan-return Promise + * * @api */ function convertThenable($thenable): GraphQL\Executor\Promise\Promise @@ -1834,6 +1838,10 @@ function convertThenable($thenable): GraphQL\Executor\Promise\Promise * Accepts our Promise wrapper, extracts adopted promise out of it and executes actual `then` logic described * in Promises/A+ specs. Then returns new wrapped instance of GraphQL\Executor\Promise\Promise. * + * @phpstan-param Promise $promise + * + * @phpstan-return Promise + * * @api */ function then( @@ -1849,6 +1857,8 @@ function then( * * @param callable(callable $resolve, callable $reject): void $resolver * + * @phpstan-return Promise + * * @api */ function create(callable $resolver): GraphQL\Executor\Promise\Promise @@ -1860,6 +1870,8 @@ function create(callable $resolver): GraphQL\Executor\Promise\Promise * * @param mixed $value * + * @phpstan-return Promise + * * @api */ function createFulfilled($value = null): GraphQL\Executor\Promise\Promise @@ -1867,9 +1879,9 @@ function createFulfilled($value = null): GraphQL\Executor\Promise\Promise ```php /** - * Creates a rejected promise for a reason if the reason is not a promise. + * Return a promise rejected with the given reason. * - * If the provided reason is a promise, then it is returned as-is. + * @phpstan-return Promise * * @api */ @@ -1883,6 +1895,8 @@ function createRejected(Throwable $reason): GraphQL\Executor\Promise\Promise * * @param iterable $promisesOrValues * + * @phpstan-return Promise + * * @api */ function all(iterable $promisesOrValues): GraphQL\Executor\Promise\Promise diff --git a/phpstan.neon.dist b/phpstan.neon.dist index bb358463a..ec13cceaf 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -61,6 +61,22 @@ parameters: - src/Executor/Promise - examples/04-async-php + # The v2 AmpPromiseAdapter wraps concrete Amp\Success/Amp\Failure; against the + # invariant Amp\Promise binding PHPStan reports a return mismatch (as + # Amp\Success on a v2 install, or bare Amp\Success when v2 is absent on a + # v3 install). Tolerate either form / either state. + - message: "~Method GraphQL\\\\Executor\\\\Promise\\\\Adapter\\\\AmpPromiseAdapter::create(Fulfilled|Rejected)\\(\\) should return GraphQL\\\\Executor\\\\Promise\\\\Promise> but returns GraphQL\\\\Executor\\\\Promise\\\\Promise<(Amp\\\\Promise\\|)?Amp\\\\(Success|Failure)()?(\\|Amp\\\\Promise)?>~" + reportUnmatched: false + path: src/Executor/Promise/Adapter/AmpPromiseAdapter.php + + # AmpFutureAdapter::createFulfilled()/createRejected() build a concrete Amp\Future + # whose generic arg PHPStan infers narrower than the invariant Amp\Future + # binding. Only reported when amphp/amp v3 is installed (absent under v2), so + # tolerate either state. + - message: "~Method GraphQL\\\\Executor\\\\Promise\\\\Adapter\\\\AmpFutureAdapter::create(Fulfilled|Rejected)\\(\\) should return GraphQL\\\\Executor\\\\Promise\\\\Promise> but returns GraphQL\\\\Executor\\\\Promise\\\\Promise)?>~" + reportUnmatched: false + path: src/Executor/Promise/Adapter/AmpFutureAdapter.php + includes: - phpstan-baseline.neon - phpstan/include-by-php-version.php diff --git a/src/Executor/Promise/Adapter/AmpFutureAdapter.php b/src/Executor/Promise/Adapter/AmpFutureAdapter.php index 556ceae48..0a75e7cce 100644 --- a/src/Executor/Promise/Adapter/AmpFutureAdapter.php +++ b/src/Executor/Promise/Adapter/AmpFutureAdapter.php @@ -15,6 +15,8 @@ * Allows integration with amphp/amp v3 (fiber-based futures). * * @see https://amphp.org/amp + * + * @implements PromiseAdapter> */ class AmpFutureAdapter implements PromiseAdapter { @@ -23,17 +25,26 @@ public function isThenable($value): bool return $value instanceof Future; } - /** @throws InvariantViolation */ + /** + * @throws InvariantViolation + * + * @phpstan-return Promise> + */ public function convertThenable($thenable): Promise { return new Promise($thenable, $this); } - /** @throws InvariantViolation */ + /** + * @phpstan-param Promise> $promise + * + * @throws InvariantViolation + * + * @phpstan-return Promise> + */ public function then(Promise $promise, ?callable $onFulfilled = null, ?callable $onRejected = null): Promise { $future = $promise->adoptedPromise; - assert($future instanceof Future); $next = async(static function () use ($future, $onFulfilled, $onRejected) { try { @@ -80,6 +91,8 @@ static function (\Throwable $exception) use ($deferred): void { /** * @throws \Error * @throws InvariantViolation + * + * @phpstan-return Promise> */ public function createFulfilled($value = null): Promise { @@ -94,7 +107,11 @@ public function createFulfilled($value = null): Promise return new Promise(Future::complete($value), $this); } - /** @throws InvariantViolation */ + /** + * @throws InvariantViolation + * + * @phpstan-return Promise> + */ public function createRejected(\Throwable $reason): Promise { return new Promise(Future::error($reason), $this); diff --git a/src/Executor/Promise/Adapter/AmpPromiseAdapter.php b/src/Executor/Promise/Adapter/AmpPromiseAdapter.php index 25e9fc84b..bc7bad890 100644 --- a/src/Executor/Promise/Adapter/AmpPromiseAdapter.php +++ b/src/Executor/Promise/Adapter/AmpPromiseAdapter.php @@ -12,6 +12,9 @@ use function Amp\Promise\all; +/** + * @implements PromiseAdapter> + */ class AmpPromiseAdapter implements PromiseAdapter { public function isThenable($value): bool @@ -25,7 +28,13 @@ public function convertThenable($thenable): Promise return new Promise($thenable, $this); } - /** @throws InvariantViolation */ + /** + * @phpstan-param Promise> $promise + * + * @throws InvariantViolation + * + * @phpstan-return Promise> + */ public function then(Promise $promise, ?callable $onFulfilled = null, ?callable $onRejected = null): Promise { $deferred = new Deferred(); @@ -42,7 +51,6 @@ public function then(Promise $promise, ?callable $onFulfilled = null, ?callable }; $ampPromise = $promise->adoptedPromise; - assert($ampPromise instanceof AmpPromise); $ampPromise->onResolve($onResolve); return new Promise($deferred->promise(), $this); @@ -68,6 +76,8 @@ static function (\Throwable $exception) use ($deferred): void { /** * @throws \Error * @throws InvariantViolation + * + * @phpstan-return Promise> */ public function createFulfilled($value = null): Promise { @@ -76,7 +86,11 @@ public function createFulfilled($value = null): Promise return new Promise($promise, $this); } - /** @throws InvariantViolation */ + /** + * @throws InvariantViolation + * + * @phpstan-return Promise> + */ public function createRejected(\Throwable $reason): Promise { $promise = new Failure($reason); diff --git a/src/Executor/Promise/Adapter/ReactPromiseAdapter.php b/src/Executor/Promise/Adapter/ReactPromiseAdapter.php index 7eb153c13..683bedb65 100644 --- a/src/Executor/Promise/Adapter/ReactPromiseAdapter.php +++ b/src/Executor/Promise/Adapter/ReactPromiseAdapter.php @@ -12,6 +12,9 @@ use function React\Promise\reject; use function React\Promise\resolve; +/** + * @implements PromiseAdapter> + */ class ReactPromiseAdapter implements PromiseAdapter { public function isThenable($value): bool @@ -25,16 +28,25 @@ public function convertThenable($thenable): Promise return new Promise($thenable, $this); } - /** @throws InvariantViolation */ + /** + * @phpstan-param Promise> $promise + * + * @throws InvariantViolation + * + * @phpstan-return Promise> + */ public function then(Promise $promise, ?callable $onFulfilled = null, ?callable $onRejected = null): Promise { $reactPromise = $promise->adoptedPromise; - assert($reactPromise instanceof ReactPromiseInterface); return new Promise($reactPromise->then($onFulfilled, $onRejected), $this); } - /** @throws InvariantViolation */ + /** + * @throws InvariantViolation + * + * @phpstan-return Promise> + */ public function create(callable $resolver): Promise { $reactPromise = new ReactPromise($resolver); @@ -42,7 +54,11 @@ public function create(callable $resolver): Promise return new Promise($reactPromise, $this); } - /** @throws InvariantViolation */ + /** + * @throws InvariantViolation + * + * @phpstan-return Promise> + */ public function createFulfilled($value = null): Promise { $reactPromise = resolve($value); @@ -50,15 +66,24 @@ public function createFulfilled($value = null): Promise return new Promise($reactPromise, $this); } - /** @throws InvariantViolation */ + /** + * @throws InvariantViolation + * + * @phpstan-return Promise> + */ public function createRejected(\Throwable $reason): Promise { + /** @var ReactPromiseInterface $reactPromise */ $reactPromise = reject($reason); return new Promise($reactPromise, $this); } - /** @throws InvariantViolation */ + /** + * @throws InvariantViolation + * + * @phpstan-return Promise> + */ public function all(iterable $promisesOrValues): Promise { foreach ($promisesOrValues as &$promiseOrValue) { @@ -70,6 +95,7 @@ public function all(iterable $promisesOrValues): Promise $promisesOrValuesArray = is_array($promisesOrValues) ? $promisesOrValues : iterator_to_array($promisesOrValues); + /** @var ReactPromiseInterface $reactPromise */ $reactPromise = all($promisesOrValuesArray)->then(static fn (array $values): array => array_map( static fn ($key) => $values[$key], array_keys($promisesOrValuesArray), diff --git a/src/Executor/Promise/Adapter/SyncPromiseAdapter.php b/src/Executor/Promise/Adapter/SyncPromiseAdapter.php index d59151c8c..67c262e4b 100644 --- a/src/Executor/Promise/Adapter/SyncPromiseAdapter.php +++ b/src/Executor/Promise/Adapter/SyncPromiseAdapter.php @@ -11,6 +11,8 @@ /** * Allows changing order of field resolution even in sync environments * (by leveraging queue of deferreds and promises). + * + * @implements PromiseAdapter */ class SyncPromiseAdapter implements PromiseAdapter { @@ -32,11 +34,16 @@ public function convertThenable($thenable): Promise return new Promise($thenable, $this); } - /** @throws InvariantViolation */ + /** + * @phpstan-param Promise $promise + * + * @throws InvariantViolation + * + * @phpstan-return Promise + */ public function then(Promise $promise, ?callable $onFulfilled = null, ?callable $onRejected = null): Promise { $syncPromise = $promise->adoptedPromise; - assert($syncPromise instanceof SyncPromise); return new Promise($syncPromise->then($onFulfilled, $onRejected), $this); } diff --git a/src/Executor/Promise/Promise.php b/src/Executor/Promise/Promise.php index 42c307b28..0eb420b69 100644 --- a/src/Executor/Promise/Promise.php +++ b/src/Executor/Promise/Promise.php @@ -2,24 +2,34 @@ namespace GraphQL\Executor\Promise; -use Amp\Future as AmpFuture; -use Amp\Promise as AmpPromise; use GraphQL\Error\InvariantViolation; -use GraphQL\Executor\Promise\Adapter\SyncPromise; -use React\Promise\PromiseInterface as ReactPromise; /** * Convenience wrapper for promises represented by Promise Adapter. + * + * The adopted promise is whatever the configured adapter produces (e.g. + * {@see \GraphQL\Executor\Promise\Adapter\SyncPromise}, a ReactPHP promise or an + * amphp Future). It is kept as a generic so the concrete platform type never has + * to be named in this class, which would otherwise require importing a class + * that may not exist for the installed platform. + * + * @template TAdopted = mixed */ class Promise { - /** @var SyncPromise|ReactPromise|AmpFuture|AmpPromise */ + /** + * @phpstan-var TAdopted + * + * @readonly + */ public $adoptedPromise; + /** @phpstan-var PromiseAdapter */ private PromiseAdapter $adapter; /** - * @param mixed $adoptedPromise + * @phpstan-param TAdopted $adoptedPromise + * @phpstan-param PromiseAdapter $adapter * * @throws InvariantViolation */ @@ -34,6 +44,7 @@ public function __construct($adoptedPromise, PromiseAdapter $adapter) $this->adapter = $adapter; } + /** @phpstan-return Promise */ public function then(?callable $onFulfilled = null, ?callable $onRejected = null): Promise { return $this->adapter->then($this, $onFulfilled, $onRejected); diff --git a/src/Executor/Promise/PromiseAdapter.php b/src/Executor/Promise/PromiseAdapter.php index a91ee243d..89e1f8e63 100644 --- a/src/Executor/Promise/PromiseAdapter.php +++ b/src/Executor/Promise/PromiseAdapter.php @@ -4,6 +4,8 @@ /** * Provides a means for integration of async PHP platforms ([related docs](data-fetching.md#async-php)). + * + * @template TAdopted = mixed */ interface PromiseAdapter { @@ -21,6 +23,8 @@ public function isThenable($value): bool; * * @param mixed $thenable * + * @phpstan-return Promise + * * @api */ public function convertThenable($thenable): Promise; @@ -29,6 +33,10 @@ public function convertThenable($thenable): Promise; * Accepts our Promise wrapper, extracts adopted promise out of it and executes actual `then` logic described * in Promises/A+ specs. Then returns new wrapped instance of GraphQL\Executor\Promise\Promise. * + * @phpstan-param Promise $promise + * + * @phpstan-return Promise + * * @api */ public function then(Promise $promise, ?callable $onFulfilled = null, ?callable $onRejected = null): Promise; @@ -38,6 +46,8 @@ public function then(Promise $promise, ?callable $onFulfilled = null, ?callable * * @param callable(callable $resolve, callable $reject): void $resolver * + * @phpstan-return Promise + * * @api */ public function create(callable $resolver): Promise; @@ -47,14 +57,16 @@ public function create(callable $resolver): Promise; * * @param mixed $value * + * @phpstan-return Promise + * * @api */ public function createFulfilled($value = null): Promise; /** - * Creates a rejected promise for a reason if the reason is not a promise. + * Return a promise rejected with the given reason. * - * If the provided reason is a promise, then it is returned as-is. + * @phpstan-return Promise * * @api */ @@ -66,6 +78,8 @@ public function createRejected(\Throwable $reason): Promise; * * @param iterable $promisesOrValues * + * @phpstan-return Promise + * * @api */ public function all(iterable $promisesOrValues): Promise; diff --git a/tests/Executor/Promise/AmpFutureAdapterTest.php b/tests/Executor/Promise/AmpFutureAdapterTest.php index 5f4b0539e..a8bbb4663 100644 --- a/tests/Executor/Promise/AmpFutureAdapterTest.php +++ b/tests/Executor/Promise/AmpFutureAdapterTest.php @@ -49,7 +49,7 @@ public function testConvertsAmpFuturesToGraphQLOnes(): void $promise = $ampAdapter->convertThenable($future); - self::assertInstanceOf(Future::class, $promise->adoptedPromise); + self::assertSame($future, $promise->adoptedPromise); } public function testThen(): void @@ -67,8 +67,6 @@ static function ($value) use (&$result): void { } ); - self::assertInstanceOf(Future::class, $resultPromise->adoptedPromise); - $resultPromise->adoptedPromise->await(); self::assertSame(1, $result); @@ -81,17 +79,13 @@ public function testCreate(): void $resolve(1); }); - self::assertInstanceOf(Future::class, $resolvedPromise->adoptedPromise); - $result = null; $resultPromise = $resolvedPromise->then(static function ($value) use (&$result): void { $result = $value; }); - $resultFuture = $resultPromise->adoptedPromise; - self::assertInstanceOf(Future::class, $resultFuture); - $resultFuture->await(); + $resultPromise->adoptedPromise->await(); self::assertSame(1, $result); } @@ -101,17 +95,13 @@ public function testCreateFulfilled(): void $ampAdapter = new AmpFutureAdapter(); $fulfilledPromise = $ampAdapter->createFulfilled(1); - self::assertInstanceOf(Future::class, $fulfilledPromise->adoptedPromise); - $result = null; $resultPromise = $fulfilledPromise->then(static function ($value) use (&$result): void { $result = $value; }); - $resultFuture = $resultPromise->adoptedPromise; - self::assertInstanceOf(Future::class, $resultFuture); - $resultFuture->await(); + $resultPromise->adoptedPromise->await(); self::assertSame(1, $result); } @@ -121,8 +111,6 @@ public function testCreateRejected(): void $ampAdapter = new AmpFutureAdapter(); $rejectedPromise = $ampAdapter->createRejected(new \Exception('I am a bad promise')); - self::assertInstanceOf(Future::class, $rejectedPromise->adoptedPromise); - $exception = null; $resultPromise = $rejectedPromise->then( @@ -132,9 +120,7 @@ static function ($error) use (&$exception): void { } ); - $resultFuture = $resultPromise->adoptedPromise; - self::assertInstanceOf(Future::class, $resultFuture); - $resultFuture->await(); + $resultPromise->adoptedPromise->await(); self::assertInstanceOf(\Throwable::class, $exception); self::assertSame('I am a bad promise', $exception->getMessage()); @@ -147,8 +133,6 @@ public function testAll(): void $allPromise = $ampAdapter->all($promises); - self::assertInstanceOf(Future::class, $allPromise->adoptedPromise); - $result = $allPromise->adoptedPromise->await(); self::assertSame([1, 2, 3], $result); @@ -164,9 +148,7 @@ public function testAllShouldPreserveTheOrderOfTheArrayWhenResolvingAsyncPromise $deferred->complete(3); - $allFuture = $allPromise->adoptedPromise; - self::assertInstanceOf(Future::class, $allFuture); - $result = $allFuture->await(); + $result = $allPromise->adoptedPromise->await(); self::assertSame([1, 2, 3, 4], $result); } diff --git a/tests/Executor/Promise/AmpPromiseAdapterTest.php b/tests/Executor/Promise/AmpPromiseAdapterTest.php index b416a7d3b..616805cda 100644 --- a/tests/Executor/Promise/AmpPromiseAdapterTest.php +++ b/tests/Executor/Promise/AmpPromiseAdapterTest.php @@ -76,7 +76,6 @@ static function ($value) use (&$result): void { ); self::assertSame(1, $result); - self::assertInstanceOf(Promise::class, $resultPromise->adoptedPromise); } public function testCreate(): void @@ -86,8 +85,6 @@ public function testCreate(): void $resolve(1); }); - self::assertInstanceOf(Promise::class, $resolvedPromise->adoptedPromise); - $result = null; $resolvedPromise->then(static function ($value) use (&$result): void { @@ -140,8 +137,6 @@ public function testAll(): void $allPromise = $ampAdapter->all($promises); - self::assertInstanceOf(Promise::class, $allPromise->adoptedPromise); - $result = null; $allPromise->then(static function ($values) use (&$result): void { diff --git a/tests/Executor/Promise/SyncPromiseAdapterTest.php b/tests/Executor/Promise/SyncPromiseAdapterTest.php index 4c5465f9d..3348fcebf 100644 --- a/tests/Executor/Promise/SyncPromiseAdapterTest.php +++ b/tests/Executor/Promise/SyncPromiseAdapterTest.php @@ -37,7 +37,7 @@ public function testConvert(): void $dfd = new Deferred(static function (): void {}); $result = $this->promises->convertThenable($dfd); - self::assertInstanceOf(SyncPromise::class, $result->adoptedPromise); + self::assertSame($dfd, $result->adoptedPromise); $this->expectException(InvariantViolation::class); $this->expectExceptionMessage('Expected instance of GraphQL\Deferred, got (empty string)'); @@ -51,14 +51,12 @@ public function testThen(): void $result = $this->promises->then($promise); - self::assertInstanceOf(SyncPromise::class, $result->adoptedPromise); + self::assertNotSame($promise, $result); } public function testCreatePromise(): void { - $promise = $this->promises->create(static function ($resolve, $reject): void {}); - - self::assertInstanceOf(SyncPromise::class, $promise->adoptedPromise); + $this->promises->create(static function ($resolve, $reject): void {}); $promise = $this->promises->create(static function ($resolve, $reject): void { $resolve('A'); @@ -200,13 +198,9 @@ public function testWait(): void // until all pending promises are resolved self::assertSame(2, $result); - $p3AdoptedPromise = $p3->adoptedPromise; - self::assertInstanceOf(SyncPromise::class, $p3AdoptedPromise); - self::assertSame(SyncPromise::FULFILLED, $p3AdoptedPromise->state); + self::assertSame(SyncPromise::FULFILLED, $p3->adoptedPromise->state); - $allAdoptedPromise = $all->adoptedPromise; - self::assertInstanceOf(SyncPromise::class, $allAdoptedPromise); - self::assertSame(SyncPromise::FULFILLED, $allAdoptedPromise->state); + self::assertSame(SyncPromise::FULFILLED, $all->adoptedPromise->state); self::assertSame([1, 2, 3, 4], $called); From c72fd0f82e7b992aaf35516ebf860a4729be206f Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 5 Jul 2026 20:03:26 +0200 Subject: [PATCH 05/36] v15.34.0 changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86c386093..52050f5f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +## v15.34.0 + +### Added + +- Make Promise and PromiseAdapter generic over the adopted promise type https://github.com/webonyx/graphql-php/pull/1941 + ## v15.33.1 ### Fixed From 26035d4d3a09f30d99bb33d871c860eb4ab4986b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:51:06 +0000 Subject: [PATCH 06/36] Update dependency friendsofphp/php-cs-fixer to v3.95.12 (#1944) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d8916fb94..29b0b4b9d 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "amphp/http-server": "^2.1 || ^3", "dms/phpunit-arraysubset-asserts": "dev-master", "ergebnis/composer-normalize": "^2.28", - "friendsofphp/php-cs-fixer": "3.95.11", + "friendsofphp/php-cs-fixer": "3.95.12", "mll-lab/php-cs-fixer-config": "5.13.0", "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", From 8f81f4b848c1a7372409409f56ab3396f7ada0e0 Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Thu, 9 Jul 2026 11:09:19 +0300 Subject: [PATCH 07/36] fix(utils): preserve deprecated input values in client schema --- src/Utils/BuildClientSchema.php | 1 + tests/Utils/BuildClientSchemaTest.php | 28 +++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Utils/BuildClientSchema.php b/src/Utils/BuildClientSchema.php index 33276c04b..13d157868 100644 --- a/src/Utils/BuildClientSchema.php +++ b/src/Utils/BuildClientSchema.php @@ -522,6 +522,7 @@ public function buildInputValue(array $inputValueIntrospection): array $inputValue = [ 'description' => $inputValueIntrospection['description'], + 'deprecationReason' => $inputValueIntrospection['deprecationReason'] ?? null, 'type' => $type, ]; diff --git a/tests/Utils/BuildClientSchemaTest.php b/tests/Utils/BuildClientSchemaTest.php index 8d7e17599..9f990f1b6 100644 --- a/tests/Utils/BuildClientSchemaTest.php +++ b/tests/Utils/BuildClientSchemaTest.php @@ -521,18 +521,42 @@ enum Color { """So sickening""" MAUVE @deprecated(reason: "No longer in fashion") } - + + input ColorInput { + oldColor: String @deprecated(reason: "Use color") + color: String + } + type Query { """This is a shiny string field""" shinyString: String - + """This is a deprecated string field""" deprecatedString: String @deprecated(reason: "Use shinyString") + paint(oldColor: String @deprecated(reason: "Use color"), color: String, input: ColorInput): Color color: Color } '); } + /** it('prints deprecated input values from a client schema', () => {. */ + public function testPrintsDeprecatedInputValuesFromClientSchema(): void + { + $sdl = << {. */ public function testBuildsASchemaWithEmptyDeprecationReasons(): void { From 2e1f1f6000d4352f5b694d34d89a8321ed68c137 Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Thu, 9 Jul 2026 12:43:47 +0300 Subject: [PATCH 08/36] test(utils): avoid redundant client schema print coverage --- tests/Utils/BuildClientSchemaTest.php | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/Utils/BuildClientSchemaTest.php b/tests/Utils/BuildClientSchemaTest.php index 9f990f1b6..677062481 100644 --- a/tests/Utils/BuildClientSchemaTest.php +++ b/tests/Utils/BuildClientSchemaTest.php @@ -539,24 +539,6 @@ enum Color { '); } - /** it('prints deprecated input values from a client schema', () => {. */ - public function testPrintsDeprecatedInputValuesFromClientSchema(): void - { - $sdl = << {. */ public function testBuildsASchemaWithEmptyDeprecationReasons(): void { From ad2957806df58696229dcf71c561e0020672c7ce Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:59:44 +0000 Subject: [PATCH 09/36] Update dependency friendsofphp/php-cs-fixer to v3.95.13 (#1946) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 29b0b4b9d..af86c6362 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "amphp/http-server": "^2.1 || ^3", "dms/phpunit-arraysubset-asserts": "dev-master", "ergebnis/composer-normalize": "^2.28", - "friendsofphp/php-cs-fixer": "3.95.12", + "friendsofphp/php-cs-fixer": "3.95.13", "mll-lab/php-cs-fixer-config": "5.13.0", "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", From 8363b2ad7fd66351b3c0bfd7ceb34e5421e895fd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:02:07 +0000 Subject: [PATCH 10/36] Update dependency friendsofphp/php-cs-fixer to v3.95.15 (#1947) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index af86c6362..8f60d06b1 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "amphp/http-server": "^2.1 || ^3", "dms/phpunit-arraysubset-asserts": "dev-master", "ergebnis/composer-normalize": "^2.28", - "friendsofphp/php-cs-fixer": "3.95.13", + "friendsofphp/php-cs-fixer": "3.95.15", "mll-lab/php-cs-fixer-config": "5.13.0", "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", From 9b3eab758ef08af51054b79a6fd92b482a0c3253 Mon Sep 17 00:00:00 2001 From: OpaqueRock <305760598+OpaqueRock@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:35:25 +0300 Subject: [PATCH 11/36] Optimize Lexer::readString and Visitor::visitInParallel Lexer::readString previously decoded and validated one UTF-8 character at a time via Lexer::readChar(). Replace this with a bulk scan using strcspn() to copy runs of bytes that need no special handling (i.e. anything other than a quote, backslash, or control character) in a single native call, falling back to the original per-character logic only for quotes, line terminators, control characters, and escape sequences. Care is taken to keep $this->position (a decoded-character count, not a byte count) accurate for multi-byte UTF-8 content. TAB (0x09), a legal, unescaped SourceCharacter per the GraphQL spec, is excluded from the stop-byte set so it is scanned through like any other ordinary character instead of being individually inspected. Visitor::visitInParallel previously called extractVisitFn() to resolve the enter/leave callback for the current visitor and node kind on every single node, for every wrapped visitor - an O(nodes x visitors) number of array lookups, even though a callback's identity for a given (visitor, kind, direction) triple never changes during a traversal. Since NodeKind exposes a small, fixed set of kinds, precompute this mapping once per call (O(visitors x kinds)) instead. Additionally, replace func_get_args() plus argument unpacking in the enter/leave dispatcher closures with the five explicit, typed parameters they are always called with (matching Visitor::visit()'s single call site), avoiding func_get_args()'s per-call overhead. Add a regression test for the TAB-handling edge case described above. --- src/Language/Lexer.php | 178 ++++++++++++++++++++--------------- src/Language/Visitor.php | 42 ++++++--- tests/Language/LexerTest.php | 15 +++ 3 files changed, 147 insertions(+), 88 deletions(-) diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index de2314319..4fbf9d25f 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -391,18 +391,50 @@ private function readString(int $line, int $col, Token $prev): Token { $start = $this->position; - // Skip leading quote and read first string char: - [$char, $code, $bytes] = $this->moveStringCursor(1, 1) - ->readChar(); + // Skip leading quote + $this->moveStringCursor(1, 1); + + // Bulk-scan runs of bytes that need no special handling via strcspn() + // (a single native call) instead of decoding/validating one UTF-8 + // character at a time. + // + // The stop-byte set below includes ASCII control characters + // (0x00-0x1F), not just backslash/quote/line-terminators, because + // assertValidStringCharacterCode() must still reject most of them. + // TAB (0x09) is excluded: it is a legal, unescaped SourceCharacter + // per the GraphQL spec, so it is scanned through like any other + // ordinary character instead of being individually inspected. + static $stopBytes; + $stopBytes ??= '"\\' . implode('', array_map('chr', array_diff(range(0x00, 0x1F), [0x09]))); - $chunk = ''; + $body = $this->source->body; + $bodyLength = strlen($body); $value = ''; - while (! in_array($code, [null, 10, 13], true)) { // not LineTerminator - if ($code === 34) { // Closing Quote (") + while (true) { + $byteStart = $this->byteStreamPosition; + $runLength = strcspn($body, $stopBytes, $byteStart); + + if ($runLength > 0) { + $chunk = substr($body, $byteStart, $runLength); $value .= $chunk; + // $this->position counts decoded characters, not bytes: count the + // non-continuation bytes (i.e. NOT matching 10xxxxxx) in the chunk to + // keep it accurate for multi-byte UTF-8 content. + $continuationBytes = preg_match_all('/[\x80-\xBF]/', $chunk); + assert(is_int($continuationBytes), 'Pattern is valid, so preg_match_all() cannot fail'); + $this->position += strlen($chunk) - $continuationBytes; + $this->byteStreamPosition = $byteStart + $runLength; + } - // Skip quote + $bytePos = $this->byteStreamPosition; + if ($bytePos >= $bodyLength) { + break; // EOF mid-string + } + + $code = ord($body[$bytePos]); + + if ($code === 34) { // Closing Quote (") $this->moveStringCursor(1, 1); return new Token( @@ -416,79 +448,77 @@ private function readString(int $line, int $col, Token $prev): Token ); } - $this->assertValidStringCharacterCode($code, $this->position); - $this->moveStringCursor(1, $bytes); - - if ($code === 92) { // \ - $value .= $chunk; - [, $code] = $this->readChar(true); - - switch ($code) { - case 34: - $value .= '"'; - break; - case 47: - $value .= '/'; - break; - case 92: - $value .= '\\'; - break; - case 98: - $value .= chr(8); // \b (backspace) - break; - case 102: - $value .= "\f"; - break; - case 110: - $value .= "\n"; - break; - case 114: - $value .= "\r"; - break; - case 116: - $value .= "\t"; - break; - case 117: - $position = $this->position; - [$hex] = $this->readChars(4); - if (preg_match('/[0-9a-fA-F]{4}/', $hex) !== 1) { - throw new SyntaxError($this->source, $position - 1, "Invalid character escape sequence: \\u{$hex}"); - } - - $code = hexdec($hex); - assert(is_int($code), 'Since only a single char is read'); + if ($code === 10 || $code === 13) { // LineTerminator + break; + } - // UTF-16 surrogate pair detection and handling. - $highOrderByte = $code >> 8; - if ($highOrderByte >= 0xD8 && $highOrderByte <= 0xDF) { - [$utf16Continuation] = $this->readChars(6); - if (preg_match('/^\\\u[0-9a-fA-F]{4}$/', $utf16Continuation) !== 1) { - throw new SyntaxError($this->source, $this->position - 5, 'Invalid UTF-16 trailing surrogate: ' . $utf16Continuation); - } + // Any other control character reaching here must be invalid + // (TAB, the only legal one, is excluded from the stop-byte set). + $this->assertValidStringCharacterCode($code, $this->position); - $surrogatePairHex = $hex . substr($utf16Continuation, 2, 4); - $value .= mb_convert_encoding(pack('H*', $surrogatePairHex), 'UTF-8', 'UTF-16'); - break; + // $code === 92 (\), i.e. an escape sequence. + $this->moveStringCursor(1, 1); + [, $code] = $this->readChar(true); + + switch ($code) { + case 34: + $value .= '"'; + break; + case 47: + $value .= '/'; + break; + case 92: + $value .= '\\'; + break; + case 98: + $value .= chr(8); // \b (backspace) + break; + case 102: + $value .= "\f"; + break; + case 110: + $value .= "\n"; + break; + case 114: + $value .= "\r"; + break; + case 116: + $value .= "\t"; + break; + case 117: + $position = $this->position; + [$hex] = $this->readChars(4); + if (preg_match('/[0-9a-fA-F]{4}/', $hex) !== 1) { + throw new SyntaxError($this->source, $position - 1, "Invalid character escape sequence: \\u{$hex}"); + } + + $code = hexdec($hex); + assert(is_int($code), 'Since only a single char is read'); + + // UTF-16 surrogate pair detection and handling. + $highOrderByte = $code >> 8; + if ($highOrderByte >= 0xD8 && $highOrderByte <= 0xDF) { + [$utf16Continuation] = $this->readChars(6); + if (preg_match('/^\\\u[0-9a-fA-F]{4}$/', $utf16Continuation) !== 1) { + throw new SyntaxError($this->source, $this->position - 5, 'Invalid UTF-16 trailing surrogate: ' . $utf16Continuation); } - $this->assertValidStringCharacterCode($code, $position - 2); - - $value .= Utils::chr($code); + $surrogatePairHex = $hex . substr($utf16Continuation, 2, 4); + $value .= mb_convert_encoding(pack('H*', $surrogatePairHex), 'UTF-8', 'UTF-16'); break; - // null means EOF, will delegate to general handling of unterminated strings - case null: - continue 2; - default: - $chr = Utils::chr($code); - throw new SyntaxError($this->source, $this->position - 1, "Invalid character escape sequence: \\{$chr}"); - } - - $chunk = ''; - } else { - $chunk .= $char; + } + + $this->assertValidStringCharacterCode($code, $position - 2); + + $value .= Utils::chr($code); + break; + // null means EOF, will delegate to general handling of unterminated strings + case null: + break; + default: + $chr = Utils::chr($code); + throw new SyntaxError($this->source, $this->position - 1, "Invalid character escape sequence: \\{$chr}"); } - - [$char, $code, $bytes] = $this->readChar(); } throw new SyntaxError($this->source, $this->position, 'Unterminated string.'); diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index d1101c68a..750a6d8c3 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -383,24 +383,42 @@ public static function visitInParallel(array $visitors): array $visitorsCount = count($visitors); $skipping = new \SplFixedArray($visitorsCount); + // extractVisitFn() otherwise re-derives the same enter/leave callable + // via array lookups on every single node visited, for every visitor - + // O(nodes x visitors) calls - even though its result for a given + // (visitor, kind, isLeaving) triple never changes during one + // traversal. NodeKind has a small, fixed set of constants, so + // precompute the callback per visitor/kind/direction once here + // (O(visitors x kinds)) instead. + static $allKinds; + $allKinds ??= array_values(array_filter((new \ReflectionClass(NodeKind::class))->getConstants(), 'is_string')); + + $enterFns = []; + $leaveFns = []; + foreach ($visitors as $i => $visitor) { + foreach ($allKinds as $kind) { + $enterFns[$i][$kind] = self::extractVisitFn($visitor, $kind, false); + $leaveFns[$i][$kind] = self::extractVisitFn($visitor, $kind, true); + } + } + return [ - 'enter' => static function (Node $node) use ($visitors, $skipping, $visitorsCount) { + // Declares node, key, parent, path, and ancestors explicitly to + // match Visitor::visit()'s `$visitFn($node, $key, $parent, + // $path, $ancestors)` call. + 'enter' => static function (Node $node, $key, $parent, $path, $ancestors) use ($skipping, $visitorsCount, $enterFns) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] !== null) { continue; } - $fn = self::extractVisitFn( - $visitors[$i], - $node->kind, - false - ); + $fn = $enterFns[$i][$node->kind] ?? null; if ($fn === null) { continue; } - $result = $fn(...func_get_args()); + $result = $fn($node, $key, $parent, $path, $ancestors); if ($result === null) { continue; @@ -416,17 +434,13 @@ public static function visitInParallel(array $visitors): array return null; }, - 'leave' => static function (Node $node) use ($visitors, $skipping, $visitorsCount) { + 'leave' => static function (Node $node, $key, $parent, $path, $ancestors) use ($skipping, $visitorsCount, $leaveFns) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] === null) { - $fn = self::extractVisitFn( - $visitors[$i], - $node->kind, - true - ); + $fn = $leaveFns[$i][$node->kind] ?? null; if ($fn !== null) { - $result = $fn(...func_get_args()); + $result = $fn($node, $key, $parent, $path, $ancestors); if ($result === null) { continue; diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index 25ad8f11d..e7ae5ff56 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -296,6 +296,21 @@ public function testLexesStrings(): void ); } + public function testLexesStringsWithUnescapedTabCharacter(): void + { + $source = '"before' . "\t" . 'after"'; + + self::assertArraySubset( + [ + 'kind' => Token::STRING, + 'start' => 0, + 'end' => strlen($source), + 'value' => 'before' . "\t" . 'after', + ], + (array) $this->lexOne($source) + ); + } + /** @see it('lexes block strings') */ public function testLexesBlockString(): void { From 28bea9ae3bbdee8c03cd438c445c6f5b0dba909d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:14:26 +0000 Subject: [PATCH 12/36] Update dependency phpstan/phpstan-strict-rules to v2.0.12 (#1950) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 8f60d06b1..9b84c0584 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "2.2.5", "phpstan/phpstan-phpunit": "2.0.18", - "phpstan/phpstan-strict-rules": "2.0.11", + "phpstan/phpstan-strict-rules": "2.0.12", "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", "psr/http-message": "^1 || ^2", "react/http": "^1.6", From 28aa0bccc39260ccecf1aac301c34fafa211d550 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 19 Jul 2026 17:48:39 +0200 Subject: [PATCH 13/36] npx --yes prettier --- .github/workflows/autofix.yml | 2 +- Makefile | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 4fb78ffe1..fa7b1e498 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -28,7 +28,7 @@ jobs: - run: composer docs - - run: npx prettier --write --tab-width=2 '*.md' '**/*.md' + - run: npx --yes prettier --write --tab-width=2 '*.md' '**/*.md' - uses: autofix-ci/action@v1 with: diff --git a/Makefile b/Makefile index 25d05798d..da61ae9b6 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ php-cs-fixer: ## Fix code style .PHONY: prettier prettier: ## Format code with prettier - npx prettier --write --tab-width=2 '*.md' '**/*.md' + npx --yes prettier --write --tab-width=2 '*.md' '**/*.md' phpstan.neon: printf "includes:\n - phpstan.neon.dist" > phpstan.neon @@ -52,7 +52,7 @@ bench: ## Runs benchmarks with PHPBench .PHONY: docs docs: ## Generate the class-reference docs php generate-class-reference.php - npx prettier --write docs/class-reference.md + npx --yes prettier --write docs/class-reference.md .PHONY: ai-sync ai-sync: ## Generate local agent configuration from .ai From 5e99c3edc5b442ac7fad0870035a1a8a4a827b2d Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 19 Jul 2026 17:50:21 +0200 Subject: [PATCH 14/36] Apply latest rector fixes to tests --- tests/Executor/Promise/AmpPromiseAdapterTest.php | 3 +-- tests/Utils/SchemaExtenderTest.php | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/Executor/Promise/AmpPromiseAdapterTest.php b/tests/Executor/Promise/AmpPromiseAdapterTest.php index 616805cda..f7e52d942 100644 --- a/tests/Executor/Promise/AmpPromiseAdapterTest.php +++ b/tests/Executor/Promise/AmpPromiseAdapterTest.php @@ -67,8 +67,7 @@ public function testThen(): void $promise = $ampAdapter->convertThenable($ampPromise); $result = null; - - $resultPromise = $ampAdapter->then( + $ampAdapter->then( $promise, static function ($value) use (&$result): void { $result = $value; diff --git a/tests/Utils/SchemaExtenderTest.php b/tests/Utils/SchemaExtenderTest.php index 2035f2f43..26b9cc44b 100644 --- a/tests/Utils/SchemaExtenderTest.php +++ b/tests/Utils/SchemaExtenderTest.php @@ -474,7 +474,7 @@ public function testExtendsScalarsByAddingSpecifiedByDirective(): void $extendedSchema = SchemaExtender::extend($schema, Parser::parse($extensionSDL)); $foo = $extendedSchema->getType('Foo'); - assert($foo instanceof ScalarType); + self::assertInstanceOf(ScalarType::class, $foo); self::assertSame('https://example.com/foo_spec', $foo->specifiedByURL); self::assertEmpty($extendedSchema->validate()); @@ -503,7 +503,7 @@ public function testExtendsScalarsWithCustomSpecifiedByOverrideShouldNotThrow(): extend scalar Foo @specifiedBy ')); $foo = $extendedSchema->getType('Foo'); - assert($foo instanceof ScalarType); + self::assertInstanceOf(ScalarType::class, $foo); // Custom @specifiedBy without url arg leaves specifiedByURL as null self::assertNull($foo->specifiedByURL); From 3c9d876ae5006ea8ef98da97d9ede7baad8962e7 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 19 Jul 2026 17:51:53 +0200 Subject: [PATCH 15/36] Skip overly eager RemoveDuplicatedReturnSelfDocblockRector --- rector.php | 1 + 1 file changed, 1 insertion(+) diff --git a/rector.php b/rector.php index cd9b7544c..ee7a60c22 100644 --- a/rector.php +++ b/rector.php @@ -38,6 +38,7 @@ __DIR__ . '/tests/TestCaseBase.php', // Array output may differ between tested PHP versions, assertEquals smooths over this ], Rector\PHPUnit\PHPUnit60\Rector\ClassMethod\AddDoesNotPerformAssertionToNonAssertingTestRector::class, // False-positive + Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector::class, // Overly eager on removing static or $this ]); $rectorConfig->paths([ __DIR__ . '/benchmarks', From 203d11e77843599e0c17b7810887200229527fb4 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 19 Jul 2026 17:52:45 +0200 Subject: [PATCH 16/36] Remove skips for deprecated Rector rules --- rector.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/rector.php b/rector.php index ee7a60c22..aa1596998 100644 --- a/rector.php +++ b/rector.php @@ -13,12 +13,8 @@ $rectorConfig->skip([ Rector\CodeQuality\Rector\Isset_\IssetOnPropertyObjectToPropertyExistsRector::class, // isset() is nice when moving towards typed properties Rector\CodeQuality\Rector\Identical\FlipTypeControlToUseExclusiveTypeRector::class, // Unnecessarily complex with PHPStan - Rector\CodeQuality\Rector\Concat\JoinStringConcatRector::class => [ - __DIR__ . '/tests', // Sometimes more readable for long strings - ], Rector\CodeQuality\Rector\ClassMethod\LocallyCalledStaticMethodToNonStaticRector::class, // static methods are fine Rector\CodeQuality\Rector\Foreach_\UnusedForeachValueToArrayKeysRector::class, // Less efficient - Rector\CodeQuality\Rector\Switch_\SwitchTrueToIfRector::class, // More expressive in some cases Rector\DeadCode\Rector\If_\RemoveAlwaysTrueIfConditionRector::class, // Sometimes necessary to prove runtime behavior matches defined types Rector\DeadCode\Rector\If_\RemoveDeadInstanceOfRector::class, // Sometimes necessary to prove runtime behavior matches defined types Rector\DeadCode\Rector\Node\RemoveNonExistingVarAnnotationRector::class, // Sometimes false-positive From dee2bd7b534113f2a71605dc9025f0d2d6def64c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:10:49 +0000 Subject: [PATCH 17/36] Autofix --- docs/schema-definition.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/schema-definition.md b/docs/schema-definition.md index 06ea1f14b..0a2b23dfc 100644 --- a/docs/schema-definition.md +++ b/docs/schema-definition.md @@ -78,15 +78,15 @@ with complex input values (see [Mutations and Input Types](type-definitions/inpu The schema constructor expects an instance of [`GraphQL\Type\SchemaConfig`](class-reference.md#graphqltypeschemaconfig) or an array with the following options: -| Option | Type | Notes | -| ------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| query | `ObjectType` or `callable(): ?ObjectType` or `null` | **Required.** Object type (usually named `Query`) containing root-level fields of your read API | -| mutation | `ObjectType` or `callable(): ?ObjectType` or `null` | Object type (usually named `Mutation`) containing root-level fields of your write API | -| subscription | `ObjectType` or `callable(): ?ObjectType` or `null` | Reserved for future subscriptions implementation. Currently presented for compatibility with introspection query of **graphql-js**, used by various clients (like Relay or GraphiQL) | -| directives | `array` | A full list of [directives](type-definitions/directives.md) supported by your schema. By default, contains built-in **@skip** and **@include** directives.

If you pass your own directives and still want to use built-in directives - add them explicitly. For example:

_array_merge(GraphQL::getStandardDirectives(), [$myCustomDirective]);_ | -| types | `iterable` | Additional types to register in the schema.

Use this for object types that are not directly referenced in fields but implement an interface that resolves to them via **resolveType**.

Can also contain custom scalar types named like built-in scalars (`String`, `Int`, etc.) to [override them](type-definitions/scalars.md#overriding-built-in-scalars) on a per-schema basis. | -| scalarOverrides | `array` or `null` | Custom scalar types named like built-in scalars (`String`, `Int`, etc.) that [override them](type-definitions/scalars.md#overriding-built-in-scalars) on a per-schema basis.

When `null` (default), overrides are discovered by scanning **types**, which fully resolves it even when given as a lazy callable. Pass overrides explicitly (or an empty array for none) to skip the scan. | -| typeLoader | `callable(string $name): Type` | Expected to return a type instance given the name. Must always return the same instance if called multiple times, see [lazy loading](#lazy-loading-of-types). See section below on lazy type loading. | +| Option | Type | Notes | +| --------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| query | `ObjectType` or `callable(): ?ObjectType` or `null` | **Required.** Object type (usually named `Query`) containing root-level fields of your read API | +| mutation | `ObjectType` or `callable(): ?ObjectType` or `null` | Object type (usually named `Mutation`) containing root-level fields of your write API | +| subscription | `ObjectType` or `callable(): ?ObjectType` or `null` | Reserved for future subscriptions implementation. Currently presented for compatibility with introspection query of **graphql-js**, used by various clients (like Relay or GraphiQL) | +| directives | `array` | A full list of [directives](type-definitions/directives.md) supported by your schema. By default, contains built-in **@skip** and **@include** directives.

If you pass your own directives and still want to use built-in directives - add them explicitly. For example:

_array_merge(GraphQL::getStandardDirectives(), [$myCustomDirective]);_ | +| types | `iterable` | Additional types to register in the schema.

Use this for object types that are not directly referenced in fields but implement an interface that resolves to them via **resolveType**.

Can also contain custom scalar types named like built-in scalars (`String`, `Int`, etc.) to [override them](type-definitions/scalars.md#overriding-built-in-scalars) on a per-schema basis. | +| scalarOverrides | `array` or `null` | Custom scalar types named like built-in scalars (`String`, `Int`, etc.) that [override them](type-definitions/scalars.md#overriding-built-in-scalars) on a per-schema basis.

When `null` (default), overrides are discovered by scanning **types**, which fully resolves it even when given as a lazy callable. Pass overrides explicitly (or an empty array for none) to skip the scan. | +| typeLoader | `callable(string $name): Type` | Expected to return a type instance given the name. Must always return the same instance if called multiple times, see [lazy loading](#lazy-loading-of-types). See section below on lazy type loading. | ### Using config class From 239febcf74ad380dd053b87054510984cb282e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Moitie=CC=81?= Date: Tue, 21 Jul 2026 00:38:54 +0100 Subject: [PATCH 18/36] Add test covering setScalarOverrides(null) reset branch Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Type/ScalarOverridesTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Type/ScalarOverridesTest.php b/tests/Type/ScalarOverridesTest.php index 07b414180..435c64a5b 100644 --- a/tests/Type/ScalarOverridesTest.php +++ b/tests/Type/ScalarOverridesTest.php @@ -670,6 +670,21 @@ public function testTypeMapStillResolvesLazyTypesWithExplicitScalarOverrides(): self::assertSame($extraType, $schema->getTypeMap()['Extra']); } + public function testSetScalarOverridesNullResetsOverrides(): void + { + $uppercaseString = self::createUppercaseString(); + + $config = SchemaConfig::create() + ->setQuery(self::createQueryType()) + ->setScalarOverrides([$uppercaseString]); + + self::assertSame([Type::STRING => $uppercaseString], $config->getScalarOverrides()); + + $config->setScalarOverrides(null); + + self::assertNull($config->getScalarOverrides()); + } + public function testSetScalarOverridesRejectsNonScalarTypes(): void { $config = new SchemaConfig(); From 9e2cdbcffdc3cf285243c4357b48710e58ddb0dc Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 21 Jul 2026 15:51:10 +0200 Subject: [PATCH 19/36] Update CHANGELOG for v15.34.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with Claude Code --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52050f5f0..505d0bba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +## v15.34.1 + +### Fixed + +- Preserve `deprecationReason` when rebuilding input values from introspection in `BuildClientSchema` https://github.com/webonyx/graphql-php/pull/1945 + ## v15.34.0 ### Added From 5605b5b12029054b1c10e470ebf69ca8e4ac47c3 Mon Sep 17 00:00:00 2001 From: OpaqueRock <305760598+OpaqueRock@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:09:13 +0300 Subject: [PATCH 20/36] Fix visitInParallel() dropping visitors for custom Node kinds The enter/leave callback caches were previously prefilled only for NodeKind constants, silently skipping generic and kind-specific visitors for Nodes with custom (non-NodeKind) kinds. Switch to lazy, per-direction caches that resolve and store a callback (or `false` as a "no callback" sentinel) on first use, keyed by any kind string. Also fixes a pre-existing undefined-array-key warning in visit() when traversing a Node with a custom kind. Add regression tests covering generic and kind-specific visitors for a custom Node kind. --- src/Language/Visitor.php | 43 +++++++++++--------- tests/Language/VisitorTest.php | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index 750a6d8c3..3b31fc38d 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -319,7 +319,9 @@ public static function visit(object $root, array $visitor, ?array $keyMap = null ]; $inList = $node instanceof NodeList; - $keys = ($inList ? $node : $visitorKeys[$node->kind]) ?? []; + // Nodes with a kind not present in $visitorKeys (e.g. a custom + // Node subclass) are treated as leaves, i.e. no children. + $keys = $inList ? $node : ($visitorKeys[$node->kind] ?? []); $index = -1; $edits = []; if ($parent !== null) { @@ -387,34 +389,34 @@ public static function visitInParallel(array $visitors): array // via array lookups on every single node visited, for every visitor - // O(nodes x visitors) calls - even though its result for a given // (visitor, kind, isLeaving) triple never changes during one - // traversal. NodeKind has a small, fixed set of constants, so - // precompute the callback per visitor/kind/direction once here - // (O(visitors x kinds)) instead. - static $allKinds; - $allKinds ??= array_values(array_filter((new \ReflectionClass(NodeKind::class))->getConstants(), 'is_string')); - + // traversal. Cache it per visitor/kind on first encounter instead, + // using `false` as a "no callback" sentinel so a plain `??` lookup + // can tell "not cached yet" (missing key -> null) apart from + // "cached, nothing to call" (`false`). A Node's kind isn't required + // to be a NodeKind constant (custom Node subclasses are supported), + // so this works for arbitrary kinds, not just the built-in ones. + // Enter and leave are cached independently in their own dispatcher. $enterFns = []; $leaveFns = []; - foreach ($visitors as $i => $visitor) { - foreach ($allKinds as $kind) { - $enterFns[$i][$kind] = self::extractVisitFn($visitor, $kind, false); - $leaveFns[$i][$kind] = self::extractVisitFn($visitor, $kind, true); - } - } return [ // Declares node, key, parent, path, and ancestors explicitly to // match Visitor::visit()'s `$visitFn($node, $key, $parent, // $path, $ancestors)` call. - 'enter' => static function (Node $node, $key, $parent, $path, $ancestors) use ($skipping, $visitorsCount, $enterFns) { + 'enter' => static function (Node $node, $key, $parent, $path, $ancestors) use ($visitors, $skipping, $visitorsCount, &$enterFns) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] !== null) { continue; } - $fn = $enterFns[$i][$node->kind] ?? null; + $kind = $node->kind; + $fn = $enterFns[$i][$kind] ?? null; if ($fn === null) { + $fn = $enterFns[$i][$kind] = self::extractVisitFn($visitors[$i], $kind, false) ?? false; + } + + if ($fn === false) { continue; } @@ -434,12 +436,17 @@ public static function visitInParallel(array $visitors): array return null; }, - 'leave' => static function (Node $node, $key, $parent, $path, $ancestors) use ($skipping, $visitorsCount, $leaveFns) { + 'leave' => static function (Node $node, $key, $parent, $path, $ancestors) use ($visitors, $skipping, $visitorsCount, &$leaveFns) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] === null) { - $fn = $leaveFns[$i][$node->kind] ?? null; + $kind = $node->kind; + $fn = $leaveFns[$i][$kind] ?? null; + + if ($fn === null) { + $fn = $leaveFns[$i][$kind] = self::extractVisitFn($visitors[$i], $kind, true) ?? false; + } - if ($fn !== null) { + if ($fn !== false) { $result = $fn($node, $key, $parent, $path, $ancestors); if ($result === null) { diff --git a/tests/Language/VisitorTest.php b/tests/Language/VisitorTest.php index 12acd43e0..3919b1868 100644 --- a/tests/Language/VisitorTest.php +++ b/tests/Language/VisitorTest.php @@ -1905,4 +1905,77 @@ public function testThrowsExceptionWhenAddingIntegerToNodeList(): void ] ); } + + /** + * visitInParallel() must still invoke generic enter/leave visitors for a + * Node whose kind is not one of the built-in NodeKind constants (e.g. a + * custom Node subclass defined by a consumer). See + * https://github.com/webonyx/graphql-php/pull/1948 for context: an + * optimization that precomputes enter/leave callbacks per NodeKind + * constant must not silently drop callbacks for unknown kinds. + */ + public function testVisitInParallelCallsGenericVisitorsForCustomKind(): void + { + $customNode = new class([]) extends Node { + public string $kind = 'CustomKind'; + }; + + $visited = []; + + Visitor::visit( + $customNode, + Visitor::visitInParallel([ + [ + 'enter' => static function (Node $node) use (&$visited): void { + $visited[] = ['enter', $node->kind]; + }, + 'leave' => static function (Node $node) use (&$visited): void { + $visited[] = ['leave', $node->kind]; + }, + ], + ]) + ); + + self::assertSame( + [ + ['enter', 'CustomKind'], + ['leave', 'CustomKind'], + ], + $visited + ); + } + + /** Same as above, but for a visitor keyed specifically by the custom kind. */ + public function testVisitInParallelCallsKindSpecificVisitorForCustomKind(): void + { + $customNode = new class([]) extends Node { + public string $kind = 'CustomKind'; + }; + + $visited = []; + + Visitor::visit( + $customNode, + Visitor::visitInParallel([ + [ + 'CustomKind' => [ + 'enter' => static function (Node $node) use (&$visited): void { + $visited[] = ['enter', $node->kind]; + }, + 'leave' => static function (Node $node) use (&$visited): void { + $visited[] = ['leave', $node->kind]; + }, + ], + ], + ]) + ); + + self::assertSame( + [ + ['enter', 'CustomKind'], + ['leave', 'CustomKind'], + ], + $visited + ); + } } From 44f76e8e8d5d044fb2ee838607832ba13250f45c Mon Sep 17 00:00:00 2001 From: OpaqueRock <305760598+OpaqueRock@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:17:16 +0300 Subject: [PATCH 21/36] Address review feedback on Lexer/Visitor optimization - Use a HEREDOC for the tab-character test source, deriving the expected value from it via substr() so input and expectation can't drift apart. - Type-hint $path/$ancestors as array in visitInParallel()'s enter/leave closures, and document $key/$parent via @phpstan-param since PHP 7.4 doesn't support union types. - Add @var docblocks describing the shape of the enter/leave callback caches. - Replace the lazily-built $stopBytes runtime computation in Lexer::readString() with a precomputed STRING_STOP_BYTES constant. --- src/Language/Lexer.php | 21 +++++++++++++-------- src/Language/Visitor.php | 25 ++++++++++++++++++++----- tests/Language/LexerTest.php | 6 ++++-- tests/Language/VisitorTest.php | 2 +- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index 4fbf9d25f..91e30e25f 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -34,6 +34,15 @@ class Lexer private const TOKEN_PIPE = 124; private const TOKEN_BRACE_R = 125; + /** + * Bytes that must be individually inspected while scanning a string body: + * the closing quote ("), the escape character (\), and all C0 control + * characters (0x00-0x1F) except TAB (0x09), which is a legal, unescaped + * SourceCharacter per the GraphQL spec and is scanned through like any + * other ordinary character. + */ + private const STRING_STOP_BYTES = "\"\\\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f"; + public Source $source; /** @phpstan-var ParserOptions */ @@ -398,14 +407,10 @@ private function readString(int $line, int $col, Token $prev): Token // (a single native call) instead of decoding/validating one UTF-8 // character at a time. // - // The stop-byte set below includes ASCII control characters - // (0x00-0x1F), not just backslash/quote/line-terminators, because + // The stop-byte set includes ASCII control characters (0x00-0x1F), + // not just backslash/quote/line-terminators, because // assertValidStringCharacterCode() must still reject most of them. - // TAB (0x09) is excluded: it is a legal, unescaped SourceCharacter - // per the GraphQL spec, so it is scanned through like any other - // ordinary character instead of being individually inspected. - static $stopBytes; - $stopBytes ??= '"\\' . implode('', array_map('chr', array_diff(range(0x00, 0x1F), [0x09]))); + // See self::STRING_STOP_BYTES for details on the excluded TAB (0x09). $body = $this->source->body; $bodyLength = strlen($body); @@ -413,7 +418,7 @@ private function readString(int $line, int $col, Token $prev): Token while (true) { $byteStart = $this->byteStreamPosition; - $runLength = strcspn($body, $stopBytes, $byteStart); + $runLength = strcspn($body, self::STRING_STOP_BYTES, $byteStart); if ($runLength > 0) { $chunk = substr($body, $byteStart, $runLength); diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index 3b31fc38d..4732302c8 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -396,14 +396,23 @@ public static function visitInParallel(array $visitors): array // to be a NodeKind constant (custom Node subclasses are supported), // so this works for arbitrary kinds, not just the built-in ones. // Enter and leave are cached independently in their own dispatcher. + /** @var array> $enterFns Cache of extractVisitFn() results, keyed by visitor index then node kind; `false` means "no enter callback for this visitor/kind". */ $enterFns = []; + /** @var array> $leaveFns Cache of extractVisitFn() results, keyed by visitor index then node kind; `false` means "no leave callback for this visitor/kind". */ $leaveFns = []; return [ - // Declares node, key, parent, path, and ancestors explicitly to - // match Visitor::visit()'s `$visitFn($node, $key, $parent, - // $path, $ancestors)` call. - 'enter' => static function (Node $node, $key, $parent, $path, $ancestors) use ($visitors, $skipping, $visitorsCount, &$enterFns) { + /** + * Declares node, key, parent, path, and ancestors explicitly to + * match Visitor::visit()'s `$visitFn($node, $key, $parent, + * $path, $ancestors)` call. + * + * @phpstan-param string|int|null $key + * @phpstan-param Node|NodeList|null $parent + * @phpstan-param array $path + * @phpstan-param array> $ancestors + */ + 'enter' => static function (Node $node, $key, $parent, array $path, array $ancestors) use ($visitors, $skipping, $visitorsCount, &$enterFns) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] !== null) { continue; @@ -436,7 +445,13 @@ public static function visitInParallel(array $visitors): array return null; }, - 'leave' => static function (Node $node, $key, $parent, $path, $ancestors) use ($visitors, $skipping, $visitorsCount, &$leaveFns) { + /** + * @phpstan-param string|int|null $key + * @phpstan-param Node|NodeList|null $parent + * @phpstan-param array $path + * @phpstan-param array> $ancestors + */ + 'leave' => static function (Node $node, $key, $parent, array $path, array $ancestors) use ($visitors, $skipping, $visitorsCount, &$leaveFns) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] === null) { $kind = $node->kind; diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index e7ae5ff56..bd0028313 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -298,14 +298,16 @@ public function testLexesStrings(): void public function testLexesStringsWithUnescapedTabCharacter(): void { - $source = '"before' . "\t" . 'after"'; + $source = <<<'GRAPHQL' + "before after" + GRAPHQL; self::assertArraySubset( [ 'kind' => Token::STRING, 'start' => 0, 'end' => strlen($source), - 'value' => 'before' . "\t" . 'after', + 'value' => substr($source, 1, -1), // strip the surrounding quotes ], (array) $this->lexOne($source) ); diff --git a/tests/Language/VisitorTest.php b/tests/Language/VisitorTest.php index 3919b1868..2c43802d8 100644 --- a/tests/Language/VisitorTest.php +++ b/tests/Language/VisitorTest.php @@ -1945,7 +1945,7 @@ public function testVisitInParallelCallsGenericVisitorsForCustomKind(): void ); } - /** Same as above, but for a visitor keyed specifically by the custom kind. */ + /** Same as testVisitInParallelCallsGenericVisitorsForCustomKind, but for a visitor keyed specifically by the custom kind. */ public function testVisitInParallelCallsKindSpecificVisitorForCustomKind(): void { $customNode = new class([]) extends Node { From cf27f7eabea9c52ad8210b2918ad554c1c93cc02 Mon Sep 17 00:00:00 2001 From: OpaqueRock <305760598+OpaqueRock@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:43:32 +0300 Subject: [PATCH 22/36] Remove visit() custom-kind bugfix, split into #1952 The visit() undefined-array-key warning fix for custom Node kinds, along with its regression tests, has been split out into a separate PR (webonyx/graphql-php#1952) since it is unrelated to the Lexer/Visitor optimization work here. --- src/Language/Visitor.php | 4 +- tests/Language/VisitorTest.php | 73 ---------------------------------- 2 files changed, 1 insertion(+), 76 deletions(-) diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index 4732302c8..23ce8dc56 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -319,9 +319,7 @@ public static function visit(object $root, array $visitor, ?array $keyMap = null ]; $inList = $node instanceof NodeList; - // Nodes with a kind not present in $visitorKeys (e.g. a custom - // Node subclass) are treated as leaves, i.e. no children. - $keys = $inList ? $node : ($visitorKeys[$node->kind] ?? []); + $keys = ($inList ? $node : $visitorKeys[$node->kind]) ?? []; $index = -1; $edits = []; if ($parent !== null) { diff --git a/tests/Language/VisitorTest.php b/tests/Language/VisitorTest.php index 2c43802d8..12acd43e0 100644 --- a/tests/Language/VisitorTest.php +++ b/tests/Language/VisitorTest.php @@ -1905,77 +1905,4 @@ public function testThrowsExceptionWhenAddingIntegerToNodeList(): void ] ); } - - /** - * visitInParallel() must still invoke generic enter/leave visitors for a - * Node whose kind is not one of the built-in NodeKind constants (e.g. a - * custom Node subclass defined by a consumer). See - * https://github.com/webonyx/graphql-php/pull/1948 for context: an - * optimization that precomputes enter/leave callbacks per NodeKind - * constant must not silently drop callbacks for unknown kinds. - */ - public function testVisitInParallelCallsGenericVisitorsForCustomKind(): void - { - $customNode = new class([]) extends Node { - public string $kind = 'CustomKind'; - }; - - $visited = []; - - Visitor::visit( - $customNode, - Visitor::visitInParallel([ - [ - 'enter' => static function (Node $node) use (&$visited): void { - $visited[] = ['enter', $node->kind]; - }, - 'leave' => static function (Node $node) use (&$visited): void { - $visited[] = ['leave', $node->kind]; - }, - ], - ]) - ); - - self::assertSame( - [ - ['enter', 'CustomKind'], - ['leave', 'CustomKind'], - ], - $visited - ); - } - - /** Same as testVisitInParallelCallsGenericVisitorsForCustomKind, but for a visitor keyed specifically by the custom kind. */ - public function testVisitInParallelCallsKindSpecificVisitorForCustomKind(): void - { - $customNode = new class([]) extends Node { - public string $kind = 'CustomKind'; - }; - - $visited = []; - - Visitor::visit( - $customNode, - Visitor::visitInParallel([ - [ - 'CustomKind' => [ - 'enter' => static function (Node $node) use (&$visited): void { - $visited[] = ['enter', $node->kind]; - }, - 'leave' => static function (Node $node) use (&$visited): void { - $visited[] = ['leave', $node->kind]; - }, - ], - ], - ]) - ); - - self::assertSame( - [ - ['enter', 'CustomKind'], - ['leave', 'CustomKind'], - ], - $visited - ); - } } From caa28ddd8acee9a66bd47def6ba6f1d31c23ff04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Moitie=CC=81?= Date: Wed, 22 Jul 2026 16:47:12 +0100 Subject: [PATCH 23/36] Point scalar override comment at motivating lighthouse issue Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Type/Schema.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Schema.php b/src/Type/Schema.php index da186022c..3099315b3 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -374,7 +374,7 @@ private function getScalarOverrides(): array { if ($this->scalarOverrides === null) { // When overrides are given explicitly, the scan of types is unnecessary. - // This keeps a lazily provided types callable unresolved, see https://github.com/webonyx/graphql-php/issues/1874. + // This keeps a lazily provided types callable unresolved, see https://github.com/nuwave/lighthouse/issues/2771. $explicitScalarOverrides = $this->config->scalarOverrides; if ($explicitScalarOverrides !== null) { return $this->scalarOverrides = $explicitScalarOverrides; From 9b0d5cc168943c1af32fcf8eef34d32b43c6354c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Moitie=CC=81?= Date: Wed, 22 Jul 2026 17:02:02 +0100 Subject: [PATCH 24/36] Move scalar override validation into schema validation Deep checks (must be a ScalarType named after a built-in scalar) now run during schema validation rather than in the SchemaConfig setter. The setter keeps only a dev-time assert to catch basic misuse and narrow the type for the name-keyed map, keeping runtime config cheap. Also catches misuse via the public $scalarOverrides property, which bypasses the setter. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Type/Schema.php | 1 + src/Type/SchemaConfig.php | 20 +++++++---------- src/Type/SchemaValidationContext.php | 24 +++++++++++++++++++++ tests/Type/ScalarOverridesTest.php | 32 ++++++++++++++++++++++------ 4 files changed, 59 insertions(+), 18 deletions(-) diff --git a/src/Type/Schema.php b/src/Type/Schema.php index 3099315b3..46bebbe68 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -620,6 +620,7 @@ public function validate(): array // Validate the schema, producing a list of errors. $context = new SchemaValidationContext($this); $context->validateRootTypes(); + $context->validateScalarOverrides(); $context->validateDirectives(); $context->validateTypes(); diff --git a/src/Type/SchemaConfig.php b/src/Type/SchemaConfig.php index 3b925cbe8..18fd21992 100644 --- a/src/Type/SchemaConfig.php +++ b/src/Type/SchemaConfig.php @@ -282,9 +282,10 @@ public function getScalarOverrides(): ?array } /** - * @param array|null $scalarOverrides + * Deeper validation (that each override is a ScalarType named after a built-in scalar) + * runs during schema validation, see SchemaValidationContext::validateScalarOverrides(). * - * @throws InvariantViolation + * @param array|null $scalarOverrides * * @api */ @@ -298,17 +299,12 @@ public function setScalarOverrides(?array $scalarOverrides): self $this->scalarOverrides = []; foreach ($scalarOverrides as $scalarOverride) { + // Deeper validation (correct scalar names, actually being a ScalarType) happens + // during schema validation, see SchemaValidationContext::validateScalarOverrides(). + // This assertion just catches basic misuse at development time and narrows the type + // for the name-keyed map below. // @phpstan-ignore-next-line not strictly enforceable unless PHP gets generics - if (! $scalarOverride instanceof ScalarType) { - $scalarTypeClass = ScalarType::class; - $notScalarType = Utils::printSafe($scalarOverride); - throw new InvariantViolation("Expected instanceof {$scalarTypeClass}, got: {$notScalarType}."); - } - - if (! Type::isBuiltInScalarName($scalarOverride->name)) { - $builtInScalarNames = implode(', ', Type::BUILT_IN_SCALAR_NAMES); - throw new InvariantViolation("Expected scalar override to be named after a built-in scalar ({$builtInScalarNames}), got: {$scalarOverride->name}."); - } + assert($scalarOverride instanceof ScalarType, 'Expected instanceof ' . ScalarType::class . ', got: ' . Utils::printSafe($scalarOverride)); $this->scalarOverrides[$scalarOverride->name] = $scalarOverride; } diff --git a/src/Type/SchemaValidationContext.php b/src/Type/SchemaValidationContext.php index d683bdf7b..f717210bf 100644 --- a/src/Type/SchemaValidationContext.php +++ b/src/Type/SchemaValidationContext.php @@ -77,6 +77,30 @@ public function validateRootTypes(): void $this->schema->getSubscriptionType(); } + public function validateScalarOverrides(): void + { + $scalarOverrides = $this->schema->getConfig()->scalarOverrides; + if ($scalarOverrides === null) { + return; + } + + foreach ($scalarOverrides as $scalarOverride) { + // @phpstan-ignore-next-line not strictly enforceable unless PHP gets generics + if (! $scalarOverride instanceof ScalarType) { + $scalarTypeClass = ScalarType::class; + $notScalarType = Utils::printSafe($scalarOverride); + $this->reportError("Expected scalar override to be instanceof {$scalarTypeClass}, got: {$notScalarType}."); + + continue; + } + + if (! Type::isBuiltInScalarName($scalarOverride->name)) { + $builtInScalarNames = implode(', ', Type::BUILT_IN_SCALAR_NAMES); + $this->reportError("Expected scalar override to be named after a built-in scalar ({$builtInScalarNames}), got: {$scalarOverride->name}.", $scalarOverride->astNode); + } + } + } + /** @param array|Node|null $nodes */ public function reportError(string $message, $nodes = null): void { diff --git a/tests/Type/ScalarOverridesTest.php b/tests/Type/ScalarOverridesTest.php index 435c64a5b..28c75d338 100644 --- a/tests/Type/ScalarOverridesTest.php +++ b/tests/Type/ScalarOverridesTest.php @@ -685,26 +685,46 @@ public function testSetScalarOverridesNullResetsOverrides(): void self::assertNull($config->getScalarOverrides()); } - public function testSetScalarOverridesRejectsNonScalarTypes(): void + public function testSetScalarOverridesAssertsScalarTypesAtDevelopmentTime(): void { $config = new SchemaConfig(); - $this->expectException(InvariantViolation::class); + $this->expectException(\AssertionError::class); // @phpstan-ignore-next-line intentionally wrong $config->setScalarOverrides([self::createQueryType()]); } - public function testSetScalarOverridesRejectsNonBuiltInScalarNames(): void + public function testSchemaValidationRejectsNonBuiltInScalarNames(): void { $customScalar = new CustomScalarType([ 'name' => 'DateTime', 'serialize' => static fn ($value): string => (string) $value, ]); - $config = new SchemaConfig(); + $schema = new Schema([ + 'query' => self::createQueryType(), + 'scalarOverrides' => [$customScalar], + ]); + + $errors = $schema->validate(); + + self::assertCount(1, $errors); + self::assertStringContainsString('named after a built-in scalar', $errors[0]->getMessage()); + } + + public function testSchemaValidationRejectsNonScalarOverridesSetViaPublicProperty(): void + { + $config = SchemaConfig::create()->setQuery(self::createQueryType()); + // Bypass the setter's normalization to simulate direct misuse of the public property. + // @phpstan-ignore-next-line intentionally wrong + $config->scalarOverrides = [self::createQueryType()]; + + $schema = new Schema($config); + + $errors = $schema->validate(); - $this->expectException(InvariantViolation::class); - $config->setScalarOverrides([$customScalar]); + self::assertCount(1, $errors); + self::assertStringContainsString('instanceof', $errors[0]->getMessage()); } /** @see https://github.com/webonyx/graphql-php/issues/1874 */ From 31d6d9e1aa5b97693be2c986f9c276375466b59e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:08:32 +0000 Subject: [PATCH 25/36] Autofix --- docs/class-reference.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/class-reference.md b/docs/class-reference.md index e0d60e0c7..81cd48e81 100644 --- a/docs/class-reference.md +++ b/docs/class-reference.md @@ -923,9 +923,10 @@ function getScalarOverrides(): ?array ```php /** - * @param array|null $scalarOverrides + * Deeper validation (that each override is a ScalarType named after a built-in scalar) + * runs during schema validation, see SchemaValidationContext::validateScalarOverrides(). * - * @throws InvariantViolation + * @param array|null $scalarOverrides * * @api */ From 65089c97bf82da951f7f40c7e42df1c3c70866e7 Mon Sep 17 00:00:00 2001 From: OpaqueRock <305760598+OpaqueRock@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:24:17 +0300 Subject: [PATCH 26/36] Remove Visitor optimization, split into #1953 The Visitor::visitInParallel() enter/leave callback caching optimization has been split out into a separate PR (webonyx/graphql-php#1953), so it can be reviewed independently of the Lexer::readString optimization here. --- src/Language/Visitor.php | 66 ++++++++++------------------------------ 1 file changed, 16 insertions(+), 50 deletions(-) diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index 23ce8dc56..d1101c68a 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -383,51 +383,24 @@ public static function visitInParallel(array $visitors): array $visitorsCount = count($visitors); $skipping = new \SplFixedArray($visitorsCount); - // extractVisitFn() otherwise re-derives the same enter/leave callable - // via array lookups on every single node visited, for every visitor - - // O(nodes x visitors) calls - even though its result for a given - // (visitor, kind, isLeaving) triple never changes during one - // traversal. Cache it per visitor/kind on first encounter instead, - // using `false` as a "no callback" sentinel so a plain `??` lookup - // can tell "not cached yet" (missing key -> null) apart from - // "cached, nothing to call" (`false`). A Node's kind isn't required - // to be a NodeKind constant (custom Node subclasses are supported), - // so this works for arbitrary kinds, not just the built-in ones. - // Enter and leave are cached independently in their own dispatcher. - /** @var array> $enterFns Cache of extractVisitFn() results, keyed by visitor index then node kind; `false` means "no enter callback for this visitor/kind". */ - $enterFns = []; - /** @var array> $leaveFns Cache of extractVisitFn() results, keyed by visitor index then node kind; `false` means "no leave callback for this visitor/kind". */ - $leaveFns = []; - return [ - /** - * Declares node, key, parent, path, and ancestors explicitly to - * match Visitor::visit()'s `$visitFn($node, $key, $parent, - * $path, $ancestors)` call. - * - * @phpstan-param string|int|null $key - * @phpstan-param Node|NodeList|null $parent - * @phpstan-param array $path - * @phpstan-param array> $ancestors - */ - 'enter' => static function (Node $node, $key, $parent, array $path, array $ancestors) use ($visitors, $skipping, $visitorsCount, &$enterFns) { + 'enter' => static function (Node $node) use ($visitors, $skipping, $visitorsCount) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] !== null) { continue; } - $kind = $node->kind; - $fn = $enterFns[$i][$kind] ?? null; + $fn = self::extractVisitFn( + $visitors[$i], + $node->kind, + false + ); if ($fn === null) { - $fn = $enterFns[$i][$kind] = self::extractVisitFn($visitors[$i], $kind, false) ?? false; - } - - if ($fn === false) { continue; } - $result = $fn($node, $key, $parent, $path, $ancestors); + $result = $fn(...func_get_args()); if ($result === null) { continue; @@ -443,24 +416,17 @@ public static function visitInParallel(array $visitors): array return null; }, - /** - * @phpstan-param string|int|null $key - * @phpstan-param Node|NodeList|null $parent - * @phpstan-param array $path - * @phpstan-param array> $ancestors - */ - 'leave' => static function (Node $node, $key, $parent, array $path, array $ancestors) use ($visitors, $skipping, $visitorsCount, &$leaveFns) { + 'leave' => static function (Node $node) use ($visitors, $skipping, $visitorsCount) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] === null) { - $kind = $node->kind; - $fn = $leaveFns[$i][$kind] ?? null; - - if ($fn === null) { - $fn = $leaveFns[$i][$kind] = self::extractVisitFn($visitors[$i], $kind, true) ?? false; - } - - if ($fn !== false) { - $result = $fn($node, $key, $parent, $path, $ancestors); + $fn = self::extractVisitFn( + $visitors[$i], + $node->kind, + true + ); + + if ($fn !== null) { + $result = $fn(...func_get_args()); if ($result === null) { continue; From b67de08a46d42a5f606e455851882c27d3a3aba8 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Thu, 23 Jul 2026 08:59:54 +0200 Subject: [PATCH 27/36] Update CHANGELOG for v15.35.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with Claude Code --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b05cef8f..c8af5692e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +## v15.35.0 + ### Added - Allow setting per-schema built-in scalar overrides explicitly via `SchemaConfig` option `scalarOverrides`, avoiding the resolution of lazily provided `types` on built-in scalar lookups https://github.com/webonyx/graphql-php/pull/1927 From f9e81d756a1c8382c87428c0d31869185c86a427 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Thu, 23 Jul 2026 10:59:12 +0200 Subject: [PATCH 28/36] Clarify release notes format in AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with Claude Code --- .ai/AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.ai/AGENTS.md b/.ai/AGENTS.md index 61b146186..a7ced06e1 100644 --- a/.ai/AGENTS.md +++ b/.ai/AGENTS.md @@ -46,8 +46,8 @@ After merging a PR and releasing: 2. Pull latest master: `git pull` 3. Update CHANGELOG.md: move the entry from `## Unreleased` into a new versioned section (e.g. `## v15.32.0`), add the PR URL as a reference, leave an empty `## Unreleased` at the top 4. Commit and push the CHANGELOG update to master -5. Create the release: `gh release create vX.Y.Z --repo webonyx/graphql-php --title "vX.Y.Z" --notes "..."` -6. Comment on the PR thanking the author and linking the release: `gh pr comment --repo webonyx/graphql-php --body "Thanks @! Released as [vX.Y.Z]()."` +5. Create the release: `gh release create vX.Y.Z --repo=webonyx/graphql-php --title="vX.Y.Z" --notes=""` +6. Comment on the PR thanking the author and linking the release: `gh pr comment --repo=webonyx/graphql-php --body="Thanks @! Released as [vX.Y.Z]()."` Version bump rules (semver): - New feature β†’ minor bump (v15.31.x β†’ v15.32.0) From 031cb5258883124bede559f773d8ab92a2436c04 Mon Sep 17 00:00:00 2001 From: OpaqueRock <305760598+OpaqueRock@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:42:40 +0300 Subject: [PATCH 29/36] Fix loose switch comparison mistaking NUL byte for EOF in string escapes PHP's switch uses loose comparison, so `case null` also matches code 0, causing a backslash followed by a raw NUL byte to be treated as EOF instead of an invalid escape sequence. Check for EOF with a strict comparison before the switch instead. --- src/Language/Lexer.php | 8 +++++--- tests/Language/LexerTest.php | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index 91e30e25f..d64f71ce2 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -465,6 +465,11 @@ private function readString(int $line, int $col, Token $prev): Token $this->moveStringCursor(1, 1); [, $code] = $this->readChar(true); + // null means EOF; checked separately since switch is not a strict comparison. + if ($code === null) { + break; + } + switch ($code) { case 34: $value .= '"'; @@ -517,9 +522,6 @@ private function readString(int $line, int $col, Token $prev): Token $value .= Utils::chr($code); break; - // null means EOF, will delegate to general handling of unterminated strings - case null: - break; default: $chr = Utils::chr($code); throw new SyntaxError($this->source, $this->position - 1, "Invalid character escape sequence: \\{$chr}"); diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index bd0028313..c913928ca 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -440,6 +440,7 @@ public static function reportsUsefulStringErrors(): iterable yield ['"bad \\uD835\\uXXXX esc"', 'Invalid UTF-16 trailing surrogate: \\uXXXX', self::loc(1, 13)]; yield ['"bad \\uD835\\uFXXX esc"', 'Invalid UTF-16 trailing surrogate: \\uFXXX', self::loc(1, 13)]; yield ['"bad \\uD835\\uXXXF esc"', 'Invalid UTF-16 trailing surrogate: \\uXXXF', self::loc(1, 13)]; + yield ['"bad \\' . "\0" . ' esc"', 'Invalid character escape sequence: \\' . "\0", self::loc(1, 7)]; } /** From 240f5b903b028d6916323fd3fd3fd6f43894eeed Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:56:41 +0000 Subject: [PATCH 30/36] Update dependency friendsofphp/php-cs-fixer to v3.95.17 (#1955) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 9b84c0584..fb2943c60 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "amphp/http-server": "^2.1 || ^3", "dms/phpunit-arraysubset-asserts": "dev-master", "ergebnis/composer-normalize": "^2.28", - "friendsofphp/php-cs-fixer": "3.95.15", + "friendsofphp/php-cs-fixer": "3.95.17", "mll-lab/php-cs-fixer-config": "5.13.0", "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", From cc722fdc06a9392d8b8fe75604d084f8a8e9b1fa Mon Sep 17 00:00:00 2001 From: OpaqueRock <305760598+OpaqueRock@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:55:00 +0300 Subject: [PATCH 31/36] Use mb_strlen for codepoint counting, simplify tab-character test --- src/Language/Lexer.php | 9 +++------ tests/Language/LexerTest.php | 4 ++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index d64f71ce2..928a49550 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -423,12 +423,9 @@ private function readString(int $line, int $col, Token $prev): Token if ($runLength > 0) { $chunk = substr($body, $byteStart, $runLength); $value .= $chunk; - // $this->position counts decoded characters, not bytes: count the - // non-continuation bytes (i.e. NOT matching 10xxxxxx) in the chunk to - // keep it accurate for multi-byte UTF-8 content. - $continuationBytes = preg_match_all('/[\x80-\xBF]/', $chunk); - assert(is_int($continuationBytes), 'Pattern is valid, so preg_match_all() cannot fail'); - $this->position += strlen($chunk) - $continuationBytes; + // $this->position counts decoded characters, not bytes: use + // mb_strlen() to get the codepoint count for multi-byte UTF-8 content. + $this->position += mb_strlen($chunk, 'UTF-8'); $this->byteStreamPosition = $byteStart + $runLength; } diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index c913928ca..fa7969978 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -306,8 +306,8 @@ public function testLexesStringsWithUnescapedTabCharacter(): void [ 'kind' => Token::STRING, 'start' => 0, - 'end' => strlen($source), - 'value' => substr($source, 1, -1), // strip the surrounding quotes + 'end' => 14, + 'value' => "before\tafter", ], (array) $this->lexOne($source) ); From 79f830499d76d8265245dbb1d6c0e973cd6a5f97 Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Sat, 25 Jul 2026 09:31:04 +0200 Subject: [PATCH 32/36] perf(amp): avoid fibers in future adapter --- CHANGELOG.md | 4 + .../Promise/Adapter/AmpFutureAdapter.php | 125 ++++++---- .../Executor/Promise/AmpFutureAdapterTest.php | 215 +++++++++++++++++- 3 files changed, 294 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8af5692e..38ffbe270 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 +### Fixed + +- Avoid creating an AMPHP Fiber for each `AmpFutureAdapter` continuation and aggregate https://github.com/webonyx/graphql-php/pull/1954 + ## v15.35.0 ### Added diff --git a/src/Executor/Promise/Adapter/AmpFutureAdapter.php b/src/Executor/Promise/Adapter/AmpFutureAdapter.php index 0a75e7cce..c39e9895c 100644 --- a/src/Executor/Promise/Adapter/AmpFutureAdapter.php +++ b/src/Executor/Promise/Adapter/AmpFutureAdapter.php @@ -8,9 +8,6 @@ use GraphQL\Executor\Promise\Promise; use GraphQL\Executor\Promise\PromiseAdapter; -use function Amp\async; -use function Amp\Future\await; - /** * Allows integration with amphp/amp v3 (fiber-based futures). * @@ -46,25 +43,38 @@ public function then(Promise $promise, ?callable $onFulfilled = null, ?callable { $future = $promise->adoptedPromise; - $next = async(static function () use ($future, $onFulfilled, $onRejected) { - try { - $value = $future->await(); - } catch (\Throwable $reason) { - if ($onRejected === null) { - throw $reason; + $deferred = new DeferredFuture(); + + static::observeFuture( + $future, + static function ($value) use ($deferred, $onFulfilled): void { + try { + static::resolveDeferred( + $deferred, + $onFulfilled === null + ? $value + : $onFulfilled($value) + ); + } catch (\Throwable $fulfillmentException) { + $deferred->error($fulfillmentException); } + }, + static function (\Throwable $exception) use ($deferred, $onRejected): void { + if ($onRejected === null) { + $deferred->error($exception); - return static::unwrapResult($onRejected($reason)); - } + return; + } - if ($onFulfilled === null) { - return $value; + try { + static::resolveDeferred($deferred, $onRejected($exception)); + } catch (\Throwable $rejectionException) { + $deferred->error($rejectionException); + } } + ); - return static::unwrapResult($onFulfilled($value)); - }); - - return new Promise($next, $this); + return new Promise($deferred->getFuture(), $this); } /** @throws InvariantViolation */ @@ -127,8 +137,9 @@ public function all(iterable $promisesOrValues): Promise ? $promisesOrValues : iterator_to_array($promisesOrValues); - /** @var array> $futures */ - $futures = []; + $deferredFuture = new DeferredFuture(); + $future = $deferredFuture->getFuture(); + $remaining = 0; foreach ($items as $key => $item) { if ($item instanceof Promise) { @@ -136,21 +147,38 @@ public function all(iterable $promisesOrValues): Promise } if ($item instanceof Future) { - $futures[$key] = $item; + ++$remaining; + static::observeFuture( + $item, + static function ($value) use (&$items, $key, $deferredFuture, $future, &$remaining): void { + if ($future->isComplete()) { + return; + } + + $items[$key] = $value; + --$remaining; + if ($remaining !== 0) { + return; + } + + $deferredFuture->complete($items); + }, + static function (\Throwable $exception) use ($deferredFuture, $future): void { + if ($future->isComplete()) { + return; + } + + $deferredFuture->error($exception); + } + ); } } - $combined = async(static function () use ($items, $futures): array { - if ($futures === []) { - return $items; - } - - $resolved = await($futures); - - return array_replace($items, $resolved); - }); + if ($remaining === 0 && ! $future->isComplete()) { + $deferredFuture->complete($items); + } - return new Promise($combined, $this); + return new Promise($future, $this); } /** @@ -164,13 +192,15 @@ protected static function resolveDeferred(DeferredFuture $deferred, $value): voi } if ($value instanceof Future) { - async(static function () use ($deferred, $value): void { - try { - $deferred->complete($value->await()); - } catch (\Throwable $exception) { + static::observeFuture( + $value, + static function ($value) use ($deferred): void { + $deferred->complete($value); + }, + static function (\Throwable $exception) use ($deferred): void { $deferred->error($exception); } - }); + ); return; } @@ -179,20 +209,21 @@ protected static function resolveDeferred(DeferredFuture $deferred, $value): voi } /** - * @param mixed $value + * @template T * - * @return mixed + * @param Future $future + * @param \Closure(T): void $onFulfilled + * @param \Closure(\Throwable): void $onRejected */ - protected static function unwrapResult($value) + protected static function observeFuture(Future $future, \Closure $onFulfilled, \Closure $onRejected): void { - if ($value instanceof Promise) { - $value = $value->adoptedPromise; - } - - if ($value instanceof Future) { - return $value->await(); - } - - return $value; + $future + ->map(static function ($value) use ($onFulfilled): void { + $onFulfilled($value); + }) + ->catch(static function (\Throwable $exception) use ($onRejected): void { + $onRejected($exception); + }) + ->ignore(); } } diff --git a/tests/Executor/Promise/AmpFutureAdapterTest.php b/tests/Executor/Promise/AmpFutureAdapterTest.php index a8bbb4663..136e3349e 100644 --- a/tests/Executor/Promise/AmpFutureAdapterTest.php +++ b/tests/Executor/Promise/AmpFutureAdapterTest.php @@ -72,6 +72,78 @@ static function ($value) use (&$result): void { self::assertSame(1, $result); } + public function testThenUnwrapsFutureReturnedByFulfillmentCallback(): void + { + $ampAdapter = new AmpFutureAdapter(); + $deferred = new DeferredFuture(); + $promise = $ampAdapter->convertThenable(Future::complete(1)); + + $resultPromise = $ampAdapter->then($promise, static function ($value) use ($deferred): Future { + self::assertSame(1, $value); + + return $deferred->getFuture(); + }); + + $deferred->complete(2); + + self::assertSame(2, $resultPromise->adoptedPromise->await()); + } + + public function testThenDoesNotInvokeRejectionCallbackForFulfillmentCallbackException(): void + { + $ampAdapter = new AmpFutureAdapter(); + $promise = $ampAdapter->convertThenable(Future::complete()); + + $resultPromise = $ampAdapter->then( + $promise, + static function ($value): void { + self::assertNull($value); + + throw new \RuntimeException('fulfillment failed'); + }, + static function (\Throwable $reason): void { + self::fail('The rejection callback must not run for a fulfillment callback exception.'); + } + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('fulfillment failed'); + + $resultPromise->adoptedPromise->await(); + } + + public function testThenRejectsWhenNoRejectionCallbackIsProvided(): void + { + $ampAdapter = new AmpFutureAdapter(); + $promise = $ampAdapter->convertThenable(Future::error(new \RuntimeException('failed'))); + $resultPromise = $ampAdapter->then($promise); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('failed'); + + $resultPromise->adoptedPromise->await(); + } + + public function testThenRejectsWhenRejectionCallbackThrows(): void + { + $ampAdapter = new AmpFutureAdapter(); + $promise = $ampAdapter->convertThenable(Future::error(new \RuntimeException('failed'))); + $resultPromise = $ampAdapter->then( + $promise, + null, + static function (\Throwable $reason): void { + self::assertSame('failed', $reason->getMessage()); + + throw new \RuntimeException('rejection failed'); + } + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('rejection failed'); + + $resultPromise->adoptedPromise->await(); + } + public function testCreate(): void { $ampAdapter = new AmpFutureAdapter(); @@ -90,6 +162,31 @@ public function testCreate(): void self::assertSame(1, $result); } + public function testCreateUnwrapsPromise(): void + { + $ampAdapter = new AmpFutureAdapter(); + $nestedPromise = $ampAdapter->createFulfilled(1); + $promise = $ampAdapter->create(static function ($resolve) use ($nestedPromise): void { + $resolve($nestedPromise); + }); + + self::assertSame(1, $promise->adoptedPromise->await()); + } + + public function testCreateRejectsWhenNestedFutureFails(): void + { + $ampAdapter = new AmpFutureAdapter(); + $future = Future::error(new \RuntimeException('failed')); + $promise = $ampAdapter->create(static function ($resolve) use ($future): void { + $resolve($future); + }); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('failed'); + + $promise->adoptedPromise->await(); + } + public function testCreateFulfilled(): void { $ampAdapter = new AmpFutureAdapter(); @@ -126,6 +223,27 @@ static function ($error) use (&$exception): void { self::assertSame('I am a bad promise', $exception->getMessage()); } + public function testThenUnwrapsFutureReturnedByRejectionCallback(): void + { + $ampAdapter = new AmpFutureAdapter(); + $deferred = new DeferredFuture(); + $promise = $ampAdapter->convertThenable(Future::error(new \RuntimeException('failed'))); + + $resultPromise = $ampAdapter->then( + $promise, + null, + static function (\Throwable $reason) use ($deferred): Future { + self::assertSame('failed', $reason->getMessage()); + + return $deferred->getFuture(); + } + ); + + $deferred->complete('recovered'); + + self::assertSame('recovered', $resultPromise->adoptedPromise->await()); + } + public function testAll(): void { $ampAdapter = new AmpFutureAdapter(); @@ -138,11 +256,23 @@ public function testAll(): void self::assertSame([1, 2, 3], $result); } - public function testAllShouldPreserveTheOrderOfTheArrayWhenResolvingAsyncPromises(): void + public function testAllResolvesEmptyIterable(): void + { + $ampAdapter = new AmpFutureAdapter(); + + self::assertSame([], $ampAdapter->all([])->adoptedPromise->await()); + } + + public function testAllShouldPreserveKeysWhenResolvingAsyncPromises(): void { $ampAdapter = new AmpFutureAdapter(); $deferred = new DeferredFuture(); - $promises = [Future::complete(1), 2, $deferred->getFuture(), Future::complete(4)]; + $promises = [ + 'first' => Future::complete(1), + 'second' => 2, + 'third' => $deferred->getFuture(), + 'fourth' => Future::complete(4), + ]; $allPromise = $ampAdapter->all($promises); @@ -150,6 +280,85 @@ public function testAllShouldPreserveTheOrderOfTheArrayWhenResolvingAsyncPromise $result = $allPromise->adoptedPromise->await(); - self::assertSame([1, 2, 3, 4], $result); + self::assertSame([ + 'first' => 1, + 'second' => 2, + 'third' => 3, + 'fourth' => 4, + ], $result); + } + + public function testAllAcceptsPromisesInIterables(): void + { + $ampAdapter = new AmpFutureAdapter(); + $promise = $ampAdapter->createFulfilled(1); + $promises = (static function () use ($promise): \Generator { + yield 'promise' => $promise; + yield 'future' => Future::complete(2); + })(); + + $allPromise = $ampAdapter->all($promises); + + self::assertSame(['promise' => 1, 'future' => 2], $allPromise->adoptedPromise->await()); + } + + public function testAllRejectsWhenOneFutureFails(): void + { + $ampAdapter = new AmpFutureAdapter(); + $deferred = new DeferredFuture(); + $allPromise = $ampAdapter->all([ + 'resolved' => Future::complete(1), + 'rejected' => $deferred->getFuture(), + ]); + + $deferred->error(new \RuntimeException('failed')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('failed'); + + $allPromise->adoptedPromise->await(); + } + + public function testAllRemainsRejectedWhenAnotherFutureResolves(): void + { + $ampAdapter = new AmpFutureAdapter(); + $rejected = new DeferredFuture(); + $resolved = new DeferredFuture(); + $allPromise = $ampAdapter->all([$rejected->getFuture(), $resolved->getFuture()]); + + $rejected->error(new \RuntimeException('failed')); + $resolved->complete('resolved'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('failed'); + + $allPromise->adoptedPromise->await(); + } + + public function testAllIgnoresOtherFuturesAfterRejection(): void + { + $ampAdapter = new AmpFutureAdapter(); + $rejected = new DeferredFuture(); + $resolved = new DeferredFuture(); + $alsoRejected = new DeferredFuture(); + $allPromise = $ampAdapter->all([ + $rejected->getFuture(), + $resolved->getFuture(), + $alsoRejected->getFuture(), + ]); + + $rejected->error(new \RuntimeException('failed')); + + try { + $allPromise->adoptedPromise->await(); + self::fail('Expected aggregate rejection.'); + } catch (\RuntimeException $exception) { + self::assertSame('failed', $exception->getMessage()); + } + + $resolved->complete('resolved'); + $alsoRejected->error(new \RuntimeException('also failed')); + + async(static function (): void {})->await(); } } From ae93bd6f2f52f46b109b64855c12374fe7d2cd86 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:10:49 +0000 Subject: [PATCH 33/36] Update dependency phpstan/phpstan to v2.2.6 (#1956) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index fb2943c60..a877db6eb 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "2.2.5", + "phpstan/phpstan": "2.2.6", "phpstan/phpstan-phpunit": "2.0.18", "phpstan/phpstan-strict-rules": "2.0.12", "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", From b5b209b6644f8be0c2ca73b44624ab4eb6423f95 Mon Sep 17 00:00:00 2001 From: Sam Reed Date: Mon, 27 Jul 2026 19:00:11 +0100 Subject: [PATCH 34/36] .gitattributes: Add .worktreeinclude --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 9a1d91a0e..835ceb734 100644 --- a/.gitattributes +++ b/.gitattributes @@ -26,3 +26,4 @@ /rector.php export-ignore /renovate.json export-ignore /UPGRADE.md export-ignore +/.worktreeinclude export-ignore From ab0ccf89865645279ec8a9d716ca2af3f35315d6 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 28 Jul 2026 09:04:20 +0200 Subject: [PATCH 35/36] Add tests for full Lexer.php coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the single-char escape sequences (\/, \b, \f, \n, \r, \t), the & punctuator token, and multi-byte UTF-8 in readChar(). πŸ€– Generated with Claude Code --- tests/Language/LexerTest.php | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index fa7969978..313abdf45 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -125,6 +125,14 @@ public function testSkipsWhitespacesAndComments(): void $example3 = ',,,foo,,,'; self::assertArraySubset($expected, (array) $this->lexOne($example3)); + + self::assertArraySubset( + [ + 'kind' => Token::NAME, + 'value' => 'foo', + ], + (array) $this->lexOne("#comment with 2-byte ΓΌ and 4-byte πŸ˜€\nfoo") + ); } /** @see it('errors respect whitespace') */ @@ -296,6 +304,19 @@ public function testLexesStrings(): void ); } + public function testLexesStringSingleCharEscapeSequences(): void + { + self::assertArraySubset( + [ + 'kind' => Token::STRING, + 'start' => 0, + 'end' => 22, + 'value' => "\" / \x08 \f \n \r \t", + ], + (array) $this->lexOne('"\\" \\/ \\b \\f \\n \\r \\t"') + ); + } + public function testLexesStringsWithUnescapedTabCharacter(): void { $source = <<<'GRAPHQL' @@ -584,6 +605,10 @@ public function testLexesPunctuation(): void ['kind' => Token::DOLLAR, 'start' => 0, 'end' => 1, 'value' => null], (array) $this->lexOne('$') ); + self::assertArraySubset( + ['kind' => Token::AMP, 'start' => 0, 'end' => 1, 'value' => null], + (array) $this->lexOne('&') + ); self::assertArraySubset( ['kind' => Token::PAREN_L, 'start' => 0, 'end' => 1, 'value' => null], (array) $this->lexOne('(') From be55406ba5da7ddc3b50b88dc70d31f975538688 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 28 Jul 2026 10:46:53 +0200 Subject: [PATCH 36/36] Release v15.36.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with Claude Code --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8af5692e..a3bba66ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +## v15.36.0 + +### Changed + +- Optimize `Lexer::readString()` to bulk-scan ordinary bytes with `strcspn()` instead of decoding one UTF-8 character at a time https://github.com/webonyx/graphql-php/pull/1948 + ## v15.35.0 ### Added