From dfe933cd0b4960b2405f99328bd62e338b24a9d6 Mon Sep 17 00:00:00 2001 From: Oguz Han Asnaz Date: Wed, 25 Mar 2026 17:10:28 +0100 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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']; From abf2cc031deccaddf39a894d7a891b53bcc81f96 Mon Sep 17 00:00:00 2001 From: Oguz Han Asnaz Date: Mon, 18 May 2026 11:06:30 +0200 Subject: [PATCH 07/10] fix: ensure non-nullable pagination for specific entries and terms queries --- src/GraphQL/Queries/SpecificEntriesQuery.php | 2 +- src/GraphQL/Queries/SpecificTermsQuery.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/GraphQL/Queries/SpecificEntriesQuery.php b/src/GraphQL/Queries/SpecificEntriesQuery.php index 3efd98f8807..3ede9df49ef 100644 --- a/src/GraphQL/Queries/SpecificEntriesQuery.php +++ b/src/GraphQL/Queries/SpecificEntriesQuery.php @@ -58,7 +58,7 @@ public function type(): Type $type = GraphQL::type($unionType->name); } - return GraphQL::paginate($type); + return GraphQL::nonNull(GraphQL::paginate($type)); } public function args(): array diff --git a/src/GraphQL/Queries/SpecificTermsQuery.php b/src/GraphQL/Queries/SpecificTermsQuery.php index 30f40c857cc..58542434b25 100644 --- a/src/GraphQL/Queries/SpecificTermsQuery.php +++ b/src/GraphQL/Queries/SpecificTermsQuery.php @@ -50,7 +50,7 @@ public function type(): Type $type = GraphQL::type($unionType->name); } - return GraphQL::paginate($type); + return GraphQL::nonNull(GraphQL::paginate($type)); } public function args(): array From 1588621dd76daa69134f944a9f8c7a04aa07535e Mon Sep 17 00:00:00 2001 From: Oguz Han Asnaz Date: Mon, 18 May 2026 14:19:10 +0200 Subject: [PATCH 08/10] feat: add SpecificEntryQuery and SpecificTermQuery for enhanced GraphQL support --- src/GraphQL/DefaultSchema.php | 12 +- src/GraphQL/Queries/SpecificEntryQuery.php | 129 ++++++++++++++++++ src/GraphQL/Queries/SpecificTermQuery.php | 82 +++++++++++ ...DefaultSchemaCollectionTermQueriesTest.php | 34 ++--- tests/GraphQL/SpecificEntryQueryTest.php | 93 +++++++++++++ tests/GraphQL/SpecificTermQueryTest.php | 89 ++++++++++++ 6 files changed, 421 insertions(+), 18 deletions(-) create mode 100644 src/GraphQL/Queries/SpecificEntryQuery.php create mode 100644 src/GraphQL/Queries/SpecificTermQuery.php create mode 100644 tests/GraphQL/SpecificEntryQueryTest.php create mode 100644 tests/GraphQL/SpecificTermQueryTest.php diff --git a/src/GraphQL/DefaultSchema.php b/src/GraphQL/DefaultSchema.php index 2f338eec8d0..4c80389e223 100644 --- a/src/GraphQL/DefaultSchema.php +++ b/src/GraphQL/DefaultSchema.php @@ -26,6 +26,8 @@ use Statamic\GraphQL\Queries\PingQuery; use Statamic\GraphQL\Queries\SitesQuery; use Statamic\GraphQL\Queries\SpecificEntriesQuery; +use Statamic\GraphQL\Queries\SpecificEntryQuery; +use Statamic\GraphQL\Queries\SpecificTermQuery; use Statamic\GraphQL\Queries\SpecificTermsQuery; use Statamic\GraphQL\Queries\TaxonomiesQuery; use Statamic\GraphQL\Queries\TaxonomyQuery; @@ -113,7 +115,10 @@ private function getSpecificEntriesQueries(): array return collect($handles) ->filter(fn ($handle) => in_array($handle, $allowed)) - ->map(fn ($handle) => new SpecificEntriesQuery($handle)) + ->flatMap(fn ($handle) => [ + new SpecificEntriesQuery($handle), + new SpecificEntryQuery($handle), + ]) ->all(); } @@ -143,7 +148,10 @@ private function getSpecificTermsQueries(): array return collect($handles) ->filter(fn ($handle) => in_array($handle, $allowed)) - ->map(fn ($handle) => new SpecificTermsQuery($handle)) + ->flatMap(fn ($handle) => [ + new SpecificTermsQuery($handle), + new SpecificTermQuery($handle), + ]) ->all(); } diff --git a/src/GraphQL/Queries/SpecificEntryQuery.php b/src/GraphQL/Queries/SpecificEntryQuery.php new file mode 100644 index 00000000000..345daed5283 --- /dev/null +++ b/src/GraphQL/Queries/SpecificEntryQuery.php @@ -0,0 +1,129 @@ +attributes['name'] = $name; + + 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) { + return GraphQL::type(EntryType::buildName($combinations[0]['collection'], $combinations[0]['blueprint'])); + } + + $unionType = new DynamicEntryUnionType($combinations); + GraphQL::addType($unionType); + + return GraphQL::type($unionType->name); + } + + public function args(): array + { + return [ + 'id' => GraphQL::string(), + 'slug' => GraphQL::string(), + 'uri' => GraphQL::string(), + 'site' => GraphQL::string(), + 'filter' => GraphQL::type(JsonArgument::NAME), + ]; + } + + public function resolve($root, $args) + { + $query = Entry::query(); + + $query->where('collection', $this->collectionHandle); + + if ($id = $args['id'] ?? null) { + $query->where('id', $id); + } + + if ($slug = $args['slug'] ?? null) { + $query->where('slug', $slug); + } + + if ($uri = $args['uri'] ?? null) { + $query->where('uri', $uri); + } + + if ($site = $args['site'] ?? null) { + $query->where('site', $site); + } + + $filters = $args['filter'] ?? []; + + $this->filterQuery($query, $filters); + + $entry = $query->limit(1)->get()->first(); + + if ($entry && $entry->status() !== 'published' && request()->isLivePreview() && ! request()->isLivePreviewOf($entry)) { + return null; + } + + if ($entry && $filters) { + $allowedFilters = collect($this->allowedFilters([])); + + $forbidden = collect($filters) + ->keys() + ->filter(fn ($filter) => ! $allowedFilters->contains($filter)); + + if ($forbidden->isNotEmpty()) { + throw ValidationException::withMessages([ + 'filter' => 'Forbidden: '.$forbidden->join(', '), + ]); + } + } + + return $entry; + } + + private function filterQuery($query, $filters) + { + if (! request()->isLivePreview() && (! isset($filters['status']) && ! isset($filters['published']))) { + $filters['status'] = 'published'; + } + + $this->traitFilterQuery($query, $filters); + } + + public function allowedFilters($args) + { + return FilterAuthorizer::allowedForSubResources('graphql', 'collections', $this->collectionHandle); + } +} diff --git a/src/GraphQL/Queries/SpecificTermQuery.php b/src/GraphQL/Queries/SpecificTermQuery.php new file mode 100644 index 00000000000..4b098f96a1a --- /dev/null +++ b/src/GraphQL/Queries/SpecificTermQuery.php @@ -0,0 +1,82 @@ +attributes['name'] = $name; + + 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) { + return GraphQL::type(TermType::buildName($combinations[0]['taxonomy'], $combinations[0]['blueprint'])); + } + + $unionType = new DynamicTermUnionType($combinations); + GraphQL::addType($unionType); + + return GraphQL::type($unionType->name); + } + + public function args(): array + { + return [ + 'id' => GraphQL::string(), + ]; + } + + public function resolve($root, $args) + { + $query = Term::query(); + + $query->where('taxonomy', $this->taxonomyHandle); + + if ($id = $args['id'] ?? null) { + $query->where('id', $id); + } + + $term = $query->limit(1)->get()->first(); + + if ($term && ! in_array($taxonomy = $term->taxonomy()->handle(), $this->allowedSubResources())) { + throw ValidationException::withMessages([ + 'id' => 'Forbidden: '.$taxonomy, + ]); + } + + return $term; + } + + public function allowedSubResources() + { + return ResourceAuthorizer::allowedSubResources('graphql', 'taxonomies'); + } +} diff --git a/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php b/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php index d726be038ef..dede7813def 100644 --- a/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php +++ b/tests/GraphQL/DefaultSchemaCollectionTermQueriesTest.php @@ -9,6 +9,8 @@ use Statamic\Facades\Taxonomy; use Statamic\GraphQL\DefaultSchema; use Statamic\GraphQL\Queries\SpecificEntriesQuery; +use Statamic\GraphQL\Queries\SpecificEntryQuery; +use Statamic\GraphQL\Queries\SpecificTermQuery; use Statamic\GraphQL\Queries\SpecificTermsQuery; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -45,7 +47,7 @@ public function it_registers_no_collection_queries_when_config_is_empty() $queries = $this->getQueryInstances(); $this->assertEmpty( - collect($queries)->filter(fn ($q) => $q instanceof SpecificEntriesQuery)->all() + collect($queries)->filter(fn ($q) => $q instanceof SpecificEntriesQuery || $q instanceof SpecificEntryQuery)->all() ); } @@ -62,11 +64,11 @@ public function it_registers_collection_queries_for_explicit_handles() $queries = $this->getQueryInstances(); $collectionQueries = collect($queries) - ->filter(fn ($q) => $q instanceof SpecificEntriesQuery) + ->filter(fn ($q) => $q instanceof SpecificEntriesQuery || $q instanceof SpecificEntryQuery) ->values(); - $this->assertCount(1, $collectionQueries); - $this->assertEquals('blog', $this->getQueryName($collectionQueries[0])); + $this->assertCount(2, $collectionQueries); + $this->assertEquals(['blog', 'blogEntry'], $collectionQueries->map(fn ($q) => $this->getQueryName($q))->sort()->values()->all()); } #[Test] @@ -82,13 +84,13 @@ public function it_registers_collection_queries_for_all_when_wildcard_is_used() $queries = $this->getQueryInstances(); $collectionQueries = collect($queries) - ->filter(fn ($q) => $q instanceof SpecificEntriesQuery) + ->filter(fn ($q) => $q instanceof SpecificEntriesQuery || $q instanceof SpecificEntryQuery) ->values(); $names = $collectionQueries->map(fn ($q) => $this->getQueryName($q))->sort()->values()->all(); - $this->assertCount(2, $collectionQueries); - $this->assertEquals(['blog', 'pages'], $names); + $this->assertCount(4, $collectionQueries); + $this->assertEquals(['blog', 'blogEntry', 'page', 'pages'], $names); } #[Test] @@ -103,7 +105,7 @@ public function it_does_not_register_collection_queries_when_resource_is_disable $queries = $this->getQueryInstances(); $this->assertEmpty( - collect($queries)->filter(fn ($q) => $q instanceof SpecificEntriesQuery)->all() + collect($queries)->filter(fn ($q) => $q instanceof SpecificEntriesQuery || $q instanceof SpecificEntryQuery)->all() ); } @@ -116,7 +118,7 @@ public function it_registers_no_taxonomy_queries_when_config_is_empty() $queries = $this->getQueryInstances(); $this->assertEmpty( - collect($queries)->filter(fn ($q) => $q instanceof SpecificTermsQuery)->all() + collect($queries)->filter(fn ($q) => $q instanceof SpecificTermsQuery || $q instanceof SpecificTermQuery)->all() ); } @@ -133,11 +135,11 @@ public function it_registers_taxonomy_queries_for_explicit_handles() $queries = $this->getQueryInstances(); $termQueries = collect($queries) - ->filter(fn ($q) => $q instanceof SpecificTermsQuery) + ->filter(fn ($q) => $q instanceof SpecificTermsQuery || $q instanceof SpecificTermQuery) ->values(); - $this->assertCount(1, $termQueries); - $this->assertEquals('tags', $this->getQueryName($termQueries[0])); + $this->assertCount(2, $termQueries); + $this->assertEquals(['tag', 'tags'], $termQueries->map(fn ($q) => $this->getQueryName($q))->sort()->values()->all()); } #[Test] @@ -153,13 +155,13 @@ public function it_registers_taxonomy_queries_for_all_when_wildcard_is_used() $queries = $this->getQueryInstances(); $termQueries = collect($queries) - ->filter(fn ($q) => $q instanceof SpecificTermsQuery) + ->filter(fn ($q) => $q instanceof SpecificTermsQuery || $q instanceof SpecificTermQuery) ->values(); $names = $termQueries->map(fn ($q) => $this->getQueryName($q))->sort()->values()->all(); - $this->assertCount(2, $termQueries); - $this->assertEquals(['categories', 'tags'], $names); + $this->assertCount(4, $termQueries); + $this->assertEquals(['categories', 'category', 'tag', 'tags'], $names); } #[Test] @@ -174,7 +176,7 @@ public function it_does_not_register_taxonomy_queries_when_resource_is_disabled( $queries = $this->getQueryInstances(); $this->assertEmpty( - collect($queries)->filter(fn ($q) => $q instanceof SpecificTermsQuery)->all() + collect($queries)->filter(fn ($q) => $q instanceof SpecificTermsQuery || $q instanceof SpecificTermQuery)->all() ); } diff --git a/tests/GraphQL/SpecificEntryQueryTest.php b/tests/GraphQL/SpecificEntryQueryTest.php new file mode 100644 index 00000000000..b77bf0f2386 --- /dev/null +++ b/tests/GraphQL/SpecificEntryQueryTest.php @@ -0,0 +1,93 @@ +save(); + $this->mockBlueprints('blog_posts', ['post']); + + $query = new SpecificEntryQuery('blog_posts'); + + $this->assertEquals('blogPost', $this->getQueryName($query)); + } + + #[Test] + public function it_uses_simple_handle_as_query_name() + { + Collection::make('pages')->save(); + $this->mockBlueprints('pages', ['page']); + + $query = new SpecificEntryQuery('pages'); + + $this->assertEquals('page', $this->getQueryName($query)); + } + + #[Test] + public function it_appends_entry_suffix_when_singular_name_matches_plural_query_name() + { + Collection::make('blog')->save(); + $this->mockBlueprints('blog', ['post']); + + $query = new SpecificEntryQuery('blog'); + + $this->assertEquals('blogEntry', $this->getQueryName($query)); + } + + #[Test] + public function it_does_not_include_collection_arg() + { + Collection::make('pages')->save(); + $this->mockBlueprints('pages', ['page']); + + $query = new SpecificEntryQuery('pages'); + $args = $query->args(); + + $this->assertArrayNotHasKey('collection', $args); + $this->assertArrayHasKey('id', $args); + $this->assertArrayHasKey('slug', $args); + $this->assertArrayHasKey('uri', $args); + $this->assertArrayHasKey('filter', $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/SpecificTermQueryTest.php b/tests/GraphQL/SpecificTermQueryTest.php new file mode 100644 index 00000000000..c08e0870e22 --- /dev/null +++ b/tests/GraphQL/SpecificTermQueryTest.php @@ -0,0 +1,89 @@ +save(); + $this->mockBlueprints('product_categories', ['category']); + + $query = new SpecificTermQuery('product_categories'); + + $this->assertEquals('productCategory', $this->getQueryName($query)); + } + + #[Test] + public function it_uses_simple_handle_as_query_name() + { + Taxonomy::make('tags')->save(); + $this->mockBlueprints('tags', ['tag']); + + $query = new SpecificTermQuery('tags'); + + $this->assertEquals('tag', $this->getQueryName($query)); + } + + #[Test] + public function it_appends_term_suffix_when_singular_name_matches_plural_query_name() + { + Taxonomy::make('news')->save(); + $this->mockBlueprints('news', ['item']); + + $query = new SpecificTermQuery('news'); + + $this->assertEquals('newsTerm', $this->getQueryName($query)); + } + + #[Test] + public function it_does_not_include_taxonomy_arg() + { + Taxonomy::make('tags')->save(); + $this->mockBlueprints('tags', ['tag']); + + $query = new SpecificTermQuery('tags'); + $args = $query->args(); + + $this->assertArrayNotHasKey('taxonomy', $args); + $this->assertArrayHasKey('id', $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 07d6532aa986413157586735ee563a40cf9885f5 Mon Sep 17 00:00:00 2001 From: Oguz Han Asnaz Date: Mon, 18 May 2026 15:29:55 +0200 Subject: [PATCH 09/10] feat: move type selection into the DynamicEntryUnionType class and also respect mounted collections --- src/Fieldtypes/Entries.php | 26 +------ src/Fieldtypes/Terms.php | 30 +------- src/GraphQL/Queries/SpecificEntriesQuery.php | 17 +---- src/GraphQL/Queries/SpecificEntryQuery.php | 20 +---- src/GraphQL/Queries/SpecificTermQuery.php | 18 +---- src/GraphQL/Queries/SpecificTermsQuery.php | 19 +---- src/GraphQL/Types/DynamicEntryUnionType.php | 73 ++++++++++++++++++- src/GraphQL/Types/DynamicTermUnionType.php | 58 ++++++++++++++- tests/GraphQL/DynamicEntryUnionTypeTest.php | 56 ++++++++++++++ tests/GraphQL/EntriesFieldtypeGqlTypeTest.php | 13 ++-- tests/GraphQL/TermsFieldtypeGqlTypeTest.php | 15 ++-- 11 files changed, 201 insertions(+), 144 deletions(-) create mode 100644 tests/GraphQL/DynamicEntryUnionTypeTest.php diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php index 67d2d66b825..217ce61b1c5 100644 --- a/src/Fieldtypes/Entries.php +++ b/src/Fieldtypes/Entries.php @@ -19,7 +19,6 @@ 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; @@ -480,30 +479,7 @@ public function toGqlType() 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); - } + $type = DynamicEntryUnionType::createTypeFor($this->getConfiguredCollections()); if ($this->config('max_items') !== 1) { $type = GraphQL::listOf(GraphQL::nonNull($type)); diff --git a/src/Fieldtypes/Terms.php b/src/Fieldtypes/Terms.php index a0c99307b2c..6258087cf71 100644 --- a/src/Fieldtypes/Terms.php +++ b/src/Fieldtypes/Terms.php @@ -21,7 +21,6 @@ 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; @@ -552,34 +551,7 @@ public function toGqlType() 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); - } + $type = DynamicTermUnionType::createTypeFor($this->getConfiguredTaxonomies()); if ($this->config('max_items') !== 1) { $type = GraphQL::listOf(GraphQL::nonNull($type)); diff --git a/src/GraphQL/Queries/SpecificEntriesQuery.php b/src/GraphQL/Queries/SpecificEntriesQuery.php index 3ede9df49ef..3746111facf 100644 --- a/src/GraphQL/Queries/SpecificEntriesQuery.php +++ b/src/GraphQL/Queries/SpecificEntriesQuery.php @@ -14,7 +14,6 @@ use Statamic\GraphQL\Queries\Concerns\FiltersQuery; use Statamic\GraphQL\Queries\Concerns\ScopesQuery; use Statamic\GraphQL\Types\DynamicEntryUnionType; -use Statamic\GraphQL\Types\EntryType; use Statamic\GraphQL\Types\JsonArgument; use Statamic\Query\OrderBy; use Statamic\Support\Str; @@ -43,22 +42,8 @@ public function __construct(protected string $collectionHandle) 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::nonNull(GraphQL::paginate($type)); + return GraphQL::nonNull(GraphQL::paginate(DynamicEntryUnionType::createTypeFor($collection))); } public function args(): array diff --git a/src/GraphQL/Queries/SpecificEntryQuery.php b/src/GraphQL/Queries/SpecificEntryQuery.php index 345daed5283..2998048fc9d 100644 --- a/src/GraphQL/Queries/SpecificEntryQuery.php +++ b/src/GraphQL/Queries/SpecificEntryQuery.php @@ -10,7 +10,6 @@ use Statamic\Facades\GraphQL; use Statamic\GraphQL\Queries\Concerns\FiltersQuery; use Statamic\GraphQL\Types\DynamicEntryUnionType; -use Statamic\GraphQL\Types\EntryType; use Statamic\GraphQL\Types\JsonArgument; use Statamic\Support\Str; @@ -35,22 +34,7 @@ public function __construct(protected string $collectionHandle) 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) { - return GraphQL::type(EntryType::buildName($combinations[0]['collection'], $combinations[0]['blueprint'])); - } - - $unionType = new DynamicEntryUnionType($combinations); - GraphQL::addType($unionType); - - return GraphQL::type($unionType->name); + return DynamicEntryUnionType::createTypeFor(Collection::findByHandle($this->collectionHandle)); } public function args(): array @@ -68,8 +52,6 @@ public function resolve($root, $args) { $query = Entry::query(); - $query->where('collection', $this->collectionHandle); - if ($id = $args['id'] ?? null) { $query->where('id', $id); } diff --git a/src/GraphQL/Queries/SpecificTermQuery.php b/src/GraphQL/Queries/SpecificTermQuery.php index 4b098f96a1a..13a6dd97c0b 100644 --- a/src/GraphQL/Queries/SpecificTermQuery.php +++ b/src/GraphQL/Queries/SpecificTermQuery.php @@ -9,7 +9,6 @@ use Statamic\Facades\Taxonomy; use Statamic\Facades\Term; use Statamic\GraphQL\Types\DynamicTermUnionType; -use Statamic\GraphQL\Types\TermType; use Statamic\Support\Str; class SpecificTermQuery extends Query @@ -29,22 +28,7 @@ public function __construct(protected string $taxonomyHandle) 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) { - return GraphQL::type(TermType::buildName($combinations[0]['taxonomy'], $combinations[0]['blueprint'])); - } - - $unionType = new DynamicTermUnionType($combinations); - GraphQL::addType($unionType); - - return GraphQL::type($unionType->name); + return DynamicTermUnionType::createTypeFor(Taxonomy::findByHandle($this->taxonomyHandle)); } public function args(): array diff --git a/src/GraphQL/Queries/SpecificTermsQuery.php b/src/GraphQL/Queries/SpecificTermsQuery.php index 58542434b25..88273e7ebbe 100644 --- a/src/GraphQL/Queries/SpecificTermsQuery.php +++ b/src/GraphQL/Queries/SpecificTermsQuery.php @@ -12,7 +12,6 @@ use Statamic\GraphQL\Queries\Concerns\FiltersQuery; use Statamic\GraphQL\Types\DynamicTermUnionType; use Statamic\GraphQL\Types\JsonArgument; -use Statamic\GraphQL\Types\TermType; use Statamic\Query\OrderBy; use Statamic\Support\Str; @@ -34,23 +33,7 @@ public function __construct(protected string $taxonomyHandle) 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::nonNull(GraphQL::paginate($type)); + return GraphQL::nonNull(GraphQL::paginate(DynamicTermUnionType::createTypeFor(Taxonomy::findByHandle($this->taxonomyHandle)))); } public function args(): array diff --git a/src/GraphQL/Types/DynamicEntryUnionType.php b/src/GraphQL/Types/DynamicEntryUnionType.php index ac64138b774..01f327cb8b2 100644 --- a/src/GraphQL/Types/DynamicEntryUnionType.php +++ b/src/GraphQL/Types/DynamicEntryUnionType.php @@ -4,8 +4,12 @@ namespace Statamic\GraphQL\Types; -use Rebing\GraphQL\Support\Facades\GraphQL; +use GraphQL\Type\Definition\Type; +use Illuminate\Support\Collection as IlluminateCollection; use Rebing\GraphQL\Support\UnionType; +use Statamic\Contracts\Entries\Collection; +use Statamic\Facades\Collection as CollectionFacade; +use Statamic\Facades\GraphQL; class DynamicEntryUnionType extends UnionType { @@ -27,6 +31,22 @@ public static function getTypeName(array $types): string return 'DynamicEntryUnionType_'.implode('_', $typeNames); } + public static function createTypeFor(Collection|array $collections): Type + { + $combinations = is_array($collections) + ? static::combinationsForHandles($collections) + : static::combinationsFor($collections); + + if (count($combinations) === 1) { + return GraphQL::type(EntryType::buildName($combinations[0]['collection'], $combinations[0]['blueprint'])); + } + + $unionType = new static($combinations); + GraphQL::addType($unionType); + + return GraphQL::type($unionType->name); + } + public function types(): array { return array_map(function ($type) { @@ -38,4 +58,55 @@ public function resolveType($value) { return GraphQL::type(EntryType::buildName($value->collection(), $value->blueprint())); } + + protected static function combinationsFor(Collection $collection): array + { + return static::uniqueCombinations( + static::collectionsFor($collection) + ->flatMap(fn (Collection $collection) => $collection->entryBlueprints()->map(fn ($blueprint) => [ + 'collection' => $collection, + 'blueprint' => $blueprint, + ])) + ->all() + ); + } + + protected static function combinationsForHandles(array $handles): array + { + return static::uniqueCombinations( + collect($handles) + ->flatMap(function ($handle) { + $collection = CollectionFacade::find($handle); + + if (! $collection) { + return []; + } + + return static::combinationsFor($collection); + }) + ->all() + ); + } + + protected static function uniqueCombinations(array $combinations): array + { + return collect($combinations) + ->unique(fn (array $combination) => EntryType::buildName($combination['collection'], $combination['blueprint'])) + ->values() + ->all(); + } + + protected static function collectionsFor(Collection $collection): IlluminateCollection + { + return collect([$collection])->merge(static::mountedCollections($collection)); + } + + protected static function mountedCollections(Collection $collection): IlluminateCollection + { + return CollectionFacade::all()->filter(function (Collection $mounted) use ($collection) { + $mount = $mounted->mount(); + + return $mount && $mount->collectionHandle() === $collection->handle(); + }); + } } diff --git a/src/GraphQL/Types/DynamicTermUnionType.php b/src/GraphQL/Types/DynamicTermUnionType.php index f40413545b9..75962404e6c 100644 --- a/src/GraphQL/Types/DynamicTermUnionType.php +++ b/src/GraphQL/Types/DynamicTermUnionType.php @@ -4,8 +4,11 @@ namespace Statamic\GraphQL\Types; -use Rebing\GraphQL\Support\Facades\GraphQL; +use GraphQL\Type\Definition\Type; use Rebing\GraphQL\Support\UnionType; +use Statamic\Contracts\Taxonomies\Taxonomy; +use Statamic\Facades\GraphQL; +use Statamic\Facades\Taxonomy as TaxonomyFacade; class DynamicTermUnionType extends UnionType { @@ -27,6 +30,22 @@ public static function getTypeName(array $types): string return 'DynamicTermUnion_'.implode('_', $typeNames); } + public static function createTypeFor(Taxonomy|array $taxonomies): Type + { + $combinations = is_array($taxonomies) + ? static::combinationsForHandles($taxonomies) + : static::combinationsFor($taxonomies); + + if (count($combinations) === 1) { + return GraphQL::type(TermType::buildName($combinations[0]['taxonomy'], $combinations[0]['blueprint'])); + } + + $unionType = new static($combinations); + GraphQL::addType($unionType); + + return GraphQL::type($unionType->name); + } + public function types(): array { return array_map(function ($type) { @@ -38,4 +57,41 @@ public function resolveType($value) { return GraphQL::type(TermType::buildName($value->term()->taxonomy(), $value->term()->blueprint())); } + + protected static function combinationsFor(Taxonomy $taxonomy): array + { + return static::uniqueCombinations( + $taxonomy->termBlueprints() + ->map(fn ($blueprint) => [ + 'taxonomy' => $taxonomy, + 'blueprint' => $blueprint, + ]) + ->all() + ); + } + + protected static function combinationsForHandles(array $handles): array + { + return static::uniqueCombinations( + collect($handles) + ->flatMap(function ($handle) { + $taxonomy = TaxonomyFacade::find($handle); + + if (! $taxonomy) { + return []; + } + + return static::combinationsFor($taxonomy); + }) + ->all() + ); + } + + protected static function uniqueCombinations(array $combinations): array + { + return collect($combinations) + ->unique(fn (array $combination) => TermType::buildName($combination['taxonomy'], $combination['blueprint'])) + ->values() + ->all(); + } } diff --git a/tests/GraphQL/DynamicEntryUnionTypeTest.php b/tests/GraphQL/DynamicEntryUnionTypeTest.php new file mode 100644 index 00000000000..30eb3ec07a5 --- /dev/null +++ b/tests/GraphQL/DynamicEntryUnionTypeTest.php @@ -0,0 +1,56 @@ +save(); + $mount = EntryFactory::collection('pages')->slug('blog')->create(); + + Collection::make('blog')->mount($mount->id())->save(); + + $page = tap($this->partialMock(\Statamic\Fields\Blueprint::class), function ($m) { + $m->shouldReceive('handle')->andReturn('page'); + }); + $article = tap($this->partialMock(\Statamic\Fields\Blueprint::class), function ($m) { + $m->shouldReceive('handle')->andReturn('article'); + }); + + BlueprintRepository::shouldReceive('in') + ->with('collections/pages') + ->andReturn(collect(['page' => $page])); + BlueprintRepository::shouldReceive('in') + ->with('collections/blog') + ->andReturn(collect(['article' => $article])); + + $pages = Collection::findByHandle('pages'); + $blog = Collection::findByHandle('blog'); + + EntryInterface::addTypes(); + + $expectedName = DynamicEntryUnionType::getTypeName([ + ['collection' => $pages, 'blueprint' => $page], + ['collection' => $blog, 'blueprint' => $article], + ]); + + $type = DynamicEntryUnionType::createTypeFor($pages); + + $this->assertEquals($expectedName, $type->name); + } +} diff --git a/tests/GraphQL/EntriesFieldtypeGqlTypeTest.php b/tests/GraphQL/EntriesFieldtypeGqlTypeTest.php index 1554db319d3..2ac5c04cff4 100644 --- a/tests/GraphQL/EntriesFieldtypeGqlTypeTest.php +++ b/tests/GraphQL/EntriesFieldtypeGqlTypeTest.php @@ -49,19 +49,16 @@ public function it_uses_a_concrete_entry_type_when_a_single_blueprint_is_targete ->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]); + EntryInterface::addTypes(); - GraphQL::shouldReceive('addType')->never(); + $expected = EntryType::buildName(Collection::findByHandle('blog_posts'), $article); - $this->fieldtype([ + $type = $this->fieldtype([ 'collections' => ['blog_posts'], 'max_items' => 1, ])->toGqlType(); + + $this->assertEquals($expected, $type->name); } #[Test] diff --git a/tests/GraphQL/TermsFieldtypeGqlTypeTest.php b/tests/GraphQL/TermsFieldtypeGqlTypeTest.php index 52dff707fe9..1dd938c9910 100644 --- a/tests/GraphQL/TermsFieldtypeGqlTypeTest.php +++ b/tests/GraphQL/TermsFieldtypeGqlTypeTest.php @@ -52,20 +52,16 @@ public function it_uses_a_concrete_term_type_when_a_single_blueprint_is_possible ->with('taxonomies/tags') ->andReturn(collect(['tag' => $tag])); - $expected = TermType::buildName($taxonomy, $tag); - - GraphQL::shouldReceive('type') - ->once() - ->with($expected) - ->andReturn((object) ['name' => $expected]); + TermInterface::addTypes(); - GraphQL::shouldReceive('addType')->never(); - GraphQL::shouldReceive('listOf')->never(); + $expected = TermType::buildName($taxonomy, $tag); - $this->fieldtype([ + $type = $this->fieldtype([ 'taxonomies' => ['tags'], 'max_items' => 1, ])->toGqlType(); + + $this->assertEquals($expected, $type->name); } #[Test] @@ -111,4 +107,3 @@ private function fieldtype(array $config = []): Terms return (new Terms)->setField($field); } } - From 3d6aeffcf007ef50b9d95c868d400229ba3c90bf Mon Sep 17 00:00:00 2001 From: Oguz Han Asnaz Date: Mon, 18 May 2026 16:06:28 +0200 Subject: [PATCH 10/10] feat: fallback to the usual interfaces if no valid configurations are found --- src/GraphQL/Types/DynamicEntryUnionType.php | 4 ++++ src/GraphQL/Types/DynamicTermUnionType.php | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/GraphQL/Types/DynamicEntryUnionType.php b/src/GraphQL/Types/DynamicEntryUnionType.php index 01f327cb8b2..f37764a8af1 100644 --- a/src/GraphQL/Types/DynamicEntryUnionType.php +++ b/src/GraphQL/Types/DynamicEntryUnionType.php @@ -37,6 +37,10 @@ public static function createTypeFor(Collection|array $collections): Type ? static::combinationsForHandles($collections) : static::combinationsFor($collections); + if (count($combinations) === 0) { + return GraphQL::type(EntryInterface::NAME); + } + if (count($combinations) === 1) { return GraphQL::type(EntryType::buildName($combinations[0]['collection'], $combinations[0]['blueprint'])); } diff --git a/src/GraphQL/Types/DynamicTermUnionType.php b/src/GraphQL/Types/DynamicTermUnionType.php index 75962404e6c..6e97ce95586 100644 --- a/src/GraphQL/Types/DynamicTermUnionType.php +++ b/src/GraphQL/Types/DynamicTermUnionType.php @@ -36,6 +36,10 @@ public static function createTypeFor(Taxonomy|array $taxonomies): Type ? static::combinationsForHandles($taxonomies) : static::combinationsFor($taxonomies); + if (count($combinations) === 0) { + return GraphQL::type(TermInterface::NAME); + } + if (count($combinations) === 1) { return GraphQL::type(TermType::buildName($combinations[0]['taxonomy'], $combinations[0]['blueprint'])); }