From dfe933cd0b4960b2405f99328bd62e338b24a9d6 Mon Sep 17 00:00:00 2001 From: Oguz Han Asnaz Date: Wed, 25 Mar 2026 17:10:28 +0100 Subject: [PATCH 1/6] feat: add dynamic union GraphQL types for terms and entries --- src/Fieldtypes/Entries.php | 41 ++++++- src/Fieldtypes/Terms.php | 44 ++++++- src/GraphQL/Types/DynamicEntryUnionType.php | 48 ++++++++ src/GraphQL/Types/DynamicTermUnionType.php | 48 ++++++++ tests/GraphQL/EntriesFieldtypeGqlTypeTest.php | 108 +++++++++++++++++ tests/GraphQL/TermsFieldtypeGqlTypeTest.php | 114 ++++++++++++++++++ 6 files changed, 399 insertions(+), 4 deletions(-) create mode 100644 src/GraphQL/Types/DynamicEntryUnionType.php create mode 100644 src/GraphQL/Types/DynamicTermUnionType.php create mode 100644 tests/GraphQL/EntriesFieldtypeGqlTypeTest.php create mode 100644 tests/GraphQL/TermsFieldtypeGqlTypeTest.php diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php index 81e1971e3ee..01577bdb8cd 100644 --- a/src/Fieldtypes/Entries.php +++ b/src/Fieldtypes/Entries.php @@ -17,6 +17,9 @@ use Statamic\Facades\Search; use Statamic\Facades\Site; use Statamic\Facades\User; +use Statamic\GraphQL\Types\DynamicEntryUnionType; +use Statamic\GraphQL\Types\EntryInterface; +use Statamic\GraphQL\Types\EntryType; use Statamic\Http\Resources\CP\Entries\EntriesFieldtypeEntries; use Statamic\Http\Resources\CP\Entries\EntriesFieldtypeEntry as EntryResource; use Statamic\Query\OrderBy; @@ -455,10 +458,44 @@ protected function getConfiguredCollections() public function toGqlType() { - $type = GraphQL::type('EntryInterface'); + // If the fieldtype isn't constrained to specific collections, return the generic EntryInterface. + if (empty($this->config('collections'))) { + $type = GraphQL::type(EntryInterface::NAME); + + if ($this->config('max_items') !== 1) { + $type = GraphQL::listOf(GraphQL::nonNull($type)); + } + + return $type; + } + + $configuredCollections = $this->getConfiguredCollections(); + + $combinations = collect($configuredCollections)->flatMap(function ($collectionHandle) { + $collection = Collection::find($collectionHandle); + + if (! $collection) { + return []; + } + + return $collection->entryBlueprints()->map(fn ($blueprint) => [ + 'collection' => $collection, + 'blueprint' => $blueprint, + ]); + })->values()->all(); + + if (count($combinations) === 1) { + $collection = $combinations[0]['collection']; + $blueprint = $combinations[0]['blueprint']; + $type = GraphQL::type(EntryType::buildName($collection, $blueprint)); + } else { + $newType = new DynamicEntryUnionType($combinations); + GraphQL::addType($newType); + $type = GraphQL::type($newType->name); + } if ($this->config('max_items') !== 1) { - $type = GraphQL::listOf($type); + $type = GraphQL::listOf(GraphQL::nonNull($type)); } return $type; diff --git a/src/Fieldtypes/Terms.php b/src/Fieldtypes/Terms.php index b1b3ee9a497..1724e7b0768 100644 --- a/src/Fieldtypes/Terms.php +++ b/src/Fieldtypes/Terms.php @@ -19,7 +19,9 @@ use Statamic\Facades\Taxonomy; use Statamic\Facades\Term; use Statamic\Facades\User; +use Statamic\GraphQL\Types\DynamicTermUnionType; use Statamic\GraphQL\Types\TermInterface; +use Statamic\GraphQL\Types\TermType; use Statamic\Http\Resources\CP\Taxonomies\TermsFieldtypeTerms as TermsResource; use Statamic\Query\OrderBy; use Statamic\Query\OrderedQueryBuilder; @@ -529,10 +531,48 @@ protected function getConfiguredTaxonomies() public function toGqlType() { - $type = GraphQL::type(TermInterface::NAME); + // If the fieldtype isn't constrained to specific taxonomies, return the generic TermInterface. + if (empty($this->field()->config()['taxonomies'])) { + $type = GraphQL::type(TermInterface::NAME); + + if ($this->config('max_items') !== 1) { + $type = GraphQL::listOf(GraphQL::nonNull($type)); + } + + return $type; + } + + $configuredTaxonomies = $this->getConfiguredTaxonomies(); + + $combinations = collect($configuredTaxonomies)->flatMap(function ($taxonomyHandle) { + $taxonomy = Taxonomy::find($taxonomyHandle); + + if (! $taxonomy) { + return []; + } + + $blueprints = $taxonomy->termBlueprints(); + + return $blueprints->map(function ($blueprint) use ($taxonomy) { + return [ + 'taxonomy' => $taxonomy, + 'blueprint' => $blueprint, + ]; + }); + })->values()->all(); + + if (count($combinations) === 1) { + $taxonomy = $combinations[0]['taxonomy']; + $blueprint = $combinations[0]['blueprint']; + $type = GraphQL::type(TermType::buildName($taxonomy, $blueprint)); + } else { + $newType = new DynamicTermUnionType($combinations); + GraphQL::addType($newType); + $type = GraphQL::type($newType->name); + } if ($this->config('max_items') !== 1) { - $type = GraphQL::listOf($type); + $type = GraphQL::listOf(GraphQL::nonNull($type)); } return $type; diff --git a/src/GraphQL/Types/DynamicEntryUnionType.php b/src/GraphQL/Types/DynamicEntryUnionType.php new file mode 100644 index 00000000000..112eea5f9db --- /dev/null +++ b/src/GraphQL/Types/DynamicEntryUnionType.php @@ -0,0 +1,48 @@ + 'DynamicEntryUnionType', + ]; + + public function __construct(protected array $types) + { + $this->attributes['name'] = self::getTypeName($types); + } + + /** + * Get the name of the dynamic union type. + * + * @param array{collection: Collection, blueprint: Blueprint} $types + */ + public static function getTypeName(array $types): string + { + $typeNames = array_map(function ($type) { + return EntryType::buildName($type['collection'], $type['blueprint']); + }, $types); + + return 'DynamicEntryUnionType_'.implode('_', $typeNames); + } + + public function types(): array + { + return array_map(function ($type) { + return GraphQL::type(EntryType::buildName($type['collection'], $type['blueprint'])); + }, $this->types); + } + + public function resolveType($value) + { + return GraphQL::type(EntryType::buildName($value->collection(), $value->blueprint())); + } +} diff --git a/src/GraphQL/Types/DynamicTermUnionType.php b/src/GraphQL/Types/DynamicTermUnionType.php new file mode 100644 index 00000000000..ad16587e909 --- /dev/null +++ b/src/GraphQL/Types/DynamicTermUnionType.php @@ -0,0 +1,48 @@ + 'DynamicTermUnionType', + ]; + + public function __construct(protected array $types) + { + $this->attributes['name'] = self::getTypeName($types); + } + + /** + * Get the name of the dynamic union type + * + * @param array{taxonomy: Taxonomy, blueprint: Blueprint} $types + */ + public static function getTypeName(array $types): string + { + $typeNames = array_map(function ($type) { + return TermType::buildName($type['taxonomy'], $type['blueprint']); + }, $types); + + return 'DynamicTermUnion_'.implode('_', $typeNames); + } + + public function types(): array + { + return array_map(function ($type) { + return GraphQL::type(TermType::buildName($type['taxonomy'], $type['blueprint'])); + }, $this->types); + } + + public function resolveType($value) + { + return GraphQL::type(TermType::buildName($value->term()->taxonomy(), $value->term()->blueprint())); + } +} diff --git a/tests/GraphQL/EntriesFieldtypeGqlTypeTest.php b/tests/GraphQL/EntriesFieldtypeGqlTypeTest.php new file mode 100644 index 00000000000..1554db319d3 --- /dev/null +++ b/tests/GraphQL/EntriesFieldtypeGqlTypeTest.php @@ -0,0 +1,108 @@ +once() + ->with('EntryInterface') + ->andReturn((object) ['name' => 'EntryInterface']); + + GraphQL::shouldReceive('addType')->never(); + + $this->fieldtype([ + // no collections configured + 'max_items' => 1, + ])->toGqlType(); + } + + #[Test] + public function it_uses_a_concrete_entry_type_when_a_single_blueprint_is_targeted() + { + Collection::make('blog_posts')->save(); + + /** @var \Statamic\Fields\Blueprint $article */ + $article = tap($this->partialMock(\Statamic\Fields\Blueprint::class), function ($m) { + $m->shouldReceive('handle')->andReturn('article'); + }); + BlueprintRepository::shouldReceive('in') + ->with('collections/blog_posts') + ->andReturn(collect(['article' => $article])); + + $expected = EntryType::buildName(Collection::findByHandle('blog_posts'), $article); + + GraphQL::shouldReceive('type') + ->once() + ->with($expected) + ->andReturn((object) ['name' => $expected]); + + GraphQL::shouldReceive('addType')->never(); + + $this->fieldtype([ + 'collections' => ['blog_posts'], + 'max_items' => 1, + ])->toGqlType(); + } + + #[Test] + public function it_uses_a_dynamic_union_when_multiple_blueprints_are_possible() + { + Collection::make('blog_posts')->save(); + + $article = tap($this->partialMock(\Statamic\Fields\Blueprint::class), function ($m) { + $m->shouldReceive('handle')->andReturn('article'); + }); + $artDirected = tap($this->partialMock(\Statamic\Fields\Blueprint::class), function ($m) { + $m->shouldReceive('handle')->andReturn('art_directed'); + }); + + BlueprintRepository::shouldReceive('in') + ->with('collections/blog_posts') + ->andReturn(collect(['article' => $article, 'art_directed' => $artDirected])); + + $collection = Collection::findByHandle('blog_posts'); + $expectedName = DynamicEntryUnionType::getTypeName([ + ['collection' => $collection, 'blueprint' => $article], + ['collection' => $collection, 'blueprint' => $artDirected], + ]); + + // Ensure the concrete entry types exist before the union resolves its members. + EntryInterface::addTypes(); + + $type = $this->fieldtype([ + 'collections' => ['blog_posts'], + 'max_items' => 1, + ])->toGqlType(); + + $this->assertEquals($expectedName, $type->name); + } + + private function fieldtype(array $config = []): Entries + { + $field = new Field('test', array_merge([ + 'type' => 'entries', + ], $config)); + + return (new Entries)->setField($field); + } +} diff --git a/tests/GraphQL/TermsFieldtypeGqlTypeTest.php b/tests/GraphQL/TermsFieldtypeGqlTypeTest.php new file mode 100644 index 00000000000..52dff707fe9 --- /dev/null +++ b/tests/GraphQL/TermsFieldtypeGqlTypeTest.php @@ -0,0 +1,114 @@ +once() + ->with(TermInterface::NAME) + ->andReturn((object) ['name' => TermInterface::NAME]); + + GraphQL::shouldReceive('addType')->never(); + GraphQL::shouldReceive('listOf')->never(); + + $this->fieldtype([ + // no taxonomies configured + 'max_items' => 1, + ])->toGqlType(); + } + + #[Test] + public function it_uses_a_concrete_term_type_when_a_single_blueprint_is_possible() + { + /** @var \Statamic\Taxonomies\Taxonomy $taxonomy */ + $taxonomy = tap(Taxonomy::make('tags'))->save(); + + /** @var \Statamic\Fields\Blueprint $tag */ + $tag = tap($this->partialMock(\Statamic\Fields\Blueprint::class), function ($m) { + $m->shouldReceive('handle')->andReturn('tag'); + }); + + BlueprintRepository::shouldReceive('in') + ->with('taxonomies/tags') + ->andReturn(collect(['tag' => $tag])); + + $expected = TermType::buildName($taxonomy, $tag); + + GraphQL::shouldReceive('type') + ->once() + ->with($expected) + ->andReturn((object) ['name' => $expected]); + + GraphQL::shouldReceive('addType')->never(); + GraphQL::shouldReceive('listOf')->never(); + + $this->fieldtype([ + 'taxonomies' => ['tags'], + 'max_items' => 1, + ])->toGqlType(); + } + + #[Test] + public function it_uses_a_dynamic_union_when_multiple_blueprints_are_possible() + { + /** @var \Statamic\Taxonomies\Taxonomy $taxonomy */ + $taxonomy = tap(Taxonomy::make('tags'))->save(); + + $primary = tap($this->partialMock(\Statamic\Fields\Blueprint::class), function ($m) { + $m->shouldReceive('handle')->andReturn('primary'); + }); + $secondary = tap($this->partialMock(\Statamic\Fields\Blueprint::class), function ($m) { + $m->shouldReceive('handle')->andReturn('secondary'); + }); + + BlueprintRepository::shouldReceive('in') + ->with('taxonomies/tags') + ->andReturn(collect(['primary' => $primary, 'secondary' => $secondary])); + + $expectedName = DynamicTermUnionType::getTypeName([ + ['taxonomy' => $taxonomy, 'blueprint' => $primary], + ['taxonomy' => $taxonomy, 'blueprint' => $secondary], + ]); + + // Ensure the concrete term types exist before the union resolves its members. + TermInterface::addTypes(); + + /** @var object $type */ + $type = $this->fieldtype([ + 'taxonomies' => ['tags'], + 'max_items' => 1, + ])->toGqlType(); + + $this->assertEquals($expectedName, $type->name); + } + + private function fieldtype(array $config = []): Terms + { + $field = new Field('test', array_merge([ + 'type' => 'terms', + ], $config)); + + return (new Terms)->setField($field); + } +} + From e7840efaf7bc3765353f29c09b95d1a9c41f6839 Mon Sep 17 00:00:00 2001 From: Oguz Han Asnaz Date: Thu, 30 Apr 2026 22:05:13 +0200 Subject: [PATCH 2/6] feat: add toggle for improved types --- config/graphql.php | 13 +++++++++++++ src/Fieldtypes/Entries.php | 11 +++++++++++ src/Fieldtypes/Terms.php | 10 ++++++++++ 3 files changed, 34 insertions(+) diff --git a/config/graphql.php b/config/graphql.php index 329c0d463f0..6bc5b66a53f 100644 --- a/config/graphql.php +++ b/config/graphql.php @@ -27,6 +27,19 @@ 'users' => false, ], + /* + |-------------------------------------------------------------------------- + | Improved Types + |-------------------------------------------------------------------------- + | + | When enabled, fields like entries and terms can return dynamically + | generated union types when multiple blueprints are possible. Also will + | use non-nullable types for entries and terms. + | + */ + + 'improved_types' => env('STATAMIC_GRAPHQL_IMPROVED_TYPES', true), + /* |-------------------------------------------------------------------------- | Authentication diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php index 01577bdb8cd..019203cc7d3 100644 --- a/src/Fieldtypes/Entries.php +++ b/src/Fieldtypes/Entries.php @@ -458,6 +458,17 @@ protected function getConfiguredCollections() public function toGqlType() { + // Fallback to old behaviour if improved types are disabled. + if (! config('statamic.graphql.improved_types', false)) { + $type = GraphQL::type('EntryInterface'); + + if ($this->config('max_items') !== 1) { + $type = GraphQL::listOf($type); + } + + return $type; + } + // If the fieldtype isn't constrained to specific collections, return the generic EntryInterface. if (empty($this->config('collections'))) { $type = GraphQL::type(EntryInterface::NAME); diff --git a/src/Fieldtypes/Terms.php b/src/Fieldtypes/Terms.php index 1724e7b0768..ac1ed257e8c 100644 --- a/src/Fieldtypes/Terms.php +++ b/src/Fieldtypes/Terms.php @@ -531,6 +531,16 @@ protected function getConfiguredTaxonomies() public function toGqlType() { + if (! config('statamic.graphql.improved_types', false)) { + $type = GraphQL::type(TermInterface::NAME); + + if ($this->config('max_items') !== 1) { + $type = GraphQL::listOf($type); + } + + return $type; + } + // If the fieldtype isn't constrained to specific taxonomies, return the generic TermInterface. if (empty($this->field()->config()['taxonomies'])) { $type = GraphQL::type(TermInterface::NAME); From 4a3e45cb6b09680d0c5d86e04f99ea5e60873b3f Mon Sep 17 00:00:00 2001 From: Oguz Han Asnaz Date: Thu, 14 May 2026 15:02:07 +0200 Subject: [PATCH 3/6] feat: add dynamic union GraphQL type for asset handling and add tests --- src/Fieldtypes/Assets/Assets.php | 17 ++- tests/GraphQL/AssetsFieldtypeGqlTypeTest.php | 106 +++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 tests/GraphQL/AssetsFieldtypeGqlTypeTest.php diff --git a/src/Fieldtypes/Assets/Assets.php b/src/Fieldtypes/Assets/Assets.php index 3a419e3503e..402803af54f 100644 --- a/src/Fieldtypes/Assets/Assets.php +++ b/src/Fieldtypes/Assets/Assets.php @@ -18,6 +18,7 @@ use Statamic\Fields\Fieldtype; use Statamic\Fieldtypes\UpdatesReferences; use Statamic\GraphQL\Types\AssetInterface; +use Statamic\GraphQL\Types\AssetType; use Statamic\Http\Resources\CP\Assets\AssetsFieldtypeAsset as AssetResource; use Statamic\Query\Scopes\Filter; use Statamic\Support\Arr; @@ -492,10 +493,22 @@ protected function getItemsForPreProcessIndex($values): Collection public function toGqlType() { - $type = GraphQL::type(AssetInterface::NAME); + // Fallback to old behaviour if improved types are disabled. + if (! config('statamic.graphql.improved_types', false)) { + $type = GraphQL::type(AssetInterface::NAME); + + if ($this->config('max_files') !== 1) { + $type = GraphQL::listOf($type); + } + + return $type; + } + + $container = $this->container(); + $type = GraphQL::type(AssetType::buildName($container)); if ($this->config('max_files') !== 1) { - $type = GraphQL::listOf($type); + $type = GraphQL::listOf(GraphQL::nonNull($type)); } return $type; diff --git a/tests/GraphQL/AssetsFieldtypeGqlTypeTest.php b/tests/GraphQL/AssetsFieldtypeGqlTypeTest.php new file mode 100644 index 00000000000..0488eeca76a --- /dev/null +++ b/tests/GraphQL/AssetsFieldtypeGqlTypeTest.php @@ -0,0 +1,106 @@ +set('statamic.graphql.improved_types', false); + + GraphQL::shouldReceive('type') + ->once() + ->with(AssetInterface::NAME) + ->andReturn((object) ['name' => AssetInterface::NAME]); + + GraphQL::shouldReceive('addType')->never(); + GraphQL::shouldReceive('listOf')->never(); + + $this->fieldtype([ + 'container' => 'test_container', + 'max_files' => 1, + ])->toGqlType(); + } + + #[Test] + public function it_uses_a_concrete_asset_type_when_a_container_is_configured() + { + config()->set('statamic.graphql.improved_types', true); + + Storage::fake('test', ['url' => '/assets']); + AssetContainer::make('photos')->disk('test')->save(); + + $container = AssetContainer::findByHandle('photos'); + $expected = AssetType::buildName($container); + + GraphQL::shouldReceive('type') + ->once() + ->with($expected) + ->andReturn((object) ['name' => $expected]); + + GraphQL::shouldReceive('addType')->never(); + GraphQL::shouldReceive('listOf')->never(); + + $this->fieldtype([ + 'container' => 'photos', + 'max_files' => 1, + ])->toGqlType(); + } + + #[Test] + public function it_wraps_in_non_null_list_when_max_files_is_not_one() + { + config()->set('statamic.graphql.improved_types', true); + + Storage::fake('test', ['url' => '/assets']); + AssetContainer::make('documents')->disk('test')->save(); + + $container = AssetContainer::findByHandle('documents'); + $expected = AssetType::buildName($container); + + $innerType = (object) ['name' => $expected]; + + GraphQL::shouldReceive('type') + ->once() + ->with($expected) + ->andReturn($innerType); + + GraphQL::shouldReceive('nonNull') + ->once() + ->with($innerType) + ->andReturn((object) ['name' => 'NonNull('.$expected.')']); + + GraphQL::shouldReceive('listOf') + ->once() + ->andReturn((object) ['name' => 'ListOf(NonNull('.$expected.'))']); + + $this->fieldtype([ + 'container' => 'documents', + ])->toGqlType(); + } + + private function fieldtype(array $config = []): Assets + { + $field = new Field('test', array_merge([ + 'type' => 'assets', + ], $config)); + + return (new Assets)->setField($field); + } +} From 50d279ef711fe54b01ba35f8e426a0d5d77c06fb Mon Sep 17 00:00:00 2001 From: Oguz Han Asnaz Date: Thu, 14 May 2026 20:37:56 +0200 Subject: [PATCH 4/6] refactor: change config structure --- config/graphql.php | 4 +++- src/Fieldtypes/Assets/Assets.php | 2 +- src/Fieldtypes/Entries.php | 2 +- src/Fieldtypes/Terms.php | 2 +- tests/GraphQL/AssetsFieldtypeGqlTypeTest.php | 6 +++--- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/config/graphql.php b/config/graphql.php index 6bc5b66a53f..8147e836661 100644 --- a/config/graphql.php +++ b/config/graphql.php @@ -38,7 +38,9 @@ | */ - 'improved_types' => env('STATAMIC_GRAPHQL_IMPROVED_TYPES', true), + 'improved_types' => [ + 'enabled' => env('STATAMIC_GRAPHQL_IMPROVED_TYPES', true), + ], /* |-------------------------------------------------------------------------- diff --git a/src/Fieldtypes/Assets/Assets.php b/src/Fieldtypes/Assets/Assets.php index 402803af54f..6011c5f66e4 100644 --- a/src/Fieldtypes/Assets/Assets.php +++ b/src/Fieldtypes/Assets/Assets.php @@ -494,7 +494,7 @@ protected function getItemsForPreProcessIndex($values): Collection public function toGqlType() { // Fallback to old behaviour if improved types are disabled. - if (! config('statamic.graphql.improved_types', false)) { + if (! config('statamic.graphql.improved_types.enabled', false)) { $type = GraphQL::type(AssetInterface::NAME); if ($this->config('max_files') !== 1) { diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php index 019203cc7d3..67d2d66b825 100644 --- a/src/Fieldtypes/Entries.php +++ b/src/Fieldtypes/Entries.php @@ -459,7 +459,7 @@ protected function getConfiguredCollections() public function toGqlType() { // Fallback to old behaviour if improved types are disabled. - if (! config('statamic.graphql.improved_types', false)) { + if (! config('statamic.graphql.improved_types.enabled', false)) { $type = GraphQL::type('EntryInterface'); if ($this->config('max_items') !== 1) { diff --git a/src/Fieldtypes/Terms.php b/src/Fieldtypes/Terms.php index ac1ed257e8c..a0c99307b2c 100644 --- a/src/Fieldtypes/Terms.php +++ b/src/Fieldtypes/Terms.php @@ -531,7 +531,7 @@ protected function getConfiguredTaxonomies() public function toGqlType() { - if (! config('statamic.graphql.improved_types', false)) { + if (! config('statamic.graphql.improved_types.enabled', false)) { $type = GraphQL::type(TermInterface::NAME); if ($this->config('max_items') !== 1) { diff --git a/tests/GraphQL/AssetsFieldtypeGqlTypeTest.php b/tests/GraphQL/AssetsFieldtypeGqlTypeTest.php index 0488eeca76a..7075f257832 100644 --- a/tests/GraphQL/AssetsFieldtypeGqlTypeTest.php +++ b/tests/GraphQL/AssetsFieldtypeGqlTypeTest.php @@ -22,7 +22,7 @@ class AssetsFieldtypeGqlTypeTest extends TestCase #[Test] public function it_uses_asset_interface_when_improved_types_are_disabled() { - config()->set('statamic.graphql.improved_types', false); + config()->set('statamic.graphql.improved_types.enabled', false); GraphQL::shouldReceive('type') ->once() @@ -41,7 +41,7 @@ public function it_uses_asset_interface_when_improved_types_are_disabled() #[Test] public function it_uses_a_concrete_asset_type_when_a_container_is_configured() { - config()->set('statamic.graphql.improved_types', true); + config()->set('statamic.graphql.improved_types.enabled', true); Storage::fake('test', ['url' => '/assets']); AssetContainer::make('photos')->disk('test')->save(); @@ -66,7 +66,7 @@ public function it_uses_a_concrete_asset_type_when_a_container_is_configured() #[Test] public function it_wraps_in_non_null_list_when_max_files_is_not_one() { - config()->set('statamic.graphql.improved_types', true); + config()->set('statamic.graphql.improved_types.enabled', true); Storage::fake('test', ['url' => '/assets']); AssetContainer::make('documents')->disk('test')->save(); From 3040bea133d478ea76e548ccc763e09137fa8035 Mon Sep 17 00:00:00 2001 From: Oguz Han Asnaz Date: Thu, 14 May 2026 20:51:53 +0200 Subject: [PATCH 5/6] feat: implement specific entries and terms queries for GraphQL --- config/graphql.php | 12 ++ src/GraphQL/DefaultSchema.php | 72 +++++++ src/GraphQL/Queries/SpecificEntriesQuery.php | 132 ++++++++++++ src/GraphQL/Queries/SpecificTermsQuery.php | 107 ++++++++++ ...DefaultSchemaCollectionTermQueriesTest.php | 197 ++++++++++++++++++ tests/GraphQL/SpecificEntriesQueryTest.php | 83 ++++++++ tests/GraphQL/SpecificTermsQueryTest.php | 82 ++++++++ 7 files changed, 685 insertions(+) create mode 100644 src/GraphQL/Queries/SpecificEntriesQuery.php create mode 100644 src/GraphQL/Queries/SpecificTermsQuery.php create mode 100644 tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php create mode 100644 tests/GraphQL/SpecificEntriesQueryTest.php create mode 100644 tests/GraphQL/SpecificTermsQueryTest.php diff --git a/config/graphql.php b/config/graphql.php index 8147e836661..30fe3dd14c4 100644 --- a/config/graphql.php +++ b/config/graphql.php @@ -36,10 +36,22 @@ | generated union types when multiple blueprints are possible. Also will | use non-nullable types for entries and terms. | + | You may also register per-collection and per-taxonomy queries that + | return typed results. List collection or taxonomy handles under + | "collections" and "terms", or use "*" to enable all. + | */ 'improved_types' => [ 'enabled' => env('STATAMIC_GRAPHQL_IMPROVED_TYPES', true), + 'collections' => [ + // 'blog_posts', + // '*', + ], + 'terms' => [ + // 'tags', + // '*', + ], ], /* diff --git a/src/GraphQL/DefaultSchema.php b/src/GraphQL/DefaultSchema.php index ed2e70278a4..2f338eec8d0 100644 --- a/src/GraphQL/DefaultSchema.php +++ b/src/GraphQL/DefaultSchema.php @@ -4,7 +4,9 @@ use Facades\Statamic\API\ResourceAuthorizer; use Rebing\GraphQL\Support\Contracts\ConfigConvertible; +use Statamic\Facades\Collection; use Statamic\Facades\GraphQL; +use Statamic\Facades\Taxonomy; use Statamic\GraphQL\Middleware\CacheResponse; use Statamic\GraphQL\Middleware\HandleAuthentication; use Statamic\GraphQL\Queries\AssetContainerQuery; @@ -23,6 +25,8 @@ use Statamic\GraphQL\Queries\NavsQuery; use Statamic\GraphQL\Queries\PingQuery; use Statamic\GraphQL\Queries\SitesQuery; +use Statamic\GraphQL\Queries\SpecificEntriesQuery; +use Statamic\GraphQL\Queries\SpecificTermsQuery; use Statamic\GraphQL\Queries\TaxonomiesQuery; use Statamic\GraphQL\Queries\TaxonomyQuery; use Statamic\GraphQL\Queries\TermQuery; @@ -69,12 +73,80 @@ private function getQueries() $queries = $queries->merge(ResourceAuthorizer::isAllowed('graphql', $resource) ? $qs : []); }); + $queries = $queries + ->merge($this->getSpecificEntriesQueries()) + ->merge($this->getSpecificTermsQueries()); + return $queries ->merge(config('statamic.graphql.queries', [])) ->merge(GraphQL::getExtraQueries()) ->all(); } + private function getSpecificEntriesQueries(): array + { + // rebing/graphql-laravel calls toConfig() eagerly during boot + // at which point the Stache is not yet ready. + // The schema is rebuilt when an actual request hits the controller, + // where the Stache is fully booted, so wildcards still expand correctly there. + if (! app()->isBooted()) { + return []; + } + + if (! ResourceAuthorizer::isAllowed('graphql', 'collections')) { + return []; + } + + $configured = config('statamic.graphql.improved_types.collections', []); + + if (empty($configured)) { + return []; + } + + if (in_array('*', $configured)) { + $handles = Collection::handles()->all(); + } else { + $handles = $configured; + } + + $allowed = ResourceAuthorizer::allowedSubResources('graphql', 'collections'); + + return collect($handles) + ->filter(fn ($handle) => in_array($handle, $allowed)) + ->map(fn ($handle) => new SpecificEntriesQuery($handle)) + ->all(); + } + + private function getSpecificTermsQueries(): array + { + if (! app()->isBooted()) { + return []; + } + + if (! ResourceAuthorizer::isAllowed('graphql', 'taxonomies')) { + return []; + } + + $configured = config('statamic.graphql.improved_types.terms', []); + + if (empty($configured)) { + return []; + } + + if (in_array('*', $configured)) { + $handles = Taxonomy::handles()->all(); + } else { + $handles = $configured; + } + + $allowed = ResourceAuthorizer::allowedSubResources('graphql', 'taxonomies'); + + return collect($handles) + ->filter(fn ($handle) => in_array($handle, $allowed)) + ->map(fn ($handle) => new SpecificTermsQuery($handle)) + ->all(); + } + private function getMiddleware() { return array_merge( diff --git a/src/GraphQL/Queries/SpecificEntriesQuery.php b/src/GraphQL/Queries/SpecificEntriesQuery.php new file mode 100644 index 00000000000..3efd98f8807 --- /dev/null +++ b/src/GraphQL/Queries/SpecificEntriesQuery.php @@ -0,0 +1,132 @@ +attributes['name'] = Str::camel($collectionHandle); + + parent::__construct(); + } + + public function type(): Type + { + $collection = Collection::findByHandle($this->collectionHandle); + $blueprints = $collection->entryBlueprints(); + + $combinations = $blueprints->map(fn ($blueprint) => [ + 'collection' => $collection, + 'blueprint' => $blueprint, + ])->values()->all(); + + if (count($combinations) === 1) { + $type = GraphQL::type(EntryType::buildName($combinations[0]['collection'], $combinations[0]['blueprint'])); + } else { + $unionType = new DynamicEntryUnionType($combinations); + GraphQL::addType($unionType); + $type = GraphQL::type($unionType->name); + } + + return GraphQL::paginate($type); + } + + public function args(): array + { + return [ + 'limit' => GraphQL::int(), + 'page' => GraphQL::int(), + 'filter' => GraphQL::type(JsonArgument::NAME), + 'query_scope' => GraphQL::type(JsonArgument::NAME), + 'sort' => GraphQL::listOf(GraphQL::string()), + 'site' => GraphQL::string(), + ]; + } + + public function resolve($root, $args) + { + $query = Entry::query(); + + $query->where('collection', $this->collectionHandle); + + if ($site = $args['site'] ?? null) { + $query->where('site', $site); + } + + $this->filterQuery($query, $args['filter'] ?? []); + + $this->scopeQuery($query, $args['query_scope'] ?? []); + + $this->sortQuery($query, $args['sort'] ?? []); + + return $query->paginate($args['limit'] ?? 1000); + } + + private function filterQuery($query, $filters) + { + if (! isset($filters['status']) && ! isset($filters['published'])) { + $filters['status'] = 'published'; + } + + $this->traitFilterQuery($query, $filters); + } + + private function sortQuery($query, $sorts) + { + if (empty($sorts)) { + $sorts = ['id']; + } + + foreach ($sorts as $sort) { + $order = 'asc'; + + if (Str::contains($sort, ' ')) { + [$sort, $order] = explode(' ', $sort); + } + + if ($sort = OrderBy::column($sort)) { + $query->orderBy($sort, $order); + } + } + } + + public function allowedFilters($args) + { + return FilterAuthorizer::allowedForSubResources('graphql', 'collections', $this->collectionHandle); + } + + public function allowedScopes($args) + { + return QueryScopeAuthorizer::allowedForSubResources('graphql', 'collections', $this->collectionHandle); + } +} diff --git a/src/GraphQL/Queries/SpecificTermsQuery.php b/src/GraphQL/Queries/SpecificTermsQuery.php new file mode 100644 index 00000000000..30f40c857cc --- /dev/null +++ b/src/GraphQL/Queries/SpecificTermsQuery.php @@ -0,0 +1,107 @@ +attributes['name'] = Str::camel($taxonomyHandle); + + parent::__construct(); + } + + public function type(): Type + { + $taxonomy = Taxonomy::findByHandle($this->taxonomyHandle); + $blueprints = $taxonomy->termBlueprints(); + + $combinations = $blueprints->map(fn ($blueprint) => [ + 'taxonomy' => $taxonomy, + 'blueprint' => $blueprint, + ])->values()->all(); + + if (count($combinations) === 1) { + $type = GraphQL::type(TermType::buildName($combinations[0]['taxonomy'], $combinations[0]['blueprint'])); + } else { + $unionType = new DynamicTermUnionType($combinations); + GraphQL::addType($unionType); + $type = GraphQL::type($unionType->name); + } + + return GraphQL::paginate($type); + } + + public function args(): array + { + return [ + 'limit' => GraphQL::int(), + 'page' => GraphQL::int(), + 'filter' => GraphQL::type(JsonArgument::NAME), + 'sort' => GraphQL::listOf(GraphQL::string()), + 'site' => GraphQL::string(), + ]; + } + + public function resolve($root, $args) + { + $query = Term::query(); + + $query->where('taxonomy', $this->taxonomyHandle); + + if ($filters = $args['filter'] ?? null) { + $this->filterQuery($query, $filters); + } + + if ($sort = $args['sort'] ?? null) { + $this->sortQuery($query, $sort); + } + + if ($site = $args['site'] ?? null) { + $query->where('site', $site); + } + + return $query->paginate($args['limit'] ?? 1000); + } + + private function sortQuery($query, $sorts) + { + foreach ($sorts as $sort) { + $order = 'asc'; + + if (Str::contains($sort, ' ')) { + [$sort, $order] = explode(' ', $sort); + } + + if ($sort = OrderBy::column($sort)) { + $query->orderBy($sort, $order); + } + } + } + + public function allowedFilters($args) + { + return FilterAuthorizer::allowedForSubResources('graphql', 'taxonomies', $this->taxonomyHandle); + } +} diff --git a/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php b/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php new file mode 100644 index 00000000000..7817450aefc --- /dev/null +++ b/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php @@ -0,0 +1,197 @@ +set("statamic.graphql.resources.{$resource}", true); + } + + private function mockBlueprint(string $namespace, string $handle): void + { + $blueprint = tap($this->partialMock(\Statamic\Fields\Blueprint::class), function ($m) use ($handle) { + $m->shouldReceive('handle')->andReturn($handle); + $m->shouldReceive('addGqlTypes')->zeroOrMoreTimes(); + }); + + BlueprintRepository::shouldReceive('in') + ->with($namespace) + ->andReturn(collect([$handle => $blueprint])); + BlueprintRepository::shouldReceive('find')->andReturn($blueprint); + } + + #[Test] + public function it_registers_no_collection_queries_when_config_is_empty() + { + $this->enableResource('collections'); + config()->set('statamic.graphql.improved_types.collections', []); + + $queries = $this->getQueryInstances(); + + $this->assertEmpty( + collect($queries)->filter(fn ($q) => $q instanceof SpecificEntriesQuery)->all() + ); + } + + #[Test] + public function it_registers_collection_queries_for_explicit_handles() + { + Collection::make('blog')->save(); + Collection::make('pages')->save(); + $this->mockBlueprint('collections/blog', 'post'); + $this->mockBlueprint('collections/pages', 'page'); + + $this->enableResource('collections'); + config()->set('statamic.graphql.improved_types.collections', ['blog']); + + $queries = $this->getQueryInstances(); + $collectionQueries = collect($queries) + ->filter(fn ($q) => $q instanceof SpecificEntriesQuery) + ->values(); + + $this->assertCount(1, $collectionQueries); + $this->assertEquals('blog', $this->getQueryName($collectionQueries[0])); + } + + #[Test] + public function it_registers_collection_queries_for_all_when_wildcard_is_used() + { + Collection::make('blog')->save(); + Collection::make('pages')->save(); + $this->mockBlueprint('collections/blog', 'post'); + $this->mockBlueprint('collections/pages', 'page'); + + $this->enableResource('collections'); + config()->set('statamic.graphql.improved_types.collections', ['*']); + + $queries = $this->getQueryInstances(); + $collectionQueries = collect($queries) + ->filter(fn ($q) => $q instanceof SpecificEntriesQuery) + ->values(); + + $names = $collectionQueries->map(fn ($q) => $this->getQueryName($q))->sort()->values()->all(); + + $this->assertCount(2, $collectionQueries); + $this->assertEquals(['blog', 'pages'], $names); + } + + #[Test] + public function it_does_not_register_collection_queries_when_resource_is_disabled() + { + Collection::make('blog')->save(); + $this->mockBlueprint('collections/blog', 'post'); + + config()->set('statamic.graphql.resources.collections', false); + config()->set('statamic.graphql.improved_types.collections', ['*']); + + $queries = $this->getQueryInstances(); + + $this->assertEmpty( + collect($queries)->filter(fn ($q) => $q instanceof SpecificEntriesQuery)->all() + ); + } + + #[Test] + public function it_registers_no_taxonomy_queries_when_config_is_empty() + { + $this->enableResource('taxonomies'); + config()->set('statamic.graphql.improved_types.terms', []); + + $queries = $this->getQueryInstances(); + + $this->assertEmpty( + collect($queries)->filter(fn ($q) => $q instanceof SpecificTermsQuery)->all() + ); + } + + #[Test] + public function it_registers_taxonomy_queries_for_explicit_handles() + { + Taxonomy::make('tags')->save(); + Taxonomy::make('categories')->save(); + $this->mockBlueprint('taxonomies/tags', 'tag'); + $this->mockBlueprint('taxonomies/categories', 'category'); + + $this->enableResource('taxonomies'); + config()->set('statamic.graphql.improved_types.terms', ['tags']); + + $queries = $this->getQueryInstances(); + $termQueries = collect($queries) + ->filter(fn ($q) => $q instanceof SpecificTermsQuery) + ->values(); + + $this->assertCount(1, $termQueries); + $this->assertEquals('tags', $this->getQueryName($termQueries[0])); + } + + #[Test] + public function it_registers_taxonomy_queries_for_all_when_wildcard_is_used() + { + Taxonomy::make('tags')->save(); + Taxonomy::make('categories')->save(); + $this->mockBlueprint('taxonomies/tags', 'tag'); + $this->mockBlueprint('taxonomies/categories', 'category'); + + $this->enableResource('taxonomies'); + config()->set('statamic.graphql.improved_types.terms', ['*']); + + $queries = $this->getQueryInstances(); + $termQueries = collect($queries) + ->filter(fn ($q) => $q instanceof SpecificTermsQuery) + ->values(); + + $names = $termQueries->map(fn ($q) => $this->getQueryName($q))->sort()->values()->all(); + + $this->assertCount(2, $termQueries); + $this->assertEquals(['categories', 'tags'], $names); + } + + #[Test] + public function it_does_not_register_taxonomy_queries_when_resource_is_disabled() + { + Taxonomy::make('tags')->save(); + $this->mockBlueprint('taxonomies/tags', 'tag'); + + config()->set('statamic.graphql.resources.taxonomies', false); + config()->set('statamic.graphql.improved_types.terms', ['*']); + + $queries = $this->getQueryInstances(); + + $this->assertEmpty( + collect($queries)->filter(fn ($q) => $q instanceof SpecificTermsQuery)->all() + ); + } + + private function getQueryName($query): string + { + $reflection = new \ReflectionProperty($query, 'attributes'); + + return $reflection->getValue($query)['name']; + } + + private function getQueryInstances(): array + { + $schema = app(DefaultSchema::class); + $config = $schema->getConfig(); + + return collect($config['query']) + ->map(fn ($q) => is_string($q) ? app($q) : $q) + ->all(); + } +} diff --git a/tests/GraphQL/SpecificEntriesQueryTest.php b/tests/GraphQL/SpecificEntriesQueryTest.php new file mode 100644 index 00000000000..6a1288c6370 --- /dev/null +++ b/tests/GraphQL/SpecificEntriesQueryTest.php @@ -0,0 +1,83 @@ +save(); + $this->mockBlueprints('blog_posts', ['post']); + + $query = new SpecificEntriesQuery('blog_posts'); + + $this->assertEquals('blogPosts', $this->getQueryName($query)); + } + + #[Test] + public function it_uses_simple_handle_as_query_name() + { + Collection::make('pages')->save(); + $this->mockBlueprints('pages', ['page']); + + $query = new SpecificEntriesQuery('pages'); + + $this->assertEquals('pages', $this->getQueryName($query)); + } + + #[Test] + public function it_does_not_include_collection_arg() + { + Collection::make('pages')->save(); + $this->mockBlueprints('pages', ['page']); + + $query = new SpecificEntriesQuery('pages'); + $args = $query->args(); + + $this->assertArrayNotHasKey('collection', $args); + $this->assertArrayHasKey('limit', $args); + $this->assertArrayHasKey('page', $args); + $this->assertArrayHasKey('filter', $args); + $this->assertArrayHasKey('query_scope', $args); + $this->assertArrayHasKey('sort', $args); + $this->assertArrayHasKey('site', $args); + } + + private function mockBlueprints(string $collection, array $handles): void + { + $mapped = []; + + foreach ($handles as $handle) { + $bp = tap($this->partialMock(Blueprint::class), function ($m) use ($handle) { + $m->shouldReceive('handle')->andReturn($handle); + $m->shouldReceive('addGqlTypes')->zeroOrMoreTimes(); + }); + $mapped[$handle] = $bp; + } + + BlueprintRepository::shouldReceive('in') + ->with("collections/{$collection}") + ->andReturn(collect($mapped)); + BlueprintRepository::shouldReceive('find')->andReturn(array_values($mapped)[0]); + } + + private function getQueryName($query): string + { + $reflection = new \ReflectionProperty($query, 'attributes'); + + return $reflection->getValue($query)['name']; + } +} diff --git a/tests/GraphQL/SpecificTermsQueryTest.php b/tests/GraphQL/SpecificTermsQueryTest.php new file mode 100644 index 00000000000..0066dfef683 --- /dev/null +++ b/tests/GraphQL/SpecificTermsQueryTest.php @@ -0,0 +1,82 @@ +save(); + $this->mockBlueprints('product_categories', ['category']); + + $query = new SpecificTermsQuery('product_categories'); + + $this->assertEquals('productCategories', $this->getQueryName($query)); + } + + #[Test] + public function it_uses_simple_handle_as_query_name() + { + Taxonomy::make('tags')->save(); + $this->mockBlueprints('tags', ['tag']); + + $query = new SpecificTermsQuery('tags'); + + $this->assertEquals('tags', $this->getQueryName($query)); + } + + #[Test] + public function it_does_not_include_taxonomy_arg() + { + Taxonomy::make('tags')->save(); + $this->mockBlueprints('tags', ['tag']); + + $query = new SpecificTermsQuery('tags'); + $args = $query->args(); + + $this->assertArrayNotHasKey('taxonomy', $args); + $this->assertArrayHasKey('limit', $args); + $this->assertArrayHasKey('page', $args); + $this->assertArrayHasKey('filter', $args); + $this->assertArrayHasKey('sort', $args); + $this->assertArrayHasKey('site', $args); + } + + private function mockBlueprints(string $taxonomy, array $handles): void + { + $mapped = []; + + foreach ($handles as $handle) { + $bp = tap($this->partialMock(Blueprint::class), function ($m) use ($handle) { + $m->shouldReceive('handle')->andReturn($handle); + $m->shouldReceive('addGqlTypes')->zeroOrMoreTimes(); + }); + $mapped[$handle] = $bp; + } + + BlueprintRepository::shouldReceive('in') + ->with("taxonomies/{$taxonomy}") + ->andReturn(collect($mapped)); + BlueprintRepository::shouldReceive('find')->andReturn(array_values($mapped)[0]); + } + + private function getQueryName($query): string + { + $reflection = new \ReflectionProperty($query, 'attributes'); + + return $reflection->getValue($query)['name']; + } +} From 110b7d51155388776a7e84166a6d7da753c19c82 Mon Sep 17 00:00:00 2001 From: Oguz Han Asnaz Date: Thu, 14 May 2026 22:39:38 +0200 Subject: [PATCH 6/6] chore: clean up --- src/GraphQL/Types/DynamicEntryUnionType.php | 7 ------- src/GraphQL/Types/DynamicTermUnionType.php | 7 ------- tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php | 2 ++ tests/GraphQL/SpecificEntriesQueryTest.php | 2 ++ tests/GraphQL/SpecificTermsQueryTest.php | 2 ++ 5 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/GraphQL/Types/DynamicEntryUnionType.php b/src/GraphQL/Types/DynamicEntryUnionType.php index 112eea5f9db..ac64138b774 100644 --- a/src/GraphQL/Types/DynamicEntryUnionType.php +++ b/src/GraphQL/Types/DynamicEntryUnionType.php @@ -6,8 +6,6 @@ use Rebing\GraphQL\Support\Facades\GraphQL; use Rebing\GraphQL\Support\UnionType; -use Statamic\Contracts\Entries\Collection; -use Statamic\Fields\Blueprint; class DynamicEntryUnionType extends UnionType { @@ -20,11 +18,6 @@ public function __construct(protected array $types) $this->attributes['name'] = self::getTypeName($types); } - /** - * Get the name of the dynamic union type. - * - * @param array{collection: Collection, blueprint: Blueprint} $types - */ public static function getTypeName(array $types): string { $typeNames = array_map(function ($type) { diff --git a/src/GraphQL/Types/DynamicTermUnionType.php b/src/GraphQL/Types/DynamicTermUnionType.php index ad16587e909..f40413545b9 100644 --- a/src/GraphQL/Types/DynamicTermUnionType.php +++ b/src/GraphQL/Types/DynamicTermUnionType.php @@ -6,8 +6,6 @@ use Rebing\GraphQL\Support\Facades\GraphQL; use Rebing\GraphQL\Support\UnionType; -use Statamic\Contracts\Taxonomies\Taxonomy; -use Statamic\Fields\Blueprint; class DynamicTermUnionType extends UnionType { @@ -20,11 +18,6 @@ public function __construct(protected array $types) $this->attributes['name'] = self::getTypeName($types); } - /** - * Get the name of the dynamic union type - * - * @param array{taxonomy: Taxonomy, blueprint: Blueprint} $types - */ public static function getTypeName(array $types): string { $typeNames = array_map(function ($type) { diff --git a/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php b/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php index 7817450aefc..d726be038ef 100644 --- a/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php +++ b/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php @@ -180,6 +180,8 @@ public function it_does_not_register_taxonomy_queries_when_resource_is_disabled( private function getQueryName($query): string { + // The Query class does not have a getName() method, + // so we need to use reflection to get the name. $reflection = new \ReflectionProperty($query, 'attributes'); return $reflection->getValue($query)['name']; diff --git a/tests/GraphQL/SpecificEntriesQueryTest.php b/tests/GraphQL/SpecificEntriesQueryTest.php index 6a1288c6370..4ae35ea0154 100644 --- a/tests/GraphQL/SpecificEntriesQueryTest.php +++ b/tests/GraphQL/SpecificEntriesQueryTest.php @@ -76,6 +76,8 @@ private function mockBlueprints(string $collection, array $handles): void private function getQueryName($query): string { + // The Query class does not have a getName() method, + // so we need to use reflection to get the name. $reflection = new \ReflectionProperty($query, 'attributes'); return $reflection->getValue($query)['name']; diff --git a/tests/GraphQL/SpecificTermsQueryTest.php b/tests/GraphQL/SpecificTermsQueryTest.php index 0066dfef683..80bce2571a6 100644 --- a/tests/GraphQL/SpecificTermsQueryTest.php +++ b/tests/GraphQL/SpecificTermsQueryTest.php @@ -75,6 +75,8 @@ private function mockBlueprints(string $taxonomy, array $handles): void private function getQueryName($query): string { + // The Query class does not have a getName() method, + // so we need to use reflection to get the name. $reflection = new \ReflectionProperty($query, 'attributes'); return $reflection->getValue($query)['name'];