From 0f2bbec68063ff44936b929396c96e8a8a01d2c4 Mon Sep 17 00:00:00 2001 From: ypianykh Date: Wed, 15 Apr 2026 08:12:01 +0200 Subject: [PATCH 1/2] Implement AmpFutureAdapter for integration with AMPHP v3 and add corresponding tests --- CHANGELOG.md | 2 + composer.json | 5 +- .../amphp-v3/event-loop/README.md | 47 +++++ .../amphp-v3/event-loop/graphql.php | 35 ++++ .../amphp-v3/http-server/README.md | 18 ++ .../amphp-v3/http-server/graphql.php | 55 ++++++ examples/04-async-php/amphp-v3/schema.php | 37 ++++ phpstan.neon.dist | 8 + .../Promise/Adapter/AmpFutureAdapter.php | 181 ++++++++++++++++++ src/Executor/Promise/Promise.php | 3 +- .../Executor/Promise/AmpFutureAdapterTest.php | 174 +++++++++++++++++ .../Promise/AmpPromiseAdapterTest.php | 7 + 12 files changed, 569 insertions(+), 3 deletions(-) create mode 100644 examples/04-async-php/amphp-v3/event-loop/README.md create mode 100644 examples/04-async-php/amphp-v3/event-loop/graphql.php create mode 100644 examples/04-async-php/amphp-v3/http-server/README.md create mode 100644 examples/04-async-php/amphp-v3/http-server/graphql.php create mode 100644 examples/04-async-php/amphp-v3/schema.php create mode 100644 src/Executor/Promise/Adapter/AmpFutureAdapter.php create mode 100644 tests/Executor/Promise/AmpFutureAdapterTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f36b02f5..1c5016e38 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 +- Implement `AmpFutureAdapter` for integration with AMPHP v3 + ## v15.31.5 ### Fixed diff --git a/composer.json b/composer.json index 940e9b693..d890b94f9 100644 --- a/composer.json +++ b/composer.json @@ -14,8 +14,8 @@ "ext-mbstring": "*" }, "require-dev": { - "amphp/amp": "^2.6", - "amphp/http-server": "^2.1", + "amphp/amp": "^2.6 || ^3.0", + "amphp/http-server": "^2.1 || ^3.0", "dms/phpunit-arraysubset-asserts": "dev-master", "ergebnis/composer-normalize": "^2.28", "friendsofphp/php-cs-fixer": "3.95.1", @@ -37,6 +37,7 @@ "ticketswap/phpstan-error-formatter": "1.3.0" }, "suggest": { + "amphp/amp": "To leverage async resolving on AMPHP platform (v3 with AmpFutureAdapter, v2 with AmpPromiseAdapter)", "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", "psr/http-message": "To use standard GraphQL server", "react/promise": "To leverage async resolving on React PHP platform" diff --git a/examples/04-async-php/amphp-v3/event-loop/README.md b/examples/04-async-php/amphp-v3/event-loop/README.md new file mode 100644 index 000000000..836c9a5a2 --- /dev/null +++ b/examples/04-async-php/amphp-v3/event-loop/README.md @@ -0,0 +1,47 @@ +## GraphQL with AMPHP v3 (Fiber-based) + +This example uses [AMPHP v3](https://amphp.org/) with fiber-based async execution via `AmpFutureAdapter`. + +### Dependencies + +```sh +composer require amphp/amp:^3 +``` + +### Run locally + +```sh +php -S localhost:8080 graphql.php +``` + +### Make a request + +```sh +curl --data '{"query": "query { product article }"}' \ + --header "Content-Type: application/json" \ + localhost:8080 +``` + +### Migrating from AMPHP v2 + +If you were using the `AmpPromiseAdapter` with AMPHP v2, switch to `AmpFutureAdapter` with AMPHP v3: + +```php +// Before (amphp/amp ^2) +use GraphQL\Executor\Promise\Adapter\AmpPromiseAdapter; +$adapter = new AmpPromiseAdapter(); + +// After (amphp/amp ^3) +use GraphQL\Executor\Promise\Adapter\AmpFutureAdapter; +$adapter = new AmpFutureAdapter(); +``` + +Resolver return types change from `Amp\Promise` to `Amp\Future`: + +```php +// Before +'resolve' => fn (): \Amp\Promise => \Amp\call(fn (): string => 'value'), + +// After +'resolve' => fn (): \Amp\Future => \Amp\async(fn (): string => 'value'), +``` diff --git a/examples/04-async-php/amphp-v3/event-loop/graphql.php b/examples/04-async-php/amphp-v3/event-loop/graphql.php new file mode 100644 index 000000000..089af0784 --- /dev/null +++ b/examples/04-async-php/amphp-v3/event-loop/graphql.php @@ -0,0 +1,35 @@ +then(function (ExecutionResult $result): void { + echo json_encode($result->toArray(), JSON_THROW_ON_ERROR); +}); diff --git a/examples/04-async-php/amphp-v3/http-server/README.md b/examples/04-async-php/amphp-v3/http-server/README.md new file mode 100644 index 000000000..bb531e2fd --- /dev/null +++ b/examples/04-async-php/amphp-v3/http-server/README.md @@ -0,0 +1,18 @@ +## HTTP-Server example with AMPHP + +This is a basic example using [AMPHP](https://amphp.org/). + +### Dependencies + +When you want to use this in your project, you need to install the +following dependencies: + +``` +composer require amphp/http-server +``` + +### Run locally + +``` +php graphql.php +``` diff --git a/examples/04-async-php/amphp-v3/http-server/graphql.php b/examples/04-async-php/amphp-v3/http-server/graphql.php new file mode 100644 index 000000000..a9566d788 --- /dev/null +++ b/examples/04-async-php/amphp-v3/http-server/graphql.php @@ -0,0 +1,55 @@ +expose('localhost:8080'); + +$server->start( + new ClosureRequestHandler(static function (Request $request) use ($schema): Response { + $input = json_decode(buffer($request->getBody()), true); + + $adapter = new AmpFutureAdapter(); + $promise = GraphQL::promiseToExecute( + $adapter, + $schema, + $input['query'], + null, + null, + $input['variables'] ?? null, + $input['operationName'] ?? null + ); + + $future = $promise->adoptedPromise; + $result = $future->await(); + assert($result instanceof ExecutionResult); + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode($result->toArray(), JSON_THROW_ON_ERROR) + ); + }), + new DefaultErrorHandler() +); + +Amp\trapSignal([SIGINT, SIGTERM]); + +$server->stop(); \ No newline at end of file diff --git a/examples/04-async-php/amphp-v3/schema.php b/examples/04-async-php/amphp-v3/schema.php new file mode 100644 index 000000000..5faca25b9 --- /dev/null +++ b/examples/04-async-php/amphp-v3/schema.php @@ -0,0 +1,37 @@ + new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'product' => [ + 'type' => Type::string(), + 'resolve' => static fn (): Future => async( + // use inside the closure e.g. amphp/mysql, amphp/http-client, ... + static fn (): string => 'xyz' + ), + ], + 'article' => [ + 'type' => Type::string(), + 'resolve' => static fn (): Future => async( + // use inside the closure e.g. amphp/mysql, amphp/http-client, ... + static fn (): string => 'zyx' + ), + ], + ], + ]), +]); diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 81440567c..764c6ff4e 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -52,6 +52,14 @@ parameters: - message: "~Access to an undefined property GraphQL\\\\Language\\\\AST\\\\Node\\:\\:\\$.+~" path: tests/Language/VisitorTest.php + # Ignore not installed Amp v2 packages + - message: "~(Amp\\\\.* not found|(unknown class|has invalid type|has invalid bound type|invalid return type|Result of method|Method|Cannot instantiate interface) Amp\\\\)~" + paths: + - tests/Executor/Promise/AmpFutureAdapterTest.php + - tests/Executor/Promise/AmpPromiseAdapterTest.php + - src/Executor/Promise + - examples/04-async-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 new file mode 100644 index 000000000..556ceae48 --- /dev/null +++ b/src/Executor/Promise/Adapter/AmpFutureAdapter.php @@ -0,0 +1,181 @@ +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; + } + + return static::unwrapResult($onRejected($reason)); + } + + if ($onFulfilled === null) { + return $value; + } + + return static::unwrapResult($onFulfilled($value)); + }); + + return new Promise($next, $this); + } + + /** @throws InvariantViolation */ + public function create(callable $resolver): Promise + { + $deferred = new DeferredFuture(); + + try { + $resolver( + static function ($value) use ($deferred): void { + static::resolveDeferred($deferred, $value); + }, + static function (\Throwable $exception) use ($deferred): void { + $deferred->error($exception); + } + ); + } catch (\Throwable $exception) { + $deferred->error($exception); + } + + return new Promise($deferred->getFuture(), $this); + } + + /** + * @throws \Error + * @throws InvariantViolation + */ + public function createFulfilled($value = null): Promise + { + if ($value instanceof Promise) { + return $value; + } + + if ($value instanceof Future) { + return new Promise($value, $this); + } + + return new Promise(Future::complete($value), $this); + } + + /** @throws InvariantViolation */ + public function createRejected(\Throwable $reason): Promise + { + return new Promise(Future::error($reason), $this); + } + + /** + * @throws \Error + * @throws InvariantViolation + */ + public function all(iterable $promisesOrValues): Promise + { + $items = is_array($promisesOrValues) + ? $promisesOrValues + : iterator_to_array($promisesOrValues); + + /** @var array> $futures */ + $futures = []; + + foreach ($items as $key => $item) { + if ($item instanceof Promise) { + $item = $item->adoptedPromise; + } + + if ($item instanceof Future) { + $futures[$key] = $item; + } + } + + $combined = async(static function () use ($items, $futures): array { + if ($futures === []) { + return $items; + } + + $resolved = await($futures); + + return array_replace($items, $resolved); + }); + + return new Promise($combined, $this); + } + + /** + * @param DeferredFuture $deferred + * @param mixed $value + */ + protected static function resolveDeferred(DeferredFuture $deferred, $value): void + { + if ($value instanceof Promise) { + $value = $value->adoptedPromise; + } + + if ($value instanceof Future) { + async(static function () use ($deferred, $value): void { + try { + $deferred->complete($value->await()); + } catch (\Throwable $exception) { + $deferred->error($exception); + } + }); + + return; + } + + $deferred->complete($value); + } + + /** + * @param mixed $value + * + * @return mixed + */ + protected static function unwrapResult($value) + { + if ($value instanceof Promise) { + $value = $value->adoptedPromise; + } + + if ($value instanceof Future) { + return $value->await(); + } + + return $value; + } +} diff --git a/src/Executor/Promise/Promise.php b/src/Executor/Promise/Promise.php index 28db6fbaa..42c307b28 100644 --- a/src/Executor/Promise/Promise.php +++ b/src/Executor/Promise/Promise.php @@ -2,6 +2,7 @@ namespace GraphQL\Executor\Promise; +use Amp\Future as AmpFuture; use Amp\Promise as AmpPromise; use GraphQL\Error\InvariantViolation; use GraphQL\Executor\Promise\Adapter\SyncPromise; @@ -12,7 +13,7 @@ */ class Promise { - /** @var SyncPromise|ReactPromise|AmpPromise */ + /** @var SyncPromise|ReactPromise|AmpFuture|AmpPromise */ public $adoptedPromise; private PromiseAdapter $adapter; diff --git a/tests/Executor/Promise/AmpFutureAdapterTest.php b/tests/Executor/Promise/AmpFutureAdapterTest.php new file mode 100644 index 000000000..6f9b2d593 --- /dev/null +++ b/tests/Executor/Promise/AmpFutureAdapterTest.php @@ -0,0 +1,174 @@ +isThenable(Future::complete())); + $errorFuture = Future::error(new \Exception()); + self::assertTrue($ampAdapter->isThenable($errorFuture)); + $errorFuture->ignore(); + $asyncFuture = async(static function (): void {}); + self::assertTrue($ampAdapter->isThenable($asyncFuture)); + $asyncFuture->await(); + self::assertFalse($ampAdapter->isThenable(false)); + self::assertFalse($ampAdapter->isThenable(true)); + self::assertFalse($ampAdapter->isThenable(1)); + self::assertFalse($ampAdapter->isThenable(0)); + self::assertFalse($ampAdapter->isThenable('test')); + self::assertFalse($ampAdapter->isThenable('')); + self::assertFalse($ampAdapter->isThenable([])); + self::assertFalse($ampAdapter->isThenable(new \stdClass())); + } + + public function testConvertsAmpFuturesToGraphQLOnes(): void + { + $ampAdapter = new AmpFutureAdapter(); + $future = Future::complete(1); + + $promise = $ampAdapter->convertThenable($future); + + self::assertInstanceOf(Future::class, $promise->adoptedPromise); + } + + public function testThen(): void + { + $ampAdapter = new AmpFutureAdapter(); + $future = Future::complete(1); + $promise = $ampAdapter->convertThenable($future); + + $result = null; + + $resultPromise = $ampAdapter->then( + $promise, + static function ($value) use (&$result): void { + $result = $value; + } + ); + + self::assertInstanceOf(Future::class, $resultPromise->adoptedPromise); + + $resultPromise->adoptedPromise->await(); + + self::assertSame(1, $result); + } + + public function testCreate(): void + { + $ampAdapter = new AmpFutureAdapter(); + $resolvedPromise = $ampAdapter->create(static function ($resolve): 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; + \PHPUnit\Framework\Assert::assertInstanceOf(Future::class, $resultFuture); + $resultFuture->await(); + + self::assertSame(1, $result); + } + + 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; + \PHPUnit\Framework\Assert::assertInstanceOf(Future::class, $resultFuture); + $resultFuture->await(); + + self::assertSame(1, $result); + } + + 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( + null, + static function ($error) use (&$exception): void { + $exception = $error; + } + ); + + $resultFuture = $resultPromise->adoptedPromise; + \PHPUnit\Framework\Assert::assertInstanceOf(Future::class, $resultFuture); + $resultFuture->await(); + + self::assertInstanceOf(\Throwable::class, $exception); + self::assertSame('I am a bad promise', $exception->getMessage()); + } + + public function testAll(): void + { + $ampAdapter = new AmpFutureAdapter(); + $promises = [Future::complete(1), Future::complete(2), Future::complete(3)]; + + $allPromise = $ampAdapter->all($promises); + + self::assertInstanceOf(Future::class, $allPromise->adoptedPromise); + + $result = $allPromise->adoptedPromise->await(); + + self::assertSame([1, 2, 3], $result); + } + + public function testAllShouldPreserveTheOrderOfTheArrayWhenResolvingAsyncPromises(): void + { + $ampAdapter = new AmpFutureAdapter(); + $deferred = new DeferredFuture(); + $promises = [Future::complete(1), 2, $deferred->getFuture(), Future::complete(4)]; + + $allPromise = $ampAdapter->all($promises); + + // Resolve the async future + $deferred->complete(3); + + $allFuture = $allPromise->adoptedPromise; + self::assertInstanceOf(Future::class, $allFuture); + $result = $allFuture->await(); + + self::assertSame([1, 2, 3, 4], $result); + } +} diff --git a/tests/Executor/Promise/AmpPromiseAdapterTest.php b/tests/Executor/Promise/AmpPromiseAdapterTest.php index f2ed9ad1c..4438e04c2 100644 --- a/tests/Executor/Promise/AmpPromiseAdapterTest.php +++ b/tests/Executor/Promise/AmpPromiseAdapterTest.php @@ -18,6 +18,13 @@ */ final class AmpPromiseAdapterTest extends TestCase { + protected function setUp(): void + { + if (! class_exists(\Amp\Deferred::class)) { + self::markTestSkipped('amphmp/amp ^2 is required for this test suite.'); + } + } + public function testIsThenableReturnsTrueWhenAnAmpPromiseIsGiven(): void { $ampAdapter = new AmpPromiseAdapter(); From 8a92a1ac300a02b67d1b009199923793ae64af36 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 08:55:06 +0000 Subject: [PATCH 2/2] Autofix --- examples/04-async-php/amphp-v3/http-server/graphql.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/04-async-php/amphp-v3/http-server/graphql.php b/examples/04-async-php/amphp-v3/http-server/graphql.php index a9566d788..60082a869 100644 --- a/examples/04-async-php/amphp-v3/http-server/graphql.php +++ b/examples/04-async-php/amphp-v3/http-server/graphql.php @@ -52,4 +52,4 @@ Amp\trapSignal([SIGINT, SIGTERM]); -$server->stop(); \ No newline at end of file +$server->stop();