diff --git a/.ai/AGENTS.md b/.ai/AGENTS.md index ba6598fcd..61b146186 100644 --- a/.ai/AGENTS.md +++ b/.ai/AGENTS.md @@ -36,6 +36,7 @@ The following elements are part of the stable public API: - Keep changes focused and minimal. - Run `make stan` and `make test` for behavioral changes. - Use `make fix` for style/refactoring consistency when needed. +- Only add `/** @see it('...') */` to a test when it has a direct counterpart with that exact name in graphql-js. ## Release Workflow diff --git a/CHANGELOG.md b/CHANGELOG.md index 623205f0e..d2db2934e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Fixed + +- Fix `SingleFieldSubscription` validation to expand fragments before counting root fields, reject introspection fields, and reject `@skip`/`@include` at the subscription root https://github.com/webonyx/graphql-php/pull/1930 +- This release completes full compliance with the [October 2021 GraphQL specification](https://spec.graphql.org/October2021) + ## v15.33.0 ### Added @@ -983,7 +988,7 @@ This release brings several breaking changes. Please refer to [UPGRADE](UPGRADE. - Support repeatable directives (https://github.com/webonyx/graphql-php/issues/643) - Support SDL Validation and other schema validation improvements (e.g. https://github.com/webonyx/graphql-php/issues/492) -- Added promise adapter for [Amp](https://amphp.org/) (https://github.com/webonyx/graphql-php/issues/551) +- Added promise adapter for [Amp](https://amphp.org) (https://github.com/webonyx/graphql-php/issues/551) - Query plan utility improvements (https://github.com/webonyx/graphql-php/issues/513, https://github.com/webonyx/graphql-php/issues/632) - Allow retrieving query complexity once query has been completed (https://github.com/webonyx/graphql-php/issues/316) - Allow input types to be passed in from variables using \stdClass instead of associative arrays (https://github.com/webonyx/graphql-php/issues/535) @@ -991,7 +996,7 @@ This release brings several breaking changes. Please refer to [UPGRADE](UPGRADE. ### Changed -- Compliant with the GraphQL specification [June 2018 Edition](https://spec.graphql.org/June2018/) +- Compliant with the GraphQL specification [June 2018 Edition](https://spec.graphql.org/June2018) - Having an empty string in `deprecationReason` will now print the `@deprecated` directive (only a `null` `deprecationReason` won't print the `@deprecated` directive). ### Optimized @@ -1167,19 +1172,19 @@ New features and notable changes: - Changed minimum PHP version from 5.4 to 5.5 - Lazy loading of types without separate build step (see https://github.com/webonyx/graphql-php/issues/69, see [docs](https://webonyx.github.io/graphql-php/type-system/schema/#lazy-loading-of-types)) - PSR-7 compliant Standard Server (see [docs](https://webonyx.github.io/graphql-php/executing-queries/#using-server)) -- New default error formatting, which does not expose sensitive data (see [docs](https://webonyx.github.io/graphql-php/error-handling/)) +- New default error formatting, which does not expose sensitive data (see [docs](https://webonyx.github.io/graphql-php/error-handling)) - Ability to define custom error handler to filter/log/re-throw exceptions after execution (see [docs](https://webonyx.github.io/graphql-php/error-handling/#custom-error-handling-and-formatting)) - Allow defining schema configuration using objects with fluent setters vs array (see [docs](https://webonyx.github.io/graphql-php/type-system/schema/#using-config-class)) - Allow serializing AST to array and re-creating AST from array lazily (see [docs](https://webonyx.github.io/graphql-php/reference/#graphqlutilsast)) - [Apollo-style](https://dev-blog.apollodata.com/query-batching-in-apollo-63acfd859862) query batching support via server (see [docs](https://webonyx.github.io/graphql-php/executing-queries/#query-batching)) - Schema validation, including validation of interface implementations (see [docs](https://webonyx.github.io/graphql-php/type-system/schema/#schema-validation)) -- Ability to pass custom config formatter when defining schema using [GraphQL type language](http://graphql.org/learn/schema/#type-language) (see [docs](https://webonyx.github.io/graphql-php/type-system/type-language/)) +- Ability to pass custom config formatter when defining schema using [GraphQL type language](http://graphql.org/learn/schema/#type-language) (see [docs](https://webonyx.github.io/graphql-php/type-system/type-language)) Improvements: - Significantly improved parser performance (see https://github.com/webonyx/graphql-php/issues/137 and https://github.com/webonyx/graphql-php/issues/128) - Support for PHP7 exceptions everywhere (see https://github.com/webonyx/graphql-php/issues/127) -- Improved [documentation](https://webonyx.github.io/graphql-php/) and docblock comments +- Improved [documentation](https://webonyx.github.io/graphql-php) and docblock comments Deprecations and breaking changes - see [UPGRADE](UPGRADE.md) document. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7fc32305..bafe39055 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,8 @@ composer test ``` Some tests have an annotation such as `@see it('')`. -It references a matching test in the [graphql-js implementation](https://github.com/graphql/graphql-js). +It references a test with the exact same name in the [graphql-js reference implementation](https://github.com/graphql/graphql-js). +Only add `@see it(...)` when the test has a direct counterpart in graphql-js with that exact name. When porting tests that utilize [the `dedent()` test utility from `graphql-js`](https://github.com/graphql/graphql-js/blob/99d6079434/src/__testUtils__/dedent.js), we instead use [the PHP native `nowdoc` syntax](https://www.php.net/manual/en/language.types.string.php#language.types.string.syntax.nowdoc). diff --git a/README.md b/README.md index 582ed5e39..bb9aa103a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ This is a PHP implementation of the [GraphQL](https://graphql.org) [specification](https://github.com/graphql/graphql-spec) based on the [reference implementation in JavaScript](https://github.com/graphql/graphql-js). +Fully compliant with the [October 2021 GraphQL specification](https://spec.graphql.org/October2021). +[September 2025 specification](https://spec.graphql.org/September2025) compliance is [in progress](https://github.com/webonyx/graphql-php/issues/1931). + ## Sponsors If you make money using this project, please consider sponsoring [its maintainer on GitHub Sponsors](https://github.com/sponsors/spawnia) or [the project on OpenCollective](https://opencollective.com/webonyx-graphql-php). diff --git a/docs/complementary-tools.md b/docs/complementary-tools.md index 493459e00..68a0456bc 100644 --- a/docs/complementary-tools.md +++ b/docs/complementary-tools.md @@ -1,6 +1,6 @@ ## Server Integrations -- [Standard Server](executing-queries.md#using-server) – Out of the box integration with any PSR-7 compatible framework (like [Slim](https://slimframework.com) or [Laminas Mezzio](https://docs.mezzio.dev/mezzio/)) +- [Standard Server](executing-queries.md#using-server) – Out of the box integration with any PSR-7 compatible framework (like [Slim](https://slimframework.com) or [Laminas Mezzio](https://docs.mezzio.dev/mezzio)) - [Lighthouse](https://github.com/nuwave/lighthouse) – Laravel package, SDL-first - [Laravel GraphQL](https://github.com/rebing/graphql-laravel) - Laravel package, code-first - [OverblogGraphQLBundle](https://github.com/overblog/GraphQLBundle) – Symfony bundle @@ -19,7 +19,7 @@ - [GraphQL Batch Processor](https://github.com/vasily-kartashov/graphql-batch-processing) – Provides a builder interface for defining collection, querying, filtering, and post-processing logic of batched data fetches - [GraphQL Utils](https://github.com/simPod/GraphQL-Utils) – Objective schema definition builders (no need for arrays anymore) - [Relay Library](https://github.com/ivome/graphql-relay-php) – Helps construct Relay related schema definitions -- [Resonance/GraphQL](https://resonance.distantmagic.com/docs/features/graphql/) – Integrates with Swoole for parallelism. Define your schema code-first with annotations. +- [Resonance/GraphQL](https://resonance.distantmagic.com/docs/features/graphql) – Integrates with Swoole for parallelism. Define your schema code-first with annotations. - [GraphQL PHP Validation Toolkit](https://github.com/shmax/graphql-php-validation-toolkit) - Small library for validation of user input - [MLL Scalars](https://github.com/mll-lab/graphql-php-scalars) - Collection of custom scalar types - [X GraphQL](https://github.com/x-graphql) - Provides set of libraries for building http schema, schema gateway, transforming schema, generating PHP code from execution definition, and more. diff --git a/docs/concepts.md b/docs/concepts.md index 18e4bc48b..e496c241b 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -147,6 +147,6 @@ $blogPostType = new ObjectType([ ## Further Reading -To get deeper understanding of GraphQL concepts - [read the docs on official GraphQL website](https://graphql.org/learn/) +To get deeper understanding of GraphQL concepts - [read the docs on official GraphQL website](https://graphql.org/learn) To get started with **graphql-php** - continue to next section ["Getting Started"](getting-started.md) diff --git a/docs/data-fetching.md b/docs/data-fetching.md index 09791e1cc..13fad0136 100644 --- a/docs/data-fetching.md +++ b/docs/data-fetching.md @@ -189,7 +189,7 @@ $queryType = new ObjectType([ Since: 0.9.0 One of the most annoying problems with data fetching is a so-called -[N+1 problem](https://secure.phabricator.com/book/phabcontrib/article/n_plus_one/).
+[N+1 problem](https://secure.phabricator.com/book/phabcontrib/article/n_plus_one).
Consider following GraphQL query: ```graphql @@ -262,7 +262,7 @@ If your project runs in an environment that supports async operations you can leverage the power of your platform to resolve some fields asynchronously. The only requirement: your platform must support the concept of Promises compatible with -[Promises A+](https://promisesaplus.com/) specification. +[Promises A+](https://promisesaplus.com) specification. To start using this feature, switch facade method for query execution from **executeQuery** to **promiseToExecute**: @@ -293,7 +293,7 @@ Where **$promiseAdapter** is an instance of: - For [AMPHP](https://github.com/amphp/amp):
`GraphQL\Executor\Promise\Adapter\AmpPromiseAdapter` -- For [Swoole](https://swoole.com/) or [OpenSwoole](https://openswoole.com/):
+- For [Swoole](https://swoole.com) or [OpenSwoole](https://openswoole.com):
You can use an external library: [Resonance](https://resonance.distantmagic.com/docs/features/graphql/standalone-promise-adapter.html) - Other platforms: write your own class implementing interface:
diff --git a/docs/executing-queries.md b/docs/executing-queries.md index 7c5ad5282..7956bbc3b 100644 --- a/docs/executing-queries.md +++ b/docs/executing-queries.md @@ -63,7 +63,7 @@ $server = new StandardServer([/* server options, see below */]); $server->handleRequest(); // parses PHP globals and emits response ``` -Server also supports [PSR-7 request/response interfaces](https://www.php-fig.org/psr/psr-7/): +Server also supports [PSR-7 request/response interfaces](https://www.php-fig.org/psr/psr-7): ```php use GraphQL\Server\StandardServer; @@ -92,7 +92,7 @@ PSR-7 is useful when you want to integrate the server into existing framework: - [PSR-7 for Laravel](https://laravel.com/docs/requests#psr7-requests) - [Symfony PSR-7 Bridge](https://symfony.com/doc/current/components/psr7.html) - [Slim](https://www.slimframework.com/docs/v4/concepts/value-objects.html) -- [Laminas Mezzio](https://docs.mezzio.dev/mezzio/) +- [Laminas Mezzio](https://docs.mezzio.dev/mezzio) ### Server configuration options @@ -103,7 +103,7 @@ PSR-7 is useful when you want to integrate the server into existing framework: | context | `mixed` | Any value that holds information shared between all field resolvers. Most often they use it to pass currently logged in user, locale details, etc.

It will be available as the 3rd argument in all field resolvers. (see section on [Field Definitions](type-definitions/object-types.md#field-configuration-options) for reference) **graphql-php** never modifies this value and passes it _as is_ to all underlying resolvers. | | fieldResolver | `callable` | A resolver function to use when one is not provided by the schema. If not provided, the [default field resolver is used](data-fetching.md#default-field-resolver). | | validationRules | `array` or `callable` | A set of rules for query validation step. The default value is all available rules. The empty array would allow skipping query validation (may be convenient for persisted queries which are validated before persisting and assumed valid during execution).

Pass `callable` to return different validation rules for different queries (e.g. empty array for persisted query and a full list of rules for regular queries). When passed, it is expected to have the following signature:

**function ([OperationParams](class-reference.md#graphqlserveroperationparams) $params, DocumentNode $node, $operationType): array** | -| queryBatching | `bool` | Flag indicating whether this server supports query batching ([apollo-style](https://www.apollographql.com/blog/apollo-client/performance/query-batching/)).

Defaults to **false** | +| queryBatching | `bool` | Flag indicating whether this server supports query batching ([apollo-style](https://www.apollographql.com/blog/apollo-client/performance/query-batching)).

Defaults to **false** | | debugFlag | `int` | Debug flags. See [docs on error debugging](error-handling.md#debugging-tools) (flag values are the same). | | persistedQueryLoader | `callable` | A function which is called to fetch actual query when server encounters a **queryId**.

The server does not implement persistence part (which you will have to build on your own), but it allows you to execute queries which were persisted previously.

Expected function signature:
**function ($queryId, [OperationParams](class-reference.md#graphqlserveroperationparams) $params)**

Function is expected to return query **string** or parsed **DocumentNode**

[Read more about persisted queries](https://www.apollographql.com/blog/apollo-client/persisted-graphql-queries). | | errorFormatter | `callable` | Custom error formatter. See [error handling docs](error-handling.md#custom-error-handling-and-formatting). | @@ -130,7 +130,7 @@ $server = new StandardServer($config); ## Query batching -Standard Server supports query batching ([apollo-style](https://www.apollographql.com/blog/apollo-client/performance/query-batching/)). +Standard Server supports query batching ([apollo-style](https://www.apollographql.com/blog/apollo-client/performance/query-batching)). One of the major benefits of Server over a sequence of **executeQuery()** calls is that [Deferred resolvers](data-fetching.md#solving-n1-problem) won't be isolated in queries. diff --git a/docs/getting-started.md b/docs/getting-started.md index 727594a6d..a7f687d86 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,7 +1,7 @@ ## Prerequisites This documentation assumes your familiarity with GraphQL concepts. If it is not the case - -first learn about GraphQL on [the official website](https://graphql.org/learn/). +first learn about GraphQL on [the official website](https://graphql.org/learn). ## Installation diff --git a/docs/index.md b/docs/index.md index 17bf323e5..f27e20f28 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,9 +13,9 @@ It is intended to be an alternative to REST and SOAP APIs (even for **existing a GraphQL itself is a [specification](https://github.com/graphql/graphql-spec) designed by Facebook engineers. Various implementations of this specification were written -[in different languages and environments](https://graphql.org/code/). +[in different languages and environments](https://graphql.org/code). -Great overview of GraphQL features and benefits is presented on [the official website](https://graphql.org/). +Great overview of GraphQL features and benefits is presented on [the official website](https://graphql.org). All of them equally apply to this PHP implementation. ## About graphql-php @@ -46,10 +46,8 @@ existing PHP frameworks, add support for Relay, etc. The first version of this library (v0.1) was released on August 10th 2015. -The current version supports all features described by GraphQL specification -as well as some experimental features like -[schema definition language](schema-definition-language.md) and -[schema printer](class-reference.md#graphqlutilsschemaprinter). +Fully compliant with the [October 2021 GraphQL specification](https://spec.graphql.org/October2021). +[September 2025 specification](https://spec.graphql.org/September2025) compliance is [in progress](https://github.com/webonyx/graphql-php/issues/1931). Ready for real-world usage. diff --git a/docs/security.md b/docs/security.md index dc9be4ce8..727b5caf7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -106,7 +106,7 @@ This will set the rule globally. Alternatively, you can provide validation rules ## Disabling Introspection -[Introspection](https://graphql.org/learn/introspection/) is a mechanism for fetching schema structure. +[Introspection](https://graphql.org/learn/introspection) is a mechanism for fetching schema structure. It is used by tools like GraphiQL for auto-completion, query validation, etc. Introspection is enabled by default. It means that anybody can get a full description of your schema by diff --git a/examples/04-async-php/amphp/http-server/README.md b/examples/04-async-php/amphp/http-server/README.md index bb531e2fd..702bd561b 100644 --- a/examples/04-async-php/amphp/http-server/README.md +++ b/examples/04-async-php/amphp/http-server/README.md @@ -1,6 +1,6 @@ ## HTTP-Server example with AMPHP -This is a basic example using [AMPHP](https://amphp.org/). +This is a basic example using [AMPHP](https://amphp.org). ### Dependencies diff --git a/src/Validator/Rules/SingleFieldSubscription.php b/src/Validator/Rules/SingleFieldSubscription.php index fa4ab1cce..ca2e2deee 100644 --- a/src/Validator/Rules/SingleFieldSubscription.php +++ b/src/Validator/Rules/SingleFieldSubscription.php @@ -3,10 +3,21 @@ namespace GraphQL\Validator\Rules; use GraphQL\Error\Error; +use GraphQL\Language\AST\DirectiveNode; +use GraphQL\Language\AST\FieldNode; +use GraphQL\Language\AST\FragmentDefinitionNode; +use GraphQL\Language\AST\FragmentSpreadNode; +use GraphQL\Language\AST\InlineFragmentNode; +use GraphQL\Language\AST\Node; use GraphQL\Language\AST\NodeKind; use GraphQL\Language\AST\OperationDefinitionNode; +use GraphQL\Language\AST\SelectionSetNode; use GraphQL\Language\Visitor; use GraphQL\Language\VisitorOperation; +use GraphQL\Type\Definition\AbstractType; +use GraphQL\Type\Definition\Directive; +use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Schema; use GraphQL\Validator\QueryValidationContext; class SingleFieldSubscription extends ValidationRule @@ -16,15 +27,73 @@ public function getVisitor(QueryValidationContext $context): array return [ NodeKind::OPERATION_DEFINITION => static function (OperationDefinitionNode $node) use ($context): VisitorOperation { if ($node->operation === 'subscription') { - $selections = $node->selectionSet->selections; + $schema = $context->getSchema(); + $subscriptionType = $schema->getSubscriptionType(); - if (count($selections) > 1) { - $offendingSelections = $selections->splice(1, count($selections)); + if ($subscriptionType !== null) { + $operationName = $node->name->value ?? null; - $context->reportError(new Error( - static::multipleFieldsInOperation($node->name->value ?? null), - $offendingSelections - )); + // Collect fragment definitions from document + /** @var array $fragments */ + $fragments = []; + foreach ($context->getDocument()->definitions as $definition) { + if ($definition instanceof FragmentDefinitionNode) { + $fragments[$definition->name->value] = $definition; + } + } + + // Collect fields by expanding fragments, also collecting forbidden directives + /** @var array> $groupedFieldSet */ + $groupedFieldSet = []; + /** @var array $visitedFragments */ + $visitedFragments = []; + /** @var array $forbiddenDirectiveNodes */ + $forbiddenDirectiveNodes = []; + self::collectFields( + $schema, + $subscriptionType, + $node->selectionSet, + $fragments, + $groupedFieldSet, + $visitedFragments, + $forbiddenDirectiveNodes + ); + + if ($forbiddenDirectiveNodes !== []) { + $context->reportError(new Error( + static::skipIncludeInOperation($operationName), + $forbiddenDirectiveNodes + )); + + return Visitor::skipNode(); + } + + if (count($groupedFieldSet) > 1) { + $keys = array_keys($groupedFieldSet); + /** @var array $extraFieldNodes */ + $extraFieldNodes = []; + for ($keyIndex = 1, $keyCount = count($keys); $keyIndex < $keyCount; ++$keyIndex) { + foreach ($groupedFieldSet[$keys[$keyIndex]] as $fieldNode) { + $extraFieldNodes[] = $fieldNode; + } + } + + $context->reportError(new Error( + static::multipleFieldsInOperation($operationName), + $extraFieldNodes + )); + } + + // Check for introspection fields + foreach ($groupedFieldSet as $fieldNodes) { + $fieldName = $fieldNodes[0]->name->value; + if ($fieldName[0] === '_' && ($fieldName[1] ?? '') === '_') { + $context->reportError(new Error( + static::introspectionFieldInOperation($operationName), + $fieldNodes + )); + } + } } } @@ -33,6 +102,106 @@ public function getVisitor(QueryValidationContext $context): array ]; } + /** + * @param array $fragments + * @param array> $groupedFieldSet + * @param array $visitedFragments + * @param array $forbiddenDirectiveNodes + * + * @throws \Exception + */ + private static function collectFields( + Schema $schema, + ObjectType $runtimeType, + SelectionSetNode $selectionSet, + array $fragments, + array &$groupedFieldSet, + array &$visitedFragments, + array &$forbiddenDirectiveNodes + ): void { + foreach ($selectionSet->selections as $selection) { + if ($selection instanceof FieldNode) { + foreach ($selection->directives as $directive) { + $directiveName = $directive->name->value; + if ($directiveName === Directive::SKIP_NAME || $directiveName === Directive::INCLUDE_NAME) { + $forbiddenDirectiveNodes[] = $directive; + } + } + + $responseKey = $selection->alias->value ?? $selection->name->value; + if (! isset($groupedFieldSet[$responseKey])) { + $groupedFieldSet[$responseKey] = []; + } + + $groupedFieldSet[$responseKey][] = $selection; + } elseif ($selection instanceof InlineFragmentNode) { + if (! self::doesFragmentConditionMatch($schema, $selection, $runtimeType)) { + continue; + } + + self::collectFields( + $schema, + $runtimeType, + $selection->selectionSet, + $fragments, + $groupedFieldSet, + $visitedFragments, + $forbiddenDirectiveNodes + ); + } elseif ($selection instanceof FragmentSpreadNode) { + $fragmentName = $selection->name->value; + if (isset($visitedFragments[$fragmentName])) { + continue; + } + + $visitedFragments[$fragmentName] = true; + + if (! isset($fragments[$fragmentName])) { + continue; + } + + $fragment = $fragments[$fragmentName]; + if (! self::doesFragmentConditionMatch($schema, $fragment, $runtimeType)) { + continue; + } + + self::collectFields( + $schema, + $runtimeType, + $fragment->selectionSet, + $fragments, + $groupedFieldSet, + $visitedFragments, + $forbiddenDirectiveNodes + ); + } + } + } + + /** + * @param InlineFragmentNode|FragmentDefinitionNode $fragment + * + * @throws \Exception + */ + private static function doesFragmentConditionMatch(Schema $schema, Node $fragment, ObjectType $type): bool + { + $typeConditionNode = $fragment->typeCondition; + if ($typeConditionNode === null) { + return true; + } + + $conditionalType = $schema->getType($typeConditionNode->name->value); + if ($conditionalType === $type) { + return true; + } + + if ($conditionalType instanceof AbstractType) { + return $schema->isSubType($conditionalType, $type); + } + + return false; + } + public static function multipleFieldsInOperation(?string $operationName): string { if ($operationName === null) { @@ -41,4 +210,22 @@ public static function multipleFieldsInOperation(?string $operationName): string return "Subscription \"{$operationName}\" must select only one top level field."; } + + public static function introspectionFieldInOperation(?string $operationName): string + { + if ($operationName === null) { + return 'Anonymous Subscription must not select an introspection top level field.'; + } + + return "Subscription \"{$operationName}\" must not select an introspection top level field."; + } + + public static function skipIncludeInOperation(?string $operationName): string + { + if ($operationName === null) { + return 'Anonymous Subscription must not use `@skip` or `@include` directives in the top level selection.'; + } + + return "Subscription \"{$operationName}\" must not use `@skip` or `@include` directives in the top level selection."; + } } diff --git a/tests/Validator/SingleFieldSubscriptionsTest.php b/tests/Validator/SingleFieldSubscriptionsTest.php index 42ab5c338..72a5bc8da 100644 --- a/tests/Validator/SingleFieldSubscriptionsTest.php +++ b/tests/Validator/SingleFieldSubscriptionsTest.php @@ -4,6 +4,9 @@ use GraphQL\Language\SourceLocation; use GraphQL\Tests\ErrorHelper; +use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Definition\Type; +use GraphQL\Type\Schema; use GraphQL\Validator\Rules\SingleFieldSubscription; /** @@ -11,7 +14,7 @@ */ final class SingleFieldSubscriptionsTest extends ValidatorTestCase { - /** @see it('valid single field subscription') */ + /** @see it('valid subscription') */ public function testValidSingleFieldSubscription(): void { $this->expectPassesRule( @@ -26,7 +29,6 @@ public function testValidSingleFieldSubscription(): void ); } - /** @see it('valid single field bulk subscriptions') */ public function testValidSingleFieldBulkSubscriptions(): void { $this->expectPassesRule( @@ -37,9 +39,9 @@ public function testValidSingleFieldBulkSubscriptions(): void meows } } - + subscription sub2 { - dogSubscribe { + barkSubscribe { barks } } @@ -47,7 +49,6 @@ public function testValidSingleFieldBulkSubscriptions(): void ); } - /** @see it('valid single field anonymous subscription') */ public function testValidSingleFieldAnonymousSubscription(): void { $this->expectPassesRule( @@ -62,7 +63,6 @@ public function testValidSingleFieldAnonymousSubscription(): void ); } - /** @see it('valid single field subscription') */ public function testValidSingleFieldSubscriptionWithMultipleResultFields(): void { $this->expectPassesRule( @@ -78,7 +78,62 @@ public function testValidSingleFieldSubscriptionWithMultipleResultFields(): void ); } - /** @see it('invalid multiple field subscription') */ + /** @see it('valid subscription with fragment') */ + public function testValidSubscriptionWithFragment(): void + { + $this->expectPassesRule( + new SingleFieldSubscription(), + ' + subscription sub { + ...fragA + } + fragment fragA on SubscriptionRoot { + catSubscribe { + meows + } + } + ' + ); + } + + /** @see it('valid subscription with fragment and field') */ + public function testValidSubscriptionWithFragmentAndField(): void + { + $this->expectPassesRule( + new SingleFieldSubscription(), + ' + subscription sub { + ...fragA + catSubscribe { + meows + } + } + fragment fragA on SubscriptionRoot { + catSubscribe { + meows + } + } + ' + ); + } + + public function testValidSubscriptionWithInlineFragment(): void + { + $this->expectPassesRule( + new SingleFieldSubscription(), + ' + subscription sub { + ... on SubscriptionRoot { + catSubscribe { + meows + } + } + } + ' + ); + } + + /** @see it('fails with more than one root field') */ public function testInvalidMultipleFieldSubscription(): void { $this->expectFailsRule( @@ -97,7 +152,7 @@ public function testInvalidMultipleFieldSubscription(): void ); } - /** @see it('invalid multiple field anonymous subscription') */ + /** @see it('fails with more than one root field in anonymous subscriptions') */ public function testInvalidMultipleFieldAnonymousSubscription(): void { $this->expectFailsRule( @@ -116,7 +171,7 @@ public function testInvalidMultipleFieldAnonymousSubscription(): void ); } - /** @see it('invalid many fields subscription') */ + /** @see it('fails with many more than one root field') */ public function testInvalidManyFieldsSubscription(): void { $this->expectFailsRule( @@ -138,7 +193,6 @@ public function testInvalidManyFieldsSubscription(): void ); } - /** @see it('invalid many fields anonymous subscription') */ public function testInvalidManyFieldAnonymousSubscription(): void { $this->expectFailsRule( @@ -160,6 +214,251 @@ public function testInvalidManyFieldAnonymousSubscription(): void ); } + /** @see it('fails with many more than one root field via fragments') */ + public function testInvalidManyFieldsViaFragments(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + ...fragA + ...fragB + } + fragment fragA on SubscriptionRoot { + catSubscribe { + meows + } + } + fragment fragB on SubscriptionRoot { + barkSubscribe { + barks + } + } + ', + [$this->multipleFieldsInOperation('sub', [12, 9])] + ); + } + + /** @see it('does not infinite loop on recursive fragments') */ + public function testDoesNotInfiniteLoopOnRecursiveFragments(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + ...fragA + } + fragment fragA on SubscriptionRoot { + catSubscribe { + meows + } + ...fragA + ...fragB + } + fragment fragB on SubscriptionRoot { + barkSubscribe { + barks + } + ...fragA + } + ', + [$this->multipleFieldsInOperation('sub', [13, 9])] + ); + } + + /** @see it('fails with introspection field') */ + public function testFailsWithIntrospectionField(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + __typename + } + ', + [$this->introspectionFieldInOperation('sub', [3, 9])] + ); + } + + /** @see it('fails with introspection field in anonymous subscription') */ + public function testFailsWithIntrospectionFieldAnonymous(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription { + __typename + } + ', + [$this->introspectionFieldInOperation(null, [3, 9])] + ); + } + + /** @see it('fails with more than one root field including introspection') */ + public function testFailsWithMoreThanOneRootFieldIncludingIntrospection(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + catSubscribe { + meows + } + __typename + } + ', + [ + $this->multipleFieldsInOperation('sub', [6, 9]), + $this->introspectionFieldInOperation('sub', [6, 9]), + ] + ); + } + + /** @see it('fails with more than one root field including aliased introspection via fragment') */ + public function testFailsWithAliasedIntrospectionViaFragment(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + ...fragA + } + fragment fragA on SubscriptionRoot { + catSubscribe { + meows + } + name: __typename + } + ', + [ + $this->multipleFieldsInOperation('sub', [9, 9]), + $this->introspectionFieldInOperation('sub', [9, 9]), + ] + ); + } + + /** @see it('fails with @skip or @include directive') */ + public function testFailsWithSkipDirective(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + catSubscribe @skip(if: true) { + meows + } + } + ', + [$this->skipIncludeInOperation('sub', [3, 22])] + ); + } + + public function testFailsWithIncludeDirective(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + catSubscribe @include(if: true) { + meows + } + } + ', + [$this->skipIncludeInOperation('sub', [3, 22])] + ); + } + + /** @see it('fails with @skip or @include directive in anonymous subscription') */ + public function testFailsWithSkipDirectiveAnonymous(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription { + catSubscribe @skip(if: true) { + meows + } + } + ', + [$this->skipIncludeInOperation(null, [3, 22])] + ); + } + + public function testFailsWithSkipDirectiveViaFragment(): void + { + $this->expectFailsRule( + new SingleFieldSubscription(), + ' + subscription sub { + ...frag + } + fragment frag on SubscriptionRoot { + catSubscribe @include(if: true) { + meows + } + } + ', + [$this->skipIncludeInOperation('sub', [6, 22])] + ); + } + + public function testValidSubscriptionWithInlineFragmentWithoutTypeCondition(): void + { + $this->expectPassesRule( + new SingleFieldSubscription(), + ' + subscription sub { + ... { + catSubscribe { + meows + } + } + } + ' + ); + } + + public function testIgnoresFieldsFromNonMatchingFragmentTypeCondition(): void + { + $this->expectPassesRule( + new SingleFieldSubscription(), + ' + subscription sub { + catSubscribe { + meows + } + ... on Cat { + meows + } + } + ' + ); + } + + /** @see it('skips if not subscription type') */ + public function testSkipsIfNotSubscriptionType(): void + { + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'QueryRoot', + 'fields' => [ + 'foo' => ['type' => Type::string()], + ], + ]), + ]); + + $this->expectPassesRuleWithSchema( + $schema, + new SingleFieldSubscription(), + ' + subscription { + foo + bar + } + ' + ); + } + /** * @param array ...$locations A tuple of line and column * @@ -176,4 +475,38 @@ private function multipleFieldsInOperation(?string $operationName, array ...$loc }, $locations) ); } + + /** + * @param array ...$locations A tuple of line and column + * + * @phpstan-return ErrorArray + */ + private function introspectionFieldInOperation(?string $operationName, array ...$locations): array + { + return ErrorHelper::create( + SingleFieldSubscription::introspectionFieldInOperation($operationName), + array_map(static function (array $location): SourceLocation { + [$line, $column] = $location; + + return new SourceLocation($line, $column); + }, $locations) + ); + } + + /** + * @param array ...$locations A tuple of line and column + * + * @phpstan-return ErrorArray + */ + private function skipIncludeInOperation(?string $operationName, array ...$locations): array + { + return ErrorHelper::create( + SingleFieldSubscription::skipIncludeInOperation($operationName), + array_map(static function (array $location): SourceLocation { + [$line, $column] = $location; + + return new SourceLocation($line, $column); + }, $locations) + ); + } }