diff --git a/Makefile b/Makefile index bc88d40..126fb89 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ approve: ## Generate code and approve it as expected .PHONY: test-examples test-examples: ## Test examples cd examples/custom-types && ./test.sh + cd examples/inline-fragments && ./test.sh cd examples/input && ./test.sh cd examples/install && ./test.sh cd examples/php-keywords && ./test.sh diff --git a/examples/inline-fragments/.gitignore b/examples/inline-fragments/.gitignore new file mode 100644 index 0000000..0351644 --- /dev/null +++ b/examples/inline-fragments/.gitignore @@ -0,0 +1,4 @@ +/sailor +/vendor +/composer.lock +/generated diff --git a/examples/inline-fragments/composer.json b/examples/inline-fragments/composer.json new file mode 100644 index 0000000..23cefe8 --- /dev/null +++ b/examples/inline-fragments/composer.json @@ -0,0 +1,24 @@ +{ + "require": { + "spawnia/sailor": "@dev" + }, + "autoload": { + "psr-4": { + "Spawnia\\Sailor\\InlineFragments\\": "generated/" + } + }, + "repositories": [ + { + "type": "path", + "url": "./sailor", + "options": { + "symlink": false + } + } + ], + "scripts": { + "move-package": "rsync --recursive ../../ sailor --exclude examples --exclude vendor --exclude .idea --exclude .git --exclude .build --delete", + "pre-install-cmd": "@move-package", + "pre-update-cmd": "@move-package" + } +} diff --git a/examples/inline-fragments/expected/Operations/SearchQuery.php b/examples/inline-fragments/expected/Operations/SearchQuery.php new file mode 100644 index 0000000..1971cb3 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/SearchQuery.php @@ -0,0 +1,65 @@ + + */ +class SearchQuery extends \Spawnia\Sailor\Operation +{ + /** + * @param string $query + */ + public static function execute($query): SearchQuery\SearchQueryResult + { + return self::executeOperation( + $query, + ); + } + + protected static function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + ['query', new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter)], + ]; + } + + public static function document(): string + { + return /* @lang GraphQL */ 'query SearchQuery($query: String!) { + __typename + search(query: $query) { + __typename + id + ... on Article { + title + content { + __typename + text + } + } + ... on Video { + title + content { + __typename + url + duration + } + } + } + }'; + } + + public static function endpoint(): string + { + return 'inline-fragments'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../sailor.php'); + } +} diff --git a/examples/inline-fragments/expected/Operations/SearchQuery/Search/Article.php b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Article.php new file mode 100644 index 0000000..a0aa777 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Article.php @@ -0,0 +1,58 @@ +__set('id', $id); + } + if ($title !== self::UNDEFINED) { + $instance->__set('title', $title); + } + if ($content !== self::UNDEFINED) { + $instance->__set('content', $content); + } + $instance->__typename = 'Article'; + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + 'id' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\IDConverter), + 'title' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + 'content' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\InlineFragments\Operations\SearchQuery\Search\Content\ArticleContent), + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + ]; + } + + public static function endpoint(): string + { + return 'inline-fragments'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../../sailor.php'); + } +} diff --git a/examples/inline-fragments/expected/Operations/SearchQuery/Search/Content/ArticleContent.php b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Content/ArticleContent.php new file mode 100644 index 0000000..a01910c --- /dev/null +++ b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Content/ArticleContent.php @@ -0,0 +1,46 @@ +__set('text', $text); + } + $instance->__typename = 'ArticleContent'; + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + 'text' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + ]; + } + + public static function endpoint(): string + { + return 'inline-fragments'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../../../sailor.php'); + } +} diff --git a/examples/inline-fragments/expected/Operations/SearchQuery/Search/Content/VideoContent.php b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Content/VideoContent.php new file mode 100644 index 0000000..5221f66 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Content/VideoContent.php @@ -0,0 +1,52 @@ +__set('url', $url); + } + if ($duration !== self::UNDEFINED) { + $instance->__set('duration', $duration); + } + $instance->__typename = 'VideoContent'; + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + 'url' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + 'duration' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\IntConverter), + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + ]; + } + + public static function endpoint(): string + { + return 'inline-fragments'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../../../sailor.php'); + } +} diff --git a/examples/inline-fragments/expected/Operations/SearchQuery/Search/Video.php b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Video.php new file mode 100644 index 0000000..89122f5 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Video.php @@ -0,0 +1,58 @@ +__set('id', $id); + } + if ($title !== self::UNDEFINED) { + $instance->__set('title', $title); + } + if ($content !== self::UNDEFINED) { + $instance->__set('content', $content); + } + $instance->__typename = 'Video'; + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + 'id' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\IDConverter), + 'title' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + 'content' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\InlineFragments\Operations\SearchQuery\Search\Content\VideoContent), + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + ]; + } + + public static function endpoint(): string + { + return 'inline-fragments'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../../sailor.php'); + } +} diff --git a/examples/inline-fragments/expected/Operations/SearchQuery/SearchQuery.php b/examples/inline-fragments/expected/Operations/SearchQuery/SearchQuery.php new file mode 100644 index 0000000..a1ec53d --- /dev/null +++ b/examples/inline-fragments/expected/Operations/SearchQuery/SearchQuery.php @@ -0,0 +1,49 @@ + $search + * @property string $__typename + */ +class SearchQuery extends \Spawnia\Sailor\ObjectLike +{ + /** + * @param array $search + */ + public static function make($search): self + { + $instance = new self; + + if ($search !== self::UNDEFINED) { + $instance->__set('search', $search); + } + $instance->__typename = 'Query'; + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + 'search' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\ListConverter(new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\PolymorphicConverter([ + 'Article' => '\\Spawnia\\Sailor\\InlineFragments\\Operations\\SearchQuery\\Search\\Article', + 'Video' => '\\Spawnia\\Sailor\\InlineFragments\\Operations\\SearchQuery\\Search\\Video', + ])))), + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + ]; + } + + public static function endpoint(): string + { + return 'inline-fragments'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../sailor.php'); + } +} diff --git a/examples/inline-fragments/expected/Operations/SearchQuery/SearchQueryErrorFreeResult.php b/examples/inline-fragments/expected/Operations/SearchQuery/SearchQueryErrorFreeResult.php new file mode 100644 index 0000000..30df896 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/SearchQuery/SearchQueryErrorFreeResult.php @@ -0,0 +1,18 @@ +data = SearchQuery::fromStdClass($data); + } + + /** + * Useful for instantiation of successful mocked results. + * + * @return static + */ + public static function fromData(SearchQuery $data): self + { + $instance = new static; + $instance->data = $data; + + return $instance; + } + + public function errorFree(): SearchQueryErrorFreeResult + { + return SearchQueryErrorFreeResult::fromResult($this); + } + + public static function endpoint(): string + { + return 'inline-fragments'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../sailor.php'); + } +} diff --git a/examples/inline-fragments/expected/Types/__DirectiveLocation.php b/examples/inline-fragments/expected/Types/__DirectiveLocation.php new file mode 100644 index 0000000..7760888 --- /dev/null +++ b/examples/inline-fragments/expected/Types/__DirectiveLocation.php @@ -0,0 +1,36 @@ + new class() extends EndpointConfig { + public function namespace(): string + { + return 'Spawnia\Sailor\InlineFragments'; + } + + public function targetPath(): string + { + return __DIR__ . '/generated'; + } + + public function schemaPath(): string + { + return __DIR__ . '/schema.graphql'; + } + + public function finder(): Finder + { + return new DirectoryFinder(__DIR__ . '/src'); + } + + public function makeClient(): Client + { + return new MockClient(static fn(): Response => Response::fromStdClass((object) [ + 'data' => (object) [ + '__typename' => 'Query', + 'search' => [ + (object) [ + '__typename' => 'Article', + 'id' => '1', + 'title' => 'Test Article', + 'content' => (object) [ + '__typename' => 'ArticleContent', + 'text' => 'Article text', + ], + ], + (object) [ + '__typename' => 'Video', + 'id' => '2', + 'title' => 'Test Video', + 'content' => (object) [ + '__typename' => 'VideoContent', + 'url' => 'https://example.com/video.mp4', + 'duration' => 120, + ], + ], + ], + ], + ])); + } + }, +]; diff --git a/examples/inline-fragments/schema.graphql b/examples/inline-fragments/schema.graphql new file mode 100644 index 0000000..b4c0eba --- /dev/null +++ b/examples/inline-fragments/schema.graphql @@ -0,0 +1,28 @@ +type Query { + search(query: String!): [SearchResult!]! +} + +interface SearchResult { + id: ID! +} + +type Article implements SearchResult { + id: ID! + title: String! + content: ArticleContent! +} + +type Video implements SearchResult { + id: ID! + title: String! + content: VideoContent! +} + +type ArticleContent { + text: String! +} + +type VideoContent { + url: String! + duration: Int! +} diff --git a/examples/inline-fragments/src/SearchQuery.graphql b/examples/inline-fragments/src/SearchQuery.graphql new file mode 100644 index 0000000..8f68c86 --- /dev/null +++ b/examples/inline-fragments/src/SearchQuery.graphql @@ -0,0 +1,18 @@ +query SearchQuery($query: String!) { + search(query: $query) { + id + ... on Article { + title + content { + text + } + } + ... on Video { + title + content { + url + duration + } + } + } +} diff --git a/examples/inline-fragments/src/test.php b/examples/inline-fragments/src/test.php new file mode 100644 index 0000000..78f2d1c --- /dev/null +++ b/examples/inline-fragments/src/test.php @@ -0,0 +1,20 @@ +errorFree(); + +$article = $result->data->search[0]; +assert($article instanceof SearchQuery\Search\Article); +assert($article->id === '1'); +assert($article->title === 'Test Article'); +assert($article->content->text === 'Article text'); + +$video = $result->data->search[1]; +assert($video instanceof SearchQuery\Search\Video); +assert($video->id === '2'); +assert($video->title === 'Test Video'); +assert($video->content->url === 'https://example.com/video.mp4'); +assert($video->content->duration === 120); diff --git a/examples/inline-fragments/test.sh b/examples/inline-fragments/test.sh new file mode 100755 index 0000000..6d8d226 --- /dev/null +++ b/examples/inline-fragments/test.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euxo pipefail + +composer update +composer reinstall spawnia/sailor +vendor/bin/sailor + +php src/test.php diff --git a/src/Codegen/OperationStack.php b/src/Codegen/OperationStack.php index 3bd2e4f..ca738c0 100644 --- a/src/Codegen/OperationStack.php +++ b/src/Codegen/OperationStack.php @@ -23,8 +23,19 @@ public function __construct(OperationBuilder $operation) /** @param array $selection */ public function setSelection(string $namespace, array $selection): void { - // Ignore if already set, we already were in that subtree - $this->selections[$namespace] ??= $selection; + // Merge with existing selection to handle multiple inline fragments + // with the same field name but different types + if (!isset($this->selections[$namespace])) { + $this->selections[$namespace] = $selection; + } else { + // Only add new types, don't overwrite existing ones + // (we already processed that type's subtree) + foreach ($selection as $typeName => $builder) { + if (!isset($this->selections[$namespace][$typeName])) { + $this->selections[$namespace][$typeName] = $builder; + } + } + } } /** @return iterable */ diff --git a/tests/Examples.php b/tests/Examples.php index 89a3101..5519550 100644 --- a/tests/Examples.php +++ b/tests/Examples.php @@ -13,6 +13,7 @@ final class Examples public const EXAMPLES = [ 'custom-types', + 'inline-fragments', 'input', 'php-keywords', 'polymorphic',