diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e134cb..bf09a15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## v1.3.0 + +### Fixed + +- Handle fields omitted through `@skip` or `@include` https://github.com/spawnia/sailor/pull/79 + ## v1.2.1 ### Fixed diff --git a/README.md b/README.md index f659fe4..20103a5 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,31 @@ the following is more efficient as it does not instantiate a new object: ->assertErrorFree(); // Throws if there are errors ``` +### Client directives + +When using GraphQL's `@skip` or `@include` directives in your operations, fields can be omitted from the server response. +Sailor marks such fields as nullable in the generated result classes, allowing you to safely handle cases where they are absent: + +```graphql +query UserProfile($skipEmail: Boolean!) { + user { + name + email @skip(if: $skipEmail) + } +} +``` + +The generated `email` field will be nullable, even if it was non-nullable in the schema. +When skipped (or not included), the property will always be `null`, but can be accessed without error. + +```php +UserProfile::execute(skipEmail: true) + ->errorFree() + ->data + ->user + ->email // null +``` + ### Queries with arguments Your generated operation classes will be annotated with the arguments your query defines. diff --git a/composer.json b/composer.json index 1955df2..04765be 100644 --- a/composer.json +++ b/composer.json @@ -64,6 +64,7 @@ "psr-4": { "Spawnia\\Sailor\\CustomTypesSrc\\": "examples/custom-types/src/", "Spawnia\\Sailor\\CustomTypes\\": "examples/custom-types/expected/", + "Spawnia\\Sailor\\InlineFragments\\": "examples/inline-fragments/expected/", "Spawnia\\Sailor\\Input\\": "examples/input/expected/", "Spawnia\\Sailor\\PhpKeywords\\": "examples/php-keywords/expected/", "Spawnia\\Sailor\\Polymorphic\\": "examples/polymorphic/expected/", diff --git a/examples/inline-fragments/expected/Operations/InlineFragmentWithDirectNonNullableField.php b/examples/inline-fragments/expected/Operations/InlineFragmentWithDirectNonNullableField.php new file mode 100644 index 0000000..0bfccd6 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/InlineFragmentWithDirectNonNullableField.php @@ -0,0 +1,53 @@ + + */ +class InlineFragmentWithDirectNonNullableField extends \Spawnia\Sailor\Operation +{ + /** + * @param bool $skip + */ + public static function execute( + $skip, + ): InlineFragmentWithDirectNonNullableField\InlineFragmentWithDirectNonNullableFieldResult { + return self::executeOperation( + $skip, + ); + } + + protected static function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + ['skip', new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\BooleanConverter)], + ]; + } + + public static function document(): string + { + return /* @lang GraphQL */ 'query InlineFragmentWithDirectNonNullableField($skip: Boolean!) { + __typename + search(query: "test") { + __typename + ... on Article @skip(if: $skip) { + title + } + } + }'; + } + + 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/InlineFragmentWithDirectNonNullableField/InlineFragmentWithDirectNonNullableField.php b/examples/inline-fragments/expected/Operations/InlineFragmentWithDirectNonNullableField/InlineFragmentWithDirectNonNullableField.php new file mode 100644 index 0000000..987b55c --- /dev/null +++ b/examples/inline-fragments/expected/Operations/InlineFragmentWithDirectNonNullableField/InlineFragmentWithDirectNonNullableField.php @@ -0,0 +1,49 @@ + $search + * @property string $__typename + */ +class InlineFragmentWithDirectNonNullableField 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\\InlineFragmentWithDirectNonNullableField\\Search\\Article', + 'Video' => '\\Spawnia\\Sailor\\InlineFragments\\Operations\\InlineFragmentWithDirectNonNullableField\\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/InlineFragmentWithDirectNonNullableField/InlineFragmentWithDirectNonNullableFieldErrorFreeResult.php b/examples/inline-fragments/expected/Operations/InlineFragmentWithDirectNonNullableField/InlineFragmentWithDirectNonNullableFieldErrorFreeResult.php new file mode 100644 index 0000000..1e0a717 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/InlineFragmentWithDirectNonNullableField/InlineFragmentWithDirectNonNullableFieldErrorFreeResult.php @@ -0,0 +1,18 @@ +data = InlineFragmentWithDirectNonNullableField::fromStdClass($data); + } + + /** + * Useful for instantiation of successful mocked results. + * + * @return static + */ + public static function fromData(InlineFragmentWithDirectNonNullableField $data): self + { + $instance = new static; + $instance->data = $data; + + return $instance; + } + + public function errorFree(): InlineFragmentWithDirectNonNullableFieldErrorFreeResult + { + return InlineFragmentWithDirectNonNullableFieldErrorFreeResult::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/Operations/InlineFragmentWithDirectNonNullableField/Search/Article.php b/examples/inline-fragments/expected/Operations/InlineFragmentWithDirectNonNullableField/Search/Article.php new file mode 100644 index 0000000..0ef21c7 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/InlineFragmentWithDirectNonNullableField/Search/Article.php @@ -0,0 +1,47 @@ +__typename = 'Article'; + if ($title !== self::UNDEFINED) { + $instance->__set('title', $title); + } + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + 'title' => new \Spawnia\Sailor\Convert\NullConverter(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/InlineFragmentWithDirectNonNullableField/Search/Video.php b/examples/inline-fragments/expected/Operations/InlineFragmentWithDirectNonNullableField/Search/Video.php new file mode 100644 index 0000000..068699a --- /dev/null +++ b/examples/inline-fragments/expected/Operations/InlineFragmentWithDirectNonNullableField/Search/Video.php @@ -0,0 +1,38 @@ +__typename = 'Video'; + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + '__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/InlineFragmentWithNestedNonNullableField.php b/examples/inline-fragments/expected/Operations/InlineFragmentWithNestedNonNullableField.php new file mode 100644 index 0000000..f5fbab6 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/InlineFragmentWithNestedNonNullableField.php @@ -0,0 +1,56 @@ + + */ +class InlineFragmentWithNestedNonNullableField extends \Spawnia\Sailor\Operation +{ + /** + * @param bool $skip + */ + public static function execute( + $skip, + ): InlineFragmentWithNestedNonNullableField\InlineFragmentWithNestedNonNullableFieldResult { + return self::executeOperation( + $skip, + ); + } + + protected static function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + ['skip', new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\BooleanConverter)], + ]; + } + + public static function document(): string + { + return /* @lang GraphQL */ 'query InlineFragmentWithNestedNonNullableField($skip: Boolean!) { + __typename + search(query: "test") { + __typename + ... on Article @skip(if: $skip) { + content { + __typename + text + } + } + } + }'; + } + + 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/InlineFragmentWithNestedNonNullableField/InlineFragmentWithNestedNonNullableField.php b/examples/inline-fragments/expected/Operations/InlineFragmentWithNestedNonNullableField/InlineFragmentWithNestedNonNullableField.php new file mode 100644 index 0000000..a8aaecf --- /dev/null +++ b/examples/inline-fragments/expected/Operations/InlineFragmentWithNestedNonNullableField/InlineFragmentWithNestedNonNullableField.php @@ -0,0 +1,50 @@ +|null $search + */ +class InlineFragmentWithNestedNonNullableField extends \Spawnia\Sailor\ObjectLike +{ + /** + * @param array|null $search + */ + public static function make( + $search = 'Special default value that allows Sailor to differentiate between explicitly passing null and not passing a value at all.', + ): self { + $instance = new self; + + $instance->__typename = 'Query'; + if ($search !== self::UNDEFINED) { + $instance->__set('search', $search); + } + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + 'search' => new \Spawnia\Sailor\Convert\NullConverter(new \Spawnia\Sailor\Convert\ListConverter(new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\PolymorphicConverter([ + 'Article' => '\\Spawnia\\Sailor\\InlineFragments\\Operations\\InlineFragmentWithNestedNonNullableField\\Search\\Article', + 'Video' => '\\Spawnia\\Sailor\\InlineFragments\\Operations\\InlineFragmentWithNestedNonNullableField\\Search\\Video', + ])))), + ]; + } + + 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/InlineFragmentWithNestedNonNullableField/InlineFragmentWithNestedNonNullableFieldErrorFreeResult.php b/examples/inline-fragments/expected/Operations/InlineFragmentWithNestedNonNullableField/InlineFragmentWithNestedNonNullableFieldErrorFreeResult.php new file mode 100644 index 0000000..cb1c5cf --- /dev/null +++ b/examples/inline-fragments/expected/Operations/InlineFragmentWithNestedNonNullableField/InlineFragmentWithNestedNonNullableFieldErrorFreeResult.php @@ -0,0 +1,18 @@ +data = InlineFragmentWithNestedNonNullableField::fromStdClass($data); + } + + /** + * Useful for instantiation of successful mocked results. + * + * @return static + */ + public static function fromData(InlineFragmentWithNestedNonNullableField $data): self + { + $instance = new static; + $instance->data = $data; + + return $instance; + } + + public function errorFree(): InlineFragmentWithNestedNonNullableFieldErrorFreeResult + { + return InlineFragmentWithNestedNonNullableFieldErrorFreeResult::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/Operations/InlineFragmentWithNestedNonNullableField/Search/Article.php b/examples/inline-fragments/expected/Operations/InlineFragmentWithNestedNonNullableField/Search/Article.php new file mode 100644 index 0000000..3de83ed --- /dev/null +++ b/examples/inline-fragments/expected/Operations/InlineFragmentWithNestedNonNullableField/Search/Article.php @@ -0,0 +1,47 @@ +__typename = 'Article'; + if ($content !== self::UNDEFINED) { + $instance->__set('content', $content); + } + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + 'content' => new \Spawnia\Sailor\Convert\NullConverter(new \Spawnia\Sailor\InlineFragments\Operations\InlineFragmentWithNestedNonNullableField\Search\Content\ArticleContent), + ]; + } + + 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/InlineFragmentWithNestedNonNullableField/Search/Content/ArticleContent.php b/examples/inline-fragments/expected/Operations/InlineFragmentWithNestedNonNullableField/Search/Content/ArticleContent.php new file mode 100644 index 0000000..76d8957 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/InlineFragmentWithNestedNonNullableField/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/InlineFragmentWithNestedNonNullableField/Search/Video.php b/examples/inline-fragments/expected/Operations/InlineFragmentWithNestedNonNullableField/Search/Video.php new file mode 100644 index 0000000..2dd3f87 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/InlineFragmentWithNestedNonNullableField/Search/Video.php @@ -0,0 +1,38 @@ +__typename = 'Video'; + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + '__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/src/SearchQuery.graphql b/examples/inline-fragments/src/SearchQuery.graphql index 8f68c86..c1a09a5 100644 --- a/examples/inline-fragments/src/SearchQuery.graphql +++ b/examples/inline-fragments/src/SearchQuery.graphql @@ -16,3 +16,21 @@ query SearchQuery($query: String!) { } } } + +query InlineFragmentWithDirectNonNullableField($skip: Boolean!) { + search(query: "test") { + ... on Article @skip(if: $skip) { + title + } + } +} + +query InlineFragmentWithNestedNonNullableField($skip: Boolean!) { + search(query: "test") { + ... on Article @skip(if: $skip) { + content { + text + } + } + } +} diff --git a/examples/simple/expected/Operations/IncludeNonNullable.php b/examples/simple/expected/Operations/IncludeNonNullable.php new file mode 100644 index 0000000..96e5249 --- /dev/null +++ b/examples/simple/expected/Operations/IncludeNonNullable.php @@ -0,0 +1,47 @@ + + */ +class IncludeNonNullable extends \Spawnia\Sailor\Operation +{ + /** + * @param bool $value + */ + public static function execute($value): IncludeNonNullable\IncludeNonNullableResult + { + return self::executeOperation( + $value, + ); + } + + protected static function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + ['value', new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\BooleanConverter)], + ]; + } + + public static function document(): string + { + return /* @lang GraphQL */ 'query IncludeNonNullable($value: Boolean!) { + __typename + nonNullable @include(if: $value) + }'; + } + + public static function endpoint(): string + { + return 'simple'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../sailor.php'); + } +} diff --git a/examples/simple/expected/Operations/IncludeNonNullable/IncludeNonNullable.php b/examples/simple/expected/Operations/IncludeNonNullable/IncludeNonNullable.php new file mode 100644 index 0000000..3ba8fbb --- /dev/null +++ b/examples/simple/expected/Operations/IncludeNonNullable/IncludeNonNullable.php @@ -0,0 +1,47 @@ +__typename = 'Query'; + if ($nonNullable !== self::UNDEFINED) { + $instance->__set('nonNullable', $nonNullable); + } + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + 'nonNullable' => new \Spawnia\Sailor\Convert\NullConverter(new \Spawnia\Sailor\Convert\StringConverter), + ]; + } + + public static function endpoint(): string + { + return 'simple'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../sailor.php'); + } +} diff --git a/examples/simple/expected/Operations/IncludeNonNullable/IncludeNonNullableErrorFreeResult.php b/examples/simple/expected/Operations/IncludeNonNullable/IncludeNonNullableErrorFreeResult.php new file mode 100644 index 0000000..3ac8824 --- /dev/null +++ b/examples/simple/expected/Operations/IncludeNonNullable/IncludeNonNullableErrorFreeResult.php @@ -0,0 +1,18 @@ +data = IncludeNonNullable::fromStdClass($data); + } + + /** + * Useful for instantiation of successful mocked results. + * + * @return static + */ + public static function fromData(IncludeNonNullable $data): self + { + $instance = new static; + $instance->data = $data; + + return $instance; + } + + public function errorFree(): IncludeNonNullableErrorFreeResult + { + return IncludeNonNullableErrorFreeResult::fromResult($this); + } + + public static function endpoint(): string + { + return 'simple'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../sailor.php'); + } +} diff --git a/examples/simple/expected/Operations/SkipNonNullable.php b/examples/simple/expected/Operations/SkipNonNullable.php new file mode 100644 index 0000000..db3f6af --- /dev/null +++ b/examples/simple/expected/Operations/SkipNonNullable.php @@ -0,0 +1,47 @@ + + */ +class SkipNonNullable extends \Spawnia\Sailor\Operation +{ + /** + * @param bool $value + */ + public static function execute($value): SkipNonNullable\SkipNonNullableResult + { + return self::executeOperation( + $value, + ); + } + + protected static function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + ['value', new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\BooleanConverter)], + ]; + } + + public static function document(): string + { + return /* @lang GraphQL */ 'query SkipNonNullable($value: Boolean!) { + __typename + nonNullable @skip(if: $value) + }'; + } + + public static function endpoint(): string + { + return 'simple'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../sailor.php'); + } +} diff --git a/examples/simple/expected/Operations/SkipNonNullable/SkipNonNullable.php b/examples/simple/expected/Operations/SkipNonNullable/SkipNonNullable.php new file mode 100644 index 0000000..2edd31a --- /dev/null +++ b/examples/simple/expected/Operations/SkipNonNullable/SkipNonNullable.php @@ -0,0 +1,47 @@ +__typename = 'Query'; + if ($nonNullable !== self::UNDEFINED) { + $instance->__set('nonNullable', $nonNullable); + } + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + 'nonNullable' => new \Spawnia\Sailor\Convert\NullConverter(new \Spawnia\Sailor\Convert\StringConverter), + ]; + } + + public static function endpoint(): string + { + return 'simple'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../sailor.php'); + } +} diff --git a/examples/simple/expected/Operations/SkipNonNullable/SkipNonNullableErrorFreeResult.php b/examples/simple/expected/Operations/SkipNonNullable/SkipNonNullableErrorFreeResult.php new file mode 100644 index 0000000..51f1d25 --- /dev/null +++ b/examples/simple/expected/Operations/SkipNonNullable/SkipNonNullableErrorFreeResult.php @@ -0,0 +1,18 @@ +data = SkipNonNullable::fromStdClass($data); + } + + /** + * Useful for instantiation of successful mocked results. + * + * @return static + */ + public static function fromData(SkipNonNullable $data): self + { + $instance = new static; + $instance->data = $data; + + return $instance; + } + + public function errorFree(): SkipNonNullableErrorFreeResult + { + return SkipNonNullableErrorFreeResult::fromResult($this); + } + + public static function endpoint(): string + { + return 'simple'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../sailor.php'); + } +} diff --git a/examples/simple/schema.graphql b/examples/simple/schema.graphql index 3bc15a2..3c420a6 100644 --- a/examples/simple/schema.graphql +++ b/examples/simple/schema.graphql @@ -2,6 +2,7 @@ type Query { scalarWithArg(arg: String): ID twoArgs(first: String, second: Int): ID singleObject: SomeObject + nonNullable: String! } type SomeObject { diff --git a/examples/simple/src/clientDirectives.graphql b/examples/simple/src/clientDirectives.graphql index 00d4b21..49ae003 100644 --- a/examples/simple/src/clientDirectives.graphql +++ b/examples/simple/src/clientDirectives.graphql @@ -16,3 +16,11 @@ query ClientDirectiveInlineFragmentQuery($value: Boolean!) { twoArgs } } + +query SkipNonNullable($value: Boolean!) { + nonNullable @skip(if: $value) +} + +query IncludeNonNullable($value: Boolean!) { + nonNullable @include(if: $value) +} diff --git a/src/Codegen/OperationGenerator.php b/src/Codegen/OperationGenerator.php index c0172b3..2f25b4f 100644 --- a/src/Codegen/OperationGenerator.php +++ b/src/Codegen/OperationGenerator.php @@ -2,8 +2,10 @@ namespace Spawnia\Sailor\Codegen; +use GraphQL\Language\AST\DirectiveNode; use GraphQL\Language\AST\DocumentNode; use GraphQL\Language\AST\FieldNode; +use GraphQL\Language\AST\InlineFragmentNode; use GraphQL\Language\AST\NameNode; use GraphQL\Language\AST\NodeKind; use GraphQL\Language\AST\OperationDefinitionNode; @@ -12,7 +14,9 @@ use GraphQL\Language\Visitor; use GraphQL\Language\VisitorOperation; use GraphQL\Type\Definition\CompositeType; +use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\InterfaceType; +use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Definition\UnionType; @@ -52,6 +56,12 @@ public function __construct(Schema $schema, DocumentNode $document, EndpointConf /** @var array */ protected array $types; + /** Track nesting depth within selection sets */ + protected int $selectionNestingDepth = 0; + + /** @var array Inline fragments keyed by their nesting depth */ + protected array $inlineFragmentsByDepth = []; + /** @var array */ protected array $operationStorage = []; @@ -186,6 +196,16 @@ public function generate(): iterable ); }, ], + NodeKind::INLINE_FRAGMENT => [ + 'enter' => function (InlineFragmentNode $inlineFragment): void { + $this->inlineFragmentsByDepth[$this->selectionNestingDepth] = $inlineFragment; + ++$this->selectionNestingDepth; + }, + 'leave' => function (InlineFragmentNode $_): void { + --$this->selectionNestingDepth; + unset($this->inlineFragmentsByDepth[$this->selectionNestingDepth]); + }, + ], NodeKind::FIELD => [ 'enter' => function (FieldNode $field) use ($typeInfo): ?VisitorOperation { // We are only interested in the name that will come from the server @@ -196,6 +216,21 @@ public function generate(): iterable $type = $typeInfo->getType(); assert($type !== null, 'schema is validated'); + // @skip and @include directives mean the server may omit the field, + // even if the schema type is non-null. Only unwrap for direct children of fragments with directives. + // Exception: __typename is always available and non-nullable + if ($type instanceof NonNull + && ( + self::fieldHasSkipOrInclude($field) + || $this->parentInlineFragmentHasSkipOrInclude() + ) + && $fieldName !== Introspection::TYPE_NAME_FIELD_NAME + ) { + $type = $type->getWrappedType(); + } + + ++$this->selectionNestingDepth; + $namedType = Type::getNamedType($type); assert($namedType !== null, 'schema is validated'); // @phpstan-ignore function.alreadyNarrowedType, notIdentical.alwaysTrue (keep for safety across graphql-php versions) @@ -290,6 +325,8 @@ public function generate(): iterable return null; }, 'leave' => function (FieldNode $_) use ($typeInfo): void { + --$this->selectionNestingDepth; + $type = $typeInfo->getType(); assert($type !== null, 'schema is validated'); @@ -342,4 +379,33 @@ protected function currentNamespace(): string { return implode('\\', $this->namespaceStack); } + + /** @param iterable $directives */ + protected static function hasSkipOrIncludeDirective(iterable $directives): bool + { + foreach ($directives as $directive) { + $name = $directive->name->value; + if ($name === Directive::SKIP_NAME || $name === Directive::INCLUDE_NAME) { + return true; + } + } + + return false; + } + + protected static function fieldHasSkipOrInclude(FieldNode $field): bool + { + return self::hasSkipOrIncludeDirective($field->directives); + } + + protected function parentInlineFragmentHasSkipOrInclude(): bool + { + // Check if the direct parent inline fragment (at current depth) has @skip or @include + $parentFragment = $this->inlineFragmentsByDepth[$this->selectionNestingDepth - 1] ?? null; + if ($parentFragment === null) { + return false; + } + + return self::hasSkipOrIncludeDirective($parentFragment->directives); + } } diff --git a/src/ObjectLike.php b/src/ObjectLike.php index 433e9f1..5627af5 100644 --- a/src/ObjectLike.php +++ b/src/ObjectLike.php @@ -2,6 +2,7 @@ namespace Spawnia\Sailor; +use Spawnia\Sailor\Convert\NullConverter; use Spawnia\Sailor\Convert\TypeConverter; use Spawnia\Sailor\Error\InvalidDataException; @@ -91,6 +92,10 @@ public function fromGraphQL($value): self $converters = $this->converters(); foreach ($converters as $name => $converter) { if (! property_exists($value, $name)) { + if ($converter instanceof NullConverter) { + continue; // Field omitted due to @skip/@include directive + } + $endpoint = static::endpoint(); throw new InvalidDataException("{$endpoint}: Missing field {$name}."); } diff --git a/tests/Integration/InlineFragmentsTest.php b/tests/Integration/InlineFragmentsTest.php new file mode 100644 index 0000000..08d02d4 --- /dev/null +++ b/tests/Integration/InlineFragmentsTest.php @@ -0,0 +1,115 @@ + (object) [ + '__typename' => 'Query', + 'search' => [ + (object) [ + '__typename' => 'Article', + ], + ], + ], + ]); + + self::assertNotNull($result->data); + $article = $result->data->search[0]; + self::assertInstanceOf(InlineFragmentWithDirectNonNullableField\Search\Article::class, $article); + self::assertNull($article->title); + } + + public function testInlineFragmentWithDirectNonNullableFieldPresent(): void + { + $result = InlineFragmentWithDirectNonNullableField\InlineFragmentWithDirectNonNullableFieldResult::fromStdClass((object) [ + 'data' => (object) [ + '__typename' => 'Query', + 'search' => [ + (object) [ + '__typename' => 'Article', + 'title' => 'Test Article', + ], + ], + ], + ]); + + self::assertNotNull($result->data); + $article = $result->data->search[0]; + self::assertInstanceOf(InlineFragmentWithDirectNonNullableField\Search\Article::class, $article); + self::assertSame('Test Article', $article->title); + } + + public function testInlineFragmentWithNestedNonNullableFieldOmitted(): void + { + $result = InlineFragmentWithNestedNonNullableField\InlineFragmentWithNestedNonNullableFieldResult::fromStdClass((object) [ + 'data' => (object) [ + '__typename' => 'Query', + 'search' => [ + (object) [ + '__typename' => 'Article', + ], + ], + ], + ]); + + self::assertNotNull($result->data); + self::assertNotNull($result->data->search); + $article = $result->data->search[0]; + self::assertInstanceOf(InlineFragmentWithNestedNonNullableField\Search\Article::class, $article); + self::assertNull($article->content); + } + + public function testInlineFragmentWithNestedNonNullableFieldPresent(): void + { + $result = InlineFragmentWithNestedNonNullableField\InlineFragmentWithNestedNonNullableFieldResult::fromStdClass((object) [ + 'data' => (object) [ + '__typename' => 'Query', + 'search' => [ + (object) [ + '__typename' => 'Article', + 'content' => (object) [ + '__typename' => 'ArticleContent', + 'text' => 'Article content', + ], + ], + ], + ], + ]); + + self::assertNotNull($result->data); + self::assertNotNull($result->data->search); + $article = $result->data->search[0]; + self::assertInstanceOf(InlineFragmentWithNestedNonNullableField\Search\Article::class, $article); + self::assertNotNull($article->content); + self::assertSame('Article content', $article->content->text); + } + + public function testInlineFragmentWithNestedNonNullableFieldMissing(): void + { + $this->expectException(InvalidDataException::class); + $this->expectExceptionMessage('Missing field text'); + + InlineFragmentWithNestedNonNullableField\InlineFragmentWithNestedNonNullableFieldResult::fromStdClass((object) [ + 'data' => (object) [ + '__typename' => 'Query', + 'search' => [ + (object) [ + '__typename' => 'Article', + 'content' => (object) [ + '__typename' => 'ArticleContent', + ], + ], + ], + ], + ]); + } +} diff --git a/tests/Integration/SimpleTest.php b/tests/Integration/SimpleTest.php index 302a0a1..48456f5 100644 --- a/tests/Integration/SimpleTest.php +++ b/tests/Integration/SimpleTest.php @@ -9,8 +9,13 @@ use Spawnia\Sailor\Events\ReceiveResponse; use Spawnia\Sailor\Events\StartRequest; use Spawnia\Sailor\Response; +use Spawnia\Sailor\Simple\Operations\ClientDirectiveFragmentSpreadQuery; +use Spawnia\Sailor\Simple\Operations\ClientDirectiveInlineFragmentQuery; +use Spawnia\Sailor\Simple\Operations\ClientDirectiveQuery; +use Spawnia\Sailor\Simple\Operations\IncludeNonNullable; use Spawnia\Sailor\Simple\Operations\MyObjectNestedQuery; use Spawnia\Sailor\Simple\Operations\MyScalarQuery; +use Spawnia\Sailor\Simple\Operations\SkipNonNullable; use Spawnia\Sailor\Tests\TestCase; final class SimpleTest extends TestCase @@ -205,4 +210,149 @@ public function testNestedObjectNull(): void self::assertNotNull($object); self::assertNull($object->nested); } + + public function testSkipNonNullableFieldOmittedByServer(): void + { + $result = SkipNonNullable\SkipNonNullable::fromStdClass((object) [ + '__typename' => 'Query', + ]); + + self::assertNull($result->nonNullable); + } + + public function testSkipNonNullableFieldPresentWhenSkipConditionFalse(): void + { + $result = SkipNonNullable\SkipNonNullable::fromStdClass((object) [ + '__typename' => 'Query', + 'nonNullable' => 'value', + ]); + + self::assertSame('value', $result->nonNullable); + } + + public function testIncludeNonNullableFieldOmittedByServer(): void + { + $result = IncludeNonNullable\IncludeNonNullable::fromStdClass((object) [ + '__typename' => 'Query', + ]); + + self::assertNull($result->nonNullable); + } + + public function testIncludeNonNullableFieldPresentWhenIncludeConditionTrue(): void + { + $result = IncludeNonNullable\IncludeNonNullable::fromStdClass((object) [ + '__typename' => 'Query', + 'nonNullable' => 'value', + ]); + + self::assertSame('value', $result->nonNullable); + } + + public function testSkipNullableFieldOmitted(): void + { + $result = ClientDirectiveQuery\ClientDirectiveQuery::fromStdClass((object) [ + '__typename' => 'Query', + 'twoArgs' => 'present', + ]); + + self::assertNull($result->scalarWithArg); + self::assertSame('present', $result->twoArgs); + } + + public function testIncludeNullableFieldOmitted(): void + { + $result = ClientDirectiveQuery\ClientDirectiveQuery::fromStdClass((object) [ + '__typename' => 'Query', + 'scalarWithArg' => 'present', + ]); + + self::assertNull($result->twoArgs); + self::assertSame('present', $result->scalarWithArg); + } + + public function testClientDirectiveAllFieldsOmitted(): void + { + $result = ClientDirectiveQuery\ClientDirectiveQuery::fromStdClass((object) [ + '__typename' => 'Query', + ]); + + self::assertNull($result->scalarWithArg); + self::assertNull($result->twoArgs); + } + + public function testClientDirectiveAllFieldsPresent(): void + { + $result = ClientDirectiveQuery\ClientDirectiveQuery::fromStdClass((object) [ + '__typename' => 'Query', + 'scalarWithArg' => 'foo', + 'twoArgs' => 'bar', + ]); + + self::assertSame('foo', $result->scalarWithArg); + self::assertSame('bar', $result->twoArgs); + } + + public function testFragmentSpreadSkipOmitsField(): void + { + $result = ClientDirectiveFragmentSpreadQuery\ClientDirectiveFragmentSpreadQuery::fromStdClass((object) [ + '__typename' => 'Query', + ]); + + self::assertNull($result->twoArgs); + } + + public function testFragmentSpreadSkipFieldPresentWhenConditionFalse(): void + { + $result = ClientDirectiveFragmentSpreadQuery\ClientDirectiveFragmentSpreadQuery::fromStdClass((object) [ + '__typename' => 'Query', + 'twoArgs' => 'value', + ]); + + self::assertSame('value', $result->twoArgs); + } + + public function testInlineFragmentSkipOmitsField(): void + { + $result = ClientDirectiveInlineFragmentQuery\ClientDirectiveInlineFragmentQuery::fromStdClass((object) [ + '__typename' => 'Query', + ]); + + self::assertNull($result->twoArgs); + } + + public function testInlineFragmentSkipFieldPresentWhenConditionFalse(): void + { + $result = ClientDirectiveInlineFragmentQuery\ClientDirectiveInlineFragmentQuery::fromStdClass((object) [ + '__typename' => 'Query', + 'twoArgs' => 'value', + ]); + + self::assertSame('value', $result->twoArgs); + } + + public function testSkipNonNullableViaResultFieldOmitted(): void + { + $result = SkipNonNullable\SkipNonNullableResult::fromStdClass((object) [ + 'data' => (object) [ + '__typename' => 'Query', + ], + ]); + + self::assertNotNull($result->data); + self::assertNull($result->data->nonNullable); + } + + public function testSkipNonNullableViaResultFieldPresent(): void + { + $result = SkipNonNullable\SkipNonNullableResult::fromStdClass((object) [ + 'data' => (object) [ + '__typename' => 'Query', + 'nonNullable' => 'hello', + ], + ]); + + self::assertNotNull($result->data); + self::assertSame('hello', $result->data->nonNullable); + } }