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) 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 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/CHANGELOG.md b/CHANGELOG.md index 86c386093..fdbe76ddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,34 @@ 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.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 + +- 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.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 + +- Make Promise and PromiseAdapter generic over the adopted promise type https://github.com/webonyx/graphql-php/pull/1941 + ## v15.33.1 ### Fixed 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 diff --git a/composer.json b/composer.json index c646329b2..a877db6eb 100644 --- a/composer.json +++ b/composer.json @@ -18,14 +18,14 @@ "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.17", "mll-lab/php-cs-fixer-config": "5.13.0", "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "2.2.4", - "phpstan/phpstan-phpunit": "2.0.17", - "phpstan/phpstan-strict-rules": "2.0.11", + "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", "psr/http-message": "^1 || ^2", "react/http": "^1.6", diff --git a/docs/class-reference.md b/docs/class-reference.md index fb0cf6bd1..81cd48e81 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,27 @@ function getTypes() function setTypes($types): self ``` +```php +/** + * @return array|null + * + * @api + */ +function getScalarOverrides(): ?array +``` + +```php +/** + * Deeper validation (that each override is a ScalarType named after a built-in scalar) + * runs during schema validation, see SchemaValidationContext::validateScalarOverrides(). + * + * @param array|null $scalarOverrides + * + * @api + */ +function setScalarOverrides(?array $scalarOverrides): self +``` + ```php /** * @return array|null @@ -1805,6 +1827,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 +1848,8 @@ function isThenable($value): bool * * @param mixed $thenable * + * @phpstan-return Promise + * * @api */ function convertThenable($thenable): GraphQL\Executor\Promise\Promise @@ -1834,6 +1860,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 +1879,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 +1892,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 +1901,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 +1917,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/docs/schema-definition.md b/docs/schema-definition.md index 5c0ffe1d9..0a2b23dfc 100644 --- a/docs/schema-definition.md +++ b/docs/schema-definition.md @@ -78,14 +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. | -| 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 diff --git a/docs/type-definitions/scalars.md b/docs/type-definitions/scalars.md index 55b71ec7c..4529dfa24 100644 --- a/docs/type-definitions/scalars.md +++ b/docs/type-definitions/scalars.md @@ -161,4 +161,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/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/rector.php b/rector.php index cd9b7544c..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 @@ -38,6 +34,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', diff --git a/src/Executor/Promise/Adapter/AmpFutureAdapter.php b/src/Executor/Promise/Adapter/AmpFutureAdapter.php index 556ceae48..c39e9895c 100644 --- a/src/Executor/Promise/Adapter/AmpFutureAdapter.php +++ b/src/Executor/Promise/Adapter/AmpFutureAdapter.php @@ -8,13 +8,12 @@ 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). * * @see https://amphp.org/amp + * + * @implements PromiseAdapter> */ class AmpFutureAdapter implements PromiseAdapter { @@ -23,37 +22,59 @@ 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 { - $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 */ @@ -80,6 +101,8 @@ static function (\Throwable $exception) use ($deferred): void { /** * @throws \Error * @throws InvariantViolation + * + * @phpstan-return Promise> */ public function createFulfilled($value = null): Promise { @@ -94,7 +117,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); @@ -110,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) { @@ -119,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); } /** @@ -147,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; } @@ -162,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/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/src/Language/Lexer.php b/src/Language/Lexer.php index de2314319..928a49550 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 */ @@ -391,18 +400,43 @@ 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); - $chunk = ''; + // 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 includes ASCII control characters (0x00-0x1F), + // not just backslash/quote/line-terminators, because + // assertValidStringCharacterCode() must still reject most of them. + // See self::STRING_STOP_BYTES for details on the excluded TAB (0x09). + + $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, self::STRING_STOP_BYTES, $byteStart); + + if ($runLength > 0) { + $chunk = substr($body, $byteStart, $runLength); $value .= $chunk; + // $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; + } + + $bytePos = $this->byteStreamPosition; + if ($bytePos >= $bodyLength) { + break; // EOF mid-string + } + + $code = ord($body[$bytePos]); - // Skip quote + if ($code === 34) { // Closing Quote (") $this->moveStringCursor(1, 1); return new Token( @@ -416,79 +450,79 @@ 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); + if ($code === 10 || $code === 13) { // LineTerminator + break; + } - 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}"); - } + // 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); - $code = hexdec($hex); - assert(is_int($code), 'Since only a single char is read'); + // $code === 92 (\), i.e. an escape sequence. + $this->moveStringCursor(1, 1); + [, $code] = $this->readChar(true); - // 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); - } + // null means EOF; checked separately since switch is not a strict comparison. + if ($code === null) { + break; + } - $surrogatePairHex = $hex . substr($utf16Continuation, 2, 4); - $value .= mb_convert_encoding(pack('H*', $surrogatePairHex), 'UTF-8', 'UTF-16'); - break; + 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); - [$char, $code, $bytes] = $this->readChar(); + $value .= Utils::chr($code); + break; + default: + $chr = Utils::chr($code); + throw new SyntaxError($this->source, $this->position - 1, "Invalid character escape sequence: \\{$chr}"); + } } throw new SyntaxError($this->source, $this->position, 'Unterminated string.'); diff --git a/src/Type/Schema.php b/src/Type/Schema.php index af38cf5fb..46bebbe68 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/nuwave/lighthouse/issues/2771. + $explicitScalarOverrides = $this->config->scalarOverrides; + if ($explicitScalarOverrides !== null) { + return $this->scalarOverrides = $explicitScalarOverrides; + } + $this->scalarOverrides = []; $builtInScalars = Type::builtInScalars(); @@ -613,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 abf82dd4f..18fd21992 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,47 @@ public function setTypes($types): self return $this; } + /** + * @return array|null + * + * @api + */ + public function getScalarOverrides(): ?array + { + return $this->scalarOverrides; + } + + /** + * Deeper validation (that each override is a ScalarType named after a built-in scalar) + * runs during schema validation, see SchemaValidationContext::validateScalarOverrides(). + * + * @param array|null $scalarOverrides + * + * @api + */ + public function setScalarOverrides(?array $scalarOverrides): self + { + if ($scalarOverrides === null) { + $this->scalarOverrides = null; + + return $this; + } + + $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 + assert($scalarOverride instanceof ScalarType, 'Expected instanceof ' . ScalarType::class . ', got: ' . Utils::printSafe($scalarOverride)); + + $this->scalarOverrides[$scalarOverride->name] = $scalarOverride; + } + + return $this; + } + /** * @return array|null * 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/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/Executor/Promise/AmpFutureAdapterTest.php b/tests/Executor/Promise/AmpFutureAdapterTest.php index 5f4b0539e..136e3349e 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,13 +67,83 @@ static function ($value) use (&$result): void { } ); - self::assertInstanceOf(Future::class, $resultPromise->adoptedPromise); - $resultPromise->adoptedPromise->await(); 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(); @@ -81,37 +151,54 @@ 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); } + 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(); $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 +208,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,14 +217,33 @@ 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()); } + 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(); @@ -147,27 +251,114 @@ public function testAll(): void $allPromise = $ampAdapter->all($promises); - self::assertInstanceOf(Future::class, $allPromise->adoptedPromise); - $result = $allPromise->adoptedPromise->await(); 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); $deferred->complete(3); - $allFuture = $allPromise->adoptedPromise; - self::assertInstanceOf(Future::class, $allFuture); - $result = $allFuture->await(); + $result = $allPromise->adoptedPromise->await(); + + 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')); - self::assertSame([1, 2, 3, 4], $result); + async(static function (): void {})->await(); } } diff --git a/tests/Executor/Promise/AmpPromiseAdapterTest.php b/tests/Executor/Promise/AmpPromiseAdapterTest.php index b416a7d3b..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; @@ -76,7 +75,6 @@ static function ($value) use (&$result): void { ); self::assertSame(1, $result); - self::assertInstanceOf(Promise::class, $resultPromise->adoptedPromise); } public function testCreate(): void @@ -86,8 +84,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 +136,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); diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index 25ad8f11d..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,36 @@ 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' + "before after" + GRAPHQL; + + self::assertArraySubset( + [ + 'kind' => Token::STRING, + 'start' => 0, + 'end' => 14, + 'value' => "before\tafter", + ], + (array) $this->lexOne($source) + ); + } + /** @see it('lexes block strings') */ public function testLexesBlockString(): void { @@ -423,6 +461,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)]; } /** @@ -566,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('(') diff --git a/tests/Type/ScalarOverridesTest.php b/tests/Type/ScalarOverridesTest.php index e72e50228..28c75d338 100644 --- a/tests/Type/ScalarOverridesTest.php +++ b/tests/Type/ScalarOverridesTest.php @@ -568,6 +568,165 @@ 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 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 testSetScalarOverridesAssertsScalarTypesAtDevelopmentTime(): void + { + $config = new SchemaConfig(); + + $this->expectException(\AssertionError::class); + // @phpstan-ignore-next-line intentionally wrong + $config->setScalarOverrides([self::createQueryType()]); + } + + public function testSchemaValidationRejectsNonBuiltInScalarNames(): void + { + $customScalar = new CustomScalarType([ + 'name' => 'DateTime', + 'serialize' => static fn ($value): string => (string) $value, + ]); + + $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(); + + self::assertCount(1, $errors); + self::assertStringContainsString('instanceof', $errors[0]->getMessage()); + } + /** @see https://github.com/webonyx/graphql-php/issues/1874 */ public function testTypeLoaderIsNotCalledForBuiltInScalarNames(): void { diff --git a/tests/Utils/BuildClientSchemaTest.php b/tests/Utils/BuildClientSchemaTest.php index 8d7e17599..677062481 100644 --- a/tests/Utils/BuildClientSchemaTest.php +++ b/tests/Utils/BuildClientSchemaTest.php @@ -521,13 +521,19 @@ 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 } '); 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);